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:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

View File

View File

@@ -0,0 +1,137 @@
"""로거 - SQLite DB + structlog 기반 작업 이력 추적."""
from __future__ import annotations
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
import structlog
from sqlalchemy import Column, DateTime, Float, Integer, String, Text, create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
# ──────────────────────────── ORM 모델 ────────────────────────────
class Base(DeclarativeBase):
pass
class JobRecord(Base):
"""조감도 생성 작업 1건의 이력 레코드."""
__tablename__ = "jobs"
id = Column(Integer, primary_key=True, autoincrement=True)
dxf_path = Column(String(512), nullable=False)
dxf_hash = Column(String(32))
timestamp = Column(DateTime, default=datetime.utcnow)
seed = Column(Integer)
prompt_version = Column(String(32))
prompt_hash = Column(String(32))
status = Column(String(16), default="pending") # pending / running / done / failed
output_path = Column(String(512))
quality_score = Column(Float)
error_message = Column(Text)
latency_ms = Column(Float)
# ──────────────────────────── DB 세션 ────────────────────────────
_engine = None
_SessionFactory = None
def init_db(db_path: str | Path = "cad_aerial_gen.db"):
global _engine, _SessionFactory
_engine = create_engine(f"sqlite:///{db_path}", echo=False)
Base.metadata.create_all(_engine)
_SessionFactory = sessionmaker(bind=_engine)
def get_db_session() -> Session:
if _SessionFactory is None:
init_db()
return _SessionFactory()
# ──────────────────────────── structlog 설정 ────────────────────────────
def setup_logging(log_file: Optional[Path] = None, level: str = "INFO"):
"""콘솔 + 파일 동시 로깅을 설정한다."""
handlers = [logging.StreamHandler(sys.stdout)]
if log_file:
log_file.parent.mkdir(parents=True, exist_ok=True)
handlers.append(logging.FileHandler(str(log_file), encoding="utf-8"))
logging.basicConfig(
format="%(message)s",
level=getattr(logging, level.upper(), logging.INFO),
handlers=handlers,
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
structlog.dev.ConsoleRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, level.upper(), logging.INFO)
),
logger_factory=structlog.PrintLoggerFactory(),
)
def get_logger(name: str = "cad_aerial_gen"):
return structlog.get_logger(name)
# ──────────────────────────── 작업 이력 헬퍼 ────────────────────────────
class JobLogger:
"""작업 이력 CRUD 래퍼."""
def create_job(self, db: Session, dxf_path: str, dxf_hash: str = "") -> JobRecord:
record = JobRecord(dxf_path=dxf_path, dxf_hash=dxf_hash, status="pending")
db.add(record)
db.commit()
db.refresh(record)
return record
def start_job(self, db: Session, job_id: int, seed: int, prompt_version: str, prompt_hash: str):
record = db.query(JobRecord).filter_by(id=job_id).first()
if record:
record.status = "running"
record.seed = seed
record.prompt_version = prompt_version
record.prompt_hash = prompt_hash
db.commit()
def complete_job(
self,
db: Session,
job_id: int,
output_path: str,
quality_score: float,
latency_ms: float,
):
record = db.query(JobRecord).filter_by(id=job_id).first()
if record:
record.status = "done"
record.output_path = output_path
record.quality_score = quality_score
record.latency_ms = latency_ms
db.commit()
def fail_job(self, db: Session, job_id: int, error: str):
record = db.query(JobRecord).filter_by(id=job_id).first()
if record:
record.status = "failed"
record.error_message = error
db.commit()
def list_jobs(self, db: Session, limit: int = 50):
return db.query(JobRecord).order_by(JobRecord.id.desc()).limit(limit).all()

View File

