From 1df5509b293f372deabd3fe7aa1dfa0b629ed5c5 Mon Sep 17 00:00:00 2001 From: sjat Date: Mon, 8 Jun 2026 22:40:32 +0200 Subject: [PATCH] feat: calibration capture UI with reprojection residuals Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/calibration-ui.test.ts | 22 +++++++ src/app/calibration-ui.ts | 106 +++++++++++++++++++++++++++++++++ src/main.ts | 11 ++++ 3 files changed, 139 insertions(+) create mode 100644 src/app/calibration-ui.test.ts create mode 100644 src/app/calibration-ui.ts diff --git a/src/app/calibration-ui.test.ts b/src/app/calibration-ui.test.ts new file mode 100644 index 0000000..0f42a38 --- /dev/null +++ b/src/app/calibration-ui.test.ts @@ -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); + }); +}); diff --git a/src/app/calibration-ui.ts b/src/app/calibration-ui.ts new file mode 100644 index 0000000..b78cf07 --- /dev/null +++ b/src/app/calibration-ui.ts @@ -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 = ` +

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]}) → px(${p.image[0].toFixed(0)}, ${p.image[1].toFixed(0)})${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(); + // 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]); + }); +} diff --git a/src/main.ts b/src/main.ts index 9e09683..f3fadbb 100644 --- a/src/main.ts +++ b/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 { try { const cfg = await loadConfig('config.json');