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.
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()
| 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.
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:
| 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.
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()
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.
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()
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.
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%}"})
| 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_rnear ±1 means the metric moves in lockstep with opsz inside most families.pct_families_strongis 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.
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()
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.
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).
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.