Skip to content

Put a JPEG's chroma back the way every other reader does - #41

Merged
tannevaled merged 3 commits into
mainfrom
dct-fancy-upsample
Aug 31, 2026
Merged

Put a JPEG's chroma back the way every other reader does#41
tannevaled merged 3 commits into
mainfrom
dct-fancy-upsample

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Diagnoses and closes #40.

The cause

Our JPEG decode disagrees with poppler because of chroma upsampling, and because of nothing else.

Go's image/jpeg hands back an *image.YCbCr. Reading that through raster.FromImage reads chroma through image.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

decoded how vs poppler's pdfimages -png
greyscale JPEG, 112 real corpus pictures largest channel difference 1 level — the ISO/IEC 10918-2 IDCT allowance exactly
4:4:4 colour, synthetic bit-identical
4:2:0 colour, us-dol/CA-10.pdf — before 56 levels, on 48.3% of pixels
4:2:0 colour, us-dol/CA-10.pdf — after this change 3 levels, on 0.018% of pixels

A 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:

  • 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 in libjpeg at all — they go through int_upsample, which replicates — so they are left alone.
  • A picture with no chroma to put back is left alone.

TestChromaIsLeftAloneWhereLibjpegLeavesItAlone asserts each of those cases comes out byte for byte as raster.FromImage would have made it, so this cannot become the odd one out in the other direction.

TestChromaIsPutBackTheWayLibjpegPutsItBack pins 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.js truncates with 0 | (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.

`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.
@tannevaled
tannevaled merged commit 8bea382 into main Aug 31, 2026
1 check passed
@tannevaled
tannevaled deleted the dct-fancy-upsample branch August 31, 2026 13:28
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.

Our JPEG differs from poppler by more than the ISO IDCT allowance on two thirds of compared pictures

1 participant