@@ -0,0 +1,58 @@
"""프롬프트 레지스트리 - 버전 관리 및 재현 가능성 보장."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional
import yaml
class PromptRegistry:
"""프롬프트 템플릿 버전을 관리하고 변경 이력을 추적한다."""
def __init__(self, templates_dir: Path):
self.templates_dir = templates_dir
def list_versions(self) -> List[str]:
"""사용 가능한 템플릿 버전 목록을 반환한다 (최신순)."""
yamls = sorted(self.templates_dir.glob("v*.yaml"), reverse=True)
return [p.stem for p in yamls]
def latest_version(self) -> Optional[str]:
versions = self.list_versions()
return versions[0] if versions else None
def load_template(self, version: str) -> Dict:
path = self.templates_dir / f"{version}.yaml"
if not path.exists():
raise FileNotFoundError(f"템플릿 버전 {version}이 없습니다.")
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
def compare(self, version_a: str, version_b: str) -> Dict:
"""두 버전의 차이점을 반환한다."""
a = self.load_template(version_a)
b = self.load_template(version_b)
diff = {}
all_keys = set(a) | set(b)
for key in all_keys:
va, vb = a.get(key), b.get(key)
if va != vb:
diff[key] = {"old": va, "new": vb}
return diff
def save_new_version(self, new_version: str, template: Dict) -> Path:
"""새 버전 템플릿을 저장한다."""
path = self.templates_dir / f"{new_version}.yaml"
if path.exists():
raise FileExistsError(f"버전 {new_version}이 이미 존재합니다.")
with open(path, "w", encoding="utf-8") as f:
yaml.dump(template, f, allow_unicode=True, default_flow_style=False)
return path
def get_version_for_hash(self, prompt_hash: str, db_session) -> Optional[str]:
"""프롬프트 해시로 사용된 버전을 역조회한다."""
from harness.logger import JobRecord
record = db_session.query(JobRecord).filter_by(prompt_hash=prompt_hash).first()
return record.prompt_version if record else None

View File

@@ -0,0 +1,98 @@
"""품질 검증기 - 생성된 이미지가 기준을 충족하는지 자동 검사한다."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import List
import numpy as np
try:
import cv2
from PIL import Image
except ImportError:
raise ImportError("opencv-python, Pillow이 필요합니다: pip install opencv-python Pillow")
@dataclass
class ValidationResult:
passed: bool
score: float # 0.0 ~ 1.0 종합 품질 점수
resolution_ok: bool
sharpness_ok: bool
color_diversity_ok: bool
messages: List[str]
@property
def summary(self) -> str:
status = "PASS" if self.passed else "FAIL"
return f"[{status}] score={self.score:.2f} | " + " | ".join(self.messages)
class QualityValidator:
"""이미지 품질을 자동으로 검증한다."""
def __init__(
self,
min_resolution: int = 2048,
sharpness_threshold: float = 100.0,
color_diversity_threshold: float = 0.15,
):
self.min_resolution = min_resolution
self.sharpness_threshold = sharpness_threshold
self.color_diversity_threshold = color_diversity_threshold
def validate(self, image_path: Path) -> ValidationResult:
if not image_path.exists():
return ValidationResult(
passed=False, score=0.0,
resolution_ok=False, sharpness_ok=False, color_diversity_ok=False,
messages=["파일이 존재하지 않음"],
)
img_pil = Image.open(image_path).convert("RGB")
img_cv = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
resolution_ok, res_msg = self._check_resolution(img_pil)
sharpness_ok, sharp_score, sharp_msg = self._check_sharpness(img_cv)
color_ok, color_msg = self._check_color_diversity(img_cv)
scores = [
1.0 if resolution_ok else 0.0,
min(sharp_score / (self.sharpness_threshold * 3), 1.0),
1.0 if color_ok else 0.3,
]
overall_score = float(np.mean(scores))
passed = resolution_ok and sharpness_ok and color_ok
return ValidationResult(
passed=passed,
score=overall_score,
resolution_ok=resolution_ok,
sharpness_ok=sharpness_ok,
color_diversity_ok=color_ok,
messages=[res_msg, sharp_msg, color_msg],
)
def _check_resolution(self, img: Image.Image):
w, h = img.size
ok = w >= self.min_resolution and h >= self.min_resolution
msg = f"해상도={w}x{h} {'OK' if ok else f'(최소 {self.min_resolution} 필요)'}"
return ok, msg
def _check_sharpness(self, img_cv):
gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
lap_var = cv2.Laplacian(gray, cv2.CV_64F).var()
ok = lap_var >= self.sharpness_threshold
msg = f"선명도={lap_var:.1f} {'OK' if ok else f'(임계값 {self.sharpness_threshold})'}"
return ok, float(lap_var), msg
def _check_color_diversity(self, img_cv):
"""색상 다양성 검사 - 단색 평면 출력을 탐지한다."""
hsv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2HSV)
s_channel = hsv[:, :, 1].astype(np.float32) / 255.0
mean_saturation = float(s_channel.mean())
ok = mean_saturation >= self.color_diversity_threshold
msg = f"색상다양성={mean_saturation:.3f} {'OK' if ok else f'(임계값 {self.color_diversity_threshold})'}"
return ok, msg

