Skip to content

fix(#1976): report the gamut transform's destination as gamt, and classify on zero - #1979

Merged
xsscx merged 1 commit into
masterfrom
issue-1976-gamut-semantics
Aug 5, 2026
Merged

fix(#1976): report the gamut transform's destination as gamt, and classify on zero#1979
xsscx merged 1 commit into
masterfrom
issue-1976-gamut-semantics

Conversation

@colourbill-ctrl

Copy link
Copy Markdown
Contributor

Part of #1976.

Fixes both defects reported there. Deliberately not Fixes #1976 — see "What this
does not include" at the end; the close is @xsscx's call.

Reported by @xsscx, whose ci-qa-maintainer-test-branch established the bisect to
1f0a9dd (2015) and confirmed 321dbb1 (#1177) as a faithful rewrite rather than the
origin of the threshold. Both findings reproduce; the analysis below is my own
verification, with one correction to the framing.

1. The gamut transform could never be used

ICC.1:2022 9.2.29 allows gamutTag to be lut8Type, lut16Type or lutBToAType — all
B-to-A shaped — so CIccXform::Create()'s icXformLutGamut case forces bInput false to
traverse the tag in its stored direction. But m_bInput also drives GetDstSpace() and
GetNumDstSamples(), whose !m_bInput branches answer with the profile's device space:

reported by the xform recorded by AddXform
destination space m_Header.colorSpace (e.g. CMYK) icSigGamutData
destination samples 4 1

Begin()'s trailing output-count guard compares GetDestSamples() against the last
xform's GetNumDstSamples() and rejected the chain with icCmmStatBadSpaceLink.

Every gamt-bearing profile in the corpus failed. A full tag-table scan of all 255
.icc under Testing/ finds exactly 7 carrying gamutTag. Measured on master c0cd71db
with iccApplyNamedCmm <lab> 3 0 <profile> 30:

profile gamt type master this PR
Testing/V2/v2CmykLut16.icc lut16Type rc=1 Invalid space link rc=0
Testing/CMYK-3DLUTs/CMYK-3DLUTs.icc lutBtoAType rc=1 rc=0
Testing/CMYK-3DLUTs/CMYK-3DLUTs2.icc lutBtoAType rc=1 rc=0
Testing/hybrid/ICC/CMYK_Hybrid_Profile.icc lutBtoAType rc=1 rc=0
Testing/hybrid/ICC/CMYK-W_Overprint_Profile.icc lutBtoAType rc=1 rc=0
Testing/hybrid/ICC/CMYK-S_Overprint_Profile.icc lutBtoAType rc=1 rc=0
Testing/hybrid/ICC/CMYK-STop_Overprint_Profile.icc lutBtoAType rc=1 rc=0

7 of 7 red, 7 of 7 green. The path had no working caller in the tree.

SetGamutXform() now marks the transform so its reported destination stops following the
traversal direction, leaving m_bInput doing only the one job it is meant to do.

Two scope decisions

A setter, not a SetParams() parameter. SetParams() is documented as the API for
overridden Create functions, so an added parameter is a public surface change every
out-of-tree subclass inherits. CIccXform already has this exact idiom one line above —
ShareProfile(), SetPcsAdjustXform() — so SetGamutXform() matches it and changes no
existing signature.

The source side is deliberately untouched. Extracting both case icXformLutGamut:
blocks confirms neither assigns bUseSpectralPCS, so it stays false and GetSrcSpace()
already returns m_Header.pcs — exactly what AddXform records. Overriding the source
accessors too would be redundant.

2. IsInGamut() applied an 8-bit threshold to a tag that need not be 8-bit

if (*pInternal < (1.0f/255.0f))   // in gamut

1/255 is exactly 257/65535, so every 16-bit code below 258 read as in gamut. The tag
defines zero as in gamut and every non-zero value as out.

Correction to the report's framing

The report cites a measured 1.5259e-05 (= 1/65535) as the misclassified value. That is
right about the encoding but not about how the defect is reached on real profiles.

Every gamt value in all 7 corpus tables is ≡ 0 (mod 257) — 8-bit values replicated ×257
— so wherever a non-zero node exists at all, the smallest is exactly 257/65535 == 1/255,
which the old test classified correctly. What exposes the threshold is
interpolation between a zero node and a non-zero one, where intermediate values fall
underneath.

Sweeping 3444 Lab coordinates through CMYK-3DLUTs.icc:

92 of 3444 results land in (0, 1/255)      <- all reported in gamut by the old test
smallest: 7.13166e-15

So the defect is real and reachable on tracked corpus data, but through the interpolator
rather than through a stored node. Without that measurement the change looks unreachable
on the corpus, so it is recorded in the code comment and the test.

The comparison is now against zero. NaN and infinity both fail it and so report out of
gamut — the safe verdict for a value the transform could not produce meaningfully.

Coverage of the other gamut paths

CIccXformMpe::Create() gets the same marking: no in-tree caller, but it is public API and
its gamut case forces bInput false identically. CIccCmm::AddXform() and
CIccNamedColorCmm::AddXform() both route through CIccXform::Create() with the lut type
intact, so they are covered without further change. The tag-based AddXform overload has
no gamut concept — it never mentions icSigGamutData — and is untouched.

Test

iccdev.gamut-xform-semantics (.github/ci/regression/gamut-xform-semantics.cpp), pinning
both the connection metadata and the exact-zero classification, registered in both call
lists in Build/Cmake/Testing/CMakeLists.txt.

It builds its gamt profile in memory: all seven gamt-bearing profiles under Testing/
are generated from XML by the suite rather than tracked, so a fixture-loading test would
depend on generation order.

Each assertion is red-green against one half of the fix — verified by ablation, and
re-verified after every restructuring of the test:

tree state failing assertions
master (both defects) 4 — three classification, one connection
only IsInGamut reverted 4 classification (incl. the end-to-end one, which now reaches Apply)
only destination accessors reverted 1 connection
this PR 0

The sub-cutoff assertion uses 256.0f/65535.0f — the largest 16-bit code the old threshold
swallowed — rather than a nextafter() step, so it is an exact binary value on every
platform and names the real boundary.

Pre-flight

lane result
GCC 15.2.0, CI regression container, Release+LTO, -Wall -Wextra -Wpedantic -Werror, strict warnings ENABLED grep -cE 'warning:' = 0, errors = 0
clang 18 Debug, full build 0 warnings; ctest 150/151
clang ASan+UBSan Debug, ASAN_OPTIONS=detect_leaks=1 ctest 150/151; 0 sanitizer findings
git diff --check clean

The single failure in every lane is iccdev.spectral-tiff-preview — a missing local python
imagecodecs module, failing identically on unmodified master here, not a regression.

All 7 corpus profiles above were also re-run under ASan+UBSan with leak detection: rc=0,
zero findings.

What this does not include

ci-qa-maintainer-test-branch also carries synthetic .icc/.xml/.json/.tif fixtures,
a shell regression driver, and an iccApplyProfiles intent-30 TIFF path. None of that is
here: the two library defects reproduce on profiles already in the corpus, so the added
fixtures are not needed to pin them, and the tool-level coverage is a separate scope call.
That is why this is Part of rather than Fixes — if you consider the tool-level
regressions in scope for #1976, they still need doing.

@github-actions github-actions Bot added Testing CTest, regression, or test coverage Source C or C++ source code changes Configuration Repository, CMake, YAML, JSON, or tool configuration Build Build system, CMake, compiler, or packaging pending CI checks still running passed All CI checks passed and removed pending CI checks still running labels Aug 5, 2026
…ssify on zero

Two independent defects on the gamt path, both reachable on the existing corpus.

The gamut transform could never be used. ICC.1:2022 9.2.29 allows gamutTag to be
lut8Type, lut16Type or lutBToAType -- all B-to-A shaped -- so the icXformLutGamut
case in CIccXform::Create() forces bInput false to traverse the tag in its stored
direction. m_bInput also drives GetDstSpace() and GetNumDstSamples(), whose
!m_bInput branches answer with the profile's device space. CIccCmm::AddXform() had
correctly recorded PCS -> icSigGamutData, so the two disagreed and Begin()'s
trailing output-count guard rejected the chain with icCmmStatBadSpaceLink. All
seven gamt-bearing profiles under Testing/ failed this way; the path had no working
caller. SetGamutXform() now marks the transform so its reported destination stops
following the traversal direction, which leaves m_bInput doing only the one job it
is meant to do.

CIccCmm::IsInGamut() applied an 8-bit threshold to a tag that need not be 8-bit,
returning true for anything below 1/255 -- a faithful float rewrite of the earlier
(unsigned)(v*255.0) truncation, so the assumption predates that rewrite. Since
1/255 is exactly 257/65535, every 16-bit code below 258 read as in gamut. All seven
corpus gamt tables store 8-bit values replicated x257, so wherever a non-zero node
exists the smallest is exactly 257 and the old test judged it correctly; it is
interpolation between a zero node and a 257 node that falls underneath. Sweeping
3444 Lab coordinates through CMYK-3DLUTs.icc yields 92 out-of-gamut results the old
test called in gamut, the smallest ~7.1e-15. The comparison is now against zero, as
the tag definition specifies; NaN and infinity both fail it and so report out of
gamut, which is the safe verdict.

CIccXformMpe::Create() gets the same marking. It has no in-tree caller but is
public API and its gamut case forces bInput false identically. The two AddXform
paths that build gamut transforms both route through CIccXform::Create(), so they
are covered; the tag-based AddXform overload has no gamut concept and is untouched.
Neither Create() gamut case touches bUseSpectralPCS, so GetSrcSpace() already
returns the PCS that AddXform records and the source side needs no change.

Adds iccdev.gamut-xform-semantics, which pins the connection metadata and the
exact-zero classification. Each assertion is red-green against one half of the fix:
reverting only IsInGamut fails the four classification assertions, reverting only
the destination accessors fails the connection assertion. The test builds its gamt
profile in memory because every gamt-bearing profile under Testing/ is generated
from XML rather than tracked, so loading one would depend on generation order.

Reported by @xsscx, whose ci-qa-maintainer-test-branch established the bisect to
1f0a9dd (2015) and confirmed 321dbb1 (#1177) as a faithful rewrite rather than the
origin of the threshold.
@xsscx
xsscx force-pushed the issue-1976-gamut-semantics branch from c96b45d to 49e2362 Compare August 5, 2026 11:11
@xsscx xsscx added this to the v2.3.2.3 milestone Aug 5, 2026
@xsscx xsscx linked an issue Aug 5, 2026 that may be closed by this pull request
@xsscx xsscx linked an issue Aug 5, 2026 that may be closed by this pull request
@github-actions github-actions Bot added pending CI checks still running and removed passed All CI checks passed labels Aug 5, 2026
@xsscx

xsscx commented Aug 5, 2026

Copy link
Copy Markdown
Member

the tool-level coverage is a separate scope call

I will add to TODO for the tool coverage.

Thank You.

@xsscx
xsscx merged commit 547c4be into master Aug 5, 2026
36 checks passed
@xsscx
xsscx deleted the issue-1976-gamut-semantics branch August 5, 2026 11:29
@xsscx xsscx linked an issue Aug 5, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build Build system, CMake, compiler, or packaging Configuration Repository, CMake, YAML, JSON, or tool configuration pending CI checks still running Source C or C++ source code changes Testing CTest, regression, or test coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bisect: 1f0a9dd CIccCmm::IsInGamut()

2 participants