[PaddleOCR] 학습용 REC 데이터셋 생성 스크립트(업데이트 버전)

2026. 1. 4. 18:55·3. 자습 & 메모(실전, 실습, 프로젝트)/3-2 메모(실전, 프로젝트)

기존 코드 데이터셋 생성 스크립트에서 단어구조 안정화, 한글 + 영어 데이터셋을 생성, 한글 폰트 깨짐을 폰트 다운로드를 통해 깨짐 방지를 먼저함.

LEXER_MAP = {
    '.py': 'python',
    '.js': 'javascript',
    '.mjs': 'javascript',
    '.ts': 'typescript',
    '.tsx': 'tsx',
    '.html': 'html',
    '.css': 'css',
    '.yml': 'yaml',
    '.yaml': 'yaml',
}

 

특정 언어에서 많이 보이는 패턴들의 기호들을 추출해냄.

SYMBOLS = [
    '(', ')', '{', '}', '[', ']', '.', ':', ';', '=', '+', '-', '*', '/', '<', '>', 
    '|', '_', '&', '$', '@', '!', '?', '%', '^', '~', '`', '#', '\\', '"', "'", ',',
    '::', '...', '->', '=>', '&&', '||', '??', '?:', '===', '!==', '>>', '<<', '>>>',
    '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '<=', '>=', '==', '!=',
    '\\n', '\\t', '\\r', '\\"', "\\'", '\\\\',
]

코드 전체 예시

"""
PaddleOCR 학습용 REC 데이터셋 생성 스크립트 (고급 버전)

코드 캡쳐본에서 영어+한글+특수기호를 공백/레이아웃까지 최대한 보존하여 추출
들여쓰기는 라벨에서 제거하고 bbox 기반 후처리로 복원 (정책1)

특징:
- 버킷 기반 샘플링 (word_short, phrase_short, line_mid, line_long, symbol_focus, korean_focus)
- 한글 데이터 비율 강제 (35~50%)
- 실제 캡쳐 노이즈 증강 (30~60%)
- 롱 라인 샘플 (120~220자, 10~20%)
- 기호 커버리지 강화
- CLI 옵션 지원
"""

import sys
import os
import random
import re
import argparse
from pathlib import Path
from typing import List, Tuple, Optional, Dict
from tqdm import tqdm
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageEnhance, ImageFilter
import cv2

# chardet 라이브러리 (인코딩 자동 감지)
try:
    import chardet
    CHARDET_AVAILABLE = True
except ImportError:
    CHARDET_AVAILABLE = False
    print("[WARN] chardet이 설치되지 않았습니다. UTF-8로만 읽습니다.")

# Pygments 관련
try:
    from pygments.lexers import get_lexer_by_name
    PYGMENTS_AVAILABLE = True
except ImportError:
    PYGMENTS_AVAILABLE = False
    print("[WARN] Pygments가 설치되지 않았습니다. 문법 강조 없이 진행합니다.")

# UTF-8 출력 설정
if sys.platform == 'win32':
    import io
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')

# ============================================================================
# 설정 (CLI로 오버라이드 가능)
# ============================================================================

# 프로젝트 루트
WORKSPACE_ROOT = Path(__file__).parent.parent.parent.parent
CORE_TRAIN_DIR = Path(__file__).parent
TRAIN_DATA_DIR = CORE_TRAIN_DIR / "train_data"
REC_DIR = TRAIN_DATA_DIR / "rec"
REC_TRAIN_IMAGES_DIR = REC_DIR / "train"
REC_VAL_IMAGES_DIR = REC_DIR / "val"
REC_GT_TRAIN_FILE = REC_DIR / "rec_gt_train.txt"
REC_GT_VAL_FILE = REC_DIR / "rec_gt_test.txt"

# 기본 목표 개수
DEFAULT_TARGET_TRAIN_COUNT = 25000
DEFAULT_TARGET_VAL_COUNT = 5000

# 제외할 디렉토리/파일
EXCLUDE_DIRS = {
    '__pycache__', 'node_modules', '.git', 'venv', 'venv_ocr',
    'outputs', 'output', 'train_data', 'code_syntax_dataset',
    '.next', 'dist', 'build', '.venv'
}

EXCLUDE_FILES = {
    'rec_gt.txt', 'rec_gt_train.txt', 'rec_gt_test.txt'
}

# 지원하는 파일 확장자
CODE_EXTENSIONS = {'.py', '.js', '.html', '.css', '.yml', '.yaml', '.ts', '.tsx', '.mjs'}

# 파일 확장자별 Lexer 매핑
LEXER_MAP = {
    '.py': 'python',
    '.js': 'javascript',
    '.mjs': 'javascript',
    '.ts': 'typescript',
    '.tsx': 'tsx',
    '.html': 'html',
    '.css': 'css',
    '.yml': 'yaml',
    '.yaml': 'yaml',
}

# 폰트 우선순위 (영문 + 한글 지원 모노스페이스 폰트)
FONT_PRIORITY = [
    'D2Coding',  # 한글 지원 모노스페이스 (우선순위 1)
    'D2CodingBold',
    'Noto Sans Mono CJK KR',  # 한글 지원 모노스페이스
    'Malgun Gothic',  # Windows 한글 기본 폰트 (모노스페이스는 아니지만 대체용)
    'Fira Code',
    'JetBrains Mono',
    'Consolas',
    'Courier New',
    'Lucida Console',
    'Monaco',
    'Menlo',
    'DejaVu Sans Mono',
]

# 한글 지원 폰트 필수 여부 (CLI로 오버라이드)
DEFAULT_REQUIRE_KOREAN_FONT = False

