This commit is contained in:
2026-07-10 19:01:33 +09:00
parent e3d66e717c
commit b98affbf99
22 changed files with 1681 additions and 752 deletions
+15
View File
@@ -4,6 +4,7 @@
동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다.
"""
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -14,6 +15,9 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_mo
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
from config.config_system import build_surface_model_config
# 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지)
ProgressCallback = Callable[[int, str, str], None]
def _relative_to_project(project_root: Path, path: Path) -> str:
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
@@ -27,6 +31,7 @@ def run_surface_analysis(
source_filters: list[str],
methods: list[str],
force: bool = False,
on_progress: ProgressCallback | None = None,
) -> dict[str, Any]:
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
@@ -36,6 +41,11 @@ def run_surface_analysis(
- manifest: 지표면 모델 파이프라인 manifest
- models: [{model_type, model_file_path, resolution_m, generation_params, layers}]
"""
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
on_progress(percent, stage, message)
stage_root = project_root / "B04_wf1_Surface"
processed_dir = stage_root / "processed"
models_dir = stage_root / "models"
@@ -43,6 +53,7 @@ def run_surface_analysis(
models_dir.mkdir(parents=True, exist_ok=True)
# 1. LAS 구조화 (structured.npz)
_report(10, "structurize", "LAS 구조화 중")
structured_path = structurize_las(las_path, processed_dir)
with np.load(structured_path) as structured:
xyz = structured["xyz"]
@@ -62,15 +73,19 @@ def run_surface_analysis(
data = {"xyz": xyz, "bounds": bounds}
# 2. 지면 필터 실행
_report(40, "ground_filter", "지면 필터 적용 중")
masks = build_ground_masks(data, source_filters)
ground_summary = summarize_masks(data, masks)
# 3. 지표면 5종 모델 빌드
_report(70, "surface_model", "지표면 모델 생성 중")
config = build_surface_model_config()
config["source_filters"] = list(source_filters)
config["precompute"] = list(methods)
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
_report(95, "saving", "결과 저장 중")
processed = {
"processed_file_path": _relative_to_project(project_root, structured_path),
"converted_file_path": None,
@@ -206,10 +206,10 @@ async def list_project_point_cloud_inputs(
await cursor.execute(
"""
SELECT id, file_type, original_filename, raw_file_path, file_size_mb,
crs_epsg, status, created_at
crs_epsg, status, upload_at
FROM input_files
WHERE project_id = %s AND file_type IN ('las', 'laz')
ORDER BY created_at DESC, id DESC
ORDER BY upload_at DESC, id DESC
""",
(str(project_id),),
)
+131 -30
View File
@@ -32,12 +32,45 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
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(
@@ -124,9 +157,17 @@ async def analyze_surface(
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:
await update_project_status(connection, project_id, "WF1_ANALYZING")
await connection.commit()
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)
@@ -137,6 +178,12 @@ async def analyze_surface(
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,
@@ -145,6 +192,7 @@ async def analyze_surface(
source_filters=source_filters,
methods=methods,
force=request.force,
on_progress=_on_progress,
)
# DB 기록 (트랜잭션)
@@ -157,12 +205,15 @@ async def analyze_surface(
analysis_result=result,
source_filters=source_filters,
)
await update_project_status(connection, project_id, "WF1_COMPLETE")
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"],
@@ -172,14 +223,14 @@ 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")
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:
except Exception as exc:
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
async with pool.acquire() as connection:
await update_project_status(connection, project_id, "WF1_FAILED")
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,
@@ -317,43 +368,93 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
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
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), 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":
# 만약 새 테이블에 정보가 없다면 기존 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"
progress_percent = 0
current_stage = "failed"
message = "WF1 분석에 실패했습니다."
elif model_count > 0 or project_status == "WF1_COMPLETE":
if not message:
message = "WF1 분석에 실패했습니다."
elif state == "COMPLETE":
status = "completed"
progress_percent = 100
current_stage = "completed"
message = "WF1 분석이 완료되었습니다."
elif project_status == "WF1_ANALYZING":
if not message:
message = "WF1 분석이 완료되었습니다."
elif state == "IN_PROGRESS":
status = "in_progress"
progress_percent = 30
current_stage = "surface_analysis"
message = "WF1 분석이 진행 중입니다."
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"
message = "WF1 분석 대기 중입니다."
if not message:
message = "WF1 분석 대기 중입니다."
return {
"project_id": str(project_id),
"status": status,
+4 -180
View File
@@ -2,10 +2,10 @@
* B04_wf1_Surface_UI_Page.ts
* 로그인 후 04: 1차 워크플로우 (지표면 모델 분석)
*
* 3단 레이아웃 (frontend.md §2):
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell)
* 좌측: 입력 파일 ID + 지면 필터/지표면 표현 선택 + 실행 옵션
* 우측: 생성된 지표면 모델 목록 그리
* 데스크톱 IDE 스타일 레이아웃 (createWorkflowLayout):
* 헤더: 페이지 타이틀 + 진행 단계 스텝바 (숨김/복원 토글)
* 좌측 오버레이 패널: 입력 파일 자동 선택 + 지면 필터/지표면 표현 + 실행 옵션
* 우측 메인: 3D 포인트클라우드 뷰어 + 지면 통계 + 지표면 모델
*
* 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션]
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
@@ -15,9 +15,7 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createTag,
createWorkflowShell,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
@@ -81,180 +79,6 @@ function buildCheckboxGroup(
return { root, selected };
}
export function renderB04SurfaceLegacy(root: HTMLElement): void {
const shell = createWorkflowShell({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
activeStep: 0,
});
/* ---- 좌측 입력 패널 ---- */
const inputFileField = createInputField({
label: L("B04_Surface_Field_InputId"),
type: "number",
min: 1,
placeholder: L("B04_Surface_Field_InputId_Placeholder"),
});
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
"grid_min_z",
"csf",
"pmf",
]);
const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [
"dtm",
"tin",
]);
const forceLabel = document.createElement("label");
forceLabel.className = "b04-surface__check";
const forceBox = document.createElement("input");
forceBox.type = "checkbox";
const forceText = document.createElement("span");
forceText.textContent = L("B04_Surface_Field_Force");
forceLabel.append(forceBox, forceText);
const analyzeButton = createButton({
label: L("B04_Surface_Btn_Analyze"),
variant: "filled",
onClick: () => void onB04_Surface_Analyze_Click(),
});
const leftForm = document.createElement("div");
leftForm.className = "b04-surface__form";
leftForm.append(
inputFileField.root,
filterGroup.root,
methodGroup.root,
forceLabel,
analyzeButton,
);
shell.leftPanel.append(leftForm);
/* ---- 우측 결과 영역 ---- */
const resultHeader = document.createElement("div");
resultHeader.className = "b04-surface__result-head";
const resultTitle = document.createElement("h3");
resultTitle.textContent = L("B04_Surface_Result_Title");
const refreshButton = createButton({
label: L("B04_Surface_Btn_Refresh"),
variant: "ghost",
onClick: () => void onB04_Surface_Refresh_Click(),
});
resultHeader.append(resultTitle, refreshButton);
const modelList = document.createElement("div");
modelList.className = "b04-surface__models";
const resultArea = document.createElement("div");
resultArea.className = "b04-surface__result";
resultArea.append(resultHeader, modelList);
shell.rightArea.append(resultArea);
function renderModels(models: readonly SurfaceModelSummary[]): void {
modelList.replaceChildren();
if (models.length === 0) {
const empty = document.createElement("p");
empty.className = "b04-surface__empty";
empty.textContent = L("B04_Surface_Result_Empty");
modelList.append(empty);
return;
}
for (const model of models) {
const card = document.createElement("div");
card.className = "b04-surface__model-card";
const head = document.createElement("div");
head.className = "b04-surface__model-head";
const type = document.createElement("strong");
type.textContent = model.model_type;
const variant =
model.status === "CONFIRMED" ? "success" : model.status === "FAILED" ? "danger" : "neutral";
head.append(type, createTag(model.status, variant));
const meta = document.createElement("div");
meta.className = "b04-surface__model-meta";
const resolution = document.createElement("span");
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${model.resolution_m ?? "-"}`;
const path = document.createElement("span");
path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`;
meta.append(resolution, path);
card.append(head, meta);
modelList.append(card);
}
}
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) {
inputFileField.setError(L("B04_Surface_Error_Project"));
showToast(L("B04_Surface_Error_Project"), "error");
}
return projectId;
}
async function onB04_Surface_Analyze_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
const rawId = inputFileField.input.value.trim();
const inputFileId = Number(rawId);
if (!rawId || !Number.isInteger(inputFileId) || inputFileId <= 0) {
inputFileField.setError(L("B04_Surface_Error_InputId"));
return;
}
if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) {
inputFileField.setError(L("B04_Surface_Error_Selection"));
return;
}
inputFileField.setError();
showLoadingOverlay();
try {
const response = await analyzeSurface(projectId, {
input_file_id: inputFileId,
source_filters: [...filterGroup.selected],
methods: [...methodGroup.selected],
force: forceBox.checked,
});
showToast(
`${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`,
"success",
);
await loadModels(projectId);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed");
inputFileField.setError(`${L("B04_Surface_Analyze_Failed")} ${detail}`);
showToast(L("B04_Surface_Analyze_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
async function loadModels(projectId: string): Promise<void> {
showLoadingOverlay();
try {
const response = await listSurfaceModels(projectId);
renderModels(response.models);
} catch {
showToast(L("B04_Surface_Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
async function onB04_Surface_Refresh_Click(): Promise<void> {
const projectId = getProjectId();
if (projectId) await loadModels(projectId);
}
renderModels([]);
root.replaceChildren(shell.root);
const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (initialProjectId) void loadModels(initialProjectId);
}
function buildInfoLine(label: string, value: unknown): HTMLElement {
const row = document.createElement("div");
row.className = "b04-surface__line";