Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72faa8a154 | ||
|
|
6cb9c917d2 | ||
|
|
7b41de9ef6 | ||
|
|
b934b44ca8 |
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), 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)
|
||||
),
|
||||
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
trace_set_view
|
||||
),
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -657,7 +657,9 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
|
||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
|
||||
let base = if enhanced { tr::enhanced_preset(name) } else { tr::preset(name) };
|
||||
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
|
||||
@@ -727,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))
|
||||
}
|
||||
@@ -778,12 +782,11 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let mut params = trace_params(p)?;
|
||||
let view = view_param(C, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's, else "Custom".
|
||||
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
|
||||
// else "Custom".
|
||||
let named = str_param(p, "preset").unwrap_or("Default");
|
||||
let preset_label = match tr::preset(named) {
|
||||
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(¶ms_json) => named.to_string(),
|
||||
_ => "Custom".to_string(),
|
||||
};
|
||||
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json);
|
||||
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
|
||||
let roots = root_ids(s, p)?;
|
||||
// The image (inside a trace group, or selected directly).
|
||||
let (target, img) = {
|
||||
@@ -848,10 +851,10 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
// 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.
|
||||
// Traced lines: round corners, and ends round or square as the tracer found them.
|
||||
Some(width) => Appearance {
|
||||
items: vec![AppearanceItem::Stroke(StrokeLayer {
|
||||
cap: LineCap::Round,
|
||||
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
|
||||
join: LineJoin::Round,
|
||||
..StrokeLayer::new(paint, width * unit)
|
||||
})],
|
||||
|
||||
@@ -771,6 +771,40 @@ 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.
|
||||
#[test]
|
||||
fn image_trace_enhanced_presets() {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "enhanced": true})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let stored = n.extras().trace.clone().unwrap();
|
||||
assert_eq!(stored["preset"], "6 Colors");
|
||||
assert_eq!((stored["params"]["shapes"].as_bool(), stored["params"]["autoGrouping"].as_bool()), (Some(true), Some(true)));
|
||||
// The disc of the test image is a live ellipse, in a group with nothing (it has nothing inside).
|
||||
let shapes: Vec<_> = traced_paths(&n).into_iter().filter(|c| matches!(&c.kind, NodeKind::Path { live: Some(_), .. })).collect();
|
||||
assert_eq!(shapes.len(), 1);
|
||||
// Listed with each preset; the legacy preset is as it was.
|
||||
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let six = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "6 Colors").unwrap().clone();
|
||||
assert_eq!((six["params"]["shapes"].as_bool(), six["enhanced"]["shapes"].as_bool()), (Some(false), Some(true)));
|
||||
assert_eq!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "High Fidelity Photo").unwrap()["enhanced"]["shapes"], false);
|
||||
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such", "enhanced": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,6 +36,8 @@ pub(crate) struct Line {
|
||||
/// Stroke width in pixels.
|
||||
pub width: f64,
|
||||
pub pixels: usize,
|
||||
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
|
||||
pub square: bool,
|
||||
}
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
@@ -374,22 +376,44 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
|
||||
// wide as its pixels make it).
|
||||
widths.sort_by(f64::total_cmp);
|
||||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||||
// Each end goes where the ink ends, less the half width its round cap adds.
|
||||
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
|
||||
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
|
||||
// of the line's middle as along it, where a round end stops short by a seventh of the width.
|
||||
let (mut short_by, mut told) = (0.0, 0);
|
||||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||||
for k in 0..2 {
|
||||
if r.ends[k].is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let n = r.pts.len();
|
||||
let (tip, inner) = if k == 0 { (r.pts[0], r.pts[3.min(n - 1)]) } else { (r.pts[n - 1], r.pts[n - 1 - 3.min(n - 1)]) };
|
||||
let d = tip - inner;
|
||||
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
|
||||
// The last points of a wide line's skeleton turn towards a corner of its end: the end
|
||||
// lies where the line was going a width back, and they are put on that line.
|
||||
let back = (width.round() as usize + 2).max(3);
|
||||
let wide = width >= 3.0 && n > 2 * back;
|
||||
let (tip, inner) = (r.pts[at(0)], r.pts[at(back)]);
|
||||
let d = if wide { inner - r.pts[at(2 * back)] } else { tip - inner };
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let more = ink.beyond(tip, d / d.hypot()) - width / 2.0;
|
||||
if more.abs() > 0.05 && more > -1.0 {
|
||||
let to = tip + d / d.hypot() * more;
|
||||
if k == 0 { r.pts[0] = to } else { r.pts[n - 1] = to }
|
||||
let along = d / d.hypot();
|
||||
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
|
||||
if wide {
|
||||
for i in 0..back {
|
||||
r.pts[at(i)] = on_line(r.pts[at(i)]);
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
|
||||
short_by += short / width;
|
||||
told += 1;
|
||||
}
|
||||
let more = reach - width / 2.0;
|
||||
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
|
||||
r.pts[at(0)] = tip + along * more;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,7 +433,7 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len(), square: told > 0 && short_by / f64::from(told) < 0.07 })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
|
||||
@@ -188,7 +188,7 @@ pub(crate) fn fit_loop(
|
||||
across: &dyn Fn(Crack) -> (f64, u32),
|
||||
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
|
||||
style: &Style,
|
||||
) -> Option<SubPath> {
|
||||
) -> Option<(SubPath, bool)> {
|
||||
let pieces = subpixel::outline(l, w, h, across, junction_at);
|
||||
let mut segs: Vec<Cubic> = Vec::new();
|
||||
for piece in &pieces {
|
||||
@@ -206,7 +206,7 @@ pub(crate) fn fit_loop(
|
||||
segs.extend(fit_piece(&piece.pts, &style, true));
|
||||
}
|
||||
}
|
||||
subpath(&segs, true)
|
||||
Some((subpath(&segs, true)?, pieces.iter().any(|p| p.hard)))
|
||||
}
|
||||
|
||||
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
|
||||
|
||||
+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, _| {
|
||||
|
||||
@@ -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, gradient: None }
|
||||
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+76
-7
@@ -430,6 +430,14 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> {
|
||||
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
|
||||
}
|
||||
|
||||
/// The enhanced version of built-in preset `name`: the preset with the options the enhanced
|
||||
/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo
|
||||
/// (and Flat Logo, which has shapes of its own). Gradients stay an option to turn on.
|
||||
pub fn enhanced_preset(name: &str) -> Option<TraceParams> {
|
||||
let p = preset(name)?;
|
||||
Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo") && p.mode != Mode::Logo, ..p })
|
||||
}
|
||||
|
||||
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
|
||||
/// lines stroked with it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -446,6 +454,9 @@ pub struct TracedPath {
|
||||
pub shape: Option<TracedShape>,
|
||||
/// Gradients: the linear gradient the path is filled with (`color` is its middle colour).
|
||||
pub gradient: Option<TracedGradient>,
|
||||
/// Create Strokes: the stroke's ends are square (a cap that goes half the width past the
|
||||
/// path's end, cut straight), as the line's ink is; otherwise round.
|
||||
pub square_caps: bool,
|
||||
}
|
||||
|
||||
/// The traced result, bottom-most path first.
|
||||
@@ -810,6 +821,36 @@ impl Layers<'_> {
|
||||
shift
|
||||
}
|
||||
|
||||
/// Whether the pixels inside `l` (a loop along pixel edges) all have one label.
|
||||
fn one_colour_within(&self, l: &Loop) -> bool {
|
||||
let mut first = None;
|
||||
let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1)));
|
||||
for y in y0..y1 {
|
||||
// The loop's upright edges that pass this row of pixels, left to right: the pixels
|
||||
// between the first and the second are inside, and so on.
|
||||
let mut xs: Vec<i32> = l
|
||||
.pts
|
||||
.iter()
|
||||
.zip(l.pts.iter().cycle().skip(1))
|
||||
.filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1))
|
||||
.map(|(a, _)| a.0)
|
||||
.collect();
|
||||
xs.sort_unstable();
|
||||
for pair in xs.as_chunks::<2>().0 {
|
||||
for x in pair[0]..pair[1] {
|
||||
let label = usize::try_from(y)
|
||||
.ok()
|
||||
.zip(usize::try_from(x).ok())
|
||||
.and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w));
|
||||
if label.is_none() || *first.get_or_insert(label) != label {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
first.is_some()
|
||||
}
|
||||
|
||||
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
|
||||
/// maximum.
|
||||
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
|
||||
@@ -846,14 +887,14 @@ impl Layers<'_> {
|
||||
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
|
||||
let min_hole = params.noise.max(1) as i64;
|
||||
let mut paths = vec![];
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>| {
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>, square_caps: bool| {
|
||||
let n = path.anchor_count();
|
||||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||||
return false;
|
||||
}
|
||||
// (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 });
|
||||
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient, square_caps });
|
||||
true
|
||||
};
|
||||
let mut comps = trace_mask(&mask, bw, bh);
|
||||
@@ -866,27 +907,55 @@ impl Layers<'_> {
|
||||
}
|
||||
}
|
||||
for comp in comps {
|
||||
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style);
|
||||
// (`hard`: some of the outline has no anti-aliasing, and is known less exactly.)
|
||||
let hard = std::cell::Cell::new(false);
|
||||
let fit = |l: &Loop| {
|
||||
let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?;
|
||||
hard.set(hard.get() || is_hard);
|
||||
Some(sp)
|
||||
};
|
||||
let Some(outer) = fit(&comp.outer) else { continue };
|
||||
let mut subs = vec![outer];
|
||||
for hole in &comp.holes {
|
||||
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
|
||||
continue;
|
||||
}
|
||||
subs.extend(fit(hole));
|
||||
let Some(mut sp) = fit(hole) else { continue };
|
||||
// Shapes: a hole that is a shape is that shape, as what fills it is (the two are
|
||||
// one outline: they stay one). Only where one colour fills it: the parts of
|
||||
// several are not that shape, and would no longer meet it.
|
||||
if params.shapes
|
||||
&& area_stroke.is_none()
|
||||
&& self.one_colour_within(hole)
|
||||
&& let Some(exact) = shapes::recognise(&sp, hard.get()).and_then(|shape| shape.path().subpaths.into_iter().next())
|
||||
{
|
||||
// (Which way round each goes: by the area of the polygon through points along it.)
|
||||
let turn = |sp: &vectorcraft_geom::SubPath| {
|
||||
use vectorcraft_geom::ParamCurve;
|
||||
let pts: Vec<_> =
|
||||
(0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect();
|
||||
pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::<f64>()
|
||||
};
|
||||
let mut exact = exact;
|
||||
if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) {
|
||||
exact.reverse();
|
||||
}
|
||||
sp = exact;
|
||||
}
|
||||
subs.push(sp);
|
||||
}
|
||||
// Shapes: a filled outline without holes that is a shape becomes that shape.
|
||||
let shape = match subs.as_slice() {
|
||||
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only),
|
||||
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only, hard.get()),
|
||||
_ => None,
|
||||
};
|
||||
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
|
||||
if !add(path, comp.pixels, area_stroke, shape) {
|
||||
if !add(path, comp.pixels, area_stroke, shape, false) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for line in lines {
|
||||
if !add(line.path, line.pixels, Some(line.width), None) {
|
||||
if !add(line.path, line.pixels, Some(line.width), None, line.square) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +493,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
square_caps: false,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
+105
-56
@@ -37,13 +37,18 @@ impl TracedShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// How near (px) a sharp corner the outline may fall short of it, and by how much more than
|
||||
/// elsewhere: anti-aliasing and blur take that much off a corner.
|
||||
const CORNER: f64 = 3.0;
|
||||
const CORNER_SLACK: f64 = 0.75;
|
||||
|
||||
/// How far a point may lie from the shape: half a pixel, and for large shapes 0.2% of their size.
|
||||
fn tolerance(size: f64) -> f64 {
|
||||
(0.002 * size).max(0.5)
|
||||
}
|
||||
|
||||
/// The shape the closed outline `sp` is, if it is one (and at least 6 px across).
|
||||
pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
|
||||
pub(crate) fn recognise(sp: &SubPath, hard: bool) -> Option<TracedShape> {
|
||||
if !sp.closed || sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
@@ -83,7 +88,8 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
|
||||
// The direction of the longer axis (none when the shape is as wide every way).
|
||||
let axis = if spread < 0.01 * mean { 0.0 } else { 0.5 * (2.0 * sxy).atan2(sxx - syy) };
|
||||
let size = 4.0 * major.sqrt();
|
||||
let tol = tolerance(size);
|
||||
// (An outline without anti-aliasing is only known to about half a pixel.)
|
||||
let tol = tolerance(size) * if hard { 1.5 } else { 1.0 };
|
||||
if size < 6.0 || !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -110,64 +116,107 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
|
||||
}
|
||||
}
|
||||
|
||||
// A rectangle, along its longest straight side (a square has no longer axis to go by).
|
||||
// A rectangle. Its sides run along its longest straight side if it has one, else along the
|
||||
// longer axis of its area (a blurred one may have no side that is straight), or the pixels.
|
||||
let quarter = std::f64::consts::FRAC_PI_2;
|
||||
let folded = |a: f64| a - quarter * (a / quarter).round();
|
||||
let side = (0..sp.segment_count())
|
||||
.filter(|&i| sp.segment_is_line(i))
|
||||
.map(|i| sp.segment(i))
|
||||
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)));
|
||||
if let Some(side) = side {
|
||||
let d = side.p3 - side.p0;
|
||||
// Turned by under 45° either way: a rectangle is the same a quarter turn on.
|
||||
let quarter = std::f64::consts::FRAC_PI_2;
|
||||
let angle = d.y.atan2(d.x);
|
||||
let angle = angle - quarter * (angle / quarter).round();
|
||||
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)))
|
||||
.map(|s| folded((s.p3 - s.p0).y.atan2((s.p3 - s.p0).x)));
|
||||
for angle in side.into_iter().chain([folded(axis), 0.0]) {
|
||||
let xy: Vec<(f64, f64)> = pts.iter().map(|&p| local(p, angle)).collect();
|
||||
let (hw, hh) = xy.iter().fold((0.0f64, 0.0f64), |m, p| (m.0.max(p.0.abs()), m.1.max(p.1.abs())));
|
||||
// Square corners first; else rounded ones, which take (4 − π) r² off the area.
|
||||
let rounded = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt().min(hw.min(hh));
|
||||
for radius in [0.0, rounded] {
|
||||
let off = xy.iter().map(|&(x, y)| {
|
||||
// Distance to the rounded rectangle's edge.
|
||||
// (Its half sizes: where its sides lie on average, not where their farthest points do.)
|
||||
let side = |far: f64, upright: bool, other: f64| {
|
||||
let on: Vec<f64> = xy
|
||||
.iter()
|
||||
.map(|p| if upright { (p.0, p.1) } else { (p.1, p.0) })
|
||||
.filter(|&(a, b)| a.abs() >= far - 0.5 && b.abs() <= other - CORNER)
|
||||
.map(|(a, _)| a.abs())
|
||||
.collect();
|
||||
if on.len() >= 4 { on.iter().sum::<f64>() / on.len() as f64 } else { far }
|
||||
};
|
||||
let (hw, hh) = (side(hw, true, hh), side(hh, false, hw));
|
||||
if hw <= 2.0 || hh <= 2.0 {
|
||||
continue;
|
||||
}
|
||||
// How far the outline is from the rectangle with corners of `radius`, at worst; the points
|
||||
// by its corners `slack` nearer than they are.
|
||||
let off = |radius: f64, slack: f64| {
|
||||
let each = xy.iter().map(|&(x, y)| {
|
||||
let (qx, qy) = (x.abs() - hw + radius, y.abs() - hh + radius);
|
||||
(qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs()
|
||||
let d = (qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs();
|
||||
if x.abs() > hw - CORNER && y.abs() > hh - CORNER { (d - slack).max(0.0) } else { d }
|
||||
});
|
||||
if hw > 2.0 && hh > 2.0 && (radius == 0.0 || radius >= 0.75) && off.fold(0.0, f64::max) <= tol {
|
||||
return Some(TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() });
|
||||
}
|
||||
each.fold(0.0, f64::max)
|
||||
};
|
||||
let rectangle = |radius: f64| TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() };
|
||||
// Square corners (anti-aliasing and blur take a little off a corner: that is no radius).
|
||||
// (Not of a thin bar: it is all corners, and would pass for a rectangle whatever it is.)
|
||||
if off(0.0, if hw.min(hh) >= 2.0 * CORNER { CORNER_SLACK } else { 0.0 }) <= tol {
|
||||
return Some(rectangle(0.0));
|
||||
}
|
||||
// Rounded ones: the radius that fits best, near the one the area says ((4 − π) r² is missing).
|
||||
let by_area = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt();
|
||||
let radii = (0..=40).map(|k| by_area + (f64::from(k) - 20.0) * 0.15).filter(|r| (CORNER..=hw.min(hh)).contains(r));
|
||||
if let Some((_, radius)) = radii.map(|r| (off(r, 0.0), r)).min_by(|a, b| a.0.total_cmp(&b.0)).filter(|best| best.0 <= tol) {
|
||||
return Some(rectangle(radius));
|
||||
}
|
||||
}
|
||||
|
||||
// A regular polygon: its corners evenly round the middle, its sides straight between them.
|
||||
let sides = sp.anchors.len();
|
||||
if (3..=12).contains(&sides) {
|
||||
let radius = sp.anchors.iter().map(|a| a.p.distance(c)).sum::<f64>() / sides as f64;
|
||||
let first = sp.anchors.first()?.p - c;
|
||||
let rotation = (first.y.atan2(first.x).to_degrees() + 90.0).rem_euclid(360.0 / sides as f64);
|
||||
let shape = TracedShape::Polygon { center: c, radius, sides: sides as u32, rotation };
|
||||
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
|
||||
// Every corner of the polygon has one of the outline's on it, and every point of the
|
||||
// outline lies on one of the polygon's sides.
|
||||
let cornered = ideal.iter().all(|q| sp.anchors.iter().any(|a| q.distance(a.p) <= 1.5 * tol));
|
||||
let on_a_side = |p: Point| {
|
||||
(0..ideal.len()).any(|i| {
|
||||
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
|
||||
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
|
||||
p.distance(a + (b - a) * t) <= tol
|
||||
})
|
||||
// A regular polygon: going round it, its outline is nearest its middle once a side and farthest
|
||||
// once a corner. The sides are as many as that happens most strongly for, the corners where
|
||||
// it is farthest, the size what the area says.
|
||||
let polar: Vec<(f64, f64)> = pts.iter().map(|&p| ((p - c).hypot(), (p - c).y.atan2((p - c).x))).collect();
|
||||
let weights: Vec<f64> = (0..n).map(|i| (polar[(i + 1) % n].1 - polar[i].1).sin().abs()).collect();
|
||||
let mean = polar.iter().zip(&weights).map(|(p, w)| p.0 * w).sum::<f64>() / weights.iter().sum::<f64>().max(1e-9);
|
||||
let wave = |sides: u32| {
|
||||
polar.iter().zip(&weights).fold((0.0, 0.0), |m, (p, w)| {
|
||||
(m.0 + (p.0 - mean) * w * (f64::from(sides) * p.1).cos(), m.1 + (p.0 - mean) * w * (f64::from(sides) * p.1).sin())
|
||||
})
|
||||
};
|
||||
let sides = (3..=12u32).max_by(|&a, &b| wave(a).0.hypot(wave(a).1).total_cmp(&wave(b).0.hypot(wave(b).1)))?;
|
||||
let (re, im) = wave(sides);
|
||||
let corner = im.atan2(re) / f64::from(sides);
|
||||
// (More exactly: by the outline's own corners, where it has them there.)
|
||||
let step = 2.0 * std::f64::consts::PI / f64::from(sides);
|
||||
let turns: Vec<f64> = sp
|
||||
.anchors
|
||||
.iter()
|
||||
.map(|a| ((a.p - c).y.atan2((a.p - c).x) - corner, (a.p - c).hypot()))
|
||||
.map(|(d, far)| (d - step * (d / step).round(), far))
|
||||
.filter(|&(d, far)| (d * far).abs() <= CORNER && far > 0.9 * mean)
|
||||
.map(|(d, _)| d)
|
||||
.collect();
|
||||
let corner = corner + turns.iter().sum::<f64>() / turns.len().max(1) as f64;
|
||||
let radius = (2.0 * area / (f64::from(sides) * (2.0 * std::f64::consts::PI / f64::from(sides)).sin())).sqrt();
|
||||
let rotation = (corner.to_degrees() + 90.0).rem_euclid(360.0 / f64::from(sides));
|
||||
let shape = TracedShape::Polygon { center: c, radius, sides, rotation };
|
||||
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
|
||||
// Every point of the outline lies on one of the polygon's sides (by its corners, nearly).
|
||||
let side_length = 2.0 * radius * (std::f64::consts::PI / f64::from(sides)).sin();
|
||||
let on_a_side = |p: Point| {
|
||||
let slack = if side_length >= 4.0 * CORNER && ideal.iter().any(|q| q.distance(p) <= CORNER) { CORNER_SLACK } else { 0.0 };
|
||||
(0..ideal.len()).any(|i| {
|
||||
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
|
||||
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
|
||||
p.distance(a + (b - a) * t) <= tol + slack
|
||||
})
|
||||
};
|
||||
if radius > 3.0 && pts.iter().all(|&p| on_a_side(p)) {
|
||||
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
|
||||
let side = radius * std::f64::consts::SQRT_2;
|
||||
let turned = (rotation + 45.0).rem_euclid(90.0);
|
||||
let square = TracedShape::Rectangle {
|
||||
center: c,
|
||||
width: side,
|
||||
height: side,
|
||||
radius: 0.0,
|
||||
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
|
||||
};
|
||||
if cornered && pts.iter().all(|&p| on_a_side(p)) {
|
||||
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
|
||||
let side = radius * std::f64::consts::SQRT_2;
|
||||
let turned = (rotation + 45.0).rem_euclid(90.0);
|
||||
let square = TracedShape::Rectangle {
|
||||
center: c,
|
||||
width: side,
|
||||
height: side,
|
||||
radius: 0.0,
|
||||
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
|
||||
};
|
||||
return Some(if sides == 4 { square } else { shape });
|
||||
}
|
||||
return Some(if sides == 4 { square } else { shape });
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -177,7 +226,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is(shape: TracedShape) -> Option<TracedShape> {
|
||||
recognise(shape.path().subpaths.first().unwrap())
|
||||
recognise(shape.path().subpaths.first().unwrap(), false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -221,20 +270,20 @@ mod tests {
|
||||
fn what_only_resembles_a_shape_is_none() {
|
||||
// A star, an L, a rectangle with one corner cut, an egg.
|
||||
let star = shapes::star(Point::new(50.0, 50.0), 30.0, 12.0, 5, 0.0);
|
||||
assert_eq!(recognise(&star.subpaths[0]), None);
|
||||
assert_eq!(recognise(&star.subpaths[0], false), None);
|
||||
let poly = |pts: &[(f64, f64)]| SubPath::polyline(&pts.iter().map(|&p| Point::from(p)).collect::<Vec<_>>(), true);
|
||||
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)])), None);
|
||||
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)])), None);
|
||||
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)]), false), None);
|
||||
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)]), false), None);
|
||||
let mut egg = shapes::ellipse(Rect::new(10.0, 10.0, 70.0, 50.0));
|
||||
egg.subpaths[0].anchors[2].p.x += 4.0;
|
||||
assert_eq!(recognise(&egg.subpaths[0]), None);
|
||||
assert_eq!(recognise(&egg.subpaths[0], false), None);
|
||||
// Too small to say, open, or not a number.
|
||||
assert_eq!(is(TracedShape::Ellipse { center: Point::new(5.0, 5.0), rx: 2.0, ry: 2.0, rotation: 0.0 }), None);
|
||||
let mut open = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
|
||||
open.closed = false;
|
||||
assert_eq!(recognise(&open), None);
|
||||
assert_eq!(recognise(&open, false), None);
|
||||
let mut bad = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
|
||||
bad.anchors[1].p.x = f64::NAN;
|
||||
assert_eq!(recognise(&bad), None);
|
||||
assert_eq!(recognise(&bad, false), None);
|
||||
}
|
||||
}
|
||||
@@ -1265,3 +1265,33 @@ fn a_long_line_two_degrees_askew_is_not_levelled() {
|
||||
// 200 px at 2° rises 7 px: levelled, the bar would be 8 px tall, not 15.
|
||||
assert!(b.height() > 13.0, "{b:?}");
|
||||
}
|
||||
|
||||
/// Create Strokes: a line cut square at its ends is stroked with square caps, a line with round
|
||||
/// ends with round ones, and either ends where its ink does.
|
||||
#[test]
|
||||
fn stroke_caps_are_square_or_round_as_the_ink_is() {
|
||||
let line = |square: bool| {
|
||||
let img = Raster::from_fn(140, 40, |x, y| {
|
||||
// Anti-aliased by 4 × 4 samples: a bar 8 px thick from x = 20 to 110.
|
||||
let mut inside = 0;
|
||||
for sy in 0..4 {
|
||||
for sx in 0..4 {
|
||||
let (px, py) = (f64::from(x) + (f64::from(sx) + 0.5) / 4.0, f64::from(y) + (f64::from(sy) + 0.5) / 4.0);
|
||||
let d = if square { (py - 20.3).abs().max((px - 65.0).abs() - 41.0) } else { (px.clamp(24.0, 106.0) - px).hypot(py - 20.3) };
|
||||
inside += u32::from(d <= 4.0);
|
||||
}
|
||||
}
|
||||
let v = (255 - inside * 255 / 16) as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let res = trace(&img, &crate::preset("Outline").unwrap());
|
||||
let [only] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
|
||||
let (a, b) = (only.path.subpaths[0].anchors[0].p, only.path.subpaths[0].anchors.last().unwrap().p);
|
||||
// Either cap goes half the width past the path's end: the path runs from 24 to 106.
|
||||
assert!((a.x.min(b.x) - 24.0).abs() < 0.6 && (a.x.max(b.x) - 106.0).abs() < 0.6, "{a:?} {b:?}");
|
||||
assert!((only.stroke.unwrap() - 8.0).abs() < 0.4);
|
||||
only.square_caps
|
||||
};
|
||||
assert!(line(true));
|
||||
assert!(!line(false));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -64,6 +64,7 @@ static ALLOC: Counting = Counting;
|
||||
|
||||
/// Whether `input` is what `preset` is meant for.
|
||||
fn target(preset: &str, input: &Input) -> bool {
|
||||
let preset = preset.strip_suffix(", enhanced").unwrap_or(preset);
|
||||
let classes: &[Class] = match preset {
|
||||
"High Fidelity Photo"
|
||||
| "Low Fidelity Photo"
|
||||
@@ -74,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],
|
||||
@@ -147,6 +148,7 @@ fn svg_trace(svg: &str) -> TraceResult {
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
square_caps: false,
|
||||
})
|
||||
});
|
||||
TraceResult { paths: paths.collect(), palette: vec![[0; 3]], ..Default::default() }
|
||||
@@ -185,12 +187,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 % | gradient paths | found % | angle off | ramp off |\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 % | false 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(26));
|
||||
return format!("| refused |{}", " |".repeat(27));
|
||||
}
|
||||
let cells = [
|
||||
stat(&m, |m| m.delta, false, 2),
|
||||
@@ -215,6 +217,7 @@ fn row(runs: &[&Run]) -> String {
|
||||
stat(&m, |m| m.width_off, false, 2),
|
||||
stat(&m, |m| m.stroke_anchors, false, 1),
|
||||
stat(&m, |m| m.shapes, false, 0),
|
||||
stat(&m, |m| m.false_shapes, false, 2),
|
||||
stat(&m, |m| m.grouped, false, 0),
|
||||
stat(&m, |m| m.gradients, false, 1),
|
||||
stat(&m, |m| m.ramp_found, false, 0),
|
||||
@@ -264,6 +267,11 @@ fn report() -> String {
|
||||
] {
|
||||
presets.push((name, TraceParams { gradients, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
// Each preset's enhanced version, on that preset's own inputs.
|
||||
for name in vectorcraft_trace::PRESET_NAMES {
|
||||
let enhanced: &'static str = Box::leak(format!("{name}, enhanced").into_boxed_str());
|
||||
presets.push((enhanced, vectorcraft_trace::enhanced_preset(name).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);
|
||||
@@ -351,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);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ pub fn fidelity(src: &Raster, out: &Raster) -> (f64, f64) {
|
||||
fn painted(tp: &TracedPath) -> BezPath {
|
||||
let bez = tp.path.to_bezpath();
|
||||
match tp.stroke {
|
||||
Some(width) => kurbo::stroke(bez, &Stroke::new(width), &StrokeOpts::default(), 0.05),
|
||||
Some(width) => {
|
||||
let cap = if tp.square_caps { kurbo::Cap::Square } else { kurbo::Cap::Round };
|
||||
kurbo::stroke(bez, &Stroke::new(width).with_caps(cap), &StrokeOpts::default(), 0.05)
|
||||
}
|
||||
None => bez,
|
||||
}
|
||||
}
|
||||
@@ -194,6 +197,8 @@ pub struct Metrics {
|
||||
pub stroke_anchors: f64,
|
||||
/// Share (%) of the art's plain shapes that came out as a shape lying within 0.6 px of it.
|
||||
pub shapes: f64,
|
||||
/// Shapes: paths made a shape that lie more than 0.6 px off the art.
|
||||
pub false_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
|
||||
@@ -242,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);
|
||||
@@ -271,11 +283,16 @@ 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 !matches!(input.class, Class::Tone | Class::Ramp) {
|
||||
let mut false_shapes = if params.shapes { 0.0 } else { nan };
|
||||
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)));
|
||||
(geo_mean, geo_max) = traced.distance_to(&truth);
|
||||
// Shapes that are not the art's: a path made a shape that strays more than 0.6 px from it.
|
||||
false_shapes =
|
||||
res.paths.iter().filter(|tp| tp.shape.is_some() && Outline::new([&tp.path.to_bezpath()], None).distance_to(&truth).1 > 0.6).count()
|
||||
as f64;
|
||||
miss_max = truth.distance_to(&traced).1;
|
||||
|
||||
let true_corners = input.scaled_corners(size);
|
||||
@@ -378,6 +395,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
ramp_angle,
|
||||
ramp_off,
|
||||
shapes: shapes_found,
|
||||
false_shapes,
|
||||
centre_off,
|
||||
width_off,
|
||||
stroke_anchors,
|
||||
|
||||
@@ -1428,6 +1428,7 @@ lighting Light Světlo
|
||||
Black and White Černobílé
|
||||
Corners Rohy
|
||||
Auto Grouping Automatické seskupení
|
||||
Enhanced Vylepšené
|
||||
Ignore Color Ignorovat barvu
|
||||
Ignore White Ignorovat bílou
|
||||
Palette: Paleta:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1230,6 +1230,7 @@
|
||||
Ignore Ignorieren
|
||||
Ignore Artboards (all the art as one page) Zeichenflächen ignorieren (gesamte Grafik als eine Seite)
|
||||
Auto Grouping Automatisch gruppieren
|
||||
Enhanced Erweitert
|
||||
Ignore Color Farbe ignorieren
|
||||
Ignore White Weiß ignorieren
|
||||
Palette: Palette:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1248,6 +1248,7 @@
|
||||
Ignore Ignorar
|
||||
Ignore Artboards (all the art as one page) Ignorar mesas de trabajo (toda la ilustración como una página)
|
||||
Auto Grouping Agrupación automática
|
||||
Enhanced Mejorado
|
||||
Ignore Color Ignorar color
|
||||
Ignore White Ignorar blanco
|
||||
Palette: Paleta:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1247,6 +1247,7 @@
|
||||
Ignore Ignorer
|
||||
Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page)
|
||||
Auto Grouping Groupement automatique
|
||||
Enhanced Amélioré
|
||||
Ignore Color Ignorer la couleur
|
||||
Ignore White Ignorer le blanc
|
||||
Palette: Palette :
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1612,6 +1612,7 @@
|
||||
Ignore Kihagy
|
||||
Ignore Artboards (all the art as one page) Rajztáblák figyelmen kívül hagyása (az összes grafika egy oldalon)
|
||||
Auto Grouping Automatikus csoportosítás
|
||||
Enhanced Továbbfejlesztett
|
||||
Ignore Color Szín figyelmen kívül hagyása
|
||||
Ignore White Fehér figyelmen kívül hagyása
|
||||
Image Kép
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1247,6 +1247,7 @@
|
||||
Ignore Ignora
|
||||
Ignore Artboards (all the art as one page) Ignora tavole da disegno (tutta la grafica come una sola pagina)
|
||||
Auto Grouping Raggruppamento automatico
|
||||
Enhanced Avanzato
|
||||
Ignore Color Ignora colore
|
||||
Ignore White Ignora bianco
|
||||
Palette: Palette:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1485,6 +1485,7 @@
|
||||
Ignore 無視
|
||||
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
|
||||
Auto Grouping 自動グループ化
|
||||
Enhanced 拡張
|
||||
Ignore Color カラーを無視
|
||||
Ignore White 白を無視
|
||||
Palette: パレット:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1480,6 +1480,7 @@
|
||||
Ignore 무시
|
||||
Ignore Artboards (all the art as one page) 아트보드 무시 (모든 아트웍을 한 페이지로)
|
||||
Auto Grouping 자동 그룹화
|
||||
Enhanced 향상됨
|
||||
Ignore Color 색상 무시
|
||||
Ignore White 흰색 무시
|
||||
Palette: 팔레트:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1378,6 +1378,7 @@ lighting Light Światło
|
||||
Black and White Czarno-biały
|
||||
Corners Narożniki
|
||||
Auto Grouping Automatyczne grupowanie
|
||||
Enhanced Ulepszone
|
||||
Ignore Color Ignoruj kolor
|
||||
Ignore White Ignoruj biel
|
||||
Palette: Paleta:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1245,6 +1245,7 @@
|
||||
Ignore Игнорировать
|
||||
Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница)
|
||||
Auto Grouping Автогруппировка
|
||||
Enhanced Улучшенный
|
||||
Ignore Color Игнорировать цвет
|
||||
Ignore White Игнорировать белый
|
||||
Palette: Палитра:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1241,6 +1241,7 @@
|
||||
Ignore Ігнорувати
|
||||
Ignore Artboards (all the art as one page) Ігнорувати монтажні області (весь малюнок як одна сторінка)
|
||||
Auto Grouping Автогрупування
|
||||
Enhanced Покращений
|
||||
Ignore Color Ігнорувати колір
|
||||
Ignore White Ігнорувати білий
|
||||
Palette: Палітра:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 660 and column 40.
|
@@ -1441,6 +1441,7 @@
|
||||
Ignore Bỏ qua
|
||||
Ignore Artboards (all the art as one page) Bỏ qua bảng vẽ (toàn bộ bản vẽ thành một trang)
|
||||
Auto Grouping Tự động nhóm
|
||||
Enhanced Nâng cao
|
||||
Ignore Color Bỏ qua màu
|
||||
Ignore White Bỏ qua màu trắng
|
||||
Image Hình ảnh
|
||||
|
||||
|
@@ -1193,6 +1193,7 @@
|
||||
Ignore 忽略
|
||||
Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页)
|
||||
Auto Grouping 自动编组
|
||||
Enhanced 增强
|
||||
Ignore Color 忽略颜色
|
||||
Ignore White 忽略白色
|
||||
Palette: 调板:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1223,6 +1223,7 @@
|
||||
Ignore 略過
|
||||
Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面)
|
||||
Auto Grouping 自動群組
|
||||
Enhanced 增強
|
||||
Ignore Color 忽略顏色
|
||||
Ignore White 忽略白色
|
||||
Palette: 調色盤:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -68,6 +68,15 @@ struct TraceUi {
|
||||
synced: Value,
|
||||
}
|
||||
|
||||
/// The parameters of each preset's enhanced version, by the preset's name.
|
||||
fn enhanced_presets(app: &mut VectorcraftApp) -> Vec<(String, Value)> {
|
||||
let v = app.session.execute("imageTrace.presets", &json!({})).unwrap_or_default();
|
||||
v["presets"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().map(|p| (p["name"].as_str().unwrap_or("").to_string(), p["enhanced"].clone())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn presets(app: &mut VectorcraftApp) -> Vec<(String, Value)> {
|
||||
let v = app.session.execute("imageTrace.presets", &json!({})).unwrap_or_default();
|
||||
v["presets"]
|
||||
@@ -242,6 +251,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
retrace = true;
|
||||
}
|
||||
});
|
||||
// The preset's enhanced version (with Shapes and Auto Grouping) or its legacy one.
|
||||
let better = enhanced_presets(app).into_iter().find(|p| p.0 == st.preset).map(|p| p.1).filter(|p| !p.is_null());
|
||||
let legacy = all.iter().find(|p| p.0 == st.preset).map(|p| p.1.clone());
|
||||
let on = better.as_ref() == Some(&st.params);
|
||||
if let (Some(better), Some(legacy)) = (better, legacy)
|
||||
&& widgets::check(ui, tl!("Enhanced"), on, true)
|
||||
{
|
||||
st.params = if on { legacy } else { better };
|
||||
retrace = true;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("View:"));
|
||||
if let Some(v) = view_dropdown(ui, st.view, 170.0) {
|
||||
|
||||
+847
-525
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user