Files
bflatastic 250da4226e Image Trace: Gradients
Gradients (params `gradients`, a checkbox in the panel for Grayscale and Color;
off unless asked for): an area whose colour changes evenly along one direction
is traced as one path filled with a linear gradient, not as bands of flat colour.

Gradients are found before the palette is made, so no colour is spent on their
bands: a connected run of smoothly changing pixels is a gradient when a plane
fits each colour channel with all three sloping the same way, the colours along
that way are at most six stops with ramps between them, and painting them back
gives the area's own pixels. Flat colour, soft edges between flat colours and
shading that goes two ways are left as they were.

The harness gains two linear-gradient inputs with known lines and stops, and
measures whether each is found, the angle of its line and the colours of its ramp.
2026-10-13 12:16:03 +07:00

214 lines
8.1 KiB
Rust
Raw Permalink 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.
//! A small scanline rasteriser and image filters for the quality harness: test inputs are drawn
//! from ground-truth vector shapes here, never loaded from files.
use kurbo::{BezPath, PathEl, Point};
use vectorcraft_trace::{Raster, TracedGradient};
/// The closed polylines of `path` (one per subpath), flattened within `tol`.
pub fn polylines(path: &BezPath, tol: f64) -> Vec<Vec<Point>> {
let (mut out, mut cur) = (vec![], vec![]);
kurbo::flatten(path.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(p);
}
PathEl::LineTo(p) => cur.push(p),
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
out
}
/// Adds the non-zero-winding coverage (0–1 per pixel) of `path` to `cov` (`w × h`); returns the
/// rows touched. Five sub-scanlines per row, exact along x.
pub fn coverage_into(path: &BezPath, w: usize, h: usize, cov: &mut [f32]) -> (usize, usize) {
const SS: usize = 5;
let mut edges = vec![];
for p in polylines(path, 0.02) {
for i in 0..p.len() {
let (a, b) = (p[i], p[(i + 1) % p.len()]);
if a.y < b.y {
edges.push((a, b, 1));
} else if b.y < a.y {
edges.push((b, a, -1));
}
}
}
let (lo, hi) = edges.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), e| (lo.min(e.0.y), hi.max(e.1.y)));
if edges.is_empty() {
return (0, 0);
}
let (r0, r1) = (lo.floor().clamp(0.0, h as f64) as usize, hi.ceil().clamp(0.0, h as f64) as usize);
let mut xs: Vec<(f64, i32)> = vec![];
for row in r0..r1 {
for k in 0..SS {
let y = row as f64 + (k as f64 + 0.5) / SS as f64;
xs.clear();
xs.extend(edges.iter().filter(|(a, b, _)| a.y <= y && y < b.y).map(|(a, b, d)| (a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y), *d)));
xs.sort_by(|p, q| p.0.total_cmp(&q.0));
let (mut wind, mut start) = (0, 0.0);
for &(x, d) in &xs {
let before = wind;
wind += d;
if before == 0 {
start = x;
} else if wind == 0 {
let (x0, x1) = (start.clamp(0.0, w as f64), x.clamp(0.0, w as f64));
for px in x0.floor() as usize..(x1.ceil() as usize).min(w) {
cov[row * w + px] += ((x1.min(px as f64 + 1.0) - x0.max(px as f64)).max(0.0) / SS as f64) as f32;
}
}
}
}
}
(r0, r1)
}
/// The coverage of `path` alone.
pub fn coverage(path: &BezPath, w: usize, h: usize) -> Vec<f32> {
let mut cov = vec![0.0; w * h];
coverage_into(path, w, h, &mut cov);
cov
}
/// Paints `scene` (bottom first) over `bg`, anti-aliased, the way a renderer shows a trace.
pub fn render(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize) -> Raster {
let scene: Vec<(BezPath, [u8; 3], Option<&TracedGradient>)> = scene.iter().map(|(p, c)| (p.clone(), *c, None)).collect();
render_painted(&scene, bg, w, h)
}
/// [`render`], with the paths that have a gradient filled with it.
pub fn render_painted(scene: &[(BezPath, [u8; 3], Option<&TracedGradient>)], bg: [u8; 3], w: usize, h: usize) -> Raster {
let mut buf = vec![bg.map(f32::from); w * h];
let mut cov = vec![0.0f32; w * h];
for (path, color, gradient) in scene {
let (r0, r1) = coverage_into(path, w, h, &mut cov);
for i in r0 * w..r1 * w {
let a = cov[i].min(1.0);
if a > 0.0 {
let color = match gradient {
Some(g) => g.color_at(Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5)).map(|v| v as f32),
None => color.map(f32::from),
};
for ch in 0..3 {
buf[i][ch] += (color[ch] - buf[i][ch]) * a;
}
cov[i] = 0.0;
}
}
}
from_rgb(w, h, &buf)
}
/// Draws ground-truth art: each pixel is the average of `K × K` hard samples of the top-most
/// shape, so abutting shapes blend with each other on their shared edge and never with the
/// background behind them (which painting them one after another would let through).
pub fn render_truth(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize, k: usize) -> Raster {
let (bw, bh) = (w * k, h * k);
let mut top = vec![bg; bw * bh];
let mut cov = vec![0.0f32; bw * bh];
for (path, color) in scene {
let mut big = path.clone();
big.apply_affine(kurbo::Affine::scale(k as f64));
let (r0, r1) = coverage_into(&big, bw, bh, &mut cov);
for i in r0 * bw..r1 * bw {
if cov[i] >= 0.5 {
top[i] = *color;
}
cov[i] = 0.0;
}
}
let mut buf = vec![[0.0f32; 3]; w * h];
for y in 0..bh {
for x in 0..bw {
for ch in 0..3 {
buf[(y / k) * w + x / k][ch] += f32::from(top[y * bw + x][ch]) / (k * k) as f32;
}
}
}
from_rgb(w, h, &buf)
}
fn from_rgb(w: usize, h: usize, buf: &[[f32; 3]]) -> Raster {
let mut rgba = Vec::with_capacity(w * h * 4);
for p in buf {
rgba.extend(p.map(|v| v.round().clamp(0.0, 255.0) as u8));
rgba.push(255);
}
Raster::new(w as u32, h as u32, rgba)
}
/// A deterministic hash of a pixel position and a salt, as a value in `-1.0..1.0`.
pub fn noise(x: u32, y: u32, salt: u32) -> f64 {
let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ salt.wrapping_mul(0xC2B2_AE3D);
v ^= v >> 15;
v = v.wrapping_mul(0x2C1B_3C6D);
v ^= v >> 12;
v = v.wrapping_mul(0x297A_2D39);
v ^= v >> 15;
f64::from(v) / f64::from(u32::MAX) * 2.0 - 1.0
}
fn map(img: &Raster, f: impl Fn(u32, u32, [f64; 3]) -> [f64; 3]) -> Raster {
Raster::from_fn(img.width, img.height, |x, y| {
let p = img.pixel(x, y);
let c = f(x, y, [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
[c[0].round().clamp(0.0, 255.0) as u8, c[1].round().clamp(0.0, 255.0) as u8, c[2].round().clamp(0.0, 255.0) as u8, 255]
})
}
/// A slight blur (the 1-4-6-4-1 binomial kernel each way, about one pixel of sigma): a resized or
/// re-sampled image.
pub fn blur(img: &Raster) -> Raster {
const K: [f64; 5] = [1.0, 4.0, 6.0, 4.0, 1.0];
let (w, h) = (img.width as i64, img.height as i64);
let pass = |src: &Raster, dx: i64, dy: i64| {
map(src, |x, y, _| {
let mut s = [0.0; 3];
for (k, wt) in K.iter().enumerate() {
let o = k as i64 - 2;
let p = src.pixel((i64::from(x) + o * dx).clamp(0, w - 1) as u32, (i64::from(y) + o * dy).clamp(0, h - 1) as u32);
for ch in 0..3 {
s[ch] += f64::from(p[ch]) * wt / 16.0;
}
}
s
})
};
pass(&pass(img, 1, 0), 0, 1)
}
/// Compression-like noise: an offset per 8 × 8 block plus ringing-sized noise per pixel.
pub fn noisy(img: &Raster) -> Raster {
map(img, |x, y, c| {
let block = 3.0 * noise(x / 8, y / 8, 7);
[0, 1, 2].map(|ch| c[ch] + block + 5.0 * noise(x, y, 11 + ch as u32))
})
}
/// The colour a scan gives a clean colour, before shading, blur and grain: ink on cream paper.
pub fn scan_color(c: [u8; 3]) -> [u8; 3] {
const PAPER: [f64; 3] = [236.0, 230.0, 214.0];
const INK: [f64; 3] = [34.0, 32.0, 44.0];
[0, 1, 2].map(|ch| (INK[ch] + (PAPER[ch] - INK[ch]) * f64::from(c[ch]) / 255.0).round() as u8)
}
/// Scanned ink on paper: the colours of [`scan_color`], uneven lighting, a slight blur and grain.
pub fn scanned(img: &Raster) -> Raster {
let (w, h) = (f64::from(img.width), f64::from(img.height));
let toned = map(img, |x, y, c| {
let s = scan_color(c.map(|v| v as u8));
let shade = 1.0 - 0.07 * (f64::from(x) / w) - 0.05 * ((f64::from(y) / h) * 3.0).sin().abs();
s.map(|v| f64::from(v) * shade)
});
map(&blur(&toned), |x, y, c| {
let g = 4.0 * noise(x, y, 23);
c.map(|v| v + g)
})
}