feat(photos): pick calibration photo before Mask (#60)

Add pickCalibration state/UI over batch thumbs. Continue from batch opens
pick (including single-photo confirm). User pick enters M1 Mask on that
Source only; draft stores calibrationIndex. Removes first-photo proxy.
This commit is contained in:
2026-09-15 21:11:07 +00:00
parent 4fa8126471
commit d18b96c10b
4 changed files with 141 additions and 10 deletions
+7
View File
@@ -5,6 +5,7 @@
import type { Capture } from './capture/capture.types'; import type { Capture } from './capture/capture.types';
import Resume from './capture/Resume.svelte'; import Resume from './capture/Resume.svelte';
import BatchPhotos from './capture/BatchPhotos.svelte'; import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte'; import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte'; import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte';
@@ -54,6 +55,12 @@
onRemove={session.removeFromBatch} onRemove={session.removeFromBatch}
onContinue={session.continueBatch} onContinue={session.continueBatch}
/> />
{:else if cap.k === 'pickCalibration'}
<PickCalibration
photos={cap.photos}
onPick={session.pickCalibration}
onBack={session.back}
/>
{:else if cap.k === 'mask'} {:else if cap.k === 'mask'}
<MaskPanel <MaskPanel
state={cap} state={cap}
@@ -0,0 +1,66 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Source } from './capture.types';
export let photos: Source[];
export let onPick: (index: number) => void;
export let onBack: () => void;
let urls: string[] = [];
$: {
for (const u of urls) URL.revokeObjectURL(u);
urls = photos.map((p) => URL.createObjectURL(p.blob));
}
onDestroy(() => {
for (const u of urls) URL.revokeObjectURL(u);
});
</script>
<section class="panel">
<h1>Pick calibration photo</h1>
<p class="hint">
Choose which photo is the top-down reference for length and thickness.
Mask, length axis, and Measure run on that photo only.
</p>
<ul class="thumbs" aria-label="Calibration candidates">
{#each photos as _p, i (urls[i])}
<li class="thumb">
<img src={urls[i]} alt={`Photo ${i + 1}`} />
<button
type="button"
class="pick"
aria-label={`Use photo ${i + 1} as calibration`}
on:click={() => onPick(i)}
>Use as calibration</button>
</li>
{/each}
</ul>
<button class="back" type="button" on:click={onBack}>Back</button>
</section>
<style>
.panel { padding: 24px 16px; color: #eee; }
h1 { font-size: 1.4rem; }
.hint { color: #bbb; line-height: 1.5; font-size: 0.95rem; }
.thumbs {
list-style: none; padding: 0; margin: 16px 0;
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px;
}
.thumb {
background: #1e1e1e; border-radius: 8px; overflow: hidden;
display: flex; flex-direction: column;
}
.thumb img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; display: block; background: #000; }
.pick {
border: 0; background: #00e5ff; color: #002; padding: 10px 8px;
font-size: 0.9rem; font-weight: 700; cursor: pointer;
}
.back {
margin-top: 8px; width: 100%; padding: 12px; font-size: 1rem; border-radius: 10px;
border: 1px solid #444; background: transparent; color: #ccc; cursor: pointer;
}
</style>
+6 -3
View File
@@ -71,10 +71,11 @@ export interface Measures {
} }
// 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' replaces M1 'addPhoto' — accumulate 1–N photos before calibration. // M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration.
export type Capture = export type Capture =
| { k: 'resume'; draft: Draft } | { k: 'resume'; draft: Draft }
| { k: 'batch'; photos: Source[] } | { k: 'batch'; photos: Source[] }
| { k: 'pickCalibration'; photos: Source[] }
| { | {
k: 'mask'; k: 'mask';
source: Source; source: Source;
@@ -117,10 +118,12 @@ export const DEFAULT_MASK_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50
// M2 extends with `batch` (capture-order photo blobs). Legacy M1 drafts omit it. // M2 extends with `batch` (capture-order photo blobs). Legacy M1 drafts omit it.
export interface Draft { export interface Draft {
version: 1; version: 1;
stage: 'batch' | 'mask' | 'axis' | 'measure'; // last approved / persisted state stage: 'batch' | 'pickCalibration' | 'mask' | 'axis' | 'measure'; // last approved / persisted state
/** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */ /** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */
batch?: Source[]; batch?: Source[];
source: Source; // calibration / active photo; for stage==='batch' = photos[0] /** Index into batch of the user-picked calibration photo. Set once picked. */
calibrationIndex?: number;
source: Source; // calibration / active photo; for stage==='batch'|'pickCalibration' = photos[0] placeholder
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch') maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
+62 -7
View File
@@ -30,8 +30,10 @@ export interface CaptureSession {
/** Alias: same as addToBatch (no longer jumps to mask). */ /** Alias: same as addToBatch (no longer jumps to mask). */
addPhoto(file: File): Promise<void>; addPhoto(file: File): Promise<void>;
removeFromBatch(index: number): void; removeFromBatch(index: number): void;
/** Advance when batch has ≥1 photo. M1 bridge: mask on first photo. */ /** Advance when batch has ≥1 photo → pickCalibration. */
continueBatch(): void; continueBatch(): void;
/** User picks which batch photo is the calibration reference → Mask on that Source. */
pickCalibration(index: number): void;
retakes(): void; retakes(): void;
setSensitivity(n: number): void; setSensitivity(n: number): void;
setSheet(s: Sheet): void; setSheet(s: Sheet): void;
@@ -63,6 +65,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const busy = writable(false); const busy = writable(false);
let state: Capture = { k: 'batch', photos: [] }; let state: Capture = { k: 'batch', photos: [] };
let batchPhotos: Source[] = []; let batchPhotos: Source[] = [];
let calibrationIndex: number | null = null;
let worker: Worker | null = null; let worker: Worker | null = null;
let jobId = 0; let jobId = 0;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
@@ -173,9 +176,25 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
inventoryPng: null, inventoryPng: null,
}; };
} }
if (state.k === 'pickCalibration') {
return {
version: 1,
stage: 'pickCalibration',
batch: state.photos,
source: state.photos[0],
maskParams: DEFAULT_MASK_PARAMS,
mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX },
lengthEighths: 0,
thicknessEighths: 0,
results: null,
inventoryPng: null,
};
}
const calIdx = calibrationIndex ?? undefined;
if (state.k === 'axis') { if (state.k === 'axis') {
return { return {
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source], version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params, source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
@@ -185,6 +204,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
return { return {
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params, source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
@@ -195,11 +215,22 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k === 'mask' && state.overlay) { if (state.k === 'mask' && state.overlay) {
return { return {
version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx,
source: state.source, maskParams: state.overlay.params, source: state.source, maskParams: state.overlay.params,
mask: strip(state.overlay), mask: strip(state.overlay),
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
}; };
} }
// Mask before first overlay settles: still persist calibration choice + batch.
if (state.k === 'mask') {
return {
version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx,
source: state.source, maskParams: state.control,
mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX },
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
return null; return null;
}; };
@@ -252,11 +283,19 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const restoreFromDraft = async (d: Draft) => { const restoreFromDraft = async (d: Draft) => {
batchPhotos = batchPhotosOf(d); batchPhotos = batchPhotosOf(d);
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
if (d.stage === 'batch') { if (d.stage === 'batch') {
teardownWorker(); teardownWorker();
calibrationIndex = null;
set({ k: 'batch', photos: [...batchPhotos] }); set({ k: 'batch', photos: [...batchPhotos] });
return; return;
} }
if (d.stage === 'pickCalibration') {
teardownWorker();
calibrationIndex = null;
set({ k: 'pickCalibration', photos: [...batchPhotos] });
return;
}
const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob }; const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
if (d.stage === 'mask') { if (d.stage === 'mask') {
teardownWorker(); teardownWorker();
@@ -289,9 +328,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
} }
}; };
const enterMaskFromBatch = () => { const enterMaskOnSource = (source: Source) => {
if (batchPhotos.length === 0) return;
const source = batchPhotos[0];
teardownWorker(); teardownWorker();
set({ set({
k: 'mask', k: 'mask',
@@ -324,6 +361,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'resume') return; if (state.k !== 'resume') return;
teardownWorker(); teardownWorker();
batchPhotos = []; batchPhotos = [];
calibrationIndex = null;
await drafts.clearDraft(); await drafts.clearDraft();
set({ k: 'batch', photos: [] }); set({ k: 'batch', photos: [] });
}, },
@@ -361,11 +399,23 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
continueBatch() { continueBatch() {
if (state.k !== 'batch' || state.photos.length === 0) return; if (state.k !== 'batch' || state.photos.length === 0) return;
batchPhotos = [...state.photos]; batchPhotos = [...state.photos];
enterMaskFromBatch(); calibrationIndex = null;
teardownWorker();
set({ k: 'pickCalibration', photos: [...batchPhotos] });
void persist();
},
pickCalibration(index: number) {
if (state.k !== 'pickCalibration') return;
if (index < 0 || index >= state.photos.length) return;
batchPhotos = [...state.photos];
calibrationIndex = index;
enterMaskOnSource(batchPhotos[index]);
}, },
retakes() { retakes() {
teardownWorker(); teardownWorker();
calibrationIndex = null;
// Return to the batch (photos kept) so the user can add/replace before continuing. // Return to the batch (photos kept) so the user can add/replace before continuing.
set({ k: 'batch', photos: [...batchPhotos] }); set({ k: 'batch', photos: [...batchPhotos] });
void persist(); void persist();
@@ -448,10 +498,15 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}, },
back() { back() {
if (state.k === 'mask') { if (state.k === 'pickCalibration') {
teardownWorker(); calibrationIndex = null;
set({ k: 'batch', photos: [...batchPhotos] }); set({ k: 'batch', photos: [...batchPhotos] });
void persist(); void persist();
} else if (state.k === 'mask') {
teardownWorker();
calibrationIndex = null;
set({ k: 'pickCalibration', photos: [...batchPhotos] });
void persist();
} else if (state.k === 'axis') { } else if (state.k === 'axis') {
// back to mask keeps the last approved mask as the live overlay // back to mask keeps the last approved mask as the live overlay
const m = state.approvedMask; const m = state.approvedMask;