Files
Aislo/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py
T
eomsangdonandClaude Opus 5 5c22c367a9 perf(계측): 격자 확장·하강 방향장 단계 마크 추가
PLAN 0-10. 1차 영역이 2.6초로 떨어진 뒤 남은 병목이 `expand_by_red_boundary` 102초라
그 안을 가르기 위한 계측. 로직 불변.

- 확장 회차별 시간·격자·셀 수·방향장 재사용 여부를 한 줄씩
- 격자 해석 1회: 하강 방향장(재사용/새로) / 지형 조립 / 도로 굽기 / 세류 새김 / 도달 판정
- 하강 방향장: 등고선 굽기 / 밴드 표고(EDT 1회) / 밴드별 하강거리(EDT N회) /
  위치에너지 조립 / 흐름 경로

실측 로그상 방향장이 회차마다 새로 만들어지고 밴드가 100단이라, 격자 전체 EDT 가
회차당 100회씩 도는 구조로 보임 — 그 가설을 수치로 가르려는 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:58:45 +09:00

276 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""등고선 기반 흐름 방향 — 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 세운다.
기존 방식(등고선 → TIN 보간 → 지표면 기울기 → D8)은 보간면이 만든 가짜 웅덩이와 평탄
삼각형 때문에 흐름이 중간에서 끊겼다. 실데이터에서 채움·평탄해소를 거치고도 싱크가 수천 개
남았고, 그 싱크 하나가 상류 유역 전체를 통째로 삼켰다.
여기서는 **보간면을 거치지 않는다.** 등고선을 격자에 직접 굽고, 셀마다 "내가 속한 등고
라인보다 한 단 낮은 등고 라인"이 어디인지를 찾아 그쪽으로 방향을 준다(2026-07-31 사용자 지시).
① 등고선을 격자에 굽는다 — 셀이 어느 표고의 라인 위인지 기록
② 셀마다 가장 가까운 등고 라인을 찾아 그 표고를 '밴드'로 삼는다
③ 표고가 높은 밴드부터 내려오며, 각 밴드에서 **한 단 낮은 등고 라인까지의 거리**를 잰다
④ 위치에너지 = 밴드 순위 × 큰 수 + 그 거리
⑤ 수신 셀 = 위치에너지가 더 낮은 8이웃 중 화살표 방향에 가장 가까운 셀
④의 위치에너지는 흐름을 따라 반드시 감소한다. 그래서 **순환도 웅덩이도 원리적으로 생기지
않는다** — 채움이나 평탄면 해소가 아예 필요 없다.
⑤ 덕분에 화면 화살표(32방위)와 실제 추적 경로가 항상 같은 방향을 가리킨다. 예전에는
화살표는 기울기, 추적은 D8이라 서로 어긋나 눈으로 검증할 수가 없었다.
"""
from __future__ import annotations
import logging
import time
import math
from dataclasses import dataclass
from typing import Any
import numpy as np
from rasterio.features import rasterize
from scipy.ndimage import distance_transform_edt
from shapely.geometry import shape
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
AZIMUTH_INVALID,
AZIMUTH_SINK,
AZIMUTH_STEPS,
GridSpec,
_feature_elevation,
grid_transform,
iter_linestrings,
)
from config.config_system import DRAINAGE_CONTOUR_MIN_LENGTH_M
logger = logging.getLogger(__name__)
# 8이웃 (행 증분, 열 증분).
_NEIGHBOURS = (
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
)
@dataclass
class ContourDescent:
"""등고선에서 직접 세운 흐름 방향 격자."""
spec: GridSpec
band_elevation: np.ndarray # (R, C) float32 — 셀이 속한 등고 라인 표고, 무효는 NaN
valid: np.ndarray # (R, C) bool — 방향을 세운 셀
receiver: np.ndarray # (R*C,) int32 — 다음 셀, 최하단 밴드는 자기 자신
step_length: np.ndarray # (R*C,) float32
azimuth: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효)
levels: list[float] # 사용된 등고 표고(내림차순)
def rasterize_contours(
spec: GridSpec,
contour_features: list[dict[str, Any]],
elevation_floor_m: float | None = None,
) -> tuple[np.ndarray, list[float]]:
"""등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN)."""
by_level: dict[float, list[Any]] = {}
for feature in contour_features:
geometry = feature.get("geometry")
if not geometry:
continue
elevation = _feature_elevation(feature.get("properties") or {})
if elevation is None:
continue
if elevation_floor_m is not None and elevation < elevation_floor_m:
continue
try:
parsed = shape(geometry)
except Exception: # noqa: BLE001
continue
for line in iter_linestrings(parsed):
if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M:
continue
by_level.setdefault(float(elevation), []).append(line)
burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32)
levels = sorted(by_level, reverse=True)
transform = grid_transform(spec)
for elevation in levels:
stamp = rasterize(
[(line, 1) for line in by_level[elevation]],
out_shape=(spec.n_rows, spec.n_cols),
transform=transform,
fill=0,
dtype="uint8",
all_touched=True,
).astype(bool)
# 낮은 표고부터 덮어써야 겹치는 셀이 낮은 라인으로 남는다 — 물은 낮은 쪽으로 간다.
burned[stamp] = elevation
logger.info(
"배수유역: 등고 라인 %d단(%.0f~%.0fm)을 격자에 굽어 %d셀",
len(levels),
levels[-1] if levels else 0.0,
levels[0] if levels else 0.0,
int(np.isfinite(burned).sum()),
)
return burned, levels
def build_contour_descent(
spec: GridSpec,
contour_features: list[dict[str, Any]],
domain: np.ndarray | None = None,
elevation_floor_m: float | None = None,
) -> ContourDescent:
"""등고선만으로 셀별 흐름 방향을 세운다. 보간면을 만들지 않는다."""
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
marks = [("시작", time.perf_counter())]
rows, cols = spec.n_rows, spec.n_cols
burned, levels = rasterize_contours(spec, contour_features, elevation_floor_m)
marks.append(("등고선 굽기(rasterize_contours)", time.perf_counter()))
empty = ContourDescent(
spec=spec,
band_elevation=np.full((rows, cols), np.nan, dtype=np.float32),
valid=np.zeros((rows, cols), dtype=bool),
receiver=np.arange(spec.size, dtype=np.int32),
step_length=np.zeros(spec.size, dtype=np.float32),
azimuth=np.full(spec.size, AZIMUTH_INVALID, dtype=np.int16),
levels=levels,
)
if len(levels) < 2:
logger.warning("배수유역: 등고 라인이 2단 미만이라 방향을 세울 수 없습니다.")
return empty
on_contour = np.isfinite(burned)
# ② 셀마다 가장 가까운 등고 라인의 표고 = 그 셀의 밴드.
_, (near_row, near_col) = distance_transform_edt(~on_contour, return_indices=True)
band_elevation = burned[near_row, near_col].astype(np.float32)
marks.append(("밴드 표고(EDT 1회)", time.perf_counter()))
inside = domain if domain is not None else np.ones((rows, cols), dtype=bool)
band_elevation = np.where(inside, band_elevation, np.nan)
# ③④ 높은 밴드부터 내려오며 한 단 낮은 등고 라인까지의 거리와 목표 셀을 구한다.
distance = np.full((rows, cols), np.inf, dtype=np.float32)
target_row = np.zeros((rows, cols), dtype=np.int32)
target_col = np.zeros((rows, cols), dtype=np.int32)
band_rank = np.full((rows, cols), -1, dtype=np.int32)
for rank, elevation in enumerate(levels[:-1]):
members = inside & (band_elevation == elevation)
if not members.any():
continue
lower = on_contour & (burned < elevation)
if not lower.any():
continue
step_distance, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True)
distance[members] = step_distance[members].astype(np.float32)
target_row[members] = step_row[members]
target_col[members] = step_col[members]
band_rank[members] = len(levels) - 1 - rank # 높을수록 큰 값
# 최하단 밴드는 더 내려갈 등고 라인이 없다. 무효로 빼지 않고 **정지 셀**로 남긴다 —
# 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다.
marks.append((f"밴드별 하강거리(EDT {len(levels) - 1}회)", time.perf_counter()))
lowest = inside & (band_elevation == levels[-1]) & (band_rank < 0)
if lowest.any():
distance[lowest] = 0.0
band_rank[lowest] = 0
valid = band_rank >= 0
if not valid.any():
logger.warning("배수유역: 하강 방향을 세운 셀이 없습니다.")
return empty
# 밴드가 하나 낮아지면 위치에너지가 반드시 떨어지도록 거리 최대치보다 큰 간격을 준다.
finite = distance[valid & np.isfinite(distance)]
span = (float(finite.max()) if finite.size else 1.0) + 2.0
distance[valid & ~np.isfinite(distance)] = 0.0
potential = np.where(valid, band_rank.astype(np.float64) * span + distance, np.inf)
marks.append(("위치에너지 조립", time.perf_counter()))
receiver, step_length, azimuth = _route_by_potential(
spec, potential, valid, target_row, target_col
)
marks.append(("흐름 경로(_route_by_potential)", time.perf_counter()))
_log_steps(f"하강 방향장({rows}×{cols})", marks)
logger.info(
"배수유역: 등고선 하강 방향 %d셀 (밴드 %d단), 최하단 정지 %d셀",
int(valid.sum()),
int(band_rank[valid].max() - band_rank[valid].min() + 1),
int((azimuth == AZIMUTH_SINK).sum()),
)
return ContourDescent(
spec=spec,
band_elevation=np.where(valid, band_elevation, np.nan).astype(np.float32),
valid=valid,
receiver=receiver,
step_length=step_length,
azimuth=azimuth,
levels=levels,
)
def _route_by_potential(
spec: GridSpec,
potential: np.ndarray,
valid: np.ndarray,
target_row: np.ndarray,
target_col: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""위치에너지가 낮은 8이웃 중 **화살표 방향에 가장 가까운** 셀을 수신 셀로 고른다.
화살표는 "한 단 낮은 등고 라인 쪽"을 가리키는 연속 방위이고, 수신 셀은 그 방위에 가장
가까운 이웃이다. 그래서 화면 화살표와 실제 추적 경로가 어긋나지 않는다.
위치에너지가 더 낮은 이웃만 후보로 두므로 순환이 생기지 않는다.
"""
rows, cols = spec.n_rows, spec.n_cols
grid_row, grid_col = np.meshgrid(np.arange(rows), np.arange(cols), indexing="ij")
# 목표(한 단 낮은 등고 라인 위의 셀)를 향하는 연속 방위.
aim_row = (target_row - grid_row).astype(np.float64)
aim_col = (target_col - grid_col).astype(np.float64)
aim_norm = np.hypot(aim_row, aim_col)
aim_norm[aim_norm == 0.0] = 1.0
aim_row /= aim_norm
aim_col /= aim_norm
padded = np.full((rows + 2, cols + 2), np.inf)
padded[1:-1, 1:-1] = potential
flat_index = np.arange(spec.size, dtype=np.int32).reshape(rows, cols)
padded_index = np.full((rows + 2, cols + 2), -1, dtype=np.int32)
padded_index[1:-1, 1:-1] = flat_index
best_score = np.full((rows, cols), -np.inf)
receiver = flat_index.copy()
step = np.zeros((rows, cols), dtype=np.float32)
centre = potential
for row_shift, col_shift in _NEIGHBOURS:
neighbour = padded[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
length = math.hypot(row_shift, col_shift)
# 방위 일치도(코사인 유사도)가 클수록 좋은 후보다.
score = (aim_row * row_shift + aim_col * col_shift) / length
better = valid & np.isfinite(neighbour) & (neighbour < centre) & (score > best_score)
if not better.any():
continue
best_score = np.where(better, score, best_score)
neighbour_index = padded_index[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
receiver = np.where(better, neighbour_index, receiver)
step = np.where(better, np.float32(length * spec.cell_m), step)
moved = receiver != flat_index
delta_row = (receiver // cols - flat_index // cols).astype(np.float64)
delta_col = (receiver % cols - flat_index % cols).astype(np.float64)
angle = np.arctan2(delta_row, delta_col)
code = np.rint(angle / (2.0 * math.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS
azimuth = np.where(moved, code, AZIMUTH_SINK)
azimuth = np.where(valid, azimuth, AZIMUTH_INVALID)
return receiver.reshape(-1), step.reshape(-1), azimuth.reshape(-1).astype(np.int16)