Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_Router.py
T
2026-07-10 18:12:17 +09:00

371 lines
15 KiB
Python

"""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
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
create_processed_point_cloud,
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(
connection: aiomysql.Connection,
*,
project_id: UUID,
input_file_id: int,
analysis_result: dict[str, Any],
source_filters: list[str],
) -> list[int]:
"""WF1 분석 결과를 DB에 저장한다.
이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서
begin/commit/rollback을 한 번만 수행해야 한다.
"""
input_file = await get_input_file(connection, project_id, input_file_id)
processed = analysis_result["processed"]
processed_cloud_id = await create_processed_point_cloud(
connection,
input_file_id=input_file_id,
project_id=project_id,
process_type="structured",
processed_file_path=processed["processed_file_path"],
converted_format=None,
converted_file_path=processed["converted_file_path"],
point_count=processed["point_count"],
bounds=processed["bounds"],
statistics=processed["statistics"],
classification_summary=None,
processing_params={"filters": source_filters},
)
surface_model_ids: list[int] = []
for model in analysis_result["models"]:
model_id = await create_surface_model(
connection,
project_id=project_id,
model_type=model["model_type"],
source_file_id=input_file_id,
processed_cloud_id=processed_cloud_id,
crs_epsg=input_file["crs_epsg"],
resolution_m=model["resolution_m"],
model_file_path=model["model_file_path"],
generation_params=model["generation_params"],
)
surface_model_ids.append(model_id)
for layer in model["layers"]:
await create_terrain_layer(
connection,
surface_model_id=model_id,
layer_name=layer["layer_name"],
geometry_type=layer["geometry_type"],
layer_file_path=layer["file_path"],
file_format=layer["file_format"],
file_size_mb=None,
statistics=None,
)
return surface_model_ids
@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:
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)
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 파일을 찾을 수 없습니다."},
)
# 무거운 지형 연산은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
result = await asyncio.to_thread(
run_surface_analysis,
project_root,
las_path,
source_filters=source_filters,
methods=methods,
force=request.force,
)
# 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 update_project_status(connection, project_id, "WF1_COMPLETE")
await connection.commit()
except Exception:
await connection.rollback()
raise
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:
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": "지표면 분석 처리 중 오류가 발생했습니다."},
)
@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.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 분석 상태를 조회한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
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
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)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."},
)