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:
@@ -315,7 +315,7 @@ export function createCaptureSession(): CaptureSession {
|
|||||||
let pngBlob: Blob;
|
let pngBlob: Blob;
|
||||||
let belowFloor = false;
|
let belowFloor = false;
|
||||||
try {
|
try {
|
||||||
const r = await encodeInventoryPng(bitmap, approvedMask.slabBounds);
|
const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source });
|
||||||
pngBlob = r.blob;
|
pngBlob = r.blob;
|
||||||
belowFloor = r.sourceBelowFloor;
|
belowFloor = r.sourceBelowFloor;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { composeInventoryFrame } from './inventory.ts';
|
||||||
|
import type { Mask, Source } from '../capture/capture.types';
|
||||||
|
|
||||||
|
const makePhoto = (w: number, h: number, paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255]) => {
|
||||||
|
const rgba = new Uint8Array(w * h * 4);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const [r, g, b, a] = paint(x, y);
|
||||||
|
const i = (y * w + x) * 4;
|
||||||
|
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width: w, height: h, rgba };
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskFor = (bitset: Uint8Array, slabBounds: Mask['slabBounds']): Pick<Mask, 'bitset' | 'slabBounds'> =>
|
||||||
|
({ bitset, slabBounds });
|
||||||
|
|
||||||
|
test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha 255', () => {
|
||||||
|
const W = 8, H = 8;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 1; y < 5; y++) for (let x = 1; x < 5; x++) bitset[y * W + x] = 1;
|
||||||
|
bitset[2 * W + 2] = 0;
|
||||||
|
bitset[3 * W + 3] = 0;
|
||||||
|
const SLAB = [200, 40, 20, 255] as const;
|
||||||
|
const SHEET = [0, 255, 0, 255] as const;
|
||||||
|
const src = makePhoto(W, H, (x, y) => (bitset[y * W + x] === 1 ? [...SLAB] : [...SHEET]));
|
||||||
|
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
|
||||||
|
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
|
||||||
|
|
||||||
|
assert.equal(out.sourceBelowFloor, true);
|
||||||
|
assert.ok(out.width < 1600, 'undersized source stays small');
|
||||||
|
|
||||||
|
let opaque = 0;
|
||||||
|
let transparent = 0;
|
||||||
|
for (let i = 0; i < out.rgba.length; i += 4) {
|
||||||
|
const a = out.rgba[i + 3];
|
||||||
|
if (a === 0) {
|
||||||
|
transparent++;
|
||||||
|
assert.equal(out.rgba[i], 0);
|
||||||
|
assert.equal(out.rgba[i + 1], 0);
|
||||||
|
assert.equal(out.rgba[i + 2], 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
opaque++;
|
||||||
|
assert.equal(a, 255);
|
||||||
|
assert.deepEqual(
|
||||||
|
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]],
|
||||||
|
[SLAB[0], SLAB[1], SLAB[2]],
|
||||||
|
'opaque pixel must be slab RGB, never sheet green',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(opaque > 0, 'slab pixels are opaque');
|
||||||
|
assert.ok(transparent > 0, 'frame has transparent pixels');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3:4 frame at the floor: 2000×1800 slab → 1600×2133', () => {
|
||||||
|
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2000, heightPx: 1800 };
|
||||||
|
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||||
|
const out = composeInventoryFrame(
|
||||||
|
makePhoto(source.widthPx, source.heightPx),
|
||||||
|
maskFor(bitset, { x: 0, y: 0, w: 2000, h: 1800 }),
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
assert.equal(out.width, 1600);
|
||||||
|
assert.equal(out.height, 2133);
|
||||||
|
assert.equal(out.sourceBelowFloor, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
|
||||||
|
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||||
|
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||||
|
const out = composeInventoryFrame(
|
||||||
|
makePhoto(source.widthPx, source.heightPx),
|
||||||
|
maskFor(bitset, { x: 100, y: 100, w: 2000, h: 1800 }),
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
let minx = out.width, maxx = -1, miny = out.height, maxy = -1;
|
||||||
|
for (let y = 0; y < out.height; y++) {
|
||||||
|
for (let x = 0; x < out.width; x++) {
|
||||||
|
if (out.rgba[(y * out.width + x) * 4 + 3] !== 0) {
|
||||||
|
if (x < minx) minx = x;
|
||||||
|
if (x > maxx) maxx = x;
|
||||||
|
if (y < miny) miny = y;
|
||||||
|
if (y > maxy) maxy = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const xs = [minx, maxx], ys = [miny, maxy];
|
||||||
|
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
|
||||||
|
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
|
||||||
|
assert.ok(Math.abs(wExtent - 1280) <= 2, `width extent ${wExtent} ≈ 1280 (0.8×1600)`);
|
||||||
|
assert.ok(Math.abs(hExtent - 1152) <= 2, `height extent ${hExtent} ≈ 1152 (0.8×1800×0.64)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('belowFloor: small slab → sourceBelowFloor, outW < 1600', () => {
|
||||||
|
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||||
|
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||||
|
const out = composeInventoryFrame(
|
||||||
|
makePhoto(source.widthPx, source.heightPx),
|
||||||
|
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
assert.equal(out.sourceBelowFloor, true);
|
||||||
|
assert.ok(out.width < 1600, `outW ${out.width}`);
|
||||||
|
});
|
||||||
@@ -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 FLOOR = 1600; // shorter side, px
|
||||||
const FILL = 0.8; // 80% fill at widest and tallest points
|
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab
|
||||||
const MARGIN = 0.08; // crop padding around slab bounds
|
|
||||||
const ASPECT_W = 3;
|
const ASPECT_W = 3;
|
||||||
const ASPECT_H = 4;
|
const ASPECT_H = 4;
|
||||||
|
|
||||||
// 3:4 portrait RGBA PNG: slab centered at 80% fill (widest and tallest points),
|
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||||
// shorter side >= 1600 when the source can supply it. No upscale: an undersized
|
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||||
// 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));
|
|
||||||
|
|
||||||
const belowFloor = Math.min(srcW, srcH) < FLOOR;
|
export interface InventoryInput {
|
||||||
// Portrait 3:4: width is the shorter side. Meet the floor when source allows.
|
bitmap: ImageBitmap;
|
||||||
const outW = belowFloor ? Math.max(1, Math.min(srcW, FLOOR)) : FLOOR;
|
mask: MaskPick;
|
||||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
source: SourcePick;
|
||||||
const canvas = new OffscreenCanvas(outW, outH);
|
}
|
||||||
const ctx = canvas.getContext('2d')!;
|
|
||||||
ctx.clearRect(0, 0, outW, outH);
|
export interface Frame {
|
||||||
const maxW = outW * FILL;
|
width: number;
|
||||||
const maxH = outH * FILL;
|
height: number;
|
||||||
const scale = Math.min(maxW / srcW, maxH / srcH);
|
rgba: Uint8Array;
|
||||||
const dw = srcW * scale;
|
sourceBelowFloor: boolean;
|
||||||
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' });
|
export function composeInventoryFrame(
|
||||||
return { blob, sourceBelowFloor: belowFloor };
|
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