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
--editabilityprices every edit by MDL, with bonuses learned from the artists' files. Each pass used to keep an edit while every sample stayed within3·σ + slackof 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 kindkis kept when½·Δχ² ≤ λ·(numbers saved + credit + b_k)(editable/price.rs):χ²both ways between the curve and the evidence by exact distances (the fit's sampledcurves::chi2has 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, andb_ka 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=offrestores 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=1runs 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 autois the default in Quality mode.Args::restoreis now anOption: unset,Args::restore_moderesolves it toautoin Quality mode when the build can restore (the network compiled in, or--restore-command) and tooffin Fast and balanced and in a build with nothing to restore with, which then traces exactly as--restore offdoes; a given--restorealways wins. On a clean inputautomakes 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-webagainst 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=0paints 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_gapidentical, 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. --editabilitywrites curves the way drawing tools do. Every circular arc is written as cubics cut at its x and y extrema, handles on the axes and4/3·tan(Δ/4)·rlong (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,--editabilityagainst the default trace, quality-512ssop: composite 0.9185 (it was 0.9925); divergence from the artistkinked−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 % andsweep+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_featurescut 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_noisetakes 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, withVfrom 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 keepSIDE_TOLand their bytes on all six clean gate conditions, and Fast, which measures no noise, is unchanged too. Onquality-web111 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 autoand--sr autoact 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 autorestored 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_scoreabove 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 automakes no probe and traces as--restore offdoes, 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 probeautokeeps also reports what the plain trace reports, its stage log and lambda; it used to print only therestoreorsrline.- 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 intoinkvec-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.
openmojiEngland 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=0switches the stage off;INKVEC_COMPLETION_DEBUGreports 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;
--rescorescores them, to check the scorer). What the artist's file contributes is cached by its hash inbench/data/_cache(the artist's side ofgeom: 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, andINKVEC_RIBBONS=0does 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; lucideclosed-captionanditeration-ccwnow 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_facetakes 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/casesFast passesedge_axistoo (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_chordmoved toplanar/chord.rs, unchanged but for the seam fix below.crates/inkvec/tests/api.rsrestored_pixels_are_traced_with_soft_intakeripples 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_1f3feat 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), withratio_gapup to +0.01 % andturning_gapup 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/casessawtooth_diagandribbon_w1.5pass. On all 1386 icons ofbench/alpha_eval.py90 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_w1passes. - 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, formerlywasm_float_select.py), exactly: same results and same traps on 1323 edge cases, both architectures.TestWazeroRemainderreports whether wazero still has the bug (a 40-value module where1000 rem_u 256returns 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.DROPPEDsays 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-jsonand--artifact-dir(human-<platform>.json).bench/human_stats.pytraces 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 owndesign_stats.pyandnode_stats.pycounted;bench/theory/human_gap.pykeeps 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. Nosorryand no axiom beyond Lean's three standard ones (lake build InkvecTheory.Audit); aformalCI 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.mdcompares 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_farreported). 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_faris 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 itgeom_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 rawturningandratioaxes, 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_gapis |the trace's turning per raster side − the artist's per canvas side|,ratio_gapis |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; thewebtier 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.pywrites it; the 246 JPEG files (3.3 MB) are committed so every machine scores the same bytes. The scorer reads a.jpgintake 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) andnaturality_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::noisecarries the result asinkvec_core::noise::NoiseModelfor the representation chain; below it, and on every clean intake, it isNoiseModel::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 onweb(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 thewebtier falls fromχ²/M150 to 3.1 (1.8 under Huber). The evaluator gains the blur forward model (½ μ₂ A'',window_sum_resampledand two companions proved inWindows.lean), Huber's robust cost, stroke bands and per-layer compositing at junctions (A2), andEdgeScorer, 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χ²/M0.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 fromGen/Evidence.lean.bench/theory/renderer_floor.pymeasures 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/casesmirror_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. AddedDEFAULT_MAX_WIDTH_REL_SPREAD(20%) toCriteriaandGRAPH_CRITERIAso 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
--strokespath now usesGRAPH_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²toink_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/casesglyph_ring_bar). From 0.98 up an ink now counts as opaque. On all 1386 icons ofbench/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, andcargo docbuilds 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-unblockand--no-soft-intaketurn 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,ypair 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.pyno 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. --minifykeeps 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 autowithout an upscaler traces directly and says so, as--restore autodoes;--sr onstill 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-budgetinside 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, andnative.rshas 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-cliis 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-fitand most ofinkvec-tracebecame 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.tsandrail.tsare 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, andtools/install_hooks.pyto 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, optionmonochrome; 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
.dmgand.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
modeoption, 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_stopsvia 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.tomlpolicy asserting clean license compliance with MPL exceptions.cargo-macheteautomated dependency audit integrated into the quality ratchet.- Colour groups (
--merge-colors, andmerge_colorsininkvec::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,=#hexinto a named colour,=@ninto 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:webinstudio/builds the site;scripts/deploy-space.pyuploads 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.pyrebuilds 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 of0. 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 theink_ideasrules, 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 throughinkvec_core::env, once per process, and a switch is off when unset, empty or0-- soINKVEC_NO_CARVE=0now leaves carving on, where it used to switch it off.JDBGandTAPERDBGareINKVEC_JDBGandINKVEC_TAPERDBG. The full table isdocs/internal/env-vars.md, andbench/quality.pyfails if a newstd::env::varappears outside the helper. Default output is byte-identical (1,290/1,290 traces across 6 suites), and the releaseinkvecbinary is 5.44 MB (-202 KB / -3.6% vs 0.2.0). - Library API behind the
researchfeature 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 ininkvec_fit::structuralexcepteval_segment.inkvec_fit::cost::CostModel::STANDARDis 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=0switches 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.jsonre-recorded);INKVEC_UNDERLAP=0switches 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 4left three for the artwork. On 10 transparent emoji at--colors 4mean 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_dp16.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 autowith 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 onstill 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.exeinstaller removes an existing "Inkvec Studio Lite" (the per-user.exeinstall silently, an old.msithrough msiexec) before installing, asks first if it is open, and keeps the right-click entry and theinkveccommand if they were on. The.msiinstaller 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
.icostay square with the drawing centred on transparent padding. - Studio: a pasted
rgb(r, g, b)orrgba(...)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
svgminin the WebAssembly build: the SVG minifier reachable from a browser.svgmin(svg, lossless, tolerance_px, judge, corner_degrees, decimals, document)returns{ svg, report };losslesspicksinkvec_svgmin::compact(no segment moved, the picture out is the picture in) overminify(each path refitted within the tolerance), and the report carriesbytesBefore/bytesAftermeasured in UTF-8 bytes. Invalid input throws anErrorwithcode = "invalid_svg". The committedweb/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.pyfails 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_CUBICandINKVEC_G1_BREAK: use--bezier-costand--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_PENALTYand the other tuning overrides listed as class (a) indocs/internal/env-vars.md.
Fixed
- The boundary solve's gridline walk (
boundary_opt::crossings) steppedm += 1.0until 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_pairsindex underflow,bin()coordinates,Fadecomposition, andfade_chi2threshold) 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.svgexport copy, the WebAssemblysvgmin). Since 0.1.6 every trace carries a header inside<svg>whose<metadata>block has anrdf: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-restorebuilds 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 atlogolabs.organd 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<svgand 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.--minifystrips both, as it does everything else that carries no geometry. -
--bezier-costand--corner-angle, and a per-trace cost model ininkvec-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 withINKVEC_PARAMS_CUBICandINKVEC_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 ofinkvec::Options, so the bindings do not have them. editability, an option on the tracer (--editabilityon the command line, and a field ofinkvec::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, andstructure_jsonin 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
traceandpreparetake the options as one JSON object, the same onetrace_jsontakes, instead of twelve positional arguments. An option added toinkvec::Optionsnow reaches the Space by the page sending its key. estimate_noisereads 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-featuresstraight 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 --buildandstudioeach 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::markfeeds 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 newdocsworkflow — 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); onlydattributes 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 thedtext itself is minimised: relative or absolute per command, whichever is shorter, repeated letters and needless separators and leading zeros dropped,H/V/Swhere 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-documentto 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. -
--minifyre-encodes the path data through that writer, taking about 9.6% off a trace with nothing rounded and no pixel changed.inkvec-svgmin --bytes-onlyis the same rewrite as a standalone tool, and--decimalsthere 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--restorepre-pass now runs on the Space, off by default and with the same three modes (off,auto,on). The page loads the samerestorer.onnxfromLogolabs/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
-
--marginon reduced input. The margin was silently dropped whenever the SVG was presented at a larger size than it was traced at (--max-dimcapped 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.categoryintauri.conf.jsonwas"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 oninvalid 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/*.tgzfailed on the 0.1.5 tag with a git authentication error: npm reads a baredir/file.tgzas the GitHub shorthandowner/repoand went looking forssh://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.ymlis triggered byworkflow_runand aworkflow_runevent'sgithub.refis the default branch even when the run it followed was a tag, so itsrefs/tags/vguard was false every time. Publishing would also need a verified namespace the placeholder group idcom.logolabsdoes not have.packages/java/README.mdanddocs/BINDINGS.mdnow 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-codedinkvec-0.1.4.jar, which every version bump turned into a path that is not there. -
The Windows
.msicould 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, sodocs/THIRD_PARTY.mdand the app's ownTHIRD_PARTY.mdbecame one target file installed by two components — WiX rejects that (ICE30) and the MSI never linked, while the.deband.rpmcarried both files correctly. The app's copy is nowstudio/STUDIO_THIRD_PARTY.mdat 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 globstarfails 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 withfindnow. -
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-bundleis what let the category bug reach a tag: packaging was the one step no CI run had ever executed, on any platform. Thestudioworkflow 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.pyreads 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'sRESTORER.txtand 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 ofstop-colorandstop-opacity. Opaque input traces byte-for-byte as before.--no-native-alpha(orINKVEC_NATIVE_ALPHA=0) restores the old path. Dark-ground pixel error on the screen set 0.063 -> 0.0017. --cutoutonly matters with--no-native-alphanow.- 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-alphano 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-intelrunner 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 — seeinkvec --help. - Browser demo.
crates/inkvec-wasmcompiles the same pipeline to WebAssembly; the static page inweb/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, featurerestore-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-burnon CPU,restore-wgpuon 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-budgetis 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-dimis 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 theinkvecbinary's CLI surface) is not yet stable and may change before 1.0.