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:
immortaly007andClaude Opus 5.5 committed 2026-10-15 15:07:16 +02:00
1 parent abef0f982b
commit 41baa25a01
7 files changed
+59 -25

No files matched your search

+5 -1
View File
@@ -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);
+27 -1
View File
@@ -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();
}
}
+1 -7
View File
@@ -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) };
+2
View File
@@ -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;
};