Fit the background brightness from the photo border and subtract it, so side-lit photos threshold cleanly. The clicked pixel (or, without a click, the border) decides whether the piece is darker or lighter, replacing the invert checkbox. Adds the MDF-on-wood-floor photo as a regression fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
3.6 KiB
TypeScript
120 lines
3.6 KiB
TypeScript
import { detect, otsuThreshold } from './detect';
|
|
import { prepareForExport } from './geometry';
|
|
import { loadPhoto } from './load-image';
|
|
import { shapeFromPolygons } from './margin';
|
|
import type { CV } from './opencv-loader';
|
|
import { scaleFromLine } from './scale';
|
|
import type { Point, RasterImage, Shape } from './types';
|
|
|
|
export type Tool = 'none' | 'scale' | 'seed' | 'draw';
|
|
|
|
const DEFAULT_THRESHOLD = 128;
|
|
const MIN_HOLE_MM = 3;
|
|
|
|
export class AppState {
|
|
// Raw: the cv module and pixel buffers must never be wrapped in reactive proxies.
|
|
cv = $state.raw<CV | null>(null);
|
|
cvError = $state<string | null>(null);
|
|
image = $state.raw<RasterImage | null>(null);
|
|
loadError = $state<string | null>(null);
|
|
|
|
tool = $state<Tool>('none');
|
|
scalePoints = $state<Point[]>([]);
|
|
lengthMm = $state<number | null>(null);
|
|
threshold = $state(DEFAULT_THRESHOLD);
|
|
seed = $state<Point | null>(null);
|
|
marginMm = $state(-2);
|
|
mode = $state<'auto' | 'manual'>('auto');
|
|
manualOuter = $state<Point[]>([]);
|
|
manualHoles = $state<Point[][]>([]);
|
|
drawing = $state<Point[]>([]);
|
|
|
|
pxPerMm = $derived(
|
|
this.scalePoints.length === 2 && this.lengthMm !== null
|
|
? scaleFromLine(this.scalePoints[0], this.scalePoints[1], this.lengthMm)
|
|
: null,
|
|
);
|
|
scaleInvalid = $derived(this.scalePoints.length === 2 && this.lengthMm !== null && this.pxPerMm === null);
|
|
marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0);
|
|
|
|
shapePx = $derived.by((): Shape | null => {
|
|
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);
|
|
}
|
|
return detect(cv, image, {
|
|
threshold: this.threshold, seed: this.seed, marginPx: this.marginPx,
|
|
minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0,
|
|
});
|
|
});
|
|
|
|
exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null);
|
|
|
|
setCv(cv: CV) {
|
|
this.cv = cv;
|
|
if (this.image) this.threshold = otsuThreshold(cv, this.image);
|
|
}
|
|
|
|
setImage(image: RasterImage) {
|
|
this.image = image;
|
|
this.tool = 'none';
|
|
this.scalePoints = [];
|
|
this.seed = null;
|
|
this.clearManual();
|
|
this.threshold = this.cv ? otsuThreshold(this.cv, image) : DEFAULT_THRESHOLD;
|
|
}
|
|
|
|
async loadFile(file: Blob) {
|
|
try {
|
|
this.loadError = null;
|
|
this.setImage(await loadPhoto(file));
|
|
} catch {
|
|
this.loadError = 'Could not read this image file.';
|
|
}
|
|
}
|
|
|
|
autoThreshold() {
|
|
if (this.cv && this.image) this.threshold = otsuThreshold(this.cv, this.image);
|
|
}
|
|
|
|
handleImageClick(p: Point) {
|
|
const image = this.image;
|
|
if (!image || p.x < 0 || p.y < 0 || p.x >= image.width || p.y >= image.height) return;
|
|
switch (this.tool) {
|
|
case 'scale':
|
|
if (this.scalePoints.length === 1) {
|
|
this.scalePoints.push(p);
|
|
this.tool = 'none';
|
|
} else {
|
|
this.scalePoints = [p];
|
|
}
|
|
break;
|
|
case 'seed':
|
|
this.seed = p;
|
|
this.tool = 'none';
|
|
break;
|
|
case 'draw':
|
|
this.drawing.push(p);
|
|
break;
|
|
}
|
|
}
|
|
|
|
finishPolygon() {
|
|
if (this.drawing.length >= 3) {
|
|
if (this.manualOuter.length === 0) this.manualOuter = this.drawing;
|
|
else this.manualHoles.push(this.drawing);
|
|
this.mode = 'manual';
|
|
}
|
|
this.drawing = [];
|
|
}
|
|
|
|
clearManual() {
|
|
this.manualOuter = [];
|
|
this.manualHoles = [];
|
|
this.drawing = [];
|
|
this.mode = 'auto';
|
|
if (this.tool === 'draw') this.tool = 'none';
|
|
}
|
|
}
|