Inkvec

Stage 14 — Fast mode

A second route through the pipeline: the same planar map, sub-pixel refinement and emitter as Quality mode, with every expensive search replaced by a one-pass version and the curve fitter replaced by a Potrace-class one — and, since the 2026-09-30 round, each of its own stages computed on row runs, in parallel where it pays, to the same bytes, but for the image frame, which is now written as the image rectangle. Since 2026-10-04 its corner threshold is 0.1 px (§6), and an opt-in third mode, --mode balanced, runs it with eight iterations of Quality's boundary solve and a fit a quarter finer (§8).

Source: crates/inkvec-trace/src/fast/ — front.rs (the front end), palette.rs, faces.rs and faces/runs.rs (the clean-up), bands.rs (ramps), mod.rs, polygon.rs, smooth.rs, curve.rs and prims.rs (the fitter), replay.rs (a test-only replay of dumped fitter inputs); crates/inkvec-trace/src/regions.rs (cap_components, which the clean-up calls past the face-id limit); crates/inkvec-fit/src/primitives/ellipse.rs (taubin_ellipse, fit_ellipse_seeded, which the fitter's primitive test calls); crates/inkvec-cli/src/fast.rs (the command line's side, balanced mode included). The stages it shares are documented in 01-intake.md, 06-planar-map.md, 07-subpixel.md, 10-symmetry.md and 13-emit.md. Entry points: --mode fast and --mode balanced (TraceMode::Fast and TraceMode::Balanced, crates/inkvec-cli/src/args.rs:21-34, default quality). The trace crate dispatches to fast::trace_color or, for an image traced with its transparency, fast::trace_color_native (fast/front.rs:17-29) when ColorOptions::fast is set (crates/inkvec-trace/src/lib.rs:304-316); the command line fits the result with fast::fit (crates/inkvec-cli/src/fast.rs:115-134), which calls inkvec_trace::fast::fit_edges (fast/mod.rs:380-427) with the map's width and height. Pipeline position: it replaces stages 03–05 (palette, regions, gradients) with its own front end; shares stages 01, 06, 07, 10 and 13; skips 08 (the boundary solve, lib.rs:1141, boundary_opt::optimise_for at boundary_opt.rs:593-611; balanced runs it, capped at eight iterations, §8) and 09 (decode, lib.rs:1152); and replaces 11 (curve fitting) with its own fitter, without 12 (repair) or shape harmonization (repair_fits, crates/inkvec-cli/src/pipeline.rs:851; emit_options, pipeline.rs:526-537).

What problem this solves

Quality mode spends its time searching: a palette recovered by minimum description length with spatial evidence tests for every candidate, gradient bands merged pairwise with a refit after every merge, the whole boundary solved at once against an exact render, and every boundary fitted by a global multi-model dynamic program. Fast mode keeps what makes the output correct — "the planar map whose shared edges are traced once so neighbouring faces cannot open a seam, the sub-pixel refinement of every boundary point, and the emitter with its seam underlap, compound paths and minify" — and replaces the rest with one-pass versions (the module overview of fast/mod.rs). Each stage is linear or near-linear in the number of pixels or boundary points, and nothing reads a clock, so the output is the same on every machine. The command line describes the trade as "several times faster, a little less faithful" (args.rs:340-343).

The round of 2026-09-30 rewrote Fast's own stages and the stages it shares for speed, with the output held fixed: every rewrite is exact, keeps the code it replaced as a test oracle, and was byte-compared on the benchmark sets. Two changes in the fitter are the exceptions, and the quality gate judged them instead (§6): the image frame is written as the image rectangle, which changes opaque images with a background face, and the orthogonal ellipse starts from the algebraic conic alone, which changed no file on the gate but is not provably identical. This page describes the pipeline as it stands after that round, stage by stage, with what each stage computes in the field's standard terms, the method, its citations as the code's doc comments label them, and the measured costs before and after. §6 and §8 cover the changes of 2026-10-04 (the corner threshold, and balanced mode). It also covers two fixes merged on 2026-10-03 that came after the round: a run of curve pieces that U-turns is no longer collapsed to its last piece, and a ring of pieces is never one cubic (§6, step 6; 3513beb, judged by the gate); and an image with more components than face ids has its smallest components merged into their neighbours instead of folded into face 0 (§3; be5abad, byte-identical on the gate's sets).

How the numbers were measured. Stage timings come from the implementing branches' merge notes and the research reports of 2026-09-30, on the development machine (an 8-core Ryzen 7 5800X, shared with other work, so milliseconds are approximate and counts and ratios are the reliable part). The combined figures — every branch of the round together — come from one run of main at 55ee4e0 against the merged integ/fast tip, per image, mean ms. The fitter's stage figures marked "replay" come from the phase-1 replay: 11,021 map edges dumped by the research build and fitted again on one thread without the engine (fast/replay.rs). The sets: screen, 246 icons at 128 × 128 (241 of them RGBA, traced with native alpha); held_a, 156 held-out icons; s512, 58 images at 512 px; big, 7 opaque images (six at 2048 × 2048 and a 1672 × 941 masthead); bigalpha, 3 transparent images at 2048 px; flat, one flat logo at 512 px. "Byte-identical" means the SVG files compared equal byte for byte against the previous build.

Inputs and outputs

The same as Quality mode's: a decoded Rgba (and the source alpha, when transparency is traced natively) in; a ColorTrace — the planar map with sub-pixel boundaries, one fill and one palette ink per face — out of the trace crate; one fitted path per map edge out of the fitter; one SVG document out of the emitter. Two differences in what comes out:

  • fills are flat, except where the ramp pass (stage 4 below) finds that a run of posterised bands is one gradient — on opaque images, with gradients on. The native-alpha path has no ramps: "a gradient across opacity is a fade, which is quality mode's" (fast/front.rs:14-16);
  • the report opens with a line saying Fast mode ran and naming what it skipped — "fast mode Potrace-class fit, flat fills; not run: boundary solve, curve DP, gradients, ring repair, harmonization" (report, crates/inkvec-cli/src/fast.rs:184-211) — followed by every option the caller moved off its default that only steers a skipped stage (fast_ignored, fast.rs:136-182): --tau, --content-units, --bezier-cost, --corner-angle, --time-budget, --simplify-faint, --harmonize-threshold, --use-symbols, --no-repair and --harmonize / --no-harmonize, in that order. Not --no-gradients, because Fast still merges ramps, and not --precision or --lambda-scale, which the intake rescales on an oversampled raster, so a changed value is no evidence the caller set them. So a tuned setting that did nothing is not silent. Balanced mode writes its own first line instead (§8) and lists the same ignored options.

The pipeline, in order

