Compare commits
No commits in common. "3c47b28c9504c19faa615d9ad53608ff59daad3b" and "d22cdd5302db44291b2b6227cf6c2f018f361198" have entirely different histories.
3c47b28c95
...
d22cdd5302
23 changed files with 239 additions and 1593 deletions
|
|
@ -22,10 +22,10 @@ Serve the built `dist/` as static files. Configure via `public/config.json` (bun
|
|||
## Use
|
||||
|
||||
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 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`.
|
||||
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`.
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
|
@ -1,164 +0,0 @@
|
|||
# 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: **~12–16 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 (Brown–Conrady):** physically correct and
|
||||
reusable, leaves the door open to rectifying the video later, but needs a nonlinear
|
||||
(Gauss–Newton) solver — more code than the problem currently warrants.
|
||||
- **Thin-plate spline / mesh warp:** highest accuracy but wants many control points
|
||||
(20–40) and more code; overkill for the ~8–12-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 ~8–12 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).
|
||||
|
|
@ -1,5 +1,23 @@
|
|||
{
|
||||
"streamUrl": "/camera/mjpg/video.mjpg",
|
||||
"calibration": null,
|
||||
"calibration": {
|
||||
"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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { Vec2, PolyWarp, Alignment } from '../types';
|
||||
import type { Vec2, Mat3, Alignment } from '../types';
|
||||
import { imageToMachine, alignmentFromOrigin } from '../geometry/transform';
|
||||
|
||||
export interface AlignmentDeps {
|
||||
panel: HTMLElement;
|
||||
overlay: HTMLCanvasElement;
|
||||
getAlignment: () => Alignment;
|
||||
getWarp: () => PolyWarp | null;
|
||||
getHomography: () => Mat3 | null;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -61,13 +61,13 @@ export function mountAlignment(deps: AlignmentDeps): void {
|
|||
let lastMachine: Vec2 | null = null;
|
||||
|
||||
overlay.addEventListener('mousedown', (ev) => {
|
||||
const warp = deps.getWarp();
|
||||
if (!warp) return;
|
||||
const H = deps.getHomography();
|
||||
if (!H) return;
|
||||
// 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) {
|
||||
const a = deps.getAlignment();
|
||||
const m = imageToMachine(warp, normFromEvent(overlay, ev));
|
||||
const m = imageToMachine(H, normFromEvent(overlay, ev));
|
||||
Object.assign(a, alignmentFromOrigin(m, a.rot));
|
||||
armOrigin = false;
|
||||
overlay.classList.remove('interactive');
|
||||
|
|
@ -77,15 +77,15 @@ export function mountAlignment(deps: AlignmentDeps): void {
|
|||
}
|
||||
dragging = true;
|
||||
overlay.classList.add('interactive');
|
||||
lastMachine = imageToMachine(warp, normFromEvent(overlay, ev));
|
||||
lastMachine = imageToMachine(H, normFromEvent(overlay, ev));
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (ev) => {
|
||||
if (!dragging) return;
|
||||
const warp = deps.getWarp();
|
||||
if (!warp || !lastMachine) return;
|
||||
const H = deps.getHomography();
|
||||
if (!H || !lastMachine) return;
|
||||
const a = deps.getAlignment();
|
||||
const cur = imageToMachine(warp, normFromEvent(overlay, ev));
|
||||
const cur = imageToMachine(H, normFromEvent(overlay, ev));
|
||||
if (ev.shiftKey) {
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { residuals } from './calibration-ui';
|
||||
import { estimatePolyWarp } from '../geometry/polywarp';
|
||||
import { estimateHomography } from '../geometry/homography';
|
||||
import type { Vec2 } from '../types';
|
||||
|
||||
describe('calibration residuals', () => {
|
||||
it('reports near-zero error for a clean degree-1 fit', () => {
|
||||
it('reports near-zero error for a clean 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);
|
||||
const image: Vec2[] = [[10, 10], [210, 12], [208, 210], [8, 208]];
|
||||
const H = estimateHomography(machine, image);
|
||||
const errs = residuals(H, machine, image);
|
||||
expect(Math.max(...errs)).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it('flags a mismatched point with a large residual', () => {
|
||||
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50], [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);
|
||||
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50]];
|
||||
const image: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [80, 20]]; // last is wrong
|
||||
const H = estimateHomography(machine, image);
|
||||
const errs = residuals(H, machine, image);
|
||||
expect(errs[4]!).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { Vec2, PolyWarp } from '../types';
|
||||
import { estimatePolyWarp, applyPolyWarp } from '../geometry/polywarp';
|
||||
import type { Vec2, Mat3 } from '../types';
|
||||
import { estimateHomography, applyHomography } from '../geometry/homography';
|
||||
|
||||
/** Per-point reprojection error in normalized units: |warp(machine) − image|. */
|
||||
export function residuals(warp: PolyWarp, machine: Vec2[], image: Vec2[]): number[] {
|
||||
/** Per-point reprojection error in pixels: |H·machine − image|. */
|
||||
export function residuals(H: Mat3, 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);
|
||||
const p = applyHomography(H, m);
|
||||
return Math.hypot(p[0] - image[i]![0], p[1] - image[i]![1]);
|
||||
});
|
||||
}
|
||||
|
|
@ -18,15 +18,16 @@ interface CalibPoint {
|
|||
export interface CalibrationDeps {
|
||||
panel: HTMLElement;
|
||||
overlay: HTMLCanvasElement;
|
||||
onWarp: (w: PolyWarp, points: CalibPoint[]) => void;
|
||||
onHomography: (H: Mat3, points: CalibPoint[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the calibration panel. Flow: enter machine X/Y → click "Click point in video" →
|
||||
* click the spindle tip in the video → repeat ≥10× → choose degree → "Compute".
|
||||
* click the spindle tip in the video → repeat ≥4× → "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.
|
||||
* Captured image points and the resulting homography are in normalized [0,1] camera-frame
|
||||
* coordinates (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 {
|
||||
const { panel, overlay } = deps;
|
||||
|
|
@ -35,7 +36,7 @@ export function mountCalibration(deps: CalibrationDeps): void {
|
|||
|
||||
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"><small>Jog the spindle to a known X/Y, enter it, then click the tip in the video.</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" />
|
||||
|
|
@ -43,8 +44,7 @@ export function mountCalibration(deps: CalibrationDeps): void {
|
|||
</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-compute">Compute homography</button>
|
||||
<button id="cal-clear">Clear</button>
|
||||
</div>
|
||||
<textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea>
|
||||
|
|
@ -54,7 +54,6 @@ export function mountCalibration(deps: CalibrationDeps): void {
|
|||
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[]) => {
|
||||
|
|
@ -75,6 +74,8 @@ export function mountCalibration(deps: CalibrationDeps): void {
|
|||
overlay.addEventListener('click', (ev) => {
|
||||
if (!pendingMachine || !overlay.classList.contains('interactive')) return;
|
||||
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 = [
|
||||
(ev.clientX - rect.left) / rect.width,
|
||||
(ev.clientY - rect.top) / rect.height,
|
||||
|
|
@ -92,28 +93,24 @@ export function mountCalibration(deps: CalibrationDeps): void {
|
|||
});
|
||||
|
||||
(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}.`;
|
||||
if (points.length < 4) {
|
||||
jsonEl.value = 'Need at least 4 points.';
|
||||
return;
|
||||
}
|
||||
const machine = points.map((p) => p.machine);
|
||||
const image = points.map((p) => p.image);
|
||||
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 H = estimateHomography(machine, image);
|
||||
// Per-axis pixel residual (normalized error scaled by canvas width/height) — used only for misclick display.
|
||||
const errsPx = machine.map((m, i) => {
|
||||
const p = applyPolyWarp(warp, m);
|
||||
const p = applyHomography(H, 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]);
|
||||
jsonEl.value = JSON.stringify(
|
||||
{ imagePoints: image, machinePoints: machine, homography: H },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
deps.onHomography(H, [...points]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mountAlignment } from './alignment-ui';
|
||||
import { mountCalibration } from './calibration-ui';
|
||||
import type { Vec2, PolyWarp, Alignment } from '../types';
|
||||
import { estimatePolyWarp } from '../geometry/polywarp';
|
||||
import type { Mat3, Alignment } from '../types';
|
||||
|
||||
// Identity warp: machine coords == normalized coords for this test, so a
|
||||
// Identity homography: machine coords == normalized coords for this test, so a
|
||||
// normalized click at fraction f over the 100px box maps to machine value f.
|
||||
// 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);
|
||||
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||
|
||||
function makeOverlay(): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas');
|
||||
|
|
@ -42,7 +39,7 @@ describe('overlay interaction', () => {
|
|||
panel,
|
||||
overlay,
|
||||
getAlignment: () => alignment,
|
||||
getWarp: () => IDENT,
|
||||
getHomography: () => IDENT,
|
||||
onChange: () => {},
|
||||
});
|
||||
overlay.dispatchEvent(mouse('mousedown', 50, 50));
|
||||
|
|
@ -56,12 +53,12 @@ describe('overlay interaction', () => {
|
|||
it('while calibration is armed, a click is captured by calibration and does NOT drag the alignment', () => {
|
||||
const calibPanel = document.createElement('div');
|
||||
const alignPanel = document.createElement('div');
|
||||
mountCalibration({ panel: calibPanel, overlay, onWarp: () => {} });
|
||||
mountCalibration({ panel: calibPanel, overlay, onHomography: () => {} });
|
||||
mountAlignment({
|
||||
panel: alignPanel,
|
||||
overlay,
|
||||
getAlignment: () => alignment,
|
||||
getWarp: () => IDENT,
|
||||
getHomography: () => IDENT,
|
||||
onChange: () => {},
|
||||
});
|
||||
(calibPanel.querySelector('#cal-x') as HTMLInputElement).value = '10';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createState } from './state';
|
||||
import { estimatePolyWarp } from '../geometry/polywarp';
|
||||
import type { Vec2 } from '../types';
|
||||
import type { Mat3 } from '../types';
|
||||
|
||||
// Identity warp: image == machine.
|
||||
const square: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const IDENT = estimatePolyWarp(square, square, 1);
|
||||
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||
|
||||
describe('app state', () => {
|
||||
it('starts with no segments and a zero alignment', () => {
|
||||
|
|
@ -23,12 +20,12 @@ describe('app state', () => {
|
|||
|
||||
it('projected() returns image-space segments when calibrated', () => {
|
||||
const s = createState();
|
||||
s.setWarp(IDENT);
|
||||
s.setHomography(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);
|
||||
expect(proj[0]!.points[0]).toEqual([2, 0]);
|
||||
expect(proj[0]!.points[1]).toEqual([12, 0]);
|
||||
});
|
||||
|
||||
it('projected() returns [] when not calibrated', () => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import type { Segment, Alignment, PolyWarp } from '../types';
|
||||
import type { Segment, Alignment, Mat3 } from '../types';
|
||||
import { parseGcode } from '../gcode/parser';
|
||||
import { projectSegments } from '../geometry/transform';
|
||||
|
||||
export interface AppState {
|
||||
segments: Segment[];
|
||||
alignment: Alignment;
|
||||
warp: PolyWarp | null;
|
||||
homography: Mat3 | null;
|
||||
loadGcode(text: string): string[];
|
||||
setWarp(w: PolyWarp | null): void;
|
||||
setHomography(H: Mat3 | null): void;
|
||||
projected(): Segment[];
|
||||
}
|
||||
|
||||
|
|
@ -15,18 +15,18 @@ export function createState(): AppState {
|
|||
const state: AppState = {
|
||||
segments: [],
|
||||
alignment: { tx: 0, ty: 0, rot: 0 },
|
||||
warp: null,
|
||||
homography: null,
|
||||
loadGcode(text: string): string[] {
|
||||
const { segments, warnings } = parseGcode(text);
|
||||
state.segments = segments;
|
||||
return warnings;
|
||||
},
|
||||
setWarp(w: PolyWarp | null): void {
|
||||
state.warp = w;
|
||||
setHomography(H: Mat3 | null): void {
|
||||
state.homography = H;
|
||||
},
|
||||
projected(): Segment[] {
|
||||
if (!state.warp) return [];
|
||||
return projectSegments(state.segments, state.alignment, state.warp);
|
||||
if (!state.homography) return [];
|
||||
return projectSegments(state.segments, state.alignment, state.homography);
|
||||
},
|
||||
};
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { loadConfig, DEFAULT_RENDER } from './config';
|
||||
import { estimatePolyWarp } from './geometry/polywarp';
|
||||
|
||||
function mockFetch(body: unknown, ok = true) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
|
|
@ -21,16 +20,18 @@ describe('loadConfig', () => {
|
|||
});
|
||||
|
||||
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 };
|
||||
const calibration = {
|
||||
imagePoints: [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
machinePoints: [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
homography: [1, 0, 0, 0, 1, 0, 0, 0, 1],
|
||||
};
|
||||
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 } } });
|
||||
mockFetch({ streamUrl: 'x', calibration: { homography: [1, 2, 3] } });
|
||||
const cfg = await loadConfig('config.json');
|
||||
expect(cfg.calibration).toBeNull();
|
||||
});
|
||||
|
|
@ -49,23 +50,14 @@ describe('loadConfig', () => {
|
|||
expect(cfg.renderDefaults).not.toHaveProperty('bogus'); // extra key dropped
|
||||
});
|
||||
|
||||
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 };
|
||||
it('rejects a calibration whose homography contains NaN', async () => {
|
||||
const calibration = {
|
||||
imagePoints: [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
machinePoints: [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
homography: [1, 0, 0, 0, 1, 0, 0, 0, NaN],
|
||||
};
|
||||
mockFetch({ streamUrl: 'x', calibration });
|
||||
const cfg = await loadConfig('config.json');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AppConfig, Calibration, PolyMap, PolyWarp, Vec2, RenderStyle } from './types';
|
||||
import type { AppConfig, Calibration, Mat3, Vec2, RenderStyle } from './types';
|
||||
|
||||
export const DEFAULT_RENDER: RenderStyle = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 };
|
||||
|
||||
|
|
@ -10,48 +10,18 @@ function isVec2Array(v: unknown): v is Vec2[] {
|
|||
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 {
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
const obj = c as Record<string, unknown>;
|
||||
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;
|
||||
const warp = parseWarp(obj.warp);
|
||||
if (!warp) return null;
|
||||
return { imagePoints: obj.imagePoints, machinePoints: obj.machinePoints, warp };
|
||||
return {
|
||||
imagePoints: obj.imagePoints,
|
||||
machinePoints: obj.machinePoints,
|
||||
homography: obj.homography as Mat3,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRenderStyle(r: unknown): RenderStyle {
|
||||
|
|
|
|||
31
src/geometry/homography.test.ts
Normal file
31
src/geometry/homography.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
78
src/geometry/homography.ts
Normal file
78
src/geometry/homography.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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,
|
||||
];
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/** 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);
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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.
|
||||
* 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);
|
||||
}
|
||||
|
|
@ -1,22 +1,20 @@
|
|||
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';
|
||||
import type { Vec2, Mat3, Segment, Alignment } from '../types';
|
||||
|
||||
const close = (a: Vec2, b: Vec2, eps = 1e-6) =>
|
||||
const close = (a: Vec2, b: Vec2, eps = 1e-9) =>
|
||||
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);
|
||||
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||
|
||||
describe('transform', () => {
|
||||
it('applyAlignment rotates then translates', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('projectSegments applies alignment then the warp to every point', () => {
|
||||
it('projectSegments applies alignment then homography 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);
|
||||
|
|
@ -25,12 +23,10 @@ describe('transform', () => {
|
|||
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('imageToMachine is the inverse of the homography', () => {
|
||||
const H: Mat3 = [2, 0, 10, 0, 2, 20, 0, 0, 1];
|
||||
// machine (5,5) → image (20,30); invert should return (5,5)
|
||||
expect(close(imageToMachine(H, [20, 30]), [5, 5])).toBe(true);
|
||||
});
|
||||
|
||||
it('alignmentFromOrigin places work-origin at the given machine point', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
|
||||
import { applyPolyWarp, applyPolyWarpInverse } from './polywarp';
|
||||
import type { Vec2, Mat3, Segment, Alignment } from '../types';
|
||||
import { applyHomography, invertMat3 } from './homography';
|
||||
|
||||
/** Apply per-job alignment to a point: rotate by rot, then translate by (tx,ty). */
|
||||
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]];
|
||||
}
|
||||
|
||||
/** Project work-coordinate segments to image: work → (alignment) → machine → (warp) → image. */
|
||||
export function projectSegments(segments: Segment[], a: Alignment, warp: PolyWarp): Segment[] {
|
||||
/** Project work-coordinate segments to image pixels: work → (alignment) → machine → (H) → image. */
|
||||
export function projectSegments(segments: Segment[], a: Alignment, H: Mat3): Segment[] {
|
||||
return segments.map((seg) => ({
|
||||
kind: seg.kind,
|
||||
points: seg.points.map((p) => applyPolyWarp(warp, applyAlignment(a, p))),
|
||||
points: seg.points.map((p) => applyHomography(H, 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);
|
||||
/** Convert an image-pixel point to machine mm using the inverse homography. */
|
||||
export function imageToMachine(H: Mat3, imagePoint: Vec2): Vec2 {
|
||||
return applyHomography(invertMat3(H), imagePoint);
|
||||
}
|
||||
|
||||
/** Build an alignment that places the work origin (0,0) at the given machine point. */
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ fileInput.addEventListener('change', async () => {
|
|||
mountCalibration({
|
||||
panel: document.getElementById('calib-panel') as HTMLElement,
|
||||
overlay,
|
||||
onWarp: (w) => {
|
||||
state.setWarp(w);
|
||||
onHomography: (H) => {
|
||||
state.setHomography(H);
|
||||
statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
|
||||
render();
|
||||
},
|
||||
|
|
@ -62,7 +62,7 @@ mountAlignment({
|
|||
panel: document.getElementById('align-panel') as HTMLElement,
|
||||
overlay,
|
||||
getAlignment: () => state.alignment,
|
||||
getWarp: () => state.warp,
|
||||
getHomography: () => state.homography,
|
||||
onChange: render,
|
||||
});
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ async function boot(): Promise<void> {
|
|||
const cfg = await loadConfig('config.json');
|
||||
style = cfg.renderDefaults;
|
||||
if (cfg.streamUrl) stream.src = cfg.streamUrl;
|
||||
if (cfg.calibration) state.setWarp(cfg.calibration.warp);
|
||||
if (cfg.calibration) state.setHomography(cfg.calibration.homography);
|
||||
statusEl.textContent = cfg.calibration ? 'Ready. Open a G-code file.' : 'Not calibrated — calibrate first.';
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Config error: ${(e as Error).message}`;
|
||||
|
|
|
|||
23
src/types.ts
23
src/types.ts
|
|
@ -1,22 +1,7 @@
|
|||
export type Vec2 = [number, number];
|
||||
|
||||
/** 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;
|
||||
}
|
||||
/** Row-major 3×3 matrix. */
|
||||
export type Mat3 = [number, number, number, number, number, number, number, number, number];
|
||||
|
||||
export type MoveKind = 'cut' | 'rapid';
|
||||
|
||||
|
|
@ -38,8 +23,8 @@ export interface Calibration {
|
|||
imagePoints: Vec2[];
|
||||
/** Corresponding machine coordinates, mm. */
|
||||
machinePoints: Vec2[];
|
||||
/** Bidirectional polynomial warp (machine-mm ↔ normalized image). */
|
||||
warp: PolyWarp;
|
||||
/** machine-mm → normalized [0,1] image coords. */
|
||||
homography: Mat3;
|
||||
}
|
||||
|
||||
export interface RenderStyle {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
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({
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue