Merge pull request #1627 from storytold/u/trace-gradients
Image Trace: Gradients
This commit is contained in:
12 files changed
+1217
-442
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶ms, 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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+531
-423
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user