From 9d1bca31fdf4e1ada8050a2ea67c648eefeaaee9 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:47:33 +0300 Subject: [PATCH] calibrate: refuse a fit that is not to a chart, and a chart off the frame Calibrate solved and offered "Apply to the camera" for any 24 samples, chart or not. On a camera's plain red wall with no chart in view, the detector reported 19 cells with two corners outside the 1920x1080 frame (at 2170,1986 and 634,1095 of a lattice that starts at 946,230). Every quad measured on that wall -- the detected one, the default middle third, a dragged one -- solved at 19 to 26 mean dE2000, with coefficients up to +-20 and rows summing to exactly 1, so the camera accepted it too. Applied to a gk7205v200, it turned the live picture into amplified noise; a user reported the same as "mosaic black and white tiled image" (OpenIPC/firmware#2234). - detectChart() returns null when any corner lies outside the frame. The cells past the edge have nothing under them to measure, and it is what the detector made of that wall. - solveFromPatches() refuses a mean dE2000 over 12. The real chart in tests/chart-on-wood.dng fits at 4.77 and a make-chart.mjs chart at 7.47 (drawn from 8-bit sRGB, scored against Lab); the wall and that frame's own wood fit at 19 to 26. The UI checks measured fixture.dng's middle third, which has no chart in it, and relied on getting a matrix back. They now measure a chart drawn under the default corners, carrying fixture.dng's ColorMatrix1 so the light is still named; and a new check measures fixture.dng and expects the refusal on the panel with nothing to apply. make-chart.mjs and the check server pass camera matrices through for that. Every new check was seen failing with the fix reverted. Refs #42 --- dist/calibrate.js | 17 +++++++++++++++++ dist/engine.js | 14 ++++++++++---- src/calibrate.js | 17 +++++++++++++++++ src/engine.js | 14 ++++++++++---- tests/ui-check.html | 38 ++++++++++++++++++++++++++++++++++---- tools/make-chart.mjs | 4 ++-- tools/smoke.mjs | 39 +++++++++++++++++++++++++++++++++++++++ tools/ui-check.mjs | 5 ++++- 8 files changed, 133 insertions(+), 15 deletions(-) diff --git a/dist/calibrate.js b/dist/calibrate.js index 305be5c..fc2bff0 100644 --- a/dist/calibrate.js +++ b/dist/calibrate.js @@ -426,6 +426,20 @@ const CLIP_LIMIT = 0.02; /* How many patches a fit needs before its answer means anything. */ const MIN_PATCHES = 12; +/* + * How far from the chart a fit may land and still be a fit to the chart. + * + * Rows that sum to one are guaranteed by construction, so they say nothing + * about whether the patches were a chart. The error does. The real chart in + * tests/chart-on-wood.dng fits at a mean of 4.77 ΔE2000, and a chart drawn by + * tools/make-chart.mjs at 7.47 -- it is drawn from the 8-bit sRGB values, not + * the Lab ones it is scored against. Patches off that frame's wood, and off a + * camera's plain wall with no chart on it, fit at 19 to 26 wherever the + * corners were put -- and applied to the camera, a matrix solved from that + * wall turned its picture into amplified noise (OpenIPC/raw-editor#42). + */ +const MAX_MEAN_DELTA_E = 12; + /* * Score a camera-to-display matrix on the chart, the way solveFromPatches * scores its own: white-balanced camera values through the matrix, one @@ -557,6 +571,9 @@ export function solveFromPatches(measured, opts = {}) { deltaE(apply3(LINEAR_SRGB_TO_XYZ50, pred(p, i)), CHART_XYZ50[i])), 0) / errs.filter((e) => e !== null).length; fit.patches = weights.reduce((s, w) => s + w, 0); + if (!(fit.meanDeltaE <= MAX_MEAN_DELTA_E)) + 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`); /* * ColorMatrix1 maps XYZ *under the light the chart was lit by* to the diff --git a/dist/engine.js b/dist/engine.js index 7b38d8e..3ea9872 100644 --- a/dist/engine.js +++ b/dist/engine.js @@ -269,10 +269,16 @@ export class Engine { const found = x.detect_chart(cfa, this.chartPtr); if (found <= 0) return null; const f = new Float32Array(x.memory.buffer, this.chartPtr, 8); - return { - corners: [[f[0], f[1]], [f[2], f[3]], [f[4], f[5]], [f[6], f[7]]], - cells: found, - }; + const corners = [[f[0], f[1]], [f[2], f[3]], [f[4], f[5]], [f[6], f[7]]]; + // A lattice whose corners the detector had to extrapolate off the frame + // is not a chart that can be measured -- the cells past the edge have + // nothing under them -- and on a frame with no chart at all it is what a + // fit to the wall looks like: a plain red wall came back as 19 cells + // with corners at (2170, 1986) on a 1920x1080 frame, and the matrix + // solved from it wrecked the camera's picture (OpenIPC/raw-editor#42). + if (corners.some(([cx, cy]) => !(cx >= 0 && cy >= 0 && cx <= i.width && cy <= i.height))) + return null; + return { corners, cells: found }; } histogram() { diff --git a/src/calibrate.js b/src/calibrate.js index 305be5c..fc2bff0 100644 --- a/src/calibrate.js +++ b/src/calibrate.js @@ -426,6 +426,20 @@ const CLIP_LIMIT = 0.02; /* How many patches a fit needs before its answer means anything. */ const MIN_PATCHES = 12; +/* + * How far from the chart a fit may land and still be a fit to the chart. + * + * Rows that sum to one are guaranteed by construction, so they say nothing + * about whether the patches were a chart. The error does. The real chart in + * tests/chart-on-wood.dng fits at a mean of 4.77 ΔE2000, and a chart drawn by + * tools/make-chart.mjs at 7.47 -- it is drawn from the 8-bit sRGB values, not + * the Lab ones it is scored against. Patches off that frame's wood, and off a + * camera's plain wall with no chart on it, fit at 19 to 26 wherever the + * corners were put -- and applied to the camera, a matrix solved from that + * wall turned its picture into amplified noise (OpenIPC/raw-editor#42). + */ +const MAX_MEAN_DELTA_E = 12; + /* * Score a camera-to-display matrix on the chart, the way solveFromPatches * scores its own: white-balanced camera values through the matrix, one @@ -557,6 +571,9 @@ export function solveFromPatches(measured, opts = {}) { deltaE(apply3(LINEAR_SRGB_TO_XYZ50, pred(p, i)), CHART_XYZ50[i])), 0) / errs.filter((e) => e !== null).length; fit.patches = weights.reduce((s, w) => s + w, 0); + if (!(fit.meanDeltaE <= MAX_MEAN_DELTA_E)) + 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`); /* * ColorMatrix1 maps XYZ *under the light the chart was lit by* to the diff --git a/src/engine.js b/src/engine.js index 7b38d8e..3ea9872 100644 --- a/src/engine.js +++ b/src/engine.js @@ -269,10 +269,16 @@ export class Engine { const found = x.detect_chart(cfa, this.chartPtr); if (found <= 0) return null; const f = new Float32Array(x.memory.buffer, this.chartPtr, 8); - return { - corners: [[f[0], f[1]], [f[2], f[3]], [f[4], f[5]], [f[6], f[7]]], - cells: found, - }; + const corners = [[f[0], f[1]], [f[2], f[3]], [f[4], f[5]], [f[6], f[7]]]; + // A lattice whose corners the detector had to extrapolate off the frame + // is not a chart that can be measured -- the cells past the edge have + // nothing under them -- and on a frame with no chart at all it is what a + // fit to the wall looks like: a plain red wall came back as 19 cells + // with corners at (2170, 1986) on a 1920x1080 frame, and the matrix + // solved from it wrecked the camera's picture (OpenIPC/raw-editor#42). + if (corners.some(([cx, cy]) => !(cx >= 0 && cy >= 0 && cx <= i.width && cy <= i.height))) + return null; + return { corners, cells: found }; } histogram() { diff --git a/tests/ui-check.html b/tests/ui-check.html index 260e484..6567d18 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -8,6 +8,21 @@ const results = []; const t = (name, ok, detail = '') => results.push({ name, ok, detail }); +/* A frame Calibrate can measure without a drag: a chart drawn exactly under + * the default corners (the middle third, as defaultCorners puts them). It has + * to be a chart -- the solver refuses patches that do not fit one, which is + * what fixture.dng's middle third is (OpenIPC/raw-editor#42) -- and it + * carries fixture.dng's own ColorMatrix1, so the light can be named. */ +async function calibrationChart() { + const W = 640, H = 480; + const corners = [[W * 0.3, H * 0.35], [W * 0.7, H * 0.35], [W * 0.7, H * 0.72], [W * 0.3, H * 0.72]]; + const matrices = [{ illuminant: 17, matrix: [1.120947, -0.41217, -0.39907, -0.212967, 1.211035, + 0.065135, -0.054979, 0.354551, 0.849321] }]; + const url = '/__chart.dng?corners=' + encodeURIComponent(JSON.stringify(corners)) + + '&matrices=' + encodeURIComponent(JSON.stringify(matrices)); + return new Uint8Array(await (await fetch(url)).arrayBuffer()); +} + /* * The phone pass. * @@ -653,9 +668,24 @@ `dragged to ${dragged}, now ${host.querySelector('.re-chart-grip[data-corner="0"]').style.left}, default ${atDefault}`); t('with the previous result cleared rather than left to be applied', !host.querySelector('[data-act="apply-to-camera"]')); - // Back to the frame the rest of this block measures. + // A frame with no chart in it, measured anyway: the answer is a refusal + // on the panel, and nothing to send to a camera. Before the solver had + // a limit on the fit this was a matrix that, applied, turned a camera's + // picture into amplified noise (OpenIPC/raw-editor#42). await ed.open(new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()), 'fixture.dng'); await new Promise((r) => setTimeout(r, 400)); + host.querySelector('[data-act="measure"]').click(); + let said = ''; + for (let i = 0; i < 100 && !said; i++) { + await new Promise((r) => setTimeout(r, 100)); + said = [...host.querySelectorAll('.re-warn')].map((w) => w.textContent).join(' | '); + } + t('measuring a frame with no chart in it is refused on the panel', + /not on a colour chart/.test(said), said.slice(0, 160) || '(nothing said)'); + t('and offers nothing to write to the camera', !host.querySelector('[data-act="apply-to-camera"]')); + // Back to a frame the rest of this block can measure. + await ed.open(await calibrationChart(), 'chart.dng'); + await new Promise((r) => setTimeout(r, 400)); } host.querySelector('[data-act="measure"]').click(); @@ -668,8 +698,8 @@ const txt = host.querySelector('.re-note')?.parentElement?.textContent || ''; const res = [...host.querySelectorAll('p.re-note')].map((p) => p.textContent).join(' | '); t('the result reports ΔE2000', /Mean ΔE2000 \d+\.\d\d, worst \d+\.\d\d/.test(res), res.slice(0, 160) || txt.slice(0, 80)); - // fixture.dng is off a real camera and carries its ColorMatrix1/2, so - // the light can be named. + // The chart frame carries a real camera's ColorMatrix1, so the light + // can be named. 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)'); @@ -1571,7 +1601,7 @@ keep: async () => { seen.push('keep'); throw new Error('the camera answered 500.'); }, }, }); - await ed.open(new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()), 'fixture.dng'); + await ed.open(await calibrationChart(), 'chart.dng'); [...host.querySelectorAll('.re-seg button')].find((b) => b.textContent === 'Calibrate').click(); await new Promise((r) => setTimeout(r, 300)); host.querySelector('[data-act="measure"]').click(); diff --git a/tools/make-chart.mjs b/tools/make-chart.mjs index 125f56a..b138915 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 } = {}) { + saturated = null, colorMatrices = [] } = {}) { 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); @@ -94,5 +94,5 @@ export function makeChartFrame({ width = 640, height = 480, corners, const v = rgb[(y * width + x) * 3 + p] + rnd() * noise * 2; px[y * width + x] = Math.max(0, Math.min(4095, Math.round(v))); } - return { bytes: makeDng({ width, height, pixels: px, black: 0, white: 4095 }), corners }; + return { bytes: makeDng({ width, height, pixels: px, black: 0, white: 4095, colorMatrices }), corners }; } diff --git a/tools/smoke.mjs b/tools/smoke.mjs index c47e026..737ebd2 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -776,6 +776,37 @@ console.log('\na chart on a textured wall is still a chart'); const hand = [[192.4, 203.4], [577.5, 185.2], [594.2, 443.1], [208.4, 468.8]]; const err = ch ? Math.max(...ch.corners.map((p, i) => Math.hypot(p[0] - hand[i][0], p[1] - hand[i][1]))) : Infinity; assert('where it actually is', err < 6, err.toFixed(1) + ' px from the hand-placed corners'); + + /* + * What the solver is told on this frame decides what a camera is sent. + * The chart fits well; the wood around it does not fit at all, and before + * a limit on the fit error it solved anyway -- rows summing to one, as + * they always do -- into a matrix that, applied to a camera, turned the + * picture into amplified noise (OpenIPC/raw-editor#42). + */ + const { solveFromPatches, patchCentres } = await import('../src/calibrate.js'); + const measure = (corners) => { + const patches = [], clipped = []; + for (const c of patchCentres(corners)) { + const got = e.samplePatch(c.x, c.y, Math.max(4, Math.round(c.radius))); + patches.push(got.raw); + clipped.push(got.clipped || 0); + } + return solveFromPatches(patches, { clipped, colorMatrices: info.colorMatrices }); + }; + let onChart = null, onChartErr = ''; + try { onChart = measure(ch.corners); } catch (x) { onChartErr = x.message; } + assert('the chart itself still calibrates', !!onChart && onChart.fit.meanDeltaE < 10, + onChart ? onChart.fit.meanDeltaE.toFixed(2) + ' ΔE2000' : onChartErr); + // The editor's default corners, the middle third of the frame: on this + // frame that is mostly boards and the shelf, not the chart. + const W = info.width, H = info.height; + let offChart = ''; + try { + measure([[W * 0.3, H * 0.35], [W * 0.7, H * 0.35], [W * 0.7, H * 0.72], [W * 0.3, H * 0.72]]); + } catch (x) { offChart = x.message; } + assert('the wood beside it is refused, not solved', /not on a colour chart/.test(offChart), + offChart || 'a matrix came back'); } console.log('\nthe camera profile: its AWB curve, its matrices, and a new set built from lights'); @@ -1157,6 +1188,14 @@ console.log('\nthe chart is found where it was drawn'); engine.open(readFileSync(new URL(`../tests/${f}`, import.meta.url))); assert(`${f} has no chart in it, and none is reported`, engine.detectChart() === null); } + + // A chart hanging off the bottom edge: the detector sees 18 cells and + // extrapolates the lattice to corners below the frame. The cells there + // have nothing under them to measure, and a lattice with corners off the + // frame is also what the detector made of a plain wall (two corners past + // the frame's far edge) -- so it is not reported. + engine.open(makeChartFrame({ corners: [[120, 250], [520, 250], [520, 520], [120, 520]] }).bytes); + assert('a chart cut off by the frame edge is not reported', engine.detectChart() === null); } console.log('\nhostile metadata stays data'); diff --git a/tools/ui-check.mjs b/tools/ui-check.mjs index d42acd0..61648b3 100644 --- a/tools/ui-check.mjs +++ b/tools/ui-check.mjs @@ -80,9 +80,12 @@ const server = createServer(async (req, res) => { if (req.url.startsWith('/__chart.dng')) { const q = new URL(req.url, 'http://x').searchParams; const corners = JSON.parse(q.get('corners')); + // A camera's own ColorMatrix1/2, when the check needs the light named + // off them the way a real frame's are. + const colorMatrices = JSON.parse(q.get('matrices') || '[]'); const { makeChartFrame } = await import('./make-chart.mjs'); res.writeHead(200, { 'content-type': 'application/octet-stream' }); - res.end(makeChartFrame({ corners }).bytes); + res.end(makeChartFrame({ corners, colorMatrices }).bytes); return; } const p = join(ROOT, normalize(decodeURIComponent(req.url.split('?')[0])));