- 공용 A00_Common/map/map_lidar_upload — 파일 고름 · POST multipart · 1 초 묻기 · 서클 · 끝난 뒤 일(주소 · 진행 묻기만 인자) - 예정노선 UI_LidarUpload = 부품 부르기(화면 · 동작 그대로) · 안 쓰는 uploadPlanLidar 걷음 - B05 UI_RouteEdit_Terrain — 칩 · 단추 · 서클 한 칸 · 끝나면 확인창 「설계 지형을 라이다로 바꿈?」(이미 라이다 = force 다시 셈) · covered 거짓 = 바꿈 막고 알림 · 들어올 때 도는 일 이어 봄 - common_util_lidar_upload.drop_stale_filters — 갈음 뒤 안 지은 라이다 필터(옛 LAS) 모델 · 등고선 · 마스크 · 지면점 · manifest 줄 지움 · save_models drop = 그 행만(확정 행 남김) - 시험 test_637_lidar(필터 지움 · 행 지움 · B05 화면) · test_624_4b(올리기 = 공용 부품으로 옮김) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
10 KiB
Python
230 lines
10 KiB
Python
"""B05 라이다 나중 올리기(PLAN 637-2 · 요구 546) — 프로젝트 만든 뒤 LAS 를 올려 지형 칩에 라이다.
|
|
|
|
- `POST /api/projects/{id}/route/lidar` — LAS/LAZ(+ .prj) multipart `files` → 뒤에서 짓기
|
|
(저장 · 설계 지형 바꿈 중 = 409 · 이미 돎 = 409)
|
|
- `GET …/route/lidar` — 진행 · 결과 `{ready · running · percent · message · error · interrupted ·
|
|
covered · covered_ratio · epsg · files · design · sources}`
|
|
|
|
짓는 자리 = 프로젝트 뿌리 `B04_PreProcess/models`(B05 표본이 읽는 자리) ·
|
|
입력 LAS = `B04_PreProcess/lidar_las/`(B03 원본 · 입력 행은 안 건드림) · 다시 올림 = 갈음.
|
|
길 = 공용 `common_util_lidar_upload`(예정노선과 같음 · `maps=False` = 지도 · GIS · 도엽 서피스
|
|
안 받음 → 수치지형도 모델 · 배수유역 그대로).
|
|
DB = `surface_models` 넣기/고치기만(`keep` — 갈음 때 id · 확정 · 노선 연결 살림 ·
|
|
`save_surface_analysis_to_db` 안 씀) · 안 지은 라이다 필터(옛 LAS 것) = 파일 · 행 지움
|
|
(`drop_stale_filters` · 뒤에 고르면 새 LAS 로 다시 지음 · PLAN 637-3).
|
|
라이다 확정값 — 지금 설계가 라이다면 그 필터로 갈음 · 수치지형도면 `las_selection` 에 적음
|
|
(칩 라이다 열림 · 설계 지형은 그대로).
|
|
노선 · 노드 · 초기값 · 종단 · 횡단 안 바뀜 — 설계 값은 사용자가 지형 칩 확인창에서
|
|
`POST /route/design-terrain`(갈음 뒤 같은 지형 = `force`) 할 때만.
|
|
노선 덮임 = 저장 노선을 2 m 마다 새 지형에서 표본 — 빈칸 있으면 `covered` 거짓
|
|
(화면이 라이다 바꿈 막음).
|
|
일 동안 [저장]과 같은 프로젝트 잠금(`common_util_save_guard`).
|
|
|
|
계산 자리 = ③ 서버 단독.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import numpy as np
|
|
from fastapi import APIRouter, File, HTTPException, UploadFile
|
|
|
|
from common_util import common_util_lidar_upload as lidar_upload
|
|
from common_util.common_util_json import atomic_write_json
|
|
from common_util.common_util_surface_confirmation import (
|
|
LAS_STASH_KEY,
|
|
analysis_terrain_sources,
|
|
design_terrain,
|
|
get_surface_confirmation_params,
|
|
stash_las_selection,
|
|
)
|
|
from config.config_db import get_db_pool
|
|
|
|
router = APIRouter(prefix="/api/projects", tags=["B05 Route Lidar"])
|
|
|
|
_JOBS: dict[tuple[str, str], dict[str, Any]] = {}
|
|
_TASKS: set[asyncio.Task] = set()
|
|
KIND = "lidar"
|
|
PRE = Path("B04_PreProcess")
|
|
MODELS = PRE / "models"
|
|
UPLOAD = PRE / "lidar_upload"
|
|
LAS_DIR = PRE / "lidar_las"
|
|
META = PRE / "lidar_meta.json"
|
|
JOB = PRE / "lidar_job.json"
|
|
STEP_M = 2.0
|
|
_KEYS = ("source_filter", "method", "smooth")
|
|
|
|
|
|
def las_target(params: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""이미 있는 라이다 확정값 — 지금 설계(라이다) · 적어 둔 것 · 없으면 None(새로 덧붙임)."""
|
|
if design_terrain(params) == "las":
|
|
return {key: params[key] for key in _KEYS}
|
|
stash = params.get(LAS_STASH_KEY)
|
|
if isinstance(stash, dict) and stash.get("source_filter"):
|
|
return {key: stash[key] for key in _KEYS if key in stash}
|
|
return None
|
|
|
|
|
|
def project_epsg(root: Path) -> int:
|
|
"""작업 좌표계 — 설계 노선 · 지형 PRJ 사다리(`resolve_project_crs`) · EPSG 로 못 읽으면 400."""
|
|
from pyproj import CRS
|
|
|
|
from common_util.common_util_crs import identify_epsg, resolve_project_crs
|
|
from common_util.common_util_route_geometry import load_design_route
|
|
|
|
planned = load_design_route(root)
|
|
text = resolve_project_crs(root, route_crs_input=planned.crs_input if planned else None)
|
|
epsg = identify_epsg(CRS.from_user_input(text), text)
|
|
if epsg is None:
|
|
raise HTTPException(status_code=400, detail="프로젝트 좌표계를 EPSG 로 읽지 못했습니다.")
|
|
return int(epsg)
|
|
|
|
|
|
def line_points(line: list[list[float]], step: float = STEP_M) -> np.ndarray:
|
|
"""꺾은선을 `step` m 마다(꼭짓점 포함) 나눈 점."""
|
|
xy = np.asarray(line, dtype=np.float64).reshape(-1, 2)
|
|
out = [xy[:1]]
|
|
for a, b in zip(xy[:-1], xy[1:]):
|
|
count = max(1, int(np.ceil(np.hypot(*(b - a)) / step)))
|
|
t = np.arange(1, count + 1)[:, None] / count
|
|
out.append(a + (b - a) * t)
|
|
return np.vstack(out)
|
|
|
|
|
|
def route_coverage(root: Path, selection: dict[str, Any]) -> tuple[bool, float]:
|
|
"""저장 노선이 새 라이다 지형에 다 덮이나 — (덮임, 덮인 비율)."""
|
|
from B05_Profile.B05_Profile_Router_Replan import _planned_line
|
|
from common_util.common_util_surface_sampler import build_surface_sampler
|
|
|
|
line = _planned_line(root)
|
|
if len(line) < 2:
|
|
return False, 0.0
|
|
sampler = build_surface_sampler(
|
|
root / MODELS,
|
|
str(selection["source_filter"]),
|
|
str(selection["method"]),
|
|
bool(selection["smooth"]),
|
|
)
|
|
_, valid = sampler.sample_xy(line_points(line))
|
|
return bool(valid.all()), round(float(np.mean(valid)), 4)
|
|
|
|
|
|
def build_route_lidar(
|
|
root: Path, epsg: int, target: dict[str, Any] | None, report: Any
|
|
) -> dict[str, Any]:
|
|
"""올린 LAS → `lidar_las/` 갈음 → 뿌리에 짓기(있던 라이다 필터 · 없으면 자동) → 덮임 → 메타."""
|
|
from config.config_system import (
|
|
SURFACE_AUTO_METHODS,
|
|
SURFACE_CONFIRM_DEFAULT_METHOD,
|
|
SURFACE_CONFIRM_DEFAULT_SMOOTH,
|
|
)
|
|
|
|
las_paths = lidar_upload.stage_las(root / UPLOAD, root / LAS_DIR, epsg, report)
|
|
method = str((target or {}).get("method") or SURFACE_CONFIRM_DEFAULT_METHOD)
|
|
result = lidar_upload.build_surface(
|
|
root,
|
|
las_paths,
|
|
report,
|
|
[str(target["source_filter"])] if target else None,
|
|
sorted({*SURFACE_AUTO_METHODS, method}),
|
|
)
|
|
selection = target or {
|
|
"source_filter": result["source_filters"][0],
|
|
"method": method,
|
|
"smooth": bool(SURFACE_CONFIRM_DEFAULT_SMOOTH),
|
|
}
|
|
report(96, "노선 덮임 보는 중")
|
|
covered, ratio = route_coverage(root, selection)
|
|
meta = {
|
|
"epsg": int(epsg),
|
|
"files": [p.name for p in las_paths],
|
|
"selection": selection,
|
|
"point_count": result["processed"]["point_count"],
|
|
"bounds": result["processed"]["bounds"],
|
|
"covered": covered,
|
|
"covered_ratio": ratio,
|
|
}
|
|
atomic_write_json(root / META, meta)
|
|
models = [model for model in result["models"] if model.get("model_file_path")]
|
|
dropped = lidar_upload.drop_stale_filters(root, list(result["source_filters"]))
|
|
return {"meta": meta, "models": models, "dropped": dropped}
|
|
|
|
|
|
async def _root_and_params(project_id: UUID) -> tuple[Path, dict[str, Any]]:
|
|
from B05_Profile.B05_Profile_Router_Replan import _project_paths
|
|
|
|
paths = await _project_paths(project_id)
|
|
if paths is None:
|
|
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
|
|
async with get_db_pool().acquire() as connection:
|
|
params = await get_surface_confirmation_params(connection, str(project_id))
|
|
return paths[0], params
|
|
|
|
|
|
@router.post("/{project_id}/route/lidar", response_model=None)
|
|
async def upload_route_lidar(project_id: UUID, files: list[UploadFile] = File(...)) -> dict:
|
|
"""B 에 LAS 올리기 → 뒤에서 라이다 지형(진행 = `GET …/route/lidar`)."""
|
|
from common_util import common_util_save_guard as guard
|
|
|
|
lidar_upload.check_names([upload.filename or "" for upload in files])
|
|
root, params = await _root_and_params(project_id)
|
|
lock = guard.project_lock(str(project_id))
|
|
if lock.locked():
|
|
raise HTTPException(status_code=409, detail="저장 중입니다 — 끝난 뒤 다시 올리세요.")
|
|
if (_JOBS.get((str(project_id), KIND)) or {}).get("running"):
|
|
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
|
|
target, design = las_target(params), design_terrain(params)
|
|
epsg = await asyncio.to_thread(project_epsg, root)
|
|
await lidar_upload.receive_uploads(files, root / UPLOAD)
|
|
|
|
async def work(report: Any) -> None:
|
|
async with lock:
|
|
got = await asyncio.to_thread(build_route_lidar, root, epsg, target, report)
|
|
await lidar_upload.save_models(
|
|
project_id, got["models"], epsg, keep=True, drop=got["dropped"]
|
|
)
|
|
if design == "sheet": # 칩 라이다 열림 · 설계 지형은 그대로
|
|
async with get_db_pool().acquire() as connection:
|
|
await connection.begin()
|
|
await stash_las_selection(connection, str(project_id), got["meta"]["selection"])
|
|
await connection.commit()
|
|
report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개")
|
|
|
|
return lidar_upload.start_job(_JOBS, _TASKS, project_id, KIND, work, root / JOB, "B05")
|
|
|
|
|
|
def read_meta(root: Path) -> dict[str, Any]:
|
|
try:
|
|
return json.loads((root / META).read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
@router.get("/{project_id}/route/lidar", response_model=None)
|
|
async def route_lidar(project_id: UUID) -> dict[str, Any]:
|
|
"""올린 라이다 — 진행 · 메타(덮임 · 좌표계 · 파일) · 지금 설계 지형 · 고를 수 있는 지형."""
|
|
root, params = await _root_and_params(project_id)
|
|
job = _JOBS.get((str(project_id), KIND))
|
|
job = dict(job) if job else await asyncio.to_thread(lidar_upload.saved_job, root / JOB)
|
|
meta = await asyncio.to_thread(read_meta, root)
|
|
picked = meta.get("selection") or {}
|
|
dem = root / MODELS / f"dtm_{picked.get('source_filter')}.npz"
|
|
ready = bool(picked) and await asyncio.to_thread(dem.is_file)
|
|
return {
|
|
"status": "success",
|
|
**job,
|
|
"ready": ready,
|
|
"covered": bool(meta.get("covered")),
|
|
"covered_ratio": meta.get("covered_ratio"),
|
|
"epsg": meta.get("epsg"),
|
|
"files": meta.get("files", []),
|
|
"point_count": meta.get("point_count"),
|
|
"design": design_terrain(params),
|
|
"sources": analysis_terrain_sources(params, root / MODELS),
|
|
}
|