import type { Mask, Source } from '../capture/capture.types'; import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop'; type MaskPick = Pick; type SourcePick = Pick; export interface InventoryInput { bitmap: ImageBitmap; mask: MaskPick; source: SourcePick; /** Calibration-photo rotation in degrees; batch siblings remain unrotated. */ rotationDeg?: number; /** Crop fill, 0 [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)); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(b.w))); const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h))); const sw = x1 - x0; const sh = y1 - y0; const { outW, outH, belowFloor } = cropFrameSize(sw, sh); const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill); const rgba = new Uint8Array(outW * outH * 4); const xEnd = ox + dw; const yEnd = oy + dh; for (let y = 0; y < outH; y++) { for (let x = 0; x < outW; x++) { if (x + 0.5 < ox || x + 0.5 >= xEnd || y + 0.5 < oy || y + 0.5 >= yEnd) continue; const sx = Math.floor(x0 + (x + 0.5 - ox) / scale); const sy = Math.floor(y0 + (y + 0.5 - oy) / scale); if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue; if (mask.bitset[sy * source.widthPx + sx] !== 1) continue; const si = (sy * source.widthPx + sx) * 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 }; } 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 }> { const { bitmap, mask, source } = input; const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); const srcCtx = srcCanvas.getContext('2d')!; srcCtx.drawImage(bitmap, 0, 0); const img = srcCtx.getImageData(0, 0, source.widthPx, source.heightPx); const frame = composeInventoryFrame( { width: source.widthPx, height: source.heightPx, rgba: img.data }, mask, source, input.fill, input.rotationDeg, ); const out = new OffscreenCanvas(frame.width, frame.height); const ctx = out.getContext('2d')!; ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0); const blob = await out.convertToBlob({ type: 'image/png' }); return { blob, sourceBelowFloor: frame.sourceBelowFloor }; }