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>
This commit is contained in:
parent
8a488322c1
commit
3da6b1c092
@ -5,16 +5,63 @@ description: 부스/인테리어/배선/조명 설계 데이터를 나노바나
|
|||||||
|
|
||||||
# 나노바나나 시공 결과 시각화 스킬
|
# 나노바나나 시공 결과 시각화 스킬
|
||||||
|
|
||||||
|
ReRoomAI 실증 패턴(`docs/analysis/reroomai-source.md`)을 표준화한 파이프라인.
|
||||||
|
"부스 골격 보존 + 표면(집기·조명·배선) 교체" image-to-image 편집.
|
||||||
|
|
||||||
|
## 호출 표준
|
||||||
|
- **모델**: `gemini-3.1-flash-image-preview` (나노바나나 2). 환경변수 `NANOBANANA_MODEL` 로 오버라이드 가능.
|
||||||
|
- **호출**: `tools/nanobanana/client.py`의 `NanoBananaClient`만 사용(직접 API 호출 금지).
|
||||||
|
google-genai(`google.genai`) SDK로 `generate_content(model, contents=[Part.from_bytes(...), text])`.
|
||||||
|
참조 이미지는 `inlineData(mimeType+base64)`, 지시문은 `text` — parts 배열로 전달. mimeType 자동 감지.
|
||||||
|
- **보안**: API 키는 환경변수 `GEMINI_API_KEY` 에서만 로드. 코드/로그/커밋/응답에 절대 기록 금지.
|
||||||
|
★ 실제 Gemini 호출은 **소유자 승인 대기**(reroomai-source.md §8) — 승인 전 실호출 강제 금지.
|
||||||
|
|
||||||
|
## 프롬프트 빌더 (구조화 사전 조립 + 보존/교체 분리)
|
||||||
|
`build_booth_prompt(scene, shot_preset, layers)` 가 PLANNING §6-2 스키마를 4단으로 조립:
|
||||||
|
1. **대상+스타일** — `BOOTH_TYPES`(독립/조립/코너/아일랜드) × `BOOTH_STYLES`(럭셔리/테크/친환경/미니멀/표준) + 치수·브랜드컬러.
|
||||||
|
2. **보존 잠금** — 부스 외곽 치수·기둥·바닥 트렌치 그리드·천장 트러스·통로 방향·카메라 앵글 유지.
|
||||||
|
3. **교체 지정** — `FIXTURE_LAYERS`(집기/조명/전기/네트워크) 레이어별 조각 + 간판 문구(한글)·자재·존·색온도.
|
||||||
|
4. **사진 품질** — photorealistic tradeshow, natural lighting, high detail, no people/real logos.
|
||||||
|
|
||||||
|
입력은 §6-2 `scene`(hall/booth/design/lighting/wiring + shot/render_hints). 중첩/평면 모두 관대 파싱.
|
||||||
|
|
||||||
## 절차
|
## 절차
|
||||||
1. 입력 확인: 부스 스펙(홀, 크기, 타입, 브랜드 컬러, 자재, 조명)이 구조화되어 있는지 확인. 없으면 사용자/설계 데이터에서 추출해 dict로 정리.
|
1. 입력 확인: `scene` dict(§6-2)가 구조화됐는지 확인. 없으면 설계 데이터/사용자에서 추출.
|
||||||
2. `GEMINI_API_KEY` 환경변수 확인. 없으면 발급 안내 후 중단.
|
2. `GEMINI_API_KEY` 확인. 없으면 발급 안내 후 중단. (승인 전이면 실호출 보류)
|
||||||
3. `tools/nanobanana/client.py`의 `NanoBananaClient` 사용 (직접 API 호출 금지).
|
3. `NanoBananaClient().render_shot(scene, shot_preset="S1", reference_image=빈부스사진, seed=고정값)`.
|
||||||
4. 표준 샷 세트(S1~S7, tools/nanobanana/README.md 표 참조) 순서로 생성: S1 정면 주간 → S2 정면 야간 → S3 통로 → S4 내부. 조감 요청이면 S7(view="aerial"), 배선 요청이면 S6 `generate_wiring_overlay`(network/power/plumbing).
|
4. 표준 샷 세트 S1~S7 순서로 생성:
|
||||||
5. 빈 부스 실측 사진이 있으면 `reference_image`로 전달해 실제 홀 구조를 보존 (S5 before/after 쌍 구성).
|
- S1 정면 주간 → S2 정면 야간 → S3 통로 → S4 내부 (모두 `render_shot`)
|
||||||
6. 생성 이미지에 "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음" 워터마크를 합성(Pillow)한 뒤 산출 폴더 `output/visualizations/`에 저장.
|
- S5 Before/After: S1 결과 + `reference_image`(빈 부스 실측) 쌍
|
||||||
7. 같은 부스의 추가 샷은 첫 생성 이미지를 참조 이미지로 재사용해 아이덴티티를 유지.
|
- S7 홀 조감: `render_shot(shot_preset="S7")`
|
||||||
|
- **S6 배선 오버레이**: 생성형 아님 → `render_wiring_overlay_raster(wiring, hall_dims_m, kinds, base_image)`
|
||||||
|
(도면 위 전기 적/네트워크 청/급배수 녹 경로를 좌표 정합 래스터 합성. 시공 검증용 1차 산출물).
|
||||||
|
발표/설명용 보조 이미지가 필요할 때만 `generate_wiring_overlay`(생성형, 검증 사용 금지).
|
||||||
|
5. 빈 부스 실측 사진은 `reference_image`로 전달 → 실제 홀 구조 보존 합성(S5 before/after 쌍).
|
||||||
|
6. 워터마크: 모든 산출 이미지에 "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음" 합성(Pillow) 후
|
||||||
|
`output/visualizations/`에 저장. `GeneratedImage.save()`가 사이드카 `.meta.json`(생성일·스키마 해시·
|
||||||
|
모델 버전·seed·워터마크 고지)을 결정적으로 기록하고 PNG는 tEXt 청크에도 임베드.
|
||||||
|
7. 일관성: 같은 부스의 추가 샷은 **동일 `seed`** 전달(B-12). seed 미지원 SDK/모델이면 자동으로
|
||||||
|
**참조체인 기반 일관성**으로 폴백(첫 생성 이미지를 다음 샷 `reference_image`로 재사용).
|
||||||
|
|
||||||
|
## 표준 샷 세트 S1~S7
|
||||||
|
| 샷 | 호출 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 정면 주간 | `render_shot(scene,"S1")` | 기본 컨펌 컷 |
|
||||||
|
| S2 정면 야간 | `render_shot(scene,"S2")` | 조명 연출 평가 |
|
||||||
|
| S3 통로 뷰 | `render_shot(scene,"S3")` | 관람객 동선 |
|
||||||
|
| S4 내부 뷰 | `render_shot(scene,"S4")` | 집기 배치 |
|
||||||
|
| S5 Before/After | S1 + `reference_image` 쌍 | 시공 전/후 비교 |
|
||||||
|
| S6 배선 오버레이 | `render_wiring_overlay_raster(...)` ★래스터 합성 | 시공 검증(좌표 정합) |
|
||||||
|
| S7 홀 조감 | `render_shot(scene,"S7")` | 배치안 비교 |
|
||||||
|
|
||||||
|
## 프로덕션 방어 (ReRoomAI route.ts 패턴)
|
||||||
|
- **입력 크기 가드**: 참조 이미지 8MB 상한 + 긴 쪽 1024px 다운스케일/JPEG 0.85(`downscale_for_upload`).
|
||||||
|
- **SAFETY 처리**: `finish_reason == SAFETY` → `NanoBananaSafetyError`.
|
||||||
|
- **에러 분기**: 키 무효→`NanoBananaAuthError`, 쿼터/429→`NanoBananaQuotaError`, SAFETY→`NanoBananaSafetyError`.
|
||||||
|
- **쿼터 차감**: `usage_recorder` 훅은 **성공 시에만** 호출(실패는 카운트 소모 안 함).
|
||||||
|
|
||||||
## 품질 체크
|
## 품질 체크
|
||||||
- 부스 크기 비율이 스펙과 맞는가 (3x3 부스가 대형 부스처럼 보이면 재생성)
|
- 부스 크기 비율이 스펙과 맞는가 (3x3 부스가 대형처럼 보이면 재생성).
|
||||||
- 텍스트 사이니지가 한국어인가
|
- 텍스트 사이니지가 한국어로 정확한가 (실패 시 간판 영역 후처리 합성 — PLANNING §6-4).
|
||||||
- 실존 브랜드 로고/사람 얼굴이 없는가
|
- 실존 브랜드 로고/사람 얼굴이 없는가.
|
||||||
|
- 계약·심사 서류에는 생성 이미지 사용 금지(도면만 유효 — PLANNING §6-5).
|
||||||
|
|||||||
@ -5,14 +5,14 @@
|
|||||||
| ID | 심각도 | 내용 | 담당 에이전트 | 상태 |
|
| ID | 심각도 | 내용 | 담당 에이전트 | 상태 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| B-01 | Major | 조립부스 옵션(간판·가구·조명 선택) 신청 UI 부재 — PLANNING M3 Phase 1 범위인데 design.md에 화면 없음. SCR-05/06에 조립부스 모드 추가 필요 | designer | open |
|
| B-01 | Major | 조립부스 옵션(간판·가구·조명 선택) 신청 UI 부재 — PLANNING M3 Phase 1 범위인데 design.md에 화면 없음. SCR-05/06에 조립부스 모드 추가 필요 | designer | open |
|
||||||
| B-02 | Major | S6 배선 오버레이의 시공 검증용 산출은 생성형이 아닌 백엔드 래스터 합성 렌더러로 구현 필요 (client.py에는 주의 주석만 반영됨) | developer | open |
|
| B-02 | Major | S6 배선 오버레이의 시공 검증용 산출은 생성형이 아닌 백엔드 래스터 합성 렌더러로 구현 필요 (client.py에는 주의 주석만 반영됨) | developer | **done** (v1.2) — `render_wiring_overlay_raster()` 신설(로컬 PIL, 좌표 정합, power=적/network=청/plumbing=녹, 트렌치 마커·범례, base_image 합성). 생성형 `generate_wiring_overlay`는 발표 보조용으로 분리 |
|
||||||
| B-03 | Major | 나노바나나 입력 스키마를 PLANNING §6-2 정식 스키마(booth polygon, zones, shot preset, render_hints)로 확장 + GeneratedImage에 메타데이터(생성일·스키마 해시·모델 버전) 임베드 | visualizer/developer | open |
|
| B-03 | Major | 나노바나나 입력 스키마를 PLANNING §6-2 정식 스키마(booth polygon, zones, shot preset, render_hints)로 확장 + GeneratedImage에 메타데이터(생성일·스키마 해시·모델 버전) 임베드 | visualizer/developer | **done** (v1.2) — `build_booth_prompt(scene)`가 §6-2 scene(hall/booth/design/lighting/wiring/shot/render_hints) 수용, `GeneratedImage.save()`가 사이드카 `.meta.json`+PNG tEXt 청크로 생성일·스키마 해시(SHA-256)·모델 버전·seed 결정적 임베드 |
|
||||||
| B-04 | Minor | design.md 화면 수 표기(13) → 실제 웹 12+모바일 2=14 정정 | designer | open |
|
| B-04 | Minor | design.md 화면 수 표기(13) → 실제 웹 12+모바일 2=14 정정 | designer | open |
|
||||||
| B-05 | Minor | SCR-02 프롬프트 사이드바 "정산" 메뉴 — IA와 불일치 정리 | designer | open |
|
| B-05 | Minor | SCR-02 프롬프트 사이드바 "정산" 메뉴 — IA와 불일치 정리 | designer | open |
|
||||||
| B-06 | Minor | SCR-01 이메일 인증 로그인 프롬프트 누락 | designer | open |
|
| B-06 | Minor | SCR-01 이메일 인증 로그인 프롬프트 누락 | designer | open |
|
||||||
| B-07 | Minor | SCR-03 "공유" 버튼 프롬프트 누락 + 위반 요약(2/3)과 캔버스 핀 샘플(적2·주1) 불일치 | designer | open |
|
| B-07 | Minor | SCR-03 "공유" 버튼 프롬프트 누락 + 위반 요약(2/3)과 캔버스 핀 샘플(적2·주1) 불일치 | designer | open |
|
||||||
| B-08 | Minor | SCR-04 샘플 지표 산술 오류(510부스 ↔ 판매면적 4,420㎡) | designer | open |
|
| B-08 | Minor | SCR-04 샘플 지표 산술 오류(510부스 ↔ 판매면적 4,420㎡) | designer | open |
|
||||||
| B-09 | Minor | analysis 문서 내 D-15/D-25 표기 혼재 정리 | planner | open |
|
| B-09 | Minor | analysis 문서 내 D-15/D-25 표기 혼재 정리 | planner | **done** (v1.1) — kintex-website.md §5 마일스톤을 D-150/D-30/D-25/D-7로 통일(유틸리티=D-25), D-15 오기 제거 |
|
||||||
| B-10 | Minor | 홀매니저(SCR-10/11)·현장 모바일(SCR-M1/M2) 화면에 Phase 2/3 라벨 부착, 조명 배치안 제안(M4a) 전용 UI 검토 | designer | open |
|
| B-10 | Minor | 홀매니저(SCR-10/11)·현장 모바일(SCR-M1/M2) 화면에 Phase 2/3 라벨 부착, 조명 배치안 제안(M4a) 전용 UI 검토 | designer | open |
|
||||||
| B-11 | Major | ReRoomAI 소스 분석(docs/analysis/reroomai-source.md) — 소스 폴더 연결 대기 중. 완료 후 PLANNING §6 ReRoomAI 연계 절 보강 | planner/visualizer | blocked |
|
| B-11 | Major | ReRoomAI 소스 분석(docs/analysis/reroomai-source.md) — 소스 폴더 연결 대기 중. 완료 후 PLANNING §6 ReRoomAI 연계 절 보강 | planner/visualizer | **done** (v1.1) — 소스 분석 완료·PLANNING §6-4/6-5 확정(모델·SDK·구조화 사전·보존/교체 프롬프트 템플릿·RenderJob 방어·UX), §10 R11 해소·R12 추가 |
|
||||||
| B-12 | Minor | client.py에 동일 시드/일관성 파라미터(PLANNING §6-3) 지원 검토 (Gemini API 시드 지원 범위 확인 필요) | visualizer | open |
|
| B-12 | Minor | client.py에 동일 시드/일관성 파라미터(PLANNING §6-3) 지원 검토 (Gemini API 시드 지원 범위 확인 필요) | visualizer | **done** (v1.2) — `render_shot(..., seed=)` 지원. `GenerateContentConfig(seed=)`로 전달 시도하되 SDK/모델 미지원(TypeError)이면 자동으로 참조체인 기반 일관성(첫 컷을 다음 샷 reference_image로 재사용)으로 폴백. SKILL.md에 명시. ★실호출 검증은 소유자 승인 후 |
|
||||||
|
|||||||
119
docs/PLANNING.md
119
docs/PLANNING.md
@ -1,8 +1,8 @@
|
|||||||
# 킨텍스 AI 전시관리 시스템 기획서
|
# 킨텍스 AI 전시관리 시스템 기획서
|
||||||
|
|
||||||
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 버전: v1.0
|
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 버전: v1.1
|
||||||
> 근거 문서: `docs/analysis/kintex-website.md` (킨텍스 웹사이트 25개 페이지 + 전시주최자매뉴얼 PDF + 참가업체 매뉴얼 PDF 전수 분석, 2026-07-11)
|
> 근거 문서: `docs/analysis/kintex-website.md` (킨텍스 웹사이트 25개 페이지 + 전시주최자매뉴얼 PDF + 참가업체 매뉴얼 PDF 전수 분석, 2026-07-11)
|
||||||
> 후속 연계 예정: `docs/analysis/reroomai-source.md` (ReRoomAI 이미지 파이프라인 소스 분석 — 추가 시 6장 갱신)
|
> 근거 문서: `docs/analysis/reroomai-source.md` (ReRoomAI 이미지 파이프라인 소스 분석, 2026-07-11) — 6장 나노바나나 파이프라인 제어 파라미터 확정 근거
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -253,14 +253,16 @@ graph TB
|
|||||||
|
|
||||||
## 6. 나노바나나 시각화 파이프라인
|
## 6. 나노바나나 시각화 파이프라인
|
||||||
|
|
||||||
|
> **제어 파라미터 확정 (v1.1)**: 본 장의 모델·SDK·프롬프트 아키텍처·방어 로직은 ReRoomAI 소스 분석(`docs/analysis/reroomai-source.md`)의 실증 기법으로 확정되었다. ReRoomAI는 "건축 골격 보존 + 표면 요소 교체"라는 동형(同型) 문제(빈 부스 골격 유지 + 배치·인테리어·조명·배선 시공 결과 합성)를 검증된 단일 호출로 해결하고 있어, 그 호출 스택·프롬프트 패턴·방어 로직을 부스 도메인으로 치환해 채택한다.
|
||||||
|
|
||||||
### 6-1. 파이프라인 개요
|
### 6-1. 파이프라인 개요
|
||||||
|
|
||||||
```
|
```
|
||||||
구조화 데이터(M2~M4) → 씬 컴파일러(3D 간이 렌더/도면 래스터) → 프롬프트 빌더
|
구조화 데이터(M2~M4) → 씬 컴파일러(3D 간이 렌더/도면 래스터, 긴 쪽 1024px) → 프롬프트 빌더(구조화 사전 조립)
|
||||||
→ Gemini 이미지 생성(참조 이미지 조건부) → 후처리(워터마크·메타데이터) → CDN 캐시
|
→ Gemini image-to-image 편집(참조 이미지 inlineData + 지시문 text) → RenderJob 방어·검수 → 후처리(워터마크·메타데이터) → CDN 캐시
|
||||||
```
|
```
|
||||||
|
|
||||||
핵심 원칙: **텍스트 프롬프트 단독 생성이 아니라, 시스템이 만든 도면/간이 렌더를 참조 이미지로 넣어 구조(부스 위치·크기·통로)를 보존하고 스타일만 사실화**한다. 이는 ReRoomAI의 "참조 이미지 기반 구조 보존 + 스타일 변환" 패턴을 재사용하는 것으로, 소스 상세 분석(`docs/analysis/reroomai-source.md`)이 추가되는 대로 본 절의 제어 파라미터를 구체화한다(연계 예정).
|
핵심 원칙: **텍스트 프롬프트 단독 생성이 아니라, 시스템이 만든 도면/간이 렌더를 참조 이미지로 넣어 구조(부스 위치·크기·통로·앵글)를 보존하고 스타일만 사실화**한다. ReRoomAI의 "참조 이미지 기반 구조 보존 + 스타일 변환" 패턴을 부스 도메인으로 이식한다. Stable Diffusion·ControlNet 등 별도 파이프라인 없이 **Gemini 이미지 모델 단일 호출**이 원본 구조를 참조·보존한다는 점이 ReRoomAI에서 실증되었다.
|
||||||
|
|
||||||
### 6-2. 입력 데이터 스키마 (요약)
|
### 6-2. 입력 데이터 스키마 (요약)
|
||||||
|
|
||||||
@ -294,16 +296,100 @@ render_hints: { style: "photoreal_tradeshow", crowd: "sparse" }
|
|||||||
| S6 배선 오버레이 | S1/평면 탑뷰에 전기(적)·네트워크(청)·급배수(녹) 경로 오버레이 | 시공업체·홀매니저 검증용 | M4 저장 시 |
|
| S6 배선 오버레이 | S1/평면 탑뷰에 전기(적)·네트워크(청)·급배수(녹) 경로 오버레이 | 시공업체·홀매니저 검증용 | M4 저장 시 |
|
||||||
| S7 홀 전경(조감) | 홀 전체 아이소메트릭 | 주최자 배치안 비교 | M2 배치안 생성 시 |
|
| S7 홀 전경(조감) | 홀 전체 아이소메트릭 | 주최자 배치안 비교 | M2 배치안 생성 시 |
|
||||||
|
|
||||||
S6는 사실화 생성이 아니라 **도면 정합 오버레이가 우선**(정확성이 목적) — 래스터 합성 후 배경만 생성 이미지 사용.
|
S6(배선 오버레이)는 **생성형 이미지가 아니라 백엔드 래스터 합성이 우선**이다. 시공업체·홀매니저의 검증 산출물이므로 좌표 정확성이 목적이며, 생성 모델의 재질/색 왜곡이 개입되면 안 된다 → PostGIS 배선 지오메트리(전기 적·네트워크 청·급배수 녹)를 S1/평면 탑뷰 위에 백엔드 렌더러로 정확 오버레이하고, **배경만(부스 골격·바닥) 생성 이미지를 사용**한다. 이 경로는 Gemini 호출 없이(또는 배경 1회만) 결정적으로 산출된다. (BACKLOG B-02 — developer 트랙 백엔드 래스터 렌더러로 구현)
|
||||||
|
|
||||||
### 6-4. 프롬프트 전략
|
### 6-4. 모델·SDK·프롬프트 아키텍처 (ReRoomAI 실증 기법 확정)
|
||||||
|
|
||||||
1. **2단 구성**: (a) 시스템 고정부 — "한국 전시장 KINTEX 홀 내부, 콘크리트 폴리싱 바닥, 층고 12m 트러스 천장…" 등 홀별 사전 촬영 참조 사진과 함께 고정, (b) 가변부 — 스키마를 자연어로 직렬화(치수·자재·간판 문구 명시).
|
**(1) 호출 스택 — 검증된 정답 채택**
|
||||||
2. **구조 보존**: 씬 컴파일러의 간이 렌더(회색 박스 수준)를 참조 이미지로 제공, "이 구도·배치를 유지하고 사실적 재질로" 지시. 텍스트 간판 문구는 생성 후 오탈자 검수(비전 모델 자동 확인) — **한글 텍스트 렌더링 불안정은 알려진 한계**로, 실패 시 간판 영역 후처리 합성.
|
|
||||||
3. **일관성**: 같은 부스의 S1~S5는 동일 시드/참조 체인으로 생성해 컷 간 자재·색상 일치 유도. 완전한 일치는 보장 불가 — UI에 "컷별 차이 존재 가능" 고지.
|
|
||||||
4. **비용 제어**: 저장 시 자동 생성은 S1·S7만, 나머지는 온디맨드. 동일 스키마 해시 캐시.
|
|
||||||
|
|
||||||
### 6-5. 워터마크·고지 정책 (필수)
|
| 항목 | 확정값 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| SDK | `@google/genai` (Google 공식) | ReRoomAI `route.ts` 프로덕션 검증 |
|
||||||
|
| 모델 | `gemini-3.1-flash-image-preview` (나노바나나 2 / Nano Banana 2) | 동일 |
|
||||||
|
| 호출 형태 | `ai.models.generateContent()` — image-to-image 편집 | 동일 |
|
||||||
|
| 입력 구성 | `parts` 배열에 **입력 이미지 `{ inlineData: { mimeType, data(base64) } }` + 지시문 `{ text }`** 동시 전달 | 멀티모달 인페인팅형 편집 — 원본 구조 참조·보존 |
|
||||||
|
| 응답 처리 | candidate에서 `inlineData`(생성 이미지 base64) 추출 | 동일 |
|
||||||
|
|
||||||
|
`tools/nanobanana/`(프로젝트 규칙) 모듈이 이 호출 패턴을 표준화하며, `nanobanana-visualize` 스킬이 부스 도메인 래퍼를 제공한다.
|
||||||
|
|
||||||
|
**(2) 프롬프트 = 구조화 사전 조립 + "보존/교체 명시적 분리"**
|
||||||
|
|
||||||
|
ReRoomAI `lib/constants.ts`의 핵심 패턴 — UI 라벨(한글)과 프롬프트 조각(영문)을 한 객체에 묶어 **UI 선택과 서버 프롬프트가 단일 출처를 공유** — 를 부스 도메인 구조화 사전으로 이식한다. 각 항목은 `{ id, label(한글), prompt(영문), swatch? }` 형태.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 부스 유형 — 골격 특성 프롬프트 조각
|
||||||
|
BOOTH_TYPES = [
|
||||||
|
{ id: "assembled", label: "조립부스", prompt: "standard octanorm shell scheme booth, aluminium frame walls" },
|
||||||
|
{ id: "independent", label: "독립부스", prompt: "custom-built independent booth, free-standing structure" },
|
||||||
|
{ id: "corner", label: "코너부스", prompt: "corner booth open on two aisle-facing sides" },
|
||||||
|
{ id: "island", label: "아일랜드부스", prompt: "island booth open on all four sides" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 부스 스타일 — 표면·분위기 프롬프트 조각(+ swatch 색3종)
|
||||||
|
BOOTH_STYLES = [
|
||||||
|
{ id: "luxury", label: "럭셔리", prompt: "premium luxury exhibition design: warm wood, brass accents, layered lighting" },
|
||||||
|
{ id: "tech", label: "테크", prompt: "high-tech booth: LED walls, dark palette, cool white accent lighting" },
|
||||||
|
{ id: "eco", label: "친환경", prompt: "sustainable booth: recycled timber, greenery, warm diffuse lighting" },
|
||||||
|
{ id: "minimal", label: "미니멀", prompt: "minimal booth: clean white surfaces, hidden lighting, uncluttered" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 시공 레이어 — 조명/전기/네트워크 레이어별 프롬프트 조각(변형 렌더용)
|
||||||
|
FIXTURE_LAYERS = {
|
||||||
|
lighting: { day: "even daylight-balanced exhibition lighting",
|
||||||
|
night: "dramatic accent spotlights on products, dimmed ambient" },
|
||||||
|
power: "power outlets and distribution box neatly integrated at booth base",
|
||||||
|
network: "network access point and cabling routed along booth structure",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
이 사전들은 M2(부스 유형)·M3(스타일·집기)·M4(조명 모드·배선 레이어) UI 선택값과 그대로 매핑되어, 프롬프트 빌더가 조각을 조립한다.
|
||||||
|
|
||||||
|
**(3) 부스 도메인 프롬프트 템플릿 (보존/교체 명시 잠금)**
|
||||||
|
|
||||||
|
ReRoomAI 4단 구성(대상+스타일 → 보존 잠금 → 교체 지정 → 사진 품질)을 부스로 치환:
|
||||||
|
|
||||||
|
```
|
||||||
|
Render this exhibition booth as if construction is complete.
|
||||||
|
① 대상+스타일: {BOOTH_TYPES.prompt} at KINTEX exhibition hall, styled as {BOOTH_STYLES.prompt}.
|
||||||
|
② 보존 잠금(KEEP EXACTLY THE SAME):
|
||||||
|
booth outer footprint dimensions, structural columns, floor trench grid,
|
||||||
|
ceiling truss, aisle direction and the camera angle.
|
||||||
|
③ 교체·배치(REPLACE / PLACE):
|
||||||
|
fixtures — desks, shelves, banners, signage;
|
||||||
|
{FIXTURE_LAYERS.lighting[mode]}; {FIXTURE_LAYERS.power}; {FIXTURE_LAYERS.network};
|
||||||
|
carpet and booth wall graphics — to match the target style.
|
||||||
|
④ 사진 품질:
|
||||||
|
photorealistic trade-show photography, natural exposure, high detail,
|
||||||
|
Korean exhibition hall interior (concrete polished / carpet floor per hall).
|
||||||
|
```
|
||||||
|
|
||||||
|
- **가변 직렬화**: 씬 스키마(6-2)의 치수·자재·간판 문구를 자연어로 직렬화해 위 슬롯에 주입. 홀별 사전 촬영 참조 사진을 함께 참조 이미지로 전달(홀6 카펫·홀7 콘크리트 폴리싱 등).
|
||||||
|
- **구조 보존 강화**: 씬 컴파일러 간이 렌더(회색 박스 수준)를 참조 이미지 `inlineData`로 제공 → "이 구도·배치를 유지, 사실적 재질로만".
|
||||||
|
- **간판 텍스트**: 생성 후 비전 모델로 오탈자 자동 검수. **한글 텍스트 렌더링 불안정은 알려진 한계** → 실패 시 간판 영역 후처리 합성.
|
||||||
|
- **레이어 변형**: `FIXTURE_LAYERS.lighting.night`만 교체하면 S2(야간 점등), 배선 레이어 강조는 S6(단, S6은 6-3대로 래스터 합성 우선).
|
||||||
|
|
||||||
|
**(4) 일관성·비용 제어**
|
||||||
|
|
||||||
|
- **일관성**: 같은 부스 S1~S5는 동일 참조 체인(간이 렌더 + 직전 결과)으로 생성해 컷 간 자재·색상 일치 유도. 완전 일치는 보장 불가 — UI에 "컷별 차이 존재 가능" 고지. (Gemini 시드 지원 범위는 구현 시 확인 — BACKLOG B-12)
|
||||||
|
- **비용 제어**: 저장 시 자동 생성은 S1·S7만, 나머지는 온디맨드. **동일 스키마 해시 캐시**. RenderJob 쿼터는 성공 시에만 차감(6-5).
|
||||||
|
|
||||||
|
### 6-5. RenderJob 방어 로직 및 UX (ReRoomAI (D)(E)(G) 이식)
|
||||||
|
|
||||||
|
**(1) Canvas 1024px 전처리 (전송량·비용·지연 동시 절감)**
|
||||||
|
- 클라이언트에서 도면/현장 사진을 **긴 쪽 1024px 다운스케일 → `toDataURL('image/jpeg', 0.85)` → base64 data URL**로 변환 후 전송. ReRoomAI `Studio.handleImageFile`과 동일. 부스 간이 렌더도 동일 규격으로 정규화.
|
||||||
|
|
||||||
|
**(2) RenderJob 워커 방어 로직 (ReRoomAI `route.ts` 골격 복제)**
|
||||||
|
- **다층 크기 가드**: content-length 상한(8MB) → base64 실크기(×1.33) 재검증.
|
||||||
|
- **mimeType 자동 감지**: data URL에서 mimeType + base64 정규식 분리(하드코딩 금지).
|
||||||
|
- **SAFETY 처리**: 응답 `finishReason==='SAFETY'` → 차단 에러로 분기.
|
||||||
|
- **에러 분기 → 친화적 한글 메시지**: `API_KEY_INVALID` / `RESOURCE_EXHAUSTED·quota·429` / `SAFETY` 구분.
|
||||||
|
- **성공 시에만 쿼터 차감**: 실패는 사용량 소모 안 함. ReRoomAI는 인메모리 `Map<ip,...>`이나(재시작·다중 인스턴스 취약) — 본 시스템은 **PostgreSQL/Redis 기반 RenderJob 쿼터**(행사별 상한, 6-4 비용 제어와 통합)로 대체.
|
||||||
|
- 전 과정 비동기(8장): RenderJob 큐잉 → 워커 Gemini 호출 → WebSocket 완료 푸시 → 재시도·비용 상한·스키마 해시 캐시 워커 레벨 관리.
|
||||||
|
|
||||||
|
**(3) 단계별 로딩 UX**
|
||||||
|
- ReRoomAI `LOADING_STATUSES` 순환 패턴을 부스용으로 치환: **"부스 골격 인식 → 집기 배치 → 전기·조명 배선 → 최종 고화질 렌더링"**을 `aria-live`로 순환 표시. 진행 배지("생성 중… 평균 40초")와 연결. 결과는 Before/After 드래그 슬라이더(ReRoomAI `CompareSlider.tsx` 거의 무수정 재사용 — clip-path + 포인터 캡처 + 키보드/ARIA)로 S5(빈 부스 ↔ 시공 후) 표시.
|
||||||
|
|
||||||
|
### 6-6. 워터마크·고지 정책 (필수)
|
||||||
|
|
||||||
- 모든 생성 이미지에 시각 워터마크 "**AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음**" + 메타데이터(생성일, 스키마 해시, 모델 버전) 임베드.
|
- 모든 생성 이미지에 시각 워터마크 "**AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음**" + 메타데이터(생성일, 스키마 해시, 모델 버전) 임베드.
|
||||||
- 계약·심사 서류에는 생성 이미지 사용 금지(도면만 유효) — 시스템이 서류 생성 시 자동 배제.
|
- 계약·심사 서류에는 생성 이미지 사용 금지(도면만 유효) — 시스템이 서류 생성 시 자동 배제.
|
||||||
@ -317,11 +403,12 @@ S6는 사실화 생성이 아니라 **도면 정합 오버레이가 우선**(정
|
|||||||
|
|
||||||
| 데이터셋 | 내용(분석 문서 실측값) | 확보 방법 | 상태 |
|
| 데이터셋 | 내용(분석 문서 실측값) | 확보 방법 | 상태 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 홀 마스터 | 1전시장 홀1~5(홀당 10,611~10,773㎡, 63×171m, 층고 15m, 5t/㎡, 약 600부스), 2전시장 홀6(5,580㎡, 층고 10m, 2t/㎡, 카펫, 200부스)·홀7/8(각 11,290㎡, 126×90×12m, 510부스)·홀9(13,238㎡)/홀10(13,072㎡, 132×99×15m). 반홀 분할(1A 4,941㎡/1B 5,670㎡ 등) | 공개 스펙 + 킨텍스 CAD 원본 요청 | 공개값 확보, CAD 협의 필요 |
|
| 홀 마스터 | 1전시장 홀1~5(홀당 10,611~10,773㎡, **171×63×15m**, 5t/㎡, 약 600부스) + **옥외전시장 2,849㎡**, 2전시장 홀6(5,580㎡=**93×60×10m**, 2t/㎡, 카펫, 200부스)·홀7/8(각 11,290㎡, 126×90×12m, 510부스)·홀9(13,238㎡)/홀10(13,072㎡, 132×99×15m). 반홀 분할(1A 4,941㎡/1B 5,670㎡ 등). **제3전시장(2028) 홀11~18 계획** | 공개 스펙 + 킨텍스 CAD 원본 요청 | 공개값 확보(2차 검증 반영), CAD 협의 필요 |
|
||||||
| 트렌치 그리드 | 홀별 트렌치 위치·공급 매트릭스(전기·급배수·압축공기·전화·인터넷, 홀1·7 가스) | **킨텍스 제공 필수** — 미공개 | 미확보 (Phase 1 착수 조건) |
|
| 트렌치 그리드 | 홀별 트렌치 위치·공급 매트릭스(전기·급배수·압축공기·전화·인터넷, 홀1·7 가스) | **킨텍스 제공 필수** — 미공개 | 미확보 (Phase 1 착수 조건) |
|
||||||
| 요율 마스터 | 전시홀 2,250원/㎡(12h), 로비 10,000원/㎡, 옥외 2,000원/㎡, 이벤트홀 2,420원/㎡, 성수기 +10%/비수기 -10%/1전시장 +10%, 용도 외 30% 할증, 예치금 15~20% | 공개 임대요율표 | 확보 |
|
| 요율 마스터 | 전시홀 2,250원/㎡(12h), 로비 10,000원/㎡, 옥외 2,000원/㎡, 이벤트홀 2,420원/㎡, 성수기 +10%/비수기 -10%/1전시장 +10%, 용도 외 30% 할증, 예치금 15~20% | 공개 임대요율표 | 확보 |
|
||||||
| 유틸리티 요금 | 전기 1kW 55,000원·분전반 50A 100,000원, 압축공기 150,000원/구, 급배수 150,000원/구, 인터넷 150,000원/회선(KT 80,000원 병존 — 정합 확인 필요) | 참가업체 매뉴얼 | 확보(검증 필요) |
|
| 유틸리티 요금 | 전기 1kW 55,000원·분전반 50A 100,000원, 압축공기 150,000원/구, 급배수 150,000원/구, 인터넷 150,000원/회선(KT 80,000원 병존 — 정합 확인 필요) | 참가업체 매뉴얼 | 확보(검증 필요) |
|
||||||
| 규정 룰셋 | 높이 5m, 리깅 6.5~8.5m+구조계산서 D-7, 복층 1/2, 방염, 소음 70~75dB, 금지작업 목록 | 매뉴얼 → 룰 엔진 코드화 | 확보 |
|
| 부스 표준 사양 | **조립(기본)부스 표준 포함 품목: 스포트라이트 5개·220V 2구 콘센트 1개·방염 A급 바닥재·기본 전력 1kW/부스. 프리미엄 부스 6×3×4m·2kW.** 초과 전력·조명은 유틸리티 추가 신청 | 참가업체 매뉴얼(kintex.com 2차 검증) | 확보 |
|
||||||
|
| 규정 룰셋 | 높이 5m, 리깅 6.5~8.5m+구조계산서 D-7, 복층 1/2, 방염, 소음 70~75dB, **이격(인접 벽 30cm·천장 60cm), 조명 반입 금지(지정 조명만 사용)**, 금지작업 목록 | 매뉴얼 → 룰 엔진 코드화 | 확보 |
|
||||||
| 회의실 마스터 | 37개 회의실, 시간대별 요금 (P2 범위) | 공개 요율표 | 확보 |
|
| 회의실 마스터 | 37개 회의실, 시간대별 요금 (P2 범위) | 공개 요율표 | 확보 |
|
||||||
|
|
||||||
### 7-2. 외부 연동
|
### 7-2. 외부 연동
|
||||||
@ -425,7 +512,8 @@ graph LR
|
|||||||
| R8 | 요금·규정 데이터의 공식성 — 웹 공개값(예: 인터넷 150,000원 vs KT 80,000원 병존)이 실계약가와 다를 수 있음 | 중간 | 견적에 "공시가 기준, 최종가는 킨텍스 확정" 고지, 요율 마스터를 킨텍스 확인본으로 교체하는 절차 마련 |
|
| R8 | 요금·규정 데이터의 공식성 — 웹 공개값(예: 인터넷 150,000원 vs KT 80,000원 병존)이 실계약가와 다를 수 있음 | 중간 | 견적에 "공시가 기준, 최종가는 킨텍스 확정" 고지, 요율 마스터를 킨텍스 확인본으로 교체하는 절차 마련 |
|
||||||
| R9 | 이해관계 충돌 — 장치업체는 설계 자동화를 일감 위협으로 인식 가능 | 중간 | 포지셔닝을 '초안+검증 도구'로: 반려 감소·컨펌 단축이라는 업체 이득 강조, M7로 수주 채널 제공 |
|
| R9 | 이해관계 충돌 — 장치업체는 설계 자동화를 일감 위협으로 인식 가능 | 중간 | 포지셔닝을 '초안+검증 도구'로: 반려 감소·컨펌 단축이라는 업체 이득 강조, M7로 수주 채널 제공 |
|
||||||
| R10 | 개인정보·영업비밀 — 참가업체 부스 설계는 경쟁사에 민감 | 중간 | 행사 단위 격리, 부스 데이터 접근은 소유 참가업체+주최자+홀매니저로 한정 |
|
| R10 | 개인정보·영업비밀 — 참가업체 부스 설계는 경쟁사에 민감 | 중간 | 행사 단위 격리, 부스 데이터 접근은 소유 참가업체+주최자+홀매니저로 한정 |
|
||||||
| R11 | ReRoomAI 파이프라인 분석 미완 — 구조 보존 제어 상세는 소스 분석 후 확정 | 낮음 | 6장을 '연계 예정'으로 버전 관리, 분석 도착 시 v1.1 갱신 |
|
| R11 | ~~ReRoomAI 파이프라인 분석 미완 — 구조 보존 제어 상세는 소스 분석 후 확정~~ **[해소 v1.1]** | 낮음 | **해소(2026-07-11)**: `reroomai-source.md` 분석 완료 → 6장 모델·SDK·프롬프트 아키텍처·방어 로직 확정(6-4/6-5). BACKLOG B-11 done |
|
||||||
|
| R12 | **나노바나나(Gemini) 외부 API 미승인** — GUARDiA 외부 API 금지 원칙상 현재 승인 예외는 `api.anthropic.com`뿐이며, Gemini(`generativelanguage.googleapis.com`)는 미승인. kintex는 GUARDiA ITSM(관공서 관제)과 별개 도메인의 독립 저장소(`zio/kintex`)이나, 승인 없이 M5 구현 착수 시 원칙 위반 | 높음 | **M5 파이프라인 구현 착수 전 소유자 승인 확정 선행(게이트)**. 승인 시 `GEMINI_API_KEY`는 서버 env로만 로드(코드·DB·커밋·로그·응답 기록 금지, ReRoomAI (E) 방어 패턴 준수). **미승인 시 완화책**: 온프레미스 이미지 생성(SDXL 등) 폴백 어댑터 검토 — 단 image-to-image 구조보존 품질 재평가 필요 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -434,3 +522,4 @@ graph LR
|
|||||||
| 버전 | 일자 | 작성자 | 내용 |
|
| 버전 | 일자 | 작성자 | 내용 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| v1.0 | 2026-07-11 | planner | 최초 작성 — kintex-website.md 분석 기반 전체 기획. ReRoomAI 소스 분석(reroomai-source.md)은 추가 시 6장 갱신 예정 |
|
| v1.0 | 2026-07-11 | planner | 최초 작성 — kintex-website.md 분석 기반 전체 기획. ReRoomAI 소스 분석(reroomai-source.md)은 추가 시 6장 갱신 예정 |
|
||||||
|
| v1.1 | 2026-07-11 | planner | ReRoomAI 소스 분석 반영. ①§6 나노바나나 파이프라인 제어 파라미터 확정 — 모델 `gemini-3.1-flash-image-preview`(나노바나나 2)·`@google/genai` SDK·image-to-image(inlineData+text parts) 호출 스택, 구조화 사전(BOOTH_TYPES·BOOTH_STYLES·FIXTURE_LAYERS) + "보존/교체 명시 분리" 부스 프롬프트 템플릿(§6-4), Canvas 1024px 전처리·RenderJob 방어 로직(크기 가드·SAFETY·에러 분기·성공 시에만 쿼터 차감)·단계별 로딩 UX 신설(§6-5). ②S6 배선 오버레이 백엔드 래스터 합성 우선 명확화(§6-3). ③§7 마스터 데이터 순증 반영(기본부스 표준 품목·프리미엄 6×3×4m 2kW·조명 반입 금지·이격 30/60cm·옥외 2,849㎡·홀6 93×60×10m·제3전시장 홀11~18). ④§10 R11 해소, R12(Gemini 외부 API 미승인·소유자 승인 게이트) 추가. BACKLOG B-11 done, B-09 정리 |
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
- 홀당 10,611~10,773㎡ (A/B 2분할 가능, 예: 1A 4,941㎡/1B 5,670㎡), 63×171m, 층고 15m, 바닥하중 5t/㎡, 콘크리트 폴리싱, 홀당 약 600부스(3×3m)
|
- 홀당 10,611~10,773㎡ (A/B 2분할 가능, 예: 1A 4,941㎡/1B 5,670㎡), 63×171m, 층고 15m, 바닥하중 5t/㎡, 콘크리트 폴리싱, 홀당 약 600부스(3×3m)
|
||||||
- 트렌치 유틸리티: 전기·급배수·압축공기·전화·인터넷 (홀1은 가스 포함)
|
- 트렌치 유틸리티: 전기·급배수·압축공기·전화·인터넷 (홀1은 가스 포함)
|
||||||
- **2전시장**: 홀 6~10 (5개 홀), 54,470㎡
|
- **2전시장**: 홀 6~10 (5개 홀), 54,470㎡
|
||||||
- 홀6: 5,580㎡(6A/B/C 각 1,860㎡), 층고 10m, 2t/㎡, 카펫 마감, 200부스
|
- 홀6: 5,580㎡=93×60×10m(6A/B/C 각 1,860㎡), 2t/㎡, 카펫 마감, 200부스
|
||||||
- 홀7·8: 각 11,290㎡, 126×90×12m, 5t/㎡, 510부스 (홀7 가스 포함)
|
- 홀7·8: 각 11,290㎡, 126×90×12m, 5t/㎡, 510부스 (홀7 가스 포함)
|
||||||
- 홀9: 13,238㎡ / 홀10: 13,072㎡, 132×99×15m, 5t/㎡
|
- 홀9: 13,238㎡ / 홀10: 13,072㎡, 132×99×15m, 5t/㎡
|
||||||
- 이동식 파티션으로 홀 분할·통합, 고층고로 복층부스·리깅 지원
|
- 이동식 파티션으로 홀 분할·통합, 고층고로 복층부스·리깅 지원
|
||||||
@ -79,7 +79,7 @@
|
|||||||
2. **장치공사 도면 심사 자동화**: 독립부스 평면도·입면도·전기도면·리깅 구조계산서를 kxwp에 제출하면 사람이 검토하는 구조 → 도면 파싱 AI로 방염·높이·하중(2~5t/㎡ 홀별 상이)·리깅 높이(6.5~8.5m) 규정 위반 자동 플래깅
|
2. **장치공사 도면 심사 자동화**: 독립부스 평면도·입면도·전기도면·리깅 구조계산서를 kxwp에 제출하면 사람이 검토하는 구조 → 도면 파싱 AI로 방염·높이·하중(2~5t/㎡ 홀별 상이)·리깅 높이(6.5~8.5m) 규정 위반 자동 플래깅
|
||||||
3. **유틸리티(전기·급배수·압축공기·통신) 신청·배선 최적화**: 참가업체가 위치표시도를 수기로 그려 등록하는 프로세스 → 부스 좌표 기반 트렌치 최단 배선 자동 산출, 분전반 용량(kW 합산) 자동 계산·요금 자동 견적, 마감기한(D-25 등) 리마인더 자동화
|
3. **유틸리티(전기·급배수·압축공기·통신) 신청·배선 최적화**: 참가업체가 위치표시도를 수기로 그려 등록하는 프로세스 → 부스 좌표 기반 트렌치 최단 배선 자동 산출, 분전반 용량(kW 합산) 자동 계산·요금 자동 견적, 마감기한(D-25 등) 리마인더 자동화
|
||||||
4. **임대 견적·배정 자동화**: 기본단가 2,250원/㎡ × 월별(±10%)·홀별(+10%) 차등 × 반홀 단위 + 관리비 예치금 15~20% 규칙이 명확 → 챗봇/견적기 자동화 용이. 홀 배정도 행사일정 DB와 연동한 가용성 실시간 조회 가능
|
4. **임대 견적·배정 자동화**: 기본단가 2,250원/㎡ × 월별(±10%)·홀별(+10%) 차등 × 반홀 단위 + 관리비 예치금 15~20% 규칙이 명확 → 챗봇/견적기 자동화 용이. 홀 배정도 행사일정 DB와 연동한 가용성 실시간 조회 가능
|
||||||
5. **서류 워크플로 디지털화**: 배정신청서·주최자신고서류가 아직 **HWP 양식 + 이메일/방문 제출** → 웹폼 + AI 서류 검수(누락 항목, 재해대처계획서 적정성)로 전환. D-150/D-30/D-15/D-7 마일스톤 자동 트래킹
|
5. **서류 워크플로 디지털화**: 배정신청서·주최자신고서류가 아직 **HWP 양식 + 이메일/방문 제출** → 웹폼 + AI 서류 검수(누락 항목, 재해대처계획서 적정성)로 전환. D-150(배정)/D-30(사전협의)/D-25(유틸리티)/D-7(신고서류) 마일스톤 자동 트래킹
|
||||||
6. **반입/반출 물류 스케줄링**: 화물차 순번제·통행증·하역장 배정을 수작업 운영 → 차량 예약 슬롯 시스템 + 대기열 최적화, 중량물(5t) 우선순위 자동 배치
|
6. **반입/반출 물류 스케줄링**: 화물차 순번제·통행증·하역장 배정을 수작업 운영 → 차량 예약 슬롯 시스템 + 대기열 최적화, 중량물(5t) 우선순위 자동 배치
|
||||||
7. **등록업체 매칭**: 739개 업체 DB가 단순 리스트 → 행사 유형·부스 규모·지역 기반 업체 추천, 견적 비교
|
7. **등록업체 매칭**: 739개 업체 DB가 단순 리스트 → 행사 유형·부스 규모·지역 기반 업체 추천, 견적 비교
|
||||||
8. **참가업체 공통 포털 부재**: 유틸리티 신청이 전시회별 주최자 시스템에 파편화 → 킨텍스 공통 참가업체 e-서비스 플랫폼(신청→위치도→결제→현장 개통 확인)이 가장 큰 공백
|
8. **참가업체 공통 포털 부재**: 유틸리티 신청이 전시회별 주최자 시스템에 파편화 → 킨텍스 공통 참가업체 e-서비스 플랫폼(신청→위치도→결제→현장 개통 확인)이 가장 큰 공백
|
||||||
|
|||||||
179
docs/analysis/reroomai-source.md
Normal file
179
docs/analysis/reroomai-source.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# ReRoomAI 소스코드 분석
|
||||||
|
|
||||||
|
> 분석일: 2026-07-11 · 대상: `C:\GUARDiA\workspace\ReRoomAI` — AI 공간 인테리어 리디자인 웹 서비스
|
||||||
|
> 목적: 킨텍스 전시 부스 "시공 후 결과 사진 생성 AI" 시스템(PLANNING §6 나노바나나 파이프라인)의 구조보존 제어 파라미터 확정
|
||||||
|
> 연계: PLANNING.md R11 / BACKLOG B-11 해소 근거 문서
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
|
**ReRoom AI**는 방 사진 한 장을 업로드하고 공간 유형·인테리어 스타일을 선택하면 약 10초 만에 새로운 분위기의 방으로 재렌더링하는 AI 인테리어 리디자인 웹 서비스다.
|
||||||
|
|
||||||
|
핵심 설계 철학은 **"건축학적 뼈대 보존 + 표면 요소 교체"**. 원본 방의 벽·창문·문·천장·**카메라 시야 구도(perspective)**는 그대로 유지하고 가구·조명·색감·장식만 선택한 테마로 바꾼다. 이것이 킨텍스 부스 시스템에 이식할 가장 중요한 개념 — "빈 부스 공간 골격은 유지하고 그 안에 배치·인테리어·조명·배선만 시공된 결과를 합성"과 동일한 문제 구조다.
|
||||||
|
|
||||||
|
주요 기능:
|
||||||
|
- 6가지 공간 유형(거실/침실/주방/욕실/서재/원룸) × 8가지 스타일(모던/미니멀/북유럽/인더스트리얼/재팬디/미드센추리/한옥/호텔라운지)
|
||||||
|
- **이중 API 모드**: 데모 모드(서버 키, 브라우저 로컬 2회 + IP당 하루 10회 제한) / BYOK 모드(사용자 개인 키, 무제한)
|
||||||
|
- **Before/After 드래그 비교 슬라이더**(마우스·터치·키보드 지원)
|
||||||
|
- 결과 PNG 다운로드 + "다른 스타일로 다시 디자인"(원본 재업로드 없이 연속 실험)
|
||||||
|
- 클라이언트 Canvas 전처리(긴 쪽 1024px 다운스케일)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기술 스택
|
||||||
|
|
||||||
|
| 레이어 | 기술 |
|
||||||
|
|--------|------|
|
||||||
|
| 프레임워크 | **Next.js 16.2.10** (App Router, Node runtime) — 단일 리포 서버리스, Vercel 배포 지향 |
|
||||||
|
| 언어 | TypeScript 5 |
|
||||||
|
| UI | React 19.2.4 |
|
||||||
|
| 스타일 | Tailwind CSS v4 (`@theme` 커스텀 토큰), Pretendard + Noto Serif KR (`next/font`) |
|
||||||
|
| **AI SDK** | **`@google/genai` ^2.10.0** (Google 공식 SDK) |
|
||||||
|
| **모델** | **`gemini-3.1-flash-image-preview`** (= 나노바나나 2 / Nano Banana 2) |
|
||||||
|
| 의존성 | 극히 단순 — `@google/genai`, `next`, `react`, `react-dom` **4개뿐** |
|
||||||
|
|
||||||
|
> 주목: `AGENTS.md`에 "이 Next.js는 학습 데이터와 다르다, 코드 작성 전 `node_modules/next/dist/docs/` 가이드를 읽으라"는 경고. Next 16 신버전이라 관례가 다를 수 있음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 디렉터리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
ReRoomAI/
|
||||||
|
├── app/
|
||||||
|
│ ├── layout.tsx # 폰트·메타데이터·OG
|
||||||
|
│ ├── page.tsx # 섹션 조립: Header→Hero→StyleGallery→HowItWorks→Studio→Faq→Footer
|
||||||
|
│ ├── globals.css # 디자인 토큰(@theme)·그레인/리빌
|
||||||
|
│ └── api/generate/route.ts # ★ Gemini 이미지 생성 라우트(IP 레이트리밋 포함) — 시스템의 심장
|
||||||
|
├── components/
|
||||||
|
│ ├── Studio.tsx # ★ 메인 인터랙션(업로드·옵션·생성·결과) — 클라이언트 플로우 전체
|
||||||
|
│ ├── CompareSlider.tsx # ★ Before/After 슬라이더(clip-path + 포인터캡처)
|
||||||
|
│ ├── Header/Hero/StyleGallery/StyleCards/HowItWorks/Faq/Footer.tsx # 랜딩 섹션
|
||||||
|
│ └── Reveal.tsx # 스크롤 리빌 래퍼
|
||||||
|
├── lib/
|
||||||
|
│ ├── constants.ts # ★ 공간유형·스타일 정의 (UI와 서버 프롬프트 단일 출처)
|
||||||
|
│ └── useLocalStorage.ts # SSR 안전 localStorage 훅
|
||||||
|
├── .env.example # GEMINI_API_KEY
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
핵심 파일 4개: `app/api/generate/route.ts`(백엔드 생성), `lib/constants.ts`(프롬프트 사전), `components/Studio.tsx`(클라이언트 플로우), `components/CompareSlider.tsx`(결과 비교 UI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AI / 이미지 생성 파이프라인 ★
|
||||||
|
|
||||||
|
### 사용 모델·API
|
||||||
|
- **Google Gemini `gemini-3.1-flash-image-preview`(나노바나나 2)** 를 `@google/genai` 공식 SDK의 `ai.models.generateContent()`로 호출.
|
||||||
|
- **입력 이미지 + 텍스트 지시문을 함께 전달**하는 멀티모달 image-to-image(인페인팅형 편집). Stable Diffusion·ControlNet 등 미사용 — 순수 Gemini 이미지 모델 단일 호출로 원본 구조를 참조·보존.
|
||||||
|
|
||||||
|
### 이미지 흐름 (입력 → 변환 → 결과)
|
||||||
|
|
||||||
|
```
|
||||||
|
[클라이언트 Studio.tsx]
|
||||||
|
1. 파일 업로드(드래그앤드롭/클릭) — image/* 검증, 10MB 상한
|
||||||
|
2. Canvas 전처리: 긴 쪽 1024px 다운스케일 → canvas.toDataURL('image/jpeg', 0.85) → base64 data URL (전송량·비용 절감 핵심)
|
||||||
|
3. POST /api/generate { image(base64), roomTypeId, styleId, byokKey }
|
||||||
|
|
||||||
|
[서버 route.ts — runtime='nodejs', maxDuration=60]
|
||||||
|
4. content-length 8MB 가드 → JSON 파싱 → roomType/style 유효성 검증
|
||||||
|
5. API 키 결정: BYOK 우선, 없으면 process.env.GEMINI_API_KEY
|
||||||
|
6. 데모 모드면 IP당 일일 제한(Map 인메모리) 체크
|
||||||
|
7. data URL에서 mimeType + base64 분리(정규식), 8MB*1.33 재검증
|
||||||
|
8. 프롬프트 조립 → Gemini generateContent 호출
|
||||||
|
9. 응답 candidate에서 inlineData(생성 이미지 base64) 추출
|
||||||
|
- finishReason==='SAFETY' → 차단 에러
|
||||||
|
- 성공 시에만 IP 카운트 차감(실패는 횟수 소모 안 함)
|
||||||
|
10. { image: base64 } 반환
|
||||||
|
|
||||||
|
[클라이언트]
|
||||||
|
11. `data:image/png;base64,${data.image}`로 resultImage 세팅
|
||||||
|
12. CompareSlider에 before(업로드)·after(결과) 주입
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프롬프트 구성 방식 (★ 가장 차용 가치 높음)
|
||||||
|
|
||||||
|
**구조화된 사전(dictionary) 조각을 템플릿에 조립**. `lib/constants.ts`가 UI 라벨(한글)과 프롬프트 조각(영문)을 한 객체에 묶어 **UI 선택과 서버 프롬프트가 단일 출처 공유**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 공간 유형: { id, label(한글), prompt(영문) }
|
||||||
|
{ id: "living_room", label: "거실", prompt: "living room" }
|
||||||
|
|
||||||
|
// 스타일: { id, label, desc, swatch(색3종), prompt(상세 지시문) }
|
||||||
|
{ id: "modern", label: "모던", swatch:["#2b2b2e",...],
|
||||||
|
prompt: "sleek modern style: clean lines, neutral palette with charcoal and greige,
|
||||||
|
low-profile furniture, matte finishes, statement lighting" }
|
||||||
|
```
|
||||||
|
|
||||||
|
최종 지시문 템플릿(route.ts:106):
|
||||||
|
```
|
||||||
|
Redesign this {roomType.prompt} interior in {style.prompt}.
|
||||||
|
Keep the room architecture — walls, windows, doors, ceiling and camera perspective — exactly the same.
|
||||||
|
Replace furniture, lighting, color palette and decor to match the target style.
|
||||||
|
Photorealistic interior photography, natural lighting, high detail.
|
||||||
|
```
|
||||||
|
|
||||||
|
프롬프트 4단 구성: **① 대상+스타일 지정 → ② "보존할 것" 명시적 잠금(architecture/perspective) → ③ "교체할 것" 명시 → ④ 사진 품질 지시(photorealistic/natural lighting/high detail)**. 이 "보존/교체 명시적 분리" 패턴이 부스 시스템의 핵심.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 킨텍스 부스 시스템 차용 패턴 ★★★
|
||||||
|
|
||||||
|
(전시 부스 배치·인테리어·전기·조명·네트워크 배선 자동화 → 나노바나나 시공 후 결과 사진)
|
||||||
|
|
||||||
|
**(A) 모델·SDK 선택 — 검증된 정답 그대로 채택**
|
||||||
|
- `@google/genai` + `gemini-3.1-flash-image-preview`, `generateContent`에 `{ inlineData:{mimeType,data} }`(입력 이미지) + `{ text: instruction }`(지시문)을 `parts` 배열로 전달하는 호출 형태 복제. `nanobanana-visualize` 스킬이 이 호출 패턴을 표준화.
|
||||||
|
|
||||||
|
**(B) "보존/교체 명시적 분리" 프롬프트 아키텍처 — 부스 도메인 치환**
|
||||||
|
- ReRoom "walls·windows·ceiling·camera perspective exactly same" → 부스 **"부스 외곽 치수·기둥·바닥 트렌치 그리드·천장 트러스·통로 방향·카메라 앵글 유지"**.
|
||||||
|
- ReRoom "furniture·lighting·color·decor 교체" → 부스 **"집기(데스크·선반·배너·사이니지)·조명 기구·전기 콘센트·네트워크 AP·카펫/부스 벽면 그래픽 배치"**.
|
||||||
|
- **구조화 사전 재사용**: `ROOM_TYPES`/`STYLES` → 부스 도메인 사전
|
||||||
|
- `BOOTH_TYPES`(독립/조립/코너/아일랜드, 3×3·6×3…)
|
||||||
|
- `BOOTH_STYLES`(럭셔리/테크/친환경/미니멀…)
|
||||||
|
- `FIXTURE_LAYERS`(조명 레이어·전기 배선 레이어·네트워크 배선 레이어) — **레이어별 프롬프트 조각** 조립로 "조명만 야간 연출"(S2)·"배선 오버레이"(S6) 변형 렌더.
|
||||||
|
- 각 객체가 `{ id, label(한글), prompt(영문), swatch }`를 갖고 **UI 선택 ↔ 서버 프롬프트 단일 출처 공유** 유지 시 유지보수 비용 급감.
|
||||||
|
|
||||||
|
**(C) Before/After 비교 슬라이더(`CompareSlider.tsx`) — 거의 무수정 재사용**
|
||||||
|
- `clip-path: inset(0 {100-pos}% 0 0)`로 After 레이어 클립 → 리사이즈에도 픽셀 정렬 유지. 포인터 캡처 + 키보드(방향키·Shift 큰 스텝) + ARIA slider 접근성 완비 자립 컴포넌트. design.md S5(시공 전 빈 부스 ↔ 시공 후 렌더)에 그대로 투입. React/Next 스택이면 파일 복사 수준.
|
||||||
|
|
||||||
|
**(D) 클라이언트 Canvas 전처리(`Studio.handleImageFile`)**
|
||||||
|
- 긴 쪽 1024px 다운스케일 + JPEG 0.85 → base64. 전송량·모델 비용·응답시간 동시 절감 필수 전처리. 부스 도면/현장 사진 동일 적용.
|
||||||
|
|
||||||
|
**(E) API 라우트 방어 로직 — 프로덕션 안정성 템플릿**
|
||||||
|
- 다층 크기 가드(content-length 8MB → base64 ×1.33 재검증), `finishReason==='SAFETY'` 처리, **에러 분기**(API_KEY_INVALID / RESOURCE_EXHAUSTED·quota·429 / SAFETY)로 친화적 한글 메시지. **성공 시에만 사용량 차감**. 부스 API(RenderJob 워커)의 골격으로 복제.
|
||||||
|
|
||||||
|
**(F) 이중 키 모드(데모/BYOK) + IP 레이트리밋**
|
||||||
|
- 서버 인메모리 `Map<ip,{count,resetAt}>` 24h 윈도우. 전시회 현장 태블릿 데모 노출에 유용. **인메모리라 재시작·다중 인스턴스 취약** → 우리 시스템은 PostgreSQL/Redis 기반 RenderJob 쿼터로 대체(PLANNING §6-4 비용 제어와 통합).
|
||||||
|
|
||||||
|
**(G) 단계별 로딩 UX**
|
||||||
|
- `LOADING_STATUSES`("공간 구조 분석 → 스타일 요소 배치 → 조명·색상 튜닝 → 최종 고화질 렌더링") 2.5초 순환. 부스용 "부스 골격 인식 → 집기 배치 → 전기·조명 배선 → 최종 렌더링"으로 치환. design.md 진행 배지("생성 중… 평균 40초")와 연결.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI / 화면 구성
|
||||||
|
|
||||||
|
단일 페이지(랜딩) 서비스. `page.tsx` 세로 조립: Header → Hero(CompareSlider LCP priority) → StyleGallery(카드 클릭 시 `window` 커스텀이벤트 `reroom:style`로 Studio 전달) → HowItWorks → **Studio ★** → Faq → Footer.
|
||||||
|
|
||||||
|
**Studio 흐름**(단일 컴포넌트가 입력→로딩→결과 3상태 조건부 렌더):
|
||||||
|
1. **입력** — 좌: `01 원본 업로드`(드래그존+미리보기), 우: `02 공간 유형`(pill), `03 스타일`(스와치 카드). 하단: BYOK 토글+키, 에러 alert, "생성하기".
|
||||||
|
2. **로딩** — 바운스 도트 + 순환 상태 텍스트(`aria-live`) + "약 10초".
|
||||||
|
3. **결과** — "Redesign Complete" 배지 + CompareSlider + [PNG 다운로드]/[다른 스타일]/[다른 사진].
|
||||||
|
|
||||||
|
상태 전부 로컬 `useState` + `useLocalStorage`(무료횟수·BYOK·키 영속). 전역 스토어·라우팅 없음 — 극경량 단일화면 SPA. 부스 초기 PoC도 이 패턴으로 빠르게 구현 후 확장 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 설계 착수 3대 차용 결론
|
||||||
|
|
||||||
|
1. **호출 스택 그대로**: `@google/genai` → `gemini-3.1-flash-image-preview`, 입력이미지(inlineData)+지시문(text) parts 전달 image-to-image 편집. `route.ts`가 프로덕션 API 골격 템플릿.
|
||||||
|
2. **프롬프트 = 구조화 사전 조립 + 보존/교체 명시 분리**: `constants.ts` `{id,label,prompt}`를 부스 도메인(부스타입·스타일·조명/전기/네트워크 레이어)으로 치환, "골격·앵글 유지 / 집기·배선·조명 배치" 명시 잠금 템플릿.
|
||||||
|
3. **CompareSlider·Canvas 전처리·단계별 로딩 UX** 파일 복사 수준 재사용(React/Next 전제).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ★ 보안 결정 필요 사항 (선행)
|
||||||
|
|
||||||
|
나노바나나(Gemini) 이미지 생성은 **외부 API 호출**이다. GUARDiA 보안 제약(루트 CLAUDE.md)상 외부 API는 원칙 금지이며 현재 승인된 예외는 `api.anthropic.com`뿐 — **Gemini(generativelanguage.googleapis.com)는 미승인**. 단, 본 kintex 프로젝트는 GUARDiA ITSM(관공서 인프라 관제)과 별개 도메인의 독립 저장소(`zio/kintex`)이며, PLANNING v1.0과 사용자 요구가 명시적으로 나노바나나를 채택하고 있다.
|
||||||
|
|
||||||
|
**조치**: 부스 M5 파이프라인 구현 착수 전 소유자에게 **Gemini 외부 호출 승인 여부**를 확정한다. 승인 시 `GEMINI_API_KEY`를 서버 env로만 로드(코드·DB·커밋·로그·응답 기록 금지, ReRoomAI (E) 방어 패턴 준수). 미승인 시 대안 — 온프레미스 이미지 생성(SDXL 등) 어댑터로 폴백하되 image-to-image 구조보존 품질은 재평가 필요.
|
||||||
Binary file not shown.
BIN
output/visualizations/_smoke_wiring.png
Normal file
BIN
output/visualizations/_smoke_wiring.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
15
output/visualizations/_smoke_wiring.png.meta.json
Normal file
15
output/visualizations/_smoke_wiring.png.meta.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"generated_at": "2026-07-11T07:32:19.154568+00:00",
|
||||||
|
"render_path": "backend_raster_composite",
|
||||||
|
"kinds": [
|
||||||
|
"power",
|
||||||
|
"network"
|
||||||
|
],
|
||||||
|
"rendered_counts": {
|
||||||
|
"power": 1,
|
||||||
|
"network": 1
|
||||||
|
},
|
||||||
|
"px_per_m": 40,
|
||||||
|
"watermark_required": true,
|
||||||
|
"watermark_text": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음"
|
||||||
|
}
|
||||||
@ -1,39 +1,71 @@
|
|||||||
# tools/nanobanana — Gemini 이미지 생성(나노바나나) 연동 모듈
|
# tools/nanobanana — Gemini 이미지 생성(나노바나나) 연동 모듈
|
||||||
|
|
||||||
부스/인테리어/배선/조명 설계 데이터를 "시공 후 결과 사진"으로 렌더링한다.
|
부스/인테리어/배선/조명 설계 데이터를 "시공 후 결과 사진"으로 렌더링한다.
|
||||||
|
설계 기준: ReRoomAI 실증 패턴(`docs/analysis/reroomai-source.md`) + PLANNING §6.
|
||||||
|
|
||||||
## 준비
|
## 준비
|
||||||
```bash
|
```bash
|
||||||
pip install google-genai
|
pip install google-genai Pillow
|
||||||
export GEMINI_API_KEY=발급받은_키 # https://aistudio.google.com
|
export GEMINI_API_KEY=발급받은_키 # https://aistudio.google.com
|
||||||
```
|
```
|
||||||
|
- 모델 기본값: `gemini-3.1-flash-image-preview`(나노바나나 2). `NANOBANANA_MODEL`로 오버라이드.
|
||||||
|
- ★ 실제 Gemini 호출은 소유자 승인 대기(reroomai-source.md §8). 키/네트워크 없이도 import·구조는 성립.
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작 (§6-2 scene 스키마)
|
||||||
```python
|
```python
|
||||||
from tools.nanobanana.client import NanoBananaClient
|
from tools.nanobanana.client import NanoBananaClient
|
||||||
|
|
||||||
|
scene = {
|
||||||
|
"hall": {"id": "제1전시장 7홀", "dims_m": [126, 90], "ceiling_m": 12},
|
||||||
|
"booth": {"id": "A-102", "size_m": [6, 3], "type": "independent"},
|
||||||
|
"design": {
|
||||||
|
"signage": {"text": "주식회사 가디아"},
|
||||||
|
"brand_color": "#0052A5",
|
||||||
|
"zones": [{"type": "demo"}, {"type": "consult"}],
|
||||||
|
"materials": [{"part": "wall", "finish": "matte_white"}],
|
||||||
|
},
|
||||||
|
"lighting": {"mode": "night", "color_temp_k": 4000},
|
||||||
|
"wiring": {"power": [{"from_trench": [1, 1], "to": [5, 2], "kw": 3}]},
|
||||||
|
"render_hints": {"style": "tech"},
|
||||||
|
}
|
||||||
|
|
||||||
c = NanoBananaClient()
|
c = NanoBananaClient()
|
||||||
img = c.generate_booth_photo({
|
img = c.render_shot(scene, shot_preset="S2",
|
||||||
"hall": "제1전시장 3홀",
|
reference_image="empty_booth.jpg", # 빈 부스 실측 → 구조 보존
|
||||||
"size_m": (6, 3),
|
seed=12345) # 컷 간 일관성(B-12)
|
||||||
"booth_type": "목공부스",
|
img.save("output/visualizations/booth_S2.png") # 사이드카 .meta.json 동시 기록
|
||||||
"brand_color": "#0052A5",
|
|
||||||
"materials": ["백색 무광 목공", "우드 포인트월", "그레이 카펫"],
|
|
||||||
"lighting": {"color_temp_k": 4000, "accents": ["로고 스포트", "쇼케이스 라인조명"]},
|
|
||||||
}, view="front", time_of_day="night")
|
|
||||||
img.save("booth_night.png")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 표준 샷 세트 S1~S7 (PLANNING §6 기준, visualizer 에이전트가 관리)
|
## 표준 샷 세트 S1~S7 (PLANNING §6-3)
|
||||||
| 샷 | 호출 | 용도 |
|
| 샷 | 호출 | 용도 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| S1 정면 주간 | `generate_booth_photo(view="front", time_of_day="day")` | 기본 시안 확인 |
|
| S1 정면 주간 | `render_shot(scene,"S1")` | 기본 시안 확인 |
|
||||||
| S2 정면 야간 | `generate_booth_photo(view="front", time_of_day="night")` | 조명 연출 평가 |
|
| S2 정면 야간 | `render_shot(scene,"S2")` | 조명 연출 평가 |
|
||||||
| S3 통로 뷰 | `generate_booth_photo(view="aisle")` | 관람객 시점 동선 평가 |
|
| S3 통로 뷰 | `render_shot(scene,"S3")` | 관람객 시점 동선 |
|
||||||
| S4 내부 뷰 | `generate_booth_photo(view="interior")` | 인테리어/집기 배치 |
|
| S4 내부 뷰 | `render_shot(scene,"S4")` | 인테리어/집기 배치 |
|
||||||
| S5 Before/After | S1 결과 + `reference_image`(빈 부스 실측) 쌍 | 시공 전/후 비교 슬라이더 |
|
| S5 Before/After | S1 결과 + `reference_image`(빈 부스 실측) 쌍 | 시공 전/후 비교 |
|
||||||
| S6 배선 오버레이 | `generate_wiring_overlay(kind="network|power|plumbing")` | 발표/설명용 보조 이미지 (시공 검증은 백엔드 래스터 합성 사용) |
|
| S6 배선 오버레이 | `render_wiring_overlay_raster(wiring, hall_dims_m, kinds)` ★래스터 합성 | 시공 검증(좌표 정합) |
|
||||||
| S7 홀 전경 조감 | `generate_booth_photo(view="aerial")` | 배치안 비교(SCR-04) |
|
| S7 홀 전경 조감 | `render_shot(scene,"S7")` | 배치안 비교(SCR-04) |
|
||||||
|
|
||||||
## 정책
|
### S6 — 생성형 아님 (B-02)
|
||||||
- 생성 이미지는 "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음" 워터마크를 붙여 서비스에 노출한다 (계약 서류 사용 금지).
|
시공 검증용 S6는 좌표 정합이 보장되는 **백엔드 래스터 합성**이다(로컬 PIL):
|
||||||
- 참조 이미지(빈 부스 실측 사진)를 넣으면 실제 홀 구조를 보존한 합성이 된다 (ReRoomAI 패턴).
|
```python
|
||||||
|
from tools.nanobanana.client import render_wiring_overlay_raster
|
||||||
|
ov = render_wiring_overlay_raster(scene["wiring"], hall_dims_m=(6, 3),
|
||||||
|
kinds=["power", "network", "plumbing"],
|
||||||
|
base_image="floorplan.png") # 선택
|
||||||
|
ov.save("output/visualizations/booth_S6_wiring.png")
|
||||||
|
```
|
||||||
|
`generate_wiring_overlay(layout, kind)`(생성형)는 발표/설명용 보조 이미지 전용 — 시공 검증 사용 금지.
|
||||||
|
|
||||||
|
## 하위호환
|
||||||
|
평면 `booth_spec` dict를 쓰던 기존 코드는 `generate_booth_photo(booth_spec, view, time_of_day)`가
|
||||||
|
내부적으로 §6-2 scene 으로 변환 후 `render_shot`에 위임한다(그대로 동작).
|
||||||
|
|
||||||
|
## 방어·정책
|
||||||
|
- 입력 이미지 8MB 상한 + 긴 쪽 1024px 다운스케일/JPEG 0.85(`downscale_for_upload`).
|
||||||
|
- 에러 분기: `NanoBananaAuthError`(키 무효) / `NanoBananaQuotaError`(429·쿼터) / `NanoBananaSafetyError`(SAFETY).
|
||||||
|
- 쿼터/카운트는 **성공 시에만** 차감(`usage_recorder` 훅).
|
||||||
|
- 모든 산출 이미지에 "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음" 워터마크(계약 서류 사용 금지).
|
||||||
|
- 메타데이터(생성일·스키마 해시·모델 버전·seed)는 사이드카 `.meta.json` + PNG tEXt 청크로 임베드(B-03).
|
||||||
|
- API 키는 환경변수 `GEMINI_API_KEY`에서만 로드 — 코드/로그/커밋에 절대 기록 금지.
|
||||||
|
|||||||
BIN
tools/nanobanana/__pycache__/client.cpython-314.pyc
Normal file
BIN
tools/nanobanana/__pycache__/client.cpython-314.pyc
Normal file
Binary file not shown.
@ -1,42 +1,407 @@
|
|||||||
"""나노바나나(Gemini 이미지 생성) 연동 클라이언트.
|
"""나노바나나(Gemini 이미지 생성) 연동 클라이언트.
|
||||||
|
|
||||||
킨텍스 AI 시스템의 모든 이미지 생성은 이 모듈을 통해서만 수행한다.
|
킨텍스 AI 시스템의 모든 이미지 생성은 이 모듈을 통해서만 수행한다.
|
||||||
환경변수 GEMINI_API_KEY 필요. 키가 없으면 명확한 에러를 낸다.
|
설계 기준: 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
|
from tools.nanobanana.client import NanoBananaClient
|
||||||
client = NanoBananaClient()
|
client = NanoBananaClient()
|
||||||
result = client.generate_booth_photo(
|
img = client.render_shot(scene, shot_preset="S1", reference_image="empty_booth.jpg")
|
||||||
booth_spec={
|
img.save("output/visualizations/booth_S1.png") # 사이드카 .meta.json 동시 기록
|
||||||
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
MODEL_NAME = os.environ.get("NANOBANANA_MODEL", "gemini-2.5-flash-image")
|
# ----------------------------------------------------------------------------
|
||||||
|
# 모델 / 경로 상수
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#: 기본 모델 = 나노바나나 2 (ReRoomAI 검증 모델). 환경변수로 오버라이드 가능.
|
||||||
|
MODEL_NAME = os.environ.get("NANOBANANA_MODEL", "gemini-3.1-flash-image-preview")
|
||||||
|
|
||||||
PROMPTS_DIR = Path(__file__).parent / "prompts"
|
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 NanoBananaError(RuntimeError):
|
||||||
pass
|
"""나노바나나 연동 일반 오류."""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
@dataclass
|
||||||
@ -44,10 +409,42 @@ class GeneratedImage:
|
|||||||
data: bytes
|
data: bytes
|
||||||
mime_type: str
|
mime_type: str
|
||||||
prompt_used: str
|
prompt_used: str
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
def save(self, path: str | Path) -> Path:
|
def save(self, path: str | Path) -> Path:
|
||||||
|
"""이미지 저장 + 결정적 메타데이터 임베드.
|
||||||
|
|
||||||
|
- 사이드카 JSON(`<path>.meta.json`)을 항상 기록(결정적).
|
||||||
|
- PNG 이면 tEXt 청크에 메타데이터를 추가 임베드(PIL 있을 때).
|
||||||
|
"""
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
p.write_bytes(self.data)
|
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
|
return p
|
||||||
|
|
||||||
|
|
||||||
@ -58,110 +455,364 @@ def _load_template(name: str) -> str:
|
|||||||
return f.read_text(encoding="utf-8")
|
return f.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 클라이언트
|
||||||
|
# ============================================================================
|
||||||
class NanoBananaClient:
|
class NanoBananaClient:
|
||||||
"""Gemini 이미지 생성 API 래퍼. google-genai SDK 사용."""
|
"""Gemini 이미지 생성 API 래퍼. google-genai SDK, image-to-image 편집.
|
||||||
|
|
||||||
def __init__(self, api_key: Optional[str] = None, model: str = MODEL_NAME):
|
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")
|
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise NanoBananaError(
|
raise NanoBananaAuthError(
|
||||||
"GEMINI_API_KEY 환경변수가 필요합니다. "
|
"GEMINI_API_KEY 환경변수가 필요합니다. "
|
||||||
"https://aistudio.google.com 에서 발급 후 설정하세요."
|
"https://aistudio.google.com 에서 발급 후 설정하세요."
|
||||||
)
|
)
|
||||||
self.model = model
|
self.model = model
|
||||||
# 지연 임포트: SDK 미설치 환경에서도 모듈 로드는 가능하게
|
self.usage_recorder = usage_recorder
|
||||||
|
self._seed_supported: Optional[bool] = None # None=미확인, True/False=판별됨
|
||||||
|
|
||||||
|
# 지연 임포트: SDK 미설치 환경에서도 모듈 로드는 가능하게(승인 전 방어)
|
||||||
try:
|
try:
|
||||||
from google import genai # type: ignore
|
from google import genai # type: ignore
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
raise NanoBananaError("pip install google-genai 필요") from e
|
raise NanoBananaError("pip install google-genai 필요") from e
|
||||||
self._client = genai.Client(api_key=self.api_key)
|
self._client = genai.Client(api_key=self.api_key)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 저수준 생성
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def generate(
|
def generate(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
reference_image: Optional[str | Path] = None,
|
reference_image: Optional[str | Path | bytes] = None,
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
) -> GeneratedImage:
|
) -> GeneratedImage:
|
||||||
"""저수준 생성. 프롬프트(+선택적 참조 이미지) → 이미지 1장."""
|
"""프롬프트(+선택 참조 이미지) → 이미지 1장. image-to-image 편집.
|
||||||
|
|
||||||
|
참조 이미지는 업로드 전 긴 쪽 1024px 로 다운스케일된다(ReRoomAI 전처리).
|
||||||
|
"""
|
||||||
|
from google.genai import types # type: ignore
|
||||||
|
|
||||||
contents: list = []
|
contents: list = []
|
||||||
if reference_image:
|
if reference_image is not None:
|
||||||
import mimetypes
|
img_bytes, mime = downscale_for_upload(reference_image)
|
||||||
|
contents.append(types.Part.from_bytes(data=img_bytes, mime_type=mime))
|
||||||
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)
|
contents.append(prompt)
|
||||||
|
|
||||||
resp = self._client.models.generate_content(
|
resp = self._call_model(contents, seed)
|
||||||
model=self.model, contents=contents
|
result = self._extract_image(resp, prompt, metadata or {})
|
||||||
)
|
|
||||||
for part in resp.candidates[0].content.parts:
|
# 성공 시에만 사용량 차감(ReRoomAI: 실패는 횟수 소모 안 함)
|
||||||
if getattr(part, "inline_data", None):
|
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(
|
return GeneratedImage(
|
||||||
data=part.inline_data.data,
|
data=inline.data,
|
||||||
mime_type=part.inline_data.mime_type,
|
mime_type=getattr(inline, "mime_type", "image/png"),
|
||||||
prompt_used=prompt,
|
prompt_used=prompt,
|
||||||
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
raise NanoBananaError("이미지가 반환되지 않았습니다: " + str(resp))
|
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(
|
def generate_booth_photo(
|
||||||
self,
|
self,
|
||||||
booth_spec: dict,
|
booth_spec: dict,
|
||||||
view: str = "front",
|
view: str = "front",
|
||||||
time_of_day: str = "day",
|
time_of_day: str = "day",
|
||||||
reference_image: Optional[str | Path] = None,
|
reference_image: Optional[str | Path | bytes] = None,
|
||||||
|
seed: Optional[int] = None,
|
||||||
) -> GeneratedImage:
|
) -> GeneratedImage:
|
||||||
"""부스 스펙 → 시공 후 사실적 사진. 템플릿: prompts/booth_photo.txt"""
|
"""[하위호환] 평면 booth_spec dict → 시공 후 사진.
|
||||||
template = _load_template("booth_photo")
|
|
||||||
w, d = booth_spec.get("size_m", (3, 3))
|
내부적으로 §6-2 scene 으로 변환 후 render_shot 위임.
|
||||||
view_phrase = {
|
view: front|aisle|interior|aerial, time_of_day: day|night.
|
||||||
"front": "eye-level front view of the booth",
|
"""
|
||||||
"aisle": "view from the visitor aisle looking at the booth",
|
view_to_shot = {
|
||||||
"interior": "view from inside the booth showing interior fixtures",
|
("front", "day"): "S1",
|
||||||
"aerial": "high-angle aerial overview of the exhibition hall floor with this booth visible in context", # S7
|
("front", "night"): "S2",
|
||||||
}.get(view, view)
|
("aisle", "day"): "S3",
|
||||||
prompt = template.format(
|
("interior", "day"): "S4",
|
||||||
hall=booth_spec.get("hall", "KINTEX exhibition hall"),
|
("aerial", "day"): "S7",
|
||||||
width=w,
|
}
|
||||||
depth=d,
|
shot = view_to_shot.get((view, time_of_day))
|
||||||
booth_type=booth_spec.get("booth_type", "custom wooden booth"),
|
if shot is None:
|
||||||
signage=booth_spec.get("signage", ""),
|
shot = {"front": "S1", "aisle": "S3", "interior": "S4", "aerial": "S7"}.get(
|
||||||
brand_color=booth_spec.get("brand_color", "#0052A5"),
|
view, "S1"
|
||||||
materials=", ".join(booth_spec.get("materials", [])),
|
)
|
||||||
color_temp=booth_spec.get("lighting", {}).get("color_temp_k", 4000),
|
if time_of_day == "night":
|
||||||
accents=", ".join(booth_spec.get("lighting", {}).get("accents", [])),
|
shot = "S2"
|
||||||
view=view_phrase,
|
|
||||||
time_of_day=time_of_day,
|
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
|
||||||
)
|
)
|
||||||
return self.generate(prompt, reference_image=reference_image)
|
|
||||||
|
|
||||||
#: S6 배선 색상 규약 (design.md §1-2와 동일)
|
|
||||||
WIRING_COLORS = {"power": "red", "network": "blue", "plumbing": "green"}
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# S6 생성형 오버레이 (발표/설명용 보조 — 시공 검증 아님)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
def generate_wiring_overlay(
|
def generate_wiring_overlay(
|
||||||
self, layout_json: dict, kind: str = "network"
|
self, layout_json: dict, kind: str = "network"
|
||||||
) -> GeneratedImage:
|
) -> GeneratedImage:
|
||||||
"""배치도 + 배선 계획 → 시공 안내용 오버레이 이미지(S6).
|
"""[보조] 배치도 + 배선 계획 → 생성형 오버레이(발표/설명용).
|
||||||
|
|
||||||
kind: network(청) | power(적) | plumbing(급배수, 녹)
|
★ 시공 검증용 S6 1차 산출물은 좌표 정합이 보장되는 백엔드 래스터 합성
|
||||||
|
(render_wiring_overlay_raster)이다. 이 생성형 이미지는 시공 검증에 쓰지 말 것.
|
||||||
※ 주의(PLANNING §6-3): S6의 1차 산출물은 좌표 정합이 보장되는
|
|
||||||
래스터 합성(백엔드 렌더러)이며, 이 생성형 오버레이는 발표/설명용
|
|
||||||
보조 이미지다. 시공 검증용으로 사용하지 말 것.
|
|
||||||
"""
|
"""
|
||||||
if kind not in self.WIRING_COLORS:
|
if kind not in self.WIRING_COLORS:
|
||||||
raise NanoBananaError(f"kind는 {list(self.WIRING_COLORS)} 중 하나: {kind}")
|
raise NanoBananaError(f"kind는 {list(self.WIRING_COLORS)} 중 하나: {kind}")
|
||||||
template = _load_template("wiring_overlay")
|
template = _load_template("wiring_overlay")
|
||||||
import json
|
|
||||||
|
|
||||||
prompt = template.format(
|
prompt = template.format(
|
||||||
kind=kind,
|
kind=kind,
|
||||||
color=self.WIRING_COLORS[kind],
|
color=self.WIRING_COLORS[kind],
|
||||||
layout=json.dumps(layout_json, ensure_ascii=False),
|
layout=json.dumps(layout_json, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
return self.generate(prompt)
|
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 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@ -1,11 +1,24 @@
|
|||||||
Photorealistic professional photograph of a completed exhibition booth at {hall}, KINTEX convention center in South Korea, immediately after construction is finished, before visitors arrive.
|
# [참조 문서] 부스 사진 프롬프트 4단 구조
|
||||||
|
#
|
||||||
Booth specification:
|
# 이 파일은 더 이상 코드에서 format() 소비되지 않는다.
|
||||||
- Size: {width}m wide x {depth}m deep, {booth_type}
|
# 최종 지시문은 client.py 의 build_booth_prompt(scene, shot_preset, layers) 가
|
||||||
- Primary brand color: {brand_color}
|
# 구조화 사전(BOOTH_TYPES · BOOTH_STYLES · FIXTURE_LAYERS · SHOT_PRESETS)에서 결정적으로 조립한다.
|
||||||
- Materials and finishes: {materials}
|
# 프롬프트 문안을 검토/수정할 때 아래 4단 구조와 client.py 사전을 함께 본다.
|
||||||
- Lighting: {color_temp}K color temperature, accent lighting: {accents}
|
#
|
||||||
- Fascia signage text (render exactly, in Korean): "{signage}"
|
# ── 조립 구조 (ReRoomAI 보존/교체 분리 패턴) ────────────────────────────
|
||||||
- View: {view}, {time_of_day} lighting conditions
|
#
|
||||||
|
# ① 대상 + 스타일
|
||||||
Photography style: shot on a full-frame camera with a 24mm lens at eye level, sharp focus, realistic exhibition hall ambient lighting with visible ceiling trusses and neighboring empty booths, polished concrete floor with carpet inside the booth. No people, no real brand logos other than the specified signage text. The image must look like a real construction-completion documentation photo, not a 3D render.
|
# "Photorealistic professional photograph of a completed {BOOTH_TYPES[type].prompt}
|
||||||
|
# at {hall}, KINTEX ... {W}m wide x {D}m deep, immediately after construction,
|
||||||
|
# before visitors arrive. Primary brand color {brand}. Design style:
|
||||||
|
# {BOOTH_STYLES[style].prompt}. Camera: {SHOT_PRESETS[shot].camera}."
|
||||||
|
#
|
||||||
|
# ② 보존 잠금 (_preserve_lock)
|
||||||
|
# 부스 외곽 치수 · 구조 기둥 · 바닥 트렌치 그리드 · 천장 트러스 · 통로 방향 · 카메라 앵글 유지.
|
||||||
|
#
|
||||||
|
# ③ 교체 지정 (_replace_spec, 활성 FIXTURE_LAYERS 조각 조립)
|
||||||
|
# 집기 · 조명 · 전기 콘센트 · 네트워크 AP + 간판 문구(한글) · 자재 · 존 · 색온도.
|
||||||
|
#
|
||||||
|
# ④ 사진 품질 (_photo_quality)
|
||||||
|
# photorealistic tradeshow, natural lighting(day/night), high detail,
|
||||||
|
# no people, no real brand logos, 실제 시공완료 문서 사진처럼(3D 렌더 아님).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user