Download satr_model.py from steppelabs/xattot: direct link, hf CLI and curl.
- Browser
- Download file 4.19 kB
-
https://huggingface.co/steppelabs/xattot/resolve/main/satr_model.py
- Command line
-
hf download hf://steppelabs/xattot/satr_model.py
-
curl -L -o satr_model.py https://huggingface.co/steppelabs/xattot/resolve/main/satr_model.py
4.19 kB
| """Satr — Xattot's text-line recognizer (PyTorch definition, for fine-tuning). | |
| Inference does not need this file: `satr/satr.onnx` runs with onnxruntime only. | |
| Architecture: CNN (height 48 -> 1, width / 4) + 2 Transformer blocks (d=192, 4 heads) + linear head, trained with CTC. | |
| 3.26M parameters. Classes are copied unchanged from the training notebook, so the weights load with strict=True. | |
| import torch, satr_model | |
| model, chars = satr_model.load("satr/satr.safetensors", "satr/vocab.json") | |
| x = torch.from_numpy(line_gray_uint8[None, None]) # (1, 1, 48, W), white background, height 48 | |
| print(satr_model.decode(model(x).log_softmax(-1), chars)) | |
| Input: grayscale line image resized to height 48 (keep aspect ratio). CTC blank = index 0, class i = chars[i - 1]. | |
| License: CC BY-NC-SA 4.0 (see LICENSE). Fine-tuned weights may be shared under the same license with attribution. | |
| """ | |
| import json | |
| import torch | |
| import torch.nn.functional as F | |
| from torch import nn | |
| class Blok(nn.Module): | |
| def __init__(self, ci, co, s): | |
| super().__init__() | |
| self.c1 = nn.Conv2d(ci, co, 3, s, 1, bias=False); self.b1 = nn.BatchNorm2d(co) | |
| self.c2 = nn.Conv2d(co, co, 3, 1, 1, bias=False); self.b2 = nn.BatchNorm2d(co) | |
| self.sk = nn.Sequential(nn.Conv2d(ci, co, 1, s, bias=False), nn.BatchNorm2d(co)) if (ci != co or s != 1) else nn.Identity() | |
| def forward(self, x): | |
| return F.gelu(self.b2(self.c2(F.gelu(self.b1(self.c1(x))))) + self.sk(x)) | |
| class TBlok(nn.Module): | |
| # own Transformer block: nn.MultiheadAttention fixes the width in TorchScript ONNX export; shapes here stay dynamic | |
| def __init__(self, d, h=4, ff=3, p=0.1): | |
| super().__init__() | |
| self.h, self.p = h, p | |
| self.n1, self.qkv, self.o = nn.LayerNorm(d), nn.Linear(d, 3 * d), nn.Linear(d, d) | |
| self.n2, self.ff = nn.LayerNorm(d), nn.Sequential(nn.Linear(d, d * ff), nn.GELU(), nn.Linear(d * ff, d)) | |
| self.dr = nn.Dropout(p) | |
| def forward(self, x): # (N,T,d) | |
| q, k, v = self.qkv(self.n1(x)).unflatten(-1, (3, self.h, -1)).permute(2, 0, 3, 1, 4) | |
| a = F.scaled_dot_product_attention(q, k, v, dropout_p=self.p if self.training else 0.0) | |
| x = x + self.dr(self.o(a.transpose(1, 2).flatten(2))) | |
| return x + self.dr(self.ff(self.n2(x))) | |
| class Satr(nn.Module): | |
| def __init__(self, n_cls, d=192): | |
| super().__init__() | |
| self.cnn = nn.Sequential( | |
| nn.Conv2d(1, 32, 3, 1, 1, bias=False), nn.BatchNorm2d(32), nn.GELU(), | |
| Blok(32, 48, (2, 2)), Blok(48, 48, 1), # 24 x W/2 | |
| Blok(48, 96, (2, 2)), Blok(96, 96, 1), # 12 x W/4 | |
| Blok(96, 144, (2, 1)), Blok(144, 144, 1), # 6 x W/4 | |
| Blok(144, d, (2, 1)), Blok(d, d, 1)) # 3 x W/4 | |
| self.yig = nn.Conv2d(d, d, (3, 1), bias=False) # 1 x W/4 | |
| self.pos = nn.Conv1d(d, d, 9, padding=4, groups=d) | |
| self.tr = nn.Sequential(TBlok(d), TBlok(d)) | |
| self.out = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, n_cls)) | |
| def forward(self, x): # x: (N,1,48,W) uint8, or float already normalized to -1..1 | |
| x = (x.float() / 255.0 - 0.5) / 0.5 if x.dtype == torch.uint8 else x | |
| f = self.yig(self.cnn(x)).squeeze(2) # (N,d,T) | |
| f = f + self.pos(f) | |
| return self.out(self.tr(f.transpose(1, 2))) # (N,T,C) logits | |
| def load(weights="satr/satr.safetensors", vocab="satr/vocab.json", device="cpu"): | |
| from safetensors.torch import load_file | |
| chars = json.load(open(vocab, encoding="utf-8"))["chars"] | |
| model = Satr(len(chars) + 1) | |
| model.load_state_dict(load_file(weights, device="cpu"), strict=True) | |
| return model.to(device).eval(), chars | |
| def decode(logp, chars): | |
| """Greedy CTC decoding of (N,T,C) scores.""" | |
| out = [] | |
| for s in logp.argmax(-1).cpu().numpy(): | |
| p, t = 0, [] | |
| for c in s: | |
| if c != p and c != 0: | |
| t.append(chars[c - 1]) | |
| p = c | |
| out.append("".join(t)) | |
| return out | |