feat: bivariate polynomial warp model (degree-3 default for barrel)
This commit is contained in:
parent
72d32db516
commit
c7b48105a6
3 changed files with 164 additions and 0 deletions
64
src/geometry/polywarp.test.ts
Normal file
64
src/geometry/polywarp.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { estimatePolyWarp, applyPolyWarp, applyPolyWarpInverse } from './polywarp';
|
||||
import { estimateHomography, applyHomography } from './homography';
|
||||
import type { Vec2 } from '../types';
|
||||
|
||||
const dist = (a: Vec2, b: Vec2) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
|
||||
// 16-point "box + #" calibration layout on a 2440x1220 bed (thirds in each axis).
|
||||
const XS = [0, 813.3, 1626.7, 2440];
|
||||
const YS = [0, 406.7, 813.3, 1220];
|
||||
const machineGrid: Vec2[] = XS.flatMap((x) => YS.map((y): Vec2 => [x, y]));
|
||||
|
||||
// "True" camera map: affine into ~[0.1,0.9] then barrel distortion about the image centre.
|
||||
function trueMap(p: Vec2): Vec2 {
|
||||
const u0 = 0.1 + 0.8 * (p[0] / 2440);
|
||||
const v0 = 0.1 + 0.8 * (p[1] / 1220);
|
||||
const cx = 0.5, cy = 0.5, k = 0.25;
|
||||
const dx = u0 - cx, dy = v0 - cy, r2 = dx * dx + dy * dy;
|
||||
const f = 1 + k * r2;
|
||||
return [cx + dx * f, cy + dy * f];
|
||||
}
|
||||
|
||||
describe('polywarp', () => {
|
||||
it('recovers an exact quadratic map at unseen points', () => {
|
||||
// dst is an exact degree-2 polynomial of src → fit must reproduce it.
|
||||
const quad = (p: Vec2): Vec2 => {
|
||||
const x = p[0] / 2440, y = p[1] / 1220;
|
||||
return [0.2 + 0.5 * x + 0.1 * y + 0.3 * x * x, 0.1 + 0.6 * y + 0.2 * x * y];
|
||||
};
|
||||
const image = machineGrid.map(quad);
|
||||
const w = estimatePolyWarp(machineGrid, image, 2);
|
||||
for (const m of [[400, 200], [1200, 900], [2000, 300]] as Vec2[]) {
|
||||
expect(dist(applyPolyWarp(w, m), quad(m))).toBeLessThan(1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
it('beats a homography on barrel-distorted data', () => {
|
||||
// Barrel distortion multiplies the coordinate by (1 + k·r²), so as a function of
|
||||
// machine coords it is degree 3 (dx·r² → dx³ + dx·dy²). A homography (and a degree-2
|
||||
// polynomial) cannot represent that; degree 3 fits it essentially exactly.
|
||||
const image = machineGrid.map(trueMap);
|
||||
const w = estimatePolyWarp(machineGrid, image, 3);
|
||||
const H = estimateHomography(machineGrid, image);
|
||||
// Evaluate on off-grid interior points (fifths), where barrel error is real.
|
||||
const test: Vec2[] = [[488, 244], [1464, 732], [1952, 488], [976, 976]];
|
||||
const polyMax = Math.max(...test.map((m) => dist(applyPolyWarp(w, m), trueMap(m))));
|
||||
const homoMax = Math.max(...test.map((m) => dist(applyHomography(H, m), trueMap(m))));
|
||||
expect(polyMax).toBeLessThan(homoMax * 0.05);
|
||||
});
|
||||
|
||||
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
82
src/geometry/polywarp.ts
Normal 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 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);
|
||||
}
|
||||
18
src/types.ts
18
src/types.ts
|
|
@ -3,6 +3,24 @@ export type Vec2 = [number, number];
|
|||
/** Row-major 3×3 matrix. */
|
||||
export type Mat3 = [number, number, number, number, number, number, number, 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;
|
||||
}
|
||||
|
||||
export type MoveKind = 'cut' | 'rapid';
|
||||
|
||||
/** A polyline in millimetres (machine coordinates before per-job alignment). */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue