626 lines
26 KiB
Python
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
|