Skip to content

fix(bpc): estimate black through the tag family the xform actually uses - #2310

Merged
xsscx merged 1 commit into
masterfrom
fix/bpc-d2b-tag-family
Aug 25, 2026
Merged

fix(bpc): estimate black through the tag family the xform actually uses#2310
xsscx merged 1 commit into
masterfrom
fix/bpc-d2b-tag-family

Conversation

@maxderhak

@maxderhak maxderhak commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Problem

CIccXform::Create() chooses between the AToBx/BToAx colorimetric tags and the DToBx/BToDx MPE tags, and a caller forces the colorimetric ones by passing bUseD2BxB2DxTags=false to AddXform().

CIccApplyBPC rebuilds its own cmm objects to find the black point, and hardcoded that choice as version >= V5 ? colorimetric : MPE instead of asking the xform it is adjusting:

pICC->m_Header.version >= icVersionNumberV5 ? false : true

For a v4 profile carrying both families this is backwards. The transform applies BToAx while black is measured through BToDx, so the scale and offset BPC installs come from a pipeline that is never applied. These are exactly the profiles where the two pipelines are known to differ — that is why a caller opts out — so the error is not marginal, and it is silent.

Only the B-side is affected. Create()'s input branch gates its DToBx lookup on spectralPCS || version >= V5, so a plain v4 profile resolves device→PCS through AToBx whatever the flag says. The output branch has no such gate and follows the flag alone.

Fix

m_bUseD2BTags already existed on CIccXform but was dead — set false in the constructor and never written since.

  • Record it at the SetParams() sites in CIccXform::Create() and CIccXformMpe::Create(), after the icXformLutSpectral and icXformLutColorimetric adjustments have rewritten it, so it names the family the selection code actually walked rather than what the caller asked for.
  • Expose UseD2BTags() / SetUseD2BTags() as inline accessors — no layout or vtable change, so ABI is unaffected.
  • CalcFactors() already receives the xform, so only pixelXfm() and getBlackXfm() gain a parameter.

The Create() overload that is handed a tag outright keeps the constructor default, with a comment saying why: there is no selection to record, and no way for CIccApplyBPC to reproduce the caller's choice by rebuilding from the profile.

Blast radius

The V5 arm of the ternary is preserved, so only the explicit opt-out changes behaviour and the default path is byte-identical. The CLI tools cannot reach the changed configuration — iccApplyToLink decodes intent 10 + (no D2Bx) and 40 + (BPC) as mutually exclusive switch cases, so BPC there always implies bUseD2BxB2DxTags=true. No existing end-to-end baseline can shift; this is a library-API-only change.

Regression coverage

iccdev.bpc-d2b-tag-family builds a v4.3 CMYK printer profile in code whose BToA0 and BToD0 return different inks (cyan 0.60 vs 0.95) and whose AToB0 turns those into L* 40 and L* 5 — both clearing calcSrcBlackPoint()'s L*≤50 clip as distinct values, since a pair that both clipped would leave the test toothless.

Applying one CMYK pixel with BPC enabled:

flag true flag false BToD0 absent (reference)
before 0.499921 0.499921 0.349680
after 0.499921 0.349680 0.349680

Before the change the flag was inert and black came from BToD0 either way. After, false matches the BToA0-only reference exactly. The test exits 1 before and 0 after.

Verification performed

Configured and built with the repo's own vs2022-x64 preset (VS2022 Professional, MSVC x64 Release, vcpkg toolchain) and exercised through CTest:

