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