mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-28 15:01:20 -07:00
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:
@@ -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"] }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)?))
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user