Skip to content

Commit 9f1a011

Browse files
matmenclaude
andcommitted
AVIF: convert nclx color primaries to sRGB
SDR AVIFs with other primaries than sRGB's in their nclx box (or sequence header) were shown as their stored values: Display P3 or BT.2020 photos washed out or oversaturated. Without an ICC profile, decoding now converts them like an ICC profile: icc.ts makes a matrix/TRC profile of the CICP primaries (H.273's chromaticities, Bradford adapted to D50) and transfer (gamma 2.2, 2.8, linear; the BT.709 family and others as sRGB's curve; PQ and HLG stay with the HDR path), applied relative colorimetric like profiles (applyColorProfile false keeps the stored values). Lossless fixtures by avifenc --cicp (12/13/0, 9/1/0) are within 1 of LittleCMS with the same primaries. Of 368 AVIFs, the 11 this changes are P3 (12/13), BT.2020 linear SDR (9/8) and BT.709 with gamma 2.2 (1/4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GAqbnFSNaYDBGD1Km2
1 parent fb000bb commit 9f1a011

9 files changed

Lines changed: 91 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 3.12.0
4+
5+
### Changes
6+
7+
- AVIFs without an ICC profile whose nclx box (or sequence header) gives other color primaries than sRGB's (Display
8+
P3, BT.2020, DCI-P3, ...) or a gamma 2.2, 2.8 or linear transfer are converted to sRGB when decoding, like browsers
9+
show them (`applyColorProfile: false` keeps the stored values): as a matrix/TRC profile of those primaries,
10+
relative colorimetric, within 1 of LittleCMS. Transfers of the BT.709 family are taken as sRGB's curve, so most
11+
AVIFs (BT.709 or unspecified primaries) stay the same.
12+
313
## 3.11.0
414

515
### Fixes

‎scripts/icc-fixtures.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
# parametric curve, Adobe RGB with a gamma, P3 with a table curve, Rec. 2020 with a type 4 parametric curve, gray), and
66
# one that is sRGB (its pixels stay as they are), and a gray one on a color image (not applied). Also JPEG XLs by
77
# cjxl: lossless ones (their pixels are in the profile's space) and a lossy one (XYB, decoded to sRGB: its profile is
8-
# only what the image was before). Needs Pillow (with AVIF), numpy and cjxl (libjxl):
9-
# CJXL=path/to/cjxl python scripts/icc-fixtures.py
8+
# only what the image was before). And lossless AVIFs without a profile but other color primaries in their nclx box
9+
# (Display P3; BT.2020 with BT.709's transfer, taken as sRGB's curve) by avifenc, with LittleCMS's references of the
10+
# same primaries. Needs Pillow (with AVIF), numpy, cjxl (libjxl) and avifenc (libavif):
11+
# CJXL=path/to/cjxl AVIFENC=path/to/avifenc python scripts/icc-fixtures.py
1012
import io, os, struct, subprocess
1113
from pathlib import Path
1214
import numpy as np
@@ -136,3 +138,16 @@ def convert(im, icc):
136138
# profile, decoded to sRGB without it)
137139
for name, distance in [('display-p3', 0), ('gray', 0), ('rec2020', 1)]:
138140
subprocess.run([os.environ.get('CJXL', 'cjxl'), '-d', str(distance), str(out / f'{name}.png'), str(out / f'{name}{"-xyb" if distance else ""}.jxl')], check=True, capture_output=True)
141+
142+
# AVIFs with nclx primaries instead of a profile (lossless: their pixels are the source's)
143+
plain = source.copy()
144+
plain.putalpha(alpha)
145+
plain.save(out / 'nclx-source.png')
146+
bt2020 = rgb_profile([(0.708, 0.292), (0.170, 0.797), (0.131, 0.046)], SRGB_TRC, 'BT.2020 with sRGB curve')
147+
ref = convert(source, bt2020)
148+
ref.putalpha(alpha)
149+
ref.save(out / 'bt2020-nclx.ref.png')
150+
for name, cicp in [('display-p3-nclx', '12/13/0'), ('bt2020-nclx', '9/1/0')]:
151+
subprocess.run([os.environ.get('AVIFENC', 'avifenc'), '--lossless', '--cicp', cicp, str(out / 'nclx-source.png'), str(out / f'{name}.avif')], check=True, capture_output=True)
152+
(out / 'nclx-source.png').unlink()
153+

‎src/codecs/avif.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,7 @@ const MIN_THREADED_PIXELS = 4 << 20, PIXELS_PER_THREAD = 1 << 20;
568568
const TILES_PER_THREAD = 4;
569569

