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 { calibrationScale, fillFromSetting, FILL_DEFAULT } from '../png/crop';
import { measure } from '../measure/measure'; import { measure } from '../measure/measure';
import { initialTapeLine, rectAlongLine, slabBounds, minAreaRect } from '../geometry/rect'; 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 { 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 { 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'; 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) => export const defaultMaskRun: MaskRun = async (source, params, decoded) =>
buildMask(params, decoded, walkMask(decoded, params)); 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 { function walkMask(decoded: DecodedPhoto, params: MaskParams): Uint8Array {
const keep = keepForSheet(params.sheet, params.sensitivity, samplePixels(decoded));
const { width, height, rgba } = decoded; const { width, height, rgba } = decoded;
const bitset = new Uint8Array(width * height); const tone = sheetToneFor(params.sheet, decoded);
for (let i = 0; i < width * height; i++) { const tolerance = 20 + (params.sensitivity / 100) * 80; // 20..100 distance
bitset[i] = keep(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]) ? 1 : 0; return removeBackground(width, height, rgba, { tolerance, sheetTone: tone });
}
return bitset;
} }
/** Stride-16 edge-pixel sample for `autoPickSheet` (matches the P1 mask worker). */ /** Sheet tone to anchor the flood fill: sampled border, biased by the choice. */
function samplePixels(decoded: DecodedPhoto): { r: number; g: number; b: number }[] { function sheetToneFor(sheet: MaskParams['sheet'], decoded: DecodedPhoto): { r: number; g: number; b: number } {
const { width, height, rgba } = decoded; const sampled = sampleSheetTone(decoded.width, decoded.height, decoded.rgba);
const samples: { r: number; g: number; b: number }[] = []; if (sheet === 'green') return { r: 40, g: 170, b: 60 };
for (let y = 0; y < height; y += 16) { if (sheet === 'black') return { r: 30, g: 30, b: 30 };
for (let x = 0; x < width; x += 16) { return sampled;
const i = (y * width + x) * 4;
samples.push({ r: rgba[i], g: rgba[i + 1], b: rgba[i + 2] });
}
}
return samples;
} }
/** Default inventory encode: P1 frame composer + canvas → image/png blob. */ /** Default inventory encode: P1 frame composer + canvas → image/png blob. */
+70
View File
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { removeBackground, sampleSheetTone } from './bgRemoval';
function grid(w: number, h: number, fill: (x: number, y: number) => [number, number, number]): {
rgba: Uint8ClampedArray;
width: number;
height: number;
} {
const rgba = new Uint8ClampedArray(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const [r, g, b] = fill(x, y);
const i = (y * w + x) * 4;
rgba[i] = r;
rgba[i + 1] = g;
rgba[i + 2] = b;
rgba[i + 3] = 255;
}
}
return { rgba, width: w, height: h };
}
test('removeBackground: slab on a border sheet is kept; sheet removed', () => {
// 10x10; border = dark sheet (30,30,30); inner 4..5 = bright slab (180,140,90)
const { rgba, width, height } = grid(10, 10, (x, y) =>
x >= 3 && x <= 6 && y >= 3 && y <= 6 ? [180, 140, 90] : [30, 30, 30]);
const bitset = removeBackground(width, height, rgba, { tolerance: 40 });
const slabPx = bitset.reduce((s, b) => s + b, 0);
// Inner 4x4 slab kept
assert.equal(slabPx, 16);
// Border pixel is background
assert.equal(bitset[0], 0);
// Center slab pixel kept
assert.equal(bitset[(5 * 10 + 5)], 1);
});
test('removeBackground: slab overlapping sheet luminance is kept via connectivity', () => {
// The failure mode of a global threshold: a dark slab pixel the SAME color as
// the sheet survives ONLY because it is enclosed by bright slab, never
// connected to the border. (A dark pixel touching the border is sheet.)
const { rgba, width, height } = grid(9, 9, (x, y) => {
// slab occupies x,y in 1..7 (a 7x7 region), sheet is the outer ring
if (x >= 1 && x <= 7 && y >= 1 && y <= 7) {
// an inner dark column at x=4, only in interior rows y=2..6 so it is never
// adjacent to the border sheet
if (x === 4 && y >= 2 && y <= 6) return [30, 30, 30];
return [150, 120, 80];
}
return [30, 30, 30]; // sheet (border ring)
});
const bitset = removeBackground(width, height, rgba, { tolerance: 25 });
// The enclosed dark slab column (x=4, y=2..6) must be kept — surrounded by
// bright slab, never connected to the border.
for (let y = 2; y <= 6; y++) {
assert.equal(bitset[y * 9 + 4], 1, `enclosed dark slab px at (4,${y}) kept`);
}
// Border sheet removed
assert.equal(bitset[0], 0);
assert.equal(bitset[8], 0);
assert.equal(bitset[80], 0);
});
test('sampleSheetTone: returns the dominant border color', () => {
const { rgba, width, height } = grid(8, 8, () => [40, 170, 60]);
const tone = sampleSheetTone(width, height, rgba);
assert.equal(tone.r, 40);
assert.equal(tone.g, 170);
assert.equal(tone.b, 60);
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Client-side background (sheet) removal for the M1 capture mask (CAPTURE-30..60).
*
* The sheet and the slab frequently OVERLAP in luminance (a "black" sheet often
* reads at lum ~50 while the slab's bark/shadow edges dip into the same band),
* so a global brightness threshold cannot separate them. Instead this uses
* connectivity: the sheet is the region CONNECTED TO THE IMAGE BORDER whose
* pixels are close in color to the sampled sheet tone. Everything not reached
* by that border flood-fill is the slab.
*
* This is the standard, robust approach used by the prior prototypes and is
* stable across lighting because it anchors on the actual sheet color present
* in the frame, not an assumed black/green.
*/
export interface BgRemovalOptions {
/**
* Tolerance for "sheet-like": a pixel joins the background flood-fill if its
* per-channel distance to the sampled sheet tone is within this value.
* Maps the UI "sensitivity" (0–100) onto a 0–~120 distance. Higher = more
* aggressive removal (wider tolerance).
*/
tolerance: number;
/** Optional fixed sheet tone; sampled from the border when omitted. */
sheetTone?: { r: number; g: number; b: number };
}
/** Sample the sheet tone from the outer border ring (median of border pixels). */
export function sampleSheetTone(
width: number,
height: number,
rgba: Uint8Array | Uint8ClampedArray,
): { r: number; g: number; b: number } {
const rs: number[] = [];
const gs: number[] = [];
const bs: number[] = [];
const step = Math.max(1, Math.floor(Math.max(width, height) / 200));
const push = (x: number, y: number) => {
const i = (y * width + x) * 4;
rs.push(rgba[i]);
gs.push(rgba[i + 1]);
bs.push(rgba[i + 2]);
};
for (let x = 0; x < width; x += step) {
push(x, 0);
push(x, height - 1);
}
for (let y = 0; y < height; y += step) {
push(0, y);
push(width - 1, y);
}
const median = (arr: number[]) => {
const s = [...arr].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)] ?? 0;
};
return { r: median(rs), g: median(gs), b: median(bs) };
}
/**
* Remove the sheet-connected background. Returns a slab bitset (1 = keep/slab).
* Flood-fills from every border pixel; a neighbor joins the fill only while it
* stays within `tolerance` of the sheet tone. The slab is the complement.
*/
export function removeBackground(
width: number,
height: number,
rgba: Uint8Array | Uint8ClampedArray,
opts: BgRemovalOptions,
): Uint8Array {
const tone = opts.sheetTone ?? sampleSheetTone(width, height, rgba);
const tol2 = opts.tolerance * opts.tolerance;
const isSheetLike = (i: number) => {
const dr = rgba[i] - tone.r;
const dg = rgba[i + 1] - tone.g;
const db = rgba[i + 2] - tone.b;
return dr * dr + dg * dg + db * db <= tol2;
};
const n = width * height;
const visited = new Uint8Array(n); // 1 = reached by flood fill (= background)
const stack: number[] = [];
// Seed from all border pixels that look sheet-like.
const seed = (x: number, y: number) => {
const idx = y * width + x;
if (visited[idx]) return;
if (isSheetLike(idx * 4)) {
visited[idx] = 1;
stack.push(idx);
}
};
for (let x = 0; x < width; x++) {
seed(x, 0);
seed(x, height - 1);
}
for (let y = 0; y < height; y++) {
seed(0, y);
seed(width - 1, y);
}
// Iterative flood fill (4-neighbor).
while (stack.length > 0) {
const idx = stack.pop()!;
const x = idx % width;
const y = (idx / width) | 0;
// 4-neighbors
if (x > 0 && !visited[idx - 1] && isSheetLike((idx - 1) * 4)) {
visited[idx - 1] = 1;
stack.push(idx - 1);
}
if (x < width - 1 && !visited[idx + 1] && isSheetLike((idx + 1) * 4)) {
visited[idx + 1] = 1;
stack.push(idx + 1);
}
if (y > 0 && !visited[idx - width] && isSheetLike((idx - width) * 4)) {
visited[idx - width] = 1;
stack.push(idx - width);
}
if (y < height - 1 && !visited[idx + width] && isSheetLike((idx + width) * 4)) {
visited[idx + width] = 1;
stack.push(idx + width);
}
}
const bitset = new Uint8Array(n);
for (let i = 0; i < n; i++) bitset[i] = visited[i] ? 0 : 1;
return bitset;
}