0.1.26
Loading...
Searching...
No Matches
LiDARCloud.py
Go to the documentation of this file.
1"""
2LiDARCloud - High-level interface for LiDAR simulation and point cloud processing
3
4Provides Python interface to Helios LiDAR plugin for:
5- Synthetic LiDAR scanning
6- Point cloud management and filtering
7- Triangulation and mesh generation
8- Leaf area density calculations
9"""
10
11from enum import IntEnum
12from typing import List, Tuple, Optional, Union
13from .wrappers import ULiDARWrapper as lidar_wrapper
14from .Context import Context, check_context_alive
15from .plugins.registry import get_plugin_registry
16from .exceptions import HeliosError
17from .wrappers.DataTypes import vec3, RGBcolor, SphericalCoord
18from .validation.datatypes import validate_vec3
19from .validation.core import validate_positive_value
20
21
23 """Exception raised for LiDAR-specific errors"""
24 pass
25
26
27class ScanPattern(IntEnum):
28 """Geometric beam pattern returned by :meth:`LiDARCloud.getScanPattern`.
29
30 RASTER is the uniform-angular-grid pattern produced by :meth:`LiDARCloud.addScan`;
31 SPINNING_MULTIBEAM is the rotating multi-channel pattern produced by
32 :meth:`LiDARCloud.addScanSpinning` (each row is a laser channel at a fixed zenith angle).
33
34 This is the geometric pattern, orthogonal to :class:`ScanMode` (the acquisition mode). Use
35 :meth:`LiDARCloud.getScanBeamZenithAngles` to read a spinning scan's per-channel angles.
36 """
37 RASTER = 0
38 SPINNING_MULTIBEAM = 1
39 #: Rotating-Risley-prism rosette (Livox-style), produced by :meth:`LiDARCloud.addScanRisley`.
40 #: Stored as a single-row (Ntheta=1) table; the per-pulse direction comes from the prism optics.
41 RISLEY_PRISM = 2
42
43
44class ScanMode(IntEnum):
45 """High-level acquisition mode returned by :meth:`LiDARCloud.getScanMode`.
46
47 STATIC_RASTER is a uniform angular grid from a single fixed origin (terrestrial/tripod);
48 MOVING_RASTER is a fixed angular fan swept along a trajectory (mobile/airborne raster),
49 produced by :meth:`LiDARCloud.addScanMovingRaster`; SPINNING is a continuously-rotating
50 multi-channel sensor, produced by :meth:`LiDARCloud.addScanSpinning`.
51 """
52 STATIC_RASTER = 0
53 MOVING_RASTER = 1
54 SPINNING = 2
55 #: Rotating-Risley-prism rosette sensor (Livox-style; always trajectory-driven), produced by
56 #: :meth:`LiDARCloud.addScanRisley`. A stationary capture is two coincident poses separated in time.
57 RISLEY_PRISM = 3
58
59
60class RisleyPrism:
61 """A single rotating wedge prism in a Risley-prism beam deflector (see :meth:`LiDARCloud.addScanRisley`).
62
63 A pair of such prisms with different (and generally incommensurate) rotation rates traces the
64 characteristic non-repetitive rosette of a Livox sensor. The beam direction is computed by
65 non-paraxial ray tracing through the wedges; the field of view is an emergent property of the
66 wedge angles and refractive indices, not a directly specified parameter.
67
68 Args:
69 wedge_angle: Wedge (inclination) angle of the prism in radians.
70 refractive_index: Refractive index of the prism glass.
71 rotor_rate: Rotation rate about the optical axis in radians/second (the sign sets the rotation
72 direction; a counter-rotating pair traces a rosette).
73 phase: Initial clocking angle of the wedge about the optical axis in radians at scan time t=0.
74 """
75
76 __slots__ = ("wedge_angle", "refractive_index", "rotor_rate", "phase")
77
78 def __init__(self, wedge_angle: float, refractive_index: float,
79 rotor_rate: float, phase: float = 0.0):
80 self.wedge_angle = float(wedge_angle)
81 self.refractive_index = float(refractive_index)
82 self.rotor_rate = float(rotor_rate)
83 self.phase = float(phase)
84
85 def to_list(self) -> List[float]:
86 """Return the prism as a 4-element [wedge_angle, refractive_index, rotor_rate, phase] list."""
87 return [self.wedge_angle, self.refractive_index, self.rotor_rate, self.phase]
88
89 def __repr__(self) -> str:
90 return (f"RisleyPrism(wedge_angle={self.wedge_angle}, refractive_index={self.refractive_index}, "
91 f"rotor_rate={self.rotor_rate}, phase={self.phase})")
92
93 def __eq__(self, other) -> bool:
94 if not isinstance(other, RisleyPrism):
95 return NotImplemented
96 return self.to_list() == other.to_list()
97
98
99class ReturnMode(IntEnum):
100 """Return-reporting mode for analytic-waveform synthetic scans (see
101 :meth:`LiDARCloud.getScanReturnMode`/:meth:`LiDARCloud.setScanReturnMode`).
102
103 MULTI reports every detected return (discrete multi-return, no limit); SINGLE reports at
104 most :meth:`LiDARCloud.getScanMaxReturns` returns per pulse, selected by the scan's
105 :class:`SingleReturnSelection` policy.
106 """
107 MULTI = 0
108 SINGLE = 1
109
110
111class SingleReturnSelection(IntEnum):
112 """Which return(s) a limited-return instrument keeps when a pulse resolves more returns
113 than the return limit (see :meth:`LiDARCloud.setScanSingleReturnSelection`).
114
115 The kept subset is always reported nearest-first.
116 """
117 STRONGEST = 0
118 FIRST = 1
119 LAST = 2
120 #: Dual return: keep the strongest return AND the last (farthest) return of the
121 #: pulse, deduplicated to one when they are the same return. Models the
122 #: "strongest + last" dual-return mode of real discrete-return scanners.
123 #: Intrinsically yields 1 or 2 returns and ignores the per-scan maxReturns.
124 STRONGEST_PLUS_LAST = 3
125
126
127class LiDARCloud:
128 """
129 High-level interface for LiDAR point cloud operations.
130
131 Supports synthetic scanning, point cloud filtering, triangulation,
132 and leaf area density calculations.
133
134 Example:
135 >>> from pyhelios import LiDARCloud
136 >>> from pyhelios.types import vec3
137 >>>
138 >>> with LiDARCloud() as lidar:
139 ... # Add a scan
140 ... scan_id = lidar.addScan(
141 ... origin=vec3(0, 0, 1),
142 ... Ntheta=100, theta_range=(0, 1.57),
143 ... Nphi=100, phi_range=(-3.14, 3.14),
144 ... exit_diameter=0.01, beam_divergence=0.001
145 ... )
146 ...
147 ... # Add hit points
148 ... lidar.addHitPoint(scan_id, vec3(1, 0, 0), vec3(1, 0, 0))
149 ...
150 ... # Export point cloud
151 ... lidar.exportPointCloud("output.xyz")
152 """
153
154 def __init__(self):
155 """
156 Initialize LiDARCloud.
157
158 Raises:
159 LiDARError: If plugin not available in current build
160 RuntimeError: If cloud initialization fails
161 """
162 # Check plugin availability
163 registry = get_plugin_registry()
164 if not registry.is_plugin_available('lidar'):
165 raise LiDARError(
166 "LiDAR plugin not available. Rebuild PyHelios with LiDAR:\n"
167 " build_scripts/build_helios --plugins lidar\n"
168 "\n"
169 "System requirements:\n"
170 " - Platforms: Windows, Linux, macOS\n"
171 " - GPU: Optional (enables GPU acceleration)"
172 )
173
174 self._cloud_ptr = lidar_wrapper.createLiDARcloud()
175 if not self._cloud_ptr:
176 raise LiDARError("Failed to create LiDAR cloud")
177
178 # Keeps the ctypes progress-callback bridge alive while native code holds it (see
179 # setProgressCallback); ctypes does not retain a reference on its own.
181
182 def __enter__(self):
183 """Context manager entry"""
184 return self
185
186 def __exit__(self, exc_type, exc_val, exc_tb):
187 """Context manager exit - cleanup resources"""
188 if hasattr(self, '_cloud_ptr') and self._cloud_ptr:
189 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
190 self._cloud_ptr = None
191
192 def __del__(self):
193 """Fallback destructor for cleanup without context manager"""
194 if hasattr(self, '_cloud_ptr') and self._cloud_ptr is not None:
195 try:
196 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
197 self._cloud_ptr = None
198 except Exception as e:
199 import warnings
200 warnings.warn(f"Error in LiDARCloud.__del__: {e}")
201
202 def addScan(self, origin: Union[vec3, List[float], Tuple[float, float, float]],
203 Ntheta: int, theta_range: Tuple[float, float],
204 Nphi: int, phi_range: Tuple[float, float],
205 exit_diameter: float, beam_divergence: float,
206 column_format: Optional[List[str]] = None,
207 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
208 scan_tilt_roll: float = 0.0, scan_tilt_pitch: float = 0.0,
209 scan_azimuth_offset: float = 0.0) -> int:
210 """
211 Add a LiDAR scan to the point cloud.
212
213 Args:
214 origin: Scanner position (vec3 or 3-element list/tuple)
215 Ntheta: Number of scan points in zenith direction
216 theta_range: Zenith angle range (min, max) in radians
217 Nphi: Number of scan points in azimuthal direction
218 phi_range: Azimuthal angle range (min, max) in radians
219 exit_diameter: Laser beam exit diameter (meters)
220 beam_divergence: Beam divergence angle (radians)
221 column_format: Optional list of column-format labels. Non-standard labels
222 (anything other than geometry/standard tokens like x/y/z/r/g/b/raydir)
223 cause syntheticScan to sample that named primitive data from the struck
224 primitive onto each hit's data map, retrievable via getHitData(). Defaults
225 to None (empty format).
226
227 One label is special: "reflectivity_lidar" modulates each hit's "intensity"
228 (intensity *= reflectivity) rather than being stored as its own hit-data
229 key, so getHitData(i, "reflectivity_lidar") will NOT return it.
230 range_noise_stddev: Standard deviation of Gaussian range (along-beam) measurement
231 noise in meters. Only affects synthetic-scan generation. Defaults to 0.0
232 (noise disabled).
233 angle_noise_stddev: Standard deviation of Gaussian angular (beam-pointing) jitter
234 in radians. Only affects synthetic-scan generation. Defaults to 0.0 (jitter
235 disabled).
236 scan_tilt_roll: Global scanner tilt roll angle in radians, modeling residual tilt of
237 the scanner spin axis away from plumb (right-hand rotation about the body lateral
238 axis). Only affects synthetic-scan generation. Defaults to 0.0 (level).
239 scan_tilt_pitch: Global scanner tilt pitch angle in radians (right-hand rotation about
240 the body forward/azimuth-zero axis). Only affects synthetic-scan generation.
241 Defaults to 0.0 (level).
242 scan_azimuth_offset: Global scanner azimuth (heading) offset in radians, a right-hand
243 rotation about the world +z axis applied on top of the azimuth sweep. Only affects
244 synthetic-scan generation. Defaults to 0.0 (no offset).
245
246 Returns:
247 Scan ID for referencing this scan
248
249 Example:
250 >>> scan_id = lidar.addScan(
251 ... origin=vec3(0, 0, 1),
252 ... Ntheta=100, theta_range=(0, 1.57),
253 ... Nphi=100, phi_range=(-3.14, 3.14),
254 ... exit_diameter=0.01, beam_divergence=0.001,
255 ... column_format=["my_scalar"]
256 ... )
257 """
258 # Convert origin to vec3 if needed
259 if isinstance(origin, (list, tuple)):
260 if len(origin) != 3:
261 raise ValueError("Origin must have 3 elements [x, y, z]")
262 origin = vec3(*origin)
263 elif not hasattr(origin, 'x'):
264 raise ValueError("Origin must be vec3 or 3-element list/tuple")
265
266 origin_list = [origin.x, origin.y, origin.z]
267
268 # Validate scan parameters
269 validate_positive_value(Ntheta, 'Ntheta', 'addScan')
270 validate_positive_value(Nphi, 'Nphi', 'addScan')
271
272 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
273 raise ValueError("theta_range must be a tuple (min, max)")
274 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
275 raise ValueError("phi_range must be a tuple (min, max)")
276
277 if column_format is not None:
278 if not isinstance(column_format, (list, tuple)) or \
279 not all(isinstance(c, str) for c in column_format):
280 raise ValueError("column_format must be a list of strings")
281 column_format = list(column_format)
282
283 if range_noise_stddev < 0:
284 raise ValueError("range_noise_stddev must be non-negative")
285 if angle_noise_stddev < 0:
286 raise ValueError("angle_noise_stddev must be non-negative")
287
288 return lidar_wrapper.addLiDARScan(
289 self._cloud_ptr, origin_list, Ntheta, theta_range,
290 Nphi, phi_range, exit_diameter, beam_divergence, column_format,
291 range_noise_stddev, angle_noise_stddev,
292 scan_tilt_roll, scan_tilt_pitch, scan_azimuth_offset
293 )
294
295 def addScanMoving(self, Ntheta: int, theta_range: Tuple[float, float],
296 Nphi: int, phi_range: Tuple[float, float],
297 exit_diameter: float, beam_divergence: float,
298 traj_t: List[float],
299 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
300 traj_rot: List[List[float]], pulse_rate_hz: float,
301 rot_is_quaternion: bool = True,
302 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
303 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
304 column_format: Optional[List[str]] = None,
305 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
306 t0: float = 0.0) -> int:
307 """
308 Add a moving-platform (mobile/airborne) raster LiDAR scan driven by a 6-DOF pose trajectory.
309
310 Unlike :meth:`addScan`, the scanner pose changes during the sweep. For each pulse the synthetic-scan
311 generator computes its acquisition time ``t = t0 + ordinal / pulse_rate_hz``, interpolates the platform
312 pose at that time (linear position, SLERP orientation), and emits a per-pulse origin
313 ``o = pos + R(q) * lever_arm`` and direction ``d = R(q) * R(boresight) * d_body``. Every resulting hit
314 and miss stores its own origin (hit-data "origin_x"/"origin_y"/"origin_z", retrievable via
315 :meth:`getHitOrigin`), timestamp ("timestamp"), and firing index ("pulse_id").
316
317 The static tilt roll/pitch/azimuth fields are NOT applied in this mode; attitude comes entirely
318 from the trajectory and the boresight misalignment. Because the pulses do not lie on a fixed
319 theta-phi grid they cannot be triangulated, so leaf-area inversion must use
320 :meth:`calculateLeafArea` with an explicit ``Gtheta``.
321
322 Args:
323 Ntheta: Number of scan points in zenith direction (raster grid rows)
324 theta_range: Zenith angle range (min, max) in radians
325 Nphi: Number of scan points in azimuthal direction (raster grid columns)
326 phi_range: Azimuthal angle range (min, max) in radians
327 exit_diameter: Laser beam exit diameter (meters)
328 beam_divergence: Beam divergence angle (radians)
329 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
330 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
331 traj_rot: Platform orientations, one entry per traj_t entry. Each entry is a length-4
332 quaternion (qx, qy, qz, qw, Hamilton body->world) when ``rot_is_quaternion`` is True,
333 otherwise a length-3 roll/pitch/yaw Euler triple in radians (intrinsic Z-Y-X).
334 pulse_rate_hz: Pulse repetition rate in Hz (must be > 0)
335 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
336 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
337 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
338 column_format: Optional list of column-format labels (see addScan)
339 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
340 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
341 t0: Time of the first pulse in seconds (relative time; default 0)
342
343 Returns:
344 Scan ID for referencing this scan
345 """
346 validate_positive_value(Ntheta, 'Ntheta', 'addScanMoving')
347 validate_positive_value(Nphi, 'Nphi', 'addScanMoving')
348
349 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
350 raise ValueError("theta_range must be a tuple (min, max)")
351 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
352 raise ValueError("phi_range must be a tuple (min, max)")
353 if pulse_rate_hz <= 0:
354 raise ValueError("pulse_rate_hz must be greater than 0")
355 if range_noise_stddev < 0:
356 raise ValueError("range_noise_stddev must be non-negative")
357 if angle_noise_stddev < 0:
358 raise ValueError("angle_noise_stddev must be non-negative")
359
360 rot_stride = 4 if rot_is_quaternion else 3
361 _, pos_list, rot_list = self._validate_trajectory(
362 traj_t, traj_pos, traj_rot, rot_stride, 'addScanMoving')
363
364 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
365 else list(lever_arm)) if lever_arm is not None else None
366 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
367 else list(boresight_rpy)) if boresight_rpy is not None else None
368
369 if column_format is not None:
370 if not isinstance(column_format, (list, tuple)) or \
371 not all(isinstance(c, str) for c in column_format):
372 raise ValueError("column_format must be a list of strings")
373 column_format = list(column_format)
374
375 return lidar_wrapper.addLiDARScanMoving(
376 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
377 exit_diameter, beam_divergence,
378 [float(t) for t in traj_t], pos_list, rot_list, bool(rot_is_quaternion),
379 float(pulse_rate_hz), lever_list, boresight_list, column_format,
380 range_noise_stddev, angle_noise_stddev, float(t0)
381 )
382
383 @staticmethod
384 def _validate_trajectory(traj_t, traj_pos, traj_rot, rot_stride, method):
385 """Shared trajectory validation/marshalling for moving/spinning scans.
386
387 Returns (t_list, pos_list, rot_list) of plain Python floats. rot_stride is 4 for
388 quaternions or 3 for Euler triples; pass rot_stride=None to skip rotation validation.
389 """
390 if not isinstance(traj_t, (list, tuple)) or len(traj_t) == 0:
391 raise ValueError("traj_t must be a non-empty list of trajectory sample times")
392 M = len(traj_t)
393 if len(traj_pos) != M:
394 raise ValueError("traj_t and traj_pos must have the same length M")
395 if rot_stride is not None and len(traj_rot) != M:
396 raise ValueError("traj_t and the trajectory orientation list must have the same length M")
397 # Fail fast on a non-monotonic trajectory rather than deferring to a C++ exception.
398 if any(traj_t[i] >= traj_t[i + 1] for i in range(M - 1)):
399 raise ValueError("traj_t must be strictly monotonically increasing")
400
401 def _to_xyz(v, name):
402 if hasattr(v, 'x'):
403 return [v.x, v.y, v.z]
404 if isinstance(v, (list, tuple)) and len(v) == 3:
405 return [float(c) for c in v]
406 raise ValueError(f"{name} must be a vec3 or 3-element list/tuple")
407
408 pos_list = [_to_xyz(p, "Each traj_pos entry") for p in traj_pos]
409
410 rot_list = None
411 if rot_stride is not None:
412 rot_list = []
413 for r in traj_rot:
414 if not isinstance(r, (list, tuple)) or len(r) != rot_stride:
415 label = 'qx,qy,qz,qw' if rot_stride == 4 else 'roll,pitch,yaw'
416 raise ValueError(
417 f"Each trajectory orientation entry must have {rot_stride} elements ({label})"
418 )
419 rot_list.append([float(c) for c in r])
420 return [float(t) for t in traj_t], pos_list, rot_list
421
422 def addScanSpinning(self, beam_elevation_angles: List[float],
423 azimuth_step: float, pulse_rate_hz: float,
424 traj_t: List[float],
425 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
426 traj_rot: List[List[float]],
427 rot_is_quaternion: bool = True,
428 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
429 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
430 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
431 column_format: Optional[List[str]] = None,
432 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
433 t0: float = 0.0) -> int:
434 """
435 Add a continuously-spinning multibeam scan from physical instrument parameters.
436
437 High-level entry point for a rotating multi-channel sensor (Velodyne/Ouster/Hesai) on a moving
438 (or stationary) platform. The azimuth grid, rotation rate, and revolution count are derived
439 internally from the azimuth resolution, PRF, and trajectory duration; you never specify an
440 azimuth range or step count. Sets the scan's :class:`ScanMode` to ``SPINNING``. For a stationary
441 "spin in place" capture (a tripod), supply two coincident poses (same position and orientation)
442 separated in time by the acquisition duration.
443
444 Args:
445 beam_elevation_angles: Per-channel beam ELEVATION angles above the horizon, in radians
446 (NOT zenith — elevation above the horizon, where zenith = pi/2 - elevation; this matches
447 manufacturer spec sheets)
448 azimuth_step: Azimuth angular resolution in radians per firing step (must be > 0)
449 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
450 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
451 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
452 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
453 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
454 Euler triple in radians (intrinsic Z-Y-X).
455 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
456 exit_diameter: Laser beam exit diameter (meters, default 0)
457 beam_divergence: Beam divergence angle (radians, default 0)
458 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
459 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
460 column_format: Optional list of column-format labels (default ["x", "y", "z"])
461 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
462 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
463 t0: Time of the first pulse in seconds (relative time; default 0)
464
465 Returns:
466 Scan ID for referencing this scan
467 """
468 if not isinstance(beam_elevation_angles, (list, tuple)) or len(beam_elevation_angles) == 0:
469 raise ValueError("beam_elevation_angles must be a non-empty list of per-channel angles")
470 if azimuth_step <= 0:
471 raise ValueError("azimuth_step must be greater than 0")
472 if pulse_rate_hz <= 0:
473 raise ValueError("pulse_rate_hz must be greater than 0")
474 if range_noise_stddev < 0:
475 raise ValueError("range_noise_stddev must be non-negative")
476 if angle_noise_stddev < 0:
477 raise ValueError("angle_noise_stddev must be non-negative")
478
479 rot_stride = 4 if rot_is_quaternion else 3
480 t_list, pos_list, rot_list = self._validate_trajectory(
481 traj_t, traj_pos, traj_rot, rot_stride, 'addScanSpinning')
482
483 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
484 else list(lever_arm)) if lever_arm is not None else None
485 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
486 else list(boresight_rpy)) if boresight_rpy is not None else None
487
488 if column_format is not None:
489 if not isinstance(column_format, (list, tuple)) or \
490 not all(isinstance(c, str) for c in column_format):
491 raise ValueError("column_format must be a list of strings")
492 column_format = list(column_format)
493
494 return lidar_wrapper.addLiDARScanSpinning(
495 self._cloud_ptr, [float(a) for a in beam_elevation_angles],
496 float(azimuth_step), float(pulse_rate_hz),
497 t_list, pos_list, rot_list, bool(rot_is_quaternion),
498 exit_diameter, beam_divergence,
499 lever_list, boresight_list, column_format,
500 range_noise_stddev, angle_noise_stddev, float(t0)
501 )
502
503 def addScanMovingRaster(self, Ntheta: int, theta_range: Tuple[float, float],
504 Nphi: int, phi_range: Tuple[float, float],
505 pulse_rate_hz: float,
506 traj_t: List[float],
507 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
508 traj_quat: List[List[float]],
509 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
510 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
511 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
512 column_format: Optional[List[str]] = None,
513 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
514 t0: float = 0.0) -> int:
515 """
516 Add a moving-platform raster scan: a fixed angular fan swept along a quaternion trajectory.
517
518 High-level wrapper around :meth:`addScanMoving` for a non-spinning sensor on a moving platform.
519 Specify the per-frame angular fan resolution plus the trajectory and PRF; Helios derives the
520 per-pulse time sampling along the trajectory. Sets the scan's :class:`ScanMode` to ``MOVING_RASTER``.
521
522 Args:
523 Ntheta: Number of zenith samples in the angular fan
524 theta_range: Zenith angle range (min, max) in radians
525 Nphi: Number of azimuth samples in the angular fan
526 phi_range: Azimuthal angle range (min, max) in radians
527 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
528 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
529 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
530 traj_quat: Platform orientation quaternions (qx, qy, qz, qw, Hamilton body->world), one per
531 traj_t entry
532 exit_diameter: Laser beam exit diameter (meters, default 0)
533 beam_divergence: Beam divergence angle (radians, default 0)
534 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
535 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
536 column_format: Optional list of column-format labels (default ["x", "y", "z"])
537 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
538 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
539 t0: Time of the first pulse in seconds (relative time; default 0)
540
541 Returns:
542 Scan ID for referencing this scan
543 """
544 validate_positive_value(Ntheta, 'Ntheta', 'addScanMovingRaster')
545 validate_positive_value(Nphi, 'Nphi', 'addScanMovingRaster')
546 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
547 raise ValueError("theta_range must be a tuple (min, max)")
548 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
549 raise ValueError("phi_range must be a tuple (min, max)")
550 if pulse_rate_hz <= 0:
551 raise ValueError("pulse_rate_hz must be greater than 0")
552 if range_noise_stddev < 0:
553 raise ValueError("range_noise_stddev must be non-negative")
554 if angle_noise_stddev < 0:
555 raise ValueError("angle_noise_stddev must be non-negative")
556
557 t_list, pos_list, quat_list = self._validate_trajectory(
558 traj_t, traj_pos, traj_quat, 4, 'addScanMovingRaster')
559
560 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
561 else list(lever_arm)) if lever_arm is not None else None
562 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
563 else list(boresight_rpy)) if boresight_rpy is not None else None
564
565 if column_format is not None:
566 if not isinstance(column_format, (list, tuple)) or \
567 not all(isinstance(c, str) for c in column_format):
568 raise ValueError("column_format must be a list of strings")
569 column_format = list(column_format)
570
571 return lidar_wrapper.addLiDARScanMovingRaster(
572 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
573 float(pulse_rate_hz),
574 t_list, pos_list, quat_list,
575 exit_diameter, beam_divergence,
576 lever_list, boresight_list, column_format,
577 range_noise_stddev, angle_noise_stddev, float(t0)
578 )
579
580 def addScanRisley(self, prisms: List[Union['RisleyPrism', List[float], Tuple[float, ...]]],
581 refractive_index_air: float, pulse_rate_hz: float,
582 traj_t: List[float],
583 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
584 traj_rot: List[List[float]],
585 rot_is_quaternion: bool = True,
586 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
587 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
588 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
589 column_format: Optional[List[str]] = None,
590 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
591 t0: float = 0.0) -> int:
592 """
593 Add a rotating-Risley-prism (Livox-style rosette) scan from physical instrument parameters.
594
595 High-level entry point for a Livox rosette-pattern sensor (Mid-40/Mid-70/Avia). A single beam is
596 refracted through a stack of continuously rotating wedge prisms, tracing a non-repetitive rosette
597 that fills a circular field of view. The scan is stored as an Ntheta=1, Nphi=Npulses table, where
598 Npulses = round(pulse_rate_hz * trajectory_duration). Sets the scan's :class:`ScanMode` to
599 ``RISLEY_PRISM`` and :class:`ScanPattern` to ``RISLEY_PRISM``. Like a spinning scan it is always
600 trajectory-driven; a stationary tripod capture is two coincident poses (same position and
601 orientation) separated in time by the acquisition duration.
602
603 Args:
604 prisms: Rotating wedge prisms in beam-traversal order (at least one; a Livox sensor uses two
605 counter-rotating prisms). Each entry is a :class:`RisleyPrism` or a 4-element
606 [wedge_angle, refractive_index, rotor_rate, phase] list/tuple (radians / unitless / rad-per-s / radians).
607 refractive_index_air: Refractive index of the medium surrounding the prisms (typically 1.0)
608 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
609 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
610 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
611 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
612 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
613 Euler triple in radians (intrinsic Z-Y-X).
614 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
615 exit_diameter: Laser beam exit diameter (meters, default 0)
616 beam_divergence: Beam divergence angle (radians, default 0)
617 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
618 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
619 column_format: Optional list of column-format labels (default ["x", "y", "z"])
620 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
621 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
622 t0: Time of the first pulse in seconds (relative time; default 0)
623
624 Returns:
625 Scan ID for referencing this scan
626 """
627 if not isinstance(prisms, (list, tuple)) or len(prisms) == 0:
628 raise ValueError("prisms must be a non-empty list of RisleyPrism or 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
629 prism_lists = []
630 for p in prisms:
631 if isinstance(p, RisleyPrism):
632 prism_lists.append(p.to_list())
633 elif isinstance(p, (list, tuple)) and len(p) == 4:
634 prism_lists.append([float(c) for c in p])
635 else:
636 raise ValueError("Each prism must be a RisleyPrism or a 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
637 if refractive_index_air <= 0:
638 raise ValueError("refractive_index_air must be greater than 0")
639 if pulse_rate_hz <= 0:
640 raise ValueError("pulse_rate_hz must be greater than 0")
641 if range_noise_stddev < 0:
642 raise ValueError("range_noise_stddev must be non-negative")
643 if angle_noise_stddev < 0:
644 raise ValueError("angle_noise_stddev must be non-negative")
645
646 rot_stride = 4 if rot_is_quaternion else 3
647 t_list, pos_list, rot_list = self._validate_trajectory(
648 traj_t, traj_pos, traj_rot, rot_stride, 'addScanRisley')
649
650 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
651 else list(lever_arm)) if lever_arm is not None else None
652 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
653 else list(boresight_rpy)) if boresight_rpy is not None else None
654
655 if column_format is not None:
656 if not isinstance(column_format, (list, tuple)) or \
657 not all(isinstance(c, str) for c in column_format):
658 raise ValueError("column_format must be a list of strings")
659 column_format = list(column_format)
660
661 return lidar_wrapper.addLiDARScanRisley(
662 self._cloud_ptr, prism_lists, float(refractive_index_air), float(pulse_rate_hz),
663 t_list, pos_list, rot_list, bool(rot_is_quaternion),
664 exit_diameter, beam_divergence,
665 lever_list, boresight_list, column_format,
666 range_noise_stddev, angle_noise_stddev, float(t0)
667 )
668
669 def getScanCount(self) -> int:
670 """Get total number of scans in the cloud"""
671 return lidar_wrapper.getLiDARScanCount(self._cloud_ptr)
673 def getScanOrigin(self, scanID: int) -> vec3:
674 """Get origin of a specific scan"""
675 if scanID < 0:
676 raise ValueError("Scan ID must be non-negative")
677 origin_list = lidar_wrapper.getLiDARScanOrigin(self._cloud_ptr, scanID)
678 return vec3(*origin_list)
679
680 def getScanSizeTheta(self, scanID: int) -> int:
681 """Get number of zenith scan points for a scan"""
682 if scanID < 0:
683 raise ValueError("Scan ID must be non-negative")
684 return lidar_wrapper.getLiDARScanSizeTheta(self._cloud_ptr, scanID)
685
686 def getScanSizePhi(self, scanID: int) -> int:
687 """Get number of azimuthal scan points for a scan"""
688 if scanID < 0:
689 raise ValueError("Scan ID must be non-negative")
690 return lidar_wrapper.getLiDARScanSizePhi(self._cloud_ptr, scanID)
691
692 def getScanRangeNoiseStdDev(self, scanID: int) -> float:
693 """Get the range (along-beam) measurement noise standard deviation for a scan (meters).
694
695 Returns the value supplied to addScan() as ``range_noise_stddev`` (0.0 if disabled).
696 """
697 if scanID < 0:
698 raise ValueError("Scan ID must be non-negative")
699 return lidar_wrapper.getLiDARScanRangeNoiseStdDev(self._cloud_ptr, scanID)
700
701 def getScanAngleNoiseStdDev(self, scanID: int) -> float:
702 """Get the angular (beam-pointing) jitter standard deviation for a scan (radians).
703
704 Returns the value supplied to addScan() as ``angle_noise_stddev`` (0.0 if disabled).
705 """
706 if scanID < 0:
707 raise ValueError("Scan ID must be non-negative")
708 return lidar_wrapper.getLiDARScanAngleNoiseStdDev(self._cloud_ptr, scanID)
709
710 def getScanTiltRoll(self, scanID: int) -> float:
711 """Get the global scanner tilt roll angle for a scan (radians; 0.0 if level)."""
712 if scanID < 0:
713 raise ValueError("Scan ID must be non-negative")
714 return lidar_wrapper.getLiDARScanTiltRoll(self._cloud_ptr, scanID)
715
716 def getScanTiltPitch(self, scanID: int) -> float:
717 """Get the global scanner tilt pitch angle for a scan (radians; 0.0 if level)."""
718 if scanID < 0:
719 raise ValueError("Scan ID must be non-negative")
720 return lidar_wrapper.getLiDARScanTiltPitch(self._cloud_ptr, scanID)
721
722 def getScanAzimuthOffset(self, scanID: int) -> float:
723 """Get the global scanner azimuth (heading) offset for a scan (radians; 0.0 if none)."""
724 if scanID < 0:
725 raise ValueError("Scan ID must be non-negative")
726 return lidar_wrapper.getLiDARScanAzimuthOffset(self._cloud_ptr, scanID)
727
728 def getScanPattern(self, scanID: int) -> int:
729 """Get the scan pattern for a scan.
730
731 Returns an integer: 0 = raster (uniform angular grid), 1 = spinning multibeam
732 (rotating multi-channel sensor), 2 = Risley-prism (Livox-style rosette). Compare against
733 ``ScanPattern.RASTER`` / ``ScanPattern.SPINNING_MULTIBEAM`` / ``ScanPattern.RISLEY_PRISM``.
734 """
735 if scanID < 0:
736 raise ValueError("Scan ID must be non-negative")
737 return lidar_wrapper.getLiDARScanPattern(self._cloud_ptr, scanID)
738
739 def getScanBeamZenithAngles(self, scanID: int) -> List[float]:
740 """Get the per-channel beam zenith angles (radians) for a multibeam scan.
741
742 Returns an empty list for a raster scan.
743 """
744 if scanID < 0:
745 raise ValueError("Scan ID must be non-negative")
746 return lidar_wrapper.getLiDARScanBeamZenithAngles(self._cloud_ptr, scanID)
747
748 def getScanMode(self, scanID: int) -> ScanMode:
749 """Get the high-level acquisition mode of a scan as a :class:`ScanMode`.
750
751 STATIC_RASTER (fixed-origin grid), MOVING_RASTER (fan swept along a trajectory),
752 SPINNING (continuously-rotating multi-channel sensor), or RISLEY_PRISM (Livox-style rosette).
753 """
754 if scanID < 0:
755 raise ValueError("Scan ID must be non-negative")
756 return ScanMode(lidar_wrapper.getLiDARScanMode(self._cloud_ptr, scanID))
757
758 def getScanStepsPerRev(self, scanID: int) -> int:
759 """Get the number of azimuth firing steps per revolution (spinning scans; 0 otherwise)."""
760 if scanID < 0:
761 raise ValueError("Scan ID must be non-negative")
762 return lidar_wrapper.getLiDARScanStepsPerRev(self._cloud_ptr, scanID)
763
764 def getScanRotationRate(self, scanID: int) -> float:
765 """Get the sensor-head rotation rate in revolutions/second (spinning scans; 0 otherwise)."""
766 if scanID < 0:
767 raise ValueError("Scan ID must be non-negative")
768 return lidar_wrapper.getLiDARScanRotationRate(self._cloud_ptr, scanID)
769
770 def getScanRevolutions(self, scanID: int) -> float:
771 """Get the number of revolutions the sensor head made (spinning scans; 0 otherwise)."""
772 if scanID < 0:
773 raise ValueError("Scan ID must be non-negative")
774 return lidar_wrapper.getLiDARScanRevolutions(self._cloud_ptr, scanID)
775
776 def getScanRisleyPrisms(self, scanID: int) -> List[RisleyPrism]:
777 """Get the rotating wedge prisms of a Risley-prism scan as a list of :class:`RisleyPrism`.
778
779 Returns the prism stack in beam-traversal order (empty for non-Risley scans).
780 """
781 if scanID < 0:
782 raise ValueError("Scan ID must be non-negative")
783 raw = lidar_wrapper.getLiDARScanRisleyPrisms(self._cloud_ptr, scanID)
784 return [RisleyPrism(p[0], p[1], p[2], p[3]) for p in raw]
785
786 def getScanRisleyRefractiveIndexAir(self, scanID: int) -> float:
787 """Get the refractive index of the medium surrounding a Risley scan's prisms (1.0 for non-Risley)."""
788 if scanID < 0:
789 raise ValueError("Scan ID must be non-negative")
790 return lidar_wrapper.getLiDARScanRisleyRefractiveIndexAir(self._cloud_ptr, scanID)
791
792 def getScanReturnMode(self, scanID: int) -> ReturnMode:
793 """Get the return-reporting mode of a scan as a :class:`ReturnMode` (MULTI or SINGLE)."""
794 if scanID < 0:
795 raise ValueError("Scan ID must be non-negative")
796 return ReturnMode(lidar_wrapper.getLiDARScanReturnMode(self._cloud_ptr, scanID))
797
798 def setScanReturnMode(self, scanID: int, return_mode: Union[ReturnMode, int]):
799 """Set the return-reporting mode of a scan (ReturnMode.MULTI or ReturnMode.SINGLE).
800
801 Only affects analytic-waveform synthetic scans (more than one ray per pulse).
802 """
803 if scanID < 0:
804 raise ValueError("Scan ID must be non-negative")
805 lidar_wrapper.setLiDARScanReturnMode(self._cloud_ptr, scanID, int(return_mode))
806
807 def getScanSingleReturnSelection(self, scanID: int) -> SingleReturnSelection:
808 """Get the single/limited-return selection policy as a :class:`SingleReturnSelection`."""
809 if scanID < 0:
810 raise ValueError("Scan ID must be non-negative")
811 return SingleReturnSelection(
812 lidar_wrapper.getLiDARScanSingleReturnSelection(self._cloud_ptr, scanID))
813
814 def setScanSingleReturnSelection(self, scanID: int, selection: Union[SingleReturnSelection, int]):
815 """Set the single/limited-return selection policy (STRONGEST, FIRST, LAST, or STRONGEST_PLUS_LAST).
816
817 Used when the scan's return mode is SINGLE and a pulse resolves more returns than maxReturns.
818 STRONGEST_PLUS_LAST is a dual-return mode that intrinsically yields 1 or 2 returns and
819 ignores maxReturns.
820 """
821 if scanID < 0:
822 raise ValueError("Scan ID must be non-negative")
823 lidar_wrapper.setLiDARScanSingleReturnSelection(self._cloud_ptr, scanID, int(selection))
824
825 def getScanMaxReturns(self, scanID: int) -> int:
826 """Get the maximum returns per pulse used in single/limited-return mode (1 = single, N = N-return)."""
827 if scanID < 0:
828 raise ValueError("Scan ID must be non-negative")
829 return lidar_wrapper.getLiDARScanMaxReturns(self._cloud_ptr, scanID)
830
831 def setScanMaxReturns(self, scanID: int, max_returns: int):
832 """Set the maximum returns per pulse used in single/limited-return mode (must be >= 1)."""
833 if scanID < 0:
834 raise ValueError("Scan ID must be non-negative")
835 if max_returns < 1:
836 raise ValueError("max_returns must be >= 1")
837 lidar_wrapper.setLiDARScanMaxReturns(self._cloud_ptr, scanID, int(max_returns))
838
839 def setSyntheticScanMemoryBudget(self, bytes: int):
840 """Set the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
841
842 :meth:`syntheticScan` fans each pulse into ``rays_per_pulse`` sub-rays; for a
843 large scan the simultaneously-traced sub-rays can demand many gigabytes if
844 traced in one batch. This caps the live trace scratch buffers, so the per-scan
845 beam fan-out is processed in chunks sized to stay near this budget regardless of
846 scan resolution. It bounds only the transient buffers, not the output cloud.
847
848 If never called, the budget is automatic and path-dependent (8 GiB on a GPU
849 build, 4 GiB otherwise). Call this to override that with a fixed cap, typically
850 to lower peak memory on a constrained host.
851
852 Args:
853 bytes: Soft cap in bytes on the live ray-tracing scratch buffers. Must be > 0.
854 """
855 if bytes <= 0:
856 raise ValueError("memory budget must be greater than zero")
857 lidar_wrapper.setLiDARSyntheticScanMemoryBudget(self._cloud_ptr, int(bytes))
858
859 def getSyntheticScanMemoryBudget(self) -> int:
860 """Get the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
861
862 Returns the explicitly configured budget set via :meth:`setSyntheticScanMemoryBudget`, or
863 0 if using the automatic path-dependent default (8 GiB on a GPU build, 4 GiB otherwise).
864 """
865 return lidar_wrapper.getLiDARSyntheticScanMemoryBudget(self._cloud_ptr)
867 def getScanPulseWidth(self, scanID: int) -> float:
868 """Get the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
869 if scanID < 0:
870 raise ValueError("Scan ID must be non-negative")
871 return lidar_wrapper.getLiDARScanPulseWidth(self._cloud_ptr, scanID)
872
873 def setScanPulseWidth(self, scanID: int, pulse_width: float):
874 """Set the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
875 if scanID < 0:
876 raise ValueError("Scan ID must be non-negative")
877 if pulse_width < 0:
878 raise ValueError("pulse_width must be non-negative")
879 lidar_wrapper.setLiDARScanPulseWidth(self._cloud_ptr, scanID, float(pulse_width))
880
881 def getScanDetectionThreshold(self, scanID: int) -> float:
882 """Get the detection threshold (energy fraction, noise floor) of a scan."""
883 if scanID < 0:
884 raise ValueError("Scan ID must be non-negative")
885 return lidar_wrapper.getLiDARScanDetectionThreshold(self._cloud_ptr, scanID)
886
887 def setScanDetectionThreshold(self, scanID: int, detection_threshold: float):
888 """Set the detection threshold (energy fraction, noise floor) of a scan."""
889 if scanID < 0:
890 raise ValueError("Scan ID must be non-negative")
891 if detection_threshold < 0:
892 raise ValueError("detection_threshold must be non-negative")
893 lidar_wrapper.setLiDARScanDetectionThreshold(self._cloud_ptr, scanID, float(detection_threshold))
894
895 def addHitPoint(self, scanID: int,
896 xyz: Union[vec3, List[float], Tuple[float, float, float]],
897 direction: Union[vec3, SphericalCoord, List[float], Tuple[float, float]],
898 color: Optional[Union[RGBcolor, List[float], Tuple[float, float, float]]] = None):
899 """
900 Add a hit point to the point cloud.
901
902 Args:
903 scanID: Scan ID this hit belongs to
904 xyz: Hit point coordinates (vec3 or 3-element list)
905 direction: Ray direction (vec3/SphericalCoord or 2-3 element list)
906 color: Optional RGB color (RGBcolor or 3-element list)
907 """
908 # Convert xyz to list
909 if isinstance(xyz, (list, tuple)):
910 if len(xyz) != 3:
911 raise ValueError("XYZ must have 3 elements")
912 xyz_list = list(xyz)
913 elif hasattr(xyz, 'x'):
914 xyz_list = [xyz.x, xyz.y, xyz.z]
915 else:
916 raise ValueError("XYZ must be vec3 or 3-element list/tuple")
917
918 # Convert direction to list
919 if isinstance(direction, (list, tuple)):
920 if len(direction) < 2:
921 raise ValueError("Direction must have at least 2 elements [radius, elevation]")
922 direction_list = list(direction)
923 elif hasattr(direction, 'radius'): # SphericalCoord
924 direction_list = [direction.radius, direction.elevation, direction.azimuth]
925 elif hasattr(direction, 'x'): # vec3
926 direction_list = [direction.x, direction.y, direction.z]
927 else:
928 raise ValueError("Direction must be vec3/SphericalCoord or 2-3 element list")
929
930 # Add with or without color
931 if color is not None:
932 if isinstance(color, (list, tuple)):
933 if len(color) != 3:
934 raise ValueError("Color must have 3 elements [r, g, b]")
935 color_list = list(color)
936 elif hasattr(color, 'r'):
937 color_list = [color.r, color.g, color.b]
938 else:
939 raise ValueError("Color must be RGBcolor or 3-element list")
940
941 lidar_wrapper.addLiDARHitPointRGB(self._cloud_ptr, scanID, xyz_list, direction_list, color_list)
942 else:
943 lidar_wrapper.addLiDARHitPoint(self._cloud_ptr, scanID, xyz_list, direction_list)
944
945 def addHitPoints(self, scanID: int, xyz_array, direction_array, color_array=None):
946 """
947 Add many hit points to the point cloud in a single bulk call.
948
949 This skips the per-point Python loop by passing contiguous buffers
950 straight to the native library in one FFI call.
951
952 Args:
953 scanID: Scan ID these hits belong to
954 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
955 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth]
956 (azimuth is currently ignored, matching addHitPoint)
957 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
958 """
959 import numpy as np
961 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
962 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
963
964 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
965 raise ValueError("xyz_array must have shape (N, 3)")
966 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
967 raise ValueError("direction_array must have shape (N, 3)")
968
969 count = xyz_array.shape[0]
970 if direction_array.shape[0] != count:
971 raise ValueError("xyz_array and direction_array must have the same number of rows")
972
973 if color_array is not None:
974 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
975 if color_array.ndim != 2 or color_array.shape[1] != 3:
976 raise ValueError("color_array must have shape (N, 3)")
977 if color_array.shape[0] != count:
978 raise ValueError("color_array must have the same number of rows as xyz_array")
979
980 lidar_wrapper.addLiDARHitPoints(self._cloud_ptr, scanID,
981 xyz_array, direction_array, count, color_array)
982
983 def addHitPointsWithData(self, scanID: int, xyz_array, direction_array,
984 data_labels=None, data_values=None, color_array=None):
985 """
986 Add many hit points carrying a per-hit data map in a single bulk call.
987
988 Like addHitPoints, but also populates each hit's named-scalar data map —
989 the in-memory equivalent of what the ASCII loader does for non-standard
990 columns. This is the path multi-return LAD needs (timestamp/target_index/
991 target_count land in the map so gapfillMisses() can group beams by pulse).
992
993 Args:
994 scanID: Scan ID these hits belong to (the scan must already exist)
995 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
996 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth].
997 Pass cart2sphere(xyz - origin) to match loadASCIIFile;
998 the full SphericalCoord (incl. radius) is used.
999 data_labels: Optional list of data-map key names (length k)
1000 data_values: Optional (N, k) values for those keys (float64)
1001 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
1002 """
1003 import numpy as np
1004
1005 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
1006 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
1007
1008 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
1009 raise ValueError("xyz_array must have shape (N, 3)")
1010 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
1011 raise ValueError("direction_array must have shape (N, 3)")
1012
1013 count = xyz_array.shape[0]
1014 if direction_array.shape[0] != count:
1015 raise ValueError("xyz_array and direction_array must have the same number of rows")
1016
1017 labels = list(data_labels or [])
1018 if labels:
1019 data_values = np.ascontiguousarray(data_values, dtype=np.float64)
1020 if data_values.ndim != 2 or data_values.shape != (count, len(labels)):
1021 raise ValueError("data_values must have shape (N, len(data_labels))")
1022 else:
1023 data_values = None
1024
1025 if color_array is not None:
1026 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
1027 if color_array.ndim != 2 or color_array.shape[1] != 3:
1028 raise ValueError("color_array must have shape (N, 3)")
1029 if color_array.shape[0] != count:
1030 raise ValueError("color_array must have the same number of rows as xyz_array")
1031
1032 lidar_wrapper.addLiDARHitPointsWithData(
1033 self._cloud_ptr, scanID, xyz_array, direction_array, count,
1034 color_array, labels, data_values)
1035
1036 def getHitCount(self) -> int:
1037 """Get total number of hit points in cloud"""
1038 return lidar_wrapper.getLiDARHitCount(self._cloud_ptr)
1040 def getHitXYZ(self, index: int) -> vec3:
1041 """Get coordinates of a hit point"""
1042 if index < 0:
1043 raise ValueError("Index must be non-negative")
1044 xyz_list = lidar_wrapper.getLiDARHitXYZ(self._cloud_ptr, index)
1045 return vec3(*xyz_list)
1046
1047 def getHitOrigin(self, index: int) -> vec3:
1048 """Get the (x,y,z) beam-emission origin of a hit point.
1049
1050 For moving-platform scans (see :meth:`addScanMoving`) this is the per-pulse emission origin
1051 recorded on the hit; for static scans it falls back to the single scan origin of the hit's scan.
1052 """
1053 if index < 0:
1054 raise ValueError("Index must be non-negative")
1055 xyz_list = lidar_wrapper.getLiDARHitOrigin(self._cloud_ptr, index)
1056 return vec3(*xyz_list)
1057
1058 def getHitRaydir(self, index: int) -> SphericalCoord:
1059 """Get ray direction of a hit point"""
1060 if index < 0:
1061 raise ValueError("Index must be non-negative")
1062 direction_list = lidar_wrapper.getLiDARHitRaydir(self._cloud_ptr, index)
1063 # direction_list is [radius, elevation, azimuth]; preserve azimuth (was previously dropped).
1064 return SphericalCoord(direction_list[0], direction_list[1], direction_list[2])
1065
1066 def getHitColor(self, index: int) -> RGBcolor:
1067 """Get color of a hit point"""
1068 if index < 0:
1069 raise ValueError("Index must be non-negative")
1070 color_list = lidar_wrapper.getLiDARHitColor(self._cloud_ptr, index)
1071 return RGBcolor(*color_list)
1072
1073 def getHitScanID(self, index: int) -> int:
1074 """Get the scan ID a hit point belongs to"""
1075 if index < 0:
1076 raise ValueError("Index must be non-negative")
1077 return lidar_wrapper.getLiDARHitScanID(self._cloud_ptr, index)
1078
1079 def doesHitDataExist(self, index: int, label: str) -> bool:
1080 """Check whether a named scalar data value exists for a hit point.
1081
1082 Per-hit data computed by syntheticScan includes 'intensity', 'distance',
1083 'timestamp', 'target_index', 'target_count', 'deviation', 'nRaysHit', plus any
1084 primitive-data labels listed in the scan's column_format.
1085 """
1086 if index < 0:
1087 raise ValueError("Index must be non-negative")
1088 return lidar_wrapper.doesLiDARHitDataExist(self._cloud_ptr, index, label)
1089
1090 def getHitData(self, index: int, label: str) -> float:
1091 """Get a named scalar data value for a hit point.
1092
1093 Raises HeliosError if the label does not exist for this hit; guard with
1094 doesHitDataExist() when unsure.
1095 """
1096 if index < 0:
1097 raise ValueError("Index must be non-negative")
1098 return lidar_wrapper.getLiDARHitData(self._cloud_ptr, index, label)
1099
1100 def getHitDataAll(self, label: str) -> List[float]:
1101 """Bulk-export a named scalar data value for all hits in a single FFI call.
1102
1103 Returns a list of length getHitCount(); entries are NaN where the label is
1104 absent for that hit. Much faster than looping getHitData() for large clouds.
1105
1106 Note: values are returned at float32 precision (vs. getHitData(), which returns
1107 full float64). Use getHitData() per-hit if full precision is required.
1108 """
1109 n = self.getHitCount()
1110 if n == 0:
1111 return []
1112 return lidar_wrapper.getLiDARHitData_all(self._cloud_ptr, label, n)
1113
1114 def getHitsXYZRGB(self) -> Tuple[List[vec3], List[RGBcolor]]:
1115 """Bulk-export coordinates and colors for all hits in a single FFI call.
1116
1117 Returns (positions, colors) where positions is a list of vec3 and colors a list
1118 of RGBcolor, each of length getHitCount(). Much faster than looping
1119 getHitXYZ()/getHitColor() for large clouds.
1120 """
1121 n = self.getHitCount()
1122 if n == 0:
1123 return [], []
1124 xyz_flat, rgb_flat = lidar_wrapper.getLiDARHitsXYZRGB_all(self._cloud_ptr, n)
1125 positions = [vec3(xyz_flat[3 * i], xyz_flat[3 * i + 1], xyz_flat[3 * i + 2]) for i in range(n)]
1126 colors = [RGBcolor(rgb_flat[3 * i], rgb_flat[3 * i + 1], rgb_flat[3 * i + 2]) for i in range(n)]
1127 return positions, colors
1128
1129 # ---- Bulk numpy exports (single FFI call each; no per-hit Python loop) ----
1130 # These power the synthetic-scan fast path: extracting a million-hit cloud via
1131 # the per-index getters (getHitXYZ/getHitColor/getHitScanID/...) costs tens of
1132 # millions of FFI crossings, which dominated scan time. The *Array methods pull
1133 # each quantity in one contiguous copy.
1134
1135 def getHitsXYZRGBArrays(self):
1136 """Bulk-export hit coordinates + colors as numpy arrays.
1137
1138 Returns (xyz, rgb), each (getHitCount(), 3) float32. Empty (0,3) arrays
1139 when there are no hits.
1140 """
1141 import numpy as np
1142 n = self.getHitCount()
1143 if n == 0:
1144 return np.empty((0, 3), np.float32), np.empty((0, 3), np.float32)
1145 return lidar_wrapper.getLiDARHitsXYZRGB_all_np(self._cloud_ptr, n)
1146
1147 def getHitDataArray(self, label: str):
1148 """Bulk-export a named scalar field as an (getHitCount(),) float32 array,
1149 NaN where the label is absent for a hit."""
1150 import numpy as np
1151 n = self.getHitCount()
1152 if n == 0:
1153 return np.empty((0,), np.float32)
1154 return lidar_wrapper.getLiDARHitData_all_np(self._cloud_ptr, label, n)
1155
1156 def getHitDataColumn(self, label: str, absent_value: float = -9999.0) -> List[float]:
1157 """Bulk-export a named scalar column via the native cache-linear columnar path.
1158
1159 Faster than :meth:`getHitDataAll` for whole-field reads (a single cache-linear pass over
1160 the contiguous native column rather than per-hit tree lookups), and returns full float64
1161 precision. Entries are ``absent_value`` where the label is absent for a hit. Returns a list
1162 of length getHitCount().
1163 """
1164 n = self.getHitCount()
1165 if n == 0:
1166 return []
1167 return lidar_wrapper.getLiDARHitDataColumn(self._cloud_ptr, label, n, absent_value)
1168
1169 def getHitDataColumnIndex(self, label: str) -> int:
1170 """Get the internal column slot index for a hit-data label.
1171
1172 Per-hit scalar data is stored column-wise; this resolves a label to its column slot for
1173 repeated bulk access without re-resolving the label by string. Returns -1 if the label has
1174 never been set on any hit.
1175 """
1176 if not isinstance(label, str):
1177 raise TypeError(f"label must be a str, got {type(label).__name__}")
1178 return lidar_wrapper.getLiDARHitDataColumnIndex(self._cloud_ptr, label)
1179
1180 def getHitDataColumnArray(self, label: str, absent_value: float = -9999.0):
1181 """Bulk-export a named scalar column as an (getHitCount(),) float64 numpy array
1182 via the columnar path (``absent_value`` where the label is absent for a hit)."""
1183 import numpy as np
1184 n = self.getHitCount()
1185 if n == 0:
1186 return np.empty((0,), np.float64)
1187 return lidar_wrapper.getLiDARHitDataColumn_np(self._cloud_ptr, label, n, absent_value)
1188
1189 def getHitScanIDArray(self):
1190 """Bulk-export the scan ID of every hit as an (getHitCount(),) int32 array."""
1191 import numpy as np
1192 n = self.getHitCount()
1193 if n == 0:
1194 return np.empty((0,), np.int32)
1195 return lidar_wrapper.getLiDARHitScanID_all(self._cloud_ptr, n)
1196
1197 def getHitMissArray(self):
1198 """Bulk-export the miss flag of every hit as an (getHitCount(),) int32
1199 array (1 == sky/miss, 0 == real surface return)."""
1200 import numpy as np
1201 n = self.getHitCount()
1202 if n == 0:
1203 return np.empty((0,), np.int32)
1204 return lidar_wrapper.isLiDARHitMiss_all(self._cloud_ptr, n)
1205
1206 def deleteHitPoint(self, index: int):
1207 """Delete a hit point from the cloud"""
1208 if index < 0:
1209 raise ValueError("Index must be non-negative")
1210 lidar_wrapper.deleteLiDARHitPoint(self._cloud_ptr, index)
1211
1212 def isHitMiss(self, index: int) -> bool:
1213 """Return True if a hit is a "miss" (a fired pulse that returned nothing).
1214
1215 Misses are the transmitted beams that form the denominator of the per-voxel
1216 transmission probability used by :meth:`calculateLeafArea`. They are produced by
1217 ``syntheticScan(..., record_misses=True)`` and by :meth:`gapfillMisses`.
1218 """
1219 if index < 0:
1220 raise ValueError("Index must be non-negative")
1221 return lidar_wrapper.isLiDARHitMiss(self._cloud_ptr, index)
1222
1223 def hasMisses(self) -> bool:
1224 """Return True if the cloud contains at least one miss.
1225
1226 :meth:`calculateLeafArea` requires misses and fails fast without them.
1227 """
1228 return lidar_wrapper.lidarHasMisses(self._cloud_ptr)
1230 @staticmethod
1231 def getMissDistance() -> float:
1232 """Return the LIDAR_MISS_DISTANCE constant (meters): the distance at which a
1233 miss point is placed along its beam."""
1234 return lidar_wrapper.getLiDARMissDistance()
1236 def coordinateShift(self, shift: Union[vec3, List[float], Tuple[float, float, float]]):
1237 """
1238 Translate all hit points by a shift vector.
1239
1240 Args:
1241 shift: Translation vector (vec3 or 3-element list)
1242 """
1243 if isinstance(shift, (list, tuple)):
1244 if len(shift) != 3:
1245 raise ValueError("Shift must have 3 elements [x, y, z]")
1246 shift_list = list(shift)
1247 elif hasattr(shift, 'x'):
1248 shift_list = [shift.x, shift.y, shift.z]
1249 else:
1250 raise ValueError("Shift must be vec3 or 3-element list/tuple")
1251
1252 lidar_wrapper.lidarCoordinateShift(self._cloud_ptr, shift_list)
1253
1254 def coordinateRotation(self, rotation: Union[SphericalCoord, List[float], Tuple[float, float]]):
1255 """
1256 Rotate all hit points by spherical rotation angles.
1257
1258 Args:
1259 rotation: Rotation angles (SphericalCoord or 2-3 element list)
1260 """
1261 if isinstance(rotation, (list, tuple)):
1262 if len(rotation) < 2:
1263 raise ValueError("Rotation must have at least 2 elements [radius, elevation]")
1264 rotation_list = list(rotation)
1265 elif hasattr(rotation, 'radius'):
1266 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1267 else:
1268 raise ValueError("Rotation must be SphericalCoord or 2-3 element list")
1269
1270 lidar_wrapper.lidarCoordinateRotation(self._cloud_ptr, rotation_list)
1271
1272 def triangulateHitPoints(self, Lmax: float, max_aspect_ratio: float = 4.0):
1273 """
1274 Generate triangle mesh from hit points using Delaunay triangulation.
1275
1276 Args:
1277 Lmax: Maximum triangle edge length
1278 max_aspect_ratio: Maximum triangle aspect ratio (default 4.0)
1279 """
1280 validate_positive_value(Lmax, 'Lmax', 'triangulateHitPoints')
1281 validate_positive_value(max_aspect_ratio, 'max_aspect_ratio', 'triangulateHitPoints')
1282 lidar_wrapper.lidarTriangulateHitPoints(self._cloud_ptr, Lmax, max_aspect_ratio)
1283
1284 def getTriangleCount(self) -> int:
1285 """Get number of triangles in the mesh"""
1286 return lidar_wrapper.getLiDARTriangleCount(self._cloud_ptr)
1288 def getTriangulationStats(self) -> dict:
1289 """Filter diagnostics from the most recent triangulateHitPoints() call.
1290
1291 Returns a dict::
1292
1293 {"candidates", "dropped_lmax", "dropped_aspect", "dropped_degenerate"}
1294
1295 Each dropped triangle is attributed to one primary reason (Lmax, then
1296 aspect, then degenerate), so ``candidates == getTriangleCount() +
1297 dropped_lmax + dropped_aspect + dropped_degenerate``. All zero if
1298 triangulation has not been run. Use this to tell whether an empty or
1299 sparse mesh is data-limited (few candidates) or filter-limited (many
1300 candidates dropped by Lmax/aspect).
1301 """
1302 return lidar_wrapper.getLiDARTriangulationStats(self._cloud_ptr)
1304 def getTriangleVerticesAll(self):
1305 """Bulk-export every triangle's vertices and source scan in one call.
1306
1307 Returns (xyz_flat, scan_ids): xyz_flat is a (T*9,) float32 array laid out
1308 [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per triangle, scan_ids is a (T,)
1309 int32 array. Avoids the Context round-trip and the per-triangle
1310 getPrimitiveVertices loop.
1311 """
1312 return lidar_wrapper.getLiDARTriangleVertices_all(
1313 self._cloud_ptr, self.getTriangleCount())
1314
1315 def setExternalTriangulation(self, vertices, scan_ids):
1316 """Replace the internal triangulation with an externally-supplied mesh.
1317
1318 Bypasses the internal Delaunay triangulation so a mesh produced elsewhere
1319 (a re-used Helios triangulation, or a per-scan open3d Ball-Pivot mesh) can
1320 drive leaf-area inversion without a recompute. After this call,
1321 ``calculateLeafArea()`` runs unchanged.
1322
1323 Args:
1324 vertices: Triangle vertices in world coordinates, accepted as a
1325 (T, 9) array laid out [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per
1326 triangle, a (T, 3, 3) array, or a flat (T*9,) array -- the same
1327 layout :meth:`getTriangleVerticesAll` exports, so a Helios mesh
1328 round-trips directly.
1329 scan_ids: Source scan index for each triangle, shape (T,). Required;
1330 every entry must be a valid scan index (see :meth:`addScan`),
1331 since the leaf-angle G(theta) term needs each triangle's ray
1332 direction. A merged mesh with no scan association is not valid.
1333
1334 A grid must already be defined (see :meth:`addGrid`).
1335 """
1336 import numpy as np
1337 verts = np.ascontiguousarray(vertices, dtype=np.float32).reshape(-1)
1338 if verts.size % 9 != 0:
1339 raise ValueError(
1340 f"vertices has {verts.size} floats, must be a multiple of 9 (9 per triangle)")
1341 tri_count = verts.size // 9
1342
1343 scans = np.ascontiguousarray(scan_ids, dtype=np.int32).reshape(-1)
1344 if scans.size != tri_count:
1345 raise ValueError(
1346 f"scan_ids has {scans.size} entries, expected {tri_count} (one per triangle)")
1347
1348 lidar_wrapper.lidarSetExternalTriangulation(
1349 self._cloud_ptr, verts, scans, tri_count)
1350
1351 def distanceFilter(self, maxdistance: float):
1352 """Filter hit points by maximum distance from scanner"""
1353 validate_positive_value(maxdistance, 'maxdistance', 'distanceFilter')
1354 lidar_wrapper.lidarDistanceFilter(self._cloud_ptr, maxdistance)
1355
1356 def reflectanceFilter(self, minreflectance: float):
1357 """Filter hit points by minimum reflectance value"""
1358 lidar_wrapper.lidarReflectanceFilter(self._cloud_ptr, minreflectance)
1360 def firstHitFilter(self):
1361 """Keep only first return hit points"""
1362 lidar_wrapper.lidarFirstHitFilter(self._cloud_ptr)
1364 def lastHitFilter(self):
1365 """Keep only last return hit points"""
1366 lidar_wrapper.lidarLastHitFilter(self._cloud_ptr)
1368 def exportPointCloud(self, filename: str, write_header: bool = True):
1369 """Export point cloud to ASCII file.
1370
1371 Args:
1372 filename: Output file path.
1373 write_header: If True (default), prepend a ``#``-prefixed comment line listing the
1374 column field names (CloudCompare convention). The loader skips ``#``-prefixed
1375 lines, so headered files round-trip through ``loadXML()``. Set False for a
1376 bare data file.
1377 """
1378 if not filename:
1379 raise ValueError("Filename cannot be empty")
1380 lidar_wrapper.exportLiDARPointCloud(self._cloud_ptr, filename, write_header)
1381
1382 def exportLeafAreaUncertainty(self, filename: str):
1383 """Export per-voxel leaf-area sampling uncertainty to a self-describing ASCII file.
1384
1385 The file has a ``#``-prefixed header and one row per grid cell:
1386 ``cell_index leaf_area beam_count I_rdi LAD_std_error ci_valid``. Requires that
1387 :meth:`calculateLeafArea` has been run with an ``element_width`` (the uncertainty
1388 overload).
1389 """
1390 if not filename:
1391 raise ValueError("Filename cannot be empty")
1392 lidar_wrapper.exportLiDARLeafAreaUncertainty(self._cloud_ptr, filename)
1393
1394 def exportScans(self, filename: str):
1395 """Export all scans to an XML metadata file plus one ASCII data file per scan.
1396
1397 Args:
1398 filename: Path of the XML metadata file to write (e.g. "output/scans.xml").
1399 One ASCII data file is auto-generated per scan, named by stripping the XML
1400 extension and appending "_<scanID>.xyz" (e.g. "output/scans_0.xyz"). The
1401 resulting XML can be re-loaded with loadXML() from the same working directory.
1402 """
1403 if not filename:
1404 raise ValueError("Filename cannot be empty")
1405 lidar_wrapper.exportLiDARScans(self._cloud_ptr, filename)
1406
1407 def loadXML(self, filename: str):
1408 """Load scan metadata from XML file"""
1409 if not filename:
1410 raise ValueError("Filename cannot be empty")
1411 lidar_wrapper.loadLiDARXML(self._cloud_ptr, filename)
1412
1413 def disableMessages(self):
1414 """Disable console output messages"""
1415 lidar_wrapper.lidarDisableMessages(self._cloud_ptr)
1417 def enableMessages(self):
1418 """Enable console output messages"""
1419 lidar_wrapper.lidarEnableMessages(self._cloud_ptr)
1421 def addGrid(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1422 size: Union[vec3, List[float], Tuple[float, float, float]],
1423 ndiv: Union[List[int], Tuple[int, int, int]],
1424 rotation: float = 0.0,
1425 column_z_offsets: Optional[Union[List[float], Tuple[float, ...]]] = None):
1426 """
1427 Add a rectangular grid of voxel cells.
1428
1429 Args:
1430 center: Grid center position (vec3 or 3-element list)
1431 size: Grid dimensions [x, y, z] (vec3 or 3-element list)
1432 ndiv: Number of divisions [nx, ny, nz] (3-element list)
1433 rotation: Azimuthal rotation angle (degrees, default 0.0)
1434 column_z_offsets: Optional per-(x,y)-column vertical offset for terrain
1435 following, row-major as ``[j*ndiv[0] + i]`` with length
1436 ``ndiv[0]*ndiv[1]``. Each vertical column of voxels is shifted in z by
1437 its column's offset so the grid can track an external terrain surface
1438 (e.g. a DEM). ``None`` (the default) builds an axis-regular grid.
1439
1440 Note:
1441 ``rotation`` is in **degrees** here, matching the native ``addGrid()``.
1442 :meth:`addGridCell` takes its rotation in **radians** — the two native
1443 entry points genuinely differ, and PyHelios passes each through unchanged.
1444 :meth:`getCellRotation` reports degrees.
1445
1446 Example:
1447 >>> lidar.addGrid(
1448 ... center=vec3(0, 0, 0.5),
1449 ... size=vec3(10, 10, 1),
1450 ... ndiv=[10, 10, 5],
1451 ... rotation=0.0
1452 ... )
1453
1454 Terrain-following grid over a 2x2 column layout:
1455
1456 >>> lidar.addGrid(
1457 ... center=vec3(0, 0, 0.5),
1458 ... size=vec3(10, 10, 1),
1459 ... ndiv=[2, 2, 5],
1460 ... column_z_offsets=[0.0, 0.1, 0.2, 0.3]
1461 ... )
1462 """
1463 # Convert center to list
1464 if isinstance(center, (list, tuple)):
1465 if len(center) != 3:
1466 raise ValueError("Center must have 3 elements [x, y, z]")
1467 center_list = list(center)
1468 elif hasattr(center, 'x'):
1469 center_list = [center.x, center.y, center.z]
1470 else:
1471 raise ValueError("Center must be vec3 or 3-element list/tuple")
1472
1473 # Convert size to list
1474 if isinstance(size, (list, tuple)):
1475 if len(size) != 3:
1476 raise ValueError("Size must have 3 elements [x, y, z]")
1477 size_list = list(size)
1478 elif hasattr(size, 'x'):
1479 size_list = [size.x, size.y, size.z]
1480 else:
1481 raise ValueError("Size must be vec3 or 3-element list/tuple")
1482
1483 # Validate ndiv
1484 if not isinstance(ndiv, (list, tuple)) or len(ndiv) != 3:
1485 raise ValueError("Ndiv must be a 3-element list [nx, ny, nz]")
1486
1487 if column_z_offsets is None:
1488 lidar_wrapper.addLiDARGrid(self._cloud_ptr, center_list, size_list, list(ndiv), rotation)
1489 return
1490
1491 if not isinstance(column_z_offsets, (list, tuple)):
1492 raise ValueError(
1493 "column_z_offsets must be a list or tuple of floats, got "
1494 f"{type(column_z_offsets).__name__}"
1495 )
1496
1497 expected = ndiv[0] * ndiv[1]
1498 if len(column_z_offsets) != expected:
1499 raise ValueError(
1500 f"column_z_offsets must have length ndiv[0]*ndiv[1] = {expected} "
1501 f"(one value per grid column), got {len(column_z_offsets)}"
1502 )
1503
1504 lidar_wrapper.addLiDARGridTerrainFollowing(
1505 self._cloud_ptr, center_list, size_list, list(ndiv), rotation,
1506 [float(z) for z in column_z_offsets]
1507 )
1508
1509 def addGridCell(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1510 size: Union[vec3, List[float], Tuple[float, float, float]],
1511 rotation: float = 0.0):
1512 """
1513 Add a single grid cell.
1514
1515 Args:
1516 center: Cell center position (vec3 or 3-element list)
1517 size: Cell dimensions [x, y, z] (vec3 or 3-element list)
1518 rotation: Azimuthal rotation angle (radians, default 0.0)
1519
1520 Note:
1521 ``rotation`` is in **radians** here, whereas :meth:`addGrid` takes
1522 **degrees**. This asymmetry is inherited from the native API — the native
1523 ``addGridCell()`` stores the angle directly in the cell's radian field while
1524 ``addGrid()`` converts from degrees. :meth:`getCellRotation` reports degrees.
1525 """
1526 # Convert center to list
1527 if isinstance(center, (list, tuple)):
1528 if len(center) != 3:
1529 raise ValueError("Center must have 3 elements [x, y, z]")
1530 center_list = list(center)
1531 elif hasattr(center, 'x'):
1532 center_list = [center.x, center.y, center.z]
1533 else:
1534 raise ValueError("Center must be vec3 or 3-element list/tuple")
1535
1536 # Convert size to list
1537 if isinstance(size, (list, tuple)):
1538 if len(size) != 3:
1539 raise ValueError("Size must have 3 elements [x, y, z]")
1540 size_list = list(size)
1541 elif hasattr(size, 'x'):
1542 size_list = [size.x, size.y, size.z]
1543 else:
1544 raise ValueError("Size must be vec3 or 3-element list/tuple")
1545
1546 lidar_wrapper.addLiDARGridCell(self._cloud_ptr, center_list, size_list, rotation)
1547
1548 def getGridCellCount(self) -> int:
1549 """Get total number of grid cells"""
1550 return lidar_wrapper.getLiDARGridCellCount(self._cloud_ptr)
1552 def getCellCenter(self, index: int) -> vec3:
1553 """Get the true world-space center position of a grid cell.
1554
1555 For a grid created with a non-zero azimuthal ``rotation``, this is the lattice
1556 center rotated about the grid anchor (about +z), so it lies in the same rotated
1557 world frame as the hit points, scan origins, and grid bounding box. For an
1558 un-rotated grid it is simply the lattice center.
1559 """
1560 if index < 0:
1561 raise ValueError("Index must be non-negative")
1562 center_list = lidar_wrapper.getLiDARCellCenter(self._cloud_ptr, index)
1563 return vec3(*center_list)
1564
1565 def getCellSize(self, index: int) -> vec3:
1566 """Get size of a grid cell"""
1567 if index < 0:
1568 raise ValueError("Index must be non-negative")
1569 size_list = lidar_wrapper.getLiDARCellSize(self._cloud_ptr, index)
1570 return vec3(*size_list)
1571
1572 def getCellRotation(self, index: int) -> float:
1573 """Get the azimuthal rotation of a grid cell about the z-axis, in degrees.
1574
1575 The units match the ``rotation`` argument of :meth:`addGrid`. Note that
1576 :meth:`addGridCell` takes its rotation in radians.
1577 """
1578 if index < 0:
1579 raise ValueError("Index must be non-negative")
1580 return lidar_wrapper.getLiDARCellRotation(self._cloud_ptr, index)
1581
1582 def getCellLeafArea(self, index: int) -> float:
1583 """Get leaf area of a grid cell (m²)"""
1584 if index < 0:
1585 raise ValueError("Index must be non-negative")
1586 return lidar_wrapper.getLiDARCellLeafArea(self._cloud_ptr, index)
1587
1588 def getCellLeafAreaDensity(self, index: int) -> float:
1589 """Get leaf area density of a grid cell (m²/m³)"""
1590 if index < 0:
1591 raise ValueError("Index must be non-negative")
1592 return lidar_wrapper.getLiDARCellLeafAreaDensity(self._cloud_ptr, index)
1593
1594 def getCellBeamCount(self, index: int) -> int:
1595 """Get the beam count N that entered a grid cell during the leaf-area inversion.
1596
1597 Returns -1 if :meth:`calculateLeafArea` has not been run for this cell.
1598 """
1599 if index < 0:
1600 raise ValueError("Index must be non-negative")
1601 return lidar_wrapper.getLiDARCellBeamCount(self._cloud_ptr, index)
1602
1603 def getCellRelativeDensityIndex(self, index: int) -> float:
1604 """Get the relative density index (I_rdi) for a grid cell."""
1605 if index < 0:
1606 raise ValueError("Index must be non-negative")
1607 return lidar_wrapper.getLiDARCellRelativeDensityIndex(self._cloud_ptr, index)
1608
1609 def getCellMeanPathLength(self, index: int) -> float:
1610 """Get the mean beam path length (m) through a grid cell."""
1611 if index < 0:
1612 raise ValueError("Index must be non-negative")
1613 return lidar_wrapper.getLiDARCellMeanPathLength(self._cloud_ptr, index)
1614
1615 def getCellLADVariance(self, index: int) -> float:
1616 """Get the per-voxel LAD sampling variance for a grid cell.
1617
1618 Returns -1 if uncertainty has not been computed (call :meth:`calculateLeafArea`
1619 with an ``element_width``).
1620 """
1621 if index < 0:
1622 raise ValueError("Index must be non-negative")
1623 return lidar_wrapper.getLiDARCellLADVariance(self._cloud_ptr, index)
1624
1625 def getCellLeafAreaConfidenceInterval(self, index: int, confidence_level: float = 0.95):
1626 """Get the leaf-area confidence interval for a single grid cell.
1627
1628 Returns a ``(valid, lower, upper)`` tuple. ``valid`` is False when the interval is
1629 gated out by the Pimont validity envelope (single-voxel intervals are often
1630 untrustworthy; prefer :meth:`getGroupLADConfidenceInterval`). Requires
1631 :meth:`calculateLeafArea` to have been run with an ``element_width``.
1632 """
1633 if index < 0:
1634 raise ValueError("Index must be non-negative")
1635 return lidar_wrapper.getLiDARCellLeafAreaConfidenceInterval(
1636 self._cloud_ptr, index, confidence_level)
1637
1638 def getGroupLADConfidenceInterval(self, indices: List[int], confidence_level: float = 0.95):
1639 """Get the group-scale LAD confidence interval over a set of grid cells (recommended).
1640
1641 Returns a ``(valid, mean_lad, lower, upper)`` tuple (Pimont et al. 2018, Eq. 39,
1642 assuming voxel independence). Requires :meth:`calculateLeafArea` to have been run
1643 with an ``element_width``.
1644 """
1645 if not indices:
1646 raise ValueError("indices must contain at least one cell index")
1647 if any(i < 0 for i in indices):
1648 raise ValueError("Cell indices must be non-negative")
1649 return lidar_wrapper.getLiDARGroupLADConfidenceInterval(
1650 self._cloud_ptr, indices, confidence_level)
1651
1652 def getCellGtheta(self, index: int) -> float:
1653 """Get G(theta) value for a grid cell"""
1654 if index < 0:
1655 raise ValueError("Index must be non-negative")
1656 return lidar_wrapper.getLiDARCellGtheta(self._cloud_ptr, index)
1657
1658 def setCellGtheta(self, Gtheta: float, index: int):
1659 """Set G(theta) value for a grid cell"""
1660 if index < 0:
1661 raise ValueError("Index must be non-negative")
1662 lidar_wrapper.setLiDARCellGtheta(self._cloud_ptr, Gtheta, index)
1663
1664 def calculateHitGridCell(self):
1665 """Calculate hit point grid cell assignments"""
1666 lidar_wrapper.calculateLiDARHitGridCell(self._cloud_ptr)
1668 def gapfillMisses(self):
1669 """
1670 Gapfill sky/miss points where rays didn't hit geometry.
1671
1672 Important for accurate leaf area calculations with real LiDAR data.
1673 Should be called before triangulation when processing real data.
1674 """
1675 lidar_wrapper.gapfillLiDARMisses(self._cloud_ptr)
1677 def syntheticScan(self, context: Context,
1678 rays_per_pulse: Optional[int] = None,
1679 pulse_distance_threshold: Optional[float] = None,
1680 scan_grid_only: bool = False,
1681 record_misses: bool = True,
1682 append: bool = False,
1683 return_mode: Optional[Union[ReturnMode, int]] = None,
1684 cancel_flag=None):
1685 """
1686 Perform synthetic LiDAR scan of geometry in Context.
1687
1688 Requires scan metadata to be defined first via addScan() or loadXML().
1689 Uses ray tracing to simulate LiDAR instrument measurements.
1690
1691 Args:
1692 context: Helios Context containing geometry to scan
1693 rays_per_pulse: Number of rays per pulse (None=discrete-return, typical: 100)
1694 pulse_distance_threshold: Distance threshold for aggregating hits (meters, required for waveform)
1695 scan_grid_only: If True, only scan within defined grid cells
1696 record_misses: If True, record miss/sky points where rays don't hit geometry
1697 append: If True, append to existing hits; if False, clear existing hits
1698 return_mode: Optional :class:`ReturnMode` (MULTI or SINGLE) for analytic-waveform scans.
1699 Overrides each scan's stored return mode for this call only. Only valid when
1700 rays_per_pulse is set (waveform mode); raises ValueError otherwise. In SINGLE mode
1701 up to each scan's getScanMaxReturns() returns per pulse are reported, selected by the
1702 scan's single-return selection policy.
1703
1704 Example (Discrete-return):
1705 >>> from pyhelios import Context, LiDARCloud
1706 >>> from pyhelios.types import vec3
1707 >>> with Context() as context:
1708 ... # Add geometry
1709 ... context.addPatch(center=vec3(0, 0, 0.5), size=vec2(1, 1))
1710 ...
1711 ... with LiDARCloud() as lidar:
1712 ... # Define scan parameters
1713 ... scan_id = lidar.addScan(
1714 ... origin=vec3(0, 0, 2),
1715 ... Ntheta=100, theta_range=(0, 1.57),
1716 ... Nphi=100, phi_range=(0, 6.28),
1717 ... exit_diameter=0, beam_divergence=0
1718 ... )
1719 ...
1720 ... # Perform discrete-return scan
1721 ... lidar.syntheticScan(context)
1722
1723 Example (Full-waveform):
1724 >>> lidar.syntheticScan(
1725 ... context,
1726 ... rays_per_pulse=100,
1727 ... pulse_distance_threshold=0.02,
1728 ... record_misses=True
1729 ... )
1730 """
1731 if not isinstance(context, Context):
1732 raise TypeError("context must be a Context instance")
1733
1734 context_ptr = context.getNativePtr()
1735
1736 # Register an external cancellation flag (a ctypes.c_int set non-zero from
1737 # another thread) so a long ray trace can be aborted mid-pass. Cleared in
1738 # the finally below so a later scan on this cloud isn't pre-cancelled.
1739 if cancel_flag is not None:
1740 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, cancel_flag)
1741 try:
1742 self._dispatch_synthetic_scan(
1743 context_ptr, rays_per_pulse, pulse_distance_threshold,
1744 scan_grid_only, record_misses, append, return_mode)
1745 finally:
1746 if cancel_flag is not None:
1747 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, None)
1748
1749 def _dispatch_synthetic_scan(self, context_ptr, rays_per_pulse,
1750 pulse_distance_threshold, scan_grid_only,
1751 record_misses, append, return_mode):
1752 # Discrete-return mode (single ray per pulse)
1753 if rays_per_pulse is None:
1754 if return_mode is not None:
1755 raise ValueError(
1756 "return_mode is only valid for analytic-waveform scans; pass rays_per_pulse (> 1)")
1757 # Honor scan_grid_only and record_misses for discrete scans too. record_misses
1758 # defaults to True so the cloud carries the transmitted beams that
1759 # calculateLeafArea() requires.
1760 lidar_wrapper.syntheticLiDARScanDiscrete(
1761 self._cloud_ptr, context_ptr, scan_grid_only, record_misses, append)
1762 else:
1763 # Full-waveform mode (multiple rays per pulse)
1764 if pulse_distance_threshold is None:
1765 raise ValueError("pulse_distance_threshold required for full-waveform scanning")
1766
1767 validate_positive_value(rays_per_pulse, 'rays_per_pulse', 'syntheticScan')
1768 validate_positive_value(pulse_distance_threshold, 'pulse_distance_threshold', 'syntheticScan')
1769
1770 if return_mode is None:
1771 lidar_wrapper.syntheticLiDARScanFull(
1772 self._cloud_ptr, context_ptr,
1773 rays_per_pulse, pulse_distance_threshold,
1774 scan_grid_only, record_misses, append
1775 )
1776 else:
1777 lidar_wrapper.syntheticLiDARScanReturnMode(
1778 self._cloud_ptr, context_ptr,
1779 rays_per_pulse, pulse_distance_threshold, int(return_mode),
1780 scan_grid_only, record_misses, append
1781 )
1782
1783 def calculateLeafArea(self, context: Context, min_voxel_hits: Optional[int] = None,
1784 element_width: Optional[float] = None, Gtheta: Optional[float] = None):
1785 """
1786 Calculate leaf area for each grid cell.
1787
1788 Requires triangulation to have been performed first, UNLESS a ``Gtheta`` is supplied
1789 (see below).
1790
1791 .. note::
1792 The cloud must contain misses (transmitted beams that returned nothing) — the
1793 inversion fails fast without them. Misses are produced by
1794 ``syntheticScan(..., record_misses=True)`` (the default) or by
1795 :meth:`gapfillMisses`. Use :meth:`hasMisses` to check.
1796
1797 Args:
1798 context: Helios Context instance
1799 min_voxel_hits: Optional minimum number of hits required per voxel
1800 element_width: Optional characteristic vegetation element width (meters). When
1801 provided, per-voxel sampling uncertainty (Pimont et al. 2018) is computed
1802 alongside the leaf-area estimate and becomes available via
1803 :meth:`getCellLADVariance`, :meth:`getCellLeafAreaConfidenceInterval`, and
1804 :meth:`getGroupLADConfidenceInterval`. ``element_width <= 0`` yields a
1805 sampling-only variance.
1806 Gtheta: Optional caller-supplied mean leaf-projection coefficient G(theta), in (0,1]
1807 (0.5 = spherical/random leaf-angle distribution). When provided, leaf area is
1808 computed via a beam-based inversion that uses each hit's per-pulse beam origin and
1809 does NOT require triangulation — the only supported path for moving-platform scans
1810 (see :meth:`addScanMoving`). Requires both ``min_voxel_hits`` and ``element_width``
1811 to also be specified.
1812
1813 Example:
1814 >>> from pyhelios import Context, LiDARCloud
1815 >>> with Context() as context:
1816 ... with LiDARCloud() as lidar:
1817 ... # ... load data, add grid, triangulate ...
1818 ... lidar.calculateLeafArea(context)
1819 """
1820 if not isinstance(context, Context):
1821 raise TypeError("context must be a Context instance")
1822
1823 # Validate argument combinations before touching native state (fail-fast).
1824 if Gtheta is not None:
1825 if min_voxel_hits is None or element_width is None:
1826 raise ValueError(
1827 "Gtheta requires both min_voxel_hits and element_width to also be specified "
1828 "(the G(theta) overload takes all three)")
1829 if Gtheta <= 0:
1830 # The native overload treats Gtheta <= 0 as the "compute from triangulation"
1831 # sentinel, which silently disables the supplied-G(theta) path. Reject it here.
1832 raise ValueError(
1833 "Gtheta must be > 0 and in (0, 1] (e.g. 0.5 for a spherical leaf-angle distribution)")
1834 elif element_width is not None and min_voxel_hits is None:
1835 raise ValueError(
1836 "element_width requires min_voxel_hits to also be specified "
1837 "(the uncertainty overload takes both)")
1838
1839 context_ptr = context.getNativePtr()
1840 if Gtheta is not None:
1841 lidar_wrapper.calculateLiDARLeafAreaGtheta(
1842 self._cloud_ptr, context_ptr, Gtheta, min_voxel_hits, element_width)
1843 elif element_width is not None:
1844 lidar_wrapper.calculateLiDARLeafAreaUncertainty(
1845 self._cloud_ptr, context_ptr, min_voxel_hits, element_width)
1846 elif min_voxel_hits is None:
1847 lidar_wrapper.calculateLiDARLeafArea(self._cloud_ptr, context_ptr)
1848 else:
1849 lidar_wrapper.calculateLiDARLeafAreaMinHits(self._cloud_ptr, context_ptr, min_voxel_hits)
1850
1851 def calculateSyntheticLeafArea(self, context: Context):
1852 """
1853 Calculate synthetic leaf area (for validation of synthetic scans).
1854
1855 Uses exact primitive geometry to calculate leaf area, useful for
1856 validating synthetic scan accuracy.
1857
1858 Args:
1859 context: Helios Context instance containing primitive geometry
1860 """
1861 if not isinstance(context, Context):
1862 raise TypeError("context must be a Context instance")
1863 context_ptr = context.getNativePtr()
1864 lidar_wrapper.calculateSyntheticLiDARLeafArea(self._cloud_ptr, context_ptr)
1865
1866 def calculateSyntheticGtheta(self, context: Context):
1867 """
1868 Calculate synthetic G(theta) (for validation of synthetic scans).
1869
1870 Uses exact primitive geometry to calculate G(theta), useful for
1871 validating synthetic scan accuracy.
1872
1873 Args:
1874 context: Helios Context instance containing primitive geometry
1875 """
1876 if not isinstance(context, Context):
1877 raise TypeError("context must be a Context instance")
1878 context_ptr = context.getNativePtr()
1879 lidar_wrapper.calculateSyntheticLiDARGtheta(self._cloud_ptr, context_ptr)
1880
1881 def exportTriangleNormals(self, filename: str):
1882 """Export triangle normal vectors to file"""
1883 if not filename:
1884 raise ValueError("Filename cannot be empty")
1885 lidar_wrapper.exportLiDARTriangleNormals(self._cloud_ptr, filename)
1886
1887 def exportTriangleAreas(self, filename: str):
1888 """Export triangle areas to file"""
1889 if not filename:
1890 raise ValueError("Filename cannot be empty")
1891 lidar_wrapper.exportLiDARTriangleAreas(self._cloud_ptr, filename)
1892
1893 def exportLeafAreas(self, filename: str):
1894 """Export leaf areas for each grid cell to file"""
1895 if not filename:
1896 raise ValueError("Filename cannot be empty")
1897 lidar_wrapper.exportLiDARLeafAreas(self._cloud_ptr, filename)
1898
1899 def exportLeafAreaDensities(self, filename: str):
1900 """Export leaf area densities for each grid cell to file"""
1901 if not filename:
1902 raise ValueError("Filename cannot be empty")
1903 lidar_wrapper.exportLiDARLeafAreaDensities(self._cloud_ptr, filename)
1904
1905 def exportGtheta(self, filename: str):
1906 """Export G(theta) values for each grid cell to file"""
1907 if not filename:
1908 raise ValueError("Filename cannot be empty")
1909 lidar_wrapper.exportLiDARGtheta(self._cloud_ptr, filename)
1910
1911 def addTrianglesToContext(self, context: Context):
1912 """
1913 Add triangulated mesh to Context as triangle primitives.
1914
1915 Converts the triangulated point cloud mesh into Context triangle
1916 primitives that can be used for further analysis or visualization.
1917
1918 Args:
1919 context: Helios Context instance
1920
1921 Example:
1922 >>> with Context() as context:
1923 ... with LiDARCloud() as lidar:
1924 ... lidar.loadXML("scan.xml")
1925 ... lidar.triangulateHitPoints(Lmax=0.5, max_aspect_ratio=5)
1926 ... lidar.addTrianglesToContext(context)
1927 ... print(f"Added {context.getPrimitiveCount()} triangles to context")
1928 """
1929 if not isinstance(context, Context):
1930 raise TypeError("context must be a Context instance")
1931 lidar_wrapper.addLiDARTrianglesToContext(self._cloud_ptr, context.getNativePtr())
1932
1933 def initializeCollisionDetection(self, context: Context):
1934 """
1935 Initialize CollisionDetection plugin for ray tracing.
1936
1937 Required before performing synthetic scans.
1938
1939 Args:
1940 context: Helios Context instance containing geometry
1941 """
1942 if not isinstance(context, Context):
1943 raise TypeError("context must be a Context instance")
1944
1945 # Retain a reference to the Context. The native side constructs a
1946 # CollisionDetection that stores the raw Context* for its lifetime, so a
1947 # temporary Context would otherwise be freed while still referenced.
1948 # Note the C++ side only builds CollisionDetection once (it no-ops if one
1949 # already exists), so the first Context passed here is the one that stays
1950 # bound - re-initializing with a different Context has no effect.
1951 if getattr(self, '_cd_context', None) is not None and self._cd_context is not context:
1952 raise RuntimeError(
1953 "LiDARCloud collision detection is already initialized with a different Context.\n"
1954 "The native CollisionDetection keeps the Context it was first given; "
1955 "passing another one here would silently have no effect.\n"
1956 "\n"
1957 "Fix: create a new LiDARCloud for a different Context, or reuse the "
1958 "Context this cloud was initialized with."
1959 )
1960 self._cd_context = context
1961
1962 lidar_wrapper.initializeLiDARCollisionDetection(self._cloud_ptr, context.getNativePtr())
1963
1964 def _check_cd_context_alive(self):
1965 """Raise if the Context bound to collision detection has been destroyed."""
1966 if getattr(self, '_cd_context', None) is not None:
1967 check_context_alive(self._cd_context, "LiDARCloud collision detection")
1968
1969 def enableCDGPUAcceleration(self):
1970 """Enable GPU acceleration for collision detection ray tracing"""
1971 self._check_cd_context_alive()
1972 lidar_wrapper.enableLiDARCDGPUAcceleration(self._cloud_ptr)
1973
1974 def disableCDGPUAcceleration(self):
1975 """Disable GPU acceleration (use CPU ray tracing)"""
1976 self._check_cd_context_alive()
1977 lidar_wrapper.disableLiDARCDGPUAcceleration(self._cloud_ptr)
1978
1979 def isGPUAvailable(self) -> bool:
1980 """Return True if a CUDA-capable GPU is available for collision-detection ray tracing.
1981
1982 Reports capability (compiled with CUDA, a device present, and HELIOS_NO_GPU not set); use
1983 :meth:`isGPUAccelerationEnabled` to query whether GPU acceleration is currently toggled on.
1984 """
1985 return lidar_wrapper.isLiDARGPUAvailable(self._cloud_ptr)
1986
1987 def isGPUAccelerationEnabled(self) -> bool:
1988 """Return True if GPU acceleration is currently enabled for collision-detection ray tracing."""
1989 return lidar_wrapper.isLiDARGPUAccelerationEnabled(self._cloud_ptr)
1990
1991 def setSyntheticScanProgressPointer(self, ptr):
1992 """Register an external per-scan progress counter polled during :meth:`syntheticScan`.
1993
1994 ``ptr`` is a ``ctypes.c_int`` into which syntheticScan writes the 0-based index of the scan
1995 currently being ray-traced (set to :meth:`getScanCount` when the batch finishes), letting a
1996 host thread poll progress while the blocking scan runs. The counter is owned by the caller and
1997 must outlive the scan. Pass ``None`` to clear.
1998 """
1999 import ctypes
2000 if ptr is not None and not isinstance(ptr, ctypes.c_int):
2001 raise TypeError("ptr must be a ctypes.c_int (or None to clear)")
2002 lidar_wrapper.setLiDARSyntheticScanProgressPointer(self._cloud_ptr, ptr)
2003
2004 def setProgressCallback(self, callback):
2005 """Register a progress callback fired with ``(progress_fraction, message)`` during :meth:`syntheticScan`.
2006
2007 ``progress_fraction`` is a float in [0, 1]; ``message`` is a ``str`` describing the current
2008 phase. Pass ``None`` to clear the callback. The callback bridge is kept alive on this
2009 :class:`LiDARCloud` for as long as it is registered.
2010 """
2011 if callback is None:
2012 # Clear the native callback first, then drop our reference, so a failure in the native
2013 # call cannot leave C++ holding a freed bridge.
2014 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, None)
2015 self._progress_callback_ref = None
2016 return
2017
2018 if not callable(callback):
2019 raise TypeError("callback must be callable or None")
2020
2021 def _trampoline(progress, message):
2022 callback(float(progress), message.decode('utf-8') if message else "")
2023
2024 # Keep the ctypes callback object alive for as long as native code holds it; ctypes does not.
2025 self._progress_callback_ref = lidar_wrapper.LiDARProgressCallback(_trampoline)
2026 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, self._progress_callback_ref)
2027
2028 def is_available(self) -> bool:
2029 """
2030 Check if LiDAR is available in current build.
2031
2032 Returns:
2033 True if plugin is available, False otherwise
2034 """
2035 registry = get_plugin_registry()
2036 return registry.is_plugin_available('lidar')
2037
2039# Convenience function
2040def create_lidar_cloud() -> LiDARCloud:
2041 """
2042 Create LiDARCloud instance.
2043
2044 Returns:
2045 LiDARCloud instance
2046 """
2047 return LiDARCloud()
High-level interface for LiDAR point cloud operations.
__init__(self)
Initialize LiDARCloud.
__enter__(self)
Context manager entry.
__exit__(self, exc_type, exc_val, exc_tb)
Context manager exit - cleanup resources.
__del__(self)
Fallback destructor for cleanup without context manage.
Exception raised for LiDAR-specific errors.
Definition LiDARCloud.py:23
Return-reporting mode for analytic-waveform synthetic scans (see :meth:LiDARCloud....
A single rotating wedge prism in a Risley-prism beam deflector (see :meth:LiDARCloud....
Definition LiDARCloud.py:74
__init__(self, float wedge_angle, float refractive_index, float rotor_rate, float phase=0.0)
Definition LiDARCloud.py:79
List[float] to_list(self)
Return the prism as a 4-element [wedge_angle, refractive_index, rotor_rate, phase] list.
Definition LiDARCloud.py:86
High-level acquisition mode returned by :meth:LiDARCloud.getScanMode.
Definition LiDARCloud.py:51
Geometric beam pattern returned by :meth:LiDARCloud.getScanPattern.
Definition LiDARCloud.py:36
Which return(s) a limited-return instrument keeps when a pulse resolves more returns than the return ...
Exception classes for PyHelios library.
Definition exceptions.py:10