GCodeOverlay/docs/superpowers/plans/2026-06-11-wide-angle-distortion.md

1041 lines
39 KiB
Markdown
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.

# Wide-Angle Distortion Compensation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the single homography with a bivariate polynomial warp so the G-code overlay curves to match the wide-angle camera's barrel distortion.
**Architecture:** A `PolyWarp` stores two independently least-squares-fit polynomial maps — forward (machine-mm → normalized image) for drawing and inverse (normalized image → machine-mm) for click interactions. Machine inputs are normalized (center+scale) before building monomials to keep the normal equations well-conditioned. Degree 2 is the default; degree 3 is selectable. The homography path is fully retired.
**Tech Stack:** TypeScript, Vite, Vitest. No new dependencies.
**Reference spec:** `docs/superpowers/specs/2026-06-11-wide-angle-distortion-design.md`
---
## Notes for the implementer
- **Vitest uses esbuild**, which strips types without whole-program type-checking. So per-file tests run green even while *other* files still reference the old `homography`/`Mat3` API mid-migration. The final `tsc --noEmit` (via `npm run build`, Task 8) is the gate that catches cross-file type holes — do not skip it.
- Run a single test file with: `npx vitest run src/path/to/file.test.ts`
- Run everything with: `npm test`
- Image coordinates are normalized `[0,1]` camera-frame fractions throughout (unchanged from today).
---
## Task 1: Linear-algebra helper (`linalg.ts`)
Extract the existing Gaussian-elimination solver into its own module and add a least-squares helper that both polynomial fits will share.
**Files:**
- Create: `src/geometry/linalg.ts`
- Test: `src/geometry/linalg.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/geometry/linalg.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { solveLinear, leastSquares } from './linalg';
describe('linalg', () => {
it('solveLinear solves a 2x2 system', () => {
// 2x + y = 5 ; x + 3y = 10 → x = 1, y = 3
const x = solveLinear([[2, 1], [1, 3]], [5, 10]);
expect(x[0]!).toBeCloseTo(1, 9);
expect(x[1]!).toBeCloseTo(3, 9);
});
it('solveLinear throws on a singular system', () => {
expect(() => solveLinear([[1, 2], [2, 4]], [3, 6])).toThrow();
});
it('leastSquares recovers exact coefficients of an over-determined linear fit', () => {
// model: t = 2*a + 3*b ; rows are [a, b]
const rows = [[1, 0], [0, 1], [1, 1], [2, 1]];
const targets = rows.map((r) => 2 * r[0]! + 3 * r[1]!);
const c = leastSquares(rows, targets);
expect(c[0]!).toBeCloseTo(2, 9);
expect(c[1]!).toBeCloseTo(3, 9);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/geometry/linalg.test.ts`
Expected: FAIL — cannot find module `./linalg`.
- [ ] **Step 3: Write minimal implementation**
Create `src/geometry/linalg.ts`:
```typescript
/** Solve a square linear system A x = b by Gaussian elimination with partial pivoting. */
export function solveLinear(A: number[][], b: number[]): number[] {
const n = b.length;
const M = A.map((row, i) => [...row, b[i]!]);
for (let col = 0; col < n; col++) {
let pivot = col;
for (let r = col + 1; r < n; r++) {
if (Math.abs(M[r]![col]!) > Math.abs(M[pivot]![col]!)) pivot = r;
}
if (Math.abs(M[pivot]![col]!) < 1e-12) throw new Error('Singular system');
[M[col], M[pivot]] = [M[pivot]!, M[col]!];
const pivRow = M[col]!;
for (let r = 0; r < n; r++) {
if (r === col) continue;
const factor = M[r]![col]! / pivRow[col]!;
for (let k = col; k <= n; k++) M[r]![k]! -= factor * pivRow[k]!;
}
}
return M.map((row, i) => row[n]! / row[i]!);
}
/**
* Least-squares solve for c minimizing ‖rows·c targets‖ via the normal equations
* (rowsᵀ rows) c = rowsᵀ targets. `rows` is M×N (M ≥ N), `targets` is length M.
*/
export function leastSquares(rows: number[][], targets: number[]): number[] {
const n = rows[0]!.length;
const ata: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
const atb: number[] = new Array(n).fill(0);
for (let r = 0; r < rows.length; r++) {
const row = rows[r]!;
for (let i = 0; i < n; i++) {
atb[i]! += row[i]! * targets[r]!;
for (let j = 0; j < n; j++) ata[i]![j]! += row[i]! * row[j]!;
}
}
return solveLinear(ata, atb);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/geometry/linalg.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/geometry/linalg.ts src/geometry/linalg.test.ts
git commit -m "feat: extract linalg solver + add least-squares helper"
```
---
## Task 2: Polynomial warp model (`polywarp.ts`)
The core of the feature: fit and apply the bivariate polynomial maps.
**Files:**
- Create: `src/geometry/polywarp.ts`
- Modify: `src/types.ts` (add `PolyMap`, `PolyWarp` — pure types only)
- Test: `src/geometry/polywarp.test.ts`
- [ ] **Step 1: Add the shared types**
In `src/types.ts`, add after the `Mat3` type (leave `Mat3` itself in place; it becomes unused but harmless):
```typescript
/** One fitted polynomial map: dst = Σ cᵢ · monomialᵢ(normalized src). */
export interface PolyMap {
degree: number;
/** Input normalization applied before evaluating monomials. */
norm: { off: Vec2; scl: Vec2 };
/** Coefficients for the u/x output channel. */
cu: number[];
/** Coefficients for the v/y output channel. */
cv: number[];
}
/** A bidirectional polynomial warp: forward machine→image, inverse image→machine. */
export interface PolyWarp {
degree: number;
fwd: PolyMap;
inv: PolyMap;
}
```
- [ ] **Step 2: Write the failing test**
Create `src/geometry/polywarp.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { estimatePolyWarp, applyPolyWarp, applyPolyWarpInverse } from './polywarp';
import { estimateHomography, applyHomography } from './homography';
import type { Vec2 } from '../types';
const dist = (a: Vec2, b: Vec2) => Math.hypot(a[0] - b[0], a[1] - b[1]);
// 16-point "box + #" calibration layout on a 2440x1220 bed (thirds in each axis).
const XS = [0, 813.3, 1626.7, 2440];
const YS = [0, 406.7, 813.3, 1220];
const machineGrid: Vec2[] = XS.flatMap((x) => YS.map((y): Vec2 => [x, y]));
// "True" camera map: affine into ~[0.1,0.9] then barrel distortion about the image centre.
function trueMap(p: Vec2): Vec2 {
const u0 = 0.1 + 0.8 * (p[0] / 2440);
const v0 = 0.1 + 0.8 * (p[1] / 1220);
const cx = 0.5, cy = 0.5, k = 0.25;
const dx = u0 - cx, dy = v0 - cy, r2 = dx * dx + dy * dy;
const f = 1 + k * r2;
return [cx + dx * f, cy + dy * f];
}
describe('polywarp', () => {
it('recovers an exact quadratic map at unseen points', () => {
// dst is an exact degree-2 polynomial of src → fit must reproduce it.
const quad = (p: Vec2): Vec2 => {
const x = p[0] / 2440, y = p[1] / 1220;
return [0.2 + 0.5 * x + 0.1 * y + 0.3 * x * x, 0.1 + 0.6 * y + 0.2 * x * y];
};
const image = machineGrid.map(quad);
const w = estimatePolyWarp(machineGrid, image, 2);
for (const m of [[400, 200], [1200, 900], [2000, 300]] as Vec2[]) {
expect(dist(applyPolyWarp(w, m), quad(m))).toBeLessThan(1e-6);
}
});
it('beats a homography on barrel-distorted data', () => {
const image = machineGrid.map(trueMap);
const w = estimatePolyWarp(machineGrid, image, 2);
const H = estimateHomography(machineGrid, image);
// Evaluate on off-grid interior points (fifths), where barrel error is real.
const test: Vec2[] = [[488, 244], [1464, 732], [1952, 488], [976, 976]];
const polyMax = Math.max(...test.map((m) => dist(applyPolyWarp(w, m), trueMap(m))));
const homoMax = Math.max(...test.map((m) => dist(applyHomography(H, m), trueMap(m))));
expect(polyMax).toBeLessThan(homoMax * 0.5);
});
it('inverse round-trips machine points within a small tolerance', () => {
const image = machineGrid.map(trueMap);
const w = estimatePolyWarp(machineGrid, image, 3);
for (const m of [[400, 200], [1200, 900], [2000, 300]] as Vec2[]) {
expect(dist(applyPolyWarpInverse(w, applyPolyWarp(w, m)), m)).toBeLessThan(2); // mm
}
});
it('throws when given fewer points than the degree needs', () => {
const five = machineGrid.slice(0, 5);
const img = five.map(trueMap);
expect(() => estimatePolyWarp(five, img, 2)).toThrow(); // degree 2 needs 6
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/geometry/polywarp.test.ts`
Expected: FAIL — cannot find module `./polywarp`.
- [ ] **Step 4: Write minimal implementation**
Create `src/geometry/polywarp.ts`:
```typescript
import type { Vec2, PolyMap, PolyWarp } from '../types';
import { leastSquares } from './linalg';
/** Number of monomial terms of total degree ≤ d in two variables. */
function termCount(degree: number): number {
return ((degree + 1) * (degree + 2)) / 2;
}
/** Monomials x^i·y^j with i+j ≤ degree, in a fixed order. */
function monomials(degree: number, x: number, y: number): number[] {
const terms: number[] = [];
for (let i = 0; i <= degree; i++) {
for (let j = 0; j <= degree - i; j++) terms.push(x ** i * y ** j);
}
return terms;
}
/** Center+scale so normalized inputs sit roughly in [-1,1] (conditions the fit). */
function computeNorm(pts: Vec2[]): { off: Vec2; scl: Vec2 } {
const xs = pts.map((p) => p[0]);
const ys = pts.map((p) => p[1]);
const off: Vec2 = [xs.reduce((a, b) => a + b, 0) / xs.length, ys.reduce((a, b) => a + b, 0) / ys.length];
const sclx = Math.max(...xs.map((x) => Math.abs(x - off[0]))) || 1;
const scly = Math.max(...ys.map((y) => Math.abs(y - off[1]))) || 1;
return { off, scl: [sclx, scly] };
}
function applyNorm(norm: { off: Vec2; scl: Vec2 }, p: Vec2): Vec2 {
return [(p[0] - norm.off[0]) / norm.scl[0], (p[1] - norm.off[1]) / norm.scl[1]];
}
/** Fit one polynomial map src → dst of the given degree. */
function fitPolyMap(src: Vec2[], dst: Vec2[], degree: number): PolyMap {
if (src.length !== dst.length) throw new Error('fitPolyMap: src/dst length mismatch');
const need = termCount(degree);
if (src.length < need) throw new Error(`Need at least ${need} points for degree ${degree}`);
const norm = computeNorm(src);
const rows = src.map((p) => {
const [x, y] = applyNorm(norm, p);
return monomials(degree, x, y);
});
const cu = leastSquares(rows, dst.map((d) => d[0]));
const cv = leastSquares(rows, dst.map((d) => d[1]));
return { degree, norm, cu, cv };
}
/** Evaluate a fitted polynomial map at a point. */
export function applyPolyMap(m: PolyMap, p: Vec2): Vec2 {
const [x, y] = applyNorm(m.norm, p);
const mon = monomials(m.degree, x, y);
let u = 0;
let v = 0;
for (let i = 0; i < mon.length; i++) {
u += m.cu[i]! * mon[i]!;
v += m.cv[i]! * mon[i]!;
}
return [u, v];
}
/** Fit a bidirectional warp from matching machine↔image point pairs. */
export function estimatePolyWarp(machine: Vec2[], image: Vec2[], degree = 2): PolyWarp {
return {
degree,
fwd: fitPolyMap(machine, image, degree),
inv: fitPolyMap(image, machine, degree),
};
}
/** machine-mm → normalized image. */
export function applyPolyWarp(w: PolyWarp, machine: Vec2): Vec2 {
return applyPolyMap(w.fwd, machine);
}
/** normalized image → machine-mm. */
export function applyPolyWarpInverse(w: PolyWarp, image: Vec2): Vec2 {
return applyPolyMap(w.inv, image);
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/geometry/polywarp.test.ts`
Expected: PASS (4 tests). `homography.ts` still exists, so the test's homography import resolves.
- [ ] **Step 6: Commit**
```bash
git add src/types.ts src/geometry/polywarp.ts src/geometry/polywarp.test.ts
git commit -m "feat: bivariate polynomial warp model"
```
---
## Task 3: Migrate the transform layer (`transform.ts`)
Switch `projectSegments` and `imageToMachine` from homography to `PolyWarp`.
**Files:**
- Modify: `src/geometry/transform.ts`
- Test: `src/geometry/transform.test.ts`
- [ ] **Step 1: Update the test**
Replace the entire contents of `src/geometry/transform.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { applyAlignment, projectSegments, imageToMachine, alignmentFromOrigin } from './transform';
import { estimatePolyWarp } from './polywarp';
import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
const close = (a: Vec2, b: Vec2, eps = 1e-6) =>
Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
// Identity warp: image == machine. A degree-1 fit on a non-degenerate quad is exact.
const square: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
const IDENT: PolyWarp = estimatePolyWarp(square, square, 1);
describe('transform', () => {
it('applyAlignment rotates then translates', () => {
const a: Alignment = { tx: 5, ty: 1, rot: Math.PI / 2 };
expect(close(applyAlignment(a, [1, 0]), [5, 2])).toBe(true);
});
it('projectSegments applies alignment then the warp to every point', () => {
const segs: Segment[] = [{ kind: 'cut', points: [[0, 0], [10, 0]] }];
const a: Alignment = { tx: 2, ty: 3, rot: 0 };
const out = projectSegments(segs, a, IDENT);
expect(out[0]!.kind).toBe('cut');
expect(close(out[0]!.points[0]!, [2, 3])).toBe(true);
expect(close(out[0]!.points[1]!, [12, 3])).toBe(true);
});
it('imageToMachine inverts the warp', () => {
// Warp scales machine→image by 0.5 (offset 0): machine (4,8) → image (2,4).
const m: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
const img: Vec2[] = m.map((p): Vec2 => [p[0] * 0.5, p[1] * 0.5]);
const w = estimatePolyWarp(m, img, 1);
expect(close(imageToMachine(w, [2, 4]), [4, 8], 1e-4)).toBe(true);
});
it('alignmentFromOrigin places work-origin at the given machine point', () => {
const a = alignmentFromOrigin([7, 9], 0);
expect(close(applyAlignment(a, [0, 0]), [7, 9])).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/geometry/transform.test.ts`
Expected: FAIL — `projectSegments`/`imageToMachine` still expect a `Mat3`; `imageToMachine` call signature mismatch produces wrong results or runtime error.
- [ ] **Step 3: Update the implementation**
Replace the entire contents of `src/geometry/transform.ts`:
```typescript
import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
import { applyPolyWarp, applyPolyWarpInverse } from './polywarp';
/** Apply per-job alignment to a point: rotate by rot, then translate by (tx,ty). */
export function applyAlignment(a: Alignment, p: Vec2): Vec2 {
const c = Math.cos(a.rot);
const s = Math.sin(a.rot);
return [a.tx + c * p[0] - s * p[1], a.ty + s * p[0] + c * p[1]];
}
/** Project work-coordinate segments to image: work → (alignment) → machine → (warp) → image. */
export function projectSegments(segments: Segment[], a: Alignment, warp: PolyWarp): Segment[] {
return segments.map((seg) => ({
kind: seg.kind,
points: seg.points.map((p) => applyPolyWarp(warp, applyAlignment(a, p))),
}));
}
/** Convert a normalized image point to machine mm using the warp's inverse map. */
export function imageToMachine(warp: PolyWarp, imagePoint: Vec2): Vec2 {
return applyPolyWarpInverse(warp, imagePoint);
}
/** Build an alignment that places the work origin (0,0) at the given machine point. */
export function alignmentFromOrigin(machinePoint: Vec2, rot: number): Alignment {
return { tx: machinePoint[0], ty: machinePoint[1], rot };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/geometry/transform.test.ts`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/geometry/transform.ts src/geometry/transform.test.ts
git commit -m "refactor: transform layer uses PolyWarp"
```
---
## Task 4: Migrate app state (`state.ts`)
Rename `homography`/`setHomography` to `warp`/`setWarp`.
**Files:**
- Modify: `src/app/state.ts`
- Test: `src/app/state.test.ts`
- [ ] **Step 1: Update the test**
Replace the entire contents of `src/app/state.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { createState } from './state';
import { estimatePolyWarp } from '../geometry/polywarp';
import type { Vec2 } from '../types';
// Identity warp: image == machine.
const square: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
const IDENT = estimatePolyWarp(square, square, 1);
describe('app state', () => {
it('starts with no segments and a zero alignment', () => {
const s = createState();
expect(s.segments).toEqual([]);
expect(s.alignment).toEqual({ tx: 0, ty: 0, rot: 0 });
});
it('loadGcode stores parsed segments and returns warning count', () => {
const s = createState();
const warnings = s.loadGcode('G21 G90\nG1 X10 Y0');
expect(s.segments).toHaveLength(1);
expect(warnings).toEqual([]);
});
it('projected() returns image-space segments when calibrated', () => {
const s = createState();
s.setWarp(IDENT);
s.loadGcode('G21 G90\nG1 X10 Y0');
s.alignment.tx = 2;
const proj = s.projected();
expect(proj[0]!.points[0]![0]).toBeCloseTo(2, 4);
expect(proj[0]!.points[1]![0]).toBeCloseTo(12, 4);
});
it('projected() returns [] when not calibrated', () => {
const s = createState();
s.loadGcode('G1 X10 Y0');
expect(s.projected()).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/app/state.test.ts`
Expected: FAIL — `s.setWarp` is not a function.
- [ ] **Step 3: Update the implementation**
Replace the entire contents of `src/app/state.ts`:
```typescript
import type { Segment, Alignment, PolyWarp } from '../types';
import { parseGcode } from '../gcode/parser';
import { projectSegments } from '../geometry/transform';
export interface AppState {
segments: Segment[];
alignment: Alignment;
warp: PolyWarp | null;
loadGcode(text: string): string[];
setWarp(w: PolyWarp | null): void;
projected(): Segment[];
}
export function createState(): AppState {
const state: AppState = {
segments: [],
alignment: { tx: 0, ty: 0, rot: 0 },
warp: null,
loadGcode(text: string): string[] {
const { segments, warnings } = parseGcode(text);
state.segments = segments;
return warnings;
},
setWarp(w: PolyWarp | null): void {
state.warp = w;
},
projected(): Segment[] {
if (!state.warp) return [];
return projectSegments(state.segments, state.alignment, state.warp);
},
};
return state;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/app/state.test.ts`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/app/state.ts src/app/state.test.ts
git commit -m "refactor: app state stores PolyWarp"
```
---
## Task 5: Migrate config load/validate (`config.ts`)
Validate a `warp` object instead of a 9-element `homography`.
**Files:**
- Modify: `src/config.ts`, `src/types.ts` (Calibration field)
- Test: `src/config.test.ts`
- [ ] **Step 1: Update the Calibration type**
In `src/types.ts`, change the `Calibration` interface (it currently ends with `homography: Mat3;`):
```typescript
export interface Calibration {
/** Calibration points in normalized [0,1] camera-frame coordinates. */
imagePoints: Vec2[];
/** Corresponding machine coordinates, mm. */
machinePoints: Vec2[];
/** Bidirectional polynomial warp (machine-mm ↔ normalized image). */
warp: PolyWarp;
}
```
- [ ] **Step 2: Update the test**
Replace the two homography-specific test cases in `src/config.test.ts`. Replace the `'keeps a valid calibration'` test (lines ~22-31), the `'drops a malformed calibration to null'` test (lines ~33-37), and the `'rejects a calibration whose homography contains NaN'` test (lines ~53-62) with:
```typescript
it('keeps a valid calibration', async () => {
const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][];
const warp = estimatePolyWarp(square, square, 1);
const calibration = { imagePoints: square, machinePoints: square, warp };
mockFetch({ streamUrl: 'x', calibration });
const cfg = await loadConfig('config.json');
expect(cfg.calibration).toEqual(calibration);
});
it('drops a malformed calibration to null', async () => {
mockFetch({ streamUrl: 'x', calibration: { warp: { degree: 2 } } });
const cfg = await loadConfig('config.json');
expect(cfg.calibration).toBeNull();
});
it('rejects a calibration whose warp coefficients contain NaN', async () => {
const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][];
const warp = estimatePolyWarp(square, square, 1);
warp.fwd.cu[0] = NaN;
const calibration = { imagePoints: square, machinePoints: square, warp };
mockFetch({ streamUrl: 'x', calibration });
const cfg = await loadConfig('config.json');
expect(cfg.calibration).toBeNull();
});
```
Add this import near the top of `src/config.test.ts` (after the existing imports):
```typescript
import { estimatePolyWarp } from './geometry/polywarp';
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/config.test.ts`
Expected: FAIL — `config.ts` still validates `homography`, so the valid-warp calibration is dropped to `null`.
- [ ] **Step 4: Update the implementation**
In `src/config.ts`, update the import line and replace `parseCalibration`. First change the type import:
```typescript
import type { AppConfig, Calibration, PolyMap, PolyWarp, Vec2, RenderStyle } from './types';
```
Then replace the `parseCalibration` function (lines ~13-25) with:
```typescript
function isNumArray(v: unknown, len: number): v is number[] {
return Array.isArray(v) && v.length === len && v.every(isFiniteNumber);
}
function isVec2(v: unknown): v is Vec2 {
return Array.isArray(v) && v.length === 2 && v.every(isFiniteNumber);
}
function termCount(degree: number): number {
return ((degree + 1) * (degree + 2)) / 2;
}
function parsePolyMap(m: unknown, degree: number): PolyMap | null {
if (!m || typeof m !== 'object') return null;
const o = m as Record<string, unknown>;
const norm = o.norm as Record<string, unknown> | undefined;
if (!norm || !isVec2(norm.off) || !isVec2(norm.scl)) return null;
const n = termCount(degree);
if (!isNumArray(o.cu, n) || !isNumArray(o.cv, n)) return null;
return { degree, norm: { off: norm.off as Vec2, scl: norm.scl as Vec2 }, cu: o.cu as number[], cv: o.cv as number[] };
}
function parseWarp(w: unknown): PolyWarp | null {
if (!w || typeof w !== 'object') return null;
const o = w as Record<string, unknown>;
if (!isFiniteNumber(o.degree)) return null;
const fwd = parsePolyMap(o.fwd, o.degree);
const inv = parsePolyMap(o.inv, o.degree);
if (!fwd || !inv) return null;
return { degree: o.degree, fwd, inv };
}
function parseCalibration(c: unknown): Calibration | null {
if (!c || typeof c !== 'object') return null;
const obj = c as Record<string, unknown>;
if (!isVec2Array(obj.imagePoints) || !isVec2Array(obj.machinePoints)) return null;
if (obj.imagePoints.length !== obj.machinePoints.length || obj.imagePoints.length < 4) return null;
const warp = parseWarp(obj.warp);
if (!warp) return null;
return { imagePoints: obj.imagePoints, machinePoints: obj.machinePoints, warp };
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/config.test.ts`
Expected: PASS (all cases).
- [ ] **Step 6: Commit**
```bash
git add src/config.ts src/config.test.ts src/types.ts
git commit -m "refactor: config validates PolyWarp calibration"
```
---
## Task 6: Migrate the calibration UI (`calibration-ui.ts`)
Compute a `PolyWarp` (with a degree selector), update the residual readout and JSON output.
**Files:**
- Modify: `src/app/calibration-ui.ts`
- Test: `src/app/calibration-ui.test.ts`
- [ ] **Step 1: Update the test**
Replace the entire contents of `src/app/calibration-ui.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { residuals } from './calibration-ui';
import { estimatePolyWarp } from '../geometry/polywarp';
import type { Vec2 } from '../types';
describe('calibration residuals', () => {
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[] = 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], [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);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/app/calibration-ui.test.ts`
Expected: FAIL — `residuals` still takes a `Mat3` and calls `applyHomography`.
- [ ] **Step 3: Update the implementation**
Replace the entire contents of `src/app/calibration-ui.ts`:
```typescript
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]);
});
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/app/calibration-ui.test.ts`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/app/calibration-ui.ts src/app/calibration-ui.test.ts
git commit -m "feat: calibration UI computes PolyWarp with degree selector"
```
---
## Task 7: Migrate alignment UI + main wiring
Update the two remaining consumers of the old API.
**Files:**
- Modify: `src/app/alignment-ui.ts`, `src/main.ts`
- Test: `src/app/overlay-interaction.test.ts` (read first; update any `getHomography`/`setHomography`/`Mat3` references)
- [ ] **Step 1: Inspect the interaction test for old-API references**
Run: `grep -n "Homography\|homography\|Mat3\|setWarp\|getWarp" src/app/overlay-interaction.test.ts`
For each match, update it to the new API: `getHomography` dep → `getWarp`, `Mat3` identity → a `PolyWarp` built with `estimatePolyWarp(square, square, 1)` (import from `../geometry/polywarp`), where `const square: Vec2[] = [[0,0],[10,0],[10,10],[0,10]]`. If there are no matches, this test needs no change — proceed.
- [ ] **Step 2: Run the interaction test to see the current state**
Run: `npx vitest run src/app/overlay-interaction.test.ts`
Expected: After Step 1 edits (if any), it should FAIL only because `alignment-ui.ts` still exposes `getHomography`. If it already passes (test didn't reference the renamed dep), that's fine — proceed to Step 3.
- [ ] **Step 3: Update `alignment-ui.ts`**
In `src/app/alignment-ui.ts`, change the import (line 1) and the `AlignmentDeps` interface, then rename the local `H` usages. Apply these edits:
Change line 1:
```typescript
import type { Vec2, PolyWarp, Alignment } from '../types';
```
In `AlignmentDeps`, replace `getHomography: () => Mat3 | null;` with:
```typescript
getWarp: () => PolyWarp | null;
```
Replace every `const H = deps.getHomography();` with `const warp = deps.getWarp();`, every `if (!H)` / `if (!H || ...)` guard's `H` with `warp`, and every `imageToMachine(H, ...)` with `imageToMachine(warp, ...)`. There are three `getHomography` call sites (mousedown, mousemove, plus the guard in mousemove) — update all. `imageToMachine` itself is already `PolyWarp`-typed from Task 3, so only the variable name and dep call change.
- [ ] **Step 4: Update `main.ts`**
In `src/main.ts`, apply these edits:
Replace the `mountCalibration({...})` call (lines ~51-59):
```typescript
mountCalibration({
panel: document.getElementById('calib-panel') as HTMLElement,
overlay,
onWarp: (w) => {
state.setWarp(w);
statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
render();
},
});
```
In the `mountAlignment({...})` call, replace `getHomography: () => state.homography,` with:
```typescript
getWarp: () => state.warp,
```
In `boot()`, replace `if (cfg.calibration) state.setHomography(cfg.calibration.homography);` with:
```typescript
if (cfg.calibration) state.setWarp(cfg.calibration.warp);
```
- [ ] **Step 5: Run the interaction test to verify it passes**
Run: `npx vitest run src/app/overlay-interaction.test.ts`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/app/alignment-ui.ts src/main.ts src/app/overlay-interaction.test.ts
git commit -m "refactor: alignment UI and main wiring use PolyWarp"
```
---
## Task 8: Retire the homography module and verify the whole build
Delete the now-unused homography code and confirm the project type-checks and all tests pass.
**Files:**
- Delete: `src/geometry/homography.ts`, `src/geometry/homography.test.ts`
- [ ] **Step 1: Confirm there are no remaining importers**
Run: `grep -rn "from './homography'\|from '../geometry/homography'\|geometry/homography" src/`
Expected: only matches inside `homography.ts`/`homography.test.ts` themselves (which we delete) and possibly the `polywarp.test.ts` import — see Step 2.
- [ ] **Step 2: Drop the homography import from the polywarp test**
`src/geometry/polywarp.test.ts` (Task 2) imports `estimateHomography, applyHomography` for the "beats a homography" test. Since `homography.ts` is being deleted, **inline a minimal homography** in that test instead. At the top of `polywarp.test.ts`, remove the line:
```typescript
import { estimateHomography, applyHomography } from './homography';
```
and replace the `'beats a homography on barrel-distorted data'` test body's homography use with a self-contained least-squares affine baseline (an affine map is the best a no-distortion linear model can do, and like a homography it cannot represent barrel curvature — the comparison still demonstrates the polynomial's advantage). Replace that whole test with:
```typescript
it('beats an affine/linear baseline on barrel-distorted data', () => {
// Barrel distortion is degree-3 in machine coords, so degree 3 fits it essentially
// exactly while a degree-1 (affine, no curvature) baseline cannot.
const image = machineGrid.map(trueMap);
const w = estimatePolyWarp(machineGrid, image, 3);
const baseline = estimatePolyWarp(machineGrid, image, 1); // degree-1 = affine, no curvature
const test: Vec2[] = [[488, 244], [1464, 732], [1952, 488], [976, 976]];
const polyMax = Math.max(...test.map((m) => dist(applyPolyWarp(w, m), trueMap(m))));
const baseMax = Math.max(...test.map((m) => dist(applyPolyWarp(baseline, m), trueMap(m))));
expect(baseMax).toBeGreaterThan(1e-3); // affine baseline genuinely fails on barrel data
expect(polyMax).toBeLessThan(1e-6); // degree-3 fit of cubic data is near-exact everywhere
});
```
- [ ] **Step 3: Delete the homography files**
```bash
git rm src/geometry/homography.ts src/geometry/homography.test.ts
```
- [ ] **Step 4: Run the full test suite**
Run: `npm test`
Expected: PASS — all suites green, no reference to the deleted module.
- [ ] **Step 5: Type-check + build (the cross-file gate)**
Run: `npm run build`
Expected: `tsc --noEmit` passes with no errors, Vite build succeeds. If `tsc` flags an unused `Mat3` import anywhere, remove that import. (The `Mat3` *type* may remain defined in `types.ts` unused — that is not a compile error.)
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "refactor: retire homography model in favour of PolyWarp"
```
---
## Task 9: Regenerate the default calibration
The committed `dist/config.json` still holds the old homography, which `parseCalibration` now rejects (→ "Not calibrated"). This is expected per the spec's migration note. The real recalibration happens on the machine with the box+# target, but verify the app boots cleanly without a valid calibration.
**Files:**
- Inspect: `dist/config.json`, `public/config.json` (if present)
- [ ] **Step 1: Check which config files carry a homography**
Run: `grep -rln "homography" dist public 2>/dev/null`
- [ ] **Step 2: Confirm graceful handling**
The app already shows "Not calibrated — calibrate first." when `cfg.calibration` is `null` (`main.ts` boot). No code change needed — the stale homography config simply parses to `null`. Leave the file as-is OR, to avoid a confusing dead field, remove the `homography`/`calibration` block from `dist/config.json` and `public/config.json` so the file is clean. Do whichever the repo convention prefers; if unsure, leave it (harmless).
- [ ] **Step 3: Capture the real calibration on the machine (manual, runtime)**
This is the on-hardware step, not a code change:
1. Draw the box + "#" target (lines at thirds) by jog-and-mark: jog the spindle to each of the 16 targets (every combination of `X ∈ {0, 813.3, 1626.7, 2440}`, `Y ∈ {0, 406.7, 813.3, 1220}`) and mark at the tip.
2. In the app: for each mark, enter its X/Y, click "Click point in video", click the mark. Repeat for all 16.
3. Leave degree at 2, click "Compute warp". Check the per-point `err …px` readout — if max error is large, switch degree to 3 and recompute.
4. Copy the JSON from the textarea into `dist/config.json` under `calibration`, redeploy per `memory/deployment-mf01.md`.
- [ ] **Step 4: Commit any config cleanup**
```bash
git add dist/config.json public/config.json 2>/dev/null
git commit -m "chore: clear stale homography calibration from config" || echo "nothing to commit"
```
---
## Self-review notes
- **Spec coverage:** model (T2), forward+inverse maps (T2/T3), normalization/conditioning (T2), linalg extraction (T1), transform/state/config/calib/alignment/main migration (T3T7), homography retirement (T8), 16-point box+# target & jog-and-mark + degree escalation (T6 UI + T9 procedure), migration of stale config (T9), tests incl. synthetic-barrel regression guard (T2). All spec sections map to a task.
- **Inverse independence caveat** (from spec/user review) is realized by fitting `inv` separately in `estimatePolyWarp`; the round-trip test uses a 2 mm tolerance accordingly rather than asserting exact inversion.
- **Type consistency:** `PolyWarp`/`PolyMap` shapes (`degree`, `norm.off`, `norm.scl`, `cu`, `cv`) are identical across `types.ts`, `polywarp.ts`, and `config.ts` validation. Renames are consistent: `setHomography``setWarp`, `getHomography``getWarp`, `onHomography``onWarp`, `homography``warp`.