# 버킷 설정 (CLI로 오버라이드 가능)
BUCKET_RATIOS = {
    'word_short': 0.25,      # 1~20자 단어
    'phrase_short': 0.15,    # 2~3단어 <= 25자
    'line_mid': 0.30,        # 40~120자
    'line_long': 0.15,       # 120~220자
    'symbol_focus': 0.10,    # 기호 집중
    'korean_focus': 0.05,    # 한글 집중 (추가로 한글 비율 35~50% 강제)
}

# 한글 비율 강제 (전체 샘플 중 최소 비율)
KOREAN_MIN_RATIO = 0.35
KOREAN_MAX_RATIO = 0.50

# 증강 설정
AUGMENTATION_PROB = 0.45  # 30~60% 범위, 기본 45%
AUGMENTATION_CONFIG = {
    'scale_range': (0.85, 1.15),
    'blur_sigma': (0.3, 0.8),
    'sharpen_factor': (0.9, 1.1),
    'jpeg_quality_range': (60, 95),
    'noise_std': 3.0,
    'rotation_range': (-1.0, 1.0),
}

# 라인 샘플 길이 범위
LINE_MID_MIN = 40
LINE_MID_MAX = 120
LINE_LONG_MIN = 120
LINE_LONG_MAX = 220

# 특수 기호 집중 학습용 기호 (강화)
SYMBOLS = [
    '(', ')', '{', '}', '[', ']', '.', ':', ';', '=', '+', '-', '*', '/', '<', '>', 
    '|', '_', '&', '$', '@', '!', '?', '%', '^', '~', '`', '#', '\\', '"', "'", ',',
    '::', '...', '->', '=>', '&&', '||', '??', '?:', '===', '!==', '>>', '<<', '>>>',
    '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '<=', '>=', '==', '!=',
    '\\n', '\\t', '\\r', '\\"', "\\'", '\\\\',
]

# 한글 주석/문자열 템플릿 (fallback용)
KOREAN_TEMPLATES = [
    # 주석
    '# 한글 주석 예시',
    '// 한글 주석',
    '/* 한글 주석 */',
    '# TODO: 한글 작업',
    '# FIXME: 한글 버그 수정',
    # 문자열
    '"한글 문자열"',
    "'한글 문자열'",
    '`한글 템플릿 ${변수}`',
    'f"한글 포맷 {value}"',
    # 변수명/함수명
    '한글변수 = 10',
    'def 한글함수():',
    'class 한글클래스:',
    # 혼합
    'print("한글 출력")',
    'console.log("한글 로그")',
    'return "한글 반환값"',
]

# 테마 설정
DARK_THEMES = [
    {
        'bg': (30, 30, 30),
        'text': (212, 212, 212),
        'comment': (106, 153, 85),
        'keyword': (86, 156, 214),
        'string': (206, 145, 120),
        'number': (181, 206, 168),
        'name': (156, 220, 254),
    },
    {
        'bg': (25, 25, 35),
        'text': (200, 200, 220),
        'comment': (100, 150, 100),
        'keyword': (80, 150, 210),
        'string': (200, 140, 115),
        'number': (175, 200, 165),
        'name': (150, 215, 250),
    },
    {
        'bg': (28, 28, 28),
        'text': (248, 248, 242),
        'comment': (117, 113, 94),
        'keyword': (249, 38, 114),
        'string': (230, 219, 116),
        'number': (174, 129, 255),
        'name': (102, 217, 239),
    },
]

LIGHT_THEMES = [
    {
        'bg': (255, 255, 255),
        'text': (30, 30, 30),
        'comment': (0, 128, 0),
        'keyword': (0, 0, 255),
        'string': (163, 21, 21),
        'number': (0, 128, 128),
        'name': (0, 0, 128),
    },
]

# ============================================================================
# 유틸리티 함수
# ============================================================================

def contains_hangul(text: str) -> bool:
    """한글 포함 여부 확인"""
    return any('\uac00' <= char <= '\ud7a3' for char in text)


def has_symbol(text: str) -> bool:
    """특수 기호 포함 여부 확인"""
    return any(sym in text for sym in SYMBOLS)


def find_code_files(root: Path) -> List[Path]:
    """워크스페이스 내 모든 코드 파일 찾기"""
    code_files = []
    for ext in CODE_EXTENSIONS:
        for file_path in root.rglob(f'*{ext}'):
            if any(excluded in file_path.parts for excluded in EXCLUDE_DIRS):
                continue
            if file_path.name in EXCLUDE_FILES:
                continue
            if file_path.is_file() and file_path.stat().st_size > 0:
                code_files.append(file_path)
    return code_files


def extract_code_lines(file_path: Path) -> List[str]:
    """파일에서 코드 라인 추출 (한글 포함, 들여쓰기 선행 공백 보존)"""
    lines = []
    try:
        if CHARDET_AVAILABLE:
            rawdata = file_path.read_bytes()
            result = chardet.detect(rawdata)
            encoding = result['encoding'] if result['encoding'] else 'utf-8'
            try:
                content = rawdata.decode('utf-8-sig', errors='replace')
            except:
                try:
                    content = rawdata.decode(encoding, errors='replace')
                except:
                    content = rawdata.decode('utf-8', errors='replace')
        else:
            try:
                content = file_path.read_text(encoding='utf-8-sig', errors='replace')
            except:
                content = file_path.read_text(encoding='utf-8', errors='replace')
    except Exception:
        return lines
    
    for line in content.split('\n'):
        line = line.rstrip('\n\r')
        if not line.strip():
            continue
        if len(line.strip()) < 2:
            continue
        if len(line) > 250:  # 롱 라인까지 포함하도록 상향
            continue
        lines.append(line)
    
    return lines


