260710_0
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"""B04 지표면 분석 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -17,19 +19,39 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
create_surface_model,
|
||||
create_terrain_layer,
|
||||
get_input_file,
|
||||
list_project_point_cloud_inputs,
|
||||
list_surface_models,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
SurfaceAnalyzeRequest,
|
||||
SurfaceAnalyzeResponse,
|
||||
SurfaceGroundStatsResponse,
|
||||
SurfaceInputFileListResponse,
|
||||
SurfaceInputFileSummary,
|
||||
SurfaceModelListResponse,
|
||||
SurfaceModelSummary,
|
||||
SurfacePointCloudSampleResponse,
|
||||
)
|
||||
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 Analysis"])
|
||||
POINT_CLOUD_SAMPLE_LIMIT = 100_000
|
||||
|
||||
|
||||
async def update_project_status(
|
||||
connection: aiomysql.Connection, project_id: UUID, status: str
|
||||
) -> None:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET status = %s, updated_at = NOW()
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(status, str(project_id)),
|
||||
)
|
||||
|
||||
|
||||
async def save_surface_analysis_to_db(
|
||||
@@ -103,6 +125,8 @@ async def analyze_surface(
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await update_project_status(connection, project_id, "WF1_ANALYZING")
|
||||
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)
|
||||
@@ -133,6 +157,7 @@ async def analyze_surface(
|
||||
analysis_result=result,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
await update_project_status(connection, project_id, "WF1_COMPLETE")
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -147,9 +172,15 @@ async def analyze_surface(
|
||||
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:
|
||||
await update_project_status(connection, project_id, "WF1_FAILED")
|
||||
await connection.commit()
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection:
|
||||
await update_project_status(connection, project_id, "WF1_FAILED")
|
||||
await connection.commit()
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
|
||||
@@ -175,6 +206,108 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
) -> SurfacePointCloudSampleResponse | JSONResponse:
|
||||
"""구조화된 LAS 결과에서 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_wf1_Surface" / "processed" / "structured.npz"
|
||||
if not structured_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
||||
)
|
||||
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
bounds = np.asarray(structured["bounds"], dtype=np.float64)
|
||||
point_count = int(len(xyz))
|
||||
if point_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rng = np.random.default_rng(20260710)
|
||||
indexes = rng.choice(point_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
|
||||
sample = xyz[indexes]
|
||||
else:
|
||||
sample = xyz
|
||||
|
||||
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(),
|
||||
)
|
||||
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_wf1_Surface" / "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": "지면 통계 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/status")
|
||||
async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
||||
"""WF1 분석 상태를 조회한다."""
|
||||
@@ -184,20 +317,50 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as model_count
|
||||
FROM surface_models
|
||||
WHERE project_id = %s
|
||||
SELECT p.status as project_status, COUNT(sm.id) as model_count
|
||||
FROM projects p
|
||||
LEFT JOIN surface_models sm ON sm.project_id = p.id
|
||||
WHERE p.id = %s AND p.deleted_at IS NULL
|
||||
GROUP BY p.id, p.status
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
|
||||
)
|
||||
model_count = int(row["model_count"]) if row else 0
|
||||
|
||||
status = "completed" if model_count > 0 else "in_progress"
|
||||
project_status = str(row.get("project_status") or "NEW")
|
||||
if project_status == "WF1_FAILED":
|
||||
status = "failed"
|
||||
progress_percent = 0
|
||||
current_stage = "failed"
|
||||
message = "WF1 분석에 실패했습니다."
|
||||
elif model_count > 0 or project_status == "WF1_COMPLETE":
|
||||
status = "completed"
|
||||
progress_percent = 100
|
||||
current_stage = "completed"
|
||||
message = "WF1 분석이 완료되었습니다."
|
||||
elif project_status == "WF1_ANALYZING":
|
||||
status = "in_progress"
|
||||
progress_percent = 30
|
||||
current_stage = "surface_analysis"
|
||||
message = "WF1 분석이 진행 중입니다."
|
||||
else:
|
||||
status = "pending"
|
||||
progress_percent = 0
|
||||
current_stage = "pending"
|
||||
message = "WF1 분석 대기 중입니다."
|
||||
return {
|
||||
"project_id": str(project_id),
|
||||
"status": status,
|
||||
"model_count": model_count,
|
||||
"progress_percent": progress_percent,
|
||||
"current_stage": current_stage,
|
||||
"message": message,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id)
|
||||
|
||||
Reference in New Issue
Block a user