Skip to content

Commit afdd2a5

Browse files
matmenclaude
andcommitted
ICC: don't apply gray profiles to color images
Like libpng, which rejects them: a gray profile embedded in a color image (Pillow keeps the profile when converting gray to RGB) set all channels to the transformed red one. It's applied only when the pixels are gray. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 7016bcc commit afdd2a5

5 files changed

Lines changed: 10 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
(ICCP, also animated), AVIFs (colr prof/rICC, which then describe the RGB values instead of the nclx box) and TIFFs.
2727
Matrix/TRC profiles (RGB and gray; gamma, table and parametric curves) are applied relative colorimetric, colors
2828
outside sRGB clipped: Display P3 photos from phones, Adobe RGB and ProPhoto images no longer look washed out. The
29-
results are within 1 of LittleCMS's. Profiles with only lookup tables, CMYK profiles and ones that are sRGB (like
30-
most cameras embed) leave the pixels as they are; converting a 1920x1080 image takes about 35 ms.
29+
results are within 1 of LittleCMS's. Profiles with only lookup tables, CMYK profiles, gray profiles of color images (like libpng,
30+
which rejects them) and ones that are sRGB (like most cameras embed) leave the pixels as they are; converting a 1920x1080 image takes about 35 ms.
3131
- `font-size` resolves `em`, `ex`, percentages and keywords against the parent's size for all elements (lengths in `em`
3232
used the default 16 pixels when an ancestor's size was relative), and `font-weight` `bolder` and `lighter`.
3333
- `Image.renderSVG()` blends elements with `mix-blend-mode` (all 16 modes and `plus-lighter`, in CSS) and isolates

‎scripts/icc-fixtures.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# ImageCms) converts them to, relative colorimetric to sRGB, as PNGs: from the source pixels for the lossless formats,
44
# from what Pillow decodes for JPEG and AVIF. The profiles are matrix/TRC ones written here (Display P3 with a
55
# parametric curve, Adobe RGB with a gamma, P3 with a table curve, Rec. 2020 with a type 4 parametric curve, gray), and
6-
# one that is sRGB (its pixels stay as they are). Needs Pillow (with AVIF) and numpy:
6+
# one that is sRGB (its pixels stay as they are), and a gray one on a color image (not applied). Needs Pillow (with AVIF) and numpy:
77
# python scripts/icc-fixtures.py
88
import io, struct
99
from pathlib import Path
@@ -127,3 +127,5 @@ def convert(im, icc):
127127
gray = source.convert('L')
128128
gray.save(out / 'gray.png', icc_profile=gray_profile)
129129
convert(gray, gray_profile).save(out / 'gray.ref.png')
130+
# a gray profile on a color image (invalid, like libpng says: not applied)
131+
source.save(out / 'gray-on-rgb.png', icc_profile=gray_profile)

‎src/util/icc.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,10 @@ export function profileTransform(icc: Uint8Array): ((rgba: Uint8Array | Uint8Cla
135135

136136
if (p.gray) {
137137
// (the gray's luminance in all three channels: the profile's white is sRGB's)
138+
// (only for gray images: like libpng, a gray profile of a color image isn't applied)
138139
const k = linear[0], lut = Uint8Array.from({ length: 256 }, (_, i) => out(k[i]));
139140
return rgba => {
141+
for (let i = 0; i < rgba.length; i += 4) if (rgba[i] !== rgba[i + 1] || rgba[i] !== rgba[i + 2]) return;
140142
for (let i = 0; i < rgba.length; i += 4) rgba[i] = rgba[i + 1] = rgba[i + 2] = lut[rgba[i]];
141143
};
142144
}

‎tests/icc.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const difference = (a, b) => {
3232
const raw = await Image.decode(data, {applyColorProfile: false});
3333
if (file !== 'srgb.png' && difference(raw, ref) <= tolerance) panic(`${file}: applyColorProfile: false should keep the stored colors`);
3434
}
35+
// (a gray profile of a color image isn't applied)
36+
const color = await Image.decode(await fs.readFile('./tests/icc/gray-on-rgb.png'));
37+
if (difference(color, await Image.decode(await fs.readFile('./tests/icc/gray-on-rgb.png'), {applyColorProfile: false})) !== 0) panic('a gray profile of a color image should be ignored');
3538
// (an sRGB profile leaves the pixels as they are)
3639
const srgb = await fs.readFile('./tests/icc/srgb.png');
3740
if (difference(await Image.decode(srgb), await Image.decode(srgb, {applyColorProfile: false})) !== 0) panic('an sRGB profile should leave the pixels as they are');

‎tests/icc/gray-on-rgb.png‎

8.94 KB
Loading

0 commit comments

Comments
 (0)