crnn-rukopys
A compact CRNN + CTC line recognizer for handwritten Ukrainian document text, trained from scratch on the Rukopys dataset. At ~6.25M parameters with greedy CTC decode it is CPU-friendly — a lightweight alternative to transformer recognizers.
The training data included handwritten, printed and annotation region crops from Rukopys.
TL;DR
| value | |
|---|---|
| Architecture | CRNN: 6-layer CNN → BiLSTM (hidden 256) → linear CTC head |
| Parameters | ~6.25M |
| Trained | from scratch (no pretrained backbone), silver→gold curriculum |
| Alphabet | 340 characters + CTC blank |
| Handles | handwritten, printed, annotation |
| Gold-val CER / WER | 0.1539 / 0.4188 |
| Input | a single grayscale line crop, height 32 px, width ≤ 512 px, scaled to [-1, 1] |
| Output | the transcribed string (metric-normalized character set) |
Intended use
Recognizing cropped line/region images from handwritten Ukrainian documents, downstream of a layout detector — where a small, fast, CPU-capable recognizer is preferred over a transformer one.
For stronger recognition quality at ~90× the parameter count, see
Hukyl/trocr-large-rukopys.
Architecture
CNN feature extractor → BiLSTM sequence model → linear CTC head. No pretrained backbone — trained end-to-end on Rukopys crops.
- CNN: 6 conv layers (1→64→128→256→256→512→512, 3×3), BatchNorm after the
3rd and 5th conv, MaxPool to height 1 (pools
2,2 · 2,2 · (2,1) · (2,1)so width resolution is preserved for the sequence). - RNN: single bidirectional LSTM, hidden size 256 (512-d output).
- Head:
Linear(512 → 256)→ log-softmax → CTC (blank index 0). - Alphabet: 340 characters + CTC blank (341 classes total), built from
the silver+gold training vocabulary. Covers Ukrainian Cyrillic (incl.
і ї є ґ ў ѣ), Latin, digits, punctuation, and a long tail of typographic / mathematical symbols (–—«»“”№∑∫≈≤≥…etc.). - Input: grayscale, height-normalised to 32 px, width capped at 512 px,
scaled to
[-1, 1].
How to use
Note: resize crops with cv2.INTER_AREA — training used it, and INTER_LINEAR
aliases on the heavy downscale to 32 px height.
import cv2
import torch
import torch.nn as nn
from huggingface_hub import hf_hub_download
IMG_HEIGHT, IMG_MAX_WIDTH = 32, 512
class CRNN(nn.Module):
def __init__(self, num_classes: int, rnn_hidden: int = 256) -> None:
super().__init__()
self.cnn = nn.Sequential(
nn.Conv2d(1, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2),
nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2),
nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(), nn.MaxPool2d((2, 1)),
nn.Conv2d(256, 512, 3, padding=1), nn.BatchNorm2d(512), nn.ReLU(),
nn.Conv2d(512, 512, 3, padding=1), nn.ReLU(), nn.MaxPool2d((2, 1)),
)
self.rnn = nn.LSTM(512, rnn_hidden, bidirectional=True, batch_first=False)
self.fc = nn.Linear(rnn_hidden * 2, num_classes)
def forward(self, x):
feat = self.cnn(x)
_b, _c, h, _w = feat.shape
feat = feat.squeeze(2) if h == 1 else feat.mean(dim=2)
feat = feat.permute(2, 0, 1)
out, _ = self.rnn(feat)
return torch.log_softmax(self.fc(out), dim=-1)
ckpt = torch.load(hf_hub_download("Hukyl/crnn-rukopys", "best.pt"), weights_only=False)
chars = ckpt["chars"]
model = CRNN(len(chars), ckpt["config"]["rnn_hidden"])
model.load_state_dict(ckpt["model_state"])
model.eval()
gray = cv2.imread("line_crop.jpg", cv2.IMREAD_GRAYSCALE)
new_w = max(4, min(int(gray.shape[1] * IMG_HEIGHT / gray.shape[0]), IMG_MAX_WIDTH))
resized = cv2.resize(gray, (new_w, IMG_HEIGHT), interpolation=cv2.INTER_AREA)
tensor = (torch.from_numpy(resized).float().unsqueeze(0) / 127.5 - 1.0).unsqueeze(0)
with torch.inference_mode():
log_probs = model(tensor)[:, 0, :]
# greedy CTC decode (collapse repeats, drop blank=0)
idx, prev, out = log_probs.argmax(-1).tolist(), None, []
for i in idx:
if i != prev and i != 0:
out.append(i)
prev = i
print("".join(chars[i] for i in out))
The model expects single-line / single-region crops, not full pages.
Training curriculum
Two-stage silver→gold curriculum, trained from scratch. CTC loss (blank=0,
zero_infinity), AdamW, OneCycleLR schedule, online augmentation, best checkpoint
selected by lowest validation CER.
- Silver pretrain (20 ep, early stopping): CTC pretrain from scratch on the Rukopys silver split (auto-labeled crops): 119,346 train / 21,061 val.
- Gold fine-tune (30 ep): warm-start from the stage-1 checkpoint, with the alphabet grown to cover gold-only characters, on the human-labeled gold train split: 18,948 train / 3,345 val crops, stratified at crop level. The published weights are the best-val-CER epoch (29 of 30).
Hyperparameters (as launched)
| hyperparameter | value |
|---|---|
| epochs | 20 (silver) + 30 (gold), early stopping with patience 5 |
| batch size | 128 |
| learning rate | 1e-3 |
| weight decay | 1e-4 |
| optimizer / schedule | AdamW, OneCycleLR |
| gradient clipping | 5.0 |
| loss | CTC (blank=0, zero_infinity) |
| online augmentation | on (default profile, at working resolution) |
| eval | greedy CTC decode, full val each epoch |
| precision / device | fp16 mixed precision, CUDA |
| seed | 42 |
Online data augmentation
Online augmentation was applied during training, at working resolution: each crop was
first downscaled with INTER_AREA to a 64 px working height (2× the model's 32 px
input), augmented, then resized to the final 32 px tensor. Only training crops were
augmented (no augmented validation was measured).
Each crop was transformed once per epoch by one geometric + one or two photometric operations at random. Each class received a separate augmentation profile that was selected to minimize the distribution shift.
| region type | geometric (one) | photometric (one or two) |
|---|---|---|
| handwritten, annotation | margin pad 2–15% / trim 1–5%, rotation ±1–5°, elastic distortion (α=25, σ=5), baseline warp (amp 2–8 px, freq 0.5–2.0) | paper-colour shift (LAB a±10 / b±15), Gaussian noise (σ 5–15), JPEG recompression (q 30–65), contrast/gamma (0.7–1.3 / 0.6–1.5), morphological erode/dilate (kernel 2) |
| printed | margin pad 2–15% / trim 1–5%, rotation ±1–3° | paper-colour shift (a±5 / b±10), Gaussian noise (σ 3–10), JPEG recompression (q 40–70), contrast/gamma (0.8–1.2 / 0.8–1.3), morphological erode/dilate (kernel 2) |
These simulate scanner/paper variation, ink thinning/bleed, and natural handwriting deformation, widening the range of appearances beyond the raw training crops.
Results
Protocol: full held-out gold validation split filtered to the model's classes
(3,345 crops, no subsampling), greedy CTC decode, INTER_AREA resize (training
parity). Labels are metric-normalized so CER/WER reflect the scored character set.
Overall
| metric | value |
|---|---|
| CER | 0.1539 |
| WER | 0.4188 |
| exact-match accuracy | 0.2706 |
| n_samples | 3,345 |
Per class
| class | n | CER | WER | accuracy |
|---|---|---|---|---|
| handwritten | 3,223 | 0.1507 | 0.4132 | 0.2696 |
| annotation | 79 | 0.4496 | 0.7578 | 0.3924 |
| printed | 43 | 0.2716 | 0.6267 | 0.1163 |
We also acknowledge that printed/annotation n is quite small, so measuring CER against them is quite noisy.
Files
| file | description |
|---|---|
best.pt |
lowest-val-CER checkpoint (gold fine-tune, epoch 29) |
The checkpoint is a self-contained .pt payload: model weights, the alphabet, the
architecture config (rnn_hidden, img_height, img_max_width), and the
training-time metrics.
Limitations & biases
- Handwritten Ukrainian archival document material only; expect degradation on other scripts, languages, or modern born-digital text.
- The character set and normalization are tuned to the document-OCR metric — outputs are normalized text, not faithful transcription.
- Greedy CTC decode only — no language model or beam search.
- The train/val split is stratified at crop level, not page level — crops from the same page (and writer) appear on both sides, so the reported val CER is optimistic compared to a page-level protocol.
annotationandprintedclasses have small training support and higher CER;formulaandtableare unsupported.- Single training run — no across-run variance estimate.
Training data & attribution
| dataset | source | license | role |
|---|---|---|---|
| Rukopys | UkrainianCatholicUniversity/rukopys |
CC BY 4.0 | silver pretrain (auto-labeled) + gold fine-tune |
- Downloads last month
- 11
Dataset used to train Hukyl/crnn-rukopys
Evaluation results
- CER on Rukopys, gold valvalidation set self-reported0.154
- WER on Rukopys, gold valvalidation set self-reported0.419