feat: detect piece outline with threshold, seed selection and margin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e4eebaaa2
commit
9e48c5d762
2 files changed
+156
-1
No files matched your search
+69
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Mat } from '@techstark/opencv-js';
|
import type { Mat } from '@techstark/opencv-js';
|
||||||
import type { CV } from './opencv-loader';
|
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. */
|
/** Blurred grayscale; shared by thresholding and Otsu so both see the same pixels. */
|
||||||
function toGray(cv: CV, image: RasterImage): Mat {
|
function toGray(cv: CV, image: RasterImage): Mat {
|
||||||
@@ -79,3 +79,71 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
|
|||||||
kernel?.delete();
|
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;
|
||||||
|
}
|
||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user