Skip to content

Commit 0467587

Browse files
committed
fix: wire up optimize_scans in test_encoder's encode_rust()
encode_rust() was missing .optimize_scans(config.optimize_scans) in the Encoder builder chain. The Rust scan optimizer was never invoked during parity comparisons — the encoder always used the fixed 9-scan script. C's encoder correctly used its scan search, finding simpler scripts (4-5 scans, no successive approximation) at low quality. This made Rust appear 1-4% larger at Q10-Q50 when the actual optimizer works correctly. With the fix, Max Compression parity is within ±0.4% average at all quality levels. At low quality (Q10-Q50) Rust now produces smaller files than C.
1 parent 06beec3 commit 0467587

File tree

3 files changed

+60
-91
lines changed

3 files changed

+60
-91
lines changed

CLAUDE.md

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,21 @@ Reproduce: `cargo test --release --test parity_benchmark -- --nocapture`
8282
| Full Progressive | 85 | +0.00% | 0.35% |
8383
| Full Progressive | 90 | +0.08% | 0.34% |
8484
| Full Progressive | 95 | +0.13% | 0.40% |
85-
| Max Compression | 75 | +0.59% | 2.12% |
86-
| Max Compression | 85 | +0.41% | 1.25% |
87-
| Max Compression | 90 | +0.28% | 0.59% |
88-
| Max Compression | 95 | +0.40% | 0.81% |
85+
| Max Compression | 55 | -0.04% | 1.64% |
86+
| Max Compression | 65 | +0.14% | 0.97% |
87+
| Max Compression | 75 | +0.29% | 1.08% |
88+
| Max Compression | 85 | +0.36% | 0.87% |
89+
| Max Compression | 90 | +0.39% | 0.84% |
90+
| Max Compression | 95 | +0.28% | 0.64% |
8991

9092
**Configs:** Baseline = huffman opt only. +Trellis = AC trellis. Full = AC trellis + DC trellis + deringing. Max Compression = Full + `optimize_scans: true`. All others use `optimize_scans: false`. All use `force_baseline: true`.
9193

9294
**Key findings:**
9395
- With trellis at Q75, Rust produces **smaller** files than C (-0.15% to -0.24%)
9496
- Without trellis, consistent +0.21% gap from `fast-yuv` color conversion ±1 rounding
9597
- Without `optimize_scans`, all configs within ±0.25% average, worst-case per-image deviation under 1%
96-
- With `optimize_scans` (Max Compression), within +0.6% average — different scan search heuristics
98+
- With `optimize_scans` (Max Compression), within ±0.4% average, per-image max ~1.6%
99+
- Rust scan optimizer sometimes finds different local optima than C (different Al/freq split choices)
97100
- Visual quality equivalent (SSIMULACRA2 and Butteraugli verified)
98101

99102
**Mode explanations:**
@@ -248,24 +251,26 @@ cascade through DC differential encoding. Both produce visually identical images
248251

249252
### Known Issues / Active Investigations
250253

251-
#### File Size Gap with optimize_scans - FIXED ✅ (Dec 2025)
254+
#### File Size Gap with optimize_scans - FIXED ✅ (Feb 2026)
252255

253-
**Original symptom:** Rust produced ~2-4% larger files with `optimize_scans` enabled
254-
because refinement scans were trial-encoded independently, producing garbage sizes.
256+
**Original symptom:** Rust produced ~1-4% larger files with `optimize_scans` at low
257+
quality levels (Q10-Q50), with kodim23 showing +3.37% at Q40.
255258

256-
**Fix:** `ScanTrialEncoder` (`src/scan_trial.rs`) now encodes all 64 candidate scans
257-
sequentially with proper state tracking between scans. Each scan also builds its own
258-
optimal Huffman table via two-pass encoding (count + encode), matching C mozjpeg's
259-
per-scan Huffman behavior.
259+
**Root cause:** `encode_rust()` in `test_encoder.rs` was not passing `optimize_scans`
260+
to the `Encoder` builder chain. The Rust scan optimizer was never called — the encoder
261+
always used the fixed 9-scan script regardless of the `optimize_scans` flag. The C
262+
encoder correctly used its scan search to find simpler scripts (4-5 scans, no SA) at
263+
low quality.
260264

