diff --git a/dist/calibrate.js b/dist/calibrate.js index fc2bff0..094544a 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,54 @@ 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; + } + /* 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] ? + 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 +695,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..3e3c88f 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,46 @@ 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 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 { + own = readColour(parseIni(await calibrate.baseline())).ccm; + } catch { + own = null; + } + if (!own) why = 'no-baseline'; + } + 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. @@ -3057,6 +3093,35 @@ 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 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 as it is now, ` + + `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/dist/engine.wasm b/dist/engine.wasm index 6676130..47dcb22 100755 Binary files a/dist/engine.wasm and b/dist/engine.wasm differ diff --git a/src/calibrate.js b/src/calibrate.js index fc2bff0..094544a 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,54 @@ 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; + } + /* 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] ? + 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 +695,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..3e3c88f 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,46 @@ 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 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 { + own = readColour(parseIni(await calibrate.baseline())).ccm; + } catch { + own = null; + } + if (!own) why = 'no-baseline'; + } + 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. @@ -3057,6 +3093,35 @@ 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 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 as it is now, ` + + `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/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/tests/ui-check.html b/tests/ui-check.html index 6567d18..412b7ed 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 as it is now/.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/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..7d08b66 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -666,6 +666,58 @@ 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 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', + !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. @@ -758,6 +810,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'); { /*