fix(m4): apply tape tilt to calibration output (#99)
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user