Skip to content

Commit 816cda8

Browse files
committed
jpeg xl gain maps
1 parent 87aa1c4 commit 816cda8

6 files changed

Lines changed: 103 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
at a quality or size, `{ gainMap: false }` leaves it out; `DecodeOptions.gainMap: false` doesn't read it.
2424
- Files with the gain map cut off (some chat apps and uploads keep the index but drop the map) and broken gain map
2525
metadata decode as before, without one.
26-
- AVIFs carry gain maps too (a tone mapped item, AVIF 1.2): `Image.decode` reads them and `encodeAVIF()` writes
27-
them (with the same `gainMap` option), so Ultra HDR JPEGs convert to HDR AVIFs a fraction of the size.
26+
- AVIFs and JPEG XLs carry gain maps too (AVIF 1.2's tone mapped item, JPEG XL's jhgm box): `Image.decode` reads
27+
them, `encodeAVIF()` and `encodeJXL()` write them (with the same `gainMap` option), so Ultra HDR JPEGs convert to
28+
HDR AVIFs and JPEG XLs a fraction of the size.
2829
- iPhone JPEGs carry Apple's own kind of gain map (with its headroom in Apple's XMP or Exif maker note): it's read
2930
too, converted to the same parameters, and written as an Ultra HDR gain map.
3031

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ await writeFile('output.png', await image.encode());
7171
| GIF, APNG (animations) | ✓ | ✓ |
7272
| BMP, QOI, TGA, Netpbm | ✓ | ✓ |
7373
| TIFF, ICO/CUR, Radiance HDR | ✓ | |
74-
| [HDR gain maps](https://imagescript.dev/classes/GainMap.html) (Ultra HDR and iPhone JPEGs, AVIF) | ✓ | ✓, kept through edits |
74+
| [HDR gain maps](https://imagescript.dev/classes/GainMap.html) (Ultra HDR and iPhone JPEGs, AVIF, JPEG XL) | ✓ | ✓, kept through edits |
7575

7676
[Animations](https://imagescript.dev/classes/Animation.html) can be decoded from GIF, APNG, WebP and JPEG XL,
7777
and encoded as GIF, APNG or WebP, all at once or

‎src/codecs/jxl.ts‎

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { encodeModular } from './jxl/modular-lossless.ts';
2020
import { encodeVarDCT } from './jxl/vardct-enc.ts';
2121
import { concat } from '../util/mem.ts';
2222
import { srgbDecode, type Curve } from '../util/icc.ts';
23+
import { readISO, writeISO, type Params } from './gainmap.ts';
2324

2425
const CONTAINER = [0, 0, 0, 12, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a];
2526

@@ -33,15 +34,28 @@ export function isJXL(d: Uint8Array): boolean {
3334
* Encodes RGBA pixels as a JPEG XL, lossless or lossy (with a Butteraugli distance above 0, and an effort 0-2): the
3435
* codestream, in the container if there is Exif data (a TIFF header and IFDs); lossy with up to threads worker threads
3536
*/
36-
export async function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0, effort = 1, threads = 1): Promise<Uint8Array> {
37-
const stream = distance > 0 ? await encodeVarDCT(rgba, width, height, { distance, effort, threads })
38-
: effort ? await encodeModular(rgba, width, height, threads) : encodeRGBA(rgba, width, height);
39-
if (!exif?.length) return stream;
37+
export async function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0, effort = 1, threads = 1, gainMap?: GainMapInput): Promise<Uint8Array> {
38+
const codestreamOf = (pixels: Uint8Array, w: number, h: number, d: number): Promise<Uint8Array> | Uint8Array => d > 0 ? encodeVarDCT(pixels, w, h, { distance: d, effort, threads })
39+
: effort ? encodeModular(pixels, w, h, threads) : encodeRGBA(pixels, w, h);
40+
const stream = await codestreamOf(rgba, width, height, distance);
41+
if (!exif?.length && !gainMap) return stream;
4042
// (ftyp: brand jxl, minor version 0, compatible jxl; Exif: the offset of the TIFF header, 0, then the TIFF data)
4143
const jxl = [0x6a, 0x78, 0x6c, 0x20];
42-
return concat([CONTAINER, ...box('ftyp', jxl, [0, 0, 0, 0], jxl), ...box('Exif', [0, 0, 0, 0], exif), ...box('jxlc', stream)]);
44+
const boxes = [...box('ftyp', jxl, [0, 0, 0, 0], jxl)];
45+
if (exif?.length) boxes.push(...box('Exif', [0, 0, 0, 0], exif));
46+
if (gainMap) {
47+
// jhgm (libjxl's JxlGainMapWriteBundle): version 0, the ISO 21496-1 metadata (its size, 16 bits), no color
48+
// encoding or ICC profile (sizes 0: the alternate image's are the base image's), then the map's codestream
49+
const metadata = writeISO(gainMap.params);
50+
const map = await codestreamOf(gainMap.pixels, gainMap.width, gainMap.height, gainMap.distance);
51+
boxes.push(...box('jhgm', [0, metadata.length >> 8, metadata.length & 255], metadata, [0, 0, 0, 0, 0], map));
52+
}
53+
return concat([CONTAINER, ...boxes, ...box('jxlc', stream)]);
4354
}
4455

56+
/** A gain map to write: its RGBA pixels, parameters and the distance to encode it at (0: losslessly) */
57+
export interface GainMapInput { pixels: Uint8Array, width: number, height: number, params: Params, distance: number }
58+
4559
// a box: its header and content
4660
const box = (type: string, ...content: ArrayLike<number>[]): ArrayLike<number>[] => {
4761
const size = 8 + content.reduce((n, c) => n + c.length, 0);
@@ -50,10 +64,9 @@ const box = (type: string, ...content: ArrayLike<number>[]): ArrayLike<number>[]
5064

5165
const fourcc = (d: Uint8Array, o: number): string => String.fromCharCode(d[o], d[o + 1], d[o + 2], d[o + 3]);
5266

53-
/** The codestream of a file: itself, or the container's jxlc box or jxlp boxes joined */
54-
function codestream(d: Uint8Array): Uint8Array {
55-
if (d[0] === 0xff) return d;
56-
const view = new DataView(d.buffer, d.byteOffset, d.byteLength), parts: Uint8Array[] = [];
67+
// the container's boxes: their types and contents
68+
function* boxesOf(d: Uint8Array): Generator<{ type: string, content: Uint8Array }> {
69+
const view = new DataView(d.buffer, d.byteOffset, d.byteLength);
5770
for (let o = 0; o + 8 <= d.length;) {
5871
let size = view.getUint32(o), header = 8;
5972
const type = fourcc(d, o + 4);
@@ -63,11 +76,20 @@ function codestream(d: Uint8Array): Uint8Array {
6376
header = 16;
6477
} else if (size === 0) size = d.length - o;
6578
if (size < header || o + size > d.length) throw new Error(`JPEG XL: invalid ${type} box`);
66-
if (type === 'jxlc') return d.subarray(o + header, o + size);
67-
// (jxlp: a 4-byte index, then a part)
68-
if (type === 'jxlp') parts.push(d.subarray(o + header + 4, o + size));
79+
yield { type, content: d.subarray(o + header, o + size) };
6980
o += size;
7081
}
82+
}
83+
84+
/** The codestream of a file: itself, or the container's jxlc box or jxlp boxes joined */
85+
function codestream(d: Uint8Array): Uint8Array {
86+
if (d[0] === 0xff) return d;
87+
const parts: Uint8Array[] = [];
88+
for (const { type, content } of boxesOf(d)) {
89+
if (type === 'jxlc') return content;
90+
// (jxlp: a 4-byte index, then a part)
91+
if (type === 'jxlp') parts.push(content.subarray(4));
92+
}
7193
if (!parts.length) throw new Error('JPEG XL: no codestream');
7294
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
7395
let at = 0;
@@ -165,8 +187,33 @@ export async function* frames(data: Uint8Array, maxPixels = MAX_PIXELS, toneMapp
165187
}
166188
}
167189

168-
/** Decodes the first frame shown */
169-
export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneMapping = 'reinhard', threads = 1): Promise<{ width: number, height: number, pixels: Uint8Array, orientation: number, icc?: Uint8Array, colorSpace?: ColorSpace }> {
170-
for await (const frame of frames(data, maxPixels, toneMapping, threads)) return frame;
190+
/** Decodes the first frame shown (and the gain map of the jhgm box, if asked for and there is one that can be read) */
191+
export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneMapping = 'reinhard', threads = 1, gainMap = false): Promise<{ width: number, height: number, pixels: Uint8Array, orientation: number, icc?: Uint8Array, colorSpace?: ColorSpace, gainMap?: { width: number, height: number, pixels: Uint8Array, params: Params } }> {
192+
for await (const frame of frames(data, maxPixels, toneMapping, threads)) {
193+
if (!gainMap || data[0] === 0xff) return frame;
194+
return { ...frame, gainMap: await decodeGainMap(data, maxPixels).catch(() => undefined) };
195+
}
171196
throw new Error('JPEG XL: no frame shown');
172197
}
198+
199+
// the jhgm box's gain map (libjxl's JxlGainMapReadBundle): version 0, the ISO 21496-1 metadata (its size, 16 bits),
200+
// the alternate image's color encoding and ICC profile (their sizes, 8 and 32 bits), the map's codestream
201+
async function decodeGainMap(data: Uint8Array, maxPixels: number): Promise<{ width: number, height: number, pixels: Uint8Array, params: Params } | undefined> {
202+
for (const { type, content: c } of boxesOf(data)) {
203+
if (type !== 'jhgm') continue;
204+
if (c.length < 3 || c[0] !== 0) return undefined;
205+
const metadataSize = c[1] << 8 | c[2];
206+
let o = 3 + metadataSize;
207+
if (o + 1 > c.length) return undefined;
208+
const metadata = c.subarray(3, o);
209+
// (the metadata as ISO 21496-1 has it; or after a version byte, like AVIF's tmap item)
210+
const params = readISO(metadata) ?? (metadata[0] === 0 ? readISO(metadata.subarray(1)) : null);
211+
o += 1 + c[o];
212+
if (!params || o + 4 > c.length) return undefined;
213+
o += 4 + (c[o] << 24 >>> 0) + (c[o + 1] << 16 | c[o + 2] << 8 | c[o + 3]);
214+
if (o >= c.length) return undefined;
215+
// (its values as they are: no color management, no tone mapping)
216+
for await (const { width, height, pixels } of frames(c.subarray(o), maxPixels, 'clamp', 1)) return { width, height, pixels, params };
217+
}
218+
return undefined;
219+
}

‎src/gainmap.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ const perChannel = (value: number | number[], name: string): number[] => {
3535
* A gain map: a small image saying how much brighter each part of an image gets on an HDR display, as Ultra HDR
3636
* (JPEG-R) photos from phones carry it. HDR displays show the image boosted by it, the others the image as it is.
3737
*
38-
* {@link Image.decode} reads the gain maps of JPEGs and AVIFs into {@link Image.gainMap}, {@link Image.encodeJPEG} and
39-
* {@link Image.encodeAVIF} write them;
38+
* {@link Image.decode} reads the gain maps of JPEGs, AVIFs and JPEG XLs into {@link Image.gainMap},
39+
* {@link Image.encodeJPEG}, {@link Image.encodeAVIF} and {@link Image.encodeJXL} write them;
4040
* the map's pixels are only decoded once they're needed. The map covers the whole image, at any resolution (phones
4141
* write a quarter of the width and height). Its value `v` (0-255, per channel or one for all) boosts the linear
4242
* light of the image by `2^(gainMapMin + (gainMapMax - gainMapMin) * (v / 255)^(1 / gamma))`

‎src/image.ts‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,8 @@ export interface DecodeOptions {
232232
*/
233233
toneMapping?: ToneMapping;
234234
/**
235-
* Whether to read the gain maps of JPEGs (Ultra HDR, JPEG-R, ISO 21496-1, Apple's) and AVIFs (tone mapped items)
236-
* into {@link Image.gainMap}. A JPEG's map's pixels are only decoded when they're needed. Default: `true`
235+
* Whether to read the gain maps of JPEGs (Ultra HDR, JPEG-R, ISO 21496-1, Apple's), AVIFs (tone mapped items) and
236+
* JPEG XLs (jhgm boxes) into {@link Image.gainMap}. A JPEG's map's pixels are only decoded when they're needed. Default: `true`
237237
*/
238238
gainMap?: boolean;
239239
/**
@@ -271,6 +271,12 @@ export interface JXLEncodeOptions {
271271
threads?: number;
272272
/** Metadata to include, see {@link Metadata} */
273273
metadata?: Metadata;
274+
/**
275+
* Whether to write the image's {@link Image.gainMap} (brighter on HDR displays: a jhgm box, like libjxl's), or how:
276+
* `quality` (1-100 like `quality`, 100 lossless; default `90`) and `scale` (its size relative to the image, default
277+
* its own). Default: `true`
278+
*/
279+
gainMap?: boolean | { quality?: number, scale?: number };
274280
}
275281

276282
/** Options of {@link Image.encodeAVIF} */
@@ -2488,16 +2494,14 @@ export class Image {
24882494
* container, with the metadata as Exif
24892495
* @param options See {@link JXLEncodeOptions}
24902496
*/
2491-
async encodeJXL({ quality, effort = 1, threads = defaultThreads(), metadata = {} }: JXLEncodeOptions = {}): Promise<Uint8Array> {
2497+
async encodeJXL({ quality, effort = 1, threads = defaultThreads(), metadata = {}, gainMap = true }: JXLEncodeOptions = {}): Promise<Uint8Array> {
24922498
checkEffort(effort, 2);
24932499
checkThreads(threads);
2494-
let distance = 0;
2495-
if (quality !== undefined) {
2496-
const q = checkQuality(quality);
2497-
// (cjxl's distance of a quality)
2498-
distance = q >= 100 ? 0 : q >= 30 ? 0.1 + (100 - q) * 0.09 : 53 / 3000 * q * q - 23 / 20 * q + 25;
2499-
}
2500-
return jxl.encode(this.__bytes__(), this.width, this.height, exifData(resolveMetadata(metadata)), distance, effort, threads);
2500+
// (cjxl's distance of a quality)
2501+
const distanceOf = (q: number): number => q >= 100 ? 0 : q >= 30 ? 0.1 + (100 - q) * 0.09 : 53 / 3000 * q * q - 23 / 20 * q + 25;
2502+
const distance = quality === undefined ? 0 : distanceOf(checkQuality(quality));
2503+
const map = this.__gain_map_pixels__(gainMap);
2504+
return jxl.encode(this.__bytes__(), this.width, this.height, exifData(resolveMetadata(metadata)), distance, effort, threads, map && { ...map, distance: distanceOf(map.quality) });
25012505
}
25022506

25032507
/**
@@ -2542,7 +2546,7 @@ export class Image {
25422546
return { width, height, pixels };
25432547
};
25442548
if (ImageType.isAVIF(dataView)) return avif.decode(data, maxPixels, toneMapping, threads, gainMap);
2545-
if (ImageType.isJXL(dataView)) return jxl.decode(data, maxPixels, toneMapping, threads);
2549+
if (ImageType.isJXL(dataView)) return jxl.decode(data, maxPixels, toneMapping, threads, gainMap);
25462550
if (ImageType.isBMP(dataView)) return bitmap(bmp.decode(data, maxPixels));
25472551
if (ImageType.isICO(dataView)) return bitmap(ico.decode(data, maxPixels));
25482552
if (ImageType.isQOI(dataView)) return qoi.decode(data, maxPixels);

‎tests/gainmap.js‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,26 @@ const meanValue = (image, left, top, right, bottom) => {
245245
if (scaled.gainMap.width !== 128 || scaled.gainMap.height !== 96) panic(`AVIF ${name}: scaled map ${scaled.gainMap.width}x${scaled.gainMap.height}`);
246246
}
247247

248+
// JPEG XL: the gain map in a jhgm box (lossy and lossless), the image as without one
249+
for (const [name, image] of [['single-channel', single], ['multi-channel', multi]]) {
250+
for (const options of [{quality: 80}, {}]) {
251+
const jxl = await image.encodeJXL(options);
252+
const back = await Image.decode(jxl);
253+
const map = back.gainMap;
254+
if (!map || map.channels !== image.gainMap.channels || map.width !== image.gainMap.width || !close(map.gainMapMax, image.gainMap.gainMapMax) || !close(map.gainMapMin, image.gainMap.gainMapMin) || Math.abs(map.hdrCapacityMax - image.gainMap.hdrCapacityMax) > 1e-6)
255+
panic(`JXL ${name} ${JSON.stringify(options)}: ${map ? `${map.channels} channels, ${map.width}, ${map.gainMapMax}` : 'no gain map'}`);
256+
let sum = 0;
257+
const a = map.image.bitmap, b = image.gainMap.image.bitmap;
258+
for (let i = 0; i < a.length; i += 4) sum += Math.abs(a[i] - b[i]);
259+
if (sum / (a.length / 4) > 1) panic(`JXL ${name}: map off by ${sum / (a.length / 4)}`);
260+
const plain = await image.encodeJXL({...options, gainMap: false});
261+
if ((await Image.decode(plain)).gainMap !== null || !same(back, await Image.decode(plain))) panic(`JXL ${name}: the base differs with the map, or gainMap: false wrote it`);
262+
if ((await Image.decode(jxl, {gainMap: false})).gainMap !== null) panic(`JXL ${name}: gainMap: false still read the map`);
263+
}
264+
const lossless = await Image.decode(await image.encodeJXL({gainMap: {quality: 100}}));
265+
if (!same(lossless.gainMap.image, image.gainMap.image)) panic(`JXL ${name}: a lossless map changed`);
266+
}
267+
248268
// invalid arguments throw before the image or its map changes
249269
for (const [name, call] of [['blur', i => i.blur('5')], ['fill', i => i.fill('red')], ['cover', i => i.cover(-1, 10)]]) {
250270
const image = single.clone();

0 commit comments

Comments
 (0)