// M1-3 mask key geometry — pure functions, no canvas/DOM. // // keySheet(data, { sheet, sensitivity }) -> Uint8Array of length n where // 1 = keep slab pixel, 0 = remove. maskStats(mask) -> { kept, total, empty }. // // Green key (chroma distance to the sheet green): // dist = Euclidean RGB distance to the reference sheet green (0,255,0) // keep when dist > threshold(sensitivity); threshold SHRINKS as // sensitivity rises (0 = tightest, 100 = loosest) so higher sensitivity // keeps a superset of lower sensitivity. // // Black key (brightness above a cutoff): // keep when brightness > cutoff. // // Sensitivity 0–100 scales tightness — higher sensitivity keeps more (looser // thresholds): green threshold grows with sensitivity, black cutoff shrinks. // Auto picks whichever sheet yields more kept pixels. /** * Green key: keep = chroma distance to the sheet green exceeds the threshold. * Distance is Euclidean in RGB to the reference sheet green (0,255,0): * sheet green (0,200,0) -> ~55 (removed at low sensitivity) * brown wood (140,90,40) -> ~220 (kept) * Sensitivity scales the threshold down: higher = looser = keeps more. */ // Reference sheet green: pure keying green. Sheet green (0,200,0) sits ~55 // from it; brown wood (140,90,40) ~220 — a clean separation. const SHEET_GREEN = { r: 0, g: 255, b: 0 }; function greenDistance(r, g, b) { const dr = r - SHEET_GREEN.r; const dg = g - SHEET_GREEN.g; const db = b - SHEET_GREEN.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 * threshold keeps MORE pixels. Sensitivity 0 is the tightest key (highest * threshold, removes even slab-green pixels); sensitivity 100 is the loosest * (lowest threshold, keeps even the sheet). Monotone: higher sensitivity * keeps a superset of lower sensitivity. * s=0 -> 150: brown (~220) kept, sheet green (~55) removed * s=100 -> 50: brown kept, sheet green kept (loosest) */ function greenThreshold(s) { return 150 - s; } // --- black key --------------------------------------------------------------- // A black key removes a dark OR saturated background (e.g. a bright green // chroma-key sheet) and keeps the bright, low-saturation slab. Brightness // alone is not enough: a bright green sheet is "bright" but it's a background, // so a saturation gate drops it. /** Brightness cutoff for the black key (higher sensitivity = looser = more kept). */ 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 30 + (s / 100) * 100; } /** Saturation gate for the black key: pixels more saturated than this are * treated as background (e.g. a green keying sheet, saturation ≈ 200) and * dropped, even if bright. The slab is low-saturation. */ const BLACK_SAT_CAP = 120; /** * Key the sheet out of a pixel buffer. * @param {Uint8ClampedArray|{r,g,b,a}} data RGBA pixels, length n*4. * @param {{sheet?:'auto'|'green'|'black', sensitivity?:number}} opts * sensitivity is 0–100 (0 = tightest key, 100 = loosest). * @returns {Uint8Array} length n, 1=keep slab pixel, 0=remove. */ export function keySheet(data, opts = {}) { const sheet = opts.sheet ?? 'auto'; const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50)); const n = data.length / 4; const mask = new Uint8Array(n); let sheetMode; if (sheet === 'green' || sheet === 'black') sheetMode = sheet; else { // 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, bk = 0; for (let i = 0; i < n; i++) { gk += g[i]; bk += b[i]; } // 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; const r = data[j], g = data[j + 1], b = data[j + 2]; if (sheetMode === 'green') { // 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); mask[i] = bright > blackCutoff(sensitivity) && sat < BLACK_SAT_CAP ? 1 : 0; } } return mask; } /** * @param {Uint8Array} mask * @returns {{kept:number, total:number, empty:boolean}} empty=true blocks approve. */ export function maskStats(mask) { let kept = 0; for (let i = 0; i < mask.length; i++) kept += mask[i]; return { kept, total: mask.length, empty: kept === 0 }; }