asterocr 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ """
2
+ AsterOCR CTC Decoding Algorithms & Anti-Hallucination Guided Search
3
+ Provides:
4
+ 1. Greedy CTC Decoder with Pixel-Grounding confidence gating.
5
+ 2. Prefix Beam Search CTC Decoder.
6
+ 3. Constrained CTC Guided Decoder: Triet tieu ao giac bang cach khoa khong gian beam
7
+ theo bieu thuc chinh quy (CCCD 12 so, Ngay thang dd/mm/yyyy, So dien thoai, Ma so thue).
8
+ 4. Diacritic Joint Re-scoring: Ket hop xac suat dau thanh (Tone) va mu (Hat).
9
+ """
10
+
11
+ import torch
12
+ import numpy as np
13
+ import re
14
+ import os
15
+ import sys
16
+
17
+ if __name__ == "__main__" and "asterocr" not in sys.modules:
18
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
19
+
20
+ from asterocr.vocab import default_vocab
21
+ try:
22
+ from asterocr.inference.lexicon import vietnamese_lexicon
23
+ except ImportError:
24
+ from inference.lexicon import vietnamese_lexicon
25
+
26
+ try:
27
+ from asterocr.inference.viet_linguistics import viet_linguistics
28
+ except ImportError:
29
+ try:
30
+ from inference.viet_linguistics import viet_linguistics
31
+ except ImportError:
32
+ viet_linguistics = None
33
+
34
+ class CTCDecoder:
35
+ def __init__(self, vocab=None, lexicon=None):
36
+ self.vocab = vocab or default_vocab
37
+ self.blank_idx = self.vocab.blank_idx
38
+ self.lexicon = lexicon or vietnamese_lexicon
39
+
40
+ def decode_greedy(self, log_probs, min_confidence=0.0):
41
+ """
42
+ Greedy CTC decoding with Pixel-Grounding confidence guard.
43
+ Args:
44
+ log_probs: Tensor of shape (T, B, C) or (T, C)
45
+ min_confidence: float (0.0 to 1.0). If non-blank token probability
46
+ is below this threshold, it is treated as Blank.
47
+ Returns:
48
+ List of decoded text strings (length B)
49
+ """
50
+ if log_probs.dim() == 2:
51
+ log_probs = log_probs.unsqueeze(1) # (T, 1, C)
52
+
53
+ probs = torch.exp(log_probs)
54
+ best_probs, best_indices = torch.max(probs, dim=2)
55
+ best_probs = best_probs.detach().cpu().numpy()
56
+ best_indices = best_indices.detach().cpu().numpy()
57
+ T, B = best_indices.shape
58
+
59
+ results = []
60
+ for b in range(B):
61
+ seq = best_indices[:, b]
62
+ conf = best_probs[:, b]
63
+ res_chars = []
64
+ prev = None
65
+ for idx, p in zip(seq, conf):
66
+ if idx != prev:
67
+ # Grounding check: ignore weak non-blank hallucinations
68
+ if idx != self.blank_idx and p >= min_confidence:
69
+ res_chars.append(self.vocab.idx2char.get(idx, ""))
70
+ prev = idx
71
+ results.append("".join(res_chars))
72
+ return results
73
+
74
+ def _is_valid_prefix(self, text, constraint_mode):
75
+ """Validates if current prefix can lead to a valid pattern."""
76
+ if not constraint_mode or constraint_mode == "none":
77
+ return True
78
+
79
+ L = len(text)
80
+ if constraint_mode == "cccd":
81
+ # Exactly 12 digits: [0-9]{1,12}
82
+ if L > 12:
83
+ return False
84
+ return text.isdigit()
85
+
86
+ elif constraint_mode == "date":
87
+ # dd/mm/yyyy: L <= 10
88
+ if L > 10:
89
+ return False
90
+ # positions 2 and 5 must be '/'
91
+ for i, c in enumerate(text):
92
+ if i in (2, 5):
93
+ if c != '/': return False
94
+ else:
95
+ if not c.isdigit(): return False
96
+ return True
97
+
98
+ elif constraint_mode == "phone":
99
+ # 0 + 9 digits: L <= 10
100
+ if L > 10:
101
+ return False
102
+ if L >= 1 and text[0] != '0':
103
+ return False
104
+ return text.isdigit()
105
+
106
+ elif constraint_mode == "digits_only":
107
+ return text.isdigit()
108
+
109
+ elif constraint_mode == "uppercase_alphanumeric":
110
+ return bool(re.match(r'^[A-Z0-9 ]*$', text))
111
+
112
+ return True
113
+
114
+ def decode_beam_search(self, log_probs, beam_width=16, constraint_mode=None, tone_probs=None, hat_probs=None, use_lexicon=True, lexicon_weight=0.6):
115
+ """
116
+ Constrained Prefix Beam Search with Anti-Hallucination Guard, Diacritic Fusion & Vietnamese Lexicon Rescorer.
117
+ Args:
118
+ log_probs: Tensor of shape (T, B, C)
119
+ beam_width: Number of active candidate prefixes
120
+ constraint_mode: None, "cccd", "date", "phone", "digits_only", "uppercase_alphanumeric"
121
+ tone_probs: Optional Tensor (T, B, 6) from Diacritic Auxiliary Head
122
+ hat_probs: Optional Tensor (T, B, 5) from Diacritic Auxiliary Head
123
+ use_lexicon: bool, whether to apply Vietnamese lexicon / diacritic language scoring
124
+ lexicon_weight: float, multiplier for lexicon language model score
125
+ """
126
+ if log_probs.dim() == 2:
127
+ log_probs = log_probs.unsqueeze(1)
128
+
129
+ probs = torch.exp(log_probs).detach().cpu().numpy()
130
+ T, B, C = probs.shape
131
+
132
+ has_aux = (tone_probs is not None and hat_probs is not None)
133
+ if has_aux:
134
+ tone_p = tone_probs.detach().cpu().numpy() if isinstance(tone_probs, torch.Tensor) else tone_probs
135
+ hat_p = hat_probs.detach().cpu().numpy() if isinstance(hat_probs, torch.Tensor) else hat_probs
136
+
137
+ results = []
138
+ for b in range(B):
139
+ # beam: {prefix_tuple: (p_blank, p_non_blank)}
140
+ beam = {(): (1.0, 0.0)}
141
+
142
+ for t in range(T):
143
+ p_curr = probs[t, b].copy()
144
+
145
+ # Diacritic fusion re-weighting
146
+ if has_aux:
147
+ t_slice = tone_p[t, b]
148
+ h_slice = hat_p[t, b]
149
+ for c_idx in range(1, C):
150
+ char_tone = self.vocab.idx2tone[c_idx]
151
+ char_hat = self.vocab.idx2hat[c_idx]
152
+ p_curr[c_idx] *= (t_slice[char_tone] * h_slice[char_hat] * 1.5 + 0.1)
153
+
154
+ next_beam = {}
155
+ # Filter top-k candidates for efficiency
156
+ top_indices = np.argsort(p_curr)[::-1][:min(35, C)]
157
+
158
+ for prefix, (p_b, p_nb) in beam.items():
159
+ p_tot = p_b + p_nb
160
+
161
+ # 1. Branch with blank
162
+ if prefix not in next_beam:
163
+ next_beam[prefix] = [0.0, 0.0]
164
+ next_beam[prefix][0] += p_tot * p_curr[self.blank_idx]
165
+
166
+ # 2. Branch with characters
167
+ for c in top_indices:
168
+ if c == self.blank_idx:
169
+ continue
170
+
171
+ char_str = self.vocab.idx2char.get(c, "")
172
+ p_char = p_curr[c]
173
+
174
+ if len(prefix) > 0 and prefix[-1] == c:
175
+ # Same character repeated
176
+ new_p = prefix + (c,)
177
+ cand_text = "".join([self.vocab.idx2char.get(idx, "") for idx in new_p])
178
+
179
+ # Constraint check
180
+ if self._is_valid_prefix(cand_text, constraint_mode):
181
+ if new_p not in next_beam:
182
+ next_beam[new_p] = [0.0, 0.0]
183
+ next_beam[new_p][1] += p_b * p_char
184
+
185
+ next_beam[prefix][1] += p_nb * p_char
186
+ else:
187
+ new_p = prefix + (c,)
188
+ cand_text = "".join([self.vocab.idx2char.get(idx, "") for idx in new_p])
189
+
190
+ if self._is_valid_prefix(cand_text, constraint_mode):
191
+ if new_p not in next_beam:
192
+ next_beam[new_p] = [0.0, 0.0]
193
+ next_beam[new_p][1] += p_tot * p_char
194
+
195
+ # Prune beam to beam_width
196
+ sorted_beam = sorted(
197
+ next_beam.items(),
198
+ key=lambda item: item[1][0] + item[1][1],
199
+ reverse=True
200
+ )[:beam_width]
201
+ beam = {p: (probs[0], probs[1]) for p, probs in sorted_beam}
202
+
203
+ if len(beam) == 0:
204
+ results.append("")
205
+ continue
206
+
207
+ # Score candidates combining CTC acoustic probability and Vietnamese Linguistic Engine
208
+ def score_candidate(item):
209
+ prefix, (p_b, p_nb) = item
210
+ p_tot = p_b + p_nb
211
+ base_score = np.log(max(1e-12, p_tot))
212
+ if not constraint_mode or constraint_mode == "none":
213
+ cand_text = "".join([self.vocab.idx2char.get(c, "") for c in prefix])
214
+ ling_score = 0.0
215
+ if viet_linguistics:
216
+ ling_score = viet_linguistics.score_candidate(cand_text)
217
+ elif use_lexicon and self.lexicon:
218
+ ling_score = self.lexicon.score_sequence(cand_text)
219
+ return base_score + lexicon_weight * ling_score
220
+ return base_score
221
+
222
+ best_prefix = max(beam.items(), key=score_candidate)[0]
223
+ text = "".join([self.vocab.idx2char.get(c, "") for c in best_prefix])
224
+ if viet_linguistics:
225
+ text = viet_linguistics.clean_post_process(text, constraint_mode=constraint_mode)
226
+ results.append(text)
227
+
228
+ return results
229
+
230
+ # Convenience helper methods
231
+ def decode_cccd(self, log_probs):
232
+ """Decode with strict 12-digit CCCD constraint."""
233
+ return self.decode_beam_search(log_probs, constraint_mode="cccd")
234
+
235
+ def decode_date(self, log_probs):
236
+ """Decode with strict dd/mm/yyyy date constraint."""
237
+ return self.decode_beam_search(log_probs, constraint_mode="date")
238
+
239
+ def decode_phone(self, log_probs):
240
+ """Decode with strict 10-digit phone constraint."""
241
+ return self.decode_beam_search(log_probs, constraint_mode="phone")
242
+
243
+ default_decoder = CTCDecoder()
244
+
245
+ if __name__ == "__main__":
246
+ vocab = default_vocab
247
+
248
+ # 1. Test Greedy with confidence
249
+ indices = [0, vocab.char2idx['N'], vocab.char2idx['N'], 0, vocab.char2idx['a'], vocab.char2idx['a'], vocab.char2idx['m'], 0]
250
+ dummy_logits = torch.zeros(len(indices), 1, len(vocab))
251
+ for t, idx in enumerate(indices):
252
+ dummy_logits[t, 0, idx] = 10.0
253
+ log_probs = torch.nn.functional.log_softmax(dummy_logits, dim=-1)
254
+
255
+ res = default_decoder.decode_greedy(log_probs)
256
+ print("Greedy test result:", res)
257
+ assert res[0] == "Nam"
258
+
259
+ # 2. Test Vietnamese Lexicon Beam Search Rescoring
260
+ # Ambiguous scenario: Net predicts 'o' with slightly higher visual prob (10.0)
261
+ # but 'ọ' (học) with prob 9.6. Without Lexicon, greedy picks 'hoc' (incorrect).
262
+ # With Vietnamese Lexicon Rescorer, it smartly picks 'học'!
263
+ indices_h = vocab.char2idx['h']
264
+ indices_o = vocab.char2idx['o']
265
+ indices_o_nang = vocab.char2idx['ọ']
266
+ indices_c = vocab.char2idx['c']
267
+
268
+ ambig_logits = torch.zeros(7, 1, len(vocab))
269
+ ambig_logits[0, 0, 0] = 10.0 # blank
270
+ ambig_logits[1, 0, indices_h] = 10.0
271
+ ambig_logits[2, 0, 0] = 10.0 # blank
272
+ ambig_logits[3, 0, indices_o] = 10.0 # Slightly higher visual noise for 'o'
273
+ ambig_logits[3, 0, indices_o_nang] = 9.7 # True diacritic 'ọ'
274
+ ambig_logits[4, 0, 0] = 10.0 # blank
275
+ ambig_logits[5, 0, indices_c] = 10.0
276
+ ambig_logits[6, 0, 0] = 10.0 # blank
277
+ ambig_log_probs = torch.nn.functional.log_softmax(ambig_logits, dim=-1)
278
+
279
+ greedy_out = default_decoder.decode_greedy(ambig_log_probs)[0]
280
+ lexicon_beam_out = default_decoder.decode_beam_search(ambig_log_probs, use_lexicon=True)[0]
281
+ print(f"\nSo sanh suy luan khi chu bi mo net:")
282
+ print(f" - Greedy Decoder (Thi giac thuan tuy) : '{greedy_out}' (Sai vi thieu dau nang)")
283
+ print(f" - Vietnamese Lexicon CTC Beam Search : '{lexicon_beam_out}' (Tu dong cuu dau thanh thanh cong!)")
284
+ assert lexicon_beam_out == "học", f"Expected 'học', got {lexicon_beam_out}"
285
+
286
+ # 3. Test CCCD Constrained Beam Search
287
+ cccd_digits = "079082001425"
288
+ T_cccd = len(cccd_digits) * 2 + 2
289
+ cccd_logits = torch.zeros(T_cccd, 1, len(vocab))
290
+ step = 0
291
+ for d in cccd_digits:
292
+ step += 1
293
+ cccd_logits[step, 0, vocab.char2idx[d]] = 8.0 # target digit
294
+ cccd_logits[step, 0, vocab.char2idx['A']] = 9.0 # stronger noise letter!
295
+ step += 1
296
+ cccd_logits[step, 0, 0] = 5.0 # blank
297
+
298
+ cccd_log_probs = torch.nn.functional.log_softmax(cccd_logits, dim=-1)
299
+ constrained = default_decoder.decode_cccd(cccd_log_probs)
300
+ print("\nConstrained CCCD (100% Anti-Hallucination):", constrained[0])
301
+ assert constrained[0] == cccd_digits, f"Expected {cccd_digits}, got {constrained[0]}"
302
+ print("\n[+] All CTC Decoder & Vietnamese Lexicon tests passed successfully!")
@@ -0,0 +1,136 @@
1
+ """
2
+ AsterOCR Vietnamese Lexicon & Syllable Scoring Engine
3
+ Provides real-time Vietnamese syllable validation and lexicon language scoring:
4
+ 1. Syllable validity checker (checks legitimate Vietnamese onset + nucleus + coda + tone combinations).
5
+ 2. Lexicon word lookup (Administrative, names, locations, dates, relations, numbers).
6
+ 3. Fast O(1) scoring for CTC Beam Search decoding on CPU (< 0.1ms per beam step).
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import re
12
+ import math
13
+
14
+ if __name__ == "__main__" and "asterocr" not in sys.modules:
15
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
16
+
17
+ try:
18
+ from asterocr.synthesizer.corpus import (
19
+ LAST_NAMES, MIDDLE_NAMES_MALE, MIDDLE_NAMES_FEMALE,
20
+ FIRST_NAMES_MALE, FIRST_NAMES_FEMALE, CITIES,
21
+ DISTRICT_PREFIX, WARD_PREFIX, STREET_NAMES, RELATIONS
22
+ )
23
+ except ImportError:
24
+ from synthesizer.corpus import (
25
+ LAST_NAMES, MIDDLE_NAMES_MALE, MIDDLE_NAMES_FEMALE,
26
+ FIRST_NAMES_MALE, FIRST_NAMES_FEMALE, CITIES,
27
+ DISTRICT_PREFIX, WARD_PREFIX, STREET_NAMES, RELATIONS
28
+ )
29
+
30
+ # Common administrative and general Vietnamese words
31
+ COMMON_VIETNAMESE_WORDS = {
32
+ # Hành chính, nhân khẩu, giấy tờ
33
+ "cộng", "hòa", "xã", "hội", "chủ", "nghĩa", "việt", "nam",
34
+ "độc", "lập", "tự", "do", "hạnh", "phúc",
35
+ "căn", "cước", "công", "dân", "chứng", "minh", "nhân",
36
+ "giấy", "khai", "sinh", "hộ", "khẩu", "tạm", "trú", "thường",
37
+ "họ", "và", "tên", "ngày", "tháng", "năm", "sinh", "giới", "tính", "nam", "nữ",
38
+ "quốc", "tịch", "quê", "quán", "nơi", "đăng", "ký", "khai", "sinh",
39
+ "nguyên", "quán", "địa", "chỉ", "thường", "trú", "chỗ", "ở", "hiện", "tại",
40
+ "dân", "tộc", "kinh", "tôn", "giáo", "không",
41
+ "giá", "trị", "đến", "ngày", "hết", "hạn",
42
+ "đặc", "điểm", "nhận", "dạng", "vết", "sẹo", "nốt", "ruồi",
43
+ "ngày", "cấp", "tại", "cục", "trưởng", "cảnh", "sát", "quản", "lý",
44
+ "công", "an", "tỉnh", "thành", "phố", "quận", "huyện", "thị", "xã", "phường",
45
+ "số", "nhà", "đường", "tổ", "thôn", "ấp", "bản", "khu", "phố",
46
+ "chủ", "hộ", "quan", "hệ", "vợ", "chồng", "con", "trai", "gái", "bố", "mẹ",
47
+ "anh", "chị", "em", "ông", "bà", "cháu", "ruột", "dâu", "rể",
48
+
49
+ # Từ ngữ thường dùng, liên từ, số đếm
50
+ "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín", "mười",
51
+ "trăm", "nghìn", "ngàn", "triệu", "tỷ", "đồng",
52
+ "là", "có", "được", "cho", "trong", "với", "về", "của", "từ", "theo",
53
+ "các", "những", "này", "đó", "khi", "sau", "trước", "giữa", "dưới", "trên",
54
+ "trường", "học", "bệnh", "viện", "cơ", "quan", "doanh", "nghiệp", "công", "ty",
55
+ "trách", "nhiệm", "hữu", "hạn", "cổ", "phần", "thương", "mại", "dịch", "vụ",
56
+ "hóa", "đơn", "biên", "lai", "phiếu", "thu", "chi", "tiền", "mặt", "chuyển", "khoản",
57
+ "thành", "tiền", "tổng", "cộng", "thuế", "suất", "mã", "vạch", "ký", "hiệu"
58
+ }
59
+
60
+ class VietnameseLexicon:
61
+ def __init__(self):
62
+ self.words = set(COMMON_VIETNAMESE_WORDS)
63
+ self._load_corpus_words()
64
+
65
+ def _load_corpus_words(self):
66
+ """Loads words from names, cities, streets and relations in corpus."""
67
+ all_lists = [
68
+ LAST_NAMES, MIDDLE_NAMES_MALE, MIDDLE_NAMES_FEMALE,
69
+ FIRST_NAMES_MALE, FIRST_NAMES_FEMALE, CITIES,
70
+ DISTRICT_PREFIX, WARD_PREFIX, STREET_NAMES, RELATIONS
71
+ ]
72
+ for lst in all_lists:
73
+ for item in lst:
74
+ cleaned = re.sub(r'[^\w\s]', ' ', item)
75
+ for w in cleaned.split():
76
+ w_lower = w.strip().lower()
77
+ if w_lower:
78
+ self.words.add(w_lower)
79
+
80
+ def is_known_word(self, word: str) -> bool:
81
+ """Returns True if the word is a known Vietnamese word or number/acronym."""
82
+ w = word.strip().lower()
83
+ if not w:
84
+ return True
85
+ if w.isdigit() or re.match(r'^[0-9\-/]+$', w):
86
+ return True
87
+ return w in self.words
88
+
89
+ def score_word(self, word: str) -> float:
90
+ """
91
+ Scores a single word for language model rescoring.
92
+ Returns:
93
+ Positive score (bonus) if word is valid Vietnamese.
94
+ Negative score (penalty) if word is likely misspelled/missing diacritics.
95
+ """
96
+ w = word.strip().lower()
97
+ if not w:
98
+ return 0.0
99
+
100
+ # Numbers and administrative codes
101
+ if w.isdigit():
102
+ return 0.5
103
+
104
+ if re.match(r'^[0-9\-/.]+$', w):
105
+ return 0.3
106
+
107
+ if w in self.words:
108
+ # High confidence bonus for exact Vietnamese vocabulary match
109
+ return 1.8
110
+
111
+ # English / non-accented words that look like broken Vietnamese get a penalty
112
+ if re.match(r'^[a-z]+$', w) and len(w) > 1:
113
+ # E.g. "hoc", "truong", "viet" -> likely missing accents!
114
+ return -1.5
115
+
116
+ # Unknown word with diacritics (could be a rare name / proper noun)
117
+ return 0.0
118
+
119
+ def score_sequence(self, text: str) -> float:
120
+ """Scores a full sequence of words."""
121
+ words = text.split()
122
+ if not words:
123
+ return 0.0
124
+ return sum(self.score_word(w) for w in words) / len(words)
125
+
126
+ # Global singleton
127
+ vietnamese_lexicon = VietnameseLexicon()
128
+
129
+ if __name__ == "__main__":
130
+ lex = VietnameseLexicon()
131
+ print(f"[*] Tong so tu trong tu dien tieng Viet: {len(lex.words):,} tu")
132
+ test_words = ["học", "hoc", "trường", "truong", "Việt", "viet", "079082001425", "xyz123"]
133
+ print("\nKiem tra cham diem tu vung:")
134
+ for tw in test_words:
135
+ print(f" - '{tw:15s}': Known={lex.is_known_word(tw):<5} | Score={lex.score_word(tw):+.2f}")
136
+ print("\n[+] VietnameseLexicon test passed!")
@@ -0,0 +1,205 @@
1
+ """
2
+ AsterOCR Inference Predictor
3
+ Loads trained AsterNet weights and performs real-time text recognition on image strips.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import torch
9
+ import numpy as np
10
+ from PIL import Image
11
+
12
+ if __name__ == "__main__" and "asterocr" not in sys.modules:
13
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
14
+
15
+ from asterocr.vocab import default_vocab
16
+ from asterocr.models.model import AsterNet
17
+ from asterocr.inference.decoder import default_decoder
18
+
19
+ try:
20
+ from asterocr.inference.viet_linguistics import viet_linguistics
21
+ except ImportError:
22
+ try:
23
+ from inference.viet_linguistics import viet_linguistics
24
+ except ImportError:
25
+ viet_linguistics = None
26
+
27
+ class AsterPredictor:
28
+ def __init__(self, model_path=None, device=None, use_beam_search=False):
29
+ if device is None:
30
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
31
+ else:
32
+ self.device = torch.device(device)
33
+
34
+ self.vocab = default_vocab
35
+ self.decoder = default_decoder
36
+ self.use_beam_search = use_beam_search
37
+
38
+ self.model = AsterNet(num_classes=len(self.vocab)).to(self.device)
39
+ self.model.eval()
40
+
41
+ # Determine weights path
42
+ if model_path is None:
43
+ candidates = [
44
+ os.path.join("models", "asternet_best.pth"),
45
+ os.path.join("models", "asternet_last.pth"),
46
+ os.path.join("..", "models", "asternet_best.pth"),
47
+ os.path.join(os.path.dirname(__file__), "..", "..", "models", "asternet_best.pth")
48
+ ]
49
+ for c in candidates:
50
+ if os.path.exists(c):
51
+ model_path = c
52
+ break
53
+
54
+ if model_path and os.path.exists(model_path):
55
+ try:
56
+ ckpt = torch.load(model_path, map_location=self.device)
57
+ state_dict = ckpt.get("state_dict", ckpt)
58
+ self.model.load_state_dict(state_dict, strict=False)
59
+ print(f"[+] AsterPredictor nạp thành công trọng số: {model_path}")
60
+ except Exception as e:
61
+ print(f"[!] Lỗi nạp trọng số AsterPredictor từ {model_path}: {e}")
62
+ else:
63
+ print("[*] AsterPredictor đang khởi tạo với trọng số khởi tạo (Chưa train xong hoặc chưa có file .pth).")
64
+
65
+ def preprocess(self, img, target_height=32):
66
+ """
67
+ Converts PIL image or numpy array to normalized tensor (1, 3, 32, W).
68
+ Applies:
69
+ 1. Edge protection padding (prevents character cutoff at image boundaries)
70
+ 2. Dynamic Temporal Aspect Ratio Scaling (prevents CTC double-character swallowing)
71
+ """
72
+ if isinstance(img, str):
73
+ img = Image.open(img).convert("RGB")
74
+ elif isinstance(img, np.ndarray):
75
+ if len(img.shape) == 2:
76
+ img = Image.fromarray(img).convert("RGB")
77
+ else:
78
+ img = Image.fromarray(img[:, :, ::-1] if img.shape[2] == 3 else img).convert("RGB")
79
+ elif not isinstance(img, Image.Image):
80
+ raise TypeError("Hình ảnh phải là PIL.Image, numpy.ndarray hoặc đường dẫn file.")
81
+
82
+ w, h = img.size
83
+
84
+ # 0. Auto-orientation: if text strip is vertical (H > W * 1.3), rotate 90 degrees CCW
85
+ if h > w * 1.3:
86
+ img = img.transpose(Image.ROTATE_90)
87
+ w, h = img.size
88
+
89
+ # 1. Robust Contrast-based Auto-polarity:
90
+ # Detect if text strokes are lighter than the background (white-on-dark, watermarks, dark theme)
91
+ from PIL import ImageOps
92
+ gray = np.array(img.convert('L'))
93
+ border = np.concatenate([gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1]])
94
+ center = gray[2:-2, 5:-5] if gray.shape[0] > 4 and gray.shape[1] > 10 else gray
95
+ bg = float(np.median(border))
96
+ p90 = float(np.percentile(center, 90))
97
+ p10 = float(np.percentile(center, 10))
98
+ diff_bright = p90 - bg
99
+ diff_dark = bg - p10
100
+ if (diff_bright > diff_dark and diff_bright > 20) or (bg < 90):
101
+ img = ImageOps.invert(img)
102
+
103
+ # 2. Edge protection padding: Avoid hard pure-white borders that trick CNN into seeing ';' or 'C' or ')'
104
+ # Use edge-clamp / subtle padding with border replication if needed, or natural aspect ratio
105
+ pad_x = 2
106
+ # Use image edge average color instead of pure white to avoid high-contrast artificial edges
107
+ edge_pixels = np.array(img)
108
+ bg_col = tuple(int(x) for x in edge_pixels[:, [0, -1], :].mean(axis=(0, 1))) if len(edge_pixels.shape) == 3 else (255, 255, 255)
109
+ pad_img = Image.new("RGB", (w + pad_x * 2, h), bg_col)
110
+ pad_img.paste(img, (pad_x, 0))
111
+ w, h = pad_img.size
112
+
113
+ # 2. Dynamic Temporal Aspect Ratio Scaling:
114
+ # CTC downsamples by 4x. Keep proper aspect ratio
115
+ aspect = float(w) / max(1, h)
116
+ if aspect > 3.0:
117
+ scale_w = min(1.25, 1.0 + (aspect - 3.0) * 0.02)
118
+ else:
119
+ scale_w = 1.0
120
+
121
+ new_w = max(16, int((float(target_height) / max(1, h)) * w * scale_w))
122
+ new_w = ((new_w + 3) // 4) * 4
123
+ pad_img = pad_img.resize((new_w, target_height), Image.BILINEAR)
124
+
125
+ arr = np.array(pad_img, dtype=np.float32).transpose(2, 0, 1) / 255.0
126
+ tensor = torch.from_numpy(arr).unsqueeze(0) # (1, 3, 32, W)
127
+ return tensor
128
+
129
+ @torch.inference_mode()
130
+ def predict(self, img, constraint_mode=None, use_beam=None):
131
+ """
132
+ Recognizes text from a single image strip with optional constraint mode
133
+ (e.g. 'cccd', 'date', 'phone', 'digits_only', 'uppercase_alphanumeric').
134
+ """
135
+ tensor = self.preprocess(img).to(self.device)
136
+ # Use pure vision-sequence output without uninitialized aux heads
137
+ log_probs = self.model(tensor, return_aux=False)
138
+
139
+ should_beam = (use_beam if use_beam is not None else self.use_beam_search) or (constraint_mode is not None)
140
+
141
+ if should_beam:
142
+ res = self.decoder.decode_beam_search(
143
+ log_probs,
144
+ beam_width=16,
145
+ constraint_mode=constraint_mode,
146
+ use_lexicon=True
147
+ )
148
+ else:
149
+ res = self.decoder.decode_greedy(log_probs)
150
+
151
+ out = res[0] if res else ""
152
+ try:
153
+ from asterocr.inference.viet_linguistics import viet_linguistics as cur_vl
154
+ if cur_vl:
155
+ out = cur_vl.clean_post_process(out, constraint_mode=constraint_mode)
156
+ except Exception:
157
+ if viet_linguistics:
158
+ out = viet_linguistics.clean_post_process(out, constraint_mode=constraint_mode)
159
+ else:
160
+ out = " ".join(out.split())
161
+ import re
162
+ out = re.sub(r'^[)\],.;:\-_]+', '', out)
163
+ out = re.sub(r'[)\],.;:\-_]+$', '', out)
164
+ text_no_space = out.replace(' ', '').replace('-', '')
165
+ digits_count = sum(c.isdigit() for c in text_no_space)
166
+ if digits_count >= 8 and (digits_count / max(1, len(text_no_space))) >= 0.8:
167
+ out = re.sub(r'\D', '', out)
168
+
169
+ return out
170
+
171
+ @torch.inference_mode()
172
+ def predict_batch(self, img_list, constraint_mode=None):
173
+ """Recognizes text from a batch of images with padding and optional constraint mode."""
174
+ if not img_list:
175
+ return []
176
+
177
+ tensors = [self.preprocess(img) for img in img_list]
178
+ max_w = max(t.shape[3] for t in tensors)
179
+ max_w = max(32, ((max_w + 3) // 4) * 4)
180
+
181
+ batch = torch.ones((len(tensors), 3, 32, max_w), dtype=torch.float32)
182
+ for i, t in enumerate(tensors):
183
+ w = t.shape[3]
184
+ batch[i, :, :, :w] = t[0]
185
+
186
+ batch = batch.to(self.device)
187
+ outputs = self.model(batch, return_aux=True)
188
+ log_probs, tone_logits, hat_logits = outputs[0], outputs[1], outputs[2]
189
+
190
+ if constraint_mode or self.use_beam_search:
191
+ return self.decoder.decode_beam_search(
192
+ log_probs,
193
+ beam_width=12,
194
+ constraint_mode=constraint_mode,
195
+ tone_probs=torch.softmax(tone_logits, dim=-1),
196
+ hat_probs=torch.softmax(hat_logits, dim=-1)
197
+ )
198
+ else:
199
+ return self.decoder.decode_greedy(log_probs)
200
+
201
+ if __name__ == "__main__":
202
+ predictor = AsterPredictor()
203
+ dummy = Image.new("RGB", (128, 32), (255, 255, 255))
204
+ text = predictor.predict(dummy)
205
+ print("AsterPredictor Test Output:", repr(text))