"""B04 지표면 분석 FastAPI 라우터.""" import asyncio import json import logging from pathlib import Path from typing import Any from uuid import UUID import numpy as np from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse 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_Repository import ( clear_confirmed_surface_models, get_input_file, list_project_point_cloud_inputs, 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, SurfaceGroundStatsResponse, SurfaceInputFileListResponse, SurfaceInputFileSummary, SurfaceModelListResponse, SurfaceModelSummary, SurfacePointCloudSampleResponse, ) 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_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( get_surface_confirmation_params, surface_confirmation_defaults, ) from common_util.common_util_workflow_state import ( fail_stage, start_stage, update_stage_progress, ) from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 500_000 # 상태 조회·모델 프리뷰 엔드포인트는 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에 기록한다.""" 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() try: params = { "input_file_id": request.input_file_id, "source_filters": source_filters, "methods": methods, "force": request.force, } async with pool.acquire() as connection: async with connection.cursor() as cursor: await start_stage(cursor, str(project_id), 1, params) await clear_confirmed_surface_models(connection, project_id) await connection.commit() 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 파일을 찾을 수 없습니다."}, ) # 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록). 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, 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, ) async with connection.cursor() as cursor: await update_stage_progress(cursor, str(project_id), 1, 100) await connection.commit() except Exception: await connection.rollback() raise 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: async with pool.acquire() as connection, connection.cursor() as cursor: await fail_stage(cursor, str(project_id), 1, str(exc)) await connection.commit() return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) except Exception as exc: logger.exception("B04 지표면 분석 실패: project_id=%s", project_id) async with pool.acquire() as connection, connection.cursor() as cursor: await fail_stage(cursor, str(project_id), 1, str(exc)) await connection.commit() 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을 완료한다.""" 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"] ), } ) await confirm_surface_selection( connection, project_id, request.model_id, selection, ) await connection.commit() except Exception: await connection.rollback() raise # 재확정 체인(2026-08-04 사용자 확정) — 새 지표면 기준으로 B05·B06을 백그라운드 # 재계산·저장한다. 사용자 저장 입력(제어점·경사 옵션·측점별 설계)은 유지·이월된다. # 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다. from B03_FileInput.B03_FileInput_Service_Chain import run_redesign_chain redesign_task = asyncio.create_task( run_redesign_chain(project_id, request.model_id, dict(selection)), name=f"redesign-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": "입력 파일 목록 조회 중 오류가 발생했습니다."}, ) @router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse) async def get_surface_point_cloud( project_id: UUID, filter: str | None = None, ) -> SurfacePointCloudSampleResponse | JSONResponse: """원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다.""" 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)) structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz" if not structured_path.is_file(): return JSONResponse( status_code=404, content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."}, ) source_path = structured_path if filter is not None: # 필터 목록은 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) with np.load(source_path) as structured: xyz = np.asarray(structured["xyz"], dtype=np.float32) bounds = np.asarray(structured["bounds"], dtype=np.float64) point_count = ( int(structured["point_count"]) if "point_count" in structured else int(len(xyz)) ) source_count = int(len(xyz)) if source_count > POINT_CLOUD_SAMPLE_LIMIT: rng = np.random.default_rng(20260710) indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False) sample = xyz[indexes] else: sample = xyz rgb_sample = None if "rgb" in structured: rgb_arr = np.asarray(structured["rgb"]) if rgb_arr.ndim > 0 and len(rgb_arr) == source_count: if source_count > POINT_CLOUD_SAMPLE_LIMIT: rgb_sample = rgb_arr[indexes] else: rgb_sample = rgb_arr return SurfacePointCloudSampleResponse( project_id=str(project_id), point_count=point_count, sampled_count=int(len(sample)), bounds={ "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]), }, points=sample.astype(float).tolist(), rgb=rgb_sample.astype(int).tolist() if rgb_sample is not None else None, ) 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": "확정 지표면 정보를 불러오지 못했습니다."}, ) @router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse) async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse: """manifest에서 필터별 지면 포인트 통계를 반환한다.""" 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)) manifest_path = project_root / "B04_PreProcess" / "models" / "manifest.json" if not manifest_path.is_file(): return SurfaceGroundStatsResponse(project_id=str(project_id), filters={}) manifest = json.loads(manifest_path.read_text(encoding="utf-8")) filters = { key: { "source_point_count": value.get("source_point_count"), "methods": value.get("methods", {}), } for key, value in manifest.get("source_filters", {}).items() if isinstance(value, dict) } return SurfaceGroundStatsResponse(project_id=str(project_id), filters=filters) 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": "지면 통계 조회 중 오류가 발생했습니다."}, )