175 Initialize LiDARCloud.
178 LiDARError: If plugin not available in current build
179 RuntimeError: If cloud initialization fails
182 registry = get_plugin_registry()
183 if not registry.is_plugin_available(
'lidar'):
185 "LiDAR plugin not available. Rebuild PyHelios with LiDAR:\n"
186 " build_scripts/build_helios --plugins lidar\n"
188 "System requirements:\n"
189 " - Platforms: Windows, Linux, macOS\n"
190 " - GPU: Optional (enables GPU acceleration)"
195 raise LiDARError(
"Failed to create LiDAR cloud")
210 """Context manager entry"""
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)
220 """Fallback destructor for cleanup without context manager"""
221 if hasattr(self, '_cloud_ptr') and self._cloud_ptr is not None:
223 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
224 self._cloud_ptr = None
225 except Exception as e:
227 warnings.warn(f"Error in LiDARCloud.__del__: {e}")
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:
238 Add a LiDAR scan to the point cloud.
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).
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
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
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).
274 Scan ID for referencing this scan
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"]
285 # Convert origin to vec3 if needed
286 if isinstance(origin, (list, tuple)):
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")
293 origin_list = [origin.x, origin.y, origin.z]
295 # Validate scan parameters
296 validate_positive_value(Ntheta, 'Ntheta', 'addScan')
297 validate_positive_value(Nphi, 'Nphi', 'addScan')
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)")
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)
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")
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
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,
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:
335 Add a moving-platform (mobile/airborne) raster LiDAR scan driven by a 6-DOF pose trajectory.
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").
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``.
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)
371 Scan ID for referencing this scan
373 validate_positive_value(Ntheta, 'Ntheta', 'addScanMoving')
374 validate_positive_value(Nphi, 'Nphi', 'addScanMoving')
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")
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')
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
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)
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)
411 def _validate_trajectory(traj_t, traj_pos, traj_rot, rot_stride, method):
412 """Shared trajectory validation/marshalling for moving/spinning scans.
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.
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")
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")
428 def _to_xyz(v, name):
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")
435 pos_list = [_to_xyz(p, "Each traj_pos entry") for p in traj_pos]
438 if rot_stride is not None:
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'
444 f"Each trajectory orientation entry must have {rot_stride} elements ({label})"
446 rot_list.append([float(c) for c in r])
447 return [float(t) for t in traj_t], pos_list, rot_list
449 def addScanSpinning(self, beam_elevation_angles: List[float],
450 azimuth_step: float, pulse_rate_hz: 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:
462 Add a continuously-spinning multibeam scan from physical instrument parameters.
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.
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)
493 Scan ID for referencing this scan
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")
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')
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
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)
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)
530 def addScanMovingRaster(self, Ntheta: int, theta_range: Tuple[float, float],
531 Nphi: int, phi_range: Tuple[float, float],
532 pulse_rate_hz: 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:
543 Add a moving-platform raster scan: a fixed angular fan swept along a quaternion trajectory.
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``.
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
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)
569 Scan ID for referencing this scan
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")
584 t_list, pos_list, quat_list = self._validate_trajectory(
585 traj_t, traj_pos, traj_quat, 4, 'addScanMovingRaster')
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
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)
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)
607 def addScanRisley(self, prisms: List[Union['RisleyPrism', List[float], Tuple[float, ...]]],
608 refractive_index_air: float, pulse_rate_hz: 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:
620 Add a rotating-Risley-prism (Livox-style rosette) scan from physical instrument parameters.
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.
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)
652 Scan ID for referencing this scan
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]")
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])
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")
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')
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
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)
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)
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"""
703 raise ValueError("Scan ID must be non-negative")
704 origin_list = lidar_wrapper.getLiDARScanOrigin(self._cloud_ptr, scanID)
705 return vec3(*origin_list)
707 def getScanSizeTheta(self, scanID: int) -> int:
708 """Get number of zenith scan points for a scan"""
710 raise ValueError("Scan ID must be non-negative")
711 return lidar_wrapper.getLiDARScanSizeTheta(self._cloud_ptr, scanID)
713 def getScanSizePhi(self, scanID: int) -> int:
714 """Get number of azimuthal scan points for a scan"""
716 raise ValueError("Scan ID must be non-negative")
717 return lidar_wrapper.getLiDARScanSizePhi(self._cloud_ptr, scanID)
719 def getScanRangeNoiseStdDev(self, scanID: int) -> float:
720 """Get the range (along-beam) measurement noise standard deviation for a scan (meters).
722 Returns the value supplied to addScan() as ``range_noise_stddev`` (0.0 if disabled).
725 raise ValueError("Scan ID must be non-negative")
726 return lidar_wrapper.getLiDARScanRangeNoiseStdDev(self._cloud_ptr, scanID)
728 def getScanAngleNoiseStdDev(self, scanID: int) -> float:
729 """Get the angular (beam-pointing) jitter standard deviation for a scan (radians).
731 Returns the value supplied to addScan() as ``angle_noise_stddev`` (0.0 if disabled).
734 raise ValueError("Scan ID must be non-negative")
735 return lidar_wrapper.getLiDARScanAngleNoiseStdDev(self._cloud_ptr, scanID)
737 def getScanTiltRoll(self, scanID: int) -> float:
738 """Get the global scanner tilt roll angle for a scan (radians; 0.0 if level)."""
740 raise ValueError("Scan ID must be non-negative")
741 return lidar_wrapper.getLiDARScanTiltRoll(self._cloud_ptr, scanID)
743 def getScanTiltPitch(self, scanID: int) -> float:
744 """Get the global scanner tilt pitch angle for a scan (radians; 0.0 if level)."""
746 raise ValueError("Scan ID must be non-negative")
747 return lidar_wrapper.getLiDARScanTiltPitch(self._cloud_ptr, scanID)
749 def getScanAzimuthOffset(self, scanID: int) -> float:
750 """Get the global scanner azimuth (heading) offset for a scan (radians; 0.0 if none)."""
752 raise ValueError("Scan ID must be non-negative")
753 return lidar_wrapper.getLiDARScanAzimuthOffset(self._cloud_ptr, scanID)
755 def getScanPattern(self, scanID: int) -> int:
756 """Get the scan pattern for a scan.
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``.
763 raise ValueError("Scan ID must be non-negative")
764 return lidar_wrapper.getLiDARScanPattern(self._cloud_ptr, scanID)
766 def getScanBeamZenithAngles(self, scanID: int) -> List[float]:
767 """Get the per-channel beam zenith angles (radians) for a multibeam scan.
769 Returns an empty list for a raster scan.
772 raise ValueError("Scan ID must be non-negative")
773 return lidar_wrapper.getLiDARScanBeamZenithAngles(self._cloud_ptr, scanID)
775 def getScanMode(self, scanID: int) -> ScanMode:
776 """Get the high-level acquisition mode of a scan as a :class:`ScanMode`.
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).
782 raise ValueError("Scan ID must be non-negative")
783 return ScanMode(lidar_wrapper.getLiDARScanMode(self._cloud_ptr, scanID))
785 def getScanStepsPerRev(self, scanID: int) -> int:
786 """Get the number of azimuth firing steps per revolution (spinning scans; 0 otherwise)."""
788 raise ValueError("Scan ID must be non-negative")
789 return lidar_wrapper.getLiDARScanStepsPerRev(self._cloud_ptr, scanID)
791 def getScanRotationRate(self, scanID: int) -> float:
792 """Get the sensor-head rotation rate in revolutions/second (spinning scans; 0 otherwise)."""
794 raise ValueError("Scan ID must be non-negative")
795 return lidar_wrapper.getLiDARScanRotationRate(self._cloud_ptr, scanID)
797 def getScanRevolutions(self, scanID: int) -> float:
798 """Get the number of revolutions the sensor head made (spinning scans; 0 otherwise)."""
800 raise ValueError("Scan ID must be non-negative")
801 return lidar_wrapper.getLiDARScanRevolutions(self._cloud_ptr, scanID)
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`.
806 Returns the prism stack in beam-traversal order (empty for non-Risley scans).
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]
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)."""
816 raise ValueError("Scan ID must be non-negative")
817 return lidar_wrapper.getLiDARScanRisleyRefractiveIndexAir(self._cloud_ptr, scanID)
819 def getScanReturnMode(self, scanID: int) -> ReturnMode:
820 """Get the return-reporting mode of a scan as a :class:`ReturnMode` (MULTI or SINGLE)."""
822 raise ValueError("Scan ID must be non-negative")
823 return ReturnMode(lidar_wrapper.getLiDARScanReturnMode(self._cloud_ptr, scanID))
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).
828 Only affects analytic-waveform synthetic scans (more than one ray per pulse).
831 raise ValueError("Scan ID must be non-negative")
832 lidar_wrapper.setLiDARScanReturnMode(self._cloud_ptr, scanID, int(return_mode))
834 def getScanSingleReturnSelection(self, scanID: int) -> SingleReturnSelection:
835 """Get the single/limited-return selection policy as a :class:`SingleReturnSelection`."""
837 raise ValueError("Scan ID must be non-negative")
838 return SingleReturnSelection(
839 lidar_wrapper.getLiDARScanSingleReturnSelection(self._cloud_ptr, scanID))
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).
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
849 raise ValueError("Scan ID must be non-negative")
850 lidar_wrapper.setLiDARScanSingleReturnSelection(self._cloud_ptr, scanID, int(selection))
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)."""
855 raise ValueError("Scan ID must be non-negative")
856 return lidar_wrapper.getLiDARScanMaxReturns(self._cloud_ptr, scanID)
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)."""
861 raise ValueError("Scan ID must be non-negative")
863 raise ValueError("max_returns must be >= 1")
864 lidar_wrapper.setLiDARScanMaxReturns(self._cloud_ptr, scanID, int(max_returns))
866 def setSyntheticScanMemoryBudget(self, bytes: int):
867 """Set the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
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.
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.
880 bytes: Soft cap in bytes on the live ray-tracing scratch buffers. Must be > 0.
883 raise ValueError("memory budget must be greater than zero")
884 lidar_wrapper.setLiDARSyntheticScanMemoryBudget(self._cloud_ptr, int(bytes))
886 def getSyntheticScanMemoryBudget(self) -> int:
887 """Get the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
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).
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)."""
897 raise ValueError("Scan ID must be non-negative")
898 return lidar_wrapper.getLiDARScanPulseWidth(self._cloud_ptr, scanID)
900 def setScanPulseWidth(self, scanID: int, pulse_width: float):
901 """Set the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
903 raise ValueError("Scan ID must be non-negative")
905 raise ValueError("pulse_width must be non-negative")
906 lidar_wrapper.setLiDARScanPulseWidth(self._cloud_ptr, scanID, float(pulse_width))
908 def getScanDetectionThreshold(self, scanID: int) -> float:
909 """Get the detection threshold (energy fraction, noise floor) of a scan."""
911 raise ValueError("Scan ID must be non-negative")
912 return lidar_wrapper.getLiDARScanDetectionThreshold(self._cloud_ptr, scanID)
914 def setScanDetectionThreshold(self, scanID: int, detection_threshold: float):
915 """Set the detection threshold (energy fraction, noise floor) of a scan."""
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))
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):
927 Add a hit point to the point cloud.
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)
935 # Convert xyz to list
936 if isinstance(xyz, (list, tuple)):
938 raise ValueError("XYZ must have 3 elements")
940 elif hasattr(xyz, 'x'):
941 xyz_list = [xyz.x, xyz.y, xyz.z]
943 raise ValueError("XYZ must be vec3 or 3-element list/tuple")
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]
955 raise ValueError("Direction must be vec3/SphericalCoord or 2-3 element list")
957 # Add with or without color
958 if color is not None:
959 if isinstance(color, (list, tuple)):
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]
966 raise ValueError("Color must be RGBcolor or 3-element list")
968 lidar_wrapper.addLiDARHitPointRGB(self._cloud_ptr, scanID, xyz_list, direction_list, color_list)
970 lidar_wrapper.addLiDARHitPoint(self._cloud_ptr, scanID, xyz_list, direction_list)
972 def addHitPoints(self, scanID: int, xyz_array, direction_array, color_array=None):
974 Add many hit points to the point cloud in a single bulk call.
976 This skips the per-point Python loop by passing contiguous buffers
977 straight to the native library in one FFI call.
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]
988 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
989 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
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)")
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")
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")
1007 lidar_wrapper.addLiDARHitPoints(self._cloud_ptr, scanID,
1008 xyz_array, direction_array, count, color_array)
1010 def addHitPointsWithData(self, scanID: int, xyz_array, direction_array,
1011 data_labels=None, data_values=None, color_array=None):
1013 Add many hit points carrying a per-hit data map in a single bulk call.
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).
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]
1030 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
1031 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
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)")
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")
1042 labels = list(data_labels or [])
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))")
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")
1057 lidar_wrapper.addLiDARHitPointsWithData(
1058 self._cloud_ptr, scanID, xyz_array, direction_array, count,
1059 color_array, labels, data_values)
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"""
1068 raise ValueError("Index must be non-negative")
1069 xyz_list = lidar_wrapper.getLiDARHitXYZ(self._cloud_ptr, index)
1070 return vec3(*xyz_list)
1072 def getHitOrigin(self, index: int) -> vec3:
1073 """Get the (x,y,z) beam-emission origin of a hit point.
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.
1079 raise ValueError("Index must be non-negative")
1080 xyz_list = lidar_wrapper.getLiDARHitOrigin(self._cloud_ptr, index)
1081 return vec3(*xyz_list)
1083 def getHitRaydir(self, index: int) -> SphericalCoord:
1084 """Get ray direction of a hit point"""
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])
1091 def getHitColor(self, index: int) -> RGBcolor:
1092 """Get color of a hit point"""
1094 raise ValueError("Index must be non-negative")
1095 color_list = lidar_wrapper.getLiDARHitColor(self._cloud_ptr, index)
1096 return RGBcolor(*color_list)
1098 def getHitScanID(self, index: int) -> int:
1099 """Get the scan ID a hit point belongs to"""
1101 raise ValueError("Index must be non-negative")
1102 return lidar_wrapper.getLiDARHitScanID(self._cloud_ptr, index)
1104 def doesHitDataExist(self, index: int, label: str) -> bool:
1105 """Check whether a named scalar data value exists for a hit point.
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.
1112 raise ValueError("Index must be non-negative")
1113 return lidar_wrapper.doesLiDARHitDataExist(self._cloud_ptr, index, label)
1115 def getHitData(self, index: int, label: str) -> float:
1116 """Get a named scalar data value for a hit point.
1118 Raises HeliosError if the label does not exist for this hit; guard with
1119 doesHitDataExist() when unsure.
1122 raise ValueError("Index must be non-negative")
1123 return lidar_wrapper.getLiDARHitData(self._cloud_ptr, index, label)
1125 def getHitDataAll(self, label: str) -> List[float]:
1126 """Bulk-export a named scalar data value for all hits in a single FFI call.
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.
1131 Note: values are returned at float32 precision (vs. getHitData(), which returns
1132 full float64). Use getHitData() per-hit if full precision is required.
1134 n = self.getHitCount()
1137 return lidar_wrapper.getLiDARHitData_all(self._cloud_ptr, label, n)
1139 def getHitsXYZRGB(self) -> Tuple[List[vec3], List[RGBcolor]]:
1140 """Bulk-export coordinates and colors for all hits in a single FFI call.
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.
1146 n = self.getHitCount()
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
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.
1160 def getHitsXYZRGBArrays(self):
1161 """Bulk-export hit coordinates + colors as numpy arrays.
1163 Returns (xyz, rgb), each (getHitCount(), 3) float32. Empty (0,3) arrays
1164 when there are no hits.
1167 n = self.getHitCount()
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)
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."""
1176 n = self.getHitCount()
1178 return np.empty((0,), np.float32)
1179 return lidar_wrapper.getLiDARHitData_all_np(self._cloud_ptr, label, n)
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.
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().
1189 n = self.getHitCount()
1192 return lidar_wrapper.getLiDARHitDataColumn(self._cloud_ptr, label, n, absent_value)
1194 def getHitDataColumnIndex(self, label: str) -> int:
1195 """Get the internal column slot index for a hit-data label.
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.
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)
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)."""
1209 n = self.getHitCount()
1211 return np.empty((0,), np.float64)
1212 return lidar_wrapper.getLiDARHitDataColumn_np(self._cloud_ptr, label, n, absent_value)
1214 def getHitScanIDArray(self):
1215 """Bulk-export the scan ID of every hit as an (getHitCount(),) int32 array."""
1217 n = self.getHitCount()
1219 return np.empty((0,), np.int32)
1220 return lidar_wrapper.getLiDARHitScanID_all(self._cloud_ptr, n)
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)."""
1226 n = self.getHitCount()
1228 return np.empty((0,), np.int32)
1229 return lidar_wrapper.isLiDARHitMiss_all(self._cloud_ptr, n)
1231 def deleteHitPoint(self, index: int):
1232 """Delete a hit point from the cloud"""
1234 raise ValueError("Index must be non-negative")
1235 lidar_wrapper.deleteLiDARHitPoint(self._cloud_ptr, index)
1237 def isHitMiss(self, index: int) -> bool:
1238 """Return True if a hit is a "miss" (a fired pulse that returned nothing).
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`.
1245 raise ValueError("Index must be non-negative")
1246 return lidar_wrapper.isLiDARHitMiss(self._cloud_ptr, index)
1248 def hasMisses(self) -> bool:
1249 """Return True if the cloud contains at least one miss.
1251 :meth:`calculateLeafArea` requires misses and fails fast without them.
1253 return lidar_wrapper.lidarHasMisses(self._cloud_ptr)
1255 def isMultiReturnData(self) -> bool:
1256 """Return True if the cloud contains multi-return data.
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.
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.
1272 Requires helios-core v1.3.85 or newer.
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
1279 return lidar_wrapper.lidarIsMultiReturnData(self._cloud_ptr)
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]]):
1289 Translate all hit points by a shift vector.
1292 shift: Translation vector (vec3 or 3-element list)
1294 if isinstance(shift, (list, tuple)):
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]
1301 raise ValueError("Shift must be vec3 or 3-element list/tuple")
1303 lidar_wrapper.lidarCoordinateShift(self._cloud_ptr, shift_list)
1305 def coordinateRotation(self, rotation: Union[SphericalCoord, List[float], Tuple[float, float]]):
1307 Rotate all hit points by spherical rotation angles.
1310 rotation: Rotation angles (SphericalCoord or 2-3 element list)
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]
1319 raise ValueError("Rotation must be SphericalCoord or 2-3 element list")
1321 lidar_wrapper.lidarCoordinateRotation(self._cloud_ptr, rotation_list)
1323 def triangulateHitPoints(self, Lmax: float, max_aspect_ratio: float = 4.0):
1325 Generate triangle mesh from hit points using Delaunay triangulation.
1328 Lmax: Maximum triangle edge length
1329 max_aspect_ratio: Maximum triangle aspect ratio (default 4.0)
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()
1338 def getTriangleCount(self) -> int:
1339 """Get number of triangles in the mesh
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.
1346 return lidar_wrapper.getLiDARTriangleCount(self._cloud_ptr)
1348 def getTriangulationStats(self) -> dict:
1349 """Filter diagnostics from the most recent triangulateHitPoints() call.
1353 {"candidates", "dropped_lmax", "dropped_aspect", "dropped_degenerate"}
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).
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.
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.
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.
1378 return lidar_wrapper.getLiDARTriangleVertices_all(
1379 self._cloud_ptr, self.getTriangleCount())
1381 def setExternalTriangulation(self, vertices, scan_ids):
1382 """Replace the internal triangulation with an externally-supplied mesh.
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.
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.
1400 A grid must already be defined (see :meth:`addGrid`).
1403 verts = np.ascontiguousarray(vertices, dtype=np.float32).reshape(-1)
1404 if verts.size % 9 != 0:
1406 f"vertices has {verts.size} floats, must be a multiple of 9 (9 per triangle)")
1407 tri_count = verts.size // 9
1409 scans = np.ascontiguousarray(scan_ids, dtype=np.int32).reshape(-1)
1410 if scans.size != tri_count:
1412 f"scan_ids has {scans.size} entries, expected {tri_count} (one per triangle)")
1414 lidar_wrapper.lidarSetExternalTriangulation(
1415 self._cloud_ptr, verts, scans, tri_count)
1417 def distanceFilter(self, maxdistance: float):
1418 """Filter hit points by maximum distance from scanne
r"""
1419 validate_positive_value(maxdistance, 'maxdistance', 'distanceFilter')
1420 lidar_wrapper.lidarDistanceFilter(self._cloud_ptr, maxdistance)
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.
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
1445 raise ValueError("Filename cannot be empty")
1446 lidar_wrapper.exportLiDARPointCloud(self._cloud_ptr, filename, write_header)
1448 def exportLeafAreaUncertainty(self, filename: str):
1449 """Export per-voxel leaf-area sampling uncertainty to a self-describing ASCII file.
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
1457 raise ValueError("Filename cannot be empty")
1458 lidar_wrapper.exportLiDARLeafAreaUncertainty(self._cloud_ptr, filename)
1460 def exportScans(self, filename: str):
1461 """Export all scans to an XML metadata file plus one ASCII data file per scan.
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.
1470 raise ValueError("Filename cannot be empty")
1471 lidar_wrapper.exportLiDARScans(self._cloud_ptr, filename)
1473 def loadXML(self, filename: str):
1474 """Load scan metadata from XML file"""
1476 raise ValueError("Filename cannot be empty")
1477 lidar_wrapper.loadLiDARXML(self._cloud_ptr, filename)
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):
1493 Add a rectangular grid of voxel cells.
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.
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.
1514 ... center=vec3(0, 0, 0.5),
1515 ... size=vec3(10, 10, 1),
1516 ... ndiv=[10, 10, 5],
1520 Terrain-following grid over a 2x2 column layout:
1523 ... center=vec3(0, 0, 0.5),
1524 ... size=vec3(10, 10, 1),
1526 ... column_z_offsets=[0.0, 0.1, 0.2, 0.3]
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]
1537 raise ValueError("Center must be vec3 or 3-element list/tuple")
1539 # Convert size to list
1540 if isinstance(size, (list, tuple)):
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]
1547 raise ValueError("Size must be vec3 or 3-element list/tuple")
1550 if not isinstance(ndiv, (list, tuple)) or len(ndiv) != 3:
1551 raise ValueError("Ndiv must be a 3-element list [nx, ny, nz]")
1553 if column_z_offsets is None:
1554 lidar_wrapper.addLiDARGrid(self._cloud_ptr, center_list, size_list, list(ndiv), rotation)
1557 if not isinstance(column_z_offsets, (list, tuple)):
1559 "column_z_offsets must be a list or tuple of floats, got "
1560 f"{type(column_z_offsets).__name__}"
1563 expected = ndiv[0] * ndiv[1]
1564 if len(column_z_offsets) != expected:
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)}"
1570 lidar_wrapper.addLiDARGridTerrainFollowing(
1571 self._cloud_ptr, center_list, size_list, list(ndiv), rotation,
1572 [float(z) for z in column_z_offsets]
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):
1579 Add a single grid cell.
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)
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.
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]
1600 raise ValueError("Center must be vec3 or 3-element list/tuple")
1602 # Convert size to list
1603 if isinstance(size, (list, tuple)):
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]
1610 raise ValueError("Size must be vec3 or 3-element list/tuple")
1612 lidar_wrapper.addLiDARGridCell(self._cloud_ptr, center_list, size_list, rotation)
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.
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.
1627 raise ValueError("Index must be non-negative")
1628 center_list = lidar_wrapper.getLiDARCellCenter(self._cloud_ptr, index)
1629 return vec3(*center_list)
1631 def getCellCenterUnrotated(self, index: int) -> vec3:
1632 """Get the UNROTATED (axis-aligned lattice) center position of a grid cell.
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.
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.
1643 raise ValueError("Index must be non-negative")
1644 center_list = lidar_wrapper.getLiDARCellCenterUnrotated(self._cloud_ptr, index)
1645 return vec3(*center_list)
1647 def getCellSize(self, index: int) -> vec3:
1648 """Get size of a grid cell"""
1650 raise ValueError("Index must be non-negative")
1651 size_list = lidar_wrapper.getLiDARCellSize(self._cloud_ptr, index)
1652 return vec3(*size_list)
1654 def getCellRotation(self, index: int) -> float:
1655 """Get the azimuthal rotation of a grid cell about the z-axis, in degrees.
1657 The units match the ``rotation`` argument of :meth:`addGrid`. Note that
1658 :meth:`addGridCell` takes its rotation in radians.
1661 raise ValueError("Index must be non-negative")
1662 return lidar_wrapper.getLiDARCellRotation(self._cloud_ptr, index)
1664 def getCellLeafArea(self, index: int) -> float:
1665 """Get leaf area of a grid cell (m²)"""
1667 raise ValueError("Index must be non-negative")
1668 return lidar_wrapper.getLiDARCellLeafArea(self._cloud_ptr, index)
1670 def getCellLeafAreaDensity(self, index: int) -> float:
1671 """Get leaf area density of a grid cell (m²/m³)"""
1673 raise ValueError("Index must be non-negative")
1674 return lidar_wrapper.getLiDARCellLeafAreaDensity(self._cloud_ptr, index)
1676 def getCellBeamCount(self, index: int) -> int:
1677 """Get the beam count N that entered a grid cell during the leaf-area inversion.
1679 Returns -1 if :meth:`calculateLeafArea` has not been run for this cell.
1682 raise ValueError("Index must be non-negative")
1683 return lidar_wrapper.getLiDARCellBeamCount(self._cloud_ptr, index)
1685 def getCellRelativeDensityIndex(self, index: int) -> float:
1686 """Get the relative density index (I_rdi) for a grid cell."""
1688 raise ValueError("Index must be non-negative")
1689 return lidar_wrapper.getLiDARCellRelativeDensityIndex(self._cloud_ptr, index)
1691 def getCellMeanPathLength(self, index: int) -> float:
1692 """Get the mean beam path length (m) through a grid cell."""
1694 raise ValueError("Index must be non-negative")
1695 return lidar_wrapper.getLiDARCellMeanPathLength(self._cloud_ptr, index)
1697 def getCellLADVariance(self, index: int) -> float:
1698 """Get the per-voxel LAD sampling variance for a grid cell.
1700 Returns -1 if uncertainty has not been computed (call :meth:`calculateLeafArea`
1701 with an ``element_width``).
1704 raise ValueError("Index must be non-negative")
1705 return lidar_wrapper.getLiDARCellLADVariance(self._cloud_ptr, index)
1707 def getCellLeafAreaConfidenceInterval(self, index: int, confidence_level: float = 0.95):
1708 """Get the leaf-area confidence interval for a single grid cell.
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``.
1716 raise ValueError("Index must be non-negative")
1717 return lidar_wrapper.getLiDARCellLeafAreaConfidenceInterval(
1718 self._cloud_ptr, index, confidence_level)
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).
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``.
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)
1734 def getCellGtheta(self, index: int) -> float:
1735 """Get G(theta) value for a grid cell"""
1737 raise ValueError("Index must be non-negative")
1738 return lidar_wrapper.getLiDARCellGtheta(self._cloud_ptr, index)
1740 def setCellGtheta(self, Gtheta: float, index: int):
1741 """Set G(theta) value for a grid cell"""
1743 raise ValueError("Index must be non-negative")
1744 lidar_wrapper.setLiDARCellGtheta(self._cloud_ptr, Gtheta, index)
1746 def calculateHitGridCell(self):
1747 """Calculate hit point grid cell assignments"""
1748 lidar_wrapper.calculateLiDARHitGridCell(self._cloud_ptr)
1750 def gapfillMisses(self):
1752 Gapfill sky/miss points where rays didn't hit geometry.
1754 Important for accurate leaf area calculations with real LiDAR data.
1755 Should be called before triangulation when processing real data.
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`.
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.
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:
1775 Gapfill missing points and return only how many were added.
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
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)
1788 Number of missing points added
1791 RuntimeError: If the native library predates helios-core v1.3.84
1794 return lidar_wrapper.gapfillLiDARMissesCount(self._cloud_ptr)
1795 return lidar_wrapper.gapfillLiDARMissesCountScan(
1796 self._cloud_ptr, scanID, gapfill_grid_only, add_flags
1799 def getVirtualMissCount(self) -> int:
1801 Number of gap-filled misses currently held in virtualized form.
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.
1809 RuntimeError: If the native library predates helios-core v1.3.84
1811 return lidar_wrapper.getLiDARVirtualMissCount(self._cloud_ptr)
1813 def getCroppedReturnStats(self) -> dict:
1815 Returns the last :meth:`calculateLeafArea` call inferred from ``target_count``.
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.
1830 dict with integer counts:
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
1842 All zero before the first inversion and for a cloud whose pulses are complete.
1845 RuntimeError: If the native library predates this feature
1847 return lidar_wrapper.getLiDARCroppedReturnStats(self._cloud_ptr)
1849 def hasVirtualMisses(self) -> bool:
1851 Whether any gap-filled miss is currently held in virtualized form.
1854 RuntimeError: If the native library predates helios-core v1.3.84
1856 return lidar_wrapper.hasLiDARVirtualMisses(self._cloud_ptr)
1858 def materializeMisses(self) -> None:
1860 Convert every virtualized gap-filled miss into a stored hit point.
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.
1868 RuntimeError: If the native library predates helios-core v1.3.84
1870 lidar_wrapper.materializeLiDARMisses(self._cloud_ptr)
1872 def getScanGridDirection(self, scanID: int, row: int, column: int) -> SphericalCoord:
1874 Beam direction at a scan-grid cell, from the model fitted during gap-filling.
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.
1882 row: Scan-grid row (zenith index)
1883 column: Scan-grid column (azimuth index)
1886 Unit direction of that cell's beam
1889 RuntimeError: If the native library predates helios-core v1.3.84
1891 radius, elevation, azimuth = lidar_wrapper.getLiDARScanGridDirection(
1892 self._cloud_ptr, scanID, row, column
1894 return SphericalCoord(radius, elevation, azimuth)
1896 def getHitXYZColumn(self):
1898 Read every hit's position in index order in one pass.
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.
1905 List of (x, y, z) tuples, one per hit
1908 RuntimeError: If the native library predates helios-core v1.3.84
1910 return lidar_wrapper.getLiDARHitXYZColumn(self._cloud_ptr, self.getHitCount())
1912 def getHitScanIDColumn(self):
1914 Read every hit's scan ID in index order in one pass.
1916 See :meth:`getHitXYZColumn` for why this is preferred over a per-index loop.
1919 List of scan indices, one per hit
1922 RuntimeError: If the native library predates helios-core v1.3.84
1924 return lidar_wrapper.getLiDARHitScanIDColumn(self._cloud_ptr, self.getHitCount())
1926 def estimateHitPointMemory(self, hit_count: int) -> int:
1928 Estimate the resident memory a cloud of ``hit_count`` points will occupy, in bytes.
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.
1935 Most accurate once at least one point exists, since it reads the labels created
1939 hit_count: Number of hit points to estimate for
1942 Estimated resident bytes
1945 RuntimeError: If the native library predates helios-core v1.3.84
1947 return lidar_wrapper.estimateLiDARHitPointMemory(self._cloud_ptr, hit_count)
1949 def setMaxHitPoints(self, max_hits: int) -> None:
1951 Set the cap on stored hit points before loading fails with a diagnostic.
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.
1961 max_hits: Maximum stored hit points, or 0 to disable the check
1964 RuntimeError: If the native library predates helios-core v1.3.84
1966 lidar_wrapper.setLiDARMaxHitPoints(self._cloud_ptr, max_hits)
1968 def getMaxHitPoints(self) -> int:
1970 Current cap on stored hit points, or 0 if the check is disabled.
1973 RuntimeError: If the native library predates helios-core v1.3.84
1975 return lidar_wrapper.getLiDARMaxHitPoints(self._cloud_ptr)
1978 def getDefaultMaxHitPoints() -> int:
1980 Default cap on the number of stored hit points in a cloud (100 million).
1983 RuntimeError: If the native library predates helios-core v1.3.84
1985 return lidar_wrapper.getLiDARDefaultMaxHitPoints()
1987 def reserveHitPoints(self, hit_count: int) -> None:
1989 Reserve capacity for hit points and every scalar-data column at once.
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.
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.
2003 hit_count: Expected total number of hit points in the cloud
2006 RuntimeError: If the native library predates helios-core v1.3.84
2008 lidar_wrapper.reserveLiDARHitPoints(self._cloud_ptr, hit_count)
2010 def setExactPathLengths(self, exact: bool) -> None:
2012 Keep every beam path length exactly, instead of binning them.
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.
2021 exact: True to keep every sample; False (the default) to bin above the threshold
2024 RuntimeError: If the native library predates helios-core v1.3.84
2026 lidar_wrapper.setLiDARExactPathLengths(self._cloud_ptr, exact)
2028 def getExactPathLengths(self) -> bool:
2030 Whether path lengths are accumulated exactly.
2033 RuntimeError: If the native library predates helios-core v1.3.84
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,
2046 Perform synthetic LiDAR scan of geometry in Context.
2048 Requires scan metadata to be defined first via addScan() or loadXML().
2049 Uses ray tracing to simulate LiDAR instrument measurements.
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.
2065 Example (Discrete-return):
2066 >>> from pyhelios import Context, LiDARCloud
2067 >>> from pyhelios.types import vec3
2068 >>> with Context() as context:
2070 ... context.addPatch(center=vec3(0, 0, 0.5), size=vec2(1, 1))
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
2081 ... # Perform discrete-return scan
2082 ... lidar.syntheticScan(context)
2084 Example (Full-waveform):
2085 >>> lidar.syntheticScan(
2087 ... rays_per_pulse=100,
2088 ... pulse_distance_threshold=0.02,
2089 ... record_misses=True
2092 if not isinstance(context, Context):
2093 raise TypeError("context must be a Context instance")
2095 context_ptr = context.getNativePtr()
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)
2103 self._dispatch_synthetic_scan(
2104 context_ptr, rays_per_pulse, pulse_distance_threshold,
2105 scan_grid_only, record_misses, append, return_mode)
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()
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:
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)
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")
2131 validate_positive_value(rays_per_pulse, 'rays_per_pulse', 'syntheticScan')
2132 validate_positive_value(pulse_distance_threshold, 'pulse_distance_threshold', 'syntheticScan')
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
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
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):
2151 Calculate leaf area for each grid cell.
2153 Requires triangulation to have been performed first, UNLESS a ``Gtheta`` is supplied
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.
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.
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.
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
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)
2201 if not isinstance(context, Context):
2202 raise TypeError("context must be a Context instance")
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))
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:
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)]
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:
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")
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.
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:
2238 "element_width requires min_voxel_hits to also be specified "
2239 "(the uncertainty overload takes both)")
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)
2254 lidar_wrapper.calculateLiDARLeafAreaMinHits(self._cloud_ptr, context_ptr, min_voxel_hits)
2256 def calculateSyntheticLeafArea(self, context: Context):
2258 Calculate synthetic leaf area (for validation of synthetic scans).
2260 Uses exact primitive geometry to calculate leaf area, useful for
2261 validating synthetic scan accuracy.
2264 context: Helios Context instance containing primitive geometry
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)
2271 def calculateSyntheticGtheta(self, context: Context):
2273 Calculate synthetic G(theta) (for validation of synthetic scans).
2275 Uses exact primitive geometry to calculate G(theta), useful for
2276 validating synthetic scan accuracy.
2279 context: Helios Context instance containing primitive geometry
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)
2286 def exportTriangleNormals(self, filename: str):
2287 """Export triangle normal vectors to file
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.
2294 raise ValueError("Filename cannot be empty")
2295 lidar_wrapper.exportLiDARTriangleNormals(self._cloud_ptr, filename)
2297 def exportTriangleAreas(self, filename: str):
2298 """Export triangle areas to file
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.
2305 raise ValueError("Filename cannot be empty")
2306 lidar_wrapper.exportLiDARTriangleAreas(self._cloud_ptr, filename)
2308 def exportLeafAreas(self, filename: str):
2309 """Export leaf areas for each grid cell to file"""
2311 raise ValueError("Filename cannot be empty")
2312 lidar_wrapper.exportLiDARLeafAreas(self._cloud_ptr, filename)
2314 def exportLeafAreaDensities(self, filename: str):
2315 """Export leaf area densities for each grid cell to file"""
2317 raise ValueError("Filename cannot be empty")
2318 lidar_wrapper.exportLiDARLeafAreaDensities(self._cloud_ptr, filename)
2320 def exportGtheta(self, filename: str):
2321 """Export G(theta) values for each grid cell to file"""
2323 raise ValueError("Filename cannot be empty")
2324 lidar_wrapper.exportLiDARGtheta(self._cloud_ptr, filename)
2326 def addTrianglesToContext(self, context: Context):
2328 Add triangulated mesh to Context as triangle primitives.
2330 Converts the triangulated point cloud mesh into Context triangle
2331 primitives that can be used for further analysis or visualization.
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.
2339 context: Helios Context instance
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")
2349 if not isinstance(context, Context):
2350 raise TypeError("context must be a Context instance")
2351 lidar_wrapper.addLiDARTrianglesToContext(self._cloud_ptr, context.getNativePtr())
2353 def initializeCollisionDetection(self, context: Context):
2355 Initialize CollisionDetection plugin for ray tracing.
2357 Required before performing synthetic scans.
2360 context: Helios Context instance containing geometry
2362 if not isinstance(context, Context):
2363 raise TypeError("context must be a Context instance")
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:
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"
2377 "Fix: create a new LiDARCloud for a different Context, or reuse the "
2378 "Context this cloud was initialized with."
2380 self._cd_context = context
2382 lidar_wrapper.initializeLiDARCollisionDetection(self._cloud_ptr, context.getNativePtr())
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")
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)
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)
2399 def isGPUAvailable(self) -> bool:
2400 """Return True if a CUDA-capable GPU is available for collision-detection ray tracing.
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.
2405 return lidar_wrapper.isLiDARGPUAvailable(self._cloud_ptr)
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)
2411 def setSyntheticScanProgressPointer(self, ptr):
2412 """Register an external per-scan progress counter polled during :meth:`syntheticScan`.
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.
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)
2424 # ------------------------------------------------------------------
2425 # helios-core 1.3.86 additions
2426 # ------------------------------------------------------------------
2428 def createHitDataColumn(self, label: str, column_type: HitDataType) -> None:
2430 Create a per-hit scalar-data column with an explicit storage type.
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.
2438 label: Label of the data value (e.g. "intensity")
2439 column_type: A :class:`HitDataType` member
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
2447 self._validate_label(label)
2448 if not isinstance(column_type, HitDataType):
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))
2454 def getHitDataType(self, label: str) -> HitDataType:
2456 Storage type of an existing per-hit scalar-data column.
2458 A column typed implicitly may since have widened to ``FLOAT64``.
2461 label: Label of the data value
2464 The column's current :class:`HitDataType`
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
2473 if not isinstance(label, str):
2474 raise TypeError(f"label must be a str, got {type(label).__name__}")
2476 raise ValueError("label cannot be empty")
2477 return HitDataType(lidar_wrapper.getLiDARHitDataType(self._cloud_ptr, label))
2479 def getHitDataColumnFloat32(self, label: str, absent_value: float = -9999.0) -> List[float]:
2481 Bulk-export a named scalar column as 32-bit floats.
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.
2488 label: Label of the data value
2489 absent_value: Value reported for hits that lack the label
2492 List of floats of length :meth:`getHitCount`
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
2499 self._validate_label(label)
2500 n = self.getHitCount()
2503 return lidar_wrapper.getLiDARHitDataColumnF32(self._cloud_ptr, label, n, absent_value)
2505 def getHitDataColumnFloat32Array(self, label: str, absent_value: float = -9999.0):
2507 Bulk-export a named scalar column as a ``(getHitCount(),)`` float32 numpy array.
2509 See :meth:`getHitDataColumnFloat32`.
2512 self._validate_label(label)
2513 n = self.getHitCount()
2515 return np.empty((0,), np.float32)
2516 return lidar_wrapper.getLiDARHitDataColumnF32_np(self._cloud_ptr, label, n, absent_value)
2518 def getHitDataColumnInt32(self, label: str, absent_value: int = -9999) -> List[int]:
2520 Bulk-export a named scalar column as 32-bit signed integers.
2522 Reads an ``INT32`` column without widening it.
2525 label: Label of the data value
2526 absent_value: Value reported for hits that lack the label
2529 List of ints of length :meth:`getHitCount`
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
2539 self._validate_label(label)
2540 n = self.getHitCount()
2543 return lidar_wrapper.getLiDARHitDataColumnI32(self._cloud_ptr, label, n, absent_value)
2545 def getHitDataColumnInt32Array(self, label: str, absent_value: int = -9999):
2547 Bulk-export a named scalar column as a ``(getHitCount(),)`` int32 numpy array.
2549 See :meth:`getHitDataColumnInt32`.
2552 self._validate_label(label)
2553 n = self.getHitCount()
2555 return np.empty((0,), np.int32)
2556 return lidar_wrapper.getLiDARHitDataColumnI32_np(self._cloud_ptr, label, n, absent_value)
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__}")
2563 raise ValueError("label cannot be empty")
2565 def addHitPointsBulk(self, scanID: int, xyz, dir_spherical=None,
2566 labels: Optional[List[str]] = None, values=None) -> None:
2568 Bulk-ingest hit points through the native bulk path, with double-precision positions.
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.
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.
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
2590 >>> import numpy as np
2591 >>> lidar.addHitPointsBulk(0, np.zeros((10, 3)),
2592 ... labels=["intensity"], values=np.ones((10, 1)))
2595 raise ValueError("scanID must be non-negative")
2596 lidar_wrapper.addLiDARHitPointsBulk(self._cloud_ptr, scanID, xyz, dir_spherical,
2599 def deleteHitPoints(self, first: int, count: int) -> None:
2601 Delete a contiguous range of hit points, preserving the order of the rest.
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.
2610 first: Index of the first hit to delete
2611 count: Number of hits to delete
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
2619 raise ValueError("first must be non-negative")
2621 raise ValueError("count must be non-negative")
2622 lidar_wrapper.deleteLiDARHitPoints(self._cloud_ptr, first, count)
2624 def setTriangulationSink(self, callback) -> None:
2626 Register a sink that receives each scan's triangles as :meth:`triangulateHitPoints` finishes it.
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.
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.
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.
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`.
2652 callback: Callable taking ``(scanID, vertices, ids)``, or ``None`` to clear
2655 TypeError: If ``callback`` is neither callable nor None
2656 RuntimeError: If the native library predates helios-core v1.3.86
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
2666 if not callable(callback):
2667 raise TypeError("callback must be callable or None")
2669 self._triangulation_sink_error = None
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.
2678 verts = np.ctypeslib.as_array(xyz9, shape=(n, 9)).copy()
2680 verts = np.empty((0, 9), np.float32)
2682 id_arr = np.ctypeslib.as_array(ids, shape=(n, 2)).copy()
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
2690 self._triangulation_sink_ref = lidar_wrapper.LiDARTriangulationSinkCallback(_trampoline)
2691 lidar_wrapper.setLiDARTriangulationSink(self._cloud_ptr, self._triangulation_sink_ref)
2693 def setSyntheticScanHitSink(self, callback) -> None:
2695 Register a sink fired after each chunk of a :meth:`syntheticScan` lands in the cloud.
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.
2705 Chunk size is bounded by :meth:`setSyntheticScanMemoryBudget`. Pass ``None`` to clear.
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`.
2715 callback: Callable taking ``(first, count)``, or ``None`` to clear
2718 TypeError: If ``callback`` is neither callable nor None
2719 RuntimeError: If the native library predates helios-core v1.3.86
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
2727 if not callable(callback):
2728 raise TypeError("callback must be callable or None")
2730 self._synthetic_hit_sink_error = None
2732 def _trampoline(first, count, user_data):
2733 # See setTriangulationSink: a raised exception cannot cross the ctypes boundary.
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
2740 self._synthetic_hit_sink_ref = lidar_wrapper.LiDARSyntheticScanHitSinkCallback(_trampoline)
2741 lidar_wrapper.setLiDARSyntheticScanHitSink(self._cloud_ptr, self._synthetic_hit_sink_ref)
2743 def _raise_pending_callback_error(self) -> None:
2744 """Re-raise an exception captured inside a streaming-sink callback, if any.
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.
2750 for attr in ('_triangulation_sink_error', '_synthetic_hit_sink_error'):
2751 exc = getattr(self, attr, None)
2753 setattr(self, attr, None)
2756 def getScanHitCount(self, scanID: int) -> int:
2758 Number of hit points (stored returns plus virtualized misses) belonging to one scan.
2760 This is the length the per-scan readers fill -- :meth:`getScanHitIndices`,
2761 :meth:`getScanHitXYZColumn` and :meth:`getScanHitDataColumn`.
2767 Number of hits in that scan
2770 ValueError: If ``scanID`` is negative
2771 RuntimeError: If the native library predates helios-core v1.3.86
2773 self._validate_scan_id(scanID)
2774 return lidar_wrapper.getLiDARScanHitCount(self._cloud_ptr, scanID)
2776 def getScanHitIndices(self, scanID: int) -> List[int]:
2778 Global indices of one scan's hit points, in the order the per-scan readers use.
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.
2791 List of global hit indices, one per hit in the scan
2794 ValueError: If ``scanID`` is negative
2795 RuntimeError: If the native library predates helios-core v1.3.86
2797 self._validate_scan_id(scanID)
2798 n = self.getScanHitCount(scanID)
2799 return lidar_wrapper.getLiDARScanHitIndices(self._cloud_ptr, scanID, n)
2801 def getScanHitXYZColumn(self, scanID: int):
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.
2814 List of (x, y, z) tuples, one per hit in the scan, in local order
2817 ValueError: If ``scanID`` is negative
2818 RuntimeError: If the native library predates helios-core v1.3.86
2820 self._validate_scan_id(scanID)
2821 n = self.getScanHitCount(scanID)
2822 return lidar_wrapper.getLiDARScanHitXYZColumn(self._cloud_ptr, scanID, n)
2824 def getScanHitDataColumn(self, scanID: int, label: str,
2825 absent_value: float = -9999.0) -> List[float]:
2827 Read one scan's values of a scalar-data label in a single pass, as doubles.
2829 The per-scan counterpart of :meth:`getHitDataColumn`; see
2830 :meth:`getScanHitXYZColumn` for why this is preferred over filtering the whole cloud.
2834 label: Label of the data value
2835 absent_value: Value reported for hits that lack the label
2838 List of floats, one per hit in the scan, in local order
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
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,
2851 def getScanHitDataColumnFloat32(self, scanID: int, label: str,
2852 absent_value: float = -9999.0) -> List[float]:
2854 Read one scan's values of a scalar-data label as 32-bit floats.
2856 See :meth:`getScanHitDataColumn` and :meth:`getHitDataColumnFloat32`.
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
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,
2869 def getScanHitDataColumnInt32(self, scanID: int, label: str,
2870 absent_value: int = -9999) -> List[int]:
2872 Read one scan's values of a scalar-data label as 32-bit signed integers.
2874 See :meth:`getScanHitDataColumn` and :meth:`getHitDataColumnInt32`.
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
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,
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__}")
2893 raise ValueError("scanID must be non-negative")
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:
2899 Calculate leaf area for only a block of the voxel grid.
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.
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.
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
2925 if not isinstance(context, Context):
2926 raise TypeError("context must be a Context instance")
2927 context_ptr = context.getNativePtr()
2930 lidar_wrapper.calculateLiDARLeafAreaBlock(
2931 self._cloud_ptr, context_ptr, min_voxel_hits, element_width, ijk_min, ijk_max)
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,
2942 per_cell = [float(g) for g in Gtheta]
2944 raise ValueError("A per-cell Gtheta sequence must contain at least one value")
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,
2952 def getCellGlobalIJK(self, index: int) -> Tuple[int, int, int]:
2954 Lattice index (i, j, k) of a grid cell along x, y and z.
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.
2961 index: Index of a grid cell
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
2972 raise ValueError("index must be non-negative")
2973 return lidar_wrapper.getLiDARCellGlobalIJK(self._cloud_ptr, index)
2975 def getGridGlobalCount(self) -> Tuple[int, int, int]:
2977 Number of lattice cells along x, y and z (the ``ndiv`` passed to :meth:`addGrid`).
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
2986 return lidar_wrapper.getLiDARGridGlobalCount(self._cloud_ptr)
2988 def getHitPointCapacity(self) -> int:
2990 Number of hit points the cloud can hold before its arrays reallocate.
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.
2996 Current hit-point capacity
2999 RuntimeError: If the native library predates helios-core v1.3.86
3001 return lidar_wrapper.getLiDARHitPointCapacity(self._cloud_ptr)
3003 def setProgressCallback(self, callback):
3004 """Register a progress callback fired with ``(progress_fraction, message)`` during :meth:`syntheticScan`.
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.
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
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 "")
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)
3027 def is_available(self) -> bool:
3029 Check if LiDAR is available in current build.
3032 True if plugin is available, False otherwise
3034 registry = get_plugin_registry()
3035 return registry.is_plugin_available('lidar')
3038# Convenience function
3039def create_lidar_cloud() -> LiDARCloud:
3041 Create LiDARCloud instance.