Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions dist/calibrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions dist/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
17 changes: 17 additions & 0 deletions src/calibrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Incomplete charts are still reported 📎 Requirement gap ≡ Correctness

detectChart() returns { corners, cells: found } after checking corner bounds, without requiring
found to equal 24. The underlying detector accepts as few as 18 distinct cells, so an incomplete
detection with in-frame corners reaches the editor as a found chart, where it is offered for
measurement with only a warning.
Agent Prompt
## Issue description
The new bounds check still reports detections containing fewer than 24 distinct cells as charts.

## Fix Focus Areas
- src/engine.js[269-281]
- dist/engine.js[269-281]

## Recommended Fix
Return null unless the detector reports all 24 cells, in both the source and built copy. Add a test for an in-frame detection with fewer than 24 cells.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

histogram() {
Expand Down
38 changes: 34 additions & 4 deletions tests/ui-check.html
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
Expand All @@ -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)');
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions tools/make-chart.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
}
39 changes: 39 additions & 0 deletions tools/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
5 changes: 4 additions & 1 deletion tools/ui-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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])));
Expand Down
Loading