3 Commits

Author SHA1 Message Date
grokbot_ty abef27cf2a docs(deploy): fallback UAT on Grok Bot computer :18181
When Tailscale cannot reach ubudual5060, Capture UAT serves from the
bot computer on :18181. Primary overnight remains :10881. Never touch
Ty local :18080 or legacy :10880.
2026-09-25 17:30:14 -07:00
grokbot_ty 03d3fcf460 fix(photos): preserve tapered tips and dark live edge (#73 #74)
Lower the axial body threshold while retaining fringe rejection, minimize length-only tip inset, and make sheet sensitivity curves testable and conservative at zero.
2026-09-16 15:18:06 +00:00
grokbot_ty 037a4b870a fix(photos): shared EXIF-oriented decode for axis/overlay space (#72)
PhotoStage used new Image() while mask worker and calculate used bare
createImageBitmap; Orientation-8 matched-set JPEGs can disagree across
those paths so the approved pink axis is not the overlay (ux,uy).

One decodeOrientedBitmap helper always requests imageOrientation
from-image (with bare fallback), wired into PhotoStage, mask.worker,
and session calculate/siblings.
2026-09-16 05:08:13 +00:00
10 changed files with 601 additions and 121 deletions
+21 -6
View File
@@ -1,20 +1,35 @@
# Host # Host
## Overnight Milestone 2 Capture (UAT) ## Overnight Capture UAT (primary — when Tailscale is up)
- Host: `ubudual5060` / Tailscale `100.64.0.9` - Host: `ubudual5060` / Tailscale `100.64.0.9`
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`) - Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`) — or salvage rebuild of the same role
- Port: **10881** → 80 - Port: **10881** → 80
- URL: `http://100.64.0.9:10881` - URL: `http://100.64.0.9:10881`
- Replaces prior overnight container `slabuploader-m1-overnight` (same port). - Replaces prior overnight container `slabuploader-m1-overnight` (same port).
## Ty local Node/SvelteKit (do not overwrite) ## Fallback UAT — Grok Bot computer (when Tailscale to 5060 is down)
Use this path for salvage / Capture UAT until the box can reach `100.64.0.9` again.
- Host: Grok Bot computer (slab-m1 box)
- Serve: static Capture build (`frontend` → `npm run build`, then `vite preview` or equivalent static server)
- Port: **18181** (chosen so it never collides with Ty local on 5060)
- Bind: `0.0.0.0:18181`
- URL (from this box): `http://127.0.0.1:18181` — Ty reaches it via the bot computer / whatever path the app exposes; Tailscale IP of this box when mesh returns
- Branch for salvage: `salvage-one-woo-draft` (cut from `m2-ds`)
- Do **not** require Docker on the box for this fallback.
When Tailscale is restored, prefer primary overnight `:10881` again and stop advertising the fallback as the main UAT URL.
## Ty local Node/SvelteKit on 5060 (do not overwrite)
- Host: `ubudual5060` / `100.64.0.9` - Host: `ubudual5060` / `100.64.0.9`
- Container: `slabuploader-m1` - Container: `slabuploader-m1`
- Port: **18080** → 80 (inventory previously listed 10880; do not bind overnight there) - Port: **18080** → 80
- URL: `http://100.64.0.9:18080` - URL: `http://100.64.0.9:18080`
- **Never** recreate or remap this container for overnight UAT. - **Never** recreate or remap this container for overnight or box-fallback UAT.
- **Never** bind overnight or fallback UAT to **10880** either (legacy Ty local).
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local port. Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local ports.
Do not put a host name in the product spec. Do not put a host name in the product spec.
+79 -76
View File
@@ -2,9 +2,11 @@
import { onDestroy, onMount, tick } from 'svelte'; import { onDestroy, onMount, tick } from 'svelte';
import type { BBox } from './capture.types'; import type { BBox } from './capture.types';
import { fitDisplay } from './fitDisplay'; import { fitDisplay } from './fitDisplay';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
// Display-only: renders the photo fitted to device width (height capped) plus // Display-only: renders the photo fitted to device width (height capped) plus
// one overlay layer. All math lives on source pixels, not this canvas. // one overlay layer. All math lives on source pixels, not this canvas.
// Geometry must use the same EXIF-oriented bitmap space as the mask worker (#72).
export let blob: Blob; export let blob: Blob;
export let maxH = 420; export let maxH = 420;
export let bbox: BBox | null = null; export let bbox: BBox | null = null;
@@ -12,21 +14,12 @@
export let maskW = 0; export let maskW = 0;
export let maskH = 0; export let maskH = 0;
let url: string | null = null;
let canvas: HTMLCanvasElement | undefined; let canvas: HTMLCanvasElement | undefined;
let stage: HTMLDivElement | undefined; let stage: HTMLDivElement | undefined;
let imgW = 0, imgH = 0; // source px, learned once per blob let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
let parentW = 0; let parentW = 0;
let decodeGen = 0;
const setBlobUrl = (b: Blob) => { let decoding = false;
if (url) URL.revokeObjectURL(url);
url = URL.createObjectURL(b);
};
$: if (blob) {
setBlobUrl(blob);
imgW = 0;
}
const applyFit = () => { const applyFit = () => {
if (!canvas || !imgW) return; if (!canvas || !imgW) return;
@@ -37,73 +30,83 @@
canvas.style.height = `${fit.fitH}px`; canvas.style.height = `${fit.fitH}px`;
}; };
const redraw = () => { const paintOverlays = (ctx: CanvasRenderingContext2D) => {
if (!url || !canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d')!; if (maskBitset && maskW > 0 && maskH > 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height); const off = document.createElement('canvas');
const img = new Image(); off.width = maskW;
img.onload = () => { off.height = maskH;
if (!canvas) return; const octx = off.getContext('2d')!;
imgW = img.width; const d = octx.createImageData(maskW, maskH);
imgH = img.height; const tile = 8;
for (let p = 0; p < maskW * maskH; p++) {
if (!maskBitset[p]) {
const x = p % maskW;
const y = (p / maskW) | 0;
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
d.data[p * 4] = light ? 220 : 180;
d.data[p * 4 + 1] = light ? 220 : 180;
d.data[p * 4 + 2] = light ? 220 : 180;
d.data[p * 4 + 3] = 220;
}
}
octx.putImageData(d, 0, 0);
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
}
if (bbox && bbox.corners.length === 4) {
const c = bbox.corners;
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = Math.max(2, canvas.width / 400);
ctx.beginPath();
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
ctx.closePath();
ctx.stroke();
// The axis reads corners[3] → corners[0], the long side.
const a = c[3];
const b = c[0];
ctx.strokeStyle = '#ff3d71';
ctx.lineWidth = Math.max(3, canvas.width / 300);
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
ctx.fillStyle = '#ff3d71';
ctx.beginPath();
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
const r = Math.max(10, canvas.width / 60);
ctx.moveTo(b[0], b[1]);
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fill();
}
};
const redraw = async () => {
if (!blob || !canvas) return;
const gen = ++decodeGen;
decoding = true;
let bmp: ImageBitmap | null = null;
try {
bmp = await decodeOrientedBitmap(blob);
if (gen !== decodeGen || !canvas) return;
imgW = bmp.width;
imgH = bmp.height;
canvas.width = imgW; canvas.width = imgW;
canvas.height = imgH; canvas.height = imgH;
applyFit(); applyFit();
ctx.drawImage(img, 0, 0); const ctx = canvas.getContext('2d')!;
if (maskBitset && maskW > 0 && maskH > 0) { ctx.clearRect(0, 0, canvas.width, canvas.height);
const off = document.createElement('canvas'); ctx.drawImage(bmp, 0, 0);
off.width = maskW; paintOverlays(ctx);
off.height = maskH; } finally {
const octx = off.getContext('2d')!; if (bmp) bmp.close();
const d = octx.createImageData(maskW, maskH); if (gen === decodeGen) decoding = false;
const tile = 8; }
for (let p = 0; p < maskW * maskH; p++) {
if (!maskBitset[p]) {
const x = p % maskW;
const y = (p / maskW) | 0;
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
d.data[p * 4] = light ? 220 : 180;
d.data[p * 4 + 1] = light ? 220 : 180;
d.data[p * 4 + 2] = light ? 220 : 180;
d.data[p * 4 + 3] = 220;
}
}
octx.putImageData(d, 0, 0);
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
}
if (bbox && bbox.corners.length === 4) {
const c = bbox.corners;
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = Math.max(2, canvas.width / 400);
ctx.beginPath();
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
ctx.closePath();
ctx.stroke();
// The axis reads corners[3] → corners[0], the long side.
const a = c[3];
const b = c[0];
ctx.strokeStyle = '#ff3d71';
ctx.lineWidth = Math.max(3, canvas.width / 300);
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
ctx.fillStyle = '#ff3d71';
ctx.beginPath();
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
const r = Math.max(10, canvas.width / 60);
ctx.moveTo(b[0], b[1]);
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fill();
}
};
img.src = url;
}; };
$: if (url || bbox || maskBitset) void tick().then(redraw); $: if (blob || bbox || maskBitset) void tick().then(redraw);
onMount(() => { onMount(() => {
if (stage) parentW = stage.clientWidth; if (stage) parentW = stage.clientWidth;
@@ -119,12 +122,12 @@
return () => ro.disconnect(); return () => ro.disconnect();
}); });
onDestroy(() => { onDestroy(() => {
if (url) URL.revokeObjectURL(url); decodeGen += 1;
}); });
</script> </script>
<div class="stage" bind:this={stage}> <div class="stage" bind:this={stage}>
{#if url} {#if imgW > 0 || decoding || blob}
<canvas bind:this={canvas}></canvas> <canvas bind:this={canvas}></canvas>
{:else} {:else}
<div class="loading">Decoding photo…</div> <div class="loading">Decoding photo…</div>
@@ -0,0 +1,149 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
decodeOrientedBitmap,
ORIENTED_BITMAP_OPTIONS,
} from '../decodeOrientedBitmap.ts';
const here = dirname(fileURLToPath(import.meta.url));
const fixturePath = join(here, '../../tests/fixtures/set1-48x2/face-a.jpeg');
const RealCreateImageBitmap = globalThis.createImageBitmap;
afterEach(() => {
if (RealCreateImageBitmap === undefined) {
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
} else {
globalThis.createImageBitmap = RealCreateImageBitmap;
}
});
/** Minimal EXIF Orientation + largest SOF size for the orient-8 fixture. */
function readJpegOrientAndSof(buf: Buffer): { orientation: number; sofW: number; sofH: number } {
let orientation = 1;
const exif = buf.indexOf(Buffer.from('Exif\0\0'));
if (exif >= 0) {
const tiff = exif + 6;
const le = buf[tiff] === 0x49;
const u16 = (o: number) => (le ? buf.readUInt16LE(o) : buf.readUInt16BE(o));
const u32 = (o: number) => (le ? buf.readUInt32LE(o) : buf.readUInt32BE(o));
const ifd = tiff + u32(tiff + 4);
const n = u16(ifd);
for (let k = 0; k < n; k++) {
const e = ifd + 2 + k * 12;
const tag = u16(e);
const typ = u16(e + 2);
if (tag === 0x0112) {
orientation = typ === 3 ? u16(e + 8) : u32(e + 8);
}
}
}
let sofW = 0;
let sofH = 0;
let i = 0;
while (i < buf.length - 9) {
if (buf[i] === 0xff && buf[i + 1] >= 0xc0 && buf[i + 1] <= 0xc3) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
if (w * h > sofW * sofH) {
sofW = w;
sofH = h;
}
const segLen = buf.readUInt16BE(i + 2);
i += 2 + segLen;
} else {
i += 1;
}
}
return { orientation, sofW, sofH };
}
/** Orientation 5–8 swap width/height for display. */
function orientedSize(sofW: number, sofH: number, orientation: number): { w: number; h: number } {
if (orientation >= 5 && orientation <= 8) return { w: sofH, h: sofW };
return { w: sofW, h: sofH };
}
test('fixture face-a.jpeg is EXIF Orientation 8 (stored landscape → oriented portrait) (#72)', () => {
const buf = readFileSync(fixturePath);
const { orientation, sofW, sofH } = readJpegOrientAndSof(buf);
assert.equal(orientation, 8, 'matched-set JPEGs carry Orientation 8');
assert.equal(sofW, 4000);
assert.equal(sofH, 2256);
const { w, h } = orientedSize(sofW, sofH, orientation);
assert.equal(w, 2256);
assert.equal(h, 4000);
});
test('decodeOrientedBitmap always requests imageOrientation from-image (#72)', async () => {
let seen: ImageBitmapOptions | undefined;
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
seen = opts;
return { width: 2256, height: 4000, close() {} } as ImageBitmap;
};
const blob = new Blob([readFileSync(fixturePath)], { type: 'image/jpeg' });
const bmp = await decodeOrientedBitmap(blob);
assert.deepEqual(seen, ORIENTED_BITMAP_OPTIONS);
assert.equal(ORIENTED_BITMAP_OPTIONS.imageOrientation, 'from-image');
assert.equal(bmp.width, 2256);
assert.equal(bmp.height, 4000);
});
test('decodeOrientedBitmap falls back to bare createImageBitmap if option rejected (#72)', async () => {
let calls = 0;
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
calls += 1;
if (opts && 'imageOrientation' in opts) {
throw new TypeError('imageOrientation unsupported');
}
return { width: 90, height: 160, close() {} } as ImageBitmap;
};
const bmp = await decodeOrientedBitmap(new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }));
assert.equal(calls, 2, 'retry bare after option rejection');
assert.equal(bmp.width, 90);
assert.equal(bmp.height, 160);
});
test('decodeOrientedBitmap: Chromium orients fixture to 2256×4000 (#72)', async (t) => {
let chromium: typeof import('@playwright/test').chromium;
try {
({ chromium } = await import('@playwright/test'));
} catch {
t.skip('playwright not available');
return;
}
const browser = await chromium.launch();
try {
const page = await browser.newPage();
const buf = readFileSync(fixturePath);
const b64 = buf.toString('base64');
// Inline the same contract as decodeOrientedBitmap (worker-safe, no bundler).
const dims = await page.evaluate(async (b64jpeg: string) => {
const bin = atob(b64jpeg);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
const blob = new Blob([arr], { type: 'image/jpeg' });
const opts = { imageOrientation: 'from-image' as const };
let bmp: ImageBitmap;
try {
bmp = await createImageBitmap(blob, opts);
} catch {
bmp = await createImageBitmap(blob);
}
const out = { w: bmp.width, h: bmp.height };
bmp.close();
return out;
}, b64);
assert.equal(dims.w, 2256, `oriented width (got ${dims.w})`);
assert.equal(dims.h, 4000, `oriented height (got ${dims.h})`);
} finally {
await browser.close();
}
});
+3 -2
View File
@@ -10,6 +10,7 @@ import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect'; import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
const DEBOUNCE_MS = 2000; const DEBOUNCE_MS = 2000;
const BUSY_MS = 300; const BUSY_MS = 300;
@@ -512,7 +513,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'measure') return; if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state; const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return; if (lengthEighths === 0 || thicknessEighths === 0) return;
const bitmap = await createImageBitmap(source.blob); const bitmap = await decodeOrientedBitmap(source.blob);
const base = measure( const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx, approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
@@ -587,7 +588,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
widthPx: derived.widthPx, widthPx: derived.widthPx,
heightPx: derived.heightPx, heightPx: derived.heightPx,
}; };
const siblingBitmap = await createImageBitmap(photo.blob); const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean }; let encoded: { blob: Blob; sourceBelowFloor: boolean };
try { try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource }); encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
+29
View File
@@ -0,0 +1,29 @@
/**
* Shared decode for capture photos so PhotoStage, mask worker, inventory, and
* calculate all share one EXIF-oriented pixel space.
*
* Matched-set / DJI JPEGs often carry EXIF Orientation 8 (stored landscape,
* display portrait). `new Image()` and bare `createImageBitmap(blob)` can
* disagree across browsers → pink axis arrow space ≠ mask/overlay bitset space.
*
* Contract: always request `imageOrientation: 'from-image'`. If the runtime
* rejects that option (older engines), fall back to bare `createImageBitmap`;
* engines that already apply EXIF by default still match Image/canvas size.
* Engines that never apply EXIF may still diverge after fallback — document
* that path rather than inventing a software rotate.
*/
export const ORIENTED_BITMAP_OPTIONS: ImageBitmapOptions = {
imageOrientation: 'from-image',
};
export async function decodeOrientedBitmap(blob: Blob): Promise<ImageBitmap> {
try {
return await createImageBitmap(blob, ORIENTED_BITMAP_OPTIONS);
} catch (first) {
try {
return await createImageBitmap(blob);
} catch {
throw first;
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { blackKeep, greenKeep } from './sheetKeep.ts';
// --- Black sheet sensitivity (#74) ---
test('blackKeep: s=0 keeps dark live-edge wood (lum ~25–50), removes near-black sheet (#74)', () => {
// Near-black sheet (lum ≈ 5)
assert.equal(blackKeep(5, 5, 5, 0), false, 'near-black sheet removed at s=0');
assert.equal(blackKeep(0, 0, 0, 0), false, 'pure black sheet removed at s=0');
// Dark wood / bark / live edge
assert.equal(blackKeep(25, 22, 18, 0), true, 'lum~22 dark wood kept at s=0');
assert.equal(blackKeep(40, 35, 30, 0), true, 'lum~35 dark wood kept at s=0');
assert.equal(blackKeep(50, 45, 40, 0), true, 'lum~46 wood kept at s=0');
// Mid wood always kept
assert.equal(blackKeep(120, 90, 60, 0), true, 'mid wood kept at s=0');
});
test('blackKeep: s=100 removes more than s=0; curve monotonic (#74)', () => {
// Dark-ish pixel that sits between floors: sheet at high s, wood at low s
const darkish = { r: 80, g: 70, b: 60 }; // lum ≈ 72
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 0), true, 'lum~72 kept at s=0');
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 100), false, 'lum~72 sheet at s=100');
// Near-black always sheet
assert.equal(blackKeep(2, 2, 2, 0), false);
assert.equal(blackKeep(2, 2, 2, 100), false);
// Bright wood always kept
assert.equal(blackKeep(200, 160, 100, 0), true);
assert.equal(blackKeep(200, 160, 100, 100), true);
// Monotonic: once removed, stays removed as s increases
const probe = { r: 60, g: 55, b: 50 }; // lum ≈ 56
let prevKeep = true;
for (const s of [0, 25, 50, 75, 100]) {
const keep = blackKeep(probe.r, probe.g, probe.b, s);
if (!prevKeep) assert.equal(keep, false, `once sheet at lower s, stay sheet at s=${s}`);
prevKeep = keep;
}
// s=100 must remove more pixels than s=0 for this mid-dark band
assert.equal(blackKeep(probe.r, probe.g, probe.b, 0), true);
assert.equal(blackKeep(probe.r, probe.g, probe.b, 100), false);
});
test('greenKeep: s=0 does not eat mild wood greens; s=100 widens sheet match (#74)', () => {
// Neutral wood
assert.equal(greenKeep(140, 110, 80, 0), true);
assert.equal(greenKeep(140, 110, 80, 100), true);
// Mild green cast on wood (g - max(r,b) ≈ 5) — must keep at s=0
assert.equal(greenKeep(100, 105, 90, 0), true, 'mild green cast kept at s=0');
// Strong sheet green
assert.equal(greenKeep(40, 180, 50, 0), false, 'strong green sheet removed at s=0');
assert.equal(greenKeep(40, 180, 50, 100), false);
// Borderline: g-max ≈ 20 — may become sheet at high s
const border = { r: 80, g: 100, b: 70 }; // delta = 20
assert.equal(greenKeep(border.r, border.g, border.b, 0), true, 'delta20 kept at s=0');
assert.equal(greenKeep(border.r, border.g, border.b, 100), false, 'delta20 sheet at s=100');
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Sheet-vs-slab keep predicates used by the mask worker.
* Extracted so unit tests can cover sensitivity curves without the worker.
*/
/** Keep slab pixels on a green sheet. Higher s widens the green-sheet match. */
export function greenKeep(r: number, g: number, b: number, s: number): boolean {
const t = Math.max(0, Math.min(100, s)) / 100;
// s=0 removes only strongly green sheet; increasing sensitivity widens the match.
const isSheet = g - Math.max(r, b) > 45 - 35 * t;
return !isSheet;
}
/**
* Keep slab pixels on a black sheet.
* At s=0 only near-black sheet (very low lum) is removed so dark live-edge /
* bark (lum ~25–50) stays. The match widens monotonically toward s=100 (#74).
*/
export function blackKeep(r: number, g: number, b: number, s: number): boolean {
const t = Math.max(0, Math.min(100, s)) / 100;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
// Old floor 40 ate dark wood at minimum sensitivity. Floor 10 isolates near-black.
const isSheet = lum < 10 + 140 * t;
return !isSheet;
}
export function autoPickSheet(
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';
}
+204 -7
View File
@@ -6,6 +6,7 @@ import {
axisRightUnit, axisRightUnit,
buildOverlayPlan, buildOverlayPlan,
inventorySourceMap, inventorySourceMap,
BODY_WIDTH_FRAC,
type OverlayPlan, type OverlayPlan,
} from './overlay.ts'; } from './overlay.ts';
import { orientedRect, minAreaRect } from '../geometry/rect.ts'; import { orientedRect, minAreaRect } from '../geometry/rect.ts';
@@ -403,7 +404,7 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho
widths.push({ bin, width }); widths.push({ bin, width });
if (width > maxWidth) maxWidth = width; if (width > maxWidth) maxWidth = width;
} }
const thresh = Math.max(4, maxWidth * 0.32); const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
widths.sort((a, b) => a.bin - b.bin); widths.sort((a, b) => a.bin - b.bin);
let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin; let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin;
for (const { bin, width } of widths) { for (const { bin, width } of widths) {
@@ -423,8 +424,8 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]); const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
// Axial edge-inset (#71) shortens span slightly vs tip stations // Axial edge-inset (#71) shortens span slightly vs tip stations
assert.ok( assert.ok(
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 24, lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
`length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow inset)`, `length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow small axial inset only; #73)`,
); );
// Axial stations of length ends = tip stations (pure axis-right offset does not change along-u). // Axial stations of length ends = tip stations (pure axis-right offset does not change along-u).
@@ -545,6 +546,43 @@ function tiltedAxis25Fixture() {
return { W, H, bitset, bbox, ux0, uy0, rx0, ry0, halfL, cx, cy }; return { W, H, bitset, bbox, ux0, uy0, rx0, ry0, halfL, cx, cy };
} }
test('buildOverlayPlan: angleDeg=90 vertical long side → width left→right within 2° of horizontal (#72)', () => {
// Upright slab: approved length axis is vertical (angleDeg 90). Width marks must
// run left→right ≈ horizontal (atan2 ≈ 0 or ±π), not slanted with the photo.
const W = 200, H = 400;
const bitset = new Uint8Array(W * H);
for (let y = 40; y <= 360; y++) {
for (let x = 60; x <= 140; x++) bitset[y * W + x] = 1;
}
const prev = minAreaRect(bitset, W, H)!;
const bbox = orientedRect(prev, 90);
assert.equal(bbox.angleDeg, 90);
const scale = 10; // 320px span ≈ 32"
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
});
assert.ok(plan.widths.length > 0, 'expected width stations');
for (const w of plan.widths) {
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
// Fold to [-90, 90] so left→right and right→left both count as horizontal.
let deg = (ang * 180) / Math.PI;
if (deg > 90) deg -= 180;
if (deg < -90) deg += 180;
assert.ok(
Math.abs(deg) <= 2,
`width station ${w.stationIn} left→right must be within 2° of horizontal (atan2 deg=${deg.toFixed(2)}; left=${w.left} right=${w.right})`,
);
}
});
test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || (rx,ry) (#70)', () => { test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || (rx,ry) (#70)', () => {
const { W, H, bitset, bbox, cx, cy } = tiltedAxis25Fixture(); const { W, H, bitset, bbox, cx, cy } = tiltedAxis25Fixture();
const scale = 10; const scale = 10;
@@ -586,7 +624,7 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right ||
wbins.push({ bin, width }); wbins.push({ bin, width });
if (width > maxW) maxW = width; if (width > maxW) maxW = width;
} }
const thresh = Math.max(4, maxW * 0.32); const thresh = Math.max(4, maxW * BODY_WIDTH_FRAC);
wbins.sort((a, b) => a.bin - b.bin); wbins.sort((a, b) => a.bin - b.bin);
let tipLo = wbins[0].bin, tipHi = wbins[wbins.length - 1].bin; let tipLo = wbins[0].bin, tipHi = wbins[wbins.length - 1].bin;
for (const { bin, width } of wbins) { for (const { bin, width } of wbins) {
@@ -658,12 +696,13 @@ function fringeTipFixture() {
for (let y = 120; y <= 440; y++) { for (let y = 120; y <= 440; y++) {
for (let x = 90; x <= 190; x++) bitset[y * W + x] = 1; for (let x = 90; x <= 190; x++) bitset[y * W + x] = 1;
} }
// Dense fringe whiskers (narrower than body) past top/bottom — denser than 0.2% // Dense fringe whiskers (~10% of body width) past tips — denser than 0.2%.
// Must stay below BODY_WIDTH_FRAC (#73 ~0.15) so width-aware tips exclude them.
for (let y = 95; y < 120; y++) { for (let y = 95; y < 120; y++) {
for (let x = 130; x <= 150; x++) bitset[y * W + x] = 1; for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
} }
for (let y = 441; y <= 465; y++) { for (let y = 441; y <= 465; y++) {
for (let x = 130; x <= 150; x++) bitset[y * W + x] = 1; for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
} }
// Approved axis ~vertical (slight tilt) // Approved axis ~vertical (slight tilt)
const ang = (8 * Math.PI) / 180; const ang = (8 * Math.PI) / 180;
@@ -920,3 +959,161 @@ test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller fo
); );
} }
}); });
// --- Tapered live-edge tips must not be dropped (#73) ---
/**
* Live-edge taper: body max width ~100px; tips taper to ~20% of max (~20px).
* Old BODY_WIDTH_FRAC=0.32 dropped tip bins; length dim under-reached wood ends.
*/
function taperedTipFixture() {
const W = 240, H = 500;
const bitset = new Uint8Array(W * H);
const y0 = 40, y1 = 460;
const maxHalf = 50; // body half-width → ~100px
for (let y = y0; y <= y1; y++) {
const t = (y - y0) / (y1 - y0); // 0..1 along length
// Cosine taper: tips at ~20% of max width, mid at full
const tipFrac = 0.20;
const envelope = tipFrac + (1 - tipFrac) * Math.sin(Math.PI * t);
const half = Math.max(2, Math.round(maxHalf * envelope));
const cx = 120;
for (let x = cx - half; x <= cx + half; x++) bitset[y * W + x] = 1;
}
const ang = (5 * Math.PI) / 180;
const ux0 = Math.sin(ang);
const uy0 = Math.cos(ang);
const rx0 = -uy0;
const ry0 = ux0;
const cx = 120, cy = 250, halfL = 220, halfW = 70;
const corners: [number, number][] = [
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
];
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 5 };
return { W, H, bitset, bbox, y0, y1, tipFrac: 0.20, maxHalf, ux0, uy0, cx, cy };
}
test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ tip span − small inset (#73)', () => {
const { W, H, bitset, bbox, y0, y1, tipFrac, maxHalf, ux0, uy0, cx, cy } = taperedTipFixture();
// Sanity: tip bins are ~15–25% of max width (would fail old 0.32 thresh)
const tipHalf = Math.max(2, Math.round(maxHalf * tipFrac));
const tipWidth = tipHalf * 2 + 1;
const bodyWidth = maxHalf * 2 + 1;
const tipRatio = tipWidth / bodyWidth;
assert.ok(tipRatio >= 0.15 && tipRatio <= 0.25, `fixture tip ratio ${tipRatio.toFixed(3)} in [0.15,0.25]`);
assert.ok(tipRatio < 0.32, `fixture tip ratio ${tipRatio.toFixed(3)} must be < old 0.32 thresh`);
const scale = 10;
const wood = maskWoodAabb(bitset, W, H);
const slabBounds = {
x: wood.minX - 8,
y: wood.minY - 8,
w: wood.maxX - wood.minX + 16,
h: wood.maxY - wood.minY + 16,
};
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 42,
lengthLabel: '42"',
mapToInventory: inv.mapToInventory,
});
// Width-aware tip stations with current BODY_WIDTH_FRAC
const { start: aa, end: bb } = axisEndpoints(bbox);
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
const ux = ex / elen, uy = ey / elen;
const cProj = cx * ux + cy * uy;
const rx = -uy, ry = ux;
const binMin = new Map<number, number>();
const binMax = new Map<number, number>();
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
const along = x * ux + y * uy;
const across = (x - cx) * rx + (y - cy) * ry;
const bin = Math.round(along);
const prevMin = binMin.get(bin);
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
const prevMax = binMax.get(bin);
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
}
}
let maxWidth = 0;
const widths: { bin: number; width: number }[] = [];
for (const [bin, minA] of binMin) {
const maxA = binMax.get(bin)!;
const width = maxA - minA + 1;
widths.push({ bin, width });
if (width > maxWidth) maxWidth = width;
}
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
widths.sort((a, b) => a.bin - b.bin);
let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin;
for (const { bin, width } of widths) {
if (width >= thresh) { tipLo = bin; break; }
}
for (let i = widths.length - 1; i >= 0; i--) {
if (widths[i].width >= thresh) { tipHi = widths[i].bin; break; }
}
// Tip bins at the axial extremes must survive the threshold (not dropped)
const firstBin = widths[0];
const lastBin = widths[widths.length - 1];
assert.ok(
firstBin.width >= thresh || tipLo <= firstBin.bin + 3,
`low tip bin width=${firstBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipLo=${tipLo} first=${firstBin.bin})`,
);
assert.ok(
lastBin.width >= thresh || tipHi >= lastBin.bin - 3,
`high tip bin width=${lastBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipHi=${tipHi} last=${lastBin.bin})`,
);
// Axial tip span must reach near the physical wood ends (y0/y1)
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
const oriented = pickBottomTowardTop(tipStart, tipEnd);
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
// Length must reach tip stations minus only a small axial inset (tighten old −24px)
assert.ok(
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
`length span ${lenAxial.toFixed(1)} must match tip span ${tipAxial.toFixed(1)} within small inset (#73)`,
);
// Prove old 0.32 thresh would have under-reached (fixture is meaningful)
const oldThresh = Math.max(4, maxWidth * 0.32);
let oldLo = widths[0].bin, oldHi = widths[widths.length - 1].bin;
for (const { bin, width } of widths) {
if (width >= oldThresh) { oldLo = bin; break; }
}
for (let i = widths.length - 1; i >= 0; i--) {
if (widths[i].width >= oldThresh) { oldHi = widths[i].bin; break; }
}
assert.ok(
(tipHi - tipLo) > (oldHi - oldLo) + 10,
`new tip span ${tipHi - tipLo} must exceed old-0.32 span ${oldHi - oldLo} (taper kept)`,
);
// Still no overshoot past wood AABB along axis (keep #69/#71)
const woodProjs: number[] = [];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
woodProjs.push(x * ux + y * uy);
}
}
woodProjs.sort((a, b) => a - b);
const woodSpan = woodProjs[woodProjs.length - 1] - woodProjs[0];
const tipSpanSrc = tipHi - tipLo;
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
});
+17 -8
View File
@@ -82,7 +82,13 @@ export interface OverlayPlanInput {
} }
/** /**
* Axial tip stations from across-width profile (#71). * Min across-width as a fraction of max bin width for an axial bin to count as a tip.
* #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain.
*/
export const BODY_WIDTH_FRAC = 0.15;
/**
* Axial tip stations from across-width profile (#71/#73).
* Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles; * Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles;
* require each axial bin to have meaningful across-width before it counts as a tip. * require each axial bin to have meaningful across-width before it counts as a tip.
*/ */
@@ -121,8 +127,8 @@ function widthAwareAxialRange(
widths.push({ bin, width }); widths.push({ bin, width });
if (width > maxWidth) maxWidth = width; if (width > maxWidth) maxWidth = width;
} }
// Body threshold: exclude narrow fringe; keep natural mild tapers. // Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73).
const thresh = Math.max(4, maxWidth * 0.32); const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
widths.sort((a, b) => a.bin - b.bin); widths.sort((a, b) => a.bin - b.bin);
let tipLo = widths[0].bin; let tipLo = widths[0].bin;
let tipHi = widths[widths.length - 1].bin; let tipHi = widths[widths.length - 1].bin;
@@ -282,7 +288,10 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
// Inset for stroke + arrowhead so visual tips sit on/inside edges (#71). // Inset for stroke + arrowhead so visual tips sit on/inside edges (#71).
const lineW = Math.max(2, Math.round(fontPx / 10)); const lineW = Math.max(2, Math.round(fontPx / 10));
const arrowHead = Math.max(7, fontPx * 0.32); const arrowHead = Math.max(7, fontPx * 0.32);
const edgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15); // Width tips retain stroke/arrowhead protection. Length endpoints need only
// half-stroke protection: larger arrowhead-derived inset visibly under-reached tips (#73).
const widthEdgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15);
const lengthEdgeInset = Math.max(0.5, lineW / 2);
const pad = Math.max(10, Math.min(28, fontPx * 1.1)); const pad = Math.max(10, Math.min(28, fontPx * 1.1));
const stations = overlayWidthStations(lengthIn); const stations = overlayWidthStations(lengthIn);
const widths: OverlayWidthMark[] = []; const widths: OverlayWidthMark[] = [];
@@ -300,8 +309,8 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const wlen = Math.hypot(wdx, wdy) || 1; const wlen = Math.hypot(wdx, wdy) || 1;
const wux = wdx / wlen; const wux = wdx / wlen;
const wuy = wdy / wlen; const wuy = wdy / wlen;
const left: [number, number] = [leftMapped[0] + wux * edgeInset, leftMapped[1] + wuy * edgeInset]; const left: [number, number] = [leftMapped[0] + wux * widthEdgeInset, leftMapped[1] + wuy * widthEdgeInset];
const right: [number, number] = [rightMapped[0] - wux * edgeInset, rightMapped[1] - wuy * edgeInset]; const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset];
// Width number sits axis-left outside the slab (past left tip). // Width number sits axis-left outside the slab (past left tip).
const labelOff = extent.minP - pad - fontPx * 0.85; const labelOff = extent.minP - pad - fontPx * 0.85;
const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff]; const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff];
@@ -337,8 +346,8 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const llen0 = Math.hypot(ldx0, ldy0) || 1; const llen0 = Math.hypot(ldx0, ldy0) || 1;
const lux = ldx0 / llen0; const lux = ldx0 / llen0;
const luy = ldy0 / llen0; const luy = ldy0 / llen0;
const aInv: [number, number] = [aMapped[0] + lux * edgeInset, aMapped[1] + luy * edgeInset]; const aInv: [number, number] = [aMapped[0] + lux * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset];
const bInv: [number, number] = [bMapped[0] - lux * edgeInset, bMapped[1] - luy * edgeInset]; const bInv: [number, number] = [bMapped[0] - lux * lengthEdgeInset, bMapped[1] - luy * lengthEdgeInset];
const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2]; const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2];
// Length label further axis-right of dim line so glyphs stay off the wood (#71). // Length label further axis-right of dim line so glyphs stay off the wood (#71).
const mapOrigin = mapToInventory(0, 0); const mapOrigin = mapToInventory(0, 0);
+4 -22
View File
@@ -1,31 +1,13 @@
import { minAreaRect, slabBounds } from '../geometry/rect'; import { minAreaRect, slabBounds } from '../geometry/rect';
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types'; import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
let bitmap: ImageBitmap | null = null; let bitmap: ImageBitmap | null = null;
let data: Uint8ClampedArray | null = null; let data: Uint8ClampedArray | null = null;
let w = 0, h = 0; let w = 0, h = 0;
let current = 0; // highest job id issued by main; older replies are dropped 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 { function runMask(params: MaskParams, id: number): WorkerOut {
if (!data) return { id, kind: 'failed', reason: 'no source decoded' }; if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
let sheet = params.sheet; let sheet = params.sheet;
@@ -36,7 +18,7 @@ function runMask(params: MaskParams, id: number): WorkerOut {
const i = (y * w + x) * 4; const i = (y * w + x) * 4;
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] }); samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
} }
sheet = autoPick(samples); sheet = autoPickSheet(samples);
} }
const keep = sheet === 'green' ? greenKeep : blackKeep; const keep = sheet === 'green' ? greenKeep : blackKeep;
const s = Math.max(0, Math.min(100, params.sensitivity)); const s = Math.max(0, Math.min(100, params.sensitivity));
@@ -69,7 +51,7 @@ self.onmessage = (e: MessageEvent<WorkerIn>) => {
} }
data = null; data = null;
w = h = 0; w = h = 0;
createImageBitmap(msg.blob) decodeOrientedBitmap(msg.blob)
.then((bmp) => { .then((bmp) => {
w = bmp.width; w = bmp.width;
h = bmp.height; h = bmp.height;