From 353577d5ec466fb3774221cc5197f05e019f0ac7 Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 15:30:12 +0200 Subject: [PATCH] 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 --- src/lib/app-state.svelte.ts | 28 +++++++++++++++++++++------- src/lib/detect.ts | 20 ++++++++++++++++++-- tests/detect.test.ts | 25 ++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/lib/app-state.svelte.ts b/src/lib/app-state.svelte.ts index 6122d2f..bac3dad 100644 --- a/src/lib/app-state.svelte.ts +++ b/src/lib/app-state.svelte.ts @@ -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 { loadPhoto } from './load-image'; import { shapeFromPolygons } from './margin'; @@ -39,17 +40,30 @@ export class AppState { scaleInvalid = $derived(this.scalePoints.length === 2 && this.lengthMm !== null && this.pxPerMm === null); 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 => { - const { cv, image, prepared } = this; + const { cv, image } = this; if (!cv || !image) return null; if (this.mode === 'manual') { return shapeFromPolygons(cv, image.width, image.height, { outer: this.manualOuter, holes: this.manualHoles }, this.marginPx); } - if (!prepared) return null; - return detectPrepared(cv, prepared, { - threshold: this.threshold, seed: this.seed, marginPx: this.marginPx, - minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0, - }); + return this.pieceMask ? traceMask(cv, this.pieceMask, this.marginPx) : null; }); exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null); diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 2e9ed8a..9e652b5 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -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 { + 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): Mat | null { const { gray, background } = prepared; const width = gray.cols; const height = gray.rows; @@ -226,6 +240,7 @@ export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectPa const labels = new cv.Mat(); const stats = new cv.Mat(); const centroids = new cv.Mat(); + let found = false; try { const segment = (dark: boolean, seed: Point | null) => { 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; trimTexturedEdges(cv, mask, prepared.texture, prepared.textureRadius); fillSmallHoles(cv, mask, params.minHolePx); - return traceMask(cv, mask, params.marginPx); + found = true; + return mask; } finally { - mask.delete(); + if (!found) mask.delete(); kernel.delete(); labels.delete(); stats.delete(); diff --git a/tests/detect.test.ts b/tests/detect.test.ts index e519685..fd27c10 100644 --- a/tests/detect.test.ts +++ b/tests/detect.test.ts @@ -1,5 +1,5 @@ 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 { loadOpenCV, type CV } from '../src/lib/opencv-loader'; import type { DetectParams } from '../src/lib/types'; @@ -149,4 +149,27 @@ describe('detect', () => { 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); }); + + 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); + } + }); });