Files
Aislo/B04_PreProcess/B04_PreProcess_Engine_Structurize.py
T
eomsangdonandClaude Opus 5.5 11c75f022a refactor(767-1): 안 쓰는 지면 필터 · 서피스 지움 — 필터 넷(CSF · 격자 최저 · PMF · RANSAC) · 표현 셋(TIN + Taubin 평활 · NURBS · meshfree) · 수치지형도 비교 방식 다섯 · 안 부르는 길 정리
- 필터 = 업체 분류 · 점진 TIN · 둘의 다듬기 판만 · 분석 허용 목록 = Ground 등록부(available_filters) 한 벌
- 표현 = DTM · implicit 만 · 평활 = DTM 만 · config 기본 목록 · PRECOMPUTE · 설정 사전 맞춤(설정 지문 바뀜 — 768 에서 다시 지음)
- 등고선 = 격자 · 굳힌 격자만(삼각망 굽기 · 외곽 맞춤 · implicit RBF 폴백 걷음) · 표본기 · 경로 찾기 tin · nurbs · meshfree 갈래 걷음
- 수치지형도 = 라플라스만(TIN 둘 · TPS · ANUDEM형 · 다중해상도 걷음) · B04 단추 하나
- 안 부르는 길 — B04 JSON 점군 GET · ground-stats GET + TS · B07 옛 계획평면도(라이다) 짓기 · lidar_source · LIDAR_ID · plan_lidar 종류 이름 · SOURCE_FILTER_LABELS · MODEL_METHODS(py) · MESH_* 설정
- 옛 manifest 의 지운 갈래 줄은 DB 로 되살리지 않음 · 옛 csf 행은 미리보기 · 등고선 그대로 열림 · 지운 이름 요청 = 400
- 낡은 글 — Ground · Pipeline 머리 · WF1 「없으면 csf」 · B07 음영 기본 「csf」 → 빈 값 · 등고선 폴백 「csf」 → 404 · B04 3D 보기 처음값 · B05 Schema · B06 Prebuild · 씨닝 설명
- 시험 지움 2 — test_b04_contour_raster · test_b04_contour_equivalence(지운 삼각망 굽기 · 없는 프로젝트를 봐 건너뛰던 것)
- 시험 덜어냄 — test_b04_ground_filters CSF · 격자 최저 네 경우(지운 필터)
- 시험 고침 — 허용 목록 · 형 검사에 걸린 csf · tin · grid_min_z · meshfree · tin_sheet 견본을 ptd · implicit · laplace 로(54_22 · 238_1 · 443 · 342_2 · 346_3 · 351_1 · 442_2 · 271 + helper · 344_1 · 133_1 · 343_2 · 637 · 754_1 · 763_1 깨진 tin → 깨진 implicit · 763_3 · b05 둘 · b06 · sheet_surface TIN(도엽선) 경우 지움) · 이름만 바꿈(273_2 · 343_1 · 343_3 · 624_1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:35:12 +09:00

339 lines
14 KiB
Python

"""B04 LAS/LAZ 고속 구조화 엔진 — 여러 장을 한 벌로 병합한다 (2026-09-06 사용자 확정).
드론 라이다는 사업지가 넓으면 도엽별로 여러 장이 온다. 여기서 **합친 범위**로 한 벌을
만들고, 뒤 단계(지면필터·모델·등고선·배수)는 받는 형식이 그대로라 손대지 않는다.
점이 임계를 넘으면 **칸(기본 0.5m)마다 최저점 하나만** 남긴다(씨닝). 설계가 쓰는 격자가
1m(다듬기 칸 1m)라 0.5m 는 설계보다 촘촘해 결과 표고가 사실상 같고, 30GB 두
장이 메모리 29GB → 1.4GB 로 내려간다. 임계 아래면 원본 점을 그대로 쓴다 — 작은 자료의
결과는 바뀌지 않는다.
"""
import logging
import os
import tempfile
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
import laspy
import numpy as np
from common_util.common_util_json import replace_with_retry
from config.config_system import (
SURFACE_DEFAULT_RGB_VALUE,
SURFACE_LAS_CHUNK_SIZE,
SURFACE_MERGE_MAX_GAP_M,
SURFACE_THIN_CELL_SIZE_M,
SURFACE_THIN_MAX_CELLS,
SURFACE_THIN_TRIGGER_POINTS,
)
logger = logging.getLogger(__name__)
ProgressCallback = Callable[[int], None]
PathLike = str | Path
# 청크에서 점과 함께 옮기는 속성들 — 파일에 없으면 기본값이 남는다.
_ATTRIBUTES = ("intensity", "rgb", "return_number", "number_of_returns", "classification")
def _as_list(las_path: PathLike | Sequence[PathLike]) -> list[Path]:
if isinstance(las_path, (str, Path)):
return [Path(las_path)]
return [Path(item) for item in las_path]
def point_cloud_extent(path: PathLike) -> tuple[int, tuple[float, float, float, float]]:
"""머리글만 읽어 점 수와 XY 범위를 돌려준다 — 파일 크기와 무관하게 즉시 끝난다."""
with laspy.open(Path(path)) as las_file:
header = las_file.header
return int(header.point_count), (
float(header.mins[0]),
float(header.mins[1]),
float(header.maxs[0]),
float(header.maxs[1]),
)
def merge_gap_error(
paths: Sequence[PathLike], gap_m: float = SURFACE_MERGE_MAX_GAP_M
) -> str | None:
"""서로 멀리 떨어진 지형 파일이 섞였는지 — 문제면 안내 문구, 없으면 None.
다른 사업지 파일이나 좌표계가 다른 파일이 섞이면 합친 범위가 통째로 어긋나 격자가
터진다. 도엽으로 나뉜 자료는 경계가 맞닿으므로 여유를 두고 **어느 파일과도 만나지
않는 파일**만 걸러 낸다.
"""
sources = _as_list(paths)
if len(sources) < 2:
return None
boxes = [(path, point_cloud_extent(path)[1]) for path in sources]
for index, (path, box) in enumerate(boxes):
near = any(
box[0] - gap_m <= other[2]
and other[0] - gap_m <= box[2]
and box[1] - gap_m <= other[3]
and other[1] - gap_m <= box[3]
for other_index, (_, other) in enumerate(boxes)
if other_index != index
)
if not near:
return (
f"지형 파일 「{path.name}」의 좌표가 다른 파일과 "
f"{int(gap_m):,}m 넘게 떨어져 있습니다."
" 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오."
)
return None
class _Merged:
"""합친 점을 담는 그릇 — 원본 유지형과 씨닝형이 같은 모양으로 낸다."""
def __init__(self, capacity: int) -> None:
self.xyz = np.empty((capacity, 3), dtype=np.float64)
self.intensity = np.zeros(capacity, dtype=np.uint16)
self.rgb = np.full((capacity, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
self.return_number = np.ones(capacity, dtype=np.uint8)
self.number_of_returns = np.ones(capacity, dtype=np.uint8)
self.classification = np.zeros(capacity, dtype=np.uint8)
self.size = 0
def arrays(self) -> dict[str, np.ndarray]:
end = self.size
return {
"xyz": self.xyz[:end],
"intensity": self.intensity[:end],
"rgb": self.rgb[:end],
"return_number": self.return_number[:end],
"number_of_returns": self.number_of_returns[:end],
"classification": self.classification[:end],
}
def append(self, columns: dict[str, np.ndarray]) -> None:
count = len(columns["x"])
section = slice(self.size, self.size + count)
self.xyz[section, 0] = columns["x"]
self.xyz[section, 1] = columns["y"]
self.xyz[section, 2] = columns["z"]
for key in _ATTRIBUTES:
if key in columns:
getattr(self, key)[section] = columns[key]
self.size += count
def _chunk_columns(chunk: Any, dimensions: set[str]) -> dict[str, np.ndarray]:
"""청크에서 쓸 값만 꺼낸다. 파일에 없는 항목은 키를 빼서 기본값이 남게 한다."""
columns: dict[str, np.ndarray] = {
"x": np.asarray(chunk.x, dtype=np.float64),
"y": np.asarray(chunk.y, dtype=np.float64),
"z": np.asarray(chunk.z, dtype=np.float64),
}
if "intensity" in dimensions:
columns["intensity"] = np.asarray(chunk.intensity, dtype=np.uint16)
if {"red", "green", "blue"}.issubset(dimensions):
colors = np.stack(
[
np.asarray(chunk.red, dtype=np.float64),
np.asarray(chunk.green, dtype=np.float64),
np.asarray(chunk.blue, dtype=np.float64),
],
axis=1,
)
if colors.size and float(colors.max()) > 255.0:
colors /= 256.0
columns["rgb"] = colors.clip(0, 255).astype(np.uint8)
if {"return_number", "number_of_returns"}.issubset(dimensions):
columns["return_number"] = np.asarray(chunk.return_number, dtype=np.uint8)
columns["number_of_returns"] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
if "classification" in dimensions:
columns["classification"] = np.asarray(chunk.classification, dtype=np.uint8)
return columns
class _ThinGrid:
"""씨닝형 — **지면 분류점은 전부** 남기고, 나머지는 칸마다 최저점 하나만 남긴다.
설계 지표면을 만드는 것은 지면점이다(업체가 분류해 준 ASPRS class 2). 그 점을 하나도
버리지 않으므로 **지면 결과는 씨닝 전과 완전히 같다**(2026-09-06 용화 실측: 1m 지면
격자 141,969칸 전부 표고 차이 0). 지면점은 원본의 2~3%뿐이라 남겨도 가볍다.
나머지(수목·구조물·잡음)는 칸마다 최저점만 남긴다 — 분류가 없는 자료에서 점진 TIN이
지면을 찾을 밑그림으로 충분하다(씨앗 간격 10m · 다듬기 칸 1m > 칸 0.5m).
한 청크 안에서 같은 칸이 여러 번 나오면 뒤에 쓴 값이 이겨 최저점이 아니게 된다.
그래서 청크를 (칸, 표고)로 정렬해 **칸마다 첫 점**만 골라 낸 뒤 격자와 견준다.
"""
#: ASPRS 지면 분류 코드.
GROUND_CLASS = 2
def __init__(self, bounds: np.ndarray, cell_size: float) -> None:
self.cell_size = cell_size
self.x_min = float(bounds[0, 0])
self.y_min = float(bounds[1, 0])
self.width = int(np.ceil((float(bounds[0, 1]) - self.x_min) / cell_size)) + 1
self.height = int(np.ceil((float(bounds[1, 1]) - self.y_min) / cell_size)) + 1
cells = self.width * self.height
if cells > SURFACE_THIN_MAX_CELLS:
raise ValueError(
"지형 자료의 합친 범위가 너무 넓습니다."
" 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오."
)
self.best_z = np.full(cells, np.inf, dtype=np.float64)
self.x = np.zeros(cells, dtype=np.float64)
self.y = np.zeros(cells, dtype=np.float64)
self.intensity = np.zeros(cells, dtype=np.uint16)
self.rgb = np.full((cells, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
self.return_number = np.ones(cells, dtype=np.uint8)
self.number_of_returns = np.ones(cells, dtype=np.uint8)
self.classification = np.zeros(cells, dtype=np.uint8)
# 그대로 남길 지면점 — 청크마다 모아 두었다가 마지막에 잇는다.
self.ground: list[dict[str, np.ndarray]] = []
def add(self, columns: dict[str, np.ndarray]) -> None:
classification = columns.get("classification")
if classification is not None:
is_ground = classification == self.GROUND_CLASS
if is_ground.any():
self.ground.append({key: value[is_ground] for key, value in columns.items()})
keep = ~is_ground
columns = {key: value[keep] for key, value in columns.items()}
x, y, z = columns["x"], columns["y"], columns["z"]
if not len(x):
return
grid_x = np.clip(((x - self.x_min) / self.cell_size).astype(np.int64), 0, self.width - 1)
grid_y = np.clip(((y - self.y_min) / self.cell_size).astype(np.int64), 0, self.height - 1)
cell = grid_y * self.width + grid_x
order = np.lexsort((z, cell))
sorted_cell = cell[order]
first = np.ones(len(order), dtype=bool)
first[1:] = sorted_cell[1:] != sorted_cell[:-1]
candidate = order[first]
candidate_cell = cell[candidate]
better = z[candidate] < self.best_z[candidate_cell]
chosen = candidate[better]
target = candidate_cell[better]
self.best_z[target] = z[chosen]
self.x[target] = x[chosen]
self.y[target] = y[chosen]
for key in _ATTRIBUTES:
if key in columns:
getattr(self, key)[target] = columns[key][chosen]
def collect(self) -> _Merged:
occupied = np.flatnonzero(np.isfinite(self.best_z))
ground_count = sum(len(item["x"]) for item in self.ground)
merged = _Merged(len(occupied) + ground_count)
merged.xyz[: len(occupied), 0] = self.x[occupied]
merged.xyz[: len(occupied), 1] = self.y[occupied]
merged.xyz[: len(occupied), 2] = self.best_z[occupied]
for key in _ATTRIBUTES:
getattr(merged, key)[: len(occupied)] = getattr(self, key)[occupied]
merged.size = len(occupied)
for item in self.ground:
merged.append(item)
return merged
def _headers(sources: list[Path]) -> tuple[int, np.ndarray, bool]:
"""전체 점 수·합친 범위(3x2)·색 보유 여부를 머리글만 읽어 구한다."""
total = 0
has_rgb = False
mins = np.full(3, np.inf, dtype=np.float64)
maxs = np.full(3, -np.inf, dtype=np.float64)
for source in sources:
with laspy.open(source) as las_file:
header = las_file.header
total += int(header.point_count)
mins = np.minimum(mins, np.asarray(header.mins, dtype=np.float64))
maxs = np.maximum(maxs, np.asarray(header.maxs, dtype=np.float64))
dimensions = set(header.point_format.dimension_names)
has_rgb = has_rgb or {"red", "green", "blue"}.issubset(dimensions)
if not np.isfinite(mins).all():
mins = np.zeros(3, dtype=np.float64)
maxs = np.zeros(3, dtype=np.float64)
return total, np.column_stack((mins, maxs)), has_rgb
def _merge_sources(
sources: list[Path],
total_points: int,
bounds: np.ndarray,
thin: bool,
progress_callback: ProgressCallback | None,
) -> _Merged:
grid = _ThinGrid(bounds, SURFACE_THIN_CELL_SIZE_M) if thin else None
merged = _Merged(total_points) if grid is None else None
done = 0
for source in sources:
with laspy.open(source) as las_file:
dimensions = set(las_file.header.point_format.dimension_names)
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
columns = _chunk_columns(chunk, dimensions)
if grid is not None:
grid.add(columns)
else:
merged.append(columns)
done += len(columns["x"])
if progress_callback:
progress_callback(int(done / total_points * 100) if total_points else 100)
return grid.collect() if grid is not None else merged
def structurize_las(
las_path: PathLike | Sequence[PathLike],
output_dir: str | Path,
progress_callback: ProgressCallback | None = None,
) -> Path:
"""지형 파일 한 장 또는 여러 장을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
sources = _as_list(las_path)
if not sources:
raise ValueError("구조화할 지형 파일이 없습니다.")
target_dir = Path(output_dir)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / "structured.npz"
total_points, bounds, has_rgb = _headers(sources)
thin = total_points > SURFACE_THIN_TRIGGER_POINTS
merged = _merge_sources(sources, total_points, bounds, thin, progress_callback)
logger.info(
"B04 구조화: 파일 %d장 원본 %d점 → 저장 %d점 (씨닝 %s)",
len(sources),
total_points,
merged.size,
f"{SURFACE_THIN_CELL_SIZE_M}m 칸" if thin else "없음",
)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=target_dir,
prefix=".structured.",
suffix=".npz.tmp",
delete=False,
) as temporary:
temporary_path = Path(temporary.name)
np.savez_compressed(
temporary,
**merged.arrays(),
bounds=bounds,
total_points=np.array([merged.size], dtype=np.int64),
source_point_count=np.array([total_points], dtype=np.int64),
source_file_count=np.array([len(sources)], dtype=np.int64),
thinned=np.array([int(thin)], dtype=np.int8),
has_rgb=np.array([int(has_rgb)], dtype=np.int8),
)
temporary.flush()
os.fsync(temporary.fileno())
# 이 교체가 윈도우 공유 위반으로 죽으면 WF1 분석 전체가 실패한다 — 재시도한다.
replace_with_retry(temporary_path, target)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
if progress_callback:
progress_callback(100)
return target