From 465ddfded9bce50efc5c1a73e34b7b21a609c7b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 19:08:40 -0700 Subject: [PATCH] Image Trace: Flat Logo finds a bar two pixels wide in its own color When Flat Logo finds the colors itself (no colors named), a flat bar two pixels wide (whole pixels of one color) on a ground beside other art was lost or traced in another color. No bar pixel has a uniform 3 x 3 neighborhood, so neither the first clustering nor the check for unexplained colors took it. A gray bar on white beside black art was traced as white, and a red or green bar was traced as black. Bars three pixels wide or more were already found. The check for unexplained colors now also takes the pixels of such a bar: two rows or columns of one color (within about 3.5 in RGB distance), three pixels long, with the same ground on both sides. They count when the known colors do not explain them, and also when their color lies less than two thirds as far from the ground's known color as from every other known color, where the bar would otherwise be traced as the ground. A gray bar darker than about #9B9B9B on white beside black art stays black, #808080 included, because a black stroke 1 to 1.6 px wide on the pixel grid gives the same pixels. Named colors are unchanged. --- crates/trace/src/logo/palette.rs | 57 +++++++++++++++++++++++++++++--- crates/trace/src/logo/tests.rs | 39 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/crates/trace/src/logo/palette.rs b/crates/trace/src/logo/palette.rs index d21995495..157889372 100644 --- a/crates/trace/src/logo/palette.rs +++ b/crates/trace/src/logo/palette.rs @@ -336,24 +336,71 @@ pub(crate) fn fold_tones(pix: &[Px], mut palette: Vec<[u8; 3]>) -> Vec<[u8; 3]> } } +/// The color of the ground a flat bar two pixels wide lies on, if the pixel at (`x`, `y`) is one of +/// the bar's. Across the bar (down or across the image) the pixel and one neighbor are the bar, and +/// the pixels on either side of the two are the ground; along the bar, the pixel's neighbors on +/// both sides are bar and ground in the same way. Every bar pixel lies within √12, about 3.5 (in +/// RGB), of the pixel's color; the ground's pixels lie within 10 (in RGB) of the first one and at +/// least 25 from the pixel's color. `at` is the opaque color at a pixel, `None` outside the image or where it is +/// not opaque. +fn bar_ground(at: &impl Fn(isize, isize) -> Option<[f64; 3]>, x: isize, y: isize, c: [f64; 3]) -> Option<[f64; 3]> { + let bar = |q: Option<[f64; 3]>| q.is_some_and(|q| dist2(q, c) <= 12.0); + for ((ax, ay), (bx, by)) in [((1, 0), (0, 1)), ((0, 1), (1, 0))] { + for s in [-1, 1] { + // Across the bar: ground, the pixel, its partner, ground. + let Some(g) = at(x + 2 * s * bx, y + 2 * s * by) else { continue }; + let ground = |q: Option<[f64; 3]>| q.is_some_and(|q| dist2(q, g) < 100.0 && dist2(q, c) >= 625.0); + let flat = (-1..=1).all(|k| { + let (px, py) = (x + k * ax, y + k * ay); + bar(at(px, py)) + && bar(at(px + s * bx, py + s * by)) + && ground(at(px - s * bx, py - s * by)) + && ground(at(px + 2 * s * bx, py + 2 * s * by)) + }); + if flat { + return Some(g); + } + } + } + None +} + /// Colours the current palette cannot explain. `residual` is each pixel's error after the best blend /// of the known classes (see `unmix`); a solid run of pixels (a pixel and its four neighbours agree) /// with a large error is a colour that is missing, such as the small accent that was too few -/// pixels to be found at first. Groups of at least three such pixels at least 25 away from every -/// known colour are returned, most common first. +/// pixels to be found at first. A flat bar two pixels wide on a ground within 25 of a known color +/// ([`bar_ground`]) has no such run: its pixels count when their error is large, and also when their +/// color lies less than two thirds as far from that known color as from every other known color (a +/// gray bar on white beside black art), where the bar would otherwise be traced as the ground. +/// Groups of at least three such pixels at least 25 away from every known color are returned, most +/// common first. pub(crate) fn unexplained_colours(pix: &[Px], w: usize, h: usize, residual: &[f32], known: &[[u8; 3]]) -> Vec<[u8; 3]> { let mut pal: Vec<[f64; 3]> = known.iter().map(|c| c.map(f64::from)).collect(); pal.push([255.0, 255.0, 255.0]); let at = |x: usize, y: usize| pix.get(y * w + x).filter(|p| p[3] > 0.98).map(rgb255); + let at_signed = |x: isize, y: isize| { + let (x, y) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?); + if x < w && y < h { at(x, y) } else { None } + }; let mut groups: Vec<([f64; 3], usize)> = Vec::new(); for y in 1..h.saturating_sub(1) { for x in 1..w.saturating_sub(1) { - if residual.get(y * w + x).copied().unwrap_or(0.0) < 0.0064 { + let large = residual.get(y * w + x).copied().unwrap_or(0.0) >= 0.0064; + let Some(c) = at(x, y) else { continue }; + if pal.iter().any(|q| dist2(*q, c) < 625.0) { continue; } - let Some(c) = at(x, y) else { continue }; let uniform = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)].iter().all(|&(xx, yy)| at(xx, yy).is_some_and(|q| dist2(q, c) < 100.0)); - if !uniform || pal.iter().any(|q| dist2(*q, c) < 625.0) { + let missing = match (uniform, isize::try_from(x), isize::try_from(y)) { + (true, ..) => large, + (false, Ok(sx), Ok(sy)) => bar_ground(&at_signed, sx, sy, c).is_some_and(|g| { + // The known color of the ground, and every other known color more than 1.5 times as far from the bar. + let Some(ground) = pal.iter().copied().find(|q| dist2(*q, g) < 625.0) else { return false }; + large || pal.iter().filter(|q| dist2(**q, ground) >= 625.0).all(|q| 2.25 * dist2(c, ground) < dist2(c, *q)) + }), + _ => false, + }; + if !missing { continue; } match groups.iter_mut().find(|(m, _)| dist2(*m, c) < 400.0) { diff --git a/crates/trace/src/logo/tests.rs b/crates/trace/src/logo/tests.rs index d0c773678..95b1916aa 100644 --- a/crates/trace/src/logo/tests.rs +++ b/crates/trace/src/logo/tests.rs @@ -556,6 +556,45 @@ fn sparse_marks_on_paper_are_not_promoted_to_colours() { } } +/// Bars two pixels wide (whole pixels of one color) on white beside a black disc, with the colors +/// found: no pixel of a bar has a 3 × 3 neighborhood of its own color, and a gray bar is a blend of +/// black and white. Light grays, a red and a pale yellow are found and traced in their own color. A +/// gray bar darker than about `#9B9B9B` stays black: a black stroke 1 to 1.6 px wide on the pixel +/// grid gives the same pixels. +#[test] +fn a_bar_two_pixels_wide_beside_black_art_is_found_in_its_color() { + const INK: [u8; 3] = [4, 4, 3]; + for (bar, want) in [ + ([170u8, 170, 170], None), + ([200, 200, 200], None), + ([230, 230, 230], None), + ([210, 40, 50], None), + ([250, 230, 150], None), + ([128, 128, 128], Some(INK)), + ([51, 51, 51], Some(INK)), + ] { + let img = Raster::from_fn(120, 60, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let c = if (fx - 25.0).hypot(fy - 28.0) <= 18.0 { + INK + } else if ((50..52).contains(&x) && (8..50).contains(&y)) || ((60..110).contains(&x) && (30..32).contains(&y)) { + bar + } else { + [255, 255, 255] + }; + [c[0], c[1], c[2], 255] + }); + let res = trace(&img, &crate::preset("Flat Logo").unwrap()); + let colour_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color); + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect(); + let want = want.unwrap_or(bar); + for at in [Point::new(51.0, 20.0), Point::new(51.0, 40.0), Point::new(70.0, 31.0), Point::new(100.0, 31.0)] { + assert_eq!(colour_at(at), Some(want), "bar {bar:?}: {at:?}; {paths:?}"); + } + assert_eq!(colour_at(Point::new(25.0, 28.0)), Some(INK), "bar {bar:?}: the disc; {paths:?}"); + } +} + /// 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.