diff --git a/frontend/src/mask/largestComponent.test.ts b/frontend/src/mask/largestComponent.test.ts new file mode 100644 index 0000000..0b4e7c3 --- /dev/null +++ b/frontend/src/mask/largestComponent.test.ts @@ -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); +}); diff --git a/frontend/src/mask/largestComponent.ts b/frontend/src/mask/largestComponent.ts new file mode 100644 index 0000000..b6ae131 --- /dev/null +++ b/frontend/src/mask/largestComponent.ts @@ -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; +} diff --git a/frontend/src/worker/mask.worker.ts b/frontend/src/worker/mask.worker.ts index b0bebe8..4bfe066 100644 --- a/frontend/src/worker/mask.worker.ts +++ b/frontend/src/worker/mask.worker.ts @@ -2,6 +2,7 @@ import { minAreaRect, slabBounds } from '../geometry/rect'; import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types'; import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep'; +import { keepLargestComponent } from '../mask/largestComponent'; let bitmap: ImageBitmap | null = null; let data: Uint8ClampedArray | null = null; @@ -33,6 +34,11 @@ function runMask(params: MaskParams, id: number): WorkerOut { 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 } }; } + // 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); if (!bbox) { return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };