Files
Aislo/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py
T

172 lines
7.3 KiB
Python

"""메인 배수유역 내부 세분화 — 분할선으로 폴리곤을 쪼갠다 (2026-07-30).
불변 조건(사용자 지시): 전체(메인) 배수유역 경계는 절대 변경하지 않는다.
세분화는 확정된 메인 유역 폴리곤을 관 사이 분할선(물갈림 고개에서 오르는
능선 근사선)으로 **내부에서만** 쪼개는 방식이다 — 외곽 재추적 금지.
따라서 세부유역은 서로 배타적이고 합집합은 항상 메인 유역과 동일하다.
"""
from __future__ import annotations
import logging
import math
from shapely.geometry import LineString, Point, Polygon
from shapely.ops import split as shapely_split
from shapely.ops import substring, unary_union
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import DividerStep
logger = logging.getLogger(__name__)
# 분할 절단선 연장: 도로 하류측(m)과 능선 너머(폴리곤 대각선 배수).
CUT_ROAD_TAIL_M = 40.0
CUT_RIDGE_EXTEND_RATIO = 1.5
def _cut_line(
road_point: Point,
steps: list[DividerStep],
road_line: LineString,
main_polygon: Polygon,
) -> LineString | None:
"""분할선 스텝을 절단선으로 확장한다 — 도로 하류측과 능선 너머까지 관통.
split()은 절단선이 폴리곤 경계를 완전히 넘어야 동작하므로 양끝을 연장한다.
스텝이 없으면(등고선 공백) 도로 법선 직선으로 폴백한다.
"""
bounds = main_polygon.bounds
reach = CUT_RIDGE_EXTEND_RATIO * math.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1])
points = [(road_point.x, road_point.y)]
points.extend((step.point.x, step.point.y) for step in steps if step.geom_index >= 0)
if len(points) < 2:
# 폴백: 도로 접선의 법선 방향으로 폴리곤을 관통하는 직선.
t = road_line.project(road_point)
a = road_line.interpolate(max(0.0, t - 5.0))
b = road_line.interpolate(min(road_line.length, t + 5.0))
dx, dy = b.x - a.x, b.y - a.y
norm = math.hypot(dx, dy)
if norm < 1e-6:
return None
nx, ny = -dy / norm, dx / norm
head = (road_point.x + nx * reach, road_point.y + ny * reach)
tail = (road_point.x - nx * reach, road_point.y - ny * reach)
return LineString([tail, (road_point.x, road_point.y), head])
# 능선 너머 연장: 마지막 진행 방향 유지.
(px, py), (qx, qy) = points[-2], points[-1]
dx, dy = qx - px, qy - py
norm = math.hypot(dx, dy)
if norm >= 1e-6:
points.append((qx + dx / norm * reach, qy + dy / norm * reach))
# 도로 하류측 연장: 첫 스텝 → 도로점 방향을 그대로 지나쳐 내려간다.
(fx, fy) = points[1]
dx, dy = road_point.x - fx, road_point.y - fy
norm = math.hypot(dx, dy)
if norm >= 1e-6:
points.insert(
0,
(
road_point.x + dx / norm * CUT_ROAD_TAIL_M,
road_point.y + dy / norm * CUT_ROAD_TAIL_M,
),
)
return LineString(points)
def _interval_index(piece: Polygon, road_line: LineString, divide_ts: list[float]) -> int:
"""조각이 어느 관 구간(k)에 속하는지 — 구간 도로와 맞닿는 길이가 최대인 곳.
대표점 투영은 대형 계곡 조각에서 오판한다(상류로 길게 뻗은 조각의 대표점이
엉뚱한 구간에 떨어짐). 도로 접촉이 전혀 없는 조각만 대표점 투영으로 폴백.
"""
strip = piece.buffer(1.0)
best_k, best_length = -1, 0.0
for k in range(len(divide_ts) - 1):
segment = substring(road_line, divide_ts[k], divide_ts[k + 1])
if segment.is_empty:
continue
length = segment.intersection(strip).length
if length > best_length:
best_k, best_length = k, length
if best_k >= 0:
return best_k
t = road_line.project(piece.representative_point())
for k in range(len(divide_ts) - 1):
if divide_ts[k] <= t <= divide_ts[k + 1]:
return k
return 0 if t < divide_ts[0] else len(divide_ts) - 2
def subdivide_main_polygon(
main_polygon: Polygon,
divide_points: list[Point],
dividers: list[list[DividerStep]],
road_line: LineString,
) -> list[Polygon | None]:
"""메인 유역 폴리곤을 내부 분할선으로 쪼개 관 구간별 조각을 돌려준다.
반환 길이 = 관 개수(구간 수). 조각이 없는 구간은 None.
split() 기반이므로 조각들은 배타적이고 합집합 == 메인 폴리곤이 보장된다.
구간에 여러 조각이 잡히면(절단선 재진입) 모두 합쳐 가장 큰 폴리곤을 쓴다.
"""
interval_count = len(divide_points) - 1
if interval_count <= 1:
return [main_polygon]
pieces: list[Polygon] = [main_polygon]
for position in range(1, interval_count):
cut = _cut_line(divide_points[position], dividers[position], road_line, main_polygon)
if cut is None:
logger.warning("분할 절단선 생성 실패(구간 %d) — 해당 분할을 건너뜁니다.", position)
continue
next_pieces: list[Polygon] = []
for piece in pieces:
try:
parts = shapely_split(piece, cut)
except Exception: # noqa: BLE001 - 절단 실패 시 조각 유지
next_pieces.append(piece)
continue
split_parts = [
part
for part in getattr(parts, "geoms", [parts])
if part.geom_type == "Polygon" and not part.is_empty
]
next_pieces.extend(split_parts if split_parts else [piece])
pieces = next_pieces
divide_ts = [road_line.project(point) for point in divide_points]
assigned: list[list[Polygon]] = [[] for _ in range(interval_count)]
for piece in pieces:
assigned[_interval_index(piece, road_line, divide_ts)].append(piece)
# 구간별 대표 조각 = 최대 조각과 그에 붙는 조각들. 비연결 잔여 조각은 버리지
# 않고(합집합 불변 조건) 맞닿는 인접 구간으로 재배정한다.
result: list[Polygon | None] = []
leftovers: list[Polygon] = []
for group in assigned:
merged = _merge_touching(group)
result.append(merged[0] if merged else None)
leftovers.extend(merged[1:])
for extra in leftovers:
for position in sorted(
range(interval_count),
key=lambda k: extra.distance(result[k]) if result[k] is not None else math.inf,
):
base = result[position]
if base is None or not extra.touches(base):
continue
candidate = base.union(extra).buffer(0)
if candidate.geom_type == "Polygon":
result[position] = candidate
break
else:
logger.warning("세분화 잔여 조각(%.0f m²)을 재배정하지 못해 제외합니다.", extra.area)
return result
def _merge_touching(group: list[Polygon]) -> list[Polygon]:
"""조각 묶음을 서로 맞닿는 것끼리 합쳐 면적 내림차순으로 돌려준다."""
if not group:
return []
merged = unary_union(group).buffer(0)
parts = list(merged.geoms) if merged.geom_type == "MultiPolygon" else [merged]
parts = [part for part in parts if part.geom_type == "Polygon" and not part.is_empty]
return sorted(parts, key=lambda part: part.area, reverse=True)