From b3a9fdf330027ffe81340f84d31b8a33f49587ad Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Mon, 14 Sep 2026 19:11:52 +0000 Subject: [PATCH] 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. --- frontend/src/capture/session.ts | 2 +- frontend/src/png/inventory.test.ts | 108 +++++++++++++++++++++++++++++ frontend/src/png/inventory.ts | 106 +++++++++++++++++++--------- 3 files changed, 184 insertions(+), 32 deletions(-) create mode 100644 frontend/src/png/inventory.test.ts diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 99ebf0d..6c62e96 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -315,7 +315,7 @@ export function createCaptureSession(): CaptureSession { let pngBlob: Blob; let belowFloor = false; try { - const r = await encodeInventoryPng(bitmap, approvedMask.slabBounds); + const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source }); pngBlob = r.blob; belowFloor = r.sourceBelowFloor; } catch { diff --git a/frontend/src/png/inventory.test.ts b/frontend/src/png/inventory.test.ts new file mode 100644 index 0000000..d8245da --- /dev/null +++ b/frontend/src/png/inventory.test.ts @@ -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 => + ({ 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 = { 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 = { 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 = { 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 = { 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}`); +}); diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts index acc69cc..9c6ccef 100644 --- a/frontend/src/png/inventory.ts +++ b/frontend/src/png/inventory.ts @@ -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; +type SourcePick = Pick; - 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 }; }