brush-paint: stop silent-zeroing sub-threshold unpainted; expose back_dir_cutoff as tunable

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.
This commit is contained in:
Mitchell Hansen
2026-05-06 15:47:20 -07:00
parent 901c851b08
commit e172d5703e
3 changed files with 24 additions and 20 deletions

View File

@@ -47,6 +47,7 @@ export const DEFAULT_PAINT_PARAMS = {
overpaint_penalty: 0.10, overpaint_penalty: 0.10,
walk_bg_penalty: 0.69, walk_bg_penalty: 0.69,
min_score_factor: 0.20, min_score_factor: 0.20,
back_dir_cutoff: -0.7,
polish_iters: 2, polish_iters: 2,
polish_search_factor: 0.5, polish_search_factor: 0.5,
bg_penalty: 2.0, bg_penalty: 2.0,

View File

@@ -96,6 +96,12 @@ pub struct PaintParams {
/// fraction of the brush area (e.g. 0.05 = "stop when no direction /// fraction of the brush area (e.g. 0.05 = "stop when no direction
/// adds even 5% of a fresh disk worth of new coverage"). /// adds even 5% of a fresh disk worth of new coverage").
pub min_score_factor: f32, 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 /// Number of relax↔shorten tick-tock rounds after the bidirectional
/// walk. Each round runs (a) waypoint relaxation toward unpainted ink, /// walk. Each round runs (a) waypoint relaxation toward unpainted ink,
/// then (b) waypoint pruning where it doesn't lose coverage. 0 disables. /// then (b) waypoint pruning where it doesn't lose coverage. 0 disables.
@@ -156,6 +162,7 @@ impl Default for PaintParams {
overpaint_penalty: 0.10, overpaint_penalty: 0.10,
walk_bg_penalty: 0.69, walk_bg_penalty: 0.69,
min_score_factor: 0.20, min_score_factor: 0.20,
back_dir_cutoff: -0.7,
polish_iters: 2, polish_iters: 2,
polish_search_factor: 0.5, polish_search_factor: 0.5,
bg_penalty: 2.0, bg_penalty: 2.0,
@@ -711,11 +718,13 @@ impl Grid {
/// Pick the next stroke's start by analysing the connected components /// Pick the next stroke's start by analysing the connected components
/// of remaining unpainted ink. Components smaller than /// of remaining unpainted ink. Components smaller than
/// `min_component_pixels` are not worth a separate stroke — we paint /// `min_component_pixels` are skipped as seed candidates — they're
/// them with a single disk stamp here and skip. The largest /// too small for a separate stroke to walk effectively. They stay in
/// substantial component (writing-order tie-broken: topmost first, /// the unpainted mask so the metric reports them truthfully and the
/// then leftmost) yields the seed; we use its highest-SDF interior /// optimizer sees gaps. The largest substantial component
/// pixel and then ridge-snap so the brush starts on the centerline. /// (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 /// Returns `None` once nothing remains worth painting, which lets
/// `paint_fill` exit cleanly instead of burning through max_strokes /// `paint_fill` exit cleanly instead of burning through max_strokes
@@ -759,22 +768,14 @@ impl Grid {
} }
if components.is_empty() { return None; } if components.is_empty() { return None; }
// Drop sub-threshold components: paint them with a single disk // Skip sub-threshold components as seed candidates — they're
// stamp at their centroid and forget about them. They were just // too small for a separate stroke to walk effectively. Leave
// mask-edge artifacts from the previous stroke's brush sweep. // 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)) let mut best: Option<(usize, (i32, i32))> = None; // (component_idx, (top, left))
for (i, (pixels, (top, left, _, _))) in components.iter().enumerate() { for (i, (pixels, (top, left, _, _))) in components.iter().enumerate() {
if (pixels.len() as u32) < min_component_pixels { if (pixels.len() as u32) < min_component_pixels { continue; }
// 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;
}
// Writing-order priority: topmost; then leftmost. // Writing-order priority: topmost; then leftmost.
match best { match best {
None => best = Some((i, (*top, *left))), 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 theta = 2.0 * std::f32::consts::PI * i as f32 / params.n_directions as f32;
let dir = (theta.cos(), theta.sin()); let dir = (theta.cos(), theta.sin());
let probe = (p.0 + dir.0 * step_size, p.1 + dir.1 * step_size); 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); 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 // Compute breakdown either way (cheap-ish; lets the viz show

View File

@@ -62,6 +62,8 @@ pub fn default_axes() -> Vec<Axis> {
set: |p, v| p.momentum_weight = v, get: |p| p.momentum_weight }, 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, 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 }, 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, 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 }, 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, Axis { name: "pen_lift_penalty", lo: 0.0, hi: 200.0, is_int: false,