diff --git a/Cargo.toml b/Cargo.toml index 2ba28e0..565e5ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "cmdapp", "webapp", ] \ No newline at end of file diff --git a/cmdapp/.gitignore b/cmdapp/.gitignore new file mode 100644 index 0000000..18e5178 --- /dev/null +++ b/cmdapp/.gitignore @@ -0,0 +1,3 @@ +*.svg +*.png +*.jpg \ No newline at end of file diff --git a/cmdapp/Cargo.toml b/cmdapp/Cargo.toml new file mode 100644 index 0000000..e1ee0f1 --- /dev/null +++ b/cmdapp/Cargo.toml @@ -0,0 +1,15 @@ + [package] + name = "vtracer" + version = "0.1.0" + authors = ["Chris Tsang "] + edition = "2018" + description = "A cmd app to convert images into vector graphics." + homepage = "http://www.visioncortex.org/vtracer" + repository = "https://github.com/visioncortex/vtracer/" + categories = ["graphics"] + keywords = ["svg", "computer-graphics"] + + [dependencies] + clap = "2.33.3" + image = "0.23.10" + visioncortex = { path = "../visioncortex" } \ No newline at end of file diff --git a/cmdapp/src/config.rs b/cmdapp/src/config.rs new file mode 100644 index 0000000..ba26490 --- /dev/null +++ b/cmdapp/src/config.rs @@ -0,0 +1,297 @@ +use std::path::PathBuf; +use clap::{Arg, App}; +use visioncortex::path::PathSimplifyMode; +use super::util::{path_simplify_mode, preset, deg2rad}; + +pub enum Preset { + Bw, + Poster, + Photo +} + +#[derive(Debug)] +pub struct Config { + pub input_path: PathBuf, + pub output_path: PathBuf, + pub color_mode: String, + pub filter_speckle: usize, + pub color_precision: i32, + pub layer_difference: i32, + pub mode: PathSimplifyMode, + pub corner_threshold: i32, + pub length_threshold: f64, + pub max_iterations: usize, + pub splice_threshold: i32, +} + +#[derive(Debug)] +pub struct ConverterConfig { + pub input_path: PathBuf, + pub output_path: PathBuf, + pub color_mode: String, + pub filter_speckle_area: usize, + pub color_precision_loss: i32, + pub layer_difference: i32, + pub mode: PathSimplifyMode, + pub corner_threshold: f64, + pub length_threshold: f64, + pub max_iterations: usize, + pub splice_threshold: f64, +} + +impl Default for Config { + fn default() -> Self { + Self { + input_path: PathBuf::default(), + output_path: PathBuf::default(), + color_mode: String::from("color"), + mode: path_simplify_mode("spline"), + filter_speckle: 4, + color_precision: 6, + layer_difference: 16, + corner_threshold: 60, + length_threshold: 4.0, + splice_threshold: 45, + max_iterations: 10, + } + } +} + +impl Config { + pub fn from_args() -> Self { + let app = App::new("visioncortex VTracer").about("A cmd app to convert images into vector graphics."); + + let app = app.arg(Arg::with_name("input") + .long("input") + .short("i") + .takes_value(true) + .help("Path to input raster image") + .required(true)); + + let app = app.arg(Arg::with_name("output") + .long("output") + .short("o") + .takes_value(true) + .help("Path to output vector graphics") + .required(true)); + + let app = app.arg(Arg::with_name("color_mode") + .long("colormode") + .takes_value(true) + .help("True color image `color` (default) or Binary image `bw`")); + + let app = app.arg(Arg::with_name("preset") + .long("preset") + .takes_value(true) + .help("Use one of the preset configs `bw`, `poster`, `photo`")); + + let app = app.arg(Arg::with_name("filter_speckle") + .long("filter_speckle") + .short("f") + .takes_value(true) + .help("Discard patches smaller than X px in size")); + + let app = app.arg(Arg::with_name("color_precision") + .long("color_precision") + .short("p") + .takes_value(true) + .help("Number of significant bits to use in an RGB channel")); + + let app = app.arg(Arg::with_name("gradient_step") + .long("gradient_step") + .short("g") + .takes_value(true) + .help("Color difference between gradient layers")); + + let app = app.arg(Arg::with_name("corner_threshold") + .long("corner_threshold") + .short("c") + .takes_value(true) + .help("Minimum momentary angle (degree) to be considered a corner")); + + let app = app.arg(Arg::with_name("segment_length") + .long("segment_length") + .short("l") + .takes_value(true) + .help("Perform iterative subdivide smooth until all segments are shorter than this length")); + + let app = app.arg(Arg::with_name("splice_threshold") + .long("splice_threshold") + .short("s") + .takes_value(true) + .help("Minimum angle displacement (degree) to splice a spline")); + + let app = app.arg(Arg::with_name("mode") + .long("mode") + .short("m") + .takes_value(true) + .help("Curver fitting mode `pixel`, `polygon`, `spline`")); + + // Extract matches + let matches = app.get_matches(); + + let mut config = Config::default(); + let input_path = matches.value_of("input").expect("Input path is required, please specify it by --input or -i."); + let output_path = matches.value_of("output").expect("Output path is required, please specify it by --output or -o."); + + if let Some(value) = matches.value_of("preset") { + config = Self::from_preset(preset(value), input_path, output_path); + } + + config.input_path = PathBuf::from(input_path); + config.output_path = PathBuf::from(output_path); + + if let Some(value) = matches.value_of("color_mode") { + config.color_mode = String::from(if value.trim() == "bw" || value.trim() == "BW" {"binary"} else {"color"}) + } + + if let Some(value) = matches.value_of("mode") { + let value = value.trim(); + config.mode = path_simplify_mode(if value == "pixel" { + "none" + } else if value == "polygon" { + "polygon" + } else if value == "spline" { + "spline" + } else { + panic!("Parser Error: Curve fitting mode is invalid with value {}", value); + }); + } + + if let Some(value) = matches.value_of("filter_speckle") { + if value.trim().parse::().is_ok() { // is numeric + let value = value.trim().parse::().unwrap(); + if value < 1 || value > 16 { + panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [1,16].", value); + } + config.filter_speckle = value; + } else { + panic!("Parser Error: Filter speckle is not a positive integer with value {}.", value); + } + } + + if let Some(value) = matches.value_of("color_precision") { + if value.trim().parse::().is_ok() { // is numeric + let value = value.trim().parse::().unwrap(); + if value < 1 || value > 8 { + panic!("Out of Range Error: Color precision is invalid at {}. It must be within [1,8].", value); + } + config.color_precision = value; + } else { + panic!("Parser Error: Color precision is not an integer with value {}.", value); + } + } + + if let Some(value) = matches.value_of("gradient_step") { + if value.trim().parse::().is_ok() { // is numeric + let value = value.trim().parse::().unwrap(); + if value < 0 || value > 255 { + panic!("Out of Range Error: Gradient step is invalid at {}. It must be within [0,255].", value); + } + config.layer_difference = value; + } else { + panic!("Parser Error: Gradient step is not an integer with value {}.", value); + } + } + + if let Some(value) = matches.value_of("corner_threshold") { + if value.trim().parse::().is_ok() { // is numeric + let value = value.trim().parse::().unwrap(); + if value < 0 || value > 180 { + panic!("Out of Range Error: Corner threshold is invalid at {}. It must be within [0,180].", value); + } + config.corner_threshold = value + } else { + panic!("Parser Error: Corner threshold is not numeric with value {}.", value); + } + } + + if let Some(value) = matches.value_of("segment_length") { + if value.trim().parse::().is_ok() { // is numeric + let value = value.trim().parse::().unwrap(); + if value < 3.5 || value > 10.0 { + panic!("Out of Range Error: Segment length is invalid at {}. It must be within [3.5,10].", value); + } + config.length_threshold = value; + } else { + panic!("Parser Error: Segment length is not numeric with value {}.", value); + } + } + + if let Some(value) = matches.value_of("splice_threshold") { + if value.trim().parse::().is_ok() { // is numeric + let value = value.trim().parse::().unwrap(); + if value < 0 || value > 180 { + panic!("Out of Range Error: Segment length is invalid at {}. It must be within [0,180].", value); + } + config.splice_threshold = value; + } else { + panic!("Parser Error: Segment length is not numeric with value {}.", value); + } + } + + config + } + + pub fn from_preset(preset: Preset, input_path: &str, output_path: &str) -> Self { + let input_path = PathBuf::from(input_path); + let output_path = PathBuf::from(output_path); + match preset { + Preset::Bw => Self { + input_path, + output_path, + color_mode: String::from("binary"), + filter_speckle: 4, + color_precision: 6, + layer_difference: 16, + mode: path_simplify_mode("spline"), + corner_threshold: 60, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: 45, + }, + Preset::Poster => Self { + input_path, + output_path, + color_mode: String::from("color"), + filter_speckle: 4, + color_precision: 8, + layer_difference: 16, + mode: path_simplify_mode("spline"), + corner_threshold: 60, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: 45, + }, + Preset::Photo => Self { + input_path, + output_path, + color_mode: String::from("color"), + filter_speckle: 10, + color_precision: 8, + layer_difference: 48, + mode: path_simplify_mode("spline"), + corner_threshold: 180, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: 45, + } + } + } + + pub fn into_converter_config(self) -> ConverterConfig { + ConverterConfig { + input_path: self.input_path, + output_path: self.output_path, + color_mode: self.color_mode, + filter_speckle_area: self.filter_speckle * self.filter_speckle, + color_precision_loss: 8 - self.color_precision, + layer_difference: self.layer_difference, + mode: self.mode, + corner_threshold: deg2rad(self.corner_threshold), + length_threshold: self.length_threshold, + max_iterations: self.max_iterations, + splice_threshold: deg2rad(self.splice_threshold), + } + } +} diff --git a/cmdapp/src/converter.rs b/cmdapp/src/converter.rs new file mode 100644 index 0000000..da01b94 --- /dev/null +++ b/cmdapp/src/converter.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; +use std::{fs::File, io::Write}; + +use visioncortex::Color; +use visioncortex::{ColorName, color_clusters::RunnerConfig}; +use visioncortex::{ColorImage, color_clusters::Runner}; +use super::config::{Config, ConverterConfig}; +use super::svg::SvgFile; + +pub fn convert_image_to_svg(config: Config) -> Result<(), String> { + let config = config.into_converter_config(); + if config.color_mode == "color" { + color_image_to_svg(config) + } else if config.color_mode == "binary" { + binary_image_to_svg(config) + } else { + Err(format!("Unknown color mode: {}", config.color_mode)) + } +} + +pub fn color_image_to_svg(config: ConverterConfig) -> Result<(), String> { + let (img, width, height); + match read_image(config.input_path) { + Ok(values) => { + img = values.0; + width = values.1; + height = values.2; + }, + Err(msg) => return Err(msg), + } + + let runner = Runner::new(RunnerConfig { + batch_size: 25600, + good_min_area: config.filter_speckle_area, + good_max_area: (width * height), + is_same_color_a: config.color_precision_loss, + is_same_color_b: 1, + deepen_diff: config.layer_difference, + hollow_neighbours: 1, + }, img); + + let clusters = runner.run(); + + let view = clusters.view(); + + let mut svg = SvgFile::new(width, height); + for &cluster_index in view.clusters_output.iter().rev() { + let cluster = view.get_cluster(cluster_index); + let svg_path = cluster.to_svg_path( + &view, + false, + config.mode, + config.corner_threshold, + config.length_threshold, + config.max_iterations, + config.splice_threshold + ); + svg.add_path(svg_path, cluster.residue_color()); + } + + let out_file = File::create(config.output_path); + let mut out_file = match out_file { + Ok(file) => file, + Err(_) => return Err(String::from("Cannot create output file.")), + }; + + out_file.write_all(&svg.to_svg_file().as_bytes()).unwrap(); + + Ok(()) +} + +pub fn binary_image_to_svg(config: ConverterConfig) -> Result<(), String> { + + let (img, width, height); + match read_image(config.input_path) { + Ok(values) => { + img = values.0; + width = values.1; + height = values.2; + }, + Err(msg) => return Err(msg), + } + let img = img.to_binary_image(|x| x.r < 128); + + let clusters = img.to_clusters(false); + + let mut svg = SvgFile::new(width, height); + for i in 0..clusters.len() { + let cluster = clusters.get_cluster(i); + if cluster.size() >= config.filter_speckle_area { + let svg_path = cluster.to_svg_path( + config.mode, + config.corner_threshold, + config.length_threshold, + config.max_iterations, + config.splice_threshold, + ); + let color = Color::color(&ColorName::Black); + svg.add_path(svg_path, color); + } + } + + write_svg(svg, config.output_path) +} + +fn read_image(input_path: PathBuf) -> Result<(ColorImage, usize, usize), String> { + let img = image::open(input_path); + let img = match img { + Ok(file) => file.to_rgba(), + Err(_) => return Err(String::from("No image file found at specified input path")), + }; + + let (width, height) = (img.width() as usize, img.height() as usize); + let img = ColorImage {pixels: img.as_raw().to_vec(), width, height}; + + Ok((img, width, height)) +} + +fn write_svg(svg: SvgFile, output_path: PathBuf) -> Result<(), String> { + let out_file = File::create(output_path); + let mut out_file = match out_file { + Ok(file) => file, + Err(_) => return Err(String::from("Cannot create output file.")), + }; + + out_file.write_all(&svg.to_svg_file().as_bytes()).unwrap(); + + Ok(()) +} \ No newline at end of file diff --git a/cmdapp/src/lib.rs b/cmdapp/src/lib.rs new file mode 100644 index 0000000..74a8e01 --- /dev/null +++ b/cmdapp/src/lib.rs @@ -0,0 +1,9 @@ +mod config; +mod converter; +mod svg; +mod util; + +pub use config::*; +pub use converter::*; +pub use svg::*; +pub use util::*; \ No newline at end of file diff --git a/cmdapp/src/main.rs b/cmdapp/src/main.rs new file mode 100644 index 0000000..230d524 --- /dev/null +++ b/cmdapp/src/main.rs @@ -0,0 +1,14 @@ +use vtracer::{Config, convert_image_to_svg}; + +fn main() { + let config = Config::from_args(); + let result = convert_image_to_svg(config); + match result { + Ok(()) => { + println!("Conversion successful."); + }, + Err(msg) => { + panic!("Conversion failed with error message: {}", msg); + } + } +} \ No newline at end of file diff --git a/cmdapp/src/svg.rs b/cmdapp/src/svg.rs new file mode 100644 index 0000000..7abccb1 --- /dev/null +++ b/cmdapp/src/svg.rs @@ -0,0 +1,43 @@ +use visioncortex::Color; + +pub struct SvgPath { + path: String, + color: Color, +} + +pub struct SvgFile { + patches: Vec, + width: usize, + height: usize, +} + +impl SvgFile { + pub fn new(width: usize, height: usize) -> Self { + SvgFile { + patches: vec![], + width, + height, + } + } + + pub fn add_path(&mut self, path: String, color: Color) { + self.patches.push(SvgPath { + path, + color + }) + } + + pub fn to_svg_file(&self) -> String { + let mut result: Vec = vec![format!(r#" + + "#, self.width, self.height)]; + + for patch in &self.patches { + let color = patch.color; + result.push(format!("\n", patch.path, color.r, color.g, color.b)); + }; + + result.push(String::from("")); + result.concat() + } +} \ No newline at end of file diff --git a/cmdapp/src/util.rs b/cmdapp/src/util.rs new file mode 100644 index 0000000..06034c1 --- /dev/null +++ b/cmdapp/src/util.rs @@ -0,0 +1,24 @@ +use visioncortex::PathSimplifyMode; +use super::Preset; + +pub fn path_simplify_mode(s: &str) -> PathSimplifyMode { + match s { + "polygon" => PathSimplifyMode::Polygon, + "spline" => PathSimplifyMode::Spline, + "none" => PathSimplifyMode::None, + _ => panic!("unknown PathSimplifyMode {}", s), + } +} + +pub fn preset(s: &str) -> Preset { + match s { + "bw" => Preset::Bw, + "poster" => Preset::Poster, + "photo" => Preset::Photo, + _ => panic!("unknown Preset {}", s), + } +} + +pub fn deg2rad(deg: i32) -> f64 { + deg as f64 / 180.0 * std::f64::consts::PI +} \ No newline at end of file