feat(capture): ship tape-line calibration (#99)

This commit is contained in:
Ty
2026-09-24 14:47:13 -07:00
parent 6fb92362a4
commit 4ca2ef57a7
13 changed files with 437 additions and 738 deletions
+32 -13
View File
@@ -1,16 +1,16 @@
import { writable } from 'svelte/store';
import type {
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, WorkerIn, WorkerOut,
} from './capture.types';
import {
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
} from './capture.types';
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { initialTapeLine, rectAlongLine } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { FILL_DEFAULT, fillFromSetting } from '../png/crop';
import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
const DEBOUNCE_MS = 2000;
@@ -41,7 +41,7 @@ export interface CaptureSession {
setSensitivity(n: number): void;
setSheet(s: Sheet): void;
approveMask(): void;
setAxisAngle(deg: number): void;
setTapeLine(tapeLine: TapeLine): void;
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
setCropFill(pctOrFraction: number): void;
approveAxis(): void;
@@ -212,6 +212,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
tapeLine: state.axis.tapeLine,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
@@ -222,6 +223,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
tapeLine: state.axis.tapeLine,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
@@ -316,6 +318,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
// fall back to the mask's own bbox.
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
const draftTapeLine = (d: Draft) => d.tapeLine ?? initialTapeLine(d.source.widthPx, d.source.heightPx);
const restoreFromDraft = async (d: Draft) => {
batchPhotos = batchPhotosOf(d);
@@ -343,7 +346,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
const tapeLine = draftTapeLine(d);
set({ k: 'axis', source, approvedMask: mask, axis: {
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine,
} });
return;
}
if (d.inventoryPng) {
@@ -352,7 +358,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}
set({
k: 'measure', source, approvedMask: mask,
axis: { bbox: draftAxisBbox(d) },
axis: {
bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end),
tapeLine: draftTapeLine(d),
},
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? {
@@ -481,14 +490,20 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
const overlay = state.overlay;
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
const tapeLine = initialTapeLine(state.source.widthPx, state.source.heightPx);
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: {
bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine,
} });
void persist();
},
setAxisAngle(deg: number) {
setTapeLine(tapeLine: TapeLine) {
if (state.k !== 'axis') return;
const a = ((Math.round(deg) % 180) + 180) % 180;
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
const start: [number, number] = [...tapeLine.start];
const end: [number, number] = [...tapeLine.end];
const bbox = rectAlongLine(state.axis.bbox, start, end);
if (!(bbox.longSidePx > 0)) return;
set({ ...state, axis: { bbox, tapeLine: { start, end } } });
void persist();
},
@@ -501,7 +516,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'axis') return;
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: { bbox: state.axis.bbox },
axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine },
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
@@ -525,10 +540,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return;
const scale = calibrationScale(axis.tapeLine.start, axis.tapeLine.end, eighthsToIn(lengthEighths));
if (!(scale > 0) || !Number.isFinite(scale)) return;
const bitmap = await decodeOrientedBitmap(source.blob);
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale,
);
let pngBlob: Blob;
let belowFloor = false;
@@ -636,7 +653,9 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine,
} });
}
},