Files

293 lines
12 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, TracedPath};
use crate::inputs::{Class, Input, Variant};
use crate::raster::{coverage_into, polylines, render};
/// 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 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,
/// Paths under 6 px of area.
pub specks: f64,
/// How far (px) the trace reaches outside the image: a stray curve.
pub outside: f64,
}
/// 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();
let (delta_mean, edge_delta) = fidelity(src, &render(&shapes, [255; 3], n, n));
// 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);
if input.class != Class::Tone {
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);
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 input.class != Class::Lines {
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) = (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;
}
}
}
// 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();
}
}
Metrics {
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 },
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),
}
}