fix(B05): 세류선 겹침 셀을 판정 맨 앞에서 적색 확정

세류선 위인데 빨강이 아닌 셀이 남던 문제. 원인 3가지를 함께 고쳤다.

1. 사슬의 마지막 셀 누락 — zip(chain, chain[1:]) 이라 각 갈래의 끝 셀이
   세류 셀로 표시되지 않았다. burned[chain] 전체로 바꿨다.
2. 표고 없는 셀 제외 — valid(등고선 TIN 껍질 안) 조건 때문에 껍질 밖
   세류 셀이 새김에서 빠져 회색으로 남았다. 세류선은 확정 자료이므로
   표고 유무를 따지지 않는다.
3. 사슬이 그런 셀로 흘러들면 not valid[following] 에서 파랑으로 판정됐다.
   이미 색이 정해진 셀이면 따라가도록 조건을 풀었다.

판정 순서를 지시대로 바꿨다:
  0. 세류선과 겹치는 셀을 먼저 적색으로 못박는다
  1. 최외곽 셀에서 사슬 추적
  2. 도로/세류 셀에 닿으면 사슬 전체 적색, 못 닿으면 전체 미도달
  3. 이미 색이 정해진 셀을 만나면 그 색을 물려받고 끝
  4. 최외곽에 안 걸린 내부 셀을 따로 출발

표고 없는 세류 셀도 화살표가 나오도록 descent_azimuth 에서 forced 셀을
유효로 인정. 합류 지점 끝 셀을 미연결로 오탐하던 경고도 갈래 공유
횟수로 판정하게 수정.

