This stage also exists as a plain-language page with diagrams.
Stage 04 — Regions
Turns a per-pixel palette label into a set of connected, single-ink faces: speckle gone, anti-aliasing folded into its neighbours, disconnected same-colour shapes told apart, and pixel-corner ambiguities resolved by the image.
Source: crates/inkvec-trace/src/regions.rs (connected components in crates/inkvec-trace/src/regions/components.rs), called from crates/inkvec-trace/src/lib.rs
Entry point: trace_color_full_with_alpha() (lib.rs:301), stages within it; the saddle pass runs at the head of finish_color_trace_alpha() (lib.rs:1048)
Pipeline position: after color::label_image (mark "labels", lib.rs:544), before gradient::merge_gradient_bands_with_ink (mark "merge_bands", lib.rs:615). The split sub-stage runs later, after stage 05's carve (lib.rs:685), and the saddle pass after it, just before planar::build (mark "build_map", lib.rs:1106)
This stage is four sub-stages, with stopwatch marks; the first two run before stage 05 and the last two after it:
| mark | line | function(s) |
|---|---|---|
despeckle |
lib.rs:548 |
despeckle (regions.rs:1027-1053) |
blend_absorb |
lib.rs:586 |
absorb_blend_slivers (regions.rs:754-820), reassign_blend_pixels (regions.rs:929-1025) |
split |
lib.rs:699 |
split_components (regions.rs:210-242) |
saddles |
lib.rs:1077 |
merge_saddle_faces (regions.rs:46-203), compiled only in a research build; planar::build's own corner logic acts on its result downstream |
The module overview (regions.rs:1-31) lists the same order. The native-alpha path (native.rs:885-896, native.rs:953) runs despeckle and split_components unchanged and four-channel counterparts of the two blend passes; trace_color_from_labels runs despeckle (lib.rs:874) and split_components (lib.rs:971). Fast mode has its own face writer (see 14-fast-mode.md).
What problem this solves
color::label_image assigns every pixel the palette entry nearest its measured colour. That is a per-pixel decision with no notion of shape: it does not know that a pixel is noise, that a run of pixels is only anti-aliasing between two real inks, that two blobs of the same colour on opposite sides of the canvas are different objects, or that four pixels meeting at one corner might belong to a single connected mark. Each of those is a distinct failure mode, and each gets its own pass:
- Speckle — an isolated pixel or tiny cluster that landed on the wrong palette entry (JPEG ringing, a stray anti-aliased pixel) becomes its own face with its own boundary unless removed first.
- Blend slivers — anti-aliased pixels between two real inks are frequently nearest a third palette entry, not either of the two they are blending. Left alone they form thin sliver faces along every boundary in the image, each minting junctions the boundary fitter has to honour (
regions.rs:756-760). Measured onmosaic_grid6(36 flat cells): 128 faces and 368 edges, where the truth has 37 regions (lib.rs:551-556). - Disconnected same colour — palette labelling only records colour, not connectivity. Two separate shapes of one ink must become two faces, not one, or every per-face measurement downstream (including gradient fitting) is corrupted.
- Bowtie corners — where two arms of one shape (or two separate shapes) meet at exactly one pixel corner, the labels alone cannot say whether that corner is one connected mark or two shapes touching at a point.
Inputs and outputs
Working state through this stage is labels: Vec<u16>, one palette index per pixel (w * h long), plus the palette pal: color::Palette, the composited sRGB image rgb: &[[f32; 3]] (composited onto white), the true alpha channel (source_alpha when its length matches, else img.data[..][3], lib.rs:558-563), and sigma_noise: f64 (from coverage::estimate_noise). All colour distances in this stage are plain Euclidean distances in sRGB [0, 1], on purpose: anti-aliasing mixes encoded values linearly, so a blend pixel lies on the straight sRGB segment between its two inks (regions.rs:23-28).
despeckleandabsorb_blend_slivers/reassign_blend_pixelsrelabel pixels in place — the array staysw * hlong, only the label at each pixel changes.split_componentschanges the indexing scheme: it turns the label map (one id per ink, or per label stage 05 minted) into a face map (au16per pixel that is now a connected-component id), plusface_color: Vec<usize>mapping each face id back to the label it came from (regions.rs:210-214).lib.rs:702-726then gives each face its label's fill and maps a minted label back to its palette entry.merge_saddle_facesmay reduce the number of faces in use by unioning face ids at resolved corners. Onlylabelsis rewritten;face_fill,face_colorandn_facesare returned unchanged, so absorbed faces keep their (now unused) entries (regions.rs:75-77).
How it works
Despeckle (despeckle, regions.rs:1027-1053)
4-connected components of the label array are found once, before any is absorbed (regions::Components, regions/components.rs:1-20: each row cut into runs of one label, every run united with the runs of the same label that overlap it in the row above by a union-find, the unions resolved in a second pass — the two-pass scheme of K. Wu, E. Otoo and K. Suzuki, "Optimizing Two-Pass Connected-Component Labeling Algorithms", Pattern Analysis and Applications 12(2), pages 117–135, 2009, applied to runs rather than pixels. The code names two adaptations: the image is multi-label, so "same component" means "same label and connected", and components are numbered in raster order of their first pixel, as the old flood fill numbered them. Member lists are built only for the components a caller selects). Any component smaller than min_size pixels is reassigned, whole, to the neighbouring label sharing the most pixel edges with it, read from the labels as they are at that moment, so a speckle absorbed earlier counts under its new label; ties go to the lower label (commonest_neighbour, regions.rs:1055-1090, std::cmp::Reverse(lab) at regions.rs:1088). A component with no neighbour (the whole image one label) is left alone, and min_size <= 1 is a no-op (regions.rs:1033-1039).
min_size is opts.min_region, described below.
Codec clean-up on a lossy intake (fast/codec.rs, 2026-10-11)
On an opaque lossy intake (ColorOptions::lossy_intake) that did not come out of the restorer (ColorOptions::restored), the despeckled labels go through three passes before blend absorption, the first Quality's own and the other two Fast's halo pass (14-fast-mode.md, "Noisy input"):
- Codec variants (
codec::variant_map). A JPEG stores chroma at half resolution, so a stroke beside a coloured fill takes the fill's tint over its whole width without moving its luma, and the MDL palette founds the tint as an ink of its own: onopenmoji/E056(web) it kept#000109,#080100and#030801beside#000000, and the trace came back with 674 faces, 41 colours and 250 subpaths where the artist drew four colours. An ink withinCODEC_VARIANT_LEVELS(8 levels) of a heavier ink in luma-weighted YCbCr (each chroma channel at a quarter of luma's weight on squares, the halo pass's metric) and within the soft floor (SOFT_SAME_INK_DE00) is relabelled to it everywhere; inks are taken in descending weight, and a variant is never a target. The three tints lie 2.7-5.9 levels from black; the mosaic's neighbouring fills differ in luma and lie farther. - Halos,
RunLabels::absorb_halos_heldin its strict form: every thin component is held to the strip rule and the segment test, not only one of an ink that owns an area elsewhere as in Fast, because Quality's palette admits a thin ink on evidence and a real strip can lie on the segment between its neighbours (noto-emoji/emoji_u1f9ed's needle shadow, three to four pixels wide between the teal needle and the white dial, went with the hull test alone). - Thin variants:
RunLabels::merge_same_inksat the soft floor for thin components, as in Fast.
The labels come back as palette indices; the palette is not changed, and an ink nothing is labelled with draws no face. Measured on quality-web against the build before it (246 icons, 2026-10-11, the gate's distance axes): dE00 −6.83 %, turning_gap −36.57 %, ratio_gap −11.89 %, geom −3.73 %, all four better, 87 icons changed (69 better and 18 worse in dE00); per icon, openmoji's colours and subpaths went 10.6 and 38.0 to 5.7 and 12.8 (the artists': 3.9 and 11.7), noto-emoji's 15.6 and 32.3 to 14.8 and 29.2 (9.0 and 25.0). The halo passes alone (without the variant merge) read dE00 −4.69 % and turning_gap −25.8 %; the halo test in Fast's form instead of the strict one, dE00 −7.09 % and turning_gap −43.2 %, but it erased real strips like the needle shadow. A restored image takes none of it either: the restorer took the codec's damage out, and on the restored path (quality-web, --restore on, the gate's 0.3 sample) the clean-up read dE00 +0.24 % (the brown border strip of openmoji/E30B, a real ink between the black outline and the white sheet, went to the outline), so it is off there; the intake stays lossy for the palette. A clean intake takes none of this and is byte-identical. Tests: fast/codec/tests.rs.
Blend absorption (blend_absorb, lib.rs:549-586)
Two passes, both skipped when INKVEC_NO_ABSORB is set (lib.rs:557):
absorb_blend_slivers (regions.rs:754-820). Runs at most two rounds, because dissolving one sliver can leave a neighbouring one thinner or bounded by fewer inks (regions.rs:768-771). Each round finds the components once (SliverRound, regions.rs:822-859), visits them in raster order, and lets each edit be seen by later components in the same round. The three tests, each on counts rather than colours so that one noisy pixel cannot swing them, are listed on absorb_sliver (regions.rs:673-752):
1. Thin: fewer than 20 % of its pixels are interior (5 · interior < area; a pixel is interior when all its in-image 4-neighbours are in the component), and it has at least one neighbour. "A blob with a real interior is a shape, not a seam."
2. Between few inks: its three most-touched neighbouring labels (ties to the lower label) account for at least 80 % of its boundary contacts, and at least two of them have a palette colour (regions.rs:710-735). A seam between two or three inks qualifies; a crumb in a busy area does not.
3. A blend of those inks: at least 80 % of its pixels lie within tol of a convex mixture() of the neighbour inks (regions.rs:684-685). If any pixel is translucent (alpha < 0.99) and white is not already one of them, the white backdrop (BACKDROP = [1.0, 1.0, 1.0], regions.rs:418-419) joins as a pseudo-ink, because a translucent edge composited onto white is a blend with white (regions.rs:736-739).
mixture() (regions.rs:421-520) explains a pixel's colour as the nearest point of any segment between two candidate inks or any triangle spanned by three, and returns the residual distance and the ink with the largest weight in that nearest mixture — "the ink the pixel 'mostly is'". Pairs and triples, because an anti-aliased edge pixel is a blend of two inks at a boundary and three where boundaries meet (regions.rs:443-449); the search is exhaustive (O(k³)), which is fine because callers pass at most five inks.
On success each pixel moves to its own dominant ink (sliver_destinations, regions.rs:629-671), so a seam between A and B is split down the middle rather than handed wholesale to one side. When the backdrop dominates, the pixel is a translucent edge fading to nothing and goes to the dominant real ink instead (or to the most-touched neighbour if that fails). The tolerance is tol = max(3·sigma_noise, 0.025) (regions.rs:787); the floor "keeps clean synthetic input, where σ is near zero, from rejecting blends over float rounding in the rasteriser" (regions.rs:764-766). Debug output for each rejection reason is available under INKVEC_ABSDBG.
reassign_blend_pixels (regions.rs:929-1025). The per-pixel sequel: after whole slivers are gone, single blend pixels remain along boundaries carrying a third ink's label. For ROUNDS = 4 rounds (regions.rs:958):
- The candidate inks are the pixel's own label plus up to three other distinct labels among its eight neighbours, first found in the scan order NW, N, NE, W, E, SW, S, SE; with fewer than two labels the pixel stays. The backdrop is added when the pixel is translucent (regions.rs:1002-1005).
- The pixel moves to the dominant ink of its nearest convex mixture() of those inks (pairs and triples) only when all three hold: that ink differs from its current one; the residual r <= tol with the same tol; and r < 0.5 · |c − own|, "the blend explains the pixel at least twice as well as the ink it has, so a pixel that is a plausible match for its own ink is left alone" (regions.rs:938-941, test at regions.rs:1018).
- Each round decides pixels from the labels as the round found them, in parallel with rayon, and then applies the moves, so the result does not depend on scan order. A decision reads only the pixel's 3x3 neighbourhood, so after the first round only the neighbourhoods of the pixels that just moved are decided again (relabel_rounds, regions.rs:861-927); every other pixel would decide "stay" as it did before. "Not from the literature: a plain worklist, because the moves after the first round are few (on the screen set 94 % of them happen in round one)".
If either pass changed anything, despeckle is run again (lib.rs:582-584) to clean up whatever the reassignment left behind.
Split into connected faces (split, split_components, regions.rs:210-242)
Flood-fills the label array into 4-connected components, numbered in raster order of each component's first pixel, producing a face id per pixel and face_color mapping each face id back to its label (split_within, regions.rs:244-291). 4-connectivity, not 8, is what the planar map and the boundary tracer assume: two same-ink pixels that touch only at a corner are two faces, and the corner is a junction (regions.rs:216-217). This is the step that turns "every pixel of this colour" into "this one connected shape". Skipping it merges every disconnected same-coloured shape into a single region — wrong for editing (an editor could no longer move one shape independently) and for measurement: concentric rings quantised to six palette entries put several non-adjacent annuli of slightly different colour into one "region", whose flat residual was then a thousand times what noise could explain, so the fill fitter explained the difference with a radial gradient (lib.rs:686-695).
More components than face ids. Face ids are u16, and u16::MAX is reserved for the outside of the image in the planar map (and for "not yet visited" here), so at most MAX_FACES = 65,535 faces (ids 0..=65534) can be numbered (regions.rs:205-208). The split used to number the first 65,535 components and drop the pixels of every later one into face 0, which then held pixels of many inks scattered over the bottom of the image under the first component's colour: "a 1024 px RGB noise image (36 inks, 68,654 components) came back with exactly 65,535 faces, a 2.5 MB SVG and 15 minutes of work (r2-product, 2026-10-02)" (regions.rs:219-230). Now split_within gives up where the old loop started folding (regions.rs:255-257), and split_components first merges the smallest components into their neighbours with cap_components (regions.rs:293-416) and then numbers the result as usual (regions.rs:231-242). A map with at most MAX_FACES components takes exactly the old path.
cap_components works in rounds (regions.rs:296-317):
1. Find the components (Components). If there are at most cap, stop.
2. Take the excess = count − cap smallest, ordered by size and then by raster order of their first pixel, so the result does not depend on hashing or threads.
3. In that order, relabel each one with its commonest neighbouring label (the label sharing the most pixel edges, ties to the lower label), unless a neighbour was already relabelled this round, or it was itself chosen as some other component's target. Every component next to it that carries the chosen label is marked a target, which keeps its label for the rest of the round.
Each relabelling removes a component: a relabelled component takes the label of a neighbouring target, which keeps that label to the end of the round, so the two are one component afterwards; no two relabelled components touch, so no merge is undone by a later one; and the first component of every round can always be relabelled, so the rounds end (regions.rs:312-317; a progress == 0 guard against looping forever stays at regions.rs:411-414). The code labels it "Method from: region merging on a region adjacency graph, smallest and most similar first", as in K. Haris, S. N. Efstratiadis, N. Maglaveras, A. K. Katsaggelos (1998), Hybrid image segmentation using watersheds and fast region merging, IEEE TIP 7(12), pages 1684–1699, https://doi.org/10.1109/83.730380; "Adapted: the merge order is size alone and "most similar" is the longest shared border (the labels carry no colour here), and the stop is the face-id limit rather than a dissimilarity threshold" (regions.rs:328-333). It is reached only past MAX_FACES components, "which no image in the benchmark sets comes near (the largest 2048 px input has a few thousand faces)" (regions.rs:323-326). With INKVEC_DIAG set, a split that capped reports how many components it merged (regions.rs:237-240). Fast mode's face writer calls the same cap_components (14-fast-mode.md).
Measured (w2-robust report, section 4, 2026-10-03): on the r2-product noise_1024 image the trace went from 65,535 faces with face 0 folded to 65,511 connected faces, none folded; the fuzz image noise_rgb_1024 to 65,524. Both still take 16-18 minutes, in the palette (450-650 s) and the emitter (400-520 s), not here.
Saddle resolution (saddles, merge_saddle_faces, regions.rs:46-203)
An experiment: compiled only with the research feature (regions.rs:78, call site lib.rs:1062-1076), and even then a no-op unless INKVEC_SADDLE is set, or when the image is narrower or shorter than 2 px (regions.rs:91-93). See Environment overrides.
For every interior pixel corner (i, j) where 1 <= i < w, 1 <= j < h, look at the four pixels meeting there: nw = (i-1,j-1), ne = (i,j-1), sw = (i-1,j), se = (i,j).
- Skip unless the four face ids differ across every pixel edge at the corner —
nw != ne && nw != sw && ne != se && sw != se(regions.rs:116). This is the four-way junction the labels cannot resolve alone. - Skip unless the two diagonals carry exactly two distinct inks (palette indices via
face_color), one ink per diagonal:face_color[nw] == face_color[se]andface_color[ne] == face_color[sw], and the two inks differ (regions.rs:119-125). Faces with no ink and inks with no palette colour, or two inks closer than1e-6in squared distance, are skipped (regions.rs:126-133). - Compute the coverage of ink A (the NW/SE ink) over ink C at each of the four pixels by projecting its colour onto the segment between the two palette colours:
t(p) = clamp(((p − C) · (A − C)) / |A − C|², 0, 1)(cover,regions.rs:134-141). - Evaluate the saddle of the bilinear patch through the four coverages (
regions.rs:144-151):den = cnw + cse - cne - csw num = cnw*cse - cne*csw at_corner = num / den (or the plain mean (cnw+cne+csw+cse)/4 if |den| < 1e-9: the patch has no saddle)The doc comment calls this "the asymptotic decider used to resolve ambiguous cells in marching squares" (regions.rs:53-58); it cites no paper.at_corner > 0.5joins the A faces (the NW/SE diagonal); otherwise the C faces. - Propagate the pixel noise through this: the noise projects onto the ink axis as
σ_t = σ / |A − C|, and first-order error propagation givesσ_corner = σ_t · |∇s|, with the four partial derivativesgofat_cornerwith respect to the coverages, orσ_t / 2for the degenerate mean (regions.rs:67-70,regions.rs:152-164). - The reading is decisive only if
|at_corner - 0.5| > SADDLE_SIGMAS * sigma_corner, withSADDLE_SIGMAS = 3.0(regions.rs:40-44, test atregions.rs:165): "three sigma of the propagated coverage noise. Below that the two readings are indistinguishable, and the corner keeps the junction it has always had." A perfect checkerboard corner (coverages 1, 0, 0, 1) givesat_cornerexactly 0.5 by the formula, so it is never decisive and keeps its junction. - A decisive corner unions the two faces on the winning diagonal via union-find (
find/parent,regions.rs:95-108; the larger root is pointed at the smaller,regions.rs:182-186).
After the scan, every pixel's label is rewritten to its union-find root (regions.rs:193-195). Absorbed face ids are left in face_fill, face_color and n_faces, unused, rather than renumbered (regions.rs:75-77).
planar::build, called immediately afterward (lib.rs:1080), is what turns a merged corner into two boundary points instead of one junction: split_saddle_corners (planar.rs:185-270) gives the two faces on the cut diagonal their own copy of a degree-4 corner when exactly one diagonal is a single face, and leaves a corner whose diagonals are both one face, or neither, as a junction (planar.rs:235-237). "Which diagonal is one face is settled upstream, in merge_saddle_faces, where the image can be consulted" (planar.rs:207-210). The rule is covered by planar.rs's saddle_tests module (planar.rs:1312-1391): four_inks_meeting_at_a_corner_stay_a_junction confirms an unreadable four-way corner keeps its junction, and a_corner_two_shapes_share_becomes_two_points / splitting_gives_each_shape_its_own_copy_of_the_corner confirm that once one diagonal is a single face, the corner becomes two independent points, one per touching shape, and every edge still separates exactly the two faces it did before.
Constants and thresholds
| name | value | controls | stated derivation |
|---|---|---|---|
ColorOptions::min_region (default) |
4 (library default, lib.rs:217), but the CLI always overrides it with args.min_area.max(1.0) as usize (crates/inkvec-cli/src/pipeline.rs:180), and args.min_area defaults to 2.0 (crates/inkvec-cli/src/args.rs:251) — so the shipped default is 2 pixels. Above the reference extent the CLI scales --min-area by the square of the measured oversampling factor (price_in_raster_units, crates/inkvec-cli/src/lib.rs:382-396, reasons at lib.rs:442-473) |
despeckle's min_size, and (via min_region.max(2)) the carve pass's minimum feature size (crates/inkvec-trace/src/lib.rs:676) |
See the comment at crates/inkvec-cli/src/pipeline.rs:168-179, quoted below |
SADDLE_SIGMAS |
3.0 |
how many propagated-noise sigma a corner's coverage value must clear the 0.5 mark by before a saddle is resolved | regions.rs:43-45: "three sigma of the propagated coverage noise. Below that the two readings are indistinguishable, and the corner keeps the junction it has always had." No further derivation than "three sigma" as a standard significance bar; not tuned against a benchmark in the comment. |
MAX_FACES |
65,535 (u16::MAX) |
most faces a face map can number; past it cap_components merges the smallest components |
derived: ids are u16 and u16::MAX is reserved for the outside of the image (regions.rs:205-208) |
| absorption rounds | 2 |
how many rounds absorb_blend_slivers runs |
regions.rs:768-769: "dissolving one sliver can leave a neighbouring one thinner or bounded by fewer inks"; no numeric derivation for two |
| absorption thinness gate | interior pixels < area / 5 |
which components are candidate slivers | regions.rs:678-679: "A blob with a real interior is a shape, not a seam"; no numeric derivation for a fifth |
| absorption dominant-neighbour gate | top-3 neighbours cover >= 4/5 of foreign contacts |
which components are candidate slivers | regions.rs:680-683: "A seam between two or three inks qualifies; a crumb in a busy area does not"; no numeric derivation for 80 % |
| absorption pass threshold | >= 4/5 of pixels within tolerance |
whether a qualifying component is actually absorbed | regions.rs:684-685; no numeric derivation for 80 % |
| translucency gate | alpha < 0.99 |
when the white backdrop joins the candidate inks | regions.rs:685-687, regions.rs:934-935: a translucent edge composited onto white is a blend with white; no derivation for 0.99 |
tol (both blend passes) |
max(3 * sigma_noise, 0.025) sRGB |
how close a pixel's colour must be to a mixture hypothesis to count as a match | three sigma of the measured noise; the floor keeps clean synthetic input from rejecting blends over float rounding (regions.rs:764-766); no derivation for 0.025 (about 6.4/255) itself |
CODEC_VARIANT_LEVELS |
8 levels (luma-weighted YCbCr) | on a lossy intake, how close an ink may lie to a heavier one and be the codec's variant of it (fast/codec.rs) |
the codec's tinted blacks on openmoji/E056 lie 2.7-5.9 levels from black; measured with the clean-up on quality-web (above). No sweep of the value |
reassign_blend_pixels::ROUNDS |
4 |
how many rounds the per-pixel reassignment runs | no stated derivation (regions.rs:958) |
| reassign "beats own label" margin | residual < 0.5 * |c − own| |
how much better a blend hypothesis must be than the pixel's own ink before it moves | regions.rs:940-941: "the blend explains the pixel at least twice as well as the ink it has, so a pixel that is a plausible match for its own ink is left alone"; no derivation for the half |
| 9-pixel colour-mode floor (rejected) | tried, not shipped | would have been min_region in colour mode |
crates/inkvec-cli/src/pipeline.rs:176-181 (quoted below): tried 2026-09-03, measured worse on the 980-icon set — objective 0.7885 vs 0.7673 with the 2 px floor, 508 icons worse / 171 better in dE00. It erased real dots and thin details more often than it removed confetti; the shipped default stays at --min-area (2 px). |
The min_region decision, quoted directly (crates/inkvec-cli/src/pipeline.rs:171-181):
A 2px floor is appropriate for the continuous bilevel path, but in colour mode it lets anti-aliased edge samples become their own tiny faces. Those faces split a smooth shared boundary into a literal pixel staircase before fitting. Nine pixels removes that confetti on the grid canaries while long thin artwork remains intact; an explicit --min-area always wins for deliberately tiny art. A 9-pixel colour-mode floor was tried (2026-09-03) to absorb anti-aliased stair-step faces and measured worse on the full 980-icon set: objective 0.7885 vs 0.7673 with the 2 px floor, 508 icons worse / 171 better in dE00 - it erased real dots and thin details more often than it removed confetti. Blend absorption and residual carving handle the stair-steps at the label level instead. The default therefore stays at the --min-area value (2 px).
The comment reads in the order the idea was tried and then reverted; the operative fact is the last sentence — the shipped default is 2 px, and the 9-pixel floor is a recorded negative result, not a live option gated by any flag. Blend absorption (this stage) and residual carving (stage 05) are what handle the stair-step confetti instead. The relative floor in price_in_raster_units is the other side of the same record: "a flat 9-px colour-mode floor was tried on 2026-09-03 and measured worse ... A floor that rises only when the raster is measurably carrying surplus pixels cannot make that trade" (crates/inkvec-cli/src/lib.rs:464-469).
Failure modes and edge cases
mosaic_grid6(crates/inkvec-trace/src/lib.rs:549-554): unabsorbed blend slivers on a 36-cell flat mosaic produced 128 faces and 368 edges against a 37-region truth — the canonical example of why blend absorption exists at all.- Genuinely thin features: a thin component whose colour is off every segment and triangle between its neighbours' inks fails the third absorption test and keeps its own label; a component with a real interior fails the first (
regions.rs:675-688). No unit test in this crate names a thin-feature canary. - Translucent edges: an outer-edge pixel composited onto white is a mixture with the backdrop; the backdrop is a candidate ink whenever any pixel in the group (or the pixel, in the per-pixel pass) is translucent, and a pixel whose dominant ink is the backdrop goes to the dominant real ink instead (
regions.rs:629-636,regions.rs:943-944). - Deterministic tie-breaks:
despeckle,cap_componentsand the absorption tally break ties on the label value itself rather than on hash-map or thread order (regions.rs:1086-1089,regions.rs:305-306,regions.rs:716); the per-pixel pass decides every pixel of a round from the same snapshot, so the result does not depend on scan order or thread count (regions.rs:944-946). - More components than face ids: before
cap_components, every component past 65,535 was folded into face 0 (a 1024 px RGB noise image: 65,535 faces, a 2.5 MB SVG, 15 minutes,regions.rs:221-225). Now the smallest components are merged into their neighbours until the rest fit, and every face is one connected component of one label (regions.rs:1286-1302, a 300 x 300 checkerboard of 90,000 components). Widening face ids tou32was considered and dropped: it touches the planar map, boundary solve, fitter, emitter and CLI (w2-robust report, section 6). - Saddle pass in a research build: joining two faces at a corner changes which faces the emitter's containment tree stacks and punches; the current source records no measurement of the pass's effect (see Open questions).
Environment overrides
Since the settings cleanup (CHANGELOG, 0.2.0, Changed) the engine reads its environment through one helper (inkvec_core::env): a switch is off when unset, empty or 0, and every variable is read once per process. Variables marked removed below are gone (their defaults are constants now); those marked research build are read only by a binary built with --features research. The full list, with what is left and why, is docs/internal/env-vars.md.
| variable | effect |
|---|---|
INKVEC_NO_ABSORB |
if set, skips both absorb_blend_slivers and reassign_blend_pixels (and the despeckle after them) on the classic path (crates/inkvec-trace/src/lib.rs:555) and their four-channel counterparts on the native-alpha path (native.rs:884) |
INKVEC_ABSDBG |
prints per-component rejection reasons from absorb_blend_slivers to stderr for components over 15 pixels: not thin, contacts too spread, blend fail (read at regions.rs:788, printed at regions.rs:704-746 and regions.rs:805-807) |
INKVEC_SADDLE (research build) |
must be set to turn the saddle pass on; it is off by default (regions.rs:91) |
INKVEC_SADDLEDBG (research build) |
prints, per decided or tied corner, the two ink ids, the corner value, its uncertainty and the verdict, then a count of the joins (regions.rs:94, regions.rs:167-177, regions.rs:196-201) |
INKVEC_DUMP_LABELS |
(path) writes the post-absorption label image as a binary PPM using palette colours, labels without a palette entry in magenta (crates/inkvec-trace/src/lib.rs:586-588, dump_labels at regions.rs:1092-1105) |
INKVEC_DIAG |
among the engine's diagnostics, reports a split that had to merge components to fit the face-id limit (regions.rs:237-240) |
INKVEC_TIMING |
enables the Stopwatch (crates/inkvec-trace/src/lib.rs:1223-1231, :1268-1298), printing each stage mark's wall-clock time |
Open questions
- The
tolfloor of0.025sRGB (roughly 6.4/255) in both blend passes (regions.rs:787,regions.rs:959) is explained as a guard for near-zeroσon clean synthetic input (regions.rs:764-766), but the value itself has no stated derivation — Unverified: why this specific value rather than, say,2/255or1/255. reassign_blend_pixels'sROUNDS = 4and the "at least twice as well" (0.5 · |c − own|) margin carry no stated derivation in the source; they read as chosen values rather than measured ones. The same holds for the absorption gates' fifths and the 0.99 translucency gate.SADDLE_SIGMAS = 3.0(regions.rs:44) is justified in the comment only as a standard three-sigma significance bar; the comment does not report a swept or rejected alternative value — Unverified: whether it was swept.- The saddle pass is an experiment, compiled only into a research build and off there by default, and the current source records no measurement of its effect. The source once did: a comment on
merge_saddle_facesincrates/inkvec-trace/src/lib.rs, added by7e68839(2026-09-08) and dropped when the module was split out by9402826(2026-09-12), read "231 of the 246 screen icons are untouched — but it does not yet pay for itself on the set: objective 0.4005 -> 0.4010, six icons better and nine worse. The nine are the emitter's containment tree being rewritten by a merge into the background". Both commits are only on the pre-squash history branchbackup/pre-cleanup-full-history, not in the history this tree was built from, and the measurement was not repeated after the pass moved behind theresearchfeature. A second comment of the same commit, at the relabelling, gave the "two men holding hands" icon as the example: the merge cost it 0.06 dE00, "only because the face absorbed was absorbed into the background". cap_componentsmerges by size alone and by the longest shared border, with no colour test (the labels carry none at that point,regions.rs:331-333); on an image that reaches the limit, which specks are lost is decided by size and adjacency only. No image in the benchmark sets reaches the limit, so the choice has no measured effect on quality.- Tests:
split_componentsanddespecklehave direct unit tests inregions.rs's own#[cfg(test)] mod tests(test_split_components_disjoint,regions.rs:1114-1122;test_despeckle_removes_single_pixel,regions.rs:1124-1131);the_split_below_the_limit_is_the_old_splitholds the split equal, id for id, to the old split on random maps below the limit (regions.rs:1207-1217);capping_components_merges_whole_components_into_neighbours(regions.rs:1219-1284) anda_map_with_more_components_than_face_ids_is_split_into_connected_faces(regions.rs:1286-1302) cover the cap.crates/inkvec-trace/tests/components.rsexercises the split end-to-end throughtrace_color_full(e.g.concentric_rings_are_separate_faces_and_each_is_flat,components.rs:154).absorb_blend_slivers,reassign_blend_pixelsanddespeckleare held equal to their flood-fill, every-pixel-every-round versions on random scenes byregions/reference_tests.rs(and the four-channel ones bynative/reference_tests.rs). Themosaic_grid6case cited inlib.rsis a corpus-level fixture, not a unit test in this crate.