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:
@@ -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
|
||||
@@ -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, ""
|
||||
Reference in New Issue
Block a user