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:
1 parent
8ea525a1e8
commit
077c3cc31d
3 files changed
+60
-15
No files matched your search
@@ -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<CV | null>(null);
|
||||
cvError = $state<string | 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);
|
||||
|
||||
tool = $state<Tool>('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) {
|
||||
|
||||
+28
-8
@@ -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();
|
||||
|
||||
+16
-1
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user