mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user