fix(overlay): Photos-ready dims follow tape, not wood MAR

After rotateCalibration, map tape tick A→B through bakeTiltTransform into
baked cal coords, orient overlay bbox with orientedRect(..., tapeAngle), and
pass optional axis into buildOverlayPlan so length || tape and widths ⊥ tape.
This commit is contained in:
grokbot_ty
2026-09-26 08:08:21 -07:00
parent d7fc424b69
commit 4ea2618475
5 changed files with 257 additions and 18 deletions
+20 -2
View File
@@ -10,7 +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 { rotateCalibration } from '../png/rotateCal'; import { bakeTiltTransform, rotateCalibration } from '../png/rotateCal';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry'; import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
import { getCropFill } from '../settings/cropFill'; import { getCropFill } from '../settings/cropFill';
@@ -636,19 +636,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// #63: second copy of the calibration inventory PNG with dimension overlays. // #63: second copy of the calibration inventory PNG with dimension overlays.
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal]. // Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
// Overlay uses the same baked orientation as the clean cal PNG (#99 scope). // Overlay uses the same baked orientation as the clean cal PNG (#99 scope).
// Length / width directions follow the tape (not wood MAR): map tick A→B
// through the same bake as rotateCal, then orient the overlay bbox to that.
let overlayBlob: Blob; let overlayBlob: Blob;
try { try {
const preEp = axis.tape.spanPx > 0
? tapeEndpoints(axis.tape, source.widthPx, source.heightPx)
: null;
const bakeXform = bakeTiltTransform(source.widthPx, source.heightPx, axis.tape.tiltDeg);
let overlayBbox = calMask.bbox;
let overlayAxis: { start: [number, number]; end: [number, number] } | undefined;
if (preEp && preEp.spanPx > 0) {
const start = bakeXform.mapPoint(preEp.start[0], preEp.start[1]);
const end = bakeXform.mapPoint(preEp.end[0], preEp.end[1]);
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 180) % 180;
overlayBbox = orientedRect(calMask.bbox, angleDeg);
overlayAxis = { start, end };
}
overlayBlob = await encodeOverlay({ overlayBlob = await encodeOverlay({
inventoryPng: pngBlob, inventoryPng: pngBlob,
bitset: calMask.bitset, bitset: calMask.bitset,
widthPx: calSource.widthPx, widthPx: calSource.widthPx,
heightPx: calSource.heightPx, heightPx: calSource.heightPx,
bbox: calMask.bbox, bbox: overlayBbox,
scale: base.scale, scale: base.scale,
lengthIn: eighthsToIn(lengthEighths), lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths), lengthLabel: fmtEighths(lengthEighths),
slabBounds: calMask.slabBounds, slabBounds: calMask.slabBounds,
source: calSource, source: calSource,
axis: overlayAxis,
}); });
} catch { } catch {
// Deterministic tests may inject a non-decodable inventory blob; still // Deterministic tests may inject a non-decodable inventory blob; still
+107
View File
@@ -1117,3 +1117,110 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥
const tipSpanSrc = tipHi - tipLo; const tipSpanSrc = tipHi - tipLo;
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`); assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
}); });
test('buildOverlayPlan: when MAR angle ≠ tape angle, length || tape and width ⊥ tape (~2°)', () => {
// Wood MAR is horizontal (angleDeg 0); tape / approved axis is vertical (90°).
// Without optional axis (or orientedRect to tape), widths would be vertical.
// With tape-driven axis, length is vertical and width left→right ≈ horizontal.
const W = 240, H = 400;
const bitset = new Uint8Array(W * H);
// Slightly elongated horizontally so MAR long side ≈ 0°, but still a tall slab.
for (let y = 40; y <= 360; y++) {
for (let x = 40; x <= 200; x++) bitset[y * W + x] = 1;
}
const mar = minAreaRect(bitset, W, H)!;
// Force wood-long-axis bbox to 0° — intentionally NOT the tape (vertical).
const woodBbox = orientedRect(mar, 0);
assert.equal(woodBbox.angleDeg, 0);
// Tape vertical: tick A near bottom, B near top (image y↓).
const tapeStart: [number, number] = [120, 350];
const tapeEnd: [number, number] = [120, 50];
const tapeAngleDeg = ((Math.atan2(tapeEnd[1] - tapeStart[1], tapeEnd[0] - tapeStart[0]) * 180) / Math.PI + 180) % 180;
// Overlay bbox keeps MAR extents but direction from tape (session.calculate path).
const tapeBbox = orientedRect(woodBbox, tapeAngleDeg);
const scale = 10;
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: tapeBbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
axis: { start: tapeStart, end: tapeEnd },
});
// Length a→b must be parallel to tape (vertical → |dx| small vs |dy|).
const ldx = plan.length.b[0] - plan.length.a[0];
const ldy = plan.length.b[1] - plan.length.a[1];
const llen = Math.hypot(ldx, ldy) || 1;
const lux = ldx / llen, luy = ldy / llen;
const tdx = tapeEnd[0] - tapeStart[0], tdy = tapeEnd[1] - tapeStart[1];
const tlen = Math.hypot(tdx, tdy) || 1;
const tux = tdx / tlen, tuy = tdy / tlen;
const lengthDot = Math.abs(lux * tux + luy * tuy);
assert.ok(lengthDot > 0.999, `length must || tape (dot=${lengthDot})`);
assert.ok(plan.widths.length > 0, 'expected width stations');
// Tape-perpendicular = horizontal for a vertical tape.
for (const w of plan.widths) {
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
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 tape-perpendicular/horizontal (deg=${deg.toFixed(2)})`,
);
}
// Contrast: same wood bbox WITHOUT tape axis would follow MAR (horizontal length).
const woodPlan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: woodBbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
});
const wdx = woodPlan.length.b[0] - woodPlan.length.a[0];
const wdy = woodPlan.length.b[1] - woodPlan.length.a[1];
const wlen = Math.hypot(wdx, wdy) || 1;
// Wood MAR length should NOT be parallel to vertical tape.
const woodDot = Math.abs((wdx / wlen) * tux + (wdy / wlen) * tuy);
assert.ok(woodDot < 0.5, `wood-MAR overlay must disagree with tape (dot=${woodDot})`);
});
test('buildOverlayPlan: optional axis alone drives direction even if bbox angle wrong', () => {
const W = 200, H = 400;
const bitset = new Uint8Array(W * H);
for (let y = 50; y <= 350; y++) {
for (let x = 70; x <= 130; x++) bitset[y * W + x] = 1;
}
const mar = minAreaRect(bitset, W, H)!;
// Wrong bbox angle on purpose (horizontal) while axis is vertical.
const wrongBbox = orientedRect(mar, 0);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: wrongBbox,
scale: 10,
lengthIn: 30,
lengthLabel: '30"',
mapToInventory: (x, y) => [x, y],
axis: { start: [100, 340], end: [100, 60] },
});
assert.ok(plan.widths.length > 0);
for (const w of plan.widths) {
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
let deg = (ang * 180) / Math.PI;
if (deg > 90) deg -= 180;
if (deg < -90) deg += 180;
assert.ok(Math.abs(deg) <= 2, `axis override: width deg=${deg.toFixed(2)}`);
}
});
+13 -3
View File
@@ -81,6 +81,12 @@ export interface OverlayPlanInput {
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */ /** Inventory PNG size — used to clamp labels inside the canvas (#71). */
inventoryW?: number; inventoryW?: number;
inventoryH?: number; inventoryH?: number;
/**
* Optional explicit length axis (e.g. baked tape tick A→B). When set, (ux,uy)
* come from this segment instead of bbox corners — keeps width ⊥ tape even
* when MAR wood long side differs from the tape.
*/
axis?: { start: [number, number]; end: [number, number] };
} }
/** /**
@@ -154,6 +160,7 @@ function maskAxisSegment(
w: number, w: number,
h: number, h: number,
bbox: BBox, bbox: BBox,
axis?: { start: [number, number]; end: [number, number] },
): { ): {
start: [number, number]; start: [number, number];
end: [number, number]; end: [number, number];
@@ -164,7 +171,7 @@ function maskAxisSegment(
uy: number; uy: number;
span: number; span: number;
} { } {
const { start: a, end: b } = axisEndpoints(bbox); const { start: a, end: b } = axis ?? axisEndpoints(bbox);
const ex = b[0] - a[0]; const ex = b[0] - a[0];
const ey = b[1] - a[1]; const ey = b[1] - a[1];
const elen = Math.hypot(ex, ey) || 1; const elen = Math.hypot(ex, ey) || 1;
@@ -270,9 +277,9 @@ function fmtInches(inches: number): string {
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const { const {
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory, bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
inventoryW, inventoryH, inventoryW, inventoryH, axis,
} = input; } = input;
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox); const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox, axis);
// Re-orient span ends so EXIF bottom establishes station origin; axis still // 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). // lies on the approved M1 direction (seg ux/uy flipped only when needed).
const oriented = pickBottomTowardTop(seg.start, seg.end); const oriented = pickBottomTowardTop(seg.start, seg.end);
@@ -522,6 +529,8 @@ export interface EncodeOverlayInput {
source: { widthPx: number; heightPx: number }; source: { widthPx: number; heightPx: number };
/** Optional override; default reads live settings.crop_fill (#99). */ /** Optional override; default reads live settings.crop_fill (#99). */
cropFill?: number; cropFill?: number;
/** Optional explicit length axis in source px (baked tape A→B). */
axis?: { start: [number, number]; end: [number, number] };
} }
/** /**
@@ -549,6 +558,7 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
mapToInventory, mapToInventory,
inventoryW: outW, inventoryW: outW,
inventoryH: outH, inventoryH: outH,
axis: input.axis,
}); });
drawOverlayPlan(ctx, plan); drawOverlayPlan(ctx, plan);
return await canvas.convertToBlob({ type: 'image/png' }); return await canvas.convertToBlob({ type: 'image/png' });
+56
View File
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bakeTiltTransform } from './rotateCal.ts';
test('bakeTiltTransform: identity ≈0° leaves points and size unchanged', () => {
const x = bakeTiltTransform(800, 600, 0);
assert.equal(x.identity, true);
assert.equal(x.outW, 800);
assert.equal(x.outH, 600);
assert.deepEqual(x.mapPoint(100, 200), [100, 200]);
assert.deepEqual(x.mapPoint(0, 0), [0, 0]);
});
test('bakeTiltTransform: 360° folds to identity', () => {
const x = bakeTiltTransform(400, 300, 360);
assert.equal(x.identity, true);
assert.deepEqual(x.mapPoint(10, 20), [10, 20]);
});
test('bakeTiltTransform: 90° CW maps centre→centre; corner into AABB canvas', () => {
const srcW = 200, srcH = 100;
const x = bakeTiltTransform(srcW, srcH, 90);
assert.equal(x.identity, false);
// Rotated AABB of 200×100 about centre ≈ 100×200 (ceil may add 1px from float).
assert.ok(x.outW >= 100 && x.outW <= 101, `outW=${x.outW}`);
assert.ok(x.outH >= 200 && x.outH <= 201, `outH=${x.outH}`);
const [cx, cy] = x.mapPoint(srcW / 2, srcH / 2);
assert.ok(Math.abs(cx - x.outW / 2) < 1e-9);
assert.ok(Math.abs(cy - x.outH / 2) < 1e-9);
// Top-left (0,0) relative (−100,−50) → CW90 → (~50,~−100) → +out/2
const [tlx, tly] = x.mapPoint(0, 0);
assert.ok(Math.abs(tlx - (50 + x.outW / 2)) < 1e-4, `tlx=${tlx}`);
assert.ok(Math.abs(tly - (-100 + x.outH / 2)) < 1e-4, `tly=${tly}`);
});
test('bakeTiltTransform: maps a tape-like vertical segment; direction rotates with tilt', () => {
const srcW = 400, srcH = 600;
const start: [number, number] = [200, 100];
const end: [number, number] = [200, 500];
const preUx = 0, preUy = 1; // vertical down
for (const tilt of [15, 30, -20, 45]) {
const x = bakeTiltTransform(srcW, srcH, tilt);
const a = x.mapPoint(start[0], start[1]);
const b = x.mapPoint(end[0], end[1]);
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len;
// Expected: rotate (preUx, preUy) clockwise by tilt
const rad = (((tilt % 360) + 360) % 360) * Math.PI / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const expUx = preUx * cos - preUy * sin;
const expUy = preUx * sin + preUy * cos;
const dot = ux * expUx + uy * expUy;
assert.ok(dot > 0.999, `tilt=${tilt} direction mismatch dot=${dot}`);
}
});
+61 -13
View File
@@ -12,28 +12,43 @@ export interface RotatedCal {
source: Pick<Source, 'widthPx' | 'heightPx'>; source: Pick<Source, 'widthPx' | 'heightPx'>;
} }
/** Pure math for the same clockwise-about-centre bake used by rotateCalibration. */
export interface BakeTiltTransform {
/** True when tilt is ~0° (identity; no resample). */
identity: boolean;
tiltDeg: number;
srcW: number;
srcH: number;
outW: number;
outH: number;
/** Map a point from pre-bake source coords into baked canvas coords. */
mapPoint: (x: number, y: number) => [number, number];
}
/** /**
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate). * Clockwise rotation about image centre; output canvas sized to AABB of rotated
* Returns a new bitmap the caller must close, plus recomputed mask geometry. * corners — identical sizing to rotateCalibration (canvas translate+rotate path).
* Identity (≈0°) returns clones of inputs without resampling.
*/ */
export async function rotateCalibration( export function bakeTiltTransform(
bitmap: ImageBitmap, srcW: number,
mask: Mask, srcH: number,
source: Pick<Source, 'widthPx' | 'heightPx'>,
tiltDeg: number, tiltDeg: number,
): Promise<RotatedCal> { ): BakeTiltTransform {
const a = ((tiltDeg % 360) + 360) % 360; const a = ((tiltDeg % 360) + 360) % 360;
if (a < 0.05 || a > 359.95) { if (a < 0.05 || a > 359.95) {
// No meaningful rotation — hand back the same bitmap reference; caller closes once. return {
return { bitmap, mask, source }; identity: true,
tiltDeg: a,
srcW,
srcH,
outW: srcW,
outH: srcH,
mapPoint: (x, y) => [x, y],
};
} }
const srcW = source.widthPx;
const srcH = source.heightPx;
const rad = (a * Math.PI) / 180; const rad = (a * Math.PI) / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
// Axis-aligned bounds of rotated rectangle corners.
const corners = [ const corners = [
[0, 0], [0, 0],
[srcW, 0], [srcW, 0],
@@ -53,6 +68,39 @@ export async function rotateCalibration(
} }
const outW = Math.max(1, Math.ceil(maxX - minX)); const outW = Math.max(1, Math.ceil(maxX - minX));
const outH = Math.max(1, Math.ceil(maxY - minY)); const outH = Math.max(1, Math.ceil(maxY - minY));
return {
identity: false,
tiltDeg: a,
srcW,
srcH,
outW,
outH,
mapPoint: (x, y) => {
const cx = x - srcW / 2;
const cy = y - srcH / 2;
return [cx * cos - cy * sin + outW / 2, cx * sin + cy * cos + outH / 2];
},
};
}
/**
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate).
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
* Identity (≈0°) returns clones of inputs without resampling.
*/
export async function rotateCalibration(
bitmap: ImageBitmap,
mask: Mask,
source: Pick<Source, 'widthPx' | 'heightPx'>,
tiltDeg: number,
): Promise<RotatedCal> {
const xform = bakeTiltTransform(source.widthPx, source.heightPx, tiltDeg);
if (xform.identity) {
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
return { bitmap, mask, source };
}
const { srcW, srcH, outW, outH, tiltDeg: a } = xform;
const rad = (a * Math.PI) / 180;
const canvas = new OffscreenCanvas(outW, outH); const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
ctx.translate(outW / 2, outH / 2); ctx.translate(outW / 2, outH / 2);