from __future__ import annotations import hashlib import json import numpy as np from pathlib import Path from typing import Any from scipy import ndimage from scipy.interpolate import RectBivariateSpline import trimesh # config 파라미터 로드 import config from utils.terrain_model_converter import ( TerrainContext, _atomic_npz, _write_glb, _scene_vertices, _height_colors, _grid_vertices, _grid_faces, _clip_and_compact_mesh, _artifact_size ) SMOOTHING_ALGORITHM_VERSION = 2 def compute_smoothing_signature(config_dict: dict[str, Any]) -> str: """스무딩 관련 파라미터의 해시 서명을 계산합니다.""" smooth_params = {k: v for k, v in config_dict.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: """NaN 및 무효 영역(mask == False) 오염을 방지하기 위한 정규화 가우시안 필터""" if sigma <= 0: return grid.copy() # 무효 영역은 0으로 채우고 가중치 배열 생성 V = grid.copy() V[~mask] = 0.0 W = np.zeros_like(grid, dtype=float) W[mask] = 1.0 # 각각 가우시안 블러 수행 V_blur = ndimage.gaussian_filter(V, sigma=sigma, mode="constant", cval=0.0) W_blur = ndimage.gaussian_filter(W, sigma=sigma, mode="constant", cval=0.0) # 0 나누기 방지용 마스크 valid_denom = W_blur > 1e-10 result = grid.copy() result[valid_denom] = V_blur[valid_denom] / W_blur[valid_denom] return result def smooth_dtm( context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path, ) -> dict[str, Any]: """DTM 격자 지형 데이터를 로드하여 C2 바이큐빅 보간을 적용하고 스무딩 모델을 생성합니다.""" # 원본 npz 데이터 로드 data = np.load(original_model_path) x_orig = data["x"] y_orig = data["y"] z_orig = data["z"] valid_orig = 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 # 가우시안 마스크 가중 평활화 진행 (NaN/경계 오염 제거) z_pre = _masked_gaussian_filter(z_orig, valid_orig, sigma_pixels) # B-Spline C2 평가 spline = RectBivariateSpline( y_orig, x_orig, z_pre, kx=3, ky=3, s=float(context.config.get("smoothing_dtm_spline_smooth", 0.0)) ) # 지정 해상도(예: 0.5m)로 C2 그리드 재작성 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) # footprint 영역 검증 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" # 압축 모델 저장 및 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 저수축 스무딩 필터를 적용하고 저장합니다.""" # 원본 npz 데이터 로드 data = np.load(original_model_path) vertices = data["vertices"] faces = data["faces"] if len(vertices) < 3 or not len(faces): raise ValueError("TIN 스무딩을 수행할 삼각망 메쉬 데이터가 올바르지 않습니다.") # Taubin 스무딩 강도 노브 파라미터 로드 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: # Taubin 스무딩 필터 적용 trimesh.smoothing.filter_taubin(mesh, lamb=lamb, nu=mu, iterations=iterations) vertices_smooth = np.asarray(mesh.vertices, dtype=np.float32) # 지표면 스무딩은 평면 위치를 변형하는 메시 리토폴로지가 아니다. # DTM과 동일하게 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" # 압축 모델 저장 및 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) elif method == "tin": return smooth_tin(context, output_dir, stem, original_model_path) else: raise ValueError(f"스무딩이 불가능한 지형 표현 방식입니다: {method}")