Skip to content

feat(gamut-color, gamut-jpeg, gamut-webp, gamut-avif)!: upsample chroma like each reference decoder by default - #1044

Merged
justin13888 merged 5 commits into
masterfrom
feat/1016-fancy-upsampling
Nov 4, 2026
Merged

justin13888 merged 5 commits into
masterfrom
feat/1016-fancy-upsampling

Conversation

@justin13888

@justin13888 justin13888 commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

gamut's JPEG, WebP and AVIF decoders now produce the same Rgb8/Rgba8 that each format's reference decoder produces by default on subsampled input. Until now gamut replicated chroma, while libjpeg-turbo, libwebp and libavif all interpolate. The interpolating filter is the new default. Nearest-neighbour replication stays available as ChromaUpsampling::Nearest. The work moves up the dependency graph: gamut-color, which owns the YCbCr conversion and Yuv420, gets the shared kernels first, and the three decoders then switch onto them.

All three references use the same 9/3/3/1 geometry: chroma centred between luma samples, edge samples repeated. They differ in rounding. So each kernel ports one reference exactly instead of all three sharing one approximation.

gamut-color (crates/gamut-color/src/upsample.rs, src/ycbcr.rs, src/lib.rs, benches/color.rs, README.md, STATUS.md). Commit 1 is breaking.

  • New upsample module, containing:
    • ChromaUpsampling (#[repr(u8)], Nearest = 0, Interpolated = 1 default, #[non_exhaustive]), re-exported at the crate root.
    • taps: the 2:1 row pair that all three references pick.
    • libjpeg::{h2v1, h2v2, h1v2}: libjpeg-turbo jdsample.c fancy upsampling, with the alternating +1/+2 and +8/+7 rounding bias and the replication fallback for an h2v1/h2v2 component one or two samples wide.
    • libwebp::fancy_row: libwebp's two-stage diagonal upsampler.
    • libavif::bilinear_row: libavif's built-in bilinear filter, returned as an exact sum in sixteenths of a code value.
  • YcbcrMatrix::to_rgb_sixteenths converts that unrounded chroma with one final rounding, which is what libavif's f32 path does.
  • Breaking: Yuv420::to_rgb8(range, upsampling) now takes the mode explicitly. Interpolated gives output byte-identical to libwebp.
  • The stale "tracked as issue Implement quality optimizations for lossy WebP encoder #32" comment is gone. Implement quality optimizations for lossy WebP encoder #32 closed without changing the decoder.
  • to_rgb8 on a zero-width buffer, which Yuv420::new accepts, returns an empty Vec instead of panicking.
  • The bench now times the interpolated path.

gamut-jpeg (src/decoder.rs, src/lib.rs, STATUS.md, tests/oracle.rs, tests/backends.rs):

  • The built-in decoder picks a filter for each component by its sampling ratio, the way jinit_upsampler does: h2v1, h1v2 or h2v2 interpolate, and every other integer ratio is replicated. Non-integer ratios, which libjpeg-turbo refuses, stay replicated.
  • New option: JpegDecoder::with_chroma_upsampling. ChromaUpsampling is re-exported.
  • Oracle tests:
    • Decode-parity batteries are now byte-identical in every mode, sequential and progressive. Subsampled used to be held only to a 20 dB PSNR floor.
    • New 4:4:0 (h1v2) and narrow-width battery.
    • New Nearest check against djpeg -nosmooth.
  • tests/backends.rs: the Debug expectation now includes the upsampling field.

gamut-webp (src/decoder.rs, src/lib.rs, STATUS.md, tests/oracle.rs, tests/common/mod.rs, tests/backends.rs, tests/default_bytes.rs):

  • The VP8 RGB and ALPH RGBA paths use the decoder's upsampling, whichever decoder produced the planes.
  • New option: WebpDecoder::with_chroma_upsampling, which composes with the decoder limits from feat(webp)!: add caller-set decoder resource limits #1029. It shows in Debug.
  • Oracle tests:
    • The RGB check goes from "max diff ≤ 24" to exact.
    • New exact RGBA (ALPH) check.
    • New Nearest check against libwebp with no_fancy_upsampling. Its helper libwebp_decode_rgba_nofancy is in tests/common.
  • tests/backends.rs: the backend fixture now has varying chroma, so the backend path is shown to honour both modes.
  • tests/default_bytes.rs: the default-decode digests are re-pinned. The previous digests still hold under Nearest, which shows the change moved only the upsampler.

gamut-avif (src/decode.rs, src/lib.rs, README.md, STATUS.md, tests/conformance.rs, tests/decode.rs):

  • On the RGBA surfaces, 4:2:0 and 4:2:2 chroma now go through libavif's bilinear filter, kept in sixteenths through YcbcrMatrix::to_rgb_sixteenths.
  • New: DecodeOptions (limits plus chroma upsampling) and decode_{item,primary}_rgba{8,16}_with_options. The existing *_with_limits methods forward to them.
  • Tests:
    • New conformance test against libavif's default conversion.
    • The colr-stripped test now compares default against default and tightens from max 3 to max 1.
    • The H.273 goldens pin Nearest. The 10/12-bit reference also checks the interpolated path against an independent f64 9/3/3/1 blend.
    • New test for the primary *_with_options surfaces.

Tooling (dev-only, outside the workspace):

  • tooling/libjpeg-oracle/src/{lib.rs,shim.c}: adds Subsampling::S440 and decode_nearest, which turns off do_fancy_upsampling.
  • tooling/libavif-oracle/src/lib.rs: adds decode_rgba_default_upsampling.

Measured tolerance against each reference on 4:2:0 (acceptance)

Format Reference (default decode) Oracle Result
JPEG libjpeg-turbo 3.2 djpeg default (fancy upsampling, ISLOW IDCT) tooling/libjpeg-oracle byte-identical (max abs diff 0) at 4:2:0 and 4:2:2 (sequential and progressive, q 40/75/95 batteries plus a q80 narrow-width battery, widths 1–64) and 4:4:0 (sequential and progressive at q80, widths 1–64)
WebP libwebp 1.5.0 dwebp default (WebPDecodeRGB(A): fancy, no dithering) libwebp-sys2 0.2 (an existing dev-dep of gamut-webp) byte-identical (max abs diff 0) in RGB and RGBA, 1×1 up to 255 wide, odd sizes, single rows and columns
AVIF libavif 1.4.2 + dav1d, avifImageYUVToRGB default (AUTOMATIC; libyuv off, so libavif's built-in bilinear filter) tooling/libavif-oracle + tooling/dav1d-oracle max abs diff 1 over 14 files: the libavif 4:2:0 corpus (kodim03, a 5-tile grid) and gamut's own 4:2:0 and 4:2:2 output in BT.709 full and BT.601 limited range

Nearest is held to the reference's non-interpolating mode in the same way: byte-identical to djpeg -nosmooth and to libwebp no_fancy_upsampling. For AVIF it keeps the existing bound against libavif NEAREST.

Decisions taken

  1. Owner crate: gamut-color, not gamut-core convert. cargo metadata shows gamut-jpeg, gamut-webp and gamut-avif all depend on gamut-color, which already owns Yuv420::to_rgb8 and YcbcrMatrix. gamut-core's convert only rearranges interleaved Pixel layouts and never sees chroma planes. Reverse: move upsample.rs into gamut-core and repoint the imports.
  2. One shared enum gamut_color::ChromaUpsampling { Nearest = 0, Interpolated = 1 }, #[repr(u8)], #[non_exhaustive], default Interpolated, append-only discriminants. Each decoder applies its own format's reference filter for Interpolated, so callers learn one vocabulary. Rejected: a per-format enum, which would be three copies of the same two choices, and naming variants after references, which would break the "default matches the reference" reading. Reverse: add per-crate enums mapped onto the kernels.
  3. One kernel per reference rather than one generic filter. The rounding differs, and the acceptance asks for parity. A shared integer 9/3/3/1 would miss libjpeg-turbo's ordered-dither bias and libwebp's two-stage truncation by 1 LSB.
  4. Breaking Yuv420::to_rgb8(range, upsampling), so gamut-color 3.x → 4.0 via release-plz. CLAUDE.md prefers a breaking bump to a worse contract, and an explicit parameter can't silently disagree with the decoders' default. Rejected: a non-breaking to_rgb8_with that leaves to_rgb8 replicating. Reverse: restore the one-argument signature as Nearest.
  5. API shape:
    • JPEG/WebP: a with_chroma_upsampling builder on the decoder, matching their existing builders.
    • AVIF: #[non_exhaustive] DecodeOptions { limits, chroma_upsampling } plus decode_{item,primary}_rgba{8,16}_with_options. AVIF has no decoder object, and the per-call *_with_limits methods are its configuration surface. Adding the setting to DecodeLimits would misname it, and replacing *_with_limits would break callers.
    • The AvifContainer forwarders get no _with_options twins, matching how they have no _with_limits twins.
    • Reverse: fold DecodeOptions into *_with_limits in a breaking release.
  6. AVIF interpolated output goes through YcbcrMatrix for every matrix, 8-bit BT.601 included, with chroma carried in sixteenths. libavif blends chroma in float and rounds once. Rounding chroma to 8 bits first, or reusing the libwebp-exact BT.601 inverse that causes today's max-3 gap, would miss the ≤ 1 target. The 4:4:4 and Nearest paths keep the libwebp-exact inverse and are byte-identical to before. Reverse: route Interpolated through Converter::select.
  7. AVIF reference = libavif without libyuv, because that is what tooling/libavif-oracle builds (AVIF_LIBYUV=OFF) and what can be tested here. libyuv-enabled builds use libyuv's own fixed-point conversion, which is not reproduced. This is documented on the surface and in STATUS.
  8. Non-integer JPEG sampling ratios (e.g. 2 of 3) stay replicated. libjpeg-turbo refuses those streams (JERR_FRACT_SAMPLE_NOTIMPL), so there is no reference output to match, and refusing what gamut decodes today would narrow a reader. This is documented in the module docs and STATUS.
  9. gamut-heic is left on nearest-neighbour: out of gamut-jpeg, gamut-webp, gamut-avif: decoders upsample chroma by sample replication, diverging from reference decoders #1016's scope, filed as gamut-heic: upsample chroma like libheif by default #1041 under epic epic: competitive parity with the image-evaluation benchmark #1022.
  10. No gamut-ffi/wasm setter in this PR. C and wasm callers get the new default. The enum is already repr(u8) with permanent discriminants, so exposing it later is mechanical. Only the default changes; no C signature does.

Validation

GitHub CI is disabled on purpose for this repository, so nothing below comes from CI. Every command was run locally on d6058cc1, or on the commit before it where noted. The head 83486e84 adds only a doc-comment/STATUS rewrap on top of it, re-checked with mise run fmt-check (passed).

  • mise run fmt-check — passed. mise run fmt-tooling-check — passed.
  • mise run lint (cargo clippy --workspace --all-targets --all-features -D warnings) — passed. It ran on the pre-rebase series. After rebasing onto 430fbcea, cargo clippy -p gamut-jpeg -p gamut-webp -p gamut-color -p gamut-avif --all-targets --all-features -- -D warnings passed again.
  • cargo test --all-features --no-fail-fast -p gamut-color -p gamut-jpeg -p gamut-webp -p gamut-avif -p gamut-heic -p gamut-dng -p gamut -p gamut-cli -p gamut-ffi -p gamut-wasm — touched crates plus dependents, scoped rather than the whole workspace:
    • Everything passed except one gamut-webp Debug unit test, which pinned the new field's absence after feat(webp)!: add caller-set decoder resource limits #1029 landed. That test is fixed in this PR.
    • After the final rebase, cargo test --all-features --no-fail-fast -p gamut-jpeg -p gamut-webp -p gamut-color -p gamut -p gamut-cli -p gamut-ffi — all passed.
    • cargo test -p gamut-avif, all targets — passed.
  • mise run mutants-diff (memory-capped runner, base origin/master): 313 mutants: 275 caught, 37 unviable, 1 missed, 0 timeouts. The survivor was upsample.rs:136 + → * in libjpeg::h2v1, the even-output + 1 bias. The JPEG oracle kills it, but the gate is per-crate (test_workspace = false). A gamut-color unit case now kills it: I applied the mutant by hand, the test failed, and I reverted it. The full two-hour survey was not re-run after that one-test fix.
  • Coverage, cargo llvm-cov --all-features -p gamut-color -p gamut-jpeg -p gamut-webp -p gamut-avif --ignore-filename-regex '(crates/gamut-(cli|wasm|ffi|jxl-sys)|tooling)/' --fail-under-lines 80 — passed:
    • TOTAL lines 98.73%.
    • upsample.rs 100%, ycbcr.rs 99.39%, gamut-jpeg decoder.rs 97.66%, gamut-webp decoder.rs 98.93%, gamut-avif decode.rs 97.74%.
    • None of the remaining missed lines is a line this PR changed.

Coverage gaps (declared)

  • No oracle comparison for subsampled CMYK/YCCK/RGB JPEG, or for 2-of-4 or 4:1:1 ratios. The libjpeg-oracle encoder only emits YCbCr with luma factors. Kernel choice for those ratios is pinned by the filter_picks_libjpeg_turbos_method_for_each_ratio unit test, and the kernels by the YCbCr oracle.
  • The AVIF 16-bit (Rgba16) interpolated path has no libavif comparison, because the oracle decodes to RGBA8. It is checked against an independent f64 9/3/3/1 reference within one coded LSB.
  • AVIF frames carrying samples above the coded depth's maximum, which are non-conformant: libavif clamps chroma before interpolating and gamut does not. The output may differ there.

Risks and rollout

Issue

Closes #1016

Add the `upsample` module, the owner of chroma reconstruction for every
decoder that presents subsampled YCbCr as RGB (#1016):

- `ChromaUpsampling` (`#[repr(u8)]`, `Nearest = 0`, `Interpolated = 1`,
  the default; append-only discriminants, `#[non_exhaustive]`).
- `taps`, the 2:1 vertical row pair all three references share.
- `libjpeg::{h2v1, h2v2, h1v2}`: libjpeg-turbo's fancy upsampling with
  its alternating rounding bias and narrow-component fallback.
- `libwebp::fancy_row`: libwebp's two-stage fancy upsampler.
- `libavif::bilinear_row`: libavif's built-in bilinear, returned in
  sixteenths so no rounding happens before conversion.

`YcbcrMatrix::to_rgb_sixteenths` converts that unrounded chroma with a
single final rounding.

BREAKING CHANGE: `Yuv420::to_rgb8` takes a `ChromaUpsampling`. Pass
`ChromaUpsampling::Nearest` for the previous output, or `Interpolated`
for output byte-identical to libwebp's default decode. gamut-webp passes
`Nearest` here, so its output is unchanged by this commit.

The stale "tracked as issue #32" comment in the `ycbcr` module is gone:

Part of #1016
The built-in decoder now reconstructs subsampled components the way
libjpeg-turbo's `djpeg` does by default. It uses the `jdsample.c` fancy
triangle filters (h2v1, h1v2, h2v2) from `gamut_color::upsample::libjpeg`,
picked by component ratio as `jinit_upsampler` picks them. Like
libjpeg-turbo, an h2v1/h2v2 component one or two samples wide, and every
other ratio, is still replicated.

`JpegDecoder::with_chroma_upsampling(ChromaUpsampling::Nearest)` keeps
the old replication everywhere, and `ChromaUpsampling` is re-exported.
A pushed backend is unaffected.

The oracle tests now require byte-identical output in every mode:
- gray/4:4:4/4:2:2/4:2:0, sequential and progressive (4:2:0 was
  ~22.9 dB PSNR under replication);
- a new 4:4:0 (h1v2) and narrow-width battery against libjpeg-turbo's
  default decode;
- `Nearest` against `djpeg -nosmooth`.

libjpeg-oracle gains `Subsampling::S440` and `decode_nearest`, which
turns off `do_fancy_upsampling`.

Part of #1016
Lossy (VP8) decodes now reconstruct 4:2:0 chroma with libwebp's fancy
upsampler (`ChromaUpsampling::Interpolated`, via `Yuv420::to_rgb8`). That
applies to both the RGB and the `ALPH` RGBA paths, whichever decoder
produced the planes. Together with the libwebp-exact inverse, the output
is byte-identical to `dwebp`'s default decode.

`WebpDecoder::with_chroma_upsampling(ChromaUpsampling::Nearest)` keeps
replication (`dwebp -nofancy`), and `ChromaUpsampling` is re-exported.

Oracle (libwebp via libwebp-sys2):
- RGB decode parity goes from a max-diff of 24 to exact over the full
  dimension matrix, odd sizes and single rows/columns included;
- RGBA with `ALPH` is exact;
- `Nearest` is exact against libwebp with `no_fancy_upsampling`.

The default-decode digests are re-pinned. The old digests still hold
under `Nearest`, so the switch moved only the upsampler.

Part of #1016
The RGBA presentation surfaces now reconstruct 4:2:0 and 4:2:2 chroma
with libavif's built-in bilinear filter (`ChromaUpsampling::Interpolated`,
`gamut_color::upsample::libavif`). The interpolated chroma stays in
sixteenths through `YcbcrMatrix::to_rgb_sixteenths`, so it is rounded
once, at the end, as libavif's `f32` path is. Interpolated output
therefore takes the H.273 converter for every matrix. The libwebp-exact
8-bit BT.601 inverse stays on the 4:4:4 and `Nearest` paths, which are
byte-identical to before.

New: `DecodeOptions` (`#[non_exhaustive]`: limits plus chroma
upsampling) and `decode_{item,primary}_rgba{8,16}_with_options`. The
`*_with_limits` surfaces forward to these with the default upsampling.
`ChromaUpsampling` is re-exported.

Oracle: against libavif's default conversion (libyuv off in this
oracle), max abs diff is 1 over 14 files. They cover the 4:2:0 corpus
(kodim03, a 5-tile grid) and gamut's own 4:2:0 and 4:2:2 output in BT.709
full and BT.601 limited range, odd sizes included. The colr-stripped
test now compares default against default and tightens from max 3 to
max 1. The H.273 goldens pin `Nearest`, and the 10/12-bit reference
also checks the interpolated path against an independent f64 9/3/3/1
blend. libavif-oracle gains `decode_rgba_default_upsampling`.

Part of #1016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gamut-jpeg, gamut-webp, gamut-avif: decoders upsample chroma by sample replication, diverging from reference decoders

1 participant