Skip to content

Commit df2f057

Browse files
iccviz: improve GamutVolume accuracy; reuse library CIELAB; fix warning
GamutVolume (#2 accuracy, #3 degeneracy): - Boundary sampling now covers ALL 2N cube facets (fix one coord, grid the other N-1), not just the 2-skeleton. Identical point set for N=3 (RGB unchanged); captures the 3-face interiors for N>=4, so CMYK+ gamuts are no longer under-captured. gamutVolumeParams / the size guard use the new 2N*(S+1)^(N-1) count via a shared boundarySampleCount() helper. - Dilation and erosion are now MATCHED: the cube (Chebyshev) dilation is peeled back by a 26-neighbour (Chebyshev) erosion, making dilate-then-erode a proper morphological closing. Removes the ~9% convex-corner over-estimate the old box-dilate / 6-neighbour-erode mismatch produced. Verified dilate-stable and voxel-size-convergent. - Out-of-box Lab points are dropped (not clamped onto the box face). - New GamutVolumeResult.degenerate flag: set when the boundary sampling was mostly non-finite or the enclosed region is at the voxel-resolution floor, so a caller can show N/A instead of a misleading tiny number. BEHAVIOUR CHANGE (user-facing metric): reported gamut volumes change because the corner over-count is gone. Example: sRGB A2B1 at the default voxelSize=2.0 goes 805728 -> 735816 dE*ab^3. Values are now dilate-stable and converge downward as voxelSize shrinks (quantisation). Update any baselines/changelog. neutralSrc (#4): reuse IccProfLib icLabtoXYZ (nullptr white => D50) instead of hand-rolling the L*->XYZ inverse companding, so the CIELAB constants live in one place. addPrimaryPoint (#5): silence the unused colorHint parameter (reserved for a future per-point colour hint). Verified: g++ -Wall -Wextra -Wunused-function clean; linked and run on sRGB v4 (N=3) and CMYK-3DLUTs (N=4), plus hostile voxelSize/dilate args, all sane. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bfe56c4 commit df2f057

2 files changed

Lines changed: 97 additions & 53 deletions

File tree

iccviz/IccVizModel.cpp

Lines changed: 87 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include <cstddef> // std::size_t
2525
#include <cstdint> // std::uint32_t / std::uint64_t (used below; previously only transitive)
2626
#include <cstdio>
27+
#include <cstdlib> // std::abs (int)
2728
#include <cstring>
2829
#include <limits>
2930

@@ -211,7 +212,7 @@ void addPrimaryPoint(Graph& g, Series& s, CIccTag* tag, const char* label,
211212
if (!xyz) return;
212213
XY p = xyFromICCXYZ(xyz);
213214
s.verts.push_back(Vertex{p.x, p.y, label, kNaN});
214-
(void)g;
215+
(void)g; (void)colorHint; // both reserved for a future per-point colour hint
215216
}
216217

217218
Graph buildChromaticityGraph(CIccProfile* pIcc) {
@@ -862,9 +863,10 @@ icRenderingIntent neutralIntentForSig(icTagSignature sig) {
862863
// internal PCS encoding the xform expects (Lab directly, or D50 XYZ for XYZ PCS).
863864
void neutralSrc(float L, icColorSpaceSignature pcs, icFloatNumber* src) {
864865
if (pcs == icSigXYZData) {
865-
float f = (L + 16.0f) / 116.0f;
866-
float g = (f * f * f > 0.008856f) ? f * f * f : (f - 16.0f / 116.0f) / 7.787f;
867-
src[0] = 0.9642f * g; src[1] = 1.0f * g; src[2] = 0.8249f * g; // D50 human XYZ
866+
// Reuse IccProfLib's CIELAB->XYZ (nullptr white => D50) instead of hand-rolling
867+
// the inverse companding, so the constants live in exactly one place.
868+
icFloatNumber lab[3] = { L, 0.0f, 0.0f };
869+
icLabtoXYZ(src, lab, nullptr); // -> D50 human XYZ (Y=1)
868870
icXyzToPcs(src);
869871
} else {
870872
src[0] = L; src[1] = 0.0f; src[2] = 0.0f; // human L*a*b*
@@ -986,18 +988,19 @@ bool buildNeutralAxisGraph(CIccProfile* pIcc, icTagSignature sig, Graph& out,
986988
}
987989

988990
// ── Gamut volume: boundary voxelisation + flood-fill (see IccVizModel.hpp) ────
989-
// Pure Lab-space geometry, ported verbatim from chardata's gamut-wasm
990-
// `gamutVolumeIcc` (the ICC-specific device→PCS boundary eval lives in the public
991-
// GamutVolume() below). Why voxel occupancy and not signed-tetra / convex hull /
992-
// star-|tetra| on the boundary: the sampled device boundary self-overlaps and
993-
// isn't consistently wound, so those all mis-measure; voxel occupancy of the
994-
// enclosed solid is robust. Dilate seals sampling gaps against flood-fill leaks;
995-
// the erosion removes MOST of the dilation's outward bias but not all of it — the
996-
// box (Chebyshev) dilation and the 6-neighbour (city-block) erosion cancel on
997-
// axis-aligned faces yet leave a residual outward bias at convex corners/edges,
998-
// so `volume` is a slight over-estimate there. Combined with the 2-skeleton
999-
// sampling caveat in boundaryDeviceSamples() (the true boundary is under-captured
1000-
// for N≥4), treat the result as a robust ESTIMATE, not an exact measure.
991+
// Pure Lab-space geometry, adapted from chardata's gamut-wasm `gamutVolumeIcc`
992+
// (the ICC-specific device→PCS boundary eval lives in the public GamutVolume()
993+
// below). Why voxel occupancy and not signed-tetra / convex hull / star-|tetra|
994+
// on the boundary: the sampled device boundary self-overlaps and isn't
995+
// consistently wound, so those all mis-measure; voxel occupancy of the enclosed
996+
// solid is robust. Dilate seals sampling gaps against flood-fill leaks; the
997+
// erosion removes the dilation's outward bias — the CUBE (Chebyshev) dilation is
998+
// matched by a 26-neighbour (Chebyshev) erosion, so dilate-then-erode is a proper
999+
// morphological closing: the added shell cancels on the outer surface (corners
1000+
// included) rather than over-reaching there, while the gap-sealing survives.
1001+
// The boundary is sampled over ALL cube facets (see boundaryDeviceSamples), so it
1002+
// is captured for N>=4 too. `volume` remains a discrete-voxel ESTIMATE (resolution
1003+
// set by voxelSize), but without the earlier corner-high / CMYK-low systematic bias.
10011004

10021005
// Bounds for the untrusted / caller-supplied gamut-volume geometry (see the
10031006
// GamutVolume() argument clamping and the cell ceiling below).
@@ -1042,6 +1045,12 @@ double voxelEnclosedVolume(const std::vector<float>& lab, double vs,
10421045
if (!(lf >= 0.0 && lf < nL) || !(af >= 0.0 && af < nA) || !(bf >= 0.0 && bf < nB))
10431046
continue;
10441047
const int li = (int)lf, ai = (int)af, bi = (int)bf;
1048+
// Splat a cube (Chebyshev ball of radius `dilate`). This seals diagonal gaps
1049+
// between sparse boundary samples so the flood-fill can't leak through them.
1050+
// The erosion below peels the SAME Chebyshev shell back (26-neighbour), so
1051+
// dilate-then-erode is a proper morphological closing: the outward shell
1052+
// cancels on the outer surface (corners included) while the gap-sealing
1053+
// survives — no systematic corner-high bias, no leak-driven low bias.
10451054
for (int dl = -dilate; dl <= dilate; ++dl)
10461055
for (int da = -dilate; da <= dilate; ++da)
10471056
for (int db = -dilate; db <= dilate; ++db)
@@ -1073,10 +1082,19 @@ double voxelEnclosedVolume(const std::vector<float>& lab, double vs,
10731082
for (std::size_t id = 0; id < total; ++id) {
10741083
if (g[id] == 2) continue;
10751084
const int b = (int)(id % nB), a = (int)((id / nB) % nA), l = (int)(id / ((std::size_t)nB * nA));
1076-
if ((l > 0 && g[IDX(l - 1, a, b)] == 2) || (l < nL - 1 && g[IDX(l + 1, a, b)] == 2) ||
1077-
(a > 0 && g[IDX(l, a - 1, b)] == 2) || (a < nA - 1 && g[IDX(l, a + 1, b)] == 2) ||
1078-
(b > 0 && g[IDX(l, a, b - 1)] == 2) || (b < nB - 1 && g[IDX(l, a, b + 1)] == 2))
1079-
add.push_back(id);
1085+
// 26-neighbour (Chebyshev) test so the erosion peels the same cube shell the
1086+
// dilation added (matched morphological closing). Any exterior neighbour in
1087+
// the 3x3x3 stencil reclaims this cell.
1088+
bool touchesExterior = false;
1089+
for (int dl = -1; dl <= 1 && !touchesExterior; ++dl)
1090+
for (int da = -1; da <= 1 && !touchesExterior; ++da)
1091+
for (int db = -1; db <= 1 && !touchesExterior; ++db) {
1092+
if (!dl && !da && !db) continue;
1093+
const int ll = l + dl, aa = a + da, bb = b + db;
1094+
if (ll < 0 || ll >= nL || aa < 0 || aa >= nA || bb < 0 || bb >= nB) continue;
1095+
if (g[IDX(ll, aa, bb)] == 2) touchesExterior = true;
1096+
}
1097+
if (touchesExterior) add.push_back(id);
10801098
}
10811099
for (std::size_t id : add) g[id] = 2;
10821100
}
@@ -1086,33 +1104,45 @@ double voxelEnclosedVolume(const std::vector<float>& lab, double vs,
10861104
return (double)enclosedCells * vs * vs * vs;
10871105
}
10881106

1089-
// Device-cube 2-skeleton (boundary-face) samples in 0..1 (IccProfLib device
1090-
// convention): for each free-axis pair (di,dj) swept 0..S, all 2^(N-2) corner
1091-
// combinations of the remaining axes. Flat buffer, N floats per point.
1107+
// Number of samples boundaryDeviceSamples(N, S) emits: the device N-cube has 2N
1108+
// facets, each an (N-1)-cube grid-sampled at (S+1) points per free axis. (Shared
1109+
// edges/faces between facets are double-counted, which voxelisation collapses.)
1110+
double boundarySampleCount(int N, int S) {
1111+
if (N < 1) N = 1;
1112+
const int e = (N >= 2) ? (N - 1) : 0; // free axes per facet
1113+
return 2.0 * N * std::pow((double)(S + 1), (double)e);
1114+
}
1115+
1116+
// Sample the BOUNDARY of the device N-cube in 0..1 (IccProfLib device convention):
1117+
// the union of its 2N facets — each obtained by fixing one coordinate to 0 or 1 and
1118+
// grid-sampling the remaining N-1 coordinates at S+1 steps. Flat buffer, N floats
1119+
// per point.
10921120
//
1093-
// NOTE: for N==3 the 2-skeleton IS the full cube surface, but for N>=4 the gamut
1094-
// boundary also includes the interiors of the 3-faces (three free coordinates),
1095-
// which this does not sample — so for CMYK+ the enclosed volume is biased low.
1096-
// This is the sampling caveat referenced by voxelEnclosedVolume() above.
1121+
// For N==3 this is the six cube faces (identical to the old 2-skeleton). For N>=4
1122+
// it also covers the interiors of the 3-faces (three free coordinates) the
1123+
// 2-skeleton missed, so the enclosed volume is no longer biased low for CMYK+.
10971124
std::vector<float> boundaryDeviceSamples(int N, int S) {
10981125
std::vector<float> out;
1099-
int fixed[kMaxInkChannels];
1126+
if (N < 1) return out;
11001127
float cv[kMaxInkChannels];
1101-
for (int di = 0; di < N; ++di)
1102-
for (int dj = di + 1; dj < N; ++dj) {
1103-
int nFixed = 0;
1104-
for (int d = 0; d < N; ++d) if (d != di && d != dj) fixed[nFixed++] = d;
1105-
const int nCombos = 1 << nFixed;
1106-
for (int combo = 0; combo < nCombos; ++combo)
1107-
for (int u = 0; u <= S; ++u)
1108-
for (int w = 0; w <= S; ++w) {
1109-
for (int d = 0; d < N; ++d) cv[d] = 0.0f;
1110-
cv[di] = (float)u / S;
1111-
cv[dj] = (float)w / S;
1112-
for (int k = 0; k < nFixed; ++k) cv[fixed[k]] = ((combo >> k) & 1) ? 1.0f : 0.0f;
1113-
for (int d = 0; d < N; ++d) out.push_back(cv[d]);
1114-
}
1128+
int freeAxes[kMaxInkChannels];
1129+
int idx[kMaxInkChannels];
1130+
for (int fixedAxis = 0; fixedAxis < N; ++fixedAxis) {
1131+
int nFree = 0;
1132+
for (int d = 0; d < N; ++d) if (d != fixedAxis) freeAxes[nFree++] = d;
1133+
for (int fv = 0; fv <= 1; ++fv) { // fixed coordinate = 0 or 1
1134+
for (int i = 0; i < nFree; ++i) idx[i] = 0;
1135+
for (;;) { // odometer over the nFree free axes, each 0..S
1136+
for (int d = 0; d < N; ++d) cv[d] = 0.0f;
1137+
cv[fixedAxis] = (float)fv;
1138+
for (int i = 0; i < nFree; ++i) cv[freeAxes[i]] = (float)idx[i] / (float)S;
1139+
for (int d = 0; d < N; ++d) out.push_back(cv[d]);
1140+
int k = 0;
1141+
for (; k < nFree; ++k) { if (++idx[k] <= S) break; idx[k] = 0; }
1142+
if (k >= nFree) break; // all free axes wrapped (also ends the N==1 facet)
1143+
}
11151144
}
1145+
}
11161146
return out;
11171147
}
11181148

@@ -1121,13 +1151,16 @@ std::vector<float> boundaryDeviceSamples(int N, int S) {
11211151
// the boundary-point ceiling (a very-high-channel profile → volume unsupported).
11221152
void gamutVolumeParams(int N, int& steps, double& vs, int& dilate) {
11231153
if (N < 1) N = 1;
1124-
const double faces = (N * (N - 1) / 2.0) * std::pow(2.0, std::max(0, N - 2));
11251154
const double TARGET = 180000.0, MAX_POINTS = 1500000.0;
1126-
int s = (int)std::floor(std::sqrt(TARGET / std::max(1.0, faces))) - 1;
1155+
// Boundary point count is 2N*(S+1)^(N-1); invert it to hit TARGET, then shrink
1156+
// until it fits MAX_POINTS. (For N==3 the exponent is 2, reproducing the old
1157+
// face-sampling budget; for N>=4 facet sampling grows faster, so S auto-drops.)
1158+
const int e = (N >= 2) ? (N - 1) : 1;
1159+
int s = (int)std::floor(std::pow(TARGET / (2.0 * N), 1.0 / (double)e)) - 1;
11271160
if (s > 48) s = 48;
11281161
if (s < 6) s = 6;
1129-
while (s > 2 && faces * (double)(s + 1) * (s + 1) > MAX_POINTS) --s;
1130-
steps = (faces * (double)(s + 1) * (s + 1) > MAX_POINTS) ? -1 : s;
1162+
while (s > 2 && boundarySampleCount(N, s) > MAX_POINTS) --s;
1163+
steps = (boundarySampleCount(N, s) > MAX_POINTS) ? -1 : s;
11311164
vs = (N <= 4) ? 2.0 : (N <= 6 ? 2.5 : 3.0);
11321165
dilate = (s >= 40) ? 1 : (s >= 20 ? 2 : 3);
11331166
}
@@ -1373,14 +1406,13 @@ GamutVolumeResult GamutVolume(CIccProfile* pIcc, icTagSignature aToBTag,
13731406
if (dl < 0) dl = 0;
13741407
if (dl > kMaxGamutDilate) dl = kMaxGamutDilate; // clamp the structuring element
13751408
// Hard ceiling on total boundary points (CWE-400 guard against a crafted profile).
1376-
const double faces = (N * (N - 1) / 2.0) * std::pow(2.0, std::max(0, N - 2));
1377-
if (faces * (double)(S + 1) * (S + 1) > 3000000.0) { delete x; return fail("device boundary too large for volume"); }
1409+
if (boundarySampleCount(N, S) > 3000000.0) { delete x; return fail("device boundary too large for volume"); }
13781410

13791411
icStatusCMM st = icCmmStatOk;
13801412
CIccApplyXform* ap = x->GetNewApply(st);
13811413
if (!ap || st != icCmmStatOk) { delete ap; delete x; return fail("transform apply init failed"); }
13821414

1383-
// Sample the device 2-skeleton → human L*a*b*.
1415+
// Sample the device-cube boundary (all facets) → human L*a*b*.
13841416
const std::vector<float> dev = boundaryDeviceSamples(N, S);
13851417
const int nPts = (int)(dev.size() / N);
13861418
std::vector<float> lab;
@@ -1418,6 +1450,12 @@ GamutVolumeResult GamutVolume(CIccProfile* pIcc, icTagSignature aToBTag,
14181450
r.samplesPerAxis = S;
14191451
r.voxelSize = vs;
14201452
r.nColorants = N;
1453+
// Degeneracy signal (result still ok, but unreliable): flag when most boundary
1454+
// samples were non-finite (transform failing over much of the device cube) or the
1455+
// enclosed region is at/below the voxel-resolution floor (collapsed toward a
1456+
// point/plane). Lets a caller show N/A instead of a misleading tiny number.
1457+
const int finitePts = (int)(lab.size() / 3);
1458+
r.degenerate = (finitePts * 2 < nPts) || (cells <= 27);
14211459
r.ok = true;
14221460
return r;
14231461
}

iccviz/IccVizModel.hpp

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,24 +218,30 @@ RasterResult RenderRaster(CIccProfile* pIcc, const std::string& id, Verbosity v
218218

219219
// ── Gamut volume ─────────────────────────────────────────────────────────────
220220
// Volume (ΔE*ab³) enclosed by a profile's gamut, measured by boundary
221-
// voxelisation + flood-fill: sample the device-cube 2-skeleton through the
222-
// AToB transform → L*a*b*, voxelise, dilate to seal sampling gaps, flood-fill
221+
// voxelisation + flood-fill: sample the device-cube boundary (all facets) through
222+
// the AToB transform → L*a*b*, voxelise, dilate to seal sampling gaps, flood-fill
223223
// the exterior, erode the dilation back, count enclosed voxels. A scalar
224224
// metric — not a Graph/Raster — so it sits outside the Enumerate/Render path.
225225
//
226-
// Ported from chardata's lcms2 `gamutVolumeIcc`; here the device→PCS step uses
226+
// Adapted from chardata's lcms2 `gamutVolumeIcc`; here the device→PCS step uses
227227
// IccProfLib (CIccXform on `aToBTag`, device values 0..1, PCS→Lab decoded via
228228
// icLabFromPcs / icXyzFromPcs+icXYZtoLab). Pick (tag, intent) to select the
229229
// gamut: perceptual = AToB0/icPerceptual, relative = AToB1/icRelativeColorimetric,
230230
// saturation = AToB2/icSaturation, absolute = AToB1/icAbsoluteColorimetric.
231+
//
232+
// `volume` is a discrete-voxel estimate (resolution = voxelSize). Check
233+
// `degenerate`: when true the boundary sampling largely failed or collapsed, so
234+
// the number is unreliable and a caller cannot tell "genuinely tiny gamut" from
235+
// "sampling collapsed" — surface it as N/A rather than a real measurement.
231236
struct GamutVolumeResult {
232237
bool ok = false;
233238
std::string error;
234239
double volume = 0.0; // ΔE*ab³ = enclosed voxels × voxelSize³
235240
long long voxels = 0;
236-
int samplesPerAxis = 0; // device 2-skeleton boundary steps used
241+
int samplesPerAxis = 0; // device-cube boundary steps per free axis
237242
double voxelSize = 0.0; // Lab grid cell edge (ΔE*ab)
238243
int nColorants = 0; // device channels
244+
bool degenerate = false;// boundary collapsed / mostly non-finite: volume unreliable
239245
};
240246

241247
// Compute the gamut volume for one device→PCS (AToB) tag at `intent`. Pass 0 for

0 commit comments

Comments
 (0)