fix(capture): warn 300–799px PNG floor; hard-block only below 300
Split FLOOR_WARN_PX=800 / FLOOR_BLOCK_PX=300. Warn-band still encodes and includes the photo (Photos-ready + batchApply ok+warn). Block excludes siblings and clean cal. CAPTURE-105 / CAPTURE.md / PHOTOS.md updated.
This commit is contained in:
@@ -77,12 +77,14 @@
|
||||
widthMinIn: eighthsToIn(r.widths.min),
|
||||
widthMaxIn: eighthsToIn(r.widths.max),
|
||||
widthAvgIn: eighthsToIn(r.widths.avg),
|
||||
sourceBelowFloor: r.sourceBelowFloor,
|
||||
sourceBelowBlock: r.sourceBelowBlock,
|
||||
sourceBelowWarn: r.sourceBelowWarn,
|
||||
lineup: buildPhotosReadyLineup({
|
||||
inventoryPng: r.inventoryPng,
|
||||
overlayPng: r.overlayPng,
|
||||
batchApply: m.batchApply,
|
||||
sourceBelowFloor: r.sourceBelowFloor,
|
||||
sourceBelowBlock: r.sourceBelowBlock,
|
||||
sourceBelowWarn: r.sourceBelowWarn,
|
||||
}),
|
||||
};
|
||||
saving = true;
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
inventoryPng: state.results.inventoryPng,
|
||||
overlayPng: state.results.overlayPng,
|
||||
batchApply: state.batchApply,
|
||||
sourceBelowFloor: state.results.sourceBelowFloor,
|
||||
sourceBelowBlock: state.results.sourceBelowBlock,
|
||||
sourceBelowWarn: state.results.sourceBelowWarn,
|
||||
});
|
||||
lineupUrls = lineupItems.map((it) =>
|
||||
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
|
||||
@@ -347,8 +348,14 @@
|
||||
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
|
||||
<div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div>
|
||||
</div>
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{#if state.results.sourceBelowBlock}
|
||||
<div class="warn" role="alert" data-testid="size-floor-block">
|
||||
Source shorter side is below 300px — inventory PNG blocked. Retake a larger photo.
|
||||
</div>
|
||||
{:else if state.results.sourceBelowWarn}
|
||||
<div class="warn" data-testid="size-floor-warn">
|
||||
Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||
@@ -365,6 +372,9 @@
|
||||
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.warn}
|
||||
<span class="item-warn" data-testid="lineup-warn">{item.warn}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}
|
||||
<li class="lineup-item error" role="alert">
|
||||
@@ -376,7 +386,7 @@
|
||||
</ol>
|
||||
|
||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
@@ -437,6 +447,7 @@
|
||||
.results .nums { font-size: 1rem; }
|
||||
.results .nums div { font-size: 1rem; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.item-warn { display: block; margin-top: 6px; color: #c07020; font-size: 0.8rem; font-weight: 600; }
|
||||
.lineup {
|
||||
list-style: none; padding: 0; margin: 16px 0 0;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
|
||||
@@ -82,14 +82,17 @@ export interface Measures {
|
||||
inventoryPng: Blob;
|
||||
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
|
||||
overlayPng?: Blob;
|
||||
sourceBelowFloor: boolean;
|
||||
/** Shorter side of slab bbox < 300 px — hard fail / exclude clean cal. */
|
||||
sourceBelowBlock: boolean;
|
||||
/** Shorter side in 300–799 px — encode/include with warn. */
|
||||
sourceBelowWarn: boolean;
|
||||
}
|
||||
|
||||
// Result of applying the calibration mask to one batch sibling. The
|
||||
// calibration slot is null because its inventory PNG lives in Measures.
|
||||
export type BatchPhotoResult =
|
||||
| null
|
||||
| { status: 'ok'; inventoryPng: Blob }
|
||||
| { status: 'ok'; inventoryPng: Blob; /** Soft size-floor concern; photo still included. */ warn?: string }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||
|
||||
@@ -92,7 +92,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
|
||||
null,
|
||||
{ status: 'error', message: 'empty mask: no slab pixels detected' },
|
||||
{ status: 'ok', inventoryPng: ok },
|
||||
{ status: 'error', message: 'PNG size floor: shorter side is below 800 px' },
|
||||
{ status: 'error', message: 'PNG size floor: shorter side is below 300 px' },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
@@ -118,7 +118,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
|
||||
const err3 = lineup[4];
|
||||
assert.equal(err3.kind, 'error');
|
||||
if (err3.kind === 'error') {
|
||||
assert.match(err3.message, /floor|800/i);
|
||||
assert.match(err3.message, /floor|300/i);
|
||||
}
|
||||
|
||||
// Successful sibling still appears after cal+overlay, in capture order among siblings
|
||||
@@ -151,16 +151,37 @@ test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
|
||||
test('test(photos): skip clean cal when sourceBelowBlock; keep overlay + siblings (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('below'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
|
||||
sourceBelowFloor: true,
|
||||
sourceBelowBlock: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
|
||||
});
|
||||
|
||||
test('test(photos): warn band keeps clean cal + attaches warn; siblings with warn stay png (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('warn-cal'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [
|
||||
null,
|
||||
{ status: 'ok', inventoryPng: png('sib'), warn: 'PNG size warn: shorter side is below 800 px (300–799); photo included' },
|
||||
],
|
||||
sourceBelowWarn: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']);
|
||||
assert.equal(lineup[0].kind, 'png');
|
||||
if (lineup[0].kind === 'png') {
|
||||
assert.match(lineup[0].warn ?? '', /warn|800|300/i);
|
||||
}
|
||||
assert.equal(lineup[2].kind, 'png');
|
||||
if (lineup[2].kind === 'png') {
|
||||
assert.match(lineup[2].warn ?? '', /warn|800/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
|
||||
assert.deepEqual(buildPhotosReadyLineup({}), []);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -8,6 +8,8 @@ export type PhotosReadyLineupItem =
|
||||
blob: Blob;
|
||||
/** Capture-order index for non-calibration batch inventory PNGs. */
|
||||
batchIndex?: number;
|
||||
/** Soft size-floor warn (300–799); photo still shown. */
|
||||
warn?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
@@ -19,25 +21,45 @@ export interface PhotosReadyLineupInput {
|
||||
inventoryPng?: Blob | null;
|
||||
overlayPng?: Blob | null;
|
||||
batchApply?: BatchPhotoResult[] | null;
|
||||
/** When true, omit the clean calibration PNG (M1 floor warn path). */
|
||||
sourceBelowFloor?: boolean;
|
||||
/** When true, omit the clean calibration PNG (hard block < 300 px). */
|
||||
sourceBelowBlock?: boolean;
|
||||
/** Soft warn for calibration (300–799); clean cal still included. */
|
||||
sourceBelowWarn?: boolean;
|
||||
/** Calibration warn message when sourceBelowWarn. */
|
||||
calibrationWarn?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Photos-ready lineup:
|
||||
* 1. Clean calibration inventory PNG
|
||||
* 1. Clean calibration inventory PNG (skipped only on hard block)
|
||||
* 2. Dimension-overlay copy
|
||||
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
|
||||
* with error slots for empty mask / floor failures.
|
||||
* with error slots for empty mask / block-floor failures; warn stays on png.
|
||||
*/
|
||||
export function buildPhotosReadyLineup(
|
||||
input: PhotosReadyLineupInput,
|
||||
): PhotosReadyLineupItem[] {
|
||||
const items: PhotosReadyLineupItem[] = [];
|
||||
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
|
||||
const {
|
||||
inventoryPng,
|
||||
overlayPng,
|
||||
batchApply,
|
||||
sourceBelowBlock,
|
||||
sourceBelowWarn,
|
||||
calibrationWarn,
|
||||
} = input;
|
||||
|
||||
if (inventoryPng && !sourceBelowFloor) {
|
||||
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
|
||||
if (inventoryPng && !sourceBelowBlock) {
|
||||
const warn = sourceBelowWarn
|
||||
? (calibrationWarn
|
||||
?? 'PNG size warn: shorter side is below 800 px (300–799); photo included')
|
||||
: undefined;
|
||||
items.push({
|
||||
kind: 'png',
|
||||
role: 'calibration',
|
||||
blob: inventoryPng,
|
||||
...(warn ? { warn } : {}),
|
||||
});
|
||||
}
|
||||
if (overlayPng) {
|
||||
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
|
||||
@@ -53,6 +75,7 @@ export function buildPhotosReadyLineup(
|
||||
role: 'batch',
|
||||
blob: slot.inventoryPng,
|
||||
batchIndex: i,
|
||||
...(slot.warn ? { warn: slot.warn } : {}),
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
|
||||
@@ -211,7 +211,8 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
|
||||
// Distinctive PNG blob so tests cannot confuse with source JPEG thumbs
|
||||
return {
|
||||
blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -269,7 +270,8 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['ok-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -287,15 +289,15 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => {
|
||||
test('#62: below block floor (<300) on one sibling → per-photo error; others proceed', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // ok
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // below floor
|
||||
const c = tinyJpeg('c'); // below block floor
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === c) {
|
||||
return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } };
|
||||
return { w: 250, h: 200, slabPx: 250 * 200, slabBounds: { x: 0, y: 0, w: 250, h: 200 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
@@ -303,10 +305,11 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
|
||||
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
|
||||
return {
|
||||
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowFloor: below,
|
||||
blob: new Blob([short < 300 ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowBlock: short < 300,
|
||||
sourceBelowWarn: short >= 300 && short < 800,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -317,9 +320,47 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error');
|
||||
assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error');
|
||||
if (apply![2]?.status === 'error') {
|
||||
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
|
||||
assert.match(apply![2].message, /floor|300|size/i, 'clear per-photo size-floor error');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: warn band (300–799) on sibling → ok with warn; photo included', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // ok
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // warn band
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === c) {
|
||||
return { w: 500, h: 400, slabPx: 500 * 400, slabBounds: { x: 0, y: 0, w: 500, h: 400 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
|
||||
return {
|
||||
blob: new Blob([short < 800 ? 'warn' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowBlock: short < 300,
|
||||
sourceBelowWarn: short >= 300 && short < 800,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok');
|
||||
if (apply![2]?.status === 'ok') {
|
||||
assert.match(apply![2].warn ?? '', /warn|800|300/i, 'warn message attached');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
@@ -330,7 +371,8 @@ test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -373,7 +415,8 @@ test('#62: draft persists batchApply after Calculate (for #64 UI later)', async
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -401,7 +444,8 @@ test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mas
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -426,7 +470,8 @@ test('#62: Calculate does not re-enter Mask / does not open Mask controls for si
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['x'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -463,7 +508,8 @@ test('#62: resume restores batchApply alongside calibration results', async () =
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['resume-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
|
||||
@@ -185,7 +185,8 @@ test('test(photos): Calculate stores overlayPng on results, clean inventory unto
|
||||
encodeN++;
|
||||
return {
|
||||
blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -221,7 +222,8 @@ test('test(photos): single-photo batch still yields clean + overlayPng (#63)', a
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -243,7 +245,8 @@ test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', asy
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface CaptureSession {
|
||||
export interface SessionOpts {
|
||||
draft?: DraftStore;
|
||||
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }>;
|
||||
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
|
||||
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
|
||||
}
|
||||
@@ -414,6 +414,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
...d.results,
|
||||
inventoryPng: d.inventoryPng ?? new Blob(),
|
||||
overlayPng: d.overlayPng ?? undefined,
|
||||
// Migrate legacy sourceBelowFloor → warn; missing flags default false.
|
||||
sourceBelowBlock: (d.results as { sourceBelowBlock?: boolean }).sourceBelowBlock ?? false,
|
||||
sourceBelowWarn:
|
||||
(d.results as { sourceBelowWarn?: boolean }).sourceBelowWarn
|
||||
?? (d.results as { sourceBelowFloor?: boolean }).sourceBelowFloor
|
||||
?? false,
|
||||
}
|
||||
: null,
|
||||
batchApply: d.batchApply,
|
||||
@@ -632,7 +638,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
tapeAxis,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
let belowBlock = false;
|
||||
let belowWarn = false;
|
||||
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
|
||||
let calBitmap = bitmap;
|
||||
let calMask = approvedMask;
|
||||
@@ -650,7 +657,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
belowBlock = r.sourceBelowBlock;
|
||||
belowWarn = r.sourceBelowWarn;
|
||||
} catch {
|
||||
// CAPTURE.md: inventory is image/png even when encode is unavailable.
|
||||
pngBlob = new Blob([], { type: 'image/png' });
|
||||
@@ -704,7 +712,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
...base,
|
||||
inventoryPng: pngBlob,
|
||||
overlayPng: overlayBlob,
|
||||
sourceBelowFloor: belowFloor,
|
||||
sourceBelowBlock: belowBlock,
|
||||
sourceBelowWarn: belowWarn,
|
||||
};
|
||||
const params = approvedMask.params;
|
||||
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||
@@ -736,16 +745,21 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
heightPx: derived.heightPx,
|
||||
};
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
let encoded: { blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean };
|
||||
try {
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
||||
} finally {
|
||||
siblingBitmap.close();
|
||||
}
|
||||
if (encoded.sourceBelowFloor) {
|
||||
throw new Error('PNG size floor: shorter side is below 800 px');
|
||||
if (encoded.sourceBelowBlock) {
|
||||
throw new Error('PNG size floor: shorter side is below 300 px');
|
||||
}
|
||||
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||
const warn = encoded.sourceBelowWarn
|
||||
? 'PNG size warn: shorter side is below 800 px (300–799); photo included'
|
||||
: undefined;
|
||||
batchApply[index] = warn
|
||||
? { status: 'ok', inventoryPng: encoded.blob, warn }
|
||||
: { status: 'ok', inventoryPng: encoded.blob };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||
batchApply[index] = { status: 'error', message };
|
||||
|
||||
@@ -18,7 +18,8 @@ export interface CalibratedCapture {
|
||||
widthMinIn: number;
|
||||
widthMaxIn: number;
|
||||
widthAvgIn: number;
|
||||
sourceBelowFloor: boolean;
|
||||
sourceBelowBlock: boolean;
|
||||
sourceBelowWarn: boolean;
|
||||
lineup: PhotosReadyLineupItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export function measure(
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
tape?: TapeAxis | null,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowBlock: false; sourceBelowWarn: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
let start: [number, number];
|
||||
let ux: number;
|
||||
@@ -132,6 +132,7 @@ export function measure(
|
||||
widths,
|
||||
sqft,
|
||||
bdft,
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
* Shared crop constants for inventory PNG + tape-line overlay (#99).
|
||||
* Aspect is fixed 3:4; crop_fill only moves the long-axis end insets.
|
||||
* Non-retro: changing fill affects future encodes only, not stored PNGs.
|
||||
*
|
||||
* Size floors (shorter side of slab bbox, source px):
|
||||
* - FLOOR_WARN_PX (800): target frame shorter side when source allows;
|
||||
* 300–799 → warn but still encode / include.
|
||||
* - FLOOR_BLOCK_PX (300): hard fail / exclude below this.
|
||||
*/
|
||||
|
||||
export const DEFAULT_CROP_FILL = 0.9;
|
||||
export const ASPECT_W = 3;
|
||||
export const ASPECT_H = 4;
|
||||
export const FLOOR_PX = 800;
|
||||
/** Target inventory frame shorter side when source can supply it. */
|
||||
export const FLOOR_WARN_PX = 800;
|
||||
/** Hard-block threshold: shorter side below this fails / excludes the photo. */
|
||||
export const FLOOR_BLOCK_PX = 300;
|
||||
/** @deprecated Prefer FLOOR_WARN_PX; kept as alias for the target frame size. */
|
||||
export const FLOOR_PX = FLOOR_WARN_PX;
|
||||
|
||||
/** Fraction of the long axis inset from each end: (1 - fill) / 2. */
|
||||
export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number {
|
||||
|
||||
@@ -30,7 +30,8 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha
|
||||
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.equal(out.sourceBelowBlock, true, '4px slab is below block floor');
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.ok(out.width < 800, 'undersized source stays small');
|
||||
|
||||
let opaque = 0;
|
||||
@@ -66,10 +67,11 @@ test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => {
|
||||
);
|
||||
assert.equal(out.width, 800);
|
||||
assert.equal(out.height, 1067);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
});
|
||||
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => {
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, no warn/block', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 1200, heightPx: 1000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
@@ -77,10 +79,24 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
|
||||
maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.equal(out.width, 800);
|
||||
});
|
||||
|
||||
test('warn band: shorter side 500 (300–799) → sourceBelowWarn, encode allowed, no upscale past source', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 600, heightPx: 500 };
|
||||
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: 600, h: 500 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowWarn, true);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed warn floor`);
|
||||
});
|
||||
|
||||
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
@@ -107,7 +123,7 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
||||
});
|
||||
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||
test('belowWarn (was belowFloor): 900×700 → warn not block, outW capped at warn floor', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
@@ -115,6 +131,20 @@ test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upsc
|
||||
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
|
||||
assert.equal(out.sourceBelowWarn, true);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the warn floor`);
|
||||
});
|
||||
|
||||
test('belowBlock: shorter side 200 → sourceBelowBlock, no upscale', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 250, heightPx: 200 };
|
||||
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: 250, h: 200 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowBlock, true);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.ok(out.width <= 800);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
import { ASPECT_H, ASPECT_W, FLOOR_PX, DEFAULT_CROP_FILL } from './crop';
|
||||
import { ASPECT_H, ASPECT_W, FLOOR_WARN_PX, FLOOR_BLOCK_PX, DEFAULT_CROP_FILL } from './crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
@@ -17,7 +17,10 @@ export interface Frame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
sourceBelowFloor: boolean;
|
||||
/** Shorter side of slab bbox is below FLOOR_BLOCK_PX (hard fail). */
|
||||
sourceBelowBlock: boolean;
|
||||
/** Shorter side is in [FLOOR_BLOCK_PX, FLOOR_WARN_PX) — encode/include with warn. */
|
||||
sourceBelowWarn: boolean;
|
||||
}
|
||||
|
||||
export function composeInventoryFrame(
|
||||
@@ -34,8 +37,12 @@ export function composeInventoryFrame(
|
||||
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_PX;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR_PX)) : FLOOR_PX;
|
||||
const shortSide = Math.min(sw, sh);
|
||||
const sourceBelowBlock = shortSide < FLOOR_BLOCK_PX;
|
||||
const sourceBelowWarn = !sourceBelowBlock && shortSide < FLOOR_WARN_PX;
|
||||
// Undersized vs warn floor: frame from available source (no upscale to 800).
|
||||
const undersized = shortSide < FLOOR_WARN_PX;
|
||||
const outW = undersized ? Math.max(1, Math.min(sw, FLOOR_WARN_PX)) : FLOOR_WARN_PX;
|
||||
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;
|
||||
@@ -60,12 +67,12 @@ export function composeInventoryFrame(
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
||||
return { width: outW, height: outH, rgba, sourceBelowBlock, sourceBelowWarn };
|
||||
}
|
||||
|
||||
export async function encodeInventoryPng(
|
||||
input: InventoryInput,
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
): Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }> {
|
||||
const { bitmap, mask, source } = input;
|
||||
const fill = input.cropFill ?? getCropFill();
|
||||
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||
@@ -82,5 +89,9 @@ export async function encodeInventoryPng(
|
||||
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 };
|
||||
return {
|
||||
blob,
|
||||
sourceBelowBlock: frame.sourceBelowBlock,
|
||||
sourceBelowWarn: frame.sourceBelowWarn,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user