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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user