From 8ea525a1e8a835d3497777d0199f323c1a18b52e Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 15:21:42 +0200 Subject: [PATCH] fix: apply the safety margin with a distance transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An elliptical erode kernel costs radius² per pixel. While typing a length, an intermediate value like "3" mm made the margin hundreds of pixels and froze the page for minutes. Co-Authored-By: Claude Opus 5.5 --- src/lib/detect.ts | 29 ++++++++++++++++++++++------- tests/trace-mask.test.ts | 11 +++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 4a498f5..8783aa3 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -103,14 +103,9 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null { const padded = new cv.Mat(); const contours = new cv.MatVector(); const hierarchy = new cv.Mat(); - let kernel: Mat | null = null; try { cv.copyMakeBorder(mask, padded, pad, pad, pad, pad, cv.BORDER_CONSTANT, new cv.Scalar(0)); - if (radius > 0) { - kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(2 * radius + 1, 2 * radius + 1)); - if (marginPx < 0) cv.erode(padded, padded, kernel); - else cv.dilate(padded, padded, kernel); - } + if (radius > 0) offsetMask(cv, padded, marginPx < 0 ? -radius : radius); cv.findContours(padded, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE); const count = contours.size(); @@ -146,7 +141,27 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null { padded.delete(); contours.delete(); hierarchy.delete(); - kernel?.delete(); + } +} + +/** + * Erodes (negative) or dilates (positive) a mask by a radius, in place. Uses a distance transform: + * an elliptical kernel costs radius² per pixel and froze the UI for large margins. + */ +function offsetMask(cv: CV, mask: Mat, radius: number) { + const source = new cv.Mat(); + const dist = new cv.Mat(); + try { + // Dilating the piece is eroding the background, so measure from the other side. + if (radius > 0) cv.bitwise_not(mask, source); + else mask.copyTo(source); + cv.distanceTransform(source, dist, cv.DIST_L2, cv.DIST_MASK_PRECISE); + const r = Math.abs(radius); + cv.threshold(dist, dist, r, 255, radius > 0 ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY); + dist.convertTo(mask, cv.CV_8U); + } finally { + source.delete(); + dist.delete(); } } diff --git a/tests/trace-mask.test.ts b/tests/trace-mask.test.ts index df41cc7..7bc295f 100644 --- a/tests/trace-mask.test.ts +++ b/tests/trace-mask.test.ts @@ -85,6 +85,17 @@ describe('traceMask', () => { }); }); +describe('traceMask performance', () => { + it('handles a huge margin quickly (e.g. while a length is half-typed)', () => { + const m = maskFrom(cv, 2000, 1500, (x, y) => inRect(x, y, 200, 200, 1800, 1300)); + mats.push(m); + const start = performance.now(); + const shape = traceMask(cv, m, -300)!; + expect(performance.now() - start).toBeLessThan(1500); + expect(Math.abs(bounds(shape.outer).minX - 500)).toBeLessThanOrEqual(2); + }); +}); + describe('otsuThreshold', () => { it('lands between background and piece brightness', () => { const image = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));