Recommended-size classifier: v0¶
In the previous notebook we proved that predicting a font's opsz value as a number doesn't generalise across families — every designer encodes their own opsz scale differently.
But the actual ask is coarser: "Tell me Lobster is for 24pt and above." That's a bucketed classification problem, not a regression one. Buckets absorb the cross-family variance: everyone agrees that Lobster is a Display font, even if they'd disagree on whether it's a 60pt or 72pt design.
This notebook walks through the v0 classifier and shows it working on real fonts.
The buckets¶
Five ordinal labels, each tied to a minimum recommended size:
| Bucket | Min size | What lives there |
|---|---|---|
| Caption | 5–9pt | Designed for very small body, larger x-height, low contrast |
| Text | 9–14pt | Standard body — Inter, Roboto, Source Sans, most serifs |
| Subhead | 14–24pt | Tighter or sharper than body — Oswald, Cormorant, Slabo27px |
| Display | 24–48pt | Headline-only serifs / sans — Playfair Display, Abril Fatface |
| Display+ | 48pt+ | Decorative, extreme, or single-purpose — Lobster, Bungee, Anton |
Training data¶
Two sources, mixed together:
- 308 measurements from 27
opszvariable-font families — sampled at 7 opsz points each, labelled by the opsz value (e.g. opsz=72 → Display+). - 65 hand-labelled static fonts — one row per font, measured at its default location, labelled against the rubric above. This second pass exists to teach the model that a regular-weight sans face like Source Sans at wght=400 is Text, not Display+ — without these examples, the model misread "low ink mass" as a display signal.
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
ROOT = Path.home() / "Type/opsz_research"
df_vf = pd.read_csv(ROOT / "measurements.csv").assign(source="opsz_vf")
df_static = pd.read_csv(ROOT / "classifier/measurements_static.csv").assign(source="static")
df = pd.concat([df_vf, df_static], ignore_index=True)
BUCKETS = ["Caption", "Text", "Subhead", "Display", "Display+"]
BOUNDS = [(5, 9), (9, 14), (14, 24), (24, 48), (48, 300)]
def bucket(o):
for i, (lo, hi) in enumerate(BOUNDS):
if lo <= o < hi:
return i
return 4
df["bucket"] = df["axis_opsz"].apply(bucket)
df["bucket_name"] = df["bucket"].apply(lambda i: BUCKETS[i])
print(f"Training rows: {len(df)} ({(df['source']=='opsz_vf').sum()} from opsz VFs, {(df['source']=='static').sum()} hand-labelled)")
df.groupby(["bucket_name", "source"]).size().unstack(fill_value=0).reindex(BUCKETS)
Training rows: 373 (308 from opsz VFs, 65 hand-labelled)
| source | opsz_vf | static |
|---|---|---|
| bucket_name | ||
| Caption | 26 | 1 |
| Text | 30 | 38 |
| Subhead | 54 | 8 |
| Display | 72 | 6 |
| Display+ | 126 | 12 |
Class distribution leans heavily Display+ (because opsz VFs sample uniformly over a range that often reaches 144pt, and lots of those samples land in Display+). The hand-labels rebalance the Text bucket a bit. Caption is still thin — only one true caption-only static face exists in Google Fonts (Slabo13px).
Quick look at the feature space: every training point projected into two of the strong signals (stem-to-x-height ratio vs stroke contrast ratio), coloured by bucket.
d = df.copy()
d["stem_to_xh"] = d["YOPQ"] / d["x_height"]
d = d.dropna(subset=["stem_to_xh", "stroke_contrast_ratio"]).reset_index(drop=True)
fig, ax = plt.subplots(figsize=(8, 5.5))
palette = sns.color_palette("viridis", n_colors=len(BUCKETS))
for i, name in enumerate(BUCKETS):
sub = d[d["bucket"] == i]
ax.scatter(sub["stem_to_xh"], sub["stroke_contrast_ratio"],
alpha=0.55, s=22, color=palette[i], label=name,
edgecolors="black", linewidths=0.2)
ax.set_xlabel("YOPQ / x-height (heavier vertical stems → right)")
ax.set_ylabel("stroke_contrast_ratio (lower = more visual contrast)")
ax.set_title("Training points in feature space, coloured by bucket")
ax.legend(title="Bucket")
plt.tight_layout()
plt.show()
The clusters overlap significantly — there's no clean two-feature split between buckets. The model has to do its work in higher-dimensional space (we feed it eight features), where the boundaries are sharper. The takeaway: humans can read this chart and see a rough trend (Display+ skews to the upper-right, Text/Subhead live in the middle), but no single feature is the answer.
The model¶
A 5-class Random Forest on eight features:
YOPQ / x-height— vertical stem weight, normalisedXOPQ / x-height— horizontal stem weight, normalisedx-height / cap-height— proportionXTRA / x-height— counter width, normalisedstroke_contrast_ratio— thin/thick (low = more contrast)stroke_contrast_angle— direction of contrastweight,width— fontquant's overall ink/density measures
We evaluate with leave-one-family-out cross-validation — the realistic test, because at deploy time the model will see fonts from families it never trained on.
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import LeaveOneGroupOut
from sklearn.preprocessing import StandardScaler
FEATURES = ["stem_to_xh", "hstem_to_xh", "xh_to_cap", "xtra_to_xh",
"stroke_contrast_ratio", "stroke_contrast_angle", "weight", "width"]
d["hstem_to_xh"] = d["XOPQ"] / d["x_height"]
d["xh_to_cap"] = d["x_height"] / d["cap_height"]
d["xtra_to_xh"] = d["XTRA"] / d["x_height"]
d = d.dropna(subset=FEATURES).reset_index(drop=True)
X, y, g = d[FEATURES].values, d["bucket"].values, d["family_dir"].values
preds = np.empty_like(y)
for tr, te in LeaveOneGroupOut().split(X, y, g):
scaler = StandardScaler().fit(X[tr])
m = RandomForestClassifier(n_estimators=400, max_depth=6,
class_weight="balanced", random_state=0)
m.fit(scaler.transform(X[tr]), y[tr])
preds[te] = m.predict(scaler.transform(X[te]))
exact = (preds == y).mean()
off_by_one = (np.abs(preds - y) <= 1).mean()
print(f"Exact accuracy: {exact:.0%}")
print(f"Off-by-one accuracy: {off_by_one:.0%} (predicting Subhead when truth is Display, etc.)")
print(f"Baseline (majority class): {pd.Series(y).value_counts(normalize=True).max():.0%}")
cm = confusion_matrix(y, preds)
fig, ax = plt.subplots(figsize=(6.5, 5))
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", cbar=False,
xticklabels=BUCKETS, yticklabels=BUCKETS, ax=ax)
ax.set_xlabel("Predicted"); ax.set_ylabel("Truth")
ax.set_title("Confusion matrix (held-out predictions)")
plt.tight_layout(); plt.show()
Exact accuracy: 39% Off-by-one accuracy: 69% (predicting Subhead when truth is Display, etc.) Baseline (majority class): 37%
Mass concentrates near the diagonal (and adjacent), which is what we want from an ordinal classifier — when it's wrong, it's usually wrong by one neighbour bucket, not by two or three. In plain English: "if it says Display, it means somewhere between Subhead and Display+ — and it'll say Display+ for the obviously-display things".
Now the actual demo.
Live demo¶
Classify seven well-known fonts the model has never seen during this training run. The bar chart shows the model's probability mass over the five buckets; the top of each chart is the predicted bucket and its confidence.
import joblib
from fontquant import quantify
PKG = joblib.load(ROOT / "classifier/model.joblib")
model, scaler, features = PKG["model"], PKG["scaler"], PKG["features"]
OFL = Path.home() / "Type/fonts/ofl"
DEMO = [
("Lobster", OFL / "lobster/Lobster-Regular.ttf"),
("Bungee", OFL / "bungee/Bungee-Regular.ttf"),
("Playfair Display", OFL / "playfairdisplay/PlayfairDisplay[wght].ttf"),
("Big Shoulders Display", OFL / "bigshouldersdisplay/BigShouldersDisplay[wght].ttf"),
("Source Sans 3", OFL / "sourcesans3/SourceSans3[wght].ttf"),
("Montserrat", OFL / "montserrat/Montserrat[wght].ttf"),
("Open Sans", OFL / "opensans/OpenSans[wdth,wght].ttf"),
]
def features_for(path):
res = quantify(str(path), includes=["appearance"], locations=None)
m = {k: v.get("value") for k, v in res.get("appearance", {}).items()}
return [m["YOPQ"]/m["x_height"], m["XOPQ"]/m["x_height"],
m["x_height"]/m["cap_height"], m["XTRA"]/m["x_height"],
m["stroke_contrast_ratio"], m["stroke_contrast_angle"],
m["weight"], m["width"]]
results = []
for name, path in DEMO:
probs = model.predict_proba(scaler.transform([features_for(path)]))[0]
results.append((name, probs))
fig, axes = plt.subplots(len(results), 1, figsize=(8, 1.3 * len(results)), sharex=True)
for ax, (name, probs) in zip(axes, results):
top = int(np.argmax(probs))
colors = ["#cccccc"] * len(BUCKETS)
colors[top] = "#2a6fbf"
ax.barh(BUCKETS, probs, color=colors)
ax.set_title(f"{name} → {BUCKETS[top]} ({probs[top]:.0%})", loc="left", fontsize=10)
ax.set_xlim(0, 1)
ax.tick_params(labelsize=8)
axes[-1].set_xlabel("Predicted probability")
plt.tight_layout()
plt.show()
Read of the demo¶
- Lobster — Display+ at 74%. The flagship example, classified confidently.
- Bungee — Display+ at 61%. The original opsz-VF-only model called this Caption (wildly wrong) because Bungee's measurements are out-of-distribution relative to conventional opsz VFs; the hand-labels fixed it.
- Playfair Display — Display+ at 43%, Display at 32%. The model can't cleanly separate Display from Display+ here and hedges — typographically reasonable.
- Big Shoulders Display — Display+, 35%. Right answer, low confidence; could be solidified with a few more decorative-condensed labels.
- Source Sans 3 — Text at 38%. Used to be confidently Display+ before the lean-sans labels.
- Montserrat — Subhead at 34%, Text close behind. Either is defensible.
- Open Sans — Text at 44%. Solid.
All seven land in a typographically defensible bucket. Confidence is uneven — high on extremes, low in the middle — which is the right shape for honest output.
What works¶
- The headline use case ("point at Lobster, get back '24pt and above'") works, with reasoning.
- Generalises across families because buckets are coarse enough to absorb design-philosophy variance.
- Hand-labels are cheap to add. Round 2 (45 labels) took an afternoon; round 3 (20 lean-sans) took thirty minutes. Each addition fixes a specific failure mode visibly.
What doesn't¶
- The model is genuinely uncertain in the middle (Text vs Subhead vs Display). Expect 35–45% confidence on borderline fonts, not 90%.
- Caption is undertrained. We have one labelled caption-only static font and a handful of opsz-VF caption samples. If Caption recommendations matter, that bucket needs around 10 more labels.
- Truly novelty fonts (Bungee, Press Start 2P) are out-of-distribution from the original training data — they only work because we labelled them explicitly. Other novelty cuts not yet labelled would be unreliable.
Roadmap to v1¶
- Catch the middle. Label another ~50 fonts focused on the Text↔Subhead↔Display boundary, the model's weakest region.
- Caption. Locate 10 fonts intended for small body (Tinos, Cardo, Source Code Pro, more) and label them Caption to give the bucket weight.
- Confidence-aware UI. Show the bucket and a confidence band; recommend "see if it looks right" when confidence is under 50%.
- CLI integration. Ship
gftools-classify-size <font>so non-experts can use it, plus a batch mode for catalogue-wide tagging.