Files
s-canvas/_unused/test_gate_render.py
HYUNJUNGLEE b9342f6726 Import S-CANVAS source + iter=1~7 lint cleanup
S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진.
~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수).

이 커밋은 7-iter cleanup이 적용된 상태로 import:
- F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError
  (Py3.13에서 reproduce 확인된 진짜 버그)
- RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화
- F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization

신규 파일:
- ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화
- requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel)
- .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외

검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:29:08 +09:00

401 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물 조감도 스탠드얼론 테스트 스크립트.
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 여수로 수문 설치도(12).dxf")
args.section = str(base / "12995740-M40-002 여수로 수문 설치도(22).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()