This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
@@ -0,0 +1,151 @@
"""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}")