kintex/tools/nanobanana/client.py
zio 3da6b1c092 feat(harness): ReRoomAI 분석 반영 — PLANNING v1.1 나노바나나 파이프라인 확정 + client.py 재작성 + Stitch 전달
- 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>
2026-07-11 16:40:11 +09:00

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 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음",
},
)