Files
Aislo/utils/contour_loader.py
T
2026-07-05 14:05:22 +09:00

245 lines
10 KiB
Python

"""
국토지리정보원(NGII) 수치지형도 등고선 데이터 가공 모듈.
대용량 GeoPackage(.gpkg)에 내장된 공간 인덱스를 활용하여
사용자의 프로젝트 영역(WGS84 BBOX)에 겹치는 등고선 라인을 0.1초 만에 오려내어 GeoJSON으로 변환합니다.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import geopandas as gpd
from pyproj import CRS, Transformer
import pyogrio
def load_ngii_contours(
source_dir: Path,
bounds_wgs84: dict[str, float],
output_dir: Path,
filename: str = "등고선_bounds.geojson",
) -> dict[str, Any]:
"""source_dir에 위치한 national_contours.gpkg 파일(또는 shp 파일들)로부터
bounds 영역에 걸치는 등고선만 공간 인덱스를 활용해 고속으로 잘라내어 GeoJSON으로 저장합니다.
"""
output_dir.mkdir(parents=True, exist_ok=True)
out_path = output_dir / filename
source_dir = Path(source_dir)
gpkg_path = source_dir / "national_contours.gpkg"
min_lon = bounds_wgs84["min_lon"]
max_lon = bounds_wgs84["max_lon"]
min_lat = bounds_wgs84["min_lat"]
max_lat = bounds_wgs84["max_lat"]
features_count = 0
# 1. 고속 GeoPackage(.gpkg) 데이터가 보관소에 존재하는 경우
if gpkg_path.exists():
print(f" -> [등고선] 고속 공간 DB 로드 시작: {gpkg_path.name}")
try:
# 1-1. 레이어 CRS(좌표계) 정보 사전 획득
info = pyogrio.read_info(gpkg_path, layer="contours")
gpkg_crs_wkt = info.get("crs")
# WGS84 사각형 경계를 미터 단위 공간 DB 쿼리용으로 변환
bbox = (min_lon, min_lat, max_lon, max_lat)
if gpkg_crs_wkt:
gpkg_crs = CRS.from_user_input(gpkg_crs_wkt)
# 만약 공간 DB의 좌표계가 위경도(4326)가 아니라면, BBOX 경계도 공간 DB의 좌표계 단위로 역변환하여 검색
if gpkg_crs.to_epsg() != 4326:
print(f" -> [등고선] 검색 범위 BBOX를 DB 좌표계({gpkg_crs.to_string()})로 변환하여 쿼리")
transformer = Transformer.from_crs("EPSG:4326", gpkg_crs, always_xy=True)
# 꼭짓점 변환 후 새 BBOX 경계 결정
x1, y1 = transformer.transform(min_lon, min_lat)
x2, y2 = transformer.transform(max_lon, max_lat)
# 역변환된 Bounds
bbox = (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
# 1-2. 공간 인덱스를 활용해 고속 bbox 쿼리 수행
gdf = gpd.read_file(
gpkg_path,
layer="contours",
bbox=bbox,
engine="pyogrio"
)
# 1-3. 결과 좌표를 최종적으로 위경도(EPSG:4326)로 변환
if gdf.crs and gdf.crs.to_epsg() != 4326:
print(f" -> [등고선] 결과 데이터 투영 변환 수행: {gdf.crs.to_string()} -> EPSG:4326")
gdf = gdf.to_crs("EPSG:4326")
features_count = len(gdf)
# 표준 GeoJSON 피처 컬렉션 및 properties 속성 구조의 무결성 보장
if features_count > 0:
features_list = []
for _, row in gdf.iterrows():
# geometry 데이터 추출
geom = json.loads(gpd.GeoSeries([row.geometry]).to_json())["features"][0]["geometry"]
# elevation 표고 정보 안전 추출 (gpkg 병합 시의 필드들 대응)
elevation = row.get("elevation")
if elevation is None:
elevation = row.get("cont_val") or row.get("elev_val") or row.get("ELEV")
try:
elevation = float(elevation) if elevation is not None else None
except (ValueError, TypeError):
elevation = None
features_list.append({
"type": "Feature",
"geometry": geom,
"properties": {
"elevation": elevation, # 프론트엔드가 요구하는 소문자 elevation 키 보장
"source": "national_contours.gpkg"
}
})
geojson = {
"type": "FeatureCollection",
"features": features_list
}
out_path.write_text(json.dumps(geojson, ensure_ascii=False), encoding="utf-8")
print(f" -> [등고선] 완료: 공간 인덱스 크롭 성공! 총 {features_count}개 라인 추출 완료.")
else:
# 겹치는 피처가 없을 때 빈 GeoJSON 생성
empty_geojson = {"type": "FeatureCollection", "features": []}
out_path.write_text(json.dumps(empty_geojson), encoding="utf-8")
print(" -> [등고선] 지정한 바운더리 영역에 겹치는 등고선이 존재하지 않습니다.")
return {
"feature_count": features_count,
"files_scanned": 1,
"files_used": 1,
"output": str(out_path),
}
except Exception as e:
print(f" -> [등고선] GeoPackage 고속 처리 중 오류 발생 (Fallback 자동 전환): {e}")
# 오류 발생 시 아래의 기존 shp 스캔 방식으로 자동 Fallback
# 2. GeoPackage가 없거나 처리 실패 시: 기존 shp 개별 파일 풀스캔 수행 (느림)
import shapefile
print(" -> [등고선] 개별 .shp 파일 대상 풀스캔을 시작합니다 (대용량 파일 시 수 분 이상 소요될 수 있음).")
shp_files = sorted(source_dir.rglob("*.shp"))
contour_files = [p for p in shp_files if p.name.lower() != "national_contours.shp"]
features: list[dict[str, Any]] = []
files_used = 0
def _source_epsg_transformer(shp_path: Path) -> Transformer:
prj_path = shp_path.with_suffix(".prj")
src_crs: CRS
if prj_path.exists():
try:
src_crs = CRS.from_wkt(prj_path.read_text(encoding="utf-8", errors="ignore"))
except Exception:
src_crs = CRS.from_epsg(5186)
else:
src_crs = CRS.from_epsg(5186)
return Transformer.from_crs(src_crs, CRS.from_epsg(4326), always_xy=True)
def _detect_elevation_field(reader: shapefile.Reader) -> int | None:
data_fields = reader.fields[1:]
numeric_idxs: list[int] = []
for i, (name, ftype, _size, _dec) in enumerate(data_fields):
if ftype in ("N", "F"):
numeric_idxs.append(i)
lname = str(name).lower()
if any(kw in lname for kw in ("등고수치", "등고", "표고", "수치", "elev", "cont", "height", "alti", "num")):
return i
if len(numeric_idxs) == 1:
return numeric_idxs[0]
return None
def _parts_to_linestrings(points: list, parts: list) -> list[list[list[float]]]:
if not parts:
return [list(points)]
segments: list[list[list[float]]] = []
bounds = list(parts) + [len(points)]
for a, b in zip(bounds[:-1], bounds[1:]):
seg = points[a:b]
if len(seg) >= 2:
segments.append(seg)
return segments
for shp_path in contour_files:
if shp_path.name.lower().startswith("national_"):
continue
try:
reader = shapefile.Reader(str(shp_path), encoding="cp949")
except Exception:
try:
reader = shapefile.Reader(str(shp_path), encoding="utf-8")
except Exception:
continue
if reader.shapeType not in {3, 13, 23}:
reader.close()
continue
transformer = _source_epsg_transformer(shp_path)
elev_idx = _detect_elevation_field(reader)
files_used += 1
kept = 0
for sr in reader.iterShapeRecords():
shape = sr.shape
if not shape.points:
continue
elevation = None
if elev_idx is not None:
try:
elevation = float(sr.record[elev_idx])
except (ValueError, TypeError, IndexError):
elevation = None
line_strings: list[list[list[float]]] = []
any_in_box = False
for seg in _parts_to_linestrings(shape.points, list(shape.parts)):
coords: list[list[float]] = []
for pt in seg:
lon, lat = transformer.transform(pt[0], pt[1])
coords.append([round(lon, 7), round(lat, 7)])
if (min_lon <= lon <= max_lon) and (min_lat <= lat <= max_lat):
any_in_box = True
if len(coords) >= 2:
line_strings.append(coords)
if not any_in_box or not line_strings:
continue
if len(line_strings) == 1:
geom = {"type": "LineString", "coordinates": line_strings[0]}
else:
geom = {"type": "MultiLineString", "coordinates": line_strings}
features.append({
"type": "Feature",
"geometry": geom,
"properties": {"elevation": elevation, "source": shp_path.name},
})
kept += 1
reader.close()
if kept:
print(f" -> [등고선 fallback] {shp_path.name}: {kept}개 라인 선별")
geojson = {"type": "FeatureCollection", "features": features}
out_path.write_text(json.dumps(geojson, ensure_ascii=False), encoding="utf-8")
return {
"feature_count": len(features),
"files_scanned": len(shp_files),
"files_used": files_used,
"output": str(out_path),
}