Skip to content

Compare per channel, with a magnitude gate and two aggregate terms - #19

Merged
tannevaled merged 7 commits into
mainfrom
per-channel-measure
Aug 31, 2026
Merged

Compare per channel, with a magnitude gate and two aggregate terms#19
tannevaled merged 7 commits into
mainfrom
per-channel-measure

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Implements the measure designed in #16, replacing the one #17 was honest about.

The old per-pixel predicate was dark(), a binarisation at luma 128, so the recorded share was not the fraction of pixels that differ but the fraction whose black/white classification differs. A decoder rendering every pixel at luma 120 where poppler renders luma 20 scored 0.000 — perfect agreement — on a 100/255 error at every pixel. The measure was blind in exactly the direction a lossy decoder fails.

This compares per channel, counts a pixel as differing only when its magnitude exceeds the gate, and carries two aggregate terms beside the count: a mean squared error for accumulated noise, and a signed mean for bias — the failure the old measure could not see at all.

Opened by the coordinator on the author's behalf: the branch was complete and committed but the push was refused in that session. The measurement findings and the CI confirmation follow in a comment.

…terms

The per-pixel predicate was a bisection at luminance 128, so Share was the
fraction of pixels whose ink CLASSIFICATION differed and not the fraction that
differed. A decoder rendering every pixel at 120 where poppler renders 20
scored 0.000 on an error of 100 levels everywhere — blind in exactly the
direction a lossy decoder fails.

difference now subtracts channel by channel and carries four terms out of one
walk: Peak, the largest single-channel difference, which is the criterion; a
count of pixels above Gate, reported as Share and never spent as a budget;
and MSE and a SIGNED mean, in levels squared and levels, which are FFmpeg's
omse and ome and pdfium's mse units. The signed mean is the term that catches
bias, which is what the bisection could not see at all. Neither aggregate is
a pass criterion: no bound has been measured for pictures that were extracted
rather than rendered, and borrowing pdfium's would repeat the mistake the
withdrawn 1% tolerance was.

Gate is 2, derived rather than borrowed. pdfium's 3 buys rasteriser slack we
have no use for since pdfimages extracts; ISO/IEC 10918-2 allows a conformant
IDCT one level per sample either side of the reference
(libavcodec/tests/dct.c:259), so two conformant decoders may differ by two.

The three things the rule did not settle:

- Colour conversion. Each picture now carries the colour space pdfimages
  reports (ImageOutputDev.cc:152-190), and the pictures poppler had to convert
  to reach RGB are tallied in their own bucket with their own agreement figure
  and their own magnitudes. Only gray, rgb and "-" count as direct; index is
  counted converted because the base space is not reported, and a picture that
  cannot be classified must not be credited as agreement. An unread listing
  leaves everything converted, which is deliberately loud.
- Polarity. Inverted is computed in the same pass against the judge's
  complement and stays its own signal. The direct comparison is tried first,
  so a uniform mid-grey — within the gate of its own complement — is reported
  as agreeing rather than as a convention.
- Alpha. An ordinary picture is compared in R, G and B; a stencil in one
  derived coverage channel, ours the alpha render puts the shape in and theirs
  255 minus the luminance, because poppler's black is where a stencil paints.

Also: pictures are ordered by the number pdfimages wrote into the file name
rather than lexically, which mattered for the pages that hold more than a
thousand of them, and the baseline record now carries the gate it was taken
at, because a record taken by another instrument is not comparable with one
taken by this.
render labels Stencil on three different things — a /ImageMask true stencil,
a /SMask and a /Mask — and does NOT put them in the same channels. Read out
of the buffers rather than assumed:

  us-opm/SF2801PR.pdf Im0    /ImageMask true, 325x240, every RGB nought,
                             two alpha values: the shape is in ALPHA.
  us-opm/sf2822.pdf Im0/SMask  eight-bit grey, 116x73, alpha 255 at all
                             8468 pixels: the levels are in RGB.

Taking the alpha of the second gives 255 everywhere, which is why us-opm's
one agreeing (samples) mask read as a 48% disagreement with a peak of 255 and
a mean of +88.6 — an artefact of the reduction and not a decoder.

