From 6671d7d45e5a835ba9e1fa616b43478c1828e5e5 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Mon, 27 Jul 2026 16:31:24 +0100 Subject: [PATCH] Fix spline ballooning on sparse slices (visioncortex 0.9.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A splice slice with very uneven point spacing — a few-pixel jog followed by a long straight leg, which the walker produces around thin strands — was fitted by a single cubic that interpolated every sample exactly while swinging up to ~30 px sideways between them (the fit error was only measured at the samples). On the Cityscape sample at -p 8 -g 28 the maroon strand's wall bulged across its 2 px gap; a long-standing defect present in pre-1.0 vtracer as well. The fix lives in visioncortex 0.9.1 (densify slices with witness points, keep the full multi-cubic chain): the mosaic's shared-boundary spline fitter switches to the new fit_points_with_beziers so open segments get the same treatment as stacked mode. Verified against the exact pixel-mode rasterization — the fitted wall now matches it at every probed row — with identical path counts and only +3 cubics across the whole Cityscape output. All three workspaces (core, py, nodejs) carry a [patch.crates-io] entry pointing at the local visioncortex until 0.9.1 is published. Regression coverage for spline handle anchoring; re-bless goldens tests/spline_fit.rs traces the real reproduction (the Cityscape sample at color precision 8 / gradient step 28, whose maroon region contains a 1 px by 330 px strand) through both compositors and asserts the property the sparse-slice bug violated: every cubic's control points stay within its shape's on-curve bounding box plus a 15 px allowance. The ballooning fit put a handle 25 px outside the whole shape, so the margin is decisive in both directions. The sample photo is decoded via a test-only image dev-dependency. The synthetic goldens are re-blessed: the fitting fix shifts fixture coordinates slightly (witness points refine fits even on clean shapes); the old files still passed the render-diff, so the visual change is sub-tolerance, but goldens should byte-match intentional behavior. Depend on published visioncortex 0.9.1; drop local path patches --- CHANGELOG.md | 4 + Cargo.toml | 5 +- crates/vtracer/Cargo.toml | 2 + crates/vtracer/src/mosaic/fit.rs | 11 ++- .../vtracer/tests/goldens/bands_palette.svg | 6 +- crates/vtracer/tests/goldens/bands_spline.svg | 8 +- .../tests/goldens/checker_mosaic_polygon.svg | 88 +++++++++--------- .../vtracer/tests/goldens/checker_spline.svg | 2 +- .../tests/goldens/disc_mosaic_spline.svg | 2 +- crates/vtracer/tests/goldens/disc_opt0.svg | 2 +- crates/vtracer/tests/goldens/disc_opt2.svg | 2 +- crates/vtracer/tests/goldens/disc_spline.svg | 2 +- .../tests/goldens/disc_watershed_spline.svg | 4 +- crates/vtracer/tests/goldens/ring_spline.svg | 4 +- .../vtracer/tests/goldens/swatches_color.svg | 32 +++---- .../vtracer/tests/goldens/swatches_quant4.svg | 26 +++--- crates/vtracer/tests/spline_fit.rs | 92 +++++++++++++++++++ 17 files changed, 193 insertions(+), 99 deletions(-) create mode 100644 crates/vtracer/tests/spline_fit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5334e26..a2bf17e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * `color_mode` is replaced by `clustering` (`color-cluster` | `bw` | `watershed`) across the CLI (`--clustering`), Rust (`Config::clustering`, enum `Clustering`), Python, and Node — the field selects the region-forming algorithm, not a color space. +### Fixed + +* Spline fitting no longer swings far away from the outline around thin strands (a long-standing defect, via visioncortex 0.9.1): a sparse splice slice — a few-pixel jog followed by a long straight leg — could be fitted by a single cubic that interpolated every sample while ballooning up to ~30 px sideways between them, visibly crossing narrow gaps. Slices are now densified before fitting and multi-cubic fits are kept in full, in both stacked mode and the mosaic's shared-boundary fitter. + ## 1.0.0-alpha.1 - 2026-07-24 Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages. diff --git a/Cargo.toml b/Cargo.toml index 726d629..4188256 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,4 @@ homepage = "http://www.visioncortex.org/vtracer" repository = "https://github.com/visioncortex/vtracer/" [workspace.dependencies] -visioncortex = "0.9" -# For local development against an unreleased visioncortex, add a patch: -# [patch.crates-io] -# visioncortex = { path = "../visioncortex" } +visioncortex = "0.9.1" diff --git a/crates/vtracer/Cargo.toml b/crates/vtracer/Cargo.toml index c1819b5..14d66d4 100644 --- a/crates/vtracer/Cargo.toml +++ b/crates/vtracer/Cargo.toml @@ -22,3 +22,5 @@ visioncortex.workspace = true # Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not # compiled for wasm targets, so the library stays wasm-safe. resvg = "0.45" +# Decode the sample photo for the spline-fitting regression test. Test-only. +image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/crates/vtracer/src/mosaic/fit.rs b/crates/vtracer/src/mosaic/fit.rs index 56fe335..13b21ef 100644 --- a/crates/vtracer/src/mosaic/fit.rs +++ b/crates/vtracer/src/mosaic/fit.rs @@ -98,7 +98,7 @@ impl SegmentFitter for PolygonSegmentFitter { /// gapless. A distance-based DP can't do this: near the √2/2 threshold it /// can't separate staircase noise from real curvature. Smoothing and per-slice /// cubic fitting then reuse the same visioncortex machinery stacked mode uses -/// (open-path variants of the smoothing primitives + `fit_points_with_bezier`), +/// (open-path variants of the smoothing primitives + `fit_points_with_beziers`), /// so the curve character matches stacked. #[derive(Debug, Clone)] pub struct SplineSegmentFitter { @@ -146,14 +146,15 @@ fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] { /// uses in `Spline::from_path_f64`, so mosaic curves have the same character. const FIT_ERROR: f64 = 10.0; -/// Fit one splice slice into a single cubic, exactly as stacked mode does -/// (`fit_points_with_bezier`: one retract-handled cubic per slice, endpoints -/// pinned to the slice ends). +/// Fit one splice slice, exactly as stacked mode does +/// (`fit_points_with_beziers`: the full retract-handled cubic chain per slice, +/// outer endpoints pinned to the slice ends — a sparse or multi-curve slice is +/// kept faithful instead of being collapsed onto one ballooning cubic). fn fit_slice(slice: &[PointF64], out: &mut Vec<[PointF64; 4]>) { match slice.len() { 0 | 1 => {} 2 => out.push(straight_cubic(slice[0], slice[1])), - _ => out.push(SubdivideSmooth::fit_points_with_bezier(slice, FIT_ERROR)), + _ => out.extend(SubdivideSmooth::fit_points_with_beziers(slice, FIT_ERROR)), } } diff --git a/crates/vtracer/tests/goldens/bands_palette.svg b/crates/vtracer/tests/goldens/bands_palette.svg index dd12864..ac9301f 100644 --- a/crates/vtracer/tests/goldens/bands_palette.svg +++ b/crates/vtracer/tests/goldens/bands_palette.svg @@ -1,7 +1,7 @@ - - - + + + diff --git a/crates/vtracer/tests/goldens/bands_spline.svg b/crates/vtracer/tests/goldens/bands_spline.svg index 5b12122..0e35e32 100644 --- a/crates/vtracer/tests/goldens/bands_spline.svg +++ b/crates/vtracer/tests/goldens/bands_spline.svg @@ -1,8 +1,8 @@ - - - - + + + + diff --git a/crates/vtracer/tests/goldens/checker_mosaic_polygon.svg b/crates/vtracer/tests/goldens/checker_mosaic_polygon.svg index 03e70ed..75873ab 100644 --- a/crates/vtracer/tests/goldens/checker_mosaic_polygon.svg +++ b/crates/vtracer/tests/goldens/checker_mosaic_polygon.svg @@ -1,52 +1,50 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/vtracer/tests/goldens/checker_spline.svg b/crates/vtracer/tests/goldens/checker_spline.svg index 475e174..900c1cc 100644 --- a/crates/vtracer/tests/goldens/checker_spline.svg +++ b/crates/vtracer/tests/goldens/checker_spline.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_mosaic_spline.svg b/crates/vtracer/tests/goldens/disc_mosaic_spline.svg index db1cfce..3c09dc5 100644 --- a/crates/vtracer/tests/goldens/disc_mosaic_spline.svg +++ b/crates/vtracer/tests/goldens/disc_mosaic_spline.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_opt0.svg b/crates/vtracer/tests/goldens/disc_opt0.svg index f3b329d..ba8de10 100644 --- a/crates/vtracer/tests/goldens/disc_opt0.svg +++ b/crates/vtracer/tests/goldens/disc_opt0.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_opt2.svg b/crates/vtracer/tests/goldens/disc_opt2.svg index da91a39..7f18a54 100644 --- a/crates/vtracer/tests/goldens/disc_opt2.svg +++ b/crates/vtracer/tests/goldens/disc_opt2.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_spline.svg b/crates/vtracer/tests/goldens/disc_spline.svg index da91a39..7f18a54 100644 --- a/crates/vtracer/tests/goldens/disc_spline.svg +++ b/crates/vtracer/tests/goldens/disc_spline.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/vtracer/tests/goldens/disc_watershed_spline.svg b/crates/vtracer/tests/goldens/disc_watershed_spline.svg index ab6de44..a7d5b23 100644 --- a/crates/vtracer/tests/goldens/disc_watershed_spline.svg +++ b/crates/vtracer/tests/goldens/disc_watershed_spline.svg @@ -1,7 +1,7 @@ - - + + diff --git a/crates/vtracer/tests/goldens/ring_spline.svg b/crates/vtracer/tests/goldens/ring_spline.svg index c6c27ea..fde4c67 100644 --- a/crates/vtracer/tests/goldens/ring_spline.svg +++ b/crates/vtracer/tests/goldens/ring_spline.svg @@ -1,7 +1,7 @@ - - + + diff --git a/crates/vtracer/tests/goldens/swatches_color.svg b/crates/vtracer/tests/goldens/swatches_color.svg index bd98675..7e5939f 100644 --- a/crates/vtracer/tests/goldens/swatches_color.svg +++ b/crates/vtracer/tests/goldens/swatches_color.svg @@ -1,20 +1,20 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/crates/vtracer/tests/goldens/swatches_quant4.svg b/crates/vtracer/tests/goldens/swatches_quant4.svg index 6de3ab2..8f32011 100644 --- a/crates/vtracer/tests/goldens/swatches_quant4.svg +++ b/crates/vtracer/tests/goldens/swatches_quant4.svg @@ -1,17 +1,17 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/crates/vtracer/tests/spline_fit.rs b/crates/vtracer/tests/spline_fit.rs new file mode 100644 index 0000000..1e59d4e --- /dev/null +++ b/crates/vtracer/tests/spline_fit.rs @@ -0,0 +1,92 @@ +//! Spline fitting stays anchored to the geometry it approximates. +//! +//! Regression for the sparse-slice ballooning bug: a splice slice with very +//! uneven point spacing (a few-pixel jog then a long straight leg, produced by +//! the walker around thin strands) used to be fitted by a single cubic that +//! interpolated the samples exactly while swinging ~30 px sideways between +//! them — its control points landing far outside the shape itself. The +//! Cityscape sample at color precision 8 / gradient step 28 is the real +//! reproduction (a 1 px, 330 px-tall strand in the maroon region). + +use std::path::PathBuf; + +use vtracer::ir::PathCmd; +use vtracer::{ColorImage, Config, Hierarchical, VectorDoc}; + +fn cityscape() -> ColorImage { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg"); + let img = image::open(&p).expect("sample image").to_rgba8(); + let (w, h) = (img.width() as usize, img.height() as usize); + ColorImage { + pixels: img.into_raw(), + width: w, + height: h, + } +} + +/// Every cubic's control points must stay within its shape's on-curve bounding +/// box plus a small overshoot allowance. The ballooning bug put handles ~25 px +/// outside the whole shape; a healthy fit stays within the fit error (10). +fn assert_handles_anchored(doc: &VectorDoc, margin: f64) { + for (si, shape) in doc.shapes.iter().enumerate() { + // Bounding box over on-curve points only. + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + let mut on_curve = |p: &visioncortex::PointF64| { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + }; + for sub in &shape.path.subpaths { + for cmd in &sub.commands { + match cmd { + PathCmd::MoveTo(p) | PathCmd::LineTo(p) => on_curve(p), + PathCmd::CubicTo(_, _, p) => on_curve(p), + PathCmd::Close => {} + } + } + } + for sub in &shape.path.subpaths { + for cmd in &sub.commands { + if let PathCmd::CubicTo(c1, c2, _) = cmd { + for q in [c1, c2] { + assert!( + q.x >= x0 - margin + && q.x <= x1 + margin + && q.y >= y0 - margin + && q.y <= y1 + margin, + "shape {si}: control point ({},{}) strays outside \ + bbox ({x0},{y0})..({x1},{y1}) + {margin}", + q.x, + q.y + ); + } + } + } + } + } +} + +#[test] +fn spline_handles_stay_anchored_on_photo() { + let img = cityscape(); + let base = Config { + color_precision: 8, + layer_difference: 28, + ..Config::default() + }; + + // Stacked: per-region closed outlines through Spline::from_path_f64. + let doc = base.build().unwrap().run(&img).unwrap(); + assert!(doc.shapes.len() > 500, "sanity: the trace produced real output"); + assert_handles_anchored(&doc, 15.0); + + // Cutout: open boundary segments through the mosaic's segment fitter. + let cutout = Config { + hierarchical: Hierarchical::Cutout, + ..base + }; + let doc = cutout.build().unwrap().run(&img).unwrap(); + assert_handles_anchored(&doc, 15.0); +}