Files
vectorcraft/crates/trace/src/logo/field.rs
T
Jax SpenserandClaude Sonnet 5.5 29310175af Image Trace: Flat Logo mode (sub-pixel edges, restored corners, true lines and circles)
A fourth Image Trace mode for flat-colour logos. The existing tracer follows hard pixel
edges, so an outline can only be as good as the pixel grid; here each pixel is read as a
blend of the logo's flat colours, the blend is interpolated smoothly (cubic B-spline), and
every outline is placed where two colours are equally present, to a fraction of a pixel.

Outlines are then cleaned the way a person would:
- corners the blur rounded are put back (line fits either side of a bend, intersected);
- near-straight runs and near-circular arcs become exact lines and arcs (Snap Curves To Lines);
- the rest is fitted with as few cubic Beziers as the tolerance allows (Schneider);
- touching colours overlap by 0.06 px so no hairline gap opens between independent fits;
- shapes are painted biggest first; transparent areas fully enclosed by artwork (letters
  knocked out of a badge, the counters of an "o") are painted white so the logo reads the
  same on any background, or become real holes with Ignore White (each hole belongs to the
  smallest outline containing it);
- an outline whose fit strays more than 1.4 px from the raw contour is fitted plainly.

Colours are found automatically (and completed from pixels no blend of the known colours
explains, so a small accent is not lost) or named with `logoColors: ["#rrggbb", ...]` (white is
always its own class; colours closer than 12 count as one; at most ten). Gradients and photos
are refused with advice to use Color (a flat-colour score of the solid areas, 0.93). A
hard-edged source with no anti-aliasing is softened by under a pixel and fitted with looser
snap tolerances. Images over 1024 px on the long side are averaged down first (further, if
they are busy enough to make the unmixing expensive) and the result is mapped back exactly.
More real outlines than we will fit are refused, not silently truncated.

Also: a "Flat Logo" preset, the mode in the Image Trace panel (Method hidden: it always
stacks), the command docs and MCP prompt, es/fr/it/ru translations for the two new messages
and the mode name, and the ROADMAP.

Never-crash: no unwrap/expect/panic, every size and float is bounded and checked, with
tests for degenerate and extreme input (NaN/infinite parameters, 0x0 and 1x1 images, sparse
outlines, an anchor budget, a 200,000-entry palette) and a 250-run randomised no-panic
property test. The B-spline interpolation follows Unser et al. and Thevenaz et al., and the
curve fitter follows Schneider (Graphics Gems); both are written from the papers.

Measured (local experiment, not in CI): a flat-colour logo's vector original rendered down
to a soft 199x100 px PNG and traced. Mean edge error vs the original at 1600 px wide:
built-in colour presets 2.5 to 3.4 px, Flat Logo 0.78 px (0.48 px from a crisp source);
region overlap 0.88-0.91 vs 0.966. About 0.1 s for a 200 px logo.

Tests: 60 new in the trace crate (unmix, field, contours, geometry, Beziers, corners,
outline fitting, palette, end-to-end on synthetic shapes made in code) and 3 in the engine.
cargo xtask ci passes (fmt, clippy, test, assets, brands, layers, wasm): 4,290 tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 17:57:04 +11:00

