mirror of
https://github.com/aphrody-labs/vectorcraft.git
synced 2026-10-14 03:29:00 +00:00
WIP, not gated: gradients grown over noise and soft rims, largest area first; radial gradients (truth input, engine mapping)
This commit is contained in:
1 parent
6cb9c917d2
commit
72faa8a154
6 files changed
+247
-42
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), 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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, _| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user