Files
Aislo/common_util/common_util_wms_tiles.py
T
eomsangdonandClaude Opus 5.5 251dfd9a5b feat(D03): 받은 산림 · 환경 자료 줌별 조각 — 벡터(임도 · 산림토양도 · 경제림) · 그림 대리(생태자연도 · 산사태) (PLAN 622-1 · 요구 529)
- 새 common_util_vector_tiles: SHP(cp949 · .prj) → 3857 색인 GeoPackage(data_forest/_index) → z8~15 MVT · 원자료마다 규칙 한 줄(줌 · 단순화 · 작은 면 거름 · 남길 칸) · 창고 storage/_shared/vector_tiles/<name>/v1_<지문> · 615 꼴 잠금 · .part
- 새 common_util_wms_tiles: WMS → 256 px PNG(3857 bbox) · 처음 받고 storage/_shared/wms_tiles 기억 · 빈 · 오류 = 투명 PNG 300 s 기억 · 키 없음 = 404 · 키 안 샘
- 새 D03_PlanAnalysis_Router_Tiles: /plan/vt/{name}/{z}/{x}/{y}.pbf · /plan/wmt/{name}/{z}/{x}/{y}.png · main.py 등록
- 새 tools/precompute_vector_tiles.py: 원자료 이름 · 줌 · 경위도 상자 · 일꾼
- 시험 test_622_vector_tiles.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:16:05 +09:00

157 lines
5.4 KiB
Python

"""그림 조각 대리 — 바깥 WMS 를 웹 메르카토르 z/x/y 256 px PNG 로(PLAN 622-1 · 요구 529).
- 겹 = `SOURCES`(ecology 생태자연도 오픈API · landslide 생활안전지도 산사태위험지도) · 조각 상자
= 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_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"
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"},
),
}
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}?serviceKey={_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, ""