"""구조물 조감도 스탠드얼론 테스트 스크립트. Gate_Sample DXF → 파라미터 추출 → 3D 모델 → 다각도 캡처 → AI 렌더링. 사용법: python test_gate_render.py python test_gate_render.py --interactive # 인터랙티브 3D 뷰어 python test_gate_render.py --ai # AI 렌더링 포함 (GEMINI_API_KEY 필요) """ from __future__ import annotations import argparse import os import sys import math from pathlib import Path import numpy as np import pyvista as pv from PIL import Image, ImageDraw, ImageFilter from spillway_parser import parse_spillway_dxf, SpillwayParams from spillway_3d_builder import SpillwayBuilder, COLORS # --------------------------------------------------------------------------- # 렌더링 유틸 # --------------------------------------------------------------------------- def add_all_meshes(plotter: pv.Plotter, meshes: list): """메쉬 리스트를 플로터에 추가.""" for mesh, color, opacity in meshes: try: plotter.add_mesh( mesh, color=color, opacity=opacity, smooth_shading=True, specular=0.3, specular_power=10, ) except Exception as e: print(f" mesh 추가 실패: {e}") def compute_camera_for_birdseye(params: SpillwayParams, elevation_deg: float = 35.0, azimuth_deg: float = 225.0, zoom: float = 1.2) -> tuple: """여수로 구조물을 프레임에 담는 카메라 위치 계산. elevation_deg: 앙각 (수평=0°, 수직 아래로=90°) azimuth_deg: 방위각 (북=0°, 동=90°, 남=180°, 서=270°) zoom: 값이 클수록 카메라가 멀어짐 (전체를 더 많이 담음) """ # 씬 전체 범위 (수면 + 구조물 + 하류 에이프런 포함) # 수면: Y ∈ [-40, 0.5], 구조물: Y ∈ [0, pier_length], 하류: Y ∈ [pier_length, pier_length+30] scene_y_min = -40.0 scene_y_max = params.pier_length + 30.0 scene_x_span = params.total_span scene_z_min = min(params.el_upstream_bed, params.el_downstream, params.el_gate_sill) - 1.0 scene_z_max = params.el_bridge_top + 6.0 # 권양기 지붕까지 # 씬의 중심 (focal point) — 구조물 위주 cx = scene_x_span / 2 cy = (0 + params.pier_length) / 2 # 구조물 중심 cz = (params.el_gate_sill + params.el_bridge_top) / 2 # 씬의 대각선 크기 scene_dx = scene_x_span * 1.2 # X 방향 여유 scene_dy = scene_y_max - scene_y_min scene_dz = scene_z_max - scene_z_min scene_diag = math.sqrt(scene_dx ** 2 + scene_dy ** 2 + scene_dz ** 2) # 카메라 거리: PyVista 기본 viewAngle ≈ 30° 가정, tan(15°) ≈ 0.268 # 프레임 꽉 채우려면 dist = (scene_diag/2) / tan(15°) ≈ scene_diag * 1.87 dist = scene_diag * 1.0 * zoom # 방위각/앙각 → 구면 좌표 el_rad = math.radians(elevation_deg) az_rad = math.radians(azimuth_deg) dx = dist * math.cos(el_rad) * math.sin(az_rad) dy = -dist * math.cos(el_rad) * math.cos(az_rad) # Y축 반전 (북→양) dz = dist * math.sin(el_rad) camera_pos = (cx + dx, cy + dy, cz + dz) focal_point = (cx, cy, cz) view_up = (0, 0, 1) return camera_pos, focal_point, view_up def capture_view(params: SpillwayParams, meshes: list, elevation_deg: float, azimuth_deg: float, size: int = 1536, bg_color: str = "#C8D4E0", zoom: float = 1.3) -> Image.Image: """지정한 각도에서 3D 씬을 캡처.""" plotter = pv.Plotter(off_screen=True, window_size=(size, size)) plotter.set_background(bg_color) add_all_meshes(plotter, meshes) # 카메라 설정 cam_pos, focal, up = compute_camera_for_birdseye( params, elevation_deg, azimuth_deg, zoom ) plotter.camera_position = [cam_pos, focal, up] # 조명 설정: 기본 헤드라이트 + 방향 조명 plotter.enable_3_lights() img_arr = plotter.screenshot(return_img=True, window_size=(size, size)) plotter.close() img = Image.fromarray(img_arr) return img def capture_depth(params: SpillwayParams, meshes: list, elevation_deg: float, azimuth_deg: float, size: int = 1536, zoom: float = 1.3) -> Image.Image: """depth map 캡처 (제어맵용).""" plotter = pv.Plotter(off_screen=True, window_size=(size, size)) plotter.set_background("black") for mesh, _, _ in meshes: plotter.add_mesh(mesh, color="white", smooth_shading=False) cam_pos, focal, up = compute_camera_for_birdseye(params, elevation_deg, azimuth_deg, zoom) plotter.camera_position = [cam_pos, focal, up] # show()로 렌더 파이프라인 초기화 후 depth 추출 plotter.show(auto_close=False) try: z_img = plotter.get_image_depth() except Exception: z_img = None plotter.close() if z_img is None: return Image.new("L", (size, size), 0) # NaN 처리 + 정규화 z_img = np.array(z_img, dtype=np.float32) z_finite = z_img[np.isfinite(z_img)] if len(z_finite) == 0: return Image.new("L", (size, size), 0) z_min, z_max = z_finite.min(), z_finite.max() if z_max - z_min < 1e-6: return Image.new("L", (size, size), 128) z_norm = (z_img - z_min) / (z_max - z_min) z_norm = np.where(np.isfinite(z_norm), z_norm, 1.0) # 가까울수록 밝게 (invert) z_norm = 1.0 - z_norm z_8bit = (z_norm * 255).astype(np.uint8) return Image.fromarray(z_8bit, "L") def capture_lineart(params: SpillwayParams, meshes: list, elevation_deg: float, azimuth_deg: float, size: int = 1536, zoom: float = 1.3) -> Image.Image: """라인아트 캡처 (흰 배경 + 검은 엣지).""" plotter = pv.Plotter(off_screen=True, window_size=(size, size)) plotter.set_background("white") for mesh, _, _ in meshes: plotter.add_mesh(mesh, color="white", show_edges=True, edge_color="black", line_width=1) cam_pos, focal, up = compute_camera_for_birdseye(params, elevation_deg, azimuth_deg, zoom) plotter.camera_position = [cam_pos, focal, up] img_arr = plotter.screenshot(return_img=True, window_size=(size, size)) plotter.close() return Image.fromarray(img_arr) def compose_guide_image(capture: Image.Image, depth: Image.Image, lineart: Image.Image) -> Image.Image: """캡처 + depth + lineart를 가이드 이미지로 합성.""" # 모두 동일 크기로 맞춤 base = capture.convert("RGB") d = depth.convert("RGB").resize(base.size) la = lineart.convert("RGB").resize(base.size) # 80% base + 20% depth, 그 위에 lineart 살짝 arr_base = np.array(base, dtype=np.float32) arr_depth = np.array(d, dtype=np.float32) arr_line = np.array(la, dtype=np.float32) blend = arr_base * 0.80 + arr_depth * 0.20 # 라인아트: 검은 픽셀만 선택해서 덧씌움 line_mask = (arr_line.mean(axis=2, keepdims=True) < 100).astype(np.float32) final = blend * (1 - line_mask * 0.4) + arr_line * (line_mask * 0.4) final = np.clip(final, 0, 255).astype(np.uint8) return Image.fromarray(final) # --------------------------------------------------------------------------- # 인터랙티브 뷰어 # --------------------------------------------------------------------------- def show_interactive(params: SpillwayParams, meshes: list): """PyVista 인터랙티브 뷰어. q로 종료.""" plotter = pv.Plotter(title="EG-VIEW Gate: Interactive Preview") plotter.set_background("#2B3A4A") add_all_meshes(plotter, meshes) # 카메라 초기 위치: bird's eye cam_pos, focal, up = compute_camera_for_birdseye(params, 35, 225, 1.2) plotter.camera_position = [cam_pos, focal, up] plotter.enable_3_lights() plotter.show_grid(color="#555") plotter.add_axes() plotter.add_text( params.summary().replace("\n", " "), font_size=10, color="white", position="upper_left", ) plotter.show() # --------------------------------------------------------------------------- # AI 렌더링 (Gemini) # --------------------------------------------------------------------------- def render_with_gemini(guide_img: Image.Image, prompt: str, api_key: str) -> Image.Image | None: """Gemini API로 AI 렌더링. 실패 시 None.""" try: from google import genai from google.genai import types import io as _io client = genai.Client(api_key=api_key) # 이미지를 PNG 바이트로 변환 buf = _io.BytesIO() guide_img.save(buf, format="PNG") img_bytes = buf.getvalue() response = client.models.generate_content( model="gemini-2.5-flash-image", contents=[ prompt, types.Part.from_bytes(data=img_bytes, mime_type="image/png"), ], ) # 응답에서 이미지 추출 for part in response.candidates[0].content.parts: if hasattr(part, "inline_data") and part.inline_data: img_data = part.inline_data.data return Image.open(_io.BytesIO(img_data)) print(" Gemini 응답에 이미지 없음") return None except Exception as e: print(f" Gemini 렌더링 오류: {e}") return None def build_gate_prompt(params: SpillwayParams, time_of_day: str = "daytime") -> str: """수문 구조물용 AI 프롬프트.""" return ( f"Photorealistic bird's eye view of a dam spillway gate facility, " f"{params.n_gates} radial (Tainter) gates each {params.gate_width:.0f}m wide by {params.gate_height:.0f}m tall, " f"ogee-profile concrete weir, service bridge on top, " f"hoist houses above each gate, {time_of_day}, " f"crystal clear water upstream, concrete apron downstream, " f"maintain exact structural geometry, layout, and proportions from the input image, " f"preserve gate positions and pier locations precisely, " f"professional architectural rendering, " f"8K ultra sharp detail, high dynamic range, " f"realistic concrete texture, steel gate panels, " f"bright daylight with sharp shadows, clear blue sky" ) # --------------------------------------------------------------------------- # 메인 # --------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser() ap.add_argument("--plan", default=None, help="Plan DXF (1/2)") ap.add_argument("--section", default=None, help="Section DXF (2/2)") ap.add_argument("--interactive", action="store_true", help="인터랙티브 3D 뷰어") ap.add_argument("--ai", action="store_true", help="AI 렌더링 (Gemini)") ap.add_argument("--output", default="gate_render_output", help="출력 디렉토리") ap.add_argument("--time", default="daytime", choices=["daytime", "sunset", "overcast"]) ap.add_argument("--size", type=int, default=1536, help="렌더 해상도") args = ap.parse_args() # 기본 샘플 경로 if args.plan is None: base = Path("Gate_Sample") args.plan = str(base / "12995740-M40-001 여수로 수문 설치도(1/2).dxf") args.section = str(base / "12995740-M40-002 여수로 수문 설치도(2/2).dxf") # 출력 디렉토리 out = Path(args.output) out.mkdir(exist_ok=True) # 1) 파라미터 추출 print("=" * 60) print("Step 1: DXF 파싱") print("=" * 60) params = parse_spillway_dxf(args.plan, args.section) print(params.summary()) # 2) 3D 모델 빌드 print("\n" + "=" * 60) print("Step 2: 3D 모델 빌드") print("=" * 60) builder = SpillwayBuilder(params) meshes = builder.build_all() print(f"{len(meshes)}개 메쉬 컴포넌트 생성") # 3) 인터랙티브 모드? if args.interactive: print("\n인터랙티브 뷰어 실행 중...") show_interactive(params, meshes) return # 4) 다각도 캡처 print("\n" + "=" * 60) print("Step 3: 다각도 캡처") print("=" * 60) views = [ ("top_down", 75, 180, 1.0), # 수직 상부 ("bird_eye_1", 35, 225, 1.2), # 조감도 (북동) ("bird_eye_2", 35, 135, 1.2), # 조감도 (북서) ("bird_eye_3", 25, 180, 1.3), # 조감도 (정면) ("elevation", 5, 180, 1.1), # 정면 입면 ] for name, elev, azim, zoom in views: print(f" [{name}] elev={elev}°, azim={azim}°") img = capture_view(params, meshes, elev, azim, size=args.size, zoom=zoom) img_path = out / f"capture_{name}.png" img.save(img_path) print(f" → {img_path}") # 5) 제어맵 추출 (bird_eye_1 기준) print("\n" + "=" * 60) print("Step 4: 제어맵 추출 (bird_eye_1)") print("=" * 60) main_elev, main_azim, main_zoom = 35, 225, 1.2 capture = capture_view(params, meshes, main_elev, main_azim, args.size, zoom=main_zoom) capture.save(out / "capture_main.png") print(f" capture_main.png") depth = capture_depth(params, meshes, main_elev, main_azim, args.size, zoom=main_zoom) depth.save(out / "depth_map.png") print(f" depth_map.png") lineart = capture_lineart(params, meshes, main_elev, main_azim, args.size, zoom=main_zoom) lineart.save(out / "lineart_map.png") print(f" lineart_map.png") guide = compose_guide_image(capture, depth, lineart) guide.save(out / "guide_composite.png") print(f" guide_composite.png") # 6) AI 렌더링 (선택적) if args.ai: api_key = os.environ.get("GEMINI_API_KEY", "") if not api_key: print("\n경고: GEMINI_API_KEY 환경변수 필요 (AI 렌더링 건너뜀)") else: print("\n" + "=" * 60) print("Step 5: AI 렌더링 (Gemini)") print("=" * 60) prompt = build_gate_prompt(params, args.time) print(f" 프롬프트: {prompt[:100]}...") ai_img = render_with_gemini(guide, prompt, api_key) if ai_img: ai_img.save(out / "ai_rendered.png") print(f" → ai_rendered.png") else: print(f" AI 렌더링 실패") print("\n" + "=" * 60) print(f"완료. 출력 디렉토리: {out.absolute()}") print("=" * 60) if __name__ == "__main__": main()