Files
SlabUploader/frontend/src/png/overlay.ts
T
grokbot_ty a22525023a 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.
2026-09-15 21:31:22 +00:00

347 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}