fix(m4): apply tape tilt to calibration output (#99)

This commit is contained in:
Ty
2026-09-25 07:31:13 -07:00
parent 7908a99ec3
commit 1882fa3842
7 changed files with 287 additions and 6 deletions
@@ -252,6 +252,35 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
s.dispose();
});
test('#62: calibration tape tilt is not copied to sibling photos (#99)', async () => {
installFakes();
const rotations: (number | undefined)[] = [];
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
rotations.push(input.rotationDeg);
return { blob: new Blob(['ok-png'], { type: 'image/png' }), sourceBelowFloor: false };
},
} as Parameters<typeof createCaptureSession>[0]);
await driveToMeasure(s, [tinyJpeg('sibling-a'), tinyJpeg('calibration'), tinyJpeg('sibling-b')], 1, { sheet: 'black' });
s.back();
s.setTapePose({
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
tickStartY: 32,
tickEndY: 608,
sourceW: 1000,
sourceH: 900,
});
await s.calculateFromAxis(320, 16);
assert.equal(rotations.length, 3, 'one encoder call for calibration and each sibling');
assert.equal(rotations[0], 20, 'calibration receives the selected tape tilt');
assert.equal(rotations[1], undefined, 'sibling photos remain unrotated');
assert.equal(rotations[2], undefined, 'sibling photos remain unrotated');
s.dispose();
});
test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => {
installFakes();
const a = tinyJpeg('a'); // will empty-mask
@@ -403,6 +403,44 @@ test('calculateFromAxis stays on the tape page until the calibrated result exist
s.dispose();
});
test('calculateFromAxis passes the tape tilt to the calibration PNG and overlay only', async () => {
installFakeMaskWorker();
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap;
const inventoryTilts: (number | undefined)[] = [];
const overlayTilts: (number | undefined)[] = [];
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
inventoryTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg);
return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false };
},
encodeOverlay: async (input) => {
overlayTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg);
return new Blob(['overlay'], { type: 'image/png' });
},
});
await s.addToBatch(tinyJpeg('cal'));
s.continueBatch();
s.pickCalibration(0);
s.setSheet('black');
await settleOverlay(s);
s.approveMask();
s.setTapePose({
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
tickStartY: 32,
tickEndY: 608,
sourceW: MASK_W,
sourceH: MASK_H,
});
await s.calculateFromAxis(320, 16);
assert.deepEqual(inventoryTilts, [20], 'calibration inventory must receive tape tilt');
assert.deepEqual(overlayTilts, [20], 'dimension overlay must receive tape tilt');
s.dispose();
});
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
installFakeMaskWorker();
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
+2 -1
View File
@@ -370,7 +370,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
{ start: axis.tapeStart, end: axis.tapeEnd },
);
try {
const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill });
const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill, rotationDeg: axis.photoTransform.tiltDeg });
pngBlob = encoded.blob;
belowFloor = encoded.sourceBelowFloor;
} catch {
@@ -390,6 +390,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
slabBounds: approvedMask.slabBounds,
source,
cropFill,
rotationDeg: axis.photoTransform.tiltDeg,
});
} catch {
return new Blob(['overlay'], { type: 'image/png' });
+31
View File
@@ -107,6 +107,37 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
});
test('calibration rotation turns a landscape slab into an upright crop extent (#99)', () => {
const width = 6;
const height = 4;
const bitset = new Uint8Array(width * height);
for (let y = 1; y <= 2; y++) {
for (let x = 1; x <= 4; x++) bitset[y * width + x] = 1;
}
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: width, heightPx: height };
const mask = maskFor(bitset, { x: 1, y: 1, w: 4, h: 2 });
const photo = makePhoto(width, height, (x, y) =>
bitset[y * width + x] ? [200, 40, 20, 255] : [0, 255, 0, 255],
);
const extent = (rgba: Uint8Array, outW: number, outH: number) => {
let minX = outW, maxX = -1, minY = outH, maxY = -1;
for (let y = 0; y < outH; y++) {
for (let x = 0; x < outW; x++) {
if (rgba[(y * outW + x) * 4 + 3] === 0) continue;
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
}
}
return { width: maxX - minX + 1, height: maxY - minY + 1 };
};
const unrotated = composeInventoryFrame(photo, mask, source, 1, 0);
const rotated = composeInventoryFrame(photo, mask, source, 1, 90);
assert.ok(extent(unrotated.rgba, unrotated.width, unrotated.height).width >
extent(unrotated.rgba, unrotated.width, unrotated.height).height);
assert.ok(extent(rotated.rgba, rotated.width, rotated.height).height >
extent(rotated.rgba, rotated.width, rotated.height).width);
});
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
+128 -1
View File
@@ -13,6 +13,8 @@ export interface InventoryInput {
source: SourcePick;
/** Global crop fill percent. Default 90. */
cropFill?: number;
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
rotationDeg?: number;
}
export interface Frame {
@@ -27,7 +29,11 @@ export function composeInventoryFrame(
mask: MaskPick,
source: SourcePick,
fill = 0.9,
rotationDeg = 0,
): Frame {
if (Number.isFinite(rotationDeg) && Math.abs(rotationDeg % 360) > 1e-8) {
return composeRotatedInventoryFrame(src, mask, source, fill, rotationDeg);
}
const b = mask.slabBounds;
const x0 = Math.max(0, Math.round(b.x));
const y0 = Math.max(0, Math.round(b.y));
@@ -64,10 +70,130 @@ export function composeInventoryFrame(
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
}
interface PixelBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
function maskPixelBounds(bitset: Uint8Array, w: number, h: number): PixelBounds | null {
let minX = w, minY = h, maxX = -1, maxY = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!bitset[y * w + x]) continue;
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
}
}
return maxX < 0 ? null : { minX, minY, maxX, maxY };
}
function rotatePoint(
x: number, y: number, bounds: PixelBounds, radians: number,
): [number, number] {
const cx = (bounds.minX + bounds.maxX) / 2;
const cy = (bounds.minY + bounds.maxY) / 2;
const dx = x - cx, dy = y - cy;
const cos = Math.cos(radians), sin = Math.sin(radians);
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
}
export interface MaskRotation {
bounds: { x: number; y: number; w: number; h: number };
toRotated: (x: number, y: number) => [number, number];
toSource: (x: number, y: number) => [number, number];
}
export function createMaskRotation(
bitset: Uint8Array,
w: number,
h: number,
rotationDeg: number,
): MaskRotation {
const pixels = maskPixelBounds(bitset, w, h);
if (!pixels) {
return {
bounds: { x: 0, y: 0, w: 0, h: 0 },
toRotated: (x, y) => [x, y],
toSource: (x, y) => [x, y],
};
}
const radians = (rotationDeg * Math.PI) / 180;
const toRotated = (x: number, y: number) => rotatePoint(x, y, pixels, radians);
const toSource = (x: number, y: number) => rotatePoint(x, y, pixels, -radians);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!bitset[y * w + x]) continue;
const [rx, ry] = toRotated(x, y);
minX = Math.min(minX, rx); minY = Math.min(minY, ry);
maxX = Math.max(maxX, rx); maxY = Math.max(maxY, ry);
}
}
return {
bounds: { x: minX - 0.5, y: minY - 0.5, w: maxX - minX + 1, h: maxY - minY + 1 },
toRotated,
toSource,
};
}
export function rotatedMaskBounds(
bitset: Uint8Array,
w: number,
h: number,
rotationDeg: number,
) {
return createMaskRotation(bitset, w, h, rotationDeg).bounds;
}
function composeRotatedInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
fill: number,
rotationDeg: number,
): Frame {
const rotation = createMaskRotation(mask.bitset, source.widthPx, source.heightPx, rotationDeg);
if (!(rotation.bounds.w > 0 && rotation.bounds.h > 0)) {
throw new RangeError('Cannot rotate an empty approved mask');
}
const rotated = rotation.bounds;
const belowFloor = Math.min(rotated.w, rotated.h) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(rotated.w, FLOOR)) : FLOOR;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
const scale = Math.min((outW * fill) / Math.max(rotated.w, 1), (outH * fill) / Math.max(rotated.h, 1));
const dw = rotated.w * scale;
const dh = rotated.h * scale;
const ox = (outW - dw) / 2;
const oy = (outH - dh) / 2;
const rgba = new Uint8Array(outW * outH * 4);
for (let y = 0; y < outH; y++) {
for (let x = 0; x < outW; x++) {
if (x + 0.5 < ox || x + 0.5 >= ox + dw || y + 0.5 < oy || y + 0.5 >= oy + dh) continue;
const rx = rotated.x + (x + 0.5 - ox) / scale;
const ry = rotated.y + (y + 0.5 - oy) / scale;
const [sourceX, sourceY] = rotation.toSource(rx, ry);
const sx = Math.round(sourceX);
const sy = Math.round(sourceY);
if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue;
const siPixel = sy * source.widthPx + sx;
if (mask.bitset[siPixel] !== 1) continue;
const si = siPixel * 4;
const di = (y * outW + x) * 4;
rgba[di] = src.rgba[si];
rgba[di + 1] = src.rgba[si + 1];
rgba[di + 2] = src.rgba[si + 2];
rgba[di + 3] = 255;
}
}
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
}
export async function encodeInventoryPng(
input: InventoryInput,
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
const { bitmap, mask, source, cropFill } = input;
const { bitmap, mask, source, cropFill, rotationDeg } = input;
const fill = Math.max(1, Math.min(100, cropFill ?? 90)) / 100;
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
const srcCtx = srcCanvas.getContext('2d')!;
@@ -78,6 +204,7 @@ export async function encodeInventoryPng(
mask,
source,
fill,
rotationDeg,
);
const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!;
+31 -1
View File
@@ -299,7 +299,37 @@ test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#
});
// --- Length dim must not overshoot wood footprint (#69) ---
test('inventorySourceMap follows calibration rotation for overlay geometry (#99)', () => {
const W = 400, H = 400;
const bitset = new Uint8Array(W * H).fill(1);
const slabBounds = { x: 0, y: 0, w: W, h: H };
const unrotated = inventorySourceMap(
slabBounds,
{ widthPx: W, heightPx: H },
800,
1067,
0.9,
);
const rotated = inventorySourceMap(
slabBounds,
{ widthPx: W, heightPx: H },
800,
1067,
0.9,
bitset,
90,
);
const horizontal = (map: ReturnType<typeof inventorySourceMap>) => {
const a = map.mapToInventory(100, 200);
const b = map.mapToInventory(300, 200);
return [b[0] - a[0], b[1] - a[1]] as const;
};
const [unrotatedDx, unrotatedDy] = horizontal(unrotated);
const [rotatedDx, rotatedDy] = horizontal(rotated);
assert.ok(Math.abs(unrotatedDx) > Math.abs(unrotatedDy), 'unrotated source axis stays horizontal');
assert.ok(Math.abs(rotatedDy) > Math.abs(rotatedDx), '90° calibration rotation maps it vertically');
});
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
function tiltedSlabFixture() {
+28 -3
View File
@@ -1,5 +1,6 @@
import type { BBox } from '../capture/capture.types';
import { axisEndpoints } from '../measure/measure';
import { createMaskRotation } from './inventory';
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
export function overlayWidthStations(lengthIn: number): number[] {
@@ -486,15 +487,30 @@ export function inventorySourceMap(
outW: number,
outH: number,
fill = 0.9,
bitset?: Uint8Array,
rotationDeg = 0,
): InventoryMap {
const fillRatio = fill;
if (bitset && Math.abs(rotationDeg % 360) > 1e-8) {
const rotation = createMaskRotation(bitset, source.widthPx, source.heightPx, rotationDeg);
const scale = Math.min((outW * fill) / Math.max(rotation.bounds.w, 1), (outH * fill) / Math.max(rotation.bounds.h, 1));
const ox = (outW - rotation.bounds.w * scale) / 2;
const oy = (outH - rotation.bounds.h * scale) / 2;
return {
outW,
outH,
mapToInventory: (sx, sy) => {
const [rx, ry] = rotation.toRotated(sx, sy);
return [ox + (rx - rotation.bounds.x) * scale, oy + (ry - rotation.bounds.y) * scale];
},
};
}
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 * fillRatio) / Math.max(sw, 1), (outH * fillRatio) / Math.max(sh, 1));
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;
@@ -519,6 +535,7 @@ export interface EncodeOverlayInput {
slabBounds: { x: number; y: number; w: number; h: number };
source: { widthPx: number; heightPx: number };
cropFill?: number;
rotationDeg?: number;
}
/**
@@ -534,7 +551,15 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
const fill = Math.max(1, Math.min(100, input.cropFill ?? 90)) / 100;
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
const { mapToInventory } = inventorySourceMap(
input.slabBounds,
input.source,
outW,
outH,
fill,
input.bitset,
input.rotationDeg,
);
const plan = buildOverlayPlan({
bitset: input.bitset,
widthPx: input.widthPx,