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}
sourceImageB64={image?.preview_b64 ?? null}
outputImageB64={passes[activePass]?.vizB64 ?? null}
/>
) : (
<Viewport

View File

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

View File

@@ -505,7 +505,18 @@ pub fn evaluate_graph(
let node = node_map[id];
let result: Option<Vec<u8>> = match &node.kind {
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) => {
let mut ins = incoming[id].clone();
ins.sort_by_key(|&(_, p)| p);
@@ -530,12 +541,12 @@ pub fn evaluate_graph(
.and_then(|n| outputs.get(n.id.as_str()).cloned())
.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()
.filter(|(id, _)| {
let key: &str = id;
node_map.get(key).map_or(true, |n|
!matches!(n.kind, NodeKind::Source | NodeKind::Output))
node_map.get(key).map_or(true, |n| !matches!(n.kind, NodeKind::Source))
})
.map(|(id, map)| (id.to_string(), map))
.collect();