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,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]