Rewrite into a vectorization framework (pillars 1–4)

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, <g fill> 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.
This commit is contained in:
Chris Tsang
2026-07-23 22:27:24 +01:00
parent 913d0ac7e0
commit e46c971845
26 changed files with 2463 additions and 2 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
target target
Cargo.lock Cargo.lock
*.sublime* *.sublime*
.vscode .vscode
.DS_Store
+22 -1
View File
@@ -1,7 +1,28 @@
[workspace] [workspace]
members = [ 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", "cmdapp",
"webapp", "webapp",
] ]
resolver = "2"
resolver = "2"
[workspace.package]
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
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" }
+21
View File
@@ -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"] }
+209
View File
@@ -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<Preset>,
/// Color image (`color`) or binary image (`bw`).
#[arg(long = "colormode")]
colormode: Option<ColorMode>,
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
#[arg(long)]
hierarchical: Option<Hierarchical>,
/// Curve-fitting mode: pixel, polygon, spline.
#[arg(short, long)]
mode: Option<FitMode>,
/// 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<i64>,
/// Significant bits per RGB channel (1..=8).
#[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(1..=8))]
color_precision: Option<i64>,
/// Color difference between gradient layers (0..=255).
#[arg(short = 'g', long, value_parser = clap::value_parser!(i64).range(0..=255))]
gradient_step: Option<i64>,
/// 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<i64>,
/// Subdivide until all segments are shorter than this length (3.5..=10).
#[arg(short = 'l', long, value_parser = parse_segment_length)]
segment_length: Option<f64>,
/// 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<i64>,
/// Decimal places to use in path coordinates.
#[arg(long)]
path_precision: Option<u32>,
/// Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'.
#[arg(long)]
palette: Option<String>,
/// Fixed palette from a file (one hex color per line or comma-separated).
#[arg(long)]
palette_file: Option<PathBuf>,
/// Auto-quantize to at most N colors.
#[arg(long)]
max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
optimize: Option<u8>,
}
fn parse_segment_length(s: &str) -> Result<f64, String> {
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<Vec<Color>, 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<Color, String> {
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<usize>| {
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<Config, String> {
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<ColorImage, String> {
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
}
}
}
+18
View File
@@ -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
+28
View File
@@ -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<Layer> = 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;
}
}
+75
View File
@@ -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)));
}
}
+53
View File
@@ -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
}
}
+47
View File
@@ -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<Color>,
}
impl FixedPalette {
pub fn new(colors: Vec<Color>) -> 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<Oklab> = 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);
}
}
}
+148
View File
@@ -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<Sample>,
}
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<Sample> = 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<Color> = buckets.iter().map(Bucket::representative).collect();
let lab: Vec<Oklab> = 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);
}
}
}
+31
View File
@@ -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
}
+264
View File
@@ -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<u32>,
/// Fixed palette (empty = none). Takes priority over `max_colors`.
pub palette: Vec<Color>,
/// Auto-quantize target color count (None = off).
pub max_colors: Option<usize>,
/// 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<dyn Frontend> {
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<Box<dyn ColorFitter>> {
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<dyn CurveFitter> {
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<Box<dyn OptimizerPass>> {
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<Pipeline, Error> {
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<Self, Self::Err> {
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<Self, Self::Err> {
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<Self, Self::Err> {
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<Self, Self::Err> {
match s {
"bw" => Ok(Self::Bw),
"poster" => Ok(Self::Poster),
"photo" => Ok(Self::Photo),
_ => Err(format!("unknown preset {s}")),
}
}
}
+41
View File
@@ -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<String> 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())
}
}
+173
View File
@@ -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<PointF64> = 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
}
+63
View File
@@ -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<Segmentation, Error> {
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)
}
}
@@ -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<Segmentation, Error> {
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)
}
}
+105
View File
@@ -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<Color, Error> {
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);
}
}
}
}
+26
View File
@@ -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<Segmentation, Error>;
}
+34
View File
@@ -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,
}
}
}
+100
View File
@@ -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<Layer>,
}
impl Segmentation {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
layers: Vec::new(),
}
}
}
+90
View File
@@ -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<PathCmd>,
}
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<PointF64> {
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<SubPath>,
}
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<Shape>,
}
impl VectorDoc {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
shapes: Vec::new(),
}
}
}
+48
View File
@@ -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};
+207
View File
@@ -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<PathCmd>) -> 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);
}
}
+49
View File
@@ -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<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
pub writer: SvgWriter,
}
impl Pipeline {
/// Run the pipeline to the output document IR (before serialization).
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
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<String, Error> {
Ok(self.writer.write(&self.run(img)?))
}
}
+429
View File
@@ -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 `<g fill>` 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 `<g fill>` grouping.
pub shorthands: bool,
/// Decimal places for coordinates (`None` = full precision).
pub precision: Option<u32>,
}
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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(
out,
"<!-- Generator: visioncortex VTracer {} -->",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">",
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("</svg>\n");
out
}
/// Emit shapes, grouping maximal runs of consecutive same-fill shapes into
/// a single `<g fill>` (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, "<g fill=\"{}\">", fill);
for shape in run {
self.write_path(out, shape, false);
}
out.push_str("</g>\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,
"<path d=\"{}\" fill=\"{}\"/>",
d,
shape_fill(shape)
);
} else {
let _ = writeln!(out, "<path d=\"{}\"/>", 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<u32>,
out: String,
cur: PointF64,
started: bool,
/// Absolute second control point of the previous cubic, for `S` detection.
prev_cubic_c2: Option<PointF64>,
}
impl Emitter {
fn new(relative: bool, shorthands: bool, precision: Option<u32>) -> 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<String> = 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<String> = 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>) -> 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<u32>) -> 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'));
}
}
+88
View File
@@ -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("<svg"), "missing <svg> element:\n{svg}");
assert!(svg.trim_end().ends_with("</svg>"), "missing </svg> close");
assert!(svg.contains("<path"), "expected at least one path:\n{svg}");
}
#[test]
fn default_color_pipeline_produces_svg() {
let img = two_band_image(32);
let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn all_fit_modes_produce_svg() {
let img = two_band_image(32);
for mode in [FitMode::Pixel, FitMode::Polygon, FitMode::Spline] {
let config = Config {
mode,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
}
#[test]
fn binary_pipeline_produces_svg() {
let img = two_band_image(32);
let config = Config {
color_mode: ColorMode::Binary,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn optimize_levels_shrink_or_match() {
let img = two_band_image(48);
let mut sizes = Vec::new();
for level in [0u8, 1, 2] {
let config = Config {
optimize: level,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
sizes.push(svg.len());
}
// Higher optimization should never produce larger output than level 0.
assert!(sizes[1] <= sizes[0], "opt1 {} > 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"));
}