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.