From e46c9718453cb3aa59a5d118f34874820fb520a9 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 23 Jul 2026 22:24:22 +0100 Subject: [PATCH] =?UTF-8?q?Rewrite=20into=20a=20vectorization=20framework?= =?UTF-8?q?=20(pillars=201=E2=80=934)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 0.6.x single-pipeline crate with a stage-based framework, per docs/design/. Implements Motivation pillars 1–4 (frontend, curve fitting, color fitting, optimizer); mosaic (5) and bindings are deferred. Workspace: - crates/vtracer — the framework library (wasm-safe, no I/O) - crates/vtracer-cli — thin CLI wrapper (clap 4 + image I/O) - cmdapp/ and webapp/ excluded from the workspace (git-preserved) Stages behind object-safe traits, composed by a Pipeline driver: - Frontend: ColorClusterFrontend (+ transparency keying), BinaryFrontend - CurveFitter: Pixel / Polygon / Spline (region tracing via visioncortex) - ColorFitter: Identity, FixedPalette (OKLab-nearest), AutoQuantize (area-weighted median cut), MergeAdjacent - OptimizerPass: QuantizePass, SimplifyPass - SvgWriter: relative/absolute shortest encoding, H/V/S shorthands, compact number formatting, grouping visioncortex is a path dependency on the local 0.9.0 checkout. Verified: 14 unit/integration tests pass; framework builds for wasm32-unknown-unknown; CLI output renders faithfully via rsvg. --- .gitignore | 3 +- Cargo.toml | 23 +- crates/vtracer-cli/Cargo.toml | 21 + crates/vtracer-cli/src/main.rs | 209 +++++++++ crates/vtracer/Cargo.toml | 18 + crates/vtracer/src/colorfit/merge.rs | 28 ++ crates/vtracer/src/colorfit/mod.rs | 75 ++++ crates/vtracer/src/colorfit/oklab.rs | 53 +++ crates/vtracer/src/colorfit/palette.rs | 47 ++ crates/vtracer/src/colorfit/quantize.rs | 148 +++++++ crates/vtracer/src/compose/mod.rs | 31 ++ crates/vtracer/src/config.rs | 264 ++++++++++++ crates/vtracer/src/error.rs | 41 ++ crates/vtracer/src/fitter/mod.rs | 173 ++++++++ crates/vtracer/src/frontend/binary.rs | 63 +++ crates/vtracer/src/frontend/color_cluster.rs | 92 ++++ crates/vtracer/src/frontend/keying.rs | 105 +++++ crates/vtracer/src/frontend/mod.rs | 26 ++ crates/vtracer/src/ir/mod.rs | 34 ++ crates/vtracer/src/ir/region.rs | 100 +++++ crates/vtracer/src/ir/vector.rs | 90 ++++ crates/vtracer/src/lib.rs | 48 +++ crates/vtracer/src/optimize/mod.rs | 207 +++++++++ crates/vtracer/src/pipeline.rs | 49 +++ crates/vtracer/src/svg/mod.rs | 429 +++++++++++++++++++ crates/vtracer/tests/pipeline.rs | 88 ++++ 26 files changed, 2463 insertions(+), 2 deletions(-) create mode 100644 crates/vtracer-cli/Cargo.toml create mode 100644 crates/vtracer-cli/src/main.rs create mode 100644 crates/vtracer/Cargo.toml create mode 100644 crates/vtracer/src/colorfit/merge.rs create mode 100644 crates/vtracer/src/colorfit/mod.rs create mode 100644 crates/vtracer/src/colorfit/oklab.rs create mode 100644 crates/vtracer/src/colorfit/palette.rs create mode 100644 crates/vtracer/src/colorfit/quantize.rs create mode 100644 crates/vtracer/src/compose/mod.rs create mode 100644 crates/vtracer/src/config.rs create mode 100644 crates/vtracer/src/error.rs create mode 100644 crates/vtracer/src/fitter/mod.rs create mode 100644 crates/vtracer/src/frontend/binary.rs create mode 100644 crates/vtracer/src/frontend/color_cluster.rs create mode 100644 crates/vtracer/src/frontend/keying.rs create mode 100644 crates/vtracer/src/frontend/mod.rs create mode 100644 crates/vtracer/src/ir/mod.rs create mode 100644 crates/vtracer/src/ir/region.rs create mode 100644 crates/vtracer/src/ir/vector.rs create mode 100644 crates/vtracer/src/lib.rs create mode 100644 crates/vtracer/src/optimize/mod.rs create mode 100644 crates/vtracer/src/pipeline.rs create mode 100644 crates/vtracer/src/svg/mod.rs create mode 100644 crates/vtracer/tests/pipeline.rs diff --git a/.gitignore b/.gitignore index 950c227..d7723ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target Cargo.lock *.sublime* -.vscode \ No newline at end of file +.vscode +.DS_Store diff --git a/Cargo.toml b/Cargo.toml index 999f733..1d26b57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,28 @@ [workspace] members = [ + "crates/vtracer", + "crates/vtracer-cli", +] + +# The pre-1.0 crates are kept in the tree for git history but are no longer +# part of the build. They are replaced by the crates/ workspace above. +exclude = [ "cmdapp", "webapp", ] -resolver = "2" \ No newline at end of file + +resolver = "2" + +[workspace.package] +version = "1.0.0-alpha.1" +authors = ["Chris Tsang "] +edition = "2021" +license = "MIT OR Apache-2.0" +homepage = "http://www.visioncortex.org/vtracer" +repository = "https://github.com/visioncortex/vtracer/" + +[workspace.dependencies] +# visioncortex 0.9.0 is currently unreleased; developed against the local +# checkout. Releases will pin a published 0.9.x. +visioncortex = { version = "0.9", path = "../visioncortex" } diff --git a/crates/vtracer-cli/Cargo.toml b/crates/vtracer-cli/Cargo.toml new file mode 100644 index 0000000..c6b42e6 --- /dev/null +++ b/crates/vtracer-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vtracer-cli" +description = "Command-line front-end for the vtracer vectorization framework." +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +categories = ["graphics", "command-line-utilities"] +keywords = ["svg", "vectorization", "computer-graphics"] + +[[bin]] +name = "vtracer" +path = "src/main.rs" + +[dependencies] +vtracer = { version = "1.0.0-alpha.1", path = "../vtracer" } +visioncortex.workspace = true +image = "0.25" +clap = { version = "4", features = ["derive"] } diff --git a/crates/vtracer-cli/src/main.rs b/crates/vtracer-cli/src/main.rs new file mode 100644 index 0000000..980bfb4 --- /dev/null +++ b/crates/vtracer-cli/src/main.rs @@ -0,0 +1,209 @@ +//! Thin command-line front-end over the `vtracer` framework. +//! +//! Handles the two things the framework deliberately leaves out: image file +//! I/O and argument parsing. Everything else is delegated to +//! [`vtracer::Config`] / [`vtracer::Pipeline`]. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; +use visioncortex::{Color, ColorImage}; +use vtracer::{ColorMode, Config, FitMode, Hierarchical, Preset}; + +/// Convert an image into vector graphics. +#[derive(Parser, Debug)] +#[command(name = "vtracer", version, about, rename_all = "kebab-case")] +struct Args { + /// Path to the input raster image. + #[arg(short, long)] + input: PathBuf, + + /// Path to the output SVG. + #[arg(short, long)] + output: PathBuf, + + /// Start from a preset: bw, poster, photo. + #[arg(long)] + preset: Option, + + /// Color image (`color`) or binary image (`bw`). + #[arg(long = "colormode")] + colormode: Option, + + /// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic). + #[arg(long)] + hierarchical: Option, + + /// Curve-fitting mode: pixel, polygon, spline. + #[arg(short, long)] + mode: Option, + + /// Discard patches smaller than X px in size (0..=16). + #[arg(short = 'f', long, value_parser = clap::value_parser!(i64).range(0..=16))] + filter_speckle: Option, + + /// Significant bits per RGB channel (1..=8). + #[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(1..=8))] + color_precision: Option, + + /// Color difference between gradient layers (0..=255). + #[arg(short = 'g', long, value_parser = clap::value_parser!(i64).range(0..=255))] + gradient_step: Option, + + /// Minimum momentary angle (degrees) to be a corner (0..=180). + #[arg(short = 'c', long, value_parser = clap::value_parser!(i64).range(0..=180))] + corner_threshold: Option, + + /// Subdivide until all segments are shorter than this length (3.5..=10). + #[arg(short = 'l', long, value_parser = parse_segment_length)] + segment_length: Option, + + /// Minimum angle displacement (degrees) to splice a spline (0..=180). + #[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))] + splice_threshold: Option, + + /// Decimal places to use in path coordinates. + #[arg(long)] + path_precision: Option, + + /// Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'. + #[arg(long)] + palette: Option, + + /// Fixed palette from a file (one hex color per line or comma-separated). + #[arg(long)] + palette_file: Option, + + /// Auto-quantize to at most N colors. + #[arg(long)] + max_colors: Option, + + /// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. + #[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))] + optimize: Option, +} + +fn parse_segment_length(s: &str) -> Result { + let v: f64 = s + .parse() + .map_err(|_| format!("`{s}` is not a number"))?; + if !(3.5..=10.0).contains(&v) { + return Err(format!("segment length {v} is out of range [3.5, 10]")); + } + Ok(v) +} + +/// Parse a comma/whitespace/newline separated list of `#rrggbb` colors. +fn parse_palette(text: &str) -> Result, String> { + let mut colors = Vec::new(); + for token in text.split(|c: char| c == ',' || c.is_whitespace()) { + let token = token.trim(); + if token.is_empty() { + continue; + } + colors.push(parse_hex_color(token)?); + } + Ok(colors) +} + +fn parse_hex_color(token: &str) -> Result { + let hex = token.strip_prefix('#').unwrap_or(token); + if hex.len() != 6 { + return Err(format!("`{token}` is not a #rrggbb color")); + } + let parse = |range: std::ops::Range| { + u8::from_str_radix(&hex[range], 16).map_err(|_| format!("`{token}` is not a #rrggbb color")) + }; + Ok(Color::new(parse(0..2)?, parse(2..4)?, parse(4..6)?)) +} + +fn build_config(args: &Args) -> Result { + let mut config = match args.preset { + Some(preset) => Config::from_preset(preset), + None => Config::default(), + }; + + if let Some(v) = args.colormode { + config.color_mode = v; + } + if let Some(v) = args.hierarchical { + config.hierarchical = v; + } + if let Some(v) = args.mode { + config.mode = v; + } + if let Some(v) = args.filter_speckle { + config.filter_speckle = v as usize; + } + if let Some(v) = args.color_precision { + config.color_precision = v as i32; + } + if let Some(v) = args.gradient_step { + config.layer_difference = v as i32; + } + if let Some(v) = args.corner_threshold { + config.corner_threshold = v as i32; + } + if let Some(v) = args.segment_length { + config.length_threshold = v; + } + if let Some(v) = args.splice_threshold { + config.splice_threshold = v as i32; + } + if args.path_precision.is_some() { + config.path_precision = args.path_precision; + } + if let Some(v) = args.optimize { + config.optimize = v; + } + if let Some(v) = args.max_colors { + config.max_colors = Some(v); + } + + // Palette: inline flag wins over file; both parse to a color list. + if let Some(text) = &args.palette { + config.palette = parse_palette(text)?; + } else if let Some(path) = &args.palette_file { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read palette file: {e}"))?; + config.palette = parse_palette(&text)?; + } + + Ok(config) +} + +fn read_image(path: &std::path::Path) -> Result { + let img = image::open(path) + .map_err(|_| "no image file found at specified input path".to_string())? + .to_rgba8(); + let (width, height) = (img.width() as usize, img.height() as usize); + Ok(ColorImage { + pixels: img.into_raw(), + width, + height, + }) +} + +fn run() -> Result<(), String> { + let args = Args::parse(); + let config = build_config(&args)?; + let pipeline = config.build().map_err(|e| e.to_string())?; + let img = read_image(&args.input)?; + let svg = pipeline.to_svg(&img).map_err(|e| e.to_string())?; + std::fs::write(&args.output, svg).map_err(|e| format!("cannot write output file: {e}"))?; + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => { + println!("Conversion successful."); + ExitCode::SUCCESS + } + Err(msg) => { + eprintln!("Conversion failed: {msg}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/vtracer/Cargo.toml b/crates/vtracer/Cargo.toml new file mode 100644 index 0000000..ba7178c --- /dev/null +++ b/crates/vtracer/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "vtracer" +description = "A vectorization framework that converts raster images into vector graphics: pluggable frontends, curve fitters, color fitting, and output optimization." +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +categories = ["graphics", "computer-vision"] +keywords = ["svg", "vectorization", "computer-graphics"] + +[lib] +name = "vtracer" +path = "src/lib.rs" + +[dependencies] +visioncortex.workspace = true diff --git a/crates/vtracer/src/colorfit/merge.rs b/crates/vtracer/src/colorfit/merge.rs new file mode 100644 index 0000000..5ad734b --- /dev/null +++ b/crates/vtracer/src/colorfit/merge.rs @@ -0,0 +1,28 @@ +use crate::ir::{Layer, Segmentation}; + +use super::ColorFitter; + +/// Union consecutive layers that share a paint into a single layer. Run this +/// after palette snapping (which is what creates runs of identical paints) to +/// cut the shape count without changing appearance. +#[derive(Debug, Clone, Default)] +pub struct MergeAdjacent; + +impl ColorFitter for MergeAdjacent { + fn fit(&self, seg: &mut Segmentation) { + if seg.layers.len() < 2 { + return; + } + let mut merged: Vec = Vec::with_capacity(seg.layers.len()); + for layer in seg.layers.drain(..) { + if let Some(last) = merged.last_mut() { + if last.paint == layer.paint { + last.mask = last.mask.union(&layer.mask); + continue; + } + } + merged.push(layer); + } + seg.layers = merged; + } +} diff --git a/crates/vtracer/src/colorfit/mod.rs b/crates/vtracer/src/colorfit/mod.rs new file mode 100644 index 0000000..89016d7 --- /dev/null +++ b/crates/vtracer/src/colorfit/mod.rs @@ -0,0 +1,75 @@ +//! Color fitters: rewrite layer paints before compositing. +//! +//! * [`Identity`] — keep the frontend's mean colors (0.6.x behavior). +//! * [`FixedPalette`] — snap each paint to the nearest entry of a fixed +//! palette, measured in OKLab. +//! * [`AutoQuantize`] — reduce the palette to at most `max_colors` via +//! area-weighted median cut. +//! * [`MergeAdjacent`] — union consecutive layers that share a paint, cutting +//! shape count for free. + +mod merge; +mod oklab; +mod palette; +mod quantize; + +pub use merge::MergeAdjacent; +pub use palette::FixedPalette; +pub use quantize::AutoQuantize; + +use crate::ir::Segmentation; + +/// A color fitter rewrites the paints of a segmentation in place. +pub trait ColorFitter { + fn fit(&self, seg: &mut Segmentation); +} + +/// No-op fitter: paints keep the frontend's mean cluster colors. +#[derive(Debug, Clone, Default)] +pub struct Identity; + +impl ColorFitter for Identity { + fn fit(&self, _seg: &mut Segmentation) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::{Layer, Paint, RegionMask}; + use visioncortex::{BinaryImage, Color, PointI32}; + + fn layer(color: Color) -> Layer { + let mut image = BinaryImage::new_w_h(1, 1); + image.set_pixel(0, 0, true); + Layer { + paint: Paint::Solid(color), + mask: RegionMask::new(image, PointI32 { x: 0, y: 0 }), + } + } + + #[test] + fn fixed_palette_snaps_to_nearest_oklab() { + let mut seg = Segmentation::new(1, 1); + seg.layers.push(layer(Color::new(250, 10, 10))); // near red + seg.layers.push(layer(Color::new(10, 10, 250))); // near blue + + let palette = FixedPalette::new(vec![Color::new(255, 0, 0), Color::new(0, 0, 255)]); + palette.fit(&mut seg); + + assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(255, 0, 0))); + assert_eq!(seg.layers[1].paint, Paint::Solid(Color::new(0, 0, 255))); + } + + #[test] + fn merge_adjacent_unions_same_paint_runs() { + let mut seg = Segmentation::new(2, 1); + seg.layers.push(layer(Color::new(0, 0, 0))); + seg.layers.push(layer(Color::new(0, 0, 0))); + seg.layers.push(layer(Color::new(255, 255, 255))); + + MergeAdjacent.fit(&mut seg); + + assert_eq!(seg.layers.len(), 2); + assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(0, 0, 0))); + } +} diff --git a/crates/vtracer/src/colorfit/oklab.rs b/crates/vtracer/src/colorfit/oklab.rs new file mode 100644 index 0000000..9869cf3 --- /dev/null +++ b/crates/vtracer/src/colorfit/oklab.rs @@ -0,0 +1,53 @@ +//! Minimal sRGB → OKLab conversion for perceptual color distance. +//! +//! OKLab (Björn Ottosson, 2020) gives a Euclidean space where distance +//! approximates perceived color difference far better than raw RGB. + +use visioncortex::Color; + +/// A color in the OKLab space. +#[derive(Debug, Clone, Copy)] +pub struct Oklab { + pub l: f64, + pub a: f64, + pub b: f64, +} + +fn srgb_to_linear(c: u8) -> f64 { + let c = c as f64 / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +impl Oklab { + pub fn from_color(color: &Color) -> Self { + let r = srgb_to_linear(color.r); + let g = srgb_to_linear(color.g); + let b = srgb_to_linear(color.b); + + let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b; + let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b; + let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b; + + let l_ = l.cbrt(); + let m_ = m.cbrt(); + let s_ = s.cbrt(); + + Oklab { + l: 0.210_454_255_3 * l_ + 0.793_617_785_0 * m_ - 0.004_072_046_8 * s_, + a: 1.977_998_495_1 * l_ - 2.428_592_205_0 * m_ + 0.450_593_709_9 * s_, + b: 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766_0 * s_, + } + } + + /// Squared Euclidean distance (monotonic with distance; avoids the sqrt). + pub fn distance_squared(&self, other: &Oklab) -> f64 { + let dl = self.l - other.l; + let da = self.a - other.a; + let db = self.b - other.b; + dl * dl + da * da + db * db + } +} diff --git a/crates/vtracer/src/colorfit/palette.rs b/crates/vtracer/src/colorfit/palette.rs new file mode 100644 index 0000000..522cc95 --- /dev/null +++ b/crates/vtracer/src/colorfit/palette.rs @@ -0,0 +1,47 @@ +use visioncortex::Color; + +use crate::ir::{Paint, Segmentation}; + +use super::oklab::Oklab; +use super::ColorFitter; + +/// Snap every layer paint to the nearest color in a fixed palette, measured in +/// OKLab. An empty palette leaves paints untouched. +#[derive(Debug, Clone, Default)] +pub struct FixedPalette { + pub colors: Vec, +} + +impl FixedPalette { + pub fn new(colors: Vec) -> Self { + Self { colors } + } + + /// The palette entry closest to `color` in OKLab. + fn nearest(&self, color: &Color, lab: &[Oklab]) -> Color { + let target = Oklab::from_color(color); + let mut best = self.colors[0]; + let mut best_dist = f64::INFINITY; + for (i, entry) in self.colors.iter().enumerate() { + let dist = target.distance_squared(&lab[i]); + if dist < best_dist { + best_dist = dist; + best = *entry; + } + } + best + } +} + +impl ColorFitter for FixedPalette { + fn fit(&self, seg: &mut Segmentation) { + if self.colors.is_empty() { + return; + } + let lab: Vec = self.colors.iter().map(Oklab::from_color).collect(); + for layer in &mut seg.layers { + let snapped = self.nearest(&layer.paint.color(), &lab); + layer.paint = Paint::Solid(snapped); + } + } +} diff --git a/crates/vtracer/src/colorfit/quantize.rs b/crates/vtracer/src/colorfit/quantize.rs new file mode 100644 index 0000000..7898d53 --- /dev/null +++ b/crates/vtracer/src/colorfit/quantize.rs @@ -0,0 +1,148 @@ +use visioncortex::Color; + +use crate::ir::{Paint, Segmentation}; + +use super::oklab::Oklab; +use super::ColorFitter; + +/// Reduce the layer palette to at most `max_colors` representative colors via +/// area-weighted median cut, then snap each layer to the nearest representative +/// (in OKLab). +#[derive(Debug, Clone)] +pub struct AutoQuantize { + pub max_colors: usize, +} + +impl Default for AutoQuantize { + fn default() -> Self { + Self { max_colors: 16 } + } +} + +#[derive(Clone, Copy)] +struct Sample { + color: Color, + weight: u64, +} + +struct Bucket { + samples: Vec, +} + +impl Bucket { + /// Extent (max - min) of the given channel across the bucket. + fn channel_range(&self, channel: usize) -> u8 { + let mut lo = u8::MAX; + let mut hi = u8::MIN; + for s in &self.samples { + let v = s.color.rgb_u8()[channel]; + lo = lo.min(v); + hi = hi.max(v); + } + hi.saturating_sub(lo) + } + + fn widest_channel(&self) -> usize { + let mut best = 0; + let mut best_range = 0u8; + for c in 0..3 { + let r = self.channel_range(c); + if r > best_range { + best_range = r; + best = c; + } + } + best + } + + fn total_weight(&self) -> u64 { + self.samples.iter().map(|s| s.weight).sum() + } + + /// Weighted-average representative color. + fn representative(&self) -> Color { + let mut r = 0u64; + let mut g = 0u64; + let mut b = 0u64; + let mut w = 0u64; + for s in &self.samples { + let rgb = s.color.rgb_u8(); + r += rgb[0] as u64 * s.weight; + g += rgb[1] as u64 * s.weight; + b += rgb[2] as u64 * s.weight; + w += s.weight; + } + if w == 0 { + return Color::new(0, 0, 0); + } + Color::new((r / w) as u8, (g / w) as u8, (b / w) as u8) + } + + /// Split at the weighted median of the widest channel. + fn split(mut self) -> (Bucket, Bucket) { + let channel = self.widest_channel(); + self.samples + .sort_by_key(|s| s.color.rgb_u8()[channel]); + let half = self.total_weight() / 2; + let mut acc = 0u64; + let mut cut = 1; + for (i, s) in self.samples.iter().enumerate() { + acc += s.weight; + if acc >= half { + cut = (i + 1).clamp(1, self.samples.len().saturating_sub(1).max(1)); + break; + } + } + let right = self.samples.split_off(cut); + (Bucket { samples: self.samples }, Bucket { samples: right }) + } +} + +impl ColorFitter for AutoQuantize { + fn fit(&self, seg: &mut Segmentation) { + if self.max_colors == 0 || seg.layers.is_empty() { + return; + } + + let samples: Vec = seg + .layers + .iter() + .map(|l| Sample { + color: l.paint.color(), + weight: l.mask.area() as u64 + 1, + }) + .collect(); + + let mut buckets = vec![Bucket { samples }]; + while buckets.len() < self.max_colors { + // Split the bucket with the widest single-channel range. + let target = buckets + .iter() + .enumerate() + .filter(|(_, b)| b.samples.len() > 1) + .max_by_key(|(_, b)| b.channel_range(b.widest_channel())); + let Some((idx, _)) = target else { break }; + let bucket = buckets.swap_remove(idx); + let (a, b) = bucket.split(); + buckets.push(a); + buckets.push(b); + } + + let palette: Vec = buckets.iter().map(Bucket::representative).collect(); + let lab: Vec = palette.iter().map(Oklab::from_color).collect(); + + for layer in &mut seg.layers { + let target = Oklab::from_color(&layer.paint.color()); + let mut best = palette[0]; + let mut best_dist = f64::INFINITY; + for (i, entry) in palette.iter().enumerate() { + let d = target.distance_squared(&lab[i]); + if d < best_dist { + best_dist = d; + best = *entry; + } + } + layer.paint = Paint::Solid(best); + } + } +} diff --git a/crates/vtracer/src/compose/mod.rs b/crates/vtracer/src/compose/mod.rs new file mode 100644 index 0000000..4d261c8 --- /dev/null +++ b/crates/vtracer/src/compose/mod.rs @@ -0,0 +1,31 @@ +//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`]. +//! +//! Only **stacked** composition is implemented: each layer is traced +//! independently into closed outlines and stacked in paint order (painter's +//! algorithm). The **mosaic** compositor — gapless tessellation with shared +//! boundary geometry — is a separate milestone and not built yet. + +use crate::fitter::CurveFitter; +use crate::ir::{Segmentation, Shape, VectorDoc}; + +/// Which compositing strategy the pipeline uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Compositing { + /// Independent per-region closed outlines, stacked bottom-to-top. + Stacked, +} + +/// Trace every layer's closed outline and stack the shapes in paint order. +pub fn compose_stacked(seg: &Segmentation, fitter: &dyn CurveFitter) -> VectorDoc { + let mut doc = VectorDoc::new(seg.width, seg.height); + for layer in &seg.layers { + let path = fitter.fit_region(&layer.mask); + if !path.is_empty() { + doc.shapes.push(Shape { + paint: layer.paint, + path, + }); + } + } + doc +} diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs new file mode 100644 index 0000000..9b03eaa --- /dev/null +++ b/crates/vtracer/src/config.rs @@ -0,0 +1,264 @@ +//! High-level configuration and presets that assemble a [`Pipeline`]. + +use std::str::FromStr; + +use visioncortex::Color; + +use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAdjacent}; +use crate::compose::Compositing; +use crate::error::Error; +use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter}; +use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend}; +use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass}; +use crate::pipeline::Pipeline; +use crate::svg::SvgWriter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorMode { + Color, + Binary, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hierarchical { + Stacked, + /// True mosaic cutout — not yet implemented (separate milestone). + Cutout, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FitMode { + Pixel, + Polygon, + Spline, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preset { + Bw, + Poster, + Photo, +} + +/// High-level converter configuration. [`Config::build`] turns this into a +/// concrete [`Pipeline`]. +#[derive(Debug, Clone)] +pub struct Config { + pub color_mode: ColorMode, + pub hierarchical: Hierarchical, + /// Speckle filter given as a side length; the area threshold is its square. + pub filter_speckle: usize, + /// Significant bits per RGB channel (1..=8). + pub color_precision: i32, + /// Color difference between gradient layers. + pub layer_difference: i32, + pub mode: FitMode, + /// Corner threshold in degrees. + pub corner_threshold: i32, + /// Segment length threshold in pixels. + pub length_threshold: f64, + pub max_iterations: usize, + /// Splice threshold in degrees. + pub splice_threshold: i32, + /// Coordinate precision (decimal places) for output. + pub path_precision: Option, + /// Fixed palette (empty = none). Takes priority over `max_colors`. + pub palette: Vec, + /// Auto-quantize target color count (None = off). + pub max_colors: Option, + /// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. + pub optimize: u8, +} + +impl Default for Config { + fn default() -> Self { + Self { + color_mode: ColorMode::Color, + hierarchical: Hierarchical::Stacked, + filter_speckle: 4, + color_precision: 6, + layer_difference: 16, + mode: FitMode::Spline, + corner_threshold: 60, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: 45, + path_precision: Some(2), + palette: Vec::new(), + max_colors: None, + optimize: 1, + } + } +} + +impl Config { + pub fn from_preset(preset: Preset) -> Self { + match preset { + Preset::Bw => Self { + color_mode: ColorMode::Binary, + ..Self::default() + }, + Preset::Poster => Self { + color_mode: ColorMode::Color, + color_precision: 8, + ..Self::default() + }, + Preset::Photo => Self { + color_mode: ColorMode::Color, + filter_speckle: 10, + color_precision: 8, + layer_difference: 48, + corner_threshold: 180, + ..Self::default() + }, + } + } + + fn fit_params(&self) -> FitParams { + FitParams { + corner_threshold: deg2rad(self.corner_threshold), + length_threshold: self.length_threshold, + max_iterations: self.max_iterations, + splice_threshold: deg2rad(self.splice_threshold), + } + } + + fn frontend(&self) -> Box { + let filter_speckle_area = self.filter_speckle * self.filter_speckle; + match self.color_mode { + ColorMode::Color => Box::new(ColorClusterFrontend { + filter_speckle_area, + color_precision_loss: 8 - self.color_precision, + layer_difference: self.layer_difference, + }), + ColorMode::Binary => Box::new(BinaryFrontend { + filter_speckle_area, + threshold: 128, + diagonal: false, + }), + } + } + + fn color_fitters(&self) -> Vec> { + if !self.palette.is_empty() { + vec![ + Box::new(FixedPalette::new(self.palette.clone())), + Box::new(MergeAdjacent), + ] + } else if let Some(max_colors) = self.max_colors { + vec![Box::new(AutoQuantize { max_colors }), Box::new(MergeAdjacent)] + } else { + vec![Box::new(Identity)] + } + } + + fn fitter(&self) -> Box { + match self.mode { + FitMode::Pixel => Box::new(PixelFitter), + FitMode::Polygon => Box::new(PolygonFitter), + FitMode::Spline => Box::new(SplineFitter::new(self.fit_params())), + } + } + + fn optimizers(&self) -> Vec> { + if self.optimize == 0 { + return Vec::new(); + } + let precision = self.path_precision.unwrap_or(2); + vec![ + Box::new(QuantizePass::new(precision)), + Box::new(SimplifyPass), + ] + } + + fn writer(&self) -> SvgWriter { + match self.optimize { + 0 => SvgWriter { + relative: false, + shorthands: false, + precision: self.path_precision, + }, + 1 => SvgWriter { + relative: true, + shorthands: false, + precision: self.path_precision, + }, + _ => SvgWriter { + relative: true, + shorthands: true, + precision: self.path_precision, + }, + } + } + + /// Assemble a concrete pipeline from this configuration. + pub fn build(&self) -> Result { + let compositing = match self.hierarchical { + Hierarchical::Stacked => Compositing::Stacked, + Hierarchical::Cutout => { + return Err(Error::Unsupported( + "the mosaic (cutout) compositor is not yet implemented".into(), + )) + } + }; + + Ok(Pipeline { + frontend: self.frontend(), + color_fitters: self.color_fitters(), + fitter: self.fitter(), + compositing, + optimizers: self.optimizers(), + writer: self.writer(), + }) + } +} + +fn deg2rad(deg: i32) -> f64 { + deg as f64 / 180.0 * std::f64::consts::PI +} + +impl FromStr for ColorMode { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "color" => Ok(Self::Color), + "binary" | "bw" | "BW" => Ok(Self::Binary), + _ => Err(format!("unknown color mode {s}")), + } + } +} + +impl FromStr for Hierarchical { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "stacked" => Ok(Self::Stacked), + "cutout" => Ok(Self::Cutout), + _ => Err(format!("unknown hierarchical mode {s}")), + } + } +} + +impl FromStr for FitMode { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "pixel" | "none" => Ok(Self::Pixel), + "polygon" => Ok(Self::Polygon), + "spline" => Ok(Self::Spline), + _ => Err(format!("unknown fit mode {s}")), + } + } +} + +impl FromStr for Preset { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "bw" => Ok(Self::Bw), + "poster" => Ok(Self::Poster), + "photo" => Ok(Self::Photo), + _ => Err(format!("unknown preset {s}")), + } + } +} diff --git a/crates/vtracer/src/error.rs b/crates/vtracer/src/error.rs new file mode 100644 index 0000000..0ebaeb8 --- /dev/null +++ b/crates/vtracer/src/error.rs @@ -0,0 +1,41 @@ +use std::fmt; + +/// Errors produced by the framework stages and the pipeline driver. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// The input image had zero width or height. + EmptyImage, + /// Transparency keying was requested but no unused key color could be found. + NoKeyColor, + /// A requested feature is recognized but not yet implemented. + Unsupported(String), + /// Any other failure, carrying a human-readable message. + Other(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::EmptyImage => write!(f, "input image is empty"), + Error::NoKeyColor => { + write!(f, "unable to find an unused color in image to use as key") + } + Error::Unsupported(what) => write!(f, "unsupported: {what}"), + Error::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(msg: String) -> Self { + Error::Other(msg) + } +} + +impl From<&str> for Error { + fn from(msg: &str) -> Self { + Error::Other(msg.to_string()) + } +} diff --git a/crates/vtracer/src/fitter/mod.rs b/crates/vtracer/src/fitter/mod.rs new file mode 100644 index 0000000..96892c1 --- /dev/null +++ b/crates/vtracer/src/fitter/mod.rs @@ -0,0 +1,173 @@ +//! Curve fitters: turn a region's pixel mask into vector outlines. +//! +//! The three built-ins wrap the corresponding visioncortex tracing modes and +//! emit our [`MultiPath`] IR in absolute (document) coordinates: +//! +//! * [`PixelFitter`] — exact lattice polyline (no simplification). +//! * [`PolygonFitter`] — staircase-symmetric Douglas–Peucker polygon. +//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics. +//! +//! All three trace *closed* region outlines (outer ring plus holes). Open +//! polyline fitting (needed for the mosaic compositor) will arrive with that +//! milestone. + +use visioncortex::clusters::Cluster as BinaryCluster; +use visioncortex::{ + CompoundPath, CompoundPathElement, PathSimplifyMode, PointF64, PointI32, +}; + +use crate::ir::{MultiPath, PathCmd, RegionMask, SubPath}; + +/// Fitting parameters shared by the built-in fitters. Only the spline fitter +/// consults the smoothing/splice fields. +#[derive(Debug, Clone, Copy)] +pub struct FitParams { + /// Minimum momentary angle (radians) to be considered a corner. + pub corner_threshold: f64, + /// Subdivide until all segments are shorter than this length (px). + pub length_threshold: f64, + /// Maximum smoothing iterations. + pub max_iterations: usize, + /// Minimum angle displacement (radians) to splice a spline. + pub splice_threshold: f64, +} + +impl Default for FitParams { + fn default() -> Self { + Self { + corner_threshold: std::f64::consts::PI / 3.0, // 60° + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: std::f64::consts::PI / 4.0, // 45° + } + } +} + +/// A curve fitter traces a region mask into closed vector outlines. +pub trait CurveFitter { + fn fit_region(&self, mask: &RegionMask) -> MultiPath; +} + +/// Exact lattice polyline; every pixel-boundary step is preserved. +#[derive(Debug, Clone, Default)] +pub struct PixelFitter; + +impl CurveFitter for PixelFitter { + fn fit_region(&self, mask: &RegionMask) -> MultiPath { + trace_region(mask, PathSimplifyMode::None, FitParams::default()) + } +} + +/// Douglas–Peucker polygon with staircase removal. +#[derive(Debug, Clone, Default)] +pub struct PolygonFitter; + +impl CurveFitter for PolygonFitter { + fn fit_region(&self, mask: &RegionMask) -> MultiPath { + trace_region(mask, PathSimplifyMode::Polygon, FitParams::default()) + } +} + +/// Smoothed spline (cubic Bézier) fitter. +#[derive(Debug, Clone, Default)] +pub struct SplineFitter { + pub params: FitParams, +} + +impl SplineFitter { + pub fn new(params: FitParams) -> Self { + Self { params } + } +} + +impl CurveFitter for SplineFitter { + fn fit_region(&self, mask: &RegionMask) -> MultiPath { + trace_region(mask, PathSimplifyMode::Spline, self.params) + } +} + +/// Trace every connected component of a masked region and merge the resulting +/// outlines into a single [`MultiPath`] in absolute coordinates. +/// +/// This mirrors visioncortex's `Cluster::to_compound_path`: the mask (with +/// holes already punched) is split into connected sub-clusters, each traced +/// independently, then offset into document space. +fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> MultiPath { + let mut multi = MultiPath::new(); + for sub in mask.image.to_clusters(false).iter() { + let offset = PointI32 { + x: mask.offset.x + sub.rect.left, + y: mask.offset.y + sub.rect.top, + }; + let compound = BinaryCluster::image_to_compound_path( + &offset, + &sub.to_binary_image(), + mode, + params.corner_threshold, + params.length_threshold, + params.max_iterations, + params.splice_threshold, + ); + append_compound(&mut multi, &compound); + } + multi +} + +fn append_compound(multi: &mut MultiPath, compound: &CompoundPath) { + for element in compound.iter() { + match element { + CompoundPathElement::PathI32(p) => { + let pts: Vec = p + .path + .iter() + .map(|q| PointF64 { + x: q.x as f64, + y: q.y as f64, + }) + .collect(); + multi.push(polyline_subpath(&pts)); + } + CompoundPathElement::PathF64(p) => { + multi.push(polyline_subpath(&p.path)); + } + CompoundPathElement::Spline(s) => { + multi.push(spline_subpath(&s.points)); + } + } + } +} + +/// A closed polyline whose last point repeats the first becomes +/// `MoveTo · LineTo* · Close`. +fn polyline_subpath(points: &[PointF64]) -> SubPath { + let mut sub = SubPath::new(); + if points.len() < 2 { + return sub; + } + // The tracer emits closed paths whose final point duplicates the first. + let closed = points.first() == points.last(); + let body_end = if closed { points.len() - 1 } else { points.len() }; + sub.commands.push(PathCmd::MoveTo(points[0])); + for p in &points[1..body_end] { + sub.commands.push(PathCmd::LineTo(*p)); + } + sub.commands.push(PathCmd::Close); + sub +} + +/// A spline of `1 + 3n` points becomes `MoveTo · CubicTo* · Close`. +fn spline_subpath(points: &[PointF64]) -> SubPath { + let mut sub = SubPath::new(); + if points.len() < 4 || (points.len() - 1) % 3 != 0 { + return sub; + } + sub.commands.push(PathCmd::MoveTo(points[0])); + let mut i = 1; + while i + 2 < points.len() { + sub.commands + .push(PathCmd::CubicTo(points[i], points[i + 1], points[i + 2])); + i += 3; + } + sub.commands.push(PathCmd::Close); + sub +} diff --git a/crates/vtracer/src/frontend/binary.rs b/crates/vtracer/src/frontend/binary.rs new file mode 100644 index 0000000..cb0fc4f --- /dev/null +++ b/crates/vtracer/src/frontend/binary.rs @@ -0,0 +1,63 @@ +use visioncortex::{Color, ColorImage, PointI32}; + +use crate::error::Error; +use crate::ir::{Layer, Paint, RegionMask, Segmentation}; + +use super::Frontend; + +/// Binary (black/white) frontend: threshold the image then cluster the +/// foreground. Every region is painted black. +#[derive(Debug, Clone)] +pub struct BinaryFrontend { + /// Discard clusters smaller than this many pixels. + pub filter_speckle_area: usize, + /// A pixel is foreground when its red channel is below this threshold. + pub threshold: u8, + /// Whether to connect clusters diagonally. + pub diagonal: bool, +} + +impl Default for BinaryFrontend { + fn default() -> Self { + Self { + filter_speckle_area: 16, + threshold: 128, + diagonal: false, + } + } +} + +impl Frontend for BinaryFrontend { + fn segment(&self, img: &ColorImage) -> Result { + if img.width == 0 || img.height == 0 { + return Err(Error::EmptyImage); + } + + let width = img.width; + let height = img.height; + let threshold = self.threshold; + let binary = img.to_binary_image(|c| c.r < threshold); + let clusters = binary.to_clusters(self.diagonal); + + let mut seg = Segmentation::new(width as u32, height as u32); + let black = Color::new(0, 0, 0); + for i in 0..clusters.len() { + let cluster = clusters.get_cluster(i); + if cluster.size() >= self.filter_speckle_area { + let mask = RegionMask::new( + cluster.to_binary_image(), + PointI32 { + x: cluster.rect.left, + y: cluster.rect.top, + }, + ); + seg.layers.push(Layer { + paint: Paint::Solid(black), + mask, + }); + } + } + + Ok(seg) + } +} diff --git a/crates/vtracer/src/frontend/color_cluster.rs b/crates/vtracer/src/frontend/color_cluster.rs new file mode 100644 index 0000000..2382e07 --- /dev/null +++ b/crates/vtracer/src/frontend/color_cluster.rs @@ -0,0 +1,92 @@ +use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX}; +use visioncortex::{Color, ColorImage, PointI32}; + +use crate::error::Error; +use crate::ir::{Layer, Paint, RegionMask, Segmentation}; + +use super::keying::{apply_key, find_unused_color, should_key_image}; +use super::Frontend; + +/// Hierarchical color-clustering frontend — the classic VTracer color path. +#[derive(Debug, Clone)] +pub struct ColorClusterFrontend { + /// Discard clusters smaller than this many pixels. + pub filter_speckle_area: usize, + /// Bits of color precision dropped when comparing pixels (0 = full 8-bit). + pub color_precision_loss: i32, + /// Color difference between hierarchical gradient layers. + pub layer_difference: i32, +} + +impl Default for ColorClusterFrontend { + fn default() -> Self { + Self { + filter_speckle_area: 16, + color_precision_loss: 2, + layer_difference: 16, + } + } +} + +impl Frontend for ColorClusterFrontend { + fn segment(&self, img: &ColorImage) -> Result { + if img.width == 0 || img.height == 0 { + return Err(Error::EmptyImage); + } + + let width = img.width; + let height = img.height; + let mut img = img.clone(); + + // Transparency keying (stacked mode discards the keyed background). + let key_color = if should_key_image(&img) { + let key = find_unused_color(&img)?; + apply_key(&mut img, key); + key + } else { + // All-zero is the sentinel understood by visioncortex as "no keying". + Color::default() + }; + + let runner = Runner::new( + RunnerConfig { + diagonal: self.layer_difference == 0, + hierarchical: HIERARCHICAL_MAX, + batch_size: 25600, + good_min_area: self.filter_speckle_area, + good_max_area: width * height, + is_same_color_a: self.color_precision_loss, + is_same_color_b: 1, + deepen_diff: self.layer_difference, + hollow_neighbours: 1, + key_color, + keying_action: KeyingAction::Discard, + }, + img, + ); + + let clusters = runner.run(); + let view = clusters.view(); + + let mut seg = Segmentation::new(width as u32, height as u32); + // `clusters_output` is top-to-bottom; reverse to get bottom-to-top + // paint order for the layer stack. + for &cluster_index in view.clusters_output.iter().rev() { + let cluster = view.get_cluster(cluster_index); + let image = cluster.to_image_with_hole(view.width, true); + let mask = RegionMask::new( + image, + PointI32 { + x: cluster.rect.left, + y: cluster.rect.top, + }, + ); + seg.layers.push(Layer { + paint: Paint::Solid(cluster.residue_color()), + mask, + }); + } + + Ok(seg) + } +} diff --git a/crates/vtracer/src/frontend/keying.rs b/crates/vtracer/src/frontend/keying.rs new file mode 100644 index 0000000..5d5650d --- /dev/null +++ b/crates/vtracer/src/frontend/keying.rs @@ -0,0 +1,105 @@ +//! Transparency keying, ported from the 0.6.x `converter.rs`. +//! +//! When an image has substantial transparency, fully-transparent pixels are +//! recolored to an unused "key" color so the clustering runner can treat them +//! as a discardable background. The random key search of 0.6.x is replaced by a +//! deterministic sweep so results are reproducible and `no_std`/wasm-friendly. + +use visioncortex::{Color, ColorImage}; + +use crate::error::Error; + +/// Fraction of pixels in the sampled rows that must be transparent before the +/// whole image is keyed. +const KEYING_THRESHOLD: f32 = 0.2; + +/// Whether the image carries enough transparency to warrant keying. +pub fn should_key_image(img: &ColorImage) -> bool { + if img.width == 0 || img.height == 0 { + return false; + } + + let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize; + let mut transparent = 0usize; + let rows = [ + 0, + img.height / 4, + img.height / 2, + 3 * img.height / 4, + img.height - 1, + ]; + for y in rows { + for x in 0..img.width { + if img.get_pixel(x, y).a == 0 { + transparent += 1; + } + if transparent >= threshold { + return true; + } + } + } + false +} + +fn color_exists(img: &ColorImage, color: Color) -> bool { + for y in 0..img.height { + for x in 0..img.width { + let p = img.get_pixel(x, y); + if p.r == color.r && p.g == color.g && p.b == color.b { + return true; + } + } + } + false +} + +/// Find a color not present in the image, to be used as the key. Tries the +/// primary/secondary colors first, then does a deterministic sweep of the RGB +/// cube. Returns [`Error::NoKeyColor`] only if every probed color is used. +pub fn find_unused_color(img: &ColorImage) -> Result { + let specials = [ + Color::new(255, 0, 0), + Color::new(0, 255, 0), + Color::new(0, 0, 255), + Color::new(255, 255, 0), + Color::new(0, 255, 255), + Color::new(255, 0, 255), + ]; + for &c in specials.iter() { + if !color_exists(img, c) { + return Ok(c); + } + } + + // Deterministic sweep: step by a value coprime-ish with 256 to spread out. + const STEP: u16 = 37; + let mut r = 0u16; + while r < 256 { + let mut g = 0u16; + while g < 256 { + let mut b = 0u16; + while b < 256 { + let c = Color::new(r as u8, g as u8, b as u8); + if !color_exists(img, c) { + return Ok(c); + } + b += STEP; + } + g += STEP; + } + r += STEP; + } + + Err(Error::NoKeyColor) +} + +/// Recolor every fully-transparent pixel to `key`, in place. +pub fn apply_key(img: &mut ColorImage, key: Color) { + for y in 0..img.height { + for x in 0..img.width { + if img.get_pixel(x, y).a == 0 { + img.set_pixel(x, y, &key); + } + } + } +} diff --git a/crates/vtracer/src/frontend/mod.rs b/crates/vtracer/src/frontend/mod.rs new file mode 100644 index 0000000..2f09d79 --- /dev/null +++ b/crates/vtracer/src/frontend/mod.rs @@ -0,0 +1,26 @@ +//! Frontends: algorithms that turn a raster image into a [`Segmentation`]. +//! +//! Built-ins: +//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic +//! VTracer color path), including transparency keying. +//! * [`BinaryFrontend`] — threshold to black/white then cluster. +//! +//! Third parties can implement [`Frontend`] to feed external label maps or ML +//! segmentation into the pipeline. + +mod binary; +mod color_cluster; +mod keying; + +pub use binary::BinaryFrontend; +pub use color_cluster::ColorClusterFrontend; + +use visioncortex::ColorImage; + +use crate::error::Error; +use crate::ir::Segmentation; + +/// A frontend segments a raster image into ordered paint layers. +pub trait Frontend { + fn segment(&self, img: &ColorImage) -> Result; +} diff --git a/crates/vtracer/src/ir/mod.rs b/crates/vtracer/src/ir/mod.rs new file mode 100644 index 0000000..600477f --- /dev/null +++ b/crates/vtracer/src/ir/mod.rs @@ -0,0 +1,34 @@ +//! Core intermediate representation shared by the pipeline stages. +//! +//! Two IRs flow through the pipeline: +//! +//! * [`Segmentation`] — the frontend output: ordered paint layers over a +//! raster canvas (painter's algorithm, bottom to top). This is what the +//! [`crate::colorfit`] stages rewrite. +//! * [`VectorDoc`] — the output document: resolved shapes with fitted paths. +//! This is what the [`crate::optimize`] passes and the [`crate::svg`] writer +//! operate on. + +mod region; +mod vector; + +pub use region::{Layer, RegionMask, Segmentation}; +pub use vector::{MultiPath, PathCmd, Shape, SubPath, VectorDoc}; + +use visioncortex::Color; + +/// The final appearance of a region. Only solid colors are supported today; +/// the enum leaves room for gradients and patterns later. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Paint { + Solid(Color), +} + +impl Paint { + /// The representative solid color of this paint. + pub fn color(&self) -> Color { + match self { + Paint::Solid(c) => *c, + } + } +} diff --git a/crates/vtracer/src/ir/region.rs b/crates/vtracer/src/ir/region.rs new file mode 100644 index 0000000..1373c95 --- /dev/null +++ b/crates/vtracer/src/ir/region.rs @@ -0,0 +1,100 @@ +use visioncortex::{BinaryImage, PointI32}; + +use super::Paint; + +/// A region's pixel coverage: a local binary mask positioned on the canvas. +/// +/// Foreground pixels are `true`. Holes (interior background) are already +/// punched out of the mask, so a mask is self-describing for tracing. +#[derive(Debug, Clone)] +pub struct RegionMask { + /// Local coverage; `true` = inside the region. + pub image: BinaryImage, + /// Position of the mask's top-left corner in full-canvas coordinates. + pub offset: PointI32, +} + +impl RegionMask { + pub fn new(image: BinaryImage, offset: PointI32) -> Self { + Self { image, offset } + } + + pub fn width(&self) -> usize { + self.image.width + } + + pub fn height(&self) -> usize { + self.image.height + } + + /// Number of foreground pixels. + pub fn area(&self) -> usize { + let mut count = 0; + for y in 0..self.image.height { + for x in 0..self.image.width { + if self.image.get_pixel(x, y) { + count += 1; + } + } + } + count + } + + /// Combine two masks into one covering the union of their bounding boxes. + /// Foreground is the OR of both; this is used by the layer-merge step. + pub fn union(&self, other: &RegionMask) -> RegionMask { + let left = self.offset.x.min(other.offset.x); + let top = self.offset.y.min(other.offset.y); + let right = (self.offset.x + self.image.width as i32) + .max(other.offset.x + other.image.width as i32); + let bottom = (self.offset.y + self.image.height as i32) + .max(other.offset.y + other.image.height as i32); + + let width = (right - left) as usize; + let height = (bottom - top) as usize; + let mut image = BinaryImage::new_w_h(width, height); + + for src in [self, other] { + for y in 0..src.image.height { + for x in 0..src.image.width { + if src.image.get_pixel(x, y) { + let gx = (src.offset.x + x as i32 - left) as usize; + let gy = (src.offset.y + y as i32 - top) as usize; + image.set_pixel(gx, gy, true); + } + } + } + } + + RegionMask::new(image, PointI32 { x: left, y: top }) + } +} + +/// A single paint layer. Layers are painted bottom-to-top. +#[derive(Debug, Clone)] +pub struct Layer { + /// Fill applied to the region. Starts as the cluster's mean color; a + /// [`crate::colorfit::ColorFitter`] may rewrite it. + pub paint: Paint, + /// Pixel coverage of the region. + pub mask: RegionMask, +} + +/// Frontend output: ordered layers over a canvas, in paint order. +#[derive(Debug, Clone)] +pub struct Segmentation { + pub width: u32, + pub height: u32, + /// Bottom-to-top paint order. + pub layers: Vec, +} + +impl Segmentation { + pub fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + layers: Vec::new(), + } + } +} diff --git a/crates/vtracer/src/ir/vector.rs b/crates/vtracer/src/ir/vector.rs new file mode 100644 index 0000000..642885c --- /dev/null +++ b/crates/vtracer/src/ir/vector.rs @@ -0,0 +1,90 @@ +use visioncortex::PointF64; + +use super::Paint; + +/// A single drawing command in a subpath. Coordinates are absolute, in +/// full-canvas (document) space — the writer bakes any offset into them. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PathCmd { + /// Start a new subpath at the given point. + MoveTo(PointF64), + /// Straight line to the given point. + LineTo(PointF64), + /// Cubic Bézier: two control points then the endpoint. + CubicTo(PointF64, PointF64, PointF64), + /// Close the current subpath back to its start. + Close, +} + +/// One connected outline: a `MoveTo` followed by line/cubic segments, usually +/// terminated by `Close`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SubPath { + pub commands: Vec, +} + +impl SubPath { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.commands.is_empty() + } + + /// The starting point of the subpath, if any. + pub fn start(&self) -> Option { + match self.commands.first() { + Some(PathCmd::MoveTo(p)) => Some(*p), + _ => None, + } + } +} + +/// A shape may consist of several subpaths (outer ring plus holes). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MultiPath { + pub subpaths: Vec, +} + +impl MultiPath { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.subpaths.iter().all(SubPath::is_empty) + } + + pub fn push(&mut self, subpath: SubPath) { + if !subpath.is_empty() { + self.subpaths.push(subpath); + } + } +} + +/// A filled shape in the output document. +#[derive(Debug, Clone)] +pub struct Shape { + pub paint: Paint, + pub path: MultiPath, +} + +/// The output document IR: what the optimizer passes and the writer consume. +#[derive(Debug, Clone)] +pub struct VectorDoc { + pub width: u32, + pub height: u32, + /// Shapes in paint order (first drawn is bottom). + pub shapes: Vec, +} + +impl VectorDoc { + pub fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + shapes: Vec::new(), + } + } +} diff --git a/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs new file mode 100644 index 0000000..7fea084 --- /dev/null +++ b/crates/vtracer/src/lib.rs @@ -0,0 +1,48 @@ +//! # vtracer +//! +//! A vectorization *framework*: raster images become vector graphics through a +//! pipeline of pluggable stages. +//! +//! ```text +//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ VectorDoc +//! │ +//! OptimizerPass* ─────┤ +//! ▼ +//! SvgWriter ─▶ SVG +//! ``` +//! +//! The crate is wasm-safe: it performs no file or image I/O (that lives in the +//! `vtracer-cli` wrapper). Everything here compiles to +//! `wasm32-unknown-unknown`. +//! +//! ## Quick start +//! +//! ```no_run +//! use vtracer::{Config, ColorImage}; +//! +//! # fn load() -> ColorImage { todo!() } +//! let img: ColorImage = load(); +//! let svg = Config::default().build().unwrap().to_svg(&img).unwrap(); +//! ``` +//! +//! For finer control, assemble a [`Pipeline`] directly from the stage traits +//! in [`frontend`], [`colorfit`], [`fitter`], [`compose`], [`optimize`], and +//! [`svg`]. + +pub mod colorfit; +pub mod compose; +pub mod config; +pub mod error; +pub mod fitter; +pub mod frontend; +pub mod ir; +pub mod optimize; +pub mod pipeline; +pub mod svg; + +pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset}; +pub use error::Error; +pub use pipeline::Pipeline; + +// Re-export the visioncortex value types callers need at the boundary. +pub use visioncortex::{Color, ColorImage, PointF64, PointI32}; diff --git a/crates/vtracer/src/optimize/mod.rs b/crates/vtracer/src/optimize/mod.rs new file mode 100644 index 0000000..0591ffb --- /dev/null +++ b/crates/vtracer/src/optimize/mod.rs @@ -0,0 +1,207 @@ +//! Optimizer passes over the [`VectorDoc`] before serialization. +//! +//! * [`QuantizePass`] — round every coordinate once, in document space. Doing +//! it here (rather than at write time) lets [`SimplifyPass`] act on the +//! rounded geometry, and it bakes offsets into coordinates so the writer +//! never needs a per-path `translate`. +//! * [`SimplifyPass`] — drop zero-length and collinear-redundant segments that +//! quantization may have created. + +use visioncortex::PointF64; + +use crate::ir::{MultiPath, PathCmd, SubPath, VectorDoc}; + +/// An optimizer pass rewrites the document in place. +pub trait OptimizerPass { + fn run(&self, doc: &mut VectorDoc); +} + +/// Round all coordinates to `precision` decimal places. +#[derive(Debug, Clone, Copy)] +pub struct QuantizePass { + pub precision: u32, +} + +impl QuantizePass { + pub fn new(precision: u32) -> Self { + Self { precision } + } + + fn round(&self, v: f64) -> f64 { + let factor = 10f64.powi(self.precision as i32); + (v * factor).round() / factor + } + + fn round_pt(&self, p: PointF64) -> PointF64 { + PointF64 { + x: self.round(p.x), + y: self.round(p.y), + } + } +} + +impl OptimizerPass for QuantizePass { + fn run(&self, doc: &mut VectorDoc) { + for shape in &mut doc.shapes { + for sub in &mut shape.path.subpaths { + for cmd in &mut sub.commands { + *cmd = match *cmd { + PathCmd::MoveTo(p) => PathCmd::MoveTo(self.round_pt(p)), + PathCmd::LineTo(p) => PathCmd::LineTo(self.round_pt(p)), + PathCmd::CubicTo(c1, c2, e) => PathCmd::CubicTo( + self.round_pt(c1), + self.round_pt(c2), + self.round_pt(e), + ), + PathCmd::Close => PathCmd::Close, + }; + } + } + } + } +} + +/// Remove zero-length segments and collinear-redundant line vertices. +#[derive(Debug, Clone, Copy, Default)] +pub struct SimplifyPass; + +/// Tolerance for treating two points as coincident. +const COINCIDENT_EPS: f64 = 1e-6; +/// Perpendicular-distance tolerance for treating three points as collinear. +const COLLINEAR_EPS: f64 = 1e-4; + +fn approx_eq(a: PointF64, b: PointF64) -> bool { + (a.x - b.x).abs() < COINCIDENT_EPS && (a.y - b.y).abs() < COINCIDENT_EPS +} + +/// Perpendicular distance of `b` from the line through `a` and `c`. +fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool { + let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + let base = ((c.x - a.x).powi(2) + (c.y - a.y).powi(2)).sqrt(); + if base < COINCIDENT_EPS { + return true; + } + (cross.abs() / base) < COLLINEAR_EPS +} + +fn simplify_subpath(sub: &SubPath) -> SubPath { + let mut out = SubPath::new(); + // `prev` is the point active before the last emitted command; `last` is the + // current point after it. Both are needed to test collinearity of a run. + let mut prev = PointF64::default(); + let mut last = PointF64::default(); + + for cmd in &sub.commands { + match *cmd { + PathCmd::MoveTo(p) => { + out.commands.push(PathCmd::MoveTo(p)); + prev = p; + last = p; + } + PathCmd::LineTo(p) => { + if approx_eq(last, p) { + continue; // zero-length + } + if let Some(PathCmd::LineTo(_)) = out.commands.last() { + if collinear(prev, last, p) { + *out.commands.last_mut().unwrap() = PathCmd::LineTo(p); + last = p; // anchor `prev` unchanged + continue; + } + } + out.commands.push(PathCmd::LineTo(p)); + prev = last; + last = p; + } + PathCmd::CubicTo(c1, c2, e) => { + out.commands.push(PathCmd::CubicTo(c1, c2, e)); + prev = last; + last = e; + } + PathCmd::Close => { + out.commands.push(PathCmd::Close); + } + } + } + + out +} + +impl OptimizerPass for SimplifyPass { + fn run(&self, doc: &mut VectorDoc) { + for shape in &mut doc.shapes { + let mut subpaths = Vec::with_capacity(shape.path.subpaths.len()); + for sub in &shape.path.subpaths { + let simplified = simplify_subpath(sub); + // Keep only subpaths with real geometry (a MoveTo plus at least + // one drawing command beyond Close). + let draws = simplified + .commands + .iter() + .filter(|c| matches!(c, PathCmd::LineTo(_) | PathCmd::CubicTo(..))) + .count(); + if draws > 0 { + subpaths.push(simplified); + } + } + shape.path = MultiPath { subpaths }; + } + doc.shapes.retain(|s| !s.path.is_empty()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::{MultiPath, Paint, Shape}; + use visioncortex::Color; + + fn pt(x: f64, y: f64) -> PointF64 { + PointF64 { x, y } + } + + fn doc_with(commands: Vec) -> VectorDoc { + let mut doc = VectorDoc::new(100, 100); + doc.shapes.push(Shape { + paint: Paint::Solid(Color::new(0, 0, 0)), + path: MultiPath { + subpaths: vec![SubPath { commands }], + }, + }); + doc + } + + #[test] + fn quantize_rounds_coordinates() { + let mut doc = doc_with(vec![ + PathCmd::MoveTo(pt(1.234, 5.678)), + PathCmd::LineTo(pt(9.876, 0.001)), + PathCmd::Close, + ]); + QuantizePass::new(1).run(&mut doc); + let cmds = &doc.shapes[0].path.subpaths[0].commands; + assert_eq!(cmds[0], PathCmd::MoveTo(pt(1.2, 5.7))); + assert_eq!(cmds[1], PathCmd::LineTo(pt(9.9, 0.0))); + } + + #[test] + fn simplify_drops_collinear_and_zero_length() { + // A straight run of colinear points plus a duplicate should collapse. + let mut doc = doc_with(vec![ + PathCmd::MoveTo(pt(0.0, 0.0)), + PathCmd::LineTo(pt(1.0, 0.0)), + PathCmd::LineTo(pt(2.0, 0.0)), // collinear with previous run + PathCmd::LineTo(pt(2.0, 0.0)), // zero-length + PathCmd::LineTo(pt(2.0, 5.0)), + PathCmd::Close, + ]); + SimplifyPass.run(&mut doc); + let cmds = &doc.shapes[0].path.subpaths[0].commands; + // MoveTo, one merged horizontal LineTo, one vertical LineTo, Close. + assert_eq!(cmds.len(), 4); + assert_eq!(cmds[0], PathCmd::MoveTo(pt(0.0, 0.0))); + assert_eq!(cmds[1], PathCmd::LineTo(pt(2.0, 0.0))); + assert_eq!(cmds[2], PathCmd::LineTo(pt(2.0, 5.0))); + assert_eq!(cmds[3], PathCmd::Close); + } +} diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs new file mode 100644 index 0000000..e014e31 --- /dev/null +++ b/crates/vtracer/src/pipeline.rs @@ -0,0 +1,49 @@ +//! The pipeline driver: composes the stages and runs an image through them. + +use visioncortex::ColorImage; + +use crate::colorfit::ColorFitter; +use crate::compose::{compose_stacked, Compositing}; +use crate::error::Error; +use crate::fitter::CurveFitter; +use crate::frontend::Frontend; +use crate::ir::VectorDoc; +use crate::optimize::OptimizerPass; +use crate::svg::SvgWriter; + +/// A fully-assembled vectorization pipeline. Build one with +/// [`crate::Config::build`], or construct it directly for full control. +pub struct Pipeline { + pub frontend: Box, + pub color_fitters: Vec>, + pub fitter: Box, + pub compositing: Compositing, + pub optimizers: Vec>, + pub writer: SvgWriter, +} + +impl Pipeline { + /// Run the pipeline to the output document IR (before serialization). + pub fn run(&self, img: &ColorImage) -> Result { + let mut seg = self.frontend.segment(img)?; + + for fitter in &self.color_fitters { + fitter.fit(&mut seg); + } + + let mut doc = match self.compositing { + Compositing::Stacked => compose_stacked(&seg, self.fitter.as_ref()), + }; + + for pass in &self.optimizers { + pass.run(&mut doc); + } + + Ok(doc) + } + + /// Run the pipeline and serialize the result to an SVG string. + pub fn to_svg(&self, img: &ColorImage) -> Result { + Ok(self.writer.write(&self.run(img)?)) + } +} diff --git a/crates/vtracer/src/svg/mod.rs b/crates/vtracer/src/svg/mod.rs new file mode 100644 index 0000000..2aac0e1 --- /dev/null +++ b/crates/vtracer/src/svg/mod.rs @@ -0,0 +1,429 @@ +//! Serialize a [`VectorDoc`] to an SVG string. +//! +//! The writer makes the encoding choices that shrink output without changing +//! geometry: +//! +//! * per segment, the shorter of absolute vs. relative deltas (`L`/`l`, `C`/`c`); +//! * `H`/`V` (`h`/`v`) for axis-aligned lines and `S`/`s` for smooth cubic +//! continuations; +//! * compact number formatting (trimmed zeros, leading-dot decimals, no +//! separator before a negative); +//! * optional `` grouping of consecutive same-fill shapes. +//! +//! Coordinates are assumed to already be in absolute document space (the +//! [`crate::optimize::QuantizePass`] bakes in any offset), so no per-path +//! `transform` is emitted. + +use std::fmt::Write as _; + +use visioncortex::PointF64; + +use crate::ir::{Paint, PathCmd, Shape, SubPath, VectorDoc}; + +/// SVG serializer configuration. +#[derive(Debug, Clone, Copy)] +pub struct SvgWriter { + /// Allow relative commands where they serialize shorter. + pub relative: bool, + /// Allow `H`/`V`/`S` shorthands and `` grouping. + pub shorthands: bool, + /// Decimal places for coordinates (`None` = full precision). + pub precision: Option, +} + +impl Default for SvgWriter { + fn default() -> Self { + Self { + relative: true, + shorthands: true, + precision: Some(2), + } + } +} + +impl SvgWriter { + pub fn write(&self, doc: &VectorDoc) -> String { + let mut out = String::new(); + out.push_str("\n"); + let _ = writeln!( + out, + "", + env!("CARGO_PKG_VERSION") + ); + let _ = writeln!( + out, + "", + doc.width, doc.height + ); + + if self.shorthands { + self.write_grouped(&mut out, &doc.shapes); + } else { + for shape in &doc.shapes { + self.write_path(&mut out, shape, true); + } + } + + out.push_str("\n"); + out + } + + /// Emit shapes, grouping maximal runs of consecutive same-fill shapes into + /// a single `` (preserving paint order). + fn write_grouped(&self, out: &mut String, shapes: &[Shape]) { + let mut i = 0; + while i < shapes.len() { + let fill = shape_fill(&shapes[i]); + let mut j = i + 1; + while j < shapes.len() && shape_fill(&shapes[j]) == fill { + j += 1; + } + let run = &shapes[i..j]; + if run.len() > 1 { + let _ = writeln!(out, "", fill); + for shape in run { + self.write_path(out, shape, false); + } + out.push_str("\n"); + } else { + self.write_path(out, &run[0], true); + } + i = j; + } + } + + fn write_path(&self, out: &mut String, shape: &Shape, with_fill: bool) { + let d = self.encode_path(shape); + if d.is_empty() { + return; + } + if with_fill { + let _ = writeln!( + out, + "", + d, + shape_fill(shape) + ); + } else { + let _ = writeln!(out, "", d); + } + } + + fn encode_path(&self, shape: &Shape) -> String { + let mut emitter = Emitter::new(self.relative, self.shorthands, self.precision); + for sub in &shape.path.subpaths { + emitter.subpath(sub); + } + emitter.finish() + } +} + +fn shape_fill(shape: &Shape) -> String { + match shape.paint { + Paint::Solid(c) => c.to_hex_string(), + } +} + +/// Streaming SVG-path encoder that tracks the current point. +struct Emitter { + relative: bool, + shorthands: bool, + precision: Option, + out: String, + cur: PointF64, + started: bool, + /// Absolute second control point of the previous cubic, for `S` detection. + prev_cubic_c2: Option, +} + +impl Emitter { + fn new(relative: bool, shorthands: bool, precision: Option) -> Self { + Self { + relative, + shorthands, + precision, + out: String::new(), + cur: PointF64::default(), + started: false, + prev_cubic_c2: None, + } + } + + fn finish(self) -> String { + self.out + } + + fn subpath(&mut self, sub: &SubPath) { + for cmd in &sub.commands { + match *cmd { + PathCmd::MoveTo(p) => self.move_to(p), + PathCmd::LineTo(p) => self.line_to(p), + PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e), + PathCmd::Close => { + self.out.push('Z'); + self.prev_cubic_c2 = None; + } + } + } + } + + fn move_to(&mut self, p: PointF64) { + if !self.started { + // First move is always absolute. + let token = format!("M{}", self.coord(p)); + self.out.push_str(&token); + self.started = true; + } else { + let abs = format!("M{}", self.coord(p)); + let token = if self.relative { + let rel = format!("m{}", self.coord_delta(p)); + shorter(abs, rel) + } else { + abs + }; + self.out.push_str(&token); + } + self.cur = p; + self.prev_cubic_c2 = None; + } + + fn line_to(&mut self, p: PointF64) { + let mut candidates: Vec = Vec::new(); + + // Axis-aligned shorthands. + if self.shorthands { + if p.y == self.cur.y { + candidates.push(format!("H{}", self.num(p.x))); + if self.relative { + candidates.push(format!("h{}", self.num(p.x - self.cur.x))); + } + } + if p.x == self.cur.x { + candidates.push(format!("V{}", self.num(p.y))); + if self.relative { + candidates.push(format!("v{}", self.num(p.y - self.cur.y))); + } + } + } + + candidates.push(format!("L{}", self.coord(p))); + if self.relative { + candidates.push(format!("l{}", self.coord_delta(p))); + } + + self.out.push_str(&shortest(candidates)); + self.cur = p; + self.prev_cubic_c2 = None; + } + + fn cubic_to(&mut self, c1: PointF64, c2: PointF64, e: PointF64) { + let mut candidates: Vec = Vec::new(); + + // Smooth continuation: c1 is the reflection of the previous cubic's c2. + if self.shorthands { + if let Some(prev_c2) = self.prev_cubic_c2 { + let reflection = PointF64 { + x: 2.0 * self.cur.x - prev_c2.x, + y: 2.0 * self.cur.y - prev_c2.y, + }; + if approx(reflection, c1) { + candidates.push(format!( + "S{}", + self.coord_list(&[c2, e]) + )); + if self.relative { + candidates.push(format!( + "s{}", + self.delta_list(&[c2, e]) + )); + } + } + } + } + + candidates.push(format!("C{}", self.coord_list(&[c1, c2, e]))); + if self.relative { + candidates.push(format!("c{}", self.delta_list(&[c1, c2, e]))); + } + + self.out.push_str(&shortest(candidates)); + self.cur = e; + self.prev_cubic_c2 = Some(c2); + } + + // --- number/coordinate formatting ------------------------------------- + + fn num(&self, v: f64) -> String { + fmt_num(v, self.precision) + } + + /// Absolute coordinate pair. + fn coord(&self, p: PointF64) -> String { + join_nums(&[self.num(p.x), self.num(p.y)]) + } + + /// Delta coordinate pair relative to the current point. + fn coord_delta(&self, p: PointF64) -> String { + join_nums(&[self.num(p.x - self.cur.x), self.num(p.y - self.cur.y)]) + } + + /// Absolute list of points, flattened. + fn coord_list(&self, pts: &[PointF64]) -> String { + let mut nums = Vec::with_capacity(pts.len() * 2); + for p in pts { + nums.push(self.num(p.x)); + nums.push(self.num(p.y)); + } + join_nums(&nums) + } + + /// Delta list of points relative to the current point (all deltas are from + /// `cur`, matching SVG's relative-command semantics for multi-point ops). + fn delta_list(&self, pts: &[PointF64]) -> String { + let mut nums = Vec::with_capacity(pts.len() * 2); + for p in pts { + nums.push(self.num(p.x - self.cur.x)); + nums.push(self.num(p.y - self.cur.y)); + } + join_nums(&nums) + } +} + +fn approx(a: PointF64, b: PointF64) -> bool { + (a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6 +} + +fn shorter(a: String, b: String) -> String { + if b.len() < a.len() { + b + } else { + a + } +} + +fn shortest(candidates: Vec) -> String { + candidates + .into_iter() + .min_by_key(|s| s.len()) + .unwrap_or_default() +} + +/// Join formatted numbers with the minimal separators SVG allows: a comma, +/// except that a leading `-` is self-separating. +fn join_nums(nums: &[String]) -> String { + let mut s = String::new(); + for (i, n) in nums.iter().enumerate() { + if i > 0 && !n.starts_with('-') { + s.push(','); + } + s.push_str(n); + } + s +} + +/// Compact number formatting: round to precision, trim trailing zeros, use a +/// leading-dot for magnitudes below 1. +fn fmt_num(v: f64, precision: Option) -> String { + let v = match precision { + Some(p) => { + let factor = 10f64.powi(p as i32); + (v * factor).round() / factor + } + None => v, + }; + // Normalize -0.0 to 0. + if v == 0.0 { + return "0".to_string(); + } + + let mut s = match precision { + Some(p) => format!("{:.*}", p as usize, v), + None => format!("{v}"), + }; + + if s.contains('.') { + while s.ends_with('0') { + s.pop(); + } + if s.ends_with('.') { + s.pop(); + } + } + + if let Some(rest) = s.strip_prefix("0.") { + s = format!(".{rest}"); + } else if let Some(rest) = s.strip_prefix("-0.") { + s = format!("-.{rest}"); + } + + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::{MultiPath, Paint, PathCmd, Shape, SubPath}; + use visioncortex::Color; + + #[test] + fn number_formatting() { + assert_eq!(fmt_num(0.0, Some(2)), "0"); + assert_eq!(fmt_num(-0.0, Some(2)), "0"); + assert_eq!(fmt_num(1.50, Some(2)), "1.5"); + assert_eq!(fmt_num(0.5, Some(2)), ".5"); + assert_eq!(fmt_num(-0.5, Some(2)), "-.5"); + assert_eq!(fmt_num(2.0, Some(2)), "2"); + assert_eq!(fmt_num(3.14159, Some(2)), "3.14"); + } + + #[test] + fn join_omits_separator_before_negative() { + let nums = vec!["1".to_string(), "-2".to_string(), "3".to_string()]; + assert_eq!(join_nums(&nums), "1-2,3"); + } + + fn square_shape() -> Shape { + use visioncortex::PointF64; + let p = |x, y| PointF64 { x, y }; + let mut sub = SubPath::new(); + sub.commands = vec![ + PathCmd::MoveTo(p(0.0, 0.0)), + PathCmd::LineTo(p(10.0, 0.0)), + PathCmd::LineTo(p(10.0, 10.0)), + PathCmd::LineTo(p(0.0, 10.0)), + PathCmd::Close, + ]; + Shape { + paint: Paint::Solid(Color::new(255, 0, 0)), + path: MultiPath { subpaths: vec![sub] }, + } + } + + #[test] + fn encodes_axis_aligned_shorthands() { + let writer = SvgWriter { + relative: true, + shorthands: true, + precision: Some(2), + }; + let d = writer.encode_path(&square_shape()); + // Horizontal/vertical lines collapse to H/V/h/v; first move is absolute. + assert!(d.starts_with("M0,0")); + assert!(d.contains('H') || d.contains('h')); + assert!(d.contains('V') || d.contains('v')); + assert!(d.ends_with('Z')); + } + + #[test] + fn absolute_mode_uses_no_relative_commands() { + let writer = SvgWriter { + relative: false, + shorthands: false, + precision: Some(2), + }; + let d = writer.encode_path(&square_shape()); + assert!(!d.contains('l')); + assert!(!d.contains('c')); + assert!(d.contains('L')); + } +} diff --git a/crates/vtracer/tests/pipeline.rs b/crates/vtracer/tests/pipeline.rs new file mode 100644 index 0000000..1aeb568 --- /dev/null +++ b/crates/vtracer/tests/pipeline.rs @@ -0,0 +1,88 @@ +//! End-to-end pipeline smoke tests over synthetic images. + +use vtracer::{ColorImage, ColorMode, Config, FitMode, Hierarchical}; + +/// Build a `size × size` image split into two vertical color bands. +fn two_band_image(size: usize) -> ColorImage { + let mut pixels = Vec::with_capacity(size * size * 4); + for _y in 0..size { + for x in 0..size { + let (r, g, b) = if x < size / 2 { + (220, 40, 40) + } else { + (40, 40, 220) + }; + pixels.extend_from_slice(&[r, g, b, 255]); + } + } + ColorImage { + pixels, + width: size, + height: size, + } +} + +fn assert_valid_svg(svg: &str) { + assert!(svg.contains(" element:\n{svg}"); + assert!(svg.trim_end().ends_with(""), "missing close"); + assert!(svg.contains(" opt0 {}", sizes[1], sizes[0]); + assert!(sizes[2] <= sizes[0], "opt2 {} > opt0 {}", sizes[2], sizes[0]); +} + +#[test] +fn cutout_is_reported_unsupported() { + let config = Config { + hierarchical: Hierarchical::Cutout, + ..Config::default() + }; + let err = config.build().err().expect("cutout should be unsupported"); + assert!(err.to_string().contains("mosaic")); +}