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:
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user