174 lines
8.3 KiB
Python
174 lines
8.3 KiB
Python
"""Gemini Vision — 로고/브랜드 비주얼 자동 분석 (OpenAI 호환 모드).
|
|
|
|
정확한 hex 색상은 color_extractor가 CSS에서 직접 뽑음 (Vision은 근사값밖에 못 냄).
|
|
Vision은 사람이 봐야 알 수 있는 정성 정보 — 심볼 형태/워드마크/톤 — 를 담당.
|
|
"""
|
|
import base64
|
|
import json
|
|
import logging
|
|
import re
|
|
import httpx
|
|
from openai import AsyncOpenAI
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_MODEL = "gemini-2.5-flash"
|
|
|
|
|
|
class VisionClient:
|
|
"""Gemini Vision을 OpenAI 호환 endpoint로 호출. GEMINI_API_KEY만 필요."""
|
|
|
|
def __init__(self, api_key: str, model: str = DEFAULT_MODEL, timeout: float = 30.0, max_retries: int = 2):
|
|
self.client = AsyncOpenAI(
|
|
api_key=api_key,
|
|
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
timeout=timeout,
|
|
max_retries=max_retries,
|
|
)
|
|
self.model = model
|
|
|
|
@staticmethod
|
|
def _extract_json(text: str) -> dict | None:
|
|
if not text:
|
|
return None
|
|
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
if m:
|
|
try:
|
|
return json.loads(m.group(1))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
m = re.search(r"\{.*\}", text, re.DOTALL)
|
|
if m:
|
|
try:
|
|
return json.loads(m.group(0))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return None
|
|
|
|
@staticmethod
|
|
async def _fetch_as_data_url(url: str) -> str | None:
|
|
"""Gemini는 URL 직접 fetch가 막힌 호스트가 많아 base64 인라인으로 변환.
|
|
+ 'image does not exist' 같은 placeholder 이미지 거부 (작은 bytes / 잘못된 content-type)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as c:
|
|
resp = await c.get(url)
|
|
if resp.status_code != 200:
|
|
logger.warning("[vision] fetch %s status=%s", url, resp.status_code)
|
|
return None
|
|
mime = resp.headers.get("content-type", "").split(";")[0].strip()
|
|
# 실제 이미지가 아니면 거부 (HTML 페이지가 404 대신 200으로 리다이렉트 되는 경우)
|
|
if not mime.startswith("image/"):
|
|
logger.warning("[vision] %s not an image (content-type=%s)", url, mime)
|
|
return None
|
|
size = len(resp.content)
|
|
if size < 500:
|
|
logger.warning("[vision] %s too small (%d bytes) — likely placeholder", url, size)
|
|
return None
|
|
b64 = base64.b64encode(resp.content).decode("ascii")
|
|
return f"data:{mime};base64,{b64}"
|
|
except Exception as e:
|
|
logger.warning("[vision] fetch error %s: %s", url, e)
|
|
return None
|
|
|
|
async def _ask(self, image_urls: list[str], prompt: str, max_tokens: int = 4000) -> dict | None:
|
|
content: list[dict] = []
|
|
for u in image_urls:
|
|
if not u:
|
|
continue
|
|
data_url = await self._fetch_as_data_url(u)
|
|
if not data_url:
|
|
continue
|
|
content.append({"type": "image_url", "image_url": {"url": data_url}})
|
|
if not any(c.get("type") == "image_url" for c in content):
|
|
logger.warning("[vision] no images could be fetched")
|
|
return None
|
|
content.append({"type": "text", "text": prompt})
|
|
|
|
try:
|
|
resp = await self.client.chat.completions.create(
|
|
model=self.model,
|
|
messages=[{"role": "user", "content": content}],
|
|
max_tokens=max_tokens,
|
|
)
|
|
choice = resp.choices[0]
|
|
if choice.finish_reason != "stop":
|
|
logger.warning("[vision] unexpected finish_reason=%s", choice.finish_reason)
|
|
return self._extract_json(choice.message.content or "")
|
|
except Exception as e:
|
|
logger.warning("[vision] error: %s", e)
|
|
return None
|
|
|
|
async def analyze_brand_assets(
|
|
self,
|
|
logo_url: str | None,
|
|
homepage_url: str | None,
|
|
additional_images: list[str] | None = None,
|
|
) -> dict:
|
|
"""로고 이미지를 보고 정성 분석. 정확한 hex는 color_extractor가 따로 처리하므로 여기선 안 뽑음."""
|
|
urls = [u for u in [logo_url] + list(additional_images or []) if u]
|
|
if not urls:
|
|
return {}
|
|
prompt = (
|
|
"당신은 브랜드 로고 시각 분석가입니다. 첨부된 이미지(첫 번째가 병원의 대표 로고)를 보고 "
|
|
"아래 JSON 스키마로만 응답하세요. 코드펜스 없이 순수 JSON만 출력.\n"
|
|
"{\n"
|
|
' "logo_description": "로고를 1~2문장으로 설명 (심볼 형태 + 워드마크 + 전반적 톤). 예: \'둥근 잎사귀를 감싼 추상 심볼에 세리프 한글 워드마크, 차분하고 고급스러운 톤\'",\n'
|
|
' "logo_style": "minimal | illustrative | typographic | abstract 중 하나",\n'
|
|
' "has_symbol": "심볼/아이콘이 있으면 true, 글자만 있으면 false (boolean)",\n'
|
|
' "logo_symbol": "심볼이 묘사하는 대상 (예: \'잎사귀\', \'추상 곡선\'). 없으면 빈 문자열",\n'
|
|
' "logo_text": "로고에 보이는 워드마크 텍스트 그대로 (한글/영문). 없으면 빈 문자열",\n'
|
|
' "logo_colors_desc": "로고에 쓰인 색감을 사람이 부르는 이름으로 서술 (예: \'딥네이비 + 골드\'). 정확한 hex는 출력하지 말 것"\n'
|
|
"}\n"
|
|
"주의: 색상 hex 값이나 logo URL 같은 필드는 출력하지 마세요 (별도 추출 로직이 처리).\n"
|
|
"모든 설명/텍스트 값은 반드시 한국어로 작성하세요 (영어 금지)."
|
|
)
|
|
result = await self._ask(urls, prompt)
|
|
if not result:
|
|
return {}
|
|
# logo_images는 우리가 직접 채움 (Vision은 묘사만)
|
|
result["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None}
|
|
return result
|
|
|
|
async def describe_channel_logos(
|
|
self,
|
|
official_logo_url: str | None,
|
|
channel_logos: list[dict],
|
|
) -> dict | None:
|
|
"""채널별 프로필 이미지(로고)를 보고 각각 설명 + 공식 로고와 일치 여부 평가.
|
|
channel_logos: [{"channel": "Instagram", "url": "..."}, ...]
|
|
반환: {"channel_logos": [{"channel","logo_description","is_official"}], "inconsistency_summary", "recommendation"}"""
|
|
items = [c for c in channel_logos if c.get("url")]
|
|
if not items:
|
|
return None
|
|
|
|
# 공식 로고가 있으면 맨 앞에 두고 기준으로 삼음
|
|
urls: list[str] = []
|
|
if official_logo_url:
|
|
urls.append(official_logo_url)
|
|
urls.extend(c["url"] for c in items)
|
|
channel_order = ", ".join(c.get("channel", "?") for c in items)
|
|
|
|
if official_logo_url:
|
|
header = (
|
|
"첨부 이미지 중 **첫 번째가 이 병원의 공식 로고**입니다. "
|
|
f"이어지는 이미지들은 채널별 프로필 이미지이며 순서는: {channel_order}.\n"
|
|
"각 채널 로고를 1문장으로 설명하고, 공식 로고(첫 번째)와 일치하면 is_official=true, "
|
|
"비공식 변형/모델사진/다른 이미지면 false로 평가하세요.\n"
|
|
)
|
|
else:
|
|
header = (
|
|
f"첨부 이미지는 한 병원의 채널별 프로필 이미지입니다. 순서: {channel_order}.\n"
|
|
"각 채널 로고를 1문장으로 설명하세요 (공식 로고 기준이 없으므로 is_official은 판단 가능하면만).\n"
|
|
)
|
|
prompt = (
|
|
header
|
|
+ "아래 JSON으로만 응답 (코드펜스 없이 순수 JSON):\n"
|
|
"{\n"
|
|
' "channel_logos": [{"channel": "...", "logo_description": "...", "is_official": true}],\n'
|
|
' "inconsistency_summary": "채널 간 로고 일관성 1~2문장 요약",\n'
|
|
' "recommendation": "통합 권고 1문장"\n'
|
|
"}\n"
|
|
"모든 logo_description·inconsistency_summary·recommendation은 반드시 한국어로 작성하세요 (영어 금지)."
|
|
)
|
|
return await self._ask(urls, prompt)
|