fix: weight slider, Output node preview, blend_mode serialization

Three bugs fixed:

1. Weight slider had no effect — evaluate_graph called apply_layer but never
   applied layer.weight. Now applies the same lerp as the old apply_stack:
   effective = clamp(255*(1-w) + raw*w, 0, 255). w=0 → background, w=1 →
   identity, w>1 → amplified toward ink.

2. Output node showed thresholded binary (vizB64) instead of the raw
   response map. Root cause: Output was filtered out of node_previews, so
   NodeGraph fell back to outputImageB64 which is the post-threshold viz.
   Fix: include Output in node_previews (only Source is excluded); NodeGraph
   now uses nodePreviews[node.id] for all nodes except Source. The threshold
   slider in Hulls & Contours no longer affects detection graph thumbnails.

3. Combine blend_mode was always "Average" because the frontend stored the
   field as blendMode (camelCase) but the Rust payload expected blend_mode
   (snake_case) — serde found nothing and defaulted. Changed to snake_case
   throughout NodeGraph.jsx to match the rest of the payload convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:24:25 -07:00
parent 11fa0bb86f
commit 3ff65a93ef
3 changed files with 23 additions and 14 deletions

View File

@@ -417,7 +417,6 @@ export default function App() {
}} }}
nodePreviews={passes[activePass].nodePreviews} nodePreviews={passes[activePass].nodePreviews}
sourceImageB64={image?.preview_b64 ?? null} sourceImageB64={image?.preview_b64 ?? null}
outputImageB64={passes[activePass]?.vizB64 ?? null}
/> />
) : ( ) : (
<Viewport <Viewport

View File

@@ -39,7 +39,7 @@ function bezier(from, to) {
} }
// ── Component ────────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────────
export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB64, outputImageB64 }) { export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB64 }) {
const canvasRef = useRef(null) const canvasRef = useRef(null)
const [pan, setPan] = useState({ x: 40, y: 40 }) const [pan, setPan] = useState({ x: 40, y: 40 })
const [zoom, setZoom] = useState(1) const [zoom, setZoom] = useState(1)
@@ -190,7 +190,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
const id = newNodeId(kind) const id = newNodeId(kind)
const node = kind === 'Kernel' const node = kind === 'Kernel'
? { id, kind, x, y, ...defaultKernelProps() } ? { id, kind, x, y, ...defaultKernelProps() }
: { id, kind, x, y, blendMode: 'Average', inputCount: 2 } : { id, kind, x, y, blend_mode: 'Average', inputCount: 2 }
const g = graphRef.current const g = graphRef.current
onChangeRef.current({ ...g, nodes: [...g.nodes, node] }) onChangeRef.current({ ...g, nodes: [...g.nodes, node] })
} }
@@ -226,10 +226,9 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
: (node.kind === 'Kernel' || node.kind === 'Output') ? 1 : 0 : (node.kind === 'Kernel' || node.kind === 'Output') ? 1 : 0
const hasOut = node.kind !== 'Output' const hasOut = node.kind !== 'Output'
// Preview image: explicit source/output images, or per-node detection map // Source shows the original image; all other nodes (including Output)
const preview = node.kind === 'Source' ? sourceImageB64 // show the raw response map from nodePreviews — consistent and threshold-independent.
: node.kind === 'Output' ? outputImageB64 const preview = node.kind === 'Source' ? sourceImageB64 : nodePreviews?.[node.id]
: nodePreviews?.[node.id]
const accentColor = node.kind === 'Source' ? '#7c3aed' const accentColor = node.kind === 'Source' ? '#7c3aed'
: node.kind === 'Output' ? '#b45309' : node.kind === 'Output' ? '#b45309'
@@ -326,11 +325,11 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
{node.kind === 'Combine' && (<> {node.kind === 'Combine' && (<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{BLEND_MODES.map(m => ( {BLEND_MODES.map(m => (
<button key={m} onMouseDown={e => e.stopPropagation()} onClick={() => updateNode(node.id, { blendMode: m })} <button key={m} onMouseDown={e => e.stopPropagation()} onClick={() => updateNode(node.id, { blend_mode: m })}
style={{ style={{
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none', padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
background: node.blendMode === m ? '#0f766e' : '#1e293b', background: node.blend_mode === m ? '#0f766e' : '#1e293b',
color: node.blendMode === m ? '#fff' : '#94a3b8', color: node.blend_mode === m ? '#fff' : '#94a3b8',
}} }}
>{m}</button> >{m}</button>
))} ))}

View File

@@ -505,7 +505,18 @@ pub fn evaluate_graph(
let node = node_map[id]; let node = node_map[id];
let result: Option<Vec<u8>> = match &node.kind { let result: Option<Vec<u8>> = match &node.kind {
NodeKind::Source => None, NodeKind::Source => None,
NodeKind::Kernel(layer) => Some(apply_layer(rgb, layer)), NodeKind::Kernel(layer) => {
let raw = apply_layer(rgb, layer);
let w = layer.weight;
// w=0 → full background, w=1 → identity, w>1 → amplify toward ink
Some(if (w - 1.0).abs() < 1e-6 {
raw
} else {
raw.iter().map(|&r| {
(255.0 * (1.0 - w) + r as f32 * w).clamp(0.0, 255.0) as u8
}).collect()
})
}
NodeKind::Combine(mode) => { NodeKind::Combine(mode) => {
let mut ins = incoming[id].clone(); let mut ins = incoming[id].clone();
ins.sort_by_key(|&(_, p)| p); ins.sort_by_key(|&(_, p)| p);
@@ -530,12 +541,12 @@ pub fn evaluate_graph(
.and_then(|n| outputs.get(n.id.as_str()).cloned()) .and_then(|n| outputs.get(n.id.as_str()).cloned())
.unwrap_or_else(bg); .unwrap_or_else(bg);
// Per-node previews — omit Source (no output) and Output (same as final_map) // Per-node previews — omit only Source (no map output); include Output so
// the graph editor can show the raw response map (not the thresholded vizB64).
let previews: HashMap<String, Vec<u8>> = outputs.into_iter() let previews: HashMap<String, Vec<u8>> = outputs.into_iter()
.filter(|(id, _)| { .filter(|(id, _)| {
let key: &str = id; let key: &str = id;
node_map.get(key).map_or(true, |n| node_map.get(key).map_or(true, |n| !matches!(n.kind, NodeKind::Source))
!matches!(n.kind, NodeKind::Source | NodeKind::Output))
}) })
.map(|(id, map)| (id.to_string(), map)) .map(|(id, map)| (id.to_string(), map))
.collect(); .collect();