261-
**Result:** Max Compression mode matches C mozjpeg within ±0.15% at all quality levels.
262-
At Q75, Rust produces smaller files than C.
265+
**Fix:** Added `.optimize_scans(config.optimize_scans)` to `encode_rust()` builder chain
266+
in `src/test_encoder.rs`.
263267

264-
**Note (Feb 2025):** Previous results showed ±2.2% because the C test harness didn't
265-
explicitly disable `optimize_scans`. C mozjpeg's `JCP_MAX_COMPRESSION` default enables
266-
`optimize_scans=TRUE`, causing `jpeg_simple_progression()` to call
267-
`jpeg_search_progression()` and generate an optimized ~12-scan script, while Rust used
268-
the fixed 9-scan script. All C encoder wrappers now explicitly control `optimize_scans`.
268+
**Result:** Max Compression within ±0.4% average at all quality levels. At low quality
269+
(Q10-Q50) Rust is now **smaller** than C. Per-image max deviation ~1.6% (from different
270+
local optima in scan search, not a bug).
271+
272+
**Previous fixes (Dec 2025):** ScanTrialEncoder sequential encoding + per-scan Huffman.
273+
**Previous note (Feb 2025):** C test harness optimize_scans control.
269274

270275
#### AC Refinement Decoder Errors - FIXED ✅ (Dec 2024)
271276

scans-lq.md

Lines changed: 35 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,46 @@
1-
# Investigation: optimize_scans divergence at low quality
1+
# Investigation: optimize_scans divergence at low quality — RESOLVED
22

3-
## Problem
3+
## Root Cause
44

5-
The `Max Compression` config (`optimize_scans: true`) shows increasing file size gap
6-
between Rust and C at low quality levels. At Q40, it exceeds our 1% average / 3%
7-
per-image thresholds:
5+
`encode_rust()` in `src/test_encoder.rs` was missing `.optimize_scans(config.optimize_scans)`
6+
in the `Encoder` builder chain. The Rust scan optimizer was **never called** — the encoder
7+
always used the fixed 9-scan script regardless of the `optimize_scans` config flag.
88

9-
| Q | Avg Delta | Max Dev | Worst Image |
10-
|----|-----------|---------|-------------|
11-
| 40 | +1.11% | 3.37% | kodim23, kodim09 |
12-
| 50 | +0.77% | 3.13% | kodim23 |
13-
| 55 | +0.75% | 2.82% | kodim23 |
14-
| 65 | +0.70% | 2.74% | |
15-
| 75 | +0.59% | 2.12% | |
16-
| 85 | +0.41% | 1.25% | |
17-
| 90 | +0.28% | 0.59% | |
18-
| 95 | +0.40% | 0.81% | |
9+
Meanwhile, the C encoder correctly passed `optimize_scans` via FFI, so C's scan search
10+
found simpler, more efficient scripts at low quality (4-5 scans without successive
11+
approximation), while Rust always used the default 9-scan SA script.
1912

20-
Without `optimize_scans`, all configs are within ±0.7% average even at Q40.
21-
The gap is strictly in the scan optimization search.
13+
## Evidence
2214

23-
## Context
15+
Before fix (R=Rust optimize_scans, C=C optimize_scans):
16+
- R(optsc) == R(fixed) at ALL quality levels — Rust optimizer was never invoked
17+
- C correctly found smaller scripts at low Q (C saves 4.6% at Q10 vs fixed script)
2418

25-
`optimize_scans` tries multiple progressive scan configurations and picks the
26-
smallest. Both Rust and C implement this, but their scan search heuristics may
27-
differ. At low quality, more coefficients are quantized to zero, giving the
28-
optimizer a larger search space where different heuristics produce different
29-
local optima.
19+
After fix — Rust scan optimizer runs and finds similar scripts as C:
20+
```
21+
Q R(optsc) C(optsc) Δopt%
22+
10 183834 184696 -0.47% (was +4.18%)
23+
20 354621 357038 -0.68% (was +2.40%)
24+
30 507224 509658 -0.48% (was +1.54%)
25+
40 643993 645968 -0.31% (was +1.11%)
26+
50 769538 771715 -0.28% (was +0.77%)
27+
75 1263157 1259526 +0.29% (was +0.59%)
28+
85 1766757 1760481 +0.36% (was +0.41%)
29+
95 3218288 3209303 +0.28% (was +0.40%)
30+
```
3031

