From 0d90595a40087617b74729245d71e9454e7cec1b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 31 Aug 2026 16:27:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(B03/B04):=20PRJ=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EA=B3=84=20=ED=8C=90=EB=B3=84=EC=9D=84=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=20=EB=A7=A4=EC=B9=AD=EC=97=90=EC=84=9C=20WKT=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8+EPSG=20=EB=9D=BC=EB=B2=A8=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_epsg_from_prj()가 WKT에 항상 있는 false_easting 때문에 EAST 분기가 무조건 걸려 모든 PRJ를 EPSG:5187로 판정했다(표준 WKT 6종 실측). 새 원청 자료의 5176(비표준 TOWGS84)·5179(AUTHORITY 없는 ESRI WKT)가 이 경로로 들어오면 최대 100km 어긋난다. - common_util/common_util_crs.py 신설: 판별 사다리(pyproj DB 대조 → AUTHORITY 태그 → 투영 파라미터 지문)와 변환 입력 정규화. 수평 성분에 TOWGS84가 박힌 PRJ는 EPSG로 갈아타지 않고 원문 WKT로 변환해 지역 보정을 보존한다. 수직 성분의 bound(KNGeoid)는 2D 변환에 무관하므로 무시. - B04 get_epsg_from_prj: 죽은 문자열 분기 제거, 유틸 위임. 시그니처 불변 — 기존 COMPD_CS 프로젝트는 예전과 같은 EPSG:5187 문자열이 나온다. - B03 _component_metadata: to_epsg 실패 시 사다리 라벨 보강, normalize_crs_metadata: BoundCRS 벗김(TOWGS84 PRJ 수평 탐색 실패 수정). 검증: tmp/tests/test_common_util_crs.py 13건 포함 스위트 29 passed. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Engine_Analyze.py | 13 +- .../B04_PreProcess_Engine_VWorld.py | 33 ++-- common_util/common_util_crs.py | 168 ++++++++++++++++++ 3 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 common_util/common_util_crs.py diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 3bed2c34..23fc4b29 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -55,6 +55,12 @@ def _component_metadata(crs: CRS) -> dict[str, Any]: """CRS 구성 요소를 JSON 저장 가능한 메타데이터로 변환한다.""" authority = crs.to_authority() epsg = crs.to_epsg() + if epsg is None and (crs.is_projected or crs.is_geographic): + # DB 대조 실패(비표준 TOWGS84·AUTHORITY 없는 ESRI WKT 등) — 파라미터 + # 지문으로 라벨을 보강한다. 변환 정본은 여전히 원문 WKT다 (2026-08-31). + from common_util.common_util_crs import identify_epsg + + epsg = identify_epsg(crs) return { "name": crs.name, "type": crs.type_name, @@ -77,7 +83,12 @@ def normalize_crs_metadata(crs: Any | None) -> dict[str, Any]: } parsed = crs if isinstance(crs, CRS) else CRS.from_user_input(crs) - components = parsed.sub_crs_list or [parsed] + # TOWGS84가 붙은 WKT는 BoundCRS로 감싸져 is_projected가 False가 된다 — + # 벗겨야 수평 성분 탐색과 EPSG 라벨이 동작한다 (2026-08-31). + from common_util.common_util_crs import strip_bound + + flattened = strip_bound(parsed) + components = [strip_bound(item) for item in (flattened.sub_crs_list or [flattened])] horizontal = next( (item for item in components if item.is_projected or item.is_geographic), None, diff --git a/B04_PreProcess/B04_PreProcess_Engine_VWorld.py b/B04_PreProcess/B04_PreProcess_Engine_VWorld.py index 2746cb87..64f47eae 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_VWorld.py +++ b/B04_PreProcess/B04_PreProcess_Engine_VWorld.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import math import urllib.parse import urllib.request @@ -12,6 +13,8 @@ from pathlib import Path from PIL import Image from pyproj import Transformer +logger = logging.getLogger(__name__) + # 설정 불러오기 try: from config import config_system @@ -30,26 +33,22 @@ except ImportError: def get_epsg_from_prj(prj_content: str) -> str: - """PRJ 파일 텍스트 내용을 해석하여 적합한 EPSG 좌표계 코드를 추정합니다. + """PRJ 텍스트를 좌표 변환 입력 문자열로 정규화합니다. - 기본 한반도 주요 좌표계 매핑: - - Korea Central Belt (중부원점 20만, 60만) -> EPSG:5186 (Korea 2000 Central) - - Korea East Belt (동부원점) -> EPSG:5187 - - Korea West Belt (서부원점) -> EPSG:5185 - - WGS84 UTM 52N -> EPSG:32652 + pyproj가 EPSG DB 정의와 일치를 확정하면 `"EPSG:n"`, 아니면 파일 WKT 그대로 + 반환합니다(TOWGS84 지역 보정 보존) — 둘 다 `Transformer.from_crs` 입력으로 + 동작합니다. WKT가 불량·공백일 때만 기존 기본값 중부원점을 유지합니다. + + (2026-08-31 수리: 예전 문자열 매칭은 WKT에 항상 있는 `false_easting` 때문에 + "EAST" 분기가 무조건 걸려 모든 PRJ를 EPSG:5187로 판정했다.) """ - prj_upper = prj_content.upper() - if "EAST" in prj_upper: - return "EPSG:5187" - elif "WEST" in prj_upper: - return "EPSG:5185" - elif "CENTRAL" in prj_upper: - return "EPSG:5186" - elif "UTM" in prj_upper and "52N" in prj_upper: - return "EPSG:32652" + from common_util.common_util_crs import crs_input_from_prj - # 디폴트는 한국 중부 2000 좌표계 적용 - return "EPSG:5186" + crs_input = crs_input_from_prj(prj_content) + if crs_input is None: + logger.warning("PRJ 좌표계 해석 실패 — 기본값 EPSG:5186 사용") + return "EPSG:5186" + return crs_input def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]: diff --git a/common_util/common_util_crs.py b/common_util/common_util_crs.py new file mode 100644 index 00000000..6717c9f4 --- /dev/null +++ b/common_util/common_util_crs.py @@ -0,0 +1,168 @@ +# common_util_crs.py +# PRJ(WKT) 좌표계 판별 — 정본은 WKT, EPSG는 라벨 (2026-08-31 확정). +# +# 배경: 원청 자료의 PRJ는 EPSG AUTHORITY가 없거나(UTM-K ESRI WKT), TOWGS84 7모수가 +# EPSG DB 정의와 달라 `pyproj.CRS.to_epsg()`가 None을 주는 경우가 실측됐다. +# 그래서 ① 변환기는 파일의 WKT 그대로 만들고(지역 보정 보존), ② EPSG 코드는 +# 메타데이터·로그용 라벨로만 판별한다. 라벨 실패는 차단 사유가 아니다. + +from __future__ import annotations + +import logging +import re + +from pyproj import CRS + +logger = logging.getLogger(__name__) + +# 한국에서 실제로 들어오는 투영 좌표계 후보 — 파라미터 지문 대조(사다리 ③)에만 쓴다. +# 사다리 ①(pyproj DB 대조)은 이 목록과 무관하게 세계 모든 EPSG를 판별한다. +KOREA_CANDIDATE_EPSG: tuple[int, ...] = ( + 5185, + 5186, + 5187, + 5188, # Korea 2000 서·중부·동부·동해 (GRS80, FN 600k) + 5179, + 5178, # UTM-K (GRS80 / Bessel) + 5173, + 5174, + 5175, + 5176, + 5177, # Korean 1985 구좌표계 (Bessel, FN 500k) + 32651, + 32652, # WGS84 UTM 51N·52N + 4326, +) + +_AUTHORITY_PATTERN = re.compile(r'AUTHORITY\["EPSG",\s*"(\d+)"\]') + + +def strip_bound(crs: CRS) -> CRS: + """TOWGS84가 붙어 BoundCRS로 감싸진 경우 원래 좌표계를 꺼낸다. + + 감싸진 채로는 `coordinate_operation`이 Helmert 7모수를 가리켜 투영 + 파라미터가 안 보이고, `is_projected`도 False라 수평 성분 탐색이 실패한다. + """ + if crs.is_bound and crs.source_crs is not None: + return crs.source_crs + return crs + + +def horizontal_crs(crs: CRS) -> CRS: + """COMPD_CS(수직 CRS 결합)·BoundCRS를 벗겨 수평 성분만 꺼낸다.""" + crs = strip_bound(crs) + if crs.is_compound: + for component in crs.sub_crs_list: + component = strip_bound(component) + if component.is_projected or component.is_geographic: + return component + return crs + + +def _projection_fingerprint(crs: CRS) -> tuple | None: + """투영 지문 — (중앙자오선, 원점위도, 축척, FE, FN, 타원체 장반경). + + TOWGS84는 뺀다: 같은 좌표계라도 파일마다 지역 보정 모수가 다를 수 있고, + 여기서 만드는 것은 변환기가 아니라 라벨이다. + """ + crs = horizontal_crs(crs) + if crs.is_geographic: + return ("geographic", round(crs.ellipsoid.semi_major_metre, 3)) + operation = crs.coordinate_operation + if operation is None: + return None + params = {p.name.lower(): p.value for p in operation.params} + + def pick(*needles: str) -> float | None: + for needle in needles: + for name, value in params.items(): + if needle in name: + return value + return None + + return ( + round(pick("longitude of natural origin", "central meridian") or 0.0, 6), + round(pick("latitude of natural origin") or 0.0, 6), + round(pick("scale factor") or 1.0, 6), + round(pick("false easting") or 0.0, 3), + round(pick("false northing") or 0.0, 3), + round(crs.ellipsoid.semi_major_metre, 3), + ) + + +def identify_epsg(crs: CRS, wkt_text: str | None = None) -> int | None: + """EPSG 라벨 판별 사다리. 못 고르면 None — 기본값으로 때우지 않는다. + + ① pyproj DB 대조(전 세계 EPSG) → ② 원문 WKT 마지막 AUTHORITY 태그 + → ③ 투영 파라미터 지문을 한국 후보와 대조. + """ + flat = horizontal_crs(crs) + # WKT1 재직렬화는 축 순서 등 부차 정보가 빠져 기본 신뢰도(70)로는 DB 대조가 + # 실패한다. 라벨 용도이므로 느슨한 대조(25)를 쓴다 — always_xy 변환에는 무해. + epsg = flat.to_epsg(min_confidence=25) + if epsg is not None: + return epsg + + if wkt_text: + codes = _AUTHORITY_PATTERN.findall(wkt_text) + # WKT1은 마지막 AUTHORITY가 PROJCS 자신의 코드다 (앞의 것들은 타원체·측지계 등). + for code_text in reversed(codes): + code = int(code_text) + if code in KOREA_CANDIDATE_EPSG: + return code + + fingerprint = _projection_fingerprint(crs) + if fingerprint is None: + return None + for code in KOREA_CANDIDATE_EPSG: + try: + if _projection_fingerprint(CRS.from_epsg(code)) == fingerprint: + return code + except Exception: # pragma: no cover — pyproj DB 이상 시 다음 후보로 + continue + return None + + +def crs_input_from_prj(prj_text: str) -> str | None: + """PRJ 텍스트를 `Transformer.from_crs` 입력 문자열로 정규화한다. + + pyproj가 DB 정의와 일치를 확정하면 `"EPSG:n"`, 아니면 **원문 WKT 그대로** + 반환한다 — 파일에 박힌 TOWGS84 지역 보정을 보존하기 위해서다. + WKT 자체가 불량이면 None (차단은 이때만). + """ + text = (prj_text or "").strip() + if not text: + return None + try: + crs = CRS.from_wkt(text) + except Exception: + try: + crs = CRS.from_user_input(text) + except Exception: + return None + horizontal_raw = crs + if crs.is_compound: + for component in crs.sub_crs_list: + flattened = strip_bound(component) + if flattened.is_projected or flattened.is_geographic: + horizontal_raw = component + break + if horizontal_raw.is_bound: + # 수평 좌표계에 지역 보정(TOWGS84)이 박혀 있다 — EPSG 코드로 갈아타면 + # 그 보정이 사라지므로 라벨과 무관하게 원문 WKT로 변환한다. + # (수직 성분의 bound — KNGeoid 지오이드 — 는 2D 변환에 무관하므로 무시.) + label = identify_epsg(crs, text) + logger.info( + "PRJ에 사용자 TOWGS84 보정 — 파일 WKT로 변환 (라벨 %s)", + f"EPSG:{label}" if label else "미상", + ) + return text + epsg = horizontal_crs(crs).to_epsg(min_confidence=25) + if epsg is not None: + return f"EPSG:{epsg}" + label = identify_epsg(crs, text) + if label is not None: + logger.info("PRJ 좌표계 라벨 EPSG:%d — 변환은 파일 WKT로 수행(보정 모수 보존)", label) + else: + logger.warning("PRJ 좌표계의 EPSG 라벨을 찾지 못함 — 파일 WKT로 변환: %s", crs.name) + return text