Compare commits
11
Commits
99cafc0b55
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1183ce85c | ||
|
|
98285d0539 | ||
|
|
353577d5ec | ||
|
|
e925ce66d5 | ||
|
|
077c3cc31d | ||
|
|
8ea525a1e8 | ||
|
|
55a25ba978 | ||
|
|
41baa25a01 | ||
|
|
abef0f982b | ||
|
|
bec28fd0c1 | ||
|
|
4b19c7ea4e |
No files matched your search
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
@@ -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.
|
||||
Generated
+1408
-1
File diff suppressed because it is too large.
Load diff
+5
-2
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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) };
|
||||
|
||||
@@ -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
@@ -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,3 +1,4 @@
|
||||
import '@knadh/oat/oat.min.css';
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+2
-2
@@ -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`.
|
||||
Vendored
+10
-1
@@ -1 +1,10 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"file": "mdf_floor.jpeg",
|
||||
"threshold": null,
|
||||
"seed": null,
|
||||
"pxPerMm": 1,
|
||||
"expected": { "widthMm": 1712, "heightMm": 675, "holes": 0 },
|
||||
"toleranceMm": 20
|
||||
}
|
||||
]
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
+3
-14
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user