2026-06-11 09:29:24 +02:00
|
|
|
|
import type { Vec2, PolyWarp } from '../types';
|
|
|
|
|
|
import { estimatePolyWarp, applyPolyWarp } from '../geometry/polywarp';
|
2026-06-08 22:40:32 +02:00
|
|
|
|
|
2026-06-11 09:29:24 +02:00
|
|
|
|
/** Per-point reprojection error in normalized units: |warp(machine) − image|. */
|
|
|
|
|
|
export function residuals(warp: PolyWarp, machine: Vec2[], image: Vec2[]): number[] {
|
2026-06-08 22:45:29 +02:00
|
|
|
|
if (machine.length !== image.length) throw new Error('residuals: machine/image length mismatch');
|
2026-06-08 22:40:32 +02:00
|
|
|
|
return machine.map((m, i) => {
|
2026-06-11 09:29:24 +02:00
|
|
|
|
const p = applyPolyWarp(warp, m);
|
2026-06-08 22:40:32 +02:00
|
|
|
|
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;
|
2026-06-11 09:29:24 +02:00
|
|
|
|
onWarp: (w: PolyWarp, points: CalibPoint[]) => void;
|
2026-06-08 22:40:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Renders the calibration panel. Flow: enter machine X/Y → click "Click point in video" →
|
2026-06-11 09:29:24 +02:00
|
|
|
|
* click the spindle tip in the video → repeat ≥10× → choose degree → "Compute".
|
2026-06-08 22:45:29 +02:00
|
|
|
|
*
|
2026-06-11 09:29:24 +02:00
|
|
|
|
* Image points and the resulting warp are in normalized [0,1] camera-frame coordinates
|
|
|
|
|
|
* (machine-mm ↔ normalized image), so they are display-size independent.
|
2026-06-08 22:40:32 +02:00
|
|
|
|
*/
|
|
|
|
|
|
export function mountCalibration(deps: CalibrationDeps): void {
|
|
|
|
|
|
const { panel, overlay } = deps;
|
|
|
|
|
|
const points: CalibPoint[] = [];
|
|
|
|
|
|
let pendingMachine: Vec2 | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
panel.innerHTML = `
|
|
|
|
|
|
<h2>Calibration</h2>
|
2026-06-11 09:29:24 +02:00
|
|
|
|
<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>
|
2026-06-08 22:40:32 +02:00
|
|
|
|
<div class="row">
|
|
|
|
|
|
X <input id="cal-x" type="number" step="0.1" />
|
|
|
|
|
|
Y <input id="cal-y" type="number" step="0.1" />
|
|
|
|
|
|
<button id="cal-arm">Click point in video</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul id="cal-list"></ul>
|
|
|
|
|
|
<div class="row">
|
2026-06-11 09:29:24 +02:00
|
|
|
|
Degree <select id="cal-degree"><option value="2">2</option><option value="3" selected>3</option></select>
|
|
|
|
|
|
<button id="cal-compute">Compute warp</button>
|
2026-06-08 22:40:32 +02:00
|
|
|
|
<button id="cal-clear">Clear</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-11 09:29:24 +02:00
|
|
|
|
const degreeEl = panel.querySelector('#cal-degree') as HTMLSelectElement;
|
2026-06-08 22:40:32 +02:00
|
|
|
|
const jsonEl = panel.querySelector('#cal-json') as HTMLTextAreaElement;
|
|
|
|
|
|
|
|
|
|
|
|
const renderList = (errs?: number[]) => {
|
|
|
|
|
|
list.innerHTML = points
|
2026-06-08 22:57:03 +02:00
|
|
|
|
.map((p, i) => `<li>(${p.machine[0]}, ${p.machine[1]}) → img(${p.image[0].toFixed(3)}, ${p.image[1].toFixed(3)})${errs ? ` <small>err ${errs[i]!.toFixed(1)}px</small>` : ''}</li>`)
|
2026-06-08 22:40:32 +02:00
|
|
|
|
.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 = [
|
2026-06-08 22:45:29 +02:00
|
|
|
|
(ev.clientX - rect.left) / rect.width,
|
|
|
|
|
|
(ev.clientY - rect.top) / rect.height,
|
2026-06-08 22:40:32 +02:00
|
|
|
|
];
|
|
|
|
|
|
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', () => {
|
2026-06-11 09:29:24 +02:00
|
|
|
|
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}.`;
|
2026-06-08 22:40:32 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const machine = points.map((p) => p.machine);
|
|
|
|
|
|
const image = points.map((p) => p.image);
|
2026-06-11 09:29:24 +02:00
|
|
|
|
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.
|
2026-06-08 22:57:03 +02:00
|
|
|
|
const errsPx = machine.map((m, i) => {
|
2026-06-11 09:29:24 +02:00
|
|
|
|
const p = applyPolyWarp(warp, m);
|
2026-06-08 22:57:03 +02:00
|
|
|
|
return Math.hypot((p[0] - image[i]![0]) * overlay.width, (p[1] - image[i]![1]) * overlay.height);
|
|
|
|
|
|
});
|
|
|
|
|
|
renderList(errsPx);
|
2026-06-11 09:29:24 +02:00
|
|
|
|
jsonEl.value = JSON.stringify({ imagePoints: image, machinePoints: machine, warp }, null, 2);
|
|
|
|
|
|
deps.onWarp(warp, [...points]);
|
2026-06-08 22:40:32 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|