Inkvec Denoiser (inkvec-denoiser-001)
0
21 commits
1 linked in READMEs
updated Sep 18, 2026

A small (19.7M-parameter) image-restoration network that removes JPEG, WebP and AI-decoder (VAE round-trip) damage from logos, icons and other flat artwork before vectorising it. It is the standalone release of the pre-trace "restorer" shipped inside Inkvec, LogoLabs' raster-to-vector tracer β useful on its own for anyone who wants a JPEG/WebP cleanup pass tuned for flat, few-colour art rather than photographs.
Licence: Both the model weights (
restorer.onnx) and the accompanying code are released under the Apache-2.0 Licence. Commercial, independent, and academic use are fully supported.
Feed it a raster that has been through JPEG or WebP compression, or decoded out of a diffusion model's VAE, and it hands back a same-size image with the ringing, block noise and decode softness reduced β sharper edges, flatter interiors, colours pulled back toward what a clean render of the same artwork would have. It was trained and evaluated specifically as a pre-processor for a vectoriser, not for photographs and not for general-purpose super-resolution: the loss and the evaluation both score how much better a boundary-tracing pipeline does on the output, not PSNR or a perceptual metric in isolation (see Evaluation).

ConvNeXtRestorer, a 5-scale ConvNeXt U-Net (source:
svg_depth/model_v4.py, class ConvNeXtRestorer):
UpWithFiLM β four upsampling stages with skip connections, each modulated by
a FiLM (feature-wise linear modulation) vector computed from a global average-pooled
summary of the bottleneck, so the decoder can condition on whole-image context (e.g. "this
image is 97% white background") and not just local features.residual=True:
the network predicts x_out = x_in + head(h) with a zero-initialised head, so at
initialisation (and wherever the head has nothing useful to add) it is the identity β clean
input passes through unchanged by construction. Output is clamped to [0, 1] at inference;
training sees the unclamped value so gradients survive an overshoot.Parameter count: 19,664,163 (19.7M) β counted directly from the ONNX export's
initializers (sum of numel across graph.initializer), not copied from a training log.
| file | what it is |
|---|---|
restorer.onnx | The network, ONNX opset 17. Not committed to this repo's git history (see .gitignore); regenerate it locally with prepare_release.py, or download it from this Hugging Face repo directly. |
SHA256SUMS | Checksum of restorer.onnx as shipped. |
prepare_release.py | Copies the weights from the inkvec source tree and writes SHA256SUMS. |
image, shape [1, 3, height, width], float32, values in
[0, 1], RGB, NCHW. Height and width must each be a multiple of 16 (four 2x
downsamplings) β pad with edge-replicate padding on the bottom and right only (not
centred) if they are not, i.e. F.pad(x, (0, pad_w, 0, pad_h), mode="replicate") in
PyTorch, then crop the output back to the original size. This is exactly what
crates/inkvec-restore/src/planar.rs does in the Rust build.restored, same shape, float32. Already clamped to [0, 1] β
no further clamping needed, but see the recommended post-processing below.dynamic_axes on export), so one exported graph runs at any
padded resolution; there is no fixed input size baked in.The network's own L1 loss has zero gradient once its internal clamp is active, so it settles
a few levels short of true black/white on strongly-clamped pixels (#fdffff rather than pure
white, #040101 rather than pure black β see Known defect, fixed at inference below).
Two cheap fixes, applied in this order, both implemented in
crates/inkvec-restore/src/lib.rs:
round(clamp(x, 0, 1) * 255) / 255. Every accuracy number below was
measured on output read back through an 8-bit PNG, so quantising in-process keeps you on
the recipe that was actually validated.SNAP_LEVELS = 6 in the Rust source.)import numpy as np
import onnxruntime as ort
from PIL import Image
MULTIPLE = 16
SNAP_LEVELS = 6 / 255.0
def restore(sess, rgb01: np.ndarray) -> np.ndarray:
"""rgb01: float32 HWC in [0, 1]. Returns the same shape."""
h, w, _ = rgb01.shape
ph, pw = -(-h // MULTIPLE) * MULTIPLE, -(-w // MULTIPLE) * MULTIPLE # round up to x16
padded = np.pad(rgb01, ((0, ph - h), (0, pw - w), (0, 0)), mode="edge")
chw = padded.transpose(2, 0, 1)[None].astype(np.float32) # 1x3xHxW
(out,) = sess.run(["restored"], {"image": chw})
out = out[0].transpose(1, 2, 0)[:h, :w] # crop back, HWC
out = np.round(np.clip(out, 0, 1) * 255) / 255 # quantise to 8-bit
lo, hi = out <= SNAP_LEVELS, out >= 1 - SNAP_LEVELS
snap_hi = hi.all(axis=-1, keepdims=True)
snap_lo = lo.all(axis=-1, keepdims=True)
out = np.where(snap_hi, 1.0, np.where(snap_lo, 0.0, out))
return out.astype(np.float32)
sess = ort.InferenceSession("restorer.onnx", providers=["CPUExecutionProvider"])
img = np.asarray(Image.open("damaged.jpg").convert("RGB"), dtype=np.float32) / 255.0
clean = restore(sess, img)
Image.fromarray((clean * 255 + 0.5).astype(np.uint8)).save("restored.png")
inkvec damaged.jpg -o clean.svg --restore on
# or let it decide for itself:
inkvec damaged.jpg -o clean.svg --restore auto
Requires the CLI built with the restore-model (ONNX Runtime) or restore-burn (pure Rust)
feature β see the main README.
--restore on always restores first; --restore auto traces once, checks whether the trace
disagrees with the input where it claims to be flat, and only restores and retraces if it
looks damaged. Restoring forces lossy/noise-aware intake for the retrace, the same way
--lossy on does.
svg_depth/runs/restorer_widermask_2492368/last.pt, training step
12,000, run name restorer_widermask. Initialised from an earlier run in the same
lineage, restorer_resid_flat_512mix_2484738/last.pt, and fine-tuned further with a wider
masking augmentation (profile=production, vae_prob=0.15, clean_prob=0.25,
flat_weight=0.02).svg_depth/export_restorer_onnx.py, run against that checkpoint. Two
inference-only rewrites, both numerically verified during export, not just asserted:
CDCBlock.reparameterize()), replacing a conv-then-subtract with
one conv.scale_factor=2 resize instead of a
size=skip.shape[-2:] resize β the two are mathematically identical once the input is
padded to a multiple of 16 (every skip connection is then exactly 2x the tensor being
upsampled), but the size-based form exports a Shape/Gather/Resize chain that ONNX
importers other than PyTorch's own handle unevenly.export_restorer_onnx.py against last.pt produces a byte-identical .onnx file
(sha256 matches SHA256SUMS), and onnxruntime vs. unmodified PyTorch on two input shapes
(256x256, 192x320) agree to max absolute difference 1.31e-6 (mean ~1.6e-7) β a rounding
error many times smaller than a single 8-bit level (0.0039).Two inference-time defects were found and are corrected by the recommended post-processing above rather than by retraining (a retrain that fixes them structurally is recommended future work β see Limitations):
#fdffff / #040101). On a 90%-background image this alone dominates mean colour error.
Fixed by the extreme-snap step above.