31-
## What to investigate
32+
At low quality, Rust is now **smaller** than C (the scan optimizer works well).
3233

33-
1. **Map the full curve.** Run Max Compression at Q10, Q20, Q25, Q30, Q35, Q40,
34-
Q45, Q50 on the Kodak corpus. Add a temporary `#[test]` or `#[ignore]` test
35-
to `parity_benchmark.rs` that only runs Max Compression across these qualities
36-
and prints per-image detail for each. Determine where the gap plateaus.
34+
## Fix
3735

38-
2. **Per-image scan counts.** For the worst images (kodim23, kodim09), compare
39-
the number of scans chosen by Rust vs C at Q40. Use `count_scans()` (pattern
40-
in `corpus_comparison.rs`). If scan counts differ, the search is finding
41-
fundamentally different scan scripts.
36+
One-line fix in `src/test_encoder.rs:134`:
37+
```rust
38+
.optimize_scans(config.optimize_scans)
39+
```
4240

43-
3. **Compare scan scripts directly.** Parse the SOS markers from both outputs
44-
and print `(Ns, comps, Ss, Se, Ah, Al)` for each scan. Pattern is in
45-
`corpus_comparison.rs::print_scan_details()`. Identify which scans differ.
41+
## Remaining Observations
4642

47-
4. **Trace the scan trial encoder.** The Rust implementation is in
48-
`src/scan_trial.rs`. The C implementation calls `jpeg_search_progression()`
49-
in `jcmaster.c`. Compare:
50-
- How many candidate scans are evaluated
51-
- The cost function (file size estimation)
52-
- The greedy selection order
53-
- Whether the trial encoder's Huffman table estimation matches C's
54-
55-
5. **Check if C uses `trellis_freq_split` during scan search.** C mozjpeg has
56-
`trellis_freq_split = 8` which splits AC trellis into low/high frequency
57-
passes. If C's scan optimizer accounts for this split during trial encoding
58-
but Rust doesn't, that could explain the gap at low quality where the split
59-
matters more.
60-
61-
6. **Kodim23 specifically.** This image consistently has the worst deviation.
62-
It's a landscape with lots of sky gradient + sharp foreground detail.
63-
Encode it standalone at Q40 with both, diff the scan scripts, and check
64-
if one finds genuinely smaller output or if it's a Huffman table estimation
65-
error in the trial encoder.
66-
67-
## Key files
68-
69-
- `src/scan_trial.rs` — Rust scan trial encoder
70-
- `src/progressive.rs` — Rust progressive scan generation
71-
- `tests/parity_benchmark.rs` — benchmark test (add exploration tests here)
72-
- `tests/corpus_comparison.rs` — has `count_scans()` and `print_scan_details()`
73-
- C: `jcmaster.c``jpeg_search_progression()`
74-
- C: `jcphuff.c` → trial encoding for scan cost estimation
75-
76-
## Acceptance criteria
77-
78-
- Understand whether the gap is from different scan scripts or different
79-
file sizes for the same scan script
80-
- If different scripts: determine if Rust's choice is suboptimal or just different
81-
- If same scripts: the gap is in entropy coding, not scan search — investigate
82-
per-scan Huffman table differences
83-
- Document findings, decide whether to fix or accept and adjust thresholds
43+
Some images still show Rust choosing different scripts than C (different Al levels
44+
or frequency splits). This is expected — the scan search is a greedy heuristic and
45+
can find different local optima. The per-image max deviation is ~1.6% at Q55, which
46+
is within acceptable range.

src/test_encoder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ pub fn encode_rust(rgb: &[u8], width: u32, height: u32, config: &TestEncoderConf
135135
.subsampling(config.subsampling)
136136
.progressive(config.progressive)
137137
.optimize_huffman(config.optimize_huffman)
138+
.optimize_scans(config.optimize_scans)
138139
.trellis(trellis)
139140
.overshoot_deringing(config.overshoot_deringing)
140141
.force_baseline(config.force_baseline)

0 commit comments

Comments
 (0)