diff --git a/src/lib/app-state.svelte.ts b/src/lib/app-state.svelte.ts index 5f7bbd1..4ce5c7d 100644 --- a/src/lib/app-state.svelte.ts +++ b/src/lib/app-state.svelte.ts @@ -9,6 +9,7 @@ import type { Point, RasterImage, Shape } from './types'; export type Tool = 'none' | 'scale' | 'seed' | 'draw'; const DEFAULT_THRESHOLD = 128; +const MIN_HOLE_MM = 3; export class AppState { // Raw: the cv module and pixel buffers must never be wrapped in reactive proxies. @@ -43,7 +44,10 @@ export class AppState { if (this.mode === 'manual') { return shapeFromPolygons(cv, image.width, image.height, { outer: this.manualOuter, holes: this.manualHoles }, this.marginPx); } - return detect(cv, image, { threshold: this.threshold, invert: this.invert, seed: this.seed, marginPx: this.marginPx }); + return detect(cv, image, { + threshold: this.threshold, invert: this.invert, seed: this.seed, marginPx: this.marginPx, + minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0, + }); }); exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null); diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 2cf9c53..207ae6f 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -100,6 +100,7 @@ export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape const lab = labels.data32S; const m = mask.data; for (let i = 0; i < lab.length; i++) m[i] = lab[i] === label ? 255 : 0; + fillSmallHoles(cv, mask, params.minHolePx); return traceMask(cv, mask, params.marginPx); } finally { gray.delete(); @@ -134,16 +135,41 @@ function pickComponent( } } + // Without a click, specks (dust, grain) must never pass as the piece. + const minArea = 0.01 * width * height; 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) { + if (area >= minArea && area > bestArea) { bestArea = area; best = label; } } return best; } + +/** Fill holes under minHolePx in both directions, before the margin can grow them. */ +function fillSmallHoles(cv: CV, mask: Mat, minHolePx: number) { + if (minHolePx <= 0) return; + const contours = new cv.MatVector(); + const hierarchy = new cv.Mat(); + try { + cv.findContours(mask, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE); + const h = hierarchy.data32S; + for (let i = 0; i < contours.size(); i++) { + if (h[i * 4 + 3] === -1) continue; + const c = contours.get(i); + const rect = cv.boundingRect(c); + c.delete(); + if (rect.width < minHolePx && rect.height < minHolePx) { + cv.drawContours(mask, contours, i, new cv.Scalar(255), cv.FILLED); + } + } + } finally { + contours.delete(); + hierarchy.delete(); + } +} diff --git a/src/lib/geometry.ts b/src/lib/geometry.ts index 4131774..d5b7d52 100644 --- a/src/lib/geometry.ts +++ b/src/lib/geometry.ts @@ -2,8 +2,6 @@ import type { Point, Shape } from './types'; export type Bounds = { minX: number; minY: number; maxX: number; maxY: number }; -const MIN_HOLE_MM = 3; - export function bounds(points: Point[]): Bounds { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of points) { @@ -70,11 +68,7 @@ export function prepareForExport(shapePx: Shape, pxPerMm: number, toleranceMm = simplifyRing(ring.map((p) => ({ x: p.x / pxPerMm, y: p.y / pxPerMm })), toleranceMm); const outer = toMm(shapePx.outer); if (outer.length < 3) return null; - const holes = shapePx.holes.map(toMm).filter((hole) => { - if (hole.length < 3) return false; - const { width, height } = shapeSize({ outer: hole, holes: [] }); - return width >= MIN_HOLE_MM || height >= MIN_HOLE_MM; - }); + const holes = shapePx.holes.map(toMm).filter((hole) => hole.length >= 3); const { minX, minY } = bounds(outer); const shift = (ring: Point[]) => ring.map((p) => ({ x: p.x - minX, y: p.y - minY })); return { outer: shift(outer), holes: holes.map(shift) }; diff --git a/src/lib/types.ts b/src/lib/types.ts index c348dd8..532b689 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,4 +12,6 @@ export type DetectParams = { seed: Point | null; /** Negative shrinks the piece (and grows holes). */ marginPx: number; + /** Holes smaller than this in both directions are treated as glare and filled. */ + minHolePx: number; }; diff --git a/tests/detect.test.ts b/tests/detect.test.ts index 0c06ed3..b6dcc10 100644 --- a/tests/detect.test.ts +++ b/tests/detect.test.ts @@ -11,7 +11,7 @@ beforeAll(async () => { }); const params = (overrides: Partial = {}): DetectParams => ({ - threshold: 128, invert: false, seed: null, marginPx: 0, ...overrides, + threshold: 128, invert: false, 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. */ @@ -84,4 +84,23 @@ describe('detect', () => { }); 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(); + }); }); diff --git a/tests/fixtures.test.ts b/tests/fixtures.test.ts index 52800d8..8c0250d 100644 --- a/tests/fixtures.test.ts +++ b/tests/fixtures.test.ts @@ -34,7 +34,7 @@ describe.skipIf(cases.length === 0)('detect on real photos', () => { it.each(cases)('$file', (c) => { const image = readJpeg(dir + c.file); const threshold = c.threshold ?? otsuThreshold(cv, image); - const shape = detect(cv, image, { threshold, invert: c.invert, seed: c.seed, marginPx: 0 }); + const shape = detect(cv, image, { threshold, invert: c.invert, seed: c.seed, marginPx: 0, minHolePx: 3 * c.pxPerMm }); expect(shape).not.toBeNull(); const mm = prepareForExport(shape!, c.pxPerMm)!; const size = shapeSize(mm); diff --git a/tests/geometry.test.ts b/tests/geometry.test.ts index 75a7492..6985528 100644 --- a/tests/geometry.test.ts +++ b/tests/geometry.test.ts @@ -53,20 +53,9 @@ describe('prepareForExport', () => { expect(mm.outer).toHaveLength(4); }); - it('drops holes smaller than 3 mm in both directions (glare/speckle)', () => { - const shapePx = { - outer: denseSquare(0, 0, 400), - holes: [denseSquare(100, 100, 8), denseSquare(200, 200, 40)], // 2 mm and 10 mm at 4 px/mm - }; - const mm = prepareForExport(shapePx, 4)!; - expect(mm.holes).toHaveLength(1); - expect(shapeSize({ outer: mm.holes[0], holes: [] }).width).toBeCloseTo(10); - }); - - it('keeps a long thin hole that is under 3 mm in only one direction', () => { - const slot: Point[] = [{ x: 100, y: 100 }, { x: 300, y: 100 }, { x: 300, y: 104 }, { x: 100, y: 104 }]; - const mm = prepareForExport({ outer: denseSquare(0, 0, 400), holes: [slot] }, 4)!; - expect(mm.holes).toHaveLength(1); + it('keeps every hole, however small (speck filtering happens before the margin)', () => { + const shapePx = { outer: denseSquare(0, 0, 400), holes: [denseSquare(100, 100, 8), denseSquare(200, 200, 40)] }; + expect(prepareForExport(shapePx, 4)!.holes).toHaveLength(2); }); it('returns null when the outer ring degenerates', () => {