229 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Smooth sub-pixel fields: cubic B-spline interpolation of a coverage plane.
//!
//! Method: cubic B-spline interpolation with a recursive prefilter, as described in M. Unser, A.
//! Aldroubi and M. Eden, "B-spline signal processing" (IEEE Trans. Signal Processing, 1993) and P.
//! Thévenaz, T. Blu and M. Unser, "Interpolation revisited" (IEEE Trans. Medical Imaging, 2000).
//! Written from those papers.
//!
//! Interpolating coverage (an area fraction), not colour, and then comparing classes, puts an
//! edge where two classes are equally present, which is accurate to a fraction of a pixel.
/// Gaussian blur of a `w × h` plane (σ in pixels, edges repeated). Linear, so per-pixel weights that
/// sum to one across planes still do.
pub(crate) fn blur_plane(plane: &mut [f32], w: usize, h: usize, sigma: f64) {
if w == 0 || h == 0 || plane.len() < w * h || sigma.is_nan() || sigma <= 0.0 {
return;
}
let r = (3.0 * sigma).ceil() as i64;
let k: Vec<f64> = (-r..=r).map(|i| (-0.5 * (i as f64 / sigma).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
let at = |src: &[f32], x: i64, y: i64| {
f64::from(src.get(y.clamp(0, h as i64 - 1) as usize * w + x.clamp(0, w as i64 - 1) as usize).copied().unwrap_or(0.0))
};
let mut tmp = vec![0.0f32; w * h];
for y in 0..h as i64 {
for x in 0..w as i64 {
let v: f64 = k.iter().enumerate().map(|(j, kv)| kv * at(plane, x + j as i64 - r, y)).sum();
if let Some(d) = tmp.get_mut(y as usize * w + x as usize) {
*d = (v / sum) as f32;
}
}
}
for y in 0..h as i64 {
for x in 0..w as i64 {
let v: f64 = k.iter().enumerate().map(|(j, kv)| kv * at(&tmp, x, y + j as i64 - r)).sum();
if let Some(d) = plane.get_mut(y as usize * w + x as usize) {
*d = (v / sum) as f32;
}
}
}
}
/// Replace samples by cubic B-spline coefficients so that interpolation passes through them
/// (Unser's recursive filter, mirror boundaries).
fn prefilter_line(c: &mut [f64]) {
let n = c.len();
if n < 2 {
return;
}
let z = 3.0f64.sqrt() - 2.0;
let gain = (1.0 - z) * (1.0 - 1.0 / z);
for v in c.iter_mut() {
*v *= gain;
}
// Causal initial value: the exact mirror-boundary sum for short lines, a truncated one for long.
let at = |k: usize| c.get(k).copied().unwrap_or(0.0);
let horizon = ((1e-9f64).ln() / z.abs().ln()).ceil() as usize;
let first = if horizon < n {
let mut zk = z;
let mut sum = at(0);
for k in 1..horizon {
sum += zk * at(k);
zk *= z;
}
sum
} else {
let iz = 1.0 / z;
let mut zk = z;
let mut z2n = z.powi(n as i32 - 1);
let mut sum = at(0) + z2n * at(n - 1);
z2n = z2n * z2n * iz;
for k in 1..n - 1 {
sum += (zk + z2n) * at(k);
zk *= z;
z2n *= iz;
}
sum / (1.0 - zk * zk)
};
if let Some(v) = c.first_mut() {
*v = first;
}
for k in 1..n {
let prev = c.get(k - 1).copied().unwrap_or(0.0);
if let Some(v) = c.get_mut(k) {
*v += z * prev;
}
}
let (a, b) = (c.get(n - 2).copied().unwrap_or(0.0), c.get(n - 1).copied().unwrap_or(0.0));
if let Some(last) = c.last_mut() {
*last = (z / (z * z - 1.0)) * (z * a + b);
}
for k in (0..n - 1).rev() {
let next = c.get(k + 1).copied().unwrap_or(0.0);
if let Some(v) = c.get_mut(k) {
*v = z * (next - *v);
}
}
}
/// B-spline coefficients of a `w × h` plane.
fn coefficients(plane: &[f32], w: usize, h: usize) -> Vec<f64> {
let mut c: Vec<f64> = plane.iter().map(|&v| f64::from(v)).collect();
for row in c.chunks_mut(w.max(1)) {
prefilter_line(row);
}
let mut col = vec![0.0f64; h];
for x in 0..w {
for (y, v) in col.iter_mut().enumerate() {
*v = c.get(y * w + x).copied().unwrap_or(0.0);
}
prefilter_line(&mut col);
for (y, v) in col.iter().enumerate() {
if let Some(dst) = c.get_mut(y * w + x) {
*dst = *v;
}
}
}
c
}
/// First tap and the four cubic B-spline weights for every output sample along one axis.
fn taps(n_in: usize, u: usize) -> Vec<(i64, [f64; 4])> {
(0..n_in * u)
.map(|i| {
let x = (i as f64 + 0.5) / u as f64 - 0.5;
let i0 = x.floor();
let t = x - i0;
let t2 = t * t;
let t3 = t2 * t;
let w = [(1.0 - t).powi(3) / 6.0, (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0, (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0, t3 / 6.0];
(i0 as i64 - 1, w)
})
.collect()
}
/// The plane smoothly upsampled `u` times (sample centres stay aligned), clamped to `0..=1`.
pub(crate) fn upsample(plane: &[f32], w: usize, h: usize, u: usize) -> Vec<f32> {
if w == 0 || h == 0 || u == 0 || plane.len() < w * h {
return Vec::new();
}
let coef = coefficients(plane, w, h);
let (tx, ty) = (taps(w, u), taps(h, u));
let ow = w * u;
// Whole-sample mirror boundaries, matching the prefilter.
let clampi = |v: i64, n: usize| {
if n < 2 {
return 0;
}
let period = 2 * (n as i64 - 1);
let m = v.rem_euclid(period);
(if m < n as i64 { m } else { period - m }) as usize
};
let mut tmp = vec![0.0f64; h * ow];
for y in 0..h {
let row = coef.get(y * w..(y + 1) * w).unwrap_or(&[]);
for (ox, &(base, wt)) in tx.iter().enumerate() {
let mut s = 0.0;
for (j, wj) in wt.iter().enumerate() {
s += wj * row.get(clampi(base + j as i64, w)).copied().unwrap_or(0.0);
}
if let Some(d) = tmp.get_mut(y * ow + ox) {
*d = s;
}
}
}
let mut out = vec![0.0f32; ow * h * u];
for (oy, &(base, wt)) in ty.iter().enumerate() {
let rows: [usize; 4] = std::array::from_fn(|j| clampi(base + j as i64, h));
for ox in 0..ow {
let mut s = 0.0;
for (j, wj) in wt.iter().enumerate() {
s += wj * tmp.get(rows[j] * ow + ox).copied().unwrap_or(0.0);
}
if let Some(d) = out.get_mut(oy * ow + ox) {
*d = s.clamp(0.0, 1.0) as f32;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_constant_plane_stays_constant() {
let up = upsample(&[0.5; 6 * 5], 6, 5, 4);
assert_eq!(up.len(), 24 * 20);
assert!(up.iter().all(|&v| (v - 0.5).abs() < 1e-4));
}
#[test]
fn interpolation_passes_through_the_samples() {
// Sample (x, y) lives at the centre of its u×u block, i.e. between output samples; with
// u = 1 the output is the input.
let plane: Vec<f32> = (0..30).map(|i| ((i * 7) % 11) as f32 / 11.0).collect();
let up = upsample(&plane, 6, 5, 1);
for (a, b) in plane.iter().zip(&up) {
assert!((a - b).abs() < 1e-4, "{a} vs {b}");
}
}
#[test]
fn a_blurred_step_crosses_half_where_the_edge_is() {
// A vertical edge half-way through column 4: columns 0..4 are 0, column 4 is 0.5, then 1.
let (w, h) = (9, 3);
let plane: Vec<f32> = (0..w * h)
.map(|i| match i % w {
0..=3 => 0.0,
4 => 0.5,
_ => 1.0,
})
.collect();
let u = 8;
let up = upsample(&plane, w, h, u);
let row = &up[(h * u / 2) * (w * u)..(h * u / 2 + 1) * (w * u)];
let x = row.iter().position(|&v| v >= 0.5).unwrap_or(0);
// The 0.5 crossing sits at the centre of column 4 → output sample 4.5 * u = 36.
assert!((x as f64 - 36.0).abs() <= 1.5, "crossing at {x}");
}
#[test]
fn degenerate_input_is_empty_not_a_panic() {
assert!(upsample(&[], 0, 0, 4).is_empty());
assert!(upsample(&[1.0], 1, 1, 0).is_empty());
assert!(upsample(&[1.0], 2, 2, 3).is_empty());
}
}