PLAN 0-10. 「등고선 굽기」가 확장 8회 합 19s 였는데, 실측해 보니 굽기 자체는 0.8~2.0s 이고 나머지는 **피처 4,200개를 회차마다 다시 `shape()` 로 푸는 값**이었음. 푸는 결과는 격자와 무관하므로 같은 목록·같은 하한이면 그대로 쓴다(목록 객체를 함께 들고 있어 id 가 다른 목록에 재사용되지 않음, 최근 2벌만 보관). 실측 — 같은 격자 연속 4회: 2.66 · 1.90 · 1.99 · 2.03s (2회차부터 파싱 재사용), 결과 배열은 4회 모두 동일. `build_contour_descent` 전체는 옛 코드 12.8s → 7.2s 이며 band_elevation·valid·receiver·step_length·azimuth·levels 전부 `np.array_equal` 동일. 안 한 것 — 격자가 커질 때 옛 굽기를 옮겨 붙이고 **테두리만** 굽는 안은 만들어 재 봤으나 `rasterize` 호출당 고정 비용 때문에 오히려 3.2s → 10.6s 로 느렸고, 창에 걸치는 선만 넘기도록 고쳐도 3.8s 로 손해였음(창 4개 × 166단). 그래서 걷어내고 파싱 캐시만 남김. `levels` 는 피처 전체에서 나오므로 격자와 무관하다는 것도 확인함. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
410 lines
18 KiB
Python
410 lines
18 KiB
Python
"""등고선 기반 흐름 방향 — 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 세운다.
|
||
|
||
기존 방식(등고선 → 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] # 사용된 등고 표고(내림차순)
|
||
|
||
|
||
_LINES_CACHE: list[tuple[Any, int, float | None, dict[float, list[Any]]]] = []
|
||
|
||
|
||
def _lines_by_level(
|
||
contour_features: list[dict[str, Any]], elevation_floor_m: float | None
|
||
) -> dict[float, list[Any]]:
|
||
"""등고선 피처를 표고별 선 묶음으로 푼다 — **격자와 무관**하므로 한 번만 푼다.
|
||
|
||
확장 회차마다 다시 부르는데 피처 4,200개를 매번 `shape()` 로 푸는 비용이 그대로
|
||
붙었다. 같은 목록·같은 하한이면 그대로 돌려준다(목록 객체를 함께 들고 있어 id 가
|
||
다른 목록에 재사용되지 않는다).
|
||
"""
|
||
for holder, count, floor, cached in _LINES_CACHE:
|
||
if (
|
||
holder is contour_features
|
||
and count == len(contour_features)
|
||
and floor == elevation_floor_m
|
||
):
|
||
return cached
|
||
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)
|
||
_LINES_CACHE.append((contour_features, len(contour_features), elevation_floor_m, by_level))
|
||
del _LINES_CACHE[:-2]
|
||
return by_level
|
||
|
||
|
||
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 = _lines_by_level(contour_features, elevation_floor_m)
|
||
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
|
||
|
||
|
||
# 밴드별 하강거리 EDT 를 **쓸 자리 둘레**에서만 돌리기 위한 값들.
|
||
#
|
||
# 왜(2026-09-07 실측) — 확장 회차마다 방향장을 다시 만드는데, 그 안의 밴드별 EDT 가
|
||
# 격자 전체를 단계마다 훑어 확장 95.8s 중 50.4s 를 썼다. 정작 쓰는 값은 그 단계의
|
||
# 밴드 셀뿐이다. 다만 `analyze_domain` 이 `domain` 을 안 넘겨 밴드가 **격자 전역에
|
||
# 흩어지므로** 바운딩박스 하나로는 안 좁아진다. 그래서 격자를 타일로 나눠 **셀이 있는
|
||
# 타일만** 그 둘레 `margin` 까지 잘라 EDT 를 돌린다.
|
||
#
|
||
# 창 밖에 더 가까운 등고선이 있을 수 있으면(창 안 최대 거리가 여유에 닿거나 창에 낮은
|
||
# 라인이 없으면) **그 타일만** 창을 4배로 넓혀 다시 잰다 — 근사가 아니라 같은 값을 싸게
|
||
# 구하는 것. 단계 전체를 격자 전체로 되돌리면 이득이 사라진다(그 방식일 때 폴백 25/91).
|
||
#
|
||
# 고른 근거(745×1035 격자·166단 중 EDT 도는 91단, 전체격자 방식 5.2s):
|
||
# **타일 128 여유 32 → 2.1s(넓힘 15)** 타일 128 여유 24 → 1.9s(넓힘 52)
|
||
# 타일 128 여유 48 → 2.5s(넓힘 7) 타일 256 여유 32 → 2.7s(넓힘 15)
|
||
# 타일 512 여유 32 → 4.5s(넓힘 15)
|
||
# 여유 24 가 0.2s 빠르지만 넓힘이 52회로 지형에 예민해 32 로 뒀다. 한 단 아래 등고선은
|
||
# 주곡선 5m·셀 1m 에서 대개 수십 칸 안이다. 넓힘 횟수는 계측 줄에 찍힌다.
|
||
DESCENT_WINDOW_MARGIN_CELLS = 32
|
||
DESCENT_TILE_CELLS = 128
|
||
|
||
|
||
def _distance_to_lower(
|
||
members: np.ndarray,
|
||
lower: np.ndarray,
|
||
margin: int | None = None,
|
||
tile: int | None = None,
|
||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, int]:
|
||
"""`members` 셀에서 한 단 낮은 등고 라인까지의 거리와 목표 셀(**전체 격자 좌표**).
|
||
|
||
네 번째 값은 창을 넓혀 다시 잰 타일 수다(0 = 전부 첫 창에서 끝남). 반환 순서는
|
||
`members` 의 행우선 순서 — 호출부가 `distance[members] = ...` 로 그대로 넣는다.
|
||
"""
|
||
margin = DESCENT_WINDOW_MARGIN_CELLS if margin is None else margin
|
||
tile = DESCENT_TILE_CELLS if tile is None else tile
|
||
rows, cols = members.shape
|
||
out_distance = np.zeros((rows, cols), dtype=np.float64)
|
||
out_row = np.zeros((rows, cols), dtype=np.int32)
|
||
out_col = np.zeros((rows, cols), dtype=np.int32)
|
||
widened = 0
|
||
|
||
for row_start in range(0, rows, tile):
|
||
row_stop = min(rows, row_start + tile)
|
||
for col_start in range(0, cols, tile):
|
||
col_stop = min(cols, col_start + tile)
|
||
tile_members = members[row_start:row_stop, col_start:col_stop]
|
||
if not tile_members.any():
|
||
continue
|
||
# 첫 창에서 안 닿으면 **그 타일만** 창을 넓혀 다시 잰다 — 단계 전체를 격자
|
||
# 전체로 되돌리면 이득이 사라진다(실측 폴백 25/91).
|
||
reach = margin
|
||
attempt = 0
|
||
while True:
|
||
win_row0 = max(0, row_start - reach)
|
||
win_row1 = min(rows, row_stop + reach)
|
||
win_col0 = max(0, col_start - reach)
|
||
win_col1 = min(cols, col_stop + reach)
|
||
whole = win_row0 == 0 and win_col0 == 0 and win_row1 == rows and win_col1 == cols
|
||
window_lower = lower[win_row0:win_row1, win_col0:win_col1]
|
||
if not window_lower.any():
|
||
if whole:
|
||
return _full_distance_to_lower(members, lower)
|
||
reach *= 4
|
||
attempt += 1
|
||
widened += 1
|
||
continue
|
||
step, (step_row, step_col) = distance_transform_edt(
|
||
~window_lower, return_indices=True
|
||
)
|
||
in_window = np.zeros(window_lower.shape, dtype=bool)
|
||
in_window[
|
||
row_start - win_row0 : row_stop - win_row0,
|
||
col_start - win_col0 : col_stop - win_col0,
|
||
] = tile_members
|
||
hit_rows, hit_cols = np.nonzero(in_window)
|
||
values = step[hit_rows, hit_cols]
|
||
# 타일 둘레로 `reach` 를 뒀으므로, 거리가 그보다 짧으면 창 밖에 더
|
||
# 가까운 것은 있을 수 없다. 닿으면 창을 넓혀 다시 잰다.
|
||
if not whole and values.size and float(values.max()) >= reach:
|
||
reach *= 4
|
||
attempt += 1
|
||
widened += 1
|
||
continue
|
||
break
|
||
global_rows = hit_rows + win_row0
|
||
global_cols = hit_cols + win_col0
|
||
out_distance[global_rows, global_cols] = values
|
||
out_row[global_rows, global_cols] = step_row[hit_rows, hit_cols] + win_row0
|
||
out_col[global_rows, global_cols] = step_col[hit_rows, hit_cols] + win_col0
|
||
|
||
return out_distance[members], out_row[members], out_col[members], widened
|
||
|
||
|
||
def _full_distance_to_lower(
|
||
members: np.ndarray, lower: np.ndarray
|
||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, bool]:
|
||
"""격자 전체 EDT — 창으로 못 믿을 때만 쓰는 폴백."""
|
||
step, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True)
|
||
return step[members], step_row[members], step_col[members], True
|
||
|
||
|
||
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)
|
||
window_widened = 0
|
||
edt_steps = 0
|
||
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, widened = _distance_to_lower(members, lower)
|
||
window_widened += widened
|
||
edt_steps += 1
|
||
distance[members] = step_distance.astype(np.float32)
|
||
target_row[members] = step_row
|
||
target_col[members] = step_col
|
||
band_rank[members] = len(levels) - 1 - rank # 높을수록 큰 값
|
||
|
||
# 최하단 밴드는 더 내려갈 등고 라인이 없다. 무효로 빼지 않고 **정지 셀**로 남긴다 —
|
||
# 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다.
|
||
marks.append(
|
||
(
|
||
f"밴드별 하강거리(EDT {edt_steps}단 · 창 넓힘 {window_widened}회)",
|
||
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)
|