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 ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ AsterOCR - Modern High-Performance Vietnamese OCR Engine
3
+ Designed to replace VietOCR with non-autoregressive CTC sequence modeling,
4
+ dynamic synthetic data generation, and sub-second recognition.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+
9
+ from asterocr.pipeline import AsterOCR
10
+ from asterocr.vocab import default_vocab
11
+
12
+ __all__ = ["AsterOCR", "default_vocab", "__version__"]
asterocr/cli.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ Command Line Interface for AsterOCR
3
+ Usage:
4
+ asterocr path/to/image.jpg
5
+ asterocr path/to/image.jpg --backend onnx
6
+ """
7
+
8
+ import sys
9
+ import argparse
10
+ import os
11
+
12
+ def main():
13
+ parser = argparse.ArgumentParser(
14
+ prog="asterocr",
15
+ description="AsterOCR: Ultra-fast Vietnamese Text Recognition CLI"
16
+ )
17
+ parser.add_argument("image", help="Path to input image file (jpg, png, webp)")
18
+ parser.add_argument("--backend", default="onnx", choices=["onnx", "torch"], help="Inference backend (default: onnx)")
19
+ parser.add_argument("--device", default="cpu", help="Computation device (cpu or cuda)")
20
+ parser.add_argument("--model-path", default=None, help="Explicit path to asternet.onnx or asternet.pth")
21
+ parser.add_argument("--version", action="version", version="AsterOCR v1.0.0")
22
+
23
+ args = parser.parse_args()
24
+
25
+ if not os.path.exists(args.image):
26
+ print(f"[-] Lỗi: File ảnh không tồn tại: {args.image}", file=sys.stderr)
27
+ sys.exit(1)
28
+
29
+ from asterocr.pipeline import AsterOCR
30
+ ocr = AsterOCR(model_path=args.model_path, backend=args.backend, device=args.device)
31
+ result = ocr.predict(args.image)
32
+ print(result)
33
+
34
+ if __name__ == "__main__":
35
+ main()
asterocr/comparator.py ADDED
@@ -0,0 +1,412 @@
1
+ """
2
+ AsterOCR vs VietOCR Comparison Engine
3
+ Provides simultaneous side-by-side inference, precision comparison,
4
+ latency benchmark, and full page document parsing.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import time
10
+ import io
11
+ import json
12
+ import base64
13
+ import torch
14
+ import cv2
15
+ import numpy as np
16
+ from PIL import Image
17
+
18
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
19
+
20
+ from asterocr.inference.predictor import AsterPredictor
21
+ from asterocr.trainer.metrics import calculate_cer, calculate_wer, levenshtein_distance
22
+ from vietocr.tool.config import Cfg
23
+ from vietocr.tool.predictor import Predictor
24
+ from rapidocr_onnxruntime import RapidOCR
25
+
26
+ class OCRComparator:
27
+ _instance = None
28
+
29
+ @classmethod
30
+ def get_instance(cls):
31
+ if cls._instance is None:
32
+ cls._instance = OCRComparator()
33
+ return cls._instance
34
+
35
+ def __init__(self):
36
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
37
+ print(f"[*] OCRComparator khởi tạo trên thiết bị: {self.device}")
38
+
39
+ # 1. AsterNet SOTA Predictor
40
+ model_path = os.path.join(os.path.dirname(__file__), "..", "models", "asternet_best.pth")
41
+ if not os.path.exists(model_path):
42
+ model_path = os.path.join("models", "asternet_best.pth")
43
+ self.model_path = os.path.abspath(model_path)
44
+ self.model_mtime = os.path.getmtime(self.model_path) if os.path.exists(self.model_path) else 0
45
+ self.aster_pred = AsterPredictor(model_path=self.model_path, device=self.device)
46
+ print(f"[+] AsterNet nạp thành công từ: {self.model_path}")
47
+
48
+ # 2. VietOCR Predictor
49
+ config = Cfg.load_config_from_name('vgg_transformer')
50
+ config['device'] = self.device
51
+ config['predictor']['beamsearch'] = False
52
+ self.viet_pred = Predictor(config)
53
+ print("[+] VietOCR (vgg_transformer) nạp thành công!")
54
+
55
+ # 3. Detector cho Full-page document
56
+ try:
57
+ self.detector = RapidOCR(use_cls=False, use_rec=False)
58
+ print("[+] RapidOCR text line detector sẵn sàng!")
59
+ except Exception as e:
60
+ print(f"[!] Cảnh báo khởi tạo RapidOCR: {e}")
61
+ self.detector = None
62
+
63
+ # Benchmark running state
64
+ self.benchmark_state = {
65
+ "status": "idle",
66
+ "progress": 0,
67
+ "total": 0,
68
+ "current": 0,
69
+ "dataset_name": "",
70
+ "metrics": {},
71
+ "results": [],
72
+ "error": None
73
+ }
74
+
75
+ def check_and_reload(self):
76
+ """Hot-reloads AsterNet if weights file has been updated by training."""
77
+ if os.path.exists(self.model_path):
78
+ cur_mtime = os.path.getmtime(self.model_path)
79
+ if cur_mtime > self.model_mtime:
80
+ try:
81
+ print(f"[*] Phát hiện weights mới ({time.ctime(cur_mtime)}), đang tự động nạp lại AsterNet...")
82
+ from asterocr.inference.predictor import AsterPredictor
83
+ self.aster_pred = AsterPredictor(model_path=self.model_path, device=self.device)
84
+ self.model_mtime = cur_mtime
85
+ print("[+] Tự động nạp lại AsterNet thành công!")
86
+ except Exception as e:
87
+ print(f"[!] Lỗi khi nạp lại AsterNet: {e}")
88
+
89
+ def compare_crop(self, pil_img, gt_text=None):
90
+ """
91
+ Runs both models on a single line crop and returns side-by-side metrics.
92
+ """
93
+ self.check_and_reload()
94
+ # Warmup/ensure RGB
95
+ if pil_img.mode != "RGB":
96
+ pil_img = pil_img.convert("RGB")
97
+
98
+ # AsterNet inference
99
+ t0 = time.perf_counter()
100
+ aster_text = self.aster_pred.predict(pil_img)
101
+ aster_lat = (time.perf_counter() - t0) * 1000
102
+
103
+ # VietOCR inference
104
+ t0 = time.perf_counter()
105
+ viet_text = self.viet_pred.predict(pil_img)
106
+ viet_lat = (time.perf_counter() - t0) * 1000
107
+
108
+ speedup = viet_lat / max(0.001, aster_lat)
109
+
110
+ res = {
111
+ "aster": {
112
+ "text": aster_text,
113
+ "latency_ms": round(aster_lat, 2),
114
+ "fps": round(1000.0 / max(1.0, aster_lat), 1)
115
+ },
116
+ "vietocr": {
117
+ "text": viet_text,
118
+ "latency_ms": round(viet_lat, 2),
119
+ "fps": round(1000.0 / max(1.0, viet_lat), 1)
120
+ },
121
+ "speedup": round(speedup, 2)
122
+ }
123
+
124
+ if gt_text is not None and len(gt_text.strip()) > 0:
125
+ gt = gt_text.strip()
126
+ aster_cer = calculate_cer([gt], [aster_text]) * 100
127
+ aster_wer = calculate_wer([gt], [aster_text]) * 100
128
+ viet_cer = calculate_cer([gt], [viet_text]) * 100
129
+ viet_wer = calculate_wer([gt], [viet_text]) * 100
130
+
131
+ res["gt"] = gt
132
+ res["aster"]["cer"] = round(aster_cer, 2)
133
+ res["aster"]["wer"] = round(aster_wer, 2)
134
+ res["aster"]["match"] = (aster_text.strip() == gt)
135
+
136
+ res["vietocr"]["cer"] = round(viet_cer, 2)
137
+ res["vietocr"]["wer"] = round(viet_wer, 2)
138
+ res["vietocr"]["match"] = (viet_text.strip() == gt)
139
+
140
+ if aster_cer < viet_cer:
141
+ winner = "asternet"
142
+ elif viet_cer < aster_cer:
143
+ winner = "vietocr"
144
+ else:
145
+ winner = "draw"
146
+ res["accuracy_winner"] = winner
147
+
148
+ return res
149
+
150
+ def compare_document_page(self, np_img_bgr):
151
+ """
152
+ Detects all text lines on a document page and recognizes with both models.
153
+ Returns visual annotated image base64 and line-by-line comparison data.
154
+ """
155
+ self.check_and_reload()
156
+ if self.detector is None:
157
+ return {"error": "RapidOCR detector không khả dụng."}
158
+
159
+ boxes, _ = self.detector(np_img_bgr)
160
+ if not boxes:
161
+ return {"lines": [], "annotated_image": None, "total_lines": 0}
162
+
163
+ h_img, w_img = np_img_bgr.shape[:2]
164
+ lines_info = []
165
+ pil_crops = []
166
+ box_coords = []
167
+
168
+ # Sort boxes top to bottom
169
+ sorted_boxes = sorted(boxes, key=lambda b: np.min(np.array(b[0] if isinstance(b[0], list) and len(b) == 2 else b)[:, 1]))
170
+
171
+ for idx, b in enumerate(sorted_boxes):
172
+ pts = np.array(b[0] if isinstance(b[0], list) and len(b) == 2 else b)
173
+ x_min, y_min = np.min(pts, axis=0)
174
+ x_max, y_max = np.max(pts, axis=0)
175
+
176
+ pad = 2
177
+ x1 = max(0, int(x_min) - pad)
178
+ y1 = max(0, int(y_min) - pad)
179
+ x2 = min(w_img, int(x_max) + pad)
180
+ y2 = min(h_img, int(y_max) + pad)
181
+
182
+ if y2 - y1 < 6 or x2 - x1 < 6:
183
+ continue
184
+
185
+ crop = np_img_bgr[y1:y2, x1:x2]
186
+ if crop.size == 0:
187
+ continue
188
+
189
+ crop_pil = Image.fromarray(cv2.cvtColor(crop, cv2.COLOR_BGR2RGB))
190
+ pil_crops.append(crop_pil)
191
+ box_coords.append((x1, y1, x2, y2))
192
+
193
+ # Annotate image
194
+ annotated = np_img_bgr.copy()
195
+
196
+ # Batch inference
197
+ t0 = time.perf_counter()
198
+ aster_preds = [self.aster_pred.predict(c) for c in pil_crops]
199
+ aster_total_time = (time.perf_counter() - t0) * 1000
200
+
201
+ t0 = time.perf_counter()
202
+ viet_preds = [self.viet_pred.predict(c) for c in pil_crops]
203
+ viet_total_time = (time.perf_counter() - t0) * 1000
204
+
205
+ for i, (coords, ap, vp) in enumerate(zip(box_coords, aster_preds, viet_preds)):
206
+ x1, y1, x2, y2 = coords
207
+ # Draw bbox: Cyan for line
208
+ cv2.rectangle(annotated, (x1, y1), (x2, y2), (0, 255, 255), 1)
209
+ cv2.putText(annotated, str(i + 1), (x1, max(12, y1 - 2)), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1)
210
+
211
+ # Crop thumbnail base64
212
+ crop_rgb = pil_crops[i]
213
+ buf = io.BytesIO()
214
+ crop_rgb.save(buf, format="JPEG", quality=85)
215
+ crop_b64 = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
216
+
217
+ lines_info.append({
218
+ "line_id": i + 1,
219
+ "bbox": [x1, y1, x2, y2],
220
+ "crop_thumbnail": crop_b64,
221
+ "aster_text": ap,
222
+ "viet_text": vp,
223
+ "is_identical": (ap.strip() == vp.strip())
224
+ })
225
+
226
+ # Encode annotated image
227
+ _, enc_jpg = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 80])
228
+ annotated_b64 = "data:image/jpeg;base64," + base64.b64encode(enc_jpg).decode()
229
+
230
+ return {
231
+ "total_lines": len(lines_info),
232
+ "aster_total_ms": round(aster_total_time, 2),
233
+ "viet_total_ms": round(viet_total_time, 2),
234
+ "speedup": round(viet_total_time / max(0.001, aster_total_time), 2),
235
+ "lines": lines_info,
236
+ "annotated_image": annotated_b64
237
+ }
238
+
239
+ def start_batch_benchmark(self, dataset_name, max_samples=100):
240
+ """
241
+ Runs benchmark in a background thread and updates self.benchmark_state.
242
+ """
243
+ import threading
244
+ if self.benchmark_state["status"] == "running":
245
+ return False, "Benchmark đang chạy. Hãy đợi hoặc dừng phiên hiện tại."
246
+
247
+ self.benchmark_state = {
248
+ "status": "running",
249
+ "progress": 0,
250
+ "total": 0,
251
+ "current": 0,
252
+ "dataset_name": dataset_name,
253
+ "metrics": {},
254
+ "results": [],
255
+ "error": None
256
+ }
257
+
258
+ t = threading.Thread(target=self._run_benchmark_worker, args=(dataset_name, max_samples), daemon=True)
259
+ t.start()
260
+ return True, "Benchmark đã bắt đầu."
261
+
262
+ def _run_benchmark_worker(self, dataset_name, max_samples):
263
+ try:
264
+ # Hot-reload linguistics and weights before running benchmark
265
+ try:
266
+ import importlib
267
+ import asterocr.inference.viet_linguistics as vl
268
+ importlib.reload(vl)
269
+ self.check_and_reload()
270
+ except Exception as e:
271
+ print(f"[!] Warning reloading modules: {e}")
272
+
273
+ samples = []
274
+ if dataset_name == "mega_test":
275
+ labels_file = os.path.join("dataset_mega_test", "labels.json")
276
+ if os.path.exists(labels_file):
277
+ with open(labels_file, "r", encoding="utf-8") as f:
278
+ meta = json.load(f)
279
+ for item in meta:
280
+ img_p = os.path.join("dataset_mega_test", item["image_path"])
281
+ if os.path.exists(img_p):
282
+ img = Image.open(img_p).convert("RGB")
283
+ samples.append((img, item["text"], os.path.basename(img_p)))
284
+ elif dataset_name in ["real_scans", "unified", "viet_real"]:
285
+ if dataset_name == "viet_real":
286
+ lmdb_path = "dataset_viet_real.lmdb"
287
+ elif dataset_name == "unified":
288
+ lmdb_path = "dataset_unified.lmdb"
289
+ else:
290
+ lmdb_path = "dataset_real_scans.lmdb"
291
+ if os.path.exists(lmdb_path):
292
+ import lmdb
293
+ env = lmdb.open(lmdb_path, readonly=True, max_readers=1)
294
+ with env.begin() as txn:
295
+ num_val = txn.get(b'num-samples')
296
+ total_db = int(num_val.decode('utf-8')) if num_val else 0
297
+ step = max(1, total_db // max(1, max_samples))
298
+ for i in range(1, total_db, step):
299
+ if len(samples) >= max_samples:
300
+ break
301
+ img_key = f"image-{i:09d}".encode()
302
+ lbl_key = f"label-{i:09d}".encode()
303
+ img_b = txn.get(img_key)
304
+ lbl_b = txn.get(lbl_key)
305
+ if img_b and lbl_b:
306
+ try:
307
+ img = Image.open(io.BytesIO(img_b)).convert("RGB")
308
+ txt = lbl_b.decode('utf-8').strip()
309
+ if txt:
310
+ samples.append((img, txt, f"sample_{i:07d}.jpg"))
311
+ except Exception:
312
+ pass
313
+ env.close()
314
+
315
+ if not samples:
316
+ self.benchmark_state["status"] = "error"
317
+ self.benchmark_state["error"] = f"Không tìm thấy dữ liệu cho '{dataset_name}'."
318
+ return
319
+
320
+ self.benchmark_state["total"] = len(samples)
321
+ results = []
322
+ gt_all = []
323
+ aster_all = []
324
+ viet_all = []
325
+
326
+ t_aster_total = 0.0
327
+ t_viet_total = 0.0
328
+
329
+ for idx, (img, gt, name) in enumerate(samples):
330
+ # AsterNet
331
+ t0 = time.perf_counter()
332
+ ap = self.aster_pred.predict(img)
333
+ t_a = (time.perf_counter() - t0) * 1000
334
+ t_aster_total += t_a
335
+
336
+ # VietOCR
337
+ t0 = time.perf_counter()
338
+ vp = self.viet_pred.predict(img)
339
+ t_v = (time.perf_counter() - t0) * 1000
340
+ t_viet_total += t_v
341
+
342
+ gt_all.append(gt)
343
+ aster_all.append(ap)
344
+ viet_all.append(vp)
345
+
346
+ # Thumbnail
347
+ buf = io.BytesIO()
348
+ img.save(buf, format="JPEG", quality=80)
349
+ img_b64 = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
350
+
351
+ # Per-sample metrics
352
+ a_match = (ap.strip() == gt.strip())
353
+ v_match = (vp.strip() == gt.strip())
354
+
355
+ results.append({
356
+ "id": idx + 1,
357
+ "name": name,
358
+ "thumbnail": img_b64,
359
+ "gt": gt,
360
+ "aster_pred": ap,
361
+ "viet_pred": vp,
362
+ "aster_match": a_match,
363
+ "viet_match": v_match,
364
+ "aster_ms": round(t_a, 1),
365
+ "viet_ms": round(t_v, 1),
366
+ "aster_cer": round(calculate_cer([gt], [ap]) * 100, 1),
367
+ "viet_cer": round(calculate_cer([gt], [vp]) * 100, 1)
368
+ })
369
+
370
+ self.benchmark_state["current"] = idx + 1
371
+ self.benchmark_state["progress"] = round((idx + 1) / len(samples) * 100, 1)
372
+
373
+ # Final metrics
374
+ N = len(samples)
375
+ aster_cer = round(calculate_cer(gt_all, aster_all) * 100, 2)
376
+ aster_wer = round(calculate_wer(gt_all, aster_all) * 100, 2)
377
+ aster_exact = round(sum(1 for g, p in zip(gt_all, aster_all) if g.strip() == p.strip()) / N * 100, 2)
378
+ aster_fps = round(N / (t_aster_total / 1000.0), 1)
379
+ aster_avg_ms = round(t_aster_total / N, 2)
380
+
381
+ viet_cer = round(calculate_cer(gt_all, viet_all) * 100, 2)
382
+ viet_wer = round(calculate_wer(gt_all, viet_all) * 100, 2)
383
+ viet_exact = round(sum(1 for g, p in zip(gt_all, viet_all) if g.strip() == p.strip()) / N * 100, 2)
384
+ viet_fps = round(N / (t_viet_total / 1000.0), 1)
385
+ viet_avg_ms = round(t_viet_total / N, 2)
386
+
387
+ speedup = round(aster_fps / max(0.01, viet_fps), 2)
388
+
389
+ self.benchmark_state["metrics"] = {
390
+ "aster": {
391
+ "cer": aster_cer,
392
+ "wer": aster_wer,
393
+ "exact": aster_exact,
394
+ "fps": aster_fps,
395
+ "avg_ms": aster_avg_ms
396
+ },
397
+ "vietocr": {
398
+ "cer": viet_cer,
399
+ "wer": viet_wer,
400
+ "exact": viet_exact,
401
+ "fps": viet_fps,
402
+ "avg_ms": viet_avg_ms
403
+ },
404
+ "speedup": speedup,
405
+ "winner": "AsterNet" if aster_cer <= viet_cer else "VietOCR"
406
+ }
407
+ self.benchmark_state["results"] = results
408
+ self.benchmark_state["status"] = "finished"
409
+
410
+ except Exception as e:
411
+ self.benchmark_state["status"] = "error"
412
+ self.benchmark_state["error"] = str(e)
@@ -0,0 +1,117 @@
1
+ """
2
+ AsterOCR Fast Real Dataset Exporter
3
+ Extracts unexported real document scan samples (en_00, en_01, random) from ocr_dataset.zip
4
+ and packages them into dataset_real_scans.lmdb for seamless streaming to Dual GPUs.
5
+ """
6
+
7
+ import os
8
+ import io
9
+ import sys
10
+ import json
11
+ import time
12
+ import zipfile
13
+ import lmdb
14
+ from PIL import Image
15
+
16
+ if sys.platform == "win32":
17
+ try:
18
+ sys.stdout.reconfigure(encoding='utf-8')
19
+ sys.stderr.reconfigure(encoding='utf-8')
20
+ except Exception:
21
+ pass
22
+
23
+ def export_real_zip_to_lmdb(
24
+ zip_path="dataset_download/ocr_dataset.zip",
25
+ index_path="dataset_download/ocr_dataset_index.json",
26
+ output_dir="dataset_real_scans.lmdb",
27
+ target_height=32,
28
+ categories=("en_00", "en_01", "random"),
29
+ batch_commit=5000
30
+ ):
31
+ if not os.path.exists(zip_path):
32
+ print(f"[!] Không tìm thấy tệp zip: {zip_path}")
33
+ return False
34
+ if not os.path.exists(index_path):
35
+ print(f"[!] Không tìm thấy tệp index: {index_path}")
36
+ return False
37
+
38
+ print(f"[*] Đang tải danh mục từ {index_path}...")
39
+ with open(index_path, "r", encoding="utf-8") as f:
40
+ all_entries = json.load(f)
41
+
42
+ # Filter targets
43
+ target_entries = [
44
+ item for item in all_entries
45
+ if any(item[0].startswith(cat + "/") for cat in categories)
46
+ ]
47
+ total_target = len(target_entries)
48
+ print(f"[+] Tìm thấy {total_target:,} mẫu ảnh thật thuộc các nhóm: {categories}")
49
+
50
+ os.makedirs(output_dir, exist_ok=True)
51
+ # 20GB map size ceiling
52
+ env = lmdb.open(output_dir, map_size=25 * 1024 * 1024 * 1024, sync=False)
53
+
54
+ t0 = time.time()
55
+ written_count = 0
56
+ txn = env.begin(write=True)
57
+
58
+ with zipfile.ZipFile(zip_path, "r") as zf:
59
+ for idx, (img_rel, txt_rel) in enumerate(target_entries, 1):
60
+ try:
61
+ img_data = zf.read(img_rel)
62
+ txt_data = zf.read(txt_rel).decode("utf-8", errors="ignore").strip()
63
+ if not txt_data:
64
+ continue
65
+
66
+ # Resize to target height 32
67
+ im = Image.open(io.BytesIO(img_data)).convert("RGB")
68
+ w, h = im.size
69
+ if w < 4 or h < 4:
70
+ continue
71
+ new_w = max(16, int((float(target_height) / max(1, h)) * w))
72
+ im_res = im.resize((new_w, target_height), Image.BILINEAR)
73
+
74
+ buf = io.BytesIO()
75
+ im_res.save(buf, format="JPEG", quality=90)
76
+ jpeg_bytes = buf.getvalue()
77
+
78
+ written_count += 1
79
+ img_key = f"image-{written_count:09d}".encode("utf-8")
80
+ lbl_key = f"label-{written_count:09d}".encode("utf-8")
81
+
82
+ txn.put(img_key, jpeg_bytes)
83
+ txn.put(lbl_key, txt_data.encode("utf-8"))
84
+
85
+ if written_count % batch_commit == 0:
86
+ txn.commit()
87
+ txn = env.begin(write=True)
88
+ elapsed = time.time() - t0
89
+ sps = written_count / max(0.1, elapsed)
90
+ print(f"--> Đã xuất: {written_count:,}/{total_target:,} mẫu ({written_count/total_target*100:.1f}%) | {sps:.0f} mẫu/s")
91
+
92
+ except Exception as e:
93
+ continue
94
+
95
+ # Finalize
96
+ txn.put(b"num-samples", str(written_count).encode("utf-8"))
97
+ txn.commit()
98
+ env.sync()
99
+ env.close()
100
+
101
+ total_time = time.time() - t0
102
+ info = {
103
+ "format": "lmdb",
104
+ "num_samples": written_count,
105
+ "categories": list(categories),
106
+ "source": zip_path,
107
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
108
+ "total_seconds": round(total_time, 1)
109
+ }
110
+ with open(os.path.join(output_dir, "dataset_info.json"), "w", encoding="utf-8") as f:
111
+ json.dump(info, f, indent=2, ensure_ascii=False)
112
+
113
+ print(f"\n[+] HOÀN TẤT ĐÓNG GÓI {written_count:,} MẪU VÀO {output_dir} TRONG {total_time:.1f} GIÂY!")
114
+ return True
115
+
116
+ if __name__ == "__main__":
117
+ export_real_zip_to_lmdb()