rusty_jpeg 0.3.2

Pure-Rust JPEG/MJPEG decoder and encoder: baseline + progressive, planar YUV in/out, AVX2/NEON kernels, box-averaged chroma, opt-in trellis. At parity with FFmpeg 8.1.2 on encode and decode at matched output size.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# Changes since vendoring


Everything this fork does differently from upstream `jpeg-decoder` 0.3.2 and
`jpeg-encoder` 0.7.0. Keep this current — it is what makes re-syncing with
upstream possible.

## 0.3.2


Cleanup release. No behaviour change — output is byte-identical across default,
`-optimize_huffman`, `-progressive` and `-trellis`.

- **Removes the measurement scaffolding** built during the optimization campaign:
  the `RUSTY_JPEG_ABLATE` stage-ablation knobs, the `RUSTY_JPEG_DOUBLE`
  stage-pricing probes, the `RUSTY_JPEG_ARM` A/B arms for work that has landed,
  and the campaign-only counters. The findings are recorded in `WHYS.md`; the
  switches were only ever there to produce them.
- **Keeps every scalar twin.** Those are the correctness oracles the SIMD tests
  assert against, and the fallbacks for non-x86 targets — they stay in the tree.
- **README rebuilt** around what the crate does now: performance, the compression
  options and their real costs, and how correctness is gated. The per-kernel
  performance narrative is gone; it documented the journey rather than the
  product.

## 0.3.1


README only — no code change. crates.io bakes the README into the published
version, so 0.3.0 shipped a page that described the wrong measurement method and
had two sections missing.

- The methodology prose said "best-of-N with the arms alternated". These are
  **paired win-rates with z-scores at N >= 31**, which is a different estimator —
  and best-of-N is precisely the one that produced the retracted 1.19-1.45x
  claims. Now stated correctly, with the N-trend that shows why it matters.
- The content description applied the decode fixture to both halves. Encode is
  measured on a 40-frame 1080p clip at ~680 KB/frame; decode on a 1/f-fractal
  still stream-copied to 300 frames. Two fixtures on purpose — and mislabelling
  them is the exact error that let a trellis cost figure ship 46x wrong.
- **Restores the "Compression, not just speed" and speed-provenance sections**,
  lost to a regex edit in the 0.2.x series. The compression story — chroma
  box-averaging at -17.12% BD-rate, and the two opt-in size/speed trades — is
  arguably this crate's better claim, and it had silently vanished from the
  front page.

## 0.3.0


Two byte-identical encoder wins — and a corrected standing that is the reason
for the minor bump.

- **AC coefficients are found with a SIMD mask, not a branch per coefficient.**
  Counts showed the loop visiting all 63 AC positions to locate the ~16% that
  are non-zero, and that scan alone measured ~12-13% of whole encode — about a
  third of all entropy cost. `write_ac_block` now builds a 64-bit non-zero mask
  with one AVX2 compare and steps the set bits with `trailing_zeros`.
  **1.28x faster whole encode (25 pairs, z 3.36).**

- **SIMD block extraction.** `get_block` was the #2 stage at ~12%. The luma path
  (two thirds of 4:2:0 blocks) is now one 8-byte load + widen + subtract per row;
  the 4:2:0 chroma path uses `maddubs` for the box filter's horizontal sums.
  **1.25x faster whole encode (25 pairs, z 4.20)**, and the stage's own share
  fell to the null floor.

  Both gated byte-for-byte against their scalar oracles — 81 encodes across 9
  geometries, 3 subsamplings and 3 qualities for the block extractor; baseline,
  progressive and both Huffman modes for the AC scan.

