"""
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