ImageProcessor_MainServer/worker/text_rendering_module.py

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)