- 필터 = 업체 분류 · 점진 TIN · 둘의 다듬기 판만 · 분석 허용 목록 = Ground 등록부(available_filters) 한 벌 - 표현 = DTM · implicit 만 · 평활 = DTM 만 · config 기본 목록 · PRECOMPUTE · 설정 사전 맞춤(설정 지문 바뀜 — 768 에서 다시 지음) - 등고선 = 격자 · 굳힌 격자만(삼각망 굽기 · 외곽 맞춤 · implicit RBF 폴백 걷음) · 표본기 · 경로 찾기 tin · nurbs · meshfree 갈래 걷음 - 수치지형도 = 라플라스만(TIN 둘 · TPS · ANUDEM형 · 다중해상도 걷음) · B04 단추 하나 - 안 부르는 길 — B04 JSON 점군 GET · ground-stats GET + TS · B07 옛 계획평면도(라이다) 짓기 · lidar_source · LIDAR_ID · plan_lidar 종류 이름 · SOURCE_FILTER_LABELS · MODEL_METHODS(py) · MESH_* 설정 - 옛 manifest 의 지운 갈래 줄은 DB 로 되살리지 않음 · 옛 csf 행은 미리보기 · 등고선 그대로 열림 · 지운 이름 요청 = 400 - 낡은 글 — Ground · Pipeline 머리 · WF1 「없으면 csf」 · B07 음영 기본 「csf」 → 빈 값 · 등고선 폴백 「csf」 → 404 · B04 3D 보기 처음값 · B05 Schema · B06 Prebuild · 씨닝 설명 - 시험 지움 2 — test_b04_contour_raster · test_b04_contour_equivalence(지운 삼각망 굽기 · 없는 프로젝트를 봐 건너뛰던 것) - 시험 덜어냄 — test_b04_ground_filters CSF · 격자 최저 네 경우(지운 필터) - 시험 고침 — 허용 목록 · 형 검사에 걸린 csf · tin · grid_min_z · meshfree · tin_sheet 견본을 ptd · implicit · laplace 로(54_22 · 238_1 · 443 · 342_2 · 346_3 · 351_1 · 442_2 · 271 + helper · 344_1 · 133_1 · 343_2 · 637 · 754_1 · 763_1 깨진 tin → 깨진 implicit · 763_3 · b05 둘 · b06 · sheet_surface TIN(도엽선) 경우 지움) · 이름만 바꿈(273_2 · 343_1 · 343_3 · 624_1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
9.2 KiB
Python
216 lines
9.2 KiB
Python
"""B04 지표면 모델 등고선 FastAPI 라우터 (700줄 규정에 따라 본 라우터에서 분리)."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import math
|
|
import re
|
|
import time
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import numpy as np
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse, Response
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B04_PreProcess.B04_PreProcess_Engine_Contour import (
|
|
CONTOUR_EXTRACTOR_VERSION,
|
|
contour_cache_is_stale,
|
|
extract_contours,
|
|
)
|
|
from common_util.common_util_atomic import atomic_write_bytes
|
|
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
|
|
from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"])
|
|
|
|
MODEL_REPRESENTATIONS = {
|
|
"dtm": "regular_grid",
|
|
"implicit": "local_rbf_height_field",
|
|
}
|
|
|
|
|
|
def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool:
|
|
"""캐시가 현재 추출기 버전이고 모델 · 같은 필터 dtm · 굳힌 격자보다 최신인지 검사한다."""
|
|
if contour_cache_is_stale(contour_path, model_path):
|
|
return False
|
|
try:
|
|
with contour_path.open("rb") as cache_file:
|
|
head = cache_file.read(256).decode("utf-8", errors="ignore")
|
|
except OSError:
|
|
return False
|
|
match = re.search(r'"extractor_version"\s*:\s*(\d+)', head)
|
|
return bool(match) and int(match.group(1)) == CONTOUR_EXTRACTOR_VERSION
|
|
|
|
|
|
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
|
|
async def get_surface_model_contour(
|
|
request: Request,
|
|
project_id: UUID,
|
|
model_id: int,
|
|
interval: float = 1.0,
|
|
smooth: bool = False,
|
|
recalculate: bool = False,
|
|
) -> Response | JSONResponse:
|
|
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT model_type, model_file_path
|
|
FROM surface_models
|
|
WHERE id = %s AND project_id = %s
|
|
""",
|
|
(model_id, str(project_id)),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
|
)
|
|
model_file_path = row[1]
|
|
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
if not model_file_path:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
|
)
|
|
|
|
model_path = project_root / model_file_path
|
|
models_dir = model_path.parent
|
|
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
|
|
stem = model_path.stem
|
|
# 파일 이름 = `{표현}_{필터}` · 지운 표현(608 · tin 등)이면 404
|
|
method, _, filter_key = stem.partition("_")
|
|
if smooth and method == "dtm":
|
|
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
|
contour_model_path = models_dir / f"{stem}_smooth.npz"
|
|
representation = "regular_grid"
|
|
else:
|
|
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
|
contour_model_path = model_path
|
|
representation = MODEL_REPRESENTATIONS.get(method) if filter_key else None
|
|
contour_path = models_dir / contour_filename
|
|
if recalculate or not _is_contour_cache_current(contour_path, contour_model_path):
|
|
if not math.isfinite(interval) or interval < 0.5:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
|
|
)
|
|
if not contour_model_path.is_file() or representation is None:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"status": "error",
|
|
"message": "등고선 생성에 필요한 모델 파일이 없습니다.",
|
|
},
|
|
)
|
|
generation_started = time.monotonic()
|
|
contours = await asyncio.to_thread(
|
|
extract_contours,
|
|
contour_model_path,
|
|
representation,
|
|
interval,
|
|
SURFACE_CONTOUR_GRID_RESOLUTION_M,
|
|
None,
|
|
)
|
|
logger.info(
|
|
"등고선 온디맨드 계산: filter=%s, method=%s, smooth=%s, "
|
|
"interval=%.1f, duration=%.1fs",
|
|
filter_key,
|
|
method,
|
|
smooth,
|
|
interval,
|
|
time.monotonic() - generation_started,
|
|
)
|
|
# 화면은 이 상자의 중심을 원점 삼아 등고선을 놓는다 — 메시(glb)를 만들 때 쓴
|
|
# 상자와 같아야 등고선이 지형 위에 얹힌다. 도엽 서피스는 라이다와 원점을 맞추려
|
|
# `scene_bounds`를 따로 갖고 있으므로 그 값이 있으면 먼저 쓴다(2026-09-01).
|
|
with np.load(contour_model_path) as model_data:
|
|
if "scene_bounds" in model_data:
|
|
model_bounds = np.asarray(model_data["scene_bounds"], dtype=float)
|
|
elif "bounds" in model_data:
|
|
model_bounds = np.asarray(model_data["bounds"], dtype=float)
|
|
else:
|
|
with np.load(structured_path) as structured:
|
|
model_bounds = np.asarray(structured["bounds"], dtype=float)
|
|
bounds_payload = {
|
|
"x": model_bounds[0].tolist(),
|
|
"y": model_bounds[1].tolist(),
|
|
"z": model_bounds[2].tolist(),
|
|
}
|
|
payload = {
|
|
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
|
|
"project_id": str(project_id),
|
|
"source_filter": filter_key,
|
|
"method": method,
|
|
"interval": interval,
|
|
"bounds": bounds_payload,
|
|
"contours": contours,
|
|
}
|
|
atomic_write_bytes(
|
|
contour_path,
|
|
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
)
|
|
|
|
if not contour_path.is_file():
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"status": "error",
|
|
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
|
|
},
|
|
)
|
|
|
|
# 브라우저가 이미 같은 등고선 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다).
|
|
return cached_file_response(request, contour_path, "application/json", contour_filename)
|
|
|
|
except Exception:
|
|
logger.exception(
|
|
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/surface/models/{model_id}/quality")
|
|
async def get_surface_model_quality(project_id: UUID, model_id: int) -> JSONResponse:
|
|
"""서피스 품질 지표 넷(762-2) — 저장본 지문이 다르면 다시 셈 · 격자 표현 밖이면 null."""
|
|
from B04_PreProcess.B04_PreProcess_Engine_Quality import ensure_quality
|
|
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT model_file_path FROM surface_models WHERE id = %s AND project_id = %s",
|
|
(model_id, str(project_id)),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row or not row[0]:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
|
)
|
|
model_path = Path(resolve_stored_project_path(stored_path)) / row[0]
|
|
method, _, filter_key = model_path.stem.partition("_")
|
|
try:
|
|
quality = await asyncio.to_thread(ensure_quality, model_path.parent, filter_key, method)
|
|
except Exception:
|
|
logger.exception("서피스 품질 지표 실패: project_id=%s, model_id=%s", project_id, model_id)
|
|
quality = None
|
|
if quality:
|
|
quality = {key: value for key, value in quality.items() if key != "key"}
|
|
return JSONResponse(content={"status": "success", "quality": quality})
|