Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_Landslide.py
eomsangdonandClaude Opus 5.5 84f243cee1 feat(D03 겹): 노선분석 지도 산사태위험지도 겹 — 행안부 생활안전지도 WMS · 키 없으면 그 겹만 꺼짐 (PLAN 584-1 · 요구 444)
- 서버: IF_0046_WMS 겹 A2SM_SANSATAI 를 받은 범위 경위도 상자로 한 장(2 m/px · 한 변 2,048) → 작업 좌표계 상자로 옮겨 그림 · 덮으면 저장본 · 키 없음 = 503 「키 없음」 · 받기 실패 · 그림 아님 = 502
- 키 = .env SAFEMAP_API_KEY 새 칸(값 비움)
- 화면: 단추 하나(alert) · 묶음 셋째 · 처음 끔 · 바탕 캔버스 · 범례 1~5 등급(--draw-map-landslide-1~5)
- 켬끔 취향 읽기를 LayerDefs 로 옮김(700줄 한도)
- test_553_4: 겹 목록에 landslide 더함(새 겹 · 기대 목록 갱신)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:59:11 +09:00

132 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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