feat: Hull as a graph node — threshold/connectivity/color-filter in the pipeline
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e2e8f0', letterSpacing: '0.05em', pointerEvents: 'none' }}>
|
||||
{node.kind === 'Source' ? 'Source'
|
||||
: node.kind === 'Output' ? 'Output'
|
||||
: node.kind === 'Hull' ? 'Hull'
|
||||
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
|
||||
: 'Combine'}
|
||||
: node.kind === 'Combine' ? 'Combine'
|
||||
: 'Output'}
|
||||
</span>
|
||||
{!isFixed && (
|
||||
<button onClick={() => deleteNode(node.id)} onMouseDown={e => e.stopPropagation()}
|
||||
@@ -343,6 +343,34 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{node.kind === 'Hull' && (<>
|
||||
<Slider label="Threshold" value={node.threshold ?? 128} min={1} max={254} step={1}
|
||||
onChange={v => updateNode(node.id, { threshold: v })} />
|
||||
<Slider label="Min area" value={node.min_area ?? 4} min={1} max={5000} step={1}
|
||||
onChange={v => updateNode(node.id, { min_area: v })} />
|
||||
<Slider label="RDP ε" value={node.rdp_epsilon ?? 1.5} min={0.1} max={10} step={0.1}
|
||||
onChange={v => updateNode(node.id, { rdp_epsilon: v })} />
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 10, color: '#6b7280' }}>Connectivity</span>
|
||||
{['four','eight'].map(c => (
|
||||
<button key={c} onMouseDown={e => e.stopPropagation()}
|
||||
onClick={() => updateNode(node.id, { connectivity: c })}
|
||||
style={{
|
||||
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
|
||||
background: (node.connectivity ?? 'four') === c ? '#0f766e' : '#1e293b',
|
||||
color: (node.connectivity ?? 'four') === c ? '#fff' : '#94a3b8',
|
||||
}}
|
||||
>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
<div onMouseDown={e => e.stopPropagation()}>
|
||||
<ColorFilter
|
||||
filter={node.color_filter ?? defaultColorFilter()}
|
||||
onChange={cf => updateNode(node.id, { color_filter: cf })}
|
||||
/>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* Preview thumbnail */}
|
||||
{preview && (
|
||||
<img src={`data:image/jpeg;base64,${preview}`} alt="" draggable={false}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Section from './Section.jsx'
|
||||
import Slider from './Slider.jsx'
|
||||
import ColorFilter from './ColorFilter.jsx'
|
||||
import { FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE } from '../store.js'
|
||||
|
||||
// Colors that match the view-mode tab dots in the top bar
|
||||
@@ -45,35 +44,6 @@ export default function PassPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Hulls & Contours ── how the response map becomes components */}
|
||||
<Section title="Hulls & Contours" defaultOpen accent={C.hulls}>
|
||||
<Slider label="Threshold" value={pass.threshold} min={1} max={254} step={1}
|
||||
onChange={v => setDetection({ threshold: v })} />
|
||||
<Slider label="Min area" value={pass.min_area} min={1} max={5000} step={1}
|
||||
onChange={v => setDetection({ min_area: v })} />
|
||||
<Slider label="RDP ε" value={pass.rdp_epsilon} min={0.1} max={10} step={0.1}
|
||||
onChange={v => setDetection({ rdp_epsilon: v })} />
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className="text-neutral-400 text-xs w-20 shrink-0">Connectivity</span>
|
||||
<div className="flex gap-1">
|
||||
{['four','eight'].map(c => (
|
||||
<button key={c}
|
||||
onClick={() => setDetection({ connectivity: c })}
|
||||
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||
pass.connectivity === c
|
||||
? 'bg-teal-700 text-white'
|
||||
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||
}`}
|
||||
>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-neutral-800">
|
||||
<ColorFilter filter={pass.colorFilter} onChange={cf => setDetection({ colorFilter: cf })} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Fill ── how hulls become strokes */}
|
||||
<Section title="Fill" defaultOpen accent={C.fill}>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GraphEdge>,
|
||||
}
|
||||
|
||||
/// Evaluate the detection graph. Returns (final_response_map, per_node_maps).
|
||||
/// Per-node maps: node_id → Vec<u8> 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<u8>,
|
||||
/// 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<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
pub fn evaluate_graph(
|
||||
rgb: &RgbImage,
|
||||
graph: &DetectionGraph,
|
||||
) -> (Vec<u8>, std::collections::HashMap<String, Vec<u8>>) {
|
||||
) -> 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<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))
|
||||
})
|
||||
// Collect raw maps (node_id → Vec<u8>). Hull nodes store their upstream
|
||||
// map; Source nodes produce nothing and are absent.
|
||||
let raw_maps: std::collections::HashMap<String, Vec<u8>> = 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<u8> {
|
||||
|
||||
211
src/lib.rs
211
src/lib.rs
@@ -68,6 +68,12 @@ pub struct GraphNodePayload {
|
||||
pub xdog_phi: Option<f32>,
|
||||
// Combine params (optional)
|
||||
pub blend_mode: Option<String>,
|
||||
// Hull params (optional — only for kind="Hull")
|
||||
pub threshold: Option<u8>,
|
||||
pub min_area: Option<u32>,
|
||||
pub rdp_epsilon: Option<f32>,
|
||||
pub connectivity: Option<String>,
|
||||
pub color_filter: Option<ColorFilterPayload>,
|
||||
}
|
||||
|
||||
#[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<String, String> = 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<String, String> =
|
||||
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<hulls::Hull> = Vec::new();
|
||||
let mut first_hull_response: Option<Vec<u8>> = 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);
|
||||
|
||||
Reference in New Issue
Block a user