fix(measure): show masked calibration preview
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
import PhotoStage from './PhotoStage.svelte';
|
import PhotoStage from './PhotoStage.svelte';
|
||||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||||
import { cropMarginFraction } from '../png/crop';
|
import { cropMarginFraction } from '../png/crop';
|
||||||
|
import { createMaskedPreviewBlob } from '../png/maskedPreview';
|
||||||
import { getTaxonomy } from '../api/slabs';
|
import { getTaxonomy } from '../api/slabs';
|
||||||
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
|
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +42,40 @@
|
|||||||
$: tape = state.axis.tape;
|
$: tape = state.axis.tape;
|
||||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
$: 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 lineupUrls: string[] = [];
|
||||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||||
|
|
||||||
@@ -84,6 +119,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
previewGeneration += 1;
|
||||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,16 +190,19 @@
|
|||||||
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
|
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
|
||||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||||
|
|
||||||
<PhotoStage
|
{#if maskedPreview}
|
||||||
mode="tape"
|
<PhotoStage
|
||||||
blob={state.source.blob}
|
mode="tape"
|
||||||
maskBitset={state.approvedMask.bitset}
|
blob={maskedPreview}
|
||||||
{maskW}
|
{tape}
|
||||||
{maskH}
|
{cropFill}
|
||||||
{tape}
|
onTapeChange={onTapeChange}
|
||||||
{cropFill}
|
/>
|
||||||
onTapeChange={onTapeChange}
|
{:else}
|
||||||
/>
|
<div class="preview-loading" role="status">
|
||||||
|
{previewError || 'Preparing background-removed preview…'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div>
|
<div>
|
||||||
@@ -344,6 +383,7 @@
|
|||||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||||
.hint { color: #aaa; font-size: 0.9rem; 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; }
|
.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; }
|
.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 { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
@@ -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<Blob> {
|
||||||
|
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<Blob>((resolve, reject) => {
|
||||||
|
(canvas as HTMLCanvasElement).toBlob((blob: Blob | null) => (blob ? resolve(blob) : reject(new Error('Could not encode preview'))), 'image/png');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user