Skip to content

Commit 87aa1c4

Browse files
committed
avif gain maps
1 parent 35e2a32 commit 87aa1c4

8 files changed

Lines changed: 170 additions & 35 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
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.
2628
- iPhone JPEGs carry Apple's own kind of gain map (with its headroom in Apple's XMP or Exif maker note): it's read
2729
too, converted to the same parameters, and written as an Ultra HDR gain map.
2830

‎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-
| [Ultra HDR](https://imagescript.dev/classes/GainMap.html) (JPEG with a gain map) | ✓ | ✓, kept through edits |
74+
| [HDR gain maps](https://imagescript.dev/classes/GainMap.html) (Ultra HDR and iPhone JPEGs, AVIF) | ✓ | ✓, 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/avif-enc.ts‎

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77
import { encodeAV1, encodeAV1Threaded, type AV1Image, type AV1Params } from './av1/encode.ts';
88
import { startThreads } from '../util/threads.ts';
9+
import { writeISO, type Params } from './gainmap.ts';
910

1011
/** Options of the AVIF encoder */
1112
export interface AVIFOptions {
@@ -21,6 +22,8 @@ export interface AVIFOptions {
2122
exif?: Uint8Array;
2223
/** at most this many worker threads (1: none) */
2324
threads: number;
25+
/** a gain map (RGBA, grey for one for all channels), written as a tone mapped derived item like libavif does */
26+
gainMap?: { pixels: Uint8Array, width: number, height: number, params: Params, quality: number };
2427
}
2528

2629
// (images below this many pixels encode faster without threads, which take about 0.1 s to start, and the pixels per
@@ -37,6 +40,8 @@ const qindex = (quality: number): number => QINDEX[Math.round((100 - Math.max(0,
3740

3841
// BT.601 full range (the color description: BT.709 primaries, sRGB transfer, BT.601 matrix)
3942
const KR = 0.299, KB = 0.114, KG = 1 - KR - KB, CICP = [1, 13, 6];
43+
// (a gain map's: unspecified primaries and transfer, its values aren't colors; BT.601's matrix)
44+
const MAP_CICP = [2, 2, 6];
4045

4146
function toYUV(rgba: Uint8Array, width: number, height: number, subsampled: boolean): Uint8Array[] {
4247
const n = width * height, y = new Uint8Array(n);
@@ -177,7 +182,7 @@ function seqMonochrome(d: Uint8Array, o: number): boolean {
177182
}
178183

179184
/** Encodes RGBA pixels as an AVIF (with an alpha image if the image has transparency) */
180-
export async function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort, exif, threads: maxThreads }: AVIFOptions): Promise<Uint8Array> {
185+
export async function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort, exif, threads: maxThreads, gainMap }: AVIFOptions): Promise<Uint8Array> {
181186
if (width < 1 || height < 1) throw new RangeError('AVIF: dimensions must be at least 1');
182187
if (width > 65536 || height > 65536) throw new RangeError('AVIF: dimensions must be at most 65536');
183188
let opaque = true;
@@ -212,47 +217,76 @@ export async function encode(rgba: Uint8Array, width: number, height: number, {
212217
}
213218
return encodeAV1(image, params);
214219
};
215-
let color: Uint8Array, alpha: Uint8Array | null = null;
220+
let color: Uint8Array, alpha: Uint8Array | null = null, map: Uint8Array | null = null;
221+
// (the gain map: monochrome if grey, else 4:4:4, its values as they are, no transfer)
222+
let mapGray = true;
223+
if (gainMap) for (let i = 0; i < gainMap.pixels.length; i += 4) if (gainMap.pixels[i] !== gainMap.pixels[i + 1] || gainMap.pixels[i] !== gainMap.pixels[i + 2]) { mapGray = false; break; }
216224
try {
217225
color = await av1({ planes, width, height, layout: gray ? 0 : subsampled ? 1 : 3, cicp }, { qidx: qindex(quality), effort });
218226
if (!opaque) {
219227
const a = new Uint8Array(width * height);
220228
for (let i = 0; i < a.length; i++) a[i] = original[4 * i + 3];
221229
alpha = await av1({ planes: [a], width, height, layout: 0, cicp: [2, 2, 2] }, { qidx: qindex(alphaQuality), effort });
222230
}
231+
if (gainMap) {
232+
const { pixels, width: w, height: h } = gainMap;
233+
const mapPlanes = mapGray ? [Uint8Array.from({ length: w * h }, (_, i) => pixels[4 * i])] : toYUV(pixels, w, h, false);
234+
map = await av1({ planes: mapPlanes, width: w, height: h, layout: mapGray ? 0 : 3, cicp: MAP_CICP }, { qidx: qindex(gainMap.quality), effort });
235+
}
223236
} finally {
224237
stop();
225238
}
226239

227240
// the boxes: ftyp, meta (the items, their locations in mdat, their properties), mdat
228241
const profile = subsampled || gray ? 'MA1B' : 'MA1A';
229-
const ftyp = box('ftyp', ascii('avif'), u32(0), ascii('avif'), ascii('mif1'), ascii('miaf'), ascii(profile));
242+
const ftyp = box('ftyp', ascii('avif'), u32(0), ascii('avif'), ascii('mif1'), ascii('miaf'), ascii(profile), map ? ascii('tmap') : []);
230243
const hdlr = fullBox('hdlr', 0, 0, u32(0), ascii('pict'), u32(0), u32(0), u32(0), [0]);
231244
const pitm = fullBox('pitm', 0, 0, u16(1));
232-
// the items: the color image (1), the alpha image, the Exif data (its TIFF header's offset, 0, then the TIFF data)
233-
const items: { type: string, name: string, data: ArrayLike<number>[] }[] = [{ type: 'av01', name: 'Color', data: [color] }];
234-
if (alpha) items.push({ type: 'av01', name: 'Alpha', data: [alpha] });
235-
if (exif?.length) items.push({ type: 'Exif', name: 'Exif', data: [u32(0), exif] });
236-
const sizes = items.map(item => item.data.reduce((n, d) => n + d.length, 0));
237-
const infe = (id: number, type: string, name: string): number[] => fullBox('infe', 2, 0, u16(id), u16(0), ascii(type), ascii(name), [0]);
238-
const iinf = fullBox('iinf', 0, 0, u16(items.length), ...items.map((item, i) => infe(i + 1, item.type, item.name)));
239-
// (the alpha image is auxiliary to the color image, the Exif data describes it)
240-
const exifId = exif?.length ? items.length : 0;
241-
const iref = alpha || exifId ? fullBox('iref', 0, 0, alpha ? box('auxl', u16(2), u16(1), u16(1)) : [], exifId ? box('cdsc', u16(exifId), u16(1), u16(1)) : []) : [];
242-
const ispe = fullBox('ispe', 0, 0, u32(width), u32(height));
243-
const colr = box('colr', ascii('nclx'), u16(cicp[0]), u16(cicp[1]), u16(cicp[2]), [0x80]);
245+
// the items (ids from 1): the color image, the alpha image, the gain map (hidden) and the tone mapped item made of
246+
// the color image and it (its data: version 0 and the ISO 21496-1 metadata), the Exif data (its TIFF header's
247+
// offset, 0, then the TIFF data); properties are added to ipco (1-based) and associated (essential: | 0x80)
248+
const items: { type: string, name: string, hidden?: boolean, data: ArrayLike<number>[], props: number[] }[] = [];
249+
const properties: number[][] = [];
250+
const property = (p: number[]): number => properties.push(p);
251+
const nclx = (c: number[]): number[] => box('colr', ascii('nclx'), u16(c[0]), u16(c[1]), u16(c[2]), [0x80]);
244252
const pixi = (channels: number): number[] => fullBox('pixi', 0, 0, [channels], new Array<number>(channels).fill(8));
245-
const auxC = fullBox('auxC', 0, 0, ascii('urn:mpeg:mpegB:cicp:systems:auxiliary:alpha'), [0]);
246-
// (properties 1-based: ispe, av1C, colr, pixi; alpha: av1C, pixi, auxC)
247-
const ipco = box('ipco', ispe, av1C(color), colr, pixi(gray ? 1 : 3), alpha ? av1C(alpha) : [], alpha ? pixi(1) : [], alpha ? auxC : []);
248-
const ipma = fullBox('ipma', 0, 0, u32(alpha ? 2 : 1), u16(1), [4, 1, 0x82, 3, 4], alpha ? [...u16(2), 4, 1, 0x85, 6, 7] : []);
253+
const ispe = property(fullBox('ispe', 0, 0, u32(width), u32(height)));
254+
const colorConfig = property(av1C(color)) | 0x80, colr = property(nclx(cicp));
255+
items.push({ type: 'av01', name: 'Color', data: [color], props: [ispe, colorConfig, colr, property(pixi(gray ? 1 : 3))] });
256+
const alphaId = alpha ? items.push({
257+
type: 'av01', name: 'Alpha', data: [alpha],
258+
props: [ispe, property(av1C(alpha)) | 0x80, property(pixi(1)), property(fullBox('auxC', 0, 0, ascii('urn:mpeg:mpegB:cicp:systems:auxiliary:alpha'), [0]))]
259+
}) : 0;
260+
let mapId = 0, tmapId = 0;
261+
if (map && gainMap) {
262+
mapId = items.push({
263+
type: 'av01', name: 'GMap', hidden: true, data: [map],
264+
props: [property(fullBox('ispe', 0, 0, u32(gainMap.width), u32(gainMap.height))), property(av1C(map)) | 0x80, property(nclx(MAP_CICP)), property(pixi(mapGray ? 1 : 3))]
265+
});
266+
// (the HDR rendition's colors: the image's primaries with PQ's transfer, like avifenc assumes for JPEG gain maps)
267+
tmapId = items.push({ type: 'tmap', name: 'TMap', data: [[0], writeISO(gainMap.params)], props: [ispe, property(nclx([cicp[0], 16, cicp[2]]))] });
268+
}
269+
const exifId = exif?.length ? items.push({ type: 'Exif', name: 'Exif', data: [u32(0), exif], props: [] }) : 0;
270+
const sizes = items.map(item => item.data.reduce((n, d) => n + d.length, 0));
271+
const infe = (id: number, item: typeof items[number]): number[] => fullBox('infe', 2, item.hidden ? 1 : 0, u16(id), u16(0), ascii(item.type), ascii(item.name), [0]);
272+
const iinf = fullBox('iinf', 0, 0, u16(items.length), ...items.map((item, i) => infe(i + 1, item)));
273+
// (the alpha image is auxiliary to the color image, the tone mapped item made of the color image and the gain map,
274+
// the Exif data describes the color image)
275+
const refs = [alphaId ? box('auxl', u16(alphaId), u16(1), u16(1)) : [], tmapId ? box('dimg', u16(tmapId), u16(2), u16(1), u16(mapId)) : [],
276+
exifId ? box('cdsc', u16(exifId), u16(1), u16(1)) : []];
277+
const iref = refs.some(r => r.length) ? fullBox('iref', 0, 0, ...refs) : [];
278+
const ipco = box('ipco', ...properties);
279+
const associated = items.map((item, i) => [i + 1, item.props] as const).filter(([, props]) => props.length);
280+
const ipma = fullBox('ipma', 0, 0, u32(associated.length), ...associated.map(([id, props]) => [...u16(id), props.length, ...props]));
249281
const iprp = box('iprp', ipco, ipma);
282+
// (the tone mapped item preferred over the color image: an alternatives group, its id after the items')
283+
const grpl = tmapId ? box('grpl', fullBox('altr', 0, 0, u32(items.length + 1), u32(2), u32(tmapId), u32(1))) : [];
250284
// the locations (absolute offsets into mdat, one after the other, filled in once the size of meta is known)
251285
const ilocFor = (offset: number): number[] => fullBox('iloc', 0, 0, [0x44, 0x00], u16(items.length), ...items.map((_, i) => {
252286
const at = offset + sizes.slice(0, i).reduce((n, size) => n + size, 0);
253287
return [...u16(i + 1), ...u16(0), ...u16(1), ...u32(at), ...u32(sizes[i])];
254288
}));
255-
const metaFor = (offset: number): number[] => fullBox('meta', 0, 0, hdlr, pitm, ilocFor(offset), iinf, iref, iprp);
289+
const metaFor = (offset: number): number[] => fullBox('meta', 0, 0, hdlr, pitm, ilocFor(offset), iinf, iref, iprp, grpl);
256290
const headerSize = ftyp.length + metaFor(0).length + 8;
257291
const head = [...ftyp, ...metaFor(headerSize)];
258292
const mdatSize = 8 + sizes.reduce((n, size) => n + size, 0);

‎src/codecs/avif.ts‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
1010
import { decodeAV1, Frame } from './av1/frame.ts';
1111
import { toSRGB, hdrToLinear, ToneMapping } from './hdr.ts';
1212
import { runChunks, sharedOf, startThreads, type ChunkTask, type Thread } from '../util/threads.ts';
13+
import { readISO, type Params } from './gainmap.ts';
14+
15+
/** A decoded gain map: its RGBA pixels and parameters */
16+
export interface DecodedGainMap { width: number, height: number, pixels: Uint8Array, params: Params }
1317

1418
interface Box { type: string, start: number, end: number }
1519

@@ -568,7 +572,7 @@ const MIN_THREADED_PIXELS = 4 << 20, PIXELS_PER_THREAD = 1 << 20;
568572
const TILES_PER_THREAD = 4;
569573

570574
/** 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, cicp?: [number, number] }> {
575+
export async function decode(d: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneMapping = 'reinhard', threads = 1, gainMap = false): Promise<{ width: number, height: number, pixels: Uint8Array, icc?: Uint8Array, cicp?: [number, number], gainMap?: DecodedGainMap }> {
572576
if (!isAVIF(d)) throw new Error('AVIF: invalid header');
573577
let meta: Box | undefined, moov: Box | undefined;
574578
for (const box of boxes(d, 0, d.length)) {
@@ -597,7 +601,10 @@ export async function decode(d: Uint8Array, maxPixels = MAX_PIXELS, toneMapping:
597601
const n = Math.min(threads, Math.floor(pixels / PIXELS_PER_THREAD));
598602
const pool = n > 1 && pixels >= MIN_THREADED_PIXELS && pixels <= maxPixels ? startThreads(n) : null;
599603
try {
600-
return await decodeImage(d, it, items, idat, alphaItem, alphaOf, maxPixels, toneMapping, pool);
604+
const image: Awaited<ReturnType<typeof decodeImage>> & { gainMap?: DecodedGainMap } = await decodeImage(d, it, items, idat, alphaItem, alphaOf, maxPixels, toneMapping, pool);
605+
// (a gain map that can't be read leaves the image without one)
606+
if (gainMap) image.gainMap = await decodeGainMap(d, primary, items, idat, maxPixels).catch(() => undefined);
607+
return image;
601608
} finally {
602609
if (pool) for (const t of pool) t.terminate();
603610
}
@@ -625,15 +632,43 @@ async function decodeImage(d: Uint8Array, it: Item, items: Map<number, Item>, id
625632
if (tiles.length && tiles.every(t => t)) alpha = await decodeGrid(d, it, items, idat, maxPixels, threads, tiles.map(t => (t as Item).id));
626633
}
627634
let pixels = await toRgba(color, alpha, toneMapping, alphaItem && (it.refs.get('prem') ?? []).includes(alphaItem.id), threads);
628-
let { width, height } = color;
629-
// the transformations, in the order of their properties
635+
const transformed = transform(d, it, color.width, color.height, pixels);
636+
const { width, height } = transformed;
637+
pixels = transformed.pixels;
638+
// (without a profile, the color primaries and transfer of the nclx box or the sequence header: see cicpTransform)
639+
return { width, height, pixels, icc, cicp: icc ? undefined : [color.primaries, color.transfer] };
640+
}
641+
642+
// an item's transformations (crop, rotation, mirroring), in the order of its properties
643+
function transform(d: Uint8Array, it: Item, width: number, height: number, pixels: Uint8Array): { width: number, height: number, pixels: Uint8Array } {
630644
for (const { box } of it.props) {
631645
if (box.type === 'clap') ({ width, height, pixels } = clap(d, box, width, height, pixels));
632646
else if (box.type === 'irot') ({ width, height, pixels } = rotate(pixels, width, height, d[box.start] & 3));
633647
else if (box.type === 'imir') pixels = mirror(pixels, width, height, d[box.start] & 1);
634648
}
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] };
649+
return { width, height, pixels };
650+
}
651+
652+
// the gain map of the primary item (AVIF 1.2, like libavif's read.c): a tone mapped ('tmap') derived item made from
653+
// the primary item and the gain map item (its dimg references, in that order), its data a version byte (0) and the
654+
// ISO 21496-1 metadata. The map item is transformed like the primary (libavif writes the same properties to both)
655+
async function decodeGainMap(d: Uint8Array, primary: number, items: Map<number, Item>, idat: Box | undefined, maxPixels: number): Promise<DecodedGainMap | undefined> {
656+
for (const tmap of items.values()) {
657+
const inputs = tmap.refs.get('dimg') ?? [];
658+
if (tmap.type !== 'tmap' || inputs.length !== 2 || inputs[0] !== primary) continue;
659+
const data = itemData(d, tmap, idat);
660+
if (data[0] !== 0) return undefined;
661+
const params = readISO(data.subarray(1));
662+
const mapItem = items.get(inputs[1]);
663+
if (!params || !mapItem) return undefined;
664+
const yuv = await decodeItem(d, mapItem, items, idat, maxPixels, null);
665+
applyColr(d, mapItem, yuv);
666+
// (its values as they are: no transfer function to tone map)
667+
yuv.transfer = 13;
668+
const { width, height, pixels } = transform(d, mapItem, yuv.width, yuv.height, await toRgba(yuv, null, 'clamp'));
669+
return { width, height, pixels, params };
670+
}
671+
return undefined;
637672
}
638673

639674
// the ICC profile of a colr box (prof or rICC)

‎src/codecs/gainmap.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export function valid(p: Params): boolean {
251251
// ISO 21496-1 (version 0): u16 minimum version, u16 writer version, flags (0x80: 3 channels, 0x40: use the base color
252252
// space, 4: the base is the HDR image, 8: a common denominator; libultrahdr's bits), then the headrooms and per channel min, max, gamma,
253253
// base offset, alternate offset, as fractions (big endian, signed where they can be negative)
254-
function readISO(data: Uint8Array): Params | null {
254+
export function readISO(data: Uint8Array): Params | null {
255255
if (data.length < 5) return null;
256256
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
257257
if (view.getUint16(0) !== 0) return null;

‎src/gainmap.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +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 into {@link Image.gainMap}, {@link Image.encodeJPEG} writes them;
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;
3940
* the map's pixels are only decoded once they're needed. The map covers the whole image, at any resolution (phones
4041
* write a quarter of the width and height). Its value `v` (0-255, per channel or one for all) boosts the linear
4142
* light of the image by `2^(gainMapMin + (gainMapMax - gainMapMin) * (v / 255)^(1 / gamma))`
@@ -96,6 +97,16 @@ export class GainMap {
9697
return gainMap;
9798
}
9899

100+
/** @internal from decoded pixels (an AVIF's or JPEG XL's map) */
101+
static __from_pixels__(image: Image, params: codec.Params): GainMap {
102+
const gainMap = Object.create(GainMap.prototype) as GainMap;
103+
gainMap.__jpeg__ = null;
104+
gainMap.__apple__ = 0;
105+
gainMap.__pixels__ = image;
106+
gainMap.__set_params__(params);
107+
return gainMap;
108+
}
109+
99110
private __set_params__(p: codec.Params): void {
100111
this.gainMapMin = p.min.slice();
101112
this.gainMapMax = p.max.slice();

0 commit comments

Comments
 (0)