From 077c3cc31db2dc9c56a2c7d79355c904b9acd244 Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 15:22:36 +0200 Subject: [PATCH] perf: prepare the lighting-corrected image once per photo Threshold, click, scale and margin changes now reuse it, so each update skips the ~200 ms grayscale and lighting fit. Co-Authored-By: Claude Opus 5.5 --- src/lib/app-state.svelte.ts | 22 ++++++++++++++++------ src/lib/detect.ts | 36 ++++++++++++++++++++++++++++-------- tests/detect.test.ts | 17 ++++++++++++++++- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/lib/app-state.svelte.ts b/src/lib/app-state.svelte.ts index 10b394b..4afe5a1 100644 --- a/src/lib/app-state.svelte.ts +++ b/src/lib/app-state.svelte.ts @@ -1,4 +1,4 @@ -import { detect, otsuThreshold } from './detect'; +import { detectPrepared, prepareImage, preparedThreshold, type PreparedImage } from './detect'; import { prepareForExport } from './geometry'; import { loadPhoto } from './load-image'; import { shapeFromPolygons } from './margin'; @@ -16,6 +16,8 @@ export class AppState { cv = $state.raw(null); cvError = $state(null); image = $state.raw(null); + /** Lighting-corrected grayscale of `image`, computed once per photo; owns WASM memory. */ + prepared = $state.raw(null); loadError = $state(null); tool = $state('none'); @@ -38,12 +40,13 @@ export class AppState { marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0); shapePx = $derived.by((): Shape | null => { - const { cv, image } = this; + const { cv, image, prepared } = 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); } - return detect(cv, image, { + 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, }); @@ -53,7 +56,7 @@ export class AppState { setCv(cv: CV) { this.cv = cv; - if (this.image) this.threshold = otsuThreshold(cv, this.image); + this.prepare(); } setImage(image: RasterImage) { @@ -62,7 +65,14 @@ export class AppState { this.scalePoints = []; this.seed = null; this.clearManual(); - this.threshold = this.cv ? otsuThreshold(this.cv, image) : DEFAULT_THRESHOLD; + this.prepare(); + } + + private prepare() { + this.prepared?.gray.delete(); + const { cv, image } = this; + this.prepared = cv && image ? prepareImage(cv, image) : null; + this.threshold = cv && this.prepared ? preparedThreshold(cv, this.prepared) : DEFAULT_THRESHOLD; } async loadFile(file: Blob) { @@ -75,7 +85,7 @@ export class AppState { } autoThreshold() { - if (this.cv && this.image) this.threshold = otsuThreshold(this.cv, this.image); + if (this.cv && this.prepared) this.threshold = preparedThreshold(this.cv, this.prepared); } handleImageClick(p: Point) { diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 8783aa3..e1ef922 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -2,8 +2,11 @@ import type { Mat } from '@techstark/opencv-js'; import type { CV } from './opencv-loader'; import type { DetectParams, Point, RasterImage, Shape } from './types'; -/** Blurred, lighting-corrected grayscale; shared by thresholding and Otsu so both see the same pixels. */ -function toGray(cv: CV, image: RasterImage): { gray: Mat; background: number } { +/** Blurred, lighting-corrected grayscale plus the background brightness. Caller deletes `gray`. */ +export type PreparedImage = { gray: Mat; background: number }; + +/** The per-photo part of detection; reuse it across threshold, click and margin changes. */ +export function prepareImage(cv: CV, image: RasterImage): PreparedImage { const src = cv.matFromImageData(image as unknown as ImageData); const gray = new cv.Mat(); try { @@ -86,12 +89,19 @@ function fitSurface(samples: { t: number[]; v: number }[]): number[] { } export function otsuThreshold(cv: CV, image: RasterImage): number { - const { gray } = toGray(cv, image); + const prepared = prepareImage(cv, image); + try { + return preparedThreshold(cv, prepared); + } finally { + prepared.gray.delete(); + } +} + +export function preparedThreshold(cv: CV, prepared: PreparedImage): number { const out = new cv.Mat(); try { - return Math.round(cv.threshold(gray, out, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU)); + return Math.round(cv.threshold(prepared.gray, out, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU)); } finally { - gray.delete(); out.delete(); } } @@ -166,7 +176,18 @@ function offsetMask(cv: CV, mask: Mat, radius: number) { } export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape | null { - const { gray, background } = toGray(cv, image); + const prepared = prepareImage(cv, image); + try { + return detectPrepared(cv, prepared, params); + } finally { + prepared.gray.delete(); + } +} + +export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectParams): Shape | null { + const { gray, background } = prepared; + const width = gray.cols; + const height = gray.rows; const mask = new cv.Mat(); const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(5, 5)); const labels = new cv.Mat(); @@ -178,7 +199,7 @@ export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape cv.morphologyEx(mask, mask, cv.MORPH_OPEN, kernel); cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel); const count = cv.connectedComponentsWithStats(mask, labels, stats, centroids, 8, cv.CV_32S); - return pickComponent(cv, labels, stats, count, image.width, image.height, seed); + return pickComponent(cv, labels, stats, count, width, height, seed); }; const seedDark = seedIsDark(gray, params.threshold, params.seed); let label = seedDark === null ? null : segment(seedDark, params.seed); @@ -192,7 +213,6 @@ export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape fillSmallHoles(cv, mask, params.minHolePx); return traceMask(cv, mask, params.marginPx); } finally { - gray.delete(); mask.delete(); kernel.delete(); labels.delete(); diff --git a/tests/detect.test.ts b/tests/detect.test.ts index a3a18ba..bebc585 100644 --- a/tests/detect.test.ts +++ b/tests/detect.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { detect, otsuThreshold } from '../src/lib/detect'; +import { detect, detectPrepared, otsuThreshold, prepareImage, preparedThreshold } 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'; @@ -114,4 +114,19 @@ describe('detect', () => { const framed = grayImage(400, 300, (x, y) => (inRect(x, y, 0, 60, 240, 260) || inRect(x, y, 330, 100, 340, 110) ? 220 : 30)); expect(detect(cv, framed, params())).toBeNull(); }); + + it('reuses a prepared image for repeated detections', () => { + const prepared = prepareImage(cv, plateScene); + try { + expect(preparedThreshold(cv, prepared)).toBe(otsuThreshold(cv, plateScene)); + for (const marginPx of [0, -5]) { + const direct = detect(cv, plateScene, params({ marginPx }))!; + const reused = detectPrepared(cv, prepared, params({ marginPx }))!; + expect(bounds(reused.outer)).toEqual(bounds(direct.outer)); + expect(reused.holes).toHaveLength(direct.holes.length); + } + } finally { + prepared.gray.delete(); + } + }); });