Files
SlabUploader/frontend/tests/unit/mask.test.js
T
Ty a83c9239ff UAT-7: fix auto key selection — prefer key that removes more background
Root cause: auto selected the key with MORE kept pixels, which always
picked the green key (it keeps 100% of dark non-green backgrounds).
The black key correctly isolates the slab on dark surfaces.

Fix:
- auto now prefers the key with FEWER kept pixels (removed more bg)
  when both are non-empty
- green key has a greenness pre-filter: non-greenish pixels pass
  through (kept) so the black key can handle them, instead of the
  green key keeping 100% of a dark surface
- added 3 regression tests: dark-bg auto selection, green-sheet auto
  selection, green-key passthrough on non-green surfaces

106/106 unit tests green.
2026-09-14 07:41:51 -07:00

157 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
keySheet,
maskStats,
} from '../../src/lib/core/mask.js';
// M1-3 mask key geometry — unit tests (RED first, per strict TDD).
//
// Ground truth: docs/CAPTURE.md §Mask
// - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather
// - Control value updates IMMEDIATELY; overlay recomputes 2.0 s after the
// last sensitivity change (debounce).
// - Approve blocked when maskStats is empty.
//
// All functions here are pure (no DOM) so the state is unit-testable in Node;
// the live UI (M1-5) drives this machine.
//
// --- synthetic photo helpers (no DOM) -----------------------------------------\n//
function solid(w, h, r, g, b) {
const data = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w * h; i++) {
data[i * 4 + 0] = r;
data[i * 4 + 1] = g;
data[i * 4 + 2] = b;
data[i * 4 + 3] = 255;
}
return data;
}
function withRect(data, width, x0, y0, w, h, r, g, b) {
for (let y = y0; y < y0 + h; y++) {
for (let x = x0; x < x0 + w; x++) {
const i = (y * width + x) * 4;
data[i + 0] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = 255;
}
}
return data;
}
// 12 x 8 green sheet with an 8 x 4 brown slab at (2,2).
const GREEN_PHOTO = () => withRect(solid(12, 8, 0, 200, 0), 12, 2, 2, 8, 4, 140, 90, 40);
const DIMS = { width: 12, height: 8 };
// --- keySheet ---------------------------------------------------------------------
//
describe('keySheet', () => {
it('keeps the slab in a green sheet photo', () => {
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'green',
sensitivity: 50,
});
// 8x4 = 32 pixels
expect(maskStats(mask).kept).toBe(32);
});
it('keeps the slab in a black sheet photo', () => {
const blackPhoto = withRect(solid(12, 8, 10, 10, 10), 12, 2, 2, 8, 4, 220, 210, 190);
const mask = keySheet(blackPhoto, {
sheet: 'black',
sensitivity: 50,
});
expect(maskStats(mask).kept).toBe(32);
});
it('auto-selects the better key', () => {
// A photo that works well for both should return the green one
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'auto',
sensitivity: 50,
});
expect(maskStats(mask).kept).toBe(32);
});
it('handles sensitivity 0 (tightest)', () => {
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'green',
sensitivity: 0,
});
// At s=0, threshold is 150.
// brown wood (140,90,40) dist = sqrt(140^2 + (90-255)^2 + 40^2) = sqrt(19600 + 27225 + 1600) = ~218.
// 218 > 150, so kept.
expect(maskStats(mask).kept).toBe(32);
});
it('handles sensitivity 100 (loosest)', () => {
const mask = keySheet(GREEN_PHOTO(), {
sheet: 'green',
sensitivity: 100,
});
// At s=100, threshold is 50.
// Sheet green (0,200,0) dist = 55.
// 55 > 50, so sheet is kept too!
// 12x8 = 96 pixels
expect(maskStats(mask).kept).toBe(96);
});
it('green key passes non-green pixels through (black key handles them)', () => {
// Real slab photos (sample_01..06) have a dark blue-gray background
// (e.g. rgb 26,27,29) with a bright warm slab (e.g. rgb 250,200,140).
// The green key is designed to key out a GREEN sheet only. On a dark
// (non-green) surface it passes pixels through (keeps them) so that
// the black key can remove them. The green key alone does NOT remove
// a dark background — that is the black key's job. Auto selects the
// black key in this case (see the auto test below).
const darkBg = solid(12, 8, 26, 27, 29);
withRect(darkBg, 12, 2, 2, 8, 4, 250, 200, 140);
const mask = keySheet(darkBg, { sheet: 'green', sensitivity: 50 });
const stats = maskStats(mask);
// Green key on dark bg: all pixels are non-greenish, so all are kept.
// This is BY DESIGN — the green key is a no-op on non-green surfaces,
// and auto will select the black key instead.
expect(stats.kept).toBe(96);
});
it('auto selects black key over green key on a dark-background photo', () => {
// Real photo: dark bg + bright slab. Green key keeps ~everything (bad).
// Black key keeps only the slab (good). Auto must pick black.
const darkBg = solid(12, 8, 26, 27, 29);
withRect(darkBg, 12, 2, 2, 8, 4, 250, 200, 140);
const mask = keySheet(darkBg, { sheet: 'auto', sensitivity: 50 });
const stats = maskStats(mask);
// Should keep only the slab (32 px), not the whole frame (96 px).
expect(stats.kept).toBe(32);
});
it('auto selects green key over black key on a green-sheet photo', () => {
// Green sheet + brown slab. Green key keeps slab only. Black key may
// keep slab + some sheet. Auto must pick green (fewer kept = better).
const photo = GREEN_PHOTO();
const mask = keySheet(photo, { sheet: 'auto', sensitivity: 50 });
const stats = maskStats(mask);
expect(stats.kept).toBe(32);
});
});
// --- maskStats -------------------------------------------------------------------
//
describe('maskStats', () => {
it('correctly reports kept count and empty status', () => {
const mask = new Uint8Array([1, 0, 1, 0]);
const stats = maskStats(mask);
expect(stats.kept).toBe(2);
expect(stats.total).toBe(4);
expect(stats.empty).toBe(false);
});
it('reports empty=true when no pixels kept', () => {
const mask = new Uint8Array([0, 0, 0, 0]);
const stats = maskStats(mask);
expect(stats.kept).toBe(0);
expect(stats.empty).toBe(true);
});
});