import os import json import argparse import numpy as np from PIL import Image import torch import torch.nn as nn HERE = os.path.dirname(os.path.abspath(__file__)) MODEL_PATH = os.path.join(HERE, "qocr_tiny_v1.pt") VOCAB_PATH = os.path.join(HERE, "vocab.json") CONFIG_PATH = os.path.join(HERE, "config.json") with open(VOCAB_PATH, "r", encoding="utf-8") as f: vocab = json.load(f) with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = json.load(f) CHARSET = vocab["charset"] BLANK_ID = int(vocab["blank"]) VOCAB_SIZE = int(vocab["vocab_size"]) MAX_HEIGHT = int(config["input"]["max_height"]) MAX_WIDTH = int(config["input"]["max_width"]) CNN1, CNN2, CNN3, CNN4, CNN5 = [int(x) for x in config["encoder"]["channels"]] LATENT_DIM = int(config["encoder"]["latent_dim"]) GRU_HIDDEN = int(config["rnn"]["hidden"]) GRU_LAYERS = int(config["rnn"]["layers"]) ID_TO_CHAR = {i + 1: c for i, c in enumerate(CHARSET)} DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") USE_AMP = (DEVICE.type == "cuda") class ConvBNAct(nn.Module): def __init__(self, in_channels, out_channels, stride=(1, 1)): super().__init__() self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn = nn.BatchNorm2d(out_channels) self.act = nn.SiLU(inplace=True) def forward(self, x): return self.act(self.bn(self.conv(x))) class DepthwiseSeparable(nn.Module): def __init__(self, in_channels, out_channels, stride=(1, 1)): super().__init__() self.depthwise = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=stride, padding=1, groups=in_channels, bias=False) self.depth_bn = nn.BatchNorm2d(in_channels) self.pointwise = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.point_bn = nn.BatchNorm2d(out_channels) self.act = nn.SiLU(inplace=True) def forward(self, x): x = self.depthwise(x) x = self.depth_bn(x) x = self.act(x) x = self.pointwise(x) x = self.point_bn(x) x = self.act(x) return x class QOCRSmall(nn.Module): def __init__(self, vocab_size): super().__init__() self.encoder = nn.Sequential( ConvBNAct(1, CNN1, stride=(2, 1)), ConvBNAct(CNN1, CNN2, stride=(2, 1)), ConvBNAct(CNN2, CNN3, stride=(2, 1)), DepthwiseSeparable(CNN3, CNN4, stride=(1, 2)), DepthwiseSeparable(CNN4, CNN5, stride=(1, 1)), ) self.latent_projection = nn.Sequential( nn.Conv2d(CNN5, LATENT_DIM, kernel_size=1, bias=False), nn.BatchNorm2d(LATENT_DIM), nn.SiLU(inplace=True), ) self.gru = nn.GRU( input_size=LATENT_DIM, hidden_size=GRU_HIDDEN, num_layers=GRU_LAYERS, batch_first=True, bidirectional=True, dropout=0.15, ) self.norm = nn.LayerNorm(GRU_HIDDEN * 2) self.classifier = nn.Linear(GRU_HIDDEN * 2, vocab_size) def forward(self, x): x = self.encoder(x) x = self.latent_projection(x) x = x.mean(dim=2) x = x.transpose(1, 2) x, _ = self.gru(x) x = self.norm(x) x = self.classifier(x) return x.transpose(0, 1) model = QOCRSmall(VOCAB_SIZE).to(DEVICE) state_dict = torch.load(MODEL_PATH, map_location=DEVICE) model.load_state_dict(state_dict) model.eval() def preprocess_image(image): if not isinstance(image, Image.Image): image = Image.fromarray(np.asarray(image)) image = image.convert("L") width, height = image.size if width <= 0 or height <= 0: raise ValueError("Invalid image dimensions.") scale = min(1.0, MAX_WIDTH / width, MAX_HEIGHT / height) if scale < 1.0: width = max(1, round(width * scale)) height = max(1, round(height * scale)) image = image.resize((width, height), Image.Resampling.LANCZOS) array = np.asarray(image, dtype=np.float32) array /= 255.0 tensor = torch.from_numpy(array).unsqueeze(0).unsqueeze(0).to(DEVICE) return tensor def decode_logits(logits): ids = logits.argmax(dim=2) sequence = ids[:, 0].tolist() previous = BLANK_ID output = [] for token in sequence: if token != BLANK_ID and token != previous: output.append(ID_TO_CHAR.get(token, "")) previous = token return "".join(output) @torch.inference_mode() def ocr(image): tensor = preprocess_image(image) if USE_AMP: with torch.autocast(device_type="cuda", dtype=torch.float16): logits = model(tensor) else: logits = model(tensor) return decode_logits(logits) def ocr_file(path): with Image.open(path) as image: return ocr(image) def main(): parser = argparse.ArgumentParser(description="QOCR-Tiny v1 OCR") parser.add_argument("image", help="Path to cropped text image") args = parser.parse_args() result = ocr_file(args.image) print(result) if __name__ == "__main__": main()