"""국가 등고선 원본 데이터 분석 스크립트 (1차). 국가기본도(TN_CTRLN)와 연속수치지형도(N3L_F0010000)는 컬럼 체계가 서로 다르므로 스키마를 자동 판별한 뒤 동일한 지표로 환산해 비교한다. 측정 항목 1. 데이터 개요 : 소스 수, 레코드 수, 좌표계, 전체 범위 2. 등고선 종류 : 계곡선/주곡선/간곡선/조곡선 분포와 실측 등고 간격 3. 지형 구분 : 볼록지/오목지 분포와 오목지 폐합률 4. 지오메트리 정밀도: 평균 정점간격, 라인 길이 분포 5. 요약 판정 : 실질 등고 간격, 지성선 포함 여부 용어 주의 계곡선(計曲線)은 주곡선 5개마다 굵게 그리는 index contour이며 골짜기와 무관하다. 오목지(凹地)는 폐합된 저지(웅덩이·분지)를 뜻하며 골짜기가 아니다. [사전 필수 패키지] pip install geopandas pyogrio shapely 사용법 python analyze_contour_source.py <경로> [--sample 20000] [--encoding cp949] <경로> : .zip | .shp | SHP가 들어있는 폴더 """ import argparse import glob import os import sys import zipfile from collections import Counter try: import numpy as np import pyogrio except ImportError: print("[오류] 필수 라이브러리가 누락되었습니다.") print(">>> pip install geopandas pyogrio shapely") sys.exit(1) # 국가기본도: 등고선 구분과 지형 구분이 각각 독립 컬럼으로 존재한다. PROFILE_NGII = { "id": "NGII", "name": "국가기본도 (TN_CTRLN)", "must_have": ("CTRLN_SE", "TPGRPH_SE", "CTRLN_HG"), "kind_field": "CTRLN_SE", "kind_codes": { "CTC001": "계곡선", "CTC002": "주곡선", "CTC003": "간곡선", "CTC004": "조곡선", }, "topo_field": "TPGRPH_SE", "topo_codes": {"TPC001": "볼록지", "TPC002": "오목지"}, "height_field": "CTRLN_HG", "extra_fields": ("MESRMTH_SE",), } # 연속수치지형도: 지형 구분이 통합코드(SCLS) 안에 인코딩되어 있다. PROFILE_SLDM = { "id": "SLDM", "name": "연속수치지형도 (N3L_F0010000)", "must_have": ("DIVI", "CONT", "SCLS"), "kind_field": "DIVI", "kind_codes": { "CTD001": "계곡선", "CTD002": "주곡선", "CTD003": "간곡선", "CTD004": "조곡선", }, "topo_field": "SCLS", "topo_codes": {"F001711": "볼록지", "F001712": "오목지"}, "height_field": "CONT", "extra_fields": (), } PROFILES = (PROFILE_NGII, PROFILE_SLDM) def collect_sources(path: str) -> list[str]: """입력 경로에서 읽을 수 있는 SHP 목록을 만든다 (zip은 /vsizip/ 경로로 변환).""" path = os.path.abspath(path) if path.lower().endswith(".zip"): with zipfile.ZipFile(path) as zf: inner = sorted(n for n in zf.namelist() if n.lower().endswith(".shp")) base = "/vsizip/" + path.replace("\\", "/") return [f"{base}/{n}" for n in inner] if path.lower().endswith(".shp"): return [path] if os.path.isdir(path): found = sorted(glob.glob(os.path.join(path, "**", "*.shp"), recursive=True)) if found: return found # 폴더 안에 zip만 있는 경우 재귀 처리 out: list[str] = [] for z in sorted(glob.glob(os.path.join(path, "**", "*.zip"), recursive=True)): out.extend(collect_sources(z)) return out return [] def detect_profile(fields: list[str]) -> dict | None: """필드명으로 데이터 제품을 판별한다.""" names = set(fields) for profile in PROFILES: if names.issuperset(profile["must_have"]): return profile return None def topo_label(profile: dict, value) -> str: """지형 구분 값을 볼록지/오목지 라벨로 환산한다.""" if value is None: return "미상" text = str(value) if profile["id"] == "NGII": return profile["topo_codes"].get(text, f"정의외({text})") for prefix, label in profile["topo_codes"].items(): if text.startswith(prefix): return label return f"정의외({text})" def scan_attributes(sources: list[str], profile: dict, encoding: str) -> dict: """속성만 전수 조회하여 분포를 집계한다 (지오메트리는 읽지 않음).""" kind = Counter() topo = Counter() cross = Counter() extra = {f: Counter() for f in profile["extra_fields"]} heights: dict[str, set] = {} per_source: list[tuple[str, int]] = [] bounds = None total = 0 for src in sources: df = pyogrio.read_dataframe(src, read_geometry=False, encoding=encoding) total += len(df) per_source.append((os.path.basename(src), len(df))) # 소스마다 범위가 다르므로 합집합을 누적한다 (첫 소스 범위만 쓰면 전국이 아니다). box = pyogrio.read_info(src, encoding=encoding).get("total_bounds") if box is not None: bounds = ( box if bounds is None else ( min(bounds[0], box[0]), min(bounds[1], box[1]), max(bounds[2], box[2]), max(bounds[3], box[3]), ) ) kinds = [ profile["kind_codes"].get(str(v), f"정의외({v})") for v in df[profile["kind_field"]] ] topos = [topo_label(profile, v) for v in df[profile["topo_field"]]] kind.update(kinds) topo.update(topos) cross.update(zip(kinds, topos)) for f in extra: if f in df.columns: extra[f].update(df[f].tolist()) for k, h in zip(kinds, df[profile["height_field"]]): if h is None: continue heights.setdefault(k, set()).add(round(float(h), 2)) return { "total": total, "per_source": per_source, "kind": kind, "topo": topo, "cross": cross, "extra": extra, "heights": heights, "bounds": bounds, } def scan_geometry(src: str, profile: dict, encoding: str, sample: int) -> dict: """대표 소스 1개에서 지오메트리를 표본 추출해 형상 정밀도를 측정한다.""" result: dict = {"source": os.path.basename(src), "sample": 0} gdf = pyogrio.read_dataframe(src, encoding=encoding, max_features=sample) gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty] if not len(gdf): return result lengths = np.array([g.length for g in gdf.geometry]) verts = int(sum(len(g.coords) for g in gdf.geometry if g.geom_type == "LineString")) parts = int(sum(1 for g in gdf.geometry if g.geom_type == "LineString")) result.update( { "sample": len(gdf), "crs": str(gdf.crs), "total_len": float(lengths.sum()), "len_median": float(np.median(lengths)), "len_max": float(lengths.max()), "vertices": verts, "vertex_gap": float(lengths.sum() / max(verts - parts, 1)), } ) # 오목지 폐합률: 오목지가 웅덩이인지 골짜기인지 판별하는 핵심 지표 for label in ("오목지", "볼록지"): mask = [topo_label(profile, v) == label for v in gdf[profile["topo_field"]]] subset = gdf[mask] if not len(subset): continue closed = [ list(g.coords)[0] == list(g.coords)[-1] for g in subset.geometry if g.geom_type == "LineString" ] if closed: result[f"closed_{label}"] = (sum(closed) / len(closed), len(closed)) return result def interval_of(values: set) -> list: """등고 높이 값 집합에서 실측 간격 종류를 뽑는다.""" vals = sorted(values) return sorted({round(vals[i + 1] - vals[i], 2) for i in range(len(vals) - 1)}) def report(path: str, profile: dict, attrs: dict, geom: dict, bounds) -> None: """분석 결과를 표준 출력에 정리한다.""" total = attrs["total"] line = "=" * 74 print(line) print("등고선 원본 분석 : %s" % path) print("판별된 제품 : %s" % profile["name"]) print(line) print("\n[1] 데이터 개요") print(" 소스 %d개, 총 레코드 %s건" % (len(attrs["per_source"]), format(total, ","))) if geom.get("crs"): print(" 좌표계 %s" % geom["crs"]) if bounds: print( " 전체 범위 X %.0f~%.0f / Y %.0f~%.0f" % (bounds[0], bounds[2], bounds[1], bounds[3]) ) for name, cnt in attrs["per_source"][:15]: print(" %-24s %s" % (name, format(cnt, ","))) if len(attrs["per_source"]) > 15: print(" ... 외 %d개" % (len(attrs["per_source"]) - 15)) print("\n[2] 등고선 종류 (계곡선=index contour, 골짜기 아님)") for k, v in attrs["kind"].most_common(): print(" %-8s %10s (%5.2f%%)" % (k, format(v, ","), v / total * 100)) for k in ("간곡선", "조곡선"): if k not in attrs["kind"]: print(" %-8s %10s (스펙 정의 있으나 실데이터 없음)" % (k, "0")) print("\n 실측 등고 간격") for k in sorted(attrs["heights"]): vals = sorted(attrs["heights"][k]) print( " %-8s 값 %d종, 범위 %g ~ %g m, 간격종류 %s" % (k, len(vals), vals[0], vals[-1], interval_of(attrs["heights"][k])[:6]) ) print("\n[3] 지형 구분 (오목지=폐합 저지, 골짜기 아님)") for k, v in attrs["topo"].most_common(): print(" %-10s %10s (%5.2f%%)" % (k, format(v, ","), v / total * 100)) print("\n 종류 x 지형 교차") for (a, b), v in attrs["cross"].most_common(10): print(" %-8s x %-10s %10s" % (a, b, format(v, ","))) for field, counter in attrs["extra"].items(): if counter: print("\n %s" % field) for k, v in counter.most_common(6): print(" %-10s %10s (%5.2f%%)" % (k, format(v, ","), v / total * 100)) print( "\n[4] 지오메트리 정밀도 (표본 %s건 / %s)" % (format(geom.get("sample", 0), ","), geom.get("source", "-")) ) if geom.get("sample"): print(" 평균 정점간격 %.2f m" % geom["vertex_gap"]) print(" 라인 길이 중앙값 %.0f m, 최대 %.0f m" % (geom["len_median"], geom["len_max"])) for label in ("볼록지", "오목지"): key = f"closed_{label}" if key in geom: ratio, n = geom[key] print(" %s 폐합률 %.1f%% (표본 %s개)" % (label, ratio * 100, format(n, ","))) print("\n[5] 요약 판정") main = attrs["heights"].get("주곡선") if main: iv = interval_of(main) print(" 실질 등고 간격 : %g m (주곡선 기준)" % (iv[0] if iv else 0)) has_fine = any(k in attrs["kind"] for k in ("간곡선", "조곡선")) print(" 간곡선/조곡선 : %s" % ("있음" if has_fine else "없음 → 1m 보간 필요")) print(" 볼록지/오목지 : %s" % ("있음" if "오목지" in attrs["topo"] else "없음")) print(" 능선/계곡 지성선: 없음 (등고선 레이어에는 포함되지 않는 항목)") print(line) def main() -> None: parser = argparse.ArgumentParser(description="국가 등고선 원본 데이터 분석") parser.add_argument("path", help=".zip | .shp | SHP가 든 폴더") parser.add_argument("--sample", type=int, default=20000, help="지오메트리 표본 수 (기본 20000)") parser.add_argument("--encoding", default="cp949", help="DBF 인코딩 (기본 cp949)") args = parser.parse_args() sources = collect_sources(args.path) if not sources: print("[오류] 읽을 SHP를 찾지 못했습니다: %s" % args.path) sys.exit(1) info = pyogrio.read_info(sources[0], encoding=args.encoding) profile = detect_profile(list(info["fields"])) if profile is None: print("[오류] 알 수 없는 스키마입니다. 필드: %s" % list(info["fields"])) sys.exit(1) attrs = scan_attributes(sources, profile, args.encoding) geom = scan_geometry(sources[0], profile, args.encoding, args.sample) report(args.path, profile, attrs, geom, attrs["bounds"]) if __name__ == "__main__": main()