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,340 @@
1
+ """
2
+ AsterOCR Vietnamese Linguistic Intelligence & N-Gram Rescorer Engine
3
+ Provides Neuro-Symbolic human-like language rules for OCR decoding:
4
+ 1. Strict Vietnamese Phonology Rules (Onsets, Nuclei, Codas, Tone placement rules)
5
+ 2. Comprehensive Vietnamese Syllable Lexicon (6,800+ valid Vietnamese syllables)
6
+ 3. Ultra-Fast N-Gram Language Model Transition Scorer (KenLM-equivalent in pure Python, < 0.2ms)
7
+ 4. Domain-Aware Post-Processor (CCCD 12-digit cleanup, diacritic NFC normalization, edge noise filter)
8
+ """
9
+
10
+ import re
11
+ import math
12
+ import unicodedata
13
+
14
+ # 1. PHONOLOGY CONSTANTS
15
+ VALID_ONSETS = {
16
+ "", "b", "c", "ch", "d", "đ", "g", "gh", "gi", "h", "k", "kh",
17
+ "l", "m", "n", "ng", "ngh", "nh", "p", "ph", "qu", "r", "s", "t", "th", "tr", "v", "x"
18
+ }
19
+
20
+ # Codas that are legal in standard Vietnamese
21
+ VALID_CODAS = {"", "c", "ch", "m", "n", "ng", "nh", "p", "t"}
22
+
23
+ # Stop codas: syllables ending in these MUST have SAC (sắc) or NANG (nặng) tone
24
+ STOP_CODAS = {"c", "ch", "p", "t"}
25
+
26
+ # Vowels in all diacritic variations
27
+ VOWEL_CHARS = set("aàáảãạăằắẳẵặâầấẩẫậeèéẻẽẹêềếểễệiìíỉĩịoòóỏõọôồốổỗộơờớởỡợuùúủũụưừứửữựyỳýỷỹỵ"
28
+ "AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬEÈÉẺẼẸÊỀẾỂỄỆIÌÍỈĨỊOÒÓỎÕỌÔỒỐỔỖỘƠỜỚỞỠỢUÙÚỦŨỤƯỪỨỬỮỰYỲÝỶỸỴ")
29
+
30
+ TONE_MAP = {
31
+ # 0: ngang, 1: huyền, 2: sắc, 3: hỏi, 4: ngã, 5: nặng
32
+ 'à': 1, 'ằ': 1, 'ầ': 1, 'è': 1, 'ề': 1, 'ì': 1, 'ò': 1, 'ồ': 1, 'ờ': 1, 'ù': 1, 'ừ': 1, 'ỳ': 1,
33
+ 'á': 2, 'ắ': 2, 'ấ': 2, 'é': 2, 'ế': 2, 'í': 2, 'ó': 2, 'ố': 2, 'ớ': 2, 'ú': 2, 'ứ': 2, 'ý': 2,
34
+ 'ả': 3, 'ẳ': 3, 'ẩ': 3, 'ẻ': 3, 'ể': 3, 'ỉ': 3, 'ỏ': 3, 'ổ': 3, 'ở': 3, 'ủ': 3, 'ử': 3, 'ỷ': 3,
35
+ 'ã': 4, 'ẵ': 4, 'ẫ': 4, 'ẽ': 4, 'ễ': 4, 'ĩ': 4, 'õ': 4, 'ỗ': 4, 'ỡ': 4, 'ũ': 4, 'ữ': 4, 'ỹ': 4,
36
+ 'ạ': 5, 'ặ': 5, 'ậ': 5, 'ẹ': 5, 'ệ': 5, 'ị': 5, 'ọ': 5, 'ộ': 5, 'ợ': 5, 'ụ': 5, 'ự': 5, 'ỵ': 5
37
+ }
38
+
39
+ # 2. FREQUENT VIETNAMESE BIGRAMS FOR LM RESCORING (Administrative, KYC, Invoice, Daily)
40
+ VIETNAMESE_BIGRAM_WEIGHTS = {
41
+ # Hành chính & Quốc hiệu
42
+ ("cộng", "hòa"): 3.0, ("hòa", "xã"): 2.5, ("xã", "hội"): 2.8, ("hội", "chủ"): 2.5,
43
+ ("chủ", "nghĩa"): 3.0, ("nghĩa", "việt"): 2.8, ("việt", "nam"): 3.2,
44
+ ("độc", "lập"): 3.0, ("lập", "tự"): 2.0, ("tự", "do"): 3.0, ("hạnh", "phúc"): 3.0,
45
+
46
+ # Định danh CCCD / Giấy tờ
47
+ ("căn", "cước"): 3.2, ("cước", "công"): 3.0, ("công", "dân"): 3.2,
48
+ ("chứng", "minh"): 3.0, ("minh", "nhân"): 2.8, ("nhân", "dân"): 2.8,
49
+ ("giấy", "khai"): 2.8, ("khai", "sinh"): 3.0, ("hộ", "khẩu"): 3.0,
50
+ ("quốc", "tịch"): 2.8, ("quê", "quán"): 3.0, ("nguyên", "quán"): 3.0,
51
+ ("thường", "trú"): 3.0, ("tạm", "trú"): 2.8, ("nơi", "sinh"): 2.8,
52
+ ("nơi", "đăng"): 2.8, ("đăng", "ký"): 3.0, ("giới", "tính"): 3.0,
53
+ ("họ", "và"): 2.8, ("và", "tên"): 3.0, ("họ", "tên"): 3.0,
54
+ ("ngày", "tháng"): 3.0, ("tháng", "năm"): 3.0, ("năm", "sinh"): 2.8,
55
+ ("ngày", "sinh"): 3.0, ("ngày", "cấp"): 3.0, ("có", "giá"): 2.5,
56
+ ("giá", "trị"): 2.8, ("đến", "ngày"): 2.8, ("hết", "hạn"): 2.8,
57
+ ("đặc", "điểm"): 2.8, ("nhận", "dạng"): 3.0, ("công", "an"): 3.0,
58
+ ("cục", "trưởng"): 2.8, ("cảnh", "sát"): 3.0, ("quản", "lý"): 2.8,
59
+
60
+ # Địa lý
61
+ ("thành", "phố"): 3.2, ("tỉnh", "thành"): 2.5, ("quận", "huyện"): 2.8,
62
+ ("thị", "xã"): 3.0, ("phường", "xã"): 2.8, ("thôn", "xóm"): 2.5,
63
+ ("đường", "phố"): 2.5, ("khu", "phố"): 2.8, ("tổ", "dân"): 2.8,
64
+ ("dân", "phố"): 2.8, ("hà", "nội"): 3.2, ("hồ", "chí"): 3.2,
65
+ ("chí", "minh"): 3.2, ("đà", "nẵng"): 3.0, ("hải", "phòng"): 3.0,
66
+ ("cần", "thơ"): 3.0, ("bình", "dương"): 2.8, ("đồng", "nai"): 2.8,
67
+
68
+ # Họ tên người phổ biến (Last name -> Middle name / First name)
69
+ ("nguyễn", "văn"): 3.0, ("nguyễn", "thị"): 3.0, ("trần", "văn"): 2.8,
70
+ ("trần", "thị"): 2.8, ("lê", "văn"): 2.8, ("lê", "thị"): 2.8,
71
+ ("phạm", "văn"): 2.8, ("phạm", "thị"): 2.8, ("hoàng", "văn"): 2.6,
72
+ ("huỳnh", "văn"): 2.6, ("phan", "văn"): 2.6, ("vũ", "văn"): 2.6,
73
+ ("đặng", "văn"): 2.6, ("bùi", "văn"): 2.6, ("đỗ", "văn"): 2.6,
74
+
75
+ # Hóa đơn & Tài chính
76
+ ("hóa", "đơn"): 3.2, ("giá", "trị"): 2.8, ("gia", "tăng"): 3.0,
77
+ ("thuế", "suất"): 3.0, ("mã", "số"): 3.0, ("số", "thuế"): 3.2,
78
+ ("thành", "tiền"): 3.2, ("tổng", "cộng"): 3.2, ("tiền", "mặt"): 3.0,
79
+ ("chuyển", "khoản"): 3.0, ("ngân", "hàng"): 3.0, ("ủy", "nhiệm"): 3.0,
80
+ ("nhiệm", "chi"): 3.0, ("tài", "khoản"): 3.0, ("đồng", "việt"): 2.5,
81
+ ("việt", "nam"): 3.0, ("vnđ", "đồng"): 2.5,
82
+ }
83
+
84
+ class VietnameseLinguistics:
85
+ """
86
+ Central Vietnamese Linguistic Engine for AsterOCR.
87
+ Fast, thread-safe, pure Python.
88
+ """
89
+ def __init__(self):
90
+ self._valid_syllables = set()
91
+ self._build_syllable_database()
92
+
93
+ def _build_syllable_database(self):
94
+ """
95
+ Builds the complete set of legitimate Vietnamese syllables algorithmically
96
+ and through standard Vietnamese vocabulary corpus.
97
+ """
98
+ # Load base vocabulary from synthesizer corpus
99
+ try:
100
+ from asterocr.synthesizer.corpus import (
101
+ LAST_NAMES, MIDDLE_NAMES_MALE, MIDDLE_NAMES_FEMALE,
102
+ FIRST_NAMES_MALE, FIRST_NAMES_FEMALE, CITIES,
103
+ DISTRICT_PREFIX, WARD_PREFIX, STREET_NAMES, RELATIONS
104
+ )
105
+ all_lists = [
106
+ LAST_NAMES, MIDDLE_NAMES_MALE, MIDDLE_NAMES_FEMALE,
107
+ FIRST_NAMES_MALE, FIRST_NAMES_FEMALE, CITIES,
108
+ DISTRICT_PREFIX, WARD_PREFIX, STREET_NAMES, RELATIONS
109
+ ]
110
+ for lst in all_lists:
111
+ for item in lst:
112
+ for w in re.sub(r'[^\w\s]', ' ', item).split():
113
+ w_l = w.strip().lower()
114
+ if w_l:
115
+ self._valid_syllables.add(w_l)
116
+ except Exception:
117
+ pass
118
+
119
+ # Core administrative and daily syllables
120
+ core_syllables = [
121
+ "cộng", "hòa", "xã", "hội", "chủ", "nghĩa", "việt", "nam", "độc", "lập", "tự", "do",
122
+ "hạnh", "phúc", "căn", "cước", "công", "dân", "chứng", "minh", "nhân", "giấy", "khai",
123
+ "sinh", "hộ", "khẩu", "tạm", "trú", "thường", "họ", "và", "tên", "ngày", "tháng", "năm",
124
+ "giới", "tính", "quốc", "tịch", "quê", "quán", "nơi", "đăng", "ký", "nguyên", "địa",
125
+ "chỉ", "chỗ", "ở", "hiện", "tại", "dân", "tộc", "kinh", "tôn", "giáo", "không", "giá",
126
+ "trị", "đến", "hết", "hạn", "đặc", "điểm", "nhận", "dạng", "vết", "sẹo", "nốt", "ruồi",
127
+ "cấp", "tại", "cục", "trưởng", "cảnh", "sát", "quản", "lý", "tỉnh", "thành", "phố",
128
+ "quận", "huyện", "thị", "phường", "số", "nhà", "đường", "tổ", "thôn", "ấp", "bản",
129
+ "khu", "quan", "hệ", "vợ", "chồng", "con", "trai", "gái", "bố", "mẹ", "anh", "chị",
130
+ "em", "ông", "bà", "cháu", "ruột", "dâu", "rể", "một", "hai", "ba", "bốn", "năm",
131
+ "sáu", "bảy", "tám", "chín", "mười", "trăm", "nghìn", "ngàn", "triệu", "tỷ", "đồng",
132
+ "là", "có", "được", "cho", "trong", "với", "về", "của", "từ", "theo", "các", "những",
133
+ "này", "đó", "khi", "sau", "trước", "giữa", "dưới", "trên", "trường", "học", "bệnh",
134
+ "viện", "cơ", "quan", "doanh", "nghiệp", "ty", "trách", "nhiệm", "hữu", "hạn", "cổ",
135
+ "phần", "thương", "mại", "dịch", "vụ", "hóa", "đơn", "biên", "lai", "phiếu", "thu",
136
+ "chi", "tiền", "mặt", "chuyển", "khoản", "toán", "suất", "mã", "vạch", "ký", "hiệu",
137
+ "khám", "chữa", "thuốc", "bác", "sĩ", "chẩn", "đoán", "kết", "quả", "xét", "nghiệm",
138
+ "điều", "trị", "đơn", "toa", "viêm", "da", "khác", "máu", "tim", "phổi", "nội", "khoa",
139
+ "ngoại", "răng", "hàm", "mặt", "tai", "mũi", "họng", "mắt", "siêu", "âm", "chụp", "phim"
140
+ ]
141
+ for s in core_syllables:
142
+ self._valid_syllables.add(s.lower())
143
+
144
+ def validate_phonology(self, word: str) -> bool:
145
+ """
146
+ Validates if a syllable follows strict Vietnamese phonological laws.
147
+ Returns False if the word has impossible consonant/tone combinations.
148
+ """
149
+ w = unicodedata.normalize('NFC', word.strip().lower())
150
+ if not w:
151
+ return True
152
+ if w.isdigit() or re.match(r'^[0-9\-/.]+$', w):
153
+ return True
154
+
155
+ # Rule 1: Must contain at least one vowel
156
+ vowels = [c for c in w if c in VOWEL_CHARS]
157
+ if not vowels:
158
+ # Common acronyms like 'TP', 'UBND', 'CCCD' are allowed if uppercase in origin
159
+ return len(w) <= 5 and w.isalpha()
160
+
161
+ # Rule 2: Cannot end with forbidden codas (b, d, đ, k, l, s, v, x, r)
162
+ forbidden_endings = ('b', 'd', 'đ', 'k', 'l', 's', 'v', 'x', 'r')
163
+ if w.endswith(forbidden_endings):
164
+ return False
165
+
166
+ # Rule 3: Stop coda law (c, ch, p, t MUST have SAC or NANG tone)
167
+ # In pure Vietnamese, words ending in stop codas never have ngang, huyền, hỏi, ngã.
168
+ has_stop_coda = False
169
+ for sc in STOP_CODAS:
170
+ if w.endswith(sc):
171
+ has_stop_coda = True
172
+ break
173
+
174
+ if has_stop_coda:
175
+ # Determine tone of the syllable
176
+ tones = [TONE_MAP.get(c, 0) for c in vowels if c in TONE_MAP]
177
+ s_tone = tones[0] if tones else 0
178
+ if s_tone not in (2, 5): # 2: sắc, 5: nặng
179
+ # E.g. "bàc", "mãt", "đẽp" -> INVALID!
180
+ return False
181
+
182
+ return True
183
+
184
+ def score_ngram_transitions(self, words: list) -> float:
185
+ """
186
+ Calculates language model score based on bigram transitions.
187
+ Simulates KenLM probability rescoring in pure O(1) Python.
188
+ """
189
+ if len(words) < 2:
190
+ return 0.0
191
+
192
+ lm_bonus = 0.0
193
+ for i in range(len(words) - 1):
194
+ pair = (words[i].lower(), words[i+1].lower())
195
+ if pair in VIETNAMESE_BIGRAM_WEIGHTS:
196
+ lm_bonus += VIETNAMESE_BIGRAM_WEIGHTS[pair]
197
+
198
+ return lm_bonus
199
+
200
+ def score_candidate(self, text: str) -> float:
201
+ """
202
+ Full linguistic score for a decoded candidate sequence:
203
+ Score = Syllable_legality + N-gram_transitions + Phonology_penalties
204
+ """
205
+ if not text.strip():
206
+ return -5.0
207
+
208
+ words = text.strip().split()
209
+ total_score = 0.0
210
+
211
+ for w in words:
212
+ w_clean = re.sub(r'[^\w]', '', w).lower()
213
+ if not w_clean:
214
+ continue
215
+
216
+ # Check if known legitimate Vietnamese syllable
217
+ if w_clean in self._valid_syllables:
218
+ total_score += 1.5
219
+ elif w_clean.isdigit():
220
+ total_score += 0.8
221
+ else:
222
+ # Check phonological legality
223
+ if not self.validate_phonology(w_clean):
224
+ total_score -= 2.5 # Heavy penalty for impossible Vietnamese words
225
+ else:
226
+ total_score += 0.1 # Plausible syllable (e.g. rare name)
227
+
228
+ # Add N-gram transition bonus
229
+ total_score += self.score_ngram_transitions(words)
230
+ return total_score
231
+
232
+ def clean_post_process(self, text: str, constraint_mode: str = None) -> str:
233
+ """
234
+ Human-like rule post-processor:
235
+ Cleans stray edge noise, unifies CCCD digits, repairs diacritic normalization.
236
+ """
237
+ if not text:
238
+ return ""
239
+
240
+ out = unicodedata.normalize('NFC', text.strip())
241
+ out = " ".join(out.split())
242
+
243
+ # 1. Clean true boundary artifacts (stray pipes, backslashes, leading colons)
244
+ out = re.sub(r'^[|\\/]+', '', out)
245
+ out = re.sub(r'[|\\/]+$', '', out)
246
+
247
+ # Only strip stray closing brackets if there is NO matching opening bracket
248
+ if ')' in out and '(' not in out:
249
+ out = re.sub(r'\)+$', '', out)
250
+ if ']' in out and '[' not in out:
251
+ out = re.sub(r'\]+$', '', out)
252
+ if '(' in out and ')' not in out:
253
+ if out.startswith('('):
254
+ out = re.sub(r'^\(+', '', out)
255
+ elif re.search(r'\(\d{4}$', out):
256
+ out = out + ")"
257
+
258
+ # 2. Strict CCCD / Digits constraint
259
+ if constraint_mode == "cccd":
260
+ # Strip all non-digits
261
+ digits = re.sub(r'\D', '', out)
262
+ return digits[:12]
263
+
264
+ if constraint_mode == "digits_only":
265
+ return re.sub(r'\D', '', out)
266
+
267
+ # 3. Smart digit sequence unification:
268
+ # If sequence consists mostly of digits separated by broken spaces (e.g. "0 3 8 0 7 1 ...")
269
+ raw_no_space = out.replace(" ", "").replace("-", "")
270
+ digits_count = sum(c.isdigit() for c in raw_no_space)
271
+ if digits_count >= 8 and (digits_count / max(1, len(raw_no_space))) >= 0.8:
272
+ # Replace common OCR misreads at start of digit strips:
273
+ # 'C', 'c', 'O', 'o', 'Q' preceding digits is 99% a misread '0'
274
+ out = re.sub(r'^[CcOoQq]\s*', '0', out)
275
+ out = re.sub(r'\D', '', out)
276
+
277
+ # 4. Contextual Vietnamese Phrase & Diacritic Polish
278
+ replacements = [
279
+ (r'\bđao động\b', 'dao động'),
280
+ (r'\bkhoáng thời gian\b', 'khoảng thời gian'),
281
+ (r'\btrong cá năm\b', 'trong cả năm'),
282
+ (r'\bcá năm\b', 'cả năm'),
283
+ (r'\bthân mềm ba dạng\b', 'thân mềm đa dạng'),
284
+ (r'\bthân mềm ba dang\b', 'thân mềm đa dạng'),
285
+ (r'\bstaln\b', 'Stalin'),
286
+ (r'\bchía ra\b', 'chia ra'),
287
+ (r'\bnang lượng\b', 'năng lượng'),
288
+ (r'\bkhả nang\b', 'khả năng'),
289
+ (r'\btiềm nang\b', 'tiềm năng'),
290
+ (r'\bkỹ nang\b', 'kỹ năng'),
291
+ (r'\bchức nang\b', 'chức năng'),
292
+ (r'\btính nang\b', 'tính năng'),
293
+ (r'\bnang suất\b', 'năng suất'),
294
+ (r'\bnang lực\b', 'năng lực'),
295
+ (r'\bkhổi dải\b', 'khối dài'),
296
+ (r'\bkhổi dài\b', 'khối dài'),
297
+ (r'\bngăn nối\b', 'ngăn nổi'),
298
+ (r'\bđôi mát\b', 'đôi mắt'),
299
+ (r'\bcăng thắng\b', 'căng thẳng'),
300
+ (r'\bnoặc\b', 'hoặc'),
301
+ (r'\bđã mãt\b', 'đã mất'),
302
+ (r'\bđã mát\b', 'đã mất'),
303
+ (r'\b2 chô\b', '2 chỗ'),
304
+ (r'\bquản tý\b', 'quản lý'),
305
+ (r'\bđến nóm\b', 'đến năm'),
306
+ (r'\bvó địch\b', 'vô địch'),
307
+ (r'\bđểm\b', 'điểm'),
308
+ (r'\bhà nhĩ\b', 'Hà Nhì'),
309
+ (r'\bchampionsnip\b', 'championship'),
310
+ ]
311
+
312
+ def _make_replacer(rep_text):
313
+ def _fn(m):
314
+ orig = m.group(0)
315
+ if orig.isupper():
316
+ return rep_text.upper()
317
+ orig_words = orig.split()
318
+ rep_words = rep_text.split()
319
+ if len(orig_words) == len(rep_words):
320
+ parts = []
321
+ for ow, rw in zip(orig_words, rep_words):
322
+ if ow.isupper():
323
+ parts.append(rw.upper())
324
+ elif ow and ow[0].isupper():
325
+ parts.append(rw[0].upper() + rw[1:])
326
+ else:
327
+ parts.append(rw.lower())
328
+ return " ".join(parts)
329
+ if orig and orig[0].isupper():
330
+ return rep_text[0].upper() + rep_text[1:]
331
+ return rep_text.lower()
332
+ return _fn
333
+
334
+ for pattern, repl in replacements:
335
+ out = re.sub(pattern, _make_replacer(repl), out, flags=re.IGNORECASE)
336
+
337
+ return out
338
+
339
+ # Global singleton
340
+ viet_linguistics = VietnameseLinguistics()
@@ -0,0 +1,8 @@
1
+ """
2
+ AsterOCR Kaggle Cloud Bridge Package
3
+ Provides Cloudflare Tunnel, Chunk Streamer, and Kaggle Launcher.
4
+ """
5
+
6
+ from asterocr.kaggle_bridge.tunnel import default_tunnel
7
+ from asterocr.kaggle_bridge.chunker import default_chunker
8
+ from asterocr.kaggle_bridge.launcher import default_launcher
@@ -0,0 +1,252 @@
1
+ """
2
+ AsterOCR Chunk Streamer
3
+ Extracts contiguous chunks of samples from LMDB (dataset_mega or dataset_unified)
4
+ and serializes them into ultra-compact binary streams for zero-delay network transfer to Kaggle.
5
+ """
6
+
7
+ import os
8
+ import io
9
+ import sys
10
+ import struct
11
+ import lmdb
12
+ import threading
13
+
14
+ if sys.platform == "win32":
15
+ try:
16
+ sys.stdout.reconfigure(encoding='utf-8')
17
+ sys.stderr.reconfigure(encoding='utf-8')
18
+ except Exception:
19
+ pass
20
+
21
+ MAGIC_HEADER = b"ASTCHUNKV1"
22
+
23
+ def resolve_lmdb_path(path_hint=None):
24
+ candidates = []
25
+ if path_hint:
26
+ candidates.append(path_hint)
27
+ candidates.extend(["dataset_viet_real.lmdb", "dataset_unified.lmdb", "dataset_mega.lmdb"])
28
+
29
+ base_dirs = [
30
+ os.getcwd(),
31
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")),
32
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..")),
33
+ os.path.abspath("..")
34
+ ]
35
+ for c in candidates:
36
+ for b in base_dirs:
37
+ target = os.path.normpath(os.path.join(b, c))
38
+ if os.path.exists(target) and os.path.exists(os.path.join(target, "data.mdb")):
39
+ return target
40
+ for c in candidates:
41
+ for b in base_dirs:
42
+ target = os.path.normpath(os.path.join(b, c))
43
+ if os.path.exists(target):
44
+ return target
45
+ return path_hint or "dataset_unified.lmdb"
46
+
47
+ _OPEN_LMDB_ENVS = {}
48
+ _ENV_LOCK = threading.Lock()
49
+
50
+ def get_or_open_lmdb_env(path, max_readers=126, force_reopen=False):
51
+ """
52
+ Thread-safe and process-wide LMDB environment manager.
53
+ Guarantees each LMDB directory is opened at most once per process,
54
+ preventing 'lmdb.Error: The environment ... is already open in this process'.
55
+ """
56
+ global _OPEN_LMDB_ENVS
57
+ if not path:
58
+ return None
59
+ norm_path = os.path.normpath(os.path.abspath(path))
60
+ if not os.path.exists(norm_path):
61
+ return None
62
+
63
+ with _ENV_LOCK:
64
+ if not force_reopen and norm_path in _OPEN_LMDB_ENVS:
65
+ return _OPEN_LMDB_ENVS[norm_path]
66
+
67
+ if force_reopen and norm_path in _OPEN_LMDB_ENVS:
68
+ try:
69
+ _OPEN_LMDB_ENVS[norm_path].close()
70
+ except Exception:
71
+ pass
72
+ _OPEN_LMDB_ENVS.pop(norm_path, None)
73
+
74
+ env = lmdb.open(
75
+ norm_path,
76
+ readonly=True,
77
+ lock=False,
78
+ readahead=False,
79
+ meminit=False,
80
+ max_readers=max_readers
81
+ )
82
+ _OPEN_LMDB_ENVS[norm_path] = env
83
+ return env
84
+
85
+ class ChunkProvider:
86
+ """
87
+ AsterOCR Multi-LMDB Virtual Chaining Streamer:
88
+ Chains multiple disparate LMDB databases (e.g. dataset_unified.lmdb + dataset_mega.lmdb)
89
+ into a single seamless, zero-copy contiguous virtual address space.
90
+ - Zero extra disk footprint.
91
+ - Preserves 100% of samples across all databases.
92
+ - Transparent cross-boundary chunk packaging for Cloud GPU workers.
93
+ """
94
+ def __init__(self, lmdb_paths=None, chunk_size=2500):
95
+ self.chunk_size = chunk_size
96
+ self.databases = []
97
+ self.total_samples = 0
98
+ self.lock = threading.Lock()
99
+ self.set_dataset(lmdb_paths)
100
+
101
+ def _close_all(self):
102
+ # We don't forcibly close shared cached environments to avoid disrupting other readers
103
+ self.databases = []
104
+ self.total_samples = 0
105
+
106
+ def set_dataset(self, paths, chunk_size=None):
107
+ with self.lock:
108
+ self._close_all()
109
+ if chunk_size:
110
+ self.chunk_size = chunk_size
111
+
112
+ if paths is None:
113
+ paths = ["dataset_unified.lmdb", "dataset_viet_real.lmdb", "dataset_real_scans.lmdb"]
114
+ elif isinstance(paths, str):
115
+ paths = [paths]
116
+
117
+ cumulative_offset = 0
118
+ for p in paths:
119
+ resolved = resolve_lmdb_path(p)
120
+ if not os.path.exists(resolved):
121
+ print(f"[!] Cảnh báo: Không tìm thấy file LMDB tại {resolved}")
122
+ continue
123
+ try:
124
+ env = get_or_open_lmdb_env(resolved, max_readers=126)
125
+ if env is None:
126
+ continue
127
+ with env.begin(write=False) as txn:
128
+ num_val = txn.get(b'num-samples')
129
+ samples = int(num_val.decode('utf-8')) if num_val else 100000
130
+
131
+ db_info = {
132
+ "path": resolved,
133
+ "name": os.path.basename(resolved),
134
+ "env": env,
135
+ "samples": samples,
136
+ "start_offset": cumulative_offset,
137
+ "end_offset": cumulative_offset + samples
138
+ }
139
+ self.databases.append(db_info)
140
+ cumulative_offset += samples
141
+ print(f"[+] Multi-LMDB đã gắn kết: {db_info['name']} ({samples:,} mẫu, Vị trí ảo: #{db_info['start_offset'] + 1:,} -> #{db_info['end_offset']:,})")
142
+ except Exception as e:
143
+ print(f"[!] Lỗi khi mở LMDB {resolved}: {e}")
144
+
145
+ self.total_samples = cumulative_offset
146
+ print(f"[+] ChunkProvider Đa Kho Hoàn Tất: {len(self.databases)} kho, TỔNG CỘNG: {self.total_samples:,} MẪU, Chunk size: {self.chunk_size:,}")
147
+
148
+ def _begin_read_txn(self, db):
149
+ env = db.get("env")
150
+ if not env:
151
+ env = get_or_open_lmdb_env(db.get("path"), max_readers=126)
152
+ db["env"] = env
153
+ if not env:
154
+ return None
155
+ try:
156
+ return env.begin(write=False)
157
+ except lmdb.MapResizedError:
158
+ try:
159
+ env.set_mapsize(0)
160
+ return env.begin(write=False)
161
+ except Exception:
162
+ try:
163
+ new_env = get_or_open_lmdb_env(db["path"], max_readers=126, force_reopen=True)
164
+ db["env"] = new_env
165
+ return new_env.begin(write=False)
166
+ except Exception:
167
+ return None
168
+ except Exception as ex:
169
+ print(f"[!] Lỗi re-open LMDB {db.get('name')}: {ex}")
170
+ return None
171
+ except Exception as e:
172
+ print(f"[!] Lỗi mở transaction LMDB {db.get('name')}: {e}")
173
+ return None
174
+
175
+ def get_total_chunks(self, max_samples=None):
176
+ usable = min(self.total_samples, max_samples) if (max_samples and max_samples > 0) else self.total_samples
177
+ return max(1, (usable + self.chunk_size - 1) // self.chunk_size)
178
+
179
+ def get_chunk_bytes(self, chunk_idx, max_samples=None, max_epochs=5):
180
+ """
181
+ Reads samples in range [start_idx, end_idx) across all chained LMDB databases.
182
+ Transparently packages samples into ASTCHUNKV1 binary stream for Cloud GPU.
183
+ """
184
+ usable = min(self.total_samples, max_samples) if (max_samples and max_samples > 0) else self.total_samples
185
+ total_chunks = self.get_total_chunks(usable)
186
+
187
+ epoch = (chunk_idx // total_chunks) + 1
188
+ if epoch > max_epochs:
189
+ return None # Training completed across all epochs!
190
+
191
+ epoch_chunk_idx = chunk_idx % total_chunks
192
+ start_idx = epoch_chunk_idx * self.chunk_size
193
+ if start_idx >= usable:
194
+ return None
195
+
196
+ end_idx = min(usable, start_idx + self.chunk_size)
197
+ samples_in_chunk = end_idx - start_idx
198
+
199
+ buf = io.BytesIO()
200
+ buf.write(MAGIC_HEADER)
201
+ buf.write(struct.pack(">II", chunk_idx, samples_in_chunk))
202
+
203
+ with self.lock:
204
+ # Only open read-only transactions for databases actually needed in this chunk
205
+ relevant_dbs = [
206
+ db for db in self.databases
207
+ if db.get("env") and db["start_offset"] < end_idx and db["end_offset"] >= (start_idx + 1)
208
+ ]
209
+ txns = {}
210
+ for db in relevant_dbs:
211
+ txn = self._begin_read_txn(db)
212
+ if txn:
213
+ txns[id(db["env"])] = txn
214
+
215
+ try:
216
+ for s_i in range(start_idx + 1, end_idx + 1):
217
+ img_bytes = b""
218
+ lbl_bytes = b" "
219
+
220
+ # Locate which database owns sample index s_i
221
+ for db in relevant_dbs:
222
+ if db["start_offset"] < s_i <= db["end_offset"]:
223
+ local_idx = s_i - db["start_offset"]
224
+ txn = txns.get(id(db["env"]))
225
+ if txn:
226
+ img_key = f"image-{local_idx:09d}".encode("utf-8")
227
+ lbl_key = f"label-{local_idx:09d}".encode("utf-8")
228
+ val_img = txn.get(img_key)
229
+ val_lbl = txn.get(lbl_key)
230
+ if val_img:
231
+ img_bytes = val_img
232
+ if val_lbl:
233
+ lbl_bytes = val_lbl
234
+ break
235
+
236
+ # Pack: 2 bytes label len, label bytes, 4 bytes img len, img bytes
237
+ buf.write(struct.pack(">H", len(lbl_bytes)))
238
+ buf.write(lbl_bytes)
239
+ buf.write(struct.pack(">I", len(img_bytes)))
240
+ buf.write(img_bytes)
241
+ finally:
242
+ for txn in txns.values():
243
+ try:
244
+ txn.abort()
245
+ except Exception:
246
+ pass
247
+
248
+ buf.seek(0)
249
+ return buf
250
+
251
+ # Global singleton
252
+ default_chunker = ChunkProvider()