184 lines
6.9 KiB
TypeScript
184 lines
6.9 KiB
TypeScript
import type { Mask, Source } from '../capture/capture.types';
|
|
import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop';
|
|
|
|
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
|
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
|
|
|
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;
|
|
}
|
|
|
|
export interface Frame {
|
|
width: number;
|
|
height: number;
|
|
rgba: Uint8Array;
|
|
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));
|
|
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 };
|
|
}
|