a83c9239ff
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.
157 lines
5.4 KiB
JavaScript
157 lines
5.4 KiB
JavaScript
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);
|
||
});
|
||
});
|