Margin slider steps drop from ~800 ms to under 70 ms in the browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.6 KiB
TypeScript
176 lines
7.6 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import { detect, detectPrepared, disposePrepared, otsuThreshold, prepareImage, preparedThreshold, segmentPiece, traceMask } from '../src/lib/detect';
|
|
import { bounds } from '../src/lib/geometry';
|
|
import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
|
|
import type { DetectParams } from '../src/lib/types';
|
|
import { grayImage, inCircle, inRect } from './helpers/images';
|
|
|
|
let cv: CV;
|
|
beforeAll(async () => {
|
|
({ cv } = await loadOpenCV());
|
|
});
|
|
|
|
const params = (overrides: Partial<DetectParams> = {}): DetectParams => ({
|
|
threshold: 128, seed: null, marginPx: 0, minHolePx: 0, ...overrides,
|
|
});
|
|
|
|
/** Light plate x 40..159, y 30..129 with hole r=15 at (100,80), plus a small light blob, on dark ground. */
|
|
const plateScene = grayImage(200, 160, (x, y) => {
|
|
const plate = inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15);
|
|
const blob = inRect(x, y, 170, 140, 185, 152);
|
|
return plate || blob ? 220 : 30;
|
|
});
|
|
|
|
const near = (actual: number, expected: number) => expect(Math.abs(actual - expected)).toBeLessThanOrEqual(2);
|
|
|
|
describe('detect', () => {
|
|
it('finds the plate and its hole, ignoring the smaller blob', () => {
|
|
const shape = detect(cv, plateScene, params())!;
|
|
const b = bounds(shape.outer);
|
|
near(b.minX, 40); near(b.maxX, 159); near(b.minY, 30); near(b.maxY, 129);
|
|
expect(shape.holes).toHaveLength(1);
|
|
});
|
|
|
|
it('finds a dark piece on a light background without any setting', () => {
|
|
const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
|
|
const b = bounds(detect(cv, inverted, params())!.outer);
|
|
near(b.minX, 40); near(b.maxX, 159);
|
|
});
|
|
|
|
it('takes the polarity from the clicked pixel', () => {
|
|
const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
|
|
const b = bounds(detect(cv, inverted, params({ seed: { x: 100, y: 80 } }))!.outer);
|
|
near(b.minX, 40); near(b.maxX, 159);
|
|
});
|
|
|
|
it('corrects uneven lighting across the photo', () => {
|
|
// Light from the right: the floor ramps 120 → 220 and the piece is 50 darker than the floor around it.
|
|
const sideLit = grayImage(400, 300, (x, y) => 120 + (100 * x) / 400 - (inRect(x, y, 100, 75, 300, 225) ? 50 : 0));
|
|
const shape = detect(cv, sideLit, params({ threshold: otsuThreshold(cv, sideLit) }))!;
|
|
const b = bounds(shape.outer);
|
|
near(b.minX, 100); near(b.maxX, 299); near(b.minY, 75); near(b.maxY, 224);
|
|
});
|
|
|
|
it('selects the clicked piece even when another piece is larger', () => {
|
|
const shape = detect(cv, plateScene, params({ seed: { x: 177, y: 146 } }))!;
|
|
const b = bounds(shape.outer);
|
|
near(b.minX, 170); near(b.maxX, 184);
|
|
});
|
|
|
|
it('falls back to the automatic choice when the click is on the background', () => {
|
|
const b = bounds(detect(cv, plateScene, params({ seed: { x: 5, y: 5 } }))!.outer);
|
|
near(b.minX, 40);
|
|
});
|
|
|
|
it('ignores a seed outside the image', () => {
|
|
const b = bounds(detect(cv, plateScene, params({ seed: { x: -10, y: 500 } }))!.outer);
|
|
near(b.minX, 40);
|
|
});
|
|
|
|
it('needs a seed for a piece touching the photo border', () => {
|
|
const framed = grayImage(200, 160, (x, y) => (inRect(x, y, 0, 30, 120, 130) ? 220 : 30));
|
|
expect(detect(cv, framed, params())).toBeNull();
|
|
const b = bounds(detect(cv, framed, params({ seed: { x: 50, y: 80 } }))!.outer);
|
|
near(b.minX, 0); near(b.maxX, 119);
|
|
});
|
|
|
|
it('returns null when the clicked region is the whole image', () => {
|
|
const allLight = grayImage(100, 100, () => 220);
|
|
expect(detect(cv, allLight, params({ seed: { x: 50, y: 50 } }))).toBeNull();
|
|
});
|
|
|
|
it('returns null when nothing passes the threshold', () => {
|
|
expect(detect(cv, grayImage(100, 100, () => 30), params())).toBeNull();
|
|
});
|
|
|
|
it('applies the margin', () => {
|
|
const b = bounds(detect(cv, plateScene, params({ marginPx: -5 }))!.outer);
|
|
near(b.minX, 45); near(b.maxX, 154);
|
|
});
|
|
|
|
it('removes single-pixel speckle from the piece', () => {
|
|
const speckled = grayImage(200, 160, (x, y) => {
|
|
if (x === 70 && y === 50) return 30; // one dark pixel inside the plate
|
|
return inRect(x, y, 40, 30, 160, 130) ? 220 : 30;
|
|
});
|
|
expect(detect(cv, speckled, params())!.holes).toHaveLength(0);
|
|
});
|
|
|
|
it('fills specks smaller than minHolePx before the margin can grow them', () => {
|
|
const glare = grayImage(200, 160, (x, y) => {
|
|
const plate = inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15) && !inCircle(x, y, 60, 50, 3);
|
|
return plate ? 220 : 30;
|
|
});
|
|
expect(detect(cv, glare, params({ marginPx: -6 }))!.holes).toHaveLength(2);
|
|
expect(detect(cv, glare, params({ marginPx: -6, minHolePx: 10 }))!.holes).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps a long thin hole that is under minHolePx in only one direction', () => {
|
|
const slotted = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) && !inRect(x, y, 60, 60, 120, 66) ? 220 : 30));
|
|
expect(detect(cv, slotted, params({ minHolePx: 10 }))!.holes).toHaveLength(1);
|
|
});
|
|
|
|
it('does not auto-pick a speck when the real piece touches the border', () => {
|
|
const framed = grayImage(400, 300, (x, y) => (inRect(x, y, 0, 60, 240, 260) || inRect(x, y, 330, 100, 340, 110) ? 220 : 30));
|
|
expect(detect(cv, framed, params())).toBeNull();
|
|
});
|
|
|
|
it('reuses a prepared image for repeated detections', () => {
|
|
const prepared = prepareImage(cv, plateScene);
|
|
try {
|
|
expect(preparedThreshold(cv, prepared)).toBe(otsuThreshold(cv, plateScene));
|
|
for (const marginPx of [0, -5]) {
|
|
const direct = detect(cv, plateScene, params({ marginPx }))!;
|
|
const reused = detectPrepared(cv, prepared, params({ marginPx }))!;
|
|
expect(bounds(reused.outer)).toEqual(bounds(direct.outer));
|
|
expect(reused.holes).toHaveLength(direct.holes.length);
|
|
}
|
|
} finally {
|
|
disposePrepared(prepared);
|
|
}
|
|
});
|
|
|
|
it('cuts off textured background (wood grain) that touches a smooth piece', () => {
|
|
// Smooth dark piece x 150..449, y 100..299 on a light floor, with a dark grainy knot touching its top-left.
|
|
const knotted = grayImage(600, 450, (x, y) => {
|
|
if (inRect(x, y, 150, 100, 450, 300)) return 80;
|
|
if (inRect(x, y, 90, 50, 170, 110)) return Math.floor(x / 3) % 2 ? 40 : 110;
|
|
return 200;
|
|
});
|
|
const b = bounds(detect(cv, knotted, params({ threshold: 140 }))!.outer);
|
|
expect(b.minX).toBeGreaterThanOrEqual(144);
|
|
expect(b.minY).toBeGreaterThanOrEqual(94);
|
|
near(b.maxX, 449); near(b.maxY, 299);
|
|
});
|
|
|
|
it('keeps a piece that is itself textured (e.g. plywood grain)', () => {
|
|
const plywood = grayImage(600, 450, (x, y) =>
|
|
inRect(x, y, 150, 100, 450, 300) ? (Math.floor(x / 3) % 2 ? 40 : 110) : 200);
|
|
const b = bounds(detect(cv, plywood, params({ threshold: 140 }))!.outer);
|
|
near(b.minX, 150); near(b.maxX, 449); near(b.minY, 100); near(b.maxY, 299);
|
|
});
|
|
|
|
it('segments once and applies different margins to the same piece mask', () => {
|
|
const prepared = prepareImage(cv, plateScene);
|
|
const piece = segmentPiece(cv, prepared, { threshold: 128, seed: null, minHolePx: 0 })!;
|
|
try {
|
|
for (const marginPx of [0, -5, 3]) {
|
|
const direct = detectPrepared(cv, prepared, params({ marginPx }))!;
|
|
expect(bounds(traceMask(cv, piece, marginPx)!.outer)).toEqual(bounds(direct.outer));
|
|
}
|
|
} finally {
|
|
piece.delete();
|
|
disposePrepared(prepared);
|
|
}
|
|
});
|
|
|
|
it('segmentPiece returns null when nothing is found', () => {
|
|
const prepared = prepareImage(cv, grayImage(100, 100, () => 30));
|
|
try {
|
|
expect(segmentPiece(cv, prepared, { threshold: 128, seed: null, minHolePx: 0 })).toBeNull();
|
|
} finally {
|
|
disposePrepared(prepared);
|
|
}
|
|
});
|
|
});
|