Files
Aislo/common_util/common_util_wms_tiles.py
T
eomsangdonandClaude Opus 5.5 f6cfaf446c feat(760-1): 암상 지질도 겹 · 노선 따라 바위 종류 구간 · 밖 겹 출처 줄
- 서버: 지오빅데이터(KIGAM) 수치지질도 1:50,000 — WFS 암상 면(깨진 한글 칸은 기호별 점 조회로 채움) · 받은 범위 ∪ 노선으로 자름 · 저장본 · 노선 따라 1 m 표본 구간 · 키는 서버만(.env GEOBIGDATA_API_KEY · 쿼리 key)
- 그림 조각 대리에 geology(키 쿼리 이름 칸 key_param · 옛 겹 serviceKey 그대로)
- 화면(D03 · B05): 「제한·재해」에 「암상 지질도」 — 그림 조각 + 갈래 색 면 · 「기호 · 암상」 글 · 범례 · 노선 구간 표(참고 — 토사 · 암 판정 아님)
- 켠 밖 겹마다 범례 맨 아래 출처 줄(브이월드 · 국토지리정보원 · 산림청 · 환경부 · 생활안전지도 공공누리 · 한국지질자원연구원 CC BY-NC)
- 범례 칸 높이 한도 · 밀기(낮은 화면)
- 시험: test_760_1 새로 · test_634_2 · test_553_4 겹 차례 표에 geology 한 줄 더함(새 겹 · 낡은 표)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:32:53 +09:00

175 lines
6.1 KiB
Python

