ImageProcessor_MainServer/worker/text_rendering_module2.py

626 lines
26 KiB
Python

# -*- coding: utf-8 -*-
"""
Text Rendering Module (최종 교체판)
- PaddleOCR 결과(폴리곤) + 번역문을 인페인팅된 이미지에 자연스럽게 합성
- 기존 API 호환: render_text(image, ocr_results, translated_texts, font_number=None)
- 추가 API:
* render_text_on_quadrilateral(image, polygon, text, ...)
* render_with_market_preset(image_bgr, ocr_results, translated_texts, market="coupang", preset="basic", ...)
- 특징:
* 4점 폴리곤(사다리꼴) 원근 투영 합성
* WCAG 대비(4.5:1) 자동 보정
* 외곽선/섀도/글로우 + 블렌딩 모드 지원
* Pango/HarfBuzz(+Cairo) 가능시 고품질 셰이핑/레이아웃, 미지원 환경은 Pillow 폴백
* 한글/CJK 줄바꿈 폭맞춤, 이분 탐색 폰트 피팅
- 폰트:
* /app/worker/fonts/ 경로의 폰트를 번호로 매핑
* 기본 폰트 번호: 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
# ───────────────────────────── GI(Pango) 체크 ─────────────────────────────
class _PangoCtx:
"""Pango/PangoCairo/cairo 사용 가능 여부와 핸들."""
def __init__(self, logger=None):
self.available = False
self.Pango = None
self.PangoCairo = None
self.cairo = None
try:
import gi
gi.require_version("Pango", "1.0")
gi.require_version("PangoCairo", "1.0")
from gi.repository import Pango, PangoCairo, cairo # type: ignore
self.Pango = Pango
self.PangoCairo = PangoCairo
self.cairo = cairo
self.available = True
if logger:
logger.log("[Pango] 사용 가능", level=logging.INFO)
except Exception as e:
if logger:
logger.log(f"[Pango] 사용 불가 → Pillow 폴백 ({e})", level=logging.WARNING)
# ───────────────────────────── 색/대비 유틸 ─────────────────────────────
def _srgb_to_lum(rgb: Tuple[int, int, int]) -> float:
r, g, b = [v / 255.0 for v in rgb]
def _ch(c): return (c / 12.92) if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = _ch(r), _ch(g), _ch(b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _contrast_ratio(c1: Tuple[int, int, int], c2: Tuple[int, int, int]) -> float:
L1, L2 = _srgb_to_lum(c1), _srgb_to_lum(c2)
L1, L2 = max(L1, L2), min(L1, L2)
return (L1 + 0.05) / (L2 + 0.05)
def _ensure_wcag_contrast(fg: Tuple[int, int, int], bg: Tuple[int, int, int], target: float = 4.5) -> Tuple[int, int, int]:
# 흑/백/원색 후보 중 최적 먼저
candidates = [(0, 0, 0), (255, 255, 255), fg]
best = max(candidates, key=lambda c: _contrast_ratio(c, bg))
if _contrast_ratio(best, bg) >= target:
return best
# 부족하면 밝기 차를 늘리는 방향으로 보정
fa = np.array(fg, np.float32)
ba = np.array(bg, np.float32)
direction = np.sign(fa - ba)
for k in (32, 64, 96, 128):
cand = np.clip(fa + direction * k, 0, 255).astype(np.uint8)
if _contrast_ratio(tuple(map(int, cand)), bg) >= target:
return tuple(map(int, cand))
return tuple(map(int, fa))
# ───────────────────────────── 기하/합성 유틸 ─────────────────────────────
def _order_quad(pts: List[Tuple[int, int]]) -> np.ndarray:
pts = np.array(pts, dtype=np.float32)
s = pts.sum(axis=1)
d = np.diff(pts, axis=1).ravel()
tl = pts[np.argmin(s)]
br = pts[np.argmax(s)]
tr = pts[np.argmin(d)]
bl = pts[np.argmax(d)]
return np.array([tl, tr, br, bl], dtype=np.float32)
def _poly_bbox(poly: List[Tuple[int, int]]) -> Tuple[int, int, int, int]:
arr = np.array(poly)
x1, y1 = int(np.min(arr[:, 0])), int(np.min(arr[:, 1]))
x2, y2 = int(np.max(arr[:, 0])), int(np.max(arr[:, 1]))
return x1, y1, x2, y2
def _median_bg_rgb(image_bgr: np.ndarray, x1: int, y1: int, x2: int, y2: int) -> Tuple[int, int, int]:
h, w = image_bgr.shape[:2]
x1 = max(0, x1); y1 = max(0, y1); x2 = min(w, x2); y2 = min(h, y2)
if x2 <= x1 or y2 <= y1:
return (200, 200, 200)
region = image_bgr[y1:y2, x1:x2].reshape(-1, 3)
if region.size == 0:
return (200, 200, 200)
m = np.median(region, axis=0) # BGR
return (int(m[2]), int(m[1]), int(m[0])) # RGB
def _alpha_blend(dst_bgr: np.ndarray, src_rgba: np.ndarray) -> np.ndarray:
b, g, r, a = cv2.split(src_rgba)
a = a.astype(np.float32) / 255.0
fg = cv2.merge([b, g, r]).astype(np.float32)
bg = dst_bgr.astype(np.float32)
out = fg * a[..., None] + bg * (1.0 - a[..., None])
return out.astype(np.uint8)
def _apply_blend_mode(dst_bgr: np.ndarray, blended_bgr: np.ndarray, mode: str = "normal") -> np.ndarray:
if mode == "normal":
return blended_bgr
d = dst_bgr.astype(np.float32) / 255.0
s = blended_bgr.astype(np.float32) / 255.0
if mode == "multiply":
out = d * s
elif mode == "screen":
out = 1 - (1 - d) * (1 - s)
elif mode == "overlay":
mask = d <= 0.5
out = np.empty_like(d)
out[mask] = 2 * d[mask] * s[mask]
out[~mask] = 1 - 2 * (1 - d[~mask]) * (1 - s[~mask])
else:
out = s
return (np.clip(out, 0, 1) * 255).astype(np.uint8)
def _warp_rgba_to_polygon(dst_bgr: np.ndarray, src_rgba: np.ndarray, polygon: List[Tuple[int, int]]) -> np.ndarray:
h, w = dst_bgr.shape[:2]
x1, y1, x2, y2 = _poly_bbox(polygon)
W, H = max(1, x2 - x1), max(1, y2 - y1)
src_rgba = cv2.resize(src_rgba, (W, H), interpolation=cv2.INTER_LANCZOS4)
Hm = cv2.getPerspectiveTransform(
np.array([[0, 0], [W, 0], [W, H], [0, H]], dtype=np.float32),
_order_quad(polygon)
)
warped = cv2.warpPerspective(src_rgba, Hm, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_TRANSPARENT)
return _alpha_blend(dst_bgr, warped)
# ───────────────────────────── 마켓 프리셋 ─────────────────────────────
def _create_market_presets() -> Dict[str, Dict[str, Any]]:
"""쿠팡/네이버 느낌 프리셋."""
COUPANG_BLUE = (0, 116, 228)
NAVER_GREEN = (3, 199, 90)
return {
"coupang_basic": {
"color": (0, 0, 0), "bg_color": None,
"outline": 1, "shadow": 1,
"letter_spacing": 0, "line_height": 1.12,
"blend_mode": "overlay", "max_font": 96,
"rounded_bg": False, "padding": (0, 0),
},
"coupang_badge": {
"color": (255, 255, 255), "bg_color": COUPANG_BLUE,
"outline": 0, "shadow": 2,
"letter_spacing": -1, "line_height": 1.00,
"blend_mode": "screen", "max_font": 84,
"rounded_bg": True, "padding": (10, 6),
},
"naver_basic": {
"color": (10, 10, 10), "bg_color": None,
"outline": 1, "shadow": 1,
"letter_spacing": 0, "line_height": 1.15,
"blend_mode": "normal", "max_font": 96,
"rounded_bg": False, "padding": (0, 0),
},
"naver_price": {
"color": NAVER_GREEN, "bg_color": None,
"outline": 1, "shadow": 1,
"letter_spacing": 0, "line_height": 1.10,
"blend_mode": "screen", "max_font": 110,
"rounded_bg": False, "padding": (0, 0),
},
}
# ───────────────────────────── 메인 클래스 ─────────────────────────────
class TextRenderingModule:
def __init__(self, logger, font_path: Optional[str] = None):
"""
logger: logger.log(msg, level=logging.INFO) 형태를 지원
font_path: 외부에서 기본 폰트 경로 강제 지정 가능(보통 None)
"""
self.logger = logger
self.default_font_size = 20
self.font_cache: Dict[str, ImageFont.FreeTypeFont] = {}
self.default_font_number = 3
default_path = self._setup_default_fonts()
self.font_path = font_path or default_path
self._pango = _PangoCtx(self.logger) # Pango 준비
self.logger.log("텍스트 렌더링 모듈(최종판) 초기화 완료", 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 k in list(self.font_map.keys()):
p = self.font_map[k]
if not os.path.exists(p):
self.logger.log(f"[경고] 폰트 없음: {p} -> 제거", level=logging.WARNING)
del self.font_map[k]
if self.default_font_number in self.font_map:
return self.font_map[self.default_font_number]
if self.font_map:
first = next(iter(self.font_map.values()))
self.logger.log(f"[주의] 기본(3) 없음 → {first} 사용", level=logging.WARNING)
return first
self.logger.log("[오류] 사용 가능한 폰트가 없습니다. PIL 기본 폰트 사용", level=logging.ERROR)
return None
def _get_font_path_by_number(self, font_number: Optional[int]) -> Optional[str]:
if font_number is not None:
if font_number in self.font_map:
return self.font_map[font_number]
self.logger.log(f"[경고] 알 수 없는 font_number={font_number} → 기본 사용", level=logging.WARNING)
return self.font_path
# ── Pillow 폰트/측정
def _get_pillow_font(self, size: int, font_path: Optional[str]) -> ImageFont.FreeTypeFont:
if font_path is None:
key = f"__PIL_DEFAULT__{size}"
if key not in self.font_cache:
self.font_cache[key] = ImageFont.load_default()
return self.font_cache[key]
key = f"{font_path}_{size}"
if key not in self.font_cache:
try:
self.font_cache[key] = ImageFont.truetype(font_path, size)
except Exception as e:
self.logger.log(f"[경고] PIL truetype 실패({e}) → 기본 폰트", level=logging.WARNING)
self.font_cache[key] = ImageFont.load_default()
return self.font_cache[key]
def _estimate_text_size(self, text: str, font_size: int, font_path: Optional[str]) -> Tuple[int, int]:
font = self._get_pillow_font(font_size, font_path)
try:
bbox = font.getbbox(text)
w = bbox[2] - bbox[0]; h = bbox[3] - bbox[1]
except AttributeError:
w, h = font.getsize(text)
return w, h
# ── 이분 탐색 폰트 피팅(단일 행 기준)
def _calc_font_fit_binary(self, text: str, tw: int, th: int, min_s: int, max_s: int, font_path: Optional[str]) -> int:
lo, hi = min_s, max_s
best = min_s
while lo <= hi:
mid = (lo + hi) // 2
w, h = self._estimate_text_size(text, mid, font_path)
if w <= tw and h <= th:
best = mid; lo = mid + 1
else:
hi = mid - 1
return best
# ────────────────────── RGBA 텍스트 생성 (Pango 우선) ──────────────────────
def _render_text_rgba(
self,
text: str,
width: int,
font_family_or_path: str,
font_size: int,
color: Tuple[int, int, int],
*,
line_height: float = 1.15,
letter_spacing: int = 0,
rounded_bg: bool = False,
bg_color: Optional[Tuple[int, int, int]] = None,
padding: Tuple[int, int] = (0, 0),
outline: int = 0,
outline_color: Tuple[int, int, int] = (255, 255, 255),
shadow: int = 0,
shadow_color: Tuple[int, int, int] = (0, 0, 0),
) -> np.ndarray:
"""
width 폭 안에서 줄바꿈/레이아웃을 적용한 텍스트 RGBA 생성.
- Pango/PangoCairo 사용 가능 시 그 경로 사용
- 아니면 Pillow로 폴백
"""
if self._pango.available:
try:
return self._render_text_rgba_pango(
text, width, font_family_or_path, font_size, color,
line_height=line_height, letter_spacing=letter_spacing,
rounded_bg=rounded_bg, bg_color=bg_color, padding=padding,
outline=outline, outline_color=outline_color,
shadow=shadow, shadow_color=shadow_color
)
except Exception as e:
self.logger.log(f"[Pango] 실패 → Pillow 폴백: {e}", level=logging.WARNING)
return self._render_text_rgba_pillow(
text, width, font_family_or_path, font_size, color,
line_height=line_height, letter_spacing=letter_spacing,
rounded_bg=rounded_bg, bg_color=bg_color, padding=padding,
outline=outline, outline_color=outline_color,
shadow=shadow, shadow_color=shadow_color
)
# Pango/Cairo 경로
def _render_text_rgba_pango(
self, text: str, width: int, font_family: str, font_size: int, color: Tuple[int, int, int],
*, line_height: float, letter_spacing: int,
rounded_bg: bool, bg_color: Optional[Tuple[int, int, int]],
padding: Tuple[int, int], outline: int, outline_color: Tuple[int, int, int],
shadow: int, shadow_color: Tuple[int, int, int],
) -> np.ndarray:
Pango, PangoCairo, cairo = self._pango.Pango, self._pango.PangoCairo, self._pango.cairo
# 측정용 surface
surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)
ctx = cairo.Context(surf)
layout = PangoCairo.create_layout(ctx)
fd = Pango.FontDescription(f"{font_family} {font_size}")
layout.set_font_description(fd)
layout.set_width(width * Pango.SCALE)
layout.set_wrap(Pango.WrapMode.WORD_CHAR)
layout.set_alignment(Pango.Alignment.LEFT)
attrs = Pango.AttrList()
if letter_spacing != 0:
attrs.insert(Pango.attr_letter_spacing_new(letter_spacing * Pango.SCALE))
layout.set_attributes(attrs)
layout.set_text(text, -1)
# 전체 크기
lw, lh = layout.get_pixel_size()
if lh <= 0: lh = font_size
px, py = padding
W = max(1, width + px * 2 + outline * 2 + shadow * 2)
H = max(1, int(lh * line_height) + py * 2 + outline * 2 + shadow * 2)
surf2 = cairo.ImageSurface(cairo.FORMAT_ARGB32, W, H)
ctx2 = cairo.Context(surf2)
# 배경
if bg_color is not None:
r, g, b = [v / 255.0 for v in bg_color]
ctx2.set_source_rgba(r, g, b, 1.0)
rad = 6 if rounded_bg else 0
self._cairo_round_rect(ctx2, 0, 0, W, H, rad)
ctx2.fill()
# 섀도
tx = px + outline + shadow
ty = py + outline + shadow
if shadow > 0:
r, g, b = [v / 255.0 for v in shadow_color]
ctx2.set_source_rgba(r, g, b, 0.5)
PangoCairo.update_layout(ctx2, layout)
ctx2.move_to(tx + shadow, ty + shadow)
PangoCairo.show_layout(ctx2, layout)
# 외곽선
if outline > 0:
r, g, b = [v / 255.0 for v in outline_color]
ctx2.set_source_rgba(r, g, b, 1.0)
ctx2.set_line_width(outline * 2)
PangoCairo.update_layout(ctx2, layout)
ctx2.move_to(tx, ty)
PangoCairo.layout_path(ctx2, layout)
ctx2.stroke()
# 본문
r, g, b = [v / 255.0 for v in color]
ctx2.set_source_rgba(r, g, b, 1.0)
ctx2.move_to(tx, ty)
PangoCairo.show_layout(ctx2, layout)
buf = surf2.get_data()
arr = np.frombuffer(buf, np.uint8).reshape(H, W, 4)
return arr.copy() # ARGB premultiplied -> RGBA와 동등 취급
@staticmethod
def _cairo_round_rect(ctx, x, y, w, h, r):
if r <= 0:
ctx.rectangle(x, y, w, h); return
import math as _m
ctx.new_sub_path()
ctx.arc(x + w - r, y + r, r, -90 * _m.pi / 180, 0)
ctx.arc(x + w - r, y + h - r, r, 0, 90 * _m.pi / 180)
ctx.arc(x + r, y + h - r, r, 90 * _m.pi / 180, 180 * _m.pi / 180)
ctx.arc(x + r, y + r, r, 180 * _m.pi / 180, 270 * _m.pi / 180)
ctx.close_path()
# Pillow 폴백 경로(간단 다중행)
def _render_text_rgba_pillow(
self, text: str, width: int, font_path_or_name: str, font_size: int, color: Tuple[int, int, int],
*, line_height: float, letter_spacing: int, rounded_bg: bool, bg_color: Optional[Tuple[int, int, int]],
padding: Tuple[int, int], outline: int, outline_color: Tuple[int, int, int],
shadow: int, shadow_color: Tuple[int, int, int]
) -> np.ndarray:
try:
font = ImageFont.truetype(font_path_or_name, font_size)
except Exception:
font = ImageFont.load_default()
# 폭 기준 줄바꿈
def _wrap(txt: str) -> str:
lines, line = [], ""
for ch in txt:
test = line + ch
w = int(font.getlength(test)) if hasattr(font, "getlength") else font.getbbox(test)[2]
if w <= width or not line:
line = test
else:
lines.append(line); line = ch
if line: lines.append(line)
return "\n".join(lines)
wrapped = _wrap(text)
lines = wrapped.split("\n")
max_w, total_h = 0, 0
for ln in lines:
w = int(font.getlength(ln)) if hasattr(font, "getlength") else font.getbbox(ln)[2]
h = font.getbbox(ln)[3]
max_w = max(max_w, w)
total_h += int(h * line_height)
px, py = padding
W = max(1, max_w + px * 2 + outline * 2 + shadow * 2)
H = max(1, total_h + py * 2 + outline * 2 + shadow * 2)
img = Image.new("RGBA", (W, H), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
# 배경
if bg_color is not None:
self._draw_round_rect(draw, (0, 0, W, H), 6 if rounded_bg else 0, bg_color + (255,))
# 섀도/외곽선/본문
y = py + outline + shadow
for ln in lines:
# 섀도
if shadow > 0:
draw.text((px + outline + shadow * 2, y + shadow),
ln, font=font, fill=shadow_color + (128,))
# 외곽선
if outline > 0:
for ox in range(-outline, outline + 1):
for oy in range(-outline, outline + 1):
if ox == 0 and oy == 0: continue
draw.text((px + outline + ox, y + outline + oy),
ln, font=font, fill=outline_color + (255,))
# 본문
draw.text((px + outline, y + outline), ln, font=font, fill=color + (255,))
h = font.getbbox(ln)[3]
y += int(h * line_height)
return np.array(img, dtype=np.uint8)
@staticmethod
def _draw_round_rect(draw: ImageDraw.ImageDraw, box: Tuple[int, int, int, int], radius: int, fill: Tuple[int, int, int, int]):
x1, y1, x2, y2 = box
w, h = x2 - x1, y2 - y1
rr = Image.new("RGBA", (w, h), (255, 255, 255, 0))
d = ImageDraw.Draw(rr)
if radius <= 0:
d.rectangle((0, 0, w, h), fill=fill)
else:
d.rounded_rectangle((0, 0, w, h), radius=radius, fill=fill)
draw.bitmap((x1, y1), rr)
# ────────────────────── 기존 API: 회전 중심 정렬 ──────────────────────
def render_text(
self,
image: np.ndarray,
ocr_results: List[Dict],
translated_texts: List[str],
font_number: Optional[int] = None,
use_wcag: bool = True,
outline: int = 1,
shadow: int = 0,
blend_mode: str = "normal",
) -> np.ndarray:
"""
기존 인터페이스 유지. (사다리꼴 투영이 아닌, 중심 정렬 + 회전)
"""
out = image.copy()
fpath = self._get_font_path_by_number(font_number)
for r, t in zip(ocr_results, translated_texts):
poly = r['polygon']
arr = np.array(poly)
x1, y1, x2, y2 = int(np.min(arr[:, 0])), int(np.min(arr[:, 1])), int(np.max(arr[:, 0])), int(np.max(arr[:, 1]))
W, H = max(1, x2 - x1), max(1, y2 - y1)
# 폰트 크기(단일 행)
font_size = self._calc_font_fit_binary(t, W, H, 8, 120, fpath)
# 배경색 → 텍스트색
bg = _median_bg_rgb(out, x1, y1, x2, y2)
txt_color = (0, 0, 0) if _srgb_to_lum(bg) > 0.5 else (255, 255, 255)
if use_wcag:
txt_color = _ensure_wcag_contrast(txt_color, bg, 4.5)
# RGBA 만들고(단일 행), 회전/중앙 배치
rgba = self._render_text_rgba(
text=t, width=W, font_family_or_path=fpath or "NanumSquareRound",
font_size=font_size, color=txt_color,
outline=outline, shadow=shadow
)
if len(arr) >= 2:
dx = float(arr[1][0] - arr[0][0]); dy = float(arr[1][1] - arr[0][1])
ang = math.degrees(math.atan2(dy, dx))
else:
ang = 0.0
pil_rgba = Image.fromarray(rgba)
if ang != 0:
pil_rgba = pil_rgba.rotate(ang, expand=True)
rgba = np.array(pil_rgba)
tw, th = rgba.shape[1], rgba.shape[0]
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
xx, yy = int(cx - tw // 2), int(cy - th // 2)
# 경계 체크 & 합성
x0, y0 = max(0, xx), max(0, yy)
xE, yE = min(out.shape[1], xx + tw), min(out.shape[0], yy + th)
sx, sy = max(0, -xx), max(0, -yy)
if x0 < xE and y0 < yE:
roi = out[y0:yE, x0:xE]
cut = rgba[sy:sy + (yE - y0), sx:sx + (xE - x0)]
blended = _alpha_blend(roi, cut)
blended = _apply_blend_mode(roi, blended, mode=blend_mode)
out[y0:yE, x0:xE] = blended
return out
# ────────────────────── 신규: 4점 폴리곤 원근 투영 ──────────────────────
def render_text_on_quadrilateral(
self,
image: np.ndarray,
polygon: List[Tuple[int, int]],
text: str,
font_number: Optional[int] = None,
use_wcag: bool = True,
multiline: bool = True,
outline: int = 1,
shadow: int = 0,
letter_spacing: int = 0,
line_height: float = 1.15,
blend_mode: str = "normal",
) -> np.ndarray:
out = image.copy()
x1, y1, x2, y2 = _poly_bbox(polygon)
W, H = max(1, x2 - x1), max(1, y2 - y1)
fpath = self._get_font_path_by_number(font_number)
bg = _median_bg_rgb(out, x1, y1, x2, y2)
txt_color = (0, 0, 0) if _srgb_to_lum(bg) > 0.5 else (255, 255, 255)
if use_wcag:
txt_color = _ensure_wcag_contrast(txt_color, bg, 4.5)
# 대략적 폰트 크기 추정(높이에 근사)
font_size = max(12, min(int(H * 0.85), 120))
rgba = self._render_text_rgba(
text=text, width=W, font_family_or_path=fpath or "NanumSquareRound",
font_size=font_size, color=txt_color,
line_height=line_height, letter_spacing=letter_spacing,
outline=outline, shadow=shadow
)
out = _warp_rgba_to_polygon(out, rgba, polygon)
out = _apply_blend_mode(image, out, mode=blend_mode)
return out
# ────────────────────── 신규: 마켓 프리셋 일괄 적용 ──────────────────────
def render_with_market_preset(
self,
image_bgr: np.ndarray,
ocr_results: List[Dict],
translated_texts: List[str],
market: str = "coupang", # 'coupang' | 'naver'
preset: str = "basic", # 'basic' | 'badge' | 'price'
font_number: Optional[int] = None,
) -> np.ndarray:
presets = _create_market_presets()
key = f"{market}_{preset}"
if key not in presets:
key = "coupang_basic" if market == "coupang" else "naver_basic"
self.logger.log(f"[preset] {market}/{preset} 없음 → {key} 사용", level=logging.WARNING)
st = presets[key]
out = image_bgr.copy()
fpath = self._get_font_path_by_number(font_number) or "NanumSquareRound"
for r, txt in zip(ocr_results, translated_texts):
poly = r["polygon"]
x1, y1, x2, y2 = _poly_bbox(poly)
W, H = max(1, x2 - x1), max(1, y2 - y1)
# 배경-대비 반영
bg = _median_bg_rgb(out, x1, y1, x2, y2)
fg = _ensure_wcag_contrast(st["color"], bg, 4.5)
font_size = min(max(12, int(H * 0.85)), st["max_font"])
rgba = self._render_text_rgba(
text=txt, width=W, font_family_or_path=fpath, font_size=font_size, color=fg,
line_height=st["line_height"], letter_spacing=st["letter_spacing"],
rounded_bg=st["rounded_bg"], bg_color=st["bg_color"], padding=st["padding"],
outline=st["outline"], outline_color=(255, 255, 255),
shadow=st["shadow"], shadow_color=(0, 0, 0)
)
out = _warp_rgba_to_polygon(out, rgba, poly)
out = _apply_blend_mode(image_bgr, out, mode=st["blend_mode"])
return out