245 lines
10 KiB
Python
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),
|
|
}
|