Compare commits

...

17 commits

Author SHA1 Message Date
sjat
3c47b28c95 chore: remove orphaned Mat3 type; refresh README for polynomial warp 2026-06-11 09:41:28 +02:00
sjat
7b06902984 chore: clear stale homography calibration (awaiting box+# polynomial recalibration) 2026-06-11 09:38:51 +02:00
sjat
885f94098c fix: declare process global so tsc build gate passes (pre-existing) 2026-06-11 09:37:44 +02:00
sjat
83f4c3eb7e refactor: retire homography model in favour of PolyWarp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:35:51 +02:00
sjat
31577787f1 docs: sync Task 8 test rewrite to hard-bound assertions 2026-06-11 09:34:46 +02:00
sjat
7614590b03 refactor: alignment UI and main wiring use PolyWarp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:32:54 +02:00
sjat
103e96f097 feat: calibration UI computes PolyWarp with degree selector 2026-06-11 09:29:24 +02:00
sjat
f3081d36b5 fix: reject warp configs with non-positive-integer degree 2026-06-11 09:28:04 +02:00
sjat
3e9878ac42 refactor: config validates PolyWarp calibration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:25:47 +02:00
sjat
0130188416 refactor: app state stores PolyWarp 2026-06-11 09:23:42 +02:00
sjat
dc59f5ed63 refactor: transform layer uses PolyWarp 2026-06-11 09:22:02 +02:00
sjat
fcfa3a0d80 test: tighten barrel regression guard to a hard near-exact bound 2026-06-11 09:21:04 +02:00
sjat
2c2bbb17b9 docs: correct default degree to 3 (barrel distortion is cubic in machine coords) 2026-06-11 09:17:15 +02:00
sjat
c7b48105a6 feat: bivariate polynomial warp model (degree-3 default for barrel) 2026-06-11 09:16:02 +02:00
sjat
72d32db516 feat: extract linalg solver + add least-squares helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:09:05 +02:00
sjat
04fc642137 docs: add box+# calibration target procedure to distortion spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:51:48 +02:00
sjat
a94beccaad docs: design for wide-angle distortion compensation (polynomial warp)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:46:11 +02:00
23 changed files with 1593 additions and 239 deletions

View file

@ -22,10 +22,10 @@ Serve the built `dist/` as static files. Configure via `public/config.json` (bun
## Use ## Use
1. Open a local G-code file with the **Open G-code** button. 1. Open a local G-code file with the **Open G-code** button.
2. **Calibrate** (one-time, persisted in `config.json`): jog the CNC spindle to a known X/Y, enter those coordinates, then click the spindle tip in the video. Repeat for at least 4 well-spread points (near the table corners gives the best accuracy). Click **Compute homography** — the per-point error (px) flags any misclick. Paste the generated JSON into `public/config.json`. 2. **Calibrate** (one-time, persisted in `config.json`): jog the CNC spindle to a known X/Y, enter those coordinates, then click the spindle tip in the video. Repeat for **at least 10 well-spread points** including the table corners (distortion is worst there). A good target is a "box + #" — lines at the thirds of each axis — giving 16 points (12 on the perimeter + 4 interior crossings); jog-and-mark each so the coordinate is exact. Pick the polynomial **Degree** (default **3**, which compensates wide-angle barrel distortion; drop to 2 for sparse calibration), click **Compute warp** — the per-point error (px) flags any misclick — and paste the generated JSON into `public/config.json`.
3. **Align** the toolpath to the material: drag it to move, Shift-drag to rotate, use **Set origin by clicking video**, or type a numeric X/Y/rotation. Then reality-check the overlay against the live cut. 3. **Align** the toolpath to the material: drag it to move, Shift-drag to rotate, use **Set origin by clicking video**, or type a numeric X/Y/rotation. Then reality-check the overlay against the live cut.
## Notes ## Notes
- A perspective (homography) transform assumes straight cuts stay straight. If a wide-angle camera bows straight lines, the calibration per-point error will reveal it; lens-distortion correction is a possible future addition. - Calibration uses a **bivariate polynomial warp** (machine-mm ↔ normalized image), not a plain perspective homography. A homography keeps straight lines straight, so it cannot follow the curvature a wide-angle lens introduces; the polynomial's cubic terms model that barrel distortion (which is a degree-3 effect on position), so the overlay tracks the bowed bed edges instead of drifting at the corners. The forward map draws the overlay; an independently-fit inverse map powers click-to-set-origin and dragging.
- Supported G-code: G0/G1/G2/G3 motion, G90/G91, G20/G21 units, arcs via I/J or R. Cutting moves are drawn solid, rapids dashed. - Supported G-code: G0/G1/G2/G3 motion, G90/G91, G20/G21 units, arcs via I/J or R. Cutting moves are drawn solid, rapids dashed.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,164 @@
# Wide-Angle Distortion Compensation — Design
**Date:** 2026-06-11
**Status:** Approved, pending implementation plan
## Problem
The overlay aligns the projected G-code to the live CNC camera feed using a single
**homography** (8-DoF perspective transform, `src/geometry/homography.ts`). A homography
maps straight lines to straight lines. The wide-angle camera introduces **barrel (radial)
distortion**: straight machine paths near the bed edges bow outward in the image. No
homography can represent that, so even with perfect corner calibration the overlay drifts
in the middle of each edge and toward the corners.
This is a **model** limitation, not a calibration-points problem.
## Constraints / context
- Camera is **permanently fixed** relative to the bed → calibration is one-time and durable.
- Correction strategy: **bend the overlay** to match the raw (curved) video. The camera
image is left untouched (no per-frame image rectification).
- Calibration budget: **~1216 points** jogged to known machine X/Y across the bed,
including near the corners (where distortion is worst). See *Calibration target* below.
- Keep the existing overlay pipeline shape: `machine-mm → image-point` for drawing, and
`image-point → machine-mm` for click-to-set-origin / drag-to-rotate.
## Chosen approach: bivariate polynomial warp
Replace the homography with a **bivariate polynomial warp** (`PolyWarp`) that maps machine
coordinates directly to normalized image coordinates. The cubic terms (`x³, x²y, xy², y³`)
are what capture the symmetric radial barrel distortion (see the degree correction below);
the lower-order terms absorb the bed's perspective trapezoid. Because the map is **linear in
its coefficients**, it is fit with the same least-squares machinery the homography already
used.
Considered and rejected for now:
- **Physical radial lens model + homography (BrownConrady):** physically correct and
reusable, leaves the door open to rectifying the video later, but needs a nonlinear
(GaussNewton) solver — more code than the problem currently warrants.
- **Thin-plate spline / mesh warp:** highest accuracy but wants many control points
(2040) and more code; overkill for the ~812-point budget.
Fallback path: with sparse calibration (<10 points) drop to degree 2; if residuals stay
high after degree 3, graduate to the radial model. The polynomial is the smallest change
that actually fixes the observed drift.
> **Correction (validated during implementation):** barrel distortion multiplies the
> coordinate by `(1 + k·r²)`, so as a function of machine coordinates it is **degree 3**
> (`dx·r²` expands to `dx³ + dx·dy²`), not degree 2. A degree-2 polynomial therefore does
> *not* beat a homography on real radial distortion — only degree 3 does. Hence degree 3 is
> the default below, not an escalation.
### The model
`PolyWarp` stores coefficients for **two independently-fit maps** over the same calibration
point pairs:
- **Forward** `machine(x,y) → image(u,v)` — draws the overlay.
- **Inverse** `image(u,v) → machine(x,y)` — feeds click-to-set-origin and drag-to-rotate.
A polynomial has no closed-form inverse, so rather than invert numerically we fit the
reverse direction directly from the same points. The inverse map only serves UI clicks, so
its few-pixel / sub-mm accuracy is sufficient; forward and inverse need not be exact mutual
inverses.
Default **degree 3** (10 coefficients per axis, needs ≥10 points) — the degree that
actually compensates barrel distortion; the 16-point box+# target supports it comfortably.
**Degree 2** (6 coefficients per axis, needs ≥6 points) is a manual fallback for sparse
calibration. The calibration UI exposes a degree selector defaulting to 3.
Image coordinates remain **normalized [0,1]** camera-frame fractions, as today — keeps
calibration independent of display/canvas size.
## Components
| File | Change |
|------|--------|
| `src/geometry/linalg.ts` | **New.** Extract `solveLinear` (Gaussian elimination w/ partial pivoting) from `homography.ts` so both the warp fit and any future model share it. |
| `src/geometry/polywarp.ts` | **New.** `PolyWarp` type; `estimatePolyWarp(machine, image, degree)` — builds the polynomial design matrix, least-squares-fits forward + inverse; `applyPolyWarp(w, p)`, `applyPolyWarpInverse(w, p)`. |
| `src/geometry/transform.ts` | `projectSegments(...)` and `imageToMachine(...)` take a `PolyWarp` instead of `Mat3`. |
| `src/app/state.ts` | `homography: Mat3 \| null``warp: PolyWarp \| null`; `setHomography``setWarp`. |
| `src/main.ts` | Wire `setWarp` / `getWarp`; load `cfg.calibration.warp`. |
| `src/app/calibration-ui.ts` | Call `estimatePolyWarp`; keep per-point residual (px) readout as the degree-sufficiency signal; update emitted JSON. |
| `src/app/alignment-ui.ts` | `getWarp(): PolyWarp \| null`. |
| `src/types.ts` | `Calibration.homography: Mat3``Calibration.warp: PolyWarp`. |
| `src/config.ts` | Validate `warp` (degree + forward/inverse coefficient arrays) instead of a 9-element homography. |
| `src/geometry/homography.ts` | **Retired** (fully replaced). |
## Data flow
```
G-code (mm)
→ applyAlignment (per-job rotate/translate, mm)
→ applyPolyWarp forward (mm → normalized image)
→ scale to canvas px → draw overlay
video click (px)
→ normalize [0,1]
→ applyPolyWarpInverse (normalized image → mm)
→ set work origin / rotation
```
## Calibration target (practical procedure)
A **box + "#"** drawn on the bed: lines at the thirds of each axis, i.e.
`x ∈ {0, ⅓, ⅔, 1}` and `y ∈ {0, ⅓, ⅔, 1}`. Their 16 intersections are the calibration
points — **12 on the perimeter** (4 corners + 2 per side) plus **4 interior crossings** at
the centre of the "#". Click all 16 in the feed.
The 4 interior points matter: a polynomial constrained only on the boundary can wiggle in
the interior, so the centre crossings pin the fit where edge points can't.
**Capture by jog-and-mark, not ruler-and-guess:** jog the spindle to each target
coordinate and mark at the tip, so the commanded machine coordinate is exact and the mark
sits exactly under the tip. Then click each mark in the video.
For the 2440×1220 bed the 16 targets are every combination of:
```
X: 0 813.3 1626.7 2440
Y: 0 406.7 813.3 1220
```
Interior four: (813.3, 406.7), (1626.7, 406.7), (813.3, 813.3), (1626.7, 813.3).
This 16-point spread also clears the degree-3 minimum, so escalating from degree 2 to 3
needs no extra capture.
## Migration
The committed default calibration in `dist/config.json` is a homography and **will not
carry over** — a homography is not a polynomial. Recalibrate once with ~812 spread points
(including corners). The `Calibration` object keeps `imagePoints` / `machinePoints` and
replaces `homography` with `warp`. One-time effort; the fixed camera makes it durable.
## Error handling
- `estimatePolyWarp` throws on fewer points than the degree requires, or a singular system
(e.g. collinear / degenerate point layout) — surfaced in the calibration panel like the
current "Need at least 4 points" message, adjusted to the degree's minimum.
- `config.ts` validation rejects malformed `warp` (returns `null` calibration), same
pattern as today's homography validation.
- Per-point residual (px) shown after Compute so a bad fit (mis-click, too-low degree) is
visible immediately.
## Testing
- **`src/geometry/polywarp.test.ts`** (new):
- Round-trip: `applyPolyWarpInverse(w, applyPolyWarp(w, p)) ≈ p` within tolerance.
- Fit a **synthetic barrel-distorted grid** (known radial coefficient) and assert
sub-pixel residuals — the case a homography provably fails; regression guard for the
whole feature.
- Too-few-points and degenerate-layout throw.
- **`src/geometry/linalg.test.ts`** (new or moved): `solveLinear` correctness, singular
throws.
- Update `transform.test.ts`, `calibration-ui.test.ts`, `state.test.ts` for `PolyWarp`.
- Retire `homography.test.ts`.
## Out of scope (YAGNI)
- Per-frame video rectification (we bend the overlay instead).
- Physical lens model / nonlinear solver.
- Thin-plate spline / many-point calibration.
- Backward-compat reading of legacy homography configs (one-time recalibration instead).

View file

@ -1,23 +1,5 @@
{ {
"streamUrl": "/camera/mjpg/video.mjpg", "streamUrl": "/camera/mjpg/video.mjpg",
"calibration": { "calibration": null,
"imagePoints": [
[0.3182042321463028, 0.008781558726673985],
[0.6584910919108977, 0.010976948408342482],
[0.9349741654696311, 0.9341383095499451],
[0.05201209184657934, 0.9407244785949506]
],
"machinePoints": [
[0, 0],
[0, 1220],
[2440, 1220],
[2440, 0]
],
"homography": [
-0.00012223053029634107, 0.00028168511954120923, 0.3182042321463022,
0.00014436860873395254, 0.0000018455329325253353, 0.008781558726673699,
-0.0002525449441254382, 0.000004193623923514485, 1
]
},
"renderDefaults": { "cutColor": "#00ffff", "rapidColor": "#ff7700", "lineWidth": 3 } "renderDefaults": { "cutColor": "#00ffff", "rapidColor": "#ff7700", "lineWidth": 3 }
} }

View file

@ -1,11 +1,11 @@
import type { Vec2, Mat3, Alignment } from '../types'; import type { Vec2, PolyWarp, Alignment } from '../types';
import { imageToMachine, alignmentFromOrigin } from '../geometry/transform'; import { imageToMachine, alignmentFromOrigin } from '../geometry/transform';
export interface AlignmentDeps { export interface AlignmentDeps {
panel: HTMLElement; panel: HTMLElement;
overlay: HTMLCanvasElement; overlay: HTMLCanvasElement;
getAlignment: () => Alignment; getAlignment: () => Alignment;
getHomography: () => Mat3 | null; getWarp: () => PolyWarp | null;
onChange: () => void; onChange: () => void;
} }
@ -61,13 +61,13 @@ export function mountAlignment(deps: AlignmentDeps): void {
let lastMachine: Vec2 | null = null; let lastMachine: Vec2 | null = null;
overlay.addEventListener('mousedown', (ev) => { overlay.addEventListener('mousedown', (ev) => {
const H = deps.getHomography(); const warp = deps.getWarp();
if (!H) return; if (!warp) return;
// Another module (e.g. calibration) has armed the overlay for this click; don't hijack it. // Another module (e.g. calibration) has armed the overlay for this click; don't hijack it.
if (!armOrigin && overlay.classList.contains('interactive')) return; if (!armOrigin && overlay.classList.contains('interactive')) return;
if (armOrigin) { if (armOrigin) {
const a = deps.getAlignment(); const a = deps.getAlignment();
const m = imageToMachine(H, normFromEvent(overlay, ev)); const m = imageToMachine(warp, normFromEvent(overlay, ev));
Object.assign(a, alignmentFromOrigin(m, a.rot)); Object.assign(a, alignmentFromOrigin(m, a.rot));
armOrigin = false; armOrigin = false;
overlay.classList.remove('interactive'); overlay.classList.remove('interactive');
@ -77,15 +77,15 @@ export function mountAlignment(deps: AlignmentDeps): void {
} }
dragging = true; dragging = true;
overlay.classList.add('interactive'); overlay.classList.add('interactive');
lastMachine = imageToMachine(H, normFromEvent(overlay, ev)); lastMachine = imageToMachine(warp, normFromEvent(overlay, ev));
}); });
window.addEventListener('mousemove', (ev) => { window.addEventListener('mousemove', (ev) => {
if (!dragging) return; if (!dragging) return;
const H = deps.getHomography(); const warp = deps.getWarp();
if (!H || !lastMachine) return; if (!warp || !lastMachine) return;
const a = deps.getAlignment(); const a = deps.getAlignment();
const cur = imageToMachine(H, normFromEvent(overlay, ev)); const cur = imageToMachine(warp, normFromEvent(overlay, ev));
if (ev.shiftKey) { if (ev.shiftKey) {
// rotate about the work origin's current machine position (tx,ty) // rotate about the work origin's current machine position (tx,ty)
const angle = (p: Vec2) => Math.atan2(p[1] - a.ty, p[0] - a.tx); const angle = (p: Vec2) => Math.atan2(p[1] - a.ty, p[0] - a.tx);

View file

@ -1,22 +1,22 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { residuals } from './calibration-ui'; import { residuals } from './calibration-ui';
import { estimateHomography } from '../geometry/homography'; import { estimatePolyWarp } from '../geometry/polywarp';
import type { Vec2 } from '../types'; import type { Vec2 } from '../types';
describe('calibration residuals', () => { describe('calibration residuals', () => {
it('reports near-zero error for a clean fit', () => { it('reports near-zero error for a clean degree-1 fit', () => {
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100]]; const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100]];
const image: Vec2[] = [[10, 10], [210, 12], [208, 210], [8, 208]]; const image: Vec2[] = machine.map((p): Vec2 => [p[0] * 0.5 + 5, p[1] * 0.5 + 5]);
const H = estimateHomography(machine, image); const w = estimatePolyWarp(machine, image, 1);
const errs = residuals(H, machine, image); const errs = residuals(w, machine, image);
expect(Math.max(...errs)).toBeLessThan(1e-6); expect(Math.max(...errs)).toBeLessThan(1e-4);
}); });
it('flags a mismatched point with a large residual', () => { it('flags a mismatched point with a large residual', () => {
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50]]; const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50], [25, 75], [75, 25]];
const image: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [80, 20]]; // last is wrong const image: Vec2[] = [[0, 0], [50, 0], [50, 50], [0, 50], [25, 25], [12.5, 37.5], [90, 5]]; // last is wrong
const H = estimateHomography(machine, image); const w = estimatePolyWarp(machine, image, 1);
const errs = residuals(H, machine, image); const errs = residuals(w, machine, image);
expect(errs[4]!).toBeGreaterThan(5); expect(errs[6]!).toBeGreaterThan(5);
}); });
}); });

View file

@ -1,11 +1,11 @@
import type { Vec2, Mat3 } from '../types'; import type { Vec2, PolyWarp } from '../types';
import { estimateHomography, applyHomography } from '../geometry/homography'; import { estimatePolyWarp, applyPolyWarp } from '../geometry/polywarp';
/** Per-point reprojection error in pixels: |H·machine image|. */ /** Per-point reprojection error in normalized units: |warp(machine) image|. */
export function residuals(H: Mat3, machine: Vec2[], image: Vec2[]): number[] { export function residuals(warp: PolyWarp, machine: Vec2[], image: Vec2[]): number[] {
if (machine.length !== image.length) throw new Error('residuals: machine/image length mismatch'); if (machine.length !== image.length) throw new Error('residuals: machine/image length mismatch');
return machine.map((m, i) => { return machine.map((m, i) => {
const p = applyHomography(H, m); const p = applyPolyWarp(warp, m);
return Math.hypot(p[0] - image[i]![0], p[1] - image[i]![1]); return Math.hypot(p[0] - image[i]![0], p[1] - image[i]![1]);
}); });
} }
@ -18,16 +18,15 @@ interface CalibPoint {
export interface CalibrationDeps { export interface CalibrationDeps {
panel: HTMLElement; panel: HTMLElement;
overlay: HTMLCanvasElement; overlay: HTMLCanvasElement;
onHomography: (H: Mat3, points: CalibPoint[]) => void; onWarp: (w: PolyWarp, points: CalibPoint[]) => void;
} }
/** /**
* Renders the calibration panel. Flow: enter machine X/Y click "Click point in video" * Renders the calibration panel. Flow: enter machine X/Y click "Click point in video"
* click the spindle tip in the video repeat 4× "Compute". * click the spindle tip in the video repeat 10× choose degree "Compute".
* *
* Captured image points and the resulting homography are in normalized [0,1] camera-frame * Image points and the resulting warp are in normalized [0,1] camera-frame coordinates
* coordinates (machine-mm normalized image), so they are display-size independent. * (machine-mm normalized image), so they are display-size independent.
* The JSON output structure stays the same (imagePoints now hold normalized values).
*/ */
export function mountCalibration(deps: CalibrationDeps): void { export function mountCalibration(deps: CalibrationDeps): void {
const { panel, overlay } = deps; const { panel, overlay } = deps;
@ -36,7 +35,7 @@ export function mountCalibration(deps: CalibrationDeps): void {
panel.innerHTML = ` panel.innerHTML = `
<h2>Calibration</h2> <h2>Calibration</h2>
<div class="row"><small>Jog the spindle to a known X/Y, enter it, then click the tip in the video.</small></div> <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"> <div class="row">
X <input id="cal-x" type="number" step="0.1" /> X <input id="cal-x" type="number" step="0.1" />
Y <input id="cal-y" type="number" step="0.1" /> Y <input id="cal-y" type="number" step="0.1" />
@ -44,7 +43,8 @@ export function mountCalibration(deps: CalibrationDeps): void {
</div> </div>
<ul id="cal-list"></ul> <ul id="cal-list"></ul>
<div class="row"> <div class="row">
<button id="cal-compute">Compute homography</button> 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> <button id="cal-clear">Clear</button>
</div> </div>
<textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea> <textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea>
@ -54,6 +54,7 @@ export function mountCalibration(deps: CalibrationDeps): void {
const yEl = panel.querySelector('#cal-y') as HTMLInputElement; const yEl = panel.querySelector('#cal-y') as HTMLInputElement;
const armBtn = panel.querySelector('#cal-arm') as HTMLButtonElement; const armBtn = panel.querySelector('#cal-arm') as HTMLButtonElement;
const list = panel.querySelector('#cal-list') as HTMLUListElement; 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 jsonEl = panel.querySelector('#cal-json') as HTMLTextAreaElement;
const renderList = (errs?: number[]) => { const renderList = (errs?: number[]) => {
@ -74,8 +75,6 @@ export function mountCalibration(deps: CalibrationDeps): void {
overlay.addEventListener('click', (ev) => { overlay.addEventListener('click', (ev) => {
if (!pendingMachine || !overlay.classList.contains('interactive')) return; if (!pendingMachine || !overlay.classList.contains('interactive')) return;
const rect = overlay.getBoundingClientRect(); const rect = overlay.getBoundingClientRect();
// Store normalized [0,1] image coords (fraction of the displayed camera frame).
// This makes the calibration independent of display/canvas size.
const px: Vec2 = [ const px: Vec2 = [
(ev.clientX - rect.left) / rect.width, (ev.clientX - rect.left) / rect.width,
(ev.clientY - rect.top) / rect.height, (ev.clientY - rect.top) / rect.height,
@ -93,24 +92,28 @@ export function mountCalibration(deps: CalibrationDeps): void {
}); });
(panel.querySelector('#cal-compute') as HTMLButtonElement).addEventListener('click', () => { (panel.querySelector('#cal-compute') as HTMLButtonElement).addEventListener('click', () => {
if (points.length < 4) { const degree = parseInt(degreeEl.value, 10);
jsonEl.value = 'Need at least 4 points.'; const need = ((degree + 1) * (degree + 2)) / 2;
if (points.length < need) {
jsonEl.value = `Need at least ${need} points for degree ${degree}.`;
return; return;
} }
const machine = points.map((p) => p.machine); const machine = points.map((p) => p.machine);
const image = points.map((p) => p.image); const image = points.map((p) => p.image);
const H = estimateHomography(machine, image); let warp: PolyWarp;
// Per-axis pixel residual (normalized error scaled by canvas width/height) — used only for misclick display. 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 errsPx = machine.map((m, i) => {
const p = applyHomography(H, m); const p = applyPolyWarp(warp, m);
return Math.hypot((p[0] - image[i]![0]) * overlay.width, (p[1] - image[i]![1]) * overlay.height); return Math.hypot((p[0] - image[i]![0]) * overlay.width, (p[1] - image[i]![1]) * overlay.height);
}); });
renderList(errsPx); renderList(errsPx);
jsonEl.value = JSON.stringify( jsonEl.value = JSON.stringify({ imagePoints: image, machinePoints: machine, warp }, null, 2);
{ imagePoints: image, machinePoints: machine, homography: H }, deps.onWarp(warp, [...points]);
null,
2,
);
deps.onHomography(H, [...points]);
}); });
} }

View file

@ -2,11 +2,14 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { mountAlignment } from './alignment-ui'; import { mountAlignment } from './alignment-ui';
import { mountCalibration } from './calibration-ui'; import { mountCalibration } from './calibration-ui';
import type { Mat3, Alignment } from '../types'; import type { Vec2, PolyWarp, Alignment } from '../types';
import { estimatePolyWarp } from '../geometry/polywarp';
// Identity homography: machine coords == normalized coords for this test, so a // Identity warp: machine coords == normalized coords for this test, so a
// normalized click at fraction f over the 100px box maps to machine value f. // normalized click at fraction f over the 100px box maps to machine value f.
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // We use degree 1 fitted on a unit square so the map is a near-perfect identity.
const square: Vec2[] = [[0, 0], [1, 0], [1, 1], [0, 1]];
const IDENT: PolyWarp = estimatePolyWarp(square, square, 1);
function makeOverlay(): HTMLCanvasElement { function makeOverlay(): HTMLCanvasElement {
const c = document.createElement('canvas'); const c = document.createElement('canvas');
@ -39,7 +42,7 @@ describe('overlay interaction', () => {
panel, panel,
overlay, overlay,
getAlignment: () => alignment, getAlignment: () => alignment,
getHomography: () => IDENT, getWarp: () => IDENT,
onChange: () => {}, onChange: () => {},
}); });
overlay.dispatchEvent(mouse('mousedown', 50, 50)); overlay.dispatchEvent(mouse('mousedown', 50, 50));
@ -53,12 +56,12 @@ describe('overlay interaction', () => {
it('while calibration is armed, a click is captured by calibration and does NOT drag the alignment', () => { it('while calibration is armed, a click is captured by calibration and does NOT drag the alignment', () => {
const calibPanel = document.createElement('div'); const calibPanel = document.createElement('div');
const alignPanel = document.createElement('div'); const alignPanel = document.createElement('div');
mountCalibration({ panel: calibPanel, overlay, onHomography: () => {} }); mountCalibration({ panel: calibPanel, overlay, onWarp: () => {} });
mountAlignment({ mountAlignment({
panel: alignPanel, panel: alignPanel,
overlay, overlay,
getAlignment: () => alignment, getAlignment: () => alignment,
getHomography: () => IDENT, getWarp: () => IDENT,
onChange: () => {}, onChange: () => {},
}); });
(calibPanel.querySelector('#cal-x') as HTMLInputElement).value = '10'; (calibPanel.querySelector('#cal-x') as HTMLInputElement).value = '10';

View file

@ -1,8 +1,11 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createState } from './state'; import { createState } from './state';
import type { Mat3 } from '../types'; import { estimatePolyWarp } from '../geometry/polywarp';
import type { Vec2 } from '../types';
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // Identity warp: image == machine.
const square: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
const IDENT = estimatePolyWarp(square, square, 1);
describe('app state', () => { describe('app state', () => {
it('starts with no segments and a zero alignment', () => { it('starts with no segments and a zero alignment', () => {
@ -20,12 +23,12 @@ describe('app state', () => {
it('projected() returns image-space segments when calibrated', () => { it('projected() returns image-space segments when calibrated', () => {
const s = createState(); const s = createState();
s.setHomography(IDENT); s.setWarp(IDENT);
s.loadGcode('G21 G90\nG1 X10 Y0'); s.loadGcode('G21 G90\nG1 X10 Y0');
s.alignment.tx = 2; s.alignment.tx = 2;
const proj = s.projected(); const proj = s.projected();
expect(proj[0]!.points[0]).toEqual([2, 0]); expect(proj[0]!.points[0]![0]).toBeCloseTo(2, 4);
expect(proj[0]!.points[1]).toEqual([12, 0]); expect(proj[0]!.points[1]![0]).toBeCloseTo(12, 4);
}); });
it('projected() returns [] when not calibrated', () => { it('projected() returns [] when not calibrated', () => {

View file

@ -1,13 +1,13 @@
import type { Segment, Alignment, Mat3 } from '../types'; import type { Segment, Alignment, PolyWarp } from '../types';
import { parseGcode } from '../gcode/parser'; import { parseGcode } from '../gcode/parser';
import { projectSegments } from '../geometry/transform'; import { projectSegments } from '../geometry/transform';
export interface AppState { export interface AppState {
segments: Segment[]; segments: Segment[];
alignment: Alignment; alignment: Alignment;
homography: Mat3 | null; warp: PolyWarp | null;
loadGcode(text: string): string[]; loadGcode(text: string): string[];
setHomography(H: Mat3 | null): void; setWarp(w: PolyWarp | null): void;
projected(): Segment[]; projected(): Segment[];
} }
@ -15,18 +15,18 @@ export function createState(): AppState {
const state: AppState = { const state: AppState = {
segments: [], segments: [],
alignment: { tx: 0, ty: 0, rot: 0 }, alignment: { tx: 0, ty: 0, rot: 0 },
homography: null, warp: null,
loadGcode(text: string): string[] { loadGcode(text: string): string[] {
const { segments, warnings } = parseGcode(text); const { segments, warnings } = parseGcode(text);
state.segments = segments; state.segments = segments;
return warnings; return warnings;
}, },
setHomography(H: Mat3 | null): void { setWarp(w: PolyWarp | null): void {
state.homography = H; state.warp = w;
}, },
projected(): Segment[] { projected(): Segment[] {
if (!state.homography) return []; if (!state.warp) return [];
return projectSegments(state.segments, state.alignment, state.homography); return projectSegments(state.segments, state.alignment, state.warp);
}, },
}; };
return state; return state;

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { loadConfig, DEFAULT_RENDER } from './config'; import { loadConfig, DEFAULT_RENDER } from './config';
import { estimatePolyWarp } from './geometry/polywarp';
function mockFetch(body: unknown, ok = true) { function mockFetch(body: unknown, ok = true) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@ -20,18 +21,16 @@ describe('loadConfig', () => {
}); });
it('keeps a valid calibration', async () => { it('keeps a valid calibration', async () => {
const calibration = { const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][];
imagePoints: [[0, 0], [1, 0], [1, 1], [0, 1]], const warp = estimatePolyWarp(square, square, 1);
machinePoints: [[0, 0], [1, 0], [1, 1], [0, 1]], const calibration = { imagePoints: square, machinePoints: square, warp };
homography: [1, 0, 0, 0, 1, 0, 0, 0, 1],
};
mockFetch({ streamUrl: 'x', calibration }); mockFetch({ streamUrl: 'x', calibration });
const cfg = await loadConfig('config.json'); const cfg = await loadConfig('config.json');
expect(cfg.calibration).toEqual(calibration); expect(cfg.calibration).toEqual(calibration);
}); });
it('drops a malformed calibration to null', async () => { it('drops a malformed calibration to null', async () => {
mockFetch({ streamUrl: 'x', calibration: { homography: [1, 2, 3] } }); mockFetch({ streamUrl: 'x', calibration: { warp: { degree: 2 } } });
const cfg = await loadConfig('config.json'); const cfg = await loadConfig('config.json');
expect(cfg.calibration).toBeNull(); expect(cfg.calibration).toBeNull();
}); });
@ -50,14 +49,23 @@ describe('loadConfig', () => {
expect(cfg.renderDefaults).not.toHaveProperty('bogus'); // extra key dropped expect(cfg.renderDefaults).not.toHaveProperty('bogus'); // extra key dropped
}); });
it('rejects a calibration whose homography contains NaN', async () => { it('rejects a calibration whose warp coefficients contain NaN', async () => {
const calibration = { const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][];
imagePoints: [[0, 0], [1, 0], [1, 1], [0, 1]], const warp = estimatePolyWarp(square, square, 1);
machinePoints: [[0, 0], [1, 0], [1, 1], [0, 1]], warp.fwd.cu[0] = NaN;
homography: [1, 0, 0, 0, 1, 0, 0, 0, NaN], const calibration = { imagePoints: square, machinePoints: square, warp };
};
mockFetch({ streamUrl: 'x', calibration }); mockFetch({ streamUrl: 'x', calibration });
const cfg = await loadConfig('config.json'); const cfg = await loadConfig('config.json');
expect(cfg.calibration).toBeNull(); expect(cfg.calibration).toBeNull();
}); });
it('rejects a warp with a non-positive-integer degree', async () => {
const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][];
// A negative degree makes termCount collapse to 0, so empty coeff arrays would
// vacuously validate — guard against that.
const warp = { degree: -1, fwd: { norm: { off: [0, 0], scl: [1, 1] }, cu: [], cv: [] }, inv: { norm: { off: [0, 0], scl: [1, 1] }, cu: [], cv: [] } };
mockFetch({ streamUrl: 'x', calibration: { imagePoints: square, machinePoints: square, warp } });
const cfg = await loadConfig('config.json');
expect(cfg.calibration).toBeNull();
});
}); });

View file

@ -1,4 +1,4 @@
import type { AppConfig, Calibration, Mat3, Vec2, RenderStyle } from './types'; import type { AppConfig, Calibration, PolyMap, PolyWarp, Vec2, RenderStyle } from './types';
export const DEFAULT_RENDER: RenderStyle = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 }; export const DEFAULT_RENDER: RenderStyle = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 };
@ -10,18 +10,48 @@ function isVec2Array(v: unknown): v is Vec2[] {
return Array.isArray(v) && v.every((p) => Array.isArray(p) && p.length === 2 && p.every(isFiniteNumber)); return Array.isArray(v) && v.every((p) => Array.isArray(p) && p.length === 2 && p.every(isFiniteNumber));
} }
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>;
// Require a positive integer degree: a negative/zero/fractional degree makes termCount
// collapse to 0, which would vacuously accept empty coefficient arrays (a garbage warp).
if (!isFiniteNumber(o.degree) || !Number.isInteger(o.degree) || o.degree < 1) 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 { function parseCalibration(c: unknown): Calibration | null {
if (!c || typeof c !== 'object') return null; if (!c || typeof c !== 'object') return null;
const obj = c as Record<string, unknown>; const obj = c as Record<string, unknown>;
if (!isVec2Array(obj.imagePoints) || !isVec2Array(obj.machinePoints)) return null; if (!isVec2Array(obj.imagePoints) || !isVec2Array(obj.machinePoints)) return null;
if (!Array.isArray(obj.homography) || obj.homography.length !== 9) return null;
if (!obj.homography.every(isFiniteNumber)) return null;
if (obj.imagePoints.length !== obj.machinePoints.length || obj.imagePoints.length < 4) return null; if (obj.imagePoints.length !== obj.machinePoints.length || obj.imagePoints.length < 4) return null;
return { const warp = parseWarp(obj.warp);
imagePoints: obj.imagePoints, if (!warp) return null;
machinePoints: obj.machinePoints, return { imagePoints: obj.imagePoints, machinePoints: obj.machinePoints, warp };
homography: obj.homography as Mat3,
};
} }
function parseRenderStyle(r: unknown): RenderStyle { function parseRenderStyle(r: unknown): RenderStyle {

View file

@ -1,31 +0,0 @@
import { describe, it, expect } from 'vitest';
import { estimateHomography, applyHomography, invertMat3 } from './homography';
import type { Vec2, Mat3 } 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;
describe('homography', () => {
it('recovers the identity from coincident point sets', () => {
const pts: Vec2[] = [[0, 0], [1, 0], [1, 1], [0, 1]];
const H = estimateHomography(pts, pts);
expect(close(applyHomography(H, [0.3, 0.7]), [0.3, 0.7])).toBe(true);
});
it('recovers a known perspective transform', () => {
const H: Mat3 = [1.1, 0.2, 5, -0.1, 0.9, 3, 0.001, 0.002, 1];
const src: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [40, 60]];
const dst = src.map((p) => applyHomography(H, p));
const est = estimateHomography(src, dst);
for (const p of [[10, 20], [70, 30], [55, 90]] as Vec2[]) {
expect(close(applyHomography(est, p), applyHomography(H, p), 1e-4)).toBe(true);
}
});
it('invertMat3 round-trips a point', () => {
const H: Mat3 = [1.1, 0.2, 5, -0.1, 0.9, 3, 0.001, 0.002, 1];
const inv = invertMat3(H);
const p: Vec2 = [37, 91];
expect(close(applyHomography(inv, applyHomography(H, p)), p, 1e-6)).toBe(true);
});
});

View file

@ -1,78 +0,0 @@
import type { Vec2, Mat3 } from '../types';
/** Apply a homography to a point (projective divide). */
export function applyHomography(H: Mat3, p: Vec2): Vec2 {
const [a, b, c, d, e, f, g, h, i] = H;
const w = g * p[0] + h * p[1] + i;
return [(a * p[0] + b * p[1] + c) / w, (d * p[0] + e * p[1] + f) / w];
}
/** Solve a square linear system A x = b by Gaussian elimination with partial pivoting. */
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 in homography fit');
[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]!);
}
/**
* Estimate a homography mapping src dst from 4 point pairs.
* Solves for 8 DoF (h33 fixed to 1) via least squares (normal equations).
*/
export function estimateHomography(src: Vec2[], dst: Vec2[]): Mat3 {
if (src.length !== dst.length || src.length < 4) {
throw new Error('Need at least 4 matching point pairs');
}
// Build 2N×8 design matrix M and target vector t.
const rows: number[][] = [];
const t: number[] = [];
for (let i = 0; i < src.length; i++) {
const [x, y] = src[i]!;
const [u, v] = dst[i]!;
rows.push([x, y, 1, 0, 0, 0, -x * u, -y * u]);
t.push(u);
rows.push([0, 0, 0, x, y, 1, -x * v, -y * v]);
t.push(v);
}
// Normal equations: (Mᵀ M) h = Mᵀ t → 8×8 solve.
const ata: number[][] = Array.from({ length: 8 }, () => new Array(8).fill(0));
const atb: number[] = new Array(8).fill(0);
for (let r = 0; r < rows.length; r++) {
const row = rows[r]!;
for (let i = 0; i < 8; i++) {
atb[i]! += row[i]! * t[r]!;
for (let j = 0; j < 8; j++) ata[i]![j]! += row[i]! * row[j]!;
}
}
const h = solveLinear(ata, atb);
return [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!, h[5]!, h[6]!, h[7]!, 1];
}
/** Invert a 3×3 matrix. */
export function invertMat3(m: Mat3): Mat3 {
const [a, b, c, d, e, f, g, h, i] = m;
const A = e * i - f * h;
const B = -(d * i - f * g);
const C = d * h - e * g;
const det = a * A + b * B + c * C;
if (Math.abs(det) < 1e-12) throw new Error('Non-invertible matrix');
const invDet = 1 / det;
return [
A * invDet, (c * h - b * i) * invDet, (b * f - c * e) * invDet,
B * invDet, (a * i - c * g) * invDet, (c * d - a * f) * invDet,
C * invDet, (b * g - a * h) * invDet, (a * e - b * d) * invDet,
];
}

View file

@ -0,0 +1,24 @@
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);
});
});

38
src/geometry/linalg.ts Normal file
View file

@ -0,0 +1,38 @@
/** 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);
}

View file

@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { estimatePolyWarp, applyPolyWarp, applyPolyWarpInverse } from './polywarp';
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 an affine baseline on barrel-distorted data', () => {
// Barrel distortion is degree-3 in machine coords: degree 3 fits it near-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);
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
});
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
});
});

82
src/geometry/polywarp.ts Normal file
View file

@ -0,0 +1,82 @@
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 machineimage point pairs.
* Defaults to degree 3: barrel distortion is a cubic effect on position
* (the radial term r² multiplies the coordinate), so degree 3 is what actually
* compensates a wide-angle lens. Degree 2 is a fallback for sparse calibration.
*/
export function estimatePolyWarp(machine: Vec2[], image: Vec2[], degree = 3): 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);
}

View file

@ -1,20 +1,22 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { applyAlignment, projectSegments, imageToMachine, alignmentFromOrigin } from './transform'; import { applyAlignment, projectSegments, imageToMachine, alignmentFromOrigin } from './transform';
import type { Vec2, Mat3, Segment, Alignment } from '../types'; import { estimatePolyWarp } from './polywarp';
import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
const close = (a: Vec2, b: Vec2, eps = 1e-9) => const close = (a: Vec2, b: Vec2, eps = 1e-6) =>
Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps; Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // 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', () => { describe('transform', () => {
it('applyAlignment rotates then translates', () => { it('applyAlignment rotates then translates', () => {
const a: Alignment = { tx: 5, ty: 1, rot: Math.PI / 2 }; const a: Alignment = { tx: 5, ty: 1, rot: Math.PI / 2 };
// (1,0) rotated 90° → (0,1), then +(5,1) → (5,2)
expect(close(applyAlignment(a, [1, 0]), [5, 2])).toBe(true); expect(close(applyAlignment(a, [1, 0]), [5, 2])).toBe(true);
}); });
it('projectSegments applies alignment then homography to every point', () => { it('projectSegments applies alignment then the warp to every point', () => {
const segs: Segment[] = [{ kind: 'cut', points: [[0, 0], [10, 0]] }]; const segs: Segment[] = [{ kind: 'cut', points: [[0, 0], [10, 0]] }];
const a: Alignment = { tx: 2, ty: 3, rot: 0 }; const a: Alignment = { tx: 2, ty: 3, rot: 0 };
const out = projectSegments(segs, a, IDENT); const out = projectSegments(segs, a, IDENT);
@ -23,10 +25,12 @@ describe('transform', () => {
expect(close(out[0]!.points[1]!, [12, 3])).toBe(true); expect(close(out[0]!.points[1]!, [12, 3])).toBe(true);
}); });
it('imageToMachine is the inverse of the homography', () => { it('imageToMachine inverts the warp', () => {
const H: Mat3 = [2, 0, 10, 0, 2, 20, 0, 0, 1]; // Warp scales machine→image by 0.5 (offset 0): machine (4,8) → image (2,4).
// machine (5,5) → image (20,30); invert should return (5,5) const m: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
expect(close(imageToMachine(H, [20, 30]), [5, 5])).toBe(true); 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', () => { it('alignmentFromOrigin places work-origin at the given machine point', () => {

View file

@ -1,5 +1,5 @@
import type { Vec2, Mat3, Segment, Alignment } from '../types'; import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
import { applyHomography, invertMat3 } from './homography'; import { applyPolyWarp, applyPolyWarpInverse } from './polywarp';
/** Apply per-job alignment to a point: rotate by rot, then translate by (tx,ty). */ /** Apply per-job alignment to a point: rotate by rot, then translate by (tx,ty). */
export function applyAlignment(a: Alignment, p: Vec2): Vec2 { export function applyAlignment(a: Alignment, p: Vec2): Vec2 {
@ -8,17 +8,17 @@ export function applyAlignment(a: Alignment, p: Vec2): Vec2 {
return [a.tx + c * p[0] - s * p[1], a.ty + s * p[0] + c * p[1]]; return [a.tx + c * p[0] - s * p[1], a.ty + s * p[0] + c * p[1]];
} }
/** Project work-coordinate segments to image pixels: work → (alignment) → machine → (H) → image. */ /** Project work-coordinate segments to image: work → (alignment) → machine → (warp) → image. */
export function projectSegments(segments: Segment[], a: Alignment, H: Mat3): Segment[] { export function projectSegments(segments: Segment[], a: Alignment, warp: PolyWarp): Segment[] {
return segments.map((seg) => ({ return segments.map((seg) => ({
kind: seg.kind, kind: seg.kind,
points: seg.points.map((p) => applyHomography(H, applyAlignment(a, p))), points: seg.points.map((p) => applyPolyWarp(warp, applyAlignment(a, p))),
})); }));
} }
/** Convert an image-pixel point to machine mm using the inverse homography. */ /** Convert a normalized image point to machine mm using the warp's inverse map. */
export function imageToMachine(H: Mat3, imagePoint: Vec2): Vec2 { export function imageToMachine(warp: PolyWarp, imagePoint: Vec2): Vec2 {
return applyHomography(invertMat3(H), imagePoint); return applyPolyWarpInverse(warp, imagePoint);
} }
/** Build an alignment that places the work origin (0,0) at the given machine point. */ /** Build an alignment that places the work origin (0,0) at the given machine point. */

View file

@ -51,8 +51,8 @@ fileInput.addEventListener('change', async () => {
mountCalibration({ mountCalibration({
panel: document.getElementById('calib-panel') as HTMLElement, panel: document.getElementById('calib-panel') as HTMLElement,
overlay, overlay,
onHomography: (H) => { onWarp: (w) => {
state.setHomography(H); state.setWarp(w);
statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).'; statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
render(); render();
}, },
@ -62,7 +62,7 @@ mountAlignment({
panel: document.getElementById('align-panel') as HTMLElement, panel: document.getElementById('align-panel') as HTMLElement,
overlay, overlay,
getAlignment: () => state.alignment, getAlignment: () => state.alignment,
getHomography: () => state.homography, getWarp: () => state.warp,
onChange: render, onChange: render,
}); });
@ -71,7 +71,7 @@ async function boot(): Promise<void> {
const cfg = await loadConfig('config.json'); const cfg = await loadConfig('config.json');
style = cfg.renderDefaults; style = cfg.renderDefaults;
if (cfg.streamUrl) stream.src = cfg.streamUrl; if (cfg.streamUrl) stream.src = cfg.streamUrl;
if (cfg.calibration) state.setHomography(cfg.calibration.homography); if (cfg.calibration) state.setWarp(cfg.calibration.warp);
statusEl.textContent = cfg.calibration ? 'Ready. Open a G-code file.' : 'Not calibrated — calibrate first.'; statusEl.textContent = cfg.calibration ? 'Ready. Open a G-code file.' : 'Not calibrated — calibrate first.';
} catch (e) { } catch (e) {
statusEl.textContent = `Config error: ${(e as Error).message}`; statusEl.textContent = `Config error: ${(e as Error).message}`;

View file

@ -1,7 +1,22 @@
export type Vec2 = [number, number]; export type Vec2 = [number, number];
/** Row-major 3×3 matrix. */ /** One fitted polynomial map: dst = Σ cᵢ · monomialᵢ(normalized src). */
export type Mat3 = [number, number, number, number, number, number, number, number, number]; 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;
}
export type MoveKind = 'cut' | 'rapid'; export type MoveKind = 'cut' | 'rapid';
@ -23,8 +38,8 @@ export interface Calibration {
imagePoints: Vec2[]; imagePoints: Vec2[];
/** Corresponding machine coordinates, mm. */ /** Corresponding machine coordinates, mm. */
machinePoints: Vec2[]; machinePoints: Vec2[];
/** machine-mm → normalized [0,1] image coords. */ /** Bidirectional polynomial warp (machine-mm ↔ normalized image). */
homography: Mat3; warp: PolyWarp;
} }
export interface RenderStyle { export interface RenderStyle {

View file

@ -1,5 +1,9 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
// Minimal Node global declaration so tsc resolves `process.env` below without pulling in
// the full @types/node package (tsconfig pins `types` to vitest/globals only).
declare const process: { env: Record<string, string | undefined> };
export default defineConfig({ export default defineConfig({
// Dev-only: proxy the CNC MJPEG camera so the page can load it via a // Dev-only: proxy the CNC MJPEG camera so the page can load it via a
// same-origin relative path (`/camera/...`), exactly as nginx does in // same-origin relative path (`/camera/...`), exactly as nginx does in