perf(B04): 표고 보간 삼각망을 물어볼 자리 둘레로 좁힘

노선 [확인] 이 293초였고 그중 285초가 배수유역 분석, 다시 그중 256초가
1차 영역 만들기였음. 범인은 ElevationSampler 하나임 —
LinearNDInterpolator 가 들로네 삼각망을 첫 호출 때 만드는데 등고선 구름을
통째로(용화 317,348점) 넘겨 그 한 번이 256~1,481초로 뛰었음(편차 큼).

표고를 묻는 자리는 도로 교차 노드에 닿은 세류 조각 몇 개뿐임. 그 조각들만
먼저 골라 그 둘레 100m 안 등고선만 남기고 삼각망을 만듦. 여유 100m 는
등고선 재샘플 간격(5m)의 스무 배라 물어볼 점은 언제나 볼록껍질 안쪽이고
보간값이 그대로임.

실측(용화 5601e828) — 보간 구름 317,348 → 4,367점,
1차 영역 256.6s → 43.8s(측정 중 기기 부하가 9배였음, 준비 5.1s → 45.7s).
결과 완전 동일 — 1차 영역 266,697㎡ · 격자 545×835 · 268,456셀 ·
노선 이탈 0m/1079m · 상류망 1 / 하류망 109 / 미연결 1153.

pytest 409 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 04:37:00 +09:00
co-authored by Claude Opus 5
parent 4da04929c4
commit 9317ad3175
@@ -90,14 +90,25 @@ def split_streams_at_road(
node_edges.setdefault(head, []).append(index) node_edges.setdefault(head, []).append(index)
node_edges.setdefault(tail, []).append(index) node_edges.setdefault(tail, []).append(index)
sampler = ElevationSampler(cloud) # 표고를 묻는 조각은 **도로 교차 노드에 닿은 것뿐**이다(나머지는 확산으로만 정해진다).
# 그 조각들만 먼저 골라 두고, 그 둘레로 삼각망을 좁힌다 — 위 클래스 주석 참조.
seed_pieces = [
(index, piece, touching)
for index, piece in enumerate(pieces)
if (touching := [node for node in ends[index] if node in crossing_nodes])
]
focus_xy = (
np.concatenate(
[np.asarray(piece.coords, dtype=np.float64)[:, :2] for _index, piece, _n in seed_pieces]
)
if seed_pieces
else None
)
sampler = ElevationSampler(cloud, focus_xy=focus_xy)
# 씨앗 조각 → 그 조각의 하류쪽 끝점(= 도로 교차 노드). 이 값이 물 흐름 방향의 기준이 된다. # 씨앗 조각 → 그 조각의 하류쪽 끝점(= 도로 교차 노드). 이 값이 물 흐름 방향의 기준이 된다.
upper_seeds: dict[int, tuple[float, float]] = {} upper_seeds: dict[int, tuple[float, float]] = {}
lower_seeds: dict[int, tuple[float, float]] = {} lower_seeds: dict[int, tuple[float, float]] = {}
for index, piece in enumerate(pieces): for index, piece, touching in seed_pieces:
touching = [node for node in ends[index] if node in crossing_nodes]
if not touching:
continue
heights = sampler.at(np.array(touching, dtype=np.float64)) heights = sampler.at(np.array(touching, dtype=np.float64))
crossing_node = touching[int(np.argmin(heights))] crossing_node = touching[int(np.argmin(heights))]
if _mean_elevation(piece, sampler) > float(np.min(heights)): if _mean_elevation(piece, sampler) > float(np.min(heights)):
@@ -210,16 +221,42 @@ class ElevationSampler:
최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가 최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가
실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을 실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을
1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다. 1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다.
**`focus_xy` 를 꼭 줄 것 — 노선 [확인] 이 3분 반이던 원인이 여기였다**(2026-09-07 실측).
`LinearNDInterpolator` 는 들로네 삼각망을 **첫 호출 때** 만드는데, 등고선 구름을 통째로
(용화 317,348점) 넘기면 그 한 번이 **256~1,481초**로 뛴다(같은 입력에도 편차가 큼).
쓰는 곳은 세류 조각 위 몇 점뿐이므로, 물어볼 자리 둘레만 남기면 삼각망이 작아져
**1초 아래**가 된다. 남기는 여유(`margin_m`)는 등고선 재샘플 간격(5m)의 스무 배라
물어볼 점은 언제나 볼록껍질 한참 안쪽에 있고 보간값도 그대로다.
""" """
def __init__(self, cloud: ContourCloud) -> None: def __init__(
self,
cloud: ContourCloud,
focus_xy: np.ndarray | None = None,
margin_m: float = 100.0,
) -> None:
self._z = cloud.z self._z = cloud.z
if cloud.is_empty: if cloud.is_empty:
self._interpolator = None self._interpolator = None
self._tree = None self._tree = None
return return
self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z) xy = np.asarray(cloud.xy, dtype=np.float64)
self._tree = cKDTree(cloud.xy) z = np.asarray(cloud.z, dtype=np.float64)
if focus_xy is not None and len(focus_xy):
focus = np.asarray(focus_xy, dtype=np.float64)
near = cKDTree(focus).query(xy, workers=-1)[0] <= margin_m
if near.any():
xy, z = xy[near], z[near]
logger.info(
"배수유역: 표고 보간 구름을 물어볼 자리 %.0fm 안으로 줄임 — %d%d",
margin_m,
len(cloud.z),
len(z),
)
self._z = z
self._interpolator = LinearNDInterpolator(xy, z)
self._tree = cKDTree(xy)
def at(self, xy: np.ndarray) -> np.ndarray: def at(self, xy: np.ndarray) -> np.ndarray:
"""(N, 2) 좌표의 표고 (N,).""" """(N, 2) 좌표의 표고 (N,)."""