diff --git a/src/lib/detect.ts b/src/lib/detect.ts new file mode 100644 index 0000000..295713e --- /dev/null +++ b/src/lib/detect.ts @@ -0,0 +1,81 @@ +import type { Mat } from '@techstark/opencv-js'; +import type { CV } from './opencv-loader'; +import type { Point, RasterImage, Shape } from './types'; + +/** Blurred grayscale; shared by thresholding and Otsu so both see the same pixels. */ +function toGray(cv: CV, image: RasterImage): Mat { + const src = cv.matFromImageData(image as unknown as ImageData); + const gray = new cv.Mat(); + try { + cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY); + cv.GaussianBlur(gray, gray, new cv.Size(5, 5), 0); + return gray; + } finally { + src.delete(); + } +} + +export function otsuThreshold(cv: CV, image: RasterImage): number { + const gray = toGray(cv, image); + const out = new cv.Mat(); + try { + return Math.round(cv.threshold(gray, out, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU)); + } finally { + gray.delete(); + out.delete(); + } +} + +export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null { + const radius = Math.round(Math.abs(marginPx)); + // Padding keeps dilation from being clipped and contours off the image edge. + const pad = radius + 2; + const padded = new cv.Mat(); + const contours = new cv.MatVector(); + const hierarchy = new cv.Mat(); + let kernel: Mat | null = null; + try { + cv.copyMakeBorder(mask, padded, pad, pad, pad, pad, cv.BORDER_CONSTANT, new cv.Scalar(0)); + if (radius > 0) { + kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(2 * radius + 1, 2 * radius + 1)); + if (marginPx < 0) cv.erode(padded, padded, kernel); + else cv.dilate(padded, padded, kernel); + } + cv.findContours(padded, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE); + + const count = contours.size(); + const h = hierarchy.data32S; // per contour: [next, prev, firstChild, parent] + let best = -1; + let bestArea = 0; + for (let i = 0; i < count; i++) { + if (h[i * 4 + 3] !== -1) continue; + const c = contours.get(i); + const area = cv.contourArea(c); + c.delete(); + if (area > bestArea) { + bestArea = area; + best = i; + } + } + if (best === -1) return null; + + const ring = (i: number): Point[] => { + const c = contours.get(i); + const d = c.data32S; + const pts: Point[] = []; + for (let k = 0; k < d.length; k += 2) pts.push({ x: d[k] - pad, y: d[k + 1] - pad }); + c.delete(); + return pts; + }; + const holes: Point[][] = []; + for (let i = 0; i < count; i++) { + if (h[i * 4 + 3] === best) holes.push(ring(i)); + } + return { outer: ring(best), holes }; + } finally { + padded.delete(); + contours.delete(); + hierarchy.delete(); + kernel?.delete(); + } +} diff --git a/src/lib/opencv-loader.ts b/src/lib/opencv-loader.ts new file mode 100644 index 0000000..1059268 --- /dev/null +++ b/src/lib/opencv-loader.ts @@ -0,0 +1,18 @@ +export type CV = typeof import('@techstark/opencv-js'); + +let loading: Promise<{ cv: CV }> | null = null; + +// Wrapped in an object: the cv module is a thenable, so resolving with it directly never settles. +export function loadOpenCV(): Promise<{ cv: CV }> { + loading ??= (async () => { + const mod = (await import('@techstark/opencv-js')) as unknown as { default: CV }; + const cv = mod.default; + if (!cv.Mat) { + await new Promise((resolve) => { + (cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve(); + }); + } + return { cv }; + })(); + return loading; +} diff --git a/tests/helpers/images.ts b/tests/helpers/images.ts new file mode 100644 index 0000000..8e2d74d --- /dev/null +++ b/tests/helpers/images.ts @@ -0,0 +1,35 @@ +import type { Mat } from '@techstark/opencv-js'; +import type { CV } from '../../src/lib/opencv-loader'; +import type { RasterImage } from '../../src/lib/types'; + +/** Grayscale RGBA image where `paint(x, y)` returns the brightness 0–255. */ +export function grayImage(width: number, height: number, paint: (x: number, y: number) => number): RasterImage { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const v = paint(x, y); + data[i] = data[i + 1] = data[i + 2] = v; + data[i + 3] = 255; + } + } + return { width, height, data }; +} + +/** CV_8UC1 mask with 255 where `inside(x, y)`. Caller must delete it. */ +export function maskFrom(cv: CV, width: number, height: number, inside: (x: number, y: number) => boolean): Mat { + const mask = cv.Mat.zeros(height, width, cv.CV_8UC1) as Mat; + const data = mask.data; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (inside(x, y)) data[y * width + x] = 255; + } + } + return mask; +} + +export const inRect = (x: number, y: number, x0: number, y0: number, x1: number, y1: number) => + x >= x0 && x < x1 && y >= y0 && y < y1; + +export const inCircle = (x: number, y: number, cx: number, cy: number, r: number) => + (x - cx) ** 2 + (y - cy) ** 2 <= r * r; diff --git a/tests/trace-mask.test.ts b/tests/trace-mask.test.ts new file mode 100644 index 0000000..df41cc7 --- /dev/null +++ b/tests/trace-mask.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import type { Mat } from '@techstark/opencv-js'; +import { otsuThreshold, traceMask } from '../src/lib/detect'; +import { bounds } from '../src/lib/geometry'; +import { loadOpenCV, type CV } from '../src/lib/opencv-loader'; +import { grayImage, inCircle, inRect, maskFrom } from './helpers/images'; + +let cv: CV; +const mats: Mat[] = []; +beforeAll(async () => { + ({ cv } = await loadOpenCV()); +}); +afterEach(() => { + mats.splice(0).forEach((m) => m.delete()); +}); + +/** 200x160 plate: rect x 40..159, y 30..129 with a round hole r=15 at (100,80). */ +function plate(): Mat { + const m = maskFrom(cv, 200, 160, (x, y) => inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15)); + mats.push(m); + return m; +} + +describe('loadOpenCV', () => { + it('resolves to the same cv instance every time', async () => { + const again = await loadOpenCV(); + expect(again.cv).toBe(cv); + expect(typeof cv.Mat).toBe('function'); + }); +}); + +describe('traceMask', () => { + it('returns the outer contour and the hole', () => { + const shape = traceMask(cv, plate(), 0)!; + const b = bounds(shape.outer); + expect(Math.abs(b.minX - 40)).toBeLessThanOrEqual(2); + expect(Math.abs(b.maxX - 159)).toBeLessThanOrEqual(2); + expect(Math.abs(b.minY - 30)).toBeLessThanOrEqual(2); + expect(Math.abs(b.maxY - 129)).toBeLessThanOrEqual(2); + expect(shape.holes).toHaveLength(1); + const h = bounds(shape.holes[0]); + expect(h.minX).toBeGreaterThan(82); + expect(h.maxX).toBeLessThan(118); + }); + + it('shrinks the outer edge and grows holes for a negative margin', () => { + const shape = traceMask(cv, plate(), -5)!; + const b = bounds(shape.outer); + expect(Math.abs(b.minX - 45)).toBeLessThanOrEqual(2); + expect(Math.abs(b.maxX - 154)).toBeLessThanOrEqual(2); + const h = bounds(shape.holes[0]); + expect(Math.abs(h.minX - 79)).toBeLessThanOrEqual(2); + expect(Math.abs(h.maxX - 121)).toBeLessThanOrEqual(2); + }); + + it('grows the outer edge for a positive margin, even past the image border', () => { + const shape = traceMask(cv, plate(), 5)!; + expect(Math.abs(bounds(shape.outer).minX - 35)).toBeLessThanOrEqual(2); + + const edge = maskFrom(cv, 100, 100, (x, y) => inRect(x, y, 0, 20, 60, 80)); + mats.push(edge); + expect(bounds(traceMask(cv, edge, 5)!.outer).minX).toBeLessThan(0); + }); + + it('keeps the larger part when erosion splits the piece in two', () => { + // 60x80 block and 40x40 block joined by a 7 px bridge; an 11 px erosion cuts the bridge. + const m = maskFrom(cv, 200, 120, (x, y) => + inRect(x, y, 20, 20, 80, 100) || inRect(x, y, 120, 40, 160, 80) || inRect(x, y, 80, 56, 120, 63)); + mats.push(m); + const b = bounds(traceMask(cv, m, -5)!.outer); + expect(b.maxX).toBeLessThan(100); + }); + + it('returns null when the margin removes the piece entirely', () => { + const m = maskFrom(cv, 50, 50, (x, y) => inRect(x, y, 20, 20, 30, 30)); + mats.push(m); + expect(traceMask(cv, m, -8)).toBeNull(); + }); + + it('does not modify the input mask', () => { + const m = plate(); + const before = cv.countNonZero(m); + traceMask(cv, m, -5); + expect(cv.countNonZero(m)).toBe(before); + }); +}); + +describe('otsuThreshold', () => { + it('lands between background and piece brightness', () => { + const image = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) ? 220 : 30)); + const t = otsuThreshold(cv, image); + expect(t).toBeGreaterThan(30); + expect(t).toBeLessThan(220); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 02b567c..cc71282 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,5 +11,7 @@ export default defineConfig({ include: ['tests/**/*.test.ts'], testTimeout: 30000, hookTimeout: 60000, + // OpenCV's CJS export is a thenable; copying it onto the namespace makes `import()` never settle. + deps: { interopDefault: false }, }, });