"""B04 지표면 스무딩 엔진 (DTM 가우시안+B-Spline). 원본 모델(npz)을 로드해 표고 Z만 평활화한 스무딩 모델과 프리뷰(GLB)를 만든다. XY 격자는 원본을 유지한다. """ import hashlib import json from pathlib import Path from typing import Any import numpy as np from scipy import ndimage from scipy.interpolate import RectBivariateSpline from B04_PreProcess.B04_PreProcess_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([x_coords[1] - x_coords[0]], np.float32), # 넓힌 실제 칸(633) ) 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 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) raise ValueError(f"스무딩이 불가능한 지형 표현 방식입니다: {method}")