xattot / satr_model.py
otabektorayev's picture
Xattot v1: Satr v1 (ONNX + safetensors), page pipeline, model card, benchmark
ea52c5b verified
Raw History Blame Contribute Delete
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