87 lines
3.6 KiB
Python
87 lines
3.6 KiB
Python
"""② narration_text — 나레이션 3문장 + voice + BGM 스타일 + 메타태그.
|
|
|
|
상류 오독을 프롬프트에 넣으면 하류가 이미지를 다시 보지 않고 베껴 쓰므로,
|
|
앞 단계가 읽은 글자 대신 좌표만 넘기고 글자는 확대 크롭에서 직접 읽게 한다.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
from pydantic import ValidationError
|
|
|
|
from answers.narration_answer import NarrationAnswer
|
|
from models.detect import Box, Regions
|
|
from settings import settings
|
|
from utils.common_llm import StructuredLLM
|
|
from utils.image import to_data_uri
|
|
from utils.prompt import load_prompt
|
|
|
|
Image.MAX_IMAGE_PIXELS = None
|
|
|
|
# 글자를 읽어야 하는 영역만 잘라 확대해 따로 보여준다 (장식 서체 오독 방지)
|
|
CROP_LABELS = (("② 제목 영역 확대", "title"),
|
|
("③ 일시 영역 확대", "datetime"),
|
|
("④ 장소 영역 확대", "place"))
|
|
CROP_LONG_EDGE = 1024
|
|
POSTER_MAX_SIZE = (1024, 1024)
|
|
NARRATION_TEMPERATURE = 0.6
|
|
|
|
NARRATION_SYSTEM_PROMPT = load_prompt("narration")
|
|
NARRATION_RETRY_PROMPT = load_prompt("narration_retry")
|
|
|
|
narration_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key)
|
|
|
|
|
|
def crop_region(poster: Image.Image, box: Box, pad: float = 0.03) -> Image.Image | None:
|
|
"""전체 한 장으로는 장식 서체의 획 간격을 놓치므로 해당 영역만 잘라 확대한다."""
|
|
width, height = poster.size
|
|
left = int(max(0.0, box.x0 - pad) * width)
|
|
right = int(min(1.0, box.x1 + pad) * width)
|
|
top = int(max(0.0, box.y0 - pad) * height)
|
|
bottom = int(min(1.0, box.y1 + pad) * height)
|
|
if right - left < 16 or bottom - top < 16:
|
|
return None
|
|
crop = poster.crop((left, top, right, bottom))
|
|
scale = CROP_LONG_EDGE / max(crop.size)
|
|
if scale > 1:
|
|
crop = crop.resize((round(crop.width * scale), round(crop.height * scale)), Image.LANCZOS)
|
|
return crop
|
|
|
|
|
|
def build_images(poster: Image.Image, regions: Regions) -> list[tuple[str, str]]:
|
|
images = [("① 포스터 전체:", to_data_uri(poster, POSTER_MAX_SIZE))]
|
|
for label, kind in CROP_LABELS:
|
|
box = regions.regions.get(kind)
|
|
crop = crop_region(poster, box) if box else None
|
|
if crop is None:
|
|
continue
|
|
images.append((f"{label} (여기 적힌 글자를 한 자씩 읽어라):",
|
|
to_data_uri(crop, (CROP_LONG_EDGE, CROP_LONG_EDGE))))
|
|
return images
|
|
|
|
|
|
def build_prompt(regions: Regions) -> str:
|
|
"""좌표만 넘긴다 — 앞 단계가 읽은 text는 뺀다."""
|
|
slim = {
|
|
"title_style": regions.title_style,
|
|
"regions": {kind: {"x0": box.x0, "x1": box.x1, "y0": box.y0, "y1": box.y1}
|
|
for kind, box in regions.regions.items()},
|
|
}
|
|
return "영역 분석 JSON:\n" + json.dumps(slim, ensure_ascii=False)
|
|
|
|
|
|
async def generate_narration(poster: Path | Image.Image, regions: Regions) -> NarrationAnswer:
|
|
"""검증 실패 시 사유를 피드백으로 넣어 1회만 재생성하고, 2회째 실패는 그대로 올린다."""
|
|
source = (Image.open(poster) if isinstance(poster, Path) else poster).convert("RGB")
|
|
images = build_images(source, regions)
|
|
prompt = build_prompt(regions)
|
|
|
|
try:
|
|
return await narration_llm.ask_with_images(
|
|
NarrationAnswer, prompt, images,
|
|
system=NARRATION_SYSTEM_PROMPT, temperature=NARRATION_TEMPERATURE)
|
|
except ValidationError as first_error:
|
|
return await narration_llm.ask_with_images(
|
|
NarrationAnswer, prompt + NARRATION_RETRY_PROMPT.format(error=first_error), images,
|
|
system=NARRATION_SYSTEM_PROMPT, temperature=NARRATION_TEMPERATURE)
|