Skip to content

Commit eaf5221

Browse files
matmenclaude
andcommitted
Encode lossless JPEG XL: libjxl's fast lossless encoder, like cjxl -e 1
Image.encodeJXL ports libjxl's fast lossless encoder (enc_fast_lossless.cc) for 8-bit images, with libjxl's image header: the codestream is byte for byte what cjxl -e 1 writes on CPUs with AVX2 or NEON (chunks of 16 pixels; the tests also check the chunks of 8 of builds without SIMD). Palettes are sorted like libstdc++'s std::sort, so ties end up the same. Unlike libjxl, colors whose hashes collide within one chunk of the palette detection fall back to YCoCg (libjxl loses one of them). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 8df7364 commit eaf5221

8 files changed

Lines changed: 969 additions & 3 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 3.5.0
4+
5+
### Features
6+
7+
- `Image.encodeJXL()` encodes lossless JPEG XLs: ported from libjxl's fast lossless encoder, the codestream is byte for
8+
byte what `cjxl -e 1` writes on CPUs with AVX2 or NEON (libjxl's output depends on the CPU: it works in chunks of 16
9+
pixels there, of 8 without SIMD, of 32 with AVX-512). Images of at most 511 colors besides black get a palette,
10+
others YCoCg; greyscale images are encoded as one channel, alpha only if the image has transparency. Unlike libjxl,
11+
two colors whose hashes collide within 8 pixels don't end up as one (libjxl then writes a palette that loses one of
12+
them; such images are encoded with YCoCg instead).
13+
14+
### Performance
15+
16+
- JPEG XL decoding is about 25% faster: lossy 2048x1536 images take about 0.87 s instead of 1.2 s, lossless ones of
17+
the default effort 2.6 s instead of 3.7 s. The output is the same, except for rounding: linear light becomes sRGB
18+
like libjxl does (a rational polynomial of the square root instead of a power).
19+
320
## 3.4.0
421

