Skip to content

Read CalGray and CalRGB, and let a JPEG reach its colour space - #50

Merged
tannevaled merged 1 commit into
mainfrom
calibrated-spaces
Sep 10, 2026
Merged

Read CalGray and CalRGB, and let a JPEG reach its colour space#50
tannevaled merged 1 commit into
mainfrom
calibrated-spaces

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Two defects, and the second is what made the first unreachable.

The spaces were read as their device namesakes

CalGray and CalRGB were mapped straight onto DeviceGray and DeviceRGB. They are not those spaces: each says what CIE XYZ its numbers stand for, under a white point of its own choosing, so reaching sRGB is a gamma, a matrix and a chromatic adaptation.

With the gamma the format defaults to, a CalGray component is linear luminance — a mid grey encodes to 188, not the 128 a device grey gives.

calibrated.go reads the two dictionaries and carries the format defaults; gfx/color v0.22.0 does the arithmetic (go-gfx/gfx#39, measured against poppler at 1 level in 256).

And a JPEG could not reach a colour space anyway

jpegThroughSpace asked the colour space only for a one-component picture in one of four named families:

switch sp.name {
case "Separation", "DeviceN", "Indexed", "Lab":
default:
    return nil
}
if sp.components != 1 { return nil }

So a three-component JPEG in a calibrated space was drawn from the decoder’s own colours and the space was never consulted — the new library would have had no caller. The rule is now what it should have been: a space that is not one of the four device singletons has something to say about the samples, whatever their count. The device spaces are package-level singletons, so identity is the whole test.

What it is worth

openpdf’s PDF 2.0 test file draws the same 1466-byte JPEG stream twice, once through a CalRGB (Adobe RGB matrix, D50, gamma 2.2) and once through DeviceRGB, and its own page text says the two should differ. Against pdfimages:

before after
peak 110 20
share of pixels differing 0.5764 0.0098
mse 909.34 0.30

The 20 that remains is not colour

Our conversion applied to poppler’s own decoded samples disagrees with poppler by one level. But a sample error of ±3 — exactly what the direct bucket measures for this same stream — moves the answer by up to 35 levels once a gamma of 2.2 and an Adobe RGB matrix have amplified it.

A calibrated space magnifies a decoder disagreement. That is worth knowing before reading any per-channel bound: the conformance gate is derived for a picture read directly.

Lab is deliberately left alone

Correction, after merge. This section said poppler's GfxLabColorSpace::getXYZ does not multiply by the white point where ISO 32000-2 8.6.5.4 says to. That was wrong, and it was wrong because I read one function instead of running the program: getXYZ indeed does not, but ::getRGB multiplies immediately after calling it (X *= whiteX;).

Settled by experiment: a hand-built four-pixel Lab document, extracted with pdfimages, matches the specification's formula on 4 of 4 pixels within one level and departs from the no-white-point formula by 13 levels on a neutral mid tone. The judge follows the specification, so there is no conflict and labSpace()'s grey stand-in is simply a defect of ours. It is fixed in a follow-up rather than here.

Lab is unchanged in this PR. For scale: 12 of 2268 form documents mention /Lab, and none of the 1013 scans.

Coverage stays at 100%.

🤖 Generated with Claude Code

Two defects, and the second is what made the first unreachable.

CalGray and CalRGB were read as DeviceGray and DeviceRGB. They are not:
each says what CIE XYZ its numbers stand for under a white point of its
own, so reaching sRGB is a gamma, a matrix and a chromatic adaptation. With
the gamma the format defaults to, a CalGray component is linear luminance
and a mid grey encodes to 188 rather than 128. calibrated.go reads the two
dictionaries and gfx/color v0.22.0 does the arithmetic.

jpegThroughSpace then asked the colour space only for a ONE-component
picture in one of four named families, so a three-component JPEG in a
calibrated space was drawn from the decoder's own colours and the space was
never consulted. The rule is now what it should always have been: a space
that is not one of the four device singletons has something to say about
the samples, whatever their count.

Measured on openpdf's PDF 2.0 test file, whose two pictures are the SAME
1466-byte JPEG stream drawn once through a CalRGB and once through
DeviceRGB, and whose own page text says they must differ:

  peak   110 -> 20      share  0.5764 -> 0.0098      mse  909.34 -> 0.30

The 20 that remains is not colour. Our conversion applied to poppler's own
decoded samples disagrees with poppler by ONE level; but a sample error of
+/-3, which is exactly what the direct bucket measures for this same
stream, moves the answer by up to 35 levels once a gamma of 2.2 and an
Adobe RGB matrix have amplified it. A calibrated space magnifies a decoder
disagreement, which is worth knowing before reading any per-channel bound.

Lab is deliberately left as it was. poppler's GfxLabColorSpace does not
multiply by the white point where ISO 32000-2 8.6.5.4 says to, so a correct
Lab and the judge would disagree for a reason that is not ours; that wants
an experiment of its own. 12 of 2268 form documents mention /Lab, none of
the 1013 scans.
@tannevaled
tannevaled merged commit 0a8e169 into main Sep 10, 2026
1 check passed
@tannevaled
tannevaled deleted the calibrated-spaces branch September 10, 2026 20:07
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