Skip to content

Commit 07db874

Browse files
committed
JPEG XL: lossless encoding like cjxl -e 3
1 parent b44a677 commit 07db874

12 files changed

Lines changed: 512 additions & 60 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# Changelog
22

3-
## 3.13.3
3+
## 3.14.0
4+
5+
### New
6+
7+
- Lossless `encodeJXL()` compresses like libjxl's `cjxl -e 3` (effort 3) by default: about 15% smaller files for
8+
photos (a 1920x1080 photo: 1.17 MB instead of 1.39 MB) and 13% for graphics, within 1-2% of cjxl's sizes. It
9+
uses a palette of up to 1024 colors where it fits (else palettes of the channels that use few of their values),
10+
YCoCg and the weighted predictor in contexts of its error, and codes the groups in worker threads
11+
(`JXLEncodeOptions.threads`, the same files): the photo takes 0.24 s with 8 threads, 0.65 s with one. `effort: 0`
12+
keeps the fast mode, byte for byte what `cjxl -e 1` writes (0.11 s for the photo); `effort` applies to lossless
13+
compression now too.
414

515
### Performance
616

‎scripts/jxl-lossless-references.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Prints the SHA-256 of what cjxl -e 1 (libjxl's fast lossless mode) writes for the images of tests/jxl-encode.js:
2-
# Image.encodeJXL writes the same. The channels are those encodeJXL picks (grey if every pixel is, alpha if one isn't
2+
# Image.encodeJXL writes the same at effort 0. Then the sizes of what cjxl -e 3 writes (effort 1 is within a few percent). The channels are those encodeJXL picks (grey if every pixel is, alpha if one isn't
33
# opaque). libjxl's output depends on the CPU: with AVX2 or NEON it works in chunks of 16 pixels (what encodeJXL does),
44
# without SIMD in chunks of 8 (the test checks these too), with AVX-512 in chunks of 32. Needs Pillow and cjxl:
55
# CJXL=path/to/cjxl python scripts/jxl-lossless-references.py
@@ -21,6 +21,15 @@ def images():
2121
yield 'external.png grey', np.dstack([red, red, red, np.full_like(red, 255)])
2222

2323

24+
def save(rgba):
25+
grey = (rgba[..., 0] == rgba[..., 1]).all() and (rgba[..., 0] == rgba[..., 2]).all()
26+
opaque = (rgba[..., 3] == 255).all()
27+
channels = [0] if grey else [0, 1, 2]
28+
if not opaque: channels.append(3)
29+
a = rgba[..., channels]
30+
Image.fromarray(a[..., 0] if len(channels) == 1 else a, {1: 'L', 2: 'LA', 3: 'RGB', 4: 'RGBA'}[len(channels)]).save(tmp / 'in.png')
31+
32+
2433
for name, rgba in images():
2534
grey = (rgba[..., 0] == rgba[..., 1]).all() and (rgba[..., 0] == rgba[..., 2]).all()
2635
opaque = (rgba[..., 3] == 255).all()
@@ -30,3 +39,8 @@ def images():
3039
Image.fromarray(a[..., 0] if len(channels) == 1 else a, {1: 'L', 2: 'LA', 3: 'RGB', 4: 'RGBA'}[len(channels)]).save(tmp / 'in.png')
3140
subprocess.run([CJXL, '--quiet', str(tmp / 'in.png'), str(tmp / 'out.jxl'), '-d', '0', '-e', '1'], check=True)
3241
print(f" '{name}': '{hashlib.sha256((tmp / 'out.jxl').read_bytes()).hexdigest()}',")
42+
43+
for name, rgba in images():
44+
save(rgba)
45+
subprocess.run([CJXL, '--quiet', str(tmp / 'in.png'), str(tmp / 'out.jxl'), '-d', '0', '-e', '3'], check=True)
46+
print(f" '{name}': {(tmp / 'out.jxl').stat().st_size},")

‎src/codecs/jxl.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { decodeFrame, DecoderState, type FrameThreads } from './jxl/frame.ts';
1616
import { ColorOutput } from './jxl/color.ts';
1717
import { ToneMapping } from './hdr.ts';
1818
import { encodeRGBA } from './jxl/fast-lossless.ts';
19+
import { encodeModular } from './jxl/modular-lossless.ts';
1920
import { encodeVarDCT } from './jxl/vardct-enc.ts';
2021
import { concat } from '../util/mem.ts';
2122
import { srgbDecode, type Curve } from '../util/icc.ts';
@@ -33,7 +34,8 @@ export function isJXL(d: Uint8Array): boolean {
3334
* codestream, in the container if there is Exif data (a TIFF header and IFDs); lossy with up to threads worker threads
3435
*/
3536
export async function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0, effort = 1, threads = 1): Promise<Uint8Array> {
36-
const stream = distance > 0 ? await encodeVarDCT(rgba, width, height, { distance, effort, threads }) : encodeRGBA(rgba, width, height);
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);
3739
if (!exif?.length) return stream;
3840
// (ftyp: brand jxl, minor version 0, compatible jxl; Exif: the offset of the TIFF header, 0, then the TIFF data)
3941
const jxl = [0x6a, 0x78, 0x6c, 0x20];

‎src/codecs/jxl/entropy-enc.ts‎

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ const token = (v: number): number => {
3939
return SPLIT + ((n - SPLIT_EXPONENT) << (MSB + LSB)) + ((m >>> (n - MSB)) << LSB) + (m & ((1 << LSB) - 1));
4040
};
4141

42+
/** The number of tokens (of 32-bit values) */
43+
export const MAX_TOKEN = token(0xffffffff) + 1;
44+
45+
/** Counts the tokens of values (from to to) by context, into counts (MAX_TOKEN per context) */
46+
export function countTokens(counts: Float64Array, ctx: Uint32Array, value: Uint32Array, from: number, to: number): void {
47+
for (let i = from; i < to; i++) counts[ctx[i] * MAX_TOKEN + token(value[i])]++;
48+
}
49+
4250
// ---- ANS
4351

4452
const ANS_LOG_TAB_SIZE = 12, ANS_TAB_SIZE = 1 << ANS_LOG_TAB_SIZE, LOG_ALPHABET_SIZE = 8;
@@ -250,22 +258,19 @@ export class EntropyCode {
250258
private readonly ctxMap: Uint8Array;
251259
private readonly ans: AnsCode[];
252260

253-
constructor(streams: Tokens[], readonly numContexts: number, { maxClusters = 64 }: EntropyOptions = {}) {
254-
// the histograms of the contexts' tokens
255-
let alphabet = 1;
256-
for (const t of streams) {
257-
for (let i = 0; i < t.length; i++) {
258-
const tk = token(t.value[i]);
259-
if (tk + 1 > alphabet) alphabet = tk + 1;
260-
}
261-
}
262-
const hist = Array.from({ length: numContexts }, () => new Float64Array(alphabet)), totals = new Float64Array(numContexts);
263-
for (const t of streams) {
264-
for (let i = 0; i < t.length; i++) {
265-
hist[t.ctx[i]][token(t.value[i])]++;
266-
totals[t.ctx[i]]++;
267-
}
261+
/** the code of streams' tokens, or of their counts (see countTokens) */
262+
constructor(streams: Tokens[] | Float64Array, readonly numContexts: number, { maxClusters = 64 }: EntropyOptions = {}) {
263+
// the histograms of the contexts' tokens (up to the largest token used)
264+
let counts: Float64Array;
265+
if (streams instanceof Float64Array) counts = streams;
266+
else {
267+
counts = new Float64Array(numContexts * MAX_TOKEN);
268+
for (const t of streams) countTokens(counts, t.ctx, t.value, 0, t.length);
268269
}
270+
let alphabet = 1;
271+
for (let i = 0; i < counts.length; i++) if (counts[i] && i % MAX_TOKEN + 1 > alphabet) alphabet = i % MAX_TOKEN + 1;
272+
const hist = Array.from({ length: numContexts }, (_, c) => counts.slice(c * MAX_TOKEN, c * MAX_TOKEN + alphabet));
273+
const totals = Float64Array.from(hist, h => h.reduce((sum, v) => sum + v, 0));
269274
this.ctxMap = new Uint8Array(numContexts);
270275
const clusters = cluster(hist, totals, Math.min(maxClusters, 255, numContexts), this.ctxMap).map(h => {
271276
let size = h.length;
@@ -293,29 +298,45 @@ export class EntropyCode {
293298

294299
/** writes the tokens of a stream */
295300
writeTokens(w: BitWriter, t: Tokens): void {
296-
const map = this.ctxMap;
297-
// ANS: the tokens in reverse, the 16 bits of each renormalization read after the token's symbol (before its
298-
// raw bits); the final state first
299-
const codes = this.ans, n = t.length, chunks = new Int32Array(n);
300-
let state = 0x130000;
301-
for (let i = n - 1; i >= 0; i--) {
302-
const code = codes[map[t.ctx[i]]], tk = token(t.value[i]), freq = code.freq[tk];
303-
chunks[i] = -1;
304-
if (state >>> 20 >= freq) {
305-
chunks[i] = state & 0xffff;
306-
state >>>= 16;
307-
}
308-
const v = Math.floor(state / freq);
309-
state = v * ANS_TAB_SIZE + code.reverse[tk][state - v * freq];
301+
writeTokens(w, this.tables(), t.ctx, t.value, 0, t.length);
302+
}
303+
304+
/** what writing tokens needs (for threads, which get copies) */
305+
tables(): CodeTables {
306+
return { ctxMap: this.ctxMap, freq: this.ans.map(c => c.freq), reverse: this.ans.map(c => c.reverse) };
307+
}
308+
}
309+
310+
/** An entropy code's tables for writing tokens: the context map, per cluster the symbols' frequencies and table slots */
311+
export interface CodeTables {
312+
ctxMap: Uint8Array;
313+
freq: Int32Array[];
314+
reverse: Int32Array[][];
315+
}
316+
317+
/** Writes tokens (from to to) with a code */
318+
export function writeTokens(w: BitWriter, { ctxMap: map, freq: freqs, reverse }: CodeTables, ctx: Uint32Array, value: Uint32Array, from: number, to: number): void {
319+
// ANS: the tokens in reverse, the 16 bits of each renormalization read after the token's symbol (before its raw
320+
// bits); the final state first
321+
const chunks = new Int32Array(to - from);
322+
let state = 0x130000;
323+
for (let i = to - 1; i >= from; i--) {
324+
const c = map[ctx[i]], tk = token(value[i]), freq = freqs[c][tk];
325+
chunks[i - from] = -1;
326+
if (state >>> 20 >= freq) {
327+
chunks[i - from] = state & 0xffff;
328+
state >>>= 16;
310329
}
311-
w.write(32, state);
312-
for (let i = 0; i < n; i++) {
313-
if (chunks[i] >= 0) w.write(16, chunks[i]);
314-
const v = t.value[i];
315-
if (v >= SPLIT) {
316-
const nbits = 31 - Math.clz32(v) - MSB - LSB;
317-
w.write(nbits, (v >>> LSB) & ((1 << nbits) - 1));
318-
}
330+
const v = Math.floor(state / freq);
331+
state = v * ANS_TAB_SIZE + reverse[c][tk][state - v * freq];
332+
}
333+
w.write(32, state);
334+
for (let i = from; i < to; i++) {
335+
if (chunks[i - from] >= 0) w.write(16, chunks[i - from]);
336+
const v = value[i];
337+
if (v >= SPLIT) {
338+
const nbits = 31 - Math.clz32(v) - MSB - LSB;
339+
w.write(nbits, (v >>> LSB) & ((1 << nbits) - 1));
319340
}
320341
}
321342
}

‎src/codecs/jxl/fast-lossless.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,8 +694,8 @@ export function encode(px: Uint8Array, width: number, height: number, numChans:
694694

695695
// ---- headers
696696

697-
// a regular modular frame: one pass, no filters, replacing, the last one
698-
function frameHeader(w: BitWriter, alpha: boolean): void {
697+
/** A regular modular frame: one pass, no filters, replacing, the last one */
698+
export function frameHeader(w: BitWriter, alpha: boolean): void {
699699
w.write(1, 0);
700700
w.write(2, 0b00);
701701
w.write(1, 1);

0 commit comments

Comments
 (0)