WIP, not gated: gradients grown over noise and soft rims, largest area first; radial gradients (truth input, engine mapping)

This commit is contained in:
bflatastic committed 2026-10-13 14:26:53 +07:00
1 parent 6cb9c917d2
commit 72faa8a154
6 files changed
+247 -42

No files matched your search

+6 -4
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), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo and Flat Logo), 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}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo and Flat Logo), 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, or outwards from a point, is one path with a linear or radial 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)
),
@@ -729,12 +729,14 @@ 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`.
/// The gradient fill for a gradient the tracer found (in the image's pixels), placed in the
/// document by `xf` (a radial one: its middle and a point on its outermost circle; a circle
/// still where the image is scaled evenly).
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 });
let kind = if g.radial { GradientKind::Radial } else { GradientKind::Linear };
let mut paint = GradientPaint::new(Gradient { kind, stops });
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
+13
View File
@@ -771,6 +771,19 @@ fn image_trace_gradients_fills_with_a_gradient() {
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 { .. })));
// Colours going out from a point: a radial gradient, its middle where the image has it.
let round = vectorcraft_trace::Raster::from_fn(120, 100, |x, y| {
let t = ((f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 40.0) / 90.0).min(1.0);
[(250.0 - 200.0 * t) as u8, (230.0 - 150.0 * t) as u8, 90, 255]
});
let mut s = session();
add_image(&mut s, &round, Affine::translate((10.0, 20.0)));
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": true}})).unwrap();
let paths = traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())));
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
assert_eq!((paths.len(), g.gradient.kind), (1, vectorcraft_color::GradientKind::Radial));
let middle = g.geom.as_ref().unwrap().start;
assert!((middle.x - 60.0).abs() < 1.5 && (middle.y - 60.0).abs() < 1.5, "{middle:?}");
}
/// A preset's enhanced version: the preset with Auto Grouping and Shapes, under the preset's name.
+184 -29
View File
@@ -25,10 +25,14 @@ 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;
/// How far (levels) a pixel beside a gradient may be from the gradient's colour there and join it.
const GROW: f64 = 10.0;
/// A linear gradient a traced path is filled with.
/// A gradient a traced path is filled with: linear, or radial (the same colour all round a point).
#[derive(Clone, Debug, PartialEq)]
pub struct TracedGradient {
/// Radial: `start` is the middle, and the stops go outwards to the circle through `end`.
pub radial: bool,
/// 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,
@@ -38,10 +42,20 @@ pub struct TracedGradient {
}
impl TracedGradient {
/// How far along the gradient `p` is: 0 at `start`, 1 at `end` (or on the circle through it).
fn along(&self, p: Point) -> f64 {
let d = self.end - self.start;
if d.hypot2() <= 0.0 {
0.0
} else if self.radial {
(p - self.start).hypot() / d.hypot()
} else {
(p - self.start).dot(d) / d.hypot2()
}
}
/// 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 t = self.along(p).clamp(0.0, 1.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) };
@@ -52,6 +66,12 @@ impl TracedGradient {
}
}
/// The pixels beside pixel `i` of a `w` × `h` image.
fn beside(i: usize, w: usize, h: usize) -> impl Iterator<Item = 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()
}
/// 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>) {
@@ -103,11 +123,8 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
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.
// The connected smooth pixels from `seed` that `seen` has not: into `members`.
let mut flood = |seed: usize, seen: &mut [bool], members: &mut Vec<usize>| {
members.clear();
stack.push(seed);
if let Some(s) = seen.get_mut(seed) {
@@ -115,8 +132,7 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
}
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() {
for j in beside(i, w, h) {
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;
@@ -125,7 +141,26 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
}
}
}
if members.len() < MIN_PIXELS || found.len() >= 1000 {
};
// The areas, largest first: a gradient that noise or a soft rim broke into several is found in
// its largest piece and grown over the rest.
let mut areas: Vec<(usize, usize)> = vec![];
for seed in 0..w * h {
if !seen.get(seed).copied().unwrap_or(true) && smooth.get(seed).copied().unwrap_or(false) {
flood(seed, &mut seen, &mut members);
if members.len() >= MIN_PIXELS {
areas.push((members.len(), seed));
}
}
}
areas.sort_unstable_by(|a, b| b.cmp(a));
seen.fill(false);
for (_, seed) in areas {
if seen.get(seed).copied().unwrap_or(true) || found.len() >= 1000 {
continue;
}
flood(seed, &mut seen, &mut members);
if members.len() < MIN_PIXELS {
continue;
}
// The gradient is read from the area's inside, where there is one: the pixels at its rim
@@ -135,16 +170,12 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
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();
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i, w, h).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) {
for j in beside(i, w, h) {
if let Some(slot) = depth.get_mut(j).filter(|s| **s == unknown) {
*slot = d;
next.push(j);
@@ -156,19 +187,34 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
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 {
if let Some((g, mut part)) = fitted {
for &i in &part {
if let Some(o) = of.get_mut(i) {
*o = found.len() as u32;
}
}
// Grown over every pixel next to it that has its colour there: the pixels noise put a
// little off, a soft rim, the pieces noise cut off it.
while let Some(i) = part.pop() {
for j in beside(i, w, h) {
let near = px(j).is_some_and(|c| {
let own = g.color_at(Point::new((j % w) as f64 + 0.5, (j / w) as f64 + 0.5));
(0..3).all(|k| (f64::from(c[k]) - own[k]).abs() <= GROW)
});
if near && of.get(j).copied() == Some(u32::MAX) {
of[j] = found.len() as u32;
seen[j] = true;
part.push(j);
}
}
}
found.push(g);
}
}
(of, found)
}
/// The linear gradient the pixels `members` of `img` (`w` wide) are, if they are one, and those of
/// The gradient (linear, else radial) 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;
@@ -206,14 +252,101 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
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());
let line = (major > 0.0 && minor <= 0.03 * major)
.then(|| ramp(img, w, members, all, rim, Axis::Line(middle, Vec2::new(angle.cos(), angle.sin()))))
.flatten();
// Not along one direction: round a point, perhaps.
line.or_else(|| ramp(img, w, members, all, rim, Axis::Round(middle_of_rings(img, w, members)?)))
}
/// What a gradient's colours go along: a line through a point, or outwards from a point.
#[derive(Clone, Copy)]
enum Axis {
Line(Point, Vec2),
Round(Point),
}
impl Axis {
/// How far (px) along it `p` is.
fn along(self, p: Point) -> f64 {
match self {
Axis::Line(through, dir) => (p - through).dot(dir),
Axis::Round(middle) => (p - middle).hypot(),
}
}
/// The point `t` px along it.
fn at(self, t: f64) -> Point {
match self {
Axis::Line(through, dir) => through + dir * t,
Axis::Round(middle) => middle + Vec2::new(t, 0.0),
}
}
}
/// The middle of the pixels `members` if their colour goes round a point: the common middle of the
/// circles that the pixels of one tone lie on, for three tones.
fn middle_of_rings(img: &Raster, w: usize, members: &[usize]) -> Option<Point> {
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 3).map_or([0u8; 3], |p| [p[0], p[1], p[2]]);
// By the channel that changes most.
let (low, high) =
members.iter().fold(([255u8; 3], [0u8; 3]), |m, &i| ([0, 1, 2].map(|k| m.0[k].min(rgb(i)[k])), [0, 1, 2].map(|k| m.1[k].max(rgb(i)[k]))));
let channel = (0..3).max_by_key(|&k| high[k].saturating_sub(low[k]))?;
let mut count = [0usize; 256];
members.iter().for_each(|&i| count[rgb(i)[channel] as usize] += 1);
let tone_at = |share: f64| {
let mut seen = 0;
count.iter().position(|&n| {
seen += n;
seen as f64 >= share * members.len() as f64
})
};
let mut middles = vec![];
for share in [0.3, 0.5, 0.7] {
let tone = tone_at(share)? as i32;
// The circle x² + y² + d x + e y + f = 0 nearest the pixels of that tone, by least squares.
let (mut m, mut v) = ([[0.0f64; 3]; 3], [0.0f64; 3]);
let mut n = 0;
for &i in members.iter().filter(|&&i| (i32::from(rgb(i)[channel]) - tone).abs() <= 1) {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let (row, z) = ([x, y, 1.0], -(x * x + y * y));
for a in 0..3 {
for b in 0..3 {
m[a][b] += row[a] * row[b];
}
v[a] += row[a] * z;
}
n += 1;
}
let det = |m: &[[f64; 3]; 3]| {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
};
let whole = det(&m);
if n < 30 || whole.abs() < 1e-6 {
return None;
}
let solved = |col: usize| {
let mut swapped = m;
(0..3).for_each(|r| swapped[r][col] = v[r]);
det(&swapped) / whole
};
middles.push(Point::new(-solved(0) / 2.0, -solved(1) / 2.0));
}
let mean = (middles.iter().fold(Vec2::ZERO, |s, p| s + p.to_vec2()) / middles.len() as f64).to_point();
(mean.x.is_finite() && mean.y.is_finite() && middles.iter().all(|p| p.distance(mean) <= 3.0)).then_some(mean)
}
/// The gradient the pixels `members` are along `axis`, if they are one (see [`fit`]).
fn ramp(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize, axis: Axis) -> Option<(TracedGradient, Vec<usize>)> {
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 radial = matches!(axis, Axis::Round(_));
// The colours along it, a pixel's width at a time.
let along = |i: usize| (at(i) - middle).dot(dir);
let along = |i: usize| axis.along(at(i));
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)));
// (A radial gradient starts at its middle, whether or not the area reaches it.)
let t0 = if radial { 0.0 } else { t0 };
let length = t1 - t0;
if !(MIN_LENGTH..1e6).contains(&length) {
return None;
@@ -272,7 +405,7 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
_ => stops.push((t, c)),
}
}
let (first, last) = (stops.first()?.0, stops.last()?.0);
let (first, last) = (if radial { 0.0 } else { 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);
@@ -282,13 +415,19 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
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(),
radial,
start: axis.at(t0 + first * length),
end: axis.at(t0 + last * length),
// (A radial one's first colour goes on to its middle.)
stops: stops
.iter()
.enumerate()
.map(|(j, &(t, c))| (if j == 0 { 0.0 } else { (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 place = |i: usize| gradient.along(at(i));
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));
@@ -305,6 +444,7 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
}
}
let gradient = TracedGradient {
radial,
start: gradient.start + d * u0,
end: gradient.start + d * u1,
stops: stops.into_iter().map(|(u, c)| ((u - u0) / (u1 - u0), c)).collect(),
@@ -423,6 +563,21 @@ mod tests {
assert!(detect(&ramp(96, 96, |x, y| [40.0 + 2.0 * x, 40.0 + 2.0 * y, 90.0])).1.is_empty());
}
#[test]
fn colours_going_round_a_point_are_a_radial_gradient() {
// Yellow at (70, 50) to dark red 80 px out.
let img = ramp(160, 120, |x, y| {
let t = ((x - 70.0).hypot(y - 50.0) / 80.0).min(1.0);
[250.0 - 130.0 * t, 220.0 - 190.0 * t, 60.0 - 30.0 * t]
});
let (_, found) = detect(&img);
let g = found.iter().find(|g| g.radial).expect("a radial gradient");
assert!(g.start.distance(Point::new(70.0, 50.0)) < 1.0, "{:?}", g.start);
assert!(((g.end - g.start).hypot() - 80.0).abs() < 5.0, "{:?}", g);
let (first, last) = (g.stops[0].1, g.stops.last().unwrap().1);
assert!(first[0] > 240 && first[1] > 205 && last[0] < 135 && last[1] < 45, "{:?}", g.stops);
}
#[test]
fn three_stops_are_three_stops() {
let img = ramp(200, 40, |x, _| {
+32 -4
View File
@@ -26,9 +26,12 @@ pub enum Class {
Tone,
/// Linear gradients whose line and stops are known: one over the whole image, one in a disc.
Ramp,
/// A radial gradient whose middle and stops are known.
Round,
}
pub const CLASSES: [Class; 8] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone, Class::Ramp];
pub const CLASSES: [Class; 9] =
[Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone, Class::Ramp, Class::Round];
impl Class {
pub fn name(self) -> &'static str {
@@ -41,6 +44,7 @@ impl Class {
Class::Flat => "flat colour art",
Class::Tone => "gradient, photo",
Class::Ramp => "linear gradients",
Class::Round => "radial gradient",
}
}
}
@@ -97,9 +101,11 @@ pub struct Input {
tone: Option<Tone>,
}
/// A linear gradient: its line (256-unit coordinates) and its stops along it (0 to 1).
/// A gradient: its line (256-unit coordinates) and its stops along it (0 to 1); or, radial, its
/// middle, a point on its outermost circle, and its stops outwards.
#[derive(Clone)]
pub struct Ramp {
pub radial: bool,
pub start: Point,
pub end: Point,
pub stops: Vec<(f64, [f64; 3])>,
@@ -108,7 +114,7 @@ pub struct Ramp {
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 t = if self.radial { (p - self.start).hypot() / d.hypot() } else { (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);
@@ -121,7 +127,12 @@ 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])] }
Ramp {
radial: false,
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.
@@ -129,12 +140,28 @@ fn three_stop_ramp() -> Ramp {
let dir = kurbo::Vec2::from_angle(110f64.to_radians());
let centre = Point::new(128.0, 128.0);
Ramp {
radial: false,
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])],
}
}
/// Pale yellow at (100, 150) out to deep blue 200 units away, over the whole image.
fn radial_ramp() -> Ramp {
let middle = Point::new(100.0, 150.0);
Ramp {
radial: true,
start: middle,
end: middle + kurbo::Vec2::new(200.0, 0.0),
stops: vec![(0.0, [250.0, 240.0, 150.0]), (1.0, [20.0, 40.0, 130.0])],
}
}
fn radial(u: f64, v: f64, _: u32, _: u32) -> [f64; 3] {
radial_ramp().color_at(Point::new(u * 256.0, v * 256.0))
}
/// The radius of the disc the three-stop gradient fills.
const DISC: f64 = 90.0;
@@ -364,6 +391,7 @@ pub fn inputs() -> Vec<Input> {
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) },
Input { class: Class::Round, ramp: Some(radial_ramp()), ..tone("radial gradient", radial) },
];
if let Some(input) = all.iter_mut().find(|i| i.name == "plain shapes") {
input.plain = plain;
+2 -2
View File
@@ -75,7 +75,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],
name if name.ends_with("Gradients") => &[Class::Ramp, Class::Round],
"Sketched Art" => &[Class::Shape, Class::Lines],
"Silhouettes" => &[Class::Shape],
"Line Art" | "Outline" => &[Class::Lines],
@@ -359,7 +359,7 @@ fn quality_report() {
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)) {
for (input, size, variant, img) in draw_all(&inputs).iter().filter(|d| !matches!(d.0.class, Class::Tone | Class::Ramp | Class::Round)) {
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);
}
+10 -3
View File
@@ -247,12 +247,19 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
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());
// (Of the input's kind: a linear gradient found in a radial one is not it.)
let found = res
.paths
.iter()
.filter(|tp| tp.gradient.as_ref().is_some_and(|g| g.radial == ramp.radial))
.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);
// (A radial gradient has no direction: its middle shows in the colours along the line.)
ramp_angle = if ramp.radial { f64::NAN } else { between.min(180.0 - between) };
ramp_off = (0..=16)
.map(|j| {
let p = ramp.start + truth * (f64::from(j) / 16.0);
@@ -277,7 +284,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);
let mut false_shapes = if params.shapes { 0.0 } else { nan };
if !matches!(input.class, Class::Tone | Class::Ramp) {
if !matches!(input.class, Class::Tone | Class::Ramp | Class::Round) {
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)));