feat: calibration UI computes PolyWarp with degree selector
This commit is contained in:
parent
f3081d36b5
commit
103e96f097
2 changed files with 39 additions and 36 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<h2>Calibration</h2>
|
||||
<div class="row"><small>Jog the spindle to a known X/Y, enter it, then click the tip in the video.</small></div>
|
||||
<div class="row"><small>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.</small></div>
|
||||
<div class="row">
|
||||
X <input id="cal-x" type="number" step="0.1" />
|
||||
Y <input id="cal-y" type="number" step="0.1" />
|
||||
|
|
@ -44,7 +43,8 @@ export function mountCalibration(deps: CalibrationDeps): void {
|
|||
</div>
|
||||
<ul id="cal-list"></ul>
|
||||
<div class="row">
|
||||
<button id="cal-compute">Compute homography</button>
|
||||
Degree <select id="cal-degree"><option value="2">2</option><option value="3" selected>3</option></select>
|
||||
<button id="cal-compute">Compute warp</button>
|
||||
<button id="cal-clear">Clear</button>
|
||||
</div>
|
||||
<textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea>
|
||||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue