//! 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::{Clustering, Config, FitMode, Hierarchical, Preset}; /// Convert an image into vector graphics. #[derive(Parser, Debug)] #[command(name = "vtracer", version, about, rename_all = "kebab-case")] struct Args { /// Input raster image (positional; or use --input). #[arg(value_name = "INPUT")] input_pos: Option, /// Output SVG (positional; or use --output). #[arg(value_name = "OUTPUT")] output_pos: Option, /// Path to the input raster image. #[arg(short = 'i', long = "input", value_name = "INPUT")] input: Option, /// Path to the output SVG. #[arg(short = 'o', long = "output", value_name = "OUTPUT")] output: Option, /// Start from a preset: bw, poster, photo. #[arg(long)] preset: Option, /// Region forming: `color-cluster` (default), `bw`, or `watershed`. #[arg(long)] clustering: Option, /// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic). #[arg(long)] hierarchical: Option, /// Curve-fitting mode: pixel, polygon, spline. #[arg(short, long)] mode: Option, /// Discard patches smaller than X px in size (0..=128). #[arg(short = 'f', long, value_parser = clap::value_parser!(i64).range(0..=128))] filter_speckle: Option, /// Significant bits per RGB channel (1..=8). #[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(1..=8))] color_precision: Option, /// Color difference between gradient layers (0..=255). #[arg(short = 'g', long, value_parser = clap::value_parser!(i64).range(0..=255))] gradient_step: Option, /// Minimum momentary angle (degrees) to be a corner (0..=180). /// /// Hidden from help: a fine-tuning knob few conversions need — the /// default (60) serves; `--simplify` is the knob worth reaching for. #[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))] corner_threshold: Option, /// Subdivide until all segments are shorter than this length (3.5..=10). /// /// Hidden from help: with `--simplify` reducing anchors by an explicit /// error tolerance, this legacy knob's effect on output is negligible. #[arg(long, hide = true, value_parser = parse_segment_length)] segment_length: Option, /// Minimum angle displacement (degrees) to splice a spline (0..=180). /// /// Hidden from help: a fine-tuning knob few conversions need — the /// default (45) serves; `--simplify` is the knob worth reaching for. #[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))] splice_threshold: Option, /// Simplify curves: fewest cubics within this tolerance in px (try 1-2.5). #[arg(long, value_name = "TOLERANCE", value_parser = parse_simplify_tolerance)] simplify: Option, /// Decimal places to use in path coordinates. #[arg(long)] path_precision: Option, /// Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'. #[arg(long)] palette: Option, /// Fixed palette from a file (one hex color per line or comma-separated). #[arg(long)] palette_file: Option, /// Auto-quantize to at most N colors. #[arg(long)] max_colors: Option, /// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping. #[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))] optimize: Option, /// Binary mode: fixed threshold (0..=255); foreground when intensity is below it. #[arg(long, value_parser = clap::value_parser!(u8))] threshold: Option, /// Binary mode: use Bradley–Roth adaptive thresholding (handles uneven lighting). #[arg(long)] adaptive: bool, /// Adaptive window side length in px (0 = auto). Implies --adaptive. #[arg(long)] adaptive_window: Option, /// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive. #[arg(long)] adaptive_t: Option, /// Watershed clustering: hierarchy cut level (0..=255, higher = more regions). #[arg(long, value_parser = clap::value_parser!(u8))] watershed_detail: Option, } fn parse_simplify_tolerance(s: &str) -> Result { let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?; if !v.is_finite() || v <= 0.0 { return Err(format!("simplify tolerance {v} must be positive")); } Ok(v) } fn parse_segment_length(s: &str) -> Result { let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?; if !(3.5..=10.0).contains(&v) { return Err(format!("segment length {v} is out of range [3.5, 10]")); } Ok(v) } /// Parse a comma/whitespace/newline separated list of `#rrggbb` colors. fn parse_palette(text: &str) -> Result, String> { let mut colors = Vec::new(); for token in text.split(|c: char| c == ',' || c.is_whitespace()) { let token = token.trim(); if token.is_empty() { continue; } colors.push(parse_hex_color(token)?); } Ok(colors) } fn parse_hex_color(token: &str) -> Result { let hex = token.strip_prefix('#').unwrap_or(token); if hex.len() != 6 { return Err(format!("`{token}` is not a #rrggbb color")); } let parse = |range: std::ops::Range| { u8::from_str_radix(&hex[range], 16).map_err(|_| format!("`{token}` is not a #rrggbb color")) }; Ok(Color::new(parse(0..2)?, parse(2..4)?, parse(4..6)?)) } fn build_config(args: &Args) -> Result { let mut config = match args.preset { Some(preset) => Config::from_preset(preset), None => Config::default(), }; if let Some(v) = args.clustering { config.clustering = 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.simplify.is_some() { config.simplify = args.simplify; } 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); } // Binary thresholding: --adaptive (or either adaptive tuning flag) selects // Bradley–Roth; otherwise --threshold tunes the fixed cutoff. if let Some(v) = args.threshold { config.binary_threshold = v; } if args.adaptive || args.adaptive_window.is_some() || args.adaptive_t.is_some() { config.binary_adaptive = true; } if let Some(v) = args.adaptive_window { config.binary_adaptive_window = v; } if let Some(v) = args.adaptive_t { config.binary_adaptive_t = v; } if let Some(v) = args.watershed_detail { config.watershed_detail = v; } // Palette: inline flag wins over file; both parse to a color list. if let Some(text) = &args.palette { config.palette = parse_palette(text)?; } else if let Some(path) = &args.palette_file { let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read palette file: {e}"))?; config.palette = parse_palette(&text)?; } Ok(config) } fn read_image(path: &std::path::Path) -> Result { let img = image::open(path) .map_err(|_| "no image file found at specified input path".to_string())? .to_rgba8(); let (width, height) = (img.width() as usize, img.height() as usize); Ok(ColorImage { pixels: img.into_raw(), width, height, }) } fn run() -> Result<(), String> { let args = Args::parse(); // Accept input/output as positionals (`vtracer in.png out.svg`) or as // named flags; an explicit flag takes precedence over the positional. let input = args .input .as_ref() .or(args.input_pos.as_ref()) .ok_or("no input path given (positional or --input)")?; let output = args .output .as_ref() .or(args.output_pos.as_ref()) .ok_or("no output path given (positional or --output)")?; let config = build_config(&args)?; let pipeline = config.build().map_err(|e| e.to_string())?; let img = read_image(input)?; let svg = pipeline.to_svg(&img).map_err(|e| e.to_string())?; std::fs::write(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 } } }