So a mask is reduced by ONE formula applied to both sides: alpha times 255
minus the luminance, over 255. On a stencil the luminance is nought and it is
the alpha; on a soft mask the alpha is 255 and it is the inverted luminance;
on poppler's side, always opaque grey, it is the inverted luminance. All three
layouts land on the same quantity, and the judge parameter goes away because
the two sides are now read the same way.
The rule section becomes the derivation of D and N; the description of what
is compared becomes a description of what the code does, including the three
things #16 said the rule did not settle — colour conversion counted apart,
polarity kept as its own signal, and one ink-coverage formula for a mask.

The tables of figures are not touched here. They are the ink-classification
measure and are replaced by the re-measurement, not by an edit.
One gate applies to every filter, which is a loosening for the lossless ones:
JPEG 2000 conformance is required exact on most of ISO/IEC 15444-4's files and
CCITT and JBIG2 have no rounding at all, so a 100% on those used to be bit
equality and under a gate of 2 it is not necessarily.

So a bucket now counts Identical beside Exact — how many agreeing pictures had
no channel differ AT ALL — and the record carries it. Without it a reader
cannot tell an agreement rate that is bit equality from one the gate carried,
which is the question a uniform gate raises and the baseline has to answer.
The section claimed D reached 2 as a per-case exception for the two lossy
filters. What landed is one constant applied to every filter, so the document
now says that, names the consequence — it is a loosening for the lossless
ones — and points at the count that measures the loosening rather than
leaving a reader to assume bit equality.
@tannevaled

Copy link
Copy Markdown
Contributor Author

[2026-08-31 13:18:04 CEST]

Answering the five questions. The re-measurement is not finished — the
scanned corpus is, the forms corpus is running now — so every figure below is
labelled with the population it came from, and the fleet answer follows in a
second comment. Nothing here is extrapolated to the populations that have not
run.

1. The band was an artefact of the bisection, and what replaced it argues for N = 0

The 14-row low group is gone. Under the bisection, eighteen population ×
lossy-filter rows sorted by median share fell into fourteen from 0.000011 to
0.001966, then nothing, then four from 0.047161 to 0.898821. The low fourteen
were pictures where a handful of pixels crossed luminance 128 — one pixel in
seven thousand, in the worst case. Every one of that kind is now inside the
gate and counted as agreeing
, so it has left the differing column entirely.

ia-texts is the clearest single case. Under the bisection its JPXDecode
column read 9 pictures, 3 exact, 6 differing at a median of 0.0000168.
Under the gate it reads 10 pictures, 10 exact, 0 differing — and the record
also says only 2 of the 10 are bit-identical, so those pictures are conformant
rather than equal, which is what JPEG 2000 promises and what the bisection had
no way to express.

What is left is not a band with an empty middle. These are every direct
bucket in the scanned corpus that had anything differ, ordered by median share:

population filter differing share med share worst peak med peak worst mse med mean med
ia-uscourts DCTDecode 11 0.007024 0.227292 23 81 0.2898 −0.0001
ia-medical DCTDecode 1 0.070221 0.070221 62 62 1.3258 +0.0215
ia-uscourts (samples) mask 2 0.958333 0.958333 255 255 62315.6 −240.39
ia-biodiversity JPXDecode 10 0.994521 0.999976 255 255 17813.9 +34.26
ia-uscourts (samples) 2 1.000000 1.000000 207 207 16181.4 +96.87

There is still a gap in share — 0.070 to 0.958 — but it is a gap between
different objects and it licenses nothing.
The two rows below it have peaks
of 23 and 62, eleven and thirty-one times the gate. A 1% share budget would
forgive ia-uscourts's DCTDecode row outright: 0.7% of its pixels differ,
and they differ by up to 81 levels. That is Cairo's objection stated in our own
data — "otherwise some problems could be masked" — and it is the argument for
keeping N at 0 and letting the peak decide.

So: the empty band did not survive, the reasoning that read a threshold off it
was measuring the bisection, and under a magnitude measure a share threshold is
not merely unjustified but has nothing near it to arbitrate.
Reported as
found; the convenient answer would have been that the band survived and 1% was
right all along.

