"""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` 거짓 (화면이 라이다 바꿈 막음). 설계 지형이 라이다인 B = 시험 자리(`lidar_try/`)에 따로 지어 덮임 검사 → 다 덮을 때만 본 자리로 옮김 · 못 덮으면 거절(옛 LAS · 필터 파일 · 행 · 메타 그대로 · 시험 자리 지움 · PLAN 637-5). 일 동안 [저장]과 같은 프로젝트 잠금(`common_util_save_guard`). 계산 자리 = ③ 서버 단독. """ from __future__ import annotations import asyncio import json import shutil 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" TRY = PRE / "lidar_try" 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], models: Path | None = None ) -> tuple[bool, float]: """저장 노선이 새 라이다 지형(`models` · 없으면 뿌리)에 다 덮이나 — (덮임, 덮인 비율).""" 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( models or 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, guard: bool = False ) -> dict[str, Any]: """올린 LAS → `lidar_las/` 갈음 → 뿌리에 짓기(있던 라이다 필터 · 없으면 자동) → 덮임 → 메타. `guard`(설계 지형이 라이다) = 시험 자리에 지어 노선을 다 덮을 때만 뿌리로 옮김 · 못 덮으면 ValueError(뿌리 그대로).""" shutil.rmtree(root / TRY, ignore_errors=True) try: return _build(root, root / TRY if guard else root, epsg, target, report) finally: shutil.rmtree(root / TRY, ignore_errors=True) def _build( root: Path, base: Path, epsg: int, target: dict[str, Any] | None, report: Any ) -> dict[str, Any]: from config.config_system import ( SURFACE_AUTO_METHODS, SURFACE_CONFIRM_DEFAULT_METHOD, SURFACE_CONFIRM_DEFAULT_SMOOTH, ) las_paths = lidar_upload.stage_las(root / UPLOAD, base / LAS_DIR, epsg, report) method = str((target or {}).get("method") or SURFACE_CONFIRM_DEFAULT_METHOD) result = lidar_upload.build_surface( base, 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, base / MODELS) if base != root: if not covered: raise ValueError( f"노선을 {ratio * 100:.0f} % 만 덮음 — 설계 지형이 라이다라 바꾸지 않음" ) lidar_upload.promote_build(base, root) las_paths = [root / LAS_DIR / p.name for p in las_paths] 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, design == "las" ) 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), }