From 76c014b732a94aa9eece3b99fe9317e9d461c293 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:27:16 +0700 Subject: [PATCH] Image Trace: Flat Logo traces named colors a few levels off at the image's own size Squashed from #3999 onto main. --- crates/trace/src/logo/pipeline.rs | 2 +- crates/trace/src/logo/tests.rs | 32 ++++++++ crates/trace/src/logo/unmix.rs | 125 +++++++++++++++++++----------- docs/image-trace-adjustments.md | 6 +- 4 files changed, 117 insertions(+), 48 deletions(-) diff --git a/crates/trace/src/logo/pipeline.rs b/crates/trace/src/logo/pipeline.rs index 97c8e303f..777f1e47c 100644 --- a/crates/trace/src/logo/pipeline.rs +++ b/crates/trace/src/logo/pipeline.rs @@ -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. diff --git a/crates/trace/src/logo/tests.rs b/crates/trace/src/logo/tests.rs index 2c712828a..f5e52bdf7 100644 --- a/crates/trace/src/logo/tests.rs +++ b/crates/trace/src/logo/tests.rs @@ -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 { diff --git a/crates/trace/src/logo/unmix.rs b/crates/trace/src/logo/unmix.rs index 16aff8c5b..cdfdfe52f 100644 --- a/crates/trace/src/logo/unmix.rs +++ b/crates/trace/src/logo/unmix.rs @@ -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 { 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::().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::().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::() + 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::() + 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]]; diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 60c8b1092..17dbaef28 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -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.