Files
Aislo/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py
2026-07-05 21:27:23 +09:00

316 lines
10 KiB
Python

"""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