Skip to content

Commit 60a1f7e

Browse files
matmenclaude
andcommitted
Lossy JPEG XL encoder: varblock search, chroma from luma, effort
encodeJXL({ quality, effort }): effort 1 (the default) chooses the varblocks like cjxl -e 5 (libjxl's ProcessRectACS: the best 8x8 transform of each block, merged up to 64x64 where the estimated cost is lower, with the masking of libjxl's initial quantization field), 2 also tries the 4x8 and AFV transforms, 0 keeps 8x8 DCTs. Effort 1 and 2 also compute chroma from luma per 64x64 tile (libjxl's fast least squares). Forward transforms for every varblock type (transforms-enc.ts): the decoder's DCTs forward, unrolled up to 64 points (scripts/jxl-fdct.py, and idct64 for the decoder), libjxl's DCT2X2, DCT4X4 and IDENTITY, the other 8x8 ones by inverting their inverse as a sparse matrix; all roundtrip within 1e-7. Quantization, LF and token writing handle any varblock (its order, contexts, non-zero prediction). Measured on 4 images x 5 distances (BD-rate by SSIMULACRA2 / PSNR / butteraugli): effort 1 vs cjxl -e 3 -5.6 / -6.0 / -11.4%, vs -e 5 -1.5 / +0.9 / +4.4%; effort 2 vs -e 5 -3.6%, vs -e 7 +1.3% (SSIMULACRA2). libjxl's adaptive quantization of the HF, AdjustQuantBlockAC and Gaborish were tried and made files larger at equal SSIMULACRA2. 1920x1080 photo: ~1 s at effort 0, ~5 s at 1, ~10 s at 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent ebb0742 commit 60a1f7e

16 files changed

Lines changed: 2865 additions & 94 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
### Features
66

7-
- `Image.encodeJXL({ quality })` encodes lossy JPEG XLs (VarDCT): `quality` (0-100) maps to the Butteraugli distance
8-
like `cjxl -q` (90 is cjxl's default, 100 is lossless); without it, images are encoded losslessly as before. The
9-
encoder follows libjxl's effort 3: XYB, 8x8 DCTs, libjxl's quantization, the weighted predictor for the LF, ANS,
10-
the edge preserving filter. Its files are within about 1-3% of `cjxl -e 3` at the same PSNR; a 1920x1080 photo
11-
takes about 0.9 s. Alpha is lossless.
7+
- `Image.encodeJXL({ quality, effort })` encodes lossy JPEG XLs (VarDCT): `quality` (0-100) maps to the Butteraugli
8+
distance like `cjxl -q` (90 is cjxl's default, 100 is lossless); without it, images are encoded losslessly as
9+
before. The encoder follows libjxl: XYB, the varblock sizes and transforms of libjxl's search (`effort` 1, the
10+
default, like `cjxl -e 5`; 2 also tries the 4x8 and AFV transforms; 0: 8x8 DCTs only, like `cjxl -e 3`), chroma
11+
from luma per 64x64 tile, libjxl's quantization, the weighted predictor for the LF, ANS, the edge preserving
12+
filter. Alpha is lossless. At the same SSIMULACRA2 score its files are about 1.5% smaller than `cjxl -e 5`'s at
13+
effort 1 (3.5% at effort 2) and within 1-4% of `cjxl -e 7`'s (up to 9% for screenshots); effort 0 is within 1.5%
14+
of `cjxl -e 3`. A 1920x1080 photo takes about 1 s at effort 0, 5 s at effort 1, 10 s at effort 2.
1215

1316
### Fixes
1417

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
6969
byte like libwebp's cwebp)
7070
, [AVIFs](https://imagescript.matmen.dev/classes/Image.html#encodeavif) (lossy and lossless, with alpha)
7171
, [JPEG XLs](https://imagescript.matmen.dev/classes/Image.html#encodejxl) (lossless byte for byte like libjxl's
72-
`cjxl -e 1`, lossy about as small as `cjxl -e 3`)
72+
`cjxl -e 1`, lossy about as small as `cjxl -e 5`)
7373
, [BMPs](https://imagescript.matmen.dev/classes/Image.html#encodebmp)
7474
, [QOIs](https://imagescript.matmen.dev/classes/Image.html#encodeqoi)
7575
, [Netpbm images](https://imagescript.matmen.dev/classes/Image.html#encodepnm) (PBM, PGM, PPM or PAM, whichever is
@@ -130,7 +130,7 @@ the text shaping is ported from [HarfBuzz](https://github.com/harfbuzz/harfbuzz)
130130
[dav1d](https://code.videolan.org/videolan/dav1d), AVIFs are converted to RGB like
131131
[libavif](https://github.com/AOMediaCodec/libavif) does, the JPEG XL decoder is ported from
132132
[jxl-oxide](https://github.com/tirr-c/jxl-oxide) and [libjxl](https://github.com/libjxl/libjxl), the JPEG XL encoders
133-
follow libjxl's fast lossless encoder and its effort 3, the AV1 encoder mirrors the decoder and uses libaom's symbol encoder.
133+
follow libjxl's fast lossless encoder and its lossy heuristics, the AV1 encoder mirrors the decoder and uses libaom's symbol encoder.
134134
See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for their licenses.
135135

136136
---

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,8 @@ DEALINGS IN THE SOFTWARE.
291291

292292
The Modular decoding, the rendering pipeline, blending, the XYB conversion and the rendering of patches, splines and
293293
noise follow libjxl, the JPEG XL reference implementation, the lossless encoder is ported from its fast lossless
294-
encoder, the lossy encoder follows its effort 3 (heuristics, quantization, the ANS histograms, fixed trees and
295-
clustering); tests/jxl includes files of its conformance suite. JPEG XL
294+
encoder, the lossy encoder follows its heuristics (the varblock search with its adaptive quantization field,
295+
chroma from luma, quantization, the ANS histograms, fixed trees and clustering, the forward transforms); tests/jxl includes files of its conformance suite. JPEG XL
296296
is covered by Google's patent grant for libjxl (the "Additional IP Rights Grant" in libjxl's PATENTS file).
297297

298298
```

‎scripts/jxl-fdct.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Writes src/codecs/jxl/fdct.ts: the forward 8- to 64-point DCTs of src/codecs/jxl/dct.ts's recursion as
2+
# straight-line code (the same operations in the same order, on locals instead of scratch arrays):
3+
# python scripts/jxl-fdct.py > src/codecs/jxl/fdct.ts
4+
counter = [0]
5+
lines = []
6+
def tmp(expr):
7+
counter[0] += 1
8+
name = f't{counter[0]}'
9+
lines.append(f' const {name} = {expr};')
10+
return name
11+
12+
def fdct(vals, n):
13+
# vals: list of expressions (locals) of length n; returns list of result names
14+
if n == 4:
15+
i0, i1, i2, i3 = vals
16+
s03 = tmp(f'{i0} + {i3}'); s12 = tmp(f'{i1} + {i2}')
17+
t0 = tmp(f'({i0} - {i3}) * SEC0'); t1 = tmp(f'({i1} - {i2}) * SEC1')
18+
o0 = tmp(f'({t0} + {t1}) / 4'); o1 = tmp(f'({t0} - {t1}) / 4')
19+
return [tmp(f'({s03} + {s12}) / 4'), tmp(f'{o0} * Math.SQRT2 + {o1}'), tmp(f'({s03} - {s12}) / 4'), o1]
20+
half = n // 2
21+
a = [tmp(f'({vals[i]} + {vals[n - i - 1]}) / 2') for i in range(half)]
22+
b = [tmp(f'({vals[i]} - {vals[n - i - 1]}) / 2 * SEC{n}[{i}]') for i in range(half)]
23+
a = fdct(a, half)
24+
b = fdct(b, half)
25+
# b[0] *= sqrt 2, then b[i] += b[i + 1] (ascending: each adds the original)
26+
b = list(b)
27+
b[0] = tmp(f'{b[0]} * Math.SQRT2')
28+
b = [tmp(f'{b[i]} + {b[i + 1]}') for i in range(half - 1)] + [b[half - 1]]
29+
out = [None] * n
30+
for i in range(half):
31+
out[2 * i] = a[i]
32+
out[2 * i + 1] = b[i]
33+
return out
34+
35+
print('''/*!
36+
* Generated by scripts/jxl-fdct.py: the forward 8- to 64-point DCTs of dct.ts, unrolled (its recursion is ported
37+
* from jxl-oxide, crates/jxl-render/src/vardct/generic/dct.rs; Copyright (c) Wonwoo Choi, MIT License, see
38+
* THIRD_PARTY_NOTICES.md)
39+
*/
40+
41+
const SEC0 = 0.5411961, SEC1 = 1.306563;
42+
// 1 / (2 cos((2k + 1) pi / 2n))
43+
const secants = (n: number): Float64Array => Float64Array.from({ length: n / 2 }, (_, k) => 1 / Math.cos((2 * k + 1) / (2 * n) * Math.PI) / 2);
44+
const SEC8 = secants(8), SEC16 = secants(16), SEC32 = secants(32), SEC64 = secants(64);
45+
''')
46+
for n in (8, 16, 32, 64):
47+
counter[0] = 0; lines.clear()
48+
vals = [f'v{i}' for i in range(n)]
49+
out = fdct(vals, n)
50+
print(f'// the forward {n}-point DCT of buf[o + k * step] in place (generated: dct\'s recursion, unrolled)')
51+
print(f'export function fdct{n}(buf: Float32Array | Float64Array, o: number, step: number): void {{')
52+
for i in range(n):
53+
print(f' const v{i} = buf[o + {i} * step];' if i else ' const v0 = buf[o];')
54+
for l in lines: print(l)
55+
for i in range(n):
56+
print(f' buf[o + {i} * step] = {out[i]};' if i else f' buf[o] = {out[0]};')
57+
print('}')
58+
print()

‎scripts/jxl-idct.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Writes src/codecs/jxl/idct.ts: the inverse 16- and 32-point DCTs of src/codecs/jxl/dct.ts's recursion as straight-line
1+
# Writes src/codecs/jxl/idct.ts: the inverse 16-, 32- and 64-point DCTs of src/codecs/jxl/dct.ts's recursion as straight-line
22
# code (the same operations in the same order, on locals instead of scratch arrays):
33
# python scripts/jxl-idct.py > src/codecs/jxl/idct.ts
44
counter = [0]
@@ -36,17 +36,17 @@ def idct(vals, n, secname):
3636
return out
3737

3838
print('''/*!
39-
* Generated by scripts/jxl-idct.py: the inverse 16- and 32-point DCTs of dct.ts, unrolled (its recursion is ported from
39+
* Generated by scripts/jxl-idct.py: the inverse 16-, 32- and 64-point DCTs of dct.ts, unrolled (its recursion is ported from
4040
* jxl-oxide, crates/jxl-render/src/vardct/generic/dct.rs; Copyright (c) Wonwoo Choi, MIT License, see
4141
* THIRD_PARTY_NOTICES.md)
4242
*/
4343
4444
const SEC0 = 0.5411961, SEC1 = 1.306563;
4545
// 1 / (2 cos((2k + 1) pi / 2n))
4646
const secants = (n: number): Float64Array => Float64Array.from({ length: n / 2 }, (_, k) => 1 / Math.cos((2 * k + 1) / (2 * n) * Math.PI) / 2);
47-
const SEC8 = secants(8), SEC16 = secants(16), SEC32 = secants(32);
47+
const SEC8 = secants(8), SEC16 = secants(16), SEC32 = secants(32), SEC64 = secants(64);
4848
''')
49-
for n in (16, 32):
49+
for n in (16, 32, 64):
5050
counter[0] = 0; lines.clear()
5151
vals = [f'v{i}' for i in range(n)]
5252
out = idct(vals, n, f'SEC{n}')

‎src/codecs/jxl.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ export function isJXL(d: Uint8Array): boolean {
2424
}
2525

2626
/**
27-
* Encodes RGBA pixels as a JPEG XL, lossless or lossy (with a Butteraugli distance above 0): the codestream, in the
28-
* container if there is Exif data (a TIFF header and IFDs)
27+
* Encodes RGBA pixels as a JPEG XL, lossless or lossy (with a Butteraugli distance above 0, and an effort 0-2): the
28+
* codestream, in the container if there is Exif data (a TIFF header and IFDs)
2929
*/
30-
export function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0): Uint8Array {
31-
const stream = distance > 0 ? encodeVarDCT(rgba, width, height, { distance }) : encodeRGBA(rgba, width, height);
30+
export function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0, effort = 1): Uint8Array {
31+
const stream = distance > 0 ? encodeVarDCT(rgba, width, height, { distance, effort }) : encodeRGBA(rgba, width, height);
3232
if (!exif?.length) return stream;
3333
// (ftyp: brand jxl, minor version 0, compatible jxl; Exif: the offset of the TIFF header, 0, then the TIFF data)
3434
const jxl = [0x6a, 0x78, 0x6c, 0x20];

0 commit comments

Comments
 (0)