diff --git a/src/config.test.ts b/src/config.test.ts index dc7a409..1295d1a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,5 +1,6 @@ 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({ @@ -20,18 +21,16 @@ describe('loadConfig', () => { }); it('keeps a valid calibration', 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, 1], - }; + const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][]; + const warp = estimatePolyWarp(square, square, 1); + const calibration = { imagePoints: square, machinePoints: square, warp }; mockFetch({ streamUrl: 'x', calibration }); const cfg = await loadConfig('config.json'); expect(cfg.calibration).toEqual(calibration); }); it('drops a malformed calibration to null', async () => { - mockFetch({ streamUrl: 'x', calibration: { homography: [1, 2, 3] } }); + mockFetch({ streamUrl: 'x', calibration: { warp: { degree: 2 } } }); const cfg = await loadConfig('config.json'); expect(cfg.calibration).toBeNull(); }); @@ -50,12 +49,11 @@ describe('loadConfig', () => { expect(cfg.renderDefaults).not.toHaveProperty('bogus'); // extra key dropped }); - 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], - }; + it('rejects a calibration whose warp coefficients contain NaN', async () => { + const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][]; + const warp = estimatePolyWarp(square, square, 1); + warp.fwd.cu[0] = NaN; + const calibration = { imagePoints: square, machinePoints: square, warp }; mockFetch({ streamUrl: 'x', calibration }); const cfg = await loadConfig('config.json'); expect(cfg.calibration).toBeNull(); diff --git a/src/config.ts b/src/config.ts index d047a8a..a83be00 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 }; @@ -10,18 +10,46 @@ 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; + const norm = o.norm as Record | 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; + if (!isFiniteNumber(o.degree)) return null; + const fwd = parsePolyMap(o.fwd, o.degree); + const inv = parsePolyMap(o.inv, o.degree); + if (!fwd || !inv) return null; + return { degree: o.degree, fwd, inv }; +} + function parseCalibration(c: unknown): Calibration | null { if (!c || typeof c !== 'object') return null; const obj = c as Record; 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; - return { - imagePoints: obj.imagePoints, - machinePoints: obj.machinePoints, - homography: obj.homography as Mat3, - }; + const warp = parseWarp(obj.warp); + if (!warp) return null; + return { imagePoints: obj.imagePoints, machinePoints: obj.machinePoints, warp }; } function parseRenderStyle(r: unknown): RenderStyle { diff --git a/src/types.ts b/src/types.ts index 52abd87..5992e39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,8 +41,8 @@ export interface Calibration { imagePoints: Vec2[]; /** Corresponding machine coordinates, mm. */ machinePoints: Vec2[]; - /** machine-mm → normalized [0,1] image coords. */ - homography: Mat3; + /** Bidirectional polynomial warp (machine-mm ↔ normalized image). */ + warp: PolyWarp; } export interface RenderStyle {