"""B04 지표면 스무딩 엔진 (DTM 가우시안+B-Spline, TIN Taubin). 원본 모델(npz)을 로드해 표고 Z만 평활화한 스무딩 모델과 프리뷰(GLB)를 만든다. XY 위치와 삼각형 연결은 원본을 유지한다. """ import hashlib import json from pathlib import Path from typing import Any import numpy as np import trimesh from scipy import ndimage from scipy.interpolate import RectBivariateSpline from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import ( TerrainContext, artifact_size, atomic_npz, clip_and_compact_mesh, grid_faces, grid_vertices, write_glb, ) SMOOTHING_ALGORITHM_VERSION = 2 def compute_smoothing_signature(config: dict[str, Any]) -> str: """스무딩 관련 파라미터의 해시 서명을 계산한다.""" smooth_params = {k: v for k, v in config.items() if k.startswith("smoothing_")} smooth_params["algorithm_version"] = SMOOTHING_ALGORITHM_VERSION encoded = json.dumps(smooth_params, sort_keys=True, default=list).encode("utf-8") return hashlib.sha256(encoded).hexdigest()[:16] def _masked_gaussian_filter(grid: np.ndarray, mask: np.ndarray, sigma: float) -> np.ndarray: """무효 영역(mask==False) 오염을 방지하는 정규화 가우시안 필터.""" if sigma <= 0: return grid.copy() values = grid.copy() values[~mask] = 0.0 weights = np.zeros_like(grid, dtype=float) weights[mask] = 1.0 value_blur = ndimage.gaussian_filter(values, sigma=sigma, mode="constant", cval=0.0) weight_blur = ndimage.gaussian_filter(weights, sigma=sigma, mode="constant", cval=0.0) valid_denom = weight_blur > 1e-10 result = grid.copy() result[valid_denom] = value_blur[valid_denom] / weight_blur[valid_denom] return result def smooth_dtm( context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path ) -> dict[str, Any]: """DTM 격자에 가우시안+C2 바이큐빅 보간을 적용해 스무딩 모델을 만든다.""" data = np.load(original_model_path) x_orig, y_orig, z_orig, valid_orig = data["x"], data["y"], data["z"], data["valid_mask"] resolution = float(context.config["dtm_grid_resolution_meters"]) sigma_meters = float(context.config.get("smoothing_dtm_sigma_meters", 0.5)) sigma_pixels = sigma_meters / resolution if resolution > 0 else 0.0 z_pre = _masked_gaussian_filter(z_orig, valid_orig, sigma_pixels) spline = RectBivariateSpline( y_orig, x_orig, z_pre, kx=3, ky=3, s=float(context.config.get("smoothing_dtm_spline_smooth", 0.0)), ) preview_res = float(context.config.get("smoothing_dtm_preview_resolution_meters", 0.5)) x_coords, y_coords, _ = context.preview_grid(preview_res) z_smooth = np.asarray(spline(y_coords, x_coords), dtype=np.float32) valid_grid = context.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape( len(y_coords), len(x_coords) ) vertices = grid_vertices(x_coords, y_coords, z_smooth) faces = grid_faces(len(y_coords), len(x_coords)) preview_valid = context.contains_xy(vertices[:, 0], vertices[:, 1]) vertices_compact, faces_compact = clip_and_compact_mesh(vertices, faces, preview_valid) model_path = output_dir / f"{stem}_smooth.npz" preview_path = output_dir / f"{stem}_smooth_preview.glb" atomic_npz( model_path, x=x_coords, y=y_coords, z=z_smooth, valid_mask=valid_grid, resolution=np.array([preview_res], np.float32), ) write_glb(preview_path, vertices_compact, faces_compact, context.bounds) return { "model_file": model_path.name, "preview_file": preview_path.name, "preview_media_type": "model/gltf-binary", "vertex_count": int(len(vertices_compact)), "face_count": int(len(faces_compact)), "artifact_bytes": artifact_size(model_path, preview_path), "smoothing_signature": compute_smoothing_signature(context.config), } def smooth_tin( context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path ) -> dict[str, Any]: """TIN 삼각망에 Taubin 저수축 스무딩을 적용한다 (Z만 평활화).""" data = np.load(original_model_path) vertices, faces = data["vertices"], data["faces"] if len(vertices) < 3 or not len(faces): raise ValueError("TIN 스무딩을 수행할 삼각망 메쉬 데이터가 올바르지 않습니다.") iterations = int(context.config.get("smoothing_tin_taubin_iterations", 10)) lamb = float(context.config.get("smoothing_tin_taubin_lambda", 0.5)) mu = float(context.config.get("smoothing_tin_taubin_mu", -0.53)) mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) if iterations > 0: trimesh.smoothing.filter_taubin(mesh, lamb=lamb, nu=mu, iterations=iterations) vertices_smooth = np.asarray(mesh.vertices, dtype=np.float32) # 지표면 스무딩은 리토폴로지가 아니다: XY·연결은 원본, Z만 평활화. vertices_smooth[:, :2] = np.asarray(vertices[:, :2], dtype=np.float32) faces_smooth = np.asarray(mesh.faces, dtype=np.uint32) model_path = output_dir / f"{stem}_smooth.npz" preview_path = output_dir / f"{stem}_smooth_preview.glb" atomic_npz(model_path, vertices=vertices_smooth, faces=faces_smooth) write_glb(preview_path, vertices_smooth, faces_smooth, context.bounds) return { "model_file": model_path.name, "preview_file": preview_path.name, "preview_media_type": "model/gltf-binary", "vertex_count": int(len(vertices_smooth)), "face_count": int(len(faces_smooth)), "artifact_bytes": artifact_size(model_path, preview_path), "smoothing_signature": compute_smoothing_signature(context.config), } def run_smoothing( method: str, context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path ) -> dict[str, Any]: """방식에 따라 적절한 스무딩 엔진을 수행한다.""" if method == "dtm": return smooth_dtm(context, output_dir, stem, original_model_path) if method == "tin": return smooth_tin(context, output_dir, stem, original_model_path) raise ValueError(f"스무딩이 불가능한 지형 표현 방식입니다: {method}")