DB·파일에는 뜻 없는 자릿수가 붙어 있고(1.4000000000000001 꼴) 서버는 응답을 전혀 압축하지 않고 있었음. B05 한 번 진입에 118MB 수신(2026-09-06 실측). - main.py: 압축 미들웨어(1KB 이상). 3D 예상형상(/corridor)은 예외 — 숫자 배열이라 절반만 줄면서 압축에 493ms 듦(18.6->9.4MB). - common_util_json: round_floats(value, digits) + LONLAT_DIGITS 7(1.1cm) · METRE_DIGITS 6. 저장 파일은 그대로 두고 내려보낼 때만 줄임. 브라우저가 다시 계산에 넣는 값(종횡단 지반선)은 줄이면 안 된다고 머리에 경고 적음. - 도엽 GeoJSON: 자릿수만 줄인 표시용 사본(.display.geojson)을 한 번 만들어 서빙. 원본이 새로 깔리면 자동 재생성, 실패하면 원본 그대로. 등고선 도엽 65.9->36.8MB, 압축까지 11.3MB. - 배수유역 응답: 위경도 7자리. 151->100KB, 압축 22KB. ruff 통과, 테스트 390 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
392 lines
17 KiB
Python
392 lines
17 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 common_util.common_util_http_cache import cached_file_response
|
|
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
|
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_PreProcess.B04_PreProcess_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_PreProcess" / "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_PreProcess" / "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_PreProcess" / "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_PreProcess.B04_PreProcess_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:
|
|
# 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시).
|
|
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
|
|
# 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은
|
|
# 그대로고 뜻 없는 자리만 사라진다.
|
|
return cached_file_response(
|
|
request,
|
|
await asyncio.to_thread(_display_copy, 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_PreProcess" / "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_PreProcess.B04_PreProcess_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")
|
|
|
|
|
|
# 계획노선(B03 업로드 CSV) 폴리라인 조회 — 2D 지도에 계획선을 겹쳐 그리는 데 쓴다.
|
|
@router.get("/{project_id}/planned-route", response_model=None)
|
|
async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
|
"""B03에 업로드된 계획노선을 사업지 좌표계(m) 점 목록으로 돌려준다.
|
|
|
|
B05의 확정 경로가 아니라 **원청이 준 계획선**이다. 아직 올리지 않았거나 좌표가
|
|
모자라면 빈 목록을 돌려준다 — 화면은 계획선만 빼고 그대로 그린다.
|
|
"""
|
|
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
|
|
from common_util.common_util_route_geometry import (
|
|
find_planned_route_file,
|
|
read_planned_route,
|
|
)
|
|
|
|
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))
|
|
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
|
|
planned = read_planned_route(route_file) if route_file else None
|
|
if planned is None or len(planned.vertices) < 2:
|
|
return {"status": "success", "points": []}
|
|
|
|
# 배경 지도 메타와 같은 좌표계로 맞춘다. 노선 파일이 제 좌표계를 적어 두었고
|
|
# 그것이 프로젝트 좌표계와 다르면 여기서 한 번 옮긴다.
|
|
target_epsg = project_epsg_from_prj(project_root)
|
|
points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices]
|
|
source_epsg = planned.crs_input or target_epsg
|
|
if source_epsg != target_epsg:
|
|
from pyproj import Transformer
|
|
|
|
transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True)
|
|
points = [transformer.transform(x, y) for x, y in points]
|
|
return {"status": "success", "points": [{"x": x, "y": y} for x, y in points]}
|
|
except Exception as exc:
|
|
logger.warning("계획노선 조회 실패: %s", exc)
|
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
|
|
|
|
|
|
# ── 도엽등고 서피스 보간 방식 (2026-09-01) ────────────────────────────────
|
|
# 자동 전처리는 기본 방식 하나만 만든다. 나머지는 관리자가 화면에서 그 방식을
|
|
# 고를 때 여기로 요청해 만든다. 파일명이 방식마다 갈려 있어(`dtm_sheet_{방식}.npz`)
|
|
# 한 번 만든 방식은 다시 골라도 그대로 쓰인다.
|
|
|
|
|
|
@router.get("/{project_id}/surface/sheet-methods", response_model=None)
|
|
async def list_sheet_methods(project_id: UUID) -> dict[str, Any]:
|
|
"""고를 수 있는 도엽 보간 방식과, 이미 만들어 둔 방식을 알려준다."""
|
|
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_LABELS
|
|
from config.config_system import SHEET_SURFACE_METHODS
|
|
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
models_dir = Path(resolve_stored_project_path(stored_path)) / "B04_PreProcess" / "models"
|
|
return {
|
|
"status": "success",
|
|
"methods": [
|
|
{
|
|
"key": key,
|
|
"label": SHEET_METHOD_LABELS.get(key, key),
|
|
"built": (models_dir / f"dtm_sheet_{key}.npz").is_file(),
|
|
}
|
|
for key in SHEET_SURFACE_METHODS
|
|
],
|
|
}
|
|
|
|
|
|
@router.post("/{project_id}/surface/sheet-surface", response_model=None)
|
|
async def build_sheet_surface(project_id: UUID, request: Request) -> dict[str, Any] | JSONResponse:
|
|
"""도엽 서피스 한 방식을 만들어 DB에 등록한다. 이미 있으면 그대로 둔다."""
|
|
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_BUILDERS
|
|
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import build_sheet_surface_from_route
|
|
from B04_PreProcess.B04_PreProcess_Repository import save_sheet_surface_models
|
|
|
|
body = await request.json()
|
|
method = str(body.get("method") or "")
|
|
if method not in SHEET_METHOD_BUILDERS:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"status": "error", "message": f"지원하지 않는 보간 방식입니다: {method}"},
|
|
)
|
|
|
|
pool = get_db_pool()
|
|
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))
|
|
stage_root = project_root / "B04_PreProcess"
|
|
models = await asyncio.to_thread(
|
|
build_sheet_surface_from_route,
|
|
project_root,
|
|
stage_root / "processed",
|
|
stage_root / "models",
|
|
[method],
|
|
)
|
|
if not models:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"status": "error", "message": "도엽 서피스를 만들지 못했습니다."},
|
|
)
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
model_ids = await save_sheet_surface_models(connection, project_id, models)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
return {"status": "success", "method": method, "surface_model_ids": model_ids}
|
|
|
|
|
|
def _display_copy(source: Path) -> Path:
|
|
"""도엽 GeoJSON 의 **표시용 사본**(좌표 자릿수만 줄인 것) 경로를 돌려준다.
|
|
|
|
저장된 파일에는 `1.4000000000000001` 꼴로 뜻 없는 자리가 붙어 있다 — 위경도 7자리
|
|
(1.1cm)로 맞추면 형상은 그대로면서 등고선 도엽이 65.9MB → 36.8MB 가 된다(2026-09-06 실측).
|
|
압축까지 얹으면 11.3MB.
|
|
|
|
사본은 **한 번만** 만든다(66MB 기준 약 3초). 원본이 새로 깔리면 수정시각이 앞서므로
|
|
저절로 다시 만든다. 만들다 실패하면 원본을 그대로 보낸다 — 화면을 막지 않는다.
|
|
"""
|
|
target = source.with_suffix(".display.geojson")
|
|
try:
|
|
if target.exists() and target.stat().st_mtime >= source.stat().st_mtime:
|
|
return target
|
|
payload = json.loads(source.read_text(encoding="utf-8"))
|
|
target.write_text(
|
|
json.dumps(
|
|
round_floats(payload, LONLAT_DIGITS), ensure_ascii=False, separators=(",", ":")
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
logger.info(
|
|
"도엽 표시용 사본 생성: %s (%.1fMB → %.1fMB)",
|
|
source.name,
|
|
source.stat().st_size / 1e6,
|
|
target.stat().st_size / 1e6,
|
|
)
|
|
return target
|
|
except Exception as exc: # 사본 실패가 화면을 막으면 안 된다.
|
|
logger.warning("도엽 표시용 사본을 만들지 못해 원본을 보냅니다 (%s): %s", source.name, exc)
|
|
return source
|