Files
Aislo/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py
T
eomsangdonandClaude Fable 5 c1f139527d feat(B05): 배수유역을 격자 흐름 해석으로 전면 재설계
등고선 아크 추적 + 능선 행진 방식이 능선/계곡을 안정적으로 분리하지 못해
D8 물 방향 + 도로 기준 상류 추적 방식으로 교체한다.

- 능선을 따로 탐지하지 않는다. 물길을 따라가 도로에 닿는 셀만 유역이고,
  그 경계가 곧 능선이다. 유역 내부 봉우리는 자동으로 포함된다.
- 등고선 TIN 보간 후 웅덩이 채움(형태학적 재구성) + 평탄면 미세경사로
  가짜 웅덩이/평탄 삼각형에서 흐름이 끊기는 문제를 없앤다.
- 상류 추적은 포인터 더블링으로 전 셀을 한 번에 푼다. 셀의 흐름 종착
  도로 셀(root)이 유역 판정·흐름 강도·세부유역 라벨의 공통 근거가 되어,
  관을 옮겨도 격자 해석 없이 측구 라우팅만 다시 돌면 된다(.npz 캐시).
- 활성 셀이 격자 최외곽에 닿은 방향으로만 확장하고, 경계 링이 전부
  비활성이 되면(띠 폐합) 멈춘다.

변경 사항
- 신규 엔진 3종: Engine_Watershed_Grid / _Flow / _Basin
- 폐기 엔진 4종은 _legacy_watershed/ 로 원본 보관(ruff 제외)
- config_system.py §5-3-1 에 DRAINAGE_* 파라미터 18개 (격자 1m, 반경 300m)
- 표고점 데이터 사용 중단(유효 데이터 부족), 프론트 능선 토글 제거
  (전체 유역 외곽선과 같은 선이므로 중복)
- 응답에 main_polygon_lonlat / strength_profile 추가, 계획선 위 흐름 강도 표기

합성 지형 검증: 유역 179,919㎡ vs 이론 180,000㎡ (오차 0.04%),
능선 자동 검출, 확장 3회 후 자동 정지, 캐시 재사용 1.2s -> 0.1s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 16:51:47 +09:00

225 lines
9.4 KiB
Python

