Put a JPEG's chroma back the way every other reader does - #41
Merged
Conversation
`render` decodes JPEG with Go's `image/jpeg`, which hands back an `*image.YCbCr`; turning that into pixels through `raster.FromImage` reads chroma through `image.YCbCr.COffset`, and COffset divides x and y by the sampling factor (image/ycbcr.go:101). A 4:2:0 picture's chroma is therefore REPLICATED: four pixels share one sample exactly. libjpeg interpolates instead, weighting the nearer chroma sample 3 and the further one 1 in each direction — "fancy upsampling", on by default (jdapimin.c:229). poppler never touches the flag (DCTStream.cc:98), and neither do pdfium, mupdf or Ghostscript, so all four interpolate. The two are not close, and the difference is the whole of #40. Measured against `pdfimages` on the conformance corpus, replication differs from poppler by 16 to 62 levels of 255 across a third to a half of a 4:2:0 picture's pixels. Nothing else in the decode disagrees: over 112 real greyscale JPEGs the largest channel difference from poppler is ONE level, the ISO/IEC 10918-2 IDCT allowance exactly, and a synthetic 4:4:4 picture comes out of Go's decoder byte for byte identical to libjpeg's. Neither has any chroma to put back. So this reproduces libjpeg's filter, and only where libjpeg applies it: 4:2:0, 4:2:2 and 4:4:0, and within those only when the chroma plane is more than two samples wide, because libjpeg falls back to replication below that (jdsample.c, `compptr->downsampled_width > 2`). 4:1:1 and 4:1:0 have no fancy method there at all — they go through int_upsample, which replicates — so they are left alone too, and a test asserts each of those cases comes out exactly as `raster.FromImage` would have made it. # WHAT IT IS WORTH On the picture the investigation reduced to — a 75x75 4:2:0 logo in `us-dol/CA-10.pdf` — our output went from 56 levels away from poppler on 48.3% of its pixels to 3 levels away on 0.018% of them. The 3 that remain are two conformant IDCTs each within the ISO allowance of the reference and so up to 2 apart in Y, Cb and Cr, carried through the colour matrix; they are not this. # ROWS RATHER THAN PLANES The reconstruction is made one row at a time into a buffer the plane owns, not into two whole extra planes beside the picture. A picture is already the largest thing a page allocates, and reconstructing both planes in full would have raised what a JPEG costs by half for no gain. An edge row reads the last real row twice rather than reading the MCU padding that follows it in memory, which is what libjpeg does (jdmainct.c:217) and is the only reading under which the two agree.
jdsample.c:506 is the 2h1v arm and :534 the 2h2v one; the generic replicating method is chosen at :553.
The clone of ghostpdl in the reference library is a stub with no source tree in it, so "Ghostscript does this too" was a claim nothing here could be pointed at. pdfium and mupdf can be.
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.
Diagnoses and closes #40.
The cause
Our JPEG decode disagrees with poppler because of chroma upsampling, and because of nothing else.
Go's
image/jpeghands back an*image.YCbCr. Reading that throughraster.FromImagereads chroma throughimage.YCbCr.COffset, which divides x and y by the sampling factor (image/ycbcr.go:101), so a 4:2:0 picture's chroma is replicated: four pixels share one sample exactly.libjpeg interpolates instead — three quarters of the nearer chroma sample and one quarter of the further one, each way. That is "fancy upsampling", and it is on by default (
jdapimin.c:229). poppler never touches the flag (DCTStream.cc:98), and neither do pdfium, mupdf or Ghostscript.The evidence that it is only this
pdfimages -pngus-dol/CA-10.pdf— beforeus-dol/CA-10.pdf— after this changeA greyscale JPEG and a 4:4:4 one have no chroma to put back, and on those the Huffman decode, the dequantiser, the IDCT and the colour matrix already agreed with libjpeg. The 3 levels that remain are two conformant IDCTs, each within the ISO allowance of the reference and so up to 2 apart in Y, Cb and Cr, carried through the colour matrix.
What it does
It reproduces libjpeg's filter, and only where libjpeg applies it:
jdsample.c,compptr->downsampled_width > 2).int_upsample, which replicates — so they are left alone.TestChromaIsLeftAloneWhereLibjpegLeavesItAloneasserts each of those cases comes out byte for byte asraster.FromImagewould have made it, so this cannot become the odd one out in the other direction.TestChromaIsPutBackTheWayLibjpegPutsItBackpins the three interpolated samplings against expected rows evaluated from libjpeg's own expressions — including the asymmetric biases, 1 for the upper row of a pair and 2 for the lower — rather than from this code's output. A test that only agreed with itself would assert nothing about a decoder written in another language.Rows rather than planes
The reconstruction is made one row at a time into a buffer the plane owns, not into two whole extra planes beside the picture: a picture is already the largest thing a page allocates, and reconstructing both planes in full would have raised what a JPEG costs by half for no gain. An edge row reads the last real row twice rather than the MCU padding that follows it in memory, which is what libjpeg does (
jdmainct.c:217) and is the only reading under which the two agree.Not merged on my own judgement
Neither behaviour violates ISO/IEC 10918 — the standard specifies the IDCT and leaves reconstruction open, and pdf.js replicates too (
src/core/jpg.jstruncates with0 | (x * componentScaleX)). This changes the output of every subsampled JPEG in the fleet, so it is a proposal, not a defect fix. Please decide it rather than auto-merging it.