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:
andeecollard
2026-10-17 14:46:21 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 6bbf30e52a
commit 989768207f
3 changed files with 235 additions and 3 deletions
+124 -1
View File
@@ -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());
+108
View File
@@ -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