d86fc93ed6
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
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
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 {};
|