feat(B04): 도엽 서피스에 DTM 스무딩을 적용한다

LAS DTM 스무딩과 같은 두 단계를 계수 그대로 옮겨 방식마다
{stem}_smooth.npz·_smooth_preview.glb를 만든다(계수 변경 금지 — 사용자 지시):
정규화 가우시안(SURFACE_SMOOTHING_DTM_SIGMA_M) → C2 바이큐빅 B-spline 재평가
(kx=ky=3, s=SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH). smooth_dtm()은 라이다
발자국(TerrainContext)을 받아 그대로 못 쓴다.

- 결측이 하나라도 있으면 스플라인 결과가 통째로 NaN이 된다(TIN·TIN 곡면은
  볼록껍질 밖이 결측). 최근접 표고로 메워 적합하고 원래 마스크로 되돌린다.
- 재평가 격자는 원본보다 성기게 잡지 않는다 — 이 npz는 스무딩 확정 시 종·횡단이
  샘플링하는 표고 정본이라 화면 정점 상한으로 해상도를 깎으면 안 된다.
  메시 정점 수는 _preview_mesh가 알아서 줄인다.
- 화면: 방식 버튼 줄에 스무딩 드롭다운을 놓고 기본값을 켜 둔다.

실측(c1bb453f, 8종): 격자 767x840 1m 유지, 거칠기 감소(거리비례 0.341→0.217,
TIN 0.067→0.051), 표고 변화 3~33mm. build_surface_sampler(smooth=True) 정상.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 16:49:23 +09:00
co-authored by Claude Opus 5
parent c2060f3b6f
commit 184296f526
3 changed files with 95 additions and 2 deletions
@@ -46,6 +46,9 @@ from config.config_system import (
SURFACE_NURBS_CONTROL_POINTS_PER_AXIS,
SURFACE_NURBS_DEGREE,
SURFACE_NURBS_PATCH_SIZE_M,
SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M,
SURFACE_SMOOTHING_DTM_SIGMA_M,
SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH,
)
logger = logging.getLogger(__name__)
@@ -146,6 +149,75 @@ def _preview_mesh(
return clip_and_compact_mesh(vertices, faces, pv.reshape(-1))
def _write_smoothed(
models_dir: Path,
stem: str,
x: np.ndarray,
y: np.ndarray,
z: np.ndarray,
valid: np.ndarray,
bounds: np.ndarray,
) -> None:
"""`{stem}_smooth.npz`·`_smooth_preview.glb`를 만든다 — LAS DTM 스무딩과 같은 절차.
`B04_PreProcess_Engine_Smooth.smooth_dtm()`은 `TerrainContext`(라이다 발자국)를
받으므로 그대로 못 쓴다. 그래서 계수는 **config 값을 그대로** 두고 같은 두 단계만
옮긴다(2026-08-30 사용자 지시 — 계수 변경 금지):
① 무효 영역이 번지지 않는 정규화 가우시안 (`smoothing_dtm_sigma_meters`)
② C² 바이큐빅 B-spline 재평가 (`kx=ky=3`, `s=smoothing_dtm_spline_smooth`)를
`smoothing_dtm_preview_resolution_meters` 격자에서
화면 스무딩 토글과 확정 스냅샷이 이 파일을 찾으므로 이름 규칙을 지켜야 한다.
"""
from scipy.interpolate import RectBivariateSpline
from B04_PreProcess.B04_PreProcess_Engine_Smooth import _masked_gaussian_filter
cell_m = float(x[1] - x[0]) if len(x) > 1 else SHEET_SURFACE_GRID_M
sigma_pixels = SURFACE_SMOOTHING_DTM_SIGMA_M / cell_m if cell_m > 0 else 0.0
# 결측이 하나라도 있으면 스플라인 결과가 통째로 NaN이 된다(TIN·TIN 곡면은 볼록껍질
# 밖이 결측이다). 최근접 표고로 메워 적합하고 아래에서 원래 마스크로 되돌린다.
filled = z.astype(np.float64)
if not valid.all():
from scipy.ndimage import distance_transform_edt
_, (near_row, near_col) = distance_transform_edt(~valid, return_indices=True)
filled = filled[near_row, near_col]
z_pre = _masked_gaussian_filter(filled, valid, sigma_pixels)
try:
spline = RectBivariateSpline(y, x, z_pre, kx=3, ky=3, s=SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH)
except Exception as exc: # noqa: BLE001 — 스무딩 실패가 원본 산출을 막으면 안 된다
logger.warning("도엽 서피스(%s): 스무딩 스플라인 실패(%s) — 건너뜁니다.", stem, exc)
return
# 재평가 격자는 config의 프리뷰 해상도를 쓰되 원본보다 성기게 잡지 않는다 —
# 이 npz는 화면용이자 **스무딩 확정 시 종·횡단이 샘플링하는 표고 정본**이라,
# 정점 상한(화면 사정)으로 해상도를 깎으면 설계 정밀도가 같이 깎인다.
# 메시 정점 수는 _preview_mesh가 알아서 성기게 딴다.
step = max(SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, cell_m)
sx = np.arange(x[0], x[-1] + step * 0.5, step)
sy = np.arange(y[0], y[-1] + step * 0.5, step)
sz = np.asarray(spline(sy, sx), dtype=np.float32)
# 원본 유효 마스크를 최근접으로 옮겨 무효 영역을 그대로 지킨다.
col = np.clip(np.searchsorted(x, sx) - 1, 0, len(x) - 1)
row = np.clip(np.searchsorted(y, sy) - 1, 0, len(y) - 1)
svalid = valid[np.ix_(row, col)]
sz[~svalid] = np.nan
atomic_npz(
models_dir / f"{stem}_smooth.npz",
x=sx,
y=sy,
z=sz,
valid_mask=svalid,
bounds=bounds,
resolution=np.array([step], np.float32),
)
vertices, faces = _preview_mesh(sx, sy, np.nan_to_num(sz, nan=float(bounds[2, 0])), svalid)
write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, bounds)
def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.ndarray:
"""등고 라인을 격자에 굽는다 — 셀 = 그 위를 지나는 라인의 표고, 그 외 NaN.
@@ -310,6 +382,7 @@ def _write_method_model(
)
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
write_glb(preview_path, vertices, faces, bounds)
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds)
return {
"model_type": "dtm",
"source_filter": source_filter,
+4 -2
View File
@@ -524,8 +524,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
sheetToolbar.append(button);
sheetMethodButtons.set(method.key, button);
}
sheetToolbar.append(lidarLabel);
sheetViewer.setSmoothing(false);
// 스무딩 드롭다운과 라이다 토글은 오른쪽 끝에 함께 둔다.
sheetViewer.smoothingField.classList.add("b04-surface__sheet-smoothing");
sheetToolbar.append(sheetViewer.smoothingField, lidarLabel);
sheetViewer.setSmoothing(true);
selectSheetMethod(
sheetMethods.some((method) => method.key === sheetMethod)
? sheetMethod
@@ -837,3 +837,21 @@
.b04-surface__sheet-lidar {
margin-left: auto;
}
/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */
.b04-surface__sheet-smoothing {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-caption);
}
.b04-surface__sheet-smoothing .b04-surface__select {
width: auto;
min-width: 96px;
}
.b04-surface__sheet-toolbar .b04-surface__sheet-lidar {
margin-left: 0;
}