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[] {
if (machine.length !== image.length) throw new Error('residuals: machine/image length mismatch');
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".
*
* Captured image points and the resulting homography are in normalized [0,1] camera-frame
* coordinates (machine-mm → normalized image), so they are display-size independent.
* The JSON output structure stays the same (imagePoints now hold normalized values).
*/
export function mountCalibration(deps: CalibrationDeps): void {
const { panel, overlay } = deps;
const points: CalibPoint[] = [];
let pendingMachine: Vec2 | null = null;
panel.innerHTML = `
Calibration
Jog the spindle to a known X/Y, enter it, then click the tip in the video.
X
Y
`;
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) => `(${p.machine[0]}, ${p.machine[1]}) → img(${p.image[0].toFixed(3)}, ${p.image[1].toFixed(3)})${errs ? ` err ${errs[i]!.toFixed(1)}px` : ''}`)
.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();
// Store normalized [0,1] image coords (fraction of the displayed camera frame).
// This makes the calibration independent of display/canvas size.
const px: Vec2 = [
(ev.clientX - rect.left) / rect.width,
(ev.clientY - rect.top) / rect.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);
// Per-axis pixel residual (normalized error scaled by canvas width/height) — used only for misclick display.
const errsPx = machine.map((m, i) => {
const p = applyHomography(H, m);
return Math.hypot((p[0] - image[i]![0]) * overlay.width, (p[1] - image[i]![1]) * overlay.height);
});
renderList(errsPx);
jsonEl.value = JSON.stringify(
{ imagePoints: image, machinePoints: machine, homography: H },
null,
2,
);
deps.onHomography(H, [...points]);
});
}