# stage stopwatch marks code Quality counterpart measured before → after (per image)
1 intake — load.rs, cli/alpha.rs, coverage.rs shared (01) see below; with the palette, wall 282 → 203 ms at 2048 px opaque
2 histogram palette and labels palette fast/palette.rs 03 (MDL palette) 47.7 → 7.1 ms at 2048 px opaque; 1.85 → 0.40 ms at 128 px
3 region clean-up and faces slivers, despeckle, split fast/faces.rs, fast/faces/runs.rs (and regions.rs past the face-id limit) 04 (regions) 76.2 → 3.86 ms at 2048 px; 0.50 → 0.09 ms at 128 px
4 ramps ramps fast/bands.rs 05 (gradients) in the shared totals below
5 planar map and refinement build_map, symmetry_detect, refine_subpix, refine_junc, symmetry planar.rs, planar/*.rs, symmetry.rs shared (06, 07, 10) ramps + map + refinement: 42 → 13 ms at 2048 px opaque
6 Potrace-class fitter fit_dp fast/mod.rs, polygon.rs, smooth.rs, curve.rs, prims.rs 11, 12 combined: 26.27 → 5.42 ms at 2048 px opaque; 1.00 → 0.46 ms at 128 px (§6)
7 emit fills, emit cli/pipeline.rs, cli/alpha.rs, cli/emit.rs shared (13) with stage 5 on transparent images: 130 → 21 ms at 2048 px

Whole-trace effect, each branch measured on its own against its own baseline run, so the figures below are not additive: the clean-up round took trace_total at 2048 px from 193 to 116 ms; the palette-and-intake round took it from 179 to 131 ms and the process's wall time from 282 to 203 ms. At 128 px the palette round took trace_total from 4.34 to 2.80 ms.

Measured together, main 55ee4e0 against the merged tip, per image, mean ms. trace_total is the stopwatch mark after the trace crate returns (stages 2–5; set in run_color_impl, crates/inkvec-cli/src/pipeline.rs:255); fit_dp is the fit (stage 6) plus what finish_color does before it (pipeline.rs:367-371, the mark at :495):

set fit_dp trace_total
2048 px opaque (7 images) 26.27 → 5.42 161.5 → 31.0
2048 px transparent (3) 23.03 → 7.49 157.5 → 26.2
512 px (51) 5.66 → 1.79 17.1 → 6.6
128 px (246 icons) 1.00 → 0.46 4.29 → 2.48

How it works

1. Intake

What it computes. Decode the file to straight RGBA floats, undo an exact nearest-neighbour upscale, and matte transparency onto an opaque ground; then every stage reads the image composited over white. Shared with Quality mode and documented in full in 01-intake.md; this section lists what the round changed. Since the robustness merge of 2026-10-03 (d8a3650) the shared intake also picks the decoder by the file's signature, converts an embedded ICC profile to sRGB, turns the image upright by its EXIF orientation, lets a decode that will be capped allocate more (load.rs:8-26), and refuses an image with a zero side (has_pixels, load.rs:255-272); those steps are documented in 01-intake.md, and Fast takes them as Quality does.

Method.

  • One read of the file, one widening pass. load_image_capped reads the file into memory once and decodes header and pixels from those bytes (load_image_capped, load_file_bytes_capped, load.rs:392-433). The bytes become floats through a 256-entry table, UNIT[k] = k / 255 (load.rs:282-296), straight from the decoder's own buffer for 8-bit RGB and RGBA, in parallel chunks from 256 × 256 pixels on (from_dynamic, widen, load.rs:298-373).
  • Unblock by the exact lattice inverse. The columns and rows where a pixel differs from its neighbour bit for bit are the change positions, and the scan stops at the first two adjacent ones, which no pitch of 2 or more makes: on ordinary art that is the first anti-aliased curve, a few rows into the content, and no lattice is tested. Otherwise each axis keeps the source sizes whose change positions fit one cell per source pixel (an integer residue test, no tolerance), one scale is chosen for both axes, and the grid is returned only if one pixel per cell rebuilds the input bit for bit (pixel_grid, line_changes, crates/inkvec-cli/src/alpha/unblock.rs:33-63, 237-276).
  • Matte in place, in parallel. The transparency scan and the flatten are parallel maps from 256 × 256 pixels on, and the flatten writes over the input's own buffer (alpha_source_owned, crates/inkvec-cli/src/alpha.rs:599-625; flatten_in_place, has_transparency, alpha.rs:793-853).
  • Composite over white in parallel (Rgba::composited, coverage.rs:198-232).

Each is exact: every output of the parallel maps depends on one input value, the transparency scan is a pure predicate, and the unblock returns a grid only after the bit-for-bit re-expansion test; each rewrite keeps the old code as an oracle and is compared bit for bit.

Citations (labels as in the doc comments): the composite and the flatten, "Method from" Porter & Duff, "Compositing Digital Images", SIGGRAPH '84 (the "over" operator with an opaque ground, adapted to straight colour); the widening, "Not from the literature", "See also" Ragan-Kelley et al., "Halide", PLDI 2013; the lattice inverse, "Not from the literature", "See also" Popescu & Farid, "Exposing Digital Forgeries by Detecting Traces of Resampling", IEEE Trans. Signal Processing 2005, and Lothaire, "Algebraic Combinatorics on Words", Cambridge University Press 2002, chapter 2 "Sturmian Words"; flattening in place, "Not from the literature: buffer reuse", "See also" Leijen, Zorn & de Moura, "Mimalloc", APLAS 2019; the single file read, "Not from the literature: plumbing".

Costs. Before, at 2048 px (shared-stage research): the load 20 ms (decode 4.9, the RGBA copy 4.0, the float conversion 10.1), all serial; unblock 7.1 ms; the transparency scan 3.3 ms on an opaque image; the flatten 26.7 ms on a transparent one; the composite 11–18 ms, serial. After: the palette-and-intake round as a whole took wall time at 2048 px opaque from 282 to 203 ms; its per-stage intake figures were not reported separately. Identity: Fast 493/493 files, including 25 intake variants (JPEG, WebP, GIF, BMP, TIFF, grey, 16-bit, palette PNG, upscales, over 2048 px); Quality 286/286.

2. The histogram palette

What it computes, in the field's terms (fast/palette.rs:30-43). Every pixel is binned: 5 bits per sRGB channel (15-bit keys) on an opaque image, 4 + 4 + 4 colour bits and 4 opacity bits on one traced with its transparency. A bin counts as evidence for an ink only through its flat pixels — those whose four neighbours fall in the same bin — so an anti-aliased rim, one pixel wide, never becomes an ink.

  • The histogram is a colour coherence vector in its local form: per bucket, the pixels whose 4-cross stays in the bucket (the flat pixels of bin k are the erosion of X_k = {p : key(p) = k} by the 4-cross, the image padded by k), where Pass, Zabih & Miller count coherence by connected-component size.
  • Founding inks is leader clustering over weighted bins taken in popularity order: a bin joins the nearest ink when within SAME_INK (0.012 OKLab), or within merge_distance and next to one of that ink's bins on the grid; otherwise it founds an ink, up to max_colors. Strokes too thin to have a flat pixel get thin inks from paired pixels (at least one neighbour in the bin), unless they are a blend of two other inks.
  • The per-bin nearest-ink table is an inverse colour map restricted to occupied cells, and labelling sure pixels through it is histogram backprojection; a pixel whose bin is not itself an ink colour (a blend) goes to a neighbour's ink or to the ink it is made of.
  • The parallel histogram is a privatised generalised histogram, computed on runs.

Colours are compared over white and over a second ground (native::Ink2), so white paint and the clear ground are as far apart as white and black; on an opaque image that is plain OKLab.

Method: four passes (fast/palette.rs:58-73).

  1. Keys and histogram (keys_and_histogram, palette.rs:683-781). The image is cut into bands of whole rows, processed in parallel. Each band keys its rows once per colour run (key_rows, palette.rs:501-521: the key is a pure function of the pixel's four floats, and 99.56% of pixels repeat their left neighbour's colour at 2048 px), then walks key runs counting pixels, paired and flat pixels and their f64 colour sums (histogram_rows, palette.rs:568-633): inside a run the horizontal half of the erosion comes from the run's ends, and only the rows above and below are read per pixel. A run's sums are held in registers and stored once, because adding each pixel into the bin's memory made every addition wait on the store before it. Row r + 1 is keyed just before row r is counted, so the count reads pixels the keying has just brought into cache. The bands' histograms are merged at the end (Bins::absorb), which is exact by the lemma below. Rounding to a level is trunc(x + (0.5 − 2⁻²⁵)), identical to f32::round on every one of the 1,065,353,217 floats in [0, 1] for both grids and about twice as fast, because f32::round compiles to a libm call on the default x86-64 target (level, palette.rs:174-209).
  2. Inks (found_inks, palette.rs:857-893; thin_inks, :915-973) from the table of occupied bins, then opacity snapping. Microscopic: at most 36 candidates and 36 inks measured.
  3. Lookup table: each occupied bin's nearest ink, once, and whether the bin is that ink (palette.rs:1213-1228).
  4. Labels (label_rows, palette.rs:1318-1332): a sure pixel's label is written inline from the table; only the others — 0.29% of pixels at 2048 px, 4.4% at 128 px — go to the neighbourhood rule (Blends::blend_label, palette.rs:1037-1081). Each ink's share is counted per run of equal labels, in integers, and converted to the f32 value the old per-pixel float count produced (ink_shares, palette.rs:1377-1384).

Per-bin state is kept for the occupied bins only (Bins, palette.rs:211-307): a median of 16 on the 128 px screen set and at most 610 on the 2048 px set, out of 65,536 keys. A key reaches its bin through a small open-addressing hash table (Slots, palette.rs:309-409) that starts at 64 entries and doubles as bins open, so each band's table is as large as the bins it met. Below 256 × 256 pixels, and whenever rayon has a single worker (the single-threaded WebAssembly build), every pass runs on the calling thread (PARALLEL_MIN_PIXELS, palette.rs:1119-1135).

The exactness lemma for parallel sums (in_exact_set, palette.rs:443-483). Merging band histograms adds f64 sums in an order the serial pass would not use, and floating-point addition is not associative in general. It is here:

Let V be a multiset of f32 values, each 0 or in [2⁻⁸, 1], with |V| ≤ 2²². Then summing V into an f64 in any order, grouped in any way, gives the exact real sum.

Proof. A float v ≥ 2⁻⁸ has an exponent of at least −8 and a 24-bit significand, so it is an integer multiple of 2⁻³¹. Every partial sum is then j · 2⁻³¹ with 0 ≤ j · 2⁻³¹ ≤ |V| ≤ 2²², so j ≤ 2⁵³, and every such number is representable in binary64. IEEE 754 addition is "computed exactly and then rounded"; a representable exact result is returned unrounded. By induction every partial sum is exact, so the final sum is the real sum, independent of order. ∎

Every 8-bit input that has not been resampled satisfies it: a channel is k / 255 ≥ 1/255 > 2⁻⁸ or 0, and a translucent pixel over white is fl(fl(s·a) + fl(1 − a)) ≥ 1/255 (0 violations on 253 of 253 measured images). The key pass checks every value it keys (a repeated colour was already checked); a resampled raster (a --max-dim reduction, --intake-scale) carries arbitrary box averages, fails the check, and the histogram is then recounted serially in raster order over the finished keys. Above EXACT_SUM_MAX_PIXELS = 2²² (2048 × 2048, the default --max-dim) the histogram is serial too. Counts are integers, so they merge exactly in any case.

Citations (labels as in the doc comments): the sparse per-bin state, "Method from" Briggs & Torczon, "An Efficient Representation for Sparse Sets", ACM LOPLAS 1993, as described by Cox, "Using Uninitialized Memory for Fun and Profit", 2008 (adapted: the sparse side is a hash table); the hash table, "Method from" Knuth, The Art of Computer Programming, vol. 3, §6.4 (linear probing, and multiplicative hashing with the multiplier ⌊2³²/φ⌋); working on weighted distinct colours, "Inspired by" Celebi, "Improving the Performance of K-Means for Color Quantization", Image and Vision Computing 2011; runs, "Inspired by" Breuel, "Efficient Binary and Run Length Morphology", 2007; flatness, "Inspired by" Pass, Zabih & Miller, "Comparing Images Using Color Coherence Vectors", ACM Multimedia '96; the parallel histogram, "Method from" Podlozhnyuk, "Histogram calculation in CUDA", NVIDIA 2007, and Henriksen, Hellfritzsch, Sadayappan & Oancea, "Compiling Generalized Histograms for GPU", SC20 (privatised sub-histograms merged at the end; adapted: sparse, and associativity checked per input rather than assumed); ink founding, "Method from" Hartigan, Clustering Algorithms, 1975, the leader algorithm, as described in Arnold's R package leaderCluster; the lookup table, "Method from" Thomas, "Efficient Inverse Color Map Computation", Graphics Gems II, 1991; labelling, "Method from" Swain & Ballard, "Color Indexing", IJCV 1991 (histogram backprojection); the run-wise label count, "Inspired by" Collet's HIST_count_parallel_wksp in FiniteStateEntropy; the rounding, the exactness lemma and the integer share count, "Not from the literature", each with "See also" Goldberg, "What Every Computer Scientist Should Know About Floating-Point Arithmetic", ACM Computing Surveys 1991 (the lemma also names Demmel & Nguyen, "Fast Reproducible Floating-Point Summation", ARITH 2013, as the published approach it does not use, because pre-rounding would change the values); the serial threshold, "Inspired by" rayon's with_min_len. Considered and not used (palette.rs:85-96): accelerated nearest-ink search (Elkan, Hamerly), a distance transform (Rosenfeld & Pfaltz 1966), memoising blend labels, and VTracer's clustering, which has no global palette and would change the output.

Costs. Before, at 2048 px: 42.6 ms, 99% of it in the four-channel copy (6.9), the keys (4.3), the serial histogram (17.8), labelling (3.3) and an unread running share (9.8); the decisions themselves 0.16 ms. At 128 px: 1.75 ms, of which 0.37 ms was spawning rayon's global pool (the palette was the process's first parallel call) and about 0.65 ms allocating and scanning 65,536-entry tables for 16,384 pixels. After, in the merge measurement: 47.7 → 7.1 ms at 2048 px opaque, 1.85 → 0.40 ms at 128 px. At 128 px fit_dp rose by 0.35 ms, because it now pays the pool start the palette no longer does: that cost moved, it did not disappear. Identity: as for intake above; the old palette is kept verbatim as the test oracle fast/palette/reference.rs.

3. Region clean-up on row runs

What it computes, in the field's terms (fast/faces.rs:1-30): connected-component analysis of the flat zones of the label image (a flat zone is a maximal 4-connected set of pixels of one label), followed by region merging on the region adjacency graph. Five passes, six on a lossy intake, in the order the front end calls them (fast/front.rs):

  1. Slivers (absorb_slivers, faces.rs:325, mark slivers): pixels of strips — components with no interior pixel — that are blends of the inks of the thick components around them go to the nearer of those inks. Two-endmember linear unmixing, pixel by pixel: t = clamp(((c − a)·(b − a)) / |b − a|², 0, 1) and distance |c − (a + t(b − a))|² within BLEND_TOL² = 0.04², in straight sRGB and opacity (blend_of, faces.rs:159-189).
  2. Rims (absorb_rims, faces.rs:431, mark slivers): a strip whose own ink is a blend of two inks it borders is an anti-aliased rim; each of its pixels goes to the side it covers more of. 2b. Codec halos, lossy intake only (absorb_halos, faces/halos.rs, mark slivers; see "Noisy input" below): thin components whose colour lies between two inks around them in JPEG's colour space go back to those inks, pixel by pixel by luma.
  3. Same-ink merge (merge_same_inks, faces.rs, mark slivers): a component takes the ink of a larger neighbour the eye cannot tell from its own (CIEDE2000 below SAME_INK_DE00, opacities within 0.02), largest components first, longest shared border winning. On a lossy intake a component with no interior of radius 2 takes a neighbour's ink up to SOFT_SAME_INK_DE00 (5.0) instead (see "Noisy input" below).
  4. Despeckle (despeckle, faces.rs:614, mark despeckle): components under the speckle floor take the label they share the longest border with — an area filter on flat zones. The floor is min_region, or 4 px per 512 × 512 of image up to 16 px, VTracer's default 4 × 4 patch (speckle_floor, fast/front.rs:31-46).
  5. Faces (write_faces, faces/runs.rs:493-534, mark split): the components of the result are the faces; their ids are written over the label buffer. Ids are u16, and Fast numbers at most 65,534 faces (cap = u16::MAX − 1, runs.rs:513). Past that, since be5abad (merged at d8a3650, 2026-10-03), the label image is written out, its smallest components are merged into their neighbours until 65,534 remain, and the runs are read again before the ids are written. The merge is regions::cap_components (regions.rs:293-416), the same function Quality's split_components calls past its own limit of 65,535 (MAX_FACES, see 04-regions.md). In rounds: take the count − cap smallest components (by size, then by the raster order of their first pixel), and in that order give each the neighbouring label it shares the most pixel edges with (the lower label on a tie), unless a neighbour of it was already relabelled this round or it was itself chosen as another's target. Each relabelling removes a component, so the rounds end; every face is then still one 4-connected component of one ink, the smallest specks drawn in their neighbour's ink. Before, every component past the limit was folded into face 0, which then held pixels of many inks under one colour. An image with fewer components takes exactly the old path. Tested on a 300 × 300 checkerboard, 90,000 components (faces/tests.rs:36-53); the implementing branch (impl2/robust) found no real image that reaches the limit in Fast, whose palette and despeckle keep two-ink noise at 18,000 to 35,000 faces.

Noisy input: codec halos and ink variants (fast/faces/halos.rs, 2026-10-10; Quality runs the same passes on a lossy intake, with the halo test in a stricter form and a variant merge of its own, fast/codec.rs, stage 04). Both passes run only on a lossy intake (ColorOptions::lossy_intake, from the container) of an opaque image; a clean intake takes neither, and the six clean gate conditions are byte-identical. They read the evidence of the pixels, not the flag alone: a raster with no halos keeps its components.

The problem. On the web tier (512 → 400 px bicubic, JPEG quality 80, 4:2:0) Fast's palette founds the codec's halos as inks: a band two or three pixels wide on each side of an edge whose luma overshoots and whose chroma has bled, independently of the luma, so its colour lies 0.08-0.09 sRGB off the line between the two inks, twice BLEND_TOL. Neither absorb_slivers nor absorb_rims recognises it: twemoji/1f1fa, a blue flag with a white letter, came back with four inks and 215 faces (the clean raster: two inks, three paths). And the codec tints an ink with its neighbours' chroma block by block, so one black outline comes back as #010101, #010009 and #080000 (openmoji/1F9D1), which tile it into fragments with no interior; Fast's parameter ratio on web was 7.2 against 3.5 on the opaque 512 px tier (openmoji 13x, twemoji 8.9x).

The halo test (RunLabels::absorb_halos). In BT.601 full-range YCbCr, JPEG's own space, a component is a halo of inks a and b when (1) it is thin: no pixel of it has a 5 × 5 window of its own label (HALO_REACH = 2; a JPEG halo reaches about three pixels, which the radius-one interior rule of the other passes misses; deep_components, on runs); (2) a and b are inks of thick components bordering it, or bordering a thin component that borders it (the band between blue and white is a dark halo beside the blue and a light one beside the white); (3) its mean colour lies channel by channel in [min − τ|Δ| − ε, max + τ|Δ| + ε] of the two inks' values (HALO_OVERSHOOT τ = 0.2, HALO_SLACK ε = 0.02), the channels independently. A thin component of an ink that has a thick component somewhere else is held to more: it must be a strip at radius one (STRIP_REACH) and lie within HALO_LINE_TOL = 0.04 of the segment between the inks (a chroma-weighted YCbCr distance). The box alone takes real ink: twemoji/1faa3's #55ACEE rim, four pixels wide between a navy and a white, sits in it and 0.054 from the segment, where the codec's halos sit 0.009-0.035 from theirs. Of the passing pairs the nearest segment wins; each pixel goes to b when its coverage of b, read with chroma at a quarter of luma's weight on squared differences, is at least ½, which keeps each ink's area. Then an ink some of whose components were halos and that has no thick component left is a halo ink, and each remaining component of it that borders exactly one other ink takes that ink (a fleck of halo inside one ink; a ring of it around an eye, which borders two, stays as it is).

The thin-variant merge. merge_same_inks gets a second threshold for components with no 5 × 5 interior: SOFT_SAME_INK_DE00, the soft floor Quality's palette applies on the same intakes. A component with an interior keeps SAME_INK_DE00, so two flat inks a few dE00 apart (the mosaic's neighbours) stay two.

Measured (regression gate, 246 icons, fast-web against the recorded baseline): dE00 −11.83 % (95 % CI −13.90 to −9.64), turning −50.53 %, parameter ratio −42.79 % (7.22 → 4.13), geom −6.84 % (−11.27 to −2.80), all "better"; 113 icons change, 104 better and 9 worse in dE00. By family (dE00, ratio, geom): openmoji 0.297 → 0.204, 13.19 → 3.88, 0.152 → 0.121; twemoji 0.312 → 0.275, 8.92 → 3.71, 0.232 → 0.164; noto-emoji 0.496 → 0.460, 4.12 → 2.38, 1.493 → 1.514; synthetic 0.148 → 0.138, 11.35 → 5.99, 0.315 → 0.225; lucide, material-icons and simple-icons byte-identical (one ink on white: no chroma to bleed, and the luma's ringing stays on the grey line the existing passes already take). twemoji/1f1fa: 4 inks and 215 faces → 2 inks and 3 paths, dE00 0.242 → 0.144, geom 0.245 → 0.091, ratio 36.5 → 2.7. The halo pass alone, before the thin-variant merge: dE00 −9.67 %, ratio −39.0 %, geom −6.35 %. The first version, which held every ink to the box test and let a speck bordering several inks take the one it shared most border with, read dE00 −11.34 %, ratio −43.5 %, but took twemoji/1faa3's rim (dE00 0.212 → 0.344) and filled openmoji/1F9D1's eye with yellow (0.326 → 0.446).

Considered and not used. coverage::ringing_score as a second signal, for a JPEG re-saved as PNG: about 100 ms on a 2048 px image under load, a third of Fast's trace, on every image clean or not; the container flag costs nothing on a clean intake.

Method: everything on row runs (fast/faces/runs.rs). The label image is held as its maximal row runs (RunLabels); a run is scanned 8 labels at a time while they all equal the run's label, a compare the compiler turns into one vector instruction (push_row_runs, runs.rs:124-165). On runs:

  • components are union-find over runs: each run is united with the overlapping runs of the row above that carry its label, walked with two pointers; the smaller run index is kept as the root, so one forward pass numbers the classes in order of first appearance, reading each run's parent's id instead of running a find (components, runs.rs:218-290);
  • interior-ness is decided per run: pixel (x, y) of label l is interior iff its four neighbours carry l, so a run x0..x1 holds an interior pixel iff some x in x0 + 1 .. x1 − 1 lies under a run of l in the row above and over one in the row below (interiors, runs.rs:292-357);
  • the region adjacency graph with border lengths is read off contacts: consecutive runs of a row touch across one pixel edge, runs of adjacent rows across the length of their overlap, and every pixel edge between two components lies in exactly one contact (for_each_contact, runs.rs:359-406);
  • an edit (a new label per component, or a few single pixels) rebuilds the run list in one pass, merging neighbours that end up with one label, so the runs stay maximal (relabel_components, apply_pixel_edits, runs.rs:408-491).

The labels are read once, when the runs are built, and written once, with the face ids (past the face-id limit, once more: written out for the merge and read back); pixel colours are read only at strip pixels, in place, never copied (Pixels, faces.rs:119-153).

Why the output is the per-pixel code's (faces.rs:53-77): maximal runs are a canonical form of a label image, and every edit keeps them maximal, so the runs after each pass are exactly the runs the per-pixel code would read off its relabelled image; a 4-neighbour is in a pixel's component iff it has the pixel's label, so every count summed over contacts is the per-pixel count; each pass reads the labels as they were before it and chooses maxima or minima under total orders with explicit tie-breaks, so visiting order does not matter (where it did, in the sliver candidates' order, it is reproduced); and the floating-point expressions are the same functions on the same four floats. The per-pixel code is kept as a test oracle (faces/reference_tests.rs), checked on random and degenerate images and on 254 research label dumps.

Citations (labels as in the doc comments): labelling on runs, "Method from" Lemaitre & Lacassagne, "How to speed Connected Component Labeling up with SIMD RLE algorithms", WPMVP 2020 (features on runs; the final per-pixel relabelling is "very expensive" and "should be avoided"), and Wu, Otoo & Suzuki, "Optimizing two-pass connected-component labeling algorithms", PAA 2009 (the equivalence table whose root is the smallest index, and FLATTEN); "See also" He, Chao, Suzuki & Wu, "Fast connected-component labeling", Pattern Recognition 2009; the vector run scan, "Inspired by" Lemaitre & Lacassagne; despeckle and the same-ink merge, "Method from" Salembier & Serra, "Flat zones filtering, connected operators, and filters by reconstruction", IEEE TIP 1995 (connected operators: they act on whole flat zones and never split one); the speckle floor, "Inspired by" Selinger's Potrace turdsize and VTracer's filter_speckle, which delete a small region where this gives it to a neighbour; merging, "See also" Felzenszwalb & Huttenlocher, "Efficient graph-based image segmentation", IJCV 2004, and Najman & Cousty, "A graph-based mathematical morphology reader", 2014; unmixing, "See also" Bioucas-Dias et al., "Hyperspectral unmixing overview", 2012; reading pixels on demand, "Inspired by" Halide (PLDI 2013), store versus recompute; the merge past the face-id limit (on regions::cap_components), "Method from" Haris, Efstratiadis, Maglaveras & Katsaggelos, "Hybrid image segmentation using watersheds and fast region merging", IEEE TIP 1998 (region merging on the region adjacency graph, smallest and most similar first; adapted: the order is size alone, "most similar" is the longest shared border, since the labels carry no colour there, and the stop is the face-id limit rather than a dissimilarity threshold). "Not from the literature": the interior test and the border lengths on runs, the observation that the component table plus the adjacency graph with border lengths are sufficient statistics for every pass (colours are needed only at strip pixels), and the strip criterion itself. Rejected, with the numbers that decided it (faces.rs:105-112, runs.rs:65-72): caching sliver decisions (about one distinct key per strip pixel), pixel-scan decision-tree labellers such as BBDT and Spaghetti (they speed up the union-find, 1.05 ms of the 65.6 ms stage), dynamic connectivity (the union-find on runs is already 0.08–0.65 ms), and parallel stripes (at most about 1 ms left to win).

Costs. Before, at 2048 px: 18 passes over the image (22 with the same-ink merge), about 118 bytes of traffic per pixel, 65.6 ms, 39% of the whole trace — although all the passes together change at most 3.6% of the pixels (none at the median) and runs are 0.24% of the pixels at the median. After (slivers + despeckle + split, per image): 128 px 0.50 → 0.09 ms; 512 px 5.73 → 0.57 ms; 2048 px 76.2 → 3.86 ms; bigalpha 82.3 → 3.9 ms. trace_total at 2048 px: 193 → 116 ms. Identity, --mode fast: screen 246/246, held_a 156/156, big 7/7, bigalpha 3/3, s512 58/58, flat 1/1; and on 60 screen icons --no-background, --monochrome and Quality 60/60 each.

4. Ramps

What it computes (fast/bands.rs:1-32, merge_ramps, :318-500). A smooth gradient has no flat colour, so the palette quantises it into bands, and each band is a face with a boundary of its own. This is the one-shot version of Quality's pairwise band merging: adjacent faces whose inks are within RAMP_STEP = 0.09 OKLab and that share at least 3 pixel edges are joined by union-find into clusters; each cluster of two or more faces and at least 64 pixels is sampled on an even grid, fitted once with Quality's own gradient models (gradient::fit_pixels at the noise floor with the BIC penalty, then gradient::select), and kept when its RMS residual g over interior samples satisfies g ≤ max(1.25 · f + 1/255, 2/255), f being the flat bands' residual; two bands already within 1.5/255 are left flat. Opaque images with gradients on only.

Method, since 2026-09-30. Passes 1 to 4 read the palette and the row runs instead of the pixels (bands.rs:18-32):

  1. Palette precheck (inks_may_join, bands.rs:156-219): when no two different inks lie within RAMP_STEP, the pass returns before reading a pixel. It is a proof, not a guess: the faces are the 4-connected components of the ink map, so two faces that touch always carry different inks, and if every pair of different inks is farther apart than RAMP_STEP no join can happen and nothing would change. The proof holds past the face-id limit too: there write_faces relabels the smallest components with a neighbouring ink and reads the runs again (stage 3), so every face still has one ink. With 65,534 faces or more (CAPPED, bands.rs:200) the precheck returns true all the same and the full pass decides, as a conservative check only: it covers write_faces' fold of leftover ids into face 0, reached only if cap_components stopped short of the cap, which its proof rules out.
  2. Row runs of the face map (planar::runs::RowRuns, shared with the planar map).
  3. Contacts (contacts, bands.rs:72-130): the border length of every touching pair of faces, read off consecutive runs and the overlaps of adjacent rows' runs, keyed and summed after a sort; no join means return.
  4. Samples (gather_samples, bands.rs:254-316): each cluster's grid sample, read off the runs in increasing pixel index, the order the fit sums in; divisibility is tested without % (wazero's arm64 miscompile).

Citations (labels as in the doc comments): contacts and samples, "Method from" He, Chao & Suzuki, "A Run-Based Two-Scan Labeling Algorithm", IEEE TIP 2008; contacts, "Inspired by" Ji, Piper & Tang, "Erosion and dilation of binary images by arbitrary structuring elements using interval coding", Pattern Recognition Letters 1989; the precheck, "Not from the literature: a necessary condition for a join, checked on the palette before the image", "See also" He & Chao, "A Very Fast Algorithm for Simultaneously Performing Connected-Component Labeling and Euler Number Computing", IEEE TIP 2015.

Costs. Before, at 2048 px: 14.8 ms (contacts 4.6, gather 5.9, fit 3.8). The precheck proves the pass empty on 5 of the 7 opaque 2048 px images, 18 of 26 at 512 px and 3 of 5 at 128 px. After: counted in the shared totals of stage 5. The pixel versions are kept as contacts_scan and gather_samples_scan and the tests compare them.

5. The shared planar map and refinement

What it computes. The planar map of the faces — every boundary between two faces stored once, junctions exact on the lattice — then every boundary point moved to its sub-pixel position by unmixing the two faces' fills, junctions settled, and mirror symmetry detected and restored. Shared with Quality; documented in 06-planar-map.md, 07-subpixel.md and 10-symmetry.md. Fast passes the refinement the noise floor (coverage::NOISE_FLOOR) rather than a noise measurement — "the fast fitter reads no per-point uncertainty, and the refinement only needs a floor under its contrast test" (fast/front.rs:152-153) — and skips the boundary solve and decode.

Method, since 2026-09-30.

  • Cracks from row runs (dual_segments, planar/cracks.rs:213-348): vertical cracks at run starts, horizontal ones where the runs of two rows overlap with different labels.
  • Incidence by radix sort with 11-bit digits, in emission order, and O(1) walk steps through each segment end's recorded node (Incidence, planar/cracks.rs:54-211).
  • Refinement measured in parallel, then applied: a pure map over edges and, on edges of 64 points or more, over vertices, with no parallel reduction; serial under 512 boundary vertices or while a debug printout or contour dump is on (measure_subpixel, planar.rs:495-596).
  • Symmetry detection beside the measuring phase, under rayon::join; both only read the lattice map (lib.rs:1097-1130).
  • Junction debug flags read once into a OnceLock (planar/junctions.rs:54-70).

Citations (labels as in the doc comments): cracks, "Method from" He, Chao & Suzuki 2008 and He, Chao, Suzuki & Wu 2009, "Inspired by" Ji, Piper & Tang 1989, "See also" Kovalevsky 1989 and Damiand, Bertrand & Fiorio 2004; the radix sort, "Method from" Knuth, TAOCP vol. 3, §5.2.5; the parallel refinement, "Method from" Blelloch, Fineman, Gibbons & Shun, "Internally deterministic parallel algorithms can be fast", PPoPP 2012, with Demmel & Nguyen, ARITH 2013, as related work; running detection beside it, "Inspired by" Halide (PLDI 2013).

Costs. Before, at 2048 px opaque: build_map 15.9 ms (the crack scan 9.9, two comparison sorts 1.5 each), the refinement 10.6 ms serial at 0.44 µs per vertex. After, for the shared stages the round rewrote — the ramp pass, map and refinement together, plus the face-alpha pass of stage 7 on transparent images (the merge notes call them "shared stages in total"; their before figures match the research's per-stage costs, 14.8 + 15.9 + 10.6 ms, plus 88.5 ms of face alpha) — per image: 2048 px opaque 42 → 13 ms, 2048 px transparent 130 → 21 ms, 512 px 10.2 → 5.1 ms, 128 px 1.8 → 1.2 ms. Quality gains the map and refinement part too. Identity: 100% in Fast and Quality on screen, held_a, s512, big, bigalpha, flat and std; also --no-native-alpha --cutout, and --no-background / --monochrome on 60 icons. The WebAssembly and WASI builds compile, and the new hot loops use no %.

6. The Potrace-class fitter

What it computes. One fitted path per edge of the map, lines and cubic Béziers — or, for a closed edge that is a circle or an ellipse, that primitive. Each shared edge is fitted once and both faces draw the same curve, so the fitter cannot open a seam. In the field's terms it is the classical pipeline of Selinger's Potrace (2003) — optimal polygon, vertex adjustment, corner-aware smoothing, curve-run optimisation — "restated for sub-pixel input": Potrace fits a lattice path of pixel corners, where these points already sit at their measured sub-pixel positions, so every lattice test becomes a metric one (the module overview of fast/mod.rs and of polygon.rs). "Written from the paper; no Potrace or Trazor source was read or used."

This section describes the fitter as it stands after the fitter round of 2026-09-30, which changed how the polygon and the primitive test are computed and writes the image frame as the image rectangle; "The fitter round of 2026-09-30", at the end of the section, lists each change and how it was checked.

Per edge, in parallel (fit_edges, fast/mod.rs:380-427): the edges are fitted as a rayon parallel map, output in edge order whatever the thread count. The image frame is recognised first and written without fitting (below). Every other edge's tolerances depend on the OKLab contrast between its two faces' representative colours (1 when either face is outside the fills, at the image border): at or above FAINT = 0.12 they are as given; below, every distance tolerance but flat grows as FAINT / contrast, up to MAX_LOOSEN = 3×, because where the two sides are close in colour a misplaced boundary costs little; a boundary of a gradient face is fitted as if its contrast were at most FAINT / GRADIENT_LOOSEN = FAINT / 1.6, because a fitted gradient is a coarser model of its pixels than a flat ink and its boundary comes back rougher (FastFit::for_contrast, mod.rs:257-281). The defaults (FastFit::default, mod.rs:116-126): polygon tolerance 0.5 px, vertex box 0.5 px, corner tolerance CORNER_TOL = 0.1 px (Potrace's alphamax as a distance; mod.rs:91-114), merge tolerance 0.2 px (Potrace's opttolerance), and 0.05 px for a cubic to count as a line. --mode balanced runs the same fitter with FastFit::balanced (§8). A closed edge of 8 points or more drops its first point, the lattice node the refinement leaves up to 0.6 px off the edge (fit_edge, mod.rs:292-299).

The corner tolerance, 0.25 → 0.1 px (2026-10-04). The r2-fastq study (2026-10-02) swept it with everything else in Fast fixed and found slack on both axes: a corner is two lines, 4 parameters, where the smoothing cubic it replaces is 6, and on drawn icons the corner is what the artist drew. Its sweep on the 128 px screen set read dE00 0.3640 → 0.3586 and parameters per artist's parameter 2.120 → 2.032 at 0.1 px, saturating by 0.05 px. The gate (v2, against v0.2.5 on Windows, 246 icons each) judged the change on its own:

condition dE00 turning ratio verdict
fast-128ss 0.32722 → 0.32224 (−1.52 %, upper −1.30 %) +1.30 % (upper +1.83 %) 2.206 → 2.112 (−4.29 %) better, non-inferior, better
fast-512ss 0.09950 → 0.09934 (−0.16 %) +0.71 % 3.323 → 3.271 (−1.59 %) non-inferior, non-inferior, better
fast-512ssop 0.09078 → 0.09058 (−0.22 %) +0.12 % 3.620 → 3.563 (−1.57 %) non-inferior, non-inferior, better

Quality is byte-identical (the threshold is Fast's alone). By family at 128 px every family's dE00 fell (material-icons −3.7 %, lucide −2.4 %, noto −0.4 %); at 512 px lucide rose 0.5 % (7 icons better, 14 worse) and the rest fell or held. The largest single rise is simple-icons/alchemy at 512 px, 0.0249 → 0.0339, one rounded corner of the base drawn sharp.

The steps follow the order of the module overview (fast/mod.rs:43-50): fit_edges → frame_rectangle, then fit_edge → prims::primitive, then fit_denoised.

First, the image frame (frame_rectangle, mod.rs:321-378). When one face runs round the whole image border — a transparent icon's clear ground, or a background colour — the planar map traces the border as one closed ring on the lattice's outer nodes, which the sub-pixel refinement leaves in place, so the ring is the rectangle [-0.5, w − 0.5] × [-0.5, h − 0.5] (pixel centres at integers). A closed edge of at least 4 points whose every point lies on that rectangle's border, and which passes each of its four corners exactly once, is written as four lines through the corners, in the ring's own direction, from the first corner the ring reaches: 8 parameters, O(n) to recognise. The comparisons are exact, because the border nodes are exact binary fractions the refinement never moves. Anything else — an open edge, a ring with a point off the border, an inner rectangle — goes on to the fit.

Then, for every other edge (fit_edge, fit_denoised):

  1. Primitives (prims::primitive, prims.rs:99-242), closed edges of at least 12 points only, on the ring's denoised points, every point weighted with σ = 0.5 px: a cascade of cheap tests before expensive fits.

    • Kåsa's algebraic circle fit (fit_circle_kasa, one linear least-squares solve) rejects a ring whose radius is under 1.5 px or not finite, or that has a point more than 0.6 r from the circle: not round at all.
    • When the Kåsa circle lies within 0.6 px (2 · TOL) of every point, the orthogonal-distance circle (fit_circle, Levenberg–Marquardt) is accepted, with 3 parameters, if it lies within 0.3 px (TOL) of every point and the ring encloses at least 80% of π r².
    • Otherwise an ellipse. A bound rules most rings out before any ellipse fit: with span the largest distance from the first point, an ellipse within TOL of every point has rx ≥ span / 2 − TOL, while the area test with ry ≥ 1.5 px needs rx ≤ |A| / (0.8 · π · 1.5); when the bounds cross, no ellipse can pass. Then Taubin's algebraic conic (taubin_ellipse) must lie within 0.6 px of every point, and the orthogonal-distance ellipse within 0.3 px, with both radii at least 1.5 px, the larger at most span, and the area test passed: accepted with 5 parameters. The algebraic conic is Taubin's geometry alone: the library's fit_ellipse_algebraic also computes the ellipse's orthogonal χ², a Newton foot-point solve per point that nothing here reads. Levenberg–Marquardt then starts from that conic alone (fit_ellipse_seeded, crates/inkvec-fit/src/primitives/ellipse.rs:346-389; taubin_ellipse, :197-219). The library's fit_ellipse also starts from four near-circles about the orthogonal circle (radii 1.02 r and 0.98 r, at 0°, 45°, 90° and 135°) and keeps the lowest χ² of the five; here those four run only when the algebraic start fails, reusing the circle the round test already fitted when it fitted one.

    The primitive is drawn as four cubics, the standard approximation of a quarter arc with control arms k = 4/3 · tan(Δt / 4), starting on the ray from the centre through the ring's first point and running the ring's own way (ellipse_cubics, prims.rs:47-97). Rounded rectangles are left to the curve fit.

  2. Denoise (smooth::denoise, smooth.rs:51-105): a [1 2 1] / 4 binomial low-pass along the boundary, which removes the 0.1 px alternation the lattice leaves in refined points and moves a curve of radius r by only 1 / 4r px; a point that turns sharply over two steps each way (more than 50°) is kept as a corner, and the ends of an open boundary, which are junctions, stay put. A ring is denoised once: fit_edge hands the points it denoised for the primitive test straight on to fit_denoised (mod.rs:212-255, :283-319), because denoise is a pure function of the points and the closed flag and a second call could only return the same vector.

  3. Optimal polygon (polygon::open, polygon.rs:250-392; polygon::closed, polygon.rs:638-652; Selinger §2.2) — in the field's terms the min-# problem of polygonal approximation: the fewest straight sides that stay within poly_tol of every point, and among those the one closest to the points. A side i → j is admissible when the direction p_j − p_i lies in the cone of directions from p_i that pass within tol of every point between them (each point narrows the cone by asin(tol / r); Cone, polygon.rs:135-199), no point has fallen back towards p_i by more than tol, and it spans at most MAX_SPAN = 160 points. A dynamic program over vertices minimises (sides, Σ squared distances) lexicographically, the distances read in O(1) from prefix sums taken relative to the first point, so that the squares of large coordinates do not swamp the differences (Sums, polygon.rs:69-133). A closed ring is cut at its sharpest point and solved as an open run back to it. Three exact speed-ups, all keeping the vertex lists bit for bit those of the plain program, which the tests keep as tests::open_ref:

    • Fathoming (polygon.rs:272-298): branch and bound inside the dynamic program, with the side count as an exact integer bound. A side whose count best[i].sides + 1 already exceeds best[j].sides cannot win at j whatever its penalty, so the penalty is not computed (89% of the admitted sides on the screen set, 55% at 2048 px, replay); an anchor with best[i].sides ≥ best[n−1].sides is not scanned, since every path through it ends with more sides than one the end already has (18.9% of the scan steps on the screen set start at such an anchor). The doc comment proves by induction over the index that every entry with fewer sides than the final count is decided by the same anchors through the same IEEE expressions in the same order, so the path is unchanged.
    • Lattice runs in closed form (lattice_runs, polygon.rs:215-240; scan_anchor, polygon.rs:552-636): 62–72% of the scan steps, and 98% of the image frame's, lie on a run of exactly equal steps, counted for every step in one backwards pass. Once the scan is under way on one — cone open, the last point more than 2 tol from the anchor, the step at most RUN_MAX_STEP = 4 px, tol ≥ RUN_MIN_TOL = 1/16 px — every point to the run's end is admitted, and the cone and reach after it are the last point's alone, so the per-point cone work (a hypot, three divisions, a square root and four cross products on a loop-carried dependency) is skipped. The sides are still offered one by one, in order, because their penalties break ties by rounding. The proof (polygon.rs:313-333): rounding moves each run point's computed direction and cone bounds by less than 10⁻¹⁴ rad; the distances grow by |s| per point, so the reach test never fires; each point's cone is strictly tighter than the last's by at least 6·10⁻⁷ rad; every direction lies inside the previous point's cone by at least 9·10⁻⁵, far beyond the admission slack of 10⁻⁹; and r > 2 tol keeps the cone within 60°, so it never empties.
    • Parallel admissibility (admitted_sides, polygon.rs:507-550): which sides an anchor admits depends only on the points and the tolerance, never on the table. A boundary of PARALLEL_MIN = 2048 points or more scans every anchor on all cores first, into a 160-bit set per anchor (SideSet, polygon.rs:448-470), then relaxes the table sequentially in anchor order, offering the same sides in the same order; the sequential scan's skipped cone tests are exactly the sides whose offer is a no-op, so the polygon is the same on any thread count. The price: anchors the table would have fathomed are scanned too, and 24 bytes per point. Such boundaries are rare — none of the 6,645 edges of the screen set, 16 of the 564 at 2048 px, which include the image frame and set the fit's wall time (replay). The set's bits are written by shift and mask, not / 64 and % 64, so that no remainder appears in a hot loop after wazero's arm64 i32.rem_u miscompile.
  4. Vertex adjustment (smooth::adjust_vertices, smooth.rs:209-253; Selinger §2.3.1): each side gets the total-least-squares line through the points it spans, and each interior vertex moves to the point minimising its two sides' squared distances within a box of half-width vertex_box around its polygon position, with a small ridge (RIDGE = 1e-3) towards that position so parallel sides stay well posed. The ends of an open run stay exactly where they are.

  5. Smoothing into pieces (smooth::pieces, smooth.rs:314-432; Selinger §2.3.2): one join per side, moved off the side's line to where the points are (by at most JOIN_MAX = 0.5 px, since a curve drawn through the unmoved midpoints would shrink every round shape), and at each vertex the cubic from join to join, tangent to both sides, with its two arm lengths fitted to the points between the joins by Schneider's tangent-constrained Bézier fit (curve::fit, curve.rs:79-173) and capped so neither control point passes the vertex (Potrace's alpha ≤ 1). The vertex is a corner — two line pieces — when that cubic misses the points by more than corner_tol and by more than twice what the polyline through the vertex misses them by, or when the fit fails.

  6. Curve-run optimisation (curve::optimise, curve.rs:334-365; optimise_run, curve.rs:240-332; Selinger §2.4, Potrace's opticurve): runs of smooth pieces between corners are merged where one cubic says the same thing. A closed boundary is first rotated to start at a corner, when it has one, so no run is cut in two where the ring happens to start; each maximal run of smoothly joined pieces is then optimised on its own, so a corner is never smoothed over. Within a run a dynamic program over piece boundaries takes the fewest cubics: best[j+1] = min_i best[i] + 1, over i = j (the piece alone) and every i for which pieces i..=j turn one way (a piece turning less than 10⁻⁶ rad counts as either), by at most MAX_TURN = 3.10 rad in total, number at most MAX_RUN = 24, and merge into one cubic within opt_tol of every sample. The merged cubic keeps the run's end points and end tangents, and its two arm lengths are fitted (curve::fit) to samples of the pieces at t = ¼, ½, ¾ and at every join between them. On a tie the smallest i is kept, and the cubics are read back from the last piece. O(n · MAX_RUN) candidate runs for n pieces, each merge linear in its run.

Two rules, since 3513beb (merged at 46e92e4, 2026-10-03), keep every run drawn (curve.rs:251-268): * A single piece is always its own cubic, whatever it turns. The piece alone is admitted before the turn test, and only merges of two or more pieces are held to it. It used to be admitted only after the turn test, so a piece turning more than MAX_TURN — a U-turn at a vertex whose sides double back, |turn| ≈ π — left best[j+1] unreachable, and every later prefix with it; the read-back then started from an unset best[n] and returned the run's last piece alone as if it were the whole run. On a ring that is one cubic, which the fit then closes on itself, and the face is not drawn. The doc comment says why Potrace has no such failure and this program can: Potrace's polygon never doubles back, while here smooth::adjust_vertices moves the vertices off the points within loosened boxes. * A run that is the whole ring is never one cubic. A ring with no corner, or with one, is a single run that starts and ends at the same join, and one cubic with both ends there can only draw a loop or a sliver; the one candidate that covers every piece (i = 0, j = n − 1) is not tried, so a closed boundary of two or more pieces comes back as two or more cubics. A ring turns a full turn, so with honest turns MAX_TURN already rules that merge out; the guard holds where the measured turns do not add up (each is read in (−π, π], so a piece that turns further reads short).

Where every piece turns at most MAX_TURN and no whole ring merges into one cubic — every boundary the old code drew correctly — the loop makes the same choices in the same order, so those fits are unchanged bit for bit. Measured by the implementing branch (impl2/bugs report: SVG hashes, and the gate's A/B of 3513beb against the commit before it at the gate's 128ss and 512ss tiers): the fix changes the output of 9 of the 402 gate icons at 128ss, 3 at 512ss, and the 1672 × 941 masthead. Gate dE00 at 128ss: screen 0.3640 → 0.3639 (3 better, 1 worse), held_a 0.3632 → 0.3630 (2 better, none worse); at 512ss: screen 0.1106, unchanged (1 better). Parameter ratio 2.120 → 2.121 (128ss screen) and 3.192 → 3.193 (512ss screen). The largest changes: simple-icons/visa at 512 px, dE00 0.0479 → 0.0381, and openmoji 1F9D1-200D-1F393, −0.026. Off the gate, the masthead goes from 0.9051 to 0.9044 with 8441 → 8491 parameters (thin rings that had collapsed are drawn again, by the commit message). The case that found it was synthetic/gradient_radial at 512 px traced in Fast with the research boundary solve switched on (research build r2-fastq): piece 37 of 38 turned −3.109 rad, the ring collapsed to one cubic, and dE00 was 10.62; with the fix built on that research commit it traces at 0.0587 (plain Fast 0.0586).

  1. Segments (curve::to_segments, curve.rs:382-416): a cubic whose control points lie within 0.05 px of its chord, and project inside it, is written as a line; consecutive collinear lines are joined; the ends are pinned to the junctions exactly (fit_denoised, mod.rs:239-249).

Boundaries too short for a polygon (fewer than 3 points, or 4 for a ring), and any stage that leaves nothing, are drawn as straight lines through their points (too_short, lines, mod.rs:182-210).

The fitter round of 2026-09-30 (branch impl/fast-fit, merged into integ/fast at e30fa8e). Byte-identical changes: fathoming (ebf9ecb), lattice runs in closed form (0cb5017), parallel admissibility (b9660c6), the primitive test without the unread χ² and with its seeds reused (37a4e40), the ring denoised once (d3ea154), and the side bits by shift and mask (e183cac). Outside the fitter's files, and inside the fit_dp mark: finish_color moves the traced labels instead of cloning them, and Fast builds no content-unit polylines or λ multipliers unless --editability asks for them (9f290b6; crates/inkvec-cli/src/pipeline.rs:369-371, :651-669; see 01-intake.md). Each rewrite keeps the code it replaced as a test reference (polygon::tests::open_ref and closed_ref, prims::tests::primitive_ref, tests::fit_edge_ref). Identity of the exact tip against main 55ee4e0: Fast 464/464 files, Quality 256/256, --no-background and --monochrome 60 each, --editability 30; and the polygons of all 11,021 replayed edges (21,072 polygon runs, at each edge's own tolerance and at the loosest) equal to the reference at 1, 4 and 16 threads. Quality's own ellipse fit is unchanged: fit_ellipse now hands both of its seeds to fit_ellipse_seeded, which given those seeds is bit-identical to it.

Two changes alter the output, and the gate (--mode fast) judged them:

  • The frame as the image rectangle (5520e6b). Screen: dE00 0.3641 → 0.3640 (1 image better, 0 worse), worst tenth 0.9218 → 0.9218, DISTS 0.0571 → 0.0570, parameter ratio 2.127 → 2.120. held_a: dE00 0.3632 → 0.3632, worst tenth equal, DISTS 0.0538 → 0.0538, parameter ratio 2.149 → 2.137. 12 of 253 screen and 2048 px outputs change, all opaque images with a background face: on a transparent icon the frame bounds the clear ground, which is not drawn, so only the fitting time goes.
  • The orthogonal ellipse from the algebraic start alone (d403d3b). It changes the output wherever a near-circle start would have reached a lower χ², so it is not provably identical. On the gate 0 of 464 SVGs changed (screen, held_a, the 2048 px set, s512, the 2048 px transparent set, the flat logo), and a unit test checks that the single start finds the same primitives as the five on 109 rings (circles, rotated noisy ellipses and the frame), to 10⁻⁶ px. It agrees with the library's own measurement: every one of the 93 ellipses the Quality search chose on the screen set and a poster came from the algebraic start (fit_ellipse_screened). Kept as a speed-up.

The final tip was checked again on Quality: 256/256 identical.

After the round, one more fitter change alters the output, on purpose: the curve-run fix of 3513beb (step 6 above), judged by the gate on its own.

Citations (labels as in the doc comments):

  • the optimal polygon, "Method from" Selinger, "Potrace: a polygon-based tracing algorithm", 2003, §2.2 (the optimal polygon as a shortest path, fewest sides then least squared distance, with capped spans); "See also" Imai & Iri, "Computational-geometric methods for polygonal approximations of a curve", CVGIP 1986 (min-# as a shortest path over the admissible sides), and Chan & Chin, "Approximation of polygonal curves with minimum number of line segments or minimum error", IJCGA 1996 (min-# in O(n²); the span cap makes this program O(n · MAX_SPAN)); "Inspired by" the cone-intersection test of Williams, "An efficient algorithm for the piecewise linear approximation of planar curves", CGIP 1978, and Sklansky & Gonzalez, "Fast polygonal approximation of digitized curves", Pattern Recognition 1980, used here only to decide which sides are admissible. Not used, with the reasons in polygon.rs:26-36: the greedy scan-along fitters themselves (faster, but not min-#, so the polygon would change), Agarwal & Varadarajan, "Efficient algorithms for approximating polygonal chains", DCG 2000 (its subquadratic bound is for a different error metric), and an approximate multiresolution program (refuted by the Quality fitter's research for changing the output);
  • fathoming, "Method from" Morin & Marsten, "Branch-and-bound strategies for dynamic programming", Operations Research 1976 (adapted to the lexicographic value, where the side count is an exact integer bound, so no relaxation has to be solved to get one);
  • lattice runs: counting them, "Inspired by" VTracer's straight-run walker (visioncortex) and Freeman, "On the encoding of arbitrary geometric configurations", IRE Trans. Electronic Computers 1961 (here the runs are only looked up, not compressed, because the dynamic program must still see every point); the closed form, "Not from the literature" (the published speed-ups for straight runs change what is fitted), "Inspired by" VTracer's walker, which compresses runs before fitting instead, "See also" Debled-Rennesson & Reveillès, "A linear algorithm for segmentation of digital curves", IJPRAI 1995 (digital straight segments of any slope; on sub-pixel points only an exactly repeated step is a run, so the simpler test suffices). A tangential-cover bound on how far a scan can reach (Faure, Buzer & Feschet 2009) was measured and dropped: it would have saved at most 9.5% of the steps;
  • parallel admissibility, "Inspired by" Brent, "The parallel evaluation of general arithmetic expressions", J. ACM 1974 (the part with no dependencies in parallel, the short dependent chain in order), scheduled by rayon's work stealing (Blumofe & Leiserson, "Scheduling multithreaded computations by work stealing", J. ACM 1999), which also lets the cores the per-edge loop leaves idle join in;
  • the primitives, "Method from" Taubin, "Estimation of planar curves, surfaces, and nonplanar space curves defined by implicit equations with applications to edge and range image segmentation", IEEE TPAMI 1991 (the algebraic conic), Ahn, Rauh & Warnecke, "Least-squares orthogonal distances fitting of circle, sphere, ellipse, hyperbola, and parabola", Pattern Recognition 2001 (the orthogonal fit), and Halíř & Flusser, "Numerically stable direct least squares fitting of ellipses", WSCG 1998, for starting the orthogonal fit from the algebraic one alone, which they recommend as "a fast and robust estimator of a good initial solution" (their direct fit; Taubin's conic plays that part here); dropping the unread χ², "Not from the literature". Kåsa's circle fit and Levenberg–Marquardt are named in prims.rs without a citation;
  • the image frame, "Not from the literature" (the border of the raster is known exactly, so there is nothing to estimate), "See also" Selinger 2003, which traces a bitmap's border like any other boundary; the ring denoised once, "Not from the literature" (it only removes a repeated computation);
  • the curve-run optimisation (curve::optimise_run, since 3513beb), "Method from" Selinger, "Potrace: a polygon-based tracing algorithm", 2003, §2.4 (opticurve: the fewest curves over runs of consistent convexity and less than a half turn); adapted: Potrace's own program has no read-back failure because its polygon never doubles back, while here the vertices are moved off the points by smooth::adjust_vertices with loosened boxes, and can;
  • the smoothing stages (smooth.rs) and the tangent-constrained Bézier fit (curve::fit), which the round did not change, name their sources in the text without the labels: Selinger 2003 §2.3.1 and §2.3.2, and Schneider's Bézier fit with fixed end tangents (the research report cites Schneider's 1990 Graphics Gems code, FitCurves.c).

Where the time went. Before the round (fitter research, 2026-09-30, on main): fit_dp took 1.01 ms per 128 px icon (16% of the engine's stages) and 26.75 ms at 2048 px (14%); its wall time is the slowest edge's (0.81 ms and 23.2 of 23.8 ms). Replayed on one thread, the polygon was 59% of the fitter at 128 px, 75% at 2048 px and 86% on the flat logo, the primitives 21.8% and 15.2%. The image-frame ring alone was 31%, 38% and 57% of the fitter's CPU and the slowest edge on 161 of 246 icons and 7 of 7 big images; its output was always four lines plus one spurious cubic chamfering the start corner (6 parameters too many, on 166 of 246 screen icons), because fit_edge drops the ring's first point, here the corner. In the primitive test, the unread χ² was 5.0% of the fast fit's CPU on the screen set (2.0% at 2048 px), most of it on the frame, which is roughly round and reaches the ellipse test; the five-start ellipse fit was 13.9% (10.9% at 2048 px).

After, combined (all four branches, main 55ee4e0 against the merged tip; the table under "The pipeline, in order"), fit_dp per image: 26.27 → 5.42 ms at 2048 px opaque (4.8×), 23.03 → 7.49 ms at 2048 px transparent (3.1×), 5.66 → 1.79 ms at 512 px (3.2×) and 1.00 → 0.46 ms at 128 px (2.2×). The fit branch measured alone against its own baseline (merge notes): 26.2 → 6.1, 23.3 → 9.1, 5.74 → 1.96 and 1.00 → 0.47 ms on the same four sets. By stage, on the replay, one thread:

  • the frame is no longer fitted at all; the closed-form runs had already cut its polygon to a sixth of the other edges' (0cb5017);
  • the polygon stage: fathoming took it from 381 to 211 ms on the screen set and from 622 to 504 ms on the 2048 px set (ebf9ecb); the closed-form lattice runs, measured on top of fathoming under a different load, from 318 to 104 ms and from 467 to 201 ms (0cb5017). The module overview (polygon.rs:54-58) quotes the second pair for all three speed-ups together;
  • long boundaries (2048 points or more, 2048 px set, smallest of 25 runs): the scan takes 52 ms on one thread and 11 ms on all cores, the sequential relaxation, which prices the 5.8 M live sides of the 12 M admitted, stays at 40 ms, and the polygon of those edges goes from 91 to 53–58 ms (polygon.rs:521-524). That doc comment counts 16 such edges in the set, and so does the one on PARALLEL_MIN, 16 of 564 (polygon.rs:497-502; it said 34 until 542a0da recounted them from the replay dumps);
  • the primitive test: 59 → 15 ms on the screen set and 54 → 13 ms on the 2048 px set for the single ellipse start, and the whole fit_edge 202 → 158 ms and 227 → 175 ms at that commit (d403d3b);
  • inside fit_dp, outside the fitter: the label clone was 1.28 ms at 2048 px and the polylines and λ multipliers 0.47 ms (9f290b6).

What is left on a long boundary is the sequential relaxation. Pricing every admitted side in the parallel pass would take it off the critical path, but needs 8 bytes per admitted side (96 MB on that set) and twice the pricing work, so it is not done (polygon.rs:525-527). At 128 px, fit_dp also pays the rayon pool start the palette no longer pays (stage 2).

7. Emit

What it computes. The SVG document, from the fitted paths, the faces' fills and the palette: shared with Quality and documented in 13-emit.md. Fast turns shape harmonization off (emit_options, crates/inkvec-cli/src/pipeline.rs:525-536). An image traced with its transparency keeps the native-alpha model: inks carry an opacity and the clear ground is an ink of its own (fast/front.rs:14-16).

Method, since 2026-09-30. The face-alpha pass that runs inside the emit mark (alpha::face_alpha) gathers every face's alpha statistics in one pass over row runs, and fits an alpha ramp (under --cutout) only for faces where the fit can succeed: a face with fewer than 64 interior pixels, or whose interior alphas are all exactly 1, provably gets None — in the second case the normal equations' right-hand side is bit for bit a column of the matrix, so Cramer's rule gives an exactly zero gradient. The candidates' pixels are gathered in one more pass instead of one whole-image scan per face. Details, proof and citations in 13-emit.md, "Gradient and fill emission".

Costs. Before, at 2048 px on the three transparent images: 88.5 ms of Fast mode's 333 ms, 60.7 ms of it in the per-face scans; 96% of the ramp calls on the research sets were provably None, and none of 3,125 ever returned a ramp. After: inside the transparent total of stage 5, 130 → 21 ms.

8. Balanced mode

What it computes. --mode balanced (opt-in; TraceMode::Balanced, crates/inkvec-cli/src/args.rs:29-33) is Fast with two of Quality's ideas added back at a fraction of their price, on rasters up to BALANCED_MAX_SIDE = 1024 px on their longer side (crates/inkvec-cli/src/fast.rs:64-66):

  1. the global boundary solve (stage 08, 08-boundary-solve.md), stopped after BALANCED_SOLVE_ITERS = 8 L-BFGS iterations (steps accepted by its Moré–Thuente line search) per independent part of the boundary (fast.rs:68-88), between the sub-pixel refinement and the fit, exactly where Quality runs it; and
  2. the Fast fitter with tolerances a quarter finer (FastFit::balanced, crates/inkvec-trace/src/fast/mod.rs:142-163): polygon tolerance and vertex box 0.375 px, merge tolerance 0.15 px (BALANCED_TOL_SCALE = 0.75, mod.rs:128-140), the corner threshold CORNER_TOL = 0.1 px as in Fast, and the cubic-to-line test unchanged.

Everything else is Fast's: the front end, flat fills (ramps only on opaque images), no curve DP, no ring repair, no harmonization. It reads the same options as Fast, and fast_ignored lists the same ones, --time-budget included: the solve's budget is a count of iterations, never a clock, so the output is the same on every machine (color_options hands the solve a wall-clock budget in Quality only, crates/inkvec-cli/src/pipeline.rs:143-156). Above the size threshold balanced is Fast, byte for byte. Its report line says which it ran: "balanced mode Potrace-class fit at 0.75x tolerances, flat fills, boundary solve capped at 8 iterations; not run: curve DP, gradients, ring repair, harmonization", or "balanced mode over 1024 px, so fast mode's path: ..." (report, fast.rs:184-211).

Why these two stages. The r2-fastq study (2026-10-02, research build research2/fastq) priced every Quality stage by the dE00 it buys per millisecond added to Fast. At 128 px the gap to Quality is geometry, not colour: 94 % of Fast's error lies within one source pixel of a contour (bench/gt_diff.py on 48 screen icons), and a Shapley split of the gap over a 2³ factorial of {front end, boundary solve, curve DP} gives the curve DP 49 %, the solve 42 % and the front end 9 %. The DP costs 17–30× Fast's work. The solve is an anytime algorithm with a concave profile: on the screen set 2 iterations buy 50 % of the 32-iteration solve's dE00 gain, 4 buy 81 %, 8 buy 89 %, 16 buy 93 %, while most of its cost is fixed setup (band tables and the first energy evaluations). The fitter's tolerances cost no time at all: the sweep x0.5 / x0.75 / x1 read 0.2824 / 0.3154 / 0.3640 dE00 at 3.022 / 2.458 / 2.120 parameters per artist's parameter, and together with the solve x0.75 moved the 4-iteration point from 0.2872 / 1.920 to 0.2401 / 2.234. So balanced takes the solve's first iterations and the finer tolerances, and leaves the DP to Quality. (Those profiles are the solver of v0.2.5; the cap was swept again on the rewritten one, below.)

The cap, re-measured on the rewritten solve (2026-10-04). The solve was rewritten after balanced was built (Moré–Thuente line search, stopping on the projected gradient and the relative decrease, 64 iterations at most, a local fold guard; 08-boundary-solve.md), and an iteration of it is not an iteration of the old backtracking one, so the cap was swept again on the gate (balanced against v0.2.5's Fast; 128ss / 512ss; engine time a paired median ratio to Fast at 128 / 512 px, interleaved, under load):

cap dE00 turning ratio engine time
2 −29.4 / −15.8 % +17.6 / +15.9 % +6.6 / +3.8 % 1.93 / 2.43×
4 −36.3 / −18.8 % +17.6 / +14.7 % +3.0 / +2.9 % 2.08 / 2.83×
8 −37.9 / −19.0 % +14.0 / +12.3 % −0.2 / +1.3 % 2.40 / 3.32×
16 −39.7 / −19.6 % +10.8 / +10.5 % −2.0 / −0.8 % 2.97 / 4.35×
64 (no cap) 6.62 / 9.53×

The colour error still levels off after 4, but on this solver more iterations also leave a smoother boundary for the fitter, so turning and the parameter count keep falling. 8 brings the parameter count back to Fast's (ratio within noise at 128ss, +1.3 % at 512ss, +0.3 % at 512ssop) for 15 % more engine time than 4; 16 buys another 2 points of dE00 and 3 of turning for another 24 %. The cap is 8 (48b0d05).

Plumbing. The cap is a parameter at the solve's call boundary, so the solve itself is untouched: boundary_opt::optimise_alpha_capped (crates/inkvec-trace/src/boundary_opt.rs:613-634) is optimise_alpha with max_iters, which lbfgs::descend clamps to its own ceiling and hands to the per-part loop (boundary_opt/lbfgs.rs:156-171, :238); None is optimise_alpha bit for bit (boundary_opt/cap_tests.rs). ColorOptions::boundary_iters (crates/inkvec-trace/src/lib.rs:200-202) carries it: None leaves Quality uncapped and Fast without a solve, Some(n) runs the solve in either mode (boundary_opt::optimise_for, boundary_opt.rs:593-611, called at crates/inkvec-trace/src/lib.rs:1166). The command line sets it from fast::solve_iters and picks the fitter's tolerances with fit_config, both from the one test fast::balanced on the traced raster's size (fast.rs:90-113).

Robustness. The research build found one defect with the solve on in Fast: synthetic/gradient_radial at 512 px with an 8- or 16-iteration cap wrote the disc's ring as one closed cubic and the face vanished (dE00 10.6). Wave A's curve-run fix (3513beb, §6 step 6) closed it before this mode existed. Checked on the old solve with the cap at 2, 4, 8, 16 and 32 iterations on all 21 cross-compare cases at 512 and 2048 px, and on the rewritten solve with 2, 4, 8, 16, 32 and 64 on the 21 cases at 128, 512 and 2048 px (441 traces): no ring collapsed, no case read more than 1.5× Fast's dE00, and the radial case reads 0.053 at 512 px under every cap (Fast 0.058).

Measured (2026-10-04, the branch merged onto main 134939e, cap 8, against v0.2.5, Windows, under load):

set (246 icons each; dE00 / turning / ratio) Fast v0.2.5 Fast (merged) Balanced Quality v0.2.5
gate 128ss 0.3272 / 0.0748 / 2.206 0.3222 / 0.0758 / 2.112 0.2032 / 0.0852 / 2.202 0.1283 / 0.0414 / 1.511
gate 512ss 0.0995 / 0.0202 / 3.323 0.0993 / 0.0204 / 3.271 0.0806 / 0.0227 / 3.367 0.0484 / 0.0094 / 1.783
gate 512ssop 0.0908 / 0.0167 / 3.620 0.0906 / 0.0167 / 3.563 0.0703 / 0.0187 / 3.630 0.0490 / 0.0092 / 1.986
held_a, 156 icons at 128 px (dE00 / worst tenth / ratio) 0.3402 / 0.924 / 2.137 0.3346 / 0.919 / 2.044 0.2091 / 0.705 / 2.156 0.1310 / 0.420 / 1.471

The gate's columns are its own aggregates (dE00 and ratio family-macro, turning a plain mean, judged at 1024 px against the artist's file). Balanced closes 62 % of the dE00 gap between v0.2.5's Fast and Quality at 128 px, 37 % at 512 px and 49 % at 512 px opaque, and 63 % on held_a (main's Quality, after the solve rewrite, reads 0.1071 on held_a). It is better than Fast on 245 of the 246 icons at 128 px and on 242 at 512 px; every family gains at every condition (at 512ss: simple-icons −37 %, material −35 %, lucide −34 %, openmoji −32 %, twemoji −26 %, synthetic −13 %, noto −6 %). Judged as a change to Fast by the gate's rule it reads dE00 "better" everywhere (−37.9 %, −19.0 %, −22.6 %), turning "worse" (+14.0 / +12.3 / +12.3 %), and ratio within noise at 128ss, non-inferior at 512ssop and worse at 512ss (+1.3 %, upper bound 3.16 % against an effective margin of 3 %). That is why it is a mode of its own and not Fast's default; its time (2.4–3.3× Fast's engine) would rule that out anyway. The largest single rises are twemoji/1f4e6 at 128 px (0.102 → 0.136: the box's long diagonal edges shift a fraction of a pixel where two close browns meet) and openmoji/25FC at 512 px (0.005 → 0.014, the square's bottom edge).

Where the turning comes from, on the same build (gate, 128ss / 512ss / 512ssop, change against v0.2.5's Fast):

variant dE00 turning ratio
balanced (solve 8 iterations, tolerances x0.75) −37.9 / −19.0 / −22.6 % +14.0 / +12.3 / +12.3 % −0.2 / +1.3 / +0.3 %
solve 8 iterations, tolerances x1 −24.3 / −12.4 / −15.4 % +2.0 / −1.8 / −1.4 % −11.9 / −6.5 / −6.9 %

The turning rise is the finer tolerances' (more, shorter pieces following more of the measured wobble), as it was on the old solve (where x0.75 without the solve read +12.9 / +15.3 % and the solve with x1 +3.6 / +0.2 %). With Fast's own tolerances, the 8-iteration solve keeps about two thirds of balanced's dE00 gain, uses 7–12 % fewer parameters than Fast and reads turning "better" at 512 px (+2.0 % at 128 px, just over its margin). The owner's plan fixed the operating point at x0.75; the x1 variant is the alternative where structure and compactness matter more than colour error.

The 21 cross-compare cases (the artist's SVG rendered at each size with the current renderer; dE00 mean / median / mean without the two fluent emoji, judged at 1024 px; parameters per artist's parameter, mean; engine time as a paired median ratio against v0.2.5 Fast, default threading; the merged build's Fast, balanced and Quality):

size Fast v0.2.5 Fast (merged) Balanced Quality (main)
128 px 0.583 / 0.448 / 0.462, 1.51 0.574 / 0.448 / 0.457, 1.39 0.459 / 0.331 / 0.337, 1.48, 2.2× 0.317 / 0.229 / 0.256, 1.17, 17×
512 px 0.168 / 0.074 / 0.078, 2.93 0.169 / 0.074 / 0.077, 2.75 0.151 / 0.053 / 0.058, 3.28, 3.2× 0.100 / 0.048 / 0.053, 1.44, 34×
1024 px 0.117 / 0.029 / 0.039, 4.27 0.115 / 0.029 / 0.038, 4.22 0.109 / 0.022 / 0.032, 5.63, 3.6× 0.073 / 0.026 / 0.032, 1.66, 36×
2048 px, gate off 0.123 / 0.023 / 0.040, 7.68 0.121 / 0.023 / 0.040, 7.59 0.119 / 0.020 / 0.038, 10.27, 3.3× (v0.2.5: 0.068 / 0.022 / 0.028, 2.31, 33×)

Without the two fluent emoji, whose radial gradients balloon the parameter mean, balanced's parameters per artist's parameter read 1.51 / 2.57 / 2.71 against the merged Fast's 1.44 / 2.35 / 2.45 at 128 / 512 / 1024 px (+5 / +9 / +11 %).

The size gate. The table's last row is balanced with the threshold lifted (a temporary build). At 512 and 1024 px balanced reaches Quality's median and its mean without the fluent emoji at a tenth of Quality's time; the remaining mean gap is the two fluent emoji full of radial gradients, a fill-model gap no geometry stage closes. At 2048 px the gain shrinks to −1.5 % mean and −5 % without fluent (15 of 21 cases better), for 35 % more parameters and 3.3× the engine time: judged at 1024 px, a 2048 px raster's boundaries are already finer than the judge can see, and what is left is the fill model. So above 1024 px balanced runs Fast's path; 1024 is the largest size measured worth it. The research's 2048 px run, and this branch's on the old solve (−1.6 % mean, +34 % parameters, 2.7×), agree.

Time (interleaved per image, default threading, the smaller of two runs, on the shared development machine under load; engine = the stopwatch marks trace_total + fit_dp + repair + fills + emit, median ms, with the paired median ratio against the merged Fast and, in brackets, the process wall ratio):

set Fast (merged) Balanced (cap 8) Quality (main)
screen, 246 icons at 128 px 4.2 10.1, 2.40× (1.20×) 80.7, 20.4× (4.2×)
s512, 51 images at 512 px 9.1 30.3, 3.32× (1.72×) 278.6, 28.0× (9.3×)
big (7) and bigalpha (3), 2048 px Fast's path, same bytes

The solve itself (boundary_opt mark) takes 5.5 ms median at 128 px and 20.4 ms at 512 px in balanced, against 23.2 and 81.7 ms for Quality's uncapped solve on the same sets. On the old solve, with cap 4, balanced read 1.96× / 2.60× Fast's engine time; the rewritten solve costs more per iteration (2.08× / 2.83× at cap 4).

Citations (labels as in the doc comments):

  • the mode, "Inspired by" S. Zilberstein (1996), "Using anytime algorithms in intelligent systems", AI Magazine 17(3):73, https://ojs.aaai.org/aimagazine/index.php/aimagazine/article/view/1232: anytime modules composed under a budget chosen from their measured performance profiles; here the budget is fixed per mode and counted in iterations, so the output does not depend on the machine (fast.rs:1-53, boundary_opt.rs:613-626); "See also" M. Yang, H. Chao, C. Zhang, J. Guo, L. Yuan, J. Sun (2016), "Effective clipart image vectorization through direct optimization of bezigons", IEEE TVCG 22(2), https://arxiv.org/abs/1602.01913, a crude partition refined by optimisation against the image, which is this mode's shape;
  • the finer tolerances, "Not from the literature" (an operating point read off the measured Pareto front), "See also" Selinger 2003, whose opttolerance is the same knob;
  • the corner threshold, "Inspired by" Selinger 2003 §2.3.2 (alphamax, restated as a distance and set by measurement), "See also" S. Hoshyari, E. A. Dominici, A. Sheffer, N. Carr, D. Ceylan, Z. Wang, I-C. Shen (2018), "Perception-driven semi-structured boundary vectorization", ACM TOG (SIGGRAPH 2018), https://doi.org/10.1145/3197517.3201312 (corners found jointly with the fit by a learned metric; not used, because Fast must stay one pass);
  • the solve itself keeps its own sources (08-boundary-solve.md).

Constants and thresholds

Fast mode's own constants are listed, with their basis, in constants.md, section 14; the shared stages' new ones (PAR_VERTICES, PAR_MAP_VERTICES, the radix digit, RAMP_MIN_INTERIOR, the intake's serial thresholds) in sections 01, 06, 07 and 13. Every serial-versus-parallel threshold added in this round (256 × 256 pixels for the palette, the composite and the intake passes; 512 vertices for the refinement; 2048 points for the polygon's parallel scan) chooses only the schedule, never the output. The polygon's RUN_MAX_STEP and RUN_MIN_TOL only decide where the closed form for lattice runs applies, which by its proof gives the same sides either way. Fast's face-id limit, 65,534 faces (u16::MAX − 1, faces/runs.rs:513, and the same count as CAPPED in bands.rs:200), is one below Quality's MAX_FACES (section 04); past it the smallest components are merged by the rule the two modes share (regions::cap_components, stage 3).

Failure modes and edge cases

  • Balanced above 1024 px is Fast. A raster longer than BALANCED_MAX_SIDE on its longer side, after --max-dim and any resampling, traces exactly as --mode fast (byte-identical on the 2048 px sets, the 1672 px masthead included), and the report line says so (§8).
  • Balanced turns more than Fast. Its finer tolerances follow more of the measured wobble: on the gate, turning +12–14 % against v0.2.5's Fast, with the parameter ratio within −0.2 to +1.3 %, for 19–38 % less dE00 (§8). It is a mode for colour fidelity, not for the tidiest outline.

  • A resampled input takes the slow histogram. A --max-dim reduction or --intake-scale leaves box averages outside the exact set, so the palette's bands stop counting and the histogram is recounted serially in raster order. The result is still identical; only the speed is lost.

  • More than 65,534 components are merged, smallest first, into the neighbouring ink each shares the most border with, until 65,534 remain (write_faces calling regions::cap_components, the rule Quality's split_components uses past its own limit; stage 3). Every face stays one connected component of one ink; the merged specks are drawn in their neighbour's ink. The ramp precheck steps aside at that count and the full pass decides. Before be5abad the extra components were folded into face 0, which then held pixels of many inks under one colour. The implementing branch found no real image that reaches the limit in Fast; a 300 × 300 checkerboard (90,000 components) does, and is the unit test.
  • A run that U-turns, or a whole ring of smooth pieces, is never collapsed. Since 3513beb a piece turning more than MAX_TURN is always its own cubic, and a closed boundary of two or more pieces always comes back as two cubics or more (step 6 of §6). Before, a run with such a piece was read back as its last piece alone; on a ring that is one cubic closed on itself, and the face was not drawn.
  • The rayon pool start moves, it does not vanish. In the command-line tool the first parallel call spawns the global pool (0.37 ms median). With the palette serial at 128 px, fit_dp pays it instead (+0.35 ms at 128 px); the Studio and the WebAssembly Space keep a warm pool.
  • The image frame is written, not fitted, and the chamfer is gone. Since 5520e6b the ring that runs round the whole image border is written as the image rectangle, four lines and 8 parameters (frame_rectangle, fast/mod.rs:321-378), so the spurious cubic that chamfered its start corner (6 parameters too many, on 166 of 246 screen icons) no longer appears. The test compares coordinates exactly, which holds because the refinement never moves the lattice's outer nodes; a frame ring with any point off the border, or that does not pass each corner exactly once, falls back to the ordinary fit. Only opaque images with a background face change: on a transparent icon the frame bounds the clear ground, which is not drawn.
  • The single ellipse start is judged, not proven. Levenberg–Marquardt now starts from Taubin's conic alone and tries the four near-circles only when that start fails (d403d3b). Where a near-circle would have reached a lower χ² the ellipse, or whether one is accepted at all, could differ; on the gate no SVG changed (0 of 464).
  • Two run encodings exist: planar::runs::RowRuns (the planar map and ramps) and fast/faces/runs.rs (the clean-up). Unifying them, and letting RunLabels::for_each_contact feed the ramp contacts, are open opportunities, not defects.

Environment overrides

Fast mode's own stages read no environment variable that changes what they compute. One writes a diagnostic: with INKVEC_DIAG set, write_faces reports under the stage name split how many components it merged past the face-id limit (faces/runs.rs:517-520). The shared stages keep theirs (see 07-subpixel.md: INKVEC_SUBPXDBG and INKVEC_DUMP_CONTOUR make the refinement run serially, in order), and INKVEC_TIMING prints the stage marks listed above.

Two test-only harnesses read variables, through inkvec_core::env. Both are compiled only under cargo test (replay and faces::tests are declared #[cfg(test)], fast/mod.rs:62-63, fast/faces.rs:660-661), and their tests are #[ignore]d, so they run only when asked for:

variable read in what it sets
INKVEC_FFD_DIR fast/replay.rs:65-85 (dump_files); also replay_timing, :326 directory searched recursively for *.ffd edge dumps. Unset, the three replay tests print a note and return. The dump sets for replay_timing are its first-level subdirectories
INKVEC_FFD_REPS fast/replay.rs:168-172 (reps) how many timed runs a replay keeps the smallest of: default 30 in replay_long_edges, 5 in replay_timing
INKVEC_FFD_MIN fast/replay.rs:187-199 (replay_long_edges) fewest points an edge needs to be timed there; default 2048, the parallel scan's threshold
INKVEC_FACES_DUMPS fast/faces/tests.rs:476-517, :519-529 directory of the clean-up's research label dumps (*.bin) for its differential test and its per-pass timing
INKVEC_FACES_BENCH_MIN fast/faces/tests.rs:529 smallest dump, in pixels, the per-pass timing measures; default 1 (all)

The replay tests are run as INKVEC_FFD_DIR=<dir> cargo test --release -p inkvec-trace replay -- --ignored --nocapture (replay.rs:5-7). They compare the polygon with its kept reference on every dumped edge, at the edge's own tolerance and the loosest (replay_polygon_matches_the_reference_on_every_dumped_edge), time the long edges (replay_long_edges), and time each stage per dump set (replay_timing: the whole fit_edge, the primitive test, the polygon on the frame, on the other edges and through the reference, and the sum of each image's slowest edge). A dump ("FFD1", little endian: an edge count, then per edge a closed flag, the contrast and the points as f64 pairs) is written by the phase-1 research build's INKVEC_FFDUMP, which is not in this tree.

Open questions

  • Balanced's cap was chosen on the solve's profile (4 on the solve of v0.2.5, 8 on the rewritten one, §8). Any further change to what one iteration does moves it; re-measure on the gate after such a change.
  • The x1-tolerance variant (solve only) keeps about two thirds of balanced's dE00 gain with fewer parameters than Fast and nearly flat turning (§8). Which point the product wants is the owner's call.

  • The per-stage cost of intake after the round was not measured separately from the palette.

  • Fast's front end composites the image over white again (fast/front.rs:82); for an image the intake already matted (alpha 1 everywhere) that is an identity, about 9 ms at 2048 px by the merge notes. Passing the intake's over-white plane through would remove it; this is a proposal, not measured as a change.