From 103e96f0971e138b8e04a7f20fa8091fa5bc81be Mon Sep 17 00:00:00 2001 From: sjat Date: Thu, 11 Jun 2026 09:29:24 +0200 Subject: [PATCH] feat: calibration UI computes PolyWarp with degree selector --- src/app/calibration-ui.test.ts | 22 +++++++------- src/app/calibration-ui.ts | 53 ++++++++++++++++++---------------- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/app/calibration-ui.test.ts b/src/app/calibration-ui.test.ts index 0f42a38..bae2934 100644 --- a/src/app/calibration-ui.test.ts +++ b/src/app/calibration-ui.test.ts @@ -1,22 +1,22 @@ import { describe, it, expect } from 'vitest'; import { residuals } from './calibration-ui'; -import { estimateHomography } from '../geometry/homography'; +import { estimatePolyWarp } from '../geometry/polywarp'; import type { Vec2 } from '../types'; describe('calibration residuals', () => { - it('reports near-zero error for a clean fit', () => { + it('reports near-zero error for a clean degree-1 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); + const image: Vec2[] = machine.map((p): Vec2 => [p[0] * 0.5 + 5, p[1] * 0.5 + 5]); + const w = estimatePolyWarp(machine, image, 1); + const errs = residuals(w, machine, image); + expect(Math.max(...errs)).toBeLessThan(1e-4); }); 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); + const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50], [25, 75], [75, 25]]; + const image: Vec2[] = [[0, 0], [50, 0], [50, 50], [0, 50], [25, 25], [12.5, 37.5], [90, 5]]; // last is wrong + const w = estimatePolyWarp(machine, image, 1); + const errs = residuals(w, machine, image); + expect(errs[6]!).toBeGreaterThan(5); }); }); diff --git a/src/app/calibration-ui.ts b/src/app/calibration-ui.ts index 68ed23a..554974b 100644 --- a/src/app/calibration-ui.ts +++ b/src/app/calibration-ui.ts @@ -1,11 +1,11 @@ -import type { Vec2, Mat3 } from '../types'; -import { estimateHomography, applyHomography } from '../geometry/homography'; +import type { Vec2, PolyWarp } from '../types'; +import { estimatePolyWarp, applyPolyWarp } from '../geometry/polywarp'; -/** Per-point reprojection error in pixels: |H·machine − image|. */ -export function residuals(H: Mat3, machine: Vec2[], image: Vec2[]): number[] { +/** 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 = applyHomography(H, m); + const p = applyPolyWarp(warp, m); return Math.hypot(p[0] - image[i]![0], p[1] - image[i]![1]); }); } @@ -18,16 +18,15 @@ interface CalibPoint { export interface CalibrationDeps { panel: HTMLElement; overlay: HTMLCanvasElement; - onHomography: (H: Mat3, points: CalibPoint[]) => void; + 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 ≥4× → "Compute". + * click the spindle tip in the video → repeat ≥10× → choose degree → "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). + * 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; @@ -36,7 +35,7 @@ export function mountCalibration(deps: CalibrationDeps): void { panel.innerHTML = `

Calibration

-
Jog the spindle to a known X/Y, enter it, then click the tip in the video.
+
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 @@ -44,7 +43,8 @@ export function mountCalibration(deps: CalibrationDeps): void {
- + Degree +
@@ -54,6 +54,7 @@ export function mountCalibration(deps: CalibrationDeps): void { 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[]) => { @@ -74,8 +75,6 @@ export function mountCalibration(deps: CalibrationDeps): void { 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, @@ -93,24 +92,28 @@ export function mountCalibration(deps: CalibrationDeps): void { }); (panel.querySelector('#cal-compute') as HTMLButtonElement).addEventListener('click', () => { - if (points.length < 4) { - jsonEl.value = 'Need at least 4 points.'; + 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); - const H = estimateHomography(machine, image); - // Per-axis pixel residual (normalized error scaled by canvas width/height) — used only for misclick display. + 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 = applyHomography(H, m); + 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, homography: H }, - null, - 2, - ); - deps.onHomography(H, [...points]); + jsonEl.value = JSON.stringify({ imagePoints: image, machinePoints: machine, warp }, null, 2); + deps.onWarp(warp, [...points]); }); }