fix(B03/B04): PRJ 좌표계 판별을 문자열 매칭에서 WKT 정본+EPSG 라벨로 바꾼다

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 16:27:13 +09:00
co-authored by Claude Opus 5
parent 46ac54257e
commit 0d90595a40
3 changed files with 196 additions and 18 deletions
+12 -1
View File
@@ -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,
+16 -17
View File
@@ -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]:
+168
View File
@@ -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