- docs/analysis/reroomai-source.md 신규: gemini-3.1-flash-image-preview image-to-image, 보존/교체 프롬프트, CompareSlider/전처리 차용 - PLANNING v1.1: §6-4/6-5 나노바나나 호출스택·부스 프롬프트 빌더 확정, R11 해소·R12(Gemini 외부API 승인 게이트) 추가 - tools/nanobanana/client.py 전면 재작성: build_booth_prompt 4단 조립, 메타데이터 임베드, S6 래스터 합성 분리, seed 폴백, 방어 로직 - BACKLOG B-02/B-03/B-09/B-11/B-12 done - kintex.com 2차 검증 순증 사실 반영(기본부스 사양·이격·홀 스펙) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
819 lines
33 KiB
Python
819 lines
33 KiB
Python
"""나노바나나(Gemini 이미지 생성) 연동 클라이언트.
|
|
|
|
킨텍스 AI 시스템의 모든 이미지 생성은 이 모듈을 통해서만 수행한다.
|
|
설계 기준: ReRoomAI 실증 패턴(docs/analysis/reroomai-source.md) +
|
|
PLANNING §6 나노바나나 시각화 파이프라인.
|
|
|
|
핵심 패턴 (ReRoomAI 차용):
|
|
1. 호출 스택 — google-genai SDK로 gemini-3.1-flash-image-preview(나노바나나 2)를
|
|
image-to-image 편집 호출. 참조 이미지(inlineData: mimeType+base64) + 지시문(text)을
|
|
parts 배열로 전달해 부스 골격을 보존하고 스타일만 사실화한다.
|
|
2. 프롬프트 = 구조화 사전 조립 + "보존 잠금 / 교체 지정 / 사진 품질" 3단 분리.
|
|
3. 프로덕션 방어 — 입력 크기 가드, SAFETY 처리, 에러 분기, 성공 시에만 쿼터 차감.
|
|
|
|
보안:
|
|
- API 키는 환경변수 GEMINI_API_KEY 에서만 로드한다.
|
|
- 키/네트워크 없이도 import·구조가 성립하도록 방어적으로 작성(SDK 지연 임포트).
|
|
- 실제 Gemini 호출은 소유자 승인 대기 사항(reroomai-source.md §8) — 승인 전 실호출 강제 금지.
|
|
|
|
사용 예 (승인 후):
|
|
from tools.nanobanana.client import NanoBananaClient
|
|
client = NanoBananaClient()
|
|
img = client.render_shot(scene, shot_preset="S1", reference_image="empty_booth.jpg")
|
|
img.save("output/visualizations/booth_S1.png") # 사이드카 .meta.json 동시 기록
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 모델 / 경로 상수
|
|
# ----------------------------------------------------------------------------
|
|
#: 기본 모델 = 나노바나나 2 (ReRoomAI 검증 모델). 환경변수로 오버라이드 가능.
|
|
MODEL_NAME = os.environ.get("NANOBANANA_MODEL", "gemini-3.1-flash-image-preview")
|
|
|
|
PROMPTS_DIR = Path(__file__).parent / "prompts"
|
|
|
|
#: 클라이언트 Canvas 전처리와 정합(ReRoomAI §4-D) — 긴 쪽 1024px / JPEG 품질 0.85
|
|
MAX_UPLOAD_SIDE = 1024
|
|
UPLOAD_JPEG_QUALITY = 85
|
|
|
|
#: 입력 이미지 상한 (ReRoomAI route.ts content-length 8MB 가드와 정합)
|
|
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 예외 계층 (프로덕션 에러 분기용)
|
|
# ----------------------------------------------------------------------------
|
|
class NanoBananaError(RuntimeError):
|
|
"""나노바나나 연동 일반 오류."""
|
|
|
|
|
|
class NanoBananaAuthError(NanoBananaError):
|
|
"""API 키 무효/누락."""
|
|
|
|
|
|
class NanoBananaQuotaError(NanoBananaError):
|
|
"""쿼터 초과 / 429 RESOURCE_EXHAUSTED."""
|
|
|
|
|
|
class NanoBananaSafetyError(NanoBananaError):
|
|
"""콘텐츠 안전 정책 차단(finishReason == SAFETY)."""
|
|
|
|
|
|
# ============================================================================
|
|
# 구조화 프롬프트 사전 (ReRoomAI lib/constants.ts 패턴의 부스 도메인 치환)
|
|
# 각 항목 { label(한글), prompt(영문) [, swatch] } — UI 라벨 ↔ 서버 프롬프트 단일 출처.
|
|
# ============================================================================
|
|
|
|
#: 부스 타입 — PLANNING §6-2 booth.type 값과 매핑
|
|
BOOTH_TYPES: dict[str, dict[str, str]] = {
|
|
"independent": {
|
|
"label": "독립부스(목공)",
|
|
"prompt": "an independent custom-built wooden booth with solid walls and a fascia band",
|
|
},
|
|
"assembly": {
|
|
"label": "조립부스(시스템)",
|
|
"prompt": "a modular system booth built from octanorm aluminium frames and infill panels",
|
|
},
|
|
"corner": {
|
|
"label": "코너부스",
|
|
"prompt": "a corner booth open on two adjacent aisle sides",
|
|
},
|
|
"island": {
|
|
"label": "아일랜드부스",
|
|
"prompt": "an island booth open on all four sides with a central tower feature",
|
|
},
|
|
}
|
|
|
|
#: 부스 스타일 — render_hints.style 세분화. swatch 는 색 참조용(주 색상은 design 에서 별도 주입).
|
|
BOOTH_STYLES: dict[str, dict[str, Any]] = {
|
|
"luxury": {
|
|
"label": "럭셔리",
|
|
"swatch": ["#1a1a1a", "#c9a24b", "#f4f1ea"],
|
|
"prompt": "premium luxury tradeshow style: warm gold accents, dark stone-look panels, "
|
|
"backlit brand wall, layered warm accent lighting, polished finishes",
|
|
},
|
|
"tech": {
|
|
"label": "테크",
|
|
"swatch": ["#0a0e27", "#00d4ff", "#e6f7ff"],
|
|
"prompt": "high-tech style: clean matte panels, cool LED edge lighting, large digital "
|
|
"display walls, minimal seams, dark base with cyan light accents",
|
|
},
|
|
"eco": {
|
|
"label": "친환경",
|
|
"swatch": ["#2f4f3e", "#a8c686", "#f2efe6"],
|
|
"prompt": "eco / natural style: light wood, plants and greenery, recycled material "
|
|
"textures, soft daylight-balanced lighting, matte earth tones",
|
|
},
|
|
"minimal": {
|
|
"label": "미니멀",
|
|
"swatch": ["#ffffff", "#d8d8d8", "#333333"],
|
|
"prompt": "minimal style: white matte walls, clean lines, single accent color, "
|
|
"even soft lighting, uncluttered product plinths",
|
|
},
|
|
"photoreal_tradeshow": { # PLANNING §6-2 render_hints.style 기본값 폴백
|
|
"label": "표준 사실화",
|
|
"swatch": ["#ffffff", "#0052A5", "#333333"],
|
|
"prompt": "clean professional tradeshow style: balanced neutral finishes with the brand "
|
|
"accent color, even exhibition lighting, tidy product presentation",
|
|
},
|
|
}
|
|
|
|
#: 집기/배선 레이어별 프롬프트 조각 — 레이어 on/off 로 S2(조명)·S4(집기) 등 변형.
|
|
FIXTURE_LAYERS: dict[str, dict[str, str]] = {
|
|
"furniture": {
|
|
"label": "집기",
|
|
"prompt": "reception desk, product display shelves, consultation table with stools, "
|
|
"brand banners and printed fascia signage, floor carpet inside the booth",
|
|
},
|
|
"lighting": {
|
|
"label": "조명",
|
|
"prompt": "ceiling spotlights and track lights aimed at products, backlit logo, "
|
|
"linear accent lighting along shelves",
|
|
},
|
|
"electrical": {
|
|
"label": "전기",
|
|
"prompt": "power outlets and floor-box connections at fixtures, cable management hidden "
|
|
"in wall cavities and under the raised carpet edge",
|
|
},
|
|
"network": {
|
|
"label": "네트워크",
|
|
"prompt": "network access point and LAN drops for the demo stations, discreetly mounted",
|
|
},
|
|
}
|
|
|
|
#: 표준 샷 세트 S1~S7 (PLANNING §6-3). camera=구도 문구, mode=조명 모드 override,
|
|
#: mode_hint=낮/밤 분위기, generative=생성형 여부(S6 는 백엔드 래스터 합성 경로).
|
|
SHOT_PRESETS: dict[str, dict[str, Any]] = {
|
|
"S1": {
|
|
"label": "부스 정면 주간",
|
|
"camera": "eye-level front view from the visitor aisle, full-frame 24mm lens at 1.6m height",
|
|
"mode": "day",
|
|
"generative": True,
|
|
},
|
|
"S2": {
|
|
"label": "야간/점등 뷰",
|
|
"camera": "same front framing as S1, evening ambience emphasising the booth's own lighting",
|
|
"mode": "night",
|
|
"generative": True,
|
|
},
|
|
"S3": {
|
|
"label": "통로 뷰",
|
|
"camera": "three-quarter view from the adjacent aisle, neighbouring booths partly visible",
|
|
"mode": "day",
|
|
"generative": True,
|
|
},
|
|
"S4": {
|
|
"label": "부스 내부",
|
|
"camera": "interior view from the consultation zone looking out, showing fixtures and layout",
|
|
"mode": "day",
|
|
"generative": True,
|
|
},
|
|
"S5": {
|
|
"label": "Before/After",
|
|
"camera": "eye-level front view identical to S1 for before/after pairing",
|
|
"mode": "day",
|
|
"generative": True,
|
|
},
|
|
"S6": {
|
|
"label": "배선 오버레이",
|
|
"camera": "top-down floor plan overlay",
|
|
"mode": "day",
|
|
"generative": False, # ★ 백엔드 래스터 합성 경로 (render_wiring_overlay_raster)
|
|
},
|
|
"S7": {
|
|
"label": "홀 전경(조감)",
|
|
"camera": "high-angle isometric overview of the exhibition hall floor with this booth in context",
|
|
"mode": "day",
|
|
"generative": True,
|
|
},
|
|
}
|
|
|
|
#: S6 배선 색상 규약 (design.md §1-2 / PLANNING §6-3과 동일)
|
|
WIRING_COLORS = {"power": "red", "network": "blue", "plumbing": "green"}
|
|
|
|
|
|
# ============================================================================
|
|
# 프롬프트 빌더 — "보존 잠금 / 교체 지정 / 사진 품질" 3단 조립
|
|
# ============================================================================
|
|
def _preserve_lock(scene: dict) -> str:
|
|
"""보존 잠금부: 부스 골격·구도를 그대로 유지하라는 명시적 지시(ReRoomAI 보존 패턴)."""
|
|
return (
|
|
"Keep the booth structure exactly the same as the reference: outer footprint dimensions, "
|
|
"structural columns, the floor trench grid, the ceiling truss layout, the aisle orientation "
|
|
"and the camera angle must all stay identical. Do not move walls or change proportions."
|
|
)
|
|
|
|
|
|
def _replace_spec(scene: dict, layers: list[str]) -> str:
|
|
"""교체 지정부: design/lighting/wiring 스키마를 자연어로 직렬화하고 교체 대상을 명시."""
|
|
design = scene.get("design", {}) or {}
|
|
lighting = scene.get("lighting", {}) or {}
|
|
|
|
parts: list[str] = ["Build and place the following into the booth:"]
|
|
|
|
# 활성 레이어별 조각 조립
|
|
for key in layers:
|
|
frag = FIXTURE_LAYERS.get(key)
|
|
if frag:
|
|
parts.append(f"- {frag['prompt']}.")
|
|
|
|
# 간판 문구 (한글, 정확 렌더 지시 — 실패 시 후처리 합성은 SKILL.md 참조)
|
|
signage = (design.get("signage") or {}).get("text")
|
|
if signage:
|
|
parts.append(f'- Fascia signage text, render exactly in Korean: "{signage}".')
|
|
|
|
# 자재/마감
|
|
materials = design.get("materials") or []
|
|
if materials:
|
|
mat_str = ", ".join(
|
|
m.get("finish", str(m)) if isinstance(m, dict) else str(m) for m in materials
|
|
)
|
|
parts.append(f"- Materials and finishes: {mat_str}.")
|
|
|
|
# 존 구성
|
|
zones = design.get("zones") or []
|
|
if zones:
|
|
zone_str = ", ".join(z.get("type", str(z)) if isinstance(z, dict) else str(z) for z in zones)
|
|
parts.append(f"- Functional zones: {zone_str}.")
|
|
|
|
# 조명 색온도
|
|
color_temp = lighting.get("color_temp_k")
|
|
if color_temp:
|
|
parts.append(f"- Overall lighting color temperature about {color_temp}K.")
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _photo_quality(mode: str) -> str:
|
|
"""사진 품질부."""
|
|
tod = "evening / after-hours" if mode == "night" else "bright daytime hall"
|
|
return (
|
|
f"Photorealistic tradeshow photography, {tod} lighting, natural exhibition ambient light "
|
|
"with visible ceiling trusses, sharp focus, high detail. No people, no real brand logos "
|
|
"other than the specified signage text. It must look like a real construction-completion "
|
|
"documentation photo, not a 3D render."
|
|
)
|
|
|
|
|
|
def _scene_view(scene: dict) -> tuple[dict, dict, dict]:
|
|
"""scene 에서 hall/booth/design 을 관대하게 추출(중첩/평면 모두 허용)."""
|
|
inner = scene.get("scene", scene)
|
|
hall = inner.get("hall", {}) or {}
|
|
booth = inner.get("booth", {}) or {}
|
|
design = inner.get("design", {}) or {}
|
|
return hall, booth, design
|
|
|
|
|
|
def build_booth_prompt(
|
|
scene: dict,
|
|
shot_preset: str = "S1",
|
|
layers: Optional[list[str]] = None,
|
|
) -> str:
|
|
"""PLANNING §6-2 scene 스키마 → 최종 지시문.
|
|
|
|
scene:
|
|
hall: { id, dims_m, ceiling_m, floor }
|
|
booth: { id, polygon, size_m, height_m, type }
|
|
design: { walls, signage:{text,height_m}, zones, materials }
|
|
lighting: { fixtures, mode }
|
|
wiring: { power, network }
|
|
shot / render_hints (top-level 또는 scene 내부 모두 허용)
|
|
|
|
반환: "① 대상+스타일 → ② 보존 잠금 → ③ 교체 지정 → ④ 사진 품질" 4단 지시문.
|
|
"""
|
|
inner = scene.get("scene", scene)
|
|
hall = inner.get("hall", {}) or {}
|
|
booth = inner.get("booth", {}) or {}
|
|
design = inner.get("design", {}) or {}
|
|
lighting = inner.get("lighting", {}) or {}
|
|
render_hints = scene.get("render_hints") or inner.get("render_hints") or {}
|
|
|
|
preset = SHOT_PRESETS.get(shot_preset, SHOT_PRESETS["S1"])
|
|
mode = lighting.get("mode") or preset.get("mode", "day")
|
|
|
|
# 활성 레이어 기본값: 샷별. S2 는 조명 강조, S4 는 집기 강조.
|
|
if layers is None:
|
|
if shot_preset == "S2":
|
|
layers = ["lighting", "furniture"]
|
|
elif shot_preset == "S4":
|
|
layers = ["furniture", "electrical", "network"]
|
|
else:
|
|
layers = ["furniture", "lighting", "electrical", "network"]
|
|
|
|
# 부스 타입 / 스타일 사전 조회
|
|
btype = BOOTH_TYPES.get(booth.get("type", ""), BOOTH_TYPES["independent"])
|
|
style_id = render_hints.get("style", "photoreal_tradeshow")
|
|
style = BOOTH_STYLES.get(style_id, BOOTH_STYLES["photoreal_tradeshow"])
|
|
|
|
size = booth.get("size_m") or [3, 3]
|
|
w, d = (size[0], size[1]) if len(size) >= 2 else (3, 3)
|
|
hall_name = hall.get("id") or hall.get("name") or "a KINTEX exhibition hall"
|
|
brand = (design.get("brand_color")) or "#0052A5"
|
|
|
|
# ① 대상 + 스타일
|
|
header = (
|
|
f"Photorealistic professional photograph of a completed {btype['prompt']} "
|
|
f"at {hall_name}, KINTEX convention center in South Korea, "
|
|
f"{w}m wide x {d}m deep, immediately after construction, before visitors arrive. "
|
|
f"Primary brand color {brand}. Design style: {style['prompt']}. "
|
|
f"Camera: {preset['camera']}."
|
|
)
|
|
|
|
return "\n\n".join(
|
|
[
|
|
header,
|
|
_preserve_lock(inner),
|
|
_replace_spec(inner, layers),
|
|
_photo_quality(mode),
|
|
]
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# 이미지 전처리 유틸 (ReRoomAI Canvas 전처리의 서버측 대응)
|
|
# ============================================================================
|
|
def downscale_for_upload(
|
|
source: str | Path | bytes,
|
|
max_side: int = MAX_UPLOAD_SIDE,
|
|
jpeg_quality: int = UPLOAD_JPEG_QUALITY,
|
|
) -> tuple[bytes, str]:
|
|
"""긴 쪽 max_side px 다운스케일 + JPEG 품질 압축 → (bytes, mime_type).
|
|
|
|
PIL 미설치 시 원본을 그대로 반환(mime 추정). ReRoomAI Studio.handleImageFile 등가.
|
|
"""
|
|
if isinstance(source, (str, Path)):
|
|
raw = Path(source).read_bytes()
|
|
guessed = mimetypes.guess_type(str(source))[0] or "image/png"
|
|
else:
|
|
raw = source
|
|
guessed = "image/png"
|
|
|
|
if len(raw) > MAX_IMAGE_BYTES:
|
|
raise NanoBananaError(
|
|
f"입력 이미지가 상한({MAX_IMAGE_BYTES // (1024 * 1024)}MB)을 초과합니다."
|
|
)
|
|
|
|
try:
|
|
import io
|
|
|
|
from PIL import Image # type: ignore
|
|
except ImportError:
|
|
return raw, guessed # PIL 없으면 원본 유지
|
|
|
|
with Image.open(io.BytesIO(raw)) as im:
|
|
im = im.convert("RGB")
|
|
long_side = max(im.size)
|
|
if long_side > max_side:
|
|
scale = max_side / long_side
|
|
new_size = (round(im.size[0] * scale), round(im.size[1] * scale))
|
|
im = im.resize(new_size, Image.LANCZOS)
|
|
out = io.BytesIO()
|
|
im.save(out, format="JPEG", quality=jpeg_quality)
|
|
return out.getvalue(), "image/jpeg"
|
|
|
|
|
|
# ============================================================================
|
|
# 메타데이터 (B-03: 생성일·스키마 해시·모델 버전 결정적 임베드)
|
|
# ============================================================================
|
|
def _schema_hash(scene: dict) -> str:
|
|
"""scene 스키마의 결정적 SHA-256 해시(정렬 직렬화). 캐시 키·감사 추적용."""
|
|
canon = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def build_metadata(scene: dict, model: str, shot_preset: str, seed: Optional[int]) -> dict:
|
|
return {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"schema_hash": _schema_hash(scene),
|
|
"model_version": model,
|
|
"shot_preset": shot_preset,
|
|
"seed": seed,
|
|
"watermark_required": True,
|
|
"watermark_text": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음",
|
|
"notice": "계약·심사 서류 사용 금지(도면만 유효)", # PLANNING §6-5
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class GeneratedImage:
|
|
data: bytes
|
|
mime_type: str
|
|
prompt_used: str
|
|
metadata: dict = field(default_factory=dict)
|
|
|
|
def save(self, path: str | Path) -> Path:
|
|
"""이미지 저장 + 결정적 메타데이터 임베드.
|
|
|
|
- 사이드카 JSON(`<path>.meta.json`)을 항상 기록(결정적).
|
|
- PNG 이면 tEXt 청크에 메타데이터를 추가 임베드(PIL 있을 때).
|
|
"""
|
|
p = Path(path)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
wrote_embedded = False
|
|
if p.suffix.lower() == ".png":
|
|
try:
|
|
import io
|
|
|
|
from PIL import Image # type: ignore
|
|
from PIL.PngImagePlugin import PngInfo # type: ignore
|
|
|
|
info = PngInfo()
|
|
for k, v in self.metadata.items():
|
|
info.add_text(f"kintex:{k}", json.dumps(v, ensure_ascii=False))
|
|
with Image.open(io.BytesIO(self.data)) as im:
|
|
im.save(p, format="PNG", pnginfo=info)
|
|
wrote_embedded = True
|
|
except Exception:
|
|
wrote_embedded = False
|
|
|
|
if not wrote_embedded:
|
|
p.write_bytes(self.data)
|
|
|
|
# 사이드카 JSON — 항상, 결정적
|
|
sidecar = p.with_suffix(p.suffix + ".meta.json")
|
|
sidecar.write_text(
|
|
json.dumps(self.metadata, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
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, image-to-image 편집.
|
|
|
|
seed 지원 여부는 런타임에 자동 판별(B-12). 미지원 시 참조체인 기반 일관성으로 폴백.
|
|
usage_recorder: 성공 시에만 호출되는 훅(쿼터/카운트 차감) — 실패는 소모 안 함.
|
|
"""
|
|
|
|
WIRING_COLORS = WIRING_COLORS
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: Optional[str] = None,
|
|
model: str = MODEL_NAME,
|
|
usage_recorder: Optional[Callable[[dict], None]] = None,
|
|
):
|
|
# 보안: 키는 env GEMINI_API_KEY 에서만 로드. 인자 주입은 테스트/운영 편의용.
|
|
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
|
if not self.api_key:
|
|
raise NanoBananaAuthError(
|
|
"GEMINI_API_KEY 환경변수가 필요합니다. "
|
|
"https://aistudio.google.com 에서 발급 후 설정하세요."
|
|
)
|
|
self.model = model
|
|
self.usage_recorder = usage_recorder
|
|
self._seed_supported: Optional[bool] = None # None=미확인, True/False=판별됨
|
|
|
|
# 지연 임포트: 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 | bytes] = None,
|
|
seed: Optional[int] = None,
|
|
metadata: Optional[dict] = None,
|
|
) -> GeneratedImage:
|
|
"""프롬프트(+선택 참조 이미지) → 이미지 1장. image-to-image 편집.
|
|
|
|
참조 이미지는 업로드 전 긴 쪽 1024px 로 다운스케일된다(ReRoomAI 전처리).
|
|
"""
|
|
from google.genai import types # type: ignore
|
|
|
|
contents: list = []
|
|
if reference_image is not None:
|
|
img_bytes, mime = downscale_for_upload(reference_image)
|
|
contents.append(types.Part.from_bytes(data=img_bytes, mime_type=mime))
|
|
contents.append(prompt)
|
|
|
|
resp = self._call_model(contents, seed)
|
|
result = self._extract_image(resp, prompt, metadata or {})
|
|
|
|
# 성공 시에만 사용량 차감(ReRoomAI: 실패는 횟수 소모 안 함)
|
|
if self.usage_recorder is not None:
|
|
try:
|
|
self.usage_recorder(result.metadata)
|
|
except Exception:
|
|
pass
|
|
return result
|
|
|
|
def _build_config(self, seed: Optional[int]):
|
|
"""seed 지원 시 GenerateContentConfig(seed=...) 구성. 미지원이면 None."""
|
|
if seed is None or self._seed_supported is False:
|
|
return None
|
|
try:
|
|
from google.genai import types # type: ignore
|
|
|
|
return types.GenerateContentConfig(seed=seed)
|
|
except Exception:
|
|
self._seed_supported = False
|
|
return None
|
|
|
|
def _call_model(self, contents: list, seed: Optional[int]):
|
|
"""SDK 호출 + 에러 분기(키/쿼터/SAFETY). seed 폴백 포함(B-12)."""
|
|
cfg = self._build_config(seed)
|
|
try:
|
|
if cfg is not None:
|
|
resp = self._client.models.generate_content(
|
|
model=self.model, contents=contents, config=cfg
|
|
)
|
|
self._seed_supported = True
|
|
return resp
|
|
return self._client.models.generate_content(
|
|
model=self.model, contents=contents
|
|
)
|
|
except TypeError:
|
|
# seed/config 파라미터 미지원 SDK → 참조체인 일관성으로 폴백
|
|
self._seed_supported = False
|
|
return self._client.models.generate_content(
|
|
model=self.model, contents=contents
|
|
)
|
|
except Exception as e: # noqa: BLE001 — 메시지 기반 분류
|
|
self._raise_classified(e)
|
|
|
|
@staticmethod
|
|
def _raise_classified(e: Exception):
|
|
msg = str(e)
|
|
upper = msg.upper()
|
|
if "API_KEY_INVALID" in upper or "API KEY NOT VALID" in upper or "UNAUTHENT" in upper:
|
|
raise NanoBananaAuthError(
|
|
"API 키가 유효하지 않습니다. GEMINI_API_KEY 를 확인하세요."
|
|
) from e
|
|
if "RESOURCE_EXHAUSTED" in upper or "QUOTA" in upper or "429" in msg:
|
|
raise NanoBananaQuotaError(
|
|
"요청 한도(쿼터)를 초과했습니다. 잠시 후 다시 시도하세요."
|
|
) from e
|
|
if "SAFETY" in upper or "BLOCKED" in upper:
|
|
raise NanoBananaSafetyError(
|
|
"안전 정책에 의해 생성이 차단되었습니다. 지시문/참조 이미지를 조정하세요."
|
|
) from e
|
|
raise NanoBananaError(f"이미지 생성 실패: {msg}") from e
|
|
|
|
def _extract_image(self, resp, prompt: str, metadata: dict) -> GeneratedImage:
|
|
candidates = getattr(resp, "candidates", None) or []
|
|
if not candidates:
|
|
raise NanoBananaError("응답에 candidate가 없습니다: " + str(resp))
|
|
cand = candidates[0]
|
|
|
|
# finishReason == SAFETY 처리(ReRoomAI route.ts)
|
|
fr = getattr(cand, "finish_reason", None)
|
|
fr_name = getattr(fr, "name", None) or (str(fr) if fr is not None else "")
|
|
if "SAFETY" in fr_name.upper():
|
|
raise NanoBananaSafetyError(
|
|
"안전 정책에 의해 생성이 차단되었습니다(finishReason=SAFETY)."
|
|
)
|
|
|
|
content = getattr(cand, "content", None)
|
|
parts = getattr(content, "parts", None) or []
|
|
for part in parts:
|
|
inline = getattr(part, "inline_data", None)
|
|
if inline is not None and getattr(inline, "data", None):
|
|
return GeneratedImage(
|
|
data=inline.data,
|
|
mime_type=getattr(inline, "mime_type", "image/png"),
|
|
prompt_used=prompt,
|
|
metadata=metadata,
|
|
)
|
|
raise NanoBananaError("이미지가 반환되지 않았습니다: " + str(resp))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 고수준: 샷 렌더 (프롬프트 빌더 경유)
|
|
# ------------------------------------------------------------------
|
|
def render_shot(
|
|
self,
|
|
scene: dict,
|
|
shot_preset: str = "S1",
|
|
reference_image: Optional[str | Path | bytes] = None,
|
|
seed: Optional[int] = None,
|
|
layers: Optional[list[str]] = None,
|
|
) -> GeneratedImage:
|
|
"""PLANNING §6-2 scene → 표준 샷(S1~S7) 사실화 사진.
|
|
|
|
S6(배선 오버레이)는 생성형이 아니라 백엔드 래스터 합성 경로다 —
|
|
render_wiring_overlay_raster() 를 사용하라(여기서 호출 시 명시적 에러).
|
|
"""
|
|
preset = SHOT_PRESETS.get(shot_preset)
|
|
if preset is None:
|
|
raise NanoBananaError(f"알 수 없는 샷 프리셋: {shot_preset} (S1~S7)")
|
|
if not preset.get("generative", True):
|
|
raise NanoBananaError(
|
|
f"{shot_preset}({preset['label']})는 생성형이 아니라 "
|
|
"백엔드 래스터 합성 경로입니다 → render_wiring_overlay_raster() 사용."
|
|
)
|
|
prompt = build_booth_prompt(scene, shot_preset=shot_preset, layers=layers)
|
|
metadata = build_metadata(scene, self.model, shot_preset, seed)
|
|
return self.generate(
|
|
prompt, reference_image=reference_image, seed=seed, metadata=metadata
|
|
)
|
|
|
|
# 하위호환: 평면 booth_spec → scene 어댑트 후 render_shot 위임 --------
|
|
def generate_booth_photo(
|
|
self,
|
|
booth_spec: dict,
|
|
view: str = "front",
|
|
time_of_day: str = "day",
|
|
reference_image: Optional[str | Path | bytes] = None,
|
|
seed: Optional[int] = None,
|
|
) -> GeneratedImage:
|
|
"""[하위호환] 평면 booth_spec dict → 시공 후 사진.
|
|
|
|
내부적으로 §6-2 scene 으로 변환 후 render_shot 위임.
|
|
view: front|aisle|interior|aerial, time_of_day: day|night.
|
|
"""
|
|
view_to_shot = {
|
|
("front", "day"): "S1",
|
|
("front", "night"): "S2",
|
|
("aisle", "day"): "S3",
|
|
("interior", "day"): "S4",
|
|
("aerial", "day"): "S7",
|
|
}
|
|
shot = view_to_shot.get((view, time_of_day))
|
|
if shot is None:
|
|
shot = {"front": "S1", "aisle": "S3", "interior": "S4", "aerial": "S7"}.get(
|
|
view, "S1"
|
|
)
|
|
if time_of_day == "night":
|
|
shot = "S2"
|
|
|
|
size = booth_spec.get("size_m", (3, 3))
|
|
scene = {
|
|
"hall": {"id": booth_spec.get("hall", "KINTEX exhibition hall")},
|
|
"booth": {
|
|
"size_m": list(size),
|
|
"type": _map_legacy_booth_type(booth_spec.get("booth_type", "")),
|
|
},
|
|
"design": {
|
|
"signage": {"text": booth_spec.get("signage", "")},
|
|
"brand_color": booth_spec.get("brand_color", "#0052A5"),
|
|
"materials": [{"finish": m} for m in booth_spec.get("materials", [])],
|
|
},
|
|
"lighting": {
|
|
"mode": time_of_day,
|
|
"color_temp_k": booth_spec.get("lighting", {}).get("color_temp_k", 4000),
|
|
},
|
|
"render_hints": {"style": booth_spec.get("style", "photoreal_tradeshow")},
|
|
}
|
|
return self.render_shot(
|
|
scene, shot_preset=shot, reference_image=reference_image, seed=seed
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# S6 생성형 오버레이 (발표/설명용 보조 — 시공 검증 아님)
|
|
# ------------------------------------------------------------------
|
|
def generate_wiring_overlay(
|
|
self, layout_json: dict, kind: str = "network"
|
|
) -> GeneratedImage:
|
|
"""[보조] 배치도 + 배선 계획 → 생성형 오버레이(발표/설명용).
|
|
|
|
★ 시공 검증용 S6 1차 산출물은 좌표 정합이 보장되는 백엔드 래스터 합성
|
|
(render_wiring_overlay_raster)이다. 이 생성형 이미지는 시공 검증에 쓰지 말 것.
|
|
"""
|
|
if kind not in self.WIRING_COLORS:
|
|
raise NanoBananaError(f"kind는 {list(self.WIRING_COLORS)} 중 하나: {kind}")
|
|
template = _load_template("wiring_overlay")
|
|
prompt = template.format(
|
|
kind=kind,
|
|
color=self.WIRING_COLORS[kind],
|
|
layout=json.dumps(layout_json, ensure_ascii=False),
|
|
)
|
|
return self.generate(prompt)
|
|
|
|
|
|
def _map_legacy_booth_type(s: str) -> str:
|
|
s = (s or "").lower()
|
|
if "island" in s or "아일랜드" in s:
|
|
return "island"
|
|
if "corner" in s or "코너" in s:
|
|
return "corner"
|
|
if "system" in s or "assembly" in s or "조립" in s or "시스템" in s:
|
|
return "assembly"
|
|
return "independent"
|
|
|
|
|
|
# ============================================================================
|
|
# B-02: S6 배선 오버레이 = 백엔드 래스터 합성 렌더러 (생성형 아님, PIL 로컬)
|
|
# ============================================================================
|
|
def render_wiring_overlay_raster(
|
|
wiring: dict,
|
|
hall_dims_m: tuple[float, float],
|
|
kinds: Optional[list[str]] = None,
|
|
base_image: Optional[str | Path] = None,
|
|
px_per_m: int = 40,
|
|
out_size: Optional[tuple[int, int]] = None,
|
|
) -> GeneratedImage:
|
|
"""도면 위 배선 경로를 좌표 정합으로 래스터 합성(S6 시공 검증용 1차 산출물).
|
|
|
|
생성형(나노바나나)이 아니라 로컬 PIL 합성이다 — 좌표가 도면과 정확히 일치한다.
|
|
|
|
wiring: PLANNING §6-2 scene.wiring
|
|
{ power: [{from_trench:[x,y], to:[x,y], kw}], network:[{path:[[x,y],...]}], plumbing:[...] }
|
|
hall_dims_m: (width_m, depth_m) — 좌표계 기준(미터).
|
|
kinds: 렌더할 레이어(기본: power/network/plumbing 중 존재하는 것 전부).
|
|
base_image: 있으면 배경 도면 위에 합성, 없으면 흰 배경.
|
|
색상 규약: power=적, network=청, plumbing=녹 (WIRING_COLORS).
|
|
"""
|
|
try:
|
|
import io
|
|
|
|
from PIL import Image, ImageDraw # type: ignore
|
|
except ImportError as e:
|
|
raise NanoBananaError("배선 래스터 합성에는 pip install Pillow 필요") from e
|
|
|
|
w_m, d_m = hall_dims_m
|
|
if out_size is None:
|
|
out_size = (int(w_m * px_per_m), int(d_m * px_per_m))
|
|
W, H = out_size
|
|
|
|
if base_image is not None:
|
|
with Image.open(base_image) as bg:
|
|
canvas = bg.convert("RGB").resize((W, H))
|
|
else:
|
|
canvas = Image.new("RGB", (W, H), "white")
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
def to_px(pt) -> tuple[int, int]:
|
|
return (int(pt[0] * px_per_m), int(pt[1] * px_per_m))
|
|
|
|
if kinds is None:
|
|
kinds = [k for k in ("power", "network", "plumbing") if wiring.get(k)]
|
|
|
|
rendered_counts: dict[str, int] = {}
|
|
for kind in kinds:
|
|
color = WIRING_COLORS.get(kind, "black")
|
|
runs = wiring.get(kind) or []
|
|
n = 0
|
|
for run in runs:
|
|
# 경로 형태 1: {path: [[x,y],...]}
|
|
path = run.get("path") if isinstance(run, dict) else None
|
|
# 경로 형태 2: {from_trench:[x,y], to:[x,y]}
|
|
if not path and isinstance(run, dict):
|
|
a = run.get("from_trench") or run.get("from")
|
|
b = run.get("to")
|
|
if a and b:
|
|
path = [a, b]
|
|
if not path:
|
|
continue
|
|
pts = [to_px(p) for p in path]
|
|
if len(pts) >= 2:
|
|
draw.line(pts, fill=color, width=3, joint="curve")
|
|
# 트렌치 포트 마커
|
|
draw.ellipse(
|
|
[pts[0][0] - 5, pts[0][1] - 5, pts[0][0] + 5, pts[0][1] + 5],
|
|
outline=color,
|
|
width=2,
|
|
)
|
|
n += 1
|
|
rendered_counts[kind] = n
|
|
|
|
# 범례 박스
|
|
lx, ly = 10, 10
|
|
for kind in kinds:
|
|
color = WIRING_COLORS.get(kind, "black")
|
|
draw.line([(lx, ly + 6), (lx + 24, ly + 6)], fill=color, width=3)
|
|
draw.text((lx + 30, ly), f"{kind}", fill="black")
|
|
ly += 18
|
|
|
|
buf = io.BytesIO()
|
|
canvas.save(buf, format="PNG")
|
|
return GeneratedImage(
|
|
data=buf.getvalue(),
|
|
mime_type="image/png",
|
|
prompt_used="[raster-composite] wiring overlay (non-generative)",
|
|
metadata={
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"render_path": "backend_raster_composite",
|
|
"kinds": kinds,
|
|
"rendered_counts": rendered_counts,
|
|
"px_per_m": px_per_m,
|
|
"watermark_required": True,
|
|
"watermark_text": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음",
|
|
},
|
|
)
|