This stage also exists as a plain-language page with diagrams.
Stage 01 — Intake
Turns an arbitrary source file into the exact pixels the rest of the pipeline was tuned against: decoded, checked for lossy damage, undone if it is a disguised upscale, cleaned if it is genuinely degraded, priced in units of content rather than pixels, and matted to two opaque colours wherever it carries transparency.
Source: crates/inkvec-trace/src/lib.rs (decode and lossy detection),
crates/inkvec-cli/src/lib.rs (the driver: trace_image, resolution invariance),
crates/inkvec-cli/src/alpha.rs (unblock detection, matting),
crates/inkvec-sr (the super-resolution pre-pass, as a crate of its own)
Entry points: run() (crates/inkvec-cli/src/lib.rs:402), which calls
load_image() (inkvec-trace/src/lib.rs:115) then resolve_lossy()
(inkvec-cli/src/lib.rs:424), then trace_image() (inkvec-cli/src/lib.rs:196) — the
function that does essentially everything this stage covers.
Pipeline position: before the trace crate is invoked at all. trace_image ends by
calling one of run_strokes / run_bilevel / run_color, and it is run_color that
calls inkvec_trace::trace_color_full_with_alpha — stage 03 onward. Intake carries no
stopwatch mark of its own; its cost is folded into whatever the caller measures around
trace_image, except for the SR pre-pass, which times itself explicitly (see below).
What problem this solves
A raster file is not a canonical representation of a drawing — it is one snapshot at one size, possibly re-encoded, possibly resized by something that was not the artist, possibly carrying transparency the palette has no dimension for. Every stage downstream of intake was tuned against a specific kind of input (a native anti-aliased render, roughly 128 px on its long side, opaque), and every one of those assumptions can be wrong on a file a user actually hands the tool. Intake is not one problem; it is five, bundled because they all have to be resolved before a single pixel reaches the palette:
- Decode. Turn bytes of an unknown container format into straight RGBA floats.
- Know whether the file lies about its own cleanliness. A JPEG or lossy WebP looks, pixel by pixel, like a slightly noisy PNG — except the noise clusters at edges in a way that fools every pixel-level detector tried (see below), so the container format itself has to be asked.
- Undo what a viewer already did to the file. Someone with a 96-px logo who wants a large SVG frequently resizes the PNG first, with nearest-neighbour resampling — many viewers do this without being asked. What arrives is not more detail, it is the same detail redrawn as a staircase of large flat blocks, and the tracer would faithfully vectorise the staircase.
- Price every pixel-denominated constant in the tracer per unit of content, not per pixel, because the same logo exported at 128 px and at 512 px is the same drawing and should cost the same number of SVG parameters — see Resolution invariance, below.
- Reduce transparency to two opaque colours, because the projection at the heart of
stage 02 (
coverage::bilevel_coverage) and the palette's colour-mixture algebra both need a foreground and a background colour to unmix a boundary against; a fourth, alpha, dimension has nowhere to go in either of those.
Inputs and outputs
Input: a file path (load_image, crates/inkvec-trace/src/lib.rs:115) or an
in-memory byte slice (decode_image, lib.rs:120), in any format the image crate
recognises; in practice PNG, JPEG, WebP, GIF, BMP or TIFF (crates/inkvec-cli/src/args.rs:103
lists the accepted extensions). Both funnel through from_dynamic (lib.rs:125-134),
which calls .to_rgba8() and divides every byte by 255, producing straight
(unpremultiplied) floats — the same Rgba type stage 02 consumes.
Output: an Rgba the size the tracer will actually trace at (which may differ from the
file's own dimensions — see unblock, SR and --max-dim below), an Args whose lossy and
precision/min_area fields have been resolved from auto to concrete values, and — when
any resampling happened — a (display_w, display_h) pair the emitted SVG is retargeted to
at the end, so the file the user gets back always claims the size the input file claimed,
whatever size it was actually traced at.
How it works
Decode and the lossy-container check
load_image / decode_image do nothing exotic — decode, then from_dynamic. The
interesting function next to them is lossy_container (lib.rs:94-113):
pub fn lossy_container(bytes: &[u8]) -> Option<bool> { match image::guess_format(bytes).ok()? { image::ImageFormat::Jpeg => Some(true), image::ImageFormat::WebP => match bytes.get(12..16)? { b"VP8 " => Some(true), b"VP8L" => Some(false), _ => None, }, image::ImageFormat::Png | image::ImageFormat::Gif | image::ImageFormat::Bmp | image::ImageFormat::Tiff => Some(false), _ => None, } }
This is a fact about the container, not a statistic about the pixels, and the doc comment
(lib.rs:75-93) is explicit about why that distinction matters: the palette has a guard for
exactly this kind of damage (color::SOFT_NOISE_SIGMAS, documented in 03-palette.md), but
until 2026-09-08 it was switched only by coverage::intake_scale — the measured edge
width — which is blind to compression, because JPEG rings flat regions without widening
an edge. WebP is one container holding two different codecs, so the answer is read from the
RIFF sub-chunk tag at byte 12: VP8 (with the trailing space) is the lossy codec, VP8L
is lossless, and VP8X, the extended container, is left None rather than guessed — its
sub-chunks would need walking to know for certain. Every other accepted format stores exact
samples and reads Some(false). An unrecognised format reads None.
Four tests pin this down (lossy_container_tests, lib.rs:1278-1321): a real JPEG and PNG
encode read Some(true) / Some(false) (jpeg_is_lossy_and_png_is_not); only the first 32
bytes of the JPEG are needed (a_header_is_enough) — which matters, because the caller only
reads a small head of the file, not the whole thing; the three WebP RIFF tags resolve as
described (webp_is_read_from_the_riff_chunk); and nonsense bytes resolve to None, not to
a guess (nonsense_is_unknown_not_clean).
resolve_lossy and --lossy auto|on|off
resolve_lossy (crates/inkvec-cli/src/lib.rs:424-437) turns the CLI's three-way --lossy
flag (itself inkvec_sr::Mode, reused rather than a separate enum) into a definite yes or
no, once, before the trace crate ever sees a pixel:
pub fn resolve_lossy(args: &Args, head: impl FnOnce() -> Option<Vec<u8>>) -> Args { let mut out = args.clone(); if out.lossy == inkvec_sr::Mode::Auto { let lossy = head() .and_then(|b| inkvec_trace::lossy_container(&b)) .unwrap_or(false); out.lossy = if lossy { inkvec_sr::Mode::On } else { inkvec_sr::Mode::Off }; } out }
head is injected rather than read directly so the function is testable without touching
disk; run() (lib.rs:402-416) supplies it by reading the first 32 bytes of the input
file. An unreadable or unrecognised container resolves to Off — "not known to be lossy" —
because, as the doc comment states, the guard this feeds costs 10.9% on the 246-icon
screen set when it runs on a clean intake (0.4005 → 0.4442, measured 2026-09-08) and must
not fire on a guess (lib.rs:420-423). --lossy defaults to Auto
(crates/inkvec-cli/src/args.rs:82), so container-format detection runs on every trace
unless a user overrides it; On is there for a file that no longer admits what was done to
it — a screenshot of a JPEG re-saved as PNG (args.rs:38-42).
The resolved args.lossy == Mode::On becomes ColorOptions::lossy_intake
(crates/inkvec-cli/src/lib.rs:800), which the colour front end reads alongside
coverage::intake_scale to decide whether the palette's soft-intake constants
(SOFT_NOISE_SIGMAS, SOFT_SAME_INK_DE00) switch on — documented fully in 03-palette.md.
The unblock pre-pass — undoing an exact upscale
pixel_grid (crates/inkvec-cli/src/alpha.rs:227-255) answers a narrow, exact question:
is this raster a nearest-neighbour replication of a smaller one? A k× replication is
exactly invertible — average each k×k block and the original pixels return bit for
bit — so the test is deliberately strict, not a tolerance-based heuristic:
let constant = (0..h / k).all(|by| { (0..w / k).all(|bx| { let first = px(bx * k, by * k); (0..k).all(|dy| (0..k).all(|dx| { let p = px(bx * k + dx, by * k + dy); (0..4).all(|c| (p[c] - first[c]).abs() < 1.0 / 512.0) })) }) });
Every pixel in every block must match its block's first pixel to within 1.0/512.0
per channel — no tolerance for "nearly", because a looser test would also catch a genuine
drawing of large flat squares and averaging that away would be a real loss
(alpha.rs:219-223). k is tried downwards from MAX_FACTOR = 32 to 2 so an 8× upscale
is reported as 8×, not folded into a coarser factor it also happens to divide; below
smallest = 64 pixels per side the search stops even trying, because an unblocked image
that small would leave too few pixels for the later boundary solve to work with
(alpha.rs:231-234).
trace_image runs this first, before anything else looks at the image
(crates/inkvec-cli/src/lib.rs:210-226), and the doc comment above the call states the
measured cost of a naive tracer that skips it: a 96-px logo blown up to 768 traces its
pixel boundaries directly, coming back as 13 inks instead of 3 and 1568 straight lines
walking round pixel corners instead of the original curves. --no-unblock disables it.
Detection is exact-match only; a resampled or anti-aliased upscale — where block boundaries
are not perfectly constant — fails this test by design and is --sr's problem instead
(alpha.rs:225-226).
Why the SR pre-pass runs after unblock, not before
inkvec-sr (its own crate) exists for damage pixel_grid cannot undo exactly: JPEG
ringing, blur, a genuine photograph of a logo. It is documented in full below; the ordering
question is intake-specific and the comment at the call site
(crates/inkvec-cli/src/lib.rs:201-209) states the reason plainly:
Before the pre-pass, not after, and that is the whole point of the order: run the upscaler on the blocky version and it treats the block edges as the artwork — measured on a 96-px logo blown up to 768,
--sr oncame back with 14 inks and 1846 segments, worse than doing nothing. On the recovered original it has something real to put detail back into.
In other words: unblock is exact and free of side effects when it applies, so it always runs first; whatever the SR model sees next is the smallest raster that is honestly representative of the drawing, and it has real edges to sharpen rather than staircase artefacts to hallucinate detail onto.
The SR pre-pass itself: Mode::Auto | On | Off
--sr (default Off, crates/inkvec-cli/src/args.rs:83) controls a network-based
upscale-then-halve cleanup, implemented in the inkvec-sr crate.
inkvec_sr::Mode (crates/inkvec-sr/src/lib.rs:32-40):
pub enum Mode { #[default] Auto, // trace, measure the fit, clean and retrace only if the fit is bad On, // always clean first Off, // never clean }
Mode::On calls inkvec_sr::prepass (inkvec-sr/src/lib.rs:85-122) unconditionally: it
zeroes the colour channels of fully-transparent pixels (so they cannot drag their upscaled
neighbours toward an arbitrary stored colour), upscales with whatever Upscaler
implementation was built (the in-process model under the model feature, or a packaged
Python fallback via external::External), halves back down with a box filter
(clean::box_downsample) to the requested --sr-scale (default 2), and — unless
--sr-no-recolour — repaints flat regions with the source's own colours
(clean::match_flats) rather than trusting the network's colour reconstruction.
Mode::Auto (crates/inkvec-cli/src/lib.rs:231-250) is the more interesting path: it
traces the image once as it arrived (trace_once, lib.rs:471-482, itself a stripped
version of the ordinary pipeline), renders that trace back to a raster with resvg
(inkvec_sr::detect::render_svg, the same renderer the benchmark harness scores with — so
a residual measured here cannot disagree with a score measured there), and asks
inkvec_sr::decide (inkvec-sr/src/lib.rs:139-147) whether the trace explains the input
where the trace itself claims to be flat:
pub fn decide(img: &Rgba, svg: &str, threshold: f64) -> Decision { let Ok(model) = detect::render_svg(svg, img.width, img.height) else { return Decision::Keep { residual: None }; }; match detect::interior_residual(img, &model) { Some(r) if r > threshold => Decision::Clean { residual: Some(r) }, r => Decision::Keep { residual: r }, } }
interior_residual (inkvec-sr/src/detect.rs:108-140) is the signal: both images are
composited onto white, a mask marks pixels where the traced model is flat (using the
composited colour, not the straight-alpha one, because straight-alpha channels are constant
across an anti-aliased edge while only alpha moves — a mask built on them would call every
boundary pixel flat, exactly where a trace and its input are expected to disagree), and the
RMS colour difference over that masked, genuinely-flat area is the residual. A face-model
disagreement in flat territory is not modelling error — a piecewise-flat model is flat by
construction — so it is degradation.
The normalisation is deliberately sum / 9n, not the sum / 3n an RMS over three colour
channels would give (detect.rs:97-104), so this residual reads 1/sqrt(3) of the true
RMS. The doc comment calls this out explicitly as not a bug to fix: it is the formula
every threshold in the module was calibrated against, mirrored deliberately in the reference
Python implementation (tools/inkvec_sr/clean.py, "divides by three twice for the same
reason"), and "correcting it without recalibrating DEGRADED_RESIDUAL would move every
clean icon across the threshold — which is exactly what it did when this was first written
the 'right' way." interior_residual returns None when fewer than 100 masked pixels
exist to judge on, and the caller treats "cannot tell" as "keep" rather than "clean it",
because cleaning a clean image is the expensive mistake — three times worse than doing
nothing, dE00 0.605 against 0.195, per the module doc (lib.rs:9-12).
DEGRADED_RESIDUAL = 0.5 (detect.rs:36, also the default for --sr-threshold,
args.rs:84) is calibrated over 30 icons in five conditions (detect.rs:13-25):
| condition | min | median | p95 | max |
|---|---|---|---|---|
| clean | 0.000 | 0.000 | 0.275 | 0.376 |
| jpeg-q80 | 0.454 | 0.907 | 1.441 | 2.708 |
| jpeg-q50 | 0.650 | 1.241 | 1.942 | 2.669 |
| blur-1.0 | 0.274 | 0.914 | 2.332 | 5.524 |
| noise-2 | 0.812 | 0.837 | 1.156 | 1.426 |
At 0.5, no clean icon in this set is cleaned and 4.2% of damaged ones are missed; clean
and blur-1.0 overlap, so no threshold cleanly separates every condition, but the case the
detector was built for — JPEG — is cleanly separated. A cheaper, pixel-only detector (an 8×8
block signature, needing no trace at all) was tried and refuted: it reads 2.175 on clean
against 2.133 on JPEG q50, no better than noise. Auto therefore pays for one full extra
trace, because nothing cheaper discriminates.
--intake-scale (opt-in): resampling an oversampled input
normalise_intake (crates/inkvec-cli/src/lib.rs:157-181) is a separate, opt-in mechanism
from the unblock and SR pre-passes above: rather than undoing an exact replication or
cleaning genuine damage, it resamples an input that is native but carries more pixels per
unit of edge detail than the tracer needs — a photograph or a smoothly resized image, where
coverage::intake_scale (stage 02) reads a wide edge. It only runs when replicated is
false (unblock did not already fire) and args.sr == Mode::Off (the SR pre-pass, when it
runs, is expected to have already normalised scale):
fn normalise_intake(img: inkvec_trace::Rgba, quiet: bool) -> (inkvec_trace::Rgba, bool) { let scale = inkvec_trace::coverage::intake_scale(&rgb, img.width, img.height); if scale < INTAKE_SCALE_FLOOR { return (img, false); } let s = scale.min(INTAKE_SCALE_CAP); ... let out = inkvec_trace::coverage::downsample_to(&img, nw, nh); (out, true) }
INTAKE_SCALE_FLOOR = 1.5 (lib.rs:152) — below this the intake is left alone; "every
image in the corpus reads exactly 1.00" per the constant's own comment.
INTAKE_SCALE_CAP = 8.0 (lib.rs:155) bounds how much a single, possibly wrong, estimate
is allowed to throw away at once. --intake-scale defaults off (args.rs:88, 248); the
usage text states the trade explicitly (args.rs:202-215): on 8×-upsampled input at 1024 px
it is 24× faster with a twelfth of the parameters and half the colour error, but DINO
— the corpus's structural-similarity measure — falls from 0.954 to 0.916, so it is offered
for input too slow to trace at all, not to make an already-tractable trace faster.
--max-dim and --time-budget
--max-dim (default 2048, args.rs:67, 227) is a hard ceiling applied after every other
resampling decision (crates/inkvec-cli/src/lib.rs:295-314): if the longest side still
exceeds it, the image is box-filtered down to it — the same exact area average
normalise_intake uses, "so edges stay edges" — and the SVG is written at the original
size regardless. The stated reason is trace time, which grows with pixel count; 2048 is
chosen to keep "a typical logo under a few seconds" (args.rs:113-116), a qualitative
target rather than a swept figure.
--time-budget (default 0.0, meaning unlimited, args.rs:68, 228) is advisory rather
than a hard deadline. It is spent in run_color (crates/inkvec-cli/src/lib.rs:787-794):
60% of it becomes the deadline passed to ColorOptions::deadline, which stops gradient-band
merging early (the one stage whose cost grows with the square of the region count — an
unmerged band is still a correct fill, just an unmerged one); 25% becomes the millisecond
budget for the boundary solve. The output is stated to remain "a correct trace, with more
fills or a less polished outline" at any budget (args.rs:117-120) — the two fractions
(0.6, 0.25) are not derived from a sweep in anything read for this stage; see Open
questions.
Resolution invariance: content_scale, REF_EXTENT, fit_config, in_content_units
This is the subtlest mechanism in the stage, and it exists to fix a real, measured disease.
Every pixel-denominated constant in the tracer — the positional uncertainty
DEFAULT_SIGMA_MODEL (≈0.05 px, stage 02), the output coordinate --precision (0.1 px),
the speckle floor --min-area (2 px²) — was set against a corpus whose images are roughly
128 px on the long side. Handed a 512-px raster of the same logo, nothing about those
constants changes, but the geometry the fitter sees does: four times the boundary points,
each carrying four times the pixel residual for the same relative fit, while lambda
(= ln(extent/precision), crates/inkvec-fit/src/lib.rs:64-80) has grown by only
ln(4) ≈ 1.386 — a fraction of a nat, nowhere near enough to offset a chi-squared term
that scales with the square of the residual times four times as many points. The fitter
therefore keeps buying segments it does not need, because each additional segment looks
cheap next to how expensive staying wrong has become. Measured on real brand logos
(crates/inkvec-cli/src/lib.rs:90-99): 3.54× the artist's own parameter count at 128 px,
6.02× at 256 px, 11.62× at 512 px, for content that had not changed at all. A brand mark
has one complexity, and the doc comment's framing is exact: a vectoriser should return it
"whatever resolution the export happened to be."
The fix restates the pixel-denominated quantities per unit of content. Let
REF_EXTENT = 128.0 (lib.rs:103) be the size the corpus was tuned at, and
fn content_scale(img: &inkvec_trace::Rgba, args: &Args) -> f64 { if !on { return 1.0; } (img.width.max(img.height) as f64 / REF_EXTENT).max(1.0) }
(lib.rs:107-120) — never below 1.0, so a raster smaller than the reference does not
get to claim more precision than the reference had. s = extent / REF_EXTENT is exactly
1.0 at 128 px, so nothing moves for the corpus the tracer was tuned on.
Two things then use s, and it is important to be precise about which one the code
actually applies where:
in_content_units (lib.rs:139-148) multiplies a fitted polyline's positional sigma
— the per-point uncertainty from stage 02 — by s. This is called on every boundary before
fitting, in both run_bilevel (lib.rs:748-750) and run_color (lib.rs:851-855),
whenever --content-units is on, regardless of which other path produced the image. Because
the admissibility test in inkvec-fit accepts a candidate chord only while every point
stays within tau * sigma of it (stage 02's central idea), inflating sigma by s lets
the same relative deviation — now s times larger in raw pixels, because the boundary
itself is s times bigger — pass the same test it would have passed at 128 px.
fit_config (lib.rs:122-136) goes further: it also multiplies --precision by s
before deriving lambda, and then multiplies the resulting lambda by s again:
fn fit_config(img: &inkvec_trace::Rgba, args: &Args) -> FitConfig { let extent = img.width.max(img.height) as f64; let s = content_scale(img, args); let mut cfg = FitConfig::from_precision(extent, args.precision * s, args.tau); cfg.lambda *= s; cfg }
Algebraically, because extent = REF_EXTENT * s whenever s > 1, the first factor of s
cancels the s in extent, leaving lambda fixed at ln(REF_EXTENT / precision) before
the second multiplication restores it to s * ln(REF_EXTENT / precision) — the doc comment
states this outcome directly (lib.rs:122-129): the further factor of s prices the point
count, because "the data term is a sum over boundary samples, there are s times as many
of them per unit of content, and pricing a parameter in the same currency means scaling its
cost by the same s." The comment's own conclusion is that both mechanisms are needed
together: "Together with sigma scaled by s at the fit, the optimum is the one a 128 px
raster of the same shape would reach — from better points."
What actually reaches the emitted SVG is only the first mechanism. trace_image
constructs the cfg that is passed to run_strokes, run_bilevel and run_color — the
functions that produce the SVG the user gets — directly, at lib.rs:374:
let cfg = FitConfig::from_precision(extent, args.precision, args.tau);
with the raw pixel extent and the unscaled args.precision; content_scale plays no
part in this call. fit_config, the function that scales lambda as well as sigma, is
called in exactly one place in the whole crate: trace_once (lib.rs:471-482), the probe
trace used only when --sr auto decides whether the input needs cleaning — and --sr
defaults to Off (args.rs:83), so on the overwhelmingly common configuration fit_config
is never called at all. In every actual, emitted trace, --content-units widens each
boundary's tolerance (in_content_units) but leaves lambda at its raw-pixel value,
growing only logarithmically with resolution — the same quantity the module's own framing
names as insufficient on its own. See Open questions: whether this is a deliberate
simplification (sigma-widening alone may be "enough" in practice, since it is what the
9.6.2026 commit measured its real-brand-logo numbers against) or an unfinished wiring-up of
fit_config into the production path could not be settled by reading the source alone.
content_units defaults to false (args.rs:66, 226); INKVEC_CONTENT_SCALE=1 is the
environment equivalent (lib.rs:114-115). It is off by default because it is a genuine
trade, not a free correctness fix: the usage text states 5-px rotated squares get accepted
as circles and thin rings come out broken under it, trading 1.16× the artist's parameters
for 0.82× (args.rs:158-161).
The oversample block: pricing --precision and --min-area in the raster's own units
A separate mechanism, downstream of everything above, addresses a related but distinct
disease: a raster that is native (not resized, not compressed) but was simply exported far
larger than its content needs — a super-resolution model's own output, for instance, which
returns genuinely sharp edges at high resolution and so is invisible to intake_scale
(edge width) even though it is carrying far more pixels than the drawing needs. The comment
at the call site (crates/inkvec-cli/src/lib.rs:325-341) gives the measured cost of leaving
this alone: on the incorpo mark upscaled 4×, 301 paths and 11,960 coordinates against 16
paths and 456 coordinates for the same drawing at 1×; hand-scaling --precision and
--min-area for the known factor brought it back to 21 paths and 441 coordinates.
The fix is coverage::oversample_factor (stage 02), gated behind intake_scale because the
two measure different things and only one of them can make the first call safely:
let oversample = if inkvec_trace::coverage::intake_scale(&rgb, w, h) > inkvec_trace::color::SOFT_INTAKE_EDGE { inkvec_trace::coverage::oversample_factor(&rgb, w, h) as f64 } else { 1.0 };
(lib.rs:342-358). intake_scale decides whether this is native content — every corpus
raster reads at most 1.50 against the SOFT_INTAKE_EDGE = 1.75 threshold (documented fully
in 03-palette.md) — a job oversample_factor's round-trip test cannot safely do on its
own, because smooth native artwork survives halving too and would be rewritten for no
reason. oversample_factor then measures by how much, which intake_scale cannot: an SR
model's sharp-but-oversampled output reads only 2.00 by edge width where the true factor is
4. When oversample > 1.0, --precision is scaled by oversample and --min-area by
oversample² (it is an area) before FitConfig is built (lib.rs:359-372) — this happens
independently of, and after, the --content-units machinery above; both can be active on
the same trace.
Alpha handling and matting
Everything from coverage::bilevel_coverage (stage 02) onward assumes an opaque image: the
coverage projection needs a definite foreground and background colour to unmix a pixel
against, and the palette has no fourth, alpha, dimension to cluster on. So intake's last
step, after every resampling decision above, is to matte transparency away
(crates/inkvec-cli/src/alpha.rs:319-320).
choose_matte (alpha.rs:287-424) picks the flattening colour by what it would swallow
rather than by a fixed choice. White was the historical default, and white is exactly wrong
for the input people bring most often: a white mark on a transparent ground composites to
one flat white and traces to nothing at all; a pale translucent panel disappears into a
white background it is drawn over. The function walks the image once, classifying drawn
pixels into three kinds — the silhouette and its anti-aliased rim, flat translucency (one
opacity, which the emitter can later carry out as fill-opacity), and a "glow" (opacity
that varies across the shape, which no single flattening colour can serve honestly and so
gets no vote) — then tries six candidate colours in order (white, black, magenta, green,
cyan, orange) and keeps the first one that would not composite more than SWALLOWED = 0.33
of the drawn-and-translucent mass into itself, measured by CIEDE2000 within MARGIN = 10.0
(alpha.rs:288-305, 411-423). Two cheaper rules were tried and measured worse on the
246-icon screen set first — "furthest colour from everything in the image" swung a white
highlight buried in an emoji onto a saturated matte (0.4123 → 0.4735); voting on silhouette
colours alone did the same for a white sock — which is why the bar is a share of drawn
mass, not a distance to any single ink (alpha.rs:271-275).
This choice only reaches the traced faces under --cutout (default off): without it, the
image is always matted to plain white, because transparency cannot reach the output anyway
(a transparent face is painted solid, not punched, and translucency is baked flat), so
choosing a content-aware matte would move every edge in the file for no gain it could carry.
The doc comment on alpha_source records exactly why this gate is not optional: the
committed CI screen-set gate rejected an always-on content-aware matte outright — dE00
0.15404 → 0.15654 against a limit of 0.15558 — because the corpus is scored over white and
cannot see any of the gain a better matte buys on other backgrounds (alpha.rs:436-442).
INKVEC_MATTE=white|black|magenta forces the answer from the environment
(alpha.rs:309-316), bypassing choose_matte entirely.
crates/inkvec-trace/src/alpha.rs is a related but distinct mechanism, run after the
trace rather than during intake: decompose (alpha.rs:345) recovers a translucent layer
— one shape at one opacity, seen through several different backgrounds — from the flat face
partition the trace already produced. The module doc comment (alpha.rs:1-90) states the
algebra plainly: a layer of colour C at opacity a over two different backgrounds G1
and G2 produces two observed faces whose difference c_F1 − c_F2 = (1−a)·(c_G1 − c_G2) is
independent of the unknown C — a face seen against only one background can never be
distinguished from a flat region, so a layer with only one hypothesis is rejected, always.
Recovering the layer needs at least two hypotheses over well-separated backgrounds, a
quad-adjacency test that matches the actual geometry of a translucent edge crossing a
background edge, and a battery of conservative gates (opacity in (0.05, 0.98), standard
error on the recovered opacity bounded, and more — alpha.rs:68-90), because a false layer
is a visible error: it unions faces that are not one shape and paints them a colour that
appears nowhere in the source. This is exposed as --layers (default off, opt-in via
INKVEC_LAYERS too), and its own doc comment records it firing on "about one real icon in
twenty" of the census used to tune it — two of forty (args.rs:126-132,
crates/inkvec-cli/src/alpha.rs:528-529).
Constants and thresholds
| name | value | controls | stated derivation |
|---|---|---|---|
REF_EXTENT |
128.0 px |
the intake size every pixel-denominated tracer constant was tuned at; divisor for content_scale |
measured: 3.54×/6.02×/11.62× the artist's parameter count at 128/256/512 px for unchanged content (crates/inkvec-cli/src/lib.rs:90-99) |
INTAKE_SCALE_FLOOR |
1.5 |
below this, --intake-scale leaves the input alone |
"Every image in the corpus reads exactly 1.00" (lib.rs:150-151) |
INTAKE_SCALE_CAP |
8.0 |
ceiling on how much --intake-scale will discard from one estimate |
"A wrong estimate should cost detail slowly, not all at once" (lib.rs:153-154) |
--max-dim default |
2048 px |
ceiling on traced (not emitted) size | qualitative: "keeps a typical logo under a few seconds" (crates/inkvec-cli/src/args.rs:113-116); no sweep cited |
--time-budget split |
0.6 merge / 0.25 boundary-solve |
how an advisory wall-clock budget is allotted between the two most expensive stages | stated as a fixed split, no numeric derivation given (args.rs:117-120, lib.rs:787-794) |
MAX_FACTOR (pixel_grid) |
32 |
largest replication factor the unblock pre-pass will try | tried downwards so an 8× upscale is reported as 8×, not folded into a smaller divisor; no numeric derivation for the cap itself (crates/inkvec-cli/src/alpha.rs:222-223, 228) |
smallest (pixel_grid) |
64 px |
floor below which unblocking is not attempted | "leaves too few pixels for the boundary solve to work with" (alpha.rs:231-234); no swept value |
block-constant tolerance (pixel_grid) |
1.0/512.0 per channel |
how exactly a block must match to be called a replication | "strict — no tolerance for 'nearly'"; qualitative only, no numeric derivation for 1/512 specifically (alpha.rs:219-223) |
MARGIN (choose_matte) |
10.0 (CIEDE2000) |
how close a composited colour must land to a matte candidate to count as "swallowed" | no stated numeric derivation |
SWALLOWED |
0.33 |
share of drawn-and-translucent mass a matte candidate may swallow before rejection | motivated by the white-highlight and white-sock cases; the specific 0.33 itself is not swept (alpha.rs:271-306) |
DRAWN |
0.5 |
alpha above which a pixel counts as part of the silhouette rather than a glow/translucency vote | "faint content is baked against the matte whatever it is... letting it vote flipped two emoji onto a black matte" — qualitative (alpha.rs:297-307) |
DRAWN_FLOOR |
0.05 |
alpha below which a pixel is ignored entirely | no stated numeric derivation |
SOFT_SHARE |
0.05 |
share of drawn+soft pixels that must be "glow" before white is kept outright without running the candidate ladder | no stated numeric derivation |
FLAT_ALPHA |
0.02 |
spread in a pixel's neighbour alphas below which its translucency counts as "flat" rather than a glow | no stated numeric derivation |
CI-gate literal 0.33 (alpha_source) |
0.33 |
warns the user when white would swallow more than this share, absent --cutout |
equal to SWALLOWED in value but written as a separate literal, not a reference to the constant (alpha.rs:457) — see Open questions |
DEGRADED_RESIDUAL / --sr-threshold default |
0.5 |
interior-residual threshold above which --sr auto cleans |
measured over 30 icons, five conditions: sits above the worst clean reading (0.376) and below the weakest damaged one (0.454, jpeg-q80); "4.2% of damaged ones are missed" at this value (crates/inkvec-sr/src/detect.rs:1-36) |
--sr-scale default |
2 |
output scale of the pre-pass relative to the input | not derived in what was read; stated as the default only |
| interior-residual normalisation | sum / 9n, not sum / 3n |
scales every residual reading (and therefore DEGRADED_RESIDUAL) to 1/sqrt(3) of a true RMS |
deliberate, matched to the reference Python implementation; explicitly "not a bug to fix" (inkvec-sr/src/detect.rs:97-104) |
SOFT_INTAKE_EDGE |
1.75 px |
gates whether oversample_factor runs at all |
fully documented in 03-palette.md; reused here unmodified |
OVERSAMPLE_TOL |
3.0 |
round-trip error tolerance inside oversample_factor |
fully documented in 02-coverage.md; reused here unmodified |
Failure modes and edge cases
- Unblocking after the SR pre-pass instead of before it destroys the SR model's input.
Measured directly: a 96-px logo blown up to 768 and cleaned before unblocking came back
with 14 inks and 1846 segments, worse than tracing the blocky image untouched
(
crates/inkvec-cli/src/lib.rs:201-209). - A compressed logo that passes both pixel-level checks is traced as if it were clean.
This was a real, dated regression, fixed 2026-09-08 (
96c3b79): a JPEG logo measuredintake_scaleat 1.15 px, under the 1.75 pxSOFT_INTAKE_EDGEthreshold, so the palette's soft-intake guard stayed off and the tracer fitted the encoder's ringing as artwork — 243 paths over 1301 faces, 89% of them covering 5% of the drawing. Two pixel-level detectors were tried and refuted first:sigma_noise(a median Laplacian) reads its0.5/255floor on the damaged file, identical to a clean render, because ringing occupies a thin band beside the edge while most of the image stays flat and never moves the median; a flat-region Laplacian statistic separated clean (0.0000) from JPEG (1.1–2.7) cleanly — until it met a genuinely clean radial gradient, which scored higher "damage" than the compressed flat icon, because 8-bit ramp quantisation produces the same Laplacian spikes a lossy codec does. The fix — readinglossy_containerinstead of inferring damage from pixels — reduced the same file to 80 paths over 654 faces, with the face, wrench, hand and lettering intact: it removed noise, not artwork (commit96c3b79,crates/inkvec-trace/src/coverage.rs:633-640). - Resolution dependence, before the fix, was severe and monotonic: 3.54×/6.02×/11.62×
the artist's parameter count at 128/256/512 px for a logo that had not changed — the
disease
content_scale,REF_EXTENTandin_content_unitsexist to treat (see How it works, above, and the Open question aboutfit_confignot being wired into the emitted trace). --content-unitsis a genuine trade, not a free fix: 5-px rotated squares get fitted as circles and thin rings come out broken under it, in exchange for 0.82× the artist's parameters against 1.16× without it (crates/inkvec-cli/src/args.rs:158-161).--intake-scalecosts structural accuracy for speed, and says so in its own help text: 24× faster with a twelfth of the parameters and half the colour error on 8×-upsampled input at 1024 px, but DINO — the corpus's structural measure — falls from 0.954 to 0.916 (args.rs:202-213).- The SR pre-pass is three times worse than doing nothing on clean input (dE00 0.605
against 0.195,
inkvec-sr/src/lib.rs:9-12), which is whyMode::Autopays for a full probe trace before deciding, rather than guessing from pixel statistics. - A soft glow is deliberately excluded from voting on the matte, and the cost of getting
that wrong is recorded directly: letting a candle's flame vote on
noto-emoji/emoji_u1f56fchose a black matte that baked the flame dark, dE00 0.22 → 5.31 (crates/inkvec-cli/src/alpha.rs:330-335). pixel_grid's exact-match requirement is a deliberate blind spot, not an oversight. A resampled or anti-aliased upscale — bilinear, Lanczos, or anything that blends across block edges — fails the constant-block test by design; that class of damage is--sr's job (alpha.rs:225-226).
Environment overrides
| variable | effect | default | source |
|---|---|---|---|
INKVEC_CONTENT_SCALE=1 |
equivalent to --content-units |
unset (off) | crates/inkvec-cli/src/lib.rs:114-115 |
INKVEC_MATTE=white\|black\|magenta |
forces choose_matte's answer, bypassing the swallowed-mass search |
unset (choose_matte decides) |
crates/inkvec-cli/src/alpha.rs:309-316 |
INKVEC_LAYERS |
forces --layers on |
unset (off, same as --layers unset) |
crates/inkvec-cli/src/alpha.rs:538 |
INKVEC_LAYER_SIGMA |
overrides the sRGB noise sigma used when fitting a translucent layer | LAYER_SIGMA_SRGB |
crates/inkvec-cli/src/alpha.rs:567-571 |
INKVEC_ALPHADBG |
prints alpha/layer diagnostics to stderr | unset (silent) | crates/inkvec-cli/src/alpha.rs:708, crates/inkvec-cli/src/emit.rs:569 |
No INKVEC_* variable is read inside inkvec-trace/src/lib.rs's load_image
(lib.rs:127-130), decode_image (lib.rs:133-136), from_dynamic (lib.rs:138-147) or
lossy_container (lib.rs:105-124), nor anywhere in the inkvec-sr crate — every knob in
the SR pre-pass and the lossy-container check is a CLI flag, not an environment variable.
Other functions later in that same file (lib.rs) do read many INKVEC_* variables — for
palette, boundary-solve, decode and other downstream stages — but none of those reads
happen inside the four intake functions named above.
Open questions
fit_config's lambda-scaling is written, documented as necessary, and not wired into the trace that is actually emitted. Its own doc comment (crates/inkvec-cli/src/lib.rs:122-129) states plainly that both halves — sigma scaled at the fit, andlambdascaled with it — are needed together for the fitter to reach "the optimum a 128 px raster of the same shape would reach." Buttrace_image, which produces the SVG returned to the user, builds itsFitConfigdirectly atlib.rs:374(FitConfig::from_precision(extent, args.precision, args.tau)), never callingfit_config.fit_configis called in exactly one place in the crate —trace_once(lib.rs:471-482), the probe trace--sr autouses to decide whether to clean — and--srdefaults toOff, so on the ordinary code pathfit_configis never invoked at all. In every actual, emitted trace under--content-units, onlyin_content_units' sigma-widening runs;lambdastays at its raw-pixel value, growing only logarithmically with resolution, which is the exact insufficiency the module's own framing (REF_EXTENT's doc comment) names as the problem. Whether this is deliberate (sigma-widening alone may already suffice for the cases measured — the real-brand-logo numbers cited throughout this file were presumably measured against the code as it stands) or an incomplete wiring-up left over fromfit_config's introduction could not be determined from the source or its history alone, and the tracer was not run to check.- The CI-gate warning threshold in
alpha_source(crates/inkvec-cli/src/alpha.rs:457, literal0.33) duplicatesSWALLOWED(alpha.rs:305) by value but not by reference. A future change toSWALLOWEDwould silently desynchronise the warning from the matte selection it is meant to describe. choose_matte'sMARGIN,DRAWN_FLOOR,SOFT_SHAREandFLAT_ALPHAare each given a qualitative role in their surrounding comments but no measured sweep, unlikeSWALLOWEDandDRAWN, which are tied to specific before/after cases (the white-highlight and candle-flame regressions).pixel_grid'sMAX_FACTOR = 32,smallest = 64, and the1/512per-channel tolerance are each qualitatively justified but not swept to a specific measured value.--time-budget's0.6/0.25split between gradient-band merging and the boundary solve is stated as a fixed allocation with no derivation shown; it is plausible this was chosen by observing which of the two stages tends to dominate runtime, but that reasoning is not recorded where it was read.DEGRADED_RESIDUAL's calibration set is small — 30 icons in five synthetic conditions — and the module's own table showscleanandblur-1.0overlapping in range even at the chosen threshold. The doc comment is honest about this ("no threshold separates every condition"), but the generalisation of0.5beyond this specific 30-icon set was not independently verified here.