From 03d3fcf4607a2d17d645f938bbf1d428a5abfaf4 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Wed, 16 Sep 2026 15:18:06 +0000 Subject: [PATCH] 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. --- frontend/src/mask/sheetKeep.test.ts | 59 ++++++++++ frontend/src/mask/sheetKeep.ts | 36 ++++++ frontend/src/png/overlay.test.ts | 174 ++++++++++++++++++++++++++-- frontend/src/png/overlay.ts | 25 ++-- frontend/src/worker/mask.worker.ts | 23 +--- 5 files changed, 281 insertions(+), 36 deletions(-) create mode 100644 frontend/src/mask/sheetKeep.test.ts create mode 100644 frontend/src/mask/sheetKeep.ts diff --git a/frontend/src/mask/sheetKeep.test.ts b/frontend/src/mask/sheetKeep.test.ts new file mode 100644 index 0000000..9ed95a7 --- /dev/null +++ b/frontend/src/mask/sheetKeep.test.ts @@ -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'); +}); diff --git a/frontend/src/mask/sheetKeep.ts b/frontend/src/mask/sheetKeep.ts new file mode 100644 index 0000000..4577d00 --- /dev/null +++ b/frontend/src/mask/sheetKeep.ts @@ -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'; +} diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index 37f8656..d3940d9 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -6,6 +6,7 @@ import { axisRightUnit, buildOverlayPlan, inventorySourceMap, + BODY_WIDTH_FRAC, type OverlayPlan, } from './overlay.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 }); 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); let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin; 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]); // Axial edge-inset (#71) shortens span slightly vs tip stations assert.ok( - lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 24, - `length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow inset)`, + lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8, + `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). @@ -623,7 +624,7 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || wbins.push({ bin, 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); let tipLo = wbins[0].bin, tipHi = wbins[wbins.length - 1].bin; for (const { bin, width } of wbins) { @@ -695,12 +696,13 @@ function fringeTipFixture() { for (let y = 120; y <= 440; y++) { 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 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 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) const ang = (8 * Math.PI) / 180; @@ -957,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(); + const binMax = new Map(); + 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})`); +}); diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index 5e6d7b2..f487961 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -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; * 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 }); if (width > maxWidth) maxWidth = width; } - // Body threshold: exclude narrow fringe; keep natural mild tapers. - const thresh = Math.max(4, maxWidth * 0.32); + // Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73). + const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC); widths.sort((a, b) => a.bin - b.bin); let tipLo = widths[0].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). const lineW = Math.max(2, Math.round(fontPx / 10)); 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 stations = overlayWidthStations(lengthIn); const widths: OverlayWidthMark[] = []; @@ -300,8 +309,8 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { const wlen = Math.hypot(wdx, wdy) || 1; const wux = wdx / wlen; const wuy = wdy / wlen; - const left: [number, number] = [leftMapped[0] + wux * edgeInset, leftMapped[1] + wuy * edgeInset]; - const right: [number, number] = [rightMapped[0] - wux * edgeInset, rightMapped[1] - wuy * edgeInset]; + const left: [number, number] = [leftMapped[0] + wux * widthEdgeInset, leftMapped[1] + wuy * widthEdgeInset]; + const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset]; // Width number sits axis-left outside the slab (past left tip). const labelOff = extent.minP - pad - fontPx * 0.85; 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 lux = ldx0 / llen0; const luy = ldy0 / llen0; - const aInv: [number, number] = [aMapped[0] + lux * edgeInset, aMapped[1] + luy * edgeInset]; - const bInv: [number, number] = [bMapped[0] - lux * edgeInset, bMapped[1] - luy * edgeInset]; + const aInv: [number, number] = [aMapped[0] + lux * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset]; + 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]; // Length label further axis-right of dim line so glyphs stay off the wood (#71). const mapOrigin = mapToInventory(0, 0); diff --git a/frontend/src/worker/mask.worker.ts b/frontend/src/worker/mask.worker.ts index 8844901..b0bebe8 100644 --- a/frontend/src/worker/mask.worker.ts +++ b/frontend/src/worker/mask.worker.ts @@ -1,32 +1,13 @@ import { minAreaRect, slabBounds } from '../geometry/rect'; 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 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; @@ -37,7 +18,7 @@ function runMask(params: MaskParams, id: number): WorkerOut { const i = (y * w + x) * 4; 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 s = Math.max(0, Math.min(100, params.sensitivity));