- 필터 = 업체 분류 · 점진 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>
451 lines
21 KiB
Python
451 lines
21 KiB
Python
"""B04 지표면 분석 FastAPI 라우터."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import numpy as np
|
|
from fastapi import APIRouter, Depends, 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 import (
|
|
GROUND_POINT_CACHE_VERSION,
|
|
cache_ground_points,
|
|
run_surface_analysis,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Engine_Extent import (
|
|
planned_route_bounds,
|
|
project_epsg_from_prj,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
|
|
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview
|
|
from B04_PreProcess.B04_PreProcess_Repository import (
|
|
get_input_file,
|
|
list_project_point_cloud_inputs,
|
|
list_project_point_cloud_paths,
|
|
list_surface_models,
|
|
save_surface_analysis_to_db,
|
|
)
|
|
|
|
# 700줄 분리로 옮긴 이름을 **진입 파일에서 그대로 다시 노출**한다 — 옛 경로를 보던
|
|
# 호출부·테스트가 그대로 동작하게 하려는 것(2026-09-04, laptop-main 사례로 확인).
|
|
from B04_PreProcess.B04_PreProcess_Router_Progress import (
|
|
PROGRESS_FILE_RELATIVE as PROGRESS_FILE_RELATIVE,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Router_Progress import _progress_file_path as _progress_file_path
|
|
from B04_PreProcess.B04_PreProcess_Router_Progress import (
|
|
read_surface_progress as read_surface_progress,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Router_Progress import write_surface_progress
|
|
from B04_PreProcess.B04_PreProcess_Router_Status import (
|
|
get_surface_model_preview as get_surface_model_preview,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Router_Status import (
|
|
get_wf1_analysis_status as get_wf1_analysis_status,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Router_Status import router as status_router
|
|
from B04_PreProcess.B04_PreProcess_Schema import (
|
|
SurfaceAnalyzeRequest,
|
|
SurfaceAnalyzeResponse,
|
|
SurfaceConfirmedResponse,
|
|
SurfaceConfirmRequest,
|
|
SurfaceConfirmResponse,
|
|
SurfaceInputFileListResponse,
|
|
SurfaceInputFileSummary,
|
|
SurfaceModelListResponse,
|
|
SurfaceModelSummary,
|
|
)
|
|
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
|
|
from common_util.common_util_auth import require_system_admin
|
|
from common_util.common_util_http_cache import cached_file_response
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_surface_confirmation import (
|
|
confirmed_surface_params,
|
|
get_surface_confirmation_params,
|
|
surface_confirmation_defaults,
|
|
terrain_changes,
|
|
update_contour_interval_param,
|
|
)
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
|
|
|
# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내
|
|
# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04).
|
|
router.include_router(status_router)
|
|
|
|
|
|
@router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse)
|
|
async def analyze_surface(
|
|
project_id: UUID, request: SurfaceAnalyzeRequest
|
|
) -> SurfaceAnalyzeResponse | JSONResponse:
|
|
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다 — 보기만(763-3 · 요구 605).
|
|
|
|
확정 · 단계 상태 · 1 단계 값(`las_selection` 포함)을 안 바꿈 · 미리 셈도 없음. 서피스를 바꾸는
|
|
길 = 다른 모델 [확정](전처리 체인 다시)."""
|
|
try:
|
|
source_filters = request.resolved_filters()
|
|
methods = request.resolved_methods()
|
|
except ValueError as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
|
|
pool = get_db_pool()
|
|
project_root: Path | None = None
|
|
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))
|
|
input_file = await get_input_file(connection, project_id, request.input_file_id)
|
|
las_path = project_root / Path(input_file["raw_file_path"])
|
|
if not las_path.is_file():
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."},
|
|
)
|
|
# 지형 파일이 여러 장이면 합쳐서 다시 만든다 — 자동 전처리와 같은 대상을 쓴다.
|
|
terrain_paths = await list_project_point_cloud_paths(
|
|
connection, project_id, project_root
|
|
)
|
|
|
|
# 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록).
|
|
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
|
|
|
|
def _on_progress(percent: int, stage: str, message: str) -> None:
|
|
write_surface_progress(project_root, percent, stage, message)
|
|
|
|
# 무거운 지형 연산은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
|
|
result = await asyncio.to_thread(
|
|
run_surface_analysis,
|
|
project_root,
|
|
terrain_paths or [las_path],
|
|
source_filters=source_filters,
|
|
methods=methods,
|
|
force=request.force,
|
|
on_progress=_on_progress,
|
|
)
|
|
|
|
# DB 기록 (트랜잭션)
|
|
await connection.begin()
|
|
try:
|
|
surface_model_ids = await save_surface_analysis_to_db(
|
|
connection,
|
|
project_id=project_id,
|
|
input_file_id=request.input_file_id,
|
|
analysis_result=result,
|
|
source_filters=source_filters,
|
|
)
|
|
await connection.commit()
|
|
confirmed = await confirmed_surface_params(connection, str(project_id))
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
|
|
if confirmed is not None:
|
|
write_surface_progress(
|
|
project_root, 100, "completed", "조합을 지었습니다 — 확정 그대로."
|
|
)
|
|
else:
|
|
write_surface_progress(
|
|
project_root,
|
|
100,
|
|
"awaiting_confirmation",
|
|
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
|
)
|
|
|
|
return SurfaceAnalyzeResponse(
|
|
project_id=str(project_id),
|
|
ground_summary=result["ground_summary"],
|
|
manifest_status=result["manifest"].get("status", "unknown"),
|
|
surface_model_ids=surface_model_ids,
|
|
)
|
|
except LookupError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except (OSError, ValueError) as exc: # 보기만 — 단계 상태 그대로 · 진행 파일만 끝(763-3)
|
|
if project_root is not None:
|
|
write_surface_progress(project_root, 100, "failed", str(exc))
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception as exc:
|
|
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
|
|
if project_root is not None:
|
|
write_surface_progress(project_root, 100, "failed", str(exc))
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/surface/models", response_model=SurfaceModelListResponse)
|
|
async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSONResponse:
|
|
"""프로젝트의 지표면 모델 목록을 조회한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
models = await list_surface_models(connection, project_id)
|
|
return SurfaceModelListResponse(
|
|
project_id=str(project_id),
|
|
models=[SurfaceModelSummary(**model) for model in models],
|
|
)
|
|
except Exception:
|
|
logger.exception("B04 지표면 모델 목록 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "모델 목록 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/surface/confirm", response_model=SurfaceConfirmResponse)
|
|
async def confirm_surface(
|
|
project_id: UUID,
|
|
request: SurfaceConfirmRequest,
|
|
_session: dict[str, Any] = Depends(require_system_admin),
|
|
) -> SurfaceConfirmResponse | JSONResponse:
|
|
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다.
|
|
|
|
같은 서피스(필터 · 표현 · 평활) 다시 [확정] = 등고선 간격만 · 체인 없음. 다른 서피스 = 업로드와
|
|
같은 전처리 체인(B05~B09 초기화 → 자동 설계 · 추천 미리 셈 · 763-3 · 요구 605)."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
models = await list_surface_models(connection, project_id)
|
|
selected_model = next(
|
|
(model for model in models if model["id"] == request.model_id),
|
|
None,
|
|
)
|
|
if selected_model is None:
|
|
raise LookupError("확정할 지표면 모델을 찾을 수 없습니다.")
|
|
generation_params = selected_model.get("generation_params") or {}
|
|
source_filter = generation_params.get("source_filter")
|
|
if not source_filter:
|
|
raise LookupError("선택한 모델의 지면 필터 정보를 찾을 수 없습니다.")
|
|
|
|
selection = surface_confirmation_defaults()
|
|
selection.update(
|
|
{
|
|
"source_filter": str(source_filter),
|
|
"method": str(selected_model["model_type"]),
|
|
"smooth": (
|
|
request.smooth if request.smooth is not None else selection["smooth"]
|
|
),
|
|
"contour_interval_m": (
|
|
request.contour_interval_m
|
|
if request.contour_interval_m is not None
|
|
else selection["contour_interval_m"]
|
|
),
|
|
}
|
|
)
|
|
current = await confirmed_surface_params(connection, str(project_id))
|
|
same = current is not None and not terrain_changes(current, selection)
|
|
if same: # 같은 서피스 — 아무것도 안 다시 셈(등고선 간격만 · 763-3)
|
|
await update_contour_interval_param(
|
|
connection, str(project_id), float(selection["contour_interval_m"])
|
|
)
|
|
else:
|
|
await confirm_surface_selection(
|
|
connection,
|
|
project_id,
|
|
request.model_id,
|
|
selection,
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
if same:
|
|
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
|
|
# 서피스가 바뀜 — 업로드와 같은 전처리 체인 하나(B05~B09 초기화 · 763-3 · 요구 605).
|
|
# 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다.
|
|
from B03_FileInput.B03_FileInput_Service_SurfaceChange import run_surface_change_chain
|
|
|
|
redesign_task = asyncio.create_task(
|
|
run_surface_change_chain(project_id, request.model_id),
|
|
name=f"surface-change-chain-{project_id}",
|
|
)
|
|
redesign_task.add_done_callback(
|
|
lambda task: task.exception() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지
|
|
)
|
|
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
|
|
except LookupError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B04 지표면 모델 확정 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "지표면 모델 확정 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/surface/input-files", response_model=SurfaceInputFileListResponse)
|
|
async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListResponse | JSONResponse:
|
|
"""프로젝트의 WF1 분석 대상 LAS/LAZ 입력 파일 목록을 조회한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
files = await list_project_point_cloud_inputs(connection, project_id)
|
|
return SurfaceInputFileListResponse(
|
|
project_id=str(project_id),
|
|
files=[SurfaceInputFileSummary(**item) for item in files],
|
|
)
|
|
except Exception:
|
|
logger.exception("B04 입력 파일 목록 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "입력 파일 목록 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
async def _point_cloud_source(project_id: UUID, filter: str | None) -> Path | JSONResponse:
|
|
"""미리보기 원천 npz — 원본(structured) 또는 필터 지면점 저장본(낡으면 다시 만듦)."""
|
|
async with get_db_pool().acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
|
|
if not structured_path.is_file():
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
|
)
|
|
if filter is None:
|
|
return structured_path
|
|
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
|
|
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
|
|
if filter not in available_filters():
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
|
|
)
|
|
source_path = structured_path.parent / f"ground_points_{filter}.npz"
|
|
cache_is_current = False
|
|
if source_path.is_file():
|
|
with np.load(source_path) as cached:
|
|
cache_is_current = (
|
|
"cache_version" in cached
|
|
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
|
)
|
|
if not cache_is_current:
|
|
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
|
|
return source_path
|
|
|
|
|
|
@router.get("/{project_id}/surface/point-cloud/binary", response_model=None)
|
|
async def get_surface_point_cloud_binary(
|
|
project_id: UUID, request: Request, filter: str | None = None
|
|
) -> Response:
|
|
"""점군 미리보기 고정 표본 이진(ETag) — 전처리 때 만든 파일 · 없거나 낡으면 한 번 만듦."""
|
|
try:
|
|
source_path = await _point_cloud_source(project_id, filter)
|
|
if isinstance(source_path, JSONResponse):
|
|
return source_path
|
|
target = await asyncio.to_thread(ensure_point_preview, source_path, filter)
|
|
return cached_file_response(request, target, "application/octet-stream")
|
|
except LookupError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B04 포인트클라우드 이진 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/surface/confirmed", response_model=SurfaceConfirmedResponse)
|
|
async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | JSONResponse:
|
|
"""확정 지표면 구성과 지형 가장자리만 반환한다(포인트 배열 없음).
|
|
|
|
B05 3D 배치·B11 준비화면·진입 판정이 모두 이 응답 하나를 기준으로 삼는다.
|
|
구성이 바뀌면 signature가 달라지므로 프론트가 담아 둔 자료의 갱신 여부를 판단할 수 있다.
|
|
"""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
models = await list_surface_models(connection, project_id)
|
|
params = await get_surface_confirmation_params(connection, str(project_id))
|
|
|
|
confirmed = next((model for model in models if model["status"] == "CONFIRMED"), None)
|
|
source_filter = params.get("source_filter")
|
|
|
|
# 가장자리는 B05가 3D 마커 좌표를 환산할 때 쓰므로, 기존 포인트클라우드 응답과
|
|
# 같은 파일(확정 필터의 지면 포인트)에서 읽어 값이 어긋나지 않게 한다.
|
|
processed_dir = Path(resolve_stored_project_path(stored_path)) / "B04_PreProcess"
|
|
processed_dir = processed_dir / "processed"
|
|
source_path = processed_dir / "structured.npz"
|
|
if source_filter:
|
|
filtered = processed_dir / f"ground_points_{source_filter}.npz"
|
|
if filtered.is_file():
|
|
source_path = filtered
|
|
|
|
bounds_payload: dict[str, float] | None = None
|
|
point_count: int | None = None
|
|
if source_path.is_file():
|
|
with np.load(source_path) as stored:
|
|
bounds = np.asarray(stored["bounds"], dtype=np.float64)
|
|
if "point_count" in stored:
|
|
point_count = int(stored["point_count"])
|
|
bounds_payload = {
|
|
"x_min": float(bounds[0, 0]),
|
|
"x_max": float(bounds[0, 1]),
|
|
"y_min": float(bounds[1, 0]),
|
|
"y_max": float(bounds[1, 1]),
|
|
"z_min": float(bounds[2, 0]),
|
|
"z_max": float(bounds[2, 1]),
|
|
}
|
|
|
|
# LAS 없이 설계한 프로젝트는 위 두 파일이 아예 없다(도엽등고선으로 만든
|
|
# 서피스가 정본). 그때는 확정 모델 격자의 bounds를 그대로 쓴다 — 없으면
|
|
# B05가 "지표면 범위 정보를 찾을 수 없습니다"로 3D를 못 띄운다(2026-08-30).
|
|
project_root = processed_dir.parent.parent
|
|
if bounds_payload is None and confirmed and confirmed.get("model_file_path"):
|
|
model_path = project_root / str(confirmed["model_file_path"])
|
|
if model_path.is_file():
|
|
with np.load(model_path) as stored:
|
|
if "bounds" in stored:
|
|
bounds = np.asarray(stored["bounds"], dtype=np.float64)
|
|
bounds_payload = {
|
|
"x_min": float(bounds[0, 0]),
|
|
"x_max": float(bounds[0, 1]),
|
|
"y_min": float(bounds[1, 0]),
|
|
"y_max": float(bounds[1, 1]),
|
|
"z_min": float(bounds[2, 0]),
|
|
"z_max": float(bounds[2, 1]),
|
|
}
|
|
|
|
# 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None).
|
|
route_bounds = planned_route_bounds(project_root, project_epsg_from_prj(project_root))
|
|
|
|
signature = "|".join(
|
|
str(value)
|
|
for value in (
|
|
confirmed["id"] if confirmed else "none",
|
|
source_filter,
|
|
params.get("method"),
|
|
params.get("smooth"),
|
|
params.get("contour_interval_m"),
|
|
)
|
|
)
|
|
return SurfaceConfirmedResponse(
|
|
project_id=str(project_id),
|
|
model_id=int(confirmed["id"]) if confirmed else None,
|
|
source_filter=source_filter,
|
|
method=params.get("method"),
|
|
smooth=params.get("smooth"),
|
|
contour_interval_m=params.get("contour_interval_m"),
|
|
signature=signature,
|
|
point_count=point_count,
|
|
bounds=bounds_payload,
|
|
route_bounds=route_bounds,
|
|
)
|
|
except LookupError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B04 확정 지표면 요약 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "확정 지표면 정보를 불러오지 못했습니다."},
|
|
)
|