Source code for irsim.world.sensors.lidar2d

from math import cos, pi, sin
from typing import TYPE_CHECKING

import matplotlib.transforms as mtransforms
import numpy as np
import shapely
from matplotlib.collections import LineCollection
from mpl_toolkits.mplot3d import Axes3D
from mpl_toolkits.mplot3d.art3d import Line3DCollection
from shapely import MultiLineString

from irsim.config import palette_param
from irsim.lib.algorithm.ray_casting_2d import cast_rays
from irsim.util.random import rng
from irsim.util.util import (
    ClipTo2Pi,
    geometry_transform,
    transform_point_with_state,
)

if TYPE_CHECKING:
    from irsim.world.object_base import ObjectBase


[docs] class Lidar2D: """ Simulates a 2D Lidar sensor for detecting obstacles in the environment. Args: state (np.ndarray): Initial state of the sensor. obj_id (int): ID of the associated object. range_min (float): Minimum detection range. range_max (float): Maximum detection range. angle_range (float): Total angle range of the sensor. number (int): Number of laser beams. scan_time (float): Time taken for one complete scan. noise (bool): Whether noise is added to measurements. std (float): Standard deviation for range noise. angle_std (float): Standard deviation for angle noise. offset (list): Offset of the sensor from the object's position. alpha (float): Transparency for plotting. has_velocity (bool): Whether the sensor measures velocity. **kwargs: Additional arguments. color (str): Color of the sensor. Attr: - sensor_type (str): Type of sensor ("lidar2d"). Default is "lidar2d". - range_min (float): Minimum detection range in meters. Default is 0. A return closer than this is reported at ``range_min``: the beam counts as blocked but carries no measurement. - range_max (float): Maximum detection range in meters. Default is 10. - angle_range (float): Total angle range of the sensor in radians. Default is pi. Clipped to [0, 2*pi]. - angle_min (float): Starting angle of the sensor's scan relative to the forward direction in radians. Calculated as -angle_range / 2. - angle_max (float): Ending angle of the sensor's scan relative to the forward direction in radians. Calculated as angle_range / 2. - angle_inc (float): Angular increment between each laser beam in radians. Calculated as angle_range / (number - 1) when multiple beams are used. A single-beam sensor has no increment and points straight ahead. - number (int): Number of laser beams. Default is 100. - scan_time (float): Time taken to complete one full scan in seconds. Default is 0.1. - noise (bool): Whether to add noise to the measurements. Default is False. - std (float): Standard deviation for range noise in meters. Effective only if `noise` is True. Default is 0.2. - angle_std (float): Standard deviation of each beam's direction in radians, applied when `noise` is True. Default is 0, which keeps the directions exact. - offset (np.ndarray): Offset of the sensor relative to the object's position, formatted as [x, y, theta]. Default is [0, 0, 0]. - lidar_origin (np.ndarray): Origin position of the Lidar sensor, considering offset and the object's state. - alpha (float): Transparency level for plotting the laser beams. Default is 0.3. - has_velocity (bool): Whether the sensor measures the velocity of detected points. Default is False. - velocity (np.ndarray): Velocity data for each laser beam, formatted as (2, number) array. Effective only if `has_velocity` is True. Initialized to zeros. - time_inc (float): Time increment for each scan, simulating the sensor's time resolution. Default is 5e-4. - range_data (np.ndarray): Array storing range data for each laser beam. Initialized to `range_max` for all beams. - valid (np.ndarray): Boolean per beam, True for a usable return: a hit whose range lies inside ``[range_min, range_max]``. Misses and returns inside the blind zone are False (the latter read ``range_min``). - angle_list (np.ndarray): Array of angles corresponding to each laser beam, distributed linearly from `angle_min` to `angle_max`. - color (str): Color of the sensor's representation in visualizations. Default is ``palette_param.lidar``. - obj_id (int): ID of the associated object, used to differentiate between multiple sensors or objects in the environment. Default is 0. - plot_patch_list (list): List storing plot patches (e.g., line collections) for visualization purposes. - plot_line_list (list): List storing plot lines for visualization purposes. - plot_text_list (list): List storing plot text elements for visualization purposes. """ def __init__( self, state: np.ndarray | None = None, obj_id: int = 0, range_min: float = 0, range_max: float = 10, angle_range: float = pi, number: int = 100, scan_time: float = 0.1, noise: bool = False, std: float = 0.2, angle_std: float = 0.0, offset: list[float] | None = None, alpha: float = 0.3, has_velocity: bool = False, **kwargs, ) -> None: """ Initialize the Lidar2D sensor. """ if offset is None: offset = [0, 0, 0] self.sensor_type = "lidar2d" self.range_min = range_min self.range_max = range_max self.angle_range = ClipTo2Pi(angle_range) self.angle_min = -self.angle_range / 2 if number > 1 else 0.0 self.angle_max = self.angle_range / 2 if number > 1 else 0.0 self.angle_inc = self.angle_range / (number - 1) if number > 1 else 0.0 self.number = number self.scan_time = scan_time self.noise = noise self.std = std self.angle_std = angle_std self.offset = np.c_[offset] # Visualization params may be given under a `plot:` sub-dict # (preferred) or as flat top-level keys (backward compatible). _plot = kwargs.get("plot") or {} self._plot_cfg = _plot self.alpha = _plot.get("alpha", alpha) self.has_velocity = has_velocity self.velocity = np.zeros((2, number)) # All beams use one instantaneous geometry snapshot. self.time_inc = 0.0 self.range_data = range_max * np.ones(number) self.valid = np.zeros(number, dtype=bool) self.angle_list = np.linspace(self.angle_min, self.angle_max, num=number) self._cast_angles = self.angle_list self._state = state self.init_geometry(self._state) self.color = _plot.get("color", kwargs.get("color", palette_param.lidar)) self.obj_id = obj_id # Parent object reference (set by ObjectBase or SensorFactory) self.parent: ObjectBase | None = None self.plot_patch_list = [] self.plot_line_list = [] self.plot_text_list = [] @property def _env_param(self): """Access env_param via parent's env instance if available.""" if self.parent is not None and self.parent._env is not None: return self.parent._env._env_param from irsim.config import env_param return env_param
[docs] def init_geometry(self, state): """ Initialize the Lidar's scanning geometry. Args: state (np.ndarray): Current state of the sensor. """ self.origin_state = self.offset self._original_geometry = self._beam_geometry(self.angle_list) self.lidar_origin = transform_point_with_state(self.offset, state) self._geometry = geometry_transform(self._original_geometry, state) self._init_geometry = self._geometry
def _beam_geometry(self, angles: np.ndarray) -> MultiLineString: """Max-range beams along ``angles``, in the body frame of the carrier. Each beam runs from the sensor origin to ``range_max``; the mounting ``offset`` places the bundle on the carrier. Returns: MultiLineString: One segment per beam. """ angles = np.asarray(angles, dtype=float) ends = self.range_max * np.column_stack((np.cos(angles), np.sin(angles))) coordinates = np.stack((np.zeros_like(ends), ends), axis=1) geometry = shapely.multilinestrings(shapely.linestrings(coordinates)) return geometry_transform(geometry, self.offset)
[docs] def step(self, state: np.ndarray) -> None: """ Update the Lidar's state and compute per-beam ranges via ray casting. Each beam is intersected analytically against the boundary segments of nearby obstacles (polygons, linestrings, and map segments); the nearest hit along the beam is its range. This reproduces the previous geometry ``difference`` result to floating-point precision when the sensor origin is in free space, while avoiding the expensive GEOS overlay. Args: state (np.ndarray): New state of the sensor. """ self._state = state lidar_geometry = self._world_geometry(state) detected_objects = self._get_detected_objects(lidar_geometry) ranges, hit_object_indices, origin, directions = cast_rays( lidar_geometry, detected_objects, self.range_max, ) hits = hit_object_indices >= 0 if self.noise and hits.any(): ranges = np.array(ranges, dtype=float, copy=True) ranges[hits] += rng.normal(0, self.std, int(hits.sum())) in_band = (ranges >= self.range_min) & (ranges <= self.range_max) self.valid[:] = hits & in_band self.range_data[:] = np.clip(ranges, self.range_min, self.range_max) self._rebuild_scan_geometry(origin, directions) if self.has_velocity: self._assign_velocities(hit_object_indices, detected_objects)
def _get_detected_objects(self, lidar_geometry) -> list: """Select objects that may produce a return for this lidar geometry. This is the environment-facing broad-phase operation. It owns access to the scene's complete object list and geometry tree, and filters objects that sensors must ignore. Exact boundary intersections remain in the geometry-only ray-casting operation. """ objects = self._env_param.objects geometry_tree = self._env_param.GeometryTree if geometry_tree is None: return [] detected_objects = [] for object_index in geometry_tree.query(lidar_geometry): obj = objects[object_index] if obj._id == self.obj_id or not obj._geometry_valid or obj.unobstructed: continue detected_objects.append(obj) return detected_objects def _world_geometry(self, state: np.ndarray) -> MultiLineString: """Build the max-range beam geometry in world coordinates. With ``noise`` on and ``angle_std > 0`` every beam is cast slightly off its nominal direction; the scan still reports the nominal angles, as a real sensor does. """ beams = self._original_geometry self._cast_angles = self.angle_list if self.noise and self.angle_std > 0: jitter = rng.normal(0, self.angle_std, self.number) self._cast_angles = self.angle_list + jitter beams = self._beam_geometry(self._cast_angles) world_geometry = geometry_transform(beams, state) self.lidar_origin = transform_point_with_state(self.offset, state) # Use the beam geometry's exact start coordinate. Computing the same # point through a separate transform can differ by one floating-point # step across platforms, which breaks exact GEOS origin predicates. self.lidar_origin[:2, 0] = shapely.get_coordinates(world_geometry)[0] return world_geometry @property def cast_angles(self) -> np.ndarray: """Per-beam angles the last scan was cast along, in the sensor frame. Equal to ``angle_list`` unless angle noise is on, in which case each beam carries that step's jitter. Consumers that pair a measured range with a direction (fog reveal, drawn beams) use these; the scan itself reports the nominal ``angle_list``, as a real sensor does. """ return self._cast_angles def _rebuild_scan_geometry( self, origin: np.ndarray, directions: np.ndarray ) -> None: """Rebuild each clipped beam from its origin and measured range.""" # Kept for consumers that need the directions the beams were actually # cast along (they differ from the nominal angles under angle noise). self._beam_origin = origin self._beam_directions = directions endpoints = origin + self.range_data[:, None] * directions origins = np.broadcast_to(origin, endpoints.shape) beam_coordinates = np.stack([origins, endpoints], axis=1) self._geometry = shapely.multilinestrings( shapely.linestrings(beam_coordinates), ) def _assign_velocities( self, hit_object_indices: np.ndarray, detected_objects, ) -> None: """Assign velocity when ray casting reports an actual object hit. ``hit_object_indices`` refers to ``detected_objects`` and distinguishes a hit from a max-range miss, so no range margin is needed near ``range_max``. """ self.velocity[:] = 0.0 for beam_index in np.flatnonzero(hit_object_indices >= 0): object_velocity = detected_objects[ hit_object_indices[beam_index] ].velocity_xy self.velocity[:, beam_index : beam_index + 1] = object_velocity
[docs] def get_scan(self): """ Get the 2D lidar scan data. refer to the ros topic scan: http://docs.ros.org/en/melodic/api/sensor_msgs/html/msg/LaserScan.html Returns: dict: Scan data including angles, ranges, and velocities. """ scan_data = {} scan_data["angle_min"] = self.angle_min scan_data["angle_max"] = self.angle_max scan_data["angle_increment"] = self.angle_inc scan_data["time_increment"] = self.time_inc scan_data["scan_time"] = self.scan_time scan_data["range_min"] = self.range_min scan_data["range_max"] = self.range_max scan_data["ranges"] = self.range_data scan_data["intensities"] = None scan_data["velocity"] = self.velocity scan_data["valid"] = self.valid return scan_data
[docs] def get_points(self): """ Convert scan data to a point cloud. Returns: np.ndarray: Point cloud (2xN). """ return self.scan_to_pointcloud()
[docs] def get_offset(self): """ Get the sensor's offset. Returns: list: Offset as a list. """ return np.squeeze(self.offset).tolist()
[docs] def plot(self, ax, state: np.ndarray | None = None, **kwargs): """ Plot the Lidar's detected lines on a given axis. """ if state is None: state = self.state self._plot(ax, state, **kwargs)
def _init_plot(self, ax, **kwargs): """ Initialize the plot for the Lidar. """ self._plot(ax, self.origin_state, **kwargs) @property def state(self) -> np.ndarray: """ Get the current state of the lidar sensor. Returns: np.ndarray: Current state of the sensor. """ return self._state def _plot(self, ax, state, **kwargs): """ Plot the Lidar's detected lines using the specified state for positioning. Creates line segments in local coordinates and applies transforms to position them. Args: ax: Matplotlib axis. state: State vector [x, y, theta, ...] defining lidar position and orientation. **kwargs: Plotting options. """ lines = [] if isinstance(ax, Axes3D): # For 3D plotting, calculate actual world coordinates since transforms don't work the same way if state is not None and len(state) > 0: # Calculate lidar position based on object state and sensor offset lidar_x = self.lidar_origin[0, 0] lidar_y = self.lidar_origin[1, 0] lidar_theta = ( self.lidar_origin[2, 0] if self.lidar_origin.shape[0] > 2 else 0 ) else: lidar_x, lidar_y, lidar_theta = 0, 0, 0 # Create line segments in world coordinates for 3D for i in range(self.number): x_local = self.range_data[i] * cos(self.angle_list[i]) y_local = self.range_data[i] * sin(self.angle_list[i]) # Transform to world coordinates x_world = ( lidar_x + x_local * cos(lidar_theta) - y_local * sin(lidar_theta) ) y_world = ( lidar_y + x_local * sin(lidar_theta) + y_local * cos(lidar_theta) ) start_point = np.array([lidar_x, lidar_y, 0]) end_point = np.array([x_world, y_world, 0]) segment = [start_point, end_point] lines.append(segment) self.laser_LineCollection = Line3DCollection( lines, linewidths=1, colors=self.color, alpha=self.alpha, zorder=2 ) ax.add_collection3d(self.laser_LineCollection) else: # For 2D plotting, create line segments in local coordinates and use transforms for i in range(self.number): x = self.range_data[i] * cos(self.angle_list[i]) y = self.range_data[i] * sin(self.angle_list[i]) segment = [np.array([0, 0]), np.array([x, y])] lines.append(segment) self.laser_LineCollection = LineCollection( lines, linewidths=1, colors=self.color, alpha=self.alpha, zorder=2 ) ax.add_collection(self.laser_LineCollection) # Apply transform for 2D case - use provided state for positioning if state is not None and len(state) > 0: lidar_x = self.lidar_origin[0, 0] lidar_y = self.lidar_origin[1, 0] lidar_theta = ( self.lidar_origin[2, 0] if self.lidar_origin.shape[0] > 2 else 0 ) # Create transform: rotate by lidar orientation, then translate to lidar position trans = ( mtransforms.Affine2D() .rotate(lidar_theta) .translate(lidar_x, lidar_y) + ax.transData ) self.laser_LineCollection.set_transform(trans) self.plot_patch_list.append(self.laser_LineCollection) def _step_plot(self): """ Update the lidar visualization using matplotlib transforms based on current state. Creates line segments in local coordinates and applies transform to position them. """ if not hasattr(self, "laser_LineCollection"): return ax = self.laser_LineCollection.axes lines = [] if ax is None: return if isinstance(ax, Axes3D): # For 3D plotting, calculate actual world coordinates lidar_x = self.lidar_origin[0, 0] lidar_y = self.lidar_origin[1, 0] lidar_theta = ( self.lidar_origin[2, 0] if self.lidar_origin.shape[0] > 2 else 0 ) # Create line segments in world coordinates for 3D for i in range(self.number): x_local = self.range_data[i] * cos(self.angle_list[i]) y_local = self.range_data[i] * sin(self.angle_list[i]) # Transform to world coordinates x_world = ( lidar_x + x_local * cos(lidar_theta) - y_local * sin(lidar_theta) ) y_world = ( lidar_y + x_local * sin(lidar_theta) + y_local * cos(lidar_theta) ) start_point = np.array([lidar_x, lidar_y, 0]) end_point = np.array([x_world, y_world, 0]) segment = [start_point, end_point] lines.append(segment) else: # For 2D plotting, create line segments in local coordinates for i in range(self.number): x = self.range_data[i] * cos(self.angle_list[i]) y = self.range_data[i] * sin(self.angle_list[i]) segment = [np.array([0, 0]), np.array([x, y])] lines.append(segment) # Update line segments self.laser_LineCollection.set_segments(lines) # Apply transform to position the LineCollection based on current lidar origin (2D only) if not isinstance(ax, Axes3D): # 2D case lidar_x = self.lidar_origin[0, 0] lidar_y = self.lidar_origin[1, 0] lidar_theta = ( self.lidar_origin[2, 0] if self.lidar_origin.shape[0] > 2 else 0 ) # Create transform: rotate by lidar orientation, then translate to lidar position trans = ( mtransforms.Affine2D().rotate(lidar_theta).translate(lidar_x, lidar_y) + ax.transData ) self.laser_LineCollection.set_transform(trans)
[docs] def step_plot(self): """ Public method to update the lidar visualization, calls _step_plot. """ self._step_plot()
[docs] def set_laser_color( self, laser_indices, laser_color: str | None = None, alpha: float = 0.3, ): """ Set a specific color of the selected lasers. Args: laser_indices (list): The indices of the lasers to set the color. laser_color (str): The color to set the selected lasers. Default is ``palette_param.laser_highlight``. alpha (float): The transparency of the lasers. Default is 0.3. """ if laser_color is None: laser_color = palette_param.laser_highlight current_color = [self.color] * self.number current_alpha = [self.alpha] * self.number for index in laser_indices: if index < self.number: current_color[index] = laser_color current_alpha[index] = alpha self.laser_LineCollection.set_color(current_color) self.laser_LineCollection.set_alpha(current_alpha)
[docs] def plot_clear(self): """ Clear the plot elements from the axis. """ [patch.remove() for patch in self.plot_patch_list] [line.pop(0).remove() for line in self.plot_line_list] [text.remove() for text in self.plot_text_list] self.plot_patch_list = [] self.plot_line_list = [] self.plot_text_list = []
[docs] def scan_to_pointcloud(self): """ Convert the Lidar scan data to a point cloud. Only beams with a usable return (``valid``) become points, at their nominal angles; misses, returns inside the blind zone and returns pushed out of band by noise are left out. Returns: np.ndarray: Point cloud (2xN), or ``None`` when no beam is valid. """ point_cloud = [] ranges = self.range_data angles = np.linspace(self.angle_min, self.angle_max, len(ranges)) for i in range(len(ranges)): scan_range = ranges[i] angle = angles[i] if self.valid[i] and scan_range < (self.range_max - 0.02): point = np.array([[scan_range * cos(angle)], [scan_range * sin(angle)]]) point_cloud.append(point) if len(point_cloud) == 0: return None return np.hstack(point_cloud)