Can we predict a font's optical size from its shape?¶

The question. Given an arbitrary static font, can we measure its glyphs and output a sensible opsz value — the axis that distinguishes display, text, and caption cuts? If yes, we could auto-categorise the static fonts in Google Fonts without a human in the loop.

The hope. Display cuts have thinner thins, higher contrast, and smaller relative x-heights than text cuts. These are measurable. If the signal is consistent across designers, we can fit a model.

The corpus. Every variable font in google/fonts/ofl that ships an opsz axis — 42 fonts, 27 families. For each, we sampled 7 points across its opsz range and ran fontquant to extract ~20 parametric measurements at each point.

In [1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from pathlib import Path

sns.set_style("whitegrid")
plt.rcParams["figure.dpi"] = 100

CSV = Path.home() / "Type/opsz_research/measurements.csv"
df = pd.read_csv(CSV)
df.head()
Out[1]:
font family_dir is_italic location axis_opsz axis_wght XOFI XOLC XOPQ XTFI ... descender lowercase_a_style lowercase_g_style slant stencil stroke_contrast_angle stroke_contrast_ratio weight width x_height
0 Ballet[opsz].ttf ballet False opsz=16.0 16.00 NaN 20 41 17 219 ... -532.0 double_story double_story 0.554 False -59.15 0.01 0.140 0.712 316.000
1 Ballet[opsz].ttf ballet False opsz=25.33 25.33 NaN 19 40 15 211 ... -530.5 double_story double_story 0.553 False -58.27 0.01 0.135 0.703 303.836
2 Ballet[opsz].ttf ballet False opsz=34.67 34.67 NaN 13 39 14 202 ... -529.0 double_story double_story 0.553 False -58.91 0.01 0.131 0.694 291.664
3 Ballet[opsz].ttf ballet False opsz=44.0 44.00 NaN 18 37 12 194 ... -527.5 double_story double_story 0.553 False -58.91 0.01 0.126 0.685 279.500
4 Ballet[opsz].ttf ballet False opsz=53.33 53.33 NaN 17 36 11 185 ... -526.0 double_story double_story 0.553 False -58.90 0.01 0.121 0.677 267.336

5 rows × 27 columns

What we have to work with¶

308 rows: 42 fonts × ~7 sample points across each font's opsz range. Each row records the location and the measurements at that location.

In [2]:
print(f"Families: {df['family_dir'].nunique()}")
print(f"Fonts: {df['font'].nunique()}")
print(f"Total measurements: {len(df)}\n")
print("opsz range per font:")
df.groupby("font")["axis_opsz"].agg(["min", "max"]).head(8)
Families: 27
Fonts: 42
Total measurements: 308

opsz range per font:
Out[2]:
min max
font
Ballet[opsz].ttf 16.0 72.0
BigShouldersInline[opsz,wght].ttf 10.0 72.0
BigShouldersStencil[opsz,wght].ttf 10.0 72.0
BigShoulders[opsz,wght].ttf 10.0 72.0
BodoniModa-Italic[opsz,wght].ttf 6.0 96.0
BodoniModaSC-Italic[opsz,wght].ttf 6.0 96.0
BodoniModaSC[opsz,wght].ttf 6.0 96.0
BodoniModa[opsz,wght].ttf 6.0 96.0

One font at a time¶

Take Fraunces. As opsz goes from caption (5pt) to display (144pt), we'd expect vertical strokes to thin out, the thin/thick contrast to widen, and the relative x-height to shrink.

In [3]:
fraunces = (df[(df["family_dir"] == "fraunces") & (~df["is_italic"])]
            .sort_values("axis_opsz"))

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
for ax, metric, label in zip(
    axes,
    ["YOPQ", "stroke_contrast_ratio", "x_height"],
    ["YOPQ (vertical stem)", "stroke contrast ratio (thin/thick)", "x-height"],
):
    ax.plot(fraunces["axis_opsz"], fraunces[metric], "o-", color="C0")
    ax.set_xlabel("opsz")
    ax.set_title(label)
fig.suptitle("Fraunces Roman — measurements across the opsz axis", y=1.02)
plt.tight_layout()
plt.show()
No description has been provided for this image

Clean signal: YOPQ falls from ~43 to ~26 (thinner strokes at display sizes), contrast ratio falls (the thin/thick gap widens), x-height shrinks slightly. That's exactly the pattern a typographer would describe.

Does every family agree? Let's overlay a few.

In [4]:
families = ["fraunces", "dmsans", "merriweather", "inter", "playfair"]

fig, ax = plt.subplots(figsize=(8, 5))
for fam in families:
    g = df[(df["family_dir"] == fam) & (~df["is_italic"])].sort_values("axis_opsz")
    ax.plot(g["axis_opsz"], g["YOPQ"], "o-", label=fam, alpha=0.85)
ax.set_xlabel("opsz")
ax.set_ylabel("YOPQ")
ax.set_title("YOPQ vs opsz across 5 families")
ax.legend()
plt.show()
No description has been provided for this image

Same direction in every case (down and to the right), but each family has its own absolute level. So far this is encouraging — there's a real signal here.

Does it hold across the whole corpus?¶

For each (family, metric) pair, compute the Pearson correlation between the metric and opsz. A value near +1 or -1 means the metric tracks opsz tightly within that family; 0 means no relationship. Then summarise across all families.

In [5]:
from scipy.stats import pearsonr

METRICS = [
    "YOPQ", "YOLC", "YOFI", "XOPQ", "XTRA", "XTLC",
    "stroke_contrast_ratio", "x_height", "cap_height", "weight", "width",
]

rows = []
for (family, italic), g in df.groupby(["family_dir", "is_italic"]):
    if g["axis_opsz"].nunique() < 3:
        continue
    for m in METRICS:
        s = pd.to_numeric(g[m], errors="coerce")
        if s.nunique() < 2 or s.isna().all():
            continue
        r, _ = pearsonr(g["axis_opsz"], s)
        rows.append({"family": family, "metric": m, "r": r})
corr = pd.DataFrame(rows)

summary = (corr.groupby("metric")
           .agg(median_r=("r", "median"),
                pct_families_strong=("r", lambda s: (s.abs() > 0.7).mean()),
                n_families=("family", "nunique"))
           .sort_values("median_r"))
summary.style.format({"median_r": "{:+.2f}", "pct_families_strong": "{:.0%}"})
Out[5]:
  median_r pct_families_strong n_families
metric      
YOPQ -0.98 98% 26
XOPQ -0.97 79% 19
YOFI -0.97 89% 24
weight -0.97 95% 27
width -0.97 93% 27
YOLC -0.96 92% 25
x_height -0.95 77% 16
stroke_contrast_ratio -0.92 97% 24
XTLC -0.87 68% 26
XTRA -0.82 86% 23
cap_height +0.60 78% 6

Read the table this way:

  • median_r near ±1 means the metric moves in lockstep with opsz inside most families.
  • pct_families_strong is the fraction of families where the correlation is strong (|r| > 0.7).

Six metrics (YOPQ, YOLC, YOFI, weight, width, stroke contrast) sit at median_r ≈ -0.95 with 90%+ of families showing a strong relationship. The signal is real.

Then there's XTRA: median ≈ -0.8 but with much higher variance. Let's look at why.

In [6]:
xtra = corr[corr["metric"] == "XTRA"].sort_values("r")
colors = ["#d1495b" if r > 0 else "#33658a" for r in xtra["r"]]

fig, ax = plt.subplots(figsize=(9, 6))
ax.barh(xtra["family"], xtra["r"], color=colors)
ax.axvline(0, color="black", linewidth=0.5)
ax.set_xlabel("Pearson r (XTRA vs opsz)")
ax.set_title("XTRA: some families tighten counters at display, others open them")
plt.tight_layout()
plt.show()
No description has been provided for this image

About a third of families open XTRA at display sizes (red), the rest close it (blue). That's a real design split, not noise — and it's a warning sign.

It implies that any model trained on the "closing" families will predict opsz wrong for the "opening" ones, and vice versa.

So let's actually try to predict opsz¶

Take the strongest, most consistent metrics (skip XTRA), build scale-invariant ratios, and fit a model. The honest test: hold out one family at a time, fit on the rest, see how close the prediction is on the held-out family. This simulates the real use case — predicting opsz for a font the model has never seen.

Reading the result: MAE is the typical error in opsz points. The baseline is "always predict the median opsz" — if a model can't beat that, it's useless.

In [7]:
from sklearn.linear_model import Ridge
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import LeaveOneGroupOut
from sklearn.metrics import mean_absolute_error, r2_score

d = df.copy()
d["stem_to_xh"]  = d["YOPQ"] / d["x_height"]
d["hstem_to_xh"] = d["XOPQ"] / d["x_height"]
d["xh_to_cap"]   = d["x_height"] / d["cap_height"]

FEATURES = ["stem_to_xh", "hstem_to_xh", "xh_to_cap",
            "stroke_contrast_ratio", "weight", "width"]
d = d.dropna(subset=FEATURES + ["axis_opsz"]).reset_index(drop=True)
X, y, g = d[FEATURES], d["axis_opsz"], d["family_dir"]

def loo_cv(make_model):
    preds = np.empty(len(y))
    for train, test in LeaveOneGroupOut().split(X, y, g):
        m = make_model(); m.fit(X.iloc[train], y.iloc[train])
        preds[test] = m.predict(X.iloc[test])
    return mean_absolute_error(y, preds), r2_score(y, preds)

baseline_mae = mean_absolute_error(y, [y.median()] * len(y))
print(f"Baseline (always-predict-median opsz={y.median():.0f}): MAE = {baseline_mae:.1f} pt\n")
for name, make in [
    ("Ridge regression", lambda: Ridge(alpha=1.0)),
    ("Random Forest",    lambda: RandomForestRegressor(n_estimators=300, random_state=0)),
]:
    mae, r2 = loo_cv(make)
    verdict = "✅ better than baseline" if mae < baseline_mae else "❌ worse than baseline"
    print(f"{name:18s}  MAE = {mae:5.1f} pt   R² = {r2:+.2f}   {verdict}")
Baseline (always-predict-median opsz=35): MAE = 51.8 pt

Ridge regression    MAE =  70.8 pt   R² = -0.14   ❌ worse than baseline
Random Forest       MAE =  65.3 pt   R² = -0.38   ❌ worse than baseline

Both models do worse than guessing the median. The R² scores are negative, which means the models actively hurt — they're fitting noise that doesn't generalise.

We can also test the gentler version of the question: instead of predicting opsz in points, predict where on the font's own opsz range a measurement sits, on a 0–1 scale. That gives the model a much easier target (it doesn't have to know that Inter goes 14→32 while Fraunces goes 9→144).

