From 312aaaa86f3404862d108962705f01709cadc307 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 2 Oct 2026 09:04:07 +0000 Subject: [PATCH 1/3] engine: find the chart beside a shadow -- threshold flatness on rooted luma The detector calls a pixel flat when its 3x3 range is under three times the frame's noise, and takes the noise from the quietest tenth of the frame. A sensor's noise is mostly shot noise, which grows with the light, so the quietest tenth is the darkest tenth -- and on a frame with real shadows the threshold landed under the noise of the chart's own bright patches. They shattered into specks and the chart was reported missing in plain view. Measured on a lab gk7605v100 + SC2239, chart lit by window light: threshold 10.9 counts, 9 of 24 patches survived as candidates (18 are needed), no chart. The same chart under a lamp, with brighter shadows, came out at 18.8 and was found -- so whether Calibrate worked depended on the room. The downscaled luma is now black-subtracted and square-rooted (Anscombe's transform less its constant scale, which a relative threshold does not need), so noise is one size at every brightness. On the three frames taken of that chart the threshold lands at 1.6 to 1.7 and detection goes: window light no chart -> 23 cells lamp, earlier 19 cells -> 22 cells lamp 22 cells -> 22 cells with the corners within 6 px of each other across all of them. The ramp test that picks the chart's orientation only asks which way the greys fall, and a root keeps that order. The smoke test draws a chart with shot noise beside a shadow covering a third of the frame. The engine before this finds nothing on it (only those two checks fail); after, all 24 cells, 0.0 px from the drawn corners. make-chart.mjs gains `shot` (variance per count) and `dark` for it. --- dist/engine.wasm | Bin 27155 -> 26534 bytes src/engine.c | 25 +++++++++++++++++++++++-- tools/make-chart.mjs | 19 +++++++++++++++++-- tools/smoke.mjs | 22 ++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/dist/engine.wasm b/dist/engine.wasm index 6676130fac2c38cefcc43d004f1769ecdc67a5ce..47dcb22c29a471d6645318a011fcf196a0930dea 100755 GIT binary patch literal 26534 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 Y=U+T?$(>y~bLk7E2M--Mc;N8=0Q(uW5C8xG literal 27155 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 diff --git a/src/engine.c b/src/engine.c index 736cee3..28103b1 100644 --- a/src/engine.c +++ b/src/engine.c @@ -930,6 +930,25 @@ static i32 chart_downscale(i32 cfa, int *dw, int *dh, int *scale) { if (!g_ds || !g_lab || !g_stack) { g_ds = 0; return ERR_SIZE; } g_ds_cap = (i32)need; } + /* + * Square-rooted above the black level, so noise is the same size at every + * brightness. + * + * A sensor's noise is mostly shot noise, whose spread grows with the + * square root of the signal; the flatness threshold below is one number + * for the whole frame, taken from its quietest tenth -- which is its + * darkest tenth. On a lab gk7605v100 + SC2239 under window light that put + * the threshold at 10.9 counts, under the noise of the chart's own bright + * patches: they shattered into specks, 9 of the 24 survived as patches, + * 18 are needed, and a chart in plain view was reported as no chart. The + * same chart under a lamp, with brighter shadows, came out at 18.8 and + * was found. After the root the threshold lands at 1.6 to 1.7 on all three + * frames taken of it and 22 or 23 patches survive in each (Anscombe's + * transform, without its constant scale -- the threshold is relative). + * Nothing downstream needs the linear value: the ramp test only asks + * which way the greys fall, and a root keeps the order. + */ + const int per_pos = F.nblack == 4 && F.rep_rows == 2 && F.rep_cols == 2; for (int y = 0; y < oh; y++) for (int x = 0; x < ow; x++) { double sum = 0; int n = 0; @@ -938,10 +957,12 @@ static i32 chart_downscale(i32 cfa, int *dw, int *dh, int *scale) { const int px = x * sc + bx, py = y * sc + by; if (px >= w || py >= h) continue; if (plane_at(cfa, px, py) != 1) continue; /* greens carry the luma */ - sum += F.raw[py * w + px]; + const i32 b = per_pos ? F.black4[((py & 1) << 1) | (px & 1)] : F.black; + sum += (double)F.raw[py * w + px] - (double)b; n++; } - g_ds[y * ow + x] = n ? (float)(sum / n) : 0.f; + const double v = n ? sum / n : 0.0; + g_ds[y * ow + x] = (float)(2.0 * __builtin_sqrt((v > 0.0 ? v : 0.0) + 0.375)); } *dw = ow; *dh = oh; *scale = sc; return ERR_OK; diff --git a/tools/make-chart.mjs b/tools/make-chart.mjs index b138915..a2ec2ca 100644 --- a/tools/make-chart.mjs +++ b/tools/make-chart.mjs @@ -48,7 +48,7 @@ function invert3(h) { export function makeChartFrame({ width = 640, height = 480, corners, background = [700, 900, 600], gap = 0.12, surround = 120, noise = 6, seed = 7, - saturated = null, colorMatrices = [] } = {}) { + saturated = null, colorMatrices = [], shot = 0, dark = null } = {}) { if (!corners) throw new Error('corners are the point of this'); const H = homography(corners), Hi = invert3(H); const rgb = new Float64Array(width * height * 3); @@ -85,13 +85,28 @@ export function makeChartFrame({ width = 640, height = 480, corners, for (let x = sx; x < sx + sw && x < width; x++) rgb[(y * width + x) * 3] = rgb[(y * width + x) * 3 + 1] = rgb[(y * width + x) * 3 + 2] = 1e6; } + // A region in deep shadow: lit so little that it has almost no noise. + // With shot noise on, a frame like this one has its quietest tenth in the + // dark and its loudest in the bright patches -- which is every real frame. + if (dark) { + const [dx, dy, dw, dh, level] = dark; + for (let y = dy; y < dy + dh && y < height; y++) + for (let x = dx; x < dx + dw && x < width; x++) + rgb[(y * width + x) * 3] = rgb[(y * width + x) * 3 + 1] = rgb[(y * width + x) * 3 + 2] = level; + } let s = seed; const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff - 0.5); const px = new Uint16Array(width * height); for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { const p = (y % 2 === 0) ? (x % 2 === 0 ? 0 : 1) : (x % 2 === 0 ? 1 : 2); - const v = rgb[(y * width + x) * 3 + p] + rnd() * noise * 2; + const base = rgb[(y * width + x) * 3 + p]; + // `shot` is the variance per count of signal: a sensor's noise grows + // with the square root of what it collected. Four uniforms make it + // close enough to Gaussian with the same variance. + const sd = shot ? Math.sqrt(shot * base) : 0; + const g = shot ? (rnd() + rnd() + rnd() + rnd()) * Math.sqrt(3) : 0; + const v = base + rnd() * noise * 2 + g * sd; px[y * width + x] = Math.max(0, Math.min(4095, Math.round(v))); } return { bytes: makeDng({ width, height, pixels: px, black: 0, white: 4095, colorMatrices }), corners }; diff --git a/tools/smoke.mjs b/tools/smoke.mjs index 737ebd2..f9bf1d9 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -758,6 +758,28 @@ console.log('\na chart beside a blown-out window is still a chart'); ch ? ch.cells + ' cells' : 'nothing'); } +console.log('\na chart beside a deep shadow, with noise that grows with the light'); +{ + /* + * A sensor's noise is mostly shot noise, larger where there is more + * light, and a real frame has shadows. The flatness threshold is one + * number taken from the frame's quietest tenth, which is then its darkest + * tenth -- and that sat under the noise on the bright patches, which + * shattered. A lab gk7605v100 + SC2239 under window light lost the chart + * in plain view that way. Here a third of the frame is shadow; before the + * luma was square-rooted this frame reported no chart at all. + */ + const { makeChartFrame } = await import('./make-chart.mjs'); + const truth = [[300, 120], [600, 120], [600, 320], [300, 320]]; + const e = await instantiate(readFileSync(new URL('../dist/engine.wasm', import.meta.url))); + e.open(makeChartFrame({ corners: truth, noise: 1, shot: 1, background: [2000, 2600, 1800], + dark: [0, 0, 192, 480, 20] }).bytes); + const ch = e.detectChart(); + assert('a chart beside a shadow is found', !!ch && ch.cells === 24, ch ? ch.cells + ' cells' : 'nothing'); + const err = ch ? Math.max(...ch.corners.map((p, i) => Math.hypot(p[0] - truth[i][0], p[1] - truth[i][1]))) : Infinity; + assert('where it was drawn', err < 6, err.toFixed(1) + ' px from the corners that drew it'); +} + console.log('\na chart on a textured wall is still a chart'); { /* From d7df2059eab42b513075f383aa54a147e0b6e209 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 2 Oct 2026 09:12:58 +0000 Subject: [PATCH 2/3] calibrate: hold the matrix to the camera's noise, not only to the chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chart fit sees 24 averaged patches, so their noise is gone before the solver looks, and it is free to buy accuracy by amplifying the sensor's noise. On a lab gk7605v100 + SC2239 under a 2330 K lamp it did: mean ΔE2000 went from 8.9 to 5.7 with a green row amplifying noise 3.73 against the 1.69 of the matrix the camera ran, and after saving it the camera's video carried 2.2 to 2.6 times the chroma noise on flat surfaces (temporal std over 40 RTSP frames, back to back with the matrix it replaced). The picture's colour got better and the video got visibly worse. noiseGain() is that amplification per output channel: the length of each matrix row with the white balance gains folded in. solveFromPatches takes an optional noiseBudget and holds the fit under it with a penalty that is steepened until it holds (a fixed weight left rows a few per cent over). What the chart alone asked for comes back as noise.free, so the trade can be shown. A budget the free fit already meets changes nothing. The editor budgets against the camera's own matrix at the measured light, read from the profile it already runs, times NOISE_HEADROOM = 1.25. That figure is measured on the same camera, light and chart: held at 1.25 the video's chroma noise rose 0 to 30 per cent and the chart's colour error (a*b*, on the JPEG) was 10.4 against 10.8 unheld and 14.0 before; held at 1.0 it was 12.9. The result now says how much noisier than today the picture will be, and what an exact chart match would have cost -- or, with no profile to compare against, that the noise was not checked. The smoke test carries the SC2239's 24 measured patches and the 2525 K matrix that camera ran: free, 2.19 times noisier; held, within 0.13% of the budget at 7.18 ΔE2000; rows still sum to one. Both new UI checks fail without the editor change. --- dist/calibrate.js | 80 +++++++++++++++++++++++++++++++++++++++++++-- dist/editor.js | 65 +++++++++++++++++++++++++++++++++--- src/calibrate.js | 80 +++++++++++++++++++++++++++++++++++++++++++-- src/editor.js | 65 +++++++++++++++++++++++++++++++++--- tests/ui-check.html | 13 ++++++++ tools/smoke.mjs | 45 +++++++++++++++++++++++++ 6 files changed, 334 insertions(+), 14 deletions(-) diff --git a/dist/calibrate.js b/dist/calibrate.js index fc2bff0..c99c7fb 100644 --- a/dist/calibrate.js +++ b/dist/calibrate.js @@ -468,6 +468,39 @@ function summarise(errs, k) { }; } +/* + * How much a live matrix amplifies the sensor's noise, per output channel. + * + * Each output is a weighted sum of the three white-balanced inputs, so its + * noise grows with the length of that row of weights -- with the white + * balance gains folded in, because they multiply the noise before the matrix + * sees it. A row of [1, 0, 0] passes red's noise through unchanged; a row + * that subtracts its neighbours to buy saturation amplifies all three. + * + * This is the cost a chart fit never sees. The 24 patches are averages, so + * their noise is gone before the solver looks at them, and the fit is free to + * buy accuracy with amplification. On a lab gk7605v100 + SC2239 under a + * 2330 K lamp it did: the fit took mean ΔE2000 from 8.9 to 5.7 with a green + * row that amplified 3.73 against the 1.69 the camera ran before, and the + * camera's video carried 2.2 to 2.6 times the chroma noise on flat surfaces, + * measured over 40 frames against the matrix it replaced. + */ +export function noiseGain(ccm, neutral) { + const g = [1 / neutral[0], 1 / neutral[1], 1 / neutral[2]]; + return [0, 1, 2].map((r) => + Math.hypot(ccm[r * 3] * g[0], ccm[r * 3 + 1] * g[1], ccm[r * 3 + 2] * g[2])); +} + +/* + * How much noisier than the camera's own matrix a calibration may make the + * picture, by default. Measured on the same camera, same light, same chart: + * held at 1.25 times, video chroma noise rose 0 to 30 per cent instead of 2.2 + * to 2.6 times, and the chart's colour error came out at 10.4 against 10.8 + * for the unheld matrix -- the amplification bought almost nothing the + * picture shows. Held at 1.0 the error was 12.9, and the camera's own 14.0. + */ +export const NOISE_HEADROOM = 1.25; + /* * measured: 24 camera RGB triples, black-subtracted, in chart order. * opts.clipped: per patch, the fraction of it the sampler found clipped. @@ -475,6 +508,9 @@ function summarise(errs, k) { * light can be named. * opts.cct: the light's temperature, when someone knows it better than the * camera does. + * opts.noiseBudget: the most each output channel may amplify the sensor's + * noise, as noiseGain() measures it. The fit is held under it, and what the + * chart alone would have asked for comes back as `noise.free`. * * Returns the white balance the neutral row implies, both matrices, the light, * and how well the result actually fits -- because a solve always returns @@ -575,6 +611,46 @@ export function solveFromPatches(measured, opts = {}) { throw new Error(`these patches fit the chart at a mean of ${fit.meanDeltaE.toFixed(1)} ΔE2000, ` + `and a chart fits under ${MAX_MEAN_DELTA_E} — the corners are not on a colour chart`); + /* + * Held under the noise budget, when there is one. The chart has been + * judged above on the free fit -- whether these are a chart's patches does + * not depend on how much noise the answer may cost. + * + * The budget is a penalty on each row's excess, made steeper until it + * holds: a fixed weight left rows a few per cent over, and "a few per cent + * over" is not a limit. Started from the free fit, which is the nearest + * answer and the one a slack budget returns unchanged. + */ + let ccmOut = ccm, fitOut = fit, noise = null; + const free = noiseGain(ccm, neutral); + if (opts.noiseBudget) { + const budget = opts.noiseBudget; + if (budget.length !== 3 || budget.some((v) => !(v > 0))) + throw new Error('a noise budget is three positive numbers'); + let q = p; + for (let mu = 30; free.some((v, r) => v > budget[r]) && mu <= 3e5; mu *= 10) { + const held = (x) => residual(x).concat(noiseGain(ccmFrom(x), neutral) + .map((v, r) => mu * Math.max(0, v - budget[r]))); + q = levenberg(held, q); + if (noiseGain(ccmFrom(q), neutral).every((v, r) => v <= budget[r] * 1.005)) break; + } + if (q !== p) { + ccmOut = ccmFrom(q); + const errsHeld = balanced.map((_, i) => weights[i] ? + deltaE2000(lab(apply3(LINEAR_SRGB_TO_XYZ50, apply3(ccmOut, balanced[i]) + .map((v) => v * Math.exp(q[6])))), CHART_LAB50[i]) : null); + fitOut = summarise(errsHeld, Math.exp(q[6])); + fitOut.patches = fit.patches; + fitOut.meanDeltaE76 = errsHeld.reduce((s, e, i) => s + (e === null ? 0 : + deltaE(apply3(LINEAR_SRGB_TO_XYZ50, apply3(ccmOut, balanced[i]) + .map((v) => v * Math.exp(q[6]))), CHART_XYZ50[i])), 0) / fit.patches; + } + noise = { gain: noiseGain(ccmOut, neutral), budget: budget.slice(), + held: q !== p, free: q !== p ? { ccm, fit, gain: free } : null }; + } else { + noise = { gain: free, budget: null, held: false, free: null }; + } + /* * ColorMatrix1 maps XYZ *under the light the chart was lit by* to the * camera. The reference is D50, so it is carried to that light first -- @@ -611,9 +687,9 @@ export function solveFromPatches(measured, opts = {}) { const colorMatrix = fitted.map((v) => v / peak); return { - neutral, colorMatrix, ccm, balanced, weights, + neutral, colorMatrix, ccm: ccmOut, balanced, weights, light, estimated, - fit, + fit: fitOut, noise, }; } diff --git a/dist/editor.js b/dist/editor.js index 27e8227..5fb0c6a 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -12,7 +12,7 @@ * temperature/tint pair it has no model for yet. */ -import { solveFromPatches, patchCentres, scoreCcm, CHART_COLS, CHART_ROWS } from './calibrate.js'; +import { solveFromPatches, patchCentres, scoreCcm, noiseGain, NOISE_HEADROOM, CHART_COLS, CHART_ROWS } from './calibrate.js'; import { parseIni, readColour, fitAwbCurve, gainsForCt, mergeCcmTables, colourFragment, readDefectCorrection, enableDefectCorrection } from './iqprofile.js'; import { summarise, peakHold, normalise, coarsen, sweepZones, zoneDetail, @@ -2849,10 +2849,37 @@ export function mountEditor(root, { patches.push(got.raw); clipped.push(got.clipped || 0); } - return solveFromPatches(patches, { - clipped, - colorMatrices: state.info && state.info.colorMatrices, - }); + const opts = { clipped, colorMatrices: state.info && state.info.colorMatrices }; + const free = solveFromPatches(patches, opts); + /* + * No noisier than the camera is today, give or take NOISE_HEADROOM. + * + * The chart cannot say what a matrix costs in noise -- its patches are + * averages -- so the fit is held against the matrix the camera already + * runs at this light, read out of its own profile. A camera whose + * profile cannot be read, or a light that cannot be named, is solved + * free, and the result says so rather than implying it was checked. + */ + let why = null; + if (!(calibrate && calibrate.baseline)) why = 'no-baseline'; + else if (!free.light) why = 'no-light'; + else { + try { + const own = readColour(parseIni(await calibrate.baseline())).ccm; + if (!own) why = 'no-baseline'; + else { + const at = vendorCcmAt(own, free.light.cct); + const budget = noiseGain(at, free.neutral).map((v) => v * NOISE_HEADROOM); + const held = solveFromPatches(patches, { ...opts, noiseBudget: budget }); + held.noise.camera = noiseGain(at, free.neutral); + return held; + } + } catch { + why = 'no-baseline'; + } + } + free.noise.unchecked = why; + return free; } /* The panel, and the way back. @@ -3057,6 +3084,34 @@ export function mountEditor(root, { out.append(lt); } + /* + * What the colour cost in noise, in words a camera's owner can act + * on: how much noisier than today, and what matching the chart + * exactly would have cost instead. + */ + { + const nz = solved.noise; + const worst = (g, ref) => Math.max(...g.map((v, k) => v / ref[k])); + const n = el('p', 're-note'); + n.style.margin = '0 0 6px'; + n.dataset.role = 'noise'; + if (nz && nz.camera) { + const now = worst(nz.gain, nz.camera); + n.textContent = nz.held + ? `Kept to ${now.toFixed(2)}× the noise of the camera's picture today. ` + + `Matching the chart as closely as possible would fit at ΔE2000 ` + + `${nz.free.fit.meanDeltaE.toFixed(2)} and make the video ` + + `${worst(nz.free.gain, nz.camera).toFixed(1)}× as noisy.` + : `${now.toFixed(2)}× the noise of the camera's picture today, ` + + `inside the ${NOISE_HEADROOM}× allowed.`; + } else { + n.textContent = 'Not checked against the camera\'s own colour, so how much ' + + 'noisier this makes the picture is unknown: a close fit on a chart can ' + + 'amplify the sensor\'s noise several times over.'; + } + out.append(n); + } + out.append(Object.assign(el('h3', 're-cap'), { textContent: 'Live matrix, camera to display' })); out.append(matrixTable(solved.ccm)); out.append(Object.assign(el('h3', 're-cap'), { textContent: 'ColorMatrix1, XYZ to camera' })); diff --git a/src/calibrate.js b/src/calibrate.js index fc2bff0..c99c7fb 100644 --- a/src/calibrate.js +++ b/src/calibrate.js @@ -468,6 +468,39 @@ function summarise(errs, k) { }; } +/* + * How much a live matrix amplifies the sensor's noise, per output channel. + * + * Each output is a weighted sum of the three white-balanced inputs, so its + * noise grows with the length of that row of weights -- with the white + * balance gains folded in, because they multiply the noise before the matrix + * sees it. A row of [1, 0, 0] passes red's noise through unchanged; a row + * that subtracts its neighbours to buy saturation amplifies all three. + * + * This is the cost a chart fit never sees. The 24 patches are averages, so + * their noise is gone before the solver looks at them, and the fit is free to + * buy accuracy with amplification. On a lab gk7605v100 + SC2239 under a + * 2330 K lamp it did: the fit took mean ΔE2000 from 8.9 to 5.7 with a green + * row that amplified 3.73 against the 1.69 the camera ran before, and the + * camera's video carried 2.2 to 2.6 times the chroma noise on flat surfaces, + * measured over 40 frames against the matrix it replaced. + */ +export function noiseGain(ccm, neutral) { + const g = [1 / neutral[0], 1 / neutral[1], 1 / neutral[2]]; + return [0, 1, 2].map((r) => + Math.hypot(ccm[r * 3] * g[0], ccm[r * 3 + 1] * g[1], ccm[r * 3 + 2] * g[2])); +} + +/* + * How much noisier than the camera's own matrix a calibration may make the + * picture, by default. Measured on the same camera, same light, same chart: + * held at 1.25 times, video chroma noise rose 0 to 30 per cent instead of 2.2 + * to 2.6 times, and the chart's colour error came out at 10.4 against 10.8 + * for the unheld matrix -- the amplification bought almost nothing the + * picture shows. Held at 1.0 the error was 12.9, and the camera's own 14.0. + */ +export const NOISE_HEADROOM = 1.25; + /* * measured: 24 camera RGB triples, black-subtracted, in chart order. * opts.clipped: per patch, the fraction of it the sampler found clipped. @@ -475,6 +508,9 @@ function summarise(errs, k) { * light can be named. * opts.cct: the light's temperature, when someone knows it better than the * camera does. + * opts.noiseBudget: the most each output channel may amplify the sensor's + * noise, as noiseGain() measures it. The fit is held under it, and what the + * chart alone would have asked for comes back as `noise.free`. * * Returns the white balance the neutral row implies, both matrices, the light, * and how well the result actually fits -- because a solve always returns @@ -575,6 +611,46 @@ export function solveFromPatches(measured, opts = {}) { throw new Error(`these patches fit the chart at a mean of ${fit.meanDeltaE.toFixed(1)} ΔE2000, ` + `and a chart fits under ${MAX_MEAN_DELTA_E} — the corners are not on a colour chart`); + /* + * Held under the noise budget, when there is one. The chart has been + * judged above on the free fit -- whether these are a chart's patches does + * not depend on how much noise the answer may cost. + * + * The budget is a penalty on each row's excess, made steeper until it + * holds: a fixed weight left rows a few per cent over, and "a few per cent + * over" is not a limit. Started from the free fit, which is the nearest + * answer and the one a slack budget returns unchanged. + */ + let ccmOut = ccm, fitOut = fit, noise = null; + const free = noiseGain(ccm, neutral); + if (opts.noiseBudget) { + const budget = opts.noiseBudget; + if (budget.length !== 3 || budget.some((v) => !(v > 0))) + throw new Error('a noise budget is three positive numbers'); + let q = p; + for (let mu = 30; free.some((v, r) => v > budget[r]) && mu <= 3e5; mu *= 10) { + const held = (x) => residual(x).concat(noiseGain(ccmFrom(x), neutral) + .map((v, r) => mu * Math.max(0, v - budget[r]))); + q = levenberg(held, q); + if (noiseGain(ccmFrom(q), neutral).every((v, r) => v <= budget[r] * 1.005)) break; + } + if (q !== p) { + ccmOut = ccmFrom(q); + const errsHeld = balanced.map((_, i) => weights[i] ? + deltaE2000(lab(apply3(LINEAR_SRGB_TO_XYZ50, apply3(ccmOut, balanced[i]) + .map((v) => v * Math.exp(q[6])))), CHART_LAB50[i]) : null); + fitOut = summarise(errsHeld, Math.exp(q[6])); + fitOut.patches = fit.patches; + fitOut.meanDeltaE76 = errsHeld.reduce((s, e, i) => s + (e === null ? 0 : + deltaE(apply3(LINEAR_SRGB_TO_XYZ50, apply3(ccmOut, balanced[i]) + .map((v) => v * Math.exp(q[6]))), CHART_XYZ50[i])), 0) / fit.patches; + } + noise = { gain: noiseGain(ccmOut, neutral), budget: budget.slice(), + held: q !== p, free: q !== p ? { ccm, fit, gain: free } : null }; + } else { + noise = { gain: free, budget: null, held: false, free: null }; + } + /* * ColorMatrix1 maps XYZ *under the light the chart was lit by* to the * camera. The reference is D50, so it is carried to that light first -- @@ -611,9 +687,9 @@ export function solveFromPatches(measured, opts = {}) { const colorMatrix = fitted.map((v) => v / peak); return { - neutral, colorMatrix, ccm, balanced, weights, + neutral, colorMatrix, ccm: ccmOut, balanced, weights, light, estimated, - fit, + fit: fitOut, noise, }; } diff --git a/src/editor.js b/src/editor.js index 27e8227..5fb0c6a 100644 --- a/src/editor.js +++ b/src/editor.js @@ -12,7 +12,7 @@ * temperature/tint pair it has no model for yet. */ -import { solveFromPatches, patchCentres, scoreCcm, CHART_COLS, CHART_ROWS } from './calibrate.js'; +import { solveFromPatches, patchCentres, scoreCcm, noiseGain, NOISE_HEADROOM, CHART_COLS, CHART_ROWS } from './calibrate.js'; import { parseIni, readColour, fitAwbCurve, gainsForCt, mergeCcmTables, colourFragment, readDefectCorrection, enableDefectCorrection } from './iqprofile.js'; import { summarise, peakHold, normalise, coarsen, sweepZones, zoneDetail, @@ -2849,10 +2849,37 @@ export function mountEditor(root, { patches.push(got.raw); clipped.push(got.clipped || 0); } - return solveFromPatches(patches, { - clipped, - colorMatrices: state.info && state.info.colorMatrices, - }); + const opts = { clipped, colorMatrices: state.info && state.info.colorMatrices }; + const free = solveFromPatches(patches, opts); + /* + * No noisier than the camera is today, give or take NOISE_HEADROOM. + * + * The chart cannot say what a matrix costs in noise -- its patches are + * averages -- so the fit is held against the matrix the camera already + * runs at this light, read out of its own profile. A camera whose + * profile cannot be read, or a light that cannot be named, is solved + * free, and the result says so rather than implying it was checked. + */ + let why = null; + if (!(calibrate && calibrate.baseline)) why = 'no-baseline'; + else if (!free.light) why = 'no-light'; + else { + try { + const own = readColour(parseIni(await calibrate.baseline())).ccm; + if (!own) why = 'no-baseline'; + else { + const at = vendorCcmAt(own, free.light.cct); + const budget = noiseGain(at, free.neutral).map((v) => v * NOISE_HEADROOM); + const held = solveFromPatches(patches, { ...opts, noiseBudget: budget }); + held.noise.camera = noiseGain(at, free.neutral); + return held; + } + } catch { + why = 'no-baseline'; + } + } + free.noise.unchecked = why; + return free; } /* The panel, and the way back. @@ -3057,6 +3084,34 @@ export function mountEditor(root, { out.append(lt); } + /* + * What the colour cost in noise, in words a camera's owner can act + * on: how much noisier than today, and what matching the chart + * exactly would have cost instead. + */ + { + const nz = solved.noise; + const worst = (g, ref) => Math.max(...g.map((v, k) => v / ref[k])); + const n = el('p', 're-note'); + n.style.margin = '0 0 6px'; + n.dataset.role = 'noise'; + if (nz && nz.camera) { + const now = worst(nz.gain, nz.camera); + n.textContent = nz.held + ? `Kept to ${now.toFixed(2)}× the noise of the camera's picture today. ` + + `Matching the chart as closely as possible would fit at ΔE2000 ` + + `${nz.free.fit.meanDeltaE.toFixed(2)} and make the video ` + + `${worst(nz.free.gain, nz.camera).toFixed(1)}× as noisy.` + : `${now.toFixed(2)}× the noise of the camera's picture today, ` + + `inside the ${NOISE_HEADROOM}× allowed.`; + } else { + n.textContent = 'Not checked against the camera\'s own colour, so how much ' + + 'noisier this makes the picture is unknown: a close fit on a chart can ' + + 'amplify the sensor\'s noise several times over.'; + } + out.append(n); + } + out.append(Object.assign(el('h3', 're-cap'), { textContent: 'Live matrix, camera to display' })); out.append(matrixTable(solved.ccm)); out.append(Object.assign(el('h3', 're-cap'), { textContent: 'ColorMatrix1, XYZ to camera' })); diff --git a/tests/ui-check.html b/tests/ui-check.html index 6567d18..f5dc167 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -703,6 +703,12 @@ const light = host.querySelector('[data-role="light"]'); t('and names the light off the camera\'s own matrices', !!light && /about \d{4,5} K/.test(light.textContent), light?.textContent || '(no light line)'); + // The camera's own matrix was readable, so the noise is stated against + // it -- not left for the operator to find in the video afterwards. + const noise = host.querySelector('[data-role="noise"]'); + t('and says how much noisier than today the picture would be', + !!noise && /\d\.\d\d× the noise of the camera's picture today/.test(noise.textContent), + noise?.textContent || '(no noise line)'); } send.click(); @@ -1612,6 +1618,13 @@ await new Promise((r) => setTimeout(r, 100)); host.querySelector('[data-act="keep"]').click(); await new Promise((r) => setTimeout(r, 600)); + // No baseline() here, so nothing to hold the noise against -- and the + // result must say that, not imply it was checked. + { + const noise = host.querySelector('[data-role="noise"]'); + t('without the camera\'s own colour, the noise is called unchecked', + !!noise && /^Not checked/.test(noise.textContent), noise?.textContent || '(no noise line)'); + } const said = host.querySelector('[data-act="hold"]').textContent; t('a confirmation that did not reach the camera is not reported as kept', !/^Kept\./.test(said.trim()) && /did not reach/.test(said), said.slice(0, 90)); diff --git a/tools/smoke.mjs b/tools/smoke.mjs index f9bf1d9..ed5b94f 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -666,6 +666,51 @@ console.log('\ncalibration recovers a matrix it was not given'); check('the chart is 24 patches', CHART_SRGB.length, 24); + /* + * The noise a matrix costs. These 24 patches are what a lab gk7605v100 + + * SC2239 measured off a chart under a 2330 K lamp, and the matrix beside + * them is the one that camera ran at that light. Fitted free, the green + * row amplified noise 2.2 times as much as that matrix -- and the + * camera's video showed it, 2.2 to 2.6 times the chroma noise. + */ + { + const { noiseGain, NOISE_HEADROOM } = await import('../src/calibrate.js'); + const SC2239_LAMP = [[166.15, 153.5, 59.53], [397.58, 397.49, 148.76], [191.03, 282.94, 145.5], + [120.94, 164.23, 55.41], [351.42, 446.54, 201.8], [260.18, 464.5, 197.93], + [561.4, 425.78, 136.32], [136.28, 204.11, 136.13], [441.78, 295.12, 118.77], + [106.99, 121.47, 62.11], [285.99, 411.7, 117.5], [522.06, 463.38, 136.64], + [57.66, 88.11, 71.75], [152.88, 248.85, 87.56], [355.54, 197.73, 76.88], + [520.53, 548.39, 155.57], [309.23, 256.11, 122.67], [117.77, 235.67, 130.99], + [503.42, 596.72, 243.61], [405.13, 478.44, 200.57], [373.29, 442.86, 188.86], + [204.4, 240.21, 103.45], [123.09, 143.95, 61.96], [43.89, 46.89, 19.94]]; + // imx307.ini's 2525 K table, which is what that camera ran: sign-magnitude, 256 = 1. + const CAMERA = [535, 33043, 32772, 32860, 400, 32820, 32778, 32969, 467] + .map((v) => (v & 0x8000 ? -(v & 0x7fff) : v) / 256); + const free = solveFromPatches(SC2239_LAMP); + const own = noiseGain(CAMERA, free.neutral); + const ratio = Math.max(...free.noise.gain.map((v, k) => v / own[k])); + assert('fitted free, the matrix is much noisier than the camera\'s', ratio > 2, + ratio.toFixed(2) + '×'); + check('and says it was not held', free.noise.held, false); + + const budget = own.map((v) => v * NOISE_HEADROOM); + const held = solveFromPatches(SC2239_LAMP, { noiseBudget: budget }); + const over = Math.max(...held.noise.gain.map((v, k) => v / budget[k])); + assert('held, no channel is over its budget', over <= 1.005, over.toFixed(4) + ' of budget'); + assert('and it costs some accuracy, not all of it', + held.fit.meanDeltaE > free.fit.meanDeltaE && held.fit.meanDeltaE < 9, + `${free.fit.meanDeltaE.toFixed(2)} -> ${held.fit.meanDeltaE.toFixed(2)} ΔE2000`); + assert('every row still sums to one, so grey stays grey', + [0, 1, 2].every((r) => Math.abs(held.ccm[r * 3] + held.ccm[r * 3 + 1] + held.ccm[r * 3 + 2] - 1) < 1e-9)); + assert('and what the chart alone wanted comes back beside it', + held.noise.held && held.noise.free.fit.meanDeltaE === free.fit.meanDeltaE); + + // A budget the free fit already meets changes nothing. + const slack = solveFromPatches(SC2239_LAMP, { noiseBudget: free.noise.gain.map((v) => v * 2) }); + assert('a budget already met leaves the fit alone', + !slack.noise.held && slack.ccm.every((v, k) => v === free.ccm[k])); + } + // CIEDE2000 against the 34 pairs Sharma, Wu and Dalal published with their // implementation notes, to their four decimals. Pairs 7-16 are the ones // that catch the hue-mean and zero-chroma special cases. From bf259955026792b80d8b0c07102bae8b16788055 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 2 Oct 2026 09:38:48 +0000 Subject: [PATCH 3/3] calibrate: judge the camera by its own balance, and refuse a budget it cannot hold Review findings on the noise budget: - The camera's matrix was scored through the chart's white balance, not the camera's. It is now scored through the frame's AsShotNeutral, the balance the camera chose for this picture -- which is what "the picture as it is now" means. On the lab SC2239 those differ by up to 36% in the blue gain under daylight, because the camera's AWB lands on the wrong temperature there. - A held fit that ended over its budget was returned as held. The final gains are now checked, and a budget no matrix can meet is an error that says how far over the closest one is. - The catch that fell back to an unheld fit also swallowed solver errors. It now covers only reading the camera's profile; a failed held solve reaches the operator as the error it is. Not changed: the reference is the camera's matrix as it is now, so a saved calibration becomes the reference for the next one. An absolute ceiling was measured instead and does not exist across sensors: IMX307's shipped tables reach 2.27x the noise of plain white balance, the SC2239 library's own tables 5.35x. The note now says what the reference is -- "a calibration saved earlier counts as now" -- so each step is visible. --- dist/calibrate.js | 8 ++++++++ dist/editor.js | 40 +++++++++++++++++++++++++--------------- src/calibrate.js | 8 ++++++++ src/editor.js | 40 +++++++++++++++++++++++++--------------- tests/ui-check.html | 2 +- tools/smoke.mjs | 7 +++++++ 6 files changed, 74 insertions(+), 31 deletions(-) diff --git a/dist/calibrate.js b/dist/calibrate.js index c99c7fb..094544a 100644 --- a/dist/calibrate.js +++ b/dist/calibrate.js @@ -634,6 +634,14 @@ export function solveFromPatches(measured, opts = {}) { q = levenberg(held, q); if (noiseGain(ccmFrom(q), neutral).every((v, r) => v <= budget[r] * 1.005)) break; } + /* Checked, not assumed: the steepest penalty can still leave a row + * over a budget no row-sums-to-one matrix can meet, and a matrix + * reported as held must be one. */ + const reached = noiseGain(ccmFrom(q), neutral); + const over = Math.max(...reached.map((v, r) => v / budget[r])); + if (over > 1.005) + throw new Error(`no colour matrix fits this chart within the noise allowed — the ` + + `closest is ${over.toFixed(2)}× over it`); if (q !== p) { ccmOut = ccmFrom(q); const errsHeld = balanced.map((_, i) => weights[i] ? diff --git a/dist/editor.js b/dist/editor.js index 5fb0c6a..3e3c88f 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -2860,26 +2860,35 @@ export function mountEditor(root, { * profile cannot be read, or a light that cannot be named, is solved * free, and the result says so rather than implying it was checked. */ - let why = null; + let own = null, why = null; if (!(calibrate && calibrate.baseline)) why = 'no-baseline'; else if (!free.light) why = 'no-light'; else { + /* Only reading the camera's profile may fall back to an unheld fit. + * A budget that cannot be met is an answer, and it is shown as one. */ try { - const own = readColour(parseIni(await calibrate.baseline())).ccm; - if (!own) why = 'no-baseline'; - else { - const at = vendorCcmAt(own, free.light.cct); - const budget = noiseGain(at, free.neutral).map((v) => v * NOISE_HEADROOM); - const held = solveFromPatches(patches, { ...opts, noiseBudget: budget }); - held.noise.camera = noiseGain(at, free.neutral); - return held; - } + own = readColour(parseIni(await calibrate.baseline())).ccm; } catch { - why = 'no-baseline'; + own = null; } + if (!own) why = 'no-baseline'; } - free.noise.unchecked = why; - return free; + if (!own) { + free.noise.unchecked = why; + return free; + } + /* The camera's matrix at this light, through the white balance the + * camera itself chose for this frame -- its picture as it is, rather + * than as it would be with the chart's balance. */ + const at = vendorCcmAt(own, free.light.cct); + const shot = state.info && state.info.neutral; + const camNeutral = shot && shot.every((v) => v > 0) ? shot : free.neutral; + const camera = noiseGain(at, camNeutral); + const held = solveFromPatches(patches, { + ...opts, noiseBudget: camera.map((v) => v * NOISE_HEADROOM), + }); + held.noise.camera = camera; + return held; } /* The panel, and the way back. @@ -3098,11 +3107,12 @@ export function mountEditor(root, { if (nz && nz.camera) { const now = worst(nz.gain, nz.camera); n.textContent = nz.held - ? `Kept to ${now.toFixed(2)}× the noise of the camera's picture today. ` + + ? `Kept to ${now.toFixed(2)}× the noise of the camera's picture as it is now — ` + + 'a calibration saved earlier counts as "now". ' + `Matching the chart as closely as possible would fit at ΔE2000 ` + `${nz.free.fit.meanDeltaE.toFixed(2)} and make the video ` + `${worst(nz.free.gain, nz.camera).toFixed(1)}× as noisy.` - : `${now.toFixed(2)}× the noise of the camera's picture today, ` + + : `${now.toFixed(2)}× the noise of the camera's picture as it is now, ` + `inside the ${NOISE_HEADROOM}× allowed.`; } else { n.textContent = 'Not checked against the camera\'s own colour, so how much ' + diff --git a/src/calibrate.js b/src/calibrate.js index c99c7fb..094544a 100644 --- a/src/calibrate.js +++ b/src/calibrate.js @@ -634,6 +634,14 @@ export function solveFromPatches(measured, opts = {}) { q = levenberg(held, q); if (noiseGain(ccmFrom(q), neutral).every((v, r) => v <= budget[r] * 1.005)) break; } + /* Checked, not assumed: the steepest penalty can still leave a row + * over a budget no row-sums-to-one matrix can meet, and a matrix + * reported as held must be one. */ + const reached = noiseGain(ccmFrom(q), neutral); + const over = Math.max(...reached.map((v, r) => v / budget[r])); + if (over > 1.005) + throw new Error(`no colour matrix fits this chart within the noise allowed — the ` + + `closest is ${over.toFixed(2)}× over it`); if (q !== p) { ccmOut = ccmFrom(q); const errsHeld = balanced.map((_, i) => weights[i] ? diff --git a/src/editor.js b/src/editor.js index 5fb0c6a..3e3c88f 100644 --- a/src/editor.js +++ b/src/editor.js @@ -2860,26 +2860,35 @@ export function mountEditor(root, { * profile cannot be read, or a light that cannot be named, is solved * free, and the result says so rather than implying it was checked. */ - let why = null; + let own = null, why = null; if (!(calibrate && calibrate.baseline)) why = 'no-baseline'; else if (!free.light) why = 'no-light'; else { + /* Only reading the camera's profile may fall back to an unheld fit. + * A budget that cannot be met is an answer, and it is shown as one. */ try { - const own = readColour(parseIni(await calibrate.baseline())).ccm; - if (!own) why = 'no-baseline'; - else { - const at = vendorCcmAt(own, free.light.cct); - const budget = noiseGain(at, free.neutral).map((v) => v * NOISE_HEADROOM); - const held = solveFromPatches(patches, { ...opts, noiseBudget: budget }); - held.noise.camera = noiseGain(at, free.neutral); - return held; - } + own = readColour(parseIni(await calibrate.baseline())).ccm; } catch { - why = 'no-baseline'; + own = null; } + if (!own) why = 'no-baseline'; } - free.noise.unchecked = why; - return free; + if (!own) { + free.noise.unchecked = why; + return free; + } + /* The camera's matrix at this light, through the white balance the + * camera itself chose for this frame -- its picture as it is, rather + * than as it would be with the chart's balance. */ + const at = vendorCcmAt(own, free.light.cct); + const shot = state.info && state.info.neutral; + const camNeutral = shot && shot.every((v) => v > 0) ? shot : free.neutral; + const camera = noiseGain(at, camNeutral); + const held = solveFromPatches(patches, { + ...opts, noiseBudget: camera.map((v) => v * NOISE_HEADROOM), + }); + held.noise.camera = camera; + return held; } /* The panel, and the way back. @@ -3098,11 +3107,12 @@ export function mountEditor(root, { if (nz && nz.camera) { const now = worst(nz.gain, nz.camera); n.textContent = nz.held - ? `Kept to ${now.toFixed(2)}× the noise of the camera's picture today. ` + + ? `Kept to ${now.toFixed(2)}× the noise of the camera's picture as it is now — ` + + 'a calibration saved earlier counts as "now". ' + `Matching the chart as closely as possible would fit at ΔE2000 ` + `${nz.free.fit.meanDeltaE.toFixed(2)} and make the video ` + `${worst(nz.free.gain, nz.camera).toFixed(1)}× as noisy.` - : `${now.toFixed(2)}× the noise of the camera's picture today, ` + + : `${now.toFixed(2)}× the noise of the camera's picture as it is now, ` + `inside the ${NOISE_HEADROOM}× allowed.`; } else { n.textContent = 'Not checked against the camera\'s own colour, so how much ' + diff --git a/tests/ui-check.html b/tests/ui-check.html index f5dc167..412b7ed 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -707,7 +707,7 @@ // it -- not left for the operator to find in the video afterwards. const noise = host.querySelector('[data-role="noise"]'); t('and says how much noisier than today the picture would be', - !!noise && /\d\.\d\d× the noise of the camera's picture today/.test(noise.textContent), + !!noise && /\d\.\d\d× the noise of the camera's picture as it is now/.test(noise.textContent), noise?.textContent || '(no noise line)'); } diff --git a/tools/smoke.mjs b/tools/smoke.mjs index ed5b94f..7d08b66 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -705,6 +705,13 @@ console.log('\ncalibration recovers a matrix it was not given'); assert('and what the chart alone wanted comes back beside it', held.noise.held && held.noise.free.fit.meanDeltaE === free.fit.meanDeltaE); + // A budget no matrix whose rows sum to one can meet is refused, not + // returned over budget and labelled held. + let impossible = ''; + try { solveFromPatches(SC2239_LAMP, { noiseBudget: [0.1, 0.1, 0.1] }); } + catch (e) { impossible = e.message; } + assert('an impossible budget is refused', /within the noise allowed/.test(impossible), impossible); + // A budget the free fit already meets changes nothing. const slack = solveFromPatches(SC2239_LAMP, { noiseBudget: free.noise.gain.map((v) => v * 2) }); assert('a budget already met leaves the fit alone',