def split_into_words_and_phrases(line: str) -> List[str]:
    """라인을 단어 및 짧은 구문으로 분할 (정책1: 선행 공백 제거)"""
    segments = []
    line_stripped = line.lstrip()
    if not line_stripped:
        return segments
    
    words = line_stripped.split()
    if not words:
        return segments
    
    # 1. 단일 단어/기호 (word_short)
    for word in words:
        if len(word) <= 20:
            segments.append(('word_short', word))
    
    # 2. 2개 단어 조합 (phrase_short)
    for i in range(len(words) - 1):
        combo = words[i] + ' ' + words[i+1]
        if len(combo) <= 25:
            segments.append(('phrase_short', combo))
    
    # 3. 3개 단어 조합 (phrase_short)
    for i in range(len(words) - 2):
        combo = words[i] + ' ' + words[i+1] + ' ' + words[i+2]
        if len(combo) <= 25:
            segments.append(('phrase_short', combo))
    
    return segments


def extract_line_samples(lines: List[str]) -> Tuple[List[str], List[str]]:
    """라인/구문 단위 샘플 추출 (mid: 40~120자, long: 120~220자)"""
    line_mid = []
    line_long = []
    
    for line in lines:
        line_stripped = line.lstrip()
        if not line_stripped:
            continue
        length = len(line_stripped)
        if LINE_MID_MIN <= length <= LINE_MID_MAX:
            line_mid.append(line_stripped)
        elif LINE_LONG_MIN <= length <= LINE_LONG_MAX:
            line_long.append(line_stripped)
    
    return line_mid, line_long


def generate_symbol_focused_segments() -> List[str]:
    """기호 집중 학습용 세그먼트 생성 (강화)"""
    segments = []
    
    # 단독 기호
    for sym in SYMBOLS[:30]:
        segments.append(sym)
    
    # 기호 조합 (강화)
    symbol_combos = [
        '()', '{}', '[]', '==', '!=', '<=', '>=', '+=', '-=', '*=', '/=',
        '->', '=>', '::', '...', '/*', '*/', '//', '&&', '||', '??', '?:',
        '===', '!==', '>>', '<<', '>>>', '+=', '-=', '*=', '/=', '%=',
        '&=', '|=', '^=', '<<=', '>>=', '\\n', '\\t', '\\"', "\\'", '\\\\',
    ]
    segments.extend(symbol_combos)
    
    # 기호 + 식별자/숫자/괄호/공백 혼합 (비중 높임)
    common_words = ['if', 'for', 'while', 'def', 'class', 'import', 'from', 'return', 
                    'const', 'let', 'var', 'function', 'async', 'await', 'export']
    operators = ['(', '{', '[', '.', ':', '=', '+', '-', '*', '/', '<', '>', '|', '&']
    
    for word in common_words:
        for op in operators:
            # 기호 + 단어
            segments.append(op + word)
            segments.append(word + op)
            segments.append(op + ' ' + word)
            segments.append(word + ' ' + op)
            # 기호 + 숫자
            for num in ['0', '1', '2', '10', '100']:
                segments.append(op + num)
                segments.append(num + op)
    
    # 정규식/경로/URL 형태
    regex_patterns = [
        r'/^[a-z]+$/',
        r'/\d+/g',
        r'\.(jpg|png|gif)',
        r'https?://[^\s]+',
        r'C:\\Users\\[^\\]+',
        r'/[a-z]+/[a-z]+',
    ]
    segments.extend(regex_patterns)
    
    # 문자열 이스케이프
    escape_patterns = [
        r'\\n', r'\\t', r'\\r', r'\\"', r"\\'", r'\\\\',
        r'\u0041', r'\uAC00', r'\n', r'\t',
    ]
    segments.extend(escape_patterns)
    
    return segments


def generate_korean_synthetic_segments() -> List[str]:
    """한글 합성 세그먼트 생성 (fallback용)"""
    segments = []
    
    # 템플릿 기반 생성
    for template in KOREAN_TEMPLATES:
        segments.append(template)
    
    # 코드 라인에 한글 삽입
    code_prefixes = ['def ', 'class ', 'const ', 'let ', 'var ', 'function ']
    korean_suffixes = ['한글함수', '한글클래스', '한글변수', '한글상수']
    
    for prefix in code_prefixes:
        for suffix in korean_suffixes:
            segments.append(prefix + suffix + '()')
            segments.append(prefix + suffix + ' = value')
    
    return segments


def load_font(font_size: int, require_korean: bool = False) -> Tuple[Optional[ImageFont.FreeTypeFont], bool]:
    """폰트 로드 (한글 지원 여부 확인)"""
    korean_font_loaded = False
    
    for font_name in FONT_PRIORITY:
        try:
            if sys.platform == 'win32':
                font_paths = [
                    f"C:/Windows/Fonts/{font_name}.ttf",
                    f"C:/Windows/Fonts/{font_name}.otf",
                    f"C:/Windows/Fonts/{font_name.replace(' ', '')}.ttf",
                    f"C:/Windows/Fonts/{font_name.replace(' ', '')}.otf",
                ]
                for font_path in font_paths:
                    if os.path.exists(font_path):
                        try:
                            font = ImageFont.truetype(font_path, font_size)
                            if font_name in ['D2Coding', 'D2CodingBold', 'Noto Sans Mono CJK KR', 'Malgun Gothic']:
                                korean_font_loaded = True
                            return font, korean_font_loaded
                        except Exception:
                            continue
            try:
                font = ImageFont.truetype(font_name, font_size)
                if font_name in ['D2Coding', 'D2CodingBold', 'Noto Sans Mono CJK KR', 'Malgun Gothic']:
                    korean_font_loaded = True
                return font, korean_font_loaded
            except:
                continue
        except:
            continue
    
    try:
        return ImageFont.load_default(), False
    except:
        return None, False


