156 Initialize LiDARCloud.
159 LiDARError: If plugin not available in current build
160 RuntimeError: If cloud initialization fails
163 registry = get_plugin_registry()
164 if not registry.is_plugin_available(
'lidar'):
166 "LiDAR plugin not available. Rebuild PyHelios with LiDAR:\n"
167 " build_scripts/build_helios --plugins lidar\n"
169 "System requirements:\n"
170 " - Platforms: Windows, Linux, macOS\n"
171 " - GPU: Optional (enables GPU acceleration)"
176 raise LiDARError(
"Failed to create LiDAR cloud")
183 """Context manager entry"""
186 def __exit__(self, exc_type, exc_val, exc_tb):
187 """Context manager exit - cleanup resources"""
188 if hasattr(self,
'_cloud_ptr')
and self.
_cloud_ptr:
189 lidar_wrapper.destroyLiDARcloud(self.
_cloud_ptr)
193 """Fallback destructor for cleanup without context manager"""
194 if hasattr(self, '_cloud_ptr') and self._cloud_ptr is not None:
196 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
197 self._cloud_ptr = None
198 except Exception as e:
200 warnings.warn(f"Error in LiDARCloud.__del__: {e}")
202 def addScan(self, origin: Union[vec3, List[float], Tuple[float, float, float]],
203 Ntheta: int, theta_range: Tuple[float, float],
204 Nphi: int, phi_range: Tuple[float, float],
205 exit_diameter: float, beam_divergence: float,
206 column_format: Optional[List[str]] = None,
207 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
208 scan_tilt_roll: float = 0.0, scan_tilt_pitch: float = 0.0,
209 scan_azimuth_offset: float = 0.0) -> int:
211 Add a LiDAR scan to the point cloud.
214 origin: Scanner position (vec3 or 3-element list/tuple)
215 Ntheta: Number of scan points in zenith direction
216 theta_range: Zenith angle range (min, max) in radians
217 Nphi: Number of scan points in azimuthal direction
218 phi_range: Azimuthal angle range (min, max) in radians
219 exit_diameter: Laser beam exit diameter (meters)
220 beam_divergence: Beam divergence angle (radians)
221 column_format: Optional list of column-format labels. Non-standard labels
222 (anything other than geometry/standard tokens like x/y/z/r/g/b/raydir)
223 cause syntheticScan to sample that named primitive data from the struck
224 primitive onto each hit's data map, retrievable via getHitData(). Defaults
225 to None (empty format).
227 One label is special: "reflectivity_lidar" modulates each hit's "intensity"
228 (intensity *= reflectivity) rather than being stored as its own hit-data
229 key, so getHitData(i, "reflectivity_lidar") will NOT return it.
230 range_noise_stddev: Standard deviation of Gaussian range (along-beam) measurement
231 noise in meters. Only affects synthetic-scan generation. Defaults to 0.0
233 angle_noise_stddev: Standard deviation of Gaussian angular (beam-pointing) jitter
234 in radians. Only affects synthetic-scan generation. Defaults to 0.0 (jitter
236 scan_tilt_roll: Global scanner tilt roll angle in radians, modeling residual tilt of
237 the scanner spin axis away from plumb (right-hand rotation about the body lateral
238 axis). Only affects synthetic-scan generation. Defaults to 0.0 (level).
239 scan_tilt_pitch: Global scanner tilt pitch angle in radians (right-hand rotation about
240 the body forward/azimuth-zero axis). Only affects synthetic-scan generation.
241 Defaults to 0.0 (level).
242 scan_azimuth_offset: Global scanner azimuth (heading) offset in radians, a right-hand
243 rotation about the world +z axis applied on top of the azimuth sweep. Only affects
244 synthetic-scan generation. Defaults to 0.0 (no offset).
247 Scan ID for referencing this scan
250 >>> scan_id = lidar.addScan(
251 ... origin=vec3(0, 0, 1),
252 ... Ntheta=100, theta_range=(0, 1.57),
253 ... Nphi=100, phi_range=(-3.14, 3.14),
254 ... exit_diameter=0.01, beam_divergence=0.001,
255 ... column_format=["my_scalar"]
258 # Convert origin to vec3 if needed
259 if isinstance(origin, (list, tuple)):
261 raise ValueError("Origin must have 3 elements [x, y, z]")
262 origin = vec3(*origin)
263 elif not hasattr(origin, 'x'):
264 raise ValueError("Origin must be vec3 or 3-element list/tuple")
266 origin_list = [origin.x, origin.y, origin.z]
268 # Validate scan parameters
269 validate_positive_value(Ntheta, 'Ntheta', 'addScan')
270 validate_positive_value(Nphi, 'Nphi', 'addScan')
272 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
273 raise ValueError("theta_range must be a tuple (min, max)")
274 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
275 raise ValueError("phi_range must be a tuple (min, max)")
277 if column_format is not None:
278 if not isinstance(column_format, (list, tuple)) or \
279 not all(isinstance(c, str) for c in column_format):
280 raise ValueError("column_format must be a list of strings")
281 column_format = list(column_format)
283 if range_noise_stddev < 0:
284 raise ValueError("range_noise_stddev must be non-negative")
285 if angle_noise_stddev < 0:
286 raise ValueError("angle_noise_stddev must be non-negative")
288 return lidar_wrapper.addLiDARScan(
289 self._cloud_ptr, origin_list, Ntheta, theta_range,
290 Nphi, phi_range, exit_diameter, beam_divergence, column_format,
291 range_noise_stddev, angle_noise_stddev,
292 scan_tilt_roll, scan_tilt_pitch, scan_azimuth_offset
295 def addScanMoving(self, Ntheta: int, theta_range: Tuple[float, float],
296 Nphi: int, phi_range: Tuple[float, float],
297 exit_diameter: float, beam_divergence: float,
299 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
300 traj_rot: List[List[float]], pulse_rate_hz: float,
301 rot_is_quaternion: bool = True,
302 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
303 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
304 column_format: Optional[List[str]] = None,
305 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
306 t0: float = 0.0) -> int:
308 Add a moving-platform (mobile/airborne) raster LiDAR scan driven by a 6-DOF pose trajectory.
310 Unlike :meth:`addScan`, the scanner pose changes during the sweep. For each pulse the synthetic-scan
311 generator computes its acquisition time ``t = t0 + ordinal / pulse_rate_hz``, interpolates the platform
312 pose at that time (linear position, SLERP orientation), and emits a per-pulse origin
313 ``o = pos + R(q) * lever_arm`` and direction ``d = R(q) * R(boresight) * d_body``. Every resulting hit
314 and miss stores its own origin (hit-data "origin_x"/"origin_y"/"origin_z", retrievable via
315 :meth:`getHitOrigin`), timestamp ("timestamp"), and firing index ("pulse_id").
317 The static tilt roll/pitch/azimuth fields are NOT applied in this mode; attitude comes entirely
318 from the trajectory and the boresight misalignment. Because the pulses do not lie on a fixed
319 theta-phi grid they cannot be triangulated, so leaf-area inversion must use
320 :meth:`calculateLeafArea` with an explicit ``Gtheta``.
323 Ntheta: Number of scan points in zenith direction (raster grid rows)
324 theta_range: Zenith angle range (min, max) in radians
325 Nphi: Number of scan points in azimuthal direction (raster grid columns)
326 phi_range: Azimuthal angle range (min, max) in radians
327 exit_diameter: Laser beam exit diameter (meters)
328 beam_divergence: Beam divergence angle (radians)
329 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
330 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
331 traj_rot: Platform orientations, one entry per traj_t entry. Each entry is a length-4
332 quaternion (qx, qy, qz, qw, Hamilton body->world) when ``rot_is_quaternion`` is True,
333 otherwise a length-3 roll/pitch/yaw Euler triple in radians (intrinsic Z-Y-X).
334 pulse_rate_hz: Pulse repetition rate in Hz (must be > 0)
335 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
336 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
337 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
338 column_format: Optional list of column-format labels (see addScan)
339 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
340 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
341 t0: Time of the first pulse in seconds (relative time; default 0)
344 Scan ID for referencing this scan
346 validate_positive_value(Ntheta, 'Ntheta', 'addScanMoving')
347 validate_positive_value(Nphi, 'Nphi', 'addScanMoving')
349 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
350 raise ValueError("theta_range must be a tuple (min, max)")
351 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
352 raise ValueError("phi_range must be a tuple (min, max)")
353 if pulse_rate_hz <= 0:
354 raise ValueError("pulse_rate_hz must be greater than 0")
355 if range_noise_stddev < 0:
356 raise ValueError("range_noise_stddev must be non-negative")
357 if angle_noise_stddev < 0:
358 raise ValueError("angle_noise_stddev must be non-negative")
360 rot_stride = 4 if rot_is_quaternion else 3
361 _, pos_list, rot_list = self._validate_trajectory(
362 traj_t, traj_pos, traj_rot, rot_stride, 'addScanMoving')
364 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
365 else list(lever_arm)) if lever_arm is not None else None
366 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
367 else list(boresight_rpy)) if boresight_rpy is not None else None
369 if column_format is not None:
370 if not isinstance(column_format, (list, tuple)) or \
371 not all(isinstance(c, str) for c in column_format):
372 raise ValueError("column_format must be a list of strings")
373 column_format = list(column_format)
375 return lidar_wrapper.addLiDARScanMoving(
376 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
377 exit_diameter, beam_divergence,
378 [float(t) for t in traj_t], pos_list, rot_list, bool(rot_is_quaternion),
379 float(pulse_rate_hz), lever_list, boresight_list, column_format,
380 range_noise_stddev, angle_noise_stddev, float(t0)
384 def _validate_trajectory(traj_t, traj_pos, traj_rot, rot_stride, method):
385 """Shared trajectory validation/marshalling for moving/spinning scans.
387 Returns (t_list, pos_list, rot_list) of plain Python floats. rot_stride is 4 for
388 quaternions or 3 for Euler triples; pass rot_stride=None to skip rotation validation.
390 if not isinstance(traj_t, (list, tuple)) or len(traj_t) == 0:
391 raise ValueError("traj_t must be a non-empty list of trajectory sample times")
393 if len(traj_pos) != M:
394 raise ValueError("traj_t and traj_pos must have the same length M")
395 if rot_stride is not None and len(traj_rot) != M:
396 raise ValueError("traj_t and the trajectory orientation list must have the same length M")
397 # Fail fast on a non-monotonic trajectory rather than deferring to a C++ exception.
398 if any(traj_t[i] >= traj_t[i + 1] for i in range(M - 1)):
399 raise ValueError("traj_t must be strictly monotonically increasing")
401 def _to_xyz(v, name):
403 return [v.x, v.y, v.z]
404 if isinstance(v, (list, tuple)) and len(v) == 3:
405 return [float(c) for c in v]
406 raise ValueError(f"{name} must be a vec3 or 3-element list/tuple")
408 pos_list = [_to_xyz(p, "Each traj_pos entry") for p in traj_pos]
411 if rot_stride is not None:
414 if not isinstance(r, (list, tuple)) or len(r) != rot_stride:
415 label = 'qx,qy,qz,qw' if rot_stride == 4 else 'roll,pitch,yaw'
417 f"Each trajectory orientation entry must have {rot_stride} elements ({label})"
419 rot_list.append([float(c) for c in r])
420 return [float(t) for t in traj_t], pos_list, rot_list
422 def addScanSpinning(self, beam_elevation_angles: List[float],
423 azimuth_step: float, pulse_rate_hz: float,
425 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
426 traj_rot: List[List[float]],
427 rot_is_quaternion: bool = True,
428 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
429 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
430 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
431 column_format: Optional[List[str]] = None,
432 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
433 t0: float = 0.0) -> int:
435 Add a continuously-spinning multibeam scan from physical instrument parameters.
437 High-level entry point for a rotating multi-channel sensor (Velodyne/Ouster/Hesai) on a moving
438 (or stationary) platform. The azimuth grid, rotation rate, and revolution count are derived
439 internally from the azimuth resolution, PRF, and trajectory duration; you never specify an
440 azimuth range or step count. Sets the scan's :class:`ScanMode` to ``SPINNING``. For a stationary
441 "spin in place" capture (a tripod), supply two coincident poses (same position and orientation)
442 separated in time by the acquisition duration.
445 beam_elevation_angles: Per-channel beam ELEVATION angles above the horizon, in radians
446 (NOT zenith — elevation above the horizon, where zenith = pi/2 - elevation; this matches
447 manufacturer spec sheets)
448 azimuth_step: Azimuth angular resolution in radians per firing step (must be > 0)
449 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
450 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
451 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
452 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
453 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
454 Euler triple in radians (intrinsic Z-Y-X).
455 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
456 exit_diameter: Laser beam exit diameter (meters, default 0)
457 beam_divergence: Beam divergence angle (radians, default 0)
458 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
459 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
460 column_format: Optional list of column-format labels (default ["x", "y", "z"])
461 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
462 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
463 t0: Time of the first pulse in seconds (relative time; default 0)
466 Scan ID for referencing this scan
468 if not isinstance(beam_elevation_angles, (list, tuple)) or len(beam_elevation_angles) == 0:
469 raise ValueError("beam_elevation_angles must be a non-empty list of per-channel angles")
470 if azimuth_step <= 0:
471 raise ValueError("azimuth_step must be greater than 0")
472 if pulse_rate_hz <= 0:
473 raise ValueError("pulse_rate_hz must be greater than 0")
474 if range_noise_stddev < 0:
475 raise ValueError("range_noise_stddev must be non-negative")
476 if angle_noise_stddev < 0:
477 raise ValueError("angle_noise_stddev must be non-negative")
479 rot_stride = 4 if rot_is_quaternion else 3
480 t_list, pos_list, rot_list = self._validate_trajectory(
481 traj_t, traj_pos, traj_rot, rot_stride, 'addScanSpinning')
483 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
484 else list(lever_arm)) if lever_arm is not None else None
485 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
486 else list(boresight_rpy)) if boresight_rpy is not None else None
488 if column_format is not None:
489 if not isinstance(column_format, (list, tuple)) or \
490 not all(isinstance(c, str) for c in column_format):
491 raise ValueError("column_format must be a list of strings")
492 column_format = list(column_format)
494 return lidar_wrapper.addLiDARScanSpinning(
495 self._cloud_ptr, [float(a) for a in beam_elevation_angles],
496 float(azimuth_step), float(pulse_rate_hz),
497 t_list, pos_list, rot_list, bool(rot_is_quaternion),
498 exit_diameter, beam_divergence,
499 lever_list, boresight_list, column_format,
500 range_noise_stddev, angle_noise_stddev, float(t0)
503 def addScanMovingRaster(self, Ntheta: int, theta_range: Tuple[float, float],
504 Nphi: int, phi_range: Tuple[float, float],
505 pulse_rate_hz: float,
507 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
508 traj_quat: List[List[float]],
509 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
510 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
511 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
512 column_format: Optional[List[str]] = None,
513 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
514 t0: float = 0.0) -> int:
516 Add a moving-platform raster scan: a fixed angular fan swept along a quaternion trajectory.
518 High-level wrapper around :meth:`addScanMoving` for a non-spinning sensor on a moving platform.
519 Specify the per-frame angular fan resolution plus the trajectory and PRF; Helios derives the
520 per-pulse time sampling along the trajectory. Sets the scan's :class:`ScanMode` to ``MOVING_RASTER``.
523 Ntheta: Number of zenith samples in the angular fan
524 theta_range: Zenith angle range (min, max) in radians
525 Nphi: Number of azimuth samples in the angular fan
526 phi_range: Azimuthal angle range (min, max) in radians
527 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
528 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
529 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
530 traj_quat: Platform orientation quaternions (qx, qy, qz, qw, Hamilton body->world), one per
532 exit_diameter: Laser beam exit diameter (meters, default 0)
533 beam_divergence: Beam divergence angle (radians, default 0)
534 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
535 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
536 column_format: Optional list of column-format labels (default ["x", "y", "z"])
537 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
538 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
539 t0: Time of the first pulse in seconds (relative time; default 0)
542 Scan ID for referencing this scan
544 validate_positive_value(Ntheta, 'Ntheta', 'addScanMovingRaster')
545 validate_positive_value(Nphi, 'Nphi', 'addScanMovingRaster')
546 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
547 raise ValueError("theta_range must be a tuple (min, max)")
548 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
549 raise ValueError("phi_range must be a tuple (min, max)")
550 if pulse_rate_hz <= 0:
551 raise ValueError("pulse_rate_hz must be greater than 0")
552 if range_noise_stddev < 0:
553 raise ValueError("range_noise_stddev must be non-negative")
554 if angle_noise_stddev < 0:
555 raise ValueError("angle_noise_stddev must be non-negative")
557 t_list, pos_list, quat_list = self._validate_trajectory(
558 traj_t, traj_pos, traj_quat, 4, 'addScanMovingRaster')
560 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
561 else list(lever_arm)) if lever_arm is not None else None
562 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
563 else list(boresight_rpy)) if boresight_rpy is not None else None
565 if column_format is not None:
566 if not isinstance(column_format, (list, tuple)) or \
567 not all(isinstance(c, str) for c in column_format):
568 raise ValueError("column_format must be a list of strings")
569 column_format = list(column_format)
571 return lidar_wrapper.addLiDARScanMovingRaster(
572 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
573 float(pulse_rate_hz),
574 t_list, pos_list, quat_list,
575 exit_diameter, beam_divergence,
576 lever_list, boresight_list, column_format,
577 range_noise_stddev, angle_noise_stddev, float(t0)
580 def addScanRisley(self, prisms: List[Union['RisleyPrism', List[float], Tuple[float, ...]]],
581 refractive_index_air: float, pulse_rate_hz: float,
583 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
584 traj_rot: List[List[float]],
585 rot_is_quaternion: bool = True,
586 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
587 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
588 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
589 column_format: Optional[List[str]] = None,
590 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
591 t0: float = 0.0) -> int:
593 Add a rotating-Risley-prism (Livox-style rosette) scan from physical instrument parameters.
595 High-level entry point for a Livox rosette-pattern sensor (Mid-40/Mid-70/Avia). A single beam is
596 refracted through a stack of continuously rotating wedge prisms, tracing a non-repetitive rosette
597 that fills a circular field of view. The scan is stored as an Ntheta=1, Nphi=Npulses table, where
598 Npulses = round(pulse_rate_hz * trajectory_duration). Sets the scan's :class:`ScanMode` to
599 ``RISLEY_PRISM`` and :class:`ScanPattern` to ``RISLEY_PRISM``. Like a spinning scan it is always
600 trajectory-driven; a stationary tripod capture is two coincident poses (same position and
601 orientation) separated in time by the acquisition duration.
604 prisms: Rotating wedge prisms in beam-traversal order (at least one; a Livox sensor uses two
605 counter-rotating prisms). Each entry is a :class:`RisleyPrism` or a 4-element
606 [wedge_angle, refractive_index, rotor_rate, phase] list/tuple (radians / unitless / rad-per-s / radians).
607 refractive_index_air: Refractive index of the medium surrounding the prisms (typically 1.0)
608 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
609 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
610 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
611 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
612 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
613 Euler triple in radians (intrinsic Z-Y-X).
614 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
615 exit_diameter: Laser beam exit diameter (meters, default 0)
616 beam_divergence: Beam divergence angle (radians, default 0)
617 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
618 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
619 column_format: Optional list of column-format labels (default ["x", "y", "z"])
620 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
621 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
622 t0: Time of the first pulse in seconds (relative time; default 0)
625 Scan ID for referencing this scan
627 if not isinstance(prisms, (list, tuple)) or len(prisms) == 0:
628 raise ValueError("prisms must be a non-empty list of RisleyPrism or 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
631 if isinstance(p, RisleyPrism):
632 prism_lists.append(p.to_list())
633 elif isinstance(p, (list, tuple)) and len(p) == 4:
634 prism_lists.append([float(c) for c in p])
636 raise ValueError("Each prism must be a RisleyPrism or a 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
637 if refractive_index_air <= 0:
638 raise ValueError("refractive_index_air must be greater than 0")
639 if pulse_rate_hz <= 0:
640 raise ValueError("pulse_rate_hz must be greater than 0")
641 if range_noise_stddev < 0:
642 raise ValueError("range_noise_stddev must be non-negative")
643 if angle_noise_stddev < 0:
644 raise ValueError("angle_noise_stddev must be non-negative")
646 rot_stride = 4 if rot_is_quaternion else 3
647 t_list, pos_list, rot_list = self._validate_trajectory(
648 traj_t, traj_pos, traj_rot, rot_stride, 'addScanRisley')
650 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
651 else list(lever_arm)) if lever_arm is not None else None
652 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
653 else list(boresight_rpy)) if boresight_rpy is not None else None
655 if column_format is not None:
656 if not isinstance(column_format, (list, tuple)) or \
657 not all(isinstance(c, str) for c in column_format):
658 raise ValueError("column_format must be a list of strings")
659 column_format = list(column_format)
661 return lidar_wrapper.addLiDARScanRisley(
662 self._cloud_ptr, prism_lists, float(refractive_index_air), float(pulse_rate_hz),
663 t_list, pos_list, rot_list, bool(rot_is_quaternion),
664 exit_diameter, beam_divergence,
665 lever_list, boresight_list, column_format,
666 range_noise_stddev, angle_noise_stddev, float(t0)
669 def getScanCount(self) -> int:
670 """Get total number of scans in the cloud"""
671 return lidar_wrapper.getLiDARScanCount(self._cloud_ptr)
673 def getScanOrigin(self, scanID: int) -> vec3:
674 """Get origin of a specific scan"""
676 raise ValueError("Scan ID must be non-negative")
677 origin_list = lidar_wrapper.getLiDARScanOrigin(self._cloud_ptr, scanID)
678 return vec3(*origin_list)
680 def getScanSizeTheta(self, scanID: int) -> int:
681 """Get number of zenith scan points for a scan"""
683 raise ValueError("Scan ID must be non-negative")
684 return lidar_wrapper.getLiDARScanSizeTheta(self._cloud_ptr, scanID)
686 def getScanSizePhi(self, scanID: int) -> int:
687 """Get number of azimuthal scan points for a scan"""
689 raise ValueError("Scan ID must be non-negative")
690 return lidar_wrapper.getLiDARScanSizePhi(self._cloud_ptr, scanID)
692 def getScanRangeNoiseStdDev(self, scanID: int) -> float:
693 """Get the range (along-beam) measurement noise standard deviation for a scan (meters).
695 Returns the value supplied to addScan() as ``range_noise_stddev`` (0.0 if disabled).
698 raise ValueError("Scan ID must be non-negative")
699 return lidar_wrapper.getLiDARScanRangeNoiseStdDev(self._cloud_ptr, scanID)
701 def getScanAngleNoiseStdDev(self, scanID: int) -> float:
702 """Get the angular (beam-pointing) jitter standard deviation for a scan (radians).
704 Returns the value supplied to addScan() as ``angle_noise_stddev`` (0.0 if disabled).
707 raise ValueError("Scan ID must be non-negative")
708 return lidar_wrapper.getLiDARScanAngleNoiseStdDev(self._cloud_ptr, scanID)
710 def getScanTiltRoll(self, scanID: int) -> float:
711 """Get the global scanner tilt roll angle for a scan (radians; 0.0 if level)."""
713 raise ValueError("Scan ID must be non-negative")
714 return lidar_wrapper.getLiDARScanTiltRoll(self._cloud_ptr, scanID)
716 def getScanTiltPitch(self, scanID: int) -> float:
717 """Get the global scanner tilt pitch angle for a scan (radians; 0.0 if level)."""
719 raise ValueError("Scan ID must be non-negative")
720 return lidar_wrapper.getLiDARScanTiltPitch(self._cloud_ptr, scanID)
722 def getScanAzimuthOffset(self, scanID: int) -> float:
723 """Get the global scanner azimuth (heading) offset for a scan (radians; 0.0 if none)."""
725 raise ValueError("Scan ID must be non-negative")
726 return lidar_wrapper.getLiDARScanAzimuthOffset(self._cloud_ptr, scanID)
728 def getScanPattern(self, scanID: int) -> int:
729 """Get the scan pattern for a scan.
731 Returns an integer: 0 = raster (uniform angular grid), 1 = spinning multibeam
732 (rotating multi-channel sensor), 2 = Risley-prism (Livox-style rosette). Compare against
733 ``ScanPattern.RASTER`` / ``ScanPattern.SPINNING_MULTIBEAM`` / ``ScanPattern.RISLEY_PRISM``.
736 raise ValueError("Scan ID must be non-negative")
737 return lidar_wrapper.getLiDARScanPattern(self._cloud_ptr, scanID)
739 def getScanBeamZenithAngles(self, scanID: int) -> List[float]:
740 """Get the per-channel beam zenith angles (radians) for a multibeam scan.
742 Returns an empty list for a raster scan.
745 raise ValueError("Scan ID must be non-negative")
746 return lidar_wrapper.getLiDARScanBeamZenithAngles(self._cloud_ptr, scanID)
748 def getScanMode(self, scanID: int) -> ScanMode:
749 """Get the high-level acquisition mode of a scan as a :class:`ScanMode`.
751 STATIC_RASTER (fixed-origin grid), MOVING_RASTER (fan swept along a trajectory),
752 SPINNING (continuously-rotating multi-channel sensor), or RISLEY_PRISM (Livox-style rosette).
755 raise ValueError("Scan ID must be non-negative")
756 return ScanMode(lidar_wrapper.getLiDARScanMode(self._cloud_ptr, scanID))
758 def getScanStepsPerRev(self, scanID: int) -> int:
759 """Get the number of azimuth firing steps per revolution (spinning scans; 0 otherwise)."""
761 raise ValueError("Scan ID must be non-negative")
762 return lidar_wrapper.getLiDARScanStepsPerRev(self._cloud_ptr, scanID)
764 def getScanRotationRate(self, scanID: int) -> float:
765 """Get the sensor-head rotation rate in revolutions/second (spinning scans; 0 otherwise)."""
767 raise ValueError("Scan ID must be non-negative")
768 return lidar_wrapper.getLiDARScanRotationRate(self._cloud_ptr, scanID)
770 def getScanRevolutions(self, scanID: int) -> float:
771 """Get the number of revolutions the sensor head made (spinning scans; 0 otherwise)."""
773 raise ValueError("Scan ID must be non-negative")
774 return lidar_wrapper.getLiDARScanRevolutions(self._cloud_ptr, scanID)
776 def getScanRisleyPrisms(self, scanID: int) -> List[RisleyPrism]:
777 """Get the rotating wedge prisms of a Risley-prism scan as a list of :class:`RisleyPrism`.
779 Returns the prism stack in beam-traversal order (empty for non-Risley scans).
782 raise ValueError("Scan ID must be non-negative")
783 raw = lidar_wrapper.getLiDARScanRisleyPrisms(self._cloud_ptr, scanID)
784 return [RisleyPrism(p[0], p[1], p[2], p[3]) for p in raw]
786 def getScanRisleyRefractiveIndexAir(self, scanID: int) -> float:
787 """Get the refractive index of the medium surrounding a Risley scan's prisms (1.0 for non-Risley)."""
789 raise ValueError("Scan ID must be non-negative")
790 return lidar_wrapper.getLiDARScanRisleyRefractiveIndexAir(self._cloud_ptr, scanID)
792 def getScanReturnMode(self, scanID: int) -> ReturnMode:
793 """Get the return-reporting mode of a scan as a :class:`ReturnMode` (MULTI or SINGLE)."""
795 raise ValueError("Scan ID must be non-negative")
796 return ReturnMode(lidar_wrapper.getLiDARScanReturnMode(self._cloud_ptr, scanID))
798 def setScanReturnMode(self, scanID: int, return_mode: Union[ReturnMode, int]):
799 """Set the return-reporting mode of a scan (ReturnMode.MULTI or ReturnMode.SINGLE).
801 Only affects analytic-waveform synthetic scans (more than one ray per pulse).
804 raise ValueError("Scan ID must be non-negative")
805 lidar_wrapper.setLiDARScanReturnMode(self._cloud_ptr, scanID, int(return_mode))
807 def getScanSingleReturnSelection(self, scanID: int) -> SingleReturnSelection:
808 """Get the single/limited-return selection policy as a :class:`SingleReturnSelection`."""
810 raise ValueError("Scan ID must be non-negative")
811 return SingleReturnSelection(
812 lidar_wrapper.getLiDARScanSingleReturnSelection(self._cloud_ptr, scanID))
814 def setScanSingleReturnSelection(self, scanID: int, selection: Union[SingleReturnSelection, int]):
815 """Set the single/limited-return selection policy (STRONGEST, FIRST, LAST, or STRONGEST_PLUS_LAST).
817 Used when the scan's return mode is SINGLE and a pulse resolves more returns than maxReturns.
818 STRONGEST_PLUS_LAST is a dual-return mode that intrinsically yields 1 or 2 returns and
822 raise ValueError("Scan ID must be non-negative")
823 lidar_wrapper.setLiDARScanSingleReturnSelection(self._cloud_ptr, scanID, int(selection))
825 def getScanMaxReturns(self, scanID: int) -> int:
826 """Get the maximum returns per pulse used in single/limited-return mode (1 = single, N = N-return)."""
828 raise ValueError("Scan ID must be non-negative")
829 return lidar_wrapper.getLiDARScanMaxReturns(self._cloud_ptr, scanID)
831 def setScanMaxReturns(self, scanID: int, max_returns: int):
832 """Set the maximum returns per pulse used in single/limited-return mode (must be >= 1)."""
834 raise ValueError("Scan ID must be non-negative")
836 raise ValueError("max_returns must be >= 1")
837 lidar_wrapper.setLiDARScanMaxReturns(self._cloud_ptr, scanID, int(max_returns))
839 def setSyntheticScanMemoryBudget(self, bytes: int):
840 """Set the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
842 :meth:`syntheticScan` fans each pulse into ``rays_per_pulse`` sub-rays; for a
843 large scan the simultaneously-traced sub-rays can demand many gigabytes if
844 traced in one batch. This caps the live trace scratch buffers, so the per-scan
845 beam fan-out is processed in chunks sized to stay near this budget regardless of
846 scan resolution. It bounds only the transient buffers, not the output cloud.
848 If never called, the budget is automatic and path-dependent (8 GiB on a GPU
849 build, 4 GiB otherwise). Call this to override that with a fixed cap, typically
850 to lower peak memory on a constrained host.
853 bytes: Soft cap in bytes on the live ray-tracing scratch buffers. Must be > 0.
856 raise ValueError("memory budget must be greater than zero")
857 lidar_wrapper.setLiDARSyntheticScanMemoryBudget(self._cloud_ptr, int(bytes))
859 def getSyntheticScanMemoryBudget(self) -> int:
860 """Get the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
862 Returns the explicitly configured budget set via :meth:`setSyntheticScanMemoryBudget`, or
863 0 if using the automatic path-dependent default (8 GiB on a GPU build, 4 GiB otherwise).
865 return lidar_wrapper.getLiDARSyntheticScanMemoryBudget(self._cloud_ptr)
867 def getScanPulseWidth(self, scanID: int) -> float:
868 """Get the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
870 raise ValueError("Scan ID must be non-negative")
871 return lidar_wrapper.getLiDARScanPulseWidth(self._cloud_ptr, scanID)
873 def setScanPulseWidth(self, scanID: int, pulse_width: float):
874 """Set the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
876 raise ValueError("Scan ID must be non-negative")
878 raise ValueError("pulse_width must be non-negative")
879 lidar_wrapper.setLiDARScanPulseWidth(self._cloud_ptr, scanID, float(pulse_width))
881 def getScanDetectionThreshold(self, scanID: int) -> float:
882 """Get the detection threshold (energy fraction, noise floor) of a scan."""
884 raise ValueError("Scan ID must be non-negative")
885 return lidar_wrapper.getLiDARScanDetectionThreshold(self._cloud_ptr, scanID)
887 def setScanDetectionThreshold(self, scanID: int, detection_threshold: float):
888 """Set the detection threshold (energy fraction, noise floor) of a scan."""
890 raise ValueError("Scan ID must be non-negative")
891 if detection_threshold < 0:
892 raise ValueError("detection_threshold must be non-negative")
893 lidar_wrapper.setLiDARScanDetectionThreshold(self._cloud_ptr, scanID, float(detection_threshold))
895 def addHitPoint(self, scanID: int,
896 xyz: Union[vec3, List[float], Tuple[float, float, float]],
897 direction: Union[vec3, SphericalCoord, List[float], Tuple[float, float]],
898 color: Optional[Union[RGBcolor, List[float], Tuple[float, float, float]]] = None):
900 Add a hit point to the point cloud.
903 scanID: Scan ID this hit belongs to
904 xyz: Hit point coordinates (vec3 or 3-element list)
905 direction: Ray direction (vec3/SphericalCoord or 2-3 element list)
906 color: Optional RGB color (RGBcolor or 3-element list)
908 # Convert xyz to list
909 if isinstance(xyz, (list, tuple)):
911 raise ValueError("XYZ must have 3 elements")
913 elif hasattr(xyz, 'x'):
914 xyz_list = [xyz.x, xyz.y, xyz.z]
916 raise ValueError("XYZ must be vec3 or 3-element list/tuple")
918 # Convert direction to list
919 if isinstance(direction, (list, tuple)):
920 if len(direction) < 2:
921 raise ValueError("Direction must have at least 2 elements [radius, elevation]")
922 direction_list = list(direction)
923 elif hasattr(direction, 'radius'): # SphericalCoord
924 direction_list = [direction.radius, direction.elevation, direction.azimuth]
925 elif hasattr(direction, 'x'): # vec3
926 direction_list = [direction.x, direction.y, direction.z]
928 raise ValueError("Direction must be vec3/SphericalCoord or 2-3 element list")
930 # Add with or without color
931 if color is not None:
932 if isinstance(color, (list, tuple)):
934 raise ValueError("Color must have 3 elements [r, g, b]")
935 color_list = list(color)
936 elif hasattr(color, 'r'):
937 color_list = [color.r, color.g, color.b]
939 raise ValueError("Color must be RGBcolor or 3-element list")
941 lidar_wrapper.addLiDARHitPointRGB(self._cloud_ptr, scanID, xyz_list, direction_list, color_list)
943 lidar_wrapper.addLiDARHitPoint(self._cloud_ptr, scanID, xyz_list, direction_list)
945 def addHitPoints(self, scanID: int, xyz_array, direction_array, color_array=None):
947 Add many hit points to the point cloud in a single bulk call.
949 This skips the per-point Python loop by passing contiguous buffers
950 straight to the native library in one FFI call.
953 scanID: Scan ID these hits belong to
954 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
955 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth]
956 (azimuth is currently ignored, matching addHitPoint)
957 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
961 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
962 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
964 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
965 raise ValueError("xyz_array must have shape (N, 3)")
966 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
967 raise ValueError("direction_array must have shape (N, 3)")
969 count = xyz_array.shape[0]
970 if direction_array.shape[0] != count:
971 raise ValueError("xyz_array and direction_array must have the same number of rows")
973 if color_array is not None:
974 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
975 if color_array.ndim != 2 or color_array.shape[1] != 3:
976 raise ValueError("color_array must have shape (N, 3)")
977 if color_array.shape[0] != count:
978 raise ValueError("color_array must have the same number of rows as xyz_array")
980 lidar_wrapper.addLiDARHitPoints(self._cloud_ptr, scanID,
981 xyz_array, direction_array, count, color_array)
983 def addHitPointsWithData(self, scanID: int, xyz_array, direction_array,
984 data_labels=None, data_values=None, color_array=None):
986 Add many hit points carrying a per-hit data map in a single bulk call.
988 Like addHitPoints, but also populates each hit's named-scalar data map —
989 the in-memory equivalent of what the ASCII loader does for non-standard
990 columns. This is the path multi-return LAD needs (timestamp/target_index/
991 target_count land in the map so gapfillMisses() can group beams by pulse).
994 scanID: Scan ID these hits belong to (the scan must already exist)
995 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
996 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth].
997 Pass cart2sphere(xyz - origin) to match loadASCIIFile;
998 the full SphericalCoord (incl. radius) is used.
999 data_labels: Optional list of data-map key names (length k)
1000 data_values: Optional (N, k) values for those keys (float64)
1001 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
1005 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
1006 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
1008 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
1009 raise ValueError("xyz_array must have shape (N, 3)")
1010 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
1011 raise ValueError("direction_array must have shape (N, 3)")
1013 count = xyz_array.shape[0]
1014 if direction_array.shape[0] != count:
1015 raise ValueError("xyz_array and direction_array must have the same number of rows")
1017 labels = list(data_labels or [])
1019 data_values = np.ascontiguousarray(data_values, dtype=np.float64)
1020 if data_values.ndim != 2 or data_values.shape != (count, len(labels)):
1021 raise ValueError("data_values must have shape (N, len(data_labels))")
1025 if color_array is not None:
1026 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
1027 if color_array.ndim != 2 or color_array.shape[1] != 3:
1028 raise ValueError("color_array must have shape (N, 3)")
1029 if color_array.shape[0] != count:
1030 raise ValueError("color_array must have the same number of rows as xyz_array")
1032 lidar_wrapper.addLiDARHitPointsWithData(
1033 self._cloud_ptr, scanID, xyz_array, direction_array, count,
1034 color_array, labels, data_values)
1036 def getHitCount(self) -> int:
1037 """Get total number of hit points in cloud"""
1038 return lidar_wrapper.getLiDARHitCount(self._cloud_ptr)
1040 def getHitXYZ(self, index: int) -> vec3:
1041 """Get coordinates of a hit point"""
1043 raise ValueError("Index must be non-negative")
1044 xyz_list = lidar_wrapper.getLiDARHitXYZ(self._cloud_ptr, index)
1045 return vec3(*xyz_list)
1047 def getHitOrigin(self, index: int) -> vec3:
1048 """Get the (x,y,z) beam-emission origin of a hit point.
1050 For moving-platform scans (see :meth:`addScanMoving`) this is the per-pulse emission origin
1051 recorded on the hit; for static scans it falls back to the single scan origin of the hit's scan.
1054 raise ValueError("Index must be non-negative")
1055 xyz_list = lidar_wrapper.getLiDARHitOrigin(self._cloud_ptr, index)
1056 return vec3(*xyz_list)
1058 def getHitRaydir(self, index: int) -> SphericalCoord:
1059 """Get ray direction of a hit point"""
1061 raise ValueError("Index must be non-negative")
1062 direction_list = lidar_wrapper.getLiDARHitRaydir(self._cloud_ptr, index)
1063 # direction_list is [radius, elevation, azimuth]; preserve azimuth (was previously dropped).
1064 return SphericalCoord(direction_list[0], direction_list[1], direction_list[2])
1066 def getHitColor(self, index: int) -> RGBcolor:
1067 """Get color of a hit point"""
1069 raise ValueError("Index must be non-negative")
1070 color_list = lidar_wrapper.getLiDARHitColor(self._cloud_ptr, index)
1071 return RGBcolor(*color_list)
1073 def getHitScanID(self, index: int) -> int:
1074 """Get the scan ID a hit point belongs to"""
1076 raise ValueError("Index must be non-negative")
1077 return lidar_wrapper.getLiDARHitScanID(self._cloud_ptr, index)
1079 def doesHitDataExist(self, index: int, label: str) -> bool:
1080 """Check whether a named scalar data value exists for a hit point.
1082 Per-hit data computed by syntheticScan includes 'intensity', 'distance',
1083 'timestamp', 'target_index', 'target_count', 'deviation', 'nRaysHit', plus any
1084 primitive-data labels listed in the scan's column_format.
1087 raise ValueError("Index must be non-negative")
1088 return lidar_wrapper.doesLiDARHitDataExist(self._cloud_ptr, index, label)
1090 def getHitData(self, index: int, label: str) -> float:
1091 """Get a named scalar data value for a hit point.
1093 Raises HeliosError if the label does not exist for this hit; guard with
1094 doesHitDataExist() when unsure.
1097 raise ValueError("Index must be non-negative")
1098 return lidar_wrapper.getLiDARHitData(self._cloud_ptr, index, label)
1100 def getHitDataAll(self, label: str) -> List[float]:
1101 """Bulk-export a named scalar data value for all hits in a single FFI call.
1103 Returns a list of length getHitCount(); entries are NaN where the label is
1104 absent for that hit. Much faster than looping getHitData() for large clouds.
1106 Note: values are returned at float32 precision (vs. getHitData(), which returns
1107 full float64). Use getHitData() per-hit if full precision is required.
1109 n = self.getHitCount()
1112 return lidar_wrapper.getLiDARHitData_all(self._cloud_ptr, label, n)
1114 def getHitsXYZRGB(self) -> Tuple[List[vec3], List[RGBcolor]]:
1115 """Bulk-export coordinates and colors for all hits in a single FFI call.
1117 Returns (positions, colors) where positions is a list of vec3 and colors a list
1118 of RGBcolor, each of length getHitCount(). Much faster than looping
1119 getHitXYZ()/getHitColor() for large clouds.
1121 n = self.getHitCount()
1124 xyz_flat, rgb_flat = lidar_wrapper.getLiDARHitsXYZRGB_all(self._cloud_ptr, n)
1125 positions = [vec3(xyz_flat[3 * i], xyz_flat[3 * i + 1], xyz_flat[3 * i + 2]) for i in range(n)]
1126 colors = [RGBcolor(rgb_flat[3 * i], rgb_flat[3 * i + 1], rgb_flat[3 * i + 2]) for i in range(n)]
1127 return positions, colors
1129 # ---- Bulk numpy exports (single FFI call each; no per-hit Python loop) ----
1130 # These power the synthetic-scan fast path: extracting a million-hit cloud via
1131 # the per-index getters (getHitXYZ/getHitColor/getHitScanID/...) costs tens of
1132 # millions of FFI crossings, which dominated scan time. The *Array methods pull
1133 # each quantity in one contiguous copy.
1135 def getHitsXYZRGBArrays(self):
1136 """Bulk-export hit coordinates + colors as numpy arrays.
1138 Returns (xyz, rgb), each (getHitCount(), 3) float32. Empty (0,3) arrays
1139 when there are no hits.
1142 n = self.getHitCount()
1144 return np.empty((0, 3), np.float32), np.empty((0, 3), np.float32)
1145 return lidar_wrapper.getLiDARHitsXYZRGB_all_np(self._cloud_ptr, n)
1147 def getHitDataArray(self, label: str):
1148 """Bulk-export a named scalar field as an (getHitCount(),) float32 array,
1149 NaN where the label is absent for a hit."""
1151 n = self.getHitCount()
1153 return np.empty((0,), np.float32)
1154 return lidar_wrapper.getLiDARHitData_all_np(self._cloud_ptr, label, n)
1156 def getHitDataColumn(self, label: str, absent_value: float = -9999.0) -> List[float]:
1157 """Bulk-export a named scalar column via the native cache-linear columnar path.
1159 Faster than :meth:`getHitDataAll` for whole-field reads (a single cache-linear pass over
1160 the contiguous native column rather than per-hit tree lookups), and returns full float64
1161 precision. Entries are ``absent_value`` where the label is absent for a hit. Returns a list
1162 of length getHitCount().
1164 n = self.getHitCount()
1167 return lidar_wrapper.getLiDARHitDataColumn(self._cloud_ptr, label, n, absent_value)
1169 def getHitDataColumnIndex(self, label: str) -> int:
1170 """Get the internal column slot index for a hit-data label.
1172 Per-hit scalar data is stored column-wise; this resolves a label to its column slot for
1173 repeated bulk access without re-resolving the label by string. Returns -1 if the label has
1174 never been set on any hit.
1176 if not isinstance(label, str):
1177 raise TypeError(f"label must be a str, got {type(label).__name__}")
1178 return lidar_wrapper.getLiDARHitDataColumnIndex(self._cloud_ptr, label)
1180 def getHitDataColumnArray(self, label: str, absent_value: float = -9999.0):
1181 """Bulk-export a named scalar column as an (getHitCount(),) float64 numpy array
1182 via the columnar path (``absent_value`` where the label is absent for a hit)."""
1184 n = self.getHitCount()
1186 return np.empty((0,), np.float64)
1187 return lidar_wrapper.getLiDARHitDataColumn_np(self._cloud_ptr, label, n, absent_value)
1189 def getHitScanIDArray(self):
1190 """Bulk-export the scan ID of every hit as an (getHitCount(),) int32 array."""
1192 n = self.getHitCount()
1194 return np.empty((0,), np.int32)
1195 return lidar_wrapper.getLiDARHitScanID_all(self._cloud_ptr, n)
1197 def getHitMissArray(self):
1198 """Bulk-export the miss flag of every hit as an (getHitCount(),) int32
1199 array (1 == sky/miss, 0 == real surface return)."""
1201 n = self.getHitCount()
1203 return np.empty((0,), np.int32)
1204 return lidar_wrapper.isLiDARHitMiss_all(self._cloud_ptr, n)
1206 def deleteHitPoint(self, index: int):
1207 """Delete a hit point from the cloud"""
1209 raise ValueError("Index must be non-negative")
1210 lidar_wrapper.deleteLiDARHitPoint(self._cloud_ptr, index)
1212 def isHitMiss(self, index: int) -> bool:
1213 """Return True if a hit is a "miss" (a fired pulse that returned nothing).
1215 Misses are the transmitted beams that form the denominator of the per-voxel
1216 transmission probability used by :meth:`calculateLeafArea`. They are produced by
1217 ``syntheticScan(..., record_misses=True)`` and by :meth:`gapfillMisses`.
1220 raise ValueError("Index must be non-negative")
1221 return lidar_wrapper.isLiDARHitMiss(self._cloud_ptr, index)
1223 def hasMisses(self) -> bool:
1224 """Return True if the cloud contains at least one miss.
1226 :meth:`calculateLeafArea` requires misses and fails fast without them.
1228 return lidar_wrapper.lidarHasMisses(self._cloud_ptr)
1231 def getMissDistance() -> float:
1232 """Return the LIDAR_MISS_DISTANCE constant (meters): the distance at which a
1233 miss point is placed along its beam."""
1234 return lidar_wrapper.getLiDARMissDistance()
1236 def coordinateShift(self, shift: Union[vec3, List[float], Tuple[float, float, float]]):
1238 Translate all hit points by a shift vector.
1241 shift: Translation vector (vec3 or 3-element list)
1243 if isinstance(shift, (list, tuple)):
1245 raise ValueError("Shift must have 3 elements [x, y, z]")
1246 shift_list = list(shift)
1247 elif hasattr(shift, 'x'):
1248 shift_list = [shift.x, shift.y, shift.z]
1250 raise ValueError("Shift must be vec3 or 3-element list/tuple")
1252 lidar_wrapper.lidarCoordinateShift(self._cloud_ptr, shift_list)
1254 def coordinateRotation(self, rotation: Union[SphericalCoord, List[float], Tuple[float, float]]):
1256 Rotate all hit points by spherical rotation angles.
1259 rotation: Rotation angles (SphericalCoord or 2-3 element list)
1261 if isinstance(rotation, (list, tuple)):
1262 if len(rotation) < 2:
1263 raise ValueError("Rotation must have at least 2 elements [radius, elevation]")
1264 rotation_list = list(rotation)
1265 elif hasattr(rotation, 'radius'):
1266 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1268 raise ValueError("Rotation must be SphericalCoord or 2-3 element list")
1270 lidar_wrapper.lidarCoordinateRotation(self._cloud_ptr, rotation_list)
1272 def triangulateHitPoints(self, Lmax: float, max_aspect_ratio: float = 4.0):
1274 Generate triangle mesh from hit points using Delaunay triangulation.
1277 Lmax: Maximum triangle edge length
1278 max_aspect_ratio: Maximum triangle aspect ratio (default 4.0)
1280 validate_positive_value(Lmax, 'Lmax', 'triangulateHitPoints')
1281 validate_positive_value(max_aspect_ratio, 'max_aspect_ratio', 'triangulateHitPoints')
1282 lidar_wrapper.lidarTriangulateHitPoints(self._cloud_ptr, Lmax, max_aspect_ratio)
1284 def getTriangleCount(self) -> int:
1285 """Get number of triangles in the mesh"""
1286 return lidar_wrapper.getLiDARTriangleCount(self._cloud_ptr)
1288 def getTriangulationStats(self) -> dict:
1289 """Filter diagnostics from the most recent triangulateHitPoints() call.
1293 {"candidates", "dropped_lmax", "dropped_aspect", "dropped_degenerate"}
1295 Each dropped triangle is attributed to one primary reason (Lmax, then
1296 aspect, then degenerate), so ``candidates == getTriangleCount() +
1297 dropped_lmax + dropped_aspect + dropped_degenerate``. All zero if
1298 triangulation has not been run. Use this to tell whether an empty or
1299 sparse mesh is data-limited (few candidates) or filter-limited (many
1300 candidates dropped by Lmax/aspect).
1302 return lidar_wrapper.getLiDARTriangulationStats(self._cloud_ptr)
1304 def getTriangleVerticesAll(self):
1305 """Bulk-export every triangle's vertices and source scan in one call.
1307 Returns (xyz_flat, scan_ids): xyz_flat is a (T*9,) float32 array laid out
1308 [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per triangle, scan_ids is a (T,)
1309 int32 array. Avoids the Context round-trip and the per-triangle
1310 getPrimitiveVertices loop.
1312 return lidar_wrapper.getLiDARTriangleVertices_all(
1313 self._cloud_ptr, self.getTriangleCount())
1315 def setExternalTriangulation(self, vertices, scan_ids):
1316 """Replace the internal triangulation with an externally-supplied mesh.
1318 Bypasses the internal Delaunay triangulation so a mesh produced elsewhere
1319 (a re-used Helios triangulation, or a per-scan open3d Ball-Pivot mesh) can
1320 drive leaf-area inversion without a recompute. After this call,
1321 ``calculateLeafArea()`` runs unchanged.
1324 vertices: Triangle vertices in world coordinates, accepted as a
1325 (T, 9) array laid out [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per
1326 triangle, a (T, 3, 3) array, or a flat (T*9,) array -- the same
1327 layout :meth:`getTriangleVerticesAll` exports, so a Helios mesh
1328 round-trips directly.
1329 scan_ids: Source scan index for each triangle, shape (T,). Required;
1330 every entry must be a valid scan index (see :meth:`addScan`),
1331 since the leaf-angle G(theta) term needs each triangle's ray
1332 direction. A merged mesh with no scan association is not valid.
1334 A grid must already be defined (see :meth:`addGrid`).
1337 verts = np.ascontiguousarray(vertices, dtype=np.float32).reshape(-1)
1338 if verts.size % 9 != 0:
1340 f"vertices has {verts.size} floats, must be a multiple of 9 (9 per triangle)")
1341 tri_count = verts.size // 9
1343 scans = np.ascontiguousarray(scan_ids, dtype=np.int32).reshape(-1)
1344 if scans.size != tri_count:
1346 f"scan_ids has {scans.size} entries, expected {tri_count} (one per triangle)")
1348 lidar_wrapper.lidarSetExternalTriangulation(
1349 self._cloud_ptr, verts, scans, tri_count)
1351 def distanceFilter(self, maxdistance: float):
1352 """Filter hit points by maximum distance from scanne
r"""
1353 validate_positive_value(maxdistance, 'maxdistance', 'distanceFilter')
1354 lidar_wrapper.lidarDistanceFilter(self._cloud_ptr, maxdistance)
1356 def reflectanceFilter(self, minreflectance: float):
1357 """Filter hit points by minimum reflectance value"""
1358 lidar_wrapper.lidarReflectanceFilter(self._cloud_ptr, minreflectance)
1360 def firstHitFilter(self):
1361 """Keep only first return hit points"""
1362 lidar_wrapper.lidarFirstHitFilter(self._cloud_ptr)
1364 def lastHitFilter(self):
1365 """Keep only last return hit points"""
1366 lidar_wrapper.lidarLastHitFilter(self._cloud_ptr)
1368 def exportPointCloud(self, filename: str, write_header: bool = True):
1369 """Export point cloud to ASCII file.
1372 filename: Output file path.
1373 write_header: If True (default), prepend a ``#``-prefixed comment line listing the
1374 column field names (CloudCompare convention). The loader skips ``#``-prefixed
1375 lines, so headered files round-trip through ``loadXML()``. Set False for a
1379 raise ValueError("Filename cannot be empty")
1380 lidar_wrapper.exportLiDARPointCloud(self._cloud_ptr, filename, write_header)
1382 def exportLeafAreaUncertainty(self, filename: str):
1383 """Export per-voxel leaf-area sampling uncertainty to a self-describing ASCII file.
1385 The file has a ``#``-prefixed header and one row per grid cell:
1386 ``cell_index leaf_area beam_count I_rdi LAD_std_error ci_valid``. Requires that
1387 :meth:`calculateLeafArea` has been run with an ``element_width`` (the uncertainty
1391 raise ValueError("Filename cannot be empty")
1392 lidar_wrapper.exportLiDARLeafAreaUncertainty(self._cloud_ptr, filename)
1394 def exportScans(self, filename: str):
1395 """Export all scans to an XML metadata file plus one ASCII data file per scan.
1398 filename: Path of the XML metadata file to write (e.g. "output/scans.xml").
1399 One ASCII data file is auto-generated per scan, named by stripping the XML
1400 extension and appending "_<scanID>.xyz" (e.g. "output/scans_0.xyz"). The
1401 resulting XML can be re-loaded with loadXML() from the same working directory.
1404 raise ValueError("Filename cannot be empty")
1405 lidar_wrapper.exportLiDARScans(self._cloud_ptr, filename)
1407 def loadXML(self, filename: str):
1408 """Load scan metadata from XML file"""
1410 raise ValueError("Filename cannot be empty")
1411 lidar_wrapper.loadLiDARXML(self._cloud_ptr, filename)
1413 def disableMessages(self):
1414 """Disable console output messages"""
1415 lidar_wrapper.lidarDisableMessages(self._cloud_ptr)
1417 def enableMessages(self):
1418 """Enable console output messages"""
1419 lidar_wrapper.lidarEnableMessages(self._cloud_ptr)
1421 def addGrid(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1422 size: Union[vec3, List[float], Tuple[float, float, float]],
1423 ndiv: Union[List[int], Tuple[int, int, int]],
1424 rotation: float = 0.0,
1425 column_z_offsets: Optional[Union[List[float], Tuple[float, ...]]] = None):
1427 Add a rectangular grid of voxel cells.
1430 center: Grid center position (vec3 or 3-element list)
1431 size: Grid dimensions [x, y, z] (vec3 or 3-element list)
1432 ndiv: Number of divisions [nx, ny, nz] (3-element list)
1433 rotation: Azimuthal rotation angle (degrees, default 0.0)
1434 column_z_offsets: Optional per-(x,y)-column vertical offset for terrain
1435 following, row-major as ``[j*ndiv[0] + i]`` with length
1436 ``ndiv[0]*ndiv[1]``. Each vertical column of voxels is shifted in z by
1437 its column's offset so the grid can track an external terrain surface
1438 (e.g. a DEM). ``None`` (the default) builds an axis-regular grid.
1441 ``rotation`` is in **degrees** here, matching the native ``addGrid()``.
1442 :meth:`addGridCell` takes its rotation in **radians** — the two native
1443 entry points genuinely differ, and PyHelios passes each through unchanged.
1444 :meth:`getCellRotation` reports degrees.
1448 ... center=vec3(0, 0, 0.5),
1449 ... size=vec3(10, 10, 1),
1450 ... ndiv=[10, 10, 5],
1454 Terrain-following grid over a 2x2 column layout:
1457 ... center=vec3(0, 0, 0.5),
1458 ... size=vec3(10, 10, 1),
1460 ... column_z_offsets=[0.0, 0.1, 0.2, 0.3]
1463 # Convert center to list
1464 if isinstance(center, (list, tuple)):
1465 if len(center) != 3:
1466 raise ValueError("Center must have 3 elements [x, y, z]")
1467 center_list = list(center)
1468 elif hasattr(center, 'x'):
1469 center_list = [center.x, center.y, center.z]
1471 raise ValueError("Center must be vec3 or 3-element list/tuple")
1473 # Convert size to list
1474 if isinstance(size, (list, tuple)):
1476 raise ValueError("Size must have 3 elements [x, y, z]")
1477 size_list = list(size)
1478 elif hasattr(size, 'x'):
1479 size_list = [size.x, size.y, size.z]
1481 raise ValueError("Size must be vec3 or 3-element list/tuple")
1484 if not isinstance(ndiv, (list, tuple)) or len(ndiv) != 3:
1485 raise ValueError("Ndiv must be a 3-element list [nx, ny, nz]")
1487 if column_z_offsets is None:
1488 lidar_wrapper.addLiDARGrid(self._cloud_ptr, center_list, size_list, list(ndiv), rotation)
1491 if not isinstance(column_z_offsets, (list, tuple)):
1493 "column_z_offsets must be a list or tuple of floats, got "
1494 f"{type(column_z_offsets).__name__}"
1497 expected = ndiv[0] * ndiv[1]
1498 if len(column_z_offsets) != expected:
1500 f"column_z_offsets must have length ndiv[0]*ndiv[1] = {expected} "
1501 f"(one value per grid column), got {len(column_z_offsets)}"
1504 lidar_wrapper.addLiDARGridTerrainFollowing(
1505 self._cloud_ptr, center_list, size_list, list(ndiv), rotation,
1506 [float(z) for z in column_z_offsets]
1509 def addGridCell(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1510 size: Union[vec3, List[float], Tuple[float, float, float]],
1511 rotation: float = 0.0):
1513 Add a single grid cell.
1516 center: Cell center position (vec3 or 3-element list)
1517 size: Cell dimensions [x, y, z] (vec3 or 3-element list)
1518 rotation: Azimuthal rotation angle (radians, default 0.0)
1521 ``rotation`` is in **radians** here, whereas :meth:`addGrid` takes
1522 **degrees**. This asymmetry is inherited from the native API — the native
1523 ``addGridCell()`` stores the angle directly in the cell's radian field while
1524 ``addGrid()`` converts from degrees. :meth:`getCellRotation` reports degrees.
1526 # Convert center to list
1527 if isinstance(center, (list, tuple)):
1528 if len(center) != 3:
1529 raise ValueError("Center must have 3 elements [x, y, z]")
1530 center_list = list(center)
1531 elif hasattr(center, 'x'):
1532 center_list = [center.x, center.y, center.z]
1534 raise ValueError("Center must be vec3 or 3-element list/tuple")
1536 # Convert size to list
1537 if isinstance(size, (list, tuple)):
1539 raise ValueError("Size must have 3 elements [x, y, z]")
1540 size_list = list(size)
1541 elif hasattr(size, 'x'):
1542 size_list = [size.x, size.y, size.z]
1544 raise ValueError("Size must be vec3 or 3-element list/tuple")
1546 lidar_wrapper.addLiDARGridCell(self._cloud_ptr, center_list, size_list, rotation)
1548 def getGridCellCount(self) -> int:
1549 """Get total number of grid cells"""
1550 return lidar_wrapper.getLiDARGridCellCount(self._cloud_ptr)
1552 def getCellCenter(self, index: int) -> vec3:
1553 """Get the true world-space center position of a grid cell.
1555 For a grid created with a non-zero azimuthal ``rotation``, this is the lattice
1556 center rotated about the grid anchor (about +z), so it lies in the same rotated
1557 world frame as the hit points, scan origins, and grid bounding box. For an
1558 un-rotated grid it is simply the lattice center.
1561 raise ValueError("Index must be non-negative")
1562 center_list = lidar_wrapper.getLiDARCellCenter(self._cloud_ptr, index)
1563 return vec3(*center_list)
1565 def getCellSize(self, index: int) -> vec3:
1566 """Get size of a grid cell"""
1568 raise ValueError("Index must be non-negative")
1569 size_list = lidar_wrapper.getLiDARCellSize(self._cloud_ptr, index)
1570 return vec3(*size_list)
1572 def getCellRotation(self, index: int) -> float:
1573 """Get the azimuthal rotation of a grid cell about the z-axis, in degrees.
1575 The units match the ``rotation`` argument of :meth:`addGrid`. Note that
1576 :meth:`addGridCell` takes its rotation in radians.
1579 raise ValueError("Index must be non-negative")
1580 return lidar_wrapper.getLiDARCellRotation(self._cloud_ptr, index)
1582 def getCellLeafArea(self, index: int) -> float:
1583 """Get leaf area of a grid cell (m²)"""
1585 raise ValueError("Index must be non-negative")
1586 return lidar_wrapper.getLiDARCellLeafArea(self._cloud_ptr, index)
1588 def getCellLeafAreaDensity(self, index: int) -> float:
1589 """Get leaf area density of a grid cell (m²/m³)"""
1591 raise ValueError("Index must be non-negative")
1592 return lidar_wrapper.getLiDARCellLeafAreaDensity(self._cloud_ptr, index)
1594 def getCellBeamCount(self, index: int) -> int:
1595 """Get the beam count N that entered a grid cell during the leaf-area inversion.
1597 Returns -1 if :meth:`calculateLeafArea` has not been run for this cell.
1600 raise ValueError("Index must be non-negative")
1601 return lidar_wrapper.getLiDARCellBeamCount(self._cloud_ptr, index)
1603 def getCellRelativeDensityIndex(self, index: int) -> float:
1604 """Get the relative density index (I_rdi) for a grid cell."""
1606 raise ValueError("Index must be non-negative")
1607 return lidar_wrapper.getLiDARCellRelativeDensityIndex(self._cloud_ptr, index)
1609 def getCellMeanPathLength(self, index: int) -> float:
1610 """Get the mean beam path length (m) through a grid cell."""
1612 raise ValueError("Index must be non-negative")
1613 return lidar_wrapper.getLiDARCellMeanPathLength(self._cloud_ptr, index)
1615 def getCellLADVariance(self, index: int) -> float:
1616 """Get the per-voxel LAD sampling variance for a grid cell.
1618 Returns -1 if uncertainty has not been computed (call :meth:`calculateLeafArea`
1619 with an ``element_width``).
1622 raise ValueError("Index must be non-negative")
1623 return lidar_wrapper.getLiDARCellLADVariance(self._cloud_ptr, index)
1625 def getCellLeafAreaConfidenceInterval(self, index: int, confidence_level: float = 0.95):
1626 """Get the leaf-area confidence interval for a single grid cell.
1628 Returns a ``(valid, lower, upper)`` tuple. ``valid`` is False when the interval is
1629 gated out by the Pimont validity envelope (single-voxel intervals are often
1630 untrustworthy; prefer :meth:`getGroupLADConfidenceInterval`). Requires
1631 :meth:`calculateLeafArea` to have been run with an ``element_width``.
1634 raise ValueError("Index must be non-negative")
1635 return lidar_wrapper.getLiDARCellLeafAreaConfidenceInterval(
1636 self._cloud_ptr, index, confidence_level)
1638 def getGroupLADConfidenceInterval(self, indices: List[int], confidence_level: float = 0.95):
1639 """Get the group-scale LAD confidence interval over a set of grid cells (recommended).
1641 Returns a ``(valid, mean_lad, lower, upper)`` tuple (Pimont et al. 2018, Eq. 39,
1642 assuming voxel independence). Requires :meth:`calculateLeafArea` to have been run
1643 with an ``element_width``.
1646 raise ValueError("indices must contain at least one cell index")
1647 if any(i < 0 for i in indices):
1648 raise ValueError("Cell indices must be non-negative")
1649 return lidar_wrapper.getLiDARGroupLADConfidenceInterval(
1650 self._cloud_ptr, indices, confidence_level)
1652 def getCellGtheta(self, index: int) -> float:
1653 """Get G(theta) value for a grid cell"""
1655 raise ValueError("Index must be non-negative")
1656 return lidar_wrapper.getLiDARCellGtheta(self._cloud_ptr, index)
1658 def setCellGtheta(self, Gtheta: float, index: int):
1659 """Set G(theta) value for a grid cell"""
1661 raise ValueError("Index must be non-negative")
1662 lidar_wrapper.setLiDARCellGtheta(self._cloud_ptr, Gtheta, index)
1664 def calculateHitGridCell(self):
1665 """Calculate hit point grid cell assignments"""
1666 lidar_wrapper.calculateLiDARHitGridCell(self._cloud_ptr)
1668 def gapfillMisses(self):
1670 Gapfill sky/miss points where rays didn't hit geometry.
1672 Important for accurate leaf area calculations with real LiDAR data.
1673 Should be called before triangulation when processing real data.
1675 lidar_wrapper.gapfillLiDARMisses(self._cloud_ptr)
1677 def syntheticScan(self, context: Context,
1678 rays_per_pulse: Optional[int] = None,
1679 pulse_distance_threshold: Optional[float] = None,
1680 scan_grid_only: bool = False,
1681 record_misses: bool = True,
1682 append: bool = False,
1683 return_mode: Optional[Union[ReturnMode, int]] = None,
1686 Perform synthetic LiDAR scan of geometry in Context.
1688 Requires scan metadata to be defined first via addScan() or loadXML().
1689 Uses ray tracing to simulate LiDAR instrument measurements.
1692 context: Helios Context containing geometry to scan
1693 rays_per_pulse: Number of rays per pulse (None=discrete-return, typical: 100)
1694 pulse_distance_threshold: Distance threshold for aggregating hits (meters, required for waveform)
1695 scan_grid_only: If True, only scan within defined grid cells
1696 record_misses: If True, record miss/sky points where rays don't hit geometry
1697 append: If True, append to existing hits; if False, clear existing hits
1698 return_mode: Optional :class:`ReturnMode` (MULTI or SINGLE) for analytic-waveform scans.
1699 Overrides each scan's stored return mode for this call only. Only valid when
1700 rays_per_pulse is set (waveform mode); raises ValueError otherwise. In SINGLE mode
1701 up to each scan's getScanMaxReturns() returns per pulse are reported, selected by the
1702 scan's single-return selection policy.
1704 Example (Discrete-return):
1705 >>> from pyhelios import Context, LiDARCloud
1706 >>> from pyhelios.types import vec3
1707 >>> with Context() as context:
1709 ... context.addPatch(center=vec3(0, 0, 0.5), size=vec2(1, 1))
1711 ... with LiDARCloud() as lidar:
1712 ... # Define scan parameters
1713 ... scan_id = lidar.addScan(
1714 ... origin=vec3(0, 0, 2),
1715 ... Ntheta=100, theta_range=(0, 1.57),
1716 ... Nphi=100, phi_range=(0, 6.28),
1717 ... exit_diameter=0, beam_divergence=0
1720 ... # Perform discrete-return scan
1721 ... lidar.syntheticScan(context)
1723 Example (Full-waveform):
1724 >>> lidar.syntheticScan(
1726 ... rays_per_pulse=100,
1727 ... pulse_distance_threshold=0.02,
1728 ... record_misses=True
1731 if not isinstance(context, Context):
1732 raise TypeError("context must be a Context instance")
1734 context_ptr = context.getNativePtr()
1736 # Register an external cancellation flag (a ctypes.c_int set non-zero from
1737 # another thread) so a long ray trace can be aborted mid-pass. Cleared in
1738 # the finally below so a later scan on this cloud isn't pre-cancelled.
1739 if cancel_flag is not None:
1740 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, cancel_flag)
1742 self._dispatch_synthetic_scan(
1743 context_ptr, rays_per_pulse, pulse_distance_threshold,
1744 scan_grid_only, record_misses, append, return_mode)
1746 if cancel_flag is not None:
1747 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, None)
1749 def _dispatch_synthetic_scan(self, context_ptr, rays_per_pulse,
1750 pulse_distance_threshold, scan_grid_only,
1751 record_misses, append, return_mode):
1752 # Discrete-return mode (single ray per pulse)
1753 if rays_per_pulse is None:
1754 if return_mode is not None:
1756 "return_mode is only valid for analytic-waveform scans; pass rays_per_pulse (> 1)")
1757 # Honor scan_grid_only and record_misses for discrete scans too. record_misses
1758 # defaults to True so the cloud carries the transmitted beams that
1759 # calculateLeafArea() requires.
1760 lidar_wrapper.syntheticLiDARScanDiscrete(
1761 self._cloud_ptr, context_ptr, scan_grid_only, record_misses, append)
1763 # Full-waveform mode (multiple rays per pulse)
1764 if pulse_distance_threshold is None:
1765 raise ValueError("pulse_distance_threshold required for full-waveform scanning")
1767 validate_positive_value(rays_per_pulse, 'rays_per_pulse', 'syntheticScan')
1768 validate_positive_value(pulse_distance_threshold, 'pulse_distance_threshold', 'syntheticScan')
1770 if return_mode is None:
1771 lidar_wrapper.syntheticLiDARScanFull(
1772 self._cloud_ptr, context_ptr,
1773 rays_per_pulse, pulse_distance_threshold,
1774 scan_grid_only, record_misses, append
1777 lidar_wrapper.syntheticLiDARScanReturnMode(
1778 self._cloud_ptr, context_ptr,
1779 rays_per_pulse, pulse_distance_threshold, int(return_mode),
1780 scan_grid_only, record_misses, append
1783 def calculateLeafArea(self, context: Context, min_voxel_hits: Optional[int] = None,
1784 element_width: Optional[float] = None, Gtheta: Optional[float] = None):
1786 Calculate leaf area for each grid cell.
1788 Requires triangulation to have been performed first, UNLESS a ``Gtheta`` is supplied
1792 The cloud must contain misses (transmitted beams that returned nothing) — the
1793 inversion fails fast without them. Misses are produced by
1794 ``syntheticScan(..., record_misses=True)`` (the default) or by
1795 :meth:`gapfillMisses`. Use :meth:`hasMisses` to check.
1798 context: Helios Context instance
1799 min_voxel_hits: Optional minimum number of hits required per voxel
1800 element_width: Optional characteristic vegetation element width (meters). When
1801 provided, per-voxel sampling uncertainty (Pimont et al. 2018) is computed
1802 alongside the leaf-area estimate and becomes available via
1803 :meth:`getCellLADVariance`, :meth:`getCellLeafAreaConfidenceInterval`, and
1804 :meth:`getGroupLADConfidenceInterval`. ``element_width <= 0`` yields a
1805 sampling-only variance.
1806 Gtheta: Optional caller-supplied mean leaf-projection coefficient G(theta), in (0,1]
1807 (0.5 = spherical/random leaf-angle distribution). When provided, leaf area is
1808 computed via a beam-based inversion that uses each hit's per-pulse beam origin and
1809 does NOT require triangulation — the only supported path for moving-platform scans
1810 (see :meth:`addScanMoving`). Requires both ``min_voxel_hits`` and ``element_width``
1811 to also be specified.
1814 >>> from pyhelios import Context, LiDARCloud
1815 >>> with Context() as context:
1816 ... with LiDARCloud() as lidar:
1817 ... # ... load data, add grid, triangulate ...
1818 ... lidar.calculateLeafArea(context)
1820 if not isinstance(context, Context):
1821 raise TypeError("context must be a Context instance")
1823 # Validate argument combinations before touching native state (fail-fast).
1824 if Gtheta is not None:
1825 if min_voxel_hits is None or element_width is None:
1827 "Gtheta requires both min_voxel_hits and element_width to also be specified "
1828 "(the G(theta) overload takes all three)")
1830 # The native overload treats Gtheta <= 0 as the "compute from triangulation"
1831 # sentinel, which silently disables the supplied-G(theta) path. Reject it here.
1833 "Gtheta must be > 0 and in (0, 1] (e.g. 0.5 for a spherical leaf-angle distribution)")
1834 elif element_width is not None and min_voxel_hits is None:
1836 "element_width requires min_voxel_hits to also be specified "
1837 "(the uncertainty overload takes both)")
1839 context_ptr = context.getNativePtr()
1840 if Gtheta is not None:
1841 lidar_wrapper.calculateLiDARLeafAreaGtheta(
1842 self._cloud_ptr, context_ptr, Gtheta, min_voxel_hits, element_width)
1843 elif element_width is not None:
1844 lidar_wrapper.calculateLiDARLeafAreaUncertainty(
1845 self._cloud_ptr, context_ptr, min_voxel_hits, element_width)
1846 elif min_voxel_hits is None:
1847 lidar_wrapper.calculateLiDARLeafArea(self._cloud_ptr, context_ptr)
1849 lidar_wrapper.calculateLiDARLeafAreaMinHits(self._cloud_ptr, context_ptr, min_voxel_hits)
1851 def calculateSyntheticLeafArea(self, context: Context):
1853 Calculate synthetic leaf area (for validation of synthetic scans).
1855 Uses exact primitive geometry to calculate leaf area, useful for
1856 validating synthetic scan accuracy.
1859 context: Helios Context instance containing primitive geometry
1861 if not isinstance(context, Context):
1862 raise TypeError("context must be a Context instance")
1863 context_ptr = context.getNativePtr()
1864 lidar_wrapper.calculateSyntheticLiDARLeafArea(self._cloud_ptr, context_ptr)
1866 def calculateSyntheticGtheta(self, context: Context):
1868 Calculate synthetic G(theta) (for validation of synthetic scans).
1870 Uses exact primitive geometry to calculate G(theta), useful for
1871 validating synthetic scan accuracy.
1874 context: Helios Context instance containing primitive geometry
1876 if not isinstance(context, Context):
1877 raise TypeError("context must be a Context instance")
1878 context_ptr = context.getNativePtr()
1879 lidar_wrapper.calculateSyntheticLiDARGtheta(self._cloud_ptr, context_ptr)
1881 def exportTriangleNormals(self, filename: str):
1882 """Export triangle normal vectors to file"""
1884 raise ValueError("Filename cannot be empty")
1885 lidar_wrapper.exportLiDARTriangleNormals(self._cloud_ptr, filename)
1887 def exportTriangleAreas(self, filename: str):
1888 """Export triangle areas to file"""
1890 raise ValueError("Filename cannot be empty")
1891 lidar_wrapper.exportLiDARTriangleAreas(self._cloud_ptr, filename)
1893 def exportLeafAreas(self, filename: str):
1894 """Export leaf areas for each grid cell to file"""
1896 raise ValueError("Filename cannot be empty")
1897 lidar_wrapper.exportLiDARLeafAreas(self._cloud_ptr, filename)
1899 def exportLeafAreaDensities(self, filename: str):
1900 """Export leaf area densities for each grid cell to file"""
1902 raise ValueError("Filename cannot be empty")
1903 lidar_wrapper.exportLiDARLeafAreaDensities(self._cloud_ptr, filename)
1905 def exportGtheta(self, filename: str):
1906 """Export G(theta) values for each grid cell to file"""
1908 raise ValueError("Filename cannot be empty")
1909 lidar_wrapper.exportLiDARGtheta(self._cloud_ptr, filename)
1911 def addTrianglesToContext(self, context: Context):
1913 Add triangulated mesh to Context as triangle primitives.
1915 Converts the triangulated point cloud mesh into Context triangle
1916 primitives that can be used for further analysis or visualization.
1919 context: Helios Context instance
1922 >>> with Context() as context:
1923 ... with LiDARCloud() as lidar:
1924 ... lidar.loadXML("scan.xml")
1925 ... lidar.triangulateHitPoints(Lmax=0.5, max_aspect_ratio=5)
1926 ... lidar.addTrianglesToContext(context)
1927 ... print(f"Added {context.getPrimitiveCount()} triangles to context")
1929 if not isinstance(context, Context):
1930 raise TypeError("context must be a Context instance")
1931 lidar_wrapper.addLiDARTrianglesToContext(self._cloud_ptr, context.getNativePtr())
1933 def initializeCollisionDetection(self, context: Context):
1935 Initialize CollisionDetection plugin for ray tracing.
1937 Required before performing synthetic scans.
1940 context: Helios Context instance containing geometry
1942 if not isinstance(context, Context):
1943 raise TypeError("context must be a Context instance")
1945 # Retain a reference to the Context. The native side constructs a
1946 # CollisionDetection that stores the raw Context* for its lifetime, so a
1947 # temporary Context would otherwise be freed while still referenced.
1948 # Note the C++ side only builds CollisionDetection once (it no-ops if one
1949 # already exists), so the first Context passed here is the one that stays
1950 # bound - re-initializing with a different Context has no effect.
1951 if getattr(self, '_cd_context', None) is not None and self._cd_context is not context:
1953 "LiDARCloud collision detection is already initialized with a different Context.\n"
1954 "The native CollisionDetection keeps the Context it was first given; "
1955 "passing another one here would silently have no effect.\n"
1957 "Fix: create a new LiDARCloud for a different Context, or reuse the "
1958 "Context this cloud was initialized with."
1960 self._cd_context = context
1962 lidar_wrapper.initializeLiDARCollisionDetection(self._cloud_ptr, context.getNativePtr())
1964 def _check_cd_context_alive(self):
1965 """Raise if the Context bound to collision detection has been destroyed."""
1966 if getattr(self, '_cd_context', None) is not None:
1967 check_context_alive(self._cd_context, "LiDARCloud collision detection")
1969 def enableCDGPUAcceleration(self):
1970 """Enable GPU acceleration for collision detection ray tracing"""
1971 self._check_cd_context_alive()
1972 lidar_wrapper.enableLiDARCDGPUAcceleration(self._cloud_ptr)
1974 def disableCDGPUAcceleration(self):
1975 """Disable GPU acceleration (use CPU ray tracing)"""
1976 self._check_cd_context_alive()
1977 lidar_wrapper.disableLiDARCDGPUAcceleration(self._cloud_ptr)
1979 def isGPUAvailable(self) -> bool:
1980 """Return True if a CUDA-capable GPU is available for collision-detection ray tracing.
1982 Reports capability (compiled with CUDA, a device present, and HELIOS_NO_GPU not set); use
1983 :meth:`isGPUAccelerationEnabled` to query whether GPU acceleration is currently toggled on.
1985 return lidar_wrapper.isLiDARGPUAvailable(self._cloud_ptr)
1987 def isGPUAccelerationEnabled(self) -> bool:
1988 """Return True if GPU acceleration is currently enabled for collision-detection ray tracing."""
1989 return lidar_wrapper.isLiDARGPUAccelerationEnabled(self._cloud_ptr)
1991 def setSyntheticScanProgressPointer(self, ptr):
1992 """Register an external per-scan progress counter polled during :meth:`syntheticScan`.
1994 ``ptr`` is a ``ctypes.c_int`` into which syntheticScan writes the 0-based index of the scan
1995 currently being ray-traced (set to :meth:`getScanCount` when the batch finishes), letting a
1996 host thread poll progress while the blocking scan runs. The counter is owned by the caller and
1997 must outlive the scan. Pass ``None`` to clear.
2000 if ptr is not None and not isinstance(ptr, ctypes.c_int):
2001 raise TypeError("ptr must be a ctypes.c_int (or None to clear)")
2002 lidar_wrapper.setLiDARSyntheticScanProgressPointer(self._cloud_ptr, ptr)
2004 def setProgressCallback(self, callback):
2005 """Register a progress callback fired with ``(progress_fraction, message)`` during :meth:`syntheticScan`.
2007 ``progress_fraction`` is a float in [0, 1]; ``message`` is a ``str`` describing the current
2008 phase. Pass ``None`` to clear the callback. The callback bridge is kept alive on this
2009 :class:`LiDARCloud` for as long as it is registered.
2011 if callback is None:
2012 # Clear the native callback first, then drop our reference, so a failure in the native
2013 # call cannot leave C++ holding a freed bridge.
2014 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, None)
2015 self._progress_callback_ref = None
2018 if not callable(callback):
2019 raise TypeError("callback must be callable or None")
2021 def _trampoline(progress, message):
2022 callback(float(progress), message.decode('utf-8') if message else "")
2024 # Keep the ctypes callback object alive for as long as native code holds it; ctypes does not.
2025 self._progress_callback_ref = lidar_wrapper.LiDARProgressCallback(_trampoline)
2026 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, self._progress_callback_ref)
2028 def is_available(self) -> bool:
2030 Check if LiDAR is available in current build.
2033 True if plugin is available, False otherwise
2035 registry = get_plugin_registry()
2036 return registry.is_plugin_available('lidar')
2039# Convenience function
2040def create_lidar_cloud() -> LiDARCloud:
2042 Create LiDARCloud instance.