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
+108
View File
@@ -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())