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:
Felix Rieseberg
2026-01-23 11:37:07 -08:00
committed by Charles Kerr
parent 6df6ec5f09
commit 0348df5881
3 changed files with 95 additions and 3 deletions

View File

@@ -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])`

View File

@@ -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);

View File

@@ -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()', () => {