This stage also exists as a plain-language page with diagrams.
Stage 02 — Coverage
Turns an anti-aliased raster into a scalar coverage field with an explicit, per-pixel uncertainty, instead of a bitmap to be thresholded.
Source: crates/inkvec-trace/src/coverage.rs
Entry points: bilevel_coverage() (coverage.rs:191), estimate_noise() (coverage.rs:165),
intake_scale() (coverage.rs:337), oversample_factor() (coverage.rs:467),
downsample_to() (coverage.rs:388)
Pipeline position: first stage of the bilevel front end (trace_bilevel, lib.rs:156), which
calls bilevel_coverage directly. The colour front end (trace_color_full_with_alpha,
lib.rs:256) does not build a CoverageField — it takes the same noise estimate
(estimate_noise, lib.rs:269) and the same edge-width measurement (intake_scale, lib.rs:282)
and feeds them into palette extraction (stage 03) and, later, into planar::refine_subpixel
(planar.rs:660-699), which re-derives the coverage-gradient formula this module documents
rather than sharing a CoverageField object. There is no stopwatch mark named coverage; its
cost is folded into whichever caller runs first.
What problem this solves
An anti-aliased pixel at the edge of a shape is not noise, and it is not a blurry copy of a
sharp original. It is a measurement: the fraction of that pixel's area the foreground
colour covers. coverage.rs:1-33 (the module doc comment) states the stakes plainly — reading
that pixel as a measurement rather than thresholding it away is, in the authors' words, "the
single largest lever in this project", and cites the failure this fixes: on the thin_features
benchmark, VTracer recovers 2 of 11 elements and no parameter setting recovers more, because
"the information is destroyed before any tunable stage runs" (coverage.rs:6-8, referencing
docs/M0-BASELINE.md §3).
Recovering the fraction is only half the job. The other half — and the reason this module is worth a stage of its own rather than a one-line helper — is recovering how much that fraction should be trusted, per pixel, and handing that number downstream so every later stage can decide how hard to try without being told separately.
Inputs and outputs
Input: Rgba (coverage.rs:120-127) — straight (unpremultiplied) RGBA floats in [0, 1],
row-major, four floats per pixel.
Output: CoverageField (coverage.rs:41-79):
| field | type | meaning |
|---|---|---|
width, height |
usize |
dimensions |
data |
Vec<f32> |
coverage a at each pixel centre, [0, 1] |
sigma_alpha |
f64 |
standard deviation of the coverage estimate, from pixel noise |
sigma_model |
f64 |
irreducible positional error of level-set extraction itself, in pixels |
fg, bg |
[f32; 3] |
the sRGB foreground/background colours coverage was measured against |
saturation |
f64 |
confidence, in [0, 1], that fg/bg were actually observed |
Two derived methods matter downstream:
gradient_magnitude(x, y)(coverage.rs:95-100) — central-difference|grad a|at a point.position_sigma(p)(coverage.rs:107-117) — positional uncertainty of a boundary point, in pixels: the quantity every later stage actually consumes.
How it works
Inverting the measurement
For a boundary between foreground F and background B, an observed pixel is the coverage
mixture
P = a*F + (1-a)*B
Projecting P onto the F - B axis inverts this:
a = dot(P - B, F - B) / |F - B|^2
This is a least-squares estimate across all three colour channels at once (coverage.rs:10-18),
not a single luminance threshold — the module doc comment is explicit that using all three
channels is deliberate, not incidental. In code this is bilevel_coverage's central loop
(coverage.rs:218-224):
let n = (p[0] - bg[0]) * d[0] + (p[1] - bg[1]) * d[1] + (p[2] - bg[2]) * d[2]; (n as f64 / dd).clamp(0.0, 1.0) as f32
where d = F - B and dd = |F - B|^2.
Estimating F and B
F and B cannot simply be the darkest and lightest pixels in the image — a single JPEG
overshoot or stray speck would then define the colour axis for the whole image
(coverage.rs:188-190, 199-202). bilevel_coverage instead:
- Composites the image over white and computes luminance
0.2126*R + 0.7152*G + 0.0722*B(coverage.rs:196, Rec. 709 coefficients). - Sorts luminance and takes a small plateau —
0.1%of pixels, clamped to[1, n-1](coverage.rs:206) — from each extreme, rather than a flat percentile. The doc comment notes a flat 2% percentile is "too conservative: on a shape only a few pixels wide it lands inside the anti-aliased ramp and reports a foreground far lighter than the truth" (coverage.rs:200-202). - Averages the RGB of pixels within 15% of that plateau band at each extreme
(
coverage.rs:211-212) to getfg(darker) andbg(lighter).
Two error-propagation steps
This is the part the module doc comment (coverage.rs:20-33) calls out as mattering
downstream, and it is worth reproducing exactly because the rest of the tracer's adaptive
behaviour is a consequence of it, not a separate design choice:
Step 1 — pixel noise to coverage noise. Pixel noise of standard deviation sigma_pixel
propagates through the projection linearly:
sigma_a = sigma_pixel / |F - B|
A faint boundary — small |F - B| — is measured badly, and the arithmetic says so directly: the
same noise divided by a smaller contrast gives a larger sigma_a.
Step 2 — coverage noise to positional noise. The boundary is extracted as the level set
a = 0.5. For an implicit curve, positional uncertainty along the gradient direction is
sigma_position = sigma_a / |grad a|
A crisp edge has a steep coverage gradient and localizes to a fraction of a pixel; a soft or blurred edge has a shallow gradient and does not.
Composing the two:
sigma = sigma_pixel / (|F - B| * |grad a|)
in pixels. This expression is what feeds the tau * sigma admissibility envelope in
inkvec-fit (crates/inkvec-fit/src/lib.rs:16, 49-51, 398-405): a candidate chord is admissible
only if every measured point lies within tau * sigma_k of it. Because sigma already contains
the boundary's own measured confidence, "the places we are allowed to simplify hard are exactly
the places we measured badly" (coverage.rs:32-33) — adaptive simplification needs no separate
heuristic layered on top.
sigma_model and why noise alone is not enough
CoverageField::sigma_model (default DEFAULT_SIGMA_MODEL = 0.05, coverage.rs:39) is added
in quadrature to the noise-derived sigma in position_sigma (coverage.rs:107-117):
noise.hypot(self.sigma_model).clamp(1e-3, MAX_SIGMA)
The doc comment on the field (coverage.rs:50-60) explains why this exists: propagating pixel
noise alone gives "an absurdly optimistic answer on clean synthetic input" — with zero noise,
sigma_a and therefore sigma_position tend to zero, and the fit is told it knows the boundary
to a thousandth of a pixel. It does not. Extracting a boundary as the 0.5 level set of a
bilinearly-interpolated field carries its own systematic error: the true shape need not be
exactly representable by the interpolant, and the interpolant is not the true reconstruction
filter. The total is sqrt(noise^2 + model^2), a resolution limit no amount of clean input
removes. The stated derivation: "Measured on analytic circles (see the inkvec-trace tests),
level-set extraction lands within roughly 0.05px, which is the default" (coverage.rs:59-60).
That test is not in this module's own #[cfg(test)] block; it lives in
crates/inkvec-trace/tests/subpixel.rs. recovers_circle_radius_to_sub_pixel_accuracy
(subpixel.rs:107-127) renders an analytic circle, runs it through the full trace_bilevel
pipeline (which performs this module's level-set extraction), fits a circle to the recovered
contour, and asserts radius and centre error under 0.1 px;
accuracy_holds_as_the_shape_shrinks_toward_the_pixel_grid (subpixel.rs:154-167) repeats
this down to a 6px radius with a 0.15 px bound. Both measure the full contour-then-fit
pipeline rather than isolating sigma_model alone, and their asserted bounds (0.1–0.15 px)
are a looser envelope than the doc comment's "roughly 0.05px" — consistent with it, not an
independent re-derivation of the exact figure.
position_sigma also floors the pure-noise term at MAX_SIGMA = 4.0 pixels when the gradient
is at or below 1e-6 (coverage.rs:108-114) — a plateau where the level set is genuinely
unlocalizable yields a large but finite value, "rather than an infinity that would poison the
fit" (coverage.rs:106).
saturation and unidentifiability
fg and bg are only trustworthy if some pixel in the image is actually fully inside the
shape. bilevel_coverage measures this geometrically rather than colorimetrically, because a
colorimetric measure would be circular — normalization maps the darkest observation to 1.0 by
construction, so "how many pixels are near 1.0" always answers "plenty" (coverage.rs:232-234).
The non-circular question: how many covered pixels (a >= 0.5) are strictly interior, with
all four orthogonal neighbours also covered (coverage.rs:237-256)? For a resolved shape, most
covered pixels are interior; for a sub-pixel stroke, none are, because it never fills a whole
pixel with margin to spare.
saturation = interior / inside (1.0 if inside == 0)
The saturation doc comment (coverage.rs:70-78) states plainly what low saturation means: the
colour axis is genuinely unidentifiable. "A 0.3px black stroke and a 1px grey stroke produce
the same pixels, and no amount of processing separates them." The honest response, and the one
implemented, is not to guess a width and assert it — it is to widen sigma_alpha so downstream
stages simplify rather than trust a number that cannot be known:
let confidence_penalty = (1.0 / saturation.max(0.05)).min(8.0); sigma_alpha: sigma_pixel / contrast * confidence_penalty,
(coverage.rs:267, 272). The penalty is capped at 8x and the floor on saturation in the
division is 0.05, so the penalty itself is bounded; no stated derivation is given for
either the 0.05 floor or the 8.0 cap specifically (see Open questions).
estimate_noise — MAD of a Laplacian
estimate_noise(gray, w, h) (coverage.rs:211-231) estimates per-channel pixel noise from a
single grayscale array:
- Compute the discrete Laplacian at every interior pixel:
4*c - left - right - up - down(coverage.rs:219-223), using the kernel namedLAPLACIAN_KERNEL = [4.0, -1.0, -1.0, -1.0, -1.0](coverage.rs:172). The Laplacian annihilates smooth content, so what survives in a flat region is noise (coverage.rs:188). - Take the median absolute value, not the mean — "an edge is a large deviation, but a rare
one", and the median keeps genuine edges from inflating the estimate (
coverage.rs:189-190). - Convert:
mad / MAD_TO_SIGMA / gain, whereMAD_TO_SIGMA = 0.6745(coverage.rs:177) is the standard MAD-to-Gaussian-sigma conversion factor andgainis computed, not a literal:LAPLACIAN_KERNEL.iter().map(|c| c * c).sum::<f64>().sqrt()(coverage.rs:229) — the root of the sum of the kernel's squared coefficients,sqrt(4^2 + 1 + 1 + 1 + 1) = sqrt(20). The result is floored atNOISE_FLOOR = 0.5/255(coverage.rs:184,230).
The kernel's squared-coefficient sum is 20, not 6. This matches the doc's own earlier
arithmetic check and is now confirmed by the source itself: the function's doc comment
(coverage.rs:192-210) records that until 2026-09-08 the divisor was the literal sqrt(6),
and that this was a bug — sqrt(6) is the noise gain of the one-dimensional second difference
[1, -2, 1] (whose squared coefficients do sum to 6), not of the four-neighbour 2-D Laplacian
actually used here, which sums to 20. Every noise estimate before the fix was too large by
sqrt(20/6) = 1.83x, measured at 1.832x against synthetic noise of known sigma
(coverage.rs:202). Two regression tests now pin this: the_kernel_constant_matches_the_loop
(coverage.rs:992-1002) asserts the kernel is [4.0, -1.0, -1.0, -1.0, -1.0] and that its
computed gain equals sqrt(20); noise_estimate_recovers_a_known_sigma
(coverage.rs:971-989) regenerates synthetic noise of known sigma and asserts the estimate
recovers it, with a failure message that names the old 1.83x ratio explicitly as the
regression to watch for. The 0.5/255 floor is
explained by its consumer, not by this function: the code comment beside the floor notes "the
palette divides by it" (coverage.rs:1007), pinned by the test
noise_estimate_floors_at_half_a_level (coverage.rs:1004-1011), so a literal zero would be a
division-by-zero risk two stages downstream.
bilevel_coverage, intake_scale, downsample_to
bilevel_coverage(img) (coverage.rs:191-278) is the entry point that ties the above together:
estimate fg/bg, project every pixel onto the axis to get data, measure saturation,
estimate sigma_pixel via estimate_noise on the composited-over-white luminance, and combine
into sigma_alpha with the saturation penalty.
intake_scale(rgb, width, height) (coverage.rs:306-381) answers a different question: how many
raster pixels does one unit of genuine edge detail occupy? Its full derivation and measured
figures are documented in its own doc comment (coverage.rs:306-336) and are load-bearing for
stage 03, so they are repeated in 03-palette.md rather than here. The short version: a ramp of
height d over w pixels has first difference d/w and second difference ~d/w^2, so the
ratio of first to second difference recovers w directly, independent of contrast. The function
takes the median of this ratio over pixels that sit on a real edge (first > EDGE_FLOOR,
coverage.rs:339, 369), so flat interiors and single-pixel noise do not vote, and returns at
least 1.0 (coverage.rs:335-336, 380).
downsample_to(img, nw, nh) (coverage.rs:388-431) area-averages an image down. It exists partly
to serve oversample_factor's round-trip test and partly as a general utility. Its doc comment
(coverage.rs:384-387) states the reason it averages in premultiplied colour and then
un-premultiplies: averaging straight colour would let a transparent pixel's stored (and
arbitrary) RGB bleed into an opaque neighbour as a dark halo, which downstream "would become a
traced contour that is not in the artwork." The test
downsample_does_not_bleed_colour_from_transparent_pixels (coverage.rs:701-731) pins this: a
checkerboard of opaque-white and transparent-black pixels downsamples to pure white, not grey.
oversample_factor(rgb, width, height) (coverage.rs:449-524) answers yet another, distinct
question from intake_scale: not how wide an edge is, but whether the raster's pixel count
exceeds what its content needs. It halves the image (box filter), restores it (bilinear), and
compares against the original; if the round-trip error stays under OVERSAMPLE_TOL = 3.0
(mean absolute error in 8-bit levels, coverage.rs:433-447), nothing was lost and the factor is
credited. Repeated at 2x, 4x, 8x. Its own doc comment explains why it exists alongside
intake_scale rather than replacing it: intake_scale is "the wrong measure for tolerances,
because a super-resolution model defeats it — it returns a sharp edge at high resolution, so the
raster reads as barely oversampled when it carries four times the pixels the drawing needs.
Measured on the incorpo mark upscaled 4x: edge width 2.00, where the answer is 4"
(coverage.rs:454-457). oversample_factor "asks the question directly instead": an oversampled
raster's surplus pixels can be thrown away and put back losslessly, "indifferent to whether the
surplus pixels are crisp or blurred, asking only whether they say anything" (coverage.rs:459-463).
Constants and thresholds
| name | value | controls | stated derivation |
|---|---|---|---|
DEFAULT_SIGMA_MODEL |
0.05 px |
floor added in quadrature to noise-derived positional sigma | "Measured on analytic circles" (coverage.rs:59-60); the test lives in inkvec-trace/tests/subpixel.rs:107-167, not in this module — see above |
MAX_SIGMA (in position_sigma) |
4.0 px |
ceiling on positional uncertainty when the gradient vanishes | no stated derivation; described only as "a large but finite value" to avoid poisoning the fit (coverage.rs:106) |
plateau fraction (in bilevel_coverage) |
0.001 (0.1%) |
how much of the luminance extreme is trusted to define fg/bg |
qualitative only: chosen because a flat 2% percentile "lands inside the anti-aliased ramp" on thin shapes (coverage.rs:200-202); no sweep cited for 0.1% specifically |
| extreme-band tolerance | 0.15 (15% of hi - lo) |
which pixels near each luminance extreme are averaged into fg/bg |
no stated derivation |
MAD_TO_SIGMA |
0.6745, fixed |
MAD-to-Gaussian-sigma conversion in estimate_noise |
standard statistical constant, stated not derived (coverage.rs:174-177) |
| Laplacian noise gain | sqrt(20), computed from LAPLACIAN_KERNEL (coverage.rs:172, 229) |
corrects for the 4-neighbour Laplacian's noise gain in estimate_noise |
derived and pinned by test: root of the kernel's squared-coefficient sum; a hardcoded sqrt(6) (the 1-D second difference's gain) was a bug, fixed 2026-09-08 — see above |
| noise floor | 0.5/255 |
minimum estimate_noise can return |
derivation is downstream: the palette divides by this value (coverage.rs:1007, test at coverage.rs:1004-1011) |
confidence_penalty floor |
saturation.max(0.05) |
prevents an unbounded penalty when saturation is near zero |
no stated derivation |
confidence_penalty cap |
8.0 |
ceiling on the sigma inflation from low saturation | no stated derivation |
EDGE_FLOOR (intake_scale) |
2.0/255 |
minimum first difference counted as a real edge, not noise | stated purpose, no numeric derivation (coverage.rs:339) |
MAX_W (intake_scale) |
64.0 |
clamp on a single edge-width observation | stated purpose (guards the degenerate straight-ramp case), no numeric derivation (coverage.rs:341-342) |
MIN_W (intake_scale) |
0.25 |
lower clamp on a single edge-width observation | no stated derivation |
| minimum edge observations | 16 |
below this, intake_scale returns 1.0 rather than trusting a thin sample |
no stated derivation (coverage.rs:375-377) |
OVERSAMPLE_TOL |
3.0 (mean abs. error, 8-bit levels) |
round-trip error threshold for oversample_factor |
measured, and the doc comment is explicit it cannot cleanly separate native from oversampled on its own: "the lowest native reading at /2 is 1.29 ... while the 4x upscale this exists for reads 2.12 at /4" (coverage.rs:436-440); the doc comment states this value is only safe to use downstream of the intake_scale gate, never as a gate itself |
minimum size for oversample_factor |
16x16, and sw/sh >= 8 at each step |
avoids measuring on too little data | no stated derivation |
Failure modes and edge cases
- Sub-pixel features are unidentifiable, not merely hard. When every feature in the image is
narrower than a pixel,
saturationis low andfg/bgcannot be trusted as measured — see thesaturationdiscussion above. The module's answer is to inflate uncertainty, not to guess. - Ringing is invisible to both detectors in this module.
intake_scalemeasures edge width; JPEG and other lossy codecs do not widen edges, they add oscillation in flat regions beside them.estimate_noiseis a median Laplacian, and ringing occupies a band next to the edge while most of the image stays flat — the median never leaves its floor. The testringing_does_not_widen_an_edge(coverage.rs:641-677) pins both blind spots deliberately: on a synthetic ringing edge,intake_scalereads underSOFT_INTAKE_EDGE_FOR_TEST = 1.75(coverage.rs:661-666), andestimate_noisereads exactly the0.5/255floor on both the ringing image and a clean control (coverage.rs:673-677). This is documented as a real regression: "The regression this module let through on 2026-09-08... The palette's noise guard was gated on this function alone, so a compressed logo measured a native 1.15 px, the guard stayed off, and the tracer fitted the encoder's ringing as artwork" (coverage.rs:633-640). The fix was to stop askingintake_scalea question it cannot answer and add a second, file-level signal —crate::lossy_container(lib.rs:94-113), which reads the container format itself (JPEG always lossy; WebP'sVP8/VP8LRIFF tag; PNG/GIF/BMP/TIFF always lossless) rather than trying to detect damage from pixels. Two pixel-level detectors were tried first and both failed for the stated reason: "the Laplacian of a lossily-coded flat region and the Laplacian of a cleanly-rendered 8-bit colour ramp are the same size... A clean radial gradient measured a higher 'damage' score than a q50 flat icon" (lib.rs:87-89). - A single JPEG overshoot or stray speck cannot hijack the colour axis — the plateau
percentile in
bilevel_coverageexists specifically to prevent this (coverage.rs:199-202). - A transparent pixel's stored colour cannot bleed into a downsample — see the halo-bug discussion above and its pinned test.
oversample_factoris not a substitute forintake_scale, and vice versa, and the doc comments on each say so explicitly:oversample_factorcatches an SR model producing a sharp edge on surplus pixels;intake_scalecatches a soft or blurred edge, whichoversample_factor's round-trip test alone would not reliably flag as oversampled versus genuinely band-limited native content (coverage.rs:436-440).
Environment overrides
No INKVEC_* environment variable is read directly inside coverage.rs (confirmed: no
env::var call anywhere in the file). Callers downstream (color.rs, lib.rs) read several
(INKVEC_NOISE_SIGMAS, INKVEC_SAME_INK_DE00, INKVEC_PALDBG) that consume this module's
outputs — see 03-palette.md.
Open questions
DEFAULT_SIGMA_MODEL's analytic-circle test — resolved.coverage.rs's own#[cfg(test)] mod testshas no test ofsigma_modelagainst a circle, but the test does exist, ininkvec-trace/tests/subpixel.rs:107-167— see thesigma_modeldiscussion above.- The Laplacian noise-gain factor — resolved. It is no longer
sqrt(6). The 4-neighbour Laplacian kernel used here,LAPLACIAN_KERNEL = [4.0, -1.0, -1.0, -1.0, -1.0](coverage.rs:172), has squared-coefficient sum20, and the code now computes its gain assqrt(20)directly from the kernel (coverage.rs:229) rather than hardcoding a constant. A hardcodedsqrt(6)— the gain of the unrelated 1-D second difference[1, -2, 1]— was shipped until 2026-09-08 and inflated every noise estimate by1.83x; see theestimate_noisediscussion above for the fix and its regression tests. confidence_penalty's cap of8.0and floor of0.05(coverage.rs:267) have no stated derivation — no sweep or measured figure is cited, unlike almost every other constant in this module.- The
0.1%plateau,15%extreme-band tolerance,EDGE_FLOOR = 2.0/255,MAX_W = 64.0,MIN_W = 0.25, and the16-observation minimum inintake_scaleare all qualitatively justified (what would go wrong without them) but none carries a specific measured value the waySOFT_INTAKE_EDGEorOVERSAMPLE_TOLdo. These read as reasonable engineering guesses rather than swept constants. - The colour path (
trace_color_full_with_alpha) never constructs aCoverageField. It independently callsestimate_noise(lib.rs:269) andintake_scale(lib.rs:282), andplanar::refine_subpixel(planar.rs:660-699) re-derives thesigma_pixel / contrast / |grad a|formula and addsDEFAULT_SIGMA_MODELin quadrature by hand, rather than sharing this module'sCoverageField::position_sigmamethod. The two implementations are consistent today, but nothing enforces that they stay so — a change toposition_sigmawould not automatically propagate to the colour path's copy inplanar.rs.