Publish desktop updater 1.0.0-alpha.3

This commit is contained in:
VTracer Release Bot
2026-08-02 01:15:17 +01:00
commit daa866862b
152 changed files with 74300 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "vtracer-py"
description = "Python bindings for the vtracer vectorization framework."
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2024"
license = "MIT OR Apache-2.0"
readme = "README.md"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
# Excluded from the workspace: pyo3 `extension-module` cdylibs don't link
# libpython, which breaks `cargo test` at the workspace root. Built with
# maturin. Deps are declared explicitly (no workspace inheritance).
[lib]
# Python imports this as `vtracer`.
name = "vtracer"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = [
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
] }
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py38"] }
+73
View File
@@ -0,0 +1,73 @@
# vtracer (Python)
Python bindings for the [`vtracer`](https://github.com/visioncortex/vtracer)
raster-to-vector framework. Built with [pyo3](https://pyo3.rs) +
[maturin](https://www.maturin.rs); the core Rust crate stays pure (no I/O), and
this crate adds image decoding and a Pythonic API.
## Install
```sh
pip install vtracer==1.0.0a3
```
## Usage
```python
import vtracer
# one-liners
vtracer.convert_file("in.png", "out.svg")
svg = vtracer.convert_bytes(open("in.png", "rb").read()) # -> str
svg = vtracer.convert_pixels(rgba_bytes, width, height) # raw RGBA8
# a rich, reusable configuration object
cfg = vtracer.Config(mode="polygon", filter_speckle=8)
cfg.hierarchical = "cutout" # seam-free mosaic
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"] # snap to a fixed palette
cfg.max_colors = 8 # or auto-quantize
cfg.optimize = 2
svg = cfg.convert_bytes(data)
# presets
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
vtracer.Config.bw().convert_file("scan.png", "lineart.svg")
```
### `Config`
Constructor keyword arguments (all optional) — also exposed as mutable
properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| arg | default | notes |
|---|---|---|
| `clustering` | `"color-cluster"` | `"color-cluster"`, `"bw"`, or `"watershed"` |
| `hierarchical` | `"stacked"` | `"stacked"` or `"cutout"` (mosaic) |
| `mode` | `"spline"` | `"pixel"`, `"polygon"`, `"spline"` |
| `filter_speckle` | `4` | discard patches smaller than X px |
| `color_precision` | `6` | significant bits per channel |
| `layer_difference` | `16` | color diff between gradient layers |
| `corner_threshold` | `60` | degrees |
| `length_threshold` | `4.0` | px |
| `max_iterations` | `10` | |
| `splice_threshold` | `45` | degrees |
| `simplify` | `None` | curve simplification tolerance in px (try 1–2.5) |
| `path_precision` | `2` | output decimal places |
| `palette` | `None` | list of `#rrggbb` strings |
| `max_colors` | `None` | auto-quantize target |
| `optimize` | `1` | `0` off, `1` quantize+cleanup, `2` + shorthands |
| `binary_threshold` | `128` | bw: fixed cutoff, foreground below it |
| `adaptive` | `False` | bw: Bradley–Roth adaptive thresholding |
| `adaptive_window` | `0` | bw adaptive: window px (`0` = auto) |
| `adaptive_t` | `15.0` | bw adaptive: % below local mean |
| `watershed_detail` | `128` | watershed: hierarchy cut level 0..=255 |
Each `Config` has `convert_file(input, output)`, `convert_bytes(data, format=None) -> str`,
and `convert_pixels(rgba, width, height) -> str`.
## Build from source
```sh
maturin develop # into the active virtualenv
maturin build --release # produce a wheel
```
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["maturin>=1.5,<2.0"]
build-backend = "maturin"
[project]
name = "vtracer"
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT OR Apache-2.0" }
authors = [{ name = "Chris Tsang", email = "tyt2y7@gmail.com" }]
keywords = ["svg", "vectorization", "raster", "computer-graphics"]
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: 3",
"Topic :: Multimedia :: Graphics",
]
dynamic = ["version"]
[project.urls]
Homepage = "http://www.visioncortex.org/vtracer"
Repository = "https://github.com/visioncortex/vtracer/"
[tool.maturin]
# Pure-Rust extension module; the compiled library is imported as `vtracer`.
module-name = "vtracer"
features = ["pyo3/extension-module"]
+539
View File
@@ -0,0 +1,539 @@
//! Python bindings for the `vtracer` vectorization framework.
//!
//! The API centers on a mutable [`Config`] object with named properties and
//! preset constructors, plus three input paths — a file, encoded image bytes,
//! or a raw RGBA buffer — each returning the SVG (or writing it to disk):
//!
//! ```python
//! import vtracer
//!
//! # one-liners
//! vtracer.convert_file("in.png", "out.svg")
//! svg = vtracer.convert_bytes(open("in.png", "rb").read())
//!
//! # rich, reusable config
//! cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
//! cfg.max_colors = 8
//! cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
//! svg = cfg.convert_bytes(data)
//!
//! # presets
//! vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
//! ```
use std::io::Cursor;
use std::path::PathBuf;
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use ::vtracer::{
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
};
// --- string <-> enum helpers -------------------------------------------------
fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
s.parse().map_err(PyValueError::new_err)
}
fn clustering_str(c: Clustering) -> &'static str {
match c {
Clustering::ColorCluster => "color-cluster",
Clustering::Binary => "bw",
Clustering::Watershed => "watershed",
}
}
fn hierarchical_str(h: Hierarchical) -> &'static str {
match h {
Hierarchical::Stacked => "stacked",
Hierarchical::Cutout => "cutout",
}
}
fn mode_str(m: FitMode) -> &'static str {
match m {
FitMode::Pixel => "pixel",
FitMode::Polygon => "polygon",
FitMode::Spline => "spline",
}
}
fn parse_hex(token: &str) -> PyResult<Color> {
let hex = token.strip_prefix('#').unwrap_or(token);
if hex.len() != 6 {
return Err(PyValueError::new_err(format!(
"`{token}` is not a #rrggbb color"
)));
}
let byte = |r: std::ops::Range<usize>| {
u8::from_str_radix(&hex[r], 16)
.map_err(|_| PyValueError::new_err(format!("`{token}` is not a #rrggbb color")))
};
Ok(Color::new(byte(0..2)?, byte(2..4)?, byte(4..6)?))
}
// --- image helpers -----------------------------------------------------------
fn dynimg_to_color(img: image::DynamicImage) -> ColorImage {
let img = img.to_rgba8();
let (w, h) = (img.width() as usize, img.height() as usize);
ColorImage {
pixels: img.into_raw(),
width: w,
height: h,
}
}
fn decode_bytes(bytes: &[u8], format: Option<&str>) -> PyResult<ColorImage> {
let mut reader = image::ImageReader::new(Cursor::new(bytes));
match format {
Some(ext) => {
let fmt = image::ImageFormat::from_extension(ext)
.ok_or_else(|| PyValueError::new_err(format!("unknown image format `{ext}`")))?;
reader.set_format(fmt);
}
None => {
reader = reader
.with_guessed_format()
.map_err(|e| PyValueError::new_err(e.to_string()))?;
}
}
let img = reader
.decode()
.map_err(|e| PyValueError::new_err(format!("failed to decode image: {e}")))?;
Ok(dynimg_to_color(img))
}
// --- Config ------------------------------------------------------------------
/// Conversion configuration. Construct with keyword arguments or a preset,
/// mutate via properties, then call one of the `convert_*` methods.
#[pyclass(name = "Config")]
#[derive(Clone)]
struct PyConfig {
inner: CoreConfig,
}
impl PyConfig {
fn to_svg(&self, img: &ColorImage) -> PyResult<String> {
self.inner
.build()
.map_err(|e| PyValueError::new_err(e.to_string()))?
.to_svg(img)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
}
#[pymethods]
impl PyConfig {
#[new]
#[pyo3(signature = (
clustering = "color-cluster",
hierarchical = "stacked",
mode = "spline",
filter_speckle = 4,
color_precision = 6,
layer_difference = 16,
corner_threshold = 60,
length_threshold = 4.0,
max_iterations = 10,
splice_threshold = 45,
simplify = None,
path_precision = 2,
palette = None,
max_colors = None,
optimize = 1,
binary_threshold = 128,
adaptive = false,
adaptive_window = 0,
adaptive_t = 15.0,
watershed_detail = 128,
))]
#[allow(clippy::too_many_arguments)]
fn new(
clustering: &str,
hierarchical: &str,
mode: &str,
filter_speckle: usize,
color_precision: i32,
layer_difference: i32,
corner_threshold: i32,
length_threshold: f64,
max_iterations: usize,
splice_threshold: i32,
simplify: Option<f64>,
path_precision: u32,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: u8,
binary_threshold: u8,
adaptive: bool,
adaptive_window: u32,
adaptive_t: f64,
watershed_detail: u8,
) -> PyResult<Self> {
let palette = match palette {
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
None => Vec::new(),
};
Ok(Self {
inner: CoreConfig {
clustering: parse(clustering)?,
hierarchical: parse(hierarchical)?,
mode: parse(mode)?,
filter_speckle,
color_precision,
layer_difference,
corner_threshold,
length_threshold,
max_iterations,
splice_threshold,
simplify,
path_precision: Some(path_precision),
palette,
max_colors,
optimize,
binary_threshold,
binary_adaptive: adaptive,
binary_adaptive_window: adaptive_window,
binary_adaptive_t: adaptive_t,
watershed_detail,
},
})
}
/// Preset for black & white line art.
#[staticmethod]
fn bw() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Bw),
}
}
/// Preset for posterized color art.
#[staticmethod]
fn poster() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Poster),
}
}
/// Preset tuned for photographs.
#[staticmethod]
fn photo() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Photo),
}
}
// --- properties ---
#[getter]
fn clustering(&self) -> &'static str {
clustering_str(self.inner.clustering)
}
#[setter]
fn set_clustering(&mut self, v: &str) -> PyResult<()> {
self.inner.clustering = parse(v)?;
Ok(())
}
#[getter]
fn watershed_detail(&self) -> u8 {
self.inner.watershed_detail
}
#[setter]
fn set_watershed_detail(&mut self, v: u8) {
self.inner.watershed_detail = v;
}
#[getter]
fn hierarchical(&self) -> &'static str {
hierarchical_str(self.inner.hierarchical)
}
#[setter]
fn set_hierarchical(&mut self, v: &str) -> PyResult<()> {
self.inner.hierarchical = parse(v)?;
Ok(())
}
#[getter]
fn mode(&self) -> &'static str {
mode_str(self.inner.mode)
}
#[setter]
fn set_mode(&mut self, v: &str) -> PyResult<()> {
self.inner.mode = parse(v)?;
Ok(())
}
#[getter]
fn filter_speckle(&self) -> usize {
self.inner.filter_speckle
}
#[setter]
fn set_filter_speckle(&mut self, v: usize) {
self.inner.filter_speckle = v;
}
#[getter]
fn color_precision(&self) -> i32 {
self.inner.color_precision
}
#[setter]
fn set_color_precision(&mut self, v: i32) {
self.inner.color_precision = v;
}
#[getter]
fn layer_difference(&self) -> i32 {
self.inner.layer_difference
}
#[setter]
fn set_layer_difference(&mut self, v: i32) {
self.inner.layer_difference = v;
}
#[getter]
fn corner_threshold(&self) -> i32 {
self.inner.corner_threshold
}
#[setter]
fn set_corner_threshold(&mut self, v: i32) {
self.inner.corner_threshold = v;
}
#[getter]
fn length_threshold(&self) -> f64 {
self.inner.length_threshold
}
#[setter]
fn set_length_threshold(&mut self, v: f64) {
self.inner.length_threshold = v;
}
#[getter]
fn max_iterations(&self) -> usize {
self.inner.max_iterations
}
#[setter]
fn set_max_iterations(&mut self, v: usize) {
self.inner.max_iterations = v;
}
#[getter]
fn splice_threshold(&self) -> i32 {
self.inner.splice_threshold
}
#[setter]
fn set_splice_threshold(&mut self, v: i32) {
self.inner.splice_threshold = v;
}
#[getter]
fn simplify(&self) -> Option<f64> {
self.inner.simplify
}
#[setter]
fn set_simplify(&mut self, v: Option<f64>) {
self.inner.simplify = v;
}
#[getter]
fn path_precision(&self) -> Option<u32> {
self.inner.path_precision
}
#[setter]
fn set_path_precision(&mut self, v: Option<u32>) {
self.inner.path_precision = v;
}
#[getter]
fn palette(&self) -> Vec<String> {
self.inner
.palette
.iter()
.map(Color::to_hex_string)
.collect()
}
#[setter]
fn set_palette(&mut self, v: Vec<String>) -> PyResult<()> {
self.inner.palette = v.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?;
Ok(())
}
#[getter]
fn max_colors(&self) -> Option<usize> {
self.inner.max_colors
}
#[setter]
fn set_max_colors(&mut self, v: Option<usize>) {
self.inner.max_colors = v;
}
#[getter]
fn optimize(&self) -> u8 {
self.inner.optimize
}
#[setter]
fn set_optimize(&mut self, v: u8) {
self.inner.optimize = v;
}
#[getter]
fn binary_threshold(&self) -> u8 {
self.inner.binary_threshold
}
#[setter]
fn set_binary_threshold(&mut self, v: u8) {
self.inner.binary_threshold = v;
}
#[getter]
fn adaptive(&self) -> bool {
self.inner.binary_adaptive
}
#[setter]
fn set_adaptive(&mut self, v: bool) {
self.inner.binary_adaptive = v;
}
#[getter]
fn adaptive_window(&self) -> u32 {
self.inner.binary_adaptive_window
}
#[setter]
fn set_adaptive_window(&mut self, v: u32) {
self.inner.binary_adaptive_window = v;
}
#[getter]
fn adaptive_t(&self) -> f64 {
self.inner.binary_adaptive_t
}
#[setter]
fn set_adaptive_t(&mut self, v: f64) {
self.inner.binary_adaptive_t = v;
}
// --- conversion ---
/// Trace the image at `input_path` and write the SVG to `output_path`.
fn convert_file(&self, input_path: PathBuf, output_path: PathBuf) -> PyResult<()> {
let img = image::open(&input_path).map_err(|e| {
PyIOError::new_err(format!("cannot open `{}`: {e}", input_path.display()))
})?;
let svg = self.to_svg(&dynimg_to_color(img))?;
std::fs::write(&output_path, svg).map_err(|e| {
PyIOError::new_err(format!("cannot write `{}`: {e}", output_path.display()))
})
}
/// Trace encoded image `data` (png/jpg/...) and return the SVG string.
/// `format` (e.g. "png") overrides content-based format detection.
#[pyo3(signature = (data, format = None))]
fn convert_bytes(&self, data: Vec<u8>, format: Option<&str>) -> PyResult<String> {
self.to_svg(&decode_bytes(&data, format)?)
}
/// Trace a raw RGBA8 buffer (`width * height * 4` bytes) and return the SVG.
fn convert_pixels(&self, rgba: Vec<u8>, width: usize, height: usize) -> PyResult<String> {
if rgba.len() != width * height * 4 {
return Err(PyValueError::new_err(format!(
"rgba length {} != width*height*4 ({})",
rgba.len(),
width * height * 4
)));
}
self.to_svg(&ColorImage {
pixels: rgba,
width,
height,
})
}
fn __repr__(&self) -> String {
let c = &self.inner;
format!(
"Config(clustering='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
max_colors={:?}, optimize={})",
clustering_str(c.clustering),
hierarchical_str(c.hierarchical),
mode_str(c.mode),
c.filter_speckle,
c.color_precision,
c.layer_difference,
c.corner_threshold,
c.length_threshold,
c.max_iterations,
c.splice_threshold,
c.path_precision,
c.palette.len(),
c.max_colors,
c.optimize,
)
}
}
// --- module-level convenience ------------------------------------------------
/// Convert a file to SVG on disk, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (input_path, output_path, config = None))]
fn convert_file(
input_path: PathBuf,
output_path: PathBuf,
config: Option<PyConfig>,
) -> PyResult<()> {
config
.unwrap_or_else(default_config)
.convert_file(input_path, output_path)
}
/// Convert encoded image bytes to an SVG string, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (data, config = None, format = None))]
fn convert_bytes(
data: Vec<u8>,
config: Option<PyConfig>,
format: Option<&str>,
) -> PyResult<String> {
config
.unwrap_or_else(default_config)
.convert_bytes(data, format)
}
/// Convert a raw RGBA8 buffer to an SVG string, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (rgba, width, height, config = None))]
fn convert_pixels(
rgba: Vec<u8>,
width: usize,
height: usize,
config: Option<PyConfig>,
) -> PyResult<String> {
config
.unwrap_or_else(default_config)
.convert_pixels(rgba, width, height)
}
fn default_config() -> PyConfig {
PyConfig {
inner: CoreConfig::default(),
}
}
#[pymodule]
#[pyo3(name = "vtracer")]
fn vtracer_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyConfig>()?;
m.add_function(wrap_pyfunction!(convert_file, m)?)?;
m.add_function(wrap_pyfunction!(convert_bytes, m)?)?;
m.add_function(wrap_pyfunction!(convert_pixels, m)?)?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}
+67
View File
@@ -0,0 +1,67 @@
from typing import Optional
__version__: str
class Config:
"""Conversion configuration. Construct with keyword arguments or a preset,
mutate via properties, then call one of the ``convert_*`` methods."""
def __init__(
self,
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
mode: str = "spline", # "pixel" | "polygon" | "spline"
filter_speckle: int = 4,
color_precision: int = 6,
layer_difference: int = 16,
corner_threshold: int = 60,
length_threshold: float = 4.0,
max_iterations: int = 10,
splice_threshold: int = 45,
simplify: Optional[float] = None, # curve simplification tolerance in px (None = off)
path_precision: int = 2,
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
max_colors: Optional[int] = None, # auto-quantize target
optimize: int = 1, # 0 | 1 | 2
binary_threshold: int = 128, # bw: fixed cutoff 0..=255
adaptive: bool = False, # bw: Bradley–Roth adaptive
adaptive_window: int = 0, # bw adaptive: window px (0 = auto)
adaptive_t: float = 15.0, # bw adaptive: % below local mean
watershed_detail: int = 128, # watershed: cut level 0..=255
) -> None: ...
@staticmethod
def bw() -> "Config": ...
@staticmethod
def poster() -> "Config": ...
@staticmethod
def photo() -> "Config": ...
clustering: str
hierarchical: str
mode: str
filter_speckle: int
color_precision: int
layer_difference: int
corner_threshold: int
length_threshold: float
max_iterations: int
splice_threshold: int
simplify: Optional[float]
path_precision: Optional[int]
palette: list[str]
max_colors: Optional[int]
optimize: int
binary_threshold: int
adaptive: bool
adaptive_window: int
adaptive_t: float
watershed_detail: int
def convert_file(self, input_path: str, output_path: str) -> None: ...
def convert_bytes(self, data: bytes, format: Optional[str] = None) -> str: ...
def convert_pixels(self, rgba: bytes, width: int, height: int) -> str: ...
def convert_file(input_path: str, output_path: str, config: Optional[Config] = None) -> None: ...
def convert_bytes(data: bytes, config: Optional[Config] = None, format: Optional[str] = None) -> str: ...
def convert_pixels(rgba: bytes, width: int, height: int, config: Optional[Config] = None) -> str: ...