gate-b: fix mask overlay opacity, auto-sheet fallback, and knob debounce

- drawMaskOverlay: reduce green overlay max alpha 140→80 (~31% opacity),
  lighten green from (0,200,0) to (60,220,60) so photo stays visible
- resolveSheet: when auto-detect is ambiguous, try both green and black
  sheet modes, pick whichever produces non-trivial slab area (1%-99%)
- Knob sliders: debounce full remask to 150ms; oninput redraws with
  previous mask (cheap), onchange triggers immediate full remask
- Add onDestroy cleanup for debounce timeout

Fixes: issue #55 — iPad mask preview nearly solid green, knobs laggy
This commit is contained in:
grokbot_ty
2026-09-07 21:26:50 +00:00
parent 4cebdc4b70
commit a1d4a96a0a
2 changed files with 86 additions and 23 deletions
+33 -2
View File
@@ -88,11 +88,42 @@ export function detectSheet(image: RgbaImage): ResolvedSheet | "ambiguous" {
return "ambiguous";
}
/** Count pixels that are non-zero (i.e., marked as slab by border-connected mask). */
function countNonZero(mask: MaskBuffer): number {
let count = 0;
for (let i = 0; i < mask.length; i++) {
if (mask[i] !== 0) count++;
}
return count;
}
function resolveSheet(image: RgbaImage, knobs: KnobSettings): ResolvedSheet {
if (knobs.sheet === "green" || knobs.sheet === "black") return knobs.sheet;
const detected = detectSheet(image);
if (detected === "ambiguous") return "green";
return detected;
if (detected !== "ambiguous") return detected;
// Border is ambiguous — try both modes, pick whichever produces a
// non-trivial mask (1%–99% slab area). Falls back to green if both
// are degenerate.
const greenMask = buildSheetCandidates(image, "green", knobs.sensitivity);
const greenRemoved = borderConnectedSheet(greenMask, image.width, image.height);
const greenSlabCount = countNonZero(greenRemoved);
const greenSlabPct = greenSlabCount / (image.width * image.height);
const blackMask = buildSheetCandidates(image, "black", knobs.sensitivity);
const blackRemoved = borderConnectedSheet(blackMask, image.width, image.height);
const blackSlabCount = countNonZero(blackRemoved);
const blackSlabPct = blackSlabCount / (image.width * image.height);
// Prefer whichever has a non-trivial slab area (1%–99%). If both are
// degenerate, fall back to green.
const greenOk = greenSlabPct > 0.01 && greenSlabPct < 0.99;
const blackOk = blackSlabPct > 0.01 && blackSlabPct < 0.99;
if (greenOk && !blackOk) return "green";
if (blackOk && !greenOk) return "black";
// Both ok — pick the one with more slab (slab is the interesting part).
if (greenSlabCount > blackSlabCount) return "green";
if (blackSlabCount > greenSlabCount) return "black";
return "green";
}
function greenSheetCandidate(
+53 -21
View File
@@ -20,6 +20,38 @@
import { SKU_REGEX } from '$lib/validation/constants.js';
import { onMount, onDestroy } from 'svelte';
// ---- Debounce helpers for knob remask ----
let remaskTimeout: ReturnType<typeof setTimeout> | null = null;
function scheduleRemask() {
if (remaskTimeout) clearTimeout(remaskTimeout);
remaskTimeout = setTimeout(() => {
const photo = currentPhoto();
if (!photo || !photo.knobSettings) return;
applyMaskKnobs(photo.knobSettings);
if (maskCanvas) drawMaskOverlay(maskCanvas, photo);
remaskTimeout = null;
}, 150);
}
function handleKnobChange(knobs: KnobSettings) {
if (remaskTimeout) clearTimeout(remaskTimeout);
remaskTimeout = null;
applyMaskKnobs(knobs);
const photo = currentPhoto();
if (photo && maskCanvas) drawMaskOverlay(maskCanvas, photo);
}
function handleKnobInput(knobs: KnobSettings) {
const photo = currentPhoto();
if (!photo) return;
photo.knobSettings = { ...knobs };
// Immediately redraw with the current mask — cheap, no remask.
if (maskCanvas) drawMaskOverlay(maskCanvas, photo);
// Schedule full remask after 150ms of no further input.
scheduleRemask();
}
// ---- Reactive state (Svelte 5 runes) ----
let state = $state<SlabState>(createSlabState());
@@ -309,12 +341,14 @@
const imgData = ctx.createImageData(canvas.width, canvas.height);
const mask = photo.maskResult!.mask;
// Translucent light green overlay — photo remains clearly visible.
// Max alpha 80 (~31% opacity) on full-slab pixels; feathered edge blends smoothly.
for (let i = 0; i < mask.length; i++) {
const alpha = Math.round((mask[i]! / 255) * 140);
const alpha = Math.round((mask[i]! / 255) * 80);
const pixelIdx = i * 4;
imgData.data[pixelIdx] = 0;
imgData.data[pixelIdx + 1] = 200;
imgData.data[pixelIdx + 2] = 0;
imgData.data[pixelIdx] = 60;
imgData.data[pixelIdx + 1] = 220;
imgData.data[pixelIdx + 2] = 60;
imgData.data[pixelIdx + 3] = alpha;
}
ctx.putImageData(imgData, 0, 0);
@@ -415,6 +449,14 @@
ctx.putImageData(imgData, 0, 0);
}
// ---- Lifecycle ----
onDestroy(() => {
if (remaskTimeout) {
clearTimeout(remaskTimeout);
remaskTimeout = null;
}
});
// ---- Effects ----
$effect(() => {
if (state.step === 'mask' && maskCanvas) {
@@ -486,8 +528,7 @@
<label>Sheet Mode</label>
<select value={getMaskKnobs().sheet} onchange={(e) => {
const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, sheet: e.target.value as KnobSettings['sheet'] });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
handleKnobChange({ ...knobs, sheet: e.target.value as KnobSettings['sheet'] });
}}>
<option value="auto">Auto</option>
<option value="green">Green</option>
@@ -497,29 +538,20 @@
<div class="knob-group">
<label>Sensitivity: {getMaskKnobs().sensitivity.toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={getMaskKnobs().sensitivity}
oninput={(e) => {
const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, sensitivity: parseFloat(e.target.value) });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
}} />
oninput={(e) => handleKnobInput({ ...getMaskKnobs(), sensitivity: parseFloat(e.target.value) })}
onchange={(e) => handleKnobChange({ ...getMaskKnobs(), sensitivity: parseFloat(e.target.value) })} />
</div>
<div class="knob-group">
<label>Edge Offset: {getMaskKnobs().edgeOffset}</label>
<input type="range" min="-20" max="20" step="1" value={getMaskKnobs().edgeOffset}
oninput={(e) => {
const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, edgeOffset: parseInt(e.target.value) });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
}} />
oninput={(e) => handleKnobInput({ ...getMaskKnobs(), edgeOffset: parseInt(e.target.value) })}
onchange={(e) => handleKnobChange({ ...getMaskKnobs(), edgeOffset: parseInt(e.target.value) })} />
</div>
<div class="knob-group">
<label>Feather: {getMaskKnobs().feather}</label>
<input type="range" min="0" max="10" step="1" value={getMaskKnobs().feather}
oninput={(e) => {
const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, feather: parseInt(e.target.value) });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
}} />
oninput={(e) => handleKnobInput({ ...getMaskKnobs(), feather: parseInt(e.target.value) })}
onchange={(e) => handleKnobChange({ ...getMaskKnobs(), feather: parseInt(e.target.value) })} />
</div>
<div class="photo-selector">
<label>Photo:</label>