111 lines
4.6 KiB
Python
111 lines
4.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
썸네일 최종 선택 — 규칙/픽셀로 압축한 top-N 후보 중 비전 LLM이 1개 선택 (Phase 2).
|
||
|
||
배경 (하이브리드 설계):
|
||
규칙(태그 매칭) + 픽셀 헤더 필터로 썸네일 후보를 소수(top-3)로 압축한 뒤,
|
||
그 소수 중 최종 1컷만 비전 LLM이 이미지를 실제로 보고 고른다. 선택지가
|
||
"1슬롯 × 3후보"로 갇혀 있어 전면 LLM 배정의 위험(제약 위반·중복 배정·큰
|
||
블라스트 반경)이 없고, 실패 시 규칙 1위로 폴백한다.
|
||
|
||
이 단계의 실질 가치: Pillow를 쓰지 않아 포기했던 지각적 축을 비전으로 되찾음.
|
||
- 이미지 속 글자/간판/워터마크가 커버 텍스트 4슬롯과 겹치는지
|
||
- 중앙 9:16 크롭 후 주제가 잘리거나 어중간해지는지
|
||
- 업종 대표성·클릭 유인
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.utils.logger import get_logger
|
||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||
|
||
logger = get_logger("thumbnail_vision")
|
||
|
||
# 비전 판정 모델·타임아웃 — 부가 기능이므로 실패해도 규칙 폴백, 파이프라인은 진행
|
||
_VISION_MODEL = "gpt-5-mini"
|
||
_VISION_TIMEOUT = 20.0
|
||
|
||
|
||
class ThumbnailPickOutput(BaseModel):
|
||
"""비전 LLM의 썸네일 선택 출력."""
|
||
choice_index: int = Field(..., description="선택한 이미지의 0-기반 인덱스 (첨부 이미지 순서와 동일)")
|
||
reason: str = Field(..., description="선택 근거 한 줄 (텍스트 충돌/크롭 구도/대표성 관점)")
|
||
|
||
|
||
def _build_prompt(candidate_count: int, industry: str, business_name: str) -> str:
|
||
return f"""당신은 숏폼 광고 영상의 **썸네일(커버) 배경 이미지**를 고르는 전문가입니다.
|
||
|
||
첨부된 {candidate_count}장의 이미지는 이미 태그·화질 필터를 통과한 후보들입니다.
|
||
이 중 커버로 가장 적합한 **1장**을 골라 0-기반 인덱스로 반환하세요.
|
||
(첫 번째 이미지 = 0, 두 번째 = 1, ...)
|
||
|
||
업체 정보: {business_name} ({industry} 업종)
|
||
|
||
썸네일 위에는 아래 4개의 텍스트가 흰 글자로 얹힙니다:
|
||
- 상단(약 8% 높이): 카테고리 뱃지
|
||
- 중앙(약 50%): 업체명 (큰 글자)
|
||
- 중앙 하단(약 62%): 지역
|
||
- 최하단(약 94%): 해시태그
|
||
|
||
선택 기준 (중요도 순):
|
||
0. **업종 대표성·클릭 유인**: 한눈에 어떤 곳인지 전달되고 매력적일 것
|
||
1. **텍스트 충돌 회피**: 이미지 속 간판·안내판 글자·워터마크가 위 텍스트 영역과 겹치지 않을 것
|
||
2. **크롭 후 구도**: 세로 9:16 중앙 크롭 시 핵심 주제가 잘리지 않고 살아있을 것
|
||
3. **가독성**: 텍스트가 얹히는 영역(상/중/하단)이 너무 밝거나 복잡하지 않아 흰 글자가 잘 보일 것
|
||
"""
|
||
|
||
|
||
async def pick_thumbnail_by_vision(
|
||
candidates: list[dict],
|
||
industry: str,
|
||
business_name: str,
|
||
) -> dict | None:
|
||
"""후보 이미지 중 비전 LLM이 최종 1컷을 선택합니다.
|
||
|
||
Args:
|
||
candidates: [{"image_url": str, "image_tag": dict}, ...] — 규칙/픽셀로
|
||
압축한 top-N 후보 (점수 내림차순, 즉 index 0이 규칙 1위).
|
||
|
||
Returns:
|
||
선택된 candidate dict. 후보가 1개 이하이거나 호출 실패/타임아웃/무효
|
||
인덱스면 None (호출자가 규칙 1위 폴백).
|
||
"""
|
||
if len(candidates) < 2:
|
||
# 선택할 게 없음 — 규칙 1위(있으면)로 폴백
|
||
return None
|
||
|
||
urls = [c["image_url"] for c in candidates]
|
||
prompt = _build_prompt(len(candidates), industry, business_name)
|
||
chatgpt = ChatgptService(model_type="gpt", timeout=_VISION_TIMEOUT)
|
||
|
||
try:
|
||
result: ThumbnailPickOutput = await asyncio.wait_for(
|
||
chatgpt.generate_structured_output_multi_image(
|
||
prompt_text=prompt,
|
||
output_format=ThumbnailPickOutput,
|
||
model=_VISION_MODEL,
|
||
img_urls=urls,
|
||
),
|
||
timeout=_VISION_TIMEOUT,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[thumbnail_vision] 비전 선택 실패 — 규칙 폴백: {e}")
|
||
return None
|
||
|
||
idx = result.choice_index
|
||
if not (0 <= idx < len(candidates)):
|
||
logger.warning(
|
||
f"[thumbnail_vision] 무효 인덱스({idx}, 후보 {len(candidates)}개) — 규칙 폴백"
|
||
)
|
||
return None
|
||
|
||
logger.info(
|
||
f"[thumbnail_vision] 비전 선택 — index={idx}"
|
||
f"{' (규칙 1위와 동일)' if idx == 0 else ' (규칙 1위 아님)'}, "
|
||
f"url={candidates[idx]['image_url']}, reason={result.reason}"
|
||
)
|
||
return candidates[idx]
|