Files
Aislo/B04_PreProcess/B04_PreProcess_Engine_Extent.py
eomsangdonandClaude Opus 5 e83171b2b3 refactor(공통): 작업 좌표계 폴백을 창구 하나로 통합 + 트림 실패 진단
같은 폴백 사다리가 네 곳에 서로 다른 모양으로 흩어져 있었고, 그중 배수유역 라우터
`_route_center_lonlat` 은 노선 CSV 의 `crs_epsg` **라벨**을 그대로 변환에 썼다(라벨과
실좌표계가 다른 사례 실측 — 2026-09-01 용화 라벨 5179 / 실제 5176).

`common_util_crs.resolve_project_crs()` 신설 — 사다리는 ① 노선 crs_input ② 파일 라벨
(원본 좌표를 읽는 자리만) ③ 지형 PRJ(작업 좌표계 정본) ④ DB epsg ⑤ EPSG:5186.
작업 좌표계는 **지형 PRJ 우선으로 확정**(2026-09-03 사용자 결정 — 서피스 격자가 모델좌표의
주인이라 현행 유지). `project_epsg_from_prj()` 도 이 창구로 위임.

더해 지표면 트림이 노선을 통째로 지울 때 노선·지표면 bbox 를 함께 로그에 남긴다 —
"겹치지 않습니다" 만으로는 좌표계 문제인지 측량 범위 문제인지 갈리지 않았다.

검증 — `tmp/tests/test_project_crs_resolution.py` 5건 신설(사다리 4·진단 1),
전체 375 passed·17 skipped, ruff format 무변경.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:25:20 +09:00

207 lines
8.3 KiB
Python