def tokenize_with_pygments(code: str, file_ext: str) -> List[Tuple[str, str]]:
    """Pygments로 토큰화"""
    if not PYGMENTS_AVAILABLE:
        return [('Text', code)]
    try:
        lexer_name = LEXER_MAP.get(file_ext, 'text')
        lexer = get_lexer_by_name(lexer_name)
        tokens = list(lexer.get_tokens(code))
        return [(str(token_type), text) for token_type, text in tokens]
    except:
        return [('Text', code)]


def get_token_color(token_type: str, theme: dict) -> tuple:
    """토큰 타입에 따른 색상 반환"""
    token_str = str(token_type)
    if 'Comment' in token_str:
        return theme.get('comment', theme['text'])
    elif 'Keyword' in token_str or 'Name.Builtin' in token_str:
        return theme.get('keyword', theme['text'])
    elif 'String' in token_str:
        return theme.get('string', theme['text'])
    elif 'Number' in token_str:
        return theme.get('number', theme['text'])
    elif 'Name' in token_str:
        return theme.get('name', theme['text'])
    else:
        return theme['text']


def render_text_with_syntax(
    text: str,
    file_ext: str,
    font: ImageFont.FreeTypeFont,
    theme: dict,
    target_height: int = 48,
    max_width: int = 640,
    padding: int = 10,
    min_font_size: int = 8
) -> Image.Image:
    """문법 강조를 적용한 텍스트 이미지 생성 (롱 라인 지원)"""
    tokens = tokenize_with_pygments(text, file_ext)
    
    temp_img = Image.new("RGB", (3000, 200), theme['bg'])
    temp_draw = ImageDraw.Draw(temp_img)
    
    total_width = 0
    max_token_height = 0
    
    for token_type, token_text in tokens:
        bbox = temp_draw.textbbox((0, 0), token_text, font=font)
        token_width = bbox[2] - bbox[0]
        token_height = bbox[3] - bbox[1]
        total_width += token_width
        max_token_height = max(max_token_height, token_height)
    
    available_width = max_width - (padding * 2)
    available_height = target_height - (padding * 2)
    
    # 폰트 크기 조정 (롱 라인 지원)
    if total_width > available_width or max_token_height > available_height:
        scale_w = available_width / total_width if total_width > 0 else 1.0
        scale_h = available_height / max_token_height if max_token_height > 0 else 1.0
        scale = min(scale_w, scale_h, 1.0) * 0.90
        
        try:
            current_size = font.size if hasattr(font, 'size') else 16
        except:
            current_size = 16
        new_size = max(min_font_size, int(current_size * scale))
        new_font, _ = load_font(new_size, require_korean=False)
        if new_font is not None:
            font = new_font
        
        # 다시 측정
        temp_draw = ImageDraw.Draw(temp_img)
        total_width = 0
        max_token_height = 0
        for token_type, token_text in tokens:
            bbox = temp_draw.textbbox((0, 0), token_text, font=font)
            token_width = bbox[2] - bbox[0]
            token_height = bbox[3] - bbox[1]
            total_width += token_width
            max_token_height = max(max_token_height, token_height)
    
    # 최종 이미지 크기 계산 (롱 라인은 우측 패딩 확장)
    if total_width + padding * 2 > max_width:
        # 우측 패딩 확장 (글자 간격 유지)
        final_width = int(total_width + padding * 2 + 4)
    else:
        final_width = min(max_width, int(total_width + padding * 2 + 4))
    
    final_height = target_height
    
    img = Image.new("RGB", (final_width, final_height), theme['bg'])
    draw = ImageDraw.Draw(img)
    
    x_pos = padding
    y_pos = (final_height - max_token_height) // 2
    
    for token_type, token_text in tokens:
        color = get_token_color(token_type, theme)
        draw.text((x_pos, y_pos), token_text, fill=color, font=font)
        bbox = draw.textbbox((x_pos, y_pos), token_text, font=font)
        x_pos = bbox[2]
    
    return img


def apply_augmentation(img: Image.Image, config: Dict) -> Image.Image:
    """실제 캡쳐 노이즈 증강 적용"""
    img_array = np.array(img)
    
    # 스케일 변화 + 리샘플링
    if random.random() < 0.7:
        scale = random.uniform(*config['scale_range'])
        h, w = img_array.shape[:2]
        new_h, new_w = int(h * scale), int(w * scale)
        img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
        img_array = cv2.resize(img_array, (w, h), interpolation=cv2.INTER_LINEAR)
        img = Image.fromarray(img_array)
    
    # 약한 블러
    if random.random() < 0.4:
        sigma = random.uniform(*config['blur_sigma'])
        img = img.filter(ImageFilter.GaussianBlur(radius=sigma))
    
    # 샤프닝 편차
    if random.random() < 0.3:
        factor = random.uniform(*config['sharpen_factor'])
        enhancer = ImageEnhance.Sharpness(img)
        img = enhancer.enhance(factor)
    
    # 가우시안 노이즈
    if random.random() < 0.3:
        noise = np.random.normal(0, config['noise_std'], img_array.shape).astype(np.float32)
        img_array = np.clip(img_array.astype(np.float32) + noise, 0, 255).astype(np.uint8)
        img = Image.fromarray(img_array)
    
    # 아주 약한 회전
    if random.random() < 0.2:
        angle = random.uniform(*config['rotation_range'])
        img = img.rotate(angle, resample=Image.BICUBIC, expand=False, fillcolor=img.getpixel((0, 0)))
    
    return img


def enhance_image_quality(img: Image.Image) -> Image.Image:
    """이미지 품질 향상"""
    enhancer = ImageEnhance.Contrast(img)
    img = enhancer.enhance(1.2)
    enhancer = ImageEnhance.Sharpness(img)
    img = enhancer.enhance(1.3)
    return img


