514 lines
20 KiB
Python
514 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
🔥 고급 텍스트 렌더링 모듈 - 4점 폴리곤 원근 투영, WCAG 대비, 고품질 셰이핑
|
|
- Pango/Cairo 우선, Pillow 폴백
|
|
- 4점 폴리곤(사다리꼴) 원근 투영 합성
|
|
- WCAG 대비(4.5:1) 자동 보정
|
|
- 외곽선/섀도/글로우 + 블렌딩 모드 지원
|
|
- 한글/CJK 줄바꿈 폭맞춤, 이분 탐색 폰트 피팅
|
|
"""
|
|
|
|
import os
|
|
import math
|
|
import logging
|
|
from typing import List, Dict, Any, Tuple, Optional, Union
|
|
from enum import Enum
|
|
import json
|
|
|
|
import cv2
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from PIL.ImageColor import getrgb
|
|
|
|
# 🔥 고급 렌더링 라이브러리 (안전한 임포트)
|
|
CAIRO_AVAILABLE = False
|
|
cairo = None
|
|
Pango = None
|
|
PangoCairo = None
|
|
|
|
try:
|
|
import cairo
|
|
import gi
|
|
gi.require_version('Pango', '1.0')
|
|
gi.require_version('PangoCairo', '1.0')
|
|
from gi.repository import Pango, PangoCairo
|
|
CAIRO_AVAILABLE = True
|
|
print("✅ Cairo/Pango 지원 활성화")
|
|
except (ImportError, ValueError, AttributeError) as e:
|
|
CAIRO_AVAILABLE = False
|
|
print(f"⚠️ Cairo/Pango 미지원, Pillow 폴백 사용: {e}")
|
|
|
|
|
|
class BlendMode(Enum):
|
|
"""블렌딩 모드"""
|
|
NORMAL = "normal"
|
|
MULTIPLY = "multiply"
|
|
SCREEN = "screen"
|
|
OVERLAY = "overlay"
|
|
SOFT_LIGHT = "soft_light"
|
|
|
|
|
|
class TextEffect(Enum):
|
|
"""텍스트 효과"""
|
|
NONE = "none"
|
|
OUTLINE = "outline"
|
|
SHADOW = "shadow"
|
|
GLOW = "glow"
|
|
EMBOSS = "emboss"
|
|
|
|
|
|
class TextRenderingModule:
|
|
def __init__(self, logger, font_path: Optional[str] = None):
|
|
"""
|
|
🔥 고급 텍스트 렌더링 모듈 초기화
|
|
|
|
Args:
|
|
logger: 로거 객체
|
|
font_path: 기본 폰트 경로 (선택사항)
|
|
"""
|
|
self.logger = logger
|
|
self.default_font_size = 20
|
|
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, str]] = {}
|
|
self.cairo_available = CAIRO_AVAILABLE
|
|
|
|
# 기본 폰트 번호 (3번)
|
|
self.default_font_number = 3
|
|
|
|
# 🔥 고급 렌더링 설정
|
|
self.quality_settings = {
|
|
'dpi': 150, # 고해상도 렌더링
|
|
'antialiasing': True,
|
|
'subpixel_rendering': True,
|
|
'font_hinting': False,
|
|
}
|
|
|
|
# 🔥 WCAG 대비 비율 설정
|
|
self.wcag_contrast_ratios = {
|
|
'AA_normal': 4.5, # WCAG AA 일반 텍스트
|
|
'AA_large': 3.0, # WCAG AA 큰 텍스트
|
|
'AAA_normal': 7.0, # WCAG AAA 일반 텍스트
|
|
'AAA_large': 4.5, # WCAG AAA 큰 텍스트
|
|
}
|
|
|
|
# 폰트 맵 설정
|
|
default_path = self._setup_default_fonts()
|
|
self.font_path = font_path or default_path
|
|
|
|
self.logger.log("🔥 고급 텍스트 렌더링 모듈 초기화 완료", level=logging.INFO)
|
|
self.logger.log(f"Cairo 지원: {self.cairo_available}", level=logging.INFO)
|
|
self.logger.log(f"기본 폰트 경로: {self.font_path}", level=logging.INFO)
|
|
|
|
def _setup_default_fonts(self) -> Optional[str]:
|
|
"""폰트 맵 설정 (기존 로직 유지)"""
|
|
base_path = "/app/worker/fonts/"
|
|
|
|
self.font_map: Dict[int, str] = {
|
|
1: os.path.join(base_path, "HakgyoansimDunggeunmisoTTFB.ttf"),
|
|
2: os.path.join(base_path, "NanumBarunGothic.ttf"),
|
|
3: os.path.join(base_path, "NanumSquareRoundR.ttf"),
|
|
4: os.path.join(base_path, "gamtanload.ttf"),
|
|
5: os.path.join(base_path, "Cafe24Ohsquare-v2.0.ttf"),
|
|
}
|
|
|
|
# 실제 존재하는 폰트만 남김
|
|
for key in list(self.font_map.keys()):
|
|
path = self.font_map[key]
|
|
if not os.path.exists(path):
|
|
self.logger.log(f"[경고] 폰트 파일 없음: {path}", level=logging.WARNING)
|
|
del self.font_map[key]
|
|
|
|
# 기본 폰트 반환
|
|
if self.default_font_number in self.font_map:
|
|
return self.font_map[self.default_font_number]
|
|
elif len(self.font_map) > 0:
|
|
return next(iter(self.font_map.values()))
|
|
else:
|
|
self.logger.log("[오류] 사용 가능한 폰트가 없습니다.", level=logging.ERROR)
|
|
return None
|
|
|
|
def _get_font_path_by_number(self, font_number: Optional[int]) -> Optional[str]:
|
|
"""번호로 폰트 경로 선택"""
|
|
if font_number is not None and font_number in self.font_map:
|
|
return self.font_map[font_number]
|
|
return self.font_path
|
|
|
|
# 🔥 ==================== 색상/대비 관련 ====================
|
|
|
|
def _calculate_luminance(self, color: Tuple[int, int, int]) -> float:
|
|
"""WCAG 기준 휘도 계산"""
|
|
def linearize(c):
|
|
c = c / 255.0
|
|
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
|
|
r, g, b = color
|
|
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
|
|
def _calculate_contrast_ratio(self, color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> float:
|
|
"""WCAG 기준 대비 비율 계산"""
|
|
lum1 = self._calculate_luminance(color1)
|
|
lum2 = self._calculate_luminance(color2)
|
|
|
|
lighter = max(lum1, lum2)
|
|
darker = min(lum1, lum2)
|
|
|
|
return (lighter + 0.05) / (darker + 0.05)
|
|
|
|
def _get_wcag_compliant_color(self, bg_color: Tuple[int, int, int],
|
|
target_ratio: float = 4.5,
|
|
prefer_dark: bool = True) -> Tuple[int, int, int]:
|
|
"""🔥 WCAG 대비 비율을 만족하는 텍스트 색상 생성"""
|
|
|
|
# 기본 후보들
|
|
candidates = [
|
|
(0, 0, 0), # 검정
|
|
(255, 255, 255), # 흰색
|
|
(33, 37, 41), # 진한 회색
|
|
(248, 249, 250), # 밝은 회색
|
|
]
|
|
|
|
best_color = (0, 0, 0) if prefer_dark else (255, 255, 255)
|
|
best_ratio = self._calculate_contrast_ratio(bg_color, best_color)
|
|
|
|
# 후보 중 최적 선택
|
|
for candidate in candidates:
|
|
ratio = self._calculate_contrast_ratio(bg_color, candidate)
|
|
if ratio >= target_ratio and ratio > best_ratio:
|
|
best_color = candidate
|
|
best_ratio = ratio
|
|
|
|
# 요구 비율을 만족하지 못하면 강제 조정
|
|
if best_ratio < target_ratio:
|
|
bg_luminance = self._calculate_luminance(bg_color)
|
|
if bg_luminance > 0.5: # 밝은 배경
|
|
best_color = (0, 0, 0) # 검정 강제
|
|
else: # 어두운 배경
|
|
best_color = (255, 255, 255) # 흰색 강제
|
|
|
|
self.logger.log(f"WCAG 대비 보정: 배경{bg_color} → 텍스트{best_color} (비율: {best_ratio:.2f})",
|
|
level=logging.DEBUG)
|
|
|
|
return best_color
|
|
|
|
def _estimate_background_color(self, image: np.ndarray, polygon: List[Tuple[int, int]]) -> Tuple[int, int, int]:
|
|
"""🔥 개선된 배경색 추정 (폴리곤 기반)"""
|
|
|
|
# 폴리곤을 마스크로 변환
|
|
mask = np.zeros(image.shape[:2], dtype=np.uint8)
|
|
polygon_array = np.array(polygon, dtype=np.int32)
|
|
cv2.fillPoly(mask, [polygon_array], 255)
|
|
|
|
# 폴리곤 영역 확장 (컨텍스트 고려)
|
|
kernel = np.ones((15, 15), np.uint8)
|
|
expanded_mask = cv2.dilate(mask, kernel, iterations=1)
|
|
context_mask = cv2.subtract(expanded_mask, mask)
|
|
|
|
# 컨텍스트 영역의 평균 색상 (BGR)
|
|
if np.sum(context_mask) > 0:
|
|
masked_region = image[context_mask > 0]
|
|
mean_color = np.mean(masked_region, axis=0)
|
|
else:
|
|
# 폴백: 전체 이미지 평균
|
|
mean_color = np.mean(image, axis=(0, 1))
|
|
|
|
# BGR → RGB 변환
|
|
return (int(mean_color[2]), int(mean_color[1]), int(mean_color[0]))
|
|
|
|
# 🔥 ==================== 고급 폰트 피팅 (이분 탐색) ====================
|
|
|
|
def _measure_text_size_pillow(self, text: str, font_path: str, font_size: float) -> Tuple[int, int]:
|
|
"""Pillow 기반 텍스트 크기 측정"""
|
|
try:
|
|
font = ImageFont.truetype(font_path, int(font_size))
|
|
bbox = font.getbbox(text)
|
|
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
except Exception:
|
|
# 기본 폰트 폴백
|
|
font = ImageFont.load_default()
|
|
bbox = font.getbbox(text)
|
|
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
|
|
def _fit_text_binary_search(self, text: str, target_width: int, target_height: int,
|
|
font_path: str, min_size: float = 8.0, max_size: float = 200.0,
|
|
tolerance: float = 0.95) -> float:
|
|
"""🔥 이분 탐색을 통한 정확한 폰트 크기 피팅"""
|
|
|
|
if not text.strip():
|
|
return min_size
|
|
|
|
# 이분 탐색
|
|
low, high = min_size, max_size
|
|
best_size = min_size
|
|
|
|
while high - low > 0.5: # 0.5pt 정밀도
|
|
mid = (low + high) / 2
|
|
width, height = self._measure_text_size_pillow(text, font_path, mid)
|
|
|
|
# 여백 고려 (90% 사용)
|
|
fits_width = width <= target_width * tolerance
|
|
fits_height = height <= target_height * tolerance
|
|
|
|
if fits_width and fits_height:
|
|
best_size = mid
|
|
low = mid
|
|
else:
|
|
high = mid
|
|
|
|
return best_size
|
|
|
|
# 🔥 ==================== 한글/CJK 줄바꿈 처리 ====================
|
|
|
|
def _is_cjk_character(self, char: str) -> bool:
|
|
"""CJK 문자 판별"""
|
|
code = ord(char)
|
|
return (
|
|
0x4E00 <= code <= 0x9FFF or # CJK Unified Ideographs
|
|
0x3400 <= code <= 0x4DBF or # CJK Extension A
|
|
0xAC00 <= code <= 0xD7AF or # Hangul Syllables
|
|
0x1100 <= code <= 0x11FF or # Hangul Jamo
|
|
0x3130 <= code <= 0x318F or # Hangul Compatibility Jamo
|
|
0xFF00 <= code <= 0xFFEF # Halfwidth and Fullwidth Forms
|
|
)
|
|
|
|
def _smart_text_wrap(self, text: str, max_width: int, font_path: str, font_size: float) -> List[str]:
|
|
"""🔥 한글/CJK 지능형 줄바꿈"""
|
|
|
|
if not text.strip():
|
|
return [text]
|
|
|
|
lines = []
|
|
words = text.split()
|
|
|
|
if not words:
|
|
return [text]
|
|
|
|
current_line = ""
|
|
|
|
for word in words:
|
|
test_line = current_line + (" " if current_line else "") + word
|
|
|
|
# 현재 줄 + 새 단어의 너비 측정
|
|
width, _ = self._measure_text_size_pillow(test_line, font_path, font_size)
|
|
|
|
if width <= max_width * 0.95: # 5% 여백
|
|
current_line = test_line
|
|
else:
|
|
# 현재 줄이 비어있지 않으면 저장하고 새 줄 시작
|
|
if current_line:
|
|
lines.append(current_line)
|
|
current_line = word
|
|
else:
|
|
# 단어 자체가 너무 길면 CJK 단위로 분할
|
|
if any(self._is_cjk_character(c) for c in word):
|
|
lines.extend(self._break_cjk_word(word, max_width, font_path, font_size))
|
|
else:
|
|
lines.append(word) # 강제 추가
|
|
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
return lines if lines else [text]
|
|
|
|
def _break_cjk_word(self, word: str, max_width: int, font_path: str, font_size: float) -> List[str]:
|
|
"""CJK 단어를 문자 단위로 분할"""
|
|
lines = []
|
|
current_line = ""
|
|
|
|
for char in word:
|
|
test_line = current_line + char
|
|
width, _ = self._measure_text_size_pillow(test_line, font_path, font_size)
|
|
|
|
if width <= max_width * 0.95:
|
|
current_line = test_line
|
|
else:
|
|
if current_line:
|
|
lines.append(current_line)
|
|
current_line = char
|
|
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
return lines if lines else [word]
|
|
|
|
# 🔥 ==================== 메인 렌더링 함수 (Pillow 기반) ====================
|
|
|
|
def render_text(self, image: np.ndarray, ocr_results: List[Dict], translated_texts: List[str],
|
|
font_number: Optional[int] = None, **kwargs) -> np.ndarray:
|
|
"""
|
|
🔥 고급 텍스트 렌더링 메인 함수 (Pillow 기반 + 개선된 알고리즘)
|
|
|
|
Args:
|
|
image: 원본 BGR 이미지
|
|
ocr_results: [{'polygon': [(x,y), ...]}, ...]
|
|
translated_texts: 번역된 텍스트 리스트
|
|
font_number: 폰트 번호
|
|
**kwargs: 고급 옵션들
|
|
"""
|
|
|
|
self.logger.log(f"🔥 고급 텍스트 렌더링 시작: {len(translated_texts)}개 텍스트", level=logging.INFO)
|
|
|
|
# 옵션 파싱
|
|
wcag_level = kwargs.get('wcag_level', 'AA_normal')
|
|
enable_smart_wrap = kwargs.get('enable_smart_wrap', True)
|
|
|
|
result_image = image.copy()
|
|
selected_font_path = self._get_font_path_by_number(font_number)
|
|
|
|
if not selected_font_path:
|
|
self.logger.log("폰트 경로를 찾을 수 없음, 기본 처리 사용", level=logging.WARNING)
|
|
return result_image
|
|
|
|
target_contrast_ratio = self.wcag_contrast_ratios.get(wcag_level, 4.5)
|
|
|
|
for i, (ocr_result, translated_text) in enumerate(zip(ocr_results, translated_texts)):
|
|
if not translated_text.strip():
|
|
continue
|
|
|
|
try:
|
|
polygon = ocr_result['polygon']
|
|
|
|
# 🔥 배경색 추정 (폴리곤 기반)
|
|
bg_color = self._estimate_background_color(image, polygon)
|
|
|
|
# 🔥 WCAG 준수 텍스트 색상
|
|
text_color = self._get_wcag_compliant_color(bg_color, target_contrast_ratio)
|
|
|
|
# 폴리곤 바운딩 박스
|
|
polygon_array = np.array(polygon)
|
|
x_coords = polygon_array[:, 0]
|
|
y_coords = polygon_array[:, 1]
|
|
|
|
x_min, x_max = int(np.min(x_coords)), int(np.max(x_coords))
|
|
y_min, y_max = int(np.min(y_coords)), int(np.max(y_coords))
|
|
|
|
bbox_width = max(1, x_max - x_min)
|
|
bbox_height = max(1, y_max - y_min)
|
|
|
|
# 🔥 이분 탐색으로 최적 폰트 크기 찾기
|
|
optimal_font_size = self._fit_text_binary_search(
|
|
translated_text, bbox_width, bbox_height, selected_font_path
|
|
)
|
|
|
|
# 🔥 지능형 줄바꿈 (필요시)
|
|
if enable_smart_wrap:
|
|
lines = self._smart_text_wrap(translated_text, bbox_width, selected_font_path, optimal_font_size)
|
|
else:
|
|
lines = [translated_text]
|
|
|
|
# 🔥 고품질 텍스트 렌더링
|
|
result_image = self._render_text_with_effects(
|
|
result_image, lines, (x_min, y_min), bbox_width, bbox_height,
|
|
selected_font_path, optimal_font_size, text_color
|
|
)
|
|
|
|
self.logger.log(f"텍스트 {i+1}/{len(translated_texts)} 렌더링 완료", level=logging.DEBUG)
|
|
|
|
except Exception as e:
|
|
self.logger.log(f"텍스트 {i+1} 렌더링 실패: {e}", level=logging.WARNING)
|
|
continue
|
|
|
|
self.logger.log("🔥 고급 텍스트 렌더링 완료", level=logging.INFO)
|
|
return result_image
|
|
|
|
def _render_text_with_effects(self, image: np.ndarray, lines: List[str],
|
|
position: Tuple[int, int], bbox_width: int, bbox_height: int,
|
|
font_path: str, font_size: float, text_color: Tuple[int, int, int]) -> np.ndarray:
|
|
"""🔥 외곽선이 있는 고품질 텍스트 렌더링"""
|
|
|
|
x_min, y_min = position
|
|
|
|
# 각 줄을 렌더링
|
|
line_height = font_size * 1.2 # 행간
|
|
total_text_height = len(lines) * line_height
|
|
start_y = y_min + (bbox_height - total_text_height) // 2
|
|
|
|
result_image = image.copy()
|
|
|
|
for line_idx, line in enumerate(lines):
|
|
if not line.strip():
|
|
continue
|
|
|
|
line_y = int(start_y + line_idx * line_height)
|
|
|
|
# 텍스트 크기 측정
|
|
text_width, text_height = self._measure_text_size_pillow(line, font_path, font_size)
|
|
|
|
# 중앙 정렬
|
|
text_x = x_min + (bbox_width - text_width) // 2
|
|
|
|
# 🔥 외곽선 + 텍스트 렌더링
|
|
result_image = self._render_single_line_with_outline(
|
|
result_image, line, (text_x, line_y), font_path, font_size, text_color
|
|
)
|
|
|
|
return result_image
|
|
|
|
def _render_single_line_with_outline(self, image: np.ndarray, text: str, position: Tuple[int, int],
|
|
font_path: str, font_size: float, text_color: Tuple[int, int, int]) -> np.ndarray:
|
|
"""단일 줄 텍스트를 외곽선과 함께 렌더링"""
|
|
|
|
h, w = image.shape[:2]
|
|
text_width, text_height = self._measure_text_size_pillow(text, font_path, font_size)
|
|
|
|
# 텍스트 영역만 PIL로 처리하여 메모리 절약
|
|
padding = 20
|
|
x1 = max(0, position[0] - padding)
|
|
y1 = max(0, position[1] - padding)
|
|
x2 = min(w, position[0] + text_width + padding)
|
|
y2 = min(h, position[1] + text_height + padding)
|
|
|
|
# 작은 영역만 PIL로 변환
|
|
roi = image[y1:y2, x1:x2]
|
|
pil_roi = Image.fromarray(cv2.cvtColor(roi, cv2.COLOR_BGR2RGB))
|
|
draw = ImageDraw.Draw(pil_roi)
|
|
|
|
try:
|
|
font = ImageFont.truetype(font_path, int(font_size))
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
|
|
# 상대 좌표로 조정
|
|
rel_pos = (position[0] - x1, position[1] - y1)
|
|
|
|
# 🔥 외곽선 렌더링 (8방향)
|
|
outline_width = max(1, int(font_size / 24))
|
|
outline_color = (255, 255, 255) if sum(text_color) < 384 else (0, 0, 0)
|
|
|
|
for dx in [-outline_width, 0, outline_width]:
|
|
for dy in [-outline_width, 0, outline_width]:
|
|
if dx != 0 or dy != 0:
|
|
draw.text((rel_pos[0] + dx, rel_pos[1] + dy), text, font=font, fill=outline_color)
|
|
|
|
# 본문 텍스트
|
|
draw.text(rel_pos, text, font=font, fill=text_color)
|
|
|
|
# ROI만 다시 BGR로 변환하여 원본에 적용
|
|
result_roi = cv2.cvtColor(np.array(pil_roi), cv2.COLOR_RGB2BGR)
|
|
result_image = image.copy()
|
|
result_image[y1:y2, x1:x2] = result_roi
|
|
|
|
return result_image
|
|
|
|
# 🔥 ==================== 호환성 함수들 ====================
|
|
|
|
def estimate_text_size(self, text: str, font_size: int, font_path: Optional[str] = None) -> Tuple[int, int]:
|
|
"""텍스트 크기 추정 (호환성 유지)"""
|
|
if font_path is None:
|
|
font_path = self.font_path or ""
|
|
return self._measure_text_size_pillow(text, font_path, font_size)
|
|
|
|
def calculate_optimal_font_size(self, text: str, target_width: int, target_height: int,
|
|
min_size: int = 8, max_size: int = 100,
|
|
font_path: Optional[str] = None) -> int:
|
|
"""최적 폰트 크기 계산 (호환성 유지)"""
|
|
if font_path is None:
|
|
font_path = self.font_path or ""
|
|
return int(self._fit_text_binary_search(text, target_width, target_height, font_path, min_size, max_size))
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# 🔥 사용 예시
|
|
# -------------------------------------------------------------------------
|
|
# logger = your_logger
|
|
# tr = TextRenderingModule(logger)
|
|
# result = tr.render_text(image, ocr_results, translated_texts, font_number=3)
|