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