335 lines
17 KiB
Python
335 lines
17 KiB
Python
"""Gemini Vision — 로고/브랜드 비주얼 자동 분석 (OpenAI 호환 모드).
|
|
|
|
정확한 hex 색상은 color_extractor가 CSS에서 직접 뽑음 (Vision은 근사값밖에 못 냄).
|
|
Vision은 사람이 봐야 알 수 있는 정성 정보 — 심볼 형태/워드마크/톤 — 를 담당.
|
|
"""
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import re
|
|
import ssl
|
|
import httpx
|
|
import resvg_py
|
|
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).
|
|
+ 한국 의료 사이트 중 SSL이 약해서 표준 검증에 실패하는 곳 대응 (3단 SSL fallback)."""
|
|
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
|
|
|
|
def _weak_ctx() -> ssl.SSLContext:
|
|
ctx = ssl.create_default_context()
|
|
try:
|
|
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
|
|
except ssl.SSLError:
|
|
pass
|
|
return ctx
|
|
|
|
last_err: Exception | None = None
|
|
for verify in (True, _weak_ctx(), False):
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
timeout=15.0, follow_redirects=True, headers=headers, verify=verify,
|
|
) 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
|
|
# SVG는 Gemini가 못 보므로 즉시 PNG로 래스터화 (resvg, in-memory ~1ms)
|
|
content = resp.content
|
|
if mime == "image/svg+xml" or url.lower().split("?")[0].endswith(".svg"):
|
|
try:
|
|
content = bytes(resvg_py.svg_to_bytes(svg_string=resp.text))
|
|
mime = "image/png"
|
|
except Exception as e:
|
|
logger.warning("[vision] svg rasterize failed %s: %s", url, e)
|
|
return None
|
|
size = len(content)
|
|
if size < 500:
|
|
logger.warning("[vision] %s too small (%d bytes) — likely placeholder", url, size)
|
|
return None
|
|
b64 = base64.b64encode(content).decode("ascii")
|
|
return f"data:{mime};base64,{b64}"
|
|
except (httpx.ConnectError, httpx.ReadError, ssl.SSLError) as e:
|
|
last_err = e
|
|
continue
|
|
except Exception as e:
|
|
logger.warning("[vision] fetch error %s: %s", url, e)
|
|
return None
|
|
logger.warning("[vision] fetch %s SSL fallback all failed: %s", url, last_err)
|
|
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 describe_svg_text(self, svg_url: str) -> dict | None:
|
|
"""SVG는 Gemini Vision이 못 보지만 XML 텍스트 자체는 LLM이 읽을 수 있음.
|
|
SVG 소스를 받아 그대로 text endpoint에 던지고 색·심볼·텍스트를 추론하게 함.
|
|
analyze_brand_assets와 동일한 스키마(logo_description/style/has_symbol/...) 반환."""
|
|
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
|
|
|
|
def _weak_ctx() -> ssl.SSLContext:
|
|
ctx = ssl.create_default_context()
|
|
try:
|
|
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
|
|
except ssl.SSLError:
|
|
pass
|
|
return ctx
|
|
|
|
svg_text: str | None = None
|
|
for verify in (True, _weak_ctx(), False):
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
timeout=15.0, follow_redirects=True, headers=headers, verify=verify,
|
|
) as c:
|
|
resp = await c.get(svg_url)
|
|
if resp.status_code == 200:
|
|
svg_text = resp.text
|
|
break
|
|
except (httpx.ConnectError, httpx.ReadError, ssl.SSLError):
|
|
continue
|
|
except Exception as e:
|
|
logger.warning("[vision] svg fetch error %s: %s", svg_url, e)
|
|
return None
|
|
if not svg_text:
|
|
logger.warning("[vision] svg fetch failed %s", svg_url)
|
|
return None
|
|
# 페이로드 폭주 방지 — 평범한 로고 SVG는 수 KB 수준
|
|
if len(svg_text) > 60000:
|
|
svg_text = svg_text[:60000]
|
|
|
|
prompt = (
|
|
"아래는 병원 로고 SVG 소스 코드입니다. SVG 마크업(path/circle/text/fill/stroke 등)을 "
|
|
"읽고 로고의 시각적 특징을 추론해 아래 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": "워드마크 텍스트 그대로. <text> 태그 내용 우선",\n'
|
|
' "logo_colors_desc": "쓰인 색감을 사람이 부르는 이름으로 (예: \'딥네이비 + 골드\'). hex 출력 금지"\n'
|
|
"}\n"
|
|
"주의: hex 값이나 URL은 출력하지 마세요 (별도 추출 로직 처리). 모든 텍스트는 한국어로.\n\n"
|
|
"SVG 소스:\n"
|
|
f"{svg_text}"
|
|
)
|
|
try:
|
|
resp = await self.client.chat.completions.create(
|
|
model=self.model,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
max_tokens=8000, # Gemini 2.5는 thinking 토큰을 max_tokens에서 차감하므로 여유 필요
|
|
)
|
|
choice = resp.choices[0]
|
|
if choice.finish_reason != "stop":
|
|
logger.warning("[vision] svg describe finish_reason=%s", choice.finish_reason)
|
|
result = self._extract_json(choice.message.content or "")
|
|
except Exception as e:
|
|
logger.warning("[vision] svg describe error: %s", e)
|
|
return None
|
|
if not result:
|
|
return None
|
|
result["logo_images"] = {"circle": None, "horizontal": svg_url, "korean": None}
|
|
return result
|
|
|
|
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": "로고에 쓰인 색감을 사람이 부르는 이름으로 서술 (예: \'딥네이비 + 골드\')",\n'
|
|
' "logo_colors_hex": ["로고에서 시각적으로 두드러진 색 정확히 5개의 hex 근사값 배열. 예: [\'#1A2B3C\', \'#D4A017\', \'#FFFFFF\', \'#9E5C2A\', \'#1F1F1F\']. 강한 색이 5개 안 되면 음영/명도 차이로 5개 채울 것. 빈 배열 금지."]\n'
|
|
"}\n"
|
|
"주의: logo_colors_hex 는 시각 추정이라 정확도 떨어질 수 있음. CSS 추출이 우선이고 이건 fallback/보완 용.\n"
|
|
"모든 설명/텍스트 값은 반드시 한국어로 작성하세요 (영어 금지)."
|
|
)
|
|
result = await self._ask(urls, prompt)
|
|
if not result:
|
|
return {}
|
|
# logo_images는 우리가 직접 채움 (Vision은 묘사만)
|
|
result["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None}
|
|
# logo_colors_hex 5개 강제 정규화 — LLM 이 4개나 6개 줄 수도 있어서 길이 fallback.
|
|
hex_list = [h for h in (result.get("logo_colors_hex") or []) if isinstance(h, str) and h.startswith("#")]
|
|
if hex_list:
|
|
while len(hex_list) < 5:
|
|
hex_list.append(hex_list[-1]) # 마지막 색 복제로 패딩
|
|
result["logo_colors_hex"] = hex_list[:5]
|
|
else:
|
|
result["logo_colors_hex"] = []
|
|
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"}
|
|
|
|
**3채널씩 묶어 병렬 호출** (한 번에 다 묶으면 LLM이 채널-이미지 매칭 헷갈려 같은 묘사를
|
|
여러 채널에 복사하는 문제 — VIEW 한국페북·영문인스타가 둘 다 "공식 로고" 묘사로 잘못
|
|
박혔던 케이스 — 가 있어서 분리. 1채널씩 N번보다 가성비 좋음)."""
|
|
items = [c for c in channel_logos if c.get("url")]
|
|
if not items:
|
|
return None
|
|
|
|
CHUNK = 3
|
|
|
|
async def _chunk(batch: list[dict]) -> list[dict]:
|
|
urls = [official_logo_url] + [c["url"] for c in batch] if official_logo_url else [c["url"] for c in batch]
|
|
n = len(batch)
|
|
# 이미지 번호 ↔ 채널 매핑 명시
|
|
if official_logo_url:
|
|
mapping = "이미지 1 = 공식 로고\n" + "\n".join(
|
|
f"이미지 {i+2} = {c.get('channel','?')} 채널 프로필" for i, c in enumerate(batch)
|
|
)
|
|
instruction = (
|
|
f"{mapping}\n\n"
|
|
f"이미지 2~{n+1}(채널 프로필 {n}개)을 각각 **그 이미지에 실제로 보이는 그대로** "
|
|
"한국어 1문장으로 묘사하세요 (색·형태·텍스트·배경 그대로).\n"
|
|
"❗ 공식 로고(이미지 1) 묘사를 절대 복사하지 마세요. 각 채널 이미지에 보이는 실제 특징만.\n"
|
|
"각 채널이 공식 로고와 시각적으로 거의 동일하면 is_official=true, "
|
|
"심볼/색/배경/텍스트가 다르거나 모델 사진이면 false.\n"
|
|
)
|
|
else:
|
|
mapping = "\n".join(f"이미지 {i+1} = {c.get('channel','?')} 채널 프로필" for i, c in enumerate(batch))
|
|
instruction = (
|
|
f"{mapping}\n\n"
|
|
f"각 이미지를 보이는 그대로 한국어 1문장으로 묘사 (색·형태·텍스트·배경).\n"
|
|
)
|
|
schema_lines = ",\n".join(
|
|
f' {{"channel": "{c.get("channel","?")}", "logo_description": "...", "is_official": true}}'
|
|
for c in batch
|
|
)
|
|
p = (
|
|
instruction
|
|
+ "\n아래 JSON으로만 응답 (코드펜스 없이, 순수 JSON):\n{\n"
|
|
+ f' "channel_logos": [\n{schema_lines}\n ]\n'
|
|
+ "}\n"
|
|
+ f"channel 필드는 위 매핑 그대로 ({', '.join(c.get('channel','?') for c in batch)}). "
|
|
+ "logo_description은 반드시 한국어 (영어 금지)."
|
|
)
|
|
r = await self._ask(urls, p)
|
|
if not r:
|
|
return []
|
|
out = []
|
|
for c in r.get("channel_logos", []):
|
|
out.append({
|
|
"channel": c.get("channel", ""),
|
|
"logo_description": c.get("logo_description", ""),
|
|
"is_official": bool(c.get("is_official", False)) if official_logo_url else None,
|
|
})
|
|
return out
|
|
|
|
# 3개씩 청크 → 병렬
|
|
chunks = [items[i:i+CHUNK] for i in range(0, len(items), CHUNK)]
|
|
results = await asyncio.gather(*[_chunk(b) for b in chunks], return_exceptions=True)
|
|
channel_logos_out: list[dict] = []
|
|
for r in results:
|
|
if isinstance(r, Exception):
|
|
logger.warning("[vision] channel_logo chunk error: %s", r)
|
|
continue
|
|
channel_logos_out.extend(r)
|
|
if not channel_logos_out:
|
|
return None
|
|
|
|
# 일관성 요약 + 권고는 결정적 산출 (LLM 한번 더 안 부름)
|
|
if official_logo_url:
|
|
mismatches = [c["channel"] for c in channel_logos_out if not c.get("is_official")]
|
|
if not mismatches:
|
|
summary = "모든 채널이 공식 로고를 일관되게 사용하고 있습니다."
|
|
rec = "현재 일관성 유지."
|
|
else:
|
|
summary = f"{len(mismatches)}개 채널({', '.join(mismatches)})이 공식 로고와 다른 이미지를 사용해 브랜드 일관성이 부족합니다."
|
|
rec = "비공식 채널 프로필을 공식 로고로 통일 권고."
|
|
else:
|
|
summary, rec = "", ""
|
|
|
|
return {
|
|
"channel_logos": channel_logos_out,
|
|
"inconsistency_summary": summary,
|
|
"recommendation": rec,
|
|
}
|