"""B05 지형 스켈레톤(주/지 능선·계곡) 추출. 수문학적 정의: D8 흐름누적이 임계값 이상인 셀=계곡, DEM 반전 시 능선. 누적값 크기의 2차 임계값으로 주/지를 나눈다. whitebox 우선, 실패 시 numpy D8 폴백. 산출 polyline은 비용면과 동일 좌표계이며 B05_wf2_Route/route에 캐시된다. """ import json import math import tempfile import time from pathlib import Path from typing import Any import numpy as np from B05_wf2_Route.B05_wf2_Route_Engine_Solver import ( _MODELS_SUBDIR, _ROUTE_CACHE_SUBDIR, _cost_surface_signature, _load_or_build_cost_surface, ) from config.config_system import ( SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS, SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS, SKELETON_RIDGE_ACC_THRESHOLD_CELLS, SKELETON_VALLEY_ACC_THRESHOLD_CELLS, ) SKELETON_CLASSES = ("main_ridge", "minor_ridge", "main_valley", "minor_valley") _D8_OFFSETS = [ (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1), ] def _default_thresholds() -> dict[str, float]: return { "valley_acc": float(SKELETON_VALLEY_ACC_THRESHOLD_CELLS), "main_valley_acc": float(SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS), "ridge_acc": float(SKELETON_RIDGE_ACC_THRESHOLD_CELLS), "main_ridge_acc": float(SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS), } def d8_flow_accumulation_numpy(z_grid: np.ndarray, valid_mask: np.ndarray) -> np.ndarray: """numpy 기반 D8 흐름누적 (whitebox 폴백).""" rows, cols = z_grid.shape n = rows * cols z_flat = z_grid.ravel() valid_flat = valid_mask.ravel() receiver = np.full(n, -1, dtype=np.int64) for idx in range(n): if not valid_flat[idx]: continue r, c = divmod(idx, cols) zc = z_flat[idx] best_slope = 0.0 best = -1 for dr, dc in _D8_OFFSETS: nr, nc = r + dr, c + dc if not (0 <= nr < rows and 0 <= nc < cols): continue nidx = nr * cols + nc if not valid_flat[nidx]: continue drop = zc - z_flat[nidx] if drop <= 0: continue dist = math.sqrt(2.0) if (dr != 0 and dc != 0) else 1.0 slope = drop / dist if slope > best_slope: best_slope = slope best = nidx receiver[idx] = best acc = np.where(valid_flat, 1.0, 0.0) order = np.argsort(-z_flat, kind="stable") for idx in order: recv = receiver[idx] if recv >= 0 and valid_flat[idx]: acc[recv] += acc[idx] return acc.reshape(rows, cols) def _d8_flow_accumulation_whitebox( z_grid: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, valid_mask: np.ndarray, grid_res: float, ) -> np.ndarray | None: """WhiteboxTools로 D8 흐름누적을 계산한다 (실패 시 None → numpy 폴백).""" try: import rasterio from rasterio.transform import from_origin from whitebox import WhiteboxTools except Exception: return None nodata = -9999.0 rows, cols = z_grid.shape z_out = np.where(valid_mask, z_grid, nodata).astype(np.float32)[::-1, :] transform = from_origin( float(x_coords[0]) - grid_res / 2.0, float(y_coords[-1]) + grid_res / 2.0, grid_res, grid_res, ) try: with tempfile.TemporaryDirectory(prefix="wbt_skel_") as tmp: tmp_path = Path(tmp) dem_tif = tmp_path / "dem.tif" acc_tif = tmp_path / "acc.tif" with rasterio.open( dem_tif, "w", driver="GTiff", height=rows, width=cols, count=1, dtype="float32", nodata=nodata, transform=transform, ) as dst: dst.write(z_out, 1) wbt = WhiteboxTools() wbt.set_verbose_mode(False) wbt.set_working_dir(str(tmp_path)) if wbt.fill_depressions("dem.tif", "filled.tif") != 0: raise RuntimeError("fill_depressions 실패") if wbt.d8_flow_accumulation("filled.tif", "acc.tif", out_type="cells") != 0: raise RuntimeError("d8_flow_accumulation 실패") with rasterio.open(acc_tif) as src: acc = src.read(1).astype(np.float64)[::-1, :] return np.where(np.isfinite(acc) & (acc > 0) & valid_mask, acc, 0.0) except Exception: return None def _flow_accumulation( z_grid: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, valid_mask: np.ndarray, grid_res: float, ) -> np.ndarray: acc = _d8_flow_accumulation_whitebox(z_grid, x_coords, y_coords, valid_mask, grid_res) if acc is None: acc = d8_flow_accumulation_numpy(z_grid, valid_mask) return acc def _trace_polylines(mask: np.ndarray) -> list[list[tuple[int, int]]]: """1픽셀 폭 스켈레톤 마스크를 (r, c) polyline 목록으로 변환한다.""" pixels = set(zip(*np.nonzero(mask))) if not pixels: return [] def neighbors(p): r, c = p return [(r + dr, c + dc) for dr, dc in _D8_OFFSETS if (r + dr, c + dc) in pixels] degree = {p: len(neighbors(p)) for p in pixels} seeds = [p for p in pixels if degree[p] != 2] visited_edges: set = set() polylines: list[list[tuple[int, int]]] = [] def edge_key(a, b): return (a, b) if a <= b else (b, a) def walk(start, nxt): path = [start, nxt] visited_edges.add(edge_key(start, nxt)) prev, curr = start, nxt while degree[curr] == 2: candidates = [q for q in neighbors(curr) if q != prev] if not candidates: break q = candidates[0] if edge_key(curr, q) in visited_edges: break visited_edges.add(edge_key(curr, q)) path.append(q) prev, curr = curr, q return path for seed in seeds: for nb in neighbors(seed): if edge_key(seed, nb) not in visited_edges: polylines.append(walk(seed, nb)) for p in pixels: if degree[p] == 2: for nb in neighbors(p): if edge_key(p, nb) not in visited_edges: polylines.append(walk(p, nb)) return [pl for pl in polylines if len(pl) >= 2] def _mask_to_polylines( mask: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray ) -> list[dict[str, Any]]: """셀 마스크를 세선화한 뒤 모델좌표 polyline 목록으로 변환한다.""" if not mask.any(): return [] try: from skimage.morphology import skeletonize skel = skeletonize(mask) except Exception: skel = mask out = [] for pixel_path in _trace_polylines(skel): poly = [ [float(x_coords[c]), float(y_coords[r]), float(z_grid[r, c])] for r, c in pixel_path ] out.append({"polyline": poly}) return out def extract_skeleton_from_grid( x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray, valid_mask: np.ndarray, grid_res: float, thresholds: dict[str, float] | None = None, use_whitebox: bool = True, ) -> dict[str, list[dict[str, Any]]]: """격자에서 주/지 능선·계곡 polyline을 추출한다 (캐시 없음, 테스트용 공개 API).""" th = thresholds or _default_thresholds() if use_whitebox: acc_valley = _flow_accumulation(z_grid, x_coords, y_coords, valid_mask, grid_res) acc_ridge = _flow_accumulation(-z_grid, x_coords, y_coords, valid_mask, grid_res) else: acc_valley = d8_flow_accumulation_numpy(z_grid, valid_mask) acc_ridge = d8_flow_accumulation_numpy(-z_grid, valid_mask) valley_mask = valid_mask & (acc_valley >= th["valley_acc"]) main_valley_mask = valley_mask & (acc_valley >= th["main_valley_acc"]) minor_valley_mask = valley_mask & ~main_valley_mask ridge_mask = valid_mask & ~valley_mask & (acc_ridge >= th["ridge_acc"]) main_ridge_mask = ridge_mask & (acc_ridge >= th["main_ridge_acc"]) minor_ridge_mask = ridge_mask & ~main_ridge_mask return { "main_ridge": _mask_to_polylines(main_ridge_mask, x_coords, y_coords, z_grid), "minor_ridge": _mask_to_polylines(minor_ridge_mask, x_coords, y_coords, z_grid), "main_valley": _mask_to_polylines(main_valley_mask, x_coords, y_coords, z_grid), "minor_valley": _mask_to_polylines(minor_valley_mask, x_coords, y_coords, z_grid), } def _skeleton_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str: """소스 모델 + 격자 해상도 + 분류 임계값 서명.""" th = _default_thresholds() th_part = "|".join(f"{k}={v}" for k, v in sorted(th.items())) return _cost_surface_signature(models_dir, filter_key, method, smooth) + "|" + th_part def load_or_build_skeleton( project_root: Path, filter_key: str, method: str, smooth: bool ) -> dict[str, Any]: """스켈레톤을 캐시에서 로드하거나 새로 계산해 저장한다.""" project_root = Path(project_root) models_dir = project_root / _MODELS_SUBDIR cache_dir = project_root / _ROUTE_CACHE_SUBDIR suffix = "_smooth" if smooth else "" cache_path = cache_dir / f"terrain_skeleton_{filter_key}_{method}{suffix}.json" signature = _skeleton_signature(models_dir, filter_key, method, smooth) if cache_path.exists(): try: with open(cache_path, encoding="utf-8") as f: cached = json.load(f) if cached.get("signature") == signature: return cached except Exception: pass _t0 = time.time() (x_coords, y_coords, z_grid, valid_mask, _dz_dx, _dz_dy, grid_res) = ( _load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth) ) z_grid = np.asarray(z_grid, dtype=np.float64) valid_mask = np.asarray(valid_mask, dtype=bool) skeleton = extract_skeleton_from_grid( np.asarray(x_coords), np.asarray(y_coords), z_grid, valid_mask, float(grid_res) ) result: dict[str, Any] = dict(skeleton) result["grid_res"] = float(grid_res) result["signature"] = signature try: cache_dir.mkdir(parents=True, exist_ok=True) with open(cache_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) except Exception: pass return result