Skip to content

Say what the last large peak is: a four-component JPEG's chroma - #43

Merged
tannevaled merged 1 commit into
mainfrom
cmyk-chroma
Sep 20, 2026
Merged

tannevaled merged 1 commit into
mainfrom
cmyk-chroma

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

§14 left two large peaks in the converted bucket. §16 answered the 110. This is the 33, and it is the last one in this corpus that is ours.

The picture

gh-openpdf/objectXref.pdf’s 258×258 DeviceCMYK JPEG is YCCK — Adobe APP14 transform 2 — with luma and the black plate at 2×2 and the two chroma planes at 1×1, so the chroma is subsampled by two in each direction.

Separating the decode from the conversion

pdfimages -tiff writes a DeviceCMYK picture as a four-sample separated TIFF: poppler’s own CMYK, before any colour conversion. Against the samples render produces for the same stream, 91.2% agree within one level and the worst per plane is:

plane C M Y K
worst difference 36 18 21 1

K is the control and it is the whole argument. It is carried at full resolution, never upsampled, and it is right. Sorting every pixel by how fast the chroma changes across neighbouring blocks sorts the error with it:

chroma gradient C plane K plane
flat (≤ 2) 5 0
gentle (3–15) 19 1
strong (16–40) 36 1
steep (> 40) 35 1

A decode that were simply different would move K too. This one does not.

Why

chroma.go replicates libjpeg’s fancy upsampling term for term, and §14 measured the result: three-component pictures peak at 4 whether their chroma is subsampled or not. It never runs here. Go’s image/jpeg applyBlack (reader.go:675) merges the four planes itself through imageutil.DrawYCbCr, which samples chroma by replication, and hands back an *image.CMYK. The planes are gone before render is given anything.

Whether it could be put back — measured, not guessed

Inverting the conversion recovers the per-block chroma exactly where nothing clipped: over 14 244 unclipped blocks, 14 215 show a spread of 0.0 within the block and the worst is 0.5, which is the rounding of Go’s integer conversion. But 7.8% of blocks have every pixel clipped on some channel, and there the chroma is genuinely gone.

Re-upsampling what can be recovered, with libjpeg’s own filter, takes the worst CMY error from 36 to 26 — and the 26 that remains sits entirely in the blocks that cannot be recovered.

So a reconstruction is around a hundred lines for a partial improvement on one picture in this corpus, bounded by information another library discarded. It is named rather than done, as §15 does for the JPEG 2000 defect, and the honest fix is the same shape: a four-component decode that hands back its planes.

§5 gains a witness

§5 says the two-minute bound is unexercised by images on this corpus, and that its value is keeping the next document from stopping a sweep dead with nothing to show for it. A throwaway census run without a bound stalled twelve minutes on one 2496-byte document — qpdf_qtest_qpdf_shared-unnamed-field.pdf, the very file §5 names.

No records change; this is the reading of the run already landed.

🤖 Generated with Claude Code

§14 left two large peaks in the converted bucket. §16 answered the 110.
This is the 33, and it is the last one in this corpus that is ours.

gh-openpdf/objectXref.pdf's 258x258 DeviceCMYK JPEG is YCCK -- Adobe APP14
transform 2 -- with luma and black at 2x2 and the two chroma planes at 1x1.

Separating the decode from the conversion settles it. pdfimages -tiff
writes a DeviceCMYK picture as a four-sample separated TIFF, which is
poppler's own CMYK before any colour conversion. Against what render
produces for the same stream, 91.2% of samples agree within one level and
the worst per plane is C 36, M 18, Y 21 -- and K 1.

K is the control and it is the whole argument: it is carried at full
resolution, never upsampled, and it is right. Sorting every pixel by how
fast the chroma changes across neighbouring blocks sorts the error with it,
5 to 36 on the C plane, while K stays at 0 or 1 in every band. A decode
that were simply different would move K too.

chroma.go replicates libjpeg's fancy upsampling term for term and §14
measured it at peak 4 for three-component pictures. It never runs here: Go's
image/jpeg applyBlack merges the four planes itself through
imageutil.DrawYCbCr, which samples chroma by replication, and hands back an
image.CMYK. The planes are gone before render is given anything.

Whether render could put it back is measured rather than guessed. Inverting
the conversion recovers the per-block chroma exactly where nothing clipped:
over 14244 unclipped blocks, 14215 show a spread of 0.0 and the worst is
0.5. But 7.8% of blocks have every pixel clipped on some channel and there
the chroma is gone. Re-upsampling what can be recovered takes the worst CMY
error from 36 to 26, and the 26 sits entirely in the blocks that cannot be
recovered. So it is a hundred lines for a partial improvement on one
picture, bounded by information another library discarded, and it is named
rather than done -- as §15 does for the JPEG 2000 defect.

§5 also gains a witness. It says the two-minute bound is unexercised by
images on this corpus, and that its value is keeping the next document from
stopping a sweep dead with nothing to show. A throwaway census run without
a bound on 2026-09-20 stalled twelve minutes on one 2496-byte document --
qpdf_qtest_qpdf_shared-unnamed-field.pdf, the very file §5 names.
@tannevaled
tannevaled merged commit 84a8d73 into main Sep 20, 2026
1 check passed
@tannevaled
tannevaled deleted the cmyk-chroma branch September 20, 2026 16:02
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.

1 participant