From 0420a585091e50b672921c3d81b422918a40ebec Mon Sep 17 00:00:00 2001 From: mitchellhansen Date: Sun, 26 Apr 2026 21:53:24 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Hull=20as=20a=20graph=20node=20?= =?UTF-8?q?=E2=80=94=20threshold/connectivity/color-filter=20in=20the=20pi?= =?UTF-8?q?peline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hull extraction moves from standalone ProcessPassPayload fields into a first-class graph node. The detection graph now ends with a Hull node rather than an Output node. detect.rs: NodeKind::Hull { threshold, min_area, rdp_epsilon, eight_conn, cf_* } — hull params live alongside kernel/combine params in the graph model. Hull nodes store their upstream Map under their own id in raw_maps so lib.rs can retrieve it without re-evaluating the detection portion. evaluate_graph returns GraphMaps { response, raw_maps } instead of a bare tuple. final_response falls back to the upstream map of the first Hull node when no explicit Output node is present. lib.rs: GraphNodePayload: add threshold/min_area/rdp_epsilon/connectivity/color_filter fields. ProcessPassPayload simplified to { pass_index, graph } — no standalone hull fields. to_detection_graph: Hull case constructs NodeKind::Hull from node payload. process_pass_work: iterates Hull nodes from the graph, extracts hulls per-node, generates gradient hull thumbnails (render_hull_preview), combines all hulls. response_map stored for gradient fill = the map fed into the first Hull node. Frontend: defaultGraph: Source → Kernel → Hull (Hull replaces Output as terminal node). defaultHullParams() exported from store.js. defaultPass: threshold/min_area/rdp_epsilon/connectivity/colorFilter removed. NodeGraph: Hull node renders threshold/min_area/RDP-ε/connectivity/ColorFilter; teal accent (#0d9488); fixed (non-deletable); 1 input port, no output port. PassPanel: Hulls & Contours section removed — fully migrated to the Hull node. App.jsx: processPass invoke sends only { pass_index, graph }. Co-Authored-By: Claude Sonnet 4.6 --- src-frontend/src/App.jsx | 13 +- src-frontend/src/components/NodeGraph.jsx | 52 ++++-- src-frontend/src/components/PassPanel.jsx | 30 --- src-frontend/src/store.js | 28 +-- src/detect.rs | 64 +++++-- src/lib.rs | 211 ++++++++++++++++------ 6 files changed, 259 insertions(+), 139 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 958c80a0..5dce8c15 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -161,13 +161,8 @@ export default function App() { const t0 = performance.now() try { const result = await tauri.processPass({ - pass_index: idx, - graph: pass.graph, - threshold: pass.threshold, - min_area: pass.min_area, - rdp_epsilon: pass.rdp_epsilon, - connectivity: pass.connectivity, - color_filter: pass.colorFilter, + pass_index: idx, + graph: pass.graph, }) const js_process = Math.round(performance.now() - t0) setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process })) @@ -271,9 +266,7 @@ export default function App() { async function dumpDebugState() { try { const configs = passes.map(p => ({ - graph: p.graph, threshold: p.threshold, min_area: p.min_area, - rdp_epsilon: p.rdp_epsilon, connectivity: p.connectivity, - color_filter: p.colorFilter, strategy: p.strategy, + graph: p.graph, strategy: p.strategy, spacing: p.spacing, angle: p.angle, smooth_rdp: p.smoothRdp, smooth_iters: p.smoothIters, })) diff --git a/src-frontend/src/components/NodeGraph.jsx b/src-frontend/src/components/NodeGraph.jsx index 32382fed..8bdc500e 100644 --- a/src-frontend/src/components/NodeGraph.jsx +++ b/src-frontend/src/components/NodeGraph.jsx @@ -1,6 +1,7 @@ import { useRef, useState, useCallback, useEffect } from 'react' import Slider from './Slider.jsx' -import { KERNELS, BLEND_MODES, defaultKernelProps, newNodeId } from '../store.js' +import { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultColorFilter, newNodeId } from '../store.js' +import ColorFilter from './ColorFilter.jsx' // ── Layout constants ─────────────────────────────────────────────────────────── const NODE_W = 220 @@ -221,20 +222,18 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6 // ── Node rendering ───────────────────────────────────────────────────────── function renderNode(node) { - const isFixed = node.kind === 'Source' || node.kind === 'Output' + const isFixed = node.kind === 'Source' || node.kind === 'Hull' const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2) - : (node.kind === 'Kernel' || node.kind === 'Output') ? 1 : 0 - const hasOut = node.kind !== 'Output' + : (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull') ? 1 : 0 + const hasOut = node.kind !== 'Output' && node.kind !== 'Hull' - // 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' + const accentColor = node.kind === 'Source' ? '#7c3aed' + : node.kind === 'Hull' ? '#0d9488' : '#374151' - const headerBg = node.kind === 'Source' ? '#2e1065' - : node.kind === 'Output' ? '#1c1003' + const headerBg = node.kind === 'Source' ? '#2e1065' + : node.kind === 'Hull' ? '#042f2e' : '#1e293b' return ( @@ -280,9 +279,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6 > {node.kind === 'Source' ? 'Source' - : node.kind === 'Output' ? 'Output' + : node.kind === 'Hull' ? 'Hull' : node.kind === 'Kernel' ? (node.kernel ?? 'Kernel') - : 'Combine'} + : node.kind === 'Combine' ? 'Combine' + : 'Output'} {!isFixed && ( + ))} + +
e.stopPropagation()}> + updateNode(node.id, { color_filter: cf })} + /> +
+ )} + {/* Preview thumbnail */} {preview && ( - {/* ── Hulls & Contours ── how the response map becomes components */} -
- setDetection({ threshold: v })} /> - setDetection({ min_area: v })} /> - setDetection({ rdp_epsilon: v })} /> -
- Connectivity -
- {['four','eight'].map(c => ( - - ))} -
-
- -
- setDetection({ colorFilter: cf })} /> -
-
- {/* ── Fill ── how hulls become strokes */}
diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js index 36e19323..1fbf6839 100644 --- a/src-frontend/src/store.js +++ b/src-frontend/src/store.js @@ -34,25 +34,32 @@ export function defaultKernelProps() { let _nodeSeq = 0 export function newNodeId(kind) { return `${kind.toLowerCase()}_${++_nodeSeq}` } +export function defaultColorFilter() { + return { enabled: false, hue_min: 0, hue_max: 360, sat_min: 0, sat_max: 1, val_min: 0, val_max: 1 } +} + +export function defaultHullParams() { + return { + threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four', + color_filter: defaultColorFilter(), + } +} + export function defaultGraph() { const kId = newNodeId('kernel') return { nodes: [ { id: 'source', kind: 'Source', x: 60, y: 160 }, - { id: kId, kind: 'Kernel', x: 290, y: 100, ...defaultKernelProps() }, - { id: 'output', kind: 'Output', x: 560, y: 160 }, + { id: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() }, + { id: 'hull', kind: 'Hull', x: 580, y: 160, ...defaultHullParams() }, ], edges: [ - { from: 'source', to: kId, port: 0 }, - { from: kId, to: 'output', port: 0 }, + { from: 'source', to: kId, port: 0 }, + { from: kId, to: 'hull', port: 0 }, ], } } -export function defaultColorFilter() { - return { enabled: false, hue_min: 0, hue_max: 360, sat_min: 0, sat_max: 1, val_min: 0, val_max: 1 } -} - export function defaultPass(index) { const colors = [[20,20,20],[60,100,220],[200,60,60]] return { @@ -60,11 +67,6 @@ export function defaultPass(index) { penColor: colors[index] ?? [128,128,128], graph: defaultGraph(), nodePreviews: {}, - threshold: 128, - min_area: 4, - rdp_epsilon: 1.5, - connectivity: 'four', // 'four' | 'eight' - colorFilter: defaultColorFilter(), strategy: 'hatch', spacing: 5, angle: 0, diff --git a/src/detect.rs b/src/detect.rs index bf98e79c..8183671f 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -424,6 +424,16 @@ pub enum NodeKind { Kernel(DetectionLayer), Combine(BlendMode), Output, + Hull { + threshold: u8, + min_area: u32, + rdp_epsilon: f32, + eight_conn: bool, + cf_enabled: bool, + cf_hue_min: f32, cf_hue_max: f32, + cf_sat_min: f32, cf_sat_max: f32, + cf_val_min: f32, cf_val_max: f32, + }, } #[derive(Debug, Clone)] @@ -445,20 +455,26 @@ pub struct DetectionGraph { pub edges: Vec, } -/// Evaluate the detection graph. Returns (final_response_map, per_node_maps). -/// Per-node maps: node_id → Vec response map (0=ink, 255=bg). -/// Source and Output nodes are excluded from per-node maps. +/// Outputs of a full graph evaluation pass. +pub struct GraphMaps { + /// Final response map (from Output node, or upstream of first Hull node). + pub response: Vec, + /// Raw per-node response maps, keyed by node id. Used by lib.rs for hull + /// extraction (Hull nodes store their upstream map here) and thumbnail encoding. + pub raw_maps: std::collections::HashMap>, +} + pub fn evaluate_graph( rgb: &RgbImage, graph: &DetectionGraph, -) -> (Vec, std::collections::HashMap>) { +) -> GraphMaps { use std::collections::{HashMap, VecDeque}; let n = (rgb.width() * rgb.height()) as usize; let bg = || vec![255u8; n]; if graph.nodes.is_empty() { - return (bg(), HashMap::new()); + return GraphMaps { response: bg(), raw_maps: HashMap::new() }; } // Build adjacency: incoming edges per node (sorted by port for combine) @@ -492,7 +508,7 @@ pub fn evaluate_graph( } } if order.len() != graph.nodes.len() { - return (bg(), HashMap::new()); // cycle + return GraphMaps { response: bg(), raw_maps: HashMap::new() }; // cycle } let node_map: HashMap<&str, &GraphNode> = graph.nodes.iter() @@ -530,28 +546,38 @@ pub fn evaluate_graph( .find_map(|(fid, _)| outputs.get(fid).cloned()); Some(upstream.unwrap_or_else(bg)) } + // Hull nodes store their upstream Map so lib.rs can retrieve it for + // hull extraction without re-evaluating the detection portion. + NodeKind::Hull { .. } => { + incoming[id].iter() + .find_map(|(fid, _)| outputs.get(fid).cloned()) + } }; if let Some(map) = result { outputs.insert(id, map); } } - let final_map = graph.nodes.iter() - .find(|n| matches!(n.kind, NodeKind::Output)) - .and_then(|n| outputs.get(n.id.as_str()).cloned()) - .unwrap_or_else(bg); - - // 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> = outputs.into_iter() - .filter(|(id, _)| { - let key: &str = id; - node_map.get(key).map_or(true, |n| !matches!(n.kind, NodeKind::Source)) - }) + // Collect raw maps (node_id → Vec). Hull nodes store their upstream + // map; Source nodes produce nothing and are absent. + let raw_maps: std::collections::HashMap> = outputs + .into_iter() .map(|(id, map)| (id.to_string(), map)) .collect(); - (final_map, previews) + // Final response: prefer an explicit Output node; fall back to the upstream + // map of the first Hull node (which was stored under the Hull node's id). + let response = graph.nodes.iter() + .find(|n| matches!(n.kind, NodeKind::Output)) + .and_then(|n| raw_maps.get(&n.id).cloned()) + .or_else(|| { + graph.nodes.iter() + .find(|n| matches!(n.kind, NodeKind::Hull { .. })) + .and_then(|n| raw_maps.get(&n.id).cloned()) + }) + .unwrap_or_else(bg); + + GraphMaps { response, raw_maps } } fn blend_maps(maps: &[&[u8]], mode: BlendMode, n: usize) -> Vec { diff --git a/src/lib.rs b/src/lib.rs index c8f9310c..1a8b9897 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,6 +68,12 @@ pub struct GraphNodePayload { pub xdog_phi: Option, // Combine params (optional) pub blend_mode: Option, + // Hull params (optional — only for kind="Hull") + pub threshold: Option, + pub min_area: Option, + pub rdp_epsilon: Option, + pub connectivity: Option, + pub color_filter: Option, } #[derive(Deserialize, Clone, Debug)] @@ -96,13 +102,8 @@ pub struct ColorFilterPayload { #[derive(Deserialize, Clone, Debug)] pub struct ProcessPassPayload { - pub pass_index: usize, - pub graph: DetectionGraphPayload, - pub threshold: u8, - pub min_area: u32, - pub rdp_epsilon: f32, - pub connectivity: String, // "four" | "eight" - pub color_filter: ColorFilterPayload, + pub pass_index: usize, + pub graph: DetectionGraphPayload, } #[derive(Serialize, Clone, Default)] @@ -210,6 +211,22 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph ); detect::NodeKind::Combine(mode) } + "Hull" => { + let cf = n.color_filter.as_ref(); + detect::NodeKind::Hull { + threshold: n.threshold.unwrap_or(128), + min_area: n.min_area.unwrap_or(4), + rdp_epsilon: n.rdp_epsilon.unwrap_or(1.5), + eight_conn: n.connectivity.as_deref() == Some("eight"), + cf_enabled: cf.map(|f| f.enabled).unwrap_or(false), + cf_hue_min: cf.map(|f| f.hue_min).unwrap_or(0.0), + cf_hue_max: cf.map(|f| f.hue_max).unwrap_or(360.0), + cf_sat_min: cf.map(|f| f.sat_min).unwrap_or(0.0), + cf_sat_max: cf.map(|f| f.sat_max).unwrap_or(1.0), + cf_val_min: cf.map(|f| f.val_min).unwrap_or(0.0), + cf_val_max: cf.map(|f| f.val_max).unwrap_or(1.0), + } + } _ => detect::NodeKind::Output, }; detect::GraphNode { id: n.id.clone(), kind } @@ -291,6 +308,23 @@ fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String { // ── Pipeline inner functions (no Tauri, no mutex) ───────────────────────────── +fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], w: u32, h: u32) -> String { + let mut rgba = vec![15u8; (w * h * 4) as usize]; + for chunk in rgba.chunks_mut(4) { chunk[3] = 255; } + for hull in hulls_list { + let (hr, hg, hb) = hash_color(hull.id); + for &(px, py) in &hull.pixels { + let resp = response.get((py * w + px) as usize).copied().unwrap_or(0); + let intensity = (255u32 - resp as u32) as f32 / 255.0; + let i = ((py * w + px) * 4) as usize; + rgba[i] = (hr as f32 * intensity) as u8; + rgba[i+1] = (hg as f32 * intensity) as u8; + rgba[i+2] = (hb as f32 * intensity) as u8; + } + } + rgba_to_b64_png(&rgba, w, h) +} + fn process_pass_work( rgb: &image::RgbImage, payload: ProcessPassPayload, @@ -300,51 +334,95 @@ fn process_pass_work( let (w, h) = rgb.dimensions(); let det_graph = to_detection_graph(&payload.graph); - let hull_params = hulls::HullParams { - threshold: payload.threshold, - min_area: payload.min_area, - rdp_epsilon: payload.rdp_epsilon, - connectivity: if payload.connectivity == "eight" { - hulls::Connectivity::Eight - } else { - hulls::Connectivity::Four - }, - }; - let color_filter = to_color_filter(&payload.color_filter); let mut t = Instant::now(); - let (response, node_outputs) = detect::evaluate_graph(rgb, &det_graph); + let graph_maps = detect::evaluate_graph(rgb, &det_graph); t = lap!(steps, "detect", t); - let node_previews: std::collections::HashMap = node_outputs.iter() - .map(|(id, map)| (id.clone(), map_to_b64_small(map, w, h))) - .collect(); - t = lap!(steps, "node previews", t); + // JPEG thumbnails for detection nodes (Kernel, Combine, Output) + let mut node_previews: std::collections::HashMap = + graph_maps.raw_maps.iter() + .filter(|(id, _)| { + det_graph.nodes.iter().find(|n| &n.id == *id) + .map_or(false, |n| !matches!( + n.kind, + detect::NodeKind::Source | detect::NodeKind::Hull { .. } + )) + }) + .map(|(id, map)| (id.clone(), map_to_b64_small(map, w, h))) + .collect(); + t = lap!(steps, "detect previews", t); - let all_hulls = hulls::extract_hulls(&response, rgb, w, h, &hull_params); + // Process every Hull node in the graph + let mut all_hulls: Vec = Vec::new(); + let mut first_hull_response: Option> = None; + let mut first_hull_threshold: u8 = 128; + + for node in &det_graph.nodes { + if let detect::NodeKind::Hull { + threshold, min_area, rdp_epsilon, eight_conn, + cf_enabled, cf_hue_min, cf_hue_max, + cf_sat_min, cf_sat_max, cf_val_min, cf_val_max, + } = &node.kind { + // The upstream Map was stored under the Hull node's own id + let response = match graph_maps.raw_maps.get(&node.id) { + Some(m) => m, + None => continue, // disconnected hull node — skip + }; + + let hull_params = hulls::HullParams { + threshold: *threshold, + min_area: *min_area, + rdp_epsilon: *rdp_epsilon, + connectivity: if *eight_conn { hulls::Connectivity::Eight } + else { hulls::Connectivity::Four }, + }; + let color_filter = hulls::ColorFilter { + enabled: *cf_enabled, + hue_min: *cf_hue_min, hue_max: *cf_hue_max, + sat_min: *cf_sat_min, sat_max: *cf_sat_max, + val_min: *cf_val_min, val_max: *cf_val_max, + }; + + let extracted = hulls::extract_hulls(response, rgb, w, h, &hull_params); + let filtered = hulls::filter_hulls_by_color(extracted, &color_filter); + + // Hull node thumbnail — gradient hue by response intensity + node_previews.insert(node.id.clone(), render_hull_preview(response, &filtered, w, h)); + + if first_hull_response.is_none() { + first_hull_response = Some(response.clone()); + first_hull_threshold = *threshold; + } + all_hulls.extend(filtered); + } + } t = lap!(steps, "hull extract", t); - let extracted = hulls::filter_hulls_by_color(all_hulls, &color_filter); - t = lap!(steps, "color filter", t); + // Coverage and binary viz use the first Hull node's data + let response_for_viz = first_hull_response.as_deref().unwrap_or(&graph_maps.response); + let threshold = first_hull_threshold; - let total_dark = response.iter().filter(|&&p| p < payload.threshold).count(); - let hull_px: usize = extracted.iter().map(|h| h.pixels.len()).sum(); + let total_dark = response_for_viz.iter().filter(|&&p| p < threshold).count(); + let hull_px: usize = all_hulls.iter().map(|h| h.pixels.len()).sum(); let coverage_pct = if total_dark > 0 { hull_px * 100 / total_dark } else { 0 }; let mut rgba = vec![0u8; (w * h * 4) as usize]; - for (i, &r) in response.iter().enumerate() { - let v = if r < payload.threshold { 0u8 } else { 220u8 }; + for (i, &r) in response_for_viz.iter().enumerate() { + let v = if r < threshold { 0u8 } else { 220u8 }; rgba[i*4] = v; rgba[i*4+1] = v; rgba[i*4+2] = v; rgba[i*4+3] = 255; } t = lap!(steps, "viz build", t); let viz_b64 = rgba_to_b64_png(&rgba, w, h); - let hull_count = extracted.len(); + let hull_count = all_hulls.len(); lap!(steps, "png encode", t); - steps.push(StepTime { label: "total".into(), ms: t0.elapsed().as_millis() as u64 }); - (extracted, response, ProcessResult { hull_count, coverage_pct, viz_b64, node_previews, timings: steps }) + // response_map for gradient fill = the map fed into the first Hull node + let response_map = first_hull_response.unwrap_or_else(|| graph_maps.response); + + (all_hulls, response_map, ProcessResult { hull_count, coverage_pct, viz_b64, node_previews, timings: steps }) } fn generate_fill_work( @@ -876,30 +954,45 @@ mod blocking_tests { img } + fn node(id: &str, kind: &str) -> GraphNodePayload { + GraphNodePayload { + id: id.into(), kind: kind.into(), x: 0.0, y: 0.0, + kernel: None, weight: None, invert: None, blur_radius: None, + sat_min_value: None, canny_low: None, canny_high: None, + xdog_sigma2: None, xdog_tau: None, xdog_phi: None, + blend_mode: None, + threshold: None, min_area: None, rdp_epsilon: None, + connectivity: None, color_filter: None, + } + } + fn default_process_payload() -> ProcessPassPayload { + let mut k1 = node("k1", "Kernel"); + let mut hull = node("hull", "Hull"); + k1.kernel = Some("Luminance".into()); + k1.weight = Some(1.0); + k1.invert = Some(false); + k1.blur_radius = Some(0.0); + k1.sat_min_value = Some(0.0); + k1.canny_low = Some(50.0); + k1.canny_high = Some(150.0); + k1.xdog_sigma2 = Some(1.6); + k1.xdog_tau = Some(0.98); + k1.xdog_phi = Some(10.0); + hull.threshold = Some(128); + hull.min_area = Some(10); + hull.rdp_epsilon = Some(2.0); + hull.connectivity = Some("four".into()); + ProcessPassPayload { - pass_index: 0, + pass_index: 0, graph: DetectionGraphPayload { - nodes: vec![ - GraphNodePayload { id: "source".into(), kind: "Source".into(), x: 0.0, y: 0.0, kernel: None, weight: None, invert: None, blur_radius: None, sat_min_value: None, canny_low: None, canny_high: None, xdog_sigma2: None, xdog_tau: None, xdog_phi: None, blend_mode: None }, - GraphNodePayload { id: "k1".into(), kind: "Kernel".into(), x: 0.0, y: 0.0, kernel: Some("Luminance".into()), weight: Some(1.0), invert: Some(false), blur_radius: Some(0.0), sat_min_value: Some(0.0), canny_low: Some(50.0), canny_high: Some(150.0), xdog_sigma2: Some(1.6), xdog_tau: Some(0.98), xdog_phi: Some(10.0), blend_mode: None }, - GraphNodePayload { id: "output".into(), kind: "Output".into(), x: 0.0, y: 0.0, kernel: None, weight: None, invert: None, blur_radius: None, sat_min_value: None, canny_low: None, canny_high: None, xdog_sigma2: None, xdog_tau: None, xdog_phi: None, blend_mode: None }, - ], + nodes: vec![node("source", "Source"), k1, hull], edges: vec![ - GraphEdgePayload { from: "source".into(), to: "k1".into(), port: 0 }, - GraphEdgePayload { from: "k1".into(), to: "output".into(), port: 0 }, + GraphEdgePayload { from: "source".into(), to: "k1".into(), port: 0 }, + GraphEdgePayload { from: "k1".into(), to: "hull".into(), port: 0 }, ], }, - threshold: 128, - min_area: 10, - rdp_epsilon: 2.0, - connectivity: "four".into(), - color_filter: ColorFilterPayload { - enabled: false, - hue_min: 0.0, hue_max: 360.0, - sat_min: 0.0, sat_max: 1.0, - val_min: 0.0, val_max: 1.0, - }, } } @@ -1261,14 +1354,22 @@ mod viz_tests { nodes: vec![ detect::GraphNode { id: "source".into(), kind: detect::NodeKind::Source }, detect::GraphNode { id: "k1".into(), kind: detect::NodeKind::Kernel(layer) }, - detect::GraphNode { id: "output".into(), kind: detect::NodeKind::Output }, + detect::GraphNode { id: "hull".into(), kind: detect::NodeKind::Hull { + threshold: threshold as u8, min_area, rdp_epsilon: rdp_eps, + eight_conn: false, + cf_enabled: false, + cf_hue_min: 0.0, cf_hue_max: 360.0, + cf_sat_min: 0.0, cf_sat_max: 1.0, + cf_val_min: 0.0, cf_val_max: 1.0, + }}, ], edges: vec![ - detect::GraphEdge { from: "source".into(), to: "k1".into(), port: 0 }, - detect::GraphEdge { from: "k1".into(), to: "output".into(), port: 0 }, + detect::GraphEdge { from: "source".into(), to: "k1".into(), port: 0 }, + detect::GraphEdge { from: "k1".into(), to: "hull".into(), port: 0 }, ], }; - let (response, _) = detect::evaluate_graph(&img, &graph); + let gm = detect::evaluate_graph(&img, &graph); + let response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response); let params = HullParams { threshold, min_area, rdp_epsilon: rdp_eps, connectivity: hulls::Connectivity::Four }; let hs = hulls::extract_hulls(&response, &img, w, h, ¶ms);