vectorcraft/crates/trace/tests/quality/measure.rs

417 lines
19 KiB
Rust

//! What the harness measures of one trace: fidelity, geometric accuracy, economy and artefacts.
use std::collections::HashMap;
use kurbo::{BezPath, ParamCurveArclen, Point, Shape, Stroke, StrokeOpts};
use vectorcraft_geom::SubPath;
use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, TracedGradient, TracedPath};
use crate::inputs::{Class, Input, Variant};
use crate::raster::{coverage_into, polylines, render_painted};
/// sRGB to Oklab (Björn Ottosson, "A perceptual color space for image processing", 2020).
pub fn oklab(c: [u8; 3]) -> [f64; 3] {
let lin = c.map(|v| {
let v = f64::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
});
let l = (0.412_221_470_8 * lin[0] + 0.536_332_536_3 * lin[1] + 0.051_445_992_9 * lin[2]).cbrt();
let m = (0.211_903_498_2 * lin[0] + 0.680_699_545_1 * lin[1] + 0.107_396_956_6 * lin[2]).cbrt();
let s = (0.088_302_461_9 * lin[0] + 0.281_718_837_6 * lin[1] + 0.629_978_700_5 * lin[2]).cbrt();
[
0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s,
1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s,
0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s,
]
}
/// Colour difference: Euclidean in Oklab, times 100 (black to white is 100).
pub fn delta(a: [u8; 3], b: [u8; 3]) -> f64 {
let (a, b) = (oklab(a), oklab(b));
100.0 * ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
fn rgb(img: &Raster, x: usize, y: usize) -> [u8; 3] {
let p = img.pixel(x as u32, y as u32);
[p[0], p[1], p[2]]
}
/// The mean colour difference between `src` and `out`, over every pixel and weighted by how
/// strong an edge `src` has at the pixel (its lightness gradient).
pub fn fidelity(src: &Raster, out: &Raster) -> (f64, f64) {
let (w, h) = (src.width as usize, src.height as usize);
let light: Vec<f64> = (0..w * h).map(|i| oklab(rgb(src, i % w, i / w))[0]).collect();
let (mut sum, mut edge_sum, mut edge_weight) = (0.0, 0.0, 0.0);
for y in 0..h {
for x in 0..w {
let d = delta(rgb(src, x, y), rgb(out, x, y));
let at = |x: usize, y: usize| light[y.min(h - 1) * w + x.min(w - 1)];
let g = (at(x + 1, y) - at(x.saturating_sub(1), y)).hypot(at(x, y + 1) - at(x, y.saturating_sub(1)));
sum += d;
edge_sum += d * g;
edge_weight += g;
}
}
(sum / (w * h) as f64, if edge_weight > 0.0 { edge_sum / edge_weight } else { 0.0 })
}
/// A traced path as the shape it paints: stroked paths become their stroke's outline.
fn painted(tp: &TracedPath) -> BezPath {
let bez = tp.path.to_bezpath();
match tp.stroke {
Some(width) => kurbo::stroke(bez, &Stroke::new(width), &StrokeOpts::default(), 0.05),
None => bez,
}
}
/// Outlines as short segments in a grid, for nearest-distance queries.
pub struct Outline {
segs: Vec<(Point, Point)>,
grid: HashMap<(i32, i32), Vec<u32>>,
}
const CELL: f64 = 4.0;
fn seg_dist(p: Point, a: Point, b: Point) -> f64 {
let ab = b - a;
let l2 = ab.hypot2();
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
p.distance(a + ab * t)
}
impl Outline {
/// The outlines of `paths`, cut into pieces at most half a pixel long. With `inside`, pieces
/// within a pixel of the edge of an image that size are left out (the background's frame).
pub fn new<'a>(paths: impl IntoIterator<Item = &'a BezPath>, inside: Option<f64>) -> Self {
let mut out = Self { segs: vec![], grid: HashMap::new() };
for path in paths {
for line in polylines(path, 0.02) {
for i in 0..line.len() {
let (a, b) = (line[i], line[(i + 1) % line.len()]);
let n = (a.distance(b) / 0.5).ceil().max(1.0) as usize;
for k in 0..n {
let (p, q) = (a.lerp(b, k as f64 / n as f64), a.lerp(b, (k + 1) as f64 / n as f64));
let m = p.midpoint(q);
if inside.is_some_and(|s| m.x < 1.0 || m.y < 1.0 || m.x > s - 1.0 || m.y > s - 1.0) || p == q {
continue;
}
out.grid.entry(cell(m)).or_default().push(out.segs.len() as u32);
out.segs.push((p, q));
}
}
}
}
out
}
pub fn is_empty(&self) -> bool {
self.segs.is_empty()
}
/// The distance from `p` to the nearest outline (infinite when there is none).
pub fn dist(&self, p: Point) -> f64 {
let (cx, cy) = cell(p);
let mut best = f64::INFINITY;
for dy in -1..=1 {
for dx in -1..=1 {
for &i in self.grid.get(&(cx + dx, cy + dy)).into_iter().flatten() {
let (a, b) = self.segs[i as usize];
best = best.min(seg_dist(p, a, b));
}
}
}
// Anything nearer than a found piece under 3.5 px away is in the cells looked at.
if best > 3.5 {
best = self.segs.iter().map(|&(a, b)| seg_dist(p, a, b)).fold(f64::INFINITY, f64::min);
}
best
}
/// (mean, max) distance from this outline's pieces to `other`.
pub fn distance_to(&self, other: &Outline) -> (f64, f64) {
if self.is_empty() {
return (f64::NAN, f64::NAN);
}
let (mut sum, mut max) = (0.0, 0.0f64);
for &(a, b) in &self.segs {
let d = other.dist(a.midpoint(b));
sum += d;
max = max.max(d);
}
(sum / self.segs.len() as f64, max)
}
}
fn cell(p: Point) -> (i32, i32) {
((p.x / CELL).floor() as i32, (p.y / CELL).floor() as i32)
}
/// The angle (degrees) the path turns by at anchor `i` of `sp`, or `None` at an open end.
fn turn(sp: &SubPath, i: usize) -> Option<f64> {
let n = sp.anchors.len();
if n < 2 || (!sp.closed && (i == 0 || i + 1 == n)) {
return None;
}
let (before, after) = (sp.segment((i + n - 1) % n), sp.segment(i));
let first = |v: [kurbo::Vec2; 3]| v.into_iter().find(|v| v.hypot() > 1e-9);
let a = first([before.p3 - before.p2, before.p3 - before.p1, before.p3 - before.p0])?;
let b = first([after.p1 - after.p0, after.p2 - after.p0, after.p3 - after.p0])?;
Some(a.cross(b).atan2(a.dot(b)).abs().to_degrees())
}
/// Everything measured of one trace. `NAN` marks what does not apply.
#[derive(Clone, Copy, Debug)]
pub struct Metrics {
/// Mean colour difference of the trace drawn again, against the source.
pub delta: f64,
/// The same, weighted by the source's edge strength.
pub edge_delta: f64,
/// Mean and largest distance (px) from the traced outlines to the true ones.
pub geo_mean: f64,
pub geo_max: f64,
/// Largest distance (px) from a true outline to the nearest traced one: what was missed.
pub miss_max: f64,
/// Paths and anchor points, the background's frame aside.
pub paths: f64,
pub anchors: f64,
/// Anchors per anchor of the art itself.
pub anchor_ratio: f64,
/// Share (%) of the outline length made of straight segments.
pub lines: f64,
/// Share (%) of the outline length that is exactly level or upright.
pub upright: f64,
/// Share (%) of the art's sharp corners that have a traced corner within 1.5 px.
pub corners: f64,
/// Anchors where the path turns by 10° or more away from any true corner.
pub kinks: f64,
/// Paths (and their area, px) in a colour the art doesn't have.
pub slivers: f64,
pub sliver_area: f64,
/// Area (px) no path covers, and area covered twice.
pub gap: f64,
pub overlap: f64,
/// Stroked centre lines: their mean distance (px) from the true line's centre line, the mean
/// error (px) of their widths, and anchors per stroked path.
pub centre_off: f64,
pub width_off: f64,
pub stroke_anchors: f64,
/// Share (%) of the art's plain shapes that came out as a shape lying within 0.6 px of it.
pub shapes: f64,
/// Shapes: paths made a shape that lie more than 0.6 px off the art.
pub false_shapes: f64,
/// Auto Grouping: share (%) of the paths that lie inside another.
pub grouped: f64,
/// Gradients: paths filled with one; and where the input is a known gradient, whether one was
/// found (%), the angle (°) between its line and the largest traced gradient's, and the largest
/// difference (levels of a channel) between their colours along the true line.
pub gradients: f64,
pub ramp_found: f64,
pub ramp_angle: f64,
pub ramp_off: f64,
/// Paths under 6 px of area.
pub specks: f64,
/// How far (px) the trace reaches outside the image: a stray curve.
pub outside: f64,
}
/// The order a grouped result is painted in: each path, then those inside it.
fn draw_order(res: &TraceResult) -> Vec<usize> {
let mut inside = vec![vec![]; res.paths.len()];
let mut stack = vec![];
for i in (0..res.paths.len()).rev() {
match res.parents.get(i).copied().flatten() {
Some(p) => inside[p].insert(0, i),
None => stack.push(i),
}
}
let mut order = vec![];
while let Some(i) = stack.pop() {
order.push(i);
stack.extend(inside[i].iter().rev());
}
order
}
/// Measure `res`, the trace of `src` (`input` drawn `size` px square as `variant`) with `params`.
pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params: &TraceParams, res: &TraceResult) -> Metrics {
let n = size as usize;
let shapes: Vec<(BezPath, [u8; 3])> = res.paths.iter().map(|tp| (painted(tp), tp.color)).collect();
// Painted as the result is grouped: each path, then those inside it.
let drawn: Vec<(BezPath, [u8; 3], Option<&TracedGradient>)> =
draw_order(res).into_iter().map(|i| (shapes[i].0.clone(), shapes[i].1, res.paths[i].gradient.as_ref())).collect();
let (delta_mean, edge_delta) = fidelity(src, &render_painted(&drawn, [255; 3], n, n));
// Gradients: how many paths have one; and the largest of them against the gradient the input
// is: the angle between their lines, and the largest difference of a channel between their
// colours at 17 points along the true line.
let gradients = res.paths.iter().filter(|tp| tp.gradient.is_some()).count() as f64;
let (mut ramp_found, mut ramp_angle, mut ramp_off) = (f64::NAN, f64::NAN, f64::NAN);
if let Some(ramp) = &input.ramp {
let k = f64::from(size) / 256.0;
let found = res.paths.iter().filter(|tp| tp.gradient.is_some()).max_by_key(|tp| tp.pixels).and_then(|tp| tp.gradient.as_ref());
ramp_found = if found.is_some() { 100.0 } else { 0.0 };
if let Some(g) = found {
let (truth, own) = (ramp.end - ramp.start, g.end - g.start);
let between = (truth.atan2() - own.atan2()).to_degrees().rem_euclid(180.0);
ramp_angle = between.min(180.0 - between);
ramp_off = (0..=16)
.map(|j| {
let p = ramp.start + truth * (f64::from(j) / 16.0);
// (The true colour as this variant shows it: a scan's are its own.)
let a = variant.color(ramp.color_at(p).map(|v| v.round() as u8)).map(f64::from);
let b = g.color_at((p.to_vec2() * k).to_point());
(0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f64::max)
})
.fold(0.0, f64::max);
}
}
let grouped = if res.parents.is_empty() { f64::NAN } else { 100.0 * res.parents.iter().flatten().count() as f64 / res.paths.len().max(1) as f64 };
// The background's frame: a path as large as the image.
let is_frame = |p: &BezPath| {
let b = p.bounding_box();
b.width() >= 0.98 * f64::from(size) && b.height() >= 0.98 * f64::from(size)
};
let art: Vec<(&TracedPath, &BezPath)> = res.paths.iter().zip(&shapes).filter(|(_, (p, _))| !is_frame(p)).map(|(tp, (p, _))| (tp, p)).collect();
let anchors: usize = art.iter().map(|(tp, _)| tp.path.anchor_count()).sum();
let nan = f64::NAN;
let (mut geo_mean, mut geo_max, mut miss_max, mut corners, mut kinks, mut slivers, mut sliver_area) = (nan, nan, nan, nan, nan, nan, nan);
let mut false_shapes = if params.shapes { 0.0 } else { nan };
if !matches!(input.class, Class::Tone | Class::Ramp) {
let truth_shapes = input.scaled(size);
let truth = Outline::new(truth_shapes.iter().map(|(p, _)| p), None);
let traced = Outline::new(shapes.iter().map(|(p, _)| p), Some(f64::from(size)));
(geo_mean, geo_max) = traced.distance_to(&truth);
// Shapes that are not the art's: a path made a shape that strays more than 0.6 px from it.
false_shapes =
res.paths.iter().filter(|tp| tp.shape.is_some() && Outline::new([&tp.path.to_bezpath()], None).distance_to(&truth).1 > 0.6).count()
as f64;
miss_max = truth.distance_to(&traced).1;
let true_corners = input.scaled_corners(size);
let turns: Vec<(Point, f64)> = art
.iter()
.flat_map(|(tp, _)| &tp.path.subpaths)
.flat_map(|sp| (0..sp.anchors.len()).filter_map(move |i| Some((sp.anchors[i].p, turn(sp, i)?))))
.collect();
if !true_corners.is_empty() {
let kept = true_corners.iter().filter(|c| turns.iter().any(|(p, t)| *t >= 20.0 && p.distance(**c) <= 1.5)).count();
corners = 100.0 * kept as f64 / true_corners.len() as f64;
}
if !matches!(input.class, Class::Lines | Class::Askew | Class::Plain) {
kinks = turns.iter().filter(|(p, t)| *t >= 10.0 && true_corners.iter().all(|c| p.distance(*c) > 2.0)).count() as f64;
}
if params.mode != Mode::BlackAndWhite {
let colors: Vec<[u8; 3]> = truth_shapes.iter().map(|(_, c)| *c).chain([input.bg]).map(|c| variant.color(c)).collect();
let odd: Vec<f64> = shapes.iter().filter(|(_, c)| colors.iter().all(|t| delta(*t, *c) > 10.0)).map(|(p, _)| p.area().abs()).collect();
(slivers, sliver_area) = (odd.len() as f64, odd.iter().sum());
}
}
let (mut length, mut straight, mut upright) = (0.0, 0.0, 0.0);
for sp in art.iter().flat_map(|(tp, _)| &tp.path.subpaths) {
for i in 0..sp.segment_count() {
let len = sp.segment(i).arclen(0.05);
length += len;
// Straight is judged by the geometry: a line may be stored with handles along it.
let c = sp.segment(i);
if seg_dist(c.p1, c.p0, c.p3) < 0.02 && seg_dist(c.p2, c.p0, c.p3) < 0.02 {
straight += len;
if (c.p0.x - c.p3.x).abs() < 1e-9 || (c.p0.y - c.p3.y).abs() < 1e-9 {
upright += len;
}
}
}
}
// Coverage summed over the paths: under 1 is a gap, over 1 an overlap.
let (mut gap, mut overlap) = (nan, nan);
if !params.ignore_white {
let (mut sum, mut cov) = (vec![0.0f32; n * n], vec![0.0f32; n * n]);
for (p, _) in &shapes {
let (r0, r1) = coverage_into(p, n, n, &mut cov);
for i in r0 * n..r1 * n {
sum[i] += cov[i].min(1.0);
cov[i] = 0.0;
}
}
gap = sum.iter().map(|&s| f64::from((1.0 - s).max(0.0))).sum();
// Overlapping layers and Flat Logo's shapes are stacked on purpose.
if params.method == Method::Abutting && params.mode != Mode::Logo {
overlap = sum.iter().map(|&s| f64::from((s - 1.0).max(0.0))).sum();
}
}
// Stroked paths against the lines they trace: each against the true line nearest its middle.
let (mut centre_off, mut width_off, mut stroke_anchors) = (nan, nan, nan);
let truth = input.scaled_strokes(size);
let stroked: Vec<&TracedPath> = res.paths.iter().filter(|tp| tp.stroke.is_some()).collect();
if !truth.is_empty() && !stroked.is_empty() {
let (mut off, mut samples, mut width) = (0.0, 0.0, 0.0);
for tp in &stroked {
let line = Outline::new([&tp.path.to_bezpath()], None);
let Some(middle) = line.segs.get(line.segs.len() / 2).map(|s| s.0.midpoint(s.1)) else { continue };
let Some((a, b, w)) = truth.iter().copied().min_by(|p, q| seg_dist(middle, p.0, p.1).total_cmp(&seg_dist(middle, q.0, q.1))) else {
continue;
};
for s in &line.segs {
// The distance to the line through the true ends: a centre line may be shorter or
// longer than the true one by how its caps are drawn.
let d = b - a;
off += (s.0.midpoint(s.1) - a).cross(d).abs() / d.hypot().max(1e-9);
samples += 1.0;
}
width += (tp.stroke.unwrap_or(0.0) - w).abs();
}
if samples > 0.0 {
(centre_off, width_off) = (off / samples, width / stroked.len() as f64);
stroke_anchors = stroked.iter().map(|tp| tp.path.anchor_count()).sum::<usize>() as f64 / stroked.len() as f64;
}
}
// Plain shapes: each is found when some path that is a shape lies on it.
let plain = input.scaled_plain(size);
let mut shapes_found = nan;
if !plain.is_empty() {
let made: Vec<Outline> = res.paths.iter().filter(|tp| tp.shape.is_some()).map(|tp| Outline::new([&tp.path.to_bezpath()], None)).collect();
let found = plain.iter().filter(|p| {
let truth = Outline::new([*p], None);
made.iter().any(|m| m.distance_to(&truth).1 <= 0.6 && truth.distance_to(m).1 <= 0.6)
});
shapes_found = 100.0 * found.count() as f64 / plain.len() as f64;
}
Metrics {
grouped,
gradients,
ramp_found,
ramp_angle,
ramp_off,
shapes: shapes_found,
false_shapes,
centre_off,
width_off,
stroke_anchors,
delta: delta_mean,
edge_delta,
geo_mean,
geo_max,
miss_max,
paths: art.len() as f64,
anchors: anchors as f64,
anchor_ratio: if input.anchors > 0 { anchors as f64 / input.anchors as f64 } else { nan },
lines: if length > 0.0 { 100.0 * straight / length } else { nan },
upright: if length > 0.0 { 100.0 * upright / length } else { nan },
corners,
kinks,
slivers,
sliver_area,
gap,
overlap,
specks: art.iter().filter(|(_, p)| p.area().abs() < 6.0).count() as f64,
outside: shapes
.iter()
.map(|(p, _)| p.bounding_box())
.filter(|b| b.width() > 0.0 || b.height() > 0.0)
.map(|b| (-b.x0).max(-b.y0).max(b.x1 - f64::from(size)).max(b.y1 - f64::from(size)).max(0.0))
.fold(0.0, f64::max),
}
}