Files
vectorcraft/crates/trace/tests/quality/raster.rs
T
andeecollardandClaude Opus 5.5 a2e2eba2e2 Image Trace: Raster::new returns an error for a buffer of the wrong length
Raster::new checked its RGBA length with assert_eq!, so bytes of the
wrong length (from a file, a plug-in or a control message) would abort
instead of reporting an error. It now returns Result<Raster, TraceError>
and gives TraceError::Decode naming the byte count and the size (the
existing variant, whose text is already translated, instead of a new
message every language would need). The size product is checked for
overflow.

Raster::pixel indexed the bytes directly; it now gives transparent
black outside the raster, or past the end of a raster whose public
fields were set to a size its bytes don't have (which mosaic reads).

The public signature changes: every caller in the workspace was a test
(trace unit tests, Flat Logo tests and the quality harness), updated to
unwrap. No shipped code built a Raster with new; decode, decode_to_fit
and from_fn always make the right length, and trace_within already
refuses a raster whose fields disagree.

Test (code-drawn): a_raster_of_the_wrong_length_is_an_error_not_a_crash
(short, long, empty, zero-width and overflowing sizes; pixel, trace and
mosaic on a raster whose fields disagree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 19:38:06 +07:00

214 lines
8.1 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.
//! 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).unwrap()
}
/// 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)
})
}