Add a function to do in-memory conversion (#59)

* Move path handling out of conversion functions

This lets us decouple file reading/writing from the actual conversion.

* Move Config::from_args() to main.rs

* Remove path support from Config

Instead the input_path and output_path have to be passed to
convert_image_to_svg() manually.

* Add a simplified convert() function

This lets the user convert an image from memory, without encoding it to
PNG, writing it to a file, then reopening this file and decoding it
using the image crate.

It also allows the user to not write a SVG directly to a file.
This commit is contained in:
linkmauve
2023-11-12 21:28:05 +01:00
committed by GitHub
parent b2cd1a9524
commit 37a2570f49
5 changed files with 249 additions and 270 deletions

View File

@@ -1,6 +1,4 @@
use std::str::FromStr;
use std::path::PathBuf;
use clap::{Arg, App};
use visioncortex::PathSimplifyMode;
pub enum Preset {
@@ -21,8 +19,6 @@ pub enum Hierarchical {
/// Converter config
pub struct Config {
pub input_path: PathBuf,
pub output_path: PathBuf,
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
pub filter_speckle: usize,
@@ -37,8 +33,6 @@ pub struct Config {
}
pub(crate) struct ConverterConfig {
pub input_path: PathBuf,
pub output_path: PathBuf,
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
pub filter_speckle_area: usize,
@@ -55,8 +49,6 @@ pub(crate) struct ConverterConfig {
impl Default for Config {
fn default() -> Self {
Self {
input_path: PathBuf::default(),
output_path: PathBuf::default(),
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
mode: PathSimplifyMode::Spline,
@@ -109,225 +101,10 @@ impl FromStr for Preset {
}
}
fn path_simplify_mode_from_str(s: &str) -> PathSimplifyMode {
match s {
"polygon" => PathSimplifyMode::Polygon,
"spline" => PathSimplifyMode::Spline,
"none" => PathSimplifyMode::None,
_ => panic!("unknown PathSimplifyMode {}", s),
}
}
impl Config {
pub fn from_args() -> Self {
let app = App::new("visioncortex VTracer ".to_owned() + env!("CARGO_PKG_VERSION"))
.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("hierarchical")
.long("hierarchical")
.takes_value(true)
.help(
"Hierarchical clustering `stacked` (default) or non-stacked `cutout`. \
Only applies to color mode. "
));
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`"));
let app = app.arg(Arg::with_name("path_precision")
.long("path_precision")
.takes_value(true)
.help("Number of decimal places to use in path string"));
// 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::from_str(value).unwrap(), 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 = ColorMode::from_str(if value.trim() == "bw" || value.trim() == "BW" {"binary"} else {"color"}).unwrap()
}
if let Some(value) = matches.value_of("hierarchical") {
config.hierarchical = Hierarchical::from_str(value).unwrap()
}
if let Some(value) = matches.value_of("mode") {
let value = value.trim();
config.mode = path_simplify_mode_from_str(if value == "pixel" {
"none"
} else if value == "polygon" {
"polygon"
} else if value == "spline" {
"spline"
} else {
panic!("Parser Error: Curve fitting mode is invalid: {}", value);
});
}
if let Some(value) = matches.value_of("filter_speckle") {
if value.trim().parse::<usize>().is_ok() { // is numeric
let value = value.trim().parse::<usize>().unwrap();
if value > 16 {
panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [0,16].", value);
}
config.filter_speckle = value;
} else {
panic!("Parser Error: Filter speckle is not a positive integer: {}.", value);
}
}
if let Some(value) = matches.value_of("color_precision") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("gradient_step") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("corner_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("segment_length") {
if value.trim().parse::<f64>().is_ok() { // is numeric
let value = value.trim().parse::<f64>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("splice_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("path_precision") {
if value.trim().parse::<u32>().is_ok() { // is numeric
let value = value.trim().parse::<u32>().ok();
config.path_precision = value;
} else {
panic!("Parser Error: Path precision is not an unsigned integer: {}.", 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);
pub fn from_preset(preset: Preset) -> Self {
match preset {
Preset::Bw => Self {
input_path,
output_path,
color_mode: ColorMode::Binary,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
@@ -341,8 +118,6 @@ impl Config {
path_precision: Some(8),
},
Preset::Poster => Self {
input_path,
output_path,
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
@@ -356,8 +131,6 @@ impl Config {
path_precision: Some(8),
},
Preset::Photo => Self {
input_path,
output_path,
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 10,
@@ -375,8 +148,6 @@ impl Config {
pub(crate) fn into_converter_config(self) -> ConverterConfig {
ConverterConfig {
input_path: self.input_path,
output_path: self.output_path,
color_mode: self.color_mode,
hierarchical: self.hierarchical,
filter_speckle_area: self.filter_speckle * self.filter_speckle,

View File

@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::Path;
use std::{fs::File, io::Write};
use fastrand::Rng;
@@ -12,15 +12,22 @@ const NUM_UNUSED_COLOR_ITERATIONS: usize = 6;
/// the entire image will be keyed.
const KEYING_THRESHOLD: f32 = 0.2;
/// Convert an image file into svg file
pub fn convert_image_to_svg(config: Config) -> Result<(), String> {
/// Convert an in-memory image into an in-memory SVG
pub fn convert(img: ColorImage, config: Config) -> Result<SvgFile, String> {
let config = config.into_converter_config();
match config.color_mode {
ColorMode::Color => color_image_to_svg(config),
ColorMode::Binary => binary_image_to_svg(config),
ColorMode::Color => color_image_to_svg(img, config),
ColorMode::Binary => binary_image_to_svg(img, config),
}
}
/// Convert an image file into svg file
pub fn convert_image_to_svg(input_path: &Path, output_path: &Path, config: Config) -> Result<(), String> {
let img = read_image(input_path)?;
let svg = convert(img, config)?;
write_svg(svg, output_path)
}
fn color_exists_in_image(img: &ColorImage, color: Color) -> bool {
for y in 0..img.height {
for x in 0..img.width {
@@ -81,16 +88,9 @@ fn should_key_image(img: &ColorImage) -> bool {
false
}
fn color_image_to_svg(config: ConverterConfig) -> Result<(), String> {
let (mut 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),
}
fn color_image_to_svg(mut img: ColorImage, config: ConverterConfig) -> Result<SvgFile, String> {
let width = img.width;
let height = img.height;
let key_color = if should_key_image(&img) {
let key_color = find_unused_color_in_image(&img)?;
@@ -166,21 +166,13 @@ fn color_image_to_svg(config: ConverterConfig) -> Result<(), String> {
svg.add_path(paths, cluster.residue_color());
}
write_svg(svg, config.output_path)
Ok(svg)
}
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),
}
fn binary_image_to_svg(img: ColorImage, config: ConverterConfig) -> Result<SvgFile, String> {
let img = img.to_binary_image(|x| x.r < 128);
let width = img.width;
let height = img.height;
let clusters = img.to_clusters(false);
@@ -199,10 +191,10 @@ fn binary_image_to_svg(config: ConverterConfig) -> Result<(), String> {
}
}
write_svg(svg, config.output_path)
Ok(svg)
}
fn read_image(input_path: PathBuf) -> Result<(ColorImage, usize, usize), String> {
fn read_image(input_path: &Path) -> Result<ColorImage, String> {
let img = image::open(input_path);
let img = match img {
Ok(file) => file.to_rgba8(),
@@ -212,10 +204,10 @@ fn read_image(input_path: PathBuf) -> Result<(ColorImage, usize, usize), String>
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))
Ok(img)
}
fn write_svg(svg: SvgFile, output_path: PathBuf) -> Result<(), String> {
fn write_svg(svg: SvgFile, output_path: &Path) -> Result<(), String> {
let out_file = File::create(output_path);
let mut out_file = match out_file {
Ok(file) => file,

View File

@@ -19,3 +19,4 @@ pub use converter::*;
pub use svg::*;
#[cfg(feature = "python-binding")]
pub use python::*;
pub use visioncortex::ColorImage;

View File

@@ -2,9 +2,226 @@ mod config;
mod converter;
mod svg;
use std::str::FromStr;
use std::path::PathBuf;
use clap::{Arg, App};
use visioncortex::PathSimplifyMode;
use config::{Config, Preset, ColorMode, Hierarchical};
fn path_simplify_mode_from_str(s: &str) -> PathSimplifyMode {
match s {
"polygon" => PathSimplifyMode::Polygon,
"spline" => PathSimplifyMode::Spline,
"none" => PathSimplifyMode::None,
_ => panic!("unknown PathSimplifyMode {}", s),
}
}
pub fn config_from_args() -> (PathBuf, PathBuf, Config) {
let app = App::new("visioncortex VTracer ".to_owned() + env!("CARGO_PKG_VERSION"))
.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("hierarchical")
.long("hierarchical")
.takes_value(true)
.help(
"Hierarchical clustering `stacked` (default) or non-stacked `cutout`. \
Only applies to color mode. "
));
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`"));
let app = app.arg(Arg::with_name("path_precision")
.long("path_precision")
.takes_value(true)
.help("Number of decimal places to use in path string"));
// 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.");
let input_path = PathBuf::from(input_path);
let output_path = PathBuf::from(output_path);
if let Some(value) = matches.value_of("preset") {
config = Config::from_preset(Preset::from_str(value).unwrap());
}
if let Some(value) = matches.value_of("color_mode") {
config.color_mode = ColorMode::from_str(if value.trim() == "bw" || value.trim() == "BW" {"binary"} else {"color"}).unwrap()
}
if let Some(value) = matches.value_of("hierarchical") {
config.hierarchical = Hierarchical::from_str(value).unwrap()
}
if let Some(value) = matches.value_of("mode") {
let value = value.trim();
config.mode = path_simplify_mode_from_str(if value == "pixel" {
"none"
} else if value == "polygon" {
"polygon"
} else if value == "spline" {
"spline"
} else {
panic!("Parser Error: Curve fitting mode is invalid: {}", value);
});
}
if let Some(value) = matches.value_of("filter_speckle") {
if value.trim().parse::<usize>().is_ok() { // is numeric
let value = value.trim().parse::<usize>().unwrap();
if value > 16 {
panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [0,16].", value);
}
config.filter_speckle = value;
} else {
panic!("Parser Error: Filter speckle is not a positive integer: {}.", value);
}
}
if let Some(value) = matches.value_of("color_precision") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("gradient_step") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("corner_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("segment_length") {
if value.trim().parse::<f64>().is_ok() { // is numeric
let value = value.trim().parse::<f64>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("splice_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().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: {}.", value);
}
}
if let Some(value) = matches.value_of("path_precision") {
if value.trim().parse::<u32>().is_ok() { // is numeric
let value = value.trim().parse::<u32>().ok();
config.path_precision = value;
} else {
panic!("Parser Error: Path precision is not an unsigned integer: {}.", value);
}
}
(input_path, output_path, config)
}
fn main() {
let config = config::Config::from_args();
let result = converter::convert_image_to_svg(config);
let (input_path, output_path, config) = config_from_args();
let result = converter::convert_image_to_svg(&input_path, &output_path, config);
match result {
Ok(()) => {
println!("Conversion successful.");

View File

@@ -53,8 +53,6 @@ fn convert_image_to_svg_py(
let max_iterations = max_iterations.unwrap_or(10);
let config = Config {
input_path,
output_path,
color_mode,
hierarchical,
filter_speckle,
@@ -69,7 +67,7 @@ fn convert_image_to_svg_py(
..Default::default()
};
convert_image_to_svg(config).unwrap();
convert_image_to_svg(&input_path, &output_path, config).unwrap();
Ok(())
}