Source code for mimic_data_api.lidar

"""
LiDAR Module
============

This module provides data structures for representing LiDAR (Light Detection and Ranging)
sensors, including intrinsic parameters, point cloud data, and calibration information.

Supports both rotating (mechanical) and solid-state LiDAR sensors with configurable
resolution parameters.

Classes
-------
VerticalResolution
    Vertical angular resolution specification.
LidarIntrinsics
    Internal LiDAR sensor parameters.
LidarPointCloudData
    Metadata and optional point cloud data for a single scan.
Lidar
    Complete LiDAR sensor representation.

Type Aliases
------------
LidarType
    Literal type for LiDAR technology: "rotating" or "solid-state".
LidarRotationType
    Literal type for rotation direction: "clockwise" or "counter-clockwise".

Example
-------
Creating a LiDAR sensor::

    import numpy as np
    from mimic_data_api.lidar import (
        Lidar, LidarIntrinsics, VerticalResolution, LidarPointCloudData
    )
    from mimic_data_api.trajectory_numpy import WorldTrajectory

    lidar = Lidar(
        id=1,
        name="top_lidar",
        extrinsics=np.eye(4),
        intrinsics=LidarIntrinsics(
            lidar_type="rotating",
            rotation_type="clockwise",
            vertical_fov=0.7,
            horizontal_fov=2 * np.pi,
            horizontal_resolution=0.002,
            vertical_resolution=VerticalResolution(vertical_resolution=0.035),
            max_range=120.0,
            min_range=0.5
        ),
        trajectory=trajectory,
        lidar_pcds_data=[...],
        min_cut_off_range=2.0
    )
"""

from __future__ import annotations

from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field, StrictBool, model_validator

from mimic_data_api.extrinsic import SensorExtrinsic
from mimic_data_api.trajectory_numpy import WorldTrajectory
from mimic_data_api import LidarPointCloud
import numpy as np


