Koshur-OCR / model.py
KashlabsAI's picture
Release Koshur OCR v2.1 — prefix beam + train-only trigram LM
e186a72 verified
Raw
History Blame Contribute Delete
5.06 kB
from __future__ import annotations
import math
import pickle
from functools import lru_cache
from pathlib import Path
from typing import Any
import numpy as np
import torch
import torch.nn as nn
from PIL import Image
IMAGE_HEIGHT = 64
IMAGE_WIDTH = 2048
HORIZONTAL_STRIDE = 4
RNN_HIDDEN = 256
RNN_LAYERS = 2
RNN_DROPOUT = 0.05
CHECKPOINT = Path(__file__).with_name("best-model.pt")
LANGUAGE_MODEL = Path(__file__).with_name("char-trigram-lm.pkl")
BEAM_CONFIG = {
"beam_width": 10,
"token_topk": 12,
"lm_weight": 0.4,
"token_bonus": 1.5,
}
class TemporalFeatureDropout(nn.Module):
def __init__(self, probability: float = 0.0):
super().__init__()
self.p = float(probability)
def forward(self, x: torch.Tensor) -> torch.Tensor:
if not self.training or self.p == 0.0:
return x
keep = x.new_empty((x.shape[0], x.shape[1], 1)).bernoulli_(1.0 - self.p)
return x * keep / (1.0 - self.p)
class KoshurCRNN(nn.Module):
def __init__(self, n_classes: int, temporal_dropout_p: float = 0.0):
super().__init__()
self.cnn = nn.Sequential(
nn.Conv2d(1, 48, 3, padding=1), nn.BatchNorm2d(48), nn.ReLU(), nn.MaxPool2d((2, 2)),
nn.Conv2d(48, 96, 3, padding=1), nn.BatchNorm2d(96), nn.ReLU(), nn.MaxPool2d((2, 2)),
nn.Conv2d(96, 160, 3, padding=1), nn.BatchNorm2d(160), nn.ReLU(), nn.MaxPool2d((2, 1)),
nn.Conv2d(160, 192, 3, padding=1), nn.BatchNorm2d(192), nn.ReLU(),
)
self.temporal_dropout = TemporalFeatureDropout(temporal_dropout_p)
self.rnn = nn.GRU(
192 * (IMAGE_HEIGHT // 8), RNN_HIDDEN, num_layers=RNN_LAYERS,
bidirectional=True, batch_first=True, dropout=RNN_DROPOUT,
)
self.head = nn.Linear(RNN_HIDDEN * 2, n_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
z = self.cnn(x)
z = z.permute(0, 3, 1, 2).contiguous().flatten(2)
z = self.temporal_dropout(z)
z, _ = self.rnn(z)
return self.head(z).log_softmax(-1).permute(1, 0, 2)
@lru_cache(maxsize=1)
def load_model() -> tuple[KoshurCRNN, dict[int, str]]:
checkpoint = torch.load(CHECKPOINT, map_location="cpu", weights_only=False)
chars = list(checkpoint["chars"])
architecture = checkpoint.get("architecture") or {}
temporal_dropout_p = float(architecture.get("temporal_dropout_p", 0.0))
model = KoshurCRNN(len(chars) + 1, temporal_dropout_p=temporal_dropout_p)
model.load_state_dict(checkpoint["model_state_dict"], strict=True)
model.eval()
return model, {i + 1: char for i, char in enumerate(chars)}
@lru_cache(maxsize=1)
def load_language_model():
# Trusted release artifact containing only fitted character n-gram counts.
with LANGUAGE_MODEL.open("rb") as handle:
return pickle.load(handle)
def preprocess(image: Image.Image) -> tuple[torch.Tensor, int, int]:
if image is None:
raise ValueError("Upload a cropped image containing one Kashmiri text line.")
normal = image.convert("L")
scale = IMAGE_HEIGHT / max(1, normal.height)
resized_width = max(1, min(IMAGE_WIDTH, int(normal.width * scale)))
normal = normal.resize((resized_width, IMAGE_HEIGHT), Image.Resampling.BICUBIC)
canvas = Image.new("L", (IMAGE_WIDTH, IMAGE_HEIGHT), 255)
canvas.paste(normal, (0, 0))
pixels = 1.0 - np.asarray(canvas, dtype=np.float32) / 255.0
input_length = max(
1,
min(IMAGE_WIDTH // HORIZONTAL_STRIDE, int(math.ceil(resized_width / HORIZONTAL_STRIDE))),
)
return torch.from_numpy(pixels).unsqueeze(0).unsqueeze(0), input_length, resized_width
def ctc_decode(ids: list[int], itos: dict[int, str]) -> str:
output: list[str] = []
previous = None
for token in ids:
if token != 0 and token != previous:
output.append(itos.get(token, ""))
previous = token
return "".join(output)[::-1]
def recognize_line(image: Image.Image, *, decoder: str = "beam") -> tuple[str, dict[str, Any]]:
if decoder not in {"beam", "greedy"}:
raise ValueError("decoder must be 'beam' or 'greedy'")
model, itos = load_model()
tensor, input_length, resized_width = preprocess(image)
with torch.inference_mode():
logits = model(tensor)
matrix = logits[:input_length, 0]
diagnostics: dict[str, Any] = {
"input_width": image.width,
"input_height": image.height,
"resized_width": resized_width,
"ctc_frames": input_length,
"decoder": decoder,
}
if decoder == "greedy":
text = ctc_decode(matrix.argmax(-1).tolist(), itos)
else:
from ctc_prefix_beam import prefix_beam_search
ids = prefix_beam_search(
matrix.cpu().numpy(),
lm=load_language_model(),
**BEAM_CONFIG,
)
# Prefix-beam output is already CTC-collapsed in increasing-x order.
text = "".join(itos[token] for token in ids)[::-1]
diagnostics.update(BEAM_CONFIG)
return text, diagnostics