From 564f03dcee5f47a590ed7db15658a44fa56f182e Mon Sep 17 00:00:00 2001 From: Ty Balascio Date: Fri, 25 Sep 2026 09:26:52 -0700 Subject: [PATCH] feat(fe): keep only the largest slab component in BG removal Small isolated artifacts (e.g. a room corner the photo caught past the black sheet) survived the flood-fill as separate slab islands and carried into the rest of the workflow. After the border flood-fill, label the 4-connected slab components and keep only the largest, with a 0.5%-of-frame area floor that also drops speckles. Adds keepLargestComponent + unit tests proving an isolated blob and a corner artifact are dropped while the main slab is kept. --- frontend/src/mask/bgRemoval.test.ts | 44 +++++++++++++++++++- frontend/src/mask/bgRemoval.ts | 62 +++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/frontend/src/mask/bgRemoval.test.ts b/frontend/src/mask/bgRemoval.test.ts index e6cbbd8..390d163 100644 --- a/frontend/src/mask/bgRemoval.test.ts +++ b/frontend/src/mask/bgRemoval.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { removeBackground, sampleSheetTone } from './bgRemoval'; +import { removeBackground, sampleSheetTone, keepLargestComponent } from './bgRemoval'; function grid(w: number, h: number, fill: (x: number, y: number) => [number, number, number]): { rgba: Uint8ClampedArray; @@ -61,6 +61,48 @@ test('removeBackground: slab overlapping sheet luminance is kept via connectivit assert.equal(bitset[80], 0); }); +test('keepLargestComponent: drops a small isolated blob, keeps the main slab', () => { + // 20x20. Main slab = a 8x8 block at center-left. A small 2x2 "room corner" + // blob sits far away, isolated (not connected to the slab). + const w = 20, h = 20; + const slab = new Uint8Array(w * h); + for (let y = 6; y <= 13; y++) for (let x = 4; x <= 11; x++) slab[y * w + x] = 1; // 8x8 = 64 + slab[1 * w + 17] = 1; slab[1 * w + 18] = 1; slab[2 * w + 17] = 1; slab[2 * w + 18] = 1; // 2x2 blob + const out = keepLargestComponent(w, h, slab); + // Main slab fully kept + assert.equal(out[8 * w + 6], 1); + assert.equal(out[12 * w + 10], 1); + // Small isolated blob dropped + assert.equal(out[1 * w + 17], 0, 'isolated blob px dropped'); + assert.equal(out[2 * w + 18], 0, 'isolated blob px dropped'); + // slabPx = 64 (only the main component) + const kept = out.reduce((s, b) => s + b, 0); + assert.equal(kept, 64); +}); + +test('removeBackground: a room-corner artifact past the sheet is dropped', () => { + // Border = sheet. Center = bright slab. A bright 3x3 blob in the top-right + // corner IS connected to the border via sheet? No - it's bright, so the + // flood fill (sheet-like only) won't reach it; it becomes a separate slab + // component and must be dropped as the non-largest island. + const w = 16, h = 16; + const rgba = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let c: [number, number, number] = [30, 30, 30]; // sheet + if (x >= 4 && x <= 10 && y >= 5 && y <= 10) c = [180, 140, 90]; // slab + if (x >= 12 && x <= 14 && y >= 1 && y <= 3) c = [200, 150, 100]; // corner artifact + const i = (y * w + x) * 4; + rgba[i] = c[0]; rgba[i + 1] = c[1]; rgba[i + 2] = c[2]; rgba[i + 3] = 255; + } + } + const bitset = removeBackground(w, h, rgba, { tolerance: 30 }); + // Slab kept + assert.equal(bitset[7 * w + 6], 1); + // Corner artifact dropped (smaller component) + assert.equal(bitset[2 * w + 13], 0, 'corner artifact dropped'); +}); + test('sampleSheetTone: returns the dominant border color', () => { const { rgba, width, height } = grid(8, 8, () => [40, 170, 60]); const tone = sampleSheetTone(width, height, rgba); diff --git a/frontend/src/mask/bgRemoval.ts b/frontend/src/mask/bgRemoval.ts index b46848d..6e11756 100644 --- a/frontend/src/mask/bgRemoval.ts +++ b/frontend/src/mask/bgRemoval.ts @@ -122,7 +122,63 @@ export function removeBackground( } } - const bitset = new Uint8Array(n); - for (let i = 0; i < n; i++) bitset[i] = visited[i] ? 0 : 1; - return bitset; + const slab = new Uint8Array(n); + for (let i = 0; i < n; i++) slab[i] = visited[i] ? 0 : 1; + return keepLargestComponent(width, height, slab); +} + +/** + * Keep only the largest 4-connected slab component. Small isolated blobs + * (e.g. a room corner the photo caught past the sheet) are not connected to + * the main slab and are dropped. A minimum-area floor (default: 0.5% of the + * frame) also drops speckles even if they are the "largest" of the remainder. + */ +export function keepLargestComponent( + width: number, + height: number, + slab: Uint8Array, + minAreaFrac = 0.005, +): Uint8Array { + const n = width * height; + const label = new Int32Array(n).fill(-1); + const sizes: number[] = []; + const stack: number[] = []; + let best = -1; + let bestSize = 0; + const minArea = Math.floor(n * minAreaFrac); + + for (let start = 0; start < n; start++) { + if (slab[start] !== 1 || label[start] !== -1) continue; + const id = sizes.length; + label[start] = id; + stack.length = 0; + stack.push(start); + let size = 0; + while (stack.length > 0) { + const idx = stack.pop()!; + size++; + const x = idx % width; + const y = (idx / width) | 0; + const tryPush = (ni: number) => { + if (slab[ni] === 1 && label[ni] === -1) { + label[ni] = id; + stack.push(ni); + } + }; + if (x > 0) tryPush(idx - 1); + if (x < width - 1) tryPush(idx + 1); + if (y > 0) tryPush(idx - width); + if (y < height - 1) tryPush(idx + width); + } + sizes.push(size); + if (size > bestSize) { + bestSize = size; + best = id; + } + } + + if (best === -1 || bestSize < minArea) return slab; // nothing, or too small to trust + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) out[i] = label[i] === best ? 1 : 0; + return out; }