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 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 15:22:36 +02:00
1 parent 8ea525a1e8
commit 077c3cc31d
3 files changed
+60 -15

No files matched your search

+16 -6
View File
@@ -1,4 +1,4 @@
import { detect, otsuThreshold } from './detect'; import { detectPrepared, prepareImage, preparedThreshold, 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';
@@ -16,6 +16,8 @@ export class AppState {
cv = $state.raw<CV | null>(null); cv = $state.raw<CV | null>(null);
cvError = $state<string | null>(null); cvError = $state<string | null>(null);
image = $state.raw<RasterImage | null>(null); image = $state.raw<RasterImage | null>(null);
/** Lighting-corrected grayscale of `image`, computed once per photo; owns WASM memory. */
prepared = $state.raw<PreparedImage | null>(null);
loadError = $state<string | null>(null); loadError = $state<string | null>(null);
tool = $state<Tool>('none'); tool = $state<Tool>('none');
@@ -38,12 +40,13 @@ export class AppState {
marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0); marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0);
shapePx = $derived.by((): Shape | null => { shapePx = $derived.by((): Shape | null => {
const { cv, image } = this; const { cv, image, prepared } = 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);
} }
return detect(cv, image, { if (!prepared) return null;
return detectPrepared(cv, prepared, {
threshold: this.threshold, seed: this.seed, marginPx: this.marginPx, threshold: this.threshold, seed: this.seed, marginPx: this.marginPx,
minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0, minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0,
}); });
@@ -53,7 +56,7 @@ export class AppState {
setCv(cv: CV) { setCv(cv: CV) {
this.cv = cv; this.cv = cv;
if (this.image) this.threshold = otsuThreshold(cv, this.image); this.prepare();
} }
setImage(image: RasterImage) { setImage(image: RasterImage) {
@@ -62,7 +65,14 @@ export class AppState {
this.scalePoints = []; this.scalePoints = [];
this.seed = null; this.seed = null;
this.clearManual(); 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) { async loadFile(file: Blob) {
@@ -75,7 +85,7 @@ export class AppState {
} }
autoThreshold() { 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) { handleImageClick(p: Point) {
+28 -8
View File
@@ -2,8 +2,11 @@ import type { Mat } from '@techstark/opencv-js';
import type { CV } from './opencv-loader'; import type { CV } from './opencv-loader';
import type { DetectParams, Point, RasterImage, Shape } from './types'; import type { DetectParams, Point, RasterImage, Shape } from './types';
/** Blurred, lighting-corrected grayscale; shared by thresholding and Otsu so both see the same pixels. */ /** Blurred, lighting-corrected grayscale plus the background brightness. Caller deletes `gray`. */
function toGray(cv: CV, image: RasterImage): { gray: Mat; background: number } { 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 src = cv.matFromImageData(image as unknown as ImageData);
const gray = new cv.Mat(); const gray = new cv.Mat();
try { try {
@@ -86,12 +89,19 @@ function fitSurface(samples: { t: number[]; v: number }[]): number[] {
} }
export function otsuThreshold(cv: CV, image: RasterImage): 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(); const out = new cv.Mat();
try { 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 { } finally {
gray.delete();
out.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 { 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 mask = new cv.Mat();
const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(5, 5)); const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(5, 5));
const labels = new cv.Mat(); 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_OPEN, kernel);
cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel); cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel);
const count = cv.connectedComponentsWithStats(mask, labels, stats, centroids, 8, cv.CV_32S); 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); const seedDark = seedIsDark(gray, params.threshold, params.seed);
let label = seedDark === null ? null : segment(seedDark, 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); fillSmallHoles(cv, mask, params.minHolePx);
return traceMask(cv, mask, params.marginPx); return traceMask(cv, mask, params.marginPx);
} finally { } finally {
gray.delete();
mask.delete(); mask.delete();
kernel.delete(); kernel.delete();
labels.delete(); labels.delete();
+16 -1
View File
@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, it } from 'vitest'; 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 { 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';
@@ -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)); 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(); 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();
}
});
}); });