Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py
T
eomsangdonandClaude Opus 5 a9caf872b2 perf(B04/B05): 도엽 표시본 도입 + 배경 범위·도엽 기준을 계획노선으로 변경
- 도엽 병합본의 화면 표시용 사본 생성(B04_wf1_Surface_Engine_SheetView):
  프로젝트 + 500m로 잘라내고 좌표를 소수 7자리로 줄여 등고선 37.3MB -> 0.97MB.
  분석용 원본은 그대로 둔다(배수유역은 상류까지 넓은 범위가 필요).
- /geojson의 도엽 레이어를 파일 그대로 + ETag로 전송 — 매 요청 2.0초 재직렬화 제거(0.008초).
- /vworld-map도 ETag 전송으로 바꾸고, 프론트가 붙이던 `&_t=` 시간꼬리표 제거.
- 도엽 레이어를 브라우저 보관함 경유로 조회하고 준비화면에서 미리 담는다.
- 배경 지도 범위 = 라이다 범위 + 계획노선 범위 + 여유 300m(SURFACE_MAP_MARGIN_M).
- 도엽 기준 좌표 = 계획노선 시점·종점(없으면 라이다 중심). 같은 도엽이면 9매,
  이웃 도엽에 걸치면 12매. 표본 프로젝트는 기존 9매와 동일(회귀 없음).
- 선정에서 빠진 도엽 zip 정리(prune_sheets) — 표본에서 30매 중 21매가 다른 지역 잔재.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:47:29 +09:00

244 lines
10 KiB
Python

import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetView import build_sheet_view
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetParser import SHEET_LAYERS # noqa: E402
_SHEET_GEOJSON_FILES = {f"도엽_{k}": f"도엽_{k}.geojson" for k in SHEET_LAYERS}
# VWorld 메타 API
@router.get("/{project_id}/vworld-meta", response_model=None)
async def get_vworld_meta(
project_id: UUID, layer_name: str = "satellite"
) -> dict[str, Any] | JSONResponse:
"""VWorld 위성 맵 이미지 매핑 좌표 메타데이터를 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
target_layer = "white" if layer_name.lower() in ["gray", "white"] else layer_name.lower()
meta_name = f"vworld_{target_layer}_meta.json"
meta_path = target_dir / meta_name
if not meta_path.exists() and target_layer == "satellite":
meta_path = target_dir / "vworld_meta.json"
if not meta_path.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.",
},
)
return json.loads(meta_path.read_text(encoding="utf-8"))
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# VWorld 맵 API
@router.get("/{project_id}/vworld-map", response_model=None)
async def get_vworld_map(
project_id: UUID, request: Request, layer_name: str = "satellite"
) -> Response | JSONResponse:
"""배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304)."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
target_layer = layer_name.lower()
if target_layer in ["gray", "white"]:
target_layer = "white"
map_name = f"vworld_{target_layer}.png"
map_path = target_dir / map_name
if not map_path.exists() and target_layer == "satellite":
map_path = target_dir / "vworld_map.png"
if not map_path.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"VWorld {layer_name} 지도가 존재하지 않습니다.",
},
)
return cached_file_response(request, map_path, "image/png")
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# GeoJSON 조회 API
@router.get("/{project_id}/geojson", response_model=None)
async def get_project_geojson(
project_id: UUID, layer: str, request: Request
) -> dict[str, Any] | Response | JSONResponse:
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.
도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을
파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
layer_mapping = {
"지적도": "연속지적도_bounds.geojson",
"용도지역": "용도지역도_bounds.geojson",
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
"등고선": "등고선_bounds.geojson",
**_SHEET_GEOJSON_FILES,
}
filename = layer_mapping.get(layer)
if not filename:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "유효하지 않은 레이어명입니다."},
)
filepath = target_dir / filename
# 등고선은 전처리 이후 국가 gpkg가 추가된 경우를 대비해 요청 시점에 크롭 생성한다.
if layer == "등고선" and not filepath.exists():
from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import (
crop_national_contours,
read_bounds_wgs84_from_meta,
)
bounds_wgs84 = read_bounds_wgs84_from_meta(target_dir)
if bounds_wgs84 is not None:
await asyncio.to_thread(crop_national_contours, bounds_wgs84, target_dir)
if not filepath.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"요청한 레이어({layer}) 파일이 존재하지 않습니다.",
},
)
if layer in _SHEET_GEOJSON_FILES:
view_path = await asyncio.to_thread(build_sheet_view, filepath, target_dir)
return cached_file_response(
request,
view_path or filepath,
"application/geo+json",
)
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
if simplified_filepath.exists():
try:
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception:
pass
try:
import geopandas as gpd
gdf = gpd.read_file(filepath)
gdf["geometry"] = gdf["geometry"].simplify(
tolerance=0.00003, preserve_topology=True
)
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
return json.loads(filepath.read_text(encoding="utf-8"))
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
@tiles_router.get("/tiles/{project_id}/{layer}/{z}/{x}/{y}.pbf", response_model=None)
async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) -> Response:
"""프로젝트의 특정 레이어에 대한 정밀 벡터 타일(MVT) 조각을 동적으로 렌더링하여 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
layer_mapping = {
"지적도": "연속지적도_bounds.geojson",
"용도지역": "용도지역도_bounds.geojson",
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
"등고선": "등고선_bounds.geojson",
"임도노선": "임도노선.geojson",
**_SHEET_GEOJSON_FILES,
}
filename = layer_mapping.get(layer)
if not filename:
raise HTTPException(status_code=400, detail="유효하지 않은 레이어명입니다.")
filepath = target_dir / filename
if layer == "임도노선" and not filepath.exists():
from config.config_system import PROJECT_ROOT
road_shp_candidates = list(PROJECT_ROOT.glob("samples/**/*_Polyline.shp"))
if road_shp_candidates:
try:
import geopandas as gpd
gdf = gpd.read_file(road_shp_candidates[0])
gdf = gdf.to_crs(epsg=4326)
filepath.write_text(gdf.to_json(), encoding="utf-8")
except Exception:
pass
if not filepath.exists():
import mapbox_vector_tile
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
return Response(content=empty_tile, media_type="application/x-protobuf")
cache_key = f"{project_id}_{layer}"
from B04_wf1_Surface.B04_wf1_Surface_Engine_MvtHelper import generate_mvt_tile
mvt_bytes = generate_mvt_tile(filepath, cache_key, z, x, y, layer_name=layer)
return Response(
content=mvt_bytes,
media_type="application/x-protobuf",
headers={"Content-Encoding": "identity"},
)
except Exception:
import mapbox_vector_tile
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
return Response(content=empty_tile, media_type="application/x-protobuf")