View File

@@ -0,0 +1,66 @@
"""Seed 관리자 - 작업별 Seed 고정 및 추적."""
from __future__ import annotations
import hashlib
import random
from typing import Optional
from sqlalchemy.orm import Session
from harness.logger import JobRecord, get_db_session
class SeedManager:
"""DXF 파일 해시 기반 결정론적 seed를 생성하고 이력을 관리한다."""
MAX_SEED = 2**32 - 1
def get_seed(
self,
file_hash: str,
fixed_seed: Optional[int] = None,
deterministic: bool = True,
) -> int:
"""
Args:
file_hash: DXF 파일의 SHA256 해시 앞 16자
fixed_seed: 사용자가 직접 지정한 seed (None이면 자동)
deterministic: True면 파일 해시 기반, False면 랜덤
"""
if fixed_seed is not None:
return int(fixed_seed) % (self.MAX_SEED + 1)
if deterministic:
return self._hash_to_seed(file_hash)
return random.randint(0, self.MAX_SEED)
def get_or_create_seed(
self,
db: Session,
job_id: int,
file_hash: str,
fixed_seed: Optional[int] = None,
deterministic: bool = True,
) -> int:
"""DB에서 기존 seed를 조회하거나 새로 생성한다."""
existing = db.query(JobRecord).filter_by(id=job_id).first()
if existing and existing.seed is not None:
return existing.seed
seed = self.get_seed(file_hash, fixed_seed, deterministic)
if existing:
existing.seed = seed
db.commit()
return seed
@staticmethod
def _hash_to_seed(file_hash: str) -> int:
"""파일 해시를 정수 seed로 변환한다."""
digest = hashlib.sha256(file_hash.encode()).digest()
return int.from_bytes(digest[:4], "big")
@staticmethod
def describe(seed: int) -> str:
return f"seed={seed} (0x{seed:08X})"

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1,5 @@
Transform this aerial/satellite terrain image into a high-quality photorealistic bird's-eye view rendering. IMPORTANT: Keep the EXACT same terrain layout, structures, roads, water bodies from the input image. Only enhance the visual quality - add realistic textures, lighting, vegetation detail, water reflections. Do NOT change the composition or add/remove any major features.
Style: oblique aerial perspective, 3/4 view showing terrain depth, bright daylight, clear blue sky, sharp shadows, vivid green vegetation, enhance the existing satellite terrain texture and details, maintain exact terrain shape, contours, and layout from the input image, preserve water bodies, roads, and structural positions precisely, do NOT add or remove any major landscape features, photorealistic architectural visualization, professional drone photography quality, 8K ultra sharp detail, high dynamic range, realistic vegetation depth and canopy textures, natural water reflections and surface detail
Negative: blurry, low quality, distorted, watermark, text, logo, cartoon, anime, illustration, painting, sketch, oversaturated, underexposed, noisy, artifacts, completely different scene, unrelated content, changed terrain layout, moved structures, wrong topology

221
_unused/install.cmd Normal file
View File

