From 11fa0bb86f5d5df66404f30533936d87ba2a8bad Mon Sep 17 00:00:00 2001 From: mitchellhansen Date: Sun, 26 Apr 2026 21:05:10 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20gradient=5Fhatch=20fill=20=E2=80=94=20d?= =?UTF-8?q?ensity=20driven=20by=20response=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new fill strategy that uses the per-pixel response map (0=dark/ink, 255=background) to modulate scan-line spacing, producing tighter hatching in darker areas and wider spacing in lighter areas within each hull. fill.rs — gradient_hatch(hull, response, img_width, spacing, angle, min_scale): Adaptive step: local_spacing = spacing × lerp(min_scale, 1.0, resp/255) Floored at 1.0px so each integer v-row is visited at most once → O(N log N), same complexity class as parallel_hatch. Arc<[u8]> shared across rayon threads. Tests: - gradient_hatch_dark_denser_than_light: dark hull → more strokes than light - gradient_hatch_monotone_density: stroke count non-increasing with response - gradient_hatch_min_scale_one_matches_parallel: min_scale=1.0 → identical to baseline - gradient_hatch_all_points_inside_hull: containment check - gradient_hatch_performance: 256×256 dark hull < 2s in debug mode - gradient_hatch_perf_ratio_vs_parallel: ≤20× baseline (catches O(N²) regressions) lib.rs: PassState gains response_map: Vec, stored after every process_pass run. generate_fill_work receives (response_map, img_width); wraps map in Arc<[u8]> for zero-copy sharing across the rayon thread pool. pipeline_bench.rs: New section benchmarks gradient_hatch at min_scale 0.5 / 0.25 / 0.1. store.js: gradient_hatch added to FILL_STRATEGIES, FILL_USES_ANGLE, and FILL_STRATEGY_PARAMS (Min Scale slider, default 0.25). Co-Authored-By: Claude Sonnet 4.6 --- src-frontend/src/store.js | 10 +- src/fill.rs | 202 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 54 +++++----- src/pipeline_bench.rs | 14 ++- 4 files changed, 253 insertions(+), 27 deletions(-) diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js index 13ebd526..36e19323 100644 --- a/src-frontend/src/store.js +++ b/src-frontend/src/store.js @@ -4,7 +4,7 @@ export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG'] export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference'] -export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow'] +export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch'] // Per-strategy secondary parameter exposed as a slider. // Strategies not listed here have no secondary parameter. @@ -13,12 +13,14 @@ export const FILL_STRATEGY_PARAMS = { hint: 'Min circle radius as a multiple of spacing' }, waves: { label: 'Sources', min: 1, max: 9, step: 1, default: 5, hint: 'Number of concentric ring emitters' }, - flow: { label: 'Bend', min: 0.0, max: 2.0, step: 0.1, default: 1.0, - hint: '0 = straight lines · 1 = default ±45° · 2 = wild curves' }, + flow: { label: 'Bend', min: 0.0, max: 2.0, step: 0.1, default: 1.0, + hint: '0 = straight lines · 1 = default ±45° · 2 = wild curves' }, + gradient_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25, + hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' }, } // Strategies that use the angle slider -export const FILL_USES_ANGLE = new Set(['hatch', 'zigzag', 'flow']) +export const FILL_USES_ANGLE = new Set(['hatch', 'zigzag', 'flow', 'gradient_hatch']) export function defaultKernelProps() { return { diff --git a/src/fill.rs b/src/fill.rs index c6679b27..b1d384af 100644 --- a/src/fill.rs +++ b/src/fill.rs @@ -85,6 +85,88 @@ pub fn parallel_hatch(hull: &Hull, spacing_px: f32, angle_deg: f32) -> FillResul FillResult { hull_id: hull.id, strokes } } +// ── Gradient hatch ───────────────────────────────────────────────────────────── + +/// Parallel hatch with adaptive scan-line spacing driven by the response map. +/// +/// `min_scale` ∈ [0.05, 1.0]: spacing ratio applied in the darkest (most-ink) areas. +/// 1.0 → uniform spacing (identical to parallel_hatch) +/// 0.25 → 4× denser lines where response ≈ 0 +/// +/// Adaptive step: `local_spacing = spacing × lerp(min_scale, 1.0, response/255)` +/// Floored at 1.0 px so each integer v-row is visited at most once — O(N log N) time. +pub fn gradient_hatch( + hull: &Hull, + response: &[u8], + img_width: u32, + spacing_px: f32, + angle_deg: f32, + min_scale: f32, +) -> FillResult { + if hull.pixels.is_empty() || spacing_px <= 0.0 { + return FillResult { hull_id: hull.id, strokes: vec![] }; + } + let min_scale = min_scale.clamp(0.05, 1.0); + + let angle_rad = angle_deg.to_radians(); + let cos_a = angle_rad.cos(); + let sin_a = angle_rad.sin(); + + // Build per-integer-v-row buckets: v_row → sorted Vec<(u_coord, resp)> + let mut v_buckets: HashMap> = HashMap::new(); + let (mut v_min, mut v_max) = (f32::MAX, f32::MIN); + + for &(px, py) in &hull.pixels { + let (fx, fy) = (px as f32, py as f32); + let u = fx * cos_a + fy * sin_a; + let v = -fx * sin_a + fy * cos_a; + v_min = v_min.min(v); + v_max = v_max.max(v); + let resp = response.get((py * img_width + px) as usize).copied().unwrap_or(128); + v_buckets.entry(v.round() as i32).or_default().push((u, resp)); + } + for entries in v_buckets.values_mut() { + entries.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + } + + let mut strokes = Vec::new(); + let mut v = v_min + spacing_px * 0.5; + + loop { + if v > v_max + spacing_px { break; } + let v_row = v.round() as i32; + + let (entries, avg_resp): (&[(f32, u8)], u8) = if let Some(e) = v_buckets.get(&v_row) { + let sum: u32 = e.iter().map(|&(_, r)| r as u32).sum(); + let avg = (sum / e.len() as u32) as u8; + (e.as_slice(), avg) + } else { + (&[], 255) + }; + + // Emit contiguous runs along this scan line + if entries.len() >= 2 { + let mut run_start = 0; + for i in 1..=entries.len() { + let end_run = i == entries.len() || entries[i].0 - entries[i-1].0 > 1.5; + if end_run && i - run_start >= 2 { + let stroke: Vec<(f32, f32)> = entries[run_start..i].iter().map(|&(u_coord, _)| { + (u_coord * cos_a - v * sin_a, u_coord * sin_a + v * cos_a) + }).collect(); + if stroke.len() >= 2 { strokes.push(stroke); } + run_start = i; + } + } + } + + // Adaptive step: resp=0 (dark/ink) → min_scale×spacing; resp=255 (bg) → 1×spacing + let local_spacing = spacing_px * (min_scale + (1.0 - min_scale) * avg_resp as f32 / 255.0); + v += local_spacing.max(1.0); // floor: each integer v-row visited at most once + } + + FillResult { hull_id: hull.id, strokes } +} + // ── Outline ──────────────────────────────────────────────────────────────────── /// The simplified contour as a single closed stroke. @@ -1822,4 +1904,124 @@ mod tests { ); } } + + // ── gradient_hatch tests ────────────────────────────────────────────────── + + /// Build a uniform response map for a hull (all in-hull pixels set to `val`). + fn uniform_response(hull: &Hull, img_width: u32, img_height: u32, val: u8) -> Vec { + let mut resp = vec![255u8; (img_width * img_height) as usize]; + for &(px, py) in &hull.pixels { + resp[(py * img_width + px) as usize] = val; + } + resp + } + + #[test] + fn gradient_hatch_dark_denser_than_light() { + // Same 60×60 hull, two response levels — dark region should produce more strokes. + let hull = make_square_hull(4, 4, 60); + let w = 68u32; let h = 68u32; + let dark = uniform_response(&hull, w, h, 10); // very dark → tight + let light = uniform_response(&hull, w, h, 110); // lighter → wider + + let dark_r = gradient_hatch(&hull, &dark, w, 8.0, 0.0, 0.2); + let light_r = gradient_hatch(&hull, &light, w, 8.0, 0.0, 0.2); + + assert!(dark_r.strokes.len() > light_r.strokes.len(), + "dark hull should produce more strokes than light: dark={} light={}", + dark_r.strokes.len(), light_r.strokes.len()); + } + + #[test] + fn gradient_hatch_monotone_density() { + // Stroke count should decrease monotonically as response value increases. + let hull = make_square_hull(4, 4, 60); + let w = 68u32; let h = 68u32; + let counts: Vec = [10u8, 50, 100, 150, 200].iter().map(|&val| { + let resp = uniform_response(&hull, w, h, val); + gradient_hatch(&hull, &resp, w, 6.0, 0.0, 0.2).strokes.len() + }).collect(); + + for window in counts.windows(2) { + assert!(window[0] >= window[1], + "stroke count should be non-increasing with response: {:?}", counts); + } + } + + #[test] + fn gradient_hatch_min_scale_one_matches_parallel() { + // With min_scale=1.0, gradient_hatch is identical to parallel_hatch. + let hull = make_square_hull(4, 4, 60); + let w = 68u32; let h = 68u32; + let resp = uniform_response(&hull, w, h, 128); + + let grad = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 1.0); + let base = parallel_hatch(&hull, 5.0, 0.0); + + let diff = (grad.strokes.len() as i64 - base.strokes.len() as i64).abs(); + assert!(diff <= 2, + "gradient with min_scale=1.0 should match parallel_hatch: grad={} base={}", + grad.strokes.len(), base.strokes.len()); + } + + #[test] + fn gradient_hatch_all_points_inside_hull() { + let hull = make_square_hull(4, 4, 60); + let w = 68u32; let h = 68u32; + let resp = uniform_response(&hull, w, h, 30); + let result = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 0.25); + let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); + for stroke in &result.strokes { + for &(x, y) in stroke { + let px = x.round() as u32; let py = y.round() as u32; + // Allow 1px tolerance at scan-line edges + let near = (-1i32..=1).any(|dy| (-1i32..=1).any(|dx| { + pixel_set.contains(&((px as i32 + dx).max(0) as u32, (py as i32 + dy).max(0) as u32)) + })); + assert!(near, "point ({x:.1},{y:.1}) is outside hull"); + } + } + } + + #[test] + fn gradient_hatch_performance() { + // Worst case: 256×256 dark hull with min_scale=0.1 (tightest possible lines). + // Must complete within 2 seconds in debug mode. Catches O(N²) regressions. + use std::time::Instant; + let hull = make_square_hull(4, 4, 256); + let w = 264u32; let h = 264u32; + let resp = uniform_response(&hull, w, h, 5); // very dark + + let t0 = Instant::now(); + let result = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 0.1); + let elapsed_ms = t0.elapsed().as_millis(); + + assert!(elapsed_ms < 2000, + "gradient_hatch 256×256 dark hull took {}ms — expected <2000ms", elapsed_ms); + assert!(!result.strokes.is_empty(), "should produce strokes"); + } + + #[test] + fn gradient_hatch_perf_ratio_vs_parallel() { + // gradient_hatch should not be more than 20× slower than parallel_hatch on the same hull. + // Uses μs timing; generous multiplier covers debug-mode variance. + use std::time::Instant; + let hull = make_square_hull(4, 4, 128); + let w = 136u32; let h = 136u32; + let resp = uniform_response(&hull, w, h, 128); + + let t_par = { + let t = Instant::now(); + let _ = parallel_hatch(&hull, 5.0, 0.0); + t.elapsed().as_micros().max(1) + }; + let t_grad = { + let t = Instant::now(); + let _ = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 0.5); + t.elapsed().as_micros() + }; + + assert!(t_grad <= t_par * 20 + 50_000, + "gradient_hatch ({t_grad}μs) is >20× slower than parallel_hatch ({t_par}μs)"); + } } diff --git a/src/lib.rs b/src/lib.rs index 76424f4c..56fc5184 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ struct AppState { struct PassState { hulls: Vec, fill_results: Vec, + response_map: Vec, // raw detect output; kept so gradient fills can query it } impl Default for AppState { @@ -293,7 +294,7 @@ fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String { fn process_pass_work( rgb: &image::RgbImage, payload: ProcessPassPayload, -) -> (Vec, ProcessResult) { +) -> (Vec, Vec, ProcessResult) { let t0 = Instant::now(); let mut steps: Vec = Vec::new(); let (w, h) = rgb.dimensions(); @@ -343,11 +344,13 @@ fn process_pass_work( steps.push(StepTime { label: "total".into(), ms: t0.elapsed().as_millis() as u64 }); - (extracted, ProcessResult { hull_count, coverage_pct, viz_b64, node_previews, timings: steps }) + (extracted, response, ProcessResult { hull_count, coverage_pct, viz_b64, node_previews, timings: steps }) } fn generate_fill_work( hulls: Vec, + response_map: Vec, + img_width: u32, payload: FillPayload, ) -> (Vec, FillResult) { use rayon::prelude::*; @@ -359,19 +362,23 @@ fn generate_fill_work( let param = payload.param; let mut steps: Vec = Vec::new(); + // Share the response map across rayon threads without cloning it per-hull + let response_arc: std::sync::Arc<[u8]> = response_map.into(); + let mut t = Instant::now(); let raw_results: Vec = hulls.par_iter().map(|hull| { match strategy.as_str() { - "outline" => fill::outline(hull), - "zigzag" => fill::zigzag_hatch(hull, spacing, angle), - "offset" => fill::contour_offset(hull, spacing), - "spiral" => fill::spiral(hull, spacing), - "circles" => fill::circle_pack(hull, spacing, param.max(0.1)), - "voronoi" => fill::voronoi_fill(hull, spacing), - "hilbert" => fill::hilbert_fill(hull, spacing), - "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize), - "flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)), - _ => fill::parallel_hatch(hull, spacing, angle), + "outline" => fill::outline(hull), + "zigzag" => fill::zigzag_hatch(hull, spacing, angle), + "offset" => fill::contour_offset(hull, spacing), + "spiral" => fill::spiral(hull, spacing), + "circles" => fill::circle_pack(hull, spacing, param.max(0.1)), + "voronoi" => fill::voronoi_fill(hull, spacing), + "hilbert" => fill::hilbert_fill(hull, spacing), + "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize), + "flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)), + "gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_width, spacing, angle, param.clamp(0.05, 1.0)), + _ => fill::parallel_hatch(hull, spacing, angle), } }).collect(); t = lap!(steps, "fill gen", t); @@ -414,7 +421,7 @@ fn load_image(path: String, state: State>) -> Result>) { let mut st = state.lock().unwrap(); - st.passes.resize_with(count, || PassState { hulls: Vec::new(), fill_results: Vec::new() }); + st.passes.resize_with(count, PassState::default); } #[tauri::command] @@ -428,7 +435,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex>) -> Result { let idx = payload.pass_index; - // Clone hulls and release the lock before handing off to the blocking pool. - let hulls = { + // Clone hulls + response map and release the lock before handing off to the blocking pool. + let (hulls, response_map, img_width) = { let st = state.lock().unwrap(); if idx >= st.passes.len() || st.passes[idx].hulls.is_empty() { return Err("Process image first".into()); } - st.passes[idx].hulls.clone() + let w = st.image_rgb.as_ref().map(|i| i.width()).unwrap_or(0); + (st.passes[idx].hulls.clone(), st.passes[idx].response_map.clone(), w) }; let (optimised, result) = tauri::async_runtime::spawn_blocking(move || { - generate_fill_work(hulls, payload) + generate_fill_work(hulls, response_map, img_width, payload) }) .await .map_err(|e| e.to_string())?; @@ -925,7 +934,7 @@ mod blocking_tests { "mutex was blocked during heavy processing" ); - let (hulls, result) = work.await.unwrap(); + let (hulls, _, result) = work.await.unwrap(); assert!(result.timings.iter().any(|t| t.label == "total")); assert!(!hulls.is_empty(), "expected hulls from checkerboard image"); } @@ -934,13 +943,14 @@ mod blocking_tests { #[tokio::test] async fn generate_fill_does_not_hold_mutex_during_computation() { let rgb = synthetic_image(400, 300); - let (hulls, _) = process_pass_work(&rgb, default_process_payload()); + let (hulls, response_map, _) = process_pass_work(&rgb, default_process_payload()); assert!(!hulls.is_empty(), "need hulls to test fill"); + let img_width = rgb.width(); let state = Arc::new(Mutex::new(AppState { image_rgb: Some(rgb), image_path: String::new(), - passes: vec![PassState { hulls: hulls.clone(), fill_results: Vec::new() }], + passes: vec![PassState { hulls: hulls.clone(), fill_results: Vec::new(), response_map: response_map.clone() }], })); // Clone hulls and release lock — mirrors what the command handler does. @@ -961,7 +971,7 @@ mod blocking_tests { }; let work = tokio::task::spawn_blocking(move || { - generate_fill_work(work_hulls, payload) + generate_fill_work(work_hulls, response_map, img_width, payload) }); tokio::time::sleep(Duration::from_millis(5)).await; diff --git a/src/pipeline_bench.rs b/src/pipeline_bench.rs index 62f6e2a2..a7458fe1 100644 --- a/src/pipeline_bench.rs +++ b/src/pipeline_bench.rs @@ -7,7 +7,7 @@ use base64::{engine::general_purpose::STANDARD as B64, Engine}; use trac3r_lib::detect::{DetectionParams, DetectionLayer, DetectionKernel, apply_stack}; use trac3r_lib::hulls::{HullParams, Connectivity, extract_hulls}; -use trac3r_lib::fill::{parallel_hatch, smooth_fill_result, optimize_travel, FillResult}; +use trac3r_lib::fill::{parallel_hatch, gradient_hatch, smooth_fill_result, optimize_travel, FillResult}; fn t(label: &str, start: Instant) -> Instant { println!(" {:40} {:>6}ms", label, start.elapsed().as_millis()); @@ -160,6 +160,18 @@ fn main() { let now = t(&format!("serialize ({}KB JSON)", json.len() / 1024), now); drop(now); + // ── gradient_hatch ──────────────────────────────────────────────────────── + println!("\n[ gradient_hatch (same hulls, min_scale=0.25) ]"); + for min_scale in [0.5f32, 0.25, 0.1] { + let now = Instant::now(); + let raw: Vec = hulls.iter() + .map(|h| gradient_hatch(h, &response, w, 5.0, 0.0, min_scale)) + .collect(); + let strokes: usize = raw.iter().map(|r| r.strokes.len()).sum(); + let now = t(&format!("min_scale={min_scale:.2} ({strokes} strokes)"), now); + drop(now); + } + // ── Summary ─────────────────────────────────────────────────────────────── println!("\n=== SUMMARY ==="); println!(" image: {w}×{h} ({} hull px)", total_px);