diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 635a3b07..b7d3e583 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "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)) } diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 2bf8b0f1..f744b7e8 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -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. diff --git a/crates/trace/src/gradient.rs b/crates/trace/src/gradient.rs index 1d365e5e..d320fd28 100644 --- a/crates/trace/src/gradient.rs +++ b/crates/trace/src/gradient.rs @@ -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 { + 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, Vec) { @@ -103,11 +123,8 @@ pub(crate) fn detect(img: &Raster) -> (Vec, Vec) { 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| { members.clear(); stack.push(seed); if let Some(s) = seen.get_mut(seed) { @@ -115,8 +132,7 @@ pub(crate) fn detect(img: &Raster) -> (Vec, Vec) { } 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, Vec) { } } } - 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, Vec) { 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 = members.iter().copied().filter(|&i| beside(i).any(|j| depth.get(j).copied() == Some(0))).collect(); + let mut front: Vec = 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, Vec) { let inside: Vec = 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)> { 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 { + 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)> { + 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, _| { diff --git a/crates/trace/tests/quality/inputs.rs b/crates/trace/tests/quality/inputs.rs index 9b3621b9..b1ad941c 100644 --- a/crates/trace/tests/quality/inputs.rs +++ b/crates/trace/tests/quality/inputs.rs @@ -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, } -/// 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 { 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; diff --git a/crates/trace/tests/quality/main.rs b/crates/trace/tests/quality/main.rs index 6b472b0b..203c6cae 100644 --- a/crates/trace/tests/quality/main.rs +++ b/crates/trace/tests/quality/main.rs @@ -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, ¶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); } diff --git a/crates/trace/tests/quality/measure.rs b/crates/trace/tests/quality/measure.rs index 0fffbcd7..17dde602 100644 --- a/crates/trace/tests/quality/measure.rs +++ b/crates/trace/tests/quality/measure.rs @@ -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)));