de-htr-web-v2 / README.md
naeyn's picture
Link app source tag v0.2.0 in release identity
6241c1f verified
|
Raw
History Blame Contribute Delete
8.31 kB
---
license: apache-2.0
language:
- de
pipeline_tag: image-to-text
library_name: onnx
datasets:
- fhswf/german_handwriting
tags:
- handwriting-recognition
- handwritten-text-recognition
- htr
- ocr
- german-handwriting
- handschrifterkennung
- ctc
- onnx
- onnxruntime-web
- browser
- offline
- wasm
- image-to-text
- german
- resnet
- bilstm
metrics:
- cer
- wer
model-index:
- name: de-htr-web-v2
results:
- task:
type: image-to-text
name: Handwritten Text Recognition (line-level)
dataset:
type: scads-ai-german-handwriting
name: ScaDS.AI German Handwriting (sealed writer-disjoint test split)
metrics:
- type: cer
value: 8.01
name: Character Error Rate (int8, greedy, batch 1)
- type: wer
value: 32.3
name: Word Error Rate (int8, greedy, batch 1)
---
# DE·HTR v2 — German handwriting recognition in the browser
**Offline German handwritten text recognition (HTR / Handschrifterkennung) that
runs entirely client-side: a 10.4 MB int8 ONNX model for ONNX Runtime Web, no
image upload, no per-call cost, no GPU required.**
`de-htr-web-v2` is a compact line-level CTC recognizer for **modern German
handwriting** (Kurrent/Sütterlin are out of scope). It is the successor to
[`naeyn/de-htr-web`](https://huggingface.co/naeyn/de-htr-web) v1 and beats it on
the identical sealed benchmark while carrying a fully audited, license-clean
training-data ancestry.
- **Live demo:** https://de-htr-web.vercel.app
- **Input:** one pre-cropped handwriting line image (any common format)
- **Output:** Unicode transcription via greedy CTC decoding
- **Privacy:** after the page and model load, inference makes no network requests
## Results
Benchmark: 601 handwritten lines from 78 held-out writers of the ScaDS.AI German
handwriting dataset — writer-disjoint from all training data, aligned with v1's
sealed test writers (38 shared + 40 additional unseen). Greedy CTC decode, no
language model, batch size 1 (exactly how the browser runs it). v1 was
re-evaluated under the identical protocol.
| model | lines CER ↓ | WER ↓ | size |
|---|---:|---:|---:|
| **v2 int8 (this release, `model.onnx`)** | **8.01%** | **32.3%** | 10.4 MB |
| v2 fp32 (`model-fp32.onnx`) | 8.00% | 32.3% | 40.3 MB |
| v1 int8 (previous release) | 8.61% | 33.9% | 9.84 MB |
Per-writer error is heavy-tailed (median writer ≈ 6–8% CER, hardest writers
30%+): expect noticeably better results on clean, well-scanned handwriting and
worse on faint pencil, dense corrections, or unusual styles.
## Release identity
The companion browser app ships this model as of source tag
[`v0.2.0`](https://github.com/naeyn/de-htr-web/releases/tag/v0.2.0) of
[naeyn/de-htr-web](https://github.com/naeyn/de-htr-web).
| file | SHA-256 |
|---|---|
| `model.onnx` (int8) | `b576a0a1281b9be46b2574028b75575e041b7d7cb650f063886e733467cc1499` |
| `model-fp32.onnx` | `68b46411b8236b44d0d98865909d217ef13b41f12db769993c67eeb8d6e8c45a` |
| `config.json` | `4ebd69d9d27b398ea3997b031318accc13123f6a8950def1fedc6e364cb758fa` |
Architecture: ImageNet-initialized **ResNet18 stem (through layer3) + 3×BiLSTM-256
+ CTC head**, 10.06M parameters. Grayscale input at height 64, dynamic width up
to 1024, output stride 4, 112-symbol alphabet (blank + 111 characters covering
≥ 99.99% of benchmark characters).
## ⚠️ Breaking changes vs v1
v2 is **not weight- or interface-compatible** with v1 — that is why it lives in
a separate repository. Read `config.json` (`format_version: 2`) instead of
hard-coding:
| | v1 | v2 |
|---|---|---|
| input tensor | `images`, 3-channel RGB | `image`, **1-channel grayscale** |
| normalization | external ImageNet mean/std | **inside the model** — feed raw [0,1] |
| output tensor | `log_probs` | `logits` (raw; argmax decode unchanged) |
| alphabet | 195 symbols | **112 symbols** |
| max width | 2048 | **1024** |
## Usage
### JavaScript (ONNX Runtime Web — the intended path)
```js
import * as ort from "onnxruntime-web";
const config = await (await fetch("config.json")).json();
const session = await ort.InferenceSession.create("model.onnx");
// preprocess: grayscale, resize to height 64 keeping aspect ratio,
// right-pad width to a multiple of 4 with white, scale to [0,1]
const tensor = new ort.Tensor("float32", pixels, [1, 1, 64, width]);
const { logits } = await session.run({ image: tensor });
// greedy CTC decode: per-frame argmax, collapse repeats, drop blank (index 0)
```
### Python (onnxruntime)
```python
import numpy as np, onnxruntime as ort
from PIL import Image
cfg = json.load(open("config.json"))
img = Image.open("line.png").convert("L")
w = max(4, round(img.width * 64 / img.height) // 4 * 4)
img = img.resize((w, 64))
x = np.asarray(img, dtype=np.float32)[None, None] / 255.0
session = ort.InferenceSession("model.onnx")
logits = session.run(None, {"image": x})[0] # (1, w//4, 112)
ids = logits.argmax(-1)[0] # greedy CTC
text = "".join(cfg["alphabet"][i - 1] for i, prev in zip(ids, np.r_[0, ids[:-1]])
if i != 0 and i != prev)
print(text)
```
## Training recipe
Trained from scratch (no v1 weights) on a single RTX 5070 Ti in ~3 hours:
| setting | value |
|---|---|
| init | ImageNet ResNet18 stem, no synthetic pretraining |
| data | ScaDS gold lines + word crops (weight 0.3) + 5,240 page-XML extra lines + 9,909 fhswf lines |
| schedule | 65 epochs cosine, AdamW, lr 3e-4, weight decay 1e-4, batch 32 |
| batch composition | ≥ 45% gold-line quota per batch |
| augmentation | mild deterministic geometric/photometric on real images |
| seed | 20260801 (selected on validation-line CER from two independent seeds) |
**Ablation headlines** (full table in the research repo): ImageNet stem −1.3
CER; adding the fhswf corpus −0.94; *dropping* synthetic-data batch mixing
−1.28 at matched exposure; synthetic-only pretraining saturates at 0.25
epoch-equivalents and then hurts transfer; frame-wise two-seed ensembling fails
for CTC (alignment blur). The negative synthetic results are reported
deliberately — the license-clean synthetic renderer and corpus lock ship as a
separate research artifact.
## Training data and provenance
The released weights were trained **exclusively on real handwriting**:
| corpus | license | role |
|---|---|---|
| ScaDS.AI German Line/Word Handwriting ([DOI 10.5281/zenodo.18301532](https://doi.org/10.5281/zenodo.18301532)) | CC BY 4.0 | gold lines, word crops, page-XML extra lines (train writers only) |
| [`fhswf/german_handwriting`](https://huggingface.co/datasets/fhswf/german_handwriting) | AFL-3.0 | cross-domain auxiliary training (9,909 lines; training only) |
Not in the training path of these weights: synthetic rendered text, Leipzig
corpora, VLM pseudo-labels, IAM. Splits are writer-disjoint and pinned; the
sealed test writers were never trained on. Source images are **not**
redistributed here. Full dataset-level attribution: `NOTICE-training-data.md`.
## Limitations
- Modern German handwriting only — no historical scripts (Kurrent, Sütterlin),
no printed-text OCR guarantee, weak on heavy math/symbols.
- Line-level input: you must segment pages into lines first (the demo app crops
lines; full-page layout analysis is out of scope for the model).
- Heavy-tailed writer difficulty (see Results) — evaluate on your own
handwriting distribution before depending on it.
- Alphabet is 112 symbols; characters outside it (e.g. curly quotes, accented
Romance characters) cannot be emitted.
- Greedy decode has no language model; a domain lexicon or LM rescoring layer
can further reduce word errors downstream.
## License and attribution
Weights: **Apache-2.0** for the rights held by the project. ScaDS.AI data used
under CC BY 4.0 (Burghardt, Alzin, Nestler et al., ScaDS.AI / Universität
Leipzig; changes: cropping, grayscale normalization, NFC text normalization,
writer-disjoint re-splitting). fhswf data used under AFL-3.0 for training only.
Dataset-level attribution in `NOTICE-training-data.md`.
## Citation
```bibtex
@software{de_htr_web_v2,
title = {DE·HTR v2: German handwriting recognition in the browser},
author = {naeyn},
year = {2026},
version = {2.0.0},
url = {https://huggingface.co/naeyn/de-htr-web-v2},
license = {Apache-2.0}
}
```