"""그림 조각 대리 — 바깥 WMS 를 웹 메르카토르 z/x/y 256 px PNG 로(PLAN 622-1 · 요구 529).
- 겹 = `SOURCES`(ecology 생태자연도 오픈API · landslide 생활안전지도 산사태위험지도 · geology 지오빅데이터
수치지질도 1:50,000 · 760-1) · 조각 상자 = EPSG:3857 bbox 그대로 요청(모두 3857 받음 · 옮겨 그리기 없음).
- 처음 받고 기억 = `storage/_shared/wms_tiles/<name>/z/x/y.png`(영구 · `.part` → 바꿈) · 빈 조각
(전부 투명) · 오류 조각 = 서버 메모리에 `SHORT_S` 초만 기억하고 투명 PNG.
- 키 = `.env`(`SOURCES` 의 key_env) · 없으면 `MissingKeyError` · 키 값은 어떤 로그 · 응답에도 안 냄
(오류 글에 주소를 넣지 않음).
길 = `D03_PlanAnalysis_Router_Tiles` `/plan/wmt/{name}/{z}/{x}/{y}.png`.
"""
from __future__ import annotations
import functools
import io
import logging
import os
import threading
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from uuid import uuid4
from common_util.common_util_contour_tiles import tile_bounds
from config.config_system import STORAGE_BASE_DIR
from D03_PlanAnalysis.D03_PlanAnalysis_Geology import KEY_ENV as GEOLOGY_KEY_ENV
from D03_PlanAnalysis.D03_PlanAnalysis_Geology import MAP_LAYER as GEOLOGY_LAYER
from D03_PlanAnalysis.D03_PlanAnalysis_Geology import WMS_URL as GEOLOGY_WMS_URL
from D03_PlanAnalysis.D03_PlanAnalysis_Landslide import (
LANDSLIDE_KEY_ENV,
LANDSLIDE_LAYER,
LANDSLIDE_URL,
PNG_SIGNATURE,
)
logger = logging.getLogger(__name__)
WMS_ROOT = Path(STORAGE_BASE_DIR) / "_shared" / "wms_tiles"
MIN_ZOOM, MAX_ZOOM = 6, 19
TILE_PX = 256
SHORT_S = 300
TIMEOUT_S = 30 # 생태자연도 한 조각이 8~15 s 걸릴 때 있음
@dataclass(frozen=True)
class Source:
"""WMS 하나 — 주소 · 키 칸 · 고정 인자 · 상자 좌표계 칸 이름 · 키 쿼리 이름."""
url: str
key_env: str
params: dict[str, str] = field(default_factory=dict)
srs_param: str = "srs"
key_param: str = "serviceKey"
SOURCES: dict[str, Source] = {
"ecology": Source(
"https://apis.data.go.kr/B553084/ecoapi/EcologyzmpService/wms/getEcologyzmpWMS",
"DATA_GO_KR_API_KEY",
{"format": "image/png", "transparent": "TRUE"},
srs_param="crs",
),
"landslide": Source(
LANDSLIDE_URL,
LANDSLIDE_KEY_ENV,
{"layers": LANDSLIDE_LAYER, "styles": "", "format": "image/png", "transparent": "TRUE"},
),
"geology": Source(
GEOLOGY_WMS_URL,
GEOLOGY_KEY_ENV,
{
"service": "WMS",
"version": "1.1.1",
"request": "GetMap",
"layers": GEOLOGY_LAYER,
"styles": "",
"format": "image/png",
"transparent": "true",
},
key_param="key",
),
}
class MissingKeyError(RuntimeError):
"""`.env` 에 키 없음."""
_SHORT: dict[tuple[str, int, int, int], tuple[float, str]] = {}
_SHORT_LOCK = threading.Lock()
@functools.cache
def blank() -> bytes:
"""투명 256 px PNG."""
from PIL import Image
buffer = io.BytesIO()
Image.new("RGBA", (TILE_PX, TILE_PX), (0, 0, 0, 0)).save(buffer, format="PNG")
return buffer.getvalue()
def _key(source: Source) -> str:
"""키 — 인코딩된 키(`%` 있음)는 그대로 · 아니면 주소용으로 바꿈 · 없으면 `MissingKeyError`."""
key = os.environ.get(source.key_env, "").strip()
if not key:
raise MissingKeyError(f"키 없음 — .env {source.key_env}")
return key if "%" in key else urllib.parse.quote(key, safe="")
def fetch(name: str, z: int, x: int, y: int) -> bytes:
"""바깥 WMS 에서 조각 하나(PNG) — 키 없음 = `MissingKeyError` · 실패 = `RuntimeError`."""
source = SOURCES[name]
query = {
**source.params,
source.srs_param: "EPSG:3857",
"bbox": ",".join(f"{v:.3f}" for v in tile_bounds(z, x, y)),
"width": str(TILE_PX),
"height": str(TILE_PX),
}
url = f"{source.url}?{source.key_param}={_key(source)}&{urllib.parse.urlencode(query)}"
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
try:
with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response:
body = response.read()
except OSError as error:
raise RuntimeError(f"{name} 조각을 받지 못했습니다 — {type(error).__name__}") from None
if not body.startswith(PNG_SIGNATURE):
raise RuntimeError(f"{name} 조각을 받지 못했습니다 — 그림이 아닌 응답")
return body
def is_blank(png: bytes) -> bool:
"""전부 투명한 그림인가."""
from PIL import Image
image = Image.open(io.BytesIO(png))
return image.convert("RGBA").getchannel("A").getextrema()[1] == 0
def ensure_tile(name: str, z: int, x: int, y: int) -> tuple[bytes, str]:
"""(PNG, 상태) — 상태 "" = 받은 그림(창고) · "empty" · "error" = 투명 PNG(짧게 기억).
모르는 name · 범위 밖 번호 = `KeyError` · 키 없음 = `MissingKeyError`."""
if name not in SOURCES:
raise KeyError(name)
if not (MIN_ZOOM <= z <= MAX_ZOOM) or not (0 <= x < 2**z and 0 <= y < 2**z):
raise KeyError(f"{z}/{x}/{y}")
path = WMS_ROOT / name / str(z) / str(x) / f"{y}.png"
if path.is_file():
return path.read_bytes(), ""
tile = (name, z, x, y)
with _SHORT_LOCK:
until, status = _SHORT.get(tile, (0.0, ""))
if until > time.monotonic():
return blank(), status
try:
png = fetch(name, z, x, y)
status = "empty" if is_blank(png) else ""
except MissingKeyError:
raise
except (RuntimeError, OSError) as error: # OSError = 그림을 못 읽음
logger.warning("%s", error)
status = "error"
if status:
with _SHORT_LOCK:
_SHORT[tile] = (time.monotonic() + SHORT_S, status)
return blank(), status
path.parent.mkdir(parents=True, exist_ok=True)
part = path.with_suffix(f".{uuid4().hex}.part")
part.write_bytes(png)
part.replace(path) # 반쯤 쓴 조각을 남기지 않음
return png, ""