import type { Vec2, PolyWarp } from '../types';
import { estimatePolyWarp, applyPolyWarp } from '../geometry/polywarp';
/** Per-point reprojection error in normalized units: |warp(machine) − image|. */
export function residuals(warp: PolyWarp, 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 = applyPolyWarp(warp, 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;
onWarp: (w: PolyWarp, 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 ≥10× → choose degree → "Compute".
*
* Image points and the resulting warp are in normalized [0,1] camera-frame coordinates
* (machine-mm ↔ normalized image), so they are display-size independent.
*/
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. ≥10 points (degree 3); spread them out incl. corners.
X
Y
Degree
`;
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 degreeEl = panel.querySelector('#cal-degree') as HTMLSelectElement;
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();
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', () => {
const degree = parseInt(degreeEl.value, 10);
const need = ((degree + 1) * (degree + 2)) / 2;
if (points.length < need) {
jsonEl.value = `Need at least ${need} points for degree ${degree}.`;
return;
}
const machine = points.map((p) => p.machine);
const image = points.map((p) => p.image);
let warp: PolyWarp;
try {
warp = estimatePolyWarp(machine, image, degree);
} catch (e) {
jsonEl.value = `Fit failed: ${(e as Error).message}`;
return;
}
// Per-point pixel residual (normalized error scaled by canvas size) — for misclick display.
const errsPx = machine.map((m, i) => {
const p = applyPolyWarp(warp, 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, warp }, null, 2);
deps.onWarp(warp, [...points]);
});
}