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:
726
blender_renderer.py
Normal file
726
blender_renderer.py
Normal file
@@ -0,0 +1,726 @@
|
||||
"""Blender 헤드리스 기반 구조물 단독 렌더링 워커.
|
||||
|
||||
scanvas_maker.SCanvasApp 안의 구조물 빌드 다이얼로그(_open_structure_template_dialog)
|
||||
에서 호출되어, 사용자 파라미터로 빌드된 구조물을 Blender Cycles로 고품질 렌더한다.
|
||||
|
||||
워크플로 안에서의 위치:
|
||||
[A] DXF + 파라미터 → Blender 빌더 → Cycles 렌더 → 구조물 PNG ← 이 모듈
|
||||
↓
|
||||
[B] 별도 트랙: DEM/위성 → 지형 capture map
|
||||
↓
|
||||
[C] 합성: 지형 capture에 구조물 PNG 얹기 ← 추후 작업
|
||||
↓
|
||||
[D] AI 트랙: 합성 control map → Gemini → 최종 조감도
|
||||
|
||||
따라서:
|
||||
- 출력 파일명은 'structure_render.png' (AI 결과 'rendered_birdseye.png'를
|
||||
덮어쓰지 않음)
|
||||
- 결과 표시는 app._show_rendered_result()가 아니라 app._show_structure_render()
|
||||
로 분리 (없으면 OS 기본 뷰어 폴백, GUI 없는 환경에서는 단순 print)
|
||||
- transparent_bg=True 면 RGBA PNG → 추후 [C] 합성에 직접 사용 가능
|
||||
- transparent_bg=False 면 Sky 배경 → 단독 발표용 그림으로 사용
|
||||
|
||||
공개 API:
|
||||
run_blender_render(app, blender_exe, params_json, ...)
|
||||
|
||||
----------------------------------------------------------------------
|
||||
v3 (워크플로 분리):
|
||||
- 출력 기본값: rendered_birdseye.png → structure_render.png
|
||||
- app._show_rendered_result → app._show_structure_render (없으면 OS 폴백)
|
||||
- transparent_bg 인자 추가 → 빌더의 setup_lighting_and_camera/render_to_png
|
||||
호출 시 동일하게 전달
|
||||
- PIL 후처리 분기: 투명 PNG는 RGBA로 리사이즈 (RGB 변환하면 알파 손실)
|
||||
v2: tkinter / PIL 을 lazy import (GUI 없는 환경에서도 모듈 로드 가능)
|
||||
v1: 초기 버전
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
import contextlib
|
||||
|
||||
# tkinter / PIL 은 GUI 컨텍스트에서만 필요 → lazy import.
|
||||
# Harness 의존 (선택적 — 없으면 그냥 렌더만 진행)
|
||||
try:
|
||||
from harness.seed_manager import SeedManager
|
||||
from harness.logger import get_db_session
|
||||
_HARNESS_OK = True
|
||||
except Exception:
|
||||
SeedManager = None # type: ignore
|
||||
get_db_session = None # type: ignore
|
||||
_HARNESS_OK = False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Lazy import 헬퍼
|
||||
# ===========================================================================
|
||||
|
||||
def _show_error_dialog(title: str, msg: str) -> None:
|
||||
"""tkinter messagebox로 에러 다이얼로그. 없으면 stderr 폴백."""
|
||||
try:
|
||||
from tkinter import messagebox
|
||||
messagebox.showerror(title, msg)
|
||||
except Exception:
|
||||
sys.stderr.write(f"[ERROR] {title}: {msg}\n")
|
||||
|
||||
|
||||
def _open_image(path: str):
|
||||
"""PIL.Image.open 의 lazy wrapper."""
|
||||
from PIL import Image
|
||||
return Image.open(path)
|
||||
|
||||
|
||||
def _image_lanczos():
|
||||
"""PIL.Image.LANCZOS 상수 lazy 접근."""
|
||||
from PIL import Image
|
||||
return Image.LANCZOS
|
||||
|
||||
|
||||
def _open_in_os_default_viewer(path: str) -> bool:
|
||||
"""플랫폼별 기본 이미지 뷰어로 PNG 열기. 결과 표시 폴백용.
|
||||
|
||||
Returns: True if launched successfully (best-effort)
|
||||
"""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", path])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Blender 실행파일 자동 탐색
|
||||
# ===========================================================================
|
||||
|
||||
_BLENDER_SEARCH_PATHS = {
|
||||
"win32": [
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.5\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.4\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.3\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.2\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.1\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.0\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 3.6\blender.exe",
|
||||
],
|
||||
"darwin": [
|
||||
"/Applications/Blender.app/Contents/MacOS/Blender",
|
||||
],
|
||||
"linux": [
|
||||
"/usr/bin/blender",
|
||||
"/usr/local/bin/blender",
|
||||
"/snap/bin/blender",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def find_blender_executable(explicit: str | None = None) -> str | None:
|
||||
"""Blender 실행파일 위치 결정.
|
||||
|
||||
우선순위:
|
||||
1) 인자로 명시
|
||||
2) 환경변수 BLENDER_EXE
|
||||
3) PATH 안의 'blender' / 'blender.exe'
|
||||
4) 플랫폼별 표준 설치 경로 (win/mac/linux)
|
||||
5) Windows의 경우 'C:\\Program Files\\Blender Foundation\\*' glob
|
||||
|
||||
Returns: 절대 경로 (str) 또는 None
|
||||
"""
|
||||
if explicit and Path(explicit).is_file():
|
||||
return str(Path(explicit).resolve())
|
||||
|
||||
env_exe = os.environ.get("BLENDER_EXE")
|
||||
if env_exe and Path(env_exe).is_file():
|
||||
return str(Path(env_exe).resolve())
|
||||
|
||||
which = shutil.which("blender") or shutil.which("blender.exe")
|
||||
if which:
|
||||
return str(Path(which).resolve())
|
||||
|
||||
candidates = _BLENDER_SEARCH_PATHS.get(sys.platform, [])
|
||||
for c in candidates:
|
||||
if Path(c).is_file():
|
||||
return str(Path(c).resolve())
|
||||
|
||||
if sys.platform == "win32":
|
||||
prog_files = Path(r"C:\Program Files\Blender Foundation")
|
||||
if prog_files.is_dir():
|
||||
for sub in sorted(prog_files.iterdir(), reverse=True):
|
||||
exe = sub / "blender.exe"
|
||||
if exe.is_file():
|
||||
return str(exe.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 구조물 종류 자동 감지 + 빌더 라우팅
|
||||
# ===========================================================================
|
||||
|
||||
_STRUCTURE_REGISTRY = {
|
||||
"gate": {
|
||||
"module": "gate_3d_builder_bpy",
|
||||
"klass": "GateBuilderBpy",
|
||||
"marker": "n_gates",
|
||||
},
|
||||
"intake_tower": {
|
||||
"module": "intake_tower_3d_builder_bpy",
|
||||
"klass": "IntakeTowerBuilderBpy",
|
||||
"marker": "body_top_el",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_structure_kind(params_json_path: str) -> str | None:
|
||||
"""JSON 안의 marker 필드를 보고 구조물 종류 판별."""
|
||||
try:
|
||||
with open(params_json_path, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
|
||||
for kind, spec in _STRUCTURE_REGISTRY.items():
|
||||
marker = spec["marker"]
|
||||
if marker in d and isinstance(d[marker], (int, float)):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 동적 wrapper script 생성 (Cycles seed + transparent_bg 주입)
|
||||
# ===========================================================================
|
||||
|
||||
def _generate_blender_runner(
|
||||
*,
|
||||
s_canvas_dir: str,
|
||||
structure_kind: str,
|
||||
params_json: str,
|
||||
output_path: str,
|
||||
seed: int,
|
||||
samples: int,
|
||||
engine: str,
|
||||
time_preset: str,
|
||||
width: int,
|
||||
height: int,
|
||||
transparent_bg: bool = False,
|
||||
blend_path: str | None = None,
|
||||
glb_path: str | None = None,
|
||||
) -> str:
|
||||
"""헤드리스 Blender에 줄 임시 Python 스크립트.
|
||||
|
||||
Cycles seed + transparent_bg 모두 빌더 외부에서 주입.
|
||||
빌더 파일은 immutable 유지.
|
||||
"""
|
||||
spec = _STRUCTURE_REGISTRY[structure_kind]
|
||||
module = spec["module"]
|
||||
klass = spec["klass"]
|
||||
|
||||
src = textwrap.dedent(f"""
|
||||
# Auto-generated by blender_renderer.py — do not edit.
|
||||
import sys, json, traceback
|
||||
from pathlib import Path
|
||||
|
||||
SCANVAS_DIR = {s_canvas_dir!r}
|
||||
if SCANVAS_DIR not in sys.path:
|
||||
sys.path.insert(0, SCANVAS_DIR)
|
||||
|
||||
try:
|
||||
import bpy
|
||||
import {module} as B
|
||||
|
||||
# 1) Params 로드
|
||||
params_dict = json.loads(Path({params_json!r}).read_text(encoding="utf-8"))
|
||||
params = B.params_from_dict(params_dict)
|
||||
|
||||
# 2) 3D scene 빌드
|
||||
builder = B.{klass}(params, clear_scene=True)
|
||||
builder.build_all()
|
||||
print(f"[blender_renderer] objects={{builder._object_count}}")
|
||||
|
||||
# 3) 카메라 + 조명 (transparent_bg 옵션 포함)
|
||||
B.setup_lighting_and_camera(
|
||||
params,
|
||||
time_preset={time_preset!r},
|
||||
transparent_bg={transparent_bg!r},
|
||||
)
|
||||
|
||||
# 4) Cycles seed (결정론적) — 빌더 외부에서 주입
|
||||
scene = bpy.context.scene
|
||||
if {engine!r} == "CYCLES":
|
||||
scene.cycles.seed = {seed}
|
||||
print(f"[blender_renderer] cycles.seed={{scene.cycles.seed}}")
|
||||
|
||||
# 5) (옵션) .blend / .glb 부수 출력
|
||||
blend_path = {blend_path!r}
|
||||
glb_path = {glb_path!r}
|
||||
if blend_path:
|
||||
B.save_blend(blend_path)
|
||||
print(f"[blender_renderer] saved .blend -> {{blend_path}}")
|
||||
if glb_path:
|
||||
B.export_glb(glb_path)
|
||||
print(f"[blender_renderer] saved .glb -> {{glb_path}}")
|
||||
|
||||
# 6) PNG 렌더 (transparent_bg 전달)
|
||||
B.render_to_png(
|
||||
{output_path!r},
|
||||
resolution=({width}, {height}),
|
||||
samples={samples},
|
||||
engine={engine!r},
|
||||
transparent_bg={transparent_bg!r},
|
||||
)
|
||||
print(f"[blender_renderer] OK -> {output_path}")
|
||||
|
||||
except Exception as e:
|
||||
print("[blender_renderer] FAIL:", e)
|
||||
traceback.print_exc()
|
||||
sys.exit(2)
|
||||
""")
|
||||
return src
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# subprocess 실행 + stdout 스트리밍
|
||||
# ===========================================================================
|
||||
|
||||
def _run_blender_subprocess(
|
||||
blender_exe: str,
|
||||
runner_script: str,
|
||||
*,
|
||||
log_callback,
|
||||
timeout_sec: int = 1800,
|
||||
) -> tuple[int, str]:
|
||||
"""Blender를 subprocess로 호출. stdout 줄 단위로 log_callback에 전달."""
|
||||
cmd = [blender_exe, "--background", "--python", runner_script]
|
||||
log_callback(f" $ {Path(blender_exe).name} --background --python <runner.py>")
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
return -1, f"Blender 실행 실패: {e}"
|
||||
|
||||
captured: list[str] = []
|
||||
t_start = _time.time()
|
||||
try:
|
||||
for raw_line in proc.stdout: # type: ignore
|
||||
line = raw_line.rstrip()
|
||||
captured.append(line)
|
||||
if any(tag in line for tag in
|
||||
("[blender_renderer]", "[bpy-gate]", "[bpy-builder]",
|
||||
"Saved:", "Render finished")):
|
||||
log_callback(f" {line}")
|
||||
if _time.time() - t_start > timeout_sec:
|
||||
proc.kill()
|
||||
captured.append(f"[timeout] {timeout_sec}초 초과")
|
||||
log_callback(f" ⚠ {timeout_sec}초 초과 — Blender 강제 종료")
|
||||
break
|
||||
proc.wait(timeout=10)
|
||||
except Exception as e:
|
||||
proc.kill()
|
||||
captured.append(f"[exception] {e}")
|
||||
|
||||
return proc.returncode, "\n".join(captured)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 메인 진입점
|
||||
# ===========================================================================
|
||||
|
||||
def run_blender_render(
|
||||
app,
|
||||
blender_exe: str | None,
|
||||
params_json: str,
|
||||
*,
|
||||
time_preset: str = "daytime",
|
||||
engine: str = "CYCLES",
|
||||
samples: int = 128,
|
||||
output_path: str = "structure_render.png",
|
||||
transparent_bg: bool = False,
|
||||
save_blend: bool = False,
|
||||
save_glb: bool = False,
|
||||
structure_kind: str | None = None,
|
||||
timeout_sec: int = 1800,
|
||||
) -> None:
|
||||
"""구조물 단독을 Blender Cycles로 렌더 + Harness 통합.
|
||||
|
||||
AI 워크플로(Step 4)와 **별개의 트랙**입니다. 결과 PNG는 추후 지형 합성([C])
|
||||
의 입력으로 사용되거나 단독 발표용으로 사용됩니다.
|
||||
|
||||
Args:
|
||||
app: scanvas_maker.SCanvasApp (상태/로그/UI scheduling 접근)
|
||||
blender_exe: Blender 실행파일 경로. None이면 자동 탐색.
|
||||
params_json: GateParams 또는 IntakeTowerParams JSON 경로.
|
||||
time_preset: 'daytime' | 'sunset' | 'overcast'
|
||||
engine: 'CYCLES' (사실적) | 'BLENDER_EEVEE_NEXT' (빠른 미리보기)
|
||||
samples: Cycles 샘플 수 (CYCLES 외 엔진은 무시)
|
||||
output_path: 결과 PNG. 기본 'structure_render.png' (AI 결과와 분리)
|
||||
transparent_bg: True면 RGBA + 투명 배경 (지형 합성 입력용)
|
||||
save_blend: True면 .blend 추가 저장
|
||||
save_glb: True면 .glb 추가 저장 (VR/AR/외부 뷰어용)
|
||||
structure_kind: 'gate' | 'intake_tower' | None (auto-detect)
|
||||
timeout_sec: subprocess 강제 종료 임계 (기본 30분)
|
||||
|
||||
UI / harness 통합 동작:
|
||||
- app.log(...) 로 진행 상황 출력
|
||||
- app.set_status(...) 로 상태바 갱신
|
||||
- SeedManager로 결정론적 seed 산출 → Cycles seed 주입
|
||||
- QualityValidator로 결과 검증
|
||||
- JobLogger로 SQLite 이력 기록
|
||||
- 완료 후 app._show_structure_render(output_path) 호출
|
||||
(없으면 OS 기본 뷰어로 폴백)
|
||||
"""
|
||||
t_start = _time.time()
|
||||
job_id = None
|
||||
db = None
|
||||
runner_path: Path | None = None
|
||||
|
||||
# ── 0) 사전 점검 ────────────────────────────────────────────────────
|
||||
blender_exe = find_blender_executable(blender_exe)
|
||||
if not blender_exe:
|
||||
msg = (
|
||||
"Blender 실행파일을 찾을 수 없습니다.\n\n"
|
||||
"다음 중 하나로 해결:\n"
|
||||
" 1) 환경변수 BLENDER_EXE 설정 (절대경로)\n"
|
||||
" 2) 표준 위치에 설치 (Windows: "
|
||||
"C:\\Program Files\\Blender Foundation\\Blender X.X\\blender.exe)\n"
|
||||
" 3) PATH에 'blender' 추가\n\n"
|
||||
"다운로드: https://www.blender.org/download/"
|
||||
)
|
||||
app.after(0, lambda: app.log(f" ✗ {msg.splitlines()[0]}"))
|
||||
app.after(0, lambda m=msg: _show_error_dialog("Blender 없음", m))
|
||||
app.after(0, lambda: app.set_status("Blender 실행파일 없음", "#E74C3C"))
|
||||
return
|
||||
|
||||
if not Path(params_json).is_file():
|
||||
app.after(0, lambda p=params_json: app.log(f" ✗ params_json 없음: {p}"))
|
||||
app.after(0, lambda p=params_json: _show_error_dialog("파일 없음",
|
||||
f"파라미터 JSON 파일을 찾을 수 없습니다:\n{p}\n\n"
|
||||
"구조물 상세 3D 빌드 단계에서 먼저 JSON을 생성해야 합니다."))
|
||||
app.after(0, lambda: app.set_status("params.json 없음", "#E74C3C"))
|
||||
return
|
||||
|
||||
if structure_kind is None:
|
||||
structure_kind = detect_structure_kind(params_json)
|
||||
if structure_kind not in _STRUCTURE_REGISTRY:
|
||||
app.after(0, lambda k=structure_kind: app.log(
|
||||
f" ✗ 구조물 종류 인식 실패 (kind={k!r})"))
|
||||
kinds = ', '.join(_STRUCTURE_REGISTRY.keys())
|
||||
app.after(0, lambda p=params_json, ks=kinds: _show_error_dialog(
|
||||
"구조물 인식 실패",
|
||||
f"JSON에서 구조물 종류를 자동 감지할 수 없습니다.\n"
|
||||
f"params_json: {p}\n"
|
||||
f"지원 종류: {ks}"))
|
||||
return
|
||||
|
||||
s_canvas_dir = str(Path(params_json).resolve().parent)
|
||||
builder_module = _STRUCTURE_REGISTRY[structure_kind]["module"]
|
||||
builder_path = Path(s_canvas_dir) / f"{builder_module}.py"
|
||||
if not builder_path.is_file():
|
||||
app.after(0, lambda bp=builder_path: app.log(f" ✗ 빌더 모듈 없음: {bp}"))
|
||||
app.after(0, lambda bm=builder_module, bp=builder_path: _show_error_dialog(
|
||||
"빌더 모듈 없음",
|
||||
f"{bm}.py 가 다음 위치에 없습니다:\n{bp}"))
|
||||
return
|
||||
|
||||
# ── 1) Harness — Job 시작 ────────────────────────────────────────────
|
||||
dxf_hash = ""
|
||||
prompt_hash = ""
|
||||
prompt_ver = "blender_v1"
|
||||
seed = 0
|
||||
|
||||
try:
|
||||
dxf_hash = app._get_dxf_hash()
|
||||
except Exception:
|
||||
try:
|
||||
import hashlib
|
||||
with open(params_json, "rb") as f:
|
||||
dxf_hash = hashlib.sha256(f.read()).hexdigest()[:16]
|
||||
except Exception:
|
||||
dxf_hash = ""
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
prompt_hash = app._get_prompt_hash(
|
||||
f"engine={engine}|time={time_preset}|samples={samples}|"
|
||||
f"transparent={transparent_bg}|kind={structure_kind}"
|
||||
)
|
||||
|
||||
if app.job_logger and _HARNESS_OK:
|
||||
try:
|
||||
db = get_db_session()
|
||||
job = app.job_logger.create_job(db, app.dxf_path or params_json, dxf_hash)
|
||||
job_id = job.id
|
||||
seed = app.seed_mgr.get_or_create_seed(db, job_id, dxf_hash)
|
||||
if app.prompt_reg:
|
||||
prompt_ver = app.prompt_reg.latest_version() or "blender_v1"
|
||||
app.job_logger.start_job(db, job_id, seed, prompt_ver, prompt_hash)
|
||||
app.after(0, lambda jid=job_id, s=seed: app.log(
|
||||
f" Harness: job#{jid}, {SeedManager.describe(s)}, "
|
||||
f"engine={engine}, samples={samples}, time={time_preset}, "
|
||||
f"bg={'transparent' if transparent_bg else 'sky'}"))
|
||||
except Exception as e:
|
||||
app.after(0, lambda em=str(e): app.log(f" Harness 초기화 경고: {em}"))
|
||||
|
||||
# ── 2) Wrapper script + subprocess 실행 ──────────────────────────────
|
||||
try:
|
||||
# 출력 해상도 — app.target_resolution 우선, 없으면 1920×1080
|
||||
# (구조물 단독 렌더는 AI 입력 해상도와 다를 수 있어 별도 속성도 검사)
|
||||
struct_tgt = getattr(app, "structure_render_resolution", None)
|
||||
if struct_tgt and struct_tgt[0] > 0 and struct_tgt[1] > 0:
|
||||
width, height = int(struct_tgt[0]), int(struct_tgt[1])
|
||||
else:
|
||||
tgt = getattr(app, "target_resolution", None)
|
||||
if tgt and tgt[0] > 0 and tgt[1] > 0:
|
||||
width, height = int(tgt[0]), int(tgt[1])
|
||||
else:
|
||||
width, height = 1920, 1080
|
||||
|
||||
out_abs = str(Path(output_path).resolve())
|
||||
blend_path = (Path(out_abs).with_suffix(".blend").as_posix()
|
||||
if save_blend else None)
|
||||
glb_path = (Path(out_abs).with_suffix(".glb").as_posix()
|
||||
if save_glb else None)
|
||||
|
||||
runner_src = _generate_blender_runner(
|
||||
s_canvas_dir=s_canvas_dir,
|
||||
structure_kind=structure_kind,
|
||||
params_json=str(Path(params_json).resolve()),
|
||||
output_path=out_abs,
|
||||
seed=seed,
|
||||
samples=samples,
|
||||
engine=engine,
|
||||
time_preset=time_preset,
|
||||
width=width,
|
||||
height=height,
|
||||
transparent_bg=transparent_bg,
|
||||
blend_path=blend_path,
|
||||
glb_path=glb_path,
|
||||
)
|
||||
|
||||
tmp_dir = tempfile.gettempdir()
|
||||
runner_path = Path(tmp_dir) / f"scanvas_blender_runner_{os.getpid()}.py"
|
||||
runner_path.write_text(runner_src, encoding="utf-8")
|
||||
|
||||
bg_label = "투명배경" if transparent_bg else "Sky 배경"
|
||||
app.after(0, lambda be=blender_exe, sk=structure_kind, w=width, h=height,
|
||||
s=seed, bl=bg_label: app.log(
|
||||
f" Blender 실행 중... ({Path(be).name}, {sk}, {w}×{h}, "
|
||||
f"seed={s}, {bl})"))
|
||||
app.after(0, lambda: app.set_status("Blender 렌더링 중...", "#3498DB"))
|
||||
|
||||
def _ui_log(msg: str):
|
||||
app.after(0, lambda m=msg: app.log(m))
|
||||
|
||||
rc, full_log = _run_blender_subprocess(
|
||||
blender_exe, str(runner_path),
|
||||
log_callback=_ui_log,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
if rc != 0:
|
||||
tail = "\n".join(full_log.splitlines()[-30:])
|
||||
err_msg = f"Blender 종료 코드 {rc}.\n\n마지막 출력:\n{tail}"
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.fail_job(db, job_id, f"rc={rc}")
|
||||
app.after(0, lambda r=rc: app.log(f" ✗ Blender 실패 (rc={r})"))
|
||||
app.after(0, lambda em=err_msg: _show_error_dialog("Blender 오류", em))
|
||||
app.after(0, lambda: app.set_status("Blender 렌더링 실패", "#E74C3C"))
|
||||
return
|
||||
|
||||
if not Path(out_abs).is_file():
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.fail_job(db, job_id, "출력 PNG 없음")
|
||||
app.after(0, lambda o=out_abs: app.log(f" ✗ 출력 파일이 생성되지 않았습니다: {o}"))
|
||||
app.after(0, lambda: app.set_status("출력 파일 없음", "#E74C3C"))
|
||||
return
|
||||
|
||||
# ── 3) 출력 후처리 (해상도 강제 시) ─────────────────────────────
|
||||
rendered = _open_image(out_abs)
|
||||
|
||||
# 투명 PNG는 RGBA 유지 (RGB 변환하면 알파 손실)
|
||||
if transparent_bg and rendered.mode != "RGBA":
|
||||
rendered = rendered.convert("RGBA")
|
||||
|
||||
# 사용자가 명시적으로 다른 해상도를 요청한 경우만 리사이즈
|
||||
# (struct_tgt 기준; 일반적으론 빌더가 만든 그대로 사용)
|
||||
target_size = (width, height)
|
||||
if rendered.size != target_size:
|
||||
src_size = rendered.size
|
||||
app.after(0, lambda s=src_size, t=target_size: app.log(
|
||||
f" 화질 리사이즈: {s[0]}x{s[1]} → {t[0]}x{t[1]}"))
|
||||
rendered = rendered.resize(target_size, _image_lanczos())
|
||||
rendered.save(out_abs)
|
||||
|
||||
latency_ms = (_time.time() - t_start) * 1000
|
||||
|
||||
# ── 4) 품질 검증 ────────────────────────────────────────────────
|
||||
quality_score = 0.0
|
||||
if app.quality_val:
|
||||
try:
|
||||
vr = app.quality_val.validate(Path(out_abs))
|
||||
quality_score = vr.score
|
||||
app.after(0, lambda s=vr.summary: app.log(f" 품질검증: {s}"))
|
||||
except Exception as e:
|
||||
app.after(0, lambda em=str(e): app.log(f" 품질검증 오류: {em}"))
|
||||
|
||||
# ── 5) Job 완료 ─────────────────────────────────────────────────
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.complete_job(
|
||||
db, job_id, out_abs, quality_score, latency_ms
|
||||
)
|
||||
|
||||
app.after(0, lambda o=out_abs, sz=rendered.size, lm=latency_ms,
|
||||
q=quality_score, s=seed: app.log(
|
||||
f" Blender 렌더링 완료! → {o} ({sz}) "
|
||||
f"[{lm:.0f}ms, 품질={q:.2f}, seed={s}]"))
|
||||
app.after(0, lambda: app.set_status("Blender 렌더링 완료", "#2ECC71"))
|
||||
|
||||
# ── 6) 결과 표시 — AI 워크플로와 분리 ────────────────────────────
|
||||
# 우선 app._show_structure_render(path) 시도. 없으면 OS 기본 뷰어 폴백.
|
||||
def _present_result(path=out_abs):
|
||||
shown = False
|
||||
shower = getattr(app, "_show_structure_render", None)
|
||||
if callable(shower):
|
||||
try:
|
||||
shower(path)
|
||||
shown = True
|
||||
except Exception as e:
|
||||
app.log(f" 결과 표시 실패 (_show_structure_render): {e}")
|
||||
if not shown:
|
||||
if _open_in_os_default_viewer(path):
|
||||
app.log(f" 결과 PNG를 기본 뷰어로 열었습니다: {path}")
|
||||
else:
|
||||
app.log(f" 결과 PNG: {path} (뷰어 자동 실행 실패 — "
|
||||
f"파일을 직접 열어 확인하세요)")
|
||||
|
||||
app.after(0, _present_result)
|
||||
|
||||
except Exception as e:
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.fail_job(db, job_id, str(e))
|
||||
err_msg = str(e)[:300]
|
||||
app.after(0, lambda em=err_msg: app.log(f" Blender 워커 오류: {em}"))
|
||||
app.after(0, lambda: app.set_status("렌더링 실패", "#E74C3C"))
|
||||
app.after(0, lambda em=err_msg: _show_error_dialog(
|
||||
"Blender 워커 오류", f"실행 오류:\n{em}"))
|
||||
finally:
|
||||
if runner_path and runner_path.is_file():
|
||||
with contextlib.suppress(Exception):
|
||||
runner_path.unlink()
|
||||
if db:
|
||||
with contextlib.suppress(Exception):
|
||||
db.close()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CLI 진입점 (단독 실행 / 디버그용)
|
||||
# ===========================================================================
|
||||
|
||||
class _StubApp:
|
||||
"""app 인터페이스 흉내 — CLI 모드에서 가짜 app 객체로 사용."""
|
||||
def __init__(self, dxf_path: str = ""):
|
||||
self.dxf_path = dxf_path
|
||||
self.target_resolution = None
|
||||
self.structure_render_resolution = None
|
||||
self.job_logger = None
|
||||
self.seed_mgr = None
|
||||
self.prompt_reg = None
|
||||
self.quality_val = None
|
||||
|
||||
def log(self, msg: str):
|
||||
print(msg)
|
||||
|
||||
def set_status(self, text: str, color: str = ""):
|
||||
print(f"[status] {text}")
|
||||
|
||||
def after(self, delay_ms: int, fn):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
print(f"[after-error] {e}")
|
||||
|
||||
def _get_dxf_hash(self) -> str:
|
||||
return ""
|
||||
|
||||
def _get_prompt_hash(self, prompt: str) -> str:
|
||||
import hashlib
|
||||
return hashlib.sha256(prompt.encode()).hexdigest()[:16]
|
||||
|
||||
def _show_structure_render(self, path: str):
|
||||
print(f"[structure-render] {path}")
|
||||
|
||||
|
||||
def _cli():
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Blender 헤드리스 구조물 렌더 (S-CANVAS 외부 단독 실행)"
|
||||
)
|
||||
ap.add_argument("--params", required=True, help="GateParams/IntakeTowerParams JSON")
|
||||
ap.add_argument("--blender", default=None,
|
||||
help="Blender 실행파일 경로 (없으면 자동 탐색)")
|
||||
ap.add_argument("--output", default="structure_render.png",
|
||||
help="출력 PNG (기본 'structure_render.png')")
|
||||
ap.add_argument("--time", default="daytime",
|
||||
choices=["daytime", "sunset", "overcast"])
|
||||
ap.add_argument("--engine", default="CYCLES",
|
||||
choices=["CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"])
|
||||
ap.add_argument("--samples", type=int, default=128)
|
||||
ap.add_argument("--width", type=int, default=1920)
|
||||
ap.add_argument("--height", type=int, default=1080)
|
||||
ap.add_argument("--transparent", action="store_true",
|
||||
help="투명 배경 RGBA PNG (지형 합성 입력용)")
|
||||
ap.add_argument("--save-blend", action="store_true")
|
||||
ap.add_argument("--save-glb", action="store_true")
|
||||
ap.add_argument("--kind", default=None,
|
||||
choices=list(_STRUCTURE_REGISTRY.keys()),
|
||||
help="구조물 종류 (없으면 JSON에서 자동 감지)")
|
||||
ap.add_argument("--timeout", type=int, default=1800,
|
||||
help="subprocess timeout (초)")
|
||||
args = ap.parse_args()
|
||||
|
||||
stub = _StubApp()
|
||||
stub.structure_render_resolution = (args.width, args.height)
|
||||
|
||||
run_blender_render(
|
||||
stub,
|
||||
blender_exe=args.blender,
|
||||
params_json=args.params,
|
||||
time_preset=args.time,
|
||||
engine=args.engine,
|
||||
samples=args.samples,
|
||||
output_path=args.output,
|
||||
transparent_bg=args.transparent,
|
||||
save_blend=args.save_blend,
|
||||
save_glb=args.save_glb,
|
||||
structure_kind=args.kind,
|
||||
timeout_sec=args.timeout,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
Reference in New Issue
Block a user