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 in the format its own bytes announce, turned upright and converted to sRGB the way a viewer shows it, 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 written down as an opaque image, its alphas kept beside it, wherever it carries transparency.
Source: crates/inkvec-trace/src/load.rs (decode, orientation, the size cap and lossy
detection), crates/inkvec-trace/src/load/icc.rs (colour management),
crates/inkvec-cli/src/lib.rs (the driver: run, intake, price_in_raster_units,
trace_prepared_priced), crates/inkvec-cli/src/units.rs (content units),
crates/inkvec-cli/src/alpha/unblock.rs (the nearest-neighbour inverse),
crates/inkvec-cli/src/soft_intake.rs and crates/inkvec-trace/src/softness.rs (soft
intake: reducing a resampled or blurred raster), crates/inkvec-cli/src/post.rs
(retarget, stretch_axes: presenting the SVG at the arrival size),
crates/inkvec-cli/src/alpha.rs (matting), crates/inkvec-cli/src/pipeline.rs
(color_options: the lossy flag and the time budget handed to the trace),
crates/inkvec-sr (the super-resolution pre-pass, as a crate of its own)
Entry points: run() (crates/inkvec-cli/src/lib.rs:840), which calls
load_image_capped() (crates/inkvec-trace/src/load.rs:403) then resolve_lossy()
(crates/inkvec-cli/src/lib.rs:877), then trace_image_sized() (crates/inkvec-cli/src/lib.rs:212),
which is trace_prepared(intake(...)). intake() (lib.rs:296) undoes a nearest-neighbour
upscale, reduces a resampled or blurred raster (soft intake, or --intake-scale in its
place), applies --max-dim and prices the pixel-denominated knobs in the
raster's own units; trace_prepared() (lib.rs:512) installs the curve cost model and hands
over to trace_prepared_priced() (lib.rs:532), which runs the restorer and SR pre-passes and
the matte, builds the fit configuration and calls one pipeline. Between them they do
essentially everything this stage covers.
Pipeline position: before the trace crate is invoked at all. trace_prepared_priced ends
by calling one of run_strokes / run_bilevel / run_color, and it is run_color
(crates/inkvec-cli/src/pipeline.rs:114, through run_color_impl) that calls
inkvec_trace::trace_color_full_with_alpha (pipeline.rs:249) — 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 two pre-passes, which time themselves for their stats
lines (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 — in the format the bytes say they are, whatever the file is named, the right way up, and in the colours a viewer would show.
- 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 fooled the first pixel-level detectors 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. Resized with a smooth filter instead (bicubic, Lanczos), it arrives with every edge spread over several pixels, which the tracer reads as extra inks, lumpy outlines and surplus parameters.
- 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 an opaque image, because the projection at the heart of
stage 02 (
coverage::bilevel_coverage) and the palette's colour-mixture algebra both need opaque colours to unmix a boundary against. Under--no-native-alphaa fourth, alpha, dimension has nowhere to go in either, so the image is matted onto one chosen colour; under native alpha, the default, it is written down over white and its alphas travel beside it into every stage that unmixes.
Inputs and outputs
Input: a file path (load_image_capped, crates/inkvec-trace/src/load.rs:392-406, which
the command line uses, or the uncapped load_image, load.rs:98-102), an in-memory byte
slice (decode_image / decode_image_capped, load.rs:104-108, :475-488), or raw RGBA8
pixels (rgba8_capped, load.rs:490-519, which has no format, profile or orientation to
read and only applies the cap). The decoders compiled in are PNG, JPEG, WebP, GIF, BMP and
TIFF (crates/inkvec-trace/Cargo.toml:18); the command line's usage line names the same six
(crates/inkvec-cli/src/args.rs:334). Every decoded image funnels through from_dynamic (load.rs:298-342), which produces straight
(unpremultiplied) floats in [0, 1], alpha 1 where the image has none — the same Rgba
type stage 02 consumes. How it gets there depends on the layout the decoder handed over:
- 8-bit RGBA is read straight from the decoder's own buffer, with no copy;
- 8-bit RGB, the common opaque layout (every JPEG, most opaque PNGs), is widened straight to four floats with alpha 255, skipping the intermediate RGBA8 buffer;
- every other layout (grey, grey plus alpha, 16-bit, float) still goes through the
library's
into_rgba8(), whose colour conversion the loader does not reimplement.
Each byte then becomes a float by a table lookup, UNIT[k] = k as f32 / 255.0
(load.rs:282-296, built at compile time), in parallel chunks of 65,536 pixels
(CONVERT_CHUNK_PIXELS, load.rs:278-280) from 256 × 256 pixels on (PARALLEL_MIN_PIXELS,
load.rs:274-276); below that it runs on the calling thread (widen, load.rs:344-373). The
table holds exactly the quotients the old per-byte division computed, and each output float
depends on one input byte, so neither the table nor the split can change a value; the tests
the_table_holds_the_quotients and from_dynamic_is_the_old_conversion (load.rs:623-643)
check every layout bit for bit against the old to_rgba8()-then-divide path, which is kept as
a test oracle (load.rs:521-572). At 2048 px the intake had cost 20 ms, all serial: decode
4.9, the RGBA copy 4.0 and the float conversion 10.1 (load.rs:28-30). The copy is gone for
8-bit RGB and RGBA files, and the conversion is split over the cores. The method is an
engineering change, not a published one (the doc comment says "Not from the literature";
"See also" Ragan-Kelley et al., Halide, PLDI 2013, on fusing an image pipeline's stages,
load.rs:319-325).
Output: an Intake (crates/inkvec-cli/src/lib.rs:220-241): the Rgba the tracer will
actually trace at (which may differ from the file's own dimensions — see unblock, soft
intake, --intake-scale and --max-dim below), an Args whose lossy field the command line has
already resolved from auto to on or off (a library caller that skips resolve_lossy
has auto read as off, crates/inkvec-cli/src/pipeline.rs:160-161) and whose precision,
min_area and lambda_scale are priced in this raster's own units, whether an upscale was
undone (replicated), whether anything resampled the raster (normalised), whether it was
reduced to whole source pixels (stretch: unblock or soft intake, whose two axes are
presented each at its own scale), and the
(display_w, display_h) the emitted SVG is retargeted to at the end whenever anything
resampled — so the file the user gets back claims the size the input file claimed
(upright), whatever size it was actually traced at; when the SR pre-pass ran on an image
that was not unblocked, it claims the size SR produced (crates/inkvec-cli/src/lib.rs:682-690). intake is public, and separate from trace_prepared, for one
caller: a browser that runs the restorer network itself between the two (lib.rs:284-295).
How it works
Decode and the lossy-container check
The loaders read the file once, decode it in the format its bytes announce, and apply what
a viewer applies before showing it. The steps below follow the passes the module
documentation lists, in its order (load.rs:8-30):
- Read once; the bytes decide the format.
load_image_cappedreads the whole file into memory (std::fs::read,load.rs:404) and decodes both the header (the dimensions the--max-dimcap is decided from) and the pixels from those bytes (load_file_bytes_capped,load.rs:408-433); it used to open the file twice, once for the header and once forimage::open, and the one-read change is filed as "Not from the literature: plumbing." The format is chosen bysniff(load.rs:110-136): the one the bytes' signature announces (\x89PNG,\xFF\xD8\xFF,GIF8,RIFF....WEBP,BM,II*\0/MM\0*, as theimagecrate'sguess_formatreads them), when it is one of the six formats the tracer reads; otherwise the one the path's extension names; and a file that names neither is handed toimage::openitself, for its own error message. The extension used to decide alone, asimage::opendecides, so a JPEG saved as.pngfailed with "Invalid PNG signature" and a PNG saved as.jpgwith "Illegal start bytes" (intake fuzz, 2026-10-02); a file whose extension is right decodes with the same decoder as before, so its pixels are unchanged. Citation, as the doc comment gives it: "Method from: magic-number content sniffing, the rule of the WHATWG MIME Sniffing Standard (§ 6.1, "Matching an image type pattern")", the patterns being "theimagecrate'sguess_format".decode_image_capped(load.rs:475-488) sniffs the same way with no path; bytes with no signature the tracer reads get the library's own error (image::load_from_memory).one_read_equals_image_open(load.rs:663-725) checks PNG (RGBA and RGB), BMP, JPEG and TIFF files, a text file named.pngand a missing file, at caps of 0, 64 and 2048, against the old two-open loader (the same pixels, or the same error text), and checks that a PNG named.jpg, a PNG with no extension and a JPEG named.pngeach decode pixel for pixel as the correctly named file. On the robustness branch's edge-case run the two mislabelled files that v0.2.4 refused traced byte-identical to the correctly named ones (impl2/robust report, 2026-10-03). - Refuse an image with no pixels.
decode_as(load.rs:138-162) reads the header's dimensions and callshas_pixels(load.rs:255-272) before anything is decoded: a side of zero is a decode error, "the image has no pixels (W x H)". A decoder can hand back such an image without an error — a GIF whose logical-screen width is 0 decodes to 0 × 30000, while PNG refuses the same header itself — and every stage after the intake divides by a side or indexes row 0; the first to do so was the unblock test (w / min(64, w), a division by zero, in Quality and Fast alike). The intake fuzz of 2026-10-02 found it in 3 of 1,200 mutated files, all GIFs, which panicked on v0.2.4 and are now refused (a_gif_with_a_zero_wide_screen_is_refused,load.rs:971-1005). "Not from the literature: an input check."pixel_gridkeeps a guard of its own for a caller that builds anRgbaitself (see the unblock pre-pass). - A larger allocation allowance when the cap will apply. The
imagecrate refuses any decode whose output buffer would pass 512 MiB (DEFAULT_MAX_ALLOC,load.rs:211-212), its guard against decompression bombs, and that refused legitimate files too: a 12000 × 12000 RGBA PNG is 576 MB decoded and failed with "Memory limit exceeded", although the trace would be capped at 2048 px and the full-size buffer lives only until the box filter has read it. When the header says the cap will apply (target_dims,load.rs:375-390),capped_decode_limits(load.rs:225-253) allowsCAPPED_EXTRA_ALLOC= 768 MiB more on a 64-bit target, 1.25 GiB in all: room for 16384 × 16384 RGBA at 8 bits (1 GiB) or 12000 × 12000 at 16 (1.15 GB), while the 20000 × 20000 bomb of the intake fuzz (1.6 GB) and a 100000 × 100000 header (40 GB) are still refused before anything is allocated; on a 32-bit target (WebAssembly), whose whole address space is 4 GiB, the extra is 0 (load.rs:214-223). An image within the cap, or any decode withmax_dim = 0, keeps the default limits exactly, and raising a limit changes no pixel of an image that decoded before. Citation: "Not from the literature: a resource limit. See also" theimagecrate'sLimitsdocumentation, and J. Cupitt, K. Martinez (1996), VIPS: an image processing system for large images, Proc. SPIE 2663, doi:10.1117/12.233043, for the strip-at-a-time alternative — "not taken, because theimagecrate decodes whole frames and a streaming PNG path would duplicate its colour conversions." On the edge-case run the 12000 × 12000 RGBA file, refused on v0.2.4, traced in both modes (620 MB peak), and the bombs were still refused at 5-8 MB (impl2/robust report, 2026-10-03). - Colour-manage, then turn upright.
decode_upright(load.rs:164-209) isImageReader::decodetaken apart only to read, between building the decoder and reading its pixels, the EXIF orientation (ImageDecoder::orientation) and the embedded ICC profile (ImageDecoder::icc_profile): the same decoder, built with the same limits, whose output buffer is reserved from them first, exactly asdecodedoes. An unreadable orientation counts as none and an unreadable profile as no profile, so neither fails a decode whose pixels are fine. Then, on the decoded 8-bit image: - The ICC profile is converted to sRGB (
icc::to_srgb,crates/inkvec-trace/src/load/icc.rs:72-149). The tracer reads every pixel as sRGB, which a Display P3 screenshot, an Adobe RGB photo or a gamma-1.8 prepress file is not; read as sRGB, six gamma-1.8 images with the profile embedded traced at dE00 0.84 on average (worst 3.03) against 0.077 for the same art saved as sRGB (r2-inputsformats_test.py, 2026-10-02;icc.rs:4-14). The profile is parsed bymoxcms(already theimagecrate's colour engine); an unreadable profile, or one whose colour space does not match the decoded pixels (a CMYK profile on a JPEG the decoder has already turned into RGB, a Lab profile), leaves the image as it was. Otherwise an 8-bit transform to sRGB is built with the relative colorimetric intent — an RGB profile converts RGBA to RGBA, alpha passed through; a grey profile on a grey image converts grey + alpha to RGBA. Before any pixel is converted,moves_colours(icc.rs:151-197) probes the transform on every level of the red, green, blue and grey ramps and a 9 × 9 × 9 lattice; when no probe moves by more thanSRGB_TOLERANCE= 1 level in any channel (icc.rs:66-67) and no alpha changes, the profile is sRGB in all but name and the image is returned untouched, because "converting it through lookup tables would only add a level of rounding noise to pixels that were already right" (icc.rs:30-34). Otherwise the whole image is converted in parallel bands ofROWS_PER_JOB= 64 rows (icc.rs:69-70); each pixel is converted on its own, so the split cannot change a value (the_row_split_does_not_change_a_value). The conversion runs before the cap and before the floats exist, so the box filter averages sRGB values exactly as it would for a file saved in sRGB. Citation, as the module doc gives it (icc.rs:48-56): "Method from: the ICC colour-management architecture, Specification ICC.1:2010 (profile version 4.3.0.0)", International Color Consortium, "as implemented by themoxcmscrate"; "Not from the literature: the identity probe of step 3". Measured before and after the change on one run: the six gamma-1.8 PNGs went from a mean dE00 of 0.85 to 0.12, and PNGs carrying three real embedded sRGB profiles (HP, Microsoft, PIL) traced byte-identical to the untagged PNG, 18 of 18 (impl2/robust report, 2026-10-03). No image in the benchmark sets carries a profile, so the traces judged there are unchanged (icc.rs:44-46).moxcmspicks SIMD paths at run time, but its 8-bit transforms are fixed-point, so a given machine always converts the same way (icc.rs:58-60). - The EXIF orientation is applied. A camera stores the sensor's rows as they came and
records how the picture is to be turned in the EXIF Orientation tag (274, values 1-8:
identity, the two mirrors, the three rotations and the two transposes); every viewer
applies it, and
decodedoes not. Six JPEGs stored with Orientation = 6 traced sideways, dE00 2.9 to 17.6 against 0.03 to 0.42 for the same images without the tag (r2-inputsformats_test.py, 2026-10-02;load.rs:168-173); after the change they trace at 0.027-0.439, the untagged JPEGs' own figures (impl2/robust report, 2026-10-03). The orientation comes from the decoder (the EXIF of a JPEG, WebP or PNGeXIfchunk, the TIFF tag) and is applied byDynamicImage::apply_orientationafter the colour conversion and before the cap, so the cap and everything after it see the image as it is shown, and the arrival dimensions handed back are upright: a quarter turn swaps the sides (load.rs:159-161). Citation: "Method from: the Orientation tag (274) of the Exif standard (CIPA DC-008), as theimagecrate implements it:image::metadata::Orientation... andDynamicImage::apply_orientation" (load.rs:182-186).the_exif_orientation_is_applied(load.rs:884-947) checks all eight values pixel by pixel on a 3 × 2 image, value 1 bit-identical to the untagged decode.upright_dimensions(load.rs:449-473) reads the same upright size from the header alone, for a caller that reports a file's size before tracing it; Inkvec Studio does (studio/core/src/trace.rs:413). - Cap, then widen: above
max_dimon the longer side the 8-bit buffer is box-averaged down (cap_decoded,load.rs:435-447, throughcoverage::box_downsample_rgba8) before any float exists; otherwisefrom_dynamicwidens it as described above.
The --lossy auto check below still opens the file separately for its first 32 bytes, and
only when --lossy is auto (crates/inkvec-cli/src/lib.rs:857-866).
The interesting function next to the loaders is lossy_container (load.rs:58-96):
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
(load.rs:58-76) 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, load.rs:728-778): 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:871-890) 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:907-916) 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:871-876). --lossy defaults to Auto
(crates/inkvec-cli/src/args.rs:275), 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 PNG that was once a JPEG -- a re-saved screenshot, an export from a chat app"
(args.rs:150-155).
The resolved args.lossy == Mode::On becomes ColorOptions::lossy_intake
(color_options, crates/inkvec-cli/src/pipeline.rs:162), which the colour front end
reads alongside coverage::intake_scale and a third, pixel-level signal,
coverage::ringing_score, to decide whether the palette's soft-intake constants
(SOFT_NOISE_SIGMAS, SOFT_SAME_INK_DE00) switch on (crates/inkvec-trace/src/lib.rs:359-379).
ringing_score (crates/inkvec-trace/src/coverage.rs:579-627) reads where the Laplacian's
energy sits — a band 3 to 7 px out from strong edges, where clean vector art is flat, times
the sign-alternation rate there — and is "the only one of the three a re-encode cannot
launder": a JPEG re-saved as PNG defeats lossy_container. Its doc comment gives clean brand
logos firing at 3.8% and 82 to 91% of JPEG caught at the shipped threshold. All three are
documented fully in 03-palette.md.
The unblock pre-pass — undoing a nearest-neighbour upscale by any factor
pixel_grid (crates/inkvec-cli/src/alpha/unblock.rs:237-276) answers a narrow, exact
question: is this raster a nearest-neighbour resize of a smaller one, by any factor of 2 or
more, whole or not? A nearest-neighbour resize of an m-pixel line to n pixels repeats
each source pixel over a cell; libraries differ in where the cells start (Pillow,
scikit-image and OpenCV's INTER_NEAREST_EXACT sample near the pixel centre, OpenCV's
INTER_NEAREST at the corner, and a float scale breaks exact half-pixel ties either way),
but in every convention the i-th cell starts within one pixel of φ + i·p, with pitch
p = n/m (unblock.rs:14-24). Repeating one pixel per cell rebuilds such an input bit for
bit, so the inverse is exact, and the function checks that it is before it answers
(unblock.rs:27-32). It replaced (2026-10-04, r2-inputs proposal 5.3) an integer-only test
— every k×k block constant, k dividing both sides, at most 32, leaving at least 64 px
on each side — which never fired on a 3.5× browser zoom and undid a 4× wordmark only 2×.
The passes (unblock.rs:34-65):
- Change positions (
line_changes,unblock.rs:278-319): columnx ≥ 1is a change when it differs from columnx − 1in any pixel, bit for bit; likewise rows. Every change must be a cell start. Two adjacent changes mean a one-pixel cell, which no pitch of 2 or more makes, so the scan stops at the first such pair — on a native render, the first anti-aliased curve, a few rows into the content. A row identical to the row above adds no column change, so only the first row and the changing rows are walked pixel by pixel, and rows are compared as whole slices first (same_bits,unblock.rs:321-332). - Evidence: at least
MIN_CHANGES = 48changes on the two axes together (unblock.rs:120-136), so that a lattice is a finding and not a coincidence. The windows of one pixel at pitchp ≥ 2hold at most 3 integers in 5, sorchanges placed independently of a lattice fit one with probability at most0.6^r; over all the hypotheses the chance of an accidental fit is below3·n·(p+1)²·0.6^r, about2·10⁻⁶atn = 2048,p = 2.5,r = 48(a heuristic bound, stated as one in the comment). - Admissible source sizes per axis (
admissible_sources,unblock.rs:376-400):mfromMIN_SOURCEton/2(pitch at least 2) is admissible when every changeb, and 0, lies in a window[φ + i·p, φ + i·p + 1]for one phaseφ. That is the statement that the residuesb mod pfit in an arc of one pixel on a circle of circumferencep; multiplied bymit is integer arithmetic with no tolerance — the residuesq_b = b·m mod nmust fit in an arc of at mostmon the circle of circumferencen(residue_arc,unblock.rs:350-374, the arc being the complement of the widest gap between consecutive residues). The arc is closed for a fractional pitch, because Pillow's 3.5× put alternate cell starts at either end of the window, and half-open for a whole pitch, which has no ties and whose closed windows at pitch 2 would cover every integer. - One scale (
coarsest_pair,unblock.rs:402-423): someswith|n_x − s·m_x| < 1and|n_y − s·m_y| < 1(in integers,(w−1)·m_y < (h+1)·m_xand(h−1)·m_x < (w+1)·m_y), the long side at leastMIN_SOURCE_LONG = 64, the short side at leastMIN_SOURCE = 16(unblock.rs:102-118), and of those the fewest source pixels. The coarsest is the source itself whenever its pixels differ often enough to pin its grid; where they do not, a coarser lattice re-expands to the input just as exactly. An upscale 2× across and 4× down comes back as a 2× upscale of the source stretched 2× down, which is one scale. - Cell starts (
cell_starts,unblock.rs:425-455): the lattice point⌈φ + i·p⌉(L_i = (i·n + a)/mfor the arc starta), moved onto the change in its window where there is one. - Verification (
PixelGrid::reproduces,unblock.rs:204-226): every row equals its cell's first row and, in those rows, every pixel its cell's first pixel, bit for bit. Only a grid that passes is returned.
The reduction (PixelGrid::reduce, unblock.rs:155-195) keeps each cell's first pixel,
written with the area average's conventions (alpha clamped, a non-finite value read as 0,
the colour of a cell with no positive alpha written as 0), so for a whole factor it is the
old intake's downsample_to(img, w/k, h/k) bit for bit on any decoded input: the
premultiplied sum of k² ≤ 2¹⁰ equal terms is within 2⁻⁴³ relative of the exact product,
and its quotient rounds back to the f32 value (the proof is in the comment; the test
whole_factors_come_back_as_the_old_test_found_them checks it). The floors: the old 64 px
floor survives on the long side only, for the old reason ("a 2x undo of a small icon leaves
too few pixels for the boundary solve to work with": a 32 px sprite blown up 8× comes back
at 64 px, a finer lattice of the same image); the short side may drop to 16 px, so a
128 × 50 wordmark blown up 4× is undone 4×, not 2×. Fractional factors between 1 and 2
(125 % and 150 % screenshots) are not undone: their cells are one or two pixels long, and a
native 24 px lucide icon, whose edges sit on pixel boundaries, fitted such a lattice on the
r2-inputs stress set (unblock.rs:67-77). An image with a zero side, a side too small to
keep the floors at pitch 2, or a short buffer returns None first: a GIF with a zero-wide
logical screen reached the old test as 0 × 30000 in the intake fuzz of 2026-10-02 and divided
by zero (unblock.rs:253-262). Integer remainders are written as t − ⌊t/n⌋·n (rem,
floor_div, unblock.rs:334-348) because wazero's arm64 compiler once miscompiled
i32.rem_u in a hot loop and the Go binding runs this crate as WebAssembly.
Citation, as the module documentation gives it (unblock.rs:79-94): "Not from the
literature: an exact inverse decided by an integer residue test and a bit-for-bit
re-expansion, because the published resampling detectors are statistical"; "See also"
A. C. Popescu, H. Farid, "Exposing Digital Forgeries by Detecting Traces of Resampling",
IEEE Trans. Signal Processing 53(2):758–767, 2005, DOI 10.1109/TSP.2004.839932, and, for the
shape of the answer, M. Lothaire, "Algebraic Combinatorics on Words", Cambridge University
Press, 2002, chapter 2 "Sturmian Words", DOI 10.1017/CBO9781107326019 (the cell lengths along
one axis are a mechanical word of slope p); the sampling conventions are OpenCV's
InterpolationFlags and resize.cpp. The integer-only test is kept as an oracle in
crates/inkvec-cli/src/alpha/intake_tests.rs (old_pixel_grid).
Measured (r2-inputs stress set, 28 sources, Quality, against v0.2.5): on nn35 (a 128 px
render, nearest 3.5×) every input comes back as its source, dE00 1.186 → 0.375 (−68 %,
27 better, 1 worse) and parameters 13.4× → 1.41× the artist's; on nn4 the five wordmarks
are undone 4× instead of 2×, dE00 0.477 → 0.375 (−21 %). It fires on none of the 1,662 gate
and held_a rasters at 128, 256, 512 and 1024 px, nor on 872 non-nearest stress inputs, so
the gate's output is byte-identical.
intake runs this first, before anything else looks at the image
(crates/inkvec-cli/src/lib.rs:304-340), and the module documentation states the measured
cost of a naive tracer that skips it: a 96-px logo blown up to 768 traces its pixel
boundaries directly, the palette shattering from 3 inks to 13 and the boundary coming back
as 1568 straight lines walking round pixel corners (unblock.rs:3-9). Because each side of
the output was rounded on its own, the two pitches can differ slightly (448 × 150 from
128 × 43 is 3.5 across and 3.488 down); the SVG is then presented axis by axis:
Intake::stretch (lib.rs:236-238) makes present (lib.rs:243-252) add
preserveAspectRatio="none" through post::stretch_axes (crates/inkvec-cli/src/post.rs:94-149)
whenever the viewBox and the presented size differ in aspect, where the default
xMidYMid meet would letterbox the drawing by up to half a source pixel (SVG 1.1 section 7.8:
none, "Do not force uniform scaling"). --max-dim is not stretched; its box filter is free
to choose its size. --no-unblock disables the pass (crates/inkvec-cli/src/args.rs:398-405).
Detection is exact-match only: an upscale that was anti-aliased, resampled or JPEG-compressed
has no constant cells and fails by design; a resampled or blurred one is the soft intake's
(next), damage is the restorer's and --sr's.
Soft intake — reducing a resampled or blurred raster to its detail
soft_intake::reduce (crates/inkvec-cli/src/soft_intake.rs:217-271) runs next, in Quality
mode, unless --no-soft-intake (args.rs:406-413) or --intake-scale is given, the
raster was unblocked, or --sr is on, which wants the soft raster to put detail back into
(reduce_intake, crates/inkvec-cli/src/lib.rs:254-282). A raster upscaled from a smaller one,
or blurred, spreads every edge over several pixels, and the tracer meets the wide ramp three
ways: the ramp's middle colours become an ink of their own, the exact-coverage boundary
solve ripples points across a ramp no sharp edge can reproduce, and the fitter prices every
surplus pixel as evidence. On the r2-inputs stress set a 4× bicubic upscale traced at dE00
0.839 and 8.1× the artist's parameters, where its 128 px source traces at 0.375 and 1.4×
(soft_intake.rs:3-13). The decision (verdict, soft_intake.rs:171-215), in order:
- Measure the raster over white (
softness::ramp_evidence,crates/inkvec-trace/src/softness.rs:140-178). One pass over every row and column finds the strong transitions — maximal monotone runs of first differences above two 8-bit levels rising at least 0.2 — and records, for the plateau-to-plateau ones whose Sobel gradient points along the scan, the second moment of their difference profile (scan_profile,softness.rs:222-275). A native box-filtered edge sits on one partial pixel, variancec(1 − c) ≤ 1/4; resampling spreads every edge wider. Out come the share of edges wider than native (soft_fraction, againstSOFT_EDGE_VAR = 0.3), a box-equivalent width from the trimmed mean variance (√((12·mean + 1)/3), 1.0 native), and the 10th percentile of the distance between opposite transitions (feature_p10, the thinnest strokes and gaps) (summarise,softness.rs:305-346). This is the estimatorcoverage::intake_scalecould not be: a bilinear 2× makes linear ramps, whose second difference vanishes, andintake_scalereads it as exactly 1.00. - Gate: at least 16 measured edges, at least
SOFT_GATE = 0.9of them wider than native. Native renders read at most 0.18 over the corpus; every resampled or blurred raster measured reads 1.00 (softness.rs:87-95). - Sharp-edge veto (
SHARP_VETO = 0.5,soft_intake.rs:59-65): the same pass measures the core of every near-axis strong transition, plateaus or not — the differences at least a quarter of its largest (core_variance,softness.rs:277-302,CORE_SHAREat:83-86) — and a raster whose cores are mostly native (variance ≤ 1/4) is a sharp drawing with a soft element, not a soft raster. The gate alone reads only plateau-to-plateau transitions, and a glow's or a shadow's ramps are exactly those, while the artwork's own edges, running into the ramp on one side, never qualify: the first soft intake reduced 9 of the 28 native 512 px glow images 7-8× (group dE00 0.690 → 1.013). Measured: every glow and shadow image reads a sharp share of 0.80 or more, every bicubic 4×, bicubic 2× and Lanczos 3× upscale 0.20 or less. - Factor (
factor_for,soft_intake.rs:126-140): the upscale's own period whensoftness::grid_contrast(softness.rs:349-416) shows one among 3, 4, 5, 6 and 8 within 1.2 of what the width implies, elseround(1.4·width − 0.756)in[1, 8](factor_for_width,soft_intake.rs:119-124). An integer, because the reduction has to land back on the upscale's grid: 4× bicubic input reduced by 3 scored 0.358 against 0.256 at 4.grid_contrastis the spread of the per-phase means of|second difference|; "Inspired by" Popescu & Farid (resampling leaves periodic correlations), with the period known up to a few candidates. - About 2× (
MIN_UPSCALE = 3,soft_intake.rs:85-99): a measured upscale under 3 is kept as it arrived. Ondown2up(a 256 px render, bicubic 2× to 512) all 28 measure as 2×, and reducing them by 2 made the group worse, 0.248 → 0.295 with 14 of 28 worse — the wordmarks most (betterimpact 0.448 → 0.930), but icons with nothing thin in them too (lucide omega 0.029 → 0.124), so a feature guard alone does not separate them (one was measured: allowing 2× reductions when the thinnest features keep 6 px gave −1.2 % ondown2upwith 7 of 28 worse, 12mp +0.12 and omega +0.095). Tracing the 256 px source itself gains only 12 %, and a box reduction of a bicubic upscale is a blurred copy of it, not the source. - Guards: the thinnest features keep at least
MIN_FEATURE_PX = 3px and the short side 16 px, and within that the factor is the largest divisor of the measured upscale (capped_factor,soft_intake.rs:142-159), so each source pixel still becomes a whole number of traced pixels: a 3× capped to 2 by thin lettering traced worse on all three Lanczos 3× wordmarks it reached (jurlique 0.999 → 1.399), and holding them tookup3lanwfrom −6.8 % to −13.4 % with none worse. Shaded artwork (softness::shading_share,softness.rs:418-475, at least 0.008) is reduced only by 4 or more. - Reduce by the factor with the exact area average, each side
round(side/k), then back onto the 8-bit lattice,round(255·v + 10⁻⁴)/255: the average leaves flat regions a few thousandths of a level off flat, and a blurred lucide icon traced at dE00 0.250 unrounded, 0.139 rounded (soft_intake.rs:217-226). The SVG is written at the arrival size, axis by axis (stretch, as for the unblock).
Fast mode traces a soft raster as it arrived: reduced, its parameters fell 92 % on 4×
bicubic input but its colour error did not follow (−2.4 % with 12 of 28 worse; +6.6 % on
Lanczos 3×), because its palette has none of the soft-input noise guards
(lib.rs:254-265). A research port also refined an opaque ink from its opaque pixels on a
reduced raster (engine/soft-input's ink_means::opaque_from_opaque); measured on top of
this, it changed up4bic by +0.07 % (4 better, 4 worse) and was dropped.
Measured (r2-inputs stress set, 28 sources, Quality, against the unblock commit): up4bic
dE00 0.839 → 0.593 (−29 %, 23 better, 0 worse), parameters 8.07× → 2.49×; up3lanw
0.420 → 0.364 (−13 %, 10 better, 0 worse), 7.06× → 4.43×; down2up, glow, shadow and
clean512 byte-identical. On the gate's own rasters it never reduces (246/246 identical in
Quality at 128ss, 512ss and 512ssop). Literature (softness.rs:49-66,
soft_intake.rs:39-55): "Inspired by" P. Marziliano, F. Dufaux, S. Winkler, T. Ebrahimi,
"A no-reference perceptual blur metric", ICIP 2002, DOI 10.1109/ICIP.2002.1038902 (edge width
along scan lines; ours a second moment of the strong, near-axis edges, decided on a share,
not a mean); the core veto and the decision rules are "Not from the literature", each set
from the benchmark named at its constant; the degradation model is BSRGAN's (Zhang et al.,
ICCV 2021, arXiv 2103.14006), and kernel estimation (KernelGAN, NeurIPS 2019, arXiv
1909.06581) was judged heavier than vector art needs. The degraded groups are scored by
bench/degraded_eval.py (local, not a gate condition).
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 unblock call
(crates/inkvec-cli/src/lib.rs:304-312) 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 rest of intake — soft intake (or
--intake-scale in its place), --max-dim and the pricing of the knobs — also runs before
the pre-passes, "so that a probe trace either auto mode makes of an input it keeps is
bounded exactly like the real trace"; they used to run after the pre-passes' early returns,
and --restore auto / --sr auto kept a probe traced at full resolution (lib.rs:342-346).
Soft intake stands aside when --sr is on, for the same reason as the order above: the
upscaler wants the soft raster to put detail back into.
The SR pre-pass itself: Mode::Auto | On | Off
--sr (default Off, crates/inkvec-cli/src/args.rs:276) controls a network-based
upscale-then-halve cleanup, implemented in the inkvec-sr crate.
inkvec_sr::Mode (crates/inkvec-sr/src/lib.rs:35-45):
pub enum Mode { /// Trace, measure the fit, clean and retrace only if the fit is bad. #[default] Auto, /// Always clean first. On, /// Never clean. Identical to not having the mode. Off, }
Mode::On calls inkvec_sr::prepass (inkvec-sr/src/lib.rs:92-143) unconditionally: it
zeroes the colour channels of every fully transparent pixel (alpha <= 1e-4, so they cannot
drag their upscaled neighbours toward an arbitrary stored colour), upscales by the
upscaler's factor, box-averages back down (clean::box_downsample) to the requested
--sr-scale (default 2, args.rs:278), and — unless --sr-no-recolour — refits each colour
channel of the result onto a bicubic upsample of the zeroed source over its flat pixels
(clean::match_flats) rather than trusting the network's colour reconstruction. The
upscaler always runs out of process: the network is not compiled in
(crates/inkvec-sr/Cargo.toml:19-21). build_upscaler (crates/inkvec-cli/src/lib.rs:892-910)
runs --sr-command when one is given, at a fixed scale of 4, and otherwise the packaged
Python pre-pass tools/inkvec_sr through external::External::python, found by
sr_tools_dir (lib.rs:912-933): INKVEC_TOOLS_DIR when it is set (and then only that
folder), otherwise a tools/ folder beside the executable or in one of the three folders
above it, then the source checkout the binary was built from. The pass prints one stats line, "sr cleaned to WxH in Ns, lib.rs:667).
The restorer comes first. trace_prepared_priced runs the restorer pre-pass
(--restore; restore_prepass, lib.rs:984-1069) before SR. Its default is the mode's
(Args::restore_mode, 2026-10-11): auto in Quality mode when the build can restore (the
network compiled in, or --restore-command), off in Fast and balanced, where a restore's
seconds would undo the point of the mode, and off in a build with nothing to restore with,
which then traces exactly as --restore off does, stats included. A given --restore value
always wins. On a clean input auto asks for a sign of compression first and makes no probe
without one (below), so the default costs a clean PNG the ringing score alone, about 2.7 ms
at 512 px.
Its auto mode works exactly like SR's — one probe trace, kept when the input measures
clean, and "handed on so the two pre-passes never trace the same image twice"
(lib.rs:984-985); with SR off, a kept probe is returned as the trace (lib.rs:556-572), and
a restored image is traced with lossy intake forced on (lib.rs:574-586). The restorer
itself is outside this page.
Mode::Auto (crates/inkvec-cli/src/lib.rs:588-653) is the more interesting path: it takes
the restorer's probe when there is one, and otherwise traces the image once as it arrived
(trace_once, lib.rs:1107-1136), a stripped version of the ordinary pipeline — the same
fit_config, bilevel or colour, matte and border pad included (through trace_bordered, the plain path's own, since 2026-10-10, so a kept probe is byte for byte the plain trace), and
always in colour, because "a monochrome drawing disagrees with a colour input everywhere it
is not black or white, which would read as damage on every image"; under --monochrome the
probe decides and the trace is then made again as asked (lib.rs:551-555). It renders that
trace back to a raster with resvg (inkvec_sr::detect::render_svg,
inkvec-sr/src/detect.rs:58-95, 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:161-179) 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 }, } }
Keep returns the probe as the trace, with the stats line "sr residual R <= T, traced
directly" (or "could not measure the fit; traced directly"). Clean builds the upscaler,
once, and keeps it for the clean-up and the retrace (crates/inkvec-cli/src/lib.rs:591-616). Without an
upscaler, auto traces directly (impl2/bugs, merged 2026-10-03): "auto is a request to
clean if it helps, so no upscaler to be had -- no tools/inkvec_sr beside the binary, an
unusable --sr-command -- is not a reason to fail the trace: keep the probe, as a clean
input would, and say why in the stats. on asked for the clean-up outright and still fails
without one. This is --restore auto's rule" (lib.rs:617-626). The stats line reads "sr
residual R > T, but no upscaler is available (--monochrome the image is traced again uncleaned, as asked (lib.rs:630-634). The tests
sr_auto_without_an_upscaler_traces_directly and sr_on_without_an_upscaler_is_an_error
(crates/inkvec-cli/tests/pipeline.rs:407-447) check that the fallback SVG equals the plain
trace, in colour and in monochrome, and that on still errors. The fallback covers an
upscaler that cannot be built; a packaged tool that is found but fails while running still
fails the trace, under auto as under on (prepass(...)?, crates/inkvec-cli/src/lib.rs:665).
auto also needs a sign of damage of the input's own (crates/inkvec-cli/src/damage.rs,
2026-10-10). The residual reads smooth shading in clean, gradient-heavy art as damage: with
--restore auto on the gate's opaque 512 px tier (PNG renders), the restorer ran on 7 of 246
clean icons, all Noto emoji with shading, and the condition read dE00 +4.49 % and geom +13.9 %
(emoji_u1f5a8 geom 0.19 → 3.05). So a Restore decision stands only when the file or the
pixels say the input was compressed (damage::compressed): a lossy container (--lossy,
resolved from the file's first bytes), or JPEG ringing around the edges
(coverage::ringing_score above the gate the palette's noise guard uses, SOFT_RINGING_LARGE
from 256 px on, SOFT_RINGING below), which is how a JPEG re-saved as PNG is told. SR's Clean
also accepts edges wider than a native render's (damage::soft: intake_scale above
SOFT_INTAKE_EDGE), since the upscaler undoes resampling too. These are the three signals that
open the palette's soft intake, and the corpus's clean renders pass none of them. Without one,
auto keeps its probe and says so ("restore no sign of compression; traced directly"; SR: "no
sign of resampling or compression"). The evidence is asked first and the residual only after
it: the residual renders the probe at full size, the costly half. Asked the other way round,
auto cost a clean Fast trace at 512 px 16.5 ms of CPU on 31 (the render and the residual)
and the ringing 5.5 ms more where the residual fired; both are needed, so the order changes no
decision. Without evidence --restore auto makes no probe at all, and the input takes the
plain path, so its trace is the plain one by construction and a clean input pays the evidence
alone. In Fast mode the evidence is the container only: its trace of a 512 px icon costs about
31 ms of CPU and the ringing score 2.5 ms of it, which every clean PNG would pay; what Fast
gives up is a JPEG re-saved as PNG (on the web tier's own pixels the score passes the gate on
186 of 246 icons, on the clean tiers on none). On the web tier (JPEG files) every input has a
lossy container and auto decides exactly as before. Tests:
restore_auto_needs_a_sign_of_compression, sr_auto_needs_a_sign_of_resampling_or_compression
(crates/inkvec-cli/tests/pipeline.rs).
interior_residual (inkvec-sr/src/detect.rs:97-155) 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:103-110), 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, which "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, or when the two images differ in size, 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 (detect.rs:3-7).
DEGRADED_RESIDUAL = 0.5 (detect.rs:35-36, also the default for --sr-threshold,
crates/inkvec-cli/src/args.rs:277) 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 (detect.rs:27-29).
--intake-scale (opt-in): resampling an oversampled input
normalise_intake (crates/inkvec-cli/src/lib.rs:128-177) is a separate, opt-in mechanism
from the unblock and SR pre-passes above, and it takes the soft intake's place when it is
asked for: 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 --intake-scale is
set, 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) (reduce_intake,
lib.rs:254-282):
fn normalise_intake(img: inkvec_trace::Rgba, quiet: bool) -> (inkvec_trace::Rgba, bool) { let rgb = img.composited([1.0, 1.0, 1.0]); 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) }
Each side is divided by s and rounded, at least 8 px, and an image that would not shrink
on both sides is returned untouched (lib.rs:148-152). INTAKE_SCALE_FLOOR = 1.5
(lib.rs:121-123) — 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:124-126)
bounds how much a single, possibly wrong, estimate is allowed to throw away at once.
--intake-scale defaults off (args.rs:287); the usage text states the trade explicitly
(args.rs:556-569): 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:359) is a hard ceiling on the size that is traced,
applied in two places. The command line hands it to the decoder (run,
crates/inkvec-cli/src/lib.rs:856), which box-averages the 8-bit buffer down before the
floats exist (pass 5 above, with the larger allocation allowance of pass 3); and intake
applies the same ceiling again after unblock and soft intake (lib.rs:350-368), which is
where it acts for a caller that hands over a raster rather than a file (trace_image, the
bindings): each side divided and rounded, at least 8 px, by "the same exact area average the
intake normaliser uses, so edges stay edges". Either way the SVG is written at the original
size (trace_image_sized's display_size, lib.rs:205-211). The stated reason is trace
time, which grows with pixel count; 2048 is chosen because it "keeps a typical logo under a
few seconds" (args.rs:359-363), a qualitative target rather than a swept figure.
--time-budget (default 0.0, meaning no budget, args.rs:255) is advisory rather than a
hard deadline. It is spent in color_options (crates/inkvec-cli/src/pipeline.rs:145-153),
which the colour pipeline calls as it starts (pipeline.rs:229), so the clock runs from
there and each probe trace gets a clock of its own: 60% of the budget becomes
ColorOptions::deadline, after which gradient-band merging stops (the one stage whose cost
grows with the square of the region count — "an unmerged band is a correct fill, just a
separate one", crates/inkvec-trace/src/lib.rs:166-169); 25%, and at least 50 ms, becomes
boundary_ms, the boundary solve's wall-clock budget (crates/inkvec-trace/src/lib.rs:178-181).
The help text states the contract (crates/inkvec-cli/src/args.rs:364-371): "Gradient-band
merging stops at 60% of it, checked between fits, and the boundary solve gets 25%; the
output is still a correct trace, with more fills or a less polished outline. The palette and
the writer do not read it. A nonzero budget makes the output depend on the machine; 0 means
no budget and a reproducible output. Either way the merge is also bounded by a work cap set
by the pixel count". Without a budget no clock is read: the merge stops at a deterministic
work cap, max(2²⁸, 32 per pixel) units, which is on with a budget too (MERGE_WORK_FLOOR, MERGE_WORK_PER_PIXEL,
crates/inkvec-trace/src/gradient/bands.rs:40-71; 05-gradients.md), and the
boundary solve stops on its iteration count, and is skipped outright when its band tables
would pass max(256 MiB, 32 bytes per pixel) (TABLE_BUDGET_FLOOR, TABLE_BUDGET_PER_PIXEL,
crates/inkvec-trace/src/boundary_opt/band.rs:851-856;
08-boundary-solve.md). Both caps were set from what images spend,
"with room, so that it never binds on the gate's" (bands.rs:42). Fast mode lists
--time-budget among the options it ignores (crates/inkvec-cli/src/fast.rs:162). The two
fractions (0.6, 0.25) and the 50 ms floor 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 coordinate price --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), FitConfig::from_precision, crates/inkvec-fit/src/lib.rs:147-168)
has grown by only ln 4 ≈ 1.39 nats, about a fifth of its value — 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/units.rs:17-26): 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."
REF_EXTENT = 128.0 (units.rs:17-31) records the size the corpus was tuned at. The scale
s — pixels per unit of content — is measured by content_scale (units.rs:33-78) rather
than inferred from the extent:
pub(crate) fn content_scale(img: &inkvec_trace::Rgba, args: &Args) -> f64 { if !args.content_units { return 1.0; } let rgb = img.composited([1.0, 1.0, 1.0]); ... let edge = inkvec_trace::coverage::intake_scale(&rgb, img.width, img.height); let redundancy = inkvec_trace::coverage::oversample_factor(&rgb, img.width, img.height) as f64; edge.max(redundancy).max(1.0) }
Two measurements, asking different questions: intake_scale (edge width) catches blur,
resampling and upscaling and reads 1.00 on anything with honest one-pixel boundaries;
oversample_factor (the downsampling round trip, 02-coverage.md) sees a
simple drawing rendered larger than it needs — "a natively rendered icon reads 1, 1, 2 and 4
at 128, 256, 512 and 1024 while its edges stay exactly 1.00 px wide throughout"
(units.rs:59-74). Content that is genuinely resolved reads 1.0 and is left as it was found;
s is never below 1.0. It used to return extent / REF_EXTENT, which "asks a different
question -- how big is this?": a natively rendered icon reads an edge width of 1.00 at 128,
256, 512 and 1024 alike, where extent / REF_EXTENT claims 1x, 2x, 4x and 8x, so the old
scale loosened every tolerance eightfold on a 1024 px render that had lost no detail at all
(units.rs:41-50). The diagnostics module records the same disease: "content_scale
returned extent / 128 -- 12.5 where the measured answer was 4"
(crates/inkvec-trace/src/diag.rs:16).
Two things then use s, and both reach the emitted trace:
in_content_units (crates/inkvec-cli/src/units.rs:107-118) 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
(crates/inkvec-cli/src/pipeline.rs:71-74) and the colour path's fit_boundaries
(pipeline.rs:671-674), whenever --content-units is on, regardless of which other path
produced the image. The fitter weighs each point's squared deviation by 1/sigma² (its
chi-squared term, crates/inkvec-fit/src/lib.rs:82-90; the tau * sigma admissibility cone
the module header once described is no longer on the shipping path, lib.rs:92-98), so
inflating sigma by s divides each point's term by s²: the same relative deviation —
now s times larger in raw pixels, because the boundary itself is s times bigger — costs
per point what it would have cost at one pixel per unit of detail. The bilevel path deliberately leaves --min-area in pixels:
"noise does not scale with the raster", and scaled by s² it swallowed the 1-2 px gaps
between a scribble's strokes (abra_agency tile error 602 → 371 with the floor back in pixels;
60 real brands at 512 px, dE00 worst 1.33 → 0.96, 2026-09-05; pipeline.rs:61-67).
Fast mode's fitter reads neither these polylines nor their sigmas: it fits the map's edges
directly, in pixels, with its own tolerances (see 14-fast-mode.md). So in Fast mode
fit_boundaries (pipeline.rs:659-787) builds the content-unit polylines, the per-edge λ
multipliers and the content_scale they need only when something reads them:
--editability, whose passes read the polylines, or the research build's structural
baseline (INKVEC_STRUCTURAL). The condition is
need_polys = !fast || args.editability || structural (pipeline.rs:668-669); otherwise
Fits::polys and Fits::lambda_scales are empty, and the fitted paths, which depend on
neither, are unchanged (0.47 ms of the fit stage at 2048 px, pipeline.rs:651-658). Quality
builds them, and then fits each boundary on every core through
inkvec_fit::choice::describe (pipeline.rs:716-744): the dynamic program on its
content-unit polyline, beside the whole-boundary primitive search, the cheaper of the two
kept; for the image frame the rectangle is tried first and the program skipped when the
rectangle is provably cheaper (11-fitting.md, Curve or primitive). Fast mode does still
call content_scale once, through fit_config, which every trace builds its configuration
with (below): it returns 1.0 at once unless --content-units is set, and under that flag,
which Fast lists among the options it ignores (crates/inkvec-cli/src/fast.rs:159), it still
runs the scale's two raster passes there.
fit_config (crates/inkvec-cli/src/units.rs:80-94) goes further: it also multiplies
--precision by s before deriving lambda, and then multiplies the resulting lambda by
s again:
pub(crate) 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 }
So lambda = s · ln(extent / (s · precision)). The doc comment gives both factors
(units.rs:82-91): asking for precision * s makes a coordinate cost "the nats of a
coordinate at the content's own resolution", and "the further factor of s on lambda is the
point count: 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. Together with sigma scaled by s at the fit ([in_content_units]),
the optimum is the one a raster at one pixel per unit of detail would reach -- from better
points." With --content-units off, s = 1 and this is FitConfig::from_precision(extent,
precision, tau) unchanged.
Both halves reach the emitted SVG. trace_prepared_priced builds the configuration it
hands to run_strokes, run_bilevel and run_color through fit_config
(crates/inkvec-cli/src/lib.rs:806-814), and so does the probe trace (trace_once,
lib.rs:1112). The comment at the call says why:
Through
fit_config, notFitConfig::from_precisiondirectly, so that--content-unitsapplies the whole of its mechanism here and not half of it.It used to build its own configuration and skip the scaling, which left the flag scaling sigma (via
in_content_units, below) while lambda stayed tied to raw pixel extent -- exactly the half thatfit_config's doc comment says must not be applied alone.content_scalereturns 1.0 unless the flag is set, so this is the identity on the default path:FitConfig::from_precisionwith nothing scaled.
No test checks that identity, or that the flag now scales lambda; see Open questions.
content_units defaults to false (crates/inkvec-cli/src/args.rs:253); the
INKVEC_CONTENT_SCALE environment equivalent was removed in 0.1.7 (CHANGELOG, Removed).
The help text keeps it off as a trade, "it trades fidelity for parsimony" (args.rs:438-441).
It used to name two failures as the price (5-px squares fitted as circles, thin rings broken);
content_scale's own doc comment traces those to the old extent / REF_EXTENT scale — "that
over-correction, described but never traced to its cause" (units.rs:41-50) — so the help no
longer names them, and no measurement of the measured scale against them is recorded. See Open
questions.
The oversample block: pricing --precision, --min-area and lambda in the raster's own units
A separate mechanism, always on in Quality and independent of --content-units, addresses a
related but distinct disease: a raster that is native (not resized, not compressed) but
carries the drawing on more pixels than it 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). price_in_raster_units
(crates/inkvec-cli/src/lib.rs:396-508) runs at the end of intake (lib.rs:370), after
unblock, --intake-scale and --max-dim. The comment gives the measured cost of leaving
this alone (lib.rs:397-406): on a real brand 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 brought it back to 21 paths and 441 coordinates.
Two measurements of the image composited over white decide it — none in Fast mode, which
reads none of the three knobs and for which "the two measurements are three full-image round
trips, the largest cost in a fast trace at 2048 px" (lib.rs:415-417):
let edge = inkvec_trace::coverage::intake_scale(&rgb, w, h); ... let round_trip = inkvec_trace::coverage::oversample_factor(&rgb, w, h) as f64; let up = if edge > inkvec_trace::color::SOFT_INTAKE_EDGE { round_trip } else { 1.0 }; (up, round_trip)
(lib.rs:423-443). With r the round-trip factor and R = REF_EXTENT, the doc comment
(lib.rs:385-392) states the rule:
- precision
× rwhen the edge width exceeds the soft-intake threshold, else unchanged; - min-area
× r²when the longest side exceedsR, else unchanged; - lambda
× rwhen the longest side exceedsRandr > 1, else unchanged — throughargs.lambda_scale, whichfit_boundariesmultiplies into every boundary's lambda (crates/inkvec-cli/src/pipeline.rs:684-688) and the ring repair prices with (pipeline.rs:895).
The round trip is not 1 on every native render: it reads 1 for 212 of the 246 screen icons
at 128 px, and "at 512 px nearly every native render reads 2 to 8, which is the speckle floor
scaling that caller wants" (crates/inkvec-trace/src/coverage/oversample.rs:113-117), and
price_in_raster_units' comments say the same: "at 512 px its r is mostly 2 to 8"; "A
native intake at or below REF_EXTENT is left as it is (see below), so the 128 px benchmark
is untouched" (crates/inkvec-cli/src/lib.rs:394, 412-413). What keeps the 128 px tier
untouched is the REF_EXTENT guard below, with the edge-width gate for precision.
Precision stays behind the edge-width gate. intake_scale decides whether this is
native content — every corpus raster reads at most 1.50 against the SOFT_INTAKE_EDGE = 1.75
threshold (crates/inkvec-trace/src/color.rs:479-488, documented fully in 03-palette.md) — a
job the round trip cannot safely do on its own, because "smooth native artwork survives
halving too, and would be rewritten for no reason". The round trip then measures by how
much, which intake_scale cannot: a super-resolution model's sharp output "reads 2.00 where
the answer is 4" (crates/inkvec-cli/src/lib.rs:423-429). Precision needs no more than that:
on ten corpus icons at 128, 256, 512 and 1024 the segment count grows only 1.33× across the
range, a search for the precision that reproduces the 128 px drawing picks the shipped 0.1 at
every tier, and prim_ellipse comes out as nine segments at all four with no correction
(lib.rs:448-454).
The speckle floor follows the round trip alone, because it is an area: an anti-aliasing
sliver is eight times longer and eight times wider at eight times the resolution, so it
carries sixty-four times the area, sails over the floor and becomes a face. Measured on
mosaic_grid6, same palette either way: 44 faces at 128 against 772 at 1024, where 131
segments become 2367; scaling the floor by the round trip squared brings that back to 94
faces and 293 segments (lib.rs:456-462). It is relative on purpose: a flat 9-px colour-mode
floor tried on 2026-09-03 measured worse on the full 980-icon set (objective 0.7885 against
0.7673, 508 icons worse), because it erased real dots as readily as confetti
(lib.rs:464-469). And only above REF_EXTENT: min_area was fitted against 128 px rasters
as they are, so scaling by their redundancy again counts it twice — measured, 128ss objective
0.4005 → 0.4112 without the guard (lib.rs:470-476).
The round trip must keep the drawing in relative terms too. Since impl2/bugs
(merged 2026-10-03) a factor passes only when its mean round-trip error is under
OVERSAMPLE_TOL = 3 levels and at most OVERSAMPLE_KEEP = 0.5 of flat_error, the error
of erasing everything but the per-channel median (crates/inkvec-trace/src/coverage/oversample.rs:26-53).
A near-empty 144 px raster with one 38 px² disc passed the absolute test at every factor,
"because the empty canvas dilutes the mean, read as 8x, and its disc fell under the 64-fold
speckle floor" (crates/inkvec-cli/src/lib.rs:431-435); it now reads 2× and the disc is
drawn. oversample_factor, its two tests and their margins are documented in
02-coverage.md.
The lambda factor carries no measurement of its own in these comments; see Open questions.
When anything scales, the stats show it: "intake oversampled xN: scaling precision xN,
min-area xM, lambda xL", or "intake Nx more pixels than detail: scaling min-area xM and
lambda xL" when only the round trip moved (lib.rs:496). This happens independently of,
and before, the --content-units machinery above; both can be active on the same trace.
Art that touches the canvas edge: the border pad
A transparent raster whose artwork reaches its outermost ring of pixels (a tightly cropped
logo, a sticker filling its canvas) used to trace worse than the same artwork with a little
room round it. Where the art meets the canvas edge the planar map has frame edges and
junction nodes pinned to the frame, and the boundaries that end there are fitted as open
curves between two frame junctions instead of the closed outline the artist drew: a rounded
square filling its canvas (twemoji/1f7eb, 512 px) came back with one corner replaced by an
arc spanning half a side, dE00 0.117, and comes back as one rounded rectangle with the pad,
dE00 0.0002. The r2-eval research (2026-10-02, padtest.py) found it with a metamorphic
test (a one-pixel translation of the input lowered the screen set's score by 5.2 %) and
measured the oracle: embedding the raster in a 2 px transparent margin, tracing, and
cropping the render back lowered the screen set's dE00 by 2.0 % at 512 px and 5.3 % at
128 px, by 9.7 % and 22.1 % on its 72 border-touching icons, while the rest moved by +0.1 %
and +1.7 % only because the padded raster is larger and the size-dependent defaults read the
larger size.
So trace_prepared_priced, after every resampling step (the unblock and soft intake
included: the pad goes round the raster they hand on) and before the matte
(crates/inkvec-cli/src/lib.rs:774-784, through trace_bordered, lib.rs:721-750), traces
such a raster on a padded canvas and moves the document back
(crates/inkvec-cli/src/border.rs):
- Which rasters (
touches_border,border.rs:57-82): some pixel is not fully opaque (transparency is the raster's ground, so a transparent pad continues it) and some pixel of the outermost ring has alpha above zero. An opaque raster is never padded: its ground is whatever colour fills the border, and a transparent pad would send it down the alpha path. Of the 246 screen icons 81 qualify at 128 px and 78 at 512 px; the rest trace exactly as before (byte-identical, checked on all 168 non-touching icons at 512 px). - When (
border_pad_applies,lib.rs:763-774): the Quality colour pipeline only; Fast mode, the bilevel, stroke and monochrome writers,--use-symbols(whose symbols are in their own frame) and--uncertainty(whose bands go to a separate file) trace as they always did. - The pad (
pad,border.rs:84-101): the raster embedded at(PAD, PAD)in a canvasPAD= 2 px larger on every side, the new pixels[0, 0, 0, 0]. - The defaults see the original size. The knob pricing (
price_in_raster_units) has already run on the original raster inintake, and the fit configuration is built for the original longest side (fit_config_sized,crates/inkvec-cli/src/units.rs:96-105, throughtrace_matted'sextent,lib.rs:776-834), so the price of a coordinate,ln(extent / precision), and the 128 px reference-extent test do not move. The trace crate's own size-dependent priors (the gradient fits'bic_lambda(w·h),lnof the pixel count) still read the padded size: at 128 px that isln 17424againstln 16384, 0.6 % on a log price. - The crop (
crop,border.rs:103-198): every coordinate of the traced document moved by(−PAD, −PAD)and the root'sviewBox,widthandheightwritten for the original size, so the document keeps the emitter's header and pixel coordinates thatcrate::post(the margin, the background knock-out) and the Studio overlays read. A translation is exact, and each number keeps the decimals it was written with, so the result is the exact decimal. What is translated is what the colour emitter writes: the absolute coordinates of path data (shift_path_data,border.rs:331-434; a relative command moves nothing except a path's openingm),<rect>,<circle>,<ellipse>and itsrotate(a cx cy)centre,<line>, anduserSpaceOnUsegradients, whosegradientTransform, when there is one, gets the translation in front of it (prepend_translate,border.rs:293-314). Anything else that carries coordinates (<use>,<symbol>,<image>,<text>,<polygon>, a shape inside<defs>, any othertransform) makescroprefuse, and the raster is then traced again unpadded (lib.rs:742-745).
"Not from the literature: a boundary condition for our own planar map (zero padding, the
usual way image filtering gives a support that reaches past the image a known exterior,
applied here to the tracer as a whole)" (border.rs:43-50); the metamorphic relation that
found the defect is after T. Y. Chen, S. C. Cheung, S. M. Yiu (1998), Metamorphic testing:
a new approach for generating next test cases, HKUST-CS98-01,
https://arxiv.org/abs/2002.12543.
Measured on the gate (2026-10-04, the 246-icon screen set judged at 1024 px, paired against
the same build without the pad, the converged boundary solve of
08-boundary-solve.md underneath): dE00 −6.21 % at 128 px and
−2.16 % at 512 px, parameters −2.29 % and −1.72 %, both "better"; on the touching icons
alone −20.0 % (81 icons) and −10.8 % (78 icons), and every other icon byte-identical. The
whole trace at 512 px took 1.17–1.19× v0.2.5 with the pad, against 1.13× without it (30
icons, two interleaved passes, under load; the finished branch 1.22–1.24× under heavier
load). One fitter interaction was seen on the way: with
a 128-iteration solve,
simple-icons/debridlink traced on the padded canvas came back with a closed ring whose
first segment skips the corner its seam sits on (a sliver 5 px wide, dE00 0.033 → 0.216);
with the shipped 64-iteration solve it does not occur, and the ring fit is stage 11's.
--hypotheses (opt-in): structural alternatives chosen by description length
Three structural decisions of the colour tracer are right for most images and wrong, by a
lot, on a minority: blend absorption (a grey pixel between two black shapes read as their
anti-aliased blend closes a real sub-pixel gap; simple-icons/ubiquiti), native alpha
against a matte (some translucent overlays trace closer matted), and the merge distance
(0.035 in OKLab merges inks an artist kept apart). The r2-fidelity research (section 3.6)
measured each wrong on 14–34 % of the screen set, each worse when used always, and a
selector that keeps the variant whose render best explains the input raster within 0.2 % of
choosing with the artist's file in hand.
With --hypotheses (Options::hypotheses), trace_prepared_priced traces the image as the
settings ask and then once per applicable alternative (select::hypotheses,
crates/inkvec-cli/src/select.rs:95-129): blend absorption off (Args::absorb_blends, handed to
the trace crate as ColorOptions::absorb_blends), a matte instead of native alpha when the
raster has transparency, and merge 0.020; each through the border pad like the default
trace (trace_bordered, crates/inkvec-cli/src/lib.rs:721-750; the dispatch is
lib.rs:691-701). select::choose (select.rs:131-193) keeps the trace
with the shortest two-part description length,
DL_i = RSS_i / (2·σ̂²) + 0.75 · k_i · ½·ln N, σ̂² = min_i RSS_i / N, N = w·h / e²
with RSS_i the squared sRGB error between trace i rendered at the input's resolution
(an 8x-to-1x supersampled render, at most 1024 px, box-averaged; render_residual) and the
input, both over white; k_i the geometry numbers of the document, counted as the benchmark
counts them (count_params); and e the measured edge width. Method from J. Rissanen
(1978), Modeling by shortest data description, Automatica 14(5):465–471,
https://doi.org/10.1016/0005-1098(78)90005-5, and S. C. Zhu, A. Yuille (1996), Region
competition, IEEE TPAMI 18(9):884–900, https://doi.org/10.1109/34.537343; the 0.75 is the
in-house measurement's (2026-09-16) price per number. Quality colour mode only, and not
with --uncertainty; the default path is unchanged (byte-identical on 165 sampled traces,
Quality at 128 px and 512 px opaque, and Fast).
Measured against the same build without it (2026-10-04, judged at 1024 px against the
artist's file): −6.0 % dE00 at 128 px on the screen set and −7.6 % on held_a, −0.9 % at
512 px with the worst tenth −8.9 %; 14 icons better and 15 worse by more than 0.01 at
512 px, the losses mostly lucide outlines the matte hypothesis paints as a filled shape with
a hole, which the renderer anti-aliases differently from the artist's stroke. Parameters
+6.9 % at 128 px (three icons, where blend absorption off keeps anti-aliased slivers as
faces) and −0.2 % at 512 px. 3.6 times the trace time at 512 px, which is why it is off by
default.
Alpha handling and matting
Everything from coverage::bilevel_coverage (stage 02) onward reads an opaque image: the
coverage projection needs definite opaque colours to unmix a pixel against. So intake's last
step, after every resampling decision above and after the pre-passes, is to write the image
down opaque (alpha_source_owned, called at crates/inkvec-cli/src/lib.rs:789-804; see
"Applying the matte" below).
What "opaque" means depends on native alpha, on by default (--no-native-alpha or
INKVEC_NATIVE_ALPHA=0 turn it off; crates/inkvec-cli/src/args.rs:115-119, 262-263).
Natively, nothing is chosen: the image is written over white, the cutout is on, and the
alphas travel beside it into the colour trace, whose palette finds inks with an opacity of
their own (crates/inkvec-cli/src/alpha.rs:1-10, 649-659). Under --no-native-alpha the
tracer proper is opaque and the palette has no fourth, alpha, dimension to cluster on, so the
matte matters, and choose_matte (alpha.rs:344-542) picks it 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 SWALLOWED = 0.33 or more
of the drawn-and-translucent mass into itself, measured by CIEDE2000 within MARGIN = 10.0
(alpha.rs:417-422, 375-392, 401-419); if none passes, the cheapest. Two cheaper rules were
tried and measured worse on the 246-icon screen set first — "the furthest candidate from
every colour 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:358-362).
SWALLOWED is module-level because alpha_source used to restate it as a literal 0.33
until 2026-09-08, "two numbers that had to agree, with nothing keeping them in agreement"
(alpha.rs:325-329).
This choice only reaches the traced faces under --cutout (default off): without it, the
image is 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:564-570). Except where white
erases the artwork: when more than LOST_TO_WHITE = 0.5 of the drawn silhouette, composited
over white, is paint the palette cannot tell from white (at least 0.9 in every channel and
within SAME_INK_DE00 of it), the cutout is turned on for that image and the chosen matte
applies — the LogoLabs flask in white scored alpha error 0.89 either way and 0.0008 with the
cutout (alpha.rs:572-577). That bar is deliberately not SWALLOWED's dE00-10 margin: at
that margin a near-white edge counts as lost although the tracer separates it from white
easily, and turning the cutout on for those images made the printer and bride emoji worse on
every ground (noto-emoji/emoji_u1f5a8 dE00 0.53 → 0.76); at the same-ink margin white marks
on a transparent ground read 1.00 and no icon of the screen set reads above 0.32
(alpha.rs:332-342). INKVEC_MATTE, which used to force the matte from the environment, was
removed in 0.1.7 (CHANGELOG, Removed).
Applying the matte: once, in place, in parallel. The intake calls alpha_source_owned
(alpha.rs:599-625) rather than the copying alpha_source (alpha.rs:561-597), which is
now compiled for the tests only, as the oracle of the in-place version; the probe trace of
--sr auto / --restore auto goes through the plain path since 2026-10-10.
The steps:
- Any transparency at all?
has_transparency(crates/inkvec-cli/src/alpha.rs:832-853) reads the fourth float of every whole pixel and stops at the first under 0.999 — the same set of floats the old strided scan visited. On an opaque image that is a read of every alpha, 3.3 ms serial at 2048 px; it is now split over rayon's workers from 256 × 256 pixels on (INTAKE_PARALLEL_MIN,alpha.rs:826-828), in jobs of at least 16,384 pixels (FLATTEN_CHUNK,:829-830).anyis a pure predicate, so the answer cannot depend on the split. An image with none goes on untouched (Err(img)hands it back). - The matte is decided on the untouched image (
MattePlan::of,alpha.rs:640-669): white with the cutout on for the native-alpha path; otherwisechoose_matteas above, with the cutout turned on when more thanLOST_TO_WHITEof the silhouette would vanish into white, and the chosen matte applied only under the cutout. - Every pixel is flattened over the input's own buffer (
flatten_in_place,alpha.rs:793-824) byflatten_pixel(alpha.rs:779-791): witha' = clamp(a, 0, 1),p ← [r·a' + M_r·(1 − a'), g·a' + M_g·(1 − a'), b·a' + M_b·(1 − a'), 1], the old push loop's arithmetic operand for operand. The copyflatten_overmade was a fresh 64 MB buffer at 2048 px, whose page faults and release the shared-stage research measured at 5.7 + 1.7 ms (alpha.rs:603-606), and it was a serial push loop, 26.7 ms of a 2048 px transparent trace (alpha.rs:732-735). The flatten is now parallel above the same threshold, and each output pixel depends on one input pixel, so the split cannot change a bit.
The notes printed to stderr (the matte or "native", the share of clear pixels, why the
cutout was turned on) are the old ones in the old order (MattePlan::finish,
alpha.rs:671-701). Oracles for all three rewrites (the strided scan, the copying flatten,
the integer-only pixel_grid the lattice inverse replaced) are kept in crates/inkvec-cli/src/alpha/intake_tests.rs.
Citations, as the doc comments give them: the flatten is "Method from" Porter & Duff,
"Compositing Digital Images", SIGGRAPH '84 (the "over" operator with an opaque background,
adapted to straight colour, crates/inkvec-cli/src/alpha.rs:737-739); writing it in place is
"Not from the literature: buffer reuse", with "See also" Leijen, Zorn & de Moura, "Mimalloc:
Free List Sharding in Action", APLAS 2019, the allocator-side answer to the same page-fault
cost (alpha.rs:610-613).
Every stage of both modes then reads the image composited over white, and that composite,
Rgba::composited (crates/inkvec-trace/src/coverage.rs:198-232), is parallel too from
256 × 256 pixels on (COMPOSITE_PARALLEL_MIN, coverage.rs:235-236): 11–18 ms serial at
2048 px before, the same "over" expression per pixel after, checked bit for bit against the
serial map (the_parallel_composite_is_the_serial_one, coverage.rs:260). Its citation is
Porter & Duff's "over", adapted to straight colour, and "Not from the literature: the
parallel split" (coverage.rs:210-214).
crates/inkvec-trace/src/alpha.rs is a related but distinct mechanism, run after the
trace rather than during intake: decompose_with (crates/inkvec-trace/src/alpha.rs:380,
beside decompose at :370) 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 (crates/inkvec-trace/src/alpha.rs:1-134) 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 — crates/inkvec-trace/src/alpha.rs:68-89),
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; INKVEC_LAYERS is removed), and the CLI's help records it firing on "about one real
icon in twenty" of the census used to tune it — two of forty
(crates/inkvec-cli/src/args.rs:384-392, crates/inkvec-cli/src/alpha/layers.rs:49-54). The CLI
side, recover_layers (layers.rs:100, split out of the CLI's alpha.rs on 2026-10-02),
holds the decomposition to three rules, each closing a way the layered document used to draw
something other than the image (layers.rs:62-99):
- sRGB only. The document composites
fill-opacityin sRGB (SVG 1.1 §11.7.1,color-interpolation), so onlyinkvec_trace::alpha::Space::Srgbis tried. It used to try linear light too and keep whichever found more layers: onnoto-emoji/emoji_u1f469_1f3fb_200d_1f52ca black layer at 0.924 recovered in linear light repainted the dark grey hair near black, dE00 0.609 -> 2.92. - Flat faces only. A gradient face has no single ground colour to repaint, so it is given no area and takes no part.
- Reproduction. The layered document is written only when the layer stack composited
over every covered face's recovered (gamut-clamped) ground comes back within
LAYER_MAX_DE00 = 1.0of the face's own colour (layers_reproduce,layers.rs:203).
The emitter then paints the recovered layers back to front: the decomposition peels the
frontmost layer first, and painting in peel order put the front layer underneath
(synthetic/stack_overlap, dE00 0.019 -> 2.90 with --layers; write_layers,
crates/inkvec-cli/src/emit.rs:1318-1335; see 13-emit.md). Citations, as the code gives
them: "Method from" Porter & Duff 1984 (doi:10.1145/800031.808606) and SVG 1.1 §11.7.1; the
three rules "Not from the literature"; "See also" Richardt et al., EGSR 2014
(doi:10.1111/cgf.12408).
Constants and thresholds
| name | value | controls | stated derivation |
|---|---|---|---|
REF_EXTENT |
128.0 px |
the intake size every pixel-denominated tracer constant was tuned at; above it price_in_raster_units scales --min-area and lambda |
measured: 3.54×/6.02×/11.62× the artist's parameter count at 128/256/512 px for unchanged content (crates/inkvec-cli/src/units.rs:17-31); the guard measured: without it 128ss objective 0.4005 → 0.4112 (crates/inkvec-cli/src/lib.rs:470-477) |
INTAKE_SCALE_FLOOR |
1.5 |
below this, --intake-scale leaves the input alone |
"Every image in the corpus reads exactly 1.00" (lib.rs:121-123) |
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:124-126) |
--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:254, 358-362); no sweep cited |
--time-budget split |
0.6 merge / 0.25 boundary-solve |
how an advisory wall-clock budget is allotted between the two stages that read a clock | stated as a fixed split, no numeric derivation given (args.rs:364-371, crates/inkvec-cli/src/pipeline.rs:145-153) |
| boundary-solve budget floor | 50 ms |
least wall-clock budget the boundary solve gets under any --time-budget |
none (pipeline.rs:152) |
MIN_SOURCE (pixel_grid) |
16 px |
least source size the unblock keeps on the short axis | measured motive: the old 64 px floor on both sides undid five 4× wordmarks only 2× (dE00 1.971 against 1.400, params 928 against 327) (crates/inkvec-cli/src/alpha/unblock.rs:102-110); the value itself not swept |
MIN_SOURCE_LONG (pixel_grid) |
64 px |
least source size on the long axis; a smaller source comes back as the coarsest finer lattice that keeps it | the old floor, for the old reason: "a 2x undo of a small icon leaves too few pixels for the boundary solve to work with" (unblock.rs:112-118); not swept |
MIN_CHANGES (pixel_grid) |
48 changes, both axes |
least evidence for a lattice to be believed | derived: an accidental fit has probability below 3·n·(p+1)²·0.6^r, about 2·10⁻⁶ at n = 2048, p = 2.5, r = 48 (unblock.rs:120-136) |
| unblock pitch floor | 2 (m ≤ n/2) |
least factor the unblock undoes | measured: at pitches 1.2-2 a native 24 px lucide icon fitted a lattice (reduced to 19 × 20) on the stress set (unblock.rs:69-72) |
SOFT_GATE (softness::SOFT_FRACTION_GATE) |
0.9 |
least share of measured edges wider than native for soft intake | measured: native renders at most 0.18 over the corpus, resampled or blurred rasters 1.00 (crates/inkvec-trace/src/softness.rs:89-95) |
SOFT_EDGE_VAR / NATIVE_EDGE_VAR |
0.3 / 0.25 |
an edge's spread above which it is soft / a core at or below which it is native | derived: one partial pixel spreads a step with variance c(1 − c) ≤ 1/4 (softness.rs:78-82) |
CORE_SHARE |
0.25 |
the share of its largest difference a difference needs to be in an edge's core | derived: keeps a bilinear 2× ramp [1/4, 1/2, 1/4] whole and drops a glow's shallow tail (softness.rs:83-86) |
SHARP_VETO |
0.5 |
share of native-sharp cores above which a raster is a sharp drawing with a soft glow or shadow, never reduced | measured: glow and shadow at least 0.80, every upscale at most 0.20 (crates/inkvec-cli/src/soft_intake.rs:59-65) |
MIN_UPSCALE |
3 |
least measured upscale soft intake reduces | measured: 2× reductions made down2up worse, 0.248 → 0.295 with 14 of 28 worse (soft_intake.rs:85-99) |
MIN_FEATURE_PX |
3.0 px |
least width the thinnest strokes and gaps keep after a reduction | measured on the engine/soft-input benchmark's reductions ranked by it (soft_intake.rs:101-108) |
FACTOR_SLOPE, FACTOR_OFFSET |
1.4, 0.756 |
the factor an edge width implies, round(1.4·w − 0.756) |
fitted through bilinear 2× (2.1), bicubic 3× (2.5), 4× (3.3), 8× (6.1), shifted down 0.2 (soft_intake.rs:70-75) |
GRID_CONTRAST, GRID_REACH, GRID_PERIODS |
0.5, 1.2, {3,4,5,6,8} |
when an upscale's own period overrides the width's factor | measured: bicubic 3× and 4× read 0.68-1.52, blurs and 2× under 0.39 (soft_intake.rs:76-81, softness.rs:349-369) |
SHADED_SHARE, SHADED_MIN_FACTOR |
0.008, 4 |
shaded artwork is reduced only by 4 or more | measured: noto emoji worse on 5 of 6 at 3× when reduced (soft_intake.rs:111-117) |
INTAKE_PARALLEL_MIN |
65,536 px (256 × 256) |
below this the transparency scan and the flatten run on the calling thread | motivated: "at 128 px they take microseconds" (crates/inkvec-cli/src/alpha.rs:826-828); no sweep |
FLATTEN_CHUNK |
16,384 px |
pixels per parallel job of the flatten, and the smallest job of the transparency scan | none (alpha.rs:829-830) |
PARALLEL_MIN_PIXELS (load.rs) |
65,536 px (256 × 256) |
below this the byte-to-float conversion runs on the calling thread | motivated: "at 128 px it is a few microseconds, less than handing it to rayon" (crates/inkvec-trace/src/load.rs:274-276) |
CONVERT_CHUNK_PIXELS |
65,536 px |
pixels per parallel job of the byte-to-float conversion | motivated: "enough work to amortise the job and few enough jobs (64 at 2048 × 2048) for rayon to balance" (load.rs:278-280) |
UNIT |
k / 255 for k in 0..=255 |
the float every 8-bit sample becomes | derived: holds exactly the quotients the old per-byte division gave (load.rs:282-296) |
DEFAULT_MAX_ALLOC |
512 MiB |
the decode allocation limit every uncapped decode keeps | derived: the image crate's own default, checked by the_capped_limit_extends_the_library_default (load.rs:956-969, 953-969) |
CAPPED_EXTRA_ALLOC |
768 MiB on 64-bit, 0 on 32-bit |
extra allocation a decode that will be capped may make (1.25 GiB in all) | motivated: holds 16384 × 16384 RGBA at 8 bits or 12000 × 12000 at 16 and still refuses the 20000 × 20000 fuzz bomb; nothing on WebAssembly's 4 GiB address space (load.rs:214-223) |
SRGB_TOLERANCE |
1 level |
largest move of any probe colour for an embedded profile to count as sRGB and leave the image untouched | motivated: converting an sRGB profile "would only add a level of rounding noise" (crates/inkvec-trace/src/load/icc.rs:30-34, 66-67); no sweep |
ROWS_PER_JOB |
64 rows |
rows per parallel job of the ICC conversion | none (icc.rs:69-70) |
COMPOSITE_PARALLEL_MIN |
65,536 px (256 × 256) |
below this the composite over white runs on the calling thread | motivated: below it "the pass is a few microseconds" (crates/inkvec-trace/src/coverage.rs:203-208, 235-236) |
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 (crates/inkvec-cli/src/alpha.rs:385-386, 417) |
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:325-330, 352-362) |
LOST_TO_WHITE |
0.5 |
share of the drawn silhouette lost to a white matte above which the cutout is turned on (without native alpha) | measured: white marks on a transparent ground read 1.00, no screen-set icon above 0.32; the dE00-10 margin instead cost emoji_u1f5a8 0.53 → 0.76 (alpha.rs:332-342) |
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 with soft glows onto a black matte" — qualitative (alpha.rs:410-418) |
DRAWN_FLOOR |
0.05 |
alpha below which a pixel is ignored entirely | no stated numeric derivation (alpha.rs:419) |
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 (alpha.rs:439) |
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 (alpha.rs:440) |
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:13-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 (crates/inkvec-cli/src/args.rs:278) |
| 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:103-110) |
SOFT_INTAKE_EDGE |
1.75 px |
gates whether the round trip scales --precision |
fully documented in 03-palette.md; reused here unmodified (crates/inkvec-trace/src/color.rs:479-488) |
OVERSAMPLE_TOL |
3.0 |
absolute round-trip error tolerance inside oversample_factor |
fully documented in 02-coverage.md; reused here unmodified (crates/inkvec-trace/src/coverage/oversample.rs:10-24) |
OVERSAMPLE_KEEP |
0.5 |
largest share of the image's detail a round trip may lose (relative test) | fully documented in 02-coverage.md; reused here unmodified (oversample.rs:26-53) |
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:308-312). - 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; the limitation is pinned byringing_does_not_widen_an_edge,crates/inkvec-trace/src/coverage.rs:1264-1273). A JPEG re-saved as PNG defeats the container check;ringing_score, which reads where the energy sits rather than how much of it there is, now opens the same guard for it (seeresolve_lossyabove). - A mislabelled file was refused. Before the format came from the bytes, a JPEG named
.pngor a PNG named.jpgfailed to decode ("Invalid PNG signature", "Illegal start bytes"); both now decode as what they are (crates/inkvec-trace/src/load.rs:114-122). A file with no recognisable signature still goes to the decoder its extension names, whose error then describes the damage. - An image with a zero side reached the tracer. A GIF with a zero-wide logical screen
decoded without an error to 0 × 30000 and the unblock test divided by zero; such an image
is now a decode error, "the image has no pixels" (
load.rs:266-275). - A legitimate large file was refused as a decompression bomb: a 12000 × 12000 RGBA PNG
(576 MB decoded) failed with "Memory limit exceeded" although it would be traced at
2048 px. A decode that will be capped may now allocate 1.25 GiB; claims beyond that are
still refused before anything is allocated, and WebAssembly keeps the default
(
load.rs:225-241). - A phone photo traced sideways, dE00 2.9 to 17.6 against 0.03 to 0.42 without the
orientation tag; the EXIF orientation is now applied (
load.rs:168-173). - A file in another colour space traced in the wrong colours: six gamma-1.8 images
averaged dE00 0.84 against 0.077 for the same art in sRGB (
crates/inkvec-trace/src/load/icc.rs:4-14). A profile that cannot be read, or that does not match the decoded pixels (a CMYK profile on a JPEG the decoder has already turned into RGB), is still ignored and the colours read as sRGB, with anINKVEC_DIAGline saying why (icc.rs:18-29, 76-111). - 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
price_in_raster_unitsand, opt-in,content_scale,REF_EXTENTandin_content_unitsexist to treat (see How it works, above). - A sparse raster was read as heavily oversampled. A 38 px² disc on a near-empty 144 px
canvas read 8×, the speckle floor rose 64-fold and removed the disc; the relative
round-trip test reads it as 2× (
crates/inkvec-cli/src/lib.rs:431-435). The dilution is not gone in general: a small dot beside a large shape is still floored by the drawing's factor — a large disc with a 38 px² dot reads 4×, floor 32 px², so a 30 px² dot would go (impl2/bugs report, 2026-10-03). - The decode-time cap runs before unblock on the command line.
runpasses--max-dimto the decoder (lib.rs:856), andpixel_gridruns afterwards inintake, so a nearest-neighbour upscale larger than the cap reaches the lattice test already box-averaged by the cap's factor; its cells are then no longer constant and the upscale is not undone. Read from the code; no case was measured. --content-unitsis offered as a trade, not a free fix: its help text says "it trades fidelity for parsimony" (crates/inkvec-cli/src/args.rs:438-441). The two failures it used to name (5-px squares fitted as circles, thin rings broken) are onescontent_scale's doc comment attributes to the scale it replaced (crates/inkvec-cli/src/units.rs:41-50).--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 (crates/inkvec-cli/src/args.rs:563-567).- The SR pre-pass is three times worse than doing nothing on clean input (dE00 0.605
against 0.195,
crates/inkvec-sr/src/detect.rs:3-7), which is whyMode::Autopays for a full probe trace before deciding, rather than guessing from pixel statistics. --sr autowithout an upscaler failed the whole trace; it now keeps the probe and says so in the stats, as--restore autodoes, while--sr onstill fails (crates/inkvec-cli/src/lib.rs:617-626). A packaged tool that is found but fails while running still fails the trace in either mode (lib.rs:665).- With a
--time-budget, the output depends on the machine, and the palette and the writer read no budget at all (crates/inkvec-cli/src/args.rs:364-371); without one, the merge and the boundary solve are bounded only by their work and memory caps, which apply either way, and the output is reproducible. - 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:410-416, 436-438). 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 cell edges — fails the constant-cell test by design (crates/inkvec-cli/src/alpha/unblock.rs:67-77); a smooth one of 3× or more is the soft intake's, the rest is--sr's and the restorer's.- Soft intake's evidence is global. A raster whose strong edges are mostly native-sharp
is never reduced, even where part of it is a resampled paste; and one whose edges are all
soft (an image blurred on purpose) is reduced although no upscale happened, which is the
point: the blur carries no detail the reduction loses. A shaded drawing reduced by 4 or
more refits its shading at the smaller size (
soft_intake.rs:111-117).
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 | default | source |
|---|---|---|---|
INKVEC_CONTENT_SCALE=1 (removed) |
was equivalent to --content-units |
unset (off) | removed in 0.1.7 (CHANGELOG, Removed); --content-units only |
INKVEC_MATTE (removed) |
forced choose_matte's answer, bypassing the swallowed-mass search |
unset (choose_matte decides) |
removed in 0.1.7 (CHANGELOG, Removed) |
INKVEC_LAYERS (removed) |
forced --layers on |
unset (off, same as --layers unset) |
removed; --layers only |
INKVEC_LAYER_SIGMA (removed) |
overrode the sRGB noise sigma used when fitting a translucent layer | LAYER_SIGMA_SRGB (crates/inkvec-cli/src/alpha/layers.rs:14-22) |
removed |
INKVEC_NATIVE_ALPHA=0 |
turns native alpha off, as --no-native-alpha does: the image is matted first |
unset (native alpha on) | crates/inkvec-cli/src/args.rs:264 |
INKVEC_TOOLS_DIR=<dir> |
the folder holding the packaged SR pre-pass (inkvec_sr); when set, the only folder looked in |
unset (tools/ beside the binary or up to three folders above it, then the source checkout) |
crates/inkvec-cli/src/lib.rs:912-922 |
INKVEC_PYTHON=<program> |
the interpreter the packaged SR pre-pass runs with | unset (python on Windows, python3 elsewhere) |
crates/inkvec-sr/src/external.rs:59-68 |
INKVEC_DIAG=1\|json |
structured diagnostic lines on stderr; the intake's are the ICC outcome (load/icc.rs) and the soft-intake gate (edge width, ringing, lossy flag) |
unset (silent) | crates/inkvec-trace/src/diag.rs:47-60; intake lines at crates/inkvec-trace/src/load/icc.rs:79-147, crates/inkvec-trace/src/lib.rs:383-389 |
INKVEC_ALPHADBG |
prints per-face alpha diagnostics and the emitter's face dump to stderr | unset (silent) | crates/inkvec-cli/src/alpha.rs:1030, crates/inkvec-cli/src/emit.rs:636-639 |
No INKVEC_* variable is read in inkvec-trace/src/load.rs or inkvec-trace/src/load/icc.rs:
sniffing, the zero-side check, the decode limits, the ICC conversion, the orientation,
from_dynamic and lossy_container have no environment knob. icc.rs only emits
INKVEC_DIAG lines through the crate's diag! macro, which reads the variable in diag.rs.
The inkvec-sr crate reads one variable, INKVEC_PYTHON, through inkvec_core::env
like every other engine read; the CLI reads
INKVEC_TOOLS_DIR to find the packaged tool. Every knob of the SR pre-pass's decision and
clean-up is a CLI flag. Other files of the trace crate (lib.rs above all) do read many
INKVEC_* variables for palette, boundary-solve and other downstream stages, but none of
those reads happen in the loader.
Open questions
- Nothing tests
fit_config's wiring. The comment at the call (crates/inkvec-cli/src/lib.rs:806-813) says the configuration is the identity on the default path. It used to name a test,content_units_changes_the_fit_cost, as pinning that; no test of that name was ever in the workspace, so nothing visible checks either that--content-unitsnow scales lambda in the emitted trace or that the default path is unchanged by building throughfit_config. --content-units' trade is unmeasured since the scale changed. The help text used to warn of "5-px squares fitted as circles, thin rings broken" (args.rs:438-441), whilecontent_scale's doc comment says those failures were the over-correction of the oldextent / REF_EXTENTscale (units.rs:41-50); the help now says only that the flag "trades fidelity for parsimony". No measurement of the measured scale against those cases, or of the flag's parameter ratio since the change, was found; whether the flag could now be on by default is unsettled.price_in_raster_units' lambda factor carries no measurement in the comments its doc comment points to. The doc says "the reasons for each factor, and the measurements behind them, are in the comments below" (lib.rs:394-395); the comments measure the precision (lib.rs:448-454) and the speckle floor (lib.rs:456-476), but not the× ron lambda aboveREF_EXTENT.choose_matte'sMARGIN,DRAWN_FLOOR,SOFT_SHAREandFLAT_ALPHAare each given a qualitative role in their surrounding comments but no measured sweep, unlikeSWALLOWED,LOST_TO_WHITEandDRAWN, which are tied to specific before/after cases (the white-highlight, printer-emoji and candle-flame regressions).pixel_grid's floors (MIN_SOURCE = 16,MIN_SOURCE_LONG = 64) are qualitatively justified but not swept. Fractional factors under 2 (125 % and 150 % screenshots) and nearest upscales that were then JPEG-compressed (the r2-inputsnn4jpeg: dE00 1.247, where a 4 × 4 block mean would give 0.536) are not undone; the report's tolerant unblock is the follow-up for the second.- Soft intake leaves 2× upscales and Fast mode alone because reducing them did not pay on the stress set; both would need a reduction that recovers the source rather than a blurred copy of it (a deconvolution of the measured kernel), or, for Fast, the soft-input palette guards.
--time-budget's0.6/0.25split and its 50 ms floor for the boundary solve (crates/inkvec-cli/src/pipeline.rs:145-153) are stated as a fixed allocation with no derivation shown; it is plausible the split 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.