Spaces:
Running
Running
Download ocr-training/scripts/generate_detector_data_v2.py from phucsd/cham-ocr-studio: direct link, hf CLI and curl.
- Browser
- Download file 16.8 kB
-
https://huggingface.co/spaces/phucsd/cham-ocr-studio/resolve/main/ocr-training/scripts/generate_detector_data_v2.py
- Command line
-
hf download hf://spaces/phucsd/cham-ocr-studio/ocr-training/scripts/generate_detector_data_v2.py
-
curl -L -o generate_detector_data_v2.py https://huggingface.co/spaces/phucsd/cham-ocr-studio/resolve/main/ocr-training/scripts/generate_detector_data_v2.py
16.8 kB
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| Synthetic Text Detection Dataset Generator for Cham OCR (V2 - Tight Line Hard Cases). | |
| Generates realistic multi-line Cham document and manuscript pages with | |
| exact polygon bounding box labels in PaddleOCR SimpleDataSet format. | |
| Specially engineered to fix close line segmentation and tight leading issues: | |
| - 50% Ultra-Tight lines (y_spacing = font_size + 3..10px) | |
| - 30% Medium-Tight lines (y_spacing = font_size + 11..20px) | |
| - 20% Standard lines (y_spacing = font_size + 21..35px) | |
| - Multi-paragraph layouts with indentation and short ending lines | |
| - Multiprocessing for high-throughput generation on Cloud GPU/CPU environments. | |
| """ | |
| import os | |
| import sys | |
| import cv2 | |
| import json | |
| import random | |
| import argparse | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| from concurrent.futures import ProcessPoolExecutor | |
| # Force UTF-8 stdout encoding on Windows | |
| if sys.stdout and sys.stdout.encoding != 'utf-8': | |
| try: | |
| sys.stdout.reconfigure(encoding='utf-8') | |
| except AttributeError: | |
| pass | |
| # Monkeypatch NumPy 2.x compatibility | |
| if not hasattr(np, 'sctypes'): | |
| np.sctypes = { | |
| 'int': [np.int8, np.int16, np.int32, np.int64], | |
| 'uint': [np.uint8, np.uint16, np.uint32, np.uint64], | |
| 'float': [np.float16, np.float32, np.float64], | |
| 'complex': [np.complex64, np.complex128], | |
| 'others': [bool, object, bytes, str] | |
| } | |
| if not hasattr(np, 'bool'): np.bool = bool | |
| if not hasattr(np, 'int'): np.int = int | |
| if not hasattr(np, 'float'): np.float = float | |
| if not hasattr(np, 'typeDict'): np.typeDict = {} | |
| PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| sys.path.insert(0, os.path.join(PROJECT_ROOT, "scripts")) | |
| _FONT_CACHE = {} | |
| def get_cached_font(font_path, size): | |
| key = (font_path, size) | |
| if key not in _FONT_CACHE: | |
| if os.path.exists(font_path): | |
| try: | |
| _FONT_CACHE[key] = ImageFont.truetype(font_path, size) | |
| except Exception: | |
| _FONT_CACHE[key] = ImageFont.load_default() | |
| else: | |
| _FONT_CACHE[key] = ImageFont.load_default() | |
| return _FONT_CACHE[key] | |
| def apply_realistic_background(width, height): | |
| """ | |
| Generates realistic paper texture: modern book paper, aged newsprint, or antique parchment. | |
| """ | |
| style = random.random() | |
| if style < 0.40: | |
| # 1. Clean / Modern Book Paper (off-white, slight grain) | |
| base_val = random.randint(240, 253) | |
| img = np.full((height, width, 3), (base_val, base_val, base_val), dtype=np.uint8) | |
| noise = np.random.normal(0, 3, (height, width)).astype(np.float32) | |
| for c in range(3): | |
| img[:, :, c] = np.clip(img[:, :, c].astype(np.float32) + noise, 0, 255).astype(np.uint8) | |
| else: | |
| # 2. Aged Paper / Antique Parchment | |
| base_r = random.randint(220, 246) | |
| base_g = random.randint(210, 238) | |
| base_b = random.randint(185, 215) | |
| img = np.full((height, width, 3), (base_b, base_g, base_r), dtype=np.uint8) | |
| # Low frequency shading | |
| grid_h, grid_w = max(4, height // 50), max(4, width // 50) | |
| low_noise = np.random.normal(0, 10, (grid_h, grid_w)).astype(np.float32) | |
| low_noise = cv2.resize(low_noise, (width, height), interpolation=cv2.INTER_CUBIC) | |
| fine_noise = np.random.normal(0, 4, (height, width)).astype(np.float32) | |
| total_noise = low_noise + fine_noise | |
| for c in range(3): | |
| channel = img[:, :, c].astype(np.float32) + total_noise | |
| img[:, :, c] = np.clip(channel, 0, 255).astype(np.uint8) | |
| # Optional vignette | |
| if random.random() < 0.5: | |
| X = np.linspace(-1, 1, width) | |
| Y = np.linspace(-1, 1, height) | |
| mesh_x, mesh_y = np.meshgrid(X, Y) | |
| dist = np.sqrt(mesh_x**2 + mesh_y**2) | |
| vignette = 1.0 - np.clip((dist - 0.5) * 0.25, 0, 0.35) | |
| for c in range(3): | |
| img[:, :, c] = (img[:, :, c].astype(np.float32) * vignette).astype(np.uint8) | |
| return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) | |
| def warp_points(pts, matrix, amp, freq, phase): | |
| """ | |
| Applies perspective transformation and non-linear wave deformation to polygon vertices. | |
| """ | |
| pts_arr = np.array(pts, dtype=np.float32).reshape(-1, 1, 2) | |
| warped_persp = cv2.perspectiveTransform(pts_arr, matrix) | |
| final_pts = [] | |
| for pt in warped_persp: | |
| x, y = pt[0] | |
| dy = amp * np.sin(x * freq + phase) if amp > 0 else 0 | |
| final_pts.append([round(float(x), 1), round(float(y + dy), 1)]) | |
| return final_pts | |
| def generate_single_page(args_tuple): | |
| """ | |
| Worker function to generate a single labeled synthetic page. | |
| """ | |
| idx, sentences, fonts, out_dir, prefix = args_tuple | |
| width = random.choice([800, 900, 1024]) | |
| height = random.choice([600, 750, 900]) | |
| font_path = random.choice(fonts) | |
| font_size = random.randint(18, 26) | |
| font = get_cached_font(font_path, font_size) | |
| bg = apply_realistic_background(width, height) | |
| draw = ImageDraw.Draw(bg) | |
| # Line spacing distribution: | |
| # 50% Ultra-Tight (3..10px), 30% Medium-Tight (11..20px), 20% Standard (21..35px) | |
| r_mode = random.random() | |
| if r_mode < 0.50: | |
| gap = random.randint(3, 10) | |
| elif r_mode < 0.80: | |
| gap = random.randint(11, 20) | |
| else: | |
| gap = random.randint(21, 35) | |
| y_spacing = font_size + gap | |
| y_start = random.randint(30, 60) | |
| # Paragraph structure | |
| num_paras = random.randint(1, 3) | |
| lines_per_para = [] | |
| for _ in range(num_paras): | |
| lines_per_para.append(random.randint(2, 6)) | |
| line_boxes = [] | |
| current_y = y_start | |
| sent_idx = random.randint(0, max(0, len(sentences) - 20)) | |
| for para_i, num_l in enumerate(lines_per_para): | |
| for line_i in range(num_l): | |
| if current_y + font_size + 15 > height: | |
| break | |
| text = sentences[sent_idx % len(sentences)] | |
| sent_idx += 1 | |
| # Paragraph formatting: | |
| # - First line indented | |
| # - Last line of paragraph may be shorter | |
| is_first_line = (line_i == 0) | |
| is_last_line = (line_i == num_l - 1) | |
| x_pos = random.randint(60, 90) if is_first_line else random.randint(30, 45) | |
| # Trim text if too long for width | |
| while len(text) > 5: | |
| try: | |
| tb = font.getbbox(text) | |
| tw = tb[2] - tb[0] | |
| except Exception: | |
| tw = len(text) * int(font_size * 0.7) | |
| if x_pos + tw < width - 40: | |
| break | |
| parts = text.rsplit(' ', 1) | |
| text = parts[0] if len(parts) > 1 else text[:-2] | |
| if is_last_line and random.random() < 0.6: | |
| words = text.split() | |
| if len(words) > 3: | |
| cut = random.randint(3, len(words) - 1) | |
| text = ' '.join(words[:cut]) | |
| if not text.endswith(('꩞', '꩝')): | |
| text += random.choice(['꩞', '꩝']) | |
| if not text.strip(): | |
| continue | |
| ink_r = random.randint(15, 50) | |
| ink_g = random.randint(15, 45) | |
| ink_b = random.randint(15, 45) | |
| draw.text((x_pos, current_y), text, font=font, fill=(ink_r, ink_g, ink_b)) | |
| try: | |
| bbox = font.getbbox(text) | |
| text_w = bbox[2] - bbox[0] | |
| text_h = bbox[3] - bbox[1] | |
| except Exception: | |
| text_w = len(text) * int(font_size * 0.7) | |
| text_h = font_size | |
| pad_x = random.randint(2, 4) | |
| # When lines are ultra tight, pad_y must be tight to avoid overlapping labels | |
| pad_y = 1 if gap <= 6 else random.randint(2, 3) | |
| box = [ | |
| [x_pos - pad_x, current_y - pad_y], | |
| [x_pos + text_w + pad_x, current_y - pad_y], | |
| [x_pos + text_w + pad_x, current_y + text_h + pad_y], | |
| [x_pos - pad_x, current_y + text_h + pad_y] | |
| ] | |
| line_boxes.append((text, box)) | |
| current_y += y_spacing | |
| # Paragraph gap | |
| current_y += random.randint(12, 24) | |
| if current_y + font_size + 15 > height: | |
| break | |
| if not line_boxes: | |
| return None | |
| img_np = np.array(bg) | |
| h, w = img_np.shape[:2] | |
| # Geometric transformations: mild perspective & wave | |
| has_distortion = random.random() < 0.65 | |
| if has_distortion: | |
| amp = random.uniform(1.5, 4.0) | |
| freq = random.uniform(0.005, 0.010) | |
| phase = random.uniform(0, 2 * np.pi) | |
| pts1 = np.float32([[0, 0], [w, 0], [0, h], [w, h]]) | |
| skew_amt = 0.02 | |
| pts2 = np.float32([ | |
| [random.uniform(0, w * skew_amt), random.uniform(0, h * skew_amt)], | |
| [w - random.uniform(0, w * skew_amt), random.uniform(0, h * skew_amt)], | |
| [random.uniform(0, w * skew_amt), h - random.uniform(0, h * skew_amt)], | |
| [w - random.uniform(0, w * skew_amt), h - random.uniform(0, h * skew_amt)] | |
| ]) | |
| matrix = cv2.getPerspectiveTransform(pts1, pts2) | |
| x_idx = np.arange(w) | |
| y_idx = np.arange(h) | |
| X, Y = np.meshgrid(x_idx, y_idx) | |
| dy = amp * np.sin(X * freq + phase) | |
| map_x = X.astype(np.float32) | |
| map_y = (Y + dy).astype(np.float32) | |
| img_wave = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) | |
| img_warped = cv2.warpPerspective(img_wave, matrix, (w, h), borderMode=cv2.BORDER_REPLICATE) | |
| warped_labels = [] | |
| for text, box in line_boxes: | |
| w_box = warp_points(box, matrix, amp, freq, phase) | |
| warped_labels.append({ | |
| "transcription": text, | |
| "points": w_box | |
| }) | |
| else: | |
| img_warped = img_np | |
| warped_labels = [{ | |
| "transcription": text, | |
| "points": box | |
| } for text, box in line_boxes] | |
| img_name = f"{prefix}_{idx:05d}.png" | |
| img_path = os.path.join(out_dir, img_name) | |
| Image.fromarray(img_warped).save(img_path) | |
| return f"{prefix}/{img_name}\t{json.dumps(warped_labels, ensure_ascii=False)}" | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Generate synthetic Cham Detection dataset V2") | |
| parser.add_argument("--num_train", type=int, default=1000, help="Number of training pages") | |
| parser.add_argument("--num_val", type=int, default=100, help="Number of validation pages") | |
| parser.add_argument("--output_dir", type=str, default=None, help="Output directory") | |
| parser.add_argument("--workers", type=int, default=4, help="Number of parallel worker processes") | |
| args = parser.parse_args() | |
| print(f"🚀 Cham Detection Generator V2 (Tight Line Hard-Cases)") | |
| print(f" - Target Train: {args.num_train} pages") | |
| print(f" - Target Val: {args.num_val} pages") | |
| print(f" - Workers: {args.workers}") | |
| corpus_candidates = [ | |
| os.path.join(PROJECT_ROOT, "data", "corpus", "cham_text.txt"), | |
| os.path.join(PROJECT_ROOT, "ocr-training", "data", "corpus", "cham_text.txt"), | |
| os.path.join(os.getcwd(), "data", "corpus", "cham_text.txt"), | |
| os.path.join(os.getcwd(), "ocr-training", "data", "corpus", "cham_text.txt"), | |
| os.path.join(PROJECT_ROOT, "output_v22", "paddleocr_cham_finetune", "data", "corpus", "cham_text.txt"), | |
| os.path.join(PROJECT_ROOT, "data", "cham-ocr-v5-assets", "corpus", "corpus", "cham_text.txt") | |
| ] | |
| corpus_file = None | |
| for cand in corpus_candidates: | |
| if os.path.exists(cand): | |
| corpus_file = cand | |
| break | |
| if not corpus_file: | |
| print(f"📥 Chưa tìm thấy corpus cục bộ, đang tải cham_text.txt từ GitHub...") | |
| corpus_file = os.path.join(PROJECT_ROOT, "data", "corpus", "cham_text.txt") | |
| os.makedirs(os.path.dirname(corpus_file), exist_ok=True) | |
| try: | |
| import urllib.request | |
| raw_url = "https://raw.githubusercontent.com/phucsd/Cham-OCR/main/ocr-training/data/corpus/cham_text.txt" | |
| urllib.request.urlretrieve(raw_url, corpus_file) | |
| print(f"✅ Đã tải corpus thành công về: {corpus_file}") | |
| except Exception as e: | |
| print(f"⚠️ Không thể tải từ GitHub ({e}), tự động sinh corpus dự phòng...") | |
| # Fallback Cham sentence generator | |
| cham_consonants = list("ꨆꨇꨈꨉꨊꨋꨌꨍꨎꨏꨐꨑꨒꨓꨔꨕꨖꨗꨘꨙꨚꨛꨜꨝꨞꨟꨠꨡꨢꨣꨤꨥꨦꨧꨨꨀ") | |
| cham_vowels = list("ꨩꨪꨫꨬꨭꨮꨯꨰꨱꨲꨴꨵ") | |
| cham_finals = list("ꩀꩃꩌꩍꩆꩉꩊꩂꩅ") | |
| synthetic_corpus = [] | |
| for _ in range(5000): | |
| words = [] | |
| for _ in range(random.randint(4, 10)): | |
| syl = random.choice(cham_consonants) | |
| if random.random() < 0.3: syl += "ꨳ" | |
| if random.random() < 0.7: syl += random.choice(cham_vowels) | |
| if random.random() < 0.5: syl += random.choice(cham_finals) | |
| words.append(syl) | |
| synthetic_corpus.append(" ".join(words) + random.choice([" ꩞", " ꩝"])) | |
| with open(corpus_file, "w", encoding="utf-8") as f: | |
| f.write("\n".join(synthetic_corpus) + "\n") | |
| print(f"✅ Đã tạo {len(synthetic_corpus)} câu corpus dự phòng.") | |
| with open(corpus_file, "r", encoding="utf-8") as f: | |
| sentences = [line.strip() for line in f if line.strip() and not line.strip().startswith('#')] | |
| print(f" - Đã nạp {len(sentences)} dòng ngữ liệu Chăm ({corpus_file}).") | |
| fonts_dir = os.path.join(PROJECT_ROOT, "data", "fonts") | |
| if not os.path.exists(fonts_dir): | |
| fonts_dir = os.path.join(PROJECT_ROOT, "output_v22", "paddleocr_cham_finetune", "data", "fonts") | |
| font_names = ["NotoSansCham-Regular.ttf", "NotoSansCham-Bold.ttf", "NotoSansCham-Black.ttf"] | |
| fonts = [os.path.join(fonts_dir, f) for f in font_names if os.path.exists(os.path.join(fonts_dir, f))] | |
| if not fonts: | |
| fonts = [os.path.join(fonts_dir, "NotoSansCham-Regular.ttf")] | |
| print(f" - Using fonts: {[os.path.basename(f) for f in fonts]}") | |
| det_root = args.output_dir or os.path.join(PROJECT_ROOT, "data", "detector_v2") | |
| train_dir = os.path.join(det_root, "train_images") | |
| val_dir = os.path.join(det_root, "val_images") | |
| os.makedirs(train_dir, exist_ok=True) | |
| os.makedirs(val_dir, exist_ok=True) | |
| # 1. Generate Training Data | |
| print(f"\n⏳ Generating {args.num_train} training pages...") | |
| train_tasks = [(i, sentences, fonts, train_dir, "train_images") for i in range(args.num_train)] | |
| train_labels = [] | |
| with ProcessPoolExecutor(max_workers=args.workers) as executor: | |
| for res in executor.map(generate_single_page, train_tasks): | |
| if res: | |
| train_labels.append(res) | |
| if len(train_labels) % 200 == 0: | |
| print(f" ... Generated {len(train_labels)}/{args.num_train} training pages") | |
| with open(os.path.join(det_root, "det_train_label.txt"), "w", encoding="utf-8", newline="\n") as f: | |
| f.write("\n".join(train_labels) + "\n") | |
| print(f"✅ Finished training data: {len(train_labels)} pages saved.") | |
| # 2. Generate Validation Data | |
| print(f"\n⏳ Generating {args.num_val} validation pages...") | |
| val_tasks = [(i, sentences, fonts, val_dir, "val_images") for i in range(args.num_val)] | |
| val_labels = [] | |
| with ProcessPoolExecutor(max_workers=args.workers) as executor: | |
| for res in executor.map(generate_single_page, val_tasks): | |
| if res: | |
| val_labels.append(res) | |
| if len(val_labels) % 50 == 0: | |
| print(f" ... Generated {len(val_labels)}/{args.num_val} validation pages") | |
| with open(os.path.join(det_root, "det_val_label.txt"), "w", encoding="utf-8", newline="\n") as f: | |
| f.write("\n".join(val_labels) + "\n") | |
| print(f"✅ Finished validation data: {len(val_labels)} pages saved.") | |
| print(f"\n🎉 Dataset successfully generated at: {det_root}") | |
| print(f" - Label format: PaddleOCR SimpleDataSet (points + transcription)") | |
| if __name__ == '__main__': | |
| main() | |