This stage also exists as a plain-language page with diagrams.
Stage 00 — Overview
The whole pipeline: raster pixels in, an SVG document out, by treating every stage as a model-selection problem under one description-length objective.
Source: the whole tree; this page indexes it.
Entry points: inkvec_trace::trace_color_full_with_alpha (crates/inkvec-trace/src/lib.rs:275,
called via trace_color_full at lib.rs:264) for the raster-to-planar-map half;
inkvec_cli::trace_image (crates/inkvec-cli/src/lib.rs:242) for the whole command, intake
through SVG text.
Pipeline position: none — this is the front door. Every numbered stage document assumes
the reader has this one.
The core idea
crates/inkvec-trace/src/coverage.rs:1-33 states it better than a paraphrase would, so this
is close to verbatim:
An anti-aliased pixel is not a blurry approximation of the shape — it is a measurement of how much of that pixel the shape covers. Reading it as a measurement rather than as noise to be thresholded away is the single largest lever in this project.
For a boundary between a foreground colour F and a background colour B, an observed
pixel is P = a*F + (1-a)*B. Projecting onto the F-B axis inverts that:
a = dot(P - B, F - B) / |F - B|^2
a least-squares estimate that uses all three colour channels, not one luminance projection
(coverage.rs:11-18). The reason this matters in practice, not just in principle, is stated
in the same file: on the thin_features case in docs/M0-BASELINE.md §3, VTracer recovers
2 of 11 elements and no parameter setting recovers more, because the coverage information
was destroyed before any tunable stage ran (coverage.rs:6-8).
The corollary the whole codebase is built on: uncertainty comes out of the same equation.
Pixel noise sigma_pixel gives coverage uncertainty sigma_a = sigma_pixel / |F-B|; the
boundary is the level set a = 0.5, so positional uncertainty is sigma_a / |grad a|
(coverage.rs:20-33, CoverageField::position_sigma, coverage.rs:102-117). A faint edge
says, honestly, that it was measured badly, and that number is what later stages read to
decide how hard to try — tau * sigma admissibility in the curve fitter
(crates/inkvec-fit/src/multimodel.rs:13-19), fit tolerance in the boundary solve, node
budget in --simplify-faint. Nothing downstream invents its own tolerance parameter; it
reads this one.
The objective the whole system serves
Every stage that decides to keep, drop, merge or spend a parameter answers the same question, phrased as one Occam's-razor cost:
cost = 0.5 * chi2 + lambda * params
chi2 is squared error scaled by the measured per-point uncertainty (so a well-localized
boundary is held to a tighter standard than a badly-localized one); params counts the
numbers written into the SVG; lambda is the exchange rate between a nat of residual and
a nat of description length.
lambda is not tuned by taste. FitConfig::from_precision (crates/inkvec-fit/src/lib.rs:95)
derives it from what a coordinate actually costs to write down: a value confined to a range
extent and stored to resolution precision carries ln(extent / precision) nats of
information (clamped so the ratio is never below e, i.e. lambda >= 1). For a 256px
canvas at 0.1px precision that is ln(2560) ≈ 7.85 nats per coordinate pair — not the 1.0 a
first guess suggests, and the difference is "roughly a factor of two in emitted segment
count" per the doc comment. The fill-selection variant of the same idea is
gradient::bic_lambda(n) = 0.5 * ln(n) (crates/inkvec-trace/src/gradient.rs:328-330), the
Bayesian information criterion value: a large region has to earn a gradient with
proportionally more evidence than a small one does.
FitConfig::lambda's own doc comment (crates/inkvec-fit/src/lib.rs:73-82) calls it "the constant
most likely to be mis-set in a way that looks like a pipeline defect" — worth remembering
when a stage's output looks wrong and the fix turns out to be a units problem, not a logic
one.
The pipeline
Two crates carry the geometry. inkvec_trace decides what is in the image: palette,
labels, the planar map, fills. inkvec_fit turns a measured boundary into curves.
inkvec_cli drives both and turns the result into SVG text (crates/inkvec-cli/src/lib.rs:1-22):
image -> intake -> trace -> fit -> repair -> emit -> post -> SVG
The colour path proper — trace_color_full_with_alpha — runs the marks below, in order,
each timed by the Stopwatch (crates/inkvec-trace/src/lib.rs:1030-1055, printed under
INKVEC_TIMING):
| mark | line | stage | what it decides |
|---|---|---|---|
| — | crates/inkvec-cli/src/lib.rs:242 |
intake | decode, unblock a nearest-neighbour upscale, optional SR clean-up, resolution normalisation, alpha matting — doc 01-intake.md |
palette |
crates/inkvec-trace/src/lib.rs:385 (color::extract_palette_mdl :372) |
palette | how many inks, and which colours, by MDL against measured pixel noise |
labels |
crates/inkvec-trace/src/lib.rs:502 (color::label_image :387) |
labels | which ink each pixel is assigned to |
despeckle |
crates/inkvec-trace/src/lib.rs:505 |
despeckle | absorb regions below min_region into their most common neighbour |
blend_absorb |
crates/inkvec-trace/src/lib.rs:542 (absorb_blend_slivers / reassign_blend_pixels) |
blend absorption | anti-aliased pixels between two inks are not a third ink; stop them minting sliver faces |
merge_bands |
crates/inkvec-trace/src/lib.rs:570 (gradient::merge_gradient_bands_with_ink) |
gradient bands | whether adjacent palette bands are really one gradient |
carve |
crates/inkvec-trace/src/lib.rs:633 (gradient::carve_residual_features) |
carve | cut out a feature the palette quantised into its surroundings before a gradient is asked to explain it |
split |
crates/inkvec-trace/src/lib.rs:646 (split_components) |
split | a face is a connected region, not "everywhere this colour appears" |
saddles |
crates/inkvec-trace/src/lib.rs:965 (merge_saddle_faces) |
saddle join | resolve the one ambiguity labels cannot: four pixels meeting diagonally at one corner |
build_map |
crates/inkvec-trace/src/lib.rs:968 (planar::build) |
planar map | shared edges between exactly two faces, from the exact integer label grid |
symmetry_detect |
crates/inkvec-trace/src/lib.rs:972 (symmetry::detect) |
symmetry detect | find mirror/rotation pairs on the label lattice, where the comparison is exact |
refine_subpix |
crates/inkvec-trace/src/lib.rs:975 (planar::refine_subpixel) |
sub-pixel | slide each boundary point along its local normal to the measured 0.5-coverage level |
refine_junc |
crates/inkvec-trace/src/lib.rs:977 (planar::refine_junctions) |
junctions | settle shared endpoints |
boundary_opt |
crates/inkvec-trace/src/lib.rs:987 (boundary_opt::optimise) |
boundary solve | move every boundary point at once so the rendered partition matches the image |
decode |
crates/inkvec-trace/src/lib.rs:1003 (decode::decode_faces) |
decode | order-first colour/geometry fix for faces too thin to own a fully-covered pixel; off unless INKVEC_DECODE is set |
symmetry |
crates/inkvec-trace/src/lib.rs:1013 (symmetry::enforce) |
symmetry enforce | put back the exactness every upstream tie-break quietly broke |
| — | crates/inkvec-cli/src/pipeline.rs:363 (trace_total) |
— | end of the inkvec_trace half |
| — | crates/inkvec-cli/src/pipeline.rs:585 (fit_dp) |
curve fit | one global DP per boundary, {line, cubic} alphabet, MDL cost, primitives offered as an alternative and taken when they cost less |
| — | crates/inkvec-cli/src/pipeline.rs:699 (repair) |
repair | close self-crossing rings the independent per-edge fits can produce |
| — | crates/inkvec-cli/src/pipeline.rs:774 (fills) |
fills | per-face fill model already chosen upstream; demote imperceptible gradients to flat here |
| — | crates/inkvec-cli/src/pipeline.rs:894 (emit) |
emit | fitted geometry to SVG text; layers vs. flat form costed against each other |
| — | crates/inkvec-cli/src/post.rs |
post | viewBox retarget, background knock-out, margin, minify |
Crates: inkvec-core (geometry primitives), inkvec-trace (raster → planar map),
inkvec-fit (points → curves), inkvec-sr (super-resolution pre-pass), inkvec-cli
(driver, fills, emit), inkvec-wasm (browser build). inkvec-trace/src/lib.rs:1-27 gives
the same table from the trace crate's own point of view, with one line worth repeating
verbatim because it is the whole design in one sentence:
One rule governs every stage: a model is kept only when it lowers squared residual against the image by more than
lambdatimes the parameters it adds.
Data structures that flow between stages
bytes
-> Rgba (crates/inkvec-trace/src/coverage.rs:128; straight RGBA f32, [0,1])
-> Palette (crates/inkvec-trace/src/color.rs:679; Oklab colours + rgb + weight + alpha)
-> Vec<u16> labels (per-pixel face id, NOT a palette index — split_components makes that so)
-> PlanarMap { edges: Vec<Edge>, n_labels, width, height }
Edge { points: Vec<Point>, sigma: Vec<f64>, left: u16, right: u16,
start_node, end_node, closed } (crates/inkvec-trace/src/planar.rs:29)
-> FillFit { model: FillModel, chi2, params, cost } (crates/inkvec-trace/src/gradient.rs:311)
FillModel::Flat | Linear | Radial (gradient.rs:101)
-> Polyline (per edge, in content units) -> FittedPath { start, segments: Vec<Segment>, closed }
Segment::Line | Cubic | (Primitive fits carried alongside: PrimitiveFit)
-> FaceRings (which edges each face walks, and which way) -> SVG path strings (emit.rs)
The structural claim that makes this different from "trace the edges" is in
crates/inkvec-trace/src/planar.rs:1-18: a boundary between two regions is stored once.
Both faces reference the same Edge. A tracer that stores each region as an independent
closed path has to choose between two failures — lay regions edge-to-edge and rounding
disagreement between the two copies of the shared boundary opens a seam; overlap them and
the boundary is drawn twice (overdraw). docs/M0-BASELINE.md §4, quoted in the module doc,
measures VTracer sitting on that trade at overdraw 1.64 against a ground truth of 1.00. Here
seams are not merely rare, they are unrepresentable, because there is only one copy of the
edge to move.
Edge.sigma is the per-point positional uncertainty from the coverage inversion, carried
all the way to the curve fitter; Edge.left / Edge.right are face ids, u16::MAX when an
edge has been merged into the interior of a layer and no longer belongs to any ring
(crates/inkvec-cli/src/lib.rs:921-937).
Reading order
01-intake.md covers everything before the palette runs — decode, the unblock pre-pass,
the SR pre-pass, resolution-invariant tolerances, alpha matting. Stages 02 onward (numbered
per the pipeline table above) go module by module through inkvec-trace and inkvec-fit.
constants.md collects every named constant across the tree in one table.
docs/DESIGN.md against the code as it stands
docs/DESIGN.md is dated 2026-08-31 and states its own status as "M0 built and measured; M1
designed." The code in this repository has moved well past that milestone marker — most of
what DESIGN.md specifies for M1 (S0–S4) is built and running by default — so the design
document is best read as rationale for decisions already taken, not as a roadmap of what
is still to come. Specific points where the two disagree, worth a reader's attention because
disagreements are where the real design lives:
- Crate layout. DESIGN.md §6 specifies
inkvec-render,inkvec-ioandinkvec-pyas separate crates. The tree that exists has none of them: rasterisation for the SR detector lives ininkvec-sr::detect(viaresvg), SVG emission lives ininkvec-cli::emit, and there is noinkvec-py— Python involvement is limited to the packaged SR fallback intools/(crates/inkvec-cli/src/lib.rs:442-467).inkvec-sritself is not in DESIGN.md's list at all; it was added afterwards as the super-resolution pre-pass. - S0, image-formation-model estimation. DESIGN.md §"S0" calls for estimating
compositing gamma and the anti-aliasing kernel per image by fitting the edge-spread
function. No such per-image gamma/AA-kernel estimator exists in this repository.
What exists instead is narrower and more targeted —
coverage::intake_scalemeasures edge width (coverage.rs:306-337) andlossy_containerreads the file's codec (trace/lib.rs:94-113) — which is a container-format and resampling detector, not a general image-formation-model fit. Whether this is a deliberate narrowing or an unbuilt piece of S0 is not stated anywhere in the code comments. - S2, joint analysis-by-synthesis boundary solve. This part of DESIGN.md is built and
matches closely:
boundary_opt::optimise(trace/boundary_opt.rs) is exactly the "parametrize the boundary, forward-render, minimise residual against the observed image" design DESIGN.md specifies, run by default (trace/lib.rs:468-473). - S4, primitives inside one global DP. DESIGN.md insists primitives must be members of
the segmentation alphabet, decided by the same dynamic program as lines and cubics,
because "once cubics are fitted they have already absorbed the error a primitive would
have explained." The code does not do this.
cli/lib.rs:863-890runsmultimodel::optimal_multimodel(the{line, cubic}DP) andfit_primitive_or_arcs(a separate primitive/arc fit) independently, per boundary, and keeps whichever scores lower under the shared MDL cost. That is model selection between two fits, not primitives as a state inside one DP with a shared alphabet. Whether this costs anything in practice is not measured in the comments; it is a real architectural gap from the stated design, not a rewording of it. - S5, structured refinement with topology not frozen. DESIGN.md §S5 explicitly reverses
an earlier decision to freeze topology during polish, arguing for a structured move set
searched to convergence. The code has gone the other way again:
cli/lib.rs:939-960records that bothpolish(per-cubic control-point refinement against the coverage field) and the "adjudication" re-scoring stage were removed, with a measured result — "removing them takes the objective from 0.5477 to 0.4960 and dE00 from 0.2503 to 0.2146... Polish alone made 197 of 246 better by not running." The stated reason is thatboundary_opt::optimisenow solves the whole boundary jointly before the fit runs, so a local per-cubic refinement afterwards partly undoes a better global answer. This is a genuine, measured reversal of DESIGN.md's S5, not an oversight — but DESIGN.md itself still describes the removed stages as the specified design. - Symmetry as a first-class constraint. DESIGN.md's region graph (§4) models symmetry as
a constraint enforced during optimisation. The code detects symmetry once, early
(
symmetry_detectattrace/lib.rs:458, on the exact label lattice, before any geometry moves), and enforces it twice more downstream — once on the map (symmetry::enforce,trace/lib.rs:494) and once on the fitted curves, by reflecting one boundary's fit onto its mirror rather than re-solving both (cli/lib.rs:1009-1039). That is "detect once, then copy," which is cheaper and exact by construction, but it is a different design from "enforced as a constraint during re-optimisation." - §0 governing directive ("best algorithm at every stage, even substantially slower"). The S5 removal above is evidence the project has since prioritised the measured objective over that directive where the two conflicted. Worth flagging for anyone using DESIGN.md's §0 as a standing instruction: the code's own commit history (via the doc comments) shows at least one considered decision to trade the "best algorithm" for a better-measured cheaper one.
Open questions
- Whether DESIGN.md's S0 (per-image gamma / AA-kernel estimation) is intentionally narrowed to the two detectors that exist, or genuinely unbuilt, is not stated anywhere in the tree. Unverified.
docs/M0-BASELINE.mdanddocs/M1-PROGRESS.md(referenced from DESIGN.md §9.5) exist in the repository. The measurement scripts cited throughout the code comments are development-time artefacts that are not committed, so the numbers quoted above come from source-code doc comments rather than being independently re-derived from a committed harness. A reader auditing a specific number should trace it to the comment that states it.inkvec_fit::fit_path(crates/inkvec-fit/src/lib.rs:770, documented in its own doc comment as "two passes: a line-only DP... then cubics are fitted to the runs between them") is not reachable from the production pipeline. Every call site is an example or a test —crates/inkvec-cli/examples/fitdbg.rs,crates/inkvec-fit/examples/multimodel_demo.rs,crates/inkvec-fit/examples/primitives_demo.rs,crates/inkvec-fit/tests/multimodel.rs,crates/inkvec-fit/tests/primitives.rs— that exercise the line-then-cubic fit directly. The CLI pipeline instead callsmultimodel::optimal_multimodel(crates/inkvec-cli/src/pipeline.rs:103and:492) directly.fit_pathexists for examples and tests, not as part of the shipped trace.