feat(photos): burn axis-oriented dimension overlay PNG (#63)

Add pure station/axis math and canvas burn-in for a second calibration
inventory PNG (results.overlayPng). Width stations start 6 in from the
EXIF-oriented photo bottom along the approved length axis (6, 18, … ≤
L−6); length graphic sits on axis-right outside the slab. Clean
inventoryPng and batchApply[cal]=null stay unchanged.
This commit is contained in:
2026-09-15 21:31:22 +00:00
parent c674931f5c
commit a22525023a
4 changed files with 393 additions and 5 deletions
+5 -1
View File
@@ -67,6 +67,8 @@ export interface Measures {
sqft: number; // 2 dp sqft: number; // 2 dp
bdft: number; // 2 dp bdft: number; // 2 dp
inventoryPng: Blob; inventoryPng: Blob;
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
overlayPng?: Blob;
sourceBelowFloor: boolean; sourceBelowFloor: boolean;
} }
@@ -138,8 +140,10 @@ export interface Draft {
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
lengthEighths: Eighths; lengthEighths: Eighths;
thicknessEighths: Eighths; thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
inventoryPng: Blob | null; // stored so Continue can show Measure results inventoryPng: Blob | null; // stored so Continue can show Measure results
/** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */
overlayPng?: Blob | null;
/** Capture-order batch apply results; blobs are retained for the #64 lineup. */ /** Capture-order batch apply results; blobs are retained for the #64 lineup. */
batchApply?: BatchPhotoResult[]; batchApply?: BatchPhotoResult[];
} }
+39 -3
View File
@@ -9,6 +9,7 @@ import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure'; 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';
const DEBOUNCE_MS = 2000; const DEBOUNCE_MS = 2000;
const BUSY_MS = 300; const BUSY_MS = 300;
@@ -52,6 +53,8 @@ export interface SessionOpts {
draft?: DraftStore; draft?: DraftStore;
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */ /** Injectable only for deterministic tests; production uses encodeInventoryPng. */
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>; encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
} }
const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft }; const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
@@ -64,6 +67,7 @@ const batchPhotosOf = (d: Draft): Source[] =>
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const drafts = opts.draft ?? defaultDraft; const drafts = opts.draft ?? defaultDraft;
const encodeInventory = opts.encodeInventory ?? encodeInventoryPng; const encodeInventory = opts.encodeInventory ?? encodeInventoryPng;
const encodeOverlay = opts.encodeOverlay ?? encodeOverlayPng;
const capture = writable<Capture>({ k: 'batch', photos: [] }); const capture = writable<Capture>({ k: 'batch', photos: [] });
const busy = writable(false); const busy = writable(false);
let state: Capture = { k: 'batch', photos: [] }; let state: Capture = { k: 'batch', photos: [] };
@@ -204,7 +208,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}; };
} }
if (state.k === 'measure') { if (state.k === 'measure') {
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); const { inventoryPng: _drop, overlayPng: _dropOverlay, ...rest } = state.results ?? ({} as Measures);
return { return {
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx, calibrationIndex: calIdx,
@@ -213,6 +217,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null, results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null, inventoryPng: state.results?.inventoryPng ?? null,
overlayPng: state.results?.overlayPng ?? null,
batchApply: state.batchApply, batchApply: state.batchApply,
}; };
} }
@@ -342,7 +347,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
axis: { bbox: draftAxisBbox(d) }, axis: { bbox: draftAxisBbox(d) },
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results results: d.results
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() } ? {
...d.results,
inventoryPng: d.inventoryPng ?? new Blob(),
overlayPng: d.overlayPng ?? undefined,
}
: null, : null,
batchApply: d.batchApply, batchApply: d.batchApply,
}); });
@@ -521,7 +530,34 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
bitmap.close(); bitmap.close();
if (pngUrl) URL.revokeObjectURL(pngUrl); if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(pngBlob); pngUrl = URL.createObjectURL(pngBlob);
const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
// #63: second copy of the calibration inventory PNG with dimension overlays.
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
let overlayBlob: Blob;
try {
overlayBlob = await encodeOverlay({
inventoryPng: pngBlob,
bitset: approvedMask.bitset,
widthPx: source.widthPx,
heightPx: source.heightPx,
bbox: axis.bbox,
scale: base.scale,
lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
});
} catch {
// Deterministic tests may inject a non-decodable inventory blob; still
// return a distinct image/png so callers never confuse it with clean.
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
}
const results: Measures = {
...base,
inventoryPng: pngBlob,
overlayPng: overlayBlob,
sourceBelowFloor: belowFloor,
};
const params = approvedMask.params; const params = approvedMask.params;
const calIndex = calibrationIndex != null && calibrationIndex >= 0 const calIndex = calibrationIndex != null && calibrationIndex >= 0
? calibrationIndex ? calibrationIndex
+3 -1
View File
@@ -16,10 +16,12 @@ test('overlayWidthStations: every 12 in from 6 while ≤ L−6 (#63)', () => {
assert.deepEqual(overlayWidthStations(40), [6, 18, 30]); assert.deepEqual(overlayWidthStations(40), [6, 18, 30]);
assert.deepEqual(overlayWidthStations(12), [6]); assert.deepEqual(overlayWidthStations(12), [6]);
assert.deepEqual(overlayWidthStations(18), [6]); assert.deepEqual(overlayWidthStations(18), [6]);
assert.deepEqual(overlayWidthStations(19), [6, 18]); assert.deepEqual(overlayWidthStations(19), [6]); // L−6=13 → 18 excluded
assert.deepEqual(overlayWidthStations(11), []); assert.deepEqual(overlayWidthStations(11), []);
assert.deepEqual(overlayWidthStations(10), []); assert.deepEqual(overlayWidthStations(10), []);
assert.deepEqual(overlayWidthStations(6), []); assert.deepEqual(overlayWidthStations(6), []);
assert.deepEqual(overlayWidthStations(24), [6, 18]);
assert.deepEqual(overlayWidthStations(30), [6, 18]);
}); });
test('overlayWidthStations: never tip-inclusive 0 or L (#63)', () => { test('overlayWidthStations: never tip-inclusive 0 or L (#63)', () => {
+346
View File
@@ -0,0 +1,346 @@
import type { BBox } from '../capture/capture.types';
import { axisEndpoints } from '../measure/measure';
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
export function overlayWidthStations(lengthIn: number): number[] {
if (!(lengthIn > 6)) return [];
const last = lengthIn - 6;
const out: number[] = [];
for (let s = 6; s <= last + 1e-9; s += 12) out.push(s);
return out;
}
/**
* EXIF-oriented photo bottom = larger canvas y. Pick which end of the approved
* length-axis span is bottom; return unit from bottom → top along that span.
*/
export function pickBottomTowardTop(
start: [number, number],
end: [number, number],
): {
bottom: [number, number];
top: [number, number];
ux: number;
uy: number;
} {
// Larger y is closer to the bottom edge of the oriented photo.
// Tie-break (horizontal axis): prefer larger x for a stable choice.
const startIsBottom =
start[1] > end[1] || (start[1] === end[1] && start[0] >= end[0]);
const bottom = startIsBottom ? start : end;
const top = startIsBottom ? end : start;
const dx = top[0] - bottom[0];
const dy = top[1] - bottom[1];
const len = Math.hypot(dx, dy) || 1;
return { bottom, top, ux: dx / len, uy: dy / len };
}
/** Screen-space right of travel (canvas y↓): (ux,uy) → (−uy, ux). */
export function axisRightUnit(ux: number, uy: number): [number, number] {
return [-uy, ux];
}
export interface OverlayWidthMark {
stationIn: number;
center: [number, number];
left: [number, number];
right: [number, number];
label: string;
}
export interface OverlayLengthMark {
a: [number, number];
b: [number, number];
labelAt: [number, number];
label: string;
/** True when the line sits outside the slab on the axis-right side. */
outside: boolean;
}
export interface OverlayPlan {
stroke: '#000000';
fontPx: number;
length: OverlayLengthMark;
widths: OverlayWidthMark[];
}
export interface OverlayPlanInput {
bitset: Uint8Array;
widthPx: number;
heightPx: number;
bbox: BBox;
scale: number; // px per inch
lengthIn: number;
lengthLabel: string;
/** Map source px → inventory PNG px (identity OK for unit tests). */
mapToInventory: (x: number, y: number) => [number, number];
}
function maskAxisSegment(
bitset: Uint8Array,
w: number,
h: number,
bbox: BBox,
): { start: [number, number]; end: [number, number]; ux: number; uy: number; span: number } {
const { start: a, end: b } = axisEndpoints(bbox);
const ex = b[0] - a[0];
const ey = b[1] - a[1];
const elen = Math.hypot(ex, ey) || 1;
const ux = ex / elen;
const uy = ey / elen;
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
const cProj = cx * ux + cy * uy;
let minP = Infinity;
let maxP = -Infinity;
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
if (!bitset[row + x]) continue;
const p = x * ux + y * uy;
if (p < minP) minP = p;
if (p > maxP) maxP = p;
}
}
if (maxP < minP) return { start: a, end: b, ux, uy, span: 0 };
return {
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
ux,
uy,
span: maxP - minP,
};
}
/** Probe slab extent along unit (px, py) through origin (ox, oy). */
function probeExtent(
bitset: Uint8Array,
w: number,
h: number,
ox: number,
oy: number,
px: number,
py: number,
): { minP: number; maxP: number } | null {
const half = Math.max(2, Math.round((w + h) / 4));
let minP = Infinity;
let maxP = -Infinity;
for (let p = -half; p <= half; p++) {
const x = Math.round(ox + px * p);
const y = Math.round(oy + py * p);
if (x < 0 || y < 0 || x >= w || y >= h) continue;
if (bitset[y * w + x]) {
if (p < minP) minP = p;
if (p > maxP) maxP = p;
}
}
if (maxP < minP) return null;
return { minP, maxP };
}
function fmtInches(inches: number): string {
const whole = Math.floor(inches + 1e-9);
const rem = Math.round((inches - whole) * 8);
if (rem === 0) return `${whole}"`;
const frac: Record<number, string> = {
1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '',
};
if (rem === 8) return `${whole + 1}"`;
const part = frac[rem] ?? `${rem}/8`;
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
}
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const { bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory } = input;
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
// Re-orient span ends so EXIF bottom establishes station origin; axis still
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
const oriented = pickBottomTowardTop(seg.start, seg.end);
const { bottom, top, ux, uy } = oriented;
const [rx, ry] = axisRightUnit(ux, uy);
const stations = overlayWidthStations(lengthIn);
const widths: OverlayWidthMark[] = [];
for (const stationIn of stations) {
const t = stationIn * scale;
const ox = bottom[0] + ux * t;
const oy = bottom[1] + uy * t;
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
if (!extent) continue;
// left = opposite of axis-right; right = axis-right tip on slab edge
const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP];
const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP];
const widthIn = (extent.maxP - extent.minP + 1) / scale;
widths.push({
stationIn,
center: mapToInventory(ox, oy),
left: mapToInventory(leftSrc[0], leftSrc[1]),
right: mapToInventory(rightSrc[0], rightSrc[1]),
label: fmtInches(widthIn),
});
}
// Length graphic: beside wood on axis-right, outside max right extent.
let maxRight = 0;
const samples = Math.max(8, Math.round(lengthIn));
for (let i = 0; i <= samples; i++) {
const t = (i / samples) * (lengthIn * scale);
const ox = bottom[0] + ux * t;
const oy = bottom[1] + uy * t;
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
}
const pad = Math.max(16, scale * 0.75);
const outward = maxRight + pad;
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward];
const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward];
const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2];
// Nudge label further outward a bit
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)];
const fontPx = Math.max(28, Math.round(scale * 1.2));
return {
stroke: '#000000',
fontPx,
length: {
a: mapToInventory(aSrc[0], aSrc[1]),
b: mapToInventory(bSrc[0], bSrc[1]),
labelAt: mapToInventory(labelSrc[0], labelSrc[1]),
label: lengthLabel,
outside: true,
},
widths,
};
}
/** Draw black dimension graphics onto an existing canvas (inventory PNG size). */
export function drawOverlayPlan(
ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D,
plan: OverlayPlan,
): void {
const { stroke, fontPx } = plan;
ctx.save();
ctx.strokeStyle = stroke;
ctx.fillStyle = stroke;
ctx.lineWidth = Math.max(3, Math.round(fontPx / 10));
ctx.lineCap = 'round';
ctx.font = `bold ${fontPx}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const arrow = (from: [number, number], to: [number, number]) => {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const head = Math.max(10, fontPx * 0.35);
ctx.beginPath();
ctx.moveTo(from[0], from[1]);
ctx.lineTo(to[0], to[1]);
ctx.stroke();
// arrow tips at both ends
for (const [sx, sy, dir] of [
[from[0], from[1], 1],
[to[0], to[1], -1],
] as const) {
const px = -uy;
const py = ux;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + dir * ux * head + px * head * 0.4, sy + dir * uy * head + py * head * 0.4);
ctx.lineTo(sx + dir * ux * head - px * head * 0.4, sy + dir * uy * head - py * head * 0.4);
ctx.closePath();
ctx.fill();
}
};
// Length
arrow(plan.length.a, plan.length.b);
ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]);
// Widths
for (const w of plan.widths) {
arrow(w.left, w.right);
ctx.fillText(w.label, w.center[0], w.center[1]);
}
ctx.restore();
}
export interface InventoryMap {
mapToInventory: (x: number, y: number) => [number, number];
outW: number;
outH: number;
}
/** Same crop math as composeInventoryFrame — source → inventory PNG pixels. */
export function inventorySourceMap(
slabBounds: { x: number; y: number; w: number; h: number },
source: { widthPx: number; heightPx: number },
outW: number,
outH: number,
): InventoryMap {
const FILL = 0.8;
const x0 = Math.max(0, Math.round(slabBounds.x));
const y0 = Math.max(0, Math.round(slabBounds.y));
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
const sw = x1 - x0;
const sh = y1 - y0;
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
const dw = sw * scale;
const dh = sh * scale;
const ox = (outW - dw) / 2;
const oy = (outH - dh) / 2;
return {
outW,
outH,
mapToInventory: (sx, sy) => [ox + (sx - x0) * scale, oy + (sy - y0) * scale],
};
}
export interface EncodeOverlayInput {
/** Clean calibration inventory PNG bytes. */
inventoryPng: Blob;
bitset: Uint8Array;
widthPx: number;
heightPx: number;
bbox: BBox;
scale: number;
lengthIn: number;
lengthLabel: string;
slabBounds: { x: number; y: number; w: number; h: number };
source: { widthPx: number; heightPx: number };
}
/**
* Burn black dimension overlays onto a copy of the clean calibration inventory PNG.
* Returns a new image/png blob; never mutates the clean inventory.
*/
export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob> {
const bmp = await createImageBitmap(input.inventoryPng);
try {
const outW = bmp.width;
const outH = bmp.height;
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
const plan = buildOverlayPlan({
bitset: input.bitset,
widthPx: input.widthPx,
heightPx: input.heightPx,
bbox: input.bbox,
scale: input.scale,
lengthIn: input.lengthIn,
lengthLabel: input.lengthLabel,
mapToInventory,
});
drawOverlayPlan(ctx, plan);
return await canvas.convertToBlob({ type: 'image/png' });
} finally {
bmp.close();
}
}