The forms corpus is where most of the old low group lived (fr-cerfa DCT at
0.000135, gh-pdfcpu at 0.000909, uk-govuk at 0.000013). Whether it behaves
the same way is a measurement, not a prediction, and it is running.

2. What the re-measurement covers, and what it does not

Re-measured with this instrument — per channel, gate D = 2, N = 0,
render v0.20.0, pdfimages 26.04.0 — four populations, the whole
/Users/Shared/pdfscans corpus except ia-americana:

population documents unopenable refused pictures direct compared exact identical agreement converted
ia-texts 12 7 0 14 11 11 2 100.0% 0
ia-uscourts 250 0 0 134 78 63 54 80.8% 16
ia-medical 250 0 0 745 529 528 33 99.8% 1
ia-biodiversity 250 0 4 781 644 634 157 98.4% 0

identical is new and it is the column to read beside the rate. One gate
applies to every filter, which is a loosening for the lossless ones, so the
record now says how many agreeing pictures differed by nothing at all.
ia-medical's JPXDecode is the striking one: 496 pictures, 496 exact, 1
identical
. JPEG 2000 agrees with poppler within two levels everywhere and is
bit-equal almost nowhere. Under the bisection that column read 15.4% across
the fleet. Both numbers are true of different questions, which is why they are
not in the same table.

Its mirror holds too: JBIG2Decode mask is 187 exact of 187 compared and 187
identical
, and JBIG2Decode 9 of 9, all identical. The gate bought the
lossless filters nothing, which is the answer to the question a uniform gate
raises.

Still carrying old figures: every population of
/Users/Shared/pdfformsca-cra, fr-cerfa, fr-impots, gh-openpdf,
gh-pdfbox, gh-pdfcpu, gh-pypdf, gh-qpdf, gh-safedocs, gh-verapdf,
int-wipo, uk-govuk, us-dol, us-irs, us-opm, us-ssa, us-uscis,
us-uscourts — plus pdfscans-ia-americana. Their records under baseline/
are the ink bisection at render v0.19.0 and must not be subtracted from
anything above.
They are running now and this PR is not finished until they
land or are named as failed.

A new finding that is not about the measure. ia-biodiversity reports
4 refused — documents poppler opens and ours will not. That population had
never completed, so the old baseline's "refused is 0 across all 2724
documents of the 20 populations that ran"
did not cover it. Which four
documents, and why, is not yet established
; identifying them is a second pass
over that corpus and I have not run it rather than guess.

3. The three populations that never completed

population old outcome now
pdfscans-ia-biodiversity killed at 24.9 GB, rc=137 completed, peak 3.9 GB
pdfscans-ia-americana hit the 45-minute cap, rc=124, memory flat in progress — a first attempt reached 2.8 GB before my own session ended and killed the driver (rc=143, not the cap); restarted under a 90-minute cap
pdfforms-gh-openpdf killed at 27.5 GB, rc=137 not reached yet — it is in the forms queue now

render v0.20.0's fix is confirmed for the first of the two allocation
failures: 24.9 GB to 3.9 GB, and the population produced a full record. The
second has not been tried yet and I am not claiming it in advance.

4. The three problems #16 left open

Colour conversion — recorded and counted apart, not absorbed. Each picture
now carries the colour space pdfimages reports for it
(utils/ImageOutputDev.cc:152-190: gray, rgb, cmyk, lab, icc,
index, sep, devn, and - for a mask). Only gray, rgb and - count
as direct; everything else is converted and goes in its own bucket,
with its own agreement figure and its own magnitudes. The agreement rate is
computed over the direct bucket and never over the two together. Widening D
to swallow ICC arithmetic would have destroyed the gate, which is what #16 said
must not happen.

Two honesties written into the doc comment. index counts as converted even
though its base is often DeviceRGB, because pdfimages does not report the
base and a picture that cannot be classified must not be credited. And
poppler folds CalGray onto gray and CalRGB onto rgb, so a few pictures
counted direct did pass a CIE conversion — that is poppler's resolution, not a
claim of ours. A picture whose listing row could not be read at all also lands
in the converted bucket, so a failure of pdfimages -list reads as every
filter wholly converted
rather than as quiet generosity.

