uzor-vectorize 1.5.5

PNG/JPEG → SVG tracer for flat-color graphics (uzor). Inverse of uzor-icon.
Documentation
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;

use uzor_vectorize::{
    diff_heatmap, hotspot_strip, parity_rgb, parity_sheet, quantized_image, rasterize_svg,
    vectorize_path, vis_hotspots, GpuMode, VectorizeOptions,
};

fn main() -> ExitCode {
    let mut args: Vec<String> = env::args().skip(1).collect();
    if args.is_empty() {
        eprintln!(
            "uzor-vectorize svg <in> -o <out.svg> [--preview p.png]\n\
             uzor-vectorize parity <in> -o <out.svg> [--preview p.png] [--diff d.png]\n\
             uzor-vectorize inspect <in.jpg> <in.svg> --dir <crops/> [--n 12]"
        );
        return ExitCode::from(2);
    }
    let cmd = args.remove(0);
    match cmd.as_str() {
        "svg" | "parity" => {
            if let Err(e) = run(&cmd, &args) {
                eprintln!("{e}");
                return ExitCode::from(1);
            }
        }
        "inspect" => {
            if let Err(e) = inspect(&args) {
                eprintln!("{e}");
                return ExitCode::from(1);
            }
        }
        _ => {
            eprintln!("unknown command {cmd}");
            return ExitCode::from(2);
        }
    }
    ExitCode::SUCCESS
}

