feat(m1-2,m1-3): deterministic core + mask keying (TDD, green)
Card 2 (deterministic core) + Card 3 (capture+mask geometry): - src/lib/core/math.js: scale, sqft, bdft, roundHalfUp, width sampling, 1/8-inch snap — implemented to docs/CAPTURE.md identities. - src/lib/core/mask.js: keySheet green/black/auto chroma keys + maskStats. - fixed sensitivity scale (0-100, higher = looser = keeps more) - fixed inverted green threshold (150 - s) - black key drops bright saturated sheets via saturation gate - tests/unit/math.test.js: corrected 3 wrong expected values to match the spec (sqft 21x12in = 1.75 not 17.5; bdft 3.06; sample avg 11.25). - tests/e2e/mask.spec.js: skeleton, skipped pending M1-5 live UI. Full unit suite green: 24/24. E2e collects clean.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 40 + (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, keep the one with more kept pixels.
|
||||
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];
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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') {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// M1 deterministic measurement math — pure functions, no DOM.
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Measure
|
||||
// scale = longest_axis_px / L
|
||||
// sqft = (mask_pixels / scale^2) / 144 (2 decimals, half-up)
|
||||
// bdft = sqft * thickness_in (2 decimals, half-up)
|
||||
// Widths are sampled every `everyInches` along the approved length axis.
|
||||
|
||||
/** px-per-inch scale: longest axis in pixels divided by its length in inches. */
|
||||
export function scale(longestAxisPx, lengthIn) {
|
||||
return longestAxisPx / lengthIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round half-up to `decimals` places (NOT banker's rounding, not toFixed's
|
||||
* binary-float quirks). 30.625 -> 30.63, 1.005 -> 1.01.
|
||||
*/
|
||||
export function roundHalfUp(x, decimals) {
|
||||
const f = Math.pow(10, decimals);
|
||||
return Math.round((x + Number.EPSILON) * f) / f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square feet from mask pixel area and the px-per-inch scale.
|
||||
* (mask_px / scale^2) gives square inches; /144 gives square feet.
|
||||
* @returns number rounded half-up to 2 decimals.
|
||||
*/
|
||||
export function sqft(maskPx, scalePxPerIn) {
|
||||
if (maskPx <= 0 || scalePxPerIn <= 0) return 0;
|
||||
const sqIn = maskPx / (scalePxPerIn * scalePxPerIn);
|
||||
return roundHalfUp(sqIn / 144, 2);
|
||||
}
|
||||
|
||||
/** Board feet: sqft * thickness_in. 1 sqft at 1in thick = 1 bdft. */
|
||||
export function bdft(sqftVal, thicknessIn) {
|
||||
return roundHalfUp(sqftVal * thicknessIn, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sampled perpendicular widths, in inches, every `everyInches` along the axis
|
||||
* from 0 up to (but not past) axisLengthPx.
|
||||
* @param widthPx constant width in px sampled at each point
|
||||
* @param pxPerIn scale
|
||||
* @param axisLengthPx total axis length in px
|
||||
* @param everyInches sampling interval in inches
|
||||
* @returns number[] widths in inches
|
||||
*/
|
||||
export function sampledWidths(widthPx, pxPerIn, axisLengthPx, everyInches) {
|
||||
const out = [];
|
||||
for (let inch = 0; inch * pxPerIn <= axisLengthPx; inch += everyInches) {
|
||||
out.push(widthPx / pxPerIn);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* min/max/avg (inches) over a list of sampled widths given in px.
|
||||
* @param {number[]} widthsPx
|
||||
* @param {number} pxPerIn
|
||||
* @returns {{min:number, max:number, avg:number}}
|
||||
*/
|
||||
export function sampledWidthsFrom(widthsPx, pxPerIn) {
|
||||
const inches = widthsPx.map((w) => w / pxPerIn);
|
||||
const min = Math.min(...inches);
|
||||
const max = Math.max(...inches);
|
||||
const avg = inches.reduce((a, b) => a + b, 0) / inches.length;
|
||||
return { min, max, avg };
|
||||
}
|
||||
|
||||
/** Snap an inch value to the nearest 1/8 inch. */
|
||||
export function snapToEighth(inches) {
|
||||
return Math.round(inches * 8) / 8;
|
||||
}
|
||||
Reference in New Issue
Block a user