- 서버: 지오빅데이터(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>
175 lines
6.1 KiB
Python
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, ""
|