feat(photos): apply frozen mask to batch siblings after Calculate (#62)
After calibration Calculate, reuse approved sheet/sensitivity on non-calibration photos via the mask worker + inventory compose. Persist per-photo ok/error on measure.batchApply and the device draft for #64; leave Mask UI unopened and keep scale/widths/sqft/bdft on the calibration results only.
This commit is contained in:
@@ -70,6 +70,13 @@ export interface Measures {
|
|||||||
sourceBelowFloor: boolean;
|
sourceBelowFloor: 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: 'error'; message: string };
|
||||||
|
|
||||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||||
// M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration.
|
// M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration.
|
||||||
export type Capture =
|
export type Capture =
|
||||||
@@ -97,6 +104,8 @@ export type Capture =
|
|||||||
lengthEighths: Eighths;
|
lengthEighths: Eighths;
|
||||||
thicknessEighths: Eighths;
|
thicknessEighths: Eighths;
|
||||||
results: Measures | null;
|
results: Measures | null;
|
||||||
|
/** Capture-order sibling results from the post-Calculate batch pass. */
|
||||||
|
batchApply?: BatchPhotoResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Placeholder geometry for batch-only drafts (no mask yet).
|
// Placeholder geometry for batch-only drafts (no mask yet).
|
||||||
@@ -131,6 +140,8 @@ export interface Draft {
|
|||||||
thicknessEighths: Eighths;
|
thicknessEighths: Eighths;
|
||||||
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed
|
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed
|
||||||
inventoryPng: Blob | null; // stored so Continue can show Measure results
|
inventoryPng: Blob | null; // stored so Continue can show Measure results
|
||||||
|
/** Capture-order batch apply results; blobs are retained for the #64 lineup. */
|
||||||
|
batchApply?: BatchPhotoResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker protocol. Job ids increase; the worker drops anything older than the
|
// Worker protocol. Job ids increase; the worker drops anything older than the
|
||||||
|
|||||||
@@ -408,10 +408,15 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
|||||||
assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode');
|
assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still only calibration blob through the mask worker.
|
// Pre-#62: Mask UI path used only the calibration blob. After Calculate, #62
|
||||||
for (const blob of FakeMaskWorker.inits) {
|
// may init the worker on non-calibration siblings for frozen-mask apply.
|
||||||
assert.equal(blob, b);
|
const preApplyInits = FakeMaskWorker.inits.filter((blob) => blob === b || blob === a);
|
||||||
}
|
assert.ok(preApplyInits.some((blob) => blob === b), 'calibration must have been masked');
|
||||||
|
// Sibling apply may touch `a`; that is #62, not a #61 regression of Mask UI.
|
||||||
|
assert.ok(
|
||||||
|
FakeMaskWorker.inits.filter((blob) => blob === b).length >= 1,
|
||||||
|
'calibration blob must appear in worker inits',
|
||||||
|
);
|
||||||
|
|
||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import type {
|
import type {
|
||||||
Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||||
} from './capture.types';
|
} from './capture.types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||||
import { measure } from '../measure/measure';
|
import { measure } from '../measure/measure';
|
||||||
import { orientedRect } from '../geometry/rect';
|
import { orientedRect } from '../geometry/rect';
|
||||||
import { encodeInventoryPng } from '../png/inventory';
|
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
const BUSY_MS = 300;
|
const BUSY_MS = 300;
|
||||||
@@ -50,6 +50,8 @@ export interface CaptureSession {
|
|||||||
|
|
||||||
export interface SessionOpts {
|
export interface SessionOpts {
|
||||||
draft?: DraftStore;
|
draft?: DraftStore;
|
||||||
|
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
|
||||||
|
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
|
const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
|
||||||
@@ -61,6 +63,7 @@ const batchPhotosOf = (d: Draft): Source[] =>
|
|||||||
// Every transition to an approved branch persists; no explicit "save".
|
// Every transition to an approved branch persists; no explicit "save".
|
||||||
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||||
const drafts = opts.draft ?? defaultDraft;
|
const drafts = opts.draft ?? defaultDraft;
|
||||||
|
const encodeInventory = opts.encodeInventory ?? encodeInventoryPng;
|
||||||
const capture = writable<Capture>({ k: 'batch', photos: [] });
|
const capture = writable<Capture>({ k: 'batch', photos: [] });
|
||||||
const busy = writable(false);
|
const busy = writable(false);
|
||||||
let state: Capture = { k: 'batch', photos: [] };
|
let state: Capture = { k: 'batch', photos: [] };
|
||||||
@@ -210,6 +213,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||||
results: state.results ? rest : null,
|
results: state.results ? rest : null,
|
||||||
inventoryPng: state.results?.inventoryPng ?? null,
|
inventoryPng: state.results?.inventoryPng ?? null,
|
||||||
|
batchApply: state.batchApply,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (state.k === 'mask' && state.overlay) {
|
if (state.k === 'mask' && state.overlay) {
|
||||||
@@ -240,8 +244,13 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft();
|
else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-derive the mask bitset from the stored photo + params so Measure can run.
|
// Re-derive the mask bitset from a stored photo + frozen params. The decoded
|
||||||
const rederiveMask = (source: Source, params: MaskParams, stub: Omit<Mask, 'bitset' | 'params'>): Promise<Mask> =>
|
// dimensions are returned because batch Sources begin with dimensions unknown.
|
||||||
|
const rederiveMaskDetailed = (
|
||||||
|
source: Source,
|
||||||
|
params: MaskParams,
|
||||||
|
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||||
|
): Promise<{ mask: Mask; widthPx: number; heightPx: number }> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
if (typeof Worker === 'undefined') {
|
if (typeof Worker === 'undefined') {
|
||||||
reject(new Error('Worker unavailable'));
|
reject(new Error('Worker unavailable'));
|
||||||
@@ -249,6 +258,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
}
|
}
|
||||||
const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||||
let id = 0;
|
let id = 0;
|
||||||
|
let widthPx = source.widthPx;
|
||||||
|
let heightPx = source.heightPx;
|
||||||
const fail = (reason: string) => {
|
const fail = (reason: string) => {
|
||||||
w.terminate();
|
w.terminate();
|
||||||
reject(new Error(reason));
|
reject(new Error(reason));
|
||||||
@@ -256,6 +267,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
w.onmessage = (e: MessageEvent<WorkerOut>) => {
|
w.onmessage = (e: MessageEvent<WorkerOut>) => {
|
||||||
const msg = e.data;
|
const msg = e.data;
|
||||||
if (msg.kind === 'decoded') {
|
if (msg.kind === 'decoded') {
|
||||||
|
widthPx = msg.w;
|
||||||
|
heightPx = msg.h;
|
||||||
w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
|
w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -266,17 +279,27 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (msg.kind === 'ready') {
|
if (msg.kind === 'ready') {
|
||||||
w.terminate();
|
w.terminate();
|
||||||
resolve({
|
resolve({
|
||||||
|
widthPx,
|
||||||
|
heightPx,
|
||||||
|
mask: {
|
||||||
params,
|
params,
|
||||||
slabPx: msg.slabPx || stub.slabPx,
|
slabPx: msg.slabPx || stub.slabPx,
|
||||||
bitset: msg.bitset,
|
bitset: msg.bitset,
|
||||||
bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
|
bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
|
||||||
slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
|
slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
|
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rederiveMask = (
|
||||||
|
source: Source,
|
||||||
|
params: MaskParams,
|
||||||
|
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||||
|
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
|
||||||
|
|
||||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||||
// fall back to the mask's own bbox.
|
// fall back to the mask's own bbox.
|
||||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||||
@@ -321,6 +344,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
results: d.results
|
results: d.results
|
||||||
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
|
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
|
||||||
: null,
|
: null,
|
||||||
|
batchApply: d.batchApply,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||||
@@ -473,7 +497,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
// #61: M1 Calculate on the calibration source only (batch apply is #62).
|
// #62: Calculate the calibration photo, then apply its frozen mask params
|
||||||
|
// to siblings. This stays in Measure; siblings never enter Mask controls.
|
||||||
async calculate() {
|
async calculate() {
|
||||||
if (state.k !== 'measure') return;
|
if (state.k !== 'measure') return;
|
||||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||||
@@ -486,7 +511,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
let pngBlob: Blob;
|
let pngBlob: Blob;
|
||||||
let belowFloor = false;
|
let belowFloor = false;
|
||||||
try {
|
try {
|
||||||
const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source });
|
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||||
pngBlob = r.blob;
|
pngBlob = r.blob;
|
||||||
belowFloor = r.sourceBelowFloor;
|
belowFloor = r.sourceBelowFloor;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -497,7 +522,53 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||||
pngUrl = URL.createObjectURL(pngBlob);
|
pngUrl = URL.createObjectURL(pngBlob);
|
||||||
const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
|
const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
|
||||||
set({ ...state, results });
|
const params = approvedMask.params;
|
||||||
|
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||||
|
? calibrationIndex
|
||||||
|
: 0;
|
||||||
|
const batch = batchPhotos.length ? batchPhotos : [source];
|
||||||
|
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Batch photo was not processed',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Process serially so each worker owns exactly one sibling photo and a
|
||||||
|
// failing decode/mask/encode cannot prevent later siblings from running.
|
||||||
|
for (let index = 0; index < batch.length; index++) {
|
||||||
|
if (index === calIndex) continue;
|
||||||
|
const photo = batch[index];
|
||||||
|
try {
|
||||||
|
const derived = await rederiveMaskDetailed(photo, params, {
|
||||||
|
slabPx: 0,
|
||||||
|
bbox: EMPTY_BBOX,
|
||||||
|
slabBounds: EMPTY_ABOX,
|
||||||
|
});
|
||||||
|
if (derived.mask.slabPx === 0) {
|
||||||
|
throw new Error('empty mask: no slab pixels detected');
|
||||||
|
}
|
||||||
|
const siblingSource: Source = {
|
||||||
|
...photo,
|
||||||
|
widthPx: derived.widthPx,
|
||||||
|
heightPx: derived.heightPx,
|
||||||
|
};
|
||||||
|
const siblingBitmap = await createImageBitmap(photo.blob);
|
||||||
|
let encoded: { blob: Blob; sourceBelowFloor: 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');
|
||||||
|
}
|
||||||
|
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||||
|
batchApply[index] = { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ ...state, results, batchApply });
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user