"""D 노선분석 지도 산사태위험지도 겹(PLAN 584-1 · 요구 444). - 출처 = 행안부 생활안전지도 산사태위험지도 WMS(`IF_0046_WMS` · 겹 `A2SM_SANSATAI` · 1~5 등급 · safemap objtId=125) · 키 = `.env` `SAFEMAP_API_KEY`(없으면 오류 · 값은 어디에도 안 냄) - 받은 범위의 경위도 상자로 그림 한 장 받음(2 m/px · 한 변 2,048 한도) → 작업 좌표계 상자로 옮겨 그림(가까운 칸) → PNG data URL · 저장 = `processed/산사태위험지도_bounds.json` (상자가 덮으면 다시 안 받음) - 실패(키 없음 · 받기 실패 · 그림 아님) = 오류(파일 안 씀) 계산 자리 = ③ 서버 단독. """ from __future__ import annotations import base64 import io import json import logging import math import os import urllib.parse import urllib.request from pathlib import Path from typing import Any import numpy as np from common_util.common_util_json import atomic_write_json logger = logging.getLogger(__name__) Box = tuple[float, float, float, float] LANDSLIDE_URL = "http://www.safemap.go.kr/openapi2/IF_0046_WMS" LANDSLIDE_LAYER = "A2SM_SANSATAI" LANDSLIDE_KEY_ENV = "SAFEMAP_API_KEY" LANDSLIDE_FILE = "산사태위험지도_bounds.json" LANDSLIDE_VERSION = 1 PX_M = 2.0 MAX_PX = 2048 PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" class MissingKeyError(RuntimeError): """`.env` 에 키 없음.""" def request_wms(bounds: dict[str, float], width: int, height: int) -> bytes: """WMS 그림 한 장(PNG 바이트) — 키 없음 = `MissingKeyError` · 실패 = `RuntimeError`.""" key = os.environ.get(LANDSLIDE_KEY_ENV, "").strip() if not key: raise MissingKeyError(f"산사태위험지도 키 없음 — .env {LANDSLIDE_KEY_ENV}") params = { "serviceKey": key, "layers": LANDSLIDE_LAYER, "styles": "", "srs": "EPSG:4326", "bbox": f"{bounds['min_lon']},{bounds['min_lat']},{bounds['max_lon']},{bounds['max_lat']}", "format": "image/png", "width": width, "height": height, "transparent": "TRUE", } url = LANDSLIDE_URL + "?" + urllib.parse.urlencode(params) try: with urllib.request.urlopen( urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}), timeout=20 ) as response: body = response.read() except OSError as error: raise RuntimeError(f"산사태위험지도를 받지 못했습니다 — {type(error).__name__}") from None if not body.startswith(PNG_SIGNATURE): raise RuntimeError("산사태위험지도를 받지 못했습니다 — 그림이 아닌 응답") return body def warp_to_box(png: bytes, bounds: dict[str, float], box: Box, epsg: int) -> Any: """경위도 상자 그림 → 작업 좌표계 상자 그림(RGBA · 위 = 북 · 밖 = 투명 · 가까운 칸).""" from PIL import Image from pyproj import Transformer source = np.asarray(Image.open(io.BytesIO(png)).convert("RGBA")) rows, cols = source.shape[:2] x0, y0, x1, y1 = box width, height = cols, rows xs = x0 + (np.arange(width) + 0.5) * (x1 - x0) / width ys = y1 - (np.arange(height) + 0.5) * (y1 - y0) / height gx, gy = np.meshgrid(xs, ys) to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True) lon, lat = to_ll.transform(gx, gy) col = np.floor((lon - bounds["min_lon"]) / (bounds["max_lon"] - bounds["min_lon"]) * cols) row = np.floor((bounds["max_lat"] - lat) / (bounds["max_lat"] - bounds["min_lat"]) * rows) inside = (col >= 0) & (col < cols) & (row >= 0) & (row < rows) out = np.zeros((height, width, 4), np.uint8) out[inside] = source[row[inside].astype(int), col[inside].astype(int)] return Image.fromarray(out, mode="RGBA") def ensure_landslide(processed: Path, box: Box, epsg: int) -> dict[str, Any]: """산사태위험지도 그림 겹 — 저장 상자가 덮으면 그대로 · 아니면 받아 옮겨 그림.""" from D03_PlanAnalysis.D03_PlanAnalysis_Layers import _covers, _lonlat_bounds path = processed / LANDSLIDE_FILE try: saved = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): saved = None if ( isinstance(saved, dict) and saved.get("version") == LANDSLIDE_VERSION and _covers(saved.get("box_m"), box) ): return saved bounds = _lonlat_bounds(box, epsg) scale = max((box[2] - box[0]) / PX_M, (box[3] - box[1]) / PX_M) / MAX_PX step = PX_M * max(1.0, scale) width = max(1, math.ceil((box[2] - box[0]) / step)) height = max(1, math.ceil((box[3] - box[1]) / step)) image = warp_to_box(request_wms(bounds, width, height), bounds, box, epsg) buffer = io.BytesIO() image.save(buffer, format="PNG", optimize=True) value = { "version": LANDSLIDE_VERSION, "box_m": [float(v) for v in box], "image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"), "bands": [], } processed.mkdir(parents=True, exist_ok=True) atomic_write_json(path, value) logger.info("D 산사태위험지도 받기: %d × %d px", width, height) return value