"""배수유역 흐름 해석 — 도로 굽기 · 상류 추적(포인터 더블링) · 유역 폴리곤화.
핵심은 하나다: **물길을 따라가 도로에 닿는 셀만 유역이다.**
셀마다 D8 수신 셀을 따라가 종착점(root)을 구하고, 그 종착점이 도로 셀이면 활성이다.
이 방식은 능선을 따로 찾지 않는다. 능선 너머 셀의 물은 다른 계곡으로 빠져 도로에
닿지 못하므로 자동으로 비활성이 되고, 그 경계선이 곧 능선이다. 유역 안쪽 봉우리는
물이 결국 도로로 흘러 자동으로 포함된다.
종착점은 세부유역 라벨의 근거로도 그대로 쓴다 — 셀이 도달한 도로 셀이 정해지면
그 도로 셀을 담당하는 관이 곧 그 셀의 유역 번호다. 관 배치가 바뀌어도 격자 해석을
다시 돌릴 필요 없이 "도로 셀 → 관" 대응만 다시 계산하면 된다.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
from rasterio.features import rasterize, shapes
from rasterio.transform import from_origin
from scipy.spatial import cKDTree
from shapely.geometry import LineString, MultiPolygon, Polygon, shape
from shapely.ops import unary_union
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid
from config.config_system import (
DRAINAGE_MIN_BASIN_AREA_M2,
DRAINAGE_POLYGON_SIMPLIFY_M,
DRAINAGE_ROAD_WIDTH_M,
)
logger = logging.getLogger(__name__)
# 포인터 더블링 반복 상한. 한 번에 경로 길이가 2배가 되므로 2^40 스텝이면 어떤 격자도 덮는다.
_MAX_DOUBLING_ROUNDS = 40
@dataclass
class RoadRaster:
"""격자에 구운 도로. 도로 셀은 흐름을 흡수하는 종착점이 된다."""
mask: np.ndarray # (R, C) bool
cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스
chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m)
slot_of_cell: np.ndarray # (R*C,) int32 — 도로 셀이면 K 내 위치, 아니면 −1
@property
def count(self) -> int:
return int(self.cell_index.size)
@dataclass
class FlowResult:
"""상류 추적 결과."""
root: np.ndarray # (R*C,) int32 — 흐름 종착 셀의 평탄 인덱스
road_slot: np.ndarray # (R*C,) int32 — 도달한 도로 셀 슬롯, 도달 못하면 −1
active: np.ndarray # (R, C) bool — 도로에 물이 닿는 셀
path_length: np.ndarray # (R*C,) float32 — 종착점까지 물길 길이(m)
strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수(흐름 강도)
def grid_transform(spec: GridSpec):
"""rasterio 아핀 변환. 행 0이 북쪽(y_max)이다."""
return from_origin(spec.x_min, spec.y_max, spec.cell_m, spec.cell_m)
# ── 도로 굽기 ───────────────────────────────────────────────────────────────
def rasterize_road(
spec: GridSpec,
route_line: LineString,
width_m: float = DRAINAGE_ROAD_WIDTH_M,
) -> RoadRaster:
"""노선을 노폭만큼 두껍게 격자에 굽고, 각 도로 셀에 누가거리를 붙인다.
폭을 주는 이유는 실제 노면이 물을 받기 때문이기도 하지만, 1셀 선으로 구우면 D8
대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리기 때문이다. 3셀 이상 두께면
내리막 물길이 반드시 도로 셀을 한 번은 밟는다.
"""
half_width = max(width_m / 2.0, spec.cell_m)
burned = rasterize(
[(route_line.buffer(half_width), 1)],
out_shape=(spec.n_rows, spec.n_cols),
transform=grid_transform(spec),
fill=0,
dtype="uint8",
all_touched=True,
).astype(bool)
slot_of_cell = np.full(spec.size, -1, dtype=np.int32)
cell_index = np.flatnonzero(burned.reshape(-1)).astype(np.int32)
if cell_index.size == 0:
logger.warning("배수유역: 노선이 격자 범위 밖입니다 — 도로 셀 0개.")
return RoadRaster(burned, cell_index, np.zeros(0), slot_of_cell)
# 도로 셀 누가거리는 노선을 촘촘히 샘플링해 가장 가까운 샘플의 누가거리로 준다.
step = max(spec.cell_m / 2.0, 0.25)
positions = np.arange(0.0, route_line.length + step, step)
samples = np.array([list(route_line.interpolate(p).coords)[0] for p in positions])
rows = (cell_index // spec.n_cols).astype(np.float64)
cols = (cell_index % spec.n_cols).astype(np.float64)
centers = np.column_stack(
(
spec.x_min + (cols + 0.5) * spec.cell_m,
spec.y_max - (rows + 0.5) * spec.cell_m,
)
)
_, nearest = cKDTree(samples).query(centers)
chainage = np.minimum(positions[nearest], route_line.length)
slot_of_cell[cell_index] = np.arange(cell_index.size, dtype=np.int32)
return RoadRaster(burned, cell_index, chainage, slot_of_cell)
# ── 상류 추적 ───────────────────────────────────────────────────────────────
def trace_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowResult:
"""모든 셀의 물길 종착점을 구하고 도로 도달 여부(=유역 포함 여부)를 판정한다.
포인터 더블링으로 한 번에 경로 길이를 2배씩 늘려 종착점을 찾는다. 채움·평탄해소를
거친 표고에서는 흐름을 따라 표고가 단조 감소하므로 순환이 없고, 반복은 항상 끝난다.
"""
spec = terrain.spec
receiver = terrain.receiver.astype(np.int32, copy=True)
step_length = terrain.step_length.astype(np.float32, copy=True)
# 도로 셀은 흐름을 흡수한다 — 물이 도로에 닿으면 거기서 끝난다.
receiver[road.cell_index] = road.cell_index
step_length[road.cell_index] = 0.0
jump = receiver
path_length = step_length
for _ in range(_MAX_DOUBLING_ROUNDS):
next_jump = jump[jump]
if np.array_equal(next_jump, jump):
break
path_length = path_length + path_length[jump]
jump = next_jump
road_slot = road.slot_of_cell[jump]
active_flat = road_slot >= 0
strength = (
np.bincount(road_slot[active_flat], minlength=max(road.count, 1)).astype(np.int64)
if road.count
else np.zeros(0, dtype=np.int64)
)
logger.info(
"배수유역: 활성 셀 %d / %d (도로 셀 %d)", int(active_flat.sum()), spec.size, road.count
)
return FlowResult(
root=jump,
road_slot=road_slot,
active=active_flat.reshape(spec.n_rows, spec.n_cols),
path_length=path_length,
strength=strength,
)
def border_contact(active: np.ndarray) -> dict[str, bool]:
"""활성 셀이 격자 최외곽에 닿은 방향. 전부 False면 유역이 능선 안에서 닫힌 것이다."""
return {
"north": bool(active[0, :].any()),
"south": bool(active[-1, :].any()),
"west": bool(active[:, 0].any()),
"east": bool(active[:, -1].any()),
}
# ── 폴리곤화 ────────────────────────────────────────────────────────────────
def polygonize_labels(
spec: GridSpec,
labels: np.ndarray,
min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2,
) -> dict[int, Polygon | MultiPolygon]:
"""라벨 격자를 라벨별 폴리곤으로 바꾼다. 음수 라벨은 배경으로 무시한다."""
label_grid = np.ascontiguousarray(labels.reshape(spec.n_rows, spec.n_cols), dtype=np.int32)
valid_mask = label_grid >= 0
if not valid_mask.any():
return {}
collected: dict[int, list[Polygon]] = {}
for geometry, value in shapes(
label_grid, mask=valid_mask, transform=grid_transform(spec), connectivity=4
):
polygon = shape(geometry)
if polygon.is_empty or polygon.area < min_area_m2:
continue
collected.setdefault(int(value), []).append(polygon)
merged: dict[int, Polygon | MultiPolygon] = {}
for label, parts in collected.items():
union = unary_union(parts)
if union.is_empty:
continue
simplified = union.simplify(DRAINAGE_POLYGON_SIMPLIFY_M, preserve_topology=True)
merged[label] = simplified if not simplified.is_empty else union
return merged
def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]:
"""폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다."""
if geometry.is_empty:
return []
if geometry.geom_type == "MultiPolygon":
geometry = max(geometry.geoms, key=lambda part: part.area)
return [(float(x), float(y)) for x, y in geometry.exterior.coords]
def outer_boundary(
spec: GridSpec, active: np.ndarray, min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2
) -> Polygon | MultiPolygon | None:
"""활성 셀 전체의 외곽 = 2차 전체 배수유역 경계.
비활성 셀이 격자 최외곽에 띠로 완성되면 활성 영역이 그 안에 닫힌다. 그 닫힌 영역의
바깥선이 곧 분수령이므로 능선을 따로 그릴 필요가 없다.
"""
labels = np.where(active.reshape(-1), 0, -1).astype(np.int32)
polygons = polygonize_labels(spec, labels, min_area_m2)
return polygons.get(0)