570570
/** Decodes an AVIF's primary item or first frame, the conversion to RGBA in up to threads worker threads */
571-
export async function decode(d: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneMapping = 'reinhard', threads = 1): Promise<{ width: number, height: number, pixels: Uint8Array, icc?: Uint8Array }> {
571+
export async function decode(d: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneMapping = 'reinhard', threads = 1): Promise<{ width: number, height: number, pixels: Uint8Array, icc?: Uint8Array, cicp?: [number, number] }> {
572572
if (!isAVIF(d)) throw new Error('AVIF: invalid header');
573573
let meta: Box | undefined, moov: Box | undefined;
574574
for (const box of boxes(d, 0, d.length)) {
@@ -608,7 +608,7 @@ export async function decode(d: Uint8Array, maxPixels = MAX_PIXELS, toneMapping:
608608
}
609609

610610
/** Decodes an image item (with its alpha item) and converts it to RGBA, in the threads if there are */
611-
async function decodeImage(d: Uint8Array, it: Item, items: Map<number, Item>, idat: Box | undefined, alphaItem: Item | undefined, alphaOf: (id: number) => Item | undefined, maxPixels: number, toneMapping: ToneMapping, threads: Thread[] | null): Promise<{ width: number, height: number, pixels: Uint8Array, icc?: Uint8Array }> {
611+
async function decodeImage(d: Uint8Array, it: Item, items: Map<number, Item>, idat: Box | undefined, alphaItem: Item | undefined, alphaOf: (id: number) => Item | undefined, maxPixels: number, toneMapping: ToneMapping, threads: Thread[] | null): Promise<{ width: number, height: number, pixels: Uint8Array, icc?: Uint8Array, cicp?: [number, number] }> {
612612
const color = await decodeItem(d, it, items, idat, maxPixels, threads);
613613
applyColr(d, it, color);
614614
// an ICC profile describes the RGB values (rather than the primaries and transfer of the nclx box or the
@@ -632,7 +632,8 @@ async function decodeImage(d: Uint8Array, it: Item, items: Map<number, Item>, id
632632
else if (box.type === 'irot') ({ width, height, pixels } = rotate(pixels, width, height, d[box.start] & 3));
633633
else if (box.type === 'imir') pixels = mirror(pixels, width, height, d[box.start] & 1);
634634
}
635-
return { width, height, pixels, icc };
635+
// (without a profile, the color primaries and transfer of the nclx box or the sequence header: see cicpTransform)
636+
return { width, height, pixels, icc, cicp: icc ? undefined : [color.primaries, color.transfer] };
636637
}
637638

638639
// the ICC profile of a colr box (prof or rICC)
@@ -701,7 +702,7 @@ function mirror(pixels: Uint8Array, width: number, height: number, axis: number)
701702
}
702703

703704
// the first frame of an image sequence (the color track, and the alpha track that refers to it)
704-
async function decodeSequence(d: Uint8Array, moov: Box, maxPixels: number, toneMapping: ToneMapping): Promise<{ width: number, height: number, pixels: Uint8Array }> {
705+
async function decodeSequence(d: Uint8Array, moov: Box, maxPixels: number, toneMapping: ToneMapping): Promise<{ width: number, height: number, pixels: Uint8Array, cicp: [number, number] }> {
705706
const view = new DataView(d.buffer, d.byteOffset, d.byteLength);
706707
const tracks: Array<{ id: number, handler: string, auxl: number[], sample: Uint8Array, config: Uint8Array | null }> = [];
707708
for (const trak of boxes(d, moov.start, moov.end)) {
@@ -744,5 +745,5 @@ async function decodeSequence(d: Uint8Array, moov: Box, maxPixels: number, toneM
744745
return fromFrame(decodeAV1(data, 0, data.length, { maxPixels }));
745746
};
746747
const color = frame(colorTrack);
747-
return { width: color.width, height: color.height, pixels: await toRgba(color, alphaTrack ? frame(alphaTrack) : null, toneMapping) };
748+
return { width: color.width, height: color.height, pixels: await toRgba(color, alphaTrack ? frame(alphaTrack) : null, toneMapping), cicp: [color.primaries, color.transfer] };
748749
}

‎src/image.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import * as quantize from './codecs/quantize.ts';
2424
import * as textRenderer from './render/text.ts';
2525
import * as svgRenderer from './render/svg.ts';
2626
import { BinaryData, view } from './util/mem.ts';
27-
import { profileTransform } from './util/icc.ts';
27+
import { cicpTransform, profileTransform } from './util/icc.ts';
2828
import { ImageType } from './image-type.ts';
2929
import { TextLayout } from './text-layout.ts';
3030
import { Metadata, resolveMetadata, pngText, exifData } from './metadata.ts';
@@ -199,11 +199,11 @@ export interface DecodeOptions {
199199
/** Whether to rotate/mirror JPEGs, TIFFs, PNGs and WebPs as their orientation tag (Exif or XMP) says. Default: `true` */
200200
applyOrientation?: boolean;
201201
/**
202-
* Whether to convert the colors of images with an embedded ICC profile (PNG, JPEG, WebP, AVIF, TIFF) to sRGB, like
203-
* browsers show them: wide gamut photos (Display P3 from phones, Adobe RGB, ProPhoto) look as intended instead of
204-
* washed out. Matrix/TRC profiles (RGB and gray) are applied, relative colorimetric (colors outside sRGB are
205-
* clipped); profiles with only lookup tables, CMYK ones and ones that are sRGB anyway leave the pixels as they are.
206-
* Default: `true`
202+
* Whether to convert the colors of images with an embedded ICC profile (PNG, JPEG, WebP, AVIF, JPEG XL, TIFF), and
203+
* of AVIFs with other color primaries than sRGB's (Display P3, BT.2020), to sRGB, like browsers show them: wide
204+
* gamut photos (Display P3 from phones, Adobe RGB, ProPhoto) look as intended instead of washed out. Matrix/TRC
205+
* profiles (RGB and gray) are applied, relative colorimetric (colors outside sRGB are clipped); profiles with only
206+
* lookup tables, CMYK ones and ones that are sRGB anyway leave the pixels as they are. Default: `true`
207207
*/
208208
applyColorProfile?: boolean;
209209
/**
@@ -2257,7 +2257,7 @@ export class Image {
22572257
const data = view(input);
22582258
const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength);
22592259

2260-
const decoded: { width: number, height: number, pixels: Uint8Array, orientation?: number, icc?: Uint8Array } = await decoding(() => {
2260+
const decoded: { width: number, height: number, pixels: Uint8Array, orientation?: number, icc?: Uint8Array, cicp?: [number, number] } = await decoding(() => {
22612261
if (ImageType.isPNG(dataView)) return png.decode(data, maxPixels);
22622262
if (ImageType.isJPEG(dataView)) return jpeg.decode(data, { maxPixels });
22632263
if (ImageType.isTIFF(dataView)) return tiff.decode(data, maxPixels);
@@ -2283,7 +2283,8 @@ export class Image {
22832283
throw new Error('Unsupported image type');
22842284
});
22852285

2286-
if (applyColorProfile && decoded.icc) profileTransform(decoded.icc)?.(decoded.pixels);
2286+
// (an AVIF without one: its color primaries and transfer)
2287+
if (applyColorProfile) (decoded.icc ? profileTransform(decoded.icc) : decoded.cicp ? cicpTransform(...decoded.cicp) : null)?.(decoded.pixels);
22872288
// the decoded pixels become the bitmap, without a copy
22882289
const image = new Image(decoded.width, decoded.height);
22892290
image.bitmap = decoded.pixels;

‎src/util/icc.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,53 @@ export function profileTransform(icc: Uint8Array): ((rgba: Uint8Array | Uint8Cla
125125
} catch {
126126
return null;
127127
}
128-
if (!p || isSRGB(p)) return null;
128+
return p && transform(p);
129+
}
130+
131+
// the primaries (red, green, blue) and white of CICP's color primaries codes (ITU-T H.273) as xy chromaticities
132+
const CICP_PRIMARIES: Record<number, number[][]> = {
133+
1: [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06], [0.3127, 0.3290]],
134+
4: [[0.67, 0.33], [0.21, 0.71], [0.14, 0.08], [0.310, 0.316]],
135+
5: [[0.64, 0.33], [0.29, 0.60], [0.15, 0.06], [0.3127, 0.3290]],
136+
6: [[0.630, 0.340], [0.310, 0.595], [0.155, 0.070], [0.3127, 0.3290]],
137+
7: [[0.630, 0.340], [0.310, 0.595], [0.155, 0.070], [0.3127, 0.3290]],
138+
8: [[0.681, 0.319], [0.243, 0.692], [0.145, 0.049], [0.310, 0.316]],
139+
9: [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], [0.3127, 0.3290]],
140+
11: [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060], [0.314, 0.351]],
141+
12: [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060], [0.3127, 0.3290]],
142+
22: [[0.630, 0.340], [0.295, 0.605], [0.155, 0.077], [0.3127, 0.3290]],
143+
};
144+
const D50 = [0.9642, 1, 0.8249];
145+
const BRADFORD = [0.8951, 0.2664, -0.1614, -0.7502, 1.7135, 0.0367, 0.0389, -0.0685, 1.0296];
146+
147+
/**
148+
* The transform of 8-bit RGBA pixels with CICP color primaries and transfer characteristics (like an AVIF's nclx box)
149+
* to sRGB, like an ICC profile's: null for sRGB's, unknown primaries and HDR transfers (PQ, HLG). Transfers other than
150+
* gamma 2.2, 2.8 and linear are taken as sRGB's (the BT.709 family's curve describes cameras, displays show it as
151+
* sRGB)
152+
*/
153+
export function cicpTransform(primaries: number, transfer: number): ((rgba: Uint8Array | Uint8ClampedArray) => void) | null {
154+
const xy = CICP_PRIMARIES[primaries];
155+
if (!xy || transfer === 16 || transfer === 18) return null;
156+
const xyz = ([x, y]: number[]): number[] => [x / y, 1, (1 - x - y) / y];
157+
// the primaries' XYZ (columns) scaled so they add up to the white, then adapted to D50 by Bradford
158+
const p = [0, 1, 2].map(i => xyz(xy[i])), white = xyz(xy[3]);
159+
const columns = [p[0][0], p[1][0], p[2][0], p[0][1], p[1][1], p[2][1], p[0][2], p[1][2], p[2][2]];
160+
const inverse = invert3(columns);
161+
if (!inverse) return null;
162+
const scale = [0, 1, 2].map(r => inverse[r * 3] * white[0] + inverse[r * 3 + 1] * white[1] + inverse[r * 3 + 2] * white[2]);
163+
const matrix = columns.map((v, i) => v * scale[i % 3]);
164+
const cone = (v: number[]): number[] => [0, 1, 2].map(r => BRADFORD[r * 3] * v[0] + BRADFORD[r * 3 + 1] * v[1] + BRADFORD[r * 3 + 2] * v[2]);
165+
const from = cone(white), to = cone(D50);
166+
const adapt = multiply3(invert3(BRADFORD) as number[], multiply3([to[0] / from[0], 0, 0, 0, to[1] / from[1], 0, 0, 0, to[2] / from[2]], BRADFORD));
167+
const curve: Curve = transfer === 4 ? v => v ** 2.2 : transfer === 5 ? v => v ** 2.8 : transfer === 8 ? v => v
168+
: v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
169+
return transform({ gray: false, matrix: multiply3(adapt, matrix), curves: [curve, curve, curve] });
170+
}
171+
172+
// a profile's transform of 8-bit RGBA pixels to sRGB (see profileTransform)
173+
function transform(p: Profile): ((rgba: Uint8Array | Uint8ClampedArray) => void) | null {
174+
if (isSRGB(p)) return null;
129175

130176
// 8-bit values to linear light per channel, linear sRGB to 8 bits through a fine table
131177
const linear = p.curves.map(c => Float32Array.from({ length: 256 }, (_, i) => c(i / 255)));

‎tests/icc.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const difference = (a, b) => {
2323
['display-p3.webp', 'display-p3.ref.png', 1], ['display-p3.tiff', 'display-p3.ref.png', 1],
2424
['display-p3.jpg', 'display-p3.jpg.ref.png', 2], ['display-p3.avif', 'display-p3.avif.ref.png', 2],
2525
['display-p3.jxl', 'display-p3.ref.png', 1], ['gray.jxl', 'gray.ref.png', 1],
26+
// (AVIFs without a profile, their nclx box's color primaries: Display P3, BT.2020)
27+
['display-p3-nclx.avif', 'display-p3.ref.png', 1], ['bt2020-nclx.avif', 'bt2020-nclx.ref.png', 1],
2628
];
2729
for (const [file, reference, tolerance] of cases) {
2830
const data = await fs.readFile(`./tests/icc/${file}`), ref = await Image.decode(await fs.readFile(`./tests/icc/${reference}`));

‎tests/icc/bt2020-nclx.avif‎

8.49 KB
Binary file not shown.

‎tests/icc/bt2020-nclx.ref.png‎

10.4 KB
Loading

‎tests/icc/display-p3-nclx.avif‎

8.49 KB
Binary file not shown.

0 commit comments

Comments
 (0)