Repository navigation
feat(gamut-color, gamut-jpeg, gamut-webp, gamut-avif)!: upsample chroma like each reference decoder by default - #1044
Merged
Conversation
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
This was referenced Nov 4, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
gamut's JPEG, WebP and AVIF decoders now produce the same
Rgb8/Rgba8that 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 asChromaUpsampling::Nearest. The work moves up the dependency graph: gamut-color, which owns the YCbCr conversion andYuv420, 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.upsamplemodule, containing:ChromaUpsampling(#[repr(u8)],Nearest = 0,Interpolated = 1default,#[non_exhaustive]), re-exported at the crate root.taps: the 2:1 row pair that all three references pick.libjpeg::{h2v1, h2v2, h1v2}: libjpeg-turbojdsample.cfancy 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_sixteenthsconverts that unrounded chroma with one final rounding, which is what libavif'sf32path does.Yuv420::to_rgb8(range, upsampling)now takes the mode explicitly.Interpolatedgives output byte-identical to libwebp.to_rgb8on a zero-width buffer, whichYuv420::newaccepts, returns an emptyVecinstead of panicking.gamut-jpeg (
src/decoder.rs,src/lib.rs,STATUS.md,tests/oracle.rs,tests/backends.rs):jinit_upsamplerdoes: h2v1, h1v2 or h2v2 interpolate, and every other integer ratio is replicated. Non-integer ratios, which libjpeg-turbo refuses, stay replicated.JpegDecoder::with_chroma_upsampling.ChromaUpsamplingis re-exported.Nearestcheck againstdjpeg -nosmooth.tests/backends.rs: theDebugexpectation now includes theupsamplingfield.gamut-webp (
src/decoder.rs,src/lib.rs,STATUS.md,tests/oracle.rs,tests/common/mod.rs,tests/backends.rs,tests/default_bytes.rs):ALPHRGBA paths use the decoder'supsampling, whichever decoder produced the planes.WebpDecoder::with_chroma_upsampling, which composes with the decoder limits from feat(webp)!: add caller-set decoder resource limits #1029. It shows inDebug.ALPH) check.Nearestcheck against libwebp withno_fancy_upsampling. Its helperlibwebp_decode_rgba_nofancyis intests/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 underNearest, 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):YcbcrMatrix::to_rgb_sixteenths.DecodeOptions(limits plus chroma upsampling) anddecode_{item,primary}_rgba{8,16}_with_options. The existing*_with_limitsmethods forward to them.colr-stripped test now compares default against default and tightens from max 3 to max 1.Nearest. The 10/12-bit reference also checks the interpolated path against an independent f64 9/3/3/1 blend.*_with_optionssurfaces.Tooling (dev-only, outside the workspace):
tooling/libjpeg-oracle/src/{lib.rs,shim.c}: addsSubsampling::S440anddecode_nearest, which turns offdo_fancy_upsampling.tooling/libavif-oracle/src/lib.rs: addsdecode_rgba_default_upsampling.Measured tolerance against each reference on 4:2:0 (acceptance)
djpegdefault (fancy upsampling, ISLOW IDCT)tooling/libjpeg-oracledwebpdefault (WebPDecodeRGB(A): fancy, no dithering)libwebp-sys20.2 (an existing dev-dep of gamut-webp)avifImageYUVToRGBdefault (AUTOMATIC; libyuv off, so libavif's built-in bilinear filter)tooling/libavif-oracle+tooling/dav1d-oracleNearestis held to the reference's non-interpolating mode in the same way: byte-identical todjpeg -nosmoothand to libwebpno_fancy_upsampling. For AVIF it keeps the existing bound against libavifNEAREST.Decisions taken
convert.cargo metadatashows gamut-jpeg, gamut-webp and gamut-avif all depend on gamut-color, which already ownsYuv420::to_rgb8andYcbcrMatrix. gamut-core'sconvertonly rearranges interleavedPixellayouts and never sees chroma planes. Reverse: moveupsample.rsinto gamut-core and repoint the imports.gamut_color::ChromaUpsampling { Nearest = 0, Interpolated = 1 },#[repr(u8)],#[non_exhaustive], defaultInterpolated, append-only discriminants. Each decoder applies its own format's reference filter forInterpolated, 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.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-breakingto_rgb8_withthat leavesto_rgb8replicating. Reverse: restore the one-argument signature asNearest.with_chroma_upsamplingbuilder on the decoder, matching their existing builders.#[non_exhaustive] DecodeOptions { limits, chroma_upsampling }plusdecode_{item,primary}_rgba{8,16}_with_options. AVIF has no decoder object, and the per-call*_with_limitsmethods are its configuration surface. Adding the setting toDecodeLimitswould misname it, and replacing*_with_limitswould break callers.AvifContainerforwarders get no_with_optionstwins, matching how they have no_with_limitstwins.DecodeOptionsinto*_with_limitsin a breaking release.YcbcrMatrixfor 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 andNearestpaths keep the libwebp-exact inverse and are byte-identical to before. Reverse: routeInterpolatedthroughConverter::select.tooling/libavif-oraclebuilds (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.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.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 head83486e84adds only a doc-comment/STATUS rewrap on top of it, re-checked withmise 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 onto430fbcea,cargo clippy -p gamut-jpeg -p gamut-webp -p gamut-color -p gamut-avif --all-targets --all-features -- -D warningspassed 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:Debugunit 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.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, baseorigin/master): 313 mutants: 275 caught, 37 unviable, 1 missed, 0 timeouts. The survivor wasupsample.rs:136+ → *inlibjpeg::h2v1, the even-output+ 1bias. 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.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:upsample.rs100%,ycbcr.rs99.39%, gamut-jpegdecoder.rs97.66%, gamut-webpdecoder.rs98.93%, gamut-avifdecode.rs97.74%.Coverage gaps (declared)
filter_picks_libjpeg_turbos_method_for_each_ratiounit test, and the kernels by the YCbCr oracle.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.Risks and rollout
ChromaUpsampling::Nearest.Yuv420::to_rgb8). Every workspace caller is migrated.Subsampling::S440variant totooling/libjpeg-oracle, written identically so that file merges cleanly either way.crates/gamut-jpeg/STATUS.md(phase rows) andcrates/gamut-jpeg/tests/oracle.rswill conflict textually with feat(gamut-jpeg): encode 4:4:0 chroma subsampling #1028; whichever lands second resolves them.crates/gamut-jpeg/STATUS.md. Its B-subsampled XYB decode will take the fancy filter by default after this lands.Issue
Closes #1016