"""구조물 조감도 생성 UI 앱. 독립적인 CustomTkinter 애플리케이션. 워크플로우: Step 1: DXF 파일 업로드 (다중 파일 가능) Step 2: 구조물 유형 선택 (수문/건물/옹벽/교량/터널/일반) Step 3: 자동 파싱 결과 확인 & 파라미터 편집 Step 4: 3D 미리보기 (인터랙티브 또는 캡처) Step 5: 저장 / AI 렌더링 실행: python structure_ui.py """ from __future__ import annotations import os import sys import math import json import threading from pathlib import Path from tkinter import filedialog, messagebox import customtkinter as ctk import numpy as np import pyvista as pv from PIL import Image from structure_templates import ( REGISTRY, StructureTemplate, StructureParams, ParamField, ) try: from filename_classifier import suggest_with_confidence FILENAME_CLASSIFIER_AVAILABLE = True except ImportError: FILENAME_CLASSIFIER_AVAILABLE = False # UI 테마 설정 ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") class StructureUIApp(ctk.CTk): """구조물 조감도 생성 UI 메인 앱.""" def __init__(self): super().__init__() self.title("EG-VIEW Structure: 구조물 조감도 생성기") self.geometry("1240x820") # 상태 변수 self.dxf_paths: list[str] = [] self.current_template: StructureTemplate | None = None self.current_params: StructureParams | None = None self.current_meshes: list = [] self.param_entries: dict[str, ctk.CTkEntry] = {} self.current_capture: Image.Image | None = None self.ai_rendered: Image.Image | None = None # 실시간 렌더링용 지속 plotter self._live_plotter: pv.Plotter | None = None self._live_size: int = 600 # 실시간 미리보기 해상도 (빠른 응답을 위해 작게) self._render_after_id = None # 디바운싱용 after() ID self._render_in_progress = False # 렌더 설정 self.elevation_var = ctk.DoubleVar(value=35.0) self.azimuth_var = ctk.DoubleVar(value=225.0) self.zoom_var = ctk.DoubleVar(value=1.0) self.time_var = ctk.StringVar(value="낮 (Daytime)") self.api_key_var = ctk.StringVar( value=os.environ.get("GCP_PROJECT_ID", "") or os.environ.get("GEMINI_API_KEY", "") ) self.use_vertex_var = ctk.BooleanVar( value=bool(os.environ.get("GCP_PROJECT_ID", "")) ) self.vertex_location_var = ctk.StringVar( value=os.environ.get("GCP_LOCATION", "us-central1") ) self._build_layout() # 앱 종료 시 plotter 정리 self.protocol("WM_DELETE_WINDOW", self._on_close) # ---------------------------------------------------------------------- # 레이아웃 # ---------------------------------------------------------------------- def _build_layout(self): self.grid_columnconfigure(0, weight=0, minsize=420) self.grid_columnconfigure(1, weight=1) self.grid_rowconfigure(0, weight=1) # --- 왼쪽 사이드바 --- sidebar = ctk.CTkScrollableFrame(self, corner_radius=0, width=400) sidebar.grid(row=0, column=0, sticky="nsew", padx=0, pady=0) self.sidebar = sidebar # 로고 ctk.CTkLabel( sidebar, text="EG-VIEW Structure", font=ctk.CTkFont(size=22, weight="bold"), ).pack(padx=20, pady=(20, 0), anchor="w") ctk.CTkLabel( sidebar, text="토목 구조물 도면 → 3D 조감도", font=ctk.CTkFont(size=11, slant="italic"), text_color="gray", ).pack(padx=20, pady=(0, 15), anchor="w") self._build_step1_upload(sidebar) self._build_step2_template(sidebar) self._build_step3_params(sidebar) self._build_step4_render(sidebar) self._build_step5_export(sidebar) # --- 오른쪽 메인 영역 --- main = ctk.CTkFrame(self, corner_radius=10) main.grid(row=0, column=1, sticky="nsew", padx=10, pady=10) main.grid_columnconfigure(0, weight=1) main.grid_rowconfigure(0, weight=3) main.grid_rowconfigure(1, weight=1) # 이미지 프리뷰 self.preview_label = ctk.CTkLabel( main, text="3D 미리보기는 구조물 빌드 후 표시됩니다", font=ctk.CTkFont(size=14), fg_color="#1e1e1e", corner_radius=8, ) self.preview_label.grid(row=0, column=0, sticky="nsew", padx=10, pady=(10, 5)) # 로그 self.log_box = ctk.CTkTextbox( main, height=180, font=ctk.CTkFont(family="Consolas", size=11), ) self.log_box.grid(row=1, column=0, sticky="nsew", padx=10, pady=(5, 10)) self.log("EG-VIEW Structure 앱 시작. 구조물 DXF 파일을 업로드하세요.") # ---------------------------------------------------------------------- # Step 1: 파일 업로드 # ---------------------------------------------------------------------- def _build_step1_upload(self, parent): frame = self._section_frame(parent, "Step 1: DXF 파일 업로드") self.file_listbox = ctk.CTkTextbox( frame, height=90, font=ctk.CTkFont(family="Consolas", size=11), ) self.file_listbox.pack(fill="x", padx=10, pady=(5, 5)) self.file_listbox.insert("1.0", "(파일 없음)") btn_row = ctk.CTkFrame(frame, fg_color="transparent") btn_row.pack(fill="x", padx=10, pady=(0, 10)) ctk.CTkButton( btn_row, text="파일 추가", width=100, command=self._on_add_files, ).pack(side="left", padx=2) ctk.CTkButton( btn_row, text="목록 초기화", width=100, fg_color="transparent", border_width=1, command=self._on_clear_files, ).pack(side="left", padx=2) def _on_add_files(self): paths = filedialog.askopenfilenames( title="구조물 DXF 파일 선택", filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")], ) if not paths: return for p in paths: if p not in self.dxf_paths: self.dxf_paths.append(p) self._refresh_file_list() # 파일명으로 구조물 유형 자동 추정 if FILENAME_CLASSIFIER_AVAILABLE and self.dxf_paths: self._auto_suggest_template() def _auto_suggest_template(self): """업로드된 파일명들에서 구조물 유형 추정 → 드롭다운 자동 선택.""" # 각 파일마다 추정하고 최다 득표 수집 votes = {} # {template_id: total_confidence} for path in self.dxf_paths: tid, conf = suggest_with_confidence(path) if tid: votes[tid] = votes.get(tid, 0.0) + conf if not votes: self.log(" 파일명에서 구조물 유형을 추정하지 못함. 수동 선택하세요.") return # 최고 득표 best_tid = max(votes.items(), key=lambda x: x[1])[0] best_score = votes[best_tid] # 현재 선택이 다르면 변경 tpl = REGISTRY.get(best_tid) if tpl: current_name = self.template_var.get() target_name = tpl.name_ko if current_name != target_name: self.template_var.set(target_name) self._on_template_changed(target_name) self.log(f" 🔍 파일명 분석 → [{tpl.name_ko}] 자동 선택 (신뢰도 {best_score:.0%})") else: self.log(f" 🔍 파일명 확인 → [{tpl.name_ko}] 유형 일치 (신뢰도 {best_score:.0%})") def _on_clear_files(self): self.dxf_paths = [] self._refresh_file_list() def _refresh_file_list(self): self.file_listbox.delete("1.0", "end") if not self.dxf_paths: self.file_listbox.insert("1.0", "(파일 없음)") else: for i, p in enumerate(self.dxf_paths, 1): self.file_listbox.insert("end", f"{i}. {os.path.basename(p)}\n") # ---------------------------------------------------------------------- # Step 2: 템플릿 선택 # ---------------------------------------------------------------------- def _build_step2_template(self, parent): frame = self._section_frame(parent, "Step 2: 구조물 유형 선택") choices = REGISTRY.list_choices() self.template_var = ctk.StringVar(value=choices[0][1]) # 이름→ID 매핑 self._template_name_to_id = {name: tid for tid, name in choices} self.template_menu = ctk.CTkOptionMenu( frame, variable=self.template_var, values=[name for _, name in choices], command=self._on_template_changed, width=360, ) self.template_menu.pack(fill="x", padx=10, pady=(5, 5)) self.template_desc = ctk.CTkLabel( frame, text="", font=ctk.CTkFont(size=11), text_color="gray", wraplength=360, justify="left", ) self.template_desc.pack(fill="x", padx=10, pady=(0, 5)) ctk.CTkButton( frame, text="자동 파싱 실행", height=32, command=self._on_parse, ).pack(fill="x", padx=10, pady=(5, 10)) # 초기 템플릿 설명 self._on_template_changed(choices[0][1]) def _on_template_changed(self, name: str): tid = self._template_name_to_id.get(name) tpl = REGISTRY.get(tid) if tpl: self.current_template = tpl self.template_desc.configure( text=f"{tpl.description} (파일 {tpl.required_files[0]}~{tpl.required_files[1]}개)" ) def _on_parse(self): if not self.dxf_paths: messagebox.showwarning("주의", "먼저 DXF 파일을 업로드하세요.") return if not self.current_template: messagebox.showwarning("주의", "구조물 유형을 선택하세요.") return tpl = self.current_template self.log(f">>> 파싱 시작 [{tpl.name_ko}]...") try: params = tpl.parse(self.dxf_paths) self.current_params = params # 검출된 뷰 로그 views = params.get("_views") or [] if views: self.log(f" 📐 뷰 {len(views)}개 검출:") for v in views: frame = "프레임" if v.has_frame else "추정" scale = f" {v.scale_hint}" if v.scale_hint else "" label_short = v.label_text[:40] self.log(f" • [{v.view_type_ko}] \"{label_short}\" " f"({frame}{scale}, {v.width:.1f}×{v.height:.1f}m, " f"{len(v.shapes)}개 요소)") else: self.log(f" 뷰 라벨 없음 → geometry-based 처리") self._refresh_param_fields() self.log(f" 파싱 완료: 파라미터 {sum(1 for k in params.params if not k.startswith('_'))}개 (편집 가능)") for k, v in params.params.items(): if k.startswith("_"): continue if isinstance(v, (int, float)): self.log(f" {k} = {v}") except Exception as e: self.log(f" 파싱 오류: {e}") import traceback traceback.print_exc() messagebox.showerror("오류", f"파싱 중 오류:\n{e}") # ---------------------------------------------------------------------- # Step 3: 파라미터 편집 # ---------------------------------------------------------------------- def _build_step3_params(self, parent): frame = self._section_frame(parent, "Step 3: 파라미터 확인/편집") self.param_frame = ctk.CTkFrame(frame, fg_color="transparent") self.param_frame.pack(fill="x", padx=10, pady=(5, 5)) ctk.CTkLabel( self.param_frame, text="(파싱 후 표시됩니다)", text_color="gray", font=ctk.CTkFont(size=11), ).pack(padx=10, pady=10) ctk.CTkButton( frame, text="3D 모델 빌드 / 재생성", height=32, fg_color="#1f538d", hover_color="#14375e", command=self._on_build_3d, ).pack(fill="x", padx=10, pady=(5, 10)) def _refresh_param_fields(self): # 기존 위젯 제거 for w in self.param_frame.winfo_children(): w.destroy() self.param_entries = {} if not self.current_template or not self.current_params: return schema = self.current_template.get_parameter_schema() params = self.current_params # 2열 그리드 self.param_frame.grid_columnconfigure(0, weight=0) self.param_frame.grid_columnconfigure(1, weight=1) self.param_frame.grid_columnconfigure(2, weight=0) self.param_frame.grid_columnconfigure(3, weight=1) row = 0 col = 0 for field in schema: base_col = col * 2 label_text = f"{field.label}" if field.unit: label_text += f" ({field.unit})" ctk.CTkLabel( self.param_frame, text=label_text, font=ctk.CTkFont(size=11), anchor="w", ).grid(row=row, column=base_col, padx=(5, 2), pady=3, sticky="w") current_val = params.get(field.name, field.default) if field.param_type == "choice" and field.choices: var = ctk.StringVar( value=field.choices[int(current_val)] if isinstance(current_val, (int, float)) and 0 <= int(current_val) < len(field.choices) else field.choices[0] ) menu = ctk.CTkOptionMenu( self.param_frame, variable=var, values=field.choices, width=140, ) menu.grid(row=row, column=base_col + 1, padx=(2, 10), pady=3, sticky="w") self.param_entries[field.name] = var else: entry = ctk.CTkEntry(self.param_frame, width=120) entry.grid(row=row, column=base_col + 1, padx=(2, 10), pady=3, sticky="w") entry.insert(0, f"{current_val}") self.param_entries[field.name] = entry col += 1 if col >= 2: col = 0 row += 1 def _collect_params(self) -> dict: """UI의 편집 값을 수집.""" if not self.current_template: return {} schema = self.current_template.get_parameter_schema() collected = {} for field in schema: widget = self.param_entries.get(field.name) if widget is None: continue if field.param_type == "choice" and field.choices: # StringVar → 선택 인덱스 val_str = widget.get() if val_str in field.choices: collected[field.name] = field.choices.index(val_str) else: collected[field.name] = int(field.default) else: try: val_str = widget.get() if field.param_type == "int": collected[field.name] = int(float(val_str)) else: collected[field.name] = float(val_str) except (ValueError, TypeError): collected[field.name] = field.default return collected def _on_build_3d(self): if not self.current_template or not self.current_params: messagebox.showwarning("주의", "먼저 자동 파싱을 실행하세요.") return # UI 값 수집 → params 갱신 collected = self._collect_params() self.current_params.update(collected) self.log(">>> 3D 모델 빌드 중...") try: meshes = self.current_template.build_meshes(self.current_params) self.current_meshes = meshes self.log(f" {len(meshes)}개 메쉬 컴포넌트 생성 → 실시간 프리뷰 활성화") # 지속 plotter 셋업 (이후 슬라이더 이동 시 카메라만 갱신) self._setup_live_plotter() self._live_render() except Exception as e: self.log(f" 빌드 오류: {e}") import traceback traceback.print_exc() messagebox.showerror("오류", f"3D 빌드 오류:\n{e}") # ---------------------------------------------------------------------- # Step 4: 렌더 컨트롤 # ---------------------------------------------------------------------- def _build_step4_render(self, parent): frame = self._section_frame(parent, "Step 4: 렌더 설정 (실시간)") ctk.CTkLabel( frame, text="슬라이더를 움직이면 3D 화면이 실시간으로 갱신됩니다", font=ctk.CTkFont(size=10), text_color="gray", ).pack(padx=10, pady=(2, 5), anchor="w") # 앙각/방위각 슬라이더 self._build_slider(frame, "앙각", self.elevation_var, 0, 90, "°") self._build_slider(frame, "방위각", self.azimuth_var, 0, 360, "°") self._build_slider(frame, "줌", self.zoom_var, 0.5, 3.0, "×") # 시간대 row = ctk.CTkFrame(frame, fg_color="transparent") row.pack(fill="x", padx=10, pady=(5, 5)) ctk.CTkLabel(row, text="시간대", width=60).pack(side="left") ctk.CTkOptionMenu( row, variable=self.time_var, values=["낮 (Daytime)", "일몰 (Sunset)", "흐림 (Overcast)"], width=180, ).pack(side="left", padx=5) btn_row = ctk.CTkFrame(frame, fg_color="transparent") btn_row.pack(fill="x", padx=10, pady=(5, 10)) ctk.CTkButton( btn_row, text="뷰 초기화", width=150, command=self._on_reset_view, ).pack(side="left", padx=2) ctk.CTkButton( btn_row, text="인터랙티브 3D", width=150, fg_color="transparent", border_width=1, command=self._on_interactive, ).pack(side="left", padx=2) def _on_reset_view(self): """카메라 슬라이더를 기본값으로 재설정.""" self.elevation_var.set(35.0) self.azimuth_var.set(225.0) self.zoom_var.set(1.0) self._schedule_live_render(0) def _build_slider(self, parent, label, var, mn, mx, unit): row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x", padx=10, pady=2) ctk.CTkLabel(row, text=label, width=60).pack(side="left") slider = ctk.CTkSlider( row, from_=mn, to=mx, variable=var, width=200, ) slider.pack(side="left", padx=5) value_label = ctk.CTkLabel(row, text=f"{var.get():.1f}{unit}", width=50) value_label.pack(side="left") def _update(val): value_label.configure(text=f"{float(val):.1f}{unit}") # 실시간 프리뷰 트리거 (디바운싱) self._schedule_live_render() slider.configure(command=_update) def _capture_and_show(self): if not self.current_meshes: return try: # live plotter가 있으면 그걸 사용 (훨씬 빠름) if self._live_plotter is not None: self._live_render() return img = self._capture_3d(size=800) self.current_capture = img self._show_in_preview(img) self.log(f" 미리보기 갱신 완료 (800×800)") except Exception as e: self.log(f" 캡처 오류: {e}") # --- 실시간 렌더링 시스템 --- def _setup_live_plotter(self): """메쉬들을 한번만 로드한 지속 plotter 생성. 이후 슬라이더 변경 시엔 카메라만 갱신하여 빠르게 재렌더. """ # 기존 plotter 정리 if self._live_plotter is not None: try: self._live_plotter.close() except Exception: pass self._live_plotter = None if not self.current_meshes: return plotter = pv.Plotter(off_screen=True, window_size=(self._live_size, self._live_size)) plotter.set_background("#C8D4E0") for mesh, color, opacity in self.current_meshes: try: plotter.add_mesh( mesh, color=color, opacity=opacity, smooth_shading=True, specular=0.3, specular_power=10, ) except Exception: pass plotter.enable_3_lights() # 초기 카메라 설정 및 렌더 cam_pos, focal, up = self._compute_camera() plotter.camera_position = [cam_pos, focal, up] # show(auto_close=False)로 렌더 파이프라인 초기화 try: plotter.show(auto_close=False) except Exception: pass self._live_plotter = plotter def _schedule_live_render(self, delay_ms: int = 30): """디바운싱: 슬라이더 이동 중 너무 많은 렌더 호출 방지. delay_ms 후 실제 렌더. 그 사이 추가 호출이 오면 취소하고 재스케줄. """ if self._render_after_id is not None: try: self.after_cancel(self._render_after_id) except Exception: pass self._render_after_id = self.after(delay_ms, self._live_render) def _live_render(self): """카메라만 갱신하여 빠르게 재렌더.""" self._render_after_id = None if self._live_plotter is None or not self.current_meshes: return if self._render_in_progress: # 이미 렌더링 중이면 끝난 후 다시 호출되도록 스케줄 self._schedule_live_render(50) return self._render_in_progress = True try: cam_pos, focal, up = self._compute_camera() self._live_plotter.camera_position = [cam_pos, focal, up] img_arr = self._live_plotter.screenshot( return_img=True, window_size=(self._live_size, self._live_size), ) img = Image.fromarray(img_arr) self.current_capture = img self._show_in_preview(img) except Exception as e: self.log(f" 실시간 렌더 오류: {e}") finally: self._render_in_progress = False def _on_close(self): """앱 종료 시 plotter 정리.""" if self._live_plotter is not None: try: self._live_plotter.close() except Exception: pass self.destroy() def _capture_3d(self, size=1024) -> Image.Image: """현재 설정으로 3D 캡처.""" if not self.current_meshes: return Image.new("RGB", (size, size), (50, 50, 50)) plotter = pv.Plotter(off_screen=True, window_size=(size, size)) plotter.set_background("#C8D4E0") for mesh, color, opacity in self.current_meshes: try: plotter.add_mesh( mesh, color=color, opacity=opacity, smooth_shading=True, specular=0.3, specular_power=10, ) except Exception: pass # 카메라 설정 cam_pos, focal, up = self._compute_camera() plotter.camera_position = [cam_pos, focal, up] plotter.enable_3_lights() img_arr = plotter.screenshot(return_img=True, window_size=(size, size)) plotter.close() return Image.fromarray(img_arr) def _compute_camera(self): """모든 메쉬의 경계를 포함하는 카메라 계산.""" all_pts = [] for mesh, _, _ in self.current_meshes: all_pts.append(np.array(mesh.bounds).reshape(3, 2)) if not all_pts: return (50, 50, 50), (0, 0, 0), (0, 0, 1) bounds = np.array(all_pts) mins = bounds[:, :, 0].min(axis=0) maxs = bounds[:, :, 1].max(axis=0) center = (mins + maxs) / 2 diag = float(np.linalg.norm(maxs - mins)) dist = diag * self.zoom_var.get() el_rad = math.radians(self.elevation_var.get()) az_rad = math.radians(self.azimuth_var.get()) dx = dist * math.cos(el_rad) * math.sin(az_rad) dy = -dist * math.cos(el_rad) * math.cos(az_rad) dz = dist * math.sin(el_rad) cam_pos = (float(center[0] + dx), float(center[1] + dy), float(center[2] + dz)) focal = tuple(float(v) for v in center) up = (0, 0, 1) return cam_pos, focal, up def _on_interactive(self): if not self.current_meshes: messagebox.showwarning("주의", "먼저 3D 모델을 빌드하세요.") return self.log(" 인터랙티브 3D 뷰어 열기 (q로 종료)...") def _run(): plotter = pv.Plotter(title="EG-VIEW Structure: Interactive 3D") plotter.set_background("#2B3A4A") for mesh, color, opacity in self.current_meshes: try: plotter.add_mesh( mesh, color=color, opacity=opacity, smooth_shading=True, ) except Exception: pass cam_pos, focal, up = self._compute_camera() plotter.camera_position = [cam_pos, focal, up] plotter.enable_3_lights() plotter.show_grid(color="#555") plotter.add_axes() plotter.show() threading.Thread(target=_run, daemon=True).start() # ---------------------------------------------------------------------- # Step 5: 저장 / AI 렌더 # ---------------------------------------------------------------------- def _build_step5_export(self, parent): frame = self._section_frame(parent, "Step 5: 저장 / AI 렌더") # Vertex AI 사용 체크박스 vx_row = ctk.CTkFrame(frame, fg_color="transparent") vx_row.pack(fill="x", padx=10, pady=(5, 0)) ctk.CTkCheckBox( vx_row, text="Vertex AI 사용 (GCP Project)", variable=self.use_vertex_var, command=self._on_toggle_vertex, font=ctk.CTkFont(size=11), ).pack(side="left") # API Key / GCP Project ID self._api_label = ctk.CTkLabel(frame, text="GCP Project ID (Vertex)" if self.use_vertex_var.get() else "Gemini API Key", font=ctk.CTkFont(size=11)) self._api_label.pack(fill="x", padx=10, pady=(5, 0), anchor="w") self._api_entry = ctk.CTkEntry( frame, textvariable=self.api_key_var, placeholder_text="예: my-project-12345" if self.use_vertex_var.get() else "aistudio.google.com", show="" if self.use_vertex_var.get() else "*", ) self._api_entry.pack(fill="x", padx=10, pady=(0, 3)) # Vertex AI Location self._loc_entry = ctk.CTkEntry( frame, textvariable=self.vertex_location_var, placeholder_text="Vertex AI location (us-central1)", ) self._loc_entry.pack(fill="x", padx=10, pady=(0, 5)) if not self.use_vertex_var.get(): self._loc_entry.pack_forget() btn_row1 = ctk.CTkFrame(frame, fg_color="transparent") btn_row1.pack(fill="x", padx=10, pady=(5, 2)) ctk.CTkButton( btn_row1, text="캡처 PNG 저장", width=180, command=self._on_save_capture, ).pack(side="left", padx=2) ctk.CTkButton( btn_row1, text="파라미터 JSON 저장", width=180, fg_color="transparent", border_width=1, command=self._on_save_params, ).pack(side="left", padx=2) btn_row2 = ctk.CTkFrame(frame, fg_color="transparent") btn_row2.pack(fill="x", padx=10, pady=(2, 2)) ctk.CTkButton( btn_row2, text="AI 렌더링 실행", width=180, fg_color="#27AE60", hover_color="#1E8449", command=self._on_ai_render, ).pack(side="left", padx=2) ctk.CTkButton( btn_row2, text="AI 결과 저장", width=180, fg_color="transparent", border_width=1, command=self._on_save_ai, ).pack(side="left", padx=2) btn_row3 = ctk.CTkFrame(frame, fg_color="transparent") btn_row3.pack(fill="x", padx=10, pady=(2, 10)) ctk.CTkButton( btn_row3, text="전체 내보내기 (ZIP)", width=360, command=self._on_export_all, ).pack(side="left", padx=2) def _on_save_capture(self): if self.current_capture is None: messagebox.showwarning("주의", "먼저 미리보기를 생성하세요.") return path = filedialog.asksaveasfilename( defaultextension=".png", filetypes=[("PNG", "*.png")], initialfile="structure_capture.png", ) if path: # 고해상도로 다시 캡처 high_res = self._capture_3d(size=1536) high_res.save(path) self.log(f" 캡처 저장: {path}") def _on_save_params(self): if not self.current_params: messagebox.showwarning("주의", "먼저 파싱을 실행하세요.") return # 최신 UI 값 반영 self.current_params.update(self._collect_params()) path = filedialog.asksaveasfilename( defaultextension=".json", filetypes=[("JSON", "*.json")], initialfile="structure_params.json", ) if path: data = { "template_id": self.current_params.template_id, "name": self.current_params.name, "params": self._json_safe_params(self.current_params.params), "source_files": self.current_params.source_files, } with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) self.log(f" 파라미터 저장: {path}") def _json_safe_params(self, params: dict) -> dict: """numpy/복잡 객체를 JSON 직렬화 가능 형태로 변환.""" out = {} for k, v in params.items(): if isinstance(v, (int, float, str, bool)) or v is None: out[k] = v elif isinstance(v, (list, tuple)): try: out[k] = [list(p) if isinstance(p, (tuple, list)) else p for p in v] except Exception: out[k] = str(v) elif isinstance(v, np.ndarray): out[k] = v.tolist() else: out[k] = str(v) return out def _on_toggle_vertex(self): """Vertex AI 체크박스 토글.""" use_vertex = self.use_vertex_var.get() if use_vertex: self._api_label.configure(text="GCP Project ID (Vertex)") self._api_entry.configure( placeholder_text="예: my-project-12345", show="") self._loc_entry.pack(fill="x", padx=10, pady=(0, 5)) else: self._api_label.configure(text="Gemini API Key") self._api_entry.configure( placeholder_text="aistudio.google.com", show="*") self._loc_entry.pack_forget() def _on_ai_render(self): if self.current_capture is None: messagebox.showwarning("주의", "먼저 미리보기를 생성하세요.") return credential = self.api_key_var.get().strip() use_vertex = self.use_vertex_var.get() location = self.vertex_location_var.get().strip() or "us-central1" if not credential: messagebox.showwarning( "주의", "GCP Project ID를 입력하세요." if use_vertex else "Gemini API Key를 입력하세요." ) return self.log(f">>> AI 렌더링 실행 ({'Vertex AI' if use_vertex else 'Gemini API'})...") def _run(): try: guide_img = self._capture_3d(size=1536) prompt = self._build_ai_prompt() self.log(f" 프롬프트: {prompt[:80]}...") rendered = self._call_gemini( guide_img, prompt, credential, use_vertex=use_vertex, location=location, ) if rendered is None: self.log(" AI 렌더링 실패") return self.ai_rendered = rendered self._show_in_preview(rendered) self.log(" AI 렌더링 완료") except Exception as e: self.log(f" AI 오류: {e}") import traceback traceback.print_exc() threading.Thread(target=_run, daemon=True).start() def _build_ai_prompt(self) -> str: tpl = self.current_template params = self.current_params time_desc = { "낮 (Daytime)": "bright daylight, clear blue sky, sharp shadows", "일몰 (Sunset)": "warm sunset light, orange golden glow", "흐림 (Overcast)": "soft overcast lighting, muted tones", }.get(self.time_var.get(), "bright daylight") type_desc = f"civil engineering structure: {tpl.name_ko} ({tpl.description})" if tpl else "civil engineering structure" return ( f"Photorealistic bird's eye aerial view of a {type_desc}, " f"{time_desc}, " f"maintain exact structural geometry, layout, and proportions from the input image, " f"preserve all element positions precisely, " f"professional architectural rendering, " f"8K ultra sharp detail, high dynamic range, " f"realistic concrete texture, steel details, " f"natural ground texture, realistic vegetation if any" ) def _call_gemini(self, img: Image.Image, prompt: str, credential: str, use_vertex: bool = False, location: str = "us-central1") -> Image.Image | None: """Gemini 이미지 생성 호출. Args: credential: API Key 또는 GCP Project ID use_vertex: True면 Vertex AI 클라이언트 사용 location: Vertex AI region """ try: from google import genai from google.genai import types as gtypes import io as _io if use_vertex: client = genai.Client( vertexai=True, project=credential, location=location, ) self.log(f" Vertex AI 클라이언트 (project={credential}, loc={location})") else: client = genai.Client(api_key=credential) buf = _io.BytesIO() img.save(buf, format="PNG") img_bytes = buf.getvalue() response = client.models.generate_content( model="gemini-2.5-flash-image", contents=[ prompt, gtypes.Part.from_bytes(data=img_bytes, mime_type="image/png"), ], config=gtypes.GenerateContentConfig( response_modalities=["IMAGE", "TEXT"], ), ) for part in response.candidates[0].content.parts: if hasattr(part, "inline_data") and part.inline_data: return Image.open(_io.BytesIO(part.inline_data.data)) return None except Exception as e: self.log(f" Gemini 오류: {e}") return None def _on_save_ai(self): if self.ai_rendered is None: messagebox.showwarning("주의", "AI 렌더링을 먼저 실행하세요.") return path = filedialog.asksaveasfilename( defaultextension=".png", filetypes=[("PNG", "*.png")], initialfile="ai_rendered.png", ) if path: self.ai_rendered.save(path) self.log(f" AI 렌더 저장: {path}") def _on_export_all(self): if not self.current_meshes or self.current_capture is None: messagebox.showwarning("주의", "먼저 3D 빌드와 미리보기를 완료하세요.") return out_dir = filedialog.askdirectory(title="내보내기 폴더 선택") if not out_dir: return out_path = Path(out_dir) / "structure_export" out_path.mkdir(exist_ok=True) # 캡처 (고해상도) capture = self._capture_3d(size=1536) capture.save(out_path / "capture_high.png") # 파라미터 JSON if self.current_params: self.current_params.update(self._collect_params()) data = { "template_id": self.current_params.template_id, "name": self.current_params.name, "params": self._json_safe_params(self.current_params.params), "source_files": self.current_params.source_files, } with open(out_path / "params.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) # 다각도 캡처 original_elev = self.elevation_var.get() original_azim = self.azimuth_var.get() views = [ ("top_down", 75, 180), ("bird_eye_ne", 35, 225), ("bird_eye_nw", 35, 135), ("elevation", 5, 180), ] for name, el, az in views: self.elevation_var.set(el) self.azimuth_var.set(az) img = self._capture_3d(size=1024) img.save(out_path / f"view_{name}.png") self.elevation_var.set(original_elev) self.azimuth_var.set(original_azim) # AI 결과도 if self.ai_rendered is not None: self.ai_rendered.save(out_path / "ai_rendered.png") self.log(f" 전체 내보내기 완료: {out_path}") messagebox.showinfo("완료", f"내보내기 완료:\n{out_path}") # ---------------------------------------------------------------------- # 유틸리티 # ---------------------------------------------------------------------- def _section_frame(self, parent, title: str) -> ctk.CTkFrame: """섹션 프레임 + 타이틀 라벨.""" container = ctk.CTkFrame(parent, corner_radius=8, fg_color="#2a2a2a") container.pack(fill="x", padx=15, pady=6) ctk.CTkLabel( container, text=title, font=ctk.CTkFont(size=13, weight="bold"), ).pack(padx=10, pady=(8, 0), anchor="w") return container def _show_in_preview(self, img: Image.Image): """이미지를 프리뷰 영역에 표시.""" # 프리뷰 영역 크기에 맞춰 리사이즈 self.update_idletasks() max_w = self.preview_label.winfo_width() - 20 max_h = self.preview_label.winfo_height() - 20 if max_w <= 1 or max_h <= 1: max_w, max_h = 800, 600 img_ratio = img.width / img.height area_ratio = max_w / max_h if img_ratio > area_ratio: new_w = max_w new_h = int(max_w / img_ratio) else: new_h = max_h new_w = int(max_h * img_ratio) new_w = max(new_w, 100) new_h = max(new_h, 100) resized = img.resize((new_w, new_h), Image.LANCZOS) ctk_img = ctk.CTkImage(light_image=resized, dark_image=resized, size=(new_w, new_h)) self.preview_label.configure(image=ctk_img, text="") self.preview_label.image = ctk_img # GC 방지 def log(self, msg: str): import datetime ts = datetime.datetime.now().strftime("[%H:%M:%S]") self.log_box.insert("end", f"{ts} {msg}\n") self.log_box.see("end") self.update_idletasks() # --------------------------------------------------------------------------- # 메인 # --------------------------------------------------------------------------- def main(): app = StructureUIApp() app.mainloop() if __name__ == "__main__": main()