Re-verified after the branch was rebased onto current master (53 newer commits, including the apply-path guard retirement #2307 and the CLUT channel-count bounds #2306): full reconfigure and rebuild, test #90 passes with byte-identical output, and the full non-slow suite is 133/133.

Also cross-checked earlier with g++ against a hand-built IccProfLib, producing byte-identical numbers to the MSVC run.

Left open

Two pre-existing gaps outside this fix's scope:

  • pixelXfm() still hardcodes icXformLutColorimetric, which closes the DToBx path on the input side for any profile. Moot for v4 non-spectral, but it means a v5 MPE-only profile cannot resolve a source black point at all.
  • The tag-passing Create() overload records nothing, so BPC on an xform built that way still assumes the colorimetric family.

🤖 Generated with Claude Code

@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 labels Aug 25, 2026
@xsscx xsscx self-assigned this Aug 25, 2026
CIccXform::Create() chooses between the AToBx/BToAx colorimetric tags and
the DToBx/BToDx MPE tags, and a caller forces the colorimetric ones by
passing bUseD2BxB2DxTags=false to AddXform().  CIccApplyBPC rebuilds its
own cmm objects to find black, and hardcoded that choice as
"version >= V5 ? colorimetric : MPE" instead of asking the xform it is
adjusting.

For a v4 profile carrying both families that is backwards: the transform
applies BToAx while black is measured through BToDx, so the scale and
offset BPC installs come from a pipeline that is never applied.  These are
exactly the profiles where the two pipelines are known to differ -- that
is why a caller opts out -- so the error is not marginal, and it is silent.

Only the B-side is affected.  Create()'s input branch gates its DToBx
lookup on spectralPCS or version >= V5, so a plain v4 profile resolves
device->PCS through AToBx whatever the flag says; the output branch has no
such gate and follows the flag alone.

m_bUseD2BTags already existed on CIccXform but was dead -- set false in the
constructor and never written since.  Record it at the SetParams() sites in
CIccXform::Create() and CIccXformMpe::Create(), after the icXformLutSpectral
and icXformLutColorimetric adjustments have rewritten it, so it names the
family the selection code actually walked rather than what the caller asked
for.  The Create() overload that is handed a tag outright keeps the
constructor default: there is no selection to record, and no way for
CIccApplyBPC to reproduce the caller's choice by rebuilding from the
profile.

CalcFactors() already receives the xform, so only pixelXfm() and
getBlackXfm() gain a parameter.  The V5 arm of the ternary is preserved:
only the explicit opt-out changes behaviour, so the default path is
byte-identical and the CLI tools cannot reach the changed configuration
(iccApplyToLink decodes intent 10+ and 40+ as mutually exclusive cases, so
BPC always implies bUseD2BxB2DxTags=true there).

Regression: iccdev.bpc-d2b-tag-family builds a v4.3 CMYK printer profile
whose BToA0 and BToD0 return different inks and whose AToB0 turns those
into L* 40 and L* 5, both clearing the L*<=50 clip as distinct values.
Applying one pixel with BPC enabled gives 0.349680 with the flag false and
0.499921 with it true; before this change both gave 0.499921, matching the
MPE result rather than the BToA0-only reference.

Two pre-existing gaps are left open: pixelXfm() still hardcodes
icXformLutColorimetric, which closes the DToBx path on the input side for
any profile, and the tag-passing Create() overload records nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@xsscx xsscx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2026-08-25 20:41:22 UTC

@xsscx
xsscx enabled auto-merge (squash) August 25, 2026 20:41
@xsscx xsscx added this to the v2.3.2.4 milestone Aug 25, 2026
@xsscx xsscx linked an issue Aug 25, 2026 that may be closed by this pull request
@xsscx
xsscx merged commit 96507a4 into master Aug 25, 2026
27 checks passed
@xsscx
xsscx requested a balanced review from Copilot August 25, 2026 20:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates BPC black-point estimation to use the tag-family preference recorded by the adjusted transform.

Changes:

  • Records and exposes the selected DToB/BToD tag-family preference.
  • Propagates that preference through BPC helper transforms.
  • Adds a focused CTest regression.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
IccProfLib/IccCmm.h Adds tag-family accessors.
IccProfLib/IccCmm.cpp Records tag-family selection during transform creation.
IccProfLib/IccApplyBPC.h Extends BPC helper signatures.
IccProfLib/IccApplyBPC.cpp Reuses the transform’s tag-family preference.
Build/Cmake/Testing/CMakeLists.txt Registers the regression test.
.github/ci/regression/bpc-d2b-tag-family.cpp Adds the synthetic-profile regression fixture.

Comment on lines +235 to +237
CIccCmm cmm(icSigCmykData, icSigLabData, true);
if (cmm.AddXform(pICC, icPerceptual, icInterpTetrahedral, NULL,
icXformLutColor, bUseD2BxB2DxTags, &hint) != icCmmStatOk)
Comment on lines +1375 to +1377
add_test(
NAME iccdev.bpc-d2b-tag-family
COMMAND "$<TARGET_FILE:iccBpcD2bTagFamilyTest>"
@@ -0,0 +1,314 @@
/*
@xsscx
xsscx deleted the fix/bpc-d2b-tag-family branch August 25, 2026 21:12
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.

CI: Punch List #2310 | Copilot Review Notes

3 participants