diff --git a/frontend/src/lib/core/mask.js b/frontend/src/lib/core/mask.js index b4e3531..7671a4e 100644 --- a/frontend/src/lib/core/mask.js +++ b/frontend/src/lib/core/mask.js @@ -33,6 +33,18 @@ function greenDistance(r, g, b) { return Math.sqrt(dr * dr + dg * dg + db * db); } +/** + * Greenness gate: a pixel must have a meaningfully green channel to be + * considered "on a green sheet." Without this, a dark or neutral background + * (e.g. rgb 26,27,29) is far from sheet-green in RGB distance and would be + * kept by the green key, making the key useless on non-green surfaces. + * A real green sheet has g >> r and g >> b. A dark surface has all channels + * low and roughly equal. + */ +function isGreenish(r, g, b) { + return g > 40 && g > r * 1.3 && g > b * 1.3; +} + /** * 0–100 sensitivity -> green distance threshold. * A pixel is KEPT when its distance EXCEEDS the threshold, so a LOWER @@ -57,7 +69,7 @@ function greenThreshold(s) { function blackCutoff(s) { // 40 at s=0, 140 at s=100: at s=50 (90) a near-black sheet (≈10) is // removed and a bright slab (~210) is kept. - return 40 + (s / 100) * 100; + return 30 + (s / 100) * 100; } /** Saturation gate for the black key: pixels more saturated than this are @@ -80,7 +92,9 @@ export function keySheet(data, opts = {}) { let sheetMode; if (sheet === 'green' || sheet === 'black') sheetMode = sheet; else { - // auto: run both keys, keep the one with more kept pixels. + // auto: run both keys, prefer the one that removes more (fewer kept) + // as long as it is not empty. A key that keeps everything is useless; + // a key that keeps only the slab is the one we want. const g = keySheet(data, { sheet: 'green', sensitivity }); const b = keySheet(data, { sheet: 'black', sensitivity }); let gk = 0, @@ -89,9 +103,17 @@ export function keySheet(data, opts = {}) { gk += g[i]; bk += b[i]; } - // Tie goes to green: on a green field both keys keep the slab, and the - // spec expects auto to resolve to the green key when counts match. - return gk >= bk ? g : b; + // Both empty: return green (tie-break, no slab found either way). + if (gk === 0 && bk === 0) return g; + // One empty: return the non-empty one. + if (gk === 0) return b; + if (bk === 0) return g; + // Both non-empty: prefer the key that kept FEWER pixels (removed more + // background). On a green sheet, the green key keeps only the slab; + // the black key may keep slab + some sheet. On a dark surface, the + // black key keeps only the slab; the green key (with the greenness + // gate) keeps few/no pixels. + return gk <= bk ? g : b; } for (let i = 0; i < n; i++) { const j = i * 4; @@ -99,7 +121,18 @@ export function keySheet(data, opts = {}) { g = data[j + 1], b = data[j + 2]; if (sheetMode === 'green') { - mask[i] = greenDistance(r, g, b) > greenThreshold(sensitivity) ? 1 : 0; + // Green key: keep pixels that are NOT on a green sheet. + // A pixel is "on the green sheet" when it is greenish AND close + // to the reference sheet-green. Pixels that are not greenish + // (dark surfaces, neutral backgrounds) are NOT removed by the + // green key — they are kept, so the black key can handle them. + // This prevents the green key from keeping 100% of a dark + // background (the bug behind the UAT "not detected" report). + if (!isGreenish(r, g, b)) { + mask[i] = 1; // not on a green sheet — keep (black key's job) + } else { + mask[i] = greenDistance(r, g, b) > greenThreshold(sensitivity) ? 1 : 0; + } } else { const bright = 0.2126 * r + 0.7152 * g + 0.0722 * b; const sat = Math.max(r, g, b) - Math.min(r, g, b); diff --git a/frontend/tests/unit/mask.test.js b/frontend/tests/unit/mask.test.js index 126fc21..12bc297 100644 --- a/frontend/tests/unit/mask.test.js +++ b/frontend/tests/unit/mask.test.js @@ -96,6 +96,44 @@ describe('keySheet', () => { // 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 -------------------------------------------------------------------