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.
This commit is contained in:
2026-09-16 15:18:06 +00:00
parent 037a4b870a
commit 03d3fcf460
5 changed files with 281 additions and 36 deletions
+167 -7
View File
@@ -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<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})`);
});