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('traceMask performance', () => { it('handles a huge margin quickly (e.g. while a length is half-typed)', () => { const m = maskFrom(cv, 2000, 1500, (x, y) => inRect(x, y, 200, 200, 1800, 1300)); mats.push(m); const start = performance.now(); const shape = traceMask(cv, m, -300)!; expect(performance.now() - start).toBeLessThan(1500); expect(Math.abs(bounds(shape.outer).minX - 500)).toBeLessThanOrEqual(2); }); }); 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); }); });