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,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AsterOCR High-Performance Synthetic Text Image Generator
|
|
3
|
+
Features:
|
|
4
|
+
- Dynamically discovers and loads all Windows Vietnamese TTF/OTF fonts.
|
|
5
|
+
- Multi-style background generation: aged yellow paper, scan paper, grid/notebook paper, white paper.
|
|
6
|
+
- Real-world document degradations:
|
|
7
|
+
* Ink bleed / erosion / dilation
|
|
8
|
+
* Gaussian & defocus blur
|
|
9
|
+
* Salt-and-pepper / scanner noise
|
|
10
|
+
* Slight perspective / affine shear / rotation (-3 to +3 deg)
|
|
11
|
+
* Contrast & brightness variations
|
|
12
|
+
- Directly outputs normalized (H=32, W) PIL Images for training.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import random
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
if sys.platform == "win32":
|
|
21
|
+
try:
|
|
22
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
23
|
+
sys.stderr.reconfigure(encoding='utf-8')
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
# Ensure parent directory is in sys.path
|
|
28
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
|
29
|
+
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
|
|
30
|
+
import cv2
|
|
31
|
+
|
|
32
|
+
SYSTEM_FONT_DIR = "C:/Windows/Fonts"
|
|
33
|
+
|
|
34
|
+
# Preferred fonts that render Vietnamese characters reliably
|
|
35
|
+
PREFERRED_FONTS = [
|
|
36
|
+
"arial.ttf", "arialbd.ttf", "times.ttf", "timesbd.ttf", "tahoma.ttf",
|
|
37
|
+
"tahomabd.ttf", "calibri.ttf", "calibrib.ttf", "segoeui.ttf", "segoeuib.ttf",
|
|
38
|
+
"verdana.ttf", "verdanab.ttf", "cour.ttf", "courbd.ttf", "georgia.ttf",
|
|
39
|
+
"comic.ttf", "cambria.ttc"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
class AsterTextGenerator:
|
|
43
|
+
def __init__(self, font_dir=SYSTEM_FONT_DIR):
|
|
44
|
+
self.font_paths = []
|
|
45
|
+
if os.path.exists(font_dir):
|
|
46
|
+
for fname in os.listdir(font_dir):
|
|
47
|
+
if fname.lower().endswith(('.ttf', '.otf', '.ttc')):
|
|
48
|
+
full_p = os.path.join(font_dir, fname)
|
|
49
|
+
self.font_paths.append(full_p)
|
|
50
|
+
|
|
51
|
+
# Filter for preferred fonts first
|
|
52
|
+
self.preferred_paths = [
|
|
53
|
+
os.path.join(font_dir, pf) for pf in PREFERRED_FONTS
|
|
54
|
+
if os.path.exists(os.path.join(font_dir, pf))
|
|
55
|
+
]
|
|
56
|
+
if not self.preferred_paths:
|
|
57
|
+
self.preferred_paths = self.font_paths[:10] if self.font_paths else []
|
|
58
|
+
|
|
59
|
+
def get_random_font(self, size=24):
|
|
60
|
+
pool = self.preferred_paths if random.random() < 0.85 and self.preferred_paths else self.font_paths
|
|
61
|
+
if not pool:
|
|
62
|
+
return ImageFont.load_default()
|
|
63
|
+
font_p = random.choice(pool)
|
|
64
|
+
try:
|
|
65
|
+
return ImageFont.truetype(font_p, size)
|
|
66
|
+
except Exception:
|
|
67
|
+
try:
|
|
68
|
+
return ImageFont.truetype("arial.ttf", size)
|
|
69
|
+
except Exception:
|
|
70
|
+
return ImageFont.load_default()
|
|
71
|
+
|
|
72
|
+
def generate_background(self, width, height, style=None):
|
|
73
|
+
if style is None:
|
|
74
|
+
style = random.choices(["clean", "aged", "scan", "grid"], weights=[0.4, 0.3, 0.2, 0.1])[0]
|
|
75
|
+
|
|
76
|
+
if style == "clean":
|
|
77
|
+
# White or near-white
|
|
78
|
+
bg_val = random.randint(240, 255)
|
|
79
|
+
img = Image.new("RGB", (width, height), (bg_val, bg_val, bg_val))
|
|
80
|
+
|
|
81
|
+
elif style == "aged":
|
|
82
|
+
# Aged yellowish / brownish paper
|
|
83
|
+
r = random.randint(225, 248)
|
|
84
|
+
g = random.randint(r - 25, r - 5)
|
|
85
|
+
b = random.randint(g - 35, g - 15)
|
|
86
|
+
img = Image.new("RGB", (width, height), (r, g, b))
|
|
87
|
+
# Add slight gradient or texture
|
|
88
|
+
arr = np.array(img, dtype=np.int16)
|
|
89
|
+
noise = np.random.randint(-8, 8, (height, width, 3))
|
|
90
|
+
arr = np.clip(arr + noise, 0, 255).astype(np.uint8)
|
|
91
|
+
img = Image.fromarray(arr)
|
|
92
|
+
|
|
93
|
+
elif style == "scan":
|
|
94
|
+
# Grayish scan document with paper grain
|
|
95
|
+
val = random.randint(215, 238)
|
|
96
|
+
img = Image.new("RGB", (width, height), (val, val, val))
|
|
97
|
+
arr = np.array(img, dtype=np.int16)
|
|
98
|
+
noise = np.random.normal(0, 4, (height, width, 3))
|
|
99
|
+
arr = np.clip(arr + noise, 0, 255).astype(np.uint8)
|
|
100
|
+
img = Image.fromarray(arr)
|
|
101
|
+
|
|
102
|
+
else: # grid / lined
|
|
103
|
+
img = Image.new("RGB", (width, height), (250, 250, 252))
|
|
104
|
+
draw = ImageDraw.Draw(img)
|
|
105
|
+
# Faint horizontal or vertical line
|
|
106
|
+
line_y = random.randint(height // 2, height - 2)
|
|
107
|
+
draw.line([(0, line_y), (width, line_y)], fill=(210, 220, 235), width=1)
|
|
108
|
+
|
|
109
|
+
return img
|
|
110
|
+
|
|
111
|
+
def render_text(self, text, target_height=32):
|
|
112
|
+
"""Renders text into a normalized (H=target_height, W) PIL Image."""
|
|
113
|
+
if not text:
|
|
114
|
+
text = " "
|
|
115
|
+
|
|
116
|
+
font_size = random.randint(20, 26)
|
|
117
|
+
font = self.get_random_font(font_size)
|
|
118
|
+
|
|
119
|
+
# Measure text size
|
|
120
|
+
dummy = Image.new("RGB", (1, 1))
|
|
121
|
+
draw_dummy = ImageDraw.Draw(dummy)
|
|
122
|
+
bbox = draw_dummy.textbbox((0, 0), text, font=font)
|
|
123
|
+
t_w = max(10, bbox[2] - bbox[0])
|
|
124
|
+
t_h = max(10, bbox[3] - bbox[1])
|
|
125
|
+
|
|
126
|
+
# Extra padding around text
|
|
127
|
+
pad_x = random.randint(6, 16)
|
|
128
|
+
pad_y = random.randint(4, 10)
|
|
129
|
+
canvas_w = t_w + pad_x * 2
|
|
130
|
+
canvas_h = t_h + pad_y * 2
|
|
131
|
+
|
|
132
|
+
bg_img = self.generate_background(canvas_w, canvas_h)
|
|
133
|
+
draw = ImageDraw.Draw(bg_img)
|
|
134
|
+
|
|
135
|
+
# Ink color: dark black, navy, faded ink, typewriter purple
|
|
136
|
+
ink_style = random.random()
|
|
137
|
+
if ink_style < 0.7:
|
|
138
|
+
# Black / dark charcoal
|
|
139
|
+
ink_val = random.randint(10, 45)
|
|
140
|
+
ink_color = (ink_val, ink_val, ink_val)
|
|
141
|
+
elif ink_style < 0.85:
|
|
142
|
+
# Blue ink (ballpoint pen)
|
|
143
|
+
ink_color = (random.randint(15, 35), random.randint(25, 55), random.randint(90, 150))
|
|
144
|
+
elif ink_style < 0.95:
|
|
145
|
+
# Faded printer ink
|
|
146
|
+
ink_val = random.randint(60, 95)
|
|
147
|
+
ink_color = (ink_val, ink_val, ink_val)
|
|
148
|
+
else:
|
|
149
|
+
# Stamp red
|
|
150
|
+
ink_color = (random.randint(140, 190), random.randint(25, 55), random.randint(25, 55))
|
|
151
|
+
|
|
152
|
+
draw.text((pad_x - bbox[0], pad_y - bbox[1]), text, font=font, fill=ink_color)
|
|
153
|
+
|
|
154
|
+
# Apply realistic augmentations
|
|
155
|
+
bg_img = self.apply_augmentations(bg_img)
|
|
156
|
+
|
|
157
|
+
# Resize to fixed target_height (e.g. 32) preserving aspect ratio
|
|
158
|
+
w_orig, h_orig = bg_img.size
|
|
159
|
+
new_w = max(16, int((target_height / max(1, h_orig)) * w_orig))
|
|
160
|
+
final_img = bg_img.resize((new_w, target_height), Image.BILINEAR)
|
|
161
|
+
return final_img
|
|
162
|
+
|
|
163
|
+
def apply_augmentations(self, img):
|
|
164
|
+
# 1. Rotation (-2.5 to 2.5 degrees)
|
|
165
|
+
if random.random() < 0.4:
|
|
166
|
+
angle = random.uniform(-2.5, 2.5)
|
|
167
|
+
img = img.rotate(angle, resample=Image.BILINEAR, expand=True, fillcolor=(255, 255, 255))
|
|
168
|
+
|
|
169
|
+
# 2. Gaussian Blur
|
|
170
|
+
if random.random() < 0.35:
|
|
171
|
+
radius = random.uniform(0.3, 0.9)
|
|
172
|
+
img = img.filter(ImageFilter.GaussianBlur(radius))
|
|
173
|
+
|
|
174
|
+
# 3. Ink dilation / erosion with OpenCV
|
|
175
|
+
if random.random() < 0.25:
|
|
176
|
+
cv_img = np.array(img)
|
|
177
|
+
kernel_size = random.choice([2, 3])
|
|
178
|
+
kernel = np.ones((kernel_size, kernel_size), np.uint8)
|
|
179
|
+
if random.random() < 0.5:
|
|
180
|
+
cv_img = cv2.erode(cv_img, kernel, iterations=1) # thicker ink
|
|
181
|
+
else:
|
|
182
|
+
cv_img = cv2.dilate(cv_img, kernel, iterations=1) # thinner ink
|
|
183
|
+
img = Image.fromarray(cv_img)
|
|
184
|
+
|
|
185
|
+
# 4. Brightness & Contrast Jitter
|
|
186
|
+
if random.random() < 0.4:
|
|
187
|
+
enh_b = ImageEnhance.Brightness(img)
|
|
188
|
+
img = enh_b.enhance(random.uniform(0.85, 1.15))
|
|
189
|
+
if random.random() < 0.4:
|
|
190
|
+
enh_c = ImageEnhance.Contrast(img)
|
|
191
|
+
img = enh_c.enhance(random.uniform(0.85, 1.25))
|
|
192
|
+
|
|
193
|
+
return img
|
|
194
|
+
|
|
195
|
+
default_generator = AsterTextGenerator()
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
from asterocr.synthesizer.corpus import generate_random_corpus_text
|
|
199
|
+
os.makedirs("test_samples", exist_ok=True)
|
|
200
|
+
for i in range(5):
|
|
201
|
+
t = generate_random_corpus_text()
|
|
202
|
+
im = default_generator.render_text(t)
|
|
203
|
+
p = f"test_samples/sample_{i+1}.png"
|
|
204
|
+
im.save(p)
|
|
205
|
+
print(f"Generated: '{t}' -> saved to {p} (size: {im.size})")
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AsterOCR Advanced Vietnamese Handwriting & Diverse Receipt Generator
|
|
3
|
+
Produces realistic Vietnamese handwritten documents and thermal receipts/invoices.
|
|
4
|
+
Features:
|
|
5
|
+
- Notebook paper with school grid lines (ô ly học sinh) & horizontal lines
|
|
6
|
+
- Realistic ink physics: ballpoint blue, gel black, pencil graphite, fountain pen
|
|
7
|
+
- Handwriting dynamics: per-glyph baseline jitter, slant (±12 deg), variable pen pressure
|
|
8
|
+
- Thermal receipts: dot-matrix look, store names, VAT, VND amounts, transaction codes
|
|
9
|
+
- Multiprocessing-ready for millions of samples directly into LMDB
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import io
|
|
14
|
+
import sys
|
|
15
|
+
import math
|
|
16
|
+
import random
|
|
17
|
+
import time
|
|
18
|
+
import numpy as np
|
|
19
|
+
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
|
|
20
|
+
import cv2
|
|
21
|
+
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
try:
|
|
24
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
25
|
+
sys.stderr.reconfigure(encoding='utf-8')
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
from asterocr.synthesizer.font_manager import get_handwriting_fonts, get_receipt_fonts
|
|
30
|
+
from asterocr.synthesizer.corpus import generate_random_corpus_text
|
|
31
|
+
|
|
32
|
+
# Specialized corpus templates for handwriting, receipts, and forms
|
|
33
|
+
HANDWRITTEN_TEMPLATES = [
|
|
34
|
+
"Tôi tên là {name}, sinh ngày {date}",
|
|
35
|
+
"Địa chỉ: {address}",
|
|
36
|
+
"Số điện thoại liên hệ: {phone}",
|
|
37
|
+
"Số CCCD: {cccd}, cấp ngày {date}",
|
|
38
|
+
"Kính gửi: Ban Giám hiệu Trường {school}",
|
|
39
|
+
"Em xin phép được nghỉ học ngày {date}",
|
|
40
|
+
"Cộng hòa Xã hội Chủ nghĩa Việt Nam",
|
|
41
|
+
"Độc lập - Tự do - Hạnh phúc",
|
|
42
|
+
"Đơn xin xác nhận tình trạng cư trú",
|
|
43
|
+
"Người làm đơn: {name}",
|
|
44
|
+
"Cam đoan lời khai trên là hoàn toàn đúng sự thật",
|
|
45
|
+
"Hà Nội, ngày {day} tháng {month} năm 2026",
|
|
46
|
+
"Hồ Chí Minh, ngày {day} tháng {month} năm 2026",
|
|
47
|
+
"Đà Nẵng, ngày {day} tháng {month} năm 2026",
|
|
48
|
+
"Bút bi xanh Thiên Long ngòi 0.5mm",
|
|
49
|
+
"Ghi chú: Đã thanh toán đầy đủ tiền cọc",
|
|
50
|
+
"Bài học số {num}: Lịch sử dựng nước và giữ nước",
|
|
51
|
+
"Văn mẫu lớp {num}: Miêu tả cảnh đẹp quê hương em",
|
|
52
|
+
"Tổng số tiền bằng chữ: {money_words}",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
RECEIPT_TEMPLATES = [
|
|
56
|
+
"HÓA ĐƠN BÁN LẺ",
|
|
57
|
+
"PHIẾU THANH TOÁN",
|
|
58
|
+
"HÓA ĐƠN GIÁ TRỊ GIA TĂNG",
|
|
59
|
+
"SIÊU THỊ CO.OPMART HÀ NỘI",
|
|
60
|
+
"CỬA HÀNG TIỆN LỢI CIRCLE K",
|
|
61
|
+
"NHÀ THUỐC LONG CHÂU - CHI NHÁNH {num}",
|
|
62
|
+
"HIGHLANDS COFFEE - {address}",
|
|
63
|
+
"TRÀ SỮA PHÚC LONG TEA & COFFEE",
|
|
64
|
+
"BÁNH MÌ HUYNH HOA - SỐ LƯỢNG: {num}",
|
|
65
|
+
"Mã HĐ: HD{code}-{num}",
|
|
66
|
+
"Mã tra cứu: {code}VN",
|
|
67
|
+
"MST: 0{code}89",
|
|
68
|
+
"Ngày: {date} {time}",
|
|
69
|
+
"Thu ngân: NV_{name}",
|
|
70
|
+
"Cà phê sữa đá: {amount} đ",
|
|
71
|
+
"Trà sen vàng size L: {amount} đ",
|
|
72
|
+
"Bánh ngọt Tiramisu: {amount} VND",
|
|
73
|
+
"Cơm tấm sườn bì chả: {amount} đ",
|
|
74
|
+
"Phở bò tái nạm đặc biệt: {amount} đ",
|
|
75
|
+
"Tiền hàng: {amount} VND",
|
|
76
|
+
"Thuế VAT (8%): {amount} đ",
|
|
77
|
+
"TỔNG CỘNG: {amount} VNĐ",
|
|
78
|
+
"Tiền mặt: {amount} đ",
|
|
79
|
+
"Tiền thối lại: {amount} đ",
|
|
80
|
+
"Cảm ơn Quý khách - Hẹn gặp lại!",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
NAMES = ["Nguyễn Văn An", "Trần Thị Mai", "Lê Hoàng Long", "Phạm Minh Đức", "Hoàng Thu Thảo", "Vũ Hải Đăng", "Đỗ Thanh Hằng", "Bùi Quốc Tuấn", "Đinh Ngọc Ánh", "Ngô Gia Bảo"]
|
|
84
|
+
ADDRESSES = ["123 Phố Huế, Hai Bà Trưng, Hà Nội", "45 Lê Duẩn, P. Bến Nghé, Quận 1, TP.HCM", "78 Nguyễn Tri Phương, Thanh Khê, Đà Nẵng", "12 Hoàng Văn Thụ, Cần Thơ", "89 Trần Phú, Nha Trang, Khánh Hòa"]
|
|
85
|
+
SCHOOLS = ["Tiểu học Thăng Long", "THCS Chu Văn An", "THPT Chuyên Hà Nội - Amsterdam", "Đại học Bách Khoa", "Đại học Quốc gia"]
|
|
86
|
+
MONEY_WORDS = ["Một trăm năm mươi nghìn đồng chẵn", "Hai triệu ba trăm năm mươi nghìn đồng", "Năm mươi nghìn đồng", "Tám trăm chín mươi nghìn đồng"]
|
|
87
|
+
|
|
88
|
+
def sample_handwritten_text():
|
|
89
|
+
"""Generates realistic Vietnamese text for handwriting."""
|
|
90
|
+
r = random.random()
|
|
91
|
+
if r < 0.4:
|
|
92
|
+
tpl = random.choice(HANDWRITTEN_TEMPLATES)
|
|
93
|
+
text = tpl.format(
|
|
94
|
+
name=random.choice(NAMES),
|
|
95
|
+
date=f"{random.randint(1, 28):02d}/{random.randint(1, 12):02d}/{random.randint(1985, 2025)}",
|
|
96
|
+
address=random.choice(ADDRESSES),
|
|
97
|
+
phone=f"09{random.randint(10000000, 99999999)}",
|
|
98
|
+
cccd=f"001{random.randint(100000000, 999999999)}",
|
|
99
|
+
school=random.choice(SCHOOLS),
|
|
100
|
+
day=f"{random.randint(1, 28):02d}",
|
|
101
|
+
month=f"{random.randint(1, 12):02d}",
|
|
102
|
+
num=random.randint(1, 12),
|
|
103
|
+
money_words=random.choice(MONEY_WORDS)
|
|
104
|
+
)
|
|
105
|
+
return text
|
|
106
|
+
elif r < 0.8:
|
|
107
|
+
# Standard administrative / sentence text
|
|
108
|
+
return generate_random_corpus_text("sentence")
|
|
109
|
+
else:
|
|
110
|
+
# Complex diacritic-heavy combinations
|
|
111
|
+
return generate_random_corpus_text("administrative")
|
|
112
|
+
|
|
113
|
+
def sample_receipt_text():
|
|
114
|
+
"""Generates realistic Vietnamese receipt & thermal invoice text."""
|
|
115
|
+
r = random.random()
|
|
116
|
+
if r < 0.65:
|
|
117
|
+
tpl = random.choice(RECEIPT_TEMPLATES)
|
|
118
|
+
text = tpl.format(
|
|
119
|
+
name=random.choice(["Thu", "Nam", "Linh", "Hoa", "Tuấn", "Lan"]),
|
|
120
|
+
code=random.randint(10000, 99999),
|
|
121
|
+
num=random.randint(1, 99),
|
|
122
|
+
address=random.choice(["Hà Nội", "TP.HCM", "Đà Nẵng", "Cầu Giấy", "Quận 1"]),
|
|
123
|
+
date=f"{random.randint(1, 28):02d}/{random.randint(1, 12):02d}/2026",
|
|
124
|
+
time=f"{random.randint(7, 22):02d}:{random.randint(0, 59):02d}",
|
|
125
|
+
amount=f"{random.randint(15, 850) * 1000:,}".replace(",", ".")
|
|
126
|
+
)
|
|
127
|
+
return text
|
|
128
|
+
elif r < 0.85:
|
|
129
|
+
# Currency and item lines
|
|
130
|
+
return f"{random.choice(['Thanh toán', 'Tạm tính', 'Thành tiền', 'Chiết khấu'])}: {random.randint(10, 500) * 1000:,} đ".replace(",", ".")
|
|
131
|
+
else:
|
|
132
|
+
return generate_random_corpus_text("digits")
|
|
133
|
+
|
|
134
|
+
class RealisticHandwritingRenderer:
|
|
135
|
+
"""Renders authentic handwritten Vietnamese crops with pen physics and notebook paper."""
|
|
136
|
+
|
|
137
|
+
def __init__(self):
|
|
138
|
+
self.fonts = get_handwriting_fonts()
|
|
139
|
+
if not self.fonts:
|
|
140
|
+
# Fallback
|
|
141
|
+
self.fonts = ["C:/Windows/Fonts/arial.ttf"]
|
|
142
|
+
|
|
143
|
+
# Pen colors
|
|
144
|
+
self.ink_colors = [
|
|
145
|
+
(21, 48, 117), # Ballpoint blue (classic)
|
|
146
|
+
(29, 78, 216), # Bright royal blue
|
|
147
|
+
(14, 37, 94), # Dark navy blue
|
|
148
|
+
(24, 24, 27), # Gel black
|
|
149
|
+
(39, 39, 42), # Charcoal black
|
|
150
|
+
(63, 63, 70), # Pencil graphite
|
|
151
|
+
(88, 28, 135), # Purple ink (school teacher / student)
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def _create_notebook_paper(self, w, h):
|
|
155
|
+
"""Creates realistic ruled notebook paper (ô ly học sinh or kẻ ngang)."""
|
|
156
|
+
# Base paper color: off-white, light cream, or gentle vintage
|
|
157
|
+
bg_r = random.randint(245, 255)
|
|
158
|
+
bg_g = random.randint(244, 253)
|
|
159
|
+
bg_b = random.randint(236, 248)
|
|
160
|
+
img = Image.new("RGB", (w, h), (bg_r, bg_g, bg_b))
|
|
161
|
+
draw = ImageDraw.Draw(img)
|
|
162
|
+
|
|
163
|
+
style = random.choice(["ruled", "grid_oly", "plain", "dirty"])
|
|
164
|
+
|
|
165
|
+
if style == "ruled":
|
|
166
|
+
# Horizontal notebook lines (blue or light purple)
|
|
167
|
+
line_y = int(h * random.uniform(0.72, 0.85))
|
|
168
|
+
line_col = random.choice([(199, 215, 245), (218, 210, 240), (200, 200, 200)])
|
|
169
|
+
draw.line([(0, line_y), (w, line_y)], fill=line_col, width=1)
|
|
170
|
+
# Maybe top secondary line
|
|
171
|
+
if random.random() < 0.5 and line_y - 20 > 0:
|
|
172
|
+
draw.line([(0, line_y - 20), (w, line_y - 20)], fill=line_col, width=1)
|
|
173
|
+
|
|
174
|
+
elif style == "grid_oly":
|
|
175
|
+
# Ô ly học sinh (subtle blue/green grid lines)
|
|
176
|
+
grid_col = random.choice([(210, 230, 248), (220, 240, 230), (225, 225, 240)])
|
|
177
|
+
step_x = random.randint(18, 26)
|
|
178
|
+
for x in range(0, w, step_x):
|
|
179
|
+
draw.line([(x, 0), (x, h)], fill=grid_col, width=1)
|
|
180
|
+
step_y = random.randint(14, 20)
|
|
181
|
+
for y in range(0, h, step_y):
|
|
182
|
+
draw.line([(0, y), (w, y)], fill=grid_col, width=1)
|
|
183
|
+
|
|
184
|
+
elif style == "dirty":
|
|
185
|
+
# Paper with subtle smudge or crease
|
|
186
|
+
arr = np.array(img, dtype=np.float32)
|
|
187
|
+
noise = np.random.normal(0, 3.0, (h, w, 3))
|
|
188
|
+
arr = np.clip(arr + noise, 0, 255).astype(np.uint8)
|
|
189
|
+
img = Image.fromarray(arr)
|
|
190
|
+
|
|
191
|
+
return img
|
|
192
|
+
|
|
193
|
+
def render(self, text, target_height=32):
|
|
194
|
+
"""Renders single handwriting crop with human dynamics."""
|
|
195
|
+
font_path = random.choice(self.fonts)
|
|
196
|
+
font_size = random.randint(22, 30)
|
|
197
|
+
try:
|
|
198
|
+
font = ImageFont.truetype(font_path, font_size)
|
|
199
|
+
except Exception:
|
|
200
|
+
font = ImageFont.load_default()
|
|
201
|
+
|
|
202
|
+
ink = random.choice(self.ink_colors)
|
|
203
|
+
|
|
204
|
+
# Calculate raw bounding box
|
|
205
|
+
dummy_img = Image.new("L", (1, 1))
|
|
206
|
+
draw_dummy = ImageDraw.Draw(dummy_img)
|
|
207
|
+
bbox = draw_dummy.textbbox((0, 0), text, font=font)
|
|
208
|
+
text_w = max(10, bbox[2] - bbox[0])
|
|
209
|
+
text_h = max(10, bbox[3] - bbox[1])
|
|
210
|
+
|
|
211
|
+
# Canvas dimensions with padding for jitter & slanting
|
|
212
|
+
pad_x = random.randint(16, 32)
|
|
213
|
+
pad_y = random.randint(10, 20)
|
|
214
|
+
canvas_w = text_w + pad_x * 2
|
|
215
|
+
canvas_h = text_h + pad_y * 2
|
|
216
|
+
|
|
217
|
+
# 1. Background
|
|
218
|
+
paper = self._create_notebook_paper(canvas_w, canvas_h)
|
|
219
|
+
|
|
220
|
+
# 2. Text rendering with character-level baseline jitter
|
|
221
|
+
# To simulate human handwriting, render word-by-word with small y-jitter
|
|
222
|
+
words = text.split(" ")
|
|
223
|
+
curr_x = pad_x
|
|
224
|
+
draw = ImageDraw.Draw(paper)
|
|
225
|
+
|
|
226
|
+
base_y = pad_y
|
|
227
|
+
for w_idx, word in enumerate(words):
|
|
228
|
+
# Baseline jitter (±2 px per word)
|
|
229
|
+
y_jitter = random.randint(-2, 2)
|
|
230
|
+
w_bbox = draw.textbbox((0, 0), word, font=font)
|
|
231
|
+
ww = w_bbox[2] - w_bbox[0]
|
|
232
|
+
|
|
233
|
+
# Subtle ink variation per word
|
|
234
|
+
word_ink = (
|
|
235
|
+
int(np.clip(ink[0] + random.randint(-10, 10), 0, 255)),
|
|
236
|
+
int(np.clip(ink[1] + random.randint(-10, 10), 0, 255)),
|
|
237
|
+
int(np.clip(ink[2] + random.randint(-10, 10), 0, 255)),
|
|
238
|
+
)
|
|
239
|
+
draw.text((curr_x, base_y + y_jitter), word, fill=word_ink, font=font)
|
|
240
|
+
|
|
241
|
+
# Space width
|
|
242
|
+
space_w = font_size // 3 + random.randint(-2, 4)
|
|
243
|
+
curr_x += ww + space_w
|
|
244
|
+
|
|
245
|
+
# 3. Handwriting Slant & Shear
|
|
246
|
+
if random.random() < 0.7:
|
|
247
|
+
shear_factor = random.uniform(-0.22, 0.22) # natural slant
|
|
248
|
+
paper = paper.transform(
|
|
249
|
+
paper.size,
|
|
250
|
+
Image.AFFINE,
|
|
251
|
+
(1, shear_factor, 0, 0, 1, 0),
|
|
252
|
+
resample=Image.BICUBIC,
|
|
253
|
+
fillcolor=(250, 250, 250)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# 4. Pen stroke dynamics: subtle ink bleed / paper absorption
|
|
257
|
+
if random.random() < 0.5:
|
|
258
|
+
paper = paper.filter(ImageFilter.GaussianBlur(radius=random.uniform(0.3, 0.6)))
|
|
259
|
+
|
|
260
|
+
# 5. Crop tightly around text with pleasant margin
|
|
261
|
+
# Resize to target_height=32 preserving aspect ratio
|
|
262
|
+
w, h = paper.size
|
|
263
|
+
# Trim excess vertical padding
|
|
264
|
+
content_y1 = max(0, pad_y - 4)
|
|
265
|
+
content_y2 = min(h, pad_y + text_h + 8)
|
|
266
|
+
content_x1 = max(0, pad_x - 8)
|
|
267
|
+
content_x2 = min(w, curr_x + 8)
|
|
268
|
+
|
|
269
|
+
if content_x2 > content_x1 and content_y2 > content_y1:
|
|
270
|
+
paper = paper.crop((content_x1, content_y1, content_x2, content_y2))
|
|
271
|
+
|
|
272
|
+
pw, ph = paper.size
|
|
273
|
+
new_w = max(16, int((float(target_height) / max(1, ph)) * pw))
|
|
274
|
+
final_img = paper.resize((new_w, target_height), Image.LANCZOS)
|
|
275
|
+
|
|
276
|
+
return final_img
|
|
277
|
+
|
|
278
|
+
class RealisticReceiptRenderer:
|
|
279
|
+
"""Renders authentic thermal paper receipts, dot-matrix invoices, vouchers."""
|
|
280
|
+
|
|
281
|
+
def __init__(self):
|
|
282
|
+
self.fonts = get_receipt_fonts()
|
|
283
|
+
if not self.fonts:
|
|
284
|
+
self.fonts = ["C:/Windows/Fonts/consola.ttf"]
|
|
285
|
+
|
|
286
|
+
def render(self, text, target_height=32):
|
|
287
|
+
"""Renders single receipt line crop."""
|
|
288
|
+
font_path = random.choice(self.fonts)
|
|
289
|
+
font_size = random.randint(20, 26)
|
|
290
|
+
try:
|
|
291
|
+
font = ImageFont.truetype(font_path, font_size)
|
|
292
|
+
except Exception:
|
|
293
|
+
font = ImageFont.load_default()
|
|
294
|
+
|
|
295
|
+
# Receipt paper colors: grayish white, slightly translucent thermal paper
|
|
296
|
+
bg_col = random.choice([
|
|
297
|
+
(248, 248, 248),
|
|
298
|
+
(242, 242, 242),
|
|
299
|
+
(238, 240, 242),
|
|
300
|
+
(252, 250, 245)
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
# Ink: black thermal print, sometimes faded purple/gray
|
|
304
|
+
ink_col = random.choice([
|
|
305
|
+
(25, 25, 25),
|
|
306
|
+
(45, 45, 45),
|
|
307
|
+
(60, 60, 65), # Faded thermal print
|
|
308
|
+
(35, 40, 60), # Dark blue-gray
|
|
309
|
+
])
|
|
310
|
+
|
|
311
|
+
dummy_img = Image.new("L", (1, 1))
|
|
312
|
+
draw_dummy = ImageDraw.Draw(dummy_img)
|
|
313
|
+
bbox = draw_dummy.textbbox((0, 0), text, font=font)
|
|
314
|
+
text_w = max(10, bbox[2] - bbox[0])
|
|
315
|
+
text_h = max(10, bbox[3] - bbox[1])
|
|
316
|
+
|
|
317
|
+
pad_x = random.randint(8, 16)
|
|
318
|
+
pad_y = random.randint(6, 12)
|
|
319
|
+
canvas_w = text_w + pad_x * 2
|
|
320
|
+
canvas_h = text_h + pad_y * 2
|
|
321
|
+
|
|
322
|
+
img = Image.new("RGB", (canvas_w, canvas_h), bg_col)
|
|
323
|
+
draw = ImageDraw.Draw(img)
|
|
324
|
+
|
|
325
|
+
# Draw text
|
|
326
|
+
draw.text((pad_x, pad_y), text, fill=ink_col, font=font)
|
|
327
|
+
|
|
328
|
+
# Thermal receipt imperfections:
|
|
329
|
+
# 1. Thermal print banding or fading
|
|
330
|
+
if random.random() < 0.4:
|
|
331
|
+
arr = np.array(img, dtype=np.float32)
|
|
332
|
+
fade_mask = np.linspace(random.uniform(0.85, 1.0), random.uniform(0.7, 0.95), canvas_w)
|
|
333
|
+
for c in range(3):
|
|
334
|
+
arr[:, :, c] = arr[:, :, c] * fade_mask[None, :] + 255 * (1 - fade_mask[None, :])
|
|
335
|
+
img = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
|
|
336
|
+
|
|
337
|
+
# 2. Dot-matrix dithering or slight noise
|
|
338
|
+
if random.random() < 0.35:
|
|
339
|
+
arr = np.array(img, dtype=np.float32)
|
|
340
|
+
noise = np.random.normal(0, 4.0, (canvas_h, canvas_w, 3))
|
|
341
|
+
arr = np.clip(arr + noise, 0, 255).astype(np.uint8)
|
|
342
|
+
img = Image.fromarray(arr)
|
|
343
|
+
|
|
344
|
+
# Resize to target_height=32
|
|
345
|
+
pw, ph = img.size
|
|
346
|
+
new_w = max(16, int((float(target_height) / max(1, ph)) * pw))
|
|
347
|
+
final_img = img.resize((new_w, target_height), Image.LANCZOS)
|
|
348
|
+
|
|
349
|
+
return final_img
|
|
350
|
+
|
|
351
|
+
# Module-level instances for fast reuse
|
|
352
|
+
_hw_renderer = None
|
|
353
|
+
_rc_renderer = None
|
|
354
|
+
|
|
355
|
+
def get_renderers():
|
|
356
|
+
global _hw_renderer, _rc_renderer
|
|
357
|
+
if _hw_renderer is None:
|
|
358
|
+
_hw_renderer = RealisticHandwritingRenderer()
|
|
359
|
+
if _rc_renderer is None:
|
|
360
|
+
_rc_renderer = RealisticReceiptRenderer()
|
|
361
|
+
return _hw_renderer, _rc_renderer
|
|
362
|
+
|
|
363
|
+
def synth_single_sample(mode="handwriting", target_height=32):
|
|
364
|
+
"""
|
|
365
|
+
Generates a single synthetic sample (PIL.Image, text, source_type).
|
|
366
|
+
mode: 'handwriting' | 'receipt' | 'mixed'
|
|
367
|
+
"""
|
|
368
|
+
hw_ren, rc_ren = get_renderers()
|
|
369
|
+
|
|
370
|
+
if mode == "handwriting" or (mode == "mixed" and random.random() < 0.65):
|
|
371
|
+
text = sample_handwritten_text()
|
|
372
|
+
img = hw_ren.render(text, target_height=target_height)
|
|
373
|
+
source_type = "synth_handwriting"
|
|
374
|
+
else:
|
|
375
|
+
text = sample_receipt_text()
|
|
376
|
+
img = rc_ren.render(text, target_height=target_height)
|
|
377
|
+
source_type = "synth_receipt"
|
|
378
|
+
|
|
379
|
+
return img, text, source_type
|
|
380
|
+
|
|
381
|
+
def _worker_chunk_handwriting(args):
|
|
382
|
+
"""Worker task function for multiprocessing pool."""
|
|
383
|
+
start_id, count, mode, target_height = args
|
|
384
|
+
hw_ren, rc_ren = get_renderers()
|
|
385
|
+
results = []
|
|
386
|
+
|
|
387
|
+
for i in range(count):
|
|
388
|
+
sid = start_id + i
|
|
389
|
+
if mode == "handwriting" or (mode == "mixed" and random.random() < 0.65):
|
|
390
|
+
text = sample_handwritten_text()
|
|
391
|
+
img = hw_ren.render(text, target_height=target_height)
|
|
392
|
+
source_type = "synth_handwriting"
|
|
393
|
+
else:
|
|
394
|
+
text = sample_receipt_text()
|
|
395
|
+
img = rc_ren.render(text, target_height=target_height)
|
|
396
|
+
source_type = "synth_receipt"
|
|
397
|
+
|
|
398
|
+
buf = io.BytesIO()
|
|
399
|
+
img.save(buf, format="JPEG", quality=random.randint(85, 95))
|
|
400
|
+
img_bytes = buf.getvalue()
|
|
401
|
+
results.append((sid, img_bytes, text, source_type))
|
|
402
|
+
|
|
403
|
+
return results
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
print("Test rendering 5 handwriting samples...")
|
|
407
|
+
hw, rc = get_renderers()
|
|
408
|
+
os.makedirs("test_samples", exist_ok=True)
|
|
409
|
+
for idx in range(5):
|
|
410
|
+
text = sample_handwritten_text()
|
|
411
|
+
img = hw.render(text)
|
|
412
|
+
img.save(f"test_samples/hw_{idx}.jpg")
|
|
413
|
+
print(f" [HW {idx}] '{text}' -> {img.size}")
|
|
414
|
+
|
|
415
|
+
for idx in range(5):
|
|
416
|
+
text = sample_receipt_text()
|
|
417
|
+
img = rc.render(text)
|
|
418
|
+
img.save(f"test_samples/rc_{idx}.jpg")
|
|
419
|
+
print(f" [RC {idx}] '{text}' -> {img.size}")
|
|
420
|
+
print("[+] Test completed successfully! Samples saved in test_samples/")
|