refactor(B04): B04_wf1_Surface -> B04_PreProcess 전면 개명
- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""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,
|
||||
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 = {
|
||||
"meshfree": "meshfree_surfels",
|
||||
"dtm": "regular_grid",
|
||||
"tin": "triangular_mesh",
|
||||
"nurbs": "bspline_surface",
|
||||
"implicit": "local_rbf_height_field",
|
||||
}
|
||||
|
||||
|
||||
def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool:
|
||||
"""캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다."""
|
||||
try:
|
||||
if contour_path.stat().st_mtime < model_path.stat().st_mtime:
|
||||
return False
|
||||
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_type, model_file_path = row[0], 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
|
||||
parts = stem.split("_")
|
||||
if len(parts) >= 2:
|
||||
method = parts[0]
|
||||
filter_key = "_".join(parts[1:])
|
||||
else:
|
||||
method = model_type
|
||||
filter_key = "csf"
|
||||
if smooth and method in ("dtm", "tin"):
|
||||
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
contour_model_path = models_dir / f"{stem}_smooth.npz"
|
||||
representation = "regular_grid" if method == "dtm" else "triangular_mesh"
|
||||
else:
|
||||
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
contour_model_path = model_path
|
||||
representation = MODEL_REPRESENTATIONS.get(method)
|
||||
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,
|
||||
)
|
||||
with np.load(contour_model_path) as model_data:
|
||||
if "bounds" in model_data:
|
||||
model_bounds = np.asarray(model_data["bounds"], dtype=float)
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
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": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
Reference in New Issue
Block a user