diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 295713e..2cf9c53 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -1,6 +1,6 @@ import type { Mat } from '@techstark/opencv-js'; import type { CV } from './opencv-loader'; -import type { Point, RasterImage, Shape } from './types'; +import type { DetectParams, 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 { @@ -79,3 +79,71 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null { kernel?.delete(); } } + +export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape | null { + const gray = toGray(cv, image); + const mask = new cv.Mat(); + const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(5, 5)); + const labels = new cv.Mat(); + const stats = new cv.Mat(); + const centroids = new cv.Mat(); + try { + const type = params.invert ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY; + cv.threshold(gray, mask, params.threshold, 255, type); + cv.morphologyEx(mask, mask, cv.MORPH_OPEN, kernel); + cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel); + const count = cv.connectedComponentsWithStats(mask, labels, stats, centroids, 8, cv.CV_32S); + + const label = pickComponent(cv, labels, stats, count, image.width, image.height, params.seed); + if (label === null) return null; + + const lab = labels.data32S; + const m = mask.data; + for (let i = 0; i < lab.length; i++) m[i] = lab[i] === label ? 255 : 0; + return traceMask(cv, mask, params.marginPx); + } finally { + gray.delete(); + mask.delete(); + kernel.delete(); + labels.delete(); + stats.delete(); + centroids.delete(); + } +} + +function pickComponent( + cv: CV, labels: Mat, stats: Mat, count: number, width: number, height: number, seed: Point | null, +): number | null { + const stat = (label: number, field: number) => stats.intAt(label, field); + const box = (label: number) => { + const left = stat(label, cv.CC_STAT_LEFT); + const top = stat(label, cv.CC_STAT_TOP); + return { left, top, right: left + stat(label, cv.CC_STAT_WIDTH), bottom: top + stat(label, cv.CC_STAT_HEIGHT) }; + }; + + if (seed) { + const x = Math.round(seed.x); + const y = Math.round(seed.y); + if (x >= 0 && y >= 0 && x < width && y < height) { + const label = labels.intAt(y, x); + if (label !== 0) { + const b = box(label); + const coversImage = b.left === 0 && b.top === 0 && b.right === width && b.bottom === height; + return coversImage ? null : label; + } + } + } + + let best: number | null = null; + let bestArea = 0; + for (let label = 1; label < count; label++) { + const b = box(label); + if (b.left === 0 || b.top === 0 || b.right === width || b.bottom === height) continue; + const area = stat(label, cv.CC_STAT_AREA); + if (area > bestArea) { + bestArea = area; + best = label; + } + } + return best; +} diff --git a/tests/detect.test.ts b/tests/detect.test.ts new file mode 100644 index 0000000..0c06ed3 --- /dev/null +++ b/tests/detect.test.ts @@ -0,0 +1,87 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { detect } 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 => ({ + threshold: 128, invert: false, seed: null, marginPx: 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('handles a dark piece on a light background with invert', () => { + const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30)); + expect(detect(cv, inverted, params())).toBeNull(); + const b = bounds(detect(cv, inverted, params({ invert: true }))!.outer); + near(b.minX, 40); near(b.maxX, 159); + }); + + 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 seed is on background or in a hole', () => { + for (const seed of [{ x: 5, y: 5 }, { x: 100, y: 80 }]) { + const b = bounds(detect(cv, plateScene, params({ seed }))!.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); + }); +});