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:

  1. 308 measurements from 27 opsz variable-font families — sampled at 7 opsz points each, labelled by the opsz value (e.g. opsz=72 → Display+).
  2. 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.
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

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)
Out[1]:
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.

In [2]:
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()
No description has been provided for this image

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, normalised
  • XOPQ / x-height — horizontal stem weight, normalised
  • x-height / cap-height — proportion
  • XTRA / x-height — counter width, normalised
  • stroke_contrast_ratio — thin/thick (low = more contrast)
  • stroke_contrast_angle — direction of contrast
  • weight, 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.

In [3]:
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%
No description has been provided for this image

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.

In [4]:
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()
No description has been provided for this image

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¶

  1. Catch the middle. Label another ~50 fonts focused on the Text↔Subhead↔Display boundary, the model's weakest region.
  2. Caption. Locate 10 fonts intended for small body (Tinos, Cardo, Source Code Pro, more) and label them Caption to give the bucket weight.
  3. Confidence-aware UI. Show the bucket and a confidence band; recommend "see if it looks right" when confidence is under 50%.
  4. CLI integration. Ship gftools-classify-size <font> so non-experts can use it, plus a batch mode for catalogue-wide tagging.