feat: gradient_hatch fill — density driven by response map

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<u8>, 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:05:10 -07:00
parent c5a2c51e9f
commit 11fa0bb86f
4 changed files with 253 additions and 27 deletions

View File

@@ -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.
@@ -15,10 +15,12 @@ export const FILL_STRATEGY_PARAMS = {
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' },
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 {

View File

@@ -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<i32, Vec<(f32, u8)>> = 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<u8> {
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<usize> = [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)");
}
}

View File

@@ -30,6 +30,7 @@ struct AppState {
struct PassState {
hulls: Vec<hulls::Hull>,
fill_results: Vec<fill::FillResult>,
response_map: Vec<u8>, // 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<hulls::Hull>, ProcessResult) {
) -> (Vec<hulls::Hull>, Vec<u8>, ProcessResult) {
let t0 = Instant::now();
let mut steps: Vec<StepTime> = 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<hulls::Hull>,
response_map: Vec<u8>,
img_width: u32,
payload: FillPayload,
) -> (Vec<fill::FillResult>, FillResult) {
use rayon::prelude::*;
@@ -359,6 +362,9 @@ fn generate_fill_work(
let param = payload.param;
let mut steps: Vec<StepTime> = 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<fill::FillResult> = hulls.par_iter().map(|hull| {
match strategy.as_str() {
@@ -371,6 +377,7 @@ fn generate_fill_work(
"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();
@@ -414,7 +421,7 @@ fn load_image(path: String, state: State<Mutex<AppState>>) -> Result<ImageInfo,
#[tauri::command]
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
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<AppSta
let idx = payload.pass_index;
let (new_hulls, result) = tauri::async_runtime::spawn_blocking(move || {
let (new_hulls, response_map, result) = tauri::async_runtime::spawn_blocking(move || {
process_pass_work(&rgb, payload)
})
.await
@@ -440,6 +447,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
}
st.passes[idx].hulls = new_hulls;
st.passes[idx].fill_results = Vec::new();
st.passes[idx].response_map = response_map;
Ok(result)
}
@@ -448,17 +456,18 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
async fn generate_fill(payload: FillPayload, state: State<'_, Mutex<AppState>>) -> Result<FillResult, String> {
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;

View File

@@ -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<FillResult> = 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);