"""⑩ hybrid 설정을 포스터에서 뽑는다. 원본은 이 값을 사람이 손으로 재서 configs/nol_.json에 적었고, 신규 공연은 거기서 멈췄다. 좌표는 detect가 이미 찾은 영역을 쓰고, 인물 사각만 따로 묻는다. 타이밍(punches·sweep·glows)은 이미지에서 잴 수 있는 값이 아니라 기본 템플릿이다. """ from pathlib import Path import numpy as np from PIL import Image from answers.person_box_answer import PersonBoxAnswer from models.detect import Box, Regions from models.hybrid import LayerConfig, MaskSpec, Rect from services.detect import detect from settings import settings from utils.common_llm import StructuredLLM from utils.image import to_data_uri, vlm_grid_overlay from utils.prompt import load_prompt # 고정층은 글리프보다 넓게 잡는다. 글자만 덮으면 베벨·그림자가 밀려 겹쳐 보인다. FIXED_RECT_PAD = 0.012 DARK_POSTER_LUMINANCE = 60 # 이보다 어두우면 사각 고정층이 검은 상자가 된다 SWEEP_AT = 2.4 FIRST_PUNCH_AT = 3.1 PUNCH_GAP = 0.7 # 겹치면 이웃 복원이 안 된다 DATE_PUNCH_AT = 5.5 PUNCH_PEAK_MAX = 1.5 PUNCH_PEAK_MIN = 1.05 PUNCH_KINDS = ("title", "datetime") PROTECT_KINDS = ("place", "logo") PERSON_BOX_PROMPT = load_prompt("person_box") person_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key) def padded_rect(box: Box, pad: float = FIXED_RECT_PAD) -> Rect: return (max(0.0, box.x0 - pad), max(0.0, box.y0 - pad), min(1.0, box.x1 + pad), min(1.0, box.y1 + pad)) def punch_peak(rect: Rect) -> float: """넓은 블록일수록 덜 키운다 — 키운 크롭이 프레임을 넘으면 잘린다.""" width = max(rect[2] - rect[0], 1e-3) return round(min(PUNCH_PEAK_MAX, max(PUNCH_PEAK_MIN, 1 / width)), 2) def is_dark_poster(poster: Image.Image) -> bool: return float(np.asarray(poster.convert("L")).mean()) < DARK_POSTER_LUMINANCE async def ask_person_box(poster: Image.Image) -> Rect | None: answer = await person_llm.ask_with_images( PersonBoxAnswer, PERSON_BOX_PROMPT, [("격자를 씌운 포스터:", to_data_uri(vlm_grid_overlay(poster)))]) if answer.person is None: return None box = answer.person return (box.x0 / 100, box.y0 / 100, box.x1 / 100, box.y1 / 100) def emphasis(regions: Regions, dark: bool) -> tuple[list, list]: """밝은 포스터는 펀치로, 검정 포스터는 glow로 강조한다. 검정 포스터에서 사각을 키워 덮으면 그 사각이 검은 상자로 보인다. """ punches, glows = [], [] at = FIRST_PUNCH_AT for kind in PUNCH_KINDS: box = regions.regions.get(kind) if box is None: continue rect = padded_rect(box) when = DATE_PUNCH_AT if kind == "datetime" else at if dark: glows.append((rect, when)) else: punches.append((rect, when, punch_peak(rect))) at += PUNCH_GAP return punches, glows def build_from_regions(regions: Regions, person: Rect | None, dark: bool) -> LayerConfig: fixed_rects = [padded_rect(box) for box in regions.regions.values()] title = regions.regions.get("title") punches, glows = emphasis(regions, dark) return LayerConfig( mask=MaskSpec(mode="glyph" if dark else "rect"), fixed_rects=fixed_rects, sil_box=person or (0.0, 0.0, 0.0, 0.0), sil_poly=[], # 다각형은 손으로 그려야 해서 비운다 glyphs={"title": padded_rect(title)} if title else {}, sweep_at={"title": SWEEP_AT} if title else {}, punches=punches, glows=glows, protect=[padded_rect(regions.regions[kind]) for kind in PROTECT_KINDS if kind in regions.regions], glyph_dark_ink=not dark, ) async def build_layer_config(poster: Path | Image.Image, regions: Regions | None = None) -> LayerConfig: source = (Image.open(poster) if isinstance(poster, Path) else poster).convert("RGB") if regions is None: regions = (await detect(source)).data return build_from_regions(regions, await ask_person_box(source), is_dark_poster(source))