Compare commits

..
11 Commits
Author SHA1 Message Date
immortaly007andClaude Opus 5.5 c1183ce85c chore: deploy to Cloudflare Pages with wrangler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:28:34 +02:00
immortaly007andClaude Opus 5.5 98285d0539 feat: style the UI with Oat and make the empty canvas a click-to-load drop zone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:43:06 +02:00
immortaly007andClaude Opus 5.5 353577d5ec perf: cache the detected piece so margin changes only re-trace it
Margin slider steps drop from ~800 ms to under 70 ms in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:30:12 +02:00
immortaly007andClaude Opus 5.5 e925ce66d5 feat: trim textured background (wood grain) touching a smooth piece
Keep the piece's smooth core, then grow it back to restore the true edges.
Removes the wood-knot and grain leaks on the MDF-on-floor photo. Pieces that
are textured themselves are left untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:25:53 +02:00
immortaly007andClaude Opus 5.5 077c3cc31d 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>
2026-10-15 15:22:36 +02:00
immortaly007andClaude Opus 5.5 8ea525a1e8 fix: apply the safety margin with a distance transform
An elliptical erode kernel costs radius² per pixel. While typing a length,
an intermediate value like "3" mm made the margin hundreds of pixels and
froze the page for minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:21:42 +02:00
immortaly007andClaude Opus 5.5 55a25ba978 feat: correct uneven lighting and pick polarity automatically
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>
2026-10-15 15:12:16 +02:00
immortaly007andClaude Opus 5.5 41baa25a01 fix: drop glare holes before the margin and never auto-pick specks
Filtering holes after the margin let -2 mm grow 1 mm specks past the
3 mm cutoff, and could drop real holes shrunk by a positive margin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:07:16 +02:00
immortaly007andClaude Opus 5.5 abef0f982b feat: add step sidebar with scale, detection, margin and export controls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:57:22 +02:00
immortaly007andClaude Opus 5.5 bec28fd0c1 feat: add zoomable image canvas with detection overlays
Load OpenCV in the browser via a script tag: the bundler's CJS interop
copies the module's then() onto the import namespace, which never settles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:55:55 +02:00
immortaly007andClaude Opus 5.5 4b19c7ea4e feat: add AppState with click routing and derived detection/export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:46:28 +02:00
22 changed files with 2505 additions and 68 deletions

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
node_modules
dist
.wrangler
+25
View File
@@ -0,0 +1,25 @@
# Scrap Tracer
Turn a phone photo of a laser-cut scrap piece into a 1:1 DXF/SVG outline, so you can check in
RDWorks (or LightBurn/Inkscape) whether your design fits on it. Built for Makerspace Delft.
## Use
1. Photograph the piece from straight above, on a contrasting surface, with something of known length visible.
2. Load the photo, click **Measure**, click both ends of the known length, and enter it in mm.
3. Check the green outline. If needed, click the piece (this also tells the app whether
it is darker or lighter than the background), adjust the threshold, or draw the outline by hand.
4. Set the safety margin (default −2 mm shrinks the usable area a little).
5. Download DXF and import it in your laser software at 1:1, in mm.
## Develop
npm install
npm run dev # local dev server
npm test # unit tests (Vitest, OpenCV runs headless in Node)
npm run check # type check
npm run build # static build in dist/
## Deploy (Cloudflare Pages)
Build command `npm run build`, output directory `dist`. No server or environment variables needed.
+1408 -1
View File
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -8,9 +8,11 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
"test": "vitest run",
"deploy": "vite build && wrangler pages deploy dist --project-name scrap-tracer"
},
"dependencies": {
"@knadh/oat": "0.8.3",
"@techstark/opencv-js": "4.12.0-release.1"
},
"devDependencies": {
@@ -22,6 +24,7 @@
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.4",
"vitest": "^5.0.4"
"vitest": "^5.0.4",
"wrangler": "^4.151.0"
}
}
+85 -2
View File
@@ -1,3 +1,86 @@
<script lang="ts"></script>
<script lang="ts">
import { onMount } from 'svelte';
import ImageCanvas from './components/ImageCanvas.svelte';
import Sidebar from './components/Sidebar.svelte';
import { AppState } from './lib/app-state.svelte';
import { loadOpenCV } from './lib/opencv-loader';
<h1>Scrap Tracer</h1>
const app = new AppState();
onMount(() => {
loadOpenCV().then(
({ cv }) => app.setCv(cv),
() => (app.cvError = 'Could not load image processing (OpenCV).'),
);
});
function ondrop(e: DragEvent) {
e.preventDefault();
const file = e.dataTransfer?.files[0];
if (file) app.loadFile(file);
}
function onFile(e: Event & { currentTarget: HTMLInputElement }) {
const file = e.currentTarget.files?.[0];
if (file) app.loadFile(file);
}
</script>
<div class="layout">
<aside>
<Sidebar {app} />
</aside>
<main ondragover={(e) => e.preventDefault()} {ondrop}>
{#if app.image}
<ImageCanvas {app} />
{:else}
<label class="dropzone">
<input type="file" accept="image/*" hidden onchange={onFile} />
<strong>Drop a photo of a scrap piece here</strong>
<span>or click to choose a file</span>
</label>
{/if}
</main>
</div>
<style>
:global(body) {
margin: 0;
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
height: 100vh;
}
aside {
overflow-y: auto;
border-right: 1px solid var(--border);
padding: 16px;
}
main {
position: relative;
background: #2b2b2b;
}
.dropzone {
position: absolute;
inset: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 2px dashed #555;
border-radius: 12px;
color: #bbb;
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s;
}
.dropzone:hover {
border-color: #888;
background: rgba(255, 255, 255, 0.03);
}
.dropzone span {
color: #888;
font-size: 0.9rem;
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import type { AppState } from '../lib/app-state.svelte';
import type { Point, Shape } from '../lib/types';
import { fitView, screenToImage, zoomAt, type View } from '../lib/view';
let { app }: { app: AppState } = $props();
let container: HTMLDivElement;
let canvas: HTMLCanvasElement;
let size = $state({ width: 0, height: 0 });
let view = $state<View>({ scale: 1, offsetX: 0, offsetY: 0 });
let drag: { start: Point; last: Point; moved: boolean } | null = null;
const photo = $derived.by(() => {
const image = app.image;
if (!image) return null;
const c = document.createElement('canvas');
c.width = image.width;
c.height = image.height;
c.getContext('2d')!.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
return c;
});
$effect(() => {
const observer = new ResizeObserver(([entry]) => {
size = { width: Math.floor(entry.contentRect.width), height: Math.floor(entry.contentRect.height) };
});
observer.observe(container);
return () => observer.disconnect();
});
$effect(() => {
if (app.image && size.width > 0 && size.height > 0) {
view = fitView(app.image.width, app.image.height, size.width, size.height);
}
});
// Registered manually: wheel listeners must be non-passive to stop page scrolling.
$effect(() => {
const onwheel = (e: WheelEvent) => {
e.preventDefault();
view = zoomAt(view, local(e), Math.exp(-e.deltaY * 0.001));
};
canvas.addEventListener('wheel', onwheel, { passive: false });
return () => canvas.removeEventListener('wheel', onwheel);
});
$effect(() => {
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext('2d')!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!photo) return;
ctx.setTransform(view.scale, 0, 0, view.scale, view.offsetX, view.offsetY);
ctx.drawImage(photo, 0, 0);
const px = 1 / view.scale; // one screen pixel in image units
if (app.shapePx) drawShape(ctx, app.shapePx, px);
if (app.mode === 'manual') {
for (const ring of [app.manualOuter, ...app.manualHoles]) drawPolyline(ctx, ring, true, '#ff9800', px, [6 * px, 4 * px]);
}
if (app.drawing.length > 0) drawPolyline(ctx, app.drawing, false, '#ff9800', px, []);
drawScale(ctx, app.scalePoints, app.lengthMm, px);
if (app.seed) drawSeed(ctx, app.seed, px);
});
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape, px: number) {
ctx.beginPath();
for (const ring of [shape.outer, ...shape.holes]) traceRing(ctx, ring, true);
ctx.fillStyle = 'rgba(0, 200, 83, 0.25)';
ctx.fill('evenodd');
ctx.strokeStyle = '#00c853';
ctx.lineWidth = 2 * px;
ctx.setLineDash([]);
ctx.stroke();
}
function drawPolyline(ctx: CanvasRenderingContext2D, points: Point[], closed: boolean, color: string, px: number, dash: number[]) {
ctx.beginPath();
traceRing(ctx, points, closed);
ctx.strokeStyle = color;
ctx.lineWidth = 2 * px;
ctx.setLineDash(dash);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color;
for (const p of points) {
ctx.beginPath();
ctx.arc(p.x, p.y, 3 * px, 0, Math.PI * 2);
ctx.fill();
}
}
function traceRing(ctx: CanvasRenderingContext2D, ring: Point[], closed: boolean) {
ring.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
if (closed) ctx.closePath();
}
function drawScale(ctx: CanvasRenderingContext2D, points: Point[], lengthMm: number | null, px: number) {
if (points.length === 0) return;
drawPolyline(ctx, points, false, '#e53935', px, []);
if (points.length === 2 && lengthMm) {
const mid = { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };
ctx.font = `${14 * px}px sans-serif`;
ctx.fillStyle = '#e53935';
ctx.fillText(`${lengthMm} mm`, mid.x + 6 * px, mid.y - 6 * px);
}
}
function drawSeed(ctx: CanvasRenderingContext2D, p: Point, px: number) {
const r = 8 * px;
ctx.beginPath();
ctx.moveTo(p.x - r, p.y);
ctx.lineTo(p.x + r, p.y);
ctx.moveTo(p.x, p.y - r);
ctx.lineTo(p.x, p.y + r);
ctx.strokeStyle = '#2962ff';
ctx.lineWidth = 2 * px;
ctx.stroke();
}
function local(e: PointerEvent | WheelEvent): Point {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onpointerdown(e: PointerEvent) {
canvas.setPointerCapture(e.pointerId);
const p = local(e);
drag = { start: p, last: p, moved: false };
}
function onpointermove(e: PointerEvent) {
if (!drag) return;
const p = local(e);
if (Math.hypot(p.x - drag.start.x, p.y - drag.start.y) > 4) drag.moved = true;
if (drag.moved) {
view = { ...view, offsetX: view.offsetX + p.x - drag.last.x, offsetY: view.offsetY + p.y - drag.last.y };
}
drag.last = p;
}
function onpointerup(e: PointerEvent) {
if (drag && !drag.moved) app.handleImageClick(screenToImage(local(e), view));
drag = null;
}
</script>
<div class="canvas-wrap" bind:this={container}>
<canvas
bind:this={canvas}
class:picking={app.tool !== 'none'}
{onpointerdown}
{onpointermove}
{onpointerup}
></canvas>
</div>
<style>
.canvas-wrap {
position: absolute;
inset: 0;
overflow: hidden;
}
canvas {
display: block;
cursor: grab;
touch-action: none;
}
canvas.picking {
cursor: crosshair;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import type { AppState, Tool } from '../lib/app-state.svelte';
import { downloadText } from '../lib/download';
import { toDxf } from '../lib/export-dxf';
import { toSvg } from '../lib/export-svg';
import { scrapFilename, shapeSize } from '../lib/geometry';
let { app }: { app: AppState } = $props();
let fileInput: HTMLInputElement;
const size = $derived(app.exportShape ? shapeSize(app.exportShape) : null);
const nothingDetected = $derived(app.cv !== null && app.image !== null && app.shapePx === null);
function toggle(tool: Tool) {
app.tool = app.tool === tool ? 'none' : tool;
}
function onFile(e: Event & { currentTarget: HTMLInputElement }) {
const file = e.currentTarget.files?.[0];
if (file) app.loadFile(file);
}
function exportAs(ext: 'dxf' | 'svg') {
const shape = app.exportShape;
if (!shape) return;
const text = ext === 'dxf' ? toDxf(shape) : toSvg(shape);
downloadText(scrapFilename(shape, ext), text, ext === 'dxf' ? 'application/dxf' : 'image/svg+xml');
}
</script>
<h1>Scrap Tracer</h1>
{#if app.cvError}
<p class="error">{app.cvError} <button class="outline small" onclick={() => location.reload()}>Reload</button></p>
{:else if !app.cv}
<p class="muted">Loading image processing…</p>
{/if}
<section>
<h2>1. Photo</h2>
<button class="outline" onclick={() => fileInput.click()}>{app.image ? 'Replace photo' : 'Load photo'}</button>
<input bind:this={fileInput} type="file" accept="image/*" hidden onchange={onFile} />
{#if app.loadError}<p class="error">{app.loadError}</p>{/if}
</section>
<section class:disabled={!app.image}>
<h2>2. Scale</h2>
<p class="muted">Click both ends of something whose length you know.</p>
<div class="row">
<button class:outline={app.tool !== 'scale'} onclick={() => toggle('scale')} disabled={!app.image}>
{app.scalePoints.length === 2 ? 'Re-measure' : 'Measure'}
</button>
<label class="inline">Length <input type="number" min="1" step="any" bind:value={app.lengthMm} /> mm</label>
</div>
{#if app.scaleInvalid}
<p class="error">Enter a length above 0 and pick two different points.</p>
{:else if app.pxPerMm}
<p class="muted">{app.pxPerMm.toFixed(2)} px/mm</p>
{/if}
</section>
<section class:disabled={!app.image}>
<h2>3. Outline</h2>
{#if app.mode === 'auto'}
<label>
<span class="label-row">Threshold <span class="value">{app.threshold}</span></span>
<input type="range" min="0" max="255" bind:value={app.threshold} />
</label>
<div class="row">
<button class="outline" onclick={() => app.autoThreshold()} disabled={!app.cv || !app.image}>Auto</button>
<button class:outline={app.tool !== 'seed'} onclick={() => toggle('seed')} disabled={!app.image}>Click to pick piece</button>
</div>
{/if}
<div class="row">
<button class:outline={app.tool !== 'draw'} onclick={() => toggle('draw')} disabled={!app.image}>
{app.manualOuter.length === 0 ? 'Draw outline manually' : 'Draw a hole'}
</button>
{#if app.drawing.length > 0}
<button onclick={() => app.finishPolygon()}>Finish {app.manualOuter.length === 0 ? 'outline' : 'hole'}</button>
{/if}
{#if app.mode === 'manual' || app.drawing.length > 0}
<button class="ghost" onclick={() => app.clearManual()}>Back to automatic</button>
{/if}
</div>
{#if nothingDetected}
<p class="warning">Nothing detected, try adjusting the threshold, clicking the piece, or draw manually.</p>
{/if}
</section>
<section class:disabled={!app.pxPerMm}>
<h2>4. Safety margin</h2>
<label>
<span class="label-row">Margin <span class="value">{app.marginMm} mm</span></span>
<input type="range" min="-10" max="5" step="0.5" bind:value={app.marginMm} disabled={!app.pxPerMm} />
</label>
{#if !app.pxPerMm}<p class="muted">Set the scale first.</p>{/if}
</section>
<section>
<h2>5. Export</h2>
{#if size}<p>Piece: {Math.round(size.width)} × {Math.round(size.height)} mm</p>{/if}
<div class="row">
<button onclick={() => exportAs('dxf')} disabled={!app.exportShape}>Download DXF</button>
<button class="outline" onclick={() => exportAs('svg')} disabled={!app.exportShape}>Download SVG</button>
</div>
{#if !app.exportShape}
<p class="muted">{app.pxPerMm ? 'No outline to export yet.' : 'Set the scale first.'}</p>
{/if}
</section>
<style>
h1 { font-size: 1.3rem; margin: 0 0 12px; }
h2 { font-size: 1rem; margin: 0; }
p { margin: 0; }
section { padding: 14px 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
section.disabled { opacity: 0.5; }
section > label { align-self: stretch; }
input[type='range'] { width: 100%; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.inline { display: flex; gap: 6px; align-items: center; }
.inline input { width: 90px; margin: 0; }
.label-row { display: flex; justify-content: space-between; }
.value { color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
.muted { color: var(--muted-foreground); font-size: 0.875rem; }
.error { color: var(--danger); }
.warning { color: var(--warning); }
</style>
+143
View File
@@ -0,0 +1,143 @@
import type { Mat } from '@techstark/opencv-js';
import { disposePrepared, prepareImage, preparedThreshold, segmentPiece, traceMask, type PreparedImage } 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);
/** 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');
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);
private lastPieceMask: Mat | null = null;
/** The detected piece before the margin; cached so margin changes only re-trace it. */
pieceMask = $derived.by((): Mat | null => {
const { cv, prepared } = this;
const next = cv && prepared && this.mode === 'auto'
? segmentPiece(cv, prepared, {
threshold: this.threshold, seed: this.seed,
minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0,
})
: null;
// The previous mask lives in WASM memory and is unreachable once replaced.
this.lastPieceMask?.delete();
this.lastPieceMask = next;
return next;
});
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 this.pieceMask ? traceMask(cv, this.pieceMask, this.marginPx) : null;
});
exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null);
setCv(cv: CV) {
this.cv = cv;
this.prepare();
}
setImage(image: RasterImage) {
this.image = image;
this.tool = 'none';
this.scalePoints = [];
this.seed = null;
this.clearManual();
this.prepare();
}
private prepare() {
if (this.prepared) disposePrepared(this.prepared);
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) {
try {
this.loadError = null;
this.setImage(await loadPhoto(file));
} catch {
this.loadError = 'Could not read this image file.';
}
}
autoThreshold() {
if (this.cv && this.prepared) this.threshold = preparedThreshold(this.cv, this.prepared);
}
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';
}
}
+263 -22
View File
@@ -2,26 +2,139 @@ import type { Mat } from '@techstark/opencv-js';
import type { CV } from './opencv-loader';
import type { DetectParams, Point, RasterImage, Shape } from './types';
/** Blurred grayscale; shared by thresholding and Otsu so both see the same pixels. */
function toGray(cv: CV, image: RasterImage): Mat {
/** Per-photo detection inputs. Owns WASM memory: release with `disposePrepared`. */
export type PreparedImage = {
/** Blurred, lighting-corrected grayscale. */
gray: Mat;
background: number;
/** Local brightness variance (CV_32F); high on wood grain, low on smooth sheet material. */
texture: Mat;
textureRadius: 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 {
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
const window = Math.max(5, 2 * Math.round(Math.min(gray.cols, gray.rows) / 120) + 1);
const texture = localVariance(cv, gray, window);
cv.GaussianBlur(gray, gray, new cv.Size(5, 5), 0);
return gray;
return { gray, background: flattenLighting(gray), texture, textureRadius: (window - 1) / 2 + 2 };
} finally {
src.delete();
}
}
export function disposePrepared(prepared: PreparedImage) {
prepared.gray.delete();
prepared.texture.delete();
}
function localVariance(cv: CV, gray: Mat, window: number): Mat {
const size = new cv.Size(window, window);
const f = new cv.Mat();
const mean = new cv.Mat();
const variance = new cv.Mat();
try {
gray.convertTo(f, cv.CV_32F);
cv.blur(f, mean, size);
cv.multiply(f, f, variance);
cv.blur(variance, variance, size);
cv.multiply(mean, mean, mean);
cv.subtract(variance, mean, variance);
return variance;
} finally {
f.delete();
mean.delete();
}
}
/**
* Removes smooth lighting gradients (side light, vignetting) by fitting a quadratic surface to the
* photo's border band, which is almost always background. Returns the background brightness.
*/
function flattenLighting(gray: Mat): number {
const w = gray.cols;
const h = gray.rows;
const g = gray.data;
const band = Math.max(1, Math.round(Math.min(w, h) * 0.08));
const step = Math.max(1, Math.round(Math.min(w, h) / 400));
const terms = (x: number, y: number) => {
const u = x / w - 0.5;
const v = y / h - 0.5;
return [1, u, v, u * u, u * v, v * v];
};
const samples: { t: number[]; v: number }[] = [];
for (let y = 0; y < h; y += step) {
for (let x = 0; x < w; x += step) {
if (x >= band && x < w - band && y >= band && y < h - band) continue;
samples.push({ t: terms(x, y), v: g[y * w + x] });
}
}
// Keep samples near the median first, so a piece touching the border barely affects the fit.
const sorted = samples.map((s) => s.v).sort((a, b) => a - b);
const median = sorted[sorted.length >> 1];
const mad = samples.map((s) => Math.abs(s.v - median)).sort((a, b) => a - b)[samples.length >> 1];
const nearMedian = samples.filter((s) => Math.abs(s.v - median) <= 3.7 * mad + 2);
let coef = fitSurface(nearMedian);
// Second pass drops outliers (a piece touching the border) so they don't skew the fit.
const residuals = nearMedian.map((s) => s.v - dot(coef, s.t));
const sd = Math.sqrt(residuals.reduce((a, r) => a + r * r, 0) / residuals.length);
const inliers = nearMedian.filter((_, i) => Math.abs(residuals[i]) <= 2 * sd + 1);
if (inliers.length >= 6) coef = fitSurface(inliers);
const level = inliers.reduce((a, s) => a + s.v, 0) / inliers.length;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
g[i] = Math.max(0, Math.min(255, Math.round(g[i] - dot(coef, terms(x, y)) + level)));
}
}
return level;
}
const dot = (a: number[], b: number[]) => a.reduce((sum, ai, i) => sum + ai * b[i], 0);
/** Least-squares fit via the normal equations, solved with Gaussian elimination. */
function fitSurface(samples: { t: number[]; v: number }[]): number[] {
const n = 6;
const m = Array.from({ length: n }, () => new Array<number>(n + 1).fill(0));
for (const { t, v } of samples) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) m[i][j] += t[i] * t[j];
m[i][n] += t[i] * v;
}
}
for (let c = 0; c < n; c++) {
let pivot = c;
for (let r = c + 1; r < n; r++) if (Math.abs(m[r][c]) > Math.abs(m[pivot][c])) pivot = r;
[m[c], m[pivot]] = [m[pivot], m[c]];
if (Math.abs(m[c][c]) < 1e-12) continue;
for (let r = 0; r < n; r++) {
if (r === c) continue;
const f = m[r][c] / m[c][c];
for (let k = c; k <= n; k++) m[r][k] -= f * m[c][k];
}
}
return m.map((row, i) => (Math.abs(row[i]) < 1e-12 ? 0 : row[n] / row[i]));
}
export function otsuThreshold(cv: CV, image: RasterImage): number {
const gray = toGray(cv, image);
const prepared = prepareImage(cv, image);
try {
return preparedThreshold(cv, prepared);
} finally {
disposePrepared(prepared);
}
}
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();
}
}
@@ -33,14 +146,9 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
const padded = new cv.Mat();
const contours = new cv.MatVector();
const hierarchy = new cv.Mat();
let kernel: Mat | null = null;
try {
cv.copyMakeBorder(mask, padded, pad, pad, pad, pad, cv.BORDER_CONSTANT, new cv.Scalar(0));
if (radius > 0) {
kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(2 * radius + 1, 2 * radius + 1));
if (marginPx < 0) cv.erode(padded, padded, kernel);
else cv.dilate(padded, padded, kernel);
}
if (radius > 0) offsetMask(cv, padded, marginPx < 0 ? -radius : radius);
cv.findContours(padded, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE);
const count = contours.size();
@@ -76,34 +184,86 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
padded.delete();
contours.delete();
hierarchy.delete();
kernel?.delete();
}
}
/**
* Erodes (negative) or dilates (positive) a mask by a radius, in place. Uses a distance transform:
* an elliptical kernel costs radius² per pixel and froze the UI for large margins.
*/
function offsetMask(cv: CV, mask: Mat, radius: number) {
const source = new cv.Mat();
const dist = new cv.Mat();
try {
// Dilating the piece is eroding the background, so measure from the other side.
if (radius > 0) cv.bitwise_not(mask, source);
else mask.copyTo(source);
cv.distanceTransform(source, dist, cv.DIST_L2, cv.DIST_MASK_PRECISE);
const r = Math.abs(radius);
cv.threshold(dist, dist, r, 255, radius > 0 ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY);
dist.convertTo(mask, cv.CV_8U);
} finally {
source.delete();
dist.delete();
}
}
export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape | null {
const gray = toGray(cv, image);
const prepared = prepareImage(cv, image);
try {
return detectPrepared(cv, prepared, params);
} finally {
disposePrepared(prepared);
}
}
export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectParams): Shape | null {
const piece = segmentPiece(cv, prepared, params);
if (!piece) return null;
try {
return traceMask(cv, piece, params.marginPx);
} finally {
piece.delete();
}
}
/**
* The selected piece as a mask (255 = piece), before the margin. Cache it: margin changes then only
* need `traceMask`. Caller deletes the returned Mat.
*/
export function segmentPiece(cv: CV, prepared: PreparedImage, params: Omit<DetectParams, 'marginPx'>): Mat | 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();
const stats = new cv.Mat();
const centroids = new cv.Mat();
let found = false;
try {
const type = params.invert ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY;
cv.threshold(gray, mask, params.threshold, 255, type);
const segment = (dark: boolean, seed: Point | null) => {
cv.threshold(gray, mask, params.threshold, 255, dark ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY);
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);
const label = pickComponent(cv, labels, stats, count, image.width, image.height, params.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);
// A click on the background makes it "the piece", which is rejected; fall back to the automatic choice.
if (label === null) label = segment(background > params.threshold, null);
if (label === null) return null;
const lab = labels.data32S;
const m = mask.data;
for (let i = 0; i < lab.length; i++) m[i] = lab[i] === label ? 255 : 0;
return traceMask(cv, mask, params.marginPx);
trimTexturedEdges(cv, mask, prepared.texture, prepared.textureRadius);
fillSmallHoles(cv, mask, params.minHolePx);
found = true;
return mask;
} finally {
gray.delete();
mask.delete();
if (!found) mask.delete();
kernel.delete();
labels.delete();
stats.delete();
@@ -134,16 +294,97 @@ function pickComponent(
}
}
// Without a click, specks (dust, grain) must never pass as the piece.
const minArea = 0.01 * width * height;
let best: number | null = null;
let bestArea = 0;
for (let label = 1; label < count; label++) {
const b = box(label);
if (b.left === 0 || b.top === 0 || b.right === width || b.bottom === height) continue;
const area = stat(label, cv.CC_STAT_AREA);
if (area > bestArea) {
if (area >= minArea && area > bestArea) {
bestArea = area;
best = label;
}
}
return best;
}
/** Fill holes under minHolePx in both directions, before the margin can grow them. */
function fillSmallHoles(cv: CV, mask: Mat, minHolePx: number) {
if (minHolePx <= 0) return;
const contours = new cv.MatVector();
const hierarchy = new cv.Mat();
try {
cv.findContours(mask, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE);
const h = hierarchy.data32S;
for (let i = 0; i < contours.size(); i++) {
if (h[i * 4 + 3] === -1) continue;
const c = contours.get(i);
const rect = cv.boundingRect(c);
c.delete();
if (rect.width < minHolePx && rect.height < minHolePx) {
cv.drawContours(mask, contours, i, new cv.Scalar(255), cv.FILLED);
}
}
} finally {
contours.delete();
hierarchy.delete();
}
}
/** The clicked pixel's side of the threshold is the piece's; null without a usable click. */
function seedIsDark(gray: Mat, threshold: number, seed: Point | null): boolean | null {
if (!seed) return null;
const x = Math.round(seed.x);
const y = Math.round(seed.y);
if (x < 0 || y < 0 || x >= gray.cols || y >= gray.rows) return null;
return gray.ucharAt(y, x) <= threshold;
}
/**
* Removes textured regions (wood grain, knots) that touch a smoother piece and passed the threshold.
* Keeps only the smooth core, then grows it back by the texture window to restore the true edges,
* which always look textured. A piece that is itself textured (core under half its area) is left alone.
*/
function trimTexturedEdges(cv: CV, mask: Mat, texture: Mat, radius: number) {
const md = mask.data;
const tv = texture.data32F;
const samples: number[] = [];
for (let i = 0; i < md.length; i += 7) if (md[i]) samples.push(tv[i]);
if (samples.length === 0) return;
samples.sort((a, b) => a - b);
// Variance limit: 1.6x the piece's typical deviation, and never below a deviation of 4 grey levels.
const limit = Math.max(16, 2.56 * samples[samples.length >> 1]);
// copyTo, not clone(): in this opencv.js build clone() shares the pixel buffer.
const core = new cv.Mat();
mask.copyTo(core);
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
try {
const cd = core.data;
for (let i = 0; i < cd.length; i++) if (cd[i] && tv[i] > limit) cd[i] = 0;
const count = cv.connectedComponentsWithStats(core, labels, stats, centroids, 8, cv.CV_32S);
let best = 0;
let bestArea = 0;
for (let label = 1; label < count; label++) {
const area = stats.intAt(label, cv.CC_STAT_AREA);
if (area > bestArea) {
bestArea = area;
best = label;
}
}
if (bestArea < 0.5 * cv.countNonZero(mask)) return;
const lab = labels.data32S;
for (let i = 0; i < cd.length; i++) cd[i] = lab[i] === best ? 255 : 0;
offsetMask(cv, core, radius);
cv.bitwise_and(mask, core, mask);
} finally {
core.delete();
labels.delete();
stats.delete();
centroids.delete();
}
}
+1 -7
View File
@@ -2,8 +2,6 @@ import type { Point, Shape } from './types';
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
const MIN_HOLE_MM = 3;
export function bounds(points: Point[]): Bounds {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of points) {
@@ -70,11 +68,7 @@ export function prepareForExport(shapePx: Shape, pxPerMm: number, toleranceMm =
simplifyRing(ring.map((p) => ({ x: p.x / pxPerMm, y: p.y / pxPerMm })), toleranceMm);
const outer = toMm(shapePx.outer);
if (outer.length < 3) return null;
const holes = shapePx.holes.map(toMm).filter((hole) => {
if (hole.length < 3) return false;
const { width, height } = shapeSize({ outer: hole, holes: [] });
return width >= MIN_HOLE_MM || height >= MIN_HOLE_MM;
});
const holes = shapePx.holes.map(toMm).filter((hole) => hole.length >= 3);
const { minX, minY } = bounds(outer);
const shift = (ring: Point[]) => ring.map((p) => ({ x: p.x - minX, y: p.y - minY }));
return { outer: shift(outer), holes: holes.map(shift) };
+22 -3
View File
@@ -1,12 +1,11 @@
export type CV = typeof import('@techstark/opencv-js');
// Everything returns { cv }: the cv module is a thenable, so resolving with it directly never settles.
let loading: Promise<{ cv: CV }> | null = null;
// Wrapped in an object: the cv module is a thenable, so resolving with it directly never settles.
export function loadOpenCV(): Promise<{ cv: CV }> {
loading ??= (async () => {
const mod = (await import('@techstark/opencv-js')) as unknown as { default: CV };
const cv = mod.default;
const { cv } = typeof document === 'undefined' ? await importModule() : await loadScript();
if (!cv.Mat) {
await new Promise<void>((resolve) => {
(cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve();
@@ -16,3 +15,23 @@ export function loadOpenCV(): Promise<{ cv: CV }> {
})();
return loading;
}
/** Node (tests). The variable specifier keeps the bundler from emitting a second OpenCV chunk. */
async function importModule(): Promise<{ cv: CV }> {
const specifier = '@techstark/opencv-js';
const mod = (await import(/* @vite-ignore */ specifier)) as { default: CV };
return { cv: mod.default };
}
// A script tag, not import(): bundler CJS interop copies `then` and the import never settles.
async function loadScript(): Promise<{ cv: CV }> {
const { default: url } = await import('@techstark/opencv-js/dist/opencv.js?url');
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load OpenCV'));
document.head.append(script);
});
return { cv: (window as unknown as { cv: CV }).cv };
}
+2 -1
View File
@@ -8,8 +8,9 @@ export type RasterImage = { width: number; height: number; data: Uint8ClampedArr
export type DetectParams = {
threshold: number;
invert: boolean;
seed: Point | null;
/** Negative shrinks the piece (and grows holes). */
marginPx: number;
/** Holes smaller than this in both directions are treated as glare and filled. */
minHolePx: number;
};
+1
View File
@@ -1,3 +1,4 @@
import '@knadh/oat/oat.min.css';
import { mount } from 'svelte';
import App from './App.svelte';
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { AppState } from '../src/lib/app-state.svelte';
const image = { width: 100, height: 80, data: new Uint8ClampedArray(100 * 80 * 4) };
function withImage() {
const app = new AppState();
app.setImage(image);
return app;
}
describe('AppState', () => {
it('starts with sensible defaults', () => {
const app = new AppState();
expect(app.marginMm).toBe(-2);
expect(app.mode).toBe('auto');
expect(app.tool).toBe('none');
expect(app.shapePx).toBeNull();
expect(app.exportShape).toBeNull();
});
it('sets the scale from two clicks, then leaves the scale tool', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
expect(app.tool).toBe('scale');
app.handleImageClick({ x: 70, y: 10 });
expect(app.tool).toBe('none');
app.lengthMm = 30;
expect(app.pxPerMm).toBeCloseTo(2);
expect(app.marginPx).toBeCloseTo(-4);
});
it('restarts the scale line on a third click', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
app.handleImageClick({ x: 70, y: 10 });
app.tool = 'scale';
app.handleImageClick({ x: 5, y: 5 });
expect(app.scalePoints).toEqual([{ x: 5, y: 5 }]);
expect(app.pxPerMm).toBeNull();
});
it('flags an invalid scale and keeps the margin at 0 until the scale is valid', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
app.handleImageClick({ x: 10, y: 10 });
app.lengthMm = 50;
expect(app.scaleInvalid).toBe(true);
expect(app.marginPx).toBe(0);
});
it('ignores clicks outside the image', () => {
const app = withImage();
app.tool = 'seed';
app.handleImageClick({ x: -1, y: 10 });
app.handleImageClick({ x: 50, y: 80 });
expect(app.seed).toBeNull();
expect(app.tool).toBe('seed');
});
it('sets the seed and leaves the seed tool', () => {
const app = withImage();
app.tool = 'seed';
app.handleImageClick({ x: 20, y: 30 });
expect(app.seed).toEqual({ x: 20, y: 30 });
expect(app.tool).toBe('none');
});
it('draws an outline then holes, switching to manual mode', () => {
const app = withImage();
app.tool = 'draw';
[{ x: 1, y: 1 }, { x: 90, y: 1 }, { x: 90, y: 70 }].forEach((p) => app.handleImageClick(p));
app.finishPolygon();
expect(app.manualOuter).toHaveLength(3);
expect(app.mode).toBe('manual');
[{ x: 20, y: 20 }, { x: 30, y: 20 }, { x: 30, y: 30 }].forEach((p) => app.handleImageClick(p));
app.finishPolygon();
expect(app.manualHoles).toHaveLength(1);
expect(app.drawing).toEqual([]);
});
it('discards a polygon with fewer than 3 points', () => {
const app = withImage();
app.tool = 'draw';
app.handleImageClick({ x: 1, y: 1 });
app.finishPolygon();
expect(app.manualOuter).toEqual([]);
expect(app.mode).toBe('auto');
});
it('clearManual returns to auto mode', () => {
const app = withImage();
app.tool = 'draw';
[{ x: 1, y: 1 }, { x: 90, y: 1 }, { x: 90, y: 70 }].forEach((p) => app.handleImageClick(p));
app.finishPolygon();
app.clearManual();
expect(app.mode).toBe('auto');
expect(app.manualOuter).toEqual([]);
expect(app.tool).toBe('none');
});
it('a new image resets scale, seed and manual work', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
app.seed = { x: 5, y: 5 };
app.manualOuter = [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 1 }];
app.mode = 'manual';
app.setImage(image);
expect(app.scalePoints).toEqual([]);
expect(app.seed).toBeNull();
expect(app.manualOuter).toEqual([]);
expect(app.mode).toBe('auto');
expect(app.tool).toBe('none');
expect(app.threshold).toBe(128);
});
});
+97 -9
View File
@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { detect } from '../src/lib/detect';
import { detect, detectPrepared, disposePrepared, otsuThreshold, prepareImage, preparedThreshold, segmentPiece, traceMask } 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';
@@ -11,7 +11,7 @@ beforeAll(async () => {
});
const params = (overrides: Partial<DetectParams> = {}): DetectParams => ({
threshold: 128, invert: false, seed: null, marginPx: 0, ...overrides,
threshold: 128, seed: null, marginPx: 0, minHolePx: 0, ...overrides,
});
/** Light plate x 40..159, y 30..129 with hole r=15 at (100,80), plus a small light blob, on dark ground. */
@@ -31,24 +31,35 @@ describe('detect', () => {
expect(shape.holes).toHaveLength(1);
});
it('handles a dark piece on a light background with invert', () => {
it('finds a dark piece on a light background without any setting', () => {
const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
expect(detect(cv, inverted, params())).toBeNull();
const b = bounds(detect(cv, inverted, params({ invert: true }))!.outer);
const b = bounds(detect(cv, inverted, params())!.outer);
near(b.minX, 40); near(b.maxX, 159);
});
it('takes the polarity from the clicked pixel', () => {
const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
const b = bounds(detect(cv, inverted, params({ seed: { x: 100, y: 80 } }))!.outer);
near(b.minX, 40); near(b.maxX, 159);
});
it('corrects uneven lighting across the photo', () => {
// Light from the right: the floor ramps 120 → 220 and the piece is 50 darker than the floor around it.
const sideLit = grayImage(400, 300, (x, y) => 120 + (100 * x) / 400 - (inRect(x, y, 100, 75, 300, 225) ? 50 : 0));
const shape = detect(cv, sideLit, params({ threshold: otsuThreshold(cv, sideLit) }))!;
const b = bounds(shape.outer);
near(b.minX, 100); near(b.maxX, 299); near(b.minY, 75); near(b.maxY, 224);
});
it('selects the clicked piece even when another piece is larger', () => {
const shape = detect(cv, plateScene, params({ seed: { x: 177, y: 146 } }))!;
const b = bounds(shape.outer);
near(b.minX, 170); near(b.maxX, 184);
});
it('falls back to the automatic choice when the seed is on background or in a hole', () => {
for (const seed of [{ x: 5, y: 5 }, { x: 100, y: 80 }]) {
const b = bounds(detect(cv, plateScene, params({ seed }))!.outer);
it('falls back to the automatic choice when the click is on the background', () => {
const b = bounds(detect(cv, plateScene, params({ seed: { x: 5, y: 5 } }))!.outer);
near(b.minX, 40);
}
});
it('ignores a seed outside the image', () => {
@@ -84,4 +95,81 @@ describe('detect', () => {
});
expect(detect(cv, speckled, params())!.holes).toHaveLength(0);
});
it('fills specks smaller than minHolePx before the margin can grow them', () => {
const glare = grayImage(200, 160, (x, y) => {
const plate = inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15) && !inCircle(x, y, 60, 50, 3);
return plate ? 220 : 30;
});
expect(detect(cv, glare, params({ marginPx: -6 }))!.holes).toHaveLength(2);
expect(detect(cv, glare, params({ marginPx: -6, minHolePx: 10 }))!.holes).toHaveLength(1);
});
it('keeps a long thin hole that is under minHolePx in only one direction', () => {
const slotted = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) && !inRect(x, y, 60, 60, 120, 66) ? 220 : 30));
expect(detect(cv, slotted, params({ minHolePx: 10 }))!.holes).toHaveLength(1);
});
it('does not auto-pick a speck when the real piece touches the border', () => {
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 {
disposePrepared(prepared);
}
});
it('cuts off textured background (wood grain) that touches a smooth piece', () => {
// Smooth dark piece x 150..449, y 100..299 on a light floor, with a dark grainy knot touching its top-left.
const knotted = grayImage(600, 450, (x, y) => {
if (inRect(x, y, 150, 100, 450, 300)) return 80;
if (inRect(x, y, 90, 50, 170, 110)) return Math.floor(x / 3) % 2 ? 40 : 110;
return 200;
});
const b = bounds(detect(cv, knotted, params({ threshold: 140 }))!.outer);
expect(b.minX).toBeGreaterThanOrEqual(144);
expect(b.minY).toBeGreaterThanOrEqual(94);
near(b.maxX, 449); near(b.maxY, 299);
});
it('keeps a piece that is itself textured (e.g. plywood grain)', () => {
const plywood = grayImage(600, 450, (x, y) =>
inRect(x, y, 150, 100, 450, 300) ? (Math.floor(x / 3) % 2 ? 40 : 110) : 200);
const b = bounds(detect(cv, plywood, params({ threshold: 140 }))!.outer);
near(b.minX, 150); near(b.maxX, 449); near(b.minY, 100); near(b.maxY, 299);
});
it('segments once and applies different margins to the same piece mask', () => {
const prepared = prepareImage(cv, plateScene);
const piece = segmentPiece(cv, prepared, { threshold: 128, seed: null, minHolePx: 0 })!;
try {
for (const marginPx of [0, -5, 3]) {
const direct = detectPrepared(cv, prepared, params({ marginPx }))!;
expect(bounds(traceMask(cv, piece, marginPx)!.outer)).toEqual(bounds(direct.outer));
}
} finally {
piece.delete();
disposePrepared(prepared);
}
});
it('segmentPiece returns null when nothing is found', () => {
const prepared = prepareImage(cv, grayImage(100, 100, () => 30));
try {
expect(segmentPiece(cv, prepared, { threshold: 128, seed: null, minHolePx: 0 })).toBeNull();
} finally {
disposePrepared(prepared);
}
});
});
+3 -4
View File
@@ -10,10 +10,9 @@ import type { Point, RasterImage } from '../src/lib/types';
type FixtureCase = {
file: string;
threshold: number | null;
invert: boolean;
seed: Point | null;
pxPerMm: number;
expected: { widthMm: number; heightMm: number; holes: number };
expected: { widthMm: number; heightMm: number; holes?: number };
toleranceMm: number;
};
@@ -34,12 +33,12 @@ describe.skipIf(cases.length === 0)('detect on real photos', () => {
it.each(cases)('$file', (c) => {
const image = readJpeg(dir + c.file);
const threshold = c.threshold ?? otsuThreshold(cv, image);
const shape = detect(cv, image, { threshold, invert: c.invert, seed: c.seed, marginPx: 0 });
const shape = detect(cv, image, { threshold, seed: c.seed, marginPx: 0, minHolePx: 3 * c.pxPerMm });
expect(shape).not.toBeNull();
const mm = prepareForExport(shape!, c.pxPerMm)!;
const size = shapeSize(mm);
expect(Math.abs(size.width - c.expected.widthMm)).toBeLessThanOrEqual(c.toleranceMm);
expect(Math.abs(size.height - c.expected.heightMm)).toBeLessThanOrEqual(c.toleranceMm);
expect(mm.holes).toHaveLength(c.expected.holes);
if (c.expected.holes !== undefined) expect(mm.holes).toHaveLength(c.expected.holes);
});
});
+2 -2
View File
@@ -16,7 +16,6 @@ To add one:
{
"file": "plywood-1.jpg",
"threshold": null,
"invert": false,
"seed": null,
"pxPerMm": 3.21,
"expected": { "widthMm": 412, "heightMm": 287, "holes": 2 },
@@ -24,4 +23,5 @@ To add one:
}
```
`threshold: null` means Otsu's automatic value. `seed` is `{ "x": ..., "y": ... }` or `null`.
`threshold: null` means Otsu's automatic value. `holes` is optional; leave it out when
background texture creates noise holes. Without a known scale, use `pxPerMm: 1` and sizes in px. `seed` is `{ "x": ..., "y": ... }` or `null`.
+10 -1
View File
@@ -1 +1,10 @@
[]
[
{
"file": "mdf_floor.jpeg",
"threshold": null,
"seed": null,
"pxPerMm": 1,
"expected": { "widthMm": 1712, "heightMm": 675, "holes": 0 },
"toleranceMm": 20
}
]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

+3 -14
View File
@@ -53,20 +53,9 @@ describe('prepareForExport', () => {
expect(mm.outer).toHaveLength(4);
});
it('drops holes smaller than 3 mm in both directions (glare/speckle)', () => {
const shapePx = {
outer: denseSquare(0, 0, 400),
holes: [denseSquare(100, 100, 8), denseSquare(200, 200, 40)], // 2 mm and 10 mm at 4 px/mm
};
const mm = prepareForExport(shapePx, 4)!;
expect(mm.holes).toHaveLength(1);
expect(shapeSize({ outer: mm.holes[0], holes: [] }).width).toBeCloseTo(10);
});
it('keeps a long thin hole that is under 3 mm in only one direction', () => {
const slot: Point[] = [{ x: 100, y: 100 }, { x: 300, y: 100 }, { x: 300, y: 104 }, { x: 100, y: 104 }];
const mm = prepareForExport({ outer: denseSquare(0, 0, 400), holes: [slot] }, 4)!;
expect(mm.holes).toHaveLength(1);
it('keeps every hole, however small (speck filtering happens before the margin)', () => {
const shapePx = { outer: denseSquare(0, 0, 400), holes: [denseSquare(100, 100, 8), denseSquare(200, 200, 40)] };
expect(prepareForExport(shapePx, 4)!.holes).toHaveLength(2);
});
it('returns null when the outer ring degenerates', () => {
+11
View File
@@ -85,6 +85,17 @@ describe('traceMask', () => {
});
});
describe('traceMask performance', () => {
it('handles a huge margin quickly (e.g. while a length is half-typed)', () => {
const m = maskFrom(cv, 2000, 1500, (x, y) => inRect(x, y, 200, 200, 1800, 1300));
mats.push(m);
const start = performance.now();
const shape = traceMask(cv, m, -300)!;
expect(performance.now() - start).toBeLessThan(1500);
expect(Math.abs(bounds(shape.outer).minX - 500)).toBeLessThanOrEqual(2);
});
});
describe('otsuThreshold', () => {
it('lands between background and piece brightness', () => {
const image = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));