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.
- asterocr/__init__.py +12 -0
- asterocr/cli.py +35 -0
- asterocr/comparator.py +412 -0
- asterocr/dataset/export_real_zip.py +117 -0
- asterocr/dataset/loader.py +371 -0
- asterocr/dataset/unified_packer.py +402 -0
- asterocr/download_dataset.py +93 -0
- asterocr/inference/decoder.py +302 -0
- asterocr/inference/lexicon.py +136 -0
- asterocr/inference/predictor.py +205 -0
- asterocr/inference/viet_linguistics.py +340 -0
- asterocr/kaggle_bridge/__init__.py +8 -0
- asterocr/kaggle_bridge/chunker.py +252 -0
- asterocr/kaggle_bridge/colab_auto_runner.py +380 -0
- asterocr/kaggle_bridge/kaggle_worker.py +1911 -0
- asterocr/kaggle_bridge/launcher.py +245 -0
- asterocr/kaggle_bridge/tunnel.py +190 -0
- asterocr/models/backbone.py +102 -0
- asterocr/models/language.py +104 -0
- asterocr/models/model.py +113 -0
- asterocr/models/sequence.py +34 -0
- asterocr/pipeline.py +179 -0
- asterocr/studio.py +371 -0
- asterocr/synthesizer/corpus.py +378 -0
- asterocr/synthesizer/font_manager.py +170 -0
- asterocr/synthesizer/generator.py +205 -0
- asterocr/synthesizer/handwriting_generator.py +420 -0
- asterocr/synthesizer/mega_generator.py +527 -0
- asterocr/trainer/metrics.py +53 -0
- asterocr/trainer/trainer.py +1295 -0
- asterocr/vocab.py +175 -0
- asterocr-1.0.0.dist-info/METADATA +102 -0
- asterocr-1.0.0.dist-info/RECORD +36 -0
- asterocr-1.0.0.dist-info/WHEEL +5 -0
- asterocr-1.0.0.dist-info/entry_points.txt +2 -0
- asterocr-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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))
|