fn run(cmd: &str, args: &[String]) -> Result<(), String> {
    let mut src: Option<PathBuf> = None;
    let mut out: Option<PathBuf> = None;
    let mut preview: Option<PathBuf> = None;
    let mut diff: Option<PathBuf> = None;
    let mut opt = VectorizeOptions::default();
    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "-o" | "--out" => {
                i += 1;
                out = Some(PathBuf::from(args.get(i).ok_or("-o needs a path")?));
            }
            "--preview" => {
                i += 1;
                preview = Some(PathBuf::from(args.get(i).ok_or("--preview needs a path")?));
            }
            "--diff" => {
                i += 1;
                diff = Some(PathBuf::from(args.get(i).ok_or("--diff needs a path")?));
            }
            "--colors" => {
                i += 1;
                opt.colors = args.get(i).ok_or("--colors")?.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
            }
            "--merge" => {
                i += 1;
                opt.merge = args.get(i).ok_or("--merge")?.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
            }
            "--min-area" => {
                i += 1;
                opt.min_area = args.get(i).ok_or("--min-area")?.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
            }
            "--epsilon" => {
                i += 1;
                opt.epsilon = args.get(i).ok_or("--epsilon")?.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
            }
            "--absorb-dist" => {
                i += 1;
                opt.absorb_dist = args.get(i).ok_or("--absorb-dist")?.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
            }
            "--kmeans-iters" => {
                i += 1;
                opt.kmeans_iters = args.get(i).ok_or("--kmeans-iters")?.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
            }
            "--majority" => {
                opt.majority = true;
            }
            "--tau" => {
                i += 1;
                opt.tau = args.get(i).ok_or("--tau")?.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
            }
            "--median-cut" => {
                opt.tau = 0.0;
                opt.slic = 0;
            }
            "--cpu" => {
                opt.gpu = GpuMode::Cpu;
            }
            "--hybrid" => {
                opt.gpu = GpuMode::Hybrid;
            }
            "--gpu" => {
                opt.gpu = GpuMode::Gpu;
            }
            "--slic" => {
                i += 1;
                opt.slic = args.get(i).ok_or("--slic")?.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
            }
            "--fz" => {
                opt.slic = 0;
            }
            s if s.starts_with('-') => return Err(format!("unknown flag {s}")),
            s => {
                if src.is_some() {
                    return Err("extra positional".into());
                }
                src = Some(PathBuf::from(s));
            }
        }
        i += 1;
    }
    let src = src.ok_or("missing input")?;
    let out = out.ok_or("missing -o")?;
    let doc = vectorize_path(&src, &opt).map_err(|e| e.to_string())?;
    if let Some(parent) = out.parent() {
        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    fs::write(&out, &doc.svg).map_err(|e| e.to_string())?;
    println!(
        "svg {}x{}  colors={}  contours={}  {} bytes  denoise={}  -> {}",
        doc.width,
        doc.height,
        doc.colors_kept,
        doc.contours,
        doc.svg.len(),
        doc.denoise_device,
        out.display()
    );
    if cmd == "svg" {
        if let Some(p) = preview {
            let img = rasterize_svg(&doc.svg, doc.width, doc.height).map_err(|e| e.to_string())?;
            img.save(&p).map_err(|e| e.to_string())?;
            println!("preview -> {}", p.display());
        }
        return Ok(());
    }

    let prev = preview.unwrap_or_else(|| out.with_extension("png"));
    let dif = diff.unwrap_or_else(|| {
        let mut p = prev.clone();
        p.set_file_name(format!(
            "{}-diff.png",
            prev.file_stem().unwrap().to_string_lossy()
        ));
        p
    });
    let quant_path = {
        let mut p = prev.clone();
        p.set_file_name(format!(
            "{}-quant.png",
            prev.file_stem().unwrap().to_string_lossy()
        ));
        p
    };
    let src_img = image::open(&src).map_err(|e| e.to_string())?.to_rgb8();
    let svg_img = rasterize_svg(&doc.svg, doc.width, doc.height).map_err(|e| e.to_string())?;
    let q_img = quantized_image(&doc);
    if let Some(parent) = prev.parent() {
        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    svg_img.save(&prev).map_err(|e| e.to_string())?;
    q_img.save(&quant_path).map_err(|e| e.to_string())?;
    let heat = diff_heatmap(&src_img, &svg_img);
    heat.save(&dif).map_err(|e| e.to_string())?;
    let sheet_path = {
        let mut p = prev.clone();
        p.set_file_name(format!(
            "{}-sheet.png",
            prev.file_stem().unwrap().to_string_lossy()
        ));
        p
    };
    parity_sheet(&src_img, &svg_img, &q_img, &heat)
        .save(&sheet_path)
        .map_err(|e| e.to_string())?;
    let vs_src = parity_rgb(&src_img, &svg_img);
    let vs_q = parity_rgb(&q_img, &svg_img);
    println!(
        "vs source   MAE={:.2}  close={:.1}%  visible={:.1}%",
        vs_src.mae, vs_src.close_pct, vs_src.visible_pct
    );
    println!(
        "vs quant    MAE={:.2}  close={:.1}%  visible={:.1}%  (vectorization ceiling)",
        vs_q.mae, vs_q.close_pct, vs_q.visible_pct
    );
    println!(
        "preview={}  diff={}  quant={}  sheet={}",
        prev.display(),
        dif.display(),
        quant_path.display(),
        sheet_path.display()
    );
    Ok(())
}

fn inspect(args: &[String]) -> Result<(), String> {
    let mut src: Option<PathBuf> = None;
    let mut svg_path: Option<PathBuf> = None;
    let mut dir: Option<PathBuf> = None;
    let mut n = 12usize;
    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--dir" => {
                i += 1;
                dir = Some(PathBuf::from(args.get(i).ok_or("--dir")?));
            }
            "--n" => {
                i += 1;
                n = args.get(i).ok_or("--n")?.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
            }
            s if s.starts_with('-') => return Err(format!("unknown flag {s}")),
            s => {
                if src.is_none() {
                    src = Some(PathBuf::from(s));
                } else if svg_path.is_none() {
                    svg_path = Some(PathBuf::from(s));
                } else {
                    return Err("extra positional".into());
                }
            }
        }
        i += 1;
    }
    let src = src.ok_or("inspect <src> <svg> --dir <dir>")?;
    let svg_path = svg_path.ok_or("inspect <src> <svg> --dir <dir>")?;
    let dir = dir.ok_or("missing --dir")?;
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let src_img = image::open(&src).map_err(|e| e.to_string())?.to_rgb8();
    let svg_text = fs::read_to_string(&svg_path).map_err(|e| e.to_string())?;
    let svg_img = rasterize_svg(&svg_text, src_img.width(), src_img.height()).map_err(|e| e.to_string())?;
    let heat = diff_heatmap(&src_img, &svg_img);
    let spots = vis_hotspots(&src_img, &svg_img, n);
    let mut json = String::from("[\n");
    for (k, hs) in spots.iter().enumerate() {
        let strip = hotspot_strip(&src_img, &svg_img, &heat, hs);
        let name = format!("crop-{:02}.png", k + 1);
        strip
            .save(dir.join(&name))
            .map_err(|e| e.to_string())?;
        println!(
            "{}  {}x{} @{},{}  area={}  mean_err={:.1}",
            name, hs.w, hs.h, hs.x, hs.y, hs.area, hs.mean_err
        );
        if k > 0 {
            json.push_str(",\n");
        }
        json.push_str(&format!(
            "  {{\"file\":\"{name}\",\"x\":{},\"y\":{},\"w\":{},\"h\":{},\"area\":{},\"mean_err\":{:.1}}}",
            hs.x, hs.y, hs.w, hs.h, hs.area, hs.mean_err
        ));
    }
    json.push_str("\n]\n");
    fs::write(dir.join("crops.json"), json).map_err(|e| e.to_string())?;
    println!("wrote {} crops -> {}", spots.len(), dir.display());
    Ok(())
}