This stage also exists as a plain-language page with diagrams.
Stage 05 — Gradients
Turns a stack of flat, palette-quantised bands back into the smooth gradient fill that produced them, and fits every region's fill — flat, linear or radial — by minimum description length against the image.
Source: crates/inkvec-trace/src/gradient.rs (1,163 lines: the model types, colour helpers, sample gathering, scoring, the ramp-or-step test and model selection) and its modules in crates/inkvec-trace/src/gradient/: bands.rs (band merging), carve.rs (residual features), fit.rs (the flat, linear and radial fitters) and fit/profile.rs (the profile-aware radial search), stops.rs and stops/knots.rs (interior stops), evidence.rs (which pixels testify about a fill), regions.rs (region recovery switches), score.rs, eval.rs, budget.rs (sampling caps and timing counters), debug.rs and svg.rs (the file list is gradient.rs:56-67)
Entry points: merge_gradient_bands_with_ink() (gradient/bands.rs:287-305), carve_residual_features_with_detail_noise() (gradient/carve.rs:72-119), fit_fill() (gradient.rs:1110-1138)
Pipeline position: merge_bands (lib.rs:601-615) runs after blend absorption (stage 04) and before carve (lib.rs:663-685); carve runs before split (lib.rs:688-699, stage 04's face-splitting). The native-alpha path runs the same two passes (native.rs:898-933), the merge through merge_gradient_bands_guarded with an opacity veto. Each face then inherits the fill of the label it came from (lib.rs:702-720); the pipeline does not refit fills per face, because "a per-face fit would re-shatter a gradient the merge stage had just reassembled" (lib.rs:771-775). fit_fill/fit_candidates are the same machinery for one label; regroup.rs:300 calls fit_candidates directly.
What problem this solves
color::label_image hands every pixel the nearest of a small, discrete set of palette colours. A flat-coloured icon renders correctly under that scheme. A smooth gradient does not: it has no flat regions to find, so the palette quantises it into bands, one per palette entry the ramp crosses. Left alone, each band becomes its own face with its own flat fill. The module doc comment gives the concrete cost: gradient_linear costs 58 parameters as four flat layers where the ground truth is one gradient with 12 (gradient.rs:3-9).
Model selection is not limited to "flat or gradient" — a region's fill is chosen from flat (3 parameters), linear (10) and radial (9, or 11 elliptical), each plus 4 per interior stop, by the same minimum-description-length objective used throughout the tracer:
cost = 0.5 * chi2 + lambda * params
chi2 is summed squared residual in sRGB, beyond a half-LSB dead zone, scaled by the measured per-pixel noise sigma_noise (FillFit::chi2, gradient.rs:311-313); params counts the numbers that would actually be written into the SVG; lambda weights the trade. See below for the two different lambdas in play across the tracer.
Two further problems this module solves, beyond band merging and model selection:
- A feature painted on top of a region — a seam, a dot, a stroke end the palette quantised into its surroundings — leaves the fill fitter looking at a region with a cluster of pixels no single fill model can explain, and its only tool is to spend a gradient explaining it (
carve_residual_features, below). - Gradient fitting dominates the tracer's runtime. With every optional stage disabled a slow emoji still took 10.9 s; capping the palette (which is what feeds the per-region gradient search) brought it to 6.3 s (
ColorOptions::gradientsdoc comment,lib.rs:157-165, quoted in full below).--no-gradientsexists as a genuine fast path for exactly this reason. The merge loop is also the one stage whose cost grows with the square of the region count (ColorOptions::deadline,lib.rs:166-169), which is why it carries a work cap and a deadline (below).
Inputs and outputs
merge_gradient_bands_with_ink(labels: &mut [u16], rgb: &[[f32;3]], w, h, pal: &Palette, sigma_noise: f64, lambda: f64, deadline: Option<Instant>) -> (Vec<FillFit>, Vec<usize>)— mutateslabelsin place (each gradient region and each flat region with an interior of its own gets a fresh label past the palette's range) and returns oneFillFitper resulting label id, palette labels first, plus a parallelVec<usize>mapping each label back to the palette entry it was cut from (a merged gradient maps to its first band's entry) (gradient/bands.rs:271-290).merge_gradient_bands_guardedaddssame_class, a veto on which palette entries may share a fill (gradient/bands.rs:307-334).carve_residual_features_with_detail_noise(labels: &mut [u16], rgb, w, h, pal, fills: &mut Vec<FillFit>, ink: &mut Vec<usize>, sigma_noise, lambda, min_size, detail_sigma: Option<f64>, codec_band: usize) -> usize— further mutateslabels, appends newly minted flat regions tofills/inkin place, refits the parents, and returns the count minted.carve_residual_featuresis the same withdetail_sigma = Noneandcodec_band = 0(gradient/carve.rs:24-70).fit_fill(rgb, w, h, labels, label, sigma_noise, lambda) -> FillFit— the single-label entry point,select(fit_candidates(...)).FillModel(gradient.rs:127-178) — the fitted geometry:Flat([f32;3]),Linear { p0, p1, c0, c1, interp, mids },Radial { c, r, c0, c1, interp, aspect, angle, mids }. Points are pixel-centre coordinates, matching the tracer's-0.5 -0.5 w hviewBox (gradient.rs:132-134).FillFit(gradient.rs:306-318) —{ model: FillModel, chi2: f64, params: f64, cost: f64 }, the fitted model plus the numbers the selection was made on.Interp(gradient.rs:112-123) —LinearRgborSrgb, which space the gradient interpolates its stops in; a model parameter, not a rendering convention (see below).
How it works
Four design decisions stated up front in the module doc (gradient.rs:11-39)
- Fit in linear light, and also in sRGB. Physically composited and rendered gradients are linear in linear RGB; SVG itself interpolates stops in sRGB by default, and so does every renderer that produced the corpus, so a gradient that came out of an SVG is linear in sRGB and gamma-curved in linear light. Both spaces are fitted (
INTERPS = [LinearRgb, Srgb],gradient.rs:125) and MDL picks whichever explains the pixels; the winning space is part of the model and is emitted with it (color-interpolation="linearRGB"when needed,interp_attr,gradient/svg.rs:12-20). - The residual is measured in sRGB, with 8-bit quantisation as part of the observation model. An 8-bit value
vmeans the true value lies in[v - ½LSB, v + ½LSB]; a prediction inside that interval is fully consistent with the observation. The residual therefore has a dead zone of half an LSB (QUANT_HALF_STEP = 0.5/255.0,gradient.rs:109-110) and is Gaussian insigma_noisebeyond it — "the exact likelihood of a quantised Gaussian observation whensigma_noiseis below the LSB, and a mildly lenient approximation above it". Without it, "a plain Gaussian at the noise floor treats sub-LSB rounding as signal, and two gradients with fourteen parameters "explain" the rounding better than one gradient with seven — which is precisely the banding this module exists to remove" (gradient.rs:20-31). - Only strictly interior pixels enter a fit — every 4-neighbour in the region, and not on the picture edge (
collect_samples,gradient.rs:498-560). Anti-aliased edge pixels are blends with the neighbouring region: evidence about geometry, not about the fill. The picture edge counts as a boundary: "treated as interior, 64 border pixels of luminance 0.50 on a solid black shape (luanti) outweighed 550 pure ones and bought a radial that lightens at the rim"; eight neighbours instead of four "was tried and measured worse: it starves genuine gradients of samples, dev objective +0.007" (gradient.rs:530-536). - Be conservative.
cost = 0.5*chi2 + lambda*params; on a noisy flat region a gradient can only lower chi2 by a few units, whichlambda * 7(the linear-over-flat parameter gap) outweighs, and a gradient must also produce visible contrast, or it is fitting noise regardless of what chi2 says.
merge_gradient_bands_with_ink (gradient/bands.rs)
Greedy agglomerative clustering of connected components under the MDL cost (gradient/bands.rs:1-13). The four steps of merge_gradient_bands (gradient/bands.rs:263-290) map onto named stages in merge_bands_with (gradient/bands.rs:336-349) and merge_bands_budgeted (gradient/bands.rs:378-472):
- Connected components. 4-connected flood fill of the current
labels, numbered in raster order of first pixel (label_components,gradient/bands.rs:482-527), and for every pair of touching components the number of 4-neighbour pixel pairs across their seam (adj, aHashMap<u32,u32>per component;component_adjacency,gradient/bands.rs:532-587), and in the same pass how many of those pairs step by more than a discontinuity (sharp,regions::edge_step, below). Components rather than labels, because a band's palette colour can also belong to an unrelated flat region elsewhere in the image, which must not be dragged into the merge. Pairs whose labelssame_classrejects are left out, so they are never adjacent for merging; the native-alpha path passessame_opacity, true when the two entries' opacities differ by less than 0.05 (native.rs:676-689). - Evidence mask.
blend_partners()(seefill_evidencebelow) is computed once, over the palette inks of the labels as they stand before any band merges (gradient/bands.rs:403-411). - Per-component fits. Every component is fitted independently and in parallel with
rayon, since none depends on another (gradient/bands.rs:422-439), viaUnionFitter::fit(gradient/bands.rs:614-650). A fit gathers at mostFIT_PIXELS_CAP = 65536pixels (above that, every⌊len / FIT_PIXELS_CAP⌋-th of the members) (strided_union,gradient/bands.rs:653-683), and scaleschi2back up by the subsampling factor so costs stay comparable across region sizes: "Measured identical in output on the two profiling logos at 1024 and 2048 px against fitting every pixel" (gradient/bands.rs:621-627). These initial fits are neither charged to the work cap nor checked against the deadline (below). - Greedy agglomeration (
Agglomeration::run,gradient/bands.rs:876-983). Each round: - Stops if the deadline has passed (
gradient/bands.rs:913-918). - Gathers every candidate pair not yet cached: both live, sharing at least
MIN_SHARED_BOUNDARY = 3pixel pairs, and worth a union — at least one side already a gradient, or (with region recovery, below) two flat bands one ramp step apart (missing_unions,gradient/bands.rs:985-1008;worth_a_union,gradient/bands.rs:830-847) -- and not across an edge (across_edge,gradient/bands.rs:849-869, below). Otherwise two individually flat regions are never unioned just because a ramp happens to interpolate between them: bands only exist because a smooth ramp was quantised, and a quantised band still carries the ramp inside it. The test runs before the union is fitted, because fitting flat-flat unions just to discard them "was the single largest cost in the tracer"; if one of the pair later absorbs a band and becomes a gradient, the union is fitted then. - Charges the wave to the work cap, then fits every such pair in parallel and caches the fits (
gradient/bands.rs:924-936;fit_wave,gradient/bands.rs:1010-1051). - Picks the pair with the largest gain, provided
gain > 0and the union is itself a gradient (pick_merge,gradient/bands.rs:1053-1091;best_gain,gradient/bands.rs:1125-1159). The gain iscost(a) + cost(b) - cost(a ∪ b), each cost on that fit's own samples, or for a smooth pair the common-pixel gain below (pair_gain,gradient/bands.rs:1161-1228). Exact ties go to the lowest(a, b), because the neighbour list is aHashMap"whose iteration order changes from run to run, so without this a tie between two neighbours of one region would be decided by the hasher" (gradient/bands.rs:1127-1130). - Merges it: carries the union's fit forward, moves
b's pixels and seams ontoa, and updates the caches (apply_merge,gradient/bands.rs:1230-1284). - Runs until no pair gains, the work cap is reached, or the deadline passes.
Stale-cache handling (with region recovery off, INKVEC_GRAD_REGIONS=0). A cached union becomes stale once one of its members absorbs something else; it is refitted only when its cached gain would make it the merge of the round, so the accepted merge is always decided on a fresh fit. "A large gradient region swallowing a two-pixel fleck used to invalidate the union fit with every one of its other neighbours, and on a logo with one 13k-pixel region and fifty flecks along its edge that was 2,500 fits of 13k pixels each - 19 s of a 23 s trace" (gradient/bands.rs:788-796). Stale flat unions are dropped rather than kept: "a union that came out flat before the region grew can come out a gradient after (two noto icons moved 0.005 dE00 when these were kept)" (gradient/bands.rs:1260-1263). Stale gradient unions are kept: the stale cost was fitted without the absorbed pixels, an underestimate of the true union cost, so the stale gain is an overestimate and a stale entry that could win is always refitted before it does; "Scoring the absorbed pixels under the cached model instead over-estimates the cost (the refit would absorb them) and stopped a radial gradient's bands from merging at all (synthetic gradient_radial 0.04 -> 0.23 dE00, 18x the parameters)" (gradient/bands.rs:1273-1278). With region recovery on (the default), or with INKVEC_MERGE_COMMON_PIXELS, a merge instead drops every cached union and gain of the absorbing region, and they are refitted in the next wave: "Common-evidence gains do not inherit the legacy stale-cost bound" (gradient/bands.rs:1264-1272).
Region recovery (gradient/regions.rs, on by default; INKVEC_GRAD_REGIONS=0 switches it off, gradient/regions.rs:17-22). The band merger's mode for fitting a gradient to the whole region an artist filled with one, rather than to whichever pair of bands happens to be adjacent (gradient/regions.rs:1-8):
- A seam is smooth when at least SMOOTH_FRACTION = 0.5 of its pixel pairs step by less than SMOOTH_STEP = 3.0 (CIE76, Lab units): "A seam between two bands of one ramp is crossed in steps of the ramp's slope, a pixel at a time; a seam between two flat regions is crossed in one anti-aliased pixel, so at least one of the two pixel pairs across it steps half the contrast or more" (gradient/regions.rs:28-47, is_smooth at gradient/regions.rs:90-103).
- Two flat bands across a smooth seam whose inks differ by less than RAMP_STEP_DE00 = 15 (CIEDE2000) are tried as a union too — "Two flat bands of one quantised ramp are each flat -- a band is too thin to show its slope -- so the pair test never looks at them, and a ramp cut into flat bands stays cut"; the union still has to win on the pixels (ramp_step, gradient/bands.rs:817-828).
- Across a smooth seam, a blend pixel whose blend partners all lie inside the union counts as evidence for the union's fit (all_inside, gradient/regions.rs:127-137): "Every pixel of a ramp between two band inks lies on the segment between them, so the plain test calls half of each band a blend and leaves only the pixels next to the inks: the union then sees two clusters of colour, which the ramp-or-step test reads as a step" (gradient/evidence.rs:46-51).
- A smooth pair is priced by common_pixel_gain (gradient/bands.rs:178-245): both alternatives — each side's own fit and the union's — scored on the same union-interior evidence pixels, gain = ½ · (n/m) · Σ_i Σ_ch (e(split_i)² − e(merged_i)²) / σ² + λ·(params_a + params_b − params_union), so "a cost measured on another population cannot leak in". It is computed once per union fit and cached. (INKVEC_MERGE_COMMON_PIXELS, research build, prices every pair this way.)
Measured when it shipped (CHANGELOG, 0.2.0, "Gradients are recovered per region"): on the noto emoji set the share of gradient areas painted flat fell from 56 % to 30 %, mean dE00 0.3596 -> 0.3397 and DISTS 0.0652 -> 0.0576; every other family byte-identical; gradient-heavy emoji trace about 1.3x slower. Unit tests: thin_flat_bands_of_a_ramp_become_one_gradient and an_antialiased_edge_between_flats_is_not_a_ramp (gradient/regions.rs:169-238).
Edges (research prototype A10's part seam, the default since Wave B). One rule keeps the merger from joining regions an edge separates, with region recovery on or off:
- A seam is an edge when more than half of its pixel pairs step by more than
EDGE_STEP = 6(OKLab × 100, about CIELAB units;edge_stepandis_edge,gradient/regions.rs:49-88). The pairs are counted incomponent_adjacency's one pass and move with every merge like the smooth counts. Two components of at leastMIN_GRADIENT_PIXELSpixels each that meet at an edge are never unioned (across_edge,gradient/bands.rs:849-869): "segments facing each other across the discontinuity map are never one region", after S. Chakraborty et al. (2025), Image Vectorization via Gradient Reconstruction, CGF 44(2), doi:10.1111/cgf.70055, §3.2 (inspired by: theirs is a relation between whole segments cut by a multicut, ours is read off the seam the two components already share); "The size floor keeps anti-aliasing flecks, which are all edge, absorbable as before".EDGE_STEPis the paper'sτ_dat acda7ac's value for 128 px icons (the paper's 10 is for 512–2048 px; the round-2 research found 4 no better). On the princess emoji (noto-emoji/emoji_u1f478_1f3fd, held_a) the crown's lower band, 139 px of blends, was absorbed into the hair across a seam stepping 20+ units per pixel once the profile-aware candidates (below) gave the union a gradient that fitted it; the edge test stops it ("0.700 -> 0.496",gradient/bands.rs:849-869). Measured alone against v0.2.5 on the regression gate (gate v2, 246 icons): non-inferior on every axis, 3 Noto icons changed, all better or neutral; it ships as the precondition of the profile-aware candidates.
The work cap and the deadline (MergeBudget, gradient/bands.rs:125-176). Nothing else bounds the loop: each round fits the unions it has not seen, accepts one merge, and an accepted merge invalidates the absorbing region's unions; "on an image whose background is a smooth non-flat field (a photographed or scanned print, a textured or vignetted backdrop) the regions are many, large and all gradients, so the rounds, the waves and the stale refits never run out". Measured 2026-10-02 (r2-inputs stress set, v0.2.4, single thread, under load): "a photographed lucide/omega and a textured material-icons/12mp were still in this stage after 900 s (337 and 525 CPU s), and 10 of 996 stress traces hit the 300 s timeout here" (gradient/bands.rs:29-38). Two bounds now stop it (Agglomeration::run doc, gradient/bands.rs:888-906):
- Work, always on and deterministic. The cap is
max(MERGE_WORK_FLOOR, MERGE_WORK_PER_PIXEL · w · h)=max(2²⁸, 32 · w · h)units — "2²⁸ units up to about 2900 x 2900 px, 32 a pixel beyond" (gradient/bands.rs:25-27,MergeBudget::newatgradient/bands.rs:140-151). A wave is charged before it is fitted, the sum ofunion_workover its unions, and a wave that would pass the cap is not fitted at all (gradient/bands.rs:927-935); a stale refit is charged the same way (gradient/bands.rs:1074-1080); and before each pick, the common-pixel gains that pick will price for the first time are charged as one sum,gain_workeach (pending_gain_work,gradient/bands.rs:1093-1123, charged atgradient/bands.rs:1060-1066). A refused charge spends nothing and stops the loop (MergeBudget::charge,gradient/bands.rs:153-163). union_work(n) = g + MODEL_WORK_PER_SAMPLE · min(g, MAX_FIT_SAMPLES), withgthe pixels the fit gathers (allnup toFIT_PIXELS_CAP, else⌈n / ⌊n / FIT_PIXELS_CAP⌋⌉) andMODEL_WORK_PER_SAMPLE = 13: "the measured split of a union fit's time between gathering (0.25 µs a pixel) and model selection (13.4 ms for a full-sample fit, about 13 gathered pixels' worth per sample), from theINKVEC_TIMINGcounters of 2,243 union fits on a photographed wordmark (brands/sangchaimeter, 2026-10-02). Only the order of magnitude matters" (gradient/bands.rs:73-95).gain_work(n) = n + min(n, MAX_FIT_SAMPLES): a common-pixel gain gathers every pixel of the union with no stride, and with region recovery on a merge drops every gain of the absorbing region, so a region that absorbs something each round has all its gains priced again over all its pixels. "On a photographedlucide/omegathat was 225 of the stage's 245 s (INKVEC_TIMING, "pick", 2026-10-03) against 20 s of union fits: the union fits alone were not the work to bound" (gradient/bands.rs:97-110).- "Why these numbers": measured 2026-10-03 (
INKVEC_DIAG,merge work=), the most any image spent was 23.2 M units on the 246-icon screen set at 128 px (a Noto emoji with many small bands, 1,416 units a pixel), 24.4 M on the same icons at 512 px, 3.5 M onheld_a, 18.5 M on the 51-image 512 px set, 24.5 M on the opaque 2048 px inputs (the masthead, 16 units a pixel) and 7.8 M on the transparent ones; of the 772 non-pathological images of the r2-inputs stress set the most was 191 M (a scanned wordmark). "The floor, 2²⁸ = 268 M, is 11 times the gate's largest spend and above every one of those 772; the per-pixel term keeps an uncapped large image (--max-dim 0) at twice the masthead's rate. The images it stops are the ones that never finished: photographed and texture-backed prints" (gradient/bands.rs:40-53). - "The charges are functions of the sizes alone, so where the loop stops is the same on every machine and every thread count." The code labels the cap: "Not from the literature: a resource bound on our own greedy loop. Inspired by: the budgeted (anytime) form of agglomerative region merging, where the merge sequence is cut off once a resource is spent and the partition reached so far is the answer -- the region adjacency graph merging of K. Haris, S. N. Efstratiadis, N. Maglaveras, A. K. Katsaggelos (1998), Hybrid image segmentation using watersheds and fast region merging", IEEE TIP 7(12), pages 1684-1699, https://doi.org/10.1109/83.730380, "whose merge order (cheapest first) this loop shares; there the stop is a region count, here it is the work spent, because a count says nothing about how expensive the fits were" (
gradient/bands.rs:59-66). - Time, only with a deadline (
--time-budget: the CLI sets it at 60 % of the budget,crates/inkvec-cli/src/pipeline.rs:146-149). Checked at the top of each round, before each fit inside a wave, and before each stale refit, "because one wave could take most of a minute (a first wave of 1,485 unions on a textured backdrop took 52 s under--time-budget 10, 2026-10-02) and the old check only ran between rounds". A wave whose fits were skipped keeps the fits that finished and ends the loop (fit_wave,gradient/bands.rs:1016-1020). Without a deadline the clock is never read, so the trace does not depend on time (MergeBudget::out_of_time,gradient/bands.rs:165-175).
Either way the loop just stops: "every merge accepted so far stands and the other bands stay the separate, correct fills they already are -- the graceful end the time budget always had. With no deadline and the cap not reached, this is the loop as it was, round for round" (gradient/bands.rs:900-903). Measured (w2-robust report, sections 3-5, 2026-10-03): the cap never binds on the regression gate (1,656 of 1,656 traces in its identity check byte-identical to v0.2.4); on the stress set all 8 photographs that timed out now finish (39-78 CPU s), and of 26 texture-backed images 25 are identical and 1 is capped (dE00 +0.05); under --time-budget 10 the photographed omega went from 22.1 s wall (19.2 s in this stage) to 9.8 s (6.2 s). Tests: union_work_charges_what_a_fit_gathers, the_merge_budget_charges_up_to_its_cap and the_merge_stops_at_the_cap_and_otherwise_is_unchanged (gradient/bands/tests.rs:116-230).
- Write-back (
write_back,gradient/bands.rs:1305-1392). Palette labels come first, each a flat fill of its palette colour (black past the palette). Every live component that is a gradient, or flat with at leastMIN_GRADIENT_PIXELS = 16interior pixels of its own, gets a fresh label and its own fit. The remaining thin flat components keep their palette label, whose fill is refitted flat over every flat component of that label (evidence pixels only) — a thin component has no interior to estimate a colour from, and the wide ones' interiors "say what the ink is under the anti-aliasing that is all the thin one has". A flat component with an interior keeps its own colour because "two disconnected regions that quantised to the same palette entry are two objects, and pooling them gives each the mean of both: the lizard's belly plateau came out 17 LSB too dark because it shared a palette entry with the darker end of the ramp around it" (gradient/bands.rs:1338-1345). Label ids areu16andu16::MAXis the planar map's outside, so at mostcrate::regions::MAX_FACESlabels exist: "A gradient used to be minted an id without that check, so past 65,535 labels its id wrapped round onto a palette label (or onto the outside). Past the limit a component of either kind keeps its palette label and joins that label's pooled flat fill, as a thin flat component does; below it (every image in the benchmark sets) nothing changes" (gradient/bands.rs:1346-1352).
carve_residual_features (gradient/carve.rs)
Runs after band merging, before faces are split into connected components, with opts.gradients and unless INKVEC_NO_CARVE is set (lib.rs:667). The palette sees colours, not features: a two-pixel white seam through a black shape, a pair of dots, the end of a thin stroke all quantise to the ink around them. The fill fitter then sees a solid region with a residual cluster it cannot explain and buys a gradient to cover it — "three radials on solid black (ebox), black lightening to grey at a rim (luanti), a dark core on a flat face (kiss, zebra)" (gradient/carve.rs:24-36). The pass runs in three stages (gradient/carve.rs:80-82):
residual_mask(gradient/carve.rs:170-239).fill_evidenceis computed fresh over the current labels, each label's ink being its palette colour, or for a label minted after the palette its fill's representative colour (CarveImage::inks,gradient/carve.rs:139-153). Each label's flat colour — the per-channel median of its pure interior pixels — is computed only where there are at least 8 such pixels (region_flat_colours,gradient/carve.rs:241-285); this is a gate, not the residual test: "a region with too few pure interior pixels to take a median of has no reliable notion of its own colour, and its pixels are skipped rather than carved". A pixel is a candidate when it is strictly interior to its label, on a lossy intake also at leastCODEC_BAND = 2px from its label's edge (beyond_band: its whole 5x5 window carries its label), pure (not a blend), its label passes that gate, and its residual against the label's fill,max_ch |c − fill.color_at(x, y)|, exceedsthr = max(8*sigma_noise, CARVE_RESIDUAL). Wheredetail_sigmais given and the pixel's 7x7 neighbourhood is all one label,σisdetail_sigmainstead;lib.rspasses it only for a lossy intake in a research build withINKVEC_LOSSY_REGULARIZE(lib.rs:1193-1196,lib.rs:1193-1197), otherwiseNone.- The residual test is against the fill the fitter actually chose, not the region's flat colour — the reverse of the original design. "Testing the residual against the flat colour instead carved hard-edged shading the fitter had legitimately captured with a gradient (noto 1f9d1_1f3fc_200d_1f37c +0.13, 1F3DD +0.10); requiring both residuals to fail is stricter still. The dev set (51 icons) preferred the flat rule with the size and step-edge guards below (0.7797 against 0.7888), but the full 980-icon set reversed it: against the plain fit rule without guards, flat+guards was 82 better / 193 worse in dE00 (+0.0022) and fit+guards 57 / 191 (+0.0031), both at an unchanged objective. The plain rule ships" (
gradient/carve.rs:195-204). - The codec's band is not carved on a lossy intake (
CODEC_BAND,lib.rs::codec_band, 2026-10-10). A codec's error sits at the edges (on thewebtier the RMS is 4.5 levels of luma and 6.5 of chroma on the edge pixel, 0.2 and 0.35 in the interiors,docs/theory/noise.md), and its ringing is a pattern just inside every region, brighter or darker by turns. Carved, that rim became extra regions along the edges. With the band the carve keeps what lies two pixels or more inside its region; measured onquality-web(246 icons, the gate's distance axes) at 1, 2 and 3 px: dE00 +0.21 / −0.01 / −0.03 %,turning_gap−4.89 / −5.38 / −5.47 %,ratio_gap−0.58 / −0.80 / −0.82 %, geom −0.01 / −0.10 / +0.08 %, so two pixels, the one reach no axis reads worse at. A clean intake passes 0 and is byte-identical. Test:the_codec_band_along_an_edge_is_not_carved. - Blend exclusion matters concretely: "a two-pixel grey fleck where two black strokes almost touch is anti-aliasing of the gap, not a thing to draw, and minted as a region it paints over the gap (send_and_archive: two such flecks cost 0.19 dE00). A seam inside a region has no other ink within reach and stays eligible" (
gradient/carve.rs:187-191). mint_features(gradient/carve.rs:340-428). 4-connected clusters of the mask, within one label, taken in raster order of their first pixel; one of at leastmax(min_size, 4)pixels, while fewer thanCARVE_MAX = 64have been minted and theu16label space has room, gets the next label id, a flat fill of its per-channel median colour, and as its ink the palette entry nearest that colour (squared sRGB distance). The earlier idea of capping a cluster relative to its parent (a fifth of the parent, or 200 px) was rejected: "Measured on the full set that guard, with the step-edge test below, lost more small features than the explosions it prevented. OnlyCARVE_MAXsurvives, against a textured region shattering" (gradient/carve.rs:367-372).refit_parents(gradient/carve.rs:430-456). The evidence mask is recomputed on the new labels (the minted features are inks now, so pixels blending towards them stop counting), and each parent that lost pixels is refitted by full model selection over its remaining pixels.
A step-edge test (requiring the mean contrast across a cluster's boundary with the rest of its parent to reach the residual threshold) was tried alongside the flat-colour rule and rejected along with it: sound against a flat residual — "the bright end of a genuine gradient is also a small cluster of large residual, but it is reached by a ramp where a seam is a step (a skin-tone highlight carved this way cost +0.06 on emoji_u1f469_1f3fd_200d_1f4bc; the ebox seam steps by 0.5)" — but it only ever ran alongside the flat rule, which does not ship, and cost a four-neighbour sweep of every candidate cluster on the default path (gradient/carve.rs:376-383).
On a transparent canvas (the native-alpha path, native.rs:915-932). The carve is shared with the classic path and names a minted feature by the palette entry nearest its median colour over white. "Over white the clear ground is white, so a light feature -- pale yellow, white paint -- is named by the clear ink whenever no light paint ink is nearer, and on this path a face's opacity is its ink's: the feature was cut out as a face and then drawn at opacity 0" (native.rs:703-710). So after the carve, name_carved_paint (native.rs:727-792) takes each minted label whose ink is clear (opacity at most CLEAR_INK_ALPHA = 0.02, native.rs:377), and when the feature's mean opacity is at least CARVED_PAINT_ALPHA = 0.5 renames it to the visible ink nearest the two-ground point of its mean colour over white and its mean opacity (Ink2::dist, ties to the lower index). Its fill, the feature's own median colour, is not touched; a feature the carve named by a visible ink, or one that is mostly see-through, keeps its name, so a trace with neither is unchanged in every byte. The threshold was measured 2026-10-02 "on every feature the carve named by the clear ink in the screen and held_a icons at 128 px: anti-aliasing residue left in the clear ground's interior (light grey flecks in simple-icons/mysql, pale blue in twemoji/1faa3) reads 0.14 to 0.36, and painting those opaque cost dE00 on five of the eight icons; paint reads 1.00 (white details in two openmoji icons) and the repro's pale disc, rim and all, 0.80 to 0.92. The half sits between the two groups" (native.rs:691-700); a first version that renamed above opacity 0.02 painted that residue (w2-bugs report, commit 9e72ebe). The code labels it "Not from the literature: a naming rule for this pipeline's carve stage, because the carve's over-white comparison cannot tell white paint from the clear ground, which is the distinction this module exists to keep (two grounds, see the module docs). See also: the classic carve in gradient/carve.rs, which keeps its sRGB naming, since on an opaque image every ink is paint" (native.rs:728-732). Repro test: a_rare_light_shape_carved_from_the_clear_ground_is_painted (native/tests.rs:1132-1136).
Fill fitting
fit_pixels (gradient.rs:991-1079). Three tiers of samples, taken in order until one is non-empty: strictly interior pixels that pass evidence (all candidates are fitted); any pixel that passes evidence; any pixel at all (both flat only). An empty pixel list gives a black flat fill at the cost of its parameters.
fit_flat (gradient/fit.rs:271-298). The flat model is the per-channel sRGB median of the samples (over a strided subsample of at most fit_cap() samples), not the linear-light mean. The flat model is scored by the same sRGB residual as every other candidate, so it has to be the colour that minimises that residual: "for a dark region with a few light outliers (blend pixels the evidence test let through, a lost dot) the linear mean lands at a grey no pixel has, every pure pixel then pays for the outliers, and a gradient that puts the true colour in the middle and the outliers at its rim wins on solid black (luanti: flat fitted at 0.16 on a region whose interior is 0.00). The per-channel sRGB median is a robust estimate of the sRGB residual's optimum: a minority of outliers cannot move it, and on a clean region it agrees with the sRGB mean that would minimise the squared residual exactly" (gradient/fit.rs:274-283).
fill_evidence (gradient/evidence.rs:7-31). Decides which pixels are allowed to vote on a region's fill, as distinct from its label: after blend absorption, every anti-aliased pixel already carries the label of the ink it is mostly made of, so the label map alone cannot say which pixels are pure. A pixel is a blend (excluded) when its colour lies between its own ink a and the ink b of another label found within two pixels of it (the 5x5 window): with d = c − a, e = b − a, t = (d·e)/|e|², the pixel is a blend towards b when 0 < t < 1 and the perpendicular distance |d − t·e| is at most tol = max(3·σ·√3, 2/255); a pixel within tol of its own ink is pure without looking further (blend_partners, gradient/evidence.rs:41-154, tol at gradient/evidence.rs:73). Two pixels, because "the blend fringe after absorption is up to two pixels deep on a diagonal. One pixel was measured worse (twemoji flag +0.07, emoji_u1f923 +0.04) and did not recover the thin-hair case it was meant for" (gradient/evidence.rs:88-92). Without this exclusion a wedge tip that is nothing but blend pixels buys a radial gradient on a solid black shape ("luanti: black lightening to #6d6d6d over the last tenth of a slab; ebox: three gradients on solid black", gradient/evidence.rs:10-18). A genuine gradient's colours are not on a line towards a neighbour's ink, so its samples are unaffected. blend_partners also records which pixels' inks a blend is towards (up to PARTNERS = 3), which region recovery uses.
fit_samples / select (gradient.rs:797-961 / gradient.rs:963-977). fit_samples builds every admissible candidate: the flat fit always first; then, unless the region is below MIN_GRADIENT_PIXELS = 16 samples or the flat cost already beats the cheapest gradient could possibly cost — lambda * PARAMS_RADIAL, an exact short-circuit since no gradient's cost can fall below its own parameter term, so "this cannot change which model is chosen, only how long it takes to find out" (gradient.rs:845-858) — linear, radial and elliptical-radial candidates in both interpolation spaces (ramp_models, gradient.rs:736-762), followed in each space by the profile-aware radial and elliptical geometries re-stopped there (ramp_candidates, gradient.rs:764-795; "The line-scored candidates come first, so they win ties in select"; see the profile-aware search below), each gated by:
- contrast >= max(3*sigma, MIN_VISIBLE_CONTRAST), the largest per-channel range the model draws over the samples (visible_contrast, gradient/score.rs:116-124). Measured on the h-series / noto emoji: "on the icons carrying the family's error, 98 of 100 refused gradient candidates on regions of 200+ pixels fail this floor and not the support one, by a hair -- contrast 0.0039-0.0055 against 0.0059 -- while the MDL below would accept them by a wide margin. (INKVEC_MIN_CONTRAST scaled the floor so the full set could price it; the default never moved.)" (gradient.rs:860-865).
- support >= MIN_RAMP_SUPPORT = 0.10 — the fraction of samples where the model predicts a colour more than a quarter of its own contrast away from the flat colour (ramp_support, gradient/score.rs:103-114): the candidate must visibly shade at least a tenth of its samples, or it is explaining a feature (a lost dot, a chamfered tip), not a genuine gradient (gradient.rs:101-107).
- Visible once emitted (imperceptible, gradient/score.rs:130-174): a gradient whose stops all lie within IMPERCEPTIBLE_STOP_OKLAB = 0.02 of each other in OKLab is refused, because the emitter paints exactly those flat (demote_imperceptible_gradient, crates/inkvec-cli/src/pipeline/demote.rs, whose JND this is) at the midpoint of their end stops: "model selection must price what is drawn". Measured on noto-emoji/emoji_u1f36a at 512 px: the profile-aware candidates fitted the cookie's body, "a flat core with a two-level ramp, by a radial whose stops all lie within 0.02 of each other (chi² 252 over 48,313 px); the emitter painted it #f09c56, and the icon went from dE00 0.087 to 0.216. Refusing the candidate leaves the body flat in the merger, which then joins it to the ring around it into one gradient the emitter keeps (0.126)" (gradient/score.rs:146-150). Measured alone against v0.2.5: non-inferior on every axis, quality-512ssop dE00 −1.43 % (better); with the profile-aware candidates it turns quality-512ss from −2.45 % (inside its interval) into −1.99 % (better) and quality-512ssop from −1.62 % into −2.85 %. The code labels it "Not from the literature: a consistency rule between this crate's model selection and its emitter".
Each surviving ramp is also offered with one and two interior stops (fit_mid_stops, below), under the same gates. The work is parallel, but the candidates are taken in the serial order, so ties are broken the same way on any number of threads (gradient.rs:813-815).
Then a ramp-vs-step decision (gradient.rs:929-959): a gradient is the right model for shading and the wrong one for two inks the palette merged into one, and both raise a flat colour's residual, so "an earlier version of this test that compared against it declined gradients everywhere and cost the corpus a fifth of its quality". What separates a genuine ramp from a step is how the alternatives rank: "Measured over the regions this tracer paints flat where the artwork varies, the best linear ramp removes 13 % of the error there and the best pair of flat colours removes 66 %: those are steps wearing a ramp's clothes." So the step is priced directly: chi2_two_flats (the best two-colour split: one-dimensional k-means with k = 2 by Lloyd's iteration along the samples' dominant colour axis, gradient.rs:632-734) is compared against the best gradient candidate's chi2, and if chi2_two_flats < BIMODAL_MARGIN * best_grad_chi2 (BIMODAL_MARGIN = 0.85), every gradient candidate is discarded and the region stays flat.
Finally select takes the candidate of lowest cost, ties going to the earlier (simpler) candidate — flat is always first in the list, so a tie always resolves to flat.
The ramp fitters (gradient/fit.rs). Every ramp reduces to the same one-dimensional problem once its geometry is fixed: a scalar coordinate t per sample and a least-squares line colour = a + g·t per channel (fit_1d, gradient/fit.rs:24-60). The linear fitter takes the axis from the leading eigenvector of the per-channel affine slopes, scans ±45° in 1° steps around it and finishes with 40 golden-section steps (fit_linear, gradient/fit.rs:300-324). The radial fitter starts at the weighted least-squares intersection of the gradient lines and polishes the centre by pattern search on the exact 1-D residual, from 4 px steps down to 0.03 px or 600 evaluations, each on at most CENTRE_SEARCH_SAMPLES = 1024 samples; the centre stays within one bounding-box size of the region (fit_radial, gradient/fit.rs:634-661). The elliptical fitter searches centre, angle and ln aspect together, with the aspect in [1, MAX_ASPECT = 8], and returns None below an aspect of 1.02 (fit_radial_elliptic, gradient/fit.rs:642-662). The geometry-free sums of each search are formed once: "There are up to 600 of those for a circular gradient and 836 for an elliptical one, on a subsample of a thousand pixels: the elliptical fit was 663 ms of the 683 ms merge_bands spent on one emoji" (Resid1d, gradient/fit.rs:72-85).
The profile-aware radial search (gradient/fit/profile.rs; research prototype A10's part profile, the default since Wave B). The line-scored radial and elliptical fitters choose their geometry by the residual of a straight profile, but on the 168 corpus icons whose artist SVG has gradients, "73 % of the artist's visible gradients are clamped -- their stops do not span 0..1, so the profile is a flat core and a ramp at the rim, or a bump on a pad -- and they were painted flat 38 % of the time against 10 % for full-range ones" (gradient/fit/profile.rs:11-16). So each fit searches the geometry a second time, once on the sRGB colours for both spaces, under a continuous piecewise-linear profile with SPLINE_KNOTS = 8 uniform knots over the range of t (Resid1d::finish_spline, gradient/fit.rs:176-268: the hat basis, tridiagonal normal equations solved by the Thomas algorithm), and offers the circle and the ellipse it finds as extra candidates, re-stopped per space (restop_radial, gradient/fit.rs:775-829); "Replacing the line search instead was measured worse in an earlier attempt (4304ba3)".
- The search (
profile_geometries,gradient/fit/profile.rs:131-229) starts at the line fitters' seed (the weighted least-squares point nearest every gradient line, clamped to the region's box), refines the circle's centre, then sweeps the orientation every 15° at aspects 2 and 3 about it and refines all four coordinates of the ellipse; an ellipse is kept from an aspect of 1.02. - Each refinement (
Resid1d::refine_spline,gradient/fit/profile.rs:465-535) is Levenberg–Marquardt on the variable-projection functionalF(θ) = ‖P⊥(θ)·C‖², the residual left once the profile is solved in closed form for the geometryθ. Method from: L. Kaufman (1975), A variable projection method for solving separable nonlinear least squares problems, BIT 15(1), pp. 49–57, doi:10.1007/BF01932995, the Gauss–Newton step withJ = −P⊥·(∂A/∂θ)·X; G. H. Golub, V. Pereyra (1973), SIAM J. Numer. Anal. 10(2), pp. 413–432, doi:10.1137/0710036, for the functional; K. Levenberg (1944), doi:10.1090/qam/10666, and D. W. Marquardt (1963), doi:10.1137/0111030, for the damping, which scales by the diagonal so pixels, radians andlnaspect need no common unit. Adapted:∂A/∂θfollows the hat basis and includes the knots' stretch with the range oft, andP⊥·Dis never formed: "Dᵀ·P⊥·D = DᵀD − (AᵀD)ᵀ(AᵀA)⁻¹(AᵀD), andAᵀAis the same tridiagonal matrix the score solves" (gradient/fit/profile.rs:78-79). A test checks Kaufman's gradient−2·Dᵀragainst finite differences of the score. - It stops on an accepted step smaller than
LM_STEP_TOL(0.01 px, 0.01 px, 0.001 rad, 0.001 inlnaspect) or one loweringFby at mostLM_REL_TOL = 1e-9of it, when the damping passesLM_MU_MAX = 1e10, or afterLM_MAX_EVALS = 60evaluations (gradient/fit/profile.rs:112-126). The research prototype used the line fitters' compass search instead (up to 600 and 800 + 24 evaluations); on the cookie at 128 px "its 1,328 searches made 14.5 evaluations each with Levenberg–Marquardt, and the searches took 1.3 s instead of 2.3 s; on the 168 gradient icons the result was as good (dE00 0.3459 against the compass's 0.3468, as-one 48 % against 47 %)" (gradient/fit/profile.rs:99-103). "Most of what the part costs is not the search but the four extra candidates each fit then scores (their interior stops,fit_mid_stops)". - Inspired by Chakraborty et al. (2025) §3.3, whose radial geometry does not depend on the profile at all; see also O'Leary & Rust (2013), doi:10.1007/s10589-012-9492-9, and Lukáč et al., US 12,340,441 B2 (2025), "a profile-agnostic centre ... which is what the gradient-line seed already is" (
gradient/fit/profile.rs:84-93).
Measured with the edge rule (gate v2 against v0.2.5, 246 icons): quality-128ss dE00 −1.17 % (better), quality-512ss −2.45 % (non-inferior; ratio −0.32 %, better), quality-512ssop −1.62 % (better), fast-512ssop −1.77 % (better; Fast's ramp merge calls the same fitter); only Noto icons change. On the 168 corpus icons whose artist SVG has gradients (128 px, against v0.2.5, with the emitter rule and the stop repartition of this page), the share of the artist's gradients recovered as one rose from 42 % to 50 %, the share painted flat fell from 30 % to 24 %, dE00 0.3542 -> 0.3335 (−5.8 %) and paths 3423 -> 3279.
Interior stops (fit_mid_stops, gradient/stops.rs:265-337). With the candidate's geometry held fixed, each sample has a t, and an artist's three- or four-stop gradient is a piecewise-linear profile in t. Up to MAX_MID_STOPS = 2 knots are added greedily; each new knot is the stop offset, a multiple of 1/1000 (the precision the SVG carries), found by an exact scan of every offset on weighted moments binned by t (gradient/stops/knots.rs:1-28), and the profile is then refitted with IRLS_ROUNDS = 2 rounds of Huber reweighting, δ = max(3 · 1.4826 · median_i r_i, 1/255). The knots module cites Hudson (1966) and Bai & Perron (2003) for segmented regression with an unknown join, Chakraborty et al. (2025) and Lecot & Lévy (2006) for the binned profile and pre-integrated moments, and Huber (1964), Holland & Welsch (1977) and Hunter & Lange (2004) for the Huber loss, IRLS and its majorise-minimise reading (gradient/stops/knots.rs:13-56). Measured offline on 1200 recorded stop searches (screen set and noto emoji at 512 px): "the new knot lies within 0.01 of the old in 98 % of searches, and the Huber objective of the final profile is no worse than the old one's at the offsets the SVG actually carries in 98 % (one stop) and 95 % (two stops) of them, better in 16 % and 26 %" (gradient/stops/knots.rs:37-41). A round stops the search, keeping the stops found so far, when no knot position gives a solvable profile, a new knot lands within 5 % of the t span of an existing one, the refit is singular, or the knot cuts off a misfit sliver (MAX_SLIVER_MISFIT, see Open questions). The caller scores each variant against the plain ramp by MDL, so a stop is kept only when it pays for its PARAMS_STOP parameters (gradient/stops.rs:6-11). The second stop is found with the first held, so the two are then re-placed (repartition, gradient/stops.rs:339-378): each moves to the offset the exact scan picks with the other held, one pass, unless that lands within 5 % of the t span of the other; the sliver test then checks both. "On a clamped artist profile -- a flat core and a ramp with two bends -- the lone best knot sits at the steeper bend, and the second then splits the rim, leaving the core fitted by a slope". Method from J. Bai (1997), Estimating Multiple Breaks One at a Time, Econometric Theory 13(3), pp. 315–352, doi:10.1017/S0266466600005831 (breaks estimated one at a time, then each re-estimated with the others held); Bai & Perron's (2003) global search over both at once would cost O(G²) scans of the G = 1000 offsets. On the cookie (noto-emoji/emoji_u1f36a) at 512 px it moved the body's knots from 0.804 and 0.923 to the core's end and the rim, 0.126 -> 0.083 dE00 (v0.2.5: 0.087); on the regression gate, on top of the profile-aware candidates, quality-512ss dE00 against v0.2.5 went from −1.99 % to −3.87 % and quality-512ssop from −2.85 % to −3.32 %.
bic_lambda vs the fitter's lambda
bic_lambda(n) = 0.5 * ln(n) (gradient.rs:320-327) is the value of lambda at which the MDL cost equals the Bayesian information criterion for n observations: "it grows slowly with region size, so a large region has to earn a gradient with proportionally more evidence than a small one does". The pipeline calls it with the image's pixel count wherever a fill decision is made (gradient::bic_lambda(img.width * img.height): the palette's evidence at lib.rs:420, the merge at lib.rs:609, the carve at lib.rs:677; the label-map entry at lib.rs:819; the research decode at lib.rs:1158).
This is a different quantity from inkvec-fit::FitConfig::lambda, which FitConfig::from_precision derives (crates/inkvec-fit/src/lib.rs:147-169):
lambda = ln(extent / precision)
— "A coordinate confined to a range extent and stored to resolution precision carries ln(extent / precision) nats of information. For a 256px canvas written at 0.1px precision that is ln(2560) ~ 7.85", floored at 1 nat. One is a per-fill description-length weight tied to sample count; the other is a per-coordinate weight tied to canvas size and output precision. Both instantiate the same cost = 0.5*chi2 + lambda*params objective, at two different points in the pipeline (fill selection here, curve segmentation in inkvec-fit), with two independently derived lambdas.
FillModel / FillFit / color_at
FillModel::params()(gradient.rs:209-227) returns the parameter count used in the cost:PARAMS_FLAT = 3.0,PARAMS_LINEAR = 10.0(+PARAMS_STOP = 4.0per interior stop),PARAMS_RADIAL = 9.0or, whenaspect != 1,PARAMS_RADIAL_ELLIPTIC = 11.0(+ stops).FillModel::color_at(x, y)(gradient.rs:257-285) evaluates the model's predicted sRGB colour at a pixel-centre position —Flatreturns its one colour;Linear/Radialcompute the normalised axial or radial coordinatet(linear_t/radial_t, clamped to[0,1], SVG'sspreadMethod="pad") and evaluateeval_stopsacrossc0, anymids,c1in the fittedInterpspace. Scoring loops useFillModel::eval, which returns the same bits with the per-model work hoisted out (gradient/eval.rs:1-8).FillModel::representative()(gradient.rs:287-303) returns one stand-in colour — the flat colour, or the midpoint of a gradient's two end stops — used wherever a region only needs to be told apart from a neighbour (boundary unmixing), not rendered.- Elliptical radial gradients matter empirically: "Illustrator writes nearly every radial gradient with a
gradientTransform, and in the Noto corpus 217 of 279 of them are elliptical with an aspect above 1.1; a circle fitted to any of those is wrong in a way no colour tolerance can hide, and the bands it fails to merge are the visible result" (gradient.rs:152-157).
The --no-gradients fast path
Quoted in full, from ColorOptions::gradients's doc comment (crates/inkvec-trace/src/lib.rs:157-162):
Turning this off is a genuine fast path, not a cosmetic one. Gradient fitting is the dominant cost in the tracer: with every optional stage disabled a slow emoji still took 10.9s, and capping the palette — which is what feeds the per-region gradient search — took it to 6.3s. The CLI's
--no-gradientsused to replace the fitted model with a flat one after paying for the fit, so it changed the output and saved nothing.
Concretely: when opts.gradients is false, merge_gradient_bands_with_ink is never called at all ((Vec::new(), Vec::new()) is substituted, lib.rs:601-614) and the carve is skipped too (lib.rs:656); each face then takes a flat fill of its palette colour (lib.rs:696-711). The search itself is what is expensive (the radial centre alone is "six hundred residual evaluations over every sample", gradient.rs:852-854), not merely the choice of which model to keep. The CLI help text repeats the same framing: --no-gradients Skip gradient fitting entirely and fill flat. A genuine fast path: gradient fitting dominates runtime (crates/inkvec-cli/src/args.rs:448-449).
Constants and thresholds
| name | value | controls | stated derivation |
|---|---|---|---|
PARAMS_FLAT |
3.0 | flat model description length | one colour (gradient.rs:76-77) |
PARAMS_LINEAR |
10.0 | linear model description length | two axis points and two stop colours (gradient.rs:78-79) |
PARAMS_RADIAL |
9.0 | circular radial description length | centre, radius and two stop colours (gradient.rs:80-81) |
PARAMS_RADIAL_ELLIPTIC |
11.0 | elliptical radial description length | the circular one plus an aspect ratio and an angle (gradient.rs:82-83) |
PARAMS_STOP |
4.0 | each interior stop | an offset and a colour (gradient.rs:84-85) |
MAX_MID_STOPS |
2 | most interior stops fitted | measured on the corpus: "Of the 79 multi-stop gradients in the corpus 58 have one interior stop and 8 have two; the rest are approximated by two" (gradient.rs:86-88) |
MIN_GRADIENT_PIXELS |
16 | fewest interior samples before a gradient is attempted at all; also the interior a flat component needs to keep its own colour in the merge's write-back | gradient.rs:90-91: "Fewer interior pixels than this and a gradient cannot be told from noise" — no numeric derivation given |
BIMODAL_MARGIN |
0.85 | ramp-vs-step decision threshold | measured: "two flats beat one flat on any varying region, ramps included" is why the comparison is against the best gradient, not the flat fit (gradient.rs:92-96); the value 0.85 itself has no further stated derivation. A constant (gradient.rs:954); the INKVEC_BIMODAL override is removed |
MIN_VISIBLE_CONTRAST |
1.5/255.0 | floor on visible contrast for any gradient candidate | "Below this it is invisible" (gradient.rs:98-100); no numeric derivation for 1.5 specifically |
MIN_RAMP_SUPPORT |
0.10 | least fraction of samples a gradient must visibly shade | motivated by concrete regressions (luanti, ebox) but the 0.10 figure itself is not derived (gradient.rs:101-107) |
QUANT_HALF_STEP |
0.5/255.0 | residual dead zone from 8-bit quantisation | exact: half an 8-bit LSB (gradient.rs:109-110) |
MIN_SHARED_BOUNDARY |
3 | fewest seam pixel pairs two components need before their union is even considered | gradient/bands.rs:22-23, no stated derivation |
MERGE_WORK_FLOOR |
2²⁸ (268,435,456) units | least work cap any image gets | measured: 11 times the gate's largest spend (23.2 M) and above all 772 non-pathological stress images (largest 191 M) (gradient/bands.rs:40-53, gradient/bands.rs:69-71) |
MERGE_WORK_PER_PIXEL |
32 units per pixel | work cap past about 2900 x 2900 px | measured: twice the masthead's rate (16 units a pixel) for an uncapped large image (gradient/bands.rs:25-67) |
MODEL_WORK_PER_SAMPLE |
13 | gathered pixels one scored sample of a union fit is charged as | measured: 0.25 µs a gathered pixel against 13.4 ms for a full-sample model selection, 2,243 union fits on brands/sangchaimeter, 2026-10-02 (gradient/bands.rs:73-95) |
MAX_FIT_SAMPLES |
4096 | most samples one fit scores; a larger set is strided down to about this many | gradient/budget.rs:31-33, no stated derivation |
FIT_PIXELS_CAP |
65536 | pixels one fit gathers before it is sampled down | "The default gathers sixteen times what the fit will evaluate, which buys a sample spread evenly over the interior rather than over the region" (gradient/budget.rs:34-44); measured identical in output to fitting every pixel on two profiling logos at 1024 and 2048 px (gradient/bands.rs:621-627) |
CENTRE_SEARCH_SAMPLES |
1024 | samples a radial or elliptical centre search evaluates per candidate centre; the final stops use every sample | gradient/budget.rs:45-47, no stated derivation |
MAX_ASPECT |
8.0 | largest aspect an elliptical gradient may take | "Beyond this the gradient is, across any region it could plausibly fill, a linear one" (gradient/fit.rs:638-640) |
SPLINE_KNOTS |
8 | knots of the profile-aware search's piecewise-linear profile score | "seven pieces, enough to bend at a clamp and at both ends of a bump (97 % of the corpus's artist gradients have at most four stops)"; measured by the round-2 research, not tuned (gradient/fit.rs:62-67) |
LM_MAX_EVALS |
60 | most score evaluations of one profile-aware refinement | the compass search it replaced allowed 600 and 824; measured mean 14.5 (gradient/fit/profile.rs:112-115) |
LM_REL_TOL |
1e-9 | relative decrease below which an accepted refinement step ends the search | gradient/fit/profile.rs:117-119, no stated derivation |
LM_MU_MAX |
1e10 | damping at which the refinement gives up | gradient/fit/profile.rs:121-122, no stated derivation |
LM_STEP_TOL |
0.01 px, 0.01 px, 0.001 rad, 0.001 | an accepted step smaller than this in every coordinate ends the refinement | "the compass search it replaced stopped at 0.03 px" (gradient/fit/profile.rs:124-126) |
EDGE_STEP |
6.0 (OKLab × 100) | pixel-pair step above which a seam pair is a discontinuity; a seam with more than half such pairs is an edge no union crosses | Chakraborty et al.'s τ_d at acda7ac's value for 128 px icons; 4 measured no better (gradient/regions.rs:49-56) |
IMPERCEPTIBLE_STOP_OKLAB |
0.02 (OKLab) | a gradient whose stops all lie closer than this is refused (the emitter would paint it flat) | equal to the emitter's JND (crates/inkvec-cli/src/pipeline/demote.rs); gradient/score.rs:130-135 |
RAMP_STEP_DE00 |
15.0 (CIEDE2000) | largest ink difference for two flat bands to be tried as one ramp (region recovery) | gradient/regions.rs:24-26, no stated derivation |
SMOOTH_STEP |
3.0 (CIE76, Lab) | largest pixel-pair step inside one smooth region | motivated: a ramp seam is crossed in steps of its slope, a flat seam in one anti-aliased step (gradient/regions.rs:28-34) |
SMOOTH_FRACTION |
0.5 | fraction of a seam's pixel pairs that must be smooth steps | gradient/regions.rs:36-38, no stated derivation |
CARVE_RESIDUAL |
0.06 | floor on thr = max(8*sigma_noise, CARVE_RESIDUAL), the carve candidate threshold |
"Eight times the noise, and never below 0.06 (about 15 levels): an anti-aliasing fringe the evidence test let through sits below this; a seam, a dot or a stroke that the palette quantised away sits far above it" (gradient/carve.rs:16-20) |
CARVE_MAX |
64 | most features carved from one image | "a guard against a textured region shattering" (gradient/carve.rs:21-22), no numeric derivation for 64 specifically |
CODEC_BAND |
2 px | on a lossy intake, how far inside its region a pixel must lie to be carved | measured 2026-10-10 on quality-web at 1, 2 and 3 px: 2 is the one reach no gated axis reads worse at (dE00 −0.01 %, turning_gap −5.38 %, ratio_gap −0.80 %, geom −0.10 %) |
CARVED_PAINT_ALPHA |
0.5 | mean opacity at or above which a carved feature named by the clear ink is renamed to a visible ink (native-alpha path) | measured 2026-10-02: residue reads 0.14-0.36, paint 0.80-1.00 (native.rs:691-700) |
bic_lambda(n) |
0.5 * ln(n) |
fill-selection lambda | exact: the BIC choice, "the value at which the MDL cost is the Bayesian information criterion" (gradient.rs:320-327) |
IRLS_ROUNDS |
2 | rounds of Huber reweighting when a stop count's final profile is fitted | gradient/stops.rs:68-69, no stated derivation |
MAX_SLIVER_MISFIT |
4.0 | stop-acceptance gate in fit_mid_stops: rejects a new interior stop when the smaller side of its segment holds fewer than a tenth of the subsamples and its median residual exceeds MAX_SLIVER_MISFIT times the other side's (floored at 1/255) |
doc comment (gradient/stops.rs:71-73): "How much worse, in median residual, a sliver of samples cut off by a new stop may be fitted than the rest of its segment"; no stated derivation for the value 4.0 itself. See Open questions. |
Failure modes and edge cases
luantiappears three times as a regression witness: (a) a solid black region's flat fit under a linear-light mean lands at 0.16 rather than the true 0.00, motivating the median (gradient/fit.rs:277-281); (b) black lightening to#6d6d6d/#6a6a6aover the last tenth of a slab or an elliptical radius is a blend-made or feature-made ramp, refused by the evidence test or the support gate (gradient/evidence.rs:13-14,gradient.rs:104-106); (c) 64 picture-edge pixels treated as interior outweighed 550 pure ones and bought a radial (gradient.rs:481-484).ebox: three radial gradients fitted to what is actually solid black with seams — the running example for whycarve_residual_featuresexists at all (gradient/carve.rs:30,gradient/evidence.rs:14).send_and_archive: two anti-aliasing flecks between near-touching black strokes cost 0.19 dE00 when carved as if they were features — the reason the carve excludes blend pixels viafill_evidence(gradient/carve.rs:187-191).gradient_radial(synthetic): scoring a stale cached union against its own cached model rather than refitting overestimated the union's cost and stopped a radial gradient's bands from merging at all — 0.04 -> 0.23 dE00, 18x the parameters (gradient/bands.rs:1273-1278).- h-series / noto-emoji family: 98 of 100 refused gradient candidates on 200+ pixel regions fail the visible-contrast floor by a hair (contrast 0.0039-0.0055 against 0.0059) while the MDL cost itself would accept them (
gradient.rs:860-864).INKVEC_MIN_CONTRAST(removed) scaled the floor for that measurement; the default never moved. - Dev-set vs full-set reversal (carve's flat-vs-fit residual choice): a decision that looked better on a 51-icon dev set (
0.7797vs0.7888) reversed on the full 980-icon set (gradient/carve.rs:200-204) — a recorded caution about trusting small held-out sets. - Photographed and texture-backed prints: every region a large gradient, so the merge loop ran for minutes (900 s on a photographed
lucide/omega); now stopped by the work cap, leaving a correct trace with more fills (gradient/bands.rs:29-38). The initial per-component fits (gradient/bands.rs:429-439) are not charged or deadline-checked, and a fit already running finishes past the deadline (w2-robust report, section 5). noto-emoji/emoji_u1f36a(the cookie) at 512 px: the profile-aware candidates found an excellent but imperceptible radial for the body, which the emitter painted flat at the midpoint of its end stops (0.087 -> 0.216 dE00); refusing candidates the emitter would paint flat brings it to 0.126 (gradient/score.rs:146-150). The rest of the gap was the body's interior stops, both placed on the rim by the greedy knot search; the repartition (see interior stops) moves one to the core's end: 0.083, below v0.2.5's 0.087.noto-emoji/emoji_u1f478_1f3fd(the princess): a thin band of blends between the crown and the hair joined the hair across an edge once a profile-aware gradient fitted the union; the edge test stops it (gradient/bands.rs:849-869).- Past 65,535 labels: a gradient's minted label id used to wrap onto a palette label or the outside; it now keeps its palette label and joins the pooled flat fill (
gradient/bands.rs:1346-1352). The carve stops minting when theu16label space is full (gradient/carve.rs:386-389). - A light feature on a transparent canvas: carved from the clear ground and named by the clear ink, it was drawn at opacity 0;
name_carved_paintrenames it when it is mostly opaque (native.rs:727-792). The regression gate composites over white, where white paint and a hole look the same, so it reads this fix as neutral (w2-bugs report, section 4: screen set at 128ss 0.1425 -> 0.1425 dE00).
Environment overrides
Since the settings cleanup (CHANGELOG, 0.2.0, Changed) the engine reads its environment through one helper (inkvec_core::env): a switch is off when unset, empty or 0, and every variable is read once per process. Variables marked removed below are gone (their defaults are constants now); those marked research build are read only by a binary built with --features research. The full list, with what is left and why, is docs/internal/env-vars.md.
| variable | effect |
|---|---|
INKVEC_NO_CARVE |
if set, skips the carve entirely (checked alongside opts.gradients: lib.rs:667, the label-map entry at lib.rs:924, the native-alpha path at native.rs:912) |
INKVEC_GRAD_REGIONS |
region recovery, on by default; 0 switches it off (gradient/regions.rs:17-22) |
INKVEC_MERGE_COMMON_PIXELS (research build) |
prices every candidate pair on common pixels, not only smooth pairs (gradient/bands.rs:457-460) |
INKVEC_MIN_CONTRAST (removed) |
scaled the visible-contrast floor in fit_samples; the default (scale 1) never moved (gradient.rs:863-864) |
INKVEC_BIMODAL (removed) |
overrode BIMODAL_MARGIN (default 0.85) |
INKVEC_RAMP_STEP, INKVEC_SMOOTH_STEP, INKVEC_SMOOTH_FRACTION (removed) |
now the constants RAMP_STEP_DE00, SMOOTH_STEP, SMOOTH_FRACTION (gradient/regions.rs:24-38) |
INKVEC_FIT_PIXELS_CAP (removed) |
now the constant FIT_PIXELS_CAP; "nothing set it" (gradient/budget.rs:34-36) |
INKVEC_EVDBG |
verbose per-region fit diagnostics: sample counts, chosen model, contrast/support (gradient.rs:1024-1069), refused-candidate reasons (gradient.rs:892-910), per-label pixel/blend tallies from blend_partners (gradient/evidence.rs:133-152) and each carved feature (gradient/carve.rs:390-408) |
INKVEC_MERGEDBG |
verbose band-merge diagnostics: per-pair costs and gains for unions over 200 pixels (gradient/bands.rs:1174-1184, gradient/bands.rs:1219-1226), legacy and common-pixel gains side by side with INKVEC_MERGE_COMMON_PIXELS (gradient/bands.rs:1216-1218), and a stop at the time budget (gradient/bands.rs:978-980) |
INKVEC_GRADDBG |
x0,y0,x1,y1: after band merging, prints every surviving component in the window and a fresh union fit with each neighbour, flat-flat pairs included (gradient/debug.rs:1-8, called at gradient/bands.rs:465-467) |
INKVEC_TIMING |
prints per-stage timing, the merge's rounds, waves, stale refits and per-model fit time, the profile-aware search included (profile; MergeTiming::report, gradient/bands.rs:711-758; counters in gradient/budget.rs:12-29), and the number of carved features (lib.rs:672-674) |
INKVEC_DIAG |
reports the merge's work spent, its cap, its rounds and why it stopped, and a stop at the work cap as a saturated budget (gradient/bands.rs:959-977) |
--no-gradients (CLI) |
sets opts.gradients = false; see "The --no-gradients fast path" above |
--time-budget (CLI) |
sets the merge's deadline at 60 % of the budget (crates/inkvec-cli/src/pipeline.rs:146-149; help at crates/inkvec-cli/src/args.rs:364-371); 0, the default, means no deadline, and the merge is bounded by the work cap alone |
Open questions
- Four of research prototype A10's six parts were removed in Wave B.
segments(smooth-segment region proposals after Chakraborty et al. 2025 §3.2) andmdl(the proposals accepted by MDL) gave no demonstrable gain on the gate on top of the others (quality-128ss dE00 −0.76 % against −1.08 % without them; quality-512ss −2.85 % against −2.71 % and quality-512ssop −1.92 % against −1.43 %, inside the intervals) and made 34 of the 168 gradient icons worse against 19.cover(a pair ranked by the legacy gain must also gain on common pixels) failed the gate alone (quality-128ss dE00 +0.55 %, the weightlifteru1f3cb_200d_2642+0.195), andguard(refuse a profile that changes visibly within 1.5 px) changed little, mostly for the worse; together on top of the graduated parts they moved no condition by more than 0.02 points. Their multicut never cut as ported (the segment graph is edgeless whenτ_s = τ_d). They are in the history at e1bfcb5 (branchimpl2/gregions); the segmentation oracle (as-one 73 %) says the region question is still the main lever. - The emitter demotes an imperceptible gradient to the midpoint of its end stops, not to the region's own best flat colour; the fitter now refuses such candidates, but a gradient produced elsewhere (a fade, the alpha path) would still be demoted that way.
-
The profile-aware geometries add four candidates to every fit that reaches the ramp models, and their interior stops (
fit_mid_stops) are most of their cost (10.6 s of the cookie's 15.7 s merge at 128 px, single thread); a screen that skips a profile-aware geometry its line-scored twin already matches was tried (a 5 % score margin) and removed few candidates. -
MAX_SLIVER_MISFIT(gradient/stops.rs:73) gates stop acceptance insidefit_mid_stops(gradient/stops.rs:292-337), the interior-knot fitter every linear, radial and elliptical candidate is run through (gradient.rs:913). Once a new knotkis chosen and the profile refitted, the subsamples of its segment (between the neighbouring knots) are split into the side beforekand the side at or afterk; if the smaller side holds fewer than a tenth of all subsamples (a "sliver") and its median residual exceedsMAX_SLIVER_MISFIT(4.0) times the larger side's (floored at1/255), the stop is discarded and no further stops are tried (cuts_a_misfit_sliver,gradient/stops.rs:486-536). In effect, this stops the fitter from adding an interior colour stop to explain a small cluster of badly fitted pixels — "a stop bought to explain a feature at one end, not a shading" (gradient/stops.rs:282-287). It is reachable from both the band merger andfit_fill: both route throughfit_pixels(gradient.rs:1005), which callsfit_samples(gradient.rs:816), which callsfit_mid_stops(gradient.rs:913). Unverified: the source gives no stated derivation for why the threshold is 4.0 specifically. - Several thresholds —
MIN_GRADIENT_PIXELS = 16,MIN_VISIBLE_CONTRAST = 1.5/255,MIN_RAMP_SUPPORT = 0.10,MIN_SHARED_BOUNDARY = 3,CARVE_MAX = 64,RAMP_STEP_DE00 = 15,SMOOTH_FRACTION = 0.5,MAX_FIT_SAMPLES,CENTRE_SEARCH_SAMPLES— are stated qualitatively or not at all, with no reported sweep or measured alternative in the source comments, unlikeBIMODAL_MARGIN, the evidence radius or the work cap. These read as chosen rather than measured. - The gradient-evidence radius here (two pixels, not one) is explicitly measured against an alternative; by contrast
CARVE_RESIDUAL's "8 times the noise" multiplier has no comparison point recorded — only the floor (0.06) is justified by a worked example. fit_flat's stride-based subsampling (stride = (n / fit_cap()).max(1),gradient/fit.rs:289) takes everystride-th sample rather than a random or blue-noise subsample; whether this systematically biases the median on a region with any positional structure (e.g. a gradient-like drift within a nominally flat region) is not discussed in the source.common_pixel_gainprices every smooth pair on the default path through region recovery, and its doc comment now says so; it adds that pricing every pair this way (the research-buildINKVEC_MERGE_COMMON_PIXELS) "would need rendered-image validation to be the default (the split is not a render)" (gradient/bands.rs:178-181). Whether such validation was done for the smooth pairs is not recorded beside it; the measurement on record is the CHANGELOG 0.2.0 entry above.- The work cap bounds the waves, stale refits and common-pixel gains, but not the initial per-component fits, the carve or its parent refits; with
--time-budgetthe initial fits are not deadline-checked either (w2-robust report, section 5: "Remaining overrun under a budget").