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,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AsterOCR Multi-Source Blended Dataset Engine
|
|
3
|
+
Features:
|
|
4
|
+
1. AsterZipDataset: Reads directly from 7.3GB ocr_dataset.zip without extracting to disk.
|
|
5
|
+
Caches sample index to zip_index.json for instant 0.05s loading.
|
|
6
|
+
2. AsterSyntheticDataset: Infinite realistic Vietnamese administrative text generator.
|
|
7
|
+
3. AsterMultiSourceDataset: Blends real-world scanned images + synthetic Vietnamese data
|
|
8
|
+
according to configurable mixing ratios (e.g. 50% Real / 50% Synthetic).
|
|
9
|
+
4. ctc_collate_fn: Batch padder and CTC length tensor builder.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import random
|
|
16
|
+
import zipfile
|
|
17
|
+
import torch
|
|
18
|
+
from torch.utils.data import Dataset, DataLoader
|
|
19
|
+
from PIL import Image
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import lmdb
|
|
24
|
+
except ImportError:
|
|
25
|
+
lmdb = None
|
|
26
|
+
|
|
27
|
+
from asterocr.vocab import default_vocab
|
|
28
|
+
from asterocr.synthesizer.corpus import generate_random_corpus_text
|
|
29
|
+
from asterocr.synthesizer.generator import default_generator
|
|
30
|
+
|
|
31
|
+
class AsterLMDBDataset(Dataset):
|
|
32
|
+
"""
|
|
33
|
+
Ultra-high performance zero-copy LMDB Dataset loader for AsterOCR.
|
|
34
|
+
Reads directly from LMDB database (e.g. dataset_mega.lmdb).
|
|
35
|
+
Compatible with standard CRNN / ASTER / Clova AI format:
|
|
36
|
+
- 'image-%09d' -> raw JPEG/PNG bytes
|
|
37
|
+
- 'label-%09d' -> UTF-8 encoded text string
|
|
38
|
+
- 'num-samples' -> total number of samples
|
|
39
|
+
Supports multi-processing PyTorch DataLoader via lazy environment initialization.
|
|
40
|
+
"""
|
|
41
|
+
def __init__(self, lmdb_dir, target_height=32, max_samples=None):
|
|
42
|
+
if lmdb is None:
|
|
43
|
+
raise ImportError("Gói 'lmdb' chưa được cài đặt! Hãy chạy: pip install lmdb")
|
|
44
|
+
|
|
45
|
+
self.lmdb_dir = lmdb_dir
|
|
46
|
+
self.target_height = target_height
|
|
47
|
+
self.env = None
|
|
48
|
+
|
|
49
|
+
if not os.path.exists(lmdb_dir):
|
|
50
|
+
raise FileNotFoundError(f"Thư mục LMDB không tồn tại: {lmdb_dir}")
|
|
51
|
+
|
|
52
|
+
from asterocr.kaggle_bridge.chunker import get_or_open_lmdb_env
|
|
53
|
+
self.env = get_or_open_lmdb_env(self.lmdb_dir, max_readers=126)
|
|
54
|
+
if self.env is None:
|
|
55
|
+
raise FileNotFoundError(f"Không thể mở LMDB tại: {lmdb_dir}")
|
|
56
|
+
|
|
57
|
+
with self.env.begin(write=False) as txn:
|
|
58
|
+
num_samples_val = txn.get(b'num-samples')
|
|
59
|
+
if num_samples_val is None:
|
|
60
|
+
raise ValueError(f"Không tìm thấy khóa 'num-samples' trong LMDB: {lmdb_dir}")
|
|
61
|
+
total = int(num_samples_val.decode('utf-8'))
|
|
62
|
+
self.num_samples = min(total, max_samples) if (max_samples and max_samples > 0) else total
|
|
63
|
+
|
|
64
|
+
def _init_env(self):
|
|
65
|
+
if self.env is None:
|
|
66
|
+
from asterocr.kaggle_bridge.chunker import get_or_open_lmdb_env
|
|
67
|
+
self.env = get_or_open_lmdb_env(self.lmdb_dir, max_readers=126)
|
|
68
|
+
|
|
69
|
+
def __len__(self):
|
|
70
|
+
return self.num_samples
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, idx):
|
|
73
|
+
self._init_env()
|
|
74
|
+
sample_idx = idx + 1
|
|
75
|
+
try:
|
|
76
|
+
with self.env.begin(write=False) as txn:
|
|
77
|
+
img_key = f"image-{sample_idx:09d}".encode("utf-8")
|
|
78
|
+
lbl_key = f"label-{sample_idx:09d}".encode("utf-8")
|
|
79
|
+
|
|
80
|
+
img_bytes = txn.get(img_key)
|
|
81
|
+
label_bytes = txn.get(lbl_key)
|
|
82
|
+
|
|
83
|
+
if img_bytes is None or label_bytes is None:
|
|
84
|
+
img = Image.new("RGB", (64, self.target_height), (255, 255, 255))
|
|
85
|
+
text = " "
|
|
86
|
+
else:
|
|
87
|
+
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
88
|
+
raw_str = label_bytes.decode("utf-8", errors="ignore").strip()
|
|
89
|
+
text = raw_str if raw_str else " "
|
|
90
|
+
except Exception:
|
|
91
|
+
img = Image.new("RGB", (64, self.target_height), (255, 255, 255))
|
|
92
|
+
text = " "
|
|
93
|
+
|
|
94
|
+
w, h = img.size
|
|
95
|
+
new_w = max(16, int((float(self.target_height) / max(1, h)) * w))
|
|
96
|
+
# Guarantee enough width for characters so CTC feature length >= target length
|
|
97
|
+
min_w = len(text) * 8 + 16
|
|
98
|
+
new_w = min(480, max(new_w, min_w))
|
|
99
|
+
img = img.resize((new_w, self.target_height), Image.BILINEAR)
|
|
100
|
+
|
|
101
|
+
arr = np.array(img, dtype=np.float32).transpose(2, 0, 1) / 255.0
|
|
102
|
+
return torch.from_numpy(arr), text
|
|
103
|
+
|
|
104
|
+
class AsterSyntheticDataset(Dataset):
|
|
105
|
+
"""Infinite synthetic training dataset generated in real time."""
|
|
106
|
+
def __init__(self, size=10000, target_height=32, generator=None):
|
|
107
|
+
self.size = size
|
|
108
|
+
self.target_height = target_height
|
|
109
|
+
self.generator = generator or default_generator
|
|
110
|
+
|
|
111
|
+
def __len__(self):
|
|
112
|
+
return self.size
|
|
113
|
+
|
|
114
|
+
def __getitem__(self, idx):
|
|
115
|
+
text = generate_random_corpus_text()
|
|
116
|
+
img = self.generator.render_text(text, target_height=self.target_height)
|
|
117
|
+
|
|
118
|
+
arr = np.array(img, dtype=np.float32).transpose(2, 0, 1) / 255.0
|
|
119
|
+
tensor = torch.from_numpy(arr)
|
|
120
|
+
return tensor, text
|
|
121
|
+
|
|
122
|
+
class AsterZipDataset(Dataset):
|
|
123
|
+
"""
|
|
124
|
+
Directly streams image and text label pairs from ocr_dataset.zip.
|
|
125
|
+
Zero disk extraction required.
|
|
126
|
+
"""
|
|
127
|
+
def __init__(self, zip_path="dataset_download/ocr_dataset.zip", target_height=32, max_samples=None):
|
|
128
|
+
self.zip_path = zip_path
|
|
129
|
+
self.target_height = target_height
|
|
130
|
+
self.pairs = [] # list of (img_name, txt_name)
|
|
131
|
+
|
|
132
|
+
index_cache = os.path.splitext(zip_path)[0] + "_index.json"
|
|
133
|
+
|
|
134
|
+
if os.path.exists(index_cache):
|
|
135
|
+
try:
|
|
136
|
+
with open(index_cache, "r", encoding="utf-8") as f:
|
|
137
|
+
self.pairs = json.load(f)
|
|
138
|
+
except Exception:
|
|
139
|
+
self.pairs = []
|
|
140
|
+
|
|
141
|
+
if not self.pairs and os.path.exists(zip_path):
|
|
142
|
+
print(f"[*] Đang lập chỉ mục {zip_path} (lần đầu tiên)...")
|
|
143
|
+
with zipfile.ZipFile(zip_path, "r") as z:
|
|
144
|
+
all_names = set(z.namelist())
|
|
145
|
+
# Filter for images that have matching txt files
|
|
146
|
+
for n in all_names:
|
|
147
|
+
if n.endswith(('.jpg', '.png')):
|
|
148
|
+
base, _ = os.path.splitext(n)
|
|
149
|
+
txt_n = base + ".txt"
|
|
150
|
+
if txt_n in all_names:
|
|
151
|
+
self.pairs.append((n, txt_n))
|
|
152
|
+
|
|
153
|
+
# Save index cache
|
|
154
|
+
try:
|
|
155
|
+
with open(index_cache, "w", encoding="utf-8") as f:
|
|
156
|
+
json.dump(self.pairs, f)
|
|
157
|
+
print(f"[+] Đã lưu chỉ mục: {len(self.pairs)} mẫu vào {index_cache}")
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
if max_samples and len(self.pairs) > max_samples:
|
|
162
|
+
self.pairs = self.pairs[:max_samples]
|
|
163
|
+
|
|
164
|
+
self.zip_ref = None
|
|
165
|
+
|
|
166
|
+
def _get_zip(self):
|
|
167
|
+
if self.zip_ref is None:
|
|
168
|
+
self.zip_ref = zipfile.ZipFile(self.zip_path, "r")
|
|
169
|
+
return self.zip_ref
|
|
170
|
+
|
|
171
|
+
def __len__(self):
|
|
172
|
+
return len(self.pairs)
|
|
173
|
+
|
|
174
|
+
def __getitem__(self, idx):
|
|
175
|
+
if not self.pairs:
|
|
176
|
+
# Fallback dummy
|
|
177
|
+
img = Image.new("RGB", (64, self.target_height), (255, 255, 255))
|
|
178
|
+
arr = np.array(img, dtype=np.float32).transpose(2, 0, 1) / 255.0
|
|
179
|
+
return torch.from_numpy(arr), " "
|
|
180
|
+
|
|
181
|
+
img_n, txt_n = self.pairs[idx]
|
|
182
|
+
z = self._get_zip()
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
raw_img = z.read(img_n)
|
|
186
|
+
img = Image.open(io.BytesIO(raw_img)).convert("RGB")
|
|
187
|
+
w, h = img.size
|
|
188
|
+
new_w = max(16, int((self.target_height / max(1, h)) * w))
|
|
189
|
+
img = img.resize((new_w, self.target_height), Image.BILINEAR)
|
|
190
|
+
|
|
191
|
+
raw_txt = z.read(txt_n).decode("utf-8", errors="ignore").strip().strip('"\'')
|
|
192
|
+
text = raw_txt if raw_txt else " "
|
|
193
|
+
except Exception:
|
|
194
|
+
img = Image.new("RGB", (64, self.target_height), (255, 255, 255))
|
|
195
|
+
text = " "
|
|
196
|
+
|
|
197
|
+
arr = np.array(img, dtype=np.float32).transpose(2, 0, 1) / 255.0
|
|
198
|
+
return torch.from_numpy(arr), text
|
|
199
|
+
|
|
200
|
+
class AsterMultiSourceDataset(Dataset):
|
|
201
|
+
"""
|
|
202
|
+
Multi-Source Blended Dataset:
|
|
203
|
+
Dynamically blends Real Scans/Photos (from zip) + Synthetic Vietnamese text
|
|
204
|
+
according to a configurable ratio (e.g. 50% Real / 50% Synthetic).
|
|
205
|
+
"""
|
|
206
|
+
def __init__(self, synthetic_ds, real_ds=None, real_ratio=0.5, total_size=10000):
|
|
207
|
+
self.synthetic_ds = synthetic_ds
|
|
208
|
+
self.real_ds = real_ds
|
|
209
|
+
self.real_ratio = real_ratio if (real_ds and len(real_ds) > 0) else 0.0
|
|
210
|
+
self.total_size = total_size
|
|
211
|
+
|
|
212
|
+
def __len__(self):
|
|
213
|
+
return self.total_size
|
|
214
|
+
|
|
215
|
+
def __getitem__(self, idx):
|
|
216
|
+
# Decide whether to sample from real dataset or synthetic generator
|
|
217
|
+
if random.random() < self.real_ratio and self.real_ds:
|
|
218
|
+
real_idx = random.randint(0, len(self.real_ds) - 1)
|
|
219
|
+
return self.real_ds[real_idx]
|
|
220
|
+
else:
|
|
221
|
+
synth_idx = random.randint(0, len(self.synthetic_ds) - 1)
|
|
222
|
+
return self.synthetic_ds[synth_idx]
|
|
223
|
+
|
|
224
|
+
class AsterOmniDataset(Dataset):
|
|
225
|
+
"""
|
|
226
|
+
Omni-Source Blended Dataset:
|
|
227
|
+
Hop nhat toan bo moi nguon du lieu (All-In-One):
|
|
228
|
+
- Kho Hop Nhat LMDB (Viet tay VNOnDB, Vo o ly, Hoa don in kim)
|
|
229
|
+
- Kho Anh Quet/Chup Thuc Te (601,282 mau trong zip)
|
|
230
|
+
- Kho Mega Trieu Mau LMDB (1,000,000 mau sieu toc)
|
|
231
|
+
- Trinh Tu Sinh Dong Python On-The-Fly (322 Font Windows, Giay cu, Muc nhoe)
|
|
232
|
+
Can bang ty trong mau va boc ngau nhien truc tiep cho tung batch.
|
|
233
|
+
"""
|
|
234
|
+
def __init__(self, sources_dict, weights=None, total_size=50000):
|
|
235
|
+
self.sources = []
|
|
236
|
+
self.names = []
|
|
237
|
+
for name, ds in sources_dict.items():
|
|
238
|
+
if ds is not None and len(ds) > 0:
|
|
239
|
+
self.sources.append(ds)
|
|
240
|
+
self.names.append(name)
|
|
241
|
+
|
|
242
|
+
if not self.sources:
|
|
243
|
+
raise ValueError("Khong tim thay nguon du lieu nao kha dung cho Omni Dataset!")
|
|
244
|
+
|
|
245
|
+
self.total_size = total_size
|
|
246
|
+
n = len(self.sources)
|
|
247
|
+
if weights is None or len(weights) != n:
|
|
248
|
+
self.weights = [1.0 / n] * n
|
|
249
|
+
else:
|
|
250
|
+
s = sum(weights)
|
|
251
|
+
self.weights = [w / s for w in weights]
|
|
252
|
+
|
|
253
|
+
def __len__(self):
|
|
254
|
+
return self.total_size
|
|
255
|
+
|
|
256
|
+
def __getitem__(self, idx):
|
|
257
|
+
chosen_ds = random.choices(self.sources, weights=self.weights, k=1)[0]
|
|
258
|
+
rand_idx = random.randint(0, len(chosen_ds) - 1)
|
|
259
|
+
return chosen_ds[rand_idx]
|
|
260
|
+
|
|
261
|
+
class AsterFileDataset(Dataset):
|
|
262
|
+
"""Loads dataset from image directory and annotation file (path\\tlabel)."""
|
|
263
|
+
def __init__(self, annotation_file, data_root=None, target_height=32):
|
|
264
|
+
self.samples = []
|
|
265
|
+
self.target_height = target_height
|
|
266
|
+
data_root = data_root or os.path.dirname(annotation_file)
|
|
267
|
+
|
|
268
|
+
with open(annotation_file, "r", encoding="utf-8") as f:
|
|
269
|
+
for line in f:
|
|
270
|
+
line = line.strip()
|
|
271
|
+
if not line:
|
|
272
|
+
continue
|
|
273
|
+
parts = line.split("\t")
|
|
274
|
+
if len(parts) >= 2:
|
|
275
|
+
rel_p, text = parts[0], parts[1]
|
|
276
|
+
else:
|
|
277
|
+
parts = line.split(" ", 1)
|
|
278
|
+
rel_p = parts[0]
|
|
279
|
+
text = parts[1] if len(parts) > 1 else ""
|
|
280
|
+
|
|
281
|
+
full_p = os.path.join(data_root, rel_p) if not os.path.isabs(rel_p) else rel_p
|
|
282
|
+
if os.path.exists(full_p):
|
|
283
|
+
self.samples.append((full_p, text))
|
|
284
|
+
|
|
285
|
+
def __len__(self):
|
|
286
|
+
return len(self.samples)
|
|
287
|
+
|
|
288
|
+
def __getitem__(self, idx):
|
|
289
|
+
img_path, text = self.samples[idx]
|
|
290
|
+
try:
|
|
291
|
+
img = Image.open(img_path).convert("RGB")
|
|
292
|
+
w, h = img.size
|
|
293
|
+
new_w = max(16, int((self.target_height / max(1, h)) * w))
|
|
294
|
+
img = img.resize((new_w, self.target_height), Image.BILINEAR)
|
|
295
|
+
except Exception:
|
|
296
|
+
img = Image.new("RGB", (32, self.target_height), (255, 255, 255))
|
|
297
|
+
text = " "
|
|
298
|
+
|
|
299
|
+
arr = np.array(img, dtype=np.float32).transpose(2, 0, 1) / 255.0
|
|
300
|
+
return torch.from_numpy(arr), text
|
|
301
|
+
|
|
302
|
+
def ctc_collate_fn(batch, vocab=None):
|
|
303
|
+
if vocab is None:
|
|
304
|
+
vocab = default_vocab
|
|
305
|
+
|
|
306
|
+
tensors, texts = zip(*batch)
|
|
307
|
+
batch_size = len(tensors)
|
|
308
|
+
|
|
309
|
+
encoded_targets = []
|
|
310
|
+
target_lengths = []
|
|
311
|
+
|
|
312
|
+
for text in texts:
|
|
313
|
+
tokens = vocab.encode(text)
|
|
314
|
+
if not tokens:
|
|
315
|
+
tokens = [vocab.encode(" ")[0]]
|
|
316
|
+
encoded_targets.extend(tokens)
|
|
317
|
+
target_lengths.append(len(tokens))
|
|
318
|
+
|
|
319
|
+
targets = torch.tensor(encoded_targets, dtype=torch.long)
|
|
320
|
+
target_lengths = torch.tensor(target_lengths, dtype=torch.long)
|
|
321
|
+
max_target_len = int(target_lengths.max().item()) if len(target_lengths) > 0 else 1
|
|
322
|
+
|
|
323
|
+
max_w = min(640, max([t.shape[2] for t in tensors]))
|
|
324
|
+
max_w = max(max_w, (max_target_len * 4) + 16)
|
|
325
|
+
max_w = max(32, ((max_w + 3) // 4) * 4)
|
|
326
|
+
|
|
327
|
+
padded_imgs = torch.ones((batch_size, 3, 32, max_w), dtype=torch.float32)
|
|
328
|
+
input_lengths = []
|
|
329
|
+
|
|
330
|
+
for i, t in enumerate(tensors):
|
|
331
|
+
w = min(t.shape[2], max_w)
|
|
332
|
+
padded_imgs[i, :, :, :w] = t[:, :, :w]
|
|
333
|
+
feat_len = max(1, w // 4)
|
|
334
|
+
t_len = int(target_lengths[i].item())
|
|
335
|
+
feat_len = max(feat_len, t_len)
|
|
336
|
+
feat_len = min(feat_len, max_w // 4)
|
|
337
|
+
input_lengths.append(feat_len)
|
|
338
|
+
|
|
339
|
+
input_lengths = torch.tensor(input_lengths, dtype=torch.long)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
"images": padded_imgs,
|
|
343
|
+
"targets": targets,
|
|
344
|
+
"input_lengths": input_lengths,
|
|
345
|
+
"target_lengths": target_lengths,
|
|
346
|
+
"texts": list(texts)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
def create_dataloader(dataset, batch_size=32, shuffle=True, num_workers=0, pin_memory=False):
|
|
350
|
+
return DataLoader(
|
|
351
|
+
dataset,
|
|
352
|
+
batch_size=batch_size,
|
|
353
|
+
shuffle=shuffle,
|
|
354
|
+
num_workers=num_workers,
|
|
355
|
+
collate_fn=ctc_collate_fn,
|
|
356
|
+
pin_memory=pin_memory
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if __name__ == "__main__":
|
|
360
|
+
# Test Multi-Source Mixed Dataset
|
|
361
|
+
synth_ds = AsterSyntheticDataset(size=50)
|
|
362
|
+
zip_p = "dataset_download/ocr_dataset.zip"
|
|
363
|
+
real_ds = AsterZipDataset(zip_p) if os.path.exists(zip_p) else None
|
|
364
|
+
mixed_ds = AsterMultiSourceDataset(synth_ds, real_ds, real_ratio=0.5, total_size=20)
|
|
365
|
+
loader = create_dataloader(mixed_ds, batch_size=4)
|
|
366
|
+
|
|
367
|
+
for batch in loader:
|
|
368
|
+
print("Mixed batch images:", batch["images"].shape)
|
|
369
|
+
print("Mixed batch texts:", batch["texts"])
|
|
370
|
+
break
|
|
371
|
+
print("[+] Multi-source dataset test passed!")
|