""" 국토지리정보원(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), }