Files
vectorcraft/crates/trace/src/smooth.rs
T
bflatastic 77f81c8944 Image Trace: photos are smoothed within their tones first, and stacked layers go by lightness
A photo's noise is as large as the step from one palette colour to the next, so noise decided pixel by pixel
which of two neighbouring colours a pixel got: tens of thousands of specks, or (since the palette change)
neighbouring tones merged into one.

- A continuous-tone image (eight colours leave much of it unsaid; flat art, however noisy or unevenly lit, is
  not one) is smoothed before it is quantised: three rounds of a small bilateral filter, which averages noise
  away within a tone and keeps the edges between tones where and as sharp as they were.
- With Method: Overlapping, layers are stacked by lightness, from the largest colour's end of the scale,
  instead of by area. Each layer reaches under those above it, so where two colours meet every layer between
  them in the stack ends on that same edge and shows through its anti-aliasing: by area that was an unrelated
  colour's hairline around every region; by lightness it is a neighbouring tone.
- Ignore White with Overlapping: no layer reaches under the white that is left out.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- High Fidelity Photo: colour error 2.37 -> 1.75 (2.30 at the baseline), edge error 2.59 -> 1.98, 639 -> 121
  paths (3,653 at the baseline), 25,604 -> 9,818 anchors, specks 152 -> 10; a 16 megapixel photo-like image is
  traced in 3.5 s (31,131 paths) where it was refused
- Low Fidelity Photo: colour error 2.67 -> 2.45, 55 -> 37 paths
- Shades of Gray: 73 -> 17 paths, colour error 9.33 -> 9.26
- The other presets' numbers are unchanged
2026-10-13 04:28:02 +07:00

98 lines
4.2 KiB
Rust

//! Edge-preserving smoothing of continuous-tone images before they are quantised.
//!
//! A photo's tones run into each other, and its noise is as large as the step from one palette
//! colour to the next: quantised as it is, noise decides pixel by pixel which of two neighbouring
//! colours a pixel gets, and the trace is tens of thousands of specks. Each pixel is averaged with
//! its neighbours instead, each weighted by how alike it is (a bilateral filter: C. Tomasi and
//! R. Manduchi, "Bilateral Filtering for Gray and Color Images", 1998), so that noise within a tone
//! is averaged away while an edge between two tones stays where and as sharp as it was. Three
//! rounds, each along rows and then along columns, three pixels at a time.
use crate::{Raster, parallel_map};
/// Colours this far apart (levels, all channels together) count half as much as equal ones.
const ALIKE: f64 = 20.0;
/// One pass along rows (`dx = 1`) or columns (`dx = 0`).
fn pass(src: &[u8], w: usize, h: usize, along_rows: bool) -> Vec<u8> {
let rows: Vec<usize> = (0..h).collect();
let px = |x: usize, y: usize| src.get((y * w + x) * 4..(y * w + x) * 4 + 4);
let done = parallel_map(&rows, |&y| {
let mut out = Vec::with_capacity(w * 4);
for x in 0..w {
let Some(p) = px(x, y) else { continue };
if p[3] < 128 {
out.extend_from_slice(p);
continue;
}
let near = if along_rows {
[(x > 0).then(|| (x - 1, y)), (x + 1 < w).then(|| (x + 1, y))]
} else {
[(y > 0).then(|| (x, y - 1)), (y + 1 < h).then(|| (x, y + 1))]
};
let (mut sum, mut weight) = ([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])], 1.0);
for q in near.into_iter().flatten().filter_map(|(x, y)| px(x, y)).filter(|q| q[3] >= 128) {
let d2: f64 = (0..3).map(|k| (f64::from(q[k]) - f64::from(p[k])).powi(2)).sum();
let wt = 1.0 / (1.0 + d2 / (ALIKE * ALIKE)).powi(2);
for k in 0..3 {
sum[k] += wt * f64::from(q[k]);
}
weight += wt;
}
out.extend(sum.map(|v| (v / weight).round() as u8));
out.push(p[3]);
}
out
});
done.concat()
}
/// `img` with the noise within its tones smoothed away and its edges kept.
pub(crate) fn smoothed(img: &Raster) -> Raster {
let (w, h) = (img.width as usize, img.height as usize);
let mut rgba = img.rgba.clone();
if rgba.len() == w * h * 4 {
for _ in 0..3 {
rgba = pass(&pass(&rgba, w, h, true), w, h, false);
}
}
Raster { width: img.width, height: img.height, rgba }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noise_goes_and_an_edge_stays() {
// Left half about 60, right half about 160, with noise of ±6 on both.
let img = Raster::from_fn(40, 20, |x, y| {
let mut n = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77);
n ^= n >> 15;
n = n.wrapping_mul(0x2C1B_3C6D);
let n = ((n >> 12) % 13) as u8;
let v = if x < 20 { 54 + n } else { 154 + n };
[v, v, v, 255]
});
let out = smoothed(&img);
let spread = |r: &Raster, x0: u32, x1: u32| {
let v: Vec<u8> = (0..20).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)[0]).collect();
v.iter().max().unwrap() - v.iter().min().unwrap()
};
assert_eq!(spread(&img, 0, 20), 12);
assert!(spread(&out, 0, 20) <= 6 && spread(&out, 20, 40) <= 6, "{} {}", spread(&out, 0, 20), spread(&out, 20, 40));
// The edge is still one pixel sharp, where it was.
assert!(out.pixel(19, 10)[0] < 70 && out.pixel(20, 10)[0] > 150);
}
#[test]
fn transparent_pixels_and_odd_sizes_pass_through() {
let img = Raster::from_fn(3, 1, |x, _| [10 * x as u8, 0, 0, if x == 1 { 0 } else { 255 }]);
let out = smoothed(&img);
assert_eq!(out.pixel(1, 0), [10, 0, 0, 0]);
assert_eq!((out.width, out.height, out.rgba.len()), (3, 1, 12));
let broken = Raster { width: 4, height: 4, rgba: vec![0; 7] };
assert_eq!(smoothed(&broken).rgba.len(), 7);
}
}