# B04_PreProcess_Engine_Extent.py
# 전처리에서 내려받을 범위와 기준 좌표를 정한다.
#
# 배경(2026-08-01 사용자 지시): 배경 지도·수치지형도 도엽의 기준을 라이다 범위 한가운데로
# 잡으면, 계획노선이 라이다 범위를 벗어날 때 배경과 도엽이 노선을 덮지 못한다.
# 계획노선(B03 CSV)의 시점·종점을 기준으로 삼고, 없을 때만 라이다 범위로 되돌린다.
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_ROUTE_CSV_GLOB = "B03_FileInput/input/csv/*.csv"
def read_planned_route(project_root: Path) -> dict[str, Any] | None:
"""B03 계획노선 CSV를 읽어 좌표계·범위·시점·종점을 돌려준다. 없거나 형식이 어긋나면 None."""
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv
for csv_path in sorted(project_root.glob(_ROUTE_CSV_GLOB)):
try:
return analyze_planned_route_csv(csv_path)
except (OSError, ValueError) as exc:
logger.warning("B04 계획노선 CSV 해석 실패: %s (%s)", csv_path.name, exc)
return None
def _to_target_crs(
points: list[tuple[float, float]], source_epsg: int | None, target_epsg: str
) -> list[tuple[float, float]]:
"""계획노선 좌표를 라이다 좌표계로 옮긴다. 좌표계가 같거나 알 수 없으면 그대로 쓴다."""
if source_epsg is None:
return points
source = f"EPSG:{source_epsg}"
if source.upper() == target_epsg.upper():
return points
from pyproj import Transformer
transformer = Transformer.from_crs(source, target_epsg, always_xy=True)
return [transformer.transform(x, y) for x, y in points]
def download_extent(
project_root: Path,
las_bounds: dict[str, list[float]],
target_epsg: str,
) -> dict[str, list[float]]:
"""배경 지도가 반드시 덮어야 할 범위 = 라이다 범위 ∪ 계획노선 범위.
여유폭은 여기서 미터로 더하지 않는다. 내려받기 쪽에서 이 범위를 덮는 타일을 정한 뒤
바깥으로 `SURFACE_MAP_MARGIN_TILES` 겹만큼 주변 셀을 더 받는다(2026-08-01 사용자 지시).
las_bounds/반환값 모두 {"x": [최소, 최대], "y": [...], "z": [...]} 꼴(라이다 좌표계).
"""
x_min, x_max = float(las_bounds["x"][0]), float(las_bounds["x"][1])
y_min, y_max = float(las_bounds["y"][0]), float(las_bounds["y"][1])
route = read_planned_route(project_root)
if route:
bounds = route["bounds"]
corners = [
(float(bounds["x_min"]), float(bounds["y_min"])),
(float(bounds["x_max"]), float(bounds["y_max"])),
]
try:
moved = _to_target_crs(corners, route.get("epsg"), target_epsg)
except Exception as exc:
logger.warning("B04 계획노선 좌표 변환 실패 — 라이다 범위만 사용 (%s)", exc)
moved = []
for x, y in moved:
x_min, x_max = min(x_min, x), max(x_max, x)
y_min, y_max = min(y_min, y), max(y_max, y)
return {
"x": [x_min, x_max],
"y": [y_min, y_max],
"z": list(las_bounds.get("z", [0.0, 0.0])),
}
def satellite_extent(
project_root: Path,
las_bounds: dict[str, list[float]],
target_epsg: str,
) -> dict[str, list[float]]:
"""배경 지도를 확보할 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽 범위.
수치지형도 도엽과 같은 눈금으로 맞춘다 — 도엽 1매면 1매 크기, 2~3매에 걸치면 그만큼.
주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
도엽 번호를 얻지 못하면 라이다∪계획노선 범위로 되돌아간다.
"""
from pyproj import Transformer
from .B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds, sheets_for_points
points = sheet_reference_points_wgs84(project_root, las_bounds, target_epsg)
sheets = sheets_for_points(points)
if not sheets:
return download_extent(project_root, las_bounds, target_epsg)
lon_min = lat_min = float("inf")
lon_max = lat_max = float("-inf")
for sheet_no in sheets:
s_lon_min, s_lat_min, s_lon_max, s_lat_max = sheet5k_to_bounds(sheet_no)
lon_min, lon_max = min(lon_min, s_lon_min), max(lon_max, s_lon_max)
lat_min, lat_max = min(lat_min, s_lat_min), max(lat_max, s_lat_max)
to_target = Transformer.from_crs("EPSG:4326", target_epsg, always_xy=True)
x0, y0 = to_target.transform(lon_min, lat_min)
x1, y1 = to_target.transform(lon_max, lat_max)
logger.info("B04 배경 지도 기준 도엽 %d매: %s", len(sheets), ", ".join(sheets))
return {
"x": [min(x0, x1), max(x0, x1)],
"y": [min(y0, y1), max(y0, y1)],
"z": list(las_bounds.get("z", [0.0, 0.0])),
}
def planned_route_bounds(project_root: Path, target_epsg: str) -> dict[str, float] | None:
"""계획노선(B03 CSV)의 평면 범위를 프로젝트 좌표계로 돌려준다. 없으면 None.
지도(2D) 초기 화면을 도로 기준으로 맞출 때 쓴다 — 화면 쪽은 도로 범위만 알면 된다.
"""
route = read_planned_route(project_root)
if not route:
return None
bounds = route["bounds"]
corners = [
(float(bounds["x_min"]), float(bounds["y_min"])),
(float(bounds["x_max"]), float(bounds["y_max"])),
]
try:
moved = _to_target_crs(corners, route.get("epsg"), target_epsg)
except Exception as exc:
logger.warning("B04 계획노선 범위 변환 실패 (%s)", exc)
return None
xs = [point[0] for point in moved]
ys = [point[1] for point in moved]
return {"x_min": min(xs), "x_max": max(xs), "y_min": min(ys), "y_max": max(ys)}
def project_epsg_from_prj(project_root: Path) -> str:
"""프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186).
PRJ가 둘 이상 올라오므로(노선 세트 + 지형) 지형 PRJ를 고른다 — `find_project_prj`.
판정은 작업 좌표계 창구(`resolve_project_crs`) 하나로 모았다(2026-09-03).
"""
from common_util.common_util_crs import resolve_project_crs
return resolve_project_crs(project_root)
def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool:
"""저장된 배경 지도가 필요한 범위를 이미 덮고 있는가.
파일 존재 여부만 보면 계획노선이 바뀌거나 여유 셀 설정을 바꿔도 옛 사진을 계속 쓴다
(B03 업로드 → 전처리 경로는 `rebuild=False`라 더더욱 다시 받지 않는다, 2026-08-01).
"""
if not meta_path.is_file():
return False
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
return (
float(meta["x_min"]) <= extent["x"][0]
and float(meta["x_max"]) >= extent["x"][1]
and float(meta["y_min"]) <= extent["y"][0]
and float(meta["y_max"]) >= extent["y"][1]
)
except (OSError, ValueError, KeyError, TypeError):
return False
def sheet_reference_points_wgs84(
project_root: Path,
las_bounds: dict[str, list[float]],
target_epsg: str,
) -> list[tuple[float, float]]:
"""도엽 선정 기준 좌표(위도, 경도) 목록.
① 계획노선 시점·종점 → ② 라이다 범위 중심(폴백).
시점과 종점이 서로 다른 도엽에 걸치면 호출측이 두 도엽의 주변을 모두 확보한다.
"""
from pyproj import Transformer
to_wgs84 = Transformer.from_crs(target_epsg, "EPSG:4326", always_xy=True)
route = read_planned_route(project_root)
if route:
ends = [
(float(route["start_point"][0]), float(route["start_point"][1])),
(float(route["end_point"][0]), float(route["end_point"][1])),
]
try:
moved = _to_target_crs(ends, route.get("epsg"), target_epsg)
return [(lat, lon) for lon, lat in (to_wgs84.transform(x, y) for x, y in moved)]
except Exception as exc:
logger.warning("B04 계획노선 기준 좌표 산출 실패 — 라이다 중심 사용 (%s)", exc)
center_x = (float(las_bounds["x"][0]) + float(las_bounds["x"][1])) / 2.0
center_y = (float(las_bounds["y"][0]) + float(las_bounds["y"][1])) / 2.0
lon, lat = to_wgs84.transform(center_x, center_y)
return [(lat, lon)]