All numbers below are from docs/research/experiments-2026-09-12.md Β§10p in the Inkvec
repository ("The restorer works; what was wrong with it; the five ink rules; the remaining
gate", 2026-09-15) β read that document for the full experimental detail, including the
earlier (superseded) diagnosis in Β§10o.
End to end (restorer_ship_eval.py, 144 image-format pairs across 48 images; damaged
input, restorer tiled + extreme-snapped + traced with lenient intake, vs. the shipped tracer
on the same damaged input with no restoration):
| dE00 | DISTS | parameter count | |
|---|---|---|---|
| restored, tiled + snap + lenient intake | -28% | -53% | -31% |
Per format: JPEG q40 -15%/-57%/-45%; WebP q70 -24%/-68%/-24%; VAE round-trip -20%/-38%/-21%. Every content class tested (fonts, stock art, emoji, brand logos, diagrams, icons) improved on every metric.
The clean-input gate (restorer_clean_eval.py, same 48 images, undamaged): dE00 and
DISTS both get worse if the restorer runs unconditionally on clean input (+92% dE00,
+73% DISTS in the worst-case unconditional arm). This is why Inkvec's --restore defaults to
off, and why auto measures the input before deciding rather than always restoring.
Clean training targets are rendered from SVGs drawn from a pooled corpus (source:
svg_depth/data_v3.py, FullPool/SOURCES), reading four named third-party datasets, each
split into a "kept" and a "null"-tagged partition in the pool:
The training script (svg_depth/train_fixer.py) renders these to clean raster targets at
multiple crop sizes (128/256/384/512 px) and synthesises degraded inputs on the fly
(degrade(): random JPEG q50-90 or WebP re-encoding, 25% left clean) β the network never
sees a real-world damaged/clean pair, only clean-render/synthetic-damage pairs. Both the model
weights and training definitions are released under the Apache-2.0 Licence.
@software{inkvec_denoiser2026,
title = {Inkvec Denoiser: a JPEG/WebP/VAE-damage restoration network for vectorisation pre-processing},
author = {LogoLabs and Deleanu, Stefan-Lucian},
year = {2026},
url = {https://huggingface.co/Logolabs/inkvec-denoiser-001}
}
See also RELEASE_NOTES_restorer.md for the GitHub release
draft, and the main Inkvec repository for the tracer
this network was built for.

We acknowledge EuroHPC JU for awarding the project ID EHPC-AIF-2026PG01-907 access to resources on Arrhenius GPU at NAISS, Sweden. The Arrhenius system is operated by the National Academic Infrastructure for Supercomputing in Sweden (NAISS). Compute time on Arrhenius was instrumental in the training and evaluation of this denoiser/restorer model.
21 commits
Inkvec Denoiser (inkvec-denoiser-001)
0
21 commits
1 linked in READMEs
updated Sep 18, 2026

A small (19.7M-parameter) image-restoration network that removes JPEG, WebP and AI-decoder (VAE round-trip) damage from logos, icons and other flat artwork before vectorising it. It is the standalone release of the pre-trace "restorer" shipped inside Inkvec, LogoLabs' raster-to-vector tracer β useful on its own for anyone who wants a JPEG/WebP cleanup pass tuned for flat, few-colour art rather than photographs.
Licence: Both the model weights (
restorer.onnx) and the accompanying code are released under the Apache-2.0 Licence. Commercial, independent, and academic use are fully supported.
Feed it a raster that has been through JPEG or WebP compression, or decoded out of a diffusion model's VAE, and it hands back a same-size image with the ringing, block noise and decode softness reduced β sharper edges, flatter interiors, colours pulled back toward what a clean render of the same artwork would have. It was trained and evaluated specifically as a pre-processor for a vectoriser, not for photographs and not for general-purpose super-resolution: the loss and the evaluation both score how much better a boundary-tracing pipeline does on the output, not PSNR or a perceptual metric in isolation (see Evaluation).

ConvNeXtRestorer, a 5-scale ConvNeXt U-Net (source:
svg_depth/model_v4.py, class ConvNeXtRestorer):
UpWithFiLM β four upsampling stages with skip connections, each modulated by
a FiLM (feature-wise linear modulation) vector computed from a global average-pooled
summary of the bottleneck, so the decoder can condition on whole-image context (e.g. "this
image is 97% white background") and not just local features.residual=True:
the network predicts x_out = x_in + head(h) with a zero-initialised head, so at
initialisation (and wherever the head has nothing useful to add) it is the identity β clean
input passes through unchanged by construction. Output is clamped to [0, 1] at inference;
training sees the unclamped value so gradients survive an overshoot.Parameter count: 19,664,163 (19.7M) β counted directly from the ONNX export's
initializers (sum of numel across graph.initializer), not copied from a training log.
| file | what it is |
|---|---|
restorer.onnx | The network, ONNX opset 17. Not committed to this repo's git history (see .gitignore); regenerate it locally with prepare_release.py, or download it from this Hugging Face repo directly. |
SHA256SUMS | Checksum of restorer.onnx as shipped. |
prepare_release.py | Copies the weights from the inkvec source tree and writes SHA256SUMS. |
image, shape [1, 3, height, width], float32, values in
[0, 1], RGB, NCHW. Height and width must each be a multiple of 16 (four 2x
downsamplings) β pad with edge-replicate padding on the bottom and right only (not
centred) if they are not, i.e. F.pad(x, (0, pad_w, 0, pad_h), mode="replicate") in
PyTorch, then crop the output back to the original size. This is exactly what
crates/inkvec-restore/src/planar.rs does in the Rust build.restored, same shape, float32. Already clamped to [0, 1] β
no further clamping needed, but see the recommended post-processing below.dynamic_axes on export), so one exported graph runs at any
padded resolution; there is no fixed input size baked in.The network's own L1 loss has zero gradient once its internal clamp is active, so it settles
a few levels short of true black/white on strongly-clamped pixels (#fdffff rather than pure
white, #040101 rather than pure black β see Known defect, fixed at inference below).
Two cheap fixes, applied in this order, both implemented in
crates/inkvec-restore/src/lib.rs:
round(clamp(x, 0, 1) * 255) / 255. Every accuracy number below was
measured on output read back through an 8-bit PNG, so quantising in-process keeps you on
the recipe that was actually validated.SNAP_LEVELS = 6 in the Rust source.)import numpy as np
import onnxruntime as ort
from PIL import Image
MULTIPLE = 16
SNAP_LEVELS = 6 / 255.0
def restore(sess, rgb01: np.ndarray) -> np.ndarray:
"""rgb01: float32 HWC in [0, 1]. Returns the same shape."""
h, w, _ = rgb01.shape
ph, pw = -(-h // MULTIPLE) * MULTIPLE, -(-w // MULTIPLE) * MULTIPLE # round up to x16
padded = np.pad(rgb01, ((0, ph - h), (0, pw - w), (0, 0)), mode="edge")
chw = padded.transpose(2, 0, 1)[None].astype(np.float32) # 1x3xHxW
(out,) = sess.run(["restored"], {"image": chw})
out = out[0].transpose(1, 2, 0)[:h, :w] # crop back, HWC
out = np.round(np.clip(out, 0, 1) * 255) / 255 # quantise to 8-bit
lo, hi = out <= SNAP_LEVELS, out >= 1 - SNAP_LEVELS
snap_hi = hi.all(axis=-1, keepdims=True)
snap_lo = lo.all(axis=-1, keepdims=True)
out = np.where(snap_hi, 1.0, np.where(snap_lo, 0.0, out))
return out.astype(np.float32)
sess = ort.InferenceSession("restorer.onnx", providers=["CPUExecutionProvider"])
img = np.asarray(Image.open("damaged.jpg").convert("RGB"), dtype=np.float32) / 255.0
clean = restore(sess, img)
Image.fromarray((clean * 255 + 0.5).astype(np.uint8)).save("restored.png")
inkvec damaged.jpg -o clean.svg --restore on
# or let it decide for itself:
inkvec damaged.jpg -o clean.svg --restore auto
Requires the CLI built with the restore-model (ONNX Runtime) or restore-burn (pure Rust)
feature β see the main README.
--restore on always restores first; --restore auto traces once, checks whether the trace
disagrees with the input where it claims to be flat, and only restores and retraces if it
looks damaged. Restoring forces lossy/noise-aware intake for the retrace, the same way
--lossy on does.
svg_depth/runs/restorer_widermask_2492368/last.pt, training step
12,000, run name restorer_widermask. Initialised from an earlier run in the same
lineage, restorer_resid_flat_512mix_2484738/last.pt, and fine-tuned further with a wider
masking augmentation (profile=production, vae_prob=0.15, clean_prob=0.25,
flat_weight=0.02).svg_depth/export_restorer_onnx.py, run against that checkpoint. Two
inference-only rewrites, both numerically verified during export, not just asserted:
CDCBlock.reparameterize()), replacing a conv-then-subtract with
one conv.scale_factor=2 resize instead of a
size=skip.shape[-2:] resize β the two are mathematically identical once the input is
padded to a multiple of 16 (every skip connection is then exactly 2x the tensor being
upsampled), but the size-based form exports a Shape/Gather/Resize chain that ONNX
importers other than PyTorch's own handle unevenly.export_restorer_onnx.py against last.pt produces a byte-identical .onnx file
(sha256 matches SHA256SUMS), and onnxruntime vs. unmodified PyTorch on two input shapes
(256x256, 192x320) agree to max absolute difference 1.31e-6 (mean ~1.6e-7) β a rounding
error many times smaller than a single 8-bit level (0.0039).Two inference-time defects were found and are corrected by the recommended post-processing above rather than by retraining (a retrain that fixes them structurally is recommended future work β see Limitations):
#fdffff / #040101). On a 90%-background image this alone dominates mean colour error.
Fixed by the extreme-snap step above.

All numbers below are from docs/research/experiments-2026-09-12.md Β§10p in the Inkvec
repository ("The restorer works; what was wrong with it; the five ink rules; the remaining
gate", 2026-09-15) β read that document for the full experimental detail, including the
earlier (superseded) diagnosis in Β§10o.
End to end (restorer_ship_eval.py, 144 image-format pairs across 48 images; damaged
input, restorer tiled + extreme-snapped + traced with lenient intake, vs. the shipped tracer
on the same damaged input with no restoration):
| dE00 | DISTS | parameter count | |
|---|---|---|---|
| restored, tiled + snap + lenient intake | -28% | -53% | -31% |
Per format: JPEG q40 -15%/-57%/-45%; WebP q70 -24%/-68%/-24%; VAE round-trip -20%/-38%/-21%. Every content class tested (fonts, stock art, emoji, brand logos, diagrams, icons) improved on every metric.
The clean-input gate (restorer_clean_eval.py, same 48 images, undamaged): dE00 and
DISTS both get worse if the restorer runs unconditionally on clean input (+92% dE00,
+73% DISTS in the worst-case unconditional arm). This is why Inkvec's --restore defaults to
off, and why auto measures the input before deciding rather than always restoring.
Clean training targets are rendered from SVGs drawn from a pooled corpus (source:
svg_depth/data_v3.py, FullPool/SOURCES), reading four named third-party datasets, each
split into a "kept" and a "null"-tagged partition in the pool:
The training script (svg_depth/train_fixer.py) renders these to clean raster targets at
multiple crop sizes (128/256/384/512 px) and synthesises degraded inputs on the fly
(degrade(): random JPEG q50-90 or WebP re-encoding, 25% left clean) β the network never
sees a real-world damaged/clean pair, only clean-render/synthetic-damage pairs. Both the model
weights and training definitions are released under the Apache-2.0 Licence.
@software{inkvec_denoiser2026,
title = {Inkvec Denoiser: a JPEG/WebP/VAE-damage restoration network for vectorisation pre-processing},
author = {LogoLabs and Deleanu, Stefan-Lucian},
year = {2026},
url = {https://huggingface.co/Logolabs/inkvec-denoiser-001}
}
See also RELEASE_NOTES_restorer.md for the GitHub release
draft, and the main Inkvec repository for the tracer
this network was built for.

We acknowledge EuroHPC JU for awarding the project ID EHPC-AIF-2026PG01-907 access to resources on Arrhenius GPU at NAISS, Sweden. The Arrhenius system is operated by the National Academic Infrastructure for Supercomputing in Sweden (NAISS). Compute time on Arrhenius was instrumental in the training and evaluation of this denoiser/restorer model.
21 commits