Skip to content

Commit 26a6909

Browse files
2.2.0: round-trip ΔE by lightness (reference QC scatter) + fault-isolated Analysis tab
Implements the reference report's "B2A1 Roundtrip dEab by quantized L*; Pts vary from GBD to Neutral" figure, and stops one bad analysis from blanking the page. New: iccviz::RoundTripByLightness 32 lightness levels × 64 gamut-boundary points, each repeated eroded toward neutral at chroma ×0.8/×0.5/×0.2, last point of each level replaced by neutral. 8192 points — the reference's exact count. New WASM export roundTripByLightness; new plot under the histogram in Profile Statistics. An envelope (min/mean/max per band) was built FIRST and discarded after reading the actual figure: its entire content is the WITHIN-band shape — error falling from the gamut surface to the neutral axis — and any summary erases exactly that. Individual points, dotted band separators, 2 px markers. Convergence against the published GRACoL2013_CRPC6 figures (0.343 / 0.947 / 2.76): first cut 0.482 / 1.377 / 8.43 + reference L* range 0.416 / 1.222 / 8.79 + L-interpolated GBD 0.386 / 1.149 / 6.25 + denser boundary sampling (S=36) 0.411 / 1.248 / 3.22 Two findings behind those steps: - The L* range is INKSET-derived, not gamut-derived: hi = (100 + L_yellow)/2, lo = (L_blackpoint + L_blue)/2, reusing HueExtrema + WhiteBlackPoints. That reproduces the reference's 18.82–96.85 exactly. The raw gamut extent drags in shadow levels the reference excludes, where the gamut is a sliver. - A cell-maximum gamut descriptor biases OUTWARD: seeds land just outside the gamut, B2A clamps them, and the ΔE reports our sampling rather than the profile. The tell was a 13× cliff between the boundary ring and the ×0.8 ring. Interpolating in L and sampling denser fixed most of it (max 8.4 → 3.2). Residual ~20% high on mean: our GBD comes from a boundary point cloud, the reference builds one with a gamut routine we do not have. Ours errs HIGH, the conservative direction. Shape and scale match; the absolutes are ours. Fix: the Analysis tab is fault-isolated ErrorBoundary existed in the tree but was wired NOWHERE (its own comment claimed otherwise), so any render throw unmounted the whole React tree and left a blank browser tab with no message. The Analysis tab — several independent analyses over untrusted profile data through a WASM engine — is now boundaried, with a readable fallback and resetKey={bytes}. Caught this because RtByLightness was mounted inside StatsTable, which does not receive `bytes`/`intent`; they resolved as undefined globals and threw. Now threaded from ProfileStatsSection. Note the build CANNOT catch that class of bug (no scope analysis) and `npx eslint` fails here with "couldn't find eslint.config.js" — the repo has ESLint installed but no config, so nothing lints. A flat config with no-undef would have caught it; not added here because it needs plugin deps and would surface pre-existing findings. Also PlotlyGraph gains `markerSize` so a dense scatter can ask for small dots. Removed the superseded per-band aggregation from DeStats / RoundTripResult / plot-wrapper rather than leave it unread. i18n: dead band_min/mean/max keys removed (verified unreferenced first), new analysis_rt_band_pts added across 12 locales; CJK includes the measure word so it reads after a numeral. Spreadsheets re-synced. MANUAL: Analysis section documents how to read the plot band by band, and why its numbers are not comparable with the table above it. WASM built from a clean iccDEV origin/master worktree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxVtREhRmqbY71R5KnV7e
1 parent 97c5e05 commit 26a6909

27 files changed

Lines changed: 533 additions & 14 deletions

MANUAL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ The four round-trip types:
215215
- **Reproducibility (RT2)** — ΔE\*ab between the first and second round trips: how stable a repeated round trip is, independent of the first trip's gamut clipping.
216216
- **PRMG interoperability** — PCS colours inside the Perceptual Reference Medium Gamut round-tripped once; the ΔE distribution indicates cross-profile interoperability.
217217

