- 새 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>
157 lines
5.4 KiB
Python
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, ""
|