brush-paint-opt: letter-conditional curvature + exponential cluster penalty

Curvature: now zeroed for non-straight letters (O/S/G/c/e/...).
Only STRAIGHT_STROKE_LETTERS = "AEFHIKLMNTVWXYZilvwxz" pay the
curvature term in `score_for_letter` and the meta-opt inner score.
Curvy glyphs were taking a uniform penalty for their natural form;
length-excess still keeps wandering paths in check there.

Unpainted cluster density: switched from Σ size^1.5 to a true
exponential `(exp(α × size / brush_area) − 1) × brush_area` with
α=2.0 fixed. Scale-invariant in the brush. A 2×-brush cluster now
costs ~8× a 1×-brush cluster (vs ~3× under the old shape) — small
edge slop stays roughly free, big missing-feature blobs become
overwhelmingly expensive. Same shape used by `score_weighted` and
`CorpusReport`'s tier-2 aggregate so the inner soft signal and the
outer lex comparator agree.
This commit is contained in:
Mitchell Hansen
2026-05-05 22:50:18 -07:00
parent e53d73d5cb
commit 357db2b061
2 changed files with 57 additions and 9 deletions

View File

@@ -1590,6 +1590,12 @@ pub const SINGLE_STROKE_LETTERS: &str = "CGIJLMNOSUVWZcejilosvwz";
/// penalty applies when stroke count ≠ 2. /// penalty applies when stroke count ≠ 2.
pub const TWO_STROKE_LETTERS: &str = "TtXxKkYyFfHh"; pub const TWO_STROKE_LETTERS: &str = "TtXxKkYyFfHh";
/// Letters made entirely of straight strokes — the curvature penalty
/// fires only on these. Curvy letters (O/S/G/c/e/...) need to bend, so
/// applying a uniform curvature cost there penalised the natural form.
/// Excludes ambiguous cases (lowercase k/t/f) where fonts may curve.
pub const STRAIGHT_STROKE_LETTERS: &str = "AEFHIKLMNTVWXYZilvwxz";
pub fn is_single_stroke_letter(ch: char) -> bool { pub fn is_single_stroke_letter(ch: char) -> bool {
SINGLE_STROKE_LETTERS.contains(ch) SINGLE_STROKE_LETTERS.contains(ch)
} }
@@ -1598,6 +1604,36 @@ pub fn is_two_stroke_letter(ch: char) -> bool {
TWO_STROKE_LETTERS.contains(ch) TWO_STROKE_LETTERS.contains(ch)
} }
pub fn is_straight_letter(ch: char) -> bool {
STRAIGHT_STROKE_LETTERS.contains(ch)
}
/// Exponential rate for the unpainted-cluster penalty. Per-cluster
/// cost is `(exp(α × size / brush_area) 1) × brush_area`. The shape
/// is scale-invariant in the brush. At α=2.0:
/// - 1-px cluster ≈ 2 units
/// - cluster = brush_area → ~6.4 × brush_area
/// - cluster = 2 × brush_area → ~53 × brush_area
/// - cluster = 3 × brush_area → ~400 × brush_area
/// So one missing tail-leg (a multi-brush blob) outweighs hundreds
/// of single-pixel slop edges, which matches the eye's response.
pub const UNPAINTED_CLUSTER_ALPHA: f32 = 2.0;
/// Density penalty across one letter's cluster sizes. Same shape used
/// in `score_weighted` and in `CorpusReport`'s tier-2 aggregate, so the
/// inner optimiser and the outer lex comparator agree on what "bad
/// unpainted distribution" means.
pub fn unpainted_density_score(clusters: &[u32], brush_radius: f32) -> f32 {
let brush_area = std::f32::consts::PI * brush_radius * brush_radius;
if brush_area <= 0.0 { return 0.0; }
clusters.iter().map(|&n| {
// Clamp the exponent so a runaway cluster doesn't overflow f32
// (exp(20) ≈ 4.85e8; multiplied by brush_area still finite).
let exponent = (UNPAINTED_CLUSTER_ALPHA * n as f32 / brush_area).min(20.0);
(exponent.exp() - 1.0) * brush_area
}).sum()
}
/// Letter-aware score: applies the default score plus hard constraint /// Letter-aware score: applies the default score plus hard constraint
/// failures. A config that trips ANY hard ceiling returns f32::MAX so /// failures. A config that trips ANY hard ceiling returns f32::MAX so
/// the optimizer rejects it outright. Soft knobs (brush_size bonus, /// the optimizer rejects it outright. Soft knobs (brush_size bonus,
@@ -1613,7 +1649,13 @@ pub fn is_two_stroke_letter(ch: char) -> bool {
/// - 0 strokes → +200k (refuse "paint nothing") /// - 0 strokes → +200k (refuse "paint nothing")
/// - SINGLE_STROKE_LETTERS with strokes ≠ 1 → +50k per extra stroke /// - SINGLE_STROKE_LETTERS with strokes ≠ 1 → +50k per extra stroke
pub fn score_for_letter(ch: char, m: &PaintMetrics) -> f32 { pub fn score_for_letter(ch: char, m: &PaintMetrics) -> f32 {
let mut s = default_score(m); // Curvature penalty fires only on straight-stroke letters. For
// curvy letters (O/S/G/c/e/...) bending IS the natural form, so
// penalising it pushes the optimizer toward zigzag approximations.
// The length-excess term still keeps wandering paths in check.
let mut w = ScoreWeights::default();
if !is_straight_letter(ch) { w.curvature = 0.0; }
let mut s = score_weighted(m, w);
// Hard-ceiling barriers. They're "soft" in the sense that they're // Hard-ceiling barriers. They're "soft" in the sense that they're
// finite (not f32::MAX) so the optimizer can still gradient-descend // finite (not f32::MAX) so the optimizer can still gradient-descend
@@ -1744,9 +1786,7 @@ impl Default for ScoreWeights {
pub fn score_weighted(m: &PaintMetrics, w: ScoreWeights) -> f32 { pub fn score_weighted(m: &PaintMetrics, w: ScoreWeights) -> f32 {
let budget = 1.5 * m.skeleton_length as f32; let budget = 1.5 * m.skeleton_length as f32;
let excess = (m.total_length - budget).max(0.0); let excess = (m.total_length - budget).max(0.0);
let density: f32 = m.unpainted_clusters.iter() let density = unpainted_density_score(&m.unpainted_clusters, m.brush_radius);
.map(|&n| (n as f32).powf(1.5))
.sum();
w.stroke * m.strokes as f32 w.stroke * m.strokes as f32
+ w.length * m.total_length + w.length * m.total_length
+ w.bg * m.bg_painted as f32 + w.bg * m.bg_painted as f32

View File

@@ -18,7 +18,8 @@ use serde::{Serialize, Deserialize};
use crate::brush_paint::{ use crate::brush_paint::{
PaintParams, PaintMetrics, ScoreWeights, PaintParams, PaintMetrics, ScoreWeights,
score_for_letter, metrics_for, rasterize_test_letter, score_for_letter, metrics_for, rasterize_test_letter,
is_single_stroke_letter, is_two_stroke_letter, is_single_stroke_letter, is_two_stroke_letter, is_straight_letter,
unpainted_density_score,
}; };
use crate::hulls::Hull; use crate::hulls::Hull;
@@ -306,9 +307,12 @@ impl CorpusReport {
r.total_strokes += m.strokes as u64; r.total_strokes += m.strokes as u64;
r.total_repaint += m.repaint as u64; r.total_repaint += m.repaint as u64;
r.total_length += m.total_length as f64; r.total_length += m.total_length as f64;
for &cz in &m.unpainted_clusters { // Same exponential shape used in `score_weighted` so the
r.total_unpainted_density += (cz as f64).powf(1.5); // inner soft signal and the outer lex ranking agree on what
} // "bad unpainted distribution" means.
r.total_unpainted_density += unpainted_density_score(
&m.unpainted_clusters, m.brush_radius
) as f64;
} }
r r
} }
@@ -469,7 +473,11 @@ pub fn evaluate_score_weights(
/// can degenerate to "paint nothing" under low coverage weight). /// can degenerate to "paint nothing" under low coverage weight).
fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 { fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 {
use crate::brush_paint::score_weighted; use crate::brush_paint::score_weighted;
let mut s = score_weighted(m, *w); // Curvature is letter-conditional: only straight-stroke glyphs
// (AEFHIKLMNTVWXYZilvwxz) pay it. Mirror of `score_for_letter`.
let mut w_local = *w;
if !is_straight_letter(ch) { w_local.curvature = 0.0; }
let mut s = score_weighted(m, w_local);
if m.strokes == 0 { s += 200_000.0; } if m.strokes == 0 { s += 200_000.0; }
if is_single_stroke_letter(ch) && m.strokes != 1 { if is_single_stroke_letter(ch) && m.strokes != 1 {
s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32); s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32);