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
asterocr/models/model.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AsterNet - End-to-End Deep Neural Network for Vietnamese Text Recognition
|
|
3
|
+
Architecture:
|
|
4
|
+
Input (B, 3, 32, W)
|
|
5
|
+
└──> High-Frequency Asymmetric ResNet-SVTR Backbone --> (T = W/4, B, 512)
|
|
6
|
+
└──> AsterBiLSTMSequence (2-Layer Bidirectional) --> (T, B, 512)
|
|
7
|
+
├──> Main CTC Classifier Head --> Log-Probs (T, B, 227)
|
|
8
|
+
└──> Diacritic Auxiliary Heads (Optional Training):
|
|
9
|
+
├──> Tone Classifier (T, B, 6) [None, Sắc, Huyền, Hỏi, Ngã, Nặng]
|
|
10
|
+
└──> Hat Classifier (T, B, 5) [None, Mũ ^, Móc ư/ơ, Trăng ă, Gạch đ]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
from torch import nn
|
|
15
|
+
from asterocr.models.backbone import AsterResNetBackbone
|
|
16
|
+
from asterocr.models.sequence import AsterBiLSTMSequence
|
|
17
|
+
from asterocr.models.language import AsterLanguageModelHead, GatedLanguageFusion
|
|
18
|
+
from asterocr.vocab import default_vocab
|
|
19
|
+
|
|
20
|
+
class AsterNet(nn.Module):
|
|
21
|
+
def __init__(self, num_classes=None, in_channels=3, feat_dim=512, hidden_dim=256, use_ilm=True):
|
|
22
|
+
super(AsterNet, self).__init__()
|
|
23
|
+
if num_classes is None:
|
|
24
|
+
num_classes = len(default_vocab)
|
|
25
|
+
|
|
26
|
+
self.num_classes = num_classes
|
|
27
|
+
self.use_ilm = use_ilm
|
|
28
|
+
self.backbone = AsterResNetBackbone(in_channels=in_channels, out_dim=feat_dim)
|
|
29
|
+
self.sequence = AsterBiLSTMSequence(in_dim=feat_dim, hidden_dim=hidden_dim)
|
|
30
|
+
seq_dim = hidden_dim * 2 # 512
|
|
31
|
+
|
|
32
|
+
# Implicit Language Modeling Head (ILMH) & Gated Vision-Language Fusion
|
|
33
|
+
if self.use_ilm:
|
|
34
|
+
self.language_head = AsterLanguageModelHead(d_model=seq_dim, nhead=4, num_layers=2)
|
|
35
|
+
self.fusion = GatedLanguageFusion(d_model=seq_dim, init_vision_bias=1.5)
|
|
36
|
+
# Auxiliary vision projection for multi-task loss supervision
|
|
37
|
+
self.vision_classifier = nn.Linear(seq_dim, num_classes)
|
|
38
|
+
|
|
39
|
+
# Main Fused CTC projection head
|
|
40
|
+
self.classifier = nn.Linear(seq_dim, num_classes)
|
|
41
|
+
self.log_softmax = nn.LogSoftmax(dim=2)
|
|
42
|
+
|
|
43
|
+
# Diacritic auxiliary heads for supervision against diacritic loss/swallowing
|
|
44
|
+
self.tone_classifier = nn.Linear(seq_dim, 6) # None, Sắc, Huyền, Hỏi, Ngã, Nặng
|
|
45
|
+
self.hat_classifier = nn.Linear(seq_dim, 5) # None, Mũ ^, Móc ư/ơ, Trăng ă, Gạch đ
|
|
46
|
+
|
|
47
|
+
def forward(self, x, return_aux=False):
|
|
48
|
+
"""
|
|
49
|
+
Forward pass for CTC Loss training or evaluation.
|
|
50
|
+
Args:
|
|
51
|
+
x: Tensor of shape (B, 3, 32, W)
|
|
52
|
+
return_aux: bool, if True returns (log_probs, tone_logits, hat_logits, [vis_log_probs])
|
|
53
|
+
Returns:
|
|
54
|
+
log_probs: Tensor of shape (T, B, num_classes) where T = W / 4
|
|
55
|
+
"""
|
|
56
|
+
feats = self.backbone(x) # (T, B, feat_dim)
|
|
57
|
+
h_vis = self.sequence(feats) # (T, B, seq_dim)
|
|
58
|
+
|
|
59
|
+
if self.use_ilm:
|
|
60
|
+
h_lang = self.language_head(h_vis)
|
|
61
|
+
h_fused, gate = self.fusion(h_vis, h_lang)
|
|
62
|
+
logits = self.classifier(h_fused)
|
|
63
|
+
log_probs = self.log_softmax(logits)
|
|
64
|
+
|
|
65
|
+
if return_aux:
|
|
66
|
+
vis_logits = self.vision_classifier(h_vis)
|
|
67
|
+
vis_log_probs = self.log_softmax(vis_logits)
|
|
68
|
+
tone_logits = self.tone_classifier(h_fused)
|
|
69
|
+
hat_logits = self.hat_classifier(h_fused)
|
|
70
|
+
return log_probs, tone_logits, hat_logits, vis_log_probs
|
|
71
|
+
else:
|
|
72
|
+
logits = self.classifier(h_vis)
|
|
73
|
+
log_probs = self.log_softmax(logits)
|
|
74
|
+
if return_aux:
|
|
75
|
+
tone_logits = self.tone_classifier(h_vis)
|
|
76
|
+
hat_logits = self.hat_classifier(h_vis)
|
|
77
|
+
return log_probs, tone_logits, hat_logits
|
|
78
|
+
|
|
79
|
+
return log_probs
|
|
80
|
+
|
|
81
|
+
def load_pretrained(self, checkpoint_path, device="cpu"):
|
|
82
|
+
"""Gracefully loads checkpoint weights, supporting partial loads without errors."""
|
|
83
|
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
|
84
|
+
state_dict = ckpt.get("state_dict", ckpt.get("model_state_dict", ckpt))
|
|
85
|
+
missing, unexpected = self.load_state_dict(state_dict, strict=False)
|
|
86
|
+
print(f"[+] Loaded weights from {checkpoint_path} (Missing keys: {len(missing)}, Unexpected: {len(unexpected)})")
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
@torch.inference_mode()
|
|
90
|
+
def predict_tokens(self, x):
|
|
91
|
+
"""Returns argmax token indices (T, B)."""
|
|
92
|
+
log_probs = self.forward(x, return_aux=False)
|
|
93
|
+
return torch.argmax(log_probs, dim=2)
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
net = AsterNet(num_classes=227, use_ilm=True)
|
|
97
|
+
x = torch.randn(4, 3, 32, 280)
|
|
98
|
+
out, tone, hat, vis = net(x, return_aux=True)
|
|
99
|
+
print(f"AsterNet input : {x.shape}")
|
|
100
|
+
print(f"AsterNet main output : {out.shape} (T={out.shape[0]}, Batch={out.shape[1]}, Classes={out.shape[2]})")
|
|
101
|
+
print(f"AsterNet tone output : {tone.shape} (T={tone.shape[0]}, Batch={tone.shape[1]}, Tones=6)")
|
|
102
|
+
print(f"AsterNet hat output : {hat.shape} (T={hat.shape[0]}, Batch={hat.shape[1]}, Hats=5)")
|
|
103
|
+
print(f"AsterNet vis output : {vis.shape} (T={vis.shape[0]}, Batch={vis.shape[1]}, Classes={vis.shape[2]})")
|
|
104
|
+
total_params = sum(p.numel() for p in net.parameters() if p.requires_grad)
|
|
105
|
+
print(f"Total Trainable Parameters: {total_params / 1e6:.2f}M")
|
|
106
|
+
assert out.shape == (70, 4, 227)
|
|
107
|
+
|
|
108
|
+
# Test loading previous checkpoint without breaking
|
|
109
|
+
import os
|
|
110
|
+
ckpt_p = "models/asternet_last.pth"
|
|
111
|
+
if os.path.exists(ckpt_p):
|
|
112
|
+
net.load_pretrained(ckpt_p)
|
|
113
|
+
print("[+] Model verification passed!")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AsterOCR Sequence Modeling Layer
|
|
3
|
+
Uses Bidirectional LSTM to capture bidirectional contextual dependencies
|
|
4
|
+
between consecutive Vietnamese characters and diacritical marks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from torch import nn
|
|
9
|
+
|
|
10
|
+
class AsterBiLSTMSequence(nn.Module):
|
|
11
|
+
def __init__(self, in_dim=512, hidden_dim=256, num_layers=2, dropout=0.1):
|
|
12
|
+
super(AsterBiLSTMSequence, self).__init__()
|
|
13
|
+
self.lstm = nn.LSTM(
|
|
14
|
+
input_size=in_dim,
|
|
15
|
+
hidden_size=hidden_dim,
|
|
16
|
+
num_layers=num_layers,
|
|
17
|
+
bidirectional=True,
|
|
18
|
+
dropout=dropout if num_layers > 1 else 0.0,
|
|
19
|
+
batch_first=False
|
|
20
|
+
)
|
|
21
|
+
self.out_dim = hidden_dim * 2
|
|
22
|
+
|
|
23
|
+
def forward(self, x):
|
|
24
|
+
# x shape: (T, B, in_dim)
|
|
25
|
+
recurrent, _ = self.lstm(x)
|
|
26
|
+
# recurrent shape: (T, B, hidden_dim * 2)
|
|
27
|
+
return recurrent
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
seq = AsterBiLSTMSequence(in_dim=512, hidden_dim=256)
|
|
31
|
+
dummy_feat = torch.randn(64, 2, 512) # T=64, B=2, D=512
|
|
32
|
+
out = seq(dummy_feat)
|
|
33
|
+
print("Sequence input shape:", dummy_feat.shape)
|
|
34
|
+
print("Sequence output shape:", out.shape)
|
asterocr/pipeline.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AsterOCR Main High-Level Engine
|
|
3
|
+
Universal Vietnamese OCR Predictor with ONNX Runtime & PyTorch Backends.
|
|
4
|
+
Features automatic Hugging Face model weight acquisition, caching, and CLI support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import numpy as np
|
|
11
|
+
from PIL import Image
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from huggingface_hub import hf_hub_download
|
|
15
|
+
except ImportError:
|
|
16
|
+
hf_hub_download = None
|
|
17
|
+
|
|
18
|
+
from asterocr.vocab import default_vocab
|
|
19
|
+
|
|
20
|
+
DEFAULT_HF_REPO = "asterxlabs/asternet-vietnamese-ocr"
|
|
21
|
+
CACHE_DIR = os.path.expanduser(os.path.join("~", ".cache", "asterocr"))
|
|
22
|
+
|
|
23
|
+
class AsterOCR:
|
|
24
|
+
"""
|
|
25
|
+
Main user-facing OCR recognition engine for AsterOCR.
|
|
26
|
+
Usage:
|
|
27
|
+
>>> from asterocr import AsterOCR
|
|
28
|
+
>>> ocr = AsterOCR()
|
|
29
|
+
>>> text = ocr.predict("invoice.png")
|
|
30
|
+
>>> print(text)
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, model_path=None, backend="onnx", device="cpu", enable_telemetry=True):
|
|
33
|
+
self.backend = backend.lower()
|
|
34
|
+
self.device = device
|
|
35
|
+
self.vocab = default_vocab
|
|
36
|
+
self.enable_telemetry = enable_telemetry
|
|
37
|
+
|
|
38
|
+
self.model_path = self._resolve_model_path(model_path)
|
|
39
|
+
|
|
40
|
+
if self.backend == "onnx":
|
|
41
|
+
self._init_onnx()
|
|
42
|
+
elif self.backend == "torch":
|
|
43
|
+
self._init_torch()
|
|
44
|
+
else:
|
|
45
|
+
raise ValueError(f"Unsupported backend '{backend}'. Choose 'onnx' or 'torch'.")
|
|
46
|
+
|
|
47
|
+
def _resolve_model_path(self, model_path):
|
|
48
|
+
if model_path and os.path.exists(model_path):
|
|
49
|
+
return model_path
|
|
50
|
+
|
|
51
|
+
filename = "asternet.onnx" if self.backend == "onnx" else "asternet_best.pth"
|
|
52
|
+
|
|
53
|
+
# 1. Local workspace candidates
|
|
54
|
+
candidates = [
|
|
55
|
+
os.path.join("models", filename),
|
|
56
|
+
os.path.join("hf_model", filename),
|
|
57
|
+
os.path.join(CACHE_DIR, filename),
|
|
58
|
+
os.path.join(os.path.dirname(__file__), "..", "models", filename),
|
|
59
|
+
]
|
|
60
|
+
for c in candidates:
|
|
61
|
+
if os.path.exists(c):
|
|
62
|
+
return c
|
|
63
|
+
|
|
64
|
+
# 2. Auto-fetch from Hugging Face Hub
|
|
65
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
66
|
+
print(f"[*] Đang tải mô hình AsterOCR ({filename}) từ Hugging Face Hub ({DEFAULT_HF_REPO})...")
|
|
67
|
+
if hf_hub_download is None:
|
|
68
|
+
raise ImportError("Vui lòng cài đặt 'huggingface_hub' để tự động tải model: pip install huggingface_hub")
|
|
69
|
+
|
|
70
|
+
cached_file = hf_hub_download(
|
|
71
|
+
repo_id=DEFAULT_HF_REPO,
|
|
72
|
+
filename=filename,
|
|
73
|
+
cache_dir=CACHE_DIR
|
|
74
|
+
)
|
|
75
|
+
print(f"[+] Đã tải mô hình thành công về: {cached_file}")
|
|
76
|
+
return cached_file
|
|
77
|
+
|
|
78
|
+
def _init_onnx(self):
|
|
79
|
+
try:
|
|
80
|
+
import onnxruntime as ort
|
|
81
|
+
except ImportError:
|
|
82
|
+
raise ImportError("Vui lòng cài đặt 'onnxruntime' để chạy backend ONNX: pip install onnxruntime")
|
|
83
|
+
|
|
84
|
+
providers = ["CPUExecutionProvider"]
|
|
85
|
+
if self.device == "cuda" and "CUDAExecutionProvider" in ort.get_available_providers():
|
|
86
|
+
providers.insert(0, "CUDAExecutionProvider")
|
|
87
|
+
|
|
88
|
+
self.session = ort.InferenceSession(self.model_path, providers=providers)
|
|
89
|
+
self.input_name = self.session.get_inputs()[0].name
|
|
90
|
+
self.output_name = self.session.get_outputs()[0].name
|
|
91
|
+
|
|
92
|
+
def _init_torch(self):
|
|
93
|
+
try:
|
|
94
|
+
import torch
|
|
95
|
+
from asterocr.models.model import AsterNet
|
|
96
|
+
except ImportError:
|
|
97
|
+
raise ImportError("Vui lòng cài đặt 'torch' để chạy backend PyTorch: pip install torch")
|
|
98
|
+
|
|
99
|
+
self.torch_device = torch.device(self.device if torch.cuda.is_available() or self.device == "cpu" else "cpu")
|
|
100
|
+
self.model = AsterNet(num_classes=len(self.vocab)).to(self.torch_device)
|
|
101
|
+
self.model.load_pretrained(self.model_path, device=self.torch_device)
|
|
102
|
+
self.model.eval()
|
|
103
|
+
|
|
104
|
+
def preprocess_image(self, image_input):
|
|
105
|
+
"""Converts input image to normalized numpy tensor of shape (1, 3, 32, W)."""
|
|
106
|
+
if isinstance(image_input, str):
|
|
107
|
+
image = Image.open(image_input)
|
|
108
|
+
elif isinstance(image_input, bytes):
|
|
109
|
+
import io
|
|
110
|
+
image = Image.open(io.BytesIO(image_input))
|
|
111
|
+
elif isinstance(image_input, Image.Image):
|
|
112
|
+
image = image_input
|
|
113
|
+
elif isinstance(image_input, np.ndarray):
|
|
114
|
+
image = Image.fromarray(image_input)
|
|
115
|
+
else:
|
|
116
|
+
raise TypeError(f"Unsupported image input type: {type(image_input)}")
|
|
117
|
+
|
|
118
|
+
image = image.convert("RGB")
|
|
119
|
+
w, h = image.size
|
|
120
|
+
target_h = 32
|
|
121
|
+
target_w = max(32, int(w * target_h / h))
|
|
122
|
+
target_w = (target_w + 3) // 4 * 4 # multiple of 4
|
|
123
|
+
|
|
124
|
+
resized = image.resize((target_w, target_h), Image.Resampling.BILINEAR)
|
|
125
|
+
arr = np.array(resized, dtype=np.float32) / 255.0
|
|
126
|
+
# Standard mean=0.5, std=0.5 normalization
|
|
127
|
+
arr = (arr - 0.5) / 0.5
|
|
128
|
+
# (H, W, C) -> (1, C, H, W)
|
|
129
|
+
tensor = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
|
|
130
|
+
return tensor
|
|
131
|
+
|
|
132
|
+
def decode_ctc(self, log_probs_numpy):
|
|
133
|
+
"""
|
|
134
|
+
Fast Greedy CTC Decoder with Blank removal and run-length collapsing.
|
|
135
|
+
log_probs_numpy: shape (T, 1, C) or (1, T, C)
|
|
136
|
+
"""
|
|
137
|
+
if log_probs_numpy.ndim == 3:
|
|
138
|
+
if log_probs_numpy.shape[1] == 1:
|
|
139
|
+
# Shape (T, 1, C)
|
|
140
|
+
best_indices = np.argmax(log_probs_numpy[:, 0, :], axis=-1)
|
|
141
|
+
else:
|
|
142
|
+
# Shape (1, T, C)
|
|
143
|
+
best_indices = np.argmax(log_probs_numpy[0, :, :], axis=-1)
|
|
144
|
+
else:
|
|
145
|
+
best_indices = np.argmax(log_probs_numpy, axis=-1)
|
|
146
|
+
|
|
147
|
+
blank_idx = self.vocab.blank_idx
|
|
148
|
+
prev_idx = -1
|
|
149
|
+
decoded_chars = []
|
|
150
|
+
for idx in best_indices:
|
|
151
|
+
idx = int(idx)
|
|
152
|
+
if idx != prev_idx:
|
|
153
|
+
if idx != blank_idx:
|
|
154
|
+
decoded_chars.append(self.vocab.idx2char.get(idx, ""))
|
|
155
|
+
prev_idx = idx
|
|
156
|
+
return "".join(decoded_chars)
|
|
157
|
+
|
|
158
|
+
def predict(self, image_input):
|
|
159
|
+
"""
|
|
160
|
+
Runs inference on an image and returns recognized Vietnamese text string.
|
|
161
|
+
"""
|
|
162
|
+
tensor = self.preprocess_image(image_input)
|
|
163
|
+
|
|
164
|
+
if self.backend == "onnx":
|
|
165
|
+
ort_inputs = {self.input_name: tensor}
|
|
166
|
+
ort_output = self.session.run([self.output_name], ort_inputs)[0]
|
|
167
|
+
text = self.decode_ctc(ort_output)
|
|
168
|
+
else:
|
|
169
|
+
import torch
|
|
170
|
+
with torch.no_grad():
|
|
171
|
+
pt_tensor = torch.from_numpy(tensor).to(self.torch_device)
|
|
172
|
+
pt_output = self.model(pt_tensor)
|
|
173
|
+
text = self.decode_ctc(pt_output.cpu().numpy())
|
|
174
|
+
|
|
175
|
+
return text
|
|
176
|
+
|
|
177
|
+
def predict_batch(self, image_list):
|
|
178
|
+
"""Runs sequential or batched inference on a list of images."""
|
|
179
|
+
return [self.predict(img) for img in image_list]
|