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.")