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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user