mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Image Trace: Flat Logo traces line art instead of a blank result
Flat Logo found its colours only from solid pixels (a uniform 3 x 3 neighbourhood). Line art on paper has no solid area but the paper's: a stroke one or two pixels wide never fills one, so its colour was a handful of pixels among tens of thousands of white and was cut as noise (under 0.1% of the solid pixels). The palette came back empty and trace_logo returned an empty TraceResult, which imageTrace.make reported as a success with paths 0, anchors 0, colors 0. Cause: logo::palette::auto_palette had no way to find a colour that has no solid area, and logo::pipeline::trace_logo treats an empty palette as nothing to trace. Fix: when the solid pixels give no colour but white, auto_palette reads the colours from the strokes' cores instead (line_art_palette): the opaque ink pixels at least as far from white as each of their four neighbours, the middle of each stroke where it is least blended with the paper. Two guards keep that fallback to line art: - Ink is a colour at least 48 below white in one channel, and the page must have ink on at least 1% of its opaque pixels. Light grain and a few sparse marks on otherwise blank paper stay blank, as plain white does. - The lighter shades along a stroke's anti-aliased edge are local maxima of their own; a colour that lies on the line from white to a darker colour of the palette (within 20) is a blend of that ink with the paper and is folded into it (fold_fringes), the ink keeping its colour. The rest of the pipeline (unmixing, the flat-colour check and its NotFlat refusal) is unchanged, and images with a solid colour other than white never reach the fallback. Tests (code-drawn inputs): - thin_line_art_on_paper_is_traced_not_blank: four 2.4 px dark strokes and two red marks on white. Before: palette [], no paths. After: dark and red paths along the middle of every stroke and mark. - anti_aliased_coloured_lettering_has_only_its_inks: rings, slants and bars 1.6 px wide in red, blue and green. Without the fold: six colours besides white; after: exactly the three inks. - sparse_marks_on_paper_are_not_promoted_to_colours: 24 grey 12 x 2 px marks of 239, 223 or 191 on a 256 x 256 page (under 1% ink). Without the guard: palette [white, #efefef]; after: empty palette, no paths. - grainy_blank_paper_has_no_ink: paper grain 243-255 traces to nothing. - edge_shades_fold_into_their_ink_and_other_colours_stay (unit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
6bbf30e52a
commit
989768207f
@@ -118,11 +118,126 @@ fn kmeans(px: &[[f64; 3]], k: usize, seed: u64) -> (Vec<[f64; 3]>, Vec<usize>, f
|
||||
|
||||
/// Up to `max_colors` flat colours, most common first. White is left out (it is always a class of
|
||||
/// its own), as are colours that make up under 0.1% of the solid pixels (noise).
|
||||
///
|
||||
/// Line art (thin strokes on paper) has no solid area of any colour but the paper's: a stroke one
|
||||
/// or two pixels wide never fills a uniform 3 × 3 neighbourhood, so its colour is a few pixels in
|
||||
/// tens of thousands of paper and is cut as noise. Where the solid pixels give no colour, the
|
||||
/// colours are read from the strokes' cores instead ([`line_art_palette`]).
|
||||
pub(crate) fn auto_palette(pix: &[Px], w: usize, h: usize, max_colors: usize) -> Vec<[u8; 3]> {
|
||||
let mut px = solid_pixels(pix, w, h, 20.0);
|
||||
if px.len() < max_colors.max(2) * 20 {
|
||||
px = pix.iter().filter(|p| p[3] > 0.9).map(rgb255).collect();
|
||||
}
|
||||
let found = clustered(px, max_colors);
|
||||
if !found.is_empty() {
|
||||
return found.into_iter().map(|(c, _)| c).collect();
|
||||
}
|
||||
line_art_palette(pix, w, h, max_colors)
|
||||
}
|
||||
|
||||
/// How much darker than white (in its darkest channel) a colour must be to be ink rather than the
|
||||
/// paper's grain or a light smudge.
|
||||
const INK_DEPTH: f64 = 48.0;
|
||||
|
||||
/// The least share of the opaque pixels that ink must cover for a page with no solid colour but
|
||||
/// white to be line art rather than blank paper with a few specks or marks on it.
|
||||
const MIN_INK_SHARE: f64 = 0.01;
|
||||
|
||||
/// Whether `c` is ink: at least [`INK_DEPTH`] darker than white in one channel.
|
||||
fn is_ink(c: [f64; 3]) -> bool {
|
||||
c.iter().any(|&v| v <= 255.0 - INK_DEPTH)
|
||||
}
|
||||
|
||||
/// The colours of line art, read from its strokes' cores ([`ink_cores`]): nothing when ink covers
|
||||
/// under [`MIN_INK_SHARE`] of the image, and the lighter shades the anti-aliasing of a stroke's
|
||||
/// edge leaves as cores of their own folded into the ink they are a blend of ([`fold_fringes`]).
|
||||
fn line_art_palette(pix: &[Px], w: usize, h: usize, max_colors: usize) -> Vec<[u8; 3]> {
|
||||
let (mut opaque, mut ink) = (0usize, 0usize);
|
||||
for p in pix.iter().filter(|p| p[3] > 0.98) {
|
||||
opaque += 1;
|
||||
if is_ink(rgb255(p)) {
|
||||
ink += 1;
|
||||
}
|
||||
}
|
||||
if (ink as f64) < MIN_INK_SHARE * opaque as f64 {
|
||||
return Vec::new();
|
||||
}
|
||||
fold_fringes(clustered(ink_cores(pix, w, h), max_colors))
|
||||
}
|
||||
|
||||
/// The opaque ink pixels ([`is_ink`]) that are at least as far from white as each of their four
|
||||
/// neighbours: the middle of every stroke, where its colour is least blended with the paper's
|
||||
/// (and every pixel of a wider area of one colour).
|
||||
fn ink_cores(pix: &[Px], w: usize, h: usize) -> Vec<[f64; 3]> {
|
||||
let white = [255.0; 3];
|
||||
let ink = |i: usize| pix.get(i).filter(|p| p[3] > 0.98).map(|p| dist2(rgb255(p), white)).unwrap_or(0.0);
|
||||
let mut out = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let Some(p) = pix.get(i).filter(|p| p[3] > 0.98) else { continue };
|
||||
let c = rgb255(p);
|
||||
if !is_ink(c) {
|
||||
continue;
|
||||
}
|
||||
let d = dist2(c, white);
|
||||
let near = [(x > 0).then(|| i - 1), (x + 1 < w).then_some(i + 1), (y > 0).then(|| i - w), (y + 1 < h).then_some(i + w)];
|
||||
if near.into_iter().flatten().all(|j| ink(j) <= d) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How far (in RGB) a colour may lie from the line between white and an ink and still be a blend
|
||||
/// of the two.
|
||||
const FRINGE_APART: f64 = 20.0;
|
||||
|
||||
/// `groups` (colours with their counts, most common first) with each colour that is a blend of white
|
||||
/// and a darker colour of the list (it lies on the line between them, within [`FRINGE_APART`])
|
||||
/// folded into that colour: the anti-aliased edge of a thin stroke, or a stroke thinner than a
|
||||
/// pixel, is lighter than its ink but no colour of its own. The ink keeps its colour.
|
||||
fn fold_fringes(mut groups: Vec<([u8; 3], usize)>) -> Vec<[u8; 3]> {
|
||||
// Lightest first, so a blend of a blend goes to the darkest ink.
|
||||
let depth = |c: [u8; 3]| c.iter().map(|&v| 255.0 - f64::from(v)).sum::<f64>();
|
||||
groups.sort_by(|a, b| depth(a.0).total_cmp(&depth(b.0)));
|
||||
let mut i = 0;
|
||||
while i < groups.len() {
|
||||
let a = groups[i].0.map(|v| 255.0 - f64::from(v));
|
||||
// The ink `a` is the closest blend of, if any: (its index, the distance off the line).
|
||||
let mut into: Option<(usize, f64)> = None;
|
||||
for (j, g) in groups.iter().enumerate().skip(i + 1) {
|
||||
let b = g.0.map(|v| 255.0 - f64::from(v));
|
||||
let bb: f64 = b.iter().map(|v| v * v).sum();
|
||||
if bb <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let t = (0..3).map(|k| a[k] * b[k]).sum::<f64>() / bb;
|
||||
if !(0.0..1.0).contains(&t) {
|
||||
continue;
|
||||
}
|
||||
let off = (0..3).map(|k| (a[k] - t * b[k]).powi(2)).sum::<f64>().sqrt();
|
||||
if off <= FRINGE_APART && into.is_none_or(|(_, o)| off < o) {
|
||||
into = Some((j, off));
|
||||
}
|
||||
}
|
||||
match into {
|
||||
Some((j, _)) => {
|
||||
let n = groups[i].1;
|
||||
groups[j].1 += n;
|
||||
groups.remove(i);
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|g| std::cmp::Reverse(g.1));
|
||||
groups.into_iter().map(|(c, _)| c).collect()
|
||||
}
|
||||
|
||||
/// Up to `max_colors` colours that `px` clusters into, with how many of `px` each has, most common
|
||||
/// first; white and noise left out.
|
||||
fn clustered(mut px: Vec<[f64; 3]>, max_colors: usize) -> Vec<([u8; 3], usize)> {
|
||||
if px.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -163,7 +278,7 @@ pub(crate) fn auto_palette(pix: &[Px], w: usize, h: usize, max_colors: usize) ->
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|g| std::cmp::Reverse(g.1));
|
||||
groups.iter().map(|(c, _)| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
|
||||
groups.iter().map(|(c, n)| (c.map(|v| v.round().clamp(0.0, 255.0) as u8), *n)).collect()
|
||||
}
|
||||
|
||||
/// How far apart (in RGB) two tones of one colour may be: a scan's paper is a tenth darker in one
|
||||
@@ -330,6 +445,14 @@ mod tests {
|
||||
assert!(flat_fraction(&pix, 64, 16, &pal) < 0.93, "gradient scored flat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_shades_fold_into_their_ink_and_other_colours_stay() {
|
||||
// A blue ink, a blend of it with white (its anti-aliased edge), a red ink and an orange
|
||||
// that lies on no ink's line from white.
|
||||
let groups = vec![([30, 90, 200], 50), ([143, 173, 228], 40), ([210, 40, 50], 30), ([240, 150, 40], 5)];
|
||||
assert_eq!(fold_fringes(groups), vec![[30, 90, 200], [210, 40, 50], [240, 150, 40]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_find_in_empty_or_blank_images() {
|
||||
assert!(auto_palette(&[], 0, 0, 6).is_empty());
|
||||
|
||||
@@ -427,3 +427,111 @@ fn uneven_paper_is_one_colour() {
|
||||
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
|
||||
assert_eq!(res.paths.len(), 2, "{:?}", res.paths.iter().map(|p| (p.color, p.pixels)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Line art: thin dark strokes and small red marks (2.4 px wide) on white paper, anti-aliased. No stroke fills a
|
||||
/// uniform 3 × 3 neighbourhood, so the solid pixels are all paper; the strokes' colours must still
|
||||
/// be found (from the strokes' cores) and traced, not cut as noise into a blank result.
|
||||
#[test]
|
||||
fn thin_line_art_on_paper_is_traced_not_blank() {
|
||||
const INK: [u8; 3] = [46, 36, 38];
|
||||
const MARK: [u8; 3] = [196, 38, 66];
|
||||
// Distance from the point to the segment a–b.
|
||||
let seg = |x: f64, y: f64, a: (f64, f64), b: (f64, f64)| {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let t = (((x - a.0) * dx + (y - a.1) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
|
||||
(x - a.0 - t * dx).hypot(y - a.1 - t * dy)
|
||||
};
|
||||
let strokes = [((10.0, 12.0), (150.0, 30.0)), ((20.0, 110.0), (140.0, 60.0)), ((80.0, 8.0), (86.0, 112.0)), ((30.0, 40.0), (60.0, 100.0))];
|
||||
let marks = [((110.0, 95.0), (128.0, 101.0)), ((40.0, 20.0), (52.0, 18.0))];
|
||||
let img = render(160, 120, 0.0, |x, y| {
|
||||
Some(if marks.iter().any(|&(a, b)| seg(x, y, a, b) <= 1.2) {
|
||||
MARK
|
||||
} else if strokes.iter().any(|&(a, b)| seg(x, y, a, b) <= 1.2) {
|
||||
INK
|
||||
} else {
|
||||
[255, 255, 255]
|
||||
})
|
||||
});
|
||||
let res = trace_within(&img, &crate::preset("Flat Logo").unwrap(), 100_000).expect("line art is traced, not refused");
|
||||
assert!(res.paths.iter().any(|p| near(p.color, INK, 30)), "no ink path; palette {:?}", res.palette);
|
||||
assert!(res.paths.iter().any(|p| near(p.color, MARK, 30)), "no red mark; palette {:?}", res.palette);
|
||||
// The middle of each stroke and mark is painted with its colour (the topmost path there).
|
||||
let colour_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color);
|
||||
for (&(a, b), colour) in strokes.iter().map(|s| (s, INK)).chain(marks.iter().map(|m| (m, MARK))) {
|
||||
for k in 1..8 {
|
||||
let t = f64::from(k) / 8.0;
|
||||
let p = Point::new(a.0 + t * (b.0 - a.0), a.1 + t * (b.1 - a.1));
|
||||
let at = colour_at(p);
|
||||
assert!(at.is_some_and(|c| near(c, colour, 30)), "{p:?} is {at:?}, not {colour:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blank white paper with a light grain (a scan, a JPEG: each channel 243 to 255): the grain's
|
||||
/// darkest specks are no ink, and the paper traces to nothing (a blank page stays blank).
|
||||
#[test]
|
||||
fn grainy_blank_paper_has_no_ink() {
|
||||
let grain = |x: u32, y: u32, k: u32| {
|
||||
let mut v = (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663) ^ k.wrapping_mul(83_492_791)).wrapping_mul(2_654_435_761);
|
||||
v ^= v >> 15;
|
||||
(255 - (v % 13)) as u8
|
||||
};
|
||||
let img = Raster::from_fn(160, 120, |x, y| [grain(x, y, 1), grain(x, y, 2), grain(x, y, 3), 255]);
|
||||
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
|
||||
assert!(res.palette.is_empty() && res.paths.is_empty(), "palette {:?}", res.palette);
|
||||
}
|
||||
|
||||
/// White paper with 24 short grey marks (12 × 2 px) of one shade: light (239, 223) or mid grey
|
||||
/// (191). They cover under 1% of the page, so it is paper with a few specks on it, not line art:
|
||||
/// no colour is found and the page traces to nothing, as plain white paper does.
|
||||
#[test]
|
||||
fn sparse_marks_on_paper_are_not_promoted_to_colours() {
|
||||
let marks: Vec<(u32, u32)> = (0..24u32).map(|k| (10 + (k * 37) % 220, 10 + (k * 71) % 220)).collect();
|
||||
for shade in [239u8, 223, 191] {
|
||||
let img = Raster::from_fn(256, 256, |x, y| {
|
||||
let on = marks.iter().any(|&(mx, my)| (mx..mx + 12).contains(&x) && (my..my + 2).contains(&y));
|
||||
let v = if on { shade } else { 255 };
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
|
||||
assert!(res.palette.is_empty(), "shade {shade}: palette {:?}", res.palette);
|
||||
assert!(res.paths.is_empty(), "shade {shade}: {} paths", res.paths.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Three rows of thin anti-aliased "lettering" (rings, slants and bars 1.6 px wide) in red, blue
|
||||
/// and green on white. The lighter shades along the strokes' anti-aliased edges are blends of an
|
||||
/// ink with the paper, not colours of their own: the palette is the three inks and white.
|
||||
#[test]
|
||||
fn anti_aliased_coloured_lettering_has_only_its_inks() {
|
||||
const INKS: [[u8; 3]; 3] = [[210, 40, 50], [30, 90, 200], [20, 130, 70]];
|
||||
let seg = |x: f64, y: f64, a: (f64, f64), b: (f64, f64)| {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let t = (((x - a.0) * dx + (y - a.1) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
|
||||
(x - a.0 - t * dx).hypot(y - a.1 - t * dy)
|
||||
};
|
||||
// One "letter" in the cell at (cx, cy): a ring, a slanted stroke or a bar with a foot.
|
||||
let glyph = |x: f64, y: f64, k: usize, cx: f64, cy: f64| match k % 3 {
|
||||
0 => ((x - cx).hypot(y - cy) - 6.0).abs(),
|
||||
1 => seg(x, y, (cx - 5.0, cy + 8.0), (cx + 4.0, cy - 8.0)).min(seg(x, y, (cx - 1.0, cy), (cx + 6.0, cy + 7.5))),
|
||||
_ => seg(x, y, (cx - 0.3, cy - 8.0), (cx + 0.4, cy + 8.0)).min(seg(x, y, (cx - 5.0, cy + 8.2), (cx + 5.0, cy + 7.9))),
|
||||
};
|
||||
let img = render(256, 256, 0.0, |x, y| {
|
||||
for (row, ink) in INKS.iter().enumerate() {
|
||||
let cy = 40.0 + 80.0 * row as f64 + 0.37 * row as f64;
|
||||
for k in 0..14 {
|
||||
let cx = 14.0 + 17.3 * k as f64;
|
||||
if glyph(x, y, k + row, cx, cy) <= 0.8 {
|
||||
return Some(*ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some([255, 255, 255])
|
||||
});
|
||||
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
|
||||
let colours: Vec<[u8; 3]> = res.palette.iter().copied().filter(|c| !c.iter().all(|&v| v >= 245)).collect();
|
||||
assert_eq!(colours.len(), 3, "palette {:?}", res.palette);
|
||||
for ink in INKS {
|
||||
assert!(colours.iter().any(|&c| near(c, ink, 12)), "{ink:?} missing from {:?}", res.palette);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user