askakalsky commited on
Commit
824cafa
·
verified ·
1 Parent(s): 7a53f63

Upload src/recognize.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/recognize.py +126 -0
src/recognize.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OCR recognition: preprocessed ROI image -> 8-character passport string.
3
+
4
+ Model: CRNN (CNN + biGRU + 8 position heads).
5
+ Input: grayscale image of any size (resized internally to 48x256).
6
+ Output: (string, confidence) e.g. ("НР430098", 0.97)
7
+ """
8
+
9
+ from pathlib import Path
10
+ import cv2
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+
16
+ # -- Constants -----------------------------------------------------------------
17
+
18
+ DIGITS = list("0123456789")
19
+ SERIES_LETTERS = list("АВЕКМНОРСТИЮ")
20
+ ALL_CHARS = DIGITS + SERIES_LETTERS # 22 classes
21
+
22
+ N_CHARS = 8
23
+ SEQ_H = 48
24
+ SEQ_W = 256
25
+
26
+ CONFIDENCE_THRESHOLD = 0.50 # below this -> flagged as unreadable
27
+
28
+
29
+ # -- Model architecture (must match train/train_sequence.py) -------------------
30
+
31
+ class SequenceCNN(nn.Module):
32
+ RNN_SLOTS = 24
33
+ CHAR_SLOTS = [1, 4, 7, 10, 13, 16, 19, 22]
34
+
35
+ def __init__(self, n_classes: int = len(ALL_CHARS)):
36
+ super().__init__()
37
+ self.features = nn.Sequential(
38
+ nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
39
+ nn.MaxPool2d(2),
40
+ nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
41
+ nn.MaxPool2d(2),
42
+ nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
43
+ nn.MaxPool2d(2),
44
+ nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
45
+ )
46
+ self.pool = nn.AdaptiveAvgPool2d((1, self.RNN_SLOTS))
47
+ self.rnn = nn.GRU(128, 128, num_layers=1, batch_first=True,
48
+ bidirectional=True)
49
+ self.heads = nn.ModuleList([
50
+ nn.Sequential(
51
+ nn.Linear(256, 64), nn.ReLU(), nn.Dropout(0.3),
52
+ nn.Linear(64, n_classes),
53
+ )
54
+ for _ in range(N_CHARS)
55
+ ])
56
+
57
+ def forward(self, x):
58
+ feat = self.features(x)
59
+ feat = self.pool(feat).squeeze(2)
60
+ feat = feat.permute(0, 2, 1)
61
+ rnn_out, _ = self.rnn(feat)
62
+ return torch.stack(
63
+ [self.heads[i](rnn_out[:, self.CHAR_SLOTS[i], :])
64
+ for i in range(N_CHARS)],
65
+ dim=1)
66
+
67
+
68
+ # -- Load ----------------------------------------------------------------------
69
+
70
+ def load_recognizer(model_path: str | Path):
71
+ """Load CRNN model from checkpoint. Returns (model, meta_dict)."""
72
+ ck = torch.load(str(model_path), map_location="cpu", weights_only=True)
73
+ n_classes = len(ck["all_chars"])
74
+ model = SequenceCNN(n_classes=n_classes)
75
+ model.load_state_dict(ck["model_state"])
76
+ for m in model.modules():
77
+ if hasattr(m, "training"):
78
+ m.training = False
79
+ return model, ck
80
+
81
+
82
+ # -- Inference -----------------------------------------------------------------
83
+
84
+ def recognize(image: np.ndarray, model: SequenceCNN,
85
+ checkpoint: dict) -> tuple[str, float]:
86
+ """
87
+ Recognize 8-character passport string from a preprocessed ROI image.
88
+
89
+ Args:
90
+ image: grayscale numpy array (any size, will be resized).
91
+ model: loaded SequenceCNN.
92
+ checkpoint: dict returned by load_recognizer (contains idx2char etc.).
93
+
94
+ Returns:
95
+ (prediction, confidence)
96
+ confidence < CONFIDENCE_THRESHOLD means the image is likely unreadable.
97
+ """
98
+ all_chars = checkpoint["all_chars"]
99
+ idx2char = {int(k): v for k, v in checkpoint["idx2char"].items()}
100
+ seq_h = checkpoint.get("seq_h", SEQ_H)
101
+ seq_w = checkpoint.get("seq_w", SEQ_W)
102
+
103
+ letter_set = set(SERIES_LETTERS)
104
+ digit_set = set(DIGITS)
105
+ letter_idx = [i for i, c in enumerate(all_chars) if c in letter_set]
106
+ digit_idx = [i for i, c in enumerate(all_chars) if c in digit_set]
107
+
108
+ img = cv2.resize(image, (seq_w, seq_h), interpolation=cv2.INTER_AREA)
109
+ arr = img.astype(np.float32) / 255.0
110
+ x = torch.tensor(arr).unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
111
+
112
+ with torch.no_grad():
113
+ logits = model(x)[0] # (8, n_classes)
114
+
115
+ result, confidences = [], []
116
+ for pos in range(N_CHARS):
117
+ mask = torch.full((len(all_chars),), float("-inf"))
118
+ for idx in (letter_idx if pos < 2 else digit_idx):
119
+ mask[idx] = 0.0
120
+ masked = logits[pos] + mask
121
+ probs = torch.softmax(masked, dim=0)
122
+ best = probs.argmax().item()
123
+ result.append(idx2char[best])
124
+ confidences.append(probs[best].item())
125
+
126
+ return "".join(result), float(np.mean(confidences))