feat: calibration capture UI with reprojection residuals
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
38d99bb0dc
commit
1df5509b29
3 changed files with 139 additions and 0 deletions
22
src/app/calibration-ui.test.ts
Normal file
22
src/app/calibration-ui.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { residuals } from './calibration-ui';
|
||||
import { estimateHomography } from '../geometry/homography';
|
||||
import type { Vec2 } from '../types';
|
||||
|
||||
describe('calibration residuals', () => {
|
||||
it('reports near-zero error for a clean fit', () => {
|
||||
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100]];
|
||||
const image: Vec2[] = [[10, 10], [210, 12], [208, 210], [8, 208]];
|
||||
const H = estimateHomography(machine, image);
|
||||
const errs = residuals(H, machine, image);
|
||||
expect(Math.max(...errs)).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it('flags a mismatched point with a large residual', () => {
|
||||
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50]];
|
||||
const image: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [80, 20]]; // last is wrong
|
||||
const H = estimateHomography(machine, image);
|
||||
const errs = residuals(H, machine, image);
|
||||
expect(errs[4]!).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
106
src/app/calibration-ui.ts
Normal file
106
src/app/calibration-ui.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { Vec2, Mat3 } from '../types';
|
||||
import { estimateHomography, applyHomography } from '../geometry/homography';
|
||||
|
||||
/** Per-point reprojection error in pixels: |H·machine − image|. */
|
||||
export function residuals(H: Mat3, machine: Vec2[], image: Vec2[]): number[] {
|
||||
return machine.map((m, i) => {
|
||||
const p = applyHomography(H, m);
|
||||
return Math.hypot(p[0] - image[i]![0], p[1] - image[i]![1]);
|
||||
});
|
||||
}
|
||||
|
||||
interface CalibPoint {
|
||||
machine: Vec2;
|
||||
image: Vec2;
|
||||
}
|
||||
|
||||
export interface CalibrationDeps {
|
||||
panel: HTMLElement;
|
||||
overlay: HTMLCanvasElement;
|
||||
onHomography: (H: Mat3, points: CalibPoint[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the calibration panel. Flow: enter machine X/Y → click "Click point in video" →
|
||||
* click the spindle tip in the video → repeat ≥4× → "Compute".
|
||||
*/
|
||||
export function mountCalibration(deps: CalibrationDeps): void {
|
||||
const { panel, overlay } = deps;
|
||||
const points: CalibPoint[] = [];
|
||||
let pendingMachine: Vec2 | null = null;
|
||||
|
||||
panel.innerHTML = `
|
||||
<h2>Calibration</h2>
|
||||
<div class="row"><small>Jog the spindle to a known X/Y, enter it, then click the tip in the video.</small></div>
|
||||
<div class="row">
|
||||
X <input id="cal-x" type="number" step="0.1" />
|
||||
Y <input id="cal-y" type="number" step="0.1" />
|
||||
<button id="cal-arm">Click point in video</button>
|
||||
</div>
|
||||
<ul id="cal-list"></ul>
|
||||
<div class="row">
|
||||
<button id="cal-compute">Compute homography</button>
|
||||
<button id="cal-clear">Clear</button>
|
||||
</div>
|
||||
<textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea>
|
||||
`;
|
||||
|
||||
const xEl = panel.querySelector('#cal-x') as HTMLInputElement;
|
||||
const yEl = panel.querySelector('#cal-y') as HTMLInputElement;
|
||||
const armBtn = panel.querySelector('#cal-arm') as HTMLButtonElement;
|
||||
const list = panel.querySelector('#cal-list') as HTMLUListElement;
|
||||
const jsonEl = panel.querySelector('#cal-json') as HTMLTextAreaElement;
|
||||
|
||||
const renderList = (errs?: number[]) => {
|
||||
list.innerHTML = points
|
||||
.map((p, i) => `<li>(${p.machine[0]}, ${p.machine[1]}) → px(${p.image[0].toFixed(0)}, ${p.image[1].toFixed(0)})${errs ? ` <small>err ${errs[i]!.toFixed(1)}px</small>` : ''}</li>`)
|
||||
.join('');
|
||||
};
|
||||
|
||||
armBtn.addEventListener('click', () => {
|
||||
pendingMachine = [parseFloat(xEl.value), parseFloat(yEl.value)];
|
||||
if (Number.isNaN(pendingMachine[0]) || Number.isNaN(pendingMachine[1])) {
|
||||
pendingMachine = null;
|
||||
return;
|
||||
}
|
||||
overlay.classList.add('interactive');
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', (ev) => {
|
||||
if (!pendingMachine || !overlay.classList.contains('interactive')) return;
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
// Map click to native image pixels: canvas backing store == displayed media box.
|
||||
const px: Vec2 = [
|
||||
((ev.clientX - rect.left) / rect.width) * overlay.width,
|
||||
((ev.clientY - rect.top) / rect.height) * overlay.height,
|
||||
];
|
||||
points.push({ machine: pendingMachine, image: px });
|
||||
pendingMachine = null;
|
||||
overlay.classList.remove('interactive');
|
||||
renderList();
|
||||
});
|
||||
|
||||
(panel.querySelector('#cal-clear') as HTMLButtonElement).addEventListener('click', () => {
|
||||
points.length = 0;
|
||||
jsonEl.value = '';
|
||||
renderList();
|
||||
});
|
||||
|
||||
(panel.querySelector('#cal-compute') as HTMLButtonElement).addEventListener('click', () => {
|
||||
if (points.length < 4) {
|
||||
jsonEl.value = 'Need at least 4 points.';
|
||||
return;
|
||||
}
|
||||
const machine = points.map((p) => p.machine);
|
||||
const image = points.map((p) => p.image);
|
||||
const H = estimateHomography(machine, image);
|
||||
const errs = residuals(H, machine, image);
|
||||
renderList(errs);
|
||||
jsonEl.value = JSON.stringify(
|
||||
{ imagePoints: image, machinePoints: machine, homography: H },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
deps.onHomography(H, [...points]);
|
||||
});
|
||||
}
|
||||
11
src/main.ts
11
src/main.ts
|
|
@ -3,6 +3,7 @@ import { loadConfig } from './config';
|
|||
import { createState } from './app/state';
|
||||
import { computeOverlayRect, syncOverlay } from './app/layout';
|
||||
import { drawOverlay, clearCanvas } from './render/renderer';
|
||||
import { mountCalibration } from './app/calibration-ui';
|
||||
import type { RenderStyle } from './types';
|
||||
|
||||
const stage = document.getElementById('stage') as HTMLDivElement;
|
||||
|
|
@ -42,6 +43,16 @@ fileInput.addEventListener('change', async () => {
|
|||
// expose for the UI modules added in later tasks
|
||||
export const app = { state, render, resize, overlay, stage, stream };
|
||||
|
||||
mountCalibration({
|
||||
panel: document.getElementById('calib-panel') as HTMLElement,
|
||||
overlay,
|
||||
onHomography: (H) => {
|
||||
state.setHomography(H);
|
||||
statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
|
||||
render();
|
||||
},
|
||||
});
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
try {
|
||||
const cfg = await loadConfig('config.json');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue