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
217218Graph 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).
863864void 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+.
10971124std::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).
11221152void 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}
0 commit comments