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:
@@ -88,11 +88,42 @@ export function detectSheet(image: RgbaImage): ResolvedSheet | "ambiguous" {
|
|||||||
return "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 {
|
function resolveSheet(image: RgbaImage, knobs: KnobSettings): ResolvedSheet {
|
||||||
if (knobs.sheet === "green" || knobs.sheet === "black") return knobs.sheet;
|
if (knobs.sheet === "green" || knobs.sheet === "black") return knobs.sheet;
|
||||||
const detected = detectSheet(image);
|
const detected = detectSheet(image);
|
||||||
if (detected === "ambiguous") return "green";
|
if (detected !== "ambiguous") return detected;
|
||||||
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(
|
function greenSheetCandidate(
|
||||||
|
|||||||
@@ -20,6 +20,38 @@
|
|||||||
import { SKU_REGEX } from '$lib/validation/constants.js';
|
import { SKU_REGEX } from '$lib/validation/constants.js';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
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) ----
|
// ---- Reactive state (Svelte 5 runes) ----
|
||||||
let state = $state<SlabState>(createSlabState());
|
let state = $state<SlabState>(createSlabState());
|
||||||
|
|
||||||
@@ -309,12 +341,14 @@
|
|||||||
|
|
||||||
const imgData = ctx.createImageData(canvas.width, canvas.height);
|
const imgData = ctx.createImageData(canvas.width, canvas.height);
|
||||||
const mask = photo.maskResult!.mask;
|
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++) {
|
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;
|
const pixelIdx = i * 4;
|
||||||
imgData.data[pixelIdx] = 0;
|
imgData.data[pixelIdx] = 60;
|
||||||
imgData.data[pixelIdx + 1] = 200;
|
imgData.data[pixelIdx + 1] = 220;
|
||||||
imgData.data[pixelIdx + 2] = 0;
|
imgData.data[pixelIdx + 2] = 60;
|
||||||
imgData.data[pixelIdx + 3] = alpha;
|
imgData.data[pixelIdx + 3] = alpha;
|
||||||
}
|
}
|
||||||
ctx.putImageData(imgData, 0, 0);
|
ctx.putImageData(imgData, 0, 0);
|
||||||
@@ -415,6 +449,14 @@
|
|||||||
ctx.putImageData(imgData, 0, 0);
|
ctx.putImageData(imgData, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Lifecycle ----
|
||||||
|
onDestroy(() => {
|
||||||
|
if (remaskTimeout) {
|
||||||
|
clearTimeout(remaskTimeout);
|
||||||
|
remaskTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Effects ----
|
// ---- Effects ----
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (state.step === 'mask' && maskCanvas) {
|
if (state.step === 'mask' && maskCanvas) {
|
||||||
@@ -486,8 +528,7 @@
|
|||||||
<label>Sheet Mode</label>
|
<label>Sheet Mode</label>
|
||||||
<select value={getMaskKnobs().sheet} onchange={(e) => {
|
<select value={getMaskKnobs().sheet} onchange={(e) => {
|
||||||
const knobs = getMaskKnobs();
|
const knobs = getMaskKnobs();
|
||||||
applyMaskKnobs({ ...knobs, sheet: e.target.value as KnobSettings['sheet'] });
|
handleKnobChange({ ...knobs, sheet: e.target.value as KnobSettings['sheet'] });
|
||||||
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
|
|
||||||
}}>
|
}}>
|
||||||
<option value="auto">Auto</option>
|
<option value="auto">Auto</option>
|
||||||
<option value="green">Green</option>
|
<option value="green">Green</option>
|
||||||
@@ -497,29 +538,20 @@
|
|||||||
<div class="knob-group">
|
<div class="knob-group">
|
||||||
<label>Sensitivity: {getMaskKnobs().sensitivity.toFixed(2)}</label>
|
<label>Sensitivity: {getMaskKnobs().sensitivity.toFixed(2)}</label>
|
||||||
<input type="range" min="0" max="1" step="0.01" value={getMaskKnobs().sensitivity}
|
<input type="range" min="0" max="1" step="0.01" value={getMaskKnobs().sensitivity}
|
||||||
oninput={(e) => {
|
oninput={(e) => handleKnobInput({ ...getMaskKnobs(), sensitivity: parseFloat(e.target.value) })}
|
||||||
const knobs = getMaskKnobs();
|
onchange={(e) => handleKnobChange({ ...getMaskKnobs(), sensitivity: parseFloat(e.target.value) })} />
|
||||||
applyMaskKnobs({ ...knobs, sensitivity: parseFloat(e.target.value) });
|
|
||||||
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="knob-group">
|
<div class="knob-group">
|
||||||
<label>Edge Offset: {getMaskKnobs().edgeOffset}</label>
|
<label>Edge Offset: {getMaskKnobs().edgeOffset}</label>
|
||||||
<input type="range" min="-20" max="20" step="1" value={getMaskKnobs().edgeOffset}
|
<input type="range" min="-20" max="20" step="1" value={getMaskKnobs().edgeOffset}
|
||||||
oninput={(e) => {
|
oninput={(e) => handleKnobInput({ ...getMaskKnobs(), edgeOffset: parseInt(e.target.value) })}
|
||||||
const knobs = getMaskKnobs();
|
onchange={(e) => handleKnobChange({ ...getMaskKnobs(), edgeOffset: parseInt(e.target.value) })} />
|
||||||
applyMaskKnobs({ ...knobs, edgeOffset: parseInt(e.target.value) });
|
|
||||||
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="knob-group">
|
<div class="knob-group">
|
||||||
<label>Feather: {getMaskKnobs().feather}</label>
|
<label>Feather: {getMaskKnobs().feather}</label>
|
||||||
<input type="range" min="0" max="10" step="1" value={getMaskKnobs().feather}
|
<input type="range" min="0" max="10" step="1" value={getMaskKnobs().feather}
|
||||||
oninput={(e) => {
|
oninput={(e) => handleKnobInput({ ...getMaskKnobs(), feather: parseInt(e.target.value) })}
|
||||||
const knobs = getMaskKnobs();
|
onchange={(e) => handleKnobChange({ ...getMaskKnobs(), feather: parseInt(e.target.value) })} />
|
||||||
applyMaskKnobs({ ...knobs, feather: parseInt(e.target.value) });
|
|
||||||
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-selector">
|
<div class="photo-selector">
|
||||||
<label>Photo:</label>
|
<label>Photo:</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user