173 Initialize LiDARCloud.
176 LiDARError: If plugin not available in current build
177 RuntimeError: If cloud initialization fails
180 registry = get_plugin_registry()
181 if not registry.is_plugin_available(
'lidar'):
183 "LiDAR plugin not available. Rebuild PyHelios with LiDAR:\n"
184 " build_scripts/build_helios --plugins lidar\n"
186 "System requirements:\n"
187 " - Platforms: Windows, Linux, macOS\n"
188 " - GPU: Optional (enables GPU acceleration)"
193 raise LiDARError(
"Failed to create LiDAR cloud")
208 """Context manager entry"""
211 def __exit__(self, exc_type, exc_val, exc_tb):
212 """Context manager exit - cleanup resources"""
213 if hasattr(self,
'_cloud_ptr')
and self.
_cloud_ptr:
214 lidar_wrapper.destroyLiDARcloud(self.
_cloud_ptr)
218 """Fallback destructor for cleanup without context manager"""
219 if hasattr(self, '_cloud_ptr') and self._cloud_ptr is not None:
221 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
222 self._cloud_ptr = None
223 except Exception as e:
225 warnings.warn(f"Error in LiDARCloud.__del__: {e}")
227 def addScan(self, origin: Union[vec3, List[float], Tuple[float, float, float]],
228 Ntheta: int, theta_range: Tuple[float, float],
229 Nphi: int, phi_range: Tuple[float, float],
230 exit_diameter: float, beam_divergence: float,
231 column_format: Optional[List[str]] = None,
232 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
233 scan_tilt_roll: float = 0.0, scan_tilt_pitch: float = 0.0,
234 scan_azimuth_offset: float = 0.0) -> int:
236 Add a LiDAR scan to the point cloud.
239 origin: Scanner position (vec3 or 3-element list/tuple)
240 Ntheta: Number of scan points in zenith direction
241 theta_range: Zenith angle range (min, max) in radians
242 Nphi: Number of scan points in azimuthal direction
243 phi_range: Azimuthal angle range (min, max) in radians
244 exit_diameter: Laser beam exit diameter (meters)
245 beam_divergence: Beam divergence angle (radians)
246 column_format: Optional list of column-format labels. Non-standard labels
247 (anything other than geometry/standard tokens like x/y/z/r/g/b/raydir)
248 cause syntheticScan to sample that named primitive data from the struck
249 primitive onto each hit's data map, retrievable via getHitData(). Defaults
250 to None (empty format).
252 One label is special: "reflectivity_lidar" modulates each hit's "intensity"
253 (intensity *= reflectivity) rather than being stored as its own hit-data
254 key, so getHitData(i, "reflectivity_lidar") will NOT return it.
255 range_noise_stddev: Standard deviation of Gaussian range (along-beam) measurement
256 noise in meters. Only affects synthetic-scan generation. Defaults to 0.0
258 angle_noise_stddev: Standard deviation of Gaussian angular (beam-pointing) jitter
259 in radians. Only affects synthetic-scan generation. Defaults to 0.0 (jitter
261 scan_tilt_roll: Global scanner tilt roll angle in radians, modeling residual tilt of
262 the scanner spin axis away from plumb (right-hand rotation about the body lateral
263 axis). Only affects synthetic-scan generation. Defaults to 0.0 (level).
264 scan_tilt_pitch: Global scanner tilt pitch angle in radians (right-hand rotation about
265 the body forward/azimuth-zero axis). Only affects synthetic-scan generation.
266 Defaults to 0.0 (level).
267 scan_azimuth_offset: Global scanner azimuth (heading) offset in radians, a right-hand
268 rotation about the world +z axis applied on top of the azimuth sweep. Only affects
269 synthetic-scan generation. Defaults to 0.0 (no offset).
272 Scan ID for referencing this scan
275 >>> scan_id = lidar.addScan(
276 ... origin=vec3(0, 0, 1),
277 ... Ntheta=100, theta_range=(0, 1.57),
278 ... Nphi=100, phi_range=(-3.14, 3.14),
279 ... exit_diameter=0.01, beam_divergence=0.001,
280 ... column_format=["my_scalar"]
283 # Convert origin to vec3 if needed
284 if isinstance(origin, (list, tuple)):
286 raise ValueError("Origin must have 3 elements [x, y, z]")
287 origin = vec3(*origin)
288 elif not hasattr(origin, 'x'):
289 raise ValueError("Origin must be vec3 or 3-element list/tuple")
291 origin_list = [origin.x, origin.y, origin.z]
293 # Validate scan parameters
294 validate_positive_value(Ntheta, 'Ntheta', 'addScan')
295 validate_positive_value(Nphi, 'Nphi', 'addScan')
297 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
298 raise ValueError("theta_range must be a tuple (min, max)")
299 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
300 raise ValueError("phi_range must be a tuple (min, max)")
302 if column_format is not None:
303 if not isinstance(column_format, (list, tuple)) or \
304 not all(isinstance(c, str) for c in column_format):
305 raise ValueError("column_format must be a list of strings")
306 column_format = list(column_format)
308 if range_noise_stddev < 0:
309 raise ValueError("range_noise_stddev must be non-negative")
310 if angle_noise_stddev < 0:
311 raise ValueError("angle_noise_stddev must be non-negative")
313 return lidar_wrapper.addLiDARScan(
314 self._cloud_ptr, origin_list, Ntheta, theta_range,
315 Nphi, phi_range, exit_diameter, beam_divergence, column_format,
316 range_noise_stddev, angle_noise_stddev,
317 scan_tilt_roll, scan_tilt_pitch, scan_azimuth_offset
320 def addScanMoving(self, Ntheta: int, theta_range: Tuple[float, float],
321 Nphi: int, phi_range: Tuple[float, float],
322 exit_diameter: float, beam_divergence: float,
324 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
325 traj_rot: List[List[float]], pulse_rate_hz: float,
326 rot_is_quaternion: bool = True,
327 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
328 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
329 column_format: Optional[List[str]] = None,
330 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
331 t0: float = 0.0) -> int:
333 Add a moving-platform (mobile/airborne) raster LiDAR scan driven by a 6-DOF pose trajectory.
335 Unlike :meth:`addScan`, the scanner pose changes during the sweep. For each pulse the synthetic-scan
336 generator computes its acquisition time ``t = t0 + ordinal / pulse_rate_hz``, interpolates the platform
337 pose at that time (linear position, SLERP orientation), and emits a per-pulse origin
338 ``o = pos + R(q) * lever_arm`` and direction ``d = R(q) * R(boresight) * d_body``. Every resulting hit
339 and miss stores its own origin (hit-data "origin_x"/"origin_y"/"origin_z", retrievable via
340 :meth:`getHitOrigin`), timestamp ("timestamp"), and firing index ("pulse_id").
342 The static tilt roll/pitch/azimuth fields are NOT applied in this mode; attitude comes entirely
343 from the trajectory and the boresight misalignment. Because the pulses do not lie on a fixed
344 theta-phi grid they cannot be triangulated, so leaf-area inversion must use
345 :meth:`calculateLeafArea` with an explicit ``Gtheta``.
348 Ntheta: Number of scan points in zenith direction (raster grid rows)
349 theta_range: Zenith angle range (min, max) in radians
350 Nphi: Number of scan points in azimuthal direction (raster grid columns)
351 phi_range: Azimuthal angle range (min, max) in radians
352 exit_diameter: Laser beam exit diameter (meters)
353 beam_divergence: Beam divergence angle (radians)
354 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
355 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
356 traj_rot: Platform orientations, one entry per traj_t entry. Each entry is a length-4
357 quaternion (qx, qy, qz, qw, Hamilton body->world) when ``rot_is_quaternion`` is True,
358 otherwise a length-3 roll/pitch/yaw Euler triple in radians (intrinsic Z-Y-X).
359 pulse_rate_hz: Pulse repetition rate in Hz (must be > 0)
360 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
361 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
362 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
363 column_format: Optional list of column-format labels (see addScan)
364 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
365 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
366 t0: Time of the first pulse in seconds (relative time; default 0)
369 Scan ID for referencing this scan
371 validate_positive_value(Ntheta, 'Ntheta', 'addScanMoving')
372 validate_positive_value(Nphi, 'Nphi', 'addScanMoving')
374 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
375 raise ValueError("theta_range must be a tuple (min, max)")
376 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
377 raise ValueError("phi_range must be a tuple (min, max)")
378 if pulse_rate_hz <= 0:
379 raise ValueError("pulse_rate_hz must be greater than 0")
380 if range_noise_stddev < 0:
381 raise ValueError("range_noise_stddev must be non-negative")
382 if angle_noise_stddev < 0:
383 raise ValueError("angle_noise_stddev must be non-negative")
385 rot_stride = 4 if rot_is_quaternion else 3
386 _, pos_list, rot_list = self._validate_trajectory(
387 traj_t, traj_pos, traj_rot, rot_stride, 'addScanMoving')
389 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
390 else list(lever_arm)) if lever_arm is not None else None
391 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
392 else list(boresight_rpy)) if boresight_rpy is not None else None
394 if column_format is not None:
395 if not isinstance(column_format, (list, tuple)) or \
396 not all(isinstance(c, str) for c in column_format):
397 raise ValueError("column_format must be a list of strings")
398 column_format = list(column_format)
400 return lidar_wrapper.addLiDARScanMoving(
401 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
402 exit_diameter, beam_divergence,
403 [float(t) for t in traj_t], pos_list, rot_list, bool(rot_is_quaternion),
404 float(pulse_rate_hz), lever_list, boresight_list, column_format,
405 range_noise_stddev, angle_noise_stddev, float(t0)
409 def _validate_trajectory(traj_t, traj_pos, traj_rot, rot_stride, method):
410 """Shared trajectory validation/marshalling for moving/spinning scans.
412 Returns (t_list, pos_list, rot_list) of plain Python floats. rot_stride is 4 for
413 quaternions or 3 for Euler triples; pass rot_stride=None to skip rotation validation.
415 if not isinstance(traj_t, (list, tuple)) or len(traj_t) == 0:
416 raise ValueError("traj_t must be a non-empty list of trajectory sample times")
418 if len(traj_pos) != M:
419 raise ValueError("traj_t and traj_pos must have the same length M")
420 if rot_stride is not None and len(traj_rot) != M:
421 raise ValueError("traj_t and the trajectory orientation list must have the same length M")
422 # Fail fast on a non-monotonic trajectory rather than deferring to a C++ exception.
423 if any(traj_t[i] >= traj_t[i + 1] for i in range(M - 1)):
424 raise ValueError("traj_t must be strictly monotonically increasing")
426 def _to_xyz(v, name):
428 return [v.x, v.y, v.z]
429 if isinstance(v, (list, tuple)) and len(v) == 3:
430 return [float(c) for c in v]
431 raise ValueError(f"{name} must be a vec3 or 3-element list/tuple")
433 pos_list = [_to_xyz(p, "Each traj_pos entry") for p in traj_pos]
436 if rot_stride is not None:
439 if not isinstance(r, (list, tuple)) or len(r) != rot_stride:
440 label = 'qx,qy,qz,qw' if rot_stride == 4 else 'roll,pitch,yaw'
442 f"Each trajectory orientation entry must have {rot_stride} elements ({label})"
444 rot_list.append([float(c) for c in r])
445 return [float(t) for t in traj_t], pos_list, rot_list
447 def addScanSpinning(self, beam_elevation_angles: List[float],
448 azimuth_step: float, pulse_rate_hz: float,
450 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
451 traj_rot: List[List[float]],
452 rot_is_quaternion: bool = True,
453 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
454 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
455 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
456 column_format: Optional[List[str]] = None,
457 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
458 t0: float = 0.0) -> int:
460 Add a continuously-spinning multibeam scan from physical instrument parameters.
462 High-level entry point for a rotating multi-channel sensor (Velodyne/Ouster/Hesai) on a moving
463 (or stationary) platform. The azimuth grid, rotation rate, and revolution count are derived
464 internally from the azimuth resolution, PRF, and trajectory duration; you never specify an
465 azimuth range or step count. Sets the scan's :class:`ScanMode` to ``SPINNING``. For a stationary
466 "spin in place" capture (a tripod), supply two coincident poses (same position and orientation)
467 separated in time by the acquisition duration.
470 beam_elevation_angles: Per-channel beam ELEVATION angles above the horizon, in radians
471 (NOT zenith — elevation above the horizon, where zenith = pi/2 - elevation; this matches
472 manufacturer spec sheets)
473 azimuth_step: Azimuth angular resolution in radians per firing step (must be > 0)
474 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
475 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
476 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
477 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
478 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
479 Euler triple in radians (intrinsic Z-Y-X).
480 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
481 exit_diameter: Laser beam exit diameter (meters, default 0)
482 beam_divergence: Beam divergence angle (radians, default 0)
483 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
484 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
485 column_format: Optional list of column-format labels (default ["x", "y", "z"])
486 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
487 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
488 t0: Time of the first pulse in seconds (relative time; default 0)
491 Scan ID for referencing this scan
493 if not isinstance(beam_elevation_angles, (list, tuple)) or len(beam_elevation_angles) == 0:
494 raise ValueError("beam_elevation_angles must be a non-empty list of per-channel angles")
495 if azimuth_step <= 0:
496 raise ValueError("azimuth_step must be greater than 0")
497 if pulse_rate_hz <= 0:
498 raise ValueError("pulse_rate_hz must be greater than 0")
499 if range_noise_stddev < 0:
500 raise ValueError("range_noise_stddev must be non-negative")
501 if angle_noise_stddev < 0:
502 raise ValueError("angle_noise_stddev must be non-negative")
504 rot_stride = 4 if rot_is_quaternion else 3
505 t_list, pos_list, rot_list = self._validate_trajectory(
506 traj_t, traj_pos, traj_rot, rot_stride, 'addScanSpinning')
508 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
509 else list(lever_arm)) if lever_arm is not None else None
510 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
511 else list(boresight_rpy)) if boresight_rpy is not None else None
513 if column_format is not None:
514 if not isinstance(column_format, (list, tuple)) or \
515 not all(isinstance(c, str) for c in column_format):
516 raise ValueError("column_format must be a list of strings")
517 column_format = list(column_format)
519 return lidar_wrapper.addLiDARScanSpinning(
520 self._cloud_ptr, [float(a) for a in beam_elevation_angles],
521 float(azimuth_step), float(pulse_rate_hz),
522 t_list, pos_list, rot_list, bool(rot_is_quaternion),
523 exit_diameter, beam_divergence,
524 lever_list, boresight_list, column_format,
525 range_noise_stddev, angle_noise_stddev, float(t0)
528 def addScanMovingRaster(self, Ntheta: int, theta_range: Tuple[float, float],
529 Nphi: int, phi_range: Tuple[float, float],
530 pulse_rate_hz: float,
532 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
533 traj_quat: List[List[float]],
534 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
535 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
536 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
537 column_format: Optional[List[str]] = None,
538 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
539 t0: float = 0.0) -> int:
541 Add a moving-platform raster scan: a fixed angular fan swept along a quaternion trajectory.
543 High-level wrapper around :meth:`addScanMoving` for a non-spinning sensor on a moving platform.
544 Specify the per-frame angular fan resolution plus the trajectory and PRF; Helios derives the
545 per-pulse time sampling along the trajectory. Sets the scan's :class:`ScanMode` to ``MOVING_RASTER``.
548 Ntheta: Number of zenith samples in the angular fan
549 theta_range: Zenith angle range (min, max) in radians
550 Nphi: Number of azimuth samples in the angular fan
551 phi_range: Azimuthal angle range (min, max) in radians
552 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
553 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
554 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
555 traj_quat: Platform orientation quaternions (qx, qy, qz, qw, Hamilton body->world), one per
557 exit_diameter: Laser beam exit diameter (meters, default 0)
558 beam_divergence: Beam divergence angle (radians, default 0)
559 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
560 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
561 column_format: Optional list of column-format labels (default ["x", "y", "z"])
562 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
563 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
564 t0: Time of the first pulse in seconds (relative time; default 0)
567 Scan ID for referencing this scan
569 validate_positive_value(Ntheta, 'Ntheta', 'addScanMovingRaster')
570 validate_positive_value(Nphi, 'Nphi', 'addScanMovingRaster')
571 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
572 raise ValueError("theta_range must be a tuple (min, max)")
573 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
574 raise ValueError("phi_range must be a tuple (min, max)")
575 if pulse_rate_hz <= 0:
576 raise ValueError("pulse_rate_hz must be greater than 0")
577 if range_noise_stddev < 0:
578 raise ValueError("range_noise_stddev must be non-negative")
579 if angle_noise_stddev < 0:
580 raise ValueError("angle_noise_stddev must be non-negative")
582 t_list, pos_list, quat_list = self._validate_trajectory(
583 traj_t, traj_pos, traj_quat, 4, 'addScanMovingRaster')
585 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
586 else list(lever_arm)) if lever_arm is not None else None
587 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
588 else list(boresight_rpy)) if boresight_rpy is not None else None
590 if column_format is not None:
591 if not isinstance(column_format, (list, tuple)) or \
592 not all(isinstance(c, str) for c in column_format):
593 raise ValueError("column_format must be a list of strings")
594 column_format = list(column_format)
596 return lidar_wrapper.addLiDARScanMovingRaster(
597 self._cloud_ptr, Ntheta, theta_range, Nphi, phi_range,
598 float(pulse_rate_hz),
599 t_list, pos_list, quat_list,
600 exit_diameter, beam_divergence,
601 lever_list, boresight_list, column_format,
602 range_noise_stddev, angle_noise_stddev, float(t0)
605 def addScanRisley(self, prisms: List[Union['RisleyPrism', List[float], Tuple[float, ...]]],
606 refractive_index_air: float, pulse_rate_hz: float,
608 traj_pos: List[Union[vec3, List[float], Tuple[float, float, float]]],
609 traj_rot: List[List[float]],
610 rot_is_quaternion: bool = True,
611 exit_diameter: float = 0.0, beam_divergence: float = 0.0,
612 lever_arm: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
613 boresight_rpy: Optional[Union[vec3, List[float], Tuple[float, float, float]]] = None,
614 column_format: Optional[List[str]] = None,
615 range_noise_stddev: float = 0.0, angle_noise_stddev: float = 0.0,
616 t0: float = 0.0) -> int:
618 Add a rotating-Risley-prism (Livox-style rosette) scan from physical instrument parameters.
620 High-level entry point for a Livox rosette-pattern sensor (Mid-40/Mid-70/Avia). A single beam is
621 refracted through a stack of continuously rotating wedge prisms, tracing a non-repetitive rosette
622 that fills a circular field of view. The scan is stored as an Ntheta=1, Nphi=Npulses table, where
623 Npulses = round(pulse_rate_hz * trajectory_duration). Sets the scan's :class:`ScanMode` to
624 ``RISLEY_PRISM`` and :class:`ScanPattern` to ``RISLEY_PRISM``. Like a spinning scan it is always
625 trajectory-driven; a stationary tripod capture is two coincident poses (same position and
626 orientation) separated in time by the acquisition duration.
629 prisms: Rotating wedge prisms in beam-traversal order (at least one; a Livox sensor uses two
630 counter-rotating prisms). Each entry is a :class:`RisleyPrism` or a 4-element
631 [wedge_angle, refractive_index, rotor_rate, phase] list/tuple (radians / unitless / rad-per-s / radians).
632 refractive_index_air: Refractive index of the medium surrounding the prisms (typically 1.0)
633 pulse_rate_hz: Pulse repetition rate (PRF) in Hz (must be > 0)
634 traj_t: Monotonically increasing trajectory sample times in seconds (length M)
635 traj_pos: Platform positions in world coordinates, one [x, y, z] (or vec3) per traj_t entry
636 traj_rot: Platform orientations, one per traj_t entry. Length-4 quaternion (qx, qy, qz, qw,
637 Hamilton body->world) when ``rot_is_quaternion`` is True, otherwise length-3 roll/pitch/yaw
638 Euler triple in radians (intrinsic Z-Y-X).
639 rot_is_quaternion: Whether traj_rot holds quaternions (default True) or Euler angles
640 exit_diameter: Laser beam exit diameter (meters, default 0)
641 beam_divergence: Beam divergence angle (radians, default 0)
642 lever_arm: Sensor optical center in the platform body frame [x, y, z] meters (default origin)
643 boresight_rpy: Fixed sensor rotational misalignment [roll, pitch, yaw] radians (default 0)
644 column_format: Optional list of column-format labels (default ["x", "y", "z"])
645 range_noise_stddev: Std. dev. of Gaussian range noise in meters (default 0)
646 angle_noise_stddev: Std. dev. of Gaussian angular jitter in radians (default 0)
647 t0: Time of the first pulse in seconds (relative time; default 0)
650 Scan ID for referencing this scan
652 if not isinstance(prisms, (list, tuple)) or len(prisms) == 0:
653 raise ValueError("prisms must be a non-empty list of RisleyPrism or 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
656 if isinstance(p, RisleyPrism):
657 prism_lists.append(p.to_list())
658 elif isinstance(p, (list, tuple)) and len(p) == 4:
659 prism_lists.append([float(c) for c in p])
661 raise ValueError("Each prism must be a RisleyPrism or a 4-element [wedge_angle, refractive_index, rotor_rate, phase]")
662 if refractive_index_air <= 0:
663 raise ValueError("refractive_index_air must be greater than 0")
664 if pulse_rate_hz <= 0:
665 raise ValueError("pulse_rate_hz must be greater than 0")
666 if range_noise_stddev < 0:
667 raise ValueError("range_noise_stddev must be non-negative")
668 if angle_noise_stddev < 0:
669 raise ValueError("angle_noise_stddev must be non-negative")
671 rot_stride = 4 if rot_is_quaternion else 3
672 t_list, pos_list, rot_list = self._validate_trajectory(
673 traj_t, traj_pos, traj_rot, rot_stride, 'addScanRisley')
675 lever_list = ([lever_arm.x, lever_arm.y, lever_arm.z] if hasattr(lever_arm, 'x')
676 else list(lever_arm)) if lever_arm is not None else None
677 boresight_list = ([boresight_rpy.x, boresight_rpy.y, boresight_rpy.z] if hasattr(boresight_rpy, 'x')
678 else list(boresight_rpy)) if boresight_rpy is not None else None
680 if column_format is not None:
681 if not isinstance(column_format, (list, tuple)) or \
682 not all(isinstance(c, str) for c in column_format):
683 raise ValueError("column_format must be a list of strings")
684 column_format = list(column_format)
686 return lidar_wrapper.addLiDARScanRisley(
687 self._cloud_ptr, prism_lists, float(refractive_index_air), float(pulse_rate_hz),
688 t_list, pos_list, rot_list, bool(rot_is_quaternion),
689 exit_diameter, beam_divergence,
690 lever_list, boresight_list, column_format,
691 range_noise_stddev, angle_noise_stddev, float(t0)
694 def getScanCount(self) -> int:
695 """Get total number of scans in the cloud"""
696 return lidar_wrapper.getLiDARScanCount(self._cloud_ptr)
698 def getScanOrigin(self, scanID: int) -> vec3:
699 """Get origin of a specific scan"""
701 raise ValueError("Scan ID must be non-negative")
702 origin_list = lidar_wrapper.getLiDARScanOrigin(self._cloud_ptr, scanID)
703 return vec3(*origin_list)
705 def getScanSizeTheta(self, scanID: int) -> int:
706 """Get number of zenith scan points for a scan"""
708 raise ValueError("Scan ID must be non-negative")
709 return lidar_wrapper.getLiDARScanSizeTheta(self._cloud_ptr, scanID)
711 def getScanSizePhi(self, scanID: int) -> int:
712 """Get number of azimuthal scan points for a scan"""
714 raise ValueError("Scan ID must be non-negative")
715 return lidar_wrapper.getLiDARScanSizePhi(self._cloud_ptr, scanID)
717 def getScanRangeNoiseStdDev(self, scanID: int) -> float:
718 """Get the range (along-beam) measurement noise standard deviation for a scan (meters).
720 Returns the value supplied to addScan() as ``range_noise_stddev`` (0.0 if disabled).
723 raise ValueError("Scan ID must be non-negative")
724 return lidar_wrapper.getLiDARScanRangeNoiseStdDev(self._cloud_ptr, scanID)
726 def getScanAngleNoiseStdDev(self, scanID: int) -> float:
727 """Get the angular (beam-pointing) jitter standard deviation for a scan (radians).
729 Returns the value supplied to addScan() as ``angle_noise_stddev`` (0.0 if disabled).
732 raise ValueError("Scan ID must be non-negative")
733 return lidar_wrapper.getLiDARScanAngleNoiseStdDev(self._cloud_ptr, scanID)
735 def getScanTiltRoll(self, scanID: int) -> float:
736 """Get the global scanner tilt roll angle for a scan (radians; 0.0 if level)."""
738 raise ValueError("Scan ID must be non-negative")
739 return lidar_wrapper.getLiDARScanTiltRoll(self._cloud_ptr, scanID)
741 def getScanTiltPitch(self, scanID: int) -> float:
742 """Get the global scanner tilt pitch angle for a scan (radians; 0.0 if level)."""
744 raise ValueError("Scan ID must be non-negative")
745 return lidar_wrapper.getLiDARScanTiltPitch(self._cloud_ptr, scanID)
747 def getScanAzimuthOffset(self, scanID: int) -> float:
748 """Get the global scanner azimuth (heading) offset for a scan (radians; 0.0 if none)."""
750 raise ValueError("Scan ID must be non-negative")
751 return lidar_wrapper.getLiDARScanAzimuthOffset(self._cloud_ptr, scanID)
753 def getScanPattern(self, scanID: int) -> int:
754 """Get the scan pattern for a scan.
756 Returns an integer: 0 = raster (uniform angular grid), 1 = spinning multibeam
757 (rotating multi-channel sensor), 2 = Risley-prism (Livox-style rosette). Compare against
758 ``ScanPattern.RASTER`` / ``ScanPattern.SPINNING_MULTIBEAM`` / ``ScanPattern.RISLEY_PRISM``.
761 raise ValueError("Scan ID must be non-negative")
762 return lidar_wrapper.getLiDARScanPattern(self._cloud_ptr, scanID)
764 def getScanBeamZenithAngles(self, scanID: int) -> List[float]:
765 """Get the per-channel beam zenith angles (radians) for a multibeam scan.
767 Returns an empty list for a raster scan.
770 raise ValueError("Scan ID must be non-negative")
771 return lidar_wrapper.getLiDARScanBeamZenithAngles(self._cloud_ptr, scanID)
773 def getScanMode(self, scanID: int) -> ScanMode:
774 """Get the high-level acquisition mode of a scan as a :class:`ScanMode`.
776 STATIC_RASTER (fixed-origin grid), MOVING_RASTER (fan swept along a trajectory),
777 SPINNING (continuously-rotating multi-channel sensor), or RISLEY_PRISM (Livox-style rosette).
780 raise ValueError("Scan ID must be non-negative")
781 return ScanMode(lidar_wrapper.getLiDARScanMode(self._cloud_ptr, scanID))
783 def getScanStepsPerRev(self, scanID: int) -> int:
784 """Get the number of azimuth firing steps per revolution (spinning scans; 0 otherwise)."""
786 raise ValueError("Scan ID must be non-negative")
787 return lidar_wrapper.getLiDARScanStepsPerRev(self._cloud_ptr, scanID)
789 def getScanRotationRate(self, scanID: int) -> float:
790 """Get the sensor-head rotation rate in revolutions/second (spinning scans; 0 otherwise)."""
792 raise ValueError("Scan ID must be non-negative")
793 return lidar_wrapper.getLiDARScanRotationRate(self._cloud_ptr, scanID)
795 def getScanRevolutions(self, scanID: int) -> float:
796 """Get the number of revolutions the sensor head made (spinning scans; 0 otherwise)."""
798 raise ValueError("Scan ID must be non-negative")
799 return lidar_wrapper.getLiDARScanRevolutions(self._cloud_ptr, scanID)
801 def getScanRisleyPrisms(self, scanID: int) -> List[RisleyPrism]:
802 """Get the rotating wedge prisms of a Risley-prism scan as a list of :class:`RisleyPrism`.
804 Returns the prism stack in beam-traversal order (empty for non-Risley scans).
807 raise ValueError("Scan ID must be non-negative")
808 raw = lidar_wrapper.getLiDARScanRisleyPrisms(self._cloud_ptr, scanID)
809 return [RisleyPrism(p[0], p[1], p[2], p[3]) for p in raw]
811 def getScanRisleyRefractiveIndexAir(self, scanID: int) -> float:
812 """Get the refractive index of the medium surrounding a Risley scan's prisms (1.0 for non-Risley)."""
814 raise ValueError("Scan ID must be non-negative")
815 return lidar_wrapper.getLiDARScanRisleyRefractiveIndexAir(self._cloud_ptr, scanID)
817 def getScanReturnMode(self, scanID: int) -> ReturnMode:
818 """Get the return-reporting mode of a scan as a :class:`ReturnMode` (MULTI or SINGLE)."""
820 raise ValueError("Scan ID must be non-negative")
821 return ReturnMode(lidar_wrapper.getLiDARScanReturnMode(self._cloud_ptr, scanID))
823 def setScanReturnMode(self, scanID: int, return_mode: Union[ReturnMode, int]):
824 """Set the return-reporting mode of a scan (ReturnMode.MULTI or ReturnMode.SINGLE).
826 Only affects analytic-waveform synthetic scans (more than one ray per pulse).
829 raise ValueError("Scan ID must be non-negative")
830 lidar_wrapper.setLiDARScanReturnMode(self._cloud_ptr, scanID, int(return_mode))
832 def getScanSingleReturnSelection(self, scanID: int) -> SingleReturnSelection:
833 """Get the single/limited-return selection policy as a :class:`SingleReturnSelection`."""
835 raise ValueError("Scan ID must be non-negative")
836 return SingleReturnSelection(
837 lidar_wrapper.getLiDARScanSingleReturnSelection(self._cloud_ptr, scanID))
839 def setScanSingleReturnSelection(self, scanID: int, selection: Union[SingleReturnSelection, int]):
840 """Set the single/limited-return selection policy (STRONGEST, FIRST, LAST, or STRONGEST_PLUS_LAST).
842 Used when the scan's return mode is SINGLE and a pulse resolves more returns than maxReturns.
843 STRONGEST_PLUS_LAST is a dual-return mode that intrinsically yields 1 or 2 returns and
847 raise ValueError("Scan ID must be non-negative")
848 lidar_wrapper.setLiDARScanSingleReturnSelection(self._cloud_ptr, scanID, int(selection))
850 def getScanMaxReturns(self, scanID: int) -> int:
851 """Get the maximum returns per pulse used in single/limited-return mode (1 = single, N = N-return)."""
853 raise ValueError("Scan ID must be non-negative")
854 return lidar_wrapper.getLiDARScanMaxReturns(self._cloud_ptr, scanID)
856 def setScanMaxReturns(self, scanID: int, max_returns: int):
857 """Set the maximum returns per pulse used in single/limited-return mode (must be >= 1)."""
859 raise ValueError("Scan ID must be non-negative")
861 raise ValueError("max_returns must be >= 1")
862 lidar_wrapper.setLiDARScanMaxReturns(self._cloud_ptr, scanID, int(max_returns))
864 def setSyntheticScanMemoryBudget(self, bytes: int):
865 """Set the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
867 :meth:`syntheticScan` fans each pulse into ``rays_per_pulse`` sub-rays; for a
868 large scan the simultaneously-traced sub-rays can demand many gigabytes if
869 traced in one batch. This caps the live trace scratch buffers, so the per-scan
870 beam fan-out is processed in chunks sized to stay near this budget regardless of
871 scan resolution. It bounds only the transient buffers, not the output cloud.
873 If never called, the budget is automatic and path-dependent (8 GiB on a GPU
874 build, 4 GiB otherwise). Call this to override that with a fixed cap, typically
875 to lower peak memory on a constrained host.
878 bytes: Soft cap in bytes on the live ray-tracing scratch buffers. Must be > 0.
881 raise ValueError("memory budget must be greater than zero")
882 lidar_wrapper.setLiDARSyntheticScanMemoryBudget(self._cloud_ptr, int(bytes))
884 def getSyntheticScanMemoryBudget(self) -> int:
885 """Get the soft memory budget (bytes) for :meth:`syntheticScan`'s transient buffers.
887 Returns the explicitly configured budget set via :meth:`setSyntheticScanMemoryBudget`, or
888 0 if using the automatic path-dependent default (8 GiB on a GPU build, 4 GiB otherwise).
890 return lidar_wrapper.getLiDARSyntheticScanMemoryBudget(self._cloud_ptr)
892 def getScanPulseWidth(self, scanID: int) -> float:
893 """Get the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
895 raise ValueError("Scan ID must be non-negative")
896 return lidar_wrapper.getLiDARScanPulseWidth(self._cloud_ptr, scanID)
898 def setScanPulseWidth(self, scanID: int, pulse_width: float):
899 """Set the pulse width / range resolution (meters) of a scan (0 = use syntheticScan argument)."""
901 raise ValueError("Scan ID must be non-negative")
903 raise ValueError("pulse_width must be non-negative")
904 lidar_wrapper.setLiDARScanPulseWidth(self._cloud_ptr, scanID, float(pulse_width))
906 def getScanDetectionThreshold(self, scanID: int) -> float:
907 """Get the detection threshold (energy fraction, noise floor) of a scan.
909 The default is 0.001 (-30 dB) as of helios-core 1.3.92 (it was 0.05), matching the noise
910 floor of terrestrial RIEGL scans. A threshold under 1/N has no effect with N sub-rays per
911 pulse, so at ~40 rays/pulse every resolved return is reported. Only used for synthetic
915 raise ValueError("Scan ID must be non-negative")
916 return lidar_wrapper.getLiDARScanDetectionThreshold(self._cloud_ptr, scanID)
918 def setScanDetectionThreshold(self, scanID: int, detection_threshold: float):
919 """Set the detection threshold (energy fraction, noise floor) of a scan."""
921 raise ValueError("Scan ID must be non-negative")
922 if detection_threshold < 0:
923 raise ValueError("detection_threshold must be non-negative")
924 lidar_wrapper.setLiDARScanDetectionThreshold(self._cloud_ptr, scanID, float(detection_threshold))
926 def getScanRecordPartialMisses(self, scanID: int) -> bool:
927 """Whether a scan's partly intercepted multi-return pulses also record a partial-miss point.
929 See :meth:`setScanRecordPartialMisses`. The default is True.
932 ValueError: If ``scanID`` is negative
933 RuntimeError: If the native library predates helios-core v1.3.92
936 raise ValueError("Scan ID must be non-negative")
937 return lidar_wrapper.getLiDARScanRecordPartialMisses(self._cloud_ptr, scanID)
939 def setScanRecordPartialMisses(self, scanID: int, record_partial_misses: bool):
940 """Set whether a scan's partly intercepted multi-return pulses record the escaped energy as a miss.
942 With miss recording on, a multi-return pulse that is only partly intercepted also writes
943 a miss point (intensity 0) for the part of the beam that left the scene. A real
944 instrument never records that point: it reports the returns it detected and nothing for
945 the escaped energy. Set this to False to produce the record a real instrument provides
946 (plus a gap-filled miss for each fully transmitted pulse, which is always recorded when
947 miss recording is on). Single-return mode never records a partial miss.
949 Leaf-area inversion that weights a pulse's returns equally reads close to unbiased with
950 these points and high without them, so validating an inversion only on default
951 synthetic scans understates its bias on real data. Only used for synthetic scan
952 generation; the XML equivalent is ``<recordPartialMisses>``.
955 scanID: ID of the scan
956 record_partial_misses: True (default) to record partial-miss points
959 ValueError: If ``scanID`` is negative or ``record_partial_misses`` is not a bool
960 RuntimeError: If the native library predates helios-core v1.3.92
963 raise ValueError("Scan ID must be non-negative")
964 if not isinstance(record_partial_misses, bool):
966 f"record_partial_misses must be a bool, got {type(record_partial_misses).__name__}")
967 lidar_wrapper.setLiDARScanRecordPartialMisses(self._cloud_ptr, scanID, record_partial_misses)
969 def addHitPoint(self, scanID: int,
970 xyz: Union[vec3, List[float], Tuple[float, float, float]],
971 direction: Union[vec3, SphericalCoord, List[float], Tuple[float, float]],
972 color: Optional[Union[RGBcolor, List[float], Tuple[float, float, float]]] = None):
974 Add a hit point to the point cloud.
977 scanID: Scan ID this hit belongs to
978 xyz: Hit point coordinates (vec3 or 3-element list)
979 direction: Ray direction (vec3/SphericalCoord or 2-3 element list)
980 color: Optional RGB color (RGBcolor or 3-element list)
982 # Convert xyz to list
983 if isinstance(xyz, (list, tuple)):
985 raise ValueError("XYZ must have 3 elements")
987 elif hasattr(xyz, 'x'):
988 xyz_list = [xyz.x, xyz.y, xyz.z]
990 raise ValueError("XYZ must be vec3 or 3-element list/tuple")
992 # Convert direction to list
993 if isinstance(direction, (list, tuple)):
994 if len(direction) < 2:
995 raise ValueError("Direction must have at least 2 elements [radius, elevation]")
996 direction_list = list(direction)
997 elif hasattr(direction, 'radius'): # SphericalCoord
998 direction_list = [direction.radius, direction.elevation, direction.azimuth]
999 elif hasattr(direction, 'x'): # vec3
1000 direction_list = [direction.x, direction.y, direction.z]
1002 raise ValueError("Direction must be vec3/SphericalCoord or 2-3 element list")
1004 # Add with or without color
1005 if color is not None:
1006 if isinstance(color, (list, tuple)):
1008 raise ValueError("Color must have 3 elements [r, g, b]")
1009 color_list = list(color)
1010 elif hasattr(color, 'r'):
1011 color_list = [color.r, color.g, color.b]
1013 raise ValueError("Color must be RGBcolor or 3-element list")
1015 lidar_wrapper.addLiDARHitPointRGB(self._cloud_ptr, scanID, xyz_list, direction_list, color_list)
1017 lidar_wrapper.addLiDARHitPoint(self._cloud_ptr, scanID, xyz_list, direction_list)
1019 def addHitPoints(self, scanID: int, xyz_array, direction_array, color_array=None):
1021 Add many hit points to the point cloud in a single bulk call.
1023 This skips the per-point Python loop by passing contiguous buffers
1024 straight to the native library in one FFI call.
1027 scanID: Scan ID these hits belong to
1028 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
1029 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth]
1030 (azimuth is currently ignored, matching addHitPoint)
1031 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
1035 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
1036 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
1038 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
1039 raise ValueError("xyz_array must have shape (N, 3)")
1040 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
1041 raise ValueError("direction_array must have shape (N, 3)")
1043 count = xyz_array.shape[0]
1044 if direction_array.shape[0] != count:
1045 raise ValueError("xyz_array and direction_array must have the same number of rows")
1047 if color_array is not None:
1048 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
1049 if color_array.ndim != 2 or color_array.shape[1] != 3:
1050 raise ValueError("color_array must have shape (N, 3)")
1051 if color_array.shape[0] != count:
1052 raise ValueError("color_array must have the same number of rows as xyz_array")
1054 lidar_wrapper.addLiDARHitPoints(self._cloud_ptr, scanID,
1055 xyz_array, direction_array, count, color_array)
1057 def addHitPointsWithData(self, scanID: int, xyz_array, direction_array,
1058 data_labels=None, data_values=None, color_array=None):
1060 Add many hit points carrying a per-hit data map in a single bulk call.
1062 Like addHitPoints, but also populates each hit's named-scalar data map —
1063 the in-memory equivalent of what the ASCII loader does for non-standard
1064 columns. This is the path multi-return LAD needs (timestamp/target_index/
1065 target_count land in the map so gapfillMisses() can group beams by pulse).
1068 scanID: Scan ID these hits belong to (the scan must already exist)
1069 xyz_array: Hit point coordinates, shape (N, 3) [x, y, z]
1070 direction_array: Ray directions, shape (N, 3) [radius, elevation, azimuth]. Pass cart2sphere(xyz - origin) to match loadASCIIFile; the full SphericalCoord (incl. radius) is used.
1071 data_labels: Optional list of data-map key names (length k)
1072 data_values: Optional (N, k) values for those keys (float64)
1073 color_array: Optional RGB colors, shape (N, 3) [r, g, b]
1077 xyz_array = np.ascontiguousarray(xyz_array, dtype=np.float32)
1078 direction_array = np.ascontiguousarray(direction_array, dtype=np.float32)
1080 if xyz_array.ndim != 2 or xyz_array.shape[1] != 3:
1081 raise ValueError("xyz_array must have shape (N, 3)")
1082 if direction_array.ndim != 2 or direction_array.shape[1] != 3:
1083 raise ValueError("direction_array must have shape (N, 3)")
1085 count = xyz_array.shape[0]
1086 if direction_array.shape[0] != count:
1087 raise ValueError("xyz_array and direction_array must have the same number of rows")
1089 labels = list(data_labels or [])
1091 data_values = np.ascontiguousarray(data_values, dtype=np.float64)
1092 if data_values.ndim != 2 or data_values.shape != (count, len(labels)):
1093 raise ValueError("data_values must have shape (N, len(data_labels))")
1097 if color_array is not None:
1098 color_array = np.ascontiguousarray(color_array, dtype=np.float32)
1099 if color_array.ndim != 2 or color_array.shape[1] != 3:
1100 raise ValueError("color_array must have shape (N, 3)")
1101 if color_array.shape[0] != count:
1102 raise ValueError("color_array must have the same number of rows as xyz_array")
1104 lidar_wrapper.addLiDARHitPointsWithData(
1105 self._cloud_ptr, scanID, xyz_array, direction_array, count,
1106 color_array, labels, data_values)
1108 def getHitCount(self) -> int:
1109 """Get total number of hit points in cloud"""
1110 return lidar_wrapper.getLiDARHitCount(self._cloud_ptr)
1112 def getHitXYZ(self, index: int) -> vec3:
1113 """Get coordinates of a hit point"""
1115 raise ValueError("Index must be non-negative")
1116 xyz_list = lidar_wrapper.getLiDARHitXYZ(self._cloud_ptr, index)
1117 return vec3(*xyz_list)
1119 def getHitOrigin(self, index: int) -> vec3:
1120 """Get the (x,y,z) beam-emission origin of a hit point.
1122 For moving-platform scans (see :meth:`addScanMoving`) this is the per-pulse emission origin
1123 recorded on the hit; for static scans it falls back to the single scan origin of the hit's scan.
1126 raise ValueError("Index must be non-negative")
1127 xyz_list = lidar_wrapper.getLiDARHitOrigin(self._cloud_ptr, index)
1128 return vec3(*xyz_list)
1130 def getHitRaydir(self, index: int) -> SphericalCoord:
1131 """Get ray direction of a hit point"""
1133 raise ValueError("Index must be non-negative")
1134 direction_list = lidar_wrapper.getLiDARHitRaydir(self._cloud_ptr, index)
1135 # direction_list is [radius, elevation, azimuth]; preserve azimuth (was previously dropped).
1136 return SphericalCoord(direction_list[0], direction_list[1], direction_list[2])
1138 def getHitColor(self, index: int) -> RGBcolor:
1139 """Get color of a hit point"""
1141 raise ValueError("Index must be non-negative")
1142 color_list = lidar_wrapper.getLiDARHitColor(self._cloud_ptr, index)
1143 return RGBcolor(*color_list)
1145 def getHitScanID(self, index: int) -> int:
1146 """Get the scan ID a hit point belongs to"""
1148 raise ValueError("Index must be non-negative")
1149 return lidar_wrapper.getLiDARHitScanID(self._cloud_ptr, index)
1151 def doesHitDataExist(self, index: int, label: str) -> bool:
1152 """Check whether a named scalar data value exists for a hit point.
1154 Per-hit data computed by syntheticScan includes 'intensity', 'distance',
1155 'timestamp', 'target_index', 'target_count', 'deviation', 'nRaysHit', plus any
1156 primitive-data labels listed in the scan's column_format.
1159 raise ValueError("Index must be non-negative")
1160 return lidar_wrapper.doesLiDARHitDataExist(self._cloud_ptr, index, label)
1162 def getHitData(self, index: int, label: str) -> float:
1163 """Get a named scalar data value for a hit point.
1165 Raises HeliosError if the label does not exist for this hit; guard with
1166 doesHitDataExist() when unsure.
1169 raise ValueError("Index must be non-negative")
1170 return lidar_wrapper.getLiDARHitData(self._cloud_ptr, index, label)
1172 def getHitDataAll(self, label: str) -> List[float]:
1173 """Bulk-export a named scalar data value for all hits in a single FFI call.
1175 Returns a list of length getHitCount(); entries are NaN where the label is
1176 absent for that hit. Much faster than looping getHitData() for large clouds.
1178 Note: values are returned at float32 precision (vs. getHitData(), which returns
1179 full float64). Use getHitData() per-hit if full precision is required.
1181 n = self.getHitCount()
1184 return lidar_wrapper.getLiDARHitData_all(self._cloud_ptr, label, n)
1186 def getHitsXYZRGB(self) -> Tuple[List[vec3], List[RGBcolor]]:
1187 """Bulk-export coordinates and colors for all hits in a single FFI call.
1189 Returns (positions, colors) where positions is a list of vec3 and colors a list
1190 of RGBcolor, each of length getHitCount(). Much faster than looping
1191 getHitXYZ()/getHitColor() for large clouds.
1193 n = self.getHitCount()
1196 xyz_flat, rgb_flat = lidar_wrapper.getLiDARHitsXYZRGB_all(self._cloud_ptr, n)
1197 positions = [vec3(xyz_flat[3 * i], xyz_flat[3 * i + 1], xyz_flat[3 * i + 2]) for i in range(n)]
1198 colors = [RGBcolor(rgb_flat[3 * i], rgb_flat[3 * i + 1], rgb_flat[3 * i + 2]) for i in range(n)]
1199 return positions, colors
1201 # ---- Bulk numpy exports (single FFI call each; no per-hit Python loop) ----
1202 # These power the synthetic-scan fast path: extracting a million-hit cloud via
1203 # the per-index getters (getHitXYZ/getHitColor/getHitScanID/...) costs tens of
1204 # millions of FFI crossings, which dominated scan time. The *Array methods pull
1205 # each quantity in one contiguous copy.
1207 def getHitsXYZRGBArrays(self):
1208 """Bulk-export hit coordinates + colors as numpy arrays.
1210 Returns (xyz, rgb), each (getHitCount(), 3) float32. Empty (0,3) arrays
1211 when there are no hits.
1214 n = self.getHitCount()
1216 return np.empty((0, 3), np.float32), np.empty((0, 3), np.float32)
1217 return lidar_wrapper.getLiDARHitsXYZRGB_all_np(self._cloud_ptr, n)
1219 def getHitDataArray(self, label: str):
1220 """Bulk-export a named scalar field as an (getHitCount(),) float32 array,
1221 NaN where the label is absent for a hit."""
1223 n = self.getHitCount()
1225 return np.empty((0,), np.float32)
1226 return lidar_wrapper.getLiDARHitData_all_np(self._cloud_ptr, label, n)
1228 def getHitDataColumn(self, label: str, absent_value: float = -9999.0) -> List[float]:
1229 """Bulk-export a named scalar column.
1231 Faster than :meth:`getHitDataAll` for whole-field reads, and returns full float64
1232 precision. Entries are ``absent_value`` where the label is absent for a hit. Returns a list
1233 of length getHitCount().
1235 n = self.getHitCount()
1238 return lidar_wrapper.getLiDARHitDataColumn(self._cloud_ptr, label, n, absent_value)
1240 def getHitDataColumnIndex(self, label: str) -> int:
1241 """Get the internal column slot index for a hit-data label.
1243 Per-hit scalar data is stored column-wise; this resolves a label to its column slot for
1244 repeated bulk access without re-resolving the label by string. Returns -1 if the label has
1245 never been set on any hit.
1247 if not isinstance(label, str):
1248 raise TypeError(f"label must be a str, got {type(label).__name__}")
1249 return lidar_wrapper.getLiDARHitDataColumnIndex(self._cloud_ptr, label)
1251 def getHitDataColumnArray(self, label: str, absent_value: float = -9999.0):
1252 """Bulk-export a named scalar column as an (getHitCount(),) float64 numpy array
1253 via the columnar path (``absent_value`` where the label is absent for a hit)."""
1255 n = self.getHitCount()
1257 return np.empty((0,), np.float64)
1258 return lidar_wrapper.getLiDARHitDataColumn_np(self._cloud_ptr, label, n, absent_value)
1260 def getHitScanIDArray(self):
1261 """Bulk-export the scan ID of every hit as an (getHitCount(),) int32 array."""
1263 n = self.getHitCount()
1265 return np.empty((0,), np.int32)
1266 return lidar_wrapper.getLiDARHitScanID_all(self._cloud_ptr, n)
1268 def getHitMissArray(self):
1269 """Bulk-export the miss flag of every hit as an (getHitCount(),) int32
1270 array (1 == sky/miss, 0 == real surface return)."""
1272 n = self.getHitCount()
1274 return np.empty((0,), np.int32)
1275 return lidar_wrapper.isLiDARHitMiss_all(self._cloud_ptr, n)
1277 def deleteHitPoint(self, index: int):
1278 """Delete a hit point from the cloud"""
1280 raise ValueError("Index must be non-negative")
1281 lidar_wrapper.deleteLiDARHitPoint(self._cloud_ptr, index)
1283 def isHitMiss(self, index: int) -> bool:
1284 """Return True if a hit is a "miss" (a fired pulse that returned nothing).
1286 Misses are the transmitted beams that form the denominator of the per-voxel
1287 transmission probability used by :meth:`calculateLeafArea`. They are produced by
1288 ``syntheticScan(..., record_misses=True)`` and by :meth:`gapfillMisses`.
1291 raise ValueError("Index must be non-negative")
1292 return lidar_wrapper.isLiDARHitMiss(self._cloud_ptr, index)
1294 def hasMisses(self) -> bool:
1295 """Return True if the cloud contains at least one miss.
1297 :meth:`calculateLeafArea` requires misses and fails fast without them.
1299 return lidar_wrapper.lidarHasMisses(self._cloud_ptr)
1301 def isMultiReturnData(self) -> bool:
1302 """Return True if the cloud contains multi-return data.
1304 Multi-return data is data in which a single laser pulse produced more than one
1305 recorded return (any hit with ``target_count`` greater than 1). This is a behavioral
1306 switch, not just a descriptive property: :meth:`triangulateHitPoints` branches on
1307 it, triangulating first returns only (with an adaptive separation filter) for
1308 multi-return data and treating every return as an independent single return
1309 otherwise. The two branches can differ substantially in reconstructed surface
1310 area, so a cloud assembled by hand (for example through :meth:`addHitPoints` or
1311 the native ASCII loader) can use this to confirm which one will run.
1313 Multi-return data must also carry the ``timestamp`` and ``target_index`` hit-data
1314 fields, which triangulation needs to group returns into beams and select first
1315 returns. If ``target_count > 1`` is found but either field is absent, this raises
1316 rather than reporting an answer the rest of the pipeline cannot act on.
1318 Requires helios-core v1.3.85 or newer.
1321 HeliosError: If multi-return data is present but ``timestamp`` or
1322 ``target_index`` is missing
1323 RuntimeError: If the native library predates helios-core v1.3.85
1325 return lidar_wrapper.lidarIsMultiReturnData(self._cloud_ptr)
1328 def getMissDistance() -> float:
1329 """Return the LIDAR_MISS_DISTANCE constant (meters): the distance at which a
1330 miss point is placed along its beam."""
1331 return lidar_wrapper.getLiDARMissDistance()
1333 def coordinateShift(self, shift: Union[vec3, List[float], Tuple[float, float, float]]):
1335 Translate all hit points by a shift vector.
1338 shift: Translation vector (vec3 or 3-element list)
1340 if isinstance(shift, (list, tuple)):
1342 raise ValueError("Shift must have 3 elements [x, y, z]")
1343 shift_list = list(shift)
1344 elif hasattr(shift, 'x'):
1345 shift_list = [shift.x, shift.y, shift.z]
1347 raise ValueError("Shift must be vec3 or 3-element list/tuple")
1349 lidar_wrapper.lidarCoordinateShift(self._cloud_ptr, shift_list)
1351 def coordinateRotation(self, rotation: Union[SphericalCoord, List[float], Tuple[float, float]]):
1353 Rotate all hit points by spherical rotation angles.
1356 rotation: Rotation angles (SphericalCoord or 2-3 element list)
1358 if isinstance(rotation, (list, tuple)):
1359 if len(rotation) < 2:
1360 raise ValueError("Rotation must have at least 2 elements [radius, elevation]")
1361 rotation_list = list(rotation)
1362 elif hasattr(rotation, 'radius'):
1363 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1365 raise ValueError("Rotation must be SphericalCoord or 2-3 element list")
1367 lidar_wrapper.lidarCoordinateRotation(self._cloud_ptr, rotation_list)
1369 def triangulateHitPoints(self, Lmax: float, max_aspect_ratio: float = 4.0):
1371 Generate triangle mesh from hit points using Delaunay triangulation.
1373 For multi-return data only first returns are triangulated (the lowest
1374 ``target_index``, whether the scan counts returns from 0 or from 1).
1377 Lmax: Maximum triangle edge length
1378 max_aspect_ratio: Maximum triangle aspect ratio (default 4.0)
1380 validate_positive_value(Lmax, 'Lmax', 'triangulateHitPoints')
1381 validate_positive_value(max_aspect_ratio, 'max_aspect_ratio', 'triangulateHitPoints')
1382 lidar_wrapper.lidarTriangulateHitPoints(self._cloud_ptr, Lmax, max_aspect_ratio)
1383 # A triangulation sink runs during the call above. An exception raised inside it was
1384 # swallowed by ctypes and stashed; surface it now rather than reporting success.
1385 self._raise_pending_callback_error()
1387 def getTriangleCount(self) -> int:
1388 """Get number of triangles in the mesh
1391 Reports zero once a run's triangles have been streamed to a sink registered with
1392 :meth:`setTriangulationSink` -- the mesh is released rather than stored. Clear the
1393 sink before triangulating if you need the stored mesh.
1395 return lidar_wrapper.getLiDARTriangleCount(self._cloud_ptr)
1397 def getTriangulationStats(self) -> dict:
1398 """Filter diagnostics from the most recent triangulateHitPoints() call.
1402 {"candidates", "dropped_lmax", "dropped_aspect", "dropped_degenerate"}
1404 Each dropped triangle is attributed to one primary reason (Lmax, then
1405 aspect, then degenerate), so ``candidates == getTriangleCount() +
1406 dropped_lmax + dropped_aspect + dropped_degenerate``. All zero if
1407 triangulation has not been run. Use this to tell whether an empty or
1408 sparse mesh is data-limited (few candidates) or filter-limited (many
1409 candidates dropped by Lmax/aspect).
1411 return lidar_wrapper.getLiDARTriangulationStats(self._cloud_ptr)
1413 def getTriangleVerticesAll(self):
1414 """Bulk-export every triangle's vertices and source scan in one call.
1416 Returns (xyz_flat, scan_ids): xyz_flat is a (T*9,) float32 array laid out
1417 [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per triangle, scan_ids is a (T,)
1418 int32 array. Avoids the Context round-trip and the per-triangle
1419 getPrimitiveVertices loop.
1422 Raises once a run's triangles have been streamed to a sink registered with
1423 :meth:`setTriangulationSink` -- the mesh is released rather than stored, and the
1424 native consumers refuse to operate on an empty mesh. Clear the sink before
1425 triangulating if you need the stored mesh.
1427 return lidar_wrapper.getLiDARTriangleVertices_all(
1428 self._cloud_ptr, self.getTriangleCount())
1430 def setExternalTriangulation(self, vertices, scan_ids):
1431 """Replace the internal triangulation with an externally-supplied mesh.
1433 Bypasses the internal Delaunay triangulation so a mesh produced elsewhere
1434 (a re-used Helios triangulation, or a per-scan open3d Ball-Pivot mesh) can
1435 drive leaf-area inversion without a recompute. After this call,
1436 ``calculateLeafArea()`` runs unchanged.
1439 vertices: Triangle vertices in world coordinates, accepted as a
1440 (T, 9) array laid out [v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z] per
1441 triangle, a (T, 3, 3) array, or a flat (T*9,) array -- the same
1442 layout :meth:`getTriangleVerticesAll` exports, so a Helios mesh
1443 round-trips directly.
1444 scan_ids: Source scan index for each triangle, shape (T,). Required;
1445 every entry must be a valid scan index (see :meth:`addScan`),
1446 since the leaf-angle G(theta) term needs each triangle's ray
1447 direction. A merged mesh with no scan association is not valid.
1449 A grid must already be defined (see :meth:`addGrid`).
1452 verts = np.ascontiguousarray(vertices, dtype=np.float32).reshape(-1)
1453 if verts.size % 9 != 0:
1455 f"vertices has {verts.size} floats, must be a multiple of 9 (9 per triangle)")
1456 tri_count = verts.size // 9
1458 scans = np.ascontiguousarray(scan_ids, dtype=np.int32).reshape(-1)
1459 if scans.size != tri_count:
1461 f"scan_ids has {scans.size} entries, expected {tri_count} (one per triangle)")
1463 lidar_wrapper.lidarSetExternalTriangulation(
1464 self._cloud_ptr, verts, scans, tri_count)
1466 def distanceFilter(self, maxdistance: float):
1467 """Filter hit points by maximum distance from scanne
r"""
1468 validate_positive_value(maxdistance, 'maxdistance', 'distanceFilter')
1469 lidar_wrapper.lidarDistanceFilter(self._cloud_ptr, maxdistance)
1471 def reflectanceFilter(self, minreflectance: float):
1472 """Filter hit points by minimum reflectance value"""
1473 lidar_wrapper.lidarReflectanceFilter(self._cloud_ptr, minreflectance)
1475 def firstHitFilter(self):
1476 """Keep only first return hit points"""
1477 lidar_wrapper.lidarFirstHitFilter(self._cloud_ptr)
1479 def lastHitFilter(self):
1480 """Keep only last return hit points"""
1481 lidar_wrapper.lidarLastHitFilter(self._cloud_ptr)
1483 def exportPointCloud(self, filename: str, write_header: bool = True):
1484 """Export point cloud to ASCII file.
1487 filename: Output file path.
1488 write_header: If True (default), prepend a ``#``-prefixed comment line listing the
1489 column field names (CloudCompare convention). The loader skips ``#``-prefixed
1490 lines, so headered files round-trip through ``loadXML()``. Set False for a
1494 raise ValueError("Filename cannot be empty")
1495 lidar_wrapper.exportLiDARPointCloud(self._cloud_ptr, filename, write_header)
1497 def exportLeafAreaUncertainty(self, filename: str):
1498 """Export per-voxel leaf-area sampling uncertainty to a self-describing ASCII file.
1500 The file has a ``#``-prefixed header and one row per grid cell:
1501 ``cell_index leaf_area beam_count I_rdi LAD_std_error ci_valid``. Requires that
1502 :meth:`calculateLeafArea` has been run with an ``element_width`` (the uncertainty
1506 raise ValueError("Filename cannot be empty")
1507 lidar_wrapper.exportLiDARLeafAreaUncertainty(self._cloud_ptr, filename)
1509 def exportScans(self, filename: str):
1510 """Export all scans to an XML metadata file plus one ASCII data file per scan.
1513 filename: Path of the XML metadata file to write (e.g. "output/scans.xml").
1514 One ASCII data file is auto-generated per scan, named by stripping the XML
1515 extension and appending "_<scanID>.xyz" (e.g. "output/scans_0.xyz"). The
1516 resulting XML can be re-loaded with loadXML() from the same working directory.
1519 raise ValueError("Filename cannot be empty")
1520 lidar_wrapper.exportLiDARScans(self._cloud_ptr, filename)
1522 def loadXML(self, filename: str):
1523 """Load scan metadata from XML file"""
1525 raise ValueError("Filename cannot be empty")
1526 lidar_wrapper.loadLiDARXML(self._cloud_ptr, filename)
1528 def disableMessages(self):
1529 """Disable console output messages"""
1530 lidar_wrapper.lidarDisableMessages(self._cloud_ptr)
1532 def enableMessages(self):
1533 """Enable console output messages"""
1534 lidar_wrapper.lidarEnableMessages(self._cloud_ptr)
1536 def addGrid(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1537 size: Union[vec3, List[float], Tuple[float, float, float]],
1538 ndiv: Union[List[int], Tuple[int, int, int]],
1539 rotation: float = 0.0,
1540 column_z_offsets: Optional[Union[List[float], Tuple[float, ...]]] = None):
1542 Add a rectangular grid of voxel cells.
1545 center: Grid center position (vec3 or 3-element list)
1546 size: Grid dimensions [x, y, z] (vec3 or 3-element list)
1547 ndiv: Number of divisions [nx, ny, nz] (3-element list)
1548 rotation: Azimuthal rotation angle (degrees, default 0.0)
1549 column_z_offsets: Optional per-(x,y)-column vertical offset for terrain
1550 following, row-major as ``[j*ndiv[0] + i]`` with length
1551 ``ndiv[0]*ndiv[1]``. Each vertical column of voxels is shifted in z by
1552 its column's offset so the grid can track an external terrain surface
1553 (e.g. a DEM). ``None`` (the default) builds an axis-regular grid.
1556 ``rotation`` is in **degrees** here; :meth:`addGridCell` takes its rotation
1558 :meth:`getCellRotation` reports degrees.
1562 ... center=vec3(0, 0, 0.5),
1563 ... size=vec3(10, 10, 1),
1564 ... ndiv=[10, 10, 5],
1568 Terrain-following grid over a 2x2 column layout:
1571 ... center=vec3(0, 0, 0.5),
1572 ... size=vec3(10, 10, 1),
1574 ... column_z_offsets=[0.0, 0.1, 0.2, 0.3]
1577 # Convert center to list
1578 if isinstance(center, (list, tuple)):
1579 if len(center) != 3:
1580 raise ValueError("Center must have 3 elements [x, y, z]")
1581 center_list = list(center)
1582 elif hasattr(center, 'x'):
1583 center_list = [center.x, center.y, center.z]
1585 raise ValueError("Center must be vec3 or 3-element list/tuple")
1587 # Convert size to list
1588 if isinstance(size, (list, tuple)):
1590 raise ValueError("Size must have 3 elements [x, y, z]")
1591 size_list = list(size)
1592 elif hasattr(size, 'x'):
1593 size_list = [size.x, size.y, size.z]
1595 raise ValueError("Size must be vec3 or 3-element list/tuple")
1598 if not isinstance(ndiv, (list, tuple)) or len(ndiv) != 3:
1599 raise ValueError("Ndiv must be a 3-element list [nx, ny, nz]")
1601 if column_z_offsets is None:
1602 lidar_wrapper.addLiDARGrid(self._cloud_ptr, center_list, size_list, list(ndiv), rotation)
1605 if not isinstance(column_z_offsets, (list, tuple)):
1607 "column_z_offsets must be a list or tuple of floats, got "
1608 f"{type(column_z_offsets).__name__}"
1611 expected = ndiv[0] * ndiv[1]
1612 if len(column_z_offsets) != expected:
1614 f"column_z_offsets must have length ndiv[0]*ndiv[1] = {expected} "
1615 f"(one value per grid column), got {len(column_z_offsets)}"
1618 lidar_wrapper.addLiDARGridTerrainFollowing(
1619 self._cloud_ptr, center_list, size_list, list(ndiv), rotation,
1620 [float(z) for z in column_z_offsets]
1623 def addGridCell(self, center: Union[vec3, List[float], Tuple[float, float, float]],
1624 size: Union[vec3, List[float], Tuple[float, float, float]],
1625 rotation: float = 0.0):
1627 Add a single grid cell.
1630 center: Cell center position (vec3 or 3-element list)
1631 size: Cell dimensions [x, y, z] (vec3 or 3-element list)
1632 rotation: Azimuthal rotation angle (radians, default 0.0)
1635 ``rotation`` is in **radians** here, whereas :meth:`addGrid` takes
1636 **degrees**. :meth:`getCellRotation` reports degrees.
1638 # Convert center to list
1639 if isinstance(center, (list, tuple)):
1640 if len(center) != 3:
1641 raise ValueError("Center must have 3 elements [x, y, z]")
1642 center_list = list(center)
1643 elif hasattr(center, 'x'):
1644 center_list = [center.x, center.y, center.z]
1646 raise ValueError("Center must be vec3 or 3-element list/tuple")
1648 # Convert size to list
1649 if isinstance(size, (list, tuple)):
1651 raise ValueError("Size must have 3 elements [x, y, z]")
1652 size_list = list(size)
1653 elif hasattr(size, 'x'):
1654 size_list = [size.x, size.y, size.z]
1656 raise ValueError("Size must be vec3 or 3-element list/tuple")
1658 lidar_wrapper.addLiDARGridCell(self._cloud_ptr, center_list, size_list, rotation)
1660 def getGridCellCount(self) -> int:
1661 """Get total number of grid cells"""
1662 return lidar_wrapper.getLiDARGridCellCount(self._cloud_ptr)
1664 def getCellCenter(self, index: int) -> vec3:
1665 """Get the true world-space center position of a grid cell.
1667 For a grid created with a non-zero azimuthal ``rotation``, this is the lattice
1668 center rotated about the grid anchor (about +z), so it lies in the same rotated
1669 world frame as the hit points, scan origins, and grid bounding box. For an
1670 un-rotated grid it is simply the lattice center.
1673 raise ValueError("Index must be non-negative")
1674 center_list = lidar_wrapper.getLiDARCellCenter(self._cloud_ptr, index)
1675 return vec3(*center_list)
1677 def getCellCenterUnrotated(self, index: int) -> vec3:
1678 """Get the UNROTATED (axis-aligned lattice) center position of a grid cell.
1680 Companion to :meth:`getCellCenter`, which applies the grid's azimuthal rotation.
1681 This returns the center on the axis-aligned lattice instead; for an un-rotated
1682 grid the two are identical.
1684 Use this when the caller applies the grid rotation itself (e.g. rotating a whole
1685 voxel group about the grid center for display) — passing the rotated center to
1686 such code rotates the lattice twice.
1689 raise ValueError("Index must be non-negative")
1690 center_list = lidar_wrapper.getLiDARCellCenterUnrotated(self._cloud_ptr, index)
1691 return vec3(*center_list)
1693 def getCellSize(self, index: int) -> vec3:
1694 """Get size of a grid cell"""
1696 raise ValueError("Index must be non-negative")
1697 size_list = lidar_wrapper.getLiDARCellSize(self._cloud_ptr, index)
1698 return vec3(*size_list)
1700 def getCellRotation(self, index: int) -> float:
1701 """Get the azimuthal rotation of a grid cell about the z-axis, in degrees.
1703 The units match the ``rotation`` argument of :meth:`addGrid`. Note that
1704 :meth:`addGridCell` takes its rotation in radians.
1707 raise ValueError("Index must be non-negative")
1708 return lidar_wrapper.getLiDARCellRotation(self._cloud_ptr, index)
1710 def getCellLeafArea(self, index: int) -> float:
1711 """Get leaf area of a grid cell (m²)"""
1713 raise ValueError("Index must be non-negative")
1714 return lidar_wrapper.getLiDARCellLeafArea(self._cloud_ptr, index)
1716 def getCellLeafAreaDensity(self, index: int) -> float:
1717 """Get leaf area density of a grid cell (m²/m³)"""
1719 raise ValueError("Index must be non-negative")
1720 return lidar_wrapper.getLiDARCellLeafAreaDensity(self._cloud_ptr, index)
1722 def getCellBeamCount(self, index: int) -> int:
1723 """Get the beam count N that entered a grid cell during the leaf-area inversion.
1725 Returns -1 if :meth:`calculateLeafArea` has not been run for this cell.
1728 raise ValueError("Index must be non-negative")
1729 return lidar_wrapper.getLiDARCellBeamCount(self._cloud_ptr, index)
1731 def getCellRelativeDensityIndex(self, index: int) -> float:
1732 """Get the relative density index (I_rdi) for a grid cell."""
1734 raise ValueError("Index must be non-negative")
1735 return lidar_wrapper.getLiDARCellRelativeDensityIndex(self._cloud_ptr, index)
1737 def getCellMeanPathLength(self, index: int) -> float:
1738 """Get the mean beam path length (m) through a grid cell."""
1740 raise ValueError("Index must be non-negative")
1741 return lidar_wrapper.getLiDARCellMeanPathLength(self._cloud_ptr, index)
1743 def getCellLADVariance(self, index: int) -> float:
1744 """Get the per-voxel LAD sampling variance for a grid cell.
1746 Returns -1 if uncertainty has not been computed (call :meth:`calculateLeafArea`
1747 with an ``element_width``).
1750 raise ValueError("Index must be non-negative")
1751 return lidar_wrapper.getLiDARCellLADVariance(self._cloud_ptr, index)
1753 def getCellLeafAreaConfidenceInterval(self, index: int, confidence_level: float = 0.95):
1754 """Get the leaf-area confidence interval for a single grid cell.
1756 Returns a ``(valid, lower, upper)`` tuple. ``valid`` is False when the interval is
1757 gated out by the Pimont validity envelope (single-voxel intervals are often
1758 untrustworthy; prefer :meth:`getGroupLADConfidenceInterval`). Requires
1759 :meth:`calculateLeafArea` to have been run with an ``element_width``.
1762 raise ValueError("Index must be non-negative")
1763 return lidar_wrapper.getLiDARCellLeafAreaConfidenceInterval(
1764 self._cloud_ptr, index, confidence_level)
1766 def getGroupLADConfidenceInterval(self, indices: List[int], confidence_level: float = 0.95):
1767 """Get the group-scale LAD confidence interval over a set of grid cells (recommended).
1769 Returns a ``(valid, mean_lad, lower, upper)`` tuple (Pimont et al. 2018, Eq. 39,
1770 assuming voxel independence). Requires :meth:`calculateLeafArea` to have been run
1771 with an ``element_width``.
1774 raise ValueError("indices must contain at least one cell index")
1775 if any(i < 0 for i in indices):
1776 raise ValueError("Cell indices must be non-negative")
1777 return lidar_wrapper.getLiDARGroupLADConfidenceInterval(
1778 self._cloud_ptr, indices, confidence_level)
1780 def getCellGtheta(self, index: int) -> float:
1781 """Get G(theta) value for a grid cell"""
1783 raise ValueError("Index must be non-negative")
1784 return lidar_wrapper.getLiDARCellGtheta(self._cloud_ptr, index)
1786 def setCellGtheta(self, Gtheta: float, index: int):
1787 """Set G(theta) value for a grid cell"""
1789 raise ValueError("Index must be non-negative")
1790 lidar_wrapper.setLiDARCellGtheta(self._cloud_ptr, Gtheta, index)
1792 def calculateHitGridCell(self):
1793 """Calculate hit point grid cell assignments"""
1794 lidar_wrapper.calculateLiDARHitGridCell(self._cloud_ptr)
1796 def gapfillMisses(self):
1798 Gapfill sky/miss points where rays didn't hit geometry.
1800 Important for accurate leaf area calculations with real LiDAR data.
1801 Should be called before triangulation when processing real data.
1803 Each scan is reconstructed from its returns' ``row``/``column`` data if present,
1804 otherwise from their ``timestamp`` data. A static raster scan with neither is placed
1805 on its declared raster by direction (through the scan's tilt and azimuth offset),
1806 after checking that the returns lie on that raster; moving-platform, spinning
1807 multibeam and Risley-prism scans still need timestamps or row/column data. Raises
1808 an error if a scan's timestamps were rounded more coarsely than the scanner fires
1809 (returns sharing a timestamp point in different directions).
1811 Misses synthesized here are stored in virtualized form -- as a per-cell occupancy
1812 bit plus a scan-wide angular model rather than as stored points -- so they cost no
1813 per-point storage. They are counted by :meth:`getHitCount` and readable through
1814 every accessor regardless. See :meth:`hasVirtualMisses` and
1815 :meth:`getVirtualMissCount`.
1818 Reading the whole cloud back afterwards should go through the bulk accessors
1819 (:meth:`getHitsXYZRGB`, :meth:`getHitScanIDArray`, :meth:`getHitDataArray`),
1820 which read virtualized misses in one pass. A Python loop over the per-index
1821 getters costs O(Nphi) on each such point.
1823 lidar_wrapper.gapfillLiDARMisses(self._cloud_ptr)
1825 def gapfillMissesCount(self, scanID: Optional[int] = None,
1826 gapfill_grid_only: bool = False,
1827 add_flags: bool = False) -> int:
1829 Gapfill missing points and return only how many were added.
1831 Identical to :meth:`gapfillMisses` except that the count is returned instead of the
1832 filled positions, which for a fine scan grid is a large allocation most callers
1836 scanID: Scan to gapfill. ``None`` (the default) gapfills every scan, in which
1837 case ``gapfill_grid_only`` and ``add_flags`` are not used.
1838 gapfill_grid_only: Fill only within the voxel grid's bounding box
1839 add_flags: Add ``gapfillMisses_code`` as hit point data (0=original, 1=gapfilled)
1842 Number of missing points added
1845 RuntimeError: If the native library predates helios-core v1.3.84
1848 return lidar_wrapper.gapfillLiDARMissesCount(self._cloud_ptr)
1849 return lidar_wrapper.gapfillLiDARMissesCountScan(
1850 self._cloud_ptr, scanID, gapfill_grid_only, add_flags
1853 def getVirtualMissCount(self) -> int:
1855 Number of gap-filled misses currently held in virtualized form.
1857 A miss synthesized by :meth:`gapfillMisses` is a pure function of its scan-grid
1858 cell, so it is stored implicitly rather than as an element of the hit array. Such
1859 points are counted by :meth:`getHitCount` and readable through every accessor, but
1860 occupy no per-point storage.
1863 RuntimeError: If the native library predates helios-core v1.3.84
1865 return lidar_wrapper.getLiDARVirtualMissCount(self._cloud_ptr)
1867 def getCroppedReturnStats(self) -> dict:
1869 Returns counts of the removed returns that the last :meth:`calculateLeafArea` call inferred from ``target_count``.
1871 A pulse's returns are ordered by range and a beam crosses the (convex) voxel grid
1872 in one contiguous segment. So when a cloud has had returns removed -- cropped to the
1873 grid, say -- but its surviving returns still carry the per-pulse ``target_index`` and
1874 ``target_count`` the scanner wrote, the inversion places each missing return from the
1875 surviving indices alone: indices below the smallest surviving index were before the
1876 first surviving return, indices above the largest were beyond the last one, and only
1877 indices between two surviving returns cannot be placed. Under the crop-to-grid
1878 assumption the second group is counted as transmitted through every voxel the beam
1879 pierces, so a cropped beam keeps the transmittance its full record implied; the third
1880 is left out and reported as ambiguous. A stand-in miss (a return flagged ``is_miss``
1881 sharing the pulse's timestamp) is ignored for counting when the inference applies.
1884 dict with integer counts:
1886 - ``beams_with_hidden_returns`` -- pulses with at least one return recovered
1887 - ``hidden_before`` -- removed returns placed before the first surviving return
1888 (expected after any near-field culling; changes nothing)
1889 - ``hidden_after`` -- removed returns placed beyond the last surviving return
1890 (counted as transmitted)
1891 - ``hidden_ambiguous`` -- removed returns between two surviving returns (not
1892 counted; a non-zero value means the cloud was cropped INSIDE the grid)
1893 - ``beams_ambiguous`` -- pulses carrying at least one ambiguous removed return
1894 - ``standins_ignored`` -- stand-in misses left out because the count covered them
1896 All zero before the first inversion and for a cloud whose pulses are complete.
1899 RuntimeError: If the native library predates this feature
1901 return lidar_wrapper.getLiDARCroppedReturnStats(self._cloud_ptr)
1903 def hasVirtualMisses(self) -> bool:
1905 Whether any gap-filled miss is currently held in virtualized form.
1908 RuntimeError: If the native library predates helios-core v1.3.84
1910 return lidar_wrapper.hasLiDARVirtualMisses(self._cloud_ptr)
1912 def materializeMisses(self) -> None:
1914 Convert every virtualized gap-filled miss into a stored hit point.
1916 Every observable is unchanged by this call -- it trades the memory saving for real
1917 storage. It happens automatically before any operation that renumbers the hit index
1918 space (adding or deleting a hit point, writing hit data or a grid cell), so calling
1919 it explicitly is only needed to pay that cost at a chosen moment.
1922 RuntimeError: If the native library predates helios-core v1.3.84
1924 lidar_wrapper.materializeLiDARMisses(self._cloud_ptr)
1926 def getScanGridDirection(self, scanID: int, row: int, column: int) -> SphericalCoord:
1928 Beam direction at a scan-grid cell, from the model fitted during gap-filling.
1930 Available once :meth:`gapfillMisses` has run on the scan through the row/column
1931 path. This is the same reconstruction used to place synthesized misses, exposed so
1932 a caller can check the fitted geometry against known directions.
1936 row: Scan-grid row (zenith index)
1937 column: Scan-grid column (azimuth index)
1940 Unit direction of that cell's beam
1943 RuntimeError: If the native library predates helios-core v1.3.84
1945 radius, elevation, azimuth = lidar_wrapper.getLiDARScanGridDirection(
1946 self._cloud_ptr, scanID, row, column
1948 return SphericalCoord(radius, elevation, azimuth)
1950 def getHitXYZColumn(self):
1952 Read every hit's position in index order in one pass.
1954 Costs O(1) per hit even for virtualized gap-filled misses, which the per-index
1955 accessors resolve in O(Nphi). Prefer this to a Python loop over
1956 :meth:`getHitXYZ` whenever the whole cloud is being read.
1959 List of (x, y, z) tuples, one per hit
1962 RuntimeError: If the native library predates helios-core v1.3.84
1964 return lidar_wrapper.getLiDARHitXYZColumn(self._cloud_ptr, self.getHitCount())
1966 def getHitScanIDColumn(self):
1968 Read every hit's scan ID in index order in one pass.
1970 See :meth:`getHitXYZColumn` for why this is preferred over a per-index loop.
1973 List of scan indices, one per hit
1976 RuntimeError: If the native library predates helios-core v1.3.84
1978 return lidar_wrapper.getLiDARHitScanIDColumn(self._cloud_ptr, self.getHitCount())
1980 def estimateHitPointMemory(self, hit_count: int) -> int:
1982 Estimate the resident memory a cloud of ``hit_count`` points will occupy, in bytes.
1984 Each stored point costs the size of a hit point plus, for every scalar-data label
1985 the cloud carries, one double of value and one byte of presence -- the columnar
1986 store is dense, so every label costs on every point. Excludes virtualized misses
1987 and the transient of growing the arrays; see :meth:`reserveHitPoints` for that.
1989 Most accurate once at least one point exists, since it reads the labels created
1993 hit_count: Number of hit points to estimate for
1996 Estimated resident bytes
1999 RuntimeError: If the native library predates helios-core v1.3.84
2001 return lidar_wrapper.estimateLiDARHitPointMemory(self._cloud_ptr, hit_count)
2003 def setMaxHitPoints(self, max_hits: int) -> None:
2005 Set the cap on stored hit points before loading fails with a diagnostic.
2007 Exceeding the cap raises an error naming the projected point count and the limit,
2008 rather than throwing from inside the allocator where neither the scan responsible
2009 nor the size is visible. The default (:meth:`getDefaultMaxHitPoints`) is
2010 deliberately generous: it guards against a mis-specified scan grid exhausting the
2011 machine, and is not a statement about machine capacity. Raise it when the machine
2012 genuinely has the memory.
2015 max_hits: Maximum stored hit points, or 0 to disable the check
2018 RuntimeError: If the native library predates helios-core v1.3.84
2020 lidar_wrapper.setLiDARMaxHitPoints(self._cloud_ptr, max_hits)
2022 def getMaxHitPoints(self) -> int:
2024 Current cap on stored hit points, or 0 if the check is disabled.
2027 RuntimeError: If the native library predates helios-core v1.3.84
2029 return lidar_wrapper.getLiDARMaxHitPoints(self._cloud_ptr)
2032 def getDefaultMaxHitPoints() -> int:
2034 Default cap on the number of stored hit points in a cloud (100 million).
2037 RuntimeError: If the native library predates helios-core v1.3.84
2039 return lidar_wrapper.getLiDARDefaultMaxHitPoints()
2041 def reserveHitPoints(self, hit_count: int) -> None:
2043 Reserve capacity for hit points and every scalar-data column at once.
2045 Growing the hit-point array by repeated insertion temporarily needs both the old and
2046 new buffers, which for a cloud of tens of millions of returns adds gigabytes of
2047 transient memory, so a load that would fit once settled can still fail while growing.
2048 Reserving the final size once avoids this.
2050 This only reserves capacity; it does not create hit points, and
2051 :meth:`getHitCount` is unchanged. Reserving less than the eventual total is
2052 harmless, as is reserving more.
2055 hit_count: Expected total number of hit points in the cloud
2058 RuntimeError: If the native library predates helios-core v1.3.84
2060 lidar_wrapper.reserveLiDARHitPoints(self._cloud_ptr, hit_count)
2062 def setExactPathLengths(self, exact: bool) -> None:
2064 Keep every beam path length exactly, instead of binning them.
2066 The leaf-area inversion bins per-beam voxel path lengths once a voxel accumulates
2067 many samples, which bounds memory that would otherwise grow without limit with scan
2068 size. Binning recovers the extinction coefficient far inside the solver's
2069 tolerance, so this is an escape hatch for unusual geometry, or for confirming that
2070 binning is not responsible for a difference between two results.
2073 exact: True to keep every sample; False (the default) to bin above the threshold
2076 RuntimeError: If the native library predates helios-core v1.3.84
2078 lidar_wrapper.setLiDARExactPathLengths(self._cloud_ptr, exact)
2080 def getExactPathLengths(self) -> bool:
2082 Whether path lengths are accumulated exactly.
2085 RuntimeError: If the native library predates helios-core v1.3.84
2087 return lidar_wrapper.getLiDARExactPathLengths(self._cloud_ptr)
2089 def syntheticScan(self, context: Context,
2090 rays_per_pulse: Optional[int] = None,
2091 pulse_distance_threshold: Optional[float] = None,
2092 scan_grid_only: bool = False,
2093 record_misses: bool = True,
2094 append: bool = False,
2095 return_mode: Optional[Union[ReturnMode, int]] = None,
2098 Perform synthetic LiDAR scan of geometry in Context.
2100 Requires scan metadata to be defined first via addScan() or loadXML().
2101 Uses ray tracing to simulate LiDAR instrument measurements.
2104 context: Helios Context containing geometry to scan
2105 rays_per_pulse: Number of rays per pulse (None=discrete-return, typical: 100)
2106 pulse_distance_threshold: Distance threshold for aggregating hits (meters, required for waveform)
2107 scan_grid_only: If True, only scan within defined grid cells
2108 record_misses: If True, record miss/sky points where rays don't hit geometry
2109 append: If True, append to existing hits; if False, clear existing hits
2110 return_mode: Optional :class:`ReturnMode` (MULTI or SINGLE) for analytic-waveform scans.
2111 Overrides each scan's stored return mode for this call only. Only valid when
2112 rays_per_pulse is set (waveform mode); raises ValueError otherwise. In SINGLE mode
2113 up to each scan's getScanMaxReturns() returns per pulse are reported, selected by the
2114 scan's single-return selection policy.
2115 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.
2117 Example (Discrete-return):
2118 >>> from pyhelios import Context, LiDARCloud
2119 >>> from pyhelios.types import vec3
2120 >>> with Context() as context:
2122 ... context.addPatch(center=vec3(0, 0, 0.5), size=vec2(1, 1))
2124 ... with LiDARCloud() as lidar:
2125 ... # Define scan parameters
2126 ... scan_id = lidar.addScan(
2127 ... origin=vec3(0, 0, 2),
2128 ... Ntheta=100, theta_range=(0, 1.57),
2129 ... Nphi=100, phi_range=(0, 6.28),
2130 ... exit_diameter=0, beam_divergence=0
2133 ... # Perform discrete-return scan
2134 ... lidar.syntheticScan(context)
2136 Example (Full-waveform):
2137 >>> lidar.syntheticScan(
2139 ... rays_per_pulse=100,
2140 ... pulse_distance_threshold=0.02,
2141 ... record_misses=True
2144 if not isinstance(context, Context):
2145 raise TypeError("context must be a Context instance")
2147 context_ptr = context.getNativePtr()
2149 # Register an external cancellation flag (a ctypes.c_int set non-zero from
2150 # another thread) so a long ray trace can be aborted mid-pass. Cleared in
2151 # the finally below so a later scan on this cloud isn't pre-cancelled.
2152 if cancel_flag is not None:
2153 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, cancel_flag)
2155 self._dispatch_synthetic_scan(
2156 context_ptr, rays_per_pulse, pulse_distance_threshold,
2157 scan_grid_only, record_misses, append, return_mode)
2159 if cancel_flag is not None:
2160 lidar_wrapper.setLiDARCancelFlag(self._cloud_ptr, None)
2161 # A hit sink runs during the scan above. An exception raised inside it was swallowed
2162 # by ctypes and stashed; surface it now rather than reporting a successful scan.
2163 self._raise_pending_callback_error()
2165 def _dispatch_synthetic_scan(self, context_ptr, rays_per_pulse,
2166 pulse_distance_threshold, scan_grid_only,
2167 record_misses, append, return_mode):
2168 # Discrete-return mode (single ray per pulse)
2169 if rays_per_pulse is None:
2170 if return_mode is not None:
2172 "return_mode is only valid for analytic-waveform scans; pass rays_per_pulse (> 1)")
2173 # Honor scan_grid_only and record_misses for discrete scans too. record_misses
2174 # defaults to True so the cloud carries the transmitted beams that
2175 # calculateLeafArea() requires.
2176 lidar_wrapper.syntheticLiDARScanDiscrete(
2177 self._cloud_ptr, context_ptr, scan_grid_only, record_misses, append)
2179 # Full-waveform mode (multiple rays per pulse)
2180 if pulse_distance_threshold is None:
2181 raise ValueError("pulse_distance_threshold required for full-waveform scanning")
2183 validate_positive_value(rays_per_pulse, 'rays_per_pulse', 'syntheticScan')
2184 validate_positive_value(pulse_distance_threshold, 'pulse_distance_threshold', 'syntheticScan')
2186 if return_mode is None:
2187 lidar_wrapper.syntheticLiDARScanFull(
2188 self._cloud_ptr, context_ptr,
2189 rays_per_pulse, pulse_distance_threshold,
2190 scan_grid_only, record_misses, append
2193 lidar_wrapper.syntheticLiDARScanReturnMode(
2194 self._cloud_ptr, context_ptr,
2195 rays_per_pulse, pulse_distance_threshold, int(return_mode),
2196 scan_grid_only, record_misses, append
2199 def calculateLeafArea(self, context: Context, min_voxel_hits: Optional[int] = None,
2200 element_width: Optional[float] = None,
2201 Gtheta: Optional[Union[float, List[float]]] = None):
2203 Calculate leaf area for each grid cell.
2205 Requires triangulation to have been performed first, UNLESS a ``Gtheta`` is supplied
2209 The cloud must contain misses (transmitted beams that returned nothing) — the
2210 inversion fails fast without them. Misses are produced by
2211 ``syntheticScan(..., record_misses=True)`` (the default) or by
2212 :meth:`gapfillMisses`. Use :meth:`hasMisses` to check.
2215 Returns are grouped into beams by their shared ``timestamp``, so timestamps must
2216 identify one pulse each. The inversion rejects a scan whose returns sharing a
2217 timestamp point in different directions (timestamps rounded on export, e.g. to
2218 32-bit floats), and one whose per-pulse data contradicts the grouping (two returns
2219 of a pulse with the same ``target_index``, or more returns than its
2220 ``target_count``). The error names the scan and how many beams are affected.
2223 context: Helios Context instance
2224 min_voxel_hits: Optional minimum number of hits required per voxel
2225 element_width: Optional characteristic vegetation element width (meters). When
2226 provided, per-voxel sampling uncertainty (Pimont et al. 2018) is computed
2227 alongside the leaf-area estimate and becomes available via
2228 :meth:`getCellLADVariance`, :meth:`getCellLeafAreaConfidenceInterval`, and
2229 :meth:`getGroupLADConfidenceInterval`. ``element_width <= 0`` yields a
2230 sampling-only variance.
2231 Gtheta: Optional caller-supplied mean leaf-projection coefficient G(theta), in (0,1]
2232 (0.5 = spherical/random leaf-angle distribution). May be a single scalar (broadcast
2233 to every voxel) or a sequence of one value per grid cell in grid-cell order — the
2234 latter supports a spatially-varying (e.g. vertically-varying) leaf-angle
2235 distribution. When provided, leaf area is computed via a beam-based inversion that
2236 uses each hit's per-pulse beam origin and does NOT require triangulation — the only
2237 supported path for moving-platform scans (see :meth:`addScanMoving`). Requires both
2238 ``min_voxel_hits`` and ``element_width`` to also be specified.
2240 A single float applies one G(theta) to every voxel. A sequence supplies one
2241 G(theta) **per grid cell**, in grid-cell order (the order of
2242 :meth:`getCellCenter`), for a canopy whose leaf-angle distribution varies in
2243 space, typically with height. Its length must equal :meth:`getGridCellCount` and
2244 every value must be in (0,1]. The per-cell form requires helios-core v1.3.85.
2247 TypeError: If ``context`` is not a Context
2248 ValueError: If the argument combination is invalid, a G(theta) value is outside
2249 (0,1], or a per-cell sequence is empty or does not match the grid cell count
2250 HeliosError: If the native inversion fails (for example, the cloud has no misses)
2251 RuntimeError: If a per-cell ``Gtheta`` is given and the native library predates
2255 >>> from pyhelios import Context, LiDARCloud
2256 >>> with Context() as context:
2257 ... with LiDARCloud() as lidar:
2258 ... # ... load data, add grid, triangulate ...
2259 ... lidar.calculateLeafArea(context)
2261 if not isinstance(context, Context):
2262 raise TypeError("context must be a Context instance")
2264 # Gtheta may be a scalar (broadcast to every voxel) or a sequence of one value per grid
2265 # cell (a spatially-varying leaf-angle distribution). Detect which up front.
2266 gtheta_is_sequence = Gtheta is not None and not isinstance(Gtheta, (int, float))
2268 # Validate argument combinations before touching native state (fail-fast).
2269 Gtheta_per_cell = None
2270 if Gtheta is not None:
2271 if min_voxel_hits is None or element_width is None:
2273 "Gtheta requires both min_voxel_hits and element_width to also be specified "
2274 "(the G(theta) overload takes all three)")
2275 if gtheta_is_sequence:
2276 # Validate the sequence itself before any native call, so a malformed Gtheta
2277 # is reported even on a cloud that has no grid loaded yet.
2278 Gtheta_per_cell = [float(g) for g in Gtheta]
2279 if not Gtheta_per_cell:
2280 raise ValueError("A per-cell Gtheta sequence must contain at least one value")
2281 bad = [g for g in Gtheta_per_cell if not (0.0 < g <= 1.0)]
2284 f"Every per-cell Gtheta value must be in (0, 1] "
2285 f"(e.g. 0.5 for a spherical leaf-angle distribution); got {bad[0]!r}")
2286 cell_count = self.getGridCellCount()
2287 if len(Gtheta_per_cell) != cell_count:
2289 f"A per-cell Gtheta sequence must have one entry per grid cell: "
2290 f"got {len(Gtheta_per_cell)} values for {cell_count} cells")
2292 # The native overload treats Gtheta <= 0 as the "compute from triangulation"
2293 # sentinel, which silently disables the supplied-G(theta) path. Reject it here.
2295 "Gtheta must be > 0 and in (0, 1] (e.g. 0.5 for a spherical leaf-angle distribution)")
2296 elif element_width is not None and min_voxel_hits is None:
2298 "element_width requires min_voxel_hits to also be specified "
2299 "(the uncertainty overload takes both)")
2301 context_ptr = context.getNativePtr()
2302 if Gtheta_per_cell is not None:
2303 lidar_wrapper.calculateLiDARLeafAreaGthetaPerCell(
2304 self._cloud_ptr, context_ptr, Gtheta_per_cell, min_voxel_hits, element_width)
2305 elif Gtheta is not None:
2306 lidar_wrapper.calculateLiDARLeafAreaGtheta(
2307 self._cloud_ptr, context_ptr, Gtheta, min_voxel_hits, element_width)
2308 elif element_width is not None:
2309 lidar_wrapper.calculateLiDARLeafAreaUncertainty(
2310 self._cloud_ptr, context_ptr, min_voxel_hits, element_width)
2311 elif min_voxel_hits is None:
2312 lidar_wrapper.calculateLiDARLeafArea(self._cloud_ptr, context_ptr)
2314 lidar_wrapper.calculateLiDARLeafAreaMinHits(self._cloud_ptr, context_ptr, min_voxel_hits)
2316 def calculateSyntheticLeafArea(self, context: Context):
2318 Calculate synthetic leaf area (for validation of synthetic scans).
2320 Uses exact primitive geometry to calculate leaf area, useful for
2321 validating synthetic scan accuracy.
2324 context: Helios Context instance containing primitive geometry
2326 if not isinstance(context, Context):
2327 raise TypeError("context must be a Context instance")
2328 context_ptr = context.getNativePtr()
2329 lidar_wrapper.calculateSyntheticLiDARLeafArea(self._cloud_ptr, context_ptr)
2331 def calculateSyntheticGtheta(self, context: Context):
2333 Calculate synthetic G(theta) (for validation of synthetic scans).
2335 Uses exact primitive geometry to calculate G(theta), useful for
2336 validating synthetic scan accuracy.
2339 context: Helios Context instance containing primitive geometry
2341 if not isinstance(context, Context):
2342 raise TypeError("context must be a Context instance")
2343 context_ptr = context.getNativePtr()
2344 lidar_wrapper.calculateSyntheticLiDARGtheta(self._cloud_ptr, context_ptr)
2346 def exportTriangleNormals(self, filename: str):
2347 """Export triangle normal vectors to file
2350 Raises once a run's triangles have been streamed to a sink registered with
2351 :meth:`setTriangulationSink` -- the mesh is released rather than stored.
2354 raise ValueError("Filename cannot be empty")
2355 lidar_wrapper.exportLiDARTriangleNormals(self._cloud_ptr, filename)
2357 def exportTriangleAreas(self, filename: str):
2358 """Export triangle areas to file
2361 Raises once a run's triangles have been streamed to a sink registered with
2362 :meth:`setTriangulationSink` -- the mesh is released rather than stored.
2365 raise ValueError("Filename cannot be empty")
2366 lidar_wrapper.exportLiDARTriangleAreas(self._cloud_ptr, filename)
2368 def exportLeafAreas(self, filename: str):
2369 """Export leaf areas for each grid cell to file"""
2371 raise ValueError("Filename cannot be empty")
2372 lidar_wrapper.exportLiDARLeafAreas(self._cloud_ptr, filename)
2374 def exportLeafAreaDensities(self, filename: str):
2375 """Export leaf area densities for each grid cell to file"""
2377 raise ValueError("Filename cannot be empty")
2378 lidar_wrapper.exportLiDARLeafAreaDensities(self._cloud_ptr, filename)
2380 def exportGtheta(self, filename: str):
2381 """Export G(theta) values for each grid cell to file"""
2383 raise ValueError("Filename cannot be empty")
2384 lidar_wrapper.exportLiDARGtheta(self._cloud_ptr, filename)
2386 def addTrianglesToContext(self, context: Context):
2388 Add triangulated mesh to Context as triangle primitives.
2390 Converts the triangulated point cloud mesh into Context triangle
2391 primitives that can be used for further analysis or visualization.
2394 Raises once a run's triangles have been streamed to a sink registered with
2395 :meth:`setTriangulationSink` -- the mesh is released rather than stored, so there
2396 is nothing to add. Clear the sink before triangulating if you need the stored mesh.
2399 context: Helios Context instance
2402 >>> with Context() as context:
2403 ... with LiDARCloud() as lidar:
2404 ... lidar.loadXML("scan.xml")
2405 ... lidar.triangulateHitPoints(Lmax=0.5, max_aspect_ratio=5)
2406 ... lidar.addTrianglesToContext(context)
2407 ... print(f"Added {context.getPrimitiveCount()} triangles to context")
2409 if not isinstance(context, Context):
2410 raise TypeError("context must be a Context instance")
2411 lidar_wrapper.addLiDARTrianglesToContext(self._cloud_ptr, context.getNativePtr())
2413 def initializeCollisionDetection(self, context: Context):
2415 Initialize CollisionDetection plugin for ray tracing.
2417 Required before performing synthetic scans.
2420 context: Helios Context instance containing geometry
2422 if not isinstance(context, Context):
2423 raise TypeError("context must be a Context instance")
2425 # Retain a reference to the Context. The native side constructs a
2426 # CollisionDetection that stores the raw Context* for its lifetime, so a
2427 # temporary Context would otherwise be freed while still referenced.
2428 # Note the C++ side only builds CollisionDetection once (it no-ops if one
2429 # already exists), so the first Context passed here is the one that stays
2430 # bound - re-initializing with a different Context has no effect.
2431 if getattr(self, '_cd_context', None) is not None and self._cd_context is not context:
2433 "LiDARCloud collision detection is already initialized with a different Context.\n"
2434 "The native CollisionDetection keeps the Context it was first given; "
2435 "passing another one here would silently have no effect.\n"
2437 "Fix: create a new LiDARCloud for a different Context, or reuse the "
2438 "Context this cloud was initialized with."
2440 self._cd_context = context
2442 lidar_wrapper.initializeLiDARCollisionDetection(self._cloud_ptr, context.getNativePtr())
2444 def _check_cd_context_alive(self):
2445 """Raise if the Context bound to collision detection has been destroyed."""
2446 if getattr(self, '_cd_context', None) is not None:
2447 check_context_alive(self._cd_context, "LiDARCloud collision detection")
2449 def enableCDGPUAcceleration(self):
2450 """Enable GPU acceleration for collision detection ray tracing"""
2451 self._check_cd_context_alive()
2452 lidar_wrapper.enableLiDARCDGPUAcceleration(self._cloud_ptr)
2454 def disableCDGPUAcceleration(self):
2455 """Disable GPU acceleration (use CPU ray tracing)"""
2456 self._check_cd_context_alive()
2457 lidar_wrapper.disableLiDARCDGPUAcceleration(self._cloud_ptr)
2459 def isGPUAvailable(self) -> bool:
2460 """Return True if a CUDA-capable GPU is available for collision-detection ray tracing.
2462 Reports capability (compiled with CUDA, a device present, and HELIOS_NO_GPU not set); use
2463 :meth:`isGPUAccelerationEnabled` to query whether GPU acceleration is currently toggled on.
2465 return lidar_wrapper.isLiDARGPUAvailable(self._cloud_ptr)
2467 def isGPUAccelerationEnabled(self) -> bool:
2468 """Return True if GPU acceleration is currently enabled for collision-detection ray tracing."""
2469 return lidar_wrapper.isLiDARGPUAccelerationEnabled(self._cloud_ptr)
2471 def setSyntheticScanProgressPointer(self, ptr):
2472 """Register an external per-scan progress counter polled during :meth:`syntheticScan`.
2474 ``ptr`` is a ``ctypes.c_int`` into which syntheticScan writes the 0-based index of the scan
2475 currently being ray-traced (set to :meth:`getScanCount` when the batch finishes), letting a
2476 host thread poll progress while the blocking scan runs. The counter is owned by the caller and
2477 must outlive the scan. Pass ``None`` to clear.
2480 if ptr is not None and not isinstance(ptr, ctypes.c_int):
2481 raise TypeError("ptr must be a ctypes.c_int (or None to clear)")
2482 lidar_wrapper.setLiDARSyntheticScanProgressPointer(self._cloud_ptr, ptr)
2484 # ------------------------------------------------------------------
2485 # helios-core 1.3.86 additions
2486 # ------------------------------------------------------------------
2488 def createHitDataColumn(self, label: str, column_type: HitDataType) -> None:
2490 Create a per-hit scalar-data column with an explicit storage type.
2492 Call this *before* adding data carrying ``label`` to fix the column's storage type
2493 instead of letting it be inferred from the label name. An explicitly typed column is
2494 never widened: an INT32 column rejects a value that is not a 32-bit integer, and a
2495 FLOAT32 column stores values at float precision.
2498 label: Label of the data value (e.g. "intensity")
2499 column_type: A :class:`HitDataType` member
2502 TypeError: If ``label`` is not a str or ``column_type`` is not a HitDataType
2503 ValueError: If ``label`` is empty
2504 HeliosError: If the column already exists with a different type
2505 RuntimeError: If the native library predates helios-core v1.3.86
2507 self._validate_label(label)
2508 if not isinstance(column_type, HitDataType):
2510 "column_type must be a HitDataType (FLOAT64, FLOAT32 or INT32), "
2511 f"got {column_type!r}")
2512 lidar_wrapper.createLiDARHitDataColumn(self._cloud_ptr, label, int(column_type))
2514 def getHitDataType(self, label: str) -> HitDataType:
2516 Storage type of an existing per-hit scalar-data column.
2518 A column typed implicitly may since have widened to ``FLOAT64``.
2521 label: Label of the data value
2524 The column's current :class:`HitDataType`
2527 TypeError: If ``label`` is not a str
2528 ValueError: If ``label`` is empty
2529 HeliosError: If no column exists for the label (use
2530 :meth:`getHitDataColumnIndex` to test for existence without raising)
2531 RuntimeError: If the native library predates helios-core v1.3.86
2533 if not isinstance(label, str):
2534 raise TypeError(f"label must be a str, got {type(label).__name__}")
2536 raise ValueError("label cannot be empty")
2537 return HitDataType(lidar_wrapper.getLiDARHitDataType(self._cloud_ptr, label))
2539 def getHitDataColumnFloat32(self, label: str, absent_value: float = -9999.0) -> List[float]:
2541 Bulk-export a named scalar column as 32-bit floats.
2543 Reads a ``FLOAT32`` column without widening it to 8 bytes per hit; a ``FLOAT64`` or
2544 ``INT32`` column is converted element-wise. See :meth:`getHitDataColumn` for the
2545 double-precision counterpart.
2548 label: Label of the data value
2549 absent_value: Value reported for hits that lack the label
2552 List of floats of length :meth:`getHitCount`
2555 TypeError: If ``label`` is not a str
2556 ValueError: If ``label`` is empty
2557 RuntimeError: If the native library predates helios-core v1.3.86
2559 self._validate_label(label)
2560 n = self.getHitCount()
2563 return lidar_wrapper.getLiDARHitDataColumnF32(self._cloud_ptr, label, n, absent_value)
2565 def getHitDataColumnFloat32Array(self, label: str, absent_value: float = -9999.0):
2567 Bulk-export a named scalar column as a ``(getHitCount(),)`` float32 numpy array.
2569 See :meth:`getHitDataColumnFloat32`.
2572 self._validate_label(label)
2573 n = self.getHitCount()
2575 return np.empty((0,), np.float32)
2576 return lidar_wrapper.getLiDARHitDataColumnF32_np(self._cloud_ptr, label, n, absent_value)
2578 def getHitDataColumnInt32(self, label: str, absent_value: int = -9999) -> List[int]:
2580 Bulk-export a named scalar column as 32-bit signed integers.
2582 Reads an ``INT32`` column without widening it.
2585 label: Label of the data value
2586 absent_value: Value reported for hits that lack the label
2589 List of ints of length :meth:`getHitCount`
2592 TypeError: If ``label`` is not a str
2593 ValueError: If ``label`` is empty
2594 HeliosError: If any value is not an integer in the 32-bit range (a fractional
2595 value, a NaN, or a timestamp) -- read such a label with
2596 :meth:`getHitDataColumn` instead
2597 RuntimeError: If the native library predates helios-core v1.3.86
2599 self._validate_label(label)
2600 n = self.getHitCount()
2603 return lidar_wrapper.getLiDARHitDataColumnI32(self._cloud_ptr, label, n, absent_value)
2605 def getHitDataColumnInt32Array(self, label: str, absent_value: int = -9999):
2607 Bulk-export a named scalar column as a ``(getHitCount(),)`` int32 numpy array.
2609 See :meth:`getHitDataColumnInt32`.
2612 self._validate_label(label)
2613 n = self.getHitCount()
2615 return np.empty((0,), np.int32)
2616 return lidar_wrapper.getLiDARHitDataColumnI32_np(self._cloud_ptr, label, n, absent_value)
2618 def _validate_label(self, label: str) -> None:
2619 """Validate a scalar-data label argument (shared by the column readers)."""
2620 if not isinstance(label, str):
2621 raise TypeError(f"label must be a str, got {type(label).__name__}")
2623 raise ValueError("label cannot be empty")
2625 def addHitPointsBulk(self, scanID: int, xyz, dir_spherical=None,
2626 labels: Optional[List[str]] = None, values=None) -> None:
2628 Bulk-ingest hit points through the native bulk path, with double-precision positions.
2630 This is the native ``addHitPoints`` entry point, distinct from :meth:`addHitPoints`
2631 (a per-point shim taking float positions). It takes float64 coordinates, can derive
2632 beam directions automatically, and writes scalar-data columns directly.
2635 scanID: Scan ID these hits belong to (the scan must already exist)
2636 xyz: Hit point coordinates, shape (N, 3) float64
2637 dir_spherical: Beam directions, shape (N, 3) as (radius, elevation, azimuth), or
2638 ``None`` to derive each direction from the point's position relative to the
2639 scan origin (what the ASCII loader does)
2640 labels: Optional list of scalar-data column names (length k)
2641 values: Required when ``labels`` is given: (N, k) float64 values. A NaN entry
2642 leaves that label absent on that point.
2645 ValueError: If an array has the wrong shape, row counts disagree, or ``values``
2646 is missing while ``labels`` was supplied
2647 RuntimeError: If the native library predates helios-core v1.3.86
2650 >>> import numpy as np
2651 >>> lidar.addHitPointsBulk(0, np.zeros((10, 3)),
2652 ... labels=["intensity"], values=np.ones((10, 1)))
2655 raise ValueError("scanID must be non-negative")
2656 lidar_wrapper.addLiDARHitPointsBulk(self._cloud_ptr, scanID, xyz, dir_spherical,
2659 def deleteHitPoints(self, first: int, count: int) -> None:
2661 Delete a contiguous range of hit points, preserving the order of the rest.
2663 Removes hits ``[first, first+count)``. Unlike :meth:`deleteHitPoint`, which fills the
2664 freed slot with the last hit, this keeps every surviving hit in its relative order --
2665 so draining the tail of the cloud (the release step of a streaming
2666 :meth:`syntheticScan`, see :meth:`setSyntheticScanHitSink`) costs O(count) and leaves
2667 earlier indices unchanged.
2670 first: Index of the first hit to delete
2671 count: Number of hits to delete
2674 ValueError: If ``first`` or ``count`` is negative
2675 HeliosError: If the range extends past the end of the cloud
2676 RuntimeError: If the native library predates helios-core v1.3.86
2679 raise ValueError("first must be non-negative")
2681 raise ValueError("count must be non-negative")
2682 lidar_wrapper.deleteLiDARHitPoints(self._cloud_ptr, first, count)
2684 def setTriangulationSink(self, callback) -> None:
2686 Register a sink that receives each scan's triangles as :meth:`triangulateHitPoints` finishes it.
2688 With a sink set, triangulation hands each scan's finished triangles to ``callback`` and
2689 then releases them, keeping only the per-voxel leaf-angle sums the leaf-area inversion
2690 needs. Retained memory becomes one scan's triangles at a time, and
2691 :meth:`calculateLeafArea` still works and gives the same result.
2693 ``callback`` is called as ``callback(scanID, vertices, ids)`` where ``vertices`` is a
2694 ``(T, 9)`` float32 numpy array laid out ``[v0x,v0y,v0z, v1x,v1y,v1z, v2x,v2y,v2z]`` per
2695 triangle and ``ids`` is a ``(T, 2)`` int32 array of ``[scanID, gridcell]``. Both are
2696 copies owned by the caller. Pass ``None`` to clear the sink.
2699 Once a run's triangles have been streamed, the mesh is not retained:
2700 :meth:`getTriangleCount` reports zero and :meth:`getTriangleVerticesAll`,
2701 :meth:`addTrianglesToContext`, :meth:`exportTriangleNormals` and
2702 :meth:`exportTriangleAreas` raise rather than silently operating on an empty mesh.
2703 Clear the sink before triangulating if you need the stored mesh.
2706 An exception raised inside ``callback`` is captured and re-raised from the call
2707 that triggered it --
2708 typically :meth:`triangulateHitPoints`.
2711 callback: Callable taking ``(scanID, vertices, ids)``, or ``None`` to clear
2714 TypeError: If ``callback`` is neither callable nor None
2715 RuntimeError: If the native library predates helios-core v1.3.86
2719 if callback is None:
2720 lidar_wrapper.setLiDARTriangulationSink(self._cloud_ptr, None)
2721 self._triangulation_sink_ref = None
2722 self._triangulation_sink_error = None
2725 if not callable(callback):
2726 raise TypeError("callback must be callable or None")
2728 self._triangulation_sink_error = None
2730 def _trampoline(scanID, xyz9, ids, triCount, user_data):
2731 # A Python exception raised here is swallowed by ctypes (the callback simply
2732 # returns, and C++ treats that as success), so stash it and re-raise after the
2733 # native call returns. See _raise_pending_callback_error.
2737 verts = np.ctypeslib.as_array(xyz9, shape=(n, 9)).copy()
2739 verts = np.empty((0, 9), np.float32)
2741 id_arr = np.ctypeslib.as_array(ids, shape=(n, 2)).copy()
2743 id_arr = np.empty((0, 2), np.int32)
2744 callback(int(scanID), verts, id_arr)
2745 except BaseException as exc:
2746 if self._triangulation_sink_error is None:
2747 self._triangulation_sink_error = exc
2749 self._triangulation_sink_ref = lidar_wrapper.LiDARTriangulationSinkCallback(_trampoline)
2750 lidar_wrapper.setLiDARTriangulationSink(self._cloud_ptr, self._triangulation_sink_ref)
2752 def setSyntheticScanHitSink(self, callback) -> None:
2754 Register a sink fired after each chunk of a :meth:`syntheticScan` lands in the cloud.
2756 Without a sink, every chunk's returns accumulate in the cloud until the scan finishes,
2757 so a very large scan holds every return before the caller can read any. With a sink,
2758 ``callback(first, count)`` is invoked after each chunk with the index of the first new
2759 hit and the number of new hits. Inside the callback the new hits can be read (through
2760 the per-scan column readers), written out, and then released with
2761 :meth:`deleteHitPoints` -- they are always the tail of the cloud, so the cloud never
2762 holds more than one chunk.
2764 Chunk size is bounded by :meth:`setSyntheticScanMemoryBudget`. Pass ``None`` to clear.
2767 An exception raised inside ``callback`` is captured and re-raised from the call
2768 that triggered it -- typically
2769 :meth:`syntheticScan`.
2772 callback: Callable taking ``(first, count)``, or ``None`` to clear
2775 TypeError: If ``callback`` is neither callable nor None
2776 RuntimeError: If the native library predates helios-core v1.3.86
2778 if callback is None:
2779 lidar_wrapper.setLiDARSyntheticScanHitSink(self._cloud_ptr, None)
2780 self._synthetic_hit_sink_ref = None
2781 self._synthetic_hit_sink_error = None
2784 if not callable(callback):
2785 raise TypeError("callback must be callable or None")
2787 self._synthetic_hit_sink_error = None
2789 def _trampoline(first, count, user_data):
2790 # See setTriangulationSink: a raised exception cannot cross the ctypes boundary.
2792 callback(int(first), int(count))
2793 except BaseException as exc:
2794 if self._synthetic_hit_sink_error is None:
2795 self._synthetic_hit_sink_error = exc
2797 self._synthetic_hit_sink_ref = lidar_wrapper.LiDARSyntheticScanHitSinkCallback(_trampoline)
2798 lidar_wrapper.setLiDARSyntheticScanHitSink(self._cloud_ptr, self._synthetic_hit_sink_ref)
2800 def _raise_pending_callback_error(self) -> None:
2801 """Re-raise an exception captured inside a streaming-sink callback, if any.
2803 A Python exception raised inside a ctypes callback never propagates -- ctypes returns 0
2804 and C++ takes that for success -- so the sink trampolines stash the exception and this
2805 re-raises it once the native call has returned.
2807 for attr in ('_triangulation_sink_error', '_synthetic_hit_sink_error'):
2808 exc = getattr(self, attr, None)
2810 setattr(self, attr, None)
2813 def getScanHitCount(self, scanID: int) -> int:
2815 Number of hit points (stored returns plus virtualized misses) belonging to one scan.
2817 This is the length the per-scan readers fill -- :meth:`getScanHitIndices`,
2818 :meth:`getScanHitXYZColumn` and :meth:`getScanHitDataColumn`.
2824 Number of hits in that scan
2827 ValueError: If ``scanID`` is negative
2828 RuntimeError: If the native library predates helios-core v1.3.86
2830 self._validate_scan_id(scanID)
2831 return lidar_wrapper.getLiDARScanHitCount(self._cloud_ptr, scanID)
2833 def getScanHitIndices(self, scanID: int) -> List[int]:
2835 Global indices of one scan's hit points, in the order the per-scan readers use.
2837 A scan's hits need not be contiguous in the global index space (a filter's
2838 swap-and-pop deletion reorders the cloud, and gap-filled misses live above every
2839 stored return), so the per-scan readers present a scan's hits in their own local
2840 order. This maps each local position back to the global index used by
2841 :meth:`getHitXYZ` and friends. Stored returns come first, in stored order, followed
2842 by the scan's virtualized misses.
2848 List of global hit indices, one per hit in the scan
2851 ValueError: If ``scanID`` is negative
2852 RuntimeError: If the native library predates helios-core v1.3.86
2854 self._validate_scan_id(scanID)
2855 n = self.getScanHitCount(scanID)
2856 return lidar_wrapper.getLiDARScanHitIndices(self._cloud_ptr, scanID, n)
2858 def getScanHitXYZColumn(self, scanID: int):
2860 Read one scan's hit positions in a single pass.
2862 Costs O(hits in the scan), not O(hits in the cloud).
2863 Prefer this to filtering :meth:`getHitXYZColumn` by scan.
2869 List of (x, y, z) tuples, one per hit in the scan, in local order
2872 ValueError: If ``scanID`` is negative
2873 RuntimeError: If the native library predates helios-core v1.3.86
2875 self._validate_scan_id(scanID)
2876 n = self.getScanHitCount(scanID)
2877 return lidar_wrapper.getLiDARScanHitXYZColumn(self._cloud_ptr, scanID, n)
2879 def getScanHitDataColumn(self, scanID: int, label: str,
2880 absent_value: float = -9999.0) -> List[float]:
2882 Read one scan's values of a scalar-data label in a single pass, as doubles.
2884 The per-scan counterpart of :meth:`getHitDataColumn`; see
2885 :meth:`getScanHitXYZColumn` for why this is preferred over filtering the whole cloud.
2889 label: Label of the data value
2890 absent_value: Value reported for hits that lack the label
2893 List of floats, one per hit in the scan, in local order
2896 TypeError: If ``label`` is not a str
2897 ValueError: If ``scanID`` is negative or ``label`` is empty
2898 RuntimeError: If the native library predates helios-core v1.3.86
2900 self._validate_scan_id(scanID)
2901 self._validate_label(label)
2902 n = self.getScanHitCount(scanID)
2903 return lidar_wrapper.getLiDARScanHitDataColumn(self._cloud_ptr, scanID, label, n,
2906 def getScanHitDataColumnFloat32(self, scanID: int, label: str,
2907 absent_value: float = -9999.0) -> List[float]:
2909 Read one scan's values of a scalar-data label as 32-bit floats.
2911 See :meth:`getScanHitDataColumn` and :meth:`getHitDataColumnFloat32`.
2914 TypeError: If ``label`` is not a str
2915 ValueError: If ``scanID`` is negative or ``label`` is empty
2916 RuntimeError: If the native library predates helios-core v1.3.86
2918 self._validate_scan_id(scanID)
2919 self._validate_label(label)
2920 n = self.getScanHitCount(scanID)
2921 return lidar_wrapper.getLiDARScanHitDataColumnF32(self._cloud_ptr, scanID, label, n,
2924 def getScanHitDataColumnInt32(self, scanID: int, label: str,
2925 absent_value: int = -9999) -> List[int]:
2927 Read one scan's values of a scalar-data label as 32-bit signed integers.
2929 See :meth:`getScanHitDataColumn` and :meth:`getHitDataColumnInt32`.
2932 TypeError: If ``label`` is not a str
2933 ValueError: If ``scanID`` is negative or ``label`` is empty
2934 HeliosError: If any value is not an integer in the 32-bit range
2935 RuntimeError: If the native library predates helios-core v1.3.86
2937 self._validate_scan_id(scanID)
2938 self._validate_label(label)
2939 n = self.getScanHitCount(scanID)
2940 return lidar_wrapper.getLiDARScanHitDataColumnI32(self._cloud_ptr, scanID, label, n,
2943 def _validate_scan_id(self, scanID: int) -> None:
2944 """Validate a scan index argument (shared by the per-scan readers)."""
2945 if not isinstance(scanID, int) or isinstance(scanID, bool):
2946 raise TypeError(f"scanID must be an int, got {type(scanID).__name__}")
2948 raise ValueError("scanID must be non-negative")
2950 def calculateLeafAreaBlock(self, context: Context, ijk_min, ijk_max,
2951 min_voxel_hits: int, element_width: float,
2952 Gtheta: Optional[Union[float, List[float]]] = None) -> None:
2954 Calculate leaf area for only a block of the voxel grid.
2956 The block form of :meth:`calculateLeafArea`, for inverting a large grid a tile at a
2957 time. Requires a regular lattice grid (as built by :meth:`addGrid`); use
2958 :meth:`getGridGlobalCount` for the lattice dimensions and :meth:`getCellGlobalIJK` to
2959 map a cell index to its lattice coordinate.
2962 context: Helios Context instance
2963 ijk_min: Lattice index (i, j, k) of the block's first cell
2964 ijk_max: Lattice index (i, j, k) of the block's last cell, inclusive
2965 min_voxel_hits: Minimum number of beams that must have entered a voxel
2966 element_width: Characteristic vegetation element width (m); <= 0 yields a
2967 sampling-only variance
2968 Gtheta: Optional caller-supplied G(theta) in (0,1]. A single float applies one
2969 value to every voxel; a sequence supplies one value **per grid cell** (the
2970 whole grid, not just the block) in grid-cell order. When omitted,
2971 triangulation supplies G(theta) and must have been run.
2974 TypeError: If ``context`` is not a Context
2975 ValueError: If a lattice index is not 3 elements, or a G(theta) sequence is empty
2976 HeliosError: If the grid is not a regular lattice, the block is out of range, or
2977 the inversion fails (for example, the cloud has no misses)
2978 RuntimeError: If the native library predates helios-core v1.3.86
2980 if not isinstance(context, Context):
2981 raise TypeError("context must be a Context instance")
2982 context_ptr = context.getNativePtr()
2985 lidar_wrapper.calculateLiDARLeafAreaBlock(
2986 self._cloud_ptr, context_ptr, min_voxel_hits, element_width, ijk_min, ijk_max)
2989 if isinstance(Gtheta, (int, float)) and not isinstance(Gtheta, bool):
2990 if not (0.0 < float(Gtheta) <= 1.0):
2991 raise ValueError(f"Gtheta must be in (0, 1], got {Gtheta}")
2992 lidar_wrapper.calculateLiDARLeafAreaGthetaBlock(
2993 self._cloud_ptr, context_ptr, Gtheta, min_voxel_hits, element_width,
2997 per_cell = [float(g) for g in Gtheta]
2999 raise ValueError("A per-cell Gtheta sequence must contain at least one value")
3001 if not (0.0 < g <= 1.0):
3002 raise ValueError(f"Every Gtheta value must be in (0, 1], got {g}")
3003 lidar_wrapper.calculateLiDARLeafAreaGthetaPerCellBlock(
3004 self._cloud_ptr, context_ptr, per_cell, min_voxel_hits, element_width,
3007 def getCellGlobalIJK(self, index: int) -> Tuple[int, int, int]:
3009 Lattice index (i, j, k) of a grid cell along x, y and z.
3011 For a grid built by :meth:`addGrid` this is the cell's position in the
3012 ``ndiv.x`` by ``ndiv.y`` by ``ndiv.z`` lattice; cells are stored in the order
3013 ``k*ny*nx + j*nx + i``. It is the coordinate :meth:`calculateLeafAreaBlock` takes.
3016 index: Index of a grid cell
3022 ValueError: If ``index`` is negative
3023 HeliosError: If ``index`` is out of range
3024 RuntimeError: If the native library predates helios-core v1.3.86
3027 raise ValueError("index must be non-negative")
3028 return lidar_wrapper.getLiDARCellGlobalIJK(self._cloud_ptr, index)
3030 def getGridGlobalCount(self) -> Tuple[int, int, int]:
3032 Number of lattice cells along x, y and z (the ``ndiv`` passed to :meth:`addGrid`).
3038 HeliosError: If the grid is empty or its cells do not form a regular lattice
3039 RuntimeError: If the native library predates helios-core v1.3.86
3041 return lidar_wrapper.getLiDARGridGlobalCount(self._cloud_ptr)
3043 def getHitPointCapacity(self) -> int:
3045 Number of hit points the cloud can hold before its arrays reallocate.
3047 The counterpart of :meth:`reserveHitPoints`: use it to confirm a reservation took
3048 effect, or to see how much headroom remains before the next growth reallocation.
3051 Current hit-point capacity
3054 RuntimeError: If the native library predates helios-core v1.3.86
3056 return lidar_wrapper.getLiDARHitPointCapacity(self._cloud_ptr)
3058 def setProgressCallback(self, callback):
3059 """Register a progress callback fired with ``(progress_fraction, message)`` during :meth:`syntheticScan`.
3061 ``progress_fraction`` is a float in [0, 1]; ``message`` is a ``str`` describing the current
3062 phase. Pass ``None`` to clear the callback. The callback bridge is kept alive on this
3063 :class:`LiDARCloud` for as long as it is registered.
3065 if callback is None:
3066 # Clear the native callback first, then drop our reference, so a failure in the native
3067 # call cannot leave C++ holding a freed bridge.
3068 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, None)
3069 self._progress_callback_ref = None
3072 if not callable(callback):
3073 raise TypeError("callback must be callable or None")
3075 def _trampoline(progress, message):
3076 callback(float(progress), message.decode('utf-8') if message else "")
3078 # Keep the ctypes callback object alive for as long as native code holds it; ctypes does not.
3079 self._progress_callback_ref = lidar_wrapper.LiDARProgressCallback(_trampoline)
3080 lidar_wrapper.setLiDARProgressCallback(self._cloud_ptr, self._progress_callback_ref)
3082 # ------------------------------------------------------------------
3083 # helios-core 1.3.89 additions
3084 # ------------------------------------------------------------------
3086 def deleteHitData(self, label: str) -> None:
3088 Remove a per-hit scalar-data column from every hit in the cloud.
3090 Use this to discard hit data that is wrong and that later processing would otherwise
3091 trust -- for example timestamps rounded more coarsely than the scanner fires, which
3092 can then no longer group returns into pulses, or a ``target_index`` rewritten on
3093 export. Every other column keeps its values.
3095 Misses synthesized by :meth:`gapfillMisses` report some labels themselves. Removing
3096 ``"timestamp"`` or ``"gapfillMisses_code"`` also removes it from those misses. The
3097 labels they need to exist (``"is_miss"``, ``"row"``, ``"column"``, ``"nRaysHit"``,
3098 and ``"origin_x"``/``"origin_y"``/``"origin_z"`` on a moving scan) cannot be removed
3099 while any scan has synthesized misses.
3101 A static raster scan whose timestamps were rounded can be gap-filled by removing
3102 them (and ``"target_index"`` for multi-return data) first, so that
3103 :meth:`gapfillMisses` places the returns on the declared raster by direction.
3106 label: Label of the data value
3109 TypeError: If ``label`` is not a str
3110 ValueError: If ``label`` is empty
3111 HeliosError: If no column exists for the label, or the label is one that
3112 synthesized misses depend on
3113 RuntimeError: If the native library predates helios-core v1.3.89
3115 self._validate_label(label)
3116 lidar_wrapper.deleteLiDARHitData(self._cloud_ptr, label)
3118 def getNominalScanGridCell(self, scanID: int, point: vec3) -> Tuple[int, int]:
3120 Scan-grid cell (row, column) a point lies in, from the scan's declared raster.
3122 The inverse of the raster the synthetic scanner fires: the point's direction from
3123 the scan origin is taken back through the scan's azimuth offset and tilt into the
3124 scanner's frame; the row is its zenith on the uniform [thetaMin, thetaMax] grid, and
3125 the column its azimuth from phiMin on the uniform [phiMin, phiMax] grid after the
3126 head's continuous rotation during each column is removed. The cell is not clamped: a
3127 point outside the declared raster maps to a row or column outside ``[0, Ntheta)`` or
3128 ``[0, Nphi)``, which :meth:`gapfillMisses` ignores.
3130 Use it to give returns ``row``/``column`` hit data when their timestamps cannot
3131 identify their pulses (rounded on export, say), so that :meth:`gapfillMisses` places
3132 the misses through its row/column path. The cells are only as good as the declared
3133 raster: the scan's angular ranges, size, tilt and azimuth offset must describe the
3137 scanID: Scan index; must be a static raster scan (not a moving, spinning
3138 multibeam or Risley-prism scan)
3139 point: (x, y, z) of the return
3142 ``(row, column)``: row is the zenith index, column the azimuth index
3145 ValueError: If ``point`` is not a vec3
3146 HeliosError: If the scan does not exist or is not a static raster scan
3147 RuntimeError: If the native library predates helios-core v1.3.89
3149 if not isinstance(point, vec3):
3150 raise ValueError(f"point must be a vec3, got {type(point).__name__}")
3151 return lidar_wrapper.getLiDARNominalScanGridCell(
3152 self._cloud_ptr, scanID, (point.x, point.y, point.z))
3154 def is_available(self) -> bool:
3156 Check if LiDAR is available in current build.
3159 True if plugin is available, False otherwise
3161 registry = get_plugin_registry()
3162 return registry.is_plugin_available('lidar')
3165# Convenience function
3166def create_lidar_cloud() -> LiDARCloud:
3168 Create LiDARCloud instance.