[docs] class VerticalResolution(BaseModel): """Vertical angular resolution specification for LiDAR sensors. LiDAR sensors can specify vertical resolution in two ways: 1. A single uniform resolution value (radians per point, i.e. the angular step between adjacent beams) 2. A per-channel elevation angle mapping (for sensors with discrete beam angles) Exactly one of these must be provided. Attributes ---------- vertical_resolution : float or None Uniform vertical resolution in radians per point (angular step between adjacent beams), e.g. ``np.deg2rad(0.33)`` for a 0.33-degree step. Use this for sensors with continuous or uniform vertical spacing. elevation_channel_mapping : dict[int, float] or None Mapping from channel index (0 to N-1) to elevation angle in radians. Use this for sensors with discrete beam angles (e.g., Velodyne, Ouster). Raises ------ ValueError If neither or both resolution specifications are provided. Example ------- Uniform resolution:: >>> res = VerticalResolution(vertical_resolution=0.035) Per-channel mapping (e.g., 4-beam LiDAR):: >>> res = VerticalResolution( ... elevation_channel_mapping={ ... 0: -0.26, # -15 degrees ... 1: -0.09, # -5 degrees ... 2: 0.09, # +5 degrees ... 3: 0.26 # +15 degrees ... } ... ) """ vertical_resolution: float | None = Field( default=None, description=( "Vertical resolution in radians per point (angular step between " "adjacent beams), not points per radian." ), ) elevation_channel_mapping: dict[int, float] | None = Field( default=None, description="""Mapping between channels (from 0 to N-1), angles in in radians.""", )
[docs] @model_validator(mode="after") def only_one_of_vertical_resolution_or_elevation_channel_mapping( self, ) -> VerticalResolution: """Validate that exactly one resolution specification is provided. Returns ------- VerticalResolution The validated resolution instance. Raises ------ ValueError If neither or both specifications are provided. """ if self.vertical_resolution is None and self.elevation_channel_mapping is None: raise ValueError( "At least one of vertical_resolution or elevation_channel_mapping must be provided." ) if ( self.vertical_resolution is not None and self.elevation_channel_mapping is not None ): raise ValueError( "Only one of vertical_resolution or elevation_channel_mapping must be provided." ) return self
#: LiDAR sensor technology type. #: #: - ``"rotating"``: Mechanical spinning LiDAR with 360° horizontal coverage #: - ``"solid-state"``: Non-mechanical LiDAR with limited horizontal FOV LidarType = Literal["rotating", "solid-state"] #: Rotation direction for mechanical LiDAR sensors. #: #: Viewing the LiDAR from above (top-down view), assuming standard #: coordinates (X-forward, Y-left, Z-up): #: #: - ``"clockwise"``: Rotates clockwise #: - ``"counter-clockwise"``: Rotates counter-clockwise LidarRotationType = Literal["clockwise", "counter-clockwise"]
[docs] class LidarIntrinsics(BaseModel): """Internal parameters for a LiDAR sensor. Defines the sensing characteristics of a LiDAR including field of view, resolution, range limits, and beam properties. Attributes ---------- lidar_type : {"rotating", "solid-state"} Technology type of the LiDAR sensor. rotation_type : {"clockwise", "counter-clockwise"} or None Rotation direction for rotating LiDARs (top-down view). Required if ``lidar_type`` is "rotating". vertical_fov : float Vertical field of view in radians. horizontal_fov : float Horizontal field of view in radians. Typically 2π for rotating LiDARs. horizontal_resolution : float Horizontal angular resolution in radians per point (the angular step between adjacent azimuth samples), not points per radian. For example, a sensor with 3600 points over a full 360-degree rotation should set ``horizontal_resolution = 2 * np.pi / 3600``, equivalently ``np.deg2rad(0.1)``. Every first-party converter (mimic-wod, mimic-nuscene, mimic-kitti360, mimic-osdar23) uses this convention. vertical_resolution : VerticalResolution Vertical resolution specification (uniform or per-channel). max_range : float Maximum detection range in meters. min_range : float Minimum detection range in meters. horizontal_beam_divergence : float or None Horizontal laser beam divergence in radians. Optional. vertical_beam_divergence : float or None Vertical laser beam divergence in radians. Optional. Raises ------ ValueError If ``lidar_type`` is "rotating" but ``rotation_type`` is not specified. Example ------- >>> intrinsics = LidarIntrinsics( ... lidar_type="rotating", ... rotation_type="clockwise", ... vertical_fov=0.7, # ~40 degrees ... horizontal_fov=2 * np.pi, # 360 degrees ... horizontal_resolution=0.002, ... vertical_resolution=VerticalResolution(vertical_resolution=0.035), ... max_range=120.0, ... min_range=0.5, ... horizontal_beam_divergence=0.003, ... vertical_beam_divergence=0.003 ... ) """ lidar_type: LidarType = Field( description="Type of Lidar, Solid-State or Rotating, " ) rotation_type: LidarRotationType | None = Field( description="If lidar rotating, we need to know how : " "Clockwise or counter-clockwise when looking at it topview." "Lidar is assumed z-axis up-ward, x-forward, y-left. " ) vertical_fov: float = Field(description="Vertical field of view in rad.") horizontal_fov: float = Field(description="Horizontal field of view in rad.") horizontal_resolution: float = Field( description=( "Horizontal angular resolution in radians per point (angular step " "between adjacent azimuth samples), e.g. 2*pi/num_points_per_rotation. " "Not points per radian." ) ) vertical_resolution: VerticalResolution = Field( description="Vertical resolution parameters, can be specified either as a single value *or* as a mapping between channels and angles." ) max_range: float = Field(description="Maximum range of the LiDAR sensor in meters.") min_range: float = Field(description="Minimum range of the LiDAR sensor in meters.") horizontal_beam_divergence: float | None = Field( default=None, description=""" Horizontal divergence of laser beam in radians""" ) vertical_beam_divergence: float | None = Field( default=None, description=""" Vertical divergence of laser beam in radians""" )
[docs] @model_validator(mode="after") def validate_rotating_type(self): """Validate that rotating LiDARs have a rotation type specified. Returns ------- LidarIntrinsics The validated intrinsics instance. Raises ------ ValueError If ``lidar_type`` is "rotating" but ``rotation_type`` is None. """ if self.lidar_type == "rotating" and self.rotation_type is None: raise ValueError( "If lidar is of type rotating, rotation type needs a value (clockwise or counter-clockwise)" ) return self
[docs] class LidarPointCloudData(BaseModel): """Metadata and optional point cloud data for a single LiDAR scan. This class stores information about individual point cloud captures, including file location, timestamp, and processing state. Attributes ---------- filepath : str Path to the point cloud file (PLY format). lidar_pcd : LidarPointCloud or None Optional in-memory point cloud data. Not serialized to JSON. timestamp : float Timestamp in seconds corresponding to the middle of the scan. For rotating LiDARs, this is the midpoint of the 360° rotation. egomotion_compensated : bool ``True`` if the point cloud has been motion-compensated (unrolled) to account for vehicle motion during the scan. has_missing_points : bool ``True`` if the point cloud contains missing/invalid points (typically indicated by intensity = -1). Note ---- Point clouds should be stored in the sensor's local coordinate frame (sensor referential), not world or vehicle coordinates. Example ------- >>> pcd_data = LidarPointCloudData( ... filepath="data/lidar/scan_000.ply", ... timestamp=0.0, ... egomotion_compensated=True, ... has_missing_points=False ... ) """ filepath: str = Field(description="Filepath to the point cloud data plyfile.") lidar_pcd: LidarPointCloud | None = Field( default=None, exclude=True, description="Lidar point-cloud to be used when loading a scene, will not be serialized in json file.", ) timestamp: float = Field( description="Timestamp of the point cloud in seconds that corresponds to the middle of the scan." ) egomotion_compensated: bool = Field( description="Whether the point-cloud is unrolled or not" ) has_missing_points: bool = Field( description="Whether the point-cloud has missing points (points with intensity at -1)" )
[docs] class Lidar(BaseModel): """Complete LiDAR sensor representation. This class encapsulates all information about a LiDAR sensor including its calibration, intrinsic parameters, trajectory, and captured point cloud data. Attributes ---------- id : int Unique identifier for the LiDAR within the sensor suite. name : str Human-readable name for the LiDAR (e.g., "top_lidar", "front_lidar"). extrinsics : SensorExtrinsic 4x4 transformation matrix from sensor frame to vehicle frame. intrinsics : LidarIntrinsics Internal LiDAR parameters (FOV, resolution, range, etc.). trajectory : WorldTrajectory Time-series of LiDAR poses in world coordinates. lidar_pcds_data : list of LidarPointCloudData List of captured point clouds with their metadata. min_cut_off_range : float Minimum range threshold for ego vehicle removal. Points closer than this distance are typically from the ego vehicle and should be filtered out. use_for_training : bool Whether this LiDAR stream should be used for model training. Defaults to ``True``. Set to ``False`` to exclude this stream. Note ---- The coordinate frame convention is: - X-axis: Forward - Y-axis: Left - Z-axis: Up (FLU convention) Example ------- >>> lidar = Lidar( ... id=0, ... name="velodyne_top", ... extrinsics=np.eye(4), ... intrinsics=intrinsics, ... trajectory=trajectory, ... lidar_pcds_data=[pcd_data], ... min_cut_off_range=2.0 ... ) """ id: int name: str extrinsics: SensorExtrinsic = Field( description="Extrinsic calibration from sensor to vehicle frame" ) intrinsics: LidarIntrinsics = Field(description="Lidar intrinsic parameters.") trajectory: WorldTrajectory lidar_pcds_data: list[LidarPointCloudData] = Field( description="List of point clouds captured by the LiDAR sensor." ) min_cut_off_range: float = Field( description="Cut off range for removing the ego vehicle from the lidar" ) use_for_training: StrictBool = Field( default=True, description="Whether this lidar stream is enabled for training. Must be true or false.", )
[docs] @model_validator(mode="after") def validate_timestamps_for_solid_state(self): """Validate timestamp requirements for solid-state LiDARs. Returns ------- Lidar The validated LiDAR instance. """ if self.intrinsics.lidar_type: pass # raise error if notimestamps per points return self
@property def data(self) -> list[LidarPointCloudData]: """Alias for accessing point cloud data. Returns ------- list of LidarPointCloudData The list of captured point clouds. """ return self.lidar_pcds_data
[docs] def __eq__(self, other: object) -> bool: """Check equality with another Lidar. Performs deep comparison of all LiDAR attributes including calibration parameters, trajectory, and point cloud data. Parameters ---------- other : object The object to compare against. Returns ------- bool ``True`` if LiDARs are equal, ``False`` otherwise. """ if not isinstance(other, Lidar): return False if self.id != other.id: return False if self.name != other.name: return False if not np.allclose(self.extrinsics, other.extrinsics): return False if self.intrinsics != other.intrinsics: return False if self.trajectory != other.trajectory: return False if self.lidar_pcds_data != other.lidar_pcds_data: return False if self.use_for_training != other.use_for_training: return False return True