Image Trace: Flat Logo traces named colors a few levels off at the image's own size

Squashed from #3999 onto main.
This commit is contained in:
dotCooCoo
2026-10-24 19:02:09 +07:00
committed by bflatastic
parent d0644e5eb9
commit 76c014b732
4 changed files with 117 additions and 48 deletions
+1 -1
View File
@@ -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 pixels `unmix` may have to work on (those that are not exactly one colour). A busier image is
/// Most distinct pixel values `unmix` may have to work on (those that are not exactly one color). 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.
+32
View File
@@ -244,6 +244,38 @@ fn a_trace_averaged_down_says_the_size_it_worked_at() {
assert_eq!(res.worked_at, None);
}
#[test]
fn named_colors_a_few_levels_off_trace_an_image_up_to_1024_px_at_full_size() {
// A light disc in a dark ring with a blue square on it, hard edges, 800 px square, and a dark
// 2 px dot on the disc at odd pixels: averaged down by 2, the dot would be a quarter of each of
// four pixels and leave no shape.
let img = Raster::from_fn(800, 800, |x, y| {
let (fx, fy) = (f64::from(x) / 5.0, f64::from(y) / 5.0);
let d = (fx - 80.0).hypot(fy - 80.0);
if (401..=402).contains(&x) && (201..=202).contains(&y) {
[4, 4, 3, 255]
} else if (70.0..=110.0).contains(&fx) && (70.0..=110.0).contains(&fy) {
[34, 124, 250, 255]
} else if d <= 60.0 {
[252, 248, 227, 255]
} else if d <= 68.0 {
[4, 4, 3, 255]
} else {
[0, 0, 0, 0]
}
});
for (off, names) in [(8, ["#0C0C0B", "#F4F0DB", "#1A74F2"]), (12, ["#10100F", "#F0ECD7", "#1670EE"])] {
let p = TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() };
let res = trace(&img, &p);
let color_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color);
assert_eq!(res.paths.len(), 4, "names {off} levels off");
let (dark, cream, blue) = ([4 + off, 4 + off, 3 + off], [252 - off, 248 - off, 227 - off], [34 - off, 124 - off, 250 - off]);
assert_eq!(color_at(Point::new(402.0, 202.0)), Some(dark), "names {off} levels off");
assert_eq!(color_at(Point::new(450.0, 450.0)), Some(blue), "names {off} levels off");
assert_eq!(color_at(Point::new(200.0, 400.0)), Some(cream), "names {off} levels off");
}
}
/// Every anchor of every path is finite and inside (a small margin around) the image.
fn assert_sane(res: &TraceResult, w: u32, h: u32) {
for t in &res.paths {
+79 -46
View File
@@ -5,6 +5,8 @@
//! runs, so each pixel is instead explained as a mix of at most three classes, whichever mix
//! reproduces the pixel best.
use std::collections::{HashMap, HashSet};
/// Premultiplied RGBA in `0..=1`.
pub(crate) type Px = [f32; 4];
@@ -88,14 +90,17 @@ fn subsets(classes: &[[f64; 4]]) -> Vec<Subset> {
out
}
/// How many pixels are not (nearly) exactly one class: the ones `unmix` has to work on.
/// 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 {
pix.iter()
.filter(|px| {
let p = [f64::from(px[0]), f64::from(px[1]), f64::from(px[2]), f64::from(px[3])];
!classes.iter().any(|cv| (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum::<f64>().sqrt() < PURE)
})
.count()
let mut seen = HashSet::new();
for px in pix {
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));
}
}
seen.len()
}
/// Coverage planes, one `width × height` plane per class (each pixel's weights sum to 1).
@@ -106,6 +111,9 @@ pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]
// Squared error left over by the best mix: large where no mix of the classes explains the pixel.
let mut residual = vec![0.0f32; n];
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();
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.
@@ -122,54 +130,64 @@ pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]
}
continue;
}
// 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 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;
}
let mut w = [0.0f64; 3];
for (a, wa) in w.iter_mut().enumerate().take(s.n) {
*wa = (0..s.n).map(|b| s.ninv[a * s.n + b] * rhs[b]).sum();
}
if w.iter().take(s.n).any(|&x| x < -0.02) {
continue;
}
let mut sum = 0.0;
for wa in w.iter_mut().take(s.n) {
*wa = wa.clamp(0.0, 1.0);
sum += *wa;
}
if sum < 1e-9 {
continue;
}
for wa in w.iter_mut().take(s.n) {
*wa /= sum;
}
let mut res = s.penalty;
for (c, &pc) in p.iter().enumerate() {
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 (res, weights) = mixed.entry(px.map(f32::to_bits)).or_insert_with(|| best_mix(p, near, &subs));
if let Some(r) = residual.get_mut(i) {
*r = (best.0 - best_penalty).max(0.0) as f32;
*r = *res;
}
for (c, wv) in best.1 {
for &(c, wv) in weights.iter() {
if let Some(v) = planes.get_mut(c).and_then(|pl| pl.get_mut(i)) {
*v = wv as f32;
*v = wv;
}
}
}
(planes, residual)
}
/// A pixel's mix: the squared error it leaves and each class's weight.
type Mix = (f32, Vec<(usize, f32)>);
/// 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 {
// 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 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;
}
let mut w = [0.0f64; 3];
for (a, wa) in w.iter_mut().enumerate().take(s.n) {
*wa = (0..s.n).map(|b| s.ninv[a * s.n + b] * rhs[b]).sum();
}
if w.iter().take(s.n).any(|&x| x < -0.02) {
continue;
}
let mut sum = 0.0;
for wa in w.iter_mut().take(s.n) {
*wa = wa.clamp(0.0, 1.0);
sum += *wa;
}
if sum < 1e-9 {
continue;
}
for wa in w.iter_mut().take(s.n) {
*wa /= sum;
}
let mut res = s.penalty;
for (c, &pc) in p.iter().enumerate() {
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;
}
}
((best.0 - best_penalty).max(0.0) as f32, best.1.into_iter().map(|(c, w)| (c, w as f32)).collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,6 +229,21 @@ mod tests {
assert!(res[1] < 1e-3, "an explained blend residual {}", res[1]);
}
#[test]
fn a_repeated_color_near_a_class_is_counted_once_and_mixed_alike() {
// A flat area a few levels off red, a blend, and pure red and transparent pixels.
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());
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}");
}
assert!((pl[0][1000] - 0.3).abs() < 0.02 && (pl[1][1000] - 0.7).abs() < 0.02);
}
#[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]];
+5 -1
View File
@@ -39,7 +39,11 @@ page describes the same warning for images over 1500 px). An image of more than
192) is traced from a copy scaled down to 64 megapixels, and the trace says that too. Flat Logo traces at
most 1024 pixels on a side: a larger decoded raster is averaged down in whole-number blocks first (a 1025-pixel
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. A trace that would
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
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
about 90 bytes an anchor; a 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million.