Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-11-01 23:03:44 +09:00
7 changed files with 318 additions and 11 deletions
@@ -0,0 +1,247 @@
"""B04 점진 TIN(PTD · Axelsson 꼴) 지면 필터 — 분류 없는 LAS 의 기본 필터.
1. 작은 칸(후보 칸) 최저점 = 후보 · 시작 간격 칸 최저점 = 씨앗.
2. 씨앗 삼각망 → 후보마다 삼각면까지 연직 거리 · 세 꼭짓점 각을 보고 문턱 안이면
삼각형마다 가장 가까운 하나를 지면에 더함 → 더 안 들어올 때까지 되풀이.
삼각망 밖 후보 · 격자는 맞닿은 테두리 삼각형 평면을 늘여 봄(밖으로 차례로 넓어짐).
굵게 → 가늘게: 후보가 모두 면 위로 뜬 칸(면이 처짐)에 반 간격 씨앗을 더해 다시 다짐.
끝에 둘레보다 최대 거리 넘게 튀거나 꺼진 외딴 꼭짓점(가시 · 잡점)을 걷음.
3. 마지막 지면 TIN 을 후보 칸 격자로 굳히고 모든 점을 면까지 연직 거리로 판정 —
면 아래 최대 거리 · 면 위 0.2 m(꼭짓점 = 칸 최저라 땅은 면 곁 · 위는 덤불).
장면 셋(DJI Terra 꼴) = 평지 3° · 0.3 m / 완경사 6° · 0.5 m / 급경사 10° · 0.7 m
· 기본 급경사(임도).
큰 파일 = 점을 덩이로 나눠 칸 최저만 모음 · 후보가 상한을 넘으면 후보 칸을 키움(메모리 상한).
"""
import math
from typing import Any
import numpy as np
from scipy import ndimage
from scipy.spatial import Delaunay, cKDTree
from config.config_system import SURFACE_PTD_SCENE, SURFACE_PTD_SEED_SPACING_M
# 장면 → (최대 각 °, 최대 거리 m)
PTD_SCENES = {"flat": (3.0, 0.3), "gentle": (6.0, 0.5), "steep": (10.0, 0.7)}
_CHUNK_POINTS = 5_000_000
def _cell_minima(xyz: np.ndarray, origin: tuple[float, float], cell: float, cols: int):
"""칸마다 가장 낮은 점의 (칸 번호, 점 번호)를 덩이로 나눠 모은다."""
keys, picks = [], []
for start in range(0, len(xyz), _CHUNK_POINTS):
part = xyz[start : start + _CHUNK_POINTS]
gx = ((part[:, 0] - origin[0]) / cell).astype(np.int64)
gy = ((part[:, 1] - origin[1]) / cell).astype(np.int64)
key = gy * cols + gx
order = np.lexsort((part[:, 2], key))
first = np.r_[True, key[order][1:] != key[order][:-1]]
keys.append(key[order][first])
picks.append(order[first] + start)
key = np.concatenate(keys)
pick = np.concatenate(picks)
order = np.lexsort((xyz[pick, 2], key))
first = np.r_[True, key[order][1:] != key[order][:-1]]
return key[order][first], pick[order][first]
def _lone_vertices(points: np.ndarray, gap: float, rounds: int = 10) -> np.ndarray:
"""둘레 꼭짓점 모두보다 gap 넘게 높거나(덤불 가시) 낮은(땅 밑 잡점) 꼭짓점 번호.
능선 · 골은 축 따라 비슷한 이웃이 있어 안 걸림."""
keep = np.arange(len(points))
for _ in range(rounds):
if len(keep) < 4:
break
indptr, nb = Delaunay(points[keep, :2]).vertex_neighbor_vertices
z = points[keep, 2]
low = np.minimum.reduceat(z[nb], indptr[:-1])
high = np.maximum.reduceat(z[nb], indptr[:-1])
lone = (z > high + gap) | (z < low - gap)
if not lone.any():
break
keep = keep[~lone]
return np.setdiff1d(np.arange(len(points)), keep)
def _lowest_per_cell(points: np.ndarray, origin: tuple[float, float], size: float):
"""칸마다 가장 낮은 점 — (칸 열, 칸 행, 점 번호)."""
gx = ((points[:, 0] - origin[0]) / size).astype(np.int64)
gy = ((points[:, 1] - origin[1]) / size).astype(np.int64)
key = gy * (gx.max() + 1) + gx
order = np.lexsort((points[:, 2], key))
first = np.r_[True, key[order][1:] != key[order][:-1]]
pick = order[first]
return gx[pick], gy[pick], pick
def _barycentric(tri: Delaunay, simplex: np.ndarray, q: np.ndarray) -> np.ndarray:
"""세 꼭짓점 무게(차례 = simplices 차례). tri.transform 은 BLAS 스레드 다툼으로
이 PC 에서 1 만 삼각형에 14 초 → 직접 셈."""
p = tri.points[tri.simplices[simplex]]
v0, v1, v2 = p[:, 1] - p[:, 0], p[:, 2] - p[:, 0], q - p[:, 0]
den = v0[:, 0] * v1[:, 1] - v1[:, 0] * v0[:, 1]
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
l1 = (v2[:, 0] * v1[:, 1] - v1[:, 0] * v2[:, 1]) / den
l2 = (v0[:, 0] * v2[:, 1] - v2[:, 0] * v0[:, 1]) / den
return np.column_stack([1.0 - l1 - l2, l1, l2])
def _locate(tri: Delaunay, q: np.ndarray, max_steps: int = 200) -> np.ndarray:
"""점이 든 삼각형 번호 · 삼각망 밖이면 맞닿은 테두리 삼각형(그 평면으로 늘여 봄).
가장 가까운 꼭짓점에서 이웃으로 걸음 — scipy find_simplex 는 점마다 0 번에서 걸어
백만 점에 수십 분이 걸린다."""
_, nearest = cKDTree(tri.points).query(q)
simplex = tri.vertex_to_simplex[nearest].astype(np.int64)
active = np.arange(len(q))
for _ in range(max_steps):
if not len(active):
break
bary = _barycentric(tri, simplex[active], q[active])
worst = bary.argmin(axis=1)
moving = bary[np.arange(len(active)), worst] < -1e-9
step = tri.neighbors[simplex[active], worst]
walk = moving & (step >= 0)
simplex[active[walk]] = step[walk]
active = active[walk]
return simplex
def _plane_distance(tri: Delaunay, gz: np.ndarray, simplex: np.ndarray, q: np.ndarray):
"""점 q 의 삼각면까지 연직 거리와 세 꼭짓점에서 본 최대 각(°).
연직 = 비탈에서 수직 거리보다 관목을 덜 받음(수직은 45° 비탈 0.7 m 가 연직 1 m)."""
vertex = tri.simplices[simplex]
weight = _barycentric(tri, simplex, q[:, :2])
distance = np.abs(q[:, 2] - (weight * gz[vertex]).sum(axis=1))
reach = np.linalg.norm(q[:, None, :2] - tri.points[vertex], axis=2).min(axis=1)
angle = np.degrees(np.arctan(distance / np.maximum(reach, 1e-9)))
return distance, angle
def _tin_dz(ground: np.ndarray, q: np.ndarray) -> np.ndarray:
"""q 높이 − 지면 TIN 높이(연직)."""
tri = Delaunay(ground[:, :2])
simplex = _locate(tri, q[:, :2])
weight = _barycentric(tri, simplex, q[:, :2])
return q[:, 2] - (weight * ground[tri.simplices[simplex], 2]).sum(axis=1)
def _densify(
cand: np.ndarray, is_ground: np.ndarray, max_angle: float, max_distance: float, rounds: int
) -> None:
"""삼각형마다 문턱 안 가장 가까운 후보 하나를 지면에 더함 — 더 안 들어올 때까지."""
for _ in range(rounds):
ground = cand[is_ground]
tri = Delaunay(ground[:, :2])
pending = np.flatnonzero(~is_ground)
if not len(pending):
return
simplex = _locate(tri, cand[pending, :2])
distance, angle = _plane_distance(tri, ground[:, 2], simplex, cand[pending])
# 꼭짓점 곁 점은 각이 커지기만 함 → 최대 거리 절반 안이면 각을 안 봄
ok = (distance <= max_distance) & ((angle <= max_angle) | (distance <= max_distance / 2))
pending, simplex, distance = pending[ok], simplex[ok], distance[ok]
if not len(pending):
return
order = np.lexsort((distance, simplex))
first = np.r_[True, simplex[order][1:] != simplex[order][:-1]]
is_ground[pending[order[first]]] = True
if first.sum() < max(10, 0.001 * is_ground.sum()):
return
def filter_ptd(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
scene: str = SURFACE_PTD_SCENE,
seed_spacing: float = SURFACE_PTD_SEED_SPACING_M,
max_angle: float | None = None,
max_distance: float | None = None,
above_distance: float = 0.2,
candidate_cell: float = 0.5,
max_candidates: int = 3_000_000,
max_iterations: int = 60,
refine_min_spacing: float = 2.5,
) -> np.ndarray:
"""점진 TIN 으로 지면 포인트의 불리언 마스크를 반환한다."""
if scene not in PTD_SCENES:
raise ValueError(f"점진 TIN 장면은 {', '.join(PTD_SCENES)} 중 하나여야 합니다.")
scene_angle, scene_distance = PTD_SCENES[scene]
max_angle = scene_angle if max_angle is None else float(max_angle)
max_distance = scene_distance if max_distance is None else float(max_distance)
for name, value in (("시작 간격", seed_spacing), ("후보 칸", candidate_cell)):
if not math.isfinite(value) or value <= 0:
raise ValueError(f"{name}은 0보다 큰 유한한 값이어야 합니다.")
if not (0 < max_angle < 90) or max_distance <= 0 or above_distance < 0:
raise ValueError("최대 각은 0~90°, 최대 거리는 0보다 커야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if len(xyz) < 3:
return np.ones(len(xyz), dtype=bool)
x_min, y_min = float(xyz[:, 0].min()), float(xyz[:, 1].min())
x_max, y_max = float(xyz[:, 0].max()), float(xyz[:, 1].max())
width, height = max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6)
# ponytail: 후보 상한은 칸을 키워 지킴(공간 타일 아님) · 수 km² 넘으면 겹침 타일로
cell = max(candidate_cell, math.sqrt(width * height / max_candidates))
cols = int(width / cell) + 1
cand_key, cand_idx = _cell_minima(xyz, (x_min, y_min), cell, cols)
cand = xyz[cand_idx]
# 씨앗 = 시작 간격 칸마다 가장 낮은 후보 + 자료 가장자리는 1/4 간격 칸마다(삼각망이
# 가장자리까지 닿게 — 밖은 늘여 볼 테두리 삼각형이 가늘어 땅을 놓침)
origin = (x_min, y_min)
is_ground = np.zeros(len(cand), dtype=bool)
is_ground[_lowest_per_cell(cand, origin, seed_spacing)[2]] = True
gx, gy, pick = _lowest_per_cell(cand, origin, seed_spacing / 4)
occupied = np.zeros((gy.max() + 1, gx.max() + 1), dtype=bool)
occupied[gy, gx] = True
edge = occupied & ~ndimage.binary_erosion(occupied, iterations=1, border_value=0)
is_ground[pick[edge[gy, gx]]] = True
_densify(cand, is_ground, max_angle, max_distance, max_iterations)
# 굵게 → 가늘게: 칸 안 후보가 모두 면보다 최대 거리 넘게 위면(땅이 모든 반사점 밑일 수
# 없음 = 성긴 씨앗 사이에서 면이 처짐) 면에 가장 가까운 후보를 씨앗으로 더하고 다시 다짐
spacing = seed_spacing / 2
while spacing >= refine_min_spacing:
dz = np.zeros(len(cand))
pending = np.flatnonzero(~is_ground)
dz[pending] = _tin_dz(cand[is_ground], cand[pending])
gx, gy, pick = _lowest_per_cell(np.column_stack([cand[:, :2], dz]), origin, spacing)
# 간격 절반보다 높이 뜬 칸 = 땅이 안 보이는 수관 · 덤불 → 씨앗으로 안 씀
sag = pick[(dz[pick] > max_distance) & (dz[pick] <= spacing / 2)]
if len(sag):
is_ground[sag] = True
_densify(cand, is_ground, max_angle, max_distance, max_iterations)
spacing /= 2
ground_idx = np.flatnonzero(is_ground)
is_ground[ground_idx[_lone_vertices(cand[ground_idx], max_distance)]] = False
# 마지막 지면 TIN → 후보 칸 격자 → 모든 점 연직 거리 판정
ground = cand[is_ground]
tri = Delaunay(ground[:, :2])
rows = int(height / cell) + 2
mesh_x, mesh_y = np.meshgrid(x_min + np.arange(cols + 1) * cell, y_min + np.arange(rows) * cell)
node = np.column_stack([mesh_x.ravel(), mesh_y.ravel()])
simplex = _locate(tri, node)
weight = _barycentric(tri, simplex, node)
grid = (weight * ground[tri.simplices[simplex], 2]).sum(axis=1).reshape(mesh_x.shape)
mask = np.zeros(len(xyz), dtype=bool)
for start in range(0, len(xyz), _CHUNK_POINTS):
part = xyz[start : start + _CHUNK_POINTS]
fx = np.clip((part[:, 0] - x_min) / cell, 0, cols - 1e-6)
fy = np.clip((part[:, 1] - y_min) / cell, 0, rows - 1 - 1e-6)
ix, iy = fx.astype(np.int64), fy.astype(np.int64)
tx, ty = fx - ix, fy - iy
z_tin = (
grid[iy, ix] * (1 - tx) * (1 - ty)
+ grid[iy, ix + 1] * tx * (1 - ty)
+ grid[iy + 1, ix] * (1 - tx) * ty
+ grid[iy + 1, ix + 1] * tx * ty
)
dz = part[:, 2] - z_tin
mask[start : start + len(part)] = (dz >= -max_distance) & (dz <= above_distance)
return mask
@@ -18,6 +18,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
from B04_PreProcess.B04_PreProcess_Engine_Filter_Grid import filter_grid_min_z
from B04_PreProcess.B04_PreProcess_Engine_Filter_PMF import filter_pmf
from B04_PreProcess.B04_PreProcess_Engine_Filter_PTD import filter_ptd
from B04_PreProcess.B04_PreProcess_Engine_Filter_RANSAC import filter_ransac
from config.config_system import SURFACE_AUTO_FALLBACK_FILTER
@@ -27,6 +28,7 @@ _FILTERS = {
"csf": filter_csf,
"pmf": filter_pmf,
"ransac": filter_ransac,
"ptd": filter_ptd,
"classification": filter_classification,
}
+2 -2
View File
@@ -51,7 +51,7 @@ import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_swi
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
const SOURCE_FILTERS = ["classification", "grid_min_z", "csf", "pmf"] as const;
const SOURCE_FILTERS = ["classification", "ptd", "grid_min_z", "csf", "pmf"] as const;
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
const DEFAULT_FILTER = "csf";
const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저
@@ -77,7 +77,7 @@ function buildSelectField(
label,
options: values.map((value) => ({
value,
text: value.replaceAll("_", " ").toUpperCase(),
text: value === "ptd" ? "점진 TIN" : value.replaceAll("_", " ").toUpperCase(),
})),
value: defaultValue,
size: 36,