지면 필터를 고쳐 지면점이 9~26배 늘자, 필터 전체 x 표현 전체를 미리 만드는 자동 전처리가 감당 못 할 만큼 길어졌다(용화 기준 15~20 모델). 산출물 대부분은 아무도 열어 보지 않는다. - 자동 전처리는 기본 필터 1종 x SURFACE_AUTO_METHODS(dtm) 만 만든다. 스무딩 유무 두 벌은 기존대로 같이 나온다. - 기본 필터는 고정값이 아니라 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 classification, 없으면 csf. csf는 분류 없는 LAS를 필터링하기 위한 수단이므로 그때만 쓴다. - 관리자가 B04 드롭다운을 바꾸면 그 조합이 이미 저장돼 있는지 보고, 없으면 모달로 물은 뒤 그 조합만 계산해 영구 저장한다. 취소하면 드롭다운을 되돌린다. 이미 있으면 묻지 않고 저장된 데이터를 그대로 쓴다. - config_signature에서 source_filters·precompute를 뺀다. 이 둘은 "무엇을 만들지"를 고르는 값이라 서명에 넣으면 조합을 바꿀 때마다 manifest가 통째로 폐기돼 이전에 만들어 둔 조합이 사라진다. - analyzeSurface가 API_ANALYSIS_TIMEOUT_MS를 쓴다 — 기본 30초로는 조합 하나를 만드는 동안 abort 된다. detect_extra_filters()는 resolve_auto_source_filters()로 대체했다. 필터를 말없이 덧붙이는 대신, 자동 경로의 기본값을 정하는 판정으로 쓴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""B04 지표면 모델 공통 컨텍스트 및 메시 유틸리티.
|
|
|
|
지면 마스크가 적용된 포인트에서 footprint(외곽), 격자, 프리뷰 격자를 만들고,
|
|
GLB/PLY 프리뷰 및 npz 모델을 원자적으로 저장하는 공통 기능을 제공한다.
|
|
5개 표현(TIN/DTM/NURBS/implicit/meshfree) 빌더가 이 컨텍스트를 공유한다.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
import numpy as np
|
|
import trimesh
|
|
from scipy import ndimage
|
|
from scipy.interpolate import RectBivariateSpline
|
|
|
|
from common_util.common_util_atomic import atomic_write_bytes, atomic_write_npz
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MODEL_VERSION = 1
|
|
MODEL_METHODS = ("tin", "dtm", "nurbs", "implicit", "meshfree")
|
|
SOURCE_FILTER_LABELS = {"grid_min_z": "Grid Min-Z", "csf": "CSF", "pmf": "PMF"}
|
|
ProgressCallback = Callable[[int], None]
|
|
|
|
# 대용량 포인트 배치 처리 크기
|
|
_BATCH_SIZE = 500_000
|
|
|
|
# NURBS 평활 계수 — 제어점당 허용 잔차제곱(m²).
|
|
# 0.01(RMS 0.1m)은 요구가 너무 빡빡해 FITPACK이 제어점 수(122x113)보다 많은
|
|
# knot(126x117)을 밀어넣고 계수가 발산했다 — csf 표고 1e105, grid_min_z 1e10
|
|
# (2026-08-17 실측). 1.0(RMS 1m)이면 knot이 17~35로 떨어지고 세 지면 필터 모두
|
|
# 데이터 표고 범위 안에 머문다. NURBS는 평활 곡면 표현이라 이 정도 완화가 맞다.
|
|
NURBS_RESIDUAL_PER_CONTROL_POINT = 1.0
|
|
|
|
|
|
def fit_nurbs_spline(
|
|
x_control: np.ndarray, y_control: np.ndarray, z_control: np.ndarray, degree: int
|
|
) -> tuple[RectBivariateSpline, tuple[float, float]]:
|
|
"""제어 격자에 B-spline 곡면을 맞추고, 발산 판정용 표고 허용 범위를 함께 준다.
|
|
|
|
모델 빌더와 등고선 엔진이 같은 곡면을 각각 만들므로 여기서 한 번에 정의한다.
|
|
"""
|
|
spline = RectBivariateSpline(
|
|
y_control,
|
|
x_control,
|
|
z_control,
|
|
kx=min(degree, len(y_control) - 1),
|
|
ky=min(degree, len(x_control) - 1),
|
|
s=float(len(x_control) * len(y_control)) * NURBS_RESIDUAL_PER_CONTROL_POINT,
|
|
)
|
|
z_min = float(np.min(z_control))
|
|
z_max = float(np.max(z_control))
|
|
margin = max((z_max - z_min) * 0.5, 1.0)
|
|
return spline, (z_min - margin, z_max + margin)
|
|
|
|
|
|
def evaluate_nurbs_spline(
|
|
spline: RectBivariateSpline,
|
|
y_coords: np.ndarray,
|
|
x_coords: np.ndarray,
|
|
z_range: tuple[float, float],
|
|
label: str = "",
|
|
) -> np.ndarray:
|
|
"""스플라인을 평가하고 표고 허용 범위를 벗어난 값을 잘라낸다.
|
|
|
|
평활 스플라인은 제어점이 성긴 구석에서 발산할 수 있다. 그대로 두면 float32
|
|
캐스팅에서 inf가 되고 프리뷰 색상(nan)과 등고선 레벨 산출(`np.arange`가
|
|
"Maximum allowed size exceeded"로 실패)까지 번진다.
|
|
"""
|
|
z_values = np.asarray(spline(y_coords, x_coords), dtype=np.float64)
|
|
low, high = z_range
|
|
outside = np.count_nonzero(~np.isfinite(z_values) | (z_values < low) | (z_values > high))
|
|
if outside:
|
|
logger.warning(
|
|
"NURBS 곡면이 표고 범위를 벗어나 잘라냈습니다: %s%d개 셀 (허용 %.1f~%.1fm)",
|
|
f"{label} " if label else "",
|
|
int(outside),
|
|
low,
|
|
high,
|
|
)
|
|
z_values = np.nan_to_num(z_values, nan=low, posinf=high, neginf=low)
|
|
return np.clip(z_values, low, high)
|
|
|
|
|
|
# 서명에서 빼는 키 — 지오메트리를 만드는 방법이 아니라 "무엇을 만들지"를 고르는 값들.
|
|
# source_filters·precompute를 넣으면 조합을 바꿀 때마다 manifest가 통째로 폐기돼
|
|
# 이전에 만들어 둔 조합이 사라진다. manifest는 이미 필터·방식별로 키가 갈린다.
|
|
_SIGNATURE_EXCLUDED_KEYS = {"source_filters", "precompute"}
|
|
|
|
|
|
def config_signature(config: dict[str, Any]) -> str:
|
|
"""지오메트리 생성 방법만 남긴 캐시 서명 (선택 목록·등고선·스무딩 제외)."""
|
|
sig_config = {
|
|
k: v
|
|
for k, v in config.items()
|
|
if not k.startswith("contour_")
|
|
and not k.startswith("smoothing_")
|
|
and k not in _SIGNATURE_EXCLUDED_KEYS
|
|
}
|
|
encoded = json.dumps(sig_config, sort_keys=True, default=list).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()[:16]
|
|
|
|
|
|
def bounds_dict(bounds: np.ndarray) -> dict[str, list[float]]:
|
|
return {
|
|
"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])],
|
|
}
|
|
|
|
|
|
def scene_vertices(vertices: np.ndarray, bounds: np.ndarray) -> np.ndarray:
|
|
"""모델 좌표를 뷰어(Y-up) 좌표계로 변환한다."""
|
|
center = bounds.mean(axis=1)
|
|
result = np.empty((len(vertices), 3), dtype=np.float32)
|
|
result[:, 0] = vertices[:, 0] - center[0]
|
|
result[:, 1] = vertices[:, 2] - center[2]
|
|
result[:, 2] = -(vertices[:, 1] - center[1])
|
|
return result
|
|
|
|
|
|
def height_colors(vertices: np.ndarray) -> np.ndarray:
|
|
"""표고에 따른 그라디언트 정점 색상(RGBA)을 만든다."""
|
|
if not len(vertices):
|
|
return np.empty((0, 4), dtype=np.uint8)
|
|
z = vertices[:, 2]
|
|
span = max(float(np.max(z) - np.min(z)), 1e-9)
|
|
t = np.clip((z - np.min(z)) / span, 0.0, 1.0)
|
|
colors = np.empty((len(vertices), 4), dtype=np.uint8)
|
|
colors[:, 0] = np.clip(36 + 190 * t, 0, 255).astype(np.uint8)
|
|
colors[:, 1] = np.clip(86 + 95 * np.sin(t * np.pi), 0, 255).astype(np.uint8)
|
|
colors[:, 2] = np.clip(128 - 80 * t, 0, 255).astype(np.uint8)
|
|
colors[:, 3] = 255
|
|
return colors
|
|
|
|
|
|
def write_glb(path: Path, vertices: np.ndarray, faces: np.ndarray, bounds: np.ndarray) -> None:
|
|
mesh = trimesh.Trimesh(
|
|
vertices=scene_vertices(vertices, bounds),
|
|
faces=np.asarray(faces, dtype=np.int64),
|
|
vertex_colors=height_colors(vertices),
|
|
process=False,
|
|
)
|
|
payload = mesh.export(file_type="glb")
|
|
if not isinstance(payload, bytes):
|
|
raise TypeError("GLB exporter did not return bytes")
|
|
atomic_write_bytes(path, payload)
|
|
|
|
|
|
def write_binary_ply(
|
|
path: Path, vertices: np.ndarray, normals: np.ndarray, bounds: np.ndarray
|
|
) -> None:
|
|
verts = scene_vertices(vertices, bounds)
|
|
scene_normals = np.empty_like(normals, dtype=np.float32)
|
|
scene_normals[:, 0] = normals[:, 0]
|
|
scene_normals[:, 1] = normals[:, 2]
|
|
scene_normals[:, 2] = -normals[:, 1]
|
|
colors = height_colors(vertices)
|
|
dtype = np.dtype(
|
|
[
|
|
("x", "<f4"),
|
|
("y", "<f4"),
|
|
("z", "<f4"),
|
|
("nx", "<f4"),
|
|
("ny", "<f4"),
|
|
("nz", "<f4"),
|
|
("red", "u1"),
|
|
("green", "u1"),
|
|
("blue", "u1"),
|
|
("alpha", "u1"),
|
|
]
|
|
)
|
|
records = np.empty(len(vertices), dtype=dtype)
|
|
records["x"], records["y"], records["z"] = verts.T
|
|
records["nx"], records["ny"], records["nz"] = scene_normals.T
|
|
records["red"], records["green"], records["blue"], records["alpha"] = colors.T
|
|
header = (
|
|
"ply\nformat binary_little_endian 1.0\n"
|
|
f"element vertex {len(vertices)}\n"
|
|
"property float x\nproperty float y\nproperty float z\n"
|
|
"property float nx\nproperty float ny\nproperty float nz\n"
|
|
"property uchar red\nproperty uchar green\nproperty uchar blue\nproperty uchar alpha\n"
|
|
"end_header\n"
|
|
).encode("ascii")
|
|
atomic_write_bytes(path, header + records.tobytes())
|
|
|
|
|
|
def grid_faces(rows: int, cols: int) -> np.ndarray:
|
|
"""정규 격자의 삼각형 면 인덱스를 만든다."""
|
|
if rows < 2 or cols < 2:
|
|
return np.empty((0, 3), dtype=np.uint32)
|
|
base = np.arange((rows - 1) * (cols - 1), dtype=np.uint32)
|
|
row = base // (cols - 1)
|
|
col = base % (cols - 1)
|
|
top_left = row * cols + col
|
|
faces = np.empty((len(base) * 2, 3), dtype=np.uint32)
|
|
faces[0::2] = np.stack([top_left, top_left + cols, top_left + 1], axis=1)
|
|
faces[1::2] = np.stack([top_left + 1, top_left + cols, top_left + cols + 1], axis=1)
|
|
return faces
|
|
|
|
|
|
def clip_and_compact_mesh(
|
|
vertices: np.ndarray, faces: np.ndarray, valid_vertices: np.ndarray
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
"""footprint 내부 정점만 사용하는 면을 남기고 미사용 정점을 제거한다."""
|
|
if not len(faces):
|
|
return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32)
|
|
kept_faces = faces[np.all(valid_vertices[faces], axis=1)]
|
|
if not len(kept_faces):
|
|
return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32)
|
|
used = np.unique(kept_faces)
|
|
remap = np.full(len(vertices), -1, dtype=np.int64)
|
|
remap[used] = np.arange(len(used))
|
|
return vertices[used], remap[kept_faces].astype(np.uint32)
|
|
|
|
|
|
def grid_vertices(x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray) -> np.ndarray:
|
|
xx, yy = np.meshgrid(x_coords, y_coords)
|
|
return np.column_stack([xx.ravel(), yy.ravel(), z_grid.ravel()]).astype(np.float32)
|
|
|
|
|
|
def artifact_size(*paths: Path) -> int:
|
|
return int(sum(path.stat().st_size for path in paths if path.exists()))
|
|
|
|
|
|
@dataclass
|
|
class TerrainContext:
|
|
"""지면 마스크가 적용된 포인트 집합에서 파생 격자·footprint를 캐싱한다."""
|
|
|
|
xyz: np.ndarray
|
|
mask: np.ndarray
|
|
bounds: np.ndarray
|
|
config: dict[str, Any]
|
|
_indices: np.ndarray | None = None
|
|
_samples: dict[int, np.ndarray] = field(default_factory=dict)
|
|
_grids: dict[float, tuple[np.ndarray, np.ndarray, np.ndarray]] = field(default_factory=dict)
|
|
_footprint: tuple[float, float, float, np.ndarray] | None = None
|
|
|
|
@property
|
|
def source_count(self) -> int:
|
|
return int(np.count_nonzero(self.mask))
|
|
|
|
def indices(self) -> np.ndarray:
|
|
if self._indices is None:
|
|
self._indices = np.flatnonzero(self.mask)
|
|
return self._indices
|
|
|
|
def sample(self, maximum: int) -> np.ndarray:
|
|
maximum = max(3, int(maximum))
|
|
if maximum in self._samples:
|
|
return self._samples[maximum]
|
|
indices = self.indices()
|
|
if len(indices) > maximum:
|
|
positions = np.linspace(0, len(indices) - 1, maximum, dtype=np.int64)
|
|
indices = indices[positions]
|
|
points = np.asarray(self.xyz[indices], dtype=np.float32)
|
|
self._samples[maximum] = points
|
|
return points
|
|
|
|
def footprint(self) -> tuple[float, float, float, np.ndarray]:
|
|
if self._footprint is not None:
|
|
return self._footprint
|
|
resolution = max(float(self.config.get("footprint_resolution_meters", 1.0)), 0.1)
|
|
x_min, x_max = self.bounds[0]
|
|
y_min, y_max = self.bounds[1]
|
|
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
|
|
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
|
|
occupied = np.zeros((rows, cols), dtype=bool)
|
|
indices = self.indices()
|
|
for start in range(0, len(indices), _BATCH_SIZE):
|
|
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
|
|
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
|
|
gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1)
|
|
occupied[gy, gx] = True
|
|
if not occupied.any():
|
|
raise ValueError("기준 필터에 footprint를 만들 포인트가 없습니다.")
|
|
|
|
close_cells = max(
|
|
0,
|
|
int(math.ceil(float(self.config.get("footprint_gap_close_meters", 1.0)) / resolution)),
|
|
)
|
|
footprint = occupied
|
|
if close_cells:
|
|
padded = np.pad(footprint, close_cells, mode="constant", constant_values=False)
|
|
padded = ndimage.binary_closing(
|
|
padded, structure=np.ones((3, 3), dtype=bool), iterations=close_cells
|
|
)
|
|
footprint = padded[close_cells:-close_cells, close_cells:-close_cells]
|
|
if bool(self.config.get("keep_largest_footprint", True)):
|
|
labels, component_count = ndimage.label(
|
|
footprint, structure=np.ones((3, 3), dtype=bool)
|
|
)
|
|
if component_count:
|
|
sizes = np.bincount(labels.ravel())
|
|
sizes[0] = 0
|
|
footprint = labels == int(np.argmax(sizes))
|
|
footprint = ndimage.binary_fill_holes(footprint)
|
|
|
|
inset_cells = max(
|
|
0, int(math.ceil(float(self.config.get("boundary_inset_meters", 1.0)) / resolution))
|
|
)
|
|
if inset_cells:
|
|
footprint = ndimage.binary_erosion(
|
|
footprint,
|
|
structure=np.ones((3, 3), dtype=bool),
|
|
iterations=inset_cells,
|
|
border_value=0,
|
|
)
|
|
if not footprint.any():
|
|
raise ValueError("외곽 안쪽 기준 적용 후 유효한 footprint가 없습니다.")
|
|
self._footprint = (float(x_min), float(y_min), resolution, footprint)
|
|
return self._footprint
|
|
|
|
def contains_xy(self, x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
|
x_min, y_min, resolution, footprint = self.footprint()
|
|
gx = np.floor((np.asarray(x) - x_min) / resolution).astype(np.int64)
|
|
gy = np.floor((np.asarray(y) - y_min) / resolution).astype(np.int64)
|
|
valid = (gx >= 0) & (gx < footprint.shape[1]) & (gy >= 0) & (gy < footprint.shape[0])
|
|
result = np.zeros(np.broadcast(x, y).shape, dtype=bool)
|
|
result[valid] = footprint[gy[valid], gx[valid]]
|
|
return result
|
|
|
|
def footprint_metadata(self) -> dict[str, Any]:
|
|
_, _, resolution, footprint = self.footprint()
|
|
return {
|
|
"footprint_area_m2": round(float(footprint.sum()) * resolution * resolution, 3),
|
|
"footprint_resolution_meters": resolution,
|
|
"boundary_inset_meters": float(self.config.get("boundary_inset_meters", 1.0)),
|
|
}
|
|
|
|
def grid(self, resolution: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
resolution = max(float(resolution), 0.05)
|
|
cached = self._grids.get(resolution)
|
|
if cached is not None:
|
|
return cached
|
|
x_min, x_max = self.bounds[0]
|
|
y_min, y_max = self.bounds[1]
|
|
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
|
|
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
|
|
grid = np.full((rows, cols), np.inf, dtype=np.float32)
|
|
indices = self.indices()
|
|
for start in range(0, len(indices), _BATCH_SIZE):
|
|
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
|
|
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
|
|
gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1)
|
|
np.minimum.at(grid, (gy, gx), points[:, 2])
|
|
missing = ~np.isfinite(grid)
|
|
if missing.all():
|
|
raise ValueError("기준 필터에 지면 포인트가 없습니다.")
|
|
if missing.any():
|
|
nearest = ndimage.distance_transform_edt(
|
|
missing, return_distances=False, return_indices=True
|
|
)
|
|
grid = grid[tuple(nearest)]
|
|
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
|
|
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
|
|
result = (x_coords, y_coords, grid)
|
|
self._grids[resolution] = result
|
|
return result
|
|
|
|
def preview_grid(
|
|
self, preferred_resolution: float
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
x_span = max(float(self.bounds[0, 1] - self.bounds[0, 0]), preferred_resolution)
|
|
y_span = max(float(self.bounds[1, 1] - self.bounds[1, 0]), preferred_resolution)
|
|
maximum = max(4, int(self.config["max_preview_vertices"]))
|
|
predicted = (x_span / preferred_resolution + 1) * (y_span / preferred_resolution + 1)
|
|
if predicted > maximum:
|
|
preferred_resolution *= math.sqrt(predicted / maximum)
|
|
return self.grid(preferred_resolution)
|
|
|
|
def clear_caches(self) -> None:
|
|
self._samples.clear()
|
|
self._grids.clear()
|
|
self._indices = None
|
|
|
|
|
|
def with_footprint(context: TerrainContext, metadata: dict[str, Any]) -> dict[str, Any]:
|
|
metadata.update(context.footprint_metadata())
|
|
return metadata
|
|
|
|
|
|
# 모델 빌더가 사용하는 원자적 저장 래퍼 (공통 유틸 재노출)
|
|
atomic_npz = atomic_write_npz
|