Read the ink layer of a scanned page, and stop showing its negative - #27
Merged
Conversation
Two things, because the first exposed the second and neither is right alone. JBIG2 is decoded. A modern scanned page is two images: a low-resolution colour background stored as JPEG 2000, and a high-resolution bitonal ink layer over it stored as JBIG2. We could read the background and not the ink. Counting filters by what they encode as page CONTENT put JBIG2 in 20 documents of 3 217, which is why it had been set aside; counting the images it SHAPES put it in 4 089, because it is almost never the content — it is the /Mask. A stencil mask paints where its sample is 0, and applyStencilMask was clearing the alpha where the stencil painted rather than where it did not. Every explicit mask in the corpus was therefore showing the exact complement of itself: on a scanned page, the ink is the only part that was hidden. The test that covered it asserted the same inversion, so the suite was green. Asked which half of a two-colour page a mask of eight 0 bits and eight 1 bits paints, poppler answers the 0 half; so does this now, for a raw mask and a JBIG2 one alike. The inversion had been invisible because the two halves cancelled: masks that could not be decoded took their image with them (#26), and the ones that could were drawn inside out. Decoding JBIG2 without fixing the mask made 5 of 25 medical scans worse and 2 of them catastrophic — 98% of pixels wrong on the worst — which is how the second defect was found. Measured against poppler, first page of 25 medical scans: improved 11 unchanged 14 worse 0 mean differing pixels 0.0083 -> 0.0004 worst page 0.1087 -> 0.0004 No decoder is written here. The reference is github.com/dkrisman/gobig2, chosen by decoding 403 JBIG2 masks taken from real scans and comparing every pixel against poppler's own decoder: it was exact on all 396 it accepted, where the candidate that decoded the most was exact on 9%. Its seven refusals are a resource budget, not a defect, and this takes it at its default: a stream it declines is a stream not drawn, which is the rule the rest of the file follows. gobig2's limits are process-global, so they are not raised from inside a library. 100% statement coverage, go vet and -race clean, nine cross-compile targets, CGO_ENABLED=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Two things, because the first exposed the second and neither is right alone.
JBIG2 is decoded
A modern scanned page is two images: a low-resolution colour background stored as JPEG 2000, and a high-resolution bitonal ink layer over it stored as JBIG2. We could read the background and not the ink.
JBIG2 had been set aside on a measurement that counted filters by what they encode as page content — by that count it was in 20 documents of 3 217 and left three pages blank. It is almost never the content. It is the
/Mask. Counted as what it shapes, it is in 4 089 images across the same corpus.The mask was inside out
A stencil mask paints where its sample is 0.
applyStencilMaskcleared the alpha where the stencil painted rather than where it did not, so every explicit mask in the corpus showed the exact complement of itself — on a scanned page, the ink is the only part that was hidden. The test covering it asserted the same inversion, so the suite was green.Asked which half of a two-colour page a mask of eight
0bits and eight1bits paints, poppler answers the0half. So does this now, for a raw mask and a JBIG2 one alike.The defect had been invisible because two halves cancelled: masks that could not be decoded took their image with them (#26), and the ones that could were drawn inside out. Decoding JBIG2 without fixing the mask made 5 of 25 medical scans worse and 2 catastrophic — 98% of pixels wrong on the worst. That is how it was found: the A/B against poppler, not the test suite.
Measured
First page of 25 medical scans, against poppler:
Which decoder
No decoder is written here. Four pure-Go candidates exist and all four advertise full segment coverage. I extracted 403 JBIG2 masks from real scans and compared every pixel against poppler's own decoder:
internal/)The one that decodes the most is wrong nearly everywhere. Counting successes would have picked it.
gobig2's seven refusals are a resource budget, not a defect: it caps a symbol at 4 megapixels and real scans carry symbols up to 8. This takes it at its default — a stream it declines is a stream not drawn, which is the rule the rest of the file follows. Its limits are process-global, so they are not raised from inside a library.
The same decoder is added to
go-gfx/gfx/codecin go-gfx/gfx#23, for the standalone file form.Gate
100% statement coverage,
go vetand-raceclean, nine cross-compile targets,CGO_ENABLED=0. The test fixture is synthetic, written out as bytes rather than committed, and round-trips through three independent implementations.🤖 Generated with Claude Code