55 Initialize LiDARCloud.
58 LiDARError: If plugin not available in current build
59 RuntimeError: If cloud initialization fails
62 registry = get_plugin_registry()
63 if not registry.is_plugin_available(
'lidar'):
65 "LiDAR plugin not available. Rebuild PyHelios with LiDAR:\n"
66 " build_scripts/build_helios --plugins lidar\n"
68 "System requirements:\n"
69 " - Platforms: Windows, Linux, macOS\n"
70 " - GPU: Optional (enables GPU acceleration)"
75 raise LiDARError(
"Failed to create LiDAR cloud")
78 """Context manager entry"""
81 def __exit__(self, exc_type, exc_val, exc_tb):
82 """Context manager exit - cleanup resources"""
83 if hasattr(self,
'_cloud_ptr')
and self.
_cloud_ptr:
84 lidar_wrapper.destroyLiDARcloud(self.
_cloud_ptr)
88 """Fallback destructor for cleanup without context manager"""
89 if hasattr(self, '_cloud_ptr') and self._cloud_ptr is not None:
91 lidar_wrapper.destroyLiDARcloud(self._cloud_ptr)
92 self._cloud_ptr = None
93 except Exception as e:
95 warnings.warn(f"Error in LiDARCloud.__del__: {e}")
97 def addScan(self, origin: Union[vec3, List[float], Tuple[float, float, float]],
98 Ntheta: int, theta_range: Tuple[float, float],
99 Nphi: int, phi_range: Tuple[float, float],
100 exit_diameter: float, beam_divergence: float) -> int:
102 Add a LiDAR scan to the point cloud.
105 origin: Scanner position (vec3 or 3-element list/tuple)
106 Ntheta: Number of scan points in zenith direction
107 theta_range: Zenith angle range (min, max) in radians
108 Nphi: Number of scan points in azimuthal direction
109 phi_range: Azimuthal angle range (min, max) in radians
110 exit_diameter: Laser beam exit diameter (meters)
111 beam_divergence: Beam divergence angle (radians)
114 Scan ID for referencing this scan
117 >>> scan_id = lidar.addScan(
118 ... origin=vec3(0, 0, 1),
119 ... Ntheta=100, theta_range=(0, 1.57),
120 ... Nphi=100, phi_range=(-3.14, 3.14),
121 ... exit_diameter=0.01, beam_divergence=0.001
124 # Convert origin to vec3 if needed
125 if isinstance(origin, (list, tuple)):
127 raise ValueError("Origin must have 3 elements [x, y, z]")
128 origin = vec3(*origin)
129 elif not hasattr(origin, 'x'):
130 raise ValueError("Origin must be vec3 or 3-element list/tuple")
132 origin_list = [origin.x, origin.y, origin.z]
134 # Validate scan parameters
135 validate_positive_value(Ntheta, 'Ntheta', 'addScan')
136 validate_positive_value(Nphi, 'Nphi', 'addScan')
138 if not isinstance(theta_range, (list, tuple)) or len(theta_range) != 2:
139 raise ValueError("theta_range must be a tuple (min, max)")
140 if not isinstance(phi_range, (list, tuple)) or len(phi_range) != 2:
141 raise ValueError("phi_range must be a tuple (min, max)")
143 return lidar_wrapper.addLiDARScan(
144 self._cloud_ptr, origin_list, Ntheta, theta_range,
145 Nphi, phi_range, exit_diameter, beam_divergence
148 def getScanCount(self) -> int:
149 """Get total number of scans in the cloud"""
150 return lidar_wrapper.getLiDARScanCount(self._cloud_ptr)
152 def getScanOrigin(self, scanID: int) -> vec3:
153 """Get origin of a specific scan"""
155 raise ValueError("Scan ID must be non-negative")
156 origin_list = lidar_wrapper.getLiDARScanOrigin(self._cloud_ptr, scanID)
157 return vec3(*origin_list)
159 def getScanSizeTheta(self, scanID: int) -> int:
160 """Get number of zenith scan points for a scan"""
162 raise ValueError("Scan ID must be non-negative")
163 return lidar_wrapper.getLiDARScanSizeTheta(self._cloud_ptr, scanID)
165 def getScanSizePhi(self, scanID: int) -> int:
166 """Get number of azimuthal scan points for a scan"""
168 raise ValueError("Scan ID must be non-negative")
169 return lidar_wrapper.getLiDARScanSizePhi(self._cloud_ptr, scanID)
171 def addHitPoint(self, scanID: int,
172 xyz: Union[vec3, List[float], Tuple[float, float, float]],
173 direction: Union[vec3, SphericalCoord, List[float], Tuple[float, float]],
174 color: Optional[Union[RGBcolor, List[float], Tuple[float, float, float]]] = None):
176 Add a hit point to the point cloud.
179 scanID: Scan ID this hit belongs to
180 xyz: Hit point coordinates (vec3 or 3-element list)
181 direction: Ray direction (vec3/SphericalCoord or 2-3 element list)
182 color: Optional RGB color (RGBcolor or 3-element list)
184 # Convert xyz to list
185 if isinstance(xyz, (list, tuple)):
187 raise ValueError("XYZ must have 3 elements")
189 elif hasattr(xyz, 'x'):
190 xyz_list = [xyz.x, xyz.y, xyz.z]
192 raise ValueError("XYZ must be vec3 or 3-element list/tuple")
194 # Convert direction to list
195 if isinstance(direction, (list, tuple)):
196 if len(direction) < 2:
197 raise ValueError("Direction must have at least 2 elements [radius, elevation]")
198 direction_list = list(direction)
199 elif hasattr(direction, 'radius'): # SphericalCoord
200 direction_list = [direction.radius, direction.elevation, direction.azimuth]
201 elif hasattr(direction, 'x'): # vec3
202 direction_list = [direction.x, direction.y, direction.z]
204 raise ValueError("Direction must be vec3/SphericalCoord or 2-3 element list")
206 # Add with or without color
207 if color is not None:
208 if isinstance(color, (list, tuple)):
210 raise ValueError("Color must have 3 elements [r, g, b]")
211 color_list = list(color)
212 elif hasattr(color, 'r'):
213 color_list = [color.r, color.g, color.b]
215 raise ValueError("Color must be RGBcolor or 3-element list")
217 lidar_wrapper.addLiDARHitPointRGB(self._cloud_ptr, scanID, xyz_list, direction_list, color_list)
219 lidar_wrapper.addLiDARHitPoint(self._cloud_ptr, scanID, xyz_list, direction_list)
221 def getHitCount(self) -> int:
222 """Get total number of hit points in cloud"""
223 return lidar_wrapper.getLiDARHitCount(self._cloud_ptr)
225 def getHitXYZ(self, index: int) -> vec3:
226 """Get coordinates of a hit point"""
228 raise ValueError("Index must be non-negative")
229 xyz_list = lidar_wrapper.getLiDARHitXYZ(self._cloud_ptr, index)
230 return vec3(*xyz_list)
232 def getHitRaydir(self, index: int) -> SphericalCoord:
233 """Get ray direction of a hit point"""
235 raise ValueError("Index must be non-negative")
236 direction_list = lidar_wrapper.getLiDARHitRaydir(self._cloud_ptr, index)
237 return SphericalCoord(direction_list[0], direction_list[1])
239 def getHitColor(self, index: int) -> RGBcolor:
240 """Get color of a hit point"""
242 raise ValueError("Index must be non-negative")
243 color_list = lidar_wrapper.getLiDARHitColor(self._cloud_ptr, index)
244 return RGBcolor(*color_list)
246 def deleteHitPoint(self, index: int):
247 """Delete a hit point from the cloud"""
249 raise ValueError("Index must be non-negative")
250 lidar_wrapper.deleteLiDARHitPoint(self._cloud_ptr, index)
252 def coordinateShift(self, shift: Union[vec3, List[float], Tuple[float, float, float]]):
254 Translate all hit points by a shift vector.
257 shift: Translation vector (vec3 or 3-element list)
259 if isinstance(shift, (list, tuple)):
261 raise ValueError("Shift must have 3 elements [x, y, z]")
262 shift_list = list(shift)
263 elif hasattr(shift, 'x'):
264 shift_list = [shift.x, shift.y, shift.z]
266 raise ValueError("Shift must be vec3 or 3-element list/tuple")
268 lidar_wrapper.lidarCoordinateShift(self._cloud_ptr, shift_list)
270 def coordinateRotation(self, rotation: Union[SphericalCoord, List[float], Tuple[float, float]]):
272 Rotate all hit points by spherical rotation angles.
275 rotation: Rotation angles (SphericalCoord or 2-3 element list)
277 if isinstance(rotation, (list, tuple)):
278 if len(rotation) < 2:
279 raise ValueError("Rotation must have at least 2 elements [radius, elevation]")
280 rotation_list = list(rotation)
281 elif hasattr(rotation, 'radius'):
282 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
284 raise ValueError("Rotation must be SphericalCoord or 2-3 element list")
286 lidar_wrapper.lidarCoordinateRotation(self._cloud_ptr, rotation_list)
288 def triangulateHitPoints(self, Lmax: float, max_aspect_ratio: float = 4.0):
290 Generate triangle mesh from hit points using Delaunay triangulation.
293 Lmax: Maximum triangle edge length
294 max_aspect_ratio: Maximum triangle aspect ratio (default 4.0)
296 validate_positive_value(Lmax, 'Lmax', 'triangulateHitPoints')
297 validate_positive_value(max_aspect_ratio, 'max_aspect_ratio', 'triangulateHitPoints')
298 lidar_wrapper.lidarTriangulateHitPoints(self._cloud_ptr, Lmax, max_aspect_ratio)
300 def getTriangleCount(self) -> int:
301 """Get number of triangles in the mesh"""
302 return lidar_wrapper.getLiDARTriangleCount(self._cloud_ptr)
304 def distanceFilter(self, maxdistance: float):
305 """Filter hit points by maximum distance from scanne
r"""
306 validate_positive_value(maxdistance, 'maxdistance', 'distanceFilter')
307 lidar_wrapper.lidarDistanceFilter(self._cloud_ptr, maxdistance)
309 def reflectanceFilter(self, minreflectance: float):
310 """Filter hit points by minimum reflectance value"""
311 lidar_wrapper.lidarReflectanceFilter(self._cloud_ptr, minreflectance)
313 def firstHitFilter(self):
314 """Keep only first return hit points"""
315 lidar_wrapper.lidarFirstHitFilter(self._cloud_ptr)
317 def lastHitFilter(self):
318 """Keep only last return hit points"""
319 lidar_wrapper.lidarLastHitFilter(self._cloud_ptr)
321 def exportPointCloud(self, filename: str):
322 """Export point cloud to ASCII file"""
324 raise ValueError("Filename cannot be empty")
325 lidar_wrapper.exportLiDARPointCloud(self._cloud_ptr, filename)
327 def loadXML(self, filename: str):
328 """Load scan metadata from XML file"""
330 raise ValueError("Filename cannot be empty")
331 lidar_wrapper.loadLiDARXML(self._cloud_ptr, filename)
333 def disableMessages(self):
334 """Disable console output messages"""
335 lidar_wrapper.lidarDisableMessages(self._cloud_ptr)
337 def enableMessages(self):
338 """Enable console output messages"""
339 lidar_wrapper.lidarEnableMessages(self._cloud_ptr)
341 def addGrid(self, center: Union[vec3, List[float], Tuple[float, float, float]],
342 size: Union[vec3, List[float], Tuple[float, float, float]],
343 ndiv: Union[List[int], Tuple[int, int, int]],
344 rotation: float = 0.0):
346 Add a rectangular grid of voxel cells.
349 center: Grid center position (vec3 or 3-element list)
350 size: Grid dimensions [x, y, z] (vec3 or 3-element list)
351 ndiv: Number of divisions [nx, ny, nz] (3-element list)
352 rotation: Azimuthal rotation angle (radians, default 0.0)
356 ... center=vec3(0, 0, 0.5),
357 ... size=vec3(10, 10, 1),
358 ... ndiv=[10, 10, 5],
362 # Convert center to list
363 if isinstance(center, (list, tuple)):
365 raise ValueError("Center must have 3 elements [x, y, z]")
366 center_list = list(center)
367 elif hasattr(center, 'x'):
368 center_list = [center.x, center.y, center.z]
370 raise ValueError("Center must be vec3 or 3-element list/tuple")
372 # Convert size to list
373 if isinstance(size, (list, tuple)):
375 raise ValueError("Size must have 3 elements [x, y, z]")
376 size_list = list(size)
377 elif hasattr(size, 'x'):
378 size_list = [size.x, size.y, size.z]
380 raise ValueError("Size must be vec3 or 3-element list/tuple")
383 if not isinstance(ndiv, (list, tuple)) or len(ndiv) != 3:
384 raise ValueError("Ndiv must be a 3-element list [nx, ny, nz]")
386 lidar_wrapper.addLiDARGrid(self._cloud_ptr, center_list, size_list, list(ndiv), rotation)
388 def addGridCell(self, center: Union[vec3, List[float], Tuple[float, float, float]],
389 size: Union[vec3, List[float], Tuple[float, float, float]],
390 rotation: float = 0.0):
392 Add a single grid cell.
395 center: Cell center position (vec3 or 3-element list)
396 size: Cell dimensions [x, y, z] (vec3 or 3-element list)
397 rotation: Azimuthal rotation angle (radians, default 0.0)
399 # Convert center to list
400 if isinstance(center, (list, tuple)):
402 raise ValueError("Center must have 3 elements [x, y, z]")
403 center_list = list(center)
404 elif hasattr(center, 'x'):
405 center_list = [center.x, center.y, center.z]
407 raise ValueError("Center must be vec3 or 3-element list/tuple")
409 # Convert size to list
410 if isinstance(size, (list, tuple)):
412 raise ValueError("Size must have 3 elements [x, y, z]")
413 size_list = list(size)
414 elif hasattr(size, 'x'):
415 size_list = [size.x, size.y, size.z]
417 raise ValueError("Size must be vec3 or 3-element list/tuple")
419 lidar_wrapper.addLiDARGridCell(self._cloud_ptr, center_list, size_list, rotation)
421 def getGridCellCount(self) -> int:
422 """Get total number of grid cells"""
423 return lidar_wrapper.getLiDARGridCellCount(self._cloud_ptr)
425 def getCellCenter(self, index: int) -> vec3:
426 """Get center position of a grid cell"""
428 raise ValueError("Index must be non-negative")
429 center_list = lidar_wrapper.getLiDARCellCenter(self._cloud_ptr, index)
430 return vec3(*center_list)
432 def getCellSize(self, index: int) -> vec3:
433 """Get size of a grid cell"""
435 raise ValueError("Index must be non-negative")
436 size_list = lidar_wrapper.getLiDARCellSize(self._cloud_ptr, index)
437 return vec3(*size_list)
439 def getCellLeafArea(self, index: int) -> float:
440 """Get leaf area of a grid cell (m²)"""
442 raise ValueError("Index must be non-negative")
443 return lidar_wrapper.getLiDARCellLeafArea(self._cloud_ptr, index)
445 def getCellLeafAreaDensity(self, index: int) -> float:
446 """Get leaf area density of a grid cell (m²/m³)"""
448 raise ValueError("Index must be non-negative")
449 return lidar_wrapper.getLiDARCellLeafAreaDensity(self._cloud_ptr, index)
451 def getCellGtheta(self, index: int) -> float:
452 """Get G(theta) value for a grid cell"""
454 raise ValueError("Index must be non-negative")
455 return lidar_wrapper.getLiDARCellGtheta(self._cloud_ptr, index)
457 def setCellGtheta(self, Gtheta: float, index: int):
458 """Set G(theta) value for a grid cell"""
460 raise ValueError("Index must be non-negative")
461 lidar_wrapper.setLiDARCellGtheta(self._cloud_ptr, Gtheta, index)
463 def calculateHitGridCell(self):
464 """Calculate hit point grid cell assignments"""
465 lidar_wrapper.calculateLiDARHitGridCell(self._cloud_ptr)
467 def gapfillMisses(self):
469 Gapfill sky/miss points where rays didn't hit geometry.
471 Important for accurate leaf area calculations with real LiDAR data.
472 Should be called before triangulation when processing real data.
474 lidar_wrapper.gapfillLiDARMisses(self._cloud_ptr)
476 def syntheticScan(self, context: Context,
477 rays_per_pulse: Optional[int] = None,
478 pulse_distance_threshold: Optional[float] = None,
479 scan_grid_only: bool = False,
480 record_misses: bool = True,
481 append: bool = False):
483 Perform synthetic LiDAR scan of geometry in Context.
485 Requires scan metadata to be defined first via addScan() or loadXML().
486 Uses ray tracing to simulate LiDAR instrument measurements.
489 context: Helios Context containing geometry to scan
490 rays_per_pulse: Number of rays per pulse (None=discrete-return, typical: 100)
491 pulse_distance_threshold: Distance threshold for aggregating hits (meters, required for waveform)
492 scan_grid_only: If True, only scan within defined grid cells
493 record_misses: If True, record miss/sky points where rays don't hit geometry
494 append: If True, append to existing hits; if False, clear existing hits
496 Example (Discrete-return):
497 >>> from pyhelios import Context, LiDARCloud
498 >>> from pyhelios.types import vec3
499 >>> with Context() as context:
501 ... context.addPatch(center=vec3(0, 0, 0.5), size=vec2(1, 1))
503 ... with LiDARCloud() as lidar:
504 ... # Define scan parameters
505 ... scan_id = lidar.addScan(
506 ... origin=vec3(0, 0, 2),
507 ... Ntheta=100, theta_range=(0, 1.57),
508 ... Nphi=100, phi_range=(0, 6.28),
509 ... exit_diameter=0, beam_divergence=0
512 ... # Perform discrete-return scan
513 ... lidar.syntheticScan(context)
515 Example (Full-waveform):
516 >>> lidar.syntheticScan(
518 ... rays_per_pulse=100,
519 ... pulse_distance_threshold=0.02,
520 ... record_misses=True
523 if not isinstance(context, Context):
524 raise TypeError("context must be a Context instance")
526 context_ptr = context.getNativePtr()
528 # Discrete-return mode (single ray per pulse)
529 if rays_per_pulse is None:
530 # Use append-aware version to ensure explicit control
531 lidar_wrapper.syntheticLiDARScanAppend(self._cloud_ptr, context_ptr, append)
533 # Full-waveform mode (multiple rays per pulse)
534 if pulse_distance_threshold is None:
535 raise ValueError("pulse_distance_threshold required for full-waveform scanning")
537 validate_positive_value(rays_per_pulse, 'rays_per_pulse', 'syntheticScan')
538 validate_positive_value(pulse_distance_threshold, 'pulse_distance_threshold', 'syntheticScan')
540 lidar_wrapper.syntheticLiDARScanFull(
541 self._cloud_ptr, context_ptr,
542 rays_per_pulse, pulse_distance_threshold,
543 scan_grid_only, record_misses, append
546 def calculateLeafArea(self, context: Context, min_voxel_hits: Optional[int] = None):
548 Calculate leaf area for each grid cell.
550 Requires triangulation to have been performed first.
553 context: Helios Context instance
554 min_voxel_hits: Optional minimum number of hits required per voxel
557 >>> from pyhelios import Context, LiDARCloud
558 >>> with Context() as context:
559 ... with LiDARCloud() as lidar:
560 ... # ... load data, add grid, triangulate ...
561 ... lidar.calculateLeafArea(context)
563 if not isinstance(context, Context):
564 raise TypeError("context must be a Context instance")
566 context_ptr = context.getNativePtr()
567 if min_voxel_hits is None:
568 lidar_wrapper.calculateLiDARLeafArea(self._cloud_ptr, context_ptr)
570 lidar_wrapper.calculateLiDARLeafAreaMinHits(self._cloud_ptr, context_ptr, min_voxel_hits)
572 def calculateSyntheticLeafArea(self, context: Context):
574 Calculate synthetic leaf area (for validation of synthetic scans).
576 Uses exact primitive geometry to calculate leaf area, useful for
577 validating synthetic scan accuracy.
580 context: Helios Context instance containing primitive geometry
582 if not isinstance(context, Context):
583 raise TypeError("context must be a Context instance")
584 context_ptr = context.getNativePtr()
585 lidar_wrapper.calculateSyntheticLiDARLeafArea(self._cloud_ptr, context_ptr)
587 def calculateSyntheticGtheta(self, context: Context):
589 Calculate synthetic G(theta) (for validation of synthetic scans).
591 Uses exact primitive geometry to calculate G(theta), useful for
592 validating synthetic scan accuracy.
595 context: Helios Context instance containing primitive geometry
597 if not isinstance(context, Context):
598 raise TypeError("context must be a Context instance")
599 context_ptr = context.getNativePtr()
600 lidar_wrapper.calculateSyntheticLiDARGtheta(self._cloud_ptr, context_ptr)
602 def exportTriangleNormals(self, filename: str):
603 """Export triangle normal vectors to file"""
605 raise ValueError("Filename cannot be empty")
606 lidar_wrapper.exportLiDARTriangleNormals(self._cloud_ptr, filename)
608 def exportTriangleAreas(self, filename: str):
609 """Export triangle areas to file"""
611 raise ValueError("Filename cannot be empty")
612 lidar_wrapper.exportLiDARTriangleAreas(self._cloud_ptr, filename)
614 def exportLeafAreas(self, filename: str):
615 """Export leaf areas for each grid cell to file"""
617 raise ValueError("Filename cannot be empty")
618 lidar_wrapper.exportLiDARLeafAreas(self._cloud_ptr, filename)
620 def exportLeafAreaDensities(self, filename: str):
621 """Export leaf area densities for each grid cell to file"""
623 raise ValueError("Filename cannot be empty")
624 lidar_wrapper.exportLiDARLeafAreaDensities(self._cloud_ptr, filename)
626 def exportGtheta(self, filename: str):
627 """Export G(theta) values for each grid cell to file"""
629 raise ValueError("Filename cannot be empty")
630 lidar_wrapper.exportLiDARGtheta(self._cloud_ptr, filename)
632 def addTrianglesToContext(self, context: Context):
634 Add triangulated mesh to Context as triangle primitives.
636 Converts the triangulated point cloud mesh into Context triangle
637 primitives that can be used for further analysis or visualization.
640 context: Helios Context instance
643 >>> with Context() as context:
644 ... with LiDARCloud() as lidar:
645 ... lidar.loadXML("scan.xml")
646 ... lidar.triangulateHitPoints(Lmax=0.5, max_aspect_ratio=5)
647 ... lidar.addTrianglesToContext(context)
648 ... print(f"Added {context.getPrimitiveCount()} triangles to context")
650 if not isinstance(context, Context):
651 raise TypeError("context must be a Context instance")
652 lidar_wrapper.addLiDARTrianglesToContext(self._cloud_ptr, context.getNativePtr())
654 def initializeCollisionDetection(self, context: Context):
656 Initialize CollisionDetection plugin for ray tracing.
658 Required before performing synthetic scans.
661 context: Helios Context instance containing geometry
663 if not isinstance(context, Context):
664 raise TypeError("context must be a Context instance")
665 lidar_wrapper.initializeLiDARCollisionDetection(self._cloud_ptr, context.getNativePtr())
667 def enableCDGPUAcceleration(self):
668 """Enable GPU acceleration for collision detection ray tracing"""
669 lidar_wrapper.enableLiDARCDGPUAcceleration(self._cloud_ptr)
671 def disableCDGPUAcceleration(self):
672 """Disable GPU acceleration (use CPU ray tracing)"""
673 lidar_wrapper.disableLiDARCDGPUAcceleration(self._cloud_ptr)
675 def is_available(self) -> bool:
677 Check if LiDAR is available in current build.
680 True if plugin is available, False otherwise
682 registry = get_plugin_registry()
683 return registry.is_plugin_available('lidar')
686# Convenience function