Skip to content

Latest commit

 

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

centerline

Derive the centerline of a glyph from the font file it lives in, by computing the medial axis of the outline itself rather than of a picture of it.

The word "centerline" as a pen path

If you want a single-stroke version of a specific typeface, there is currently nowhere to get one. The hand-drawn engraving fonts (Hershey and its descendants) are centerlines but are not derived from anything - somebody drew them. The raster tools (autotrace -centerline, Inkscape's Centerline Trace, skimage.skeletonize) rasterize the glyph first and throw away the exact geometry the font file already contains. This project closes that tool gap.

The method is not new and this project does not claim it is. Berio et al., StrokeStyles (ACM TOG 41(3):28, 2022) compute the medial axis from glyph outlines, prune it by branch salience and decompose it into strokes. What is missing is a usable implementation: no code was published, their strokes overlap by design because the goal is stylisation rather than a pen path, and there is no evaluation of reconstruction fidelity or resolution stability. Those are the gaps this fills.

The pipeline in four pictures

Outline, medial axis, pruned and labelled axis, pen path

Left to right, all four from the same font file and produced by the code in this repository (python docs/figures/render.py):

  1. Outline. The glyph's area region, contours merged by the non-zero winding rule, the Bezier segments flattened to a polygon at a tolerance of half a font unit.
  2. Medial axis. Exact, from a segment Voronoi diagram of that polygon - and a bush: every corner of the flattened outline throws its own branch. This capital R arrives with 280 edges; the pen path at the far right is four strokes.
  3. Pruned and labelled. The contraction removes the corner artefacts (pale) and holds the number of components and independent cycles fixed while it does; what survives is labelled, the serif bar blue, the terminal chains orange.
  4. Pen path. The surviving chains fitted as cubic B-splines in (x, y, r), joined into strokes at the forks, each stroke given a direction and a place in the writing order. One colour per stroke; the halo behind each is the stroke drawn at twice its median inscribed radius, which is how much ink the path claims back.

Status

Research in progress. There is a pen path you can export today, as SVG or as JSON, but no font file comes out the other end yet, and two of the contributions the design spec claims are still open.

Stage What it does State
1 glyphsource, region font file to an area region, contours merged by the non-zero winding rule works
2 flatten Bezier contours to an integer polygon path works
3 medial segment Voronoi, primary interior edges, radius and provenance per edge works
4 prune, features, smooth remove the branches that are artefacts, say what the surviving ones are, then fit each as a curve works; the literature's error bound does not hold on this corpus
5 strokes chains to strokes: fans, pairing at the forks, direction and writing order works; junctions are resolved by angle alone, and the order is a heuristic nobody measured
6 export, jsonout SVG with one path per stroke, and a versioned JSON interchange document works; UFO, SVG font, CXF, Hershey, G-code and TrueType are not started

Two claims from the design spec are explicitly not implemented, and no part of this code should be cited as implementing them: the radius term in junction resolution, and a stroke order validated against writers' consensus (UJIpenchars2). strokes.py says so in its own docstring. The order that ships exists so that a drawing animation does not visibly run backwards.

What else is known to be imperfect, measured rather than suspected:

  • The serif detector finds serifs on I, A and M only, and only in two of the three serif faces of the corpus - Playfair Display 4 marks, Roboto Slab 6, EB Garamond none. Its negative control holds: Inter and Caveat produce none. The gap is not in the predicate but in the axis it reads on, worked out in docs/research/2026-08-08-phase-3b-marken.md.
  • The smoothing bound shape error <= sqrt(2) * f_e (Zhu et al. 2014) holds in 14 of 40 corpus glyphs. That is the main result of docs/research/2026-08-08-phase-3c-glaettung.md, and it is a negative one.
  • A round dot is a degenerate stroke - the medial axis of a disc is its centre, and of a rounded square a short segment. The i in the picture at the top of this file has a stroke where a typographer would want a point.

Install

pip install -e .            # the library and the "centerline" command
pip install -e ".[eval]"    # plus the measurement harness (pulls in scikit-image)
pip install -e ".[dev]"     # plus pytest, hypothesis and ruff

Python 3.11 or newer. Runtime dependencies are fontTools, pyvoronoi (Boost.Polygon), shapely, numpy and scipy. scipy is a runtime dependency and not part of the eval extra on purpose: stage 4's smoothing ships, so its B-spline basis and its optimiser have to be there for the delivered path, not only for the measurement one.

Use

The pen path, font file to SVG in one call - one <path> per stroke, exact cubic Bezier spans, stroke-width twice the median inscribed radius and the exact per-span radii next to it in data-radii:

centerline pen /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf Rag -o pen.svg
centerline json MyFont.ttf Rag -o strokes.json

json writes the same geometry as the interchange document, format centerline-glyphs version 1: units per em, the font's family name, the commit that produced it, and per glyph its advance and one object per stroke with a width and an SVG path d. One object per stroke and not one merged path per glyph, because a consumer draws a stroke at a time and each has to restart its own dash pattern.

The debug picture, which answers the other question - whether the right branches fell:

centerline axis MyFont.ttf IOAB -o axis.svg
centerline axis MyFont.ttf B -o axis.svg --radii     # also draw the inscribed circles
centerline axis MyFont.ttf B -o pruned.svg --prune   # contract the axis, draw what fell pale
centerline axis MyFont.ttf B -o pruned.svg --prune --terminals trim
centerline axis MyFont.ttf B -o smooth.svg --smooth   # fit the delivered axis as splines

It prints one line per glyph with the edge count and a topology check - the number of components and independent cycles the skeleton has, against the number the region requires. A B has two counters, so its skeleton must have two cycles. That expectation is computed, not annotated, so it holds for any glyph. With --smooth the line also carries the control point count, which is what the smoothing is measured by.

Dials

axis carries the first four, pen and json the last two; the rest are inherited at their defaults there rather than repeated as flags until somebody needs them.

  • --tau-beta is the branch salience above which a branch is kept. Its default is 1.03, measured on this corpus; the 2.3 published for Latin type was tried and rejected, because on an exact axis rather than a Voronoi-discrete one it condemns every first-round leaf branch. The measured window is 1.001 to 1.1.
  • --tau-serif is the arm length over the radius at the T-junction, above which a bar is too long to be a serif. Default 3.0, at which a neo-grotesque and a script produce no serif mark at all.
  • --terminals decides whether the terminal-trim mark - the whole leaf-to-base chain of a terminal group, which at a bracketed serif is the serif itself - counts as delivered (keep, the default) or is cut back to the fork (trim). It changes what a later stage would export and what the measurement table counts, not what --prune draws: the axis picture always shows every mark in its own colour, whichever policy is in force. It is a policy and not a fix: trimming does buy a strictly positive radius at every stroke end, and it costs reconstruction fidelity, both measured in docs/research/2026-08-08-phase-3b-marken.md.
  • --shape-error is the target boundary error of the smoothing in font units. Default 0.5, anchored on the flattening tolerance, because an axis is no more accurate than the outline it was flattened from. The fit itself runs at --shape-error / sqrt(2). The measured window is font-class dependent: 8.0 still keeps a neo-grotesque and a slab under the one-percent reconstruction threshold, while a didone, a humanist serif and a script break it above 2.0. On axis it only has an effect together with --smooth.
  • --tau-fan is the chain reach over the radius at its base, below which a group of chains at a stroke end is a corner fan and is replaced by a single run to the cap. Default 2.3, and unlike the four above it was set at the picture rather than measured: sqrt(2) is where the corner leaf of a right angle sits by construction, acute corners reach further, and the first bracketed serif on this corpus sits past 3, so the dial rests between them.

The measurement harness

Run the evaluation over the corpus, which writes a measurement table and a visual atlas of every glyph, then aggregate it into the summary tables below:

python -m corpus.fetch      # download and instance the six corpus fonts
centerline eval             # out/measurements.csv and out/atlas.svg, about 7.5 minutes
centerline sweep            # tolerance, grid, resolution, residual, threshold, salience, serif
                             # and smoothing series - the last dominates, about 40 minutes on
                             # its own, and the whole sweep lands near 50
centerline report           # out/summary.csv, aggregated from out/measurements.csv

What is measured

Reconstruction fidelity is RER (the share of the glyph the reconstructed stroke fails to cover) reported together with SS (skeleton sparsity). The pair matters: an unpruned axis has an RER near zero by construction, so RER alone says nothing. Over the five-font corpus, 40 glyphs per method, from out/summary.csv - reproduce it with centerline eval && centerline report:

method RER median RER p90 SS median components correct
exact (this project, unpruned) 0.0063 % 0.0160 % 2.836 40/40
pruned (this project, contracted) 0.0085 % 0.0255 % 1.868 40/40
smoothed (this project, fitted as splines) 0.0904 % 0.1423 % 1.868 40/40
skimage-skeletonize 2.7862 % 4.1737 % 1.854 40/40
skimage-medial-axis 0.2741 % 0.5642 % 1.903 40/40
spike-zhang-suen (own reimplementation) 3.1594 % 4.4166 % 2.067 40/40

Read that honestly: the RER column is close to a tautology for the exact method, because the medial axis reconstructs its own shape by definition. What it demonstrates is that the implementation is correct, and the SS column is the price - the raw axis is the least sparse in the field, precisely because nothing has been removed from it yet.

The pruned row is the one that costs something, and this is what it costs at the shipped tau_beta = 1.03: RER max 0.0304 % over the whole corpus, at SS 1.868 and a median of 313 edges. Against skimage-medial-axis, the baseline the pruned axis has to beat, that is a win in the worst case by a factor of about 35 - 0.03 % against its 1.06 % - at better sparsity (1.868 against 1.903) and under a quarter of the edges. At the published tau_beta = 2.3 (Berio et al.), which this project used until it was measured, the worst case was 3.5916 % and five of forty glyphs broke the one-percent mark. Why the two thresholds differ this much is worked out in docs/research/2026-08-07-phase-3a-pruning.md; what the marks on top of the contraction find, and what cutting the terminals costs, is in docs/research/2026-08-08-phase-3b-marken.md.

The smoothed row costs a factor of about 11 in median RER over the pruned one and still sits a factor of about 5 under the one-percent threshold. Neither RER nor SS is what the row is for, though: SS measures the axis's length, which smoothing barely changes (1.868 either way), and the size that does change is the representation. That is the control_points column, new in this phase: 4176 control points over the whole corpus against 12195 edges of the pruned axis, or 34.2 %. The saving is not spread evenly - it is entirely in the long chains. Inter's O is one closed chain and costs 22 control points against 576 edges; Inter's M is 17 short chains and costs 168 against 55, because a cubic B-spline needs four control points per chain no matter how short it is. Both directions, the corpus-wide bound check, and the target sweep are in docs/research/2026-08-08-phase-3c-glaettung.md.

The stroke stage after it is not measured at all, on purpose: it has no corpus table and no sweep, and its acceptance was that the letters look right (docs/superpowers/specs/2026-08-09-stiftbahn-fuer-sketchury-design.md).

Layout

src/centerline/        the library, one module per pipeline stage
src/centerline/eval/   metrics, baselines, corpus harness - the "eval" extra
corpus/                font list with licence, pinned commit and checksum, plus the fetcher
out/                   the measurement tables, tracked so results are diffable
baseline/              the raster-thinning spike this project grew out of, kept as evidence
docs/                  design spec, plans, literature notes, decision notes
docs/figures/          the README's pictures and the script that regenerates them
tests/                 mirrors src/, including property-based tests

Development:

python -m pytest -q
ruff check src tests corpus docs/figures
ruff format --check src tests corpus docs/figures
python docs/figures/render.py    # regenerate the README's figures (needs the corpus)

Corpus

No font files are stored in this repository. corpus/corpus.toml lists six faces across five typographic classes, each pinned to one commit of the Google Fonts monorepo with a checksum, and corpus/fetch.py downloads them and instances the named weight from the variable font. Five are under the SIL Open Font License 1.1 and Roboto Slab is under Apache 2.0; the licence is recorded per entry rather than claimed for the set. The measurement tables are computed over the first five - Schibsted Grotesk was added later, as a second neo-grotesque.

The figures in this file are drawings of Roboto Slab and Inter, produced by this code from those pinned files.

Language

The code, its comments and its output are English. The documents under docs/ are German - they are working material rather than part of what ships. The reading order, if you want the argument rather than the tool:

Document What it settles
docs/superpowers/specs/2026-08-05-centerline-fonts-design.md the design of the whole thing
docs/research/2026-08-05-medialachse-und-voronoi.md, -skelett-pruning.md, -striche-und-werkzeuge.md what the literature says about medial axes, pruning and strokes
docs/research/2026-08-06-phase-2-messwerte.md the measurement instrument and the baselines
docs/research/2026-08-07-phase-3a-pruning.md why tau_beta is 1.03 and not the published 2.3
docs/research/2026-08-08-phase-3b-marken.md what the serif and terminal marks find, and miss
docs/research/2026-08-08-phase-3c-glaettung.md the spline fit, and the bound that does not hold
docs/superpowers/specs/2026-08-09-stiftbahn-fuer-sketchury-design.md the pen path: strokes, order, export

Licence

MIT, see LICENSE. The single file under baseline/ is the author's own earlier raster-thinning spike, the one this project grew out of. It is not executed - the raster baselines in the evaluation are reimplemented - and it is kept only because two statements in the evaluation cite it as their source. Same licence.

About

Single-stroke pen paths from any outline font. Computes the exact medial axis of the glyph's Bezier contours instead of rasterizing first, then prunes it, fits it as splines and splits it into strokes.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages