Files
Aislo/common_util/common_util_contour_tiles.py
T
eomsangdonandClaude Opus 5.5 8ce7ef6cbe feat(D 예정노선): 등고선 = 줌별 벡터 조각 — 서버 조각 · 공용 창고 · 화면 고정 소스 (PLAN 615-1 · 615-2 · 요구 496)
- 서버: 새 common_util_contour_tiles — 조각(z8~15) = 전국 등고선 상자 읽기 · 줌 간격 거름 · 단순화 · 3857 · MVT(겹 contour · h · k) · 창고 storage/_shared/contour_tiles/v1_<지문>/z/x/y.pbf · 잠금 · .part 바꿈 · 빈 조각 저장
- 줌 간격: z8 1000 · z9 500(화면 꼭짓점 z13 이하 실측) · z10 200 · z11 100 · z12 50 · z13 25 · z14 10(+25) · z15 5 m
- 길: GET /api/projects/{id}/plan/contour/{z}/{x}/{y}.pbf
- 화면: contourTileSource(위성처럼 고정 · minzoom 8 · maxzoom 15) · 등고 겹 셋 = 벡터 조각 · 받는 동안 서클 · GL 이면 606 등고 도엽 조각 안 받음(옛 캔버스는 그대로) · 주곡선 간격 셈 걷음
- 시험: test_615_contour_tiles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:06:48 +09:00

160 lines
6.3 KiB
Python