In [8]:
def normalise(g):
    o = g["axis_opsz"]
    return (o - o.min()) / (o.max() - o.min()) if o.max() != o.min() else pd.Series(0.5, index=g.index)

d["opsz_norm"] = d.groupby("font", group_keys=False).apply(normalise)
y_norm = d["opsz_norm"]

baseline_mae_norm = mean_absolute_error(y_norm, [y_norm.median()] * len(y_norm))
print(f"Baseline MAE on 0-1 scale: {baseline_mae_norm:.2f} (i.e. typical error is {baseline_mae_norm * 100:.0f}% of the font's range)\n")

def loo_cv_norm(make_model):
    preds = np.empty(len(y_norm))
    for train, test in LeaveOneGroupOut().split(X, y_norm, g):
        m = make_model(); m.fit(X.iloc[train], y_norm.iloc[train])
        preds[test] = m.predict(X.iloc[test])
    return mean_absolute_error(y_norm, preds)

for name, make in [
    ("Ridge regression", lambda: Ridge(alpha=1.0)),
    ("Random Forest",    lambda: RandomForestRegressor(n_estimators=300, random_state=0)),
]:
    mae = loo_cv_norm(make)
    print(f"{name:18s}  MAE = {mae:.2f}  (typical error: {mae * 100:.0f}% of range)")
