feat(capture): ship Milestone 1 browser capture app

Why
Phone and PC need a single capture flow that ends at inventory PNG and
board-foot numbers with no backend, inference, or Woo create.

Scope
Add frontend/ Vite+Svelte capture UI with Resume, Add photo, Mask,
Length axis, and Measure. Mask runs in a worker with Auto/Green/Black
and a 2.0s sensitivity debounce. Measure computes scale, widths, sqft,
bdft, and a 3:4 alpha inventory PNG. Persist one device draft in
IndexedDB. Serve the static build from frontend/Dockerfile via nginx
(deploy/nginx-m1.conf). No FastAPI.

Verification
sudo docker build -f frontend/Dockerfile -t slabuploader-m1 .
sudo docker run -d --name slabuploader-m1 -p 0.0.0.0:18080:80 slabuploader-m1
curl http://127.0.0.1:18080/ returns 200
Playwright: synthetic green-sheet photo through Calculate shows sqft/bdft
This commit is contained in:
2026-09-14 07:53:39 +00:00
parent 3bae76cea3
commit d86fc93ed6
24 changed files with 2969 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import { minAreaRect, slabBounds } from '../geometry/rect';
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
let bitmap: ImageBitmap | null = null;
let data: Uint8ClampedArray | null = null;
let w = 0, h = 0;
let current = 0; // highest job id issued by main; older replies are dropped
// Keep slab pixels; remove the sheet. Higher sensitivity widens the sheet match.
const greenKeep = (r: number, g: number, b: number, s: number): boolean => {
const t = s / 100;
const isSheet = g - Math.max(r, b) > 10 + 35 * t;
return !isSheet;
};
const blackKeep = (r: number, g: number, b: number, s: number): boolean => {
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const isSheet = lum < 40 + 140 * (s / 100);
return !isSheet;
};
const autoPick = (samples: { r: number; g: number; b: number }[]): 'green' | 'black' => {
let gr = 0, gl = 0;
for (const p of samples) {
if (p.g - Math.max(p.r, p.b) > 20) gr++;
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
}
return gl >= gr ? 'black' : 'green';
};
function runMask(params: MaskParams, id: number): WorkerOut {
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
let sheet = params.sheet;
if (sheet === 'auto') {
const samples: { r: number; g: number; b: number }[] = [];
for (let y = 0; y < h; y += 16)
for (let x = 0; x < w; x += 16) {
const i = (y * w + x) * 4;
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
}
sheet = autoPick(samples);
}
const keep = sheet === 'green' ? greenKeep : blackKeep;
const s = Math.max(0, Math.min(100, params.sensitivity));
const bitset = new Uint8Array(w * h);
let slabPx = 0;
for (let i = 0, p = 0; i < data.length; i += 4, p++) {
if (keep(data[i], data[i + 1], data[i + 2], s)) {
bitset[p] = 1;
slabPx++;
}
}
if (slabPx === 0) {
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
}
const bbox = minAreaRect(bitset, w, h);
if (!bbox) {
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
}
return { id, kind: 'ready', slabPx, bitset, bbox, slabBounds: slabBounds(bitset, w, h) };
}
self.onmessage = (e: MessageEvent<WorkerIn>) => {
const msg = e.data;
if (msg.id < current) return; // superseded by a newer job
current = msg.id;
if (msg.kind === 'init') {
if (bitmap) {
bitmap.close();
bitmap = null;
}
data = null;
w = h = 0;
createImageBitmap(msg.blob)
.then((bmp) => {
w = bmp.width;
h = bmp.height;
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
data = ctx.getImageData(0, 0, w, h).data;
bitmap = bmp;
self.postMessage({ id: msg.id, kind: 'decoded', w, h } as WorkerOut);
})
.catch((err) => self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut));
return;
}
if (msg.kind === 'invalidate') {
if (bitmap) {
bitmap.close();
bitmap = null;
}
data = null;
w = h = 0;
return;
}
try {
const out = runMask({ sheet: msg.sheet, sensitivity: msg.sensitivity }, msg.id);
if (out.kind === 'ready') {
self.postMessage(out, [out.bitset.buffer]);
} else {
self.postMessage(out);
}
} catch (err) {
self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut);
}
};
export {};