Inverted — its own signal, computed in the same pass. The complement is
tested against the same gate, and inverted means the direct comparison failed
the gate and the complemented one passed it
. The direct comparison is tried
first, which matters: a uniform mid-grey is within the gate of its own
complement, and without that ordering a picture that agrees would be filed away
as a polarity convention and dropped out of the rate. It also generalises — a
stencil convention carried through a codec that rounds is still recognised,
where Share == 1 needed exactness.

Alpha — one formula, after reading the buffers rather than the flags. This
is where the first draft was wrong and the correction is the second commit.
render sets Stencil on three different things and does not put them in
the same channels:

  • us-opm/SF2801PR.pdf Im0, /ImageMask true, 325×240 — every RGB nought,
    exactly two alpha values: the shape is in alpha;
  • us-opm/sf2822.pdf Im0/SMask, eight-bit grey, 116×73 — alpha 255 at all
    8468 pixels: the levels are in RGB.

Taking the alpha of the second gives 255 everywhere, and it made us-opm's one
agreeing (samples) mask read as a 48% disagreement with a peak of 255 and a
mean of +88.6
— an artefact of the reduction, not a decoder. So a mask is
reduced by one formula applied to both sides, alpha × (255 − luminance) ÷ 255: on a stencil the luminance is nought and it is the alpha, on a soft mask
the alpha is 255 and it is the inverted luminance, and on poppler's side —
always opaque grey — it is the inverted luminance. All three layouts land on
the same quantity, and the judge parameter went away because the two sides
are now read identically. An ordinary picture is compared in R, G and B, and
alpha is left out of those because pdfimages writes an opaque picture for
anything that is not a mask.

5. CI

Green on the PR head by REST:

$ gh api repos/go-pdfkit/conformance/commits/89476149f4c23975d9f8e34cf61f258befb95dd6/check-runs
1
Build + test + exact 100% coverage gate   completed   success   2026-08-31T11:15:42Z

Locally the same head passes go vet, -race, the nine cross-compile targets,
CGO_ENABLED=0, GOWORK=off, exact 100% statement coverage on every package,
and gofmt -l empty under the Go 1.27.0 toolchain rather than the local
1.26.4. Not merged.

What is not done

The forms corpus and ia-americana are running; baseline/ still holds the
old records and baseline/README.md still describes the old instrument. Both
are replaced when the run lands, and the populations that fail will be named in
the document rather than omitted. The four ia-biodiversity refusals are
counted and not diagnosed.

No timings anywhere. A fleet sweep is running on this machine throughout,
so every duration measured here would be a measurement of that sweep as much as
of this. Counts and pixel comparisons are unaffected by load; wall-clock is not.

Every record under baseline/ was taken by the ink bisection at render
v0.19.0 and none of it can be subtracted from a per-channel figure, so all of
it is replaced. Both seams are now behind the records rather than in front of
them: one instrument, one library version, 23 populations, 3280 documents,
zero failures.

The three populations that never completed all completed. ia-biodiversity
went from 24.9 GB and rc=137 to a 3.9 GB peak and a full record, gh-openpdf
from 27.5 GB likewise, so render v0.20.0's tree-for-a-graph fix is confirmed
on both. ia-americana ran to 53 minutes at a 5.2 GB peak: it was never a
defect, only a slow population of large scans behind a 45-minute cap.

Two findings the old instrument could not have made.

The empty band that produced the 1% is gone. Fourteen of eighteen rows used
to sit below 1% with a factor of 24 of nothing above them; two of twenty-one
do now, and the largest gap anywhere in the ordered medians is 6.8. Worse for
the withdrawn rule, those two rows have peak medians of 18 and 23 levels —
nine and eleven times the gate — so what sits under 1% is no longer rounding
but sparse gross error, and a share budget there would forgive pictures wrong
by up to 81 levels. N stays at 0.

And the two lossy filters separate. JPXDecode goes from 15.4% to 99.2% with 7
of 1225 bit-identical, which is exactly what a conformant lossy decoder looks
like. DCTDecode does not move — 33.2% to 34.0% — so it differs from poppler by
more than the ISO/IEC 10918-2 IDCT allowance on two thirds of what it was
compared on, by 16 to 62 levels in most populations. That is a specific decode
finding and it is recorded, not diagnosed.

