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);