def is_valid_segment(segment: str, max_length: int = 220) -> bool:
    """세그먼트 유효성 검증"""
    if len(segment.strip()) < 1:
        return False
    if len(segment) > max_length:
        return False
    if re.match(r'^\d+$', segment.strip()):
        return False
    return True


def collect_segments_by_bucket() -> Dict[str, List[str]]:
    """버킷별 세그먼트 수집"""
    print("=" * 70)
    print("코드 파일 스캔 및 버킷별 세그먼트 추출 중...")
    print("=" * 70)
    
    code_files = find_code_files(WORKSPACE_ROOT)
    print(f"발견된 코드 파일: {len(code_files):,}개")
    
    buckets = {
        'word_short': [],
        'phrase_short': [],
        'line_mid': [],
        'line_long': [],
        'symbol_focus': [],
        'korean_focus': [],
    }
    
    all_lines = []
    korean_lines = []
    
    for file_path in tqdm(code_files, desc="파일 읽기"):
        lines = extract_code_lines(file_path)
        all_lines.extend(lines)
        
        # 한글 포함 라인 우선 수집
        for line in lines:
            if contains_hangul(line):
                korean_lines.append(line)
        
        # 단어/구문 단위 세그먼트
        for line in lines:
            segments = split_into_words_and_phrases(line)
            for bucket_type, segment in segments:
                if is_valid_segment(segment, max_length=25):
                    if contains_hangul(segment):
                        buckets['korean_focus'].append(segment)
                    elif has_symbol(segment):
                        buckets['symbol_focus'].append(segment)
                    else:
                        buckets[bucket_type].append(segment)
    
    # 라인 샘플 추출
    line_mid, line_long = extract_line_samples(all_lines)
    buckets['line_mid'].extend(line_mid)
    buckets['line_long'].extend(line_long)
    
    # 한글 라인 샘플 추가
    korean_mid, korean_long = extract_line_samples(korean_lines)
    buckets['line_mid'].extend(korean_mid)
    buckets['line_long'].extend(korean_long)
    buckets['korean_focus'].extend(korean_mid[:100])  # 한글 라인 샘플 일부 추가
    buckets['korean_focus'].extend(korean_long[:50])
    
    # 기호 집중 세그먼트 추가
    symbol_focused = generate_symbol_focused_segments()
    buckets['symbol_focus'].extend(symbol_focused)
    
    # 한글 합성 세그먼트 (fallback)
    if len(buckets['korean_focus']) < 1000:
        korean_synthetic = generate_korean_synthetic_segments()
        buckets['korean_focus'].extend(korean_synthetic)
    
    print(f"\n추출된 세그먼트 (버킷별):")
    for bucket_name, segments in buckets.items():
        print(f"  {bucket_name}: {len(segments):,}개")
    
    return buckets


def sample_with_korean_ratio(
    buckets: Dict[str, List[str]],
    total_count: int,
    korean_min_ratio: float,
    korean_max_ratio: float,
    bucket_ratios: Dict[str, float]
) -> List[str]:
    """한글 비율을 강제하여 샘플링"""
    all_segments = []
    
    # 버킷별 목표 개수 계산
    bucket_targets = {}
    for bucket_name, ratio in bucket_ratios.items():
        bucket_targets[bucket_name] = int(total_count * ratio)
    
    # 한글 최소 개수 계산
    korean_min_count = int(total_count * korean_min_ratio)
    korean_max_count = int(total_count * korean_max_ratio)
    
    # 각 버킷에서 샘플링
    for bucket_name, target_count in bucket_targets.items():
        available = buckets.get(bucket_name, [])
        if len(available) >= target_count:
            sampled = random.sample(available, target_count)
        else:
            sampled = available.copy()
            # 부족하면 변형으로 다양성 확보
            while len(sampled) < target_count and len(available) > 0:
                base = random.choice(available)
                # 간단한 변형 (공백 추가/제거, 기호 변경 등)
                variants = [
                    base,
                    base.replace('  ', ' '),
                    base.replace(' ', '  '),
                ]
                sampled.extend(variants[:target_count - len(sampled)])
        all_segments.extend(sampled)
    
    # 한글 비율 확인 및 조정
    korean_count = sum(1 for s in all_segments if contains_hangul(s))
    
    if korean_count < korean_min_count:
        # 한글 부족 시 korean_focus 버킷에서 추가
        korean_needed = korean_min_count - korean_count
        korean_available = [s for s in buckets['korean_focus'] if s not in all_segments]
        if len(korean_available) >= korean_needed:
            all_segments.extend(random.sample(korean_available, korean_needed))
        else:
            all_segments.extend(korean_available)
            # 합성 세그먼트 추가
            synthetic = generate_korean_synthetic_segments()
            all_segments.extend(synthetic[:korean_needed - len(korean_available)])
    
    elif korean_count > korean_max_count:
        # 한글 과다 시 일부 제거
        korean_segments = [s for s in all_segments if contains_hangul(s)]
        non_korean_segments = [s for s in all_segments if not contains_hangul(s)]
        excess = korean_count - korean_max_count
        korean_segments = korean_segments[:-excess] if excess < len(korean_segments) else []
        all_segments = korean_segments + non_korean_segments
    
    random.shuffle(all_segments)
    return all_segments[:total_count]


