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 { 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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user