fix(capture): align masked tape calibration with #99

This commit is contained in:
Ty
2026-09-24 16:49:17 -07:00
parent 4ca2ef57a7
commit d49ff2fab5
18 changed files with 757 additions and 181 deletions
+106
View File
@@ -8,6 +8,8 @@ export interface InventoryInput {
bitmap: ImageBitmap;
mask: MaskPick;
source: SourcePick;
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
rotationDeg?: number;
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
fill?: number;
}
@@ -19,12 +21,80 @@ export interface Frame {
sourceBelowFloor: boolean;
}
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,
};
}
/** Pixel-edge bounds of the approved mask after rotating around its center. */
export function rotatedMaskBounds(bitset: Uint8Array, w: number, h: number, rotationDeg: number) {
return createMaskRotation(bitset, w, h, rotationDeg).bounds;
}
export function composeInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
fill: number = FILL_DEFAULT,
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));
@@ -55,6 +125,41 @@ export function composeInventoryFrame(
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
}
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 { outW, outH, belowFloor } = cropFrameSize(rotated.w, rotated.h);
const { scale, dw, dh, ox, oy } = cropOffsets(rotated.w, rotated.h, outW, outH, fill);
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 }> {
@@ -68,6 +173,7 @@ export async function encodeInventoryPng(
mask,
source,
input.fill,
input.rotationDeg,
);
const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!;