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,56 @@
|
||||
"""D 예정노선 받은 산림 · 환경 자료 조각 API (PLAN 622-1 · 요구 529).
|
||||
|
||||
- `GET /api/projects/{id}/plan/vt/{name}/{z}/{x}/{y}.pbf` — 벡터 조각(forest_road · forest_soil ·
|
||||
eco_forest · 공용 창고 · `common_util_vector_tiles`)
|
||||
- `GET …/plan/wmt/{name}/{z}/{x}/{y}.png` — 그림 조각 대리(ecology · landslide · 처음 받고 기억 ·
|
||||
`common_util_wms_tiles`) · 빈 · 오류 조각 = 투명 PNG + `X-Tile-Empty` · 짧게 기억
|
||||
권한 = 다른 plan 길과 같음(`main.py` 등록 때 회사 보호).
|
||||
|
||||
계산 자리 = ③ 서버 단독.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
|
||||
from common_util import common_util_vector_tiles as vector_tiles
|
||||
from common_util import common_util_wms_tiles as wms_tiles
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["D03 Plan Tiles"])
|
||||
|
||||
KEEP = "private, max-age=86400"
|
||||
SHORT = f"private, max-age={wms_tiles.SHORT_S}"
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/vt/{name}/{z}/{x}/{y}.pbf", response_model=None)
|
||||
async def plan_vector_tile(project_id: UUID, name: str, z: int, x: int, y: int) -> Response:
|
||||
"""받은 산림 자료 벡터 조각 — 공용 창고 · 없으면 원자료에서 만듦 · 빈 조각 = 빈 MVT."""
|
||||
if name not in vector_tiles.RULES:
|
||||
raise HTTPException(status_code=404, detail=f"모르는 겹입니다 — {name}")
|
||||
try:
|
||||
tile = await asyncio.to_thread(vector_tiles.ensure_tile, name, z, x, y)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.") from error
|
||||
return Response(
|
||||
content=tile, media_type="application/x-protobuf", headers={"Cache-Control": KEEP}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/wmt/{name}/{z}/{x}/{y}.png", response_model=None)
|
||||
async def plan_wms_tile(project_id: UUID, name: str, z: int, x: int, y: int) -> Response:
|
||||
"""그림 조각 대리 — 처음 받고 기억 · 키 없음 = 404 + 까닭."""
|
||||
if name not in wms_tiles.SOURCES:
|
||||
raise HTTPException(status_code=404, detail=f"모르는 겹입니다 — {name}")
|
||||
try:
|
||||
png, status = await asyncio.to_thread(wms_tiles.ensure_tile, name, z, x, y)
|
||||
except wms_tiles.MissingKeyError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.") from error
|
||||
headers = {"Cache-Control": SHORT if status else KEEP}
|
||||
if status:
|
||||
headers["X-Tile-Empty"] = status
|
||||
return Response(content=png, media_type="image/png", headers=headers)
|
||||
@@ -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, ""
|
||||
@@ -77,6 +77,7 @@ from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_das
|
||||
from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Network import router as d03_plan_network_router
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Router_Tiles import router as d03_plan_tiles_router
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Link import dashboard_router as d04_link_dashboard_router
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Link import router as d04_plan_link_router
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_router
|
||||
@@ -494,6 +495,7 @@ app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company)
|
||||
app.include_router(d03_plan_analysis_router, dependencies=protected_with_company)
|
||||
app.include_router(d03_plan_tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(d03_plan_network_router, dependencies=protected_with_company)
|
||||
app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
|
||||
app.include_router(d04_plan_link_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""받은 산림 · 환경 자료 조각(PLAN 622-1) — 벡터 조각 MVT 풀어 읽기(겹 = name · 남길 칸) · 줌별 규칙
|
||||
(자료 줌 밖 = 빈 조각 · 작은 면은 낮은 줌에서 빠짐) · 빈 조각 · 처음만 만들고 둘째 = 파일 ·
|
||||
원자료 없음 · 그림 조각 대리 기억(받은 것 = 파일 · 빈 · 오류 = 짧게) · 키 없음 = 404 · 키 안 샘.
|
||||
원자료 = 시험 폴더의 작은 SHP(cp949 · EPSG:5186) · 바깥 WMS 는 부르지 않음.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_622_vector_tiles.py -q
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
|
||||
import geopandas as gpd
|
||||
import mapbox_vector_tile
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import LineString, Polygon
|
||||
|
||||
from common_util import common_util_vector_tiles as vt
|
||||
from common_util import common_util_wms_tiles as wt
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Router_Tiles import router
|
||||
|
||||
LON, LAT = 128.0, 36.5 # 시험 도형 자리(국내 산지)
|
||||
_TO_TM = Transformer.from_crs("EPSG:4326", "EPSG:5186", always_xy=True)
|
||||
|
||||
|
||||
def _tile(lon: float, lat: float, z: int) -> tuple[int, int, int]:
|
||||
n = 2**z
|
||||
r = math.radians(lat)
|
||||
y = (1 - math.log(math.tan(r) + 1 / math.cos(r)) / math.pi) / 2 * n
|
||||
return z, int((lon + 180) / 360 * n), int(y)
|
||||
|
||||
|
||||
def _square(cx: float, cy: float, half: float, hole: float = 0.0) -> Polygon:
|
||||
ring = [
|
||||
(cx - half, cy - half),
|
||||
(cx + half, cy - half),
|
||||
(cx + half, cy + half),
|
||||
(cx - half, cy + half),
|
||||
]
|
||||
holes = [
|
||||
[
|
||||
(cx - hole, cy - hole),
|
||||
(cx - hole, cy + hole),
|
||||
(cx + hole, cy + hole),
|
||||
(cx + hole, cy - hole),
|
||||
]
|
||||
]
|
||||
return Polygon(ring, holes if hole else None)
|
||||
|
||||
|
||||
def _write(path, rows: list[dict], geoms: list) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
gpd.GeoDataFrame(rows, geometry=geoms, crs="EPSG:5186").to_file(path, encoding="cp949")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data(tmp_path, monkeypatch):
|
||||
"""시험 원자료 셋 + 창고 · 색인을 시험 폴더로."""
|
||||
root = tmp_path / "data_forest"
|
||||
cx, cy = _TO_TM.transform(LON, LAT)
|
||||
_write(
|
||||
root / "임도망도" / "시험도" / "1.shp",
|
||||
[{"FRRD_NM": "용화간선", "FRRD_DV_B": "1", "FRRD_FCLTW": 4.0, "FRRD_ESTBL": "2019"}],
|
||||
[LineString([(cx - 3000, cy - 2000), (cx + 3000, cy + 2000)])],
|
||||
)
|
||||
_write(
|
||||
root / "산림입지토양도_1-25000" / "FS_IJ100_47" / "47.shp",
|
||||
[{"SLTP_CD": "02"}, {"SLTP_CD": "28"}],
|
||||
[_square(cx, cy, 2000, hole=500), _square(cx + 2600, cy, 40)], # 큰 면(구멍) · 작은 면
|
||||
)
|
||||
_write(
|
||||
root / "경제림육성단지" / "ecoFrst_01_20141202" / "47" / "47.shp",
|
||||
[{"STAGE_NMPL": "형제봉단지"}],
|
||||
[_square(cx - 1000, cy, 800)],
|
||||
)
|
||||
_write(
|
||||
root / "경제림육성단지" / "ecoFrst_02_20141202" / "47" / "47.shp",
|
||||
[{"STAGE_NMPL": "영주북부단지"}],
|
||||
[_square(cx + 1000, cy, 800)],
|
||||
)
|
||||
monkeypatch.setattr(vt, "DATA_ROOT", root)
|
||||
monkeypatch.setattr(vt, "INDEX_ROOT", tmp_path / "index")
|
||||
monkeypatch.setattr(vt, "TILES_ROOT", tmp_path / "tiles")
|
||||
vt._stamp.cache_clear()
|
||||
yield tmp_path
|
||||
vt._stamp.cache_clear()
|
||||
|
||||
|
||||
def _features(name: str, data: bytes) -> list[dict]:
|
||||
return mapbox_vector_tile.decode(data).get(name, {"features": []})["features"]
|
||||
|
||||
|
||||
def test_road_tile_keeps_columns(data):
|
||||
feats = _features("forest_road", vt.ensure_tile("forest_road", *_tile(LON, LAT, 13)))
|
||||
assert len(feats) == 1
|
||||
assert feats[0]["properties"] == {"kind": 1, "name": "용화간선", "width": 4.0, "year": 2019}
|
||||
assert feats[0]["geometry"]["type"] == "LineString"
|
||||
|
||||
|
||||
def test_soil_zoom_rules(data):
|
||||
# 자료 줌 밖(z8 · 9) = 빈 조각 · 저장 안 함
|
||||
assert _features("forest_soil", vt.ensure_tile("forest_soil", *_tile(LON, LAT, 9))) == []
|
||||
assert not list((data / "tiles").rglob("*.pbf"))
|
||||
# z10 = 큰 면만(작은 면 80 m 는 32 px² 밑) · z15 = 작은 면도
|
||||
low = _features("forest_soil", vt.ensure_tile("forest_soil", *_tile(LON, LAT, 10)))
|
||||
assert [f["properties"] for f in low] == [{"code": "02", "name": "갈색약건산림토양"}]
|
||||
lon, lat = Transformer.from_crs("EPSG:5186", "EPSG:4326", always_xy=True).transform(
|
||||
*(v + d for v, d in zip(_TO_TM.transform(LON, LAT), (2600, 0), strict=True))
|
||||
)
|
||||
high = _features("forest_soil", vt.ensure_tile("forest_soil", *_tile(lon, lat, 15)))
|
||||
assert {"code": "28", "name": "암쇄토양"} in [f["properties"] for f in high]
|
||||
|
||||
|
||||
def test_polygon_winding_keeps_hole(data):
|
||||
feats = _features("forest_soil", vt.ensure_tile("forest_soil", *_tile(LON, LAT, 12)))
|
||||
big = [f for f in feats if f["properties"]["code"] == "02"]
|
||||
assert big and big[0]["geometry"]["type"] == "Polygon"
|
||||
assert len(big[0]["geometry"]["coordinates"]) == 2 # 바깥 + 구멍(감김 방향이 맞아야 둘)
|
||||
|
||||
|
||||
def test_eco_owner(data):
|
||||
feats = _features("eco_forest", vt.ensure_tile("eco_forest", *_tile(LON, LAT, 12)))
|
||||
assert sorted((f["properties"]["own"], f["properties"]["name"]) for f in feats) == [
|
||||
("국유", "형제봉단지"),
|
||||
("사유", "영주북부단지"),
|
||||
]
|
||||
|
||||
|
||||
def test_empty_tile_saved_and_second_is_file(data, monkeypatch):
|
||||
tile = _tile(LON + 1.0, LAT + 1.0, 12) # 국내 · 도형 없음
|
||||
first = vt.ensure_tile("forest_road", *tile)
|
||||
assert _features("forest_road", first) == []
|
||||
assert vt.tile_path(vt._folder("forest_road"), *tile).is_file()
|
||||
|
||||
def boom(*_):
|
||||
raise AssertionError("다시 셈")
|
||||
|
||||
monkeypatch.setattr(vt, "build_tile", boom)
|
||||
assert vt.ensure_tile("forest_road", *tile) == first
|
||||
|
||||
|
||||
def test_range_and_unknown(data):
|
||||
with pytest.raises(KeyError):
|
||||
vt.ensure_tile("forest_road", 7, 0, 0)
|
||||
with pytest.raises(KeyError):
|
||||
vt.ensure_tile("forest_road", 16, 0, 0)
|
||||
with pytest.raises(KeyError):
|
||||
vt.ensure_tile("nope", 10, 0, 0)
|
||||
assert _features("forest_road", vt.ensure_tile("forest_road", 10, 0, 0)) == [] # 국내 밖
|
||||
|
||||
|
||||
def test_no_source_is_empty(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(vt, "DATA_ROOT", tmp_path / "none")
|
||||
monkeypatch.setattr(vt, "TILES_ROOT", tmp_path / "tiles")
|
||||
vt._stamp.cache_clear()
|
||||
try:
|
||||
assert _features("forest_road", vt.ensure_tile("forest_road", *_tile(LON, LAT, 12))) == []
|
||||
assert not (tmp_path / "tiles").exists()
|
||||
finally:
|
||||
vt._stamp.cache_clear()
|
||||
|
||||
|
||||
# ── 그림 조각 대리 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _png(alpha: int) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGBA", (256, 256), (20, 160, 0, alpha)).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wms(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(wt, "WMS_ROOT", tmp_path / "wms")
|
||||
monkeypatch.setattr(wt, "_SHORT", {})
|
||||
calls: list[tuple] = []
|
||||
answers: dict[str, object] = {}
|
||||
|
||||
def fetch(name, z, x, y):
|
||||
calls.append((name, z, x, y))
|
||||
answer = answers.get(name, _png(255))
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return answer
|
||||
|
||||
monkeypatch.setattr(wt, "fetch", fetch)
|
||||
return calls, answers
|
||||
|
||||
|
||||
def test_wms_first_fetch_then_file(wms):
|
||||
calls, _ = wms
|
||||
png, status = wt.ensure_tile("ecology", 13, 7000, 3200)
|
||||
assert status == "" and png.startswith(b"\x89PNG")
|
||||
assert wt.ensure_tile("ecology", 13, 7000, 3200) == (png, "")
|
||||
assert len(calls) == 1
|
||||
assert (wt.WMS_ROOT / "ecology" / "13" / "7000" / "3200.png").is_file()
|
||||
|
||||
|
||||
def test_wms_empty_and_error_short_memory(wms, monkeypatch):
|
||||
calls, answers = wms
|
||||
answers["landslide"] = _png(0)
|
||||
assert wt.ensure_tile("landslide", 12, 3500, 1600) == (wt.blank(), "empty")
|
||||
assert wt.ensure_tile("landslide", 12, 3500, 1600)[1] == "empty"
|
||||
answers["ecology"] = RuntimeError("ecology 조각을 받지 못했습니다 — URLError")
|
||||
assert wt.ensure_tile("ecology", 12, 3500, 1600) == (wt.blank(), "error")
|
||||
assert len(calls) == 2 # 둘째는 기억에서
|
||||
assert not list(wt.WMS_ROOT.rglob("*.png"))
|
||||
# 기억 시간 지나면 다시 받음
|
||||
now = wt.time.monotonic()
|
||||
monkeypatch.setattr(wt.time, "monotonic", lambda: now + wt.SHORT_S + 1)
|
||||
answers["ecology"] = _png(255)
|
||||
assert wt.ensure_tile("ecology", 12, 3500, 1600)[1] == ""
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_wms_range_and_unknown(wms):
|
||||
with pytest.raises(KeyError):
|
||||
wt.ensure_tile("ecology", 5, 0, 0)
|
||||
with pytest.raises(KeyError):
|
||||
wt.ensure_tile("nope", 10, 0, 0)
|
||||
|
||||
|
||||
def test_wms_key_missing_404_and_key_not_leaked(tmp_path, monkeypatch, caplog):
|
||||
monkeypatch.setattr(wt, "WMS_ROOT", tmp_path / "wms")
|
||||
monkeypatch.setattr(wt, "_SHORT", {})
|
||||
monkeypatch.delenv("SAFEMAP_API_KEY", raising=False)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
pid = "ce266c60-ab1f-416b-92f2-f37d1bf539d8"
|
||||
res = client.get(f"/api/projects/{pid}/plan/wmt/landslide/12/3500/1600.png")
|
||||
assert res.status_code == 404 and "SAFEMAP_API_KEY" in res.json()["detail"]
|
||||
|
||||
# 받기 실패 = 투명 PNG + 머리 · 로그 · 응답에 키 없음
|
||||
secret = "SECRET-KEY-622"
|
||||
monkeypatch.setenv("SAFEMAP_API_KEY", secret)
|
||||
|
||||
def fail(request, timeout):
|
||||
raise OSError(f"fail {request.full_url}")
|
||||
|
||||
monkeypatch.setattr(wt.urllib.request, "urlopen", fail)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
res = client.get(f"/api/projects/{pid}/plan/wmt/landslide/12/3500/1600.png")
|
||||
assert res.status_code == 200 and res.headers["X-Tile-Empty"] == "error"
|
||||
assert res.headers["Cache-Control"] == f"private, max-age={wt.SHORT_S}"
|
||||
assert secret not in caplog.text and secret.encode() not in res.content
|
||||
assert client.get(f"/api/projects/{pid}/plan/wmt/nope/12/0/0.png").status_code == 404
|
||||
|
||||
|
||||
def test_router_vector(data):
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
pid = "ce266c60-ab1f-416b-92f2-f37d1bf539d8"
|
||||
z, x, y = _tile(LON, LAT, 13)
|
||||
res = client.get(f"/api/projects/{pid}/plan/vt/forest_road/{z}/{x}/{y}.pbf")
|
||||
assert res.status_code == 200 and res.headers["content-type"] == "application/x-protobuf"
|
||||
assert _features("forest_road", res.content)[0]["properties"]["kind"] == 1
|
||||
assert client.get(f"/api/projects/{pid}/plan/vt/nope/{z}/{x}/{y}.pbf").status_code == 404
|
||||
assert client.get(f"/api/projects/{pid}/plan/vt/forest_road/7/0/0.pbf").status_code == 400
|
||||
|
||||
|
||||
def test_key_quoting(monkeypatch):
|
||||
monkeypatch.setenv("DATA_GO_KR_API_KEY", "a+b/c=")
|
||||
assert wt._key(wt.SOURCES["ecology"]) == "a%2Bb%2Fc%3D"
|
||||
monkeypatch.setenv("DATA_GO_KR_API_KEY", "a%2Bb")
|
||||
assert wt._key(wt.SOURCES["ecology"]) == "a%2Bb"
|
||||
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""받은 산림 자료 벡터 조각 미리 만들기(PLAN 622-1) — 줌아웃 조각(z8~12)을 공용 창고에 미리.
|
||||
|
||||
실행: ./venv/Scripts/python.exe tools/precompute_vector_tiles.py forest_road --workers 2
|
||||
--zooms 8-12 줌 범위 · --bbox 129.0,36.7,129.3,36.9 경위도 상자만(시험 지역)
|
||||
--status 남은 수 · 크기만
|
||||
|
||||
- 원자료 이름 = `common_util_vector_tiles.RULES`(forest_road · forest_soil · eco_forest).
|
||||
- 색인(GeoPackage) 먼저 한 번 → 조각 목록 = 도형 상자가 닿는 z/x/y(가장 큰 줌에서 세고 위 줌은
|
||||
부모) · 낮은 줌부터.
|
||||
- 셈 = `ensure_tile`(창고에 있으면 건너뜀 = 이어 하기 · 잠금 · `.part` → 바꿈) · 낮은 우선순위 ·
|
||||
일꾼 여럿.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from common_util import common_util_vector_tiles as vt # noqa: E402
|
||||
from tools.precompute_terrain import below_normal # noqa: E402
|
||||
|
||||
|
||||
def tiles_for(bounds: np.ndarray, zooms: range) -> list[tuple[int, int, int]]:
|
||||
"""도형 상자(4 × N · 3857)가 닿는 조각(낮은 줌부터)."""
|
||||
top = zooms.stop - 1
|
||||
size = vt.WORLD_M / 2**top
|
||||
half = vt.WORLD_M / 2
|
||||
x0 = np.floor((bounds[0] + half) / size).astype(np.int64)
|
||||
x1 = np.floor((bounds[2] + half) / size).astype(np.int64)
|
||||
y0 = np.floor((half - bounds[3]) / size).astype(np.int64)
|
||||
y1 = np.floor((half - bounds[1]) / size).astype(np.int64)
|
||||
found: set[tuple[int, int]] = set()
|
||||
for a, b, c, d in zip(x0, x1, y0, y1, strict=True):
|
||||
found.update((x, y) for x in range(a, b + 1) for y in range(c, d + 1))
|
||||
tiles = {(z, x >> (top - z), y >> (top - z)) for z in zooms for x, y in found}
|
||||
return sorted(t for t in tiles if vt.in_bounds(*t))
|
||||
|
||||
|
||||
def build(job: tuple[str, int, int, int]) -> tuple[tuple[str, int, int, int], int, float]:
|
||||
started = time.monotonic()
|
||||
size = len(vt.ensure_tile(*job))
|
||||
return job, size, round(time.monotonic() - started, 2)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="받은 산림 자료 벡터 조각 미리 만들기(PLAN 622-1)")
|
||||
parser.add_argument("name", choices=sorted(vt.RULES))
|
||||
parser.add_argument("--zooms", default="8-12")
|
||||
parser.add_argument("--workers", type=int, default=2)
|
||||
parser.add_argument("--bbox", default="", help="경위도 서,남,동,북 — 이 상자에 닿는 도형만")
|
||||
parser.add_argument("--status", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
lo, _, hi = args.zooms.partition("-")
|
||||
zooms = range(max(int(lo), vt.RULES[args.name].min_zoom), int(hi or lo) + 1)
|
||||
if int(lo) < vt.MIN_ZOOM or zooms.stop - 1 > vt.MAX_ZOOM:
|
||||
parser.error(f"줌 = {vt.MIN_ZOOM}~{vt.MAX_ZOOM}")
|
||||
|
||||
import pyogrio
|
||||
from pyproj import Transformer
|
||||
|
||||
below_normal()
|
||||
started = time.monotonic()
|
||||
index = vt.ensure_index(args.name)
|
||||
folder = vt._folder(args.name)
|
||||
if index is None or folder is None:
|
||||
raise SystemExit(f"원자료 없음: {vt.DATA_ROOT} {vt.RULES[args.name].globs}")
|
||||
print(f"색인 {index.name} · {time.monotonic() - started:.0f} s", flush=True)
|
||||
_, bounds = pyogrio.read_bounds(index, layer=args.name)
|
||||
if args.bbox:
|
||||
w, s, e, n = (float(v) for v in args.bbox.split(","))
|
||||
to_merc = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
|
||||
(mw, me), (ms, mn) = to_merc.transform([w, e], [s, n])
|
||||
hit = (bounds[2] >= mw) & (bounds[0] <= me) & (bounds[3] >= ms) & (bounds[1] <= mn)
|
||||
bounds = bounds[:, hit]
|
||||
tiles = tiles_for(bounds, zooms)
|
||||
todo = [t for t in tiles if not vt.tile_path(folder, *t).is_file()]
|
||||
print(f"조각 {len(tiles)} · 남음 {len(todo)} · 창고 {folder} · 일꾼 {args.workers}", flush=True)
|
||||
if not args.status and todo:
|
||||
started = time.monotonic()
|
||||
with ProcessPoolExecutor(args.workers, initializer=below_normal) as pool:
|
||||
futures = [pool.submit(build, (args.name, *t)) for t in todo]
|
||||
for n, future in enumerate(as_completed(futures), 1):
|
||||
(_, z, x, y), size, seconds = future.result()
|
||||
if n % 50 == 0 or n == len(todo):
|
||||
elapsed = time.monotonic() - started
|
||||
print(
|
||||
f"{n}/{len(todo)} z{z}/{x}/{y} {size} B {seconds} s · {elapsed:.0f} s",
|
||||
flush=True,
|
||||
)
|
||||
for z in zooms:
|
||||
paths = [vt.tile_path(folder, *t) for t in tiles if t[0] == z]
|
||||
have = [p.stat().st_size for p in paths if p.is_file()]
|
||||
print(f"z{z} 조각 {len(paths)} · 있음 {len(have)} · {sum(have) / 1e6:.1f} MB", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user