feat(fe): replace threshold mask with client-side sheet BG removal (flood-fill)

The green/black-sheet luminance threshold could not separate the slab from
the 'black' sheet, which in real photos reads at lum ~50 and overlaps the
slab's bark/shadow tones (measured on the user's samples: 0% green, 60-80%
dark, no brightness gap) - it removed 100% of the image. Replace the mask
pass with connectivity-based background removal: sample the sheet tone from
the border, flood-fill from the border marking sheet-connected pixels as
background, keep the connected remainder as the slab. Robust to tonal
overlap. Adds bgRemoval.ts (removeBackground + sampleSheetTone) with unit
tests proving a sheet-toned slab pixel survives via enclosure.
This commit is contained in:
2026-09-25 09:07:39 -07:00
parent 673593c9b6
commit 1c58f12c5d
3 changed files with 219 additions and 20 deletions
+21 -20
View File
@@ -15,7 +15,7 @@ import { get, writable, type Readable } from 'svelte/store';
import { calibrationScale, fillFromSetting, FILL_DEFAULT } from '../png/crop';
import { measure } from '../measure/measure';
import { initialTapeLine, rectAlongLine, slabBounds, minAreaRect } from '../geometry/rect';
import { keepForSheet } from '../mask/sheetKeep';
import { removeBackground, sampleSheetTone } from '../mask/bgRemoval';
import { composeInventoryFrame } from '../png/inventory';
import { eighthsToIn, type Axis, type BatchPhotoResult, type Capture, type Mask, type MaskParams, type Measures, type Sheet, type Source } from './capture.types';
import { buildPhotosReadyLineup, type PhotosReadyLineupInput, type PhotosReadyLineupItem } from './photosReadyLineup';
@@ -99,32 +99,33 @@ export const buildMask = (
};
};
/** Default mask pass: keep predicates from `mask/sheetKeep` over the decoded grid. */
/** Default mask pass: client-side sheet removal (border flood-fill). */
export const defaultMaskRun: MaskRun = async (source, params, decoded) =>
buildMask(params, decoded, walkMask(decoded, params));
/** Walk the decoded RGBA grid through the sheet-keep predicate (CAPTURE-30..60). */
/**
* Mask pass (CAPTURE-30..60): remove the sheet-connected background by
* flood-filling from the image border against the sampled sheet tone, keeping
* the connected remainder as the slab. This is robust to sheet/slab luminance
* overlap that defeats a global brightness threshold.
*
* `sheet`: 'auto' (sampled tone) uses pure connectivity; 'green'/'black' bias
* the sampled tone toward that sheet so the fill anchors correctly.
* `sensitivity` (0–100) maps onto the color distance tolerance.
*/
function walkMask(decoded: DecodedPhoto, params: MaskParams): Uint8Array {
const keep = keepForSheet(params.sheet, params.sensitivity, samplePixels(decoded));
const { width, height, rgba } = decoded;
const bitset = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
bitset[i] = keep(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]) ? 1 : 0;
}
return bitset;
const tone = sheetToneFor(params.sheet, decoded);
const tolerance = 20 + (params.sensitivity / 100) * 80; // 20..100 distance
return removeBackground(width, height, rgba, { tolerance, sheetTone: tone });
}
/** Stride-16 edge-pixel sample for `autoPickSheet` (matches the P1 mask worker). */
function samplePixels(decoded: DecodedPhoto): { r: number; g: number; b: number }[] {
const { width, height, rgba } = decoded;
const samples: { r: number; g: number; b: number }[] = [];
for (let y = 0; y < height; y += 16) {
for (let x = 0; x < width; x += 16) {
const i = (y * width + x) * 4;
samples.push({ r: rgba[i], g: rgba[i + 1], b: rgba[i + 2] });
}
}
return samples;
/** Sheet tone to anchor the flood fill: sampled border, biased by the choice. */
function sheetToneFor(sheet: MaskParams['sheet'], decoded: DecodedPhoto): { r: number; g: number; b: number } {
const sampled = sampleSheetTone(decoded.width, decoded.height, decoded.rgba);
if (sheet === 'green') return { r: 40, g: 170, b: 60 };
if (sheet === 'black') return { r: 30, g: 30, b: 30 };
return sampled;
}
/** Default inventory encode: P1 frame composer + canvas → image/png blob. */