def generate_dataset(
    target_train: int,
    target_val: int,
    require_korean_font: bool,
    korean_min_ratio: float,
    korean_max_ratio: float,
    augmentation_prob: float,
    bucket_ratios: Dict[str, float]
):
    """데이터셋 생성 (Train + Validation)"""
    print("\n" + "=" * 70)
    print("PaddleOCR REC 데이터셋 생성 (고급 버전)")
    print(f"목표: Train {target_train:,}개 + Val {target_val:,}개 = 총 {target_train + target_val:,}개")
    print("=" * 70)
    
    # 디렉토리 생성
    REC_TRAIN_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
    REC_VAL_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
    
    # 버킷별 세그먼트 수집
    buckets = collect_segments_by_bucket()
    
    total_count = target_train + target_val
    
    # 샘플링 (한글 비율 강제)
    all_segments = sample_with_korean_ratio(
        buckets, total_count, korean_min_ratio, korean_max_ratio, bucket_ratios
    )
    
    # 폰트 로드
    font, has_korean = load_font(16, require_korean=require_korean_font)
    if font is None:
        print("[ERROR] 폰트를 로드할 수 없습니다.")
        return
    
    if require_korean_font and not has_korean:
        print("[ERROR] 한글 지원 폰트를 찾을 수 없습니다!")
        print("[INFO] 다음 폰트 중 하나를 설치해주세요:")
        print("  - D2Coding: https://github.com/naver/d2codingfont")
        print("  - Noto Sans Mono CJK KR")
        return
    
    if has_korean:
        print(f"[INFO] 한글 지원 폰트 로드 완료")
    else:
        print("[WARN] 한글 지원 폰트를 찾을 수 없습니다. 영문 폰트로 진행합니다.")
    
    # 통계 수집용
    stats = {
        'korean_count': 0,
        'symbol_count': 0,
        'augmented_count': 0,
        'bucket_counts': {name: 0 for name in bucket_ratios.keys()},
    }
    
    # Train 데이터셋 생성
    print(f"\n[1/2] Train 이미지 생성 중... ({target_train:,}개)")
    train_labels = []
    train_segments = all_segments[:target_train]
    
    random.seed(42)
    
    for i in tqdm(range(target_train), desc="Train 이미지 생성"):
        segment = train_segments[i]
        
        # 버킷 분류
        length = len(segment)
        if length <= 20:
            bucket_type = 'word_short'
        elif length <= 25:
            bucket_type = 'phrase_short'
        elif length <= 120:
            bucket_type = 'line_mid'
        elif length <= 220:
            bucket_type = 'line_long'
        else:
            bucket_type = 'line_long'
        
        stats['bucket_counts'][bucket_type] = stats['bucket_counts'].get(bucket_type, 0) + 1
        
        if contains_hangul(segment):
            stats['korean_count'] += 1
        if has_symbol(segment):
            stats['symbol_count'] += 1
        
        # 테마 선택
        if random.random() < 0.8:
            theme = random.choice(DARK_THEMES)
        else:
            theme = random.choice(LIGHT_THEMES)
        
        # 파일 확장자 랜덤 선택
        file_ext = random.choice(['.py', '.js', '.ts', '.html', '.css'])
        
        # 이미지 생성 (롱 라인은 더 넓은 이미지)
        if bucket_type == 'line_long':
            max_img_width = 1600
            min_font_size = 8
        elif bucket_type == 'line_mid':
            max_img_width = 960
            min_font_size = 10
        else:
            max_img_width = 640
            min_font_size = 10
        
        img = render_text_with_syntax(
            segment,
            file_ext,
            font,
            theme,
            target_height=48,
            max_width=max_img_width,
            padding=10,
            min_font_size=min_font_size
        )
        
        # 증강 적용
        should_augment = random.random() < augmentation_prob
        if should_augment:
            img = apply_augmentation(img, AUGMENTATION_CONFIG)
            stats['augmented_count'] += 1
        else:
            img = enhance_image_quality(img)
        
        # 저장 (증강 시 JPEG 품질 낮춤)
        img_name = f"word_{i + 1:06d}.jpg"
        img_path = REC_TRAIN_IMAGES_DIR / img_name
        jpeg_quality = random.randint(60, 95) if should_augment else 95
        img.save(img_path, "JPEG", quality=jpeg_quality, optimize=True)
        
        # 라벨 추가
        rel_path = f"train/{img_name}"
        clean_label = segment.replace('\t', ' ').replace('\n', ' ').lstrip().rstrip()
        train_labels.append(f"{rel_path}\t{clean_label}\n")
    
    # Validation 데이터셋 생성
    print(f"\n[2/2] Validation 이미지 생성 중... ({target_val:,}개)")
    val_labels = []
    val_segments = all_segments[target_train:target_train + target_val]
    
    random.seed(123)
    
    for i in tqdm(range(target_val), desc="Val 이미지 생성"):
        if i >= len(val_segments):
            break
        segment = val_segments[i]
        
        # 버킷 분류
        length = len(segment)
        if length <= 20:
            bucket_type = 'word_short'
        elif length <= 25:
            bucket_type = 'phrase_short'
        elif length <= 120:
            bucket_type = 'line_mid'
        else:
            bucket_type = 'line_long'
        
        if contains_hangul(segment):
            stats['korean_count'] += 1
        if has_symbol(segment):
            stats['symbol_count'] += 1
        
        # 테마 선택
        if random.random() < 0.8:
            theme = random.choice(DARK_THEMES)
        else:
            theme = random.choice(LIGHT_THEMES)
        
        # 파일 확장자 랜덤 선택
        file_ext = random.choice(['.py', '.js', '.ts', '.html', '.css'])
        
        # 이미지 생성
        if bucket_type == 'line_long':
            max_img_width = 1600
            min_font_size = 8
        elif bucket_type == 'line_mid':
            max_img_width = 960
            min_font_size = 10
        else:
            max_img_width = 640
            min_font_size = 10
        
        img = render_text_with_syntax(
            segment,
            file_ext,
            font,
            theme,
            target_height=48,
            max_width=max_img_width,
            padding=10,
            min_font_size=min_font_size
        )
        
        # 증강 적용
        should_augment = random.random() < augmentation_prob
        if should_augment:
            img = apply_augmentation(img, AUGMENTATION_CONFIG)
            stats['augmented_count'] += 1
        else:
            img = enhance_image_quality(img)
        
        # 저장
        img_name = f"word_{i + 1:06d}.jpg"
        img_path = REC_VAL_IMAGES_DIR / img_name
        jpeg_quality = random.randint(60, 95) if should_augment else 95
        img.save(img_path, "JPEG", quality=jpeg_quality, optimize=True)
        
        # 라벨 추가
        rel_path = f"val/{img_name}"
        clean_label = segment.replace('\t', ' ').replace('\n', ' ').lstrip().rstrip()
        val_labels.append(f"{rel_path}\t{clean_label}\n")
    
    # 라벨 파일 저장
    print("\n라벨 파일 저장 중...")
    with open(REC_GT_TRAIN_FILE, 'w', encoding='utf-8', newline='\n') as f:
        f.writelines(train_labels)
    
    with open(REC_GT_VAL_FILE, 'w', encoding='utf-8', newline='\n') as f:
        f.writelines(val_labels)
    
    # 최종 통계 출력
    total_generated = len(train_labels) + len(val_labels)
    print("\n" + "=" * 70)
    print("[OK] 데이터셋 생성 완료!")
    print("=" * 70)
    print(f"\n📊 생성 결과:")
    print(f"  Train 이미지: {len(train_labels):,}개")
    print(f"  Val 이미지: {len(val_labels):,}개")
    print(f"  총 이미지: {total_generated:,}개")
    
    print(f"\n📈 통계:")
    print(f"  한글 포함: {stats['korean_count']:,}개 ({stats['korean_count']/total_generated*100:.1f}%)")
    print(f"  기호 포함: {stats['symbol_count']:,}개 ({stats['symbol_count']/total_generated*100:.1f}%)")
    print(f"  증강 적용: {stats['augmented_count']:,}개 ({stats['augmented_count']/total_generated*100:.1f}%)")
    print(f"\n  버킷별 분포:")
    for bucket_name, count in stats['bucket_counts'].items():
        if count > 0:
            print(f"    {bucket_name}: {count:,}개 ({count/total_generated*100:.1f}%)")
    
    print(f"\n📁 저장 위치:")
    print(f"  Train 이미지: {REC_TRAIN_IMAGES_DIR}")
    print(f"  Val 이미지: {REC_VAL_IMAGES_DIR}")
    print(f"  Train 라벨: {REC_GT_TRAIN_FILE}")
    print(f"  Val 라벨: {REC_GT_VAL_FILE}")
    
    print(f"\n✨ 적용된 기능:")
    print(f"  - 버킷 기반 샘플링 (6개 버킷)")
    print(f"  - 한글 비율 강제: {korean_min_ratio*100:.0f}~{korean_max_ratio*100:.0f}%")
    print(f"  - 증강 적용: {augmentation_prob*100:.0f}%")
    print(f"  - 롱 라인 지원: 120~220자 (최대 1600px)")
    print(f"  - 기호 커버리지 강화")
    print(f"  - 한글 지원 폰트: {'사용' if has_korean else '미사용'}")


