feat(D03): 예정노선 라이다 — LAS 올리기 · B04 엔진 지형 · terrain=lidar (PLAN 624-4 · 요구 530 · 531)

- 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>
This commit is contained in:
2026-10-31 20:33:47 +09:00
co-authored by Claude Opus 5.5
parent c5b20c5c57
commit de8f2fee4a
6 changed files with 356 additions and 21 deletions
+25 -15
View File
@@ -197,9 +197,12 @@ def run_surface_analysis(
methods: list[str],
force: bool = False,
on_progress: ProgressCallback | None = None,
maps: bool = True,
) -> dict[str, Any]:
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
`maps=False` = 지도 · GIS 받기와 도엽 서피스를 건너뜀(D 라이다 · PLAN 624-4).
`las_path`는 지형 파일 한 장 또는 여러 장이다 — 여러 장이면 합친 범위로 한 벌을
만든다(2026-09-06 사용자 확정).
@@ -349,31 +352,38 @@ def run_surface_analysis(
)
# 3-2·3-3. VWorld 지도·국가 GIS 벡터·수치지형도 도엽 (공용 블록 — 도엽 서피스도 사용)
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
download_geodata(
project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report
)
if maps:
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
download_geodata(
project_root,
processed_dir,
las_bounds_dict,
las_paths[0].parent,
rebuild,
report=_report,
)
# 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다
# (2026-08-30 사용자 확정). 실패해도 분석은 계속한다.
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
sheet_models: list[dict[str, Any]] = []
try:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
if maps:
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
try:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
# 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때.
sheet_models = build_sheet_surface_from_route(
project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS)
)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
# 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때.
sheet_models = build_sheet_surface_from_route(
project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS)
)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
_report(95, "saving", "결과 저장 중")
+194
View File
@@ -0,0 +1,194 @@
"""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}
+1 -1
View File
@@ -57,7 +57,7 @@ router = APIRouter(prefix="/api/projects", tags=["D03 Plan Analysis"])
# 진행 — (프로젝트, 일) → {running, percent, message, error} · 프로세스 메모리
_JOBS: dict[tuple[str, str], dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
KINDS = ("terrain", "analyze")
KINDS = ("terrain", "analyze", "lidar")
class Center(BaseModel):
@@ -3,7 +3,8 @@
겹 길(`/plan/layers/*`)은 프로젝트 종류를 안 가림:
- 예정노선(PLAN) = 받은 범위 · 지형 = 수치지형도(`models_plan/dtm_sheet_<방법>.npz`) ·
라이다 = D 에 LAS 올리기(624-4) 전이라 없음(404)
`lidar` = D 에 올린 LAS 로 지은 DTM(624-4 · `D03_PlanAnalysis_Lidar` · 안 올렸으면 404 ·
범위 밖 = 빈칸)
- 그 밖(임도 등) = 범위 = B04 배경 지도 상자(`vworld_white_meta.json` · 없으면 원천 격자 상자) ·
지형 = 설계 지형(stage 1 params · 기본) · `lidar` = 라이다 확정 지표면 ·
`sheet` = 수치지형도 서피스 ·
@@ -29,6 +30,7 @@ from common_util.common_util_storage import resolve_stored_project_path
from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD
from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Lidar as lidar
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
@@ -80,13 +82,12 @@ def layer_store(root: Path, plan: bool) -> Path:
def plan_frame(root: Path, epsg: int, box: Box, choice: str | None) -> Frame:
"""예정노선 — 수치지형도 하나(design = sheet) · lidar = 없음."""
"""예정노선 — 수치지형도 하나(design = sheet) · lidar = 올린 LAS 의 DTM(없으면 None)."""
source = check_source(choice)
source = "sheet" if source == "design" else source
npz = terrain.models_dir_of(Path(root)) / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
return Frame(
Path(root), epsg, box, layer_store(root, True), source, npz if source == "sheet" else None
)
dem = npz if source == "sheet" else lidar.lidar_dem(root)
return Frame(Path(root), epsg, box, layer_store(root, True), source, dem)
def road_dem(models: Path, params: dict[str, Any], source: str) -> tuple[str, Path | None]:
+2
View File
@@ -75,6 +75,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_r
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router
from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router
from D03_PlanAnalysis.D03_PlanAnalysis_Lidar import router as d03_plan_lidar_router
from D03_PlanAnalysis.D03_PlanAnalysis_Network import router as d03_plan_network_router
from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router
from D03_PlanAnalysis.D03_PlanAnalysis_Router_Tiles import router as d03_plan_tiles_router
@@ -497,6 +498,7 @@ app.include_router(b05_route_lifecycle_router, dependencies=protected_with_compa
app.include_router(d03_plan_analysis_router, dependencies=protected_with_company)
app.include_router(d03_plan_tiles_router, dependencies=protected_with_company)
app.include_router(d03_plan_network_router, dependencies=protected_with_company)
app.include_router(d03_plan_lidar_router, dependencies=protected_with_company)
app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
app.include_router(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
+128
View File
@@ -0,0 +1,128 @@
"""PLAN 624-4 — D 예정노선 라이다: LAS 올리기 → B04 엔진 지형 → `terrain=lidar` 겹(요구 530 · 531).
D 몫 길 = `D03_PlanAnalysis_Lidar` · B04 엔진은 `maps=False` 인자만 덧(기본 = B 그대로).
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from fastapi import HTTPException
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Lidar as lidar
from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as source
D_EPSG, LAS_EPSG = 5187, 5176 # D = GRS80 동부 · 업체 LAS = 베셀 동부(검증 용화와 같음)
def _write_las(path: Path, epsg: int | None, x0: float, y0: float) -> None:
"""두 덩이(가운데 빈 땅) 지면점 LAS — 비탈 0.3."""
import laspy
from pyproj import CRS
rng = np.random.default_rng(7)
xs, ys = [], []
for dx in (0.0, 160.0):
xs.append(rng.uniform(x0 + dx, x0 + dx + 60, 6000))
ys.append(rng.uniform(y0, y0 + 60, 6000))
x, y = np.concatenate(xs), np.concatenate(ys)
header = laspy.LasHeader(point_format=3, version="1.2")
header.offsets = [round(x0), round(y0), 0]
header.scales = [0.01, 0.01, 0.01]
if epsg is not None:
header.add_crs(CRS.from_epsg(epsg))
data = laspy.LasData(header)
data.x, data.y, data.z = x, y, 500 + 0.3 * (x - x0)
data.classification = np.full(len(x), 2, np.uint8)
path.parent.mkdir(parents=True, exist_ok=True)
data.write(path)
def _built(tmp_path: Path, monkeypatch, epsg: int | None = LAS_EPSG) -> dict: # type: ignore[no-untyped-def]
import B04_PreProcess.B04_PreProcess_Engine as engine
called: list[int] = []
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: called.append(1))
_write_las(lidar.lidar_root(tmp_path) / lidar.UPLOAD / "t.las", epsg, 208300.0, 367500.0)
got = lidar.build_lidar(tmp_path, D_EPSG, lambda *_: None)
assert called == [] # 지도 · GIS 받기 안 함
return got
def test_이름_고르기() -> None:
lidar.check_names(["a.las", "a.prj", "b.LAZ"])
for names in (["a.prj"], ["a.las", "a.txt"], ["a.las", "A.las"]):
with pytest.raises(HTTPException) as caught:
lidar.check_names(names)
assert caught.value.status_code == 400
def test_올리기_뒤_라이다_지형_B_꼴(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
got = _built(tmp_path, monkeypatch)
meta = got["meta"]
dem = lidar.lidar_dem(tmp_path)
assert dem is not None and dem.name == f"dtm_{meta['source_filter']}.npz"
assert dem.parent == lidar.lidar_root(tmp_path) / "B04_PreProcess" / "models"
assert meta["files"] == ["t.las"] and meta["point_count"] == 12000
assert not (lidar.lidar_root(tmp_path) / lidar.UPLOAD).exists()
# surface_models 행 경로 = 프로젝트 뿌리 기준
from B04_PreProcess.B04_PreProcess_Repository import _validate_stage_path
assert any(m["model_file_path"] == meta["model_file"] for m in got["models"])
assert all(_validate_stage_path(m["model_file_path"]) for m in got["models"])
# 좌표계 옮김 — 베셀 → GRS80 북 +10 만 m 언저리
from pyproj import Transformer
want = Transformer.from_crs(LAS_EPSG, D_EPSG, always_xy=True).transform(208300.0, 367500.0)
assert abs(meta["bounds"]["x_min"] - want[0]) < 1.0
assert abs(meta["bounds"]["y_min"] - want[1]) < 1.0
# 다시 올리면 갈음
_write_las(lidar.lidar_root(tmp_path) / lidar.UPLOAD / "u.las", None, 208300.0, 467500.0)
again = lidar.build_lidar(tmp_path, D_EPSG, lambda *_: None)["meta"]
assert again["files"] == ["u.las"] and abs(again["bounds"]["y_min"] - 467500.0) < 1.0
def test_terrain_lidar_급경사_0차곡_범위_밖_빈칸(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
with pytest.raises(HTTPException) as caught: # 안 올림 = 404
source.need_dem(source.plan_frame(tmp_path, D_EPSG, (0, 0, 1, 1), "lidar"))
assert caught.value.status_code == 404
_built(tmp_path, monkeypatch)
frame = source.plan_frame(tmp_path, D_EPSG, (0, 0, 1, 1), "lidar")
npz = source.need_dem(frame)
assert frame.source == "lidar" and npz == lidar.lidar_dem(tmp_path)
value = layers.slope_layer(tmp_path, npz.parent, D_EPSG, "slope", npz)
counts = value["counts"]
assert sum(counts) > 0 and max(range(len(counts)), key=counts.__getitem__) in (29, 30)
# 두 덩이 사이 = 빈칸(수치지형도로 안 메움)
with np.load(npz) as dem:
valid = np.asarray(dem["valid_mask"], bool)
x = np.asarray(dem["x"], float)
x0 = float(x[0])
gap = (x > x0 + 90) & (x < x0 + 130)
assert gap.any() and not valid[:, gap].any()
work, light, full = source.zero_order_calls(frame, 50.0, 5000.0)
assert work(*light) is None # 저장본 없음
value = work(*full)
assert value["cell_m"] > 0 and work(*light) == value # 다음 = 저장본
def test_B04_기본은_지도_받기_그대로(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""B 프로젝트 전처리(기본 인자) = 지도 · GIS 받기 · 도엽 서피스 그대로 부름."""
import B04_PreProcess.B04_PreProcess_Engine as engine
import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet
calls: list[str] = []
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: calls.append("maps"))
monkeypatch.setattr(
sheet, "build_sheet_surface_from_route", lambda *a, **k: calls.append("sheet") or []
)
las = tmp_path / "B03_FileInput" / "input" / "las" / "t.las"
_write_las(las, None, 200000.0, 500000.0)
engine.run_surface_analysis(tmp_path, las, source_filters=None, methods=["dtm"])
assert calls == ["maps", "sheet"]
calls.clear()
engine.run_surface_analysis(tmp_path, las, source_filters=None, methods=["dtm"], maps=False)
assert calls == []