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.
This commit is contained in:
2026-09-25 09:26:52 -07:00
parent 0bc86ecc6d
commit 564f03dcee
2 changed files with 102 additions and 4 deletions
+43 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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]): { function grid(w: number, h: number, fill: (x: number, y: number) => [number, number, number]): {
rgba: Uint8ClampedArray; rgba: Uint8ClampedArray;
@@ -61,6 +61,48 @@ test('removeBackground: slab overlapping sheet luminance is kept via connectivit
assert.equal(bitset[80], 0); 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', () => { test('sampleSheetTone: returns the dominant border color', () => {
const { rgba, width, height } = grid(8, 8, () => [40, 170, 60]); const { rgba, width, height } = grid(8, 8, () => [40, 170, 60]);
const tone = sampleSheetTone(width, height, rgba); const tone = sampleSheetTone(width, height, rgba);
+59 -3
View File
@@ -122,7 +122,63 @@ export function removeBackground(
} }
} }
const bitset = new Uint8Array(n); const slab = new Uint8Array(n);
for (let i = 0; i < n; i++) bitset[i] = visited[i] ? 0 : 1; for (let i = 0; i < n; i++) slab[i] = visited[i] ? 0 : 1;
return bitset; 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;
} }