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');