test(capture): require mask alpha in inventory PNG compose
Why
Calculate showed accurate numbers but the inventory PNG kept the sheet
background. Measure already uses approvedMask.bitset; encode did not.
Scope
Add composeInventoryFrame and inventory.test.ts alpha/crop proofs.
Wire encodeInventoryPng({ bitmap, mask, source }) at calculate().
This commit still paints the slabBounds AABB without the bitset gate so
the alpha contract fails until the follow-up fix.
Verification
node --import tsx --test src/png/inventory.test.ts
alpha contract fails (sheet green leaks); 3:4 / 80% / floor pass.
This commit is contained in:
@@ -1,38 +1,82 @@
|
||||
import type { ABox } from '../capture/capture.types';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const FLOOR = 1600; // shorter side, px
|
||||
const FILL = 0.8; // 80% fill at widest and tallest points
|
||||
const MARGIN = 0.08; // crop padding around slab bounds
|
||||
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab
|
||||
const ASPECT_W = 3;
|
||||
const ASPECT_H = 4;
|
||||
|
||||
// 3:4 portrait RGBA PNG: slab centered at 80% fill (widest and tallest points),
|
||||
// shorter side >= 1600 when the source can supply it. No upscale: an undersized
|
||||
// source yields sourceBelowFloor = true (warn + require a new photo).
|
||||
export async function encodeInventoryPng(
|
||||
bitmap: ImageBitmap,
|
||||
slab: ABox,
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
const cw = Math.round(slab.w * (1 + 2 * MARGIN));
|
||||
const ch = Math.round(slab.h * (1 + 2 * MARGIN));
|
||||
const sx = Math.max(0, Math.round(slab.x - slab.w * MARGIN));
|
||||
const sy = Math.max(0, Math.round(slab.y - slab.h * MARGIN));
|
||||
const srcW = Math.min(bitmap.width - sx, Math.max(1, cw));
|
||||
const srcH = Math.min(bitmap.height - sy, Math.max(1, ch));
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
|
||||
const belowFloor = Math.min(srcW, srcH) < FLOOR;
|
||||
// Portrait 3:4: width is the shorter side. Meet the floor when source allows.
|
||||
const outW = belowFloor ? Math.max(1, Math.min(srcW, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, outW, outH);
|
||||
const maxW = outW * FILL;
|
||||
const maxH = outH * FILL;
|
||||
const scale = Math.min(maxW / srcW, maxH / srcH);
|
||||
const dw = srcW * scale;
|
||||
const dh = srcH * scale;
|
||||
ctx.drawImage(bitmap, sx, sy, srcW, srcH, (outW - dw) / 2, (outH - dh) / 2, dw, dh);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
return { blob, sourceBelowFloor: belowFloor };
|
||||
export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
sourceBelowFloor: boolean;
|
||||
}
|
||||
|
||||
export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
): Frame {
|
||||
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 belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
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;
|
||||
const oy = (outH - dh) / 2;
|
||||
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;
|
||||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user