mirror of
https://github.com/electron/electron.git
synced 2026-04-10 03:01:51 -04:00
fix: normalize color space for consistent pixel values in nativeImage
toBitmap() now normalizes the color space to sRGB by default, ensuring that visually identical images produce consistent pixel values regardless of their original color profile (e.g., Display P3 on macOS). An optional colorSpace parameter is added to toBitmap() to control the target color space: 'srgb' (default), 'display-p3', or 'source' (no conversion). Closes #46949
This commit is contained in:
committed by
Charles Kerr
parent
6df6ec5f09
commit
0348df5881
@@ -267,9 +267,13 @@ Returns `Buffer` - A [Buffer][buffer] that contains the image's `JPEG` encoded d
|
||||
|
||||
* `options` Object (optional)
|
||||
* `scaleFactor` Number (optional) - Defaults to 1.0.
|
||||
* `colorSpace` string (optional) - The target color space for the bitmap data.
|
||||
Can be `srgb`, `display-p3`, or `source`. Defaults to `srgb`.
|
||||
|
||||
Returns `Buffer` - A [Buffer][buffer] that contains a copy of the image's raw bitmap pixel
|
||||
data.
|
||||
data in BGRA format with premultiplied alpha values. The color space is normalized to sRGB
|
||||
by default to ensure consistent pixel values across different source images, regardless of
|
||||
their original color space (e.g., Display P3 on macOS).
|
||||
|
||||
#### `image.toDataURL([options])`
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "shell/common/skia_util.h"
|
||||
#include "shell/common/thread_restrictions.h"
|
||||
#include "third_party/skia/include/core/SkBitmap.h"
|
||||
#include "third_party/skia/include/core/SkColorSpace.h"
|
||||
#include "third_party/skia/include/core/SkImageInfo.h"
|
||||
#include "third_party/skia/include/core/SkPixelRef.h"
|
||||
#include "ui/base/layout.h"
|
||||
@@ -254,10 +255,28 @@ v8::Local<v8::Value> NativeImage::ToPNG(gin::Arguments* args) {
|
||||
v8::Local<v8::Value> NativeImage::ToBitmap(gin::Arguments* args) {
|
||||
v8::Isolate* const isolate = args->isolate();
|
||||
|
||||
const float scale = GetScaleFactorFromOptions(args);
|
||||
float scale = 1.0f;
|
||||
std::string color_space_name = "srgb";
|
||||
gin_helper::Dictionary options;
|
||||
if (args->GetNext(&options)) {
|
||||
options.Get("scaleFactor", &scale);
|
||||
options.Get("colorSpace", &color_space_name);
|
||||
}
|
||||
|
||||
const auto src = image_.AsImageSkia().GetRepresentation(scale).GetBitmap();
|
||||
|
||||
const auto dst_info = SkImageInfo::MakeN32Premul(src.dimensions());
|
||||
sk_sp<SkColorSpace> dst_color_space;
|
||||
if (color_space_name == "display-p3") {
|
||||
dst_color_space =
|
||||
SkColorSpace::MakeRGB(SkNamedTransferFn::kSRGB, SkNamedGamut::kDisplayP3);
|
||||
} else if (color_space_name == "source") {
|
||||
dst_color_space = src.refColorSpace();
|
||||
} else {
|
||||
dst_color_space = SkColorSpace::MakeSRGB();
|
||||
}
|
||||
|
||||
const auto dst_info =
|
||||
SkImageInfo::MakeN32Premul(src.dimensions(), std::move(dst_color_space));
|
||||
const size_t dst_n_bytes = dst_info.computeMinByteSize();
|
||||
auto dst_buf = v8::ArrayBuffer::New(isolate, dst_n_bytes);
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@ describe('nativeImage module', () => {
|
||||
height: 3,
|
||||
width: 3
|
||||
};
|
||||
// 128x128 gray PNG without an embedded color profile (defaults to sRGB).
|
||||
// Generated with: convert -size 128x128 xc:'rgb(128,128,128)' -strip gray_srgb.png
|
||||
const image128x128ColorSpace1 = {
|
||||
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAAAXNSR0IArs4c6QAAATBJREFUeJzt0TENACAAwDBACnowi0Nk9GBVsGRz3zPiLB3wuwZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAOwB3+ACH2yDfGoAAAAASUVORK5CYII='
|
||||
};
|
||||
// 128x128 gray PNG with an embedded Display P3 color profile.
|
||||
// Generated with: convert -size 128x128 xc:'rgb(128,128,128)' -profile DisplayP3.icc gray_p3.png
|
||||
const image128x128ColorSpace2 = {
|
||||
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAABK2lDQ1BTa2lhAAAokX2QMUvDUBSFv1cKomYRFR0cMnbRppWmDdahqbXo2Cqk3dI0FLFNQxrRvas/wtlNcBGhs4uT4CTi4i4IrpXXDClIPNPHuQfuPRdSmwBpDQZeGDTqpmq12urCBwLBTLYz8kmWgJ/XKPuy/U8uSYtdd+QAX0AYWK02iC6w1ov4SnIn4mvJl6EfgriRHJw0qiDugUxvjjtz7PiBzL8B5UH/wonvRnG90yZgAVvUGTKkRx+XLE3OOcMmi0YNgxK71KhQoUCFHHlKGOgU0KhiUqRKkUN0SuTJcTBjA13+M1o5fof9yXQ6fYy94wnc6bD0EHuZPVhR4Ok59uIf+3Zgz6w0kHJN+F4H5RZWP2F5DGzIcUJX9U9XlSM8HHZQyaORQ/8FDJRN2vTWQQEAAAEvSURBVHic7dFBCQAgAMBAtaNgSysa4x7uEgw29z0jztIBv2sA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAewL8CXIWdMtwAAAAASUVORK5CYII='
|
||||
};
|
||||
|
||||
const dataUrlImages = [
|
||||
image1x1,
|
||||
@@ -422,6 +432,65 @@ describe('nativeImage module', () => {
|
||||
const crop = image.crop({ width: 25, height: 64, x: 0, y: 0 });
|
||||
expect(crop.toBitmap().length).to.equal(25 * 64 * 4);
|
||||
});
|
||||
|
||||
it('toBitmap() normalizes color space for consistent pixel values', () => {
|
||||
// These two images are visually identical 128x128 gray squares but have
|
||||
// different embedded color profiles (sRGB vs Display P3).
|
||||
const img1 = nativeImage.createFromDataURL(image128x128ColorSpace1.dataUrl);
|
||||
const img2 = nativeImage.createFromDataURL(image128x128ColorSpace2.dataUrl);
|
||||
const bitmap1 = img1.toBitmap();
|
||||
const bitmap2 = img2.toBitmap();
|
||||
|
||||
expect(img1.getSize()).to.deep.equal(img2.getSize());
|
||||
|
||||
const size = img1.getSize();
|
||||
const pixelCount = size.width * size.height;
|
||||
|
||||
let maxDifference = 0;
|
||||
let totalDifference = 0;
|
||||
|
||||
for (let i = 0; i < bitmap1.length; i += 4) {
|
||||
const diff = Math.max(
|
||||
Math.abs(bitmap1[i] - bitmap2[i]),
|
||||
Math.abs(bitmap1[i + 1] - bitmap2[i + 1]),
|
||||
Math.abs(bitmap1[i + 2] - bitmap2[i + 2])
|
||||
);
|
||||
maxDifference = Math.max(maxDifference, diff);
|
||||
totalDifference += diff;
|
||||
// Alpha channels should always match
|
||||
expect(bitmap1[i + 3]).to.equal(bitmap2[i + 3]);
|
||||
}
|
||||
|
||||
const avgDifference = totalDifference / (pixelCount * 3);
|
||||
|
||||
// After color space normalization to sRGB, pixel values should be very similar
|
||||
expect(maxDifference).to.be.at.most(3,
|
||||
'Maximum pixel difference should be ≤3 after color space normalization');
|
||||
expect(avgDifference).to.be.below(1,
|
||||
'Average pixel difference should be <1 per channel');
|
||||
|
||||
// toBitmap should be deterministic
|
||||
expect(bitmap1.equals(img1.toBitmap())).to.be.true;
|
||||
expect(bitmap2.equals(img2.toBitmap())).to.be.true;
|
||||
});
|
||||
|
||||
it('toBitmap() accepts a colorSpace option', () => {
|
||||
const img = nativeImage.createFromDataURL(image128x128ColorSpace2.dataUrl);
|
||||
|
||||
const srgbBitmap = img.toBitmap({ colorSpace: 'srgb' });
|
||||
const sourceBitmap = img.toBitmap({ colorSpace: 'source' });
|
||||
const p3Bitmap = img.toBitmap({ colorSpace: 'display-p3' });
|
||||
|
||||
// All should produce valid bitmaps of the same size
|
||||
const expectedSize = 128 * 128 * 4;
|
||||
expect(srgbBitmap.length).to.equal(expectedSize);
|
||||
expect(sourceBitmap.length).to.equal(expectedSize);
|
||||
expect(p3Bitmap.length).to.equal(expectedSize);
|
||||
|
||||
// Default (no option) should match explicit srgb
|
||||
const defaultBitmap = img.toBitmap();
|
||||
expect(defaultBitmap.equals(srgbBitmap)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAspectRatio()', () => {
|
||||
|
||||
Reference in New Issue
Block a user