"""등고선 줌별 벡터 조각 — 웹 메르카토르 z/x/y MVT 를 공용 창고에(PLAN 615 · 요구 496 · 499).
조각 하나 = 전국 등고선(EPSG:5179)을 조각 상자(+ 여유 8 px)로 읽음 → 줌 간격 거름(SQL · 높이 색인)
→ 5179 에서 자르고 단순화(약 1 px) → 3857 → 조각 상자로 자름 → MVT(겹 `contour` · 속성 `h` 높이 ·
`k` 굵은 선 1/0 · 높이 하나 = 지물 하나). 512 px 조각 · 범위 4096.
창고 = `storage/_shared/contour_tiles/v<규칙 판>_<전국 파일 지문>/z/x/y.pbf` — 처음 볼 때
만들어 영구 · 빈 조각도 저장(다시 안 셈) · 잠금 · `.part` → 바꿈.
전국 파일 없는 PC = 있는 판 조각을 믿음 · 없으면 빈 조각.
화면 = `ui_template_gl_map.ts` `contourTileSource` ·
길 = `D03_PlanAnalysis_Router` `/plan/contour/{z}/{x}/{y}.pbf`.
"""
from __future__ import annotations
import hashlib
import math
from pathlib import Path
import numpy as np
from pyproj import Transformer
from common_util.common_util_terrain_store import NATIONAL_GPKG, _gpkg_stamp, _Lock
from config.config_system import STORAGE_BASE_DIR
TILES_ROOT = Path(STORAGE_BASE_DIR) / "_shared" / "contour_tiles"
RULE_VERSION = 1
MIN_ZOOM, MAX_ZOOM = 8, 15
LAYER = "contour"
EXTENT = 4096
TILE_PX = 512
BUFFER_PX = 8
# 줌별 담는 간격 · 굵은 선(m) — 화면 선 간격 약 6~12 px(분석 496 2장 「보통」)
STEP_M = {8: 1000, 9: 500, 10: 200, 11: 100, 12: 50, 13: 25, 14: 10, 15: 5}
BOLD_M = {8: 5000, 9: 2500, 10: 1000, 11: 500, 12: 250, 13: 25, 14: 25, 15: 25}
SIMPLIFY_PX = {14: 0.5, 15: 0.25} # 그 밖 1 px
# 국내 경위도(밖 = 빈 조각 · 요청 안 함 — 화면 소스 `bounds` 와 같음)
BOUNDS_LONLAT = (124.0, 33.0, 132.0, 39.0)
WORLD_M = 2 * math.pi * 6378137.0
# 단순화 허용치 — 메르카토르 m/px × 위도 36.5° 줄임(실제 땅 m)
GROUND_SCALE = math.cos(math.radians(36.5))
_TO_MERC = Transformer.from_crs("EPSG:5179", "EPSG:3857", always_xy=True)
_FROM_MERC = Transformer.from_crs("EPSG:3857", "EPSG:5179", always_xy=True)
def tile_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
"""조각 상자(EPSG:3857 m · 서 · 남 · 동 · 북)."""
size = WORLD_M / 2**z
west = -WORLD_M / 2 + x * size
north = WORLD_M / 2 - y * size
return west, north - size, west + size, north
def in_bounds(z: int, x: int, y: int) -> bool:
"""조각이 국내 경위도 상자에 닿나."""
lon0, lat0, lon1, lat1 = BOUNDS_LONLAT
n = 2**z
def tx(lon: float) -> int:
return math.floor((lon + 180) / 360 * n)
def ty(lat: float) -> int:
r = math.radians(lat)
return math.floor((1 - math.log(math.tan(r) + 1 / math.cos(r)) / math.pi) / 2 * n)
return tx(lon0) <= x <= tx(lon1) and ty(lat1) <= y <= ty(lat0)
def _folder() -> Path | None:
"""지금 판 창고 — 전국 파일 지문 · 없으면 있는 판 중 마지막 · 그것도 없으면 None."""
stamp = _gpkg_stamp()
if stamp:
tag = hashlib.sha1(stamp.encode()).hexdigest()[:8]
return TILES_ROOT / f"v{RULE_VERSION}_{tag}"
found = sorted(TILES_ROOT.glob(f"v{RULE_VERSION}_*"))
return found[-1] if found else None
def _empty() -> bytes:
import mapbox_vector_tile
return mapbox_vector_tile.encode([{"name": LAYER, "features": []}])
def build_tile(z: int, x: int, y: int) -> bytes:
"""조각 하나 만들기(창고 안 씀) — 전국 파일에서 읽어 MVT."""
import mapbox_vector_tile
import pyogrio
import shapely
if not (MIN_ZOOM <= z <= MAX_ZOOM) or not in_bounds(z, x, y) or not NATIONAL_GPKG.is_file():
return _empty()
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)
# 메르카토르 상자 둘레 → 5179 상자(가장자리 휨 감쌈)
t = np.linspace(0.0, 1.0, 9)
mx = np.concatenate(
[box[0] + (box[2] - box[0]) * t] * 2 + [np.full(9, box[0]), np.full(9, box[2])]
)
my = np.concatenate(
[np.full(9, box[1]), np.full(9, box[3])] + [box[1] + (box[3] - box[1]) * t] * 2
)
gx, gy = _FROM_MERC.transform(mx, my)
grow = 10.0
rect = (gx.min() - grow, gy.min() - grow, gx.max() + grow, gy.max() + grow)
step, bold = STEP_M[z], BOLD_M[z]
frame = pyogrio.read_dataframe(
NATIONAL_GPKG,
bbox=rect,
columns=["CTRLN_HG"],
where=f"CTRLN_HG % {step} = 0 OR CTRLN_HG % {bold} = 0",
)
if frame.empty:
return _empty()
tol = SIMPLIFY_PX.get(z, 1.0) * WORLD_M / (TILE_PX * 2**z) * GROUND_SCALE
geoms = shapely.simplify(shapely.clip_by_rect(frame.geometry.values, *rect), tol)
geoms = shapely.transform(
geoms, lambda xy: np.column_stack(_TO_MERC.transform(xy[:, 0], xy[:, 1]))
)
geoms = shapely.clip_by_rect(geoms, *box)
parts, owner = shapely.get_parts(geoms, return_index=True)
keep = shapely.get_num_points(parts) >= 2
parts, levels = parts[keep], frame["CTRLN_HG"].to_numpy(dtype=np.int64)[owner][keep]
features = [
{
"geometry": shapely.multilinestrings(parts[levels == level]),
"properties": {"h": int(level), "k": int(level % bold == 0)},
}
for level in np.unique(levels)
]
return mapbox_vector_tile.encode(
[{"name": LAYER, "features": features}],
default_options={"quantize_bounds": (west, south, east, north), "extents": EXTENT},
)
def ensure_tile(z: int, x: int, y: int) -> bytes:
"""조각 바이트 — 창고에 있으면 그대로 · 없으면 만들어 저장(잠금 · `.part` → 바꿈)."""
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 not in_bounds(z, x, y):
return _empty()
folder = _folder()
if folder is None:
return _empty()
path = folder / str(z) / str(x) / f"{y}.pbf"
if path.is_file():
return path.read_bytes()
if not NATIONAL_GPKG.is_file():
return _empty() # 전국 파일 없는 PC = 남은 판에 없으면 빈 조각(저장 안 함)
with _Lock(path.with_suffix(".lock")):
if path.is_file():
return path.read_bytes()
data = build_tile(z, x, y)
part = path.with_suffix(".part")
part.write_bytes(data)
part.replace(path)
return data