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>
This commit is contained in:
2026-10-31 19:16:05 +09:00
co-authored by Claude Opus 5.5
parent 2648c92b70
commit 251dfd9a5b
6 changed files with 881 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
"""받은 산림 자료 줌별 벡터 조각 — 웹 메르카토르 z/x/y MVT 를 공용 창고에(PLAN 622-1 · 요구 529).
원자료 = `resources/data_forest/` SHP(cp949 · .prj 좌표계 그대로 읽음) · 원자료마다 규칙 한 줄
(`RULES` — 파일 · 줌 · 단순화 px · 작은 면 거름 · 남길 칸).
색인 = 원자료를 3857 로 옮겨 남길 칸 + 면적만 담은 GeoPackage(공간 색인) — 처음 한 번
`resources/data_forest/_index/<name>_v<규칙 판>_<원자료 지문>.gpkg`.
조각 하나 = 색인을 조각 상자(+ 여유 8 px)로 읽음 → 작은 면 거름(SQL) → 상자로 자르고 단순화
→ MVT(겹 = name · 지물 하나 = 도형 하나). 512 px 조각 · 범위 4096.
창고 = `storage/_shared/vector_tiles/<name>/v<규칙 판>_<원자료 지문>/z/x/y.pbf` — 처음 볼 때
만들어 영구 · 빈 조각도 저장 · 잠금 · `.part` → 바꿈(615 등고선 조각 꼴).
원자료 없는 PC = 있는 판 조각을 믿음 · 없으면 빈 조각.
길 = `D03_PlanAnalysis_Router_Tiles` `/plan/vt/{name}/{z}/{x}/{y}.pbf` · 미리 만들기 =
`tools/precompute_vector_tiles.py`.
"""
from __future__ import annotations
import functools
import hashlib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from common_util.common_util_contour_tiles import (
BUFFER_PX,
EXTENT,
TILE_PX,
WORLD_M,
in_bounds,
tile_bounds,
)
from common_util.common_util_terrain_store import _Lock
from config.config_system import PROJECT_ROOT, STORAGE_BASE_DIR
DATA_ROOT = Path(PROJECT_ROOT) / "resources" / "data_forest"
INDEX_ROOT = DATA_ROOT / "_index"
TILES_ROOT = Path(STORAGE_BASE_DIR) / "_shared" / "vector_tiles"
RULE_VERSION = 1
MIN_ZOOM, MAX_ZOOM = 8, 15
ENCODING = "cp949"
# 산림입지토양도 토양형 코드(SLTP_CD) — 설명서 `DATA014_산림입지도(25000).pdf` 코드 정의서
SOIL_TYPES = {
"01": "갈색건조산림토양",
"02": "갈색약건산림토양",
"03": "갈색적윤산림토양",
"04": "갈색약습산림토양",
"05": "적색계갈색건조산림토양",
"06": "적색계갈색약건산림토양",
"07": "적색건조산림토양",
"08": "적색약건산림토양",
"09": "황색건조산림토양",
"10": "암적색건조산림토양",
"11": "암적색약건산림토양",
"12": "암적색적윤산림토양",
"13": "암적갈색건조산림토양",
"14": "암적갈색약건산림토양",
"15": "회갈색건조산림토양",
"16": "회갈색약건산림토양",
"17": "화산회건조산림토양",
"18": "화산회약건산림토양",
"19": "화산회적윤산림토양",
"20": "화산회습윤산림토양",
"21": "화산회자갈많은산림토양",
"22": "화산회성적색건조산림토양",
"23": "화산회성적색약건산림토양",
"24": "약침식토양",
"25": "강침식토양",
"26": "사방지토양",
"27": "미숙토양",
"28": "암쇄토양",
"29": "전석지",
"82": "제지",
"91": "주거지",
"92": "초지",
"93": "경작지",
"94": "수채",
"95": "과수원",
"97": "군사지역",
"99": "기타",
}
def _text(value: Any) -> str:
return "" if value is None or value != value else str(value).strip()
def _road(row: dict[str, Any], _: Path) -> dict[str, Any]:
kind = _text(row.get("FRRD_DV_B"))
year = _text(row.get("FRRD_ESTBL"))
width = row.get("FRRD_FCLTW")
return {
"kind": int(kind) if kind.isdigit() else 0,
"name": _text(row.get("FRRD_NM")),
"width": float(width) if width == width and width is not None else 0.0,
"year": int(year) if year.isdigit() else 0,
}
def _soil(row: dict[str, Any], _: Path) -> dict[str, Any]:
code = _text(row.get("SLTP_CD"))
return {"code": code, "name": SOIL_TYPES.get(code, "")}
def _eco(row: dict[str, Any], path: Path) -> dict[str, Any]:
own = "국유" if "ecoFrst_01" in path.as_posix() else "사유"
return {"own": own, "name": _text(row.get("STAGE_NMPL"))}
@dataclass(frozen=True)
class Rule:
"""원자료 하나 — 파일 · 자료 줌 · 단순화 px · 작은 면 거름(px² · 줌별) · 칸 뽑기."""
globs: tuple[str, ...]
columns: tuple[str, ...]
props: Any
min_zoom: int = MIN_ZOOM
simplify_px: float = 1.0
min_area_px: dict[int, float] = field(default_factory=dict)
RULES: dict[str, Rule] = {
"forest_road": Rule(
("임도망도/*/*.shp",),
("FRRD_NM", "FRRD_DV_B", "FRRD_FCLTW", "FRRD_ESTBL"),
_road,
),
"forest_soil": Rule(
("산림입지토양도_1-25000/*/*.shp",),
("SLTP_CD",),
_soil,
min_zoom=10,
min_area_px={10: 32.0, 11: 16.0, 12: 8.0, 13: 2.0},
),
"eco_forest": Rule(
("경제림육성단지/ecoFrst_01_*/*/*.shp", "경제림육성단지/ecoFrst_02_*/*/*.shp"),
("STAGE_NMPL",),
_eco,
min_area_px={8: 16.0, 9: 8.0, 10: 4.0, 11: 2.0, 12: 1.0},
),
}
def sources(name: str) -> list[Path]:
"""원자료 SHP 목록(차례 고정)."""
rule = RULES[name]
return sorted(p for pattern in rule.globs for p in DATA_ROOT.glob(pattern))
@functools.cache
def _stamp(name: str) -> str | None:
"""원자료 지문(SHP · DBF 크기 · 수정 시각) — 없으면 None.
ponytail: 프로세스마다 한 번 셈 · 원자료를 바꾸면 서버를 다시 켬."""
files = sources(name)
if not files:
return None
parts = []
for shp in files:
for path in (shp, shp.with_suffix(".dbf")):
stat = path.stat()
parts.append(
f"{path.relative_to(DATA_ROOT).as_posix()}:{stat.st_size}:{stat.st_mtime_ns}"
)
return hashlib.sha1("\n".join(parts).encode()).hexdigest()[:8]
def _folder(name: str) -> Path | None:
"""지금 판 창고 — 원자료 지문 · 없으면 있는 판 중 마지막 · 그것도 없으면 None."""
tag = _stamp(name)
if tag:
return TILES_ROOT / name / f"v{RULE_VERSION}_{tag}"
found = sorted((TILES_ROOT / name).glob(f"v{RULE_VERSION}_*"))
return found[-1] if found else None
def ensure_index(name: str) -> Path | None:
"""3857 색인 GeoPackage — 없으면 원자료에서 만듦(잠금 · `.part` → 바꿈) · 원자료 없음 = None."""
import geopandas as gpd
import pyogrio
tag = _stamp(name)
if tag is None:
return None
path = INDEX_ROOT / f"{name}_v{RULE_VERSION}_{tag}.gpkg"
if path.is_file():
return path
rule = RULES[name]
with _Lock(path.with_suffix(".lock")):
if path.is_file():
return path
part = path.with_suffix(".part.gpkg")
part.unlink(missing_ok=True)
for shp in sources(name):
frame = pyogrio.read_dataframe(shp, encoding=ENCODING, columns=list(rule.columns))
frame = frame[frame.geometry.notna() & ~frame.geometry.is_empty].to_crs(3857)
rows = frame.drop(columns="geometry").to_dict("records")
out = gpd.GeoDataFrame(
[rule.props(row, shp) for row in rows] or None,
geometry=frame.geometry.values,
crs=3857,
)
out["area"] = out.geometry.area
pyogrio.write_dataframe(
out, part, layer=name, driver="GPKG", promote_to_multi=True, append=part.exists()
)
part.replace(path)
return path
def _empty(name: str) -> bytes:
import mapbox_vector_tile
return mapbox_vector_tile.encode([{"name": name, "features": []}])
def build_tile(name: str, z: int, x: int, y: int) -> bytes:
"""조각 하나 만들기(창고 안 씀) — 색인에서 읽어 MVT."""
import mapbox_vector_tile
import pyogrio
import shapely
rule = RULES[name]
if not (rule.min_zoom <= z <= MAX_ZOOM) or not in_bounds(z, x, y):
return _empty(name)
index = ensure_index(name)
if index is None:
return _empty(name)
west, south, east, north = tile_bounds(z, x, y)
pad = (east - west) * BUFFER_PX / TILE_PX
box = (west - pad, south - pad, east + pad, north + pad)
px_m = WORLD_M / (TILE_PX * 2**z)
min_area = rule.min_area_px.get(z, 0.0) * px_m * px_m
frame = pyogrio.read_dataframe(
index, layer=name, bbox=box, where=f"area >= {min_area}" if min_area else None
)
if frame.empty:
return _empty(name)
geoms = shapely.clip_by_rect(frame.geometry.values, *box)
# 고리 보존 안 함 = 빠름(작게 무너진 면은 빠짐 · 그림만 쓰므로 유효성 안 따짐)
geoms = shapely.simplify(geoms, rule.simplify_px * px_m, preserve_topology=False)
# 조각 칸 좌표(위 = +y)로 묶어 옮김(인코더 한 도형씩 = 느림 · 반올림 · 겹친 점은 인코더) ·
# normalize = 바깥 고리 시계 방향 → 인코더가 y 를 뒤집으면 MVT 규칙(바깥 = 양의 넓이)
scale = EXTENT / (east - west)
geoms = shapely.normalize(shapely.transform(geoms, lambda xy: (xy - (west, south)) * scale))
keep = ~shapely.is_empty(geoms)
if not keep.any():
return _empty(name)
props = frame.drop(columns=["geometry", "area"]).to_dict("records")
features = [
{"geometry": geom, "properties": prop}
for geom, prop, ok in zip(geoms, props, keep, strict=True)
if ok
]
return mapbox_vector_tile.encode(
[{"name": name, "features": features}],
default_options={"extents": EXTENT, "check_winding_order": False},
)
def tile_path(folder: Path, z: int, x: int, y: int) -> Path:
return folder / str(z) / str(x) / f"{y}.pbf"
def ensure_tile(name: str, z: int, x: int, y: int) -> bytes:
"""조각 바이트 — 창고에 있으면 그대로 · 없으면 만들어 저장(잠금 · `.part` → 바꿈).
모르는 name · 범위 밖 번호 = `KeyError`."""
if name not in RULES:
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}")
if z < RULES[name].min_zoom or not in_bounds(z, x, y):
return _empty(name)
folder = _folder(name)
if folder is None:
return _empty(name)
path = tile_path(folder, z, x, y)
if path.is_file():
return path.read_bytes()
if _stamp(name) is None:
return _empty(name) # 원자료 없는 PC = 남은 판에 없으면 빈 조각(저장 안 함)
with _Lock(path.with_suffix(".lock")):
if path.is_file():
return path.read_bytes()
data = build_tile(name, z, x, y)
part = path.with_suffix(".part")
part.write_bytes(data)
part.replace(path)
return data
+156
View File
@@ -0,0 +1,156 @@
"""그림 조각 대리 — 바깥 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, ""