feat(gate-b): Gate B mask + BDFT + length axis (#21–#23) #31
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user