occulus 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- occulus/__init__.py +45 -0
- occulus/_cpp/__init__.py +64 -0
- occulus/_version.py +7 -0
- occulus/config.py +57 -0
- occulus/exceptions.py +51 -0
- occulus/features/__init__.py +465 -0
- occulus/filters/__init__.py +340 -0
- occulus/io/__init__.py +17 -0
- occulus/io/readers.py +490 -0
- occulus/io/writers.py +247 -0
- occulus/mesh/__init__.py +351 -0
- occulus/metrics/__init__.py +368 -0
- occulus/normals/__init__.py +203 -0
- occulus/registration/__init__.py +40 -0
- occulus/registration/global_registration.py +435 -0
- occulus/registration/icp.py +537 -0
- occulus/segmentation/__init__.py +33 -0
- occulus/segmentation/ground.py +389 -0
- occulus/segmentation/objects.py +334 -0
- occulus/types.py +539 -0
- occulus/viz/__init__.py +256 -0
- occulus-1.0.0.dist-info/METADATA +511 -0
- occulus-1.0.0.dist-info/RECORD +25 -0
- occulus-1.0.0.dist-info/WHEEL +4 -0
- occulus-1.0.0.dist-info/licenses/LICENSE +674 -0
occulus/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Occulus — Multi-platform point cloud analysis.
|
|
2
|
+
|
|
3
|
+
Registration, segmentation, meshing, and feature extraction for
|
|
4
|
+
aerial, terrestrial, and UAV LiDAR point clouds.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
import occulus
|
|
9
|
+
|
|
10
|
+
cloud = occulus.read("scan.laz", platform="terrestrial")
|
|
11
|
+
cloud = occulus.estimate_normals(cloud)
|
|
12
|
+
result = occulus.icp(source, target)
|
|
13
|
+
|
|
14
|
+
Platform-aware types::
|
|
15
|
+
|
|
16
|
+
from occulus.types import PointCloud, AerialCloud, TerrestrialCloud, UAVCloud
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from occulus._version import __version__
|
|
21
|
+
from occulus.io import read, write
|
|
22
|
+
from occulus.types import (
|
|
23
|
+
AcquisitionMetadata,
|
|
24
|
+
AerialCloud,
|
|
25
|
+
Platform,
|
|
26
|
+
PointCloud,
|
|
27
|
+
ScanPosition,
|
|
28
|
+
TerrestrialCloud,
|
|
29
|
+
UAVCloud,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AcquisitionMetadata",
|
|
34
|
+
"AerialCloud",
|
|
35
|
+
"Platform",
|
|
36
|
+
# Types
|
|
37
|
+
"PointCloud",
|
|
38
|
+
"ScanPosition",
|
|
39
|
+
"TerrestrialCloud",
|
|
40
|
+
"UAVCloud",
|
|
41
|
+
"__version__",
|
|
42
|
+
# I/O
|
|
43
|
+
"read",
|
|
44
|
+
"write",
|
|
45
|
+
]
|
occulus/_cpp/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""C++ backend bindings via pybind11.
|
|
2
|
+
|
|
3
|
+
This package wraps the compiled C++ core library. The actual C++ source
|
|
4
|
+
lives in ``cpp/`` at the repository root and is built via CMake + pybind11.
|
|
5
|
+
|
|
6
|
+
If the compiled extension is not available (e.g., pure-Python fallback),
|
|
7
|
+
functions in this module raise ``OcculusCppError`` with install instructions.
|
|
8
|
+
|
|
9
|
+
Submodules (when compiled):
|
|
10
|
+
|
|
11
|
+
occulus._cpp.kdtree — k-d tree construction and queries
|
|
12
|
+
occulus._cpp.registration — ICP point-to-point and point-to-plane
|
|
13
|
+
occulus._cpp.segmentation — CSF, PMF ground classification
|
|
14
|
+
occulus._cpp.mesh — Poisson, BPA surface reconstruction
|
|
15
|
+
occulus._cpp.features — RANSAC plane/cylinder fitting
|
|
16
|
+
occulus._cpp.normals — PCA normal estimation
|
|
17
|
+
occulus._cpp.filters — SOR, voxel grid, radius outlier
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
|
|
24
|
+
from occulus.exceptions import OcculusCppError
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_CPP_AVAILABLE = False
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from occulus._cpp._core import ( # type: ignore[import-not-found,unused-ignore]
|
|
32
|
+
features,
|
|
33
|
+
filters,
|
|
34
|
+
kdtree,
|
|
35
|
+
mesh,
|
|
36
|
+
normals,
|
|
37
|
+
registration,
|
|
38
|
+
segmentation,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_CPP_AVAILABLE = True
|
|
42
|
+
except ImportError:
|
|
43
|
+
logger.debug("C++ backend not available — using pure-Python fallbacks")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_cpp(operation: str) -> None:
|
|
47
|
+
"""Raise an error if the C++ backend is not available.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
operation : str
|
|
52
|
+
Name of the operation requiring C++.
|
|
53
|
+
|
|
54
|
+
Raises
|
|
55
|
+
------
|
|
56
|
+
OcculusCppError
|
|
57
|
+
Always, if C++ is not compiled.
|
|
58
|
+
"""
|
|
59
|
+
if not _CPP_AVAILABLE:
|
|
60
|
+
raise OcculusCppError(
|
|
61
|
+
f"The C++ backend is required for '{operation}' but is not installed. "
|
|
62
|
+
"Rebuild with: pip install occulus --no-binary :all: "
|
|
63
|
+
"or install a prebuilt wheel for your platform."
|
|
64
|
+
)
|
occulus/_version.py
ADDED
occulus/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Configuration and constants for Occulus.
|
|
2
|
+
|
|
3
|
+
All configurable values live here. Never hardcode paths, thresholds,
|
|
4
|
+
or environment-specific values anywhere else in the codebase.
|
|
5
|
+
|
|
6
|
+
Environment Variables
|
|
7
|
+
---------------------
|
|
8
|
+
OCCULUS_NUM_THREADS
|
|
9
|
+
Number of threads for parallel operations. Defaults to CPU count.
|
|
10
|
+
|
|
11
|
+
OCCULUS_LOG_LEVEL
|
|
12
|
+
Logging verbosity. Defaults to WARNING.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Defaults
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
DEFAULT_NUM_THREADS: int = os.cpu_count() or 4
|
|
28
|
+
DEFAULT_LOG_LEVEL: str = "WARNING"
|
|
29
|
+
|
|
30
|
+
# Registration defaults
|
|
31
|
+
DEFAULT_ICP_MAX_ITERATIONS: int = 50
|
|
32
|
+
DEFAULT_ICP_TOLERANCE: float = 1e-6
|
|
33
|
+
DEFAULT_ICP_MAX_CORRESPONDENCE_DISTANCE: float = 1.0
|
|
34
|
+
|
|
35
|
+
# Segmentation defaults
|
|
36
|
+
DEFAULT_GROUND_CLOTH_RESOLUTION: float = 2.0 # meters — CSF default for aerial
|
|
37
|
+
DEFAULT_GROUND_MAX_ANGLE: float = 15.0 # degrees
|
|
38
|
+
DEFAULT_MIN_SEGMENT_POINTS: int = 50
|
|
39
|
+
|
|
40
|
+
# Mesh defaults
|
|
41
|
+
DEFAULT_POISSON_DEPTH: int = 8
|
|
42
|
+
DEFAULT_BPA_RADII_FACTOR: float = 2.0
|
|
43
|
+
|
|
44
|
+
# Filter defaults
|
|
45
|
+
DEFAULT_SOR_K_NEIGHBORS: int = 20
|
|
46
|
+
DEFAULT_SOR_STD_RATIO: float = 2.0
|
|
47
|
+
DEFAULT_VOXEL_SIZE: float = 0.05 # meters
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Environment variable overrides
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
NUM_THREADS: int = int(os.environ.get("OCCULUS_NUM_THREADS", DEFAULT_NUM_THREADS))
|
|
54
|
+
LOG_LEVEL: str = os.environ.get("OCCULUS_LOG_LEVEL", DEFAULT_LOG_LEVEL)
|
|
55
|
+
|
|
56
|
+
if NUM_THREADS != DEFAULT_NUM_THREADS:
|
|
57
|
+
logger.debug("Using custom thread count from environment: %d", NUM_THREADS)
|
occulus/exceptions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Exception hierarchy for Occulus.
|
|
2
|
+
|
|
3
|
+
All domain exceptions inherit from ``OcculusError`` so callers can
|
|
4
|
+
catch the base class to handle any library error:
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
cloud = occulus.read("scan.laz")
|
|
8
|
+
except occulus.OcculusError as exc:
|
|
9
|
+
logger.error("Occulus operation failed: %s", exc)
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OcculusError(Exception):
|
|
15
|
+
"""Base exception for all Occulus errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OcculusIOError(OcculusError):
|
|
19
|
+
"""Raised when reading or writing point cloud files fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OcculusValidationError(OcculusError):
|
|
23
|
+
"""Raised when input parameters fail validation."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OcculusRegistrationError(OcculusError):
|
|
27
|
+
"""Raised when point cloud registration fails to converge."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OcculusSegmentationError(OcculusError):
|
|
31
|
+
"""Raised when segmentation produces invalid or empty results."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OcculusMeshError(OcculusError):
|
|
35
|
+
"""Raised when surface reconstruction fails."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OcculusFeatureError(OcculusError):
|
|
39
|
+
"""Raised when geometric feature extraction fails."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OcculusCppError(OcculusError):
|
|
43
|
+
"""Raised when the C++ backend encounters an unrecoverable error."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OcculusNetworkError(OcculusError):
|
|
47
|
+
"""Raised when a network or remote data-access operation fails."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnsupportedPlatformError(OcculusError):
|
|
51
|
+
"""Raised when an operation is not supported for the given acquisition platform."""
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Geometric feature extraction from point clouds.
|
|
2
|
+
|
|
3
|
+
Detects and fits geometric primitives (planes, cylinders) using RANSAC,
|
|
4
|
+
and computes eigenvalue-based geometric descriptors for each point.
|
|
5
|
+
|
|
6
|
+
Available functions
|
|
7
|
+
-------------------
|
|
8
|
+
- :func:`detect_planes` — sequential RANSAC plane detection
|
|
9
|
+
- :func:`detect_cylinders` — RANSAC cylinder fitting
|
|
10
|
+
- :func:`compute_geometric_features` — per-point eigenvalue features
|
|
11
|
+
|
|
12
|
+
All implementations use pure NumPy and SciPy. No optional dependencies required.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from numpy.typing import NDArray
|
|
22
|
+
from scipy.spatial import KDTree # type: ignore[import-untyped]
|
|
23
|
+
|
|
24
|
+
from occulus.exceptions import OcculusFeatureError, OcculusValidationError
|
|
25
|
+
from occulus.types import PointCloud
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CylinderResult",
|
|
31
|
+
"GeometricFeatures",
|
|
32
|
+
"PlaneResult",
|
|
33
|
+
"compute_geometric_features",
|
|
34
|
+
"detect_cylinders",
|
|
35
|
+
"detect_planes",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PlaneResult:
|
|
41
|
+
"""A detected planar surface.
|
|
42
|
+
|
|
43
|
+
Attributes
|
|
44
|
+
----------
|
|
45
|
+
equation : NDArray[np.float64]
|
|
46
|
+
Plane equation ``[a, b, c, d]`` where ``ax + by + cz + d = 0``.
|
|
47
|
+
``[a, b, c]`` is the unit normal.
|
|
48
|
+
normal : NDArray[np.float64]
|
|
49
|
+
Unit normal vector (3,).
|
|
50
|
+
inlier_mask : NDArray[np.bool_]
|
|
51
|
+
Boolean mask of points belonging to this plane (length = original n_points).
|
|
52
|
+
n_inliers : int
|
|
53
|
+
Number of inlier points.
|
|
54
|
+
rmse : float
|
|
55
|
+
RMS distance of inlier points to the fitted plane.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
equation: NDArray[np.float64]
|
|
59
|
+
normal: NDArray[np.float64]
|
|
60
|
+
inlier_mask: NDArray[np.bool_]
|
|
61
|
+
n_inliers: int
|
|
62
|
+
rmse: float
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class CylinderResult:
|
|
67
|
+
"""A detected cylindrical surface.
|
|
68
|
+
|
|
69
|
+
Attributes
|
|
70
|
+
----------
|
|
71
|
+
axis_point : NDArray[np.float64]
|
|
72
|
+
A point on the cylinder axis (3,).
|
|
73
|
+
axis_direction : NDArray[np.float64]
|
|
74
|
+
Unit direction vector of the cylinder axis (3,).
|
|
75
|
+
radius : float
|
|
76
|
+
Fitted cylinder radius.
|
|
77
|
+
inlier_mask : NDArray[np.bool_]
|
|
78
|
+
Boolean mask of inlier points.
|
|
79
|
+
n_inliers : int
|
|
80
|
+
Number of inlier points.
|
|
81
|
+
rmse : float
|
|
82
|
+
RMS distance of inlier points to the cylinder surface.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
axis_point: NDArray[np.float64]
|
|
86
|
+
axis_direction: NDArray[np.float64]
|
|
87
|
+
radius: float
|
|
88
|
+
inlier_mask: NDArray[np.bool_]
|
|
89
|
+
n_inliers: int
|
|
90
|
+
rmse: float
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class GeometricFeatures:
|
|
95
|
+
"""Per-point eigenvalue-based geometric features.
|
|
96
|
+
|
|
97
|
+
All arrays have length ``n_points``.
|
|
98
|
+
|
|
99
|
+
Attributes
|
|
100
|
+
----------
|
|
101
|
+
linearity : NDArray[np.float64]
|
|
102
|
+
``(λ1 - λ2) / λ1`` — high for linear structures (edges, poles).
|
|
103
|
+
planarity : NDArray[np.float64]
|
|
104
|
+
``(λ2 - λ3) / λ1`` — high for planar surfaces.
|
|
105
|
+
sphericity : NDArray[np.float64]
|
|
106
|
+
``λ3 / λ1`` — high for volumetric / isotropic regions.
|
|
107
|
+
omnivariance : NDArray[np.float64]
|
|
108
|
+
``(λ1 * λ2 * λ3) ** (1/3)`` — overall dispersion.
|
|
109
|
+
anisotropy : NDArray[np.float64]
|
|
110
|
+
``(λ1 - λ3) / λ1``.
|
|
111
|
+
eigenentropy : NDArray[np.float64]
|
|
112
|
+
``-sum(λi * log(λi))`` — disorder measure.
|
|
113
|
+
curvature : NDArray[np.float64]
|
|
114
|
+
``λ3 / (λ1 + λ2 + λ3)`` — surface curvature estimate.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
linearity: NDArray[np.float64]
|
|
118
|
+
planarity: NDArray[np.float64]
|
|
119
|
+
sphericity: NDArray[np.float64]
|
|
120
|
+
omnivariance: NDArray[np.float64]
|
|
121
|
+
anisotropy: NDArray[np.float64]
|
|
122
|
+
eigenentropy: NDArray[np.float64]
|
|
123
|
+
curvature: NDArray[np.float64]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def detect_planes(
|
|
127
|
+
cloud: PointCloud,
|
|
128
|
+
*,
|
|
129
|
+
distance_threshold: float = 0.02,
|
|
130
|
+
ransac_n: int = 3,
|
|
131
|
+
num_iterations: int = 1000,
|
|
132
|
+
max_planes: int = 10,
|
|
133
|
+
min_points: int = 100,
|
|
134
|
+
) -> list[PlaneResult]:
|
|
135
|
+
"""Detect planar surfaces using sequential RANSAC.
|
|
136
|
+
|
|
137
|
+
Iteratively finds the largest plane, removes its inliers, and repeats
|
|
138
|
+
until fewer than ``min_points`` remain or ``max_planes`` planes are found.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
cloud : PointCloud
|
|
143
|
+
Input point cloud.
|
|
144
|
+
distance_threshold : float, optional
|
|
145
|
+
Maximum distance from a point to the plane model to count as an
|
|
146
|
+
inlier, by default 0.02.
|
|
147
|
+
ransac_n : int, optional
|
|
148
|
+
Number of points sampled per RANSAC hypothesis, by default 3.
|
|
149
|
+
num_iterations : int, optional
|
|
150
|
+
Number of RANSAC iterations per plane, by default 1000.
|
|
151
|
+
max_planes : int, optional
|
|
152
|
+
Maximum number of planes to extract, by default 10.
|
|
153
|
+
min_points : int, optional
|
|
154
|
+
Stop extracting when fewer than this many points remain,
|
|
155
|
+
by default 100.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
list[PlaneResult]
|
|
160
|
+
Detected planes, ordered by decreasing inlier count.
|
|
161
|
+
|
|
162
|
+
Raises
|
|
163
|
+
------
|
|
164
|
+
OcculusFeatureError
|
|
165
|
+
If the cloud has too few points.
|
|
166
|
+
"""
|
|
167
|
+
if cloud.n_points < ransac_n:
|
|
168
|
+
raise OcculusFeatureError(
|
|
169
|
+
f"detect_planes requires at least {ransac_n} points, got {cloud.n_points}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
xyz = cloud.xyz.copy()
|
|
173
|
+
remaining_mask = np.ones(cloud.n_points, dtype=bool)
|
|
174
|
+
planes: list[PlaneResult] = []
|
|
175
|
+
|
|
176
|
+
rng = np.random.default_rng()
|
|
177
|
+
|
|
178
|
+
while remaining_mask.sum() >= min_points and len(planes) < max_planes:
|
|
179
|
+
pts = xyz[remaining_mask]
|
|
180
|
+
n_remaining = len(pts)
|
|
181
|
+
|
|
182
|
+
best_inliers = 0
|
|
183
|
+
best_eq: NDArray[np.float64] = np.zeros(4)
|
|
184
|
+
|
|
185
|
+
for _ in range(num_iterations):
|
|
186
|
+
sample_idx = rng.choice(n_remaining, size=ransac_n, replace=False)
|
|
187
|
+
sample = pts[sample_idx]
|
|
188
|
+
|
|
189
|
+
# Fit plane through 3 points
|
|
190
|
+
v1 = sample[1] - sample[0]
|
|
191
|
+
v2 = sample[2] - sample[0]
|
|
192
|
+
normal = np.cross(v1, v2)
|
|
193
|
+
norm = np.linalg.norm(normal)
|
|
194
|
+
if norm < 1e-12:
|
|
195
|
+
continue
|
|
196
|
+
normal = normal / norm
|
|
197
|
+
d = -np.dot(normal, sample[0])
|
|
198
|
+
eq = np.array([normal[0], normal[1], normal[2], d])
|
|
199
|
+
|
|
200
|
+
# Signed distances
|
|
201
|
+
dists = np.abs(pts @ normal + d)
|
|
202
|
+
n_inliers = int((dists <= distance_threshold).sum())
|
|
203
|
+
|
|
204
|
+
if n_inliers > best_inliers:
|
|
205
|
+
best_inliers = n_inliers
|
|
206
|
+
best_eq = eq
|
|
207
|
+
|
|
208
|
+
if best_inliers < min_points:
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
# Refit on all inliers for accurate equation
|
|
212
|
+
normal_best = best_eq[:3]
|
|
213
|
+
d_best = best_eq[3]
|
|
214
|
+
dists_all = np.abs(pts @ normal_best + d_best)
|
|
215
|
+
inlier_local = dists_all <= distance_threshold
|
|
216
|
+
|
|
217
|
+
inlier_pts = pts[inlier_local]
|
|
218
|
+
centroid = inlier_pts.mean(axis=0)
|
|
219
|
+
centred = inlier_pts - centroid
|
|
220
|
+
_, _, Vt = np.linalg.svd(centred, full_matrices=False)
|
|
221
|
+
normal_fit = Vt[-1]
|
|
222
|
+
if np.dot(normal_fit, normal_best) < 0:
|
|
223
|
+
normal_fit = -normal_fit
|
|
224
|
+
d_fit = -np.dot(normal_fit, centroid)
|
|
225
|
+
final_eq = np.array([*normal_fit, d_fit])
|
|
226
|
+
|
|
227
|
+
dists_final = np.abs(pts @ normal_fit + d_fit)
|
|
228
|
+
final_inlier_local = dists_final <= distance_threshold
|
|
229
|
+
rmse = float(np.sqrt((dists_final[final_inlier_local] ** 2).mean()))
|
|
230
|
+
|
|
231
|
+
# Map local inlier mask back to full cloud mask
|
|
232
|
+
remaining_indices = np.where(remaining_mask)[0]
|
|
233
|
+
full_inlier_mask = np.zeros(cloud.n_points, dtype=bool)
|
|
234
|
+
full_inlier_mask[remaining_indices[final_inlier_local]] = True
|
|
235
|
+
|
|
236
|
+
planes.append(
|
|
237
|
+
PlaneResult(
|
|
238
|
+
equation=final_eq,
|
|
239
|
+
normal=normal_fit,
|
|
240
|
+
inlier_mask=full_inlier_mask,
|
|
241
|
+
n_inliers=int(final_inlier_local.sum()),
|
|
242
|
+
rmse=rmse,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Remove inliers from remaining points
|
|
247
|
+
remaining_mask[remaining_indices[final_inlier_local]] = False
|
|
248
|
+
logger.debug(
|
|
249
|
+
"detect_planes: found plane %d (%d inliers, rmse=%.4f), %d remaining",
|
|
250
|
+
len(planes),
|
|
251
|
+
int(final_inlier_local.sum()),
|
|
252
|
+
rmse,
|
|
253
|
+
remaining_mask.sum(),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
logger.info("detect_planes: found %d planes", len(planes))
|
|
257
|
+
return planes
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def detect_cylinders(
|
|
261
|
+
cloud: PointCloud,
|
|
262
|
+
*,
|
|
263
|
+
distance_threshold: float = 0.02,
|
|
264
|
+
radius_range: tuple[float, float] = (0.01, 2.0),
|
|
265
|
+
num_iterations: int = 5000,
|
|
266
|
+
min_points: int = 100,
|
|
267
|
+
) -> list[CylinderResult]:
|
|
268
|
+
"""Detect cylindrical surfaces using RANSAC.
|
|
269
|
+
|
|
270
|
+
Fits cylinders to point cloud data — useful for extracting pipes,
|
|
271
|
+
poles, and tree trunks from TLS or UAV point clouds. Requires normals
|
|
272
|
+
on the input cloud for best results.
|
|
273
|
+
|
|
274
|
+
Parameters
|
|
275
|
+
----------
|
|
276
|
+
cloud : PointCloud
|
|
277
|
+
Input point cloud. Normals improve axis estimation significantly.
|
|
278
|
+
distance_threshold : float, optional
|
|
279
|
+
Maximum distance from a point to the cylinder surface to be an
|
|
280
|
+
inlier, by default 0.02.
|
|
281
|
+
radius_range : tuple[float, float], optional
|
|
282
|
+
``(min_radius, max_radius)`` to accept as a valid cylinder,
|
|
283
|
+
by default ``(0.01, 2.0)``.
|
|
284
|
+
num_iterations : int, optional
|
|
285
|
+
Number of RANSAC iterations, by default 5000.
|
|
286
|
+
min_points : int, optional
|
|
287
|
+
Minimum inliers required for a cylinder to be reported, by default 100.
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
list[CylinderResult]
|
|
292
|
+
Detected cylinders, ordered by decreasing inlier count.
|
|
293
|
+
|
|
294
|
+
Raises
|
|
295
|
+
------
|
|
296
|
+
OcculusValidationError
|
|
297
|
+
If ``radius_range`` is invalid.
|
|
298
|
+
OcculusFeatureError
|
|
299
|
+
If the cloud has too few points.
|
|
300
|
+
"""
|
|
301
|
+
r_min, r_max = radius_range
|
|
302
|
+
if r_min >= r_max or r_min <= 0:
|
|
303
|
+
raise OcculusValidationError(
|
|
304
|
+
f"radius_range must be (min, max) with 0 < min < max, got {radius_range}"
|
|
305
|
+
)
|
|
306
|
+
if cloud.n_points < 6:
|
|
307
|
+
raise OcculusFeatureError(
|
|
308
|
+
f"detect_cylinders requires at least 6 points, got {cloud.n_points}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
xyz = cloud.xyz
|
|
312
|
+
rng = np.random.default_rng()
|
|
313
|
+
cylinders: list[CylinderResult] = []
|
|
314
|
+
best_inliers = 0
|
|
315
|
+
best_result: CylinderResult | None = None
|
|
316
|
+
|
|
317
|
+
for _ in range(num_iterations):
|
|
318
|
+
# Sample 2 points and use normals to estimate axis
|
|
319
|
+
idx = rng.choice(cloud.n_points, size=2, replace=False)
|
|
320
|
+
p1, p2 = xyz[idx[0]], xyz[idx[1]]
|
|
321
|
+
|
|
322
|
+
if cloud.has_normals and cloud.normals is not None:
|
|
323
|
+
n1, n2 = cloud.normals[idx[0]], cloud.normals[idx[1]]
|
|
324
|
+
axis = np.cross(n1, n2)
|
|
325
|
+
else:
|
|
326
|
+
axis = p2 - p1
|
|
327
|
+
|
|
328
|
+
axis_norm = np.linalg.norm(axis)
|
|
329
|
+
if axis_norm < 1e-12:
|
|
330
|
+
continue
|
|
331
|
+
axis = axis / axis_norm
|
|
332
|
+
|
|
333
|
+
# Project points onto plane perpendicular to axis
|
|
334
|
+
proj = xyz - np.outer(xyz @ axis, axis)
|
|
335
|
+
p1_proj = p1 - (p1 @ axis) * axis
|
|
336
|
+
radii = np.linalg.norm(proj - p1_proj, axis=1)
|
|
337
|
+
|
|
338
|
+
# Estimate radius as median distance from p1_proj
|
|
339
|
+
radius_est = float(np.median(radii))
|
|
340
|
+
if not (r_min <= radius_est <= r_max):
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
dists = np.abs(radii - radius_est)
|
|
344
|
+
n_inliers = int((dists <= distance_threshold).sum())
|
|
345
|
+
|
|
346
|
+
if n_inliers > best_inliers:
|
|
347
|
+
best_inliers = n_inliers
|
|
348
|
+
inlier_mask = dists <= distance_threshold
|
|
349
|
+
rmse = float(np.sqrt((dists[inlier_mask] ** 2).mean()))
|
|
350
|
+
best_result = CylinderResult(
|
|
351
|
+
axis_point=p1.copy(),
|
|
352
|
+
axis_direction=axis.copy(),
|
|
353
|
+
radius=radius_est,
|
|
354
|
+
inlier_mask=inlier_mask,
|
|
355
|
+
n_inliers=n_inliers,
|
|
356
|
+
rmse=rmse,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if best_result is not None and best_result.n_inliers >= min_points:
|
|
360
|
+
cylinders.append(best_result)
|
|
361
|
+
|
|
362
|
+
logger.info("detect_cylinders: found %d cylinders", len(cylinders))
|
|
363
|
+
return cylinders
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def compute_geometric_features(
|
|
367
|
+
cloud: PointCloud,
|
|
368
|
+
radius: float,
|
|
369
|
+
*,
|
|
370
|
+
max_nn: int = 30,
|
|
371
|
+
) -> GeometricFeatures:
|
|
372
|
+
"""Compute eigenvalue-based geometric features for each point.
|
|
373
|
+
|
|
374
|
+
For each point, PCA is performed on its local neighbourhood within
|
|
375
|
+
``radius``. The three eigenvalues (λ1 ≥ λ2 ≥ λ3) are used to derive
|
|
376
|
+
seven geometric descriptors.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
cloud : PointCloud
|
|
381
|
+
Input point cloud. Must have at least 3 points.
|
|
382
|
+
radius : float
|
|
383
|
+
Neighbourhood search radius.
|
|
384
|
+
max_nn : int, optional
|
|
385
|
+
Maximum number of neighbours per point for PCA, by default 30.
|
|
386
|
+
|
|
387
|
+
Returns
|
|
388
|
+
-------
|
|
389
|
+
GeometricFeatures
|
|
390
|
+
Seven feature arrays of length ``n_points``.
|
|
391
|
+
|
|
392
|
+
Raises
|
|
393
|
+
------
|
|
394
|
+
OcculusValidationError
|
|
395
|
+
If radius is not positive.
|
|
396
|
+
OcculusFeatureError
|
|
397
|
+
If the cloud has fewer than 3 points.
|
|
398
|
+
"""
|
|
399
|
+
if radius <= 0:
|
|
400
|
+
raise OcculusValidationError(f"radius must be positive, got {radius}")
|
|
401
|
+
if cloud.n_points < 3:
|
|
402
|
+
raise OcculusFeatureError(
|
|
403
|
+
f"compute_geometric_features requires at least 3 points, got {cloud.n_points}"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
xyz = cloud.xyz
|
|
407
|
+
n = len(xyz)
|
|
408
|
+
tree = KDTree(xyz)
|
|
409
|
+
neighbour_lists = tree.query_ball_point(xyz, r=radius, workers=-1)
|
|
410
|
+
|
|
411
|
+
linearity = np.zeros(n)
|
|
412
|
+
planarity = np.zeros(n)
|
|
413
|
+
sphericity = np.zeros(n)
|
|
414
|
+
omnivariance = np.zeros(n)
|
|
415
|
+
anisotropy = np.zeros(n)
|
|
416
|
+
eigenentropy = np.zeros(n)
|
|
417
|
+
curvature = np.zeros(n)
|
|
418
|
+
|
|
419
|
+
for i, neighbours in enumerate(neighbour_lists):
|
|
420
|
+
if len(neighbours) < 3:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
idx = np.array(neighbours)
|
|
424
|
+
if len(idx) > max_nn:
|
|
425
|
+
dists = np.linalg.norm(xyz[idx] - xyz[i], axis=1)
|
|
426
|
+
idx = idx[np.argpartition(dists, max_nn)[:max_nn]]
|
|
427
|
+
|
|
428
|
+
pts = xyz[idx]
|
|
429
|
+
centred = pts - pts.mean(axis=0)
|
|
430
|
+
cov = centred.T @ centred / len(pts)
|
|
431
|
+
eigenvalues = np.linalg.eigvalsh(cov) # ascending order
|
|
432
|
+
|
|
433
|
+
# Ensure non-negative (numerical noise)
|
|
434
|
+
eigenvalues = np.maximum(eigenvalues, 0)
|
|
435
|
+
eigenvalues = np.sort(eigenvalues)[::-1] # descending: λ1 ≥ λ2 ≥ λ3
|
|
436
|
+
l1, l2, l3 = eigenvalues
|
|
437
|
+
lsum = l1 + l2 + l3
|
|
438
|
+
|
|
439
|
+
if l1 < 1e-12:
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
linearity[i] = (l1 - l2) / l1
|
|
443
|
+
planarity[i] = (l2 - l3) / l1
|
|
444
|
+
sphericity[i] = l3 / l1
|
|
445
|
+
omnivariance[i] = (l1 * l2 * l3) ** (1.0 / 3.0) if l1 * l2 * l3 > 0 else 0.0
|
|
446
|
+
anisotropy[i] = (l1 - l3) / l1
|
|
447
|
+
|
|
448
|
+
# Eigenentropy: normalise eigenvalues before log
|
|
449
|
+
if lsum > 1e-12:
|
|
450
|
+
lnorm = eigenvalues / lsum
|
|
451
|
+
lnorm = lnorm[lnorm > 1e-12]
|
|
452
|
+
eigenentropy[i] = float(-np.sum(lnorm * np.log(lnorm)))
|
|
453
|
+
|
|
454
|
+
curvature[i] = l3 / lsum if lsum > 1e-12 else 0.0
|
|
455
|
+
|
|
456
|
+
logger.debug("compute_geometric_features: processed %d points (r=%.4f)", n, radius)
|
|
457
|
+
return GeometricFeatures(
|
|
458
|
+
linearity=linearity,
|
|
459
|
+
planarity=planarity,
|
|
460
|
+
sphericity=sphericity,
|
|
461
|
+
omnivariance=omnivariance,
|
|
462
|
+
anisotropy=anisotropy,
|
|
463
|
+
eigenentropy=eigenentropy,
|
|
464
|
+
curvature=curvature,
|
|
465
|
+
)
|