Merge pull request #1627 from storytold/u/trace-gradients

Image Trace: Gradients
This commit is contained in:
BFlat authored and GitHub committed 2026-10-13 12:24:35 +07:00
commit 5bfe774420
12 files changed
+1217 -442

No files matched your search

+13 -2
View File
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -727,6 +727,16 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
live
}
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
/// document by `xf`.
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
/// inside it. Without parents, the nodes as they are.
@@ -835,7 +845,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
}
// A library colour fills with the swatch's own colour (its model kept).
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
let paint = Paint::solid(color);
// Gradients: a path the tracer found to be a linear gradient is filled with it.
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
Some(width) => Appearance {
+27
View File
@@ -746,6 +746,33 @@ fn image_trace_auto_grouping_groups_what_is_inside() {
assert_eq!(expanded.children().unwrap().len(), 2);
}
/// Gradients: an image that is one linear gradient is one path filled with it, the gradient's line
/// where the image lies; without the option it is bands of flat colour, as saved documents have it.
#[test]
fn image_trace_gradients_fills_with_a_gradient() {
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
let t = (f64::from(x) + 0.5) / 100.0;
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
});
let make = |gradients: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": gradients}})).unwrap();
traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())))
};
let paths = make(true);
assert_eq!(paths.len(), 1);
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Linear);
assert_eq!(g.gradient.stops.len(), 2);
// Level, from the image's left edge (x = 50) to its right one (x = 250), at any height.
let geom = g.geom.as_ref().unwrap();
let (a, b) = if geom.start.x < geom.end.x { (geom.start, geom.end) } else { (geom.end, geom.start) };
assert!((a.x - 50.0).abs() < 3.0 && (b.x - 250.0).abs() < 3.0 && (a.y - b.y).abs() < 2.0, "{geom:?}");
let flat = make(false);
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
}
#[test]
fn image_trace_release_and_expand() {
let mut s = session();
+437
View File
@@ -0,0 +1,437 @@
//! Gradients: areas whose colour changes evenly along one direction are found before the image is
//! reduced to its palette, and each is traced as one path filled with a linear gradient instead of
//! as bands of flat colour.
//!
//! An area is a connected run of pixels that each differ little from their neighbours. It is a
//! gradient when a plane fits each of its colour channels with all three sloping the same way, the
//! colours along that way are a few stops with even ramps between them, and painting those stops
//! back gives the area's own pixels. Anything else (a flat area, a photo's sky lit two ways, two
//! flats with a soft edge between them) is left to be traced as before.
use crate::Raster;
use crate::quantize::TRANSPARENT;
use kurbo::{Point, Vec2};
/// The largest step (levels of a channel) between neighbouring pixels of one smooth area.
const STEP: i32 = 8;
/// The fewest pixels a gradient has, and the least length (px) along its direction.
const MIN_PIXELS: usize = 400;
const MIN_LENGTH: f64 = 16.0;
/// The least change (levels of a channel) from one stop to another: less is a flat colour.
const MIN_CHANGE: f64 = 24.0;
/// The most stops, and the least distance between two (as a share of the length): closer stops are
/// an edge, not a ramp.
const MAX_STOPS: usize = 6;
const MIN_SPACING: f64 = 0.08;
/// How far (levels) the stops may be from the colours along the gradient.
const STOP_TOLERANCE: f64 = 2.0;
/// A linear gradient a traced path is filled with.
#[derive(Clone, Debug, PartialEq)]
pub struct TracedGradient {
/// The gradient's line, in pixel coordinates: the first stop's colour is at `start` and before
/// it, the last one's at `end` and beyond.
pub start: Point,
pub end: Point,
/// The stops: how far along the line (0 to 1, ascending, the first 0 and the last 1) and the colour.
pub stops: Vec<(f64, [u8; 3])>,
}
impl TracedGradient {
/// The gradient's colour at `p`.
pub fn color_at(&self, p: Point) -> [f64; 3] {
let d = self.end - self.start;
let t = if d.hypot2() > 0.0 { ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
let color = |i: usize| self.stops.get(i).map_or([0.0; 3], |s| s.1.map(f64::from));
let after = self.stops.iter().position(|s| s.0 >= t).unwrap_or(self.stops.len().saturating_sub(1));
let Some(before) = after.checked_sub(1) else { return color(after) };
let (ta, tb) = (self.stops.get(before).map_or(0.0, |s| s.0), self.stops.get(after).map_or(1.0, |s| s.0));
let k = if tb > ta { (t - ta) / (tb - ta) } else { 1.0 };
let (a, b) = (color(before), color(after));
[0, 1, 2].map(|c| a[c] + (b[c] - a[c]) * k)
}
}
/// The gradients of `img`: for each pixel the gradient it is part of (an index into the list, or
/// `u32::MAX`), and the list.
pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
let (w, h) = (img.width as usize, img.height as usize);
let px = |i: usize| img.rgba.get(i * 4..i * 4 + 4).filter(|p| p[3] >= 128).map(|p| [i32::from(p[0]), i32::from(p[1]), i32::from(p[2])]);
let near = |a: [i32; 3], b: Option<[i32; 3]>| b.is_some_and(|b| (0..3).all(|k| (a[k] - b[k]).abs() <= STEP));
// Smooth: an opaque pixel that differs little from each neighbour it has.
let smooth: Vec<bool> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
px(i).is_some_and(|c| {
(x == 0 || near(c, px(i - 1)))
&& (x + 1 == w || near(c, px(i + 1)))
&& (y == 0 || near(c, px(i - w)))
&& (y + 1 == h || near(c, px(i + w)))
})
})
.collect();
// Sloping: smooth, and some pixel a little way off (a 32nd of the image: a gradient across a
// large image changes by less than a level from one pixel to the next) is another colour.
// Flat colour is no part of a gradient, and left out it does not join a gradient to the flat
// area beside it where the two are nearly one colour.
let far = (w.min(h) / 32).clamp(4, 64) as i64;
let around = [
(far, 0),
(-far, 0),
(0, far),
(0, -far),
(far * 3 / 4, far * 3 / 4),
(far * 3 / 4, -far * 3 / 4),
(-far * 3 / 4, far * 3 / 4),
(-far * 3 / 4, -far * 3 / 4),
];
let sloping: Vec<bool> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let differs = |(dx, dy): (i64, i64)| {
let (nx, ny) = (x + dx, y + dy);
let other = (nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| px(ny as usize * w + nx as usize)).flatten();
px(i).zip(other).is_some_and(|(a, b)| (0..3).any(|k| (a[k] - b[k]).abs() >= 2))
};
smooth.get(i).copied().unwrap_or(false) && around.into_iter().any(differs)
})
.collect();
drop(smooth);
let smooth = sloping;
let mut of = vec![u32::MAX; w * h];
let mut seen = vec![false; w * h];
let mut depth = vec![0u16; w * h];
let mut found = vec![];
let (mut members, mut stack) = (vec![], vec![]);
for seed in 0..w * h {
if seen.get(seed).copied().unwrap_or(true) || !smooth.get(seed).copied().unwrap_or(false) {
continue;
}
// The connected smooth pixels from here.
members.clear();
stack.push(seed);
if let Some(s) = seen.get_mut(seed) {
*s = true;
}
while let Some(i) = stack.pop() {
members.push(i);
let (x, y) = (i % w, i / w);
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
if smooth.get(j).copied().unwrap_or(false) && !seen.get(j).copied().unwrap_or(true) {
if let Some(s) = seen.get_mut(j) {
*s = true;
}
stack.push(j);
}
}
}
if members.len() < MIN_PIXELS || found.len() >= 1000 {
continue;
}
// The gradient is read from the area's inside, where there is one: the pixels at its rim
// may be a soft edge to the next area, joined to this one where the two are alike.
// (`depth`: how many pixels in from the rim each of the area's pixels is, counted inwards
// from the pixels that have a neighbour outside it, as far as matters.)
let rim = far as usize + 2;
let unknown = u16::MAX;
members.iter().for_each(|&i| depth[i] = unknown);
let beside = |i: usize| {
let (x, y) = (i % w, i / w);
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten()
};
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i).any(|j| depth.get(j).copied() == Some(0))).collect();
front.iter().for_each(|&i| depth[i] = 1);
for d in 2..=rim as u16 {
let mut next = vec![];
for &i in &front {
for j in beside(i) {
if let Some(slot) = depth.get_mut(j).filter(|s| **s == unknown) {
*slot = d;
next.push(j);
}
}
}
front = next;
}
let inside: Vec<usize> = members.iter().copied().filter(|&i| depth.get(i).copied() == Some(unknown)).collect();
let fitted = fit(img, w, if inside.len() >= MIN_PIXELS { &inside } else { &members }, &members, rim);
members.iter().for_each(|&i| depth[i] = 0);
if let Some((g, part)) = fitted {
for &i in &part {
if let Some(o) = of.get_mut(i) {
*o = found.len() as u32;
}
}
found.push(g);
}
}
(of, found)
}
/// The linear gradient the pixels `members` of `img` (`w` wide) are, if they are one, and those of
/// `all` (the whole area `members` are the inside of, `rim` pixels in) that have its colours.
fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) -> Option<(TracedGradient, Vec<usize>)> {
let n = members.len() as f64;
let at = |i: usize| Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
let mut middle = Vec2::ZERO;
let mut mean = [0.0; 3];
for &i in members {
middle += at(i).to_vec2();
let c = rgb(i);
(0..3).for_each(|k| mean[k] += c[k]);
}
let middle = (middle / n).to_point();
let mean = mean.map(|v| v / n);
// A plane through each channel, by least squares about the middle.
let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
let (mut scx, mut scy) = ([0.0; 3], [0.0; 3]);
for &i in members {
let (d, c) = (at(i) - middle, rgb(i));
sxx += d.x * d.x;
sxy += d.x * d.y;
syy += d.y * d.y;
for k in 0..3 {
scx[k] += d.x * (c[k] - mean[k]);
scy[k] += d.y * (c[k] - mean[k]);
}
}
let det = sxx * syy - sxy * sxy;
if det <= 1e-6 * sxx * syy || !det.is_finite() {
return None;
}
// Each channel's slope, and the one direction they share: the longer axis of the three
// together (the shorter is what they do not share, and must be next to nothing).
let slope = [0, 1, 2].map(|k| Vec2::new((scx[k] * syy - scy[k] * sxy) / det, (scy[k] * sxx - scx[k] * sxy) / det));
let (mxx, mxy, myy) = slope.iter().fold((0.0, 0.0, 0.0), |m, s| (m.0 + s.x * s.x, m.1 + s.x * s.y, m.2 + s.y * s.y));
let spread = ((mxx - myy) / 2.0).hypot(mxy);
let (major, minor) = ((mxx + myy) / 2.0 + spread, (mxx + myy) / 2.0 - spread);
if major <= 0.0 || minor > 0.03 * major {
return None;
}
let angle = 0.5 * (2.0 * mxy).atan2(mxx - myy);
let dir = Vec2::new(angle.cos(), angle.sin());
// The colours along it, a pixel's width at a time.
let along = |i: usize| (at(i) - middle).dot(dir);
let (t0, t1) = members.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, &i| (m.0.min(along(i) - 0.5), m.1.max(along(i) + 0.5)));
let length = t1 - t0;
if !(MIN_LENGTH..1e6).contains(&length) {
return None;
}
let bins = length.ceil() as usize;
// (Each step's mean colour, at the mean place of its pixels: the few pixels at the tip of a
// round area are not at the middle of their step.)
let mut sums = vec![([0.0; 3], 0.0f64, 0.0f64); bins];
for &i in members {
let t = along(i) - t0;
if let Some(b) = sums.get_mut((t as usize).min(bins - 1)) {
let c = rgb(i);
(0..3).for_each(|k| b.0[k] += c[k]);
b.1 += 1.0;
b.2 += t;
}
}
let curve: Vec<(f64, [f64; 3])> = sums.iter().filter(|b| b.1 > 0.0).map(|b| (b.2 / b.1 / length, b.0.map(|v| v / b.1))).collect();
// The fewest stops whose ramps pass within tolerance of every colour along the way: the two
// ends, then again and again the colour farthest from the ramp it lies on.
let mut keep = vec![0, curve.len().checked_sub(1)?];
loop {
let mut worst = (STOP_TOLERANCE, 0);
for pair in keep.windows(2) {
let (Some(&(ta, a)), Some(&(tb, b))) = (curve.get(pair[0]), curve.get(pair[1])) else { return None };
for (j, &(t, c)) in curve.iter().enumerate().take(pair[1]).skip(pair[0] + 1) {
let k = (t - ta) / (tb - ta);
let off = (0..3).map(|ch| (c[ch] - (a[ch] + (b[ch] - a[ch]) * k)).abs()).fold(0.0, f64::max);
if off > worst.0 {
worst = (off, j);
}
}
}
if worst.1 == 0 {
break;
}
// (More than are let stand: those close together become one below.)
if keep.len() >= 4 * MAX_STOPS {
return None;
}
keep.push(worst.1);
keep.sort_unstable();
}
// Two stops close together are one stop where the ramp turns (a turn is not sharp to the
// pixel, least of all in a blurred image), unless their colours are far apart: that is an
// edge, not a ramp.
let mut stops: Vec<(f64, [f64; 3])> = vec![];
for &(t, c) in keep.iter().filter_map(|&j| curve.get(j)) {
match stops.last_mut() {
Some(last) if t - last.0 < MIN_SPACING => {
if (0..3).any(|k| (c[k] - last.1[k]).abs() >= MIN_CHANGE) {
return None;
}
*last = ((last.0 + t) / 2.0, [0, 1, 2].map(|k| (last.1[k] + c[k]) / 2.0));
}
_ => stops.push((t, c)),
}
}
let (first, last) = (stops.first()?.0, stops.last()?.0);
// A change of colour, not one colour.
let change =
stops.iter().flat_map(|a| stops.iter().map(move |b| (0..3).map(|k| (a.1[k] - b.1[k]).abs()).fold(0.0, f64::max))).fold(0.0, f64::max);
// And ramps most of the way: two flat colours with a soft edge between them are no gradient.
let level: f64 = stops.windows(2).filter(|s| (0..3).all(|k| (s[1].1[k] - s[0].1[k]).abs() < 4.0)).map(|s| s[1].0 - s[0].0).sum();
if !(2..=MAX_STOPS).contains(&stops.len()) || last - first < MIN_SPACING || change < MIN_CHANGE || level > 0.3 * (last - first) {
return None;
}
let gradient = TracedGradient {
start: middle + dir * (t0 + first * length),
end: middle + dir * (t0 + last * length),
stops: stops.iter().map(|&(t, c)| ((t - first) / (last - first), c.map(|v| v.round().clamp(0.0, 255.0) as u8))).collect(),
};
// Carried on to the ends of the whole area, each end ramp going on as it goes.
let d = gradient.end - gradient.start;
let place = |i: usize| (at(i) - gradient.start).dot(d) / d.hypot2();
let reach = rim as f64 * 1.5 / d.hypot();
let (u0, u1) = all.iter().fold((0.0f64, 1.0f64), |m, &i| (m.0.min(place(i)), m.1.max(place(i))));
let (u0, u1) = (u0.max(-reach), u1.min(1.0 + reach));
let ramp = |a: (f64, [u8; 3]), b: (f64, [u8; 3]), u: f64| {
[0, 1, 2].map(|k| (f64::from(a.1[k]) + (f64::from(b.1[k]) - f64::from(a.1[k])) * (u - a.0) / (b.0 - a.0)).round().clamp(0.0, 255.0) as u8)
};
let mut stops = gradient.stops.clone();
if let (&[a, b, ..], &[.., y, z]) = (stops.as_slice(), stops.as_slice()) {
if let Some(first) = stops.first_mut() {
*first = (u0, ramp(a, b, u0));
}
if let Some(last) = stops.last_mut() {
*last = (u1, ramp(y, z, u1));
}
}
let gradient = TracedGradient {
start: gradient.start + d * u0,
end: gradient.start + d * u1,
stops: stops.into_iter().map(|(u, c)| ((u - u0) / (u1 - u0), c)).collect(),
};
// Painted back, it is the area: most pixels of its inside within a few levels, and all that
// are (the rest: noise, a soft rim, the flat colour just beyond it) on average nearer still.
let off = |i: usize| {
let (c, g) = (rgb(i), gradient.color_at(at(i)));
(0..3).map(|k| (c[k] - g[k]).abs()).fold(0.0, f64::max)
};
let part: Vec<usize> = all.iter().copied().filter(|&i| off(i) <= 6.0).collect();
let mean = part.iter().map(|&i| off(i)).sum::<f64>() / part.len().max(1) as f64;
let inside = members.iter().filter(|&&i| off(i) <= 6.0).count();
(part.len() >= MIN_PIXELS && inside * 4 >= members.len() * 3 && mean <= 3.5).then_some((gradient, part))
}
/// Settles the pixels along the edge of each gradient (they are part gradient and part what lies
/// beyond, and no part of the smooth area the gradient was found in): each joins the gradient
/// (`of`) when more than half of it is the gradient's colour there, and else takes the label of
/// what lies beyond. `labels` are the image's palette labels, the gradients' pixels not among them.
pub(crate) fn settle_edges(img: &Raster, of: &mut [u32], gradients: &[TracedGradient], labels: &mut [u16]) {
let (w, h) = (img.width as usize, img.height as usize);
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 4).filter(|p| p[3] >= 128).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
// Twice: a soft edge is two pixels wide.
for _ in 0..2 {
let mut joins: Vec<(usize, u32)> = vec![];
let mut takes: Vec<(usize, u16)> = vec![];
for i in 0..w * h {
if of.get(i).copied() != Some(u32::MAX) {
continue;
}
let Some(c) = rgb(i) else { continue };
let (x, y) = ((i % w) as i64, (i / w) as i64);
let index = |x: i64, y: i64| (x >= 0 && y >= 0 && x < w as i64 && y < h as i64).then(|| y as usize * w + x as usize);
// The gradient on one side, and what lies two pixels out on the other.
let Some((g, dx, dy)) = [(1, 0), (-1, 0), (0, 1), (0, -1)].into_iter().find_map(|(dx, dy)| {
let g = index(x + dx, y + dy).and_then(|j| of.get(j).copied()).filter(|&g| g != u32::MAX)?;
Some((g, dx, dy))
}) else {
continue;
};
let Some(gradient) = gradients.get(g as usize) else { continue };
let own = gradient.color_at(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let off = (0..3).map(|k| (c[k] - own[k]).abs()).fold(0.0, f64::max);
let beyond = index(x - 2 * dx, y - 2 * dy).filter(|&j| of.get(j).copied() == Some(u32::MAX));
let other = beyond.and_then(|j| Some((rgb(j)?, labels.get(j).copied()?)));
match other {
Some((o, label)) if (0..3).map(|k| (o[k] - own[k]).abs()).fold(0.0, f64::max) > 16.0 => {
// How much of the pixel is the other colour.
let d = [0, 1, 2].map(|k| o[k] - own[k]);
let share = (0..3).map(|k| (c[k] - own[k]) * d[k]).sum::<f64>() / d.iter().map(|v| v * v).sum::<f64>();
if share < 0.5 {
joins.push((i, g));
} else if label != TRANSPARENT {
takes.push((i, label));
}
}
// Gradient beyond too, or nothing to tell it from: part of it if it has its colour.
_ if off <= 16.0 => joins.push((i, g)),
_ => {}
}
}
for (i, g) in joins {
if let Some(o) = of.get_mut(i) {
*o = g;
}
}
for (i, l) in takes {
if let Some(slot) = labels.get_mut(i) {
*slot = l;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ramp(w: u32, h: u32, f: impl Fn(f64, f64) -> [f64; 3]) -> Raster {
Raster::from_fn(w, h, |x, y| {
let c = f(f64::from(x) + 0.5, f64::from(y) + 0.5);
[c[0].round() as u8, c[1].round() as u8, c[2].round() as u8, 255]
})
}
#[test]
fn a_linear_ramp_is_one_gradient_with_its_direction_and_ends() {
// Dark blue to yellow, 30° down from level.
let dir = Vec2::new(30f64.to_radians().cos(), 30f64.to_radians().sin());
let reach = Vec2::new(120.0, 80.0).dot(dir);
let img = ramp(120, 80, |x, y| {
let t = Vec2::new(x, y).dot(dir) / reach;
[20.0 + 220.0 * t, 40.0 + 180.0 * t, 160.0 - 140.0 * t]
});
let (of, found) = detect(&img);
assert_eq!(found.len(), 1);
assert!(of.iter().all(|&g| g == 0));
let g = &found[0];
let d = g.end - g.start;
assert!((d.atan2().to_degrees() - 30.0).abs() < 1.0, "{d:?}");
assert_eq!(g.stops.len(), 2);
for (stop, truth) in g.stops.iter().zip([[20.0, 40.0, 160.0], [240.0, 220.0, 20.0]]) {
assert!((0..3).all(|k| (f64::from(stop.1[k]) - truth[k]).abs() < 6.0), "{:?}", g.stops);
}
}
#[test]
fn flat_colour_soft_edges_and_two_way_shading_are_no_gradient() {
// One colour.
assert!(detect(&ramp(64, 64, |_, _| [200.0, 30.0, 30.0])).1.is_empty());
// Two flat colours with a soft edge between them.
let soft = ramp(96, 64, |x, _| [100.0 + 30.0 / (1.0 + (-(x - 48.0)).exp()); 3]);
assert!(detect(&soft).1.is_empty());
// Red changing across and green changing down.
assert!(detect(&ramp(96, 96, |x, y| [40.0 + 2.0 * x, 40.0 + 2.0 * y, 90.0])).1.is_empty());
}
#[test]
fn three_stops_are_three_stops() {
let img = ramp(200, 40, |x, _| {
let t = x / 200.0;
if t < 0.5 { [255.0 - 400.0 * t, 40.0, 40.0 + 300.0 * t] } else { [55.0 + 300.0 * (t - 0.5), 40.0 + 400.0 * (t - 0.5), 190.0] }
});
let (_, found) = detect(&img);
assert_eq!(found.len(), 1);
assert_eq!(found[0].stops.len(), 3, "{:?}", found[0].stops);
assert!((found[0].stops[1].0 - 0.5).abs() < 0.03);
}
}
+1 -1
View File
@@ -158,7 +158,7 @@ mod tests {
fn disc(c: (f64, f64), r: f64, color: u8) -> TracedPath {
let path = PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Circle::new(c, r), 0.01));
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None }
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None }
}
#[test]
+49 -4
View File
@@ -45,6 +45,7 @@
mod centerline;
mod contour;
mod fit;
mod gradient;
mod group;
mod logo;
mod mosaic;
@@ -59,6 +60,7 @@ use serde::{Deserialize, Serialize};
use vectorcraft_geom::PathData;
pub use contour::{Component, Loop, trace_mask};
pub use gradient::TracedGradient;
pub use mosaic::mosaic;
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
pub use shapes::TracedShape;
@@ -287,6 +289,9 @@ pub struct TraceParams {
pub shapes: bool,
/// Auto Grouping: say which path lies inside which ([`TraceResult::parents`]).
pub auto_grouping: bool,
/// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction
/// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour.
pub gradients: bool,
/// Create: trace areas as filled shapes.
pub fills: bool,
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
@@ -319,6 +324,7 @@ impl Default for TraceParams {
snap_curves_to_lines: false,
shapes: false,
auto_grouping: false,
gradients: false,
fills: true,
strokes: false,
stroke_width: 10.0,
@@ -438,6 +444,8 @@ pub struct TracedPath {
pub stroke: Option<f64>,
/// Shapes: the shape the path is, exactly (`path` is that shape's outline).
pub shape: Option<TracedShape>,
/// Gradients: the linear gradient the path is filled with (`color` is its middle colour).
pub gradient: Option<TracedGradient>,
}
/// The traced result, bottom-most path first.
@@ -506,7 +514,32 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
let img = smoothed.as_ref().unwrap_or(img);
let mut q = quantize(img, params);
// Gradients are found first and kept out of the palette: no colour is spent on bands of them.
let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(img)).filter(|f| !f.1.is_empty());
let without = found.as_ref().map(|(of, _)| {
let mut rest = img.clone();
for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) {
if g != u32::MAX {
p[3] = 0;
}
}
rest
});
let mut q = quantize(without.as_ref().unwrap_or(img), params);
drop(without);
// Each gradient is a palette entry of its own (its colour: the gradient's middle).
let mut gradients: Vec<Option<TracedGradient>> = vec![None; q.palette.len()];
if let Some((mut of, found)) = found {
gradient::settle_edges(img, &mut of, &found, &mut q.labels);
let first = q.palette.len();
for (l, &g) in q.labels.iter_mut().zip(&of) {
if g != u32::MAX {
*l = u16::try_from(first + g as usize).unwrap_or(TRANSPARENT);
}
}
q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8)));
gradients.extend(found.into_iter().map(Some));
}
denoise(&mut q.labels, w, h, params.noise as usize);
// Pixel count per palette entry, and the box its pixels lie in.
let mut counts = vec![0usize; q.palette.len()];
@@ -547,6 +580,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
img,
ignore,
boxes,
gradients: &gradients,
bw_tones: bw_tones(img, &q),
style: params.outline_style(),
w,
@@ -609,6 +643,8 @@ struct Layers<'a> {
ignore: Option<[u8; 3]>,
/// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded.
boxes: Vec<Option<(usize, usize, usize, usize)>>,
/// Gradients: the gradient each palette entry is, if it is one.
gradients: &'a [Option<TracedGradient>],
/// Black and White: the luminance of white and of black (see `bw_tones`).
bw_tones: [f64; 2],
/// How outlines and centre lines are fitted.
@@ -706,8 +742,15 @@ impl Layers<'_> {
let tone = |l: u16| [if l == 1 { black } else { white }; 3];
(tone(la), tone(lb))
} else {
let (Some(ca), Some(cb)) = (self.q.palette.get(la as usize), self.q.palette.get(lb as usize)) else { return 0.0 };
(ca.map(f64::from), cb.map(f64::from))
// A side's own colour: its palette entry's, or its gradient's colour here.
let own = |l: u16, k: i32| {
let flat = self.q.palette.get(l as usize)?.map(f64::from);
let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) };
let i = c.at(k)?;
Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5)))
};
let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 };
(ca, cb)
};
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
let len2: f64 = d.iter().map(|v| v * v).sum();
@@ -808,7 +851,9 @@ impl Layers<'_> {
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
return false;
}
paths.push(TracedPath { path, color, pixels, stroke, shape });
// (A gradient fills; a stroked line of it is its middle colour.)
let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none());
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient });
true
};
let mut comps = trace_mask(&mask, bw, bh);
+1
View File
@@ -484,6 +484,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
pixels: (s.area * (factor * factor) as f64) as usize,
stroke: None,
shape: None,
gradient: None,
});
}
Ok(out)
+64 -1
View File
@@ -24,9 +24,11 @@ pub enum Class {
Flat,
/// Continuous tone: a gradient and a photo-like image.
Tone,
/// Linear gradients whose line and stops are known: one over the whole image, one in a disc.
Ramp,
}
pub const CLASSES: [Class; 7] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone];
pub const CLASSES: [Class; 8] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone, Class::Ramp];
impl Class {
pub fn name(self) -> &'static str {
@@ -38,6 +40,7 @@ impl Class {
Class::Plain => "plain shapes",
Class::Flat => "flat colour art",
Class::Tone => "gradient, photo",
Class::Ramp => "linear gradients",
}
}
}
@@ -89,9 +92,65 @@ pub struct Input {
pub plain: Vec<TracedShape>,
/// Its lines, where it is lines: each one's two ends and its width.
pub strokes: Vec<(Point, Point, f64)>,
/// The linear gradient it is, where it is one.
pub ramp: Option<Ramp>,
tone: Option<Tone>,
}
/// A linear gradient: its line (256-unit coordinates) and its stops along it (0 to 1).
#[derive(Clone)]
pub struct Ramp {
pub start: Point,
pub end: Point,
pub stops: Vec<(f64, [f64; 3])>,
}
impl Ramp {
pub fn color_at(&self, p: Point) -> [f64; 3] {
let d = self.end - self.start;
let t = ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0);
let after = self.stops.iter().position(|s| s.0 >= t).unwrap().max(1);
let (a, b) = (self.stops[after - 1], self.stops[after]);
let k = (t - a.0) / (b.0 - a.0);
[0, 1, 2].map(|c| a.1[c] + (b.1[c] - a.1[c]) * k)
}
}
/// Dark blue to yellow across the whole image, 25° down from level.
fn two_stop_ramp() -> Ramp {
let dir = kurbo::Vec2::from_angle(25f64.to_radians());
let reach = kurbo::Vec2::new(128.0, 128.0).dot(dir);
let centre = Point::new(128.0, 128.0);
Ramp { start: centre - dir * reach, end: centre + dir * reach, stops: vec![(0.0, [24.0, 40.0, 150.0]), (1.0, [250.0, 215.0, 60.0])] }
}
/// Red through white to green across a disc, 110° from level.
fn three_stop_ramp() -> Ramp {
let dir = kurbo::Vec2::from_angle(110f64.to_radians());
let centre = Point::new(128.0, 128.0);
Ramp {
start: centre - dir * DISC,
end: centre + dir * DISC,
stops: vec![(0.0, [200.0, 30.0, 40.0]), (0.4, [245.0, 240.0, 230.0]), (1.0, [20.0, 120.0, 70.0])],
}
}
/// The radius of the disc the three-stop gradient fills.
const DISC: f64 = 90.0;
fn two_stop(u: f64, v: f64, _: u32, _: u32) -> [f64; 3] {
two_stop_ramp().color_at(Point::new(u * 256.0, v * 256.0))
}
fn three_stop(u: f64, v: f64, x: u32, _: u32) -> [f64; 3] {
let p = Point::new(u * 256.0, v * 256.0);
let inside = three_stop_ramp().color_at(p);
// The disc's edge, anti-aliased: how much of the pixel (256 / size units wide) is in it.
let pixel = 256.0 * u / (f64::from(x) + 0.5);
let cover = (0.5 + (DISC - p.distance(Point::new(128.0, 128.0))) / pixel).clamp(0.0, 1.0);
[0, 1, 2].map(|c| 255.0 + (inside[c] - 255.0) * cover)
}
const WHITE: [u8; 3] = [255, 255, 255];
const INK: [u8; 3] = [0, 0, 0];
@@ -179,6 +238,7 @@ pub fn inputs() -> Vec<Input> {
corners,
strokes: vec![],
plain: vec![],
ramp: None,
tone: None,
};
let tone = |name, f: Tone| Input {
@@ -190,6 +250,7 @@ pub fn inputs() -> Vec<Input> {
corners: vec![],
strokes: vec![],
plain: vec![],
ramp: None,
tone: Some(f),
};
let ink = |p: BezPath| vec![(p, INK)];
@@ -301,6 +362,8 @@ pub fn inputs() -> Vec<Input> {
art("four-colour badge", Class::Flat, multicolor, 20, points(&bar)),
tone("gradient", gradient),
tone("photo-like", photo),
Input { class: Class::Ramp, ramp: Some(two_stop_ramp()), ..tone("two-stop gradient", two_stop) },
Input { class: Class::Ramp, ramp: Some(three_stop_ramp()), ..tone("three-stop gradient in a disc", three_stop) },
];
if let Some(input) = all.iter_mut().find(|i| i.name == "plain shapes") {
input.plain = plain;
+33 -3
View File
@@ -74,6 +74,7 @@ fn target(preset: &str, input: &Input) -> bool {
"3 Colors" => return input.name == "two touching halves",
"6 Colors" | "16 Colors" | "Flat Logo" | "Auto Color" | "6 Colors with Auto Grouping" => &[Class::Flat],
"Default with Shapes" | "6 Colors with Shapes" => &[Class::Plain],
name if name.ends_with("Gradients") => &[Class::Ramp],
"Sketched Art" => &[Class::Shape, Class::Lines],
"Silhouettes" => &[Class::Shape],
"Line Art" | "Outline" => &[Class::Lines],
@@ -139,7 +140,14 @@ fn svg_trace(svg: &str) -> TraceResult {
_ => panic!("unread transform {t}"),
});
}
Some(TracedPath { path: vectorcraft_geom::PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: None, shape: None })
Some(TracedPath {
path: vectorcraft_geom::PathData::from_bezpath(&path),
color: [0; 3],
pixels: 0,
stroke: None,
shape: None,
gradient: None,
})
});
TraceResult { paths: paths.collect(), palette: vec![[0; 3]], ..Default::default() }
}
@@ -177,12 +185,12 @@ fn stat(runs: &[&Metrics], field: impl Fn(&Metrics) -> f64, max: bool, digits: u
if x.is_finite() { format!("{:.digits$}", x + 0.0) } else { "all".into() }
}
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | grouped % |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | grouped % | gradient paths | found % | angle off | ramp off |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
fn row(runs: &[&Run]) -> String {
let m: Vec<&Metrics> = runs.iter().filter_map(|r| r.metrics.as_ref()).collect();
if m.is_empty() {
return format!("| refused |{}", " |".repeat(22));
return format!("| refused |{}", " |".repeat(26));
}
let cells = [
stat(&m, |m| m.delta, false, 2),
@@ -208,6 +216,10 @@ fn row(runs: &[&Run]) -> String {
stat(&m, |m| m.stroke_anchors, false, 1),
stat(&m, |m| m.shapes, false, 0),
stat(&m, |m| m.grouped, false, 0),
stat(&m, |m| m.gradients, false, 1),
stat(&m, |m| m.ramp_found, false, 0),
stat(&m, |m| m.ramp_angle, true, 1),
stat(&m, |m| m.ramp_off, true, 0),
];
format!("| {} |", cells.join(" | "))
}
@@ -243,6 +255,15 @@ fn report() -> String {
] {
presets.push((name, TraceParams { auto_grouping: true, ..vectorcraft_trace::preset(base).unwrap() }));
}
// Gradients: on the inputs that are linear gradients, with the option and without it.
for (name, base, gradients) in [
("6 Colors, no Gradients", "6 Colors", false),
("6 Colors with Gradients", "6 Colors", true),
("High Fidelity Photo, no Gradients", "High Fidelity Photo", false),
("High Fidelity Photo with Gradients", "High Fidelity Photo", true),
] {
presets.push((name, TraceParams { gradients, ..vectorcraft_trace::preset(base).unwrap() }));
}
let inputs = inputs();
let drawn = draw_all(&inputs);
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew | Class::Plain);
@@ -326,6 +347,15 @@ fn report() -> String {
fn quality_report() {
let md = report();
println!("{md}");
// Gradients finds none in art that is flat colour, however it is degraded.
let inputs = inputs();
for (name, _) in presets().iter().filter(|(_, p)| p.mode != vectorcraft_trace::Mode::BlackAndWhite && p.mode != vectorcraft_trace::Mode::Logo) {
let params = TraceParams { gradients: true, ..vectorcraft_trace::preset(name).unwrap() };
for (input, size, variant, img) in draw_all(&inputs).iter().filter(|d| !matches!(d.0.class, Class::Tone | Class::Ramp)) {
let found = trace_within(img, &params, MAX_ANCHORS).map_or(0, |res| res.paths.iter().filter(|p| p.gradient.is_some()).count());
assert_eq!(found, 0, "{name} with Gradients on {} at {size} px, {variant:?}", input.name);
}
}
if let Ok(path) = std::env::var("TRACE_QUALITY_REPORT") {
std::fs::write(path, md).unwrap();
}
+41 -5
View File
@@ -4,10 +4,10 @@ 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 vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, TracedGradient, TracedPath};
use crate::inputs::{Class, Input, Variant};
use crate::raster::{coverage_into, polylines, render};
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] {
@@ -196,6 +196,13 @@ pub struct Metrics {
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.
@@ -225,8 +232,33 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
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])> = draw_order(res).into_iter().map(|i| shapes[i].clone()).collect();
let (delta_mean, edge_delta) = fidelity(src, &render(&drawn, [255; 3], n, n));
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.
@@ -239,7 +271,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
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 {
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)));
@@ -341,6 +373,10 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
Metrics {
grouped,
gradients,
ramp_found,
ramp_angle,
ramp_off,
shapes: shapes_found,
centre_off,
width_off,
+13 -3
View File
@@ -2,7 +2,7 @@
//! from ground-truth vector shapes here, never loaded from files.
use kurbo::{BezPath, PathEl, Point};
use vectorcraft_trace::Raster;
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>> {
@@ -78,15 +78,25 @@ pub fn coverage(path: &BezPath, w: usize, h: usize) -> Vec<f32> {
/// 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) in scene {
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] += (f32::from(color[ch]) - buf[i][ch]) * a;
buf[i][ch] += (color[ch] - buf[i][ch]) * a;
}
cov[i] = 0.0;
}
+7
View File
@@ -372,6 +372,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
st.preset = "Custom".into();
retrace = true;
}
// (Black and White has one colour and Flat Logo flat ones: no gradients to find.)
let gradients = st.params["gradients"].as_bool().unwrap_or(false);
if widgets::check(ui, tl!("Gradients"), gradients, mode == "grayscale" || mode == "color") {
st.params["gradients"] = json!(!gradients);
st.preset = "Custom".into();
retrace = true;
}
// Ignore Color: white, unless another colour is picked.
ui.horizontal(|ui| {
let on = st.params["ignoreWhite"].as_bool().unwrap_or(false);
File diff suppressed because it is too large. Load diff