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.
155 lines
6.2 KiB
JavaScript
155 lines
6.2 KiB
JavaScript
// 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 };
|
||
}
|