perf(520-6): B04 배수유역 확장 한 번 셈 · 노선 무관 흐름 방향장 저장본 · 등고선 점 구름 메모리 보관

- 흐름 방향장 열쇠 · 짓기에서 노선 최저점 하한 뺌 — 프로젝트마다 지형 저장본 한 종류 · 초안 [업데이트] 때 다시 짓지 않음
- expand_by_red_boundary 안 — 저장 큰 방향장 전체에서 도로 도달 한 번 판정 → 「도로로 오는 셀 + 확장 폭 띠 ∪ 1차 영역」으로 자름 · 큰 격자 가장자리에 닿으면 옛 회차 확장
- 끊기 확인 = 한 번 셈 앞 · [계측] 줄(한 번 셈 · 점 구름)
- build_contour_cloud — 하한 없는 구름을 피처 목록 · 자르는 범위 열쇠로 2 벌 보관 · 하한은 점 표고로 거름(옛 결과와 점 · 차례 같음)
- test_347_1 열쇠 시험 — 하한 인자 없어짐에 맞춰 고침
- 지킴 시험 test_520_6 — 합성 비탈에서 한 번 셈 = 회차 확장(도로로 오는 셀 · 도로 셀 귀속) · 하한 달라도 방향장 한 번만 지음
- 실데이터 대조(검증_sub2 · 검증_랩탑메인 · 검증_sub3) — 도로로 오는 셀 · 귀속 · 관별 면적 12 개 차이 0 · 초안 8.0~9.3 s → 0.8~2.5 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:18:05 +09:00
co-authored by Claude Opus 5.5
parent 80615cce2f
commit fb4beb99c9
6 changed files with 216 additions and 31 deletions
@@ -14,7 +14,11 @@
하강 방향장은 처음에 **넉넉한 큰 격자**로 한 번 짓고, 회차마다 그 격자에서 잘라 쓴다
(`DESCENT_SPARE_M` · 342-3). 격자가 커질 때마다 다시 짓던 것(8회 · 30 s)을 한 번으로 줄인다.
저장소(`descent_store` = 폴더 · 도엽 등고선 지문)를 주면 지은 것을 남기고, 덮는 저장본이 있으면
읽기만 한다(347-1).
읽기만 한다(347-1). 방향장은 노선 최저점 하한 없이 짓는다 — 노선이 바뀌어도 같은 저장본(520-6).
확장은 **큰 방향장 위 한 번 셈**으로 낸다(520-6) — 큰 격자 전체에서 도로 도달을 한 번 판정하고
「도로로 오는 셀 + 확장 폭 띠 ∪ 1차 영역」으로 잘라 돌려준다. 도로로 오는 셀이 큰 격자
가장자리에 닿으면 바깥에서 더 들어올 수 있으므로 위 회차 확장(넓혀 다시 짓기)으로 간다.
"""
from __future__ import annotations
@@ -27,6 +31,7 @@ from pathlib import Path
from typing import Any
import numpy as np
from scipy.ndimage import distance_transform_cdt
from shapely.geometry import LineString
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import (
@@ -91,7 +96,6 @@ def analyze_domain(
contour_features: list[dict[str, Any]],
route_line: LineString,
upstream_streams: list[LineString],
elevation_floor_m: float | None = None,
descent: ContourDescent | None = None,
) -> GridAnalysis | None:
"""주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다.
@@ -105,7 +109,7 @@ def analyze_domain(
marks = [("시작", time.perf_counter())]
fitted = crop_descent(descent, spec) if descent is not None else None
if fitted is None:
descent = build_contour_descent(spec, contour_features, None, elevation_floor_m)
descent = build_contour_descent(spec, contour_features)
else:
descent = fitted
marks.append(
@@ -149,7 +153,10 @@ def expand_by_red_boundary(
descent_store: tuple[Path, str] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> RedExpansion | None:
"""최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다."""
"""큰 방향장 위 한 번 셈 — 가장자리에 닿으면 최외곽 적색 셀 주변으로 넓히며 반복한다.
`elevation_floor_m` 은 받기만 한다 — 방향장은 노선과 무관(520-6).
"""
_expand_started = time.perf_counter()
band_cells = max(1, int(round(band_m / spec.cell_m)))
# 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를
@@ -162,7 +169,7 @@ def expand_by_red_boundary(
folder, contour_key = descent_store
store = (
folder,
descent_input_key(contour_key, contour_features, elevation_floor_m, spec.cell_m),
descent_input_key(contour_key, contour_features, spec.cell_m),
)
def source_for(grid: GridSpec, mask: np.ndarray) -> ContourDescent:
@@ -176,17 +183,21 @@ def expand_by_red_boundary(
saved.spec.n_cols,
)
return saved
built = build_contour_descent(
_pad_spec(grid, mask, spare_cells)[0], contour_features, None, elevation_floor_m
)
built = build_contour_descent(_pad_spec(grid, mask, spare_cells)[0], contour_features)
if store is not None:
save_descent(*store, built)
return built
source = source_for(spec, domain)
analysis = analyze_domain(
spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m, source
if should_stop is not None and should_stop():
raise AnalysisStopped
once = _expand_once(
source, spec, domain, contour_features, route_line, upstream_streams, band_cells
)
if once is not None:
logger.info("[계측] 확장 한 번 셈 %.1fs", time.perf_counter() - _expand_started)
return once
analysis = analyze_domain(spec, domain, contour_features, route_line, upstream_streams, source)
if analysis is None:
return None
@@ -235,7 +246,6 @@ def expand_by_red_boundary(
contour_features,
route_line,
upstream_streams,
elevation_floor_m,
descent=source,
)
if widened_analysis is None:
@@ -274,6 +284,75 @@ def expand_by_red_boundary(
return RedExpansion(analysis=analysis, rounds=rounds, closed=closed, added_cells=added)
def _expand_once(
source: ContourDescent,
spec: GridSpec,
domain: np.ndarray,
contour_features: list[dict[str, Any]],
route_line: LineString,
upstream_streams: list[LineString],
band_cells: int,
) -> RedExpansion | None:
"""큰 방향장 전체에서 도로 도달을 한 번 판정 → 「도로로 오는 셀 + 띠 ∪ 1차 영역」으로 자름.
도로로 오는 셀은 물길 아래쪽으로 닫혀 있다(도달 셀의 다음 셀도 도달) — 잘라도 그 물길이
영역 안에 다 있어 판정이 같다. 가장자리에 닿으면 None(회차 확장으로).
"""
started = time.perf_counter()
big = source.spec
whole = analyze_domain(
big,
np.ones((big.n_rows, big.n_cols), dtype=bool),
contour_features,
route_line,
upstream_streams,
descent=source,
)
if whole is None:
return None
reaches = whole.flow.reaches_road.reshape(big.n_rows, big.n_cols)
if (outermost_cells(np.ones_like(reaches)) & reaches).any():
logger.info("배수유역: 도로로 오는 셀이 큰 격자 가장자리에 닿아 넓혀 가며 다시 셉니다.")
return None
row0 = int(round((big.y_max - spec.y_max) / spec.cell_m))
col0 = int(round((spec.x_min - big.x_min) / spec.cell_m))
wanted = np.zeros_like(reaches)
wanted[row0 : row0 + spec.n_rows, col0 : col0 + spec.n_cols] = domain
if reaches.any():
wanted |= distance_transform_cdt(~reaches, metric="chessboard") <= band_cells
rows = np.flatnonzero(wanted.any(axis=1))
cols = np.flatnonzero(wanted.any(axis=0))
top, left = int(rows[0]), int(cols[0])
cut = GridSpec(
x_min=big.x_min + left * big.cell_m,
y_max=big.y_max - top * big.cell_m,
cell_m=big.cell_m,
n_rows=int(rows[-1]) - top + 1,
n_cols=int(cols[-1]) - left + 1,
)
cut_domain = wanted[top : top + cut.n_rows, left : left + cut.n_cols]
analysis = analyze_domain(
cut, cut_domain, contour_features, route_line, upstream_streams, descent=source
)
if analysis is None:
return None
added = int(cut_domain.sum()) - int(domain.sum())
logger.info(
"[계측] 한 번 셈 %.1fs · 큰 격자 %d×%d · 도로로 오는 셀 %d · 자른 격자 %d×%d · 셀 %d (+%d)",
time.perf_counter() - started,
big.n_rows,
big.n_cols,
int(reaches.sum()),
cut.n_rows,
cut.n_cols,
int(cut_domain.sum()),
added,
)
return RedExpansion(
analysis=analysis, rounds=1 if added > 0 else 0, closed=True, added_cells=added
)
def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray:
"""8이웃 팽창을 `steps`회 반복한다(정사각 커널이라 반경 = steps 셀)."""
rows, cols = mask.shape