mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Image Trace: Flat Logo leaves out a traced outline that has no area
The fit of a thin outline can lose all its area. Its two sides can straighten onto one line, which leaves two anchors whose segments run back and forth along it. Or the fit can be a single cubic, which becomes one anchor and has no segment. Both occur when Flat Logo traces noise whose cells are one pixel in size. The trace kept such an outline as a selectable path that paints nothing. Flat Logo now leaves out an outline whose area is below 1e-6 px^2, together with its holes, and a hole whose area is below 1e-6 px^2. The area is summed about the outline's first anchor, as the other trace modes already do for a filled loop (crates/trace/src/lib.rs:996-1001). Outlines with an area of 1e-6 px^2 or more, thin slivers included, are kept as before. One test traces a black and cream noise with both colors named exactly and checks that no subpath of the result lacks an area. Two more build shapes from straight cubics: a hole without area is left out, and an outline without area is left out with its hole.
This commit is contained in:
@@ -29,6 +29,8 @@ const SAME_COLOUR: f64 = 12.0;
|
||||
const MAX_COLOURS: usize = 10;
|
||||
/// Neighbouring colours overlap by this many working pixels so no hairline gap can open.
|
||||
const OVERLAP: f64 = 0.06;
|
||||
/// A traced outline whose area (in source pixels squared) is below this paints nothing.
|
||||
const NO_AREA: f64 = 1e-6;
|
||||
|
||||
/// The raster as premultiplied RGBA, averaged down so its longest side is at most [`WORK_MAX_SIDE`].
|
||||
fn working_copy(img: &Raster, at_least: usize) -> (Vec<Px>, usize, usize, usize) {
|
||||
@@ -262,6 +264,14 @@ fn to_subpath(segs: &[Cubic], map: &ToSource) -> Option<SubPath> {
|
||||
anchors.iter().all(|a| finite(a.p) && finite(a.h_in) && finite(a.h_out)).then(|| SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// The area `sp` paints, summed about its first anchor. A subpath of one anchor has no segment and
|
||||
/// paints nothing.
|
||||
fn painted_area(sp: &SubPath) -> f64 {
|
||||
use kurbo::ParamCurveArea;
|
||||
let Some(origin) = sp.anchors.first().map(|a| a.p.to_vec2()) else { return 0.0 };
|
||||
(0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum()
|
||||
}
|
||||
|
||||
struct Shape {
|
||||
class: usize,
|
||||
area: f64,
|
||||
@@ -269,6 +279,23 @@ struct Shape {
|
||||
holes: Vec<Vec<Cubic>>,
|
||||
}
|
||||
|
||||
/// The subpaths of `s` in source pixels: its outline, then its holes. The fit of a thin outline
|
||||
/// can have no area: two anchors whose segments run along one line, or a single anchor, which has
|
||||
/// no segment. Such an outline paints nothing and is left out with its holes, and a hole with no
|
||||
/// area is left out too.
|
||||
fn shape_subpaths(s: &Shape, map: &ToSource) -> Vec<SubPath> {
|
||||
let outer = to_subpath(&s.outer, map);
|
||||
if outer.as_ref().is_some_and(|sp| painted_area(sp).abs() < NO_AREA) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut subs: Vec<SubPath> = Vec::new();
|
||||
subs.extend(outer);
|
||||
for hole in &s.holes {
|
||||
subs.extend(to_subpath(hole, map).filter(|sp| painted_area(sp).abs() >= NO_AREA));
|
||||
}
|
||||
subs
|
||||
}
|
||||
|
||||
/// A hard-edged source (a GIF, say) has no anti-aliasing to read an edge position from, and its
|
||||
/// outlines would follow the pixel staircase. Blurring the coverage by under a pixel gives the
|
||||
/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone.
|
||||
@@ -475,11 +502,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
let mut out = TraceResult { worked_at: (factor > 1).then_some((w as u32, h as u32)), ..TraceResult::default() };
|
||||
for s in shapes {
|
||||
let color = palette.get(s.class).copied().unwrap_or([255, 255, 255]);
|
||||
let mut subs: Vec<SubPath> = Vec::new();
|
||||
subs.extend(to_subpath(&s.outer, &to_source));
|
||||
for hole in &s.holes {
|
||||
subs.extend(to_subpath(hole, &to_source));
|
||||
}
|
||||
let subs = shape_subpaths(&s, &to_source);
|
||||
if subs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -498,3 +521,46 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A closed outline of straight cubics through `pts`.
|
||||
fn polygon(pts: &[(f64, f64)]) -> Vec<Cubic> {
|
||||
(0..pts.len())
|
||||
.map(|i| {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
|
||||
let (a, b) = (Point::new(a.0, a.1), Point::new(b.0, b.1));
|
||||
[a, a, b, b]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_without_area_is_left_out() {
|
||||
let map = ToSource { factor: 1.0, work: (40, 40), source: (40, 40) };
|
||||
let shape = Shape {
|
||||
class: 0,
|
||||
area: 900.0,
|
||||
outer: polygon(&[(5.0, 5.0), (35.0, 5.0), (35.0, 35.0), (5.0, 35.0)]),
|
||||
holes: vec![polygon(&[(10.0, 20.0), (20.0, 20.0)]), polygon(&[(20.0, 10.0), (30.0, 10.0), (30.0, 30.0)])],
|
||||
};
|
||||
let subs = shape_subpaths(&shape, &map);
|
||||
let areas: Vec<f64> = subs.iter().map(painted_area).collect();
|
||||
assert_eq!(subs.len(), 2, "areas {areas:?}");
|
||||
assert!(areas.iter().all(|a| a.abs() >= 100.0), "areas {areas:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outline_without_area_is_left_out_with_its_holes() {
|
||||
let map = ToSource { factor: 1.0, work: (40, 40), source: (40, 40) };
|
||||
let shape = Shape {
|
||||
class: 0,
|
||||
area: 1.0,
|
||||
outer: polygon(&[(5.0, 5.0), (35.0, 5.0)]),
|
||||
holes: vec![polygon(&[(20.0, 10.0), (30.0, 10.0), (30.0, 30.0)])],
|
||||
};
|
||||
assert!(shape_subpaths(&shape, &map).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,3 +592,24 @@ fn anti_aliased_coloured_lettering_has_only_its_inks() {
|
||||
assert!(colours.iter().any(|&c| near(c, ink, 12)), "{ink:?} missing from {:?}", res.palette);
|
||||
}
|
||||
}
|
||||
|
||||
/// An outline whose fit has no area is left out. A pixel-sized black and cream noise, with both
|
||||
/// colors named exactly, fits some specks to two anchors whose segments run along one line and
|
||||
/// others to one anchor, which has no segment.
|
||||
#[test]
|
||||
fn an_outline_fitted_without_area_is_left_out() {
|
||||
use kurbo::ParamCurveArea;
|
||||
let noise = render(96, 96, 0.0, |x, y| {
|
||||
let n = ((x.floor() * 12.9898 + y.floor() * 78.233).sin() * 43758.5453).fract();
|
||||
Some(if n > 0.5 { [4, 4, 3] } else { [252, 248, 227] })
|
||||
});
|
||||
let res = trace(&noise, &TraceParams { logo_colors: vec!["#040403".into(), "#FCF8E3".into()], ..logo_params() });
|
||||
assert!(res.paths.len() > 1, "{} paths", res.paths.len());
|
||||
for t in &res.paths {
|
||||
for sp in &t.path.subpaths {
|
||||
let origin = sp.anchors[0].p.to_vec2();
|
||||
let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum();
|
||||
assert!(area.abs() >= 1e-6, "a {:?} subpath of {} anchors with area {area:e}", t.color, sp.anchors.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user