From a1d4a96a0a4b3bd1c147bca12f7dce9957347057 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Mon, 7 Sep 2026 21:26:50 +0000 Subject: [PATCH] gate-b: fix mask overlay opacity, auto-sheet fallback, and knob debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/lib/pipeline/mask.ts | 35 ++++++++++++++- frontend/src/routes/+page.svelte | 74 ++++++++++++++++++++++--------- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/pipeline/mask.ts b/frontend/src/lib/pipeline/mask.ts index dea2f82..83d889c 100644 --- a/frontend/src/lib/pipeline/mask.ts +++ b/frontend/src/lib/pipeline/mask.ts @@ -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( diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6f7c043..80c8c13 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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 | 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(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 @@ { - 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) })} />
{ - 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) })} />
{ - 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) })} />