Files
Aislo/common_util/common_util_vector_tiles.py
T
eomsangdonandClaude Opus 5.5 251dfd9a5b 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>
2026-10-31 19:16:05 +09:00

289 lines
11 KiB
Python

"""받은 산림 자료 줌별 벡터 조각 — 웹 메르카토르 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