- 도엽 병합본의 화면 표시용 사본 생성(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>
244 lines
10 KiB
Python
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")
|