fix(mask): keep only largest connected slab component
Sheet gaps after black/green removal left stray chunks in the bitset. After keep predicates, zero all but the largest 4-connected component and recompute slabPx before bbox / slabBounds.
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { keepLargestComponent } from './largestComponent.ts';
|
||||||
|
|
||||||
|
test('keepLargestComponent: two blobs → only larger remains', () => {
|
||||||
|
// 10×6 grid: large 4×3 block (12 px) at left, small 2×2 (4 px) at right
|
||||||
|
const W = 10, H = 6;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 1; y <= 3; y++)
|
||||||
|
for (let x = 1; x <= 4; x++) bitset[y * W + x] = 1;
|
||||||
|
for (let y = 2; y <= 3; y++)
|
||||||
|
for (let x = 7; x <= 8; x++) bitset[y * W + x] = 1;
|
||||||
|
|
||||||
|
const before = bitset.reduce((a, c) => a + c, 0);
|
||||||
|
assert.equal(before, 16);
|
||||||
|
|
||||||
|
const slabPx = keepLargestComponent(bitset, W, H);
|
||||||
|
assert.equal(slabPx, 12, 'slabPx is pixel count of largest blob');
|
||||||
|
|
||||||
|
// Large blob intact
|
||||||
|
for (let y = 1; y <= 3; y++)
|
||||||
|
for (let x = 1; x <= 4; x++)
|
||||||
|
assert.equal(bitset[y * W + x], 1, `large (${x},${y}) kept`);
|
||||||
|
|
||||||
|
// Small blob zeroed
|
||||||
|
for (let y = 2; y <= 3; y++)
|
||||||
|
for (let x = 7; x <= 8; x++)
|
||||||
|
assert.equal(bitset[y * W + x], 0, `small (${x},${y}) removed`);
|
||||||
|
|
||||||
|
assert.equal(bitset.reduce((a, c) => a + c, 0), 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keepLargestComponent: empty bitset → 0', () => {
|
||||||
|
const bitset = new Uint8Array(4 * 4);
|
||||||
|
assert.equal(keepLargestComponent(bitset, 4, 4), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keepLargestComponent: single blob unchanged', () => {
|
||||||
|
const W = 5, H = 5;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 1; y <= 3; y++)
|
||||||
|
for (let x = 1; x <= 3; x++) bitset[y * W + x] = 1;
|
||||||
|
const slabPx = keepLargestComponent(bitset, W, H);
|
||||||
|
assert.equal(slabPx, 9);
|
||||||
|
assert.equal(bitset.reduce((a, c) => a + c, 0), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keepLargestComponent: 4-connected — diagonal touch is two components', () => {
|
||||||
|
// Two pixels only touching diagonally; larger is the two-pixel L on the left
|
||||||
|
// of a separate single pixel that is diagonal to nothing of equal size...
|
||||||
|
// Layout: three pixels as an L (size 3) and one diagonal fleck (size 1)
|
||||||
|
// . # .
|
||||||
|
// # # .
|
||||||
|
// . . #
|
||||||
|
const W = 3, H = 3;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
bitset[0 * W + 1] = 1;
|
||||||
|
bitset[1 * W + 0] = 1;
|
||||||
|
bitset[1 * W + 1] = 1;
|
||||||
|
bitset[2 * W + 2] = 1; // diagonal-only to the L
|
||||||
|
|
||||||
|
const slabPx = keepLargestComponent(bitset, W, H);
|
||||||
|
assert.equal(slabPx, 3);
|
||||||
|
assert.equal(bitset[2 * W + 2], 0, 'diagonal fleck removed under 4-conn');
|
||||||
|
assert.equal(bitset[0 * W + 1], 1);
|
||||||
|
assert.equal(bitset[1 * W + 0], 1);
|
||||||
|
assert.equal(bitset[1 * W + 1], 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Keep only the single largest connected component in a slab bitset.
|
||||||
|
* Sheet gaps can leave stray chunks after sheet removal; those must not
|
||||||
|
* affect bbox / slabBounds / slabPx.
|
||||||
|
*
|
||||||
|
* Connectivity: 4-connected (N/E/S/W). Mutates bitset in place; returns
|
||||||
|
* remaining slab pixel count.
|
||||||
|
*/
|
||||||
|
export function keepLargestComponent(
|
||||||
|
bitset: Uint8Array,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): number {
|
||||||
|
const n = w * h;
|
||||||
|
if (n === 0) return 0;
|
||||||
|
|
||||||
|
// labels[i] = component id (1-based), 0 = background / unvisited
|
||||||
|
const labels = new Int32Array(n);
|
||||||
|
const sizes: number[] = [0]; // sizes[id]
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
const qx = new Int32Array(n);
|
||||||
|
const qy = new Int32Array(n);
|
||||||
|
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const row = y * w;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = row + x;
|
||||||
|
if (!bitset[i] || labels[i]) continue;
|
||||||
|
nextId++;
|
||||||
|
let size = 0;
|
||||||
|
let qh = 0, qt = 0;
|
||||||
|
qx[qt] = x;
|
||||||
|
qy[qt] = y;
|
||||||
|
qt++;
|
||||||
|
labels[i] = nextId;
|
||||||
|
while (qh < qt) {
|
||||||
|
const cx = qx[qh];
|
||||||
|
const cy = qy[qh];
|
||||||
|
qh++;
|
||||||
|
size++;
|
||||||
|
// 4-connected neighbors
|
||||||
|
const nbrs: [number, number][] = [
|
||||||
|
[cx - 1, cy],
|
||||||
|
[cx + 1, cy],
|
||||||
|
[cx, cy - 1],
|
||||||
|
[cx, cy + 1],
|
||||||
|
];
|
||||||
|
for (const [nx, ny] of nbrs) {
|
||||||
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||||
|
const ni = ny * w + nx;
|
||||||
|
if (!bitset[ni] || labels[ni]) continue;
|
||||||
|
labels[ni] = nextId;
|
||||||
|
qx[qt] = nx;
|
||||||
|
qy[qt] = ny;
|
||||||
|
qt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizes[nextId] = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextId === 0) return 0;
|
||||||
|
|
||||||
|
let bestId = 1;
|
||||||
|
let bestSize = sizes[1];
|
||||||
|
for (let id = 2; id <= nextId; id++) {
|
||||||
|
if (sizes[id] > bestSize) {
|
||||||
|
bestSize = sizes[id];
|
||||||
|
bestId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextId === 1) return bestSize;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (bitset[i] && labels[i] !== bestId) bitset[i] = 0;
|
||||||
|
}
|
||||||
|
return bestSize;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { minAreaRect, slabBounds } from '../geometry/rect';
|
|||||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
||||||
|
import { keepLargestComponent } from '../mask/largestComponent';
|
||||||
|
|
||||||
let bitmap: ImageBitmap | null = null;
|
let bitmap: ImageBitmap | null = null;
|
||||||
let data: Uint8ClampedArray | null = null;
|
let data: Uint8ClampedArray | null = null;
|
||||||
@@ -33,6 +34,11 @@ function runMask(params: MaskParams, id: number): WorkerOut {
|
|||||||
if (slabPx === 0) {
|
if (slabPx === 0) {
|
||||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||||
}
|
}
|
||||||
|
// Sheet gaps leave stray chunks; lock onto the single largest slab object.
|
||||||
|
slabPx = keepLargestComponent(bitset, w, h);
|
||||||
|
if (slabPx === 0) {
|
||||||
|
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||||
|
}
|
||||||
const bbox = minAreaRect(bitset, w, h);
|
const bbox = minAreaRect(bitset, w, h);
|
||||||
if (!bbox) {
|
if (!bbox) {
|
||||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||||
|
|||||||
Reference in New Issue
Block a user