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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user