@@ -0,0 +1,221 @@
@echo off
setlocal enabledelayedexpansion
REM Claude Code Windows CMD Bootstrap Script
REM Installs Claude Code for environments where PowerShell is not available
REM Parse command line argument
set "TARGET=%~1"
if "!TARGET!"=="" set "TARGET=latest"
REM Validate target parameter
if /i "!TARGET!"=="stable" goto :target_valid
if /i "!TARGET!"=="latest" goto :target_valid
echo !TARGET! | findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul
if !ERRORLEVEL! equ 0 goto :target_valid
echo Usage: %0 [stable^|latest^|VERSION] >&2
echo Example: %0 1.0.58 >&2
exit /b 1
:target_valid
REM Check for 64-bit Windows
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid
if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid
if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid
echo Claude Code does not support 32-bit Windows. Please use a 64-bit version of Windows. >&2
exit /b 1
:arch_valid
REM Set constants
set "GCS_BUCKET=https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases"
set "DOWNLOAD_DIR=%USERPROFILE%\.claude\downloads"
REM Use native ARM64 binary on ARM64 Windows, x64 otherwise
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "PLATFORM=win32-arm64"
) else (
set "PLATFORM=win32-x64"
)
REM Create download directory
if not exist "!DOWNLOAD_DIR!" mkdir "!DOWNLOAD_DIR!"
REM Check for curl availability
curl --version >nul 2>&1
if !ERRORLEVEL! neq 0 (
echo curl is required but not available. Please install curl or use PowerShell installer. >&2
exit /b 1
)
REM Always download latest version (which has the most up-to-date installer)
call :download_file "!GCS_BUCKET!/latest" "!DOWNLOAD_DIR!\latest"
if !ERRORLEVEL! neq 0 (
echo Failed to get latest version >&2
exit /b 1
)
REM Read version from file
set /p VERSION=<"!DOWNLOAD_DIR!\latest"
del "!DOWNLOAD_DIR!\latest"
REM Download manifest
call :download_file "!GCS_BUCKET!/!VERSION!/manifest.json" "!DOWNLOAD_DIR!\manifest.json"
if !ERRORLEVEL! neq 0 (
echo Failed to get manifest >&2
exit /b 1
)
REM Extract checksum from manifest
call :parse_manifest "!DOWNLOAD_DIR!\manifest.json" "!PLATFORM!"
if !ERRORLEVEL! neq 0 (
echo Platform !PLATFORM! not found in manifest >&2
del "!DOWNLOAD_DIR!\manifest.json" 2>nul
exit /b 1
)
del "!DOWNLOAD_DIR!\manifest.json"
REM Download binary
set "BINARY_PATH=!DOWNLOAD_DIR!\claude-!VERSION!-!PLATFORM!.exe"
call :download_file "!GCS_BUCKET!/!VERSION!/!PLATFORM!/claude.exe" "!BINARY_PATH!"
if !ERRORLEVEL! neq 0 (
echo Failed to download binary >&2
if exist "!BINARY_PATH!" del "!BINARY_PATH!"
exit /b 1
)
REM Verify checksum
call :verify_checksum "!BINARY_PATH!" "!EXPECTED_CHECKSUM!"
if !ERRORLEVEL! neq 0 (
echo Checksum verification failed >&2
del "!BINARY_PATH!"
exit /b 1
)
REM Run claude install to set up launcher and shell integration
echo Setting up Claude Code...
"!BINARY_PATH!" install "!TARGET!"
set "INSTALL_RESULT=!ERRORLEVEL!"
REM Clean up downloaded file
REM Wait a moment for any file handles to be released
timeout /t 1 /nobreak >nul 2>&1
del /f "!BINARY_PATH!" >nul 2>&1
if exist "!BINARY_PATH!" (
echo Warning: Could not remove temporary file: !BINARY_PATH!
)
if !INSTALL_RESULT! neq 0 (
echo Installation failed >&2
exit /b 1
)
echo.
echo Installation complete^^!
echo.
exit /b 0
REM ============================================================================
REM SUBROUTINES
REM ============================================================================
:download_file
REM Downloads a file using curl
REM Args: %1=URL, %2=OutputPath
set "URL=%~1"
set "OUTPUT=%~2"
curl -fsSL "!URL!" -o "!OUTPUT!"
exit /b !ERRORLEVEL!
:parse_manifest
REM Parse JSON manifest to extract checksum for platform
REM Args: %1=ManifestPath, %2=Platform
set "MANIFEST_PATH=%~1"
set "PLATFORM_NAME=%~2"
set "EXPECTED_CHECKSUM="
REM Use findstr to find platform section, then look for checksum
set "FOUND_PLATFORM="
set "IN_PLATFORM_SECTION="
REM Read the manifest line by line
for /f "usebackq tokens=*" %%i in ("!MANIFEST_PATH!") do (
set "LINE=%%i"
REM Check if this line contains our platform
echo !LINE! | findstr /c:"\"%PLATFORM_NAME%\":" >nul
if !ERRORLEVEL! equ 0 (
set "IN_PLATFORM_SECTION=1"
)
REM If we're in the platform section, look for checksum
if defined IN_PLATFORM_SECTION (
echo !LINE! | findstr /c:"\"checksum\":" >nul
if !ERRORLEVEL! equ 0 (
REM Extract checksum value
for /f "tokens=2 delims=:" %%j in ("!LINE!") do (
set "CHECKSUM_PART=%%j"
REM Remove quotes, whitespace, and comma
set "CHECKSUM_PART=!CHECKSUM_PART: =!"
set "CHECKSUM_PART=!CHECKSUM_PART:"=!"
set "CHECKSUM_PART=!CHECKSUM_PART:,=!"
REM Check if it looks like a SHA256 (64 hex chars)
if not "!CHECKSUM_PART!"=="" (
call :check_length "!CHECKSUM_PART!" 64
if !ERRORLEVEL! equ 0 (
set "EXPECTED_CHECKSUM=!CHECKSUM_PART!"
exit /b 0
)
)
)
)
REM Check if we've left the platform section (closing brace)
echo !LINE! | findstr /c:"}" >nul
if !ERRORLEVEL! equ 0 set "IN_PLATFORM_SECTION="
)
)
if "!EXPECTED_CHECKSUM!"=="" exit /b 1
exit /b 0
:check_length
REM Check if string length equals expected length
REM Args: %1=String, %2=ExpectedLength
set "STR=%~1"
set "EXPECTED_LEN=%~2"
set "LEN=0"
:count_loop
if "!STR:~%LEN%,1!"=="" goto :count_done
set /a LEN+=1
goto :count_loop
:count_done
if %LEN%==%EXPECTED_LEN% exit /b 0
exit /b 1
:verify_checksum
REM Verify file checksum using certutil
REM Args: %1=FilePath, %2=ExpectedChecksum
set "FILE_PATH=%~1"
set "EXPECTED=%~2"
for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!FILE_PATH!" SHA256') do (
set "ACTUAL=%%i"
set "ACTUAL=!ACTUAL: =!"
if "!ACTUAL!"=="CertUtil:Thecommandcompletedsuccessfully." goto :verify_done
if "!ACTUAL!" neq "" (
if /i "!ACTUAL!"=="!EXPECTED!" (
exit /b 0
) else (
exit /b 1
)
)
)
:verify_done
exit /b 1

