이미지 경로 변경 및 텍스트 렌더링 모듈 개선. ROI 영역 설정 추가 및 WCAG 대비 비율 계산 기능 추가. 기존 코드 정리 및 주석 보강.

This commit is contained in:
7600M 2025-08-12 16:24:04 +09:00
parent 29a32c4ef6
commit 1b72426779
5 changed files with 1636 additions and 1122 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -16,7 +16,7 @@ import requests
API_ROOT = "http://localhost:7890" # 메인 서버 주소
IMAGE_PATH = pathlib.Path("5.jpg")
IMAGE_PATH = pathlib.Path("6.jpg")
TIMEOUT = 120 # 초
unwanted_texts = {
@ -43,6 +43,7 @@ toggle_states.update({
"blend_mode": "simple", # 단순 블렌딩
"performance_mode": True, # 빠른 경로 사용
"max_image_size": 1280, # 더 작은 크기 제한
"roi_area_high": 0.0 # 기본값: 0.60 → 0.0으로 변경 # 풀프레임 인페인팅 강제
})

View File

@ -1,59 +1,105 @@
# -*- coding: utf-8 -*-
"""
텍스트 렌더링 모듈 - 인페인팅된 이미지에 번역된 텍스트를 자연스럽게 렌더링 (라이브러리화)
- /app/worker/fonts/ 폰트를 font_map으로 관리
- 외부에서 render_text 호출 font_number로 폰트 선택
- 기본 폰트는 3(NanumSquareRoundR.ttf) 설정
🔥 고급 텍스트 렌더링 모듈 - 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
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: logger.log(msg, level=logging.INFO) 형태를 지원하는 로거
font_path (Optional[str]): 외부에서 기본 폰트 경로를 강제 지정할 사용 (보통 None)
logger: 로거 객체
font_path: 기본 폰트 경로 (선택사항)
"""
self.logger = logger
self.default_font_size = 20
self.font_cache: Dict[str, ImageFont.FreeTypeFont] = {}
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, str]] = {}
self.cairo_available = CAIRO_AVAILABLE
# 기본 폰트 번호 (요청사항: 3번)
# 기본 폰트 번호 (3번)
self.default_font_number = 3
# /app/worker/fonts/ 내 폰트 맵 구성 및 유효성 검사
default_path = self._setup_default_fonts()
# 🔥 고급 렌더링 설정
self.quality_settings = {
'dpi': 150, # 고해상도 렌더링
'antialiasing': True,
'subpixel_rendering': True,
'font_hinting': True,
}
# 외부에서 font_path가 들어오면 우선 사용, 없으면 디폴트 선택
# 🔥 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("🔥 고급 텍스트 렌더링 모듈 초기화 완료", level=logging.INFO)
self.logger.log(f"Cairo 지원: {self.cairo_available}", level=logging.INFO)
self.logger.log(f"기본 폰트 경로: {self.font_path}", level=logging.INFO)
# ---------------------------------------------------------------------
# 내부 설정: /app/worker/fonts/ 내 폰트들을 번호로 매핑
# ---------------------------------------------------------------------
def _setup_default_fonts(self) -> Optional[str]:
"""
/app/worker/fonts/ 경로의 폰트 파일을 번호로 매핑한다.
- 1: HakgyoansimDunggeunmisoTTFB.ttf
- 2: NanumBarunGothic.ttf
- 3: NanumSquareRoundR.ttf (기본)
- 4: gamtanload.ttf
- 5: Cafe24Ohsquare-v2.0.ttf
Returns:
기본으로 사용할 폰트 경로 (가능하면 3, 없으면 번째 유효한 폰트, 모두 없으면 None)
"""
"""폰트 맵 설정 (기존 로직 유지)"""
base_path = "/app/worker/fonts/"
self.font_map: Dict[int, str] = {
@ -68,238 +114,340 @@ class TextRenderingModule:
for key in list(self.font_map.keys()):
path = self.font_map[key]
if not os.path.exists(path):
self.logger.log(f"[경고] 폰트 파일 없음: {path} -> font_map에서 제외", level=logging.WARNING)
self.logger.log(f"[경고] 폰트 파일 없음: {path}", level=logging.WARNING)
del self.font_map[key]
# 기본(3번) 우선, 없으면 첫 번째 유효 폰트, 전무하면 None
# 기본 폰트 반환
if self.default_font_number in self.font_map:
return self.font_map[self.default_font_number]
elif len(self.font_map) > 0:
first_path = next(iter(self.font_map.values()))
self.logger.log(
f"[주의] 기본 3번 폰트가 없어 {first_path}로 대체 사용", level=logging.WARNING
)
return first_path
return next(iter(self.font_map.values()))
else:
self.logger.log(
"[오류] 사용 가능한 폰트가 없습니다. PIL 기본 폰트를 사용합니다.",
level=logging.ERROR,
)
self.logger.log("[오류] 사용 가능한 폰트가 없습니다.", level=logging.ERROR)
return None
# ---------------------------------------------------------------------
# 헬퍼: 번호로 폰트 경로 가져오기
# ---------------------------------------------------------------------
def _get_font_path_by_number(self, font_number: Optional[int]) -> Optional[str]:
"""
font_number로 self.font_map에서 경로를 선택.
- 유효하지 않으면 self.font_path(기본 경로) 반환
- 없으면 None
"""
if font_number is not None:
if font_number in self.font_map:
return self.font_map[font_number]
else:
self.logger.log(
f"[경고] 알 수 없는 font_number={font_number}. 기본 폰트를 사용합니다.",
level=logging.WARNING,
)
"""번호로 폰트 경로 선택"""
if font_number is not None and font_number in self.font_map:
return self.font_map[font_number]
return self.font_path
# ---------------------------------------------------------------------
# 폰트 로딩 / 사이즈 측정
# ---------------------------------------------------------------------
def get_font(self, size: int, font_path: Optional[str] = None) -> ImageFont.FreeTypeFont:
"""
폰트를 캐시하여 로딩 비용을 줄임
- font_path가 None이면 PIL 기본 폰트 사용
"""
if font_path is None:
cache_key = f"__PIL_DEFAULT__{size}"
if cache_key not in self.font_cache:
try:
font = ImageFont.load_default()
self.font_cache[cache_key] = font
except Exception as e:
self.logger.log(f"폰트 로드 오류(PIL 기본 폰트): {e}", level=logging.ERROR)
font = ImageFont.load_default()
self.font_cache[cache_key] = font
return self.font_cache[cache_key]
# 🔥 ==================== 색상/대비 관련 ====================
cache_key = f"{font_path}_{size}"
if cache_key not in self.font_cache:
try:
if os.path.exists(font_path):
font = ImageFont.truetype(font_path, size)
else:
self.logger.log(
f"[경고] 지정 경로의 폰트가 존재하지 않음: {font_path}. PIL 기본 폰트 사용",
level=logging.WARNING,
)
font = ImageFont.load_default()
self.font_cache[cache_key] = font
except Exception as e:
self.logger.log(f"폰트 로드 오류: {e}. PIL 기본 폰트 사용", level=logging.ERROR)
font = ImageFont.load_default()
self.font_cache[cache_key] = font
return self.font_cache[cache_key]
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
def estimate_text_size(self, text: str, font_size: int, font_path: Optional[str] = None) -> Tuple[int, int]:
"""
주어진 폰트로 텍스트의 렌더링 크기를 추정
"""
font = self.get_font(font_size, font_path)
try:
bbox = font.getbbox(text)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
except AttributeError:
# Pillow 버전에 따라 getbbox가 없을 수 있음
width, height = font.getsize(text)
return width, height
r, g, b = color
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
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:
"""
주어진 영역(target_width, target_height) 들어가는 최대 폰트 크기 탐색
"""
best_size = min_size
for size in range(min_size, max_size + 1):
width, height = self.estimate_text_size(text, size, font_path)
if width <= target_width and height <= target_height:
best_size = size
else:
break
return best_size
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)
# ---------------------------------------------------------------------
# 색상 관련
# ---------------------------------------------------------------------
def _estimate_background_color(self, image: np.ndarray, x1: int, y1: int, x2: int, y2: int) -> Tuple[int, int, int]:
"""
텍스트가 들어갈 영역 주변의 평균 색상(BGR) 구한 RGB로 반환
"""
margin = 5
y1_exp = max(0, y1 - margin)
y2_exp = min(image.shape[0], y2 + margin)
x1_exp = max(0, x1 - margin)
x2_exp = min(image.shape[1], x2 + margin)
region = image[y1_exp:y2_exp, x1_exp:x2_exp]
mean_color = np.mean(region, axis=(0, 1)) # BGR 평균
# RGB 튜플로 변환
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 _get_contrasting_color(self, bg_color: Tuple[int, int, int]) -> Tuple[int, int, int]:
"""
배경색과 대비되는 텍스트 색상(RGB) 선택 (단순 라이트/다크 기준)
"""
brightness = (bg_color[0] * 0.299 + bg_color[1] * 0.587 + bg_color[2] * 0.114)
if brightness > 128:
return (0, 0, 0) # 밝으면 검정
else:
return (255, 255, 255) # 어두우면 흰색
# 🔥 ==================== 고급 폰트 피팅 (이분 탐색) ====================
# ---------------------------------------------------------------------
# 메인 렌더링
# ---------------------------------------------------------------------
def render_text(
self,
image: np.ndarray,
ocr_results: List[Dict],
translated_texts: List[str],
font_number: Optional[int] = None,
) -> np.ndarray:
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:
"""
OCR 폴리곤과 번역 텍스트 리스트를 받아 지정된 폰트로 중심 정렬 렌더링
🔥 고급 텍스트 렌더링 메인 함수 (Pillow 기반 + 개선된 알고리즘)
Args:
image: 원본 BGR 이미지 (numpy.ndarray)
image: 원본 BGR 이미지
ocr_results: [{'polygon': [(x,y), ...]}, ...]
translated_texts: 영역에 대응하는 번역 텍스트 리스트
font_number: 사용할 폰트 번호 (None이면 기본 폰트)
translated_texts: 번역된 텍스트 리스트
font_number: 폰트 번호
**kwargs: 고급 옵션들
"""
print(f"render_text in translated_texts: {translated_texts}")
print(f"render_text in ocr_results : {ocr_results}")
print(f"render_text in font_number: {font_number}")
result_image = image.copy()
# 폰트 선택
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)):
polygon = ocr_result['polygon']
polygon_array = np.array(polygon)
x_coords = polygon_array[:, 0]
y_coords = polygon_array[:, 1]
if not translated_text.strip():
continue
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))
width = max(1, x_max - x_min)
height = max(1, y_max - y_min)
try:
polygon = ocr_result['polygon']
optimal_font_size = self.calculate_optimal_font_size(
translated_text, width, height, font_path=selected_font_path
)
# 🔥 배경색 추정 (폴리곤 기반)
bg_color = self._estimate_background_color(image, polygon)
text_width, text_height = self.estimate_text_size(
translated_text, optimal_font_size, selected_font_path
)
# 🔥 WCAG 준수 텍스트 색상
text_color = self._get_wcag_compliant_color(bg_color, target_contrast_ratio)
center_x = (x_min + x_max) // 2
center_y = (y_min + y_max) // 2
text_x = center_x - text_width // 2
text_y = center_y - text_height // 2
# 폴리곤 바운딩 박스
polygon_array = np.array(polygon)
x_coords = polygon_array[:, 0]
y_coords = polygon_array[:, 1]
angle = 0.0
if len(polygon_array) >= 2:
dx = float(polygon_array[1][0] - polygon_array[0][0])
dy = float(polygon_array[1][1] - polygon_array[0][1])
angle = math.degrees(math.atan2(dy, dx))
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))
bg_color = self._estimate_background_color(image, x_min, y_min, x_max, y_max)
text_color = self._get_contrasting_color(bg_color)
bbox_width = max(1, x_max - x_min)
bbox_height = max(1, y_max - y_min)
result_image = self.render_text_on_image(
result_image,
translated_text,
(int(text_x), int(text_y)),
font_size=optimal_font_size,
font_path=selected_font_path,
text_color=text_color,
background_color=None,
angle=angle,
# 🔥 이분 탐색으로 최적 폰트 크기 찾기
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_text_on_image(
self,
image: np.ndarray,
text: str,
position: Tuple[int, int],
font_size: Optional[int] = None,
font_path: Optional[str] = None,
text_color: Tuple[int, int, int] = (0, 0, 0),
background_color: Optional[Tuple[int, int, int]] = None,
angle: float = 0.0,
) -> np.ndarray:
"""
단일 텍스트를 지정 좌표에 그린다. (RGB 색상 인자 사용)
🔥 최적화: 메모리 효율성 개선
"""
if font_size is None:
font_size = self.default_font_size
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:
"""단일 줄 텍스트를 외곽선과 함께 렌더링"""
# 🔥 최적화: PIL 변환 최소화 - 필요한 영역만 처리
h, w = image.shape[:2]
text_width, text_height = self.estimate_text_size(text, font_size, font_path)
text_width, text_height = self._measure_text_size_pillow(text, font_path, font_size)
# 텍스트 영역만 PIL로 처리하여 메모리 절약
padding = 20
@ -312,28 +460,26 @@ class TextRenderingModule:
roi = image[y1:y2, x1:x2]
pil_roi = Image.fromarray(cv2.cvtColor(roi, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil_roi)
font = self.get_font(font_size, font_path)
try:
font = ImageFont.truetype(font_path, int(font_size))
except Exception:
font = ImageFont.load_default()
# 상대 좌표로 조정
rel_pos = (position[0] - x1, position[1] - y1)
# 배경 박스(옵션)
if background_color is not None:
bg_x1 = rel_pos[0] - 2
bg_y1 = rel_pos[1] - 2
bg_x2 = rel_pos[0] + text_width + 2
bg_y2 = rel_pos[1] + text_height + 2
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=background_color)
# 🔥 외곽선 렌더링 (8방향)
outline_width = max(1, int(font_size / 12))
outline_color = (255, 255, 255) if sum(text_color) < 384 else (0, 0, 0)
# 회전 처리
if angle != 0:
text_image = Image.new('RGBA', (text_width + 10, text_height + 10), (255, 255, 255, 0))
text_draw = ImageDraw.Draw(text_image)
text_draw.text((5, 5), text, font=font, fill=text_color + (255,))
rotated_text = text_image.rotate(angle, expand=True)
pil_roi.paste(rotated_text, rel_pos, rotated_text)
else:
draw.text(rel_pos, text, font=font, fill=text_color)
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)
@ -342,155 +488,26 @@ class TextRenderingModule:
return result_image
# ---------------------------------------------------------------------
# 스타일 관련 (선택 사용)
# ---------------------------------------------------------------------
def create_text_styles(self) -> Dict[str, Dict[str, Any]]:
"""다양한 텍스트 스타일 정의"""
styles = {
'default': {
'color': (0, 0, 0),
'bg_color': None,
'outline': True,
'outline_color': (255, 255, 255),
'outline_width': 1,
},
'bold': {
'color': (0, 0, 0),
'bg_color': (255, 255, 255),
'outline': True,
'outline_color': (128, 128, 128),
'outline_width': 2,
},
'highlight': {
'color': (255, 255, 255),
'bg_color': (255, 0, 0),
'outline': False,
'outline_color': None,
'outline_width': 0,
},
'subtle': {
'color': (128, 128, 128),
'bg_color': None,
'outline': True,
'outline_color': (255, 255, 255),
'outline_width': 1,
},
}
return styles
# 🔥 ==================== 호환성 함수들 ====================
def render_with_style(
self,
image: np.ndarray,
ocr_results: List[Dict],
translated_texts: List[str],
style_name: str = 'default',
font_number: Optional[int] = None,
) -> np.ndarray:
"""
스타일 사전의 /배경/외곽선 옵션을 참고해서 렌더링.
현재 코드는 기본 렌더링 결과를 반환하며, 추가 효과(그림자/글로우 ) 필요 확장.
"""
styles = self.create_text_styles()
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)
if style_name not in styles:
self.logger.log(f"[경고] 알 수 없는 스타일: {style_name}. 'default'로 대체", level=logging.WARNING)
style_name = 'default'
# NOTE: 현재 스타일 색을 직접 반영하려면 render_text 내부에서 색 적용 로직을 확장하면 됨.
# 여기서는 기본 렌더링만 수행.
result = self.render_text(
image=image,
ocr_results=ocr_results,
translated_texts=translated_texts,
font_number=font_number,
)
# (추가 스타일 후처리 자리)
return result
# ---------------------------------------------------------------------
# 텍스트 길이/크기 보정 유틸
# ---------------------------------------------------------------------
def adjust_text_for_space(
self,
text: str,
max_width: int,
max_height: int,
font_size: int,
) -> Tuple[str, int]:
"""
공간에 맞게 텍스트 조정 (간단 샘플 로직)
- 길면 줄바꿈/생략
- 필요 폰트 크기 감소
"""
if len(text) > 20:
words = text.split(' ')
if len(words) > 1:
mid = len(words) // 2
text = ' '.join(words[:mid]) + '\n' + ' '.join(words[mid:])
else:
text = text[:15] + '...'
adjusted_font_size = font_size
while adjusted_font_size > 8:
estimated_width = int(len(text) * adjusted_font_size * 0.6)
if estimated_width <= max_width:
break
adjusted_font_size -= 2
return text, adjusted_font_size
# ---------------------------------------------------------------------
# 테스트/디버그용 비교 이미지 저장
# ---------------------------------------------------------------------
def _create_style_comparison(self, images: List[np.ndarray], style_names: List[str]):
"""
스타일 비교 이미지 생성 (디버그 용도)
"""
if not images:
return
# 이미지 크기 조정
target_width = 200
target_height = int(images[0].shape[0] * target_width / images[0].shape[1])
resized_images = [cv2.resize(img, (target_width, target_height)) for img in images]
# 비교 캔버스
num_images = len(resized_images)
comparison_width = target_width * num_images
comparison_height = target_height + 30
comparison = np.ones((comparison_height, comparison_width, 3), dtype=np.uint8) * 255
# 원본
comparison[30:30 + target_height, 0:target_width] = resized_images[0]
cv2.putText(comparison, "Original", (10, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
# 스타일들
for i, (img, style_name) in enumerate(zip(resized_images[1:], style_names)):
x_offset = target_width * (i + 1)
comparison[30:30 + target_height, x_offset:x_offset + target_width] = img
cv2.putText(comparison, style_name, (x_offset + 10, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
os.makedirs("test_output", exist_ok=True)
out_path = "test_output/text_style_comparison.jpg"
cv2.imwrite(out_path, comparison)
self.logger.log(f"스타일 비교 이미지 저장 완료: {out_path}", level=logging.INFO)
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=origin_img,
# ocr_results=[{'polygon': [(10,10),(110,10),(110,40),(10,40)]}],
# translated_texts=["예시 텍스트"],
# font_number=3 # 기본값 3번, 명시적으로 지정 가능
# )
# cv2.imwrite("result.jpg", result)
# result = tr.render_text(image, ocr_results, translated_texts, font_number=3)

View File

@ -0,0 +1,496 @@
# -*- coding: utf-8 -*-
"""
텍스트 렌더링 모듈 - 인페인팅된 이미지에 번역된 텍스트를 자연스럽게 렌더링 (라이브러리화)
- /app/worker/fonts/ 내 폰트를 font_map으로 관리
- 외부에서 render_text 호출 시 font_number로 폰트 선택
- 기본 폰트는 3번(NanumSquareRoundR.ttf)로 설정
"""
import os
import math
import logging
from typing import List, Dict, Any, Tuple, Optional
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
class TextRenderingModule:
def __init__(self, logger, font_path: Optional[str] = None):
"""
Args:
logger: logger.log(msg, level=logging.INFO) 형태를 지원하는 로거
font_path (Optional[str]): 외부에서 기본 폰트 경로를 강제 지정할 때 사용 (보통 None)
"""
self.logger = logger
self.default_font_size = 20
self.font_cache: Dict[str, ImageFont.FreeTypeFont] = {}
# 기본 폰트 번호 (요청사항: 3번)
self.default_font_number = 3
# /app/worker/fonts/ 내 폰트 맵 구성 및 유효성 검사
default_path = self._setup_default_fonts()
# 외부에서 font_path가 들어오면 우선 사용, 없으면 디폴트 선택
self.font_path = font_path or default_path
self.logger.log("텍스트 렌더링 모듈 초기화 완료", level=logging.INFO)
self.logger.log(f"기본 폰트 경로: {self.font_path}", level=logging.INFO)
# ---------------------------------------------------------------------
# 내부 설정: /app/worker/fonts/ 내 폰트들을 번호로 매핑
# ---------------------------------------------------------------------
def _setup_default_fonts(self) -> Optional[str]:
"""
/app/worker/fonts/ 경로의 폰트 파일을 번호로 매핑한다.
- 1: HakgyoansimDunggeunmisoTTFB.ttf
- 2: NanumBarunGothic.ttf
- 3: NanumSquareRoundR.ttf (기본)
- 4: gamtanload.ttf
- 5: Cafe24Ohsquare-v2.0.ttf
Returns:
기본으로 사용할 폰트 경로 (가능하면 3번, 없으면 첫 번째 유효한 폰트, 모두 없으면 None)
"""
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} -> font_map에서 제외", level=logging.WARNING)
del self.font_map[key]
# 기본(3번) 우선, 없으면 첫 번째 유효 폰트, 전무하면 None
if self.default_font_number in self.font_map:
return self.font_map[self.default_font_number]
elif len(self.font_map) > 0:
first_path = next(iter(self.font_map.values()))
self.logger.log(
f"[주의] 기본 3번 폰트가 없어 {first_path}로 대체 사용", level=logging.WARNING
)
return first_path
else:
self.logger.log(
"[오류] 사용 가능한 폰트가 없습니다. PIL 기본 폰트를 사용합니다.",
level=logging.ERROR,
)
return None
# ---------------------------------------------------------------------
# 헬퍼: 번호로 폰트 경로 가져오기
# ---------------------------------------------------------------------
def _get_font_path_by_number(self, font_number: Optional[int]) -> Optional[str]:
"""
font_number로 self.font_map에서 경로를 선택.
- 유효하지 않으면 self.font_path(기본 경로) 반환
- 둘 다 없으면 None
"""
if font_number is not None:
if font_number in self.font_map:
return self.font_map[font_number]
else:
self.logger.log(
f"[경고] 알 수 없는 font_number={font_number}. 기본 폰트를 사용합니다.",
level=logging.WARNING,
)
return self.font_path
# ---------------------------------------------------------------------
# 폰트 로딩 / 사이즈 측정
# ---------------------------------------------------------------------
def get_font(self, size: int, font_path: Optional[str] = None) -> ImageFont.FreeTypeFont:
"""
폰트를 캐시하여 로딩 비용을 줄임
- font_path가 None이면 PIL 기본 폰트 사용
"""
if font_path is None:
cache_key = f"__PIL_DEFAULT__{size}"
if cache_key not in self.font_cache:
try:
font = ImageFont.load_default()
self.font_cache[cache_key] = font
except Exception as e:
self.logger.log(f"폰트 로드 오류(PIL 기본 폰트): {e}", level=logging.ERROR)
font = ImageFont.load_default()
self.font_cache[cache_key] = font
return self.font_cache[cache_key]
cache_key = f"{font_path}_{size}"
if cache_key not in self.font_cache:
try:
if os.path.exists(font_path):
font = ImageFont.truetype(font_path, size)
else:
self.logger.log(
f"[경고] 지정 경로의 폰트가 존재하지 않음: {font_path}. PIL 기본 폰트 사용",
level=logging.WARNING,
)
font = ImageFont.load_default()
self.font_cache[cache_key] = font
except Exception as e:
self.logger.log(f"폰트 로드 오류: {e}. PIL 기본 폰트 사용", level=logging.ERROR)
font = ImageFont.load_default()
self.font_cache[cache_key] = font
return self.font_cache[cache_key]
def estimate_text_size(self, text: str, font_size: int, font_path: Optional[str] = None) -> Tuple[int, int]:
"""
주어진 폰트로 텍스트의 렌더링 크기를 추정
"""
font = self.get_font(font_size, font_path)
try:
bbox = font.getbbox(text)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
except AttributeError:
# Pillow 버전에 따라 getbbox가 없을 수 있음
width, height = font.getsize(text)
return width, height
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:
"""
주어진 영역(target_width, target_height)에 들어가는 최대 폰트 크기 탐색
"""
best_size = min_size
for size in range(min_size, max_size + 1):
width, height = self.estimate_text_size(text, size, font_path)
if width <= target_width and height <= target_height:
best_size = size
else:
break
return best_size
# ---------------------------------------------------------------------
# 색상 관련
# ---------------------------------------------------------------------
def _estimate_background_color(self, image: np.ndarray, x1: int, y1: int, x2: int, y2: int) -> Tuple[int, int, int]:
"""
텍스트가 들어갈 영역 주변의 평균 색상(BGR)을 구한 뒤 RGB로 반환
"""
margin = 5
y1_exp = max(0, y1 - margin)
y2_exp = min(image.shape[0], y2 + margin)
x1_exp = max(0, x1 - margin)
x2_exp = min(image.shape[1], x2 + margin)
region = image[y1_exp:y2_exp, x1_exp:x2_exp]
mean_color = np.mean(region, axis=(0, 1)) # BGR 평균
# RGB 튜플로 변환
return (int(mean_color[2]), int(mean_color[1]), int(mean_color[0]))
def _get_contrasting_color(self, bg_color: Tuple[int, int, int]) -> Tuple[int, int, int]:
"""
배경색과 대비되는 텍스트 색상(RGB) 선택 (단순 라이트/다크 기준)
"""
brightness = (bg_color[0] * 0.299 + bg_color[1] * 0.587 + bg_color[2] * 0.114)
if brightness > 128:
return (0, 0, 0) # 밝으면 검정
else:
return (255, 255, 255) # 어두우면 흰색
# ---------------------------------------------------------------------
# 메인 렌더링
# ---------------------------------------------------------------------
def render_text(
self,
image: np.ndarray,
ocr_results: List[Dict],
translated_texts: List[str],
font_number: Optional[int] = None,
) -> np.ndarray:
"""
OCR 폴리곤과 번역 텍스트 리스트를 받아 지정된 폰트로 중심 정렬 렌더링
Args:
image: 원본 BGR 이미지 (numpy.ndarray)
ocr_results: [{'polygon': [(x,y), ...]}, ...]
translated_texts: 각 영역에 대응하는 번역 텍스트 리스트
font_number: 사용할 폰트 번호 (None이면 기본 폰트)
"""
print(f"render_text in translated_texts: {translated_texts}")
print(f"render_text in ocr_results : {ocr_results}")
print(f"render_text in font_number: {font_number}")
result_image = image.copy()
# 폰트 선택
selected_font_path = self._get_font_path_by_number(font_number)
for i, (ocr_result, translated_text) in enumerate(zip(ocr_results, translated_texts)):
polygon = ocr_result['polygon']
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))
width = max(1, x_max - x_min)
height = max(1, y_max - y_min)
optimal_font_size = self.calculate_optimal_font_size(
translated_text, width, height, font_path=selected_font_path
)
text_width, text_height = self.estimate_text_size(
translated_text, optimal_font_size, selected_font_path
)
center_x = (x_min + x_max) // 2
center_y = (y_min + y_max) // 2
text_x = center_x - text_width // 2
text_y = center_y - text_height // 2
angle = 0.0
if len(polygon_array) >= 2:
dx = float(polygon_array[1][0] - polygon_array[0][0])
dy = float(polygon_array[1][1] - polygon_array[0][1])
angle = math.degrees(math.atan2(dy, dx))
bg_color = self._estimate_background_color(image, x_min, y_min, x_max, y_max)
text_color = self._get_contrasting_color(bg_color)
result_image = self.render_text_on_image(
result_image,
translated_text,
(int(text_x), int(text_y)),
font_size=optimal_font_size,
font_path=selected_font_path,
text_color=text_color,
background_color=None,
angle=angle,
)
return result_image
def render_text_on_image(
self,
image: np.ndarray,
text: str,
position: Tuple[int, int],
font_size: Optional[int] = None,
font_path: Optional[str] = None,
text_color: Tuple[int, int, int] = (0, 0, 0),
background_color: Optional[Tuple[int, int, int]] = None,
angle: float = 0.0,
) -> np.ndarray:
"""
단일 텍스트를 지정 좌표에 그린다. (RGB 색상 인자 사용)
🔥 최적화: 메모리 효율성 개선
"""
if font_size is None:
font_size = self.default_font_size
# 🔥 최적화: PIL 변환 최소화 - 필요한 영역만 처리
h, w = image.shape[:2]
text_width, text_height = self.estimate_text_size(text, font_size, font_path)
# 텍스트 영역만 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)
font = self.get_font(font_size, font_path)
# 상대 좌표로 조정
rel_pos = (position[0] - x1, position[1] - y1)
# 배경 박스(옵션)
if background_color is not None:
bg_x1 = rel_pos[0] - 2
bg_y1 = rel_pos[1] - 2
bg_x2 = rel_pos[0] + text_width + 2
bg_y2 = rel_pos[1] + text_height + 2
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=background_color)
# 회전 처리
if angle != 0:
text_image = Image.new('RGBA', (text_width + 10, text_height + 10), (255, 255, 255, 0))
text_draw = ImageDraw.Draw(text_image)
text_draw.text((5, 5), text, font=font, fill=text_color + (255,))
rotated_text = text_image.rotate(angle, expand=True)
pil_roi.paste(rotated_text, rel_pos, rotated_text)
else:
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 create_text_styles(self) -> Dict[str, Dict[str, Any]]:
"""다양한 텍스트 스타일 정의"""
styles = {
'default': {
'color': (0, 0, 0),
'bg_color': None,
'outline': True,
'outline_color': (255, 255, 255),
'outline_width': 1,
},
'bold': {
'color': (0, 0, 0),
'bg_color': (255, 255, 255),
'outline': True,
'outline_color': (128, 128, 128),
'outline_width': 2,
},
'highlight': {
'color': (255, 255, 255),
'bg_color': (255, 0, 0),
'outline': False,
'outline_color': None,
'outline_width': 0,
},
'subtle': {
'color': (128, 128, 128),
'bg_color': None,
'outline': True,
'outline_color': (255, 255, 255),
'outline_width': 1,
},
}
return styles
def render_with_style(
self,
image: np.ndarray,
ocr_results: List[Dict],
translated_texts: List[str],
style_name: str = 'default',
font_number: Optional[int] = None,
) -> np.ndarray:
"""
스타일 사전의 색/배경/외곽선 옵션을 참고해서 렌더링.
현재 코드는 기본 렌더링 결과를 반환하며, 추가 효과(그림자/글로우 등)는 필요 시 확장.
"""
styles = self.create_text_styles()
if style_name not in styles:
self.logger.log(f"[경고] 알 수 없는 스타일: {style_name}. 'default'로 대체", level=logging.WARNING)
style_name = 'default'
# NOTE: 현재 스타일 색을 직접 반영하려면 render_text 내부에서 색 적용 로직을 확장하면 됨.
# 여기서는 기본 렌더링만 수행.
result = self.render_text(
image=image,
ocr_results=ocr_results,
translated_texts=translated_texts,
font_number=font_number,
)
# (추가 스타일 후처리 자리)
return result
# ---------------------------------------------------------------------
# 텍스트 길이/크기 보정 유틸
# ---------------------------------------------------------------------
def adjust_text_for_space(
self,
text: str,
max_width: int,
max_height: int,
font_size: int,
) -> Tuple[str, int]:
"""
공간에 맞게 텍스트 조정 (간단 샘플 로직)
- 길면 줄바꿈/생략
- 필요 시 폰트 크기 감소
"""
if len(text) > 20:
words = text.split(' ')
if len(words) > 1:
mid = len(words) // 2
text = ' '.join(words[:mid]) + '\n' + ' '.join(words[mid:])
else:
text = text[:15] + '...'
adjusted_font_size = font_size
while adjusted_font_size > 8:
estimated_width = int(len(text) * adjusted_font_size * 0.6)
if estimated_width <= max_width:
break
adjusted_font_size -= 2
return text, adjusted_font_size
# ---------------------------------------------------------------------
# 테스트/디버그용 비교 이미지 저장
# ---------------------------------------------------------------------
def _create_style_comparison(self, images: List[np.ndarray], style_names: List[str]):
"""
스타일 비교 이미지 생성 (디버그 용도)
"""
if not images:
return
# 이미지 크기 조정
target_width = 200
target_height = int(images[0].shape[0] * target_width / images[0].shape[1])
resized_images = [cv2.resize(img, (target_width, target_height)) for img in images]
# 비교 캔버스
num_images = len(resized_images)
comparison_width = target_width * num_images
comparison_height = target_height + 30
comparison = np.ones((comparison_height, comparison_width, 3), dtype=np.uint8) * 255
# 원본
comparison[30:30 + target_height, 0:target_width] = resized_images[0]
cv2.putText(comparison, "Original", (10, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
# 스타일들
for i, (img, style_name) in enumerate(zip(resized_images[1:], style_names)):
x_offset = target_width * (i + 1)
comparison[30:30 + target_height, x_offset:x_offset + target_width] = img
cv2.putText(comparison, style_name, (x_offset + 10, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
os.makedirs("test_output", exist_ok=True)
out_path = "test_output/text_style_comparison.jpg"
cv2.imwrite(out_path, comparison)
self.logger.log(f"스타일 비교 이미지 저장 완료: {out_path}", level=logging.INFO)
# -------------------------------------------------------------------------
# 사용 예시 (참고용)
# -------------------------------------------------------------------------
# logger = your_logger
# tr = TextRenderingModule(logger)
# result = tr.render_text(
# image=origin_img,
# ocr_results=[{'polygon': [(10,10),(110,10),(110,40),(10,40)]}],
# translated_texts=["예시 텍스트"],
# font_number=3 # 기본값 3번, 명시적으로 지정 가능
# )
# cv2.imwrite("result.jpg", result)