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>
This commit is contained in:
400
_unused/test_gate_render.py
Normal file
400
_unused/test_gate_render.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""구조물 조감도 스탠드얼론 테스트 스크립트.
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user