Baseline MAE on 0-1 scale: 0.29 (i.e. typical error is 29% of the font's range)

Ridge regression    MAE = 0.27  (typical error: 27% of range)
Random Forest       MAE = 0.24  (typical error: 24% of range)

Marginal improvement. Best model is off by ~25% of the font's opsz range on average — too coarse to be useful. "This font feels like somewhere between caption and text" is a wide bracket.

What this means¶

Optical size is not an objective, measurable property of glyph shapes. It's a designer-encoded relative axis: every type designer decides for their own family what "display" and "caption" should look like, and those decisions don't agree. A measurement profile that says "display" for Inter says something different for Fraunces, and any model trying to learn one universal mapping ends up wrong for half the corpus.

But the exercise wasn't wasted — we know two new things:

✅ Within a single family, the relationship between measurements and opsz is strong, monotonic, and predictable. This is enough to build a QA tool: verify that a VF's opsz axis actually does something and changes monotonically — a real and shippable use case.

✅ The strong-signal metrics (YOPQ, contrast, weight, x-height) remain useful if we have one or two known anchor points in the same family. A human-in-the-loop tagger that calibrates from a couple of known cuts could work — but it's a different product.

❌ Auto-predicting opsz for an unknown font is not a tooling gap. It's a property of the axis itself, and no amount of feature engineering will fix it within this approach.

Recommended next step¶

Pivot to the QA tool. It uses the same data and the same strong correlations, but it answers a question we know we can answer well: "is this font's opsz axis behaving correctly?" A one-week build, useful to the type design team, and grounded in evidence.