mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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.
409 lines
18 KiB
Rust
409 lines
18 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,
|
|
/// 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);
|
|
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);
|
|
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,
|
|
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),
|
|
}
|
|
}
|