- POST /plan/lidar(LAS · LAZ + .prj · 예정노선만 · 다시 올리면 갈음) → 뒤에서 지형(진행 = /plan/progress lidar) · GET /plan/lidar - 자리 = D 의 B04_PreProcess/lidar/(안은 B 와 같은 꼴) · LAS 좌표계 다르면 x · y 옮겨 적음 · DTM = surface_models 행(높이 격자) - terrain=lidar 급경사 · 음영 · 0차곡 = 올린 DTM · 범위 밖 = 빈칸(수치지형도로 안 메움 · 결정 A⑥) - B04 run_surface_analysis 에 maps 인자 덧(기본 True = B 그대로 · False = 지도 · GIS 받기 · 도엽 서피스 건너뜀) - 시험 test_624_4_plan_lidar 4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
8.3 KiB
Python
195 lines
8.3 KiB
Python
"""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}
|