"""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 ") 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()