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,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/")