GCodeOverlay/src/app/calibration-ui.ts

119 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `
<h2>Calibration</h2>
<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" />
<button id="cal-arm">Click point in video</button>
</div>
<ul id="cal-list"></ul>
<div class="row">
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>
`;
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) => `<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>`)
.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]);
});
}