feat(B04): 도엽 서피스에 인접 등고선 사이 중간 보간선을 넣어 계단을 없앤다

등고선 정점만의 Delaunay TIN은 같은 표고 정점 3개짜리 평탄 삼각형이
굴곡부·능선에서 계단(terrace)을 만든다(2026-08-30 사용자 지적).
인접 표고 등고선 쌍의 등거리 중간선을 EDT로 찾아 평균 표고 정점으로
추가하되, 멀리 있는 다른 표고 쌍에 우연히 등거리인 지점을 거르기 위해
'그 지점의 최근접 등고선이 바로 그 쌍'일 때만 인정한다.

실측(c1bb453f): 평탄 셀 9.35%→5.31%, LAS 대비 노선 |Δz| 평균
2.65→2.57m·최대 13.11→13.07m. 최고 등고선 안쪽 봉우리·같은 표고 사이
골짜기 바닥은 원천 데이터 한계로 평탄 유지.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 14:47:20 +09:00
co-authored by Claude Opus 5
parent 462b71e896
commit 80979960d3
2 changed files with 68 additions and 0 deletions
@@ -34,6 +34,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
SHEET_SURFACE_MIDLINE_MAX_M,
SURFACE_MAX_PREVIEW_VERTICES,
)
@@ -101,6 +102,68 @@ def _preview_mesh(
return clip_and_compact_mesh(vertices, faces, pv.reshape(-1))
def _densify_between_contours(spec: Any, features: list[dict[str, Any]], cloud: Any) -> Any:
"""인접 표고 등고선 쌍의 등거리 중간선을 중간 표고 정점으로 추가한다.
등고선 정점만으로 Delaunay TIN을 치면 같은 표고 정점 3개짜리 평탄 삼각형이
굴곡부·능선에서 계단(terrace)을 만든다. 두 등고선의 "사이점"(등거리선)에
평균 표고 정점을 넣어 삼각형이 반드시 서로 다른 표고를 잇게 한다
(2026-08-30 사용자 지적). 최고 등고선 안쪽 봉우리·같은 표고 사이 골짜기
바닥은 짝이 없어 평탄으로 남는다 — 원천 데이터 한계.
"""
from scipy.ndimage import distance_transform_edt
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import rasterize_contours
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ContourCloud
burned, levels = rasterize_contours(spec, features, None)
present = [level for level in sorted(levels) if bool((burned == level).any())]
if len(present) < 2:
return cloud
def _distance(level: float) -> np.ndarray:
return distance_transform_edt(burned != level, sampling=spec.cell_m).astype(np.float32)
# 1차: 셀별 최근접 등고선 레벨 — 등거리 조건은 멀리 있는 다른 표고 쌍에도 우연히
# 성립하므로(예: 525~530 골짜기에 555/560 중간점이 박힘), "그 지점의 최근접
# 등고선이 바로 그 쌍"일 때만 사이점으로 인정한다.
best_distance = np.full(burned.shape, np.inf, dtype=np.float32)
best_index = np.full(burned.shape, -1, dtype=np.int16)
for index, level in enumerate(present):
distance = _distance(level)
take = distance < best_distance
best_distance[take] = distance[take]
best_index[take] = index
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
extra_xy: list[np.ndarray] = []
extra_z: list[np.ndarray] = []
previous_distance = _distance(present[0])
for index in range(1, len(present)):
distance = _distance(present[index])
near = (previous_distance < SHEET_SURFACE_MIDLINE_MAX_M) & (
distance < SHEET_SURFACE_MIDLINE_MAX_M
)
pair_is_nearest = (best_index == index - 1) | (best_index == index)
midline = near & pair_is_nearest & (np.abs(previous_distance - distance) <= spec.cell_m)
rows, cols = np.nonzero(midline)
if len(rows):
# 등고선 정점 재샘플 밀도에 맞춰 성기게 담는다 — TIN 비용 절제.
rows, cols = rows[::5], cols[::5]
extra_xy.append(np.column_stack([xs[cols], ys[rows]]))
extra_z.append(np.full(len(rows), (present[index - 1] + present[index]) / 2.0))
previous_distance = distance
if not extra_xy:
return cloud
added = int(sum(len(a) for a in extra_z))
logger.info("도엽 서피스: 중간 보간선 정점 %d개 추가 (등고 %d단)", added, len(present))
return ContourCloud(
xy=np.vstack([cloud.xy, *extra_xy]),
z=np.concatenate([cloud.z, *extra_z]),
)
def build_sheet_surface_model(
project_root: Path,
processed_dir: Path,
@@ -130,6 +193,8 @@ def build_sheet_surface_model(
return None
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
# 계단(평탄 삼각형) 해소 — 인접 등고선 사이 중간 보간선을 정점으로 추가.
cloud = _densify_between_contours(spec, features, cloud)
surface = interpolate_elevation(spec, cloud) # (R, C), 북→남 행 순서, 외부 NaN
# DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다.