mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Tracing a 2048 x 2048 photo with High Fidelity Photo made 733,000 paths and 7.8 million anchors. The selected result's outlines and anchor squares then made an egui mesh of 1.8 GB, over the GPU's largest buffer: wgpu panicked while painting and the app quit (or ran out of memory first, at 6.4 GB). - Canvas: a selection with more than 100,000 anchors shows its bounds instead of every outline and anchor, and the hover highlight skips such an object. The Contextual Task Bar and that outline read the selection's bounds from a per-revision cache (VectorcraftApp:: selection_bounds): a selected trace drew a frame in 20 ms, not 170. - Image Trace: the colour layers are traced in parallel (26 s to 1.9 s for that photo; the web traces them in turn). trace_within stops once the paths pass an anchor budget; imageTrace.make allows two million and otherwise reports the trace as too complex, with what to change. Images over 64 megapixels are refused from their header, before they are decoded. - The trace crate's messages are translated (es, it) and scanned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
16 KiB
Rust
426 lines
16 KiB
Rust
//! VectorCraft Image Trace: raster → vector.
|
||
//!
|
||
//! A clean-room tracer built from the published ideas behind bitmap tracers (the potrace paper
|
||
//! and classic colour quantisation), not from any existing implementation:
|
||
//!
|
||
//! 1. **Quantise** the image into a small palette: a luminance threshold (Black and White), 1-D
|
||
//! k-means over the luminance histogram (Grayscale), or median cut followed by weighted
|
||
//! k-means over a 15-bit colour histogram (Color). Transparent pixels are left out.
|
||
//! 2. **Remove noise**: 4-connected same-colour components smaller than `noise` pixels are merged
|
||
//! into their most common neighbouring colour.
|
||
//! 3. **Follow boundaries**: for each colour layer, the pixel-crack edges between inside and
|
||
//! outside are linked into closed loops (inside on the right, right turns at saddles, so
|
||
//! diagonal pixels stay separate, matching 4-connectivity). Outer loops run clockwise and
|
||
//! holes counter-clockwise (y down), so the result fills correctly with either fill rule.
|
||
//! 4. **Polygon**: each loop is reduced to a polygon whose vertices stay within a fidelity
|
||
//! dependent distance of the pixel boundary (Douglas–Peucker on the crack vertices, which
|
||
//! removes the staircase).
|
||
//! 5. **Curves**: the polygon is fitted with cubic Béziers (`vectorcraft_pathops::simplify_with`,
|
||
//! least squares with corner detection); optionally nearly-straight curves snap to lines.
|
||
//!
|
||
//! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or
|
||
//! *overlapping* (stacked: each layer also covers every layer above it, so no hairline gaps).
|
||
#![forbid(unsafe_code)]
|
||
|
||
mod contour;
|
||
mod fit;
|
||
mod mosaic;
|
||
mod quantize;
|
||
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use vectorcraft_geom::PathData;
|
||
|
||
pub use contour::{Component, Loop, trace_mask};
|
||
pub use mosaic::mosaic;
|
||
pub use quantize::{Quantized, TRANSPARENT, denoise, quantize};
|
||
|
||
/// Errors decoding a raster.
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum TraceError {
|
||
#[error("could not decode image: {0}")]
|
||
Decode(String),
|
||
#[error("image is empty")]
|
||
Empty,
|
||
#[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")]
|
||
TooLarge { width: u32, height: u32, max: u64 },
|
||
#[error(
|
||
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
|
||
)]
|
||
TooComplex { max: usize },
|
||
}
|
||
|
||
/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are
|
||
/// refused instead of running out of memory.
|
||
pub const MAX_PIXELS: u64 = 64 << 20;
|
||
|
||
/// An RGBA8 raster (row-major, top row first).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Raster {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
pub rgba: Vec<u8>,
|
||
}
|
||
|
||
impl Raster {
|
||
/// A raster from RGBA bytes (`width * height * 4` of them).
|
||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Self {
|
||
assert_eq!(rgba.len(), width as usize * height as usize * 4, "rgba length must be width*height*4");
|
||
Self { width, height, rgba }
|
||
}
|
||
/// A raster whose pixel `(x, y)` is `f(x, y)`.
|
||
pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self {
|
||
let mut rgba = Vec::with_capacity(width as usize * height as usize * 4);
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
rgba.extend_from_slice(&f(x, y));
|
||
}
|
||
}
|
||
Self { width, height, rgba }
|
||
}
|
||
/// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding).
|
||
pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> {
|
||
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
|
||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
|
||
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
|
||
if w == 0 || h == 0 {
|
||
return Err(TraceError::Empty);
|
||
}
|
||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 });
|
||
}
|
||
let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8();
|
||
let (w, h) = img.dimensions();
|
||
Ok(Self { width: w, height: h, rgba: img.into_raw() })
|
||
}
|
||
/// Encode as PNG.
|
||
pub fn encode_png(&self) -> Vec<u8> {
|
||
let mut out = Vec::new();
|
||
if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) {
|
||
let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png);
|
||
}
|
||
out
|
||
}
|
||
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||
let i = (y as usize * self.width as usize + x as usize) * 4;
|
||
[self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
|
||
}
|
||
}
|
||
|
||
/// Tracing mode (Illustrator's Mode popup).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Mode {
|
||
#[default]
|
||
#[serde(alias = "bw", alias = "blackWhite", alias = "Black and White")]
|
||
BlackAndWhite,
|
||
#[serde(alias = "gray", alias = "Grayscale")]
|
||
Grayscale,
|
||
#[serde(alias = "Color")]
|
||
Color,
|
||
}
|
||
|
||
/// How colour layers relate (Advanced → Method).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Method {
|
||
/// Each colour traced on its own; neighbouring shapes share their edge.
|
||
#[default]
|
||
Abutting,
|
||
/// Stacked: every layer extends under the layers above it.
|
||
Overlapping,
|
||
}
|
||
|
||
/// Image Trace parameters (the Image Trace panel).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct TraceParams {
|
||
pub mode: Mode,
|
||
/// Black and White: pixels darker than this (luminance 0–255) are black.
|
||
pub threshold: u8,
|
||
/// Palette size for Color, number of grays for Grayscale (2–256).
|
||
pub colors: u32,
|
||
/// Paths (fidelity) 0–100: higher follows the pixels more tightly.
|
||
pub paths: f64,
|
||
/// Corners 0–100: higher keeps more corners.
|
||
pub corners: f64,
|
||
/// Noise: areas smaller than this many pixels are ignored.
|
||
pub noise: u32,
|
||
pub method: Method,
|
||
/// Drop white areas (no white background shape).
|
||
pub ignore_white: bool,
|
||
/// Replace nearly-straight curves with straight lines.
|
||
pub snap_curves_to_lines: bool,
|
||
}
|
||
|
||
impl Default for TraceParams {
|
||
/// Illustrator's [Default]: black and white, threshold 128.
|
||
fn default() -> Self {
|
||
Self {
|
||
mode: Mode::BlackAndWhite,
|
||
threshold: 128,
|
||
colors: 6,
|
||
paths: 50.0,
|
||
corners: 75.0,
|
||
noise: 25,
|
||
method: Method::Abutting,
|
||
ignore_white: false,
|
||
snap_curves_to_lines: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl TraceParams {
|
||
/// Maximum distance (px) of polygon vertices from the pixel boundary.
|
||
fn polygon_tolerance(&self) -> f64 {
|
||
let f = (self.paths / 100.0).clamp(0.0, 1.0);
|
||
0.55 + 0.75 * (1.0 - f)
|
||
}
|
||
/// Curve fitting tolerance (px).
|
||
fn fit_tolerance(&self) -> f64 {
|
||
let f = (self.paths / 100.0).clamp(0.0, 1.0);
|
||
0.2 + 1.6 * (1.0 - f)
|
||
}
|
||
/// Turn angle (degrees) above which a polygon vertex stays a corner.
|
||
fn corner_angle(&self) -> f64 {
|
||
let c = (self.corners / 100.0).clamp(0.0, 1.0);
|
||
150.0 - 115.0 * c
|
||
}
|
||
}
|
||
|
||
/// Built-in preset names in panel order (our own parameter sets).
|
||
pub const PRESET_NAMES: &[&str] = &[
|
||
"Default",
|
||
"High Fidelity Photo",
|
||
"Low Fidelity Photo",
|
||
"3 Colors",
|
||
"6 Colors",
|
||
"16 Colors",
|
||
"Shades of Gray",
|
||
"Black and White Logo",
|
||
"Sketched Art",
|
||
"Silhouettes",
|
||
"Line Art",
|
||
"Technical Drawing",
|
||
];
|
||
|
||
/// A built-in preset by name (case-insensitive; `[Default]` is accepted).
|
||
pub fn preset(name: &str) -> Option<TraceParams> {
|
||
let key = name.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase();
|
||
let d = TraceParams::default();
|
||
let color = |colors: u32, paths: f64, corners: f64, noise: u32, method: Method| TraceParams {
|
||
mode: Mode::Color,
|
||
colors,
|
||
paths,
|
||
corners,
|
||
noise,
|
||
method,
|
||
..TraceParams::default()
|
||
};
|
||
let bw = |threshold: u8, paths: f64, corners: f64, noise: u32, snap: bool| TraceParams {
|
||
mode: Mode::BlackAndWhite,
|
||
threshold,
|
||
paths,
|
||
corners,
|
||
noise,
|
||
ignore_white: true,
|
||
snap_curves_to_lines: snap,
|
||
..TraceParams::default()
|
||
};
|
||
Some(match key.as_str() {
|
||
"default" => d,
|
||
"high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping),
|
||
"low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping),
|
||
"3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting),
|
||
"6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting),
|
||
"16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting),
|
||
"shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d },
|
||
"black and white logo" => bw(128, 95.0, 80.0, 8, true),
|
||
"sketched art" => bw(150, 50.0, 50.0, 100, false),
|
||
"silhouettes" => bw(200, 40.0, 60.0, 30, false),
|
||
"line art" => bw(128, 80.0, 70.0, 10, false),
|
||
"technical drawing" => bw(128, 95.0, 90.0, 4, true),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// All built-in presets in panel order.
|
||
pub fn presets() -> Vec<(&'static str, TraceParams)> {
|
||
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
|
||
}
|
||
|
||
/// One traced shape: an outer contour plus its holes, filled with `color`.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct TracedPath {
|
||
/// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`).
|
||
pub path: PathData,
|
||
pub color: [u8; 3],
|
||
/// Filled pixel count of the component the path came from.
|
||
pub pixels: usize,
|
||
}
|
||
|
||
/// The traced result, bottom-most path first.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct TraceResult {
|
||
pub paths: Vec<TracedPath>,
|
||
/// Colours actually used.
|
||
pub palette: Vec<[u8; 3]>,
|
||
}
|
||
|
||
impl TraceResult {
|
||
pub fn anchor_count(&self) -> usize {
|
||
self.paths.iter().map(|p| p.path.anchor_count()).sum()
|
||
}
|
||
}
|
||
|
||
fn is_white(c: [u8; 3]) -> bool {
|
||
c.iter().all(|&v| v >= 245)
|
||
}
|
||
|
||
/// Trace `img` with `params`.
|
||
pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
|
||
// No trace has more than `usize::MAX` anchors.
|
||
trace_within(img, params, usize::MAX).unwrap_or_default()
|
||
}
|
||
|
||
/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more
|
||
/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The
|
||
/// colour layers are traced in parallel where threads are available.
|
||
pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
|
||
let (w, h) = (img.width as usize, img.height as usize);
|
||
if w == 0 || h == 0 || img.rgba.len() != w * h * 4 {
|
||
return Ok(TraceResult::default());
|
||
}
|
||
let mut q = quantize(img, params);
|
||
denoise(&mut q.labels, w, h, params.noise as usize);
|
||
// Pixel count per palette entry.
|
||
let mut counts = vec![0usize; q.palette.len()];
|
||
for &l in &q.labels {
|
||
if let Some(c) = counts.get_mut(l as usize) {
|
||
*c += 1;
|
||
}
|
||
}
|
||
// Layers bottom → top: largest area first.
|
||
let mut order: Vec<usize> = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect();
|
||
order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b)));
|
||
let mut rank = vec![usize::MAX; q.palette.len()];
|
||
for (r, &i) in order.iter().enumerate() {
|
||
rank[i] = r;
|
||
}
|
||
let layers: Vec<(usize, usize)> =
|
||
order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_white(q.palette[ci]))).collect();
|
||
let job = Layers {
|
||
q: &q,
|
||
rank: &rank,
|
||
params,
|
||
opts: fit::FitOptions {
|
||
polygon_tol: params.polygon_tolerance(),
|
||
fit_tol: params.fit_tolerance(),
|
||
corner_angle: params.corner_angle(),
|
||
snap_lines: params.snap_curves_to_lines,
|
||
},
|
||
w,
|
||
h,
|
||
anchors: AtomicUsize::new(0),
|
||
max_anchors,
|
||
};
|
||
let mut out = TraceResult::default();
|
||
for (paths, &(_, ci)) in parallel_map(&layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
|
||
out.paths.extend(paths.ok_or(TraceError::TooComplex { max: max_anchors })?);
|
||
let color = q.palette[ci];
|
||
if !out.palette.contains(&color) {
|
||
out.palette.push(color);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// What tracing one colour layer needs.
|
||
struct Layers<'a> {
|
||
q: &'a Quantized,
|
||
/// Each palette entry's layer, bottom (0) up.
|
||
rank: &'a [usize],
|
||
params: &'a TraceParams,
|
||
opts: fit::FitOptions,
|
||
w: usize,
|
||
h: usize,
|
||
/// Anchor points made so far, by every layer.
|
||
anchors: AtomicUsize,
|
||
max_anchors: usize,
|
||
}
|
||
|
||
impl Layers<'_> {
|
||
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
|
||
/// maximum.
|
||
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
|
||
let (params, opts) = (self.params, &self.opts);
|
||
let color = *self.q.palette.get(ci)?;
|
||
let overlapping = params.method == Method::Overlapping;
|
||
let mask: Vec<bool> = self
|
||
.q
|
||
.labels
|
||
.iter()
|
||
.map(|&l| l != TRANSPARENT && if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci })
|
||
.collect();
|
||
let min_hole = params.noise.max(1) as i64;
|
||
let mut paths = vec![];
|
||
for comp in trace_mask(&mask, self.w, self.h) {
|
||
let Some(outer) = fit::fit_loop(&comp.outer, opts) else { continue };
|
||
let mut subs = vec![outer];
|
||
for hole in &comp.holes {
|
||
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
|
||
continue;
|
||
}
|
||
subs.extend(fit::fit_loop(hole, opts));
|
||
}
|
||
let path = PathData::new(subs);
|
||
let n = path.anchor_count();
|
||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||
return None;
|
||
}
|
||
paths.push(TracedPath { path, color, pixels: comp.pixels });
|
||
}
|
||
Some(paths)
|
||
}
|
||
}
|
||
|
||
/// `f` of each of `items`, in order, spread over the available cores.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len());
|
||
if threads <= 1 {
|
||
return items.iter().map(f).collect();
|
||
}
|
||
let next = AtomicUsize::new(0);
|
||
let mut done: Vec<(usize, R)> = std::thread::scope(|s| {
|
||
let workers: Vec<_> = (0..threads)
|
||
.map(|_| {
|
||
s.spawn(|| {
|
||
let mut mine = vec![];
|
||
loop {
|
||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||
let Some(item) = items.get(i) else { break };
|
||
mine.push((i, f(item)));
|
||
}
|
||
mine
|
||
})
|
||
})
|
||
.collect();
|
||
// A worker's panic goes on in this thread, as if it had run here (the engine's guard
|
||
// reports it).
|
||
workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect()
|
||
});
|
||
done.sort_by_key(|(i, _)| *i);
|
||
done.into_iter().map(|(_, r)| r).collect()
|
||
}
|
||
|
||
/// `f` of each of `items`, in order (no threads on the web).
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||
items.iter().map(f).collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|