96 lines
3.5 KiB
TypeScript
96 lines
3.5 KiB
TypeScript
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);
|
|
});
|
|
});
|