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]); }); }