38 KiB
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/Mat3API mid-migration. The finaltsc --noEmit(vianpm 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:
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:
/** 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
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(addPolyMap,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):
/** 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:
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:
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
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:
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:
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
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:
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:
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
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;):
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:
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):
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:
import type { AppConfig, Calibration, PolyMap, PolyWarp, Vec2, RenderStyle } from './types';
Then replace the parseCalibration function (lines ~13-25) with:
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
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:
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:
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
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 anygetHomography/setHomography/Mat3references) -
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:
import type { Vec2, PolyWarp, Alignment } from '../types';
In AlignmentDeps, replace getHomography: () => Mat3 | null; with:
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):
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:
getWarp: () => state.warp,
In boot(), replace if (cfg.calibration) state.setHomography(cfg.calibration.homography); with:
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
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:
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:
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(polyMax).toBeLessThan(baseMax * 0.05);
});
- Step 3: Delete the homography files
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
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:
- 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. - In the app: for each mark, enter its X/Y, click "Click point in video", click the mark. Repeat for all 16.
- Leave degree at 2, click "Compute warp". Check the per-point
err …pxreadout — if max error is large, switch degree to 3 and recompute. - Copy the JSON from the textarea into
dist/config.jsonundercalibration, redeploy permemory/deployment-mf01.md.
- Step 4: Commit any config cleanup
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 (T3–T7), 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
invseparately inestimatePolyWarp; the round-trip test uses a 2 mm tolerance accordingly rather than asserting exact inversion. - Type consistency:
PolyWarp/PolyMapshapes (degree,norm.off,norm.scl,cu,cv) are identical acrosstypes.ts,polywarp.ts, andconfig.tsvalidation. Renames are consistent:setHomography→setWarp,getHomography→getWarp,onHomography→onWarp,homography→warp.