# -*- 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)