640 lines
26 KiB
Python
640 lines
26 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 FileResponse, 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_json import atomic_write_json
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage
|
|
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
|
|
# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다.
|
|
PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json")
|
|
|
|
|
|
def _progress_file_path(project_root: Path) -> Path:
|
|
return project_root.joinpath(*PROGRESS_FILE_RELATIVE)
|
|
|
|
|
|
def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None:
|
|
"""WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속)."""
|
|
try:
|
|
path = _progress_file_path(project_root)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
atomic_write_json(
|
|
path,
|
|
{"progress_percent": percent, "current_stage": stage, "message": message},
|
|
)
|
|
except OSError:
|
|
logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True)
|
|
|
|
|
|
def read_surface_progress(project_root: Path) -> dict[str, Any] | None:
|
|
"""progress.json을 읽어 반환한다. 없거나 손상 시 None."""
|
|
path = _progress_file_path(project_root)
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else None
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
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:
|
|
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 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 complete_stage(cursor, str(project_id), 1)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
|
|
write_surface_progress(project_root, 100, "completed", "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.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
|
|
|
|
rgb_sample = None
|
|
if "rgb" in structured:
|
|
rgb_arr = np.asarray(structured["rgb"])
|
|
if rgb_arr.ndim > 0 and len(rgb_arr) == point_count:
|
|
if point_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/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 state, progress_percent, message,
|
|
(SELECT COUNT(*) FROM surface_models
|
|
WHERE project_id = %s) as model_count
|
|
FROM project_workflow_stages
|
|
WHERE project_id = %s AND stage_no = 1
|
|
""",
|
|
(str(project_id), str(project_id)),
|
|
)
|
|
row = await cursor.fetchone()
|
|
|
|
# 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비)
|
|
if not row:
|
|
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),),
|
|
)
|
|
fallback_row = await cursor.fetchone()
|
|
if not fallback_row:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
|
|
)
|
|
model_count = int(fallback_row["model_count"])
|
|
project_status = str(fallback_row.get("project_status") or "NEW")
|
|
|
|
if project_status == "WF1_FAILED":
|
|
state = "FAILED"
|
|
progress_percent = 0
|
|
message = "WF1 분석에 실패했습니다."
|
|
elif model_count > 0 or project_status == "WF1_COMPLETE":
|
|
state = "COMPLETE"
|
|
progress_percent = 100
|
|
message = "WF1 분석이 완료되었습니다."
|
|
elif project_status == "WF1_ANALYZING":
|
|
state = "IN_PROGRESS"
|
|
progress_percent = 30
|
|
message = "WF1 분석이 진행 중입니다."
|
|
else:
|
|
state = "NOT_STARTED"
|
|
progress_percent = 0
|
|
message = "WF1 분석 대기 중입니다."
|
|
else:
|
|
state = row["state"]
|
|
progress_percent = row["progress_percent"]
|
|
message = row["message"] or ""
|
|
model_count = int(row["model_count"])
|
|
|
|
if state == "FAILED":
|
|
status = "failed"
|
|
current_stage = "failed"
|
|
if not message:
|
|
message = "WF1 분석에 실패했습니다."
|
|
elif state == "COMPLETE":
|
|
status = "completed"
|
|
progress_percent = 100
|
|
current_stage = "completed"
|
|
if not message:
|
|
message = "WF1 분석이 완료되었습니다."
|
|
elif state == "IN_PROGRESS":
|
|
status = "in_progress"
|
|
current_stage = "surface_analysis"
|
|
if not message:
|
|
message = "WF1 분석이 진행 중입니다."
|
|
# 진행률 파일이 있으면 실제 단계별 진행률로 대체
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
progress = read_surface_progress(Path(resolve_stored_project_path(stored_path)))
|
|
if progress:
|
|
progress_percent = int(progress.get("progress_percent", progress_percent))
|
|
current_stage = str(progress.get("current_stage", current_stage))
|
|
message = str(progress.get("message", message))
|
|
except LookupError:
|
|
pass
|
|
else:
|
|
status = "pending"
|
|
progress_percent = 0
|
|
current_stage = "pending"
|
|
if not message:
|
|
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": "분석 상태 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None)
|
|
async def get_surface_model_preview(
|
|
project_id: UUID,
|
|
model_id: int,
|
|
smooth: bool = False,
|
|
) -> FileResponse | JSONResponse:
|
|
"""지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다."""
|
|
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
|
|
stem = model_path.stem
|
|
|
|
ext = "ply" if model_type == "meshfree" else "glb"
|
|
if smooth and model_type in ("dtm", "tin"):
|
|
preview_filename = f"{stem}_smooth_preview.glb"
|
|
else:
|
|
preview_filename = f"{stem}_preview.{ext}"
|
|
|
|
preview_path = models_dir / preview_filename
|
|
if not preview_path.is_file():
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"status": "error",
|
|
"message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.",
|
|
},
|
|
)
|
|
|
|
media_type = "application/octet-stream"
|
|
if ext == "glb":
|
|
media_type = "model/gltf-binary"
|
|
elif ext == "ply":
|
|
media_type = "application/ply"
|
|
|
|
return FileResponse(preview_path, media_type=media_type, filename=preview_filename)
|
|
|
|
except Exception:
|
|
logger.exception(
|
|
"지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
|
|
async def get_surface_model_contour(
|
|
project_id: UUID,
|
|
model_id: int,
|
|
interval: float = 5.0,
|
|
smooth: bool = False,
|
|
) -> FileResponse | 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
|
|
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"
|
|
else:
|
|
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
|
|
|
contour_path = models_dir / contour_filename
|
|
|
|
if not contour_path.is_file():
|
|
fallback_files = list(models_dir.glob(f"contour_{filter_key}_{method}*.json"))
|
|
if smooth and method in ("dtm", "tin"):
|
|
fallback_files = list(
|
|
models_dir.glob(f"contour_{filter_key}_{method}_smooth_*.json")
|
|
)
|
|
if fallback_files:
|
|
contour_path = fallback_files[0]
|
|
|
|
if not contour_path.is_file():
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"status": "error",
|
|
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
|
|
},
|
|
)
|
|
|
|
return FileResponse(contour_path, media_type="application/json", filename=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": "등고선 파일 조회 중 오류가 발생했습니다."},
|
|
)
|