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:
@@ -10,7 +10,7 @@ import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { rotateCalibration } from '../png/rotateCal';
|
||||
import { bakeTiltTransform, rotateCalibration } from '../png/rotateCal';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
|
||||
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.
|
||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||
// 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;
|
||||
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({
|
||||
inventoryPng: pngBlob,
|
||||
bitset: calMask.bitset,
|
||||
widthPx: calSource.widthPx,
|
||||
heightPx: calSource.heightPx,
|
||||
bbox: calMask.bbox,
|
||||
bbox: overlayBbox,
|
||||
scale: base.scale,
|
||||
lengthIn: eighthsToIn(lengthEighths),
|
||||
lengthLabel: fmtEighths(lengthEighths),
|
||||
slabBounds: calMask.slabBounds,
|
||||
source: calSource,
|
||||
axis: overlayAxis,
|
||||
});
|
||||
} catch {
|
||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||
|
||||
@@ -1117,3 +1117,110 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥
|
||||
const tipSpanSrc = tipHi - tipLo;
|
||||
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)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,6 +81,12 @@ export interface OverlayPlanInput {
|
||||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||||
inventoryW?: 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,
|
||||
h: number,
|
||||
bbox: BBox,
|
||||
axis?: { start: [number, number]; end: [number, number] },
|
||||
): {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
@@ -164,7 +171,7 @@ function maskAxisSegment(
|
||||
uy: 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 ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
@@ -270,9 +277,9 @@ function fmtInches(inches: number): string {
|
||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const {
|
||||
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||||
inventoryW, inventoryH,
|
||||
inventoryW, inventoryH, axis,
|
||||
} = 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
|
||||
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
|
||||
const oriented = pickBottomTowardTop(seg.start, seg.end);
|
||||
@@ -522,6 +529,8 @@ export interface EncodeOverlayInput {
|
||||
source: { widthPx: number; heightPx: number };
|
||||
/** Optional override; default reads live settings.crop_fill (#99). */
|
||||
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,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
axis: input.axis,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -12,28 +12,43 @@ export interface RotatedCal {
|
||||
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).
|
||||
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
|
||||
* Identity (≈0°) returns clones of inputs without resampling.
|
||||
* Clockwise rotation about image centre; output canvas sized to AABB of rotated
|
||||
* corners — identical sizing to rotateCalibration (canvas translate+rotate path).
|
||||
*/
|
||||
export async function rotateCalibration(
|
||||
bitmap: ImageBitmap,
|
||||
mask: Mask,
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>,
|
||||
export function bakeTiltTransform(
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
tiltDeg: number,
|
||||
): Promise<RotatedCal> {
|
||||
): BakeTiltTransform {
|
||||
const a = ((tiltDeg % 360) + 360) % 360;
|
||||
if (a < 0.05 || a > 359.95) {
|
||||
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
|
||||
return { bitmap, mask, source };
|
||||
return {
|
||||
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 cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// Axis-aligned bounds of rotated rectangle corners.
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[srcW, 0],
|
||||
@@ -53,6 +68,39 @@ export async function rotateCalibration(
|
||||
}
|
||||
const outW = Math.max(1, Math.ceil(maxX - minX));
|
||||
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 ctx = canvas.getContext('2d')!;
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
|
||||
Reference in New Issue
Block a user