def main():
    """CLI 진입점"""
    parser = argparse.ArgumentParser(
        description='PaddleOCR REC 데이터셋 생성 스크립트 (고급 버전)',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
예시:
  # 기본 설정 (Train 25,000, Val 5,000)
  python 24_generate_code_rec_dataset_from_source.py
  
  # 대규모 생성 (Train 100,000, Val 20,000)
  python 24_generate_code_rec_dataset_from_source.py --train 100000 --val 20000
  
  # 한글 폰트 필수 + 한글 비율 50%
  python 24_generate_code_rec_dataset_from_source.py --require-korean --korean-min 0.5
  
  # 증강 비율 60%
  python 24_generate_code_rec_dataset_from_source.py --augment-prob 0.6
        """
    )
    
    parser.add_argument('--train', type=int, default=DEFAULT_TARGET_TRAIN_COUNT,
                        help=f'Train 데이터셋 크기 (기본: {DEFAULT_TARGET_TRAIN_COUNT:,})')
    parser.add_argument('--val', type=int, default=DEFAULT_TARGET_VAL_COUNT,
                        help=f'Val 데이터셋 크기 (기본: {DEFAULT_TARGET_VAL_COUNT:,})')
    parser.add_argument('--require-korean', action='store_true',
                        help='한글 지원 폰트 필수 (없으면 종료)')
    parser.add_argument('--korean-min', type=float, default=KOREAN_MIN_RATIO,
                        help=f'한글 최소 비율 (기본: {KOREAN_MIN_RATIO:.0%})')
    parser.add_argument('--korean-max', type=float, default=KOREAN_MAX_RATIO,
                        help=f'한글 최대 비율 (기본: {KOREAN_MAX_RATIO:.0%})')
    parser.add_argument('--augment-prob', type=float, default=AUGMENTATION_PROB,
                        help=f'증강 적용 확률 (기본: {AUGMENTATION_PROB:.0%})')
    
    # 버킷 비율 (고급 옵션)
    parser.add_argument('--bucket-word-short', type=float, default=BUCKET_RATIOS['word_short'],
                        help=f'word_short 버킷 비율 (기본: {BUCKET_RATIOS["word_short"]:.0%})')
    parser.add_argument('--bucket-phrase-short', type=float, default=BUCKET_RATIOS['phrase_short'],
                        help=f'phrase_short 버킷 비율 (기본: {BUCKET_RATIOS["phrase_short"]:.0%})')
    parser.add_argument('--bucket-line-mid', type=float, default=BUCKET_RATIOS['line_mid'],
                        help=f'line_mid 버킷 비율 (기본: {BUCKET_RATIOS["line_mid"]:.0%})')
    parser.add_argument('--bucket-line-long', type=float, default=BUCKET_RATIOS['line_long'],
                        help=f'line_long 버킷 비율 (기본: {BUCKET_RATIOS["line_long"]:.0%})')
    parser.add_argument('--bucket-symbol-focus', type=float, default=BUCKET_RATIOS['symbol_focus'],
                        help=f'symbol_focus 버킷 비율 (기본: {BUCKET_RATIOS["symbol_focus"]:.0%})')
    parser.add_argument('--bucket-korean-focus', type=float, default=BUCKET_RATIOS['korean_focus'],
                        help=f'korean_focus 버킷 비율 (기본: {BUCKET_RATIOS["korean_focus"]:.0%})')
    
    args = parser.parse_args()
    
    # 버킷 비율 딕셔너리 구성
    bucket_ratios = {
        'word_short': args.bucket_word_short,
        'phrase_short': args.bucket_phrase_short,
        'line_mid': args.bucket_line_mid,
        'line_long': args.bucket_line_long,
        'symbol_focus': args.bucket_symbol_focus,
        'korean_focus': args.bucket_korean_focus,
    }
    
    # 비율 합계 검증
    total_ratio = sum(bucket_ratios.values())
    if abs(total_ratio - 1.0) > 0.01:
        print(f"[WARN] 버킷 비율 합계가 1.0이 아닙니다 ({total_ratio:.2f}). 정규화합니다.")
        bucket_ratios = {k: v / total_ratio for k, v in bucket_ratios.items()}
    
    # 데이터셋 생성
    generate_dataset(
        target_train=args.train,
        target_val=args.val,
        require_korean_font=args.require_korean,
        korean_min_ratio=args.korean_min,
        korean_max_ratio=args.korean_max,
        augmentation_prob=args.augment_prob,
        bucket_ratios=bucket_ratios
    )


if __name__ == "__main__":
    main()

'3. 자습 & 메모(실전, 실습, 프로젝트) > 3-2 메모(실전, 프로젝트)' 카테고리의 다른 글

[MEMO] 실사용 모드  (0) 2026.02.08
[Memo] PaddleOCR v5 REC 파이프라인 재구축 회고  (0) 2026.01.04
[PaddleOCR] 학습용 REC 데이터셋 생성 스크립트  (0) 2026.01.02
[MEMO] PaddleOCR 코드 문법 인식 모델 학습  (0) 2026.01.01
[MEMO] 학습 데이터셋 정제 작업 메모  (0) 2025.12.31
'3. 자습 & 메모(실전, 실습, 프로젝트)/3-2 메모(실전, 프로젝트)' 카테고리의 다른 글
  • [MEMO] 실사용 모드
  • [Memo] PaddleOCR v5 REC 파이프라인 재구축 회고
  • [PaddleOCR] 학습용 REC 데이터셋 생성 스크립트
  • [MEMO] PaddleOCR 코드 문법 인식 모델 학습
고니3000원
고니3000원
공부 내용 정리, 자기발전 블로그 입니다. 기존 네이버 블로그에서 티스토리로 이전했습니다. https://blog.naver.com/pak1010pak
  • 고니3000원
    곤이의 공부 블로그
    고니3000원
  • 전체
    오늘
    어제
    • 분류 전체보기 (178) N
      • 1. AI 논문 + 모델 분석 (20)
        • AI 논문 분석 (13)
        • AI 모델 분석 (7)
      • 2. 자료구조와 알고리즘 (16)
        • 2-1 자료구조와 알고리즘 (13)
        • 2-2 강화학습 알고리즘 (3)
      • 3. 자습 & 메모(실전, 실습, 프로젝트) (25)
        • 3-1 문제 해석 (4)
        • 3-2 메모(실전, 프로젝트) (14)
        • 3-3 배포 실전 공부 (7)
      • 4. [팀] 프로젝트 및 공모전 (14)
        • 4-1 팀 프로젝트(메모, 공부) (1)
        • 4-2 Meat-A-Eye (6)
        • 4-3 RL-Tycoon-Agent (3)
        • 4-4 구조물 안정성 물리 추론 AI 경진대회(D.. (4)
      • 5. [개인] 프로젝트 및 공모전 (0)
        • 4-1 귀멸의칼날디펜스(자바스크립트 활용) (5)
        • 4-2 바탕화면 AI 펫 프로그램 (4)
        • 4-3 개인 프로젝트(기타) (3)
      • 개념 정리 step1 (32)
        • Python 기초 (7)
        • DBMS (1)
        • HTML | CSS (3)
        • Git | GitHub (1)
        • JavaScript (5)
        • Node.js (5)
        • React (1)
        • 데이터 분석 (6)
        • Python Engineering (3)
      • 개념 정리 step2 (57) N
        • Machine | Deep Learning (15)
        • 멀티모달(Multi-modal) (23)
        • 강화 학습 (10)
        • AI Agent (9) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 네이버 곤이의 블로그(Naver->Tistory)
    • Github
  • 공지사항

  • 인기 글

  • 태그

    Ai
    구현
    paddleocr
    논문 리뷰
    github
    파인튜닝
    OCR
    Attention Is All You Need
    자바스크립트
    학습
    html
    Grad-CAM
    transformer
    알고리즘
    javascript
    파이썬
    귀칼
    EfficientNet
    Python
    OCR학습
    강화 학습
    자료구조
    프로젝트
    공모전
    ViT
    Vision
    강화학습
    pandas
    bottleneck
    데이터분석
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
고니3000원
[PaddleOCR] 학습용 REC 데이터셋 생성 스크립트(업데이트 버전)
상단으로

티스토리툴바