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 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
+ ]
@@ -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
@@ -0,0 +1,7 @@
1
+ """Package version.
2
+
3
+ Single source of truth for the version string.
4
+ ``pyproject.toml`` reads this via hatch-vcs or static version declaration.
5
+ """
6
+
7
+ __version__ = "1.0.0"
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
+ )