From 89bc7d4ee9de10204e6751b63de5b2fa54f8d5a7 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 08:38:17 -0700 Subject: [PATCH] fix(measure): show masked calibration preview --- frontend/src/capture/MeasurePanel.svelte | 60 ++++++++++++++++++++---- frontend/src/png/maskedPreview.test.ts | 14 ++++++ frontend/src/png/maskedPreview.ts | 52 ++++++++++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 frontend/src/png/maskedPreview.test.ts create mode 100644 frontend/src/png/maskedPreview.ts diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index a07c605..2befc41 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -8,6 +8,7 @@ import PhotoStage from './PhotoStage.svelte'; import { getCropFill, setCropFill } from '../settings/cropFill'; import { cropMarginFraction } from '../png/crop'; + import { createMaskedPreviewBlob } from '../png/maskedPreview'; import { getTaxonomy } from '../api/slabs'; import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves'; import { @@ -41,6 +42,40 @@ $: tape = state.axis.tape; $: tiltShown = Math.round(tape.tiltDeg * 10) / 10; + let maskedPreview: Blob | null = null; + let previewError = ''; + let previewGeneration = 0; + let previewSource: Blob | null = null; + let previewMask: Uint8Array | null = null; + let previewW = 0; + let previewH = 0; + + // Build the tape photo once from the oriented source plus the approved mask. + // Never fall back to the raw source here: tape alignment must show the same + // background-removed slab footprint that was approved in Mask. + $: { + const sourceBlob = state.source.blob; + const bitset = state.approvedMask.bitset; + const width = state.source.widthPx; + const height = state.source.heightPx; + if (sourceBlob !== previewSource || bitset !== previewMask || width !== previewW || height !== previewH) { + previewSource = sourceBlob; + previewMask = bitset; + previewW = width; + previewH = height; + maskedPreview = null; + previewError = ''; + const generation = ++previewGeneration; + void createMaskedPreviewBlob(sourceBlob, bitset) + .then((blob) => { + if (generation === previewGeneration) maskedPreview = blob; + }) + .catch(() => { + if (generation === previewGeneration) previewError = 'Could not build the background-removed preview.'; + }); + } + } + let lineupUrls: string[] = []; let lineupItems: PhotosReadyLineupItem[] = []; @@ -84,6 +119,7 @@ }); onDestroy(() => { + previewGeneration += 1; for (const u of lineupUrls) URL.revokeObjectURL(u); }); @@ -154,16 +190,19 @@

Orient the slab on the fixed tape line, then enter length, thickness, and species.

The black line is fixed: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.

- + {#if maskedPreview} + + {:else} +
+ {previewError || 'Preparing background-removed preview…'} +
+ {/if}
@@ -344,6 +383,7 @@ h1 { font-size: 1.3rem; margin: 0 0 8px; } .hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; } .nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; } + .preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; } .controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; } .ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; } .ctl-label .sub { font-size: 0.7rem; color: #777; } diff --git a/frontend/src/png/maskedPreview.test.ts b/frontend/src/png/maskedPreview.test.ts new file mode 100644 index 0000000..6315f60 --- /dev/null +++ b/frontend/src/png/maskedPreview.test.ts @@ -0,0 +1,14 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyMaskAlpha } from './maskedPreview'; + +test('applyMaskAlpha keeps slab pixels and clears the background', () => { + const rgba = new Uint8ClampedArray([ + 1, 2, 3, 255, + 4, 5, 6, 255, + 7, 8, 9, 255, + ]); + const out = applyMaskAlpha(rgba, new Uint8Array([1, 0])); + assert.deepEqual([...out], [1, 2, 3, 255, 4, 5, 6, 0, 7, 8, 9, 0]); + assert.deepEqual([...rgba], [1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255]); +}); diff --git a/frontend/src/png/maskedPreview.ts b/frontend/src/png/maskedPreview.ts new file mode 100644 index 0000000..07b872a --- /dev/null +++ b/frontend/src/png/maskedPreview.ts @@ -0,0 +1,52 @@ +import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; + +/** + * Apply a source-grid mask to decoded RGBA pixels without changing their + * dimensions. The transparent pixels remain in the full source frame so the + * tape transform and all measurement geometry keep using source coordinates. + */ +export function applyMaskAlpha( + rgba: Uint8ClampedArray, + bitset: Uint8Array, +): Uint8ClampedArray { + const out = new Uint8ClampedArray(rgba); + const pixels = out.length / 4; + for (let p = 0; p < pixels; p++) { + if (p >= bitset.length || !bitset[p]) out[p * 4 + 3] = 0; + } + return out; +} + +/** + * Encode an EXIF-oriented source photo with the approved mask as alpha. + * This is display-only; the original source blob and mask stay in session + * state for geometry, measurement, and inventory encoding. + */ +export async function createMaskedPreviewBlob( + source: Blob, + bitset: Uint8Array, +): Promise { + const bitmap = await decodeOrientedBitmap(source); + try { + const width = bitmap.width; + const height = bitmap.height; + const hasOffscreenCanvas = typeof OffscreenCanvas !== 'undefined'; + const canvas: OffscreenCanvas | HTMLCanvasElement = hasOffscreenCanvas + ? new OffscreenCanvas(width, height) + : Object.assign(document.createElement('canvas'), { width, height }); + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; + if (!ctx) throw new Error('Could not create preview canvas'); + ctx.drawImage(bitmap, 0, 0); + const image = ctx.getImageData(0, 0, width, height); + image.data.set(applyMaskAlpha(image.data, bitset)); + ctx.putImageData(image, 0, 0); + if (hasOffscreenCanvas) { + return await (canvas as OffscreenCanvas).convertToBlob({ type: 'image/png' }); + } + return await new Promise((resolve, reject) => { + (canvas as HTMLCanvasElement).toBlob((blob: Blob | null) => (blob ? resolve(blob) : reject(new Error('Could not encode preview'))), 'image/png'); + }); + } finally { + bitmap.close(); + } +}