fix: drop glare holes before the margin and never auto-pick specks
Filtering holes after the margin let -2 mm grow 1 mm specks past the 3 mm cutoff, and could drop real holes shrunk by a positive margin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
abef0f982b
commit
41baa25a01
7 files changed
+59
-25
No files matched your search
@@ -9,6 +9,7 @@ import type { Point, RasterImage, Shape } from './types';
|
|||||||
export type Tool = 'none' | 'scale' | 'seed' | 'draw';
|
export type Tool = 'none' | 'scale' | 'seed' | 'draw';
|
||||||
|
|
||||||
const DEFAULT_THRESHOLD = 128;
|
const DEFAULT_THRESHOLD = 128;
|
||||||
|
const MIN_HOLE_MM = 3;
|
||||||
|
|
||||||
export class AppState {
|
export class AppState {
|
||||||
// Raw: the cv module and pixel buffers must never be wrapped in reactive proxies.
|
// 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') {
|
if (this.mode === 'manual') {
|
||||||
return shapeFromPolygons(cv, image.width, image.height, { outer: this.manualOuter, holes: this.manualHoles }, this.marginPx);
|
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);
|
exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null);
|
||||||
|
|||||||
+27
-1
@@ -100,6 +100,7 @@ export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape
|
|||||||
const lab = labels.data32S;
|
const lab = labels.data32S;
|
||||||
const m = mask.data;
|
const m = mask.data;
|
||||||
for (let i = 0; i < lab.length; i++) m[i] = lab[i] === label ? 255 : 0;
|
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);
|
return traceMask(cv, mask, params.marginPx);
|
||||||
} finally {
|
} finally {
|
||||||
gray.delete();
|
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 best: number | null = null;
|
||||||
let bestArea = 0;
|
let bestArea = 0;
|
||||||
for (let label = 1; label < count; label++) {
|
for (let label = 1; label < count; label++) {
|
||||||
const b = box(label);
|
const b = box(label);
|
||||||
if (b.left === 0 || b.top === 0 || b.right === width || b.bottom === height) continue;
|
if (b.left === 0 || b.top === 0 || b.right === width || b.bottom === height) continue;
|
||||||
const area = stat(label, cv.CC_STAT_AREA);
|
const area = stat(label, cv.CC_STAT_AREA);
|
||||||
if (area > bestArea) {
|
if (area >= minArea && area > bestArea) {
|
||||||
bestArea = area;
|
bestArea = area;
|
||||||
best = label;
|
best = label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-7
@@ -2,8 +2,6 @@ import type { Point, Shape } from './types';
|
|||||||
|
|
||||||
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
|
||||||
const MIN_HOLE_MM = 3;
|
|
||||||
|
|
||||||
export function bounds(points: Point[]): Bounds {
|
export function bounds(points: Point[]): Bounds {
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
for (const p of points) {
|
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);
|
simplifyRing(ring.map((p) => ({ x: p.x / pxPerMm, y: p.y / pxPerMm })), toleranceMm);
|
||||||
const outer = toMm(shapePx.outer);
|
const outer = toMm(shapePx.outer);
|
||||||
if (outer.length < 3) return null;
|
if (outer.length < 3) return null;
|
||||||
const holes = shapePx.holes.map(toMm).filter((hole) => {
|
const holes = shapePx.holes.map(toMm).filter((hole) => hole.length >= 3);
|
||||||
if (hole.length < 3) return false;
|
|
||||||
const { width, height } = shapeSize({ outer: hole, holes: [] });
|
|
||||||
return width >= MIN_HOLE_MM || height >= MIN_HOLE_MM;
|
|
||||||
});
|
|
||||||
const { minX, minY } = bounds(outer);
|
const { minX, minY } = bounds(outer);
|
||||||
const shift = (ring: Point[]) => ring.map((p) => ({ x: p.x - minX, y: p.y - minY }));
|
const shift = (ring: Point[]) => ring.map((p) => ({ x: p.x - minX, y: p.y - minY }));
|
||||||
return { outer: shift(outer), holes: holes.map(shift) };
|
return { outer: shift(outer), holes: holes.map(shift) };
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ export type DetectParams = {
|
|||||||
seed: Point | null;
|
seed: Point | null;
|
||||||
/** Negative shrinks the piece (and grows holes). */
|
/** Negative shrinks the piece (and grows holes). */
|
||||||
marginPx: number;
|
marginPx: number;
|
||||||
|
/** Holes smaller than this in both directions are treated as glare and filled. */
|
||||||
|
minHolePx: number;
|
||||||
};
|
};
|
||||||
+20
-1
@@ -11,7 +11,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const params = (overrides: Partial<DetectParams> = {}): DetectParams => ({
|
const params = (overrides: Partial<DetectParams> = {}): 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. */
|
/** 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);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -34,7 +34,7 @@ describe.skipIf(cases.length === 0)('detect on real photos', () => {
|
|||||||
it.each(cases)('$file', (c) => {
|
it.each(cases)('$file', (c) => {
|
||||||
const image = readJpeg(dir + c.file);
|
const image = readJpeg(dir + c.file);
|
||||||
const threshold = c.threshold ?? otsuThreshold(cv, image);
|
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();
|
expect(shape).not.toBeNull();
|
||||||
const mm = prepareForExport(shape!, c.pxPerMm)!;
|
const mm = prepareForExport(shape!, c.pxPerMm)!;
|
||||||
const size = shapeSize(mm);
|
const size = shapeSize(mm);
|
||||||
|
|||||||
+3
-14
@@ -53,20 +53,9 @@ describe('prepareForExport', () => {
|
|||||||
expect(mm.outer).toHaveLength(4);
|
expect(mm.outer).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops holes smaller than 3 mm in both directions (glare/speckle)', () => {
|
it('keeps every hole, however small (speck filtering happens before the margin)', () => {
|
||||||
const shapePx = {
|
const shapePx = { outer: denseSquare(0, 0, 400), holes: [denseSquare(100, 100, 8), denseSquare(200, 200, 40)] };
|
||||||
outer: denseSquare(0, 0, 400),
|
expect(prepareForExport(shapePx, 4)!.holes).toHaveLength(2);
|
||||||
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('returns null when the outer ring degenerates', () => {
|
it('returns null when the outer ring degenerates', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user