522
### Features

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
6767
, [JPEGs](https://imagescript.matmen.dev/classes/Image.html#encodejpeg)
6868
, lossless and lossy [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp) (lossy ones byte for
6969
byte like libwebp's cwebp)
70+
, lossless [JPEG XLs](https://imagescript.matmen.dev/classes/Image.html#encodejxl) (byte for byte like libjxl's
71+
`cjxl -e 1`)
7072
, [BMPs](https://imagescript.matmen.dev/classes/Image.html#encodebmp)
7173
, [QOIs](https://imagescript.matmen.dev/classes/Image.html#encodeqoi)
7274
, [Netpbm images](https://imagescript.matmen.dev/classes/Image.html#encodepnm) (PBM, PGM, PPM or PAM, whichever is
@@ -126,7 +128,8 @@ the text shaping is ported from [HarfBuzz](https://github.com/harfbuzz/harfbuzz)
126128
[Unicode Character Database](https://www.unicode.org/ucd/). The AV1 decoder is ported from
127129
[dav1d](https://code.videolan.org/videolan/dav1d), AVIFs are converted to RGB like
128130
[libavif](https://github.com/AOMediaCodec/libavif) does, the JPEG XL decoder is ported from
129-
[jxl-oxide](https://github.com/tirr-c/jxl-oxide) and [libjxl](https://github.com/libjxl/libjxl).
131+
[jxl-oxide](https://github.com/tirr-c/jxl-oxide) and [libjxl](https://github.com/libjxl/libjxl), the JPEG XL encoder
132+
from libjxl's fast lossless encoder.
130133
See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for their licenses.
131134

132135
---

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ following projects, under their own licenses. Each derived file says so in its h
1616
| [dav1d](https://code.videolan.org/videolan/dav1d) by VideoLAN, the dav1d authors and Two Orioles | `src/codecs/av1/*` (the AV1 decoder: bitstream parsing, symbol decoding, prediction, inverse transforms, loop filters, film grain; the tables in `src/codecs/av1/data.ts`, generated from dav1d by `scripts/av1-tables.c`) | BSD-2-Clause |
1717
| [libavif](https://github.com/AOMediaCodec/libavif) by Joe Drago | `src/codecs/avif.ts` (YUV to RGB conversion with bilinear chroma upsampling, alpha range expansion and unpremultiplication) | BSD-2-Clause |
1818
| [jxl-oxide](https://github.com/tirr-c/jxl-oxide) by Wonwoo Choi | `src/codecs/jxl/*` (the JPEG XL decoder: bit reader, header and frame header fields, entropy decoding, VarDCT, dequantization matrices, transforms, restoration filters, upsampling, the parsing of patches, splines and noise) | MIT (or Apache-2.0) |
19-
| [libjxl](https://github.com/libjxl/libjxl) by the JPEG XL Project Authors | `src/codecs/jxl/modular.ts` (Modular decoding: MA trees, predictors, the self-correcting predictor, transforms), `src/codecs/jxl/frame.ts` (the sections of frames, the order of the rendering stages, reference frames), `src/codecs/jxl/blend.ts`, `src/codecs/jxl/color.ts` (XYB to RGB), `src/codecs/jxl/features.ts` (rendering patches, splines and noise) | BSD-3-Clause |
19+
| [libjxl](https://github.com/libjxl/libjxl) by the JPEG XL Project Authors | `src/codecs/jxl/modular.ts` (Modular decoding: MA trees, predictors, the self-correcting predictor, transforms), `src/codecs/jxl/frame.ts` (the sections of frames, the order of the rendering stages, reference frames), `src/codecs/jxl/blend.ts`, `src/codecs/jxl/color.ts` (XYB to RGB), `src/codecs/jxl/features.ts` (rendering patches, splines and noise), `src/codecs/jxl/fast-lossless.ts` (the lossless encoder: palette detection, YCoCg, the gradient predictor, prefix codes, runs as LZ77, the headers) | BSD-3-Clause |
2020
| [JPEG XL conformance](https://github.com/libjxl/conformance) by the JPEG XL Project Authors | `tests/jxl/alpha-*.jxl`, `animation-spline.jxl`, `blendmodes.jxl`, `mul-no-extra-channels.jxl` (test files) | BSD-3-Clause |
2121
| [FreeType](https://freetype.org) | `src/render/hinting.ts` (behavior and parts of the logic of the TrueType interpreter) | FreeType License |
2222
| [QOI](https://github.com/phoboslab/qoi) by Dominic Szablewski | `src/codecs/qoi.ts` (the reference encoder's choices of operations) | MIT |
@@ -254,7 +254,8 @@ DEALINGS IN THE SOFTWARE.
254254
## libjxl, JPEG XL conformance
255255

256256
The Modular decoding, the rendering pipeline, blending, the XYB conversion and the rendering of patches, splines and
257-
noise follow libjxl, the JPEG XL reference implementation; tests/jxl includes files of its conformance suite. JPEG XL
257+
noise follow libjxl, the JPEG XL reference implementation, and the lossless encoder is ported from its fast lossless
258+
encoder; tests/jxl includes files of its conformance suite. JPEG XL
258259
is covered by Google's patent grant for libjxl (the "Additional IP Rights Grant" in libjxl's PATENTS file).
259260

260261
```

‎scripts/jxl-lossless-references.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# 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
3+
# opaque). libjxl's output depends on the CPU: with AVX2 or NEON it works in chunks of 16 pixels (what encodeJXL does),
4+
# without SIMD in chunks of 8 (the test checks these too), with AVX-512 in chunks of 32. Needs Pillow and cjxl:
5+
# CJXL=path/to/cjxl python scripts/jxl-lossless-references.py
6+
import hashlib, os, subprocess, tempfile
7+
from pathlib import Path
8+
import numpy as np
9+
from PIL import Image
10+
11+
root = Path(__file__).resolve().parent.parent
12+
CJXL = os.environ.get('CJXL', 'cjxl')
13+
tmp = Path(tempfile.mkdtemp())
14+
15+
16+
def images():
17+
for name in ['external.png', 'circle.png', 'readme.png', 'potato.png']:
18+
yield name, np.asarray(Image.open(root / 'tests' / 'targets' / name).convert('RGBA'))
19+
# (grey: the red channel)
20+
red = np.asarray(Image.open(root / 'tests' / 'targets' / 'external.png').convert('RGBA'))[..., 0]
21+
yield 'external.png grey', np.dstack([red, red, red, np.full_like(red, 255)])
22+
23+
24+
for name, rgba in images():
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+
subprocess.run([CJXL, '--quiet', str(tmp / 'in.png'), str(tmp / 'out.jxl'), '-d', '0', '-e', '1'], check=True)
32+
print(f" '{name}': '{hashlib.sha256((tmp / 'out.jxl').read_bytes()).hexdigest()}',")

‎src/codecs/jxl.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { frameHeader, toc } from './jxl/frame-header.ts';
1010
import { decodeFrame, DecoderState } from './jxl/frame.ts';
1111
import { ColorOutput } from './jxl/color.ts';
1212
import { ToneMapping } from './hdr.ts';
13+
export { encodeRGBA as encode } from './jxl/fast-lossless.ts';
1314

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

0 commit comments

Comments
 (0)