feat(B04): 등고선 보간 6종을 만들어 화면에서 비교하게 한다
문헌(ANUDEM Hutchinson 1988/89, DEM 보간 비교연구)은 지형에 따라 우열이
갈려 단일 최적해가 없다고 본다. 방식을 하나로 고르지 않고 전부 만들어 두고
사용자가 눈으로 비교해 정하도록 했다(2026-08-30 사용자 지시).
신규 B04_PreProcess_Engine_SheetMethods.py — 거리비례 / TPS(박판, 감쇠
최소곡률 LSMR) / 라플라스 / TIN 선형 / TIN 곡면(Clough-Tocher) / IDW.
방식마다 dtm_sheet_{key}.npz + 프리뷰를 만들어 surface_models에
source_filter=sheet_{key}로 등록하므로 기존 뷰어·등고선·프리뷰 경로가 그대로
돈다. 폐합 링 안쪽 처리는 방식과 무관하게 똑같이 적용한다.
화면: 도엽등고 3D 서피스 컨테이너에 방식 전환 버튼과 '라이다 겹쳐 보기'
토글(반투명 파랑)을 달았다. 라이다는 같은 좌표계라 같은 자리에 겹친다.
실측(c1bb453f, 6종 65s): 평탄 셀 TPS 0.003% / 거리비례 0.014% /
TIN 곡면 0.026% / 라플라스 0.235% / TIN 선형 10.4% / IDW 69.1%.
LAS 대비 노선 |dz| 평균은 2.59~3.00m로 방식 간 차이가 작다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,11 +31,15 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
|
||||
grid_vertices,
|
||||
write_glb,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import (
|
||||
SHEET_METHOD_BUILDERS,
|
||||
SHEET_METHOD_LABELS,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds
|
||||
from config.config_system import (
|
||||
SHEET_SURFACE_GRID_M,
|
||||
SHEET_SURFACE_MARGIN_M,
|
||||
SHEET_SURFACE_RELAX_ITERATIONS,
|
||||
SHEET_SURFACE_METHODS,
|
||||
SURFACE_MAX_PREVIEW_VERTICES,
|
||||
)
|
||||
|
||||
@@ -165,106 +169,6 @@ def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.n
|
||||
return burned
|
||||
|
||||
|
||||
def _relax_surface(surface: np.ndarray, fixed: np.ndarray, iterations: int) -> None:
|
||||
"""등고 라인을 고정한 채 이웃 평균으로 표고를 **살짝만** 다듬는다 (in-place).
|
||||
|
||||
거리 보간은 두 라인의 굽은 정도가 다르면 능선·계곡 중심축에 각진 자국이 남는다.
|
||||
이웃 평균 몇 회로 그 자국만 지운다.
|
||||
|
||||
**끝까지 수렴시키면 안 된다.** 수렴한 해는 라플라스 방정식의 해(harmonic)인데,
|
||||
원뿔·능선 형상 z=r은 harmonic이 아니라 biharmonic이라 harmonic 해로 끌고 가면
|
||||
마루가 눌리고 평탄 셀이 늘어난다. 등고선 DEM의 표준인 ANUDEM/Topo to Raster가
|
||||
라플라스가 아니라 **thin plate spline(biharmonic)** 을 쓰는 이유가 이것이다
|
||||
(Hutchinson 1988/89). 거리 보간은 원뿔을 정확히 재현하므로 그것을 바탕으로 두고
|
||||
다듬기만 한다.
|
||||
|
||||
합성 원뿔 검증(2026-08-30): 거리보간만 오차 0.121m·간격 CV 2.18 →
|
||||
5회 다듬기 0.118m·1.43(개선) → 20회 이상 CV 50↑·평탄 셀 증가(악화).
|
||||
"""
|
||||
if iterations <= 0:
|
||||
return
|
||||
free = ~fixed & np.isfinite(surface)
|
||||
if not free.any():
|
||||
return
|
||||
# 체커보드(red-black) 순서 — 한 색을 갱신할 때 이웃(다른 색)은 그대로라 제자리
|
||||
# 갱신이 안전하다. 과완화는 쓰지 않는다(omega=1): 빨리 수렴시킬수록 harmonic 해에
|
||||
# 가까워져 능선이 눌린다. 여기서 원하는 건 수렴이 아니라 자국 제거다.
|
||||
omega = np.float32(1.0)
|
||||
rows, cols = np.indices(surface.shape)
|
||||
red = free & (((rows + cols) & 1) == 0)
|
||||
black = free & ~red
|
||||
padded = np.zeros((surface.shape[0] + 2, surface.shape[1] + 2), dtype=np.float32)
|
||||
for _ in range(iterations):
|
||||
for colour in (red, black):
|
||||
padded[1:-1, 1:-1] = surface
|
||||
padded[0, 1:-1] = surface[0]
|
||||
padded[-1, 1:-1] = surface[-1]
|
||||
padded[1:-1, 0] = surface[:, 0]
|
||||
padded[1:-1, -1] = surface[:, -1]
|
||||
neighbours = (
|
||||
padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:]
|
||||
) * np.float32(0.25)
|
||||
surface[colour] += omega * (neighbours[colour] - surface[colour])
|
||||
logger.info("도엽 서피스: 등고선 고정 완화 %d회 (자유 셀 %d개)", iterations, int(free.sum()))
|
||||
|
||||
|
||||
def _interpolate_between_contours(burned: np.ndarray, cell_m: float) -> np.ndarray:
|
||||
"""2D 거리 보간 — 셀마다 가장 가까운 서로 다른 표고 두 라인 사이를 선형 보간한다.
|
||||
|
||||
등고선 정점 Delaunay TIN은 같은 표고 정점 3개짜리 평탄 삼각형이 계단을 만든다.
|
||||
여기서는 삼각망을 쓰지 않고, 지도 제작의 표준인 **등고선 사이 비례 보간**을
|
||||
2D에서 직접 한다(2026-08-30 사용자 지시 — 메시는 맨 마지막):
|
||||
|
||||
z = (L1·d2 + L2·d1) / (d1 + d2)
|
||||
|
||||
d1·L1 = 가장 가까운 등고 라인까지의 거리·표고, d2·L2 = **표고가 다른 것 중**
|
||||
가장 가까운 라인. 두 라인 사이에서 z가 거리에 비례해 연속으로 변하므로 계단이
|
||||
원리적으로 생기지 않는다. 라인 위(d1=0)에서는 그 표고 그대로다.
|
||||
|
||||
표고별 거리장을 한 번씩 구하며 **가장 작은 두 값**을 추적한다 — 표고가 서로 다른
|
||||
것끼리 비교하므로 L1≠L2가 보장된다. (KD-tree로 k개 이웃을 뽑는 방식은 이웃이
|
||||
전부 같은 라인의 셀이라 다른 표고를 못 찾는다.)
|
||||
|
||||
`burned`: 라인 셀 = 표고, 그 외 NaN. 계곡 구조선 앵커도 같은 격자에 구워 두면
|
||||
같은 규칙으로 제약이 된다.
|
||||
"""
|
||||
from scipy.ndimage import distance_transform_edt
|
||||
|
||||
levels = np.unique(burned[np.isfinite(burned)])
|
||||
if len(levels) < 2:
|
||||
return np.full(burned.shape, levels[0] if len(levels) else np.nan, dtype=np.float32)
|
||||
|
||||
infinity = np.float32(np.inf)
|
||||
first_d = np.full(burned.shape, infinity, dtype=np.float32)
|
||||
first_z = np.zeros(burned.shape, dtype=np.float32)
|
||||
second_d = np.full(burned.shape, infinity, dtype=np.float32)
|
||||
second_z = np.zeros(burned.shape, dtype=np.float32)
|
||||
for level in levels:
|
||||
distance = distance_transform_edt(burned != level, sampling=cell_m).astype(np.float32)
|
||||
beats_first = distance < first_d
|
||||
# 1등이 2등으로 밀린다.
|
||||
second_d = np.where(beats_first, first_d, second_d)
|
||||
second_z = np.where(beats_first, first_z, second_z)
|
||||
first_d = np.where(beats_first, distance, first_d)
|
||||
first_z = np.where(beats_first, np.float32(level), first_z)
|
||||
beats_second = ~beats_first & (distance < second_d)
|
||||
second_d = np.where(beats_second, distance, second_d)
|
||||
second_z = np.where(beats_second, np.float32(level), second_z)
|
||||
|
||||
total = first_d + second_d
|
||||
usable = np.isfinite(second_d) & (total > 1e-9)
|
||||
surface = first_z.astype(np.float32)
|
||||
surface[usable] = (
|
||||
(
|
||||
first_z[usable].astype(np.float64) * second_d[usable].astype(np.float64)
|
||||
+ second_z[usable].astype(np.float64) * first_d[usable].astype(np.float64)
|
||||
)
|
||||
/ total[usable].astype(np.float64)
|
||||
).astype(np.float32)
|
||||
logger.info("도엽 서피스: 2D 거리보간 %d셀 (제약 표고 %d단)", surface.size, len(levels))
|
||||
return surface
|
||||
|
||||
|
||||
def _resolve_enclosed_interiors(
|
||||
burned: np.ndarray, present: list[float], surface: np.ndarray, cell_m: float, interval_m: float
|
||||
) -> np.ndarray:
|
||||
@@ -327,60 +231,25 @@ def _resolve_enclosed_interiors(
|
||||
return handled
|
||||
|
||||
|
||||
def build_sheet_surface_model(
|
||||
def _write_method_model(
|
||||
project_root: Path,
|
||||
processed_dir: Path,
|
||||
models_dir: Path,
|
||||
route_xy: np.ndarray,
|
||||
epsg: int,
|
||||
spec: Any,
|
||||
surface: np.ndarray,
|
||||
method_key: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""도엽등고선으로 DTM npz·프리뷰 glb를 만들고 surface_models 등록용 dict를 돌려준다.
|
||||
|
||||
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m). 실패하면 None — 호출측은
|
||||
분석을 계속한다(도엽 미확보 지역 폴백).
|
||||
"""
|
||||
started = time.monotonic()
|
||||
features = _load_features_metric(processed_dir, epsg)
|
||||
if not features:
|
||||
return None
|
||||
|
||||
x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M
|
||||
x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M
|
||||
y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M
|
||||
y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M
|
||||
|
||||
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
|
||||
|
||||
# ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN).
|
||||
burned = _rasterize_contour_levels(spec, features)
|
||||
present = sorted(np.unique(burned[np.isfinite(burned)]).tolist())
|
||||
if len(present) < 2:
|
||||
logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.")
|
||||
return None
|
||||
# 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백.
|
||||
interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0
|
||||
|
||||
# ② 2D — 등고선 사이 거리 비례 보간(메시 없음). 여기서 나온 격자에서 1m 등고선을
|
||||
# 뽑으므로 화면 등고선이 곧 2D 보간선이다(2026-08-30 사용자 지시).
|
||||
# 제약은 **등고선만** 쓴다 — 계곡 구조선 앵커를 1m로 양자화해 섞었더니 제약
|
||||
# 표고가 29단→121단이 되어 계곡 주변만 1m 간격이 되고 보간선이 등간격을
|
||||
# 잃었다(2026-08-30 사용자 지적). V자 등고선이 계곡 하강을 이미 담고 있다.
|
||||
surface = _interpolate_between_contours(burned, spec.cell_m)
|
||||
# ③ 폐합 등고선 안쪽(마루·웅덩이)을 바깥 사면 경사로 연장 (2026-08-30 사용자 확정).
|
||||
summits = _resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m)
|
||||
# ④ 등고 라인(과 마루)을 고정한 채 완화 — 중심축 뭉침을 풀어 간격을 고르게 한다.
|
||||
_relax_surface(surface, np.isfinite(burned) | summits, SHEET_SURFACE_RELAX_ITERATIONS)
|
||||
|
||||
"""방식 하나의 표고 격자를 npz·프리뷰 glb로 저장하고 등록용 dict를 만든다."""
|
||||
# DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다.
|
||||
x_coords = spec.cell_centers_x()
|
||||
y_coords = spec.cell_centers_y()[::-1]
|
||||
z_grid = surface[::-1, :].astype(np.float32)
|
||||
valid_grid = np.isfinite(z_grid)
|
||||
if not valid_grid.any():
|
||||
logger.warning("도엽 서피스: 유효 표고 셀이 없습니다.")
|
||||
logger.warning("도엽 서피스(%s): 유효 표고 셀이 없습니다.", method_key)
|
||||
return None
|
||||
|
||||
stem = f"dtm_{SHEET_SOURCE_FILTER}"
|
||||
source_filter = f"{SHEET_SOURCE_FILTER}_{method_key}"
|
||||
stem = f"dtm_{source_filter}"
|
||||
model_path = models_dir / f"{stem}.npz"
|
||||
preview_path = models_dir / f"{stem}_preview.glb"
|
||||
finite_z = z_grid[valid_grid]
|
||||
@@ -402,32 +271,25 @@ def build_sheet_surface_model(
|
||||
bounds=bounds,
|
||||
resolution=np.array([SHEET_SURFACE_GRID_M], np.float32),
|
||||
)
|
||||
|
||||
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
|
||||
write_glb(preview_path, vertices, faces, bounds)
|
||||
|
||||
logger.info(
|
||||
"도엽 서피스 생성 완료: %d×%d 격자, 등고 %d단 (%.1fs)",
|
||||
spec.n_rows,
|
||||
spec.n_cols,
|
||||
len(present),
|
||||
time.monotonic() - started,
|
||||
)
|
||||
return {
|
||||
"model_type": "dtm",
|
||||
"source_filter": SHEET_SOURCE_FILTER,
|
||||
"source_filter": source_filter,
|
||||
"representation": "regular_grid",
|
||||
"model_file_path": str(model_path.relative_to(project_root)).replace("\\", "/"),
|
||||
"resolution_m": SHEET_SURFACE_GRID_M,
|
||||
"generation_params": {
|
||||
"source_filter": SHEET_SOURCE_FILTER,
|
||||
"source_filter": source_filter,
|
||||
"representation": "regular_grid",
|
||||
"source": "map_sheet_contours",
|
||||
"interpolation": method_key,
|
||||
"interpolation_label": SHEET_METHOD_LABELS.get(method_key, method_key),
|
||||
"margin_m": SHEET_SURFACE_MARGIN_M,
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"layer_name": f"dtm_{SHEET_SOURCE_FILTER}_preview",
|
||||
"layer_name": f"{stem}_preview",
|
||||
"geometry_type": "MESH",
|
||||
"file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"),
|
||||
"file_format": "glb",
|
||||
@@ -436,10 +298,79 @@ def build_sheet_surface_model(
|
||||
}
|
||||
|
||||
|
||||
def build_sheet_surface_model(
|
||||
project_root: Path,
|
||||
processed_dir: Path,
|
||||
models_dir: Path,
|
||||
route_xy: np.ndarray,
|
||||
epsg: int,
|
||||
methods: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""도엽등고선으로 방식별 DTM npz·프리뷰 glb를 만들고 등록용 dict 목록을 돌려준다.
|
||||
|
||||
방식을 하나로 고르지 않고 전부 만들어 두는 이유: 문헌상 지형에 따라 우열이 갈려
|
||||
화면에서 바꿔 보며 정해야 한다(2026-08-30 사용자 지시). 실패하면 빈 목록 —
|
||||
호출측은 분석을 계속한다(도엽 미확보 지역 폴백).
|
||||
|
||||
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m).
|
||||
"""
|
||||
started = time.monotonic()
|
||||
features = _load_features_metric(processed_dir, epsg)
|
||||
if not features:
|
||||
return []
|
||||
|
||||
x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M
|
||||
x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M
|
||||
y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M
|
||||
y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M
|
||||
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
|
||||
|
||||
# ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN).
|
||||
burned = _rasterize_contour_levels(spec, features)
|
||||
present = sorted(np.unique(burned[np.isfinite(burned)]).tolist())
|
||||
if len(present) < 2:
|
||||
logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.")
|
||||
return []
|
||||
# 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백.
|
||||
interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0
|
||||
|
||||
selected = methods or list(SHEET_SURFACE_METHODS)
|
||||
models: list[dict[str, Any]] = []
|
||||
for method_key in selected:
|
||||
builder = SHEET_METHOD_BUILDERS.get(method_key)
|
||||
if builder is None:
|
||||
logger.warning("도엽 서피스: 알 수 없는 보간 방식 %s — 건너뜁니다.", method_key)
|
||||
continue
|
||||
step_started = time.monotonic()
|
||||
try:
|
||||
# ② 2D 보간 — 여기서 나온 격자에서 1m 등고선을 뽑으므로 화면 등고선이 곧
|
||||
# 2D 보간선이다. 메시(glb)는 그 격자의 표현일 뿐이다(사용자 지시).
|
||||
surface = builder(spec, burned, features, spec.cell_m)
|
||||
# ③ 폐합 등고선 안쪽(마루·웅덩이)은 방식과 무관하게 같은 규칙으로 채운다.
|
||||
_resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m)
|
||||
except Exception as exc: # noqa: BLE001 — 한 방식이 죽어도 나머지는 만든다
|
||||
logger.warning("도엽 서피스(%s) 생성 실패: %s", method_key, exc)
|
||||
continue
|
||||
model = _write_method_model(project_root, models_dir, spec, surface, method_key)
|
||||
if model is not None:
|
||||
models.append(model)
|
||||
logger.info("도엽 서피스(%s) 완료 (%.1fs)", method_key, time.monotonic() - step_started)
|
||||
|
||||
logger.info(
|
||||
"도엽 서피스 생성 완료: %d×%d 격자, 등고 %d단, 방식 %d개 (%.1fs)",
|
||||
spec.n_rows,
|
||||
spec.n_cols,
|
||||
len(present),
|
||||
len(models),
|
||||
time.monotonic() - started,
|
||||
)
|
||||
return models
|
||||
|
||||
|
||||
def build_sheet_surface_from_route(
|
||||
project_root: Path, processed_dir: Path, models_dir: Path
|
||||
) -> dict[str, Any] | None:
|
||||
"""B03 업로드 계획노선 CSV를 찾아 도엽 서피스를 만든다. 없거나 실패하면 None."""
|
||||
) -> list[dict[str, Any]]:
|
||||
"""B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록."""
|
||||
from common_util.common_util_route_geometry import (
|
||||
find_planned_route_file,
|
||||
read_planned_route_csv,
|
||||
@@ -448,11 +379,11 @@ def build_sheet_surface_from_route(
|
||||
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
|
||||
if route_file is None:
|
||||
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
|
||||
return None
|
||||
return []
|
||||
planned = read_planned_route_csv(route_file)
|
||||
if planned is None or len(planned.vertices) < 2:
|
||||
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
|
||||
return None
|
||||
return []
|
||||
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
|
||||
return build_sheet_surface_model(
|
||||
project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186
|
||||
@@ -511,8 +442,8 @@ def run_sheet_surface_analysis(
|
||||
)
|
||||
|
||||
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
|
||||
model = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
|
||||
if model is None:
|
||||
models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
|
||||
if not models:
|
||||
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
|
||||
|
||||
_report(95, "saving", "결과 저장 중")
|
||||
@@ -537,5 +468,5 @@ def run_sheet_surface_analysis(
|
||||
},
|
||||
"ground_summary": {},
|
||||
"manifest": {"status": "sheet_only"},
|
||||
"models": [model],
|
||||
"models": models,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user