218+
Below the histogram, **Round-trip ΔE by lightness** answers the question the summary figures cannot: *where* in the tone scale the inversion struggles, and *how far into the gamut* the trouble reaches.
219+
220+
It samples 32 lightness levels. At each level it takes 64 points **on the gamut boundary**, then repeats them eroded toward the neutral axis at 80 %, 50 % and 20 % of their chroma — 8,192 points in all, each plotted individually, with dotted separators between levels.
221+
222+
Read it band by band. Within one band the points run from the gamut surface on the left to neutral on the right, so the error should **fall away** across the band: colours near the boundary are the hardest to invert, colours near neutral the easiest. A band whose error stays high all the way to neutral is a profile in trouble at that lightness. The tallest spikes mark the lightness levels where the gamut boundary itself is least well inverted.
223+
224+
Every seed is inside the gamut by construction, so what you see is genuine `B2A`/`A2B` disagreement rather than colours being clipped for being unreachable. Note that this plot has its **own** sampling — deliberately weighted toward the gamut boundary — so its mean and maximum are higher than, and not comparable with, the table above. It follows the rendering intent but not the round-trip type.
225+
218226
A profile lacking the device↔PCS transforms a metric needs shows a *not applicable* note; a device space too wide to sample is reported as skipped rather than as an error.
219227

220228
#### Extrema Colorimetry

frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "profiletool-frontend",
3-
"version": "2.1.0",
3+
"version": "2.2.0",
44
"description": "ICC profile viewer and validation UI",
55
"type": "module",
66
"scripts": {

frontend/public/help.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,10 @@ <h4>Profile Statistics</h4>
329329
<li><strong>Reproducibility (RT2)</strong> — ΔE*ab between the first and second round trips: how stable a repeated round trip is, independent of the first trip's gamut clipping.</li>
330330
<li><strong>PRMG interoperability</strong> — PCS colours inside the Perceptual Reference Medium Gamut round-tripped once; the ΔE distribution indicates cross-profile interoperability.</li>
331331
</ul>
332+
<p>Below the histogram, <strong>Round-trip ΔE by lightness</strong> answers the question the summary figures cannot: <em>where</em> in the tone scale the inversion struggles, and <em>how far into the gamut</em> the trouble reaches.</p>
333+
<p>It samples 32 lightness levels. At each level it takes 64 points <strong>on the gamut boundary</strong>, then repeats them eroded toward the neutral axis at 80 %, 50 % and 20 % of their chroma — 8,192 points in all, each plotted individually, with dotted separators between levels.</p>
334+
<p>Read it band by band. Within one band the points run from the gamut surface on the left to neutral on the right, so the error should <strong>fall away</strong> across the band: colours near the boundary are the hardest to invert, colours near neutral the easiest. A band whose error stays high all the way to neutral is a profile in trouble at that lightness. The tallest spikes mark the lightness levels where the gamut boundary itself is least well inverted.</p>
335+
<p>Every seed is inside the gamut by construction, so what you see is genuine <code>B2A</code>/<code>A2B</code> disagreement rather than colours being clipped for being unreachable. Note that this plot has its <strong>own</strong> sampling — deliberately weighted toward the gamut boundary — so its mean and maximum are higher than, and not comparable with, the table above. It follows the rendering intent but not the round-trip type.</p>
332336
<p>A profile lacking the device↔PCS transforms a metric needs shows a <em>not applicable</em> note; a device space too wide to sample is reported as skipped rather than as an error.</p>
333337
<h4>Extrema Colorimetry</h4>
334338
<p>The ends of the profile's reproduction range — the numbers a print operator usually reads first.</p>

frontend/public/wasm/SHA256SUMS

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ e416013b730ef48882d781115ffd41772238c6bad1dd2326fc080ed5110bf653 iccjson.mjs
66
5efa97180dd0235152cd167c847a6d7c370f132e86faec9e451300ea3715313f iccjson.wasm
77
3f6fa5c27187c9d99756bac44d3b2aad943847ea0786413af9803af4018a6fae iccconstruct.mjs
88
55880e6e58fc6a84bf03d15bba16233eff5b0db42fc996440548847a67df5e6e iccconstruct.wasm
9-
63ce2f51dca5f2d141c4c317e95dff40ed8e1d8e45afe7bbce2ba7dfd3771b55 iccplot.mjs
10-
b57a8f8d174054fcdd516082f8e1a62b60b0c74a5f354966e04947ef3615aef5 iccplot.wasm
9+
054553512d5c57f30ae64a4f5532e1f0d9c45596a18ee9a980e473d9e3a719e2 iccplot.mjs
10+
d5eb8baeebcad4b936bfd12c1b3877626df9d317d91e07bfdf118eff22c608c0 iccplot.wasm
1111
dabe0d142bf4c7590eaec75a921933a65e7fb565e653a4865ec71651b6b128ab iccpawg.mjs
1212
89d76edecb7070ee94c3b2018a69ec69fc88698a2e7e4eac971583a4fd22c3b1 iccpawg.wasm
1313
dae04d36df7afa645c70e856cb9719fd285d66574ebe7495b8edbb0c39105c08 iccimage.mjs

frontend/public/wasm/iccplot.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

frontend/public/wasm/iccplot.wasm

26.5 KB
Binary file not shown.

frontend/src/components/AnalysisPanel.jsx

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { useEffect, useState } from 'react'
33
import {
44
enumerateVisualizations, renderGraph, tagEvalInfo, gamutVolume, roundTripStats,
5-
whiteBlackPoints, hueExtrema, shadowInkPaths,
5+
whiteBlackPoints, hueExtrema, shadowInkPaths, roundTripByLightness,
66
} from '../lib/vizPlot.js'
77
import { channelColor } from './viz/colors.js'
88
import { labToRgb } from '../lib/rasterDecode.js'
@@ -219,7 +219,7 @@ function ProfileStatsSection({ bytes, t }) {
219219
{/* Dynamic, code-grounded description of the selected round-trip type. */}
220220
<p className={styles.typeDesc}>{t(typeMeta.descKey) || typeMeta.descFallback}</p>
221221

222-
<StatsTable gamut={gamut} rts={rts} type={type} t={t} />
222+
<StatsTable gamut={gamut} rts={rts} type={type} bytes={bytes} intent={intent} t={t} />
223223
</Collapsible>
224224
)
225225
}
@@ -229,7 +229,7 @@ function ProfileStatsSection({ bytes, t }) {
229229
// crosses the WASM→JS boundary, so each field is shape-guarded before formatting —
230230
// a partial or malformed result degrades to '—' or a note, never a render-time
231231
// TypeError that would blank the whole Analysis tab.
232-
function StatsTable({ gamut, rts, type, t }) {
232+
function StatsTable({ gamut, rts, type, bytes, intent, t }) {
233233
if (gamut.loading || rts.loading) {
234234
return <div className={styles.loading}><span className={styles.spinner} /> {t('analysis_loading') || 'Analysing…'}</div>
235235
}
@@ -340,6 +340,8 @@ function StatsTable({ gamut, rts, type, t }) {
340340
</div>
341341
)}
342342

343+
<RtByLightness bytes={bytes} intent={intent} t={t} />
344+
343345
{/* Worst-error colour: the device/PCS colour where this round trip fails hardest. */}
344346
{worst && (
345347
<p className={styles.rtGamut}>
@@ -350,6 +352,73 @@ function StatsTable({ gamut, rts, type, t }) {
350352
)
351353
}
352354

355+
// ── Round-trip ΔE by quantized lightness ──────────────────────────────────────
356+
// The reference QC report's scatter: every seed drawn individually, grouped into
357+
// lightness bands, with the points inside each band marching from the gamut boundary
358+
// inward to neutral. The shape WITHIN a band is the content — error is largest on the
359+
// gamut surface and falls away toward the neutral axis — so this is one place where a
360+
// summary (an envelope, a mean line) would erase exactly what the plot is for.
361+
//
362+
// It carries its own seeding, so it follows the rendering intent but NOT the
363+
// round-trip type selector, and its numbers are not comparable with the table above:
364+
// it deliberately over-weights the gamut boundary, where inversion is hardest.
365+
function RtByLightness({ bytes, intent, t }) {
366+
const [state, setState] = useState({ loading: true })
367+
368+
useEffect(() => {
369+
let cancelled = false
370+
setState({ loading: true })
371+
roundTripByLightness(bytes, intent).then(
372+
(r) => { if (!cancelled) setState({ loading: false, data: r }) },
373+
(e) => { if (!cancelled) setState({ loading: false, error: e.message }) },
374+
)
375+
return () => { cancelled = true }
376+
}, [bytes, intent])
377+
378+
if (state.loading) {
379+
return <div className={styles.loading}><span className={styles.spinner} /> {t('analysis_loading') || 'Analysing…'}</div>
380+
}
381+
// A profile without the AToB/BToA pair (or one whose lightness range cannot be
382+
// derived) simply doesn't get this plot; the section above still stands.
383+
if (state.error || !state.data) return null
384+
385+
const d = state.data
386+
const yHi = d.max > 0 ? d.max * 1.05 : 1
387+
const bandW = (d.hiL - d.loL) / (d.levels - 1)
388+
389+
// Band separators as ONE series: vertical segments joined by null-y breaks, rather
390+
// than 32 separate series cluttering the legend.
391+
const sep = []
392+
for (const L of d.levelL) { sep.push(L, 0, L, yHi, L, null) }
393+
394+
const graph = {
395+
xAxis: { label: t('analysis_rt_band_xaxis') || 'L* (lightness of the requested colour)', min: d.loL, max: d.hiL + bandW },
396+
yAxis: { label: t('analysis_rt_band_yaxis') || 'ΔE*ab', min: 0, max: yHi },
397+
// Neither series is named: this plot shows no legend (one data series plus
398+
// reference geometry), so a name would only be dead weight in the payload.
399+
series: [
400+
{ id: 'sep', role: 'hint', shape: 'polyline', points: sep, color: '#b9bec7' },
401+
{
402+
id: 'de', role: 'primary', shape: 'scatter', color: '#2f6fb3', markerSize: 2,
403+
points: d.x.flatMap((x, i) => [x, d.de[i]]),
404+
},
405+
],
406+
}
407+
408+
return (
409+
<>
410+
<p className={styles.rtGamut}>
411+
<strong>{t('analysis_rt_band_heading') || 'Round-trip ΔE by lightness'}</strong>{' '}
412+
<span className={styles.histCount}>
413+
({d.n.toLocaleString()} {t('analysis_rt_band_pts') || 'points, gamut boundary → neutral'};{' '}
414+
{t('analysis_rt_mean') || 'Mean'} {d.mean.toFixed(3)} · P90 {d.p90.toFixed(3)} · {t('analysis_rt_max') || 'Max'} {d.max.toFixed(2)})
415+
</span>
416+
</p>
417+
<PlotlyGraph graph={graph} storageKey="profiletool.rtBandHeight" defaultH={280} />
418+
</>
419+
)
420+
}
421+
353422
function NeutralSection({ bytes, profileClass, tables, t }) {
354423
// Output (printer) profiles only — others get a localized error.
355424
const isOutput = String(profileClass || '').toLowerCase().includes('output')

frontend/src/components/ProfileViewer.jsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { lazy, Suspense, useEffect, useState } from 'react'
33
import HeaderTable from './HeaderTable.jsx'
44
import TagTable from './TagTable.jsx'
5+
import ErrorBoundary from './ErrorBoundary.jsx'
56
import { preloadPlotly } from './viz/plotly.js'
67
import { useT } from '../i18n.jsx'
78
import { TAB_DEFS as TABS } from '../lib/tabs.js'
@@ -122,9 +123,22 @@ export default function ProfileViewer({
122123
</Suspense>
123124
)}
124125
{active === 'Analysis' && (
125-
<Suspense fallback={<div className={styles.loading}>{t('analysis_loading') || 'Analysing…'}</div>}>
126-
<AnalysisPanel bytes={bytes} profileClass={data.header?.['Profile Class']} />
127-
</Suspense>
126+
/* Boundaried: the Analysis tab hosts several independent analyses, each
127+
fed by untrusted profile data through a WASM engine. Without this, one
128+
bad render takes the WHOLE React tree down and the browser tab goes
129+
blank — no message, nothing to act on. Reset when the profile changes. */
130+
<ErrorBoundary
131+
resetKey={bytes}
132+
fallback={(err) => (
133+
<div className={styles.panelError}>
134+
<strong>{t('error_label')}</strong> {err?.message || String(err)}
135+
</div>
136+
)}
137+
>
138+
<Suspense fallback={<div className={styles.loading}>{t('analysis_loading') || 'Analysing…'}</div>}>
139+
<AnalysisPanel bytes={bytes} profileClass={data.header?.['Profile Class']} />
140+
</Suspense>
141+
</ErrorBoundary>
128142
)}
129143
{active === 'XML' && (
130144
<Suspense fallback={<div className={styles.loading}>{t('loading_xml_editor')}</div>}>

frontend/src/components/ProfileViewer.module.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@
4545
line-height: 1.4;
4646
}
4747
.partialIcon { font-size: 16px; flex-shrink: 0; }
48+
49+
/* Fallback shown when a tab's panel throws during render (see the ErrorBoundary
50+
around the Analysis tab). Inset rather than full-bleed like .partialBanner,
51+
because it replaces the panel's content rather than heading the whole viewer. */
52+
.panelError {
53+
margin: 16px;
54+
padding: 12px 16px;
55+
border-radius: var(--radius);
56+
background: var(--error-tint);
57+
border: 1px solid #f4cccc;
58+
color: var(--error);
59+
font-size: 12.5px;
60+
line-height: 1.5;
61+
}
4862
:global(body.dark) .partialBanner { border-bottom-color: #5a2424; }
4963

5064
.validBadge {

0 commit comments

Comments
 (0)