From 0a97cb88af0c5847db824d836d22b1deb2343dce Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 14:45:10 +0200 Subject: [PATCH] test: add real-photo fixture harness for detection regressions Co-Authored-By: Claude Opus 5.5 --- tests/fixtures.test.ts | 45 ++++++++++++++++++++++++++++++++++++ tests/fixtures/README.md | 27 ++++++++++++++++++++++ tests/fixtures/manifest.json | 1 + 3 files changed, 73 insertions(+) create mode 100644 tests/fixtures.test.ts create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/manifest.json diff --git a/tests/fixtures.test.ts b/tests/fixtures.test.ts new file mode 100644 index 0000000..52800d8 --- /dev/null +++ b/tests/fixtures.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import jpeg from 'jpeg-js'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { detect, otsuThreshold } from '../src/lib/detect'; +import { prepareForExport, shapeSize } from '../src/lib/geometry'; +import { loadOpenCV, type CV } from '../src/lib/opencv-loader'; +import type { Point, RasterImage } from '../src/lib/types'; + +type FixtureCase = { + file: string; + threshold: number | null; + invert: boolean; + seed: Point | null; + pxPerMm: number; + expected: { widthMm: number; heightMm: number; holes: number }; + toleranceMm: number; +}; + +const dir = fileURLToPath(new URL('./fixtures/', import.meta.url)); +const cases: FixtureCase[] = JSON.parse(readFileSync(dir + 'manifest.json', 'utf8')); + +function readJpeg(path: string): RasterImage { + const decoded = jpeg.decode(readFileSync(path), { useTArray: true, formatAsRGBA: true }); + return { width: decoded.width, height: decoded.height, data: new Uint8ClampedArray(decoded.data) }; +} + +describe.skipIf(cases.length === 0)('detect on real photos', () => { + let cv: CV; + beforeAll(async () => { + ({ cv } = await loadOpenCV()); + }); + + it.each(cases)('$file', (c) => { + const image = readJpeg(dir + c.file); + const threshold = c.threshold ?? otsuThreshold(cv, image); + const shape = detect(cv, image, { threshold, invert: c.invert, seed: c.seed, marginPx: 0 }); + expect(shape).not.toBeNull(); + const mm = prepareForExport(shape!, c.pxPerMm)!; + const size = shapeSize(mm); + expect(Math.abs(size.width - c.expected.widthMm)).toBeLessThanOrEqual(c.toleranceMm); + expect(Math.abs(size.height - c.expected.heightMm)).toBeLessThanOrEqual(c.toleranceMm); + expect(mm.holes).toHaveLength(c.expected.holes); + }); +}); diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..3cef82f --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,27 @@ +# Real photo fixtures + +Regression photos for `detect()`. Each entry in `manifest.json` is checked by `tests/fixtures.test.ts`. + +To add one: + +1. Take a top-down photo of a scrap piece with something of known length visible. +2. Rotate it upright and resize it so the long side is at most 2000 px. Save as JPEG. + EXIF orientation is ignored by the test decoder. +3. Measure the real piece's bounding box in mm. +4. Open the photo in the app, set the scale, and note px/mm, the threshold you used, and, + if needed, a seed point (shown in image px). +5. Add an entry: + +```json +{ + "file": "plywood-1.jpg", + "threshold": null, + "invert": false, + "seed": null, + "pxPerMm": 3.21, + "expected": { "widthMm": 412, "heightMm": 287, "holes": 2 }, + "toleranceMm": 8 +} +``` + +`threshold: null` means Otsu's automatic value. `seed` is `{ "x": ..., "y": ... }` or `null`. diff --git a/tests/fixtures/manifest.json b/tests/fixtures/manifest.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/fixtures/manifest.json @@ -0,0 +1 @@ +[]