From fad7deed5dd1bc63aeb7a9bd2e5d23fd3ec4a27b Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 14:44:35 +0200 Subject: [PATCH] feat: run manual polygons through the shared margin pipeline Co-Authored-By: Claude Opus 5.5 --- src/lib/margin.ts | 31 +++++++++++++++++++++++++++++++ tests/margin.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/lib/margin.ts create mode 100644 tests/margin.test.ts diff --git a/src/lib/margin.ts b/src/lib/margin.ts new file mode 100644 index 0000000..ecb13b8 --- /dev/null +++ b/src/lib/margin.ts @@ -0,0 +1,31 @@ +import type { Mat } from '@techstark/opencv-js'; +import { traceMask } from './detect'; +import type { CV } from './opencv-loader'; +import type { Point, Shape } from './types'; + +export function shapeFromPolygons(cv: CV, width: number, height: number, polygons: Shape, marginPx: number): Shape | null { + if (polygons.outer.length < 3) return null; + const mask = cv.Mat.zeros(height, width, cv.CV_8UC1) as Mat; + try { + fill(cv, mask, [polygons.outer], 255); + fill(cv, mask, polygons.holes.filter((h) => h.length >= 3), 0); + return traceMask(cv, mask, marginPx); + } finally { + mask.delete(); + } +} + +function fill(cv: CV, mask: Mat, rings: Point[][], value: number) { + if (rings.length === 0) return; + const mats = rings.map((ring) => + cv.matFromArray(ring.length, 1, cv.CV_32SC2, ring.flatMap((p) => [Math.round(p.x), Math.round(p.y)])), + ); + const vector = new cv.MatVector(); + try { + mats.forEach((m) => vector.push_back(m)); + cv.fillPoly(mask, vector, new cv.Scalar(value)); + } finally { + vector.delete(); + mats.forEach((m) => m.delete()); + } +} diff --git a/tests/margin.test.ts b/tests/margin.test.ts new file mode 100644 index 0000000..38b62d0 --- /dev/null +++ b/tests/margin.test.ts @@ -0,0 +1,32 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { bounds } from '../src/lib/geometry'; +import { shapeFromPolygons } from '../src/lib/margin'; +import { loadOpenCV, type CV } from '../src/lib/opencv-loader'; + +let cv: CV; +beforeAll(async () => { + ({ cv } = await loadOpenCV()); +}); + +const outer = [{ x: 40, y: 30 }, { x: 160, y: 30 }, { x: 160, y: 130 }, { x: 40, y: 130 }]; +const hole = [{ x: 90, y: 70 }, { x: 110, y: 70 }, { x: 110, y: 90 }, { x: 90, y: 90 }]; +const near = (actual: number, expected: number) => expect(Math.abs(actual - expected)).toBeLessThanOrEqual(2); + +describe('shapeFromPolygons', () => { + it('traces the drawn outline and hole', () => { + const shape = shapeFromPolygons(cv, 200, 160, { outer, holes: [hole] }, 0)!; + const b = bounds(shape.outer); + near(b.minX, 40); near(b.maxX, 160); near(b.minY, 30); near(b.maxY, 130); + expect(shape.holes).toHaveLength(1); + }); + + it('applies the margin to outline and hole', () => { + const shape = shapeFromPolygons(cv, 200, 160, { outer, holes: [hole] }, -5)!; + near(bounds(shape.outer).minX, 45); + near(bounds(shape.holes[0]).minX, 84); + }); + + it('returns null for an outline with fewer than 3 points', () => { + expect(shapeFromPolygons(cv, 200, 160, { outer: outer.slice(0, 2), holes: [] }, 0)).toBeNull(); + }); +});