합성 검증(등고선 범위 밖으로 나가는 지류 포함): 세류 셀 806개 전부
적색·전부 판정·화살표 무효 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 18:57:16 +09:00
co-authored by Claude Fable 5
parent 638af3ecc6
commit 12bee4bc82
2 changed files with 50 additions and 38 deletions
@@ -189,41 +189,46 @@ def burn_stream_flow(
것이 아니라 **그대로 따라야 한다**(2026-07-31 사용자 지시).
세류선 위 셀은 물길 방향의 다음 셀을 수신 셀로 삼는다. 그러면 세류선 셀은 물론,
세류선으로 흘러드는 사면 셀까지 전부 도로에 도달한다. 도로 셀은 흡수점이므로 건드리지 않는다.
세류선으로 흘러드는 사면 셀까지 전부 도로에 도달한다. 도로 셀은 흡수점이므로 수신 셀은
건드리지 않되, 세류 셀 목록에는 포함한다.
돌려주는 값: (흐름이 새겨진 지형, 새긴 셀 마스크).
**표고 유무를 따지지 않는다.** 세류선은 확정된 자료이므로 등고선 TIN 껍질 밖이라
표고가 없는 셀이라도 물이 지나간다는 사실은 변하지 않는다. 표고를 조건으로 걸면 그런
셀이 새김에서 빠져 파랑·회색으로 남는다.
돌려주는 값: (흐름이 새겨진 지형, 세류선이 지나는 셀 마스크).
"""
spec = terrain.spec
receiver = terrain.receiver.copy()
step_length = terrain.step_length.copy()
valid = terrain.valid.reshape(-1)
road_cells = road.mask.reshape(-1)
burned = np.zeros(spec.size, dtype=bool)
tails: list[int] = []
# 셀이 몇 갈래에 속하는지 — 하류 끝이 합류 지점인지 판단하는 근거.
visits = np.zeros(spec.size, dtype=np.int32)
for line in streams:
chain = _line_cell_chain(spec, line)
if len(chain) < 2:
if not chain:
continue
# 사슬의 **모든** 셀을 세류 셀로 표시한다 — 마지막 셀도 물길 위다.
burned[chain] = True
np.add.at(visits, np.unique(chain), 1)
for current, following in zip(chain, chain[1:]):
if not valid[current] or not valid[following] or road_cells[current]:
continue
if not _is_neighbour(spec, current, following):
if road_cells[current] or not _is_neighbour(spec, current, following):
continue
receiver[current] = following
step_length[current] = _cell_distance(spec, current, following)
burned[current] = True
tails.append(chain[-1])
# 하류 끝이 도로에도, 다른 세류 갈래지 않은 갈래만 진짜 문제다.
# 지류가 본류 중간에 합류하는 경우는 끝점이 본류 셀이므로 정상이다.
detached = sum(1 for tail in tails if not road_cells[tail] and not burned[tail])
# 하류 끝이 도로에도 닿지 않고 다른 갈래겹치지 않으면 그 갈래는 떠 있는 것이다.
# 지류가 본류 중간에 합류하는 경우는 끝 셀을 두 갈래가 공유하므로 정상이다.
detached = sum(1 for tail in tails if not road_cells[tail] and visits[tail] < 2)
if detached:
logger.warning(
"배수유역: 세류망 %d갈래의 하류 끝이 도로·다른 세류 어디에도 닿지 않습니다.",
detached,
"배수유역: 세류망 %d갈래의 하류 끝이 도로·다른 세류 어디에도 닿지 않습니다.", detached
)
logger.info("배수유역: 세류망 흐름 새김 %d (세류 %d갈래)", int(burned.sum()), len(streams))
logger.info("배수유역: 세류망 %d개 표시 (세류 %d갈래)", int(burned.sum()), len(streams))
return (
TerrainGrid(
spec=spec,
@@ -300,48 +305,49 @@ def classify_flow(
출발해 화살표가 가리키는 다음 셀, 그 셀이 가리키는 그다음 셀로 사슬처럼 이어 간다
(2026-07-31 사용자 지시).
사슬이 **도로 셀 또는 확정된 세류망 셀**에 닿으면 그 사슬 전체가 적색이다. 세류망은
이미 "도로를 건너 하류로 빠지는 물길"로 확정된 자료이므로, 물이 세류에 합류한 시점에
도로 도달이 결정된다. 사슬이 싱크에서 멈추거나 해석 영역 밖으로 나가면 전체가 미도달
(파랑 채움 + 백색 화살표)이다.
판정 순서는 다음과 같다(2026-07-31 사용자 지시):
**한 번 판정한 셀은 다시 분석하지 않는다** — 사슬이 이미 색이 정해진 셀을 만나면 그
셀의 색을 그대로 물려받고 끝낸다. 최외곽 추적으로 안 닿은 내부 셀은 그다음에 따
출발시킨다.
⓪ **세류선과 겹치는 셀을 먼저 적색으로 확정한다.** 세류망은 이미 "도로를 건너 하류로
빠지는 물길"로 확정된 자료다. 표고가 있든 없든 물이 지나간다는 사실은 변하지 않으므
추적 결과를 기다릴 이유가 없다.
① 최외곽 셀에서 출발해 사슬을 따라간다.
② 사슬이 도로 셀이나 세류 셀에 닿으면 사슬 전체가 적색, 싱크에서 멈추거나 해석 영역
밖으로 나가면 전체가 미도달(파랑 채움 + 백색 화살표)이다.
③ 이미 색이 정해진 셀을 만나면 **그 셀의 색을 그대로 물려받고** 끝낸다. 판정된 셀은
다시 분석하지 않는다.
④ 최외곽 추적에 안 걸린 내부 셀을 그다음에 따로 출발시킨다.
화살표 방위는 D8(8방위)이 아니라 지형 최급강하 32방위를 쓴다. `burned`(세류망을 따라
흐름을 새긴 셀)는 확정된 물길 방향을 그대로 쓴다.
화살표 방위는 D8(8방위)이 아니라 지형 최급강하 32방위를 쓴다. 세류 셀은 확정된 물길
방향을 그대로 쓴다.
"""
spec = terrain.spec
valid = terrain.valid.reshape(-1)
receiver = terrain.receiver
# 도로 셀과 세류망 셀 둘 다 "여기 닿으면 적색"인 종결점이다.
absorbing = road.mask.reshape(-1) & valid
if burned is not None:
absorbing = absorbing | (burned & valid)
stream_cells = np.zeros(spec.size, dtype=bool) if burned is None else burned
absorbing = (road.mask.reshape(-1) & valid) | stream_cells
direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned)
reaches = np.zeros(spec.size, dtype=bool)
# 0=미방문, 1=경로에 올라 있음, 2=판정 완료
state = np.zeros(spec.size, dtype=np.int8)
# ⓪ 세류선과 겹치는 셀을 먼저 적색으로 못박는다.
reaches[stream_cells] = True
state[stream_cells] = 2
stream_done = int(stream_cells.sum())
outer = np.flatnonzero(outermost_cells(terrain.valid))
remaining = np.flatnonzero(valid)
outer_done = _walk_from(outer, receiver, valid, absorbing, state, reaches)
interior_done = _walk_from(remaining, receiver, valid, absorbing, state, reaches)
analyzed = state == 2
if burned is not None:
stranded = int((burned & analyzed & ~reaches).sum())
if stranded:
logger.warning(
"배수유역: 세류망 새김 셀 %d개가 도로에 닿지 않습니다 — 하류 끝이 도로 셀과 "
"이어지지 않았는지 확인이 필요합니다.",
stranded,
)
logger.info(
"배수유역: 흐름 판정 %d셀 (최외곽 출발 %d / 내부 보충 %d) — 도로 도달 %d, 미도달 %d",
"배수유역: 흐름 판정 %d셀 (세류 선확정 %d / 최외곽 출발 %d / 내부 보충 %d) — "
"도로 도달 %d, 미도달 %d",
int(analyzed.sum()),
stream_done,
outer_done,
interior_done,
int((reaches & analyzed).sum()),
@@ -390,8 +396,12 @@ def _walk_from(
verdict = True # 도로 또는 세류망에 합류 — 여기서 하류로 빠진다
break
following = int(receiver[node])
if following == node or not valid[following]:
verdict = False # 싱크에 갇히거나 해석 영역 밖으로 빠짐
if following == node:
verdict = False # 싱크에 갇
break
if not valid[following] and state[following] != 2:
# 해석 영역 밖으로 빠짐. 단, 이미 색이 정해진 셀(세류 선확정 등)이면 따라간다.
verdict = False
break
node = following
for visited in path:
@@ -545,7 +545,9 @@ def descent_azimuth(
(np.abs(move_row) < 1e-12) & (np.abs(move_col) < 1e-12)
)
code = np.where(is_sink, AZIMUTH_SINK, code)
code = np.where(valid, code, AZIMUTH_INVALID)
# 세류망을 따라 흐름을 새긴 셀은 표고가 없어도 방향이 확정돼 있다.
known = valid if forced is None else (valid | forced.reshape(rows, cols))
code = np.where(known, code, AZIMUTH_INVALID)
return code.reshape(-1).astype(np.int16)