- **The FFmpeg comparison is corrected to PARITY.** Encode median **0.96x**
  (paired interleaved, N=41, z -3.59, matched output size with ours 3.1%
  smaller); decode median **1.02x** (N=31, z -0.18, inside noise).

  0.1.x through 0.2.x claimed 1.19-1.45x faster. Those were small-N artifacts:
  the same comparison read 1.45x at N=15, 1.03x at N=21 and 0.96x at N=41. The
  codec genuinely improved — the two wins above are same-binary, byte-identical
  and high-z — but it sits at parity with FFmpeg rather than ahead, and that is
  what the README now says.

## 0.2.3


**Trellis quantization is now OFF by default.** It shipped on in 0.1.7 through
0.2.2, and that was a mistake built on a bad measurement.

The published cost was "+3.1% encode time". Measured on real 1080p footage it is
**+144%** — 781 → 2062 ms pinned CPU over 40 frames. The +3.1% figure came from
this crate's own synthetic corpus at ~223 KB/frame; trellis work is
O(non-zero coefficients) per block in f64 arithmetic, so material with real
detail (~700 KB/frame here) costs vastly more. The corpus was chosen to exercise
chroma QUALITY and was never re-validated for COST.

That single default is the whole reason the crate's "1.19x faster than FFmpeg"
claim stopped holding: at matched output size with trellis on, encode was ~3.2x
SLOWER than FFmpeg rather than faster.

With it off, and with the `get_block` interior fast path added since:

| arm | pinned CPU (40f, 1080p) | output |
|---|---:|---:|
| **rusty_jpeg (default)** | **781 ms** | 27,171,752 B |
| FFmpeg 8.1.2 `-threads 1` | 953 ms | 28,040,482 B |

**1.22× faster at matched output size, with our output 3.1% smaller** — so the
size match slightly favours FFmpeg. Better than the original 1.19x, because the
`get_block` fast path landed in between.

