diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 2c36b24d..579e79bc 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -327,6 +327,15 @@ export interface DrainagePrimaryRegion { /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ row_spans: Array<[number, number, number]>; }; + /** 최외곽 적색 셀 주변 확장 결과. */ + expansion: { + rounds: number; + /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ + closed: boolean; + added_cells: number; + /** 확장 전(1차 영역) 셀 수. */ + initial_cells: number; + }; /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ flow: { encoding: "base64-uint8"; diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index 57841d48..27382d51 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -35,20 +35,15 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( find_stream_crossings, is_uphill_at, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ( - ContourDescent, - build_contour_descent, -) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ContourDescent +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Expand import expand_by_red_boundary from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( FlowClassification, RoadRaster, - burn_stream_flow, - classify_flow, expand_until_closed, largest_ring, outer_boundary, polygonize_labels, - rasterize_road, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, @@ -211,13 +206,22 @@ def preview_primary_region( @dataclass class StagePreview: - """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다.""" + """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다. + + 확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이 + 아니라 여기 `spec`/`domain`을 봐야 한다. + """ region: PrimaryRegion + spec: GridSpec | None = None + domain: np.ndarray | None = None terrain: TerrainGrid | None = None road: RoadRaster | None = None flow: FlowClassification | None = None descent: ContourDescent | None = None + expand_rounds: int = 0 + expand_closed: bool = False + expand_added_cells: int = 0 def preview_stages( @@ -227,12 +231,14 @@ def preview_stages( ) -> StagePreview | None: """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. - 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정. + 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정 + ⑤ **최외곽 적색 셀 주변 확장**. ③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체). - **격자 확장은 넣지 않는다** — 다음 검증 단계다. + ⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에 + 적색이 없으면** 멈춘다. """ if len(vertices) < 2: return None @@ -241,29 +247,39 @@ def preview_stages( if region is None: return None - spec = region.spec started = time.perf_counter() floor = route_elevation_floor([vertex.z for vertex in vertices]) - descent = build_contour_descent(spec, contour_features, region.cell_mask, floor) - if not descent.valid.any(): + expansion = expand_by_red_boundary( + region.spec, + region.cell_mask, + contour_features, + route_line, + region.split.upstream, + floor, + ) + if expansion is None: logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") return StagePreview(region=region) - # 이후 단계(유역 제원)가 표고를 쓰므로 밴드 표고를 지형 격자로 함께 들고 간다. - terrain = TerrainGrid( - spec=spec, - elevation=descent.band_elevation, - valid=descent.valid, - receiver=descent.receiver, - step_length=descent.step_length, + analysis = expansion.analysis + logger.info( + "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개)", + time.perf_counter() - started, + expansion.rounds, + analysis.spec.size, + ) + return StagePreview( + region=region, + spec=analysis.spec, + domain=analysis.domain, + terrain=analysis.terrain, + road=analysis.road, + flow=analysis.flow, + descent=analysis.descent, + expand_rounds=expansion.rounds, + expand_closed=expansion.closed, + expand_added_cells=expansion.added_cells, ) - road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) - # 확정된 상류 세류망을 따라 흐름을 새긴다 — 세류선 위 셀과 그리로 흘러드는 셀은 - # 반드시 도로에 도달해야 한다. - terrain, burned = burn_stream_flow(terrain, road, region.split.upstream) - flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth) - logger.info("배수유역: 흐름 판정 %.1fs (셀 %d개)", time.perf_counter() - started, spec.size) - return StagePreview(region=region, terrain=terrain, road=road, flow=flow, descent=descent) # ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py new file mode 100644 index 00000000..a4de669f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py @@ -0,0 +1,221 @@ +"""해석 영역 확장 — 최외곽의 **적색 셀** 주변으로 넓히며 다시 분석한다. + +적색 셀이 해석 영역 최외곽에 있다는 것은 그 바깥에서 물이 더 흘러 들어온다는 뜻이다. +거기서 멈추면 유역이 잘린다. 반대로 최외곽이 전부 파랑이면 그 바깥 물은 도로로 오지 +않으므로 더 볼 필요가 없다. + + ① 현재 해석 영역의 최외곽 셀 중 **적색**인 것을 찾는다 + ② 그 주변으로 한 겹(설정 폭) 넓힌다 + ③ 넓힌 영역으로 흐름 방향·색을 다시 분석한다 + ④ **새로 추가된 셀에 적색이 하나도 없으면 종료** (2026-07-31 사용자 지시) + +격자 bbox에 닿으면 격자 자체도 셀 정수배로 넓힌다 — 도로 시작점 기준 격자점은 유지된다. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +import numpy as np +from shapely.geometry import LineString + +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ( + ContourDescent, + build_contour_descent, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( + FlowClassification, + RoadRaster, + burn_stream_flow, + classify_flow, + outermost_cells, + rasterize_road, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid +from config.config_system import ( + DRAINAGE_RED_EXPAND_BAND_M, + DRAINAGE_RED_EXPAND_MAX_ROUNDS, + DRAINAGE_ROAD_WIDTH_M, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class GridAnalysis: + """한 회차 분석 결과 — 격자·해석 영역·방향·색까지 한 묶음.""" + + spec: GridSpec + domain: np.ndarray # (R, C) bool — 해석 대상 셀 + descent: ContourDescent + terrain: TerrainGrid + road: RoadRaster + flow: FlowClassification + + +@dataclass +class RedExpansion: + """확장 루프 결과.""" + + analysis: GridAnalysis + rounds: int # 실제로 넓힌 횟수 (0 = 처음부터 최외곽에 적색이 없었음) + closed: bool # 새로 추가한 셀에 적색이 없어 스스로 멈췄는가 + added_cells: int # 확장으로 늘어난 셀 수 + + +def analyze_domain( + spec: GridSpec, + domain: np.ndarray, + contour_features: list[dict[str, Any]], + route_line: LineString, + upstream_streams: list[LineString], + elevation_floor_m: float | None = None, +) -> GridAnalysis | None: + """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다.""" + descent = build_contour_descent(spec, contour_features, domain, elevation_floor_m) + if not descent.valid.any(): + return None + terrain = TerrainGrid( + spec=spec, + elevation=descent.band_elevation, + valid=descent.valid, + receiver=descent.receiver, + step_length=descent.step_length, + ) + road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) + terrain, burned = burn_stream_flow(terrain, road, upstream_streams) + flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth) + return GridAnalysis( + spec=spec, domain=domain, descent=descent, terrain=terrain, road=road, flow=flow + ) + + +def expand_by_red_boundary( + spec: GridSpec, + domain: np.ndarray, + contour_features: list[dict[str, Any]], + route_line: LineString, + upstream_streams: list[LineString], + elevation_floor_m: float | None = None, + band_m: float = DRAINAGE_RED_EXPAND_BAND_M, + max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS, +) -> RedExpansion | None: + """최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다.""" + analysis = analyze_domain( + spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m + ) + if analysis is None: + return None + + band_cells = max(1, int(round(band_m / spec.cell_m))) + started_cells = int(domain.sum()) + rounds = 0 + closed = False + for attempt in range(max_rounds): + current = analysis.spec + reaches = analysis.flow.reaches_road.reshape(current.n_rows, current.n_cols) + rim_red = outermost_cells(analysis.domain) & reaches + if not rim_red.any(): + closed = True # 최외곽이 전부 파랑 — 바깥 물은 도로로 오지 않는다 + break + + grown_spec, grown_domain, grown_rim = _grow_for_rim( + current, analysis.domain, rim_red, band_cells + ) + widened = grown_domain | _dilate_by(grown_rim, band_cells) + added_mask = widened & ~grown_domain + if not added_mask.any(): + closed = True + break + + logger.info( + "배수유역: %d회차 확장 — 최외곽 적색 %d셀 주변 %.0fm, 셀 %d개 추가", + attempt + 1, + int(rim_red.sum()), + band_m, + int(added_mask.sum()), + ) + widened_analysis = analyze_domain( + grown_spec, widened, contour_features, route_line, upstream_streams, elevation_floor_m + ) + if widened_analysis is None: + break + analysis = widened_analysis + rounds += 1 + + added_reaches = widened_analysis.flow.reaches_road.reshape( + grown_spec.n_rows, grown_spec.n_cols + ) + if not (added_mask & added_reaches).any(): + closed = True # 새로 추가한 셀에 적색이 없다 — 여기까지가 유역이다 + logger.info("배수유역: 새로 추가한 셀에 적색이 없어 확장을 멈춥니다.") + break + else: + logger.warning("배수유역: 확장 상한(%d회)에 도달했습니다.", max_rounds) + + added = int(analysis.domain.sum()) - started_cells + logger.info( + "배수유역: 확장 %d회, 셀 %d → %d (+%d), %s", + rounds, + started_cells, + int(analysis.domain.sum()), + added, + "닫힘" if closed else "미닫힘", + ) + return RedExpansion(analysis=analysis, rounds=rounds, closed=closed, added_cells=added) + + +def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray: + """8이웃 팽창을 `steps`회 반복한다(정사각 커널이라 반경 = steps 셀).""" + rows, cols = mask.shape + result = mask + for _ in range(steps): + padded = np.zeros((rows + 2, cols + 2), dtype=bool) + padded[1:-1, 1:-1] = result + grown = np.zeros_like(result) + for row_shift in (0, 1, 2): + for col_shift in (0, 1, 2): + grown |= padded[row_shift : row_shift + rows, col_shift : col_shift + cols] + result = grown + return result + + +def _grow_for_rim( + spec: GridSpec, domain: np.ndarray, rim: np.ndarray, band_cells: int +) -> tuple[GridSpec, np.ndarray, np.ndarray]: + """적색 최외곽이 격자 bbox에 닿았으면 그 방향으로 격자를 넓히고 마스크를 옮겨 담는다. + + 격자는 셀 정수배로만 넓히므로 도로 시작점 기준 격자점이 그대로 유지된다. + """ + north = band_cells if rim[0, :].any() else 0 + south = band_cells if rim[-1, :].any() else 0 + west = band_cells if rim[:, 0].any() else 0 + east = band_cells if rim[:, -1].any() else 0 + if not (north or south or west or east): + return spec, domain, rim + + grown = GridSpec( + x_min=spec.x_min - west * spec.cell_m, + y_max=spec.y_max + north * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + north + south, + n_cols=spec.n_cols + west + east, + ) + new_domain = np.zeros((grown.n_rows, grown.n_cols), dtype=bool) + new_rim = np.zeros_like(new_domain) + new_domain[north : north + spec.n_rows, west : west + spec.n_cols] = domain + new_rim[north : north + spec.n_rows, west : west + spec.n_cols] = rim + logger.info( + "배수유역: 격자 확대 %d×%d → %d×%d (북%d 남%d 서%d 동%d 셀)", + spec.n_rows, + spec.n_cols, + grown.n_rows, + grown.n_cols, + north, + south, + west, + east, + ) + return grown, new_domain, new_rim diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 789d8fda..c15f44b7 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -216,7 +216,9 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: ) region = preview.region to_lonlat = prepared["to_lonlat"] - spec = region.spec + # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. + spec = preview.spec or region.spec + domain = preview.domain if preview.domain is not None else region.cell_mask payload = { "status": "success", "project_id": str(project_id), @@ -235,21 +237,28 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "cell_m": spec.cell_m, "rows": spec.n_rows, "cols": spec.n_cols, - # bbox 전체 셀 수와, 1차 영역에 걸쳐 실제로 생성된 셀 수. + # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). "bbox_cells": spec.size, - "cells": region.active_cells, + "cells": int(domain.sum()) if domain is not None else 0, "width_m": round(spec.n_cols * spec.cell_m, 1), "height_m": round(spec.n_rows * spec.cell_m, 1), # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. - "row_spans": [list(span) for span in mask_row_spans(region.cell_mask)] - if region.cell_mask is not None + "row_spans": [list(span) for span in mask_row_spans(domain)] + if domain is not None else [], }, + # 최외곽 적색 셀 주변 확장 결과. + "expansion": { + "rounds": preview.expand_rounds, + "closed": preview.expand_closed, + "added_cells": preview.expand_added_cells, + "initial_cells": region.active_cells, + }, # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. - "flow": _flow_payload(preview, region), + "flow": _flow_payload(preview, domain), } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. payload["saved_to"] = write_stage( @@ -275,19 +284,24 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: }, to_lonlat, ) - _write_stage_arrays(prepared["stored_path"], preview, region, spec) + _write_stage_arrays(prepared["stored_path"], preview, domain, spec) return payload -def _write_stage_arrays(stored_path: str, preview: Any, region: Any, spec: Any) -> None: +def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" - if region.cell_mask is not None: + if domain is not None: write_grid_arrays( stored_path, "primary_region", spec, - {"mask": region.cell_mask}, - {"cells": region.active_cells, "bbox_cells": spec.size}, + {"mask": domain}, + { + "cells": int(domain.sum()), + "bbox_cells": spec.size, + "expand_rounds": preview.expand_rounds, + "expand_closed": preview.expand_closed, + }, ) flow = preview.flow if flow is None: @@ -322,7 +336,7 @@ def _write_stage_arrays(stored_path: str, preview: Any, region: Any, spec: Any) ) -def _flow_payload(preview: Any, region: Any) -> dict[str, Any] | None: +def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: @@ -331,9 +345,9 @@ def _flow_payload(preview: Any, region: Any) -> dict[str, Any] | None: 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. """ flow = preview.flow - if flow is None or region.cell_mask is None: + if flow is None or domain is None: return None - order = np.flatnonzero(region.cell_mask.reshape(-1)) + order = np.flatnonzero(domain.reshape(-1)) analyzed = flow.analyzed[order] reaches = flow.reaches_road[order] packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index ff267960..9e6f72a4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -638,10 +638,15 @@ export function createDrainagePanel(): DrainagePanel { `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` : " · 흐름 판정 없음"; + const expansion = region.expansion + ? ` · 확장 ${region.expansion.rounds}회` + + `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + + `${region.expansion.closed ? "닫힘" : "상한 도달"})` + : ""; return ( `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + - `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${flow}` + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${flow}` ); } diff --git a/config/config_system.py b/config/config_system.py index 53547279..51c5e2fc 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -251,6 +251,10 @@ DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0" DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) # 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) +# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다. +DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0")) +# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20")) # 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 # 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000"))