Inkvec

Changelog

All notable changes to Inkvec are documented in this file. The format follows Keep a Changelog; this project uses Semantic Versioning once it reaches 1.0 (before that, the library API in particular should be treated as unstable release to release).

[Unreleased]

[0.3.1] - 2026-10-13

Changed

  • --editability prices every edit by MDL, with bonuses learned from the artists' files. Each pass used to keep an edit while every sample stayed within 3·σ + slack of the evidence; each edit passed on its own and hundreds of them added up, so the mode cost +33 % dE00 and +15.6 % geometric distance from the artists' files on the screen set. Now an edit of kind k is kept when ½·Δχ² ≤ λ·(numbers saved + credit + b_k) (editable/price.rs): χ² both ways between the curve and the evidence by exact distances (the fit's sampled curves::chi2 has a floor of about two units per point, so 0.1 px moves looked free), λ the fit's price of a number, credit one number for a G1 join's implied direction, and b_k a bonus learned by inverse optimisation against the artists' files (Maximum Margin Planning; the structured perceptron's update), with the gate's geometric distance and turning held to their margins. Nine kinds were learned and pruned to three: an arc written as cubics cut at its extrema, now one arc at a time (23.9 up to a quarter turn, 40 beyond), a node at an extremum (11.85) and a smooth join (11.2, scaled by the raster's longer side over 512 px, since a smaller raster has less evidence per curve). Node alignment across rings no longer runs (it alone moved turning_gap +3.8 % for no gain), and a stroke's arcs are kept as arcs (that rewrite was the mode's whole parameter-count regression, on lucide). INKVEC_EDIT_PRICE=off restores the fixed budget.
  • Against the plain trace, all 246 screen icons: the design battery 8.4–9.3 % closer to how the artists draw at 512 px opaque, 128 px and 512 px transparent; edge placement (geomatch's mismatch within a source pixel of an artist edge) −0.90 %, −0.31 %, −0.83 %; region errors, turning_gap (+0.67 %, −0.10 %, +1.78 %) and ratio_gap (−0.87 %, −6.35 %, −0.12 %) within the gate's margins. A wireframe coded by segment kind and join angle, compared with the artist's by C-RADIOv4 at three zoom levels, is 7.4–8.2 % closer (held-out half).
  • On 228 icons never used to learn the bonuses (128 px): edge placement −0.68 %, area correct +0.01 points, the coded wireframe 6.4 % closer to the artist's (131 icons closer, 30 further).
  • The boundary solve skips a lossy intake that was not restored. It fitted sub-pixel edges to the codec's ringing and blocking: on the resized quality-80 JPEG tier, with it off, edge placement 0.1785 → 0.1653 px (better in every family, simple-icons −36 %), area correct +0.091 points, dE00 −5.6 %. A restored image (--restore) keeps the solve, as does every clean intake (byte-identical). INKVEC_BOPT_LOSSY=1 runs it as before.

[0.3.0] - 2026-10-11

Changed

  • Traces sit closer to the artist's file in every gate condition, and much closer on noisy input. Against v0.2.7 on the regression gate (scorer 4, 246 icons, all eight conditions, every gated axis better or level):
  • Quality dE00 −12.5 % at 128 px, −12.1 % at 512 px, −15.2 % at 512 px opaque and −10.4 % on the resized quality-80 JPEG tier; geometric distance from the artist's edges −3.3 to −8.1 %; distance from the artist's parameter count −25 to −36 %; parameters per artist parameter 1.47 → 0.91, 1.72 → 1.12, 1.96 → 1.32 and 2.78 → 1.77.
  • Fast dE00 −9.1 to −11.6 % in every condition; on the JPEG tier, distance from the artist's control-polygon turning −66 % and parameters 7.29 → 4.13 per artist parameter.
  • Quality takes about 1.3× v0.2.7's time (stroke detection on by default, faces completed behind their covers), measured on a shared machine; Fast's time is unchanged.
  • Of the gate's human statistics (how a file is drawn: joins, handles, grid, ties, layers; bench/human_stats.py), 25 of the 27 measured move closer to the artist's files in Quality at 512 px opaque and 23 on the JPEG tier, by more than 2 %; one and two move further.
  • --restore auto is the default in Quality mode. Args::restore is now an Option: unset, Args::restore_mode resolves it to auto in Quality mode when the build can restore (the network compiled in, or --restore-command) and to off in Fast and balanced and in a build with nothing to restore with, which then traces exactly as --restore off does; a given --restore always wins. On a clean input auto makes no probe without a sign of compression, so the default costs a clean PNG the ringing score alone (about 2.7 ms at 512 px); the gate's builds carry no network and its numbers do not move.
  • Quality mode takes the codec's damage out of its labels on a lossy intake. The MDL palette founded the codec's tints of an ink as inks of their own (on openmoji/E056, three tinted blacks beside black: 674 faces, 41 colours, 250 subpaths where the artist drew four colours), and the halos Fast's pass removes cut Quality's strokes into fragments. On an opaque lossy intake the despeckled labels now go through Fast's halo pass, in a strict form that holds every thin component to the strip rule and the segment test, through its soft merge of thin variants, and through a merge of codec variants of its own: an ink within 8 levels of a heavier one in luma-weighted YCbCr, and within the soft floor, is that ink (fast/codec.rs). quality-web against the build before (246 icons, the gate's distance axes): dE00 −6.83 %, turning_gap −36.57 %, ratio_gap −11.89 %, geom −3.73 %; openmoji's colours and subpaths per icon 10.6 and 38.0 → 5.7 and 12.8 (artists: 3.9 and 11.7). A clean intake is byte-identical, and Fast's output is unchanged. A restored image (--restore) does not take it: the restorer took the damage out, and on the restored path it read dE00 +0.24 %; ColorOptions::restored (set by the CLI's restorer pre-pass) says so.
  • Counters are cut out of their glyph, and square rectangles share their paint's path (all modes). On an opaque page, the counter of a letter O was a page-coloured element painted over a solid disc, and every rectangle was an element of its own (material-icons/border_left: seventeen <rect> where the artist has one path). Now:
  • A face in exactly the page's colour that fills a hole of a glyph on the page is dropped and written into the glyph as a hole (emit/holes.rs). As under --no-background, only a glyph whose children are all page-coloured is cut. A counter or glyph written as a circle, ellipse or rounded rectangle stays painted over, since a hole costs more numbers.
  • A square-cornered rectangle joins the compound path of its same-coloured siblings: three lines are the six numbers of a <rect>. So does a rectangle the completion stage produced.
  • The picture is unchanged by construction (Design/Layers.lean, cut_hole).
  • Regression gate (246 icons, all 8 conditions, against the previous build). Every verdict is identical or non-inferior; fast-128ss and fast-512ss are byte-identical.
    • quality-512ssop: dE00 −0.15 %, turning_gap −0.06 %, ratio_gap −0.15 %, geom −0.0001 %.
    • quality-web: −0.0008 %, −0.05 %, −0.10 %, geom unchanged.
    • quality-128ss and quality-512ss: ratio_gap −0.05 % and −0.06 %; the other axes within 2·10⁻⁶ %.
    • Two point estimates are worse, each from one icon: quality-512ss turning_gap +3·10⁻⁷ (material-icons/qr_code, a completion corner moved by 0.01 px) and fast-512ssop geom +7.8·10⁻⁷ (material-icons/admin_panel_settings, the renderer's coverage of a hole's edge pixels).
  • Closer to how the artists draw (bench/human_stats.py, 246 icons, divergence from the artist's file). The composite falls 3.1 % on quality-512ssop and 1.9 % on quality-web. On 512ssop: primitives −24.7 %, elements −14.5 %, nested −12.7 %, stacked −10.3 %. material-icons' nested share goes 0 → 0.13 (artist 0.42) and its stacked share 0.47 → 0.28 (artist 0).
  • INKVEC_CUT_HOLES=0 paints counters over, as before.
  • Nodes at a curve's extrema, where the fit's own objective prefers them (Quality). Type designers and icon artists put a curve's points where it runs horizontal or vertical, with the handles there on the axis (Adobe, Type 1 Font Format, 1990, §4.1 "Points at Extremes" and §4.2 "Tangent Continuity"; font editors check it). The dynamic program can only put a node on a measured point, with the estimated direction, so it never proposed one there. A new pass after the merge (inkvec_fit::extrema) offers each smooth run of the fit the same run with its nodes at the extrema and every join smooth: the cheapest chain through the extrema and the old nodes, each node then refined by a compass search and the arms polished on every point. It replaces the run only when the fit's own objective, ½χ² + λ·params, prefers it, with one parameter credited per anchored node (the direction it no longer writes), and only when it writes exactly as many numbers. Stroke centrelines are left alone (extrema::without), so the stroke decision is unchanged.
  • Regression gate, 246 icons (change; icons better / worse). On the tree before the completion stage, against the baseline: quality-512ssop dE00 −0.04 % (23/22), turning_gap −0.01 % (40/38), ratio_gap −0.01 % (2/0), geom −0.03 % (26/15); quality-128ss dE00 −0.03 % (26/24), turning_gap −0.40 % (41/20), ratio_gap identical, geom −0.02 % (19/16); quality-web dE00 −0.10 % (56/25), turning_gap −0.15 % (63/40), ratio_gap −0.00 % (1/0), geom −0.03 % (46/27); quality-512ss dE00 +0.002 % (25/20), turning_gap −0.18 % (51/15), ratio_gap +0.002 % (0/1), geom −0.02 % (18/20). With the completion stage, against that tree: quality-512ssop dE00 −0.05 % (24/22), turning_gap +0.05 % (40/36), ratio_gap −0.01 % (2/0), geom −0.03 % (26/15); quality-web dE00 −0.11 % (64/23), turning_gap −0.07 % (58/43), ratio_gap −0.00 % (1/0), geom −0.05 % (50/23). The rises are one icon each, through later stages: the crossing repair refits an edge next to a re-noded one differently (noto-emoji/emoji_u1f9d1_1f3fc_200d_1f37c, two numbers more), and the seam underlap gave up on a run holding one of the new cubics (noto-emoji/emoji_u1f3c4_1f3fe; fixed, see Fixed). Fast mode is byte-identical.
  • Human battery, quality-512ssop, divergence from the artist's file: smooth −3.1 % (51 icons closer, 3 further), kinked −6.1 % (19, 1), handle_axis −3.3 % (39, 4), extremum −1.3 % (33, 27), all with 95 % intervals clear of zero; composite 0.9937.
  • --editability writes curves the way drawing tools do. Every circular arc is written as cubics cut at its x and y extrema, handles on the axes and 4/3·tan(Δ/4)·r long (Riškus 2006, Information Technology and Control 35(4): 2.7e-4 of the radius for a quarter), a stroke's centreline included; no cut within 1 px of an arc's end. Smooth runs are re-noded at their extrema with three parameters credited per node and the count free. Every join that turns by at most 30° is made smooth, next to a line along the line, and joins between two edges at a junction too (editable/junctions.rs); a smooth node's two handles snap to an axis together or not at all; two lines meeting within 0.5° become one. A boundary with no junction on it, handed over as an open edge ending where it starts, now has its closing join edited like any other. Every change is held to the mode's guard, as before. Human battery, --editability against the default trace, quality-512ssop: composite 0.9185 (it was 0.9925); divergence from the artist kinked −44.2 % (92 icons closer, 21 further), handle_axis −37.1 %, handle_length −30.2 %, extremum −25.6 %, smooth −22.3 %, ties −13.3 %, kinds −12.8 %, collinear −4.0 % (it was +15.3 %); further: segment_lengths +13.3 %, nodes_per_subpath +6.4 % and sweep +12.0 % (not significant), the price of cutting arcs at quarter turns.
  • On a lossy intake the carve stage leaves the codec's band alone. A codec's error sits at the edges and its ringing is a rim of residual just inside every region, which carve_residual_features cut into extra regions along the edges. On a lossy intake a pixel is now carved only when it lies two pixels or more inside its region (gradient::carve::CODEC_BAND). quality-web (246 icons, the gate's distance axes): dE00 −0.01 %, turning_gap −5.38 %, ratio_gap −0.80 %, geom −0.10 %, 18 icons changed; 1 px read dE00 +0.21 % and 3 px geom +0.08 %. A clean intake is byte-identical. carve_residual_features_with_detail_noise takes the band as a new last argument.
  • The strip reading's corner test scales with the intake's noise on a soft Quality intake. A vertex is declined as a corner only beyond max(SIDE_TOL, 3·√(70 V)/12), three standard deviations of the one-sided fourth difference, with V from the trace's measured noise over the edge's contrast. The front end's soft-intake decision (wide edges, ringing or a lossy container) now reaches the sub-pixel refinement (RefineCtx::soft). Clean intakes keep SIDE_TOL and their bytes on all six clean gate conditions, and Fast, which measures no noise, is unchanged too. On quality-web 111 icons change and every gated axis improves at its point estimate (dE00 −0.14 %, turning_gap −0.02 %, ratio_gap −0.03 %, geom −0.03 %; docs/theory/chain-boundary.md, "strip.rs: a statistical side test on soft intakes").
  • --restore auto and --sr auto act only on input that shows damage of its own. Both decided on one signal, the input's residual against its own probe trace where the trace claims to be flat, and that reads smooth shading in clean art as damage: on the gate's clean opaque 512 px tier --restore auto restored 7 of 246 icons (all shaded Noto emoji) and read dE00 +4.49 %, geom +13.9 %. A restore now also needs a lossy container or JPEG ringing around the edges (coverage::ringing_score above the palette's gate, for a JPEG re-saved as PNG); SR's clean-up also accepts edges wider than a native render's. These are the soft intake's three signals. They are asked first, and the residual (which renders the probe at full size, the costly half: 16.5 ms of CPU on a 31 ms Fast trace at 512 px) only when one of them fires; both are needed, so the order changes no decision. Without one, --restore auto makes no probe and traces as --restore off does, and in Fast mode the sign is the container alone (the ringing score would add 2.5 ms to a 31 ms trace at 512 px). JPEG input decides as before (crates/inkvec-cli/src/damage.rs). A probe auto keeps also reports what the plain trace reports, its stage log and lambda; it used to print only the restore or sr line.
  • Faces are completed behind what is painted over them (Quality). A face partly hidden by a face or stroke painted after it was written as the crescent it shows: every edge of its cover traced again. It is now written as the cheapest shape in its painter's interval, V ⊆ E ⊆ V ∪ U (what it shows; that and what covers it). Within that interval every visible pixel stays as it was. The candidates are a circle, ellipse or rectangle; the face's own rings with each covered run replaced by the corner its owned lines meet at; a chord a margin under the cover; the run offset under the cover; a bulge; a corner cut (a chamfer a blur left, dropped and its lines extended under the cover); or a bridge (one G1 cubic continuing the visible boundary across the run). A completion that would fill the image's frame is never proposed: which face is the page is the emitter's decision. The lower bound reaches half a pixel under the cover, as the seams' underlap did, so a completed face needs no underlap.
  • Verification. The interval is proved in Lean (Design/Layers.lean, painter_interval_iff). A completion is accepted only by a checker generated into inkvec-verified (span_within, span_link; Gen/Design.lean, chain_cover, line_cover), on the solver's certificate of span covers on every scan line, in outward-rounded intervals.
  • Regression gate (246 icons, all 8 conditions, against the baseline, --no-tighten).
    • quality-128ss: dE00 −0.18 %, turning_gap −8.61 %, ratio_gap −0.12 %, geom −0.07 %.
    • quality-512ss: −0.94 %, −10.77 %, −3.64 %, −0.02 %.
    • quality-512ssop: −1.51 %, −4.34 %, −8.66 %, −0.01 %.
    • quality-web: −0.31 %, −3.84 %, −5.08 %, −0.00 %.
    • Fast output is unchanged.
  • Canary. openmoji England flag at 512 px goes from 54 to 20 parameters, the artist's count.
  • Against the artists' hidden geometry (bench/theory/amodal_eval.py, 121 emoji). A completed face's IoU with the artist's full element rises in 60 % of cases and falls in 19 % (median 0.49 → 0.52). The bridge gains most (median 0.59 → 0.75).
  • INKVEC_COMPLETION=0 switches the stage off; INKVEC_COMPLETION_DEBUG reports it.
  • Fast mode gives a JPEG's halos back to the inks they lie between (lossy intake only). A JPEG stores colour at half resolution, so an edge comes back with a band two or three pixels wide whose chroma has bled across independently of its luma: off the line between the two inks in sRGB (0.08-0.09, twice the blend tolerance), so Fast's palette founded it as an ink and its clean-up kept it, and one ink came back split into variants a few dE00 apart (an outline as #010101, #010009 and #080000). On a lossy intake (ColorOptions::lossy_intake) of an opaque image, two passes now run in the clean-up (fast/faces/halos.rs):
  • Halos. In JPEG's own colour space (BT.601 YCbCr) a thin component (no 5 × 5 window of its own) whose mean colour lies, channel by channel, between two inks around it or 20 % beyond them, is their halo, and each of its pixels goes to the ink it covers more of, read mostly from luma; a thin component of an ink that owns an area elsewhere must also be a strip lying within 0.04 of the line between them, which keeps a real four-pixel rim (twemoji/1faa3). Flecks of a halo ink inside a single ink go to it.
  • Ink variants. A component with no 5 × 5 interior merges into a neighbouring ink within 5 dE00 (the soft floor Quality's palette uses on the same intakes); one with an interior keeps the clean 1.5.
  • Regression gate, fast-web (246 icons): dE00 −11.8 %, turning −50.5 %, parameter ratio −42.8 % (7.22 → 4.13), geom −6.8 %, all demonstrable gains; openmoji's ratio 13.2 → 3.9, twemoji's 8.9 → 3.7. twemoji/1f1fa: 4 inks and 215 faces → 2 inks and 3 paths, dE00 0.242 → 0.144. The six clean conditions are byte-identical: neither pass runs on a clean intake, and neither costs anything there.
  • The regression gate is faster, with the same numbers. Icons whose SVG is byte-identical to the baseline's are traced but no longer scored (their numbers were the baseline's anyway; --rescore scores them, to check the scorer). What the artist's file contributes is cached by its hash in bench/data/_cache (the artist's side of geom: its flat render's palette, labels, edge length and near-edge mask; its design profile), dE00 converts only the sampled pixels where the two renders differ (an equal pixel's CIEDE2000 is exactly 0), the trace's flat render is labelled run by run, no render is saved when DISTS is off, and one process pool serves every condition. CPU per icon on the full set: quality-512ssop 3.99 s before, 2.84 s with a cold cache, 2.15 s warm, 1.43 s when every icon matches the baseline; fast-web 1.75, 1.03, 0.64 and 0.35 s (113 icons changed). The gate's own scoring of a changed 512 px icon fell from 2.58 to 0.72 s; the rest is the tracer. Every number is unchanged: old and new scorers on the same binary gave identical per-icon rows, summaries and verdicts on both conditions, cold and warm. Each condition's line now says where its CPU went (tracer, scoring, design battery) and how many icons were reused.
  • Stroke detection is on by default (Quality). Faces drawn as a centreline and one width are written as stroked paths wherever that is the shorter description at as good a fit; --no-detect-strokes (detect_strokes: false) turns it off, and INKVEC_RIBBONS=0 does for benchmark builds. It had stayed opt-in for its time, which is now 1.16× the trace at 512 px over the screen set (1.23-1.31× on lucide, material-icons, openmoji and simple-icons; the worst icon 0.8 s more). Two changes came with it:
  • Merges. After its splits, the stroke solve runs them backwards: every joint of a centreline is offered the line through its outer ends, an arc on either neighbour's circle (both's when they turn alike, or the three points'), and the cubic with the outer tangents; every cubic and arc the line on its chord; a closed path of arcs on one circle that circle. A merge is kept when the face's description length falls, unsolved or after a short solve, and the strokes stay within the decision's fidelity bound (ribbon/refine/merge.rs). The fitter's arcs stop at 120°, so an artist's 270° arc came back as three arcs and a short line at each cap; lucide closed-caption and iteration-ccw now carry exactly the artist's structure. A merged arc near a half turn is written as the half circle on its chord, and the solve refuses arcs within 15° of a half turn otherwise, where rounding the radius would move the drawn arc.
  • Faces that leave with a stroke. On an opaque page the inside of a stroked ring is its own face, painted the page's colour over the ring's disc. A face the stroke face alone encloses, painted exactly the colour of what lies beneath, now leaves the document with it and counts in the decision: a ring on white is one stroke over the page, not a black disc under a white one.
  • Regression gate against the stage off (246 icons, all demonstrable gains): dE00 −8.18 % quality-128ss, −8.57 % quality-512ss, −11.35 % quality-512ssop; parameters against the artist's 1.495 → 0.954, 1.728 → 1.168, 1.967 → 1.414; turning −6.1 %, −6.0 %, −5.6 %. Fast mode is unchanged.
  • fit_face takes the fidelity bound its merges keep (chi2_cap).
  • Sub-pixel boundaries are read from exact column sums (all modes). A pixel's coverage is the area of the shape inside it, so a column's coverages, summed from a pixel wholly in one face to a pixel wholly in the other, are exactly the boundary's mean height over the column, whatever its slope or curvature; four adjacent column means fix a unique cubic, which is the boundary wherever the boundary is a cubic. Stage 07 now slides each vertex between two flat fills onto that cubic (planar/strip.rs) before trying the per-vertex probes, which needed a normal they could only estimate from the lattice and read two-probe brackets at the ½-crossing, a reading biased by up to 3/2 − √2 ≈ 0.086 px even on a straight edge. A corner, a junction or a third colour in a column makes the strip reading decline, and the probes decide as before.
  • Stage-07 points on exactly known shapes (bench/theory): 0.073 → 0.011 px mean error on exact-area renders, 0.073 → 0.016 px on 8x8 supersampled ones; p99 0.32 → 0.18–0.19 px.
  • Regression gate (246 icons, against main): dE00 −10.96 % fast-128ss, −8.70 % fast-512ss, −9.83 % fast-512ssop, −4.51 % quality-128ss, −1.89 % quality-512ss, −1.36 % quality-512ssop (non-inferior); turning better in Fast and level in Quality; parameters −2.7 % fast-128ss, +1.9 % quality-128ss, within margin elsewhere. bench/cases Fast passes edge_axis too (28/42); Quality stays 42/42.
  • Time unchanged: whole-process median on 40 corpus icons at 512 px, Fast 22.3 → 22.7 ms, Quality 538 → 529 ms.
  • The theorems behind it are machine-checked (see Added); the reading's rational constants are proved exact (histopolate_cubic, strip_reading_exact).
  • probe_chord moved to planar/chord.rs, unchanged but for the seam fix below.
  • crates/inkvec/tests/api.rs restored_pixels_are_traced_with_soft_intake ripples at 12 levels instead of 9: at 9 the plain and the forced trace differed only by a 0.01 px centre error on the forced one, which the column sums no longer make; at 12 forcing soft intake changes the palette, which is what the test is for.

Fixed

  • The seam underlap no longer gives up on a curve that turns far (all modes). Whether a moved edge cut into the lower face was asked of the nesting's polygon of that face, four points per curve, whose chords stray from a long curve that turns far by more than the half-pixel reach: 0.55 px at the middle of a cubic turning 74° over 78 px. Where the curve bulged into the lower face, an edge moved the full reach outward still read as cutting into it, the whole run between two junctions went unmoved and the seam showed. The test now reads the lower face's own outline, flattened to within 0.01 px of its curves (seams.rs, fine_ring). A cubic between two extrema turns up to 90°, so this mattered more once the fit put nodes there (noto-emoji/emoji_u1f3c4_1f3fe at quality-512ss: dE00 0.0650, 0.0686 with nodes at extrema and the old test). Regression gate, against the tree with nodes at extrema and the completion stage (change; icons better / worse): quality-512ssop dE00 −1.31 % (43/1), turning_gap +0.10 % (28/33), ratio_gap +0.02 % (3/7), geom −0.01 % (5/0); quality-web dE00 −0.22 % (50/0), turning_gap +0.01 % (30/34), ratio_gap +0.02 % (11/11); fast-512ss dE00 −0.46 % (34/0), fast-512ssop −0.49 % (38/1), fast-web −0.11 % (26/2), fast-128ss −0.01 % (4/1), with ratio_gap up to +0.01 % and turning_gap up to +0.02 % in Fast: the runs that now move gain the underlap's taper vertices.
  • --editability's G1 pass turned the wrong handle. Since it was written, at each join it turned the incoming cubic's first control point, at that cubic's far end, instead of the one at the join. Within the old 3° window the harm was small; with the window at 30° (above), a join it smoothed kept its kink and the far end gained one (material-icons/dining: 0.45 of its joins between curves kinked before the fix, 0 after). Node alignment also moved nodes without their handles, so a handle a pixel long turned by up to 15° for a 0.3 px move and a smooth node became a kink; a moved node now carries its handles.
  • The seam vertex of every closed ring is refined (all modes). A closed edge stores its first point once, and the lattice normal clamped its neighbours at the ends of the edge, so the seam took a one-sided chord: on a ring whose seam lies on a horizontal run, a horizontal normal on a horizontal boundary, and the vertex stayed on the lattice, 0.30 px off on an exact disc. Quality's boundary solve repaired it; Fast kept it. The chord now wraps round the seam. Within noise on the gate (the corpus's rings mostly start at a junction or a corner).
  • Thin strokes on a transparent ground keep their ink (Quality). A stroke narrower than about two pixels covers few or no pixels completely, so the palette met its anti-aliasing before its ink, or never met the ink at all:
  • A 1-1.5 px line split across two rows (every pixel translucent) was dropped entirely. Such a colour, at least half opaque and refused only for having no interior, is now read as a hairline of its opaque version.
  • A 2 px diagonal came out as a translucent band around opaque specks, because its 0.83-covered pixels outnumbered its core and were accepted first. After the walk, a translucent ink whose opaque version is also an ink is weighed again as a blend of the two and dropped when it is coverage; a translucent wash with an interior of its own stays.
  • A thin face of an opaque ink whose pixels are all partial coverage of the ground is painted its ink, not the ink diluted towards white, so the boundary solve places its edges by coverage instead of widening it.
  • bench/cases sawtooth_diag and ribbon_w1.5 pass. On all 1386 icons of bench/alpha_eval.py 90 traces change and every family's mean error is level or lower (dark ground −0.45 % overall; simple-icons −0.79 %, openmoji −0.45 %, noto-emoji −0.23 %, twemoji −0.20 %). Fast output and the speed of both modes are unchanged.
  • A line too thin to cover a pixel keeps both of its edges (all modes). Where coverage across a boundary rises from the ground and falls back without reaching 1, the edge was read at the profile's 0.5 crossing, which for such a line is its centre line: a 1 px line between two rows came out half as wide. The edges are now the line's centre (the coverage centroid) plus or minus half its width (the coverage integrated along the normal). Regression gate: dE00 −1.6 % (Quality) and −0.6 % (Fast) at 128 px, level at 512 px. ribbon_w1 passes.
  • Symmetry the drawing does not have is no longer imposed (all modes). The label map can be symmetric at pixel resolution while the drawing is not, such as four colours meeting 0.27 px off the image centre. Each symmetry is now enforced only when the refined boundary confirms it: the 90th percentile of the paired points' disagreement must be within 3 standard deviations of their measured positions. Genuine mirrors measure 0.3-1.2 and contradicted ones 7.6-20. In Fast this drops four misleading symmetries out of 84 on a 500-trace sample, improving twemoji 1f7eb 4x and a flag 1.8x.
  • Go: integer remainders are right on arm64 (Apple silicon, Linux arm64). wazero's arm64 compiler (v1.12.0, the latest) defines a remainder's register twice, a division and then a multiply-subtract, and when register pressure spills it in between, the multiply-subtract reads a quotient that was never stored. Any of the module's 346 remainders could come out wrong; in this release's symmetry pass one became an index of 4294337216 into a 256-point boundary and the trace stopped (the 0.2.7 crash the first half-turn attempt hit was the same bug). The build now rewrites every remainder as a - div(a, b) * b (tools/wasm_wazero_fixes.py, formerly wasm_float_select.py), exactly: same results and same traps on 1323 edge cases, both architectures. TestWazeroRemainder reports whether wazero still has the bug (a 40-value module where 1000 rem_u 256 returns 1000).

Added

  • Human statistics: how a trace is drawn, against the artist's file (bench/inkvec_bench/design.py, bench/human_stats.py; reported, not gated). A battery of statistics of a file's construction, read on the trace and on the artist's own file, each as a per-icon divergence (zero for a trace drawn as the artist drew it): the segment-kind mix, smooth (G1) joins, nodes at the x/y extrema, cubic handles on the axes, kinked joins (3-30°), the share of corners, right angles and the corner angles, lines on the axes and on the diagonals, collinear nodes, each curve's sweep, handle lengths, symmetric handles, coordinates on the artist's half-unit grid, repeated values, mirror-symmetric nodes, primitives, strokes and repeated stroke widths, elements, subpaths, nodes per subpath, closed subpaths, segment lengths, colours, nested subpaths and shapes stacked over others, and as an artefact signal the share of numbers on the raster's half-pixel grid. 29 statistics are kept of 36 candidates: those correlated beyond |Spearman rho| 0.7 with one kept, over the screen set at quality-512ssop and quality-web, were dropped (design.DROPPED says which and why), and none of the kept reaches 0.57 with a gate axis. The gate takes it on every icon it traces (about 10 ms each), prints each statistic's family-macro divergence per condition and writes every icon's values to --report-json and --artifact-dir (human-<platform>.json). bench/human_stats.py traces without rendering and prints the per-family picture and the oddities, or compares two builds or flag sets icon by icon with a paired bootstrap per statistic and one composite to tune against (--exe ... --extra-args "--editability" --base-args ""). It is the one implementation of what the representation and node-placement work's own design_stats.py and node_stats.py counted; bench/theory/human_gap.py keeps only its per-face match, its turning and parameter gaps being the gate's own axes.
  • A machine-checked theory of inverting box-filter rasterisation (formal/InkvecTheory, Lean 4 + Mathlib, docs/theory/README.md): coverage defined as Lebesgue area; the column-sum theorem; the exact null space of the box filter (the layer-cake fibre of a column, and the zigzag as the one invisible deformation of a border-vertex polyline: the boundary solve's "sawtooth"); identifiability of polynomial boundaries from as many columns as coefficients; the cell-mean-to-point correction exact for cubics; the sharp 3/2 − √2 bias of the ½-crossing; minimax lower bounds for tracers that threshold or quantise first (½ px for thresholding); exact inversion of sub-pixel strokes that straddle a border and the bias of the coverage centroid; noise and supersampling versions. No sorry and no axiom beyond Lean's three standard ones (lake build InkvecTheory.Audit); a formal CI workflow checks both. A literature survey of the mathematics behind reversing rasterisation, and what of it had been formalised (nothing), is in the theory page.
  • Checkers generated from the proofs (crates/inkvec-verified, formal/InkvecTheory/InkvecTheory/Gen/). The engine's solvers stay free; a result a theorem speaks about is now accepted only by a checker generated from the Lean definition the theorem is about, in exact rationals or outward-rounded intervals, and CI regenerates the checkers and fails on any difference, so the code cannot drift from the proofs (docs/theory/verification.md compares six ways of linking them). Soundness is proved once for the expression language (evalQ_sound, evalI_sound). First kernels: strip.rs's cubic stencil is now generated (bit for bit the formula it replaced; output unchanged), and every vertex the strip reading moves must be proved to lie on the cubic the column sums determine (stripResidual_on_cubic) or it goes to the probes.
  • A geometric match to the artist's file in the regression gate (geom, gated at 2 %; geom_far reported). Both files are drawn flat at four times the input's size, every pixel takes the nearest of the artist's colours, and the area where they disagree, divided by the length of the artist's edges, is the mean distance between the trace's edges and the artist's, in input pixels (bench/inkvec_bench/geomatch.py). An edge moved by δ reads δ in any colour: a disc grown by 0.1 px reads 0.097 in dark red and in pale grey, where dE00 reads 0.021 and 0.0018 (bench/theory/geom_calibration.py). geom_far is the part more than an input pixel from any artist edge: missing or extra features. Current build: Quality 0.056 px at 128 px, 0.093 at 512 px, 0.097 at 512 px opaque; Fast 0.15, 0.32, 0.28 (about half of it geom_far). The baseline is re-recorded in format 2 (scorer version 4) for this axis; every other number in it is unchanged.
  • The gate judges turning and parameters as distances from the artist's file (turning_gap, ratio_gap, replacing the raw turning and ratio axes, same margins). Lower is not better everywhere: per canvas side the artists' files turn more than the traces of lucide, material-icons and simple-icons (3.35, 6.71 and 8.73 against 2.65, 4.70 and 6.89 at 512 px), and noto-emoji on the noisy tier writes fewer numbers than its artists, so a trace moving toward the artist used to read as a regression. turning_gap is |the trace's turning per raster side − the artist's per canvas side|, ratio_gap is |ln(parameters / the artist's)|, both family-macro means. They are derived from the stored per-icon rows, so the baselines kept every icon's numbers; only their summaries were recomputed. The raw values stay reported.
  • A noisy condition in the regression gate (quality-web, fast-web). The gate's other rasters are exact renders, noiseless but for 8-bit rounding; the web tier is each screen icon as logos usually reach a tracer: flattened onto white, resized from 512 to 400 px with bicubic interpolation (blur and overshoot at every edge) and saved as a quality-80 JPEG with 4:2:0 chroma (8×8 block quantisation, colour at half resolution), after the practical degradation model of Zhang et al. (2021, arXiv 2103.14006) fixed at one setting. bench/build_web_tier.py writes it; the 246 JPEG files (3.3 MB) are committed so every machine scores the same bytes. The scorer reads a .jpg intake and judges a file with no alpha channel over white, as an opaque tier.
  • Naturality, proved (formal/InkvecTheory/InkvecTheory/Naturality.lean): when a merge keeps the true boundary drawable, the merged least-squares fit is at least as close to the truth for every noise realisation (natural_model_closer), the gain is exactly the noise in the removed directions (natural_model_gain), the merge test reads only that noise (merge_chi2_increase), and each parameter keeps one unit of noise variance (noise_absorbed: Σᵢ ‖P bᵢ‖² = dim K).
  • bench/theory: attribution.py (where each icon's dE00 comes from: edge band by smooth, corner and junction, interior flat, shaded, blobs), params_diag.py (where the parameters go, against the artist's file) and naturality_diag.py (parameters an artist would not write: collinear vertices, straight cubics, co-circular arcs, broken continuations).
  • Noise measured at the edges, per image (Quality, soft or lossy intakes only). Where the colour path finds an intake soft or lossy (wide edges, ringing, a lossy container), the boundary evidence now measures the intake's noise from its own edges: rounding as the base, replica windows counted once, the rest from the fourth differences of consecutive window sums (winsorised, geometry gated out, per class), the tail as Student-t degrees of freedom, a point-sampling renderer's lattice read from axis-aligned edges. Above three levels of edge noise ColorTrace::noise carries the result as inkvec_core::noise::NoiseModel for the representation chain; below it, and on every clean intake, it is NoiseModel::clean(). Nothing in the pipeline reads it yet, so traced files are byte-identical to the previous build on all eight gate conditions; a clean intake does not even take the measurement. Folding it into each boundary point's σ is opt-in (INKVEC_NOISE_FOLD=1) until the fitter reads it with a robust loss: with today's fitter it trades fidelity for simplicity on web (dE00 +2.8 %, geom +1.7 %, turning −5 %, parameters −1.4 %). On a lossy intake the windows read luma (4:2:0 chroma bleeds), their inks are plateau medians, and they span the observed ramp. The truth's score on the web tier falls from χ²/M 150 to 3.1 (1.8 under Huber). The evaluator gains the blur forward model (½ μ₂ A'', window_sum_resampled and two companions proved in Windows.lean), Huber's robust cost, stroke bands and per-layer compositing at junctions (A2), and EdgeScorer, the dynamic program's window partition by point index (docs/theory/chain-boundary.md, "Phase 2, milestone 2").
  • The boundary evidence and its likelihood (inkvec_core::likelihood, inkvec_trace::evidence; not yet called by the pipeline, output unchanged): every partial pixel between two flat inks in exactly one column or row window whose unmixed sum is the left face's exact area there (window_sum_eq_area, proved), so any candidate curve (lines, Béziers, arcs drawn as usvg converts them) is scored by χ² over windows, calibrated at χ²/M 0.97 on exact 8-bit renders; per-pixel terms at junctions (with arms in cyclic order and possible continuations) and at corners; corner proposals from the fourth difference. Runs and corner proposals are accepted only by checkers generated from Gen/Evidence.lean. bench/theory/renderer_floor.py measures what the corpus intake adds beyond quantisation, per family and tier (docs/theory/chain-boundary.md, "Phase 2, milestone 1").
  • bench/theory: an exact-area rasteriser (font-rs's accumulation in float64) and the scripts that score boundary readings, and inkvec's stage 07, against exactly known geometry.
  • Half-turn symmetry. A drawing that is its own 180° rotation about the image centre, and no mirror image, now comes back exactly so (bench/cases mirror_r, Quality and Fast), through the same pairing, confirmation and averaging as the mirrors.

[0.2.7] - 2026-10-08

Fixed

  • Centerline tracing (Line art toggle, --strokes) recovers real-world line art and complex drawings (#25).
  • Width spread gate accommodates natural line variation. Stroke measurement previously rejected any stroke whose MAD width spread exceeded TAU_WIDTH * sigma_meas (only ~0.21 px), falsely rejecting uniform-looking strokes whose width fluctuates slightly (0.25–0.35 px, ~5–7%) from raster discretization, anti-aliasing phase, and natural artist pen pressure. Added DEFAULT_MAX_WIDTH_REL_SPREAD (20%) to Criteria and GRAPH_CRITERIA so realistic line art strokes are accepted while strictly continuing to reject true tapers (>35%) and filled shapes (>50%).
  • Multi-branch line art with close junctions is preserved. The CLI --strokes path now uses GRAPH_CRITERIA (min_edge_aspect: 0.5, spur_factor: 0.25), preventing skeleton edges between close junctions from being pruned by the former 3.0 aspect ratio floor.
  • Stroke balance gate incorporates round cap area. Round caps now add (π/4) * width² to ink_draw, matching the geometry SVG actually renders.
  • Skyline underflow fix. Fixed debug underflow on small image heights in ribbon/skyline.rs.
  • A stroke too thin to reach full opacity keeps its ink on a transparent ground (Quality). Box filtering leaves a 1.2 px stroke at 0.990 alpha, and the palette asked every ink below 1.0 for an interior it cannot have, so it traced the glyph from its anti-aliased fringe and lost both counters (bench/cases glyph_ring_bar). From 0.98 up an ink now counts as opaque. On all 1386 icons of bench/alpha_eval.py, 18 traces change (11 better, 7 worse) and the means are unchanged.
  • New tests pin what already held: the fitters and primitive fits are equivariant under quarter turns and mirrors, the boundary solve's gradients match finite differences, and degenerate input (empty, coincident, collinear, non-finite) is handled.
  • The test suites run anywhere. No test shells out to Python or reads the wall clock, the server's healthcheck tests no longer race on INKVEC_PORT, and cargo doc builds again (inkvec-fit's internal stages stay internal; their tests moved into the crate).

[0.2.6] - 2026-10-05

Changed

  • Quality mode traces much closer to the artist's file. Against v0.2.4 on the regression gate, Quality dE00 is −16.3 % at 128 px, −13.3 % at 512 px and −7.8 % at 512 px opaque. Parameters fall 1.9–4.2 % at the same time, and curves turn no more. Fast is −1.6 % / −0.2 % / −1.8 % with 1.6–4.3 % fewer parameters. Quality takes 1.17× v0.2.4's time at 512 px and 1.06× at 128 px; Fast's time is unchanged.
  • The boundary solve converges. It used to stop on the size of a backtracked step, often long before the optimum. It now uses a Moré–Thuente line search and stops on the projected gradient and the relative decrease (Byrd et al. 1995), within 64 iterations.
  • When a step folds two boundaries across each other, only the boundaries in the new crossing are backed off, not the whole solve.
  • Pixels that two boundaries share now count in the fit.
  • Art that touches the image border is padded by 2 px while it is fitted, then cropped. Edges at the border are now fitted like any other; before, they scored up to 20 % worse.
  • On its own, this change measures dE00 −12.2 % at 128 px and −6.2 % at 512 px, and −15.6 % on the held-out icons. Material icons, which regressed at 512 px in 0.2.4, are 4.9 % better. A trace takes about 1.2× as long at 512 px.
  • Gradients are recovered as the artist drew them more often. Radial gradient centres are searched under a free profile (after Chakraborty et al. 2025), not a straight ramp, with a Levenberg–Marquardt fit. Two regions are not merged across an edge. A gradient the writer would paint flat is refused, and its interior stops are placed together.
  • Of 168 gradient icons, 50 % are now recovered as one gradient (42 % before) and 24 % are painted flat (30 % before).
  • On its own, this change measures dE00 −3.9 % at 512 px.
  • A shape that is its own mirror image comes back symmetric. An outline that crosses its own symmetry axis is fitted on one side and reflected, when that describes it as cheaply. lucide/beaker's mirror residual goes from 2.1e-4 to 3.3e-5.
  • Upscaled input is undone before tracing.
  • A nearest-neighbour upscale by any factor of 2 or more, including non-integer factors like 3.5×, is inverted exactly, verified pixel for pixel. Before, only integer factors were. At 3.5×, dE00 falls from 1.21 to 0.35 and parameters from 14× to 1.3× the artist's.
  • Quality mode reduces smoothly resampled input (bicubic or Lanczos, 3× or more) back toward its source size, unless the image has native-sharp edges such as a glow. Bicubic 4× improves 33 %.
  • Clean input is byte-identical. --no-unblock and --no-soft-intake turn these off.
  • Crossing outlines are repaired locally. When two fitted curves cross, the fit adds a vertex beside the crossing instead of halving the whole edge's span. Fewer rings stay crossed (14 → 11 on the screen set), and openmoji icons use 4.5 % fewer parameters at 128 px.
  • Fast mode keeps sharper corners where the drawing has them. Its corner threshold went from 0.25 to 0.1 px: dE00 −1.5 % and parameters −4.3 % at 128 px, at the same speed.
  • --mode balanced (opt-in), between Fast and Quality. Fast's front end, plus the boundary solve capped at 8 iterations and a finer fitter. It closes 62 % of the error gap from Fast to Quality at 128 px and 37 % at 512 px, at Fast's parameter count, in 1.2–1.7× Fast's time (Quality takes about 20×). Above 1024 px it runs as Fast. Studio offers it as a third engine.
  • --detect-strokes (opt-in) writes stroke-drawn shapes as a centreline and one stroke width. Line icons come back near the artist's own parameter count. On the gate: dE00 −9.4 % at 128 px and −6.8 % at 512 px, parameters −20 % to −32 %, for about 1.5× the trace time. It stays opt-in until that time comes down.
  • --hypotheses (opt-in) traces three structural alternatives on hard icons and keeps the one with the shortest description (Rissanen 1978): blend absorption off, a matte instead of native alpha, a tighter merge distance. dE00 is −6.0 % at 128 px for 3.6× the time.
  • Quality mode's palette matches the artist's more often. On the regression gate, dE00 is −2.0 % at 128 px and −1.4 % at 512 px. The 512 px opaque condition and Fast mode are unchanged.
  • One ink stays one colour on upscaled input. A resampling filter's overshoot rim around a shape was admitted as a second, slightly brighter ink. Such an ink now needs an interior of its own. On the gold crest sample, three golds and notched dots become one gold and 14 clean circles, and the file is 64 % smaller.
  • Flat fills of one ink are written as one colour. A flat face within 0.5 dE00 of its ink's best-evidenced face takes that face's colour. On upscaled input, duplicate fills fall by half.
  • A rare colour is kept when the image needs it. A rare candidate becomes an ink when enough of its pixels cannot be explained as a mix of the inks around it, after Aksoy et al. 2017. Before, admission depended on the colour's share of the whole image.

Fixed

  • The regression gate's turning axis measures what it says. It read every x,y pair in a path as a point. An arc's radii and flags became points, and most traces write arcs: 215 of the 246 screen-set traces. One polyline also ran through every subpath, and <rect> and <circle> were not read at all.
  • Paths are now read command by command, arcs as the cubics they stand for, each subpath on its own, primitives included (scorer version 3).
  • The v0.2.4 baselines were re-recorded with it on both platforms. dE00 and ratio are unchanged.

[0.2.5] - 2026-10-03

Changed

  • The regression gate measures where users are, and can fail again. Its baseline had gone stale enough to pass a 13 % regression.
  • Conditions: the gate scores the 246-icon screen set in six conditions: Quality and Fast, each at 128 px, 512 px with transparency, and 512 px opaque.
  • Baselines: per-platform baselines from v0.2.4.
  • Verdict: each change gets a paired bootstrap interval (Koehn 2004) and a non-inferiority test (Lakens 2017). Margins are dE00 1 %, turning 2 % and parameter ratio 3 %.
  • No "inconclusive": where the set cannot resolve a margin, the margin is floored at the smallest change it can detect (Card et al. 2020), and such a pass is reported as "within-noise".
  • New floors in CI: coverage and mutation floors, and the 42 hard cases as a ratchet.
  • Scoring fixes: the reference-render cache now scores the same warm or cold. gt_diff.py no longer ignores inherited strokes.
  • Quality mode fits curves faster, with the same output.
  • The curve-merge search screens candidates on a provable bound (Bei & Gray 1985) and samples from cached partial sums.
  • The image frame's dynamic program is skipped when the rectangle provably wins.
  • Each boundary's primitive search runs beside its dynamic program.
  • Curve fitting takes 0.60× the time at 128 px and 0.80× at 512 px. Studio Lite's whole trace takes 0.79×.
  • Studio Lite is faster and runs on every core.
  • A bounded thread-caching allocator (after TCMalloc and Hoard) removes the global lock that made the threaded build slower than one core on gradient icons. Traces take 0.52–0.75× the time, with identical output.
  • The compiled WebAssembly is cached by the browser: a repeat visit's first trace goes from about 2 s to 1 s.
  • The thread pool starts after the app is shown: cold boot 2.6 → 1.4 s.
  • On the Space, the Studio opens in its own tab, where it can use every core; a dropped file follows in the URL fragment.
  • A large image shows a Fast draft first: the first drawing at 2048 px takes 0.5–0.7 s instead of 3–22 s.
  • Rings are wound by their nesting depth, and SVGs no longer need fill-rule. Files render the same in tools that ignore the rule (85 rendered wrong before) and are 3.3 % smaller. Where two same-colour rings overlap by a sliver, 9 icons now paint that sliver, which matches the artist in 6 of the 9.
  • --minify keeps arcs. The screen set shrinks from 541 KB to 478 KB, pixel-identical; SVGO now finds 1.1 % more to remove, not 12.6 %.
  • A gradient is drawn flat only when every stop is imperceptible, not just the two ends. At 512 px, 7 icons are better and none worse.

Fixed

  • --layers:
  • It turned some hair near-black.
  • It painted layers front to back. On its own test case dE00 was 2.90; it is 0.05 now.
  • Studio accessibility:
  • 0 axe violations in every checked state, light and dark (24–90 before);
  • the export sheet and other panels are real modal dialogs;
  • keyboard pan and a keyboard wipe slider;
  • a usable phone layout;
  • a bad first file shows an error instead of nothing.
  • Studio:
  • The status line names the engine that drew the result; a Fast draft was labelled Quality.
  • "Open in its own tab" brings the open image along.
  • The phone tip no longer covers Export.
  • A photo shot sideways is shown at its upright size, and previewed as the tracer reads it.
  • A lone small shape on a clear canvas is drawn. A 50 px² disc on a transparent 128 px canvas came out as an empty SVG; two guards each discarded it. The palette's rarity floor now lets the first visible ink through. The intake no longer reads a near-empty image as 8× oversampled: the round trip must also keep the image's detail.
  • Fast mode no longer collapses a run that turns back on itself. A piece that U-turned left the curve optimiser with no valid path, and the whole run became one cubic: a radial gradient at 512 px scored dE00 10.6, and now scores 0.06. A ring is never merged into a single cubic.
  • Light paint features are not drawn invisible. The carve stage could name a light feature after the clear ground's ink and draw it at opacity 0; it now takes the nearest visible ink.
  • --sr auto without an upscaler traces directly and says so, as --restore auto does; --sr on still fails.
  • Input the tracer used to refuse, crash on or draw wrong. On the regression gate every output is byte-identical.
  • Photos taken sideways are traced upright: the EXIF orientation is applied. A rotated phone JPEG's dE00 was 2.9–17.6; now 0.03–0.44, the same as the untagged file.
  • Embedded ICC profiles are converted to sRGB. Gamma-1.8 PNGs average dE00 0.85 → 0.12. Files tagged with an ordinary sRGB profile are unchanged.
  • A file with the wrong extension (a JPEG named .png) is decoded by its signature.
  • A zero-wide image is refused with a decode error instead of a panic.
  • Large images: with --max-dim, decodes may use up to 1.25 GiB, so a 12000 px RGBA image is traced instead of refused.
  • Pathological images finish, in bounded memory.
  • The boundary solve is skipped when its tables would pass a memory budget. A 512 px pixel checker peaked at 557 MB; it now peaks at 35 MB. A 512 px stripe pattern crashed; it now traces.
  • The gradient merge has a deterministic work cap and checks --time-budget inside each wave. Eight photographs that ran past 10 minutes now finish in 39–78 CPU seconds.
  • Past 65,535 faces, the smallest regions are merged into a neighbour; before, they were all folded into one face.

[0.2.4] - 2026-10-01

Changed

  • Fast mode is several times faster, with the same output. It went through the same process as Quality mode: each hot spot was measured, described precisely, matched to published work, and rewritten. The papers are cited in the code, with labels saying whether the code implements a paper's method, was inspired by it, or is our own. On large images the wall time of a whole CLI run fell 3.6× to 4.2×:
Input Engine time Whole CLI run
7 opaque images at 2048 px 161 → 31 ms 245 → 69 ms
3 transparent images at 2048 px 157 → 26 ms 355 → 85 ms
51 images at 512 px 17.1 → 6.6 ms 42 → 26 ms
246 icons at 128 px 4.3 → 2.5 ms unchanged (process start dominates)
  • Region clean-up (slivers, rims, same-ink merge, specks, faces) now works on row runs instead of pixels, and writes the face ids once. At 2048 px it went from 76 to 4 ms. Methods: Lemaitre & Lacassagne 2020; Wu, Otoo & Suzuki 2009; Salembier & Serra 1995.
  • The palette builds its histogram in parallel over row runs. This is exact because sums of 8-bit values are exact in 64-bit floats. It also keeps state only for occupied bins (Podlozhnyuk 2007; Henriksen et al. 2020; Briggs & Torczon 1993). At 2048 px it went from 45 to 6 ms.
  • Intake reads the file once and widens 8-bit samples through a table, in parallel. Matting runs in place. The check for pixel-upscaled images now tests only the factors that divide the gcd of the positions where pixels change.
  • Transparent images skip alpha-ramp fits that provably cannot succeed, and fit the rest in one pass. At 2048 px the transparency pass went from 95 to 13 ms.
  • Shared with Quality mode: the planar map is built from row runs, with a radix-sorted incidence table (He, Chao & Suzuki 2008). Sub-pixel refinement runs in parallel per vertex (Blelloch et al. 2012).
  • The fitter skips polygon sides that cannot win (Morin & Marsten 1976) and scans exact straight runs in closed form. Long edges scan in parallel. At 2048 px it went from 26 to 5 ms.
  • Output: unchanged byte for byte in both modes on 1,051 traces, covering JPEG, WebP, 16-bit, upscaled and oversized inputs and the cutout, monochrome and no-background modes. The one intended change is the image frame, below.
  • Fast mode writes the image frame as a rectangle. It used to end in a small curve that cut off one corner. This saves 6 parameters on every opaque image with a background. dE00 is unchanged or better: 0.3641 → 0.3640 on the screen set, 0.3632 on held-out icons.
  • Quality mode is much faster, with the same output. Each hot spot was measured, matched to a published method, and rewritten so that it reproduces today's SVG byte for byte. The papers are cited in the code.
  • Palette and labels: worked out once per distinct colour instead of once per pixel (Celebi 2011; Swain & Ballard 1991). Each candidate's claimed pixels are computed once (Korn & Muthukrishnan 2000). Connected components use run-based union-find (Wu, Otoo & Suzuki 2009). The palette is 12× faster on the 246-icon screen set.
  • Curve fitting: the unread χ² of the ellipse candidate is gone. The curve merge stops scoring a candidate once it cannot win (Bei & Gray 1985), and never re-tries a run it has already turned down (Garland & Heckbert 1997). The fitting stage is 47 % faster on the screen set.
  • Curve-fitting DP:
    • A candidate curve is skipped once a provable lower bound shows it cannot win (Morin & Marsten 1976).
    • A curve's error stops being summed once the outcome is settled (Rakthanmanon et al. 2012).
    • The table is filled endpoint by endpoint, trying the likely winner first.
    • The DP's CPU is about halved on the screen set, and cut to 0.43× on the 2048 px globe.
  • Gradient fills: one set of predictions is shared by all three scores. The small solves run on the stack, and unions no longer copy their members.
  • Overall: the whole trace is about 1.8× faster on the screen set. On the 2048 px globe it went from 1.29 s to 0.84 s.
  • Quality mode traces closer to the source. The boundary solve now compares the SVG with the image over a narrow band of pixels round every boundary, not only the pixels a boundary cuts.
  • Each pixel's coverage is exact box coverage, accumulated row by row, so the score no longer jumps when a boundary leaves a pixel. The narrow band follows Chan & Vese 2001 and Adalsteinsson & Sethian 1995; the row accumulation follows font-rs and Manson & Schaefer 2011.
  • The solver is L-BFGS with a backtracking line search, run separately on each independent part of the boundary (Liu & Nocedal 1989; Nocedal & Wright 2006; Lewis & Overton 2013).
  • The fold guard counts crossings as a spatial join (Dittrich & Seeger 2000).
  • Screen set: mean dE00 0.1557 → 0.1425, better on 155 icons and worse on 82; the worst tenth 0.5009 → 0.4665.
  • Held-out set: mean dE00 0.1476 → 0.1366, better on 105 and worse on 45; the worst tenth 0.4601 → 0.4224.
  • DISTS improves on both sets. Path counts barely move: 1.488 → 1.503 × the artist's on the screen set, and 1.475 → 1.471 on the held-out set.
  • It is faster too. On 80 screen-set icons the solve went from 28.7 to 7.9 ms, and the whole trace from 162 to 131 ms. On the seven 2048 px images the solve went from 320 to 227 ms.
  • Fast mode's output is unchanged.
  • Gradient stops are placed on the 3-decimal grid the SVG writes them to. Samples are binned once, and every offset is priced exactly from running sums (Hudson 1966; Bai & Perron 2003; Chakraborty et al. 2025). The band merge on a gradient emoji went from 721 to 507 ms. Mean dE00 is slightly better: 0.15635 → 0.15572 on the screen set, 0.14802 → 0.14761 on held-out icons. Only gradient icons change.
  • The code explains itself.
  • About 900 functions across the engine, the bindings and the Studio gained or improved their doc comments: they say what they compute. The mathematical ones give the formula with every symbol defined, the units, why the method was chosen, and the edge cases.
  • Every source file opens with where it sits in the pipeline.
  • inkvec-trace's crate docs list the Quality pipeline stage by stage, and native.rs has a table of the functions it mirrors for transparent images.
  • Wrong or stale comments were corrected where they had drifted onto the wrong item or described the opposite sign.
  • Long functions split into named stages.
  • The SVG writer (emit_color, 1,011 lines) and the colour finish (finish_color, 639 lines) are now documented stages with small context structs, as are the gradient band merge, the sub-pixel refinement, the palette, smooth-join snapping and about 40 others.
  • Functions over 100 lines: 38 → 8. The longest file: 1,722 → 1,693 lines.
  • The module cycle in inkvec-cli is gone.
  • Output is unchanged, byte for byte. Checked in Quality and Fast on the 246-icon screen set, in monochrome, editable, minify and line-art modes, and on 582 minify and fabrication cases.
  • Library code no longer prints or panics on the way. Debug output goes through one gated channel. Unwraps in inkvec-fit and most of inkvec-trace became real handling or a stated reason.

Added

  • Studio tests.
  • 118 unit tests (Vitest), run in CI.
  • The trace bookkeeping is now a pure module (lib/traceflow.ts) whose every race is tested: crossing start replies, results arriving before their start reply, Cancel undone by a queued trace, stale generations.
  • main.ts and rail.ts are split into smaller modules.
  • 42 engine tests for the upscaler, denoiser, WASM and server code, which had the least coverage.
  • tools/prepush.py, the no-build CI checks in about a minute, and tools/install_hooks.py to run it before every push. See CONTRIBUTING.md.

Fixed

  • Studio: the tenth preset's tooltip names its shortcut, Ctrl+0.

[0.2.3] - 2026-09-28

Added

  • Black & white and black-on-transparent output (--monochrome, option monochrome; issue #24). The finished colour trace is redrawn in two tones: the artwork as one black even-odd path, the background white -- or, with --no-background, fully transparent, so the counters of letters are real holes. The edges are the colour trace's own, so there is no pale anti-aliasing fringe and a light colour on white (yellow) is kept. The background is the colour covering most of the image border (or transparency); a shape takes the opposite tone of what it sits on unless it is the same colour, so white-on-black art comes back as black letters and lettering on a coloured panel is knocked out of a black panel. Works in both engines. --bilevel (tone by lightness) is unchanged and takes precedence.
  • Studio: One colour and its presets. A "One colour (black)" switch under Colour, and two presets and wizard tiles: One-colour logo (black artwork on white) and Black, no background (black artwork on transparency). The older two-tone mode is renamed to say what it does, Two tones by lightness / the Two-tone scan preset, so the two are not confused. Cmd/Ctrl+1 to 9 and 0 pick the ten presets.
  • Studio: paste an image with Ctrl+V (Cmd+V) in the desktop app, as Studio Lite already could: a screenshot or a browser's "Copy image" opens without saving it first.
  • Studio: dark artwork on a transparent background is previewed on a light checkerboard, as the Minify tab already did, so black-on-transparent output is visible on the dark stage. Only the preview changes; the SVG stays black on true transparency.
  • Studio: the colour difference ignores a background removed on purpose. With Transparent background on, pixels the SVG leaves fully clear are left out of the dE00 numbers and the difference map; the removed ground used to read as a mean dE00 above 30 (a black signature on white scored 31.9).
  • Studio: live progress while a trace runs. The elapsed time counts up ten times a second from the moment a trace is asked for, and the stage the engine is running shows the moment it starts, with its own running time and, for the long loops, how far through it is (boundaries fitted, boundary-solve iterations, gradient merge rounds, palette candidates, ring-repair rounds). An engine log in the bottom right of the stage lists every stage with its start time and duration and what the engine noted on the way (inks found, gradient fills, faces and boundaries, the solve's energy); it collapses to one line and keeps the last trace's log once it has landed. Both the desktop app and Studio Lite.
  • Engine: inkvec_core::progress, a per-trace progress and cancellation seam: stages report when they begin as well as when they end, the long loops count through their work, and a cancelled trace unwinds at its next report. With nothing installed (the command line, the library, every binding) the output is byte-identical and each report is a thread-local read.

Fixed

  • Studio: a new trace stops the old one. Changing a setting, a preset, the engine or the image, or pressing Cancel, used to leave the abandoned trace running to its end, holding the only trace slot, so the trace wanted next waited behind it: up to the whole length of a long Quality trace. The old trace now stops at the engine's next report (median 0.66 s on a 1672 px poster in Quality under load, against a median 47 s wait before; Fast about 10 ms). A trace already making the very drawing asked for (a small image's draft before its final, a Minify or Margin change) is let finish and then served from the cache. In Studio Lite a superseded trace is stopped by replacing the engine worker once it has run as long as a restart takes, and at once on Cancel or a new image.
  • Studio: batch Cancel and wizard previews stop the trace in flight, not only the next.
  • Studio: races around the trace in flight. Cancel no longer comes back 800 ms later as the queued full trace; a trace finished so fast (a cache hit) that its result arrived before the reply to the command that started it no longer leaves the Studio tracing for ever; two starts whose replies cross can no longer put the older one in charge; a snapped repaint of an older result no longer ends the trace running after it; opening an image mid-trace no longer leaves the old trace's state behind.

[0.2.2] - 2026-09-27

Changed

  • Fast mode is as fast as VTracer, with far fewer points. A 1672×941 poster traces in 0.21 s (it took 2.4 s; VTracer 0.27 s) with 8,798 coordinates instead of 47,548 (VTracer 26,306), and closer to the image (dE00 0.989 -> 0.905); a serif "G" that took 677 anchors takes 29. Anti-aliased rims around small text merge into the inks on either side, inks the eye cannot tell apart merge, and the speckle floor grows with the image, so outlines are no longer cut into short pieces. Over 13 images Fast is 0.81x VTracer's time at 1024 px and 0.86x at full size, and writes fewer coordinates than VTracer on every test set.
  • Writing the SVG is much faster in both modes: working out which face lies inside which measured every ring again for each pair of faces; each is now measured once (1.6 s -> 6 ms on that poster). The output is unchanged, byte for byte.
  • Studio: the Tune tab shows what the chosen engine reads. In Fast, the eight controls only Quality uses (precision, time limit, fewer paths, repair crossing rings, curve cost, smooth-join angle, match repeated shapes, match threshold) are hidden, and one line says how many more Quality has. They come back under Black & white or Line art, which take the same route in either engine. Hidden controls keep their values and presets still set them. Which control each engine reads was measured by tracing both engines at different values, and is held in one place with a test against the engine's own list. The Custom wizard follows the same rule.

Fixed

  • Fast mode no longer paints black text in another ink's colour. The grey edge pixels of dark text on a light ground are nearer a saturated colour used elsewhere (a red, say) than they are to black or to the ground, so they were labelled with it; and text too thin to have any flat pixels never got a black ink of its own. A pixel now takes an ink it could be made of, and a thin colour becomes an ink when enough of its pixels touch their own colour and it is not a mix of two others. On 50 brand logos the invented-colour pixels go from 5,799 to 0 and mean dE00 from 0.128 to 0.108; Fast's mean dE00 on the 246-icon screen set goes from 0.375 to 0.364. Quality mode is unchanged, byte for byte.
  • Studio: Reset on the Detail group no longer switches the engine back to Quality.

[0.2.1] - 2026-09-27

Added

  • Studio: Quality or Fast, one choice beside the Denoiser in the Result rail, in the desktop app and in the browser. Quality is the tracer as before and stays the default; Fast traces each shape in one pass, for previews, batches and very large images. The choice is remembered with the other trace controls, and in Fast the Tune controls that steer only Quality's stages say so.

Fixed

  • Inkvec Studio for Apple-silicon Macs is in this release. 0.2.0 shipped without its .dmg and .app: the build script expanded an empty argument list, which the macOS runners' bash 3.2 treats as an error, and the release carried on without that row. The Intel Mac app, and the command line on every platform, were not affected.
  • The Studio's third-party notices come out the same on every machine that writes them; build scripts and proc macros, which cargo resolves for the build machine, are listed once for all platforms and marked as build tools.
  • Continuous integration is green again: the PHP and Studio tests know the mode option, the bundled guide is checked with the Markdown and Pygments it was built with, and the Studio's Rust is formatted.

[0.2.0] - 2026-09-26

Added

  • Fast mode (--mode fast / mode: "fast"): A Potrace-speed mode that skips subpixel refinement, boundary optimization, and iterative MDL segment fitting while sharing the planar map, color palette, and native transparency handling. Ideal for real-time preview and throughput-critical pipelines.
  • Fitting and gradient evaluation optimizations:
  • Cubic::dist2_lanes: Projects four sample points onto cubic Bézier segments in parallel.
  • FillEval: Hoists invariant color-stop linear conversions and ellipse rotations out of inner pixel loops.
  • Parallel knot grid candidate evaluation in fit_mid_stops via Rayon.
  • Comprehensive test harness & CI policy:
  • 25 unit tests pinning CIEDE2000 against Sharma's 34 published reference pairs.
  • 13 unit tests covering boundary solve subpixel refinement.
  • 20 unit tests covering linear, radial, and elliptical gradient fits.
  • 33 unit tests for native alpha palette, resolving edge blend slivers and stop composition.
  • deny.toml policy asserting clean license compliance with MPL exceptions.
  • cargo-machete automated dependency audit integrated into the quality ratchet.
  • Colour groups (--merge-colors, and merge_colors in inkvec::Options, so every binding and the HTTP service have it from the generated schema): draw several fills as one, so the shapes between them join instead of staying separate faces with a traced boundary between them. '#c0392b,#e74c3c;#2c3e50,#34495e' merges each ;-separated group into its most-used member, =#hex into a named colour, =@n into the n-th member; a gradient is its stops joined by >, and a group whose target is a gradient becomes one gradient of that kind fitted to each merged region's own pixels. Colours are matched to the nearest fill of a first trace, so a colour picked off the output works; the merged image is traced again, so a run with groups costs one extra trace and a run without them is unchanged (246/246 gate icons byte-identical). The code generators learned string options for it (Python, TypeScript, Go, Swift, PHP, C#, Java, OpenAPI), and the server passes string query parameters through instead of dropping them.
  • --uncertainty <file> (and --uncertainty-k, default 2): also write where each traced boundary could be, as an SVG that overlays the trace -- every edge a band k sigma either side, sigma measured from the pixels, mean and max sigma as data attributes. Colour path only; the trace itself is unchanged. On a clean 128-px emoji the median edge sigma is 0.09 px; the same image as JPEG q20 reads 0.50 px.
  • inkvec-fab, a new crate, and Studio's Fabricate tab: prepare any SVG for a cutter, in millimetres. Modes: one colour, layered vinyl (each colour runs under the ones above by the bleed, with registration marks), inlay, print-then-cut sticker (offset contour, print bleed), stencil (islands bridged), and Lines (pen, scoring blade or laser line: each line drawn once along its centre, strokes in the file used as drawn, filled shapes through the tracer's centreline analysis with a per-region fit check). Kerf compensation, mirror for heat-transfer vinyl, weed border, thin-part removal, a size-check square, file units in mm or pixels at 96 or 72 per inch. Preflight draws what will go wrong (parts and gaps narrower than the minimum feature, specks, gradients and translucency, node counts) before it goes wrong. Output: an SVG per sheet and a combined file (contours refitted as lines and cubics), DXF R12 with curves as true arcs (biarcs written as polyline bulges), GRBL G-code with G2/G3 arcs (laser mode, power, speed, passes, holes first), and dogbone reliefs at inside corners for a router bit, with a warning where the bit cannot reach.
  • Studio: colour groups in the palette. The palette shows flat and gradient inks (gradient inks with the same stops count once, and each ink's share is measured from a render, not credited to the nearest flat colour). Drag an ink onto another to group them, onto a group to join it, out to leave; without a pointer, tick inks and press Merge. A group becomes its most-used member, a chosen member (a gradient member is extended over the group) or a custom colour, and re-traces like any control. Suggestions are proposed from each full trace (complete-linkage clustering at dE00 2.0; gradients compared by their stops) and never applied until accepted. Groups belong to the open image: presets, saved preferences and batch never carry them. Hovering or focusing an ink, group or member singles its fills out in the vector pane.
  • Studio: Certainty view beside Fill, Wireframe, Anchors and Handles: the engine's confidence bands over the trace, coloured sure (under 0.1 px), soft (under 0.3 px) and unsure, with a legend.
  • Studio: Auto or Custom on every image opened. The automatic trace starts as before, and a card over the stage offers to keep it or customise; Custom walks through what the image is (live previews of each preset), clean-up (for lossy or noisy input), colours (the colour-groups palette), detail and shape, side by side with Auto's trace. "Auto chose" explains what Auto used and offers one-click alternatives. Settings has "When an image is opened": Ask, Auto only, or Custom.
  • Studio: rebuild a messy SVG by re-tracing it. Vectorize opens an SVG, and the Minify tab has "Rebuild it clean by re-tracing": the SVG is rendered at 1024 px with exact edge coverage and traced as any other source. On 20 emoji run through VTracer 0.6 the rebuild has 40% fewer nodes and 25% fewer paths, and is as close to the artist's file or closer (mean dE00 1.393 -> 1.356).
  • Studio: a "Trace transparency" switch (Colour group), on by default as before; off composites onto a matte first.

  • Inkvec Studio Lite: the Studio's whole interface in a browser tab, for the Hugging Face Space. The backend's platform-independent half is now a shared core (studio/core) that the desktop app wraps in Tauri and the browser wraps in a Web Worker as WebAssembly (studio/wasm, threaded where the page is cross-origin isolated); the frontend switches transport at build time and keeps every command and event. Vectorize (drafts, the wizard, palette and colour groups, export as downloads), Minify, Fabricate and the denoiser (ONNX Runtime Web) work; batch, the command-line install, the context menu and the update check are desktop-only and hidden. Full screen and "Open in its own tab" in the top bar. npm run build:web in studio/ builds the site; scripts/deploy-space.py uploads it. In Hugging Face's frame a thin rule separates it from their header; "Inkvec home" returns to the front page and "Desktop app" points to the releases. It has a loading screen of its own, one bar driven by the engine's real download and start (it opens in about 1 s instead of about 3 s). The denoiser's model and ONNX Runtime Web download in the background from the moment the page loads, into Cache Storage (not with the browser's Save-Data on); a trace that wants the denoiser sooner shows the download's progress and traces again when it is ready.

  • Studio: choices are remembered, in the desktop app and in the browser: the trace controls, the preset, the tab and rail tab, the viewer's modes, export, Minify and Fabricate choices, and the desktop window's size and place. Each value is read on its own, so one bad value no longer resets the whole file; Reset keeps saved presets.
  • The Space's front page: what Inkvec is, what is new, and a before-and-after gallery of 92 cases in one scrolling panel (50 brand logos, the benchmark's 21 cases, 21 more icons and emoji, chosen by a fixed seed rather than by result), with fill, wireframe, anchor and handle views, a wipe and an A/B flick against VTracer 1.0 and Trazor, and the results tables. tools/showcase_data.py rebuilds it with any engine build. The Studio (desktop and Lite) has the same gallery as its Showcase screen. Third-party logos are shown for comparison only; see NOTICE.

Changed

  • The engine no longer takes settings from the environment. The crates read 127 distinct INKVEC_* names, 115 of them in the engine and command line, through four copy-pasted helpers with three meanings of 0. Now: 47 were tuned constants nobody set and are named constants next to their use; 35 switch experiments and are read only by a binary built with --features research (the decode stage, saddle merging, the structural simplifier, axis and G1 snapping, the free cubic, junction wedges, lossy re-labelling and the ink_ideas rules, with their knobs); 33 stay -- diagnostics that never change the SVG, and the A/B switches the changelog or a benchmark script uses (INKVEC_BOPT=0, INKVEC_NO_CARVE, INKVEC_NO_ABSORB, INKVEC_NO_TAPER, INKVEC_GRAD_REGIONS=0, INKVEC_UNDERLAP, INKVEC_NATIVE_ALPHA=0, INKVEC_EMIT_DECIMALS, INKVEC_HARMONIZE_TOL); 12 are deployment or test settings (server port, model paths, contract re-record) and are unchanged. Every one left is read through inkvec_core::env, once per process, and a switch is off when unset, empty or 0 -- so INKVEC_NO_CARVE=0 now leaves carving on, where it used to switch it off. JDBG and TAPERDBG are INKVEC_JDBG and INKVEC_TAPERDBG. The full table is docs/internal/env-vars.md, and bench/quality.py fails if a new std::env::var appears outside the helper. Default output is byte-identical (1,290/1,290 traces across 6 suites), and the release inkvec binary is 5.44 MB (-202 KB / -3.6% vs 0.2.0).
  • Library API behind the research feature now: inkvec_trace::decode, inkvec_trace::merge_saddle_faces / SADDLE_SIGMAS, ColorTrace::decode, inkvec_fit::merge::{snap_axis_aligned, snap_smooth_joins, PARAMS_AXIS_LINE, PARAMS_SMOOTH_CUBIC} and everything in inkvec_fit::structural except eval_segment. inkvec_fit::cost::CostModel::STANDARD is new.
  • Gradients are recovered per region. A gradient the artist drew across several shapes is fitted to each connected region's own pixels, instead of being painted flat or bent into one field: on the noto emoji set the share of gradient areas painted flat falls from 56% to 30%, mean dE00 0.3596 -> 0.3397 and DISTS 0.0652 -> 0.0576. Every other family is byte-identical. Gradient-heavy emoji trace about 1.3x slower. INKVEC_GRAD_REGIONS=0 switches it off for comparison.
  • Side-by-side fills no longer show a seam. Two fills that share an edge, painted next to each other over a third colour, each cover half of the edge pixel, and composited in turn they let a quarter of the ground through: a pale hairline along every such edge (pie slices, emoji shading). The face painted first now reaches half a pixel under the opaque face painted over it, tapering to the junctions and only where the upper face is thick enough to hide it. Seam pixels on the screen set fall 65%; the gate's dE00 improves 2.6% for 1% more bytes. Traces change on purpose (bindings/contract/cases.json re-recorded); INKVEC_UNDERLAP=0 switches it off for comparison. Along an edge that mixes lines and arcs the arc's ends now move with the underlap too (they were pinned, so the reach shrank to nothing beside every arc): the hairlines between gradient faces go (one emoji's hair 1723 -> 1048 seam pixels, worst 0.281 -> 0.076), and seam pixels over the screen set fall a further 15%.
  • The clear ground does not count towards the colour cap. Traced natively, a transparent image's clear ground is an ink of its own, and --colors 4 left three for the artwork. On 10 transparent emoji at --colors 4 mean dE00 goes 0.457 -> 0.186, better than compositing onto a matte (0.230). Nothing changes below the cap (246/246 gate icons byte-identical).
  • Faster, with identical output (each change checked by SHA-256 of the traced SVGs, at several thread counts, against the build before it):
  • the multimodel curve fit fills its dynamic program in parallel blocks, replayed in order: on 16 large images fit_dp 16.2 -> 10.8 s and the ring repair 14.5 -> 5.1 s;
  • the palette reads only the pixels it samples and reads its settings once per palette (7.3 -> 2.2 s over the same images), and the boundary solve clears and scans only the cells it filled (5.3 -> 3.3 s);
  • Studio measures the quality report in parallel and outside the trace slot, decodes each source once, and traces a small image's draft once instead of twice; toggling Minify or Margin no longer re-measures the drawing (and Margin no longer moves the report's numbers). The main window appears about 0.4 s sooner;
  • Studio's viewer redraws once per result, builds overlays only when they are shown, paints anchors on a canvas, and fetches the certainty bands on demand: a result on a 325 KB drawing is applied in about 25 ms instead of 0.6-1.1 s, and a zoom step with anchors on in about 25 ms instead of about 270 ms.
  • --restore auto with no restorer to run (a build without the network, or weights that are not on disk) traces directly and says why in the report lines, instead of failing the run. --restore on still fails without one.
  • The desktop app is now called Inkvec Studio; "Lite" names only the browser build. Its bundle identifier and settings folder (inkvec-studio) are unchanged, so preferences carry over. On Windows the .exe installer removes an existing "Inkvec Studio Lite" (the per-user .exe install silently, an old .msi through msiexec) before installing, asks first if it is open, and keeps the right-click entry and the inkvec command if they were on. The .msi installer does not: after it, remove the old one from Settings, Apps.

Fixed

  • Studio: opening a file from the system's context menu, or launching the app a second time with a file, now opens that file.
  • Studio: the wireframe stays one pixel wide at every zoom (it was as wide as the zoom factor).
  • Studio: brand-colour snaps belong to the image, so the export (SVG, minified SVG, PNGs, favicons, palette.json, the asset pack) and every re-trace keep them; pasting a brand palette snaps every colour, not only the first.
  • Studio: exported PNGs keep the drawing's proportions (they were square); favicons and the .ico stay square with the drawing centred on transparent padding.
  • Studio: a pasted rgb(r, g, b) or rgba(...) is one colour, not three pieces.
  • Studio: a batch row set to the Editable preset traces as Editable.
  • Studio: Reset settings keeps saved presets, and says so.
  • Studio: the notices cover the browser build, ONNX Runtime Web included.
  • Bench: an SVG whose root width and height disagree with its viewBox renders at a uniform scale; a non-square logo was squeezed (round dots became ellipses), which is what made some halftone logos look badly traced in comparisons.

[0.1.7] - 2026-09-25

A fix release: minifying anything the 0.1.6 tracer wrote was broken.

Added

  • svgmin in the WebAssembly build: the SVG minifier reachable from a browser. svgmin(svg, lossless, tolerance_px, judge, corner_degrees, decimals, document) returns { svg, report }; lossless picks inkvec_svgmin::compact (no segment moved, the picture out is the picture in) over minify (each path refitted within the tolerance), and the report carries bytesBefore/bytesAfter measured in UTF-8 bytes. Invalid input throws an Error with code = "invalid_svg". The committed web/pkg/ needs a rebuild to expose it.

Changed

  • Dependencies: kurbo 0.13, resvg 0.48, sha2 0.11, roxmltree 0.21, tower-http 0.7, svgtypes 0.16.
  • Releases are drafts. A tag push builds every archive and installer into a draft GitHub release that a maintainer reviews and publishes; nothing in CI publishes it. The two workflows that attach files to the same release (the C libraries, the Swift XCFramework) keep it a draft as well -- without that, the action they use publishes any draft it finds for the tag once its upload is done.
  • Version copies are checked. tools/check_versions.py fails CI when any file that holds its own copy of the version (the npm package and its lock, the Maven POM, the OpenAPI document, both wasm-pack packages, Inkvec Studio's manifests, both Cargo.lock files, the sibling requirements in [workspace.dependencies]) disagrees with Cargo.toml, and the release job refuses a tag that names another version. 0.1.6 went out with the npm package and the POM still at 0.1.5; they are at 0.1.7 now.

Removed

  • INKVEC_PARAMS_CUBIC and INKVEC_G1_BREAK: use --bezier-cost and --corner-angle.
  • INKVEC_CONTENT_SCALE, INKVEC_LAYERS: use --content-units, --layers.
  • INKVEC_MATTE, INKVEC_BOPT_ITERS, INKVEC_BOPT_MS, INKVEC_NOISE_SIGMAS, INKVEC_SAME_INK_DE00, INKVEC_SIGMA_SCALE, INKVEC_WOBBLE_PENALTY and the other tuning overrides listed as class (a) in docs/internal/env-vars.md.

Fixed

  • The boundary solve's gridline walk (boundary_opt::crossings) stepped m += 1.0 until it passed the segment's far end, which never happens when a coordinate is infinite or so large that adding one leaves it unchanged: a trace could hang instead of failing. Such a segment now contributes no crossings; a test with a deadline covers infinite, NaN and 1e20 coordinates.
  • Fixed native alpha palette bugs (blend_pairs index underflow, bin() coordinates, Fade composition, and fade_chi2 threshold) identified during mutation testing.
  • Fixed release draft notes overwriting in .github/workflows/release.yml.
  • Fixed rustdoc links under -Dwarnings, Java POM XML comment syntax, and PHP typed-options test coverage.
  • Minifying Inkvec's own output (inkvec-svgmin, Studio's Minify tab, the .min.svg export copy, the WebAssembly svgmin). Since 0.1.6 every trace carries a header inside <svg> whose <metadata> block has an rdf:about="" attribute. The document pass removed the <metadata> element whole and scheduled the removal of that empty attribute inside it; edits are byte ranges applied back to front, so the inner one shifted the text the outer one then cut, and the cut ran into the next element. The first path lost the start of its tag: the file was not XML any more, or a shape was gone. On the 246 regression-gate traces, every one was broken before and none is after. Nothing inside an element that is removed whole is edited now, and when two edits overlap only the enclosing one is applied.
  • inkvec-restore builds against sha2 0.11 (the weights digest is hex-encoded by hand; the string is the same).

[0.1.6] - 2026-09-22

Added

  • Non-minified output carries a generator comment and a Dublin Core <metadata> block (creator and source, both pointing at logolabs.org and the GitHub repository), both placed inside the <svg> root immediately after it opens. Invisible to every renderer; the document still starts with the literal bytes <svg and gains no XML declaration, since an SVG document is XML on its own and content placed before the root is the part most likely to be lost if someone copies out just the <svg>...</svg> element. --minify strips both, as it does everything else that carries no geometry.

  • --bezier-cost and --corner-angle, and a per-trace cost model in inkvec-fit (inkvec_fit::cost): what one Bézier segment costs the MDL objective (default 6 parameters; a line costs 2) and the turn at a join that is charged as a full corner (default 10 degrees). Lowering the first draws more curves and fewer straight segments; raising the second keeps gentler bends smooth. On the bundled logos a price of 3 with a 45-degree angle gives 82 curves instead of 48 on the crest, about 20 % more bytes and about 6 % less pixel error; the effect depends on the artwork. Until now these were process-wide and could only be set with INKVEC_PARAMS_CUBIC and INKVEC_G1_BREAK, which still work as the defaults. Output with neither option is byte-identical to before. They are options of the command line and of Inkvec Studio (Tune, Shape: Curve cost, Smooth-join angle) and are not yet fields of inkvec::Options, so the bindings do not have them.

  • editability, an option on the tracer (--editability on the command line, and a field of inkvec::Options, so every binding has it from the generated schema): post-fit passes that spend a little accuracy on structure an artist can edit — G1-smooth joins, handles snapped to the axes and to 45°, nodes that nearly share a coordinate made to share it, and rings that are their own mirror image locked into exact mirrors. Each change is guarded to the fit's own tolerance. Off by default.
  • inkvec_svgmin::structure, and structure_json in the WebAssembly build: how editable an SVG is, as three ratios — handles on an axis, smooth joins, nodes sharing a coordinate — measured from the file alone, so a drawing somebody drew and a drawing a machine wrote are counted by the same rule. Hand-drawn files sit at about 34%, 89% and 86% (median of 1,544 artist SVGs; cargo run -p inkvec-svgmin --example structure).
  • The Space has an Editable structure switch, and its stats count the three ratios.
  • Studio: an Editable structure control, an Editable preset (Ctrl+8), and an Editability card in the report that shows the three ratios beside where hand-drawn files sit.
  • Studio: a splash window covers the ~1-2 s startup instead of a blank frame, and the denoiser is now compiled into every build by default (previously an opt-in feature), so "Clean up damage" and the Photo-or-scan preset work without a separate download on first run. The one target that cannot have it built in, x86_64-apple-darwin, opts out.
  • Studio: at most one trace runs at a time — a control moved while a trace is in flight now queues rather than racing it — and a Minify or Margin change reuses the last drawing instead of re-tracing, since neither can change the geometry.

Changed

  • Studio's rail is two halves, Result and Tune, with a pinned readout of the colour difference, the coordinates and the file size and what the last control moved each by. The advanced controls no longer slide over the stage at narrow widths, groups fold and say how many of their controls have moved off the preset, and the preset tray is a row of chips. Empty Minify and Batch tabs are an invitation instead of an empty table, the overlay legend shows only while an overlay is on, and a view nobody has zoomed follows the window when it is resized.
  • Studio shows the size a trace actually ran at, not the Trace size setting: the setting is a cap, so a 622 px image was labelled "Tracing at 2048 px" even though the engine never traces above the source's own size.
  • The WebAssembly trace and prepare take the options as one JSON object, the same one trace_json takes, instead of twelve positional arguments. An option added to inkvec::Options now reaches the Space by the page sending its key.
  • estimate_noise reads the tenth percentile of the pixel Laplacian instead of the median, with the matching correction factor. The median assumes edges are rare, which fails on edge-dense art: a 622x480 line drawing whose strokes are a few pixels wide measured 53 display levels of "noise" where the trace's own flat interiors say 0.57, and every downstream tolerance divides by that estimate. Reported by an artist tracing dense line art and tracked down from their file. Byte-identical on every icon in the 246-icon screen set at 128, 512 and 1024 px (the estimate already sat on its floor); on JPEG and added-grain input, and on the reported file, only better (0.1582 to 0.1122 mean absolute error on that file).

Fixed

  • Studio: tooltips no longer stay on screen. The rail is rebuilt whenever a control or a trace changes, and a tooltip whose label was removed while it was hovered or focused never heard the pointer leave; clicking a label also focused it and showed the tooltip again. There is now one tooltip at a time, a click or Esc dismisses it, and it goes the moment its label is no longer under the pointer or the keyboard focus.
  • Studio: moving a control with the keyboard dropped focus after the first key press, because the control was rebuilt under it, and a trace's progress rebuilt the controls several times a second, which could cancel a drag on the next slider.
  • Release CI: the one Studio row with no bundled denoiser (Intel macOS, the target ONNX Runtime has no package for) passed --no-default-features straight to the Tauri CLI, which rejects it -- that flag is cargo's, and needs -- in front of it to reach cargo at all. Every tagged release back to 0.1.5 built that row this way and failed it silently as a result. Also: one platform's build failing no longer withholds every other platform's archives from the release -- build and studio each carry on past a failing row now, and the publish step ships whatever exists instead of requiring every file type to be present.

[0.1.5] - 2026-09-21

Added

  • Inkvec Studio Lite (studio/): a desktop app for Windows, macOS and Linux, built on Tauri v2 over this repository's own engine. It turns a raster logo into an SVG and minimises SVGs you already have, and it measures what it did rather than asserting it: the quality report's colour difference is CIEDE2000 between the raster the tracer saw and a render of the SVG it wrote, not an estimate. A "what this trace could not recover" panel states, from measurements, what tracing lost — lettering that came back as outlines, a lossy source, strokes baked into fills, a continuous ramp, features below the speckle floor — and says so plainly when nothing was. Everything runs on the user's own machine: the only outbound requests the binary can make are the update check and the optional denoiser download, and both say exactly what they send.

Three tabs (Vectorize, Minify SVG, Batch), a split comparison viewer with wipe, A/B and a pixel-grid detail mode, eighteen advanced controls taking their tooltip copy verbatim from the engine's own option documentation, brand-colour snapping, an export sheet with measured byte counts and a designed asset-pack README, and a share card rendered in the app. Dark and light themes at full token parity. Built and packaged on all three platforms by the new studio workflow, and released as .deb/.rpm/.AppImage, .dmg and NSIS .exe/.msi alongside the command-line archives.

The preset tray takes saved presets as well as the seven built-ins, the palette copies itself out as CSS custom properties, and detail mode carries a callout naming the worst corner it measured and offering to go there. The app draws the Inkvec mark, mono, generated from web/logo.svg so the two cannot drift.

The installers carry the inkvec command-line tool with them, and Settings can put it on PATH and add a Vectorize with Inkvec Studio entry to the image right-click menu — each row naming the exact path it wrote, with a Remove that takes it back out. On Windows the uninstaller removes both.

studio/src-tauri is a cargo workspace of its own, excluded from the root one, so cargo build --workspace keeps working without a webview SDK.

  • inkvec_trace::with_stage_sink: a thread-local hook that reports each pipeline stage boundary as it is passed, as a name and the milliseconds it took. Stopwatch::mark feeds it. Additive, a no-op when nothing is installed, and restores the previous sink on panic. It exists because a trace takes about a second and an embedder's only other options for that second are a bare spinner or a fabricated sequence of stages; the stage names are documented as explicitly unstable.

  • Documentation site (tools/build_site.py): the repo's docs rendered to static HTML in the LogoLabs design system and published to GitHub Pages (logolabs.github.io/inkvec) by the new docs workflow — the 14-stage algorithm series plus its plain-language diagram edition, the pipeline explanation with rendered equations, design, bindings, limitations, results, changelog and project documents.

  • inkvec-svgmin (crates/inkvec-svgmin): rewrite an existing SVG's paths as the fewest segments that draw the same picture, by the tracer's own minimum-description-length objective. Corners in the source are hard breaks and survive exactly; the tolerance is stated at a viewing size (--tolerance 0.1 --judge 1024); only d attributes change and a path that would not get smaller is left byte for byte. Whole subpaths that are circles, ellipses or rectangles are written as those elements, and the d text itself is minimised: relative or absolute per command, whichever is shorter, repeated letters and needless separators and leading zeros dropped, H/V/S where they say the same thing, and each number at the fewest decimals the tolerance allows. Everything that is not path data is shortened too — colours, numeric attributes, presentation attributes restating what is already inherited, style="fill:…" as an attribute, attributes shared by every child of a group moved onto the group, unreferenced ids, empty groups, comments, <metadata>, whitespace — by rules re-implemented from SVGO (MIT; see NOTICE), with --no-document to turn them off. <title>, and any <desc> somebody wrote, are never removed. On 40 corpus artist files: 23.9% of the numbers and 23.5% of the bytes removed at a mean dE00 of 0.0077 against the original (worst 0.036). On the tracer's own output the geometry gives up 5.8% — its emitter is already description-length minimal — but the bytes give up 22.1%. On a nine-file spread it beats SVGO's defaults on bytes, 33.5% to 29.7%, and running both beats either.

  • --minify re-encodes the path data through that writer, taking about 9.6% off a trace with nothing rounded and no pixel changed. inkvec-svgmin --bytes-only is the same rewrite as a standalone tool, and --decimals there is how precision is spent for bytes on purpose (two decimals takes 18% instead of 9.6%, and moves pixels). Tens of milliseconds for a small icon, under a second for a curve-dense logo, 6.5 s for the corpus's heaviest file (213 KB, 8,608 segments).

  • The denoiser in the browser (web/denoise.js, web/worker.js, crates/inkvec-wasm). The --restore pre-pass now runs on the Space, off by default and with the same three modes (off, auto, on). The page loads the same restorer.onnx from Logolabs/inkvec-denoiser-001, verified against the same SHA-256 the CLI checks, into ONNX Runtime Web (pinned 1.30.0): WebGPU where the browser has it, ONNX Runtime's WebAssembly kernels where it does not, and the result line says which ran. Measured against native ONNX Runtime on a 512-px JPEG, the WebAssembly kernels agree to 4.2e-7 — after the 8-bit quantisation the tracer reads, one channel of one pixel in 786,432 differs by one level.

Nothing around the network is reimplemented in JavaScript. inkvec-restore gains network_input / network_output (the compositing, the pad to a multiple of 16, the crop, the quantisation and the extreme-snapping its in-process backends already did, now callable by a backend this crate cannot reach), and crates/inkvec-wasm exposes them, plus inkvec_restore::decide for the auto decision, on an Intake object the page holds across the round trip. inkvec_cli::trace_image_sized is split into intake and trace_prepared at exactly the seam the restorer sits in, so the browser denoises the raster the tracer will see — after --max-dim, after the unblock — rather than one the pipeline would go on to resample; ONNX Runtime Web's session is asynchronous where the pipeline is not, which is why it cannot be a Restore backend like the others. inkvec::trace_rgba_restored is the facade's version of the trace that follows, with soft intake forced as --restore forces it. The page also shows the denoised raster next to the input, and reports the residual when auto decides not to denoise.

A browser whose only WebGPU adapter is a software one (SwiftShader, lavapipe) is treated as having no GPU: measured in a headless Chromium, a 512-px pass the WebAssembly kernels finished in about eight seconds had still not returned ten minutes into the software adapter.

While a run is going the page shows its steps rather than one spinner: the weights, the denoiser pass and the trace, each with its own track and its own elapsed time, and auto's opening trace named as the check it is. The weights are counted in bytes; the network pass and the trace report nothing until they are done, so their tracks sweep rather than inventing a percentage. - Language-binding foundation (docs/BINDINGS.md). The inkvec crate is a small stable library API (trace, trace_rgba, Options, Traced, Error); inkvec-ffi is a C library (inkvec_ffi) with a cbindgen header, include/inkvec.h; inkvec-py is the inkvec Python package (PyO3, abi3 wheels for 3.9+). Options live once, in inkvec::Options, whose JSON Schema (bindings/options.schema.json) the bindings take their options through and their typed stubs are generated from; bindings/contract/ holds the cases every binding must reproduce. Nothing is published yet. - npm package @logolabs/inkvec (packages/npm). The WebAssembly build for JavaScript and TypeScript: one ES module entry for browsers, Node.js, Deno and Bun, and a threaded build at @logolabs/inkvec/threads (cross-origin isolated pages in a Web Worker, or Node.js worker_threads). trace, traceRGBA (canvas ImageData), defaults, optionsSchema; options go to the facade as JSON and their TypeScript types are generated from the schema. crates/inkvec-wasm gains trace_json / trace_rgba_json on the facade; the positional trace the web demo calls is unchanged. Not published yet. - Java package com.logolabs:inkvec (packages/java). A JNA binding over inkvec-ffi's C ABI, Java 8+: Inkvec.trace, Inkvec.traceRgba, defaults, optionsSchema; a generated, immutable InkvecOptions builder plus a raw-JSON-options overload on every method, and InkvecException subclasses per error kind. Options are generated from the schema by bindings/codegen/java.py. Placeholder group id com.logolabs; not published yet. - NuGet package LogoLabs.Inkvec (packages/dotnet). The .NET binding: P/Invoke over inkvec_ffi, netstandard2.0 (.NET Framework 4.6.1+, Unity) and net8.0 (LibraryImport source generation there, DllImport on netstandard2.0). Inkvec.Trace, TraceRgba, TraceFile; options are the generated InkvecOptions (nullable properties, null meaning the tracer's own default) or a raw JSON string; errors are InvalidImageException, InvalidOptionsException and InkvecInternalException. Native libraries for win-x64, win-arm64, linux-x64, linux-arm64, osx-x64 and osx-arm64 ship under runtimes/. Not published yet. - Swift package (packages/swift). Inkvec.trace (image data, a file URL, a CGImage, UIImage or NSImage), Inkvec.traceRGBA, raw-JSON variants of both, defaults, optionsSchema, version, buildTarget; errors are InkvecError. InkvecOptions is generated from the options schema (bindings/codegen/swift.py). Apple platforms get the C library as a static XCFramework (packages/swift/scripts/build-xcframework.sh) through a mirror repository, logolabs/inkvec-swift, that .github/workflows/swift.yml updates on a release tag when the repository opts in; Linux links libinkvec_ffi as a system library. The contract passes on x86_64 Linux; the macOS and iOS builds have not run yet. Not published yet. - Composer package logolabs/inkvec (packages/php). The PHP binding: the C ABI called in-process through ext-FFI, PHP 8.1+. Inkvec::trace, traceFile, traceRgba, defaults, optionsSchema, version, buildTarget; options are the generated Options (camelCase properties, null meaning the tracer's own default, bindings/codegen/php.py), an array keyed by the tracer's own names, or raw JSON; errors are InvalidImageException, InvalidOptionsException, InternalException and LibraryException. A PHP string goes to the tracer as it is, with no copy into an FFI buffer; the library is found through Inkvec::useLibrary(), INKVEC_LIBRARY, the package's lib/ (filled by vendor/bin/inkvec-fetch-library from the C release archives), a sibling target/release/, or the system loader, and is refused unless its C ABI version matches. Inkvec::preload() registers it from an opcache.preload script, which is what makes a trace possible in a PHP-FPM request (ffi.enable=preload) and shares one open library across a pool. tests/HeaderTest.php holds the FFI declarations to include/inkvec.h. Released through a mirror repository by .github/workflows/php.yml; not published yet. - Docker HTTP service (crates/inkvec-server, services/docker/Dockerfile). POST /trace traces raw image bytes or a multipart/form-data image part to image/svg+xml; options come from a ?options= query parameter, an X-Inkvec-Options header, generic query parameters typed against the schema, or a multipart options part, all handed to inkvec::Options::from_json unchanged -- no option is named in the service. GET /options/schema, /options/defaults, /healthz, /version, and a generated OpenAPI 3.1 document at /openapi.json (bindings/codegen/openapi.py). Traces run on a blocking pool behind a concurrency limit (INKVEC_MAX_CONCURRENCY, 503 busy past it) and a body-size cap (INKVEC_MAX_BODY_BYTES, 413); the image is rust:1.98-bookworm building a distroless, non-root runtime. Not published or pushed anywhere by this repository. - Go module github.com/logolabs/inkvec-go (packages/go). Pure Go, no cgo: the C ABI compiled to wasm32-wasip1 (tools/build_go_wasm.sh), embedded and run by wazero, one module instance per concurrent call. Trace, TraceRGBA, TraceJSON (options passed through untouched), Defaults, OptionsSchema, errors matching ErrInvalidImage, ErrInvalidOptions, ErrInternal; the Options struct is generated from the schema (bindings/codegen/golang.py). inkvec-ffi gains inkvec_alloc / inkvec_dealloc on WASI only (not in the header), and inkvec::build_target() names that build wasm32-wasip1 instead of wasm32-unknown, which it shared with the browser build although its output differs. Released through a mirror repository by .github/workflows/go.yml; not published yet.

Fixed

  • --margin on reduced input. The margin was silently dropped whenever the SVG was presented at a larger size than it was traced at (--max-dim capped the input, or an exact pixel-block upscale was undone). The presented size now grows with the viewBox.

  • The release itself. The first 0.1.5 tag run published no command-line archives and no installers: bundle.category in tauri.conf.json was "Graphics", which is not one of the names Tauri's bundler accepts, so every Studio job on every platform built the app and then failed on invalid category — and the publish job waits on all of them. It is "GraphicsAndDesign" now. The Linux Studio jobs failed earlier still, in the link: ONNX Runtime's prebuilt needs glibc 2.38 and GCC 13's libstdc++, which Ubuntu 22.04 does not have, so those rows build on 24.04. The AppImage needed one more thing nobody had reached yet: xdg-utils, without which the bundler stops after writing the AppDir.

  • The release notes claimed a signature the builds do not have. Nothing here is code-signed on any platform — no certificate is configured — but the notes said the Windows installer was signed by LogoLabs SRL. They now say it is unsigned, and give the first-run steps for Windows SmartScreen and macOS Gatekeeper next to the checksums that do establish provenance.

  • The npm publish. npm publish dist-npm/*.tgz failed on the 0.1.5 tag with a git authentication error: npm reads a bare dir/file.tgz as the GitHub shorthand owner/repo and went looking for ssh://git@github.com/dist-npm/<file>.tgz.git. The path now starts with ./, and the step checks that exactly one tarball was packed before publishing it.

  • The Java package is built from this repository, not from Maven Central. The publish-to-Central job is gone: it could never have run, because java.yml is triggered by workflow_run and a workflow_run event's github.ref is the default branch even when the run it followed was a tag, so its refs/tags/v guard was false every time. Publishing would also need a verified namespace the placeholder group id com.logolabs does not have. packages/java/README.md and docs/BINDINGS.md now give the two commands that build the jar instead of a dependency block for an artifact that does not exist. The test job no longer looks for a hard-coded inkvec-0.1.4.jar, which every version bump turned into a path that is not there.

  • The Windows .msi could not be built at all, and packaging the app in CI is what found it. Tauri's WiX generator ignores the name a resource is mapped to and installs it under its source file name, so docs/THIRD_PARTY.md and the app's own THIRD_PARTY.md became one target file installed by two components — WiX rejects that (ICE30) and the MSI never linked, while the .deb and .rpm carried both files correctly. The app's copy is now studio/STUDIO_THIRD_PARTY.md at the source, so no rename is needed and no platform has to honour one. On Windows this also means the About screen's second notices document is installed at all, which it would not have been.

  • shopt -s globstar fails outright on the macOS runners, whose bash is 3.2. It was in the release workflow's "Collect the installers" step, so a tagged macOS row would have built the app, built the .dmg, and then thrown the job away on a shell option. Both workflows list bundles with find now.

  • The AppImage runtime is fetched before bundling, with retries, and handed to linuxdeploy through LDAI_RUNTIME_FILE. The plugin otherwise downloads it mid-bundle, where a 504 — one happened — fails the row and, on a tag, costs a re-tag.

  • Studio CI packages the app instead of only compiling it. --no-bundle is what let the category bug reach a tag: packaging was the one step no CI run had ever executed, on any platform. The studio workflow now builds the same bundle formats the release does — deb/rpm/AppImage, app/dmg, nsis/msi — and fails if a bundler produces nothing, so the next installer that will not build says so on the pull request.

  • Which system each download runs on, measured rather than assumed. A new .github/scripts/abi_floor.py reads each built binary — the versioned symbols it imports on Linux (weak references reported but not counted, since the loader may leave them null), the minimum macOS in its Mach-O load commands — and fails the build if it asks for more than the floor that row declares. The command-line archives keep the glibc 2.17 floor they had; the macOS archives now pin their deployment target (10.12 Intel, 11.0 Apple silicon) instead of inheriting whatever rustc's default happens to be that release. The restorer flavour's own higher floor is stated in the archive's RESTORER.txt and in the README's download table, next to the plain archive that runs on far older systems.

[0.1.4] - 2026-09-20

Changed

  • Transparency is traced natively, by default. An ink is a colour and an opacity and the transparent ground is an ink, instead of the image being composited onto white first: holes stay holes, white artwork on a transparent ground traces, translucent panels keep fill-opacity, and a glow or fade is one gradient of stop-color and stop-opacity. Opaque input traces byte-for-byte as before. --no-native-alpha (or INKVEC_NATIVE_ALPHA=0) restores the old path. Dark-ground pixel error on the screen set 0.063 -> 0.0017.
  • --cutout only matters with --no-native-alpha now.
  • Shape harmonization is held to the traced boundary. A repeated shape takes the cluster's consensus geometry only where that stays within 0.1 px of where its own pixels put it and costs fewer parameters. A face that another face is drawn against — one punched out of the faces below it, or one with a translucent face in its hole — is never moved, so harmonizing can no longer open a gap onto a transparent ground; nor is a fitted circle or rounded rectangle. Screen set with the default flags: mean dE00 0.299 -> 0.148 (native transparency and this together), no icon above dE00 1.0, and the alpha-channel error of harmonized icons back to the unharmonized level (22 better, 0 worse).

Fixed

  • Holes and outlines of fitted primitives. A hole written as two half-circle arcs had its ends and radius rounded separately and bulged by up to 0.76 px; a face with a hole drew its own outline from the traced ring rather than its primitive. A ring with one hole of uniform width is written as one stroked shape.
  • The same input gives the same SVG on every machine. Without --time-budget, the boundary solve still stopped on a fixed 1200 ms wall clock (and the opt-in decode stage on 600 ms), so a slower CPU, a loaded CI runner or WebAssembly could write different bytes for the same image. Only a caller's time budget runs a clock now; otherwise the solve stops on its iteration count. No output changes on the 246-icon screen set or on 1024 px rasters; two of eight complex 2048 px emoji change, for at most 1.4 s more time.
  • Nested holes. A hole inside another hole of the same face is no longer written twice (even-odd filled it back in).
  • White artwork on a transparent ground under --no-native-alpha no longer traces to a white rectangle.

[0.1.3] - 2026-09-18

Fixed

  • Release archives. Keep the default Intel-macOS archive in the release matrix while omitting its unsupported optional ONNX-Runtime restorer variant, which previously prevented the full multi-platform release from publishing.

[0.1.2] - 2026-09-18

Fixed

  • Published benchmark provenance. Replace unreproducible pre-release figures and visuals with measurements tied to the pinned 0.1.1 release executable; regenerate the README assets and distributions from the recorded 21-case data.
  • Large-input intake. Preserve exact-area resampling when the decode cap applies, avoiding a filter mismatch with the corpus's supersampled raster convention.

[0.1.1] - 2026-09-18

Fixed

  • Release verification. Pin CI and release builds to Rust 1.98.0 so formatting, lint and benchmark measurements do not change when the rolling stable channel advances. The quality baseline now records the reproducible release build rather than an unrecoverable pre-release snapshot.
  • Release packaging. Use GitHub's supported macos-15-intel runner for the Intel macOS archive.
  • Documentation checks. Escape SVG element names in Rust documentation and refresh the generated third-party component notice.

[0.1.0] - 2026-09-15

Initial public release.

Added

  • Tracer core. Reads PNG, JPEG, WebP, GIF, BMP and TIFF and writes SVG geometry positioned by the evidence in the pixels rather than a fixed tolerance: colour and bilevel (two-tone) artwork, stroked line art (--strokes, emitted as centerline strokes only when the drawing is genuinely stroked), and primitives (<circle>, ellipses, rounded rectangles) recognised and written directly instead of as curves.
  • Gradients. Linear and radial fills are fitted where the evidence supports them, with bands that were originally one gradient merged back together.
  • Curve fitting by dynamic programming over lines, cubics, arcs and whole primitives, and a planar map that shares boundaries between faces so an edge moves once for both sides.
  • CLI flag families: --restore <auto|on|off> (trained-network cleanup before tracing), --sr <auto|on|off> (super-resolution pre-pass), --lossy <auto|on|off> (noise-aware intake), --max-dim, --time-budget, --no-background, --minify, among others — see inkvec --help.
  • Browser demo. crates/inkvec-wasm compiles the same pipeline to WebAssembly; the static page in web/ runs it entirely client-side, with a threaded build (web/pkg-threads) for faster tracing where cross-origin isolation is available.
  • Optional trained restorer (--restore, feature restore-model): a small network run through ONNX Runtime that removes JPEG/WebP/AI-decoder damage before tracing. Alternate pure-Rust backends are available through Burn (restore-burn on CPU, restore-wgpu on GPU). The network weights (restorer.onnx) are not bundled with Inkvec; see the CLI's --restore-weights / INKVEC_RESTORE_ONNX.
  • SR pre-pass (--sr) via the packaged Python tool (tools/inkvec_sr), or any external command through --sr-command, as a complementary way to clean up the same class of damaged input.

Known limitations

  • --time-budget is advisory: it does not bound how long tracing takes on noise-like input, only on well-behaved artwork.
  • Decoding allocates the full raster before --max-dim is applied, so a very large image can use significant memory even though the trace itself runs at the capped size.
  • The library API (the inkvec-* crates used as libraries, as opposed to the inkvec binary's CLI surface) is not yet stable and may change before 1.0.