Rosetta
Online recognition of handwritten mathematics (trace -> LaTeX) with extensible architecture for drawing recognition.
#🪨 Rosetta — the handwritten notebook that answers back
Take notes by hand. End a calculation with = and the answer writes itself.
Draw something and the notebook names it.
Online handwriting recognition from raw pen trajectory (ink, not images), in the spirit of the iPad's Math Notes — one task-agnostic ink encoder, multiple heads, all trained locally on a laptop GPU.
Real output: the blue ink is handwritten; every orange mark was drawn by the models.
#📓 What it does
The web app is a notebook: a long ruled sheet (with pages) where you write anything — class notes, text, math, doodles — with pen, eraser and undo. Two things make it answer back:
1. Math solves itself. End a calculation with = and pause the pen for a second.
The page's strokes are segmented amid the notes (no lasso, no selection), the
calculation is recognized (seq2seq, ink → LaTeX), solved with SymPy, and the answer
draws itself in orange ink right after the =:
you write: 2 + 3 = the notebook writes: 2 + 3 = 𝟓 (orange, animated) you write: 2x + 4 = 10 the notebook writes: x = 3
Re-running never duplicates — the orange ink itself marks a calculation as done.
There's an auto toggle and a manual resolver = button; a side panel keeps the
solved history rendered with KaTeX.
2. Drawings get named. Sketch something, press desenho?: the most recent
drawing (spatially clustered strokes) goes to a classifier trained on Google
QuickDraw — 21 categories, 92% validation accuracy — and the label is written in
orange ink under the drawing ("gato", "casa", "bicicleta"…), confidence in the toolbar.
3. Equations get identified and plotted. Write x²+y²=4 and the notebook
recognizes it, classifies it — "circunferencia · raio 2 · centro (0,0)" — and
sketches the graph with axes in orange ink right below, R² and R³:
conics (line, circle, ellipse, parabola, hyperbola), quadrics (sphere, ellipsoid,
paraboloids, hyperboloids, cone, plane — drawn as isometric wireframes) and arbitrary
y = f(x) / z = f(x,y) graphs. Classification is classic linear algebra
(quadratic-form eigenvalues via SymPy + numpy), so rotated and translated forms work
too. One-variable equations like 2x+4=10 get their solution (x = 3) written below.
Everything persists locally: pages, ink and history survive reloads.
#🧠 Architecture: one encoder, multiple heads
The core design decision (ADR 0006): the input and the encoder are task-agnostic. The same BiGRU ink encoder powers both heads today — swapping tasks is a config line.
flowchart LR
A["🖊️ Online ink<br/>strokes = points {x, y}"] --> B["Per-point features<br/>x, y, dx, dy, pen, eos"]
B --> C["<b>Ink encoder</b><br/>BiGRU (or Transformer)<br/><i>task-agnostic</i>"]
C --> D["<b>LaTeX head</b><br/>Transformer decoder<br/>beam search · 6.4M params"]
C --> E["<b>Sketch head</b><br/>classifier · 2.1M params<br/>21 QuickDraw classes"]
D --> F["2 + 3 = → 5 (SymPy)"]
E --> G["🐱 'gato · 87%'"]
There is one single ink contract (schemas/ink.schema.json), mirrored in
TypeScript (web), Pydantic (api) and dataclasses (ml) — training and inference use
the exact same representation. Geometry is canonicalized server-side
(bbox normalization + fixed-step arc-length resampling), so pen density and scale
never matter.
#📊 Status & results
| Phase | Deliverable | Status |
|---|---|---|
| 0 | Scaffold, InkML→tensors, LaTeX tokenizer, shared ink schema | ✅ |
| 1 | seq2seq proof: overfit on 32 real CROHME samples | ✅ exact_match = 1.0 |
| 2 | Augmentation, beam search, full CROHME training (8.9k, 60 epochs) | ✅ retrained with fixed-step resampling: CER 1.41 → 0.81 on valid (exact 5%), density-invariant by construction — MathWriting is the next jump |
| 3 | /recognize + /evaluate (SymPy) + KaTeX rendering |
✅ verified end-to-end |
| 3.5 | The notebook: free-form pages, math detected amid notes, answer drawn as ink | ✅ verified live in the browser |
| 4 | Sketch head on the same encoder (QuickDraw, 21 classes) | ✅ 92% val accuracy, live in the notebook |
| 5 | Equations → identified (conics/quadrics/functions) and plotted as ink, R² & R³ | ✅ pipeline verified; served with the retrained checkpoint |
The honest caveat: CROHME alone is small, so the math recognizer still struggles with
long expressions (5% exact on valid) — but the retrain with fixed-step resampling cut
the character error rate from 1.41 to 0.81 and made recognition independent of
pen density by construction: interpolating the same ink ×3 now yields bit-identical
features and 15/15 identical predictions (it used to drop exact-match from 12/15 to
0/15). Scaling to MathWriting (~230k samples) is the next jump.
Details in docs/roadmap.md · decisions in docs/adr/.
#🚀 Run the notebook
# terminal 1 — model API (loads both checkpoints at startup) .\scripts\serve_api.ps1 # defaults: contas + desenhos, CPU, :8000 # terminal 2 — the notebook cd web; npm install; npm run dev # → http://localhost:3000
Write 2 + 3 = and pause; draw a cat and press desenho?. Without checkpoints the
API starts in stub mode (HTTP 501) — handy for frontend work against the contract.
If a port gets stuck (orphaned server on Windows):
Get-NetTCPConnection -LocalPort 8000 -State Listen | % OwningProcess | % { Stop-Process -Id $_ -Force }
#🧪 Prove the pipeline in 2 minutes (no dataset download)
The repo ships a synthetic ink generator — enough to prove data → training → inference with zero downloads:
uv sync # or venv + PYTHONPATH=ml/src python -m hmer_ml.data.synth --out data/synth --n 32 # 1. synthetic InkML python -m hmer_ml.train --config ml/configs/overfit_synth.yaml # 2. ~2 min on GPU python -m hmer_ml.evaluate --config ml/configs/overfit_synth.yaml \ --ckpt checkpoints/overfit_synth/last.ckpt # 3. exact_match = 1.0
💡 RTX 50xx GPUs (Blackwell) require the cu128 PyTorch build:
pip install torch --index-url https://download.pytorch.org/whl/cu128.
#🏋️ Training on real data
# math: CROHME (~9k expressions; see docs/datasets.md for download) python -m hmer_ml.train --config ml/configs/crohme.yaml python -m hmer_ml.evaluate --config ml/configs/crohme.yaml \ --ckpt checkpoints/crohme_rs/last.ckpt --root data/crohme/valid --beam 4 # sketches: QuickDraw (Phase 4 — same encoder, classification head) python scripts/download_quickdraw.py # 21 categories, ~170 MB (byte-range) python -m hmer_ml.train_sketch --config ml/configs/quickdraw.yaml # ~50 min on RTX 5050
Built for a single laptop GPU (6–8 GB): mixed precision, gradient accumulation,
length bucketing, and checkpointing with automatic resume — interrupt with
Ctrl+C and pick up right where it left off with the same command.
| Dataset | Samples | Role |
|---|---|---|
| CROHME (2011–2019) | ~8.9k train + test sets | math — current checkpoint (crohme_rs, CER 0.81 on valid) |
| MathWriting (Google, 2024) | ~230k human + 400k synthetic | math — the real fix for generalization (next) |
| QuickDraw (simplified) | 21 classes × ~5.8k used | sketches — trained, 92% val accuracy |
#📁 Monorepo layout
├── ml/ # PyTorch: data (InkML/QuickDraw), tokenizer, encoder/heads, train loops, │ ├── configs/ # beam search, "=" segmentation. YAML configs with inheritance (_base_) │ └── tests/ # 40 tests ├── api/ # FastAPI: /recognize (ink→LaTeX), /evaluate (SymPy), /page/process │ └── tests/ # (notebook: contas amid notes), /sketch/recognize. Hershey ink font. │ # 63 tests ├── web/ # Next.js: the notebook — pages, pens/eraser/undo, auto-solve, KaTeX panel ├── xournalpp-plugin/ # paused: Lua plugin with the same backend (no stroke events in Lua API) ├── scripts/ # serve_api.ps1, dataset downloads, glyph generator, e2e checks ├── schemas/ # ink.schema.json — single ink contract (web = api = ml) └── docs/ # vision, datasets, roadmap, and ADRs (architecture decisions)
Xournal++ (paused experiment): before the notebook, the same backend powered a Lua plugin for Xournal++ (
scripts/install_plugin.ps1, Ctrl+M). It works, but the Lua API has no stroke events — no write-time trigger — so the web notebook became the primary interface.
#🔩 Architecture decisions (ADRs)
| # | Decision | Why |
|---|---|---|
| 0001 | Online ink, not images | trajectory beats pixels for handwriting; native InkML datasets |
| 0002 | BiGRU by default, Transformer optional | fits in 6–8 GB VRAM; swappable via config |
| 0003 | Custom LaTeX tokenizer | \frac is one token; closed vocab from the dataset |
| 0004 | Single shared ink schema | training and inference use the same representation |
| 0005 | uv workspace | Python monorepo with a reproducible lockfile |
| 0006 | Encoder + pluggable heads | delivered: the sketch head reused the encoder unchanged |
#🗺️ Roadmap
- Fix math generalization, step 1: CROHME retrained with fixed-step resampling (train = inference, like the sketch head) — CER 1.41 → 0.81
- Step 2: scale to MathWriting (~230k samples), the real generalization jump
- Phase 4 (next): more sketch categories; auto-triage math/text/sketch in the
same pass (replace the
=pair heuristic with a learned detector) - Report CER/exact-match on the CROHME 2019 test set after the retrain
- Future: multimodal fusion (ink + rendered image) and LLM-based output refinement