| """
|
| inference.py
|
| ============
|
| Inference script for the Kurdish handwritten word recognition models in the
|
| Karez/KHWR repository.
|
|
|
| The script auto-detects the model family from the config.json located next to
|
| the model file, so the same command works for any of the four architectures
|
| (Baseline, Luong, MHSA, FAA) and for the fine-tuned FAA checkpoints.
|
|
|
| Examples:
|
| # Single image
|
| python Scripts/inference.py \
|
| --image Sample/sample_word.tif \
|
| --model_path FAA-Word-Model/model.safetensors \
|
| --vocab_path FAA-Word-Model/vocab.json
|
|
|
| # Directory of images
|
| python Scripts/inference.py \
|
| --image_dir ./test_words \
|
| --model_path FAA-Word-Model/model.safetensors \
|
| --vocab_path FAA-Word-Model/vocab.json
|
| """
|
|
|
| import argparse
|
| import os
|
| import sys
|
| import json
|
| import math
|
| import glob
|
| import torch
|
| import torch.nn as nn
|
| import torch.nn.functional as F
|
| from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
| def parse_args():
|
| parser = argparse.ArgumentParser(
|
| description="Kurdish Handwritten Word Recognition Inference"
|
| )
|
|
|
| parser.add_argument("--image", type=str, default=None,
|
| help="Path to a single image (.tif/.png/.jpg)")
|
| parser.add_argument("--image_dir", type=str, default=None,
|
| help="Directory of images to process")
|
|
|
| parser.add_argument("--model_path", type=str, required=True,
|
| help="Path to model.safetensors (or .pth checkpoint)")
|
| parser.add_argument("--vocab_path", type=str, required=True,
|
| help="Path to vocab.json")
|
| parser.add_argument("--config_path", type=str, default=None,
|
| help="Path to config.json. If omitted, the script "
|
| "looks for it next to --model_path.")
|
|
|
| parser.add_argument("--img_height", type=int, default=64)
|
| parser.add_argument("--img_width", type=int, default=164)
|
|
|
| parser.add_argument("--device", type=str, default="auto",
|
| choices=["auto", "cuda", "cpu"])
|
|
|
| parser.add_argument("--output_file", type=str, default=None,
|
| help="Optional path to save predictions as TSV "
|
| "(filename<TAB>prediction).")
|
| return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
| def load_vocabulary(vocab_path):
|
| with open(vocab_path, "r", encoding="utf-8") as f:
|
| vocab = json.load(f)
|
| blank_idx = vocab.get("<BLANK>", 0)
|
| idx_to_char = {v: k for k, v in vocab.items()}
|
| return vocab, idx_to_char, blank_idx
|
|
|
|
|
| def indices_to_text(indices, idx_to_char, blank_idx=0):
|
| out = []
|
| for i in indices:
|
| i = int(i)
|
| if i == blank_idx:
|
| continue
|
| if i in idx_to_char:
|
| out.append(idx_to_char[i])
|
| return "".join(out)
|
|
|
|
|
|
|
|
|
|
|
| class BidirectionalLSTM(nn.Module):
|
| def __init__(self, nIn, nHidden, nOut, dropout=0.0):
|
| super().__init__()
|
| self.rnn = nn.LSTM(nIn, nHidden, bidirectional=True)
|
| self.embedding = nn.Linear(nHidden * 2, nOut)
|
| self.dropout = nn.Dropout(dropout) if dropout > 0 else None
|
|
|
| def forward(self, x):
|
| recurrent, _ = self.rnn(x)
|
| if self.dropout:
|
| recurrent = self.dropout(recurrent)
|
| T, b, h = recurrent.size()
|
| return self.embedding(recurrent.view(T * b, h)).view(T, b, -1)
|
|
|
|
|
| def _build_cnn(nc, cnn_dropout):
|
| return nn.Sequential(
|
| nn.Conv2d(nc, 64, 3, 1, 1), nn.BatchNorm2d(64), nn.ReLU(True),
|
| nn.MaxPool2d(2, 2), nn.Dropout2d(cnn_dropout),
|
| nn.Conv2d(64, 128, 3, 1, 1), nn.BatchNorm2d(128), nn.ReLU(True),
|
| nn.MaxPool2d(2, 2), nn.Dropout2d(cnn_dropout),
|
| nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2,1),(2,1)), nn.Dropout2d(cnn_dropout),
|
| nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2,1),(2,1)), nn.Dropout2d(cnn_dropout),
|
| nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2,1),(2,1)), nn.Dropout2d(cnn_dropout),
|
| nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2,1),(2,1)), nn.Dropout2d(cnn_dropout),
|
| nn.Conv2d(256, 256, (1,3), 1, (0,1)), nn.BatchNorm2d(256), nn.ReLU(True),
|
| )
|
|
|
|
|
| class BaselineCRNN(nn.Module):
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| layers = []
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| layers.append(BidirectionalLSTM(input_size, nh, out_size, dropout=drop))
|
| input_size = nh
|
| self.rnn = nn.Sequential(*layers)
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| conv = conv.squeeze(2).permute(2, 0, 1)
|
| return self.rnn(conv)
|
|
|
|
|
| class LuongAttention(nn.Module):
|
| def __init__(self, hidden_size):
|
| super().__init__()
|
| self.W_a = nn.Linear(hidden_size, hidden_size, bias=False)
|
| self.out_proj = nn.Linear(hidden_size * 2, hidden_size)
|
| self.norm = nn.LayerNorm(hidden_size)
|
|
|
| def forward(self, x):
|
| T, B, H = x.size()
|
| keys = self.W_a(x)
|
| x_bth, keys_bth = x.permute(1, 0, 2), keys.permute(1, 0, 2)
|
| scores = torch.bmm(x_bth, keys_bth.transpose(1, 2)) / (H ** 0.5)
|
| weights = torch.softmax(scores, dim=-1)
|
| context = torch.bmm(weights, x_bth).permute(1, 0, 2)
|
| output = torch.tanh(self.out_proj(torch.cat([x, context], dim=-1)))
|
| return self.norm(output + x)
|
|
|
|
|
| class LuongCRNN(nn.Module):
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| self.lstm_layers = nn.ModuleList()
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| self.lstm_layers.append(
|
| BidirectionalLSTM(input_size, nh, out_size, dropout=drop)
|
| )
|
| input_size = nh
|
| self.attention = LuongAttention(nh)
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| out = conv.squeeze(2).permute(2, 0, 1)
|
| for i, layer in enumerate(self.lstm_layers):
|
| out = layer(out)
|
| if i == 1:
|
| out = self.attention(out)
|
| return out
|
|
|
|
|
| class MultiHeadSelfAttention(nn.Module):
|
| def __init__(self, hidden_size, num_heads=4, ff_dim=320, dropout=0.1):
|
| super().__init__()
|
| assert hidden_size % num_heads == 0
|
| self.num_heads = num_heads
|
| self.head_dim = hidden_size // num_heads
|
| self.q_proj = nn.Linear(hidden_size, hidden_size)
|
| self.k_proj = nn.Linear(hidden_size, hidden_size)
|
| self.v_proj = nn.Linear(hidden_size, hidden_size)
|
| self.out_proj = nn.Linear(hidden_size, hidden_size)
|
| self.ff = nn.Sequential(
|
| nn.Linear(hidden_size, ff_dim),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(dropout),
|
| nn.Linear(ff_dim, hidden_size),
|
| )
|
| self.norm1, self.norm2 = nn.LayerNorm(hidden_size), nn.LayerNorm(hidden_size)
|
| self.dropout = nn.Dropout(dropout)
|
|
|
| def forward(self, x):
|
| T, B, H = x.size()
|
| x_btn = x.permute(1, 0, 2)
|
| def split(t): return t.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
|
| Q, K, V = split(self.q_proj(x_btn)), split(self.k_proj(x_btn)), split(self.v_proj(x_btn))
|
| scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim)
|
| weights = self.dropout(torch.softmax(scores, dim=-1))
|
| attn_out = torch.matmul(weights, V).transpose(1, 2).contiguous().view(B, T, H)
|
| attn_out = self.out_proj(attn_out)
|
| x_btn = self.norm1(x_btn + self.dropout(attn_out))
|
| x_btn = self.norm2(x_btn + self.dropout(self.ff(x_btn)))
|
| return x_btn.permute(1, 0, 2)
|
|
|
|
|
| class MHSACRNN(nn.Module):
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout,
|
| num_heads=4, ff_dim=320, mhsa_dropout=0.1):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| self.lstm_layers = nn.ModuleList()
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| self.lstm_layers.append(
|
| BidirectionalLSTM(input_size, nh, out_size, dropout=drop)
|
| )
|
| input_size = nh
|
| self.attention = MultiHeadSelfAttention(nh, num_heads, ff_dim, mhsa_dropout)
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| out = conv.squeeze(2).permute(2, 0, 1)
|
| for i, layer in enumerate(self.lstm_layers):
|
| out = layer(out)
|
| if i == 1:
|
| out = self.attention(out)
|
| return out
|
|
|
|
|
| class FrequencyAdaptiveAttention(nn.Module):
|
| def __init__(self, hidden_size, vocab_size, freq_weights):
|
| super().__init__()
|
| self.register_buffer("freq_weights", freq_weights)
|
| self.attention = nn.Sequential(
|
| nn.Linear(hidden_size, hidden_size // 2),
|
| nn.Tanh(),
|
| nn.Linear(hidden_size // 2, 1),
|
| )
|
| self.char_predictor = nn.Linear(hidden_size, vocab_size)
|
| self.freq_adapter = nn.Sequential(
|
| nn.Linear(hidden_size + 1, hidden_size // 2),
|
| nn.ReLU(),
|
| nn.Linear(hidden_size // 2, hidden_size),
|
| nn.Sigmoid(),
|
| )
|
| self.out_proj = nn.Linear(hidden_size * 2, hidden_size)
|
| self.gate = nn.Sequential(
|
| nn.Linear(hidden_size * 2, hidden_size),
|
| nn.Sigmoid(),
|
| )
|
|
|
| def forward(self, x):
|
| T, batch, _ = x.size()
|
| attn_scores = self.attention(x)
|
| attn_weights = torch.softmax(attn_scores, dim=0)
|
| char_logits = self.char_predictor(x)
|
| char_probs = torch.softmax(char_logits, dim=-1)
|
| expected_rarity = (
|
| char_probs * self.freq_weights.unsqueeze(0).unsqueeze(0)
|
| ).sum(dim=-1, keepdim=True)
|
| attn_boosted = attn_weights * (1.0 + expected_rarity)
|
| attn_boosted = attn_boosted / (attn_boosted.sum(dim=0, keepdim=True) + 1e-8)
|
| context = (x * attn_boosted).sum(dim=0, keepdim=True).expand(T, -1, -1)
|
| freq_input = torch.cat([x, expected_rarity], dim=-1)
|
| freq_adapt_gate = self.freq_adapter(freq_input)
|
| combined = torch.cat([x, context], dim=-1)
|
| projected = self.out_proj(combined)
|
| gate = self.gate(combined)
|
| return gate * (freq_adapt_gate * projected) + (1 - gate) * x
|
|
|
|
|
| class FAACRNN(nn.Module):
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout,
|
| freq_weights):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| self.lstm_layers = nn.ModuleList()
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| self.lstm_layers.append(
|
| BidirectionalLSTM(input_size, nh, out_size, dropout=drop)
|
| )
|
| input_size = nh
|
| self.freq_attention = FrequencyAdaptiveAttention(
|
| hidden_size=nh, vocab_size=nclass, freq_weights=freq_weights
|
| )
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| out = conv.squeeze(2).permute(2, 0, 1)
|
| for i, layer in enumerate(self.lstm_layers):
|
| out = layer(out)
|
| if i == 1:
|
| out = self.freq_attention(out)
|
| return out
|
|
|
|
|
|
|
|
|
|
|
| def load_state_dict(model_path, device):
|
| """Load weights from .safetensors or .pth."""
|
| if model_path.endswith(".safetensors"):
|
| from safetensors.torch import load_file
|
| return load_file(model_path, device=str(device))
|
| ckpt = torch.load(model_path, map_location=device)
|
| return ckpt.get("model_state_dict", ckpt)
|
|
|
|
|
| def auto_config_path(model_path, override=None):
|
| if override is not None:
|
| return override
|
| return os.path.join(os.path.dirname(model_path), "config.json")
|
|
|
|
|
| def build_model_from_config(config, vocab_size, state_dict, device):
|
| """Read config.json + state_dict, instantiate the right architecture."""
|
| family = config.get("model_family")
|
| rnn_cfg = config.get("rnn", {})
|
| nh = rnn_cfg.get("hidden_size", 160)
|
| n_layers = rnn_cfg.get("num_layers", 3)
|
| lstm_drop = rnn_cfg.get("dropout", 0.3)
|
| cnn_drop = config.get("cnn_dropout", 0.2)
|
|
|
| if family == "FAA":
|
|
|
| fw = state_dict.get("freq_attention.freq_weights")
|
| if fw is None:
|
| fw = torch.ones(vocab_size) / vocab_size
|
| return FAACRNN(vocab_size, nh, n_layers, lstm_drop, cnn_drop,
|
| freq_weights=fw.to(device)).to(device)
|
|
|
| if family == "MHSA":
|
| atten = config.get("attention", {})
|
| return MHSACRNN(
|
| vocab_size, nh, n_layers, lstm_drop, cnn_drop,
|
| num_heads=atten.get("num_heads", 4),
|
| ff_dim=atten.get("feed_forward_dim", 320),
|
| mhsa_dropout=atten.get("dropout", 0.1),
|
| ).to(device)
|
|
|
| if family == "Luong":
|
| return LuongCRNN(vocab_size, nh, n_layers, lstm_drop, cnn_drop).to(device)
|
|
|
|
|
| return BaselineCRNN(vocab_size, nh, n_layers, lstm_drop, cnn_drop).to(device)
|
|
|
|
|
|
|
|
|
|
|
| def preprocess_image(image_path, img_height, img_width, device):
|
| image = Image.open(image_path).convert("L")
|
| ow, oh = image.size
|
| new_h = img_height
|
| new_w = min(int(new_h * ow / oh), img_width)
|
| image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| canvas = Image.new("L", (img_width, img_height), color=255)
|
| canvas.paste(image, (0, 0))
|
|
|
| import numpy as np
|
| arr = np.asarray(canvas, dtype="float32") / 255.0
|
| arr = (arr - 0.5) / 0.5
|
| tensor = torch.from_numpy(arr).unsqueeze(0).unsqueeze(0)
|
| return tensor.to(device)
|
|
|
|
|
| def ctc_greedy_decode(logits, blank_idx=0):
|
| _, max_idx = torch.max(logits, dim=2)
|
| decoded = []
|
| for b in range(max_idx.size(1)):
|
| seq = max_idx[:, b].cpu().numpy()
|
| out, prev = [], None
|
| for i in seq:
|
| if i != blank_idx and i != prev:
|
| out.append(int(i))
|
| prev = i
|
| decoded.append(out)
|
| return decoded
|
|
|
|
|
|
|
|
|
|
|
| def main():
|
| args = parse_args()
|
|
|
| if args.image is None and args.image_dir is None:
|
| print("ERROR: provide either --image or --image_dir")
|
| sys.exit(1)
|
|
|
|
|
| if args.device == "auto":
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| else:
|
| device = torch.device(args.device)
|
| print(f"Device: {device}")
|
|
|
|
|
| vocab, idx_to_char, blank_idx = load_vocabulary(args.vocab_path)
|
| vocab_size = len(vocab)
|
|
|
|
|
| config_path = auto_config_path(args.model_path, args.config_path)
|
| if not os.path.exists(config_path):
|
| print(f"ERROR: config.json not found at {config_path}")
|
| print("Pass --config_path explicitly.")
|
| sys.exit(1)
|
| with open(config_path, "r", encoding="utf-8") as f:
|
| config = json.load(f)
|
|
|
|
|
| state_dict = load_state_dict(args.model_path, device)
|
|
|
|
|
| model = build_model_from_config(config, vocab_size, state_dict, device)
|
| missing, unexpected = model.load_state_dict(state_dict, strict=False)
|
| if missing or unexpected:
|
| print(f" Missing keys : {len(missing)}")
|
| print(f" Unexpected keys: {len(unexpected)}")
|
| model.eval()
|
|
|
| family = config.get("model_family", "Baseline")
|
| n_params = sum(p.numel() for p in model.parameters())
|
| print(f"Model family: {family} | Parameters: {n_params:,}")
|
|
|
|
|
| if args.image is not None:
|
| image_paths = [args.image]
|
| else:
|
| exts = ("*.tif", "*.tiff", "*.png", "*.jpg", "*.jpeg")
|
| image_paths = []
|
| for ext in exts:
|
| image_paths.extend(sorted(glob.glob(os.path.join(args.image_dir, ext))))
|
| if not image_paths:
|
| print(f"No images found in {args.image_dir}")
|
| sys.exit(1)
|
|
|
|
|
| results = []
|
| print("")
|
| print(f"{'File':<40} | Prediction")
|
| print("-" * 70)
|
| with torch.no_grad():
|
| for path in image_paths:
|
| x = preprocess_image(path, args.img_height, args.img_width, device)
|
| logits = model(x)
|
| decoded = ctc_greedy_decode(logits, blank_idx=blank_idx)[0]
|
| pred = indices_to_text(decoded, idx_to_char, blank_idx)
|
| results.append((os.path.basename(path), pred))
|
| print(f"{os.path.basename(path):<40} | {pred}")
|
|
|
|
|
| if args.output_file is not None:
|
| with open(args.output_file, "w", encoding="utf-8") as f:
|
| for name, pred in results:
|
| f.write(f"{name}\t{pred}\n")
|
| print(f"\nPredictions saved: {args.output_file}")
|
|
|
|
|
| if __name__ == "__main__":
|
| main() |