fix: apply the safety margin with a distance transform

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 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 15:21:42 +02:00
1 parent 55a25ba978
commit 8ea525a1e8
2 files changed
+33 -7

No files matched your search

+22 -7
View File
@@ -103,14 +103,9 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
const padded = new cv.Mat(); const padded = new cv.Mat();
const contours = new cv.MatVector(); const contours = new cv.MatVector();
const hierarchy = new cv.Mat(); const hierarchy = new cv.Mat();
let kernel: Mat | null = null;
try { try {
cv.copyMakeBorder(mask, padded, pad, pad, pad, pad, cv.BORDER_CONSTANT, new cv.Scalar(0)); cv.copyMakeBorder(mask, padded, pad, pad, pad, pad, cv.BORDER_CONSTANT, new cv.Scalar(0));
if (radius > 0) { if (radius > 0) offsetMask(cv, padded, marginPx < 0 ? -radius : radius);
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);
}
cv.findContours(padded, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE); cv.findContours(padded, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE);
const count = contours.size(); const count = contours.size();
@@ -146,7 +141,27 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
padded.delete(); padded.delete();
contours.delete(); contours.delete();
hierarchy.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();
} }
} }
+11
View File
@@ -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', () => { describe('otsuThreshold', () => {
it('lands between background and piece brightness', () => { it('lands between background and piece brightness', () => {
const image = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) ? 220 : 30)); const image = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));