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:
@@ -1590,6 +1590,12 @@ pub const SINGLE_STROKE_LETTERS: &str = "CGIJLMNOSUVWZcejilosvwz";
|
||||
/// penalty applies when stroke count ≠ 2.
|
||||
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 {
|
||||
SINGLE_STROKE_LETTERS.contains(ch)
|
||||
}
|
||||
@@ -1598,6 +1604,36 @@ pub fn is_two_stroke_letter(ch: char) -> bool {
|
||||
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
|
||||
/// failures. A config that trips ANY hard ceiling returns f32::MAX so
|
||||
/// 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")
|
||||
/// - SINGLE_STROKE_LETTERS with strokes ≠ 1 → +50k per extra stroke
|
||||
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
|
||||
// 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 {
|
||||
let budget = 1.5 * m.skeleton_length as f32;
|
||||
let excess = (m.total_length - budget).max(0.0);
|
||||
let density: f32 = m.unpainted_clusters.iter()
|
||||
.map(|&n| (n as f32).powf(1.5))
|
||||
.sum();
|
||||
let density = unpainted_density_score(&m.unpainted_clusters, m.brush_radius);
|
||||
w.stroke * m.strokes as f32
|
||||
+ w.length * m.total_length
|
||||
+ w.bg * m.bg_painted as f32
|
||||
|
||||
@@ -18,7 +18,8 @@ use serde::{Serialize, Deserialize};
|
||||
use crate::brush_paint::{
|
||||
PaintParams, PaintMetrics, ScoreWeights,
|
||||
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;
|
||||
|
||||
@@ -306,9 +307,12 @@ impl CorpusReport {
|
||||
r.total_strokes += m.strokes as u64;
|
||||
r.total_repaint += m.repaint as u64;
|
||||
r.total_length += m.total_length as f64;
|
||||
for &cz in &m.unpainted_clusters {
|
||||
r.total_unpainted_density += (cz as f64).powf(1.5);
|
||||
}
|
||||
// Same exponential shape used in `score_weighted` so the
|
||||
// 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
|
||||
}
|
||||
@@ -469,7 +473,11 @@ pub fn evaluate_score_weights(
|
||||
/// can degenerate to "paint nothing" under low coverage weight).
|
||||
fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 {
|
||||
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 is_single_stroke_letter(ch) && m.strokes != 1 {
|
||||
s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32);
|
||||
|
||||
Reference in New Issue
Block a user