kintex/tools/nanobanana/client.py
zio 8a488322c1 chore: kintex 하네스 초기 커밋 (로컬 소스)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:16:33 +09:00

168 lines
6.4 KiB
Python

"""나노바나나(Gemini 이미지 생성) 연동 클라이언트.
킨텍스 AI 시스템의 모든 이미지 생성은 이 모듈을 통해서만 수행한다.
환경변수 GEMINI_API_KEY 필요. 키가 없으면 명확한 에러를 낸다.
사용 예:
from tools.nanobanana.client import NanoBananaClient
client = NanoBananaClient()
result = client.generate_booth_photo(
booth_spec={
"hall": "제1전시장 1홀",
"size_m": (6, 3),
"booth_type": "독립부스(목공)",
"signage": "주식회사 가디아",
"brand_color": "#0052A5",
"materials": ["백색 무광 목공", "그레이 카펫"],
"lighting": {"color_temp_k": 4000, "accents": ["로고 스포트라이트"]},
},
view="front", # front(S1) | aisle(S3) | interior(S4) | aerial(S7 홀 조감)
time_of_day="day", # day | night(S2 조명 평가용)
reference_image=None, # 빈 부스 실측 사진 경로 (구조 보존 합성용, S5 before/after의 before)
)
result.save("booth_after.png")
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
MODEL_NAME = os.environ.get("NANOBANANA_MODEL", "gemini-2.5-flash-image")
PROMPTS_DIR = Path(__file__).parent / "prompts"
class NanoBananaError(RuntimeError):
pass
@dataclass
class GeneratedImage:
data: bytes
mime_type: str
prompt_used: str
def save(self, path: str | Path) -> Path:
p = Path(path)
p.write_bytes(self.data)
return p
def _load_template(name: str) -> str:
f = PROMPTS_DIR / f"{name}.txt"
if not f.exists():
raise NanoBananaError(f"프롬프트 템플릿 없음: {f}")
return f.read_text(encoding="utf-8")
class NanoBananaClient:
"""Gemini 이미지 생성 API 래퍼. google-genai SDK 사용."""
def __init__(self, api_key: Optional[str] = None, model: str = MODEL_NAME):
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
if not self.api_key:
raise NanoBananaError(
"GEMINI_API_KEY 환경변수가 필요합니다. "
"https://aistudio.google.com 에서 발급 후 설정하세요."
)
self.model = model
# 지연 임포트: SDK 미설치 환경에서도 모듈 로드는 가능하게
try:
from google import genai # type: ignore
except ImportError as e:
raise NanoBananaError("pip install google-genai 필요") from e
self._client = genai.Client(api_key=self.api_key)
# ------------------------------------------------------------------
def generate(
self,
prompt: str,
reference_image: Optional[str | Path] = None,
) -> GeneratedImage:
"""저수준 생성. 프롬프트(+선택적 참조 이미지) → 이미지 1장."""
contents: list = []
if reference_image:
import mimetypes
from google.genai import types # type: ignore
ref = Path(reference_image)
mime = mimetypes.guess_type(ref.name)[0] or "image/png"
contents.append(
types.Part.from_bytes(data=ref.read_bytes(), mime_type=mime)
)
contents.append(prompt)
resp = self._client.models.generate_content(
model=self.model, contents=contents
)
for part in resp.candidates[0].content.parts:
if getattr(part, "inline_data", None):
return GeneratedImage(
data=part.inline_data.data,
mime_type=part.inline_data.mime_type,
prompt_used=prompt,
)
raise NanoBananaError("이미지가 반환되지 않았습니다: " + str(resp))
# ------------------------------------------------------------------
def generate_booth_photo(
self,
booth_spec: dict,
view: str = "front",
time_of_day: str = "day",
reference_image: Optional[str | Path] = None,
) -> GeneratedImage:
"""부스 스펙 → 시공 후 사실적 사진. 템플릿: prompts/booth_photo.txt"""
template = _load_template("booth_photo")
w, d = booth_spec.get("size_m", (3, 3))
view_phrase = {
"front": "eye-level front view of the booth",
"aisle": "view from the visitor aisle looking at the booth",
"interior": "view from inside the booth showing interior fixtures",
"aerial": "high-angle aerial overview of the exhibition hall floor with this booth visible in context", # S7
}.get(view, view)
prompt = template.format(
hall=booth_spec.get("hall", "KINTEX exhibition hall"),
width=w,
depth=d,
booth_type=booth_spec.get("booth_type", "custom wooden booth"),
signage=booth_spec.get("signage", ""),
brand_color=booth_spec.get("brand_color", "#0052A5"),
materials=", ".join(booth_spec.get("materials", [])),
color_temp=booth_spec.get("lighting", {}).get("color_temp_k", 4000),
accents=", ".join(booth_spec.get("lighting", {}).get("accents", [])),
view=view_phrase,
time_of_day=time_of_day,
)
return self.generate(prompt, reference_image=reference_image)
#: S6 배선 색상 규약 (design.md §1-2와 동일)
WIRING_COLORS = {"power": "red", "network": "blue", "plumbing": "green"}
def generate_wiring_overlay(
self, layout_json: dict, kind: str = "network"
) -> GeneratedImage:
"""배치도 + 배선 계획 → 시공 안내용 오버레이 이미지(S6).
kind: network(청) | power(적) | plumbing(급배수, 녹)
※ 주의(PLANNING §6-3): S6의 1차 산출물은 좌표 정합이 보장되는
래스터 합성(백엔드 렌더러)이며, 이 생성형 오버레이는 발표/설명용
보조 이미지다. 시공 검증용으로 사용하지 말 것.
"""
if kind not in self.WIRING_COLORS:
raise NanoBananaError(f"kind는 {list(self.WIRING_COLORS)} 중 하나: {kind}")
template = _load_template("wiring_overlay")
import json
prompt = template.format(
kind=kind,
color=self.WIRING_COLORS[kind],
layout=json.dumps(layout_json, ensure_ascii=False),
)
return self.generate(prompt)