0.1.33
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 HitDataType(IntEnum):
28 """Storage type of a per-hit scalar-data column (see :meth:`LiDARCloud.createHitDataColumn`).
29
30 Values mirror ``helios::HitDataType`` in declaration order and cross the ABI as 0/1/2.
31
32 A column created implicitly (by the first write that carries the label) starts with a type
33 chosen from the label's name and widens itself to FLOAT64 the first time it is given a value
34 it does not hold exactly. A column created explicitly is never widened: an explicit INT32
35 column rejects a value that is not a 32-bit integer, and an explicit FLOAT32 column stores
36 values at float precision.
37 """
38 #: 8-byte double. Holds any value exactly; the type of every unrecognized label.
39 FLOAT64 = 0
40 #: 4-byte float. For measurements such as intensity or reflectance.
41 FLOAT32 = 1
42 #: 4-byte signed integer. For counts and indices.
43 INT32 = 2
44
45
46class ScanPattern(IntEnum):
47 """Geometric beam pattern returned by :meth:`LiDARCloud.getScanPattern`.
48
49 RASTER is the uniform-angular-grid pattern produced by :meth:`LiDARCloud.addScan`;
50 SPINNING_MULTIBEAM is the rotating multi-channel pattern produced by
51 :meth:`LiDARCloud.addScanSpinning` (each row is a laser channel at a fixed zenith angle).
52
53 This is the geometric pattern, orthogonal to :class:`ScanMode` (the acquisition mode). Use
54 :meth:`LiDARCloud.getScanBeamZenithAngles` to read a spinning scan's per-channel angles.
55 """
56 RASTER = 0
57 SPINNING_MULTIBEAM = 1
58 #: Rotating-Risley-prism rosette (Livox-style), produced by :meth:`LiDARCloud.addScanRisley`.
59 #: Stored as a single-row (Ntheta=1) table; the per-pulse direction comes from the prism optics.
60 RISLEY_PRISM = 2
61
62
63class ScanMode(IntEnum):
64 """High-level acquisition mode returned by :meth:`LiDARCloud.getScanMode`.
65
66 STATIC_RASTER is a uniform angular grid from a single fixed origin (terrestrial/tripod);
67 MOVING_RASTER is a fixed angular fan swept along a trajectory (mobile/airborne raster),
68 produced by :meth:`LiDARCloud.addScanMovingRaster`; SPINNING is a continuously-rotating
69 multi-channel sensor, produced by :meth:`LiDARCloud.addScanSpinning`.
70 """
71 STATIC_RASTER = 0
72 MOVING_RASTER = 1
73 SPINNING = 2
74 #: Rotating-Risley-prism rosette sensor (Livox-style; always trajectory-driven), produced by
75 #: :meth:`LiDARCloud.addScanRisley`. A stationary capture is two coincident poses separated in time.
76 RISLEY_PRISM = 3
77
78
79class RisleyPrism:
80 """A single rotating wedge prism in a Risley-prism beam deflector (see :meth:`LiDARCloud.addScanRisley`).
81
82 A pair of such prisms with different (and generally incommensurate) rotation rates traces the
83 characteristic non-repetitive rosette of a Livox sensor. The beam direction is computed by
84 non-paraxial ray tracing through the wedges; the field of view is an emergent property of the
85 wedge angles and refractive indices, not a directly specified parameter.
86
87 Args:
88 wedge_angle: Wedge (inclination) angle of the prism in radians.
89 refractive_index: Refractive index of the prism glass.
90 rotor_rate: Rotation rate about the optical axis in radians/second (the sign sets the rotation
91 direction; a counter-rotating pair traces a rosette).
92 phase: Initial clocking angle of the wedge about the optical axis in radians at scan time t=0.
93 """
94
95 __slots__ = ("wedge_angle", "refractive_index", "rotor_rate", "phase")
96
97 def __init__(self, wedge_angle: float, refractive_index: float,
98 rotor_rate: float, phase: float = 0.0):
99 self.wedge_angle = float(wedge_angle)
100 self.refractive_index = float(refractive_index)
101 self.rotor_rate = float(rotor_rate)
102 self.phase = float(phase)
103
104 def to_list(self) -> List[float]:
105 """Return the prism as a 4-element [wedge_angle, refractive_index, rotor_rate, phase] list."""
106 return [self.wedge_angle, self.refractive_index, self.rotor_rate, self.phase]
107
108 def __repr__(self) -> str:
109 return (f"RisleyPrism(wedge_angle={self.wedge_angle}, refractive_index={self.refractive_index}, "
110 f"rotor_rate={self.rotor_rate}, phase={self.phase})")
111
112 def __eq__(self, other) -> bool:
113 if not isinstance(other, RisleyPrism):
114 return NotImplemented
115 return self.to_list() == other.to_list()
116
117
118class ReturnMode(IntEnum):
119 """Return-reporting mode for analytic-waveform synthetic scans (see
120 :meth:`LiDARCloud.getScanReturnMode`/:meth:`LiDARCloud.setScanReturnMode`).
121
122 MULTI reports every detected return (discrete multi-return, no limit); SINGLE reports at
123 most :meth:`LiDARCloud.getScanMaxReturns` returns per pulse, selected by the scan's
124 :class:`SingleReturnSelection` policy.
125 """
126 MULTI = 0
127 SINGLE = 1
128
129
130class SingleReturnSelection(IntEnum):
131 """Which return(s) a limited-return instrument keeps when a pulse resolves more returns
132 than the return limit (see :meth:`LiDARCloud.setScanSingleReturnSelection`).
133
134 The kept subset is always reported nearest-first.
135 """
136 STRONGEST = 0
137 FIRST = 1
138 LAST = 2
139 #: Dual return: keep the strongest return AND the last (farthest) return of the
140 #: pulse, deduplicated to one when they are the same return. Models the
141 #: "strongest + last" dual-return mode of real discrete-return scanners.
142 #: Intrinsically yields 1 or 2 returns and ignores the per-scan maxReturns.
143 STRONGEST_PLUS_LAST = 3
144
145
146class LiDARCloud:
147 """
148 High-level interface for LiDAR point cloud operations.
149
150 Supports synthetic scanning, point cloud filtering, triangulation,
151 and leaf area density calculations.
152
153 Example:
154 >>> from pyhelios import LiDARCloud
155 >>> from pyhelios.types import vec3
156 >>>
157 >>> with LiDARCloud() as lidar:
158 ... # Add a scan
159 ... scan_id = lidar.addScan(
160 ... origin=vec3(0, 0, 1),
161 ... Ntheta=100, theta_range=(0, 1.57),
162 ... Nphi=100, phi_range=(-3.14, 3.14),
163 ... exit_diameter=0.01, beam_divergence=0.001
164 ... )
165 ...
166 ... # Add hit points
167 ... lidar.addHitPoint(scan_id, vec3(1, 0, 0), vec3(1, 0, 0))
168 ...
169 ... # Export point cloud
170 ... lidar.exportPointCloud("output.xyz")
171 """
172
173 def __init__(self):
174 """
175 Initialize LiDARCloud.
176
177 Raises:
178 LiDARError: If plugin not available in current build
179 RuntimeError: If cloud initialization fails
180 """
181 # Check plugin availability
182 registry = get_plugin_registry()
183 if not registry.is_plugin_available('lidar'):
184 raise LiDARError(
185 "LiDAR plugin not available. Rebuild PyHelios with LiDAR:\n"
186 " build_scripts/build_helios --plugins lidar\n"
187 "\n"
188 "System requirements:\n"
189 " - Platforms: Windows, Linux, macOS\n"
190 " - GPU: Optional (enables GPU acceleration)"
191 )
192
193 self._cloud_ptr = lidar_wrapper.createLiDARcloud()
194 if not self._cloud_ptr:
195 raise LiDARError("Failed to create LiDAR cloud")
196
197 # Keeps the ctypes progress-callback bridge alive while native code holds it (see
198 # setProgressCallback); ctypes does not retain a reference on its own.
200
201 # Same for the 1.3.86 streaming sinks, plus the slots holding an exception raised
202 # inside a sink callback (which cannot propagate through the ctypes boundary; see
203 # setTriangulationSink and _raise_pending_callback_error).
208
209 def __enter__(self):
210 """Context manager entry"""
211 return self
212
213 def __exit__(self, exc_type, exc_val, exc_tb):
214 """Context manager exit - cleanup resources"""
215 if hasattr(self, '_cloud_ptr') and self._cloud_ptr:
216 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
217 self._cloud_ptr = None
218
219 def __del__(self):
220 """Fallback destructor for cleanup without context manager"""
221 if hasattr(self, '_cloud_ptr') and self._cloud_ptr is not None:
222 try:
223 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
224 self._cloud_ptr = None
225 except Exception as e:
226 import warnings
227 warnings.warn(f"Error in LiDARCloud.__del__: {e}")
228
229 def addScan(self, origin: Union[vec3, List[float], Tuple[float, float, float]],
230 Ntheta: int, theta_range: Tuple[float, float],
231 Nphi: int, phi_range: Tuple[float, float],
232 exit_diameter: float, beam_divergence: float,
233 column_format: Optional[List[str]] = None,
234 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
235 scan_tilt_roll: float = 0.0, scan_tilt_pitch: float = 0.0,
236 scan_azimuth_offset: float = 0.0) -> int:
237 """
238 Add a LiDAR scan to the point cloud.
239
240 Args:
241 origin: Scanner position (vec3 or 3-element list/tuple)
242 Ntheta: Number of scan points in zenith direction
243 theta_range: Zenith angle range (min, max) in radians
244 Nphi: Number of scan points in azimuthal direction
245 phi_range: Azimuthal angle range (min, max) in radians
246 exit_diameter: Laser beam exit diameter (meters)
247 beam_divergence: Beam divergence angle (radians)
248 column_format: Optional list of column-format labels. Non-standard labels
249 (anything other than geometry/standard tokens like x/y/z/r/g/b/raydir)
250 cause syntheticScan to sample that named primitive data from the struck
251 primitive onto each hit's data map, retrievable via getHitData(). Defaults
252 to None (empty format).
253
254 One label is special: "reflectivity_lidar" modulates each hit's "intensity"
255 (intensity *= reflectivity) rather than being stored as its own hit-data
256 key, so getHitData(i, "reflectivity_lidar") will NOT return it.
257 range_noise_stddev: Standard deviation of Gaussian range (along-beam) measurement
258 noise in meters. Only affects synthetic-scan generation. Defaults to 0.0
259 (noise disabled).
260 angle_noise_stddev: Standard deviation of Gaussian angular (beam-pointing) jitter
261 in radians. Only affects synthetic-scan generation. Defaults to 0.0 (jitter
262 disabled).
263 scan_tilt_roll: Global scanner tilt roll angle in radians, modeling residual tilt of
264 the scanner spin axis away from plumb (right-hand rotation about the body lateral
265 axis). Only affects synthetic-scan generation. Defaults to 0.0 (level).
266 scan_tilt_pitch: Global scanner tilt pitch angle in radians (right-hand rotation about
267 the body forward/azimuth-zero axis). Only affects synthetic-scan generation.
268 Defaults to 0.0 (level).
269 scan_azimuth_offset: Global scanner azimuth (heading) offset in radians, a right-hand
270 rotation about the world +z axis applied on top of the azimuth sweep. Only affects
271 synthetic-scan generation. Defaults to 0.0 (no offset).
272
273 Returns:
274 Scan ID for referencing this scan
275
276 Example:
277 >>> scan_id = lidar.addScan(
278 ... origin=vec3(0, 0, 1),
279 ... Ntheta=100, theta_range=(0, 1.57),
280 ... Nphi=100, phi_range=(-3.14, 3.14),
281 ... exit_diameter=0.01, beam_divergence=0.001,
282 ... column_format=["my_scalar"]
283 ... )
284 """
285 # Convert origin to vec3 if needed
286 if isinstance(origin, (list, tuple)):
287 if len(origin) != 3:
288 raise ValueError("Origin must have 3 elements [x, y, z]")
289 origin = vec3(*origin)
290 elif not hasattr(origin, 'x'):
291 raise ValueError("Origin must be vec3 or 3-element list/tuple")
292
293 origin_list = [origin.x, origin.y, origin.z]
294
295 # Validate scan parameters
296 validate_positive_value(Ntheta, 'Ntheta', 'addScan')
297 validate_positive_value(Nphi, 'Nphi', 'addScan')
298
299 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
300 raise ValueError("theta_range must be a tuple (min, max)")
301 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
302 raise ValueError("phi_range must be a tuple (min, max)")
303
304 if column_format is not None:
305 if not isinstance(column_format, (list, tuple)) or \
306 not all(isinstance(c, str) for c in column_format):
307 raise ValueError("column_format must be a list of strings")
308 column_format = list(column_format)
309
310 if range_noise_stddev < 0:
311 raise ValueError("range_noise_stddev must be non-negative")
312 if angle_noise_stddev < 0:
313 raise ValueError("angle_noise_stddev must be non-negative")
314
315 return lidar_wrapper.addLiDARScan(
316 self._cloud_ptr, origin_list, Ntheta, theta_range,
317 Nphi, phi_range, exit_diameter, beam_divergence, column_format,
318 range_noise_stddev, angle_noise_stddev,
319 scan_tilt_roll, scan_tilt_pitch, scan_azimuth_offset
320 )
321
322 def addScanMoving(self, Ntheta: int, theta_range: Tuple[float, float],
323 Nphi: int, phi_range: Tuple[float, float],
324 exit_diameter: float, beam_divergence: float,
325 traj_t: List[float],
326 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
327 traj_rot: List[List[float]], pulse_rate_hz: float,
328 rot_is_quaternion: bool = True,
329 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
330 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
331 column_format: Optional[List[str]] = None,
332 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
333 t0: float = 0.0) -> int:
334 """
335 Add a moving-platform (mobile/airborne) raster LiDAR scan driven by a 6-DOF pose trajectory.
336
337 Unlike :meth:`addScan`, the scanner pose changes during the sweep. For each pulse the synthetic-scan
338 generator computes its acquisition time ``t = t0 + ordinal / pulse_rate_hz``, interpolates the platform
339 pose at that time (linear position, SLERP orientation), and emits a per-pulse origin
340 ``o = pos + R(q) * lever_arm`` and direction ``d = R(q) * R(boresight) * d_body``. Every resulting hit
341 and miss stores its own origin (hit-data "origin_x"/"origin_y"/"origin_z", retrievable via
342 :meth:`getHitOrigin`), timestamp ("timestamp"), and firing index ("pulse_id").
343
344 The static tilt roll/pitch/azimuth fields are NOT applied in this mode; attitude comes entirely
345 from the trajectory and the boresight misalignment. Because the pulses do not lie on a fixed
346 theta-phi grid they cannot be triangulated, so leaf-area inversion must use
347 :meth:`calculateLeafArea` with an explicit ``Gtheta``.
348
349 Args:
350 Ntheta: Number of scan points in zenith direction (raster grid rows)
351 theta_range: Zenith angle range (min, max) in radians
352 Nphi: Number of scan points in azimuthal direction (raster grid columns)
353 phi_range: Azimuthal angle range (min, max) in radians
354 exit_diameter: Laser beam exit diameter (meters)
355 beam_divergence: Beam divergence angle (radians)
356 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
357 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
358 traj_rot: Platform orientations, one entry per traj_t entry. Each entry is a length-4
359 quaternion (qx, qy, qz, qw, Hamilton body->world) when ``rot_is_quaternion`` is True,
360 otherwise a length-3 roll/pitch/yaw Euler triple in radians (intrinsic Z-Y-X).
361 pulse_rate_hz: Pulse repetition rate in Hz (must be > 0)
362 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
363 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
364 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
365 column_format: Optional list of column-format labels (see addScan)
366 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
367 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
368 t0: Time of the first pulse in seconds (relative time; default 0)
369
370 Returns:
371 Scan ID for referencing this scan
372 """
373 validate_positive_value(Ntheta, 'Ntheta', 'addScanMoving')
374 validate_positive_value(Nphi, 'Nphi', 'addScanMoving')
375
376 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
377 raise ValueError("theta_range must be a tuple (min, max)")
378 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
379 raise ValueError("phi_range must be a tuple (min, max)")
380 if pulse_rate_hz <= 0:
381 raise ValueError("pulse_rate_hz must be greater than 0")
382 if range_noise_stddev < 0:
383 raise ValueError("range_noise_stddev must be non-negative")
384 if angle_noise_stddev < 0:
385 raise ValueError("angle_noise_stddev must be non-negative")
386
387 rot_stride = 4 if rot_is_quaternion else 3
388 _, pos_list, rot_list = self._validate_trajectory(
389 traj_t, traj_pos, traj_rot, rot_stride, 'addScanMoving')
390
391 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
392 else list(lever_arm)) if lever_arm is not None else None
393 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
394 else list(boresight_rpy)) if boresight_rpy is not None else None
395
396 if column_format is not None:
397 if not isinstance(column_format, (list, tuple)) or \
398 not all(isinstance(c, str) for c in column_format):
399 raise ValueError("column_format must be a list of strings")
400 column_format = list(column_format)
401
402 return lidar_wrapper.addLiDARScanMoving(
403 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
404 exit_diameter, beam_divergence,
405 [float(t) for t in traj_t], pos_list, rot_list, bool(rot_is_quaternion),
406 float(pulse_rate_hz), lever_list, boresight_list, column_format,
407 range_noise_stddev, angle_noise_stddev, float(t0)
408 )
409
410 @staticmethod
411 def _validate_trajectory(traj_t, traj_pos, traj_rot, rot_stride, method):
412 """Shared trajectory validation/marshalling for moving/spinning scans.
413
414 Returns (t_list, pos_list, rot_list) of plain Python floats. rot_stride is 4 for
415 quaternions or 3 for Euler triples; pass rot_stride=None to skip rotation validation.
416 """
417 if not isinstance(traj_t, (list, tuple)) or len(traj_t) == 0:
418 raise ValueError("traj_t must be a non-empty list of trajectory sample times")
419 M = len(traj_t)
420 if len(traj_pos) != M:
421 raise ValueError("traj_t and traj_pos must have the same length M")
422 if rot_stride is not None and len(traj_rot) != M:
423 raise ValueError("traj_t and the trajectory orientation list must have the same length M")
424 # Fail fast on a non-monotonic trajectory rather than deferring to a C++ exception.
425 if any(traj_t[i] >= traj_t[i + 1] for i in range(M - 1)):
426 raise ValueError("traj_t must be strictly monotonically increasing")
427
428 def _to_xyz(v, name):
429 if hasattr(v, 'x'):
430 return [v.x, v.y, v.z]
431 if isinstance(v, (list, tuple)) and len(v) == 3:
432 return [float(c) for c in v]
433 raise ValueError(f"{name} must be a vec3 or 3-element list/tuple")
434
435 pos_list = [_to_xyz(p, "Each traj_pos entry") for p in traj_pos]
436
437 rot_list = None
438 if rot_stride is not None:
439 rot_list = []
440 for r in traj_rot:
441 if not isinstance(r, (list, tuple)) or len(r) != rot_stride:
442 label = 'qx,qy,qz,qw' if rot_stride == 4 else 'roll,pitch,yaw'
443 raise ValueError(
444 f"Each trajectory orientation entry must have {rot_stride} elements ({label})"
445 )
446 rot_list.append([float(c) for c in r])
447 return [float(t) for t in traj_t], pos_list, rot_list
448
449 def addScanSpinning(self, beam_elevation_angles: List[float],
450 azimuth_step: float, pulse_rate_hz: float,
451 traj_t: List[float],
452 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
453 traj_rot: List[List[float]],
454 rot_is_quaternion: bool = True,
455 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
456 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
457 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
458 column_format: Optional[List[str]] = None,
459 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
460 t0: float = 0.0) -> int:
461 """
462 Add a continuously-spinning multibeam scan from physical instrument parameters.
463
464 High-level entry point for a rotating multi-channel sensor (Velodyne/Ouster/Hesai) on a moving
465 (or stationary) platform. The azimuth grid, rotation rate, and revolution count are derived
466 internally from the azimuth resolution, PRF, and trajectory duration; you never specify an
467 azimuth range or step count. Sets the scan's :class:`ScanMode` to ``SPINNING``. For a stationary
468 "spin in place" capture (a tripod), supply two coincident poses (same position and orientation)
469 separated in time by the acquisition duration.
470
471 Args:
472 beam_elevation_angles: Per-channel beam ELEVATION angles above the horizon, in radians
473 (NOT zenith — elevation above the horizon, where zenith = pi/2 - elevation; this matches
474 manufacturer spec sheets)
475 azimuth_step: Azimuth angular resolution in radians per firing step (must be > 0)
476 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
477 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
478 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
479 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
480 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
481 Euler triple in radians (intrinsic Z-Y-X).
482 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
483 exit_diameter: Laser beam exit diameter (meters, default 0)
484 beam_divergence: Beam divergence angle (radians, default 0)
485 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
486 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
487 column_format: Optional list of column-format labels (default ["x", "y", "z"])
488 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
489 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
490 t0: Time of the first pulse in seconds (relative time; default 0)
491
492 Returns:
493 Scan ID for referencing this scan
494 """
495 if not isinstance(beam_elevation_angles, (list, tuple)) or len(beam_elevation_angles) == 0:
496 raise ValueError("beam_elevation_angles must be a non-empty list of per-channel angles")
497 if azimuth_step <= 0:
498 raise ValueError("azimuth_step must be greater than 0")
499 if pulse_rate_hz <= 0:
500 raise ValueError("pulse_rate_hz must be greater than 0")
501 if range_noise_stddev < 0:
502 raise ValueError("range_noise_stddev must be non-negative")
503 if angle_noise_stddev < 0:
504 raise ValueError("angle_noise_stddev must be non-negative")
505
506 rot_stride = 4 if rot_is_quaternion else 3
507 t_list, pos_list, rot_list = self._validate_trajectory(
508 traj_t, traj_pos, traj_rot, rot_stride, 'addScanSpinning')
509
510 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
511 else list(lever_arm)) if lever_arm is not None else None
512 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
513 else list(boresight_rpy)) if boresight_rpy is not None else None
514
515 if column_format is not None:
516 if not isinstance(column_format, (list, tuple)) or \
517 not all(isinstance(c, str) for c in column_format):
518 raise ValueError("column_format must be a list of strings")
519 column_format = list(column_format)
520
521 return lidar_wrapper.addLiDARScanSpinning(
522 self._cloud_ptr, [float(a) for a in beam_elevation_angles],
523 float(azimuth_step), float(pulse_rate_hz),
524 t_list, pos_list, rot_list, bool(rot_is_quaternion),
525 exit_diameter, beam_divergence,
526 lever_list, boresight_list, column_format,
527 range_noise_stddev, angle_noise_stddev, float(t0)
528 )
529
530 def addScanMovingRaster(self, Ntheta: int, theta_range: Tuple[float, float],
531 Nphi: int, phi_range: Tuple[float, float],
532 pulse_rate_hz: float,
533 traj_t: List[float],
534 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
535 traj_quat: List[List[float]],
536 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
537 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
538 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
539 column_format: Optional[List[str]] = None,
540 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
541 t0: float = 0.0) -> int:
542 """
543 Add a moving-platform raster scan: a fixed angular fan swept along a quaternion trajectory.
544
545 High-level wrapper around :meth:`addScanMoving` for a non-spinning sensor on a moving platform.
546 Specify the per-frame angular fan resolution plus the trajectory and PRF; Helios derives the
547 per-pulse time sampling along the trajectory. Sets the scan's :class:`ScanMode` to ``MOVING_RASTER``.
548
549 Args:
550 Ntheta: Number of zenith samples in the angular fan
551 theta_range: Zenith angle range (min, max) in radians
552 Nphi: Number of azimuth samples in the angular fan
553 phi_range: Azimuthal angle range (min, max) in radians
554 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
555 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
556 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
557 traj_quat: Platform orientation quaternions (qx, qy, qz, qw, Hamilton body->world), one per
558 traj_t entry
559 exit_diameter: Laser beam exit diameter (meters, default 0)
560 beam_divergence: Beam divergence angle (radians, default 0)
561 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
562 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
563 column_format: Optional list of column-format labels (default ["x", "y", "z"])
564 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
565 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
566 t0: Time of the first pulse in seconds (relative time; default 0)
567
568 Returns:
569 Scan ID for referencing this scan
570 """
571 validate_positive_value(Ntheta, 'Ntheta', 'addScanMovingRaster')
572 validate_positive_value(Nphi, 'Nphi', 'addScanMovingRaster')
573 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
574 raise ValueError("theta_range must be a tuple (min, max)")
575 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
576 raise ValueError("phi_range must be a tuple (min, max)")
577 if pulse_rate_hz <= 0:
578 raise ValueError("pulse_rate_hz must be greater than 0")
579 if range_noise_stddev < 0:
580 raise ValueError("range_noise_stddev must be non-negative")
581 if angle_noise_stddev < 0:
582 raise ValueError("angle_noise_stddev must be non-negative")
583
584 t_list, pos_list, quat_list = self._validate_trajectory(
585 traj_t, traj_pos, traj_quat, 4, 'addScanMovingRaster')
586
587 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
588 else list(lever_arm)) if lever_arm is not None else None
589 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
590 else list(boresight_rpy)) if boresight_rpy is not None else None
591
592 if column_format is not None:
593 if not isinstance(column_format, (list, tuple)) or \
594 not all(isinstance(c, str) for c in column_format):
595 raise ValueError("column_format must be a list of strings")
596 column_format = list(column_format)
597
598 return lidar_wrapper.addLiDARScanMovingRaster(
599 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
600 float(pulse_rate_hz),
601 t_list, pos_list, quat_list,
602 exit_diameter, beam_divergence,
603 lever_list, boresight_list, column_format,
604 range_noise_stddev, angle_noise_stddev, float(t0)
605 )
606
607 def addScanRisley(self, prisms: List[Union['RisleyPrism', List[float], Tuple[float, ...]]],
608 refractive_index_air: float, pulse_rate_hz: float,
609 traj_t: List[float],
610 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
611 traj_rot: List[List[float]],
612 rot_is_quaternion: bool = True,
613 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
614 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
615 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
616 column_format: Optional[List[str]] = None,
617 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
618 t0: float = 0.0) -> int:
619 """
620 Add a rotating-Risley-prism (Livox-style rosette) scan from physical instrument parameters.
621
622 High-level entry point for a Livox rosette-pattern sensor (Mid-40/Mid-70/Avia). A single beam is
623 refracted through a stack of continuously rotating wedge prisms, tracing a non-repetitive rosette
624 that fills a circular field of view. The scan is stored as an Ntheta=1, Nphi=Npulses table, where
625 Npulses = round(pulse_rate_hz * trajectory_duration). Sets the scan's :class:`ScanMode` to
626 ``RISLEY_PRISM`` and :class:`ScanPattern` to ``RISLEY_PRISM``. Like a spinning scan it is always
627 trajectory-driven; a stationary tripod capture is two coincident poses (same position and
628 orientation) separated in time by the acquisition duration.
629
630 Args:
631 prisms: Rotating wedge prisms in beam-traversal order (at least one; a Livox sensor uses two
632 counter-rotating prisms). Each entry is a :class:`RisleyPrism` or a 4-element
633 [wedge_angle, refractive_index, rotor_rate, phase] list/tuple (radians / unitless / rad-per-s / radians).
634 refractive_index_air: Refractive index of the medium surrounding the prisms (typically 1.0)
635 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
636 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
637 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
638 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
639 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
640 Euler triple in radians (intrinsic Z-Y-X).
641 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
642 exit_diameter: Laser beam exit diameter (meters, default 0)
643 beam_divergence: Beam divergence angle (radians, default 0)
644 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
645 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
646 column_format: Optional list of column-format labels (default ["x", "y", "z"])
647 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
648 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
649 t0: Time of the first pulse in seconds (relative time; default 0)
650
651 Returns:
652 Scan ID for referencing this scan
653 """
654 if not isinstance(prisms, (list, tuple)) or len(prisms) == 0:
655 raise ValueError("prisms must be a non-empty list of RisleyPrism or 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
656 prism_lists = []
657 for p in prisms:
658 if isinstance(p, RisleyPrism):
659 prism_lists.append(p.to_list())
660 elif isinstance(p, (list, tuple)) and len(p) == 4:
661 prism_lists.append([float(c) for c in p])
662 else:
663 raise ValueError("Each prism must be a RisleyPrism or a 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
664 if refractive_index_air <= 0:
665 raise ValueError("refractive_index_air must be greater than 0")
666 if pulse_rate_hz <= 0:
667 raise ValueError("pulse_rate_hz must be greater than 0")
668 if range_noise_stddev < 0:
669 raise ValueError("range_noise_stddev must be non-negative")
670 if angle_noise_stddev < 0:
671 raise ValueError("angle_noise_stddev must be non-negative")
672
673 rot_stride = 4 if rot_is_quaternion else 3
674 t_list, pos_list, rot_list = self._validate_trajectory(
675 traj_t, traj_pos, traj_rot, rot_stride, 'addScanRisley')
676
677 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
678 else list(lever_arm)) if lever_arm is not None else None
679 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
680 else list(boresight_rpy)) if boresight_rpy is not None else None
681
682 if column_format is not None:
683 if not isinstance(column_format, (list, tuple)) or \
684 not all(isinstance(c, str) for c in column_format):
685 raise ValueError("column_format must be a list of strings")
686 column_format = list(column_format)
687
688 return lidar_wrapper.addLiDARScanRisley(
689 self._cloud_ptr, prism_lists, float(refractive_index_air), float(pulse_rate_hz),
690 t_list, pos_list, rot_list, bool(rot_is_quaternion),
691 exit_diameter, beam_divergence,
692 lever_list, boresight_list, column_format,
693 range_noise_stddev, angle_noise_stddev, float(t0)
694 )
695
696 def getScanCount(self) -> int:
697 """Get total number of scans in the cloud"""
698 return lidar_wrapper.getLiDARScanCount(self._cloud_ptr)
700 def getScanOrigin(self, scanID: int) -> vec3:
701 """Get origin of a specific scan"""
702 if scanID < 0:
703 raise ValueError("Scan ID must be non-negative")
704 origin_list = lidar_wrapper.getLiDARScanOrigin(self._cloud_ptr, scanID)
705 return vec3(*origin_list)
706
707 def getScanSizeTheta(self, scanID: int) -> int:
708 """Get number of zenith scan points for a scan"""
709 if scanID < 0:
710 raise ValueError("Scan ID must be non-negative")
711 return lidar_wrapper.getLiDARScanSizeTheta(self._cloud_ptr, scanID)
712
713 def getScanSizePhi(self, scanID: int) -> int:
714 """Get number of azimuthal scan points for a scan"""
715 if scanID < 0:
716 raise ValueError("Scan ID must be non-negative")
717 return lidar_wrapper.getLiDARScanSizePhi(self._cloud_ptr, scanID)
718
719 def getScanRangeNoiseStdDev(self, scanID: int) -> float:
720 """Get the range (along-beam) measurement noise standard deviation for a scan (meters).
721
722 Returns the value supplied to addScan() as ``range_noise_stddev`` (0.0 if disabled).
723 """
724 if scanID < 0:
725 raise ValueError("Scan ID must be non-negative")
726 return lidar_wrapper.getLiDARScanRangeNoiseStdDev(self._cloud_ptr, scanID)
727
728 def getScanAngleNoiseStdDev(self, scanID: int) -> float:
729 """Get the angular (beam-pointing) jitter standard deviation for a scan (radians).
730
731 Returns the value supplied to addScan() as ``angle_noise_stddev`` (0.0 if disabled).
732 """
733 if scanID < 0:
734 raise ValueError("Scan ID must be non-negative")
735 return lidar_wrapper.getLiDARScanAngleNoiseStdDev(self._cloud_ptr, scanID)
736
737 def getScanTiltRoll(self, scanID: int) -> float:
738 """Get the global scanner tilt roll angle for a scan (radians; 0.0 if level)."""
739 if scanID < 0:
740 raise ValueError("Scan ID must be non-negative")
741 return lidar_wrapper.getLiDARScanTiltRoll(self._cloud_ptr, scanID)
742
743 def getScanTiltPitch(self, scanID: int) -> float:
744 """Get the global scanner tilt pitch angle for a scan (radians; 0.0 if level)."""
745 if scanID < 0:
746 raise ValueError("Scan ID must be non-negative")
747 return lidar_wrapper.getLiDARScanTiltPitch(self._cloud_ptr, scanID)
748
749 def getScanAzimuthOffset(self, scanID: int) -> float:
750 """Get the global scanner azimuth (heading) offset for a scan (radians; 0.0 if none)."""
751 if scanID < 0:
752 raise ValueError("Scan ID must be non-negative")
753 return lidar_wrapper.getLiDARScanAzimuthOffset(self._cloud_ptr, scanID)
754
755 def getScanPattern(self, scanID: int) -> int:
756 """Get the scan pattern for a scan.
757
758 Returns an integer: 0 = raster (uniform angular grid), 1 = spinning multibeam
759 (rotating multi-channel sensor), 2 = Risley-prism (Livox-style rosette). Compare against
760 ``ScanPattern.RASTER`` / ``ScanPattern.SPINNING_MULTIBEAM`` / ``ScanPattern.RISLEY_PRISM``.
761 """
762 if scanID < 0:
763 raise ValueError("Scan ID must be non-negative")
764 return lidar_wrapper.getLiDARScanPattern(self._cloud_ptr, scanID)
765
766 def getScanBeamZenithAngles(self, scanID: int) -> List[float]:
767 """Get the per-channel beam zenith angles (radians) for a multibeam scan.
768
769 Returns an empty list for a raster scan.
770 """
771 if scanID < 0:
772 raise ValueError("Scan ID must be non-negative")
773 return lidar_wrapper.getLiDARScanBeamZenithAngles(self._cloud_ptr, scanID)
774
775 def getScanMode(self, scanID: int) -> ScanMode:
776 """Get the high-level acquisition mode of a scan as a :class:`ScanMode`.
777
778 STATIC_RASTER (fixed-origin grid), MOVING_RASTER (fan swept along a trajectory),
779 SPINNING (continuously-rotating multi-channel sensor), or RISLEY_PRISM (Livox-style rosette).
780 """
781 if scanID < 0:
782 raise ValueError("Scan ID must be non-negative")
783 return ScanMode(lidar_wrapper.getLiDARScanMode(self._cloud_ptr, scanID))
784
785 def getScanStepsPerRev(self, scanID: int) -> int:
786 """Get the number of azimuth firing steps per revolution (spinning scans; 0 otherwise)."""
787 if scanID < 0:
788 raise ValueError("Scan ID must be non-negative")
789 return lidar_wrapper.getLiDARScanStepsPerRev(self._cloud_ptr, scanID)
790
791 def getScanRotationRate(self, scanID: int) -> float:
792 """Get the sensor-head rotation rate in revolutions/second (spinning scans; 0 otherwise)."""
793 if scanID < 0:
794 raise ValueError("Scan ID must be non-negative")
795 return lidar_wrapper.getLiDARScanRotationRate(self._cloud_ptr, scanID)
796
797 def getScanRevolutions(self, scanID: int) -> float:
798 """Get the number of revolutions the sensor head made (spinning scans; 0 otherwise)."""
799 if scanID < 0:
800 raise ValueError("Scan ID must be non-negative")
801 return lidar_wrapper.getLiDARScanRevolutions(self._cloud_ptr, scanID)
802
803 def getScanRisleyPrisms(self, scanID: int) -> List[RisleyPrism]:
804 """Get the rotating wedge prisms of a Risley-prism scan as a list of :class:`RisleyPrism`.
805
806 Returns the prism stack in beam-traversal order (empty for non-Risley scans).
807 """
808 if scanID < 0:
809 raise ValueError("Scan ID must be non-negative")
810 raw = lidar_wrapper.getLiDARScanRisleyPrisms(self._cloud_ptr, scanID)
811 return [RisleyPrism(p[0], p[1], p[2], p[3]) for p in raw]
812
813 def getScanRisleyRefractiveIndexAir(self, scanID: int) -> float:
814 """Get the refractive index of the medium surrounding a Risley scan's prisms (1.0 for non-Risley)."""
815 if scanID < 0:
816 raise ValueError("Scan ID must be non-negative")
817 return lidar_wrapper.getLiDARScanRisleyRefractiveIndexAir(self._cloud_ptr, scanID)
818
819 def getScanReturnMode(self, scanID: int) -> ReturnMode:
820 """Get the return-reporting mode of a scan as a :class:`ReturnMode` (MULTI or SINGLE)."""
821 if scanID < 0:
822 raise ValueError("Scan ID must be non-negative")
823 return ReturnMode(lidar_wrapper.getLiDARScanReturnMode(self._cloud_ptr, scanID))
824
825 def setScanReturnMode(self, scanID: int, return_mode: Union[ReturnMode, int]):
826 """Set the return-reporting mode of a scan (ReturnMode.MULTI or ReturnMode.SINGLE).
827
828 Only affects analytic-waveform synthetic scans (more than one ray per pulse).
829 """
830 if scanID < 0:
831 raise ValueError("Scan ID must be non-negative")
832 lidar_wrapper.setLiDARScanReturnMode(self._cloud_ptr, scanID, int(return_mode))
833
834 def getScanSingleReturnSelection(self, scanID: int) -> SingleReturnSelection:
835 """Get the single/limited-return selection policy as a :class:`SingleReturnSelection`."""
836 if scanID < 0:
837 raise ValueError("Scan ID must be non-negative")
838 return SingleReturnSelection(
839 lidar_wrapper.getLiDARScanSingleReturnSelection(self._cloud_ptr, scanID))
840
841 def setScanSingleReturnSelection(self, scanID: int, selection: Union[SingleReturnSelection, int]):
842 """Set the single/limited-return selection policy (STRONGEST, FIRST, LAST, or STRONGEST_PLUS_LAST).
843
844 Used when the scan's return mode is SINGLE and a pulse resolves more returns than maxReturns.
845 STRONGEST_PLUS_LAST is a dual-return mode that intrinsically yields 1 or 2 returns and
846 ignores maxReturns.
847 """
848 if scanID < 0:
849 raise ValueError("Scan ID must be non-negative")
850 lidar_wrapper.setLiDARScanSingleReturnSelection(self._cloud_ptr, scanID, int(selection))
851
852 def getScanMaxReturns(self, scanID: int) -> int:
853 """Get the maximum returns per pulse used in single/limited-return mode (1 = single, N = N-return)."""
854 if scanID < 0:
855 raise ValueError("Scan ID must be non-negative")
856 return lidar_wrapper.getLiDARScanMaxReturns(self._cloud_ptr, scanID)
857
858 def setScanMaxReturns(self, scanID: int, max_returns: int):
859 """Set the maximum returns per pulse used in single/limited-return mode (must be >= 1)."""
860 if scanID < 0:
861 raise ValueError("Scan ID must be non-negative")
862 if max_returns < 1:
863 raise ValueError("max_returns must be >= 1")
864 lidar_wrapper.setLiDARScanMaxReturns(self._cloud_ptr, scanID, int(max_returns))
865
866 def setSyntheticScanMemoryBudget(self, bytes: int):
867 """Set the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
868
869 :meth:`syntheticScan` fans each pulse into ``rays_per_pulse`` sub-rays; for a
870 large scan the simultaneously-traced sub-rays can demand many gigabytes if
871 traced in one batch. This caps the live trace scratch buffers, so the per-scan
872 beam fan-out is processed in chunks sized to stay near this budget regardless of
873 scan resolution. It bounds only the transient buffers, not the output cloud.
874
875 If never called, the budget is automatic and path-dependent (8 GiB on a GPU
876 build, 4 GiB otherwise). Call this to override that with a fixed cap, typically
877 to lower peak memory on a constrained host.
878
879 Args:
880 bytes: Soft cap in bytes on the live ray-tracing scratch buffers. Must be > 0.
881 """
882 if bytes <= 0:
883 raise ValueError("memory budget must be greater than zero")
884 lidar_wrapper.setLiDARSyntheticScanMemoryBudget(self._cloud_ptr, int(bytes))
885
886 def getSyntheticScanMemoryBudget(self) -> int:
887 """Get the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
888
889 Returns the explicitly configured budget set via :meth:`setSyntheticScanMemoryBudget`, or
890 0 if using the automatic path-dependent default (8 GiB on a GPU build, 4 GiB otherwise).
891 """
892 return lidar_wrapper.getLiDARSyntheticScanMemoryBudget(self._cloud_ptr)
894 def getScanPulseWidth(self, scanID: int) -> float:
895 """Get the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
896 if scanID < 0:
897 raise ValueError("Scan ID must be non-negative")
898 return lidar_wrapper.getLiDARScanPulseWidth(self._cloud_ptr, scanID)
899
900 def setScanPulseWidth(self, scanID: int, pulse_width: float):
901 """Set the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
902 if scanID < 0:
903 raise ValueError("Scan ID must be non-negative")
904 if pulse_width < 0:
905 raise ValueError("pulse_width must be non-negative")
906 lidar_wrapper.setLiDARScanPulseWidth(self._cloud_ptr, scanID, float(pulse_width))
907
908 def getScanDetectionThreshold(self, scanID: int) -> float:
909 """Get the detection threshold (energy fraction, noise floor) of a scan."""
910 if scanID < 0:
911 raise ValueError("Scan ID must be non-negative")
912 return lidar_wrapper.getLiDARScanDetectionThreshold(self._cloud_ptr, scanID)
913
914 def setScanDetectionThreshold(self, scanID: int, detection_threshold: float):
915 """Set the detection threshold (energy fraction, noise floor) of a scan."""
916 if scanID < 0:
917 raise ValueError("Scan ID must be non-negative")
918 if detection_threshold < 0:
919 raise ValueError("detection_threshold must be non-negative")
920 lidar_wrapper.setLiDARScanDetectionThreshold(self._cloud_ptr, scanID, float(detection_threshold))
921
922 def addHitPoint(self, scanID: int,
923 xyz: Union[vec3, List[float], Tuple[float, float, float]],
924 direction: Union[vec3, SphericalCoord, List[float], Tuple[float, float]],
925 color: Optional[Union[RGBcolor, List[float], Tuple[float, float, float]]] = None):
926 """
927 Add a hit point to the point cloud.
928
929 Args:
930 scanID: Scan ID this hit belongs to
931 xyz: Hit point coordinates (vec3 or 3-element list)
932 direction: Ray direction (vec3/SphericalCoord or 2-3 element list)
933 color: Optional RGB color (RGBcolor or 3-element list)
934 """
935 # Convert xyz to list
936 if isinstance(xyz, (list, tuple)):
937 if len(xyz) != 3:
938 raise ValueError("XYZ must have 3 elements")
939 xyz_list = list(xyz)
940 elif hasattr(xyz, 'x'):
941 xyz_list = [xyz.x, xyz.y, xyz.z]
942 else:
943 raise ValueError("XYZ must be vec3 or 3-element list/tuple")
944
945 # Convert direction to list
946 if isinstance(direction, (list, tuple)):
947 if len(direction) < 2:
948 raise ValueError("Direction must have at least 2 elements [radius, elevation]")
949 direction_list = list(direction)
950 elif hasattr(direction, 'radius'): # SphericalCoord
951 direction_list = [direction.radius, direction.elevation, direction.azimuth]
952 elif hasattr(direction, 'x'): # vec3
953 direction_list = [direction.x, direction.y, direction.z]
954 else:
955 raise ValueError("Direction must be vec3/SphericalCoord or 2-3 element list")
956
957 # Add with or without color
958 if color is not None:
959 if isinstance(color, (list, tuple)):
960 if len(color) != 3:
961 raise ValueError("Color must have 3 elements [r, g, b]")
962 color_list = list(color)
963 elif hasattr(color, 'r'):
964 color_list = [color.r, color.g, color.b]
965 else:
966 raise ValueError("Color must be RGBcolor or 3-element list")
967
968 lidar_wrapper.addLiDARHitPointRGB(self._cloud_ptr, scanID, xyz_list, direction_list, color_list)
969 else:
970 lidar_wrapper.addLiDARHitPoint(self._cloud_ptr, scanID, xyz_list, direction_list)
971
972 def addHitPoints(self, scanID: int, xyz_array, direction_array, color_array=None):
973 """
974 Add many hit points to the point cloud in a single bulk call.
975
976 This skips the per-point Python loop by passing contiguous buffers
977 straight to the native library in one FFI call.
978
979 Args:
980 scanID: Scan ID these hits belong to
981 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
982 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth]
983 (azimuth is currently ignored, matching addHitPoint)
984 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
985 """
986 import numpy as np
988 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
989 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
990
991 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
992 raise ValueError("xyz_array must have shape (N, 3)")
993 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
994 raise ValueError("direction_array must have shape (N, 3)")
995
996 count = xyz_array.shape[0]
997 if direction_array.shape[0] != count:
998 raise ValueError("xyz_array and direction_array must have the same number of rows")
999
1000 if color_array is not None:
1001 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
1002 if color_array.ndim != 2 or color_array.shape[1] != 3:
1003 raise ValueError("color_array must have shape (N, 3)")
1004 if color_array.shape[0] != count:
1005 raise ValueError("color_array must have the same number of rows as xyz_array")
1006
1007 lidar_wrapper.addLiDARHitPoints(self._cloud_ptr, scanID,
1008 xyz_array, direction_array, count, color_array)
1009
1010 def addHitPointsWithData(self, scanID: int, xyz_array, direction_array,
1011 data_labels=None, data_values=None, color_array=None):
1012 """
1013 Add many hit points carrying a per-hit data map in a single bulk call.
1014
1015 Like addHitPoints, but also populates each hit's named-scalar data map —
1016 the in-memory equivalent of what the ASCII loader does for non-standard
1017 columns. This is the path multi-return LAD needs (timestamp/target_index/
1018 target_count land in the map so gapfillMisses() can group beams by pulse).
1019
1020 Args:
1021 scanID: Scan ID these hits belong to (the scan must already exist)
1022 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
1023 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth]. Pass cart2sphere(xyz - origin) to match loadASCIIFile; the full SphericalCoord (incl. radius) is used.
1024 data_labels: Optional list of data-map key names (length k)
1025 data_values: Optional (N, k) values for those keys (float64)
1026 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
1027 """
1028 import numpy as np
1029
1030 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
1031 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
1032
1033 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
1034 raise ValueError("xyz_array must have shape (N, 3)")
1035 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
1036 raise ValueError("direction_array must have shape (N, 3)")
1037
1038 count = xyz_array.shape[0]
1039 if direction_array.shape[0] != count:
1040 raise ValueError("xyz_array and direction_array must have the same number of rows")
1041
1042 labels = list(data_labels or [])
1043 if labels:
1044 data_values = np.ascontiguousarray(data_values, dtype=np.float64)
1045 if data_values.ndim != 2 or data_values.shape != (count, len(labels)):
1046 raise ValueError("data_values must have shape (N, len(data_labels))")
1047 else:
1048 data_values = None
1049
1050 if color_array is not None:
1051 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
1052 if color_array.ndim != 2 or color_array.shape[1] != 3:
1053 raise ValueError("color_array must have shape (N, 3)")
1054 if color_array.shape[0] != count:
1055 raise ValueError("color_array must have the same number of rows as xyz_array")
1056
1057 lidar_wrapper.addLiDARHitPointsWithData(
1058 self._cloud_ptr, scanID, xyz_array, direction_array, count,
1059 color_array, labels, data_values)
1060
1061 def getHitCount(self) -> int:
1062 """Get total number of hit points in cloud"""
1063 return lidar_wrapper.getLiDARHitCount(self._cloud_ptr)
1065 def getHitXYZ(self, index: int) -> vec3:
1066 """Get coordinates of a hit point"""
1067 if index < 0:
1068 raise ValueError("Index must be non-negative")
1069 xyz_list = lidar_wrapper.getLiDARHitXYZ(self._cloud_ptr, index)
1070 return vec3(*xyz_list)
1071
1072 def getHitOrigin(self, index: int) -> vec3:
1073 """Get the (x,y,z) beam-emission origin of a hit point.
1074
1075 For moving-platform scans (see :meth:`addScanMoving`) this is the per-pulse emission origin
1076 recorded on the hit; for static scans it falls back to the single scan origin of the hit's scan.
1077 """
1078 if index < 0:
1079 raise ValueError("Index must be non-negative")
1080 xyz_list = lidar_wrapper.getLiDARHitOrigin(self._cloud_ptr, index)
1081 return vec3(*xyz_list)
1082
1083 def getHitRaydir(self, index: int) -> SphericalCoord:
1084 """Get ray direction of a hit point"""
1085 if index < 0:
1086 raise ValueError("Index must be non-negative")
1087 direction_list = lidar_wrapper.getLiDARHitRaydir(self._cloud_ptr, index)
1088 # direction_list is [radius, elevation, azimuth]; preserve azimuth (was previously dropped).
1089 return SphericalCoord(direction_list[0], direction_list[1], direction_list[2])
1090
1091 def getHitColor(self, index: int) -> RGBcolor:
1092 """Get color of a hit point"""
1093 if index < 0:
1094 raise ValueError("Index must be non-negative")
1095 color_list = lidar_wrapper.getLiDARHitColor(self._cloud_ptr, index)
1096 return RGBcolor(*color_list)
1097
1098 def getHitScanID(self, index: int) -> int:
1099 """Get the scan ID a hit point belongs to"""
1100 if index < 0:
1101 raise ValueError("Index must be non-negative")
1102 return lidar_wrapper.getLiDARHitScanID(self._cloud_ptr, index)
1103
1104 def doesHitDataExist(self, index: int, label: str) -> bool:
1105 """Check whether a named scalar data value exists for a hit point.
1106
1107 Per-hit data computed by syntheticScan includes 'intensity', 'distance',
1108 'timestamp', 'target_index', 'target_count', 'deviation', 'nRaysHit', plus any
1109 primitive-data labels listed in the scan's column_format.
1110 """
1111 if index < 0:
1112 raise ValueError("Index must be non-negative")
1113 return lidar_wrapper.doesLiDARHitDataExist(self._cloud_ptr, index, label)
1114
1115 def getHitData(self, index: int, label: str) -> float:
1116 """Get a named scalar data value for a hit point.
1117
1118 Raises HeliosError if the label does not exist for this hit; guard with
1119 doesHitDataExist() when unsure.
1120 """
1121 if index < 0:
1122 raise ValueError("Index must be non-negative")
1123 return lidar_wrapper.getLiDARHitData(self._cloud_ptr, index, label)
1124
1125 def getHitDataAll(self, label: str) -> List[float]:
1126 """Bulk-export a named scalar data value for all hits in a single FFI call.
1127
1128 Returns a list of length getHitCount(); entries are NaN where the label is
1129 absent for that hit. Much faster than looping getHitData() for large clouds.
1130
1131 Note: values are returned at float32 precision (vs. getHitData(), which returns
1132 full float64). Use getHitData() per-hit if full precision is required.
1133 """
1134 n = self.getHitCount()
1135 if n == 0:
1136 return []
1137 return lidar_wrapper.getLiDARHitData_all(self._cloud_ptr, label, n)
1138
1139 def getHitsXYZRGB(self) -> Tuple[List[vec3], List[RGBcolor]]:
1140 """Bulk-export coordinates and colors for all hits in a single FFI call.
1141
1142 Returns (positions, colors) where positions is a list of vec3 and colors a list
1143 of RGBcolor, each of length getHitCount(). Much faster than looping
1144 getHitXYZ()/getHitColor() for large clouds.
1145 """
1146 n = self.getHitCount()
1147 if n == 0:
1148 return [], []
1149 xyz_flat, rgb_flat = lidar_wrapper.getLiDARHitsXYZRGB_all(self._cloud_ptr, n)
1150 positions = [vec3(xyz_flat[3 * i], xyz_flat[3 * i + 1], xyz_flat[3 * i + 2]) for i in range(n)]
1151 colors = [RGBcolor(rgb_flat[3 * i], rgb_flat[3 * i + 1], rgb_flat[3 * i + 2]) for i in range(n)]
1152 return positions, colors
1153
1154 # ---- Bulk numpy exports (single FFI call each; no per-hit Python loop) ----
1155 # These power the synthetic-scan fast path: extracting a million-hit cloud via
1156 # the per-index getters (getHitXYZ/getHitColor/getHitScanID/...) costs tens of
1157 # millions of FFI crossings, which dominated scan time. The *Array methods pull
1158 # each quantity in one contiguous copy.
1159
1160 def getHitsXYZRGBArrays(self):
1161 """Bulk-export hit coordinates + colors as numpy arrays.
1162
1163 Returns (xyz, rgb), each (getHitCount(), 3) float32. Empty (0,3) arrays
1164 when there are no hits.
1165 """
1166 import numpy as np
1167 n = self.getHitCount()
1168 if n == 0:
1169 return np.empty((0, 3), np.float32), np.empty((0, 3), np.float32)
1170 return lidar_wrapper.getLiDARHitsXYZRGB_all_np(self._cloud_ptr, n)
1171
1172 def getHitDataArray(self, label: str):
1173 """Bulk-export a named scalar field as an (getHitCount(),) float32 array,
1174 NaN where the label is absent for a hit."""
1175 import numpy as np
1176 n = self.getHitCount()
1177 if n == 0:
1178 return np.empty((0,), np.float32)
1179 return lidar_wrapper.getLiDARHitData_all_np(self._cloud_ptr, label, n)
1180
1181 def getHitDataColumn(self, label: str, absent_value: float = -9999.0) -> List[float]:
1182 """Bulk-export a named scalar column via the native cache-linear columnar path.
1183
1184 Faster than :meth:`getHitDataAll` for whole-field reads (a single cache-linear pass over
1185 the contiguous native column rather than per-hit tree lookups), and returns full float64
1186 precision. Entries are ``absent_value`` where the label is absent for a hit. Returns a list
1187 of length getHitCount().
1188 """
1189 n = self.getHitCount()
1190 if n == 0:
1191 return []
1192 return lidar_wrapper.getLiDARHitDataColumn(self._cloud_ptr, label, n, absent_value)
1193
1194 def getHitDataColumnIndex(self, label: str) -> int:
1195 """Get the internal column slot index for a hit-data label.
1196
1197 Per-hit scalar data is stored column-wise; this resolves a label to its column slot for
1198 repeated bulk access without re-resolving the label by string. Returns -1 if the label has
1199 never been set on any hit.
1200 """
1201 if not isinstance(label, str):
1202 raise TypeError(f"label must be a str, got {type(label).__name__}")
1203 return lidar_wrapper.getLiDARHitDataColumnIndex(self._cloud_ptr, label)
1204
1205 def getHitDataColumnArray(self, label: str, absent_value: float = -9999.0):
1206 """Bulk-export a named scalar column as an (getHitCount(),) float64 numpy array
1207 via the columnar path (``absent_value`` where the label is absent for a hit)."""
1208 import numpy as np
1209 n = self.getHitCount()
1210 if n == 0:
1211 return np.empty((0,), np.float64)
1212 return lidar_wrapper.getLiDARHitDataColumn_np(self._cloud_ptr, label, n, absent_value)
1213
1214 def getHitScanIDArray(self):
1215 """Bulk-export the scan ID of every hit as an (getHitCount(),) int32 array."""
1216 import numpy as np
1217 n = self.getHitCount()
1218 if n == 0:
1219 return np.empty((0,), np.int32)
1220 return lidar_wrapper.getLiDARHitScanID_all(self._cloud_ptr, n)
1221
1222 def getHitMissArray(self):
1223 """Bulk-export the miss flag of every hit as an (getHitCount(),) int32
1224 array (1 == sky/miss, 0 == real surface return)."""
1225 import numpy as np
1226 n = self.getHitCount()
1227 if n == 0:
1228 return np.empty((0,), np.int32)
1229 return lidar_wrapper.isLiDARHitMiss_all(self._cloud_ptr, n)
1230
1231 def deleteHitPoint(self, index: int):
1232 """Delete a hit point from the cloud"""
1233 if index < 0:
1234 raise ValueError("Index must be non-negative")
1235 lidar_wrapper.deleteLiDARHitPoint(self._cloud_ptr, index)
1236
1237 def isHitMiss(self, index: int) -> bool:
1238 """Return True if a hit is a "miss" (a fired pulse that returned nothing).
1239
1240 Misses are the transmitted beams that form the denominator of the per-voxel
1241 transmission probability used by :meth:`calculateLeafArea`. They are produced by
1242 ``syntheticScan(..., record_misses=True)`` and by :meth:`gapfillMisses`.
1243 """
1244 if index < 0:
1245 raise ValueError("Index must be non-negative")
1246 return lidar_wrapper.isLiDARHitMiss(self._cloud_ptr, index)
1247
1248 def hasMisses(self) -> bool:
1249 """Return True if the cloud contains at least one miss.
1250
1251 :meth:`calculateLeafArea` requires misses and fails fast without them.
1252 """
1253 return lidar_wrapper.lidarHasMisses(self._cloud_ptr)
1255 def isMultiReturnData(self) -> bool:
1256 """Return True if the cloud contains multi-return data.
1257
1258 Multi-return data is data in which a single laser pulse produced more than one
1259 recorded return (any hit with ``target_count`` greater than 1). This is a behavioral
1260 switch, not just a descriptive property: :meth:`triangulateHitPoints` branches on
1261 it, triangulating first returns only (with an adaptive separation filter) for
1262 multi-return data and treating every return as an independent single return
1263 otherwise. The two branches can differ substantially in reconstructed surface
1264 area, so a cloud assembled by hand (for example through :meth:`addHitPoints` or
1265 the native ASCII loader) can use this to confirm which one will run.
1266
1267 Multi-return data must also carry the ``timestamp`` and ``target_index`` hit-data
1268 fields, which triangulation needs to group returns into beams and select first
1269 returns. If ``target_count > 1`` is found but either field is absent, this raises
1270 rather than reporting an answer the rest of the pipeline cannot act on.
1271
1272 Requires helios-core v1.3.85 or newer.
1273
1274 Raises:
1275 HeliosError: If multi-return data is present but ``timestamp`` or
1276 ``target_index`` is missing
1277 RuntimeError: If the native library predates helios-core v1.3.85
1278 """
1279 return lidar_wrapper.lidarIsMultiReturnData(self._cloud_ptr)
1281 @staticmethod
1282 def getMissDistance() -> float:
1283 """Return the LIDAR_MISS_DISTANCE constant (meters): the distance at which a
1284 miss point is placed along its beam."""
1285 return lidar_wrapper.getLiDARMissDistance()
1287 def coordinateShift(self, shift: Union[vec3, List[float], Tuple[float, float, float]]):
1288 """
1289 Translate all hit points by a shift vector.
1290
1291 Args:
1292 shift: Translation vector (vec3 or 3-element list)
1293 """
1294 if isinstance(shift, (list, tuple)):
1295 if len(shift) != 3:
1296 raise ValueError("Shift must have 3 elements [x, y, z]")
1297 shift_list = list(shift)
1298 elif hasattr(shift, 'x'):
1299 shift_list = [shift.x, shift.y, shift.z]
1300 else:
1301 raise ValueError("Shift must be vec3 or 3-element list/tuple")
1302
1303 lidar_wrapper.lidarCoordinateShift(self._cloud_ptr, shift_list)
1304
1305 def coordinateRotation(self, rotation: Union[SphericalCoord, List[float], Tuple[float, float]]):
1306 """
1307 Rotate all hit points by spherical rotation angles.
1308
1309 Args:
1310 rotation: Rotation angles (SphericalCoord or 2-3 element list)
1311 """
1312 if isinstance(rotation, (list, tuple)):
1313 if len(rotation) < 2:
1314 raise ValueError("Rotation must have at least 2 elements [radius, elevation]")
1315 rotation_list = list(rotation)
1316 elif hasattr(rotation, 'radius'):
1317 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1318 else:
1319 raise ValueError("Rotation must be SphericalCoord or 2-3 element list")
1320
1321 lidar_wrapper.lidarCoordinateRotation(self._cloud_ptr, rotation_list)
1322
1323 def triangulateHitPoints(self, Lmax: float, max_aspect_ratio: float = 4.0):
1324 """
1325 Generate triangle mesh from hit points using Delaunay triangulation.
1326
1327 Args:
1328 Lmax: Maximum triangle edge length
1329 max_aspect_ratio: Maximum triangle aspect ratio (default 4.0)
1330 """
1331 validate_positive_value(Lmax, 'Lmax', 'triangulateHitPoints')
1332 validate_positive_value(max_aspect_ratio, 'max_aspect_ratio', 'triangulateHitPoints')
1333 lidar_wrapper.lidarTriangulateHitPoints(self._cloud_ptr, Lmax, max_aspect_ratio)
1334 # A triangulation sink runs during the call above. An exception raised inside it was
1335 # swallowed by ctypes and stashed; surface it now rather than reporting success.
1336 self._raise_pending_callback_error()
1337
1338 def getTriangleCount(self) -> int:
1339 """Get number of triangles in the mesh
1340
1341 .. warning::
1342 Reports zero once a run's triangles have been streamed to a sink registered with
1343 :meth:`setTriangulationSink` -- the mesh is released rather than stored. Clear the
1344 sink before triangulating if you need the stored mesh.
1345 """
1346 return lidar_wrapper.getLiDARTriangleCount(self._cloud_ptr)
1348 def getTriangulationStats(self) -> dict:
1349 """Filter diagnostics from the most recent triangulateHitPoints() call.
1350
1351 Returns a dict::
1352
1353 {"candidates", "dropped_lmax", "dropped_aspect", "dropped_degenerate"}
1354
1355 Each dropped triangle is attributed to one primary reason (Lmax, then
1356 aspect, then degenerate), so ``candidates == getTriangleCount() +
1357 dropped_lmax + dropped_aspect + dropped_degenerate``. All zero if
1358 triangulation has not been run. Use this to tell whether an empty or
1359 sparse mesh is data-limited (few candidates) or filter-limited (many
1360 candidates dropped by Lmax/aspect).
1361 """
1362 return lidar_wrapper.getLiDARTriangulationStats(self._cloud_ptr)
1364 def getTriangleVerticesAll(self):
1365 """Bulk-export every triangle's vertices and source scan in one call.
1366
1367 Returns (xyz_flat, scan_ids): xyz_flat is a (T*9,) float32 array laid out
1368 [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per triangle, scan_ids is a (T,)
1369 int32 array. Avoids the Context round-trip and the per-triangle
1370 getPrimitiveVertices loop.
1371
1372 .. warning::
1373 Raises once a run's triangles have been streamed to a sink registered with
1374 :meth:`setTriangulationSink` -- the mesh is released rather than stored, and the
1375 native consumers refuse to operate on an empty mesh. Clear the sink before
1376 triangulating if you need the stored mesh.
1377 """
1378 return lidar_wrapper.getLiDARTriangleVertices_all(
1379 self._cloud_ptr, self.getTriangleCount())
1380
1381 def setExternalTriangulation(self, vertices, scan_ids):
1382 """Replace the internal triangulation with an externally-supplied mesh.
1383
1384 Bypasses the internal Delaunay triangulation so a mesh produced elsewhere
1385 (a re-used Helios triangulation, or a per-scan open3d Ball-Pivot mesh) can
1386 drive leaf-area inversion without a recompute. After this call,
1387 ``calculateLeafArea()`` runs unchanged.
1388
1389 Args:
1390 vertices: Triangle vertices in world coordinates, accepted as a
1391 (T, 9) array laid out [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per
1392 triangle, a (T, 3, 3) array, or a flat (T*9,) array -- the same
1393 layout :meth:`getTriangleVerticesAll` exports, so a Helios mesh
1394 round-trips directly.
1395 scan_ids: Source scan index for each triangle, shape (T,). Required;
1396 every entry must be a valid scan index (see :meth:`addScan`),
1397 since the leaf-angle G(theta) term needs each triangle's ray
1398 direction. A merged mesh with no scan association is not valid.
1399
1400 A grid must already be defined (see :meth:`addGrid`).
1401 """
1402 import numpy as np
1403 verts = np.ascontiguousarray(vertices, dtype=np.float32).reshape(-1)
1404 if verts.size % 9 != 0:
1405 raise ValueError(
1406 f"vertices has {verts.size} floats, must be a multiple of 9 (9 per triangle)")
1407 tri_count = verts.size // 9
1408
1409 scans = np.ascontiguousarray(scan_ids, dtype=np.int32).reshape(-1)
1410 if scans.size != tri_count:
1411 raise ValueError(
1412 f"scan_ids has {scans.size} entries, expected {tri_count} (one per triangle)")
1413
1414 lidar_wrapper.lidarSetExternalTriangulation(
1415 self._cloud_ptr, verts, scans, tri_count)
1416
1417 def distanceFilter(self, maxdistance: float):
1418 """Filter hit points by maximum distance from scanner"""
1419 validate_positive_value(maxdistance, 'maxdistance', 'distanceFilter')
1420 lidar_wrapper.lidarDistanceFilter(self._cloud_ptr, maxdistance)
1421
1422 def reflectanceFilter(self, minreflectance: float):
1423 """Filter hit points by minimum reflectance value"""
1424 lidar_wrapper.lidarReflectanceFilter(self._cloud_ptr, minreflectance)
1426 def firstHitFilter(self):
1427 """Keep only first return hit points"""
1428 lidar_wrapper.lidarFirstHitFilter(self._cloud_ptr)
1430 def lastHitFilter(self):
1431 """Keep only last return hit points"""
1432 lidar_wrapper.lidarLastHitFilter(self._cloud_ptr)
1434 def exportPointCloud(self, filename: str, write_header: bool = True):
1435 """Export point cloud to ASCII file.
1436
1437 Args:
1438 filename: Output file path.
1439 write_header: If True (default), prepend a ``#``-prefixed comment line listing the
1440 column field names (CloudCompare convention). The loader skips ``#``-prefixed
1441 lines, so headered files round-trip through ``loadXML()``. Set False for a
1442 bare data file.
1443 """
1444 if not filename:
1445 raise ValueError("Filename cannot be empty")
1446 lidar_wrapper.exportLiDARPointCloud(self._cloud_ptr, filename, write_header)
1447
1448 def exportLeafAreaUncertainty(self, filename: str):
1449 """Export per-voxel leaf-area sampling uncertainty to a self-describing ASCII file.
1450
1451 The file has a ``#``-prefixed header and one row per grid cell:
1452 ``cell_index leaf_area beam_count I_rdi LAD_std_error ci_valid``. Requires that
1453 :meth:`calculateLeafArea` has been run with an ``element_width`` (the uncertainty
1454 overload).
1455 """
1456 if not filename:
1457 raise ValueError("Filename cannot be empty")
1458 lidar_wrapper.exportLiDARLeafAreaUncertainty(self._cloud_ptr, filename)
1459
1460 def exportScans(self, filename: str):
1461 """Export all scans to an XML metadata file plus one ASCII data file per scan.
1462
1463 Args:
1464 filename: Path of the XML metadata file to write (e.g. "output/scans.xml").
1465 One ASCII data file is auto-generated per scan, named by stripping the XML
1466 extension and appending "_<scanID>.xyz" (e.g. "output/scans_0.xyz"). The
1467 resulting XML can be re-loaded with loadXML() from the same working directory.
1468 """
1469 if not filename:
1470 raise ValueError("Filename cannot be empty")
1471 lidar_wrapper.exportLiDARScans(self._cloud_ptr, filename)
1472
1473 def loadXML(self, filename: str):
1474 """Load scan metadata from XML file"""
1475 if not filename:
1476 raise ValueError("Filename cannot be empty")
1477 lidar_wrapper.loadLiDARXML(self._cloud_ptr, filename)
1478
1479 def disableMessages(self):
1480 """Disable console output messages"""
1481 lidar_wrapper.lidarDisableMessages(self._cloud_ptr)
1483 def enableMessages(self):
1484 """Enable console output messages"""
1485 lidar_wrapper.lidarEnableMessages(self._cloud_ptr)
1487 def addGrid(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1488 size: Union[vec3, List[float], Tuple[float, float, float]],
1489 ndiv: Union[List[int], Tuple[int, int, int]],
1490 rotation: float = 0.0,
1491 column_z_offsets: Optional[Union[List[float], Tuple[float, ...]]] = None):
1492 """
1493 Add a rectangular grid of voxel cells.
1494
1495 Args:
1496 center: Grid center position (vec3 or 3-element list)
1497 size: Grid dimensions [x, y, z] (vec3 or 3-element list)
1498 ndiv: Number of divisions [nx, ny, nz] (3-element list)
1499 rotation: Azimuthal rotation angle (degrees, default 0.0)
1500 column_z_offsets: Optional per-(x,y)-column vertical offset for terrain
1501 following, row-major as ``[j*ndiv[0] + i]`` with length
1502 ``ndiv[0]*ndiv[1]``. Each vertical column of voxels is shifted in z by
1503 its column's offset so the grid can track an external terrain surface
1504 (e.g. a DEM). ``None`` (the default) builds an axis-regular grid.
1505
1506 Note:
1507 ``rotation`` is in **degrees** here, matching the native ``addGrid()``.
1508 :meth:`addGridCell` takes its rotation in **radians** — the two native
1509 entry points genuinely differ, and PyHelios passes each through unchanged.
1510 :meth:`getCellRotation` reports degrees.
1511
1512 Example:
1513 >>> lidar.addGrid(
1514 ... center=vec3(0, 0, 0.5),
1515 ... size=vec3(10, 10, 1),
1516 ... ndiv=[10, 10, 5],
1517 ... rotation=0.0
1518 ... )
1519
1520 Terrain-following grid over a 2x2 column layout:
1521
1522 >>> lidar.addGrid(
1523 ... center=vec3(0, 0, 0.5),
1524 ... size=vec3(10, 10, 1),
1525 ... ndiv=[2, 2, 5],
1526 ... column_z_offsets=[0.0, 0.1, 0.2, 0.3]
1527 ... )
1528 """
1529 # Convert center to list
1530 if isinstance(center, (list, tuple)):
1531 if len(center) != 3:
1532 raise ValueError("Center must have 3 elements [x, y, z]")
1533 center_list = list(center)
1534 elif hasattr(center, 'x'):
1535 center_list = [center.x, center.y, center.z]
1536 else:
1537 raise ValueError("Center must be vec3 or 3-element list/tuple")
1538
1539 # Convert size to list
1540 if isinstance(size, (list, tuple)):
1541 if len(size) != 3:
1542 raise ValueError("Size must have 3 elements [x, y, z]")
1543 size_list = list(size)
1544 elif hasattr(size, 'x'):
1545 size_list = [size.x, size.y, size.z]
1546 else:
1547 raise ValueError("Size must be vec3 or 3-element list/tuple")
1548
1549 # Validate ndiv
1550 if not isinstance(ndiv, (list, tuple)) or len(ndiv) != 3:
1551 raise ValueError("Ndiv must be a 3-element list [nx, ny, nz]")
1552
1553 if column_z_offsets is None:
1554 lidar_wrapper.addLiDARGrid(self._cloud_ptr, center_list, size_list, list(ndiv), rotation)
1555 return
1556
1557 if not isinstance(column_z_offsets, (list, tuple)):
1558 raise ValueError(
1559 "column_z_offsets must be a list or tuple of floats, got "
1560 f"{type(column_z_offsets).__name__}"
1561 )
1562
1563 expected = ndiv[0] * ndiv[1]
1564 if len(column_z_offsets) != expected:
1565 raise ValueError(
1566 f"column_z_offsets must have length ndiv[0]*ndiv[1] = {expected} "
1567 f"(one value per grid column), got {len(column_z_offsets)}"
1568 )
1569
1570 lidar_wrapper.addLiDARGridTerrainFollowing(
1571 self._cloud_ptr, center_list, size_list, list(ndiv), rotation,
1572 [float(z) for z in column_z_offsets]
1573 )
1574
1575 def addGridCell(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1576 size: Union[vec3, List[float], Tuple[float, float, float]],
1577 rotation: float = 0.0):
1578 """
1579 Add a single grid cell.
1580
1581 Args:
1582 center: Cell center position (vec3 or 3-element list)
1583 size: Cell dimensions [x, y, z] (vec3 or 3-element list)
1584 rotation: Azimuthal rotation angle (radians, default 0.0)
1585
1586 Note:
1587 ``rotation`` is in **radians** here, whereas :meth:`addGrid` takes
1588 **degrees**. This asymmetry is inherited from the native API — the native
1589 ``addGridCell()`` stores the angle directly in the cell's radian field while
1590 ``addGrid()`` converts from degrees. :meth:`getCellRotation` reports degrees.
1591 """
1592 # Convert center to list
1593 if isinstance(center, (list, tuple)):
1594 if len(center) != 3:
1595 raise ValueError("Center must have 3 elements [x, y, z]")
1596 center_list = list(center)
1597 elif hasattr(center, 'x'):
1598 center_list = [center.x, center.y, center.z]
1599 else:
1600 raise ValueError("Center must be vec3 or 3-element list/tuple")
1601
1602 # Convert size to list
1603 if isinstance(size, (list, tuple)):
1604 if len(size) != 3:
1605 raise ValueError("Size must have 3 elements [x, y, z]")
1606 size_list = list(size)
1607 elif hasattr(size, 'x'):
1608 size_list = [size.x, size.y, size.z]
1609 else:
1610 raise ValueError("Size must be vec3 or 3-element list/tuple")
1611
1612 lidar_wrapper.addLiDARGridCell(self._cloud_ptr, center_list, size_list, rotation)
1613
1614 def getGridCellCount(self) -> int:
1615 """Get total number of grid cells"""
1616 return lidar_wrapper.getLiDARGridCellCount(self._cloud_ptr)
1618 def getCellCenter(self, index: int) -> vec3:
1619 """Get the true world-space center position of a grid cell.
1620
1621 For a grid created with a non-zero azimuthal ``rotation``, this is the lattice
1622 center rotated about the grid anchor (about +z), so it lies in the same rotated
1623 world frame as the hit points, scan origins, and grid bounding box. For an
1624 un-rotated grid it is simply the lattice center.
1625 """
1626 if index < 0:
1627 raise ValueError("Index must be non-negative")
1628 center_list = lidar_wrapper.getLiDARCellCenter(self._cloud_ptr, index)
1629 return vec3(*center_list)
1630
1631 def getCellCenterUnrotated(self, index: int) -> vec3:
1632 """Get the UNROTATED (axis-aligned lattice) center position of a grid cell.
1633
1634 Companion to :meth:`getCellCenter`, which applies the grid's azimuthal rotation.
1635 This returns the center on the axis-aligned lattice instead; for an un-rotated
1636 grid the two are identical.
1637
1638 Use this when the caller applies the grid rotation itself (e.g. rotating a whole
1639 voxel group about the grid center for display) — passing the rotated center to
1640 such code rotates the lattice twice.
1641 """
1642 if index < 0:
1643 raise ValueError("Index must be non-negative")
1644 center_list = lidar_wrapper.getLiDARCellCenterUnrotated(self._cloud_ptr, index)
1645 return vec3(*center_list)
1646
1647 def getCellSize(self, index: int) -> vec3:
1648 """Get size of a grid cell"""
1649 if index < 0:
1650 raise ValueError("Index must be non-negative")
1651 size_list = lidar_wrapper.getLiDARCellSize(self._cloud_ptr, index)
1652 return vec3(*size_list)
1653
1654 def getCellRotation(self, index: int) -> float:
1655 """Get the azimuthal rotation of a grid cell about the z-axis, in degrees.
1656
1657 The units match the ``rotation`` argument of :meth:`addGrid`. Note that
1658 :meth:`addGridCell` takes its rotation in radians.
1659 """
1660 if index < 0:
1661 raise ValueError("Index must be non-negative")
1662 return lidar_wrapper.getLiDARCellRotation(self._cloud_ptr, index)
1663
1664 def getCellLeafArea(self, index: int) -> float:
1665 """Get leaf area of a grid cell (m²)"""
1666 if index < 0:
1667 raise ValueError("Index must be non-negative")
1668 return lidar_wrapper.getLiDARCellLeafArea(self._cloud_ptr, index)
1669
1670 def getCellLeafAreaDensity(self, index: int) -> float:
1671 """Get leaf area density of a grid cell (m²/m³)"""
1672 if index < 0:
1673 raise ValueError("Index must be non-negative")
1674 return lidar_wrapper.getLiDARCellLeafAreaDensity(self._cloud_ptr, index)
1675
1676 def getCellBeamCount(self, index: int) -> int:
1677 """Get the beam count N that entered a grid cell during the leaf-area inversion.
1678
1679 Returns -1 if :meth:`calculateLeafArea` has not been run for this cell.
1680 """
1681 if index < 0:
1682 raise ValueError("Index must be non-negative")
1683 return lidar_wrapper.getLiDARCellBeamCount(self._cloud_ptr, index)
1684
1685 def getCellRelativeDensityIndex(self, index: int) -> float:
1686 """Get the relative density index (I_rdi) for a grid cell."""
1687 if index < 0:
1688 raise ValueError("Index must be non-negative")
1689 return lidar_wrapper.getLiDARCellRelativeDensityIndex(self._cloud_ptr, index)
1690
1691 def getCellMeanPathLength(self, index: int) -> float:
1692 """Get the mean beam path length (m) through a grid cell."""
1693 if index < 0:
1694 raise ValueError("Index must be non-negative")
1695 return lidar_wrapper.getLiDARCellMeanPathLength(self._cloud_ptr, index)
1696
1697 def getCellLADVariance(self, index: int) -> float:
1698 """Get the per-voxel LAD sampling variance for a grid cell.
1699
1700 Returns -1 if uncertainty has not been computed (call :meth:`calculateLeafArea`
1701 with an ``element_width``).
1702 """
1703 if index < 0:
1704 raise ValueError("Index must be non-negative")
1705 return lidar_wrapper.getLiDARCellLADVariance(self._cloud_ptr, index)
1706
1707 def getCellLeafAreaConfidenceInterval(self, index: int, confidence_level: float = 0.95):
1708 """Get the leaf-area confidence interval for a single grid cell.
1709
1710 Returns a ``(valid, lower, upper)`` tuple. ``valid`` is False when the interval is
1711 gated out by the Pimont validity envelope (single-voxel intervals are often
1712 untrustworthy; prefer :meth:`getGroupLADConfidenceInterval`). Requires
1713 :meth:`calculateLeafArea` to have been run with an ``element_width``.
1714 """
1715 if index < 0:
1716 raise ValueError("Index must be non-negative")
1717 return lidar_wrapper.getLiDARCellLeafAreaConfidenceInterval(
1718 self._cloud_ptr, index, confidence_level)
1719
1720 def getGroupLADConfidenceInterval(self, indices: List[int], confidence_level: float = 0.95):
1721 """Get the group-scale LAD confidence interval over a set of grid cells (recommended).
1722
1723 Returns a ``(valid, mean_lad, lower, upper)`` tuple (Pimont et al. 2018, Eq. 39,
1724 assuming voxel independence). Requires :meth:`calculateLeafArea` to have been run
1725 with an ``element_width``.
1726 """
1727 if not indices:
1728 raise ValueError("indices must contain at least one cell index")
1729 if any(i < 0 for i in indices):
1730 raise ValueError("Cell indices must be non-negative")
1731 return lidar_wrapper.getLiDARGroupLADConfidenceInterval(
1732 self._cloud_ptr, indices, confidence_level)
1733
1734 def getCellGtheta(self, index: int) -> float:
1735 """Get G(theta) value for a grid cell"""
1736 if index < 0:
1737 raise ValueError("Index must be non-negative")
1738 return lidar_wrapper.getLiDARCellGtheta(self._cloud_ptr, index)
1739
1740 def setCellGtheta(self, Gtheta: float, index: int):
1741 """Set G(theta) value for a grid cell"""
1742 if index < 0:
1743 raise ValueError("Index must be non-negative")
1744 lidar_wrapper.setLiDARCellGtheta(self._cloud_ptr, Gtheta, index)
1745
1746 def calculateHitGridCell(self):
1747 """Calculate hit point grid cell assignments"""
1748 lidar_wrapper.calculateLiDARHitGridCell(self._cloud_ptr)
1750 def gapfillMisses(self):
1751 """
1752 Gapfill sky/miss points where rays didn't hit geometry.
1753
1754 Important for accurate leaf area calculations with real LiDAR data.
1755 Should be called before triangulation when processing real data.
1756
1757 Misses synthesized here are stored in virtualized form -- as a per-cell occupancy
1758 bit plus a scan-wide angular model rather than as stored points -- so they cost no
1759 per-point storage. They are counted by :meth:`getHitCount` and readable through
1760 every accessor regardless. See :meth:`hasVirtualMisses` and
1761 :meth:`getVirtualMissCount`.
1762
1763 Note:
1764 Reading the whole cloud back afterwards should go through the bulk accessors
1765 (:meth:`getHitsXYZRGB`, :meth:`getHitScanIDArray`, :meth:`getHitDataArray`),
1766 which read virtualized misses in one pass. A Python loop over the per-index
1767 getters costs O(Nphi) on each such point.
1768 """
1769 lidar_wrapper.gapfillLiDARMisses(self._cloud_ptr)
1771 def gapfillMissesCount(self, scanID: Optional[int] = None,
1772 gapfill_grid_only: bool = False,
1773 add_flags: bool = False) -> int:
1774 """
1775 Gapfill missing points and return only how many were added.
1776
1777 Identical to :meth:`gapfillMisses` except that the count is returned instead of the
1778 filled positions, which for a fine scan grid is a large allocation most callers
1779 discard.
1780
1781 Args:
1782 scanID: Scan to gapfill. ``None`` (the default) gapfills every scan, in which
1783 case ``gapfill_grid_only`` and ``add_flags`` are not used.
1784 gapfill_grid_only: Fill only within the voxel grid's bounding box
1785 add_flags: Add ``gapfillMisses_code`` as hit point data (0=original, 1=gapfilled)
1786
1787 Returns:
1788 Number of missing points added
1789
1790 Raises:
1791 RuntimeError: If the native library predates helios-core v1.3.84
1792 """
1793 if scanID is None:
1794 return lidar_wrapper.gapfillLiDARMissesCount(self._cloud_ptr)
1795 return lidar_wrapper.gapfillLiDARMissesCountScan(
1796 self._cloud_ptr, scanID, gapfill_grid_only, add_flags
1797 )
1798
1799 def getVirtualMissCount(self) -> int:
1800 """
1801 Number of gap-filled misses currently held in virtualized form.
1802
1803 A miss synthesized by :meth:`gapfillMisses` is a pure function of its scan-grid
1804 cell, so it is stored implicitly rather than as an element of the hit array. Such
1805 points are counted by :meth:`getHitCount` and readable through every accessor, but
1806 occupy no per-point storage.
1807
1808 Raises:
1809 RuntimeError: If the native library predates helios-core v1.3.84
1810 """
1811 return lidar_wrapper.getLiDARVirtualMissCount(self._cloud_ptr)
1813 def getCroppedReturnStats(self) -> dict:
1814 """
1815 Returns the last :meth:`calculateLeafArea` call inferred from ``target_count``.
1816
1817 A pulse's returns are ordered by range and a beam crosses the (convex) voxel grid
1818 in one contiguous segment. So when a cloud has had returns removed -- cropped to the
1819 grid, say -- but its surviving returns still carry the per-pulse ``target_index`` and
1820 ``target_count`` the scanner wrote, the inversion places each missing return from the
1821 surviving indices alone: indices below the smallest surviving index were before the
1822 first surviving return, indices above the largest were beyond the last one, and only
1823 indices between two surviving returns cannot be placed. Under the crop-to-grid
1824 assumption the second group is counted as transmitted through every voxel the beam
1825 pierces, so a cropped beam keeps the transmittance its full record implied; the third
1826 is left out and reported as ambiguous. A stand-in miss (a return flagged ``is_miss``
1827 sharing the pulse's timestamp) is ignored for counting when the inference applies.
1828
1829 Returns:
1830 dict with integer counts:
1831
1832 - ``beams_with_hidden_returns`` -- pulses with at least one return recovered
1833 - ``hidden_before`` -- removed returns placed before the first surviving return
1834 (expected after any near-field culling; changes nothing)
1835 - ``hidden_after`` -- removed returns placed beyond the last surviving return
1836 (counted as transmitted)
1837 - ``hidden_ambiguous`` -- removed returns between two surviving returns (not
1838 counted; a non-zero value means the cloud was cropped INSIDE the grid)
1839 - ``beams_ambiguous`` -- pulses carrying at least one ambiguous removed return
1840 - ``standins_ignored`` -- stand-in misses left out because the count covered them
1841
1842 All zero before the first inversion and for a cloud whose pulses are complete.
1843
1844 Raises:
1845 RuntimeError: If the native library predates this feature
1846 """
1847 return lidar_wrapper.getLiDARCroppedReturnStats(self._cloud_ptr)
1849 def hasVirtualMisses(self) -> bool:
1850 """
1851 Whether any gap-filled miss is currently held in virtualized form.
1852
1853 Raises:
1854 RuntimeError: If the native library predates helios-core v1.3.84
1855 """
1856 return lidar_wrapper.hasLiDARVirtualMisses(self._cloud_ptr)
1858 def materializeMisses(self) -> None:
1859 """
1860 Convert every virtualized gap-filled miss into a stored hit point.
1861
1862 Every observable is unchanged by this call -- it trades the memory saving for real
1863 storage. It happens automatically before any operation that renumbers the hit index
1864 space (adding or deleting a hit point, writing hit data or a grid cell), so calling
1865 it explicitly is only needed to pay that cost at a chosen moment.
1866
1867 Raises:
1868 RuntimeError: If the native library predates helios-core v1.3.84
1869 """
1870 lidar_wrapper.materializeLiDARMisses(self._cloud_ptr)
1872 def getScanGridDirection(self, scanID: int, row: int, column: int) -> SphericalCoord:
1873 """
1874 Beam direction at a scan-grid cell, from the model fitted during gap-filling.
1875
1876 Available once :meth:`gapfillMisses` has run on the scan through the row/column
1877 path. This is the same reconstruction used to place synthesized misses, exposed so
1878 a caller can check the fitted geometry against known directions.
1879
1880 Args:
1881 scanID: Scan index
1882 row: Scan-grid row (zenith index)
1883 column: Scan-grid column (azimuth index)
1884
1885 Returns:
1886 Unit direction of that cell's beam
1887
1888 Raises:
1889 RuntimeError: If the native library predates helios-core v1.3.84
1890 """
1891 radius, elevation, azimuth = lidar_wrapper.getLiDARScanGridDirection(
1892 self._cloud_ptr, scanID, row, column
1893 )
1894 return SphericalCoord(radius, elevation, azimuth)
1895
1896 def getHitXYZColumn(self):
1897 """
1898 Read every hit's position in index order in one pass.
1899
1900 Costs O(1) per hit even for virtualized gap-filled misses, which the per-index
1901 accessors resolve in O(Nphi). Prefer this to a Python loop over
1902 :meth:`getHitXYZ` whenever the whole cloud is being read.
1903
1904 Returns:
1905 List of (x, y, z) tuples, one per hit
1906
1907 Raises:
1908 RuntimeError: If the native library predates helios-core v1.3.84
1909 """
1910 return lidar_wrapper.getLiDARHitXYZColumn(self._cloud_ptr, self.getHitCount())
1912 def getHitScanIDColumn(self):
1913 """
1914 Read every hit's scan ID in index order in one pass.
1915
1916 See :meth:`getHitXYZColumn` for why this is preferred over a per-index loop.
1917
1918 Returns:
1919 List of scan indices, one per hit
1920
1921 Raises:
1922 RuntimeError: If the native library predates helios-core v1.3.84
1923 """
1924 return lidar_wrapper.getLiDARHitScanIDColumn(self._cloud_ptr, self.getHitCount())
1926 def estimateHitPointMemory(self, hit_count: int) -> int:
1927 """
1928 Estimate the resident memory a cloud of ``hit_count`` points will occupy, in bytes.
1929
1930 Each stored point costs the size of a hit point plus, for every scalar-data label
1931 the cloud carries, one double of value and one byte of presence -- the columnar
1932 store is dense, so every label costs on every point. Excludes virtualized misses
1933 and the transient of growing the arrays; see :meth:`reserveHitPoints` for that.
1934
1935 Most accurate once at least one point exists, since it reads the labels created
1936 so far.
1937
1938 Args:
1939 hit_count: Number of hit points to estimate for
1940
1941 Returns:
1942 Estimated resident bytes
1943
1944 Raises:
1945 RuntimeError: If the native library predates helios-core v1.3.84
1946 """
1947 return lidar_wrapper.estimateLiDARHitPointMemory(self._cloud_ptr, hit_count)
1949 def setMaxHitPoints(self, max_hits: int) -> None:
1950 """
1951 Set the cap on stored hit points before loading fails with a diagnostic.
1952
1953 Exceeding the cap raises an error naming the projected point count and the limit,
1954 rather than throwing from inside the allocator where neither the scan responsible
1955 nor the size is visible. The default (:meth:`getDefaultMaxHitPoints`) is
1956 deliberately generous: it guards against a mis-specified scan grid exhausting the
1957 machine, and is not a statement about machine capacity. Raise it when the machine
1958 genuinely has the memory.
1959
1960 Args:
1961 max_hits: Maximum stored hit points, or 0 to disable the check
1962
1963 Raises:
1964 RuntimeError: If the native library predates helios-core v1.3.84
1965 """
1966 lidar_wrapper.setLiDARMaxHitPoints(self._cloud_ptr, max_hits)
1968 def getMaxHitPoints(self) -> int:
1969 """
1970 Current cap on stored hit points, or 0 if the check is disabled.
1971
1972 Raises:
1973 RuntimeError: If the native library predates helios-core v1.3.84
1974 """
1975 return lidar_wrapper.getLiDARMaxHitPoints(self._cloud_ptr)
1977 @staticmethod
1978 def getDefaultMaxHitPoints() -> int:
1979 """
1980 Default cap on the number of stored hit points in a cloud (100 million).
1981
1982 Raises:
1983 RuntimeError: If the native library predates helios-core v1.3.84
1984 """
1985 return lidar_wrapper.getLiDARDefaultMaxHitPoints()
1987 def reserveHitPoints(self, hit_count: int) -> None:
1988 """
1989 Reserve capacity for hit points and every scalar-data column at once.
1990
1991 Growing the hit-point array by repeated insertion reallocates geometrically, and
1992 during every reallocation the old and new buffers are both live. For a cloud of
1993 tens of millions of returns that transient is gigabytes on top of the steady-state
1994 cost, and on Windows it is charged against the system commit limit at allocation
1995 time -- so a load that would comfortably fit once settled can still fail while
1996 growing. Reserving the final size once removes the transient entirely.
1997
1998 This only reserves capacity; it does not create hit points, and
1999 :meth:`getHitCount` is unchanged. Reserving less than the eventual total is
2000 harmless, as is reserving more.
2001
2002 Args:
2003 hit_count: Expected total number of hit points in the cloud
2004
2005 Raises:
2006 RuntimeError: If the native library predates helios-core v1.3.84
2007 """
2008 lidar_wrapper.reserveLiDARHitPoints(self._cloud_ptr, hit_count)
2010 def setExactPathLengths(self, exact: bool) -> None:
2011 """
2012 Keep every beam path length exactly, instead of binning them.
2013
2014 The leaf-area inversion bins per-beam voxel path lengths once a voxel accumulates
2015 many samples, which bounds memory that would otherwise grow without limit with scan
2016 size. Binning recovers the extinction coefficient far inside the solver's
2017 tolerance, so this is an escape hatch for unusual geometry, or for confirming that
2018 binning is not responsible for a difference between two results.
2019
2020 Args:
2021 exact: True to keep every sample; False (the default) to bin above the threshold
2022
2023 Raises:
2024 RuntimeError: If the native library predates helios-core v1.3.84
2025 """
2026 lidar_wrapper.setLiDARExactPathLengths(self._cloud_ptr, exact)
2028 def getExactPathLengths(self) -> bool:
2029 """
2030 Whether path lengths are accumulated exactly.
2031
2032 Raises:
2033 RuntimeError: If the native library predates helios-core v1.3.84
2034 """
2035 return lidar_wrapper.getLiDARExactPathLengths(self._cloud_ptr)
2037 def syntheticScan(self, context: Context,
2038 rays_per_pulse: Optional[int] = None,
2039 pulse_distance_threshold: Optional[float] = None,
2040 scan_grid_only: bool = False,
2041 record_misses: bool = True,
2042 append: bool = False,
2043 return_mode: Optional[Union[ReturnMode, int]] = None,
2044 cancel_flag=None):
2045 """
2046 Perform synthetic LiDAR scan of geometry in Context.
2047
2048 Requires scan metadata to be defined first via addScan() or loadXML().
2049 Uses ray tracing to simulate LiDAR instrument measurements.
2050
2051 Args:
2052 context: Helios Context containing geometry to scan
2053 rays_per_pulse: Number of rays per pulse (None=discrete-return, typical: 100)
2054 pulse_distance_threshold: Distance threshold for aggregating hits (meters, required for waveform)
2055 scan_grid_only: If True, only scan within defined grid cells
2056 record_misses: If True, record miss/sky points where rays don't hit geometry
2057 append: If True, append to existing hits; if False, clear existing hits
2058 return_mode: Optional :class:`ReturnMode` (MULTI or SINGLE) for analytic-waveform scans.
2059 Overrides each scan's stored return mode for this call only. Only valid when
2060 rays_per_pulse is set (waveform mode); raises ValueError otherwise. In SINGLE mode
2061 up to each scan's getScanMaxReturns() returns per pulse are reported, selected by the
2062 scan's single-return selection policy.
2063 cancel_flag: Optional ``ctypes.c_int`` polled during the ray trace. Setting it non-zero from another thread aborts the scan mid-pass. It is cleared when the call returns, so a later scan on this cloud is not pre-cancelled.
2064
2065 Example (Discrete-return):
2066 >>> from pyhelios import Context, LiDARCloud
2067 >>> from pyhelios.types import vec3
2068 >>> with Context() as context:
2069 ... # Add geometry
2070 ... context.addPatch(center=vec3(0, 0, 0.5), size=vec2(1, 1))
2071 ...
2072 ... with LiDARCloud() as lidar:
2073 ... # Define scan parameters
2074 ... scan_id = lidar.addScan(
2075 ... origin=vec3(0, 0, 2),
2076 ... Ntheta=100, theta_range=(0, 1.57),
2077 ... Nphi=100, phi_range=(0, 6.28),
2078 ... exit_diameter=0, beam_divergence=0
2079 ... )
2080 ...
2081 ... # Perform discrete-return scan
2082 ... lidar.syntheticScan(context)
2083
2084 Example (Full-waveform):
2085 >>> lidar.syntheticScan(
2086 ... context,
2087 ... rays_per_pulse=100,
2088 ... pulse_distance_threshold=0.02,
2089 ... record_misses=True
2090 ... )
2091 """
2092 if not isinstance(context, Context):
2093 raise TypeError("context must be a Context instance")
2094
2095 context_ptr = context.getNativePtr()
2096
2097 # Register an external cancellation flag (a ctypes.c_int set non-zero from
2098 # another thread) so a long ray trace can be aborted mid-pass. Cleared in
2099 # the finally below so a later scan on this cloud isn't pre-cancelled.
2100 if cancel_flag is not None:
2101 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, cancel_flag)
2102 try:
2103 self._dispatch_synthetic_scan(
2104 context_ptr, rays_per_pulse, pulse_distance_threshold,
2105 scan_grid_only, record_misses, append, return_mode)
2106 finally:
2107 if cancel_flag is not None:
2108 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, None)
2109 # A hit sink runs during the scan above. An exception raised inside it was swallowed
2110 # by ctypes and stashed; surface it now rather than reporting a successful scan.
2111 self._raise_pending_callback_error()
2112
2113 def _dispatch_synthetic_scan(self, context_ptr, rays_per_pulse,
2114 pulse_distance_threshold, scan_grid_only,
2115 record_misses, append, return_mode):
2116 # Discrete-return mode (single ray per pulse)
2117 if rays_per_pulse is None:
2118 if return_mode is not None:
2119 raise ValueError(
2120 "return_mode is only valid for analytic-waveform scans; pass rays_per_pulse (> 1)")
2121 # Honor scan_grid_only and record_misses for discrete scans too. record_misses
2122 # defaults to True so the cloud carries the transmitted beams that
2123 # calculateLeafArea() requires.
2124 lidar_wrapper.syntheticLiDARScanDiscrete(
2125 self._cloud_ptr, context_ptr, scan_grid_only, record_misses, append)
2126 else:
2127 # Full-waveform mode (multiple rays per pulse)
2128 if pulse_distance_threshold is None:
2129 raise ValueError("pulse_distance_threshold required for full-waveform scanning")
2130
2131 validate_positive_value(rays_per_pulse, 'rays_per_pulse', 'syntheticScan')
2132 validate_positive_value(pulse_distance_threshold, 'pulse_distance_threshold', 'syntheticScan')
2133
2134 if return_mode is None:
2135 lidar_wrapper.syntheticLiDARScanFull(
2136 self._cloud_ptr, context_ptr,
2137 rays_per_pulse, pulse_distance_threshold,
2138 scan_grid_only, record_misses, append
2139 )
2140 else:
2141 lidar_wrapper.syntheticLiDARScanReturnMode(
2142 self._cloud_ptr, context_ptr,
2143 rays_per_pulse, pulse_distance_threshold, int(return_mode),
2144 scan_grid_only, record_misses, append
2145 )
2146
2147 def calculateLeafArea(self, context: Context, min_voxel_hits: Optional[int] = None,
2148 element_width: Optional[float] = None,
2149 Gtheta: Optional[Union[float, List[float]]] = None):
2150 """
2151 Calculate leaf area for each grid cell.
2152
2153 Requires triangulation to have been performed first, UNLESS a ``Gtheta`` is supplied
2154 (see below).
2155
2156 .. note::
2157 The cloud must contain misses (transmitted beams that returned nothing) — the
2158 inversion fails fast without them. Misses are produced by
2159 ``syntheticScan(..., record_misses=True)`` (the default) or by
2160 :meth:`gapfillMisses`. Use :meth:`hasMisses` to check.
2161
2162 Args:
2163 context: Helios Context instance
2164 min_voxel_hits: Optional minimum number of hits required per voxel
2165 element_width: Optional characteristic vegetation element width (meters). When
2166 provided, per-voxel sampling uncertainty (Pimont et al. 2018) is computed
2167 alongside the leaf-area estimate and becomes available via
2168 :meth:`getCellLADVariance`, :meth:`getCellLeafAreaConfidenceInterval`, and
2169 :meth:`getGroupLADConfidenceInterval`. ``element_width <= 0`` yields a
2170 sampling-only variance.
2171 Gtheta: Optional caller-supplied mean leaf-projection coefficient G(theta), in (0,1]
2172 (0.5 = spherical/random leaf-angle distribution). May be a single scalar (broadcast
2173 to every voxel) or a sequence of one value per grid cell in grid-cell order — the
2174 latter supports a spatially-varying (e.g. vertically-varying) leaf-angle
2175 distribution. When provided, leaf area is computed via a beam-based inversion that
2176 uses each hit's per-pulse beam origin and does NOT require triangulation — the only
2177 supported path for moving-platform scans (see :meth:`addScanMoving`). Requires both
2178 ``min_voxel_hits`` and ``element_width`` to also be specified.
2179
2180 A single float applies one G(theta) to every voxel. A sequence supplies one
2181 G(theta) **per grid cell**, in grid-cell order (the order of
2182 :meth:`getCellCenter`), for a canopy whose leaf-angle distribution varies in
2183 space, typically with height. Its length must equal :meth:`getGridCellCount` and
2184 every value must be in (0,1]. The per-cell form requires helios-core v1.3.85.
2185
2186 Raises:
2187 TypeError: If ``context`` is not a Context
2188 ValueError: If the argument combination is invalid, a G(theta) value is outside
2189 (0,1], or a per-cell sequence is empty or does not match the grid cell count
2190 HeliosError: If the native inversion fails (for example, the cloud has no misses)
2191 RuntimeError: If a per-cell ``Gtheta`` is given and the native library predates
2192 helios-core v1.3.85
2193
2194 Example:
2195 >>> from pyhelios import Context, LiDARCloud
2196 >>> with Context() as context:
2197 ... with LiDARCloud() as lidar:
2198 ... # ... load data, add grid, triangulate ...
2199 ... lidar.calculateLeafArea(context)
2200 """
2201 if not isinstance(context, Context):
2202 raise TypeError("context must be a Context instance")
2203
2204 # Gtheta may be a scalar (broadcast to every voxel) or a sequence of one value per grid
2205 # cell (a spatially-varying leaf-angle distribution). Detect which up front.
2206 gtheta_is_sequence = Gtheta is not None and not isinstance(Gtheta, (int, float))
2207
2208 # Validate argument combinations before touching native state (fail-fast).
2209 Gtheta_per_cell = None
2210 if Gtheta is not None:
2211 if min_voxel_hits is None or element_width is None:
2212 raise ValueError(
2213 "Gtheta requires both min_voxel_hits and element_width to also be specified "
2214 "(the G(theta) overload takes all three)")
2215 if gtheta_is_sequence:
2216 # Validate the sequence itself before any native call, so a malformed Gtheta
2217 # is reported even on a cloud that has no grid loaded yet.
2218 Gtheta_per_cell = [float(g) for g in Gtheta]
2219 if not Gtheta_per_cell:
2220 raise ValueError("A per-cell Gtheta sequence must contain at least one value")
2221 bad = [g for g in Gtheta_per_cell if not (0.0 < g <= 1.0)]
2222 if bad:
2223 raise ValueError(
2224 f"Every per-cell Gtheta value must be in (0, 1] "
2225 f"(e.g. 0.5 for a spherical leaf-angle distribution); got {bad[0]!r}")
2226 cell_count = self.getGridCellCount()
2227 if len(Gtheta_per_cell) != cell_count:
2228 raise ValueError(
2229 f"A per-cell Gtheta sequence must have one entry per grid cell: "
2230 f"got {len(Gtheta_per_cell)} values for {cell_count} cells")
2231 elif Gtheta <= 0:
2232 # The native overload treats Gtheta <= 0 as the "compute from triangulation"
2233 # sentinel, which silently disables the supplied-G(theta) path. Reject it here.
2234 raise ValueError(
2235 "Gtheta must be > 0 and in (0, 1] (e.g. 0.5 for a spherical leaf-angle distribution)")
2236 elif element_width is not None and min_voxel_hits is None:
2237 raise ValueError(
2238 "element_width requires min_voxel_hits to also be specified "
2239 "(the uncertainty overload takes both)")
2240
2241 context_ptr = context.getNativePtr()
2242 if Gtheta_per_cell is not None:
2243 lidar_wrapper.calculateLiDARLeafAreaGthetaPerCell(
2244 self._cloud_ptr, context_ptr, Gtheta_per_cell, min_voxel_hits, element_width)
2245 elif Gtheta is not None:
2246 lidar_wrapper.calculateLiDARLeafAreaGtheta(
2247 self._cloud_ptr, context_ptr, Gtheta, min_voxel_hits, element_width)
2248 elif element_width is not None:
2249 lidar_wrapper.calculateLiDARLeafAreaUncertainty(
2250 self._cloud_ptr, context_ptr, min_voxel_hits, element_width)
2251 elif min_voxel_hits is None:
2252 lidar_wrapper.calculateLiDARLeafArea(self._cloud_ptr, context_ptr)
2253 else:
2254 lidar_wrapper.calculateLiDARLeafAreaMinHits(self._cloud_ptr, context_ptr, min_voxel_hits)
2255
2256 def calculateSyntheticLeafArea(self, context: Context):
2257 """
2258 Calculate synthetic leaf area (for validation of synthetic scans).
2259
2260 Uses exact primitive geometry to calculate leaf area, useful for
2261 validating synthetic scan accuracy.
2262
2263 Args:
2264 context: Helios Context instance containing primitive geometry
2265 """
2266 if not isinstance(context, Context):
2267 raise TypeError("context must be a Context instance")
2268 context_ptr = context.getNativePtr()
2269 lidar_wrapper.calculateSyntheticLiDARLeafArea(self._cloud_ptr, context_ptr)
2270
2271 def calculateSyntheticGtheta(self, context: Context):
2272 """
2273 Calculate synthetic G(theta) (for validation of synthetic scans).
2274
2275 Uses exact primitive geometry to calculate G(theta), useful for
2276 validating synthetic scan accuracy.
2277
2278 Args:
2279 context: Helios Context instance containing primitive geometry
2280 """
2281 if not isinstance(context, Context):
2282 raise TypeError("context must be a Context instance")
2283 context_ptr = context.getNativePtr()
2284 lidar_wrapper.calculateSyntheticLiDARGtheta(self._cloud_ptr, context_ptr)
2285
2286 def exportTriangleNormals(self, filename: str):
2287 """Export triangle normal vectors to file
2288
2289 .. warning::
2290 Raises once a run's triangles have been streamed to a sink registered with
2291 :meth:`setTriangulationSink` -- the mesh is released rather than stored.
2292 """
2293 if not filename:
2294 raise ValueError("Filename cannot be empty")
2295 lidar_wrapper.exportLiDARTriangleNormals(self._cloud_ptr, filename)
2296
2297 def exportTriangleAreas(self, filename: str):
2298 """Export triangle areas to file
2299
2300 .. warning::
2301 Raises once a run's triangles have been streamed to a sink registered with
2302 :meth:`setTriangulationSink` -- the mesh is released rather than stored.
2303 """
2304 if not filename:
2305 raise ValueError("Filename cannot be empty")
2306 lidar_wrapper.exportLiDARTriangleAreas(self._cloud_ptr, filename)
2307
2308 def exportLeafAreas(self, filename: str):
2309 """Export leaf areas for each grid cell to file"""
2310 if not filename:
2311 raise ValueError("Filename cannot be empty")
2312 lidar_wrapper.exportLiDARLeafAreas(self._cloud_ptr, filename)
2313
2314 def exportLeafAreaDensities(self, filename: str):
2315 """Export leaf area densities for each grid cell to file"""
2316 if not filename:
2317 raise ValueError("Filename cannot be empty")
2318 lidar_wrapper.exportLiDARLeafAreaDensities(self._cloud_ptr, filename)
2319
2320 def exportGtheta(self, filename: str):
2321 """Export G(theta) values for each grid cell to file"""
2322 if not filename:
2323 raise ValueError("Filename cannot be empty")
2324 lidar_wrapper.exportLiDARGtheta(self._cloud_ptr, filename)
2325
2326 def addTrianglesToContext(self, context: Context):
2327 """
2328 Add triangulated mesh to Context as triangle primitives.
2329
2330 Converts the triangulated point cloud mesh into Context triangle
2331 primitives that can be used for further analysis or visualization.
2332
2333 .. warning::
2334 Raises once a run's triangles have been streamed to a sink registered with
2335 :meth:`setTriangulationSink` -- the mesh is released rather than stored, so there
2336 is nothing to add. Clear the sink before triangulating if you need the stored mesh.
2337
2338 Args:
2339 context: Helios Context instance
2340
2341 Example:
2342 >>> with Context() as context:
2343 ... with LiDARCloud() as lidar:
2344 ... lidar.loadXML("scan.xml")
2345 ... lidar.triangulateHitPoints(Lmax=0.5, max_aspect_ratio=5)
2346 ... lidar.addTrianglesToContext(context)
2347 ... print(f"Added {context.getPrimitiveCount()} triangles to context")
2348 """
2349 if not isinstance(context, Context):
2350 raise TypeError("context must be a Context instance")
2351 lidar_wrapper.addLiDARTrianglesToContext(self._cloud_ptr, context.getNativePtr())
2352
2353 def initializeCollisionDetection(self, context: Context):
2354 """
2355 Initialize CollisionDetection plugin for ray tracing.
2356
2357 Required before performing synthetic scans.
2358
2359 Args:
2360 context: Helios Context instance containing geometry
2361 """
2362 if not isinstance(context, Context):
2363 raise TypeError("context must be a Context instance")
2364
2365 # Retain a reference to the Context. The native side constructs a
2366 # CollisionDetection that stores the raw Context* for its lifetime, so a
2367 # temporary Context would otherwise be freed while still referenced.
2368 # Note the C++ side only builds CollisionDetection once (it no-ops if one
2369 # already exists), so the first Context passed here is the one that stays
2370 # bound - re-initializing with a different Context has no effect.
2371 if getattr(self, '_cd_context', None) is not None and self._cd_context is not context:
2372 raise RuntimeError(
2373 "LiDARCloud collision detection is already initialized with a different Context.\n"
2374 "The native CollisionDetection keeps the Context it was first given; "
2375 "passing another one here would silently have no effect.\n"
2376 "\n"
2377 "Fix: create a new LiDARCloud for a different Context, or reuse the "
2378 "Context this cloud was initialized with."
2379 )
2380 self._cd_context = context
2381
2382 lidar_wrapper.initializeLiDARCollisionDetection(self._cloud_ptr, context.getNativePtr())
2383
2384 def _check_cd_context_alive(self):
2385 """Raise if the Context bound to collision detection has been destroyed."""
2386 if getattr(self, '_cd_context', None) is not None:
2387 check_context_alive(self._cd_context, "LiDARCloud collision detection")
2388
2389 def enableCDGPUAcceleration(self):
2390 """Enable GPU acceleration for collision detection ray tracing"""
2391 self._check_cd_context_alive()
2392 lidar_wrapper.enableLiDARCDGPUAcceleration(self._cloud_ptr)
2393
2394 def disableCDGPUAcceleration(self):
2395 """Disable GPU acceleration (use CPU ray tracing)"""
2396 self._check_cd_context_alive()
2397 lidar_wrapper.disableLiDARCDGPUAcceleration(self._cloud_ptr)
2398
2399 def isGPUAvailable(self) -> bool:
2400 """Return True if a CUDA-capable GPU is available for collision-detection ray tracing.
2401
2402 Reports capability (compiled with CUDA, a device present, and HELIOS_NO_GPU not set); use
2403 :meth:`isGPUAccelerationEnabled` to query whether GPU acceleration is currently toggled on.
2404 """
2405 return lidar_wrapper.isLiDARGPUAvailable(self._cloud_ptr)
2406
2407 def isGPUAccelerationEnabled(self) -> bool:
2408 """Return True if GPU acceleration is currently enabled for collision-detection ray tracing."""
2409 return lidar_wrapper.isLiDARGPUAccelerationEnabled(self._cloud_ptr)
2410
2411 def setSyntheticScanProgressPointer(self, ptr):
2412 """Register an external per-scan progress counter polled during :meth:`syntheticScan`.
2413
2414 ``ptr`` is a ``ctypes.c_int`` into which syntheticScan writes the 0-based index of the scan
2415 currently being ray-traced (set to :meth:`getScanCount` when the batch finishes), letting a
2416 host thread poll progress while the blocking scan runs. The counter is owned by the caller and
2417 must outlive the scan. Pass ``None`` to clear.
2418 """
2419 import ctypes
2420 if ptr is not None and not isinstance(ptr, ctypes.c_int):
2421 raise TypeError("ptr must be a ctypes.c_int (or None to clear)")
2422 lidar_wrapper.setLiDARSyntheticScanProgressPointer(self._cloud_ptr, ptr)
2423
2424 # ------------------------------------------------------------------
2425 # helios-core 1.3.86 additions
2426 # ------------------------------------------------------------------
2427
2428 def createHitDataColumn(self, label: str, column_type: HitDataType) -> None:
2429 """
2430 Create a per-hit scalar-data column with an explicit storage type.
2431
2432 Call this *before* adding data carrying ``label`` to fix the column's storage type
2433 instead of letting it be inferred from the label name. An explicitly typed column is
2434 never widened: an INT32 column rejects a value that is not a 32-bit integer, and a
2435 FLOAT32 column stores values at float precision.
2436
2437 Args:
2438 label: Label of the data value (e.g. "intensity")
2439 column_type: A :class:`HitDataType` member
2440
2441 Raises:
2442 TypeError: If ``label`` is not a str or ``column_type`` is not a HitDataType
2443 ValueError: If ``label`` is empty
2444 HeliosError: If the column already exists with a different type
2445 RuntimeError: If the native library predates helios-core v1.3.86
2446 """
2447 self._validate_label(label)
2448 if not isinstance(column_type, HitDataType):
2449 raise TypeError(
2450 "column_type must be a HitDataType (FLOAT64, FLOAT32 or INT32), "
2451 f"got {column_type!r}")
2452 lidar_wrapper.createLiDARHitDataColumn(self._cloud_ptr, label, int(column_type))
2453
2454 def getHitDataType(self, label: str) -> HitDataType:
2455 """
2456 Storage type of an existing per-hit scalar-data column.
2457
2458 A column typed implicitly may since have widened to ``FLOAT64``.
2459
2460 Args:
2461 label: Label of the data value
2462
2463 Returns:
2464 The column's current :class:`HitDataType`
2465
2466 Raises:
2467 TypeError: If ``label`` is not a str
2468 ValueError: If ``label`` is empty
2469 HeliosError: If no column exists for the label (use
2470 :meth:`getHitDataColumnIndex` to test for existence without raising)
2471 RuntimeError: If the native library predates helios-core v1.3.86
2472 """
2473 if not isinstance(label, str):
2474 raise TypeError(f"label must be a str, got {type(label).__name__}")
2475 if not label:
2476 raise ValueError("label cannot be empty")
2477 return HitDataType(lidar_wrapper.getLiDARHitDataType(self._cloud_ptr, label))
2478
2479 def getHitDataColumnFloat32(self, label: str, absent_value: float = -9999.0) -> List[float]:
2480 """
2481 Bulk-export a named scalar column as 32-bit floats.
2482
2483 Reads a ``FLOAT32`` column without widening it to 8 bytes per hit; a ``FLOAT64`` or
2484 ``INT32`` column is converted element-wise. See :meth:`getHitDataColumn` for the
2485 double-precision counterpart.
2486
2487 Args:
2488 label: Label of the data value
2489 absent_value: Value reported for hits that lack the label
2490
2491 Returns:
2492 List of floats of length :meth:`getHitCount`
2493
2494 Raises:
2495 TypeError: If ``label`` is not a str
2496 ValueError: If ``label`` is empty
2497 RuntimeError: If the native library predates helios-core v1.3.86
2498 """
2499 self._validate_label(label)
2500 n = self.getHitCount()
2501 if n == 0:
2502 return []
2503 return lidar_wrapper.getLiDARHitDataColumnF32(self._cloud_ptr, label, n, absent_value)
2504
2505 def getHitDataColumnFloat32Array(self, label: str, absent_value: float = -9999.0):
2506 """
2507 Bulk-export a named scalar column as a ``(getHitCount(),)`` float32 numpy array.
2508
2509 See :meth:`getHitDataColumnFloat32`.
2510 """
2511 import numpy as np
2512 self._validate_label(label)
2513 n = self.getHitCount()
2514 if n == 0:
2515 return np.empty((0,), np.float32)
2516 return lidar_wrapper.getLiDARHitDataColumnF32_np(self._cloud_ptr, label, n, absent_value)
2517
2518 def getHitDataColumnInt32(self, label: str, absent_value: int = -9999) -> List[int]:
2519 """
2520 Bulk-export a named scalar column as 32-bit signed integers.
2521
2522 Reads an ``INT32`` column without widening it.
2523
2524 Args:
2525 label: Label of the data value
2526 absent_value: Value reported for hits that lack the label
2527
2528 Returns:
2529 List of ints of length :meth:`getHitCount`
2530
2531 Raises:
2532 TypeError: If ``label`` is not a str
2533 ValueError: If ``label`` is empty
2534 HeliosError: If any value is not an integer in the 32-bit range (a fractional
2535 value, a NaN, or a timestamp) -- read such a label with
2536 :meth:`getHitDataColumn` instead
2537 RuntimeError: If the native library predates helios-core v1.3.86
2538 """
2539 self._validate_label(label)
2540 n = self.getHitCount()
2541 if n == 0:
2542 return []
2543 return lidar_wrapper.getLiDARHitDataColumnI32(self._cloud_ptr, label, n, absent_value)
2544
2545 def getHitDataColumnInt32Array(self, label: str, absent_value: int = -9999):
2546 """
2547 Bulk-export a named scalar column as a ``(getHitCount(),)`` int32 numpy array.
2548
2549 See :meth:`getHitDataColumnInt32`.
2550 """
2551 import numpy as np
2552 self._validate_label(label)
2553 n = self.getHitCount()
2554 if n == 0:
2555 return np.empty((0,), np.int32)
2556 return lidar_wrapper.getLiDARHitDataColumnI32_np(self._cloud_ptr, label, n, absent_value)
2557
2558 def _validate_label(self, label: str) -> None:
2559 """Validate a scalar-data label argument (shared by the column readers)."""
2560 if not isinstance(label, str):
2561 raise TypeError(f"label must be a str, got {type(label).__name__}")
2562 if not label:
2563 raise ValueError("label cannot be empty")
2564
2565 def addHitPointsBulk(self, scanID: int, xyz, dir_spherical=None,
2566 labels: Optional[List[str]] = None, values=None) -> None:
2567 """
2568 Bulk-ingest hit points through the native bulk path, with double-precision positions.
2569
2570 This is the native ``addHitPoints`` entry point, distinct from :meth:`addHitPoints`
2571 (a per-point shim taking float positions). It takes float64 coordinates, can derive
2572 beam directions automatically, and writes scalar-data columns directly.
2573
2574 Args:
2575 scanID: Scan ID these hits belong to (the scan must already exist)
2576 xyz: Hit point coordinates, shape (N, 3) float64
2577 dir_spherical: Beam directions, shape (N, 3) as (radius, elevation, azimuth), or
2578 ``None`` to derive each direction from the point's position relative to the
2579 scan origin (what the ASCII loader does)
2580 labels: Optional list of scalar-data column names (length k)
2581 values: Required when ``labels`` is given: (N, k) float64 values. A NaN entry
2582 leaves that label absent on that point.
2583
2584 Raises:
2585 ValueError: If an array has the wrong shape, row counts disagree, or ``values``
2586 is missing while ``labels`` was supplied
2587 RuntimeError: If the native library predates helios-core v1.3.86
2588
2589 Example:
2590 >>> import numpy as np
2591 >>> lidar.addHitPointsBulk(0, np.zeros((10, 3)),
2592 ... labels=["intensity"], values=np.ones((10, 1)))
2593 """
2594 if scanID < 0:
2595 raise ValueError("scanID must be non-negative")
2596 lidar_wrapper.addLiDARHitPointsBulk(self._cloud_ptr, scanID, xyz, dir_spherical,
2597 labels, values)
2599 def deleteHitPoints(self, first: int, count: int) -> None:
2600 """
2601 Delete a contiguous range of hit points, preserving the order of the rest.
2602
2603 Removes hits ``[first, first+count)``. Unlike :meth:`deleteHitPoint`, which fills the
2604 freed slot with the last hit, this keeps every surviving hit in its relative order --
2605 so draining the tail of the cloud (the release step of a streaming
2606 :meth:`syntheticScan`, see :meth:`setSyntheticScanHitSink`) costs O(count) and leaves
2607 earlier indices unchanged.
2608
2609 Args:
2610 first: Index of the first hit to delete
2611 count: Number of hits to delete
2612
2613 Raises:
2614 ValueError: If ``first`` or ``count`` is negative
2615 HeliosError: If the range extends past the end of the cloud
2616 RuntimeError: If the native library predates helios-core v1.3.86
2617 """
2618 if first < 0:
2619 raise ValueError("first must be non-negative")
2620 if count < 0:
2621 raise ValueError("count must be non-negative")
2622 lidar_wrapper.deleteLiDARHitPoints(self._cloud_ptr, first, count)
2624 def setTriangulationSink(self, callback) -> None:
2625 """
2626 Register a sink that receives each scan's triangles as :meth:`triangulateHitPoints` finishes it.
2627
2628 With a sink set, triangulation hands each scan's finished triangles to ``callback`` and
2629 then releases them, keeping only the per-voxel leaf-angle sums the leaf-area inversion
2630 needs. Retained memory becomes one scan's triangles at a time, and
2631 :meth:`calculateLeafArea` still works and gives the same result.
2632
2633 ``callback`` is called as ``callback(scanID, vertices, ids)`` where ``vertices`` is a
2634 ``(T, 9)`` float32 numpy array laid out ``[v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z]`` per
2635 triangle and ``ids`` is a ``(T, 2)`` int32 array of ``[scanID, gridcell]``. Both are
2636 copies owned by the caller. Pass ``None`` to clear the sink.
2637
2638 .. warning::
2639 Once a run's triangles have been streamed, the mesh is not retained:
2640 :meth:`getTriangleCount` reports zero and :meth:`getTriangleVerticesAll`,
2641 :meth:`addTrianglesToContext`, :meth:`exportTriangleNormals` and
2642 :meth:`exportTriangleAreas` raise rather than silently operating on an empty mesh.
2643 Clear the sink before triangulating if you need the stored mesh.
2644
2645 .. note::
2646 An exception raised inside ``callback`` cannot propagate through the native call
2647 (a Python exception in a ctypes callback is swallowed and reported to C++ as
2648 success). It is captured and re-raised from the call that triggered it --
2649 typically :meth:`triangulateHitPoints`.
2650
2651 Args:
2652 callback: Callable taking ``(scanID, vertices, ids)``, or ``None`` to clear
2653
2654 Raises:
2655 TypeError: If ``callback`` is neither callable nor None
2656 RuntimeError: If the native library predates helios-core v1.3.86
2657 """
2658 import numpy as np
2659
2660 if callback is None:
2661 lidar_wrapper.setLiDARTriangulationSink(self._cloud_ptr, None)
2662 self._triangulation_sink_ref = None
2663 self._triangulation_sink_error = None
2664 return
2665
2666 if not callable(callback):
2667 raise TypeError("callback must be callable or None")
2668
2669 self._triangulation_sink_error = None
2670
2671 def _trampoline(scanID, xyz9, ids, triCount, user_data):
2672 # A Python exception raised here is swallowed by ctypes (the callback simply
2673 # returns, and C++ treats that as success), so stash it and re-raise after the
2674 # native call returns. See _raise_pending_callback_error.
2675 try:
2676 n = int(triCount)
2677 if n > 0 and xyz9:
2678 verts = np.ctypeslib.as_array(xyz9, shape=(n, 9)).copy()
2679 else:
2680 verts = np.empty((0, 9), np.float32)
2681 if n > 0 and ids:
2682 id_arr = np.ctypeslib.as_array(ids, shape=(n, 2)).copy()
2683 else:
2684 id_arr = np.empty((0, 2), np.int32)
2685 callback(int(scanID), verts, id_arr)
2686 except BaseException as exc:
2687 if self._triangulation_sink_error is None:
2688 self._triangulation_sink_error = exc
2689
2690 self._triangulation_sink_ref = lidar_wrapper.LiDARTriangulationSinkCallback(_trampoline)
2691 lidar_wrapper.setLiDARTriangulationSink(self._cloud_ptr, self._triangulation_sink_ref)
2692
2693 def setSyntheticScanHitSink(self, callback) -> None:
2694 """
2695 Register a sink fired after each chunk of a :meth:`syntheticScan` lands in the cloud.
2696
2697 Without a sink, every chunk's returns accumulate in the cloud until the scan finishes,
2698 so a very large scan holds every return before the caller can read any. With a sink,
2699 ``callback(first, count)`` is invoked after each chunk with the index of the first new
2700 hit and the number of new hits. Inside the callback the new hits can be read (through
2701 the per-scan column readers), written out, and then released with
2702 :meth:`deleteHitPoints` -- they are always the tail of the cloud, so the cloud never
2703 holds more than one chunk.
2704
2705 Chunk size is bounded by :meth:`setSyntheticScanMemoryBudget`. Pass ``None`` to clear.
2706
2707 .. note::
2708 An exception raised inside ``callback`` cannot propagate through the native call
2709 (a Python exception in a ctypes callback is swallowed and reported to C++ as
2710 success, which would let the scan continue as though nothing failed). It is
2711 captured and re-raised from the call that triggered it -- typically
2712 :meth:`syntheticScan`.
2713
2714 Args:
2715 callback: Callable taking ``(first, count)``, or ``None`` to clear
2716
2717 Raises:
2718 TypeError: If ``callback`` is neither callable nor None
2719 RuntimeError: If the native library predates helios-core v1.3.86
2720 """
2721 if callback is None:
2722 lidar_wrapper.setLiDARSyntheticScanHitSink(self._cloud_ptr, None)
2723 self._synthetic_hit_sink_ref = None
2724 self._synthetic_hit_sink_error = None
2725 return
2727 if not callable(callback):
2728 raise TypeError("callback must be callable or None")
2729
2730 self._synthetic_hit_sink_error = None
2731
2732 def _trampoline(first, count, user_data):
2733 # See setTriangulationSink: a raised exception cannot cross the ctypes boundary.
2734 try:
2735 callback(int(first), int(count))
2736 except BaseException as exc:
2737 if self._synthetic_hit_sink_error is None:
2738 self._synthetic_hit_sink_error = exc
2739
2740 self._synthetic_hit_sink_ref = lidar_wrapper.LiDARSyntheticScanHitSinkCallback(_trampoline)
2741 lidar_wrapper.setLiDARSyntheticScanHitSink(self._cloud_ptr, self._synthetic_hit_sink_ref)
2742
2743 def _raise_pending_callback_error(self) -> None:
2744 """Re-raise an exception captured inside a streaming-sink callback, if any.
2745
2746 A Python exception raised inside a ctypes callback never propagates -- ctypes returns 0
2747 and C++ takes that for success -- so the sink trampolines stash the exception and this
2748 re-raises it once the native call has returned.
2749 """
2750 for attr in ('_triangulation_sink_error', '_synthetic_hit_sink_error'):
2751 exc = getattr(self, attr, None)
2752 if exc is not None:
2753 setattr(self, attr, None)
2754 raise exc
2755
2756 def getScanHitCount(self, scanID: int) -> int:
2757 """
2758 Number of hit points (stored returns plus virtualized misses) belonging to one scan.
2759
2760 This is the length the per-scan readers fill -- :meth:`getScanHitIndices`,
2761 :meth:`getScanHitXYZColumn` and :meth:`getScanHitDataColumn`.
2762
2763 Args:
2764 scanID: Scan index
2765
2766 Returns:
2767 Number of hits in that scan
2768
2769 Raises:
2770 ValueError: If ``scanID`` is negative
2771 RuntimeError: If the native library predates helios-core v1.3.86
2772 """
2773 self._validate_scan_id(scanID)
2774 return lidar_wrapper.getLiDARScanHitCount(self._cloud_ptr, scanID)
2775
2776 def getScanHitIndices(self, scanID: int) -> List[int]:
2777 """
2778 Global indices of one scan's hit points, in the order the per-scan readers use.
2779
2780 A scan's hits need not be contiguous in the global index space (a filter's
2781 swap-and-pop deletion reorders the cloud, and gap-filled misses live above every
2782 stored return), so the per-scan readers present a scan's hits in their own local
2783 order. This maps each local position back to the global index used by
2784 :meth:`getHitXYZ` and friends. Stored returns come first, in stored order, followed
2785 by the scan's virtualized misses.
2786
2787 Args:
2788 scanID: Scan index
2789
2790 Returns:
2791 List of global hit indices, one per hit in the scan
2792
2793 Raises:
2794 ValueError: If ``scanID`` is negative
2795 RuntimeError: If the native library predates helios-core v1.3.86
2796 """
2797 self._validate_scan_id(scanID)
2798 n = self.getScanHitCount(scanID)
2799 return lidar_wrapper.getLiDARScanHitIndices(self._cloud_ptr, scanID, n)
2800
2801 def getScanHitXYZColumn(self, scanID: int):
2802 """
2803 Read one scan's hit positions in a single pass.
2805 Costs O(hits in the scan), not O(hits in the cloud): the scan's stored returns are
2806 located through an index built once and kept until the cloud changes, and its
2807 virtualized misses are walked in occupancy order rather than resolved one at a time.
2808 Prefer this to filtering :meth:`getHitXYZColumn` by scan.
2809
2810 Args:
2811 scanID: Scan index
2812
2813 Returns:
2814 List of (x, y, z) tuples, one per hit in the scan, in local order
2815
2816 Raises:
2817 ValueError: If ``scanID`` is negative
2818 RuntimeError: If the native library predates helios-core v1.3.86
2819 """
2820 self._validate_scan_id(scanID)
2821 n = self.getScanHitCount(scanID)
2822 return lidar_wrapper.getLiDARScanHitXYZColumn(self._cloud_ptr, scanID, n)
2823
2824 def getScanHitDataColumn(self, scanID: int, label: str,
2825 absent_value: float = -9999.0) -> List[float]:
2826 """
2827 Read one scan's values of a scalar-data label in a single pass, as doubles.
2828
2829 The per-scan counterpart of :meth:`getHitDataColumn`; see
2830 :meth:`getScanHitXYZColumn` for why this is preferred over filtering the whole cloud.
2831
2832 Args:
2833 scanID: Scan index
2834 label: Label of the data value
2835 absent_value: Value reported for hits that lack the label
2836
2837 Returns:
2838 List of floats, one per hit in the scan, in local order
2839
2840 Raises:
2841 TypeError: If ``label`` is not a str
2842 ValueError: If ``scanID`` is negative or ``label`` is empty
2843 RuntimeError: If the native library predates helios-core v1.3.86
2844 """
2845 self._validate_scan_id(scanID)
2846 self._validate_label(label)
2847 n = self.getScanHitCount(scanID)
2848 return lidar_wrapper.getLiDARScanHitDataColumn(self._cloud_ptr, scanID, label, n,
2849 absent_value)
2850
2851 def getScanHitDataColumnFloat32(self, scanID: int, label: str,
2852 absent_value: float = -9999.0) -> List[float]:
2853 """
2854 Read one scan's values of a scalar-data label as 32-bit floats.
2855
2856 See :meth:`getScanHitDataColumn` and :meth:`getHitDataColumnFloat32`.
2857
2858 Raises:
2859 TypeError: If ``label`` is not a str
2860 ValueError: If ``scanID`` is negative or ``label`` is empty
2861 RuntimeError: If the native library predates helios-core v1.3.86
2862 """
2863 self._validate_scan_id(scanID)
2864 self._validate_label(label)
2865 n = self.getScanHitCount(scanID)
2866 return lidar_wrapper.getLiDARScanHitDataColumnF32(self._cloud_ptr, scanID, label, n,
2867 absent_value)
2868
2869 def getScanHitDataColumnInt32(self, scanID: int, label: str,
2870 absent_value: int = -9999) -> List[int]:
2871 """
2872 Read one scan's values of a scalar-data label as 32-bit signed integers.
2873
2874 See :meth:`getScanHitDataColumn` and :meth:`getHitDataColumnInt32`.
2875
2876 Raises:
2877 TypeError: If ``label`` is not a str
2878 ValueError: If ``scanID`` is negative or ``label`` is empty
2879 HeliosError: If any value is not an integer in the 32-bit range
2880 RuntimeError: If the native library predates helios-core v1.3.86
2881 """
2882 self._validate_scan_id(scanID)
2883 self._validate_label(label)
2884 n = self.getScanHitCount(scanID)
2885 return lidar_wrapper.getLiDARScanHitDataColumnI32(self._cloud_ptr, scanID, label, n,
2886 absent_value)
2887
2888 def _validate_scan_id(self, scanID: int) -> None:
2889 """Validate a scan index argument (shared by the per-scan readers)."""
2890 if not isinstance(scanID, int) or isinstance(scanID, bool):
2891 raise TypeError(f"scanID must be an int, got {type(scanID).__name__}")
2892 if scanID < 0:
2893 raise ValueError("scanID must be non-negative")
2894
2895 def calculateLeafAreaBlock(self, context: Context, ijk_min, ijk_max,
2896 min_voxel_hits: int, element_width: float,
2897 Gtheta: Optional[Union[float, List[float]]] = None) -> None:
2898 """
2899 Calculate leaf area for only a block of the voxel grid.
2900
2901 The block form of :meth:`calculateLeafArea`, for inverting a large grid a tile at a
2902 time. Requires a regular lattice grid (as built by :meth:`addGrid`); use
2903 :meth:`getGridGlobalCount` for the lattice dimensions and :meth:`getCellGlobalIJK` to
2904 map a cell index to its lattice coordinate.
2905
2906 Args:
2907 context: Helios Context instance
2908 ijk_min: Lattice index (i, j, k) of the block's first cell
2909 ijk_max: Lattice index (i, j, k) of the block's last cell, inclusive
2910 min_voxel_hits: Minimum number of beams that must have entered a voxel
2911 element_width: Characteristic vegetation element width (m); <= 0 yields a
2912 sampling-only variance
2913 Gtheta: Optional caller-supplied G(theta) in (0,1]. A single float applies one
2914 value to every voxel; a sequence supplies one value **per grid cell** (the
2915 whole grid, not just the block) in grid-cell order. When omitted,
2916 triangulation supplies G(theta) and must have been run.
2917
2918 Raises:
2919 TypeError: If ``context`` is not a Context
2920 ValueError: If a lattice index is not 3 elements, or a G(theta) sequence is empty
2921 HeliosError: If the grid is not a regular lattice, the block is out of range, or
2922 the inversion fails (for example, the cloud has no misses)
2923 RuntimeError: If the native library predates helios-core v1.3.86
2924 """
2925 if not isinstance(context, Context):
2926 raise TypeError("context must be a Context instance")
2927 context_ptr = context.getNativePtr()
2928
2929 if Gtheta is None:
2930 lidar_wrapper.calculateLiDARLeafAreaBlock(
2931 self._cloud_ptr, context_ptr, min_voxel_hits, element_width, ijk_min, ijk_max)
2932 return
2933
2934 if isinstance(Gtheta, (int, float)) and not isinstance(Gtheta, bool):
2935 if not (0.0 < float(Gtheta) <= 1.0):
2936 raise ValueError(f"Gtheta must be in (0, 1], got {Gtheta}")
2937 lidar_wrapper.calculateLiDARLeafAreaGthetaBlock(
2938 self._cloud_ptr, context_ptr, Gtheta, min_voxel_hits, element_width,
2939 ijk_min, ijk_max)
2940 return
2941
2942 per_cell = [float(g) for g in Gtheta]
2943 if not per_cell:
2944 raise ValueError("A per-cell Gtheta sequence must contain at least one value")
2945 for g in per_cell:
2946 if not (0.0 < g <= 1.0):
2947 raise ValueError(f"Every Gtheta value must be in (0, 1], got {g}")
2948 lidar_wrapper.calculateLiDARLeafAreaGthetaPerCellBlock(
2949 self._cloud_ptr, context_ptr, per_cell, min_voxel_hits, element_width,
2950 ijk_min, ijk_max)
2951
2952 def getCellGlobalIJK(self, index: int) -> Tuple[int, int, int]:
2953 """
2954 Lattice index (i, j, k) of a grid cell along x, y and z.
2955
2956 For a grid built by :meth:`addGrid` this is the cell's position in the
2957 ``ndiv.x`` by ``ndiv.y`` by ``ndiv.z`` lattice; cells are stored in the order
2958 ``k*ny*nx + j*nx + i``. It is the coordinate :meth:`calculateLeafAreaBlock` takes.
2959
2960 Args:
2961 index: Index of a grid cell
2962
2963 Returns:
2964 (i, j, k) tuple
2965
2966 Raises:
2967 ValueError: If ``index`` is negative
2968 HeliosError: If ``index`` is out of range
2969 RuntimeError: If the native library predates helios-core v1.3.86
2970 """
2971 if index < 0:
2972 raise ValueError("index must be non-negative")
2973 return lidar_wrapper.getLiDARCellGlobalIJK(self._cloud_ptr, index)
2974
2975 def getGridGlobalCount(self) -> Tuple[int, int, int]:
2976 """
2977 Number of lattice cells along x, y and z (the ``ndiv`` passed to :meth:`addGrid`).
2978
2979 Returns:
2980 (nx, ny, nz) tuple
2981
2982 Raises:
2983 HeliosError: If the grid is empty or its cells do not form a regular lattice
2984 RuntimeError: If the native library predates helios-core v1.3.86
2985 """
2986 return lidar_wrapper.getLiDARGridGlobalCount(self._cloud_ptr)
2987
2988 def getHitPointCapacity(self) -> int:
2989 """
2990 Number of hit points the cloud can hold before its arrays reallocate.
2991
2992 The counterpart of :meth:`reserveHitPoints`: use it to confirm a reservation took
2993 effect, or to see how much headroom remains before the next growth reallocation.
2994
2995 Returns:
2996 Current hit-point capacity
2997
2998 Raises:
2999 RuntimeError: If the native library predates helios-core v1.3.86
3000 """
3001 return lidar_wrapper.getLiDARHitPointCapacity(self._cloud_ptr)
3002
3003 def setProgressCallback(self, callback):
3004 """Register a progress callback fired with ``(progress_fraction, message)`` during :meth:`syntheticScan`.
3005
3006 ``progress_fraction`` is a float in [0, 1]; ``message`` is a ``str`` describing the current
3007 phase. Pass ``None`` to clear the callback. The callback bridge is kept alive on this
3008 :class:`LiDARCloud` for as long as it is registered.
3009 """
3010 if callback is None:
3011 # Clear the native callback first, then drop our reference, so a failure in the native
3012 # call cannot leave C++ holding a freed bridge.
3013 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, None)
3014 self._progress_callback_ref = None
3015 return
3016
3017 if not callable(callback):
3018 raise TypeError("callback must be callable or None")
3020 def _trampoline(progress, message):
3021 callback(float(progress), message.decode('utf-8') if message else "")
3022
3023 # Keep the ctypes callback object alive for as long as native code holds it; ctypes does not.
3024 self._progress_callback_ref = lidar_wrapper.LiDARProgressCallback(_trampoline)
3025 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, self._progress_callback_ref)
3026
3027 def is_available(self) -> bool:
3028 """
3029 Check if LiDAR is available in current build.
3030
3031 Returns:
3032 True if plugin is available, False otherwise
3033 """
3034 registry = get_plugin_registry()
3035 return registry.is_plugin_available('lidar')
3036
3037
3038# Convenience function
3039def create_lidar_cloud() -> LiDARCloud:
3040 """
3041 Create LiDARCloud instance.
3042
3043 Returns:
3044 LiDARCloud instance
3045 """
3046 return LiDARCloud()
Storage type of a per-hit scalar-data column (see :meth:LiDARCloud.createHitDataColumn).
Definition LiDARCloud.py:37
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:93
__init__(self, float wedge_angle, float refractive_index, float rotor_rate, float phase=0.0)
Definition LiDARCloud.py:98
List[float] to_list(self)
Return the prism as a 4-element [wedge_angle, refractive_index, rotor_rate, phase] list.
High-level acquisition mode returned by :meth:LiDARCloud.getScanMode.
Definition LiDARCloud.py:70
Geometric beam pattern returned by :meth:LiDARCloud.getScanPattern.
Definition LiDARCloud.py:55
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