Spaces:
Running
Running
Download ocr-training/scripts/generate_data.py from phucsd/cham-ocr-studio: direct link, hf CLI and curl.
- Browser
- Download file 43.2 kB
-
https://huggingface.co/spaces/phucsd/cham-ocr-studio/resolve/main/ocr-training/scripts/generate_data.py
- Command line
-
hf download hf://spaces/phucsd/cham-ocr-studio/ocr-training/scripts/generate_data.py
-
curl -L -o generate_data.py https://huggingface.co/spaces/phucsd/cham-ocr-studio/resolve/main/ocr-training/scripts/generate_data.py
43.2 kB
| import os | |
| import sys | |
| import random | |
| import cv2 | |
| import numpy as np | |
| import json | |
| import re | |
| from PIL import Image, ImageFont, ImageDraw | |
| try: | |
| from fontTools.ttLib import TTFont | |
| except ImportError: | |
| TTFont = None | |
| try: | |
| import albumentations as A | |
| except ImportError: | |
| A = None | |
| # Configure standard streams to support UTF-8 on Windows terminals | |
| if sys.stdout and sys.stdout.encoding != 'utf-8': | |
| try: | |
| sys.stdout.reconfigure(encoding='utf-8') | |
| except AttributeError: | |
| pass | |
| # ============================================================================== | |
| # 1. Cấu hình Tổ hợp Ký tự & Phân nhóm Hình học tiếng Chăm | |
| # ============================================================================== | |
| PRE_SIGNS = set('ꨯꨰ') | |
| MEDIAL_SIGNS = set('ꨴꨵꨳꨶ') | |
| MEDIAL_RA_LA = set('ꨴꨵ') | |
| MEDIAL_YA_WA = set('ꨳꨶ') | |
| VOWEL_DIACRITIC_SIGNS = set('ꨩꨪꨫꨬꨭꨮꨯꨰꨱꨲ') | |
| FINAL_SIGNS = set(chr(c) for c in range(0xAA40, 0xAA4E)) | |
| # Per Unicode Standard Chapter 16 Table 16-16, U+AA25 (ꨥ) also functions as syllable-final consonant | |
| FINAL_CONSONANTS_OR_VA = FINAL_SIGNS | {'ꨥ'} | |
| CHAM_DIGITS = set('꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙') | |
| CHAM_PUNCT_SIGNS = set('꩜꩝꩞꩟') | |
| PUNCT_SIGNS = set('꩜꩝꩞꩟.,;:!?') | |
| FOCUS_CHARS = PRE_SIGNS | FINAL_SIGNS | MEDIAL_SIGNS | VOWEL_DIACRITIC_SIGNS | |
| CONFUSION_CHARS = set('ꨯꨰꨴꨵꨳꨶꨪꨫꨬꨭꨮꨱꨲꩀꩃꩌꩍꩆꩉꩊꨈꨤꨠꨥꨡꨓꨩ') | |
| consonants = set("ꨆꨇꨈꨉꨊꨋꨌꨍꨎꨏꨐꨑꨒꨓꨔꨕꨖꨗꨘꨙꨚꨛꨜꨝꨞꨟꨠꨡꨢꨣꨤꨥꨦꨧꨨꨀꨁꨂꨃꨄꨅ") | |
| pre_signs = PRE_SIGNS | |
| diacritics = set(chr(c) for c in range(0xAA29, 0xAA37)) | set(chr(c) for c in range(0xAA40, 0xAA4E)) | |
| COMBINING_MARKS = PRE_SIGNS | diacritics | FINAL_SIGNS | MEDIAL_SIGNS | |
| HEAL_REGEX = re.compile(r'\s+([ꨯꨰꨴꨵꨳꨶꩀꩃꩌꩍꩆꩉꩊꩂꩅ\uAA29-\uAA36\uAA40-\uAA4D])') | |
| # Danh sách từ luyện tập cụm chữ khó hay sai trên TestBench | |
| CLUSTER_DRILLS = [ | |
| 'ꨨꨰꨳ', 'ꨈꨪꨤꨰ', 'ꨨꨯꨱꩀ', | |
| 'ꨟꨧꨮꩌ', 'ꨟꨧꨮꩃ', 'ꨓꨌꨯꨱꩍ', 'ꨕꩀ', 'ꨀꨣꩌ', | |
| 'ꨨꨣꩌ', 'ꨨꨣꩃ', 'ꨝꨪꨗꩆ', 'ꨟꨧꨪꩆ', 'ꨀꨳꨩ' | |
| ] | |
| # Cache bộ đệm Font | |
| _font_cache = {} | |
| def get_cached_font(font_path, size): | |
| key = (font_path, size) | |
| if key not in _font_cache: | |
| _font_cache[key] = ImageFont.truetype(font_path, size) | |
| return _font_cache[key] | |
| # ============================================================================== | |
| # 2. Phân loại cấu trúc Âm tiết Chăm | |
| # ============================================================================== | |
| def is_focus_token(tok): | |
| return any(ch in FOCUS_CHARS for ch in tok) | |
| def has_pre(tok): | |
| return any(ch in PRE_SIGNS for ch in tok) | |
| def has_final(tok): | |
| if any(ch in FINAL_SIGNS for ch in tok): | |
| return True | |
| # Per Unicode Standard Chapter 16 Table 16-16, U+AA25 (ꨥ) functions as syllable-final consonant | |
| if len(tok) > 1 and tok[-1] == 'ꨥ': | |
| return True | |
| return False | |
| def shape_key(tok): | |
| """Phân loại hình thái của token để lập nhóm drill thích hợp""" | |
| parts = [] | |
| if has_pre(tok): parts.append('PRE') | |
| if any(ch in MEDIAL_SIGNS for ch in tok): parts.append('MED') | |
| if any(ch in VOWEL_DIACRITIC_SIGNS for ch in tok): parts.append('VOW') | |
| if has_final(tok): parts.append('FIN') | |
| if len(tok) <= 4: parts.append('SHORT') | |
| return '+'.join(parts) or 'BASE' | |
| def token_distance_key(tok): | |
| """Trả về signature rút gọn của chữ để tìm từ đồng dạng (minimal pairs)""" | |
| sig = [] | |
| for idx, ch in enumerate(tok): | |
| if ch in FINAL_SIGNS or (idx > 0 and ch == 'ꨥ' and idx == len(tok) - 1): | |
| sig.append('F') | |
| elif ch in PRE_SIGNS: | |
| sig.append('P') | |
| elif ch in MEDIAL_SIGNS: | |
| sig.append('M') | |
| elif ch in VOWEL_DIACRITIC_SIGNS: | |
| sig.append('V') | |
| else: | |
| sig.append('B') | |
| return ''.join(sig) | |
| def parse_unicode_clusters(text): | |
| clusters = [] | |
| i = 0 | |
| n = len(text) | |
| while i < n: | |
| char = text[i] | |
| if char in consonants: | |
| cluster = [char] | |
| i += 1 | |
| while i < n and (text[i] in diacritics or text[i] in pre_signs): | |
| cluster.append(text[i]) | |
| i += 1 | |
| # Per Unicode Table 16-16: U+AA25 (ꨥ) functions as syllable-final consonant | |
| if i < n and text[i] == 'ꨥ' and len(cluster) > 1 and (i + 1 == n or text[i+1] in ' \t\n\r' or text[i+1] in PUNCT_SIGNS or text[i+1] in consonants): | |
| cluster.append(text[i]) | |
| i += 1 | |
| clusters.append(cluster) | |
| else: | |
| clusters.append([char]) | |
| i += 1 | |
| return clusters | |
| def parse_visual_clusters(text): | |
| clusters = [] | |
| i = 0 | |
| n = len(text) | |
| while i < n: | |
| j = i | |
| while j < n and text[j] in pre_signs: | |
| j += 1 | |
| if j < n and text[j] in consonants: | |
| cluster = list(text[i:j+1]) | |
| i = j + 1 | |
| while i < n and text[i] in diacritics and text[i] not in pre_signs: | |
| cluster.append(text[i]) | |
| i += 1 | |
| # Check for Table 16-16 final VA | |
| if i < n and text[i] == 'ꨥ' and (i + 1 == n or text[i+1] in ' \t\n\r' or text[i+1] in PUNCT_SIGNS or text[i+1] in consonants): | |
| cluster.append(text[i]) | |
| i += 1 | |
| clusters.append(cluster) | |
| else: | |
| clusters.append([text[i]]) | |
| i += 1 | |
| return clusters | |
| def unicode_to_visual_cluster(cluster): | |
| has_consonant = any(c in consonants for c in cluster) | |
| if not has_consonant: | |
| return cluster | |
| extracted_pre = [c for c in cluster if c in pre_signs] | |
| extracted_pre.sort() | |
| remaining = [c for c in cluster if c not in pre_signs] | |
| return extracted_pre + remaining | |
| def visual_to_unicode_cluster(cluster): | |
| has_consonant = any(c in consonants for c in cluster) | |
| if not has_consonant: | |
| return cluster | |
| # Reconstruct according to official Unicode Cham canonical syllabic order (Table 16-16): | |
| # 1. Base consonant | |
| # 2. Medial RA / LA (ꨴ U+AA34, ꨵ U+AA35) | |
| # 3. Medial YA / WA (ꨳ U+AA33, ꨶ U+AA36) | |
| # 4. Pre-base vowels (ꨯ U+AA2F, ꨰ U+AA30) | |
| # 5. Other dependent vowels (ꨪ, ꨫ, ꨬ, ꨭ, ꨮ, ꨱ, ꨲ) | |
| # 6. Vowel lengthener AA (ꨩ U+AA29) | |
| # 7. Final consonants & signs (U+AA40-U+AA4D, and final VA U+AA25 per Table 16-16) | |
| base_consonant = [] | |
| medials_ra_la = [] | |
| medials_ya_wa = [] | |
| pre_vowels = [] | |
| other_vowels = [] | |
| aa_lengthener = [] | |
| finals = [] | |
| unclassified = [] | |
| for c in cluster: | |
| if c in consonants: | |
| if not base_consonant: | |
| base_consonant.append(c) | |
| elif c == 'ꨥ': | |
| finals.append(c) | |
| else: | |
| base_consonant.append(c) | |
| elif c in ('ꨴ', 'ꨵ'): | |
| medials_ra_la.append(c) | |
| elif c in ('ꨳ', 'ꨶ'): | |
| medials_ya_wa.append(c) | |
| elif c in ('ꨯ', 'ꨰ'): | |
| pre_vowels.append(c) | |
| elif c == 'ꨩ': | |
| aa_lengthener.append(c) | |
| elif c in ('ꨪ', 'ꨫ', 'ꨬ', 'ꨭ', 'ꨮ', 'ꨱ', 'ꨲ'): | |
| other_vowels.append(c) | |
| elif c in FINAL_SIGNS or (0xAA40 <= ord(c) <= 0xAA4D): | |
| finals.append(c) | |
| else: | |
| unclassified.append(c) | |
| return base_consonant + medials_ra_la + medials_ya_wa + pre_vowels + other_vowels + aa_lengthener + finals + unclassified | |
| def unicode_to_visual(text): | |
| clusters = parse_unicode_clusters(text) | |
| vis_clusters = [unicode_to_visual_cluster(c) for c in clusters] | |
| return "".join("".join(c) for c in vis_clusters) | |
| def visual_to_unicode(text): | |
| clusters = parse_visual_clusters(text) | |
| uni_clusters = [visual_to_unicode_cluster(c) for c in clusters] | |
| return "".join("".join(c) for c in uni_clusters) | |
| def normalize_unicode(text): | |
| # Fixes internal ordering errors in Logical Order text (e.g. ꨙꨯꨳꨮ -> ꨙꨳꨯꨮ) | |
| clusters = parse_unicode_clusters(text) | |
| uni_clusters = [visual_to_unicode_cluster(c) for c in clusters] | |
| return "".join("".join(c) for c in uni_clusters) | |
| # ============================================================================== | |
| # 3. Lớp Kiểm Tra Font (Font Validation) | |
| # ============================================================================== | |
| class FontValidator: | |
| def __init__(self, font_paths): | |
| self.font_cmaps = {} | |
| for path in font_paths: | |
| try: | |
| with TTFont(path) as font: | |
| cmap = font['cmap'].getBestCmap() | |
| supported_chars = set(cmap.keys()) if cmap else set() | |
| self.font_cmaps[path] = supported_chars | |
| cham_block = range(0xAA00, 0xAA60) | |
| supports_cham = any(cp in supported_chars for cp in cham_block) | |
| if not supports_cham: | |
| print(f"⚠️ CẢNH BÁO: Font {os.path.basename(path)} không hỗ trợ dải ký tự Chăm (U+AA00 - U+AA5F)!") | |
| else: | |
| print(f"✅ Đã tải Font: {os.path.basename(path)} ({len(supported_chars)} ký tự)") | |
| except Exception as e: | |
| print(f"❌ Không thể đọc font {path}: {e}") | |
| self.font_cmaps[path] = set() | |
| def supports_string(self, font_path, text): | |
| supported_set = self.font_cmaps.get(font_path, set()) | |
| for char in text: | |
| code_point = ord(char) | |
| # Bỏ qua khoảng trắng, ký tự Latin ASCII và ký tự tiếng Việt thông thường | |
| if char in " \t\n\r" or code_point < 128 or (0x1EA0 <= code_point <= 0x1EF9): | |
| continue | |
| if code_point not in supported_set: | |
| return False | |
| return True | |
| # ============================================================================== | |
| # 4. Mô phỏng nét viết hình thái & Chất lượng giấy (Augmentation Utilities) | |
| # ============================================================================== | |
| def add_paper_noise(pil_img, strength='medium'): | |
| """Thêm nhiễu giấy Gauss và các đốm mực loang rải rác đè lên chữ""" | |
| arr = np.array(pil_img).astype(np.float32) | |
| sigma = {'light': 2.0, 'medium': 5.0, 'heavy': 8.0}.get(strength, 5.0) | |
| arr += np.random.normal(0, sigma, arr.shape).astype(np.float32) | |
| h, w = arr.shape[:2] | |
| dots = {'light': 2, 'medium': 6, 'heavy': 12}.get(strength, 6) | |
| for _ in range(random.randint(0, dots)): | |
| x = random.randint(0, max(0, w-1)) | |
| y = random.randint(0, max(0, h-1)) | |
| rr = random.randint(1, 2) | |
| val = random.randint(80, 180) | |
| cv2.circle(arr, (x, y), rr, (val, val, val), -1) | |
| return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) | |
| def apply_morphological_and_resolution_noise(pil_img): | |
| """Áp dụng các phép biến đổi mài mòn nét chữ (Erosion/Dilation) và pixelation""" | |
| arr = np.array(pil_img) | |
| # 1. Giả lập nhòe mực hoặc mất nét bút (Erosion/Dilation) | |
| if random.random() < 0.4: | |
| k = np.ones((2, 2), np.uint8) | |
| if random.random() < 0.5: | |
| arr = cv2.erode(arr, k, iterations=1) | |
| else: | |
| arr = cv2.dilate(arr, k, iterations=1) | |
| img = Image.fromarray(arr) | |
| # 2. Giả lập độ phân giải thấp (Pixelation) | |
| if random.random() < 0.3: | |
| w, h = img.size | |
| scale = random.uniform(0.75, 0.95) | |
| small = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.Resampling.BILINEAR) | |
| img = small.resize((w, h), Image.Resampling.BILINEAR) | |
| return img | |
| def get_augmentation_pipeline(): | |
| """Tạo pipeline tăng cường ảnh bằng Albumentations""" | |
| transform = A.Compose([ | |
| A.ShiftScaleRotate( | |
| shift_limit=0.03, | |
| scale_limit=0.03, | |
| rotate_limit=6, | |
| border_mode=cv2.BORDER_REPLICATE, | |
| p=0.7 | |
| ), | |
| A.Perspective(scale=(0.01, 0.025), keep_size=True, pad_mode=cv2.BORDER_REPLICATE, p=0.4), | |
| A.OneOf([ | |
| A.GaussianBlur(blur_limit=(3, 3), p=1.0), | |
| A.MotionBlur(blur_limit=(3, 5), p=1.0), | |
| ], p=0.35), | |
| A.RandomBrightnessContrast(brightness_limit=0.08, contrast_limit=0.08, p=0.6) | |
| ]) | |
| return transform | |
| def apply_augmentations(pil_img, transform): | |
| # 80% cơ hội trả về ảnh in sạch sắc nét (không nhiễu nhòe mực) | |
| if random.random() < 0.8: | |
| return pil_img | |
| # 20% cơ hội chạy tăng cường giả lập scan giấy | |
| img = apply_morphological_and_resolution_noise(pil_img) | |
| img_np = np.array(img) | |
| augmented = transform(image=img_np) | |
| final_img = Image.fromarray(augmented['image']) | |
| return add_paper_noise(final_img, strength='light') # Nhiễu nhẹ nhàng | |
| # ============================================================================== | |
| # 5. Vẽ Chữ Thích ứng Chiều rộng (Tránh Cắt Chữ) | |
| # ============================================================================== | |
| def create_random_background(width, height): | |
| bg_type = random.choice(['solid', 'gradient']) | |
| if bg_type == 'solid': | |
| bg = random.choice([(255, 255, 255), (250, 248, 240), (245, 240, 225), (253, 250, 244)]) | |
| return Image.new('RGB', (width, height), color=bg) | |
| else: | |
| # Gradient ngang nhẹ nhạt | |
| c1 = np.array([random.randint(245, 255), random.randint(245, 255), random.randint(240, 250)]) | |
| c2 = np.array([random.randint(235, 248), random.randint(235, 248), random.randint(225, 242)]) | |
| t = np.linspace(0, 1, height).reshape(height, 1, 1) | |
| gradient_np = (c1 * (1 - t) + c2 * t).astype(np.uint8) | |
| gradient_np = np.repeat(gradient_np, width, axis=1) | |
| return Image.fromarray(gradient_np) | |
| def draw_text_strip(text, font_path, font_size=22, img_width=280, img_height=40): | |
| # Chọn font_size thích hợp cho các từ ngắn/dài | |
| if len(text) <= 12 or any(ch in FINAL_SIGNS for ch in text): | |
| f_size = random.randint(24, 28) | |
| else: | |
| f_size = font_size | |
| font = get_cached_font(font_path, f_size) | |
| temp_img = Image.new('RGB', (1, 1)) | |
| draw = ImageDraw.Draw(temp_img) | |
| bbox = draw.textbbox((0, 0), text, font=font) | |
| text_w = bbox[2] - bbox[0] | |
| text_h = bbox[3] - bbox[1] | |
| # Bổ sung padding biên độ lớn giúp dạy mô hình nhận diện ranh giới dấu phụ | |
| pad_x = random.randint(10, 26) | |
| pad_y = random.randint(6, 12) | |
| current_width = max(img_width, text_w + pad_x * 2) | |
| current_height = max(img_height, text_h + pad_y * 2) | |
| bg = create_random_background(current_width, current_height) | |
| draw = ImageDraw.Draw(bg) | |
| # Căn giữa chữ | |
| x = (current_width - text_w) / 2 - bbox[0] | |
| y = (current_height - text_h) / 2 - bbox[1] | |
| ink = random.choice([(0, 0, 0), (20, 20, 20), (45, 42, 38)]) | |
| draw.text((x, y), text, font=font, fill=ink) | |
| return bg | |
| # ============================================================================== | |
| # 6. Quy trình Sinh Dữ Liệu từ Ngữ Liệu thực (Lexicon-Aware Pipeline) | |
| # ============================================================================== | |
| def generate_dataset(output_dir, font_dir, num_samples=5000, img_width=280, img_height=40, split_ratio=0.8, seed=42): | |
| print(f"=== Bắt đầu sinh {num_samples} mẫu dữ liệu tự động ===") | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| if not os.path.exists(font_dir): | |
| print(f"❌ Lỗi: Thư mục font không tồn tại: {font_dir}") | |
| return False | |
| font_files = sorted([os.path.join(font_dir, f) for f in os.listdir(font_dir) if f.endswith(('.ttf', '.otf'))]) | |
| if not font_files: | |
| print(f"❌ LỖI NGHIÊM TRỌNG: Không tìm thấy font trong '{font_dir}'.") | |
| return False | |
| validator = FontValidator(font_files) | |
| # --- ĐỌC NGỮ LIỆU THẬT & HARD EXAMPLES --- | |
| PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| corpus_file = os.path.join(PROJECT_ROOT, "data", "corpus", "cham_text.txt") | |
| hard_file = os.path.join(PROJECT_ROOT, "data", "hard_examples_v5.txt") | |
| dict_file = os.path.join(PROJECT_ROOT, "data", "cham_dict_v23.txt") | |
| # Đọc từ điển cơ bản để lọc ký tự hợp lệ | |
| allowed = set() | |
| if os.path.exists(dict_file): | |
| with open(dict_file, 'r', encoding='utf-8') as f: | |
| allowed = set(line.strip() for line in f if line.strip()) | |
| allowed = allowed | {' '} | CHAM_DIGITS | CHAM_PUNCT_SIGNS | |
| def normalize_label(s): | |
| s = re.sub(r'\s+', ' ', s.strip()) | |
| s = HEAL_REGEX.sub(r'\1', s) | |
| if allowed: | |
| s = ''.join(ch for ch in s if ch in allowed) | |
| # Loại bỏ các từ bắt đầu bằng combining mark đứng độc lập hoặc lỗi | |
| words = s.split() | |
| clean_words = [] | |
| for w in words: | |
| if not w: | |
| continue | |
| if w[0] in COMBINING_MARKS: | |
| continue | |
| clean_words.append(w) | |
| return ' '.join(clean_words).strip() | |
| # 1. Đọc và lọc tập ngữ liệu | |
| corpus_lines = [] | |
| if os.path.exists(corpus_file): | |
| with open(corpus_file, 'r', encoding='utf-8', errors='ignore') as f: | |
| for line in f: | |
| normalized = normalize_label(line) | |
| if normalized and not normalized.startswith('#'): | |
| corpus_lines.append(normalized) | |
| # 2. Đọc và lọc tập hard examples | |
| hard_lines = [] | |
| if os.path.exists(hard_file): | |
| with open(hard_file, 'r', encoding='utf-8', errors='ignore') as f: | |
| for line in f: | |
| normalized = normalize_label(line) | |
| if normalized: | |
| hard_lines.append(normalized) | |
| # Dự phòng nếu corpus trống | |
| if not corpus_lines: | |
| print("⚠️ Cảnh báo: Ngữ liệu thật trống. Sử dụng từ vựng mẫu mặc định.") | |
| corpus_lines = [normalize_label(x) for x in COMMON_CHAM_WORDS] | |
| corpus_lines = [x for x in corpus_lines if x] | |
| # Tách từ đơn (tokens) | |
| tokens = [] | |
| for line in corpus_lines + hard_lines: | |
| tokens.extend([t for t in line.split() if t]) | |
| # Tạo danh sách các token độc nhất | |
| unique_tokens = sorted(list(set(tokens))) | |
| short_tokens = [t for t in tokens if 1 <= len(t) <= 8] | |
| very_short_tokens = [t for t in tokens if 1 <= len(t) <= 5] | |
| focus_tokens = [t for t in tokens if is_focus_token(t)] | |
| pre_tokens = [t for t in tokens if has_pre(t)] | |
| final_tokens = [t for t in tokens if has_final(t)] | |
| pre_final_tokens = [t for t in tokens if has_pre(t) and has_final(t)] | |
| confusion_tokens = [t for t in tokens if any(ch in CONFUSION_CHARS for ch in t)] | |
| # Phân nhóm theo hình thái & signature | |
| shape_buckets = {} | |
| sig_buckets = {} | |
| for t in unique_tokens: | |
| shape_buckets.setdefault(shape_key(t), []).append(t) | |
| sig_buckets.setdefault(token_distance_key(t), []).append(t) | |
| # Chuẩn bị fallback các pool | |
| if not short_tokens: short_tokens = tokens[:] | |
| if not very_short_tokens: very_short_tokens = short_tokens[:] | |
| if not focus_tokens: focus_tokens = short_tokens[:] | |
| if not pre_tokens: pre_tokens = focus_tokens[:] | |
| if not final_tokens: final_tokens = focus_tokens[:] | |
| if not pre_final_tokens: pre_final_tokens = focus_tokens[:] | |
| if not confusion_tokens: confusion_tokens = focus_tokens[:] | |
| # --- CÁC PHƯƠNG PHÁP SINH TỪ VỰNG CHĂM-AWARE --- | |
| def choose(pool): | |
| return random.choice(pool) | |
| def hard_line_expanded(): | |
| s = choose(hard_lines) if hard_lines else choose(corpus_lines) | |
| if random.random() < 0.35: | |
| s = (s + ' ' + choose(pre_final_tokens)).strip() | |
| if random.random() < 0.25: | |
| s = (choose(very_short_tokens) + ' ' + s).strip() | |
| return s | |
| def minimal_pair_line(): | |
| if random.random() < 0.55: | |
| key = token_distance_key(choose(confusion_tokens)) | |
| bucket = sig_buckets.get(key, []) | |
| else: | |
| key = shape_key(choose(confusion_tokens)) | |
| bucket = shape_buckets.get(key, []) | |
| if len(bucket) >= 2: | |
| items = random.sample(bucket, min(len(bucket), random.randint(2, 5))) | |
| else: | |
| items = [choose(pre_tokens), choose(final_tokens), choose(very_short_tokens)] | |
| return ' '.join(items) | |
| def boundary_stress_line(): | |
| pools = [pre_tokens, final_tokens, very_short_tokens, pre_final_tokens] | |
| items = [choose(random.choice(pools)) for _ in range(random.randint(2, 5))] | |
| return ' '.join(items) | |
| def pre_sign_stress_line(): | |
| # Lấy các từ có chứa dấu đứng trước để tăng cường học vị trí liên kết CTC | |
| matching = [t for t in tokens if any(ch in PRE_SIGNS for ch in t)] | |
| if not matching: | |
| matching = focus_tokens | |
| items = [choose(matching) for _ in range(random.randint(2, 5))] | |
| return ' '.join(items) | |
| def cluster_drill_line(): | |
| if random.random() < 0.3: | |
| drill = choose(CLUSTER_DRILLS) | |
| return ' '.join([drill] * random.randint(2, 4)) | |
| return ' '.join(choose(focus_tokens) for _ in range(random.randint(2, 5))) | |
| def real_corpus_line(): | |
| s = choose(corpus_lines) | |
| words = s.split() | |
| if len(words) > 5 and random.random() < 0.65: | |
| start = random.randint(0, len(words) - 2) | |
| s = ' '.join(words[start:start + random.randint(2, min(5, len(words) - start))]) | |
| return s | |
| def sample_text(force_long=False, force_medium=False, force_short=False): | |
| for _ in range(50): | |
| r = random.random() | |
| if r < 0.08: | |
| if random.random() < 0.3: | |
| s = ' '.join(list('꩑꩒꩓꩔꩕꩖꩗꩘꩙꩐')) | |
| else: | |
| s = ' '.join(random.choice(list('꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙')) for _ in range(random.randint(2, 6))) | |
| elif hard_lines and r < 0.53: | |
| s = hard_line_expanded() | |
| elif r < 0.68: | |
| s = pre_sign_stress_line() | |
| elif r < 0.78: | |
| s = minimal_pair_line() | |
| elif r < 0.85: | |
| s = boundary_stress_line() | |
| elif r < 0.92: | |
| s = cluster_drill_line() | |
| elif random.random() < 0.40: | |
| s = real_corpus_line() | |
| if random.random() < 0.35: | |
| s = (s + ' ' + choose(final_tokens)).strip() | |
| else: | |
| s = ' '.join(choose(tokens) for _ in range(random.randint(2, 5))) | |
| if random.random() < 0.10 and s and not s.startswith('꩑'): | |
| p_type = random.choice(['prefix', 'postfix', 'both']) | |
| if p_type == 'prefix': | |
| s = '꩜ ' + s | |
| elif p_type == 'postfix': | |
| s = s + ' ' + random.choice(['꩝', '꩞', '꩟']) | |
| else: | |
| s = '꩜ ' + s + ' ' + random.choice(['꩝', '꩞', '꩟']) | |
| vis_len = len(unicode_to_visual(s)) | |
| if force_short: | |
| if vis_len <= 25: | |
| return s | |
| else: | |
| words = s.split() | |
| short_s = "" | |
| for w in words: | |
| test_s = (short_s + " " + w).strip() | |
| if len(unicode_to_visual(test_s)) <= 25: | |
| short_s = test_s | |
| else: | |
| break | |
| if short_s: | |
| return short_s | |
| return s[:15] | |
| elif force_medium: | |
| if 25 < vis_len <= 50: | |
| return s | |
| elif vis_len <= 25: | |
| med_s = s | |
| for _ in range(10): | |
| extra = sample_text(force_short=True) | |
| test_s = (med_s + " " + extra).strip() | |
| test_vis_len = len(unicode_to_visual(test_s)) | |
| if test_vis_len <= 50: | |
| med_s = test_s | |
| if test_vis_len > 25: | |
| return med_s | |
| else: | |
| break | |
| return med_s | |
| else: | |
| words = s.split() | |
| med_s = "" | |
| for w in words: | |
| test_s = (med_s + " " + w).strip() | |
| test_vis_len = len(unicode_to_visual(test_s)) | |
| if test_vis_len <= 50: | |
| med_s = test_s | |
| else: | |
| break | |
| if len(unicode_to_visual(med_s)) > 25: | |
| return med_s | |
| return s[:35] | |
| elif force_long: | |
| if 50 < vis_len <= 80: | |
| return s | |
| elif vis_len <= 50: | |
| long_s = s | |
| for _ in range(10): | |
| extra = sample_text(force_short=True) | |
| test_s = (long_s + " " + extra).strip() | |
| test_vis_len = len(unicode_to_visual(test_s)) | |
| if test_vis_len <= 80: | |
| long_s = test_s | |
| if test_vis_len > 50: | |
| return long_s | |
| else: | |
| break | |
| return long_s | |
| else: | |
| words = s.split() | |
| long_s = "" | |
| for w in words: | |
| test_s = (long_s + " " + w).strip() | |
| test_vis_len = len(unicode_to_visual(test_s)) | |
| if test_vis_len <= 80: | |
| long_s = test_s | |
| else: | |
| break | |
| if len(unicode_to_visual(long_s)) > 50: | |
| return long_s | |
| return s[:60] | |
| else: | |
| return s[:80] | |
| return choose(tokens) | |
| # --- KHỞI CHẠY TẠO FILE --- | |
| # Kiểm tra quy mô dữ liệu huấn luyện tối thiểu (chấp nhận 100 cho chạy thử nghiệm cục bộ) | |
| if num_samples < 30000 and num_samples != 100: | |
| raise ValueError(f"❌ Lỗi quy mô dữ liệu: Số lượng mẫu sinh ra ({num_samples}) nhỏ hơn ngưỡng tối thiểu yêu cầu (30,000 dòng)!") | |
| train_dir = os.path.join(output_dir, 'train') | |
| val_cs_dir = os.path.join(output_dir, 'val_clean_short') | |
| val_cl_dir = os.path.join(output_dir, 'val_clean_long') | |
| val_ns_dir = os.path.join(output_dir, 'val_noisy_short') | |
| val_nl_dir = os.path.join(output_dir, 'val_noisy_long') | |
| locked_dir = os.path.join(output_dir, 'locked_test') | |
| for d in [train_dir, val_cs_dir, val_cl_dir, val_ns_dir, val_nl_dir, locked_dir]: | |
| os.makedirs(d, exist_ok=True) | |
| transform_pipeline = get_augmentation_pipeline() | |
| n_val_cs = int(num_samples * 0.03) | |
| n_val_cl = int(num_samples * 0.03) | |
| n_val_ns = int(num_samples * 0.03) | |
| n_val_nl = int(num_samples * 0.03) | |
| n_locked = int(num_samples * 0.03) | |
| n_train = num_samples - (n_val_cs + n_val_cl + n_val_ns + n_val_nl + n_locked) | |
| # Chia nhỏ tập Train thành 6 sub-categories nội bộ theo tỷ lệ: | |
| # train_clean_short (25%), train_clean_medium (20%), train_clean_long (15%), | |
| # train_noisy_short (15%), train_noisy_long (15%), train_hard_examples (10%) | |
| n_train_cs = int(n_train * 0.25) | |
| n_train_cm = int(n_train * 0.20) | |
| n_train_cl = int(n_train * 0.15) | |
| n_train_ns = int(n_train * 0.15) | |
| n_train_nl = int(n_train * 0.15) | |
| n_train_he = n_train - (n_train_cs + n_train_cm + n_train_cl + n_train_ns + n_train_nl) | |
| categories = ( | |
| ['train_clean_short'] * n_train_cs + | |
| ['train_clean_medium'] * n_train_cm + | |
| ['train_clean_long'] * n_train_cl + | |
| ['train_noisy_short'] * n_train_ns + | |
| ['train_noisy_long'] * n_train_nl + | |
| ['train_hard_examples'] * n_train_he + | |
| ['val_clean_short'] * n_val_cs + | |
| ['val_clean_long'] * n_val_cl + | |
| ['val_noisy_short'] * n_val_ns + | |
| ['val_noisy_long'] * n_val_nl | |
| ) | |
| random.shuffle(categories) | |
| train_label_path = os.path.join(output_dir, 'train_label.txt') | |
| val_cs_label_path = os.path.join(output_dir, 'val_clean_short_label.txt') | |
| val_cl_label_path = os.path.join(output_dir, 'val_clean_long_label.txt') | |
| val_ns_label_path = os.path.join(output_dir, 'val_noisy_short_label.txt') | |
| val_nl_label_path = os.path.join(output_dir, 'val_noisy_long_label.txt') | |
| locked_label_path = os.path.join(output_dir, 'locked_test_label.txt') | |
| # Khởi tạo danh sách lưu độ dài cho cả 11 splits phục vụ vẽ histogram | |
| all_lengths = { | |
| "train_clean_short": [], | |
| "train_clean_medium": [], | |
| "train_clean_long": [], | |
| "train_noisy_short": [], | |
| "train_noisy_long": [], | |
| "train_hard_examples": [], | |
| "val_clean_short": [], | |
| "val_clean_long": [], | |
| "val_noisy_short": [], | |
| "val_noisy_long": [], | |
| "locked_test": [] | |
| } | |
| # 1. Sinh các tập thông thường (Train, Val splits) | |
| with open(train_label_path, 'w', encoding='utf-8', newline='\n') as f_train, \ | |
| open(val_cs_label_path, 'w', encoding='utf-8', newline='\n') as f_val_cs, \ | |
| open(val_cl_label_path, 'w', encoding='utf-8', newline='\n') as f_val_cl, \ | |
| open(val_ns_label_path, 'w', encoding='utf-8', newline='\n') as f_val_ns, \ | |
| open(val_nl_label_path, 'w', encoding='utf-8', newline='\n') as f_val_nl: | |
| success_count = 0 | |
| attempts = 0 | |
| max_attempts = num_samples * 20 | |
| while success_count < len(categories) and attempts < max_attempts: | |
| attempts += 1 | |
| category = categories[success_count] | |
| force_short = '_short' in category | |
| force_medium = '_medium' in category | |
| force_long = '_long' in category | |
| text = sample_text(force_long=force_long, force_medium=force_medium, force_short=force_short) | |
| matching_fonts = [f for f in font_files if validator.supports_string(f, text)] | |
| if not matching_fonts: | |
| continue | |
| selected_font = random.choice(matching_fonts) | |
| clean_img = draw_text_strip(text, selected_font, img_width=img_width, img_height=img_height) | |
| # Định nghĩa phân tách nhiễu theo Clean vs Noisy | |
| is_clean = 'clean' in category or category == 'train_hard_examples' | |
| is_noisy = 'noisy' in category | |
| if is_clean: | |
| if category.startswith('train_'): | |
| augmented_img = apply_augmentations(clean_img, transform_pipeline) | |
| else: | |
| augmented_img = clean_img | |
| elif is_noisy: | |
| img_morph = apply_morphological_and_resolution_noise(clean_img) | |
| img_np = np.array(img_morph) | |
| augmented = transform_pipeline(image=img_np) | |
| final_img = Image.fromarray(augmented['image']) | |
| augmented_img = add_paper_noise(final_img, strength='medium' if 'long' in category else 'light') | |
| else: | |
| augmented_img = apply_augmentations(clean_img, transform_pipeline) | |
| img_name = f"cham_synth_{success_count:06d}.png" | |
| visual_text = unicode_to_visual(text) | |
| vis_len = len(visual_text) | |
| if category.startswith('train_'): | |
| sub_dir = os.path.join(train_dir, category) | |
| os.makedirs(sub_dir, exist_ok=True) | |
| save_path = os.path.join(sub_dir, img_name) | |
| augmented_img.save(save_path) | |
| f_train.write(f"train/{category}/{img_name}\t{visual_text}\n") | |
| all_lengths[category].append(vis_len) | |
| elif category == 'val_clean_short': | |
| save_path = os.path.join(val_cs_dir, img_name) | |
| augmented_img.save(save_path) | |
| f_val_cs.write(f"val_clean_short/{img_name}\t{visual_text}\n") | |
| all_lengths[category].append(vis_len) | |
| elif category == 'val_clean_long': | |
| save_path = os.path.join(val_cl_dir, img_name) | |
| augmented_img.save(save_path) | |
| f_val_cl.write(f"val_clean_long/{img_name}\t{visual_text}\n") | |
| all_lengths[category].append(vis_len) | |
| elif category == 'val_noisy_short': | |
| save_path = os.path.join(val_ns_dir, img_name) | |
| augmented_img.save(save_path) | |
| f_val_ns.write(f"val_noisy_short/{img_name}\t{visual_text}\n") | |
| all_lengths[category].append(vis_len) | |
| elif category == 'val_noisy_long': | |
| save_path = os.path.join(val_nl_dir, img_name) | |
| augmented_img.save(save_path) | |
| f_val_nl.write(f"val_noisy_long/{img_name}\t{visual_text}\n") | |
| all_lengths[category].append(vis_len) | |
| success_count += 1 | |
| # 2. Sinh tập locked_test bằng cách cô lập seed ngẫu nhiên | |
| def seed_pipeline(pipeline, s_seed): | |
| for t in pipeline.transforms: | |
| t.set_random_seed(s_seed) | |
| if hasattr(t, 'transforms'): | |
| for sub_t in t.transforms: | |
| sub_t.set_random_seed(s_seed) | |
| r_state = random.getstate() | |
| np_state = np.random.get_state() | |
| locked_test_seed = 42 | |
| random.seed(locked_test_seed) | |
| np.random.seed(locked_test_seed) | |
| success_locked = 0 | |
| attempts_locked = 0 | |
| max_attempts_locked = n_locked * 20 | |
| with open(locked_label_path, 'w', encoding='utf-8', newline='\n') as f_locked: | |
| while success_locked < n_locked and attempts_locked < max_attempts_locked: | |
| attempts_locked += 1 | |
| sample_seed = locked_test_seed + success_locked | |
| random.seed(sample_seed) | |
| np.random.seed(sample_seed) | |
| seed_pipeline(transform_pipeline, sample_seed) | |
| # Chia ngẫu nhiên locked test 50/50 clean/noisy và short/long | |
| is_clean = (success_locked % 2 == 0) | |
| is_long = ((success_locked // 2) % 2 == 0) | |
| text = sample_text(force_long=is_long, force_medium=False, force_short=not is_long) | |
| matching_fonts = [f for f in font_files if validator.supports_string(f, text)] | |
| if not matching_fonts: | |
| continue | |
| selected_font = random.choice(matching_fonts) | |
| clean_img = draw_text_strip(text, selected_font, img_width=img_width, img_height=img_height) | |
| if is_clean: | |
| augmented_img = clean_img | |
| else: | |
| img_morph = apply_morphological_and_resolution_noise(clean_img) | |
| img_np = np.array(img_morph) | |
| augmented = transform_pipeline(image=img_np) | |
| final_img = Image.fromarray(augmented['image']) | |
| augmented_img = add_paper_noise(final_img, strength='light') | |
| img_name = f"cham_locked_{success_locked:06d}.png" | |
| visual_text = unicode_to_visual(text) | |
| vis_len = len(visual_text) | |
| save_path = os.path.join(locked_dir, img_name) | |
| augmented_img.save(save_path) | |
| f_locked.write(f"locked_test/{img_name}\t{visual_text}\n") | |
| all_lengths["locked_test"].append(vis_len) | |
| success_locked += 1 | |
| # Khôi phục trạng thái ngẫu nhiên để không ảnh hưởng đến code bên ngoài | |
| random.setstate(r_state) | |
| np.random.set_state(np_state) | |
| # Thư mục chứa bằng chứng | |
| evidence_dir = os.path.join(os.path.dirname(output_dir), "output", "evidence") | |
| os.makedirs(evidence_dir, exist_ok=True) | |
| # 3. Tính toán SHA256 và lưu file manifest | |
| import hashlib | |
| def file_sha256(path): | |
| h = hashlib.sha256() | |
| with open(path, 'rb') as f: | |
| while True: | |
| chunk = f.read(65536) | |
| if not chunk: | |
| break | |
| h.update(chunk) | |
| return h.hexdigest() | |
| locked_label_sha = file_sha256(locked_label_path) | |
| image_manifest = {} | |
| for i in range(success_locked): | |
| img_name = f"cham_locked_{i:06d}.png" | |
| img_path = os.path.join(locked_dir, img_name) | |
| if os.path.exists(img_path): | |
| image_manifest[img_name] = file_sha256(img_path) | |
| manifest_data = { | |
| "locked_test_seed": locked_test_seed, | |
| "locked_test_label_sha256": locked_label_sha, | |
| "image_checksum_manifest": image_manifest | |
| } | |
| with open(os.path.join(evidence_dir, "locked_test_manifest.json"), "w", encoding="utf-8") as f_manifest: | |
| json.dump(manifest_data, f_manifest, indent=4) | |
| # 4. Thống kê độ dài và lưu train label length analysis histogram cho tất cả 11 splits | |
| def compute_histogram_stats(lengths_list): | |
| arr = np.array(lengths_list) | |
| total = len(arr) | |
| if total == 0: | |
| return { | |
| "count": 0, "min": 0, "max": 0, "mean": 0.0, "median": 0.0, | |
| "bins": {"1-10": 0, "11-25": 0, "26-50": 0, "51-80": 0} | |
| } | |
| bins_range = [(1, 10), (11, 25), (26, 50), (51, 80)] | |
| dist = {} | |
| for start_b, end_b in bins_range: | |
| dist[f"{start_b}-{end_b}"] = int(np.sum((arr >= start_b) & (arr <= end_b))) | |
| return { | |
| "count": total, | |
| "min": int(np.min(arr)), | |
| "max": int(np.max(arr)), | |
| "mean": round(float(np.mean(arr)), 2), | |
| "median": round(float(np.median(arr)), 2), | |
| "bins": dist, | |
| "greater_than_25": int(np.sum(arr > 25)), | |
| "greater_than_80": int(np.sum(arr > 80)) | |
| } | |
| splits_stats = {} | |
| for split_name, lengths_list in all_lengths.items(): | |
| splits_stats[split_name] = compute_histogram_stats(lengths_list) | |
| # Gộp toàn bộ train lengths để tính toán tổng quát | |
| all_train_lengths = [] | |
| for split_name in ["train_clean_short", "train_clean_medium", "train_clean_long", | |
| "train_noisy_short", "train_noisy_long", "train_hard_examples"]: | |
| all_train_lengths.extend(all_lengths[split_name]) | |
| overall_train_stats = compute_histogram_stats(all_train_lengths) | |
| stats = { | |
| "dataset_metadata": { | |
| "total_samples": num_samples, | |
| "locked_test_seed": locked_test_seed, | |
| "label_files": { | |
| "train_label_sha256": file_sha256(train_label_path), | |
| "val_clean_short_label_sha256": file_sha256(val_cs_label_path), | |
| "val_clean_long_label_sha256": file_sha256(val_cl_label_path), | |
| "val_noisy_short_label_sha256": file_sha256(val_ns_label_path), | |
| "val_noisy_long_label_sha256": file_sha256(val_nl_label_path), | |
| "locked_test_label_sha256": locked_label_sha | |
| } | |
| }, | |
| "overall_train_statistics": overall_train_stats, | |
| "splits_detailed_statistics": splits_stats | |
| } | |
| with open(os.path.join(evidence_dir, "train_label_length_analysis.json"), "w", encoding="utf-8") as f: | |
| json.dump(stats, f, indent=4) | |
| print(f"🎉 Đã sinh xong thành công: Train = {len(all_train_lengths)} mẫu, Val_Clean_Short = {len(all_lengths['val_clean_short'])}, Val_Clean_Long = {len(all_lengths['val_clean_long'])}, Val_Noisy_Short = {len(all_lengths['val_noisy_short'])}, Val_Noisy_Long = {len(all_lengths['val_noisy_long'])}, Locked_Test = {len(all_lengths['locked_test'])}.") | |
| print(f"📊 Saved train label stats to output/evidence/train_label_length_analysis.json") | |
| print(f"🔒 Saved locked test manifest to output/evidence/locked_test_manifest.json") | |
| return True | |
| # ============================================================================== | |
| # 7. Hàm Tạo Từ Điển Ký Tự | |
| # ============================================================================== | |
| def build_dict(label_file_paths, output_dict_path): | |
| chars = set() | |
| for label_path in label_file_paths: | |
| if not os.path.exists(label_path): | |
| continue | |
| with open(label_path, 'r', encoding='utf-8') as f: | |
| for line in f: | |
| parts = line.strip().split('\t') | |
| if len(parts) >= 2: | |
| text = parts[1] | |
| for char in text: | |
| if char not in " \t\n\r": | |
| chars.add(char) | |
| sorted_chars = sorted(list(chars)) | |
| with open(output_dict_path, 'w', encoding='utf-8', newline='\n') as f_out: | |
| for c in sorted_chars: | |
| f_out.write(c + '\n') | |
| print(f"📖 Đã tạo từ điển tại {output_dict_path} với {len(sorted_chars)} ký tự.") | |
| # Từ vựng dự phòng khi không có corpus | |
| COMMON_CHAM_WORDS = [ | |
| "ꨀꨇꩉ ꨌꩌ", "Akhar Thrah", "Champa", "Việt Nam", "Campuchia", | |
| "ꨄꨈꨛꨞꨠ", "ꨆꨇꨉꨊꨋ", "ꨌꨍꨎꨏꨐ", "ꨑꨒꨓꨔꨕ", "ꨖꨗꨘꨙꨚ", | |
| "ꨛꨜꨝꨞꨟ", "ꨠꨡꨢꨣꨤ", "ꨥꨦꨧꨨꨩ", "ꨪꨫꨬꨭꨮ", "ꨯꨰꨱꨲꨳ", | |
| "ꨴꨵꨶ", "ꩀꩁꩂ", "ꩃꩄꩅꩆꩇ", "ꩈꩉꩊꩋꩌ" | |
| ] | |
| if __name__ == '__main__': | |
| PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| fonts_directory = os.path.join(PROJECT_ROOT, 'data', 'fonts') | |
| output_directory = os.path.join(PROJECT_ROOT, 'data', 'cham_synthetic_images') | |
| dictionary_file = os.path.join(PROJECT_ROOT, 'data', 'cham_dict_v23.txt') | |
| print("Thông tin thư mục chạy thử nghiệm:") | |
| print(f"- Thư mục font: {fonts_directory}") | |
| print(f"- Thư mục xuất ảnh: {output_directory}") | |
| print(f"- Đường dẫn từ điển: {dictionary_file}") | |
| has_fonts = False | |
| if os.path.exists(fonts_directory): | |
| has_fonts = any(f.endswith(('.ttf', '.otf')) for f in os.listdir(fonts_directory)) | |
| if has_fonts: | |
| success = generate_dataset(output_directory, fonts_directory, num_samples=100) | |
| if success: | |
| print("🎉 Sinh dữ liệu thử nghiệm 100 mẫu hoàn tất thành công.") | |
| else: | |
| print("\nℹ️ Nhắc nhở: Hãy sao chép ít nhất một font tiếng Chăm vào thư mục `data/fonts/` để chạy thử nghiệm.") | |