From e172d5703e3bef7035041b52fa3c8c0643a5ffe6 Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Wed, 6 May 2026 15:47:20 -0700 Subject: [PATCH] brush-paint: stop silent-zeroing sub-threshold unpainted; expose back_dir_cutoff as tunable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes the meta-opt needs before it can give meaningful results: 1. pick_next_component no longer flips sub-min_component_pixels components off the unpainted mask without emitting a stroke. Those pixels were missing from the rendered output but counted as "painted" by the metric, so the optimizer trained against a lying coverage signal. Across the corpus this hid 8-18 percentage points of true uncoverage. Now sub-threshold components stay in the mask and are counted truthfully against unpainted_density / cov_fail. 2. The hard-coded -0.7 back-direction filter in walk_brush is now a PaintParams field (back_dir_cutoff, default -0.7). cos(135°) ≈ -0.71, so the prior cutoff was right at M/W/A's apex angle — marginal pass at best. Made it a tunable axis (range -0.95..-0.3) so the optimizer can find a value that lets the walker take the sharp turns these letters need without breaking softer corners. --- src-frontend/src/hooks/useTauri.js | 1 + src/brush_paint.rs | 41 +++++++++++++++--------------- src/brush_paint_opt.rs | 2 ++ 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src-frontend/src/hooks/useTauri.js b/src-frontend/src/hooks/useTauri.js index 600ed982..01a7be00 100644 --- a/src-frontend/src/hooks/useTauri.js +++ b/src-frontend/src/hooks/useTauri.js @@ -47,6 +47,7 @@ export const DEFAULT_PAINT_PARAMS = { overpaint_penalty: 0.10, walk_bg_penalty: 0.69, min_score_factor: 0.20, + back_dir_cutoff: -0.7, polish_iters: 2, polish_search_factor: 0.5, bg_penalty: 2.0, diff --git a/src/brush_paint.rs b/src/brush_paint.rs index 4acb607e..ff5fd778 100644 --- a/src/brush_paint.rs +++ b/src/brush_paint.rs @@ -96,6 +96,12 @@ pub struct PaintParams { /// fraction of the brush area (e.g. 0.05 = "stop when no direction /// adds even 5% of a fresh disk worth of new coverage"). pub min_score_factor: f32, + /// Reject candidate directions whose `dot(dir, prev_dir)` is below + /// this value. cos(135°) ≈ -0.71 — at -0.7 the walker just barely + /// rejects the 135° turns that M/W/A apexes need. Closer to -1 + /// admits sharper turns (down to direct reversals); closer to 0 + /// rejects mild backward components. + pub back_dir_cutoff: f32, /// Number of relax↔shorten tick-tock rounds after the bidirectional /// walk. Each round runs (a) waypoint relaxation toward unpainted ink, /// then (b) waypoint pruning where it doesn't lose coverage. 0 disables. @@ -156,6 +162,7 @@ impl Default for PaintParams { overpaint_penalty: 0.10, walk_bg_penalty: 0.69, min_score_factor: 0.20, + back_dir_cutoff: -0.7, polish_iters: 2, polish_search_factor: 0.5, bg_penalty: 2.0, @@ -711,11 +718,13 @@ impl Grid { /// Pick the next stroke's start by analysing the connected components /// of remaining unpainted ink. Components smaller than - /// `min_component_pixels` are not worth a separate stroke — we paint - /// them with a single disk stamp here and skip. The largest - /// substantial component (writing-order tie-broken: topmost first, - /// then leftmost) yields the seed; we use its highest-SDF interior - /// pixel and then ridge-snap so the brush starts on the centerline. + /// `min_component_pixels` are skipped as seed candidates — they're + /// too small for a separate stroke to walk effectively. They stay in + /// the unpainted mask so the metric reports them truthfully and the + /// optimizer sees gaps. The largest substantial component + /// (writing-order tie-broken: topmost first, then leftmost) yields + /// the seed; we use its highest-SDF interior pixel and then + /// ridge-snap so the brush starts on the centerline. /// /// Returns `None` once nothing remains worth painting, which lets /// `paint_fill` exit cleanly instead of burning through max_strokes @@ -759,22 +768,14 @@ impl Grid { } if components.is_empty() { return None; } - // Drop sub-threshold components: paint them with a single disk - // stamp at their centroid and forget about them. They were just - // mask-edge artifacts from the previous stroke's brush sweep. + // Skip sub-threshold components as seed candidates — they're + // too small for a separate stroke to walk effectively. Leave + // them in the unpainted mask so they count against coverage and + // the optimizer sees them. Above-threshold components compete + // for the seed via writing-order tie-break. let mut best: Option<(usize, (i32, i32))> = None; // (component_idx, (top, left)) for (i, (pixels, (top, left, _, _))) in components.iter().enumerate() { - if (pixels.len() as u32) < min_component_pixels { - // Paint each pixel once and move on (fast — pixels are - // already unpainted, so just flip them off and decrement). - for &idx in pixels { - if self.unpainted[idx] { - self.unpainted[idx] = false; - self.ink_remaining -= 1; - } - } - continue; - } + if (pixels.len() as u32) < min_component_pixels { continue; } // Writing-order priority: topmost; then leftmost. match best { None => best = Some((i, (*top, *left))), @@ -999,7 +1000,7 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>, let theta = 2.0 * std::f32::consts::PI * i as f32 / params.n_directions as f32; let dir = (theta.cos(), theta.sin()); let probe = (p.0 + dir.0 * step_size, p.1 + dir.1 * step_size); - let rejected_back = has_momentum && vec_dot(dir, prev_dir_unit) < -0.7; + let rejected_back = has_momentum && vec_dot(dir, prev_dir_unit) < params.back_dir_cutoff; let rejected_off_ink = !grid.is_ink(probe.0.round() as i32, probe.1.round() as i32); // Compute breakdown either way (cheap-ish; lets the viz show diff --git a/src/brush_paint_opt.rs b/src/brush_paint_opt.rs index 7a808de4..587f8d76 100644 --- a/src/brush_paint_opt.rs +++ b/src/brush_paint_opt.rs @@ -62,6 +62,8 @@ pub fn default_axes() -> Vec { set: |p, v| p.momentum_weight = v, get: |p| p.momentum_weight }, Axis { name: "min_score_factor", lo: 0.0, hi: 0.30, is_int: false, set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor }, + Axis { name: "back_dir_cutoff", lo: -0.95, hi: -0.3, is_int: false, + set: |p, v| p.back_dir_cutoff = v, get: |p| p.back_dir_cutoff }, Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false, set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor }, Axis { name: "pen_lift_penalty", lo: 0.0, hi: 200.0, is_int: false,