Also recorded and not diagnosed: ia-biodiversity reports 4 refused, the only
nonzero refusal in 3280 documents. That population had never completed, so the
previous "refused is 0" was true of the 20 that ran and untested here.
… gap

Named and diagnosed rather than left as a gap. All four ia-biodiversity
documents open and their first page resolves; what fails is render.Images with
ErrTooMuchToDecode, because v0.20.0 gives one call a 256-megapixel budget and
refuses the whole page when a picture will not fit in what is left. The
clearest case is an ordinary 794x1372 picture refused with 223089 of 268435456
pixels remaining.

That is the other half of the fix that let the population run at all: the same
change that took it from a 24.9 GB kill to a 3.9 GB peak did it by bounding
the decode, and a bound that fires reads here as a refusal.

So Missing.Ours folds two different things together — a document we cannot
read and a page we decline to decode — and only the first is a defect. The
counts are right and the label is too coarse; splitting it is not part of #16
and is recorded rather than done.
@tannevaled

Copy link
Copy Markdown
Contributor Author

[2026-08-31 14:40:12 CEST]

The re-measurement finished. All 23 populations ran to completion, 3280
documents, zero failures
, so every record under baseline/ is now this
instrument at render v0.20.0 and nothing is inherited. Corrections to my
previous comment are marked.

1. The band — the full-fleet answer, and it is the unconvenient one

The withdrawn 1% was read off eighteen rows: fourteen medians from 0.000011 to
0.001966, then a factor of 24 of nothing, then four from 0.047161 to
0.898821. Here are all 21 direct-bucket rows of this run, ordered:

0.005553 0.007024 0.047733 0.069908 0.070221 0.072224 0.080903 0.083304
0.173316 0.222222 0.256679 0.315150 0.369112 0.369672 0.412628 0.888889
0.895425 0.958333 0.994521 1.000000 1.000000

The band is gone as a band. Largest gap anywhere: a factor of 6.8
(0.007024 → 0.047733), then 2.15 and 2.08 — the rest is a continuum. Where
fourteen of eighteen rows sat below 1%, two of twenty-one do.

But I have to report something sharper than "the band moved", because the
honest answer is worse for the withdrawn rule than "it is gone".
1% still
falls inside that 6.8× gap, so a careless reading of this list would say the
band survived, narrower. It did not survive as the same thing:

under the bisection per channel
what the low group was pixels that crossed luminance 128 — rounding by construction, since one level could flip a pixel uk-govuk DCTDecode and ia-uscourts DCTDecode, peak medians 18 and 23 levels — nine and eleven times the gate
what a 1% budget would buy forgive rounding forgive ia-uscourts, where 0.7% of pixels are wrong by up to 81 levels

The old low group has not moved below the new one — it has left the differing
column entirely
, absorbed into the gate. What now sits under 1% is sparse
gross error
, which is exactly Cairo's "otherwise some problems could be
masked"
. So the number that could be read off the new gap would buy the
opposite of what the old one was meant to buy. N stays at 0, and the
argument for it is now measured rather than borrowed.

2. The finding this was built to make: JPX and DCT are not the same problem

They read 15.4% and 33.2% under the bisection and looked alike.

filter compared exact identical agreement was
JPXDecode 1225 1215 7 99.2% 15.4%
DCTDecode 430 146 4 34.0% 33.2%

JPEG 2000 agrees within two levels on 99.2% and is bit-equal on 7 of 1225
ia-medical alone is 496 pictures, 496 exact, 1 identical. That is a
conformant lossy decoder, and the old 15.4% was measuring how often our
rounding and poppler's fell on opposite sides of luminance 128.

