Merge pull request #4008 from dotCooCoo/fix/trace-two-pixel-bar

Image Trace: Flat Logo finds a bar two pixels wide in its own color
This commit is contained in:
BFlat
2026-10-24 20:08:17 +07:00
committed by GitHub
2 changed files with 91 additions and 5 deletions
+52 -5
View File
@@ -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) {
+39
View File
@@ -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.