refactor: config validates PolyWarp calibration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-11 09:25:47 +02:00
parent 0130188416
commit 3e9878ac42
3 changed files with 48 additions and 22 deletions

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,12 +49,11 @@ 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();

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,46 @@ 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>;
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 { 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

@ -41,8 +41,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 {