Image Trace: Flat Logo traces a gray between black and white as one shape

With a gray among the colors, an anti-aliased edge pixel of black on
white is also a mix of black and gray, or of gray and white, and the
unmixing took the mix with the smallest error, which often held the
gray. A black disc and a gray square on white traced the gray as the
square plus 8 to 24 slivers around the disc, with the colors found or
named exactly.

When another mix of two classes also reproduces an edge pixel, the pixel
now takes it if that mix's classes lie nearer in the flat areas around
the pixel and the pixel has a neighbor on each side of the left-out
class, along the line between the other two. Pixels within a few levels
of a class outside a flat area are checked the same way, and they now
count toward the number of pixel values that decides when a busy image
is averaged down further. A flat area that stands for no class, as with
colors named some levels off, counts as holding the classes of its mix,
and so do the pixels of exactly its color joined to it. Whether a
hard-edged image is softened is still judged from each pixel's best mix.

The slivers are removed around sharp edges and edges blurred by up to
about half a pixel; an edge blurred by a pixel or more, or pixel noise
of a level or more, can still leave some. Gray strokes two and three
pixels wide beside black stay gray, and so does a one-pixel gray line
between black and white of at least 13 joined pixels. These trace as
black and white: a shorter one-pixel line, or one that falls between
pixels or is blurred; a soft-edged gray stroke about two pixels wide
touching black; and most or all of a soft-edged stroke about three
pixels wide touching black, 12 pixels long or less.

