Trace bitmap glyph images to bezier contours and insert them directly into UFO font sources.
Read the blog post for the design rationale, the eval harness, and an interactive in-browser demo.

It takes scanned or rendered letterforms and produces cubic outlines ready for editing or compiling into fonts, for use in AI type-design pipelines. Most autotracers reproduce a glyph's silhouette and leave the outline structure to a human; img2bez aims for the structure a font source needs: points at extrema with horizontal or vertical handles, straight segments as lines, points at inflections, G2-harmonized smooth joins, and the minimum number of points needed to render the shape (see docs/autotracing-research.md for the design rationale).
Built on the Linebender ecosystem: kurbo for bezier curve math, and norad for UFO reading and writing.

An eval-harness specimen: the reference outlines (top row) against the traced
output (bottom row), with point structure, rendered previews, and overlay
diffs. Generated with ./eval-harness/render-specimen.sh --text a.
Install the CLI:
cargo install img2bez
Or use img2bez as a library:
cargo add img2bez
The default build is batteries-included (CLI, UFO output, PNG rendering, parallelism). If you are embedding img2bez as a library and want a leaner build (fewer dependencies, faster compiles, smaller binary), disable the defaults and re-add only what you need:
# core tracing only; add `ufo` for UFO/GLIF output, `render` for
# PNG rendering + raster IoU, `parallel` for per-contour rayon
cargo add img2bez --no-default-features --features ufo
# Basic usage: trace a glyph image and insert it into a UFO source
# (the target UFO is created if it doesn't exist; --unicode is hex)
img2bez --input glyph.png --output MyFont.ufo --name A --unicode 0041
# With font metrics: --target-height is ascender minus descender,
# --y-offset is the descender, --grid snaps coordinates to a 2-unit grid
img2bez --input glyph.png --output MyFont.ufo --name A --unicode 0041 \
--target-height 1024 --y-offset -256 --grid 2
# Compare the traced output against a reference glyph
img2bez --input glyph.png --output MyFont.ufo --name A --unicode 0041 \
--reference MyFont.ufo/glyphs/A_.glif
| Flag | Default | Description |
|---|---|---|
-i, --input |
required | Input image (PNG, JPEG, BMP) |
-o, --output |
required | Output UFO path |
-n, --name |
required | Glyph name |
-u, --unicode |
— | Unicode codepoint (hex, e.g. 0041) |
-w, --width |
auto | Advance width (auto-computed from bbox if omitted) |
--target-height |
1088 | Target height in font units (ascender - descender) |
--y-offset |
-256 | Y offset after scaling (typically the descender) |
--grid |
2 | Grid size for coordinate snapping (0 = off) |
--profile |
wild | Source-class preset: wild (unknown raster), clean (font render), or photo (soft scan of printed type — forces the de-texture pre-blur) |
--accuracy |
profile | Curve fitting accuracy in font units (smaller = tighter) |
--pre-blur |
auto | Image pre-blur sigma; soft/low-contrast sources are auto-detected and blurred (--no-auto-pre-blur disables) |
--smoothing |
1.0 | Pre-fit smoothing multiplier (raise for noisy/blurry sources) |
--corner-threshold |
12 | Corner turn-angle threshold in degrees |
--mode |
default | Output-shape constraint: default, smooth (all-curves), or line (all-straight) |
--chamfer |
0 | Chamfer size (0 = off) |
--threshold |
Otsu | Fixed brightness threshold (0-255), overrides Otsu |
--invert |
false | Invert image before tracing |
--no-refine |
false | Disable raster-loss refinement (junction flats + curve merging + handle polish, scored against the source image) |
--reference |
— | Reference .glif for quality evaluation |
--format |
ufo | Output format: ufo, glif, json, or svg |
These tuning flags are global, so they also apply to the subcommands
(img2bez masters … --profile photo). Run img2bez --help or
img2bez <command> --help for the always-current, authoritative list, including
the masters (multi-master variable-font) and new-font subcommands.
use std::path::Path;
use img2bez::{trace_glyph_path, FontMetrics, Profile, TraceOptions};
let opts = TraceOptions::for_profile(Profile::Clean)
.with_em_height(1088.0)
.with_grid(2);
let metrics = FontMetrics::from_target_height(1088.0, -256.0)
.with_advance(600.0);
let glyph = trace_glyph_path(
Path::new("glyph.png"),
"A",
&['A'],
&opts,
&metrics,
)?;
// The same Glyph model serializes to JSON, GLIF, or SVG.
std::fs::write("A.glif", glyph.to_glif())?;
let json = serde_json::to_string_pretty(&glyph)?;
let svg_path = glyph.outline.to_svg_path();
# Ok::<(), Box<dyn std::error::Error>>(())
img2bez traces the grayscale image directly, using the anti-aliasing at the ink boundary for sub-pixel accuracy. Inputs are expected to carry that anti-aliasing (rendered glyphs, grayscale scans); nearest-neighbor upscaled sources are detected and downscaled to their true resolution first.
Input image (grayscale)
|
0. Load + threshold ─── io/bitmap.rs
└── nearest-upscaled sources downscaled to true resolution
|
1. Marching-squares iso-contours at the threshold level
─── pipeline/vectorize/subpixel.rs
2. Resample + adaptive smoothing
3. Curvature features: corners, straights, tangent points,
extrema, inflections, chamfers
4. Constrained cubic fitting
(H/V tangents at extrema; inflection split; bounded
minimal-count subdivision fallback)
5. Corner reconstruction, G1 align, raster-loss refinement, G2
harmonization
─── pipeline/vectorize/fit/
─── pipeline/vectorize/refine/
|
6. Post-processing ─── pipeline/cleanup/
├── Contour direction (CCW outer, CW counter)
├── Grid snapping
├── H/V handle correction
└── Optional chamfer insertion
|
7. Reposition + advance width ─── pipeline/placement.rs
|
8. UFO output ─── io/ufo.rs
The eval-harness stress gate traces clean renders of a reference font and compares the result against the original UFO outlines: point count and placement, line/curve structure, H/V handles, and matched reference points. Across basic Latin (a-z, A-Z, 0-9) the mean structural score is 0.964, with 12 glyphs reproducing their reference exactly (score 1.000) and all 62 passing the stress gate. To evaluate img2bez on your own images, use the interactive demo.
| Glyph | Score | Glyph | Score | Glyph | Score | Glyph | Score |
|---|---|---|---|---|---|---|---|
| i | 1.000 | j | 0.987 | 6 | 0.967 | D | 0.946 |
| o | 1.000 | q | 0.987 | 9 | 0.967 | S | 0.943 |
| v | 1.000 | z | 0.986 | B | 0.965 | b | 0.939 |
| E | 1.000 | P | 0.986 | g | 0.964 | p | 0.938 |
| F | 1.000 | w | 0.982 | U | 0.964 | l | 0.934 |
| H | 1.000 | J | 0.982 | n | 0.960 | 2 | 0.934 |
| I | 1.000 | W | 0.982 | h | 0.958 | Y | 0.931 |
| L | 1.000 | x | 0.981 | u | 0.958 | s | 0.927 |
| O | 1.000 | R | 0.981 | X | 0.958 | m | 0.922 |
| T | 1.000 | C | 0.978 | 5 | 0.958 | y | 0.918 |
| V | 1.000 | a | 0.972 | d | 0.957 | 0 | 0.915 |
| 1 | 1.000 | 4 | 0.971 | Q | 0.957 | e | 0.909 |
| 8 | 0.993 | A | 0.969 | c | 0.954 | r | 0.863 |
| f | 0.991 | 3 | 0.968 | N | 0.950 | 7 | 0.779 |
| t | 0.991 | k | 0.967 | Z | 0.950 | ||
| M | 0.990 | K | 0.967 | G | 0.948 |
| Feature | Default | Description |
|---|---|---|
ufo |
yes | UFO output via norad |
cli |
yes | Command-line binary via clap + serde_json (implies ufo and render) |
parallel |
yes | Per-contour parallelism via rayon |
render |
yes | PNG rendering and raster IoU evaluation via tiny-skia |
Prefix your command with VAR=1 to enable debug output for a single run:
IMG2BEZ_DEBUG_FIT=1 img2bez --input glyph.png --output MyFont.ufo --name A
| Variable | Effect |
|---|---|
IMG2BEZ_DEBUG_FIT |
Print sub-pixel pipeline splits, line sections, fit errors, and refine-pass decisions |
IMG2BEZ_DEBUG_CORNERS |
Print per-candidate corner decisions and (with the site head on) zone features and scores |
IMG2BEZ_DEBUG_FLATS |
Print per-candidate junction-flat decisions in raster-loss refinement |
IMG2BEZ_DEBUG_SIMPLIFY |
Print point-merge candidates and accept/reject reasons in cleanup |
IMG2BEZ_DEBUG_TANGENT |
Print tangent-point handle decisions |
IMG2BEZ_DEBUG_WELD |
Print convex-tip weld decisions |
IMG2BEZ_DEBUG_ARCCHAIN |
Print arc-chain straight-run drops |
IMG2BEZ_DEBUG_NO_CLEANUP |
Skip all post-processing |
IMG2BEZ_DEBUG_PIXELDIFF |
Save 1:1 pixel diff image |
IMG2BEZ_DEBUG_JOINT |
Print joint masters features, alignment columns, and section decisions |
IMG2BEZ_DEBUG_JOINT_EXTREMA |
Print dense-extremum candidate rejections in joint tracing |
IMG2BEZ_PHOTO_FAIR_DEV |
Override the photo-profile deviation-fairing threshold (font units) |
IMG2BEZ_LOG |
Append a JSONL record of each trace (features + settings + output stats) to the given path |
IMG2BEZ_LOG_DECISIONS |
Log per-candidate pipeline decisions (features + outcome) as JSONL for training data |
Two more variables are opt-ins rather than debug switches: IMG2BEZ_CORNER_HEAD=1
enables the experimental learned corner gate — a tiny embedded decision-tree
ensemble that refines corner detection — and IMG2BEZ_SITE_HEAD=1 enables the
learned zone classifier that replaces the per-candidate corner gate (the
default pipeline is fully procedural). The wasm bindings expose the same
switches as cornerHead and siteHead in the trace config.
The tracer is developed against an evaluation loop that renders a hand-drawn reference font to bitmaps, traces them back, and scores raster overlap and structural match, so changes can be pushed toward measurable goals. The harness lives in the repository (not in the published crate): see eval-harness/.
Renders every glyph of a source UFO, traces the renders with the img2bez
library in-process, and writes side-by-side outline-structure sheets — the
source font is ground truth for what the trace should be, so each sheet
is a lead on a tracer improvement. Install it from a checkout (requires a
sibling designbot checkout at
../designbot for the sheet drawing):
cargo install --path eval-harness/structure-compare
Then point it at any font:
structure-compare path/to/Font.ufo # a-z A-Z by default
structure-compare Font.ufo --glyphs a,g,s,R # or a subset
Sheets and a summary.tsv (point counts, kinks, magic-triangle/reach
scans) land in
$ claude mcp add img2bez \
-- python -m otcore.mcp_server <graph>