playreel/backend/services/build_layer_config.py

113 lines
4.2 KiB
Python

"""⑩ hybrid 설정을 포스터에서 뽑는다.
원본은 이 값을 사람이 손으로 재서 configs/nol_<slug>.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))