Docs: docs/image-trace-adjustments.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 20:32:14 -08:00
parent f062dd7ed2
commit 3ea1524618
5 changed files with 792 additions and 63 deletions
+42 -27
View File
@@ -6,7 +6,7 @@ use super::march::{area2, contours};
use super::outline::{Stats, Style, fit_outline};
use super::palette::{WHITE_MIN, auto_palette, flat_fraction, fold_tones, parse_hex, unexplained_colours};
use super::restore::RestoreParams;
use super::unmix::{Px, count_ambiguous, unmix};
use super::unmix::{Px, Switched, count_ambiguous, unmix};
use crate::{Raster, TraceError, TraceParams, TraceResult, TracedPath};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath};
@@ -20,7 +20,7 @@ const FIELD_CELLS_MAX: usize = 6_000_000;
const MIN_FLAT: f64 = 0.93;
/// Most outlines of one colour (above the noise threshold) we will fit; more is refused, not truncated.
const MAX_LOOPS_PER_CLASS: usize = if cfg!(test) { 500 } else { 20_000 };
/// Most distinct pixel values `unmix` may have to work on (those that are not exactly one color). A busier image is
/// Most distinct pixel values `unmix` may have to work on (`count_ambiguous`). A busier image is
/// averaged down further first, which bounds the time on pathological input.
const EDGE_BUDGET: usize = 150_000;
/// Palette colours closer than this (RGB distance) are one colour.
@@ -320,27 +320,33 @@ fn keep_shape(s: &Shape, map: &ToSource, kept: &mut Vec<Kept>, anchors_made: &mu
/// A hard-edged source (a GIF, say) has no anti-aliasing to read an edge position from, and its
/// outlines would follow the pixel staircase. Blurring the coverage by under a pixel gives the
/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone.
fn soften_if_aliased(planes: &mut [Vec<f32>], w: usize, h: usize) -> bool {
/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone. The
/// pixels that took another mix than their best one (`switched`) are judged by their best mix.
fn soften_if_aliased(planes: &mut [Vec<f32>], switched: &Switched, w: usize, h: usize) -> bool {
let n = w * h;
let dominant: Vec<usize> =
(0..n).map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(planes, a, i).total_cmp(&plane_at(planes, b, i))).unwrap_or(0)).collect();
let (mut boundary, mut blended) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let d = dominant.get(i).copied().unwrap_or(0);
let strongest = (0..planes.len()).map(|c| plane_at(planes, c, i)).fold(0.0f32, f32::max);
if strongest < 0.9 {
blended += 1;
}
let differs = |j: usize| dominant.get(j).is_some_and(|&o| o != d);
if (x + 1 < w && differs(i + 1)) || (y + 1 < h && differs(i + w)) {
boundary += 1;
let aliased = {
let at = |c: usize, i: usize| match switched.binary_search_by_key(&i, |&(j, _)| j) {
Ok(s) => switched.get(s).and_then(|(_, col)| col.get(c)).copied().unwrap_or(0.0),
Err(_) => plane_at(planes, c, i),
};
let dominant: Vec<usize> = (0..n).map(|i| (0..planes.len()).max_by(|&a, &b| at(a, i).total_cmp(&at(b, i))).unwrap_or(0)).collect();
let (mut boundary, mut blended) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let d = dominant.get(i).copied().unwrap_or(0);
let strongest = (0..planes.len()).map(|c| at(c, i)).fold(0.0f32, f32::max);
if strongest < 0.9 {
blended += 1;
}
let differs = |j: usize| dominant.get(j).is_some_and(|&o| o != d);
if (x + 1 < w && differs(i + 1)) || (y + 1 < h && differs(i + w)) {
boundary += 1;
}
}
}
}
let aliased = boundary > 0 && blended * 4 < boundary;
boundary > 0 && blended * 4 < boundary
};
if aliased {
for p in planes.iter_mut() {
blur_plane(p, w, h, 0.8);
@@ -437,26 +443,27 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
let mut classes = classes_for(&palette, own_white || merge_white.is_some());
// A very busy image is averaged down further until the unmixing work is bounded.
for _ in 0..6 {
if count_ambiguous(&pix, &classes) <= EDGE_BUDGET || w * h <= 16_384 {
if count_ambiguous(&pix, w, h, &classes) <= EDGE_BUDGET || w * h <= 16_384 {
break;
}
(pix, w, h, factor) = working_copy(img, factor * 2);
}
let (mut planes, residual) = unmix(&pix, w, h, &classes);
let mut planes = if params.logo_colors.is_empty() && palette.len() < MAX_COLOURS {
let (mut planes, residual, switched) = unmix(&pix, w, h, &classes);
let (mut planes, mut switched) = if params.logo_colors.is_empty() && palette.len() < MAX_COLOURS {
// Colours too small to be found at first show up as pixels nothing explains: add them.
let extra = unexplained_colours(&pix, w, h, &residual, &palette);
if extra.is_empty() {
planes
(planes, switched)
} else {
palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len()));
// (A colour nothing explained may be one more tone of a colour that is there.)
palette = fold_tones(&pix, palette);
classes = classes_for(&palette, own_white);
unmix(&pix, w, h, &classes).0
let (planes, _, switched) = unmix(&pix, w, h, &classes);
(planes, switched)
}
} else {
std::mem::take(&mut planes)
(std::mem::take(&mut planes), switched)
};
if let Some(to) = merge_white
&& palette.len() < planes.len()
@@ -467,10 +474,18 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
*t += *v;
}
}
for (_, col) in &mut switched {
if palette.len() < col.len() {
let white_weight = col.remove(palette.len());
if let Some(t) = col.get_mut(to) {
*t += white_weight;
}
}
}
}
// Without a white class, `white` is an index no class has.
let (white, transparent) = if own_white { (palette.len(), palette.len() + 1) } else { (usize::MAX, palette.len()) };
let aliased = soften_if_aliased(&mut planes, w, h);
let aliased = soften_if_aliased(&mut planes, &switched, w, h);
if own_white {
fill_knockouts(&mut planes, w, h, white, transparent);
}
+239
View File
@@ -919,3 +919,242 @@ fn an_outline_fitted_without_area_is_left_out() {
}
}
}
/// A black disc and a gray square on white, the gray lying on the line from black to white: the
/// disc's edge pixels are blends of black and white, so the gray is one shape (the square) and no
/// gray lines the disc. Colors found and named exactly, the darker gray also named 52 levels off;
/// sharp and soft edges.
#[test]
fn a_gray_between_black_and_white_is_one_shape() {
const INK: [u8; 3] = [4, 4, 3];
for gray in [[230u8, 230, 230], [200, 200, 200]] {
for blur in [0.0, 0.6] {
let img = render(120, 60, blur, |x, y| {
Some(if (x - 30.0).hypot(y - 30.0) <= 20.0 {
INK
} else if (60.0..=110.0).contains(&x) && (10.0..=50.0).contains(&y) {
gray
} else {
WHITE
})
});
// (A gray of 230 named 52 levels darker lies nearer white than its name, and is traced as white.)
let offs: &[Option<u8>] = if gray[0] == 200 { &[None, Some(0), Some(52)] } else { &[None, Some(0)] };
for &by in offs {
let (p, gray_name) = match by {
None => (logo_params(), gray),
Some(by) => {
// Each name moved `by` levels toward mid-gray: the black lighter, the gray darker.
let names = [INK.map(|v| v.saturating_add(by)), gray.map(|v| v.saturating_sub(by))];
let logo_colors = names.iter().map(|c| format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2])).collect();
(TraceParams { logo_colors, ..logo_params() }, names[1])
}
};
let what = format!("gray {gray:?}, blur {blur}, names moved {by:?}");
let res = trace(&img, &p);
let grays = res.paths.iter().filter(|t| t.color == gray_name).count();
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
assert_eq!(grays, 1, "{what}: {grays} gray paths; {paths:?}");
assert_eq!(top_color(&res, Point::new(85.0, 30.0)), Some(gray_name), "{what}: the square");
for k in 0..16 {
let a = f64::from(k) * std::f64::consts::TAU / 16.0;
let at = Point::new(30.0 + 20.0 * a.cos(), 30.0 + 20.0 * a.sin());
assert_ne!(top_color(&res, at), Some(gray_name), "{what}: the disc's edge at {at:?}");
}
}
}
}
}
/// A one-pixel gray line between flat black and flat white, with the three colors named, stays a
/// gray line along its whole length, beside a black disc whose edge pixels are as light as the gray.
#[test]
fn a_one_pixel_gray_line_between_black_and_white_stays_gray() {
const INK: [u8; 3] = [4, 4, 3];
for gray in [[230u8, 230, 230], [200, 200, 200], [128, 128, 128]] {
let img = render(120, 80, 0.0, |x, y| {
Some(if x < 40.0 || (x - 90.0).hypot(y - 40.0) <= 15.0 {
INK
} else if x < 41.0 {
gray
} else {
WHITE
})
});
let logo_colors = vec!["#040403".into(), format!("#{:02X}{:02X}{:02X}", gray[0], gray[1], gray[2]), "#FFFFFF".into()];
let res = trace(&img, &TraceParams { logo_colors, ..logo_params() });
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
for y in 2..78 {
let at = Point::new(40.5, f64::from(y) + 0.5);
assert_eq!(top_color(&res, at), Some(gray), "gray {gray:?}: the line at {at:?}; {paths:?}");
}
for k in 0..16 {
let a = f64::from(k) * std::f64::consts::TAU / 16.0;
let at = Point::new(90.0 + 15.0 * a.cos(), 40.0 + 15.0 * a.sin());
assert_ne!(top_color(&res, at), Some(gray), "gray {gray:?}: the disc's edge at {at:?}");
}
}
}
/// A gray stroke 12 pixels long touching a black disc on white, with the three colors named: the
/// stroke's pixels join the disc's edge pixels that are as light as the gray, but no gray lines the
/// rest of the disc's edge, and a sharp stroke stays gray. Strokes two and three pixels wide outside
/// the disc, on and off the pixel grid, sharp and soft. A soft stroke about two pixels wide that
/// touches black is traced as black and white, and one about three pixels wide in part, so the
/// stroke's own color is checked on sharp strokes.
#[test]
fn a_gray_stroke_touching_a_black_disc_leaves_its_edge_black_and_white() {
const INK: [u8; 3] = [4, 4, 3];
for gray in [[230u8, 230, 230], [200, 200, 200], [128, 128, 128]] {
for blur in [0.0, 0.6] {
// The disc reaches x = 42 to 43 along the stroke's rows; each stroke starts inside it.
for (sx, wide) in [(41.0, 4.0), (41.5, 4.0), (41.0, 5.0)] {
let img = render(120, 60, blur, |x, y| {
Some(if (x - 25.0).hypot(y - 28.0) <= 18.0 {
INK
} else if (sx..sx + wide).contains(&x) && (22.0..34.0).contains(&y) {
gray
} else {
WHITE
})
});
let logo_colors = vec!["#040403".into(), format!("#{:02X}{:02X}{:02X}", gray[0], gray[1], gray[2]), "#FFFFFF".into()];
let res = trace(&img, &TraceParams { logo_colors, ..logo_params() });
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
let what = format!("gray {gray:?}, blur {blur}, stroke at {sx} {wide} px wide");
if blur == 0.0 {
for y in 24..32 {
let at = Point::new(sx + wide - 1.0, f64::from(y) + 0.5);
assert_eq!(top_color(&res, at), Some(gray), "{what}: the stroke at {at:?}; {paths:?}");
}
}
// Points on the disc's edge more than 30 degrees from the stroke (which spans about 20).
for k in 6..=58 {
let a = f64::from(k) * std::f64::consts::TAU / 64.0;
let at = Point::new(25.0 + 18.0 * a.cos(), 28.0 + 18.0 * a.sin());
assert_ne!(top_color(&res, at), Some(gray), "{what}: the disc's edge at {at:?}; {paths:?}");
}
}
}
}
}
/// Stripes on white (black, a dark gray with a black disc on it, a light gray), with the colors
/// named 52 levels off: each gray then lies on the line between two other names, and the dark
/// stripe's rows beside white keep the stripe's color above and below the disc. Sharp and soft edges,
/// with and without white named.
#[test]
fn a_gray_stripe_beside_white_keeps_its_color_beside_a_black_disc() {
const INK: [u8; 3] = [5, 5, 3];
for blur in [0.0, 0.6] {
let img = render(300, 160, blur, |x, y| {
let inside = |x0: f64, y0: f64, x1: f64, y1: f64| (x0..x1).contains(&x) && (y0..y1).contains(&y);
Some(if (x - 150.0).hypot(y - 80.0) <= 12.0 || inside(20.0, 20.0, 280.0, 50.0) {
INK
} else if inside(20.0, 65.0, 280.0, 95.0) {
[100, 100, 100]
} else if inside(20.0, 110.0, 280.0, 140.0) {
[190, 190, 190]
} else {
WHITE
})
});
for white in [false, true] {
let mut logo_colors: Vec<String> = ["#393937", "#989898", "#8A8A8A"].iter().map(|s| (*s).to_string()).collect();
if white {
logo_colors.push("#FFFFFF".into());
}
let res = trace(&img, &TraceParams { logo_colors, ..logo_params() });
let what = format!("blur {blur}, white named {white}");
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
let stripe = top_color(&res, Point::new(60.0, 80.0));
assert_ne!(stripe, Some([0x39, 0x39, 0x37]), "{what}: the stripe; {paths:?}");
for x in [140.0, 145.0, 150.0, 155.0, 160.0] {
for y in [65.5, 94.5] {
assert_eq!(top_color(&res, Point::new(x, y)), stripe, "{what}: ({x}, {y}); {paths:?}");
}
}
if blur == 0.0 {
let inks = res.paths.iter().filter(|t| t.color == [0x39, 0x39, 0x37]).count();
assert_eq!(inks, 2, "{what}: {inks} paths in the black's name; {paths:?}");
}
}
}
}
/// Gray strokes two and three pixels wide on white (whole pixels of the gray), 3 and 4 px from a
/// black disc, and a two-pixel stroke six pixels long, fewer pixels than a line needs to keep its
/// class, 2 to 4.5 px from the disc: the strokes' pixels are blends of black and white too, but nothing
/// darker than the gray lies beside them, and they stay gray.
#[test]
fn thin_gray_strokes_beside_black_stay_gray() {
const INK: [u8; 3] = [4, 4, 3];
for gray in [[230u8, 230, 230], [200, 200, 200]] {
for wide in [2.0, 3.0] {
let img = render(120, 60, 0.0, |x, y| {
Some(if (x - 25.0).hypot(y - 28.0) <= 18.0 {
INK
} else if ((47.0..47.0 + wide).contains(&x) && (8.0..50.0).contains(&y))
|| ((8.0..110.0).contains(&x) && (49.0..49.0 + wide).contains(&y))
|| ((44.0..46.0).contains(&x) && (16.0..22.0).contains(&y))
{
gray
} else {
WHITE
})
});
let logo_colors = vec!["#040403".into(), format!("#{:02X}{:02X}{:02X}", gray[0], gray[1], gray[2])];
let res = trace(&img, &TraceParams { logo_colors, ..logo_params() });
let what = format!("gray {gray:?}, {wide} px strokes");
let short = (17..21).map(|y| Point::new(45.0, f64::from(y) + 0.5));
let long = [Point::new(47.0 + wide / 2.0, 20.0), Point::new(47.0 + wide / 2.0, 30.0), Point::new(25.0, 49.0 + wide / 2.0)];
for at in long.into_iter().chain(short) {
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
assert_eq!(top_color(&res, at), Some(gray), "{what}: {at:?}; {paths:?}");
}
}
}
}
/// A sharp black disc and a mid-gray square on white, with up to 2 levels of noise added to each
/// pixel: the edge pixels as light as the gray, read as blends of black and white, still count as
/// unblended when judging whether the source is aliased, so the trace is softened as without them
/// and no gray lines the disc.
#[test]
fn a_noisy_hard_edged_gray_between_black_and_white_is_one_shape() {
const GRAY: [u8; 3] = [128, 128, 128];
let mut img = render(120, 60, 0.0, |x, y| {
Some(if (x - 30.0).hypot(y - 30.0) <= 20.0 {
[0, 0, 0]
} else if (60.0..=110.0).contains(&x) && (10.0..=50.0).contains(&y) {
GRAY
} else {
WHITE
})
});
for (i, px) in img.rgba.chunks_mut(4).enumerate() {
let mut h = (i as u64 ^ 0x9E37_79B9_7F4A_7C15).wrapping_mul(0x2545_F491_4F6C_DD1D);
h ^= h >> 29;
let d = (h % 5) as i32 - 2;
for c in px.iter_mut().take(3) {
*c = (i32::from(*c) + d).clamp(0, 255) as u8;
}
}
for named in [false, true] {
let p = if named {
TraceParams { logo_colors: vec!["#000000".into(), "#808080".into(), "#FFFFFF".into()], ..logo_params() }
} else {
logo_params()
};
let res = trace(&img, &p);
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
let grays = res.paths.iter().filter(|t| t.color == GRAY).count();
assert_eq!(grays, 1, "named {named}: {grays} gray paths; {paths:?}");
assert_eq!(top_color(&res, Point::new(85.0, 30.0)), Some(GRAY), "named {named}: the square");
for k in 0..16 {
let a = f64::from(k) * std::f64::consts::TAU / 16.0;
let at = Point::new(30.0 + 20.0 * a.cos(), 30.0 + 20.0 * a.sin());
assert_ne!(top_color(&res, at), Some(GRAY), "named {named}: the disc's edge at {at:?}; {paths:?}");
}
}
}
+489 -34
View File
@@ -3,7 +3,10 @@
//! An edge pixel of an anti-aliased logo is a blend of the colours that meet there. Reading it as
//! "the nearest colour" throws away exactly the sub-pixel information that says where the edge
//! runs, so each pixel is instead explained as a mix of at most three classes, whichever mix
//! reproduces the pixel best.
//! reproduces the pixel best. When another mix also reproduces it (with a gray between black and
//! white among the classes, a black-to-white edge pixel is a mix of black and gray as well as of
//! black and white), the pixel takes the mix whose classes lie nearest it in the flat areas around it,
//! when its neighbors show an edge between those classes.
use std::collections::{HashMap, HashSet};
@@ -13,6 +16,20 @@ pub(crate) type Px = [f32; 4];
const LAMBDA: f64 = 30.0;
/// Pixels this close (in 0..1 premultiplied RGBA) to a class are taken as exactly that class.
const PURE: f64 = 4.0 / 255.0;
/// A mix whose squared error is at most this reproduces the pixel: it comes within [`PURE`] of it,
/// as a pixel taken as exactly one class does.
const REPRODUCES: f64 = PURE * PURE;
/// How far (in pixels, each way) a class is looked for around a pixel that two mixes reproduce.
const NEARBY: usize = 6;
/// A pixel whose neighbors are all this close to it (in 0..1 premultiplied RGBA) lies in a flat area.
const FLAT: f64 = 1.5 / 255.0;
/// How many joined pixels within [`PURE`] of one class are taken as art of that class, such as a
/// one-pixel line. The edge pixels of a curve that come within `PURE` of a class join in far fewer.
/// A straight edge whose anti-aliased pixels come within `PURE` of
/// a class gives the same pixels as a line of that class, and keeps the class too. A one-pixel line
/// between two classes that is shorter, falls between pixels or is blurred is read as a blend of
/// those two classes.
const LINE: usize = 2 * NEARBY + 1;
struct Subset {
idx: [usize; 3],
@@ -90,21 +107,33 @@ fn subsets(classes: &[[f64; 4]]) -> Vec<Subset> {
out
}
/// How many distinct pixel values are not (nearly) exactly one class: the ones `unmix` has to work
/// on. A flat area whose color is near but not exactly a class counts once, however large it is.
pub(crate) fn count_ambiguous(pix: &[Px], classes: &[[f64; 4]]) -> usize {
/// How many distinct pixel values `unmix` has to work on: the values not (nearly) exactly one class,
/// and the values that are, at pixels outside a flat area ([`in_flat_area`]). A flat area whose color
/// is near but not exactly a class counts once, however large it is.
pub(crate) fn count_ambiguous(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]]) -> usize {
let mut seen = HashSet::new();
for px in pix {
for (i, px) in pix.iter().take(width.saturating_mul(height)).enumerate() {
let p = [f64::from(px[0]), f64::from(px[1]), f64::from(px[2]), f64::from(px[3])];
if !classes.iter().any(|cv| (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum::<f64>().sqrt() < PURE) {
seen.insert(px.map(f32::to_bits));
let key = px.map(f32::to_bits);
if seen.contains(&key) {
continue;
}
if !classes.iter().any(|cv| (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum::<f64>().sqrt() < PURE)
|| !in_flat_area(pix, width, height, i % width, i / width)
{
seen.insert(key);
}
}
seen.len()
}
/// Coverage planes, one `width × height` plane per class (each pixel's weights sum to 1).
pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]]) -> (Vec<Vec<f32>>, Vec<f32>) {
/// The pixels that took another mix than their best one, in pixel order, each with every class's
/// weight before it did.
pub(crate) type Switched = Vec<(usize, Vec<f32>)>;
/// Coverage planes, one `width × height` plane per class (each pixel's weights sum to 1), the
/// squared error each pixel's best mix leaves, and the pixels that took another mix ([`Switched`]).
pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]]) -> (Vec<Vec<f32>>, Vec<f32>, Switched) {
let k = classes.len();
let n = width.saturating_mul(height);
let mut planes = vec![vec![0.0f32; n]; k];
@@ -113,46 +142,384 @@ pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]
let subs = subsets(classes);
// Each distinct pixel value is mixed once: a flat area that is near but not exactly a class
// repeats one value.
let mut mixed: HashMap<[u32; 4], Mix> = HashMap::new();
let mut mixed: HashMap<[u32; 4], Mixes> = HashMap::new();
// The class each pixel stands for (the class nearest it, when every other class is at least
// twice as far).
let mut stands_for: Vec<Option<usize>> = vec![None; n];
// The class each pixel comes within `PURE` of, where it does.
let mut pure: Vec<Option<usize>> = vec![None; n];
// The pixels outside a flat area that another mix also reproduces, with their values.
let mut tied: Vec<(usize, [u32; 4])> = Vec::new();
for (i, px) in pix.iter().take(n).enumerate() {
let p = [f64::from(px[0]), f64::from(px[1]), f64::from(px[2]), f64::from(px[3])];
// Fast path: a pixel that is (nearly) exactly one class.
let mut near = (f64::MAX, 0usize);
let mut second = f64::MAX;
for (c, cv) in classes.iter().enumerate() {
let d: f64 = (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum();
if d < near.0 {
second = near.0;
near = (d, c);
} else if d < second {
second = d;
}
}
if let Some(q) = stands_for.get_mut(i) {
// (Squared distances: twice as far is four times the square.)
*q = (4.0 * near.0 <= second).then_some(near.1);
}
let key = px.map(f32::to_bits);
if near.0.sqrt() < PURE {
if let Some(v) = planes.get_mut(near.1).and_then(|pl| pl.get_mut(i)) {
*v = 1.0;
}
if let Some(q) = pure.get_mut(i) {
*q = Some(near.1);
}
// (An edge pixel can come within `PURE` of a class it holds none of: a black-to-white
// edge pixel as light as a gray class.)
if !in_flat_area(pix, width, height, i % width, i / width)
&& !mixed.entry(key).or_insert_with(|| best_mixes(p, near, &subs)).tied.is_empty()
{
tied.push((i, key));
}
continue;
}
let (res, weights) = mixed.entry(px.map(f32::to_bits)).or_insert_with(|| best_mix(p, near, &subs));
let mixes = mixed.entry(key).or_insert_with(|| best_mixes(p, near, &subs));
if let Some(r) = residual.get_mut(i) {
*r = *res;
*r = mixes.residual;
}
for &(c, wv) in weights.iter() {
if let Some(v) = planes.get_mut(c).and_then(|pl| pl.get_mut(i)) {
*v = wv;
set_weights(&mut planes, i, &mixes.best, 1.0);
// (A pixel in a flat area has no neighbor far enough from it for `across_edge`.)
if !mixes.tied.is_empty() && !in_flat_area(pix, width, height, i % width, i / width) {
tied.push((i, key));
}
}
let mut switched = Vec::new();
if tied.is_empty() {
return (planes, residual, switched);
}
// A pixel two mixes reproduce takes another mix of two classes whose farthest class lies nearer
// it than the best mix's does, in the flat areas around it ([`flat_classes`], [`RowDistances::around`]),
// when the pixel lies on an edge between that mix's classes ([`across_edge`]). For the best mix,
// the classes held by flat areas between two classes count as well ([`between_classes`]). A
// pixel within `PURE` of a class that is joined to at least [`LINE`] such pixels of that class
// (itself included) keeps it ([`pure_runs`]), as a one-pixel line of gray between black and white.
let flat = flat_classes(pix, &stands_for, width, height);
let between = between_classes(pix, &flat, &mixed, width, height);
let runs = pure_runs(&pure, width, height);
let mut flat_rows = RowDistances::new(|j| [flat.get(j).copied().flatten(), None], width, height, k);
let mut between_rows = (!between.is_empty()).then(|| RowDistances::new(|j| between.get(j).copied().unwrap_or([None; 2]), width, height, k));
let mut dist = vec![NEARBY + 1; k];
let mut kept = vec![NEARBY + 1; k];
for (i, key) in tied {
if pure.get(i).copied().flatten().is_some() && runs.get(i).is_some_and(|&r| r >= LINE) {
continue;
}
let Some(mixes) = mixed.get(&key) else { continue };
let edges: Vec<&Weights> = mixes.tied.iter().filter(|m| across_edge(pix, width, height, i, classes, &mixes.best, m)).collect();
if edges.is_empty() {
continue;
}
flat_rows.around(i, &mut dist);
kept.copy_from_slice(&dist);
if let Some(rows) = &mut between_rows {
rows.around(i, &mut kept);
for (h, &d) in kept.iter_mut().zip(&dist) {
*h = (*h).min(d);
}
}
let now = farthest(&mixes.best, &kept);
let other = edges.into_iter().filter(|m| farthest(m, &dist) < now).min_by_key(|m| farthest(m, &dist));
if let Some(other) = other {
switched.push((i, planes.iter().map(|pl| pl.get(i).copied().unwrap_or(0.0)).collect()));
// (`residual` keeps the best mix's error; the mix taken comes within `REPRODUCES` of the
// pixel as well.)
set_weights(&mut planes, i, &mixes.best, 0.0);
set_weights(&mut planes, i, other, 1.0);
}
}
(planes, residual, switched)
}
/// Whether pixel `i` lies on an edge between the two classes of `other`, rather than in an area of
/// a class of `best` that `other` leaves out: for each such class, the pixel has a neighbor beyond
/// it toward each class of `other`, along the line between them. (A black-to-white edge pixel as
/// light as a gray has a darker neighbor and a lighter one; a gray stroke on white has no neighbor
/// darker than the gray.)
fn across_edge(pix: &[Px], width: usize, height: usize, i: usize, classes: &[[f64; 4]], best: &Weights, other: &Weights) -> bool {
let [(a, _), (b, _)] = other.as_slice() else { return false };
let (Some(ca), Some(cb)) = (classes.get(*a), classes.get(*b)) else { return false };
let ab: [f64; 4] = std::array::from_fn(|j| cb[j] - ca[j]);
let len2: f64 = ab.iter().map(|v| v * v).sum();
if len2 <= 1e-12 {
return false;
}
let len = len2.sqrt();
// Where `v` lies along the line from `a` to `b` (0 at `a`, 1 at `b`), and how far off it.
let place = |v: [f64; 4]| {
let t = (0..4).map(|j| (v[j] - ca[j]) * ab[j]).sum::<f64>() / len2;
let off = (0..4).map(|j| (v[j] - ca[j] - t * ab[j]).powi(2)).sum::<f64>().sqrt();
(t, off)
};
let (x, y) = (i % width.max(1), i / width.max(1));
let neighbors: Vec<f64> = (y.saturating_sub(1)..(y + 2).min(height))
.flat_map(|yy| (x.saturating_sub(1)..(x + 2).min(width)).map(move |xx| yy * width + xx))
.filter(|&j| j != i)
.filter_map(|j| pix.get(j))
.map(|q| place([f64::from(q[0]), f64::from(q[1]), f64::from(q[2]), f64::from(q[3])]))
.filter(|&(_, off)| off <= 2.0 * PURE)
.map(|(t, _)| t)
.collect();
let left_out: Vec<usize> = best.iter().filter(|&&(c, w)| w > 0.0 && c != *a && c != *b).map(|&(c, _)| c).collect();
!left_out.is_empty()
&& left_out.iter().all(|&c| {
let Some(&cv) = classes.get(c) else { return false };
let (tc, _) = place(cv);
neighbors.iter().any(|&t| (tc - t) * len > PURE) && neighbors.iter().any(|&t| (t - tc) * len > PURE)
})
}
/// How far the farthest class of `mix` lies, by the distances `dist` ([`RowDistances::around`]).
fn farthest(mix: &Weights, dist: &[usize]) -> usize {
mix.iter().filter(|&&(_, w)| w > 0.0).map(|&(c, _)| dist.get(c).copied().unwrap_or(NEARBY + 1)).max().unwrap_or(0)
}
/// Sets pixel `i` of each class's plane in `weights` to the class's weight times `scale`.
fn set_weights(planes: &mut [Vec<f32>], i: usize, weights: &[(usize, f32)], scale: f32) {
for &(c, wv) in weights {
if let Some(v) = planes.get_mut(c).and_then(|pl| pl.get_mut(i)) {
*v = wv * scale;
}
}
}
/// A class's weight in the mix of a flat area that stands for no class, from which the area
/// counts as holding that class.
const HOLDS: f32 = 0.25;
/// The class each pixel stands for (`stands_for`) where the pixel lies in a flat area
/// ([`in_flat_area`]), and `None` elsewhere.
fn flat_classes(pix: &[Px], stands_for: &[Option<usize>], width: usize, height: usize) -> Vec<Option<usize>> {
(0..width.saturating_mul(height))
.map(|i| stands_for.get(i).copied().flatten().filter(|_| in_flat_area(pix, width, height, i % width, i / width)))
.collect()
}
/// The classes held by flat areas that stand for no class (a flat color between two classes, as
/// with colors named some levels off): the classes of at least [`HOLDS`] weight in the area's mix
/// (at most two, the heaviest), held also by the pixels of exactly its color joined to it, flat or
/// not (its rows beside another color, where a soft edge or a shape close by leaves no flat pixel).
/// Empty when there is no such area.
fn between_classes(pix: &[Px], flat: &[Option<usize>], mixed: &HashMap<[u32; 4], Mixes>, width: usize, height: usize) -> Vec<[Option<usize>; 2]> {
let mut held = Vec::new();
// Pixels holding classes, from which the pixels of their color are reached.
let mut reach: Vec<usize> = Vec::new();
for (i, f) in flat.iter().enumerate() {
if f.is_some() || !in_flat_area(pix, width, height, i % width, i / width) {
continue;
}
let Some(mixes) = pix.get(i).and_then(|px| mixed.get(&px.map(f32::to_bits))) else { continue };
let mut heavy: Vec<(usize, f32)> = mixes.best.iter().copied().filter(|&(_, w)| w >= HOLDS).collect();
heavy.sort_by(|a, b| b.1.total_cmp(&a.1));
if heavy.is_empty() {
continue;
}
if held.is_empty() {
held = vec![[None; 2]; width.saturating_mul(height)];
}
if let Some(slot) = held.get_mut(i) {
*slot = [heavy.first().map(|&(c, _)| c), heavy.get(1).map(|&(c, _)| c)];
reach.push(i);
}
}
while let Some(i) = reach.pop() {
let (Some(&classes), Some(key)) = (held.get(i), pix.get(i).map(|px| px.map(f32::to_bits))) else { continue };
let (x, y) = (i % width, i / width);
for yy in y.saturating_sub(1)..(y + 2).min(height) {
for xx in x.saturating_sub(1)..(x + 2).min(width) {
let j = yy * width + xx;
if let (Some(slot @ [None, None]), Some(q)) = (held.get_mut(j), pix.get(j))
&& q.map(f32::to_bits) == key
{
*slot = classes;
reach.push(j);
}
}
}
}
(planes, residual)
held
}
/// A pixel's mix: the squared error it leaves and each class's weight.
type Mix = (f32, Vec<(usize, f32)>);
/// For each of `k` classes, how far along each row the nearest pixel holding that class (`at`, the
/// classes pixel `j` holds) lies from each pixel, up to `NEARBY + 1` when none lies within
/// [`NEARBY`]. A row is worked out when it is first asked for, so a few pixels cost a few rows.
struct RowDistances<F> {
at: F,
width: usize,
k: usize,
/// Per row, once worked out: the distance for pixel `x` and class `c` at `x * k + c`.
rows: Vec<Option<Vec<u8>>>,
}
impl<F: Fn(usize) -> [Option<usize>; 2]> RowDistances<F> {
const NONE: u8 = (NEARBY + 1) as u8;
fn new(at: F, width: usize, height: usize, k: usize) -> Self {
Self { at, width, k, rows: vec![None; height] }
}
fn row(&mut self, y: usize) -> &[u8] {
if self.rows.get(y).is_some_and(Option::is_none) {
let built = self.build(y);
if let Some(slot) = self.rows.get_mut(y) {
*slot = Some(built);
}
}
self.rows.get(y).and_then(Option::as_deref).unwrap_or(&[])
}
fn build(&self, y: usize) -> Vec<u8> {
let (width, k) = (self.width, self.k);
let mut row = vec![Self::NONE; width.saturating_mul(k)];
// Toward the right, then toward the left, each from the last pixel holding the class.
let mut last = vec![Self::NONE; k];
for x in 0..width {
let held = (self.at)(y * width + x);
for (c, d) in last.iter_mut().enumerate() {
*d = if held.contains(&Some(c)) { 0 } else { d.saturating_add(1).min(Self::NONE) };
if let Some(r) = row.get_mut(x * k + c) {
*r = *d;
}
}
}
last.fill(Self::NONE);
for x in (0..width).rev() {
for (c, d) in last.iter_mut().enumerate() {
let Some(r) = row.get_mut(x * k + c) else { continue };
*d = if *r == 0 { 0 } else { d.saturating_add(1).min(Self::NONE) };
*r = (*r).min(*d);
}
}
row
}
/// For each class, how far (in pixels, the larger of the two axes) the nearest pixel holding
/// that class lies from pixel `i`, into `dist` (one entry per class); `NEARBY + 1` when none
/// lies within [`NEARBY`].
fn around(&mut self, i: usize, dist: &mut [usize]) {
let (width, k, height) = (self.width.max(1), self.k, self.rows.len());
let (x, y) = (i % width, i / width);
dist.fill(NEARBY + 1);
for yy in y.saturating_sub(NEARBY)..(y + NEARBY + 1).min(height) {
let dy = yy.abs_diff(y);
let row = self.row(yy);
for (c, d) in dist.iter_mut().enumerate().take(k) {
if let Some(&r) = row.get(x * k + c) {
*d = (*d).min(usize::from(r).max(dy));
}
}
}
}
}
/// For each pixel within [`PURE`] of a class (`pure`), how many pixels within `PURE` of the same
/// class are joined to it (itself included, through the eight neighbors); 0 for other pixels.
fn pure_runs(pure: &[Option<usize>], width: usize, height: usize) -> Vec<usize> {
let n = width.saturating_mul(height);
let mut size = vec![0usize; n];
let mut seen = vec![false; n];
let mut stack = Vec::new();
let mut members = Vec::new();
for start in 0..n {
let Some(c) = pure.get(start).copied().flatten() else { continue };
if seen.get(start).copied().unwrap_or(true) {
continue;
}
if let Some(s) = seen.get_mut(start) {
*s = true;
}
stack.push(start);
members.clear();
while let Some(i) = stack.pop() {
members.push(i);
let (x, y) = (i % width, i / width);
for yy in y.saturating_sub(1)..(y + 2).min(height) {
for xx in x.saturating_sub(1)..(x + 2).min(width) {
let j = yy * width + xx;
if pure.get(j).copied().flatten() == Some(c)
&& let Some(s) = seen.get_mut(j)
&& !*s
{
*s = true;
stack.push(j);
}
}
}
}
for &i in &members {
if let Some(s) = size.get_mut(i) {
*s = members.len();
}
}
}
size
}
/// Whether every neighbor of the pixel at (`x`, `y`) inside the image lies within [`FLAT`] of it.
fn in_flat_area(pix: &[Px], width: usize, height: usize, x: usize, y: usize) -> bool {
let Some(p) = pix.get(y * width + x) else { return false };
(y.saturating_sub(1)..(y + 2).min(height)).all(|yy| {
(x.saturating_sub(1)..(x + 2).min(width)).all(|xx| {
pix.get(yy * width + xx).is_some_and(|q| (0..4).map(|j| (f64::from(p[j]) - f64::from(q[j])).powi(2)).sum::<f64>().sqrt() <= FLAT)
})
})
}
/// Each class's weight in a pixel's mix.
type Weights = Vec<(usize, f32)>;
/// A mix tried for a pixel: its squared error with its penalty, the penalty, and its classes with
/// their weights (the first `n`).
#[derive(Clone, Copy)]
struct Candidate {
res: f64,
penalty: f64,
idx: [usize; 3],
w: [f64; 3],
n: usize,
}
impl Candidate {
fn weights(&self) -> Weights {
self.idx.iter().zip(self.w).take(self.n).map(|(&c, w)| (c, w as f32)).collect()
}
}
/// How a pixel value is mixed: the squared error the best mix leaves, each class's weight in it,
/// and the other mixes of two classes that reproduce the value ([`REPRODUCES`]).
struct Mixes {
residual: f32,
best: Weights,
tied: Vec<Weights>,
}
/// The mix of at most three classes that reproduces `p` best, starting from the nearest single
/// class `near` (squared distance, class).
fn best_mix(p: [f64; 4], near: (f64, usize), subs: &[Subset]) -> Mix {
/// class `near` (squared distance, class), and the other mixes that also reproduce it.
fn best_mixes(p: [f64; 4], near: (f64, usize), subs: &[Subset]) -> Mixes {
let reproduces = |m: &Candidate| m.res - m.penalty <= REPRODUCES;
// Best single class is the starting point; try every two- and three-class mix.
let mut best = (near.0, vec![(near.1, 1.0f64)]);
let mut best_penalty = 0.0;
for s in subs {
let mut top = Candidate { res: near.0, penalty: 0.0, idx: [near.1, 0, 0], w: [1.0, 0.0, 0.0], n: 1 };
// The other mixes of two classes that reproduce the pixel, also when the best mix does not (a
// single class carries no penalty and can come nearer than an exact mix of two): only a mix of
// two is switched to ([`across_edge`]). A mix holding the nearest class when that class alone
// reproduces the pixel adds a class of next to no weight to it, and is left out. (At most one
// candidate per pair of classes, so the list stays small with many classes.) Each is kept with
// its subset's index, and the best mix found is left out of them at the end.
let single = reproduces(&top).then_some(near.1);
let mut top_at = None;
let mut pairs: Vec<(usize, Candidate)> = Vec::new();
for (at, s) in subs.iter().enumerate() {
let mut rhs = [0.0f64; 3];
for (j, r) in rhs.iter_mut().enumerate().take(s.n) {
*r = (0..4).map(|c| s.cols[j][c] * p[c]).sum::<f64>() + LAMBDA * LAMBDA;
@@ -180,12 +547,17 @@ fn best_mix(p: [f64; 4], near: (f64, usize), subs: &[Subset]) -> Mix {
let m: f64 = (0..s.n).map(|j| w[j] * s.cols[j][c]).sum();
res += (pc - m).powi(2);
}
if res < best.0 {
best = (res, (0..s.n).map(|j| (s.idx[j], w[j])).collect());
best_penalty = s.penalty;
let m = Candidate { res, penalty: s.penalty, idx: s.idx, w, n: s.n };
if m.n == 2 && reproduces(&m) && single.is_none_or(|c| m.idx[0] != c && m.idx[1] != c) {
pairs.push((at, m));
}
if res < top.res {
top = m;
top_at = Some(at);
}
}
((best.0 - best_penalty).max(0.0) as f32, best.1.into_iter().map(|(c, w)| (c, w as f32)).collect())
let tied = pairs.iter().filter(|&&(at, _)| Some(at) != top_at).map(|(_, m)| m.weights()).collect();
Mixes { residual: (top.res - top.penalty).max(0.0) as f32, best: top.weights(), tied }
}
#[cfg(test)]
@@ -199,7 +571,7 @@ mod tests {
#[test]
fn pure_pixels_are_exact() {
let pix = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0]];
let (pl, _) = unmix(&pix, 2, 1, &classes());
let (pl, _, _) = unmix(&pix, 2, 1, &classes());
assert_eq!((pl[0][0], pl[2][1]), (1.0, 1.0));
}
@@ -207,7 +579,7 @@ mod tests {
fn a_blend_of_two_colours_recovers_its_proportions() {
// 30% red over 70% blue.
let pix = vec![[0.3, 0.0, 0.7, 1.0]];
let (pl, _) = unmix(&pix, 1, 1, &classes());
let (pl, _, _) = unmix(&pix, 1, 1, &classes());
assert!((pl[0][0] - 0.3).abs() < 0.02, "red {}", pl[0][0]);
assert!((pl[1][0] - 0.7).abs() < 0.02, "blue {}", pl[1][0]);
}
@@ -216,7 +588,7 @@ mod tests {
fn a_half_transparent_edge_is_half_covered() {
// Red at 40% coverage over nothing: premultiplied (0.4, 0, 0, 0.4).
let pix = vec![[0.4, 0.0, 0.0, 0.4]];
let (pl, _) = unmix(&pix, 1, 1, &classes());
let (pl, _, _) = unmix(&pix, 1, 1, &classes());
assert!((pl[0][0] - 0.4).abs() < 0.02 && (pl[2][0] - 0.6).abs() < 0.02, "{:?}", (pl[0][0], pl[2][0]));
}
@@ -224,7 +596,7 @@ mod tests {
fn a_colour_no_class_can_explain_leaves_a_large_residual() {
// Green is none of red, blue or transparent, nor any blend of them.
let pix = vec![[0.0, 0.8, 0.0, 1.0], [0.3, 0.0, 0.7, 1.0]];
let (_, res) = unmix(&pix, 2, 1, &classes());
let (_, res, _) = unmix(&pix, 2, 1, &classes());
assert!(res[0] > 0.1, "unexplained pixel residual {}", res[0]);
assert!(res[1] < 1e-3, "an explained blend residual {}", res[1]);
}
@@ -235,8 +607,12 @@ mod tests {
let off = [0.97, 0.02, 0.02, 1.0];
let mut pix = vec![off; 1000];
pix.extend([[0.3, 0.0, 0.7, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0]]);
assert_eq!(count_ambiguous(&pix, &classes()), 2);
let (pl, res) = unmix(&pix, pix.len(), 1, &classes());
// (The pure red and transparent pixels lie outside a flat area, so they count too: `unmix`
// checks whether another mix reproduces them.)
assert_eq!(count_ambiguous(&pix, pix.len(), 1, &classes()), 4);
// Pure pixels in a flat area do not count.
assert_eq!(count_ambiguous(&[[1.0, 0.0, 0.0, 1.0]; 9], 3, 3, &classes()), 0);
let (pl, res, _) = unmix(&pix, pix.len(), 1, &classes());
assert!(pl[0][0] > 0.95, "red {}", pl[0][0]);
for i in 1..1000 {
assert_eq!((pl[0][i], pl[1][i], pl[2][i], res[i]), (pl[0][0], pl[1][0], pl[2][0], res[0]), "pixel {i}");
@@ -244,10 +620,89 @@ mod tests {
assert!((pl[0][1000] - 0.3).abs() < 0.02 && (pl[1][1000] - 0.7).abs() < 0.02);
}
#[test]
fn an_edge_pixel_two_mixes_reproduce_takes_the_classes_beside_it() {
// A light gray lies on the line from black to white. A row of flat black, one edge pixel,
// flat white: the edge pixel is a blend of black and white, with no gray in it, also when
// it comes within `PURE` of the gray (0.905), and when the gray alone, which carries no
// penalty, comes nearer it than any mix of two does without reproducing it (0.91).
let classes = vec![[0.9, 0.9, 0.9, 1.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]];
let row = |left: [f32; 4], v: f32| {
let mut pix = vec![left; 5];
pix.push([v, v, v, 1.0]);
pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 5]);
pix
};
for v in [0.1f32, 0.3, 0.7, 0.85, 0.905, 0.91, 0.95, 0.98] {
let (pl, _, _) = unmix(&row([0.0, 0.0, 0.0, 1.0], v), 11, 1, &classes);
assert_eq!(pl[0][5], 0.0, "gray in a {v} edge pixel");
assert!((pl[2][5] - v).abs() < 0.02 && (pl[1][5] - (1.0 - v)).abs() < 0.02, "{v}: black {} white {}", pl[1][5], pl[2][5]);
}
// Beside flat gray, a gray-to-white edge pixel keeps its gray.
let (pl, _, _) = unmix(&row([0.9, 0.9, 0.9, 1.0], 0.95), 11, 1, &classes);
assert!((pl[0][5] - 0.5).abs() < 0.02 && pl[1][5] == 0.0, "gray {} black {}", pl[0][5], pl[1][5]);
// A gray pixel between white ones, flat black four pixels off: a gray stroke, kept.
let mut pix = vec![[0.0f32, 0.0, 0.0, 1.0]; 4];
pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 2]);
pix.push([0.9, 0.9, 0.9, 1.0]);
pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 5]);
let (pl, _, _) = unmix(&pix, 12, 1, &classes);
assert_eq!((pl[0][6], pl[1][6]), (1.0, 0.0), "the stroke");
}
#[test]
fn a_column_of_pixels_as_light_as_a_class_keeps_it() {
// Flat black, one column at the gray's value, flat white. In `LINE` rows or more the column
// is gray art (a one-pixel line, or a straight edge giving the same pixels); in fewer rows it
// is an edge between black and white.
let classes = vec![[0.9, 0.9, 0.9, 1.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]];
for (rows, gray) in [(LINE, 1.0), (LINE + 10, 1.0), (LINE - 1, 0.0), (3, 0.0)] {
let mut pix = Vec::new();
for _ in 0..rows {
pix.extend([[0.0f32, 0.0, 0.0, 1.0]; 5]);
pix.push([0.9, 0.9, 0.9, 1.0]);
pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 5]);
}
let (pl, _, _) = unmix(&pix, 11, rows, &classes);
for y in 0..rows {
assert_eq!(pl[0][y * 11 + 5], gray, "{rows} rows: gray in row {y}");
}
}
}
#[test]
fn row_distances_match_a_scan_of_the_window_around_each_pixel() {
// Three classes scattered over 23 x 17 pixels, some pixels holding two, most none.
let (w, h, k) = (23usize, 17usize, 3usize);
let at = |j: usize| -> [Option<usize>; 2] {
match (j * 7919) % 97 {
0..=2 => [Some(0), None],
3 => [Some(1), Some(2)],
4 | 5 => [None, Some(2)],
_ => [None; 2],
}
};
let mut rows = RowDistances::new(at, w, h, k);
let mut dist = vec![0; k];
for i in 0..w * h {
let (x, y) = (i % w, i / w);
let mut want = vec![NEARBY + 1; k];
for yy in y.saturating_sub(NEARBY)..(y + NEARBY + 1).min(h) {
for xx in x.saturating_sub(NEARBY)..(x + NEARBY + 1).min(w) {
for c in at(yy * w + xx).into_iter().flatten() {
want[c] = want[c].min(xx.abs_diff(x).max(yy.abs_diff(y)));
}
}
}
rows.around(i, &mut dist);
assert_eq!(dist, want, "pixel ({x}, {y})");
}
}
#[test]
fn weights_sum_to_one() {
let pix = vec![[0.2, 0.1, 0.5, 0.8], [0.9, 0.0, 0.05, 0.95]];
let (pl, _) = unmix(&pix, 2, 1, &classes());
let (pl, _, _) = unmix(&pix, 2, 1, &classes());
for i in 0..2 {
let s: f32 = pl.iter().map(|p| p[i]).sum();
assert!((s - 1.0).abs() < 1e-4, "pixel {i} sums to {s}");
+21 -2
View File
@@ -97,6 +97,24 @@ The trace then uses only the named colors, and white while white is a color of i
color that was not named is traced too: each of its pixels goes to the named color, white or
transparent that has the largest share in the blend of them that comes closest to the pixel.
**Flat Logo with a color between two others.** When a traced color lies between two others, as a gray
lies between black and white, an anti-aliased edge pixel between the outer two is also a blend of the
middle color and one of them. Flat Logo then reads the pixel as the blend of the colors that lie nearest
it in the flat areas within 6 pixels, when the pixel has a neighbor darker and a neighbor lighter than the
middle color, along the line between the other two. A black disc beside a gray square on white traces
with one gray shape, the square, and no gray slivers around the disc, when its edges are sharp or blurred
by up to about half a pixel. An edge blurred by a pixel or more can still leave gray slivers along the
disc, and so can an image with noise of a level or more in its pixels. A gray stroke two or three pixels
wide beside black has no darker pixel next to it and stays gray. A soft-edged gray stroke about two
pixels wide that touches black traces as black and white, and one about three pixels wide that touches
black can lose most or all of its gray when it is short (12 pixels long or less). A line of gray one
pixel wide between black and white stays gray when at least 13 of its pixels touch: such a line and a
straight black-to-white edge whose pixels are as light as the gray give the same pixels, and both
trace as gray. A one-pixel gray line between black and white that is shorter than 13 pixels, falls
between pixels or is blurred traces as black and white. A flat area whose color lies between two named colors, as when the colors are named
some levels off, counts as holding the named colors it is a blend of, and so do the pixels of exactly
its color that touch it.
**Large images.** An image of more than 1500 pixels on a side is traced as any other, only more
slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar
shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html)
@@ -106,8 +124,9 @@ most 1024 pixels on a side: a larger decoded raster is averaged down in whole-nu
image is traced at 513, and a 16,000 × 12,000 image, decoded at 9,459 × 7,094, is traced at 946 × 710), and the trace names both sizes in `warnings`. Flat Logo gives that note in place
of the slower-trace note for an image over 1500 pixels and in place of the 64-megapixel note. An image at most
1024 pixels on a side is traced at its own size, also when the named `logoColors` differ slightly from the
image's colors. Only an image with more than 150,000 different pixel values that are not exactly one traced
color is averaged down further: the factor doubles until at most 150,000 remain or the working copy has at
image's colors. Only an image with more than 150,000 different pixel values to unmix (values that are
not exactly one traced color, and values that are, at pixels outside a flat area) is averaged down
further: the factor doubles until at most 150,000 remain or the working copy has at
most 16,384 pixels (at most six times). A trace that would
make more than 8,000,000 anchor points (2,000,000 on the web) is refused to protect memory, with advice:
raise Noise, lower Paths or Colors, or pick a lower-fidelity preset. A trace keeps
+1
View File
@@ -1320,6 +1320,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`.
- **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`.
- **Image Trace, Flat Logo with a color between two others:** an anti-aliased edge pixel between black and white is read as a blend of black and white, not of black and a gray that lies between them, when the flat areas around it and the pixels beside it show that edge, so a black disc beside a gray square on white, with sharp edges or edges blurred by up to about half a pixel, traces with one gray path, the square, instead of the square plus 8 to 24 gray slivers around the disc with the colors found or named exactly (an edge blurred by a pixel or more, or pixel noise of a level or more, can still leave gray slivers); gray strokes two and three pixels wide beside black stay gray (a soft-edged gray stroke about two pixels wide that touches black traces as black and white, and a short one about three pixels wide can lose most or all of its gray), and a one-pixel gray line of at least 13 touching pixels between black and white stays gray (a shorter one, or one that falls between pixels or is blurred, traces as black and white); code-drawn tests in `crates/trace`.
- **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed, isn't CMYK and can be read at its recorded size is cut from its own pixels when the crop edges fall on its pixel grid, copied as they are without resampling; any other image is drawn, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756).
Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)).