"""D 예정노선 라이다 — LAS 올리기 → B04 엔진 그대로 지형(요구 530 · 531 · PLAN 624-4). - `POST /api/projects/{id}/plan/lidar` — LAS(+ .prj) 올리기 · 뒤에서 지형 짓기 (진행 = `/plan/progress` 의 `lidar`) · 다시 올리면 옛 LAS 를 갈음 - `GET …/plan/lidar` — 지은 라이다 지형(없으면 `ready: false`) 자리 = `B04_PreProcess/lidar/`(D 몫 · 안은 B 와 같은 꼴 — `B03_FileInput/input/las/` · `B04_PreProcess/models/`). LAS 좌표계(.prj · LAS 머리 · 없으면 프로젝트 좌표계)가 프로젝트와 다르면 옮겨 적음(높이 그대로). 지도 · GIS 받기 · 도엽 서피스는 안 함(`maps=False`) · 지은 DTM = `surface_models` 행 (높이 표본 격자). 라이다 범위 밖 = 빈칸(격자 `valid_mask`) · 수치지형도로 메우지 않음(결정 A⑥). 계산 자리 = ③ 서버 단독. """ from __future__ import annotations import asyncio import json import shutil from pathlib import Path from typing import Any, Callable from uuid import UUID from fastapi import APIRouter, File, HTTPException, UploadFile from common_util.common_util_json import atomic_write_json router = APIRouter(prefix="/api/projects", tags=["D03 Plan Lidar"]) LIDAR = Path("B04_PreProcess") / "lidar" # 표면 모델 행 경로 = B04_PreProcess 아래 UPLOAD = "upload" LAS_DIR = Path("B03_FileInput") / "input" / "las" MODELS = Path("B04_PreProcess") / "models" META = "lidar_meta.json" LAS_SUFFIXES = (".las", ".laz") CHUNK = 2_000_000 def lidar_root(root: Path) -> Path: return Path(root) / LIDAR def read_meta(root: Path) -> dict[str, Any]: try: return json.loads((lidar_root(root) / META).read_text(encoding="utf-8")) except (OSError, ValueError): return {} def lidar_dem(root: Path) -> Path | None: """지은 라이다 DTM(평활 안 함) — 없으면 None.""" name = read_meta(root).get("model_file") path = Path(root) / name if name else None return path if path is not None and path.is_file() else None def check_names(names: list[str]) -> None: """LAS · LAZ 하나 이상 · 그 밖은 .prj 만 · 같은 이름 없음.""" suffixes = [Path(name).suffix.lower() for name in names] if not any(s in LAS_SUFFIXES for s in suffixes): raise HTTPException(status_code=400, detail="LAS 또는 LAZ 파일을 하나 이상 올리세요.") if any(s not in (*LAS_SUFFIXES, ".prj") for s in suffixes): raise HTTPException(status_code=400, detail="LAS · LAZ · PRJ 만 올릴 수 있습니다.") if len({Path(name).name.casefold() for name in names}) != len(names): raise HTTPException(status_code=400, detail="같은 이름 파일이 있습니다.") def las_epsg(path: Path, prj_text: str | None) -> int | None: """LAS 좌표계 — .prj 먼저 · 없으면 LAS 머리 · 모르면 None.""" import laspy from pyproj import CRS from common_util.common_util_crs import identify_epsg if prj_text: return identify_epsg(CRS.from_wkt(prj_text), prj_text) with laspy.open(path) as reader: crs = reader.header.parse_crs() return identify_epsg(crs, crs.to_wkt()) if crs is not None else None def place_las(src: Path, dst_dir: Path, src_epsg: int | None, epsg: int) -> Path: """올린 LAS → 지형 입력 자리 — 좌표계가 같거나 모르면 옮김 · 다르면 x · y 만 옮겨 적음(.las).""" import laspy from pyproj import CRS, Transformer dst_dir.mkdir(parents=True, exist_ok=True) if src_epsg in (None, int(epsg)): return Path(shutil.move(src, dst_dir / src.name)) dst = dst_dir / f"{src.stem}.las" move = Transformer.from_crs(src_epsg, int(epsg), always_xy=True) with laspy.open(src) as reader: old = reader.header header = laspy.LasHeader(point_format=old.point_format, version=old.version) header.scales = old.scales x0, y0 = move.transform(old.mins[0], old.mins[1]) header.offsets = [round(x0), round(y0), old.offsets[2]] header.add_crs(CRS.from_epsg(int(epsg))) with laspy.open(dst, mode="w", header=header) as writer: for points in reader.chunk_iterator(CHUNK): x, y = move.transform(points.x, points.y) record = laspy.ScaleAwarePointRecord.zeros(len(points), header=header) record.array[:] = points.array record.x, record.y, record.z = x, y, points.z writer.write_points(record) src.unlink() return dst def build_lidar(root: Path, epsg: int, report: Callable[[int, str], None]) -> dict[str, Any]: """올린 자리(`upload/`)의 LAS → 입력 자리 갈음 → B04 엔진(지면 필터 · DTM) → 메타.""" from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis from config.config_system import SURFACE_AUTO_METHODS base = lidar_root(root) upload, las_dir = base / UPLOAD, base / LAS_DIR prj = next(iter(sorted(upload.glob("*.prj"))), None) prj_text = prj.read_text(encoding="utf-8", errors="ignore") if prj else None sources = sorted(p for p in upload.iterdir() if p.suffix.lower() in LAS_SUFFIXES) if sources: # 새로 올림 = 옛 LAS 갈음 shutil.rmtree(las_dir, ignore_errors=True) report(5, "LAS 좌표계 맞추는 중") for path in sources: place_las(path, las_dir, las_epsg(path, prj_text), epsg) shutil.rmtree(upload, ignore_errors=True) las_paths = sorted(p for p in las_dir.glob("*") if p.suffix.lower() in LAS_SUFFIXES) if not las_paths: raise FileNotFoundError("올린 LAS 가 없습니다.") result = run_surface_analysis( base, las_paths, source_filters=None, methods=list(SURFACE_AUTO_METHODS), on_progress=lambda percent, _stage, message: report(10 + int(percent * 0.85), message), maps=False, ) prefix = LIDAR.as_posix() models = [ {**model, "model_file_path": f"{prefix}/{model['model_file_path']}"} for model in result["models"] if model.get("model_file_path") ] picked = result["source_filters"][0] meta = { "epsg": int(epsg), "files": [p.name for p in las_paths], "source_filter": picked, "model_file": f"{prefix}/{(MODELS / f'dtm_{picked}.npz').as_posix()}", "point_count": result["processed"]["point_count"], "bounds": result["processed"]["bounds"], } atomic_write_json(base / META, meta) return {"meta": meta, "models": models} @router.post("/{project_id}/plan/lidar", response_model=None) async def upload_plan_lidar(project_id: UUID, files: list[UploadFile] = File(...)) -> dict: """D 에 LAS 올리기 → 뒤에서 라이다 지형(진행 = `/plan/progress` 의 `lidar`).""" from B03_FileInput.B03_FileInput_Engine import save_upload_stream from D03_PlanAnalysis import D03_PlanAnalysis_Router as base check_names([upload.filename or "" for upload in files]) root, epsg, _, _ = await base._plan_ends(project_id) # 예정노선 아님 400 if (base._JOBS.get((str(project_id), "lidar")) or {}).get("running"): raise HTTPException(status_code=409, detail="이미 돌고 있습니다.") upload = lidar_root(root) / UPLOAD shutil.rmtree(upload, ignore_errors=True) upload.mkdir(parents=True) try: for item in files: await save_upload_stream(item, upload / Path(item.filename or "").name) except ValueError as error: shutil.rmtree(upload, ignore_errors=True) raise HTTPException(status_code=413, detail=str(error)) from error async def work(report: Any) -> None: got = await asyncio.to_thread(build_lidar, root, epsg, report) await base._save_models(project_id, got["models"], epsg) report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개") return base._start_job(project_id, "lidar", work, root) @router.get("/{project_id}/plan/lidar", response_model=None) async def plan_lidar(project_id: UUID) -> dict[str, Any]: """지은 라이다 지형 — `ready` · 메타(좌표계 · 파일 · 필터 · 점 수 · 범위).""" from D03_PlanAnalysis import D03_PlanAnalysis_Store as store root, _ = await store.load_plan(project_id) meta = await asyncio.to_thread(read_meta, root) ready = await asyncio.to_thread(lidar_dem, root) is not None return {"status": "success", "ready": ready, **meta}