Trellis remains available and unchanged — `Encoder::set_trellis(true)`, or
`-trellis 1` on the CLI (which also fixes the flag never having been routed
through the CLI's option allowlist, so it had no effect there at all). Its
-2.51% BD-rate benefit comes from the same synthetic corpus whose cost figure was
46x off, so treat it as unverified on real footage until re-measured.

Both READMEs corrected. The old claim stated the pre-trellis SPEED alongside the
post-trellis QUALITY, a combination that was never true at once.

## 0.2.2


Completes the 0.2.1 fix and adds the corpus that should have caught it. Anyone
decoding untrusted or progressive JPEGs should take this over 0.2.1.

- **Undefined Huffman tables are now validated at SCAN-HEADER time**, once per
  scan, instead of being discovered three levels down mid-MCU. Which tables a
  scan needs depends on what it codes — a progressive DC-only scan needs no AC
  table, and a DC *refinement* scan reads raw bits and needs neither — so the
  check applies exactly the conditions under which each table is used. The error
  now names the offending table index instead of surfacing as a panic.

  This is **not progressive-specific**. `decode_block` is shared with the
  baseline path, so any scan that names a table slot no DHT defined could reach
  it; progressive merely makes that common, because progressive scans
  legitimately declare only DC or only AC. The three deep guards from 0.2.1 stay
  as defence in depth.

- **A malformed progressive frame header can no longer size an unbounded
  allocation.** Progressive keeps every coefficient of the image resident, so
  that buffer is sized straight from the SOF — and `decoding_buffer_size_limit`
  was only enforced in `decode_planes`, which runs *after* every scan, long
  after the allocation it is meant to bound. A fuzzer reached
  `malloc(8589934592)` — 8 GiB — from a small input. The limit is now applied
  before allocating, with checked arithmetic so the product cannot wrap.

**Measured, not asserted.** A 960-file corpus from libjpeg (progressive and
baseline, 4:4:4/4:2:2/4:2:0, greyscale, optimized and not, restart intervals,
sizes from 1x1 to 255x127), each decoded through both `decode()` and
`decode_planar()`:

| build | result |
|---|---|
| shipped 0.2.0 | **960 of 1920 decodes PANICKED** — every progressive file |
| this release | 1920 decoded, 0 panics |

With 60 mutations per file: 117,120 decodes, 31,276 successful, 85,844 cleanly
rejected, **0 panics**. The sweep ships as `examples/corpus_sweep.rs`.

Both new tests were verified to FAIL against the shipped source before being
kept.

## 0.2.1


**Fixes a panic on most real progressive JPEGs.** Present in 0.1.6, 0.1.7 and
0.2.0; anyone decoding progressive files should upgrade.

libjpeg, mozjpeg and Photoshop emit DHT segments **per scan**, so a progressive
file opens with a DC-only scan (`Ss=0, Se=0`) that names an AC table slot which
is not defined until later. An optimization had hoisted `ac_table.unwrap()` out
of the AC coefficient loop — and for a DC-only scan that loop never runs, so the
unwrap had never been reached before it was moved in front of it. Result:
`called Option::unwrap() on a None value` on the first scan.

The AC table is now resolved only when the scan actually codes AC coefficients,
and a file that genuinely codes AC without defining a table returns `Err` rather
than panicking. Two sibling `unwrap`s — the DC table, and the AC table in the
successive-approximation path — were the same hazard and are now errors too.

Why nothing caught it: **this crate cannot generate a file with the layout that
triggers it.** Our encoder writes all four Huffman tables up front and names an
AC table even on DC-only scans, so every fixture we produce has a defined table
there. The regression test therefore ships a real libjpeg-produced fixture, and
that fixture is now also a seed in both the robustness corpus and `fuzz/corpus/`
— its absence is precisely why 80,000 mutations over baseline-only seeds found
zero panics while this was live.

Verified: progressive decode is bit-for-bit as correct as baseline. Against
libjpeg on identical images, progressive and baseline agree to within 0.00 dB at
4:4:4, 4:2:2 and 4:2:0 — so the fix restores correctness, not merely silence.
(The absolute 25–27 dB figure at subsampled chroma is our box upsampler versus
libjpeg's triangular "fancy" upsampling; it affects baseline identically and is
unrelated.)

Reported from a document-processing workload, where progressive JPEGs are common
in scanned material.

## 0.2.0


**The first release in which every claim is verified by CI on every target it
claims.** No API change and no output change from 0.1.7 — decode hashes and
encoder RD curves are byte-identical — so this would be a patch release by
semver. It is a minor bump as a deliberate boundary: the 0.1.x line silently
changed encoder output twice (chroma box-averaging and trellis, both in 0.1.6),
and anyone pinned to `"0.1"` got different bytes from `cargo update` with no
signal. From here, output changes cross a version boundary users can see.

Fixes two build configurations that were broken in 0.1.6/0.1.7:

- **`--no-default-features --features std,platform_independent` did not
  compile.** The fused decode->IDCT path reached for `decode::arch` and an
  `unsafe` block, both of which that configuration removes — it drops the arch
  modules and `forbid(unsafe_code)`s. Guarding with a `None` was not enough:
  under that configuration the path must not EXIST, not merely be unreachable.

- **`cargo test` did not compile on aarch64.** `examples/entropy_probe.rs`
  defined `rdtsc()` behind `#[cfg(x86)]` and called it unconditionally, so the
  ARM job died building examples.

That second one matters more than it looks. The ARM CI job is the *only* thing
that certifies the NEON kernels, and it had never reached a single test — so
0.1.6 and 0.1.7 shipped NEON quantize and NEON forward DCT **enabled by default
on aarch64 with zero execution behind them**. They are now verified bit-exactly
against the scalar oracles on real ARM hardware:

```
test encode::fdct_simd::neon::tests::fdct_neon_matches_scalar ... ok
test encode::quantization::neon::tests::neon_matches_scalar   ... ok
```

Also clears the crate to `cargo clippy --all-targets -- -D warnings` and
`cargo fmt --check`. Where clippy was wrong for the context the allow is
documented rather than the code contorted — the worker enum's variants differ in
size by design, and the quantize/trellis loops index three arrays in lockstep.

All seven CI jobs green: Linux, Windows, macOS, aarch64 (NEON), aarch64
cross-compile, lint, and a short fuzz run.

## 0.1.7


The two items 0.1.6 deferred, both taken.

- **Per-coefficient magnitude lowering in the trellis.** Restricted to
  `|q| >= 2 -> |q| - 1`, which can never produce a zero — that restriction is
  what keeps every decision independent, since the set of non-zero positions is
  unchanged and no run-length moves. It matters most where EOB truncation was
  WORST: mean BD-rate **-1.88% -> -2.51%**, and the worst content (white noise)
  improves from **+3.83% to +1.40%**.

  | content   | EOB only | EOB+mag | mag alone |
  |-----------|---------:|--------:|----------:|
  | photo     |   -5.02% |  -5.70% |    -0.71% |
  | diagonal  |   -1.25% |  -0.58% |    +0.67% |
  | text      |   -0.49% |  -1.24% |    -0.75% |
  | noise     |   +3.83% |  +1.40% |    -2.39% |
  | gradient  |   -6.44% |  -6.41% |    +0.03% |

  A density-adaptive lambda was then built and **refuted in both directions**
  (tapering down: noise +1.40% -> +2.67%; tapering up: +12.07%). Flat lambda
  wins, so block density is not the axis that explains the noise loss. Removed
  rather than left switchable.

- **NEON forward DCT.** Not a transcription of the AVX2 kernel. The butterfly is
  written once over a five-method `Lanes` trait with two backends: a scalar
  model verified **bit-exactly on x86** against the reference DCT, and a NEON
  mapping of the same trait. The transform is verified on the machine it was
  written on; only fifteen one-line intrinsic bodies are certified by ARM CI.
  Ships with `RUSTY_JPEG_NEON_FDCT=0` as a runtime fallback.

The RD corpus grew from 3 content types to 6 (adds text, noise, gradient),
because a sign flip across two clips cannot support a dispatch rule.

## 0.1.6


Five codec improvements. The two bitstream-changing ones are gated on a corpus
BD-rate over 5 quality points and 3 content types, plus an ffmpeg round-trip.

- **Chroma is box-averaged when downsampling, not decimated.** `get_block` took
  only the top-left sample of each subsampling box and discarded the other
  three, aliasing every chroma frequency above the subsampled Nyquist into the
  baseband. The tell: on saturated chroma edges, PSNR sat FLAT at ~14.1 dB from
  quality 50 to 95 while the file grew 45 KB -> 83 KB. Error that does not
  respond to bitrate is not quantization error. **BD-rate -17.12% on chroma
  detail, -0.43% on photographic content, +2.30 dB on chroma edges** — better
  quality AND fewer bits, no content regressing.

- **Trellis quantization**, on by default. Chooses where each block's EOB falls
  using real Huffman code lengths and real squared error, instead of keeping
  every coefficient rounding produced. Lambda swept against the harness rather
  than asserted: **-3.14% BD-rate for +3.1% encode time**.

- **Interleaved scans with optimized Huffman tables.** Which encode route ran
  was decided purely on an internal memory budget, so `-optimize_huffman`
  emitted one scan PER COMPONENT at every practical resolution — a layout
  mainstream encoders never produce. Now always interleaved, without paying the
  streaming route's ~23% cost.

- **NEON quantize kernel for aarch64.** The encoder was AVX2-only, so ARM ran the
  scalar path and the crate's speed claim was silently x86-only. The forward DCT
  remains scalar on ARM — deliberately, rather than shipping a 500-line kernel
  that has never executed.

- **Fuzzing.** A `fuzz/` crate with three cargo-fuzz targets, plus deterministic
  robustness tests that run on stable in ordinary CI. Baseline: 80,000 malformed
  inputs, 38,192 decoded, 41,808 cleanly rejected, **zero panics**.

## 0.1.5


Housekeeping only, no behaviour change: drops the `Worker::fused_block` trait
method, which became dead when 0.1.4 started calling the concrete worker
directly. Internal — `Worker` is not public API. Kept as its own version so the
repository and the registry describe the same code.

## 0.1.4


- **The fused transform is reached through a concrete, inlinable call.** It was
  dispatched through `&mut dyn Worker` once per block — ~49k indirect calls per
  1080p frame, and an opaque boundary that stopped the DC-only test, the
  pair-holding logic and the IDCT dispatch from inlining into the decode loop.
  `Worker::as_immediate()` now resolves the synchronous worker **once per MCU**,
  so all six blocks of a 4:2:0 MCU reach the transform statically.

  Worth **5.4%** of whole-frame decode (18,296.9 → 17,312.5 ms over 3000 frames),
  the largest single decode win in this series. Output byte-identical.

Decode now measures **~1.04× faster than FFmpeg 8.1.2** (paired ABBA, N=15,
3000-frame arms, median 1.0390, byte-identical bitstream on both arms).

## 0.1.3


Decode plumbing. Output is byte-identical throughout — verified by whole-image
checksums on six fixtures (interleaved, non-interleaved, 4K, progressive, and
the multithreaded worker) plus the full suite.

- **Entropy decode is fused into the IDCT.** A baseline interleaved scan no
  longer accumulates a whole MCU row of coefficients before transforming it: each
  block is inverse-transformed the moment it is decoded, straight into the output
  plane, with one block held back so horizontally adjacent pairs still feed the
  two-block AVX2 kernel.

  The row buffer existed only so a row could be shipped to another *thread*. For
  a worker that consumes blocks synchronously it cost a full write → zero → read
  round trip of ~18.8 MB per 1080p frame, through a ~60 KB-per-row buffer too
  large for L1. The stage counters for that path now read **0 calls**.

  Progressive scans (which revisit blocks across scans) and non-interleaved
  streams (which index the buffer by position within a batch) keep the original
  path, and are verified by counter to fall back correctly.
- **The unused row buffer is no longer allocated** on a fused scan.
- No `Arc` refcount traffic on the per-block path: the quantization table is
  reached by a split borrow rather than a clone, which would otherwise put an
  atomic RMW on a path that runs ~49k times per 1080p frame.

Measured against FFmpeg 8.1.2, one pinned core, CPU time, 3000-frame arms at
matched work: **decode ~1.5–3% faster** (single-instrument ratio 1.032; paired
N=15 median 1.0148, inside noise — the effect is real but small enough that it
sits near this machine's resolution). Encode is unchanged at **1.19× faster** at
matched output size.

## 0.1.2


Performance and one real API defect. All changes gated on **unchanged decoded
output** (whole-image checksums on six fixtures) and the full suite.

- **AVX2 two-block IDCT.** The SSSE3 kernel widened to 256 bits, transforming
  two 8×8 blocks per instruction stream — block A in the low 128-bit lane, block
  B in the high one. Every operation involved is lane-independent (the
  arithmetic is elementwise; `unpack`/`packus` never cross the lane boundary, so
  the 8×8 transpose transposes each block separately), which makes the output
  **byte-identical to running the SSSE3 kernel twice** rather than merely close.
  A 64-round oracle test asserts exactly that, including a saturation round at
  `i16::MAX`/`i16::MIN`/`u16::MAX`. Worth **7.8%** of whole-frame decode; covers
  98.5% of blocks needing a full transform (the rest are DC-only and take a
  cheaper fill).
- **`set_single_threaded` actually works now.** It was a public API that did
  **nothing** on baseline JPEGs: the scan-loop call site hardcoded the
  multithreaded worker, and because the worker is cached on first use that site
  won. Nothing caught it because output is identical either way — only cost
  differs, and it differed a lot. The synchronous worker uses **~38% less CPU**,
  and enabling it also made `reclaim_buffer` live, so MCU-row coefficient
  buffers are recycled instead of reallocating ~6.3 MB per 1080p frame
  (`DecMcuRowAlloc` 5.82% → 1.57%). Regression-gated by
  `single_threaded_flag_selects_the_immediate_worker_on_baseline`, with the
  choice observable via `decode::last_worker_was_immediate()`.
- **DC-only fill split out** (`fill_dc_only`) so a caller that has already
  established a block has no AC energy takes the shortcut without rescanning all
  63 coefficients.
- Loop-invariant hoists on the fast-AC path (the `ac_lut` `Option` discriminant
  and `ac_table.unwrap()`), kept for being strictly less work — the speed effect
  measured inside the noise floor.
- New `counters` feature, split from `profile`: deterministic event counters
  (symbols, refills, LUT hits/misses, IDCT pairs). Separate on purpose —
  enabling both at once perturbed cycle counts 3×.

Measured against FFmpeg 8.1.2, one pinned core, CPU time, matched output size:
**encode 1.19× faster**, **decode at parity** (paired ABBA, N=21, inside noise).

## Vendoring (brick 1)


Mechanical only. **Gate: byte-identical encode and decode output vs the
pre-vendor build, verified on 4K and 8K frames.**

- `jpeg-decoder/src/``src/decode/`, `jpeg-encoder/src/``src/encode/`;
  each upstream `lib.rs` became the module's `mod.rs`.
- Intra-crate paths re-rooted (`crate::x``crate::decode::x` / `crate::encode::x`).
- Crate-level attributes moved to `src/lib.rs`. The encoder's `#![no_std]` was
  dropped (the merged crate is `std`); its `forbid(unsafe_code)`-unless-`simd`
  and the decoder's `deny(unsafe_code)` are preserved as module-level
  attributes, so the unsafe surface is unchanged.
- Upstream crate-level doc comments replaced with module docs (their doctests
  referenced test fixtures that were not vendored).
- The encoder's round-trip tests used `jpeg-decoder` as a dev-dependency; they
  now use `crate::decode`, making the round-trip oracle in-crate and permanent.
- Features renamed into one namespace. **`simd` is now ON by default** — this
  is the one behavioural change, and it is why encode output is byte-identical
  to the *simd-enabled* pre-vendor build rather than the workspace's previous
  scalar default.

## Planar Y'CbCr encode input (brick 2)


Added [`encode::PlanarYcbcrImage`] — an `ImageBuffer` over planar `yuv420p` /
`yuv422p` / `yuv444p` planes with per-plane strides.

JPEG *is* a Y'CbCr codec, so a planar video frame already sits in the right
colour space at the right chroma resolution. Encoding it via RGB, as callers
previously had to, converted colour twice and resampled chroma up and then back
down. Measured on a 4K frame: **488 ms → 245 ms** (2.0x) and the chroma
round-trip loss gone.

It is exactly lossless, and the reason is worth writing down: the encoder
subsamples in `get_block`, which **point-samples** — it takes every Nth sample
and does not average. So replicating each chroma sample `h`x`v` times in
`fill_buffers` is precisely undone. The one condition is that the encoder's
`SamplingFactor` matches the source layout, which is what
`PlanarYcbcrImage::sampling_factor()` returns.

> Note for future quality work: that point-sampling is also a real defect when
> the encoder genuinely has to downsample (a 4:4:4 source asked to emit 4:2:0).
> Dropping 3 of every 4 chroma samples without a low-pass aliases; libjpeg
> averages. Fixing it is a candidate for the chroma RD gap, but it changes
> output, so it needs the corpus gate.

Decode still returns interleaved RGB: the decoder runs its chroma upsampler
before colour conversion, so a planar decode path needs a tap ahead of the
upsampler. Not yet done.