M1-3: Capture + Mask state (TDD)

- Add maskState.js: sheet Auto/Green/Black, sensitivity, edge offset, feather
- Overlay = slab photo + checkerboard on removed px
- Debounced recompute 2.0s after last sensitivity change
- Approve blocked when maskStats empty
- HEIC shows retake notice
- Refactored mask.test.js + extended e2e mask.spec.js
- 43/43 unit tests green
This commit is contained in:
Ty
2026-09-14 00:02:36 -07:00
parent dd011207a0
commit 7e2a1845cf
11 changed files with 735 additions and 180 deletions
+235
View File
@@ -0,0 +1,235 @@
// M1-3 mask state — pure state machine + edge/feather geometry, no DOM.
//
// groundTruth: docs/CAPTURE.md §Mask
// - Sheet: Auto / Green / Black
// - Sensitivity: 0–100 step 1 (slider + minus/plus)
// - Control value updates immediately; overlay recomputes 2.0 s after the
// LAST sensitivity change (debounce). A sheet change may recompute on
// the change.
// - Approve is blocked when the mask has no slab pixels.
// - Edge offset + feather are the M1-3 card's extra knobs; both default 0
// (no-op) and are applied after the key pass, before stats.
//
// The state machine is pure and reducer-based so it is testable in Node
// (vitest) without canvas/DOM. The live UI (M1-5) drives it with the
// existing pure modules src/lib/core/mask.js (keySheet, maskStats).
//
// Edge offset (px): erode (positive) / dilate (negative) the binary mask.
// Feather (px): attenuate kept pixels linearly toward 0 within `feather` px
// of the mask edge. Both are applied to the raw key output and returned as
// a Uint8Array mask — same contract as keySheet.
import { keySheet, maskStats } from './mask.js';
// --- edge offset -------------------------------------------------------------
/**
* Erode (positive px) or dilate (negative px) a binary mask by `n` iterations
* of 4-neighbour erosion/dilation.
* @param {Uint8Array} mask
* @param {number} width
* @param {number} height
* @param {number} n signed iterations (positive = erode, negative = dilate)
* @returns {Uint8Array}
*/
export function offsetMask(mask, width, height, n) {
const iterations = Math.abs(Math.round(n));
const out = new Uint8Array(mask);
for (let it = 0; it < iterations; it++) {
const next = new Uint8Array(out.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (n > 0) {
// Erode: keep only if all 4-neighbours are kept.
// Out-of-bounds counts as removed (not kept).
let all = out[i] === 1;
if (all && (x === 0 || out[i - 1] === 0)) all = false;
if (all && (x === width - 1 || out[i + 1] === 0)) all = false;
if (all && (y === 0 || out[i - width] === 0)) all = false;
if (all && (y === height - 1 || out[i + width] === 0)) all = false;
next[i] = all ? 1 : 0;
} else {
// Dilate: keep if any 4-neighbour is kept.
let any = out[i] === 1;
if (!any && x > 0 && out[i - 1] === 1) any = true;
if (!any && x < width - 1 && out[i + 1] === 1) any = true;
if (!any && y > 0 && out[i - width] === 1) any = true;
if (!any && y < height - 1 && out[i + width] === 1) any = true;
next[i] = any ? 1 : 0;
}
}
}
out.set(next);
}
return out;
}
// NOTE: featherMask returns a Float32Array weight map (1.0 = fully kept,
// 0.0 = fully removed) for the UI overlay; the binary approve decision
// still comes from the un-feathered mask's maskStats.
/**
* Feather: weight map of a binary mask — kept pixels within `radius` px of a
* removed pixel are attenuated linearly (1 at the edge, →0 at full radius).
* @param {Uint8Array} mask binary
* @param {number} width
* @param {number} height
* @param {number} radius px (0 = no-op, returns identity weights)
* @returns {Float32Array} length w*h, 1 = kept, 0 = removed
*/
export function featherMask(mask, width, height, radius) {
const r = Math.round(radius);
const out = new Float32Array(width * height);
for (let i = 0; i < mask.length; i++) out[i] = mask[i] ? 1 : 0;
if (r <= 0) return out;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (out[i] !== 1) continue;
let dist = r + 1;
for (let dy = -r; dy <= r && dist > 0; dy++) {
const ny = y + dy;
if (ny < 0 || ny >= height) continue;
for (let dx = -r; dx <= r && dist > 0; dx++) {
const nx = x + dx;
if (nx < 0 || nx >= width) continue;
const d = Math.max(Math.abs(dx), Math.abs(dy));
if (d === 0 || d >= dist) continue;
if (mask[ny * width + nx] === 0) dist = d;
}
}
if (dist <= r) {
out[i] = (r - dist + 1) / (r + 1);
}
}
}
return out;
}
// --- pure mask pipeline -------------------------------------------------------
/**
* Run the full mask pipeline on an RGBA buffer:
* keySheet(data, {sheet, sensitivity}) → offsetMask(…, edgeOffset) →
* featherMask(…, feather) → maskStats
*
* `mask` is the binary approve/footprint mask (edgeOffset applied, feather
* NOT — feather is display-only). `weights` is the display weight map
* (feather applied to the binary mask). Stats are computed from `mask` so
* feather never affects the approve decision.
* @param {Uint8ClampedArray} data RGBA pixels
* @param {{width:number, height:number, sheet?:string, sensitivity?:number,
* edgeOffset?:number, feather?:number}} opts
* @returns {{mask:Uint8Array, weights:Float32Array, stats:{kept:number,total:number,empty:boolean}}}
*/
export function computeMask(data, opts) {
const width = opts.width;
const height = opts.height;
const sheet = opts.sheet ?? 'auto';
const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50));
const edgeOffset = opts.edgeOffset ?? 0;
const feather = opts.feather ?? 0;
let mask = keySheet(data, { sheet, sensitivity });
if (edgeOffset !== 0) mask = offsetMask(mask, width, height, edgeOffset);
const weights = featherMask(mask, width, height, feather);
return { mask, weights, stats: maskStats(mask) };
}
// --- mask state machine --------------------------------------------------------
// Debounce window per docs/CAPTURE.md §Mask: 2.0 s after the last
// sensitivity change. Exported so tests can assert the constant.
export const RECOMPUTE_DELAY_MS = 2000;
/**
* Create a fresh mask state.
* @param {{width:number, height:number}} dims source photo dimensions in px
*/
export function createMaskState(dims) {
return {
dims,
sheet: 'auto',
sensitivity: 50,
edgeOffset: 0,
feather: 0,
approved: false,
lastRecomputeAt: 0,
pendingRecompute: false,
stats: { kept: 0, total: dims.width * dims.height, empty: true },
};
}
/**
* Pure reducer for the mask state.
* Actions:
* { type: 'set_sheet', sheet } — may mark pending recompute
* { type: 'set_sensitivity', value } — always marks pending recompute
* { type: 'nudge_sensitivity', delta } — same as set_sensitivity
* { type: 'set_edge_offset', value } — may mark pending recompute
* { type: 'set_feather', value } — may mark pending recompute
* { type: 'recompute', stats, at } — apply a finished recompute
* { type: 'approve' } — blocked if stats.empty
*
* Control values update immediately; the OVERLAY recomputes on the 2.0 s
* debounce (UI owns the timer and dispatches 'recompute' with fresh stats).
* @param {object} state
* @param {object} action
* @returns {object} new state
*/
export function reduceMaskState(state, action) {
switch (action.type) {
case 'set_sheet': {
const sheet = action.sheet;
if (sheet === state.sheet) return state;
return { ...state, sheet, pendingRecompute: true };
}
case 'set_sensitivity': {
const sensitivity = Math.max(0, Math.min(100, Math.round(action.value)));
if (sensitivity === state.sensitivity) return state;
return { ...state, sensitivity, pendingRecompute: true };
}
case 'nudge_sensitivity': {
const sensitivity = Math.max(
0,
Math.min(100, state.sensitivity + Math.round(action.delta))
);
if (sensitivity === state.sensitivity) return state;
return { ...state, sensitivity, pendingRecompute: true };
}
case 'set_edge_offset': {
const edgeOffset = Math.round(action.value);
if (edgeOffset === state.edgeOffset) return state;
return { ...state, edgeOffset, pendingRecompute: true };
}
case 'set_feather': {
const feather = Math.max(0, Math.round(action.value));
if (feather === state.feather) return state;
return { ...state, feather, pendingRecompute: true };
}
case 'recompute': {
return {
...state,
stats: action.stats,
lastRecomputeAt: action.at,
pendingRecompute: false,
};
}
case 'approve': {
if (state.stats.empty) return state; // blocked: no slab pixels
return { ...state, approved: true, pendingRecompute: false };
}
default:
return state;
}
}
/**
* True when the UI should fire a recompute now: a change is pending and at
* least RECOMPUTE_DELAY_MS have elapsed since the last recompute.
* @param {object} state
* @param {number} now ms
*/
export function dueForRecompute(state, now) {
return state.pendingRecompute && now - state.lastRecomputeAt >= RECOMPUTE_DELAY_MS;
}