26
_unused/nano_banana2.py Normal file
View File

@@ -0,0 +1,26 @@
from google import genai
from google.genai import types as gtypes
from PIL import Image
import os
client = genai.Client(
vertexai=True,
project=os.environ["GCP_PROJECT_ID"],
location="global", # gemini-3.x 이미지 모델은 글로벌 전용
)
prompt = "여기에 프롬프트"
image = Image.open("input.png")
response = client.models.generate_content(
model="gemini-3-pro-image-preview", # Nano Banana 2
contents=[prompt, image],
config=gtypes.GenerateContentConfig(
response_modalities=["IMAGE"],
),
)
for part in response.candidates[0].content.parts:
if part.inline_data:
with open("output.png", "wb") as f:
f.write(part.inline_data.data)

View File

@@ -0,0 +1,52 @@
import ezdxf
import numpy as np
def analyze_dxf(filepath):
print(f"Analyzing: {filepath}")
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
points = []
# 1. LWPOLYLINE, POLYLINE
for entity in msp.query('LWPOLYLINE POLYLINE'):
elevation = 0
if hasattr(entity, 'dxf'):
elevation = entity.dxf.elevation if hasattr(entity.dxf, 'elevation') else 0
for p in entity.get_points():
if len(p) >= 3:
points.append((p[0], p[1], p[2]))
else:
points.append((p[0], p[1], elevation))
# 2. LINE
for entity in msp.query('LINE'):
points.append(entity.dxf.start)
points.append(entity.dxf.end)
if not points:
print("No points found!")
return
pts = np.array(points)
min_vals = np.min(pts, axis=0)
max_vals = np.max(pts, axis=0)
ranges = max_vals - min_vals
print("\n[Statistics]")
print(f"Total points: {len(pts)}")
print(f"X: {min_vals[0]:.2f} to {max_vals[0]:.2f} (Range: {ranges[0]:.2f})")
print(f"Y: {min_vals[1]:.2f} to {max_vals[1]:.2f} (Range: {ranges[1]:.2f})")
print(f"Z: {min_vals[2]:.2f} to {max_vals[2]:.2f} (Range: {ranges[2]:.2f})")
# Ratio
if ranges[0] > 0 and ranges[1] > 0:
xy_avg_range = (ranges[0] + ranges[1]) / 2
z_ratio = (ranges[2] / xy_avg_range) * 100
print(f"Z-Ratio to XY: {z_ratio:.4f}%")
if z_ratio < 0.1:
print("WARNING: Z-range is extremely small compared to XY. Vertical exaggeration is required.")
if __name__ == "__main__":
analyze_dxf('사연댐 전체계획 평면도_contour.dxf')

1080
_unused/structure_ui.py Normal file

File diff suppressed because it is too large Load Diff

400
_unused/test_gate_render.py Normal file
View 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 여수로 수문 설치도(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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB