Repository navigation
fix: carry propagation drops bytes instead of zeroing them - #192
stephane-segning wants to merge 1 commit into
Conversation
add_one_to_output() is supposed to propagate an arithmetic-coder carry backwards through any already-written 0xFF bytes, turning each into 0x00, until it reaches a byte it can increment in place - the same rule as decimal "999 + 1 = 1000". The old loop popped bytes and only pushed one back once it found a value < 255; every 0xFF it popped along the way was simply discarded, never replaced with 0x00. That silently shortens the encoded stream by however many 0xFF bytes were in the carry chain, and every decoder read from that point on lands on the wrong bits. This is exactly the mechanism behind this crate's known kodim02 lossy_quality=85 corruption (DSSIM 0.0636 vs ~0.007-0.008 at neighbouring qualities): confirmed by reverting just this fix and reproducing the identical 0.0636 figure, then re-applying it and watching the DSSIM curve go back to being smooth and monotonic. It stayed latent because it only fires when a carry has to ripple through one or more already-saturated 0xFF bytes, which needs a long enough run of near-maximal `bottom` values in the encoder - rare enough with this encoder's previous bit traffic (a single fixed DC/DC mode per macroblock, skip signalling always disabled) that it apparently only ever fired for this one image/quality combination. It fires far more easily once the encoder writes real per-macroblock mode and skip decisions (next commit), which was how this was actually found: a from-scratch repro on kodim19.png at quality 72 with an early, since-abandoned lambda value produced a bitstream libwebp couldn't decode at all. Added a direct regression test against add_one_to_output's carry semantics. A synthetic public-API (write_bool -> flush -> decode) round-trip test was attempted too, including a few thousand-iteration randomized sweeps over probability/bit sequences, but none of them reproduced the bug in a reasonably small case - it appears to need the specific correlated bit patterns a real image's macroblock headers and DCT coefficients produce, not independent random writes. The white-box test plus the before/after kodim02 numbers above are the coverage for this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
This change brings the code in line with the VP8 spec, and so the change to the logic looks reasonable to me. (not a maintainer of image-webp.) https://www.rfc-editor.org/rfc/rfc6386.html#page-19
https://www.rfc-editor.org/rfc/rfc6386.html#page-21
I think the commit message suffices and the comment is actually safe to drop entirely. Also, as for the tests: while these would identify a regression of the current very specific bug, I don't think they will catch the next one, and I can't see this change being reverted as likely. It's a typical issue seen in AI-generated tests. The most useful thing to catch regressions in the encoder or decoder logic in the future would perhaps be a fuzzer to verify the property that the arithmetic encoding output can always be decoded to the same series of decisions; but that's a more significant project. |
Fixes #191.
The bug
add_one_to_outputpropagates an arithmetic-coder carry backwards through already-written0xFFbytes, turning each into0x00until it reaches one that can absorb the+1— thesame rule as decimal
999 + 1 = 1000.The current loop pops bytes and only pushes one back:
A popped
0xFFis not< 255, so it is discarded rather than written back as0x00.The output is shortened by however many
0xFFbytes were in the carry chain.That is worse than a wrong byte. A decoder reading a stream that is short by N bytes does
not error — it silently reads everything after the carry shifted, so every symbol from that
point on is wrong. Which is why #191 presented as corrupted output at one specific quality
rather than as a failure.
Why it has stayed latent
It only fires when a carry actually has to cross a saturated byte, which needs a long enough
run of near-maximal
bottomvalues. The encoder onmainwrites very little through thispath — a single fixed DC/DC luma mode, and
mb_no_skip_coeffalways disabled — so thecondition is essentially never met.
I hit it in a fork that added RD intra mode decision and skip signalling, which write far
more entropy-coded data per macroblock. It reproduced on
kodim02atlossy_quality = 85and only there, which is what made it worth chasing rather than dismissing as noise.
So this is a real bug in
maintoday, just one the current encoder rarely provokes. It getseasier to hit as the lossy encoder does more.
The fix
Walk the buffer in place instead of popping:
Length is preserved by construction.
Test
test_carry_propagates_through_saturated_bytescovers a carry crossing three saturatedbytes, a carry with no chain, and an all-saturated buffer where the carry falls off the
front (unreachable given the encoder's invariants, but it pins the property that the buffer
keeps its length rather than being emptied).
Verified it actually catches the bug — against the old loop it fails with:
Three bytes gone, which is the whole defect.
Checks
cargo testpasses (31 + 31).cargo fmt --checkis clean.cargo clippy --all-targets -- -D warningsreports 16 findings both before and after this change — all pre-existing,none in the touched function.
Happy to adjust the comment length if it is more than you want inline; the mechanism seemed
worth recording next to the code given how long it went unnoticed.