DCTDecode did not move. The gate rescued JPEG 2000 and did not rescue
JPEG, so our JPEG decoder differs from poppler by more than the ISO/IEC
10918-2 IDCT allowance
on two thirds of what it was compared on. The
per-population medians of the worst channel are 16, 18, 23, 23, 26, 31, 34, 47,
50, 58, 62, 171, 244, 255 — sixteen to sixty-two levels is neither rounding nor
a wholly wrong picture, which is the shape the bisection could not see. It is
recorded and not diagnosed; match can manufacture disagreements
(#13) and no pairing
audit was run.

3. All three populations that never finished, finished

population before now
pdfscans-ia-biodiversity killed at 24.9 GB, rc=137 completed, peak 3.9 GB
pdfforms-gh-openpdf killed at 27.5 GB, rc=137 completed
pdfscans-ia-americana 45-minute cap, rc=124, "whether it is merely a slow population or something that does not terminate is not known" completed, peak 5.2 GB, in 53 minutes under a 90-minute cap

render v0.20.0's tree-for-a-graph fix is confirmed on both allocation
failures. The third was never a defect — a slow population of large scans and a
cap that was too short — and that open question is closed.

4. Two corrections to my previous comment

The four ia-biodiversity refusals are not a coverage gap. I reported them
undiagnosed; they are diagnosed now, and they are our own decode budget.
All four documents open and their first page resolves; render.Images fails
with ErrTooMuchToDecode, because v0.20.0 gives one call 256 megapixels
(images.go:110) and refuses the whole page when a picture will not fit in
what is left. The clearest case is an ordinary 794 × 1372 picture refused
with 223 089 of 268 435 456 pixels remaining.

That is the other half of the fix in finding 3: the same change that took
ia-biodiversity from a 24.9 GB kill to a 3.9 GB peak did it by bounding
the decode, and a bound that fires reads here as a refusal. So Missing.Ours
folds "cannot read" together with "declined to decode", and only the first is a
defect. Splitting them is outside #16; the four are named in
baseline/README.md so nobody reads the 4 as a coverage gap.

And (samples) moved more than I said. Splitting the colour-converted
pictures out moves 3083 of its 4292 into their own bucket — fr-cerfa
alone contributes 2949 — leaving 916 compared at 69.7% against the old
84.9% over 3810. The two are not comparable and neither is "the" answer: the
old one averaged two implementations' ICC arithmetic into a decoder's score,
the new one declines to score it. The converted bucket keeps its magnitudes so
the diagnosis can be checked, and it holds up: fr-cerfa's 1788 differing
converted pictures have a median peak of 19 levels, its 232 differing
direct ones a median peak of 115.

5. The gate bought the lossless filters nothing

The question a uniform D = 2 raises, answered rather than assumed:

filter compared exact identical
JBIG2Decode 10 10 10
JBIG2Decode mask 250 250 250
JPXDecode mask 2 2 2
(samples) 916 638 638
(samples) mask 1117 1074 1074

Every agreeing picture of every lossless filter is bit-equal. Not one
needed the gate, so one constant costs the lossless filters nothing and there
is still no measured case for a per-filter exception table.

6. CI, by REST, on the final head

$ gh api repos/go-pdfkit/conformance/commits/3e8778f72d9f175606f1e0b794f6ae5f6cb5ea74/check-runs
Build + test + exact 100% coverage gate   completed   success   2026-08-31T12:39:03Z

Locally the same head passes go vet, -race, nine cross-compile targets,
CGO_ENABLED=0, GOWORK=off, exact 100% statement coverage on every package,
and gofmt -l empty under the Go 1.27.0 toolchain rather than the local
1.26.4. Not merged.

What is still open, named rather than reconstructed

  • DCTDecode's 284 differing pictures are not diagnosed. Finding 2 says
    the disagreement is real and moderate; it does not say whose it is.
  • No aggregate bound is applied. mse and mean are recorded in FFmpeg's
    and pdfium's units and bounded by nothing, because none has been measured for
    pictures that were extracted rather than rendered. Choosing one from these
    records is now possible and is a later run's job.
  • Missing.Ours is too coarse, per correction 4.
  • One page per document. A first page is not a document.

No timings are reported anywhere. A fleet sweep ran on this machine
throughout, so any duration here would measure that sweep as much as this one.
The two wall-clock figures above — the 45-minute cap and the 53 minutes —
appear only because they are the subject of finding 3, and they are compared
against a cap rather than against each other.

@tannevaled
tannevaled merged commit cea7e17 into main Aug 31, 2026
1 check passed
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