From 1ae71cedb484c4439c80983035700ad03bc694a0 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 7 Nov 2025 08:43:53 +0100 Subject: [PATCH 01/50] Add some test, improve structure of ego state, refactor arrow writing/reading. --- examples/01_viser.py | 4 +- src/py123d/common/utils/arrow_column_names.py | 79 ++++ src/py123d/common/utils/enums.py | 8 +- .../conversion/datasets/av2/__init__.py | 0 .../datasets/av2/av2_sensor_converter.py | 26 +- .../conversion/datasets/av2/utils/__init__.py | 0 .../datasets/kitti360/kitti360_converter.py | 14 +- .../datasets/nuplan/nuplan_converter.py | 13 +- .../conversion/datasets/nuscenes/__init__.py | 0 .../datasets/nuscenes/nuscenes_converter.py | 12 - .../datasets/nuscenes/utils/__init__.py | 0 .../conversion/datasets/pandaset/__init__.py | 0 .../datasets/pandaset/pandaset_converter.py | 23 +- .../datasets/pandaset/utils/__init__.py | 0 .../datasets/wopd/utils/__init__.py | 0 .../womp_boundary_utils.py | 0 .../datasets/wopd/wopd_converter.py | 18 +- ...pd_map_utils.py => wopd_map_conversion.py} | 2 +- .../{waymo_sensor_io.py => wopd_sensor_io.py} | 0 .../conversion/log_writer/arrow_log_writer.py | 195 ++++++---- .../registry/box_detection_label_registry.py | 60 +-- .../sensor_io/camera/jpeg_camera_io.py | 12 + .../sensor_io/lidar/draco_lidar_io.py | 10 + .../sensor_io/lidar/file_lidar_io.py | 2 +- .../sensor_io/lidar/laz_lidar_io.py | 10 + .../datatypes/scene/arrow/arrow_scene.py | 19 +- .../scene/arrow/arrow_scene_builder.py | 7 +- .../scene/arrow/utils/arrow_getters.py | 284 +++++++++----- .../datatypes/vehicle_state/dynamic_state.py | 194 ++++++++++ .../datatypes/vehicle_state/ego_state.py | 366 ++++++++---------- .../common/scene_filter/all_scenes.yaml | 20 + .../visualization/viser/viser_config.py | 4 +- tests/unit/conversion/registry/__init__.py | 0 .../test_box_detection_label_registry.py | 45 +++ .../registry/test_lidar_registry.py | 40 ++ 35 files changed, 956 insertions(+), 511 deletions(-) create mode 100644 src/py123d/common/utils/arrow_column_names.py create mode 100644 src/py123d/conversion/datasets/av2/__init__.py create mode 100644 src/py123d/conversion/datasets/av2/utils/__init__.py create mode 100644 src/py123d/conversion/datasets/nuscenes/__init__.py create mode 100644 src/py123d/conversion/datasets/nuscenes/utils/__init__.py create mode 100644 src/py123d/conversion/datasets/pandaset/__init__.py create mode 100644 src/py123d/conversion/datasets/pandaset/utils/__init__.py create mode 100644 src/py123d/conversion/datasets/wopd/utils/__init__.py rename src/py123d/conversion/datasets/wopd/{waymo_map_utils => utils}/womp_boundary_utils.py (100%) rename src/py123d/conversion/datasets/wopd/{waymo_map_utils/wopd_map_utils.py => wopd_map_conversion.py} (98%) rename src/py123d/conversion/datasets/wopd/{waymo_sensor_io.py => wopd_sensor_io.py} (100%) create mode 100644 src/py123d/datatypes/vehicle_state/dynamic_state.py create mode 100644 src/py123d/script/config/common/scene_filter/all_scenes.yaml create mode 100644 tests/unit/conversion/registry/__init__.py create mode 100644 tests/unit/conversion/registry/test_box_detection_label_registry.py create mode 100644 tests/unit/conversion/registry/test_lidar_registry.py diff --git a/examples/01_viser.py b/examples/01_viser.py index a91005cf..29f03aa2 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -10,9 +10,9 @@ # splits = ["nuplan-mini_test", "nuplan-mini_train", "nuplan-mini_val"] # splits = ["nuplan_private_test"] # splits = ["carla_test"] - splits = ["wopd_val"] + # splits = ["wopd_val"] # splits = ["av2-sensor_train"] - # splits = ["pandaset_test", "pandaset_val", "pandaset_train"] + splits = ["pandaset_test", "pandaset_val", "pandaset_train"] # log_names = ["2021.08.24.13.12.55_veh-45_00386_00472"] # log_names = ["2013_05_28_drive_0000_sync"] # log_names = ["2013_05_28_drive_0000_sync"] diff --git a/src/py123d/common/utils/arrow_column_names.py b/src/py123d/common/utils/arrow_column_names.py new file mode 100644 index 00000000..37a3a6bf --- /dev/null +++ b/src/py123d/common/utils/arrow_column_names.py @@ -0,0 +1,79 @@ +from typing import Callable, Final, List + +# Essential Columns +# ---------------------------------------------------------------------------------------------------------------------- +UUID_COLUMN: Final[str] = "uuid" +TIMESTAMP_US_COLUMN: Final[str] = "timestamp.us" + +# Ego State SE3 +# ---------------------------------------------------------------------------------------------------------------------- +EGO_REAR_AXLE_SE3_COLUMN: Final[str] = "ego.rear_axle_se3" +EGO_DYNAMIC_STATE_SE3_COLUMN: Final[str] = "ego.dynamic_state_se3" + +EGO_STATE_SE3_COLUMNS: Final[List[str]] = [ + EGO_REAR_AXLE_SE3_COLUMN, + EGO_DYNAMIC_STATE_SE3_COLUMN, +] + + +# Box Detections SE3 +# ---------------------------------------------------------------------------------------------------------------------- +BOX_DETECTIONS_PREFIX: Final[str] = "box_detections" +BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN: Final[str] = f"{BOX_DETECTIONS_PREFIX}.bounding_box_se3" +BOX_DETECTIONS_TOKEN_COLUMN: Final[str] = f"{BOX_DETECTIONS_PREFIX}.token" +BOX_DETECTIONS_VELOCITY_3D_COLUMN: Final[str] = f"{BOX_DETECTIONS_PREFIX}.velocity_3d" +BOX_DETECTIONS_LABEL_COLUMN: Final[str] = f"{BOX_DETECTIONS_PREFIX}.label" +BOX_DETECTIONS_NUM_LIDAR_POINTS_COLUMN: Final[str] = f"{BOX_DETECTIONS_PREFIX}.num_lidar_points" + +BOX_DETECTIONS_SE3_COLUMNS: Final[List[str]] = [ + BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN, + BOX_DETECTIONS_TOKEN_COLUMN, + BOX_DETECTIONS_VELOCITY_3D_COLUMN, + BOX_DETECTIONS_LABEL_COLUMN, + BOX_DETECTIONS_NUM_LIDAR_POINTS_COLUMN, +] + +# Traffic Lights +# ---------------------------------------------------------------------------------------------------------------------- +TRAFFIC_LIGHTS_PREFIX: Final[str] = "traffic_lights" +TRAFFIC_LIGHTS_LANE_ID_COLUMN: Final[str] = f"{TRAFFIC_LIGHTS_PREFIX}.lane_id" +TRAFFIC_LIGHTS_STATUS_COLUMN: Final[str] = f"{TRAFFIC_LIGHTS_PREFIX}.status" + +TRAFFIC_LIGHTS_COLUMNS: Final[List[str]] = [ + TRAFFIC_LIGHTS_LANE_ID_COLUMN, + TRAFFIC_LIGHTS_STATUS_COLUMN, +] + +# Pinhole Cameras +# ---------------------------------------------------------------------------------------------------------------------- +PINHOLE_PREFIX: Final[str] = "pinhole" +PINHOLE_CAMERA_DATA_COLUMN: Callable[[str], str] = lambda name: f"{PINHOLE_PREFIX}.{name}.data" +PINHOLE_CAMERA_EXTRINSIC_COLUMN: Callable[[str], str] = lambda name: f"{PINHOLE_PREFIX}.{name}.state_se3" + +PINHOLE_CAMERA_COLUMNS: Callable[[str], List[str]] = lambda name: [ + PINHOLE_CAMERA_DATA_COLUMN(name), + PINHOLE_CAMERA_EXTRINSIC_COLUMN(name), +] + + +# Fisheye MEI Cameras +# ---------------------------------------------------------------------------------------------------------------------- +FISHEYE_PREFIX: Final[str] = "fisheye_mei" +FISHEYE_CAMERA_DATA_COLUMN: Callable[[str], str] = lambda name: f"{FISHEYE_PREFIX}.{name}.data" +FISHEYE_CAMERA_EXTRINSIC_COLUMN: Callable[[str], str] = lambda name: f"{FISHEYE_PREFIX}.{name}.state_se3" + +FISHEYE_CAMERA_COLUMNS: Callable[[str], List[str]] = lambda name: [ + FISHEYE_CAMERA_DATA_COLUMN(name), + FISHEYE_CAMERA_EXTRINSIC_COLUMN(name), +] + + +# LiDAR +# ---------------------------------------------------------------------------------------------------------------------- +LIDAR_DATA_COLUMN: Callable[[str], str] = lambda name: f"lidar.{name}.data" + + +# Miscellaneous (Scenario Tags / Route) +# ---------------------------------------------------------------------------------------------------------------------- +SCENARIO_TAGS_COLUMN: str = "scenario_tags" +ROUTE_LANE_GROUP_IDS_COLUMN: str = "route_lane_group_ids" diff --git a/src/py123d/common/utils/enums.py b/src/py123d/common/utils/enums.py index 9f7d233e..af713a1a 100644 --- a/src/py123d/common/utils/enums.py +++ b/src/py123d/common/utils/enums.py @@ -1,6 +1,6 @@ from __future__ import annotations -from enum import IntEnum +from enum import Enum from pyparsing import Union @@ -13,7 +13,11 @@ def __get__(self, obj, owner): return self.f(owner) -class SerialIntEnum(IntEnum): +class SerialIntEnum(Enum): + + def __int__(self) -> int: + return self.value + def serialize(self, lower: bool = True) -> str: """Serialize the type when saving.""" # Allow for lower/upper case letters during serialize diff --git a/src/py123d/conversion/datasets/av2/__init__.py b/src/py123d/conversion/datasets/av2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 1e5a192a..47a25be8 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -29,10 +29,9 @@ PinholeIntrinsics, ) from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 +from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import ( get_av2_ford_fusion_hybrid_parameters, - rear_axle_se3_to_center_se3, ) from py123d.geometry import BoundingBoxSE3Index, StateSE3, Vector3D, Vector3DIndex from py123d.geometry.bounding_box import BoundingBoxSE3 @@ -255,16 +254,15 @@ def _extract_av2_sensor_box_detections( detections_velocity = np.zeros((num_detections, len(Vector3DIndex)), dtype=np.float64) detections_token: List[str] = annotations_slice["track_uuid"].tolist() detections_labels: List[AV2SensorBoxDetectionLabel] = [] + detections_num_lidar_points: List[int] = [] for detection_idx, (_, row) in enumerate(annotations_slice.iterrows()): row = row.to_dict() - detections_state[detection_idx, BoundingBoxSE3Index.XYZ] = [row["tx_m"], row["ty_m"], row["tz_m"]] detections_state[detection_idx, BoundingBoxSE3Index.QUATERNION] = [row["qw"], row["qx"], row["qy"], row["qz"]] detections_state[detection_idx, BoundingBoxSE3Index.EXTENT] = [row["length_m"], row["width_m"], row["height_m"]] - - detections_label = AV2SensorBoxDetectionLabel.deserialize(row["category"]) - detections_labels.append(detections_label) + detections_labels.append(AV2SensorBoxDetectionLabel.deserialize(row["category"])) + detections_num_lidar_points.append(int(row["num_interior_pts"])) detections_state[:, BoundingBoxSE3Index.STATE_SE3] = convert_relative_to_absolute_se3_array( origin=ego_state_se3.rear_axle_se3, @@ -277,9 +275,8 @@ def _extract_av2_sensor_box_detections( BoxDetectionSE3( metadata=BoxDetectionMetadata( label=detections_labels[detection_idx], - timepoint=None, track_token=detections_token[detection_idx], - confidence=None, + num_lidar_points=detections_num_lidar_points[detection_idx], ), bounding_box_se3=BoundingBoxSE3.from_array(detections_state[detection_idx]), velocity=Vector3D.from_array(detections_velocity[detection_idx]), @@ -299,19 +296,14 @@ def _extract_av2_sensor_ego_state(city_se3_egovehicle_df: pd.DataFrame, lidar_ti rear_axle_pose = _row_dict_to_state_se3(ego_pose_dict) vehicle_parameters = get_av2_ford_fusion_hybrid_parameters() - center = rear_axle_se3_to_center_se3(rear_axle_se3=rear_axle_pose, vehicle_parameters=vehicle_parameters) # TODO: Add script to calculate the dynamic state from log sequence. - dynamic_state = DynamicStateSE3( - velocity=Vector3D(x=0.0, y=0.0, z=0.0), - acceleration=Vector3D(x=0.0, y=0.0, z=0.0), - angular_velocity=Vector3D(x=0.0, y=0.0, z=0.0), - ) + dynamic_state_se3 = None - return EgoStateSE3( - center_se3=center, - dynamic_state_se3=dynamic_state, + return EgoStateSE3.from_rear_axle( + rear_axle_se3=rear_axle_pose, vehicle_parameters=vehicle_parameters, + dynamic_state_se3=dynamic_state_se3, timepoint=None, ) diff --git a/src/py123d/conversion/datasets/av2/utils/__init__.py b/src/py123d/conversion/datasets/av2/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 22d84229..71688aef 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -52,7 +52,6 @@ from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import ( get_kitti360_vw_passat_parameters, - rear_axle_se3_to_center_se3, ) from py123d.geometry import BoundingBoxSE3, Quaternion, StateSE3, Vector3D from py123d.geometry.transform.transform_se3 import convert_se3_array_between_origins, translate_se3_along_body_frame @@ -518,13 +517,12 @@ def _extract_ego_state_all(log_name: str, kitti360_folders: Dict[str, Path]) -> qz=ego_quaternion.qz, ) - rear_axle_pose = translate_se3_along_body_frame( + rear_axle_se3 = translate_se3_along_body_frame( imu_pose, Vector3D(0.05, -0.32, 0.0), ) - center = rear_axle_se3_to_center_se3(rear_axle_se3=rear_axle_pose, vehicle_parameters=vehicle_parameters) - dynamic_state = DynamicStateSE3( + dynamic_state_se3 = DynamicStateSE3( velocity=Vector3D( x=oxts_data[8], y=oxts_data[9], @@ -542,11 +540,10 @@ def _extract_ego_state_all(log_name: str, kitti360_folders: Dict[str, Path]) -> ), ) ego_state_all.append( - EgoStateSE3( - center_se3=center, - dynamic_state_se3=dynamic_state, + EgoStateSE3.from_rear_axle( + rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters, - timepoint=None, + dynamic_state=dynamic_state_se3, ) ) return ego_state_all, valid_timestamp @@ -671,7 +668,6 @@ def _extract_kitti360_box_detections_all( label=detection_label, timepoint=None, track_token=token, - confidence=None, ) bounding_box_se3 = BoundingBoxSE3.from_array(state) velocity_vector = Vector3D.from_array(velocity) diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index 36fa3a25..d7ed0ec2 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -41,11 +41,10 @@ from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import ( get_nuplan_chrysler_pacifica_parameters, - rear_axle_se3_to_center_se3, ) from py123d.geometry import StateSE3, Vector3D -check_dependencies(["nuplan", "sqlalchemy"], "nuplan") +check_dependencies(["nuplan"], "nuplan") from nuplan.database.nuplan_db.nuplan_scenario_queries import get_cameras, get_images_from_lidar_tokens from nuplan.database.nuplan_db_orm.lidar_pc import LidarPc from nuplan.database.nuplan_db_orm.nuplandb import NuPlanDB @@ -305,8 +304,7 @@ def _extract_nuplan_ego_state(nuplan_lidar_pc: LidarPc) -> EgoStateSE3: qy=nuplan_lidar_pc.ego_pose.qy, qz=nuplan_lidar_pc.ego_pose.qz, ) - center = rear_axle_se3_to_center_se3(rear_axle_se3=rear_axle_pose, vehicle_parameters=vehicle_parameters) - dynamic_state = DynamicStateSE3( + dynamic_state_se3 = DynamicStateSE3( velocity=Vector3D( x=nuplan_lidar_pc.ego_pose.vx, y=nuplan_lidar_pc.ego_pose.vy, @@ -323,11 +321,10 @@ def _extract_nuplan_ego_state(nuplan_lidar_pc: LidarPc) -> EgoStateSE3: z=nuplan_lidar_pc.ego_pose.angular_rate_z, ), ) - return EgoStateSE3( - center_se3=center, - dynamic_state_se3=dynamic_state, + return EgoStateSE3.from_rear_axle( + rear_axle_se3=rear_axle_pose, vehicle_parameters=vehicle_parameters, - timepoint=None, # NOTE: Timepoint is not needed during writing, set to None + dynamic_state_se3=dynamic_state_se3, ) diff --git a/src/py123d/conversion/datasets/nuscenes/__init__.py b/src/py123d/conversion/datasets/nuscenes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 7cc39d34..654e8aee 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -320,18 +320,10 @@ def _extract_nuscenes_ego_state(nusc, sample, can_bus) -> EgoStateSE3: acceleration=Vector3D(*acceleration), angular_velocity=Vector3D(*angular_velocity), ) - - # return EgoStateSE3( - # center_se3=pose, - # dynamic_state_se3=dynamic_state, - # vehicle_parameters=vehicle_parameters, - # timepoint=TimePoint.from_us(sample["timestamp"]), - # ) return EgoStateSE3.from_rear_axle( rear_axle_se3=imu_pose, dynamic_state_se3=dynamic_state, vehicle_parameters=vehicle_parameters, - time_point=TimePoint.from_us(sample["timestamp"]), ) @@ -342,9 +334,6 @@ def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> ann = nusc.get("sample_annotation", ann_token) box = Box(ann["translation"], ann["size"], Quaternion(ann["rotation"])) - box_quat = box.orientation - euler_angles = box_quat.yaw_pitch_roll # (yaw, pitch, roll) - # Create StateSE3 for box center and orientation center_quat = box.orientation center = StateSE3( @@ -369,7 +358,6 @@ def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> label=label, track_token=ann["instance_token"], timepoint=TimePoint.from_us(sample["timestamp"]), - confidence=1.0, # nuScenes annotations are ground truth num_lidar_points=ann.get("num_lidar_pts", 0), ) diff --git a/src/py123d/conversion/datasets/nuscenes/utils/__init__.py b/src/py123d/conversion/datasets/nuscenes/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/py123d/conversion/datasets/pandaset/__init__.py b/src/py123d/conversion/datasets/pandaset/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index 008ab4e0..0ada2937 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -31,11 +31,8 @@ from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType, PinholeIntrinsics from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 -from py123d.datatypes.vehicle_state.vehicle_parameters import ( - get_pandaset_chrysler_pacifica_parameters, - rear_axle_se3_to_center_se3, -) +from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 +from py123d.datatypes.vehicle_state.vehicle_parameters import get_pandaset_chrysler_pacifica_parameters from py123d.geometry import BoundingBoxSE3, BoundingBoxSE3Index, EulerAnglesIndex, StateSE3, Vector3D from py123d.geometry.transform.transform_se3 import convert_absolute_to_relative_se3_array from py123d.geometry.utils.constants import DEFAULT_PITCH, DEFAULT_ROLL @@ -210,23 +207,17 @@ def _get_pandaset_lidar_metadata( def _extract_pandaset_sensor_ego_state(gps: Dict[str, float], lidar_pose: Dict[str, Dict[str, float]]) -> EgoStateSE3: - rear_axle_pose = main_lidar_to_rear_axle(pandaset_pose_dict_to_state_se3(lidar_pose)) + rear_axle_se3 = main_lidar_to_rear_axle(pandaset_pose_dict_to_state_se3(lidar_pose)) vehicle_parameters = get_pandaset_chrysler_pacifica_parameters() - center = rear_axle_se3_to_center_se3(rear_axle_se3=rear_axle_pose, vehicle_parameters=vehicle_parameters) # TODO: Add script to calculate the dynamic state from log sequence. - dynamic_state = DynamicStateSE3( - # velocity=Vector3D(x=gps["xvel"], y=gps["yvel"], z=0.0), - velocity=Vector3D(x=0.0, y=0.0, z=0.0), - acceleration=Vector3D(x=0.0, y=0.0, z=0.0), - angular_velocity=Vector3D(x=0.0, y=0.0, z=0.0), - ) + dynamic_state_se3 = None - return EgoStateSE3( - center_se3=center, - dynamic_state_se3=dynamic_state, + return EgoStateSE3.from_rear_axle( + rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters, + dynamic_state_se3=dynamic_state_se3, timepoint=None, ) diff --git a/src/py123d/conversion/datasets/pandaset/utils/__init__.py b/src/py123d/conversion/datasets/pandaset/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/py123d/conversion/datasets/wopd/utils/__init__.py b/src/py123d/conversion/datasets/wopd/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/py123d/conversion/datasets/wopd/waymo_map_utils/womp_boundary_utils.py b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py similarity index 100% rename from src/py123d/conversion/datasets/wopd/waymo_map_utils/womp_boundary_utils.py rename to src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index c5d7a411..5ae3512c 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -16,7 +16,7 @@ WOPD_DETECTION_NAME_DICT, WOPD_LIDAR_TYPES, ) -from py123d.conversion.datasets.wopd.waymo_map_utils.wopd_map_utils import convert_wopd_map +from py123d.conversion.datasets.wopd.wopd_map_conversion import convert_wopd_map from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter from py123d.conversion.registry.box_detection_label_registry import WOPDBoxDetectionLabel @@ -25,15 +25,16 @@ from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.maps.map_metadata import MapMetadata from py123d.datatypes.scene.scene_metadata import LogMetadata -from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import ( +from py123d.datatypes.sensors import ( + LiDARMetadata, + LiDARType, PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, ) from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 +from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import get_wopd_chrysler_pacifica_parameters from py123d.geometry import ( BoundingBoxSE3, @@ -296,15 +297,11 @@ def _extract_wopd_ego_state(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) vehicle_parameters = get_wopd_chrysler_pacifica_parameters() # FIXME: Find dynamic state in waymo open perception dataset # https://github.com/waymo-research/waymo-open-dataset/issues/55#issuecomment-546152290 - dynamic_state = DynamicStateSE3( - velocity=Vector3D(*np.zeros(3)), - acceleration=Vector3D(*np.zeros(3)), - angular_velocity=Vector3D(*np.zeros(3)), - ) + dynamic_state_se3 = None return EgoStateSE3.from_rear_axle( rear_axle_se3=rear_axle_pose, - dynamic_state_se3=dynamic_state, + dynamic_state_se3=dynamic_state_se3, vehicle_parameters=vehicle_parameters, time_point=None, ) @@ -367,7 +364,6 @@ def _extract_wopd_box_detections( label=detections_types[detection_idx], timepoint=None, track_token=detections_token[detection_idx], - confidence=None, ), bounding_box_se3=BoundingBoxSE3.from_array(detections_state[detection_idx]), velocity=Vector3D.from_array(detections_velocity[detection_idx]), diff --git a/src/py123d/conversion/datasets/wopd/waymo_map_utils/wopd_map_utils.py b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py similarity index 98% rename from src/py123d/conversion/datasets/wopd/waymo_map_utils/wopd_map_utils.py rename to src/py123d/conversion/datasets/wopd/wopd_map_conversion.py index 741d9de7..d85adfb4 100644 --- a/src/py123d/conversion/datasets/wopd/waymo_map_utils/wopd_map_utils.py +++ b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py @@ -3,12 +3,12 @@ import numpy as np from py123d.common.utils.dependencies import check_dependencies +from py123d.conversion.datasets.wopd.utils.womp_boundary_utils import WaymoLaneData, fill_lane_boundaries from py123d.conversion.datasets.wopd.utils.wopd_constants import ( WAYMO_LANE_TYPE_CONVERSION, WAYMO_ROAD_EDGE_TYPE_CONVERSION, WAYMO_ROAD_LINE_TYPE_CONVERSION, ) -from py123d.conversion.datasets.wopd.waymo_map_utils.womp_boundary_utils import WaymoLaneData, fill_lane_boundaries from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter from py123d.datatypes.maps.abstract_map_objects import AbstractLane, AbstractRoadEdge, AbstractRoadLine from py123d.datatypes.maps.cache.cache_map_objects import ( diff --git a/src/py123d/conversion/datasets/wopd/waymo_sensor_io.py b/src/py123d/conversion/datasets/wopd/wopd_sensor_io.py similarity index 100% rename from src/py123d/conversion/datasets/wopd/waymo_sensor_io.py rename to src/py123d/conversion/datasets/wopd/wopd_sensor_io.py diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index acbcbb3b..6aed9629 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -4,6 +4,26 @@ import numpy as np import pyarrow as pa +from py123d.common.utils.arrow_column_names import ( + BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN, + BOX_DETECTIONS_LABEL_COLUMN, + BOX_DETECTIONS_NUM_LIDAR_POINTS_COLUMN, + BOX_DETECTIONS_TOKEN_COLUMN, + BOX_DETECTIONS_VELOCITY_3D_COLUMN, + EGO_DYNAMIC_STATE_SE3_COLUMN, + EGO_REAR_AXLE_SE3_COLUMN, + FISHEYE_CAMERA_DATA_COLUMN, + FISHEYE_CAMERA_EXTRINSIC_COLUMN, + LIDAR_DATA_COLUMN, + PINHOLE_CAMERA_DATA_COLUMN, + PINHOLE_CAMERA_EXTRINSIC_COLUMN, + ROUTE_LANE_GROUP_IDS_COLUMN, + SCENARIO_TAGS_COLUMN, + TIMESTAMP_US_COLUMN, + TRAFFIC_LIGHTS_LANE_ID_COLUMN, + TRAFFIC_LIGHTS_STATUS_COLUMN, + UUID_COLUMN, +) from py123d.common.utils.uuid_utils import create_deterministic_uuid from py123d.conversion.abstract_dataset_converter import AbstractLogWriter, DatasetConverterConfig from py123d.conversion.log_writer.abstract_log_writer import CameraData, LiDARData @@ -21,10 +41,10 @@ from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper from py123d.datatypes.scene.arrow.utils.arrow_metadata_utils import add_log_metadata_to_arrow_schema from py123d.datatypes.scene.scene_metadata import LogMetadata -from py123d.datatypes.sensors.lidar import LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType +from py123d.datatypes.sensors import LiDARType, PinholeCameraType from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3, EgoStateSE3Index +from py123d.datatypes.vehicle_state.dynamic_state import DynamicStateSE3Index +from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.geometry import BoundingBoxSE3Index, StateSE3, StateSE3Index, Vector3DIndex @@ -42,6 +62,15 @@ def _get_sensors_root() -> Path: return Path(DATASET_PATHS.py123d_sensors_root) +def _store_option_to_arrow_type(store_option: Literal["path", "binary", "mp4"]) -> pa.DataType: + data_type_map = { + "path": pa.string(), + "binary": pa.binary(), + "mp4": pa.int64(), + } + return data_type_map[store_option] + + class ArrowLogWriter(AbstractLogWriter): def __init__( @@ -124,14 +153,14 @@ def write( assert self._source is not None, "Log writer is not initialized." record_batch_data = { - "uuid": [ + UUID_COLUMN: [ create_deterministic_uuid( split=self._log_metadata.split, log_name=self._log_metadata.log_name, timestamp_us=timestamp.time_us, ).bytes ], - "timestamp": [timestamp.time_us], + TIMESTAMP_US_COLUMN: [timestamp.time_us], } # -------------------------------------------------------------------------------------------------------------- @@ -139,51 +168,53 @@ def write( # -------------------------------------------------------------------------------------------------------------- if self._dataset_converter_config.include_ego: assert ego_state is not None, "Ego state is required but not provided." - record_batch_data["ego_state"] = [ego_state.array] + record_batch_data[EGO_REAR_AXLE_SE3_COLUMN] = [ego_state.rear_axle_se3] + record_batch_data[EGO_DYNAMIC_STATE_SE3_COLUMN] = [ego_state.dynamic_state_se3] # -------------------------------------------------------------------------------------------------------------- # Box Detections # -------------------------------------------------------------------------------------------------------------- if self._dataset_converter_config.include_box_detections: assert box_detections is not None, "Box detections are required but not provided." - # TODO: Figure out more elegant way without for-loops. # Accumulate box detection data box_detection_state = [] - box_detection_velocity = [] box_detection_token = [] box_detection_label = [] + box_detection_velocity = [] + box_detection_num_lidar_points = [] for box_detection in box_detections: - box_detection_state.append(box_detection.bounding_box.array) - box_detection_velocity.append(box_detection.velocity.array) # TODO: make optional + box_detection_state.append(box_detection.bounding_box) box_detection_token.append(box_detection.metadata.track_token) box_detection_label.append(int(box_detection.metadata.label)) + box_detection_velocity.append(box_detection.velocity) + box_detection_num_lidar_points.append(box_detection.metadata.num_lidar_points) # Add to record batch data - record_batch_data["box_detection_state"] = [box_detection_state] - record_batch_data["box_detection_velocity"] = [box_detection_velocity] - record_batch_data["box_detection_token"] = [box_detection_token] - record_batch_data["box_detection_label"] = [box_detection_label] + record_batch_data[BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN] = [box_detection_state] + record_batch_data[BOX_DETECTIONS_TOKEN_COLUMN] = [box_detection_token] + record_batch_data[BOX_DETECTIONS_LABEL_COLUMN] = [box_detection_label] + record_batch_data[BOX_DETECTIONS_VELOCITY_3D_COLUMN] = [box_detection_velocity] + record_batch_data[BOX_DETECTIONS_NUM_LIDAR_POINTS_COLUMN] = [box_detection_num_lidar_points] # -------------------------------------------------------------------------------------------------------------- # Traffic Lights # -------------------------------------------------------------------------------------------------------------- if self._dataset_converter_config.include_traffic_lights: assert traffic_lights is not None, "Traffic light detections are required but not provided." - # TODO: Figure out more elegant way without for-loops. # Accumulate traffic light data traffic_light_ids = [] - traffic_light_types = [] + traffic_light_statuses = [] for traffic_light in traffic_lights: traffic_light_ids.append(traffic_light.lane_id) - traffic_light_types.append(int(traffic_light.status)) + traffic_light_statuses.append(int(traffic_light.status)) # Add to record batch data - record_batch_data["traffic_light_ids"] = [traffic_light_ids] - record_batch_data["traffic_light_types"] = [traffic_light_types] + record_batch_data[TRAFFIC_LIGHTS_LANE_ID_COLUMN] = [traffic_light_ids] + record_batch_data[TRAFFIC_LIGHTS_STATUS_COLUMN] = [traffic_light_statuses] # -------------------------------------------------------------------------------------------------------------- # Pinhole Cameras @@ -203,14 +234,16 @@ def write( # NOTE @DanielDauner: Missing cameras are allowed, e.g., for synchronization mismatches. # In this case, we write None/null to the arrow table. + # Theoretically, we could extend the store asynchronous cameras in the future by storing the + # camera data as a dictionary, list or struct-like object in the columns. pinhole_camera_data: Optional[Any] = None pinhole_camera_pose: Optional[StateSE3] = None if pinhole_camera_type in provided_pinhole_data: pinhole_camera_data = provided_pinhole_data[pinhole_camera_type] pinhole_camera_pose = provided_pinhole_extrinsics[pinhole_camera_type] - record_batch_data[f"{pinhole_camera_name}_data"] = [pinhole_camera_data] - record_batch_data[f"{pinhole_camera_name}_extrinsic"] = [ + record_batch_data[PINHOLE_CAMERA_DATA_COLUMN(pinhole_camera_name)] = [pinhole_camera_data] + record_batch_data[PINHOLE_CAMERA_EXTRINSIC_COLUMN(pinhole_camera_name)] = [ pinhole_camera_pose.array if pinhole_camera_pose else None ] @@ -238,8 +271,8 @@ def write( fisheye_mei_camera_data = provided_fisheye_mei_data[fisheye_mei_camera_type] fisheye_mei_camera_pose = provided_fisheye_mei_extrinsics[fisheye_mei_camera_type] - record_batch_data[f"{fisheye_mei_camera_name}_data"] = [fisheye_mei_camera_data] - record_batch_data[f"{fisheye_mei_camera_name}_extrinsic"] = [ + record_batch_data[FISHEYE_CAMERA_DATA_COLUMN(fisheye_mei_camera_name)] = [fisheye_mei_camera_data] + record_batch_data[FISHEYE_CAMERA_EXTRINSIC_COLUMN(fisheye_mei_camera_name)] = [ fisheye_mei_camera_pose.array if fisheye_mei_camera_pose else None ] @@ -250,14 +283,15 @@ def write( assert lidars is not None, "LiDAR data is required but not provided." if self._dataset_converter_config.lidar_store_option == "path_merged": - # NOTE @DanielDauner: The path_merged option is necessary for dataset, that natively store multiple + # NOTE @DanielDauner: The path_merged option is necessary for datasets, that natively store multiple # LiDAR point clouds in a single file. In this case, writing the file path several times is wasteful. # Instead, we store the file path once, and divide the point clouds during reading. assert len(lidars) == 1, "Exactly one LiDAR data must be provided for merged LiDAR storage." assert lidars[0].has_file_path, "LiDAR data must provide file path for merged LiDAR storage." merged_lidar_data: Optional[str] = str(lidars[0].relative_path) + lidar_name = LiDARType.LIDAR_MERGED.serialize() - record_batch_data[f"{LiDARType.LIDAR_MERGED.serialize()}_data"] = [merged_lidar_data] + record_batch_data[LIDAR_DATA_COLUMN(lidar_name)] = [merged_lidar_data] else: # NOTE @DanielDauner: for "path" and "binary" options, we write each LiDAR in a separate column. @@ -270,18 +304,18 @@ def write( for lidar_type in expected_lidars: lidar_name = lidar_type.serialize() lidar_data: Optional[Union[str, bytes]] = lidar_data_dict.get(lidar_type, None) - record_batch_data[f"{lidar_name}_data"] = [lidar_data] + record_batch_data[LIDAR_DATA_COLUMN(lidar_name)] = [lidar_data] # -------------------------------------------------------------------------------------------------------------- # Miscellaneous (Scenario Tags / Route) # -------------------------------------------------------------------------------------------------------------- if self._dataset_converter_config.include_scenario_tags: assert scenario_tags is not None, "Scenario tags are required but not provided." - record_batch_data["scenario_tags"] = [scenario_tags] + record_batch_data[SCENARIO_TAGS_COLUMN] = [scenario_tags] if self._dataset_converter_config.include_route: assert route_lane_group_ids is not None, "Route lane group IDs are required but not provided." - record_batch_data["route_lane_group_ids"] = [route_lane_group_ids] + record_batch_data[ROUTE_LANE_GROUP_IDS_COLUMN] = [route_lane_group_ids] record_batch = pa.record_batch(record_batch_data, schema=self._schema) self._record_batch_writer.write_batch(record_batch) @@ -310,8 +344,8 @@ def close(self) -> None: def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata: LogMetadata) -> pa.Schema: schema_list: List[Tuple[str, pa.DataType]] = [ - ("uuid", pa.uuid()), - ("timestamp", pa.int64()), + (UUID_COLUMN, pa.uuid()), + (TIMESTAMP_US_COLUMN, pa.int64()), ] # -------------------------------------------------------------------------------------------------------------- @@ -320,7 +354,8 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata if dataset_converter_config.include_ego: schema_list.extend( [ - ("ego_state", pa.list_(pa.float64(), len(EgoStateSE3Index))), + (EGO_REAR_AXLE_SE3_COLUMN, pa.list_(pa.float64(), len(StateSE3Index))), + (EGO_DYNAMIC_STATE_SE3_COLUMN, pa.list_(pa.float64(), len(DynamicStateSE3Index))), ] ) @@ -330,10 +365,26 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata if dataset_converter_config.include_box_detections: schema_list.extend( [ - ("box_detection_state", pa.list_(pa.list_(pa.float64(), len(BoundingBoxSE3Index)))), - ("box_detection_velocity", pa.list_(pa.list_(pa.float64(), len(Vector3DIndex)))), - ("box_detection_token", pa.list_(pa.string())), - ("box_detection_label", pa.list_(pa.int16())), + ( + BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN, + pa.list_(pa.list_(pa.float64(), len(BoundingBoxSE3Index))), + ), + ( + BOX_DETECTIONS_TOKEN_COLUMN, + pa.list_(pa.string()), + ), + ( + BOX_DETECTIONS_LABEL_COLUMN, + pa.list_(pa.int16()), + ), + ( + BOX_DETECTIONS_VELOCITY_3D_COLUMN, + pa.list_(pa.list_(pa.float64(), len(Vector3DIndex))), + ), + ( + BOX_DETECTIONS_NUM_LIDAR_POINTS_COLUMN, + pa.list_(pa.int64()), + ), ] ) @@ -343,8 +394,8 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata if dataset_converter_config.include_traffic_lights: schema_list.extend( [ - ("traffic_light_ids", pa.list_(pa.int64())), - ("traffic_light_types", pa.list_(pa.int16())), + (TRAFFIC_LIGHTS_LANE_ID_COLUMN, pa.list_(pa.int64())), + (TRAFFIC_LIGHTS_STATUS_COLUMN, pa.list_(pa.int16())), ] ) @@ -354,19 +405,18 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata if dataset_converter_config.include_pinhole_cameras: for pinhole_camera_type in log_metadata.pinhole_camera_metadata.keys(): pinhole_camera_name = pinhole_camera_type.serialize() - - # Depending on the storage option, define the schema for camera data - if dataset_converter_config.pinhole_camera_store_option == "path": - schema_list.append((f"{pinhole_camera_name}_data", pa.string())) - - elif dataset_converter_config.pinhole_camera_store_option == "binary": - schema_list.append((f"{pinhole_camera_name}_data", pa.binary())) - - elif dataset_converter_config.pinhole_camera_store_option == "mp4": - schema_list.append((f"{pinhole_camera_name}_data", pa.int64())) - - # Add camera pose - schema_list.append((f"{pinhole_camera_name}_extrinsic", pa.list_(pa.float64(), len(StateSE3Index)))) + schema_list.extend( + [ + ( + PINHOLE_CAMERA_DATA_COLUMN(pinhole_camera_name), + _store_option_to_arrow_type(dataset_converter_config.pinhole_camera_store_option), + ), + ( + PINHOLE_CAMERA_EXTRINSIC_COLUMN(pinhole_camera_name), + pa.list_(pa.float64(), len(StateSE3Index)), + ), + ] + ) # -------------------------------------------------------------------------------------------------------------- # Fisheye MEI Cameras @@ -374,45 +424,44 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata if dataset_converter_config.include_fisheye_mei_cameras: for fisheye_mei_camera_type in log_metadata.fisheye_mei_camera_metadata.keys(): fisheye_mei_camera_name = fisheye_mei_camera_type.serialize() - - # Depending on the storage option, define the schema for camera data - if dataset_converter_config.fisheye_mei_camera_store_option == "path": - schema_list.append((f"{fisheye_mei_camera_name}_data", pa.string())) - - elif dataset_converter_config.fisheye_mei_camera_store_option == "binary": - schema_list.append((f"{fisheye_mei_camera_name}_data", pa.binary())) - - elif dataset_converter_config.fisheye_mei_camera_store_option == "mp4": - schema_list.append((f"{fisheye_mei_camera_name}_data", pa.int64())) - - # Add camera pose - schema_list.append((f"{fisheye_mei_camera_name}_extrinsic", pa.list_(pa.float64(), len(StateSE3Index)))) + schema_list.extend( + [ + ( + FISHEYE_CAMERA_DATA_COLUMN(fisheye_mei_camera_name), + _store_option_to_arrow_type(dataset_converter_config.fisheye_mei_camera_store_option), + ), + ( + FISHEYE_CAMERA_EXTRINSIC_COLUMN(fisheye_mei_camera_name), + pa.list_(pa.float64(), len(StateSE3Index)), + ), + ] + ) # -------------------------------------------------------------------------------------------------------------- # LiDARs # -------------------------------------------------------------------------------------------------------------- if dataset_converter_config.include_lidars and len(log_metadata.lidar_metadata) > 0: if dataset_converter_config.lidar_store_option == "path_merged": - schema_list.append((f"{LiDARType.LIDAR_MERGED.serialize()}_data", pa.string())) + lidar_name = LiDARType.LIDAR_MERGED.serialize() + schema_list.append((LIDAR_DATA_COLUMN(lidar_name), pa.string())) else: for lidar_type in log_metadata.lidar_metadata.keys(): lidar_name = lidar_type.serialize() - - # Depending on the storage option, define the schema for LiDAR data - if dataset_converter_config.lidar_store_option == "path": - schema_list.append((f"{lidar_name}_data", pa.string())) - - elif dataset_converter_config.lidar_store_option == "binary": - schema_list.append((f"{lidar_name}_data", pa.binary())) + schema_list.append( + ( + LIDAR_DATA_COLUMN(lidar_name), + _store_option_to_arrow_type(dataset_converter_config.lidar_store_option), + ) + ) # -------------------------------------------------------------------------------------------------------------- # Miscellaneous (Scenario Tags / Route) # -------------------------------------------------------------------------------------------------------------- if dataset_converter_config.include_scenario_tags: - schema_list.append(("scenario_tags", pa.list_(pa.string()))) + schema_list.append((SCENARIO_TAGS_COLUMN, pa.list_(pa.string()))) if dataset_converter_config.include_route: - schema_list.append(("route_lane_group_ids", pa.list_(pa.int64()))) + schema_list.append((ROUTE_LANE_GROUP_IDS_COLUMN, pa.list_(pa.int64()))) return add_log_metadata_to_arrow_schema(pa.schema(schema_list), log_metadata) diff --git a/src/py123d/conversion/registry/box_detection_label_registry.py b/src/py123d/conversion/registry/box_detection_label_registry.py index 0d39e2d3..6deebb0b 100644 --- a/src/py123d/conversion/registry/box_detection_label_registry.py +++ b/src/py123d/conversion/registry/box_detection_label_registry.py @@ -46,36 +46,36 @@ def to_default(self) -> DefaultBoxDetectionLabel: class AV2SensorBoxDetectionLabel(BoxDetectionLabel): """Sensor dataset annotation categories.""" - ANIMAL = 1 - ARTICULATED_BUS = 2 - BICYCLE = 3 - BICYCLIST = 4 - BOLLARD = 5 - BOX_TRUCK = 6 - BUS = 7 - CONSTRUCTION_BARREL = 8 - CONSTRUCTION_CONE = 9 - DOG = 10 - LARGE_VEHICLE = 11 - MESSAGE_BOARD_TRAILER = 12 - MOBILE_PEDESTRIAN_CROSSING_SIGN = 13 - MOTORCYCLE = 14 - MOTORCYCLIST = 15 - OFFICIAL_SIGNALER = 16 - PEDESTRIAN = 17 - RAILED_VEHICLE = 18 - REGULAR_VEHICLE = 19 - SCHOOL_BUS = 20 - SIGN = 21 - STOP_SIGN = 22 - STROLLER = 23 - TRAFFIC_LIGHT_TRAILER = 24 - TRUCK = 25 - TRUCK_CAB = 26 - VEHICULAR_TRAILER = 27 - WHEELCHAIR = 28 - WHEELED_DEVICE = 29 - WHEELED_RIDER = 30 + ANIMAL = 0 + ARTICULATED_BUS = 1 + BICYCLE = 2 + BICYCLIST = 3 + BOLLARD = 4 + BOX_TRUCK = 5 + BUS = 6 + CONSTRUCTION_BARREL = 7 + CONSTRUCTION_CONE = 8 + DOG = 9 + LARGE_VEHICLE = 10 + MESSAGE_BOARD_TRAILER = 11 + MOBILE_PEDESTRIAN_CROSSING_SIGN = 12 + MOTORCYCLE = 13 + MOTORCYCLIST = 14 + OFFICIAL_SIGNALER = 15 + PEDESTRIAN = 16 + RAILED_VEHICLE = 17 + REGULAR_VEHICLE = 18 + SCHOOL_BUS = 19 + SIGN = 20 + STOP_SIGN = 21 + STROLLER = 22 + TRAFFIC_LIGHT_TRAILER = 23 + TRUCK = 24 + TRUCK_CAB = 25 + VEHICULAR_TRAILER = 26 + WHEELCHAIR = 27 + WHEELED_DEVICE = 28 + WHEELED_RIDER = 29 def to_default(self) -> DefaultBoxDetectionLabel: """Inherited, see superclass.""" diff --git a/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py b/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py index d942e729..afb9f041 100644 --- a/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py +++ b/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py @@ -5,6 +5,18 @@ import numpy.typing as npt +def is_jpeg_binary(jpeg_binary: bytes) -> bool: + """Check if the given binary data represents a JPEG image. + + :param jpeg_binary: The binary data to check. + :return: True if the binary data is a JPEG image, False otherwise. + """ + SOI_MARKER = b"\xff\xd8" # Start Of Image + EOI_MARKER = b"\xff\xd9" # End Of Image + + return jpeg_binary.startswith(SOI_MARKER) and jpeg_binary.endswith(EOI_MARKER) + + def encode_image_as_jpeg_binary(image: npt.NDArray[np.uint8]) -> bytes: _, encoded_img = cv2.imencode(".jpg", image) jpeg_binary = encoded_img.tobytes() diff --git a/src/py123d/conversion/sensor_io/lidar/draco_lidar_io.py b/src/py123d/conversion/sensor_io/lidar/draco_lidar_io.py index 61473f08..72715403 100644 --- a/src/py123d/conversion/sensor_io/lidar/draco_lidar_io.py +++ b/src/py123d/conversion/sensor_io/lidar/draco_lidar_io.py @@ -13,6 +13,16 @@ DRACO_PRESERVE_ORDER: Final[bool] = False +def is_draco_binary(draco_binary: bytes) -> bool: + """Check if the given binary data represents a Draco compressed point cloud. + + :param draco_binary: The binary data to check. + :return: True if the binary data is a Draco compressed point cloud, False otherwise. + """ + DRACO_MAGIC_NUMBER = b"DRACO" + return draco_binary.startswith(DRACO_MAGIC_NUMBER) + + def encode_lidar_pc_as_draco_binary(lidar_pc: npt.NDArray[np.float32], lidar_metadata: LiDARMetadata) -> bytes: """Compress LiDAR point cloud data using Draco format. diff --git a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py index 1a9e2583..9d783c50 100644 --- a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py +++ b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py @@ -50,7 +50,7 @@ def load_lidar_pcs_from_file( lidar_pcs_dict = load_av2_sensor_lidar_pcs_from_file(full_lidar_path) elif log_metadata.dataset == "wopd": - from py123d.conversion.datasets.wopd.waymo_sensor_io import load_wopd_lidar_pcs_from_file + from py123d.conversion.datasets.wopd.wopd_sensor_io import load_wopd_lidar_pcs_from_file lidar_pcs_dict = load_wopd_lidar_pcs_from_file(full_lidar_path, index, keep_polar_features=False) diff --git a/src/py123d/conversion/sensor_io/lidar/laz_lidar_io.py b/src/py123d/conversion/sensor_io/lidar/laz_lidar_io.py index b109c7ca..2ad33714 100644 --- a/src/py123d/conversion/sensor_io/lidar/laz_lidar_io.py +++ b/src/py123d/conversion/sensor_io/lidar/laz_lidar_io.py @@ -7,6 +7,16 @@ from py123d.datatypes.sensors.lidar import LiDAR, LiDARMetadata +def is_laz_binary(laz_binary: bytes) -> bool: + """Check if the given binary data represents a LAZ compressed point cloud. + + :param laz_binary: The binary data to check. + :return: True if the binary data is a LAZ compressed point cloud, False otherwise. + """ + LAS_MAGIC_NUMBER = b"LASF" + return laz_binary[0:4] == LAS_MAGIC_NUMBER + + def encode_lidar_pc_as_laz_binary(point_cloud: npt.NDArray[np.float32], lidar_metadata: LiDARMetadata) -> bytes: """Compress LiDAR point cloud data using LAZ format. diff --git a/src/py123d/datatypes/scene/arrow/arrow_scene.py b/src/py123d/datatypes/scene/arrow/arrow_scene.py index ee26a5f4..cb0ae3f6 100644 --- a/src/py123d/datatypes/scene/arrow/arrow_scene.py +++ b/src/py123d/datatypes/scene/arrow/arrow_scene.py @@ -10,10 +10,11 @@ from py123d.datatypes.maps.gpkg.gpkg_map import get_global_map_api, get_local_map_api from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.scene.arrow.utils.arrow_getters import ( - get_box_detections_from_arrow_table, + get_box_detections_se3_from_arrow_table, get_camera_from_arrow_table, - get_ego_vehicle_state_from_arrow_table, + get_ego_state_se3_from_arrow_table, get_lidar_from_arrow_table, + get_route_lane_group_ids_from_arrow_table, get_timepoint_from_arrow_table, get_traffic_light_detections_from_arrow_table, ) @@ -107,14 +108,14 @@ def get_timepoint_at_iteration(self, iteration: int) -> TimePoint: return get_timepoint_from_arrow_table(self._get_recording_table(), self._get_table_index(iteration)) def get_ego_state_at_iteration(self, iteration: int) -> Optional[EgoStateSE3]: - return get_ego_vehicle_state_from_arrow_table( + return get_ego_state_se3_from_arrow_table( self._get_recording_table(), self._get_table_index(iteration), self.log_metadata.vehicle_parameters, ) def get_box_detections_at_iteration(self, iteration: int) -> Optional[BoxDetectionWrapper]: - return get_box_detections_from_arrow_table( + return get_box_detections_se3_from_arrow_table( self._get_recording_table(), self._get_table_index(iteration), self.log_metadata, @@ -126,15 +127,11 @@ def get_traffic_light_detections_at_iteration(self, iteration: int) -> Optional[ ) def get_route_lane_group_ids(self, iteration: int) -> Optional[List[int]]: - route_lane_group_ids: Optional[List[int]] = None - table = self._get_recording_table() - if "route_lane_group_ids" in table.column_names: - route_lane_group_ids = table["route_lane_group_ids"][self._get_table_index(iteration)].as_py() - return route_lane_group_ids + return get_route_lane_group_ids_from_arrow_table(self._get_recording_table(), self._get_table_index(iteration)) def get_pinhole_camera_at_iteration( - self, iteration: int, camera_type: Union[PinholeCameraType, FisheyeMEICameraType] - ) -> Optional[Union[PinholeCamera, FisheyeMEICamera]]: + self, iteration: int, camera_type: PinholeCameraType + ) -> Optional[PinholeCamera]: pinhole_camera: Optional[PinholeCamera] = None if camera_type in self.available_pinhole_camera_types: pinhole_camera = get_camera_from_arrow_table( diff --git a/src/py123d/datatypes/scene/arrow/arrow_scene_builder.py b/src/py123d/datatypes/scene/arrow/arrow_scene_builder.py index 2afebbba..211209d1 100644 --- a/src/py123d/datatypes/scene/arrow/arrow_scene_builder.py +++ b/src/py123d/datatypes/scene/arrow/arrow_scene_builder.py @@ -4,6 +4,7 @@ from typing import Iterator, List, Optional, Set, Union from py123d.common.multithreading.worker_utils import WorkerPool, worker_map +from py123d.common.utils.arrow_column_names import FISHEYE_CAMERA_DATA_COLUMN, PINHOLE_CAMERA_DATA_COLUMN, UUID_COLUMN from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.scene.abstract_scene_builder import SceneBuilder @@ -120,7 +121,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil elif filter.duration_s is None: scene_extraction_metadatas.append( SceneExtractionMetadata( - initial_uuid=str(recording_table["uuid"][start_idx].as_py()), + initial_uuid=str(recording_table[UUID_COLUMN][start_idx].as_py()), initial_idx=start_idx, duration_s=(end_idx - start_idx) * log_metadata.timestep_seconds, history_s=filter.history_s if filter.history_s is not None else 0.0, @@ -165,7 +166,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil start_idx = scene_extraction_metadata.initial_idx if filter.pinhole_camera_types is not None: for pinhole_camera_type in filter.pinhole_camera_types: - column_name = f"{pinhole_camera_type.serialize()}_data" + column_name = PINHOLE_CAMERA_DATA_COLUMN(pinhole_camera_type.serialize()) if ( pinhole_camera_type in log_metadata.pinhole_camera_metadata @@ -179,7 +180,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil if filter.fisheye_mei_camera_types is not None: for fisheye_mei_camera_type in filter.fisheye_mei_camera_types: - column_name = f"{fisheye_mei_camera_type.serialize()}_data" + column_name = FISHEYE_CAMERA_DATA_COLUMN(fisheye_mei_camera_type.serialize()) if ( fisheye_mei_camera_type in log_metadata.fisheye_mei_camera_metadata diff --git a/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py b/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py index 71c08154..e1f1737e 100644 --- a/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py +++ b/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py @@ -1,20 +1,41 @@ from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional, Type, Union -import cv2 import numpy as np import numpy.typing as npt import pyarrow as pa from omegaconf import DictConfig +from py123d.common.utils.arrow_column_names import ( + BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN, + BOX_DETECTIONS_LABEL_COLUMN, + BOX_DETECTIONS_NUM_LIDAR_POINTS_COLUMN, + BOX_DETECTIONS_SE3_COLUMNS, + BOX_DETECTIONS_TOKEN_COLUMN, + BOX_DETECTIONS_VELOCITY_3D_COLUMN, + EGO_DYNAMIC_STATE_SE3_COLUMN, + EGO_REAR_AXLE_SE3_COLUMN, + EGO_STATE_SE3_COLUMNS, + FISHEYE_CAMERA_DATA_COLUMN, + FISHEYE_CAMERA_EXTRINSIC_COLUMN, + LIDAR_DATA_COLUMN, + PINHOLE_CAMERA_DATA_COLUMN, + PINHOLE_CAMERA_EXTRINSIC_COLUMN, + ROUTE_LANE_GROUP_IDS_COLUMN, + SCENARIO_TAGS_COLUMN, + TIMESTAMP_US_COLUMN, + TRAFFIC_LIGHTS_COLUMNS, + TRAFFIC_LIGHTS_LANE_ID_COLUMN, + TRAFFIC_LIGHTS_STATUS_COLUMN, +) +from py123d.common.utils.mixin import ArrayMixin from py123d.conversion.registry.lidar_index_registry import DefaultLiDARIndex -from py123d.conversion.sensor_io.camera.jpeg_camera_io import decode_image_from_jpeg_binary +from py123d.conversion.sensor_io.camera.jpeg_camera_io import decode_image_from_jpeg_binary, load_image_from_jpeg_file from py123d.conversion.sensor_io.camera.mp4_camera_io import get_mp4_reader_from_path -from py123d.conversion.sensor_io.lidar.draco_lidar_io import load_lidar_from_draco_binary +from py123d.conversion.sensor_io.lidar.draco_lidar_io import is_draco_binary, load_lidar_from_draco_binary from py123d.conversion.sensor_io.lidar.file_lidar_io import load_lidar_pcs_from_file -from py123d.conversion.sensor_io.lidar.laz_lidar_io import load_lidar_from_laz_binary +from py123d.conversion.sensor_io.lidar.laz_lidar_io import is_laz_binary, load_lidar_from_laz_binary from py123d.datatypes.detections.box_detections import ( - BoxDetection, BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper, @@ -29,6 +50,7 @@ from py123d.datatypes.sensors.lidar import LiDAR, LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType from py123d.datatypes.time.time_point import TimePoint +from py123d.datatypes.vehicle_state.dynamic_state import DynamicStateSE3 from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters from py123d.geometry import BoundingBoxSE3, StateSE3, Vector3D @@ -46,69 +68,84 @@ def get_timepoint_from_arrow_table(arrow_table: pa.Table, index: int) -> TimePoint: - return TimePoint.from_us(arrow_table["timestamp"][index].as_py()) + assert TIMESTAMP_US_COLUMN in arrow_table.schema.names, "Timestamp column not found in Arrow table." + return TimePoint.from_us(arrow_table[TIMESTAMP_US_COLUMN][index].as_py()) -def get_ego_vehicle_state_from_arrow_table( - arrow_table: pa.Table, index: int, vehicle_parameters: VehicleParameters -) -> EgoStateSE3: - timepoint = get_timepoint_from_arrow_table(arrow_table, index) - return EgoStateSE3.from_array( - array=pa.array(arrow_table["ego_state"][index]).to_numpy(), - vehicle_parameters=vehicle_parameters, - timepoint=timepoint, - ) +def get_ego_state_se3_from_arrow_table( + arrow_table: pa.Table, + index: int, + vehicle_parameters: VehicleParameters, +) -> Optional[EgoStateSE3]: + + ego_state_se3: Optional[EgoStateSE3] = None + if _all_columns_in_schema(arrow_table, EGO_STATE_SE3_COLUMNS): + timepoint = get_timepoint_from_arrow_table(arrow_table, index) + rear_axle_se3 = StateSE3.from_list(arrow_table[EGO_REAR_AXLE_SE3_COLUMN][index].as_py()) + ego_state_se3 = EgoStateSE3.from_rear_axle( + rear_axle_se3=rear_axle_se3, + vehicle_parameters=vehicle_parameters, + dynamic_state_se3=_get_optional_array_mixin( + arrow_table[EGO_DYNAMIC_STATE_SE3_COLUMN][index].as_py(), DynamicStateSE3 + ), + timepoint=timepoint, + ) + return ego_state_se3 -def get_box_detections_from_arrow_table( +def get_box_detections_se3_from_arrow_table( arrow_table: pa.Table, index: int, log_metadata: LogMetadata, ) -> BoxDetectionWrapper: - timepoint = get_timepoint_from_arrow_table(arrow_table, index) - box_detections: List[BoxDetection] = [] - box_detection_label_class = log_metadata.box_detection_label_class - - for detection_state, detection_velocity, detection_token, detection_label in zip( - arrow_table["box_detection_state"][index].as_py(), - arrow_table["box_detection_velocity"][index].as_py(), - arrow_table["box_detection_token"][index].as_py(), - arrow_table["box_detection_label"][index].as_py(), - ): - box_detection = BoxDetectionSE3( - metadata=BoxDetectionMetadata( - label=box_detection_label_class(detection_label), - timepoint=timepoint, - track_token=detection_token, - confidence=None, - ), - bounding_box_se3=BoundingBoxSE3.from_array(np.array(detection_state)), - velocity=Vector3D.from_array(np.array(detection_velocity)) if detection_velocity else None, - ) - box_detections.append(box_detection) - return BoxDetectionWrapper(box_detections=box_detections) + box_detections: Optional[BoxDetectionWrapper] = None + if _all_columns_in_schema(arrow_table, BOX_DETECTIONS_SE3_COLUMNS): + timepoint = get_timepoint_from_arrow_table(arrow_table, index) + box_detections_list: List[BoxDetectionSE3] = [] + box_detection_label_class = log_metadata.box_detection_label_class + for _bounding_box_se3, _token, _label, _velocity, _num_lidar_points in zip( + arrow_table[BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN][index].as_py(), + arrow_table[BOX_DETECTIONS_TOKEN_COLUMN][index].as_py(), + arrow_table[BOX_DETECTIONS_LABEL_COLUMN][index].as_py(), + arrow_table[BOX_DETECTIONS_VELOCITY_3D_COLUMN][index].as_py(), + arrow_table[BOX_DETECTIONS_NUM_LIDAR_POINTS_COLUMN][index].as_py(), + ): + box_detections_list.append( + BoxDetectionSE3( + metadata=BoxDetectionMetadata( + label=box_detection_label_class(_label), + track_token=_token, + num_lidar_points=_num_lidar_points, + timepoint=timepoint, + ), + bounding_box_se3=BoundingBoxSE3.from_list(_bounding_box_se3), + velocity=_get_optional_array_mixin(_velocity, Vector3D), + ) + ) + box_detections = BoxDetectionWrapper(box_detections=box_detections_list) -def get_traffic_light_detections_from_arrow_table(arrow_table: pa.Table, index: int) -> TrafficLightDetectionWrapper: - timepoint = get_timepoint_from_arrow_table(arrow_table, index) - traffic_light_detections: Optional[List[TrafficLightDetection]] = None + return box_detections - if "traffic_light_ids" in arrow_table.schema.names and "traffic_light_types" in arrow_table.schema.names: + +def get_traffic_light_detections_from_arrow_table(arrow_table: pa.Table, index: int) -> TrafficLightDetectionWrapper: + traffic_lights: Optional[List[TrafficLightDetection]] = None + if _all_columns_in_schema(arrow_table, TRAFFIC_LIGHTS_COLUMNS): + timepoint = get_timepoint_from_arrow_table(arrow_table, index) traffic_light_detections: List[TrafficLightDetection] = [] for lane_id, status in zip( - arrow_table["traffic_light_ids"][index].as_py(), - arrow_table["traffic_light_types"][index].as_py(), + arrow_table[TRAFFIC_LIGHTS_LANE_ID_COLUMN][index].as_py(), + arrow_table[TRAFFIC_LIGHTS_STATUS_COLUMN][index].as_py(), ): - traffic_light_detection = TrafficLightDetection( - timepoint=timepoint, - lane_id=lane_id, - status=TrafficLightStatus(status), + traffic_light_detections.append( + TrafficLightDetection( + timepoint=timepoint, + lane_id=lane_id, + status=TrafficLightStatus(status), + ) ) - traffic_light_detections.append(traffic_light_detection) - - traffic_light_detections = TrafficLightDetectionWrapper(traffic_light_detections=traffic_light_detections) - - return traffic_light_detections + traffic_lights = TrafficLightDetectionWrapper(traffic_light_detections=traffic_light_detections) + return traffic_lights def get_camera_from_arrow_table( @@ -116,50 +153,62 @@ def get_camera_from_arrow_table( index: int, camera_type: Union[PinholeCameraType, FisheyeMEICameraType], log_metadata: LogMetadata, -) -> Union[PinholeCamera, FisheyeMEICamera]: +) -> Optional[Union[PinholeCamera, FisheyeMEICamera]]: + assert isinstance( + camera_type, (PinholeCameraType, FisheyeMEICameraType) + ), f"camera_type must be PinholeCameraType or FisheyeMEICameraType, got {type(camera_type)}" - camera_name = camera_type.serialize() - table_data = arrow_table[f"{camera_name}_data"][index].as_py() - extrinsic_values = arrow_table[f"{camera_name}_extrinsic"][index].as_py() - extrinsic = StateSE3.from_list(extrinsic_values) if extrinsic_values is not None else None + camera: Optional[Union[PinholeCamera, FisheyeMEICamera]] = None - if table_data is None or extrinsic is None: - return None - - image: Optional[npt.NDArray[np.uint8]] = None - - if isinstance(table_data, str): - sensor_root = DATASET_SENSOR_ROOT[log_metadata.dataset] - assert sensor_root is not None, f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}" - full_image_path = Path(sensor_root) / table_data - assert full_image_path.exists(), f"Camera file not found: {full_image_path}" - - image = cv2.imread(str(full_image_path), cv2.IMREAD_COLOR) - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + camera_name = camera_type.serialize() + is_pinhole = isinstance(camera_type, PinholeCameraType) - elif isinstance(table_data, bytes): - image = decode_image_from_jpeg_binary(table_data) - elif isinstance(table_data, int): - image = _unoptimized_demo_mp4_read(log_metadata, camera_name, table_data) + if is_pinhole: + camera_data_column = PINHOLE_CAMERA_DATA_COLUMN(camera_name) + camera_extrinsic_column = PINHOLE_CAMERA_EXTRINSIC_COLUMN(camera_name) else: - raise NotImplementedError( - f"Only string file paths, bytes, or int frame indices are supported for camera data, got {type(table_data)}" - ) + camera_data_column = FISHEYE_CAMERA_DATA_COLUMN(camera_name) + camera_extrinsic_column = FISHEYE_CAMERA_EXTRINSIC_COLUMN(camera_name) + + if _all_columns_in_schema(arrow_table, [camera_data_column, camera_extrinsic_column]): + table_data = arrow_table[camera_data_column][index].as_py() + extrinsic = StateSE3.from_list(arrow_table[camera_extrinsic_column][index].as_py()) + image: Optional[npt.NDArray[np.uint8]] = None + + if isinstance(table_data, str): + sensor_root = DATASET_SENSOR_ROOT[log_metadata.dataset] + assert ( + sensor_root is not None + ), f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}" + full_image_path = Path(sensor_root) / table_data + assert full_image_path.exists(), f"Camera file not found: {full_image_path}" + + image = load_image_from_jpeg_file(full_image_path) + elif isinstance(table_data, bytes): + image = decode_image_from_jpeg_binary(table_data) + elif isinstance(table_data, int): + image = _unoptimized_demo_mp4_read(log_metadata, camera_name, table_data) + else: + raise NotImplementedError( + f"Only string file paths, bytes, or int frame indices are supported for camera data, got {type(table_data)}" + ) - if camera_name.startswith("fcam"): - camera_metadata = log_metadata.fisheye_mei_camera_metadata[camera_type] - return FisheyeMEICamera( - metadata=camera_metadata, - image=image, - extrinsic=extrinsic, - ) - else: - camera_metadata = log_metadata.pinhole_camera_metadata[camera_type] - return PinholeCamera( - metadata=camera_metadata, - image=image, - extrinsic=extrinsic, - ) + if is_pinhole: + camera_metadata = log_metadata.pinhole_camera_metadata[camera_type] + camera = PinholeCamera( + metadata=camera_metadata, + image=image, + extrinsic=extrinsic, + ) + else: + camera_metadata = log_metadata.fisheye_mei_camera_metadata[camera_type] + camera = FisheyeMEICamera( + metadata=camera_metadata, + image=image, + extrinsic=extrinsic, + ) + + return camera def get_lidar_from_arrow_table( @@ -170,14 +219,17 @@ def get_lidar_from_arrow_table( ) -> LiDAR: lidar: Optional[LiDAR] = None - lidar_column_name = f"{lidar_type.serialize()}_data" + # NOTE @DanielDauner: Some LiDAR are stored together and are seperated only during loading. + # In this case, we need to use the merged LiDAR column name. + + lidar_column_name = LIDAR_DATA_COLUMN(lidar_type.serialize()) lidar_column_name = ( - f"{LiDARType.LIDAR_MERGED.serialize()}_data" + LIDAR_DATA_COLUMN(LiDARType.LIDAR_MERGED.serialize()) if lidar_column_name not in arrow_table.schema.names else lidar_column_name ) - if lidar_column_name in arrow_table.schema.names: + if lidar_column_name in arrow_table.schema.names: lidar_data = arrow_table[lidar_column_name][index].as_py() if isinstance(lidar_data, str): lidar_pc_dict = load_lidar_pcs_from_file(relative_path=lidar_data, log_metadata=log_metadata, index=index) @@ -199,17 +251,18 @@ def get_lidar_from_arrow_table( ) elif isinstance(lidar_data, bytes): lidar_metadata = log_metadata.lidar_metadata[lidar_type] - if lidar_data.startswith(b"DRACO"): + if is_draco_binary(lidar_data): # NOTE: DRACO only allows XYZ compression, so we need to override the lidar index here. lidar_metadata.lidar_index = DefaultLiDARIndex lidar = load_lidar_from_draco_binary(lidar_data, lidar_metadata) - elif lidar_data.startswith(b"LASF"): + elif is_laz_binary(lidar_data): lidar = load_lidar_from_laz_binary(lidar_data, lidar_metadata) - elif lidar_data is None: - lidar = None - else: + + else: + raise ValueError("LiDAR binary data is neither in Draco nor LAZ format.") + elif lidar_data is not None: raise NotImplementedError( f"Only string file paths or bytes for LiDAR data are supported, got {type(lidar_data)}" ) @@ -217,6 +270,20 @@ def get_lidar_from_arrow_table( return lidar +def get_route_lane_group_ids_from_arrow_table(arrow_table: pa.Table, index: int) -> Optional[List[int]]: + route_lane_group_ids: Optional[List[int]] = None + if _all_columns_in_schema(arrow_table, [ROUTE_LANE_GROUP_IDS_COLUMN]): + route_lane_group_ids = arrow_table[ROUTE_LANE_GROUP_IDS_COLUMN][index].as_py() + return route_lane_group_ids + + +def get_scenario_tags_from_arrow_table(arrow_table: pa.Table, index: int) -> Optional[List[int]]: + scenario_tags: Optional[List[int]] = None + if _all_columns_in_schema(arrow_table, [SCENARIO_TAGS_COLUMN]): + scenario_tags = arrow_table[SCENARIO_TAGS_COLUMN][index].as_py() + return scenario_tags + + def _unoptimized_demo_mp4_read(log_metadata: LogMetadata, camera_name: str, frame_index: int) -> Optional[np.ndarray]: """A quick and dirty MP4 reader for testing purposes only. Not optimized for performance.""" image: Optional[npt.NDArray[np.uint8]] = None @@ -228,3 +295,18 @@ def _unoptimized_demo_mp4_read(log_metadata: LogMetadata, camera_name: str, fram image = reader.get_frame(frame_index) return image + + +def _get_optional_array_mixin(data: Optional[Union[List, npt.NDArray]], cls: Type[ArrayMixin]) -> Optional[ArrayMixin]: + if data is None: + return None + if isinstance(data, list): + return cls.from_list(data) + elif isinstance(data, np.ndarray): + return cls.from_array(data, copy=False) + else: + raise ValueError(f"Unsupported data type for ArrayMixin conversion: {type(data)}") + + +def _all_columns_in_schema(arrow_table: pa.Table, columns: List[str]) -> bool: + return all(column in arrow_table.schema.names for column in columns) diff --git a/src/py123d/datatypes/vehicle_state/dynamic_state.py b/src/py123d/datatypes/vehicle_state/dynamic_state.py new file mode 100644 index 00000000..ccdec58b --- /dev/null +++ b/src/py123d/datatypes/vehicle_state/dynamic_state.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum + +import numpy as np +import numpy.typing as npt + +from py123d.common.utils.enums import classproperty +from py123d.common.utils.mixin import ArrayMixin +from py123d.geometry import Vector2D, Vector3D + + +class DynamicStateSE3Index(IntEnum): + + VELOCITY_X = 0 + VELOCITY_Y = 1 + VELOCITY_Z = 2 + ACCELERATION_X = 3 + ACCELERATION_Y = 4 + ACCELERATION_Z = 5 + ANGULAR_VELOCITY_X = 6 + ANGULAR_VELOCITY_Y = 7 + ANGULAR_VELOCITY_Z = 8 + + @classproperty + def VELOCITY_3D(cls) -> slice: + return slice(cls.VELOCITY_X, cls.VELOCITY_Z + 1) + + @classproperty + def VELOCITY_2D(cls) -> slice: + return slice(cls.VELOCITY_X, cls.VELOCITY_Y + 1) + + @classproperty + def ACCELERATION_3D(cls) -> slice: + return slice(cls.ACCELERATION_X, cls.ACCELERATION_Z + 1) + + @classproperty + def ACCELERATION_2D(cls) -> slice: + return slice(cls.ACCELERATION_X, cls.ACCELERATION_Y + 1) + + @classproperty + def ANGULAR_VELOCITY_3D(cls) -> slice: + return slice(cls.ANGULAR_VELOCITY_X, cls.ANGULAR_VELOCITY_Z + 1) + + +class DynamicStateSE3(ArrayMixin): + + _array: npt.NDArray[np.float64] + + def __init__( + self, + velocity: Vector3D, + acceleration: Vector3D, + angular_velocity: Vector3D, + ): + array = np.zeros(len(DynamicStateSE3Index), dtype=np.float64) + array[DynamicStateSE3Index.VELOCITY_3D] = velocity.array + array[DynamicStateSE3Index.ACCELERATION_3D] = acceleration.array + array[DynamicStateSE3Index.ANGULAR_VELOCITY_3D] = angular_velocity.array + self._array = array + + @classmethod + def from_array(cls, array: npt.NDArray[np.float64]) -> DynamicStateSE3: + """ + Create a DynamicVehicleState from an array. + :param array: The array containing the dynamic state information. + :return: A DynamicVehicleState instance. + """ + assert array.ndim == 1 + assert array.shape[0] == len(DynamicStateSE3Index) + instance = object.__new__(cls) + instance._array = array + return instance + + @property + def array(self) -> npt.NDArray[np.float64]: + return self._array + + @property + def velocity(self) -> Vector3D: + return Vector3D.from_array(self._array[DynamicStateSE3Index.VELOCITY_3D], copy=False) + + @property + def velocity_3d(self) -> Vector3D: + return self.velocity + + @property + def velocity_2d(self) -> Vector2D: + return Vector2D.from_array(self._array[DynamicStateSE3Index.VELOCITY_2D], copy=False) + + @property + def acceleration(self) -> Vector3D: + return Vector3D.from_array(self._array[DynamicStateSE3Index.ACCELERATION_3D], copy=False) + + @property + def acceleration_3d(self) -> Vector3D: + return self.acceleration + + @property + def acceleration_2d(self) -> Vector2D: + return Vector2D.from_array(self._array[DynamicStateSE3Index.ACCELERATION_2D], copy=False) + + @property + def angular_velocity(self) -> Vector3D: + return Vector3D.from_array(self._array[DynamicStateSE3Index.ANGULAR_VELOCITY_3D], copy=False) + + @property + def dynamic_state_se2(self) -> DynamicStateSE2: + """ + Convert the DynamicVehicleState to a 2D dynamic state. + :return: A DynamicStateSE2 instance. + """ + _array = np.zeros(len(DynamicStateSE2Index), dtype=np.float64) + _array[DynamicStateSE2Index.VELOCITY_2D] = self._array[DynamicStateSE3Index.VELOCITY_2D] + _array[DynamicStateSE2Index.ACCELERATION_2D] = self._array[DynamicStateSE3Index.ACCELERATION_2D] + _array[DynamicStateSE2Index.ANGULAR_VELOCITY_Z] = self._array[DynamicStateSE3Index.ANGULAR_VELOCITY_Z] + return DynamicStateSE2.from_array(_array, copy=False) + + +class DynamicStateSE2Index(IntEnum): + + VELOCITY_X = 0 + VELOCITY_Y = 1 + ACCELERATION_X = 2 + ACCELERATION_Y = 3 + ANGULAR_VELOCITY_Z = 4 + + @classproperty + def VELOCITY_2D(cls) -> slice: + return slice(cls.VELOCITY_X, cls.VELOCITY_Y + 1) + + @classproperty + def ACCELERATION_2D(cls) -> slice: + return slice(cls.ACCELERATION_X, cls.ACCELERATION_Y + 1) + + @classproperty + def ANGULAR_VELOCITY(cls) -> int: + return cls.ANGULAR_VELOCITY_Z + + +@dataclass +class DynamicStateSE2(ArrayMixin): + + _array: npt.NDArray[np.float64] + + def __init__( + self, + velocity: Vector3D, + acceleration: Vector3D, + angular_velocity: Vector3D, + ): + array = np.zeros(len(DynamicStateSE3Index), dtype=np.float64) + array[DynamicStateSE3Index.VELOCITY_3D] = velocity.array + array[DynamicStateSE3Index.ACCELERATION_3D] = acceleration.array + array[DynamicStateSE3Index.ANGULAR_VELOCITY_3D] = angular_velocity.array + self._array = array + + @classmethod + def from_array(cls, array: npt.NDArray[np.float64]) -> DynamicStateSE3: + """ + Create a DynamicVehicleState from an array. + :param array: The array containing the dynamic state information. + :return: A DynamicVehicleState instance. + """ + assert array.ndim == 1 + assert array.shape[0] == len(DynamicStateSE3Index) + instance = object.__new__(cls) + instance._array = array + return instance + + @property + def array(self) -> npt.NDArray[np.float64]: + return self._array + + @property + def velocity(self) -> Vector2D: + return Vector2D.from_array(self._array[DynamicStateSE2Index.VELOCITY_2D], copy=False) + + @property + def velocity_2d(self) -> Vector2D: + return self.velocity + + @property + def acceleration(self) -> Vector2D: + return Vector2D.from_array(self._array[DynamicStateSE2Index.ACCELERATION_2D], copy=False) + + @property + def acceleration_2d(self) -> Vector2D: + return Vector2D.from_array(self._array[DynamicStateSE2Index.ACCELERATION_2D], copy=False) + + @property + def angular_velocity(self) -> float: + return self._array[DynamicStateSE2Index.ANGULAR_VELOCITY_Z] diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index 3ddc09a5..9ff0900a 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -1,16 +1,12 @@ from __future__ import annotations from dataclasses import dataclass -from enum import IntEnum from typing import Final, Optional -import numpy as np -import numpy.typing as npt - -from py123d.common.utils.enums import classproperty from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE2, BoxDetectionSE3 from py123d.datatypes.time.time_point import TimePoint +from py123d.datatypes.vehicle_state.dynamic_state import DynamicStateSE2, DynamicStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import ( VehicleParameters, center_se2_to_rear_axle_se2, @@ -18,117 +14,114 @@ rear_axle_se2_to_center_se2, rear_axle_se3_to_center_se3, ) -from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, StateSE2, StateSE3, Vector2D, Vector3D +from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, StateSE2, StateSE3 EGO_TRACK_TOKEN: Final[str] = "ego_vehicle" -class EgoStateSE3Index(IntEnum): - - X = 0 - Y = 1 - Z = 2 - QW = 3 - QX = 4 - QY = 5 - QZ = 6 - VELOCITY_X = 7 - VELOCITY_Y = 8 - VELOCITY_Z = 9 - ACCELERATION_X = 10 - ACCELERATION_Y = 11 - ACCELERATION_Z = 12 - ANGULAR_VELOCITY_X = 13 - ANGULAR_VELOCITY_Y = 14 - ANGULAR_VELOCITY_Z = 15 - - @classproperty - def STATE_SE3(cls) -> slice: - return slice(cls.X, cls.QZ + 1) - - @classproperty - def DYNAMIC_VEHICLE_STATE(cls) -> slice: - return slice(cls.VELOCITY_X, cls.ANGULAR_VELOCITY_Z + 1) - - @classproperty - def SCALAR(cls) -> slice: - return slice(cls.QW, cls.QW + 1) - - @classproperty - def VECTOR(cls) -> slice: - return slice(cls.QX, cls.QZ + 1) - - -@dataclass class EgoStateSE3: - center_se3: StateSE3 - dynamic_state_se3: DynamicStateSE3 - vehicle_parameters: VehicleParameters - timepoint: Optional[TimePoint] = None - tire_steering_angle: float = 0.0 + def __init__( + self, + rear_axle_se3: StateSE3, + vehicle_parameters: VehicleParameters, + dynamic_state_se3: Optional[DynamicStateSE3] = None, + timepoint: Optional[TimePoint] = None, + tire_steering_angle: Optional[float] = 0.0, + ): + self._rear_axle_se3 = rear_axle_se3 + self._vehicle_parameters = vehicle_parameters + self._dynamic_state_se3 = dynamic_state_se3 + self._timepoint: Optional[TimePoint] = timepoint + self._tire_steering_angle: Optional[float] = tire_steering_angle @classmethod - def from_array( + def from_center( cls, - array: npt.NDArray[np.float64], + center_se3: StateSE3, vehicle_parameters: VehicleParameters, + dynamic_state_se3: Optional[DynamicStateSE3] = None, timepoint: Optional[TimePoint] = None, + tire_steering_angle: float = 0.0, ) -> EgoStateSE3: - state_se3 = StateSE3.from_array(array[EgoStateSE3Index.STATE_SE3]) - dynamic_state = DynamicStateSE3.from_array(array[EgoStateSE3Index.DYNAMIC_VEHICLE_STATE]) - return EgoStateSE3(state_se3, dynamic_state, vehicle_parameters, timepoint) + + rear_axle_se3 = center_se3_to_rear_axle_se3( + center_se3=center_se3, + vehicle_parameters=vehicle_parameters, + ) + + # TODO @DanielDauner: Adapt dynamic state from center to rear-axle + return EgoStateSE3.from_rear_axle( + rear_axle_se3=rear_axle_se3, + vehicle_parameters=vehicle_parameters, + dynamic_state_se3=dynamic_state_se3, + timepoint=timepoint, + tire_steering_angle=tire_steering_angle, + ) @classmethod def from_rear_axle( cls, rear_axle_se3: StateSE3, - dynamic_state_se3: DynamicStateSE3, vehicle_parameters: VehicleParameters, - time_point: TimePoint, + dynamic_state_se3: Optional[DynamicStateSE3] = None, + timepoint: Optional[TimePoint] = None, tire_steering_angle: float = 0.0, ) -> EgoStateSE3: return EgoStateSE3( - center_se3=rear_axle_se3_to_center_se3(rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters), - dynamic_state_se3=dynamic_state_se3, # TODO: Adapt dynamic state rear-axle to center + rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters, - timepoint=time_point, + dynamic_state_se3=dynamic_state_se3, + timepoint=timepoint, tire_steering_angle=tire_steering_angle, ) @property - def array(self) -> npt.NDArray[np.float64]: - """ - Convert the EgoVehicleState to an array. - :return: An array containing the bounding box and dynamic state information. - """ - assert isinstance(self.center_se3, StateSE3) - assert isinstance(self.dynamic_state_se3, DynamicStateSE3) + def rear_axle_se3(self) -> StateSE3: + return self._rear_axle_se3 - center_array = self.center_se3.array - dynamic_array = self.dynamic_state_se3.array + @property + def vehicle_parameters(self) -> VehicleParameters: + return self._vehicle_parameters - return np.concatenate((center_array, dynamic_array), axis=0) + @property + def dynamic_state_se3(self) -> Optional[DynamicStateSE3]: + return self._dynamic_state_se3 @property - def center(self) -> StateSE3: - return self.center_se3 + def timepoint(self) -> Optional[TimePoint]: + return self._timepoint @property - def rear_axle_se3(self) -> StateSE3: - return center_se3_to_rear_axle_se3(center_se3=self.center_se3, vehicle_parameters=self.vehicle_parameters) + def tire_steering_angle(self) -> Optional[float]: + return self._tire_steering_angle @property def rear_axle_se2(self) -> StateSE2: - return self.rear_axle_se3.state_se2 + return self._rear_axle_se3.state_se2 @property def rear_axle(self) -> StateSE3: - return self.rear_axle_se3 + return self._rear_axle_se3 @property - def bounding_box(self) -> BoundingBoxSE3: + def center_se3(self) -> StateSE3: + return rear_axle_se3_to_center_se3( + rear_axle_se3=self._rear_axle_se3, + vehicle_parameters=self._vehicle_parameters, + ) + + @property + def center_se2(self) -> StateSE2: + return self.center_se3.state_se2 + + @property + def center(self) -> StateSE3: + return self.center_se3 + + @property + def bounding_box_se3(self) -> BoundingBoxSE3: return BoundingBoxSE3( center=self.center_se3, length=self.vehicle_parameters.length, @@ -136,41 +129,41 @@ def bounding_box(self) -> BoundingBoxSE3: height=self.vehicle_parameters.height, ) - @property - def bounding_box_se3(self) -> BoundingBoxSE3: - return self.bounding_box - @property def bounding_box_se2(self) -> BoundingBoxSE2: return self.bounding_box.bounding_box_se2 @property - def box_detection(self) -> BoxDetectionSE3: + def bounding_box(self) -> BoundingBoxSE3: + return self.bounding_box_se3 + + @property + def box_detection_se3(self) -> BoxDetectionSE3: return BoxDetectionSE3( metadata=BoxDetectionMetadata( label=DefaultBoxDetectionLabel.EGO, timepoint=self.timepoint, track_token=EGO_TRACK_TOKEN, - confidence=1.0, + num_lidar_points=None, ), bounding_box_se3=self.bounding_box, velocity=self.dynamic_state_se3.velocity, ) - @property - def box_detection_se3(self) -> BoxDetectionSE3: - return self.box_detection - @property def box_detection_se2(self) -> BoxDetectionSE2: return self.box_detection.box_detection_se2 + @property + def box_detection(self) -> BoxDetectionSE3: + return self.box_detection_se3 + @property def ego_state_se2(self) -> EgoStateSE2: - return EgoStateSE2( - center_se2=self.center_se3.state_se2, - dynamic_state_se2=self.dynamic_state_se3.dynamic_state_se2, + return EgoStateSE2.from_rear_axle( + rear_axle_se2=self.rear_axle_se2, vehicle_parameters=self.vehicle_parameters, + dynamic_state_se2=self.dynamic_state_se3.dynamic_state_se2 if self.dynamic_state_se3 else None, timepoint=self.timepoint, tire_steering_angle=self.tire_steering_angle, ) @@ -179,11 +172,19 @@ def ego_state_se2(self) -> EgoStateSE2: @dataclass class EgoStateSE2: - center_se2: StateSE2 - dynamic_state_se2: DynamicStateSE2 - vehicle_parameters: VehicleParameters - timepoint: Optional[TimePoint] = None - tire_steering_angle: float = 0.0 + def __init__( + self, + rear_axle_se2: StateSE2, + vehicle_parameters: VehicleParameters, + dynamic_state_se2: Optional[DynamicStateSE2] = None, + timepoint: Optional[TimePoint] = None, + tire_steering_angle: Optional[float] = 0.0, + ): + self._rear_axle_se2: StateSE2 = rear_axle_se2 + self._vehicle_parameters: VehicleParameters = vehicle_parameters + self._dynamic_state_se2: Optional[DynamicStateSE2] = dynamic_state_se2 + self._timepoint: Optional[TimePoint] = timepoint + self._tire_steering_angle: Optional[float] = tire_steering_angle @classmethod def from_rear_axle( @@ -191,32 +192,76 @@ def from_rear_axle( rear_axle_se2: StateSE2, dynamic_state_se2: DynamicStateSE2, vehicle_parameters: VehicleParameters, - time_point: TimePoint, + timepoint: TimePoint, tire_steering_angle: float = 0.0, ) -> EgoStateSE2: return EgoStateSE2( - center_se2=rear_axle_se2_to_center_se2(rear_axle_se2=rear_axle_se2, vehicle_parameters=vehicle_parameters), - dynamic_state_se2=dynamic_state_se2, # TODO: Adapt dynamic state rear-axle to center + rear_axle_se2=rear_axle_se2, + dynamic_state_se2=dynamic_state_se2, vehicle_parameters=vehicle_parameters, - timepoint=time_point, + timepoint=timepoint, tire_steering_angle=tire_steering_angle, ) - @property - def center(self) -> StateSE2: - return self.center_se2 + @classmethod + def from_center( + cls, + center_se2: StateSE2, + dynamic_state_se2: DynamicStateSE2, + vehicle_parameters: VehicleParameters, + timepoint: TimePoint, + tire_steering_angle: float = 0.0, + ) -> EgoStateSE2: + + rear_axle_se2 = center_se2_to_rear_axle_se2( + center_se2=center_se2, + vehicle_parameters=vehicle_parameters, + ) + + # TODO @DanielDauner: Adapt dynamic state from center to rear-axle + return EgoStateSE2.from_rear_axle( + rear_axle_se2=rear_axle_se2, + dynamic_state_se2=dynamic_state_se2, + vehicle_parameters=vehicle_parameters, + timepoint=timepoint, + tire_steering_angle=tire_steering_angle, + ) @property def rear_axle_se2(self) -> StateSE2: - return center_se2_to_rear_axle_se2(center_se2=self.center_se2, vehicle_parameters=self.vehicle_parameters) + return self._rear_axle_se2 + + @property + def vehicle_parameters(self) -> VehicleParameters: + return self._vehicle_parameters + + @property + def dynamic_state_se2(self) -> Optional[DynamicStateSE3]: + return self._dynamic_state_se2 + + @property + def timepoint(self) -> Optional[TimePoint]: + return self._timepoint + + @property + def tire_steering_angle(self) -> Optional[float]: + return self._tire_steering_angle @property def rear_axle(self) -> StateSE2: return self.rear_axle_se2 @property - def bounding_box(self) -> BoundingBoxSE2: + def center_se2(self) -> StateSE2: + return rear_axle_se2_to_center_se2(rear_axle_se2=self.rear_axle_se2, vehicle_parameters=self.vehicle_parameters) + + @property + def center(self) -> StateSE3: + return self.center_se2 + + @property + def bounding_box_se2(self) -> BoundingBoxSE2: return BoundingBoxSE2( center=self.center_se2, length=self.vehicle_parameters.length, @@ -224,125 +269,22 @@ def bounding_box(self) -> BoundingBoxSE2: ) @property - def bounding_box_se2(self) -> BoundingBoxSE2: - return self.bounding_box + def bounding_box(self) -> BoundingBoxSE2: + return self.bounding_box_se2 @property - def box_detection(self) -> BoxDetectionSE2: + def box_detection_se2(self) -> BoxDetectionSE2: return BoxDetectionSE2( metadata=BoxDetectionMetadata( label=DefaultBoxDetectionLabel.EGO, timepoint=self.timepoint, track_token=EGO_TRACK_TOKEN, - confidence=1.0, + num_lidar_points=None, ), - bounding_box_se2=self.bounding_box_se2, + bounding_box_se2=self.bounding_box, velocity=self.dynamic_state_se2.velocity, ) @property - def box_detection_se2(self) -> BoxDetectionSE2: - return self.box_detection - - -class DynamicStateSE3Index(IntEnum): - - VELOCITY_X = 0 - VELOCITY_Y = 1 - VELOCITY_Z = 2 - ACCELERATION_X = 3 - ACCELERATION_Y = 4 - ACCELERATION_Z = 5 - ANGULAR_VELOCITY_X = 6 - ANGULAR_VELOCITY_Y = 7 - ANGULAR_VELOCITY_Z = 8 - - @classproperty - def VELOCITY(cls) -> slice: - return slice(cls.VELOCITY_X, cls.VELOCITY_Z + 1) - - @classproperty - def ACCELERATION(cls) -> slice: - return slice(cls.ACCELERATION_X, cls.ACCELERATION_Z + 1) - - @classproperty - def ANGULAR_VELOCITY(cls) -> slice: - return slice(cls.ANGULAR_VELOCITY_X, cls.ANGULAR_VELOCITY_Z + 1) - - -@dataclass -class DynamicStateSE3: - # TODO: Make class array like - - velocity: Vector3D - acceleration: Vector3D - angular_velocity: Vector3D - - tire_steering_rate: float = 0.0 - angular_acceleration: float = 0.0 - - @classmethod - def from_array(cls, array: npt.NDArray[np.float64]) -> DynamicStateSE3: - """ - Create a DynamicVehicleState from an array. - :param array: The array containing the dynamic state information. - :return: A DynamicVehicleState instance. - """ - assert array.ndim == 1 - assert array.shape[0] == len(DynamicStateSE3Index) - velocity = Vector3D.from_array(array[DynamicStateSE3Index.VELOCITY]) - acceleration = Vector3D.from_array(array[DynamicStateSE3Index.ACCELERATION]) - angular_velocity = Vector3D.from_array(array[DynamicStateSE3Index.ANGULAR_VELOCITY]) - return DynamicStateSE3(velocity, acceleration, angular_velocity) - - @property - def array(self) -> npt.NDArray[np.float64]: - """ - Convert the DynamicVehicleState to an array. - :return: An array containing the velocity, acceleration, and angular velocity. - """ - assert isinstance(self.velocity, Vector3D) - assert isinstance(self.acceleration, Vector3D) - assert isinstance(self.angular_velocity, Vector3D) - - return np.concatenate( - ( - self.velocity.array, - self.acceleration.array, - self.angular_velocity.array, - ), - axis=0, - ) - - @property - def dynamic_state_se2(self) -> DynamicStateSE2: - """ - Convert the DynamicVehicleState to a 2D dynamic state. - :return: A DynamicStateSE2 instance. - """ - return DynamicStateSE2( - velocity=self.velocity.vector_2d, - acceleration=self.acceleration.vector_2d, - angular_velocity=self.angular_velocity.z, - tire_steering_rate=self.tire_steering_rate, - angular_acceleration=self.angular_acceleration, - ) - - -@dataclass -class DynamicStateSE2: - - velocity: Vector2D - acceleration: Vector2D - angular_velocity: float - - tire_steering_rate: float = 0.0 - angular_acceleration: float = 0.0 - - @property - def array(self) -> npt.NDArray[np.float64]: - """ - Convert the DynamicVehicleState to an array. - :return: An array containing the velocity, acceleration, and angular velocity. - """ - return np.concatenate((self.velocity.array, self.acceleration.array, np.array([self.angular_velocity])), axis=0) + def box_detection(self) -> BoxDetectionSE2: + return self.box_detection_se2 diff --git a/src/py123d/script/config/common/scene_filter/all_scenes.yaml b/src/py123d/script/config/common/scene_filter/all_scenes.yaml new file mode 100644 index 00000000..1b619af1 --- /dev/null +++ b/src/py123d/script/config/common/scene_filter/all_scenes.yaml @@ -0,0 +1,20 @@ +_target_: py123d.datatypes.scene.scene_filter.SceneFilter +_convert_: 'all' + +split_types: null +split_names: null +log_names: null + + +locations: null +scene_uuids: null +timestamp_threshold_s: null +ego_displacement_minimum_m: null + +duration_s: null +history_s: null + +pinhole_camera_types: null + +max_num_scenes: null +shuffle: True diff --git a/src/py123d/visualization/viser/viser_config.py b/src/py123d/visualization/viser/viser_config.py index be22f7f5..2bd4871f 100644 --- a/src/py123d/visualization/viser/viser_config.py +++ b/src/py123d/visualization/viser/viser_config.py @@ -79,7 +79,7 @@ class ViserConfig: # -> Frustum fisheye_frustum_visible: bool = True fisheye_mei_camera_frustum_visible: bool = True - fisheye_mei_camera_frustum_types: List[PinholeCameraType] = field( + fisheye_mei_camera_frustum_types: List[FisheyeMEICameraType] = field( default_factory=lambda: [fcam for fcam in FisheyeMEICameraType] ) fisheye_frustum_scale: float = 1.0 @@ -109,7 +109,7 @@ def _resolve_enum_arguments( self.camera_frustum_types, ) self.camera_gui_types = _resolve_enum_arguments( - FisheyeMEICameraType, + PinholeCameraType, self.camera_gui_types, ) self.fisheye_mei_camera_frustum_types = _resolve_enum_arguments( diff --git a/tests/unit/conversion/registry/__init__.py b/tests/unit/conversion/registry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/conversion/registry/test_box_detection_label_registry.py b/tests/unit/conversion/registry/test_box_detection_label_registry.py new file mode 100644 index 00000000..5ba59473 --- /dev/null +++ b/tests/unit/conversion/registry/test_box_detection_label_registry.py @@ -0,0 +1,45 @@ +import unittest + +from py123d.conversion.registry.box_detection_label_registry import BOX_DETECTION_LABEL_REGISTRY, BoxDetectionLabel + + +class TestBoxDetectionLabelRegistry(unittest.TestCase): + + def test_correct_type(self): + """Test that all registered box detection labels are of correct type.""" + for label_class in BOX_DETECTION_LABEL_REGISTRY.values(): + self.assertTrue(issubclass(label_class, BoxDetectionLabel)) + + def test_initialize_all_labels(self): + """Test that all registered box detection labels can be initialized.""" + for label_enum_class in BOX_DETECTION_LABEL_REGISTRY.values(): + label_enum_class: BoxDetectionLabel + for integer in range(len(label_enum_class)): + label_a = label_enum_class.from_int(integer) + label_b = label_enum_class(integer) + self.assertIsInstance(label_a, label_enum_class) + self.assertIsInstance(label_b, label_enum_class) + + def test_serialize_deserialize(self): + """Test that all registered box detection labels can be serialized and deserialized.""" + for label_enum_class in BOX_DETECTION_LABEL_REGISTRY.values(): + label_enum_class: BoxDetectionLabel + for integer in range(len(label_enum_class)): + label = label_enum_class.from_int(integer) + serialized_lower = label.serialize(lower=True) + serialized_upper = label.serialize(lower=False) + deserialized_lower = label_enum_class.deserialize(serialized_lower) + deserialized_upper = label_enum_class.deserialize(serialized_upper) + self.assertEqual(label, deserialized_lower) + self.assertEqual(label, deserialized_upper) + + def test_to_default(self): + """Test that all registered box detection labels can be converted to DefaultBoxDetectionLabel.""" + from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel + + for label_enum_class in BOX_DETECTION_LABEL_REGISTRY.values(): + label_enum_class: BoxDetectionLabel + for integer in range(len(label_enum_class)): + label = label_enum_class.from_int(integer) + default_label = label.to_default() + self.assertIsInstance(default_label, DefaultBoxDetectionLabel) diff --git a/tests/unit/conversion/registry/test_lidar_registry.py b/tests/unit/conversion/registry/test_lidar_registry.py new file mode 100644 index 00000000..4fd2ea57 --- /dev/null +++ b/tests/unit/conversion/registry/test_lidar_registry.py @@ -0,0 +1,40 @@ +import unittest +from enum import IntEnum + +import numpy as np + +from py123d.conversion.registry.lidar_index_registry import LIDAR_INDEX_REGISTRY, LiDARIndex + + +class TestLiDARRegistry(unittest.TestCase): + + def test_registered_types(self): + """Test that all registered LiDAR types are of correct type.""" + for lidar_class in LIDAR_INDEX_REGISTRY.values(): + self.assertTrue(issubclass(lidar_class, LiDARIndex)) + + def test_initialize_all_types(self): + """Test that all registered LiDAR types can be initialized.""" + for lidar_enum_class in LIDAR_INDEX_REGISTRY.values(): + lidar_enum_class: LiDARIndex + for integer in range(len(lidar_enum_class)): + lidar_pc_index = lidar_enum_class(integer) + self.assertIsInstance(lidar_pc_index, LiDARIndex) + self.assertIsInstance(lidar_pc_index, IntEnum) + self.assertIsInstance(lidar_pc_index, int) + + def test_xy_slice(self): + """Test that all registered LiDAR types have correct xy slice.""" + for lidar_enum_class in LIDAR_INDEX_REGISTRY.values(): + lidar_enum_class: LiDARIndex + dummy_lidar_pc = np.zeros((42, len(lidar_enum_class)), dtype=np.float32) + lidar_pc_xy_slice = dummy_lidar_pc[..., lidar_enum_class.XY] + self.assertEqual(lidar_pc_xy_slice.shape[-1], 2) + + def test_xyz_slice(self): + """Test that all registered LiDAR types have correct xyz slice.""" + for lidar_enum_class in LIDAR_INDEX_REGISTRY.values(): + lidar_enum_class: LiDARIndex + dummy_lidar_pc = np.zeros((42, len(lidar_enum_class)), dtype=np.float32) + lidar_pc_xyz_slice = dummy_lidar_pc[..., lidar_enum_class.XYZ] + self.assertEqual(lidar_pc_xyz_slice.shape[-1], 3) From ecf405d4b8b69d12a1ddfd94b782379a69dec1f8 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 7 Nov 2025 23:50:52 +0100 Subject: [PATCH 02/50] Restructure docs & adding some tests. --- .github/workflows/deploy-docs.yaml | 2 +- .gitignore | 2 - docs/_static/123D_logo_transparent_black.svg | 97 +++++ docs/_static/123D_logo_transparent_white.svg | 115 ++++++ docs/_static/logo_black.png | Bin 2486726 -> 0 bytes docs/_static/logo_white.png | Bin 2486726 -> 0 bytes docs/api/geometry/01_points.rst | 10 + docs/api/geometry/02_vectors.rst | 10 + docs/api/geometry/03_rotations.rst | 10 + docs/api/geometry/04_se.rst | 10 + docs/api/geometry/05_bounding_boxes.rst | 10 + docs/api/geometry/06_polylines.rst | 14 + docs/api/geometry/07_indexing_enums.rst | 18 + docs/api/geometry/index.rst | 14 + docs/api/map/index.rst | 12 + docs/api/map/map_api.rst | 12 + docs/api/map/map_layers.rst | 12 + docs/conf.py | 31 +- docs/geometry.rst | 87 ----- docs/index.rst | 4 +- notebooks/bev_matplotlib.ipynb | 23 +- pyproject.toml | 2 +- src/py123d/__init__.py | 2 +- src/py123d/common/utils/mixin.py | 4 + .../datasets/av2/av2_map_conversion.py | 4 +- .../datasets/av2/av2_sensor_converter.py | 2 +- .../datasets/av2/utils/av2_constants.py | 2 +- .../datasets/kitti360/kitti360_converter.py | 2 +- .../kitti360/kitti360_map_conversion.py | 4 +- .../datasets/nuplan/nuplan_converter.py | 2 +- .../datasets/nuplan/nuplan_map_conversion.py | 6 +- .../datasets/nuplan/utils/nuplan_constants.py | 2 +- .../datasets/nuscenes/nuscenes_converter.py | 2 +- .../nuscenes/nuscenes_map_conversion.py | 4 +- .../wopd/utils/womp_boundary_utils.py | 4 +- .../datasets/wopd/utils/wopd_constants.py | 2 +- .../datasets/wopd/wopd_converter.py | 2 +- .../datasets/wopd/wopd_map_conversion.py | 6 +- .../map_writer/abstract_map_writer.py | 4 +- .../conversion/map_writer/gpkg_map_writer.py | 6 +- .../registry/box_detection_label_registry.py | 109 +++--- .../opendrive/opendrive_map_conversion.py | 4 +- .../map_utils/road_edge/road_edge_3d_utils.py | 2 +- src/py123d/datatypes/detections/__init__.py | 12 + .../datatypes/detections/box_detections.py | 39 +- src/py123d/datatypes/map/__init__.py | 18 + .../datatypes/{maps => map}/abstract_map.py | 6 +- .../{maps => map}/abstract_map_objects.py | 38 +- .../datatypes/{maps => map}/cache/__init__.py | 0 .../{maps => map}/cache/cache_map_objects.py | 4 +- .../datatypes/{maps => map}/gpkg/__init__.py | 0 .../datatypes/{maps => map}/gpkg/gpkg_map.py | 12 +- .../{maps => map}/gpkg/gpkg_map_objects.py | 6 +- .../{maps => map}/gpkg/gpkg_utils.py | 0 .../datatypes/{maps => map}/map_datatypes.py | 0 .../datatypes/{maps => map}/map_metadata.py | 0 src/py123d/datatypes/scene/__init__.py | 4 + src/py123d/datatypes/scene/abstract_scene.py | 2 +- .../datatypes/scene/arrow/arrow_scene.py | 4 +- src/py123d/datatypes/scene/scene_metadata.py | 2 +- src/py123d/geometry/point.py | 18 +- src/py123d/geometry/se.py | 12 +- src/py123d/geometry/vector.py | 2 - src/py123d/visualization/color/color.py | 2 +- src/py123d/visualization/color/default.py | 92 +++-- .../visualization/matplotlib/observation.py | 6 +- .../viser/elements/map_elements.py | 4 +- .../visualization/viser/viser_viewer.py | 2 +- .../detections/test_box_detections.py | 360 ++++++++++++++++++ .../detections/test_traffic_lights.py | 85 +++++ 70 files changed, 1122 insertions(+), 278 deletions(-) create mode 100644 docs/_static/123D_logo_transparent_black.svg create mode 100644 docs/_static/123D_logo_transparent_white.svg delete mode 100644 docs/_static/logo_black.png delete mode 100644 docs/_static/logo_white.png create mode 100644 docs/api/geometry/01_points.rst create mode 100644 docs/api/geometry/02_vectors.rst create mode 100644 docs/api/geometry/03_rotations.rst create mode 100644 docs/api/geometry/04_se.rst create mode 100644 docs/api/geometry/05_bounding_boxes.rst create mode 100644 docs/api/geometry/06_polylines.rst create mode 100644 docs/api/geometry/07_indexing_enums.rst create mode 100644 docs/api/geometry/index.rst create mode 100644 docs/api/map/index.rst create mode 100644 docs/api/map/map_api.rst create mode 100644 docs/api/map/map_layers.rst delete mode 100644 docs/geometry.rst create mode 100644 src/py123d/datatypes/map/__init__.py rename src/py123d/datatypes/{maps => map}/abstract_map.py (92%) rename src/py123d/datatypes/{maps => map}/abstract_map_objects.py (94%) rename src/py123d/datatypes/{maps => map}/cache/__init__.py (100%) rename src/py123d/datatypes/{maps => map}/cache/cache_map_objects.py (98%) rename src/py123d/datatypes/{maps => map}/gpkg/__init__.py (100%) rename src/py123d/datatypes/{maps => map}/gpkg/gpkg_map.py (97%) rename src/py123d/datatypes/{maps => map}/gpkg/gpkg_map_objects.py (98%) rename src/py123d/datatypes/{maps => map}/gpkg/gpkg_utils.py (100%) rename src/py123d/datatypes/{maps => map}/map_datatypes.py (100%) rename src/py123d/datatypes/{maps => map}/map_metadata.py (100%) create mode 100644 tests/unit/datatypes/detections/test_box_detections.py create mode 100644 tests/unit/datatypes/detections/test_traffic_lights.py diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index aff1e7dc..40c83866 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -3,7 +3,7 @@ name: docs on: push: branches: - - main # Change this to your branch name (e.g., docs, dev, etc.) + - dev_v0.0.8 # Change this to your branch name (e.g., docs, dev, etc.) workflow_dispatch: # Allows manual triggering permissions: diff --git a/.gitignore b/.gitignore index 971a12d1..426cc468 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,3 @@ docs/_build/ docs/build/ _build/ .doctrees/ - -jbwang_* diff --git a/docs/_static/123D_logo_transparent_black.svg b/docs/_static/123D_logo_transparent_black.svg new file mode 100644 index 00000000..b9214f93 --- /dev/null +++ b/docs/_static/123D_logo_transparent_black.svg @@ -0,0 +1,97 @@ + + + + diff --git a/docs/_static/123D_logo_transparent_white.svg b/docs/_static/123D_logo_transparent_white.svg new file mode 100644 index 00000000..f98386fa --- /dev/null +++ b/docs/_static/123D_logo_transparent_white.svg @@ -0,0 +1,115 @@ + + + + diff --git a/docs/_static/logo_black.png b/docs/_static/logo_black.png deleted file mode 100644 index 6717f5f81f94a4adb7c3042fe6f5332891afecfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2486726 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 diff --git a/docs/_static/logo_white.png b/docs/_static/logo_white.png deleted file mode 100644 index 16aec842680f35dc3210d983a6f0e3c745216058..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2486726 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�SG_<0uX=z1Rwwb2tc3)0t6~* zfWjpRKmY;|fB*y_009U<00Izz00bZaflLs1?z9s(Vl6e+5`wxq&)+S4*>{300Izz00bZa0SG_<0uX?J z5ZLduCmv)i051gyKmY;|fIt=r5U9xFtm88TAOHafKmY;|fB*y_009U<00Izz00bZa z0SIJ+!1L#9wwvK=0h|a_l;;O$;{}D65P$##AOHafKmY;|fB*y_009U<00Izz00bZa z0apk(5utEpGVlWe5P$##3MKHTgT_dcPF}yheU2!!E%hM)0SG_<0uX=z1Rwwb2tWV= z5P$##Odvp@!h~5s0|X!d0SG_<0uX=z1RwwblL*}N{Wj;a7Qmz#K??*R009U<00Izj zngD?cr>7U^AOHafKmY;|fB*y_009U<00Izz00bZa0aFMx={lqhYXMA|4Rn}9fIx*w zvw;=}KmY;|fB*y_009U<00Izz00bZa0SG_<0uX>e1_%(G$iO7yA_S5nuztJKTC)}) zA>QTr!3mATFc5$M1Rwwb2tWV=5P$##AOHafKmY;|h?@Y>ins~WKLj8E0SG_<0uX=z z1VRwlvwZl+tOW=GkfsoT00bZa0SG_<0uX?J@dOA|7(Yb_ga8B}009U<00Izz00bZa z0SG_<0uYFpz*ZML`YCGxB8EzL5P$##f)XH55tJP5AOHafKmY;|fB*y_009U<00Izz z00bZa0SG_<0uTs9fZ#+RY!)>7SMxC=SPNjmL?ICZ5P$##AOHafKmY;|fB*y_009U< z00IzzK(+}GsL1yG<2?i*009U<00IRPIP8&@V_6GOAhb}`%kzUnwWl!zAOHafKmY;| zfB*y_009VCL4at571M-B2tWV=5P$##AOHafKmY;|fPkw6#?P9zBWnR%on8Eb00bZa z0SNdZK%l}8CF(!`0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*z+A~1CP(I;857Jxv7 zO)o68LI45~fB*y_009U<00Izz00bZa0SG_<0uX=z1RNkhaKeE}#03aI00J2$@ZFMa zkFjJeKt^W>w;=!l2tWV=5P$##AOHafKmY;|fPfDI1S)($q6!2c009U<00Izz00bZa zf$R|2@c4^IuofWk`7X~7PJB!Tga8B}009U<00JfvAX;J4Y@h`K5P$##AOHafKmY;| zfB*y_009U<00Izjj==Os58slt0M1P-4n;_SKt+Tw=?Vf6fB*y_009U<00Izz00bZa z0SG_<0uX=z1Rwx``Uw!6sGkuvAOL}S30%><;eS~RP%kDbKmY;|fB*y_009U<00Izz z00bZa0SG_<0_zYUP_YgYEih7XlD~00fL7u+=43jkISifHBw*0s#m>00Izz z00bZa0SG_<0uX=z1k5Brpu)^~K@|ib009U<00Izz00bZafrJS(UiRb{tOZCII>SK# z0uX=z1Rwwb2nYcJ6}$`}009U<00Izz00bZa0SG_<0uX=z1Rwwb2*gF;{t+h+^St7Y m{rdJfBCav$7Xp?Oc>nYMyH list: """Convert the array to a Python list.""" return self.array.tolist() + def to_list(self) -> list: + """Convert the array to a Python list.""" + return self.array.tolist() + def copy(self) -> ArrayMixin: """Return a copy of the object with a copied array.""" return self.__class__.from_array(self.array, copy=True) diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index a55a9cf4..1d4ec2b2 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -15,7 +15,7 @@ split_line_geometry_by_max_length, ) from py123d.conversion.utils.map_utils.road_edge.road_edge_3d_utils import lift_road_edges_to_3d -from py123d.datatypes.maps.cache.cache_map_objects import ( +from py123d.datatypes.map.cache.cache_map_objects import ( CacheCrosswalk, CacheGenericDrivable, CacheIntersection, @@ -24,7 +24,7 @@ CacheRoadEdge, CacheRoadLine, ) -from py123d.datatypes.maps.map_datatypes import RoadEdgeType +from py123d.datatypes.map.map_datatypes import RoadEdgeType from py123d.geometry import OccupancyMap2D, Point3DIndex, Polyline2D, Polyline3D LANE_GROUP_MARK_TYPES: List[str] = [ diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 47a25be8..1d71bee0 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -19,7 +19,7 @@ from py123d.conversion.registry.box_detection_label_registry import AV2SensorBoxDetectionLabel from py123d.conversion.registry.lidar_index_registry import AVSensorLiDARIndex from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.datatypes.scene.scene_metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( diff --git a/src/py123d/conversion/datasets/av2/utils/av2_constants.py b/src/py123d/conversion/datasets/av2/utils/av2_constants.py index 30b59fa2..623f3bf9 100644 --- a/src/py123d/conversion/datasets/av2/utils/av2_constants.py +++ b/src/py123d/conversion/datasets/av2/utils/av2_constants.py @@ -1,6 +1,6 @@ from typing import Dict, Final, Set -from py123d.datatypes.maps.map_datatypes import RoadLineType +from py123d.datatypes.map.map_datatypes import RoadLineType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType AV2_SENSOR_SPLITS: Set[str] = {"av2-sensor_train", "av2-sensor_val", "av2-sensor_test"} diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 71688aef..3154fd90 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -33,7 +33,7 @@ BoxDetectionSE3, BoxDetectionWrapper, ) -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.datatypes.scene.scene_metadata import LogMetadata from py123d.datatypes.sensors.fisheye_mei_camera import ( FisheyeMEICameraMetadata, diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py index 847250eb..47c550de 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py @@ -13,13 +13,13 @@ split_line_geometry_by_max_length, ) from py123d.conversion.utils.map_utils.road_edge.road_edge_3d_utils import lift_road_edges_to_3d -from py123d.datatypes.maps.cache.cache_map_objects import ( +from py123d.datatypes.map.cache.cache_map_objects import ( CacheCarpark, CacheGenericDrivable, CacheRoadEdge, CacheWalkway, ) -from py123d.datatypes.maps.map_datatypes import RoadEdgeType +from py123d.datatypes.map.map_datatypes import RoadEdgeType from py123d.geometry.polyline import Polyline3D MAX_ROAD_EDGE_LENGTH = 100.0 # meters, used to filter out very long road edges diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index d7ed0ec2..e0c03785 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -28,7 +28,7 @@ from py123d.conversion.registry.lidar_index_registry import NuPlanLiDARIndex from py123d.datatypes.detections.box_detections import BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetection, TrafficLightDetectionWrapper -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.datatypes.scene.scene_metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py index b8b010cb..f07e15eb 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py @@ -17,7 +17,7 @@ get_road_edge_linear_rings, split_line_geometry_by_max_length, ) -from py123d.datatypes.maps.cache.cache_map_objects import ( +from py123d.datatypes.map.cache.cache_map_objects import ( CacheCarpark, CacheCrosswalk, CacheGenericDrivable, @@ -28,8 +28,8 @@ CacheRoadLine, CacheWalkway, ) -from py123d.datatypes.maps.gpkg.gpkg_utils import get_all_rows_with_value, get_row_with_value -from py123d.datatypes.maps.map_datatypes import RoadEdgeType +from py123d.datatypes.map.gpkg.gpkg_utils import get_all_rows_with_value, get_row_with_value +from py123d.datatypes.map.map_datatypes import RoadEdgeType from py123d.geometry.polyline import Polyline2D, Polyline3D MAX_ROAD_EDGE_LENGTH: Final[float] = 100.0 # meters, used to filter out very long road edges. TODO @add to config? diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py index d904a698..cb7e7076 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py @@ -2,7 +2,7 @@ from py123d.conversion.registry.box_detection_label_registry import NuPlanBoxDetectionLabel from py123d.datatypes.detections.traffic_light_detections import TrafficLightStatus -from py123d.datatypes.maps.map_datatypes import RoadLineType +from py123d.datatypes.map.map_datatypes import RoadLineType from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.time.time_point import TimePoint diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 654e8aee..6f7cee92 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -20,7 +20,7 @@ from py123d.conversion.registry.box_detection_label_registry import NuScenesBoxDetectionLabel from py123d.conversion.registry.lidar_index_registry import NuScenesLiDARIndex from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.datatypes.scene.scene_metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py index 39ae313a..4a4b34c0 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py @@ -18,7 +18,7 @@ split_line_geometry_by_max_length, split_polygon_by_grid, ) -from py123d.datatypes.maps.cache.cache_map_objects import ( +from py123d.datatypes.map.cache.cache_map_objects import ( CacheCarpark, CacheCrosswalk, CacheGenericDrivable, @@ -29,7 +29,7 @@ CacheRoadLine, CacheWalkway, ) -from py123d.datatypes.maps.map_datatypes import RoadEdgeType, RoadLineType +from py123d.datatypes.map.map_datatypes import RoadEdgeType, RoadLineType from py123d.geometry import OccupancyMap2D, Polyline2D, Polyline3D from py123d.geometry.utils.polyline_utils import offset_points_perpendicular diff --git a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py index e9e6afad..a85330d1 100644 --- a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py +++ b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py @@ -4,8 +4,8 @@ import numpy as np import shapely.geometry as geom -from py123d.datatypes.maps.abstract_map_objects import AbstractRoadEdge, AbstractRoadLine -from py123d.datatypes.maps.map_datatypes import LaneType +from py123d.datatypes.map.abstract_map_objects import AbstractRoadEdge, AbstractRoadLine +from py123d.datatypes.map.map_datatypes import LaneType from py123d.geometry import OccupancyMap2D, Point3D, Polyline3D, PolylineSE2, StateSE2, Vector2D from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame from py123d.geometry.utils.rotation_utils import normalize_angle diff --git a/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py b/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py index efa577d6..c9a33b4f 100644 --- a/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py +++ b/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py @@ -1,7 +1,7 @@ from typing import Dict, List from py123d.conversion.registry.box_detection_label_registry import WOPDBoxDetectionLabel -from py123d.datatypes.maps.map_datatypes import LaneType, RoadEdgeType, RoadLineType +from py123d.datatypes.map.map_datatypes import LaneType, RoadEdgeType, RoadLineType from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 5ae3512c..85047ace 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -23,7 +23,7 @@ from py123d.conversion.registry.lidar_index_registry import DefaultLiDARIndex, WOPDLiDARIndex from py123d.conversion.utils.sensor_utils.camera_conventions import CameraConvention, convert_camera_convention from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.datatypes.scene.scene_metadata import LogMetadata from py123d.datatypes.sensors import ( LiDARMetadata, diff --git a/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py index d85adfb4..5f0e3515 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py +++ b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py @@ -10,8 +10,8 @@ WAYMO_ROAD_LINE_TYPE_CONVERSION, ) from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.datatypes.maps.abstract_map_objects import AbstractLane, AbstractRoadEdge, AbstractRoadLine -from py123d.datatypes.maps.cache.cache_map_objects import ( +from py123d.datatypes.map.abstract_map_objects import AbstractLane, AbstractRoadEdge, AbstractRoadLine +from py123d.datatypes.map.cache.cache_map_objects import ( CacheCarpark, CacheCrosswalk, CacheLane, @@ -19,7 +19,7 @@ CacheRoadEdge, CacheRoadLine, ) -from py123d.datatypes.maps.map_datatypes import LaneType, RoadEdgeType, RoadLineType +from py123d.datatypes.map.map_datatypes import LaneType, RoadEdgeType, RoadLineType from py123d.geometry import Polyline3D from py123d.geometry.utils.units import mph_to_mps diff --git a/src/py123d/conversion/map_writer/abstract_map_writer.py b/src/py123d/conversion/map_writer/abstract_map_writer.py index 99c867e6..739d112e 100644 --- a/src/py123d/conversion/map_writer/abstract_map_writer.py +++ b/src/py123d/conversion/map_writer/abstract_map_writer.py @@ -2,7 +2,7 @@ from abc import abstractmethod from py123d.conversion.dataset_converter_config import DatasetConverterConfig -from py123d.datatypes.maps.abstract_map_objects import ( +from py123d.datatypes.map.abstract_map_objects import ( AbstractCarpark, AbstractCrosswalk, AbstractGenericDrivable, @@ -14,7 +14,7 @@ AbstractStopLine, AbstractWalkway, ) -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_metadata import MapMetadata class AbstractMapWriter(abc.ABC): diff --git a/src/py123d/conversion/map_writer/gpkg_map_writer.py b/src/py123d/conversion/map_writer/gpkg_map_writer.py index 289e1cc2..08d74a49 100644 --- a/src/py123d/conversion/map_writer/gpkg_map_writer.py +++ b/src/py123d/conversion/map_writer/gpkg_map_writer.py @@ -9,7 +9,7 @@ from py123d.conversion.dataset_converter_config import DatasetConverterConfig from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter from py123d.conversion.map_writer.utils.gpkg_utils import IntIDMapping -from py123d.datatypes.maps.abstract_map_objects import ( +from py123d.datatypes.map.abstract_map_objects import ( AbstractCarpark, AbstractCrosswalk, AbstractGenericDrivable, @@ -23,8 +23,8 @@ AbstractSurfaceMapObject, AbstractWalkway, ) -from py123d.datatypes.maps.map_datatypes import MapLayer -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_datatypes import MapLayer +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.geometry.polyline import Polyline3D MAP_OBJECT_DATA = Dict[str, List[Union[str, int, float, bool, geom.base.BaseGeometry]]] diff --git a/src/py123d/conversion/registry/box_detection_label_registry.py b/src/py123d/conversion/registry/box_detection_label_registry.py index 6deebb0b..5befcbfc 100644 --- a/src/py123d/conversion/registry/box_detection_label_registry.py +++ b/src/py123d/conversion/registry/box_detection_label_registry.py @@ -25,17 +25,24 @@ class DefaultBoxDetectionLabel(BoxDetectionLabel): Enum for agents in py123d. """ - VEHICLE = 0 - BICYCLE = 1 - PEDESTRIAN = 2 + # Vehicles + EGO = 0 + VEHICLE = 1 + TRAIN = 2 - TRAFFIC_CONE = 3 - BARRIER = 4 - CZONE_SIGN = 5 - GENERIC_OBJECT = 6 + # Vulnerable Road Users + BICYCLE = 3 + PERSON = 4 + ANIMAL = 5 + + # Traffic Control + TRAFFIC_SIGN = 6 + TRAFFIC_CONE = 7 + TRAFFIC_LIGHT = 8 - EGO = 7 - SIGN = 8 # TODO: Remove or extent + # Other Obstacles + BARRIER = 9 + GENERIC_OBJECT = 10 def to_default(self) -> DefaultBoxDetectionLabel: """Inherited, see superclass.""" @@ -80,36 +87,36 @@ class AV2SensorBoxDetectionLabel(BoxDetectionLabel): def to_default(self) -> DefaultBoxDetectionLabel: """Inherited, see superclass.""" mapping = { - AV2SensorBoxDetectionLabel.ANIMAL: DefaultBoxDetectionLabel.GENERIC_OBJECT, + AV2SensorBoxDetectionLabel.ANIMAL: DefaultBoxDetectionLabel.ANIMAL, AV2SensorBoxDetectionLabel.ARTICULATED_BUS: DefaultBoxDetectionLabel.VEHICLE, AV2SensorBoxDetectionLabel.BICYCLE: DefaultBoxDetectionLabel.BICYCLE, - AV2SensorBoxDetectionLabel.BICYCLIST: DefaultBoxDetectionLabel.PEDESTRIAN, - AV2SensorBoxDetectionLabel.BOLLARD: DefaultBoxDetectionLabel.BARRIER, + AV2SensorBoxDetectionLabel.BICYCLIST: DefaultBoxDetectionLabel.PERSON, + AV2SensorBoxDetectionLabel.BOLLARD: DefaultBoxDetectionLabel.GENERIC_OBJECT, AV2SensorBoxDetectionLabel.BOX_TRUCK: DefaultBoxDetectionLabel.VEHICLE, AV2SensorBoxDetectionLabel.BUS: DefaultBoxDetectionLabel.VEHICLE, - AV2SensorBoxDetectionLabel.CONSTRUCTION_BARREL: DefaultBoxDetectionLabel.BARRIER, + AV2SensorBoxDetectionLabel.CONSTRUCTION_BARREL: DefaultBoxDetectionLabel.TRAFFIC_CONE, AV2SensorBoxDetectionLabel.CONSTRUCTION_CONE: DefaultBoxDetectionLabel.TRAFFIC_CONE, - AV2SensorBoxDetectionLabel.DOG: DefaultBoxDetectionLabel.GENERIC_OBJECT, + AV2SensorBoxDetectionLabel.DOG: DefaultBoxDetectionLabel.ANIMAL, AV2SensorBoxDetectionLabel.LARGE_VEHICLE: DefaultBoxDetectionLabel.VEHICLE, AV2SensorBoxDetectionLabel.MESSAGE_BOARD_TRAILER: DefaultBoxDetectionLabel.VEHICLE, - AV2SensorBoxDetectionLabel.MOBILE_PEDESTRIAN_CROSSING_SIGN: DefaultBoxDetectionLabel.CZONE_SIGN, + AV2SensorBoxDetectionLabel.MOBILE_PEDESTRIAN_CROSSING_SIGN: DefaultBoxDetectionLabel.TRAFFIC_SIGN, AV2SensorBoxDetectionLabel.MOTORCYCLE: DefaultBoxDetectionLabel.BICYCLE, - AV2SensorBoxDetectionLabel.MOTORCYCLIST: DefaultBoxDetectionLabel.BICYCLE, - AV2SensorBoxDetectionLabel.OFFICIAL_SIGNALER: DefaultBoxDetectionLabel.PEDESTRIAN, - AV2SensorBoxDetectionLabel.PEDESTRIAN: DefaultBoxDetectionLabel.PEDESTRIAN, - AV2SensorBoxDetectionLabel.RAILED_VEHICLE: DefaultBoxDetectionLabel.VEHICLE, + AV2SensorBoxDetectionLabel.MOTORCYCLIST: DefaultBoxDetectionLabel.PERSON, + AV2SensorBoxDetectionLabel.OFFICIAL_SIGNALER: DefaultBoxDetectionLabel.PERSON, + AV2SensorBoxDetectionLabel.PEDESTRIAN: DefaultBoxDetectionLabel.PERSON, + AV2SensorBoxDetectionLabel.RAILED_VEHICLE: DefaultBoxDetectionLabel.TRAIN, AV2SensorBoxDetectionLabel.REGULAR_VEHICLE: DefaultBoxDetectionLabel.VEHICLE, AV2SensorBoxDetectionLabel.SCHOOL_BUS: DefaultBoxDetectionLabel.VEHICLE, - AV2SensorBoxDetectionLabel.SIGN: DefaultBoxDetectionLabel.SIGN, - AV2SensorBoxDetectionLabel.STOP_SIGN: DefaultBoxDetectionLabel.SIGN, - AV2SensorBoxDetectionLabel.STROLLER: DefaultBoxDetectionLabel.PEDESTRIAN, + AV2SensorBoxDetectionLabel.SIGN: DefaultBoxDetectionLabel.TRAFFIC_SIGN, + AV2SensorBoxDetectionLabel.STOP_SIGN: DefaultBoxDetectionLabel.TRAFFIC_SIGN, + AV2SensorBoxDetectionLabel.STROLLER: DefaultBoxDetectionLabel.PERSON, AV2SensorBoxDetectionLabel.TRAFFIC_LIGHT_TRAILER: DefaultBoxDetectionLabel.VEHICLE, AV2SensorBoxDetectionLabel.TRUCK: DefaultBoxDetectionLabel.VEHICLE, AV2SensorBoxDetectionLabel.TRUCK_CAB: DefaultBoxDetectionLabel.VEHICLE, AV2SensorBoxDetectionLabel.VEHICULAR_TRAILER: DefaultBoxDetectionLabel.VEHICLE, - AV2SensorBoxDetectionLabel.WHEELCHAIR: DefaultBoxDetectionLabel.PEDESTRIAN, - AV2SensorBoxDetectionLabel.WHEELED_DEVICE: DefaultBoxDetectionLabel.GENERIC_OBJECT, - AV2SensorBoxDetectionLabel.WHEELED_RIDER: DefaultBoxDetectionLabel.BICYCLE, + AV2SensorBoxDetectionLabel.WHEELCHAIR: DefaultBoxDetectionLabel.PERSON, + AV2SensorBoxDetectionLabel.WHEELED_DEVICE: DefaultBoxDetectionLabel.PERSON, + AV2SensorBoxDetectionLabel.WHEELED_RIDER: DefaultBoxDetectionLabel.PERSON, } return mapping[self] @@ -146,13 +153,13 @@ def to_default(self) -> DefaultBoxDetectionLabel: KITTI360BoxDetectionLabel.CARAVAN: DefaultBoxDetectionLabel.VEHICLE, KITTI360BoxDetectionLabel.LAMP: DefaultBoxDetectionLabel.GENERIC_OBJECT, KITTI360BoxDetectionLabel.MOTORCYCLE: DefaultBoxDetectionLabel.BICYCLE, - KITTI360BoxDetectionLabel.PERSON: DefaultBoxDetectionLabel.PEDESTRIAN, + KITTI360BoxDetectionLabel.PERSON: DefaultBoxDetectionLabel.PERSON, KITTI360BoxDetectionLabel.POLE: DefaultBoxDetectionLabel.GENERIC_OBJECT, - KITTI360BoxDetectionLabel.RIDER: DefaultBoxDetectionLabel.BICYCLE, + KITTI360BoxDetectionLabel.RIDER: DefaultBoxDetectionLabel.PERSON, KITTI360BoxDetectionLabel.SMALLPOLE: DefaultBoxDetectionLabel.GENERIC_OBJECT, - KITTI360BoxDetectionLabel.STOP: DefaultBoxDetectionLabel.SIGN, - KITTI360BoxDetectionLabel.TRAFFIC_LIGHT: DefaultBoxDetectionLabel.SIGN, - KITTI360BoxDetectionLabel.TRAFFIC_SIGN: DefaultBoxDetectionLabel.SIGN, + KITTI360BoxDetectionLabel.STOP: DefaultBoxDetectionLabel.TRAFFIC_SIGN, + KITTI360BoxDetectionLabel.TRAFFIC_LIGHT: DefaultBoxDetectionLabel.TRAFFIC_LIGHT, + KITTI360BoxDetectionLabel.TRAFFIC_SIGN: DefaultBoxDetectionLabel.TRAFFIC_SIGN, KITTI360BoxDetectionLabel.TRAILER: DefaultBoxDetectionLabel.VEHICLE, KITTI360BoxDetectionLabel.TRAIN: DefaultBoxDetectionLabel.VEHICLE, KITTI360BoxDetectionLabel.TRASH_BIN: DefaultBoxDetectionLabel.GENERIC_OBJECT, @@ -189,10 +196,10 @@ def to_default(self) -> DefaultBoxDetectionLabel: mapping = { NuPlanBoxDetectionLabel.VEHICLE: DefaultBoxDetectionLabel.VEHICLE, NuPlanBoxDetectionLabel.BICYCLE: DefaultBoxDetectionLabel.BICYCLE, - NuPlanBoxDetectionLabel.PEDESTRIAN: DefaultBoxDetectionLabel.PEDESTRIAN, + NuPlanBoxDetectionLabel.PEDESTRIAN: DefaultBoxDetectionLabel.PERSON, NuPlanBoxDetectionLabel.TRAFFIC_CONE: DefaultBoxDetectionLabel.TRAFFIC_CONE, NuPlanBoxDetectionLabel.BARRIER: DefaultBoxDetectionLabel.BARRIER, - NuPlanBoxDetectionLabel.CZONE_SIGN: DefaultBoxDetectionLabel.CZONE_SIGN, + NuPlanBoxDetectionLabel.CZONE_SIGN: DefaultBoxDetectionLabel.TRAFFIC_SIGN, NuPlanBoxDetectionLabel.GENERIC_OBJECT: DefaultBoxDetectionLabel.GENERIC_OBJECT, } return mapping[self] @@ -241,19 +248,19 @@ def to_default(self): NuScenesBoxDetectionLabel.VEHICLE_TRAILER: DefaultBoxDetectionLabel.VEHICLE, NuScenesBoxDetectionLabel.VEHICLE_BICYCLE: DefaultBoxDetectionLabel.BICYCLE, NuScenesBoxDetectionLabel.VEHICLE_MOTORCYCLE: DefaultBoxDetectionLabel.BICYCLE, - NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_ADULT: DefaultBoxDetectionLabel.PEDESTRIAN, - NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_CHILD: DefaultBoxDetectionLabel.PEDESTRIAN, - NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_CONSTRUCTION_WORKER: DefaultBoxDetectionLabel.PEDESTRIAN, - NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_PERSONAL_MOBILITY: DefaultBoxDetectionLabel.PEDESTRIAN, - NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_POLICE_OFFICER: DefaultBoxDetectionLabel.PEDESTRIAN, - NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_STROLLER: DefaultBoxDetectionLabel.PEDESTRIAN, - NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_WHEELCHAIR: DefaultBoxDetectionLabel.PEDESTRIAN, + NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_ADULT: DefaultBoxDetectionLabel.PERSON, + NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_CHILD: DefaultBoxDetectionLabel.PERSON, + NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_CONSTRUCTION_WORKER: DefaultBoxDetectionLabel.PERSON, + NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_PERSONAL_MOBILITY: DefaultBoxDetectionLabel.PERSON, + NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_POLICE_OFFICER: DefaultBoxDetectionLabel.PERSON, + NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_STROLLER: DefaultBoxDetectionLabel.PERSON, + NuScenesBoxDetectionLabel.HUMAN_PEDESTRIAN_WHEELCHAIR: DefaultBoxDetectionLabel.PERSON, NuScenesBoxDetectionLabel.MOVABLE_OBJECT_TRAFFICCONE: DefaultBoxDetectionLabel.TRAFFIC_CONE, NuScenesBoxDetectionLabel.MOVABLE_OBJECT_BARRIER: DefaultBoxDetectionLabel.BARRIER, NuScenesBoxDetectionLabel.MOVABLE_OBJECT_PUSHABLE_PULLABLE: DefaultBoxDetectionLabel.GENERIC_OBJECT, NuScenesBoxDetectionLabel.MOVABLE_OBJECT_DEBRIS: DefaultBoxDetectionLabel.GENERIC_OBJECT, NuScenesBoxDetectionLabel.STATIC_OBJECT_BICYCLE_RACK: DefaultBoxDetectionLabel.GENERIC_OBJECT, - NuScenesBoxDetectionLabel.ANIMAL: DefaultBoxDetectionLabel.GENERIC_OBJECT, + NuScenesBoxDetectionLabel.ANIMAL: DefaultBoxDetectionLabel.ANIMAL, } return mapping[self] @@ -295,33 +302,33 @@ class PandasetBoxDetectionLabel(BoxDetectionLabel): def to_default(self) -> DefaultBoxDetectionLabel: mapping = { - PandasetBoxDetectionLabel.ANIMALS_BIRD: DefaultBoxDetectionLabel.GENERIC_OBJECT, # TODO: Adjust default types - PandasetBoxDetectionLabel.ANIMALS_OTHER: DefaultBoxDetectionLabel.GENERIC_OBJECT, # TODO: Adjust default types + PandasetBoxDetectionLabel.ANIMALS_BIRD: DefaultBoxDetectionLabel.ANIMAL, + PandasetBoxDetectionLabel.ANIMALS_OTHER: DefaultBoxDetectionLabel.ANIMAL, PandasetBoxDetectionLabel.BICYCLE: DefaultBoxDetectionLabel.BICYCLE, PandasetBoxDetectionLabel.BUS: DefaultBoxDetectionLabel.VEHICLE, PandasetBoxDetectionLabel.CAR: DefaultBoxDetectionLabel.VEHICLE, PandasetBoxDetectionLabel.CONES: DefaultBoxDetectionLabel.TRAFFIC_CONE, - PandasetBoxDetectionLabel.CONSTRUCTION_SIGNS: DefaultBoxDetectionLabel.CZONE_SIGN, + PandasetBoxDetectionLabel.CONSTRUCTION_SIGNS: DefaultBoxDetectionLabel.TRAFFIC_SIGN, PandasetBoxDetectionLabel.EMERGENCY_VEHICLE: DefaultBoxDetectionLabel.VEHICLE, PandasetBoxDetectionLabel.MEDIUM_SIZED_TRUCK: DefaultBoxDetectionLabel.VEHICLE, PandasetBoxDetectionLabel.MOTORCYCLE: DefaultBoxDetectionLabel.BICYCLE, PandasetBoxDetectionLabel.MOTORIZED_SCOOTER: DefaultBoxDetectionLabel.BICYCLE, PandasetBoxDetectionLabel.OTHER_VEHICLE_CONSTRUCTION_VEHICLE: DefaultBoxDetectionLabel.VEHICLE, - PandasetBoxDetectionLabel.OTHER_VEHICLE_PEDICAB: DefaultBoxDetectionLabel.BICYCLE, + PandasetBoxDetectionLabel.OTHER_VEHICLE_PEDICAB: DefaultBoxDetectionLabel.VEHICLE, PandasetBoxDetectionLabel.OTHER_VEHICLE_UNCOMMON: DefaultBoxDetectionLabel.VEHICLE, - PandasetBoxDetectionLabel.PEDESTRIAN: DefaultBoxDetectionLabel.PEDESTRIAN, - PandasetBoxDetectionLabel.PEDESTRIAN_WITH_OBJECT: DefaultBoxDetectionLabel.PEDESTRIAN, + PandasetBoxDetectionLabel.PEDESTRIAN: DefaultBoxDetectionLabel.PERSON, + PandasetBoxDetectionLabel.PEDESTRIAN_WITH_OBJECT: DefaultBoxDetectionLabel.PERSON, PandasetBoxDetectionLabel.PERSONAL_MOBILITY_DEVICE: DefaultBoxDetectionLabel.BICYCLE, PandasetBoxDetectionLabel.PICKUP_TRUCK: DefaultBoxDetectionLabel.VEHICLE, PandasetBoxDetectionLabel.PYLONS: DefaultBoxDetectionLabel.TRAFFIC_CONE, PandasetBoxDetectionLabel.ROAD_BARRIERS: DefaultBoxDetectionLabel.BARRIER, PandasetBoxDetectionLabel.ROLLING_CONTAINERS: DefaultBoxDetectionLabel.GENERIC_OBJECT, PandasetBoxDetectionLabel.SEMI_TRUCK: DefaultBoxDetectionLabel.VEHICLE, - PandasetBoxDetectionLabel.SIGNS: DefaultBoxDetectionLabel.SIGN, + PandasetBoxDetectionLabel.SIGNS: DefaultBoxDetectionLabel.TRAFFIC_SIGN, PandasetBoxDetectionLabel.TEMPORARY_CONSTRUCTION_BARRIERS: DefaultBoxDetectionLabel.BARRIER, PandasetBoxDetectionLabel.TOWED_OBJECT: DefaultBoxDetectionLabel.VEHICLE, - PandasetBoxDetectionLabel.TRAIN: DefaultBoxDetectionLabel.GENERIC_OBJECT, # TODO: Adjust default types - PandasetBoxDetectionLabel.TRAM_SUBWAY: DefaultBoxDetectionLabel.GENERIC_OBJECT, # TODO: Adjust default types + PandasetBoxDetectionLabel.TRAIN: DefaultBoxDetectionLabel.TRAIN, # TODO: Adjust default types + PandasetBoxDetectionLabel.TRAM_SUBWAY: DefaultBoxDetectionLabel.TRAIN, # TODO: Adjust default types } return mapping[self] @@ -344,8 +351,8 @@ def to_default(self) -> DefaultBoxDetectionLabel: mapping = { WOPDBoxDetectionLabel.TYPE_UNKNOWN: DefaultBoxDetectionLabel.GENERIC_OBJECT, WOPDBoxDetectionLabel.TYPE_VEHICLE: DefaultBoxDetectionLabel.VEHICLE, - WOPDBoxDetectionLabel.TYPE_PEDESTRIAN: DefaultBoxDetectionLabel.PEDESTRIAN, - WOPDBoxDetectionLabel.TYPE_SIGN: DefaultBoxDetectionLabel.SIGN, + WOPDBoxDetectionLabel.TYPE_PEDESTRIAN: DefaultBoxDetectionLabel.PERSON, + WOPDBoxDetectionLabel.TYPE_SIGN: DefaultBoxDetectionLabel.TRAFFIC_SIGN, WOPDBoxDetectionLabel.TYPE_CYCLIST: DefaultBoxDetectionLabel.BICYCLE, } return mapping[self] diff --git a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py index 2bb831c8..9663c28b 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py @@ -21,7 +21,7 @@ get_road_edges_3d_from_drivable_surfaces, lift_outlines_to_3d, ) -from py123d.datatypes.maps.cache.cache_map_objects import ( +from py123d.datatypes.map.cache.cache_map_objects import ( CacheCarpark, CacheCrosswalk, CacheGenericDrivable, @@ -32,7 +32,7 @@ CacheRoadLine, CacheWalkway, ) -from py123d.datatypes.maps.map_datatypes import RoadEdgeType, RoadLineType +from py123d.datatypes.map.map_datatypes import RoadEdgeType, RoadLineType from py123d.geometry.geometry_index import Point3DIndex from py123d.geometry.polyline import Polyline3D diff --git a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py index 42d01faf..0365722f 100644 --- a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py +++ b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py @@ -9,7 +9,7 @@ import shapely.geometry as geom from py123d.conversion.utils.map_utils.road_edge.road_edge_2d_utils import get_road_edge_linear_rings -from py123d.datatypes.maps.abstract_map_objects import ( +from py123d.datatypes.map.abstract_map_objects import ( AbstractCarpark, AbstractGenericDrivable, AbstractLane, diff --git a/src/py123d/datatypes/detections/__init__.py b/src/py123d/datatypes/detections/__init__.py index e69de29b..be1c4bb0 100644 --- a/src/py123d/datatypes/detections/__init__.py +++ b/src/py123d/datatypes/detections/__init__.py @@ -0,0 +1,12 @@ +from py123d.datatypes.detections.box_detections import ( + BoxDetectionMetadata, + BoxDetectionSE2, + BoxDetectionSE3, + BoxDetection, + BoxDetectionWrapper, +) +from py123d.datatypes.detections.traffic_light_detections import ( + TrafficLightDetection, + TrafficLightDetectionWrapper, + TrafficLightStatus, +) diff --git a/src/py123d/datatypes/detections/box_detections.py b/src/py123d/datatypes/detections/box_detections.py index 64ebb851..4ba2e285 100644 --- a/src/py123d/datatypes/detections/box_detections.py +++ b/src/py123d/datatypes/detections/box_detections.py @@ -11,20 +11,57 @@ @dataclass class BoxDetectionMetadata: + """Store metadata for a detected bounding box. + + Examples + -------- + + .. code-block:: python + + from mymodule import my_function + + result = my_function() + print(result) + + """ label: BoxDetectionLabel track_token: str - confidence: Optional[float] = None num_lidar_points: Optional[int] = None timepoint: Optional[TimePoint] = None @property def default_label(self) -> DefaultBoxDetectionLabel: + """The default label of the detection. + + :return: The default label. + """ return self.label.to_default() @dataclass class BoxDetectionSE2: + """Store a 2D bounding box detection. + + Example: + >>> from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel + >>> from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE2 + >>> from py123d.geometry import BoundingBoxSE2, StateSE2, Vector2D + >>> metadata = BoxDetectionMetadata( + ... label=DefaultBoxDetectionLabel.VEHICLE, + ... track_token="track_123", + ... ) + >>> bounding_box = BoundingBoxSE2( + ... center=StateSE2(x=0.0, y=0.0, yaw=0.0), + ... length=4.0, + ... width=2.0, + ... ) + >>> detection = BoxDetectionSE2( + ... metadata=metadata, + ... bounding_box_se2=bounding_box, + ... velocity=Vector2D(x=1.0, y=0.0), + ... ) + """ metadata: BoxDetectionMetadata bounding_box_se2: BoundingBoxSE2 diff --git a/src/py123d/datatypes/map/__init__.py b/src/py123d/datatypes/map/__init__.py new file mode 100644 index 00000000..5b339f8f --- /dev/null +++ b/src/py123d/datatypes/map/__init__.py @@ -0,0 +1,18 @@ +from py123d.datatypes.map.abstract_map import AbstractMap +from py123d.datatypes.map.abstract_map_objects import ( + AbstractCarpark, + AbstractCrosswalk, + AbstractGenericDrivable, + AbstractIntersection, + AbstractLane, + AbstractLaneGroup, + AbstractLineMapObject, + AbstractMapObject, + AbstractRoadEdge, + AbstractRoadLine, + AbstractStopLine, + AbstractSurfaceMapObject, + AbstractWalkway, +) +from py123d.datatypes.map.map_datatypes import MapLayer +from py123d.datatypes.map.map_metadata import MapMetadata diff --git a/src/py123d/datatypes/maps/abstract_map.py b/src/py123d/datatypes/map/abstract_map.py similarity index 92% rename from src/py123d/datatypes/maps/abstract_map.py rename to src/py123d/datatypes/map/abstract_map.py index 3aee0e99..be334129 100644 --- a/src/py123d/datatypes/maps/abstract_map.py +++ b/src/py123d/datatypes/map/abstract_map.py @@ -5,9 +5,9 @@ import shapely -from py123d.datatypes.maps.abstract_map_objects import AbstractMapObject -from py123d.datatypes.maps.map_datatypes import MapLayer -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.abstract_map_objects import AbstractMapObject +from py123d.datatypes.map.map_datatypes import MapLayer +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.geometry import Point2D # TODO: diff --git a/src/py123d/datatypes/maps/abstract_map_objects.py b/src/py123d/datatypes/map/abstract_map_objects.py similarity index 94% rename from src/py123d/datatypes/maps/abstract_map_objects.py rename to src/py123d/datatypes/map/abstract_map_objects.py index baea8d87..d5c7feff 100644 --- a/src/py123d/datatypes/maps/abstract_map_objects.py +++ b/src/py123d/datatypes/map/abstract_map_objects.py @@ -7,7 +7,7 @@ import trimesh from typing_extensions import TypeAlias -from py123d.datatypes.maps.map_datatypes import MapLayer, RoadEdgeType, RoadLineType +from py123d.datatypes.map.map_datatypes import MapLayer, RoadEdgeType, RoadLineType from py123d.geometry import Polyline2D, Polyline3D, PolylineSE2 # TODO: Refactor and just use int @@ -16,46 +16,50 @@ class AbstractMapObject(abc.ABC): - """ - Base interface representation of all map objects. - """ def __init__(self, object_id: MapObjectIDType): - """ - Constructor of the base map object type. + """Constructor of the base map object type. + :param object_id: unique identifier of the map object. """ self.object_id: MapObjectIDType = object_id + @property + def object_id(self) -> MapObjectIDType: + """Returns the unique identifier of the map object. + + :return: map object id + """ + return self.object_id + @property @abc.abstractmethod def layer(self) -> MapLayer: """ - Returns map layer type, e.g. LANE, ROAD_EDGE. + :return: map layer type """ class AbstractSurfaceMapObject(AbstractMapObject): - """ - Base interface representation of all map objects. - """ @property @abc.abstractmethod def shapely_polygon(self) -> geom.Polygon: - """ - Returns the 2D shapely polygon of the map object. + """Returns the 2D shapely polygon of the map object. + :return: shapely polygon """ + raise NotImplementedError @property @abc.abstractmethod def outline(self) -> Union[Polyline2D, Polyline3D]: - """ - Returns the 2D or 3D outline of the map surface, if available. + """Returns the 2D or 3D outline of the map surface, if available. + :return: 2D or 3D polyline """ + raise NotImplementedError @property @abc.abstractmethod @@ -64,6 +68,7 @@ def trimesh_mesh(self) -> trimesh.Trimesh: Returns a triangle mesh of the map surface. :return: Trimesh """ + raise NotImplementedError @property def outline_3d(self) -> Polyline3D: @@ -138,7 +143,6 @@ def shapely_linestring(self) -> geom.LineString: class AbstractLane(AbstractSurfaceMapObject): - """Abstract interface for lane objects.""" @property def layer(self) -> MapLayer: @@ -147,8 +151,8 @@ def layer(self) -> MapLayer: @property @abc.abstractmethod def speed_limit_mps(self) -> Optional[float]: - """ - Property of lanes speed limit in m/s, if available. + """Property of lanes speed limit in m/s, if available. + :return: float or none """ diff --git a/src/py123d/datatypes/maps/cache/__init__.py b/src/py123d/datatypes/map/cache/__init__.py similarity index 100% rename from src/py123d/datatypes/maps/cache/__init__.py rename to src/py123d/datatypes/map/cache/__init__.py diff --git a/src/py123d/datatypes/maps/cache/cache_map_objects.py b/src/py123d/datatypes/map/cache/cache_map_objects.py similarity index 98% rename from src/py123d/datatypes/maps/cache/cache_map_objects.py rename to src/py123d/datatypes/map/cache/cache_map_objects.py index 498c01c1..9e993e16 100644 --- a/src/py123d/datatypes/maps/cache/cache_map_objects.py +++ b/src/py123d/datatypes/map/cache/cache_map_objects.py @@ -6,7 +6,7 @@ import shapely.geometry as geom import trimesh -from py123d.datatypes.maps.abstract_map_objects import ( +from py123d.datatypes.map.abstract_map_objects import ( AbstractCarpark, AbstractCrosswalk, AbstractGenericDrivable, @@ -20,7 +20,7 @@ AbstractWalkway, MapObjectIDType, ) -from py123d.datatypes.maps.map_datatypes import MapLayer, RoadEdgeType, RoadLineType +from py123d.datatypes.map.map_datatypes import MapLayer, RoadEdgeType, RoadLineType from py123d.geometry import Polyline3D from py123d.geometry.polyline import Polyline2D diff --git a/src/py123d/datatypes/maps/gpkg/__init__.py b/src/py123d/datatypes/map/gpkg/__init__.py similarity index 100% rename from src/py123d/datatypes/maps/gpkg/__init__.py rename to src/py123d/datatypes/map/gpkg/__init__.py diff --git a/src/py123d/datatypes/maps/gpkg/gpkg_map.py b/src/py123d/datatypes/map/gpkg/gpkg_map.py similarity index 97% rename from src/py123d/datatypes/maps/gpkg/gpkg_map.py rename to src/py123d/datatypes/map/gpkg/gpkg_map.py index acc1cfbb..d95d970a 100644 --- a/src/py123d/datatypes/maps/gpkg/gpkg_map.py +++ b/src/py123d/datatypes/map/gpkg/gpkg_map.py @@ -10,9 +10,9 @@ import shapely import shapely.geometry as geom -from py123d.datatypes.maps.abstract_map import AbstractMap -from py123d.datatypes.maps.abstract_map_objects import AbstractMapObject -from py123d.datatypes.maps.gpkg.gpkg_map_objects import ( +from py123d.datatypes.map.abstract_map import AbstractMap +from py123d.datatypes.map.abstract_map_objects import AbstractMapObject +from py123d.datatypes.map.gpkg.gpkg_map_objects import ( GPKGCarpark, GPKGCrosswalk, GPKGGenericDrivable, @@ -23,9 +23,9 @@ GPKGRoadLine, GPKGWalkway, ) -from py123d.datatypes.maps.gpkg.gpkg_utils import load_gdf_with_geometry_columns -from py123d.datatypes.maps.map_datatypes import MapLayer -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.gpkg.gpkg_utils import load_gdf_with_geometry_columns +from py123d.datatypes.map.map_datatypes import MapLayer +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.geometry import Point2D from py123d.script.utils.dataset_path_utils import get_dataset_paths diff --git a/src/py123d/datatypes/maps/gpkg/gpkg_map_objects.py b/src/py123d/datatypes/map/gpkg/gpkg_map_objects.py similarity index 98% rename from src/py123d/datatypes/maps/gpkg/gpkg_map_objects.py rename to src/py123d/datatypes/map/gpkg/gpkg_map_objects.py index 97b11d73..33922e2b 100644 --- a/src/py123d/datatypes/maps/gpkg/gpkg_map_objects.py +++ b/src/py123d/datatypes/map/gpkg/gpkg_map_objects.py @@ -10,7 +10,7 @@ import shapely.geometry as geom import trimesh -from py123d.datatypes.maps.abstract_map_objects import ( +from py123d.datatypes.map.abstract_map_objects import ( AbstractCarpark, AbstractCrosswalk, AbstractGenericDrivable, @@ -24,8 +24,8 @@ AbstractWalkway, MapObjectIDType, ) -from py123d.datatypes.maps.gpkg.gpkg_utils import get_row_with_value, get_trimesh_from_boundaries -from py123d.datatypes.maps.map_datatypes import RoadEdgeType, RoadLineType +from py123d.datatypes.map.gpkg.gpkg_utils import get_row_with_value, get_trimesh_from_boundaries +from py123d.datatypes.map.map_datatypes import RoadEdgeType, RoadLineType from py123d.geometry import Point3DIndex, Polyline3D from py123d.geometry.polyline import Polyline2D diff --git a/src/py123d/datatypes/maps/gpkg/gpkg_utils.py b/src/py123d/datatypes/map/gpkg/gpkg_utils.py similarity index 100% rename from src/py123d/datatypes/maps/gpkg/gpkg_utils.py rename to src/py123d/datatypes/map/gpkg/gpkg_utils.py diff --git a/src/py123d/datatypes/maps/map_datatypes.py b/src/py123d/datatypes/map/map_datatypes.py similarity index 100% rename from src/py123d/datatypes/maps/map_datatypes.py rename to src/py123d/datatypes/map/map_datatypes.py diff --git a/src/py123d/datatypes/maps/map_metadata.py b/src/py123d/datatypes/map/map_metadata.py similarity index 100% rename from src/py123d/datatypes/maps/map_metadata.py rename to src/py123d/datatypes/map/map_metadata.py diff --git a/src/py123d/datatypes/scene/__init__.py b/src/py123d/datatypes/scene/__init__.py index e69de29b..7fc23ffc 100644 --- a/src/py123d/datatypes/scene/__init__.py +++ b/src/py123d/datatypes/scene/__init__.py @@ -0,0 +1,4 @@ +from py123d.datatypes.scene.abstract_scene import AbstractScene +from py123d.datatypes.scene.abstract_scene_builder import SceneBuilder +from py123d.datatypes.scene.scene_filter import SceneFilter +from py123d.datatypes.scene.scene_metadata import LogMetadata diff --git a/src/py123d/datatypes/scene/abstract_scene.py b/src/py123d/datatypes/scene/abstract_scene.py index 33611539..3e664871 100644 --- a/src/py123d/datatypes/scene/abstract_scene.py +++ b/src/py123d/datatypes/scene/abstract_scene.py @@ -5,7 +5,7 @@ from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.maps.abstract_map import AbstractMap +from py123d.datatypes.map.abstract_map import AbstractMap from py123d.datatypes.scene.scene_metadata import LogMetadata, SceneExtractionMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICamera, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDAR, LiDARType diff --git a/src/py123d/datatypes/scene/arrow/arrow_scene.py b/src/py123d/datatypes/scene/arrow/arrow_scene.py index cb0ae3f6..f359c1b3 100644 --- a/src/py123d/datatypes/scene/arrow/arrow_scene.py +++ b/src/py123d/datatypes/scene/arrow/arrow_scene.py @@ -6,8 +6,8 @@ from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.maps.abstract_map import AbstractMap -from py123d.datatypes.maps.gpkg.gpkg_map import get_global_map_api, get_local_map_api +from py123d.datatypes.map.abstract_map import AbstractMap +from py123d.datatypes.map.gpkg.gpkg_map import get_global_map_api, get_local_map_api from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.scene.arrow.utils.arrow_getters import ( get_box_detections_se3_from_arrow_table, diff --git a/src/py123d/datatypes/scene/scene_metadata.py b/src/py123d/datatypes/scene/scene_metadata.py index 2bc271f1..9bd6b218 100644 --- a/src/py123d/datatypes/scene/scene_metadata.py +++ b/src/py123d/datatypes/scene/scene_metadata.py @@ -5,7 +5,7 @@ import py123d from py123d.conversion.registry.box_detection_label_registry import BOX_DETECTION_LABEL_REGISTRY, BoxDetectionLabel -from py123d.datatypes.maps.map_metadata import MapMetadata +from py123d.datatypes.map.map_metadata import MapMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICameraMetadata, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType diff --git a/src/py123d/geometry/point.py b/src/py123d/geometry/point.py index 571567be..7558bef0 100644 --- a/src/py123d/geometry/point.py +++ b/src/py123d/geometry/point.py @@ -107,15 +107,6 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Point3 object.__setattr__(instance, "_array", array.copy() if copy else array) return instance - @property - def array(self) -> npt.NDArray[np.float64]: - """The array representation of the point. - - :return: A numpy array of shape (3,) containing the point coordinates [x, y, z], indexed by \ - :class:`~py123d.geometry.Point3DIndex`. - """ - return self._array - @property def x(self) -> float: """The x coordinate of the point. @@ -140,6 +131,15 @@ def z(self) -> float: """ return self._array[Point3DIndex.Z] + @property + def array(self) -> npt.NDArray[np.float64]: + """The array representation of the point. + + :return: A numpy array of shape (3,) containing the point coordinates [x, y, z], indexed by \ + :class:`~py123d.geometry.Point3DIndex`. + """ + return self._array + @property def point_2d(self) -> Point2D: """The 2D projection of the 3D point. diff --git a/src/py123d/geometry/se.py b/src/py123d/geometry/se.py index b8b30cc8..4c82e43e 100644 --- a/src/py123d/geometry/se.py +++ b/src/py123d/geometry/se.py @@ -110,7 +110,7 @@ class StateSE3(ArrayMixin): _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, z: float, qw: float, qx: float, qy: float, qz: float): - """Initialize QuaternionSE3 with x, y, z, qw, qx, qy, qz coordinates.""" + """Initialize StateSE3 with x, y, z, qw, qx, qy, qz coordinates.""" array = np.zeros(len(StateSE3Index), dtype=np.float64) array[StateSE3Index.X] = x array[StateSE3Index.Y] = y @@ -123,11 +123,11 @@ def __init__(self, x: float, y: float, z: float, qw: float, qx: float, qy: float @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> StateSE3: - """Constructs a QuaternionSE3 from a numpy array. + """Constructs a StateSE3 from a numpy array. - :param array: Array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.QuaternionSE3Index`. + :param array: Array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.StateSE3Index`. :param copy: Whether to copy the input array. Defaults to True. - :return: A QuaternionSE3 instance. + :return: A StateSE3 instance. """ assert array.ndim == 1 assert array.shape[0] == len(StateSE3Index) @@ -207,9 +207,9 @@ def qz(self) -> float: @property def array(self) -> npt.NDArray[np.float64]: - """Converts the QuaternionSE3 instance to a numpy array. + """Converts the StateSE3 instance to a numpy array. - :return: A numpy array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.QuaternionSE3Index`. + :return: A numpy array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.StateSE3Index`. """ return self._array diff --git a/src/py123d/geometry/vector.py b/src/py123d/geometry/vector.py index f2846c1f..2706f4f2 100644 --- a/src/py123d/geometry/vector.py +++ b/src/py123d/geometry/vector.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Iterable import numpy as np @@ -135,7 +134,6 @@ def __hash__(self) -> int: return hash((self.x, self.y)) -@dataclass class Vector3D(ArrayMixin): """ Class to represents 3D vectors, in x, y, z direction. diff --git a/src/py123d/visualization/color/color.py b/src/py123d/visualization/color/color.py index 5ba3c866..6cbc4967 100644 --- a/src/py123d/visualization/color/color.py +++ b/src/py123d/visualization/color/color.py @@ -73,7 +73,7 @@ def __str__(self) -> str: 9: Color("#17becf"), # cyan } -NEW_TAB_10: Dict[int, str] = { +NEW_TAB_10: Dict[int, Color] = { 0: Color("#4e79a7"), # blue 1: Color("#f28e2b"), # orange 2: Color("#e15759"), # red diff --git a/src/py123d/visualization/color/default.py b/src/py123d/visualization/color/default.py index ed5ccd25..988e01bc 100644 --- a/src/py123d/visualization/color/default.py +++ b/src/py123d/visualization/color/default.py @@ -2,7 +2,7 @@ from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.detections.traffic_light_detections import TrafficLightStatus -from py123d.datatypes.maps.map_datatypes import MapLayer +from py123d.datatypes.map.map_datatypes import MapLayer from py123d.visualization.color.color import ( BLACK, DARKER_GREY, @@ -46,9 +46,9 @@ zorder=1, ), MapLayer.CROSSWALK: PlotConfig( - fill_color=Color("#c69fbb"), + fill_color=Color("#d0b9ca"), fill_color_alpha=1.0, - line_color=Color("#c69fbb"), + line_color=Color("#d0b9ca"), line_color_alpha=0.0, line_width=1.0, line_style="-", @@ -82,10 +82,40 @@ zorder=1, ), } +# # Vehicles +# EGO = 0 +# VEHICLE = 1 +# TRAIN = 2 + +# # Vulnerable Road Users +# BICYCLE = 3 +# PERSON = 4 +# ANIMAL = 5 + +# # Traffic Control +# TRAFFIC_SIGN = 6 +# TRAFFIC_CONE = 7 +# TRAFFIC_LIGHT = 8 + +# # Other Obstacles +# BARRIER = 9 +# GENERIC_OBJECT = 10 + BOX_DETECTION_CONFIG: Dict[DefaultBoxDetectionLabel, PlotConfig] = { + # Vehicles + DefaultBoxDetectionLabel.EGO: PlotConfig( + fill_color=Color("#DE7061"), + fill_color_alpha=1.0, + line_color=BLACK, + line_color_alpha=1.0, + line_width=1.0, + line_style="-", + marker_style=HEADING_MARKER_STYLE, + zorder=4, + ), DefaultBoxDetectionLabel.VEHICLE: PlotConfig( - fill_color=ELLIS_5[4], + fill_color=Color("#699CDB"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, @@ -95,19 +125,20 @@ marker_size=1.0, zorder=3, ), - DefaultBoxDetectionLabel.PEDESTRIAN: PlotConfig( - fill_color=NEW_TAB_10[6], + DefaultBoxDetectionLabel.TRAIN: PlotConfig( + fill_color=Color("#76b7b2"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, line_width=1.0, line_style="-", - marker_style=None, + marker_style=HEADING_MARKER_STYLE, marker_size=1.0, - zorder=2, + zorder=3, ), + # VRUs DefaultBoxDetectionLabel.BICYCLE: PlotConfig( - fill_color=ELLIS_5[3], + fill_color=Color("#4e79a7"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, @@ -117,28 +148,31 @@ marker_size=1.0, zorder=2, ), - DefaultBoxDetectionLabel.TRAFFIC_CONE: PlotConfig( - fill_color=NEW_TAB_10[5], + DefaultBoxDetectionLabel.PERSON: PlotConfig( + fill_color=Color("#b07aa1"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, line_width=1.0, line_style="-", marker_style=None, + marker_size=1.0, zorder=2, ), - DefaultBoxDetectionLabel.BARRIER: PlotConfig( - fill_color=NEW_TAB_10[5], + DefaultBoxDetectionLabel.ANIMAL: PlotConfig( + fill_color=Color("#9467bd"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, line_width=1.0, line_style="-", marker_style=None, + marker_size=1.0, zorder=2, ), - DefaultBoxDetectionLabel.CZONE_SIGN: PlotConfig( - fill_color=NEW_TAB_10[5], + # Traffic Control + DefaultBoxDetectionLabel.TRAFFIC_SIGN: PlotConfig( + fill_color=Color("#E38C47"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, @@ -147,8 +181,8 @@ marker_style=None, zorder=2, ), - DefaultBoxDetectionLabel.GENERIC_OBJECT: PlotConfig( - fill_color=NEW_TAB_10[5], + DefaultBoxDetectionLabel.TRAFFIC_CONE: PlotConfig( + fill_color=Color("#E38C47"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, @@ -157,8 +191,8 @@ marker_style=None, zorder=2, ), - DefaultBoxDetectionLabel.SIGN: PlotConfig( - fill_color=NEW_TAB_10[8], + DefaultBoxDetectionLabel.TRAFFIC_LIGHT: PlotConfig( + fill_color=Color("#E38C47"), fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, @@ -167,15 +201,26 @@ marker_style=None, zorder=2, ), - DefaultBoxDetectionLabel.EGO: PlotConfig( - fill_color=ELLIS_5[0], + # Other Obstacles + DefaultBoxDetectionLabel.BARRIER: PlotConfig( + fill_color=NEW_TAB_10[5], fill_color_alpha=1.0, line_color=BLACK, line_color_alpha=1.0, line_width=1.0, line_style="-", - marker_style=HEADING_MARKER_STYLE, - zorder=4, + marker_style=None, + zorder=2, + ), + DefaultBoxDetectionLabel.GENERIC_OBJECT: PlotConfig( + fill_color=NEW_TAB_10[5], + fill_color_alpha=1.0, + line_color=BLACK, + line_color_alpha=1.0, + line_width=1.0, + line_style="-", + marker_style=None, + zorder=2, ), } @@ -199,6 +244,7 @@ line_style="--", zorder=3, ) + ROUTE_CONFIG: PlotConfig = PlotConfig( fill_color=Color("#f2c6c0ff"), fill_color_alpha=1.0, diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index e0a826f2..b39ff017 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -7,9 +7,9 @@ from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.maps.abstract_map import AbstractMap -from py123d.datatypes.maps.abstract_map_objects import AbstractLane -from py123d.datatypes.maps.map_datatypes import MapLayer +from py123d.datatypes.map.abstract_map import AbstractMap +from py123d.datatypes.map.abstract_map_objects import AbstractLane +from py123d.datatypes.map.map_datatypes import MapLayer from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.vehicle_state.ego_state import EgoStateSE2, EgoStateSE3 from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, Point2D, StateSE2Index, Vector2D diff --git a/src/py123d/visualization/viser/elements/map_elements.py b/src/py123d/visualization/viser/elements/map_elements.py index 6e16726e..4533258e 100644 --- a/src/py123d/visualization/viser/elements/map_elements.py +++ b/src/py123d/visualization/viser/elements/map_elements.py @@ -4,8 +4,8 @@ import trimesh import viser -from py123d.datatypes.maps.abstract_map import MapLayer -from py123d.datatypes.maps.abstract_map_objects import AbstractSurfaceMapObject +from py123d.datatypes.map.abstract_map import MapLayer +from py123d.datatypes.map.abstract_map_objects import AbstractSurfaceMapObject from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.geometry import Point3D, Point3DIndex diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index 1fb5559e..4f6e84bb 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -8,7 +8,7 @@ from tqdm import tqdm from viser.theme import TitlebarButton, TitlebarConfig, TitlebarImage -from py123d.datatypes.maps.map_datatypes import MapLayer +from py123d.datatypes.map.map_datatypes import MapLayer from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDARType diff --git a/tests/unit/datatypes/detections/test_box_detections.py b/tests/unit/datatypes/detections/test_box_detections.py new file mode 100644 index 00000000..5275a6fc --- /dev/null +++ b/tests/unit/datatypes/detections/test_box_detections.py @@ -0,0 +1,360 @@ +import unittest + +from py123d.conversion.registry.box_detection_label_registry import BoxDetectionLabel, DefaultBoxDetectionLabel +from py123d.datatypes.detections import ( + BoxDetectionMetadata, + BoxDetectionSE2, + BoxDetectionSE3, + BoxDetectionWrapper, +) +from py123d.datatypes.time.time_point import TimePoint +from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, StateSE2, StateSE3, Vector2D, Vector3D + + +class DummyBoxDetectionLabel(BoxDetectionLabel): + + CAR = 1 + PEDESTRIAN = 2 + BICYCLE = 3 + + def to_default(self): + mapping = { + DummyBoxDetectionLabel.CAR: DefaultBoxDetectionLabel.VEHICLE, + DummyBoxDetectionLabel.PEDESTRIAN: DefaultBoxDetectionLabel.PERSON, + DummyBoxDetectionLabel.BICYCLE: DefaultBoxDetectionLabel.BICYCLE, + } + return mapping[self] + + +sample_metadata_args = { + "label": DummyBoxDetectionLabel.CAR, + "track_token": "sample_token", + "num_lidar_points": 10, + "timepoint": TimePoint.from_s(0.0), +} + + +class TestBoxDetectionMetadata(unittest.TestCase): + + def test_initialization(self): + metadata = BoxDetectionMetadata(**sample_metadata_args) + self.assertIsInstance(metadata, BoxDetectionMetadata) + self.assertEqual(metadata.label, DummyBoxDetectionLabel.CAR) + self.assertEqual(metadata.track_token, "sample_token") + self.assertEqual(metadata.num_lidar_points, 10) + self.assertIsInstance(metadata.timepoint, TimePoint) + + def test_default_label(self): + metadata = BoxDetectionMetadata(**sample_metadata_args) + label = metadata.label + default_label = metadata.default_label + self.assertEqual(label, DummyBoxDetectionLabel.CAR) + self.assertEqual(label.to_default(), DefaultBoxDetectionLabel.VEHICLE) + self.assertEqual(default_label, DefaultBoxDetectionLabel.VEHICLE) + + def test_default_label_with_default_label(self): + sample_args = sample_metadata_args.copy() + sample_args["label"] = DefaultBoxDetectionLabel.PERSON + metadata = BoxDetectionMetadata(**sample_args) + label = metadata.label + default_label = metadata.default_label + self.assertEqual(label, DefaultBoxDetectionLabel.PERSON) + self.assertEqual(default_label, DefaultBoxDetectionLabel.PERSON) + + def test_optional_args(self): + sample_args = { + "label": DummyBoxDetectionLabel.BICYCLE, + "track_token": "another_token", + } + metadata = BoxDetectionMetadata(**sample_args) + self.assertIsInstance(metadata, BoxDetectionMetadata) + self.assertEqual(metadata.label, DummyBoxDetectionLabel.BICYCLE) + self.assertEqual(metadata.track_token, "another_token") + self.assertIsNone(metadata.num_lidar_points) + self.assertIsNone(metadata.timepoint) + + def test_missing_args(self): + sample_args = { + "label": DummyBoxDetectionLabel.CAR, + } + with self.assertRaises(TypeError): + BoxDetectionMetadata(**sample_args) + + sample_args = { + "track_token": "token_only", + } + with self.assertRaises(TypeError): + BoxDetectionMetadata(**sample_args) + + sample_args = { + "timepoint": TimePoint.from_s(0.0), + } + with self.assertRaises(TypeError): + BoxDetectionMetadata(**sample_args) + + +class TestBoxDetectionSE2(unittest.TestCase): + + def setUp(self): + self.metadata = BoxDetectionMetadata(**sample_metadata_args) + self.bounding_box_se2 = BoundingBoxSE2( + center=StateSE2(x=0.0, y=0.0, yaw=0.0), + length=4.0, + width=2.0, + ) + self.velocity = None + + def test_initialization(self): + box_detection = BoxDetectionSE2( + metadata=self.metadata, + bounding_box_se2=self.bounding_box_se2, + velocity=self.velocity, + ) + self.assertIsInstance(box_detection, BoxDetectionSE2) + self.assertEqual(box_detection.metadata, self.metadata) + self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se2) + self.assertIsNone(box_detection.velocity) + + def test_properties(self): + box_detection = BoxDetectionSE2( + metadata=self.metadata, + bounding_box_se2=self.bounding_box_se2, + velocity=self.velocity, + ) + self.assertEqual(box_detection.shapely_polygon, self.bounding_box_se2.shapely_polygon) + self.assertEqual(box_detection.center, self.bounding_box_se2.center) + self.assertEqual(box_detection.bounding_box, self.bounding_box_se2) + + def test_optional_velocity(self): + box_detection_no_velo = BoxDetectionSE2( + metadata=self.metadata, + bounding_box_se2=self.bounding_box_se2, + ) + self.assertIsInstance(box_detection_no_velo, BoxDetectionSE2) + self.assertIsNone(box_detection_no_velo.velocity) + + box_detection_velo = BoxDetectionSE2( + metadata=self.metadata, + bounding_box_se2=self.bounding_box_se2, + velocity=Vector2D(x=1.0, y=0.0), + ) + self.assertIsInstance(box_detection_velo, BoxDetectionSE2) + self.assertEqual(box_detection_velo.velocity, Vector2D(x=1.0, y=0.0)) + + +class TestBoxBoxDetectionSE3(unittest.TestCase): + + def setUp(self): + self.metadata = BoxDetectionMetadata(**sample_metadata_args) + self.bounding_box_se3 = BoundingBoxSE3( + center=StateSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), + length=4.0, + width=2.0, + height=1.5, + ) + self.velocity = Vector3D(x=1.0, y=0.0, z=0.0) + + def test_initialization(self): + box_detection = BoxDetectionSE3( + metadata=self.metadata, + bounding_box_se3=self.bounding_box_se3, + velocity=self.velocity, + ) + self.assertIsInstance(box_detection, BoxDetectionSE3) + self.assertEqual(box_detection.metadata, self.metadata) + self.assertEqual(box_detection.bounding_box_se3, self.bounding_box_se3) + self.assertEqual(box_detection.velocity, self.velocity) + + def test_properties(self): + box_detection = BoxDetectionSE3( + metadata=self.metadata, + bounding_box_se3=self.bounding_box_se3, + velocity=self.velocity, + ) + self.assertEqual(box_detection.shapely_polygon, self.bounding_box_se3.shapely_polygon) + self.assertEqual(box_detection.center, self.bounding_box_se3.center_se3) + self.assertEqual(box_detection.center_se3, self.bounding_box_se3.center_se3) + self.assertEqual(box_detection.bounding_box, self.bounding_box_se3) + self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se3.bounding_box_se2) + + def test_box_detection_se2_conversion(self): + box_detection = BoxDetectionSE3( + metadata=self.metadata, + bounding_box_se3=self.bounding_box_se3, + velocity=Vector3D(x=1.0, y=0.0, z=0.0), + ) + box_detection_se2 = box_detection.box_detection_se2 + self.assertIsInstance(box_detection_se2, BoxDetectionSE2) + self.assertEqual(box_detection_se2.metadata, self.metadata) + self.assertEqual(box_detection_se2.bounding_box_se2, self.bounding_box_se3.bounding_box_se2) + self.assertEqual(box_detection_se2.velocity, Vector2D(x=1.0, y=0.0)) + + def test_box_detection_se3_conversion(self): + box_detection_se2 = BoxDetectionSE2( + metadata=self.metadata, + bounding_box_se2=self.bounding_box_se3.bounding_box_se2, + velocity=Vector2D(x=1.0, y=0.0), + ) + box_detection_se3 = BoxDetectionSE3( + metadata=box_detection_se2.metadata, + bounding_box_se3=self.bounding_box_se3, + velocity=Vector2D(x=1.0, y=0.0), + ) + self.assertIsInstance(box_detection_se3, BoxDetectionSE3) + self.assertEqual(box_detection_se3.metadata, box_detection_se2.metadata) + self.assertEqual(box_detection_se3.bounding_box_se3, self.bounding_box_se3) + self.assertEqual(box_detection_se3.velocity, Vector2D(x=1.0, y=0.0)) + + box_detection_se3_converted = box_detection_se3.box_detection_se2 + self.assertIsInstance(box_detection_se3_converted, BoxDetectionSE2) + self.assertEqual(box_detection_se3_converted.metadata, box_detection_se2.metadata) + self.assertEqual(box_detection_se3_converted.bounding_box_se2, box_detection_se2.bounding_box_se2) + self.assertEqual(box_detection_se3_converted.velocity, box_detection_se2.velocity) + + def test_optional_velocity(self): + box_detection_no_velo = BoxDetectionSE3( + metadata=self.metadata, + bounding_box_se3=self.bounding_box_se3, + ) + self.assertIsInstance(box_detection_no_velo, BoxDetectionSE3) + self.assertIsNone(box_detection_no_velo.velocity) + + box_detection_velo = BoxDetectionSE3( + metadata=self.metadata, + bounding_box_se3=self.bounding_box_se3, + velocity=Vector3D(x=1.0, y=0.0, z=0.0), + ) + self.assertIsInstance(box_detection_velo, BoxDetectionSE3) + self.assertEqual(box_detection_velo.velocity, Vector3D(x=1.0, y=0.0, z=0.0)) + + +class TestBoxDetectionWrapper(unittest.TestCase): + + def setUp(self): + self.metadata1 = BoxDetectionMetadata( + label=DummyBoxDetectionLabel.CAR, + track_token="token1", + num_lidar_points=10, + timepoint=TimePoint.from_s(0.0), + ) + self.metadata2 = BoxDetectionMetadata( + label=DummyBoxDetectionLabel.PEDESTRIAN, + track_token="token2", + num_lidar_points=5, + timepoint=TimePoint.from_s(0.0), + ) + self.metadata3 = BoxDetectionMetadata( + label=DummyBoxDetectionLabel.BICYCLE, + track_token="token3", + num_lidar_points=8, + timepoint=TimePoint.from_s(0.0), + ) + + self.box_detection1 = BoxDetectionSE2( + metadata=self.metadata1, + bounding_box_se2=BoundingBoxSE2( + center=StateSE2(x=0.0, y=0.0, yaw=0.0), + length=4.0, + width=2.0, + ), + velocity=Vector2D(x=1.0, y=0.0), + ) + self.box_detection2 = BoxDetectionSE2( + metadata=self.metadata2, + bounding_box_se2=BoundingBoxSE2( + center=StateSE2(x=5.0, y=5.0, yaw=0.0), + length=1.0, + width=0.5, + ), + velocity=Vector2D(x=0.5, y=0.5), + ) + self.box_detection3 = BoxDetectionSE3( + metadata=self.metadata3, + bounding_box_se3=BoundingBoxSE3( + center=StateSE3(x=10.0, y=10.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), + length=2.0, + width=1.0, + height=1.5, + ), + velocity=Vector3D(x=0.0, y=1.0, z=0.0), + ) + + def test_initialization(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) + self.assertIsInstance(wrapper, BoxDetectionWrapper) + self.assertEqual(len(wrapper.box_detections), 2) + + def test_empty_initialization(self): + wrapper = BoxDetectionWrapper(box_detections=[]) + self.assertIsInstance(wrapper, BoxDetectionWrapper) + self.assertEqual(len(wrapper.box_detections), 0) + + def test_getitem(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) + self.assertEqual(wrapper[0], self.box_detection1) + self.assertEqual(wrapper[1], self.box_detection2) + + def test_getitem_out_of_range(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1]) + with self.assertRaises(IndexError): + _ = wrapper[1] + + def test_len(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2, self.box_detection3]) + self.assertEqual(len(wrapper), 3) + + def test_len_empty(self): + wrapper = BoxDetectionWrapper(box_detections=[]) + self.assertEqual(len(wrapper), 0) + + def test_iter(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) + detections = list(wrapper) + self.assertEqual(len(detections), 2) + self.assertEqual(detections[0], self.box_detection1) + self.assertEqual(detections[1], self.box_detection2) + + def test_get_detection_by_track_token_found(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2, self.box_detection3]) + detection = wrapper.get_detection_by_track_token("token2") + self.assertIsNotNone(detection) + self.assertEqual(detection, self.box_detection2) + self.assertEqual(detection.metadata.track_token, "token2") + + def test_get_detection_by_track_token_not_found(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) + detection = wrapper.get_detection_by_track_token("nonexistent_token") + self.assertIsNone(detection) + + def test_get_detection_by_track_token_empty_wrapper(self): + wrapper = BoxDetectionWrapper(box_detections=[]) + detection = wrapper.get_detection_by_track_token("token1") + self.assertIsNone(detection) + + def test_occupancy_map(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) + occupancy_map = wrapper.occupancy_map + self.assertIsNotNone(occupancy_map) + self.assertEqual(len(occupancy_map.geometries), 2) + self.assertEqual(len(occupancy_map.ids), 2) + self.assertIn("token1", occupancy_map.ids) + self.assertIn("token2", occupancy_map.ids) + + def test_occupancy_map_cached(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) + occupancy_map1 = wrapper.occupancy_map + occupancy_map2 = wrapper.occupancy_map + self.assertIs(occupancy_map1, occupancy_map2) + + def test_occupancy_map_empty(self): + wrapper = BoxDetectionWrapper(box_detections=[]) + occupancy_map = wrapper.occupancy_map + self.assertIsNotNone(occupancy_map) + self.assertEqual(len(occupancy_map.geometries), 0) + self.assertEqual(len(occupancy_map.ids), 0) + + def test_mixed_detection_types(self): + wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection3]) + self.assertEqual(len(wrapper), 2) + self.assertIsInstance(wrapper[0], BoxDetectionSE2) + self.assertIsInstance(wrapper[1], BoxDetectionSE3) diff --git a/tests/unit/datatypes/detections/test_traffic_lights.py b/tests/unit/datatypes/detections/test_traffic_lights.py new file mode 100644 index 00000000..edd90fcb --- /dev/null +++ b/tests/unit/datatypes/detections/test_traffic_lights.py @@ -0,0 +1,85 @@ +import unittest + +from py123d.datatypes.detections import TrafficLightDetection, TrafficLightDetectionWrapper, TrafficLightStatus +from py123d.datatypes.time.time_point import TimePoint + + +class TestTrafficLightStatus(unittest.TestCase): + def test_status_values(self): + """Test that TrafficLightStatus enum has correct values.""" + self.assertEqual(TrafficLightStatus.GREEN.value, 0) + self.assertEqual(TrafficLightStatus.YELLOW.value, 1) + self.assertEqual(TrafficLightStatus.RED.value, 2) + self.assertEqual(TrafficLightStatus.OFF.value, 3) + self.assertEqual(TrafficLightStatus.UNKNOWN.value, 4) + + +class TestTrafficLightDetection(unittest.TestCase): + def test_creation_with_required_fields(self): + """Test that TrafficLightDetection can be created with required fields.""" + detection = TrafficLightDetection(lane_id=1, status=TrafficLightStatus.GREEN) + assert detection.lane_id == 1 + assert detection.status == TrafficLightStatus.GREEN + assert detection.timepoint is None + + def test_creation_with_timepoint(self): + """Test that TrafficLightDetection can be created with timepoint.""" + timepoint = TimePoint(0) + detection = TrafficLightDetection( + lane_id=2, + status=TrafficLightStatus.RED, + timepoint=timepoint, + ) + assert detection.lane_id == 2 + assert detection.status == TrafficLightStatus.RED + assert detection.timepoint == timepoint + + +class TestTrafficLightDetectionWrapper(unittest.TestCase): + def setUp(self): + self.detection1 = TrafficLightDetection(lane_id=1, status=TrafficLightStatus.GREEN) + self.detection2 = TrafficLightDetection(lane_id=2, status=TrafficLightStatus.RED) + self.detection3 = TrafficLightDetection(lane_id=3, status=TrafficLightStatus.YELLOW) + self.wrapper = TrafficLightDetectionWrapper( + traffic_light_detections=[self.detection1, self.detection2, self.detection3] + ) + + def test_getitem(self): + """Test __getitem__ method of TrafficLightDetectionWrapper.""" + assert self.wrapper[0] == self.detection1 + assert self.wrapper[1] == self.detection2 + assert self.wrapper[2] == self.detection3 + + def test_len(self): + """Test __len__ method of TrafficLightDetectionWrapper.""" + assert len(self.wrapper) == 3 + + def test_iter(self): + """Test __iter__ method of TrafficLightDetectionWrapper.""" + detections = list(self.wrapper) + assert detections == [self.detection1, self.detection2, self.detection3] + + def test_get_detection_by_lane_id_found(self): + """Test get_detection_by_lane_id method of TrafficLightDetectionWrapper.""" + result = self.wrapper.get_detection_by_lane_id(2) + assert result == self.detection2 + assert result.status == TrafficLightStatus.RED + + def test_get_detection_by_lane_id_not_found(self): + """Test get_detection_by_lane_id method of TrafficLightDetectionWrapper when not found.""" + result = self.wrapper.get_detection_by_lane_id(99) + assert result is None + + def test_get_detection_by_lane_id_first_match(self): + """Test get_detection_by_lane_id method returns first match.""" + duplicate = TrafficLightDetection(lane_id=1, status=TrafficLightStatus.OFF) + wrapper = TrafficLightDetectionWrapper(traffic_light_detections=[self.detection1, duplicate]) + result = wrapper.get_detection_by_lane_id(1) + assert result == self.detection1 + + def test_empty_wrapper(self): + """Test behavior of an empty TrafficLightDetectionWrapper.""" + empty_wrapper = TrafficLightDetectionWrapper(traffic_light_detections=[]) + assert len(empty_wrapper) == 0 + assert list(empty_wrapper) == [] + assert empty_wrapper.get_detection_by_lane_id(1) is None From 8c3a3d7cbfeefd4fc1b4c90274d701ec6050f028 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 7 Nov 2025 23:57:52 +0100 Subject: [PATCH 03/50] Fix error in `docs` --- docs/api/map/map_layers.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/api/map/map_layers.rst b/docs/api/map/map_layers.rst index be345b5f..393b983e 100644 --- a/docs/api/map/map_layers.rst +++ b/docs/api/map/map_layers.rst @@ -2,7 +2,6 @@ Map Objects ----------- .. autoclass:: py123d.datatypes.map.AbstractCarpark() - :how-inheritance: :inherited-members: :autoclasstoc: From 1a42d48eacb8a1bec6fe21dc62b37bef69835be1 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sat, 8 Nov 2025 20:01:40 +0100 Subject: [PATCH 04/50] Rename `StateSE2` to `PoseSE2`. Rename `StateSE3` to `PoseSE3`. Fill docs. --- docs/api/datatypes/index.rst | 9 + docs/api/datatypes/sensors.rst | 25 +++ .../{ => 01_primitives}/01_points.rst | 2 +- .../{ => 01_primitives}/02_vectors.rst | 2 +- .../{ => 01_primitives}/03_rotations.rst | 2 +- docs/api/geometry/01_primitives/04_se.rst | 10 ++ .../{ => 01_primitives}/05_bounding_boxes.rst | 2 +- .../{ => 01_primitives}/06_polylines.rst | 2 +- .../{ => 01_primitives}/07_indexing_enums.rst | 6 +- docs/api/geometry/01_primitives/index.rst | 14 ++ .../geometry/02_transform/01_transform_2d.rst | 4 + docs/api/geometry/02_transform/index.rst | 9 + docs/api/geometry/04_se.rst | 10 -- docs/api/geometry/index.rst | 9 +- docs/index.rst | 1 + notebooks/bev_matplotlib.ipynb | 2 +- .../datasets/av2/av2_sensor_converter.py | 20 +-- .../datasets/kitti360/kitti360_converter.py | 28 ++-- .../datasets/kitti360/kitti360_sensor_io.py | 4 +- .../kitti360/utils/kitti360_helper.py | 4 +- .../datasets/nuplan/nuplan_converter.py | 6 +- .../nuplan/utils/nuplan_sql_helper.py | 12 +- .../datasets/nuscenes/nuscenes_converter.py | 12 +- .../datasets/nuscenes/nuscenes_sensor_io.py | 4 +- .../datasets/pandaset/pandaset_converter.py | 14 +- .../datasets/pandaset/pandaset_sensor_io.py | 4 +- .../pandaset/utils/pandaset_constants.py | 22 +-- .../datasets/pandaset/utils/pandaset_utlis.py | 18 +- .../wopd/utils/womp_boundary_utils.py | 10 +- .../datasets/wopd/wopd_converter.py | 22 +-- .../log_writer/abstract_log_writer.py | 4 +- .../conversion/log_writer/arrow_log_writer.py | 12 +- .../map_utils/opendrive/parser/geometry.py | 38 ++--- .../map_utils/opendrive/parser/reference.py | 4 +- .../map_utils/opendrive/utils/lane_helper.py | 10 +- .../opendrive/utils/objects_helper.py | 8 +- .../utils/sensor_utils/camera_conventions.py | 12 +- .../datatypes/detections/box_detections.py | 8 +- .../datatypes/map/abstract_map_objects.py | 4 +- .../scene/arrow/utils/arrow_getters.py | 78 ++++----- .../datatypes/sensors/fisheye_mei_camera.py | 4 +- src/py123d/datatypes/sensors/lidar.py | 6 +- .../datatypes/sensors/pinhole_camera.py | 4 +- .../datatypes/vehicle_state/ego_state.py | 40 ++--- .../vehicle_state/vehicle_parameters.py | 10 +- src/py123d/geometry/__init__.py | 6 +- src/py123d/geometry/bounding_box.py | 42 ++--- src/py123d/geometry/geometry_index.py | 6 +- src/py123d/geometry/polyline.py | 48 +++--- src/py123d/geometry/{se.py => pose.py} | 142 ++++++++-------- .../geometry/transform/transform_euler_se3.py | 46 +++--- .../geometry/transform/transform_se2.py | 100 +++++------ .../geometry/transform/transform_se3.py | 156 +++++++++--------- src/py123d/geometry/utils/polyline_utils.py | 18 +- src/py123d/visualization/matplotlib/camera.py | 4 +- .../visualization/matplotlib/observation.py | 4 +- src/py123d/visualization/matplotlib/plots.py | 2 +- src/py123d/visualization/matplotlib/utils.py | 4 +- .../viser/elements/detection_elements.py | 18 +- .../viser/elements/render_elements.py | 30 ++-- .../viser/elements/sensor_elements.py | 24 +-- .../visualization/viser/viser_viewer.py | 2 + .../detections/test_box_detections.py | 12 +- tests/unit/geometry/test_bounding_box.py | 8 +- tests/unit/geometry/test_polyline.py | 12 +- .../transform/test_transform_consistency.py | 76 +++++---- .../geometry/transform/test_transform_se2.py | 88 +++++----- .../geometry/transform/test_transform_se3.py | 70 ++++---- .../geometry/utils/test_bounding_box_utils.py | 12 +- 69 files changed, 765 insertions(+), 696 deletions(-) create mode 100644 docs/api/datatypes/index.rst create mode 100644 docs/api/datatypes/sensors.rst rename docs/api/geometry/{ => 01_primitives}/01_points.rst (95%) rename docs/api/geometry/{ => 01_primitives}/02_vectors.rst (95%) rename docs/api/geometry/{ => 01_primitives}/03_rotations.rst (94%) create mode 100644 docs/api/geometry/01_primitives/04_se.rst rename docs/api/geometry/{ => 01_primitives}/05_bounding_boxes.rst (91%) rename docs/api/geometry/{ => 01_primitives}/06_polylines.rst (95%) rename docs/api/geometry/{ => 01_primitives}/07_indexing_enums.rst (71%) create mode 100644 docs/api/geometry/01_primitives/index.rst create mode 100644 docs/api/geometry/02_transform/01_transform_2d.rst create mode 100644 docs/api/geometry/02_transform/index.rst delete mode 100644 docs/api/geometry/04_se.rst rename src/py123d/geometry/{se.py => pose.py} (78%) diff --git a/docs/api/datatypes/index.rst b/docs/api/datatypes/index.rst new file mode 100644 index 00000000..e67a813f --- /dev/null +++ b/docs/api/datatypes/index.rst @@ -0,0 +1,9 @@ +Datatypes +========= + + +.. toctree:: + :maxdepth: 2 + + + sensors diff --git a/docs/api/datatypes/sensors.rst b/docs/api/datatypes/sensors.rst new file mode 100644 index 00000000..76eba60d --- /dev/null +++ b/docs/api/datatypes/sensors.rst @@ -0,0 +1,25 @@ +Sensors +------- + +Pinhole Camera +^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.sensors.PinholeCamera + :members: + :autoclasstoc: + + +Fisheye MEI Camera +^^^^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.sensors.FisheyeMEICamera + :members: + :autoclasstoc: + + +LiDAR +^^^^^ + +.. autoclass:: py123d.datatypes.sensors.LiDAR + :members: + :autoclasstoc: diff --git a/docs/api/geometry/01_points.rst b/docs/api/geometry/01_primitives/01_points.rst similarity index 95% rename from docs/api/geometry/01_points.rst rename to docs/api/geometry/01_primitives/01_points.rst index 6eac41d9..d9b59166 100644 --- a/docs/api/geometry/01_points.rst +++ b/docs/api/geometry/01_primitives/01_points.rst @@ -1,5 +1,5 @@ Points ------- +^^^^^^ .. autoclass:: py123d.geometry.Point3D :members: diff --git a/docs/api/geometry/02_vectors.rst b/docs/api/geometry/01_primitives/02_vectors.rst similarity index 95% rename from docs/api/geometry/02_vectors.rst rename to docs/api/geometry/01_primitives/02_vectors.rst index ca8a71c3..dd3ae61e 100644 --- a/docs/api/geometry/02_vectors.rst +++ b/docs/api/geometry/01_primitives/02_vectors.rst @@ -1,5 +1,5 @@ Vectors -------- +^^^^^^^ .. autoclass:: py123d.geometry.Vector3D :members: diff --git a/docs/api/geometry/03_rotations.rst b/docs/api/geometry/01_primitives/03_rotations.rst similarity index 94% rename from docs/api/geometry/03_rotations.rst rename to docs/api/geometry/01_primitives/03_rotations.rst index 885642e3..06d98bb2 100644 --- a/docs/api/geometry/03_rotations.rst +++ b/docs/api/geometry/01_primitives/03_rotations.rst @@ -1,5 +1,5 @@ Rotations ---------- +^^^^^^^^^ .. autoclass:: py123d.geometry.Quaternion :members: diff --git a/docs/api/geometry/01_primitives/04_se.rst b/docs/api/geometry/01_primitives/04_se.rst new file mode 100644 index 00000000..ded6b380 --- /dev/null +++ b/docs/api/geometry/01_primitives/04_se.rst @@ -0,0 +1,10 @@ +Special Euclidean Groups +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.geometry.PoseSE3 + :members: + :autoclasstoc: + +.. autoclass:: py123d.geometry.PoseSE2 + :members: + :autoclasstoc: diff --git a/docs/api/geometry/05_bounding_boxes.rst b/docs/api/geometry/01_primitives/05_bounding_boxes.rst similarity index 91% rename from docs/api/geometry/05_bounding_boxes.rst rename to docs/api/geometry/01_primitives/05_bounding_boxes.rst index 74ac30bc..8d7be794 100644 --- a/docs/api/geometry/05_bounding_boxes.rst +++ b/docs/api/geometry/01_primitives/05_bounding_boxes.rst @@ -1,5 +1,5 @@ Bounding Boxes --------------- +^^^^^^^^^^^^^^ .. autoclass:: py123d.geometry.BoundingBoxSE3 :members: diff --git a/docs/api/geometry/06_polylines.rst b/docs/api/geometry/01_primitives/06_polylines.rst similarity index 95% rename from docs/api/geometry/06_polylines.rst rename to docs/api/geometry/01_primitives/06_polylines.rst index 901b2a6b..77b0a12b 100644 --- a/docs/api/geometry/06_polylines.rst +++ b/docs/api/geometry/01_primitives/06_polylines.rst @@ -1,5 +1,5 @@ Polylines ---------- +^^^^^^^^^ .. autoclass:: py123d.geometry.Polyline3D :members: diff --git a/docs/api/geometry/07_indexing_enums.rst b/docs/api/geometry/01_primitives/07_indexing_enums.rst similarity index 71% rename from docs/api/geometry/07_indexing_enums.rst rename to docs/api/geometry/01_primitives/07_indexing_enums.rst index 0d056807..c1562e06 100644 --- a/docs/api/geometry/07_indexing_enums.rst +++ b/docs/api/geometry/01_primitives/07_indexing_enums.rst @@ -1,5 +1,5 @@ Indexing Enums --------------- +^^^^^^^^^^^^^^ .. autoclass:: py123d.geometry.Point2DIndex :members: @@ -9,10 +9,10 @@ Indexing Enums :members: :no-inherited-members: -.. autoclass:: py123d.geometry.StateSE2Index +.. autoclass:: py123d.geometry.PoseSE2Index :members: :no-inherited-members: -.. autoclass:: py123d.geometry.StateSE3Index +.. autoclass:: py123d.geometry.PoseSE3Index :members: :no-inherited-members: diff --git a/docs/api/geometry/01_primitives/index.rst b/docs/api/geometry/01_primitives/index.rst new file mode 100644 index 00000000..fd74f01e --- /dev/null +++ b/docs/api/geometry/01_primitives/index.rst @@ -0,0 +1,14 @@ +Primitives +---------- + +.. toctree:: + :maxdepth: 2 + + + 01_points + 02_vectors + 03_rotations + 04_se + 05_bounding_boxes + 06_polylines + 07_indexing_enums diff --git a/docs/api/geometry/02_transform/01_transform_2d.rst b/docs/api/geometry/02_transform/01_transform_2d.rst new file mode 100644 index 00000000..ea6998ac --- /dev/null +++ b/docs/api/geometry/02_transform/01_transform_2d.rst @@ -0,0 +1,4 @@ +Transforms in 2D +^^^^^^^^^^^^^^^^ + +.. autofunction:: py123d.geometry.transform.convert_absolute_to_relative_se2_array diff --git a/docs/api/geometry/02_transform/index.rst b/docs/api/geometry/02_transform/index.rst new file mode 100644 index 00000000..050e7be9 --- /dev/null +++ b/docs/api/geometry/02_transform/index.rst @@ -0,0 +1,9 @@ +Transforms +---------- + + +.. toctree:: + :maxdepth: 2 + + + 01_transform_2d diff --git a/docs/api/geometry/04_se.rst b/docs/api/geometry/04_se.rst deleted file mode 100644 index 6a8f22c7..00000000 --- a/docs/api/geometry/04_se.rst +++ /dev/null @@ -1,10 +0,0 @@ -Special Euclidean Groups ------------------------- - -.. autoclass:: py123d.geometry.StateSE3 - :members: - :autoclasstoc: - -.. autoclass:: py123d.geometry.StateSE2 - :members: - :autoclasstoc: diff --git a/docs/api/geometry/index.rst b/docs/api/geometry/index.rst index 41467a37..c9fd3b03 100644 --- a/docs/api/geometry/index.rst +++ b/docs/api/geometry/index.rst @@ -5,10 +5,5 @@ Geometry :maxdepth: 2 - 01_points - 02_vectors - 03_rotations - 04_se - 05_bounding_boxes - 06_polylines - 07_indexing_enums + 01_primitives/index + 02_transform/index diff --git a/docs/index.rst b/docs/index.rst index 6c16df23..96e655f7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ documentation for details. api/map/index api/geometry/index + api/datatypes/index .. toctree:: :maxdepth: 1 diff --git a/notebooks/bev_matplotlib.ipynb b/notebooks/bev_matplotlib.ipynb index da7c28d0..40bea7b8 100644 --- a/notebooks/bev_matplotlib.ipynb +++ b/notebooks/bev_matplotlib.ipynb @@ -213,7 +213,7 @@ " box_detections = scene.get_box_detections_at_iteration(iteration)\n", " map_api = scene.get_map_api()\n", "\n", - " point_2d = ego_vehicle_state.bounding_box.center.state_se2.point_2d\n", + " point_2d = ego_vehicle_state.bounding_box.center.point_2d\n", " if map_api is not None:\n", " # add_debug_map_on_ax(ax, scene.get_map_api(), point_2d, radius=radius, route_lane_group_ids=None)\n", "\n", diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 1d71bee0..8e72c749 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -33,7 +33,7 @@ from py123d.datatypes.vehicle_state.vehicle_parameters import ( get_av2_ford_fusion_hybrid_parameters, ) -from py123d.geometry import BoundingBoxSE3Index, StateSE3, Vector3D, Vector3DIndex +from py123d.geometry import BoundingBoxSE3Index, PoseSE3, Vector3D, Vector3DIndex from py123d.geometry.bounding_box import BoundingBoxSE3 from py123d.geometry.transform.transform_se3 import convert_relative_to_absolute_se3_array @@ -221,7 +221,7 @@ def _get_av2_lidar_metadata( metadata[LiDARType.LIDAR_TOP] = LiDARMetadata( lidar_type=LiDARType.LIDAR_TOP, lidar_index=AVSensorLiDARIndex, - extrinsic=_row_dict_to_state_se3( + extrinsic=_row_dict_to_pose_se3( calibration_df[calibration_df["sensor_name"] == "up_lidar"].iloc[0].to_dict() ), ) @@ -229,7 +229,7 @@ def _get_av2_lidar_metadata( metadata[LiDARType.LIDAR_DOWN] = LiDARMetadata( lidar_type=LiDARType.LIDAR_DOWN, lidar_index=AVSensorLiDARIndex, - extrinsic=_row_dict_to_state_se3( + extrinsic=_row_dict_to_pose_se3( calibration_df[calibration_df["sensor_name"] == "down_lidar"].iloc[0].to_dict() ), ) @@ -264,9 +264,9 @@ def _extract_av2_sensor_box_detections( detections_labels.append(AV2SensorBoxDetectionLabel.deserialize(row["category"])) detections_num_lidar_points.append(int(row["num_interior_pts"])) - detections_state[:, BoundingBoxSE3Index.STATE_SE3] = convert_relative_to_absolute_se3_array( + detections_state[:, BoundingBoxSE3Index.POSE_SE3] = convert_relative_to_absolute_se3_array( origin=ego_state_se3.rear_axle_se3, - se3_array=detections_state[:, BoundingBoxSE3Index.STATE_SE3], + se3_array=detections_state[:, BoundingBoxSE3Index.POSE_SE3], ) box_detections: List[BoxDetectionSE3] = [] @@ -293,7 +293,7 @@ def _extract_av2_sensor_ego_state(city_se3_egovehicle_df: pd.DataFrame, lidar_ti ), f"Expected exactly one ego state for timestamp {lidar_timestamp_ns}, got {len(ego_state_slice)}." ego_pose_dict = ego_state_slice.iloc[0].to_dict() - rear_axle_pose = _row_dict_to_state_se3(ego_pose_dict) + rear_axle_pose = _row_dict_to_pose_se3(ego_pose_dict) vehicle_parameters = get_av2_ford_fusion_hybrid_parameters() @@ -345,7 +345,7 @@ def _extract_av2_sensor_pinhole_cameras( camera_data = CameraData( camera_type=pinhole_camera_type, - extrinsic=_row_dict_to_state_se3(row), + extrinsic=_row_dict_to_pose_se3(row), dataset_root=av2_sensor_data_root, relative_path=relative_image_path, ) @@ -377,9 +377,9 @@ def _extract_av2_sensor_lidars( return lidars -def _row_dict_to_state_se3(row_dict: Dict[str, float]) -> StateSE3: - """Helper function to convert a row dictionary to a StateSE3 object.""" - return StateSE3( +def _row_dict_to_pose_se3(row_dict: Dict[str, float]) -> PoseSE3: + """Helper function to convert a row dictionary to a PoseSE3 object.""" + return PoseSE3( x=row_dict["tx_m"], y=row_dict["ty_m"], z=row_dict["tz_m"], diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 3154fd90..7b1983d6 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -53,7 +53,7 @@ from py123d.datatypes.vehicle_state.vehicle_parameters import ( get_kitti360_vw_passat_parameters, ) -from py123d.geometry import BoundingBoxSE3, Quaternion, StateSE3, Vector3D +from py123d.geometry import BoundingBoxSE3, PoseSE3, Quaternion, Vector3D from py123d.geometry.transform.transform_se3 import convert_se3_array_between_origins, translate_se3_along_body_frame KITTI360_DT: Final[float] = 0.1 @@ -433,12 +433,12 @@ def _get_kitti360_lidar_metadata( metadata: Dict[LiDARType, LiDARMetadata] = {} if dataset_converter_config.include_lidars: extrinsic = get_kitti360_lidar_extrinsic(kitti360_folders[DIR_CALIB]) - extrinsic_state_se3 = StateSE3.from_transformation_matrix(extrinsic) - extrinsic_state_se3 = _extrinsic_from_imu_to_rear_axle(extrinsic_state_se3) + extrinsic_pose_se3 = PoseSE3.from_transformation_matrix(extrinsic) + extrinsic_pose_se3 = _extrinsic_from_imu_to_rear_axle(extrinsic_pose_se3) metadata[LiDARType.LIDAR_TOP] = LiDARMetadata( lidar_type=LiDARType.LIDAR_TOP, lidar_index=Kitti360LiDARIndex, - extrinsic=extrinsic_state_se3, + extrinsic=extrinsic_pose_se3, ) return metadata @@ -507,7 +507,7 @@ def _extract_ego_state_all(log_name: str, kitti360_folders: Dict[str, Path]) -> R_mat_cali = R_mat @ KITTI3602NUPLAN_IMU_CALIBRATION[:3, :3] ego_quaternion = Quaternion.from_rotation_matrix(R_mat_cali) - imu_pose = StateSE3( + imu_pose = PoseSE3( x=poses[pos, 4], y=poses[pos, 8], z=poses[pos, 12], @@ -714,7 +714,7 @@ def _extract_kitti360_lidar( def _extract_kitti360_pinhole_cameras( log_name: str, idx: int, - camera_calibration: Dict[str, StateSE3], + camera_calibration: Dict[str, PoseSE3], kitti360_folders: Dict[str, Path], data_converter_config: DatasetConverterConfig, ) -> List[CameraData]: @@ -740,7 +740,7 @@ def _extract_kitti360_pinhole_cameras( def _extract_kitti360_fisheye_mei_cameras( log_name: str, idx: int, - camera_calibration: Dict[str, StateSE3], + camera_calibration: Dict[str, PoseSE3], kitti360_folders: Dict[str, Path], data_converter_config: DatasetConverterConfig, ) -> List[CameraData]: @@ -762,13 +762,13 @@ def _extract_kitti360_fisheye_mei_cameras( return fisheye_camera_data_list -def _load_kitti_360_calibration(kitti_360_data_root: Path) -> Dict[str, StateSE3]: +def _load_kitti_360_calibration(kitti_360_data_root: Path) -> Dict[str, PoseSE3]: calib_file = kitti_360_data_root / DIR_CALIB / "calib_cam_to_pose.txt" if not calib_file.exists(): raise FileNotFoundError(f"Calibration file not found: {calib_file}") lastrow = np.array([0, 0, 0, 1]).reshape(1, 4) - calib_dict: Dict[str, StateSE3] = {} + calib_dict: Dict[str, PoseSE3] = {} with open(calib_file, "r") as f: for line in f: parts = line.strip().split() @@ -777,13 +777,13 @@ def _load_kitti_360_calibration(kitti_360_data_root: Path) -> Dict[str, StateSE3 matrix = np.array(values).reshape(3, 4) cam2pose = np.concatenate((matrix, lastrow)) cam2pose = KITTI3602NUPLAN_IMU_CALIBRATION @ cam2pose - camera_extrinsic = StateSE3.from_transformation_matrix(cam2pose) + camera_extrinsic = PoseSE3.from_transformation_matrix(cam2pose) camera_extrinsic = _extrinsic_from_imu_to_rear_axle(camera_extrinsic) calib_dict[key] = camera_extrinsic return calib_dict -def _extrinsic_from_imu_to_rear_axle(extrinsic: StateSE3) -> StateSE3: - imu_se3 = StateSE3(x=-0.05, y=0.32, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) - rear_axle_se3 = StateSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) - return StateSE3.from_array(convert_se3_array_between_origins(imu_se3, rear_axle_se3, extrinsic.array)) +def _extrinsic_from_imu_to_rear_axle(extrinsic: PoseSE3) -> PoseSE3: + imu_se3 = PoseSE3(x=-0.05, y=0.32, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + rear_axle_se3 = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + return PoseSE3.from_array(convert_se3_array_between_origins(imu_se3, rear_axle_se3, extrinsic.array)) diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py index e58b165d..e2c395e6 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py @@ -7,7 +7,7 @@ from py123d.conversion.registry.lidar_index_registry import Kitti360LiDARIndex from py123d.datatypes.scene.scene_metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARType -from py123d.geometry.se import StateSE3 +from py123d.geometry.pose import PoseSE3 from py123d.geometry.transform.transform_se3 import convert_points_3d_array_between_origins @@ -22,7 +22,7 @@ def load_kitti360_lidar_pcs_from_file(filepath: Path, log_metadata: LogMetadata) lidar_pc[..., Kitti360LiDARIndex.XYZ] = convert_points_3d_array_between_origins( from_origin=lidar_extrinsic, - to_origin=StateSE3(0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0), + to_origin=PoseSE3(0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0), points_3d_array=lidar_pc[..., Kitti360LiDARIndex.XYZ], ) diff --git a/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py b/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py index fa3afa77..89cfcd1a 100644 --- a/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py +++ b/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py @@ -6,7 +6,7 @@ from scipy.linalg import polar from py123d.conversion.datasets.kitti360.utils.kitti360_labels import BBOX_LABLES_TO_DETECTION_NAME_DICT, kittiId2label -from py123d.geometry import BoundingBoxSE3, EulerAngles, Polyline3D, StateSE3 +from py123d.geometry import BoundingBoxSE3, EulerAngles, Polyline3D, PoseSE3 # KITTI360_DATA_ROOT = Path(os.environ["KITTI360_DATA_ROOT"]) # DIR_CALIB = "calibration" @@ -137,7 +137,7 @@ def parse_scale_rotation(self): self.qz = obj_quaternion.qz def get_state_array(self) -> np.ndarray: - center = StateSE3( + center = PoseSE3( x=self.T[0], y=self.T[1], z=self.T[2], diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index e0c03785..f79a61c9 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -42,7 +42,7 @@ from py123d.datatypes.vehicle_state.vehicle_parameters import ( get_nuplan_chrysler_pacifica_parameters, ) -from py123d.geometry import StateSE3, Vector3D +from py123d.geometry import PoseSE3, Vector3D check_dependencies(["nuplan"], "nuplan") from nuplan.database.nuplan_db.nuplan_scenario_queries import get_cameras, get_images_from_lidar_tokens @@ -295,7 +295,7 @@ def _get_nuplan_lidar_metadata( def _extract_nuplan_ego_state(nuplan_lidar_pc: LidarPc) -> EgoStateSE3: vehicle_parameters = get_nuplan_chrysler_pacifica_parameters() - rear_axle_pose = StateSE3( + rear_axle_pose = PoseSE3( x=nuplan_lidar_pc.ego_pose.x, y=nuplan_lidar_pc.ego_pose.y, z=nuplan_lidar_pc.ego_pose.z, @@ -390,7 +390,7 @@ def _extract_nuplan_cameras( cam_info = log_cam_infos[image.camera_token] c2img_e = cam_info.trans_matrix c2e = img_e2e @ c2img_e - extrinsic = StateSE3.from_transformation_matrix(c2e) + extrinsic = PoseSE3.from_transformation_matrix(c2e) # Store in dictionary camera_data_list.append( diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py index 866246ba..d283ae57 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py @@ -3,7 +3,7 @@ from py123d.common.utils.dependencies import check_dependencies from py123d.conversion.datasets.nuplan.utils.nuplan_constants import NUPLAN_DETECTION_NAME_DICT from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3 -from py123d.geometry import BoundingBoxSE3, EulerAngles, StateSE3, Vector3D +from py123d.geometry import BoundingBoxSE3, EulerAngles, PoseSE3, Vector3D from py123d.geometry.utils.constants import DEFAULT_PITCH, DEFAULT_ROLL check_dependencies(modules=["nuplan"], optional_name="nuplan") @@ -42,7 +42,7 @@ def get_box_detections_for_lidarpc_token_from_db(log_file: str, token: str) -> L for row in execute_many(query, (bytearray.fromhex(token),), log_file): quaternion = EulerAngles(roll=DEFAULT_ROLL, pitch=DEFAULT_PITCH, yaw=row["yaw"]).quaternion bounding_box = BoundingBoxSE3( - center=StateSE3( + center=PoseSE3( x=row["x"], y=row["y"], z=row["z"], @@ -68,7 +68,7 @@ def get_box_detections_for_lidarpc_token_from_db(log_file: str, token: str) -> L return box_detections -def get_ego_pose_for_timestamp_from_db(log_file: str, timestamp: int) -> StateSE3: +def get_ego_pose_for_timestamp_from_db(log_file: str, timestamp: int) -> PoseSE3: query = """ SELECT ep.x, @@ -92,7 +92,7 @@ def get_ego_pose_for_timestamp_from_db(log_file: str, timestamp: int) -> StateSE if row is None: return None - return StateSE3(x=row["x"], y=row["y"], z=row["z"], qw=row["qw"], qx=row["qx"], qy=row["qy"], qz=row["qz"]) + return PoseSE3(x=row["x"], y=row["y"], z=row["z"], qw=row["qw"], qx=row["qx"], qy=row["qy"], qz=row["qz"]) def get_nearest_ego_pose_for_timestamp_from_db( @@ -101,7 +101,7 @@ def get_nearest_ego_pose_for_timestamp_from_db( tokens: List[str], lookahead_window_us: int = 50000, lookback_window_us: int = 50000, -) -> StateSE3: +) -> PoseSE3: query = f""" SELECT ep.x, @@ -125,4 +125,4 @@ def get_nearest_ego_pose_for_timestamp_from_db( args += [timestamp] for row in execute_many(query, args, log_file): - return StateSE3(x=row["x"], y=row["y"], z=row["z"], qw=row["qw"], qx=row["qx"], qy=row["qy"], qz=row["qz"]) + return PoseSE3(x=row["x"], y=row["y"], z=row["z"], qw=row["qw"], qx=row["qx"], qy=row["qy"], qz=row["qz"]) diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 6f7cee92..22e673f0 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -32,7 +32,7 @@ from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import get_nuscenes_renault_zoe_parameters -from py123d.geometry import BoundingBoxSE3, StateSE3 +from py123d.geometry import BoundingBoxSE3, PoseSE3 from py123d.geometry.vector import Vector3D check_dependencies(["nuscenes"], "nuscenes") @@ -251,7 +251,7 @@ def _get_nuscenes_lidar_metadata( extrinsic = np.eye(4) extrinsic[:3, :3] = rotation extrinsic[:3, 3] = translation - extrinsic = StateSE3.from_transformation_matrix(extrinsic) + extrinsic = PoseSE3.from_transformation_matrix(extrinsic) metadata[LiDARType.LIDAR_TOP] = LiDARMetadata( lidar_type=LiDARType.LIDAR_TOP, @@ -280,7 +280,7 @@ def _extract_nuscenes_ego_state(nusc, sample, can_bus) -> EgoStateSE3: quat = Quaternion(ego_pose["rotation"]) vehicle_parameters = get_nuscenes_renault_zoe_parameters() - imu_pose = StateSE3( + imu_pose = PoseSE3( x=ego_pose["translation"][0], y=ego_pose["translation"][1], z=ego_pose["translation"][2], @@ -334,9 +334,9 @@ def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> ann = nusc.get("sample_annotation", ann_token) box = Box(ann["translation"], ann["size"], Quaternion(ann["rotation"])) - # Create StateSE3 for box center and orientation + # Create PoseSE3 for box center and orientation center_quat = box.orientation - center = StateSE3( + center = PoseSE3( box.center[0], box.center[1], box.center[2], @@ -395,7 +395,7 @@ def _extract_nuscenes_cameras( extrinsic_matrix = np.eye(4) extrinsic_matrix[:3, :3] = rotation extrinsic_matrix[:3, 3] = translation - extrinsic = StateSE3.from_transformation_matrix(extrinsic_matrix) + extrinsic = PoseSE3.from_transformation_matrix(extrinsic_matrix) cam_path = nuscenes_data_root / str(cam_data["filename"]) diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py index e09caae6..afc9dae5 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py @@ -6,7 +6,7 @@ from py123d.conversion.registry.lidar_index_registry import NuScenesLiDARIndex from py123d.datatypes.scene.scene_metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARType -from py123d.geometry.se import StateSE3 +from py123d.geometry.pose import PoseSE3 from py123d.geometry.transform.transform_se3 import convert_points_3d_array_between_origins @@ -17,7 +17,7 @@ def load_nuscenes_lidar_pcs_from_file(pcd_path: Path, log_metadata: LogMetadata) lidar_extrinsic = log_metadata.lidar_metadata[LiDARType.LIDAR_TOP].extrinsic lidar_pc[..., NuScenesLiDARIndex.XYZ] = convert_points_3d_array_between_origins( from_origin=lidar_extrinsic, - to_origin=StateSE3(0, 0, 0, 1.0, 0, 0, 0), + to_origin=PoseSE3(0, 0, 0, 1.0, 0, 0, 0), points_3d_array=lidar_pc[..., NuScenesLiDARIndex.XYZ], ) return {LiDARType.LIDAR_TOP: lidar_pc} diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index 0ada2937..c4d181d9 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -17,7 +17,7 @@ ) from py123d.conversion.datasets.pandaset.utils.pandaset_utlis import ( main_lidar_to_rear_axle, - pandaset_pose_dict_to_state_se3, + pandaset_pose_dict_to_pose_se3, read_json, read_pkl_gz, rotate_pandaset_pose_to_iso_coordinates, @@ -33,7 +33,7 @@ from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import get_pandaset_chrysler_pacifica_parameters -from py123d.geometry import BoundingBoxSE3, BoundingBoxSE3Index, EulerAnglesIndex, StateSE3, Vector3D +from py123d.geometry import BoundingBoxSE3, BoundingBoxSE3Index, EulerAnglesIndex, PoseSE3, Vector3D from py123d.geometry.transform.transform_se3 import convert_absolute_to_relative_se3_array from py123d.geometry.utils.constants import DEFAULT_PITCH, DEFAULT_ROLL from py123d.geometry.utils.rotation_utils import get_quaternion_array_from_euler_array @@ -207,7 +207,7 @@ def _get_pandaset_lidar_metadata( def _extract_pandaset_sensor_ego_state(gps: Dict[str, float], lidar_pose: Dict[str, Dict[str, float]]) -> EgoStateSE3: - rear_axle_se3 = main_lidar_to_rear_axle(pandaset_pose_dict_to_state_se3(lidar_pose)) + rear_axle_se3 = main_lidar_to_rear_axle(pandaset_pose_dict_to_pose_se3(lidar_pose)) vehicle_parameters = get_pandaset_chrysler_pacifica_parameters() @@ -300,8 +300,8 @@ def _extract_pandaset_box_detections( # Convert coordinates to ISO 8855 # NOTE: This would be faster over a batch operation. - box_se3_array[box_idx, BoundingBoxSE3Index.STATE_SE3] = rotate_pandaset_pose_to_iso_coordinates( - StateSE3.from_array(box_se3_array[box_idx, BoundingBoxSE3Index.STATE_SE3], copy=False) + box_se3_array[box_idx, BoundingBoxSE3Index.POSE_SE3] = rotate_pandaset_pose_to_iso_coordinates( + PoseSE3.from_array(box_se3_array[box_idx, BoundingBoxSE3Index.POSE_SE3], copy=False) ).array box_detection_se3 = BoxDetectionSE3( @@ -335,9 +335,9 @@ def _extract_pandaset_sensor_camera( assert image_abs_path.exists(), f"Camera image file {str(image_abs_path)} does not exist." camera_pose_dict = camera_poses[camera_name][iteration] - camera_extrinsic = pandaset_pose_dict_to_state_se3(camera_pose_dict) + camera_extrinsic = pandaset_pose_dict_to_pose_se3(camera_pose_dict) - camera_extrinsic = StateSE3.from_array( + camera_extrinsic = PoseSE3.from_array( convert_absolute_to_relative_se3_array(ego_state_se3.rear_axle_se3, camera_extrinsic.array), copy=True ) camera_data_list.append( diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py b/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py index 14f1f236..107a7d43 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py @@ -6,7 +6,7 @@ from py123d.conversion.datasets.pandaset.utils.pandaset_utlis import ( main_lidar_to_rear_axle, - pandaset_pose_dict_to_state_se3, + pandaset_pose_dict_to_pose_se3, read_json, read_pkl_gz, ) @@ -42,7 +42,7 @@ def load_pandaset_lidars_pcs_from_file( lidar_pc_dict = load_pandaset_global_lidar_pc_from_path(pkl_gz_path) ego_pose = main_lidar_to_rear_axle( - pandaset_pose_dict_to_state_se3(read_json(pkl_gz_path.parent / "poses.json")[iteration]) + pandaset_pose_dict_to_pose_se3(read_json(pkl_gz_path.parent / "poses.json")[iteration]) ) for lidar_type in lidar_pc_dict.keys(): diff --git a/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py b/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py index 8428aadd..bda3efd1 100644 --- a/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py +++ b/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py @@ -3,7 +3,7 @@ from py123d.conversion.registry.box_detection_label_registry import PandasetBoxDetectionLabel from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType, PinholeDistortion, PinholeIntrinsics -from py123d.geometry import StateSE3 +from py123d.geometry import PoseSE3 PANDASET_SPLITS: List[str] = ["pandaset_train", "pandaset_val", "pandaset_test"] @@ -51,8 +51,8 @@ # https://github.com/scaleapi/pandaset-devkit/blob/master/docs/static_extrinsic_calibration.yaml -PANDASET_LIDAR_EXTRINSICS: Dict[str, StateSE3] = { - "front_gt": StateSE3( +PANDASET_LIDAR_EXTRINSICS: Dict[str, PoseSE3] = { + "front_gt": PoseSE3( x=-0.000451117754, y=-0.605646431446, z=-0.301525235176, @@ -61,12 +61,12 @@ qy=0.01134678181520767, qz=0.9997028534282365, ), - "main_pandar64": StateSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), + "main_pandar64": PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), } # https://github.com/scaleapi/pandaset-devkit/blob/master/docs/static_extrinsic_calibration.yaml -PANDASET_CAMERA_EXTRINSICS: Dict[str, StateSE3] = { - "back_camera": StateSE3( +PANDASET_CAMERA_EXTRINSICS: Dict[str, PoseSE3] = { + "back_camera": PoseSE3( x=-0.0004217634029916384, y=-0.21683144949675118, z=-1.0553445472201475, @@ -75,7 +75,7 @@ qy=-0.001595758695393934, qz=-0.0005330311533742299, ), - "front_camera": StateSE3( + "front_camera": PoseSE3( x=0.0002585796504896516, y=-0.03907777167811011, z=-0.0440125762408362, @@ -84,7 +84,7 @@ qy=0.7114721800418571, qz=-0.7025205466606356, ), - "front_left_camera": StateSE3( + "front_left_camera": PoseSE3( x=-0.25842240863267835, y=-0.3070654284505582, z=-0.9244245686318884, @@ -93,7 +93,7 @@ qy=-0.6283486651480494, qz=0.6206973014480826, ), - "front_right_camera": StateSE3( + "front_right_camera": PoseSE3( x=0.2546935700219631, y=-0.24929449717803095, z=-0.8686597280810242, @@ -102,7 +102,7 @@ qy=0.6120314641083645, qz=-0.6150170047424814, ), - "left_camera": StateSE3( + "left_camera": PoseSE3( x=0.23864835336611942, y=-0.2801448284013492, z=-0.5376795959387791, @@ -111,7 +111,7 @@ qy=-0.4989265501075421, qz=0.503409565706149, ), - "right_camera": StateSE3( + "right_camera": PoseSE3( x=-0.23097163411257893, y=-0.30843497058841024, z=-0.6850441215571058, diff --git a/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py b/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py index 68575e7e..316a67ab 100644 --- a/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py +++ b/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py @@ -6,7 +6,7 @@ import numpy as np -from py123d.geometry import StateSE3, Vector3D +from py123d.geometry import PoseSE3, Vector3D from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame @@ -24,13 +24,13 @@ def read_pkl_gz(pkl_gz_file: Path): return pkl_data -def pandaset_pose_dict_to_state_se3(pose_dict: Dict[str, Dict[str, float]]) -> StateSE3: - """Helper function for pandaset to convert a pose dict to StateSE3. +def pandaset_pose_dict_to_pose_se3(pose_dict: Dict[str, Dict[str, float]]) -> PoseSE3: + """Helper function for pandaset to convert a pose dict to PoseSE3. :param pose_dict: The input pose dict. - :return: The converted StateSE3. + :return: The converted PoseSE3. """ - return StateSE3( + return PoseSE3( x=pose_dict["position"]["x"], y=pose_dict["position"]["y"], z=pose_dict["position"]["z"], @@ -41,7 +41,7 @@ def pandaset_pose_dict_to_state_se3(pose_dict: Dict[str, Dict[str, float]]) -> S ) -def rotate_pandaset_pose_to_iso_coordinates(pose: StateSE3) -> StateSE3: +def rotate_pandaset_pose_to_iso_coordinates(pose: PoseSE3) -> PoseSE3: """Helper function for pandaset to rotate a pose to ISO coordinate system (x: forward, y: left, z: up). NOTE: Pandaset uses a different coordinate system (x: right, y: forward, z: up). @@ -61,10 +61,10 @@ def rotate_pandaset_pose_to_iso_coordinates(pose: StateSE3) -> StateSE3: transformation_matrix = pose.transformation_matrix.copy() transformation_matrix[0:3, 0:3] = transformation_matrix[0:3, 0:3] @ F - return StateSE3.from_transformation_matrix(transformation_matrix) + return PoseSE3.from_transformation_matrix(transformation_matrix) -def main_lidar_to_rear_axle(pose: StateSE3) -> StateSE3: +def main_lidar_to_rear_axle(pose: PoseSE3) -> PoseSE3: F = np.array( [ @@ -77,7 +77,7 @@ def main_lidar_to_rear_axle(pose: StateSE3) -> StateSE3: transformation_matrix = pose.transformation_matrix.copy() transformation_matrix[0:3, 0:3] = transformation_matrix[0:3, 0:3] @ F - rotated_pose = StateSE3.from_transformation_matrix(transformation_matrix) + rotated_pose = PoseSE3.from_transformation_matrix(transformation_matrix) imu_pose = translate_se3_along_body_frame(rotated_pose, vector_3d=Vector3D(x=-0.840, y=0.0, z=0.0)) diff --git a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py index a85330d1..1f207994 100644 --- a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py +++ b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py @@ -6,7 +6,7 @@ from py123d.datatypes.map.abstract_map_objects import AbstractRoadEdge, AbstractRoadLine from py123d.datatypes.map.map_datatypes import LaneType -from py123d.geometry import OccupancyMap2D, Point3D, Polyline3D, PolylineSE2, StateSE2, Vector2D +from py123d.geometry import OccupancyMap2D, Point3D, Polyline3D, PolylineSE2, PoseSE2, Vector2D from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame from py123d.geometry.utils.rotation_utils import normalize_angle @@ -80,7 +80,7 @@ def hit_polyline_type(self) -> int: def _collect_perpendicular_hits( - lane_query_se2: StateSE2, + lane_query_se2: PoseSE2, lane_token: str, polyline_dict: Dict[str, Dict[int, Polyline3D]], lane_polyline_se2_dict: Dict[int, PolylineSE2], @@ -215,7 +215,7 @@ def fill_lane_boundaries( 0, lane_polyline_se2.length, int(lane_polyline_se2.length / BOUNDARY_STEP_SIZE) + 1, endpoint=True ) lane_queries_se2 = [ - StateSE2.from_array(state_se2_array) for state_se2_array in lane_polyline_se2.interpolate(distances_se2) + PoseSE2.from_array(pose_se2_array) for pose_se2_array in lane_polyline_se2.interpolate(distances_se2) ] distances_3d = np.linspace( 0, lane_polyline.length, int(lane_polyline.length / BOUNDARY_STEP_SIZE) + 1, endpoint=True @@ -295,9 +295,7 @@ def fill_lane_boundaries( no_boundary_ratio = boundary_points_3d.count(None) / len(boundary_points_3d) final_boundary_points_3d = [] - def _get_default_boundary_point_3d( - lane_query_se2: StateSE2, lane_query_3d: Point3D, sign: float - ) -> Point3D: + def _get_default_boundary_point_3d(lane_query_se2: PoseSE2, lane_query_3d: Point3D, sign: float) -> Point3D: perp_boundary_distance = DEFAULT_LANE_WIDTH / 2.0 boundary_point_se2 = translate_se2_along_body_frame( lane_query_se2, Vector2D(0.0, sign * perp_boundary_distance) diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 85047ace..794f2f71 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -41,8 +41,8 @@ BoundingBoxSE3Index, EulerAngles, EulerAnglesIndex, - StateSE3, - StateSE3Index, + PoseSE3, + PoseSE3Index, Vector3D, Vector3DIndex, ) @@ -270,10 +270,10 @@ def _get_wopd_lidar_metadata( lidar_type = WOPD_LIDAR_TYPES[laser_calibration.name] - extrinsic: Optional[StateSE3] = None + extrinsic: Optional[PoseSE3] = None if laser_calibration.extrinsic: extrinsic_transform = np.array(laser_calibration.extrinsic.transform, dtype=np.float64).reshape(4, 4) - extrinsic = StateSE3.from_transformation_matrix(extrinsic_transform) + extrinsic = PoseSE3.from_transformation_matrix(extrinsic_transform) laser_metadatas[lidar_type] = LiDARMetadata( lidar_type=lidar_type, @@ -284,10 +284,10 @@ def _get_wopd_lidar_metadata( return laser_metadatas -def _get_ego_pose_se3(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) -> StateSE3: +def _get_ego_pose_se3(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) -> PoseSE3: ego_pose_matrix = np.array(frame.pose.transform, dtype=np.float64).reshape(4, 4) - ego_pose_se3 = StateSE3.from_transformation_matrix(ego_pose_matrix) - ego_pose_se3.array[StateSE3Index.XYZ] += map_pose_offset.array[Vector3DIndex.XYZ] + ego_pose_se3 = PoseSE3.from_transformation_matrix(ego_pose_matrix) + ego_pose_se3.array[PoseSE3Index.XYZ] += map_pose_offset.array[Vector3DIndex.XYZ] return ego_pose_se3 @@ -347,8 +347,8 @@ def _extract_wopd_box_detections( detections_types.append(WOPD_DETECTION_NAME_DICT[detection.type]) detections_token.append(str(detection.id)) - detections_state[:, BoundingBoxSE3Index.STATE_SE3] = convert_relative_to_absolute_se3_array( - origin=ego_pose_se3, se3_array=detections_state[:, BoundingBoxSE3Index.STATE_SE3] + detections_state[:, BoundingBoxSE3Index.POSE_SE3] = convert_relative_to_absolute_se3_array( + origin=ego_pose_se3, se3_array=detections_state[:, BoundingBoxSE3Index.POSE_SE3] ) if zero_roll_pitch: euler_array = get_euler_array_from_quaternion_array(detections_state[:, BoundingBoxSE3Index.QUATERNION]) @@ -384,11 +384,11 @@ def _extract_wopd_cameras( # NOTE @DanielDauner: The extrinsic matrix in frame.context.camera_calibration is fixed to model the ego to camera transformation. # The poses in frame.images[idx] are the motion compensated ego poses when the camera triggers. # TODO: Verify if this is correct. - camera_extrinsic: Dict[str, StateSE3] = {} + camera_extrinsic: Dict[str, PoseSE3] = {} for calibration in frame.context.camera_calibrations: camera_type = WOPD_CAMERA_TYPES[calibration.name] camera_transform = np.array(calibration.extrinsic.transform, dtype=np.float64).reshape(4, 4) - camera_pose = StateSE3.from_transformation_matrix(camera_transform) + camera_pose = PoseSE3.from_transformation_matrix(camera_transform) # NOTE: WOPD uses a different camera convention than py123d # https://arxiv.org/pdf/1912.04838 (Figure 1.) camera_pose = convert_camera_convention( diff --git a/src/py123d/conversion/log_writer/abstract_log_writer.py b/src/py123d/conversion/log_writer/abstract_log_writer.py index 238919d6..9ad8f357 100644 --- a/src/py123d/conversion/log_writer/abstract_log_writer.py +++ b/src/py123d/conversion/log_writer/abstract_log_writer.py @@ -17,7 +17,7 @@ from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.geometry import StateSE3 +from py123d.geometry import PoseSE3 class AbstractLogWriter(abc.ABC): @@ -87,7 +87,7 @@ def has_point_cloud(self) -> bool: class CameraData: camera_type: Union[PinholeCameraType, FisheyeMEICameraType] - extrinsic: StateSE3 + extrinsic: PoseSE3 timestamp: Optional[TimePoint] = None jpeg_binary: Optional[bytes] = None diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index 6aed9629..930b66dd 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -45,7 +45,7 @@ from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.dynamic_state import DynamicStateSE3Index from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.geometry import BoundingBoxSE3Index, StateSE3, StateSE3Index, Vector3DIndex +from py123d.geometry import BoundingBoxSE3Index, PoseSE3, PoseSE3Index, Vector3DIndex def _get_logs_root() -> Path: @@ -237,7 +237,7 @@ def write( # Theoretically, we could extend the store asynchronous cameras in the future by storing the # camera data as a dictionary, list or struct-like object in the columns. pinhole_camera_data: Optional[Any] = None - pinhole_camera_pose: Optional[StateSE3] = None + pinhole_camera_pose: Optional[PoseSE3] = None if pinhole_camera_type in provided_pinhole_data: pinhole_camera_data = provided_pinhole_data[pinhole_camera_type] pinhole_camera_pose = provided_pinhole_extrinsics[pinhole_camera_type] @@ -266,7 +266,7 @@ def write( # NOTE @DanielDauner: Missing cameras are allowed, e.g., for synchronization mismatches. # In this case, we write None/null to the arrow table. fisheye_mei_camera_data: Optional[Any] = None - fisheye_mei_camera_pose: Optional[StateSE3] = None + fisheye_mei_camera_pose: Optional[PoseSE3] = None if fisheye_mei_camera_type in provided_fisheye_mei_data: fisheye_mei_camera_data = provided_fisheye_mei_data[fisheye_mei_camera_type] fisheye_mei_camera_pose = provided_fisheye_mei_extrinsics[fisheye_mei_camera_type] @@ -354,7 +354,7 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata if dataset_converter_config.include_ego: schema_list.extend( [ - (EGO_REAR_AXLE_SE3_COLUMN, pa.list_(pa.float64(), len(StateSE3Index))), + (EGO_REAR_AXLE_SE3_COLUMN, pa.list_(pa.float64(), len(PoseSE3Index))), (EGO_DYNAMIC_STATE_SE3_COLUMN, pa.list_(pa.float64(), len(DynamicStateSE3Index))), ] ) @@ -413,7 +413,7 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata ), ( PINHOLE_CAMERA_EXTRINSIC_COLUMN(pinhole_camera_name), - pa.list_(pa.float64(), len(StateSE3Index)), + pa.list_(pa.float64(), len(PoseSE3Index)), ), ] ) @@ -432,7 +432,7 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata ), ( FISHEYE_CAMERA_EXTRINSIC_COLUMN(fisheye_mei_camera_name), - pa.list_(pa.float64(), len(StateSE3Index)), + pa.list_(pa.float64(), len(PoseSE3Index)), ), ] ) diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py index 3ddb24a8..a321e759 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py @@ -8,7 +8,7 @@ import numpy.typing as npt from scipy.special import fresnel -from py123d.geometry import StateSE2Index +from py123d.geometry import PoseSE2Index @dataclass @@ -25,10 +25,10 @@ class Geometry: @property def start_se2(self) -> npt.NDArray[np.float64]: - start_se2 = np.zeros(len(StateSE2Index), dtype=np.float64) - start_se2[StateSE2Index.X] = self.x - start_se2[StateSE2Index.Y] = self.y - start_se2[StateSE2Index.YAW] = self.hdg + start_se2 = np.zeros(len(PoseSE2Index), dtype=np.float64) + start_se2[PoseSE2Index.X] = self.x + start_se2[PoseSE2Index.Y] = self.y + start_se2[PoseSE2Index.YAW] = self.hdg return start_se2 def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: @@ -49,12 +49,12 @@ def parse(cls, geometry_element: Element) -> Geometry: def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: interpolated_se2 = self.start_se2.copy() - interpolated_se2[StateSE2Index.X] += s * np.cos(self.hdg) - interpolated_se2[StateSE2Index.Y] += s * np.sin(self.hdg) + interpolated_se2[PoseSE2Index.X] += s * np.cos(self.hdg) + interpolated_se2[PoseSE2Index.Y] += s * np.sin(self.hdg) if t != 0.0: - interpolated_se2[StateSE2Index.X] += t * np.cos(interpolated_se2[StateSE2Index.YAW] + np.pi / 2) - interpolated_se2[StateSE2Index.Y] += t * np.sin(interpolated_se2[StateSE2Index.YAW] + np.pi / 2) + interpolated_se2[PoseSE2Index.X] += t * np.cos(interpolated_se2[PoseSE2Index.YAW] + np.pi / 2) + interpolated_se2[PoseSE2Index.Y] += t * np.sin(interpolated_se2[PoseSE2Index.YAW] + np.pi / 2) return interpolated_se2 @@ -90,13 +90,13 @@ def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: dy = -radius * (np.cos(final_heading) - np.cos(initial_heading)) interpolated_se2 = self.start_se2.copy() - interpolated_se2[StateSE2Index.X] += dx - interpolated_se2[StateSE2Index.Y] += dy - interpolated_se2[StateSE2Index.YAW] = final_heading + interpolated_se2[PoseSE2Index.X] += dx + interpolated_se2[PoseSE2Index.Y] += dy + interpolated_se2[PoseSE2Index.YAW] = final_heading if t != 0.0: - interpolated_se2[StateSE2Index.X] += t * np.cos(interpolated_se2[StateSE2Index.YAW] + np.pi / 2) - interpolated_se2[StateSE2Index.Y] += t * np.sin(interpolated_se2[StateSE2Index.YAW] + np.pi / 2) + interpolated_se2[PoseSE2Index.X] += t * np.cos(interpolated_se2[PoseSE2Index.YAW] + np.pi / 2) + interpolated_se2[PoseSE2Index.Y] += t * np.sin(interpolated_se2[PoseSE2Index.YAW] + np.pi / 2) return interpolated_se2 @@ -131,13 +131,13 @@ def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: dx, dy = self._compute_spiral_position(s, gamma) - interpolated_se2[StateSE2Index.X] += dx - interpolated_se2[StateSE2Index.Y] += dy - interpolated_se2[StateSE2Index.YAW] += gamma * s**2 / 2 + self.curvature_start * s + interpolated_se2[PoseSE2Index.X] += dx + interpolated_se2[PoseSE2Index.Y] += dy + interpolated_se2[PoseSE2Index.YAW] += gamma * s**2 / 2 + self.curvature_start * s if t != 0.0: - interpolated_se2[StateSE2Index.X] += t * np.cos(interpolated_se2[StateSE2Index.YAW] + np.pi / 2) - interpolated_se2[StateSE2Index.Y] += t * np.sin(interpolated_se2[StateSE2Index.YAW] + np.pi / 2) + interpolated_se2[PoseSE2Index.X] += t * np.cos(interpolated_se2[PoseSE2Index.YAW] + np.pi / 2) + interpolated_se2[PoseSE2Index.Y] += t * np.sin(interpolated_se2[PoseSE2Index.YAW] + np.pi / 2) return interpolated_se2 diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py index 9ea3f0f0..ade334c5 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py @@ -13,7 +13,7 @@ from py123d.conversion.utils.map_utils.opendrive.parser.geometry import Arc, Geometry, Line, Spiral from py123d.conversion.utils.map_utils.opendrive.parser.lane import LaneOffset, Width from py123d.conversion.utils.map_utils.opendrive.parser.polynomial import Polynomial -from py123d.geometry import Point3DIndex, StateSE2Index +from py123d.geometry import Point3DIndex, PoseSE2Index TOLERANCE: Final[float] = 1e-3 @@ -152,7 +152,7 @@ def interpolate_3d(self, s: float, t: float = 0.0, lane_section_end: bool = Fals elevation_polynomial = self._find_polynomial(s, self.elevations, lane_section_end=lane_section_end) point_3d = np.zeros(len(Point3DIndex), dtype=np.float64) - point_3d[Point3DIndex.XY] = se2[StateSE2Index.XY] + point_3d[Point3DIndex.XY] = se2[PoseSE2Index.XY] point_3d[Point3DIndex.Z] = elevation_polynomial.get_value(s - elevation_polynomial.s) return point_3d diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py index 5b8045d2..f515b424 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py @@ -14,7 +14,7 @@ derive_lane_id, lane_group_id_from_lane_id, ) -from py123d.geometry import StateSE2Index +from py123d.geometry import PoseSE2Index from py123d.geometry.polyline import Polyline3D, PolylineSE2 from py123d.geometry.utils.units import kmph_to_mps, mph_to_mps @@ -156,8 +156,8 @@ def outline_polyline_3d(self) -> Polyline3D: @property def shapely_polygon(self) -> shapely.Polygon: - inner_polyline = self.inner_polyline_se2[..., StateSE2Index.XY] - outer_polyline = self.outer_polyline_se2[..., StateSE2Index.XY][::-1] + inner_polyline = self.inner_polyline_se2[..., PoseSE2Index.XY] + outer_polyline = self.outer_polyline_se2[..., PoseSE2Index.XY][::-1] polygon_exterior = np.concatenate( [ inner_polyline, @@ -239,8 +239,8 @@ def outline_polyline_3d(self) -> Polyline3D: @property def shapely_polygon(self) -> shapely.Polygon: - inner_polyline = self.inner_polyline_se2[..., StateSE2Index.XY] - outer_polyline = self.outer_polyline_se2[..., StateSE2Index.XY][::-1] + inner_polyline = self.inner_polyline_se2[..., PoseSE2Index.XY] + outer_polyline = self.outer_polyline_se2[..., PoseSE2Index.XY][::-1] polygon_exterior = np.concatenate( [ inner_polyline, diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py index 2e0117a4..bdf8ac99 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py @@ -7,8 +7,8 @@ from py123d.conversion.utils.map_utils.opendrive.parser.objects import Object from py123d.conversion.utils.map_utils.opendrive.parser.reference import ReferenceLine -from py123d.geometry import Point3D, Point3DIndex, StateSE2, Vector2D -from py123d.geometry.geometry_index import StateSE2Index +from py123d.geometry import Point3D, Point3DIndex, PoseSE2, Vector2D +from py123d.geometry.geometry_index import PoseSE2Index from py123d.geometry.polyline import Polyline3D from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame from py123d.geometry.utils.rotation_utils import normalize_angle @@ -39,12 +39,12 @@ def get_object_helper(object: Object, reference_line: ReferenceLine) -> OpenDriv # 1. Extract object position in frenet frame of the reference line - object_se2: StateSE2 = StateSE2.from_array(reference_line.interpolate_se2(s=object.s, t=object.t)) + object_se2: PoseSE2 = PoseSE2.from_array(reference_line.interpolate_se2(s=object.s, t=object.t)) object_3d: Point3D = Point3D.from_array(reference_line.interpolate_3d(s=object.s, t=object.t)) # Adjust yaw angle from object data # TODO: Consider adding setters to StateSE2 to make this cleaner - object_se2._array[StateSE2Index.YAW] = normalize_angle(object_se2.yaw + object.hdg) + object_se2._array[PoseSE2Index.YAW] = normalize_angle(object_se2.yaw + object.hdg) if len(object.outline) == 0: outline_3d = np.zeros((4, len(Point3DIndex)), dtype=np.float64) diff --git a/src/py123d/conversion/utils/sensor_utils/camera_conventions.py b/src/py123d/conversion/utils/sensor_utils/camera_conventions.py index 75837932..6de9860c 100644 --- a/src/py123d/conversion/utils/sensor_utils/camera_conventions.py +++ b/src/py123d/conversion/utils/sensor_utils/camera_conventions.py @@ -28,7 +28,7 @@ import numpy as np -from py123d.geometry import StateSE3 +from py123d.geometry import PoseSE3 class CameraConvention(Enum): @@ -47,17 +47,17 @@ class CameraConvention(Enum): def convert_camera_convention( - from_pose: StateSE3, + from_pose: PoseSE3, from_convention: Union[CameraConvention, str], to_convention: Union[CameraConvention, str], -) -> StateSE3: +) -> PoseSE3: """Convert camera pose between different conventions. 123D default is pZmYpX (+Z forward, -Y up, +X right). - :param from_pose: StateSE3 representing the camera pose to convert + :param from_pose: PoseSE3 representing the camera pose to convert :param from_convention: CameraConvention representing the current convention of the pose :param to_convention: CameraConvention representing the target convention to convert to - :return: StateSE3 representing the converted camera pose + :return: PoseSE3 representing the converted camera pose """ # TODO: Write tests for this function # TODO: Create function over batch/array of poses @@ -92,4 +92,4 @@ def convert_camera_convention( pose_transformation = from_pose.transformation_matrix.copy() F = flip_matrices[(from_convention, to_convention)] pose_transformation[:3, :3] = pose_transformation[:3, :3] @ F - return StateSE3.from_transformation_matrix(pose_transformation) + return PoseSE3.from_transformation_matrix(pose_transformation) diff --git a/src/py123d/datatypes/detections/box_detections.py b/src/py123d/datatypes/detections/box_detections.py index 4ba2e285..cc14f03d 100644 --- a/src/py123d/datatypes/detections/box_detections.py +++ b/src/py123d/datatypes/detections/box_detections.py @@ -6,7 +6,7 @@ from py123d.conversion.registry.box_detection_label_registry import BoxDetectionLabel, DefaultBoxDetectionLabel from py123d.datatypes.time.time_point import TimePoint -from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, OccupancyMap2D, StateSE2, StateSE3, Vector2D, Vector3D +from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, OccupancyMap2D, PoseSE2, PoseSE3, Vector2D, Vector3D @dataclass @@ -72,7 +72,7 @@ def shapely_polygon(self) -> shapely.geometry.Polygon: return self.bounding_box_se2.shapely_polygon @property - def center(self) -> StateSE2: + def center(self) -> PoseSE2: return self.bounding_box_se2.center @property @@ -92,11 +92,11 @@ def shapely_polygon(self) -> shapely.geometry.Polygon: return self.bounding_box_se3.shapely_polygon @property - def center(self) -> StateSE3: + def center(self) -> PoseSE3: return self.bounding_box_se3.center @property - def center_se3(self) -> StateSE3: + def center_se3(self) -> PoseSE3: return self.bounding_box_se3.center_se3 @property diff --git a/src/py123d/datatypes/map/abstract_map_objects.py b/src/py123d/datatypes/map/abstract_map_objects.py index d5c7feff..beb23a84 100644 --- a/src/py123d/datatypes/map/abstract_map_objects.py +++ b/src/py123d/datatypes/map/abstract_map_objects.py @@ -22,7 +22,7 @@ def __init__(self, object_id: MapObjectIDType): :param object_id: unique identifier of the map object. """ - self.object_id: MapObjectIDType = object_id + self._object_id: MapObjectIDType = object_id @property def object_id(self) -> MapObjectIDType: @@ -30,7 +30,7 @@ def object_id(self) -> MapObjectIDType: :return: map object id """ - return self.object_id + return self._object_id @property @abc.abstractmethod diff --git a/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py b/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py index e1f1737e..e52e65c9 100644 --- a/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py +++ b/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py @@ -53,7 +53,7 @@ from py123d.datatypes.vehicle_state.dynamic_state import DynamicStateSE3 from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters -from py123d.geometry import BoundingBoxSE3, StateSE3, Vector3D +from py123d.geometry import BoundingBoxSE3, PoseSE3, Vector3D from py123d.script.utils.dataset_path_utils import get_dataset_paths DATASET_PATHS: DictConfig = get_dataset_paths() @@ -81,7 +81,7 @@ def get_ego_state_se3_from_arrow_table( ego_state_se3: Optional[EgoStateSE3] = None if _all_columns_in_schema(arrow_table, EGO_STATE_SE3_COLUMNS): timepoint = get_timepoint_from_arrow_table(arrow_table, index) - rear_axle_se3 = StateSE3.from_list(arrow_table[EGO_REAR_AXLE_SE3_COLUMN][index].as_py()) + rear_axle_se3 = PoseSE3.from_list(arrow_table[EGO_REAR_AXLE_SE3_COLUMN][index].as_py()) ego_state_se3 = EgoStateSE3.from_rear_axle( rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters, @@ -172,41 +172,45 @@ def get_camera_from_arrow_table( if _all_columns_in_schema(arrow_table, [camera_data_column, camera_extrinsic_column]): table_data = arrow_table[camera_data_column][index].as_py() - extrinsic = StateSE3.from_list(arrow_table[camera_extrinsic_column][index].as_py()) - image: Optional[npt.NDArray[np.uint8]] = None - - if isinstance(table_data, str): - sensor_root = DATASET_SENSOR_ROOT[log_metadata.dataset] - assert ( - sensor_root is not None - ), f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}" - full_image_path = Path(sensor_root) / table_data - assert full_image_path.exists(), f"Camera file not found: {full_image_path}" - - image = load_image_from_jpeg_file(full_image_path) - elif isinstance(table_data, bytes): - image = decode_image_from_jpeg_binary(table_data) - elif isinstance(table_data, int): - image = _unoptimized_demo_mp4_read(log_metadata, camera_name, table_data) - else: - raise NotImplementedError( - f"Only string file paths, bytes, or int frame indices are supported for camera data, got {type(table_data)}" - ) - - if is_pinhole: - camera_metadata = log_metadata.pinhole_camera_metadata[camera_type] - camera = PinholeCamera( - metadata=camera_metadata, - image=image, - extrinsic=extrinsic, - ) - else: - camera_metadata = log_metadata.fisheye_mei_camera_metadata[camera_type] - camera = FisheyeMEICamera( - metadata=camera_metadata, - image=image, - extrinsic=extrinsic, - ) + extrinsic_data = arrow_table[camera_extrinsic_column][index].as_py() + + if table_data is not None and extrinsic_data is not None: + extrinsic = PoseSE3.from_list(extrinsic_data) + image: Optional[npt.NDArray[np.uint8]] = None + + if isinstance(table_data, str): + sensor_root = DATASET_SENSOR_ROOT[log_metadata.dataset] + assert ( + sensor_root is not None + ), f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}" + full_image_path = Path(sensor_root) / table_data + assert full_image_path.exists(), f"Camera file not found: {full_image_path}" + + image = load_image_from_jpeg_file(full_image_path) + elif isinstance(table_data, bytes): + image = decode_image_from_jpeg_binary(table_data) + elif isinstance(table_data, int): + image = _unoptimized_demo_mp4_read(log_metadata, camera_name, table_data) + else: + raise NotImplementedError( + f"Only string file paths, bytes, or int frame indices are supported for camera data, got {type(table_data)}" + ) + # extrinsic = PoseSE3.from_list(arrow_table[camera_extrinsic_column][index].as_py()) + + if is_pinhole: + camera_metadata = log_metadata.pinhole_camera_metadata[camera_type] + camera = PinholeCamera( + metadata=camera_metadata, + image=image, + extrinsic=extrinsic, + ) + else: + camera_metadata = log_metadata.fisheye_mei_camera_metadata[camera_type] + camera = FisheyeMEICamera( + metadata=camera_metadata, + image=image, + extrinsic=extrinsic, + ) return camera diff --git a/src/py123d/datatypes/sensors/fisheye_mei_camera.py b/src/py123d/datatypes/sensors/fisheye_mei_camera.py index 4a98afad..c6c0a1cf 100644 --- a/src/py123d/datatypes/sensors/fisheye_mei_camera.py +++ b/src/py123d/datatypes/sensors/fisheye_mei_camera.py @@ -9,7 +9,7 @@ from py123d.common.utils.enums import SerialIntEnum from py123d.common.utils.mixin import ArrayMixin -from py123d.geometry.se import StateSE3 +from py123d.geometry.pose import PoseSE3 class FisheyeMEICameraType(SerialIntEnum): @@ -26,7 +26,7 @@ class FisheyeMEICamera: metadata: FisheyeMEICameraMetadata image: npt.NDArray[np.uint8] - extrinsic: StateSE3 + extrinsic: PoseSE3 class FisheyeMEIDistortionIndex(IntEnum): diff --git a/src/py123d/datatypes/sensors/lidar.py b/src/py123d/datatypes/sensors/lidar.py index 0950c77c..e8999f43 100644 --- a/src/py123d/datatypes/sensors/lidar.py +++ b/src/py123d/datatypes/sensors/lidar.py @@ -8,7 +8,7 @@ from py123d.common.utils.enums import SerialIntEnum from py123d.conversion.registry.lidar_index_registry import LIDAR_INDEX_REGISTRY, LiDARIndex -from py123d.geometry import StateSE3 +from py123d.geometry import PoseSE3 class LiDARType(SerialIntEnum): @@ -28,7 +28,7 @@ class LiDARMetadata: lidar_type: LiDARType lidar_index: Type[LiDARIndex] - extrinsic: Optional[StateSE3] = None + extrinsic: Optional[PoseSE3] = None # TODO: add identifier if point cloud is returned in lidar or ego frame. def to_dict(self) -> dict: @@ -44,7 +44,7 @@ def from_dict(cls, data_dict: dict) -> LiDARMetadata: if data_dict["lidar_index"] not in LIDAR_INDEX_REGISTRY: raise ValueError(f"Unknown lidar index: {data_dict['lidar_index']}") lidar_index_class = LIDAR_INDEX_REGISTRY[data_dict["lidar_index"]] - extrinsic = StateSE3.from_list(data_dict["extrinsic"]) if data_dict["extrinsic"] is not None else None + extrinsic = PoseSE3.from_list(data_dict["extrinsic"]) if data_dict["extrinsic"] is not None else None return cls(lidar_type=lidar_type, lidar_index=lidar_index_class, extrinsic=extrinsic) diff --git a/src/py123d/datatypes/sensors/pinhole_camera.py b/src/py123d/datatypes/sensors/pinhole_camera.py index beefa883..c80a91bd 100644 --- a/src/py123d/datatypes/sensors/pinhole_camera.py +++ b/src/py123d/datatypes/sensors/pinhole_camera.py @@ -9,7 +9,7 @@ from py123d.common.utils.enums import SerialIntEnum from py123d.common.utils.mixin import ArrayMixin -from py123d.geometry.se import StateSE3 +from py123d.geometry.pose import PoseSE3 class PinholeCameraType(SerialIntEnum): @@ -31,7 +31,7 @@ class PinholeCamera: metadata: PinholeCameraMetadata image: npt.NDArray[np.uint8] - extrinsic: StateSE3 + extrinsic: PoseSE3 class PinholeIntrinsicsIndex(IntEnum): diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index 9ff0900a..6d5e6ec2 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -14,7 +14,7 @@ rear_axle_se2_to_center_se2, rear_axle_se3_to_center_se3, ) -from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, StateSE2, StateSE3 +from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, PoseSE2, PoseSE3 EGO_TRACK_TOKEN: Final[str] = "ego_vehicle" @@ -23,7 +23,7 @@ class EgoStateSE3: def __init__( self, - rear_axle_se3: StateSE3, + rear_axle_se3: PoseSE3, vehicle_parameters: VehicleParameters, dynamic_state_se3: Optional[DynamicStateSE3] = None, timepoint: Optional[TimePoint] = None, @@ -38,7 +38,7 @@ def __init__( @classmethod def from_center( cls, - center_se3: StateSE3, + center_se3: PoseSE3, vehicle_parameters: VehicleParameters, dynamic_state_se3: Optional[DynamicStateSE3] = None, timepoint: Optional[TimePoint] = None, @@ -62,7 +62,7 @@ def from_center( @classmethod def from_rear_axle( cls, - rear_axle_se3: StateSE3, + rear_axle_se3: PoseSE3, vehicle_parameters: VehicleParameters, dynamic_state_se3: Optional[DynamicStateSE3] = None, timepoint: Optional[TimePoint] = None, @@ -78,7 +78,7 @@ def from_rear_axle( ) @property - def rear_axle_se3(self) -> StateSE3: + def rear_axle_se3(self) -> PoseSE3: return self._rear_axle_se3 @property @@ -98,26 +98,26 @@ def tire_steering_angle(self) -> Optional[float]: return self._tire_steering_angle @property - def rear_axle_se2(self) -> StateSE2: - return self._rear_axle_se3.state_se2 + def rear_axle_se2(self) -> PoseSE2: + return self._rear_axle_se3.pose_se2 @property - def rear_axle(self) -> StateSE3: + def rear_axle(self) -> PoseSE3: return self._rear_axle_se3 @property - def center_se3(self) -> StateSE3: + def center_se3(self) -> PoseSE3: return rear_axle_se3_to_center_se3( rear_axle_se3=self._rear_axle_se3, vehicle_parameters=self._vehicle_parameters, ) @property - def center_se2(self) -> StateSE2: - return self.center_se3.state_se2 + def center_se2(self) -> PoseSE2: + return self.center_se3.pose_se2 @property - def center(self) -> StateSE3: + def center(self) -> PoseSE3: return self.center_se3 @property @@ -174,13 +174,13 @@ class EgoStateSE2: def __init__( self, - rear_axle_se2: StateSE2, + rear_axle_se2: PoseSE2, vehicle_parameters: VehicleParameters, dynamic_state_se2: Optional[DynamicStateSE2] = None, timepoint: Optional[TimePoint] = None, tire_steering_angle: Optional[float] = 0.0, ): - self._rear_axle_se2: StateSE2 = rear_axle_se2 + self._rear_axle_se2: PoseSE2 = rear_axle_se2 self._vehicle_parameters: VehicleParameters = vehicle_parameters self._dynamic_state_se2: Optional[DynamicStateSE2] = dynamic_state_se2 self._timepoint: Optional[TimePoint] = timepoint @@ -189,7 +189,7 @@ def __init__( @classmethod def from_rear_axle( cls, - rear_axle_se2: StateSE2, + rear_axle_se2: PoseSE2, dynamic_state_se2: DynamicStateSE2, vehicle_parameters: VehicleParameters, timepoint: TimePoint, @@ -207,7 +207,7 @@ def from_rear_axle( @classmethod def from_center( cls, - center_se2: StateSE2, + center_se2: PoseSE2, dynamic_state_se2: DynamicStateSE2, vehicle_parameters: VehicleParameters, timepoint: TimePoint, @@ -229,7 +229,7 @@ def from_center( ) @property - def rear_axle_se2(self) -> StateSE2: + def rear_axle_se2(self) -> PoseSE2: return self._rear_axle_se2 @property @@ -249,15 +249,15 @@ def tire_steering_angle(self) -> Optional[float]: return self._tire_steering_angle @property - def rear_axle(self) -> StateSE2: + def rear_axle(self) -> PoseSE2: return self.rear_axle_se2 @property - def center_se2(self) -> StateSE2: + def center_se2(self) -> PoseSE2: return rear_axle_se2_to_center_se2(rear_axle_se2=self.rear_axle_se2, vehicle_parameters=self.vehicle_parameters) @property - def center(self) -> StateSE3: + def center(self) -> PoseSE3: return self.center_se2 @property diff --git a/src/py123d/datatypes/vehicle_state/vehicle_parameters.py b/src/py123d/datatypes/vehicle_state/vehicle_parameters.py index ca2a1944..42bc299a 100644 --- a/src/py123d/datatypes/vehicle_state/vehicle_parameters.py +++ b/src/py123d/datatypes/vehicle_state/vehicle_parameters.py @@ -2,7 +2,7 @@ from dataclasses import asdict, dataclass -from py123d.geometry import StateSE2, StateSE3, Vector2D, Vector3D +from py123d.geometry import PoseSE2, PoseSE3, Vector2D, Vector3D from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame @@ -137,7 +137,7 @@ def get_pandaset_chrysler_pacifica_parameters() -> VehicleParameters: ) -def center_se3_to_rear_axle_se3(center_se3: StateSE3, vehicle_parameters: VehicleParameters) -> StateSE3: +def center_se3_to_rear_axle_se3(center_se3: PoseSE3, vehicle_parameters: VehicleParameters) -> PoseSE3: """ Converts a center state to a rear axle state. :param center_se3: The center state. @@ -154,7 +154,7 @@ def center_se3_to_rear_axle_se3(center_se3: StateSE3, vehicle_parameters: Vehicl ) -def rear_axle_se3_to_center_se3(rear_axle_se3: StateSE3, vehicle_parameters: VehicleParameters) -> StateSE3: +def rear_axle_se3_to_center_se3(rear_axle_se3: PoseSE3, vehicle_parameters: VehicleParameters) -> PoseSE3: """ Converts a rear axle state to a center state. :param rear_axle_se3: The rear axle state. @@ -171,7 +171,7 @@ def rear_axle_se3_to_center_se3(rear_axle_se3: StateSE3, vehicle_parameters: Veh ) -def center_se2_to_rear_axle_se2(center_se2: StateSE2, vehicle_parameters: VehicleParameters) -> StateSE2: +def center_se2_to_rear_axle_se2(center_se2: PoseSE2, vehicle_parameters: VehicleParameters) -> PoseSE2: """ Converts a center state to a rear axle state in 2D. :param center_se2: The center state in 2D. @@ -181,7 +181,7 @@ def center_se2_to_rear_axle_se2(center_se2: StateSE2, vehicle_parameters: Vehicl return translate_se2_along_body_frame(center_se2, Vector2D(-vehicle_parameters.rear_axle_to_center_longitudinal, 0)) -def rear_axle_se2_to_center_se2(rear_axle_se2: StateSE2, vehicle_parameters: VehicleParameters) -> StateSE2: +def rear_axle_se2_to_center_se2(rear_axle_se2: PoseSE2, vehicle_parameters: VehicleParameters) -> PoseSE2: """ Converts a rear axle state to a center state in 2D. :param rear_axle_se2: The rear axle state in 2D. diff --git a/src/py123d/geometry/__init__.py b/src/py123d/geometry/__init__.py index c391b29a..0d3dafb7 100644 --- a/src/py123d/geometry/__init__.py +++ b/src/py123d/geometry/__init__.py @@ -8,15 +8,15 @@ EulerAnglesIndex, EulerStateSE3Index, QuaternionIndex, - StateSE2Index, - StateSE3Index, + PoseSE2Index, + PoseSE3Index, Vector2DIndex, Vector3DIndex, ) from py123d.geometry.point import Point2D, Point3D from py123d.geometry.vector import Vector2D, Vector3D from py123d.geometry.rotation import EulerAngles, Quaternion -from py123d.geometry.se import EulerStateSE3, StateSE2, StateSE3 +from py123d.geometry.pose import EulerStateSE3, PoseSE2, PoseSE3 from py123d.geometry.bounding_box import BoundingBoxSE2, BoundingBoxSE3 from py123d.geometry.polyline import Polyline2D, Polyline3D, PolylineSE2 from py123d.geometry.occupancy_map import OccupancyMap2D diff --git a/src/py123d/geometry/bounding_box.py b/src/py123d/geometry/bounding_box.py index bc3e1b73..4efbbd7b 100644 --- a/src/py123d/geometry/bounding_box.py +++ b/src/py123d/geometry/bounding_box.py @@ -10,7 +10,7 @@ from py123d.common.utils.mixin import ArrayMixin from py123d.geometry.geometry_index import BoundingBoxSE2Index, BoundingBoxSE3Index, Corners2DIndex, Corners3DIndex from py123d.geometry.point import Point2D, Point3D -from py123d.geometry.se import StateSE2, StateSE3 +from py123d.geometry.pose import PoseSE2, PoseSE3 from py123d.geometry.utils.bounding_box_utils import bbse2_array_to_corners_array, bbse3_array_to_corners_array @@ -31,7 +31,7 @@ class BoundingBoxSE2(ArrayMixin): _array: npt.NDArray[np.float64] - def __init__(self, center: StateSE2, length: float, width: float): + def __init__(self, center: PoseSE2, length: float, width: float): """Initialize BoundingBoxSE2 with center (StateSE2), length and width. :param center: Center of the bounding box as a StateSE2 instance. @@ -60,15 +60,15 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Boundi return instance @property - def center(self) -> StateSE2: + def center(self) -> PoseSE2: """The center of the bounding box as a StateSE2 instance. :return: The center of the bounding box as a StateSE2 instance. """ - return StateSE2.from_array(self._array[BoundingBoxSE2Index.SE2]) + return PoseSE2.from_array(self._array[BoundingBoxSE2Index.SE2]) @property - def center_se2(self) -> StateSE2: + def center_se2(self) -> PoseSE2: """The center of the bounding box as a StateSE2 instance. :return: The center of the bounding box as a StateSE2 instance. @@ -136,11 +136,11 @@ def corners_dict(self) -> Dict[Corners2DIndex, Point2D]: class BoundingBoxSE3(ArrayMixin): """ - Rotated bounding box in 3D defined by center with quaternion rotation (StateSE3), length, width and height. + Rotated bounding box in 3D defined by center with quaternion rotation (PoseSE3), length, width and height. Example: - >>> from py123d.geometry import StateSE3 - >>> bbox = BoundingBoxSE3(center=StateSE3(1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0), length=4.0, width=2.0, height=1.5) + >>> from py123d.geometry import PoseSE3 + >>> bbox = BoundingBoxSE3(center=PoseSE3(1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0), length=4.0, width=2.0, height=1.5) >>> bbox.array array([1. , 2. , 3. , 1. , 0. , 0. , 0. , 4. , 2. , 1.5]) >>> bbox.bounding_box_se2.array @@ -151,16 +151,16 @@ class BoundingBoxSE3(ArrayMixin): _array: npt.NDArray[np.float64] - def __init__(self, center: StateSE3, length: float, width: float, height: float): - """Initialize BoundingBoxSE3 with center (StateSE3), length, width and height. + def __init__(self, center: PoseSE3, length: float, width: float, height: float): + """Initialize BoundingBoxSE3 with center (PoseSE3), length, width and height. - :param center: Center of the bounding box as a StateSE3 instance. + :param center: Center of the bounding box as a PoseSE3 instance. :param length: Length of the bounding box along the x-axis in the local frame. :param width: Width of the bounding box along the y-axis in the local frame. :param height: Height of the bounding box along the z-axis in the local frame. """ array = np.zeros(len(BoundingBoxSE3Index), dtype=np.float64) - array[BoundingBoxSE3Index.STATE_SE3] = center.array + array[BoundingBoxSE3Index.POSE_SE3] = center.array array[BoundingBoxSE3Index.LENGTH] = length array[BoundingBoxSE3Index.WIDTH] = width array[BoundingBoxSE3Index.HEIGHT] = height @@ -182,28 +182,28 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Boundi return instance @property - def center(self) -> StateSE3: - """The center of the bounding box as a StateSE3 instance. + def center(self) -> PoseSE3: + """The center of the bounding box as a PoseSE3 instance. - :return: The center of the bounding box as a StateSE3 instance. + :return: The center of the bounding box as a PoseSE3 instance. """ - return StateSE3.from_array(self._array[BoundingBoxSE3Index.STATE_SE3]) + return PoseSE3.from_array(self._array[BoundingBoxSE3Index.POSE_SE3]) @property - def center_se3(self) -> StateSE3: - """The center of the bounding box as a StateSE3 instance. + def center_se3(self) -> PoseSE3: + """The center of the bounding box as a PoseSE3 instance. - :return: The center of the bounding box as a StateSE3 instance. + :return: The center of the bounding box as a PoseSE3 instance. """ return self.center @property - def center_se2(self) -> StateSE2: + def center_se2(self) -> PoseSE2: """The center of the bounding box as a StateSE2 instance. :return: The center of the bounding box as a StateSE2 instance. """ - return self.center_se3.state_se2 + return self.center_se3.pose_se2 @property def length(self) -> float: diff --git a/src/py123d/geometry/geometry_index.py b/src/py123d/geometry/geometry_index.py index 5d596f77..b81032b1 100644 --- a/src/py123d/geometry/geometry_index.py +++ b/src/py123d/geometry/geometry_index.py @@ -29,7 +29,7 @@ def XY(cls) -> slice: return slice(cls.X, cls.Y + 1) -class StateSE2Index(IntEnum): +class PoseSE2Index(IntEnum): """ Indexes array-like representations of SE2 states (x,y,yaw). """ @@ -130,7 +130,7 @@ def EULER_ANGLES(cls) -> slice: return slice(cls.ROLL, cls.YAW + 1) -class StateSE3Index(IntEnum): +class PoseSE3Index(IntEnum): """ Indexes array-like representations of SE3 states with quaternions (x,y,z,qw,qx,qy,qz). """ @@ -223,7 +223,7 @@ def XYZ(cls) -> slice: return slice(cls.X, cls.Z + 1) @classproperty - def STATE_SE3(cls) -> slice: + def POSE_SE3(cls) -> slice: return slice(cls.X, cls.QZ + 1) @classproperty diff --git a/src/py123d/geometry/polyline.py b/src/py123d/geometry/polyline.py index 3f3b7f65..6474c7a4 100644 --- a/src/py123d/geometry/polyline.py +++ b/src/py123d/geometry/polyline.py @@ -10,9 +10,9 @@ from scipy.interpolate import interp1d from py123d.common.utils.mixin import ArrayMixin -from py123d.geometry.geometry_index import Point2DIndex, Point3DIndex, StateSE2Index +from py123d.geometry.geometry_index import Point2DIndex, Point3DIndex, PoseSE2Index from py123d.geometry.point import Point2D, Point3D -from py123d.geometry.se import StateSE2 +from py123d.geometry.pose import PoseSE2 from py123d.geometry.utils.constants import DEFAULT_Z from py123d.geometry.utils.polyline_utils import get_linestring_yaws, get_path_progress from py123d.geometry.utils.rotation_utils import normalize_angle @@ -116,7 +116,7 @@ def interpolate( def project( self, - point: Union[geom.Point, Point2D, StateSE2, npt.NDArray[np.float64]], + point: Union[geom.Point, Point2D, PoseSE2, npt.NDArray[np.float64]], normalized: bool = False, ) -> npt.NDArray[np.float64]: """Projects a point onto the polyline and returns the distance along the polyline to the closest point. @@ -125,7 +125,7 @@ def project( :param normalized: Whether to return the normalized distance, defaults to False. :return: The distance along the polyline to the closest point. """ - if isinstance(point, Point2D) or isinstance(point, StateSE2): + if isinstance(point, Point2D) or isinstance(point, PoseSE2): point_ = point.shapely_point elif isinstance(point, geom.Point): point_ = point @@ -148,9 +148,9 @@ def __post_init__(self): assert self._array is not None if self.linestring is None: - self.linestring = geom_creation.linestrings(self._array[..., StateSE2Index.XY]) + self.linestring = geom_creation.linestrings(self._array[..., PoseSE2Index.XY]) - self._array[:, StateSE2Index.YAW] = np.unwrap(self._array[:, StateSE2Index.YAW], axis=0) + self._array[:, PoseSE2Index.YAW] = np.unwrap(self._array[:, PoseSE2Index.YAW], axis=0) self._progress = get_path_progress(self._array) self._interpolator = interp1d(self._progress, self._array, axis=0, bounds_error=False, fill_value=0.0) @@ -161,10 +161,10 @@ def from_linestring(cls, linestring: geom.LineString) -> PolylineSE2: :param linestring: The LineString to convert. :return: A PolylineSE2 representing the same path as the LineString. """ - points_2d = np.array(linestring.coords, dtype=np.float64)[..., StateSE2Index.XY] - se2_array = np.zeros((len(points_2d), len(StateSE2Index)), dtype=np.float64) - se2_array[:, StateSE2Index.XY] = points_2d - se2_array[:, StateSE2Index.YAW] = get_linestring_yaws(linestring) + points_2d = np.array(linestring.coords, dtype=np.float64)[..., PoseSE2Index.XY] + se2_array = np.zeros((len(points_2d), len(PoseSE2Index)), dtype=np.float64) + se2_array[:, PoseSE2Index.XY] = points_2d + se2_array[:, PoseSE2Index.YAW] = get_linestring_yaws(linestring) return PolylineSE2(se2_array, linestring) @classmethod @@ -172,23 +172,23 @@ def from_array(cls, polyline_array: npt.NDArray[np.float32]) -> PolylineSE2: """Creates a PolylineSE2 from a numpy array. :param polyline_array: The input numpy array representing, either indexed by \ - :class:`~py123d.geometry.Point2DIndex` or :class:`~py123d.geometry.StateSE2Index`. + :class:`~py123d.geometry.Point2DIndex` or :class:`~py123d.geometry.PoseSE2Index`. :raises ValueError: If the input array is not of the expected shape. :return: A PolylineSE2 representing the same path as the input array. """ assert polyline_array.ndim == 2 if polyline_array.shape[-1] == len(Point2DIndex): - se2_array = np.zeros((len(polyline_array), len(StateSE2Index)), dtype=np.float64) - se2_array[:, StateSE2Index.XY] = polyline_array - se2_array[:, StateSE2Index.YAW] = get_linestring_yaws(geom_creation.linestrings(*polyline_array.T)) - elif polyline_array.shape[-1] == len(StateSE2Index): + se2_array = np.zeros((len(polyline_array), len(PoseSE2Index)), dtype=np.float64) + se2_array[:, PoseSE2Index.XY] = polyline_array + se2_array[:, PoseSE2Index.YAW] = get_linestring_yaws(geom_creation.linestrings(*polyline_array.T)) + elif polyline_array.shape[-1] == len(PoseSE2Index): se2_array = np.array(polyline_array, dtype=np.float64) else: raise ValueError("Invalid polyline array shape.") return PolylineSE2(se2_array) @classmethod - def from_discrete_se2(cls, discrete_se2: List[StateSE2]) -> PolylineSE2: + def from_discrete_se2(cls, discrete_se2: List[PoseSE2]) -> PolylineSE2: """Creates a PolylineSE2 from a list of discrete SE2 states. :param discrete_se2: The list of discrete SE2 states. @@ -198,7 +198,7 @@ def from_discrete_se2(cls, discrete_se2: List[StateSE2]) -> PolylineSE2: @property def array(self) -> npt.NDArray[np.float64]: - """Converts the polyline to a numpy array, indexed by :class:`~py123d.geometry.StateSE2Index`. + """Converts the polyline to a numpy array, indexed by :class:`~py123d.geometry.PoseSE2Index`. :return: A numpy array of shape (N, 3) representing the polyline. """ @@ -216,7 +216,7 @@ def interpolate( self, distances: Union[float, npt.NDArray[np.float64]], normalized: bool = False, - ) -> Union[StateSE2, npt.NDArray[np.float64]]: + ) -> Union[PoseSE2, npt.NDArray[np.float64]]: """Interpolates the polyline at the given distances. :param distances: The distances along the polyline to interpolate. @@ -228,16 +228,16 @@ def interpolate( clipped_distances = np.clip(distances_, 1e-8, self.length) interpolated_se2_array = self._interpolator(clipped_distances) - interpolated_se2_array[..., StateSE2Index.YAW] = normalize_angle(interpolated_se2_array[..., StateSE2Index.YAW]) + interpolated_se2_array[..., PoseSE2Index.YAW] = normalize_angle(interpolated_se2_array[..., PoseSE2Index.YAW]) if clipped_distances.ndim == 0: - return StateSE2(*interpolated_se2_array) + return PoseSE2(*interpolated_se2_array) else: return interpolated_se2_array def project( self, - point: Union[geom.Point, Point2D, StateSE2, npt.NDArray[np.float64]], + point: Union[geom.Point, Point2D, PoseSE2, npt.NDArray[np.float64]], normalized: bool = False, ) -> npt.NDArray[np.float64]: """Projects a point onto the polyline and returns the distance along the polyline to the closest point. @@ -246,7 +246,7 @@ def project( :param normalized: Whether to return the normalized distance, defaults to False. :return: The distance along the polyline to the closest point. """ - if isinstance(point, Point2D) or isinstance(point, StateSE2): + if isinstance(point, Point2D) or isinstance(point, PoseSE2): point_ = point.shapely_point elif isinstance(point, geom.Point): point_ = point @@ -350,7 +350,7 @@ def project( :param normalized: Whether to return normalized distances, defaults to False. :return: The distance along the polyline to the closest point. """ - if isinstance(point, Point2D) or isinstance(point, StateSE2) or isinstance(point, Point3D): + if isinstance(point, Point2D) or isinstance(point, PoseSE2) or isinstance(point, Point3D): point_ = point.shapely_point elif isinstance(point, geom.Point): point_ = point @@ -361,7 +361,7 @@ def project( @dataclass class PolylineSE3: - # TODO: Implement PolylineSE3 once quaternions are used in StateSE3 + # TODO: Implement PolylineSE3 once quaternions are used in PoseSE3 # Interpolating along SE3 states (i.e., 3D position + orientation) is meaningful, # but more complex than SE2 due to 3D rotations (quaternions or rotation matrices). # Linear interpolation of positions is straightforward, but orientation interpolation diff --git a/src/py123d/geometry/se.py b/src/py123d/geometry/pose.py similarity index 78% rename from src/py123d/geometry/se.py rename to src/py123d/geometry/pose.py index 4c82e43e..6cb1a734 100644 --- a/src/py123d/geometry/se.py +++ b/src/py123d/geometry/pose.py @@ -7,62 +7,62 @@ import shapely.geometry as geom from py123d.common.utils.mixin import ArrayMixin -from py123d.geometry.geometry_index import EulerStateSE3Index, Point3DIndex, StateSE2Index, StateSE3Index +from py123d.geometry.geometry_index import EulerStateSE3Index, Point3DIndex, PoseSE2Index, PoseSE3Index from py123d.geometry.point import Point2D, Point3D from py123d.geometry.rotation import EulerAngles, Quaternion -class StateSE2(ArrayMixin): +class PoseSE2(ArrayMixin): """Class to represents a 2D pose as SE2 (x, y, yaw).""" _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, yaw: float): """Initialize StateSE2 with x, y, yaw coordinates.""" - array = np.zeros(len(StateSE2Index), dtype=np.float64) - array[StateSE2Index.X] = x - array[StateSE2Index.Y] = y - array[StateSE2Index.YAW] = yaw + array = np.zeros(len(PoseSE2Index), dtype=np.float64) + array[PoseSE2Index.X] = x + array[PoseSE2Index.Y] = y + array[PoseSE2Index.YAW] = yaw object.__setattr__(self, "_array", array) @classmethod - def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> StateSE2: + def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PoseSE2: """Constructs a StateSE2 from a numpy array. :param array: Array of shape (3,) representing the state [x, y, yaw], indexed by \ - :class:`~py123d.geometry.geometry_index.StateSE2Index`. + :class:`~py123d.geometry.geometry_index.PoseSE2Index`. :param copy: Whether to copy the input array. Defaults to True. :return: A StateSE2 instance. """ assert array.ndim == 1 - assert array.shape[0] == len(StateSE2Index) + assert array.shape[0] == len(PoseSE2Index) instance = object.__new__(cls) object.__setattr__(instance, "_array", array.copy() if copy else array) return instance @property def x(self) -> float: - return self._array[StateSE2Index.X] + return self._array[PoseSE2Index.X] @property def y(self) -> float: - return self._array[StateSE2Index.Y] + return self._array[PoseSE2Index.Y] @property def yaw(self) -> float: - return self._array[StateSE2Index.YAW] + return self._array[PoseSE2Index.YAW] @property def array(self) -> npt.NDArray[np.float64]: """Converts the StateSE2 instance to a numpy array :return: A numpy array of shape (3,) containing the state, indexed by \ - :class:`~py123d.geometry.geometry_index.StateSE2Index`. + :class:`~py123d.geometry.geometry_index.PoseSE2Index`. """ return self._array @property - def state_se2(self) -> StateSE2: + def pose_se2(self) -> PoseSE2: """The 2D pose itself. Helpful for polymorphism. :return: A StateSE2 instance representing the 2D pose. @@ -75,7 +75,7 @@ def point_2d(self) -> Point2D: :return: A Point2D instance representing the 2D projection of the 2D pose. """ - return Point2D.from_array(self.array[StateSE2Index.XY]) + return Point2D.from_array(self.array[PoseSE2Index.XY]) @property def rotation_matrix(self) -> npt.NDArray[np.float64]: @@ -104,50 +104,60 @@ def shapely_point(self) -> geom.Point: return geom.Point(self.x, self.y) -class StateSE3(ArrayMixin): - """Class representing a quaternion in SE3 space.""" +class PoseSE3(ArrayMixin): + """Class representing a quaternion in SE3 space + + Examples: + >>> pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=0.7071, qx=0.0, qy=0.7071, qz=0.0) + >>> print(pose.x, pose.y, pose.z) + 1.0 2.0 3.0 + >>> print(pose.qw, pose.qx, pose.qy, pose.qz) + 0.7071 0.0 0.7071 0.0 + >>> print(pose.yaw) + 1.5707963267948966 + """ _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, z: float, qw: float, qx: float, qy: float, qz: float): - """Initialize StateSE3 with x, y, z, qw, qx, qy, qz coordinates.""" - array = np.zeros(len(StateSE3Index), dtype=np.float64) - array[StateSE3Index.X] = x - array[StateSE3Index.Y] = y - array[StateSE3Index.Z] = z - array[StateSE3Index.QW] = qw - array[StateSE3Index.QX] = qx - array[StateSE3Index.QY] = qy - array[StateSE3Index.QZ] = qz + """Initialize PoseSE3 with x, y, z, qw, qx, qy, qz coordinates.""" + array = np.zeros(len(PoseSE3Index), dtype=np.float64) + array[PoseSE3Index.X] = x + array[PoseSE3Index.Y] = y + array[PoseSE3Index.Z] = z + array[PoseSE3Index.QW] = qw + array[PoseSE3Index.QX] = qx + array[PoseSE3Index.QY] = qy + array[PoseSE3Index.QZ] = qz object.__setattr__(self, "_array", array) @classmethod - def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> StateSE3: - """Constructs a StateSE3 from a numpy array. + def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PoseSE3: + """Constructs a PoseSE3 from a numpy array. - :param array: Array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.StateSE3Index`. + :param array: Array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.PoseSE3Index`. :param copy: Whether to copy the input array. Defaults to True. - :return: A StateSE3 instance. + :return: A PoseSE3 instance. """ assert array.ndim == 1 - assert array.shape[0] == len(StateSE3Index) + assert array.shape[0] == len(PoseSE3Index) instance = object.__new__(cls) object.__setattr__(instance, "_array", array.copy() if copy else array) return instance @classmethod - def from_transformation_matrix(cls, transformation_matrix: npt.NDArray[np.float64]) -> StateSE3: - """Constructs a StateSE3 from a 4x4 transformation matrix. + def from_transformation_matrix(cls, transformation_matrix: npt.NDArray[np.float64]) -> PoseSE3: + """Constructs a PoseSE3 from a 4x4 transformation matrix. :param transformation_matrix: A 4x4 numpy array representing the transformation matrix. - :return: A StateSE3 instance. + :return: A PoseSE3 instance. """ assert transformation_matrix.ndim == 2 assert transformation_matrix.shape == (4, 4) - array = np.zeros(len(StateSE3Index), dtype=np.float64) - array[StateSE3Index.XYZ] = transformation_matrix[:3, 3] - array[StateSE3Index.QUATERNION] = Quaternion.from_rotation_matrix(transformation_matrix[:3, :3]) - return StateSE3.from_array(array, copy=False) + array = np.zeros(len(PoseSE3Index), dtype=np.float64) + array[PoseSE3Index.XYZ] = transformation_matrix[:3, 3] + array[PoseSE3Index.QUATERNION] = Quaternion.from_rotation_matrix(transformation_matrix[:3, :3]) + return PoseSE3.from_array(array, copy=False) @property def x(self) -> float: @@ -155,7 +165,7 @@ def x(self) -> float: :return: The x-coordinate. """ - return self._array[StateSE3Index.X] + return self._array[PoseSE3Index.X] @property def y(self) -> float: @@ -163,7 +173,7 @@ def y(self) -> float: :return: The y-coordinate. """ - return self._array[StateSE3Index.Y] + return self._array[PoseSE3Index.Y] @property def z(self) -> float: @@ -171,7 +181,7 @@ def z(self) -> float: :return: The z-coordinate. """ - return self._array[StateSE3Index.Z] + return self._array[PoseSE3Index.Z] @property def qw(self) -> float: @@ -179,7 +189,7 @@ def qw(self) -> float: :return: The w-coordinate. """ - return self._array[StateSE3Index.QW] + return self._array[PoseSE3Index.QW] @property def qx(self) -> float: @@ -187,7 +197,7 @@ def qx(self) -> float: :return: The x-coordinate. """ - return self._array[StateSE3Index.QX] + return self._array[PoseSE3Index.QX] @property def qy(self) -> float: @@ -195,7 +205,7 @@ def qy(self) -> float: :return: The y-coordinate. """ - return self._array[StateSE3Index.QY] + return self._array[PoseSE3Index.QY] @property def qz(self) -> float: @@ -203,25 +213,25 @@ def qz(self) -> float: :return: The z-coordinate. """ - return self._array[StateSE3Index.QZ] + return self._array[PoseSE3Index.QZ] @property def array(self) -> npt.NDArray[np.float64]: - """Converts the StateSE3 instance to a numpy array. + """Converts the PoseSE3 instance to a numpy array. - :return: A numpy array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.StateSE3Index`. + :return: A numpy array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.PoseSE3Index`. """ return self._array @property - def state_se2(self) -> StateSE2: - """Returns the quaternion state as a 2D state by ignoring the z-axis. + def pose_se2(self) -> PoseSE2: + """Returns the SE2 pose of ... :return: A StateSE2 instance representing the 2D projection of the 3D state. """ # Convert quaternion to yaw angle yaw = self.quaternion.euler_angles.yaw - return StateSE2(self.x, self.y, yaw) + return PoseSE2(self.x, self.y, yaw) @property def point_3d(self) -> Point3D: @@ -253,7 +263,7 @@ def quaternion(self) -> Quaternion: :return: A Quaternion instance representing the quaternion. """ - return Quaternion.from_array(self.array[StateSE3Index.QUATERNION]) + return Quaternion.from_array(self.array[PoseSE3Index.QUATERNION]) @property def euler_angles(self) -> EulerAngles: @@ -303,20 +313,20 @@ def transformation_matrix(self) -> npt.NDArray[np.float64]: """ transformation_matrix = np.eye(4, dtype=np.float64) transformation_matrix[:3, :3] = self.rotation_matrix - transformation_matrix[:3, 3] = self.array[StateSE3Index.XYZ] + transformation_matrix[:3, 3] = self.array[PoseSE3Index.XYZ] return transformation_matrix class EulerStateSE3(ArrayMixin): """ Class to represents a 3D pose as SE3 (x, y, z, roll, pitch, yaw). - NOTE: This class is deprecated, use :class:`~py123d.geometry.StateSE3` instead (quaternion based). + NOTE: This class is deprecated, use :class:`~py123d.geometry.PoseSE3` instead (quaternion based). """ _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, z: float, roll: float, pitch: float, yaw: float): - """Initialize StateSE3 with x, y, z, roll, pitch, yaw coordinates.""" + """Initialize PoseSE3 with x, y, z, roll, pitch, yaw coordinates.""" array = np.zeros(len(EulerStateSE3Index), dtype=np.float64) array[EulerStateSE3Index.X] = x array[EulerStateSE3Index.Y] = y @@ -328,12 +338,12 @@ def __init__(self, x: float, y: float, z: float, roll: float, pitch: float, yaw: @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> EulerStateSE3: - """Constructs a StateSE3 from a numpy array. + """Constructs a PoseSE3 from a numpy array. :param array: Array of shape (6,) representing the state [x, y, z, roll, pitch, yaw], indexed by \ - :class:`~py123d.geometry.geometry_index.StateSE3Index`. + :class:`~py123d.geometry.geometry_index.PoseSE3Index`. :param copy: Whether to copy the input array. Defaults to True. - :return: A StateSE3 instance. + :return: A PoseSE3 instance. """ assert array.ndim == 1 assert array.shape[0] == len(EulerStateSE3Index) @@ -412,20 +422,20 @@ def yaw(self) -> float: @property def array(self) -> npt.NDArray[np.float64]: - """Returns the StateSE3 instance as a numpy array. + """Returns the PoseSE3 instance as a numpy array. :return: A numpy array of shape (6,), indexed by \ - :class:`~py123d.geometry.geometry_index.StateSE3Index`. + :class:`~py123d.geometry.geometry_index.PoseSE3Index`. """ return self._array @property - def state_se2(self) -> StateSE2: + def pose_se2(self) -> PoseSE2: """Returns the 3D state as a 2D state by ignoring the z-axis. :return: A StateSE2 instance representing the 2D projection of the 3D state. """ - return StateSE2(self.x, self.y, self.yaw) + return PoseSE2(self.x, self.y, self.yaw) @property def point_3d(self) -> Point3D: @@ -476,11 +486,11 @@ def euler_angles(self) -> EulerAngles: return EulerAngles.from_array(self.array[EulerStateSE3Index.EULER_ANGLES]) @property - def state_se3(self) -> StateSE3: - quaternion_se3_array = np.zeros(len(StateSE3Index), dtype=np.float64) - quaternion_se3_array[StateSE3Index.XYZ] = self.array[EulerStateSE3Index.XYZ] - quaternion_se3_array[StateSE3Index.QUATERNION] = Quaternion.from_euler_angles(self.euler_angles) - return StateSE3.from_array(quaternion_se3_array, copy=False) + def pose_se3(self) -> PoseSE3: + quaternion_se3_array = np.zeros(len(PoseSE3Index), dtype=np.float64) + quaternion_se3_array[PoseSE3Index.XYZ] = self.array[EulerStateSE3Index.XYZ] + quaternion_se3_array[PoseSE3Index.QUATERNION] = Quaternion.from_euler_angles(self.euler_angles) + return PoseSE3.from_array(quaternion_se3_array, copy=False) @property def quaternion(self) -> Quaternion: diff --git a/src/py123d/geometry/transform/transform_euler_se3.py b/src/py123d/geometry/transform/transform_euler_se3.py index 398b2af4..48210bec 100644 --- a/src/py123d/geometry/transform/transform_euler_se3.py +++ b/src/py123d/geometry/transform/transform_euler_se3.py @@ -11,44 +11,44 @@ ) -def translate_euler_se3_along_z(state_se3: EulerStateSE3, distance: float) -> EulerStateSE3: +def translate_euler_se3_along_z(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix z_axis = R[:, 2] - state_se3_array = state_se3.array.copy() - state_se3_array[EulerStateSE3Index.XYZ] += distance * z_axis[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[EulerStateSE3Index.XYZ] += distance * z_axis[Vector3DIndex.XYZ] + return EulerStateSE3.from_array(pose_se3_array, copy=False) -def translate_euler_se3_along_y(state_se3: EulerStateSE3, distance: float) -> EulerStateSE3: +def translate_euler_se3_along_y(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix y_axis = R[:, 1] - state_se3_array = state_se3.array.copy() - state_se3_array[EulerStateSE3Index.XYZ] += distance * y_axis[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[EulerStateSE3Index.XYZ] += distance * y_axis[Vector3DIndex.XYZ] + return EulerStateSE3.from_array(pose_se3_array, copy=False) -def translate_euler_se3_along_x(state_se3: EulerStateSE3, distance: float) -> EulerStateSE3: +def translate_euler_se3_along_x(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix x_axis = R[:, 0] - state_se3_array = state_se3.array.copy() - state_se3_array[EulerStateSE3Index.XYZ] += distance * x_axis[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[EulerStateSE3Index.XYZ] += distance * x_axis[Vector3DIndex.XYZ] + return EulerStateSE3.from_array(pose_se3_array, copy=False) -def translate_euler_se3_along_body_frame(state_se3: EulerStateSE3, vector_3d: Vector3D) -> EulerStateSE3: +def translate_euler_se3_along_body_frame(pose_se3: EulerStateSE3, vector_3d: Vector3D) -> EulerStateSE3: - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix world_translation = R @ vector_3d.array - state_se3_array = state_se3.array.copy() - state_se3_array[EulerStateSE3Index.XYZ] += world_translation[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[EulerStateSE3Index.XYZ] += world_translation[Vector3DIndex.XYZ] + return EulerStateSE3.from_array(pose_se3_array, copy=False) def convert_absolute_to_relative_euler_se3_array( @@ -65,7 +65,7 @@ def convert_absolute_to_relative_euler_se3_array( t_origin = origin_array[EulerStateSE3Index.XYZ] R_origin = get_rotation_matrix_from_euler_array(origin_array[EulerStateSE3Index.EULER_ANGLES]) else: - raise TypeError(f"Expected StateSE3 or np.ndarray, got {type(origin)}") + raise TypeError(f"Expected PoseSE3 or np.ndarray, got {type(origin)}") assert se3_array.ndim >= 1 assert se3_array.shape[-1] == len(EulerStateSE3Index) @@ -106,7 +106,7 @@ def convert_relative_to_absolute_euler_se3_array( t_origin = origin_array[EulerStateSE3Index.XYZ] R_origin = get_rotation_matrix_from_euler_array(origin_array[EulerStateSE3Index.EULER_ANGLES]) else: - raise TypeError(f"Expected StateSE3 or np.ndarray, got {type(origin)}") + raise TypeError(f"Expected PoseSE3 or np.ndarray, got {type(origin)}") assert se3_array.ndim >= 1 assert se3_array.shape[-1] == len(EulerStateSE3Index) @@ -141,7 +141,7 @@ def convert_absolute_to_relative_points_3d_array( t_origin = origin[EulerStateSE3Index.XYZ] R_origin = get_rotation_matrix_from_euler_array(origin[EulerStateSE3Index.EULER_ANGLES]) else: - raise TypeError(f"Expected StateSE3 or np.ndarray, got {type(origin)}") + raise TypeError(f"Expected PoseSE3 or np.ndarray, got {type(origin)}") assert points_3d_array.ndim >= 1 assert points_3d_array.shape[-1] == len(Point3DIndex) diff --git a/src/py123d/geometry/transform/transform_se2.py b/src/py123d/geometry/transform/transform_se2.py index 0ee2d5b5..9ecd3c6d 100644 --- a/src/py123d/geometry/transform/transform_se2.py +++ b/src/py123d/geometry/transform/transform_se2.py @@ -3,77 +3,77 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import Point2DIndex, StateSE2, StateSE2Index, Vector2D, Vector2DIndex +from py123d.geometry import Point2DIndex, PoseSE2, PoseSE2Index, Vector2D, Vector2DIndex from py123d.geometry.utils.rotation_utils import normalize_angle def convert_absolute_to_relative_se2_array( - origin: Union[StateSE2, npt.NDArray[np.float64]], state_se2_array: npt.NDArray[np.float64] + origin: Union[PoseSE2, npt.NDArray[np.float64]], pose_se2_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Converts an StateSE2 array from global to relative coordinates. :param origin: origin pose of relative coords system - :param state_se2_array: array of SE2 states with (x,y,yaw), indexed by \ - :class:`~py123d.geometry.geometry_index.StateSE2Index`, in last dim + :param pose_se2_array: array of SE2 poses with (x,y,yaw), indexed by \ + :class:`~py123d.geometry.geometry_index.PoseSE2Index`, in last dim :return: SE2 array, index by \ - :class:`~py123d.geometry.geometry_index.StateSE2Index`, in last dim + :class:`~py123d.geometry.geometry_index.PoseSE2Index`, in last dim """ - if isinstance(origin, StateSE2): + if isinstance(origin, PoseSE2): origin_array = origin.array elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(StateSE2Index) + assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) origin_array = origin else: raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") - assert len(StateSE2Index) == state_se2_array.shape[-1] + assert len(PoseSE2Index) == pose_se2_array.shape[-1] - rotate_rad = -origin_array[StateSE2Index.YAW] + rotate_rad = -origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) R_inv = np.array([[cos, -sin], [sin, cos]]) - state_se2_rel = state_se2_array - origin_array - state_se2_rel[..., StateSE2Index.XY] = state_se2_rel[..., StateSE2Index.XY] @ R_inv.T - state_se2_rel[..., StateSE2Index.YAW] = normalize_angle(state_se2_rel[..., StateSE2Index.YAW]) + pose_se2_rel = pose_se2_array - origin_array + pose_se2_rel[..., PoseSE2Index.XY] = pose_se2_rel[..., PoseSE2Index.XY] @ R_inv.T + pose_se2_rel[..., PoseSE2Index.YAW] = normalize_angle(pose_se2_rel[..., PoseSE2Index.YAW]) - return state_se2_rel + return pose_se2_rel def convert_relative_to_absolute_se2_array( - origin: Union[StateSE2, npt.NDArray[np.float64]], state_se2_array: npt.NDArray[np.float64] + origin: Union[PoseSE2, npt.NDArray[np.float64]], pose_se2_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """ Converts an StateSE2 array from global to relative coordinates. :param origin: origin pose of relative coords system - :param state_se2_array: array of SE2 states with (x,y,θ) in last dim + :param pose_se2_array: array of SE2 poses with (x,y,θ) in last dim :return: SE2 coords array in relative coordinates """ - if isinstance(origin, StateSE2): + if isinstance(origin, PoseSE2): origin_array = origin.array elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(StateSE2Index) + assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) origin_array = origin else: raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") - assert len(StateSE2Index) == state_se2_array.shape[-1] + assert len(PoseSE2Index) == pose_se2_array.shape[-1] - rotate_rad = origin_array[StateSE2Index.YAW] + rotate_rad = origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) R = np.array([[cos, -sin], [sin, cos]]) - state_se2_abs = np.zeros_like(state_se2_array, dtype=np.float64) - state_se2_abs[..., StateSE2Index.XY] = state_se2_array[..., StateSE2Index.XY] @ R.T - state_se2_abs[..., StateSE2Index.XY] += origin_array[..., StateSE2Index.XY] - state_se2_abs[..., StateSE2Index.YAW] = normalize_angle( - state_se2_array[..., StateSE2Index.YAW] + origin_array[..., StateSE2Index.YAW] + pose_se2_abs = np.zeros_like(pose_se2_array, dtype=np.float64) + pose_se2_abs[..., PoseSE2Index.XY] = pose_se2_array[..., PoseSE2Index.XY] @ R.T + pose_se2_abs[..., PoseSE2Index.XY] += origin_array[..., PoseSE2Index.XY] + pose_se2_abs[..., PoseSE2Index.YAW] = normalize_angle( + pose_se2_array[..., PoseSE2Index.YAW] + origin_array[..., PoseSE2Index.YAW] ) - return state_se2_abs + return pose_se2_abs def convert_absolute_to_relative_point_2d_array( - origin: Union[StateSE2, npt.NDArray[np.float64]], point_2d_array: npt.NDArray[np.float64] + origin: Union[PoseSE2, npt.NDArray[np.float64]], point_2d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Converts an absolute 2D point array from global to relative coordinates. @@ -81,58 +81,58 @@ def convert_absolute_to_relative_point_2d_array( :param point_2d_array: array of 2D points with (x,y) in last dim :return: 2D points array in relative coordinates """ - if isinstance(origin, StateSE2): + if isinstance(origin, PoseSE2): origin_array = origin.array elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(StateSE2Index) + assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) origin_array = origin else: raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") - rotate_rad = -origin_array[StateSE2Index.YAW] + rotate_rad = -origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) R = np.array([[cos, -sin], [sin, cos]], dtype=np.float64) - point_2d_rel = point_2d_array - origin_array[..., StateSE2Index.XY] + point_2d_rel = point_2d_array - origin_array[..., PoseSE2Index.XY] point_2d_rel = point_2d_rel @ R.T return point_2d_rel def convert_relative_to_absolute_point_2d_array( - origin: Union[StateSE2, npt.NDArray[np.float64]], point_2d_array: npt.NDArray[np.float64] + origin: Union[PoseSE2, npt.NDArray[np.float64]], point_2d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, StateSE2): + if isinstance(origin, PoseSE2): origin_array = origin.array elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(StateSE2Index) + assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) origin_array = origin else: raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") - rotate_rad = origin_array[StateSE2Index.YAW] + rotate_rad = origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) R = np.array([[cos, -sin], [sin, cos]], dtype=np.float64) point_2d_abs = point_2d_array @ R.T - point_2d_abs = point_2d_abs + origin_array[..., StateSE2Index.XY] + point_2d_abs = point_2d_abs + origin_array[..., PoseSE2Index.XY] return point_2d_abs def translate_se2_array_along_body_frame( - state_se2_array: npt.NDArray[np.float64], translation: Vector2D + pose_se2_array: npt.NDArray[np.float64], translation: Vector2D ) -> npt.NDArray[np.float64]: """Translate an array of SE2 states along their respective local coordinate frames. - :param state_se2_array: array of SE2 states with (x,y,yaw) in last dim + :param pose_se2_array: array of SE2 states with (x,y,yaw) in last dim :param translation: 2D translation in local frame (x: forward, y: left) :return: translated SE2 array """ - assert len(StateSE2Index) == state_se2_array.shape[-1] - result = state_se2_array.copy() - yaws = state_se2_array[..., StateSE2Index.YAW] + assert len(PoseSE2Index) == pose_se2_array.shape[-1] + result = pose_se2_array.copy() + yaws = pose_se2_array[..., PoseSE2Index.YAW] cos_yaws, sin_yaws = np.cos(yaws), np.sin(yaws) # Transform translation from local to global frame for each state @@ -143,41 +143,41 @@ def translate_se2_array_along_body_frame( translation_vector = translation.array[Vector2DIndex.XY] # [x, y] global_translation = np.einsum("...ij,...j->...i", R, translation_vector) - result[..., StateSE2Index.XY] += global_translation + result[..., PoseSE2Index.XY] += global_translation return result -def translate_se2_along_body_frame(state_se2: StateSE2, translation: Vector2D) -> StateSE2: +def translate_se2_along_body_frame(pose_se2: PoseSE2, translation: Vector2D) -> PoseSE2: """Translate a single SE2 state along its local coordinate frame. - :param state_se2: SE2 state to translate + :param pose_se2: SE2 state to translate :param translation: 2D translation in local frame (x: forward, y: left) :return: translated SE2 state """ - return StateSE2.from_array(translate_se2_array_along_body_frame(state_se2.array, translation), copy=False) + return PoseSE2.from_array(translate_se2_array_along_body_frame(pose_se2.array, translation), copy=False) -def translate_se2_along_x(state_se2: StateSE2, distance: float) -> StateSE2: +def translate_se2_along_x(pose_se2: PoseSE2, distance: float) -> PoseSE2: """Translate a single SE2 state along its local X-axis. - :param state_se2: SE2 state to translate + :param pose_se2: SE2 state to translate :param distance: distance to translate along the local X-axis :return: translated SE2 state """ translation = Vector2D.from_array(np.array([distance, 0.0], dtype=np.float64)) - return StateSE2.from_array(translate_se2_array_along_body_frame(state_se2.array, translation), copy=False) + return PoseSE2.from_array(translate_se2_array_along_body_frame(pose_se2.array, translation), copy=False) -def translate_se2_along_y(state_se2: StateSE2, distance: float) -> StateSE2: +def translate_se2_along_y(pose_se2: PoseSE2, distance: float) -> PoseSE2: """Translate a single SE2 state along its local Y-axis. - :param state_se2: SE2 state to translate + :param pose_se2: SE2 state to translate :param distance: distance to translate along the local Y-axis :return: translated SE2 state """ translation = Vector2D.from_array(np.array([0.0, distance], dtype=np.float64)) - return StateSE2.from_array(translate_se2_array_along_body_frame(state_se2.array, translation), copy=False) + return PoseSE2.from_array(translate_se2_array_along_body_frame(pose_se2.array, translation), copy=False) def translate_2d_along_body_frame( diff --git a/src/py123d/geometry/transform/transform_se3.py b/src/py123d/geometry/transform/transform_se3.py index 8f394772..1274030a 100644 --- a/src/py123d/geometry/transform/transform_se3.py +++ b/src/py123d/geometry/transform/transform_se3.py @@ -3,7 +3,7 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import Point3DIndex, QuaternionIndex, StateSE3, StateSE3Index, Vector3D, Vector3DIndex +from py123d.geometry import Point3DIndex, PoseSE3, PoseSE3Index, QuaternionIndex, Vector3D, Vector3DIndex from py123d.geometry.utils.rotation_utils import ( conjugate_quaternion_array, get_rotation_matrices_from_quaternion_array, @@ -13,37 +13,37 @@ def _extract_rotation_translation_pose_arrays( - pose: Union[StateSE3, npt.NDArray[np.float64]], + pose: Union[PoseSE3, npt.NDArray[np.float64]], ) -> Tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]]: - """Helper function to extract rotation matrix and translation vector from a StateSE3 or np.ndarray. + """Helper function to extract rotation matrix and translation vector from a PoseSE3 or np.ndarray. - :param pose: A StateSE3 pose or np.ndarray, indexed by :class:`~py123d.geometry.StateSE3Index`. - :raises TypeError: If the pose is not a StateSE3 or np.ndarray. + :param pose: A PoseSE3 pose or np.ndarray, indexed by :class:`~py123d.geometry.PoseSE3Index`. + :raises TypeError: If the pose is not a PoseSE3 or np.ndarray. :return: A tuple containing the rotation matrix, translation vector, and pose array. """ - if isinstance(pose, StateSE3): + if isinstance(pose, PoseSE3): translation = pose.point_3d.array rotation = pose.rotation_matrix pose_array = pose.array elif isinstance(pose, np.ndarray): - assert pose.ndim == 1 and pose.shape[-1] == len(StateSE3Index) - translation = pose[StateSE3Index.XYZ] - rotation = get_rotation_matrix_from_quaternion_array(pose[StateSE3Index.QUATERNION]) + assert pose.ndim == 1 and pose.shape[-1] == len(PoseSE3Index) + translation = pose[PoseSE3Index.XYZ] + rotation = get_rotation_matrix_from_quaternion_array(pose[PoseSE3Index.QUATERNION]) pose_array = pose else: - raise TypeError(f"Expected StateSE3 or np.ndarray, got {type(pose)}") + raise TypeError(f"Expected PoseSE3 or np.ndarray, got {type(pose)}") return rotation, translation, pose_array def convert_absolute_to_relative_points_3d_array( - origin: Union[StateSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] + origin: Union[PoseSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Converts 3D points from the absolute frame to the relative frame. - :param origin: The origin state in the absolute frame, as a StateSE3 or np.ndarray. + :param origin: The origin state in the absolute frame, as a PoseSE3 or np.ndarray. :param points_3d_array: The 3D points in the absolute frame. - :raises TypeError: If the origin is not a StateSE3 or np.ndarray. + :raises TypeError: If the origin is not a PoseSE3 or np.ndarray. :return: The 3D points in the relative frame, indexed by :class:`~py123d.geometry.Point3DIndex`. """ @@ -58,46 +58,46 @@ def convert_absolute_to_relative_points_3d_array( def convert_absolute_to_relative_se3_array( - origin: Union[StateSE3, npt.NDArray[np.float64]], se3_array: npt.NDArray[np.float64] + origin: Union[PoseSE3, npt.NDArray[np.float64]], se3_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Converts an SE3 array from the absolute frame to the relative frame. - :param origin: The origin state in the absolute frame, as a StateSE3 or np.ndarray. + :param origin: The origin state in the absolute frame, as a PoseSE3 or np.ndarray. :param se3_array: The SE3 array in the absolute frame. - :raises TypeError: If the origin is not a StateSE3 or np.ndarray. - :return: The SE3 array in the relative frame, indexed by :class:`~py123d.geometry.StateSE3Index`. + :raises TypeError: If the origin is not a PoseSE3 or np.ndarray. + :return: The SE3 array in the relative frame, indexed by :class:`~py123d.geometry.PoseSE3Index`. """ R_origin, t_origin, origin_array = _extract_rotation_translation_pose_arrays(origin) assert se3_array.ndim >= 1 - assert se3_array.shape[-1] == len(StateSE3Index) + assert se3_array.shape[-1] == len(PoseSE3Index) - abs_positions = se3_array[..., StateSE3Index.XYZ] - abs_quaternions = se3_array[..., StateSE3Index.QUATERNION] + abs_positions = se3_array[..., PoseSE3Index.XYZ] + abs_quaternions = se3_array[..., PoseSE3Index.QUATERNION] rel_se3_array = np.zeros_like(se3_array) # 1. Vectorized relative position calculation: translate and rotate rel_positions = (abs_positions - t_origin) @ R_origin - rel_se3_array[..., StateSE3Index.XYZ] = rel_positions + rel_se3_array[..., PoseSE3Index.XYZ] = rel_positions # 2. Vectorized relative orientation calculation: quaternion multiplication with conjugate - q_origin_conj = conjugate_quaternion_array(origin_array[StateSE3Index.QUATERNION]) + q_origin_conj = conjugate_quaternion_array(origin_array[PoseSE3Index.QUATERNION]) rel_quaternions = multiply_quaternion_arrays(q_origin_conj, abs_quaternions) - rel_se3_array[..., StateSE3Index.QUATERNION] = rel_quaternions + rel_se3_array[..., PoseSE3Index.QUATERNION] = rel_quaternions return rel_se3_array def convert_relative_to_absolute_points_3d_array( - origin: Union[StateSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] + origin: Union[PoseSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Converts 3D points from the relative frame to the absolute frame. - :param origin: The origin state in the absolute frame, as a StateSE3 or np.ndarray. + :param origin: The origin state in the absolute frame, as a PoseSE3 or np.ndarray. :param points_3d_array: The 3D points in the relative frame, indexed by :class:`~py123d.geometry.Point3DIndex`. - :raises TypeError: If the origin is not a StateSE3 or np.ndarray. + :raises TypeError: If the origin is not a PoseSE3 or np.ndarray. :return: The 3D points in the absolute frame, indexed by :class:`~py123d.geometry.Point3DIndex`. """ R_origin, t_origin, _ = _extract_rotation_translation_pose_arrays(origin) @@ -109,67 +109,67 @@ def convert_relative_to_absolute_points_3d_array( def convert_relative_to_absolute_se3_array( - origin: StateSE3, se3_array: npt.NDArray[np.float64] + origin: PoseSE3, se3_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Converts an SE3 array from the relative frame to the absolute frame. - :param origin: The origin state in the relative frame, as a StateSE3 or np.ndarray. + :param origin: The origin state in the relative frame, as a PoseSE3 or np.ndarray. :param se3_array: The SE3 array in the relative frame. - :raises TypeError: If the origin is not a StateSE3 or np.ndarray. - :return: The SE3 array in the absolute frame, indexed by :class:`~py123d.geometry.StateSE3Index`. + :raises TypeError: If the origin is not a PoseSE3 or np.ndarray. + :return: The SE3 array in the absolute frame, indexed by :class:`~py123d.geometry.PoseSE3Index`. """ R_origin, t_origin, origin_array = _extract_rotation_translation_pose_arrays(origin) assert se3_array.ndim >= 1 - assert se3_array.shape[-1] == len(StateSE3Index) + assert se3_array.shape[-1] == len(PoseSE3Index) # Extract relative positions and orientations - rel_positions = se3_array[..., StateSE3Index.XYZ] - rel_quaternions = se3_array[..., StateSE3Index.QUATERNION] + rel_positions = se3_array[..., PoseSE3Index.XYZ] + rel_quaternions = se3_array[..., PoseSE3Index.QUATERNION] # Vectorized absolute position calculation: rotate and translate abs_positions = (R_origin @ rel_positions.T).T + t_origin - abs_quaternions = multiply_quaternion_arrays(origin_array[StateSE3Index.QUATERNION], rel_quaternions) + abs_quaternions = multiply_quaternion_arrays(origin_array[PoseSE3Index.QUATERNION], rel_quaternions) # Prepare output array abs_se3_array = se3_array.copy() - abs_se3_array[..., StateSE3Index.XYZ] = abs_positions - abs_se3_array[..., StateSE3Index.QUATERNION] = abs_quaternions + abs_se3_array[..., PoseSE3Index.XYZ] = abs_positions + abs_se3_array[..., PoseSE3Index.QUATERNION] = abs_quaternions return abs_se3_array def convert_se3_array_between_origins( - from_origin: Union[StateSE3, npt.NDArray[np.float64]], - to_origin: Union[StateSE3, npt.NDArray[np.float64]], + from_origin: Union[PoseSE3, npt.NDArray[np.float64]], + to_origin: Union[PoseSE3, npt.NDArray[np.float64]], se3_array: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: """Converts an SE3 array from one origin frame to another origin frame. - :param from_origin: The source origin state in the absolute frame, as a StateSE3 or np.ndarray. - :param to_origin: The target origin state in the absolute frame, as a StateSE3 or np.ndarray. + :param from_origin: The source origin state in the absolute frame, as a PoseSE3 or np.ndarray. + :param to_origin: The target origin state in the absolute frame, as a PoseSE3 or np.ndarray. :param se3_array: The SE3 array in the source origin frame. - :raises TypeError: If the origins are not StateSE3 or np.ndarray. - :return: The SE3 array in the target origin frame, indexed by :class:`~py123d.geometry.StateSE3Index`. + :raises TypeError: If the origins are not PoseSE3 or np.ndarray. + :return: The SE3 array in the target origin frame, indexed by :class:`~py123d.geometry.PoseSE3Index`. """ # Parse from_origin & to_origin R_from, t_from, from_origin_array = _extract_rotation_translation_pose_arrays(from_origin) R_to, t_to, to_origin_array = _extract_rotation_translation_pose_arrays(to_origin) assert se3_array.ndim >= 1 - assert se3_array.shape[-1] == len(StateSE3Index) + assert se3_array.shape[-1] == len(PoseSE3Index) - rel_positions = se3_array[..., StateSE3Index.XYZ] - rel_quaternions = se3_array[..., StateSE3Index.QUATERNION] + rel_positions = se3_array[..., PoseSE3Index.XYZ] + rel_quaternions = se3_array[..., PoseSE3Index.QUATERNION] # Compute relative transformation: T_to^-1 * T_from R_rel = R_to.T @ R_from # Relative rotation matrix t_rel = R_to.T @ (t_from - t_to) # Relative translation q_rel = multiply_quaternion_arrays( - conjugate_quaternion_array(to_origin_array[StateSE3Index.QUATERNION]), - from_origin_array[StateSE3Index.QUATERNION], + conjugate_quaternion_array(to_origin_array[PoseSE3Index.QUATERNION]), + from_origin_array[PoseSE3Index.QUATERNION], ) # Transform positions: rotate and translate @@ -180,23 +180,23 @@ def convert_se3_array_between_origins( # Prepare output array result_se3_array = np.zeros_like(se3_array) - result_se3_array[..., StateSE3Index.XYZ] = new_rel_positions - result_se3_array[..., StateSE3Index.QUATERNION] = new_rel_quaternions + result_se3_array[..., PoseSE3Index.XYZ] = new_rel_positions + result_se3_array[..., PoseSE3Index.QUATERNION] = new_rel_quaternions return result_se3_array def convert_points_3d_array_between_origins( - from_origin: Union[StateSE3, npt.NDArray[np.float64]], - to_origin: Union[StateSE3, npt.NDArray[np.float64]], + from_origin: Union[PoseSE3, npt.NDArray[np.float64]], + to_origin: Union[PoseSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: """Converts 3D points from one origin frame to another origin frame. - :param from_origin: The source origin state in the absolute frame, as a StateSE3 or np.ndarray. - :param to_origin: The target origin state in the absolute frame, as a StateSE3 or np.ndarray. + :param from_origin: The source origin state in the absolute frame, as a PoseSE3 or np.ndarray. + :param to_origin: The target origin state in the absolute frame, as a PoseSE3 or np.ndarray. :param points_3d_array: The 3D points in the source origin frame. - :raises TypeError: If the origins are not StateSE3 or np.ndarray. + :raises TypeError: If the origins are not PoseSE3 or np.ndarray. :return: The 3D points in the target origin frame, indexed by :class:`~py123d.geometry.Point3DIndex`. """ # Parse from_origin & to_origin @@ -213,64 +213,64 @@ def convert_points_3d_array_between_origins( return conv_points_3d_array -def translate_se3_along_z(state_se3: StateSE3, distance: float) -> StateSE3: +def translate_se3_along_z(pose_se3: PoseSE3, distance: float) -> PoseSE3: """Translates an SE3 state along the Z-axis. - :param state_se3: The SE3 state to translate. + :param pose_se3: The SE3 state to translate. :param distance: The distance to translate along the Z-axis. :return: The translated SE3 state. """ - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix z_axis = R[:, 2] - state_se3_array = state_se3.array.copy() - state_se3_array[StateSE3Index.XYZ] += distance * z_axis[Vector3DIndex.XYZ] - return StateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[PoseSE3Index.XYZ] += distance * z_axis[Vector3DIndex.XYZ] + return PoseSE3.from_array(pose_se3_array, copy=False) -def translate_se3_along_y(state_se3: StateSE3, distance: float) -> StateSE3: +def translate_se3_along_y(pose_se3: PoseSE3, distance: float) -> PoseSE3: """Translates a SE3 state along the Y-axis. - :param state_se3: The SE3 state to translate. + :param pose_se3: The SE3 state to translate. :param distance: The distance to translate along the Y-axis. :return: The translated SE3 state. """ - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix y_axis = R[:, 1] - state_se3_array = state_se3.array.copy() - state_se3_array[StateSE3Index.XYZ] += distance * y_axis[Vector3DIndex.XYZ] - return StateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[PoseSE3Index.XYZ] += distance * y_axis[Vector3DIndex.XYZ] + return PoseSE3.from_array(pose_se3_array, copy=False) -def translate_se3_along_x(state_se3: StateSE3, distance: float) -> StateSE3: +def translate_se3_along_x(pose_se3: PoseSE3, distance: float) -> PoseSE3: """Translates a SE3 state along the X-axis. - :param state_se3: The SE3 state to translate. + :param pose_se3: The SE3 state to translate. :param distance: The distance to translate along the X-axis. :return: The translated SE3 state. """ - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix x_axis = R[:, 0] - state_se3_array = state_se3.array.copy() - state_se3_array[StateSE3Index.XYZ] += distance * x_axis[Vector3DIndex.XYZ] - return StateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[PoseSE3Index.XYZ] += distance * x_axis[Vector3DIndex.XYZ] + return PoseSE3.from_array(pose_se3_array, copy=False) -def translate_se3_along_body_frame(state_se3: StateSE3, vector_3d: Vector3D) -> StateSE3: +def translate_se3_along_body_frame(pose_se3: PoseSE3, vector_3d: Vector3D) -> PoseSE3: """Translates a SE3 state along a vector in the body frame. - :param state_se3: The SE3 state to translate. + :param pose_se3: The SE3 state to translate. :param vector_3d: The vector to translate along in the body frame. :return: The translated SE3 state. """ - R = state_se3.rotation_matrix + R = pose_se3.rotation_matrix world_translation = R @ vector_3d.array - state_se3_array = state_se3.array.copy() - state_se3_array[StateSE3Index.XYZ] += world_translation - return StateSE3.from_array(state_se3_array, copy=False) + pose_se3_array = pose_se3.array.copy() + pose_se3_array[PoseSE3Index.XYZ] += world_translation + return PoseSE3.from_array(pose_se3_array, copy=False) def translate_3d_along_body_frame( diff --git a/src/py123d/geometry/utils/polyline_utils.py b/src/py123d/geometry/utils/polyline_utils.py index d66628e8..b721e274 100644 --- a/src/py123d/geometry/utils/polyline_utils.py +++ b/src/py123d/geometry/utils/polyline_utils.py @@ -2,7 +2,7 @@ import numpy.typing as npt from shapely.geometry import LineString -from py123d.geometry.geometry_index import Point2DIndex, StateSE2Index +from py123d.geometry.geometry_index import Point2DIndex, PoseSE2Index from py123d.geometry.transform.transform_se2 import translate_2d_along_body_frame @@ -31,13 +31,13 @@ def get_path_progress(points_array: npt.NDArray[np.float64]) -> list[float]: if points_array.shape[-1] == len(Point2DIndex): x_diff = np.diff(points_array[..., Point2DIndex.X]) y_diff = np.diff(points_array[..., Point2DIndex.X]) - elif points_array.shape[-1] == len(StateSE2Index): - x_diff = np.diff(points_array[..., StateSE2Index.X]) - y_diff = np.diff(points_array[..., StateSE2Index.Y]) + elif points_array.shape[-1] == len(PoseSE2Index): + x_diff = np.diff(points_array[..., PoseSE2Index.X]) + y_diff = np.diff(points_array[..., PoseSE2Index.Y]) else: raise ValueError( f"Invalid points_array shape: {points_array.shape}. Expected last dimension to be {len(Point2DIndex)} or " - f"{len(StateSE2Index)}." + f"{len(PoseSE2Index)}." ) points_diff: npt.NDArray[np.float64] = np.concatenate(([x_diff], [y_diff]), axis=0, dtype=np.float64) progress_diff = np.append(0.0, np.linalg.norm(points_diff, axis=0)) @@ -48,13 +48,13 @@ def offset_points_perpendicular(points_array: npt.NDArray[np.float64], offset: f if points_array.shape[-1] == len(Point2DIndex): xy = points_array[..., Point2DIndex.XY] yaws = get_points_2d_yaws(points_array[..., Point2DIndex.XY]) - elif points_array.shape[-1] == len(StateSE2Index): - xy = points_array[..., StateSE2Index.XY] - yaws = points_array[..., StateSE2Index.YAW] + elif points_array.shape[-1] == len(PoseSE2Index): + xy = points_array[..., PoseSE2Index.XY] + yaws = points_array[..., PoseSE2Index.YAW] else: raise ValueError( f"Invalid points_array shape: {points_array.shape}. Expected last dimension to be {len(Point2DIndex)} or " - f"{len(StateSE2Index)}." + f"{len(PoseSE2Index)}." ) return translate_2d_along_body_frame( diff --git a/src/py123d/visualization/matplotlib/camera.py b/src/py123d/visualization/matplotlib/camera.py index 5155aae4..91c363b9 100644 --- a/src/py123d/visualization/matplotlib/camera.py +++ b/src/py123d/visualization/matplotlib/camera.py @@ -87,8 +87,8 @@ def add_box_detections_to_camera_ax( box_detection_array[idx] = box_detection.bounding_box_se3.array # FIXME - box_detection_array[..., BoundingBoxSE3Index.STATE_SE3] = convert_absolute_to_relative_se3_array( - ego_state_se3.rear_axle_se3, box_detection_array[..., BoundingBoxSE3Index.STATE_SE3] + box_detection_array[..., BoundingBoxSE3Index.POSE_SE3] = convert_absolute_to_relative_se3_array( + ego_state_se3.rear_axle_se3, box_detection_array[..., BoundingBoxSE3Index.POSE_SE3] ) # box_detection_array[..., BoundingBoxSE3Index.XYZ] -= ego_state_se3.rear_axle_se3.point_3d.array detection_positions, detection_extents, detection_yaws = _transform_annotations_to_camera( diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index b39ff017..15523e0f 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -12,7 +12,7 @@ from py123d.datatypes.map.map_datatypes import MapLayer from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.vehicle_state.ego_state import EgoStateSE2, EgoStateSE3 -from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, Point2D, StateSE2Index, Vector2D +from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, Point2D, PoseSE2Index, Vector2D from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame from py123d.visualization.color.config import PlotConfig from py123d.visualization.color.default import ( @@ -158,7 +158,7 @@ def add_bounding_box_to_ax( arrow[1] = translate_se2_along_body_frame( center_se2, Vector2D(bounding_box.length / 2.0 + 0.5, 0.0), - ).array[StateSE2Index.XY] + ).array[PoseSE2Index.XY] ax.plot( arrow[:, 0], arrow[:, 1], diff --git a/src/py123d/visualization/matplotlib/plots.py b/src/py123d/visualization/matplotlib/plots.py index 01100f01..0b872abd 100644 --- a/src/py123d/visualization/matplotlib/plots.py +++ b/src/py123d/visualization/matplotlib/plots.py @@ -22,7 +22,7 @@ def _plot_scene_on_ax(ax: plt.Axes, scene: AbstractScene, iteration: int = 0, ra route_lane_group_ids = scene.get_route_lane_group_ids(iteration) map_api = scene.get_map_api() - point_2d = ego_vehicle_state.bounding_box.center.state_se2.point_2d + point_2d = ego_vehicle_state.bounding_box.center.pose_se2.point_2d if map_api is not None: add_default_map_on_ax(ax, map_api, point_2d, radius=radius, route_lane_group_ids=route_lane_group_ids) if traffic_light_detections is not None: diff --git a/src/py123d/visualization/matplotlib/utils.py b/src/py123d/visualization/matplotlib/utils.py index 81c60260..e0a81566 100644 --- a/src/py123d/visualization/matplotlib/utils.py +++ b/src/py123d/visualization/matplotlib/utils.py @@ -7,7 +7,7 @@ import shapely.geometry as geom from matplotlib.path import Path -from py123d.geometry import StateSE2, StateSE3 +from py123d.geometry import PoseSE2, PoseSE3 from py123d.visualization.color.config import PlotConfig @@ -113,7 +113,7 @@ def get_pose_triangle(size: float) -> geom.Polygon: def shapely_geometry_local_coords( - geometry: geom.base.BaseGeometry, origin: Union[StateSE2, StateSE3] + geometry: geom.base.BaseGeometry, origin: Union[PoseSE2, PoseSE3] ) -> geom.base.BaseGeometry: """Helper for transforming shapely geometry in coord-frame""" # TODO: move somewhere else for general use diff --git a/src/py123d/visualization/viser/elements/detection_elements.py b/src/py123d/visualization/viser/elements/detection_elements.py index be08021b..25d63172 100644 --- a/src/py123d/visualization/viser/elements/detection_elements.py +++ b/src/py123d/visualization/viser/elements/detection_elements.py @@ -8,7 +8,7 @@ from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.geometry.geometry_index import BoundingBoxSE3Index, Corners3DIndex, StateSE3Index +from py123d.geometry.geometry_index import BoundingBoxSE3Index, Corners3DIndex, PoseSE3Index from py123d.geometry.utils.bounding_box_utils import ( bbse3_array_to_corners_array, corners_array_to_3d_mesh, @@ -47,15 +47,15 @@ def add_box_detections_to_viser_server( ) # viser_server.scene.add_batched_axes( # "frames", - # batched_wxyzs=se3_array[:-1, StateSE3Index.QUATERNION], - # batched_positions=se3_array[:-1, StateSE3Index.XYZ], + # batched_wxyzs=se3_array[:-1, PoseSE3Index.QUATERNION], + # batched_positions=se3_array[:-1, PoseSE3Index.XYZ], # ) # ego_rear_axle_se3 = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array - # ego_rear_axle_se3[StateSE3Index.XYZ] -= initial_ego_state.center_se3.array[StateSE3Index.XYZ] + # ego_rear_axle_se3[PoseSE3Index.XYZ] -= initial_ego_state.center_se3.array[PoseSE3Index.XYZ] # viser_server.scene.add_frame( # "ego_rear_axle", - # position=ego_rear_axle_se3[StateSE3Index.XYZ], - # wxyz=ego_rear_axle_se3[StateSE3Index.QUATERNION], + # position=ego_rear_axle_se3[PoseSE3Index.XYZ], + # wxyz=ego_rear_axle_se3[PoseSE3Index.QUATERNION], # ) visible_handle_keys.append("lines") @@ -78,7 +78,7 @@ def _get_bounding_box_meshes(scene: AbstractScene, iteration: int, initial_ego_s # create meshes for all boxes box_se3_array = np.array([box.array for box in boxes]) - box_se3_array[..., BoundingBoxSE3Index.XYZ] -= initial_ego_state.center_se3.array[StateSE3Index.XYZ] + box_se3_array[..., BoundingBoxSE3Index.XYZ] -= initial_ego_state.center_se3.array[PoseSE3Index.XYZ] box_corners_array = bbse3_array_to_corners_array(box_se3_array) box_vertices, box_faces = corners_array_to_3d_mesh(box_corners_array) @@ -111,7 +111,7 @@ def _get_bounding_box_meshes(scene: AbstractScene, iteration: int, initial_ego_s # # Create lines for all boxes # box_se3_array = np.array([box.array for box in boxes]) -# box_se3_array[..., BoundingBoxSE3Index.XYZ] -= initial_ego_state.center_se3.array[StateSE3Index.XYZ] +# box_se3_array[..., BoundingBoxSE3Index.XYZ] -= initial_ego_state.center_se3.array[PoseSE3Index.XYZ] # box_corners_array = bbse3_array_to_corners_array(box_se3_array) # box_outlines = corners_array_to_edge_lines(box_corners_array) @@ -139,7 +139,7 @@ def _get_bounding_box_outlines( # Create lines for all boxes box_se3_array = np.array([box.array for box in boxes]) - box_se3_array[..., BoundingBoxSE3Index.XYZ] -= initial_ego_state.center_se3.array[StateSE3Index.XYZ] + box_se3_array[..., BoundingBoxSE3Index.XYZ] -= initial_ego_state.center_se3.array[PoseSE3Index.XYZ] box_corners_array = bbse3_array_to_corners_array(box_se3_array) box_outlines = corners_array_to_edge_lines(box_corners_array) diff --git a/src/py123d/visualization/viser/elements/render_elements.py b/src/py123d/visualization/viser/elements/render_elements.py index f807033e..05029ca1 100644 --- a/src/py123d/visualization/viser/elements/render_elements.py +++ b/src/py123d/visualization/viser/elements/render_elements.py @@ -3,9 +3,9 @@ from py123d.conversion.utils.sensor_utils.camera_conventions import convert_camera_convention from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.geometry.geometry_index import StateSE3Index +from py123d.geometry.geometry_index import PoseSE3Index +from py123d.geometry.pose import PoseSE3 from py123d.geometry.rotation import EulerAngles -from py123d.geometry.se import StateSE3 from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame from py123d.geometry.vector import Vector3D @@ -14,11 +14,11 @@ def get_ego_3rd_person_view_position( scene: AbstractScene, iteration: int, initial_ego_state: EgoStateSE3, -) -> StateSE3: +) -> PoseSE3: scene_center_array = initial_ego_state.center.point_3d.array ego_pose = scene.get_ego_state_at_iteration(iteration).rear_axle_se3.array - ego_pose[StateSE3Index.XYZ] -= scene_center_array - ego_pose_se3 = StateSE3.from_array(ego_pose) + ego_pose[PoseSE3Index.XYZ] -= scene_center_array + ego_pose_se3 = PoseSE3.from_array(ego_pose) ego_pose_se3 = translate_se3_along_body_frame(ego_pose_se3, Vector3D(-15.0, 0.0, 15)) # adjust the pitch to -10 degrees. @@ -39,15 +39,15 @@ def get_ego_bev_view_position( scene: AbstractScene, iteration: int, initial_ego_state: EgoStateSE3, -) -> StateSE3: +) -> PoseSE3: scene_center_array = initial_ego_state.center.point_3d.array ego_center = scene.get_ego_state_at_iteration(iteration).center.array - ego_center[StateSE3Index.XYZ] -= scene_center_array - ego_center_planar = StateSE3.from_array(ego_center) + ego_center[PoseSE3Index.XYZ] -= scene_center_array + ego_center_planar = PoseSE3.from_array(ego_center) planar_euler_angles = EulerAngles(0.0, 0.0, ego_center_planar.euler_angles.yaw) quaternion = planar_euler_angles.quaternion - ego_center_planar._array[StateSE3Index.QUATERNION] = quaternion.array + ego_center_planar._array[PoseSE3Index.QUATERNION] = quaternion.array ego_center_planar = translate_se3_along_body_frame(ego_center_planar, Vector3D(0.0, 0.0, 50)) ego_center_planar = _pitch_se3_by_degrees(ego_center_planar, 90.0) @@ -59,14 +59,14 @@ def get_ego_bev_view_position( ) -def _pitch_se3_by_degrees(state_se3: StateSE3, degrees: float) -> StateSE3: +def _pitch_se3_by_degrees(pose_se3: PoseSE3, degrees: float) -> PoseSE3: - quaternion = EulerAngles(0.0, np.deg2rad(degrees), state_se3.yaw).quaternion + quaternion = EulerAngles(0.0, np.deg2rad(degrees), pose_se3.yaw).quaternion - return StateSE3( - x=state_se3.x, - y=state_se3.y, - z=state_se3.z, + return PoseSE3( + x=pose_se3.x, + y=pose_se3.y, + z=pose_se3.z, qw=quaternion.qw, qx=quaternion.qx, qy=quaternion.qy, diff --git a/src/py123d/visualization/viser/elements/sensor_elements.py b/src/py123d/visualization/viser/elements/sensor_elements.py index 2cc6bacb..6bb7641e 100644 --- a/src/py123d/visualization/viser/elements/sensor_elements.py +++ b/src/py123d/visualization/viser/elements/sensor_elements.py @@ -11,7 +11,7 @@ from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.geometry import StateSE3Index +from py123d.geometry import PoseSE3Index from py123d.geometry.transform.transform_se3 import ( convert_relative_to_absolute_points_3d_array, convert_relative_to_absolute_se3_array, @@ -32,7 +32,7 @@ def add_camera_frustums_to_viser_server( if viser_config.camera_frustum_visible: scene_center_array = initial_ego_state.center.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array - ego_pose[StateSE3Index.XYZ] -= scene_center_array + ego_pose[PoseSE3Index.XYZ] -= scene_center_array def _add_camera_frustums_to_viser_server(camera_type: PinholeCameraType) -> None: camera = scene.get_pinhole_camera_at_iteration(scene_interation, camera_type) @@ -86,7 +86,7 @@ def add_fisheye_frustums_to_viser_server( if viser_config.fisheye_frustum_visible: scene_center_array = initial_ego_state.center.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array - ego_pose[StateSE3Index.XYZ] -= scene_center_array + ego_pose[PoseSE3Index.XYZ] -= scene_center_array def _add_fisheye_frustums_to_viser_server(fisheye_camera_type: FisheyeMEICameraType) -> None: camera = scene.get_fisheye_mei_camera_at_iteration(scene_interation, fisheye_camera_type) @@ -164,7 +164,7 @@ def add_lidar_pc_to_viser_server( scene_center_array = initial_ego_state.center.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array - ego_pose[StateSE3Index.XYZ] -= scene_center_array + ego_pose[PoseSE3Index.XYZ] -= scene_center_array def _load_lidar_points(lidar_type: LiDARType) -> npt.NDArray[np.float32]: lidar = scene.get_lidar_at_iteration(scene_interation, lidar_type) @@ -202,8 +202,8 @@ def _load_lidar_points(lidar_type: LiDARType) -> npt.NDArray[np.float32]: # viser_server.scene.add_frame( # "lidar_frame", - # position=lidar_extrinsic[StateSE3Index.XYZ], - # wxyz=lidar_extrinsic[StateSE3Index.QUATERNION], + # position=lidar_extrinsic[PoseSE3Index.XYZ], + # wxyz=lidar_extrinsic[PoseSE3Index.QUATERNION], # ) if lidar_pc_handles[LiDARType.LIDAR_MERGED] is not None: @@ -226,13 +226,13 @@ def _get_camera_values( ego_pose: npt.NDArray[np.float64], resize_factor: Optional[float] = None, ) -> Tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.uint8]]: - assert ego_pose.ndim == 1 and len(ego_pose) == len(StateSE3Index) + assert ego_pose.ndim == 1 and len(ego_pose) == len(PoseSE3Index) rel_camera_pose = camera.extrinsic.array abs_camera_pose = convert_relative_to_absolute_se3_array(origin=ego_pose, se3_array=rel_camera_pose) - camera_position = abs_camera_pose[StateSE3Index.XYZ] - camera_rotation = abs_camera_pose[StateSE3Index.QUATERNION] + camera_position = abs_camera_pose[PoseSE3Index.XYZ] + camera_rotation = abs_camera_pose[PoseSE3Index.QUATERNION] camera_image = _rescale_image(camera.image, resize_factor) return camera_position, camera_rotation, camera_image @@ -243,13 +243,13 @@ def _get_fisheye_camera_values( ego_pose: npt.NDArray[np.float64], resize_factor: Optional[float] = None, ) -> Tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.uint8]]: - assert ego_pose.ndim == 1 and len(ego_pose) == len(StateSE3Index) + assert ego_pose.ndim == 1 and len(ego_pose) == len(PoseSE3Index) rel_camera_pose = camera.extrinsic.array abs_camera_pose = convert_relative_to_absolute_se3_array(origin=ego_pose, se3_array=rel_camera_pose) - camera_position = abs_camera_pose[StateSE3Index.XYZ] - camera_rotation = abs_camera_pose[StateSE3Index.QUATERNION] + camera_position = abs_camera_pose[PoseSE3Index.XYZ] + camera_rotation = abs_camera_pose[PoseSE3Index.QUATERNION] camera_image = _rescale_image(camera.image, resize_factor) return camera_position, camera_rotation, camera_image diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index 4f6e84bb..53d9f7e1 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -382,6 +382,8 @@ def _(event: viser.GuiEvent) -> None: while server_playing: if gui_playing.value and not server_rendering: gui_timestep.value = (gui_timestep.value + 1) % num_frames + else: + time.sleep(0.1) # update config self._viser_config.playback_speed = gui_speed.value diff --git a/tests/unit/datatypes/detections/test_box_detections.py b/tests/unit/datatypes/detections/test_box_detections.py index 5275a6fc..32580a69 100644 --- a/tests/unit/datatypes/detections/test_box_detections.py +++ b/tests/unit/datatypes/detections/test_box_detections.py @@ -8,7 +8,7 @@ BoxDetectionWrapper, ) from py123d.datatypes.time.time_point import TimePoint -from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, StateSE2, StateSE3, Vector2D, Vector3D +from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, PoseSE2, PoseSE3, Vector2D, Vector3D class DummyBoxDetectionLabel(BoxDetectionLabel): @@ -98,7 +98,7 @@ class TestBoxDetectionSE2(unittest.TestCase): def setUp(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se2 = BoundingBoxSE2( - center=StateSE2(x=0.0, y=0.0, yaw=0.0), + center=PoseSE2(x=0.0, y=0.0, yaw=0.0), length=4.0, width=2.0, ) @@ -147,7 +147,7 @@ class TestBoxBoxDetectionSE3(unittest.TestCase): def setUp(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se3 = BoundingBoxSE3( - center=StateSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), + center=PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), length=4.0, width=2.0, height=1.5, @@ -253,7 +253,7 @@ def setUp(self): self.box_detection1 = BoxDetectionSE2( metadata=self.metadata1, bounding_box_se2=BoundingBoxSE2( - center=StateSE2(x=0.0, y=0.0, yaw=0.0), + center=PoseSE2(x=0.0, y=0.0, yaw=0.0), length=4.0, width=2.0, ), @@ -262,7 +262,7 @@ def setUp(self): self.box_detection2 = BoxDetectionSE2( metadata=self.metadata2, bounding_box_se2=BoundingBoxSE2( - center=StateSE2(x=5.0, y=5.0, yaw=0.0), + center=PoseSE2(x=5.0, y=5.0, yaw=0.0), length=1.0, width=0.5, ), @@ -271,7 +271,7 @@ def setUp(self): self.box_detection3 = BoxDetectionSE3( metadata=self.metadata3, bounding_box_se3=BoundingBoxSE3( - center=StateSE3(x=10.0, y=10.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), + center=PoseSE3(x=10.0, y=10.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), length=2.0, width=1.0, height=1.5, diff --git a/tests/unit/geometry/test_bounding_box.py b/tests/unit/geometry/test_bounding_box.py index 34c24fc6..7aba0821 100644 --- a/tests/unit/geometry/test_bounding_box.py +++ b/tests/unit/geometry/test_bounding_box.py @@ -4,7 +4,7 @@ import shapely.geometry as geom from py123d.common.utils.mixin import ArrayMixin -from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, Point2D, Point3D, StateSE2, StateSE3 +from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, Point2D, Point3D, PoseSE2, PoseSE3 from py123d.geometry.geometry_index import ( BoundingBoxSE2Index, BoundingBoxSE3Index, @@ -19,7 +19,7 @@ class TestBoundingBoxSE2(unittest.TestCase): def setUp(self): """Set up test fixtures.""" - self.center = StateSE2(1.0, 2.0, 0.5) + self.center = PoseSE2(1.0, 2.0, 0.5) self.length = 4.0 self.width = 2.0 self.bbox = BoundingBoxSE2(self.center, self.length, self.width) @@ -110,7 +110,7 @@ class TestBoundingBoxSE3(unittest.TestCase): def setUp(self): """Set up test fixtures.""" self.array = np.array([1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393, 4.0, 2.0, 1.5]) - self.center = StateSE3(1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393) + self.center = PoseSE3(1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393) self.length = 4.0 self.width = 2.0 self.height = 1.5 @@ -209,7 +209,7 @@ def test_array_assertions(self): def test_zero_dimensions(self): """Test bounding box with zero dimensions.""" - center = StateSE3(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + center = PoseSE3(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) bbox = BoundingBoxSE3(center, 0.0, 0.0, 0.0) self.assertEqual(bbox.length, 0.0) self.assertEqual(bbox.width, 0.0) diff --git a/tests/unit/geometry/test_polyline.py b/tests/unit/geometry/test_polyline.py index a2614410..1c5b24f7 100644 --- a/tests/unit/geometry/test_polyline.py +++ b/tests/unit/geometry/test_polyline.py @@ -3,7 +3,7 @@ import numpy as np import shapely.geometry as geom -from py123d.geometry import Point2D, Point3D, Polyline2D, Polyline3D, PolylineSE2, StateSE2 +from py123d.geometry import Point2D, Point3D, Polyline2D, Polyline3D, PolylineSE2, PoseSE2 class TestPolyline2D(unittest.TestCase): @@ -108,7 +108,7 @@ def test_project_statese2(self): coords = [(0.0, 0.0), (2.0, 0.0)] linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) - state = StateSE2(1.0, 1.0, 0.0) + state = PoseSE2(1.0, 1.0, 0.0) distance = polyline.project(state) self.assertEqual(distance, 1.0) @@ -154,7 +154,7 @@ def test_from_array_invalid_shape(self): def test_from_discrete_se2(self): """Test creating PolylineSE2 from discrete SE2 states.""" - states = [StateSE2(0.0, 0.0, 0.0), StateSE2(1.0, 0.0, 0.0), StateSE2(2.0, 0.0, 0.0)] + states = [PoseSE2(0.0, 0.0, 0.0), PoseSE2(1.0, 0.0, 0.0), PoseSE2(2.0, 0.0, 0.0)] polyline = PolylineSE2.from_discrete_se2(states) self.assertIsInstance(polyline, PolylineSE2) self.assertEqual(polyline.array.shape, (3, 3)) @@ -170,7 +170,7 @@ def test_interpolate_single_distance(self): array = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) polyline = PolylineSE2.from_array(array) state = polyline.interpolate(1.0) - self.assertIsInstance(state, StateSE2) + self.assertIsInstance(state, PoseSE2) self.assertEqual(state.x, 1.0) self.assertEqual(state.y, 0.0) @@ -187,7 +187,7 @@ def test_interpolate_normalized(self): array = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) polyline = PolylineSE2.from_array(array) state = polyline.interpolate(0.5, normalized=True) - self.assertIsInstance(state, StateSE2) + self.assertIsInstance(state, PoseSE2) self.assertEqual(state.x, 1.0) self.assertEqual(state.y, 0.0) @@ -203,7 +203,7 @@ def test_project_statese2(self): """Test projecting StateSE2 onto SE2 polyline.""" array = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) polyline = PolylineSE2.from_array(array) - state = StateSE2(1.0, 1.0, 0.0) + state = PoseSE2(1.0, 1.0, 0.0) distance = polyline.project(state) self.assertEqual(distance, 1.0) diff --git a/tests/unit/geometry/transform/test_transform_consistency.py b/tests/unit/geometry/transform/test_transform_consistency.py index a798fabd..8bc903d1 100644 --- a/tests/unit/geometry/transform/test_transform_consistency.py +++ b/tests/unit/geometry/transform/test_transform_consistency.py @@ -3,8 +3,8 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import EulerStateSE3, StateSE2, Vector2D, Vector3D -from py123d.geometry.geometry_index import EulerStateSE3Index, Point2DIndex, Point3DIndex, StateSE2Index +from py123d.geometry import EulerStateSE3, PoseSE2, Vector2D, Vector3D +from py123d.geometry.geometry_index import EulerStateSE3Index, Point2DIndex, Point3DIndex, PoseSE2Index from py123d.geometry.transform.transform_euler_se3 import ( convert_absolute_to_relative_euler_se3_array, convert_absolute_to_relative_points_3d_array, @@ -40,8 +40,8 @@ def setUp(self): def _get_random_se2_array(self, size: int) -> npt.NDArray[np.float64]: """Generate a random SE2 pose""" - random_se2_array = np.random.uniform(-self.max_pose_xyz, self.max_pose_xyz, (size, len(StateSE2Index))) - random_se2_array[:, StateSE2Index.YAW] = np.random.uniform(-np.pi, np.pi, size) # yaw angles + random_se2_array = np.random.uniform(-self.max_pose_xyz, self.max_pose_xyz, (size, len(PoseSE2Index))) + random_se2_array[:, PoseSE2Index.YAW] = np.random.uniform(-np.pi, np.pi, size) # yaw angles return random_se2_array def _get_random_se3_array(self, size: int) -> npt.NDArray[np.float64]: @@ -60,7 +60,7 @@ def test_se2_absolute_relative_conversion_consistency(self) -> None: """Test that converting absolute->relative->absolute returns original poses""" for _ in range(self.num_consistency_tests): # Generate random reference pose - reference = StateSE2.from_array(self._get_random_se2_array(1)[0]) + reference = PoseSE2.from_array(self._get_random_se2_array(1)[0]) # Generate random absolute poses num_poses = np.random.randint(self.min_random_poses, self.max_random_poses) @@ -76,11 +76,11 @@ def test_se2_points_absolute_relative_conversion_consistency(self) -> None: """Test that converting absolute->relative->absolute returns original points""" for _ in range(self.num_consistency_tests): # Generate random reference pose - reference = StateSE2.from_array(self._get_random_se2_array(1)[0]) + reference = PoseSE2.from_array(self._get_random_se2_array(1)[0]) # Generate random absolute points num_points = np.random.randint(self.min_random_poses, self.max_random_poses) - absolute_points = self._get_random_se2_array(num_points)[:, StateSE2Index.XY] + absolute_points = self._get_random_se2_array(num_points)[:, PoseSE2Index.XY] # Convert absolute -> relative -> absolute relative_points = convert_absolute_to_relative_point_2d_array(reference, absolute_points) @@ -92,7 +92,7 @@ def test_se2_points_consistency(self) -> None: """Test whether SE2 point and pose conversions are consistent""" for _ in range(self.num_consistency_tests): # Generate random reference pose - reference = StateSE2.from_array(self._get_random_se2_array(1)[0]) + reference = PoseSE2.from_array(self._get_random_se2_array(1)[0]) # Generate random absolute points num_poses = np.random.randint(self.min_random_poses, self.max_random_poses) @@ -100,24 +100,22 @@ def test_se2_points_consistency(self) -> None: # Convert absolute -> relative -> absolute relative_se2 = convert_absolute_to_relative_se2_array(reference, absolute_se2) - relative_points = convert_absolute_to_relative_point_2d_array( - reference, absolute_se2[..., StateSE2Index.XY] - ) + relative_points = convert_absolute_to_relative_point_2d_array(reference, absolute_se2[..., PoseSE2Index.XY]) np.testing.assert_array_almost_equal( - relative_se2[..., StateSE2Index.XY], relative_points, decimal=self.decimal + relative_se2[..., PoseSE2Index.XY], relative_points, decimal=self.decimal ) recovered_absolute_se2 = convert_relative_to_absolute_se2_array(reference, relative_se2) absolute_points = convert_relative_to_absolute_point_2d_array(reference, relative_points) np.testing.assert_array_almost_equal( - recovered_absolute_se2[..., StateSE2Index.XY], absolute_points, decimal=self.decimal + recovered_absolute_se2[..., PoseSE2Index.XY], absolute_points, decimal=self.decimal ) def test_se2_translation_consistency(self) -> None: """Test that SE2 translations are consistent between different methods""" for _ in range(self.num_consistency_tests): # Generate random pose - pose = StateSE2.from_array(self._get_random_se2_array(1)[0]) + pose = PoseSE2.from_array(self._get_random_se2_array(1)[0]) # Generate random distances dx = np.random.uniform(-10.0, 10.0) @@ -217,7 +215,7 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: for _ in range(self.num_consistency_tests): # Create equivalent SE2 and SE3 poses (SE3 with z=0 and no rotations except yaw) - pose_se2 = StateSE2.from_array(self._get_random_se2_array(1)[0]) + pose_se2 = PoseSE2.from_array(self._get_random_se2_array(1)[0]) pose_se3 = EulerStateSE3.from_array( np.array([pose_se2.x, pose_se2.y, 0.0, 0.0, 0.0, pose_se2.yaw], dtype=np.float64) ) @@ -228,12 +226,12 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: translated_se3_x = translate_euler_se3_along_x(pose_se3, dx) np.testing.assert_array_almost_equal( - translated_se2_x.array[StateSE2Index.XY], + translated_se2_x.array[PoseSE2Index.XY], translated_se3_x.array[EulerStateSE3Index.XY], decimal=self.decimal, ) np.testing.assert_almost_equal( - translated_se2_x.array[StateSE2Index.YAW], + translated_se2_x.array[PoseSE2Index.YAW], translated_se3_x.array[EulerStateSE3Index.YAW], decimal=self.decimal, ) @@ -244,12 +242,12 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: translated_se3_y = translate_euler_se3_along_y(pose_se3, dy) np.testing.assert_array_almost_equal( - translated_se2_y.array[StateSE2Index.XY], + translated_se2_y.array[PoseSE2Index.XY], translated_se3_y.array[EulerStateSE3Index.XY], decimal=self.decimal, ) np.testing.assert_almost_equal( - translated_se2_y.array[StateSE2Index.YAW], + translated_se2_y.array[PoseSE2Index.YAW], translated_se3_y.array[EulerStateSE3Index.YAW], decimal=self.decimal, ) @@ -260,12 +258,12 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: translated_se2_xy = translate_se2_along_body_frame(pose_se2, Vector2D(dx, dy)) translated_se3_xy = translate_euler_se3_along_body_frame(pose_se3, Vector3D(dx, dy, 0.0)) np.testing.assert_array_almost_equal( - translated_se2_xy.array[StateSE2Index.XY], + translated_se2_xy.array[PoseSE2Index.XY], translated_se3_xy.array[EulerStateSE3Index.XY], decimal=self.decimal, ) np.testing.assert_almost_equal( - translated_se2_xy.array[StateSE2Index.YAW], + translated_se2_xy.array[PoseSE2Index.YAW], translated_se3_xy.array[EulerStateSE3Index.YAW], decimal=self.decimal, ) @@ -278,7 +276,7 @@ def test_se2_se3_point_conversion_consistency(self) -> None: y = np.random.uniform(-10.0, 10.0) yaw = np.random.uniform(-np.pi, np.pi) - reference_se2 = StateSE2.from_array(np.array([x, y, yaw], dtype=np.float64)) + reference_se2 = PoseSE2.from_array(np.array([x, y, yaw], dtype=np.float64)) reference_se3 = EulerStateSE3.from_array(np.array([x, y, 0.0, 0.0, 0.0, yaw], dtype=np.float64)) # Generate 2D points and embed them in 3D with z=0 @@ -316,15 +314,15 @@ def test_se2_se3_pose_conversion_consistency(self) -> None: y = np.random.uniform(-10.0, 10.0) yaw = np.random.uniform(-np.pi, np.pi) - reference_se2 = StateSE2.from_array(np.array([x, y, yaw], dtype=np.float64)) + reference_se2 = PoseSE2.from_array(np.array([x, y, yaw], dtype=np.float64)) reference_se3 = EulerStateSE3.from_array(np.array([x, y, 0.0, 0.0, 0.0, yaw], dtype=np.float64)) # Generate 2D poses and embed them in 3D with z=0 and zero roll/pitch num_poses = np.random.randint(1, 8) pose_2d = self._get_random_se2_array(num_poses) pose_3d = np.zeros((num_poses, len(EulerStateSE3Index)), dtype=np.float64) - pose_3d[:, EulerStateSE3Index.XY] = pose_2d[:, StateSE2Index.XY] - pose_3d[:, EulerStateSE3Index.YAW] = pose_2d[:, StateSE2Index.YAW] + pose_3d[:, EulerStateSE3Index.XY] = pose_2d[:, PoseSE2Index.XY] + pose_3d[:, EulerStateSE3Index.YAW] = pose_2d[:, PoseSE2Index.YAW] # Convert using SE2 functions relative_se2 = convert_absolute_to_relative_se2_array(reference_se2, pose_2d) @@ -336,19 +334,19 @@ def test_se2_se3_pose_conversion_consistency(self) -> None: # Check that SE2 and SE3 conversions are consistent for the x,y components np.testing.assert_array_almost_equal( - relative_se2[:, StateSE2Index.XY], relative_se3[:, EulerStateSE3Index.XY], decimal=self.decimal + relative_se2[:, PoseSE2Index.XY], relative_se3[:, EulerStateSE3Index.XY], decimal=self.decimal ) np.testing.assert_array_almost_equal( - absolute_se2_recovered[:, StateSE2Index.XY], + absolute_se2_recovered[:, PoseSE2Index.XY], absolute_se3_recovered[:, EulerStateSE3Index.XY], decimal=self.decimal, ) # Check that SE2 and SE3 conversions are consistent for the yaw component np.testing.assert_array_almost_equal( - relative_se2[:, StateSE2Index.YAW], relative_se3[:, EulerStateSE3Index.YAW], decimal=self.decimal + relative_se2[:, PoseSE2Index.YAW], relative_se3[:, EulerStateSE3Index.YAW], decimal=self.decimal ) np.testing.assert_array_almost_equal( - absolute_se2_recovered[:, StateSE2Index.YAW], + absolute_se2_recovered[:, PoseSE2Index.YAW], absolute_se3_recovered[:, EulerStateSE3Index.YAW], decimal=self.decimal, ) @@ -379,7 +377,7 @@ def test_se2_array_translation_consistency(self) -> None: # Translate each pose individually result_individual = np.zeros_like(poses_array) for i in range(num_poses): - pose = StateSE2.from_array(poses_array[i]) + pose = PoseSE2.from_array(poses_array[i]) translated = translate_se2_along_body_frame(pose, translation) result_individual[i] = translated.array @@ -387,17 +385,17 @@ def test_se2_array_translation_consistency(self) -> None: def test_transform_empty_arrays(self) -> None: """Test that transform functions handle empty arrays correctly""" - reference_se2 = StateSE2.from_array(np.array([1.0, 2.0, np.pi / 4], dtype=np.float64)) + reference_se2 = PoseSE2.from_array(np.array([1.0, 2.0, np.pi / 4], dtype=np.float64)) reference_se3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.1, 0.2, 0.3], dtype=np.float64)) # Test SE2 empty arrays - empty_se2_poses = np.array([], dtype=np.float64).reshape(0, len(StateSE2Index)) + empty_se2_poses = np.array([], dtype=np.float64).reshape(0, len(PoseSE2Index)) empty_2d_points = np.array([], dtype=np.float64).reshape(0, len(Point2DIndex)) result_se2_poses = convert_absolute_to_relative_se2_array(reference_se2, empty_se2_poses) result_2d_points = convert_absolute_to_relative_point_2d_array(reference_se2, empty_2d_points) - self.assertEqual(result_se2_poses.shape, (0, len(StateSE2Index))) + self.assertEqual(result_se2_poses.shape, (0, len(PoseSE2Index))) self.assertEqual(result_2d_points.shape, (0, len(Point2DIndex))) # Test SE3 empty arrays @@ -413,14 +411,14 @@ def test_transform_empty_arrays(self) -> None: def test_transform_identity_operations(self) -> None: """Test that transforms with identity reference frames work correctly""" # Identity SE2 pose - identity_se2 = StateSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) + identity_se2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) identity_se3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) for _ in range(self.num_consistency_tests): # Test SE2 identity transforms num_poses = np.random.randint(1, 10) se2_poses = self._get_random_se2_array(num_poses) - se2_points = se2_poses[:, StateSE2Index.XY] + se2_points = se2_poses[:, PoseSE2Index.XY] relative_se2_poses = convert_absolute_to_relative_se2_array(identity_se2, se2_poses) relative_se2_points = convert_absolute_to_relative_point_2d_array(identity_se2, se2_points) @@ -449,7 +447,7 @@ def test_transform_large_rotations(self) -> None: large_yaw_se2 = np.random.uniform(-4 * np.pi, 4 * np.pi) large_euler_se3 = np.random.uniform(-4 * np.pi, 4 * np.pi, 3) - reference_se2 = StateSE2.from_array(np.array([0.0, 0.0, large_yaw_se2], dtype=np.float64)) + reference_se2 = PoseSE2.from_array(np.array([0.0, 0.0, large_yaw_se2], dtype=np.float64)) reference_se3 = EulerStateSE3.from_array( np.array([0.0, 0.0, 0.0, large_euler_se3[0], large_euler_se3[1], large_euler_se3[2]], dtype=np.float64) ) @@ -457,7 +455,7 @@ def test_transform_large_rotations(self) -> None: # Generate test poses/points test_se2_poses = self._get_random_se2_array(5) test_se3_poses = self._get_random_se3_array(5) - test_2d_points = test_se2_poses[:, StateSE2Index.XY] + test_2d_points = test_se2_poses[:, PoseSE2Index.XY] test_3d_points = test_se3_poses[:, EulerStateSE3Index.XYZ] # Test round-trip conversions should still work @@ -475,8 +473,8 @@ def test_transform_large_rotations(self) -> None: # Check consistency (allowing for angle wrapping) np.testing.assert_array_almost_equal( - test_se2_poses[:, StateSE2Index.XY], - recovered_se2[:, StateSE2Index.XY], + test_se2_poses[:, PoseSE2Index.XY], + recovered_se2[:, PoseSE2Index.XY], decimal=self.decimal, ) np.testing.assert_array_almost_equal( diff --git a/tests/unit/geometry/transform/test_transform_se2.py b/tests/unit/geometry/transform/test_transform_se2.py index 60af633e..14e6e4b6 100644 --- a/tests/unit/geometry/transform/test_transform_se2.py +++ b/tests/unit/geometry/transform/test_transform_se2.py @@ -3,7 +3,7 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import StateSE2, Vector2D +from py123d.geometry import PoseSE2, Vector2D from py123d.geometry.transform.transform_se2 import ( convert_absolute_to_relative_point_2d_array, convert_absolute_to_relative_se2_array, @@ -23,75 +23,75 @@ def setUp(self): def test_translate_se2_along_x(self) -> None: """Tests translating a SE2 state along the X-axis.""" - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) distance: float = 1.0 - result: StateSE2 = translate_se2_along_x(pose, distance) - expected: StateSE2 = StateSE2.from_array(np.array([1.0, 0.0, 0.0], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_x(pose, distance) + expected: PoseSE2 = PoseSE2.from_array(np.array([1.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_x_negative(self) -> None: """Tests translating a SE2 state along the X-axis in the negative direction.""" - pose: StateSE2 = StateSE2.from_array(np.array([1.0, 2.0, 0.0], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([1.0, 2.0, 0.0], dtype=np.float64)) distance: float = -0.5 - result: StateSE2 = translate_se2_along_x(pose, distance) - expected: StateSE2 = StateSE2.from_array(np.array([0.5, 2.0, 0.0], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_x(pose, distance) + expected: PoseSE2 = PoseSE2.from_array(np.array([0.5, 2.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_x_with_rotation(self) -> None: """Tests translating a SE2 state along the X-axis with 90 degree rotation.""" - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) distance: float = 1.0 - result: StateSE2 = translate_se2_along_x(pose, distance) - expected: StateSE2 = StateSE2.from_array(np.array([0.0, 1.0, np.pi / 2], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_x(pose, distance) + expected: PoseSE2 = PoseSE2.from_array(np.array([0.0, 1.0, np.pi / 2], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_y(self) -> None: """Tests translating a SE2 state along the Y-axis.""" - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) distance: float = 1.0 - result: StateSE2 = translate_se2_along_y(pose, distance) - expected: StateSE2 = StateSE2.from_array(np.array([0.0, 1.0, 0.0], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_y(pose, distance) + expected: PoseSE2 = PoseSE2.from_array(np.array([0.0, 1.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_y_negative(self) -> None: """Tests translating a SE2 state along the Y-axis in the negative direction.""" - pose: StateSE2 = StateSE2.from_array(np.array([1.0, 2.0, 0.0], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([1.0, 2.0, 0.0], dtype=np.float64)) distance: float = -1.5 - result: StateSE2 = translate_se2_along_y(pose, distance) - expected: StateSE2 = StateSE2.from_array(np.array([1.0, 0.5, 0.0], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_y(pose, distance) + expected: PoseSE2 = PoseSE2.from_array(np.array([1.0, 0.5, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_y_with_rotation(self) -> None: """Tests translating a SE2 state along the Y-axis with -90 degree rotation.""" - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, -np.pi / 2], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, -np.pi / 2], dtype=np.float64)) distance: float = 2.0 - result: StateSE2 = translate_se2_along_y(pose, distance) - expected: StateSE2 = StateSE2.from_array(np.array([2.0, 0.0, -np.pi / 2], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_y(pose, distance) + expected: PoseSE2 = PoseSE2.from_array(np.array([2.0, 0.0, -np.pi / 2], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_body_frame_forward(self) -> None: """Tests translating a SE2 state along the body frame forward direction, with 90 degree rotation.""" # Move 1 unit forward in the direction of yaw (pi/2 = 90 degrees = +Y direction) - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) vector: Vector2D = Vector2D(1.0, 0.0) - result: StateSE2 = translate_se2_along_body_frame(pose, vector) - expected: StateSE2 = StateSE2.from_array(np.array([0.0, 1.0, np.pi / 2], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_body_frame(pose, vector) + expected: PoseSE2 = PoseSE2.from_array(np.array([0.0, 1.0, np.pi / 2], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_body_frame_backward(self) -> None: """Tests translating a SE2 state along the body frame backward direction.""" - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) vector: Vector2D = Vector2D(-1.0, 0.0) - result: StateSE2 = translate_se2_along_body_frame(pose, vector) - expected: StateSE2 = StateSE2.from_array(np.array([-1.0, 0.0, 0.0], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_body_frame(pose, vector) + expected: PoseSE2 = PoseSE2.from_array(np.array([-1.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_body_frame_diagonal(self) -> None: """Tests translating a SE2 state along the body frame diagonal direction.""" - pose: StateSE2 = StateSE2.from_array(np.array([1.0, 0.0, np.deg2rad(45)], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([1.0, 0.0, np.deg2rad(45)], dtype=np.float64)) vector: Vector2D = Vector2D(1.0, 0.0) - result: StateSE2 = translate_se2_along_body_frame(pose, vector) - expected: StateSE2 = StateSE2.from_array( + result: PoseSE2 = translate_se2_along_body_frame(pose, vector) + expected: PoseSE2 = PoseSE2.from_array( np.array([1.0 + np.sqrt(2.0) / 2, 0.0 + np.sqrt(2.0) / 2, np.deg2rad(45)], dtype=np.float64) ) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) @@ -99,19 +99,19 @@ def test_translate_se2_along_body_frame_diagonal(self) -> None: def test_translate_se2_along_body_frame_lateral(self) -> None: """Tests translating a SE2 state along the body frame lateral direction.""" # Move 1 unit to the right (positive y in body frame) - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) vector: Vector2D = Vector2D(0.0, 1.0) - result: StateSE2 = translate_se2_along_body_frame(pose, vector) - expected: StateSE2 = StateSE2.from_array(np.array([0.0, 1.0, 0.0], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_body_frame(pose, vector) + expected: PoseSE2 = PoseSE2.from_array(np.array([0.0, 1.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_along_body_frame_lateral_with_rotation(self) -> None: """Tests translating a SE2 state along the body frame lateral direction with 90 degree rotation.""" # Move 1 unit to the right when facing 90 degrees - pose: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) + pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) vector: Vector2D = Vector2D(0.0, 1.0) - result: StateSE2 = translate_se2_along_body_frame(pose, vector) - expected: StateSE2 = StateSE2.from_array(np.array([-1.0, 0.0, np.pi / 2], dtype=np.float64)) + result: PoseSE2 = translate_se2_along_body_frame(pose, vector) + expected: PoseSE2 = PoseSE2.from_array(np.array([-1.0, 0.0, np.pi / 2], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) def test_translate_se2_array_along_body_frame_single_distance(self) -> None: @@ -140,7 +140,7 @@ def test_translate_se2_array_along_body_frame_lateral(self) -> None: def test_convert_absolute_to_relative_se2_array(self) -> None: """Tests converting absolute SE2 poses to relative SE2 poses.""" - origin: StateSE2 = StateSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) + origin: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) absolute_poses: npt.NDArray[np.float64] = np.array([[2.0, 2.0, 0.0], [0.0, 1.0, np.pi / 2]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_se2_array(origin, absolute_poses) expected: npt.NDArray[np.float64] = np.array([[1.0, 1.0, 0.0], [-1.0, 0.0, np.pi / 2]], dtype=np.float64) @@ -148,7 +148,7 @@ def test_convert_absolute_to_relative_se2_array(self) -> None: def test_convert_absolute_to_relative_se2_array_with_rotation(self) -> None: """Tests converting absolute SE2 poses to relative SE2 poses with 90 degree rotation.""" - reference: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) absolute_poses: npt.NDArray[np.float64] = np.array([[1.0, 0.0, np.pi / 2]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_se2_array(reference, absolute_poses) expected: npt.NDArray[np.float64] = np.array([[0.0, -1.0, 0.0]], dtype=np.float64) @@ -156,7 +156,7 @@ def test_convert_absolute_to_relative_se2_array_with_rotation(self) -> None: def test_convert_absolute_to_relative_se2_array_identity(self) -> None: """Tests converting absolute SE2 poses to relative SE2 poses with identity transformation.""" - reference: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) absolute_poses: npt.NDArray[np.float64] = np.array([[1.0, 2.0, np.pi / 4]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_se2_array(reference, absolute_poses) expected: npt.NDArray[np.float64] = np.array([[1.0, 2.0, np.pi / 4]], dtype=np.float64) @@ -164,7 +164,7 @@ def test_convert_absolute_to_relative_se2_array_identity(self) -> None: def test_convert_relative_to_absolute_se2_array(self) -> None: """Tests converting relative SE2 poses to absolute SE2 poses.""" - reference: StateSE2 = StateSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) relative_poses: npt.NDArray[np.float64] = np.array([[1.0, 1.0, 0.0], [-1.0, 0.0, np.pi / 2]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_relative_to_absolute_se2_array(reference, relative_poses) expected: npt.NDArray[np.float64] = np.array([[2.0, 2.0, 0.0], [0.0, 1.0, np.pi / 2]], dtype=np.float64) @@ -172,7 +172,7 @@ def test_convert_relative_to_absolute_se2_array(self) -> None: def test_convert_relative_to_absolute_se2_array_with_rotation(self) -> None: """Tests converting relative SE2 poses to absolute SE2 poses with rotation.""" - reference: StateSE2 = StateSE2.from_array(np.array([1.0, 0.0, np.pi / 2], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 0.0, np.pi / 2], dtype=np.float64)) relative_poses: npt.NDArray[np.float64] = np.array([[1.0, 0.0, 0.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_relative_to_absolute_se2_array(reference, relative_poses) expected: npt.NDArray[np.float64] = np.array([[1.0, 1.0, np.pi / 2]], dtype=np.float64) @@ -180,7 +180,7 @@ def test_convert_relative_to_absolute_se2_array_with_rotation(self) -> None: def test_convert_absolute_to_relative_point_2d_array(self) -> None: """Tests converting absolute 2D points to relative 2D points.""" - reference: StateSE2 = StateSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([[2.0, 2.0], [0.0, 1.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_point_2d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 1.0], [-1.0, 0.0]], dtype=np.float64) @@ -188,7 +188,7 @@ def test_convert_absolute_to_relative_point_2d_array(self) -> None: def test_convert_absolute_to_relative_point_2d_array_with_rotation(self) -> None: """Tests converting absolute 2D points to relative 2D points with 90 degree rotation.""" - reference: StateSE2 = StateSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_point_2d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 0.0], [0.0, -1.0]], dtype=np.float64) @@ -196,7 +196,7 @@ def test_convert_absolute_to_relative_point_2d_array_with_rotation(self) -> None def test_convert_absolute_to_relative_point_2d_array_empty(self) -> None: """Tests converting an empty array of absolute 2D points to relative 2D points.""" - reference: StateSE2 = StateSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([], dtype=np.float64).reshape(0, 2) result: npt.NDArray[np.float64] = convert_absolute_to_relative_point_2d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([], dtype=np.float64).reshape(0, 2) @@ -204,7 +204,7 @@ def test_convert_absolute_to_relative_point_2d_array_empty(self) -> None: def test_convert_relative_to_absolute_point_2d_array(self) -> None: """Tests converting relative 2D points to absolute 2D points.""" - reference: StateSE2 = StateSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) relative_points: npt.NDArray[np.float64] = np.array([[1.0, 1.0], [-1.0, 0.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_relative_to_absolute_point_2d_array(reference, relative_points) expected: npt.NDArray[np.float64] = np.array([[2.0, 2.0], [0.0, 1.0]], dtype=np.float64) @@ -212,7 +212,7 @@ def test_convert_relative_to_absolute_point_2d_array(self) -> None: def test_convert_relative_to_absolute_point_2d_array_with_rotation(self) -> None: """Tests converting relative 2D points to absolute 2D points with 90 degree rotation.""" - reference: StateSE2 = StateSE2.from_array(np.array([1.0, 0.0, np.pi / 2], dtype=np.float64)) + reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 0.0, np.pi / 2], dtype=np.float64)) relative_points: npt.NDArray[np.float64] = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_relative_to_absolute_point_2d_array(reference, relative_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 1.0], [0.0, 0.0]], dtype=np.float64) diff --git a/tests/unit/geometry/transform/test_transform_se3.py b/tests/unit/geometry/transform/test_transform_se3.py index 44e86504..4f6916f3 100644 --- a/tests/unit/geometry/transform/test_transform_se3.py +++ b/tests/unit/geometry/transform/test_transform_se3.py @@ -4,7 +4,7 @@ import numpy.typing as npt import py123d.geometry.transform.transform_euler_se3 as euler_transform_se3 -from py123d.geometry import EulerStateSE3, EulerStateSE3Index, Point3D, StateSE3, StateSE3Index +from py123d.geometry import EulerStateSE3, EulerStateSE3Index, Point3D, PoseSE3, PoseSE3Index from py123d.geometry.transform.transform_se3 import ( convert_absolute_to_relative_points_3d_array, convert_absolute_to_relative_se3_array, @@ -52,9 +52,9 @@ def setUp(self): yaw=np.deg2rad(90), ) - quat_se3_a: StateSE3 = euler_se3_a.state_se3 - quat_se3_b: StateSE3 = euler_se3_b.state_se3 - quat_se3_c: StateSE3 = euler_se3_c.state_se3 + quat_se3_a: PoseSE3 = euler_se3_a.pose_se3 + quat_se3_b: PoseSE3 = euler_se3_b.pose_se3 + quat_se3_c: PoseSE3 = euler_se3_c.pose_se3 self.euler_se3 = [euler_se3_a, euler_se3_b, euler_se3_c] self.quat_se3 = [quat_se3_a, quat_se3_b, quat_se3_c] @@ -77,9 +77,9 @@ def _convert_euler_se3_array_to_quat_se3_array( self, euler_se3_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Convert an array of SE3 poses from Euler angles to Quaternion representation""" - quat_se3_array = np.zeros((euler_se3_array.shape[0], len(StateSE3Index)), dtype=np.float64) - quat_se3_array[:, StateSE3Index.XYZ] = euler_se3_array[:, EulerStateSE3Index.XYZ] - quat_se3_array[:, StateSE3Index.QUATERNION] = get_quaternion_array_from_euler_array( + quat_se3_array = np.zeros((euler_se3_array.shape[0], len(PoseSE3Index)), dtype=np.float64) + quat_se3_array[:, PoseSE3Index.XYZ] = euler_se3_array[:, EulerStateSE3Index.XYZ] + quat_se3_array[:, PoseSE3Index.QUATERNION] = get_quaternion_array_from_euler_array( euler_se3_array[:, EulerStateSE3Index.EULER_ANGLES] ) return quat_se3_array @@ -111,11 +111,11 @@ def test_random_sanity(self): np.testing.assert_allclose( random_euler_se3_array[:, EulerStateSE3Index.XYZ], - random_quat_se3_array[:, StateSE3Index.XYZ], + random_quat_se3_array[:, PoseSE3Index.XYZ], atol=1e-6, ) quat_rotation_matrices = get_rotation_matrices_from_quaternion_array( - random_quat_se3_array[:, StateSE3Index.QUATERNION] + random_quat_se3_array[:, PoseSE3Index.QUATERNION] ) euler_rotation_matrices = get_rotation_matrices_from_euler_array( random_euler_se3_array[:, EulerStateSE3Index.EULER_ANGLES] @@ -143,11 +143,11 @@ def test_convert_absolute_to_relative_se3_array(self): euler_se3, random_euler_se3_array ) np.testing.assert_allclose( - rel_se3_euler[..., EulerStateSE3Index.XYZ], rel_se3_quat[..., StateSE3Index.XYZ], atol=1e-6 + rel_se3_euler[..., EulerStateSE3Index.XYZ], rel_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6 ) # We compare rotation matrices to avoid issues with quaternion sign ambiguity quat_rotation_matrices = get_rotation_matrices_from_quaternion_array( - rel_se3_quat[..., StateSE3Index.QUATERNION] + rel_se3_quat[..., PoseSE3Index.QUATERNION] ) euler_rotation_matrices = get_rotation_matrices_from_euler_array( rel_se3_euler[..., EulerStateSE3Index.EULER_ANGLES] @@ -175,12 +175,12 @@ def test_convert_relative_to_absolute_se3_array(self): euler_se3, random_euler_se3_array ) np.testing.assert_allclose( - abs_se3_euler[..., EulerStateSE3Index.XYZ], abs_se3_quat[..., StateSE3Index.XYZ], atol=1e-6 + abs_se3_euler[..., EulerStateSE3Index.XYZ], abs_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6 ) # We compare rotation matrices to avoid issues with quaternion sign ambiguity quat_rotation_matrices = get_rotation_matrices_from_quaternion_array( - abs_se3_quat[..., StateSE3Index.QUATERNION] + abs_se3_quat[..., PoseSE3Index.QUATERNION] ) euler_rotation_matrices = get_rotation_matrices_from_euler_array( abs_se3_euler[..., EulerStateSE3Index.EULER_ANGLES] @@ -192,11 +192,11 @@ def test_convert_se3_array_between_origins(self): for _ in range(10): random_quat_se3_array = self._get_random_quat_se3_array(np.random.randint(1, 10)) - from_se3 = StateSE3.from_array(self._get_random_quat_se3_array(1)[0]) - to_se3 = StateSE3.from_array(self._get_random_quat_se3_array(1)[0]) - identity_se3_array = np.zeros(len(StateSE3Index), dtype=np.float64) - identity_se3_array[StateSE3Index.QW] = 1.0 - identity_se3 = StateSE3.from_array(identity_se3_array) + from_se3 = PoseSE3.from_array(self._get_random_quat_se3_array(1)[0]) + to_se3 = PoseSE3.from_array(self._get_random_quat_se3_array(1)[0]) + identity_se3_array = np.zeros(len(PoseSE3Index), dtype=np.float64) + identity_se3_array[PoseSE3Index.QW] = 1.0 + identity_se3 = PoseSE3.from_array(identity_se3_array) # Check if consistent with absolute-relative-absolute conversion converted_se3_quat = convert_se3_array_between_origins(from_se3, to_se3, random_quat_se3_array) @@ -205,32 +205,32 @@ def test_convert_se3_array_between_origins(self): rel_to_se3_quat = convert_absolute_to_relative_se3_array(to_se3, abs_from_se3_quat) np.testing.assert_allclose( - converted_se3_quat[..., StateSE3Index.XYZ], - rel_to_se3_quat[..., StateSE3Index.XYZ], + converted_se3_quat[..., PoseSE3Index.XYZ], + rel_to_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6, ) np.testing.assert_allclose( - converted_se3_quat[..., StateSE3Index.QUATERNION], - rel_to_se3_quat[..., StateSE3Index.QUATERNION], + converted_se3_quat[..., PoseSE3Index.QUATERNION], + rel_to_se3_quat[..., PoseSE3Index.QUATERNION], atol=1e-6, ) # Check if consistent with absolute conversion to identity origin absolute_se3_quat = convert_se3_array_between_origins(from_se3, identity_se3, random_quat_se3_array) np.testing.assert_allclose( - absolute_se3_quat[..., StateSE3Index.XYZ], - abs_from_se3_quat[..., StateSE3Index.XYZ], + absolute_se3_quat[..., PoseSE3Index.XYZ], + abs_from_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6, ) def test_convert_points_3d_array_between_origins(self): random_points_3d = np.random.rand(10, 3) for _ in range(10): - from_se3 = StateSE3.from_array(self._get_random_quat_se3_array(1)[0]) - to_se3 = StateSE3.from_array(self._get_random_quat_se3_array(1)[0]) - identity_se3_array = np.zeros(len(StateSE3Index), dtype=np.float64) - identity_se3_array[StateSE3Index.QW] = 1.0 - identity_se3 = StateSE3.from_array(identity_se3_array) + from_se3 = PoseSE3.from_array(self._get_random_quat_se3_array(1)[0]) + to_se3 = PoseSE3.from_array(self._get_random_quat_se3_array(1)[0]) + identity_se3_array = np.zeros(len(PoseSE3Index), dtype=np.float64) + identity_se3_array[PoseSE3Index.QW] = 1.0 + identity_se3 = PoseSE3.from_array(identity_se3_array) # Check if consistent with absolute-relative-absolute conversion converted_points_quat = convert_points_3d_array_between_origins(from_se3, to_se3, random_points_3d) @@ -239,12 +239,12 @@ def test_convert_points_3d_array_between_origins(self): np.testing.assert_allclose(converted_points_quat, rel_to_se3_quat, atol=1e-6) # Check if consistent with se3 array conversion - random_se3_poses = np.zeros((random_points_3d.shape[0], len(StateSE3Index)), dtype=np.float64) - random_se3_poses[:, StateSE3Index.XYZ] = random_points_3d - random_se3_poses[:, StateSE3Index.QUATERNION] = np.array([1.0, 0.0, 0.0, 0.0]) # Identity rotation + random_se3_poses = np.zeros((random_points_3d.shape[0], len(PoseSE3Index)), dtype=np.float64) + random_se3_poses[:, PoseSE3Index.XYZ] = random_points_3d + random_se3_poses[:, PoseSE3Index.QUATERNION] = np.array([1.0, 0.0, 0.0, 0.0]) # Identity rotation converted_se3_quat_poses = convert_se3_array_between_origins(from_se3, to_se3, random_se3_poses) np.testing.assert_allclose( - converted_se3_quat_poses[:, StateSE3Index.XYZ], + converted_se3_quat_poses[:, PoseSE3Index.XYZ], converted_points_quat, atol=1e-6, ) @@ -252,8 +252,8 @@ def test_convert_points_3d_array_between_origins(self): # Check if consistent with absolute conversion to identity origin absolute_se3_quat = convert_points_3d_array_between_origins(from_se3, identity_se3, random_points_3d) np.testing.assert_allclose( - absolute_se3_quat[..., StateSE3Index.XYZ], - abs_from_se3_quat[..., StateSE3Index.XYZ], + absolute_se3_quat[..., PoseSE3Index.XYZ], + abs_from_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6, ) diff --git a/tests/unit/geometry/utils/test_bounding_box_utils.py b/tests/unit/geometry/utils/test_bounding_box_utils.py index 3b5718ca..4e160087 100644 --- a/tests/unit/geometry/utils/test_bounding_box_utils.py +++ b/tests/unit/geometry/utils/test_bounding_box_utils.py @@ -12,7 +12,7 @@ Point2DIndex, Point3DIndex, ) -from py123d.geometry.se import EulerStateSE3, StateSE3 +from py123d.geometry.pose import EulerStateSE3, PoseSE3 from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame from py123d.geometry.utils.bounding_box_utils import ( bbse2_array_to_corners_array, @@ -198,14 +198,14 @@ def test_bbse3_array_to_corners_array_one_dim(self): def test_bbse3_array_to_corners_array_one_dim_rotation(self): for _ in range(self._num_consistency_checks): - se3_state = EulerStateSE3.from_array(self._get_random_euler_se3_array(1)[0]).state_se3 + se3_state = EulerStateSE3.from_array(self._get_random_euler_se3_array(1)[0]).pose_se3 se3_array = se3_state.array # construct a bounding box bounding_box_se3_array = np.zeros((len(BoundingBoxSE3Index),), dtype=np.float64) length, width, height = np.random.uniform(0.0, self._max_extent, size=3) - bounding_box_se3_array[BoundingBoxSE3Index.STATE_SE3] = se3_array + bounding_box_se3_array[BoundingBoxSE3Index.POSE_SE3] = se3_array bounding_box_se3_array[BoundingBoxSE3Index.LENGTH] = length bounding_box_se3_array[BoundingBoxSE3Index.WIDTH] = width bounding_box_se3_array[BoundingBoxSE3Index.HEIGHT] = height @@ -227,13 +227,13 @@ def test_bbse3_array_to_corners_array_n_dim(self): for _ in range(self._num_consistency_checks): N = np.random.randint(1, 20) se3_array = self._get_random_euler_se3_array(N) - se3_state_array = np.array([EulerStateSE3.from_array(arr).state_se3.array for arr in se3_array]) + se3_state_array = np.array([EulerStateSE3.from_array(arr).pose_se3.array for arr in se3_array]) # construct a bounding box bounding_box_se3_array = np.zeros((N, len(BoundingBoxSE3Index)), dtype=np.float64) lengths, widths, heights = np.random.uniform(0.0, self._max_extent, size=(3, N)) - bounding_box_se3_array[:, BoundingBoxSE3Index.STATE_SE3] = se3_state_array + bounding_box_se3_array[:, BoundingBoxSE3Index.POSE_SE3] = se3_state_array bounding_box_se3_array[:, BoundingBoxSE3Index.LENGTH] = lengths bounding_box_se3_array[:, BoundingBoxSE3Index.WIDTH] = widths bounding_box_se3_array[:, BoundingBoxSE3Index.HEIGHT] = heights @@ -249,7 +249,7 @@ def test_bbse3_array_to_corners_array_n_dim(self): np.testing.assert_allclose( corners_array[obj_idx, corner_idx], translate_se3_along_body_frame( - StateSE3.from_array(bounding_box_se3_array[obj_idx, BoundingBoxSE3Index.STATE_SE3]), + PoseSE3.from_array(bounding_box_se3_array[obj_idx, BoundingBoxSE3Index.POSE_SE3]), body_translate_vector, ).point_3d.array, atol=1e-6, From 82bcf4c17ec054faca0e1fba9991b9dc88afa169 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sat, 8 Nov 2025 21:21:55 +0100 Subject: [PATCH 05/50] First version of the geometry module in docs. Adding some tests. --- docs/api/geometry/01_primitives/01_points.rst | 6 +- .../api/geometry/01_primitives/02_vectors.rst | 4 +- docs/api/geometry/01_primitives/04_se.rst | 4 +- .../01_primitives/05_bounding_boxes.rst | 4 +- .../geometry/01_primitives/06_polylines.rst | 4 +- .../01_primitives/07_indexing_enums.rst | 48 +++++++ .../geometry/02_transform/01_transform_2d.rst | 33 +++++ .../geometry/02_transform/02_transform_3d.rst | 37 +++++ docs/api/geometry/02_transform/index.rst | 1 + docs/api/geometry/index.rst | 1 - docs/conf.py | 20 ++- pyproject.toml | 3 +- src/py123d/geometry/transform/__init__.py | 6 +- .../geometry/transform/transform_se2.py | 134 ++++++++++++------ .../transform/test_transform_consistency.py | 26 ++-- .../geometry/transform/test_transform_se2.py | 82 ++++++++++- 16 files changed, 331 insertions(+), 82 deletions(-) create mode 100644 docs/api/geometry/02_transform/02_transform_3d.rst diff --git a/docs/api/geometry/01_primitives/01_points.rst b/docs/api/geometry/01_primitives/01_points.rst index d9b59166..9160a839 100644 --- a/docs/api/geometry/01_primitives/01_points.rst +++ b/docs/api/geometry/01_primitives/01_points.rst @@ -1,10 +1,8 @@ Points ^^^^^^ -.. autoclass:: py123d.geometry.Point3D - :members: +.. autoclass:: py123d.geometry.Point2D :autoclasstoc: -.. autoclass:: py123d.geometry.Point2D - :members: +.. autoclass:: py123d.geometry.Point3D :autoclasstoc: diff --git a/docs/api/geometry/01_primitives/02_vectors.rst b/docs/api/geometry/01_primitives/02_vectors.rst index dd3ae61e..28c78357 100644 --- a/docs/api/geometry/01_primitives/02_vectors.rst +++ b/docs/api/geometry/01_primitives/02_vectors.rst @@ -1,10 +1,10 @@ Vectors ^^^^^^^ -.. autoclass:: py123d.geometry.Vector3D +.. autoclass:: py123d.geometry.Vector2D :members: :autoclasstoc: -.. autoclass:: py123d.geometry.Vector2D +.. autoclass:: py123d.geometry.Vector3D :members: :autoclasstoc: diff --git a/docs/api/geometry/01_primitives/04_se.rst b/docs/api/geometry/01_primitives/04_se.rst index ded6b380..851c7052 100644 --- a/docs/api/geometry/01_primitives/04_se.rst +++ b/docs/api/geometry/01_primitives/04_se.rst @@ -1,10 +1,10 @@ Special Euclidean Groups ^^^^^^^^^^^^^^^^^^^^^^^^ -.. autoclass:: py123d.geometry.PoseSE3 +.. autoclass:: py123d.geometry.PoseSE2 :members: :autoclasstoc: -.. autoclass:: py123d.geometry.PoseSE2 +.. autoclass:: py123d.geometry.PoseSE3 :members: :autoclasstoc: diff --git a/docs/api/geometry/01_primitives/05_bounding_boxes.rst b/docs/api/geometry/01_primitives/05_bounding_boxes.rst index 8d7be794..b7514e9a 100644 --- a/docs/api/geometry/01_primitives/05_bounding_boxes.rst +++ b/docs/api/geometry/01_primitives/05_bounding_boxes.rst @@ -1,10 +1,10 @@ Bounding Boxes ^^^^^^^^^^^^^^ -.. autoclass:: py123d.geometry.BoundingBoxSE3 +.. autoclass:: py123d.geometry.BoundingBoxSE2 :members: :autoclasstoc: -.. autoclass:: py123d.geometry.BoundingBoxSE2 +.. autoclass:: py123d.geometry.BoundingBoxSE3 :members: :autoclasstoc: diff --git a/docs/api/geometry/01_primitives/06_polylines.rst b/docs/api/geometry/01_primitives/06_polylines.rst index 77b0a12b..6754ffee 100644 --- a/docs/api/geometry/01_primitives/06_polylines.rst +++ b/docs/api/geometry/01_primitives/06_polylines.rst @@ -1,7 +1,7 @@ Polylines ^^^^^^^^^ -.. autoclass:: py123d.geometry.Polyline3D +.. autoclass:: py123d.geometry.Polyline2D :members: :autoclasstoc: @@ -9,6 +9,6 @@ Polylines :members: :autoclasstoc: -.. autoclass:: py123d.geometry.Polyline2D +.. autoclass:: py123d.geometry.Polyline3D :members: :autoclasstoc: diff --git a/docs/api/geometry/01_primitives/07_indexing_enums.rst b/docs/api/geometry/01_primitives/07_indexing_enums.rst index c1562e06..475842dd 100644 --- a/docs/api/geometry/01_primitives/07_indexing_enums.rst +++ b/docs/api/geometry/01_primitives/07_indexing_enums.rst @@ -1,6 +1,9 @@ Indexing Enums ^^^^^^^^^^^^^^ +Points +------ + .. autoclass:: py123d.geometry.Point2DIndex :members: :no-inherited-members: @@ -9,6 +12,31 @@ Indexing Enums :members: :no-inherited-members: +Vectors +------- + +.. autoclass:: py123d.geometry.Vector2DIndex + :members: + :no-inherited-members: + +.. autoclass:: py123d.geometry.Vector3DIndex + :members: + :no-inherited-members: + +Rotations +--------- + +.. autoclass:: py123d.geometry.QuaternionIndex + :members: + :no-inherited-members: + +.. autoclass:: py123d.geometry.EulerAnglesIndex + :members: + :no-inherited-members: + +Poses +----- + .. autoclass:: py123d.geometry.PoseSE2Index :members: :no-inherited-members: @@ -16,3 +44,23 @@ Indexing Enums .. autoclass:: py123d.geometry.PoseSE3Index :members: :no-inherited-members: + + +Bounding Boxes +-------------- + +.. autoclass:: py123d.geometry.BoundingBoxSE2Index + :members: + :no-inherited-members: + +.. autoclass:: py123d.geometry.Corners2DIndex + :members: + :no-inherited-members: + +.. autoclass:: py123d.geometry.BoundingBoxSE3Index + :members: + :no-inherited-members: + +.. autoclass:: py123d.geometry.Corners3DIndex + :members: + :no-inherited-members: diff --git a/docs/api/geometry/02_transform/01_transform_2d.rst b/docs/api/geometry/02_transform/01_transform_2d.rst index ea6998ac..3bee9a63 100644 --- a/docs/api/geometry/02_transform/01_transform_2d.rst +++ b/docs/api/geometry/02_transform/01_transform_2d.rst @@ -1,4 +1,37 @@ Transforms in 2D ^^^^^^^^^^^^^^^^ +Transform 2D points between frames +---------------------------------- + +.. autofunction:: py123d.geometry.transform.convert_absolute_to_relative_points_2d_array + +.. autofunction:: py123d.geometry.transform.convert_relative_to_absolute_points_2d_array + +.. autofunction:: py123d.geometry.transform.convert_points_2d_array_between_origins + + + +Transform SE2 poses between frames +---------------------------------- + .. autofunction:: py123d.geometry.transform.convert_absolute_to_relative_se2_array + +.. autofunction:: py123d.geometry.transform.convert_relative_to_absolute_se2_array + +.. autofunction:: py123d.geometry.transform.convert_se2_array_between_origins + + + +Translation along frame axes +---------------------------- + +.. autofunction:: py123d.geometry.transform.translate_se2_along_body_frame + +.. autofunction:: py123d.geometry.transform.translate_se2_along_x + +.. autofunction:: py123d.geometry.transform.translate_se2_along_y + +.. autofunction:: py123d.geometry.transform.translate_se2_array_along_body_frame + +.. autofunction:: py123d.geometry.transform.translate_2d_along_body_frame diff --git a/docs/api/geometry/02_transform/02_transform_3d.rst b/docs/api/geometry/02_transform/02_transform_3d.rst new file mode 100644 index 00000000..b29c5479 --- /dev/null +++ b/docs/api/geometry/02_transform/02_transform_3d.rst @@ -0,0 +1,37 @@ +Transforms in 3D +^^^^^^^^^^^^^^^^ + +Transform 3D points between frames +---------------------------------- + +.. autofunction:: py123d.geometry.transform.convert_absolute_to_relative_points_3d_array + +.. autofunction:: py123d.geometry.transform.convert_relative_to_absolute_points_3d_array + +.. autofunction:: py123d.geometry.transform.convert_points_3d_array_between_origins + + + +Transform SE3 poses between frames +---------------------------------- + +.. autofunction:: py123d.geometry.transform.convert_absolute_to_relative_se3_array + +.. autofunction:: py123d.geometry.transform.convert_relative_to_absolute_se3_array + +.. autofunction:: py123d.geometry.transform.convert_se3_array_between_origins + + + +Translation along frame axes +---------------------------- + +.. autofunction:: py123d.geometry.transform.translate_se3_along_body_frame + +.. autofunction:: py123d.geometry.transform.translate_se3_along_x + +.. autofunction:: py123d.geometry.transform.translate_se3_along_y + +.. autofunction:: py123d.geometry.transform.translate_se3_along_z + +.. autofunction:: py123d.geometry.transform.translate_3d_along_body_frame diff --git a/docs/api/geometry/02_transform/index.rst b/docs/api/geometry/02_transform/index.rst index 050e7be9..60756976 100644 --- a/docs/api/geometry/02_transform/index.rst +++ b/docs/api/geometry/02_transform/index.rst @@ -7,3 +7,4 @@ Transforms 01_transform_2d + 02_transform_3d diff --git a/docs/api/geometry/index.rst b/docs/api/geometry/index.rst index c9fd3b03..a0be633f 100644 --- a/docs/api/geometry/index.rst +++ b/docs/api/geometry/index.rst @@ -4,6 +4,5 @@ Geometry .. toctree:: :maxdepth: 2 - 01_primitives/index 02_transform/index diff --git a/docs/conf.py b/docs/conf.py index fe897cf4..ad4d0791 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx_copybutton", + "sphinx_inline_tabs", "myst_parser", ] @@ -78,13 +79,9 @@ "public-methods-without-dunders", "private-methods", ] +html_css_files = ["css/theme_overrides.css", "css/version_switch.css"] +html_js_files = ["js/version_switch.js"] -# 'members': True, -# 'special-members': True, -# 'private-members': True, -# 'inherited-members': True, -# 'undoc-members': True, -# 'exclude-members': '__weakref__', # Custom CSS for color theming html_css_files = [ @@ -100,6 +97,17 @@ } ) +html_sidebars = { + "**": [ + "sidebar/brand.html", + "sidebar/search.html", + "sidebar/scroll-start.html", + "sidebar/navigation.html", + "sidebar/scroll-end.html", + "sidebar/variant-selector.html", + ] +} + # This CSS should go in /home/daniel/py123d_workspace/py123d/docs/_static/custom.css # Your conf.py already references it in html_css_files = ["custom.css"] diff --git a/pyproject.toml b/pyproject.toml index 5ccc738e..8e01bae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,8 +66,9 @@ docs = [ "Sphinx", "sphinx-rtd-theme", "sphinx-autobuild", - "myst-parser", + "sphinx-inline-tabs", "sphinx-copybutton", + "myst-parser", "furo", "autoclasstoc", ] diff --git a/src/py123d/geometry/transform/__init__.py b/src/py123d/geometry/transform/__init__.py index cf22b848..d464c7cb 100644 --- a/src/py123d/geometry/transform/__init__.py +++ b/src/py123d/geometry/transform/__init__.py @@ -1,8 +1,10 @@ from py123d.geometry.transform.transform_se2 import ( - convert_absolute_to_relative_point_2d_array, + convert_absolute_to_relative_points_2d_array, convert_absolute_to_relative_se2_array, - convert_relative_to_absolute_point_2d_array, + convert_relative_to_absolute_points_2d_array, convert_relative_to_absolute_se2_array, + convert_points_2d_array_between_origins, + convert_se2_array_between_origins, translate_se2_along_body_frame, translate_se2_along_x, translate_se2_along_y, diff --git a/src/py123d/geometry/transform/transform_se2.py b/src/py123d/geometry/transform/transform_se2.py index 9ecd3c6d..5d021df4 100644 --- a/src/py123d/geometry/transform/transform_se2.py +++ b/src/py123d/geometry/transform/transform_se2.py @@ -7,6 +7,23 @@ from py123d.geometry.utils.rotation_utils import normalize_angle +def _extract_pose_se2_array(pose: Union[PoseSE2, npt.NDArray[np.float64]]) -> npt.NDArray[np.float64]: + """Helper function to extract SE2 pose array from a PoseSE2 or np.ndarray. + + :param pose: Input pose, either a PoseSE2 instance or a 1D numpy array. + :raises TypeError: If the input is neither a PoseSE2 nor a 1D numpy array. + :return: A 1D numpy array representing the SE2 pose. + """ + if isinstance(pose, PoseSE2): + pose_array = pose.array + elif isinstance(pose, np.ndarray): + assert pose.ndim == 1 and pose.shape[-1] == len(PoseSE2Index) + pose_array = pose + else: + raise TypeError(f"Expected PoseSE2 or np.ndarray, got {type(pose)}") + return pose_array + + def convert_absolute_to_relative_se2_array( origin: Union[PoseSE2, npt.NDArray[np.float64]], pose_se2_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: @@ -18,15 +35,8 @@ def convert_absolute_to_relative_se2_array( :return: SE2 array, index by \ :class:`~py123d.geometry.geometry_index.PoseSE2Index`, in last dim """ - if isinstance(origin, PoseSE2): - origin_array = origin.array - elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) - origin_array = origin - else: - raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") - assert len(PoseSE2Index) == pose_se2_array.shape[-1] + origin_array = _extract_pose_se2_array(origin) rotate_rad = -origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) @@ -42,21 +52,14 @@ def convert_absolute_to_relative_se2_array( def convert_relative_to_absolute_se2_array( origin: Union[PoseSE2, npt.NDArray[np.float64]], pose_se2_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - """ - Converts an StateSE2 array from global to relative coordinates. + """Converts an StateSE2 array from global to relative coordinates. + :param origin: origin pose of relative coords system :param pose_se2_array: array of SE2 poses with (x,y,θ) in last dim :return: SE2 coords array in relative coordinates """ - if isinstance(origin, PoseSE2): - origin_array = origin.array - elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) - origin_array = origin - else: - raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") - assert len(PoseSE2Index) == pose_se2_array.shape[-1] + origin_array = _extract_pose_se2_array(origin) rotate_rad = origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) @@ -72,53 +75,102 @@ def convert_relative_to_absolute_se2_array( return pose_se2_abs -def convert_absolute_to_relative_point_2d_array( - origin: Union[PoseSE2, npt.NDArray[np.float64]], point_2d_array: npt.NDArray[np.float64] +def convert_se2_array_between_origins( + from_origin: Union[PoseSE2, npt.NDArray[np.float64]], + to_origin: Union[PoseSE2, npt.NDArray[np.float64]], + se2_array: npt.NDArray[np.float64], +) -> npt.NDArray[np.float64]: + """Converts an SE2 array from one origin frame to another origin frame. + + :param from_origin: The source origin state in the absolute frame, as a PoseSE2 or np.ndarray. + :param to_origin: The target origin state in the absolute frame, as a PoseSE2 or np.ndarray. + :param se2_array: The SE2 array in the source origin frame. + :raises TypeError: If the origins are not PoseSE2 or np.ndarray. + :return: The SE2 array in the target origin frame, indexed by :class:`~py123d.geometry.PoseSE2Index`. + """ + # Parse from_origin & to_origin + from_origin_array = _extract_pose_se2_array(from_origin) + to_origin_array = _extract_pose_se2_array(to_origin) + + assert se2_array.ndim >= 1 + assert se2_array.shape[-1] == len(PoseSE2Index) + + # TODO: Re-write withouts transforming to absolute frame intermediate step + abs_array = convert_relative_to_absolute_se2_array(from_origin_array, se2_array) + result_se2_array = convert_absolute_to_relative_se2_array(to_origin_array, abs_array) + + return result_se2_array + + +def convert_absolute_to_relative_points_2d_array( + origin: Union[PoseSE2, npt.NDArray[np.float64]], points_2d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: """Converts an absolute 2D point array from global to relative coordinates. :param origin: origin pose of relative coords system - :param point_2d_array: array of 2D points with (x,y) in last dim + :param points_2d_array: array of 2D points with (x,y) in last dim :return: 2D points array in relative coordinates """ - if isinstance(origin, PoseSE2): - origin_array = origin.array - elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) - origin_array = origin - else: - raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") + assert points_2d_array.ndim >= 1 + assert points_2d_array.shape[-1] == len(Point2DIndex) + origin_array = _extract_pose_se2_array(origin) rotate_rad = -origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) R = np.array([[cos, -sin], [sin, cos]], dtype=np.float64) - point_2d_rel = point_2d_array - origin_array[..., PoseSE2Index.XY] + point_2d_rel = points_2d_array - origin_array[..., PoseSE2Index.XY] point_2d_rel = point_2d_rel @ R.T return point_2d_rel -def convert_relative_to_absolute_point_2d_array( - origin: Union[PoseSE2, npt.NDArray[np.float64]], point_2d_array: npt.NDArray[np.float64] +def convert_relative_to_absolute_points_2d_array( + origin: Union[PoseSE2, npt.NDArray[np.float64]], points_2d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: + """Converts relative 2D point array to absolute coordinates. - if isinstance(origin, PoseSE2): - origin_array = origin.array - elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(PoseSE2Index) - origin_array = origin - else: - raise TypeError(f"Expected StateSE2 or np.ndarray, got {type(origin)}") + :param origin: origin pose of relative coords system + :param points_2d_array: array of 2D points with (x,y) in last dim + :return: 2D points array in absolute coordinates + """ + + origin_array = _extract_pose_se2_array(origin) rotate_rad = origin_array[PoseSE2Index.YAW] cos, sin = np.cos(rotate_rad), np.sin(rotate_rad) R = np.array([[cos, -sin], [sin, cos]], dtype=np.float64) - point_2d_abs = point_2d_array @ R.T - point_2d_abs = point_2d_abs + origin_array[..., PoseSE2Index.XY] + points_2d_abs = points_2d_array @ R.T + points_2d_abs = points_2d_abs + origin_array[..., PoseSE2Index.XY] + + return points_2d_abs + + +def convert_points_2d_array_between_origins( + from_origin: Union[PoseSE2, npt.NDArray[np.float64]], + to_origin: Union[PoseSE2, npt.NDArray[np.float64]], + points_2d_array: npt.NDArray[np.float64], +) -> npt.NDArray[np.float64]: + """Converts 2D points from one origin frame to another origin frame. + + :param from_origin: The source origin state in the absolute frame, as a PoseSE2 or np.ndarray. + :param to_origin: The target origin state in the absolute frame, as a PoseSE2 or np.ndarray. + :param points_2d_array: The 2D points in the source origin frame. + :raises TypeError: If the origins are not PoseSE2 or np.ndarray. + :return: The 2D points in the target origin frame, indexed by :class:`~py123d.geometry.Point2DIndex`. + """ + + assert points_2d_array.ndim >= 1 + assert points_2d_array.shape[-1] == len(Point2DIndex) + + from_origin_array = _extract_pose_se2_array(from_origin) + to_origin_array = _extract_pose_se2_array(to_origin) + + abs_points_array = convert_relative_to_absolute_points_2d_array(from_origin_array, points_2d_array) + result_points_array = convert_absolute_to_relative_points_2d_array(to_origin_array, abs_points_array) - return point_2d_abs + return result_points_array def translate_se2_array_along_body_frame( diff --git a/tests/unit/geometry/transform/test_transform_consistency.py b/tests/unit/geometry/transform/test_transform_consistency.py index 8bc903d1..a50afcc5 100644 --- a/tests/unit/geometry/transform/test_transform_consistency.py +++ b/tests/unit/geometry/transform/test_transform_consistency.py @@ -15,9 +15,9 @@ translate_euler_se3_along_y, ) from py123d.geometry.transform.transform_se2 import ( - convert_absolute_to_relative_point_2d_array, + convert_absolute_to_relative_points_2d_array, convert_absolute_to_relative_se2_array, - convert_relative_to_absolute_point_2d_array, + convert_relative_to_absolute_points_2d_array, convert_relative_to_absolute_se2_array, translate_se2_along_body_frame, translate_se2_along_x, @@ -83,8 +83,8 @@ def test_se2_points_absolute_relative_conversion_consistency(self) -> None: absolute_points = self._get_random_se2_array(num_points)[:, PoseSE2Index.XY] # Convert absolute -> relative -> absolute - relative_points = convert_absolute_to_relative_point_2d_array(reference, absolute_points) - recovered_absolute = convert_relative_to_absolute_point_2d_array(reference, relative_points) + relative_points = convert_absolute_to_relative_points_2d_array(reference, absolute_points) + recovered_absolute = convert_relative_to_absolute_points_2d_array(reference, relative_points) np.testing.assert_array_almost_equal(absolute_points, recovered_absolute, decimal=self.decimal) @@ -100,13 +100,15 @@ def test_se2_points_consistency(self) -> None: # Convert absolute -> relative -> absolute relative_se2 = convert_absolute_to_relative_se2_array(reference, absolute_se2) - relative_points = convert_absolute_to_relative_point_2d_array(reference, absolute_se2[..., PoseSE2Index.XY]) + relative_points = convert_absolute_to_relative_points_2d_array( + reference, absolute_se2[..., PoseSE2Index.XY] + ) np.testing.assert_array_almost_equal( relative_se2[..., PoseSE2Index.XY], relative_points, decimal=self.decimal ) recovered_absolute_se2 = convert_relative_to_absolute_se2_array(reference, relative_se2) - absolute_points = convert_relative_to_absolute_point_2d_array(reference, relative_points) + absolute_points = convert_relative_to_absolute_points_2d_array(reference, relative_points) np.testing.assert_array_almost_equal( recovered_absolute_se2[..., PoseSE2Index.XY], absolute_points, decimal=self.decimal ) @@ -285,8 +287,8 @@ def test_se2_se3_point_conversion_consistency(self) -> None: points_3d = np.column_stack([points_2d, np.zeros(num_points)]) # Convert using SE2 functions - relative_2d = convert_absolute_to_relative_point_2d_array(reference_se2, points_2d) - absolute_2d_recovered = convert_relative_to_absolute_point_2d_array(reference_se2, relative_2d) + relative_2d = convert_absolute_to_relative_points_2d_array(reference_se2, points_2d) + absolute_2d_recovered = convert_relative_to_absolute_points_2d_array(reference_se2, relative_2d) # Convert using SE3 functions relative_3d = convert_absolute_to_relative_points_3d_array(reference_se3, points_3d) @@ -393,7 +395,7 @@ def test_transform_empty_arrays(self) -> None: empty_2d_points = np.array([], dtype=np.float64).reshape(0, len(Point2DIndex)) result_se2_poses = convert_absolute_to_relative_se2_array(reference_se2, empty_se2_poses) - result_2d_points = convert_absolute_to_relative_point_2d_array(reference_se2, empty_2d_points) + result_2d_points = convert_absolute_to_relative_points_2d_array(reference_se2, empty_2d_points) self.assertEqual(result_se2_poses.shape, (0, len(PoseSE2Index))) self.assertEqual(result_2d_points.shape, (0, len(Point2DIndex))) @@ -421,7 +423,7 @@ def test_transform_identity_operations(self) -> None: se2_points = se2_poses[:, PoseSE2Index.XY] relative_se2_poses = convert_absolute_to_relative_se2_array(identity_se2, se2_poses) - relative_se2_points = convert_absolute_to_relative_point_2d_array(identity_se2, se2_points) + relative_se2_points = convert_absolute_to_relative_points_2d_array(identity_se2, se2_points) np.testing.assert_array_almost_equal(se2_poses, relative_se2_poses, decimal=self.decimal) np.testing.assert_array_almost_equal(se2_points, relative_se2_points, decimal=self.decimal) @@ -465,8 +467,8 @@ def test_transform_large_rotations(self) -> None: relative_se3 = convert_absolute_to_relative_euler_se3_array(reference_se3, test_se3_poses) recovered_se3 = convert_relative_to_absolute_euler_se3_array(reference_se3, relative_se3) - relative_2d_points = convert_absolute_to_relative_point_2d_array(reference_se2, test_2d_points) - recovered_2d_points = convert_relative_to_absolute_point_2d_array(reference_se2, relative_2d_points) + relative_2d_points = convert_absolute_to_relative_points_2d_array(reference_se2, test_2d_points) + recovered_2d_points = convert_relative_to_absolute_points_2d_array(reference_se2, relative_2d_points) relative_3d_points = convert_absolute_to_relative_points_3d_array(reference_se3, test_3d_points) recovered_3d_points = convert_relative_to_absolute_points_3d_array(reference_se3, relative_3d_points) diff --git a/tests/unit/geometry/transform/test_transform_se2.py b/tests/unit/geometry/transform/test_transform_se2.py index 14e6e4b6..dc9d9f24 100644 --- a/tests/unit/geometry/transform/test_transform_se2.py +++ b/tests/unit/geometry/transform/test_transform_se2.py @@ -4,11 +4,14 @@ import numpy.typing as npt from py123d.geometry import PoseSE2, Vector2D +from py123d.geometry.geometry_index import PoseSE2Index from py123d.geometry.transform.transform_se2 import ( - convert_absolute_to_relative_point_2d_array, + convert_absolute_to_relative_points_2d_array, convert_absolute_to_relative_se2_array, - convert_relative_to_absolute_point_2d_array, + convert_points_2d_array_between_origins, + convert_relative_to_absolute_points_2d_array, convert_relative_to_absolute_se2_array, + convert_se2_array_between_origins, translate_se2_along_body_frame, translate_se2_along_x, translate_se2_along_y, @@ -21,6 +24,14 @@ class TestTransformSE2(unittest.TestCase): def setUp(self): self.decimal = 6 # Decimal places for np.testing.assert_array_almost_equal + def _get_random_se2_array(self, num_poses: int) -> npt.NDArray[np.float64]: + """Generates a random SE2 array for testing.""" + x = np.random.uniform(-10.0, 10.0, size=(num_poses,)) + y = np.random.uniform(-10.0, 10.0, size=(num_poses,)) + yaw = np.random.uniform(-np.pi, np.pi, size=(num_poses,)) + se2_array = np.stack((x, y, yaw), axis=-1) + return se2_array + def test_translate_se2_along_x(self) -> None: """Tests translating a SE2 state along the X-axis.""" pose: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) @@ -182,7 +193,7 @@ def test_convert_absolute_to_relative_point_2d_array(self) -> None: """Tests converting absolute 2D points to relative 2D points.""" reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([[2.0, 2.0], [0.0, 1.0]], dtype=np.float64) - result: npt.NDArray[np.float64] = convert_absolute_to_relative_point_2d_array(reference, absolute_points) + result: npt.NDArray[np.float64] = convert_absolute_to_relative_points_2d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 1.0], [-1.0, 0.0]], dtype=np.float64) np.testing.assert_array_almost_equal(result, expected, decimal=self.decimal) @@ -190,7 +201,7 @@ def test_convert_absolute_to_relative_point_2d_array_with_rotation(self) -> None """Tests converting absolute 2D points to relative 2D points with 90 degree rotation.""" reference: PoseSE2 = PoseSE2.from_array(np.array([0.0, 0.0, np.pi / 2], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.float64) - result: npt.NDArray[np.float64] = convert_absolute_to_relative_point_2d_array(reference, absolute_points) + result: npt.NDArray[np.float64] = convert_absolute_to_relative_points_2d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 0.0], [0.0, -1.0]], dtype=np.float64) np.testing.assert_array_almost_equal(result, expected, decimal=self.decimal) @@ -198,7 +209,7 @@ def test_convert_absolute_to_relative_point_2d_array_empty(self) -> None: """Tests converting an empty array of absolute 2D points to relative 2D points.""" reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([], dtype=np.float64).reshape(0, 2) - result: npt.NDArray[np.float64] = convert_absolute_to_relative_point_2d_array(reference, absolute_points) + result: npt.NDArray[np.float64] = convert_absolute_to_relative_points_2d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([], dtype=np.float64).reshape(0, 2) np.testing.assert_array_almost_equal(result, expected, decimal=self.decimal) @@ -206,7 +217,7 @@ def test_convert_relative_to_absolute_point_2d_array(self) -> None: """Tests converting relative 2D points to absolute 2D points.""" reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 1.0, 0.0], dtype=np.float64)) relative_points: npt.NDArray[np.float64] = np.array([[1.0, 1.0], [-1.0, 0.0]], dtype=np.float64) - result: npt.NDArray[np.float64] = convert_relative_to_absolute_point_2d_array(reference, relative_points) + result: npt.NDArray[np.float64] = convert_relative_to_absolute_points_2d_array(reference, relative_points) expected: npt.NDArray[np.float64] = np.array([[2.0, 2.0], [0.0, 1.0]], dtype=np.float64) np.testing.assert_array_almost_equal(result, expected, decimal=self.decimal) @@ -214,6 +225,63 @@ def test_convert_relative_to_absolute_point_2d_array_with_rotation(self) -> None """Tests converting relative 2D points to absolute 2D points with 90 degree rotation.""" reference: PoseSE2 = PoseSE2.from_array(np.array([1.0, 0.0, np.pi / 2], dtype=np.float64)) relative_points: npt.NDArray[np.float64] = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float64) - result: npt.NDArray[np.float64] = convert_relative_to_absolute_point_2d_array(reference, relative_points) + result: npt.NDArray[np.float64] = convert_relative_to_absolute_points_2d_array(reference, relative_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 1.0], [0.0, 0.0]], dtype=np.float64) np.testing.assert_array_almost_equal(result, expected, decimal=self.decimal) + + def test_convert_points_2d_array_between_origins(self): + random_points_2d = np.random.rand(10, 2) + for _ in range(10): + from_se2 = PoseSE2.from_array(self._get_random_se2_array(1)[0]) + to_se2 = PoseSE2.from_array(self._get_random_se2_array(1)[0]) + + identity_se2_array = np.zeros(len(PoseSE2Index), dtype=np.float64) + identity_se2 = PoseSE2.from_array(identity_se2_array) + + # Check if consistent with absolute-relative-absolute conversion + converted_points_quat = convert_points_2d_array_between_origins(from_se2, to_se2, random_points_2d) + abs_from_se2 = convert_relative_to_absolute_points_2d_array(from_se2, random_points_2d) + rel_to_se2 = convert_absolute_to_relative_points_2d_array(to_se2, abs_from_se2) + np.testing.assert_allclose(converted_points_quat, rel_to_se2, atol=1e-6) + + # Check if consistent with absolute conversion to identity origin + absolute_se2 = convert_points_2d_array_between_origins(from_se2, identity_se2, random_points_2d) + np.testing.assert_allclose( + absolute_se2[..., PoseSE2Index.XY], + abs_from_se2[..., PoseSE2Index.XY], + atol=1e-6, + ) + + def test_convert_se2_array_between_origins(self): + for _ in range(10): + random_se2_array = self._get_random_se2_array(np.random.randint(1, 10)) + + from_se2 = PoseSE2.from_array(self._get_random_se2_array(1)[0]) + to_se2 = PoseSE2.from_array(self._get_random_se2_array(1)[0]) + identity_se2_array = np.zeros(len(PoseSE2Index), dtype=np.float64) + identity_se2 = PoseSE2.from_array(identity_se2_array) + + # Check if consistent with absolute-relative-absolute conversion + converted_se2 = convert_se2_array_between_origins(from_se2, to_se2, random_se2_array) + + abs_from_se2 = convert_relative_to_absolute_se2_array(from_se2, random_se2_array) + rel_to_se2 = convert_absolute_to_relative_se2_array(to_se2, abs_from_se2) + + np.testing.assert_allclose( + converted_se2[..., PoseSE2Index.XY], + rel_to_se2[..., PoseSE2Index.XY], + atol=1e-6, + ) + np.testing.assert_allclose( + converted_se2[..., PoseSE2Index.YAW], + rel_to_se2[..., PoseSE2Index.YAW], + atol=1e-6, + ) + + # Check if consistent with absolute conversion to identity origin + absolute_se2 = convert_se2_array_between_origins(from_se2, identity_se2, random_se2_array) + np.testing.assert_allclose( + absolute_se2[..., PoseSE2Index.XY], + abs_from_se2[..., PoseSE2Index.XY], + atol=1e-6, + ) From 906a3b408a5bc5e9b95cc617b7ef2aad2621bc06 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 9 Nov 2025 16:06:51 +0100 Subject: [PATCH 06/50] Refactor map api and scene api. --- docs/api/map/map_api.rst | 11 +- docs/api/map/map_layers.rst | 5 +- docs/api/scene/index.rst | 8 + docs/api/scene/scene_api.rst | 5 + docs/index.rst | 3 +- examples/01_viser.py | 4 +- notebooks/bev_matplotlib.ipynb | 66 +-- notebooks/bev_render.ipynb | 4 +- notebooks/camera_matplotlib.ipynb | 6 +- notebooks/camera_render.ipynb | 6 +- src/py123d/api/__init__.py | 5 + .../map/cache => api/map}/__init__.py | 0 .../map/gpkg/gpkg_map_api.py} | 352 ++++++++------ src/py123d/api/map/gpkg/gpkg_utils.py | 58 +++ .../abstract_map.py => api/map/map_api.py} | 18 +- src/py123d/api/scene/__init__.py | 3 + .../map/gpkg => api/scene/arrow}/__init__.py | 0 .../scene/arrow/arrow_scene.py | 35 +- .../scene/arrow/arrow_scene_builder.py | 32 +- .../scene/arrow/utils}/__init__.py | 0 .../scene/arrow/utils/arrow_getters.py | 2 +- .../scene/arrow/utils/arrow_metadata_utils.py | 2 +- .../scene/scene_api.py} | 13 +- .../scene/scene_builder.py} | 6 +- .../{datatypes => api}/scene/scene_filter.py | 0 src/py123d/api/scene/scene_metadata.py | 23 + .../datasets/av2/av2_map_conversion.py | 32 +- .../datasets/av2/av2_sensor_converter.py | 4 +- .../datasets/av2/utils/av2_constants.py | 2 +- .../datasets/kitti360/kitti360_converter.py | 36 +- .../kitti360/kitti360_map_conversion.py | 20 +- .../datasets/kitti360/kitti360_sensor_io.py | 2 +- .../datasets/nuplan/nuplan_converter.py | 4 +- .../datasets/nuplan/nuplan_map_conversion.py | 46 +- .../datasets/nuplan/utils/nuplan_constants.py | 2 +- .../datasets/nuscenes/nuscenes_converter.py | 3 +- .../nuscenes/nuscenes_map_conversion.py | 86 ++-- .../datasets/nuscenes/nuscenes_sensor_io.py | 2 +- .../datasets/pandaset/pandaset_converter.py | 2 +- .../wopd/utils/womp_boundary_utils.py | 8 +- .../datasets/wopd/utils/wopd_constants.py | 2 +- .../datasets/wopd/wopd_converter.py | 4 +- .../datasets/wopd/wopd_map_conversion.py | 43 +- .../log_writer/abstract_log_writer.py | 2 +- .../conversion/log_writer/arrow_log_writer.py | 4 +- .../map_writer/abstract_map_writer.py | 46 +- .../conversion/map_writer/gpkg_map_writer.py | 60 +-- .../conversion/map_writer/utils/gpkg_utils.py | 11 +- .../sensor_io/lidar/file_lidar_io.py | 2 +- .../opendrive/opendrive_map_conversion.py | 77 ++- .../map_utils/opendrive/parser/elevation.py | 42 +- .../map_utils/opendrive/parser/geometry.py | 14 +- .../utils/map_utils/opendrive/parser/lane.py | 80 ++-- .../map_utils/opendrive/parser/objects.py | 6 +- .../map_utils/opendrive/parser/opendrive.py | 18 +- .../map_utils/opendrive/parser/polynomial.py | 4 +- .../map_utils/opendrive/parser/reference.py | 54 +-- .../utils/map_utils/opendrive/parser/road.py | 82 ++-- .../map_utils/opendrive/utils/collection.py | 26 +- .../map_utils/opendrive/utils/lane_helper.py | 22 +- .../opendrive/utils/objects_helper.py | 6 +- .../map_utils/road_edge/road_edge_3d_utils.py | 34 +- src/py123d/datatypes/map/__init__.py | 18 - .../datatypes/map/abstract_map_objects.py | 453 ------------------ .../datatypes/map/cache/cache_map_objects.py | 311 ------------ .../datatypes/map/gpkg/gpkg_map_objects.py | 385 --------------- src/py123d/datatypes/map/gpkg/gpkg_utils.py | 90 ---- src/py123d/datatypes/map_objects/__init__.py | 14 + .../datatypes/map_objects/base_map_objects.py | 138 ++++++ .../map_layer_types.py} | 2 +- .../datatypes/map_objects/map_objects.py | 422 ++++++++++++++++ src/py123d/datatypes/map_objects/utils.py | 43 ++ src/py123d/datatypes/metadata/__init__.py | 2 + .../log_metadata.py} | 24 +- .../{map => metadata}/map_metadata.py | 2 - src/py123d/datatypes/scene/__init__.py | 4 - .../datatypes/scene/arrow/utils/__init__.py | 0 src/py123d/datatypes/sensors/__init__.py | 3 +- .../datatypes/vehicle_state/__init__.py | 8 + .../datatypes/vehicle_state/dynamic_state.py | 5 +- src/py123d/geometry/polyline.py | 6 +- .../script/builders/scene_builder_builder.py | 2 +- .../script/builders/scene_filter_builder.py | 2 +- .../scene_builder/default_scene_builder.yaml | 2 +- .../common/scene_filter/all_scenes.yaml | 2 +- .../common/scene_filter/log_scenes.yaml | 2 +- .../common/scene_filter/nuplan_logs.yaml | 2 +- .../common/scene_filter/nuplan_mini_logs.yaml | 2 +- .../common/scene_filter/viser_scenes.yaml | 2 +- src/py123d/visualization/color/default.py | 2 +- .../visualization/matplotlib/observation.py | 18 +- src/py123d/visualization/matplotlib/plots.py | 10 +- .../viser/elements/detection_elements.py | 8 +- .../viser/elements/map_elements.py | 12 +- .../viser/elements/render_elements.py | 11 +- .../viser/elements/sensor_elements.py | 21 +- .../visualization/viser/viser_viewer.py | 10 +- 97 files changed, 1536 insertions(+), 2057 deletions(-) create mode 100644 docs/api/scene/index.rst create mode 100644 docs/api/scene/scene_api.rst create mode 100644 src/py123d/api/__init__.py rename src/py123d/{datatypes/map/cache => api/map}/__init__.py (100%) rename src/py123d/{datatypes/map/gpkg/gpkg_map.py => api/map/gpkg/gpkg_map_api.py} (54%) create mode 100644 src/py123d/api/map/gpkg/gpkg_utils.py rename src/py123d/{datatypes/map/abstract_map.py => api/map/map_api.py} (80%) create mode 100644 src/py123d/api/scene/__init__.py rename src/py123d/{datatypes/map/gpkg => api/scene/arrow}/__init__.py (100%) rename src/py123d/{datatypes => api}/scene/arrow/arrow_scene.py (87%) rename src/py123d/{datatypes => api}/scene/arrow/arrow_scene_builder.py (89%) rename src/py123d/{datatypes/scene/arrow => api/scene/arrow/utils}/__init__.py (100%) rename src/py123d/{datatypes => api}/scene/arrow/utils/arrow_getters.py (99%) rename src/py123d/{datatypes => api}/scene/arrow/utils/arrow_metadata_utils.py (91%) rename src/py123d/{datatypes/scene/abstract_scene.py => api/scene/scene_api.py} (91%) rename src/py123d/{datatypes/scene/abstract_scene_builder.py => api/scene/scene_builder.py} (80%) rename src/py123d/{datatypes => api}/scene/scene_filter.py (100%) create mode 100644 src/py123d/api/scene/scene_metadata.py delete mode 100644 src/py123d/datatypes/map/__init__.py delete mode 100644 src/py123d/datatypes/map/abstract_map_objects.py delete mode 100644 src/py123d/datatypes/map/cache/cache_map_objects.py delete mode 100644 src/py123d/datatypes/map/gpkg/gpkg_map_objects.py delete mode 100644 src/py123d/datatypes/map/gpkg/gpkg_utils.py create mode 100644 src/py123d/datatypes/map_objects/__init__.py create mode 100644 src/py123d/datatypes/map_objects/base_map_objects.py rename src/py123d/datatypes/{map/map_datatypes.py => map_objects/map_layer_types.py} (99%) create mode 100644 src/py123d/datatypes/map_objects/map_objects.py create mode 100644 src/py123d/datatypes/map_objects/utils.py create mode 100644 src/py123d/datatypes/metadata/__init__.py rename src/py123d/datatypes/{scene/scene_metadata.py => metadata/log_metadata.py} (87%) rename src/py123d/datatypes/{map => metadata}/map_metadata.py (90%) delete mode 100644 src/py123d/datatypes/scene/__init__.py delete mode 100644 src/py123d/datatypes/scene/arrow/utils/__init__.py diff --git a/docs/api/map/map_api.rst b/docs/api/map/map_api.rst index 38b61b1b..31251c7c 100644 --- a/docs/api/map/map_api.rst +++ b/docs/api/map/map_api.rst @@ -1,12 +1,5 @@ Map API ------- -.. autoclass:: py123d.datatypes.map.AbstractMap - - -Layers & Metadata ------------------ - -.. autoclass:: py123d.datatypes.map.MapMetadata - -.. autoclass:: py123d.datatypes.map.MapLayer +.. autoclass:: py123d.api.MapAPI + :autoclasstoc: diff --git a/docs/api/map/map_layers.rst b/docs/api/map/map_layers.rst index 393b983e..0bff0bc5 100644 --- a/docs/api/map/map_layers.rst +++ b/docs/api/map/map_layers.rst @@ -1,11 +1,12 @@ Map Objects ----------- -.. autoclass:: py123d.datatypes.map.AbstractCarpark() +.. autoclass:: py123d.datatypes.map_objects.Carpark() + :show-inheritance: :inherited-members: :autoclasstoc: -.. autoclass:: py123d.datatypes.map.AbstractLane() +.. autoclass:: py123d.datatypes.map_objects.Lane() :show-inheritance: :inherited-members: :autoclasstoc: diff --git a/docs/api/scene/index.rst b/docs/api/scene/index.rst new file mode 100644 index 00000000..090b13a6 --- /dev/null +++ b/docs/api/scene/index.rst @@ -0,0 +1,8 @@ +Scene +===== + + +.. toctree:: + :maxdepth: 2 + + scene_api diff --git a/docs/api/scene/scene_api.rst b/docs/api/scene/scene_api.rst new file mode 100644 index 00000000..96db0aaf --- /dev/null +++ b/docs/api/scene/scene_api.rst @@ -0,0 +1,5 @@ +Scene API +--------- + +.. autoclass:: py123d.api.SceneAPI + :autoclasstoc: diff --git a/docs/index.rst b/docs/index.rst index 96e655f7..b2f00085 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,9 +23,10 @@ documentation for details. :maxdepth: 2 :caption: API Reference: + api/scene/index api/map/index - api/geometry/index api/datatypes/index + api/geometry/index .. toctree:: :maxdepth: 1 diff --git a/examples/01_viser.py b/examples/01_viser.py index 29f03aa2..5871b682 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -1,6 +1,6 @@ +from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder +from py123d.api.scene.scene_filter import SceneFilter from py123d.common.multithreading.worker_sequential import Sequential -from py123d.datatypes.scene.arrow.arrow_scene_builder import ArrowSceneBuilder -from py123d.datatypes.scene.scene_filter import SceneFilter from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType from py123d.visualization.viser.viser_viewer import ViserViewer diff --git a/notebooks/bev_matplotlib.ipynb b/notebooks/bev_matplotlib.ipynb index 40bea7b8..db3aa151 100644 --- a/notebooks/bev_matplotlib.ipynb +++ b/notebooks/bev_matplotlib.ipynb @@ -7,8 +7,8 @@ "metadata": {}, "outputs": [], "source": [ - "from py123d.datatypes.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.datatypes.scene.scene_filter import SceneFilter\n", + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.api.scene.scene_filter import SceneFilter\n", "\n", "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", @@ -24,10 +24,10 @@ "source": [ "# splits = [\"kitti360_train\"]\n", "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", - "# splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", + "splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", "# splits = [\"carla_test\"]\n", "# splits = [\"wopd_val\"]\n", - "splits = [\"av2-sensor_train\"]\n", + "# splits = [\"av2-sensor_train\"]\n", "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", "log_names = None\n", "scene_uuids = None\n", @@ -60,6 +60,9 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", + "from py123d.api.map.map_api import MapAPI\n", + "from py123d.datatypes.map_objects.map_layer_types import MapLayer\n", + "from py123d.datatypes.map_objects.map_objects import Lane, LaneGroup\n", "from py123d.geometry import Point2D\n", "from py123d.visualization.color.color import BLACK, DARK_GREY, DARKER_GREY, LIGHT_GREY, NEW_TAB_10, TAB_10\n", "from py123d.visualization.color.config import PlotConfig\n", @@ -71,11 +74,7 @@ " add_traffic_lights_to_ax,\n", ")\n", "from py123d.visualization.matplotlib.utils import add_shapely_linestring_to_ax, add_shapely_polygon_to_ax\n", - "from py123d.datatypes.map.abstract_map import AbstractMap\n", - "from py123d.datatypes.map.abstract_map_objects import AbstractLane, AbstractLaneGroup\n", - "from py123d.datatypes.map.gpkg.gpkg_map_objects import GPKGIntersection\n", - "from py123d.datatypes.map.map_datatypes import MapLayer\n", - "from py123d.datatypes.scene.abstract_scene import AbstractScene\n", + "from py123d.api.scene.scene_api import SceneAPI\n", "\n", "\n", "import shapely.geometry as geom\n", @@ -134,7 +133,7 @@ "\n", "def add_debug_map_on_ax(\n", " ax: plt.Axes,\n", - " map_api: AbstractMap,\n", + " map_api: MapAPI,\n", " point_2d: Point2D,\n", " radius: float,\n", " route_lane_group_ids: Optional[List[int]] = None,\n", @@ -142,19 +141,19 @@ " layers: List[MapLayer] = [\n", " # MapLayer.LANE,\n", " MapLayer.LANE_GROUP,\n", - " MapLayer.GENERIC_DRIVABLE,\n", - " MapLayer.CARPARK,\n", + " # MapLayer.GENERIC_DRIVABLE,\n", + " # MapLayer.CARPARK,\n", " # MapLayer.CROSSWALK,\n", " # MapLayer.INTERSECTION,\n", - " MapLayer.WALKWAY,\n", - " MapLayer.ROAD_EDGE,\n", - " MapLayer.ROAD_LINE,\n", + " # MapLayer.WALKWAY,\n", + " # MapLayer.ROAD_EDGE,\n", + " # MapLayer.ROAD_LINE,\n", " ]\n", " x_min, x_max = point_2d.x - radius, point_2d.x + radius\n", " y_min, y_max = point_2d.y - radius, point_2d.y + radius\n", " patch = geom.box(x_min, y_min, x_max, y_max)\n", " map_objects_dict = map_api.query(geometry=patch, layers=layers, predicate=\"intersects\")\n", - " # print(map_objects_dict[MapLayer.ROAD_EDGE])\n", + "\n", "\n", " for layer, map_objects in map_objects_dict.items():\n", " for map_object in map_objects:\n", @@ -163,24 +162,24 @@ " MapLayer.GENERIC_DRIVABLE,\n", " MapLayer.CARPARK,\n", " MapLayer.CROSSWALK,\n", - " # MapLayer.INTERSECTION,\n", + " MapLayer.INTERSECTION,\n", " MapLayer.WALKWAY,\n", " ]:\n", " add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", "\n", " if layer in [MapLayer.LANE_GROUP]:\n", - " map_object: AbstractLaneGroup\n", - " add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", - "\n", + " map_object: LaneGroup\n", + " # add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", " if map_object.intersection is not None:\n", " add_shapely_polygon_to_ax(ax, map_object.intersection.shapely_polygon, ROUTE_CONFIG)\n", "\n", - " for lane in map_object.lanes:\n", - " add_shapely_linestring_to_ax(ax, lane.centerline.linestring, CENTERLINE_CONFIG)\n", + " for lane in map_object.lanes:\n", + " # print(lane.object_id)\n", + " add_shapely_linestring_to_ax(ax, lane.centerline.linestring, CENTERLINE_CONFIG)\n", "\n", - " # if layer in [MapLayer.LANE]:\n", - " # add_shapely_linestring_to_ax(ax, map_object.centerline.linestring, CENTERLINE_CONFIG)\n", - " # add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", + " if layer in [MapLayer.LANE]:\n", + " add_shapely_linestring_to_ax(ax, map_object.centerline.linestring, CENTERLINE_CONFIG)\n", + " add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", "\n", " if layer in [MapLayer.ROAD_EDGE]:\n", " add_shapely_linestring_to_ax(ax, map_object.polyline_3d.linestring, ROAD_EDGE_CONFIG)\n", @@ -207,7 +206,7 @@ " # ax.set_title(f\"Map: {map_api.map_name}\")\n", "\n", "\n", - "def _plot_scene_on_ax(ax: plt.Axes, scene: AbstractScene, iteration: int = 0, radius: float = 80) -> plt.Axes:\n", + "def _plot_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: float = 80) -> plt.Axes:\n", "\n", " ego_vehicle_state = scene.get_ego_state_at_iteration(iteration)\n", " box_detections = scene.get_box_detections_at_iteration(iteration)\n", @@ -217,7 +216,6 @@ " if map_api is not None:\n", " # add_debug_map_on_ax(ax, scene.get_map_api(), point_2d, radius=radius, route_lane_group_ids=None)\n", "\n", - "\n", " add_default_map_on_ax(ax, map_api, point_2d, radius=radius, route_lane_group_ids=None)\n", " # add_traffic_lights_to_ax(ax, traffic_light_detections, scene.get_map_api())\n", "\n", @@ -233,7 +231,7 @@ "\n", "\n", "def plot_scene_at_iteration(\n", - " scene: AbstractScene, iteration: int = 0, radius: float = 80\n", + " scene: SceneAPI, iteration: int = 0, radius: float = 80\n", ") -> Tuple[plt.Figure, plt.Axes]:\n", "\n", " size = 10\n", @@ -262,6 +260,18 @@ "id": "3", "metadata": {}, "outputs": [], + "source": [ + "map_api = scene.get_map_api()\n", + "\n", + "map_api.get_map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], "source": [] } ], diff --git a/notebooks/bev_render.ipynb b/notebooks/bev_render.ipynb index 1bc41014..4fb28d29 100644 --- a/notebooks/bev_render.ipynb +++ b/notebooks/bev_render.ipynb @@ -7,8 +7,8 @@ "metadata": {}, "outputs": [], "source": [ - "from py123d.datatypes.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.datatypes.scene.scene_filter import SceneFilter\n", + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.api.scene.scene_filter import SceneFilter\n", "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", "# from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType " diff --git a/notebooks/camera_matplotlib.ipynb b/notebooks/camera_matplotlib.ipynb index b33cfdd8..f03c8d37 100644 --- a/notebooks/camera_matplotlib.ipynb +++ b/notebooks/camera_matplotlib.ipynb @@ -7,8 +7,8 @@ "metadata": {}, "outputs": [], "source": [ - "from py123d.datatypes.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.datatypes.scene.scene_filter import SceneFilter\n", + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.api.scene.scene_filter import SceneFilter\n", "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType" @@ -60,7 +60,7 @@ "outputs": [], "source": [ "from matplotlib import pyplot as plt\n", - "from py123d.datatypes.scene.abstract_scene import AbstractScene\n", + "from py123d.api.scene.abstract_scene import AbstractScene\n", "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax, add_camera_ax\n", "\n", "iteration = 0\n", diff --git a/notebooks/camera_render.ipynb b/notebooks/camera_render.ipynb index 4365c424..dca62416 100644 --- a/notebooks/camera_render.ipynb +++ b/notebooks/camera_render.ipynb @@ -7,8 +7,8 @@ "metadata": {}, "outputs": [], "source": [ - "from py123d.datatypes.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.datatypes.scene.scene_filter import SceneFilter\n", + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.api.scene.scene_filter import SceneFilter\n", "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType\n", @@ -63,7 +63,7 @@ "outputs": [], "source": [ "from matplotlib import pyplot as plt\n", - "from py123d.datatypes.scene.abstract_scene import AbstractScene\n", + "from py123d.api.scene.abstract_scene import AbstractScene\n", "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax, add_camera_ax\n", "import imageio\n", "import numpy as np\n", diff --git a/src/py123d/api/__init__.py b/src/py123d/api/__init__.py new file mode 100644 index 00000000..89c5f7b3 --- /dev/null +++ b/src/py123d/api/__init__.py @@ -0,0 +1,5 @@ +from py123d.api.map.map_api import MapAPI +from py123d.api.scene.scene_api import SceneAPI +from py123d.api.scene.scene_builder import SceneBuilder +from py123d.api.scene.scene_filter import SceneFilter +from py123d.api.scene.scene_metadata import SceneMetadata diff --git a/src/py123d/datatypes/map/cache/__init__.py b/src/py123d/api/map/__init__.py similarity index 100% rename from src/py123d/datatypes/map/cache/__init__.py rename to src/py123d/api/map/__init__.py diff --git a/src/py123d/datatypes/map/gpkg/gpkg_map.py b/src/py123d/api/map/gpkg/gpkg_map_api.py similarity index 54% rename from src/py123d/datatypes/map/gpkg/gpkg_map.py rename to src/py123d/api/map/gpkg/gpkg_map_api.py index d95d970a..935e6984 100644 --- a/src/py123d/datatypes/map/gpkg/gpkg_map.py +++ b/src/py123d/api/map/gpkg/gpkg_map_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import warnings from collections import defaultdict from functools import lru_cache @@ -10,34 +11,35 @@ import shapely import shapely.geometry as geom -from py123d.datatypes.map.abstract_map import AbstractMap -from py123d.datatypes.map.abstract_map_objects import AbstractMapObject -from py123d.datatypes.map.gpkg.gpkg_map_objects import ( - GPKGCarpark, - GPKGCrosswalk, - GPKGGenericDrivable, - GPKGIntersection, - GPKGLane, - GPKGLaneGroup, - GPKGRoadEdge, - GPKGRoadLine, - GPKGWalkway, +from py123d.api.map.gpkg.gpkg_utils import get_row_with_value, load_gdf_with_geometry_columns +from py123d.api.map.map_api import MapAPI +from py123d.datatypes.map_objects.base_map_objects import BaseMapObject +from py123d.datatypes.map_objects.map_layer_types import MapLayer, RoadEdgeType, RoadLineType +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, + Walkway, ) -from py123d.datatypes.map.gpkg.gpkg_utils import load_gdf_with_geometry_columns -from py123d.datatypes.map.map_datatypes import MapLayer -from py123d.datatypes.map.map_metadata import MapMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.geometry import Point2D +from py123d.geometry.polyline import Polyline3D from py123d.script.utils.dataset_path_utils import get_dataset_paths USE_ARROW: bool = True MAX_LRU_CACHED_TABLES: Final[int] = 128 # TODO: add to some configs -class GPKGMap(AbstractMap): +class GPKGMapAPI(MapAPI): def __init__(self, file_path: Path) -> None: self._file_path = file_path - self._map_object_getter: Dict[MapLayer, Callable[[str], Optional[AbstractMapObject]]] = { + self._map_object_getter: Dict[MapLayer, Callable[[str], Optional[BaseMapObject]]] = { MapLayer.LANE: self._get_lane, MapLayer.LANE_GROUP: self._get_lane_group, MapLayer.INTERSECTION: self._get_intersection, @@ -94,7 +96,7 @@ def get_available_map_objects(self) -> List[MapLayer]: self._assert_initialize() return list(self._gpd_dataframes.keys()) - def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[AbstractMapObject]: + def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[BaseMapObject]: """Inherited, see superclass.""" self._assert_initialize() @@ -104,7 +106,7 @@ def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[AbstractMa except KeyError: raise ValueError(f"Object representation for layer: {layer.name} object: {object_id} is unavailable") - def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[AbstractMapObject]: + def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[BaseMapObject]: """Inherited, see superclass.""" raise NotImplementedError @@ -114,7 +116,7 @@ def is_in_layer(self, point: Point2D, layer: MapLayer) -> bool: def get_proximal_map_objects( self, point_2d: Point2D, radius: float, layers: List[MapLayer] - ) -> Dict[MapLayer, List[AbstractMapObject]]: + ) -> Dict[MapLayer, List[BaseMapObject]]: """Inherited, see superclass.""" center_point = geom.Point(point_2d.x, point_2d.y) patch = center_point.buffer(radius) @@ -127,13 +129,11 @@ def query( predicate: Optional[str] = None, sort: bool = False, distance: Optional[float] = None, - ) -> Dict[MapLayer, Union[List[AbstractMapObject], Dict[int, List[AbstractMapObject]]]]: + ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: supported_layers = self.get_available_map_objects() unsupported_layers = [layer for layer in layers if layer not in supported_layers] assert len(unsupported_layers) == 0, f"Object representation for layer(s): {unsupported_layers} is unavailable" - object_map: Dict[MapLayer, Union[List[AbstractMapObject], Dict[int, List[AbstractMapObject]]]] = defaultdict( - list - ) + object_map: Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]] = defaultdict(list) for layer in layers: object_map[layer] = self._query_layer(geometry, layer, predicate, sort, distance) return object_map @@ -180,13 +180,13 @@ def _query_layer( predicate: Optional[str] = None, sort: bool = False, distance: Optional[float] = None, - ) -> Union[List[AbstractMapObject], Dict[int, List[AbstractMapObject]]]: + ) -> Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]: queried_indices = self._gpd_dataframes[layer].sindex.query( geometry, predicate=predicate, sort=sort, distance=distance ) if queried_indices.ndim == 2: - query_dict: Dict[int, List[AbstractMapObject]] = defaultdict(list) + query_dict: Dict[int, List[BaseMapObject]] = defaultdict(list) for geometry_idx, map_object_idx in zip(queried_indices[0], queried_indices[1]): map_object_id = self._gpd_dataframes[layer]["id"].iloc[map_object_idx] query_dict[int(geometry_idx)].append(self.get_map_object(map_object_id, layer)) @@ -253,142 +253,218 @@ def _query_layer_nearest( else: return list(ids[queried_indices]) - def _get_lane(self, id: str) -> Optional[GPKGLane]: - return ( - GPKGLane( - id, - self._gpd_dataframes[MapLayer.LANE], - self._gpd_dataframes[MapLayer.LANE_GROUP], - self._gpd_dataframes[MapLayer.INTERSECTION], + def _get_lane(self, id: str) -> Optional[Lane]: + lane: Optional[Lane] = None + lane_row = get_row_with_value(self._gpd_dataframes[MapLayer.LANE], "id", id) + + if lane_row is not None: + object_id: str = lane_row["id"] + lane_group_id: str = lane_row["lane_group_id"] + left_boundary: Polyline3D = Polyline3D.from_linestring(lane_row["left_boundary"]) + right_boundary: Optional[Polyline3D] = Polyline3D.from_linestring(lane_row["right_boundary"]) + centerline: Polyline3D = Polyline3D.from_linestring(lane_row["centerline"]) + left_lane_id: Optional[str] = lane_row["left_lane_id"] + right_lane_id: Optional[str] = lane_row["right_lane_id"] + predecessor_ids: List[str] = ast.literal_eval(lane_row.predecessor_ids) + successor_ids: List[str] = ast.literal_eval(lane_row.successor_ids) + speed_limit_mps: Optional[float] = lane_row["speed_limit_mps"] + outline: Optional[Polyline3D] = ( + Polyline3D.from_linestring(lane_row["outline"]) if lane_row["outline"] is not None else None + ) + geometry: geom.LineString = lane_row["geometry"] + + lane = Lane( + object_id=object_id, + lane_group_id=lane_group_id, + left_boundary=left_boundary, + right_boundary=right_boundary, + centerline=centerline, + left_lane_id=left_lane_id, + right_lane_id=right_lane_id, + predecessor_ids=predecessor_ids, + successor_ids=successor_ids, + speed_limit_mps=speed_limit_mps, + outline=outline, + geometry=geometry, + map_api=self, ) - if id in self._gpd_dataframes[MapLayer.LANE]["id"].tolist() - else None - ) - def _get_lane_group(self, id: str) -> Optional[GPKGLaneGroup]: - return ( - GPKGLaneGroup( - id, - self._gpd_dataframes[MapLayer.LANE_GROUP], - self._gpd_dataframes[MapLayer.LANE], - self._gpd_dataframes[MapLayer.INTERSECTION], + return lane + + def _get_lane_group(self, id: str) -> Optional[LaneGroup]: + lane_group: Optional[LaneGroup] = None + lane_group_row = get_row_with_value(self._gpd_dataframes[MapLayer.LANE_GROUP], "id", id) + if lane_group_row is not None: + + object_id: str = lane_group_row["id"] + lane_ids: List[str] = ast.literal_eval(lane_group_row.lane_ids) + left_boundary: Polyline3D = Polyline3D.from_linestring(lane_group_row["left_boundary"]) + right_boundary: Optional[Polyline3D] = Polyline3D.from_linestring(lane_group_row["right_boundary"]) + intersection_id: Optional[str] = lane_group_row["intersection_id"] + predecessor_ids: Optional[List[str]] = ast.literal_eval(lane_group_row.predecessor_ids) + successor_ids: Optional[List[str]] = ast.literal_eval(lane_group_row.successor_ids) + outline: Optional[Polyline3D] = ( + Polyline3D.from_linestring(lane_group_row["outline"]) if lane_group_row["outline"] is not None else None + ) + geometry: geom.Polygon = lane_group_row["geometry"] + + lane_group = LaneGroup( + object_id=object_id, + lane_ids=lane_ids, + left_boundary=left_boundary, + right_boundary=right_boundary, + intersection_id=intersection_id, + predecessor_ids=predecessor_ids, + successor_ids=successor_ids, + outline=outline, + geometry=geometry, + map_api=self, ) - if id in self._gpd_dataframes[MapLayer.LANE_GROUP]["id"].tolist() - else None - ) - def _get_intersection(self, id: str) -> Optional[GPKGIntersection]: - return ( - GPKGIntersection( - id, - self._gpd_dataframes[MapLayer.INTERSECTION], - self._gpd_dataframes[MapLayer.LANE], - self._gpd_dataframes[MapLayer.LANE_GROUP], + return lane_group + + def _get_intersection(self, id: str) -> Optional[Intersection]: + + intersection: Optional[Intersection] = None + intersection_row = get_row_with_value(self._gpd_dataframes[MapLayer.INTERSECTION], "id", id) + if intersection_row is not None: + + object_id: str = intersection_row["id"] + lane_group_ids: List[str] = ast.literal_eval(intersection_row.lane_group_ids) + outline: Optional[Polyline3D] = ( + Polyline3D.from_linestring(intersection_row["outline"]) + if intersection_row["outline"] is not None + else None + ) + geometry: geom.Polygon = intersection_row["geometry"] + + intersection = Intersection( + object_id=object_id, + lane_group_ids=lane_group_ids, + outline=outline, + geometry=geometry, + map_api=self, ) - if id in self._gpd_dataframes[MapLayer.INTERSECTION]["id"].tolist() - else None - ) - def _get_crosswalk(self, id: str) -> Optional[GPKGCrosswalk]: - return ( - GPKGCrosswalk(id, self._gpd_dataframes[MapLayer.CROSSWALK]) - if id in self._gpd_dataframes[MapLayer.CROSSWALK]["id"].tolist() - else None - ) + return intersection - def _get_walkway(self, id: str) -> Optional[GPKGWalkway]: - return ( - GPKGWalkway(id, self._gpd_dataframes[MapLayer.WALKWAY]) - if id in self._gpd_dataframes[MapLayer.WALKWAY]["id"].tolist() - else None - ) + def _get_crosswalk(self, id: str) -> Optional[Crosswalk]: + crosswalk: Optional[Crosswalk] = None + crosswalk_row = get_row_with_value(self._gpd_dataframes[MapLayer.CROSSWALK], "id", id) + if crosswalk_row is not None: - def _get_carpark(self, id: str) -> Optional[GPKGCarpark]: - return ( - GPKGCarpark(id, self._gpd_dataframes[MapLayer.CARPARK]) - if id in self._gpd_dataframes[MapLayer.CARPARK]["id"].tolist() - else None - ) + object_id: str = crosswalk_row["id"] + outline: Polyline3D = Polyline3D.from_linestring(crosswalk_row["outline"]) + geometry: geom.Polygon = crosswalk_row["geometry"] - def _get_generic_drivable(self, id: str) -> Optional[GPKGGenericDrivable]: - return ( - GPKGGenericDrivable(id, self._gpd_dataframes[MapLayer.GENERIC_DRIVABLE]) - if id in self._gpd_dataframes[MapLayer.GENERIC_DRIVABLE]["id"].tolist() - else None - ) + crosswalk = Crosswalk( + object_id=object_id, + outline=outline, + geometry=geometry, + ) - def _get_road_edge(self, id: str) -> Optional[GPKGRoadEdge]: - return ( - GPKGRoadEdge(id, self._gpd_dataframes[MapLayer.ROAD_EDGE]) - if id in self._gpd_dataframes[MapLayer.ROAD_EDGE]["id"].tolist() - else None - ) + return crosswalk - def _get_road_line(self, id: str) -> Optional[GPKGRoadLine]: - return ( - GPKGRoadLine(id, self._gpd_dataframes[MapLayer.ROAD_LINE]) - if id in self._gpd_dataframes[MapLayer.ROAD_LINE]["id"].tolist() - else None - ) + def _get_walkway(self, id: str) -> Optional[Walkway]: + walkway: Optional[Walkway] = None + walkway_row = get_row_with_value(self._gpd_dataframes[MapLayer.WALKWAY], "id", id) + if walkway_row is not None: + + object_id: str = walkway_row["id"] + outline: Polyline3D = Polyline3D.from_linestring(walkway_row["outline"]) + geometry: geom.Polygon = walkway_row["geometry"] + + walkway = Walkway( + object_id=object_id, + outline=outline, + geometry=geometry, + ) + + return walkway + + def _get_carpark(self, id: str) -> Optional[Carpark]: + carpark: Optional[Carpark] = None + carpark_row = get_row_with_value(self._gpd_dataframes[MapLayer.CARPARK], "id", id) + if carpark_row is not None: + + object_id: str = carpark_row["id"] + outline: Polyline3D = Polyline3D.from_linestring(carpark_row["outline"]) + geometry: geom.Polygon = carpark_row["geometry"] + + carpark = Carpark( + object_id=object_id, + outline=outline, + geometry=geometry, + ) + + return carpark + + def _get_generic_drivable(self, id: str) -> Optional[GenericDrivable]: + generic_drivable: Optional[GenericDrivable] = None + generic_drivable_row = get_row_with_value(self._gpd_dataframes[MapLayer.GENERIC_DRIVABLE], "id", id) + if generic_drivable_row is not None: + + object_id: str = generic_drivable_row["id"] + outline: Polyline3D = Polyline3D.from_linestring(generic_drivable_row["outline"]) + geometry: geom.Polygon = generic_drivable_row["geometry"] + + generic_drivable = GenericDrivable( + object_id=object_id, + outline=outline, + geometry=geometry, + ) + + return generic_drivable + + def _get_road_edge(self, id: str) -> Optional[RoadEdge]: + road_edge: Optional[RoadEdge] = None + road_edge_row = get_row_with_value(self._gpd_dataframes[MapLayer.ROAD_EDGE], "id", id) + if road_edge_row is not None: + + object_id: str = road_edge_row["id"] + polyline: Polyline3D = Polyline3D.from_linestring(road_edge_row["geometry"]) + road_edge_type: RoadEdgeType = RoadEdgeType(road_edge_row["road_edge_type"]) + + road_edge = RoadEdge( + object_id=object_id, + road_edge_type=road_edge_type, + polyline=polyline, + ) + + return road_edge + + def _get_road_line(self, id: str) -> Optional[RoadLine]: + road_line: Optional[RoadLine] = None + road_line_row = get_row_with_value(self._gpd_dataframes[MapLayer.ROAD_LINE], "id", id) + if road_line_row is not None: + + object_id: str = road_line_row["id"] + polyline: Polyline3D = Polyline3D.from_linestring(road_line_row["geometry"]) + road_line_type: RoadLineType = RoadLineType(road_line_row["road_line_type"]) + + road_line = RoadLine( + object_id=object_id, + road_line_type=road_line_type, + polyline=polyline, + ) - # def _query_layer( - # self, - # geometry: Union[shapely.Geometry, Iterable[shapely.Geometry]], - # layer: MapLayer, - # predicate: Optional[str] = None, - # sort: bool = False, - # distance: Optional[float] = None, - # ) -> Union[List[AbstractMapObject], Dict[int, List[AbstractMapObject]]]: - # queried_indices = self._gpd_dataframes[layer].sindex.query( - # geometry, predicate=predicate, sort=sort, distance=distance - # ) - # ids = self._gpd_dataframes[layer]["id"].values # numpy array for fast access - # if queried_indices.ndim == 2: - # query_dict: Dict[int, List[AbstractMapObject]] = defaultdict(list) - # for geometry_idx, map_object_idx in zip(queried_indices[0], queried_indices[1]): - # map_object_id = ids[map_object_idx] - # query_dict[int(geometry_idx)].append(self.get_map_object(map_object_id, layer)) - # return query_dict - # else: - # map_object_ids = ids[queried_indices] - # return [self.get_map_object(map_object_id, layer) for map_object_id in map_object_ids] - - # def _query_layer_objects_ids( - # self, - # geometry: Union[shapely.Geometry, Iterable[shapely.Geometry]], - # layer: MapLayer, - # predicate: Optional[str] = None, - # sort: bool = False, - # distance: Optional[float] = None, - # ) -> Union[List[AbstractMapObject], Dict[int, List[AbstractMapObject]]]: - # queried_indices = self._gpd_dataframes[layer].sindex.query( - # geometry, predicate=predicate, sort=sort, distance=distance - # ) - # if queried_indices.ndim == 2: - # query_dict: Dict[int, List[AbstractMapObject]] = defaultdict(list) - # for geometry_idx, map_object_idx in zip(queried_indices[0], queried_indices[1]): - # map_object_id = self._gpd_dataframes[layer]["id"].iloc[map_object_idx] - # query_dict[int(geometry_idx)].append(map_object_id) - # return query_dict - # else: - # map_object_ids = self._gpd_dataframes[layer]["id"].iloc[queried_indices] - # return list(map_object_ids) + return road_line @lru_cache(maxsize=MAX_LRU_CACHED_TABLES) -def get_global_map_api(dataset: str, location: str) -> GPKGMap: +def get_global_map_api(dataset: str, location: str) -> GPKGMapAPI: PY123D_MAPS_ROOT: Path = Path(get_dataset_paths().py123d_maps_root) gpkg_path = PY123D_MAPS_ROOT / dataset / f"{dataset}_{location}.gpkg" assert gpkg_path.is_file(), f"{dataset}_{location}.gpkg not found in {str(PY123D_MAPS_ROOT)}." - map_api = GPKGMap(gpkg_path) + map_api = GPKGMapAPI(gpkg_path) map_api.initialize() return map_api -def get_local_map_api(split_name: str, log_name: str) -> GPKGMap: +def get_local_map_api(split_name: str, log_name: str) -> GPKGMapAPI: PY123D_MAPS_ROOT: Path = Path(get_dataset_paths().py123d_maps_root) gpkg_path = PY123D_MAPS_ROOT / split_name / f"{log_name}.gpkg" assert gpkg_path.is_file(), f"{log_name}.gpkg not found in {str(PY123D_MAPS_ROOT)}." - map_api = GPKGMap(gpkg_path) + map_api = GPKGMapAPI(gpkg_path) map_api.initialize() return map_api diff --git a/src/py123d/api/map/gpkg/gpkg_utils.py b/src/py123d/api/map/gpkg/gpkg_utils.py new file mode 100644 index 00000000..e7d81fa3 --- /dev/null +++ b/src/py123d/api/map/gpkg/gpkg_utils.py @@ -0,0 +1,58 @@ +from typing import List, Optional + +import geopandas as gpd +import numpy as np +import pandas as pd +from shapely import wkt + + +def load_gdf_with_geometry_columns(gdf: gpd.GeoDataFrame, geometry_column_names: List[str] = []): + # TODO: refactor + # Convert string geometry columns back to shapely objects + for col in geometry_column_names: + if col in gdf.columns and len(gdf) > 0 and isinstance(gdf[col].iloc[0], str): + try: + gdf[col] = gdf[col].apply(lambda x: wkt.loads(x) if isinstance(x, str) else x) + except Exception as e: + print(f"Warning: Could not convert column {col} to geometry: {str(e)}") + + +def get_all_rows_with_value( + elements: gpd.geodataframe.GeoDataFrame, column_label: str, desired_value: str +) -> Optional[gpd.geodataframe.GeoDataFrame]: + """ + Extract all matching elements. Note, if no matching desired_key is found and empty list is returned. + :param elements: data frame from MapsDb. + :param column_label: key to extract from a column. + :param desired_value: key which is compared with the values of column_label entry. + :return: a subset of the original GeoDataFrame containing the matching key. + """ + if desired_value is None or pd.isna(desired_value): + return None + + return elements.iloc[np.where(elements[column_label].to_numpy().astype(int) == int(desired_value))] + + +def get_row_with_value( + elements: gpd.geodataframe.GeoDataFrame, column_label: str, desired_value: str +) -> Optional[gpd.GeoSeries]: + """ + Extract a matching element. + :param elements: data frame from MapsDb. + :param column_label: key to extract from a column. + :param desired_value: key which is compared with the values of column_label entry. + :return row from GeoDataFrame. + """ + if column_label == "fid": + return elements.loc[desired_value] + + geo_series: Optional[gpd.GeoSeries] = None + matching_rows = get_all_rows_with_value(elements, column_label, desired_value) + if matching_rows is not None: + + assert len(matching_rows) > 0, f"Could not find the desired key = {desired_value}" + assert len(matching_rows) == 1, ( + f"{len(matching_rows)} matching keys found. Expected to only find one." "Try using get_all_rows_with_value" + ) + geo_series = matching_rows.iloc[0] + return geo_series diff --git a/src/py123d/datatypes/map/abstract_map.py b/src/py123d/api/map/map_api.py similarity index 80% rename from src/py123d/datatypes/map/abstract_map.py rename to src/py123d/api/map/map_api.py index be334129..df1c4d18 100644 --- a/src/py123d/datatypes/map/abstract_map.py +++ b/src/py123d/api/map/map_api.py @@ -5,9 +5,9 @@ import shapely -from py123d.datatypes.map.abstract_map_objects import AbstractMapObject -from py123d.datatypes.map.map_datatypes import MapLayer -from py123d.datatypes.map.map_metadata import MapMetadata +from py123d.datatypes.map_objects.base_map_objects import BaseMapObject +from py123d.datatypes.map_objects.map_layer_types import MapLayer +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.geometry import Point2D # TODO: @@ -17,7 +17,7 @@ # - Add stop pads or stop lines. -class AbstractMap(abc.ABC): +class MapAPI(abc.ABC): @abc.abstractmethod def get_map_metadata(self) -> MapMetadata: @@ -32,11 +32,11 @@ def get_available_map_objects(self) -> List[MapLayer]: pass @abc.abstractmethod - def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[AbstractMapObject]: + def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[BaseMapObject]: pass @abc.abstractmethod - def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[AbstractMapObject]: + def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[BaseMapObject]: pass @abc.abstractmethod @@ -46,7 +46,7 @@ def is_in_layer(self, point: Point2D, layer: MapLayer) -> bool: @abc.abstractmethod def get_proximal_map_objects( self, point: Point2D, radius: float, layers: List[MapLayer] - ) -> Dict[MapLayer, List[AbstractMapObject]]: + ) -> Dict[MapLayer, List[BaseMapObject]]: pass @abc.abstractmethod @@ -57,7 +57,7 @@ def query( predicate: Optional[str] = None, sort: bool = False, distance: Optional[float] = None, - ) -> Dict[MapLayer, Union[List[AbstractMapObject], Dict[int, List[AbstractMapObject]]]]: + ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: pass @abc.abstractmethod @@ -80,7 +80,7 @@ def query_nearest( max_distance: Optional[float] = None, return_distance: bool = False, exclusive: bool = False, - ) -> Dict[MapLayer, Union[List[AbstractMapObject], Dict[int, List[AbstractMapObject]]]]: + ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: pass @property diff --git a/src/py123d/api/scene/__init__.py b/src/py123d/api/scene/__init__.py new file mode 100644 index 00000000..51c4ddc5 --- /dev/null +++ b/src/py123d/api/scene/__init__.py @@ -0,0 +1,3 @@ +from py123d.api.scene.scene_api import SceneAPI +from py123d.api.scene.scene_builder import SceneBuilder +from py123d.api.scene.scene_filter import SceneFilter diff --git a/src/py123d/datatypes/map/gpkg/__init__.py b/src/py123d/api/scene/arrow/__init__.py similarity index 100% rename from src/py123d/datatypes/map/gpkg/__init__.py rename to src/py123d/api/scene/arrow/__init__.py diff --git a/src/py123d/datatypes/scene/arrow/arrow_scene.py b/src/py123d/api/scene/arrow/arrow_scene.py similarity index 87% rename from src/py123d/datatypes/scene/arrow/arrow_scene.py rename to src/py123d/api/scene/arrow/arrow_scene.py index f359c1b3..7ea2af01 100644 --- a/src/py123d/datatypes/scene/arrow/arrow_scene.py +++ b/src/py123d/api/scene/arrow/arrow_scene.py @@ -3,13 +3,9 @@ import pyarrow as pa -from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table -from py123d.datatypes.detections.box_detections import BoxDetectionWrapper -from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.map.abstract_map import AbstractMap -from py123d.datatypes.map.gpkg.gpkg_map import get_global_map_api, get_local_map_api -from py123d.datatypes.scene.abstract_scene import AbstractScene -from py123d.datatypes.scene.arrow.utils.arrow_getters import ( +from py123d.api.map.gpkg.gpkg_map_api import get_global_map_api, get_local_map_api +from py123d.api.map.map_api import MapAPI +from py123d.api.scene.arrow.utils.arrow_getters import ( get_box_detections_se3_from_arrow_table, get_camera_from_arrow_table, get_ego_state_se3_from_arrow_table, @@ -18,8 +14,13 @@ get_timepoint_from_arrow_table, get_traffic_light_detections_from_arrow_table, ) -from py123d.datatypes.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow -from py123d.datatypes.scene.scene_metadata import LogMetadata, SceneExtractionMetadata +from py123d.api.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow +from py123d.api.scene.scene_api import SceneAPI +from py123d.api.scene.scene_metadata import SceneMetadata +from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table +from py123d.datatypes.detections.box_detections import BoxDetectionWrapper +from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper +from py123d.datatypes.metadata.log_metadata import LogMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICamera, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDAR, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType @@ -27,12 +28,12 @@ from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -class ArrowScene(AbstractScene): +class ArrowSceneAPI(SceneAPI): def __init__( self, arrow_file_path: Union[Path, str], - scene_extraction_metadata: Optional[SceneExtractionMetadata] = None, + scene_extraction_metadata: Optional[SceneMetadata] = None, ) -> None: self._arrow_file_path: Path = Path(arrow_file_path) @@ -45,18 +46,18 @@ def __init__( initial_uuid = table["uuid"][0].as_py() if scene_extraction_metadata is None: - scene_extraction_metadata = SceneExtractionMetadata( + scene_extraction_metadata = SceneMetadata( initial_uuid=initial_uuid, initial_idx=0, duration_s=self._log_metadata.timestep_seconds * num_rows, history_s=0.0, iteration_duration_s=self._log_metadata.timestep_seconds, ) - self._scene_extraction_metadata: SceneExtractionMetadata = scene_extraction_metadata + self._scene_extraction_metadata: SceneMetadata = scene_extraction_metadata # NOTE: Lazy load a log-specific map API, and keep reference. # Global maps are LRU cached internally. - self._local_map_api: Optional[AbstractMap] = None + self._local_map_api: Optional[MapAPI] = None #################################################################################################################### # Helpers for ArrowScene @@ -88,11 +89,11 @@ def _get_table_index(self, iteration: int) -> int: def get_log_metadata(self) -> LogMetadata: return self._log_metadata - def get_scene_extraction_metadata(self) -> SceneExtractionMetadata: + def get_scene_extraction_metadata(self) -> SceneMetadata: return self._scene_extraction_metadata - def get_map_api(self) -> Optional[AbstractMap]: - map_api: Optional[AbstractMap] = None + def get_map_api(self) -> Optional[MapAPI]: + map_api: Optional[MapAPI] = None if self.log_metadata.map_metadata is not None: if self.log_metadata.map_metadata.map_is_local: if self._local_map_api is None: diff --git a/src/py123d/datatypes/scene/arrow/arrow_scene_builder.py b/src/py123d/api/scene/arrow/arrow_scene_builder.py similarity index 89% rename from src/py123d/datatypes/scene/arrow/arrow_scene_builder.py rename to src/py123d/api/scene/arrow/arrow_scene_builder.py index 211209d1..8f0d3e69 100644 --- a/src/py123d/datatypes/scene/arrow/arrow_scene_builder.py +++ b/src/py123d/api/scene/arrow/arrow_scene_builder.py @@ -3,15 +3,15 @@ from pathlib import Path from typing import Iterator, List, Optional, Set, Union +from py123d.api.scene.arrow.arrow_scene import ArrowSceneAPI +from py123d.api.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow +from py123d.api.scene.scene_api import SceneAPI +from py123d.api.scene.scene_builder import SceneBuilder +from py123d.api.scene.scene_filter import SceneFilter +from py123d.api.scene.scene_metadata import SceneMetadata from py123d.common.multithreading.worker_utils import WorkerPool, worker_map from py123d.common.utils.arrow_column_names import FISHEYE_CAMERA_DATA_COLUMN, PINHOLE_CAMERA_DATA_COLUMN, UUID_COLUMN from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table -from py123d.datatypes.scene.abstract_scene import AbstractScene -from py123d.datatypes.scene.abstract_scene_builder import SceneBuilder -from py123d.datatypes.scene.arrow.arrow_scene import ArrowScene -from py123d.datatypes.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow -from py123d.datatypes.scene.scene_filter import SceneFilter -from py123d.datatypes.scene.scene_metadata import SceneExtractionMetadata from py123d.script.utils.dataset_path_utils import get_dataset_paths @@ -33,7 +33,7 @@ def __init__( self._logs_root = Path(logs_root) self._maps_root = Path(maps_root) - def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[AbstractScene]: + def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[SceneAPI]: """See superclass.""" split_types = set(filter.split_types) if filter.split_types else {"train", "val", "test"} @@ -79,8 +79,8 @@ def _discover_log_paths(logs_root: Path, split_names: Set[str], log_names: Optio return log_paths -def _extract_scenes_from_logs(log_paths: List[Path], filter: SceneFilter) -> List[AbstractScene]: - scenes: List[AbstractScene] = [] +def _extract_scenes_from_logs(log_paths: List[Path], filter: SceneFilter) -> List[SceneAPI]: + scenes: List[SceneAPI] = [] for log_path in log_paths: try: scene_extraction_metadatas = _get_scene_extraction_metadatas(log_path, filter) @@ -89,7 +89,7 @@ def _extract_scenes_from_logs(log_paths: List[Path], filter: SceneFilter) -> Lis continue for scene_extraction_metadata in scene_extraction_metadatas: scenes.append( - ArrowScene( + ArrowSceneAPI( arrow_file_path=log_path, scene_extraction_metadata=scene_extraction_metadata, ) @@ -97,8 +97,8 @@ def _extract_scenes_from_logs(log_paths: List[Path], filter: SceneFilter) -> Lis return scenes -def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFilter) -> List[SceneExtractionMetadata]: - scene_extraction_metadatas: List[SceneExtractionMetadata] = [] +def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFilter) -> List[SceneMetadata]: + scene_extraction_metadatas: List[SceneMetadata] = [] recording_table = get_lru_cached_arrow_table(log_path) log_metadata = get_log_metadata_from_arrow(log_path) @@ -120,7 +120,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil elif filter.duration_s is None: scene_extraction_metadatas.append( - SceneExtractionMetadata( + SceneMetadata( initial_uuid=str(recording_table[UUID_COLUMN][start_idx].as_py()), initial_idx=start_idx, duration_s=(end_idx - start_idx) * log_metadata.timestep_seconds, @@ -131,10 +131,10 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil else: scene_uuid_set = set(filter.scene_uuids) if filter.scene_uuids is not None else None for idx in range(start_idx, end_idx): - scene_extraction_metadata: Optional[SceneExtractionMetadata] = None + scene_extraction_metadata: Optional[SceneMetadata] = None if scene_uuid_set is None: - scene_extraction_metadata = SceneExtractionMetadata( + scene_extraction_metadata = SceneMetadata( initial_uuid=str(recording_table["uuid"][idx].as_py()), initial_idx=idx, duration_s=filter.duration_s, @@ -142,7 +142,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil iteration_duration_s=log_metadata.timestep_seconds, ) elif str(recording_table["uuid"][idx]) in scene_uuid_set: - scene_extraction_metadata = SceneExtractionMetadata( + scene_extraction_metadata = SceneMetadata( initial_uuid=str(recording_table["uuid"][idx].as_py()), initial_idx=idx, duration_s=filter.duration_s, diff --git a/src/py123d/datatypes/scene/arrow/__init__.py b/src/py123d/api/scene/arrow/utils/__init__.py similarity index 100% rename from src/py123d/datatypes/scene/arrow/__init__.py rename to src/py123d/api/scene/arrow/utils/__init__.py diff --git a/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py b/src/py123d/api/scene/arrow/utils/arrow_getters.py similarity index 99% rename from src/py123d/datatypes/scene/arrow/utils/arrow_getters.py rename to src/py123d/api/scene/arrow/utils/arrow_getters.py index e52e65c9..1672036a 100644 --- a/src/py123d/datatypes/scene/arrow/utils/arrow_getters.py +++ b/src/py123d/api/scene/arrow/utils/arrow_getters.py @@ -45,7 +45,7 @@ TrafficLightDetectionWrapper, TrafficLightStatus, ) -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata.log_metadata import LogMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICamera, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDAR, LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType diff --git a/src/py123d/datatypes/scene/arrow/utils/arrow_metadata_utils.py b/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py similarity index 91% rename from src/py123d/datatypes/scene/arrow/utils/arrow_metadata_utils.py rename to src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py index 3f264b62..e99c3c18 100644 --- a/src/py123d/datatypes/scene/arrow/utils/arrow_metadata_utils.py +++ b/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py @@ -6,7 +6,7 @@ import pyarrow as pa from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata.log_metadata import LogMetadata @lru_cache(maxsize=10000) diff --git a/src/py123d/datatypes/scene/abstract_scene.py b/src/py123d/api/scene/scene_api.py similarity index 91% rename from src/py123d/datatypes/scene/abstract_scene.py rename to src/py123d/api/scene/scene_api.py index 3e664871..9029b5d3 100644 --- a/src/py123d/datatypes/scene/abstract_scene.py +++ b/src/py123d/api/scene/scene_api.py @@ -3,10 +3,11 @@ import abc from typing import List, Optional +from py123d.api.map.map_api import MapAPI +from py123d.api.scene.scene_metadata import SceneMetadata from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.map.abstract_map import AbstractMap -from py123d.datatypes.scene.scene_metadata import LogMetadata, SceneExtractionMetadata +from py123d.datatypes.metadata.log_metadata import LogMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICamera, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDAR, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType @@ -15,7 +16,7 @@ from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters -class AbstractScene(abc.ABC): +class SceneAPI(abc.ABC): #################################################################################################################### # Abstract Methods, to be implemented by subclasses @@ -26,11 +27,11 @@ def get_log_metadata(self) -> LogMetadata: raise NotImplementedError @abc.abstractmethod - def get_scene_extraction_metadata(self) -> SceneExtractionMetadata: + def get_scene_extraction_metadata(self) -> SceneMetadata: raise NotImplementedError @abc.abstractmethod - def get_map_api(self) -> Optional[AbstractMap]: + def get_map_api(self) -> Optional[MapAPI]: raise NotImplementedError @abc.abstractmethod @@ -100,7 +101,7 @@ def available_lidar_types(self) -> List[LiDARType]: # 2. Scene Extraction Metadata properties @property - def scene_extraction_metadata(self) -> SceneExtractionMetadata: + def scene_extraction_metadata(self) -> SceneMetadata: return self.get_scene_extraction_metadata() @property diff --git a/src/py123d/datatypes/scene/abstract_scene_builder.py b/src/py123d/api/scene/scene_builder.py similarity index 80% rename from src/py123d/datatypes/scene/abstract_scene_builder.py rename to src/py123d/api/scene/scene_builder.py index 17652549..c65b8e22 100644 --- a/src/py123d/datatypes/scene/abstract_scene_builder.py +++ b/src/py123d/api/scene/scene_builder.py @@ -1,9 +1,9 @@ import abc from typing import Iterator +from py123d.api.scene.scene_api import SceneAPI +from py123d.api.scene.scene_filter import SceneFilter from py123d.common.multithreading.worker_utils import WorkerPool -from py123d.datatypes.scene.abstract_scene import AbstractScene -from py123d.datatypes.scene.scene_filter import SceneFilter class SceneBuilder(abc.ABC): @@ -12,7 +12,7 @@ class SceneBuilder(abc.ABC): """ @abc.abstractmethod - def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[AbstractScene]: + def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[SceneAPI]: """ Returns an iterator over scenes that match the given filter. :param filter: SceneFilter object to filter the scenes. diff --git a/src/py123d/datatypes/scene/scene_filter.py b/src/py123d/api/scene/scene_filter.py similarity index 100% rename from src/py123d/datatypes/scene/scene_filter.py rename to src/py123d/api/scene/scene_filter.py diff --git a/src/py123d/api/scene/scene_metadata.py b/src/py123d/api/scene/scene_metadata.py new file mode 100644 index 00000000..103a1d7e --- /dev/null +++ b/src/py123d/api/scene/scene_metadata.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SceneMetadata: + + initial_uuid: str + initial_idx: int + duration_s: float + history_s: float + iteration_duration_s: float + + @property + def number_of_iterations(self) -> int: + return round(self.duration_s / self.iteration_duration_s) + + @property + def number_of_history_iterations(self) -> int: + return round(self.history_s / self.iteration_duration_s) + + @property + def end_idx(self) -> int: + return self.initial_idx + self.number_of_iterations diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index 1d4ec2b2..33bde033 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -15,16 +15,16 @@ split_line_geometry_by_max_length, ) from py123d.conversion.utils.map_utils.road_edge.road_edge_3d_utils import lift_road_edges_to_3d -from py123d.datatypes.map.cache.cache_map_objects import ( - CacheCrosswalk, - CacheGenericDrivable, - CacheIntersection, - CacheLane, - CacheLaneGroup, - CacheRoadEdge, - CacheRoadLine, +from py123d.datatypes.map_objects.map_layer_types import RoadEdgeType +from py123d.datatypes.map_objects.map_objects import ( + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, ) -from py123d.datatypes.map.map_datatypes import RoadEdgeType from py123d.geometry import OccupancyMap2D, Point3DIndex, Polyline2D, Polyline3D LANE_GROUP_MARK_TYPES: List[str] = [ @@ -118,7 +118,7 @@ def _get_centerline_from_boundaries( right_lane_id = lane_dict["right_neighbor_id"] if lane_dict["right_neighbor_id"] in lanes else None map_writer.write_lane( - CacheLane( + Lane( object_id=lane_id, lane_group_id=lane_dict["lane_group_id"], left_boundary=lane_dict["left_lane_boundary"], @@ -140,7 +140,7 @@ def _write_av2_lane_group(lane_group_dict: Dict[int, Any], map_writer: AbstractM for lane_group_id, lane_group_values in lane_group_dict.items(): map_writer.write_lane_group( - CacheLaneGroup( + LaneGroup( object_id=lane_group_id, lane_ids=lane_group_values["lane_ids"], left_boundary=lane_group_values["left_boundary"], @@ -157,7 +157,7 @@ def _write_av2_lane_group(lane_group_dict: Dict[int, Any], map_writer: AbstractM def _write_av2_intersections(intersection_dict: Dict[int, Any], map_writer: AbstractMapWriter) -> None: for intersection_id, intersection_values in intersection_dict.items(): map_writer.write_intersection( - CacheIntersection( + Intersection( object_id=intersection_id, lane_group_ids=intersection_values["lane_group_ids"], outline=intersection_values["outline_3d"], @@ -168,7 +168,7 @@ def _write_av2_intersections(intersection_dict: Dict[int, Any], map_writer: Abst def _write_av2_crosswalks(crosswalks: Dict[int, npt.NDArray[np.float64]], map_writer: AbstractMapWriter) -> None: for cross_walk_id, crosswalk_dict in crosswalks.items(): map_writer.write_crosswalk( - CacheCrosswalk( + Crosswalk( object_id=cross_walk_id, outline=crosswalk_dict["outline"], ) @@ -178,7 +178,7 @@ def _write_av2_crosswalks(crosswalks: Dict[int, npt.NDArray[np.float64]], map_wr def _write_av2_generic_drivable(drivable_areas: Dict[int, Polyline3D], map_writer: AbstractMapWriter) -> None: for drivable_area_id, drivable_area_outline in drivable_areas.items(): map_writer.write_generic_drivable( - CacheGenericDrivable( + GenericDrivable( object_id=drivable_area_id, outline=drivable_area_outline, ) @@ -200,7 +200,7 @@ def _write_av2_road_edge(drivable_areas: Dict[int, Polyline3D], map_writer: Abst # TODO @DanielDauner: Figure out if other road edge types should/could be assigned here. map_writer.write_road_edge( - CacheRoadEdge( + RoadEdge( object_id=idx, road_edge_type=RoadEdgeType.ROAD_EDGE_BOUNDARY, polyline=Polyline3D.from_linestring(road_edge), @@ -218,7 +218,7 @@ def _write_av2_road_lines(lanes: Dict[int, Any], map_writer: AbstractMapWriter) continue map_writer.write_road_line( - CacheRoadLine( + RoadLine( object_id=running_road_line_id, road_line_type=AV2_ROAD_LINE_TYPE_MAPPING[lane[f"{side}_lane_mark_type"]], polyline=lane[f"{side}_lane_boundary"], diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 8e72c749..969c8df5 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -19,8 +19,8 @@ from py123d.conversion.registry.box_detection_label_registry import AV2SensorBoxDetectionLabel from py123d.conversion.registry.lidar_index_registry import AVSensorLiDARIndex from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.map.map_metadata import MapMetadata -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( PinholeCameraMetadata, diff --git a/src/py123d/conversion/datasets/av2/utils/av2_constants.py b/src/py123d/conversion/datasets/av2/utils/av2_constants.py index 623f3bf9..68859d2c 100644 --- a/src/py123d/conversion/datasets/av2/utils/av2_constants.py +++ b/src/py123d/conversion/datasets/av2/utils/av2_constants.py @@ -1,6 +1,6 @@ from typing import Dict, Final, Set -from py123d.datatypes.map.map_datatypes import RoadLineType +from py123d.datatypes.map_objects.map_layer_types import RoadLineType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType AV2_SENSOR_SPLITS: Set[str] = {"av2-sensor_train", "av2-sensor_val", "av2-sensor_test"} diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 7b1983d6..500b117a 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -28,21 +28,15 @@ from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter from py123d.conversion.registry.box_detection_label_registry import KITTI360BoxDetectionLabel from py123d.conversion.registry.lidar_index_registry import Kitti360LiDARIndex -from py123d.datatypes.detections.box_detections import ( - BoxDetectionMetadata, - BoxDetectionSE3, - BoxDetectionWrapper, -) -from py123d.datatypes.map.map_metadata import MapMetadata -from py123d.datatypes.scene.scene_metadata import LogMetadata -from py123d.datatypes.sensors.fisheye_mei_camera import ( +from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper +from py123d.datatypes.metadata import LogMetadata, MapMetadata +from py123d.datatypes.sensors import ( FisheyeMEICameraMetadata, FisheyeMEICameraType, FisheyeMEIDistortion, FisheyeMEIProjection, -) -from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import ( + LiDARMetadata, + LiDARType, PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, @@ -523,27 +517,15 @@ def _extract_ego_state_all(log_name: str, kitti360_folders: Dict[str, Path]) -> ) dynamic_state_se3 = DynamicStateSE3( - velocity=Vector3D( - x=oxts_data[8], - y=oxts_data[9], - z=oxts_data[10], - ), - acceleration=Vector3D( - x=oxts_data[14], - y=oxts_data[15], - z=oxts_data[16], - ), - angular_velocity=Vector3D( - x=oxts_data[20], - y=oxts_data[21], - z=oxts_data[22], - ), + velocity=Vector3D(x=oxts_data[8], y=oxts_data[9], z=oxts_data[10]), + acceleration=Vector3D(x=oxts_data[14], y=oxts_data[15], z=oxts_data[16]), + angular_velocity=Vector3D(x=oxts_data[20], y=oxts_data[21], z=oxts_data[22]), ) ego_state_all.append( EgoStateSE3.from_rear_axle( rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters, - dynamic_state=dynamic_state_se3, + dynamic_state_se3=dynamic_state_se3, ) ) return ego_state_all, valid_timestamp diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py index 47c550de..d694dd28 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py @@ -13,13 +13,13 @@ split_line_geometry_by_max_length, ) from py123d.conversion.utils.map_utils.road_edge.road_edge_3d_utils import lift_road_edges_to_3d -from py123d.datatypes.map.cache.cache_map_objects import ( - CacheCarpark, - CacheGenericDrivable, - CacheRoadEdge, - CacheWalkway, +from py123d.datatypes.map_objects.map_layer_types import RoadEdgeType +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + GenericDrivable, + RoadEdge, + Walkway, ) -from py123d.datatypes.map.map_datatypes import RoadEdgeType from py123d.geometry.polyline import Polyline3D MAX_ROAD_EDGE_LENGTH = 100.0 # meters, used to filter out very long road edges @@ -71,7 +71,7 @@ def convert_kitti360_map_with_writer(log_name: str, map_writer: AbstractMapWrite for obj in objs: if obj.label == "road": map_writer.write_generic_drivable( - CacheGenericDrivable( + GenericDrivable( object_id=obj.id, outline=obj.vertices, geometry=geom.Polygon(obj.vertices.array[:, :3]), @@ -81,7 +81,7 @@ def convert_kitti360_map_with_writer(log_name: str, map_writer: AbstractMapWrite road_outlines_3d.append(Polyline3D.from_array(road_outline_array)) elif obj.label == "sidewalk": map_writer.write_walkway( - CacheWalkway( + Walkway( object_id=obj.id, outline=obj.vertices, geometry=geom.Polygon(obj.vertices.array[:, :3]), @@ -89,7 +89,7 @@ def convert_kitti360_map_with_writer(log_name: str, map_writer: AbstractMapWrite ) elif obj.label == "driveway": map_writer.write_carpark( - CacheCarpark( + Carpark( object_id=obj.id, outline=obj.vertices, geometry=geom.Polygon(obj.vertices.array[:, :3]), @@ -108,7 +108,7 @@ def convert_kitti360_map_with_writer(log_name: str, map_writer: AbstractMapWrite for idx in range(len(road_edges_linestrings_3d)): map_writer.write_road_edge( - CacheRoadEdge( + RoadEdge( object_id=idx, road_edge_type=RoadEdgeType.ROAD_EDGE_BOUNDARY, polyline=Polyline3D.from_linestring(road_edges_linestrings_3d[idx]), diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py index e2c395e6..9b4d27d9 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py @@ -5,7 +5,7 @@ import numpy as np from py123d.conversion.registry.lidar_index_registry import Kitti360LiDARIndex -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARType from py123d.geometry.pose import PoseSE3 from py123d.geometry.transform.transform_se3 import convert_points_3d_array_between_origins diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index f79a61c9..873d3ec7 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -28,8 +28,8 @@ from py123d.conversion.registry.lidar_index_registry import NuPlanLiDARIndex from py123d.datatypes.detections.box_detections import BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetection, TrafficLightDetectionWrapper -from py123d.datatypes.map.map_metadata import MapMetadata -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( PinholeCameraMetadata, diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py index f07e15eb..dab9917e 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py @@ -7,6 +7,7 @@ import pyogrio from shapely import LineString +from py123d.api.map.gpkg.gpkg_utils import get_all_rows_with_value, get_row_with_value from py123d.conversion.datasets.nuplan.utils.nuplan_constants import ( NUPLAN_MAP_GPKG_LAYERS, NUPLAN_MAP_LOCATION_FILES, @@ -17,19 +18,18 @@ get_road_edge_linear_rings, split_line_geometry_by_max_length, ) -from py123d.datatypes.map.cache.cache_map_objects import ( - CacheCarpark, - CacheCrosswalk, - CacheGenericDrivable, - CacheIntersection, - CacheLane, - CacheLaneGroup, - CacheRoadEdge, - CacheRoadLine, - CacheWalkway, +from py123d.datatypes.map_objects.map_layer_types import RoadEdgeType +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, + Walkway, ) -from py123d.datatypes.map.gpkg.gpkg_utils import get_all_rows_with_value, get_row_with_value -from py123d.datatypes.map.map_datatypes import RoadEdgeType from py123d.geometry.polyline import Polyline2D, Polyline3D MAX_ROAD_EDGE_LENGTH: Final[float] = 100.0 # meters, used to filter out very long road edges. TODO @add to config? @@ -105,7 +105,7 @@ def _write_nuplan_lanes(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: Abs right_boundary = align_boundary_direction(centerline, right_boundary) map_writer.write_lane( - CacheLane( + Lane( object_id=lane_id, lane_group_id=all_lane_group_ids[idx], left_boundary=Polyline3D.from_linestring(left_boundary), @@ -158,7 +158,7 @@ def _write_nuplan_lane_connectors(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_w # geometries.append(lane_connector_polygons_row.geometry) map_writer.write_lane( - CacheLane( + Lane( object_id=lane_id, lane_group_id=all_lane_group_ids[idx], left_boundary=Polyline3D.from_linestring(left_boundary), @@ -216,7 +216,7 @@ def _write_nuplan_lane_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_write right_boundary = align_boundary_direction(repr_centerline, right_boundary) map_writer.write_lane_group( - CacheLaneGroup( + LaneGroup( object_id=lane_group_id, lane_ids=lane_ids, left_boundary=Polyline3D.from_linestring(left_boundary), @@ -257,7 +257,7 @@ def _write_nuplan_lane_connector_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], right_boundary = get_row_with_value(nuplan_gdf["boundaries"], "fid", str(right_boundary_fid))["geometry"] map_writer.write_lane_group( - CacheLaneGroup( + LaneGroup( object_id=lane_group_connector_id, lane_ids=lane_ids, left_boundary=Polyline3D.from_linestring(left_boundary), @@ -281,7 +281,7 @@ def _write_nuplan_intersections(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_wri )["fid"].tolist() map_writer.write_intersection( - CacheIntersection( + Intersection( object_id=intersection_id, lane_group_ids=lane_group_connector_ids, geometry=all_geometries[idx], @@ -292,19 +292,19 @@ def _write_nuplan_intersections(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_wri def _write_nuplan_crosswalks(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: # NOTE: drops: creator_id, intersection_fids, lane_fids, is_marked (?) for id, geometry in zip(nuplan_gdf["crosswalks"].fid.to_list(), nuplan_gdf["crosswalks"].geometry.to_list()): - map_writer.write_crosswalk(CacheCrosswalk(object_id=id, geometry=geometry)) + map_writer.write_crosswalk(Crosswalk(object_id=id, geometry=geometry)) def _write_nuplan_walkways(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: # NOTE: drops: creator_id for id, geometry in zip(nuplan_gdf["walkways"].fid.to_list(), nuplan_gdf["walkways"].geometry.to_list()): - map_writer.write_walkway(CacheWalkway(object_id=id, geometry=geometry)) + map_writer.write_walkway(Walkway(object_id=id, geometry=geometry)) def _write_nuplan_carparks(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: # NOTE: drops: creator_id for id, geometry in zip(nuplan_gdf["carpark_areas"].fid.to_list(), nuplan_gdf["carpark_areas"].geometry.to_list()): - map_writer.write_carpark(CacheCarpark(object_id=id, geometry=geometry)) + map_writer.write_carpark(Carpark(object_id=id, geometry=geometry)) def _write_nuplan_generic_drivables(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: @@ -312,7 +312,7 @@ def _write_nuplan_generic_drivables(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map for id, geometry in zip( nuplan_gdf["generic_drivable_areas"].fid.to_list(), nuplan_gdf["generic_drivable_areas"].geometry.to_list() ): - map_writer.write_generic_drivable(CacheGenericDrivable(object_id=id, geometry=geometry)) + map_writer.write_generic_drivable(GenericDrivable(object_id=id, geometry=geometry)) def _write_nuplan_road_edges(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: @@ -327,7 +327,7 @@ def _write_nuplan_road_edges(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer for idx in range(len(road_edges)): map_writer.write_road_edge( - CacheRoadEdge( + RoadEdge( object_id=idx, road_edge_type=RoadEdgeType.ROAD_EDGE_BOUNDARY, polyline=Polyline2D.from_linestring(road_edges[idx]), @@ -342,7 +342,7 @@ def _write_nuplan_road_lines(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer for idx in range(len(boundary_types)): map_writer.write_road_line( - CacheRoadLine( + RoadLine( object_id=fids[idx], road_line_type=NUPLAN_ROAD_LINE_CONVERSION[boundary_types[idx]], polyline=Polyline2D.from_linestring(boundaries[idx]), diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py index cb7e7076..4470821b 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py @@ -2,7 +2,7 @@ from py123d.conversion.registry.box_detection_label_registry import NuPlanBoxDetectionLabel from py123d.datatypes.detections.traffic_light_detections import TrafficLightStatus -from py123d.datatypes.map.map_datatypes import RoadLineType +from py123d.datatypes.map_objects.map_layer_types import RoadLineType from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.time.time_point import TimePoint diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 22e673f0..1d432a71 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -20,8 +20,7 @@ from py123d.conversion.registry.box_detection_label_registry import NuScenesBoxDetectionLabel from py123d.conversion.registry.lidar_index_registry import NuScenesLiDARIndex from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.map.map_metadata import MapMetadata -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata, MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( PinholeCameraMetadata, diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py index 4a4b34c0..d392e7c1 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py @@ -18,18 +18,18 @@ split_line_geometry_by_max_length, split_polygon_by_grid, ) -from py123d.datatypes.map.cache.cache_map_objects import ( - CacheCarpark, - CacheCrosswalk, - CacheGenericDrivable, - CacheIntersection, - CacheLane, - CacheLaneGroup, - CacheRoadEdge, - CacheRoadLine, - CacheWalkway, +from py123d.datatypes.map_objects.map_layer_types import RoadEdgeType, RoadLineType +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, + Walkway, ) -from py123d.datatypes.map.map_datatypes import RoadEdgeType, RoadLineType from py123d.geometry import OccupancyMap2D, Polyline2D, Polyline3D from py123d.geometry.utils.polyline_utils import offset_points_perpendicular @@ -81,7 +81,7 @@ def write_nuscenes_map( _write_nuscenes_walkways(nuscenes_map, map_writer) _write_nuscenes_carparks(nuscenes_map, map_writer) _write_nuscenes_generic_drivables(nuscenes_map, map_writer) - _write_nuscenes_stop_lines(nuscenes_map, map_writer) + _write_nuscenes_stop_zones(nuscenes_map, map_writer) _write_nuscenes_road_lines(nuscenes_map, map_writer) for lane in lanes + lane_connectors: @@ -94,7 +94,7 @@ def write_nuscenes_map( map_writer.write_lane_group(lane_group) -def _extract_nuscenes_lanes(nuscenes_map: NuScenesMap) -> List[CacheLane]: +def _extract_nuscenes_lanes(nuscenes_map: NuScenesMap) -> List[Lane]: """Helper function to extract lanes from a nuScenes map.""" # NOTE: nuScenes does not provide explicitly provide lane groups and does not assign lanes to roadblocks. @@ -114,7 +114,7 @@ def _extract_nuscenes_lanes(nuscenes_map: NuScenesMap) -> List[CacheLane]: } road_block_map = OccupancyMap2D.from_dict(road_block_dict) - lanes: List[CacheLane] = [] + lanes: List[Lane] = [] for lane_record in nuscenes_map.lane: token = lane_record["token"] @@ -140,7 +140,7 @@ def _extract_nuscenes_lanes(nuscenes_map: NuScenesMap) -> List[CacheLane]: outgoing = nuscenes_map.get_outgoing_lane_ids(token) lanes.append( - CacheLane( + Lane( object_id=token, lane_group_id=lane_group_id, left_boundary=left_boundary, @@ -159,14 +159,14 @@ def _extract_nuscenes_lanes(nuscenes_map: NuScenesMap) -> List[CacheLane]: return lanes -def _extract_nuscenes_lane_connectors(nuscenes_map: NuScenesMap, road_edges: List[CacheRoadEdge]) -> List[CacheLane]: +def _extract_nuscenes_lane_connectors(nuscenes_map: NuScenesMap, road_edges: List[RoadEdge]) -> List[Lane]: """Helper function to extract lane connectors from a nuScenes map.""" # TODO @DanielDauner: consider using connected lanes to estimate the lane width road_edge_map = OccupancyMap2D(geometries=[road_edge.shapely_linestring for road_edge in road_edges]) - lane_connectors: List[CacheLane] = [] + lane_connectors: List[Lane] = [] for lane_record in nuscenes_map.lane_connector: lane_connector_token: str = lane_record["token"] @@ -188,7 +188,7 @@ def _extract_nuscenes_lane_connectors(nuscenes_map: NuScenesMap, road_edges: Lis lane_group_id = lane_connector_token lane_connectors.append( - CacheLane( + Lane( object_id=lane_connector_token, lane_group_id=lane_group_id, left_boundary=Polyline2D.from_array(left_pts), @@ -208,11 +208,8 @@ def _extract_nuscenes_lane_connectors(nuscenes_map: NuScenesMap, road_edges: Lis def _extract_nuscenes_lane_groups( - nuscenes_map: NuScenesMap, - lanes: List[CacheLane], - lane_connectors: List[CacheLane], - intersection_assignment: Dict[str, int], -) -> List[CacheLaneGroup]: + nuscenes_map: NuScenesMap, lanes: List[Lane], lane_connectors: List[Lane], intersection_assignment: Dict[str, int] +) -> List[LaneGroup]: """Helper function to extract lane groups from a nuScenes map.""" lane_groups = [] @@ -260,7 +257,7 @@ def _extract_nuscenes_lane_groups( intersection_id = None if len(intersection_ids) == 0 else intersection_ids.pop() lane_groups.append( - CacheLaneGroup( + LaneGroup( object_id=lane_group_id, lane_ids=lane_ids, left_boundary=left_boundary, @@ -277,7 +274,7 @@ def _extract_nuscenes_lane_groups( def _write_nuscenes_intersections( - nuscenes_map: NuScenesMap, lane_connectors: List[CacheLane], map_writer: AbstractMapWriter + nuscenes_map: NuScenesMap, lane_connectors: List[Lane], map_writer: AbstractMapWriter ) -> None: """Write intersection data to map_writer and return lane-connector to intersection assignment.""" @@ -303,7 +300,7 @@ def _write_nuscenes_intersections( intersection_assignment[lane_connector_id] = idx map_writer.write_intersection( - CacheIntersection( + Intersection( object_id=idx, lane_group_ids=intersecting_lane_connector_ids, outline=None, @@ -324,12 +321,7 @@ def _write_nuscenes_crosswalks(nuscenes_map: NuScenesMap, map_writer: AbstractMa crosswalk_polygons.append(polygon) for idx, polygon in enumerate(crosswalk_polygons): - map_writer.write_crosswalk( - CacheCrosswalk( - object_id=idx, - geometry=polygon, - ) - ) + map_writer.write_crosswalk(Crosswalk(object_id=idx, geometry=polygon)) def _write_nuscenes_walkways(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: @@ -341,12 +333,7 @@ def _write_nuscenes_walkways(nuscenes_map: NuScenesMap, map_writer: AbstractMapW walkway_polygons.append(polygon) for idx, polygon in enumerate(walkway_polygons): - map_writer.write_walkway( - CacheWalkway( - object_id=idx, - geometry=polygon, - ) - ) + map_writer.write_walkway(Walkway(object_id=idx, geometry=polygon)) def _write_nuscenes_carparks(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: @@ -358,17 +345,12 @@ def _write_nuscenes_carparks(nuscenes_map: NuScenesMap, map_writer: AbstractMapW carpark_polygons.append(polygon) for idx, polygon in enumerate(carpark_polygons): - map_writer.write_carpark( - CacheCarpark( - object_id=idx, - geometry=polygon, - ) - ) + map_writer.write_carpark(Carpark(object_id=idx, geometry=polygon)) def _write_nuscenes_generic_drivables(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: """Write generic drivable area data to map_writer.""" - cell_size = 10.0 + cell_size = 20.0 drivable_polygons = [] for drivable_area_record in nuscenes_map.drivable_area: drivable_area = nuscenes_map.get("drivable_area", drivable_area_record["token"]) @@ -380,10 +362,10 @@ def _write_nuscenes_generic_drivables(nuscenes_map: NuScenesMap, map_writer: Abs # drivable_polygons.append(polygon) for idx, geometry in enumerate(drivable_polygons): - map_writer.write_generic_drivable(CacheGenericDrivable(object_id=idx, geometry=geometry)) + map_writer.write_generic_drivable(GenericDrivable(object_id=idx, geometry=geometry)) -def _write_nuscenes_stop_lines(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: +def _write_nuscenes_stop_zones(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: """Write stop line data to map_writer.""" # FIXME: Add stop lines. # stop_lines = nuscenes_map.stop_line @@ -421,7 +403,7 @@ def _write_nuscenes_road_lines(nuscenes_map: NuScenesMap, map_writer: AbstractMa line_type = _get_road_line_type(divider["line_token"], nuscenes_map) map_writer.write_road_line( - CacheRoadLine( + RoadLine( object_id=running_idx, road_line_type=line_type, polyline=Polyline3D(LineString(line.coords)), @@ -436,7 +418,7 @@ def _write_nuscenes_road_lines(nuscenes_map: NuScenesMap, map_writer: AbstractMa line_type = _get_road_line_type(divider["line_token"], nuscenes_map) map_writer.write_road_line( - CacheRoadLine( + RoadLine( object_id=running_idx, road_line_type=line_type, polyline=Polyline3D(LineString(line.coords)), @@ -445,7 +427,7 @@ def _write_nuscenes_road_lines(nuscenes_map: NuScenesMap, map_writer: AbstractMa running_idx += 1 -def _extract_nuscenes_road_edges(nuscenes_map: NuScenesMap) -> List[CacheRoadEdge]: +def _extract_nuscenes_road_edges(nuscenes_map: NuScenesMap) -> List[RoadEdge]: """Helper function to extract road edges from a nuScenes map.""" drivable_polygons = [] for drivable_area_record in nuscenes_map.drivable_area: @@ -457,10 +439,10 @@ def _extract_nuscenes_road_edges(nuscenes_map: NuScenesMap) -> List[CacheRoadEdg road_edge_linear_rings = get_road_edge_linear_rings(drivable_polygons) road_edges_linestrings = split_line_geometry_by_max_length(road_edge_linear_rings, MAX_ROAD_EDGE_LENGTH) - road_edges_cache: List[CacheRoadEdge] = [] + road_edges_cache: List[RoadEdge] = [] for idx in range(len(road_edges_linestrings)): road_edges_cache.append( - CacheRoadEdge( + RoadEdge( object_id=idx, road_edge_type=RoadEdgeType.ROAD_EDGE_BOUNDARY, polyline=Polyline2D.from_linestring(road_edges_linestrings[idx]), diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py index afc9dae5..f6f0757f 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py @@ -4,7 +4,7 @@ import numpy as np from py123d.conversion.registry.lidar_index_registry import NuScenesLiDARIndex -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARType from py123d.geometry.pose import PoseSE3 from py123d.geometry.transform.transform_se3 import convert_points_3d_array_between_origins diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index c4d181d9..12d97baa 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -27,7 +27,7 @@ from py123d.conversion.registry.box_detection_label_registry import PandasetBoxDetectionLabel from py123d.conversion.registry.lidar_index_registry import PandasetLiDARIndex from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType, PinholeIntrinsics from py123d.datatypes.time.time_point import TimePoint diff --git a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py index 1f207994..a920ae0b 100644 --- a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py +++ b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py @@ -4,8 +4,8 @@ import numpy as np import shapely.geometry as geom -from py123d.datatypes.map.abstract_map_objects import AbstractRoadEdge, AbstractRoadLine -from py123d.datatypes.map.map_datatypes import LaneType +from py123d.datatypes.map_objects.map_layer_types import LaneType +from py123d.datatypes.map_objects.map_objects import RoadEdge, RoadLine from py123d.geometry import OccupancyMap2D, Point3D, Polyline3D, PolylineSE2, PoseSE2, Vector2D from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame from py123d.geometry.utils.rotation_utils import normalize_angle @@ -169,9 +169,7 @@ def _filter_perpendicular_hits( def fill_lane_boundaries( - lane_data_dict: Dict[int, WaymoLaneData], - road_lines: List[AbstractRoadLine], - road_edges: List[AbstractRoadEdge], + lane_data_dict: Dict[int, WaymoLaneData], road_lines: List[RoadLine], road_edges: List[RoadEdge] ) -> Tuple[Dict[str, Polyline3D], Dict[str, Polyline3D]]: """Welcome to insanity. diff --git a/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py b/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py index c9a33b4f..bda4eced 100644 --- a/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py +++ b/src/py123d/conversion/datasets/wopd/utils/wopd_constants.py @@ -1,7 +1,7 @@ from typing import Dict, List from py123d.conversion.registry.box_detection_label_registry import WOPDBoxDetectionLabel -from py123d.datatypes.map.map_datatypes import LaneType, RoadEdgeType, RoadLineType +from py123d.datatypes.map_objects.map_layer_types import LaneType, RoadEdgeType, RoadLineType from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 794f2f71..38d47ffa 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -23,8 +23,8 @@ from py123d.conversion.registry.lidar_index_registry import DefaultLiDARIndex, WOPDLiDARIndex from py123d.conversion.utils.sensor_utils.camera_conventions import CameraConvention, convert_camera_convention from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.map.map_metadata import MapMetadata -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata.log_metadata import LogMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors import ( LiDARMetadata, LiDARType, diff --git a/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py index 5f0e3515..6287c2e7 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py +++ b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py @@ -10,16 +10,8 @@ WAYMO_ROAD_LINE_TYPE_CONVERSION, ) from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.datatypes.map.abstract_map_objects import AbstractLane, AbstractRoadEdge, AbstractRoadLine -from py123d.datatypes.map.cache.cache_map_objects import ( - CacheCarpark, - CacheCrosswalk, - CacheLane, - CacheLaneGroup, - CacheRoadEdge, - CacheRoadLine, -) -from py123d.datatypes.map.map_datatypes import LaneType, RoadEdgeType, RoadLineType +from py123d.datatypes.map_objects.map_layer_types import LaneType, RoadEdgeType, RoadLineType +from py123d.datatypes.map_objects.map_objects import Carpark, Crosswalk, Lane, LaneGroup, RoadEdge, RoadLine from py123d.geometry import Polyline3D from py123d.geometry.utils.units import mph_to_mps @@ -48,17 +40,17 @@ def convert_wopd_map(frame: dataset_pb2.Frame, map_writer: AbstractMapWriter) -> _write_waymo_misc_surfaces(frame, map_writer) -def _write_and_get_waymo_road_lines(frame: dataset_pb2.Frame, map_writer: AbstractMapWriter) -> List[AbstractRoadLine]: +def _write_and_get_waymo_road_lines(frame: dataset_pb2.Frame, map_writer: AbstractMapWriter) -> List[RoadLine]: """Helper function to extract road lines from a Waymo frame proto.""" - road_lines: List[AbstractRoadLine] = [] + road_lines: List[RoadLine] = [] for map_feature in frame.map_features: if map_feature.HasField("road_line"): polyline = _extract_polyline_waymo_proto(map_feature.road_line) if polyline is not None: road_line_type = WAYMO_ROAD_LINE_TYPE_CONVERSION.get(map_feature.road_line.type, RoadLineType.UNKNOWN) road_lines.append( - CacheRoadLine( + RoadLine( object_id=map_feature.id, road_line_type=road_line_type, polyline=polyline, @@ -71,17 +63,17 @@ def _write_and_get_waymo_road_lines(frame: dataset_pb2.Frame, map_writer: Abstra return road_lines -def _write_and_get_waymo_road_edges(frame: dataset_pb2.Frame, map_writer: AbstractMapWriter) -> List[AbstractRoadEdge]: +def _write_and_get_waymo_road_edges(frame: dataset_pb2.Frame, map_writer: AbstractMapWriter) -> List[RoadEdge]: """Helper function to extract road edges from a Waymo frame proto.""" - road_edges: List[AbstractRoadEdge] = [] + road_edges: List[RoadEdge] = [] for map_feature in frame.map_features: if map_feature.HasField("road_edge"): polyline = _extract_polyline_waymo_proto(map_feature.road_edge) if polyline is not None: road_edge_type = WAYMO_ROAD_EDGE_TYPE_CONVERSION.get(map_feature.road_edge.type, RoadEdgeType.UNKNOWN) road_edges.append( - CacheRoadEdge( + RoadEdge( object_id=map_feature.id, road_edge_type=road_edge_type, polyline=polyline, @@ -95,11 +87,8 @@ def _write_and_get_waymo_road_edges(frame: dataset_pb2.Frame, map_writer: Abstra def _write_and_get_waymo_lanes( - frame: dataset_pb2.Frame, - road_lines: List[AbstractRoadLine], - road_edges: List[AbstractRoadEdge], - map_writer: AbstractMapWriter, -) -> List[AbstractLane]: + frame: dataset_pb2.Frame, road_lines: List[RoadLine], road_edges: List[RoadEdge], map_writer: AbstractMapWriter +) -> List[Lane]: # 1. Load lane data from Waymo frame proto lane_data_dict: Dict[int, WaymoLaneData] = {} @@ -136,7 +125,7 @@ def _get_majority_neighbor(neighbors: List[Dict[str, int]]) -> Optional[int]: } return str(max(length, key=length.get)) - lanes: List[AbstractLane] = [] + lanes: List[Lane] = [] for lane_data in lane_data_dict.values(): # Skip lanes without boundaries @@ -144,7 +133,7 @@ def _get_majority_neighbor(neighbors: List[Dict[str, int]]) -> Optional[int]: continue lanes.append( - CacheLane( + Lane( object_id=lane_data.object_id, lane_group_id=lane_data.object_id, left_boundary=lane_data.left_boundary, @@ -164,12 +153,12 @@ def _get_majority_neighbor(neighbors: List[Dict[str, int]]) -> Optional[int]: return lanes -def _write_waymo_lane_groups(lanes: List[AbstractLane], map_writer: AbstractMapWriter) -> None: +def _write_waymo_lane_groups(lanes: List[Lane], map_writer: AbstractMapWriter) -> None: # NOTE: WOPD does not provide lane groups, so we create a lane group for each lane. for lane in lanes: map_writer.write_lane_group( - CacheLaneGroup( + LaneGroup( object_id=lane.object_id, lane_ids=[lane.object_id], left_boundary=lane.left_boundary, @@ -189,11 +178,11 @@ def _write_waymo_misc_surfaces(frame: dataset_pb2.Frame, map_writer: AbstractMap # NOTE: We currently only handle classify driveways as carparks. outline = _extract_outline_from_waymo_proto(map_feature.driveway) if outline is not None: - map_writer.write_carpark(CacheCarpark(object_id=map_feature.id, outline=outline)) + map_writer.write_carpark(Carpark(object_id=map_feature.id, outline=outline)) elif map_feature.HasField("crosswalk"): outline = _extract_outline_from_waymo_proto(map_feature.crosswalk) if outline is not None: - map_writer.write_crosswalk(CacheCrosswalk(object_id=map_feature.id, outline=outline)) + map_writer.write_crosswalk(Crosswalk(object_id=map_feature.id, outline=outline)) elif map_feature.HasField("stop_sign"): pass # TODO: Implement stop signs diff --git a/src/py123d/conversion/log_writer/abstract_log_writer.py b/src/py123d/conversion/log_writer/abstract_log_writer.py index 9ad8f357..23fc93b1 100644 --- a/src/py123d/conversion/log_writer/abstract_log_writer.py +++ b/src/py123d/conversion/log_writer/abstract_log_writer.py @@ -11,7 +11,7 @@ from py123d.conversion.dataset_converter_config import DatasetConverterConfig from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index 930b66dd..d31a8ea4 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -4,6 +4,7 @@ import numpy as np import pyarrow as pa +from py123d.api.scene.arrow.utils.arrow_metadata_utils import add_log_metadata_to_arrow_schema from py123d.common.utils.arrow_column_names import ( BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN, BOX_DETECTIONS_LABEL_COLUMN, @@ -39,8 +40,7 @@ from py123d.conversion.sensor_io.lidar.laz_lidar_io import encode_lidar_pc_as_laz_binary from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.scene.arrow.utils.arrow_metadata_utils import add_log_metadata_to_arrow_schema -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors import LiDARType, PinholeCameraType from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.dynamic_state import DynamicStateSE3Index diff --git a/src/py123d/conversion/map_writer/abstract_map_writer.py b/src/py123d/conversion/map_writer/abstract_map_writer.py index 739d112e..cc74e1ad 100644 --- a/src/py123d/conversion/map_writer/abstract_map_writer.py +++ b/src/py123d/conversion/map_writer/abstract_map_writer.py @@ -2,19 +2,19 @@ from abc import abstractmethod from py123d.conversion.dataset_converter_config import DatasetConverterConfig -from py123d.datatypes.map.abstract_map_objects import ( - AbstractCarpark, - AbstractCrosswalk, - AbstractGenericDrivable, - AbstractIntersection, - AbstractLane, - AbstractLaneGroup, - AbstractRoadEdge, - AbstractRoadLine, - AbstractStopLine, - AbstractWalkway, +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, + StopZone, + Walkway, ) -from py123d.datatypes.map.map_metadata import MapMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata class AbstractMapWriter(abc.ABC): @@ -25,43 +25,43 @@ def reset(self, dataset_converter_config: DatasetConverterConfig, map_metadata: """Reset the writer to its initial state.""" @abstractmethod - def write_lane(self, lane: AbstractLane) -> None: + def write_lane(self, lane: Lane) -> None: """Write a lane to the map.""" @abstractmethod - def write_lane_group(self, lane: AbstractLaneGroup) -> None: + def write_lane_group(self, lane: LaneGroup) -> None: """Write a group of lanes to the map.""" @abstractmethod - def write_intersection(self, intersection: AbstractIntersection) -> None: + def write_intersection(self, intersection: Intersection) -> None: """Write an intersection to the map.""" @abstractmethod - def write_crosswalk(self, crosswalk: AbstractCrosswalk) -> None: + def write_crosswalk(self, crosswalk: Crosswalk) -> None: """Write a crosswalk to the map.""" @abstractmethod - def write_carpark(self, carpark: AbstractCarpark) -> None: + def write_carpark(self, carpark: Carpark) -> None: """Write a car park to the map.""" @abstractmethod - def write_walkway(self, walkway: AbstractWalkway) -> None: + def write_walkway(self, walkway: Walkway) -> None: """Write a walkway to the map.""" @abstractmethod - def write_generic_drivable(self, obj: AbstractGenericDrivable) -> None: + def write_generic_drivable(self, obj: GenericDrivable) -> None: """Write a generic drivable area to the map.""" @abstractmethod - def write_stop_line(self, stop_line: AbstractStopLine) -> None: - """Write a stop lines to the map.""" + def write_stop_zone(self, stop_zone: StopZone) -> None: + """Write a stop zone to the map.""" @abstractmethod - def write_road_edge(self, road_edge: AbstractRoadEdge) -> None: + def write_road_edge(self, road_edge: RoadEdge) -> None: """Write a road edge to the map.""" @abstractmethod - def write_road_line(self, road_line: AbstractRoadLine) -> None: + def write_road_line(self, road_line: RoadLine) -> None: """Write a road line to the map.""" @abstractmethod diff --git a/src/py123d/conversion/map_writer/gpkg_map_writer.py b/src/py123d/conversion/map_writer/gpkg_map_writer.py index 08d74a49..58f9d159 100644 --- a/src/py123d/conversion/map_writer/gpkg_map_writer.py +++ b/src/py123d/conversion/map_writer/gpkg_map_writer.py @@ -9,22 +9,22 @@ from py123d.conversion.dataset_converter_config import DatasetConverterConfig from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter from py123d.conversion.map_writer.utils.gpkg_utils import IntIDMapping -from py123d.datatypes.map.abstract_map_objects import ( - AbstractCarpark, - AbstractCrosswalk, - AbstractGenericDrivable, - AbstractIntersection, - AbstractLane, - AbstractLaneGroup, - AbstractLineMapObject, - AbstractRoadEdge, - AbstractRoadLine, - AbstractStopLine, - AbstractSurfaceMapObject, - AbstractWalkway, +from py123d.datatypes.map_objects.map_layer_types import MapLayer +from py123d.datatypes.map_objects.map_objects import ( + BaseMapLineObject, + BaseMapSurfaceObject, + Carpark, + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, + StopZone, + Walkway, ) -from py123d.datatypes.map.map_datatypes import MapLayer -from py123d.datatypes.map.map_metadata import MapMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.geometry.polyline import Polyline3D MAP_OBJECT_DATA = Dict[str, List[Union[str, int, float, bool, geom.base.BaseGeometry]]] @@ -65,7 +65,7 @@ def reset(self, dataset_converter_config: DatasetConverterConfig, map_metadata: return map_needs_writing - def write_lane(self, lane: AbstractLane) -> None: + def write_lane(self, lane: Lane) -> None: """Inherited, see superclass.""" self._write_surface_layer(MapLayer.LANE, lane) self._map_data[MapLayer.LANE]["lane_group_id"].append(lane.lane_group_id) @@ -78,7 +78,7 @@ def write_lane(self, lane: AbstractLane) -> None: self._map_data[MapLayer.LANE]["successor_ids"].append(lane.successor_ids) self._map_data[MapLayer.LANE]["speed_limit_mps"].append(lane.speed_limit_mps) - def write_lane_group(self, lane_group: AbstractLaneGroup) -> None: + def write_lane_group(self, lane_group: LaneGroup) -> None: """Inherited, see superclass.""" self._write_surface_layer(MapLayer.LANE_GROUP, lane_group) self._map_data[MapLayer.LANE_GROUP]["lane_ids"].append(lane_group.lane_ids) @@ -88,41 +88,41 @@ def write_lane_group(self, lane_group: AbstractLaneGroup) -> None: self._map_data[MapLayer.LANE_GROUP]["left_boundary"].append(lane_group.left_boundary.linestring) self._map_data[MapLayer.LANE_GROUP]["right_boundary"].append(lane_group.right_boundary.linestring) - def write_intersection(self, intersection: AbstractIntersection) -> None: + def write_intersection(self, intersection: Intersection) -> None: """Inherited, see superclass.""" self._write_surface_layer(MapLayer.INTERSECTION, intersection) self._map_data[MapLayer.INTERSECTION]["lane_group_ids"].append(intersection.lane_group_ids) - def write_crosswalk(self, crosswalk: AbstractCrosswalk) -> None: + def write_crosswalk(self, crosswalk: Crosswalk) -> None: """Inherited, see superclass.""" self._write_surface_layer(MapLayer.CROSSWALK, crosswalk) - def write_carpark(self, carpark: AbstractCarpark) -> None: + def write_carpark(self, carpark: Carpark) -> None: """Inherited, see superclass.""" self._write_surface_layer(MapLayer.CARPARK, carpark) - def write_walkway(self, walkway: AbstractWalkway) -> None: + def write_walkway(self, walkway: Walkway) -> None: """Inherited, see superclass.""" self._write_surface_layer(MapLayer.WALKWAY, walkway) - def write_generic_drivable(self, obj: AbstractGenericDrivable) -> None: + def write_generic_drivable(self, obj: GenericDrivable) -> None: """Inherited, see superclass.""" self._write_surface_layer(MapLayer.GENERIC_DRIVABLE, obj) - def write_stop_line(self, stop_line: AbstractStopLine) -> None: + def write_stop_zone(self, stop_zone: StopZone) -> None: """Inherited, see superclass.""" # self._write_line_layer(MapLayer.STOP_LINE, stop_line) - raise NotImplementedError("Stop lines are not yet supported in GPKG maps.") + raise NotImplementedError("Stop zones are not yet supported in GPKG maps.") - def write_road_edge(self, road_edge: AbstractRoadEdge) -> None: + def write_road_edge(self, road_edge: RoadEdge) -> None: """Inherited, see superclass.""" self._write_line_layer(MapLayer.ROAD_EDGE, road_edge) - self._map_data[MapLayer.ROAD_EDGE]["road_edge_type"].append(road_edge.road_edge_type) + self._map_data[MapLayer.ROAD_EDGE]["road_edge_type"].append(int(road_edge.road_edge_type)) - def write_road_line(self, road_line: AbstractRoadLine) -> None: + def write_road_line(self, road_line: RoadLine) -> None: """Inherited, see superclass.""" self._write_line_layer(MapLayer.ROAD_LINE, road_line) - self._map_data[MapLayer.ROAD_LINE]["road_line_type"].append(road_line.road_line_type) + self._map_data[MapLayer.ROAD_LINE]["road_line_type"].append(int(road_line.road_line_type)) def close(self) -> None: """Inherited, see superclass.""" @@ -164,7 +164,7 @@ def _assert_initialized(self) -> None: assert self._map_file is not None, "Call reset() before writing data." assert self._map_metadata is not None, "Call reset() before writing data." - def _write_surface_layer(self, layer: MapLayer, surface_object: AbstractSurfaceMapObject) -> None: + def _write_surface_layer(self, layer: MapLayer, surface_object: BaseMapSurfaceObject) -> None: """Helper to write surface map objects. :param layer: map layer of surface object @@ -177,7 +177,7 @@ def _write_surface_layer(self, layer: MapLayer, surface_object: AbstractSurfaceM self._map_data[layer]["outline"].append(surface_object.outline.linestring) self._map_data[layer]["geometry"].append(surface_object.shapely_polygon) - def _write_line_layer(self, layer: MapLayer, line_object: AbstractLineMapObject) -> None: + def _write_line_layer(self, layer: MapLayer, line_object: BaseMapLineObject) -> None: """Helper to write line map objects. :param layer: map layer of line object diff --git a/src/py123d/conversion/map_writer/utils/gpkg_utils.py b/src/py123d/conversion/map_writer/utils/gpkg_utils.py index 2b9ab334..2b68affa 100644 --- a/src/py123d/conversion/map_writer/utils/gpkg_utils.py +++ b/src/py123d/conversion/map_writer/utils/gpkg_utils.py @@ -22,13 +22,16 @@ def from_series(cls, series: pd.Series) -> IntIDMapping: return IntIDMapping(str_to_int) def map(self, str_like: Any) -> Optional[int]: - # Handle NaN and None values + # NOTE: We need to convert a string-like input to an integer ID if pd.isna(str_like) or str_like is None: return None - # Convert to string for uniform handling - str_key = str(str_like) - return self.str_to_int.get(str_key, None) + if isinstance(str_like, float): + key = str(int(str_like)) # Convert float to int first to avoid decimal point + else: + key = str(str_like) + + return self.str_to_int.get(key, None) def map_list(self, id_list: Optional[List[str]]) -> List[int]: if id_list is None: diff --git a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py index 9d783c50..6e3c508e 100644 --- a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py +++ b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py @@ -5,7 +5,7 @@ import numpy.typing as npt from omegaconf import DictConfig -from py123d.datatypes.scene.scene_metadata import LogMetadata +from py123d.datatypes.metadata.log_metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARType from py123d.script.utils.dataset_path_utils import get_dataset_paths diff --git a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py index 9663c28b..09e8a10f 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py @@ -6,7 +6,7 @@ import shapely from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.utils.map_utils.opendrive.parser.opendrive import Junction, OpenDrive +from py123d.conversion.utils.map_utils.opendrive.parser.opendrive import XODR, Junction from py123d.conversion.utils.map_utils.opendrive.utils.collection import collect_element_helpers from py123d.conversion.utils.map_utils.opendrive.utils.lane_helper import ( OpenDriveLaneGroupHelper, @@ -21,18 +21,19 @@ get_road_edges_3d_from_drivable_surfaces, lift_outlines_to_3d, ) -from py123d.datatypes.map.cache.cache_map_objects import ( - CacheCarpark, - CacheCrosswalk, - CacheGenericDrivable, - CacheIntersection, - CacheLane, - CacheLaneGroup, - CacheRoadEdge, - CacheRoadLine, - CacheWalkway, +from py123d.datatypes.map_objects import ( + Carpark, + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadEdgeType, + RoadLine, + RoadLineType, + Walkway, ) -from py123d.datatypes.map.map_datatypes import RoadEdgeType, RoadLineType from py123d.geometry.geometry_index import Point3DIndex from py123d.geometry.polyline import Polyline3D @@ -48,7 +49,7 @@ def convert_xodr_map( connection_distance_threshold: float = 0.1, ) -> None: - opendrive = OpenDrive.parse_from_file(xordr_file) + opendrive = XODR.parse_from_file(xordr_file) _, junction_dict, lane_helper_dict, lane_group_helper_dict, object_helper_dict = collect_element_helpers( opendrive, interpolation_step_size, connection_distance_threshold @@ -79,9 +80,9 @@ def convert_xodr_map( def _extract_and_write_lanes( lane_group_helper_dict: Dict[str, OpenDriveLaneGroupHelper], map_writer: AbstractMapWriter, -) -> List[CacheLane]: +) -> List[Lane]: - lanes: List[CacheLane] = [] + lanes: List[Lane] = [] for lane_group_helper in lane_group_helper_dict.values(): lane_group_id = lane_group_helper.lane_group_id lane_helpers = lane_group_helper.lane_helpers @@ -90,7 +91,7 @@ def _extract_and_write_lanes( for lane_idx, lane_helper in enumerate(lane_helpers): left_lane_id = lane_helpers[lane_idx - 1].lane_id if lane_idx > 0 else None right_lane_id = lane_helpers[lane_idx + 1].lane_id if lane_idx < num_lanes - 1 else None - lane = CacheLane( + lane = Lane( object_id=lane_helper.lane_id, lane_group_id=lane_group_id, left_boundary=lane_helper.inner_polyline_3d, @@ -112,12 +113,12 @@ def _extract_and_write_lanes( def _extract_and_write_lane_groups( lane_group_helper_dict: Dict[str, OpenDriveLaneGroupHelper], map_writer: AbstractMapWriter -) -> List[CacheLaneGroup]: +) -> List[LaneGroup]: - lane_groups: List[CacheLaneGroup] = [] + lane_groups: List[LaneGroup] = [] for lane_group_helper in lane_group_helper_dict.values(): lane_group_helper: OpenDriveLaneGroupHelper - lane_group = CacheLaneGroup( + lane_group = LaneGroup( object_id=lane_group_helper.lane_group_id, lane_ids=[lane_helper.lane_id for lane_helper in lane_group_helper.lane_helpers], left_boundary=lane_group_helper.inner_polyline_3d, @@ -138,7 +139,7 @@ def _write_walkways(lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer for lane_helper in lane_helper_dict.values(): if lane_helper.type == "sidewalk": map_writer.write_walkway( - CacheWalkway( + Walkway( object_id=lane_helper.lane_id, outline=lane_helper.outline_polyline_3d, geometry=None, @@ -148,12 +149,12 @@ def _write_walkways(lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer def _extract_and_write_carparks( lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer: AbstractMapWriter -) -> List[CacheCarpark]: +) -> List[Carpark]: - carparks: List[CacheCarpark] = [] + carparks: List[Carpark] = [] for lane_helper in lane_helper_dict.values(): if lane_helper.type == "parking": - carpark = CacheCarpark( + carpark = Carpark( object_id=lane_helper.lane_id, outline=lane_helper.outline_polyline_3d, geometry=None, @@ -166,12 +167,12 @@ def _extract_and_write_carparks( def _extract_and_write_generic_drivables( lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer: AbstractMapWriter -) -> List[CacheGenericDrivable]: +) -> List[GenericDrivable]: - generic_drivables: List[CacheGenericDrivable] = [] + generic_drivables: List[GenericDrivable] = [] for lane_helper in lane_helper_dict.values(): if lane_helper.type in ["none", "border", "bidirectional"]: - generic_drivable = CacheGenericDrivable( + generic_drivable = GenericDrivable( object_id=lane_helper.lane_id, outline=lane_helper.outline_polyline_3d, geometry=None, @@ -204,7 +205,7 @@ def _find_lane_group_helpers_with_junction_id(junction_id: int) -> List[OpenDriv # TODO @DanielDauner: Create a method that extracts 3D outlines of intersections. outline = _extract_intersection_outline(lane_group_helpers, junction.id) map_writer.write_intersection( - CacheIntersection( + Intersection( object_id=junction.id, lane_group_ids=lane_group_ids_, outline=outline, @@ -216,7 +217,7 @@ def _find_lane_group_helpers_with_junction_id(junction_id: int) -> List[OpenDriv def _write_crosswalks(object_helper_dict: Dict[int, OpenDriveObjectHelper], map_writer: AbstractMapWriter) -> None: for object_helper in object_helper_dict.values(): map_writer.write_crosswalk( - CacheCrosswalk( + Crosswalk( object_id=object_helper.object_id, outline=object_helper.outline_polyline_3d, geometry=None, @@ -224,7 +225,7 @@ def _write_crosswalks(object_helper_dict: Dict[int, OpenDriveObjectHelper], map_ ) -def _write_road_lines(lanes: List[CacheLane], lane_groups: List[CacheLaneGroup], map_writer: AbstractMapWriter) -> None: +def _write_road_lines(lanes: List[Lane], lane_groups: List[LaneGroup], map_writer: AbstractMapWriter) -> None: # NOTE @DanielDauner: This method of extracting road lines is very simplistic and needs improvement. # The OpenDRIVE format provides lane boundary types that could be used here. @@ -266,20 +267,14 @@ def _write_road_lines(lanes: List[CacheLane], lane_groups: List[CacheLaneGroup], running_id += 1 for object_id, road_line_type, polyline in zip(ids, road_line_types, polylines): - map_writer.write_road_line( - CacheRoadLine( - object_id=object_id, - road_line_type=road_line_type, - polyline=polyline, - ) - ) + map_writer.write_road_line(RoadLine(object_id=object_id, road_line_type=road_line_type, polyline=polyline)) def _write_road_edges( - lanes: List[CacheLane], - lane_groups: List[CacheLaneGroup], - car_parks: List[CacheCarpark], - generic_drivables: List[CacheGenericDrivable], + lanes: List[Lane], + lane_groups: List[LaneGroup], + car_parks: List[Carpark], + generic_drivables: List[GenericDrivable], map_writer: AbstractMapWriter, ) -> None: @@ -297,7 +292,7 @@ def _write_road_edges( for road_edge_linestring in road_edge_linestrings: # TODO @DanielDauner: Figure out if other types should/could be assigned here. map_writer.write_road_edge( - CacheRoadEdge( + RoadEdge( object_id=running_id, road_edge_type=RoadEdgeType.ROAD_EDGE_BOUNDARY, polyline=Polyline3D.from_linestring(road_edge_linestring), diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/elevation.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/elevation.py index 2a1261ca..599fde63 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/elevation.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/elevation.py @@ -4,35 +4,35 @@ from typing import List, Optional from xml.etree.ElementTree import Element -from py123d.conversion.utils.map_utils.opendrive.parser.polynomial import Polynomial +from py123d.conversion.utils.map_utils.opendrive.parser.polynomial import XODRPolynomial @dataclass -class ElevationProfile: +class XORDElevationProfile: """ Models elevation along s-axis of reference line. https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_05_elevation.html#sec-1d876c00-d69e-46d9-bbcd-709ab48f14b1 """ - elevations: List[Elevation] + elevations: List[XODRElevation] def __post_init__(self): self.elevations.sort(key=lambda x: x.s, reverse=False) @classmethod - def parse(cls, elevation_profile_element: Optional[Element]) -> ElevationProfile: + def parse(cls, elevation_profile_element: Optional[Element]) -> XORDElevationProfile: args = {} - elevations: List[Elevation] = [] + elevations: List[XODRElevation] = [] if elevation_profile_element is not None: for elevation_element in elevation_profile_element.findall("elevation"): - elevations.append(Elevation.parse(elevation_element)) + elevations.append(XODRElevation.parse(elevation_element)) args["elevations"] = elevations - return ElevationProfile(**args) + return XORDElevationProfile(**args) @dataclass -class Elevation(Polynomial): +class XODRElevation(XODRPolynomial): """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_05_elevation.html#sec-66ac2b58-dc5e-4538-884d-204406ea53f2 @@ -41,41 +41,41 @@ class Elevation(Polynomial): @dataclass -class LateralProfile: +class XODRLateralProfile: """ Models elevation along t-axis of reference line. https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_05_elevation.html#sec-66ac2b58-dc5e-4538-884d-204406ea53f2 """ - super_elevations: List[SuperElevation] - shapes: List[Shape] + super_elevations: List[XODRSuperElevation] + shapes: List[XODRShape] def __post_init__(self): self.super_elevations.sort(key=lambda x: x.s, reverse=False) self.shapes.sort(key=lambda x: x.s, reverse=False) @classmethod - def parse(cls, lateral_profile_element: Optional[Element]) -> LateralProfile: + def parse(cls, lateral_profile_element: Optional[Element]) -> XODRLateralProfile: args = {} - super_elevations: List[SuperElevation] = [] - shapes: List[Shape] = [] + super_elevations: List[XODRSuperElevation] = [] + shapes: List[XODRShape] = [] if lateral_profile_element is not None: for super_elevation_element in lateral_profile_element.findall("superelevation"): - super_elevations.append(SuperElevation.parse(super_elevation_element)) + super_elevations.append(XODRSuperElevation.parse(super_elevation_element)) for shape_element in lateral_profile_element.findall("shape"): - shapes.append(Shape.parse(shape_element)) + shapes.append(XODRShape.parse(shape_element)) args["super_elevations"] = super_elevations args["shapes"] = shapes - return LateralProfile(**args) + return XODRLateralProfile(**args) @dataclass -class SuperElevation(Polynomial): +class XODRSuperElevation(XODRPolynomial): """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_05_elevation.html#sec-4abf7baf-fb2f-4263-8133-ad0f64f0feac @@ -84,7 +84,7 @@ class SuperElevation(Polynomial): @dataclass -class Shape: +class XODRShape: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/1.8.0/specification/10_roads/10_05_elevation.html#sec-66ac2b58-dc5e-4538-884d-204406ea53f2 @@ -99,9 +99,9 @@ class Shape: d: float @classmethod - def parse(cls, shape_element: Element) -> Shape: + def parse(cls, shape_element: Element) -> XODRShape: args = {key: float(shape_element.get(key)) for key in ["s", "t", "a", "b", "c", "d"]} - return Shape(**args) + return XODRShape(**args) def get_value(self, dt: float) -> float: """ diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py index a321e759..d5374d00 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py @@ -12,7 +12,7 @@ @dataclass -class Geometry: +class XODRGeometry: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/09_geometries/09_02_road_reference_line.html """ @@ -36,13 +36,13 @@ def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: @dataclass -class Line(Geometry): +class XODRLine(XODRGeometry): """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/09_geometries/09_03_straight_line.html """ @classmethod - def parse(cls, geometry_element: Element) -> Geometry: + def parse(cls, geometry_element: Element) -> XODRGeometry: args = {key: float(geometry_element.get(key)) for key in ["s", "x", "y", "hdg", "length"]} return cls(**args) @@ -60,7 +60,7 @@ def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: @dataclass -class Arc(Geometry): +class XODRArc(XODRGeometry): """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/09_geometries/09_05_arc.html """ @@ -72,7 +72,7 @@ def __post_init__(self): raise ValueError("Curvature cannot be zero for Arc geometry.") @classmethod - def parse(cls, geometry_element: Element) -> Geometry: + def parse(cls, geometry_element: Element) -> XODRGeometry: args = {key: float(geometry_element.get(key)) for key in ["s", "x", "y", "hdg", "length"]} args["curvature"] = float(geometry_element.find("arc").get("curvature")) return cls(**args) @@ -102,7 +102,7 @@ def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: @dataclass -class Spiral(Geometry): +class XODRSpiral(XODRGeometry): """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/09_geometries/09_04_spiral.html https://en.wikipedia.org/wiki/Euler_spiral @@ -117,7 +117,7 @@ def __post_init__(self): raise ValueError("Curvature change is too small, cannot define a valid spiral.") @classmethod - def parse(cls, geometry_element: Element) -> Geometry: + def parse(cls, geometry_element: Element) -> XODRGeometry: args = {key: float(geometry_element.get(key)) for key in ["s", "x", "y", "hdg", "length"]} spiral_element = geometry_element.find("spiral") args["curvature_start"] = float(spiral_element.get("curvStart")) diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/lane.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/lane.py index bb6df9f5..85867670 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/lane.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/lane.py @@ -4,35 +4,35 @@ from typing import List, Optional from xml.etree.ElementTree import Element -from py123d.conversion.utils.map_utils.opendrive.parser.polynomial import Polynomial +from py123d.conversion.utils.map_utils.opendrive.parser.polynomial import XODRPolynomial @dataclass -class Lanes: +class XODRLanes: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/11_lanes/11_01_introduction.html """ - lane_offsets: List[LaneOffset] - lane_sections: List[LaneSection] + lane_offsets: List[XODRLaneOffset] + lane_sections: List[XODRLaneSection] def __post_init__(self): self.lane_offsets.sort(key=lambda x: x.s, reverse=False) self.lane_sections.sort(key=lambda x: x.s, reverse=False) @classmethod - def parse(cls, lanes_element: Optional[Element]) -> Lanes: + def parse(cls, lanes_element: Optional[Element]) -> XODRLanes: args = {} - lane_offsets: List[LaneOffset] = [] + lane_offsets: List[XODRLaneOffset] = [] for lane_offset_element in lanes_element.findall("laneOffset"): - lane_offsets.append(LaneOffset.parse(lane_offset_element)) + lane_offsets.append(XODRLaneOffset.parse(lane_offset_element)) args["lane_offsets"] = lane_offsets - lane_sections: List[LaneSection] = [] + lane_sections: List[XODRLaneSection] = [] for lane_section_element in lanes_element.findall("laneSection"): - lane_sections.append(LaneSection.parse(lane_section_element)) + lane_sections.append(XODRLaneSection.parse(lane_section_element)) args["lane_sections"] = lane_sections - return Lanes(**args) + return XODRLanes(**args) @property def num_lane_sections(self) -> int: @@ -44,7 +44,7 @@ def last_lane_section_idx(self) -> int: @dataclass -class LaneOffset(Polynomial): +class XODRLaneOffset(XODRPolynomial): """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/11_lanes/11_04_lane_offset.html @@ -53,15 +53,15 @@ class LaneOffset(Polynomial): @dataclass -class LaneSection: +class XODRLaneSection: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/11_lanes/11_03_lane_sections.html """ s: float - left_lanes: List[Lane] - center_lanes: List[Lane] - right_lanes: List[Lane] + left_lanes: List[XODRLane] + center_lanes: List[XODRLane] + right_lanes: List[XODRLane] def __post_init__(self): self.left_lanes.sort(key=lambda x: x.id, reverse=False) @@ -69,33 +69,33 @@ def __post_init__(self): # NOTE: added assertion/filtering to check for element type or consistency @classmethod - def parse(cls, lane_section_element: Optional[Element]) -> LaneSection: + def parse(cls, lane_section_element: Optional[Element]) -> XODRLaneSection: args = {} args["s"] = float(lane_section_element.get("s")) - left_lanes: List[Lane] = [] + left_lanes: List[XODRLane] = [] if lane_section_element.find("left") is not None: for lane_element in lane_section_element.find("left").findall("lane"): - left_lanes.append(Lane.parse(lane_element)) + left_lanes.append(XODRLane.parse(lane_element)) args["left_lanes"] = left_lanes - center_lanes: List[Lane] = [] + center_lanes: List[XODRLane] = [] if lane_section_element.find("center") is not None: for lane_element in lane_section_element.find("center").findall("lane"): - center_lanes.append(Lane.parse(lane_element)) + center_lanes.append(XODRLane.parse(lane_element)) args["center_lanes"] = center_lanes - right_lanes: List[Lane] = [] + right_lanes: List[XODRLane] = [] if lane_section_element.find("right") is not None: for lane_element in lane_section_element.find("right").findall("lane"): - right_lanes.append(Lane.parse(lane_element)) + right_lanes.append(XODRLane.parse(lane_element)) args["right_lanes"] = right_lanes - return LaneSection(**args) + return XODRLaneSection(**args) @dataclass -class Lane: +class XODRLane: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/11_lanes/11_05_lane_link.html """ @@ -104,8 +104,8 @@ class Lane: type: str level: bool - widths: List[Width] - road_marks: List[RoadMark] + widths: List[XODRWidth] + road_marks: List[XODRRoadMark] predecessor: Optional[int] = None successor: Optional[int] = None @@ -115,7 +115,7 @@ def __post_init__(self): # NOTE: added assertion/filtering to check for element type or consistency @classmethod - def parse(cls, lane_element: Optional[Element]) -> Lane: + def parse(cls, lane_element: Optional[Element]) -> XODRLane: args = {} args["id"] = int(lane_element.get("id")) args["type"] = lane_element.get("type") @@ -127,21 +127,21 @@ def parse(cls, lane_element: Optional[Element]) -> Lane: if lane_element.find("link").find("successor") is not None: args["successor"] = int(lane_element.find("link").find("successor").get("id")) - widths: List[Width] = [] + widths: List[XODRWidth] = [] for width_element in lane_element.findall("width"): - widths.append(Width.parse(width_element)) + widths.append(XODRWidth.parse(width_element)) args["widths"] = widths - road_marks: List[Width] = [] + road_marks: List[XODRWidth] = [] for road_mark_element in lane_element.findall("roadMark"): - road_marks.append(RoadMark.parse(road_mark_element)) + road_marks.append(XODRRoadMark.parse(road_mark_element)) args["road_marks"] = road_marks - return Lane(**args) + return XODRLane(**args) @dataclass -class Width: +class XODRWidth: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/11_lanes/11_06_lane_geometry.html#sec-8d8ac2e0-b3d6-4048-a9ed-d5191af5c74b """ @@ -153,20 +153,20 @@ class Width: d: Optional[float] = None @classmethod - def parse(cls, width_element: Optional[Element]) -> Width: + def parse(cls, width_element: Optional[Element]) -> XODRWidth: args = {} args["s_offset"] = float(width_element.get("sOffset")) args["a"] = float(width_element.get("a")) args["b"] = float(width_element.get("b")) args["c"] = float(width_element.get("c")) args["d"] = float(width_element.get("d")) - return Width(**args) + return XODRWidth(**args) - def get_polynomial(self, t_sign: float = 1.0) -> Polynomial: + def get_polynomial(self, t_sign: float = 1.0) -> XODRPolynomial: """ Returns the polynomial representation of the width. """ - return Polynomial( + return XODRPolynomial( s=self.s_offset, a=self.a * t_sign, b=self.b * t_sign, @@ -176,7 +176,7 @@ def get_polynomial(self, t_sign: float = 1.0) -> Polynomial: @dataclass -class RoadMark: +class XODRRoadMark: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/11_lanes/11_08_road_markings.html """ @@ -193,7 +193,7 @@ def __post_init__(self): pass @classmethod - def parse(cls, road_mark_element: Optional[Element]) -> RoadMark: + def parse(cls, road_mark_element: Optional[Element]) -> XODRRoadMark: args = {} args["s_offset"] = float(road_mark_element.get("sOffset")) args["type"] = road_mark_element.get("type") @@ -202,4 +202,4 @@ def parse(cls, road_mark_element: Optional[Element]) -> RoadMark: if road_mark_element.get("width") is not None: args["width"] = float(road_mark_element.get("width")) args["lane_change"] = road_mark_element.get("lane_change") - return RoadMark(**args) + return XODRRoadMark(**args) diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py index 4c409c54..8c1b17af 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py @@ -6,7 +6,7 @@ @dataclass -class Object: +class XODRObject: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/13_objects/13_01_introduction.html """ @@ -31,7 +31,7 @@ def __post_init__(self): pass @classmethod - def parse(cls, object_element: Optional[Element]) -> Object: + def parse(cls, object_element: Optional[Element]) -> XODRObject: args = {} args["id"] = int(object_element.get("id")) @@ -53,7 +53,7 @@ def parse(cls, object_element: Optional[Element]) -> Object: outline.append(CornerLocal.parse(corner_element)) args["outline"] = outline - return Object(**args) + return XODRObject(**args) @dataclass diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py index 719f7bf3..3dfac7f1 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py @@ -6,31 +6,31 @@ from typing import List, Literal, Optional from xml.etree.ElementTree import Element, parse -from py123d.conversion.utils.map_utils.opendrive.parser.road import Road +from py123d.conversion.utils.map_utils.opendrive.parser.road import XODRRoad @dataclass -class OpenDrive: +class XODR: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/06_general_architecture/06_03_root_element.html """ header: Header - roads: List[Road] + roads: List[XODRRoad] controllers: List[Controller] junctions: List[Junction] @classmethod - def parse(cls, root_element: Element) -> OpenDrive: + def parse(cls, root_element: Element) -> XODR: args = {} args["header"] = Header.parse(root_element.find("header")) - roads: List[Road] = [] + roads: List[XODRRoad] = [] for road_element in root_element.findall("road"): try: - roads.append(Road.parse(road_element)) + roads.append(XODRRoad.parse(road_element)) except Exception as e: print( f"Error parsing road element with id/name {road_element.get('id')}/{road_element.get('name')}: {e}" @@ -48,12 +48,12 @@ def parse(cls, root_element: Element) -> OpenDrive: junctions.append(Junction.parse(junction_element)) args["junctions"] = junctions - return OpenDrive(**args) + return XODR(**args) @classmethod - def parse_from_file(cls, file_path: Path) -> OpenDrive: + def parse_from_file(cls, file_path: Path) -> XODR: tree = parse(file_path) - return OpenDrive.parse(tree.getroot()) + return XODR.parse(tree.getroot()) @dataclass diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/polynomial.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/polynomial.py index 497ffed5..5a716c52 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/polynomial.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/polynomial.py @@ -3,7 +3,7 @@ @dataclass -class Polynomial: +class XODRPolynomial: """ Multiple OpenDRIVE elements use polynomial coefficients, e.g. Elevation, LaneOffset, etc. This class provides a common interface to parse and access polynomial coefficients. @@ -19,7 +19,7 @@ class Polynomial: d: float @classmethod - def parse(cls: type["Polynomial"], element: Element) -> "Polynomial": + def parse(cls: type["XODRPolynomial"], element: Element) -> "XODRPolynomial": args = {key: float(element.get(key)) for key in ["s", "a", "b", "c", "d"]} return cls(**args) diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py index ade334c5..b94a7af0 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py @@ -9,39 +9,39 @@ import numpy as np import numpy.typing as npt -from py123d.conversion.utils.map_utils.opendrive.parser.elevation import Elevation -from py123d.conversion.utils.map_utils.opendrive.parser.geometry import Arc, Geometry, Line, Spiral -from py123d.conversion.utils.map_utils.opendrive.parser.lane import LaneOffset, Width -from py123d.conversion.utils.map_utils.opendrive.parser.polynomial import Polynomial +from py123d.conversion.utils.map_utils.opendrive.parser.elevation import XODRElevation +from py123d.conversion.utils.map_utils.opendrive.parser.geometry import XODRArc, XODRGeometry, XODRLine, XODRSpiral +from py123d.conversion.utils.map_utils.opendrive.parser.lane import XODRLaneOffset, XODRWidth +from py123d.conversion.utils.map_utils.opendrive.parser.polynomial import XODRPolynomial from py123d.geometry import Point3DIndex, PoseSE2Index TOLERANCE: Final[float] = 1e-3 @dataclass -class PlanView: +class XODRPlanView: - geometries: List[Geometry] + geometries: List[XODRGeometry] def __post_init__(self): # Ensure geometries are sorted by their starting position 's' self.geometries.sort(key=lambda x: x.s) @classmethod - def parse(cls, plan_view_element: Optional[Element]) -> PlanView: - geometries: List[Geometry] = [] + def parse(cls, plan_view_element: Optional[Element]) -> XODRPlanView: + geometries: List[XODRGeometry] = [] for geometry_element in plan_view_element.findall("geometry"): if geometry_element.find("line") is not None: - geometry = Line.parse(geometry_element) + geometry = XODRLine.parse(geometry_element) elif geometry_element.find("arc") is not None: - geometry = Arc.parse(geometry_element) + geometry = XODRArc.parse(geometry_element) elif geometry_element.find("spiral") is not None: - geometry = Spiral.parse(geometry_element) + geometry = XODRSpiral.parse(geometry_element) else: geometry_str = ET.tostring(geometry_element, encoding="unicode") raise NotImplementedError(f"Geometry not implemented: {geometry_str}") geometries.append(geometry) - return PlanView(geometries=geometries) + return XODRPlanView(geometries=geometries) @cached_property def geometry_lengths(self) -> npt.NDArray[np.float64]: @@ -71,11 +71,11 @@ def interpolate_se2(self, s: float, t: float = 0.0, lane_section_end: bool = Fal @dataclass -class ReferenceLine: +class XODRReferenceLine: - reference_line: Union[ReferenceLine, PlanView] - width_polynomials: List[Polynomial] - elevations: List[Elevation] + reference_line: Union[XODRReferenceLine, XODRPlanView] + width_polynomials: List[XODRPolynomial] + elevations: List[XODRElevation] s_offset: float @property @@ -85,40 +85,40 @@ def length(self) -> float: @classmethod def from_plan_view( cls, - plan_view: PlanView, - lane_offsets: List[LaneOffset], - elevations: List[Elevation], - ) -> ReferenceLine: + plan_view: XODRPlanView, + lane_offsets: List[XODRLaneOffset], + elevations: List[XODRElevation], + ) -> XODRReferenceLine: args = {} args["reference_line"] = plan_view args["width_polynomials"] = lane_offsets args["elevations"] = elevations args["s_offset"] = 0.0 - return ReferenceLine(**args) + return XODRReferenceLine(**args) @classmethod def from_reference_line( cls, - reference_line: ReferenceLine, - widths: List[Width], + reference_line: XODRReferenceLine, + widths: List[XODRWidth], s_offset: float = 0.0, t_sign: float = 1.0, - ) -> ReferenceLine: + ) -> XODRReferenceLine: assert t_sign in [1.0, -1.0], "t_sign must be either 1.0 or -1.0" args = {} args["reference_line"] = reference_line - width_polynomials: List[Polynomial] = [] + width_polynomials: List[XODRPolynomial] = [] for width in widths: width_polynomials.append(width.get_polynomial(t_sign=t_sign)) args["width_polynomials"] = width_polynomials args["s_offset"] = s_offset args["elevations"] = reference_line.elevations - return ReferenceLine(**args) + return XODRReferenceLine(**args) @staticmethod - def _find_polynomial(s: float, polynomials: List[Polynomial], lane_section_end: bool = False) -> Polynomial: + def _find_polynomial(s: float, polynomials: List[XODRPolynomial], lane_section_end: bool = False) -> XODRPolynomial: out_polynomial = polynomials[-1] for polynomial in polynomials[::-1]: diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/road.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/road.py index 7db82b69..ebb9c6a8 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/road.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/road.py @@ -4,14 +4,14 @@ from typing import List, Optional from xml.etree.ElementTree import Element -from py123d.conversion.utils.map_utils.opendrive.parser.elevation import ElevationProfile, LateralProfile -from py123d.conversion.utils.map_utils.opendrive.parser.lane import Lanes -from py123d.conversion.utils.map_utils.opendrive.parser.objects import Object -from py123d.conversion.utils.map_utils.opendrive.parser.reference import PlanView +from py123d.conversion.utils.map_utils.opendrive.parser.elevation import XODRLateralProfile, XORDElevationProfile +from py123d.conversion.utils.map_utils.opendrive.parser.lane import XODRLanes +from py123d.conversion.utils.map_utils.opendrive.parser.objects import XODRObject +from py123d.conversion.utils.map_utils.opendrive.parser.reference import XODRPlanView @dataclass -class Road: +class XODRRoad: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_01_introduction.html """ @@ -21,13 +21,13 @@ class Road: length: float name: Optional[str] - link: Link - road_types: List[RoadType] - plan_view: PlanView - elevation_profile: ElevationProfile - lateral_profile: LateralProfile - lanes: Lanes - objects: List[Object] + link: XODRLink + road_types: List[XODRRoadType] + plan_view: XODRPlanView + elevation_profile: XORDElevationProfile + lateral_profile: XODRLateralProfile + lanes: XODRLanes + objects: List[XODRObject] rule: Optional[str] = None # NOTE: ignored @@ -37,7 +37,7 @@ def __post_init__(self): ) # FIXME: Find out the purpose RHT=right-hand traffic, LHT=left-hand traffic @classmethod - def parse(cls, road_element: Element) -> Road: + def parse(cls, road_element: Element) -> XODRRoad: args = {} args["id"] = int(road_element.get("id")) @@ -45,51 +45,51 @@ def parse(cls, road_element: Element) -> Road: args["length"] = float(road_element.get("length")) args["name"] = road_element.get("name") - args["link"] = Link.parse(road_element.find("link")) + args["link"] = XODRLink.parse(road_element.find("link")) - road_types: List[RoadType] = [] + road_types: List[XODRRoadType] = [] for road_type_element in road_element.findall("type"): - road_types.append(RoadType.parse(road_type_element)) + road_types.append(XODRRoadType.parse(road_type_element)) args["road_types"] = road_types - args["plan_view"] = PlanView.parse(road_element.find("planView")) - args["elevation_profile"] = ElevationProfile.parse(road_element.find("elevationProfile")) - args["lateral_profile"] = LateralProfile.parse(road_element.find("lateralProfile")) + args["plan_view"] = XODRPlanView.parse(road_element.find("planView")) + args["elevation_profile"] = XORDElevationProfile.parse(road_element.find("elevationProfile")) + args["lateral_profile"] = XODRLateralProfile.parse(road_element.find("lateralProfile")) - args["lanes"] = Lanes.parse(road_element.find("lanes")) + args["lanes"] = XODRLanes.parse(road_element.find("lanes")) - objects: List[Object] = [] + objects: List[XODRObject] = [] if road_element.find("objects") is not None: for object_element in road_element.find("objects").findall("object"): - objects.append(Object.parse(object_element)) + objects.append(XODRObject.parse(object_element)) args["objects"] = objects - return Road(**args) + return XODRRoad(**args) @dataclass -class Link: +class XODRLink: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_03_road_linkage.html """ - predecessor: Optional[PredecessorSuccessor] = None - successor: Optional[PredecessorSuccessor] = None + predecessor: Optional[XODRPredecessorSuccessor] = None + successor: Optional[XODRPredecessorSuccessor] = None @classmethod - def parse(cls, link_element: Optional[Element]) -> PlanView: + def parse(cls, link_element: Optional[Element]) -> XODRPlanView: args = {} if link_element is not None: if link_element.find("predecessor") is not None: - args["predecessor"] = PredecessorSuccessor.parse(link_element.find("predecessor")) + args["predecessor"] = XODRPredecessorSuccessor.parse(link_element.find("predecessor")) if link_element.find("successor") is not None: - args["successor"] = PredecessorSuccessor.parse(link_element.find("successor")) - return Link(**args) + args["successor"] = XODRPredecessorSuccessor.parse(link_element.find("successor")) + return XODRLink(**args) @dataclass -class PredecessorSuccessor: +class XODRPredecessorSuccessor: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_03_road_linkage.html """ @@ -103,36 +103,36 @@ def __post_init__(self): assert self.contact_point is None or self.contact_point in ["start", "end"] @classmethod - def parse(cls, element: Element) -> PredecessorSuccessor: + def parse(cls, element: Element) -> XODRPredecessorSuccessor: args = {} args["element_type"] = element.get("elementType") args["element_id"] = int(element.get("elementId")) args["contact_point"] = element.get("contactPoint") - return PredecessorSuccessor(**args) + return XODRPredecessorSuccessor(**args) @dataclass -class RoadType: +class XODRRoadType: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_04_road_type.html """ s: Optional[float] = None type: Optional[str] = None - speed: Optional[Speed] = None + speed: Optional[XODRSpeed] = None @classmethod - def parse(cls, road_type_element: Optional[Element]) -> RoadType: + def parse(cls, road_type_element: Optional[Element]) -> XODRRoadType: args = {} if road_type_element is not None: args["s"] = float(road_type_element.get("s")) args["type"] = road_type_element.get("type") - args["speed"] = Speed.parse(road_type_element.find("speed")) - return RoadType(**args) + args["speed"] = XODRSpeed.parse(road_type_element.find("speed")) + return XODRRoadType(**args) @dataclass -class Speed: +class XODRSpeed: """ https://publications.pages.asam.net/standards/ASAM_OpenDRIVE/ASAM_OpenDRIVE_Specification/latest/specification/10_roads/10_04_road_type.html#sec-33dc6899-854e-4533-a3d9-76e9e1518ee7 """ @@ -141,9 +141,9 @@ class Speed: unit: Optional[str] = None @classmethod - def parse(cls, speed_element: Optional[Element]) -> Speed: + def parse(cls, speed_element: Optional[Element]) -> XODRSpeed: args = {} if speed_element is not None: args["max"] = float(speed_element.get("max")) args["unit"] = speed_element.get("unit") - return Speed(**args) + return XODRSpeed(**args) diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py index fc6f96f3..7a4368c5 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py @@ -3,9 +3,9 @@ import numpy as np -from py123d.conversion.utils.map_utils.opendrive.parser.opendrive import Junction, OpenDrive -from py123d.conversion.utils.map_utils.opendrive.parser.reference import ReferenceLine -from py123d.conversion.utils.map_utils.opendrive.parser.road import Road +from py123d.conversion.utils.map_utils.opendrive.parser.opendrive import XODR, Junction +from py123d.conversion.utils.map_utils.opendrive.parser.reference import XODRReferenceLine +from py123d.conversion.utils.map_utils.opendrive.parser.road import XODRRoad from py123d.conversion.utils.map_utils.opendrive.utils.id_system import ( build_lane_id, derive_lane_section_id, @@ -23,11 +23,11 @@ def collect_element_helpers( - opendrive: OpenDrive, + opendrive: XODR, interpolation_step_size: float, connection_distance_threshold: float, ) -> Tuple[ - Dict[int, Road], + Dict[int, XODRRoad], Dict[int, Junction], Dict[str, OpenDriveLaneHelper], Dict[str, OpenDriveLaneGroupHelper], @@ -35,13 +35,13 @@ def collect_element_helpers( ]: # 1. Fill the road and junction dictionaries - road_dict: Dict[int, Road] = {road.id: road for road in opendrive.roads} + road_dict: Dict[int, XODRRoad] = {road.id: road for road in opendrive.roads} junction_dict: Dict[int, Junction] = {junction.id: junction for junction in opendrive.junctions} # 2. Create lane helpers from the roads lane_helper_dict: Dict[str, OpenDriveLaneHelper] = {} for road in opendrive.roads: - reference_line = ReferenceLine.from_plan_view( + reference_line = XODRReferenceLine.from_plan_view( road.plan_view, road.lanes.lane_offsets, road.elevation_profile.elevations, @@ -81,7 +81,9 @@ def collect_element_helpers( return (road_dict, junction_dict, lane_helper_dict, lane_group_helper_dict, crosswalk_dict) -def _update_connection_from_links(lane_helper_dict: Dict[str, OpenDriveLaneHelper], road_dict: Dict[int, Road]) -> None: +def _update_connection_from_links( + lane_helper_dict: Dict[str, OpenDriveLaneHelper], road_dict: Dict[int, XODRRoad] +) -> None: """ Uses the links of the roads to update the connections between lane helpers. :param lane_helper_dict: Dictionary of lane helpers indexed by lane id. @@ -164,7 +166,7 @@ def _update_connection_from_links(lane_helper_dict: Dict[str, OpenDriveLaneHelpe def _update_connection_from_junctions( lane_helper_dict: Dict[str, OpenDriveLaneHelper], junction_dict: Dict[int, Junction], - road_dict: Dict[int, Road], + road_dict: Dict[int, XODRRoad], ) -> None: """ Helper function to update the lane connections based on junctions. @@ -269,7 +271,7 @@ def _post_process_connections( def _collect_lane_groups( lane_helper_dict: Dict[str, OpenDriveLaneHelper], junction_dict: Dict[int, Junction], - road_dict: Dict[int, Road], + road_dict: Dict[int, XODRRoad], ) -> None: lane_group_helper_dict: Dict[str, OpenDriveLaneGroupHelper] = {} @@ -306,13 +308,13 @@ def _collect_lane_group_ids_of_road(road_id: int) -> List[str]: return lane_group_helper_dict -def _collect_crosswalks(opendrive: OpenDrive) -> Dict[int, OpenDriveObjectHelper]: +def _collect_crosswalks(opendrive: XODR) -> Dict[int, OpenDriveObjectHelper]: object_helper_dict: Dict[int, OpenDriveObjectHelper] = {} for road in opendrive.roads: if len(road.objects) == 0: continue - reference_line = ReferenceLine.from_plan_view( + reference_line = XODRReferenceLine.from_plan_view( road.plan_view, road.lanes.lane_offsets, road.elevation_profile.elevations, diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py index f515b424..27098773 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py @@ -6,9 +6,9 @@ import numpy.typing as npt import shapely -from py123d.conversion.utils.map_utils.opendrive.parser.lane import Lane, LaneSection -from py123d.conversion.utils.map_utils.opendrive.parser.reference import ReferenceLine -from py123d.conversion.utils.map_utils.opendrive.parser.road import RoadType +from py123d.conversion.utils.map_utils.opendrive.parser.lane import XODRLane, XODRLaneSection +from py123d.conversion.utils.map_utils.opendrive.parser.reference import XODRReferenceLine +from py123d.conversion.utils.map_utils.opendrive.parser.road import XODRRoadType from py123d.conversion.utils.map_utils.opendrive.utils.id_system import ( derive_lane_group_id, derive_lane_id, @@ -23,11 +23,11 @@ class OpenDriveLaneHelper: lane_id: str - open_drive_lane: Lane + open_drive_lane: XODRLane s_inner_offset: float s_range: Tuple[float, float] - inner_boundary: ReferenceLine - outer_boundary: ReferenceLine + inner_boundary: XODRReferenceLine + outer_boundary: XODRReferenceLine speed_limit_mps: Optional[float] interpolation_step_size: float @@ -255,11 +255,11 @@ def shapely_polygon(self) -> shapely.Polygon: def lane_section_to_lane_helpers( lane_section_id: str, - lane_section: LaneSection, - reference_line: ReferenceLine, + lane_section: XODRLaneSection, + reference_line: XODRReferenceLine, s_min: float, s_max: float, - road_types: List[RoadType], + road_types: List[XODRRoadType], interpolation_step_size: float, ) -> Dict[str, OpenDriveLaneHelper]: @@ -272,7 +272,7 @@ def lane_section_to_lane_helpers( lane_id = derive_lane_id(lane_group_id, lane.id) s_inner_offset = lane_section.s if len(lane_boundaries) == 1 else 0.0 lane_boundaries.append( - ReferenceLine.from_reference_line( + XODRReferenceLine.from_reference_line( reference_line=lane_boundaries[-1], widths=lane.widths, s_offset=s_inner_offset, @@ -294,7 +294,7 @@ def lane_section_to_lane_helpers( return lane_helpers -def _get_speed_limit_mps(s: float, road_types: List[RoadType]) -> Optional[float]: +def _get_speed_limit_mps(s: float, road_types: List[XODRRoadType]) -> Optional[float]: # NOTE: Likely not correct way to extract speed limit from CARLA maps, but serves as a placeholder speed_limit_mps: Optional[float] = None diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py index bdf8ac99..97033812 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py @@ -5,8 +5,8 @@ import numpy.typing as npt import shapely -from py123d.conversion.utils.map_utils.opendrive.parser.objects import Object -from py123d.conversion.utils.map_utils.opendrive.parser.reference import ReferenceLine +from py123d.conversion.utils.map_utils.opendrive.parser.objects import XODRObject +from py123d.conversion.utils.map_utils.opendrive.parser.reference import XODRReferenceLine from py123d.geometry import Point3D, Point3DIndex, PoseSE2, Vector2D from py123d.geometry.geometry_index import PoseSE2Index from py123d.geometry.polyline import Polyline3D @@ -33,7 +33,7 @@ def shapely_polygon(self) -> shapely.Polygon: return shapely.geometry.Polygon(self.outline_3d[:, Point3DIndex.XY]) -def get_object_helper(object: Object, reference_line: ReferenceLine) -> OpenDriveObjectHelper: +def get_object_helper(object: XODRObject, reference_line: XODRReferenceLine) -> OpenDriveObjectHelper: object_helper: Optional[OpenDriveObjectHelper] = None diff --git a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py index 0365722f..f777dff4 100644 --- a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py +++ b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py @@ -9,12 +9,12 @@ import shapely.geometry as geom from py123d.conversion.utils.map_utils.road_edge.road_edge_2d_utils import get_road_edge_linear_rings -from py123d.datatypes.map.abstract_map_objects import ( - AbstractCarpark, - AbstractGenericDrivable, - AbstractLane, - AbstractLaneGroup, - AbstractSurfaceMapObject, +from py123d.datatypes.map_objects.map_objects import ( + BaseMapSurfaceObject, + Carpark, + GenericDrivable, + Lane, + LaneGroup, MapObjectIDType, ) from py123d.geometry import Point3DIndex @@ -25,10 +25,10 @@ def get_road_edges_3d_from_drivable_surfaces( - lanes: List[AbstractLane], - lane_groups: List[AbstractLaneGroup], - car_parks: List[AbstractCarpark], - generic_drivables: List[AbstractGenericDrivable], + lanes: List[Lane], + lane_groups: List[LaneGroup], + car_parks: List[Carpark], + generic_drivables: List[GenericDrivable], ) -> List[Polyline3D]: """Generates 3D road edges from drivable surfaces, i.e., lane groups, car parks, and generic drivables. This method merges polygons in 2D and lifts them to 3D using the boundaries/outlines of elements. @@ -47,7 +47,7 @@ def get_road_edges_3d_from_drivable_surfaces( # 2. Extract road edges in 2D (including conflicting lane groups) drivable_polygons: List[shapely.Polygon] = [] for map_surface in lane_groups + car_parks + generic_drivables: - map_surface: AbstractSurfaceMapObject + map_surface: BaseMapSurfaceObject drivable_polygons.append(map_surface.shapely_polygon) road_edges_2d = get_road_edge_linear_rings(drivable_polygons) @@ -73,7 +73,7 @@ def get_road_edges_3d_from_drivable_surfaces( def _get_conflicting_lane_groups( - lane_groups: List[AbstractLaneGroup], lanes: List[AbstractLane], z_threshold: float = 5.0 + lane_groups: List[LaneGroup], lanes: List[Lane], z_threshold: float = 5.0 ) -> Dict[int, List[int]]: """Identifies conflicting lane groups based on their 2D footprints and Z-values. The z-values are inferred from the centerlines of the lanes within each lane group. @@ -85,9 +85,7 @@ def _get_conflicting_lane_groups( """ # Convert to regular dictionaries for simpler access - lane_group_dict: Dict[MapObjectIDType, AbstractLaneGroup] = { - lane_group.object_id: lane_group for lane_group in lane_groups - } + lane_group_dict: Dict[MapObjectIDType, LaneGroup] = {lane_group.object_id: lane_group for lane_group in lane_groups} lane_centerline_dict: Dict[MapObjectIDType, Polyline3D] = {lane.object_id: lane.centerline for lane in lanes} # Pre-compute all centerlines @@ -255,7 +253,7 @@ def lift_outlines_to_3d( def _resolve_conflicting_lane_groups( conflicting_lane_groups: Dict[MapObjectIDType, List[MapObjectIDType]], - lane_groups: List[AbstractLaneGroup], + lane_groups: List[LaneGroup], ) -> List[Polyline3D]: """Resolve conflicting lane groups by merging their geometries. @@ -265,9 +263,7 @@ def _resolve_conflicting_lane_groups( """ # Helper dictionary for easy access to lane group data - lane_group_dict: Dict[MapObjectIDType, AbstractLaneGroup] = { - lane_group.object_id: lane_group for lane_group in lane_groups - } + lane_group_dict: Dict[MapObjectIDType, LaneGroup] = {lane_group.object_id: lane_group for lane_group in lane_groups} # NOTE @DanielDauner: A non-conflicting set has overlapping lane groups separated into different layers (e.g., bridges). # For each non-conflicting set, we can repeat the process of merging polygons in 2D and lifting to 3D. diff --git a/src/py123d/datatypes/map/__init__.py b/src/py123d/datatypes/map/__init__.py deleted file mode 100644 index 5b339f8f..00000000 --- a/src/py123d/datatypes/map/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from py123d.datatypes.map.abstract_map import AbstractMap -from py123d.datatypes.map.abstract_map_objects import ( - AbstractCarpark, - AbstractCrosswalk, - AbstractGenericDrivable, - AbstractIntersection, - AbstractLane, - AbstractLaneGroup, - AbstractLineMapObject, - AbstractMapObject, - AbstractRoadEdge, - AbstractRoadLine, - AbstractStopLine, - AbstractSurfaceMapObject, - AbstractWalkway, -) -from py123d.datatypes.map.map_datatypes import MapLayer -from py123d.datatypes.map.map_metadata import MapMetadata diff --git a/src/py123d/datatypes/map/abstract_map_objects.py b/src/py123d/datatypes/map/abstract_map_objects.py deleted file mode 100644 index beb23a84..00000000 --- a/src/py123d/datatypes/map/abstract_map_objects.py +++ /dev/null @@ -1,453 +0,0 @@ -from __future__ import annotations - -import abc -from typing import List, Optional, Tuple, Union - -import shapely.geometry as geom -import trimesh -from typing_extensions import TypeAlias - -from py123d.datatypes.map.map_datatypes import MapLayer, RoadEdgeType, RoadLineType -from py123d.geometry import Polyline2D, Polyline3D, PolylineSE2 - -# TODO: Refactor and just use int -# type MapObjectIDType = Union[str, int] for Python >= 3.12 -MapObjectIDType: TypeAlias = Union[str, int] - - -class AbstractMapObject(abc.ABC): - - def __init__(self, object_id: MapObjectIDType): - """Constructor of the base map object type. - - :param object_id: unique identifier of the map object. - """ - self._object_id: MapObjectIDType = object_id - - @property - def object_id(self) -> MapObjectIDType: - """Returns the unique identifier of the map object. - - :return: map object id - """ - return self._object_id - - @property - @abc.abstractmethod - def layer(self) -> MapLayer: - """ - - :return: map layer type - """ - - -class AbstractSurfaceMapObject(AbstractMapObject): - - @property - @abc.abstractmethod - def shapely_polygon(self) -> geom.Polygon: - """Returns the 2D shapely polygon of the map object. - - :return: shapely polygon - """ - raise NotImplementedError - - @property - @abc.abstractmethod - def outline(self) -> Union[Polyline2D, Polyline3D]: - """Returns the 2D or 3D outline of the map surface, if available. - - :return: 2D or 3D polyline - """ - raise NotImplementedError - - @property - @abc.abstractmethod - def trimesh_mesh(self) -> trimesh.Trimesh: - """ - Returns a triangle mesh of the map surface. - :return: Trimesh - """ - raise NotImplementedError - - @property - def outline_3d(self) -> Polyline3D: - """Returns the 3D outline of the map surface, or converts 2D to 3D if necessary. - - :return: 3D polyline - """ - if isinstance(self.outline, Polyline3D): - return self.outline - # Converts 2D polyline to 3D by adding a default (zero) z-coordinate - return Polyline3D.from_linestring(self.outline.linestring) - - @property - def outline_2d(self) -> Polyline2D: - """Returns the 2D outline of the map surface, or converts 3D to 2D if necessary. - - :return: 2D polyline - """ - if isinstance(self.outline, Polyline2D): - return self.outline - # Converts 3D polyline to 2D by dropping the z-coordinate - return self.outline.polyline_2d - - -class AbstractLineMapObject(AbstractMapObject): - - @property - @abc.abstractmethod - def polyline(self) -> Union[Polyline2D, Polyline3D]: - """ - Returns the polyline of the road edge, either 2D or 3D. - :return: polyline - """ - - @property - def polyline_3d(self) -> Polyline3D: - """ - Returns the 3D polyline of the road edge. - :return: 3D polyline - """ - if isinstance(self.polyline, Polyline3D): - return self.polyline - # Converts 2D polyline to 3D by adding a default (zero) z-coordinate - return Polyline3D.from_linestring(self.polyline.linestring) - - @property - def polyline_2d(self) -> Polyline2D: - """ - Returns the 2D polyline of the road line. - :return: 2D polyline - """ - if isinstance(self.polyline, Polyline2D): - return self.polyline - # Converts 3D polyline to 2D by dropping the z-coordinate - return self.polyline.polyline_2d - - @property - def polyline_se2(self) -> PolylineSE2: - """ - Returns the 2D polyline of the road line in SE(2) coordinates. - :return: 2D polyline in SE(2) - """ - return self.polyline_2d.polyline_se2 - - @property - def shapely_linestring(self) -> geom.LineString: - """ - Returns the shapely linestring of the line, either 2D or 3D. - :return: shapely linestring - """ - return self.polyline.linestring - - -class AbstractLane(AbstractSurfaceMapObject): - - @property - def layer(self) -> MapLayer: - return MapLayer.LANE - - @property - @abc.abstractmethod - def speed_limit_mps(self) -> Optional[float]: - """Property of lanes speed limit in m/s, if available. - - :return: float or none - """ - - @property - @abc.abstractmethod - def successor_ids(self) -> List[MapObjectIDType]: - """ - Property of succeeding lane object ids (front). - :return: list of lane ids - """ - - @property - @abc.abstractmethod - def successors(self) -> List[AbstractLane]: - """ - Property of succeeding lane objects (front). - :return: list of lane class - """ - - @property - @abc.abstractmethod - def predecessor_ids(self) -> List[MapObjectIDType]: - """ - Property of preceding lane object ids (behind). - :return: list of lane ids - """ - - @property - @abc.abstractmethod - def predecessors(self) -> List[AbstractLane]: - """ - Property of preceding lane objects (behind). - :return: list of lane class - """ - - @property - @abc.abstractmethod - def left_boundary(self) -> Polyline3D: - """ - Property of left boundary of lane. - :return: returns 3D polyline - """ - - @property - @abc.abstractmethod - def right_boundary(self) -> Polyline3D: - """ - Property of right boundary of lane. - :return: returns 3D polyline - """ - - @property - @abc.abstractmethod - def left_lane_id(self) -> Optional[MapObjectIDType]: - """ - Property of left lane id of lane. - :return: returns left lane id or none, if no left lane - """ - - @property - @abc.abstractmethod - def left_lane(self) -> Optional[AbstractLane]: - """ - Property of left lane of lane. - :return: returns left lane or none, if no left lane - """ - - @property - @abc.abstractmethod - def right_lane_id(self) -> Optional[MapObjectIDType]: - """ - Property of right lane id of lane. - :return: returns right lane id or none, if no right lane - """ - - @property - @abc.abstractmethod - def right_lane(self) -> Optional[AbstractLane]: - """ - Property of right lane of lane. - :return: returns right lane or none, if no right lane - """ - - @property - @abc.abstractmethod - def centerline(self) -> Polyline3D: - """ - Property of centerline of lane. - :return: returns 3D polyline - """ - - @property - @abc.abstractmethod - def lane_group_id(self) -> AbstractLaneGroup: - """ - Property of lane group id of lane. - :return: returns lane group id - """ - - @property - @abc.abstractmethod - def lane_group(self) -> AbstractLaneGroup: - """ - Property of lane group of lane. - :return: returns lane group - """ - - @property - def boundaries(self) -> Tuple[Polyline3D, Polyline3D]: - """ - Property of left and right boundary. - :return: returns tuple of left and right boundary polylines - """ - return self.left_boundary, self.right_boundary - - -class AbstractLaneGroup(AbstractSurfaceMapObject): - """Abstract interface lane groups (nearby lanes going in the same direction).""" - - @property - def layer(self) -> MapLayer: - return MapLayer.LANE_GROUP - - @property - @abc.abstractmethod - def successor_ids(self) -> List[MapObjectIDType]: - """ - Property of succeeding lane object ids (front). - :return: list of lane group ids - """ - - @property - @abc.abstractmethod - def successors(self) -> List[AbstractLaneGroup]: - """ - Property of succeeding lane group objects (front). - :return: list of lane group class - """ - - @property - @abc.abstractmethod - def predecessor_ids(self) -> List[MapObjectIDType]: - """ - Property of preceding lane object ids (behind). - :return: list of lane group ids - """ - - @property - @abc.abstractmethod - def predecessors(self) -> List[AbstractLaneGroup]: - """ - Property of preceding lane group objects (behind). - :return: list of lane group class - """ - - @property - @abc.abstractmethod - def left_boundary(self) -> Polyline3D: - """ - Property of left boundary of lane group. - :return: returns 3D polyline - """ - - @property - @abc.abstractmethod - def right_boundary(self) -> Polyline3D: - """ - Property of right boundary of lane group. - :return: returns 3D polyline - """ - - @property - @abc.abstractmethod - def lane_ids(self) -> List[MapObjectIDType]: - """ - Property of interior lane ids of a lane group. - :return: returns list of lane ids - """ - - @property - @abc.abstractmethod - def lanes(self) -> List[AbstractLane]: - """ - Property of interior lanes of a lane group. - :return: returns list of lanes - """ - - @property - @abc.abstractmethod - def intersection_id(self) -> Optional[MapObjectIDType]: - """ - Property of intersection id of a lane group. - :return: returns intersection id or none, if lane group not in intersection - """ - - @property - @abc.abstractmethod - def intersection(self) -> Optional[AbstractIntersection]: - """ - Property of intersection of a lane group. - :return: returns intersection or none, if lane group not in intersection - """ - - -class AbstractIntersection(AbstractSurfaceMapObject): - """Abstract interface for intersection objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.INTERSECTION - - @property - @abc.abstractmethod - def lane_group_ids(self) -> List[MapObjectIDType]: - """ - Property of lane group ids of intersection. - :return: returns list of lane group ids - """ - - @property - @abc.abstractmethod - def lane_groups(self) -> List[AbstractLaneGroup]: - """ - Property of lane groups of intersection. - :return: returns list of lane groups - """ - - -class AbstractCrosswalk(AbstractSurfaceMapObject): - """Abstract interface for crosswalk objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.CROSSWALK - - -class AbstractWalkway(AbstractSurfaceMapObject): - """Abstract interface for walkway objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.WALKWAY - - -class AbstractCarpark(AbstractSurfaceMapObject): - """Abstract interface for carpark objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.CARPARK - - -class AbstractGenericDrivable(AbstractSurfaceMapObject): - """Abstract interface for generic drivable objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.GENERIC_DRIVABLE - - -class AbstractStopLine(AbstractSurfaceMapObject): - """Abstract interface for stop line objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.STOP_LINE - - -class AbstractRoadEdge(AbstractLineMapObject): - """Abstract interface for road edge objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.ROAD_EDGE - - @property - @abc.abstractmethod - def road_edge_type(self) -> RoadEdgeType: - """ - Returns the road edge type. - :return: RoadEdgeType - """ - - -class AbstractRoadLine(AbstractLineMapObject): - """Abstract interface for road line objects.""" - - @property - def layer(self) -> MapLayer: - return MapLayer.ROAD_LINE - - @property - @abc.abstractmethod - def road_line_type(self) -> RoadLineType: - """ - Returns the road line type. - :return: RoadLineType - """ diff --git a/src/py123d/datatypes/map/cache/cache_map_objects.py b/src/py123d/datatypes/map/cache/cache_map_objects.py deleted file mode 100644 index 9e993e16..00000000 --- a/src/py123d/datatypes/map/cache/cache_map_objects.py +++ /dev/null @@ -1,311 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional, Union - -import numpy as np -import shapely.geometry as geom -import trimesh - -from py123d.datatypes.map.abstract_map_objects import ( - AbstractCarpark, - AbstractCrosswalk, - AbstractGenericDrivable, - AbstractIntersection, - AbstractLane, - AbstractLaneGroup, - AbstractLineMapObject, - AbstractRoadEdge, - AbstractRoadLine, - AbstractSurfaceMapObject, - AbstractWalkway, - MapObjectIDType, -) -from py123d.datatypes.map.map_datatypes import MapLayer, RoadEdgeType, RoadLineType -from py123d.geometry import Polyline3D -from py123d.geometry.polyline import Polyline2D - - -class CacheSurfaceObject(AbstractSurfaceMapObject): - """ - Base interface representation of all map objects. - """ - - def __init__( - self, - object_id: MapObjectIDType, - outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, - ) -> None: - super().__init__(object_id) - - assert outline is not None or geometry is not None, "Either outline or geometry must be provided." - - if outline is None: - outline = Polyline3D.from_linestring(geometry.exterior) - - if geometry is None: - geometry = geom.Polygon(outline.array[:, :2]) - - self._outline = outline - self._geometry = geometry - - outline = property(lambda self: self._outline) - - @property - def shapely_polygon(self) -> geom.Polygon: - """Inherited, see superclass.""" - return self._geometry - - @property - def outline_3d(self) -> Polyline3D: - """Inherited, see superclass.""" - if isinstance(self.outline, Polyline3D): - return self.outline - # Converts 2D polyline to 3D by adding a default (zero) z-coordinate - return Polyline3D.from_linestring(self.outline.linestring) - - @property - def trimesh_mesh(self) -> trimesh.Trimesh: - """Inherited, see superclass.""" - raise NotImplementedError - - -class CacheLineObject(AbstractLineMapObject): - - def __init__(self, object_id: MapObjectIDType, polyline: Union[Polyline2D, Polyline3D]) -> None: - """ - Constructor of the base line map object type. - :param object_id: unique identifier of a line map object. - """ - super().__init__(object_id) - self._polyline = polyline - - polyline = property(lambda self: self._polyline) - - -class CacheLane(CacheSurfaceObject, AbstractLane): - - def __init__( - self, - object_id: MapObjectIDType, - lane_group_id: MapObjectIDType, - left_boundary: Polyline3D, - right_boundary: Polyline3D, - centerline: Polyline3D, - left_lane_id: Optional[MapObjectIDType] = None, - right_lane_id: Optional[MapObjectIDType] = None, - predecessor_ids: List[MapObjectIDType] = [], - successor_ids: List[MapObjectIDType] = [], - speed_limit_mps: Optional[float] = None, - outline: Optional[Polyline3D] = None, - geometry: Optional[geom.Polygon] = None, - ) -> None: - - if outline is None: - outline_array = np.vstack( - ( - left_boundary.array, - right_boundary.array[::-1], - left_boundary.array[0], - ) - ) - outline = Polyline3D.from_linestring(geom.LineString(outline_array)) - - super().__init__(object_id, outline, geometry) - - self._lane_group_id = lane_group_id - self._left_boundary = left_boundary - self._right_boundary = right_boundary - self._centerline = centerline - self._left_lane_id = left_lane_id - self._right_lane_id = right_lane_id - self._predecessor_ids = predecessor_ids - self._successor_ids = successor_ids - self._speed_limit_mps = speed_limit_mps - - lane_group_id = property(lambda self: self._lane_group_id) - left_boundary = property(lambda self: self._left_boundary) - right_boundary = property(lambda self: self._right_boundary) - centerline = property(lambda self: self._centerline) - left_lane_id = property(lambda self: self._left_lane_id) - right_lane_id = property(lambda self: self._right_lane_id) - predecessor_ids = property(lambda self: self._predecessor_ids) - successor_ids = property(lambda self: self._successor_ids) - speed_limit_mps = property(lambda self: self._speed_limit_mps) - - @property - def layer(self) -> MapLayer: - """Inherited, see superclass.""" - return MapLayer.LANE - - @property - def successors(self) -> List[AbstractLane]: - """Inherited, see superclass.""" - raise NotImplementedError - - @property - def predecessors(self) -> List[AbstractLane]: - """Inherited, see superclass.""" - raise NotImplementedError - - @property - def left_lane(self) -> Optional[AbstractLane]: - """Inherited, see superclass.""" - raise NotImplementedError - - @property - def right_lane(self) -> Optional[AbstractLane]: - """Inherited, see superclass.""" - raise NotImplementedError - - @property - def lane_group(self) -> AbstractLaneGroup: - """Inherited, see superclass.""" - raise NotImplementedError - - -class CacheLaneGroup(CacheSurfaceObject, AbstractLaneGroup): - def __init__( - self, - object_id: MapObjectIDType, - lane_ids: List[MapObjectIDType], - left_boundary: Polyline3D, - right_boundary: Polyline3D, - intersection_id: Optional[MapObjectIDType] = None, - predecessor_ids: List[MapObjectIDType] = [], - successor_ids: List[MapObjectIDType] = [], - outline: Optional[Polyline3D] = None, - geometry: Optional[geom.Polygon] = None, - ): - if outline is None: - outline_array = np.vstack( - ( - left_boundary.array, - right_boundary.array[::-1], - left_boundary.array[0], - ) - ) - outline = Polyline3D.from_linestring(geom.LineString(outline_array)) - super().__init__(object_id, outline, geometry) - - self._lane_ids = lane_ids - self._left_boundary = left_boundary - self._right_boundary = right_boundary - self._intersection_id = intersection_id - self._predecessor_ids = predecessor_ids - self._successor_ids = successor_ids - - layer = property(lambda self: MapLayer.LANE_GROUP) - lane_ids = property(lambda self: self._lane_ids) - intersection_id = property(lambda self: self._intersection_id) - predecessor_ids = property(lambda self: self._predecessor_ids) - successor_ids = property(lambda self: self._successor_ids) - left_boundary = property(lambda self: self._left_boundary) - right_boundary = property(lambda self: self._right_boundary) - - @property - def successors(self) -> List[AbstractLaneGroup]: - """Inherited, see superclass.""" - raise NotImplementedError - - @property - def predecessors(self) -> List[AbstractLaneGroup]: - """Inherited, see superclass.""" - raise NotImplementedError - - @property - def lanes(self) -> List[AbstractLane]: - """Inherited, see superclass.""" - raise NotImplementedError - - @property - def intersection(self) -> Optional[AbstractIntersection]: - """Inherited, see superclass.""" - raise NotImplementedError - - -class CacheIntersection(CacheSurfaceObject, AbstractIntersection): - def __init__( - self, - object_id: MapObjectIDType, - lane_group_ids: List[MapObjectIDType], - outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, - ): - - super().__init__(object_id, outline, geometry) - self._lane_group_ids = lane_group_ids - - layer = property(lambda self: MapLayer.INTERSECTION) - lane_group_ids = property(lambda self: self._lane_group_ids) - - @property - def lane_groups(self) -> List[CacheLaneGroup]: - """Inherited, see superclass.""" - raise NotImplementedError - - -class CacheCrosswalk(CacheSurfaceObject, AbstractCrosswalk): - def __init__( - self, - object_id: MapObjectIDType, - outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, - ): - super().__init__(object_id, outline, geometry) - - -class CacheCarpark(CacheSurfaceObject, AbstractCarpark): - def __init__( - self, - object_id: MapObjectIDType, - outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, - ): - super().__init__(object_id, outline, geometry) - - -class CacheWalkway(CacheSurfaceObject, AbstractWalkway): - def __init__( - self, - object_id: MapObjectIDType, - outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, - ): - super().__init__(object_id, outline, geometry) - - -class CacheGenericDrivable(CacheSurfaceObject, AbstractGenericDrivable): - def __init__( - self, - object_id: MapObjectIDType, - outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, - ): - super().__init__(object_id, outline, geometry) - - -class CacheRoadEdge(CacheLineObject, AbstractRoadEdge): - def __init__( - self, - object_id: MapObjectIDType, - road_edge_type: RoadEdgeType, - polyline: Union[Polyline2D, Polyline3D], - ): - super().__init__(object_id, polyline) - self._road_edge_type = road_edge_type - - road_edge_type = property(lambda self: self._road_edge_type) - - -class CacheRoadLine(CacheLineObject, AbstractRoadLine): - def __init__( - self, - object_id: MapObjectIDType, - road_line_type: RoadLineType, - polyline: Union[Polyline2D, Polyline3D], - ): - super().__init__(object_id, polyline) - self._road_line_type = road_line_type - - road_line_type = property(lambda self: self._road_line_type) diff --git a/src/py123d/datatypes/map/gpkg/gpkg_map_objects.py b/src/py123d/datatypes/map/gpkg/gpkg_map_objects.py deleted file mode 100644 index 33922e2b..00000000 --- a/src/py123d/datatypes/map/gpkg/gpkg_map_objects.py +++ /dev/null @@ -1,385 +0,0 @@ -from __future__ import annotations - -import ast -from functools import cached_property -from typing import List, Optional, Union - -import geopandas as gpd -import numpy as np -import pandas as pd -import shapely.geometry as geom -import trimesh - -from py123d.datatypes.map.abstract_map_objects import ( - AbstractCarpark, - AbstractCrosswalk, - AbstractGenericDrivable, - AbstractIntersection, - AbstractLane, - AbstractLaneGroup, - AbstractLineMapObject, - AbstractRoadEdge, - AbstractRoadLine, - AbstractSurfaceMapObject, - AbstractWalkway, - MapObjectIDType, -) -from py123d.datatypes.map.gpkg.gpkg_utils import get_row_with_value, get_trimesh_from_boundaries -from py123d.datatypes.map.map_datatypes import RoadEdgeType, RoadLineType -from py123d.geometry import Point3DIndex, Polyline3D -from py123d.geometry.polyline import Polyline2D - - -class GPKGSurfaceObject(AbstractSurfaceMapObject): - """ - Base interface representation of all map objects. - """ - - def __init__(self, object_id: MapObjectIDType, surface_df: gpd.GeoDataFrame) -> None: - """ - Constructor of the base surface map object type. - :param object_id: unique identifier of a surface map object. - """ - super().__init__(object_id) - # TODO: add assertion if columns are available - self._object_df = surface_df - - @property - def shapely_polygon(self) -> geom.Polygon: - """Inherited, see superclass.""" - return self._object_row.geometry - - @cached_property - def _object_row(self) -> gpd.GeoSeries: - return get_row_with_value(self._object_df, "id", self.object_id) - - @property - def outline(self) -> Polyline3D: - """Inherited, see superclass.""" - outline_3d: Optional[Polyline3D] = None - if "outline" in self._object_df.columns: - outline_3d = Polyline3D.from_linestring(self._object_row.outline) - else: - outline_3d = Polyline3D.from_linestring(geom.LineString(self.shapely_polygon.exterior.coords)) - return outline_3d - - @property - def trimesh_mesh(self) -> trimesh.Trimesh: - """Inherited, see superclass.""" - - trimesh_mesh: Optional[trimesh.Trimesh] = None - if "right_boundary" in self._object_df.columns and "left_boundary" in self._object_df.columns: - left_boundary = Polyline3D.from_linestring(self._object_row.left_boundary) - right_boundary = Polyline3D.from_linestring(self._object_row.right_boundary) - trimesh_mesh = get_trimesh_from_boundaries(left_boundary, right_boundary) - else: - # Fallback to geometry if no boundaries are available - outline_3d_array = self.outline_3d.array - vertices_2d, faces = trimesh.creation.triangulate_polygon( - geom.Polygon(outline_3d_array[:, Point3DIndex.XY]) - ) - if len(vertices_2d) == len(outline_3d_array): - # Regular case, where vertices match outline_3d_array - vertices_3d = outline_3d_array - elif len(vertices_2d) == len(outline_3d_array) + 1: - # outline array was not closed, so we need to add the first vertex again - vertices_3d = np.vstack((outline_3d_array, outline_3d_array[0])) - else: - raise ValueError("No vertices found for triangulation.") - trimesh_mesh = trimesh.Trimesh(vertices=vertices_3d, faces=faces) - return trimesh_mesh - - -class GPKGLineObject(AbstractLineMapObject): - - def __init__(self, object_id: MapObjectIDType, line_df: gpd.GeoDataFrame) -> None: - """ - Constructor of the base line map object type. - :param object_id: unique identifier of a line map object. - """ - super().__init__(object_id) - # TODO: add assertion if columns are available - self._object_df = line_df - - @cached_property - def _object_row(self) -> gpd.GeoSeries: - return get_row_with_value(self._object_df, "id", self.object_id) - - @property - def polyline(self) -> Union[Polyline2D, Polyline3D]: - """Inherited, see superclass.""" - return Polyline3D.from_linestring(self._object_row.geometry) - - -class GPKGLane(GPKGSurfaceObject, AbstractLane): - def __init__( - self, - object_id: MapObjectIDType, - object_df: gpd.GeoDataFrame, - lane_group_df: gpd.GeoDataFrame, - intersection_df: gpd.GeoDataFrame, - ) -> None: - super().__init__(object_id, object_df) - self._lane_group_df = lane_group_df - self._intersection_df = intersection_df - - @property - def speed_limit_mps(self) -> Optional[float]: - """Inherited, see superclass.""" - return self._object_row.speed_limit_mps - - @property - def successor_ids(self) -> List[MapObjectIDType]: - """Inherited, see superclass.""" - return ast.literal_eval(self._object_row.successor_ids) - - @property - def successors(self) -> List[GPKGLane]: - """Inherited, see superclass.""" - return [GPKGLane(lane_id, self._object_df) for lane_id in self.successor_ids] - - @property - def predecessor_ids(self) -> List[MapObjectIDType]: - """Inherited, see superclass.""" - return ast.literal_eval(self._object_row.predecessor_ids) - - @property - def predecessors(self) -> List[GPKGLane]: - """Inherited, see superclass.""" - return [GPKGLane(lane_id, self._object_df) for lane_id in self.predecessor_ids] - - @property - def left_boundary(self) -> Polyline3D: - """Inherited, see superclass.""" - return Polyline3D.from_linestring(self._object_row.left_boundary) - - @property - def right_boundary(self) -> Polyline3D: - """Inherited, see superclass.""" - return Polyline3D.from_linestring(self._object_row.right_boundary) - - @property - def left_lane_id(self) -> Optional[MapObjectIDType]: - """ "Inherited, see superclass.""" - return self._object_row.left_lane_id - - @property - def left_lane(self) -> Optional[GPKGLane]: - """Inherited, see superclass.""" - return ( - GPKGLane(self.left_lane_id, self._object_df, self._lane_group_df, self._intersection_df) - if self.left_lane_id is not None and not pd.isna(self.left_lane_id) - else None - ) - - @property - def right_lane_id(self) -> Optional[MapObjectIDType]: - """Inherited, see superclass.""" - return self._object_row.right_lane_id - - @property - def right_lane(self) -> Optional[GPKGLane]: - """Inherited, see superclass.""" - return ( - GPKGLane(self.right_lane_id, self._object_df, self._lane_group_df, self._intersection_df) - if self.right_lane_id is not None and not pd.isna(self.right_lane_id) - else None - ) - - @property - def centerline(self) -> Polyline3D: - """Inherited, see superclass.""" - return Polyline3D.from_linestring(self._object_row.centerline) - - @property - def outline_3d(self) -> Polyline3D: - """Inherited, see superclass.""" - outline_array = np.vstack((self.left_boundary.array, self.right_boundary.array[::-1])) - outline_array = np.vstack((outline_array, outline_array[0])) - return Polyline3D.from_linestring(geom.LineString(outline_array)) - - @property - def lane_group_id(self) -> MapObjectIDType: - """Inherited, see superclass.""" - return self._object_row.lane_group_id - - @property - def lane_group(self) -> GPKGLaneGroup: - """Inherited, see superclass.""" - return GPKGLaneGroup( - self.lane_group_id, - self._lane_group_df, - self._object_df, - self._intersection_df, - ) - - -class GPKGLaneGroup(GPKGSurfaceObject, AbstractLaneGroup): - def __init__( - self, - object_id: MapObjectIDType, - object_df: gpd.GeoDataFrame, - lane_df: gpd.GeoDataFrame, - intersection_df: gpd.GeoDataFrame, - ): - super().__init__(object_id, object_df) - self._lane_df = lane_df - self._intersection_df = intersection_df - - @property - def successor_ids(self) -> List[MapObjectIDType]: - """Inherited, see superclass.""" - return ast.literal_eval(self._object_row.successor_ids) - - @property - def successors(self) -> List[GPKGLaneGroup]: - """Inherited, see superclass.""" - return [ - GPKGLaneGroup(lane_group_id, self._object_df, self._lane_df, self._intersection_df) - for lane_group_id in self.successor_ids - ] - - @property - def predecessor_ids(self) -> List[MapObjectIDType]: - """Inherited, see superclass.""" - return ast.literal_eval(self._object_row.predecessor_ids) - - @property - def predecessors(self) -> List[GPKGLaneGroup]: - """Inherited, see superclass.""" - return [ - GPKGLaneGroup(lane_group_id, self._object_df, self._lane_df, self._intersection_df) - for lane_group_id in self.predecessor_ids - ] - - @property - def left_boundary(self) -> Polyline3D: - """Inherited, see superclass.""" - return Polyline3D.from_linestring(self._object_row.left_boundary) - - @property - def right_boundary(self) -> Polyline3D: - """Inherited, see superclass.""" - return Polyline3D.from_linestring(self._object_row.right_boundary) - - @property - def outline_3d(self) -> Polyline3D: - """Inherited, see superclass.""" - outline_array = np.vstack((self.left_boundary.array, self.right_boundary.array[::-1])) - return Polyline3D.from_linestring(geom.LineString(outline_array)) - - @property - def lane_ids(self) -> List[MapObjectIDType]: - """Inherited, see superclass.""" - return ast.literal_eval(self._object_row.lane_ids) - - @property - def lanes(self) -> List[GPKGLane]: - """Inherited, see superclass.""" - return [ - GPKGLane( - lane_id, - self._lane_df, - self._object_df, - self._intersection_df, - ) - for lane_id in self.lane_ids - ] - - @property - def intersection_id(self) -> Optional[MapObjectIDType]: - """Inherited, see superclass.""" - return self._object_row.intersection_id - - @property - def intersection(self) -> Optional[GPKGIntersection]: - """Inherited, see superclass.""" - return ( - GPKGIntersection( - self.intersection_id, - self._intersection_df, - self._lane_df, - self._object_df, - ) - if self.intersection_id is not None and not pd.isna(self.intersection_id) - else None - ) - - -class GPKGIntersection(GPKGSurfaceObject, AbstractIntersection): - def __init__( - self, - object_id: MapObjectIDType, - object_df: gpd.GeoDataFrame, - lane_df: gpd.GeoDataFrame, - lane_group_df: gpd.GeoDataFrame, - ): - super().__init__(object_id, object_df) - self._lane_df = lane_df - self._lane_group_df = lane_group_df - - @property - def lane_group_ids(self) -> List[MapObjectIDType]: - """Inherited, see superclass.""" - return ast.literal_eval(self._object_row.lane_group_ids) - - @property - def lane_groups(self) -> List[GPKGLaneGroup]: - """Inherited, see superclass.""" - return [ - GPKGLaneGroup( - lane_group_id, - self._lane_group_df, - self._lane_df, - self._object_df, - ) - for lane_group_id in self.lane_group_ids - ] - - -class GPKGCrosswalk(GPKGSurfaceObject, AbstractCrosswalk): - def __init__(self, object_id: MapObjectIDType, object_df: gpd.GeoDataFrame): - super().__init__(object_id, object_df) - - -class GPKGCarpark(GPKGSurfaceObject, AbstractCarpark): - def __init__(self, object_id: MapObjectIDType, object_df: gpd.GeoDataFrame): - super().__init__(object_id, object_df) - - -class GPKGWalkway(GPKGSurfaceObject, AbstractWalkway): - def __init__(self, object_id: MapObjectIDType, object_df: gpd.GeoDataFrame): - super().__init__(object_id, object_df) - - -class GPKGGenericDrivable(GPKGSurfaceObject, AbstractGenericDrivable): - def __init__(self, object_id: MapObjectIDType, object_df: gpd.GeoDataFrame): - super().__init__(object_id, object_df) - - -class GPKGRoadEdge(GPKGLineObject, AbstractRoadEdge): - def __init__(self, object_id: MapObjectIDType, object_df: gpd.GeoDataFrame): - super().__init__(object_id, object_df) - - @cached_property - def _object_row(self) -> gpd.GeoSeries: - return get_row_with_value(self._object_df, "id", self.object_id) - - @property - def road_edge_type(self) -> RoadEdgeType: - """Inherited, see superclass.""" - return RoadEdgeType(int(self._object_row.road_edge_type)) - - -class GPKGRoadLine(GPKGLineObject, AbstractRoadLine): - def __init__(self, object_id: MapObjectIDType, object_df: gpd.GeoDataFrame): - super().__init__(object_id, object_df) - - @cached_property - def _object_row(self) -> gpd.GeoSeries: - return get_row_with_value(self._object_df, "id", self.object_id) - - @property - def road_line_type(self) -> RoadLineType: - """Inherited, see superclass.""" - return RoadLineType(int(self._object_row.road_line_type)) diff --git a/src/py123d/datatypes/map/gpkg/gpkg_utils.py b/src/py123d/datatypes/map/gpkg/gpkg_utils.py deleted file mode 100644 index 54dd93e6..00000000 --- a/src/py123d/datatypes/map/gpkg/gpkg_utils.py +++ /dev/null @@ -1,90 +0,0 @@ -from typing import List - -import geopandas as gpd -import numpy as np -import numpy.typing as npt -import trimesh -from shapely import wkt - -from py123d.geometry.polyline import Polyline3D - - -def load_gdf_with_geometry_columns(gdf: gpd.GeoDataFrame, geometry_column_names: List[str] = []): - # TODO: refactor - # Convert string geometry columns back to shapely objects - for col in geometry_column_names: - if col in gdf.columns and len(gdf) > 0 and isinstance(gdf[col].iloc[0], str): - try: - gdf[col] = gdf[col].apply(lambda x: wkt.loads(x) if isinstance(x, str) else x) - except Exception as e: - print(f"Warning: Could not convert column {col} to geometry: {str(e)}") - - -def get_all_rows_with_value( - elements: gpd.geodataframe.GeoDataFrame, column_label: str, desired_value: str -) -> gpd.geodataframe.GeoDataFrame: - """ - Extract all matching elements. Note, if no matching desired_key is found and empty list is returned. - :param elements: data frame from MapsDb. - :param column_label: key to extract from a column. - :param desired_value: key which is compared with the values of column_label entry. - :return: a subset of the original GeoDataFrame containing the matching key. - """ - return elements.iloc[np.where(elements[column_label].to_numpy().astype(int) == int(desired_value))] - - -def get_row_with_value(elements: gpd.geodataframe.GeoDataFrame, column_label: str, desired_value: str) -> gpd.GeoSeries: - """ - Extract a matching element. - :param elements: data frame from MapsDb. - :param column_label: key to extract from a column. - :param desired_value: key which is compared with the values of column_label entry. - :return row from GeoDataFrame. - """ - if column_label == "fid": - return elements.loc[desired_value] - - matching_rows = get_all_rows_with_value(elements, column_label, desired_value) - assert len(matching_rows) > 0, f"Could not find the desired key = {desired_value}" - assert len(matching_rows) == 1, ( - f"{len(matching_rows)} matching keys found. Expected to only find one." "Try using get_all_rows_with_value" - ) - return matching_rows.iloc[0] - - -def get_trimesh_from_boundaries( - left_boundary: Polyline3D, right_boundary: Polyline3D, resolution: float = 0.25 -) -> trimesh.Trimesh: - - def _interpolate_polyline(polyline_3d: Polyline3D, num_samples: int) -> npt.NDArray[np.float64]: - if num_samples < 2: - num_samples = 2 - distances = np.linspace(0, polyline_3d.length, num=num_samples, endpoint=True, dtype=np.float64) - return polyline_3d.interpolate(distances) - - average_length = (left_boundary.length + right_boundary.length) / 2 - num_samples = int(average_length // resolution) + 1 - left_boundary_array = _interpolate_polyline(left_boundary, num_samples) - right_boundary_array = _interpolate_polyline(right_boundary, num_samples) - return _create_lane_mesh_from_boundary_arrays(left_boundary_array, right_boundary_array) - - -def _create_lane_mesh_from_boundary_arrays( - left_boundary_array: npt.NDArray[np.float64], right_boundary_array: npt.NDArray[np.float64] -) -> trimesh.Trimesh: - - # Ensure both polylines have the same number of points - if left_boundary_array.shape[0] != right_boundary_array.shape[0]: - raise ValueError("Both polylines must have the same number of points") - - n_points = left_boundary_array.shape[0] - vertices = np.vstack([left_boundary_array, right_boundary_array]) - - faces = [] - for i in range(n_points - 1): - faces.append([i, i + n_points, i + 1]) - faces.append([i + 1, i + n_points, i + n_points + 1]) - - faces = np.array(faces) - mesh = trimesh.Trimesh(vertices=vertices, faces=faces) - return mesh diff --git a/src/py123d/datatypes/map_objects/__init__.py b/src/py123d/datatypes/map_objects/__init__.py new file mode 100644 index 00000000..7a944629 --- /dev/null +++ b/src/py123d/datatypes/map_objects/__init__.py @@ -0,0 +1,14 @@ +from py123d.datatypes.map_objects.base_map_objects import BaseMapLineObject, BaseMapObject, BaseMapSurfaceObject +from py123d.datatypes.map_objects.map_layer_types import LaneType, MapLayer, RoadEdgeType, RoadLineType +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + Crosswalk, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, + StopZone, + Walkway, +) diff --git a/src/py123d/datatypes/map_objects/base_map_objects.py b/src/py123d/datatypes/map_objects/base_map_objects.py new file mode 100644 index 00000000..90c2844d --- /dev/null +++ b/src/py123d/datatypes/map_objects/base_map_objects.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import abc +from typing import Optional, TypeAlias, Union + +import numpy as np +import shapely.geometry as geom +import trimesh + +from py123d.datatypes.map_objects.map_layer_types import MapLayer +from py123d.geometry import Point3DIndex, Polyline2D, Polyline3D + +MapObjectIDType: TypeAlias = Union[str, int] + + +class BaseMapObject(abc.ABC): + + __slots__ = ("_object_id",) + + def __init__(self, object_id: MapObjectIDType): + """Constructor of the base map object type. + + :param object_id: unique identifier of the map object. + """ + self._object_id: MapObjectIDType = object_id + + @property + def object_id(self) -> MapObjectIDType: + """Returns the unique identifier of the map object. + + :return: map object id + """ + return self._object_id + + @property + @abc.abstractmethod + def layer(self) -> MapLayer: + """Returns the map layer type. + + :return: map layer type + """ + + +class BaseMapSurfaceObject(BaseMapObject): + """ + Base interface representation of all map objects. + """ + + __slots__ = ("_outline", "_geometry") + + def __init__( + self, + object_id: MapObjectIDType, + outline: Optional[Union[Polyline2D, Polyline3D]] = None, + geometry: Optional[geom.Polygon] = None, + ) -> None: + super().__init__(object_id) + + assert outline is not None or geometry is not None, "Either outline or geometry must be provided." + + if outline is None: + outline = Polyline3D.from_linestring(geometry.exterior) + + if geometry is None: + geometry = geom.Polygon(outline.array[:, :2]) + + self._object_id = object_id + self._outline = outline + self._geometry = geometry + + @property + def outline(self) -> Union[Polyline2D, Polyline3D]: + return self._outline + + @property + def outline_2d(self) -> Polyline2D: + if isinstance(self.outline, Polyline2D): + return self._outline + # Converts 3D polyline to 2D by dropping the z-coordinate + return Polyline2D.from_linestring(self._outline.linestring) + + @property + def outline_3d(self) -> Polyline3D: + if isinstance(self._outline, Polyline3D): + return self._outline + # Converts 2D polyline to 3D by adding a default (zero) z-coordinate + return Polyline3D.from_linestring(self._outline.linestring) + + @property + def shapely_polygon(self) -> geom.Polygon: + return self._geometry + + @property + def trimesh_mesh(self) -> trimesh.Trimesh: + # Fallback to geometry if no boundaries are available + outline_3d_array = self.outline_3d.array + vertices_2d, faces = trimesh.creation.triangulate_polygon(geom.Polygon(outline_3d_array[:, Point3DIndex.XY])) + if len(vertices_2d) == len(outline_3d_array): + # Regular case, where vertices match outline_3d_array + vertices_3d = outline_3d_array + elif len(vertices_2d) == len(outline_3d_array) + 1: + # outline array was not closed, so we need to add the first vertex again + vertices_3d = np.vstack((outline_3d_array, outline_3d_array[0])) + else: + raise ValueError("No vertices found for triangulation.") + trimesh_mesh = trimesh.Trimesh(vertices=vertices_3d, faces=faces) + return trimesh_mesh + + +class BaseMapLineObject(BaseMapObject): + + __slots__ = ("_polyline",) + + def __init__(self, object_id: MapObjectIDType, polyline: Union[Polyline2D, Polyline3D]) -> None: + super().__init__(object_id) + self._polyline = polyline + + @property + def polyline(self) -> Union[Polyline2D, Polyline3D]: + return self._polyline + + @property + def polyline_2d(self) -> Polyline2D: + if isinstance(self._polyline, Polyline2D): + return self._polyline + # Converts 3D polyline to 2D by dropping the z-coordinate + return Polyline2D.from_linestring(self._polyline.linestring) + + @property + def polyline_3d(self) -> Polyline3D: + if isinstance(self._polyline, Polyline3D): + return self._polyline + # Converts 2D polyline to 3D by adding a default (zero) z-coordinate + return Polyline3D.from_linestring(self._polyline.linestring) + + @property + def shapely_linestring(self) -> geom.LineString: + return self._polyline.linestring diff --git a/src/py123d/datatypes/map/map_datatypes.py b/src/py123d/datatypes/map_objects/map_layer_types.py similarity index 99% rename from src/py123d/datatypes/map/map_datatypes.py rename to src/py123d/datatypes/map_objects/map_layer_types.py index dc9e4820..341dd042 100644 --- a/src/py123d/datatypes/map/map_datatypes.py +++ b/src/py123d/datatypes/map_objects/map_layer_types.py @@ -21,7 +21,7 @@ class MapLayer(SerialIntEnum): WALKWAY = 4 CARPARK = 5 GENERIC_DRIVABLE = 6 - STOP_LINE = 7 + STOP_ZONE = 7 ROAD_EDGE = 8 ROAD_LINE = 9 diff --git a/src/py123d/datatypes/map_objects/map_objects.py b/src/py123d/datatypes/map_objects/map_objects.py new file mode 100644 index 00000000..2f33b17c --- /dev/null +++ b/src/py123d/datatypes/map_objects/map_objects.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional, Union + +import numpy as np +import shapely.geometry as geom +from trimesh import Trimesh + +from py123d.datatypes.map_objects.base_map_objects import BaseMapLineObject, BaseMapSurfaceObject, MapObjectIDType +from py123d.datatypes.map_objects.map_layer_types import MapLayer +from py123d.datatypes.map_objects.utils import get_trimesh_from_boundaries +from py123d.geometry import Polyline2D, Polyline3D + +if TYPE_CHECKING: + from py123d.api.map.map_api import MapAPI + + +class Lane(BaseMapSurfaceObject): + + __slots__ = ( + "_lane_group_id", + "_left_boundary", + "_right_boundary", + "_centerline", + "_left_lane_id", + "_right_lane_id", + "_predecessor_ids", + "_successor_ids", + "_speed_limit_mps", + "_map_api", + ) + + def __init__( + self, + object_id: MapObjectIDType, + lane_group_id: MapObjectIDType, + left_boundary: Polyline3D, + right_boundary: Polyline3D, + centerline: Polyline3D, + left_lane_id: Optional[MapObjectIDType] = None, + right_lane_id: Optional[MapObjectIDType] = None, + predecessor_ids: List[MapObjectIDType] = [], + successor_ids: List[MapObjectIDType] = [], + speed_limit_mps: Optional[float] = None, + outline: Optional[Polyline3D] = None, + geometry: Optional[geom.Polygon] = None, + map_api: Optional["MapAPI"] = None, + ) -> None: + + if outline is None: + outline_array = np.vstack( + ( + left_boundary.array, + right_boundary.array[::-1], + left_boundary.array[0], + ) + ) + outline = Polyline3D.from_array(outline_array) + + super().__init__(object_id, outline, geometry) + + self._lane_group_id = lane_group_id + self._left_boundary = left_boundary + self._right_boundary = right_boundary + self._centerline = centerline + self._left_lane_id = left_lane_id + self._right_lane_id = right_lane_id + self._predecessor_ids = predecessor_ids + self._successor_ids = successor_ids + self._speed_limit_mps = speed_limit_mps + self._map_api = map_api + + @property + def layer(self) -> MapLayer: + return MapLayer.LANE + + @property + def lane_group_id(self) -> MapObjectIDType: + return self._lane_group_id + + @property + def lane_group(self) -> Optional[LaneGroup]: + if self._map_api is not None: + return self._map_api.get_map_object(self.lane_group_id, MapLayer.LANE_GROUP) + return None + + @property + def left_boundary(self) -> Polyline3D: + return self._left_boundary + + @property + def right_boundary(self) -> Polyline3D: + return self._right_boundary + + @property + def centerline(self) -> Polyline3D: + return self._centerline + + @property + def left_lane_id(self) -> Optional[MapObjectIDType]: + return self._left_lane_id + + @property + def left_lane(self) -> Optional[Lane]: + if self._map_api is not None and self.left_lane_id is not None: + return self._map_api.get_map_object(self.left_lane_id, self.layer) + return None + + @property + def right_lane_id(self) -> Optional[MapObjectIDType]: + return self._right_lane_id + + @property + def right_lane(self) -> Optional[Lane]: + if self._map_api is not None and self.right_lane_id is not None: + return self._map_api.get_map_object(self.right_lane_id, self.layer) + return None + + @property + def predecessor_ids(self) -> List[MapObjectIDType]: + return self._predecessor_ids + + @property + def predecessors(self) -> List[Lane]: + predecessors: Optional[List[Lane]] = None + if self._map_api is not None: + predecessors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.predecessor_ids] + return predecessors + + @property + def successor_ids(self) -> List[MapObjectIDType]: + return self._successor_ids + + @property + def successors(self) -> List[Lane]: + successors: Optional[List[Lane]] = None + if self._map_api is not None: + successors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.successor_ids] + return successors + + @property + def speed_limit_mps(self) -> Optional[float]: + return self._speed_limit_mps + + @property + def trimesh(self) -> Trimesh: + return get_trimesh_from_boundaries(self.left_boundary, self.right_boundary) + + +class LaneGroup(BaseMapSurfaceObject): + + __slots__ = ( + "_lane_ids", + "_left_boundary", + "_right_boundary", + "_intersection_id", + "_predecessor_ids", + "_successor_ids", + "_map_api", + ) + + def __init__( + self, + object_id: MapObjectIDType, + lane_ids: List[MapObjectIDType], + left_boundary: Polyline3D, + right_boundary: Polyline3D, + intersection_id: Optional[MapObjectIDType] = None, + predecessor_ids: List[MapObjectIDType] = [], + successor_ids: List[MapObjectIDType] = [], + outline: Optional[Polyline3D] = None, + geometry: Optional[geom.Polygon] = None, + map_api: Optional["MapAPI"] = None, + ): + if outline is None: + outline_array = np.vstack( + ( + left_boundary.array, + right_boundary.array[::-1], + left_boundary.array[0], + ) + ) + outline = Polyline3D.from_array(outline_array) + super().__init__(object_id, outline, geometry) + + self._lane_ids = lane_ids + self._left_boundary = left_boundary + self._right_boundary = right_boundary + self._intersection_id = intersection_id + self._predecessor_ids = predecessor_ids + self._successor_ids = successor_ids + self._map_api = map_api + + @property + def layer(self) -> MapLayer: + return MapLayer.LANE_GROUP + + @property + def lane_ids(self) -> List[MapObjectIDType]: + return self._lane_ids + + @property + def lanes(self) -> List[Lane]: + lanes: Optional[List[Lane]] = None + if self._map_api is not None: + lanes = [self._map_api.get_map_object(lane_id, MapLayer.LANE) for lane_id in self.lane_ids] + return lanes + + @property + def left_boundary(self) -> Polyline3D: + return self._left_boundary + + @property + def right_boundary(self) -> Polyline3D: + return self._right_boundary + + @property + def intersection_id(self) -> Optional[MapObjectIDType]: + return self._intersection_id + + @property + def intersection(self) -> Optional[Intersection]: + if self._map_api is not None and self.intersection_id is not None: + return self._map_api.get_map_object(self.intersection_id, MapLayer.INTERSECTION) + return None + + @property + def predecessor_ids(self) -> List[MapObjectIDType]: + return self._predecessor_ids + + @property + def predecessors(self) -> List[LaneGroup]: + predecessors: Optional[List[LaneGroup]] = None + if self._map_api is not None: + predecessors = [ + self._map_api.get_map_object(lane_group_id, self.layer) for lane_group_id in self.predecessor_ids + ] + return predecessors + + @property + def successor_ids(self) -> List[MapObjectIDType]: + return self._successor_ids + + @property + def successors(self) -> List[LaneGroup]: + successors: Optional[List[LaneGroup]] = None + if self._map_api is not None: + successors = [ + self._map_api.get_map_object(lane_group_id, self.layer) for lane_group_id in self.successor_ids + ] + return successors + + @property + def trimesh(self) -> Trimesh: + return get_trimesh_from_boundaries(self.left_boundary, self.right_boundary) + + +class Intersection(BaseMapSurfaceObject): + + __slots__ = ( + "_lane_group_ids", + "_map_api", + ) + + def __init__( + self, + object_id: MapObjectIDType, + lane_group_ids: List[MapObjectIDType], + outline: Optional[Union[Polyline2D, Polyline3D]] = None, + geometry: Optional[geom.Polygon] = None, + map_api: Optional["MapAPI"] = None, + ): + super().__init__(object_id, outline, geometry) + self._lane_group_ids = lane_group_ids + self._map_api = map_api + + @property + def layer(self) -> MapLayer: + return MapLayer.INTERSECTION + + @property + def lane_group_ids(self) -> List[MapObjectIDType]: + return self._lane_group_ids + + @property + def lane_groups(self) -> List[LaneGroup]: + lane_groups: Optional[List[LaneGroup]] = None + if self._map_api is not None: + lane_groups = [ + self._map_api.get_map_object(lane_group_id, MapLayer.LANE_GROUP) + for lane_group_id in self.lane_group_ids + ] + return lane_groups + + +class Crosswalk(BaseMapSurfaceObject): + + __slots__ = () + + def __init__( + self, + object_id: MapObjectIDType, + outline: Optional[Union[Polyline2D, Polyline3D]] = None, + geometry: Optional[geom.Polygon] = None, + ): + super().__init__(object_id, outline, geometry) + + @property + def layer(self) -> MapLayer: + return MapLayer.CROSSWALK + + +class Carpark(BaseMapSurfaceObject): + + __slots__ = () + + def __init__( + self, + object_id: MapObjectIDType, + outline: Optional[Union[Polyline2D, Polyline3D]] = None, + geometry: Optional[geom.Polygon] = None, + ): + super().__init__(object_id, outline, geometry) + + @property + def layer(self) -> MapLayer: + return MapLayer.CARPARK + + +class Walkway(BaseMapSurfaceObject): + + __slots__ = () + + def __init__( + self, + object_id: MapObjectIDType, + outline: Optional[Union[Polyline2D, Polyline3D]] = None, + geometry: Optional[geom.Polygon] = None, + ): + super().__init__(object_id, outline, geometry) + + @property + def layer(self) -> MapLayer: + return MapLayer.WALKWAY + + +class GenericDrivable(BaseMapSurfaceObject): + + __slots__ = () + + def __init__( + self, + object_id: MapObjectIDType, + outline: Optional[Union[Polyline2D, Polyline3D]] = None, + geometry: Optional[geom.Polygon] = None, + ): + super().__init__(object_id, outline, geometry) + + @property + def layer(self) -> MapLayer: + return MapLayer.GENERIC_DRIVABLE + + +class StopZone(BaseMapSurfaceObject): + + __slots__ = () + + def __init__( + self, + object_id: MapObjectIDType, + outline: Optional[Union[Polyline2D, Polyline3D]] = None, + geometry: Optional[geom.Polygon] = None, + ): + super().__init__(object_id, outline, geometry) + + @property + def layer(self) -> MapLayer: + return MapLayer.STOP_ZONE + + +class RoadEdge(BaseMapLineObject): + + __slots__ = ("_road_edge_type",) + + def __init__( + self, + object_id: MapObjectIDType, + road_edge_type: int, + polyline: Union[Polyline2D, Polyline3D], + ): + super().__init__(object_id, polyline) + self._road_edge_type = road_edge_type + + @property + def layer(self) -> MapLayer: + return MapLayer.ROAD_EDGE + + @property + def road_edge_type(self) -> int: + return self._road_edge_type + + +class RoadLine(BaseMapLineObject): + + __slots__ = ("_road_line_type",) + + def __init__( + self, + object_id: MapObjectIDType, + road_line_type: int, + polyline: Union[Polyline2D, Polyline3D], + ): + super().__init__(object_id, polyline) + self._road_line_type = road_line_type + + @property + def layer(self) -> MapLayer: + return MapLayer.ROAD_LINE + + @property + def road_line_type(self) -> int: + return self._road_line_type diff --git a/src/py123d/datatypes/map_objects/utils.py b/src/py123d/datatypes/map_objects/utils.py new file mode 100644 index 00000000..fa3b560c --- /dev/null +++ b/src/py123d/datatypes/map_objects/utils.py @@ -0,0 +1,43 @@ +import numpy as np +import numpy.typing as npt +import trimesh + +from py123d.geometry import Polyline3D + + +def get_trimesh_from_boundaries( + left_boundary: Polyline3D, right_boundary: Polyline3D, resolution: float = 0.25 +) -> trimesh.Trimesh: + + def _interpolate_polyline(polyline_3d: Polyline3D, num_samples: int) -> npt.NDArray[np.float64]: + if num_samples < 2: + num_samples = 2 + distances = np.linspace(0, polyline_3d.length, num=num_samples, endpoint=True, dtype=np.float64) + return polyline_3d.interpolate(distances) + + average_length = (left_boundary.length + right_boundary.length) / 2 + num_samples = int(average_length // resolution) + 1 + left_boundary_array = _interpolate_polyline(left_boundary, num_samples) + right_boundary_array = _interpolate_polyline(right_boundary, num_samples) + return _create_lane_mesh_from_boundary_arrays(left_boundary_array, right_boundary_array) + + +def _create_lane_mesh_from_boundary_arrays( + left_boundary_array: npt.NDArray[np.float64], right_boundary_array: npt.NDArray[np.float64] +) -> trimesh.Trimesh: + + # Ensure both polylines have the same number of points + if left_boundary_array.shape[0] != right_boundary_array.shape[0]: + raise ValueError("Both polylines must have the same number of points") + + n_points = left_boundary_array.shape[0] + vertices = np.vstack([left_boundary_array, right_boundary_array]) + + faces = [] + for i in range(n_points - 1): + faces.append([i, i + n_points, i + 1]) + faces.append([i + 1, i + n_points, i + n_points + 1]) + + faces = np.array(faces) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces) + return mesh diff --git a/src/py123d/datatypes/metadata/__init__.py b/src/py123d/datatypes/metadata/__init__.py new file mode 100644 index 00000000..89685f72 --- /dev/null +++ b/src/py123d/datatypes/metadata/__init__.py @@ -0,0 +1,2 @@ +from py123d.datatypes.metadata.map_metadata import MapMetadata +from py123d.datatypes.metadata.log_metadata import LogMetadata diff --git a/src/py123d/datatypes/scene/scene_metadata.py b/src/py123d/datatypes/metadata/log_metadata.py similarity index 87% rename from src/py123d/datatypes/scene/scene_metadata.py rename to src/py123d/datatypes/metadata/log_metadata.py index 9bd6b218..782e539a 100644 --- a/src/py123d/datatypes/scene/scene_metadata.py +++ b/src/py123d/datatypes/metadata/log_metadata.py @@ -5,7 +5,7 @@ import py123d from py123d.conversion.registry.box_detection_label_registry import BOX_DETECTION_LABEL_REGISTRY, BoxDetectionLabel -from py123d.datatypes.map.map_metadata import MapMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICameraMetadata, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType @@ -87,25 +87,3 @@ def to_dict(self) -> Dict: data_dict["lidar_metadata"] = {key.serialize(): value.to_dict() for key, value in self.lidar_metadata.items()} data_dict["map_metadata"] = self.map_metadata.to_dict() if self.map_metadata else None return data_dict - - -@dataclass(frozen=True) -class SceneExtractionMetadata: - - initial_uuid: str - initial_idx: int - duration_s: float - history_s: float - iteration_duration_s: float - - @property - def number_of_iterations(self) -> int: - return round(self.duration_s / self.iteration_duration_s) - - @property - def number_of_history_iterations(self) -> int: - return round(self.history_s / self.iteration_duration_s) - - @property - def end_idx(self) -> int: - return self.initial_idx + self.number_of_iterations diff --git a/src/py123d/datatypes/map/map_metadata.py b/src/py123d/datatypes/metadata/map_metadata.py similarity index 90% rename from src/py123d/datatypes/map/map_metadata.py rename to src/py123d/datatypes/metadata/map_metadata.py index 14fd13c8..e7de01d5 100644 --- a/src/py123d/datatypes/map/map_metadata.py +++ b/src/py123d/datatypes/metadata/map_metadata.py @@ -5,8 +5,6 @@ import py123d -# TODO: Refactor the usage of the map map metadata in this repo. - @dataclass class MapMetadata: diff --git a/src/py123d/datatypes/scene/__init__.py b/src/py123d/datatypes/scene/__init__.py deleted file mode 100644 index 7fc23ffc..00000000 --- a/src/py123d/datatypes/scene/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from py123d.datatypes.scene.abstract_scene import AbstractScene -from py123d.datatypes.scene.abstract_scene_builder import SceneBuilder -from py123d.datatypes.scene.scene_filter import SceneFilter -from py123d.datatypes.scene.scene_metadata import LogMetadata diff --git a/src/py123d/datatypes/scene/arrow/utils/__init__.py b/src/py123d/datatypes/scene/arrow/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/py123d/datatypes/sensors/__init__.py b/src/py123d/datatypes/sensors/__init__.py index 54cd70a1..6ce7fd0c 100644 --- a/src/py123d/datatypes/sensors/__init__.py +++ b/src/py123d/datatypes/sensors/__init__.py @@ -11,8 +11,9 @@ FisheyeMEICameraType, FisheyeMEICamera, FisheyeMEIDistortionIndex, - FisheyeMEIProjectionIndex, + FisheyeMEIDistortion, FisheyeMEIProjection, + FisheyeMEIProjectionIndex, FisheyeMEICameraMetadata, ) from py123d.datatypes.sensors.lidar import ( diff --git a/src/py123d/datatypes/vehicle_state/__init__.py b/src/py123d/datatypes/vehicle_state/__init__.py index e69de29b..64b7a273 100644 --- a/src/py123d/datatypes/vehicle_state/__init__.py +++ b/src/py123d/datatypes/vehicle_state/__init__.py @@ -0,0 +1,8 @@ +from py123d.datatypes.vehicle_state.dynamic_state import ( + DynamicStateSE2, + DynamicStateSE2Index, + DynamicStateSE3, + DynamicStateSE3Index, +) +from py123d.datatypes.vehicle_state.ego_state import EgoStateSE2, EgoStateSE3 +from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters diff --git a/src/py123d/datatypes/vehicle_state/dynamic_state.py b/src/py123d/datatypes/vehicle_state/dynamic_state.py index ccdec58b..b0d88fbb 100644 --- a/src/py123d/datatypes/vehicle_state/dynamic_state.py +++ b/src/py123d/datatypes/vehicle_state/dynamic_state.py @@ -61,16 +61,17 @@ def __init__( self._array = array @classmethod - def from_array(cls, array: npt.NDArray[np.float64]) -> DynamicStateSE3: + def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> DynamicStateSE3: """ Create a DynamicVehicleState from an array. :param array: The array containing the dynamic state information. + :param copy: Whether to copy the array data. :return: A DynamicVehicleState instance. """ assert array.ndim == 1 assert array.shape[0] == len(DynamicStateSE3Index) instance = object.__new__(cls) - instance._array = array + object.__setattr__(instance, "_array", array.copy() if copy else array) return instance @property diff --git a/src/py123d/geometry/polyline.py b/src/py123d/geometry/polyline.py index 6474c7a4..6e369be5 100644 --- a/src/py123d/geometry/polyline.py +++ b/src/py123d/geometry/polyline.py @@ -283,7 +283,11 @@ def from_array(cls, array: npt.NDArray[np.float64]) -> Polyline3D: :class:`~py123d.geometry.Point3DIndex`. :return: A Polyline3D instance. """ - assert array.ndim == 2 and array.shape[1] == len(Point3DIndex), "Array must be 3D with shape (N, 3)" + assert array.ndim == 2, "Array must be 2D with shape (N, 3) or (N, 2)." + if array.shape[1] == len(Point2DIndex): + array = np.hstack((array, np.full((array.shape[0], 1), DEFAULT_Z))) + elif array.shape[1] != len(Point3DIndex): + raise ValueError("Array must have shape (N, 3) for Point3D.") linestring = geom_creation.linestrings(*array.T) return Polyline3D(linestring) diff --git a/src/py123d/script/builders/scene_builder_builder.py b/src/py123d/script/builders/scene_builder_builder.py index 3c7523a4..d5dc4420 100644 --- a/src/py123d/script/builders/scene_builder_builder.py +++ b/src/py123d/script/builders/scene_builder_builder.py @@ -5,7 +5,7 @@ from hydra.utils import instantiate from omegaconf import DictConfig -from py123d.datatypes.scene.abstract_scene_builder import SceneBuilder +from py123d.api.scene.scene_builder import SceneBuilder logger = logging.getLogger(__name__) diff --git a/src/py123d/script/builders/scene_filter_builder.py b/src/py123d/script/builders/scene_filter_builder.py index 191af8ea..d4ad8d42 100644 --- a/src/py123d/script/builders/scene_filter_builder.py +++ b/src/py123d/script/builders/scene_filter_builder.py @@ -4,7 +4,7 @@ from hydra.utils import instantiate from omegaconf import DictConfig -from py123d.datatypes.scene.scene_filter import SceneFilter +from py123d.api.scene.scene_filter import SceneFilter logger = logging.getLogger(__name__) diff --git a/src/py123d/script/config/common/scene_builder/default_scene_builder.yaml b/src/py123d/script/config/common/scene_builder/default_scene_builder.yaml index cf2e553a..650ac14c 100644 --- a/src/py123d/script/config/common/scene_builder/default_scene_builder.yaml +++ b/src/py123d/script/config/common/scene_builder/default_scene_builder.yaml @@ -1,4 +1,4 @@ -_target_: py123d.datatypes.scene.arrow.arrow_scene_builder.ArrowSceneBuilder +_target_: py123d.api.scene.arrow.arrow_scene_builder.ArrowSceneBuilder _convert_: 'all' logs_root: ${dataset_paths.py123d_logs_root} diff --git a/src/py123d/script/config/common/scene_filter/all_scenes.yaml b/src/py123d/script/config/common/scene_filter/all_scenes.yaml index 1b619af1..7e150d18 100644 --- a/src/py123d/script/config/common/scene_filter/all_scenes.yaml +++ b/src/py123d/script/config/common/scene_filter/all_scenes.yaml @@ -1,4 +1,4 @@ -_target_: py123d.datatypes.scene.scene_filter.SceneFilter +_target_: py123d.api.scene.scene_filter.SceneFilter _convert_: 'all' split_types: null diff --git a/src/py123d/script/config/common/scene_filter/log_scenes.yaml b/src/py123d/script/config/common/scene_filter/log_scenes.yaml index 726b1d73..c9761a1d 100644 --- a/src/py123d/script/config/common/scene_filter/log_scenes.yaml +++ b/src/py123d/script/config/common/scene_filter/log_scenes.yaml @@ -1,4 +1,4 @@ -_target_: py123d.datatypes.scene.scene_filter.SceneFilter +_target_: py123d.api.scene.scene_filter.SceneFilter _convert_: 'all' split_types: null diff --git a/src/py123d/script/config/common/scene_filter/nuplan_logs.yaml b/src/py123d/script/config/common/scene_filter/nuplan_logs.yaml index a9dc8275..5df7f925 100644 --- a/src/py123d/script/config/common/scene_filter/nuplan_logs.yaml +++ b/src/py123d/script/config/common/scene_filter/nuplan_logs.yaml @@ -1,4 +1,4 @@ -_target_: py123d.datatypes.scene.scene_filter.SceneFilter +_target_: py123d.api.scene.scene_filter.SceneFilter _convert_: 'all' split_types: null diff --git a/src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml b/src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml index 9fd43e0d..160413fe 100644 --- a/src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml +++ b/src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml @@ -1,4 +1,4 @@ -_target_: py123d.datatypes.scene.scene_filter.SceneFilter +_target_: py123d.api.scene.scene_filter.SceneFilter _convert_: 'all' split_types: null diff --git a/src/py123d/script/config/common/scene_filter/viser_scenes.yaml b/src/py123d/script/config/common/scene_filter/viser_scenes.yaml index 1b619af1..7e150d18 100644 --- a/src/py123d/script/config/common/scene_filter/viser_scenes.yaml +++ b/src/py123d/script/config/common/scene_filter/viser_scenes.yaml @@ -1,4 +1,4 @@ -_target_: py123d.datatypes.scene.scene_filter.SceneFilter +_target_: py123d.api.scene.scene_filter.SceneFilter _convert_: 'all' split_types: null diff --git a/src/py123d/visualization/color/default.py b/src/py123d/visualization/color/default.py index 988e01bc..31d4a072 100644 --- a/src/py123d/visualization/color/default.py +++ b/src/py123d/visualization/color/default.py @@ -2,7 +2,7 @@ from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.detections.traffic_light_detections import TrafficLightStatus -from py123d.datatypes.map.map_datatypes import MapLayer +from py123d.datatypes.map_objects.map_layer_types import MapLayer from py123d.visualization.color.color import ( BLACK, DARKER_GREY, diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index 15523e0f..1dc2b32a 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -4,13 +4,13 @@ import numpy as np import shapely.geometry as geom +from py123d.api.map.map_api import MapAPI +from py123d.api.scene.scene_api import SceneAPI from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper -from py123d.datatypes.map.abstract_map import AbstractMap -from py123d.datatypes.map.abstract_map_objects import AbstractLane -from py123d.datatypes.map.map_datatypes import MapLayer -from py123d.datatypes.scene.abstract_scene import AbstractScene +from py123d.datatypes.map_objects.map_layer_types import MapLayer +from py123d.datatypes.map_objects.map_objects import Lane from py123d.datatypes.vehicle_state.ego_state import EgoStateSE2, EgoStateSE3 from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, Point2D, PoseSE2Index, Vector2D from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame @@ -33,7 +33,7 @@ def add_default_map_on_ax( ax: plt.Axes, - map_api: AbstractMap, + map_api: MapAPI, point_2d: Point2D, radius: float, route_lane_group_ids: Optional[List[int]] = None, @@ -69,7 +69,7 @@ def add_default_map_on_ax( ]: add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer]) if layer in [MapLayer.LANE]: - map_object: AbstractLane + map_object: Lane add_shapely_linestring_to_ax(ax, map_object.centerline.linestring, CENTERLINE_CONFIG) except Exception: import traceback @@ -89,7 +89,7 @@ def add_box_detections_to_ax(ax: plt.Axes, box_detections: BoxDetectionWrapper) add_bounding_box_to_ax(ax, box_detection.bounding_box, plot_config) -def add_box_future_detections_to_ax(ax: plt.Axes, scene: AbstractScene, iteration: int) -> None: +def add_box_future_detections_to_ax(ax: plt.Axes, scene: SceneAPI, iteration: int) -> None: # TODO: Refactor this function initial_agents = scene.get_box_detections_at_iteration(iteration) @@ -127,10 +127,10 @@ def add_ego_vehicle_to_ax(ax: plt.Axes, ego_vehicle_state: Union[EgoStateSE3, Eg def add_traffic_lights_to_ax( - ax: plt.Axes, traffic_light_detections: TrafficLightDetectionWrapper, map_api: AbstractMap + ax: plt.Axes, traffic_light_detections: TrafficLightDetectionWrapper, map_api: MapAPI ) -> None: for traffic_light_detection in traffic_light_detections: - lane: AbstractLane = map_api.get_map_object(str(traffic_light_detection.lane_id), MapLayer.LANE) + lane: Lane = map_api.get_map_object(str(traffic_light_detection.lane_id), MapLayer.LANE) if lane is not None: add_shapely_linestring_to_ax( ax, diff --git a/src/py123d/visualization/matplotlib/plots.py b/src/py123d/visualization/matplotlib/plots.py index 0b872abd..60785b4a 100644 --- a/src/py123d/visualization/matplotlib/plots.py +++ b/src/py123d/visualization/matplotlib/plots.py @@ -5,7 +5,7 @@ import matplotlib.pyplot as plt from tqdm import tqdm -from py123d.datatypes.scene.abstract_scene import AbstractScene +from py123d.api.scene.scene_api import SceneAPI from py123d.visualization.matplotlib.observation import ( add_box_detections_to_ax, add_default_map_on_ax, @@ -14,7 +14,7 @@ ) -def _plot_scene_on_ax(ax: plt.Axes, scene: AbstractScene, iteration: int = 0, radius: float = 80) -> plt.Axes: +def _plot_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: float = 80) -> plt.Axes: ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) box_detections = scene.get_box_detections_at_iteration(iteration) @@ -38,9 +38,7 @@ def _plot_scene_on_ax(ax: plt.Axes, scene: AbstractScene, iteration: int = 0, ra return ax -def plot_scene_at_iteration( - scene: AbstractScene, iteration: int = 0, radius: float = 80 -) -> Tuple[plt.Figure, plt.Axes]: +def plot_scene_at_iteration(scene: SceneAPI, iteration: int = 0, radius: float = 80) -> Tuple[plt.Figure, plt.Axes]: fig, ax = plt.subplots(figsize=(10, 10)) _plot_scene_on_ax(ax, scene, iteration, radius) @@ -48,7 +46,7 @@ def plot_scene_at_iteration( def render_scene_animation( - scene: AbstractScene, + scene: SceneAPI, output_path: Union[str, Path], start_idx: int = 0, end_idx: Optional[int] = None, diff --git a/src/py123d/visualization/viser/elements/detection_elements.py b/src/py123d/visualization/viser/elements/detection_elements.py index 25d63172..bc94505e 100644 --- a/src/py123d/visualization/viser/elements/detection_elements.py +++ b/src/py123d/visualization/viser/elements/detection_elements.py @@ -5,8 +5,8 @@ import trimesh import viser +from py123d.api.scene.scene_api import SceneAPI from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel -from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.geometry.geometry_index import BoundingBoxSE3Index, Corners3DIndex, PoseSE3Index from py123d.geometry.utils.bounding_box_utils import ( @@ -19,7 +19,7 @@ def add_box_detections_to_viser_server( - scene: AbstractScene, + scene: SceneAPI, scene_interation: int, initial_ego_state: EgoStateSE3, viser_server: viser.ViserServer, @@ -67,7 +67,7 @@ def add_box_detections_to_viser_server( box_detection_handles[key].visible = False -def _get_bounding_box_meshes(scene: AbstractScene, iteration: int, initial_ego_state: EgoStateSE3) -> trimesh.Trimesh: +def _get_bounding_box_meshes(scene: SceneAPI, iteration: int, initial_ego_state: EgoStateSE3) -> trimesh.Trimesh: ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) box_detections = scene.get_box_detections_at_iteration(iteration) @@ -127,7 +127,7 @@ def _get_bounding_box_meshes(scene: AbstractScene, iteration: int, initial_ego_s def _get_bounding_box_outlines( - scene: AbstractScene, iteration: int, initial_ego_state: EgoStateSE3 + scene: SceneAPI, iteration: int, initial_ego_state: EgoStateSE3 ) -> npt.NDArray[np.float64]: ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) diff --git a/src/py123d/visualization/viser/elements/map_elements.py b/src/py123d/visualization/viser/elements/map_elements.py index 4533258e..297dafa3 100644 --- a/src/py123d/visualization/viser/elements/map_elements.py +++ b/src/py123d/visualization/viser/elements/map_elements.py @@ -4,9 +4,9 @@ import trimesh import viser -from py123d.datatypes.map.abstract_map import MapLayer -from py123d.datatypes.map.abstract_map_objects import AbstractSurfaceMapObject -from py123d.datatypes.scene.abstract_scene import AbstractScene +from py123d.api import SceneAPI +from py123d.datatypes.map_objects.base_map_objects import BaseMapSurfaceObject +from py123d.datatypes.map_objects.map_layer_types import MapLayer from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.geometry import Point3D, Point3DIndex from py123d.visualization.color.default import MAP_SURFACE_CONFIG @@ -16,7 +16,7 @@ def add_map_to_viser_server( - scene: AbstractScene, + scene: SceneAPI, iteration: int, initial_ego_state: EgoStateSE3, viser_server: viser.ViserServer, @@ -66,7 +66,7 @@ def add_map_to_viser_server( def _get_map_trimesh_dict( - scene: AbstractScene, + scene: SceneAPI, initial_ego_state: EgoStateSE3, current_ego_state: Optional[EgoStateSE3], viser_config: ViserConfig, @@ -101,7 +101,7 @@ def _get_map_trimesh_dict( for map_layer in map_objects_dict.keys(): surface_meshes = [] for map_surface in map_objects_dict[map_layer]: - map_surface: AbstractSurfaceMapObject + map_surface: BaseMapSurfaceObject trimesh_mesh = map_surface.trimesh_mesh trimesh_mesh.vertices -= scene_center_array diff --git a/src/py123d/visualization/viser/elements/render_elements.py b/src/py123d/visualization/viser/elements/render_elements.py index 05029ca1..a8d91a64 100644 --- a/src/py123d/visualization/viser/elements/render_elements.py +++ b/src/py123d/visualization/viser/elements/render_elements.py @@ -1,17 +1,14 @@ import numpy as np +from py123d.api.scene.scene_api import SceneAPI from py123d.conversion.utils.sensor_utils.camera_conventions import convert_camera_convention -from py123d.datatypes.scene.abstract_scene import AbstractScene from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.geometry.geometry_index import PoseSE3Index -from py123d.geometry.pose import PoseSE3 -from py123d.geometry.rotation import EulerAngles +from py123d.geometry import EulerAngles, PoseSE3, PoseSE3Index, Vector3D from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame -from py123d.geometry.vector import Vector3D def get_ego_3rd_person_view_position( - scene: AbstractScene, + scene: SceneAPI, iteration: int, initial_ego_state: EgoStateSE3, ) -> PoseSE3: @@ -36,7 +33,7 @@ def get_ego_3rd_person_view_position( def get_ego_bev_view_position( - scene: AbstractScene, + scene: SceneAPI, iteration: int, initial_ego_state: EgoStateSE3, ) -> PoseSE3: diff --git a/src/py123d/visualization/viser/elements/sensor_elements.py b/src/py123d/visualization/viser/elements/sensor_elements.py index 6bb7641e..25014a43 100644 --- a/src/py123d/visualization/viser/elements/sensor_elements.py +++ b/src/py123d/visualization/viser/elements/sensor_elements.py @@ -6,10 +6,15 @@ import numpy.typing as npt import viser -from py123d.datatypes.scene.abstract_scene import AbstractScene -from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICamera, FisheyeMEICameraMetadata, FisheyeMEICameraType -from py123d.datatypes.sensors.lidar import LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType +from py123d.api.scene.scene_api import SceneAPI +from py123d.datatypes.sensors import ( + FisheyeMEICamera, + FisheyeMEICameraMetadata, + FisheyeMEICameraType, + LiDARType, + PinholeCamera, + PinholeCameraType, +) from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.geometry import PoseSE3Index from py123d.geometry.transform.transform_se3 import ( @@ -21,7 +26,7 @@ def add_camera_frustums_to_viser_server( - scene: AbstractScene, + scene: SceneAPI, scene_interation: int, initial_ego_state: EgoStateSE3, viser_server: viser.ViserServer, @@ -76,7 +81,7 @@ def _add_camera_frustums_to_viser_server(camera_type: PinholeCameraType) -> None def add_fisheye_frustums_to_viser_server( - scene: AbstractScene, + scene: SceneAPI, scene_interation: int, initial_ego_state: EgoStateSE3, viser_server: viser.ViserServer, @@ -130,7 +135,7 @@ def _add_fisheye_frustums_to_viser_server(fisheye_camera_type: FisheyeMEICameraT def add_camera_gui_to_viser_server( - scene: AbstractScene, + scene: SceneAPI, scene_interation: int, viser_server: viser.ViserServer, viser_config: ViserConfig, @@ -153,7 +158,7 @@ def add_camera_gui_to_viser_server( def add_lidar_pc_to_viser_server( - scene: AbstractScene, + scene: SceneAPI, scene_interation: int, initial_ego_state: EgoStateSE3, viser_server: viser.ViserServer, diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index 53d9f7e1..75722f5f 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -8,8 +8,8 @@ from tqdm import tqdm from viser.theme import TitlebarButton, TitlebarConfig, TitlebarImage -from py123d.datatypes.map.map_datatypes import MapLayer -from py123d.datatypes.scene.abstract_scene import AbstractScene +from py123d.api.scene.scene_api import SceneAPI +from py123d.datatypes.map_objects.map_layer_types import MapLayer from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType @@ -79,7 +79,7 @@ def _build_viser_server(viser_config: ViserConfig) -> viser.ViserServer: class ViserViewer: def __init__( self, - scenes: List[AbstractScene], + scenes: List[SceneAPI], viser_config: ViserConfig = ViserConfig(), scene_index: int = 0, ) -> None: @@ -99,7 +99,7 @@ def next(self) -> None: self._scene_index = (self._scene_index + 1) % len(self._scenes) self.set_scene(self._scenes[self._scene_index]) - def set_scene(self, scene: AbstractScene) -> None: + def set_scene(self, scene: SceneAPI) -> None: num_frames = scene.number_of_iterations initial_ego_state: EgoStateSE3 = scene.get_ego_state_at_iteration(0) server_playing = True @@ -392,7 +392,7 @@ def _(event: viser.GuiEvent) -> None: self.next() -def _get_scene_info_markdown(scene: AbstractScene) -> str: +def _get_scene_info_markdown(scene: SceneAPI) -> str: markdown = f""" - Dataset: {scene.log_metadata.split} - Location: {scene.log_metadata.location if scene.log_metadata.location else 'N/A'} From ce3fe8ee7c10b69e8ab02ddecee83f00146119cf Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 9 Nov 2025 18:50:27 +0100 Subject: [PATCH 07/50] Add png compression for arow reader/writer. --- examples/01_viser.py | 7 +- .../api/scene/arrow/utils/arrow_getters.py | 18 +++-- .../conversion/dataset_converter_config.py | 21 ++++-- .../datasets/nuscenes/nuscenes_converter.py | 7 +- .../log_writer/abstract_log_writer.py | 8 +++ .../conversion/log_writer/arrow_log_writer.py | 68 ++++++++++++++----- .../sensor_io/camera/png_camera_io.py | 40 +++++++++++ .../datasets/av2_sensor_dataset.yaml | 4 +- .../conversion/datasets/kitti360_dataset.yaml | 6 +- .../conversion/datasets/nuplan_dataset.yaml | 4 +- .../datasets/nuplan_mini_dataset.yaml | 4 +- .../conversion/datasets/nuscenes_dataset.yaml | 4 +- .../datasets/nuscenes_mini_dataset.yaml | 4 +- .../conversion/datasets/pandaset_dataset.yaml | 4 +- .../conversion/datasets/wopd_dataset.yaml | 4 +- .../log_writer/arrow_log_writer.yaml | 1 + 16 files changed, 155 insertions(+), 49 deletions(-) create mode 100644 src/py123d/conversion/sensor_io/camera/png_camera_io.py diff --git a/examples/01_viser.py b/examples/01_viser.py index 5871b682..9aafb3e5 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -1,18 +1,17 @@ from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder from py123d.api.scene.scene_filter import SceneFilter from py123d.common.multithreading.worker_sequential import Sequential -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType from py123d.visualization.viser.viser_viewer import ViserViewer if __name__ == "__main__": # splits = ["kitti360_train"] - # splits = ["nuscenes-mini_val", "nuscenes-mini_train"] + splits = ["nuscenes-mini_val", "nuscenes-mini_train"] # splits = ["nuplan-mini_test", "nuplan-mini_train", "nuplan-mini_val"] # splits = ["nuplan_private_test"] # splits = ["carla_test"] # splits = ["wopd_val"] # splits = ["av2-sensor_train"] - splits = ["pandaset_test", "pandaset_val", "pandaset_train"] + # splits = ["pandaset_test", "pandaset_val", "pandaset_train"] # log_names = ["2021.08.24.13.12.55_veh-45_00386_00472"] # log_names = ["2013_05_28_drive_0000_sync"] # log_names = ["2013_05_28_drive_0000_sync"] @@ -28,7 +27,7 @@ history_s=0.0, timestamp_threshold_s=None, shuffle=True, - pinhole_camera_types=[PinholeCameraType.PCAM_F0], + # pinhole_camera_types=[PinholeCameraType.PCAM_F0], ) scene_builder = ArrowSceneBuilder() worker = Sequential() diff --git a/src/py123d/api/scene/arrow/utils/arrow_getters.py b/src/py123d/api/scene/arrow/utils/arrow_getters.py index 1672036a..a195d46b 100644 --- a/src/py123d/api/scene/arrow/utils/arrow_getters.py +++ b/src/py123d/api/scene/arrow/utils/arrow_getters.py @@ -30,8 +30,13 @@ ) from py123d.common.utils.mixin import ArrayMixin from py123d.conversion.registry.lidar_index_registry import DefaultLiDARIndex -from py123d.conversion.sensor_io.camera.jpeg_camera_io import decode_image_from_jpeg_binary, load_image_from_jpeg_file +from py123d.conversion.sensor_io.camera.jpeg_camera_io import ( + decode_image_from_jpeg_binary, + is_jpeg_binary, + load_image_from_jpeg_file, +) from py123d.conversion.sensor_io.camera.mp4_camera_io import get_mp4_reader_from_path +from py123d.conversion.sensor_io.camera.png_camera_io import decode_image_from_png_binary, is_png_binary from py123d.conversion.sensor_io.lidar.draco_lidar_io import is_draco_binary, load_lidar_from_draco_binary from py123d.conversion.sensor_io.lidar.file_lidar_io import load_lidar_pcs_from_file from py123d.conversion.sensor_io.lidar.laz_lidar_io import is_laz_binary, load_lidar_from_laz_binary @@ -188,7 +193,13 @@ def get_camera_from_arrow_table( image = load_image_from_jpeg_file(full_image_path) elif isinstance(table_data, bytes): - image = decode_image_from_jpeg_binary(table_data) + if is_jpeg_binary(table_data): + image = decode_image_from_jpeg_binary(table_data) + elif is_png_binary(table_data): + image = decode_image_from_png_binary(table_data) + else: + raise ValueError("Camera binary data is neither in JPEG nor PNG format.") + elif isinstance(table_data, int): image = _unoptimized_demo_mp4_read(log_metadata, camera_name, table_data) else: @@ -258,12 +269,9 @@ def get_lidar_from_arrow_table( if is_draco_binary(lidar_data): # NOTE: DRACO only allows XYZ compression, so we need to override the lidar index here. lidar_metadata.lidar_index = DefaultLiDARIndex - lidar = load_lidar_from_draco_binary(lidar_data, lidar_metadata) elif is_laz_binary(lidar_data): - lidar = load_lidar_from_laz_binary(lidar_data, lidar_metadata) - else: raise ValueError("LiDAR binary data is neither in Draco nor LAZ format.") elif lidar_data is not None: diff --git a/src/py123d/conversion/dataset_converter_config.py b/src/py123d/conversion/dataset_converter_config.py index f9264cd7..099a9bd4 100644 --- a/src/py123d/conversion/dataset_converter_config.py +++ b/src/py123d/conversion/dataset_converter_config.py @@ -25,15 +25,15 @@ class DatasetConverterConfig: # Pinhole Cameras include_pinhole_cameras: bool = False - pinhole_camera_store_option: Literal["path", "binary", "mp4"] = "path" + pinhole_camera_store_option: Literal["path", "jpeg_binary", "png_binary", "mp4"] = "path" # Fisheye MEI Cameras include_fisheye_mei_cameras: bool = False - fisheye_mei_camera_store_option: Literal["path", "binary", "mp4"] = "path" + fisheye_mei_camera_store_option: Literal["path", "jpeg_binary", "png_binary", "mp4"] = "path" # LiDARs include_lidars: bool = False - lidar_store_option: Literal["path", "path_merged", "binary"] = "path" + lidar_store_option: Literal["path", "path_merged", "laz_binary", "draco_binary"] = "path" # Scenario tag / Route # NOTE: These are only supported for nuPlan. Consider removing or expanding support. @@ -44,12 +44,21 @@ def __post_init__(self): assert self.pinhole_camera_store_option in [ "path", - "binary", + "jpeg_binary", + "png_binary", "mp4", - ], f"Invalid camera store option, got {self.pinhole_camera_store_option}." + ], f"Invalid Pinhole camera store option, got {self.pinhole_camera_store_option}." + + assert self.fisheye_mei_camera_store_option in [ + "path", + "jpeg_binary", + "png_binary", + "mp4", + ], f"Invalid Fisheye MEI camera store option, got {self.fisheye_mei_camera_store_option}." assert self.lidar_store_option in [ "path", "path_merged", - "binary", + "laz_binary", + "draco_binary", ], f"Invalid LiDAR store option, got {self.lidar_store_option}." diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 1d432a71..fb9e0844 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -344,7 +344,12 @@ def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> center_quat.y, center_quat.z, ) - bounding_box = BoundingBoxSE3(center, box.wlh[1], box.wlh[0], box.wlh[2]) + bounding_box = BoundingBoxSE3( + center=center, + length=box.wlh[1], + width=box.wlh[0], + height=box.wlh[2], + ) # Get detection type category = ann["category_name"] label = NUSCENES_DETECTION_NAME_DICT[category] diff --git a/src/py123d/conversion/log_writer/abstract_log_writer.py b/src/py123d/conversion/log_writer/abstract_log_writer.py index 23fc93b1..aff5cf41 100644 --- a/src/py123d/conversion/log_writer/abstract_log_writer.py +++ b/src/py123d/conversion/log_writer/abstract_log_writer.py @@ -108,6 +108,14 @@ def __post_init__(self): def has_file_path(self) -> bool: return self.dataset_root is not None and self.relative_path is not None + @property + def has_jpeg_file_path(self) -> bool: + return self.relative_path is not None and str(self.relative_path).lower().endswith((".jpg", ".jpeg")) + + @property + def has_png_file_path(self) -> bool: + return self.relative_path is not None and str(self.relative_path).lower().endswith((".png",)) + @property def has_jpeg_binary(self) -> bool: return self.jpeg_binary is not None diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index d31a8ea4..f08bde27 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -35,6 +35,11 @@ load_jpeg_binary_from_jpeg_file, ) from py123d.conversion.sensor_io.camera.mp4_camera_io import MP4Writer +from py123d.conversion.sensor_io.camera.png_camera_io import ( + encode_image_as_png_binary, + load_image_from_png_file, + load_png_binary_from_png_file, +) from py123d.conversion.sensor_io.lidar.draco_lidar_io import encode_lidar_pc_as_draco_binary from py123d.conversion.sensor_io.lidar.file_lidar_io import load_lidar_pcs_from_file from py123d.conversion.sensor_io.lidar.laz_lidar_io import encode_lidar_pc_as_laz_binary @@ -62,10 +67,15 @@ def _get_sensors_root() -> Path: return Path(DATASET_PATHS.py123d_sensors_root) -def _store_option_to_arrow_type(store_option: Literal["path", "binary", "mp4"]) -> pa.DataType: +def _store_option_to_arrow_type( + store_option: Literal["path", "jpeg_binary", "png_binary", "laz_binary"], +) -> pa.DataType: data_type_map = { "path": pa.string(), - "binary": pa.binary(), + "jpeg_binary": pa.binary(), + "png_binary": pa.binary(), + "laz_binary": pa.binary(), + "draco_binary": pa.binary(), "mp4": pa.int64(), } return data_type_map[store_option] @@ -79,14 +89,12 @@ def __init__( sensors_root: Optional[Union[str, Path]] = None, ipc_compression: Optional[Literal["lz4", "zstd"]] = None, ipc_compression_level: Optional[int] = None, - lidar_compression: Optional[Literal["draco", "laz"]] = "draco", ) -> None: self._logs_root = Path(logs_root) if logs_root is not None else _get_logs_root() self._sensors_root = Path(sensors_root) if sensors_root is not None else _get_sensors_root() self._ipc_compression = ipc_compression self._ipc_compression_level = ipc_compression_level - self._lidar_compression = lidar_compression # Loaded during .reset() and cleared during .close() self._dataset_converter_config: Optional[DatasetConverterConfig] = None @@ -473,7 +481,7 @@ def _prepare_lidar_data_dict(self, lidars: List[LiDARData]) -> Dict[LiDARType, U assert lidar_data.has_file_path, "LiDAR data must provide file path for path storage." lidar_data_dict[lidar_data.lidar_type] = str(lidar_data.relative_path) - elif self._dataset_converter_config.lidar_store_option == "binary": + elif self._dataset_converter_config.lidar_store_option in ["laz_binary", "draco_binary"]: lidar_pcs_dict: Dict[LiDARType, np.ndarray] = {} # 1. Load point clouds from files @@ -494,9 +502,9 @@ def _prepare_lidar_data_dict(self, lidars: List[LiDARData]) -> Dict[LiDARType, U for lidar_type, point_cloud in lidar_pcs_dict.items(): lidar_metadata = self._log_metadata.lidar_metadata[lidar_type] binary: Optional[bytes] = None - if self._lidar_compression == "draco": + if self._dataset_converter_config.lidar_store_option == "draco_binary": binary = encode_lidar_pc_as_draco_binary(point_cloud, lidar_metadata) - elif self._lidar_compression == "laz": + elif self._dataset_converter_config.lidar_store_option == "laz_binary": binary = encode_lidar_pc_as_laz_binary(point_cloud, lidar_metadata) lidar_data_dict[lidar_type] = binary @@ -513,8 +521,10 @@ def _prepare_camera_data_dict( camera_data_dict[camera_data.camera_type] = str(camera_data.relative_path) else: raise NotImplementedError("Only file path storage is supported for camera data.") - elif store_option == "binary": + elif store_option == "jpeg_binary": camera_data_dict[camera_data.camera_type] = _get_jpeg_binary_from_camera_data(camera_data) + elif store_option == "png_binary": + camera_data_dict[camera_data.camera_type] = _get_png_binary_from_camera_data(camera_data) elif store_option == "mp4": camera_name = camera_data.camera_type.serialize() if camera_name not in self._pinhole_mp4_writers: @@ -541,15 +551,15 @@ def _get_jpeg_binary_from_camera_data(camera_data: CameraData) -> Optional[bytes if camera_data.has_jpeg_binary: jpeg_binary = camera_data.jpeg_binary + elif camera_data.has_jpeg_file_path: + absolute_path = Path(camera_data.dataset_root) / camera_data.relative_path + jpeg_binary = load_jpeg_binary_from_jpeg_file(absolute_path) + elif camera_data.has_png_file_path: + absolute_path = Path(camera_data.dataset_root) / camera_data.relative_path + numpy_image = load_image_from_png_file(absolute_path) + jpeg_binary = encode_image_as_jpeg_binary(numpy_image) elif camera_data.has_numpy_image: jpeg_binary = encode_image_as_jpeg_binary(camera_data.numpy_image) - elif camera_data.has_file_path: - absolute_path = Path(camera_data.dataset_root) / camera_data.relative_path - - if absolute_path.suffix.lower() in [".jpg", ".jpeg"]: - jpeg_binary = load_jpeg_binary_from_jpeg_file(absolute_path) - else: - raise NotImplementedError(f"Unsupported camera file format: {absolute_path.suffix} for binary storage.") else: raise NotImplementedError("Camera data must provide jpeg_binary, numpy_image, or file path for binary storage.") @@ -557,6 +567,29 @@ def _get_jpeg_binary_from_camera_data(camera_data: CameraData) -> Optional[bytes return jpeg_binary +def _get_png_binary_from_camera_data(camera_data: CameraData) -> Optional[bytes]: + png_binary: Optional[bytes] = None + + if camera_data.has_png_file_path: + absolute_path = Path(camera_data.dataset_root) / camera_data.relative_path + png_binary = load_png_binary_from_png_file(absolute_path) + elif camera_data.has_numpy_image: + png_binary = encode_image_as_png_binary(camera_data.numpy_image) + elif camera_data.has_jpeg_file_path: + absolute_path = Path(camera_data.dataset_root) / camera_data.relative_path + numpy_image = load_image_from_jpeg_file(absolute_path) + png_binary = encode_image_as_png_binary(numpy_image) + + elif camera_data.has_jpeg_binary: + numpy_image = decode_image_from_jpeg_binary(camera_data.jpeg_binary) + png_binary = encode_image_as_png_binary(numpy_image) + else: + raise NotImplementedError("Camera data must provide png_binary, numpy_image, or file path for binary storage.") + + assert png_binary is not None + return png_binary + + def _get_numpy_image_from_camera_data(camera_data: CameraData) -> Optional[np.ndarray]: numpy_image: Optional[np.ndarray] = None @@ -564,9 +597,12 @@ def _get_numpy_image_from_camera_data(camera_data: CameraData) -> Optional[np.nd numpy_image = camera_data.numpy_image elif camera_data.has_jpeg_binary: numpy_image = decode_image_from_jpeg_binary(camera_data.jpeg_binary) - elif camera_data.has_file_path: + elif camera_data.has_jpeg_file_path: absolute_path = Path(camera_data.dataset_root) / camera_data.relative_path numpy_image = load_image_from_jpeg_file(absolute_path) + elif camera_data.has_png_file_path: + absolute_path = Path(camera_data.dataset_root) / camera_data.relative_path + numpy_image = load_image_from_png_file(absolute_path) else: raise NotImplementedError("Camera data must provide numpy_image, jpeg_binary, or file path for numpy image.") diff --git a/src/py123d/conversion/sensor_io/camera/png_camera_io.py b/src/py123d/conversion/sensor_io/camera/png_camera_io.py new file mode 100644 index 00000000..e3149c4d --- /dev/null +++ b/src/py123d/conversion/sensor_io/camera/png_camera_io.py @@ -0,0 +1,40 @@ +from pathlib import Path + +import cv2 +import numpy as np +import numpy.typing as npt + + +def is_png_binary(png_binary: bytes) -> bool: + """Check if the given binary data represents a PNG image. + + :param png_binary: The binary data to check. + :return: True if the binary data is a PNG image, False otherwise. + """ + PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" # PNG file signature + + return png_binary.startswith(PNG_SIGNATURE) + + +def encode_image_as_png_binary(image: npt.NDArray[np.uint8]) -> bytes: + _, encoded_img = cv2.imencode(".png", image) + png_binary = encoded_img.tobytes() + return png_binary + + +def decode_image_from_png_binary(png_binary: bytes) -> npt.NDArray[np.uint8]: + image = cv2.imdecode(np.frombuffer(png_binary, np.uint8), cv2.IMREAD_UNCHANGED) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + return image + + +def load_png_binary_from_png_file(png_path: Path) -> bytes: + with open(png_path, "rb") as f: + png_binary = f.read() + return png_binary + + +def load_image_from_png_file(png_path: Path) -> npt.NDArray[np.uint8]: + image = cv2.imread(str(png_path), cv2.IMREAD_COLOR) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + return image diff --git a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml index ff8a2433..f0d5f6ac 100644 --- a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml @@ -23,11 +23,11 @@ av2_sensor_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "binary", "mp4" + pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "binary" + lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_traffic_lights: false diff --git a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml index 544eb879..e5b58d7d 100644 --- a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml @@ -31,15 +31,15 @@ kitti360_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" + pinhole_camera_store_option: "png_binary" # "path", "jpeg_binary", "png_binary", "mp4" # Fisheye Cameras include_fisheye_mei_cameras: true - fisheye_mei_camera_store_option: "binary" + fisheye_mei_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "path" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_traffic_lights: false diff --git a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml index 1241c2ae..fc3c1b9a 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml @@ -28,11 +28,11 @@ nuplan_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "binary", "mp4" + pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "binary" + lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" # Scenario tag / Route include_scenario_tags: true diff --git a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml index fb7818b2..e6fac609 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml @@ -28,11 +28,11 @@ nuplan_mini_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "binary", "mp4" + pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "binary" + lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" # Scenario tag / Route include_scenario_tags: true diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml index 52ce8207..b7768e63 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml @@ -26,11 +26,11 @@ nuscenes_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" + pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" + lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_fisheye_mei_cameras: false diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml index 9bc7f019..8d9c7163 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml @@ -26,11 +26,11 @@ nuscenes_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" + pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" + lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_fisheye_mei_cameras: false diff --git a/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml b/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml index 30747b42..1ab5ea02 100644 --- a/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml @@ -20,11 +20,11 @@ pandaset_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_map: false diff --git a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml index 14a643d8..b39dae96 100644 --- a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml @@ -27,11 +27,11 @@ wopd_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "binary", "mp4" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: false - lidar_store_option: "binary" # "path", "path_merged", "binary" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_traffic_lights: false diff --git a/src/py123d/script/config/conversion/log_writer/arrow_log_writer.yaml b/src/py123d/script/config/conversion/log_writer/arrow_log_writer.yaml index 61a4ead6..6caedc37 100644 --- a/src/py123d/script/config/conversion/log_writer/arrow_log_writer.yaml +++ b/src/py123d/script/config/conversion/log_writer/arrow_log_writer.yaml @@ -3,5 +3,6 @@ _convert_: 'all' logs_root: ${dataset_paths.py123d_logs_root} +sensors_root: ${dataset_paths.py123d_sensors_root} ipc_compression: null # Compression method for ipc files. Options: None, 'lz4', 'zstd' ipc_compression_level: null # Compression level for ipc files. Options: None, or depending on compression method From a5631a9c1f70efcb547a8e6f9762d4c6a2d1ad03 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 9 Nov 2025 20:48:12 +0100 Subject: [PATCH 08/50] Populate `docs` with datatypes. --- .../detections/01_box_detections.rst | 18 +++++++++++++ .../detections/02_traffic_lights.rst | 16 ++++++++++++ docs/api/datatypes/detections/index.rst | 8 ++++++ docs/api/datatypes/index.rst | 12 ++++++--- docs/api/datatypes/map_objects/01_lane.rst | 6 +++++ .../datatypes/map_objects/02_lane_group.rst | 6 +++++ .../datatypes/map_objects/03_intersection.rst | 6 +++++ .../datatypes/map_objects/04_crosswalk.rst | 6 +++++ docs/api/datatypes/map_objects/05_carpark.rst | 6 +++++ docs/api/datatypes/map_objects/06_walkway.rst | 6 +++++ .../map_objects/07_generic_drivable.rst | 6 +++++ .../datatypes/map_objects/08_stop_zone.rst | 6 +++++ .../datatypes/map_objects/09_road_edge.rst | 10 +++++++ .../datatypes/map_objects/10_road_line.rst | 9 +++++++ .../map_objects/11_base_map_objects.rst | 14 ++++++++++ docs/api/datatypes/map_objects/index.rst | 17 ++++++++++++ .../datatypes/metadata/01_log_metadata.rst | 6 +++++ .../datatypes/metadata/02_map_metadata.rst | 6 +++++ docs/api/datatypes/metadata/index.rst | 8 ++++++ docs/api/datatypes/sensors.rst | 25 ------------------ .../datatypes/sensors/01_pinhole_camera.rst | 26 +++++++++++++++++++ .../sensors/02_fisheye_mei_camera.rst | 6 +++++ docs/api/datatypes/sensors/03_lidar.rst | 6 +++++ docs/api/datatypes/sensors/index.rst | 9 +++++++ docs/api/datatypes/time/index.rst | 13 ++++++++++ .../datatypes/vehicle_state/01_ego_state.rst | 10 +++++++ .../vehicle_state/02_dynamic_state.rst | 10 +++++++ .../vehicle_state/03_vehicle_parameters.rst | 6 +++++ docs/api/datatypes/vehicle_state/index.rst | 9 +++++++ docs/api/map/index.rst | 15 +++++------ docs/api/map/map_api.rst | 5 ---- docs/api/map/map_layers.rst | 12 --------- docs/api/scene/index.rst | 11 +++----- docs/api/scene/scene_api.rst | 5 ---- docs/conf.py | 2 +- examples/01_viser.py | 4 +-- .../datasets/wopd/wopd_converter.py | 2 +- .../datatypes/map_objects/map_objects.py | 4 +-- src/py123d/datatypes/time/__init__.py | 1 + .../conversion/datasets/wopd_dataset.yaml | 2 +- 40 files changed, 281 insertions(+), 74 deletions(-) create mode 100644 docs/api/datatypes/detections/01_box_detections.rst create mode 100644 docs/api/datatypes/detections/02_traffic_lights.rst create mode 100644 docs/api/datatypes/detections/index.rst create mode 100644 docs/api/datatypes/map_objects/01_lane.rst create mode 100644 docs/api/datatypes/map_objects/02_lane_group.rst create mode 100644 docs/api/datatypes/map_objects/03_intersection.rst create mode 100644 docs/api/datatypes/map_objects/04_crosswalk.rst create mode 100644 docs/api/datatypes/map_objects/05_carpark.rst create mode 100644 docs/api/datatypes/map_objects/06_walkway.rst create mode 100644 docs/api/datatypes/map_objects/07_generic_drivable.rst create mode 100644 docs/api/datatypes/map_objects/08_stop_zone.rst create mode 100644 docs/api/datatypes/map_objects/09_road_edge.rst create mode 100644 docs/api/datatypes/map_objects/10_road_line.rst create mode 100644 docs/api/datatypes/map_objects/11_base_map_objects.rst create mode 100644 docs/api/datatypes/map_objects/index.rst create mode 100644 docs/api/datatypes/metadata/01_log_metadata.rst create mode 100644 docs/api/datatypes/metadata/02_map_metadata.rst create mode 100644 docs/api/datatypes/metadata/index.rst delete mode 100644 docs/api/datatypes/sensors.rst create mode 100644 docs/api/datatypes/sensors/01_pinhole_camera.rst create mode 100644 docs/api/datatypes/sensors/02_fisheye_mei_camera.rst create mode 100644 docs/api/datatypes/sensors/03_lidar.rst create mode 100644 docs/api/datatypes/sensors/index.rst create mode 100644 docs/api/datatypes/time/index.rst create mode 100644 docs/api/datatypes/vehicle_state/01_ego_state.rst create mode 100644 docs/api/datatypes/vehicle_state/02_dynamic_state.rst create mode 100644 docs/api/datatypes/vehicle_state/03_vehicle_parameters.rst create mode 100644 docs/api/datatypes/vehicle_state/index.rst delete mode 100644 docs/api/map/map_api.rst delete mode 100644 docs/api/map/map_layers.rst delete mode 100644 docs/api/scene/scene_api.rst diff --git a/docs/api/datatypes/detections/01_box_detections.rst b/docs/api/datatypes/detections/01_box_detections.rst new file mode 100644 index 00000000..2265db86 --- /dev/null +++ b/docs/api/datatypes/detections/01_box_detections.rst @@ -0,0 +1,18 @@ +Box Detections +^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.detections.BoxDetectionWrapper + :members: + :autoclasstoc: + +.. autoclass:: py123d.datatypes.detections.BoxDetectionMetadata + :members: + :autoclasstoc: + +.. autoclass:: py123d.datatypes.detections.BoxDetectionSE2 + :members: + :autoclasstoc: + +.. autoclass:: py123d.datatypes.detections.BoxDetectionSE3 + :members: + :autoclasstoc: diff --git a/docs/api/datatypes/detections/02_traffic_lights.rst b/docs/api/datatypes/detections/02_traffic_lights.rst new file mode 100644 index 00000000..f6abf953 --- /dev/null +++ b/docs/api/datatypes/detections/02_traffic_lights.rst @@ -0,0 +1,16 @@ +Traffic Lights +^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.detections.TrafficLightDetectionWrapper + :members: + :autoclasstoc: + + +.. autoclass:: py123d.datatypes.detections.TrafficLightDetection + :members: + :autoclasstoc: + + +.. autoclass:: py123d.datatypes.detections.TrafficLightStatus + :members: + :no-inherited-members: diff --git a/docs/api/datatypes/detections/index.rst b/docs/api/datatypes/detections/index.rst new file mode 100644 index 00000000..e05f6ca2 --- /dev/null +++ b/docs/api/datatypes/detections/index.rst @@ -0,0 +1,8 @@ +Detections +---------- + +.. toctree:: + :maxdepth: 1 + + 01_box_detections + 02_traffic_lights diff --git a/docs/api/datatypes/index.rst b/docs/api/datatypes/index.rst index e67a813f..f4979580 100644 --- a/docs/api/datatypes/index.rst +++ b/docs/api/datatypes/index.rst @@ -3,7 +3,11 @@ Datatypes .. toctree:: - :maxdepth: 2 - - - sensors + :maxdepth: 3 + + sensors/index + detections/index + map_objects/index + metadata/index + vehicle_state/index + time/index diff --git a/docs/api/datatypes/map_objects/01_lane.rst b/docs/api/datatypes/map_objects/01_lane.rst new file mode 100644 index 00000000..22940867 --- /dev/null +++ b/docs/api/datatypes/map_objects/01_lane.rst @@ -0,0 +1,6 @@ +Lane +^^^^ + +.. autoclass:: py123d.datatypes.map_objects.Lane + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/02_lane_group.rst b/docs/api/datatypes/map_objects/02_lane_group.rst new file mode 100644 index 00000000..07e4301d --- /dev/null +++ b/docs/api/datatypes/map_objects/02_lane_group.rst @@ -0,0 +1,6 @@ +Lane Group +^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.LaneGroup + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/03_intersection.rst b/docs/api/datatypes/map_objects/03_intersection.rst new file mode 100644 index 00000000..ab52d6fe --- /dev/null +++ b/docs/api/datatypes/map_objects/03_intersection.rst @@ -0,0 +1,6 @@ +Intersection +^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.Intersection + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/04_crosswalk.rst b/docs/api/datatypes/map_objects/04_crosswalk.rst new file mode 100644 index 00000000..c025eb3e --- /dev/null +++ b/docs/api/datatypes/map_objects/04_crosswalk.rst @@ -0,0 +1,6 @@ +Crosswalk +^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.Crosswalk + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/05_carpark.rst b/docs/api/datatypes/map_objects/05_carpark.rst new file mode 100644 index 00000000..b269d4aa --- /dev/null +++ b/docs/api/datatypes/map_objects/05_carpark.rst @@ -0,0 +1,6 @@ +Carpark +^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.Carpark + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/06_walkway.rst b/docs/api/datatypes/map_objects/06_walkway.rst new file mode 100644 index 00000000..0b6caf31 --- /dev/null +++ b/docs/api/datatypes/map_objects/06_walkway.rst @@ -0,0 +1,6 @@ +Walkway +^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.Walkway + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/07_generic_drivable.rst b/docs/api/datatypes/map_objects/07_generic_drivable.rst new file mode 100644 index 00000000..4ce7b9c0 --- /dev/null +++ b/docs/api/datatypes/map_objects/07_generic_drivable.rst @@ -0,0 +1,6 @@ +Generic Drivable +^^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.GenericDrivable + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/08_stop_zone.rst b/docs/api/datatypes/map_objects/08_stop_zone.rst new file mode 100644 index 00000000..e48379ce --- /dev/null +++ b/docs/api/datatypes/map_objects/08_stop_zone.rst @@ -0,0 +1,6 @@ +Stop Zone +^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.StopZone + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/09_road_edge.rst b/docs/api/datatypes/map_objects/09_road_edge.rst new file mode 100644 index 00000000..cdfa28f2 --- /dev/null +++ b/docs/api/datatypes/map_objects/09_road_edge.rst @@ -0,0 +1,10 @@ +Road Edge +^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.RoadEdge + :no-inherited-members: + :autoclasstoc: + + +.. autoclass:: py123d.datatypes.map_objects.RoadEdgeType + :no-inherited-members: diff --git a/docs/api/datatypes/map_objects/10_road_line.rst b/docs/api/datatypes/map_objects/10_road_line.rst new file mode 100644 index 00000000..606c0f1f --- /dev/null +++ b/docs/api/datatypes/map_objects/10_road_line.rst @@ -0,0 +1,9 @@ +Road Line +^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.RoadLine + :exclude-members: __init__ + :autoclasstoc: + +.. autoclass:: py123d.datatypes.map_objects.RoadLineType + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/11_base_map_objects.rst b/docs/api/datatypes/map_objects/11_base_map_objects.rst new file mode 100644 index 00000000..675a9557 --- /dev/null +++ b/docs/api/datatypes/map_objects/11_base_map_objects.rst @@ -0,0 +1,14 @@ +Base Map Objects +^^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.map_objects.BaseMapObject + :exclude-members: __init__ + :autoclasstoc: + +.. autoclass:: py123d.datatypes.map_objects.BaseMapSurfaceObject + :exclude-members: __init__ + :autoclasstoc: + +.. autoclass:: py123d.datatypes.map_objects.BaseMapLineObject + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/map_objects/index.rst b/docs/api/datatypes/map_objects/index.rst new file mode 100644 index 00000000..dbd7e215 --- /dev/null +++ b/docs/api/datatypes/map_objects/index.rst @@ -0,0 +1,17 @@ +Map Objects +----------- + +.. toctree:: + :maxdepth: 1 + + 01_lane + 02_lane_group + 03_intersection + 04_crosswalk + 05_carpark + 06_walkway + 07_generic_drivable + 08_stop_zone + 09_road_edge + 10_road_line + 11_base_map_objects diff --git a/docs/api/datatypes/metadata/01_log_metadata.rst b/docs/api/datatypes/metadata/01_log_metadata.rst new file mode 100644 index 00000000..5870408f --- /dev/null +++ b/docs/api/datatypes/metadata/01_log_metadata.rst @@ -0,0 +1,6 @@ +Log Metadata +^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.metadata.LogMetadata + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/metadata/02_map_metadata.rst b/docs/api/datatypes/metadata/02_map_metadata.rst new file mode 100644 index 00000000..017ecac4 --- /dev/null +++ b/docs/api/datatypes/metadata/02_map_metadata.rst @@ -0,0 +1,6 @@ +Map Metadata +^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.metadata.MapMetadata + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/metadata/index.rst b/docs/api/datatypes/metadata/index.rst new file mode 100644 index 00000000..34560993 --- /dev/null +++ b/docs/api/datatypes/metadata/index.rst @@ -0,0 +1,8 @@ +Metadata +-------- + +.. toctree:: + :maxdepth: 1 + + 01_log_metadata + 02_map_metadata diff --git a/docs/api/datatypes/sensors.rst b/docs/api/datatypes/sensors.rst deleted file mode 100644 index 76eba60d..00000000 --- a/docs/api/datatypes/sensors.rst +++ /dev/null @@ -1,25 +0,0 @@ -Sensors -------- - -Pinhole Camera -^^^^^^^^^^^^^^^ - -.. autoclass:: py123d.datatypes.sensors.PinholeCamera - :members: - :autoclasstoc: - - -Fisheye MEI Camera -^^^^^^^^^^^^^^^^^^ - -.. autoclass:: py123d.datatypes.sensors.FisheyeMEICamera - :members: - :autoclasstoc: - - -LiDAR -^^^^^ - -.. autoclass:: py123d.datatypes.sensors.LiDAR - :members: - :autoclasstoc: diff --git a/docs/api/datatypes/sensors/01_pinhole_camera.rst b/docs/api/datatypes/sensors/01_pinhole_camera.rst new file mode 100644 index 00000000..7e6fadea --- /dev/null +++ b/docs/api/datatypes/sensors/01_pinhole_camera.rst @@ -0,0 +1,26 @@ +Pinhole Camera +^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.sensors.PinholeCameraType + :no-inherited-members: + +.. autoclass:: py123d.datatypes.sensors.PinholeCamera + :members: + :autoclasstoc: + + +.. autoclass:: py123d.datatypes.sensors.PinholeCameraMetadata + :members: + :autoclasstoc: + + +.. autoclass:: py123d.datatypes.sensors.PinholeIntrinsics + :members: + :no-inherited-members: + :autoclasstoc: + + +.. autoclass:: py123d.datatypes.sensors.PinholeDistortion + :members: + :no-inherited-members: + :autoclasstoc: diff --git a/docs/api/datatypes/sensors/02_fisheye_mei_camera.rst b/docs/api/datatypes/sensors/02_fisheye_mei_camera.rst new file mode 100644 index 00000000..0616af1a --- /dev/null +++ b/docs/api/datatypes/sensors/02_fisheye_mei_camera.rst @@ -0,0 +1,6 @@ +Fisheye MEI Camera +^^^^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.sensors.FisheyeMEICamera + :members: + :autoclasstoc: diff --git a/docs/api/datatypes/sensors/03_lidar.rst b/docs/api/datatypes/sensors/03_lidar.rst new file mode 100644 index 00000000..6958ff20 --- /dev/null +++ b/docs/api/datatypes/sensors/03_lidar.rst @@ -0,0 +1,6 @@ +LiDAR +^^^^^ + +.. autoclass:: py123d.datatypes.sensors.LiDAR + :members: + :autoclasstoc: diff --git a/docs/api/datatypes/sensors/index.rst b/docs/api/datatypes/sensors/index.rst new file mode 100644 index 00000000..f128ad32 --- /dev/null +++ b/docs/api/datatypes/sensors/index.rst @@ -0,0 +1,9 @@ +Sensors +------- + +.. toctree:: + :maxdepth: 1 + + 01_pinhole_camera + 02_fisheye_mei_camera + 03_lidar diff --git a/docs/api/datatypes/time/index.rst b/docs/api/datatypes/time/index.rst new file mode 100644 index 00000000..ab37d362 --- /dev/null +++ b/docs/api/datatypes/time/index.rst @@ -0,0 +1,13 @@ +Time +---- + + +.. autoclass:: py123d.datatypes.time.TimePoint + :exclude-members: __init__ + :autoclasstoc: + + + +.. autoclass:: py123d.datatypes.time.TimeDuration + :exclude-members: __init__ + :autoclasstoc: diff --git a/docs/api/datatypes/vehicle_state/01_ego_state.rst b/docs/api/datatypes/vehicle_state/01_ego_state.rst new file mode 100644 index 00000000..4dcc4d83 --- /dev/null +++ b/docs/api/datatypes/vehicle_state/01_ego_state.rst @@ -0,0 +1,10 @@ +Ego Vehicle State +^^^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.vehicle_state.EgoStateSE2 + :members: + :autoclasstoc: + +.. autoclass:: py123d.datatypes.vehicle_state.EgoStateSE3 + :members: + :autoclasstoc: diff --git a/docs/api/datatypes/vehicle_state/02_dynamic_state.rst b/docs/api/datatypes/vehicle_state/02_dynamic_state.rst new file mode 100644 index 00000000..3e9a06ce --- /dev/null +++ b/docs/api/datatypes/vehicle_state/02_dynamic_state.rst @@ -0,0 +1,10 @@ +Dynamic State +^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.vehicle_state.DynamicStateSE2 + :members: + :autoclasstoc: + +.. autoclass:: py123d.datatypes.vehicle_state.DynamicStateSE3 + :members: + :autoclasstoc: diff --git a/docs/api/datatypes/vehicle_state/03_vehicle_parameters.rst b/docs/api/datatypes/vehicle_state/03_vehicle_parameters.rst new file mode 100644 index 00000000..bd0b1882 --- /dev/null +++ b/docs/api/datatypes/vehicle_state/03_vehicle_parameters.rst @@ -0,0 +1,6 @@ +Vehicle Parameters +^^^^^^^^^^^^^^^^^^ + +.. autoclass:: py123d.datatypes.vehicle_state.VehicleParameters + :members: + :autoclasstoc: diff --git a/docs/api/datatypes/vehicle_state/index.rst b/docs/api/datatypes/vehicle_state/index.rst new file mode 100644 index 00000000..1d4aa450 --- /dev/null +++ b/docs/api/datatypes/vehicle_state/index.rst @@ -0,0 +1,9 @@ +Vehicle State +------------- + +.. toctree:: + :maxdepth: 1 + + 01_ego_state + 02_dynamic_state + 03_vehicle_parameters diff --git a/docs/api/map/index.rst b/docs/api/map/index.rst index ba2fd623..0c812c93 100644 --- a/docs/api/map/index.rst +++ b/docs/api/map/index.rst @@ -1,12 +1,9 @@ -Map -=== +Map API +======= -Brief overview of the datasets section... +.. autoclass:: py123d.api.MapAPI + :autoclasstoc: -This section provides comprehensive documentation for various autonomous driving and computer vision datasets. Each dataset entry includes installation instructions, available data types, known issues, and proper citation formats. -.. toctree:: - :maxdepth: 2 - - map_api - map_layers +.. autoclass:: py123d.datatypes.map_objects.MapLayer + :autoclasstoc: diff --git a/docs/api/map/map_api.rst b/docs/api/map/map_api.rst deleted file mode 100644 index 31251c7c..00000000 --- a/docs/api/map/map_api.rst +++ /dev/null @@ -1,5 +0,0 @@ -Map API -------- - -.. autoclass:: py123d.api.MapAPI - :autoclasstoc: diff --git a/docs/api/map/map_layers.rst b/docs/api/map/map_layers.rst deleted file mode 100644 index 0bff0bc5..00000000 --- a/docs/api/map/map_layers.rst +++ /dev/null @@ -1,12 +0,0 @@ -Map Objects ------------ - -.. autoclass:: py123d.datatypes.map_objects.Carpark() - :show-inheritance: - :inherited-members: - :autoclasstoc: - -.. autoclass:: py123d.datatypes.map_objects.Lane() - :show-inheritance: - :inherited-members: - :autoclasstoc: diff --git a/docs/api/scene/index.rst b/docs/api/scene/index.rst index 090b13a6..f19a179b 100644 --- a/docs/api/scene/index.rst +++ b/docs/api/scene/index.rst @@ -1,8 +1,5 @@ -Scene -===== +Scene API +========= - -.. toctree:: - :maxdepth: 2 - - scene_api +.. autoclass:: py123d.api.SceneAPI + :autoclasstoc: diff --git a/docs/api/scene/scene_api.rst b/docs/api/scene/scene_api.rst deleted file mode 100644 index 96db0aaf..00000000 --- a/docs/api/scene/scene_api.rst +++ /dev/null @@ -1,5 +0,0 @@ -Scene API ---------- - -.. autoclass:: py123d.api.SceneAPI - :autoclasstoc: diff --git a/docs/conf.py b/docs/conf.py index ad4d0791..f5806570 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -64,7 +64,7 @@ autodoc_default_options = { "members": True, "special-members": False, - "private-members": True, + "private-members": False, "inherited-members": True, "undoc-members": True, "member-order": "bysource", diff --git a/examples/01_viser.py b/examples/01_viser.py index 9aafb3e5..5394a828 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -5,11 +5,11 @@ if __name__ == "__main__": # splits = ["kitti360_train"] - splits = ["nuscenes-mini_val", "nuscenes-mini_train"] + # splits = ["nuscenes-mini_val", "nuscenes-mini_train"] # splits = ["nuplan-mini_test", "nuplan-mini_train", "nuplan-mini_val"] # splits = ["nuplan_private_test"] # splits = ["carla_test"] - # splits = ["wopd_val"] + splits = ["wopd_val"] # splits = ["av2-sensor_train"] # splits = ["pandaset_test", "pandaset_val", "pandaset_train"] # log_names = ["2021.08.24.13.12.55_veh-45_00386_00472"] diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 38d47ffa..023a84ce 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -303,7 +303,7 @@ def _extract_wopd_ego_state(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) rear_axle_se3=rear_axle_pose, dynamic_state_se3=dynamic_state_se3, vehicle_parameters=vehicle_parameters, - time_point=None, + timepoint=None, ) diff --git a/src/py123d/datatypes/map_objects/map_objects.py b/src/py123d/datatypes/map_objects/map_objects.py index 2f33b17c..b0d16070 100644 --- a/src/py123d/datatypes/map_objects/map_objects.py +++ b/src/py123d/datatypes/map_objects/map_objects.py @@ -143,7 +143,7 @@ def speed_limit_mps(self) -> Optional[float]: return self._speed_limit_mps @property - def trimesh(self) -> Trimesh: + def trimesh_mesh(self) -> Trimesh: return get_trimesh_from_boundaries(self.left_boundary, self.right_boundary) @@ -251,7 +251,7 @@ def successors(self) -> List[LaneGroup]: return successors @property - def trimesh(self) -> Trimesh: + def trimesh_mesh(self) -> Trimesh: return get_trimesh_from_boundaries(self.left_boundary, self.right_boundary) diff --git a/src/py123d/datatypes/time/__init__.py b/src/py123d/datatypes/time/__init__.py index e69de29b..36e8f0dd 100644 --- a/src/py123d/datatypes/time/__init__.py +++ b/src/py123d/datatypes/time/__init__.py @@ -0,0 +1 @@ +from py123d.datatypes.time.time_point import TimePoint, TimeDuration diff --git a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml index b39dae96..f1b4632c 100644 --- a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml @@ -30,7 +30,7 @@ wopd_dataset: pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs - include_lidars: false + include_lidars: true lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: From e0893ea86dfedc76a04577e76551c26a984db099 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 9 Nov 2025 22:14:34 +0100 Subject: [PATCH 09/50] Improve tests and documentation of detection modalities. --- .../detections/01_box_detections.rst | 4 + .../detections/02_traffic_lights.rst | 6 +- src/py123d/common/utils/enums.py | 4 +- .../conversion/log_writer/arrow_log_writer.py | 4 +- src/py123d/conversion/registry/__init__.py | 23 ++ .../datatypes/detections/box_detections.py | 241 ++++++++++++------ .../detections/traffic_light_detections.py | 80 +++++- .../datatypes/vehicle_state/ego_state.py | 2 +- .../visualization/matplotlib/observation.py | 2 +- .../detections/test_box_detections.py | 48 ++-- tests/unit/geometry/test_polyline.py | 8 +- 11 files changed, 306 insertions(+), 116 deletions(-) diff --git a/docs/api/datatypes/detections/01_box_detections.rst b/docs/api/datatypes/detections/01_box_detections.rst index 2265db86..3d5e2829 100644 --- a/docs/api/datatypes/detections/01_box_detections.rst +++ b/docs/api/datatypes/detections/01_box_detections.rst @@ -3,16 +3,20 @@ Box Detections .. autoclass:: py123d.datatypes.detections.BoxDetectionWrapper :members: + :exclude-members: __init__ :autoclasstoc: .. autoclass:: py123d.datatypes.detections.BoxDetectionMetadata :members: + :exclude-members: __init__ :autoclasstoc: .. autoclass:: py123d.datatypes.detections.BoxDetectionSE2 :members: + :exclude-members: __init__ :autoclasstoc: .. autoclass:: py123d.datatypes.detections.BoxDetectionSE3 :members: + :exclude-members: __init__ :autoclasstoc: diff --git a/docs/api/datatypes/detections/02_traffic_lights.rst b/docs/api/datatypes/detections/02_traffic_lights.rst index f6abf953..e0c02778 100644 --- a/docs/api/datatypes/detections/02_traffic_lights.rst +++ b/docs/api/datatypes/detections/02_traffic_lights.rst @@ -2,15 +2,15 @@ Traffic Lights ^^^^^^^^^^^^^^ .. autoclass:: py123d.datatypes.detections.TrafficLightDetectionWrapper - :members: + :exclude-members: __init__ :autoclasstoc: .. autoclass:: py123d.datatypes.detections.TrafficLightDetection - :members: + :no-inherited-members: + :exclude-members: __init__ :autoclasstoc: - .. autoclass:: py123d.datatypes.detections.TrafficLightStatus :members: :no-inherited-members: diff --git a/src/py123d/common/utils/enums.py b/src/py123d/common/utils/enums.py index af713a1a..1da767ab 100644 --- a/src/py123d/common/utils/enums.py +++ b/src/py123d/common/utils/enums.py @@ -1,6 +1,6 @@ from __future__ import annotations -from enum import Enum +import enum from pyparsing import Union @@ -13,7 +13,7 @@ def __get__(self, obj, owner): return self.f(owner) -class SerialIntEnum(Enum): +class SerialIntEnum(enum.Enum): def __int__(self) -> int: return self.value diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index f08bde27..96a6b545 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -193,10 +193,10 @@ def write( box_detection_num_lidar_points = [] for box_detection in box_detections: - box_detection_state.append(box_detection.bounding_box) + box_detection_state.append(box_detection.bounding_box_se2) box_detection_token.append(box_detection.metadata.track_token) box_detection_label.append(int(box_detection.metadata.label)) - box_detection_velocity.append(box_detection.velocity) + box_detection_velocity.append(box_detection.velocity_3d) box_detection_num_lidar_points.append(box_detection.metadata.num_lidar_points) # Add to record batch data diff --git a/src/py123d/conversion/registry/__init__.py b/src/py123d/conversion/registry/__init__.py index e69de29b..ac702849 100644 --- a/src/py123d/conversion/registry/__init__.py +++ b/src/py123d/conversion/registry/__init__.py @@ -0,0 +1,23 @@ +from py123d.conversion.registry.box_detection_label_registry import ( + BOX_DETECTION_LABEL_REGISTRY, + AV2SensorBoxDetectionLabel, + BoxDetectionLabel, + DefaultBoxDetectionLabel, + KITTI360BoxDetectionLabel, + NuPlanBoxDetectionLabel, + NuScenesBoxDetectionLabel, + PandasetBoxDetectionLabel, + WOPDBoxDetectionLabel, +) +from py123d.conversion.registry.lidar_index_registry import ( + LIDAR_INDEX_REGISTRY, + AVSensorLiDARIndex, + CARLALiDARIndex, + DefaultLiDARIndex, + Kitti360LiDARIndex, + LiDARIndex, + NuPlanLiDARIndex, + NuScenesLiDARIndex, + PandasetLiDARIndex, + WOPDLiDARIndex, +) diff --git a/src/py123d/datatypes/detections/box_detections.py b/src/py123d/datatypes/detections/box_detections.py index cc14f03d..a734f411 100644 --- a/src/py123d/datatypes/detections/box_detections.py +++ b/src/py123d/datatypes/detections/box_detections.py @@ -1,139 +1,233 @@ +from __future__ import annotations + from dataclasses import dataclass from functools import cached_property from typing import List, Optional, Union import shapely -from py123d.conversion.registry.box_detection_label_registry import BoxDetectionLabel, DefaultBoxDetectionLabel -from py123d.datatypes.time.time_point import TimePoint +from py123d.conversion.registry import BoxDetectionLabel, DefaultBoxDetectionLabel +from py123d.datatypes.time import TimePoint from py123d.geometry import BoundingBoxSE2, BoundingBoxSE3, OccupancyMap2D, PoseSE2, PoseSE3, Vector2D, Vector3D -@dataclass class BoxDetectionMetadata: - """Store metadata for a detected bounding box. - - Examples - -------- - - .. code-block:: python + """Stores data about the box detection, including its label, track token, number of LiDAR points, and timepoint.""" + + __slots__ = ("_label", "_track_token", "_num_lidar_points", "_timepoint") + + def __init__( + self, + label: BoxDetectionLabel, + track_token: str, + num_lidar_points: Optional[int] = None, + timepoint: Optional[TimePoint] = None, + ) -> None: + """Initialize a BoxDetectionMetadata instance. + + :param label: The label of the detection. + :param track_token: The track token of the detection. + :param num_lidar_points: The number of LiDAR points, defaults to None. + :param timepoint: The timepoint of the detection, defaults to None. + """ + self._label = label + self._track_token = track_token + self._num_lidar_points = num_lidar_points + self._timepoint = timepoint - from mymodule import my_function + @property + def label(self) -> BoxDetectionLabel: + """The :class:`~py123d.datatypes.detections.BoxDetectionLabel`, from the original dataset's label set.""" + return self._label - result = my_function() - print(result) + @property + def track_token(self) -> str: + """The unique track token of the detection, consistent across frames.""" + return self._track_token - """ + @property + def num_lidar_points(self) -> Optional[int]: + """Optionally, the number of LiDAR points associated with the detection.""" + return self._num_lidar_points - label: BoxDetectionLabel - track_token: str - num_lidar_points: Optional[int] = None - timepoint: Optional[TimePoint] = None + @property + def timepoint(self) -> Optional[TimePoint]: + """Optionally, the :class:`~py123d.datatypes.time.TimePoint` of the detection.""" + return self._timepoint @property def default_label(self) -> DefaultBoxDetectionLabel: - """The default label of the detection. - - :return: The default label. + """The unified :class:`~py123d.conversion.registry.DefaultBoxDetectionLabel` + corresponding to the detection's label. """ return self.label.to_default() -@dataclass class BoxDetectionSE2: - """Store a 2D bounding box detection. - - Example: - >>> from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel - >>> from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE2 - >>> from py123d.geometry import BoundingBoxSE2, StateSE2, Vector2D - >>> metadata = BoxDetectionMetadata( - ... label=DefaultBoxDetectionLabel.VEHICLE, - ... track_token="track_123", - ... ) - >>> bounding_box = BoundingBoxSE2( - ... center=StateSE2(x=0.0, y=0.0, yaw=0.0), - ... length=4.0, - ... width=2.0, - ... ) - >>> detection = BoxDetectionSE2( - ... metadata=metadata, - ... bounding_box_se2=bounding_box, - ... velocity=Vector2D(x=1.0, y=0.0), - ... ) - """ - - metadata: BoxDetectionMetadata - bounding_box_se2: BoundingBoxSE2 - velocity: Optional[Vector2D] = None + """Detected, tracked, and oriented bounding box 2D space.""" + + __slots__ = ("_metadata", "_bounding_box_se2", "_velocity_2d") + + def __init__( + self, + metadata: BoxDetectionMetadata, + bounding_box_se2: BoundingBoxSE2, + velocity_2d: Optional[Vector2D] = None, + ) -> None: + """Initialize a BoxDetectionSE2 instance. + + :param metadata: The :class:`BoxDetectionMetadata` of the detection. + :param bounding_box_se2: The :class:`~py123d.datatypes.geometry.BoundingBoxSE2` of the detection. + :param velocity: Optionally, a :class:`~py123d.geometry.Vector2D` representing the velocity. + """ + + self._metadata = metadata + self._bounding_box_se2 = bounding_box_se2 + self._velocity_2d = velocity_2d @property - def shapely_polygon(self) -> shapely.geometry.Polygon: - return self.bounding_box_se2.shapely_polygon + def metadata(self) -> BoxDetectionMetadata: + """The :class:`BoxDetectionMetadata` of the detection.""" + return self._metadata + + @property + def bounding_box_se2(self) -> BoundingBoxSE2: + """The :class:`~py123d.geometry.BoundingBoxSE2` of the detection.""" + return self._bounding_box_se2 + + @property + def velocity_2d(self) -> Optional[Vector2D]: + """The :class:`~py123d.geometry.Vector2D` representing the velocity.""" + return self._velocity_2d @property - def center(self) -> PoseSE2: + def center_se2(self) -> PoseSE2: + """The :class:`~py123d.geometry.PoseSE2` representing the center of the bounding box.""" return self.bounding_box_se2.center @property - def bounding_box(self) -> BoundingBoxSE2: - return self.bounding_box_se2 + def shapely_polygon(self) -> shapely.geometry.Polygon: + """The shapely polygon of the bounding box in 2D space.""" + return self.bounding_box_se2.shapely_polygon + + @property + def box_detection_se2(self) -> BoxDetectionSE2: + """Returns self to maintain interface consistency.""" + return self @dataclass class BoxDetectionSE3: + """Detected, tracked, and oriented bounding box 3D space.""" - metadata: BoxDetectionMetadata - bounding_box_se3: BoundingBoxSE3 - velocity: Optional[Vector3D] = None + __slots__ = ("_metadata", "_bounding_box_se3", "_velocity") - @property - def shapely_polygon(self) -> shapely.geometry.Polygon: - return self.bounding_box_se3.shapely_polygon + def __init__( + self, + metadata: BoxDetectionMetadata, + bounding_box_se3: BoundingBoxSE3, + velocity: Optional[Vector3D] = None, + ) -> None: + """Initialize a BoxDetectionSE3 instance. - @property - def center(self) -> PoseSE3: - return self.bounding_box_se3.center + :param metadata: The :class:`BoxDetectionMetadata` of the detection. + :param bounding_box_se3: The :class:`~py123d.datatypes.geometry.BoundingBoxSE3` of the detection. + :param velocity: Optionally, a :class:`~py123d.geometry.Vector3D` representing the velocity. + """ + self._metadata = metadata + self._bounding_box_se3 = bounding_box_se3 + self._velocity = velocity @property - def center_se3(self) -> PoseSE3: - return self.bounding_box_se3.center_se3 + def metadata(self) -> BoxDetectionMetadata: + """The :class:`BoxDetectionMetadata` of the detection.""" + return self._metadata @property - def bounding_box(self) -> BoundingBoxSE3: - return self.bounding_box_se3 + def bounding_box_se3(self) -> BoundingBoxSE3: + """The :class:`~py123d.geometry.BoundingBoxSE3` of the detection.""" + return self._bounding_box_se3 @property def bounding_box_se2(self) -> BoundingBoxSE2: + """The SE2 projection :class:`~py123d.geometry.BoundingBoxSE2` of the SE3 bounding box.""" return self.bounding_box_se3.bounding_box_se2 + @property + def velocity_3d(self) -> Optional[Vector3D]: + """The :class:`~py123d.geometry.Vector3D` representing the velocity.""" + return self._velocity + + @property + def velocity_2d(self) -> Optional[Vector2D]: + """The 2D projection :class:`~py123d.geometry.Vector2D` of the 3D velocity.""" + return Vector2D(self._velocity.x, self._velocity.y) if self._velocity else None + + @property + def center_se3(self) -> PoseSE3: + """The :class:`~py123d.geometry.PoseSE3` representing the center of the bounding box.""" + return self.bounding_box_se3.center_se3 + + @property + def center_se2(self) -> PoseSE2: + """The :class:`~py123d.geometry.PoseSE2` representing the center of the SE2 bounding box.""" + return self.bounding_box_se2.center_se2 + @property def box_detection_se2(self) -> BoxDetectionSE2: + """The :class:`~py123d.datatypes.detections.BoxDetectionSE2` projection of this SE3 box detection.""" return BoxDetectionSE2( metadata=self.metadata, bounding_box_se2=self.bounding_box_se2, - velocity=Vector2D(self.velocity.x, self.velocity.y) if self.velocity else None, + velocity_2d=Vector2D(self.velocity_3d.x, self.velocity_3d.y) if self.velocity_3d else None, ) + @property + def shapely_polygon(self) -> shapely.geometry.Polygon: + """The shapely polygon of the bounding box in 2D space.""" + return self.bounding_box_se3.shapely_polygon + BoxDetection = Union[BoxDetectionSE2, BoxDetectionSE3] -@dataclass class BoxDetectionWrapper: - box_detections: List[BoxDetection] + def __init__(self, box_detections: List[BoxDetection]) -> None: + """Initialize a BoxDetectionWrapper instance. + + :param box_detections: A list of :class:`BoxDetection` instances. + """ + self._box_detections = box_detections + + @property + def box_detections(self) -> List[BoxDetection]: + """List of individual :class:`BoxDetectionSE2` or :class:`BoxDetectionSE3`.""" + return self._box_detections def __getitem__(self, index: int) -> BoxDetection: - return self.box_detections[index] + """Retrieve a box detection by its index. + + :param index: The index of the box detection. + :return: The box detection at the given index. + """ + return self._box_detections[index] def __len__(self) -> int: - return len(self.box_detections) + """Number of box detections.""" + return len(self._box_detections) def __iter__(self): - return iter(self.box_detections) + """Iterator over box detections.""" + return iter(self._box_detections) + + def get_detection_by_track_token(self, track_token: str) -> Optional[Union[BoxDetectionSE2, BoxDetectionSE3]]: + """Retrieve a box detection by its track token. + + :param track_token: The track token of the box detection. + :return: The box detection with the given track token, or None if not found. + """ - def get_detection_by_track_token(self, track_token: str) -> Optional[BoxDetection]: box_detection: Optional[BoxDetection] = None for detection in self.box_detections: if detection.metadata.track_token == track_token: @@ -142,7 +236,8 @@ def get_detection_by_track_token(self, track_token: str) -> Optional[BoxDetectio return box_detection @cached_property - def occupancy_map(self) -> OccupancyMap2D: + def occupancy_map_2d(self) -> OccupancyMap2D: + """The :class:`~py123d.geometry.OccupancyMap2D` representing the 2D occupancy of all box detections.""" ids = [detection.metadata.track_token for detection in self.box_detections] - geometries = [detection.bounding_box.shapely_polygon for detection in self.box_detections] + geometries = [detection.shapely_polygon for detection in self.box_detections] return OccupancyMap2D(geometries=geometries, ids=ids) diff --git a/src/py123d/datatypes/detections/traffic_light_detections.py b/src/py123d/datatypes/detections/traffic_light_detections.py index c4f1c57a..d837a35a 100644 --- a/src/py123d/datatypes/detections/traffic_light_detections.py +++ b/src/py123d/datatypes/detections/traffic_light_detections.py @@ -1,45 +1,107 @@ -from dataclasses import dataclass from typing import List, Optional from py123d.common.utils.enums import SerialIntEnum -from py123d.datatypes.time.time_point import TimePoint +from py123d.datatypes.time import TimePoint class TrafficLightStatus(SerialIntEnum): """ - Enum for TrafficLightStatus. + Enum for that represents the status of a traffic light. """ GREEN = 0 + """Green light is on.""" + YELLOW = 1 + """Yellow light is on.""" + RED = 2 + """Red light is on.""" + OFF = 3 + """Traffic light is off.""" + UNKNOWN = 4 + """Traffic light status is unknown.""" -@dataclass class TrafficLightDetection: + """ + Single traffic light detection if a lane, that includes the lane id, status (green, yellow, red, off, unknown), + and optional timepoint of the detection. + """ + + __slots__ = ("_lane_id", "_status", "_timepoint") + + def __init__(self, lane_id: int, status: TrafficLightStatus, timepoint: Optional[TimePoint] = None) -> None: + """Initialize a TrafficLightDetection instance. + + :param lane_id: The lane id associated with the traffic light detection. + :param status: The status of the traffic light (green, yellow, red, off, unknown). + :param timepoint: The optional timepoint of the detection. + """ - lane_id: int - status: TrafficLightStatus - timepoint: Optional[TimePoint] = None + self._lane_id = lane_id + self._status = status + self._timepoint = timepoint + + @property + def lane_id(self) -> int: + """The lane id associated with the traffic light detection.""" + return self._lane_id + + @property + def status(self) -> TrafficLightStatus: + """The :class:`TrafficLightStatus` of the traffic light detection.""" + return self._status + + @property + def timepoint(self) -> Optional[TimePoint]: + """The optional :class:`~py123d.datatypes.time.TimePoint` of the traffic light detection.""" + return self._timepoint -@dataclass class TrafficLightDetectionWrapper: + """The TrafficLightDetectionWrapper is a container for multiple traffic light detections. + It provides methods to access individual detections as well as to retrieve a detection by lane id. + The wrapper is is used in to read and write traffic light detections from/to logs. + """ - traffic_light_detections: List[TrafficLightDetection] + def __init__(self, traffic_light_detections: List[TrafficLightDetection]) -> None: + """Initialize a TrafficLightDetectionWrapper instance. + + :param traffic_light_detections: List of :class:`TrafficLightDetection`. + """ + self._traffic_light_detections = traffic_light_detections + + @property + def traffic_light_detections(self) -> List[TrafficLightDetection]: + """List of individual :class:`TrafficLightDetection`.""" + return self._traffic_light_detections def __getitem__(self, index: int) -> TrafficLightDetection: + """Retrieve a traffic light detection by its index. + + :param index: The index of the traffic light detection. + :return: :class:`TrafficLightDetection` at the given index. + """ return self.traffic_light_detections[index] def __len__(self) -> int: + """ + :return: The number of traffic light detections. + """ return len(self.traffic_light_detections) def __iter__(self): return iter(self.traffic_light_detections) def get_detection_by_lane_id(self, lane_id: int) -> Optional[TrafficLightDetection]: + """Retrieve a traffic light detection by its lane id. + + :param lane_id: The lane id to search for. + :return: The traffic light detection for the given lane id, or None if not found. + """ traffic_light_detection: Optional[TrafficLightDetection] = None for detection in self.traffic_light_detections: if int(detection.lane_id) == int(lane_id): diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index 6d5e6ec2..a7ffae02 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -282,7 +282,7 @@ def box_detection_se2(self) -> BoxDetectionSE2: num_lidar_points=None, ), bounding_box_se2=self.bounding_box, - velocity=self.dynamic_state_se2.velocity, + velocity_2d=self.dynamic_state_se2.velocity, ) @property diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index 1dc2b32a..85e0628d 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -86,7 +86,7 @@ def add_box_detections_to_ax(ax: plt.Axes, box_detections: BoxDetectionWrapper) # if box_detection.metadata.detection_type == DetectionType.GENERIC_OBJECT: # continue plot_config = BOX_DETECTION_CONFIG[box_detection.metadata.default_label] - add_bounding_box_to_ax(ax, box_detection.bounding_box, plot_config) + add_bounding_box_to_ax(ax, box_detection.bounding_box_se2, plot_config) def add_box_future_detections_to_ax(ax: plt.Axes, scene: SceneAPI, iteration: int) -> None: diff --git a/tests/unit/datatypes/detections/test_box_detections.py b/tests/unit/datatypes/detections/test_box_detections.py index 32580a69..52efe229 100644 --- a/tests/unit/datatypes/detections/test_box_detections.py +++ b/tests/unit/datatypes/detections/test_box_detections.py @@ -108,22 +108,22 @@ def test_initialization(self): box_detection = BoxDetectionSE2( metadata=self.metadata, bounding_box_se2=self.bounding_box_se2, - velocity=self.velocity, + velocity_2d=self.velocity, ) self.assertIsInstance(box_detection, BoxDetectionSE2) self.assertEqual(box_detection.metadata, self.metadata) self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se2) - self.assertIsNone(box_detection.velocity) + self.assertIsNone(box_detection.velocity_2d) def test_properties(self): box_detection = BoxDetectionSE2( metadata=self.metadata, bounding_box_se2=self.bounding_box_se2, - velocity=self.velocity, + velocity_2d=self.velocity, ) self.assertEqual(box_detection.shapely_polygon, self.bounding_box_se2.shapely_polygon) - self.assertEqual(box_detection.center, self.bounding_box_se2.center) - self.assertEqual(box_detection.bounding_box, self.bounding_box_se2) + self.assertEqual(box_detection.center_se2, self.bounding_box_se2.center) + self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se2) def test_optional_velocity(self): box_detection_no_velo = BoxDetectionSE2( @@ -131,15 +131,15 @@ def test_optional_velocity(self): bounding_box_se2=self.bounding_box_se2, ) self.assertIsInstance(box_detection_no_velo, BoxDetectionSE2) - self.assertIsNone(box_detection_no_velo.velocity) + self.assertIsNone(box_detection_no_velo.velocity_2d) box_detection_velo = BoxDetectionSE2( metadata=self.metadata, bounding_box_se2=self.bounding_box_se2, - velocity=Vector2D(x=1.0, y=0.0), + velocity_2d=Vector2D(x=1.0, y=0.0), ) self.assertIsInstance(box_detection_velo, BoxDetectionSE2) - self.assertEqual(box_detection_velo.velocity, Vector2D(x=1.0, y=0.0)) + self.assertEqual(box_detection_velo.velocity_2d, Vector2D(x=1.0, y=0.0)) class TestBoxBoxDetectionSE3(unittest.TestCase): @@ -163,7 +163,7 @@ def test_initialization(self): self.assertIsInstance(box_detection, BoxDetectionSE3) self.assertEqual(box_detection.metadata, self.metadata) self.assertEqual(box_detection.bounding_box_se3, self.bounding_box_se3) - self.assertEqual(box_detection.velocity, self.velocity) + self.assertEqual(box_detection.velocity_3d, self.velocity) def test_properties(self): box_detection = BoxDetectionSE3( @@ -172,10 +172,12 @@ def test_properties(self): velocity=self.velocity, ) self.assertEqual(box_detection.shapely_polygon, self.bounding_box_se3.shapely_polygon) - self.assertEqual(box_detection.center, self.bounding_box_se3.center_se3) self.assertEqual(box_detection.center_se3, self.bounding_box_se3.center_se3) - self.assertEqual(box_detection.bounding_box, self.bounding_box_se3) + self.assertEqual(box_detection.center_se2, self.bounding_box_se3.center_se2) + self.assertEqual(box_detection.bounding_box_se3, self.bounding_box_se3) self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se3.bounding_box_se2) + self.assertEqual(box_detection.velocity_3d, self.velocity) + self.assertEqual(box_detection.velocity_2d, self.velocity.vector_2d) def test_box_detection_se2_conversion(self): box_detection = BoxDetectionSE3( @@ -187,13 +189,13 @@ def test_box_detection_se2_conversion(self): self.assertIsInstance(box_detection_se2, BoxDetectionSE2) self.assertEqual(box_detection_se2.metadata, self.metadata) self.assertEqual(box_detection_se2.bounding_box_se2, self.bounding_box_se3.bounding_box_se2) - self.assertEqual(box_detection_se2.velocity, Vector2D(x=1.0, y=0.0)) + self.assertEqual(box_detection_se2.velocity_2d, Vector2D(x=1.0, y=0.0)) def test_box_detection_se3_conversion(self): box_detection_se2 = BoxDetectionSE2( metadata=self.metadata, bounding_box_se2=self.bounding_box_se3.bounding_box_se2, - velocity=Vector2D(x=1.0, y=0.0), + velocity_2d=Vector2D(x=1.0, y=0.0), ) box_detection_se3 = BoxDetectionSE3( metadata=box_detection_se2.metadata, @@ -203,13 +205,13 @@ def test_box_detection_se3_conversion(self): self.assertIsInstance(box_detection_se3, BoxDetectionSE3) self.assertEqual(box_detection_se3.metadata, box_detection_se2.metadata) self.assertEqual(box_detection_se3.bounding_box_se3, self.bounding_box_se3) - self.assertEqual(box_detection_se3.velocity, Vector2D(x=1.0, y=0.0)) + self.assertEqual(box_detection_se3.velocity_3d, Vector2D(x=1.0, y=0.0)) box_detection_se3_converted = box_detection_se3.box_detection_se2 self.assertIsInstance(box_detection_se3_converted, BoxDetectionSE2) self.assertEqual(box_detection_se3_converted.metadata, box_detection_se2.metadata) self.assertEqual(box_detection_se3_converted.bounding_box_se2, box_detection_se2.bounding_box_se2) - self.assertEqual(box_detection_se3_converted.velocity, box_detection_se2.velocity) + self.assertEqual(box_detection_se3_converted.velocity_2d, box_detection_se2.velocity_2d) def test_optional_velocity(self): box_detection_no_velo = BoxDetectionSE3( @@ -217,7 +219,7 @@ def test_optional_velocity(self): bounding_box_se3=self.bounding_box_se3, ) self.assertIsInstance(box_detection_no_velo, BoxDetectionSE3) - self.assertIsNone(box_detection_no_velo.velocity) + self.assertIsNone(box_detection_no_velo.velocity_3d) box_detection_velo = BoxDetectionSE3( metadata=self.metadata, @@ -225,7 +227,7 @@ def test_optional_velocity(self): velocity=Vector3D(x=1.0, y=0.0, z=0.0), ) self.assertIsInstance(box_detection_velo, BoxDetectionSE3) - self.assertEqual(box_detection_velo.velocity, Vector3D(x=1.0, y=0.0, z=0.0)) + self.assertEqual(box_detection_velo.velocity_3d, Vector3D(x=1.0, y=0.0, z=0.0)) class TestBoxDetectionWrapper(unittest.TestCase): @@ -257,7 +259,7 @@ def setUp(self): length=4.0, width=2.0, ), - velocity=Vector2D(x=1.0, y=0.0), + velocity_2d=Vector2D(x=1.0, y=0.0), ) self.box_detection2 = BoxDetectionSE2( metadata=self.metadata2, @@ -266,7 +268,7 @@ def setUp(self): length=1.0, width=0.5, ), - velocity=Vector2D(x=0.5, y=0.5), + velocity_2d=Vector2D(x=0.5, y=0.5), ) self.box_detection3 = BoxDetectionSE3( metadata=self.metadata3, @@ -333,7 +335,7 @@ def test_get_detection_by_track_token_empty_wrapper(self): def test_occupancy_map(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) - occupancy_map = wrapper.occupancy_map + occupancy_map = wrapper.occupancy_map_2d self.assertIsNotNone(occupancy_map) self.assertEqual(len(occupancy_map.geometries), 2) self.assertEqual(len(occupancy_map.ids), 2) @@ -342,13 +344,13 @@ def test_occupancy_map(self): def test_occupancy_map_cached(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) - occupancy_map1 = wrapper.occupancy_map - occupancy_map2 = wrapper.occupancy_map + occupancy_map1 = wrapper.occupancy_map_2d + occupancy_map2 = wrapper.occupancy_map_2d self.assertIs(occupancy_map1, occupancy_map2) def test_occupancy_map_empty(self): wrapper = BoxDetectionWrapper(box_detections=[]) - occupancy_map = wrapper.occupancy_map + occupancy_map = wrapper.occupancy_map_2d self.assertIsNotNone(occupancy_map) self.assertEqual(len(occupancy_map.geometries), 0) self.assertEqual(len(occupancy_map.ids), 0) diff --git a/tests/unit/geometry/test_polyline.py b/tests/unit/geometry/test_polyline.py index 1c5b24f7..c001d150 100644 --- a/tests/unit/geometry/test_polyline.py +++ b/tests/unit/geometry/test_polyline.py @@ -236,8 +236,12 @@ def test_from_array(self): def test_from_array_invalid_shape(self): """Test creating Polyline3D from invalid array shape.""" - array = np.array([[0.0, 0.0], [1.0, 1.0]], dtype=np.float64) - with self.assertRaises(AssertionError): + array = np.array([[0.0, 0.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0]], dtype=np.float64) + with self.assertRaises(ValueError): + Polyline3D.from_array(array) + + array = np.array([[0.0], [1.0]], dtype=np.float64) + with self.assertRaises(ValueError): Polyline3D.from_array(array) def test_array_property(self): From 924a7f10f31c206bd5a9f860b2d2318740eeacd1 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 9 Nov 2025 23:05:41 +0100 Subject: [PATCH 10/50] Add test and expand documentation for pinhole camera --- .../datatypes/sensors/01_pinhole_camera.rst | 26 +- src/py123d/common/utils/mixin.py | 2 + .../datasets/av2/av2_sensor_converter.py | 8 +- .../datasets/kitti360/kitti360_converter.py | 8 +- .../datasets/nuplan/nuplan_converter.py | 10 +- .../datasets/nuscenes/nuscenes_converter.py | 8 +- .../datasets/pandaset/pandaset_converter.py | 8 +- .../datasets/wopd/wopd_converter.py | 8 +- src/py123d/datatypes/metadata/log_metadata.py | 6 +- src/py123d/datatypes/sensors/__init__.py | 2 +- .../datatypes/sensors/pinhole_camera.py | 224 ++++++-- tests/unit/datatypes/maps/__init__.py | 0 .../datatypes/sensors/test_pinhole_camera.py | 503 ++++++++++++++++++ 13 files changed, 750 insertions(+), 63 deletions(-) delete mode 100644 tests/unit/datatypes/maps/__init__.py create mode 100644 tests/unit/datatypes/sensors/test_pinhole_camera.py diff --git a/docs/api/datatypes/sensors/01_pinhole_camera.rst b/docs/api/datatypes/sensors/01_pinhole_camera.rst index 7e6fadea..3603edbc 100644 --- a/docs/api/datatypes/sensors/01_pinhole_camera.rst +++ b/docs/api/datatypes/sensors/01_pinhole_camera.rst @@ -1,26 +1,46 @@ Pinhole Camera ^^^^^^^^^^^^^^^ -.. autoclass:: py123d.datatypes.sensors.PinholeCameraType - :no-inherited-members: +Pinhole Camera Data +------------------- .. autoclass:: py123d.datatypes.sensors.PinholeCamera :members: + :exclude-members: __init__ :autoclasstoc: +Pinhole Metadata +---------------- -.. autoclass:: py123d.datatypes.sensors.PinholeCameraMetadata +.. autoclass:: py123d.datatypes.sensors.PinholeMetadata :members: + :exclude-members: __init__ :autoclasstoc: +Pinhole Intrinsics +------------------ + .. autoclass:: py123d.datatypes.sensors.PinholeIntrinsics :members: + :exclude-members: __init__ :no-inherited-members: :autoclasstoc: +Pinhole Distortion +------------------ + .. autoclass:: py123d.datatypes.sensors.PinholeDistortion :members: + :exclude-members: __init__ :no-inherited-members: :autoclasstoc: + + +Pinhole Camera Types +-------------------- + +.. autoclass:: py123d.datatypes.sensors.PinholeCameraType + :no-inherited-members: + :exclude-members: __init__, __new__ diff --git a/src/py123d/common/utils/mixin.py b/src/py123d/common/utils/mixin.py index 798a1aef..5f0d21fe 100644 --- a/src/py123d/common/utils/mixin.py +++ b/src/py123d/common/utils/mixin.py @@ -9,6 +9,8 @@ class ArrayMixin: """Mixin class for object entities.""" + __slots__ = () + @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> ArrayMixin: """Create an instance from a NumPy array.""" diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 969c8df5..baaae942 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -23,10 +23,10 @@ from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( - PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, + PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 @@ -183,16 +183,16 @@ def _get_av2_sensor_map_metadata(split: str, source_log_path: Path) -> MapMetada def _get_av2_pinhole_camera_metadata( source_log_path: Path, dataset_converter_config: DatasetConverterConfig -) -> Dict[PinholeCameraType, PinholeCameraMetadata]: +) -> Dict[PinholeCameraType, PinholeMetadata]: - pinhole_camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} + pinhole_camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = {} if dataset_converter_config.include_pinhole_cameras: intrinsics_file = source_log_path / "calibration" / "intrinsics.feather" intrinsics_df = pd.read_feather(intrinsics_file) for _, row in intrinsics_df.iterrows(): row = row.to_dict() camera_type = AV2_CAMERA_TYPE_MAPPING[row["sensor_name"]] - pinhole_camera_metadata[camera_type] = PinholeCameraMetadata( + pinhole_camera_metadata[camera_type] = PinholeMetadata( camera_type=camera_type, width=row["width_px"], height=row["height_px"], diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 500b117a..91c94d71 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -37,10 +37,10 @@ FisheyeMEIProjection, LiDARMetadata, LiDARType, - PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, + PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 @@ -308,9 +308,9 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: def _get_kitti360_pinhole_camera_metadata( kitti360_folders: Dict[str, Path], dataset_converter_config: DatasetConverterConfig, -) -> Dict[PinholeCameraType, PinholeCameraMetadata]: +) -> Dict[PinholeCameraType, PinholeMetadata]: - pinhole_cam_metadatas: Dict[PinholeCameraType, PinholeCameraMetadata] = {} + pinhole_cam_metadatas: Dict[PinholeCameraType, PinholeMetadata] = {} if dataset_converter_config.include_pinhole_cameras: persp = kitti360_folders[DIR_CALIB] / "perspective.txt" assert persp.exists() @@ -329,7 +329,7 @@ def _get_kitti360_pinhole_camera_metadata( persp_result[f"image_{cam_id}"]["distortion"] = [float(x) for x in value.split()] for pcam_type, pcam_name in KITTI360_PINHOLE_CAMERA_TYPES.items(): - pinhole_cam_metadatas[pcam_type] = PinholeCameraMetadata( + pinhole_cam_metadatas[pcam_type] = PinholeMetadata( camera_type=pcam_type, width=persp_result[pcam_name]["wh"][0], height=persp_result[pcam_name]["wh"][1], diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index 873d3ec7..93538cf2 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -32,10 +32,10 @@ from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( - PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, + PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 @@ -242,9 +242,9 @@ def _get_nuplan_camera_metadata( source_log_path: Path, nuplan_sensor_root: Path, dataset_converter_config: DatasetConverterConfig, -) -> Dict[PinholeCameraType, PinholeCameraMetadata]: +) -> Dict[PinholeCameraType, PinholeMetadata]: - def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeCameraMetadata: + def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeMetadata: cam = list(get_cameras(source_log_path, [str(NUPLAN_CAMERA_MAPPING[camera_type].value)]))[0] intrinsics_camera_matrix = np.array(pickle.loads(cam.intrinsic), dtype=np.float64) # array of shape (3, 3) @@ -253,7 +253,7 @@ def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeCameraMetadat distortion_array = np.array(pickle.loads(cam.distortion), dtype=np.float64) # array of shape (5,) distortion = PinholeDistortion.from_array(distortion_array, copy=False) - return PinholeCameraMetadata( + return PinholeMetadata( camera_type=camera_type, width=cam.width, height=cam.height, @@ -261,7 +261,7 @@ def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeCameraMetadat distortion=distortion, ) - camera_metadata: Dict[str, PinholeCameraMetadata] = {} + camera_metadata: Dict[str, PinholeMetadata] = {} if dataset_converter_config.include_pinhole_cameras: log_name = source_log_path.stem for camera_type, nuplan_camera_type in NUPLAN_CAMERA_MAPPING.items(): diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index fb9e0844..9b0ba145 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -23,10 +23,10 @@ from py123d.datatypes.metadata import LogMetadata, MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( - PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, + PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 @@ -204,8 +204,8 @@ def _get_nuscenes_pinhole_camera_metadata( nusc: NuScenes, scene: Dict[str, Any], dataset_converter_config: DatasetConverterConfig, -) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} +) -> Dict[PinholeCameraType, PinholeMetadata]: + camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = {} if dataset_converter_config.include_pinhole_cameras: first_sample_token = scene["first_sample_token"] @@ -220,7 +220,7 @@ def _get_nuscenes_pinhole_camera_metadata( intrinsic = PinholeIntrinsics.from_camera_matrix(intrinsic_matrix) distortion = PinholeDistortion.from_array(np.zeros(5), copy=False) - camera_metadata[camera_type] = PinholeCameraMetadata( + camera_metadata[camera_type] = PinholeMetadata( camera_type=camera_type, width=cam_data["width"], height=cam_data["height"], diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index 12d97baa..c5e9e1a5 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -29,7 +29,7 @@ from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType, PinholeIntrinsics +from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType, PinholeIntrinsics, PinholeMetadata from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import get_pandaset_chrysler_pacifica_parameters @@ -155,9 +155,9 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: def _get_pandaset_camera_metadata( source_log_path: Path, dataset_config: DatasetConverterConfig -) -> Dict[PinholeCameraType, PinholeCameraMetadata]: +) -> Dict[PinholeCameraType, PinholeMetadata]: - camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} + camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = {} if dataset_config.include_pinhole_cameras: all_cameras_folder = source_log_path / "camera" @@ -171,7 +171,7 @@ def _get_pandaset_camera_metadata( assert intrinsics_file.exists(), f"Camera intrinsics file {intrinsics_file} does not exist." intrinsics_data = read_json(intrinsics_file) - camera_metadata[camera_type] = PinholeCameraMetadata( + camera_metadata[camera_type] = PinholeMetadata( camera_type=camera_type, width=1920, height=1080, diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 023a84ce..61e22166 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -28,10 +28,10 @@ from py123d.datatypes.sensors import ( LiDARMetadata, LiDARType, - PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, + PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 @@ -231,9 +231,9 @@ def _get_wopd_map_metadata(initial_frame: dataset_pb2.Frame, split: str) -> MapM def _get_wopd_camera_metadata( initial_frame: dataset_pb2.Frame, dataset_converter_config: DatasetConverterConfig -) -> Dict[PinholeCameraType, PinholeCameraMetadata]: +) -> Dict[PinholeCameraType, PinholeMetadata]: - camera_metadata_dict: Dict[PinholeCameraType, PinholeCameraMetadata] = {} + camera_metadata_dict: Dict[PinholeCameraType, PinholeMetadata] = {} if dataset_converter_config.pinhole_camera_store_option is not None: for calibration in initial_frame.context.camera_calibrations: @@ -244,7 +244,7 @@ def _get_wopd_camera_metadata( intrinsics = PinholeIntrinsics(fx=fx, fy=fy, cx=cx, cy=cy) distortion = PinholeDistortion(k1=k1, k2=k2, p1=p1, p2=p2, k3=k3) if camera_type in WOPD_CAMERA_TYPES.values(): - camera_metadata_dict[camera_type] = PinholeCameraMetadata( + camera_metadata_dict[camera_type] = PinholeMetadata( camera_type=camera_type, width=calibration.width, height=calibration.height, diff --git a/src/py123d/datatypes/metadata/log_metadata.py b/src/py123d/datatypes/metadata/log_metadata.py index 782e539a..03bd87fd 100644 --- a/src/py123d/datatypes/metadata/log_metadata.py +++ b/src/py123d/datatypes/metadata/log_metadata.py @@ -8,7 +8,7 @@ from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICameraMetadata, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType +from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType, PinholeMetadata from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters @@ -23,7 +23,7 @@ class LogMetadata: vehicle_parameters: Optional[VehicleParameters] = None box_detection_label_class: Optional[Type[BoxDetectionLabel]] = None - pinhole_camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = field(default_factory=dict) + pinhole_camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = field(default_factory=dict) fisheye_mei_camera_metadata: Dict[FisheyeMEICameraType, FisheyeMEICameraMetadata] = field(default_factory=dict) lidar_metadata: Dict[LiDARType, LiDARMetadata] = field(default_factory=dict) @@ -50,7 +50,7 @@ def from_dict(cls, data_dict: Dict) -> LogMetadata: # Pinhole Camera Metadata pinhole_camera_metadata = {} for key, value in data_dict.get("pinhole_camera_metadata", {}).items(): - pinhole_camera_metadata[PinholeCameraType.deserialize(key)] = PinholeCameraMetadata.from_dict(value) + pinhole_camera_metadata[PinholeCameraType.deserialize(key)] = PinholeMetadata.from_dict(value) data_dict["pinhole_camera_metadata"] = pinhole_camera_metadata # Fisheye MEI Camera Metadata diff --git a/src/py123d/datatypes/sensors/__init__.py b/src/py123d/datatypes/sensors/__init__.py index 6ce7fd0c..ace7984f 100644 --- a/src/py123d/datatypes/sensors/__init__.py +++ b/src/py123d/datatypes/sensors/__init__.py @@ -5,7 +5,7 @@ PinholeIntrinsics, PinholeDistortionIndex, PinholeDistortion, - PinholeCameraMetadata, + PinholeMetadata, ) from py123d.datatypes.sensors.fisheye_mei_camera import ( FisheyeMEICameraType, diff --git a/src/py123d/datatypes/sensors/pinhole_camera.py b/src/py123d/datatypes/sensors/pinhole_camera.py index c80a91bd..635b338b 100644 --- a/src/py123d/datatypes/sensors/pinhole_camera.py +++ b/src/py123d/datatypes/sensors/pinhole_camera.py @@ -1,53 +1,121 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from enum import IntEnum from typing import Any, Dict, Optional import numpy as np import numpy.typing as npt -from zmq import IntEnum from py123d.common.utils.enums import SerialIntEnum from py123d.common.utils.mixin import ArrayMixin -from py123d.geometry.pose import PoseSE3 +from py123d.geometry import PoseSE3 class PinholeCameraType(SerialIntEnum): + """Enumeration of pinhole camera types.""" PCAM_F0 = 0 + """Front camera.""" + PCAM_B0 = 1 + """Back camera.""" + PCAM_L0 = 2 + """Left camera, first from front to back.""" + PCAM_L1 = 3 + """Left camera, second from front to back.""" + PCAM_L2 = 4 + """Left camera, third from front to back.""" + PCAM_R0 = 5 + """Right camera, first from front to back.""" + PCAM_R1 = 6 + """Right camera, second from front to back.""" + PCAM_R2 = 7 + """Right camera, third from front to back.""" + PCAM_STEREO_L = 8 + """Left stereo camera.""" + PCAM_STEREO_R = 9 + """Right stereo camera.""" -@dataclass class PinholeCamera: + """Represents the recording of a pinhole camera including its metadata, image, and extrinsic pose.""" + + __slots__ = ("_metadata", "_image", "_extrinsic") + + def __init__( + self, + metadata: PinholeMetadata, + image: npt.NDArray[np.uint8], + extrinsic: PoseSE3, + ) -> None: + """Initialize a PinholeCamera instance. + + :param metadata: The metadata associated with the camera. + :param image: The image captured by the camera. + :param extrinsic: The extrinsic pose of the camera. + """ + self._metadata = metadata + self._image = image + self._extrinsic = extrinsic - metadata: PinholeCameraMetadata - image: npt.NDArray[np.uint8] - extrinsic: PoseSE3 + @property + def metadata(self) -> PinholeMetadata: + """The static :class:`PinholeMetadata` associated with the pinhole camera.""" + return self._metadata + + @property + def image(self) -> npt.NDArray[np.uint8]: + """The image captured by the pinhole camera, as a numpy array.""" + return self._image + + @property + def extrinsic(self) -> PoseSE3: + """The extrinsic :class:`~py123d.geometry.PoseSE3` of the pinhole camera, relative to the ego vehicle frame.""" + return self._extrinsic class PinholeIntrinsicsIndex(IntEnum): + """Enumeration of pinhole camera intrinsic parameters.""" FX = 0 + """Focal length in x direction.""" + FY = 1 + """Focal length in y direction.""" + CX = 2 + """Optical center x coordinate.""" + CY = 3 - SKEW = 4 # NOTE: not used, but added for completeness + """Optical center y coordinate.""" + + SKEW = 4 + """Skew coefficient. Not used in most cases.""" class PinholeIntrinsics(ArrayMixin): + """Pinhole camera intrinsics representation.""" + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, fx: float, fy: float, cx: float, cy: float, skew: float = 0.0) -> None: + """Initialize PinholeIntrinsics. + + :param fx: Focal length in x direction. + :param fy: Focal length in y direction. + :param cx: Optical center x coordinate. + :param cy: Optical center y coordinate. + :param skew: Skew coefficient. Not used in most cases, defaults to 0.0 + """ array = np.zeros(len(PinholeIntrinsicsIndex), dtype=np.float64) array[PinholeIntrinsicsIndex.FX] = fx array[PinholeIntrinsicsIndex.FY] = fy @@ -58,6 +126,12 @@ def __init__(self, fx: float, fy: float, cx: float, cy: float, skew: float = 0.0 @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PinholeIntrinsics: + """Creates a PinholeIntrinsics from a numpy array, indexed by :class:`PinholeIntrinsicsIndex`. + + :param array: A 1D numpy array containing the intrinsic parameters. + :param copy: Whether to copy the array, defaults to True + :return: A :class:`PinholeIntrinsics` instance. + """ assert array.ndim == 1 assert array.shape[-1] == len(PinholeIntrinsicsIndex) instance = object.__new__(cls) @@ -66,10 +140,10 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Pinhol @classmethod def from_camera_matrix(cls, intrinsic: npt.NDArray[np.float64]) -> PinholeIntrinsics: - """ - Create a PinholeIntrinsics from a 3x3 intrinsic matrix. + """Create a PinholeIntrinsics from a 3x3 intrinsic matrix. + :param intrinsic: A 3x3 numpy array representing the intrinsic matrix. - :return: A PinholeIntrinsics instance. + :return: A :class:`PinholeIntrinsics` instance. """ assert intrinsic.shape == (3, 3) fx = intrinsic[0, 0] @@ -82,34 +156,37 @@ def from_camera_matrix(cls, intrinsic: npt.NDArray[np.float64]) -> PinholeIntrin @property def array(self) -> npt.NDArray[np.float64]: + """A numpy array representation of the pinhole intrinsics, indexed by :class:`PinholeIntrinsicsIndex`.""" return self._array @property def fx(self) -> float: + """Focal length in x direction.""" return self._array[PinholeIntrinsicsIndex.FX] @property def fy(self) -> float: + """Focal length in y direction.""" return self._array[PinholeIntrinsicsIndex.FY] @property def cx(self) -> float: + """Optical center x coordinate.""" return self._array[PinholeIntrinsicsIndex.CX] @property def cy(self) -> float: + """Optical center y coordinate.""" return self._array[PinholeIntrinsicsIndex.CY] @property def skew(self) -> float: + """Skew coefficient. Not used in most cases.""" return self._array[PinholeIntrinsicsIndex.SKEW] @property def camera_matrix(self) -> npt.NDArray[np.float64]: - """ - Returns the intrinsic matrix. - :return: A 3x3 numpy array representing the intrinsic matrix. - """ + """The 3x3 camera intrinsic matrix K.""" K = np.array( [ [self.fx, self.skew, self.cx], @@ -122,17 +199,39 @@ def camera_matrix(self) -> npt.NDArray[np.float64]: class PinholeDistortionIndex(IntEnum): + """Enumeration of pinhole camera distortion parameters.""" + K1 = 0 + """Radial distortion coefficient k1.""" + K2 = 1 + """Radial distortion coefficient k2.""" + P1 = 2 + """Tangential distortion coefficient p1.""" + P2 = 3 + """Tangential distortion coefficient p2.""" + K3 = 4 + """Radial distortion coefficient k3.""" class PinholeDistortion(ArrayMixin): + """Pinhole camera distortion representation.""" + + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, k1: float, k2: float, p1: float, p2: float, k3: float) -> None: + """Initialize :class:`:PinholeDistortion`. + + :param k1: Radial distortion coefficient k1. + :param k2: Radial distortion coefficient k2. + :param p1: Tangential distortion coefficient p1. + :param p2: Tangential distortion coefficient p2. + :param k3: Radial distortion coefficient k3. + """ array = np.zeros(len(PinholeDistortionIndex), dtype=np.float64) array[PinholeDistortionIndex.K1] = k1 array[PinholeDistortionIndex.K2] = k2 @@ -143,6 +242,12 @@ def __init__(self, k1: float, k2: float, p1: float, p2: float, k3: float) -> Non @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PinholeDistortion: + """Creates a PinholeDistortion from a numpy array, indexed by :class:`PinholeDistortionIndex`. + + :param array: A 1D numpy array containing the distortion parameters. + :param copy: Whether to copy the array, defaults to True + :return: A :class:`PinholeDistortion` instance. + """ assert array.ndim == 1 assert array.shape[-1] == len(PinholeDistortionIndex) instance = object.__new__(cls) @@ -151,40 +256,69 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Pinhol @property def array(self) -> npt.NDArray[np.float64]: + """A numpy array representation of the pinhole distortion, indexed by :class:`PinholeDistortionIndex`.""" return self._array @property def k1(self) -> float: + """Radial distortion coefficient k1.""" return self._array[PinholeDistortionIndex.K1] @property def k2(self) -> float: + """Radial distortion coefficient k2.""" return self._array[PinholeDistortionIndex.K2] @property def p1(self) -> float: + """Tangential distortion coefficient p1.""" return self._array[PinholeDistortionIndex.P1] @property def p2(self) -> float: + """Tangential distortion coefficient p2.""" return self._array[PinholeDistortionIndex.P2] @property def k3(self) -> float: + """Radial distortion coefficient k3.""" return self._array[PinholeDistortionIndex.K3] -@dataclass -class PinholeCameraMetadata: +class PinholeMetadata: + """Static metadata for a pinhole camera, stored in a log.""" + + __slots__ = ("_camera_type", "_intrinsics", "_distortion", "_width", "_height") - camera_type: PinholeCameraType - intrinsics: Optional[PinholeIntrinsics] - distortion: Optional[PinholeDistortion] - width: int - height: int + def __init__( + self, + camera_type: PinholeCameraType, + intrinsics: Optional[PinholeIntrinsics], + distortion: Optional[PinholeDistortion], + width: int, + height: int, + ) -> None: + """Initialize a :class:`PinholeMetadata` instance. + + :param camera_type: The type of the pinhole camera. + :param intrinsics: The :class:`PinholeIntrinsics` of the pinhole camera. + :param distortion: The :class:`PinholeDistortion` of the pinhole camera. + :param width: The image width in pixels. + :param height: The image height in pixels. + """ + self._camera_type = camera_type + self._intrinsics = intrinsics + self._distortion = distortion + self._width = width + self._height = height @classmethod - def from_dict(cls, data_dict: Dict[str, Any]) -> PinholeCameraMetadata: + def from_dict(cls, data_dict: Dict[str, Any]) -> PinholeMetadata: + """Create a :class:`PinholeMetadata` from a dictionary. + + :param data_dict: A dictionary containing the metadata. + :return: A PinholeMetadata instance. + """ data_dict["camera_type"] = PinholeCameraType(data_dict["camera_type"]) data_dict["intrinsics"] = ( PinholeIntrinsics.from_list(data_dict["intrinsics"]) if data_dict["intrinsics"] is not None else None @@ -192,31 +326,59 @@ def from_dict(cls, data_dict: Dict[str, Any]) -> PinholeCameraMetadata: data_dict["distortion"] = ( PinholeDistortion.from_list(data_dict["distortion"]) if data_dict["distortion"] is not None else None ) - return PinholeCameraMetadata(**data_dict) + return PinholeMetadata(**data_dict) def to_dict(self) -> Dict[str, Any]: - data_dict = asdict(self) + """Converts the :class:`PinholeMetadata` to a dictionary. + + :return: A dictionary representation of the PinholeMetadata instance, with default Python types. + """ + data_dict = {} data_dict["camera_type"] = int(self.camera_type) data_dict["intrinsics"] = self.intrinsics.tolist() if self.intrinsics is not None else None data_dict["distortion"] = self.distortion.tolist() if self.distortion is not None else None + data_dict["width"] = self.width + data_dict["height"] = self.height return data_dict + @property + def camera_type(self) -> PinholeCameraType: + """The :class:`PinholeCameraType` of the pinhole camera.""" + return self._camera_type + + @property + def intrinsics(self) -> Optional[PinholeIntrinsics]: + """The :class:`PinholeIntrinsics` of the pinhole camera.""" + return self._intrinsics + + @property + def distortion(self) -> Optional[PinholeDistortion]: + """The :class:`PinholeDistortion` of the pinhole camera.""" + return self._distortion + + @property + def width(self) -> int: + """The image width in pixels.""" + return self._width + + @property + def height(self) -> int: + """The image height in pixels.""" + return self._height + @property def aspect_ratio(self) -> float: + """The aspect ratio (width / height) of the pinhole camera.""" return self.width / self.height @property def fov_x(self) -> float: - """ - Calculates the horizontal field of view (FOV) in radian. - """ + """The horizontal field of view (FOV) of the pinhole camera in radians.""" fov_x_rad = 2 * np.arctan(self.width / (2 * self.intrinsics.fx)) return fov_x_rad @property def fov_y(self) -> float: - """ - Calculates the vertical field of view (FOV) in radian. - """ + """The vertical field of view (FOV) of the pinhole camera in radians.""" fov_y_rad = 2 * np.arctan(self.height / (2 * self.intrinsics.fy)) return fov_y_rad diff --git a/tests/unit/datatypes/maps/__init__.py b/tests/unit/datatypes/maps/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/datatypes/sensors/test_pinhole_camera.py b/tests/unit/datatypes/sensors/test_pinhole_camera.py new file mode 100644 index 00000000..de1a73ef --- /dev/null +++ b/tests/unit/datatypes/sensors/test_pinhole_camera.py @@ -0,0 +1,503 @@ +import unittest + +import numpy as np + +from py123d.datatypes.sensors.pinhole_camera import ( + PinholeCamera, + PinholeCameraType, + PinholeDistortion, + PinholeDistortionIndex, + PinholeIntrinsics, + PinholeMetadata, +) +from py123d.geometry import PoseSE3 + + +class TestPinholeCameraType(unittest.TestCase): + + def test_camera_type_values(self): + """Test that camera type enum has expected values.""" + self.assertEqual(PinholeCameraType.PCAM_F0, PinholeCameraType.PCAM_F0) + self.assertEqual(PinholeCameraType.PCAM_B0, PinholeCameraType.PCAM_B0) + self.assertEqual(PinholeCameraType.PCAM_L0, PinholeCameraType.PCAM_L0) + self.assertEqual(PinholeCameraType.PCAM_L1, PinholeCameraType.PCAM_L1) + self.assertEqual(PinholeCameraType.PCAM_L2, PinholeCameraType.PCAM_L2) + self.assertEqual(PinholeCameraType.PCAM_R0, PinholeCameraType.PCAM_R0) + self.assertEqual(PinholeCameraType.PCAM_R1, PinholeCameraType.PCAM_R1) + self.assertEqual(PinholeCameraType.PCAM_R2, PinholeCameraType.PCAM_R2) + self.assertEqual(PinholeCameraType.PCAM_STEREO_L, PinholeCameraType.PCAM_STEREO_L) + self.assertEqual(PinholeCameraType.PCAM_STEREO_R, PinholeCameraType.PCAM_STEREO_R) + + def test_camera_type_from_int(self): + """Test creating camera type from integer.""" + self.assertEqual(PinholeCameraType(0), PinholeCameraType.PCAM_F0) + self.assertEqual(PinholeCameraType(5), PinholeCameraType.PCAM_R0) + self.assertEqual(PinholeCameraType(9), PinholeCameraType.PCAM_STEREO_R) + + def test_camera_type_count(self): + """Test that all camera types are defined.""" + camera_types = list(PinholeCameraType) + self.assertEqual(len(camera_types), 10) + + def test_camera_type_unique_values(self): + """Test that all camera type values are unique.""" + values = [ct.value for ct in PinholeCameraType] + self.assertEqual(len(values), len(set(values))) + + +class TestPinholeIntrinsics(unittest.TestCase): + + def test_intrinsics_creation(self): + """Test creating PinholeIntrinsics instance.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0, skew=0.0) + + self.assertEqual(intrinsics.fx, 500.0) + self.assertEqual(intrinsics.fy, 500.0) + self.assertEqual(intrinsics.cx, 320.0) + self.assertEqual(intrinsics.cy, 240.0) + self.assertEqual(intrinsics.skew, 0.0) + + def test_intrinsics_default_skew(self): + """Test that skew defaults to 0.0 when not provided.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + + self.assertEqual(intrinsics.skew, 0.0) + + def test_intrinsics_from_array(self): + """Test creating intrinsics from array.""" + array = np.array([500.0, 500.0, 320.0, 240.0, 0.0], dtype=np.float64) + intrinsics = PinholeIntrinsics.from_array(array) + + self.assertEqual(intrinsics.fx, 500.0) + self.assertEqual(intrinsics.fy, 500.0) + self.assertEqual(intrinsics.cx, 320.0) + self.assertEqual(intrinsics.cy, 240.0) + self.assertEqual(intrinsics.skew, 0.0) + + def test_intrinsics_from_array_copy(self): + """Test that from_array creates a copy by default.""" + array = np.array([500.0, 500.0, 320.0, 240.0, 0.0], dtype=np.float64) + intrinsics = PinholeIntrinsics.from_array(array, copy=True) + + # Modify original array + array[0] = 1000.0 + + # Intrinsics should still have original value + self.assertEqual(intrinsics.fx, 500.0) + + def test_intrinsics_from_array_no_copy(self): + """Test that from_array can avoid copying.""" + array = np.array([500.0, 500.0, 320.0, 240.0, 0.0], dtype=np.float64) + intrinsics = PinholeIntrinsics.from_array(array, copy=False) + + # Modify original array + array[0] = 1000.0 + + # Intrinsics should reflect the change + self.assertEqual(intrinsics.fx, 1000.0) + + def test_intrinsics_from_camera_matrix(self): + """Test creating intrinsics from 3x3 camera matrix.""" + K = np.array([[500.0, 0.5, 320.0], [0.0, 500.0, 240.0], [0.0, 0.0, 1.0]], dtype=np.float64) + + intrinsics = PinholeIntrinsics.from_camera_matrix(K) + + self.assertEqual(intrinsics.fx, 500.0) + self.assertEqual(intrinsics.fy, 500.0) + self.assertEqual(intrinsics.cx, 320.0) + self.assertEqual(intrinsics.cy, 240.0) + self.assertEqual(intrinsics.skew, 0.5) + + def test_intrinsics_camera_matrix_property(self): + """Test getting camera matrix from intrinsics.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=600.0, cx=320.0, cy=240.0, skew=0.5) + K = intrinsics.camera_matrix + + expected_K = np.array([[500.0, 0.5, 320.0], [0.0, 600.0, 240.0], [0.0, 0.0, 1.0]], dtype=np.float64) + + np.testing.assert_array_almost_equal(K, expected_K) + + def test_intrinsics_camera_matrix_roundtrip(self): + """Test converting to camera matrix and back.""" + original_K = np.array([[500.0, 0.5, 320.0], [0.0, 600.0, 240.0], [0.0, 0.0, 1.0]], dtype=np.float64) + + intrinsics = PinholeIntrinsics.from_camera_matrix(original_K) + restored_K = intrinsics.camera_matrix + + np.testing.assert_array_almost_equal(restored_K, original_K) + + def test_intrinsics_array_property(self): + """Test accessing the underlying array.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0, skew=0.5) + array = intrinsics.array + + self.assertIsInstance(array, np.ndarray) + self.assertEqual(array.shape, (5,)) + np.testing.assert_array_almost_equal(array, [500.0, 500.0, 320.0, 240.0, 0.5]) + + def test_intrinsics_from_list(self): + """Test creating intrinsics from list via from_list method.""" + intrinsics_list = [500.0, 500.0, 320.0, 240.0, 0.0] + intrinsics = PinholeIntrinsics.from_list(intrinsics_list) + + self.assertEqual(intrinsics.fx, 500.0) + self.assertEqual(intrinsics.fy, 500.0) + self.assertEqual(intrinsics.cx, 320.0) + self.assertEqual(intrinsics.cy, 240.0) + self.assertEqual(intrinsics.skew, 0.0) + + def test_intrinsics_tolist(self): + """Test converting intrinsics to list.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0, skew=0.5) + intrinsics_list = intrinsics.tolist() + + self.assertIsInstance(intrinsics_list, list) + self.assertEqual(len(intrinsics_list), 5) + self.assertAlmostEqual(intrinsics_list[0], 500.0) + self.assertAlmostEqual(intrinsics_list[1], 500.0) + self.assertAlmostEqual(intrinsics_list[2], 320.0) + self.assertAlmostEqual(intrinsics_list[3], 240.0) + self.assertAlmostEqual(intrinsics_list[4], 0.5) + + def test_intrinsics_different_fx_fy(self): + """Test intrinsics with different focal lengths.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=600.0, cx=320.0, cy=240.0) + + self.assertEqual(intrinsics.fx, 500.0) + self.assertEqual(intrinsics.fy, 600.0) + self.assertNotEqual(intrinsics.fx, intrinsics.fy) + + def test_intrinsics_non_centered_principal_point(self): + """Test intrinsics with non-centered principal point.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=100.0, cy=100.0) + + self.assertEqual(intrinsics.cx, 100.0) + self.assertEqual(intrinsics.cy, 100.0) + + +class TestPinholeDistortion(unittest.TestCase): + + def test_distortion_creation(self): + """Test creating PinholeDistortion instance.""" + distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) + + self.assertEqual(distortion.k1, 0.1) + self.assertEqual(distortion.k2, 0.01) + self.assertEqual(distortion.p1, 0.001) + self.assertEqual(distortion.p2, 0.001) + self.assertEqual(distortion.k3, 0.001) + + def test_distortion_from_array(self): + """Test creating distortion from array.""" + array = np.array([0.1, 0.01, 0.001, 0.001, 0.001], dtype=np.float64) + distortion = PinholeDistortion.from_array(array) + + self.assertEqual(distortion.k1, 0.1) + self.assertEqual(distortion.k2, 0.01) + self.assertEqual(distortion.p1, 0.001) + self.assertEqual(distortion.p2, 0.001) + self.assertEqual(distortion.k3, 0.001) + + def test_distortion_from_array_copy(self): + """Test that from_array creates a copy by default.""" + array = np.array([0.1, 0.01, 0.001, 0.001, 0.001], dtype=np.float64) + distortion = PinholeDistortion.from_array(array, copy=True) + + # Modify original array + array[0] = 0.5 + + # Distortion should still have original value + self.assertEqual(distortion.k1, 0.1) + + def test_distortion_from_array_no_copy(self): + """Test that from_array can avoid copying.""" + array = np.array([0.1, 0.01, 0.001, 0.001, 0.001], dtype=np.float64) + distortion = PinholeDistortion.from_array(array, copy=False) + + # Modify original array + array[0] = 0.5 + + # Distortion should reflect the change + self.assertEqual(distortion.k1, 0.5) + + def test_distortion_array_property(self): + """Test accessing the underlying array.""" + distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) + array = distortion.array + + self.assertIsInstance(array, np.ndarray) + self.assertEqual(array.shape, (len(PinholeDistortionIndex),)) + np.testing.assert_array_almost_equal(array, [0.1, 0.01, 0.001, 0.001, 0.001]) + + def test_distortion_zero_values(self): + """Test distortion with zero values.""" + distortion = PinholeDistortion(k1=0.0, k2=0.0, p1=0.0, p2=0.0, k3=0.0) + + self.assertEqual(distortion.k1, 0.0) + self.assertEqual(distortion.k2, 0.0) + self.assertEqual(distortion.p1, 0.0) + self.assertEqual(distortion.p2, 0.0) + self.assertEqual(distortion.k3, 0.0) + + def test_distortion_negative_values(self): + """Test distortion with negative values.""" + distortion = PinholeDistortion(k1=-0.1, k2=-0.01, p1=-0.001, p2=-0.001, k3=-0.001) + + self.assertEqual(distortion.k1, -0.1) + self.assertEqual(distortion.k2, -0.01) + self.assertEqual(distortion.p1, -0.001) + self.assertEqual(distortion.p2, -0.001) + self.assertEqual(distortion.k3, -0.001) + + def test_distortion_from_list(self): + """Test creating distortion from list via from_list method.""" + distortion_list = [0.1, 0.01, 0.001, 0.001, 0.001] + distortion = PinholeDistortion.from_list(distortion_list) + + self.assertEqual(distortion.k1, 0.1) + self.assertEqual(distortion.k2, 0.01) + self.assertEqual(distortion.p1, 0.001) + self.assertEqual(distortion.p2, 0.001) + self.assertEqual(distortion.k3, 0.001) + + def test_distortion_tolist(self): + """Test converting distortion to list.""" + distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) + distortion_list = distortion.tolist() + + self.assertIsInstance(distortion_list, list) + self.assertEqual(len(distortion_list), 5) + self.assertAlmostEqual(distortion_list[0], 0.1) + self.assertAlmostEqual(distortion_list[1], 0.01) + self.assertAlmostEqual(distortion_list[2], 0.001) + self.assertAlmostEqual(distortion_list[3], 0.001) + self.assertAlmostEqual(distortion_list[4], 0.001) + + +class TestPinholeMetadata(unittest.TestCase): + + def test_metadata_from_dict_with_none_intrinsics(self): + """Test creating metadata from dict with None intrinsics.""" + data_dict = { + "camera_type": 1, + "intrinsics": None, + "distortion": [0.1, 0.01, 0.001, 0.001, 0.001], + "width": 800, + "height": 600, + } + + metadata = PinholeMetadata.from_dict(data_dict) + + self.assertEqual(metadata.camera_type, PinholeCameraType.PCAM_B0) + self.assertIsNone(metadata.intrinsics) + self.assertIsNotNone(metadata.distortion) + self.assertEqual(metadata.width, 800) + self.assertEqual(metadata.height, 600) + + def test_metadata_from_dict_with_none_distortion(self): + """Test creating metadata from dict with None distortion.""" + data_dict = { + "camera_type": 2, + "intrinsics": [600.0, 600.0, 400.0, 300.0, 0.0], + "distortion": None, + "width": 800, + "height": 600, + } + + metadata = PinholeMetadata.from_dict(data_dict) + + self.assertEqual(metadata.camera_type, PinholeCameraType.PCAM_L0) + self.assertIsNotNone(metadata.intrinsics) + self.assertIsNone(metadata.distortion) + + def test_metadata_different_aspect_ratios(self): + """Test metadata with different aspect ratios.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + + # 16:9 aspect ratio + metadata_16_9 = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=1920, height=1080 + ) + self.assertAlmostEqual(metadata_16_9.aspect_ratio, 16 / 9) + + # 4:3 aspect ratio + metadata_4_3 = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 + ) + self.assertAlmostEqual(metadata_4_3.aspect_ratio, 4 / 3) + + def test_metadata_fov_with_different_focal_lengths(self): + """Test FOV calculation with different focal lengths.""" + intrinsics_narrow = PinholeIntrinsics(fx=1000.0, fy=1000.0, cx=320.0, cy=240.0) + intrinsics_wide = PinholeIntrinsics(fx=250.0, fy=250.0, cx=320.0, cy=240.0) + + metadata_narrow = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics_narrow, distortion=None, width=640, height=480 + ) + metadata_wide = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics_wide, distortion=None, width=640, height=480 + ) + + # Wider focal length should result in larger FOV + self.assertGreater(metadata_wide.fov_x, metadata_narrow.fov_x) + self.assertGreater(metadata_wide.fov_y, metadata_narrow.fov_y) + + def test_metadata_to_dict_preserves_types(self): + """Test that to_dict preserves correct types.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) + + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_R1, intrinsics=intrinsics, distortion=distortion, width=1280, height=720 + ) + + data_dict = metadata.to_dict() + + self.assertIsInstance(data_dict["camera_type"], int) + self.assertIsInstance(data_dict["width"], int) + self.assertIsInstance(data_dict["height"], int) + self.assertIsInstance(data_dict["intrinsics"], list) + self.assertIsInstance(data_dict["distortion"], list) + + def test_metadata_all_camera_types(self): + """Test metadata creation with all camera types.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + + for camera_type in PinholeCameraType: + metadata = PinholeMetadata( + camera_type=camera_type, intrinsics=intrinsics, distortion=None, width=640, height=480 + ) + self.assertEqual(metadata.camera_type, camera_type) + + def test_metadata_square_image(self): + """Test metadata with square image dimensions.""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=256.0, cy=256.0) + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=512, height=512 + ) + + self.assertEqual(metadata.aspect_ratio, 1.0) + self.assertAlmostEqual(metadata.fov_x, metadata.fov_y) + + def test_metadata_non_square_pixels(self): + """Test metadata with non-square pixels (different fx and fy).""" + intrinsics = PinholeIntrinsics(fx=500.0, fy=600.0, cx=320.0, cy=240.0) + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 + ) + + expected_fov_x = 2 * np.arctan(640 / (2 * 500.0)) + expected_fov_y = 2 * np.arctan(480 / (2 * 600.0)) + + self.assertAlmostEqual(metadata.fov_x, expected_fov_x) + self.assertAlmostEqual(metadata.fov_y, expected_fov_y) + self.assertNotAlmostEqual(metadata.fov_x, metadata.fov_y) + + +class TestPinholeCamera(unittest.TestCase): + + def test_pinhole_camera_creation(self): + """Test creating PinholeCamera instance.""" + + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 + ) + image = np.zeros((480, 640, 3), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertEqual(camera.metadata, metadata) + self.assertTrue(np.array_equal(camera.image, image)) + self.assertEqual(camera.extrinsic, extrinsic) + + def test_pinhole_camera_with_color_image(self): + """Test PinholeCamera with color image.""" + + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 + ) + image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertEqual(camera.image.shape, (480, 640, 3)) + self.assertEqual(camera.image.dtype, np.uint8) + + def test_pinhole_camera_with_grayscale_image(self): + """Test PinholeCamera with grayscale image.""" + + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_L0, intrinsics=intrinsics, distortion=None, width=640, height=480 + ) + image = np.random.randint(0, 255, (480, 640), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertEqual(camera.image.shape, (480, 640)) + + def test_pinhole_camera_with_distortion(self): + """Test PinholeCamera with distortion parameters.""" + + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, + intrinsics=intrinsics, + distortion=distortion, + width=640, + height=480, + ) + image = np.zeros((480, 640, 3), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertIsNotNone(camera.metadata.distortion) + self.assertEqual(camera.metadata.distortion.k1, 0.1) + + def test_pinhole_camera_different_types(self): + """Test PinholeCamera with different camera types.""" + + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) + image = np.zeros((480, 640, 3), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + for camera_type in [ + PinholeCameraType.PCAM_F0, + PinholeCameraType.PCAM_B0, + PinholeCameraType.PCAM_STEREO_L, + PinholeCameraType.PCAM_STEREO_R, + ]: + metadata = PinholeMetadata( + camera_type=camera_type, intrinsics=intrinsics, distortion=None, width=640, height=480 + ) + camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) + self.assertEqual(camera.metadata.camera_type, camera_type) + + def test_pinhole_camera_with_different_resolutions(self): + """Test PinholeCamera with different image resolutions.""" + + resolutions = [(640, 480), (1920, 1080), (1280, 720), (800, 600)] + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + for width, height in resolutions: + intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=width / 2, cy=height / 2) + metadata = PinholeMetadata( + camera_type=PinholeCameraType.PCAM_F0, + intrinsics=intrinsics, + distortion=None, + width=width, + height=height, + ) + image = np.zeros((height, width, 3), dtype=np.uint8) + camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertEqual(camera.metadata.width, width) + self.assertEqual(camera.metadata.height, height) + self.assertEqual(camera.image.shape[:2], (height, width)) From 0ed61e384b3db3faa6851fa24e324f2366d8457e Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 00:11:47 +0100 Subject: [PATCH 11/50] Add tests and documentation for lidar sensor. --- docs/api/datatypes/sensors/03_lidar.rst | 21 ++ .../datatypes/sensors/fisheye_mei_camera.py | 29 +- src/py123d/datatypes/sensors/lidar.py | 165 +++++++--- .../sensors/test_fisheye_mei_camera.py | 0 tests/unit/datatypes/sensors/test_lidar.py | 281 ++++++++++++++++++ 5 files changed, 446 insertions(+), 50 deletions(-) create mode 100644 tests/unit/datatypes/sensors/test_fisheye_mei_camera.py create mode 100644 tests/unit/datatypes/sensors/test_lidar.py diff --git a/docs/api/datatypes/sensors/03_lidar.rst b/docs/api/datatypes/sensors/03_lidar.rst index 6958ff20..6d4fbee7 100644 --- a/docs/api/datatypes/sensors/03_lidar.rst +++ b/docs/api/datatypes/sensors/03_lidar.rst @@ -1,6 +1,27 @@ LiDAR ^^^^^ +LiDAR Data +---------- + .. autoclass:: py123d.datatypes.sensors.LiDAR :members: + :exclude-members: __init__ :autoclasstoc: + + +LiDAR Metadata +-------------- + +.. autoclass:: py123d.datatypes.sensors.LiDARMetadata + :members: + :exclude-members: __init__ + :autoclasstoc: + + +LiDAR Types +----------- + +.. autoclass:: py123d.datatypes.sensors.LiDARType + :no-inherited-members: + :exclude-members: __init__, __new__ diff --git a/src/py123d/datatypes/sensors/fisheye_mei_camera.py b/src/py123d/datatypes/sensors/fisheye_mei_camera.py index c6c0a1cf..075f9a01 100644 --- a/src/py123d/datatypes/sensors/fisheye_mei_camera.py +++ b/src/py123d/datatypes/sensors/fisheye_mei_camera.py @@ -21,12 +21,29 @@ class FisheyeMEICameraType(SerialIntEnum): FCAM_R = 1 -@dataclass class FisheyeMEICamera: - metadata: FisheyeMEICameraMetadata - image: npt.NDArray[np.uint8] - extrinsic: PoseSE3 + def __init__( + self, + metadata: FisheyeMEICameraMetadata, + image: npt.NDArray[np.uint8], + extrinsic: PoseSE3, + ) -> None: + self._metadata = metadata + self._image = image + self._extrinsic = extrinsic + + @property + def metadata(self) -> FisheyeMEICameraMetadata: + return self._metadata + + @property + def image(self) -> npt.NDArray[np.uint8]: + return self._image + + @property + def extrinsic(self) -> PoseSE3: + return self._extrinsic class FisheyeMEIDistortionIndex(IntEnum): @@ -62,18 +79,22 @@ def array(self) -> npt.NDArray[np.float64]: @property def k1(self) -> float: + """Radial distortion coefficient.""" return self._array[FisheyeMEIDistortionIndex.K1] @property def k2(self) -> float: + """Radial distortion coefficient.""" return self._array[FisheyeMEIDistortionIndex.K2] @property def p1(self) -> float: + """Tangential distortion coefficient.""" return self._array[FisheyeMEIDistortionIndex.P1] @property def p2(self) -> float: + """Tangential distortion coefficient.""" return self._array[FisheyeMEIDistortionIndex.P2] diff --git a/src/py123d/datatypes/sensors/lidar.py b/src/py123d/datatypes/sensors/lidar.py index e8999f43..3509d6bd 100644 --- a/src/py123d/datatypes/sensors/lidar.py +++ b/src/py123d/datatypes/sensors/lidar.py @@ -1,45 +1,87 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Optional, Type +from typing import Any, Dict, Optional, Type import numpy as np import numpy.typing as npt from py123d.common.utils.enums import SerialIntEnum -from py123d.conversion.registry.lidar_index_registry import LIDAR_INDEX_REGISTRY, LiDARIndex +from py123d.conversion.registry import LIDAR_INDEX_REGISTRY, LiDARIndex from py123d.geometry import PoseSE3 class LiDARType(SerialIntEnum): + """Enumeration of LiDAR sensors, in multi-sensor setups.""" LIDAR_UNKNOWN = 0 + """Unknown LiDAR type.""" + LIDAR_MERGED = 1 + """Merged LiDAR type.""" + LIDAR_TOP = 2 + """Top-facing LiDAR type.""" + LIDAR_FRONT = 3 + """Front-facing LiDAR type.""" + LIDAR_SIDE_LEFT = 4 + """Left-side LiDAR type.""" + LIDAR_SIDE_RIGHT = 5 + """Right-side LiDAR type.""" + LIDAR_BACK = 6 + """Back-facing LiDAR type.""" + LIDAR_DOWN = 7 + """Down-facing LiDAR type.""" -@dataclass class LiDARMetadata: + """Metadata for LiDAR sensor, static for a given sensor.""" - lidar_type: LiDARType - lidar_index: Type[LiDARIndex] - extrinsic: Optional[PoseSE3] = None - # TODO: add identifier if point cloud is returned in lidar or ego frame. + __slots__ = ("_lidar_type", "_lidar_index", "_extrinsic") - def to_dict(self) -> dict: - return { - "lidar_type": self.lidar_type.name, - "lidar_index": self.lidar_index.__name__, - "extrinsic": self.extrinsic.tolist() if self.extrinsic is not None else None, - } + def __init__( + self, + lidar_type: LiDARType, + lidar_index: Type[LiDARIndex], + extrinsic: Optional[PoseSE3] = None, + ): + """Initialize LiDAR metadata. + + :param lidar_type: The type of the LiDAR sensor. + :param lidar_index: The indexing schema of the LiDAR point cloud. + :param extrinsic: The extrinsic pose of the LiDAR sensor, defaults to None + """ + self._lidar_type = lidar_type + self._lidar_index = lidar_index + self._extrinsic = extrinsic + + @property + def lidar_type(self) -> LiDARType: + """The type of the LiDAR sensor.""" + return self._lidar_type + + @property + def lidar_index(self) -> Type[LiDARIndex]: + """The indexing schema of the LiDAR point cloud.""" + return self._lidar_index + + @property + def extrinsic(self) -> Optional[PoseSE3]: + """The extrinsic :class:`~py123d.geometry.PoseSE3` of the LiDAR sensor, relative to the vehicle frame.""" + return self._extrinsic @classmethod def from_dict(cls, data_dict: dict) -> LiDARMetadata: + """Construct the LiDAR metadata from a dictionary. + + :param data_dict: A dictionary containing LiDAR metadata. + :raises ValueError: If the dictionary is missing required fields or contains invalid data. + :return: An instance of LiDARMetadata. + """ lidar_type = LiDARType[data_dict["lidar_type"]] if data_dict["lidar_index"] not in LIDAR_INDEX_REGISTRY: raise ValueError(f"Unknown lidar index: {data_dict['lidar_index']}") @@ -47,53 +89,84 @@ def from_dict(cls, data_dict: dict) -> LiDARMetadata: extrinsic = PoseSE3.from_list(data_dict["extrinsic"]) if data_dict["extrinsic"] is not None else None return cls(lidar_type=lidar_type, lidar_index=lidar_index_class, extrinsic=extrinsic) + def to_dict(self) -> Dict[str, Any]: + """Convert the LiDAR metadata to a dictionary. + + :return: A dictionary representation of the LiDAR metadata. + """ + return { + "lidar_type": self.lidar_type.name, + "lidar_index": self.lidar_index.__name__, + "extrinsic": self.extrinsic.tolist() if self.extrinsic is not None else None, + } + -@dataclass class LiDAR: + """Data structure for LiDAR point cloud data and associated metadata.""" - metadata: LiDARMetadata - point_cloud: npt.NDArray[np.float32] + __slots__ = ("_metadata", "_point_cloud") - @property - def xyz(self) -> npt.NDArray[np.float32]: + def __init__(self, metadata: LiDARMetadata, point_cloud: npt.NDArray[np.float32]) -> None: + """Initialize LiDAR data structure. + + :param metadata: LiDAR metadata. + :param point_cloud: LiDAR point cloud as an NxM numpy array, where N is the number of points + and M is the number of attributes per point as defined by the :class:`~py123d.conversion.registry.LiDARIndex`. """ - Returns the point cloud as an Nx3 array of x, y, z coordinates. + self._metadata = metadata + self._point_cloud = point_cloud + + @property + def metadata(self) -> LiDARMetadata: + """The :class:`LiDARMetadata` associated with this LiDAR recording.""" + return self._metadata + + @property + def point_cloud(self) -> npt.NDArray[np.float32]: + """The raw point cloud as an NxM numpy array, + where N is the number of points and M is the number of attributes per point, + as defined by the :class:`~py123d.conversion.registry.LiDARIndex`. Point cloud in vehicle frame. """ - return self.point_cloud[:, self.metadata.lidar_index.XYZ] + return self._point_cloud + + @property + def xyz(self) -> npt.NDArray[np.float32]: + """The point cloud as an Nx3 array of x, y, z coordinates.""" + return self._point_cloud[:, self.metadata.lidar_index.XYZ] @property def xy(self) -> npt.NDArray[np.float32]: - """ - Returns the point cloud as an Nx2 array of x, y coordinates. - """ - return self.point_cloud[:, self.metadata.lidar_index.XY] + """The point cloud as an Nx2 array of x, y coordinates.""" + return self._point_cloud[:, self.metadata.lidar_index.XY] @property def intensity(self) -> Optional[npt.NDArray[np.float32]]: - """ - Returns the intensity values of the LiDAR point cloud if available. - Returns None if intensity is not part of the point cloud. - """ - if hasattr(self.metadata.lidar_index, "INTENSITY"): - return self.point_cloud[:, self.metadata.lidar_index.INTENSITY] - return None + """The point cloud as an Nx1 array of intensity values, if available.""" + intensity: Optional[npt.NDArray[np.float32]] = None + if hasattr(self._metadata.lidar_index, "INTENSITY"): + intensity = self._point_cloud[:, self._metadata.lidar_index.INTENSITY] + return intensity @property def range(self) -> Optional[npt.NDArray[np.float32]]: - """ - Returns the range values of the LiDAR point cloud if available. - Returns None if range is not part of the point cloud. - """ - if hasattr(self.metadata.lidar_index, "RANGE"): - return self.point_cloud[:, self.metadata.lidar_index.RANGE] - return None + """The point cloud as an Nx1 array of range values, if available.""" + range: Optional[npt.NDArray[np.float32]] = None + if hasattr(self._metadata.lidar_index, "RANGE"): + range = self._point_cloud[:, self._metadata.lidar_index.RANGE] + return range @property def elongation(self) -> Optional[npt.NDArray[np.float32]]: - """ - Returns the elongation values of the LiDAR point cloud if available. - Returns None if elongation is not part of the point cloud. - """ - if hasattr(self.metadata.lidar_index, "ELONGATION"): - return self.point_cloud[:, self.metadata.lidar_index.ELONGATION] - return None + """The point cloud as an Nx1 array of elongation values, if available.""" + elongation: Optional[npt.NDArray[np.float32]] = None + if hasattr(self._metadata.lidar_index, "ELONGATION"): + elongation = self._point_cloud[:, self._metadata.lidar_index.ELONGATION] + return elongation + + @property + def ring(self) -> Optional[npt.NDArray[np.int32]]: + """The point cloud as an Nx1 array of ring values, if available.""" + ring: Optional[npt.NDArray[np.int32]] = None + if hasattr(self._metadata.lidar_index, "RING"): + ring = self._point_cloud[:, self._metadata.lidar_index.RING] + return ring diff --git a/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py b/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/datatypes/sensors/test_lidar.py b/tests/unit/datatypes/sensors/test_lidar.py new file mode 100644 index 00000000..4163dbd5 --- /dev/null +++ b/tests/unit/datatypes/sensors/test_lidar.py @@ -0,0 +1,281 @@ +import unittest + +import numpy as np + +from py123d.conversion.registry.lidar_index_registry import LIDAR_INDEX_REGISTRY +from py123d.datatypes.sensors.lidar import LiDAR, LiDARMetadata, LiDARType +from py123d.geometry import PoseSE3 + + +class TestLiDARType(unittest.TestCase): + def test_lidar_type_enum_values(self): + """Test that LiDARType enum has correct values.""" + assert LiDARType.LIDAR_UNKNOWN.value == 0 + assert LiDARType.LIDAR_MERGED.value == 1 + assert LiDARType.LIDAR_TOP.value == 2 + assert LiDARType.LIDAR_FRONT.value == 3 + assert LiDARType.LIDAR_SIDE_LEFT.value == 4 + assert LiDARType.LIDAR_SIDE_RIGHT.value == 5 + assert LiDARType.LIDAR_BACK.value == 6 + assert LiDARType.LIDAR_DOWN.value == 7 + + def test_lidar_type_enum_names(self): + """Test that LiDARType enum members have correct names.""" + assert LiDARType.LIDAR_UNKNOWN.name == "LIDAR_UNKNOWN" + assert LiDARType.LIDAR_MERGED.name == "LIDAR_MERGED" + assert LiDARType.LIDAR_TOP.name == "LIDAR_TOP" + assert LiDARType.LIDAR_FRONT.name == "LIDAR_FRONT" + assert LiDARType.LIDAR_SIDE_LEFT.name == "LIDAR_SIDE_LEFT" + assert LiDARType.LIDAR_SIDE_RIGHT.name == "LIDAR_SIDE_RIGHT" + assert LiDARType.LIDAR_BACK.name == "LIDAR_BACK" + assert LiDARType.LIDAR_DOWN.name == "LIDAR_DOWN" + + def test_lidar_type_from_value(self): + """Test that LiDARType can be created from integer values.""" + assert LiDARType(0) == LiDARType.LIDAR_UNKNOWN + assert LiDARType(1) == LiDARType.LIDAR_MERGED + assert LiDARType(2) == LiDARType.LIDAR_TOP + assert LiDARType(3) == LiDARType.LIDAR_FRONT + assert LiDARType(4) == LiDARType.LIDAR_SIDE_LEFT + assert LiDARType(5) == LiDARType.LIDAR_SIDE_RIGHT + assert LiDARType(6) == LiDARType.LIDAR_BACK + assert LiDARType(7) == LiDARType.LIDAR_DOWN + + def test_lidar_type_unique_values(self): + """Test that all LiDARType enum values are unique.""" + values = [member.value for member in LiDARType] + assert len(values) == len(set(values)) + + def test_lidar_type_count(self): + """Test that LiDARType has expected number of members.""" + assert len(LiDARType) == 8 + + +class TestLiDARMetadata(unittest.TestCase): + def setUp(self): + """Set up test fixtures.""" + + # Get a lidar index class from registry (assuming at least one exists) + self.lidar_index_class = next(iter(LIDAR_INDEX_REGISTRY.values())) + self.lidar_type = LiDARType.LIDAR_TOP + self.extrinsic = PoseSE3.from_list([1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0]) + + def test_lidar_metadata_creation_with_extrinsic(self): + """Test creating LiDARMetadata with extrinsic.""" + metadata = LiDARMetadata( + lidar_type=self.lidar_type, + lidar_index=self.lidar_index_class, + extrinsic=self.extrinsic, + ) + assert metadata.lidar_type == self.lidar_type + assert metadata.lidar_index == self.lidar_index_class + assert metadata.extrinsic is not None + + def test_lidar_metadata_creation_without_extrinsic(self): + """Test creating LiDARMetadata without extrinsic.""" + metadata = LiDARMetadata(lidar_type=self.lidar_type, lidar_index=self.lidar_index_class) + assert metadata.lidar_type == self.lidar_type + assert metadata.lidar_index == self.lidar_index_class + assert metadata.extrinsic is None + + def test_lidar_metadata_to_dict_with_extrinsic(self): + """Test serializing LiDARMetadata to dict with extrinsic.""" + metadata = LiDARMetadata( + lidar_type=self.lidar_type, + lidar_index=self.lidar_index_class, + extrinsic=self.extrinsic, + ) + data_dict = metadata.to_dict() + assert data_dict["lidar_type"] == self.lidar_type.name + assert data_dict["lidar_index"] == self.lidar_index_class.__name__ + assert data_dict["extrinsic"] is not None + assert isinstance(data_dict["extrinsic"], list) + + def test_lidar_metadata_to_dict_without_extrinsic(self): + """Test serializing LiDARMetadata to dict without extrinsic.""" + metadata = LiDARMetadata(lidar_type=self.lidar_type, lidar_index=self.lidar_index_class) + data_dict = metadata.to_dict() + assert data_dict["lidar_type"] == self.lidar_type.name + assert data_dict["lidar_index"] == self.lidar_index_class.__name__ + assert data_dict["extrinsic"] is None + + def test_lidar_metadata_from_dict_with_extrinsic(self): + """Test deserializing LiDARMetadata from dict with extrinsic.""" + data_dict = { + "lidar_type": self.lidar_type.name, + "lidar_index": self.lidar_index_class.__name__, + "extrinsic": [1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0], + } + metadata = LiDARMetadata.from_dict(data_dict) + assert metadata.lidar_type == self.lidar_type + assert metadata.lidar_index == self.lidar_index_class + assert metadata.extrinsic is not None + + def test_lidar_metadata_from_dict_without_extrinsic(self): + """Test deserializing LiDARMetadata from dict without extrinsic.""" + data_dict = { + "lidar_type": self.lidar_type.name, + "lidar_index": self.lidar_index_class.__name__, + "extrinsic": None, + } + metadata = LiDARMetadata.from_dict(data_dict) + assert metadata.lidar_type == self.lidar_type + assert metadata.lidar_index == self.lidar_index_class + assert metadata.extrinsic is None + + def test_lidar_metadata_roundtrip_with_extrinsic(self): + """Test roundtrip serialization/deserialization with extrinsic.""" + metadata = LiDARMetadata( + lidar_type=self.lidar_type, + lidar_index=self.lidar_index_class, + extrinsic=self.extrinsic, + ) + data_dict = metadata.to_dict() + restored_metadata = LiDARMetadata.from_dict(data_dict) + assert restored_metadata.lidar_type == metadata.lidar_type + assert restored_metadata.lidar_index == metadata.lidar_index + + def test_lidar_metadata_roundtrip_without_extrinsic(self): + """Test roundtrip serialization/deserialization without extrinsic.""" + metadata = LiDARMetadata(lidar_type=self.lidar_type, lidar_index=self.lidar_index_class) + data_dict = metadata.to_dict() + restored_metadata = LiDARMetadata.from_dict(data_dict) + assert restored_metadata.lidar_type == metadata.lidar_type + assert restored_metadata.lidar_index == metadata.lidar_index + assert restored_metadata.extrinsic is None + + def test_lidar_metadata_from_dict_unknown_index_raises_error(self): + """Test that unknown lidar index raises ValueError.""" + data_dict = {"lidar_type": self.lidar_type.name, "lidar_index": "UnknownLiDARIndex", "extrinsic": None} + with self.assertRaises(ValueError) as context: + LiDARMetadata.from_dict(data_dict) + assert "Unknown lidar index" in str(context.exception) + + +class TestLiDAR(unittest.TestCase): + def setUp(self): + """Set up test fixtures.""" + # Get a lidar index class from registry + + self.lidars = {} + self.extrinsic = PoseSE3.from_list([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]) + + for lidar_index_name, lidar_index_class in LIDAR_INDEX_REGISTRY.items(): + metadata = LiDARMetadata( + lidar_type=LiDARType.LIDAR_TOP, + lidar_index=lidar_index_class, + extrinsic=self.extrinsic, + ) + point_cloud = np.random.rand(100, len(lidar_index_class)).astype(np.float32) + self.lidars[lidar_index_name] = LiDAR(metadata=metadata, point_cloud=point_cloud) + + def test_lidar_xyz_property(self): + """Test xyz property returns correct shape and values.""" + for lidar in self.lidars.values(): + xyz = lidar.xyz + assert xyz.shape[0] == lidar.point_cloud.shape[0] + assert xyz.shape[1] == 3 + + def test_lidar_xy_property(self): + """Test xy property returns correct shape and values.""" + for lidar in self.lidars.values(): + xy = lidar.xy + assert xy.shape[0] == lidar.point_cloud.shape[0] + assert xy.shape[1] == 2 + + def test_lidar_intensity_property_when_available(self): + """Test intensity property when INTENSITY attribute exists.""" + for lidar in self.lidars.values(): + intensity = lidar.intensity + if hasattr(lidar.metadata.lidar_index, "INTENSITY"): + assert intensity is not None + assert intensity.shape[0] == lidar.point_cloud.shape[0] + else: + assert intensity is None + + def test_lidar_intensity_property_when_not_available(self): + """Test intensity property returns None when not available.""" + for lidar in self.lidars.values(): + if not hasattr(lidar.metadata.lidar_index, "INTENSITY"): + assert lidar.intensity is None + + def test_lidar_range_property_when_available(self): + """Test range property when RANGE attribute exists.""" + for lidar in self.lidars.values(): + range_values = lidar.range + if hasattr(lidar.metadata.lidar_index, "RANGE"): + assert range_values is not None + assert range_values.shape[0] == lidar.point_cloud.shape[0] + else: + assert range_values is None + + def test_lidar_range_property_when_not_available(self): + """Test range property returns None when not available.""" + for lidar in self.lidars.values(): + if not hasattr(lidar.metadata.lidar_index, "RANGE"): + assert lidar.range is None + + def test_lidar_elongation_property_when_available(self): + """Test elongation property when ELONGATION attribute exists.""" + for lidar in self.lidars.values(): + elongation = lidar.elongation + if hasattr(lidar.metadata.lidar_index, "ELONGATION"): + assert elongation is not None + assert elongation.shape[0] == lidar.point_cloud.shape[0] + else: + assert elongation is None + + def test_lidar_elongation_property_when_not_available(self): + """Test elongation property returns None when not available.""" + for lidar in self.lidars.values(): + if not hasattr(lidar.metadata.lidar_index, "ELONGATION"): + assert lidar.elongation is None + + def test_lidar_ring_property_when_available(self): + """Test ring property when RING attribute exists.""" + for lidar in self.lidars.values(): + ring = lidar.ring + if hasattr(lidar.metadata.lidar_index, "RING"): + assert ring is not None + assert ring.shape[0] == lidar.point_cloud.shape[0] + else: + assert ring is None + + def test_lidar_ring_property_when_not_available(self): + """Test ring property returns None when not available.""" + for lidar in self.lidars.values(): + if not hasattr(lidar.metadata.lidar_index, "RING"): + assert lidar.ring is None + + def test_lidar_with_empty_point_cloud(self): + """Test LiDAR with empty point cloud.""" + for lidar_index_class in LIDAR_INDEX_REGISTRY.values(): + metadata = LiDARMetadata( + lidar_type=LiDARType.LIDAR_TOP, + lidar_index=lidar_index_class, + extrinsic=self.extrinsic, + ) + empty_point_cloud = np.empty((0, len(lidar_index_class)), dtype=np.float32) + lidar = LiDAR(metadata=metadata, point_cloud=empty_point_cloud) + assert lidar.xyz.shape == (0, 3) + assert lidar.xy.shape == (0, 2) + + def test_lidar_with_single_point(self): + """Test LiDAR with single point.""" + for lidar_index_class in LIDAR_INDEX_REGISTRY.values(): + metadata = LiDARMetadata( + lidar_type=LiDARType.LIDAR_TOP, + lidar_index=lidar_index_class, + extrinsic=self.extrinsic, + ) + single_point_cloud = np.random.rand(1, len(lidar_index_class)).astype(np.float32) + lidar = LiDAR(metadata=metadata, point_cloud=single_point_cloud) + assert lidar.xyz.shape == (1, 3) + assert lidar.xy.shape == (1, 2) + + def test_lidar_point_cloud_dtype(self): + """Test that point cloud maintains float32 dtype.""" + for lidar in self.lidars.values(): + assert lidar.point_cloud.dtype == np.float32 + assert lidar.xyz.dtype == np.float32 + assert lidar.xy.dtype == np.float32 From fa1c75c115d5f03c07706c3caf65959fd6db36aa Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 10:27:30 +0100 Subject: [PATCH 12/50] Add Fisheye mei tests and documentation. --- .../sensors/02_fisheye_mei_camera.rst | 39 ++ .../datatypes/sensors/fisheye_mei_camera.py | 152 ++++++- .../sensors/test_fisheye_mei_camera.py | 374 ++++++++++++++++++ tests/unit/datatypes/time/test_time.py | 0 4 files changed, 550 insertions(+), 15 deletions(-) create mode 100644 tests/unit/datatypes/time/test_time.py diff --git a/docs/api/datatypes/sensors/02_fisheye_mei_camera.rst b/docs/api/datatypes/sensors/02_fisheye_mei_camera.rst index 0616af1a..b6224164 100644 --- a/docs/api/datatypes/sensors/02_fisheye_mei_camera.rst +++ b/docs/api/datatypes/sensors/02_fisheye_mei_camera.rst @@ -1,6 +1,45 @@ Fisheye MEI Camera ^^^^^^^^^^^^^^^^^^ +Fisheye MEI Camera Data +----------------------- + .. autoclass:: py123d.datatypes.sensors.FisheyeMEICamera :members: + :exclude-members: __init__ + :autoclasstoc: + +Fisheye MEI Metadata +-------------------- + +.. autoclass:: py123d.datatypes.sensors.FisheyeMEICameraMetadata + :members: + :exclude-members: __init__ :autoclasstoc: + + +Fisheye MEI Distortion +---------------------- + +.. autoclass:: py123d.datatypes.sensors.FisheyeMEIDistortion + :members: + :exclude-members: __init__ + :no-inherited-members: + :autoclasstoc: + +Fisheye MEI Projection +---------------------- + +.. autoclass:: py123d.datatypes.sensors.FisheyeMEIProjection + :members: + :exclude-members: __init__ + :no-inherited-members: + :autoclasstoc: + + +Fisheye MEI Camera Types +------------------------ + +.. autoclass:: py123d.datatypes.sensors.FisheyeMEICameraType + :no-inherited-members: + :exclude-members: __init__, __new__ diff --git a/src/py123d/datatypes/sensors/fisheye_mei_camera.py b/src/py123d/datatypes/sensors/fisheye_mei_camera.py index 075f9a01..808304ea 100644 --- a/src/py123d/datatypes/sensors/fisheye_mei_camera.py +++ b/src/py123d/datatypes/sensors/fisheye_mei_camera.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import dataclass from typing import Any, Dict, Optional import numpy as np @@ -13,15 +13,19 @@ class FisheyeMEICameraType(SerialIntEnum): - """ - Enum for fisheye cameras in d123. - """ + """Enumeration of fisheye MEI camera types in multi-sensor setups.""" FCAM_L = 0 + """Left-facing fisheye MEI camera.""" + FCAM_R = 1 + """Right-facing fisheye MEI camera.""" class FisheyeMEICamera: + """Fisheye MEI camera data structure.""" + + __slots__ = ("_metadata", "_image", "_extrinsic") def __init__( self, @@ -29,35 +33,61 @@ def __init__( image: npt.NDArray[np.uint8], extrinsic: PoseSE3, ) -> None: + """Initialize a Fisheye MEI camera. + + :param metadata: Metadata for the camera. + :param image: Image captured by the camera. + :param extrinsic: Extrinsic pose of the camera. + """ self._metadata = metadata self._image = image self._extrinsic = extrinsic @property def metadata(self) -> FisheyeMEICameraMetadata: + """The :class:`FisheyeMEICameraMetadata` object for the camera.""" return self._metadata @property def image(self) -> npt.NDArray[np.uint8]: + """Image captured by the camera, as a NumPy array.""" return self._image @property def extrinsic(self) -> PoseSE3: + """Extrinsic :class:`~py123d.geometry.PoseSE3` of the camera.""" return self._extrinsic class FisheyeMEIDistortionIndex(IntEnum): + """Indexing for fisheye MEI distortion parameters.""" K1 = 0 + """Radial distortion coefficient k1.""" + K2 = 1 + """Radial distortion coefficient k2.""" + P1 = 2 + """Tangential distortion coefficient p1.""" + P2 = 3 + """Tangential distortion coefficient p2.""" class FisheyeMEIDistortion(ArrayMixin): + + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, k1: float, k2: float, p1: float, p2: float) -> None: + """Initialize the fisheye MEI distortion parameters. + + :param k1: Radial distortion coefficient k1. + :param k2: Radial distortion coefficient k2. + :param p1: Tangential distortion coefficient p1. + :param p2: Tangential distortion coefficient p2. + """ array = np.zeros(len(FisheyeMEIDistortionIndex), dtype=np.float64) array[FisheyeMEIDistortionIndex.K1] = k1 array[FisheyeMEIDistortionIndex.K2] = k2 @@ -67,6 +97,13 @@ def __init__(self, k1: float, k2: float, p1: float, p2: float) -> None: @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> FisheyeMEIDistortion: + """Creates a :class:`FisheyeMEIDistortion` instance from a NumPy array, + indexing according to :class:`FisheyeMEIDistortionIndex`. + + :param array: Input array containing distortion parameters. + :param copy: Whether to copy the array data, defaults to True. + :return: A new instance of :class:`FisheyeMEIDistortion`. + """ assert array.ndim == 1 assert array.shape[-1] == len(FisheyeMEIDistortionIndex) instance = object.__new__(cls) @@ -75,6 +112,7 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Fishey @property def array(self) -> npt.NDArray[np.float64]: + """Underlying NumPy array of distortion parameters, indexed by :class:`FisheyeMEIDistortionIndex`.""" return self._array @property @@ -99,17 +137,35 @@ def p2(self) -> float: class FisheyeMEIProjectionIndex(IntEnum): + """Indexing for fisheye MEI projection parameters.""" GAMMA1 = 0 + """Generalized focal length gamma1.""" + GAMMA2 = 1 + """Generalized focal length gamma2.""" + U0 = 2 + """Principal point x-coordinate.""" + V0 = 3 + """Principal point y-coordinate.""" class FisheyeMEIProjection(ArrayMixin): + """Fisheye MEI projection parameters.""" + + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, gamma1: float, gamma2: float, u0: float, v0: float) -> None: + """Initialize the fisheye MEI projection parameters. + + :param gamma1: Generalized focal length gamma1. + :param gamma2: Generalized focal length gamma2. + :param u0: Principal point x-coordinate. + :param v0: Principal point y-coordinate. + """ array = np.zeros(len(FisheyeMEIProjectionIndex), dtype=np.float64) array[FisheyeMEIProjectionIndex.GAMMA1] = gamma1 array[FisheyeMEIProjectionIndex.GAMMA2] = gamma2 @@ -119,6 +175,13 @@ def __init__(self, gamma1: float, gamma2: float, u0: float, v0: float) -> None: @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> FisheyeMEIProjection: + """Intializes a :class:`FisheyeMEIProjection` from a NumPy array, + indexing according to :class:`FisheyeMEIProjectionIndex`. + + :param array: Input array containing projection parameters. + :param copy: Whether to copy the array data, defaults to True. + :return: A new instance of :class:`FisheyeMEIProjection`. + """ assert array.ndim == 1 assert array.shape[-1] == len(FisheyeMEIProjectionIndex) instance = object.__new__(cls) @@ -127,37 +190,68 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Fishey @property def array(self) -> npt.NDArray[np.float64]: + """Underlying NumPy array of projection parameters, indexed by :class:`FisheyeMEIProjectionIndex`.""" return self._array @property def gamma1(self) -> float: + """Generalized focal length gamma1.""" return self._array[FisheyeMEIProjectionIndex.GAMMA1] @property def gamma2(self) -> float: + """Generalized focal length gamma2.""" return self._array[FisheyeMEIProjectionIndex.GAMMA2] @property def u0(self) -> float: + """Principal point x-coordinate.""" return self._array[FisheyeMEIProjectionIndex.U0] @property def v0(self) -> float: + """Principal point y-coordinate.""" return self._array[FisheyeMEIProjectionIndex.V0] @dataclass class FisheyeMEICameraMetadata: + """Metadata for a fisheye MEI camera.""" - camera_type: FisheyeMEICameraType - mirror_parameter: Optional[float] - distortion: Optional[FisheyeMEIDistortion] - projection: Optional[FisheyeMEIProjection] - width: int - height: int + __slots__ = ("_camera_type", "_mirror_parameter", "_distortion", "_projection", "_width", "_height") + + def __init__( + self, + camera_type: FisheyeMEICameraType, + mirror_parameter: Optional[float], + distortion: Optional[FisheyeMEIDistortion], + projection: Optional[FisheyeMEIProjection], + width: int, + height: int, + ) -> None: + """Initialize the fisheye MEI camera metadata. + + :param camera_type: Type of the fisheye MEI camera. + :param mirror_parameter: Mirror parameter of the camera model. + :param distortion: Distortion parameters of the camera. + :param projection: Projection parameters of the camera. + :param width: Width of the camera image in pixels. + :param height: Height of the camera image in pixels. + """ + self._camera_type = camera_type + self._mirror_parameter = mirror_parameter + self._distortion = distortion + self._projection = projection + self._width = width + self._height = height @classmethod def from_dict(cls, data_dict: Dict[str, Any]) -> FisheyeMEICameraMetadata: + """Create a :class:`FisheyeMEICameraMetadata` instance from a dictionary. + + :param data_dict: Dictionary containing camera metadata. + :return: A new instance of :class:`FisheyeMEICameraMetadata`. + """ data_dict["camera_type"] = FisheyeMEICameraType(data_dict["camera_type"]) data_dict["distortion"] = ( FisheyeMEIDistortion.from_array(np.array(data_dict["distortion"])) @@ -171,15 +265,43 @@ def from_dict(cls, data_dict: Dict[str, Any]) -> FisheyeMEICameraMetadata: ) return FisheyeMEICameraMetadata(**data_dict) + @property + def camera_type(self) -> FisheyeMEICameraType: + """The type of the fisheye MEI camera.""" + return self._camera_type + + @property + def mirror_parameter(self) -> Optional[float]: + """The mirror parameter of the fisheye MEI camera.""" + return self._mirror_parameter + + @property + def distortion(self) -> Optional[FisheyeMEIDistortion]: + """The distortion parameters of the fisheye MEI camera, if available.""" + return self._distortion + + @property + def projection(self) -> Optional[FisheyeMEIProjection]: + """The projection parameters of the fisheye MEI camera, if available.""" + return self._projection + @property def aspect_ratio(self) -> float: - return self.width / self.height + """The aspect ratio of the fisheye MEI camera.""" + return self._width / self._height def to_dict(self) -> Dict[str, Any]: - data_dict = asdict(self) - data_dict["camera_type"] = int(self.camera_type) - data_dict["distortion"] = self.distortion.array.tolist() if self.distortion is not None else None - data_dict["projection"] = self.projection.array.tolist() if self.projection is not None else None + """Converts the :class:`FisheyeMEICameraMetadata` instance to a Python dictionary. + + :return: A dictionary representation of the camera metadata. + """ + data_dict: Dict[str, Any] = {} + data_dict["mirror_parameter"] = self._mirror_parameter + data_dict["camera_type"] = int(self._camera_type) + data_dict["distortion"] = self._distortion.array.tolist() if self._distortion is not None else None + data_dict["projection"] = self._projection.array.tolist() if self._projection is not None else None + data_dict["width"] = self._width + data_dict["height"] = self._height return data_dict def cam2image(self, points_3d: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: diff --git a/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py b/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py index e69de29b..829270e8 100644 --- a/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py +++ b/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py @@ -0,0 +1,374 @@ +import unittest + +import numpy as np + +from py123d.datatypes.sensors.fisheye_mei_camera import ( + FisheyeMEICamera, + FisheyeMEICameraMetadata, + FisheyeMEICameraType, + FisheyeMEIDistortion, + FisheyeMEIDistortionIndex, + FisheyeMEIProjection, + FisheyeMEIProjectionIndex, +) +from py123d.geometry import PoseSE3 + + +class TestFisheyeMEICameraType(unittest.TestCase): + + def test_camera_type_values(self): + """Test that camera type enum has expected values.""" + self.assertEqual(FisheyeMEICameraType.FCAM_L.value, 0) + self.assertEqual(FisheyeMEICameraType.FCAM_R.value, 1) + + def test_camera_type_from_int(self): + """Test creating camera type from integer values.""" + self.assertEqual(FisheyeMEICameraType(0), FisheyeMEICameraType.FCAM_L) + self.assertEqual(FisheyeMEICameraType(1), FisheyeMEICameraType.FCAM_R) + + def test_camera_type_members(self): + """Test that all expected members exist.""" + members = list(FisheyeMEICameraType) + self.assertEqual(len(members), 2) + self.assertIn(FisheyeMEICameraType.FCAM_L, members) + self.assertIn(FisheyeMEICameraType.FCAM_R, members) + + def test_camera_type_comparison(self): + """Test comparison between camera types.""" + self.assertNotEqual(FisheyeMEICameraType.FCAM_L, FisheyeMEICameraType.FCAM_R) + self.assertEqual(FisheyeMEICameraType.FCAM_L, FisheyeMEICameraType.FCAM_L) + + +class TestFisheyeMEIDistortion(unittest.TestCase): + + def test_distortion_initialization(self): + """Test distortion parameter initialization.""" + distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) + self.assertEqual(distortion.k1, 0.1) + self.assertEqual(distortion.k2, 0.2) + self.assertEqual(distortion.p1, 0.3) + self.assertEqual(distortion.p2, 0.4) + + def test_distortion_from_array(self): + """Test creating distortion from array.""" + array = np.array([0.1, 0.2, 0.3, 0.4]) + distortion = FisheyeMEIDistortion.from_array(array) + self.assertEqual(distortion.k1, 0.1) + self.assertEqual(distortion.k2, 0.2) + self.assertEqual(distortion.p1, 0.3) + self.assertEqual(distortion.p2, 0.4) + + def test_distortion_from_array_copy(self): + """Test that from_array copies data by default.""" + array = np.array([0.1, 0.2, 0.3, 0.4]) + distortion = FisheyeMEIDistortion.from_array(array, copy=True) + array[0] = 999.0 + self.assertEqual(distortion.k1, 0.1) + + def test_distortion_from_array_no_copy(self): + """Test that from_array can avoid copying.""" + array = np.array([0.1, 0.2, 0.3, 0.4]) + distortion = FisheyeMEIDistortion.from_array(array, copy=False) + array[0] = 999.0 + self.assertEqual(distortion.k1, 999.0) + + def test_distortion_array_property(self): + """Test array property returns correct values.""" + distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) + array = distortion.array + self.assertEqual(len(array), 4) + np.testing.assert_array_equal(array, [0.1, 0.2, 0.3, 0.4]) + + def test_distortion_index_mapping(self): + """Test that distortion indices map correctly.""" + distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) + self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.K1], 0.1) + self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.K2], 0.2) + self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.P1], 0.3) + self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.P2], 0.4) + + +class TestFisheyeMEIProjection(unittest.TestCase): + + def test_projection_initialization(self): + """Test projection parameter initialization.""" + projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) + self.assertEqual(projection.gamma1, 1.0) + self.assertEqual(projection.gamma2, 2.0) + self.assertEqual(projection.u0, 3.0) + self.assertEqual(projection.v0, 4.0) + + def test_projection_from_array(self): + """Test creating projection from array.""" + array = np.array([1.0, 2.0, 3.0, 4.0]) + projection = FisheyeMEIProjection.from_array(array) + self.assertEqual(projection.gamma1, 1.0) + self.assertEqual(projection.gamma2, 2.0) + self.assertEqual(projection.u0, 3.0) + self.assertEqual(projection.v0, 4.0) + + def test_projection_from_array_copy(self): + """Test that from_array copies data by default.""" + array = np.array([1.0, 2.0, 3.0, 4.0]) + projection = FisheyeMEIProjection.from_array(array, copy=True) + array[0] = 999.0 + self.assertEqual(projection.gamma1, 1.0) + + def test_projection_from_array_no_copy(self): + """Test that from_array can avoid copying.""" + array = np.array([1.0, 2.0, 3.0, 4.0]) + projection = FisheyeMEIProjection.from_array(array, copy=False) + array[0] = 999.0 + self.assertEqual(projection.gamma1, 999.0) + + def test_projection_array_property(self): + """Test array property returns correct values.""" + projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) + array = projection.array + self.assertEqual(len(array), 4) + np.testing.assert_array_equal(array, [1.0, 2.0, 3.0, 4.0]) + + def test_projection_index_mapping(self): + """Test that projection indices map correctly.""" + projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) + self.assertEqual(projection.array[FisheyeMEIProjectionIndex.GAMMA1], 1.0) + self.assertEqual(projection.array[FisheyeMEIProjectionIndex.GAMMA2], 2.0) + self.assertEqual(projection.array[FisheyeMEIProjectionIndex.U0], 3.0) + self.assertEqual(projection.array[FisheyeMEIProjectionIndex.V0], 4.0) + + +class TestFisheyeMEICameraMetadata(unittest.TestCase): + + def test_metadata_initialization(self): + """Test metadata initialization with all parameters.""" + distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) + projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=distortion, + projection=projection, + width=1920, + height=1080, + ) + self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_L) + self.assertEqual(metadata.mirror_parameter, 0.5) + self.assertEqual(metadata.distortion, distortion) + self.assertEqual(metadata.projection, projection) + self.assertEqual(metadata.aspect_ratio, 1920 / 1080) + + def test_metadata_initialization_with_none(self): + """Test metadata initialization with None distortion and projection.""" + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_R, + mirror_parameter=None, + distortion=None, + projection=None, + width=640, + height=480, + ) + self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_R) + self.assertIsNone(metadata.mirror_parameter) + self.assertIsNone(metadata.distortion) + self.assertIsNone(metadata.projection) + self.assertEqual(metadata.aspect_ratio, 640 / 480) + + def test_metadata_to_dict(self): + """Test converting metadata to dictionary.""" + distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) + projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=distortion, + projection=projection, + width=1920, + height=1080, + ) + result = metadata.to_dict() + self.assertEqual(result["camera_type"], 0) + self.assertEqual(result["mirror_parameter"], 0.5) + self.assertEqual(result["distortion"], [0.1, 0.2, 0.3, 0.4]) + self.assertEqual(result["projection"], [1.0, 2.0, 3.0, 4.0]) + self.assertEqual(result["width"], 1920) + self.assertEqual(result["height"], 1080) + + def test_metadata_to_dict_with_none(self): + """Test converting metadata with None values to dictionary.""" + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_R, + mirror_parameter=None, + distortion=None, + projection=None, + width=640, + height=480, + ) + result = metadata.to_dict() + self.assertEqual(result["camera_type"], 1) + self.assertIsNone(result["mirror_parameter"]) + self.assertIsNone(result["distortion"]) + self.assertIsNone(result["projection"]) + self.assertEqual(result["width"], 640) + self.assertEqual(result["height"], 480) + + def test_metadata_from_dict(self): + """Test creating metadata from dictionary.""" + data = { + "camera_type": 0, + "mirror_parameter": 0.5, + "distortion": [0.1, 0.2, 0.3, 0.4], + "projection": [1.0, 2.0, 3.0, 4.0], + "width": 1920, + "height": 1080, + } + metadata = FisheyeMEICameraMetadata.from_dict(data) + self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_L) + self.assertEqual(metadata.mirror_parameter, 0.5) + self.assertEqual(metadata.distortion.k1, 0.1) + self.assertEqual(metadata.projection.gamma1, 1.0) + self.assertEqual(metadata.aspect_ratio, 1920 / 1080) + + def test_metadata_from_dict_with_none(self): + """Test creating metadata from dictionary with None values.""" + data = { + "camera_type": 1, + "mirror_parameter": None, + "distortion": None, + "projection": None, + "width": 640, + "height": 480, + } + metadata = FisheyeMEICameraMetadata.from_dict(data) + self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_R) + self.assertIsNone(metadata.mirror_parameter) + self.assertIsNone(metadata.distortion) + self.assertIsNone(metadata.projection) + + def test_metadata_roundtrip(self): + """Test that to_dict and from_dict are inverses.""" + distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) + projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=distortion, + projection=projection, + width=1920, + height=1080, + ) + data_dict = metadata.to_dict() + metadata_restored = FisheyeMEICameraMetadata.from_dict(data_dict) + self.assertEqual(metadata.camera_type, metadata_restored.camera_type) + self.assertEqual(metadata.mirror_parameter, metadata_restored.mirror_parameter) + np.testing.assert_array_equal(metadata.distortion.array, metadata_restored.distortion.array) + np.testing.assert_array_equal(metadata.projection.array, metadata_restored.projection.array) + self.assertEqual(metadata.aspect_ratio, metadata_restored.aspect_ratio) + + def test_aspect_ratio_calculation(self): + """Test aspect ratio calculation.""" + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=None, + projection=None, + width=1920, + height=1080, + ) + self.assertAlmostEqual(metadata.aspect_ratio, 16 / 9, places=5) + + +class TestFisheyeMEICamera(unittest.TestCase): + + def test_camera_initialization(self): + """Test FisheyeMEICamera initialization.""" + + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4), + projection=FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0), + width=1920, + height=1080, + ) + image = np.zeros((1080, 1920), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertEqual(camera.metadata, metadata) + np.testing.assert_array_equal(camera.image, image) + self.assertEqual(camera.extrinsic, extrinsic) + + def test_camera_metadata_property(self): + """Test that metadata property returns correct metadata.""" + + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_R, + mirror_parameter=0.8, + distortion=None, + projection=None, + width=640, + height=480, + ) + image = np.ones((480, 640), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertIs(camera.metadata, metadata) + self.assertEqual(camera.metadata.camera_type, FisheyeMEICameraType.FCAM_R) + + def test_camera_image_property(self): + """Test that image property returns correct image.""" + + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=None, + projection=None, + width=640, + height=480, + ) + image = np.random.randint(0, 255, (480, 640), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) + + np.testing.assert_array_equal(camera.image, image) + self.assertEqual(camera.image.dtype, np.uint8) + + def test_camera_extrinsic_property(self): + """Test that extrinsic property returns correct pose.""" + + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=None, + projection=None, + width=640, + height=480, + ) + image = np.zeros((480, 640), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertIs(camera.extrinsic, extrinsic) + + def test_camera_with_color_image(self): + """Test camera with color (3-channel) image.""" + + metadata = FisheyeMEICameraMetadata( + camera_type=FisheyeMEICameraType.FCAM_L, + mirror_parameter=0.5, + distortion=None, + projection=None, + width=640, + height=480, + ) + image = np.zeros((480, 640, 3), dtype=np.uint8) + extrinsic = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + + camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) + + self.assertEqual(camera.image.shape, (480, 640, 3)) diff --git a/tests/unit/datatypes/time/test_time.py b/tests/unit/datatypes/time/test_time.py new file mode 100644 index 00000000..e69de29b From 85ab5cb3a248b6f05d40058c020cbd668e07594c Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 10:42:55 +0100 Subject: [PATCH 13/50] Refactor and add documentation / tests for time datatype. --- docs/api/datatypes/time/index.rst | 7 - src/py123d/datatypes/time/__init__.py | 2 +- src/py123d/datatypes/time/time_point.py | 353 +++--------------------- tests/unit/datatypes/time/test_time.py | 68 +++++ 4 files changed, 106 insertions(+), 324 deletions(-) diff --git a/docs/api/datatypes/time/index.rst b/docs/api/datatypes/time/index.rst index ab37d362..2b51ca90 100644 --- a/docs/api/datatypes/time/index.rst +++ b/docs/api/datatypes/time/index.rst @@ -1,13 +1,6 @@ Time ---- - .. autoclass:: py123d.datatypes.time.TimePoint :exclude-members: __init__ :autoclasstoc: - - - -.. autoclass:: py123d.datatypes.time.TimeDuration - :exclude-members: __init__ - :autoclasstoc: diff --git a/src/py123d/datatypes/time/__init__.py b/src/py123d/datatypes/time/__init__.py index 36e8f0dd..5ca914d0 100644 --- a/src/py123d/datatypes/time/__init__.py +++ b/src/py123d/datatypes/time/__init__.py @@ -1 +1 @@ -from py123d.datatypes.time.time_point import TimePoint, TimeDuration +from py123d.datatypes.time.time_point import TimePoint diff --git a/src/py123d/datatypes/time/time_point.py b/src/py123d/datatypes/time/time_point.py index 437bf967..1564fe95 100644 --- a/src/py123d/datatypes/time/time_point.py +++ b/src/py123d/datatypes/time/time_point.py @@ -1,353 +1,74 @@ from __future__ import annotations -from dataclasses import dataclass -# TODO: Refactor -# NOTE: Taken from nuplan and adapted. Generally, these types are handy, when handling time discrete data. - - -class TimeDuration: - """Class representing a time delta, with a microsecond resolution.""" - - __slots__ = "_time_us" - - def __init__(self, *, time_us: int, _direct: bool = True) -> None: - """Constructor, should not be called directly. Raises if the keyword parameter _direct is not set to false.""" - if _direct: - raise RuntimeError("Don't initialize this class directly, use one of the constructors instead!") - - self._time_us = time_us - - @classmethod - def from_us(cls, t_us: int) -> TimeDuration: - """ - Constructs a TimeDuration from a value in microseconds. - :param t_us: Time in microseconds. - :return: TimeDuration. - """ - assert isinstance(t_us, int), "Microseconds must be an integer!" - return cls(time_us=t_us, _direct=False) - - @classmethod - def from_ms(cls, t_ms: float) -> TimeDuration: - """ - Constructs a TimeDuration from a value in milliseconds. - :param t_ms: Time in milliseconds. - :return: TimeDuration. - """ - return cls(time_us=int(t_ms * int(1e3)), _direct=False) - - @classmethod - def from_s(cls, t_s: float) -> TimeDuration: - """ - Constructs a TimeDuration from a value in seconds. - :param t_s: Time in seconds. - :return: TimeDuration. - """ - return cls(time_us=int(t_s * int(1e6)), _direct=False) - - @property - def time_us(self) -> int: - """ - :return: TimeDuration in microseconds. - """ - return self._time_us - - @property - def time_ms(self) -> float: - """ - :return: TimeDuration in milliseconds. - """ - return self._time_us / 1e3 - - @property - def time_s(self) -> float: - """ - :return: TimeDuration in seconds. - """ - return self._time_us / 1e6 - - def __add__(self, other: object) -> TimeDuration: - """ - Adds a time duration to a time duration. - :param other: time duration. - :return: self + other if other is a TimeDuration. - """ - if isinstance(other, TimeDuration): - return TimeDuration.from_us(self.time_us + other.time_us) - return NotImplemented - - def __sub__(self, other: object) -> TimeDuration: - """ - Subtract a time duration from a time duration. - :param other: time duration. - :return: self - other if other is a TimeDuration. - """ - if isinstance(other, TimeDuration): - return TimeDuration.from_us(self.time_us - other.time_us) - return NotImplemented - - def __mul__(self, other: object) -> TimeDuration: - """ - Multiply a time duration by a scalar value. - :param other: value to multiply. - :return: self * other if other is a scalar. - """ - if isinstance(other, (int, float)): - return TimeDuration.from_s(self.time_s * other) - return NotImplemented - - def __rmul__(self, other: object) -> TimeDuration: - """ - Multiply a time duration by a scalar value. - :param other: value to multiply. - :return: self * other if other is a scalar. - """ - if isinstance(other, (int, float)): - return self * other - return NotImplemented - - def __truediv__(self, other: object) -> TimeDuration: - """ - Divides a time duration by a scalar value. - :param other: value to divide for. - :return: self / other if other is a scalar. - """ - if isinstance(other, (int, float)): - return TimeDuration.from_s(self.time_s / other) - return NotImplemented - - def __floordiv__(self, other: object) -> TimeDuration: - """ - Floor divides a time duration by a scalar value. - :param other: value to divide for. - :return: self // other if other is a scalar. - """ - if isinstance(other, (int, float)): - return TimeDuration.from_s(self.time_s // other) - return NotImplemented - - def __gt__(self, other: TimeDuration) -> bool: - """ - Self is greater than other. - :param other: TimeDuration. - :return: True if self > other, False otherwise. - """ - if isinstance(other, TimeDuration): - return self.time_us > other.time_us - return NotImplemented - - def __ge__(self, other: object) -> bool: - """ - Self is greater or equal than other. - :param other: TimeDuration. - :return: True if self >= other, False otherwise. - """ - if isinstance(other, TimeDuration): - return self.time_us >= other.time_us - return NotImplemented - - def __lt__(self, other: TimeDuration) -> bool: - """ - Self is less than other. - :param other: TimeDuration. - :return: True if self < other, False otherwise. - """ - if isinstance(other, TimeDuration): - return self.time_us < other.time_us - return NotImplemented - - def __le__(self, other: TimeDuration) -> bool: - """ - Self is less or equal than other. - :param other: TimeDuration. - :return: True if self <= other, False otherwise. - """ - if isinstance(other, TimeDuration): - return self.time_us <= other.time_us - return NotImplemented - - def __eq__(self, other: object) -> bool: - """ - Self is equal to other. - :param other: TimeDuration. - :return: True if self == other, False otherwise. - """ - if not isinstance(other, TimeDuration): - return NotImplemented - - return self.time_us == other.time_us - - def __hash__(self) -> int: - """ - :return: hash for this object. - """ - return hash(self.time_us) - - def __repr__(self) -> str: - """ - :return: String representation. - """ - return "TimeDuration({}s)".format(self.time_s) - - -@dataclass class TimePoint: - """ - Time instance in a time series. - """ + """Time instance in a time series.""" - time_us: int # [micro seconds] time since epoch in micro seconds - __slots__ = "time_us" - - def __post_init__(self) -> None: - """ - Validate class after creation. - """ - assert self.time_us >= 0, "Time point has to be positive!" + __slots__ = "_time_us" + _time_us: int # [micro seconds] time since epoch in micro seconds @classmethod def from_ns(cls, t_ns: int) -> TimePoint: - """ - Constructs a TimePoint from a value in nanoseconds. + """Constructs a TimePoint from a value in nanoseconds. + :param t_ns: Time in nanoseconds. :return: TimePoint. """ assert isinstance(t_ns, int), "Nanoseconds must be an integer!" - return TimePoint(time_us=t_ns // 1000) + instance = object.__new__(cls) + object.__setattr__(instance, "_time_us", t_ns // 1000) + return instance @classmethod def from_us(cls, t_us: int) -> TimePoint: - """ - Constructs a TimePoint from a value in microseconds. + """Constructs a TimePoint from a value in microseconds. + :param t_us: Time in microseconds. :return: TimePoint. """ assert isinstance(t_us, int), "Microseconds must be an integer!" - return TimePoint(time_us=t_us) + instance = object.__new__(cls) + object.__setattr__(instance, "_time_us", t_us) + return instance @classmethod def from_ms(cls, t_ms: float) -> TimePoint: - """ - Constructs a TimePoint from a value in milliseconds. + """Constructs a TimePoint from a value in milliseconds. + :param t_ms: Time in milliseconds. :return: TimePoint. """ - return TimePoint(time_us=int(t_ms * int(1e3))) + instance = object.__new__(cls) + object.__setattr__(instance, "_time_us", int(t_ms * int(1e3))) + return instance @classmethod def from_s(cls, t_s: float) -> TimePoint: - """ - Constructs a TimePoint from a value in seconds. + """Constructs a TimePoint from a value in seconds. + :param t_s: Time in seconds. :return: TimePoint. """ - return TimePoint(time_us=int(t_s * int(1e6))) + instance = object.__new__(cls) + object.__setattr__(instance, "_time_us", int(t_s * int(1e6))) + return instance @property - def time_ms(self) -> float: - """ - :return: TimePoint in milliseconds. - """ - return self.time_us / 1e3 + def time_ns(self) -> int: + """The timepoint in nanoseconds [ns].""" + return self._time_us * 1000 @property - def time_s(self) -> float: - """ - :return: TimePoint in seconds. - """ - return self.time_us / 1e6 - - def __add__(self, other: object) -> TimePoint: - """ - Adds a TimeDuration to generate a new TimePoint. - :param other: time point. - :return: self + other. - """ - if isinstance(other, (TimeDuration, TimePoint)): - return TimePoint(self.time_us + other.time_us) - return NotImplemented - - def __radd__(self, other: object) -> TimePoint: - """ - :param other: Right addition target. - :return: Addition with other if other is a TimeDuration. - """ - if isinstance(other, TimeDuration): - return self.__add__(other) - return NotImplemented - - def __sub__(self, other: object) -> TimePoint: - """ - Subtract a time duration from a time point. - :param other: time duration. - :return: self - other if other is a TimeDuration. - """ - if isinstance(other, (TimeDuration, TimePoint)): - return TimePoint(self.time_us - other.time_us) - return NotImplemented - - def __gt__(self, other: TimePoint) -> bool: - """ - Self is greater than other. - :param other: time point. - :return: True if self > other, False otherwise. - """ - if isinstance(other, TimePoint): - return self.time_us > other.time_us - return NotImplemented - - def __ge__(self, other: TimePoint) -> bool: - """ - Self is greater or equal than other. - :param other: time point. - :return: True if self >= other, False otherwise. - """ - if isinstance(other, TimePoint): - return self.time_us >= other.time_us - return NotImplemented - - def __lt__(self, other: TimePoint) -> bool: - """ - Self is less than other. - :param other: time point. - :return: True if self < other, False otherwise. - """ - if isinstance(other, TimePoint): - return self.time_us < other.time_us - return NotImplemented - - def __le__(self, other: TimePoint) -> bool: - """ - Self is less or equal than other. - :param other: time point. - :return: True if self <= other, False otherwise. - """ - if isinstance(other, TimePoint): - return self.time_us <= other.time_us - return NotImplemented - - def __eq__(self, other: object) -> bool: - """ - Self is equal to other - :param other: time point - :return: True if self == other, False otherwise - """ - if not isinstance(other, TimePoint): - return NotImplemented - - return self.time_us == other.time_us + def time_us(self) -> int: + """The timepoint in microseconds [μs].""" + return self._time_us - def __hash__(self) -> int: - """ - :return: hash for this object - """ - return hash(self.time_us) + @property + def time_ms(self) -> float: + """The timepoint in milliseconds [ms].""" + return self._time_us / 1e3 - def diff(self, time_point: TimePoint) -> TimeDuration: - """ - Computes the TimeDuration between self and another TimePoint. - :param time_point: The other time point. - :return: The TimeDuration between the two TimePoints. - """ - return TimeDuration.from_us(int(self.time_us - time_point.time_us)) + @property + def time_s(self) -> float: + """The timepoint in seconds [s].""" + return self._time_us / 1e6 diff --git a/tests/unit/datatypes/time/test_time.py b/tests/unit/datatypes/time/test_time.py index e69de29b..a61d4028 100644 --- a/tests/unit/datatypes/time/test_time.py +++ b/tests/unit/datatypes/time/test_time.py @@ -0,0 +1,68 @@ +import unittest + +from py123d.datatypes.time.time_point import TimePoint + + +class TestTimePoint(unittest.TestCase): + + def test_from_ns(self): + """Test constructing TimePoint from nanoseconds.""" + tp = TimePoint.from_ns(1000000) + assert tp.time_ns == 1000000 + assert tp.time_us == 1000 + + def test_from_us(self): + """Test constructing TimePoint from microseconds.""" + tp = TimePoint.from_us(1000) + assert tp.time_us == 1000 + assert tp.time_ns == 1000000 + + def test_from_ms(self): + """Test constructing TimePoint from milliseconds.""" + tp = TimePoint.from_ms(1.5) + assert tp.time_ms == 1.5 + assert tp.time_us == 1500 + + def test_from_s(self): + """Test constructing TimePoint from seconds.""" + tp = TimePoint.from_s(2.5) + assert tp.time_s == 2.5 + assert tp.time_us == 2500000 + + def test_time_ns_property(self): + """Test accessing time value in nanoseconds.""" + tp = TimePoint.from_us(1000) + assert tp.time_ns == 1000000 + + def test_time_us_property(self): + """Test accessing time value in microseconds.""" + tp = TimePoint.from_us(1000) + assert tp.time_us == 1000 + + def test_time_ms_property(self): + """Test accessing time value in milliseconds.""" + tp = TimePoint.from_us(1500) + assert tp.time_ms == 1.5 + + def test_time_s_property(self): + """Test accessing time value in seconds.""" + tp = TimePoint.from_us(2500000) + assert tp.time_s == 2.5 + + def test_from_ns_integer_assertion(self): + """Test that from_ns raises AssertionError for non-integer input.""" + with self.assertRaises(AssertionError): + TimePoint.from_ns(1000.5) + + def test_from_us_integer_assertion(self): + """Test that from_us raises AssertionError for non-integer input.""" + with self.assertRaises(AssertionError): + TimePoint.from_us(1000.5) + + def test_conversion_chain(self): + """Test conversions between different time units.""" + original_us = 123456 + tp = TimePoint.from_us(original_us) + assert TimePoint.from_ns(tp.time_ns).time_us == original_us + assert TimePoint.from_ms(tp.time_ms).time_us == original_us + assert TimePoint.from_s(tp.time_s).time_us == original_us From fb7eaf1fa8308748d3d0cee7c3e7af8381b3768a Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 11:06:59 +0100 Subject: [PATCH 14/50] Add documentation and tests for dynamic states. --- .../datatypes/detections/box_detections.py | 4 + .../detections/traffic_light_detections.py | 7 +- .../datatypes/vehicle_state/dynamic_state.py | 122 +++++++++++++----- .../detections/test_traffic_lights.py | 2 +- .../vehicle_state/test_dynamic_state.py | 109 ++++++++++++++++ 5 files changed, 211 insertions(+), 33 deletions(-) create mode 100644 tests/unit/datatypes/vehicle_state/test_dynamic_state.py diff --git a/src/py123d/datatypes/detections/box_detections.py b/src/py123d/datatypes/detections/box_detections.py index a734f411..db87a275 100644 --- a/src/py123d/datatypes/detections/box_detections.py +++ b/src/py123d/datatypes/detections/box_detections.py @@ -192,6 +192,10 @@ def shapely_polygon(self) -> shapely.geometry.Polygon: class BoxDetectionWrapper: + """The BoxDetectionWrapper is a container for multiple box detections. + It provides methods to access individual detections as well as to retrieve a detection by track token. + The wrapper is used to read and write box detections from/to logs. + """ def __init__(self, box_detections: List[BoxDetection]) -> None: """Initialize a BoxDetectionWrapper instance. diff --git a/src/py123d/datatypes/detections/traffic_light_detections.py b/src/py123d/datatypes/detections/traffic_light_detections.py index d837a35a..30601ae9 100644 --- a/src/py123d/datatypes/detections/traffic_light_detections.py +++ b/src/py123d/datatypes/detections/traffic_light_detections.py @@ -67,6 +67,8 @@ class TrafficLightDetectionWrapper: The wrapper is is used in to read and write traffic light detections from/to logs. """ + __slots__ = ("_traffic_light_detections",) + def __init__(self, traffic_light_detections: List[TrafficLightDetection]) -> None: """Initialize a TrafficLightDetectionWrapper instance. @@ -88,12 +90,11 @@ def __getitem__(self, index: int) -> TrafficLightDetection: return self.traffic_light_detections[index] def __len__(self) -> int: - """ - :return: The number of traffic light detections. - """ + """The number of traffic light detections in the wrapper.""" return len(self.traffic_light_detections) def __iter__(self): + """Iterator over the traffic light detections in the wrapper.""" return iter(self.traffic_light_detections) def get_detection_by_lane_id(self, lane_id: int) -> Optional[TrafficLightDetection]: diff --git a/src/py123d/datatypes/vehicle_state/dynamic_state.py b/src/py123d/datatypes/vehicle_state/dynamic_state.py index b0d88fbb..14907d2e 100644 --- a/src/py123d/datatypes/vehicle_state/dynamic_state.py +++ b/src/py123d/datatypes/vehicle_state/dynamic_state.py @@ -12,40 +12,65 @@ class DynamicStateSE3Index(IntEnum): + """The indices for the dynamic state in SE3.""" VELOCITY_X = 0 + """Velocity in the X direction (forward).""" + VELOCITY_Y = 1 + """Velocity in the Y direction (left).""" + VELOCITY_Z = 2 + """Velocity in the Z direction (up).""" + ACCELERATION_X = 3 + """Acceleration in the X direction (forward).""" + ACCELERATION_Y = 4 + """Acceleration in the Y direction (left).""" + ACCELERATION_Z = 5 + """Acceleration in the Z direction (up).""" + ANGULAR_VELOCITY_X = 6 + """Angular velocity around the X axis (roll).""" + ANGULAR_VELOCITY_Y = 7 + """Angular velocity around the Y axis (pitch).""" + ANGULAR_VELOCITY_Z = 8 + """Angular velocity around the Z axis (yaw).""" @classproperty def VELOCITY_3D(cls) -> slice: + """Slice for the 3D velocity components (x,y,z).""" return slice(cls.VELOCITY_X, cls.VELOCITY_Z + 1) @classproperty def VELOCITY_2D(cls) -> slice: + """Slice for the 2D velocity components (x,y).""" return slice(cls.VELOCITY_X, cls.VELOCITY_Y + 1) @classproperty def ACCELERATION_3D(cls) -> slice: + """Slice for the 3D acceleration components (x,y,z).""" return slice(cls.ACCELERATION_X, cls.ACCELERATION_Z + 1) @classproperty def ACCELERATION_2D(cls) -> slice: + """Slice for the 2D acceleration components (x,y).""" return slice(cls.ACCELERATION_X, cls.ACCELERATION_Y + 1) @classproperty def ANGULAR_VELOCITY_3D(cls) -> slice: + """Slice for the 3D angular velocity components (x,y,z).""" return slice(cls.ANGULAR_VELOCITY_X, cls.ANGULAR_VELOCITY_Z + 1) class DynamicStateSE3(ArrayMixin): + """The dynamic state of a vehicle in SE3 (3D space).""" + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__( @@ -54,6 +79,12 @@ def __init__( acceleration: Vector3D, angular_velocity: Vector3D, ): + """Initialize a :class:`DynamicStateSE3` instance. + + :param velocity: 3D velocity vector. + :param acceleration: 3D acceleration vector. + :param angular_velocity: 3D angular velocity vector. + """ array = np.zeros(len(DynamicStateSE3Index), dtype=np.float64) array[DynamicStateSE3Index.VELOCITY_3D] = velocity.array array[DynamicStateSE3Index.ACCELERATION_3D] = acceleration.array @@ -62,11 +93,11 @@ def __init__( @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> DynamicStateSE3: - """ - Create a DynamicVehicleState from an array. + """Create a :class:`DynamicStateSE3` from NumPy array of shape (9,), indexed by :class:`DynamicStateSE3Index`. + :param array: The array containing the dynamic state information. :param copy: Whether to copy the array data. - :return: A DynamicVehicleState instance. + :return: A :class:`DynamicStateSE3` instance. """ assert array.ndim == 1 assert array.shape[0] == len(DynamicStateSE3Index) @@ -74,44 +105,49 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Dynami object.__setattr__(instance, "_array", array.copy() if copy else array) return instance - @property - def array(self) -> npt.NDArray[np.float64]: - return self._array - @property def velocity(self) -> Vector3D: + """3D velocity vector.""" return Vector3D.from_array(self._array[DynamicStateSE3Index.VELOCITY_3D], copy=False) @property def velocity_3d(self) -> Vector3D: + """3D velocity vector.""" return self.velocity @property def velocity_2d(self) -> Vector2D: + """2D velocity vector.""" return Vector2D.from_array(self._array[DynamicStateSE3Index.VELOCITY_2D], copy=False) @property def acceleration(self) -> Vector3D: + """3D acceleration vector.""" return Vector3D.from_array(self._array[DynamicStateSE3Index.ACCELERATION_3D], copy=False) @property def acceleration_3d(self) -> Vector3D: + """3D acceleration vector.""" return self.acceleration @property def acceleration_2d(self) -> Vector2D: + """2D acceleration vector.""" return Vector2D.from_array(self._array[DynamicStateSE3Index.ACCELERATION_2D], copy=False) @property def angular_velocity(self) -> Vector3D: + """3D angular velocity vector.""" return Vector3D.from_array(self._array[DynamicStateSE3Index.ANGULAR_VELOCITY_3D], copy=False) + @property + def array(self) -> npt.NDArray[np.float64]: + """NumPy array representation of shape (9,), indexed by :class:`DynamicStateSE3Index`.""" + return self._array + @property def dynamic_state_se2(self) -> DynamicStateSE2: - """ - Convert the DynamicVehicleState to a 2D dynamic state. - :return: A DynamicStateSE2 instance. - """ + """The :class:`DynamicStateSE2` projection of this SE3 dynamic state.""" _array = np.zeros(len(DynamicStateSE2Index), dtype=np.float64) _array[DynamicStateSE2Index.VELOCITY_2D] = self._array[DynamicStateSE3Index.VELOCITY_2D] _array[DynamicStateSE2Index.ACCELERATION_2D] = self._array[DynamicStateSE3Index.ACCELERATION_2D] @@ -120,76 +156,104 @@ def dynamic_state_se2(self) -> DynamicStateSE2: class DynamicStateSE2Index(IntEnum): + """The indices for the dynamic state in SE2.""" VELOCITY_X = 0 + """Velocity in the X direction (forward).""" + VELOCITY_Y = 1 + """Velocity in the Y direction (left).""" + ACCELERATION_X = 2 + """Acceleration in the X direction (forward).""" + ACCELERATION_Y = 3 + """Acceleration in the Y direction (left).""" + ANGULAR_VELOCITY_Z = 4 + """Angular velocity around the Z axis (yaw).""" @classproperty def VELOCITY_2D(cls) -> slice: + """Slice for the 2D velocity components (x,y).""" return slice(cls.VELOCITY_X, cls.VELOCITY_Y + 1) @classproperty def ACCELERATION_2D(cls) -> slice: + """Slice for the 2D acceleration components (x,y).""" return slice(cls.ACCELERATION_X, cls.ACCELERATION_Y + 1) @classproperty def ANGULAR_VELOCITY(cls) -> int: + """Index for the angular velocity component (yaw).""" return cls.ANGULAR_VELOCITY_Z @dataclass class DynamicStateSE2(ArrayMixin): + """The dynamic state of a vehicle in SE2 (2D plane).""" + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__( self, - velocity: Vector3D, - acceleration: Vector3D, - angular_velocity: Vector3D, + velocity: Vector2D, + acceleration: Vector2D, + angular_velocity: float, ): - array = np.zeros(len(DynamicStateSE3Index), dtype=np.float64) - array[DynamicStateSE3Index.VELOCITY_3D] = velocity.array - array[DynamicStateSE3Index.ACCELERATION_3D] = acceleration.array - array[DynamicStateSE3Index.ANGULAR_VELOCITY_3D] = angular_velocity.array + """Initialize a :class:`DynamicStateSE2` instance. + + :param velocity: 2D velocity vector. + :param acceleration: 2D acceleration vector. + :param angular_velocity: Angular velocity around the Z axis (yaw). + """ + array = np.zeros(len(DynamicStateSE2Index), dtype=np.float64) + array[DynamicStateSE2Index.VELOCITY_2D] = velocity.array + array[DynamicStateSE2Index.ACCELERATION_2D] = acceleration.array + array[DynamicStateSE2Index.ANGULAR_VELOCITY_Z] = angular_velocity self._array = array @classmethod - def from_array(cls, array: npt.NDArray[np.float64]) -> DynamicStateSE3: - """ - Create a DynamicVehicleState from an array. + def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> DynamicStateSE2: + """Create a :class:`DynamicStateSE2` from NumPy array of shape (5,), indexed by :class:`DynamicStateSE2Index`. + :param array: The array containing the dynamic state information. - :return: A DynamicVehicleState instance. + :param copy: Whether to copy the array data. + :return: A :class:`DynamicStateSE2` instance. """ assert array.ndim == 1 - assert array.shape[0] == len(DynamicStateSE3Index) + assert array.shape[0] == len(DynamicStateSE2Index) instance = object.__new__(cls) - instance._array = array + object.__setattr__(instance, "_array", array.copy() if copy else array) return instance - @property - def array(self) -> npt.NDArray[np.float64]: - return self._array - @property def velocity(self) -> Vector2D: + """2D velocity vector.""" return Vector2D.from_array(self._array[DynamicStateSE2Index.VELOCITY_2D], copy=False) @property def velocity_2d(self) -> Vector2D: + """2D velocity vector.""" return self.velocity @property def acceleration(self) -> Vector2D: + """2D acceleration vector.""" return Vector2D.from_array(self._array[DynamicStateSE2Index.ACCELERATION_2D], copy=False) @property def acceleration_2d(self) -> Vector2D: - return Vector2D.from_array(self._array[DynamicStateSE2Index.ACCELERATION_2D], copy=False) + """2D acceleration vector.""" + return self.acceleration @property def angular_velocity(self) -> float: + """Angular velocity around the Z axis (yaw).""" return self._array[DynamicStateSE2Index.ANGULAR_VELOCITY_Z] + + @property + def array(self) -> npt.NDArray[np.float64]: + """NumPy array representation of shape (5,), indexed by :class:`DynamicStateSE2Index`.""" + return self._array diff --git a/tests/unit/datatypes/detections/test_traffic_lights.py b/tests/unit/datatypes/detections/test_traffic_lights.py index edd90fcb..118d413b 100644 --- a/tests/unit/datatypes/detections/test_traffic_lights.py +++ b/tests/unit/datatypes/detections/test_traffic_lights.py @@ -24,7 +24,7 @@ def test_creation_with_required_fields(self): def test_creation_with_timepoint(self): """Test that TrafficLightDetection can be created with timepoint.""" - timepoint = TimePoint(0) + timepoint = TimePoint.from_s(0) detection = TrafficLightDetection( lane_id=2, status=TrafficLightStatus.RED, diff --git a/tests/unit/datatypes/vehicle_state/test_dynamic_state.py b/tests/unit/datatypes/vehicle_state/test_dynamic_state.py new file mode 100644 index 00000000..d9a0fced --- /dev/null +++ b/tests/unit/datatypes/vehicle_state/test_dynamic_state.py @@ -0,0 +1,109 @@ +import unittest + +import numpy as np + +from py123d.datatypes.vehicle_state.dynamic_state import ( + DynamicStateSE2, + DynamicStateSE2Index, + DynamicStateSE3, + DynamicStateSE3Index, +) +from py123d.geometry import Vector2D, Vector3D + + +class TestDynamicStateSE3(unittest.TestCase): + def test_init(self): + velocity = Vector3D(1.0, 2.0, 3.0) + acceleration = Vector3D(4.0, 5.0, 6.0) + angular_velocity = Vector3D(7.0, 8.0, 9.0) + + state = DynamicStateSE3(velocity, acceleration, angular_velocity) + + assert np.allclose(state.velocity.array, velocity.array) + assert np.allclose(state.acceleration.array, acceleration.array) + assert np.allclose(state.angular_velocity.array, angular_velocity.array) + + def test_from_array(self): + array = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]) + state = DynamicStateSE3.from_array(array) + + assert np.allclose(state.array, array) + assert state.array is not array # Default copy=True + assert len(state.array) == len(DynamicStateSE3Index) + + def test_from_array_no_copy(self): + array = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]) + state = DynamicStateSE3.from_array(array, copy=False) + + assert state.array is array + + def test_velocity_properties(self): + velocity = Vector3D(1.0, 2.0, 3.0) + state = DynamicStateSE3(velocity, Vector3D(0, 0, 0), Vector3D(0, 0, 0)) + + assert np.allclose(state.velocity_3d.array, [1.0, 2.0, 3.0]) + assert np.allclose(state.velocity_2d.array, [1.0, 2.0]) + + def test_acceleration_properties(self): + acceleration = Vector3D(4.0, 5.0, 6.0) + state = DynamicStateSE3(Vector3D(0, 0, 0), acceleration, Vector3D(0, 0, 0)) + + assert np.allclose(state.acceleration_3d.array, [4.0, 5.0, 6.0]) + assert np.allclose(state.acceleration_2d.array, [4.0, 5.0]) + + def test_dynamic_state_se2_projection(self): + velocity = Vector3D(1.0, 2.0, 3.0) + acceleration = Vector3D(4.0, 5.0, 6.0) + angular_velocity = Vector3D(7.0, 8.0, 9.0) + + state_se3 = DynamicStateSE3(velocity, acceleration, angular_velocity) + state_se2 = state_se3.dynamic_state_se2 + + assert np.allclose(state_se2.velocity.array, [1.0, 2.0]) + assert np.allclose(state_se2.acceleration.array, [4.0, 5.0]) + assert np.isclose(state_se2.angular_velocity, 9.0) + + +class TestDynamicStateSE2(unittest.TestCase): + def test_init(self): + velocity = Vector2D(1.0, 2.0) + acceleration = Vector2D(3.0, 4.0) + angular_velocity = 5.0 + + state = DynamicStateSE2(velocity, acceleration, angular_velocity) + + assert np.allclose(state.velocity.array, velocity.array) + assert np.allclose(state.acceleration.array, acceleration.array) + assert np.isclose(state.angular_velocity, angular_velocity) + assert len(state.array) == len(DynamicStateSE2Index) + + def test_from_array(self): + array = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + state = DynamicStateSE2.from_array(array) + + assert np.allclose(state.array, array) + + def test_velocity_properties(self): + velocity = Vector2D(1.0, 2.0) + state = DynamicStateSE2(velocity, Vector2D(0, 0), 0.0) + + assert np.allclose(state.velocity.array, [1.0, 2.0]) + assert np.allclose(state.velocity_2d.array, [1.0, 2.0]) + + def test_acceleration_properties(self): + acceleration = Vector2D(3.0, 4.0) + state = DynamicStateSE2(Vector2D(0, 0), acceleration, 0.0) + + assert np.allclose(state.acceleration.array, [3.0, 4.0]) + assert np.allclose(state.acceleration_2d.array, [3.0, 4.0]) + + def test_angular_velocity_property(self): + state = DynamicStateSE2(Vector2D(0, 0), Vector2D(0, 0), 5.0) + + assert np.isclose(state.angular_velocity, 5.0) + + def test_array_property(self): + array = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + state = DynamicStateSE2.from_array(array) + + assert np.array_equal(state.array, array) From 817f1ab95f413f34db0f628f27f64e3a516766d8 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 11:46:02 +0100 Subject: [PATCH 15/50] Enhance ego vehicle documentation and add tests. --- .../datatypes/vehicle_state/01_ego_state.rst | 9 + .../datatypes/vehicle_state/ego_state.py | 142 ++++++-- .../datatypes/vehicle_state/test_ego_state.py | 303 ++++++++++++++++++ 3 files changed, 426 insertions(+), 28 deletions(-) create mode 100644 tests/unit/datatypes/vehicle_state/test_ego_state.py diff --git a/docs/api/datatypes/vehicle_state/01_ego_state.rst b/docs/api/datatypes/vehicle_state/01_ego_state.rst index 4dcc4d83..1cd87405 100644 --- a/docs/api/datatypes/vehicle_state/01_ego_state.rst +++ b/docs/api/datatypes/vehicle_state/01_ego_state.rst @@ -1,10 +1,19 @@ Ego Vehicle State ^^^^^^^^^^^^^^^^^ +Ego State in SE(2) +------------------ + .. autoclass:: py123d.datatypes.vehicle_state.EgoStateSE2 :members: + :exclude-members: __init__ :autoclasstoc: + +Ego State in SE(3) +------------------ + .. autoclass:: py123d.datatypes.vehicle_state.EgoStateSE3 :members: + :exclude-members: __init__ :autoclasstoc: diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index a7ffae02..7b3a521c 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Final, Optional from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel @@ -20,6 +19,10 @@ class EgoStateSE3: + """The EgoStateSE3 represents the state of the ego vehicle in SE3 (3D space). + It includes the rear axle pose, vehicle parameters, optional dynamic state, + optional timepoint, and optional tire steering angle. + """ def __init__( self, @@ -29,6 +32,14 @@ def __init__( timepoint: Optional[TimePoint] = None, tire_steering_angle: Optional[float] = 0.0, ): + """Initialize an :class:`EgoStateSE3` instance. + + :param rear_axle_se3: The pose of the rear axle in SE3. + :param vehicle_parameters: The parameters of the vehicle. + :param dynamic_state_se3: The dynamic state of the vehicle, defaults to None. + :param timepoint: The timepoint of the state, defaults to None. + :param tire_steering_angle: The tire steering angle, defaults to 0.0. + """ self._rear_axle_se3 = rear_axle_se3 self._vehicle_parameters = vehicle_parameters self._dynamic_state_se3 = dynamic_state_se3 @@ -44,6 +55,15 @@ def from_center( timepoint: Optional[TimePoint] = None, tire_steering_angle: float = 0.0, ) -> EgoStateSE3: + """Create an :class:`EgoStateSE3` from the center pose. + + :param center_se3: The center pose in SE3. + :param vehicle_parameters: The parameters of the vehicle. + :param dynamic_state_se3: The dynamic state of the vehicle, defaults to None. + :param timepoint: The timepoint of the state, defaults to None. + :param tire_steering_angle: The tire steering angle, defaults to 0.0. + :return: An :class:`EgoStateSE3` instance. + """ rear_axle_se3 = center_se3_to_rear_axle_se3( center_se3=center_se3, @@ -68,6 +88,15 @@ def from_rear_axle( timepoint: Optional[TimePoint] = None, tire_steering_angle: float = 0.0, ) -> EgoStateSE3: + """Create an :class:`EgoStateSE3` from the rear axle pose. + + :param rear_axle_se3: The pose of the rear axle in SE3. + :param vehicle_parameters: The parameters of the vehicle. + :param dynamic_state_se3: The dynamic state of the vehicle, defaults to None. + :param timepoint: The timepoint of the state, defaults to None. + :param tire_steering_angle: The tire steering angle, defaults to 0.0. + :return: An :class:`EgoStateSE3` instance. + """ return EgoStateSE3( rear_axle_se3=rear_axle_se3, @@ -77,36 +106,49 @@ def from_rear_axle( tire_steering_angle=tire_steering_angle, ) + @property + def rear_axle(self) -> PoseSE3: + """The :class:`~py123d.geometry.PoseSE3` of the rear axle in SE3.""" + return self._rear_axle_se3 + @property def rear_axle_se3(self) -> PoseSE3: + """The :class:`~py123d.geometry.PoseSE3` of the rear axle in SE3.""" return self._rear_axle_se3 + @property + def rear_axle_se2(self) -> PoseSE2: + """The :class:`~py123d.geometry.PoseSE2` of the rear axle in SE2.""" + return self._rear_axle_se3.pose_se2 + @property def vehicle_parameters(self) -> VehicleParameters: + """The :class:`~py123d.datatypes.vehicle_state.VehicleParameters` of the vehicle.""" return self._vehicle_parameters @property def dynamic_state_se3(self) -> Optional[DynamicStateSE3]: + """The :class:`~py123d.datatypes.vehicle_state.DynamicStateSE3` of the vehicle.""" return self._dynamic_state_se3 @property def timepoint(self) -> Optional[TimePoint]: + """The :class:`~py123d.datatypes.time.TimePoint` of the ego state, if available.""" return self._timepoint @property def tire_steering_angle(self) -> Optional[float]: + """The tire steering angle of the ego state, if available.""" return self._tire_steering_angle @property - def rear_axle_se2(self) -> PoseSE2: - return self._rear_axle_se3.pose_se2 - - @property - def rear_axle(self) -> PoseSE3: - return self._rear_axle_se3 + def center(self) -> PoseSE3: + """The :class:`~py123d.geometry.PoseSE3` of the vehicle center in SE3.""" + return self.center_se3 @property def center_se3(self) -> PoseSE3: + """The :class:`~py123d.geometry.PoseSE3` of the vehicle center in SE3.""" return rear_axle_se3_to_center_se3( rear_axle_se3=self._rear_axle_se3, vehicle_parameters=self._vehicle_parameters, @@ -114,14 +156,17 @@ def center_se3(self) -> PoseSE3: @property def center_se2(self) -> PoseSE2: + """The :class:`~py123d.geometry.PoseSE2` of the vehicle center in SE2.""" return self.center_se3.pose_se2 @property - def center(self) -> PoseSE3: - return self.center_se3 + def bounding_box(self) -> BoundingBoxSE3: + """The :class:`~py123d.geometry.BoundingBoxSE3` of the ego vehicle.""" + return self.bounding_box_se3 @property def bounding_box_se3(self) -> BoundingBoxSE3: + """The :class:`~py123d.geometry.BoundingBoxSE3` of the ego vehicle.""" return BoundingBoxSE3( center=self.center_se3, length=self.vehicle_parameters.length, @@ -131,14 +176,17 @@ def bounding_box_se3(self) -> BoundingBoxSE3: @property def bounding_box_se2(self) -> BoundingBoxSE2: + """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" return self.bounding_box.bounding_box_se2 @property - def bounding_box(self) -> BoundingBoxSE3: - return self.bounding_box_se3 + def box_detection(self) -> BoxDetectionSE3: + """The :class:`~py123d.datatypes.detections.BoxDetectionSE3` projection of the ego vehicle.""" + return self.box_detection_se3 @property def box_detection_se3(self) -> BoxDetectionSE3: + """The :class:`~py123d.datatypes.detections.BoxDetectionSE3` projection of the ego vehicle.""" return BoxDetectionSE3( metadata=BoxDetectionMetadata( label=DefaultBoxDetectionLabel.EGO, @@ -152,14 +200,12 @@ def box_detection_se3(self) -> BoxDetectionSE3: @property def box_detection_se2(self) -> BoxDetectionSE2: - return self.box_detection.box_detection_se2 - - @property - def box_detection(self) -> BoxDetectionSE3: - return self.box_detection_se3 + """The :class:`~py123d.datatypes.detections.BoxDetectionSE2` projection of the ego vehicle.""" + return self.box_detection_se3.box_detection_se2 @property def ego_state_se2(self) -> EgoStateSE2: + """The :class:`EgoStateSE2` projection of this SE3 ego state.""" return EgoStateSE2.from_rear_axle( rear_axle_se2=self.rear_axle_se2, vehicle_parameters=self.vehicle_parameters, @@ -169,8 +215,10 @@ def ego_state_se2(self) -> EgoStateSE2: ) -@dataclass class EgoStateSE2: + """The EgoStateSE2 represents the state of the ego vehicle in SE2 (2D space). + It includes the rear axle pose, vehicle parameters, optional dynamic state, and optional timepoint. + """ def __init__( self, @@ -180,6 +228,14 @@ def __init__( timepoint: Optional[TimePoint] = None, tire_steering_angle: Optional[float] = 0.0, ): + """Initialize an :class:`EgoStateSE2` instance. + + :param rear_axle_se2: The pose of the rear axle in SE2. + :param vehicle_parameters: The parameters of the vehicle. + :param dynamic_state_se2: The dynamic state of the vehicle in SE2, defaults to None. + :param timepoint: The timepoint of the state, defaults to None. + :param tire_steering_angle: The tire steering angle, defaults to 0.0 + """ self._rear_axle_se2: PoseSE2 = rear_axle_se2 self._vehicle_parameters: VehicleParameters = vehicle_parameters self._dynamic_state_se2: Optional[DynamicStateSE2] = dynamic_state_se2 @@ -195,6 +251,15 @@ def from_rear_axle( timepoint: TimePoint, tire_steering_angle: float = 0.0, ) -> EgoStateSE2: + """Create an :class:`EgoStateSE2` from the rear axle pose. + + :param rear_axle_se2: The pose of the rear axle in SE2. + :param dynamic_state_se2: The dynamic state of the vehicle in SE2. + :param vehicle_parameters: The parameters of the vehicle. + :param timepoint: The timepoint of the state. + :param tire_steering_angle: The tire steering angle, defaults to 0.0. + :return: An instance of :class:`EgoStateSE2`. + """ return EgoStateSE2( rear_axle_se2=rear_axle_se2, @@ -213,6 +278,15 @@ def from_center( timepoint: TimePoint, tire_steering_angle: float = 0.0, ) -> EgoStateSE2: + """Create an :class:`EgoStateSE2` from the center pose. + + :param center_se2: The pose of the center in SE2. + :param dynamic_state_se2: The dynamic state of the vehicle in SE2. + :param vehicle_parameters: The parameters of the vehicle. + :param timepoint: The timepoint of the state. + :param tire_steering_angle: The tire steering angle, defaults to 0.0. + :return: An instance of :class:`EgoStateSE2`. + """ rear_axle_se2 = center_se2_to_rear_axle_se2( center_se2=center_se2, @@ -228,40 +302,54 @@ def from_center( tire_steering_angle=tire_steering_angle, ) + @property + def rear_axle(self) -> PoseSE2: + """The :class:`~py123d.geometry.PoseSE2` of the rear axle in SE2.""" + return self.rear_axle_se2 + @property def rear_axle_se2(self) -> PoseSE2: + """The :class:`~py123d.geometry.PoseSE2` of the rear axle in SE2.""" return self._rear_axle_se2 @property def vehicle_parameters(self) -> VehicleParameters: + """The :class:`~py123d.datatypes.vehicle_state.VehicleParameters` of the vehicle.""" return self._vehicle_parameters @property - def dynamic_state_se2(self) -> Optional[DynamicStateSE3]: + def dynamic_state_se2(self) -> Optional[DynamicStateSE2]: + """The :class:`~py123d.datatypes.vehicle_state.DynamicStateSE2` of the vehicle.""" return self._dynamic_state_se2 @property def timepoint(self) -> Optional[TimePoint]: + """The :class:`~py123d.datatypes.time.TimePoint` of the ego state, if available.""" return self._timepoint @property def tire_steering_angle(self) -> Optional[float]: + """The tire steering angle of the ego state, if available.""" return self._tire_steering_angle @property - def rear_axle(self) -> PoseSE2: - return self.rear_axle_se2 + def center(self) -> PoseSE3: + """The :class:`~py123d.geometry.PoseSE2` of the center in SE2.""" + return self.center_se2 @property def center_se2(self) -> PoseSE2: + """The :class:`~py123d.geometry.PoseSE2` of the center in SE2.""" return rear_axle_se2_to_center_se2(rear_axle_se2=self.rear_axle_se2, vehicle_parameters=self.vehicle_parameters) @property - def center(self) -> PoseSE3: - return self.center_se2 + def bounding_box(self) -> BoundingBoxSE2: + """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" + return self.bounding_box_se2 @property def bounding_box_se2(self) -> BoundingBoxSE2: + """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" return BoundingBoxSE2( center=self.center_se2, length=self.vehicle_parameters.length, @@ -269,11 +357,13 @@ def bounding_box_se2(self) -> BoundingBoxSE2: ) @property - def bounding_box(self) -> BoundingBoxSE2: - return self.bounding_box_se2 + def box_detection(self) -> BoxDetectionSE2: + """The :class:`~py123d.datatypes.detections.BoxDetectionSE2` projection of the ego vehicle.""" + return self.box_detection_se2 @property def box_detection_se2(self) -> BoxDetectionSE2: + """The :class:`~py123d.datatypes.detections.BoxDetectionSE2` projection of the ego vehicle.""" return BoxDetectionSE2( metadata=BoxDetectionMetadata( label=DefaultBoxDetectionLabel.EGO, @@ -284,7 +374,3 @@ def box_detection_se2(self) -> BoxDetectionSE2: bounding_box_se2=self.bounding_box, velocity_2d=self.dynamic_state_se2.velocity, ) - - @property - def box_detection(self) -> BoxDetectionSE2: - return self.box_detection_se2 diff --git a/tests/unit/datatypes/vehicle_state/test_ego_state.py b/tests/unit/datatypes/vehicle_state/test_ego_state.py new file mode 100644 index 00000000..89b447b1 --- /dev/null +++ b/tests/unit/datatypes/vehicle_state/test_ego_state.py @@ -0,0 +1,303 @@ +import unittest + +from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel +from py123d.datatypes.time import TimePoint +from py123d.datatypes.vehicle_state import ( + DynamicStateSE2, + DynamicStateSE3, + EgoStateSE2, + EgoStateSE3, + VehicleParameters, +) +from py123d.datatypes.vehicle_state.ego_state import EGO_TRACK_TOKEN +from py123d.geometry import PoseSE2, PoseSE3, Vector2D, Vector3D + + +class TestEgoStateSE2(unittest.TestCase): + def setUp(self): + """Set up test fixtures for EgoStateSE2 tests.""" + self.rear_axle_pose = PoseSE2(x=0.0, y=0.0, yaw=0.0) + self.vehicle_params = VehicleParameters( + vehicle_name="test_vehicle", + length=4.5, + width=2.0, + height=1.5, + wheel_base=2.7, + rear_axle_to_center_longitudinal=1.35, + rear_axle_to_center_vertical=0.5, + ) + self.dynamic_state = DynamicStateSE2( + velocity=Vector2D(1.0, 0.0), + acceleration=Vector2D(0.1, 0.0), + angular_velocity=0.1, + ) + self.timepoint = TimePoint.from_us(1000000) + self.tire_steering_angle = 0.2 + + def test_init(self): + """Test EgoStateSE2 initialization.""" + ego_state = EgoStateSE2( + rear_axle_se2=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se2=self.dynamic_state, + timepoint=self.timepoint, + tire_steering_angle=self.tire_steering_angle, + ) + + self.assertEqual(ego_state.rear_axle_se2, self.rear_axle_pose) + self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + self.assertEqual(ego_state.dynamic_state_se2, self.dynamic_state) + self.assertEqual(ego_state.timepoint, self.timepoint) + self.assertEqual(ego_state.tire_steering_angle, self.tire_steering_angle) + + def test_from_rear_axle(self): + """Test creating EgoStateSE2 from rear axle.""" + ego_state = EgoStateSE2.from_rear_axle( + rear_axle_se2=self.rear_axle_pose, + dynamic_state_se2=self.dynamic_state, + vehicle_parameters=self.vehicle_params, + timepoint=self.timepoint, + tire_steering_angle=self.tire_steering_angle, + ) + + self.assertEqual(ego_state.rear_axle_se2, self.rear_axle_pose) + self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + + def test_from_center(self): + """Test creating EgoStateSE2 from center pose.""" + center_pose = PoseSE2(x=1.35, y=0.0, yaw=0.0) + ego_state = EgoStateSE2.from_center( + center_se2=center_pose, + dynamic_state_se2=self.dynamic_state, + vehicle_parameters=self.vehicle_params, + timepoint=self.timepoint, + tire_steering_angle=self.tire_steering_angle, + ) + + self.assertIsNotNone(ego_state.rear_axle_se2) + self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + + def test_rear_axle_property(self): + """Test rear_axle property.""" + ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + self.assertEqual(ego_state.rear_axle, self.rear_axle_pose) + self.assertEqual(ego_state.rear_axle_se2, self.rear_axle_pose) + + def test_center_property(self): + """Test center property calculation.""" + ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + center = ego_state.center_se2 + self.assertIsNotNone(center) + self.assertAlmostEqual(center.x, self.vehicle_params.rear_axle_to_center_longitudinal) + self.assertAlmostEqual(center.y, 0.0) + self.assertAlmostEqual(center.yaw, 0.0) + + def test_bounding_box_property(self): + """Test bounding box properties.""" + ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + bbox = ego_state.bounding_box_se2 + self.assertIsNotNone(bbox) + self.assertEqual(bbox.length, self.vehicle_params.length) + self.assertEqual(bbox.width, self.vehicle_params.width) + self.assertEqual(ego_state.bounding_box, bbox) + + def test_box_detection_property(self): + """Test box detection properties.""" + ego_state = EgoStateSE2( + rear_axle_se2=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se2=self.dynamic_state, + timepoint=self.timepoint, + ) + + box_det = ego_state.box_detection_se2 + self.assertIsNotNone(box_det) + self.assertEqual(box_det.metadata.label, DefaultBoxDetectionLabel.EGO) + self.assertEqual(box_det.metadata.track_token, EGO_TRACK_TOKEN) + self.assertEqual(box_det.metadata.timepoint, self.timepoint) + + box_det = ego_state.box_detection + self.assertIsNotNone(box_det) + self.assertEqual(box_det.metadata.label, DefaultBoxDetectionLabel.EGO) + self.assertEqual(box_det.metadata.track_token, EGO_TRACK_TOKEN) + self.assertEqual(box_det.metadata.timepoint, self.timepoint) + + def test_optional_parameters_none(self): + """Test EgoStateSE2 with optional parameters as None.""" + ego_state = EgoStateSE2( + rear_axle_se2=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se2=None, + timepoint=None, + tire_steering_angle=None, + ) + + self.assertIsNone(ego_state.dynamic_state_se2) + self.assertIsNone(ego_state.timepoint) + self.assertIsNone(ego_state.tire_steering_angle) + + def test_default_tire_steering_angle(self): + """Test default tire steering angle value.""" + ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + self.assertEqual(ego_state.tire_steering_angle, 0.0) + + +class TestEgoStateSE3(unittest.TestCase): + def setUp(self): + """Set up test fixtures for EgoStateSE3 tests.""" + + self.rear_axle_pose = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + self.vehicle_params = VehicleParameters( + vehicle_name="test_vehicle", + length=4.5, + width=2.0, + height=1.5, + wheel_base=2.7, + rear_axle_to_center_longitudinal=1.35, + rear_axle_to_center_vertical=0.5, + ) + self.dynamic_state = DynamicStateSE3( + velocity=Vector3D(1.0, 0.0, 0.0), + acceleration=Vector3D(0.1, 0.0, 0.0), + angular_velocity=Vector3D(0.0, 0.0, 0.1), + ) + self.timepoint = TimePoint.from_us(1000000) + self.tire_steering_angle = 0.2 + + def test_init(self): + """Test EgoStateSE3 initialization.""" + ego_state = EgoStateSE3( + rear_axle_se3=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se3=self.dynamic_state, + timepoint=self.timepoint, + tire_steering_angle=self.tire_steering_angle, + ) + + self.assertEqual(ego_state.rear_axle_se3, self.rear_axle_pose) + self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + self.assertEqual(ego_state.dynamic_state_se3, self.dynamic_state) + self.assertEqual(ego_state.timepoint, self.timepoint) + self.assertEqual(ego_state.tire_steering_angle, self.tire_steering_angle) + + def test_from_rear_axle(self): + """Test creating EgoStateSE3 from rear axle.""" + ego_state = EgoStateSE3.from_rear_axle( + rear_axle_se3=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se3=self.dynamic_state, + timepoint=self.timepoint, + tire_steering_angle=self.tire_steering_angle, + ) + + self.assertEqual(ego_state.rear_axle_se3, self.rear_axle_pose) + self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + + def test_from_center(self): + """Test creating EgoStateSE3 from center pose.""" + center_pose = PoseSE3(x=1.35, y=0.0, z=0.5, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + ego_state = EgoStateSE3.from_center( + center_se3=center_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se3=self.dynamic_state, + timepoint=self.timepoint, + tire_steering_angle=self.tire_steering_angle, + ) + + self.assertIsNotNone(ego_state.rear_axle_se3) + self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + + def test_rear_axle_properties(self): + """Test rear_axle properties.""" + ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + self.assertEqual(ego_state.rear_axle, self.rear_axle_pose) + self.assertEqual(ego_state.rear_axle_se3, self.rear_axle_pose) + self.assertIsNotNone(ego_state.rear_axle_se2) + + def test_center_properties(self): + """Test center property calculation.""" + ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + center = ego_state.center_se3 + self.assertIsNotNone(center) + self.assertAlmostEqual(center.x, self.vehicle_params.rear_axle_to_center_longitudinal) + self.assertAlmostEqual(center.y, 0.0) + + center_se2 = ego_state.center_se2 + self.assertIsNotNone(center_se2) + self.assertEqual(ego_state.center, ego_state.center_se3) + + def test_bounding_box_properties(self): + """Test bounding box properties.""" + ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + bbox_se3 = ego_state.bounding_box_se3 + self.assertIsNotNone(bbox_se3) + self.assertEqual(bbox_se3.length, self.vehicle_params.length) + self.assertEqual(bbox_se3.width, self.vehicle_params.width) + self.assertEqual(bbox_se3.height, self.vehicle_params.height) + + bbox_se2 = ego_state.bounding_box_se2 + self.assertIsNotNone(bbox_se2) + self.assertEqual(ego_state.bounding_box, bbox_se3) + + def test_box_detection_properties(self): + """Test box detection properties.""" + ego_state = EgoStateSE3( + rear_axle_se3=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se3=self.dynamic_state, + timepoint=self.timepoint, + ) + + box_det_se3 = ego_state.box_detection_se3 + self.assertIsNotNone(box_det_se3) + self.assertEqual(box_det_se3.metadata.label, DefaultBoxDetectionLabel.EGO) + self.assertEqual(box_det_se3.metadata.track_token, EGO_TRACK_TOKEN) + self.assertEqual(box_det_se3.metadata.timepoint, self.timepoint) + + box_det_se2 = ego_state.box_detection_se2 + self.assertIsNotNone(box_det_se2) + self.assertEqual(ego_state.box_detection, box_det_se3) + + def test_ego_state_se2_projection(self): + """Test projection to EgoStateSE2.""" + ego_state = EgoStateSE3( + rear_axle_se3=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se3=self.dynamic_state, + timepoint=self.timepoint, + tire_steering_angle=self.tire_steering_angle, + ) + + ego_state_se2 = ego_state.ego_state_se2 + self.assertIsNotNone(ego_state_se2) + self.assertIsInstance(ego_state_se2, EgoStateSE2) + self.assertEqual(ego_state_se2.vehicle_parameters, self.vehicle_params) + self.assertEqual(ego_state_se2.timepoint, self.timepoint) + self.assertEqual(ego_state_se2.tire_steering_angle, self.tire_steering_angle) + + def test_optional_parameters_none(self): + """Test EgoStateSE3 with optional parameters as None.""" + ego_state = EgoStateSE3( + rear_axle_se3=self.rear_axle_pose, + vehicle_parameters=self.vehicle_params, + dynamic_state_se3=None, + timepoint=None, + tire_steering_angle=None, + ) + + self.assertIsNone(ego_state.dynamic_state_se3) + self.assertIsNone(ego_state.timepoint) + self.assertIsNone(ego_state.tire_steering_angle) + + def test_default_tire_steering_angle(self): + """Test default tire steering angle value.""" + ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) + + self.assertEqual(ego_state.tire_steering_angle, 0.0) From 75e5878c0f63ba5ce46c47590bad056a6caf7727 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 12:11:39 +0100 Subject: [PATCH 16/50] Enhance documentation and add tests for vehicle parameters. --- .../vehicle_state/vehicle_parameters.py | 93 +++++++++++++------ .../vehicle_state/test_vehicle_parameters.py | 73 +++++++++++++++ 2 files changed, 139 insertions(+), 27 deletions(-) create mode 100644 tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py diff --git a/src/py123d/datatypes/vehicle_state/vehicle_parameters.py b/src/py123d/datatypes/vehicle_state/vehicle_parameters.py index 42bc299a..92ae035c 100644 --- a/src/py123d/datatypes/vehicle_state/vehicle_parameters.py +++ b/src/py123d/datatypes/vehicle_state/vehicle_parameters.py @@ -3,71 +3,101 @@ from dataclasses import asdict, dataclass from py123d.geometry import PoseSE2, PoseSE3, Vector2D, Vector3D -from py123d.geometry.transform.transform_se2 import translate_se2_along_body_frame -from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame +from py123d.geometry.transform import translate_se2_along_body_frame, translate_se3_along_body_frame @dataclass class VehicleParameters: + """Parameters that describe the physical dimensions of a vehicle.""" vehicle_name: str + """Name of the vehicle model.""" width: float + """Width of the vehicle.""" + length: float + """Length of the vehicle.""" + height: float + """Height of the vehicle.""" wheel_base: float + """Wheel base of the vehicle (longitudinal distance between front and rear axles).""" + rear_axle_to_center_vertical: float + """Distance from the rear axle to the center of the vehicle (vertical).""" + rear_axle_to_center_longitudinal: float + """Distance from the rear axle to the center of the vehicle (longitudinal).""" @classmethod def from_dict(cls, data_dict: dict) -> VehicleParameters: - return VehicleParameters(**data_dict) + """Creates a VehicleParameters instance from a dictionary. - def to_dict(self) -> dict: - return asdict(self) + :param data_dict: Dictionary containing vehicle parameters. + :return: VehicleParameters instance. + """ + return VehicleParameters(**data_dict) @property def half_width(self) -> float: + """Half the width of the vehicle.""" return self.width / 2.0 @property def half_length(self) -> float: + """Half the length of the vehicle.""" return self.length / 2.0 @property def half_height(self) -> float: + """Half the height of the vehicle.""" return self.height / 2.0 + def to_dict(self) -> dict: + """Converts the :class:`VehicleParameters` instance to a dictionary. + + :return: Dictionary representation of the vehicle parameters. + """ + return asdict(self) + def get_nuplan_chrysler_pacifica_parameters() -> VehicleParameters: - # NOTE: use parameters from nuPlan dataset + """Helper function to get nuPlan Chrysler Pacifica vehicle parameters.""" + # NOTE: These parameters are mostly available in nuPlan, except for the rear_axle_to_center_vertical. + # The value is estimated based the LiDAR point cloud. + # [1] https://en.wikipedia.org/wiki/Chrysler_Pacifica_(minivan) return VehicleParameters( vehicle_name="nuplan_chrysler_pacifica", width=2.297, length=5.176, height=1.777, wheel_base=3.089, - rear_axle_to_center_vertical=0.45, # NOTE: missing in nuPlan, TODO: find more accurate value + rear_axle_to_center_vertical=0.45, rear_axle_to_center_longitudinal=1.461, ) def get_nuscenes_renault_zoe_parameters() -> VehicleParameters: - # https://en.wikipedia.org/wiki/Renault_Zoe + """Helper function to get nuScenes Renault Zoe vehicle parameters.""" + # NOTE: The parameters in nuScenes are estimates, and partially taken from the Renault Zoe model [1]. + # [1] https://en.wikipedia.org/wiki/Renault_Zoe return VehicleParameters( vehicle_name="nuscenes_renault_zoe", width=1.730, length=4.084, height=1.562, wheel_base=2.588, - rear_axle_to_center_vertical=1.562 / 2, # NOTE: missing in nuscenes, TODO: find more accurate value + rear_axle_to_center_vertical=1.562 / 2, rear_axle_to_center_longitudinal=1.385, ) def get_carla_lincoln_mkz_2020_parameters() -> VehicleParameters: - # NOTE: values are extracted from CARLA + """Helper function to get CARLA Lincoln MKZ 2020 vehicle parameters.""" + # NOTE: These parameters are taken from the CARLA simulator vehicle model. The rear axles to center transform + # parameters are calculated based on parameters from the CARLA simulator. return VehicleParameters( vehicle_name="carla_lincoln_mkz_2020", width=1.83671, @@ -80,8 +110,10 @@ def get_carla_lincoln_mkz_2020_parameters() -> VehicleParameters: def get_wopd_chrysler_pacifica_parameters() -> VehicleParameters: - # NOTE: use parameters from nuPlan dataset - # Find better parameters for WOPD ego vehicle + """Helper function to get WOPD Chrysler Pacifica vehicle parameters.""" + # NOTE: These parameters are estimates based on the vehicle model used in the WOPD dataset. + # The vehicle should be the same (or a similar) vehicle model to nuPlan and PandaSet [1]. + # [1] https://en.wikipedia.org/wiki/Chrysler_Pacifica_(minivan) return VehicleParameters( vehicle_name="wopd_chrysler_pacifica", width=2.297, @@ -94,11 +126,13 @@ def get_wopd_chrysler_pacifica_parameters() -> VehicleParameters: def get_kitti360_vw_passat_parameters() -> VehicleParameters: - # The KITTI-360 dataset uses a 2006 VW Passat Variant B6. - # https://en.wikipedia.org/wiki/Volkswagen_Passat_(B6) - # [1] https://scispace.com/pdf/team-annieway-s-autonomous-system-18ql8b7kki.pdf - # NOTE: Parameters are estimated from the vehicle model. - # https://www.cvlibs.net/datasets/kitti-360/documentation.php + """Helper function to get KITTI-360 VW Passat vehicle parameters.""" + # NOTE: The parameters in KITTI-360 are estimates based on the vehicle model used in the dataset + # Uses a 2006 VW Passat Variant B6 [1]. Vertical distance is estimated based on the LiDAR. + # KITTI-360 is currently the only dataset where the IMU has a lateral offset to the rear axle [2] + # We do account for such offsets, but the overall estimations are not perfect. + # [1] https://en.wikipedia.org/wiki/Volkswagen_Passat_(B6) + # [2] https://www.cvlibs.net/datasets/kitti-360/documentation.php return VehicleParameters( vehicle_name="kitti360_vw_passat", width=1.820, @@ -111,8 +145,9 @@ def get_kitti360_vw_passat_parameters() -> VehicleParameters: def get_av2_ford_fusion_hybrid_parameters() -> VehicleParameters: - # NOTE: Parameters are estimated from the vehicle model. - # https://en.wikipedia.org/wiki/Ford_Fusion_Hybrid#Second_generation + """Helper function to get Argoverse 2 Ford Fusion Hybrid vehicle parameters.""" + # NOTE: Parameters are estimated from the vehicle model [1] and LiDAR point cloud. + # [1] https://en.wikipedia.org/wiki/Ford_Fusion_Hybrid#Second_generation # https://github.com/argoverse/av2-api/blob/6b22766247eda941cb1953d6a58e8d5631c561da/tests/unit/map/test_map_api.py#L375 return VehicleParameters( vehicle_name="av2_ford_fusion_hybrid", @@ -126,6 +161,10 @@ def get_av2_ford_fusion_hybrid_parameters() -> VehicleParameters: def get_pandaset_chrysler_pacifica_parameters() -> VehicleParameters: + """Helper function to get PandaSet Chrysler Pacifica vehicle parameters.""" + # NOTE: Some parameters are available in PandaSet [1], others are estimated based on the vehicle model [2]. + # [1] https://arxiv.org/pdf/2112.12610 (Figure 3 (a)) + # [2] https://en.wikipedia.org/wiki/Chrysler_Pacifica_(minivan) return VehicleParameters( vehicle_name="pandaset_chrysler_pacifica", width=2.297, @@ -138,8 +177,8 @@ def get_pandaset_chrysler_pacifica_parameters() -> VehicleParameters: def center_se3_to_rear_axle_se3(center_se3: PoseSE3, vehicle_parameters: VehicleParameters) -> PoseSE3: - """ - Converts a center state to a rear axle state. + """Converts a center state to a rear axle state. + :param center_se3: The center state. :param vehicle_parameters: The vehicle parameters. :return: The rear axle state. @@ -155,8 +194,8 @@ def center_se3_to_rear_axle_se3(center_se3: PoseSE3, vehicle_parameters: Vehicle def rear_axle_se3_to_center_se3(rear_axle_se3: PoseSE3, vehicle_parameters: VehicleParameters) -> PoseSE3: - """ - Converts a rear axle state to a center state. + """Converts a rear axle state to a center state. + :param rear_axle_se3: The rear axle state. :param vehicle_parameters: The vehicle parameters. :return: The center state. @@ -172,8 +211,8 @@ def rear_axle_se3_to_center_se3(rear_axle_se3: PoseSE3, vehicle_parameters: Vehi def center_se2_to_rear_axle_se2(center_se2: PoseSE2, vehicle_parameters: VehicleParameters) -> PoseSE2: - """ - Converts a center state to a rear axle state in 2D. + """Converts a center state to a rear axle state in 2D. + :param center_se2: The center state in 2D. :param vehicle_parameters: The vehicle parameters. :return: The rear axle state in 2D. @@ -182,8 +221,8 @@ def center_se2_to_rear_axle_se2(center_se2: PoseSE2, vehicle_parameters: Vehicle def rear_axle_se2_to_center_se2(rear_axle_se2: PoseSE2, vehicle_parameters: VehicleParameters) -> PoseSE2: - """ - Converts a rear axle state to a center state in 2D. + """Converts a rear axle state to a center state in 2D. + :param rear_axle_se2: The rear axle state in 2D. :param vehicle_parameters: The vehicle parameters. :return: The center state in 2D. diff --git a/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py new file mode 100644 index 00000000..9b0da5d2 --- /dev/null +++ b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py @@ -0,0 +1,73 @@ +import unittest + +from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters + + +class TestVehicleParameters(unittest.TestCase): + + def setUp(self): + self.params = VehicleParameters( + vehicle_name="test_vehicle", + width=2.0, + length=5.0, + height=1.8, + wheel_base=3.0, + rear_axle_to_center_vertical=0.5, + rear_axle_to_center_longitudinal=1.5, + ) + + def test_initialization(self): + self.assertEqual(self.params.vehicle_name, "test_vehicle") + self.assertEqual(self.params.width, 2.0) + self.assertEqual(self.params.length, 5.0) + self.assertEqual(self.params.height, 1.8) + self.assertEqual(self.params.wheel_base, 3.0) + self.assertEqual(self.params.rear_axle_to_center_vertical, 0.5) + self.assertEqual(self.params.rear_axle_to_center_longitudinal, 1.5) + + def test_half_width(self): + self.assertEqual(self.params.half_width, 1.0) + + def test_half_length(self): + self.assertEqual(self.params.half_length, 2.5) + + def test_half_height(self): + self.assertEqual(self.params.half_height, 0.9) + + def test_to_dict(self): + result = self.params.to_dict() + expected = { + "vehicle_name": "test_vehicle", + "width": 2.0, + "length": 5.0, + "height": 1.8, + "wheel_base": 3.0, + "rear_axle_to_center_vertical": 0.5, + "rear_axle_to_center_longitudinal": 1.5, + } + self.assertEqual(result, expected) + + def test_from_dict(self): + data = { + "vehicle_name": "from_dict_vehicle", + "width": 1.5, + "length": 4.0, + "height": 1.6, + "wheel_base": 2.5, + "rear_axle_to_center_vertical": 0.4, + "rear_axle_to_center_longitudinal": 1.2, + } + params = VehicleParameters.from_dict(data) + self.assertEqual(params.vehicle_name, "from_dict_vehicle") + self.assertEqual(params.width, 1.5) + self.assertEqual(params.length, 4.0) + self.assertEqual(params.height, 1.6) + self.assertEqual(params.wheel_base, 2.5) + self.assertEqual(params.rear_axle_to_center_vertical, 0.4) + self.assertEqual(params.rear_axle_to_center_longitudinal, 1.2) + + def test_from_dict_to_dict_round_trip(self): + original_dict = self.params.to_dict() + recreated_params = VehicleParameters.from_dict(original_dict) + recreated_dict = recreated_params.to_dict() + self.assertEqual(original_dict, recreated_dict) From 3d8090ab6c5aaadc1c47fa33e99c8f57e63b4fb9 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 15:10:55 +0100 Subject: [PATCH 17/50] Add tests for map objects and improve documentation. --- docs/api/datatypes/index.rst | 2 +- docs/api/datatypes/map_objects/01_lane.rst | 5 + .../datatypes/map_objects/09_road_edge.rst | 2 + .../datatypes/map_objects/10_road_line.rst | 3 +- src/py123d/api/map/gpkg/gpkg_map_api.py | 41 +- src/py123d/api/map/map_api.py | 6 +- .../datatypes/map_objects/base_map_objects.py | 60 +- .../datatypes/map_objects/map_layer_types.py | 100 +- .../datatypes/map_objects/map_objects.py | 215 ++++- src/py123d/datatypes/map_objects/utils.py | 15 + tests/unit/datatypes/map_objects/__init__.py | 0 .../datatypes/map_objects/mock_map_api.py | 126 +++ .../map_objects/test_base_map_objects.py | 209 ++++ .../datatypes/map_objects/test_map_objects.py | 909 ++++++++++++++++++ .../vehicle_state/test_vehicle_parameters.py | 7 + 15 files changed, 1613 insertions(+), 87 deletions(-) create mode 100644 tests/unit/datatypes/map_objects/__init__.py create mode 100644 tests/unit/datatypes/map_objects/mock_map_api.py create mode 100644 tests/unit/datatypes/map_objects/test_base_map_objects.py create mode 100644 tests/unit/datatypes/map_objects/test_map_objects.py diff --git a/docs/api/datatypes/index.rst b/docs/api/datatypes/index.rst index f4979580..44585fdd 100644 --- a/docs/api/datatypes/index.rst +++ b/docs/api/datatypes/index.rst @@ -3,7 +3,7 @@ Datatypes .. toctree:: - :maxdepth: 3 + :maxdepth: 2 sensors/index detections/index diff --git a/docs/api/datatypes/map_objects/01_lane.rst b/docs/api/datatypes/map_objects/01_lane.rst index 22940867..f9aacd92 100644 --- a/docs/api/datatypes/map_objects/01_lane.rst +++ b/docs/api/datatypes/map_objects/01_lane.rst @@ -4,3 +4,8 @@ Lane .. autoclass:: py123d.datatypes.map_objects.Lane :exclude-members: __init__ :autoclasstoc: + + +.. .. autoclass:: py123d.datatypes.map_objects.LaneType +.. :no-inherited-members: +.. :exclude-members: __new__ diff --git a/docs/api/datatypes/map_objects/09_road_edge.rst b/docs/api/datatypes/map_objects/09_road_edge.rst index cdfa28f2..b9056a16 100644 --- a/docs/api/datatypes/map_objects/09_road_edge.rst +++ b/docs/api/datatypes/map_objects/09_road_edge.rst @@ -3,8 +3,10 @@ Road Edge .. autoclass:: py123d.datatypes.map_objects.RoadEdge :no-inherited-members: + :exclude-members: __init__ :autoclasstoc: .. autoclass:: py123d.datatypes.map_objects.RoadEdgeType :no-inherited-members: + :exclude-members: __new__ diff --git a/docs/api/datatypes/map_objects/10_road_line.rst b/docs/api/datatypes/map_objects/10_road_line.rst index 606c0f1f..d7f538d3 100644 --- a/docs/api/datatypes/map_objects/10_road_line.rst +++ b/docs/api/datatypes/map_objects/10_road_line.rst @@ -6,4 +6,5 @@ Road Line :autoclasstoc: .. autoclass:: py123d.datatypes.map_objects.RoadLineType - :autoclasstoc: + :no-inherited-members: + :exclude-members: __new__ diff --git a/src/py123d/api/map/gpkg/gpkg_map_api.py b/src/py123d/api/map/gpkg/gpkg_map_api.py index 935e6984..003d656f 100644 --- a/src/py123d/api/map/gpkg/gpkg_map_api.py +++ b/src/py123d/api/map/gpkg/gpkg_map_api.py @@ -55,7 +55,7 @@ def __init__(self, file_path: Path) -> None: self._gpd_dataframes: Dict[MapLayer, gpd.GeoDataFrame] = {} self._map_metadata: Optional[MapMetadata] = None - def initialize(self) -> None: + def _initialize(self) -> None: """Inherited, see superclass.""" if len(self._gpd_dataframes) == 0: available_layers = list(gpd.list_layers(self._file_path).name) @@ -86,12 +86,13 @@ def _assert_initialize(self) -> None: def _assert_layer_available(self, layer: MapLayer) -> None: "Checks if layer is available." - assert layer in self.get_available_map_objects(), f"GPKGMap: MapLayer {layer.name} is unavailable." + assert layer in self.get_available_map_layers(), f"GPKGMap: MapLayer {layer.name} is unavailable." def get_map_metadata(self): + """Inherited, see superclass.""" return self._map_metadata - def get_available_map_objects(self) -> List[MapLayer]: + def get_available_map_layers(self) -> List[MapLayer]: """Inherited, see superclass.""" self._assert_initialize() return list(self._gpd_dataframes.keys()) @@ -130,7 +131,8 @@ def query( sort: bool = False, distance: Optional[float] = None, ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: - supported_layers = self.get_available_map_objects() + """Inherited, see superclass.""" + supported_layers = self.get_available_map_layers() unsupported_layers = [layer for layer in layers if layer not in supported_layers] assert len(unsupported_layers) == 0, f"Object representation for layer(s): {unsupported_layers} is unavailable" object_map: Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]] = defaultdict(list) @@ -146,7 +148,8 @@ def query_object_ids( sort: bool = False, distance: Optional[float] = None, ) -> Dict[MapLayer, Union[List[str], Dict[int, List[str]]]]: - supported_layers = self.get_available_map_objects() + """Inherited, see superclass.""" + supported_layers = self.get_available_map_layers() unsupported_layers = [layer for layer in layers if layer not in supported_layers] assert len(unsupported_layers) == 0, f"Object representation for layer(s): {unsupported_layers} is unavailable" object_map: Dict[MapLayer, Union[List[str], Dict[int, List[str]]]] = defaultdict(list) @@ -163,7 +166,8 @@ def query_nearest( return_distance: bool = False, exclusive: bool = False, ) -> Dict[MapLayer, Union[List[str], Dict[int, List[str]], Dict[int, List[Tuple[str, float]]]]]: - supported_layers = self.get_available_map_objects() + """Inherited, see superclass.""" + supported_layers = self.get_available_map_layers() unsupported_layers = [layer for layer in layers if layer not in supported_layers] assert len(unsupported_layers) == 0, f"Object representation for layer(s): {unsupported_layers} is unavailable" object_map: Dict[MapLayer, Union[List[str], Dict[int, List[str]]]] = defaultdict(list) @@ -181,6 +185,8 @@ def _query_layer( sort: bool = False, distance: Optional[float] = None, ) -> Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]: + """Helper method to query a single layer.""" + queried_indices = self._gpd_dataframes[layer].sindex.query( geometry, predicate=predicate, sort=sort, distance=distance ) @@ -203,7 +209,7 @@ def _query_layer_objects_ids( sort: bool = False, distance: Optional[float] = None, ) -> Union[List[str], Dict[int, List[str]]]: - # Use numpy for fast indexing and avoid .iloc in a loop + """Helper method to query a single layer.""" queried_indices = self._gpd_dataframes[layer].sindex.query( geometry, predicate=predicate, sort=sort, distance=distance @@ -227,7 +233,7 @@ def _query_layer_nearest( return_distance: bool = False, exclusive: bool = False, ) -> Union[List[str], Dict[int, List[str]], Dict[int, List[Tuple[str, float]]]]: - # Use numpy for fast indexing and avoid .iloc in a loop + """Helper method to query a single layer.""" queried_indices = self._gpd_dataframes[layer].sindex.nearest( geometry, @@ -254,6 +260,7 @@ def _query_layer_nearest( return list(ids[queried_indices]) def _get_lane(self, id: str) -> Optional[Lane]: + """Helper method for getting a lane by its ID.""" lane: Optional[Lane] = None lane_row = get_row_with_value(self._gpd_dataframes[MapLayer.LANE], "id", id) @@ -292,6 +299,7 @@ def _get_lane(self, id: str) -> Optional[Lane]: return lane def _get_lane_group(self, id: str) -> Optional[LaneGroup]: + """Helper method for getting a lane group by its ID.""" lane_group: Optional[LaneGroup] = None lane_group_row = get_row_with_value(self._gpd_dataframes[MapLayer.LANE_GROUP], "id", id) if lane_group_row is not None: @@ -324,6 +332,7 @@ def _get_lane_group(self, id: str) -> Optional[LaneGroup]: return lane_group def _get_intersection(self, id: str) -> Optional[Intersection]: + """Helper method for getting an intersection by its ID.""" intersection: Optional[Intersection] = None intersection_row = get_row_with_value(self._gpd_dataframes[MapLayer.INTERSECTION], "id", id) @@ -349,6 +358,8 @@ def _get_intersection(self, id: str) -> Optional[Intersection]: return intersection def _get_crosswalk(self, id: str) -> Optional[Crosswalk]: + """Helper method for getting a crosswalk by its ID.""" + crosswalk: Optional[Crosswalk] = None crosswalk_row = get_row_with_value(self._gpd_dataframes[MapLayer.CROSSWALK], "id", id) if crosswalk_row is not None: @@ -366,6 +377,8 @@ def _get_crosswalk(self, id: str) -> Optional[Crosswalk]: return crosswalk def _get_walkway(self, id: str) -> Optional[Walkway]: + """Helper method for getting a walkway by its ID.""" + walkway: Optional[Walkway] = None walkway_row = get_row_with_value(self._gpd_dataframes[MapLayer.WALKWAY], "id", id) if walkway_row is not None: @@ -383,6 +396,8 @@ def _get_walkway(self, id: str) -> Optional[Walkway]: return walkway def _get_carpark(self, id: str) -> Optional[Carpark]: + """Helper method for getting a carpark by its ID.""" + carpark: Optional[Carpark] = None carpark_row = get_row_with_value(self._gpd_dataframes[MapLayer.CARPARK], "id", id) if carpark_row is not None: @@ -400,6 +415,8 @@ def _get_carpark(self, id: str) -> Optional[Carpark]: return carpark def _get_generic_drivable(self, id: str) -> Optional[GenericDrivable]: + """Helper method for getting a generic drivable area by its ID.""" + generic_drivable: Optional[GenericDrivable] = None generic_drivable_row = get_row_with_value(self._gpd_dataframes[MapLayer.GENERIC_DRIVABLE], "id", id) if generic_drivable_row is not None: @@ -417,6 +434,8 @@ def _get_generic_drivable(self, id: str) -> Optional[GenericDrivable]: return generic_drivable def _get_road_edge(self, id: str) -> Optional[RoadEdge]: + """Helper method for getting a road edge by its ID.""" + road_edge: Optional[RoadEdge] = None road_edge_row = get_row_with_value(self._gpd_dataframes[MapLayer.ROAD_EDGE], "id", id) if road_edge_row is not None: @@ -434,6 +453,8 @@ def _get_road_edge(self, id: str) -> Optional[RoadEdge]: return road_edge def _get_road_line(self, id: str) -> Optional[RoadLine]: + """Helper method for getting a road line by its ID.""" + road_line: Optional[RoadLine] = None road_line_row = get_row_with_value(self._gpd_dataframes[MapLayer.ROAD_LINE], "id", id) if road_line_row is not None: @@ -457,7 +478,7 @@ def get_global_map_api(dataset: str, location: str) -> GPKGMapAPI: gpkg_path = PY123D_MAPS_ROOT / dataset / f"{dataset}_{location}.gpkg" assert gpkg_path.is_file(), f"{dataset}_{location}.gpkg not found in {str(PY123D_MAPS_ROOT)}." map_api = GPKGMapAPI(gpkg_path) - map_api.initialize() + map_api._initialize() return map_api @@ -466,5 +487,5 @@ def get_local_map_api(split_name: str, log_name: str) -> GPKGMapAPI: gpkg_path = PY123D_MAPS_ROOT / split_name / f"{log_name}.gpkg" assert gpkg_path.is_file(), f"{log_name}.gpkg not found in {str(PY123D_MAPS_ROOT)}." map_api = GPKGMapAPI(gpkg_path) - map_api.initialize() + map_api._initialize() return map_api diff --git a/src/py123d/api/map/map_api.py b/src/py123d/api/map/map_api.py index df1c4d18..2bb274f0 100644 --- a/src/py123d/api/map/map_api.py +++ b/src/py123d/api/map/map_api.py @@ -24,11 +24,7 @@ def get_map_metadata(self) -> MapMetadata: pass @abc.abstractmethod - def initialize(self) -> None: - pass - - @abc.abstractmethod - def get_available_map_objects(self) -> List[MapLayer]: + def get_available_map_layers(self) -> List[MapLayer]: pass @abc.abstractmethod diff --git a/src/py123d/datatypes/map_objects/base_map_objects.py b/src/py123d/datatypes/map_objects/base_map_objects.py index 90c2844d..dedee17a 100644 --- a/src/py123d/datatypes/map_objects/base_map_objects.py +++ b/src/py123d/datatypes/map_objects/base_map_objects.py @@ -14,6 +14,7 @@ class BaseMapObject(abc.ABC): + """Base interface representation of all map objects.""" __slots__ = ("_object_id",) @@ -26,54 +27,55 @@ def __init__(self, object_id: MapObjectIDType): @property def object_id(self) -> MapObjectIDType: - """Returns the unique identifier of the map object. - - :return: map object id - """ + """The unique identifier of the map object (unique within a map layer).""" return self._object_id @property @abc.abstractmethod def layer(self) -> MapLayer: - """Returns the map layer type. - - :return: map layer type - """ + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" class BaseMapSurfaceObject(BaseMapObject): - """ - Base interface representation of all map objects. - """ + """Base interface representation of all map objects that represent surfaces.""" - __slots__ = ("_outline", "_geometry") + __slots__ = ("_outline", "_shapely_polygon") def __init__( self, object_id: MapObjectIDType, outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, ) -> None: - super().__init__(object_id) + """Initialize a BaseMapSurfaceObject instance. Either outline or shapely_polygon must be provided. - assert outline is not None or geometry is not None, "Either outline or geometry must be provided." - - if outline is None: - outline = Polyline3D.from_linestring(geometry.exterior) + :param object_id: Unique identifier for the map object. + :param outline: Outline of the surface, either 2D or 3D, defaults to None. + :param shapely_polygon: A shapely Polygon representing the surface geometry, defaults to None. + :raises ValueError: If both outline and shapely_polygon are not provided. + """ + super().__init__(object_id) - if geometry is None: - geometry = geom.Polygon(outline.array[:, :2]) + if outline is None and shapely_polygon is None: + raise ValueError("Either outline or shapely_polygon must be provided.") + elif outline is None: + outline = Polyline3D.from_linestring(shapely_polygon.exterior) + elif shapely_polygon is None: + shapely_polygon = geom.Polygon(outline.array[:, :2]) self._object_id = object_id self._outline = outline - self._geometry = geometry + self._shapely_polygon = shapely_polygon @property def outline(self) -> Union[Polyline2D, Polyline3D]: + """The outline of the surface as either :class:`~py123d.geometry.Polyline2D` + or :class:`~py123d.geometry.Polyline3D`.""" return self._outline @property def outline_2d(self) -> Polyline2D: + """The outline of the surface as :class:`~py123d.geometry.Polyline2D`.""" if isinstance(self.outline, Polyline2D): return self._outline # Converts 3D polyline to 2D by dropping the z-coordinate @@ -81,6 +83,7 @@ def outline_2d(self) -> Polyline2D: @property def outline_3d(self) -> Polyline3D: + """The outline of the surface as :class:`~py123d.geometry.Polyline3D` (zero-padded to 3D if necessary).""" if isinstance(self._outline, Polyline3D): return self._outline # Converts 2D polyline to 3D by adding a default (zero) z-coordinate @@ -88,10 +91,12 @@ def outline_3d(self) -> Polyline3D: @property def shapely_polygon(self) -> geom.Polygon: - return self._geometry + """The shapely polygon of the surface.""" + return self._shapely_polygon @property def trimesh_mesh(self) -> trimesh.Trimesh: + """The trimesh mesh representation of the surface.""" # Fallback to geometry if no boundaries are available outline_3d_array = self.outline_3d.array vertices_2d, faces = trimesh.creation.triangulate_polygon(geom.Polygon(outline_3d_array[:, Point3DIndex.XY])) @@ -108,19 +113,28 @@ def trimesh_mesh(self) -> trimesh.Trimesh: class BaseMapLineObject(BaseMapObject): + """Base interface representation of all line map objects.""" __slots__ = ("_polyline",) def __init__(self, object_id: MapObjectIDType, polyline: Union[Polyline2D, Polyline3D]) -> None: + """Initialize a BaseMapLineObject instance. + + :param object_id: Unique identifier for the map object. + :param polyline: The polyline representation of the line object. + """ super().__init__(object_id) self._polyline = polyline @property def polyline(self) -> Union[Polyline2D, Polyline3D]: + """The polyline representation, either :class:`~py123d.geometry.Polyline2D` or + :class:`~py123d.geometry.Polyline3D`.""" return self._polyline @property def polyline_2d(self) -> Polyline2D: + """The polyline representation as :class:`~py123d.geometry.Polyline2D`.""" if isinstance(self._polyline, Polyline2D): return self._polyline # Converts 3D polyline to 2D by dropping the z-coordinate @@ -128,6 +142,7 @@ def polyline_2d(self) -> Polyline2D: @property def polyline_3d(self) -> Polyline3D: + """The polyline representation as :class:`~py123d.geometry.Polyline3D` (zero-padded to 3D if necessary).""" if isinstance(self._polyline, Polyline3D): return self._polyline # Converts 2D polyline to 3D by adding a default (zero) z-coordinate @@ -135,4 +150,5 @@ def polyline_3d(self) -> Polyline3D: @property def shapely_linestring(self) -> geom.LineString: + """The shapely LineString representation of the polyline.""" return self._polyline.linestring diff --git a/src/py123d/datatypes/map_objects/map_layer_types.py b/src/py123d/datatypes/map_objects/map_layer_types.py index 341dd042..abf44382 100644 --- a/src/py123d/datatypes/map_objects/map_layer_types.py +++ b/src/py123d/datatypes/map_objects/map_layer_types.py @@ -2,36 +2,50 @@ from py123d.common.utils.enums import SerialIntEnum -# TODO: Add stop pads or stop lines. -# - Add type for stop zones. -# - Add type for carparks, e.g. outline, driveway (Waymo), or other types. -# - Check if intersections should have types. -# - Use consistent naming conventions unknown, undefined, none, etc. +# TODO @DanielDauner: +# - Implement stop zone types. +# - Consider adding types for intersections or other layers. class MapLayer(SerialIntEnum): - """ - Enum for AbstractMapSurface. - """ + """Enum for different map layers (i.e. object types) in a map.""" LANE = 0 + """Lanes (surface).""" + LANE_GROUP = 1 + """Lane groups (surface).""" + INTERSECTION = 2 + """Intersections (surface).""" + CROSSWALK = 3 + """Crosswalks (surface).""" + WALKWAY = 4 + """Walkways (surface).""" + CARPARK = 5 + """Carparks (surface).""" + GENERIC_DRIVABLE = 6 + """Generic drivable (surface).""" + STOP_ZONE = 7 + """Stop zones (surface).""" + ROAD_EDGE = 8 + """Road edges (lines).""" + ROAD_LINE = 9 + """Road lines (lines).""" class LaneType(SerialIntEnum): - """ - Enum for LaneType. - NOTE: We use the lane types from Waymo. - https://github.com/waymo-research/waymo-open-dataset/blob/99a4cb3ff07e2fe06c2ce73da001f850f628e45a/src/waymo_open_dataset/protos/map.proto#L147 - """ + """Enum for different lane types.""" + + # NOTE @DanielDauner: We currently do not include the lane types, but should add them in the future. + # Some maps (e.g. nuPlan, Waymo) have bike lanes, which need to be distinguished from regular lanes. UNDEFINED = 0 FREEWAY = 1 @@ -40,36 +54,80 @@ class LaneType(SerialIntEnum): class RoadEdgeType(SerialIntEnum): - """ - Enum for RoadEdgeType. - NOTE: We use the road line types from Waymo. - https://github.com/waymo-research/waymo-open-dataset/blob/master/src/waymo_open_dataset/protos/map.proto#L188 + """Enum for different road edge types. + + Notes + ----- + The road edge types follow the Waymo specification [1]_. + + References + ---------- + .. [1] https://github.com/waymo-research/waymo-open-dataset/blob/master/src/waymo_open_dataset/protos/map.proto#L188 """ UNKNOWN = 0 + """Unknown road edge type.""" + ROAD_EDGE_BOUNDARY = 1 + """Physical road boundary that doesn't have traffic on the other side.""" + ROAD_EDGE_MEDIAN = 2 + """Physical road boundary that separates the car from other traffic.""" class RoadLineType(SerialIntEnum): - """ - Enum for RoadLineType. - TODO: Use the Argoverse 2 road line types. - https://github.com/waymo-research/waymo-open-dataset/blob/master/src/waymo_open_dataset/protos/map.proto#L208 + """Enum for different road line types. + + Notes + ----- + The road line types follow the Argoverse 2 specification [1]_. + + References + ---------- + .. [1] https://github.com/argoverse/av2-api/blob/6b22766247eda941cb1953d6a58e8d5631c561da/src/av2/map/lane_segment.py#L33 """ NONE = 0 + """No painted line is present.""" + UNKNOWN = 1 + """Unknown or unclassified painted line type.""" + DASH_SOLID_YELLOW = 2 + """Yellow line with dashed marking on one side and solid on the other.""" + DASH_SOLID_WHITE = 3 + """White line with dashed marking on one side and solid on the other.""" + DASHED_WHITE = 4 + """White dashed line marking.""" + DASHED_YELLOW = 5 + """Yellow dashed line marking.""" + DOUBLE_SOLID_YELLOW = 6 + """Double yellow solid line marking.""" + DOUBLE_SOLID_WHITE = 7 + """Double white solid line marking.""" + DOUBLE_DASH_YELLOW = 8 + """Double yellow dashed line marking.""" + DOUBLE_DASH_WHITE = 9 + """Double white dashed line marking.""" + SOLID_YELLOW = 10 + """Single solid yellow line marking.""" + SOLID_WHITE = 11 + """Single solid white line marking.""" + SOLID_DASH_WHITE = 12 + """Single solid white line with dashed marking on one side.""" + SOLID_DASH_YELLOW = 13 + """Single solid yellow line with dashed marking on one side.""" + SOLID_BLUE = 14 + """Single solid blue line marking.""" diff --git a/src/py123d/datatypes/map_objects/map_objects.py b/src/py123d/datatypes/map_objects/map_objects.py index b0d16070..eca8a1ef 100644 --- a/src/py123d/datatypes/map_objects/map_objects.py +++ b/src/py123d/datatypes/map_objects/map_objects.py @@ -12,10 +12,11 @@ from py123d.geometry import Polyline2D, Polyline3D if TYPE_CHECKING: - from py123d.api.map.map_api import MapAPI + from py123d.api import MapAPI class Lane(BaseMapSurfaceObject): + """Class representing a lane in a map.""" __slots__ = ( "_lane_group_id", @@ -43,10 +44,31 @@ def __init__( successor_ids: List[MapObjectIDType] = [], speed_limit_mps: Optional[float] = None, outline: Optional[Polyline3D] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, map_api: Optional["MapAPI"] = None, ) -> None: - + """Initialize a :class:`Lane` instance. + + Notes + ----- + If the map_api is provided, neighboring lanes and lane group can be accessed through the properties. + If the outline is not provided, it will be constructed from the left and right boundaries. + If the shapely_polygon is not provided, it will be constructed from the outline. + + :param object_id: The unique identifier for the lane. + :param lane_group_id: The unique identifier for the lane group this lane belongs to. + :param left_boundary: Polyline of left boundary of the lane. + :param right_boundary: Polyline of right boundary of the lane. + :param centerline: Polyline of centerline of the lane. + :param left_lane_id: The unique identifier for the left neighboring lane, defaults to None. + :param right_lane_id: The unique identifier for the right neighboring lane, defaults to None. + :param predecessor_ids: The unique identifiers for the predecessor lanes, defaults to []. + :param successor_ids: The unique identifiers for the successor lanes, defaults to []. + :param speed_limit_mps: The speed limit for the lane in meters per second, defaults to None. + :param outline: The outline of the lane, defaults to None. + :param shapely_polygon: The Shapely polygon representation of the lane, defaults to None. + :param map_api: The MapAPI instance for accessing map objects, defaults to None. + """ if outline is None: outline_array = np.vstack( ( @@ -56,8 +78,7 @@ def __init__( ) ) outline = Polyline3D.from_array(outline_array) - - super().__init__(object_id, outline, geometry) + super().__init__(object_id, outline, shapely_polygon) self._lane_group_id = lane_group_id self._left_boundary = left_boundary @@ -72,56 +93,68 @@ def __init__( @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.LANE @property def lane_group_id(self) -> MapObjectIDType: + """ID of the lane group this lane belongs to.""" return self._lane_group_id @property def lane_group(self) -> Optional[LaneGroup]: + """The :class:`LaneGroup` this lane belongs to.""" if self._map_api is not None: return self._map_api.get_map_object(self.lane_group_id, MapLayer.LANE_GROUP) return None @property def left_boundary(self) -> Polyline3D: + """The left boundary of the lane as a :class:`~py123d.geometry.Polyline3D`.""" return self._left_boundary @property def right_boundary(self) -> Polyline3D: + """The right boundary of the lane as a :class:`~py123d.geometry.Polyline3D`.""" return self._right_boundary @property def centerline(self) -> Polyline3D: + """The centerline of the lane as a :class:`~py123d.geometry.Polyline3D`.""" return self._centerline @property def left_lane_id(self) -> Optional[MapObjectIDType]: + """ID of the left neighboring lane.""" return self._left_lane_id @property def left_lane(self) -> Optional[Lane]: + """The left neighboring :class:`Lane`, if available.""" if self._map_api is not None and self.left_lane_id is not None: return self._map_api.get_map_object(self.left_lane_id, self.layer) return None @property def right_lane_id(self) -> Optional[MapObjectIDType]: + """ID of the right neighboring lane.""" return self._right_lane_id @property def right_lane(self) -> Optional[Lane]: + """The right neighboring :class:`Lane`, if available.""" if self._map_api is not None and self.right_lane_id is not None: return self._map_api.get_map_object(self.right_lane_id, self.layer) return None @property def predecessor_ids(self) -> List[MapObjectIDType]: + """List of IDs of the predecessor lanes.""" return self._predecessor_ids @property - def predecessors(self) -> List[Lane]: + def predecessors(self) -> Optional[List[Lane]]: + """List of predecessor :class:`Lane` instances.""" predecessors: Optional[List[Lane]] = None if self._map_api is not None: predecessors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.predecessor_ids] @@ -129,10 +162,12 @@ def predecessors(self) -> List[Lane]: @property def successor_ids(self) -> List[MapObjectIDType]: + """List of IDs of the successor lanes.""" return self._successor_ids @property - def successors(self) -> List[Lane]: + def successors(self) -> Optional[List[Lane]]: + """List of successor :class:`Lane` instances.""" successors: Optional[List[Lane]] = None if self._map_api is not None: successors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.successor_ids] @@ -140,14 +175,17 @@ def successors(self) -> List[Lane]: @property def speed_limit_mps(self) -> Optional[float]: + """The speed limit of the lane in meters per second.""" return self._speed_limit_mps @property def trimesh_mesh(self) -> Trimesh: + """The trimesh mesh representation of the lane.""" return get_trimesh_from_boundaries(self.left_boundary, self.right_boundary) class LaneGroup(BaseMapSurfaceObject): + """Class representing a group of lanes going in the same direction.""" __slots__ = ( "_lane_ids", @@ -169,9 +207,28 @@ def __init__( predecessor_ids: List[MapObjectIDType] = [], successor_ids: List[MapObjectIDType] = [], outline: Optional[Polyline3D] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, map_api: Optional["MapAPI"] = None, ): + """Initialize a :class:`LaneGroup` instance. + + Notes + ----- + If the map_api is provided, neighboring lane groups and intersection can be accessed through the properties. + If the outline is not provided, it will be constructed from the left and right boundaries. + If the shapely_polygon is not provided, it will be constructed from the outline. + + :param object_id: The ID of the lane group. + :param lane_ids: The IDs of the lanes in the group. + :param left_boundary: The left boundary of the lane group. + :param right_boundary: The right boundary of the lane group. + :param intersection_id: The ID of the intersection, defaults to None + :param predecessor_ids: The IDs of the predecessor lanes, defaults to [] + :param successor_ids: The IDs of the successor lanes, defaults to [] + :param outline: The outline of the lane group, defaults to None + :param shapely_polygon: The shapely polygon representation of the lane group, defaults to None + :param map_api: The map API instance, defaults to None + """ if outline is None: outline_array = np.vstack( ( @@ -181,7 +238,7 @@ def __init__( ) ) outline = Polyline3D.from_array(outline_array) - super().__init__(object_id, outline, geometry) + super().__init__(object_id, outline, shapely_polygon) self._lane_ids = lane_ids self._left_boundary = left_boundary @@ -193,14 +250,17 @@ def __init__( @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.LANE_GROUP @property def lane_ids(self) -> List[MapObjectIDType]: + """List of IDs of the lanes in the group.""" return self._lane_ids @property def lanes(self) -> List[Lane]: + """List of :class:`Lane` instances in the group.""" lanes: Optional[List[Lane]] = None if self._map_api is not None: lanes = [self._map_api.get_map_object(lane_id, MapLayer.LANE) for lane_id in self.lane_ids] @@ -208,28 +268,35 @@ def lanes(self) -> List[Lane]: @property def left_boundary(self) -> Polyline3D: + """The left boundary of the lane group.""" return self._left_boundary @property def right_boundary(self) -> Polyline3D: + """The right boundary of the lane group.""" return self._right_boundary @property def intersection_id(self) -> Optional[MapObjectIDType]: + """ID of the intersection the lane group belongs to, if available.""" return self._intersection_id @property def intersection(self) -> Optional[Intersection]: + """The :class:`Intersection` the lane group belongs to, if available.""" + intersection: Optional[Intersection] = None if self._map_api is not None and self.intersection_id is not None: - return self._map_api.get_map_object(self.intersection_id, MapLayer.INTERSECTION) - return None + intersection = self._map_api.get_map_object(self.intersection_id, MapLayer.INTERSECTION) + return intersection @property def predecessor_ids(self) -> List[MapObjectIDType]: + """List of IDs of the predecessor lane groups.""" return self._predecessor_ids @property def predecessors(self) -> List[LaneGroup]: + """List of predecessor :class:`LaneGroup` instances.""" predecessors: Optional[List[LaneGroup]] = None if self._map_api is not None: predecessors = [ @@ -239,10 +306,12 @@ def predecessors(self) -> List[LaneGroup]: @property def successor_ids(self) -> List[MapObjectIDType]: + """List of IDs of the successor lane groups.""" return self._successor_ids @property def successors(self) -> List[LaneGroup]: + """List of successor :class:`LaneGroup` instances.""" successors: Optional[List[LaneGroup]] = None if self._map_api is not None: successors = [ @@ -252,38 +321,53 @@ def successors(self) -> List[LaneGroup]: @property def trimesh_mesh(self) -> Trimesh: + """The trimesh mesh representation of the lane group.""" return get_trimesh_from_boundaries(self.left_boundary, self.right_boundary) class Intersection(BaseMapSurfaceObject): + """Class representing an intersection in a map, which consists of multiple lane groups.""" - __slots__ = ( - "_lane_group_ids", - "_map_api", - ) + __slots__ = ("_lane_group_ids", "_map_api") def __init__( self, object_id: MapObjectIDType, lane_group_ids: List[MapObjectIDType], outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, map_api: Optional["MapAPI"] = None, ): - super().__init__(object_id, outline, geometry) + """Initialize an :class:`Intersection` instance. + + Notes + ----- + If the map_api is provided, lane groups can be accessed through the properties. + Either outline or shapely_polygon must be provided. + + :param object_id: The ID of the intersection. + :param lane_group_ids: The IDs of the lane groups that belong to the intersection. + :param outline: The outline of the intersection, defaults to None. + :param shapely_polygon: The Shapely polygon representation of the intersection, defaults to None. + :param map_api: The MapAPI instance, defaults to None. + """ + super().__init__(object_id, outline, shapely_polygon) self._lane_group_ids = lane_group_ids self._map_api = map_api @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.INTERSECTION @property def lane_group_ids(self) -> List[MapObjectIDType]: + """List of IDs of the lane groups that belong to the intersection.""" return self._lane_group_ids @property def lane_groups(self) -> List[LaneGroup]: + """List of :class:`LaneGroup` instances that belong to the intersection.""" lane_groups: Optional[List[LaneGroup]] = None if self._map_api is not None: lane_groups = [ @@ -294,6 +378,7 @@ def lane_groups(self) -> List[LaneGroup]: class Crosswalk(BaseMapSurfaceObject): + """Class representing a crosswalk in a map.""" __slots__ = () @@ -301,16 +386,28 @@ def __init__( self, object_id: MapObjectIDType, outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, ): - super().__init__(object_id, outline, geometry) + """Initialize a Crosswalk instance. + + Notes + ----- + Either outline or shapely_polygon must be provided. + + :param object_id: The ID of the crosswalk. + :param outline: The outline of the crosswalk, defaults to None. + :param shapely_polygon: The Shapely polygon representation of the crosswalk, defaults to None. + """ + super().__init__(object_id, outline, shapely_polygon) @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.CROSSWALK class Carpark(BaseMapSurfaceObject): + """Class representing a carpark or driveway in a map.""" __slots__ = () @@ -318,12 +415,23 @@ def __init__( self, object_id: MapObjectIDType, outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, ): - super().__init__(object_id, outline, geometry) + """Initialize a Carpark instance. + + Notes + ----- + Either outline or shapely_polygon must be provided. + + :param object_id: The ID of the carpark. + :param outline: The outline of the carpark, defaults to None. + :param shapely_polygon: The Shapely polygon representation of the carpark, defaults to None. + """ + super().__init__(object_id, outline, shapely_polygon) @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.CARPARK @@ -335,9 +443,19 @@ def __init__( self, object_id: MapObjectIDType, outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, ): - super().__init__(object_id, outline, geometry) + """Initialize a Walkway instance. + + Notes + ----- + Either outline or shapely_polygon must be provided. + + :param object_id: The ID of the walkway. + :param outline: The outline of the walkway, defaults to None. + :param shapely_polygon: The Shapely polygon representation of the walkway, defaults to None. + """ + super().__init__(object_id, outline, shapely_polygon) @property def layer(self) -> MapLayer: @@ -345,6 +463,9 @@ def layer(self) -> MapLayer: class GenericDrivable(BaseMapSurfaceObject): + """Class representing a generic drivable area in a map. + Can overlap with other drivable areas, depending on the dataset. + """ __slots__ = () @@ -352,16 +473,28 @@ def __init__( self, object_id: MapObjectIDType, outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, ): - super().__init__(object_id, outline, geometry) + """Initialize a GenericDrivable instance. + + Notes + ----- + Either outline or shapely_polygon must be provided. + + :param object_id: The ID of the walkway. + :param outline: The outline of the walkway, defaults to None. + :param shapely_polygon: The Shapely polygon representation of the walkway, defaults to None. + """ + super().__init__(object_id, outline, shapely_polygon) @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.GENERIC_DRIVABLE class StopZone(BaseMapSurfaceObject): + """Placeholder class representing a stop zone in a map. Requires further implementation based on dataset specifics.""" __slots__ = () @@ -369,16 +502,28 @@ def __init__( self, object_id: MapObjectIDType, outline: Optional[Union[Polyline2D, Polyline3D]] = None, - geometry: Optional[geom.Polygon] = None, + shapely_polygon: Optional[geom.Polygon] = None, ): - super().__init__(object_id, outline, geometry) + """Initialize a StopZone instance. + + Notes + ----- + Either outline or shapely_polygon must be provided. + + :param object_id: The ID of the stop zone. + :param outline: The outline of the stop zone, defaults to None. + :param shapely_polygon: The Shapely polygon representation of the stop zone, defaults to None. + """ + super().__init__(object_id, outline, shapely_polygon) @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.STOP_ZONE class RoadEdge(BaseMapLineObject): + """Class representing a road edge in a map.""" __slots__ = ("_road_edge_type",) @@ -388,6 +533,12 @@ def __init__( road_edge_type: int, polyline: Union[Polyline2D, Polyline3D], ): + """Initialize a RoadEdge instance. + + :param object_id: The ID of the road edge. + :param road_edge_type: The type of the road edge. + :param polyline: The polyline representation of the road edge. + """ super().__init__(object_id, polyline) self._road_edge_type = road_edge_type @@ -397,10 +548,12 @@ def layer(self) -> MapLayer: @property def road_edge_type(self) -> int: + """The type of road edge, according to :class:`~py123d.datatypes.map_objects.map_layer_types.RoadEdgeType`.""" return self._road_edge_type class RoadLine(BaseMapLineObject): + """Class representing a road line in a map.""" __slots__ = ("_road_line_type",) @@ -410,13 +563,21 @@ def __init__( road_line_type: int, polyline: Union[Polyline2D, Polyline3D], ): + """Initialize a RoadLine instance. + + :param object_id: The ID of the road line. + :param road_line_type: The type of the road line. + :param polyline: The polyline representation of the road line. + """ super().__init__(object_id, polyline) self._road_line_type = road_line_type @property def layer(self) -> MapLayer: + """The :class:`~py123d.datatypes.map_objects.map_layer_types.MapLayer` of the map object.""" return MapLayer.ROAD_LINE @property def road_line_type(self) -> int: + """The type of road edge, according to :class:`~py123d.datatypes.map_objects.map_layer_types.RoadLineType`.""" return self._road_line_type diff --git a/src/py123d/datatypes/map_objects/utils.py b/src/py123d/datatypes/map_objects/utils.py index fa3b560c..88eb47b5 100644 --- a/src/py123d/datatypes/map_objects/utils.py +++ b/src/py123d/datatypes/map_objects/utils.py @@ -8,8 +8,16 @@ def get_trimesh_from_boundaries( left_boundary: Polyline3D, right_boundary: Polyline3D, resolution: float = 0.25 ) -> trimesh.Trimesh: + """Helper function to create a trimesh from two lane boundaries. + + :param left_boundary: The left boundary polyline. + :param right_boundary: The right boundary polyline. + :param resolution: The resolution for the mesh, defaults to 0.25. + :return: A trimesh representation of the lane. + """ def _interpolate_polyline(polyline_3d: Polyline3D, num_samples: int) -> npt.NDArray[np.float64]: + """Helper function to interpolate a polyline to a fixed number of samples.""" if num_samples < 2: num_samples = 2 distances = np.linspace(0, polyline_3d.length, num=num_samples, endpoint=True, dtype=np.float64) @@ -25,6 +33,13 @@ def _interpolate_polyline(polyline_3d: Polyline3D, num_samples: int) -> npt.NDAr def _create_lane_mesh_from_boundary_arrays( left_boundary_array: npt.NDArray[np.float64], right_boundary_array: npt.NDArray[np.float64] ) -> trimesh.Trimesh: + """Helper function to create a trimesh from two boundary arrays. + + :param left_boundary_array: The left boundary array. + :param right_boundary_array: The right boundary array. + :raises ValueError: If the boundary arrays do not have the same number of points. + :return: A trimesh representation of the lane. + """ # Ensure both polylines have the same number of points if left_boundary_array.shape[0] != right_boundary_array.shape[0]: diff --git a/tests/unit/datatypes/map_objects/__init__.py b/tests/unit/datatypes/map_objects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/datatypes/map_objects/mock_map_api.py b/tests/unit/datatypes/map_objects/mock_map_api.py new file mode 100644 index 00000000..9aae9fb7 --- /dev/null +++ b/tests/unit/datatypes/map_objects/mock_map_api.py @@ -0,0 +1,126 @@ +from typing import Dict, Iterable, List, Optional, Union + +import shapely + +from py123d.api import MapAPI +from py123d.datatypes.map_objects import BaseMapObject, MapLayer +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + GenericDrivable, + Intersection, + Lane, + LaneGroup, + RoadEdge, + RoadLine, + StopZone, + Walkway, +) +from py123d.datatypes.metadata import MapMetadata +from py123d.geometry import Point2D + + +class MockMapAPI(MapAPI): + + def __init__( + self, + lanes: List[Lane] = [], + lane_groups: List[LaneGroup] = [], + intersections: List[Intersection] = [], + crosswalks: List[Intersection] = [], + carparks: List[Carpark] = [], + walkways: List[Walkway] = [], + generic_drivables: List[GenericDrivable] = [], + stop_zones: List[StopZone] = [], + road_edges: List[RoadEdge] = [], + road_lines: List[RoadLine] = [], + add_map_api_links: bool = False, + ): + + self._layers: Dict[MapLayer, List[BaseMapObject]] = { + MapLayer.LANE: lanes, + MapLayer.LANE_GROUP: lane_groups, + MapLayer.INTERSECTION: intersections, + MapLayer.CROSSWALK: crosswalks, + MapLayer.WALKWAY: walkways, + MapLayer.CARPARK: carparks, + MapLayer.GENERIC_DRIVABLE: generic_drivables, + MapLayer.STOP_ZONE: stop_zones, + MapLayer.ROAD_EDGE: road_edges, + MapLayer.ROAD_LINE: road_lines, + } + + for layer, layer_objects in self._layers.items(): + if layer in [ + MapLayer.LANE, + MapLayer.LANE_GROUP, + MapLayer.INTERSECTION, + ]: + for obj in layer_objects: + if add_map_api_links: + obj._map_api = self # type: ignore + else: + obj._map_api = None # type: ignore + + def get_map_metadata(self) -> MapMetadata: + return MapMetadata( + dataset="test", + split="test_split", + log_name="test_log_name", + location="test_location", + map_has_z=True, + map_is_local=True, + ) + + def get_available_map_layers(self) -> List[MapLayer]: + return list(self._layers.keys()) + + def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[BaseMapObject]: + target_layer = self._layers.get(layer, []) + map_object: Optional[BaseMapObject] = None + for obj in target_layer: + if obj.object_id == object_id: + map_object = obj + break + return map_object + + def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[BaseMapObject]: + return [] + + def is_in_layer(self, point: Point2D, layer: MapLayer) -> bool: + return False + + def get_proximal_map_objects( + self, point: Point2D, radius: float, layers: List[MapLayer] + ) -> Dict[MapLayer, List[BaseMapObject]]: + return {} + + def query( + self, + geometry: Union[shapely.Geometry, Iterable[shapely.Geometry]], + layers: List[MapLayer], + predicate: Optional[str] = None, + sort: bool = False, + distance: Optional[float] = None, + ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: + return {} + + def query_object_ids( + self, + geometry: Union[shapely.Geometry, Iterable[shapely.Geometry]], + layers: List[MapLayer], + predicate: Optional[str] = None, + sort: bool = False, + distance: Optional[float] = None, + ) -> Dict[MapLayer, Union[List[str], Dict[int, List[str]]]]: + return {} + + def query_nearest( + self, + geometry: Union[shapely.Geometry, Iterable[shapely.Geometry]], + layers: List[MapLayer], + return_all: bool = True, + max_distance: Optional[float] = None, + return_distance: bool = False, + exclusive: bool = False, + ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: + return {} diff --git a/tests/unit/datatypes/map_objects/test_base_map_objects.py b/tests/unit/datatypes/map_objects/test_base_map_objects.py new file mode 100644 index 00000000..01e5e28e --- /dev/null +++ b/tests/unit/datatypes/map_objects/test_base_map_objects.py @@ -0,0 +1,209 @@ +import unittest + +import numpy as np +import shapely.geometry as geom + +from py123d.datatypes.map_objects.base_map_objects import BaseMapLineObject, BaseMapObject, BaseMapSurfaceObject +from py123d.datatypes.map_objects.map_layer_types import MapLayer +from py123d.geometry import Polyline2D, Polyline3D + + +class ConcreteMapObject(BaseMapObject): + """Concrete implementation for testing BaseMapObject.""" + + def __init__(self, object_id, layer_type=MapLayer.GENERIC_DRIVABLE): + super().__init__(object_id) + self._layer = layer_type + + @property + def layer(self) -> MapLayer: + return self._layer + + +class ConcreteMapSurfaceObject(BaseMapSurfaceObject): + """Concrete implementation for testing BaseMapSurfaceObject.""" + + def __init__(self, object_id, outline=None, shapely_polygon=None, layer_type=MapLayer.GENERIC_DRIVABLE): + super().__init__(object_id, outline, shapely_polygon) + self._layer = layer_type + + @property + def layer(self) -> MapLayer: + return self._layer + + +class ConcreteMapLineObject(BaseMapLineObject): + """Concrete implementation for testing BaseMapLineObject.""" + + def __init__(self, object_id, polyline, layer_type=MapLayer.GENERIC_DRIVABLE): + super().__init__(object_id, polyline) + self._layer = layer_type + + @property + def layer(self) -> MapLayer: + return self._layer + + +class TestBaseMapObject(unittest.TestCase): + """Test cases for BaseMapObject class.""" + + def test_init_with_string_id(self): + """Test initialization with string object ID.""" + obj = ConcreteMapObject("test_id_123") + assert obj.object_id == "test_id_123" + + def test_init_with_int_id(self): + """Test initialization with integer object ID.""" + obj = ConcreteMapObject(42) + assert obj.object_id == 42 + + def test_object_id_property(self): + """Test object_id property.""" + obj = ConcreteMapObject("unique_id") + assert obj.object_id == "unique_id" + + def test_layer_property(self): + """Test layer property.""" + obj = ConcreteMapObject("id1", MapLayer.GENERIC_DRIVABLE) + assert obj.layer == MapLayer.GENERIC_DRIVABLE + + def test_abstract_instantiation_fails(self): + """Test that instantiating BaseMapObject directly raises TypeError.""" + with self.assertRaises(TypeError): + BaseMapObject("test_id") + + +class TestBaseMapSurfaceObject(unittest.TestCase): + """Test cases for BaseMapSurfaceObject class.""" + + def test_init_with_polyline2d(self): + coords = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_1", outline=polyline) + assert obj.object_id == "surf_1" + assert isinstance(obj.outline, Polyline2D) + + def test_init_with_polyline3d(self): + coords = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 0]]) + polyline = Polyline3D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_2", outline=polyline) + assert isinstance(obj.outline, Polyline3D) + + def test_init_with_shapely_polygon(self): + polygon = geom.Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + obj = ConcreteMapSurfaceObject("surf_3", shapely_polygon=polygon) + assert obj.shapely_polygon.equals(polygon) + + def test_init_without_outline_or_polygon_raises_error(self): + with self.assertRaises(ValueError): + ConcreteMapSurfaceObject("surf_4") + + def test_outline_property(self): + coords = np.array([[0, 0], [1, 0], [1, 1], [0, 0]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_5", outline=polyline) + assert obj.outline is polyline + + def test_outline_2d_from_2d_polyline(self): + coords = np.array([[0, 0], [1, 0], [1, 1], [0, 0]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_6", outline=polyline) + assert isinstance(obj.outline_2d, Polyline2D) + + def test_outline_2d_from_3d_polyline(self): + coords = np.array([[0, 0, 5], [1, 0, 5], [1, 1, 5], [0, 0, 5]]) + polyline = Polyline3D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_7", outline=polyline) + outline_2d = obj.outline_2d + assert isinstance(outline_2d, Polyline2D) + + def test_outline_3d_from_3d_polyline(self): + coords = np.array([[0, 0, 2], [1, 0, 2], [1, 1, 2], [0, 0, 2]]) + polyline = Polyline3D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_8", outline=polyline) + assert isinstance(obj.outline_3d, Polyline3D) + + def test_outline_3d_from_2d_polyline(self): + coords = np.array([[0, 0], [1, 0], [1, 1], [0, 0]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_9", outline=polyline) + outline_3d = obj.outline_3d + assert isinstance(outline_3d, Polyline3D) + + def test_shapely_polygon_property(self): + polygon = geom.Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) + obj = ConcreteMapSurfaceObject("surf_10", shapely_polygon=polygon) + assert obj.shapely_polygon.equals(polygon) + + def test_trimesh_mesh_property(self): + coords = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 0]]) + polyline = Polyline3D.from_array(coords) + obj = ConcreteMapSurfaceObject("surf_11", outline=polyline) + mesh = obj.trimesh_mesh + assert mesh is not None + assert len(mesh.vertices) > 0 + assert len(mesh.faces) > 0 + + +class TestBaseMapLineObject(unittest.TestCase): + """Test cases for BaseMapLineObject class.""" + + def test_init_with_polyline2d(self): + coords = np.array([[0, 0], [1, 1], [2, 2]]) + polyline = Polyline2D(coords) + obj = ConcreteMapLineObject("line_1", polyline) + assert obj.object_id == "line_1" + assert isinstance(obj.polyline, Polyline2D) + + def test_init_with_polyline3d(self): + coords = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]) + polyline = Polyline3D(coords) + obj = ConcreteMapLineObject("line_2", polyline) + assert isinstance(obj.polyline, Polyline3D) + + def test_polyline_property(self): + coords = np.array([[0, 0], [1, 1], [2, 2]]) + polyline = Polyline2D(coords) + obj = ConcreteMapLineObject("line_3", polyline) + assert obj.polyline is polyline + + def test_polyline_2d_from_2d_polyline(self): + coords = np.array([[0, 0], [1, 1], [2, 2]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapLineObject("line_4", polyline) + assert isinstance(obj.polyline_2d, Polyline2D) + assert obj.polyline_2d is polyline + + def test_polyline_2d_from_3d_polyline(self): + coords = np.array([[0, 0, 5], [1, 1, 5], [2, 2, 5]]) + polyline = Polyline3D.from_array(coords) + obj = ConcreteMapLineObject("line_5", polyline) + polyline_2d = obj.polyline_2d + assert isinstance(polyline_2d, Polyline2D) + + def test_polyline_3d_from_3d_polyline(self): + coords = np.array([[0, 0, 3], [1, 1, 3], [2, 2, 3]]) + polyline = Polyline3D.from_array(coords) + obj = ConcreteMapLineObject("line_6", polyline) + assert isinstance(obj.polyline_3d, Polyline3D) + assert obj.polyline_3d is polyline + + def test_polyline_3d_from_2d_polyline(self): + coords = np.array([[0, 0], [1, 1], [2, 2]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapLineObject("line_7", polyline) + polyline_3d = obj.polyline_3d + assert isinstance(polyline_3d, Polyline3D) + + def test_shapely_linestring_property(self): + coords = np.array([[0, 0], [1, 1], [2, 2]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapLineObject("line_8", polyline) + linestring = obj.shapely_linestring + assert isinstance(linestring, geom.LineString) + + def test_object_id_with_integer(self): + coords = np.array([[0, 0], [1, 1]]) + polyline = Polyline2D.from_array(coords) + obj = ConcreteMapLineObject(999, polyline) + assert obj.object_id == 999 diff --git a/tests/unit/datatypes/map_objects/test_map_objects.py b/tests/unit/datatypes/map_objects/test_map_objects.py new file mode 100644 index 00000000..dbda5460 --- /dev/null +++ b/tests/unit/datatypes/map_objects/test_map_objects.py @@ -0,0 +1,909 @@ +import unittest +from typing import List, Tuple + +import numpy as np +import shapely +import trimesh + +from py123d.datatypes.map_objects import Intersection, Lane, LaneGroup, MapLayer +from py123d.datatypes.map_objects.map_layer_types import RoadEdgeType, RoadLineType +from py123d.datatypes.map_objects.map_objects import ( + Carpark, + Crosswalk, + GenericDrivable, + RoadEdge, + RoadLine, + StopZone, + Walkway, +) +from py123d.geometry.polyline import Polyline2D, Polyline3D + +from .mock_map_api import MockMapAPI + + +def _get_linked_map_object_setup() -> Tuple[List[Lane], List[LaneGroup], List[Intersection]]: + """Helper function to create linked map objects for testing.""" + + Z = 0.0 + + # Lanes: + lanes: List[Lane] = [] + + # Middle Lane 0, group 0 + middle_left_boundary = np.array([[0.0, 1.0, Z], [50.0, 1.0, Z]]) + middle_right_boundary = np.array([[0.0, -1.0, Z], [50.0, -1.0, Z]]) + middle_centerline = np.mean(np.array([middle_right_boundary, middle_left_boundary]), axis=0) + lanes.append( + Lane( + object_id=0, + lane_group_id=0, + left_boundary=Polyline3D.from_array(middle_left_boundary), + right_boundary=Polyline3D.from_array(middle_right_boundary), + centerline=Polyline3D.from_array(middle_centerline), + left_lane_id=1, + right_lane_id=2, + predecessor_ids=[3], + successor_ids=[4], + speed_limit_mps=0.0, + ) + ) + + # Left Lane 1, group 0 + left_left_boundary = np.array([[0.0, 2.0, Z], [50.0, 2.0, Z]]) + left_right_boundary = middle_left_boundary.copy() + left_centerline = np.mean(np.array([left_right_boundary, left_left_boundary]), axis=0) + lanes.append( + Lane( + object_id=1, + lane_group_id=0, + left_boundary=Polyline3D.from_array(left_left_boundary), + right_boundary=Polyline3D.from_array(left_right_boundary), + centerline=Polyline3D.from_array(left_centerline), + left_lane_id=None, + right_lane_id=0, + predecessor_ids=[], + successor_ids=[], + speed_limit_mps=0.0, + ) + ) + + # Right Lane 2, group 0 + right_right_boundary = np.array([[0.0, -2.0, Z], [50.0, -2.0, Z]]) + right_left_boundary = middle_right_boundary.copy() + right_centerline = np.mean(np.array([right_right_boundary, right_left_boundary]), axis=0) + lanes.append( + Lane( + object_id=2, + lane_group_id=0, + left_boundary=Polyline3D.from_array(right_left_boundary), + right_boundary=Polyline3D.from_array(right_right_boundary), + centerline=Polyline3D.from_array(right_centerline), + left_lane_id=0, + right_lane_id=None, + predecessor_ids=[], + successor_ids=[], + speed_limit_mps=0.0, + ) + ) + + # Predecessor lane 3, group 1 + predecessor_left_boundary = np.array([[-50.0, 1.0, Z], [0.0, 1.0, Z]]) + predecessor_right_boundary = np.array([[-50.0, -1.0, Z], [0.0, -1.0, Z]]) + predecessor_centerline = np.mean(np.array([predecessor_right_boundary, predecessor_left_boundary]), axis=0) + lanes.append( + Lane( + object_id=3, + lane_group_id=1, + left_boundary=Polyline3D.from_array(predecessor_left_boundary), + right_boundary=Polyline3D.from_array(predecessor_right_boundary), + centerline=Polyline3D.from_array(predecessor_centerline), + left_lane_id=None, + right_lane_id=None, + predecessor_ids=[], + successor_ids=[0], + speed_limit_mps=0.0, + ) + ) + + # Successor lane 4, group 2 + successor_left_boundary = np.array([[50.0, 1.0, Z], [100.0, 1.0, Z]]) + successor_right_boundary = np.array([[50.0, -1.0, Z], [100.0, -1.0, Z]]) + successor_centerline = np.mean(np.array([successor_right_boundary, successor_left_boundary]), axis=0) + lanes.append( + Lane( + object_id=4, + lane_group_id=2, + left_boundary=Polyline3D.from_array(successor_left_boundary), + right_boundary=Polyline3D.from_array(successor_right_boundary), + centerline=Polyline3D.from_array(successor_centerline), + left_lane_id=None, + right_lane_id=None, + predecessor_ids=[0], + successor_ids=[], + speed_limit_mps=0.0, + ) + ) + + # Lane Groups: + lane_groups = [] + + # Middle lane group 0, lanes 0,1,2 + middle_lane_group = LaneGroup( + object_id=0, + lane_ids=[0, 1, 2], + left_boundary=Polyline3D.from_array(left_left_boundary), + right_boundary=Polyline3D.from_array(left_right_boundary), + intersection_id=None, + predecessor_ids=[1], + successor_ids=[2], + ) + lane_groups.append(middle_lane_group) + + # Predecessor lane group 1, lane 3, intersection 0 + predecessor_lane_group = LaneGroup( + object_id=1, + lane_ids=[3], + left_boundary=Polyline3D.from_array(predecessor_left_boundary), + right_boundary=Polyline3D.from_array(predecessor_right_boundary), + intersection_id=0, + predecessor_ids=[], + successor_ids=[0], + ) + lane_groups.append(predecessor_lane_group) + + # Successor lane group 2, lane 4, intersection 1 + successor_lane_group = LaneGroup( + object_id=2, + lane_ids=[4], + left_boundary=Polyline3D.from_array(successor_left_boundary), + right_boundary=Polyline3D.from_array(successor_right_boundary), + intersection_id=1, + predecessor_ids=[0], + successor_ids=[], + ) + lane_groups.append(successor_lane_group) + + # Intersections: + intersections = [] + + # Intersection 0, includes lane groups 1 + intersection_predecessor = Intersection( + object_id=0, + lane_group_ids=[1], + outline=predecessor_lane_group.outline, + ) + intersections.append(intersection_predecessor) + + intersection_successor = Intersection( + object_id=1, + lane_group_ids=[2], + outline=successor_lane_group.outline, + ) + intersections.append(intersection_successor) + + return lanes, lane_groups, intersections + + +class TestLane(unittest.TestCase): + def setUp(self) -> None: + lanes, lane_groups, intersections = _get_linked_map_object_setup() + self.lanes = lanes + self.lane_groups = lane_groups + self.intersections = intersections + + def test_set_up(self): + """Test that the setup function creates the correct number of map objects.""" + self.assertEqual(len(self.lanes), 5) + self.assertEqual(len(self.lane_groups), 3) + self.assertEqual(len(self.intersections), 2) + + def test_properties(self): + """Test that the properties of the Lane objects are correct.""" + lane0 = self.lanes[0] + self.assertEqual(lane0.layer, MapLayer.LANE) + self.assertEqual(lane0.lane_group_id, 0) + self.assertIsInstance(lane0.left_boundary, Polyline3D) + self.assertIsInstance(lane0.right_boundary, Polyline3D) + self.assertIsInstance(lane0.centerline, Polyline3D) + + self.assertEqual(lane0.left_lane_id, 1) + self.assertEqual(lane0.right_lane_id, 2) + self.assertEqual(lane0.predecessor_ids, [3]) + self.assertEqual(lane0.successor_ids, [4]) + self.assertEqual(lane0.speed_limit_mps, 0.0) + self.assertIsInstance(lane0.trimesh_mesh, trimesh.base.Trimesh) + + def test_base_properties(self): + """Test that the base_surface property of the Lane objects is correct.""" + lane0 = self.lanes[0] + self.assertEqual(lane0.object_id, 0) + self.assertIsInstance(lane0.outline, Polyline3D) + self.assertIsInstance(lane0.outline_2d, Polyline2D) + self.assertIsInstance(lane0.outline_3d, Polyline3D) + self.assertIsInstance(lane0.shapely_polygon, shapely.Polygon) + + def test_left_links(self): + """Test that the left neighboring lanes are correctly linked.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + def _no_left_neighbor(lane: Lane): + self.assertIsNotNone(lane) + self.assertIsNone(lane.left_lane) + self.assertIsNone(lane.left_lane_id) + + # Middle Lane 0 + lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) + self.assertIsNotNone(lane0) + self.assertIsNotNone(lane0.left_lane) + self.assertIsInstance(lane0.left_lane, Lane) + self.assertEqual(lane0.left_lane.object_id, 1) + self.assertEqual(lane0.left_lane.object_id, lane0.left_lane_id) + + # Left Lane 1 + lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) + _no_left_neighbor(lane1) + + # Right Lane 2 + lane2: Lane = map_api.get_map_object(2, MapLayer.LANE) + self.assertIsNotNone(lane2) + self.assertIsNotNone(lane2.left_lane) + self.assertIsInstance(lane2.left_lane, Lane) + self.assertEqual(lane2.left_lane.object_id, 0) + self.assertEqual(lane2.left_lane.object_id, lane2.left_lane_id) + + # Predecessor Lane 3 + lane3: Lane = map_api.get_map_object(3, MapLayer.LANE) + _no_left_neighbor(lane3) + + # Successor Lane 4 + lane4: Lane = map_api.get_map_object(4, MapLayer.LANE) + _no_left_neighbor(lane4) + + def test_right_links(self): + """Test that the right neighboring lanes are correctly linked.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + def _no_right_neighbor(lane: Lane): + self.assertIsNotNone(lane) + self.assertIsNone(lane.right_lane) + self.assertIsNone(lane.right_lane_id) + + # Middle Lane 0 + lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) + self.assertIsNotNone(lane0) + self.assertIsNotNone(lane0.right_lane) + self.assertIsInstance(lane0.right_lane, Lane) + self.assertEqual(lane0.right_lane.object_id, 2) + self.assertEqual(lane0.right_lane.object_id, lane0.right_lane_id) + + # Left Lane 1 + lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) + self.assertIsNotNone(lane1) + self.assertIsNotNone(lane1.right_lane) + self.assertIsInstance(lane1.right_lane, Lane) + self.assertEqual(lane1.right_lane.object_id, 0) + self.assertEqual(lane1.right_lane.object_id, lane1.right_lane_id) + + # Right Lane 2 + lane2: Lane = map_api.get_map_object(2, MapLayer.LANE) + _no_right_neighbor(lane2) + + # Predecessor Lane 3 + lane3: Lane = map_api.get_map_object(3, MapLayer.LANE) + _no_right_neighbor(lane3) + + # Successor Lane 4 + lane4: Lane = map_api.get_map_object(4, MapLayer.LANE) + _no_right_neighbor(lane4) + + def test_predecessor_links(self): + """Test that the predecessor lanes are correctly linked.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + def _no_predecessors(lane: Lane): + self.assertIsNotNone(lane) + self.assertEqual(lane.predecessors, []) + self.assertEqual(lane.predecessor_ids, []) + + # Middle Lane 0 + lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) + self.assertIsNotNone(lane0) + self.assertIsNotNone(lane0.predecessors) + self.assertEqual(len(lane0.predecessors), 1) + self.assertIsInstance(lane0.predecessors[0], Lane) + self.assertEqual(lane0.predecessors[0].object_id, 3) + self.assertEqual(lane0.predecessor_ids, [3]) + + # Left Lane 1 + lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) + _no_predecessors(lane1) + + # Right Lane 2 + lane2: Lane = map_api.get_map_object(2, MapLayer.LANE) + _no_predecessors(lane2) + + # Predecessor Lane 3 + lane3: Lane = map_api.get_map_object(3, MapLayer.LANE) + _no_predecessors(lane3) + + # Successor Lane 4 + lane4: Lane = map_api.get_map_object(4, MapLayer.LANE) + self.assertIsNotNone(lane4) + self.assertIsNotNone(lane4.predecessors) + self.assertEqual(len(lane4.predecessors), 1) + self.assertIsInstance(lane4.predecessors[0], Lane) + self.assertEqual(lane4.predecessors[0].object_id, 0) + self.assertEqual(lane4.predecessor_ids, [0]) + + def test_successor_links(self): + """Test that the successor lanes are correctly linked.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + def _no_successors(lane: Lane): + self.assertIsNotNone(lane) + self.assertEqual(lane.successors, []) + self.assertEqual(lane.successor_ids, []) + + # Middle Lane 0 + lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) + self.assertIsNotNone(lane0) + self.assertIsNotNone(lane0.successors) + self.assertEqual(len(lane0.successors), 1) + self.assertIsInstance(lane0.successors[0], Lane) + self.assertEqual(lane0.successors[0].object_id, 4) + self.assertEqual(lane0.successor_ids, [4]) + + # Left Lane 1 + lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) + _no_successors(lane1) + + # Right Lane 2 + lane2: Lane = map_api.get_map_object(2, MapLayer.LANE) + _no_successors(lane2) + + # Predecessor Lane 3 + lane3: Lane = map_api.get_map_object(3, MapLayer.LANE) + self.assertIsNotNone(lane3) + self.assertIsNotNone(lane3.successors) + self.assertEqual(len(lane3.successors), 1) + self.assertIsInstance(lane3.successors[0], Lane) + self.assertEqual(lane3.successors[0].object_id, 0) + self.assertEqual(lane3.successor_ids, [0]) + + # Successor Lane 4 + lane4: Lane = map_api.get_map_object(4, MapLayer.LANE) + _no_successors(lane4) + + def test_no_links(self): + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=False, + ) + for lane in self.lanes: + lane_from_api: Lane = map_api.get_map_object(lane.object_id, MapLayer.LANE) + self.assertIsNotNone(lane_from_api) + self.assertIsNone(lane_from_api.left_lane) + self.assertIsNone(lane_from_api.right_lane) + self.assertIsNone(lane_from_api.predecessors) + self.assertIsNone(lane_from_api.successors) + + def test_lane_group_links(self): + """Test that the lane group links are correct.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + for lane in self.lanes: + lane_from_api: Lane = map_api.get_map_object(lane.object_id, MapLayer.LANE) + self.assertIsNotNone(lane_from_api) + self.assertIsNotNone(lane_from_api.lane_group) + self.assertIsInstance(lane_from_api.lane_group, LaneGroup) + self.assertEqual(lane_from_api.lane_group.object_id, lane_from_api.lane_group_id) + + +class TestLaneGroup(unittest.TestCase): + + def setUp(self): + lanes, lane_groups, intersections = _get_linked_map_object_setup() + self.lanes = lanes + self.lane_groups = lane_groups + self.intersections = intersections + + def test_properties(self): + """Test that the properties of the LaneGroup objects are correct.""" + lane_group0 = self.lane_groups[0] + self.assertEqual(lane_group0.layer, MapLayer.LANE_GROUP) + self.assertEqual(lane_group0.lane_ids, [0, 1, 2]) + self.assertIsInstance(lane_group0.left_boundary, Polyline3D) + self.assertIsInstance(lane_group0.right_boundary, Polyline3D) + self.assertEqual(lane_group0.intersection_id, None) + self.assertEqual(lane_group0.predecessor_ids, [1]) + self.assertEqual(lane_group0.successor_ids, [2]) + self.assertIsInstance(lane_group0.trimesh_mesh, trimesh.base.Trimesh) + + def test_base_properties(self): + """Test that the base surface properties of the LaneGroup objects are correct.""" + lane_group0 = self.lane_groups[0] + self.assertEqual(lane_group0.object_id, 0) + self.assertIsInstance(lane_group0.outline, Polyline3D) + self.assertIsInstance(lane_group0.outline_2d, Polyline2D) + self.assertIsInstance(lane_group0.outline_3d, Polyline3D) + self.assertIsInstance(lane_group0.shapely_polygon, shapely.Polygon) + + def test_lane_links(self): + """Test that the lanes are correctly linked to the lane group.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + # Lane group 0 contains lanes 0, 1, 2 + lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group0) + self.assertIsNotNone(lane_group0.lanes) + self.assertEqual(len(lane_group0.lanes), 3) + for i, lane in enumerate(lane_group0.lanes): + self.assertIsInstance(lane, Lane) + self.assertEqual(lane.object_id, i) + + # Lane group 1 contains lane 3 + lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group1) + self.assertIsNotNone(lane_group1.lanes) + self.assertEqual(len(lane_group1.lanes), 1) + self.assertIsInstance(lane_group1.lanes[0], Lane) + self.assertEqual(lane_group1.lanes[0].object_id, 3) + + # Lane group 2 contains lane 4 + lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group2) + self.assertIsNotNone(lane_group2.lanes) + self.assertEqual(len(lane_group2.lanes), 1) + self.assertIsInstance(lane_group2.lanes[0], Lane) + self.assertEqual(lane_group2.lanes[0].object_id, 4) + + def test_predecessor_links(self): + """Test that the predecessor lane groups are correctly linked.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + def _no_predecessors(lane_group: LaneGroup): + self.assertIsNotNone(lane_group) + self.assertEqual(lane_group.predecessors, []) + self.assertEqual(lane_group.predecessor_ids, []) + + # Lane group 0 has predecessor lane group 1 + lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group0) + self.assertIsNotNone(lane_group0.predecessors) + self.assertEqual(len(lane_group0.predecessors), 1) + self.assertIsInstance(lane_group0.predecessors[0], LaneGroup) + self.assertEqual(lane_group0.predecessors[0].object_id, 1) + self.assertEqual(lane_group0.predecessor_ids, [1]) + + # Lane group 1 has no predecessors + lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) + _no_predecessors(lane_group1) + + # Lane group 2 has predecessor lane group 0 + lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group2) + self.assertIsNotNone(lane_group2.predecessors) + self.assertEqual(len(lane_group2.predecessors), 1) + self.assertIsInstance(lane_group2.predecessors[0], LaneGroup) + self.assertEqual(lane_group2.predecessors[0].object_id, 0) + self.assertEqual(lane_group2.predecessor_ids, [0]) + + def test_successor_links(self): + """Test that the successor lane groups are correctly linked.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + def _no_successors(lane_group: LaneGroup): + self.assertIsNotNone(lane_group) + self.assertEqual(lane_group.successors, []) + self.assertEqual(lane_group.successor_ids, []) + + # Lane group 0 has successor lane group 2 + lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group0) + self.assertIsNotNone(lane_group0.successors) + self.assertEqual(len(lane_group0.successors), 1) + self.assertIsInstance(lane_group0.successors[0], LaneGroup) + self.assertEqual(lane_group0.successors[0].object_id, 2) + self.assertEqual(lane_group0.successor_ids, [2]) + + # Lane group 1 has successor lane group 0 + lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group1) + self.assertIsNotNone(lane_group1.successors) + self.assertEqual(len(lane_group1.successors), 1) + self.assertIsInstance(lane_group1.successors[0], LaneGroup) + self.assertEqual(lane_group1.successors[0].object_id, 0) + self.assertEqual(lane_group1.successor_ids, [0]) + + # Lane group 2 has no successors + lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) + _no_successors(lane_group2) + + def test_intersection_links(self): + """Test that the intersection links are correct.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + # Lane group 0 has no intersection + lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group0) + self.assertIsNone(lane_group0.intersection_id) + self.assertIsNone(lane_group0.intersection) + + # Lane group 1 has intersection 0 + lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group1) + self.assertEqual(lane_group1.intersection_id, 0) + self.assertIsNotNone(lane_group1.intersection) + self.assertIsInstance(lane_group1.intersection, Intersection) + self.assertEqual(lane_group1.intersection.object_id, 0) + + # Lane group 2 has intersection 1 + lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) + self.assertIsNotNone(lane_group2) + self.assertEqual(lane_group2.intersection_id, 1) + self.assertIsNotNone(lane_group2.intersection) + self.assertIsInstance(lane_group2.intersection, Intersection) + self.assertEqual(lane_group2.intersection.object_id, 1) + + def test_no_links(self): + """Test that when map_api is not provided, no links are available.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=False, + ) + for lane_group in self.lane_groups: + lg_from_api: LaneGroup = map_api.get_map_object(lane_group.object_id, MapLayer.LANE_GROUP) + self.assertIsNotNone(lg_from_api) + self.assertIsNone(lg_from_api.lanes) + self.assertIsNone(lg_from_api.predecessors) + self.assertIsNone(lg_from_api.successors) + self.assertIsNone(lg_from_api.intersection) + + +class TestIntersection(unittest.TestCase): + + def setUp(self): + lanes, lane_groups, intersections = _get_linked_map_object_setup() + self.lanes = lanes + self.lane_groups = lane_groups + self.intersections = intersections + + def test_properties(self): + """Test that the properties of the Intersection objects are correct.""" + intersection0 = self.intersections[0] + self.assertEqual(intersection0.layer, MapLayer.INTERSECTION) + self.assertEqual(intersection0.lane_group_ids, [1]) + self.assertIsInstance(intersection0.outline, Polyline3D) + + def test_base_properties(self): + """Test that the base surface properties of the Intersection objects are correct.""" + intersection0 = self.intersections[0] + self.assertEqual(intersection0.object_id, 0) + self.assertIsInstance(intersection0.outline, Polyline3D) + self.assertIsInstance(intersection0.outline_2d, Polyline2D) + self.assertIsInstance(intersection0.outline_3d, Polyline3D) + self.assertIsInstance(intersection0.shapely_polygon, shapely.Polygon) + + def test_lane_group_links(self): + """Test that the lane groups are correctly linked to the intersection.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=True, + ) + + # Intersection 0 contains lane group 1 + intersection0: Intersection = map_api.get_map_object(0, MapLayer.INTERSECTION) + self.assertIsNotNone(intersection0) + self.assertIsNotNone(intersection0.lane_groups) + self.assertEqual(len(intersection0.lane_groups), 1) + self.assertIsInstance(intersection0.lane_groups[0], LaneGroup) + self.assertEqual(intersection0.lane_groups[0].object_id, 1) + + # Intersection 1 contains lane group 2 + intersection1: Intersection = map_api.get_map_object(1, MapLayer.INTERSECTION) + self.assertIsNotNone(intersection1) + self.assertIsNotNone(intersection1.lane_groups) + self.assertEqual(len(intersection1.lane_groups), 1) + self.assertIsInstance(intersection1.lane_groups[0], LaneGroup) + self.assertEqual(intersection1.lane_groups[0].object_id, 2) + + def test_no_links(self): + """Test that when map_api is not provided, no links are available.""" + map_api = MockMapAPI( + lanes=self.lanes, + lane_groups=self.lane_groups, + intersections=self.intersections, + add_map_api_links=False, + ) + for intersection in self.intersections: + int_from_api: Intersection = map_api.get_map_object(intersection.object_id, MapLayer.INTERSECTION) + self.assertIsNotNone(int_from_api) + self.assertIsNone(int_from_api.lane_groups) + + +class TestCrosswalk(unittest.TestCase): + def test_properties(self): + """Test that the properties of the Crosswalk object are correct.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]) + ) + crosswalk = Crosswalk(object_id=0, outline=outline) + self.assertEqual(crosswalk.layer, MapLayer.CROSSWALK) + self.assertEqual(crosswalk.object_id, 0) + self.assertIsInstance(crosswalk.outline, Polyline3D) + self.assertIsInstance(crosswalk.shapely_polygon, shapely.Polygon) + + def test_init_with_shapely_polygon(self): + """Test initialization with shapely polygon.""" + shapely_polygon = shapely.Polygon([(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]) + crosswalk = Crosswalk(object_id=0, shapely_polygon=shapely_polygon) + self.assertEqual(crosswalk.object_id, 0) + self.assertIsInstance(crosswalk.shapely_polygon, shapely.Polygon) + self.assertIsInstance(crosswalk.outline_2d, Polyline2D) + + def test_init_with_polyline2d(self): + """Test initialization with Polyline2D outline.""" + outline = Polyline2D.from_array(np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]])) + crosswalk = Crosswalk(object_id=0, outline=outline) + self.assertIsInstance(crosswalk.outline_2d, Polyline2D) + self.assertIsInstance(crosswalk.shapely_polygon, shapely.Polygon) + + def test_base_surface_properties(self): + """Test base surface object properties.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]) + ) + crosswalk = Crosswalk(object_id=0, outline=outline) + self.assertIsInstance(crosswalk.outline_3d, Polyline3D) + self.assertTrue(crosswalk.shapely_polygon.is_valid) + + +class TestCarpark(unittest.TestCase): + def test_properties(self): + """Test that the properties of the Carpark object are correct.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 2.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 0.0]]) + ) + carpark = Carpark(object_id=1, outline=outline) + self.assertEqual(carpark.layer, MapLayer.CARPARK) + self.assertEqual(carpark.object_id, 1) + self.assertIsInstance(carpark.outline, Polyline3D) + self.assertIsInstance(carpark.shapely_polygon, shapely.Polygon) + + def test_init_with_shapely_polygon(self): + """Test initialization with shapely polygon.""" + shapely_polygon = shapely.Polygon([(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]) + carpark = Carpark(object_id=1, shapely_polygon=shapely_polygon) + self.assertEqual(carpark.object_id, 1) + self.assertIsInstance(carpark.shapely_polygon, shapely.Polygon) + self.assertIsInstance(carpark.outline_2d, Polyline2D) + + def test_init_with_polyline2d(self): + """Test initialization with Polyline2D outline.""" + outline = Polyline2D.from_array(np.array([[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]])) + carpark = Carpark(object_id=1, outline=outline) + self.assertIsInstance(carpark.outline_2d, Polyline2D) + self.assertIsInstance(carpark.shapely_polygon, shapely.Polygon) + + def test_polygon_area(self): + """Test that the polygon area is calculated correctly.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 2.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 0.0]]) + ) + carpark = Carpark(object_id=1, outline=outline) + self.assertAlmostEqual(carpark.shapely_polygon.area, 4.0) + + +class TestWalkway(unittest.TestCase): + def test_properties(self): + """Test that the properties of the Walkway object are correct.""" + outline = Polyline2D.from_array(np.array([[0.0, 0.0], [3.0, 0.0], [3.0, 1.0], [0.0, 1.0], [0.0, 0.0]])) + walkway = Walkway(object_id=2, outline=outline) + self.assertEqual(walkway.layer, MapLayer.WALKWAY) + self.assertEqual(walkway.object_id, 2) + self.assertIsInstance(walkway.outline_2d, Polyline2D) + self.assertIsInstance(walkway.shapely_polygon, shapely.Polygon) + + def test_init_with_shapely_polygon(self): + """Test initialization with shapely polygon.""" + shapely_polygon = shapely.Polygon([(0.0, 0.0), (3.0, 0.0), (3.0, 1.0), (0.0, 1.0)]) + walkway = Walkway(object_id=2, shapely_polygon=shapely_polygon) + self.assertEqual(walkway.object_id, 2) + self.assertIsInstance(walkway.shapely_polygon, shapely.Polygon) + + def test_init_with_polyline3d(self): + """Test initialization with Polyline3D outline.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [3.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]) + ) + walkway = Walkway(object_id=2, outline=outline) + self.assertIsInstance(walkway.outline_3d, Polyline3D) + self.assertIsInstance(walkway.shapely_polygon, shapely.Polygon) + + def test_polygon_bounds(self): + """Test that polygon bounds are correct.""" + outline = Polyline2D.from_array(np.array([[0.0, 0.0], [3.0, 0.0], [3.0, 1.0], [0.0, 1.0], [0.0, 0.0]])) + walkway = Walkway(object_id=2, outline=outline) + bounds = walkway.shapely_polygon.bounds + self.assertEqual(bounds, (0.0, 0.0, 3.0, 1.0)) + + +class TestGenericDrivable(unittest.TestCase): + def test_properties(self): + """Test that the properties of the GenericDrivable object are correct.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [5.0, 3.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 0.0]]) + ) + generic_drivable = GenericDrivable(object_id=3, outline=outline) + self.assertEqual(generic_drivable.layer, MapLayer.GENERIC_DRIVABLE) + self.assertEqual(generic_drivable.object_id, 3) + self.assertIsInstance(generic_drivable.outline, Polyline3D) + self.assertIsInstance(generic_drivable.shapely_polygon, shapely.Polygon) + + def test_init_with_shapely_polygon(self): + """Test initialization with shapely polygon.""" + shapely_polygon = shapely.Polygon([(0.0, 0.0), (5.0, 0.0), (5.0, 3.0), (0.0, 3.0)]) + generic_drivable = GenericDrivable(object_id=3, shapely_polygon=shapely_polygon) + self.assertEqual(generic_drivable.object_id, 3) + self.assertIsInstance(generic_drivable.shapely_polygon, shapely.Polygon) + + def test_init_with_polyline2d(self): + """Test initialization with Polyline2D outline.""" + outline = Polyline2D.from_array(np.array([[0.0, 0.0], [5.0, 0.0], [5.0, 3.0], [0.0, 3.0], [0.0, 0.0]])) + generic_drivable = GenericDrivable(object_id=3, outline=outline) + self.assertIsInstance(generic_drivable.outline_2d, Polyline2D) + self.assertIsInstance(generic_drivable.shapely_polygon, shapely.Polygon) + + def test_polygon_area(self): + """Test that the polygon area is calculated correctly.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [5.0, 3.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 0.0]]) + ) + generic_drivable = GenericDrivable(object_id=3, outline=outline) + self.assertAlmostEqual(generic_drivable.shapely_polygon.area, 15.0) + + +class TestStopZone(unittest.TestCase): + def test_properties(self): + """Test that the properties of the StopZone object are correct.""" + shapely_polygon = shapely.Polygon([(0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)]) + stop_zone = StopZone(object_id=4, shapely_polygon=shapely_polygon) + self.assertEqual(stop_zone.layer, MapLayer.STOP_ZONE) + self.assertEqual(stop_zone.object_id, 4) + self.assertIsInstance(stop_zone.shapely_polygon, shapely.Polygon) + self.assertIsInstance(stop_zone.outline_2d, Polyline2D) + + def test_init_with_polyline3d(self): + """Test initialization with Polyline3D outline.""" + outline = Polyline3D.from_array( + np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.5, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.0]]) + ) + stop_zone = StopZone(object_id=4, outline=outline) + self.assertIsInstance(stop_zone.outline, Polyline3D) + self.assertIsInstance(stop_zone.shapely_polygon, shapely.Polygon) + + def test_init_with_polyline2d(self): + """Test initialization with Polyline2D outline.""" + outline = Polyline2D.from_array(np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5], [0.0, 0.0]])) + stop_zone = StopZone(object_id=4, outline=outline) + self.assertIsInstance(stop_zone.outline_2d, Polyline2D) + self.assertIsInstance(stop_zone.shapely_polygon, shapely.Polygon) + + def test_polygon_area(self): + """Test that the polygon area is calculated correctly.""" + shapely_polygon = shapely.Polygon([(0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)]) + stop_zone = StopZone(object_id=4, shapely_polygon=shapely_polygon) + self.assertAlmostEqual(stop_zone.shapely_polygon.area, 0.5) + + +class TestRoadEdge(unittest.TestCase): + def test_properties(self): + """Test that the properties of the RoadEdge object are correct.""" + polyline = Polyline3D.from_array(np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0]])) + road_edge = RoadEdge(object_id=5, road_edge_type=1, polyline=polyline) + self.assertEqual(road_edge.layer, MapLayer.ROAD_EDGE) + self.assertEqual(road_edge.object_id, 5) + self.assertEqual(road_edge.road_edge_type, 1) + self.assertIsInstance(road_edge.polyline, Polyline3D) + + def test_init_with_polyline2d(self): + """Test initialization with Polyline2D.""" + polyline = Polyline2D.from_array(np.array([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]])) + road_edge = RoadEdge(object_id=5, road_edge_type=1, polyline=polyline) + self.assertIsInstance(road_edge.polyline, Polyline2D) + self.assertEqual(road_edge.road_edge_type, 1) + + def test_polyline_length(self): + """Test that the polyline has correct number of points.""" + polyline = Polyline3D.from_array(np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0]])) + road_edge = RoadEdge(object_id=5, road_edge_type=1, polyline=polyline) + self.assertEqual(len(road_edge.polyline.array), 3) + + def test_different_road_edge_types(self): + """Test different road edge types.""" + polyline = Polyline3D.from_array(np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]])) + for edge_type in RoadEdgeType: + road_edge = RoadEdge(object_id=5, road_edge_type=edge_type, polyline=polyline) + self.assertEqual(road_edge.road_edge_type, edge_type) + + +class TestRoadLine(unittest.TestCase): + def test_properties(self): + """Test that the properties of the RoadLine object are correct.""" + polyline = Polyline2D.from_array(np.array([[0.0, 1.0], [10.0, 1.0], [20.0, 1.0]])) + road_line = RoadLine(object_id=6, road_line_type=2, polyline=polyline) + self.assertEqual(road_line.layer, MapLayer.ROAD_LINE) + self.assertEqual(road_line.object_id, 6) + self.assertEqual(road_line.road_line_type, 2) + self.assertIsInstance(road_line.polyline, Polyline2D) + + def test_init_with_polyline3d(self): + """Test initialization with Polyline3D.""" + polyline = Polyline3D.from_array(np.array([[0.0, 1.0, 0.0], [10.0, 1.0, 0.0], [20.0, 1.0, 0.0]])) + road_line = RoadLine(object_id=6, road_line_type=2, polyline=polyline) + self.assertIsInstance(road_line.polyline, Polyline3D) + self.assertEqual(road_line.road_line_type, 2) + + def test_polyline_length(self): + """Test that the polyline has correct number of points.""" + polyline = Polyline2D.from_array(np.array([[0.0, 1.0], [10.0, 1.0], [20.0, 1.0], [30.0, 1.0]])) + road_line = RoadLine(object_id=6, road_line_type=2, polyline=polyline) + self.assertEqual(len(road_line.polyline.array), 4) + + def test_different_road_line_types(self): + """Test different road line types.""" + polyline = Polyline2D.from_array(np.array([[0.0, 1.0], [10.0, 1.0]])) + for line_type in RoadLineType: + road_line = RoadLine(object_id=6, road_line_type=line_type, polyline=polyline) + self.assertEqual(road_line.road_line_type, line_type) diff --git a/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py index 9b0da5d2..dc569a0a 100644 --- a/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py +++ b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py @@ -17,6 +17,7 @@ def setUp(self): ) def test_initialization(self): + """Test that VehicleParameters initializes correctly.""" self.assertEqual(self.params.vehicle_name, "test_vehicle") self.assertEqual(self.params.width, 2.0) self.assertEqual(self.params.length, 5.0) @@ -26,15 +27,19 @@ def test_initialization(self): self.assertEqual(self.params.rear_axle_to_center_longitudinal, 1.5) def test_half_width(self): + """Test half_width property.""" self.assertEqual(self.params.half_width, 1.0) def test_half_length(self): + """Test half_length property.""" self.assertEqual(self.params.half_length, 2.5) def test_half_height(self): + """Test half_height property.""" self.assertEqual(self.params.half_height, 0.9) def test_to_dict(self): + """Test to_dict method.""" result = self.params.to_dict() expected = { "vehicle_name": "test_vehicle", @@ -48,6 +53,7 @@ def test_to_dict(self): self.assertEqual(result, expected) def test_from_dict(self): + """Test from_dict method.""" data = { "vehicle_name": "from_dict_vehicle", "width": 1.5, @@ -67,6 +73,7 @@ def test_from_dict(self): self.assertEqual(params.rear_axle_to_center_longitudinal, 1.2) def test_from_dict_to_dict_round_trip(self): + """Test that from_dict and to_dict are inverses.""" original_dict = self.params.to_dict() recreated_params = VehicleParameters.from_dict(original_dict) recreated_dict = recreated_params.to_dict() From bbca8af3e43d150e1724943dcc359ecc4a809793 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 16:34:30 +0100 Subject: [PATCH 18/50] Update the metadata objects. Add tests and polish docs. --- .../datatypes/sensors/01_pinhole_camera.rst | 2 +- src/py123d/api/map/gpkg/gpkg_map_api.py | 14 +- .../datasets/av2/av2_sensor_converter.py | 8 +- .../datasets/kitti360/kitti360_converter.py | 8 +- .../datasets/nuplan/nuplan_converter.py | 10 +- .../datasets/nuscenes/nuscenes_converter.py | 8 +- .../datasets/pandaset/pandaset_converter.py | 8 +- .../datasets/wopd/wopd_converter.py | 8 +- src/py123d/datatypes/metadata/log_metadata.py | 164 +++++++++++++++--- src/py123d/datatypes/metadata/map_metadata.py | 91 ++++++++-- src/py123d/datatypes/sensors/__init__.py | 2 +- .../datatypes/sensors/pinhole_camera.py | 22 +-- tests/unit/datatypes/metadata/__init__.py | 0 .../datatypes/metadata/test_log_metadata.py | 153 ++++++++++++++++ .../datatypes/metadata/test_map_metadata.py | 107 ++++++++++++ .../datatypes/sensors/test_pinhole_camera.py | 34 ++-- 16 files changed, 545 insertions(+), 94 deletions(-) create mode 100644 tests/unit/datatypes/metadata/__init__.py create mode 100644 tests/unit/datatypes/metadata/test_log_metadata.py create mode 100644 tests/unit/datatypes/metadata/test_map_metadata.py diff --git a/docs/api/datatypes/sensors/01_pinhole_camera.rst b/docs/api/datatypes/sensors/01_pinhole_camera.rst index 3603edbc..dd253baf 100644 --- a/docs/api/datatypes/sensors/01_pinhole_camera.rst +++ b/docs/api/datatypes/sensors/01_pinhole_camera.rst @@ -12,7 +12,7 @@ Pinhole Camera Data Pinhole Metadata ---------------- -.. autoclass:: py123d.datatypes.sensors.PinholeMetadata +.. autoclass:: py123d.datatypes.sensors.PinholeCameraMetadata :members: :exclude-members: __init__ :autoclasstoc: diff --git a/src/py123d/api/map/gpkg/gpkg_map_api.py b/src/py123d/api/map/gpkg/gpkg_map_api.py index 003d656f..e7b27a9f 100644 --- a/src/py123d/api/map/gpkg/gpkg_map_api.py +++ b/src/py123d/api/map/gpkg/gpkg_map_api.py @@ -292,7 +292,7 @@ def _get_lane(self, id: str) -> Optional[Lane]: successor_ids=successor_ids, speed_limit_mps=speed_limit_mps, outline=outline, - geometry=geometry, + shapely_polygon=geometry, map_api=self, ) @@ -325,7 +325,7 @@ def _get_lane_group(self, id: str) -> Optional[LaneGroup]: predecessor_ids=predecessor_ids, successor_ids=successor_ids, outline=outline, - geometry=geometry, + shapely_polygon=geometry, map_api=self, ) @@ -351,7 +351,7 @@ def _get_intersection(self, id: str) -> Optional[Intersection]: object_id=object_id, lane_group_ids=lane_group_ids, outline=outline, - geometry=geometry, + shapely_polygon=geometry, map_api=self, ) @@ -371,7 +371,7 @@ def _get_crosswalk(self, id: str) -> Optional[Crosswalk]: crosswalk = Crosswalk( object_id=object_id, outline=outline, - geometry=geometry, + shapely_polygon=geometry, ) return crosswalk @@ -390,7 +390,7 @@ def _get_walkway(self, id: str) -> Optional[Walkway]: walkway = Walkway( object_id=object_id, outline=outline, - geometry=geometry, + shapely_polygon=geometry, ) return walkway @@ -409,7 +409,7 @@ def _get_carpark(self, id: str) -> Optional[Carpark]: carpark = Carpark( object_id=object_id, outline=outline, - geometry=geometry, + shapely_polygon=geometry, ) return carpark @@ -428,7 +428,7 @@ def _get_generic_drivable(self, id: str) -> Optional[GenericDrivable]: generic_drivable = GenericDrivable( object_id=object_id, outline=outline, - geometry=geometry, + shapely_polygon=geometry, ) return generic_drivable diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index baaae942..969c8df5 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -23,10 +23,10 @@ from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( + PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, - PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 @@ -183,16 +183,16 @@ def _get_av2_sensor_map_metadata(split: str, source_log_path: Path) -> MapMetada def _get_av2_pinhole_camera_metadata( source_log_path: Path, dataset_converter_config: DatasetConverterConfig -) -> Dict[PinholeCameraType, PinholeMetadata]: +) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - pinhole_camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = {} + pinhole_camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} if dataset_converter_config.include_pinhole_cameras: intrinsics_file = source_log_path / "calibration" / "intrinsics.feather" intrinsics_df = pd.read_feather(intrinsics_file) for _, row in intrinsics_df.iterrows(): row = row.to_dict() camera_type = AV2_CAMERA_TYPE_MAPPING[row["sensor_name"]] - pinhole_camera_metadata[camera_type] = PinholeMetadata( + pinhole_camera_metadata[camera_type] = PinholeCameraMetadata( camera_type=camera_type, width=row["width_px"], height=row["height_px"], diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 91c94d71..500b117a 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -37,10 +37,10 @@ FisheyeMEIProjection, LiDARMetadata, LiDARType, + PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, - PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 @@ -308,9 +308,9 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: def _get_kitti360_pinhole_camera_metadata( kitti360_folders: Dict[str, Path], dataset_converter_config: DatasetConverterConfig, -) -> Dict[PinholeCameraType, PinholeMetadata]: +) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - pinhole_cam_metadatas: Dict[PinholeCameraType, PinholeMetadata] = {} + pinhole_cam_metadatas: Dict[PinholeCameraType, PinholeCameraMetadata] = {} if dataset_converter_config.include_pinhole_cameras: persp = kitti360_folders[DIR_CALIB] / "perspective.txt" assert persp.exists() @@ -329,7 +329,7 @@ def _get_kitti360_pinhole_camera_metadata( persp_result[f"image_{cam_id}"]["distortion"] = [float(x) for x in value.split()] for pcam_type, pcam_name in KITTI360_PINHOLE_CAMERA_TYPES.items(): - pinhole_cam_metadatas[pcam_type] = PinholeMetadata( + pinhole_cam_metadatas[pcam_type] = PinholeCameraMetadata( camera_type=pcam_type, width=persp_result[pcam_name]["wh"][0], height=persp_result[pcam_name]["wh"][1], diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index 93538cf2..873d3ec7 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -32,10 +32,10 @@ from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( + PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, - PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 @@ -242,9 +242,9 @@ def _get_nuplan_camera_metadata( source_log_path: Path, nuplan_sensor_root: Path, dataset_converter_config: DatasetConverterConfig, -) -> Dict[PinholeCameraType, PinholeMetadata]: +) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeMetadata: + def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeCameraMetadata: cam = list(get_cameras(source_log_path, [str(NUPLAN_CAMERA_MAPPING[camera_type].value)]))[0] intrinsics_camera_matrix = np.array(pickle.loads(cam.intrinsic), dtype=np.float64) # array of shape (3, 3) @@ -253,7 +253,7 @@ def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeMetadata: distortion_array = np.array(pickle.loads(cam.distortion), dtype=np.float64) # array of shape (5,) distortion = PinholeDistortion.from_array(distortion_array, copy=False) - return PinholeMetadata( + return PinholeCameraMetadata( camera_type=camera_type, width=cam.width, height=cam.height, @@ -261,7 +261,7 @@ def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeMetadata: distortion=distortion, ) - camera_metadata: Dict[str, PinholeMetadata] = {} + camera_metadata: Dict[str, PinholeCameraMetadata] = {} if dataset_converter_config.include_pinhole_cameras: log_name = source_log_path.stem for camera_type, nuplan_camera_type in NUPLAN_CAMERA_MAPPING.items(): diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 9b0ba145..fb9e0844 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -23,10 +23,10 @@ from py123d.datatypes.metadata import LogMetadata, MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( + PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, - PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 @@ -204,8 +204,8 @@ def _get_nuscenes_pinhole_camera_metadata( nusc: NuScenes, scene: Dict[str, Any], dataset_converter_config: DatasetConverterConfig, -) -> Dict[PinholeCameraType, PinholeMetadata]: - camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = {} +) -> Dict[PinholeCameraType, PinholeCameraMetadata]: + camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} if dataset_converter_config.include_pinhole_cameras: first_sample_token = scene["first_sample_token"] @@ -220,7 +220,7 @@ def _get_nuscenes_pinhole_camera_metadata( intrinsic = PinholeIntrinsics.from_camera_matrix(intrinsic_matrix) distortion = PinholeDistortion.from_array(np.zeros(5), copy=False) - camera_metadata[camera_type] = PinholeMetadata( + camera_metadata[camera_type] = PinholeCameraMetadata( camera_type=camera_type, width=cam_data["width"], height=cam_data["height"], diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index c5e9e1a5..12d97baa 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -29,7 +29,7 @@ from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType, PinholeIntrinsics, PinholeMetadata +from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType, PinholeIntrinsics from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import get_pandaset_chrysler_pacifica_parameters @@ -155,9 +155,9 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: def _get_pandaset_camera_metadata( source_log_path: Path, dataset_config: DatasetConverterConfig -) -> Dict[PinholeCameraType, PinholeMetadata]: +) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = {} + camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} if dataset_config.include_pinhole_cameras: all_cameras_folder = source_log_path / "camera" @@ -171,7 +171,7 @@ def _get_pandaset_camera_metadata( assert intrinsics_file.exists(), f"Camera intrinsics file {intrinsics_file} does not exist." intrinsics_data = read_json(intrinsics_file) - camera_metadata[camera_type] = PinholeMetadata( + camera_metadata[camera_type] = PinholeCameraMetadata( camera_type=camera_type, width=1920, height=1080, diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 61e22166..023a84ce 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -28,10 +28,10 @@ from py123d.datatypes.sensors import ( LiDARMetadata, LiDARType, + PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, - PinholeMetadata, ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 @@ -231,9 +231,9 @@ def _get_wopd_map_metadata(initial_frame: dataset_pb2.Frame, split: str) -> MapM def _get_wopd_camera_metadata( initial_frame: dataset_pb2.Frame, dataset_converter_config: DatasetConverterConfig -) -> Dict[PinholeCameraType, PinholeMetadata]: +) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - camera_metadata_dict: Dict[PinholeCameraType, PinholeMetadata] = {} + camera_metadata_dict: Dict[PinholeCameraType, PinholeCameraMetadata] = {} if dataset_converter_config.pinhole_camera_store_option is not None: for calibration in initial_frame.context.camera_calibrations: @@ -244,7 +244,7 @@ def _get_wopd_camera_metadata( intrinsics = PinholeIntrinsics(fx=fx, fy=fy, cx=cx, cy=cy) distortion = PinholeDistortion(k1=k1, k2=k2, p1=p1, p2=p2, k3=k3) if camera_type in WOPD_CAMERA_TYPES.values(): - camera_metadata_dict[camera_type] = PinholeMetadata( + camera_metadata_dict[camera_type] = PinholeCameraMetadata( camera_type=camera_type, width=calibration.width, height=calibration.height, diff --git a/src/py123d/datatypes/metadata/log_metadata.py b/src/py123d/datatypes/metadata/log_metadata.py index 03bd87fd..53c80a4e 100644 --- a/src/py123d/datatypes/metadata/log_metadata.py +++ b/src/py123d/datatypes/metadata/log_metadata.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, field from typing import Dict, Optional, Type import py123d @@ -8,30 +7,149 @@ from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICameraMetadata, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType, PinholeMetadata +from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters -@dataclass class LogMetadata: - - dataset: str - split: str - log_name: str - location: str - timestep_seconds: float - - vehicle_parameters: Optional[VehicleParameters] = None - box_detection_label_class: Optional[Type[BoxDetectionLabel]] = None - pinhole_camera_metadata: Dict[PinholeCameraType, PinholeMetadata] = field(default_factory=dict) - fisheye_mei_camera_metadata: Dict[FisheyeMEICameraType, FisheyeMEICameraMetadata] = field(default_factory=dict) - lidar_metadata: Dict[LiDARType, LiDARMetadata] = field(default_factory=dict) - - map_metadata: Optional[MapMetadata] = None - version: str = str(py123d.__version__) + """Class to hold metadata information about a log.""" + + __slots__ = ( + "_dataset", + "_split", + "_log_name", + "_location", + "_timestep_seconds", + "_vehicle_parameters", + "_box_detection_label_class", + "_pinhole_camera_metadata", + "_fisheye_mei_camera_metadata", + "_lidar_metadata", + "_map_metadata", + "_version", + ) + + def __init__( + self, + dataset: str, + split: str, + log_name: str, + location: str, + timestep_seconds: float, + vehicle_parameters: Optional[VehicleParameters] = None, + box_detection_label_class: Optional[Type[BoxDetectionLabel]] = None, + pinhole_camera_metadata: Optional[Dict[PinholeCameraType, PinholeCameraMetadata]] = {}, + fisheye_mei_camera_metadata: Optional[Dict[FisheyeMEICameraType, FisheyeMEICameraMetadata]] = {}, + lidar_metadata: Optional[Dict[LiDARType, LiDARMetadata]] = {}, + map_metadata: Optional[MapMetadata] = None, + version: str = str(py123d.__version__), + ): + """Create a :class:`LogMetadata` instance from a dictionary. + + :param dataset: The dataset name in lowercase. + :param split: Data split name, typically ``{dataset_name}_{train/val/test}``. + :param log_name: Name of the log file. + :param location: Location of the log data. + :param timestep_seconds: The time interval between consecutive frames in seconds. + :param vehicle_parameters: The :class:`~py123d.datatypes.vehicle_state.VehicleParameters` + of the ego vehicle, if available. + :param box_detection_label_class: The box detection label class specific to the dataset, if available. + :param pinhole_camera_metadata: Dictionary of :class:`~py123d.datatypes.sensors.PinholeCameraType` + to :class:`~py123d.datatypes.sensors.PinholeCameraMetadata`, defaults to {} + :param fisheye_mei_camera_metadata: Dictionary of :class:`~py123d.datatypes.sensors.FisheyeMEICameraType` + to :class:`~py123d.datatypes.sensors.FisheyeMEICameraMetadata`, defaults to {} + :param lidar_metadata: Dictionary of :class:`~py123d.datatypes.sensors.LiDARType` + to :class:`~py123d.datatypes.sensors.LiDARMetadata`, defaults to {} + :param map_metadata: The :class:`~py123d.datatypes.metadata.MapMetadata` for the log, if available, defaults to None + :param version: The version of the log metadata, defaults to str(py123d.__version__) + """ + self._dataset = dataset + self._split = split + self._log_name = log_name + self._location = location + self._timestep_seconds = timestep_seconds + self._vehicle_parameters = vehicle_parameters + self._box_detection_label_class = box_detection_label_class + self._pinhole_camera_metadata = pinhole_camera_metadata + self._fisheye_mei_camera_metadata = fisheye_mei_camera_metadata + self._lidar_metadata = lidar_metadata + self._map_metadata = map_metadata + self._version = version + + @property + def dataset(self) -> str: + """The dataset name in lowercase.""" + return self._dataset + + @property + def split(self) -> str: + """Data split name, typically ``{dataset_name}_{train/val/test}``.""" + return self._split + + @property + def log_name(self) -> str: + """Name of the log file.""" + return self._log_name + + @property + def location(self) -> str: + """Location of the log data.""" + return self._location + + @property + def timestep_seconds(self) -> float: + """The time interval between consecutive frames in seconds.""" + return self._timestep_seconds + + @property + def vehicle_parameters(self) -> Optional[VehicleParameters]: + """The :class:`~py123d.datatypes.vehicle_state.VehicleParameters` of the ego vehicle, if available.""" + return self._vehicle_parameters + + @property + def box_detection_label_class(self) -> Optional[Type[BoxDetectionLabel]]: + """The box detection label class specific to the dataset, if available.""" + return self._box_detection_label_class + + @property + def pinhole_camera_metadata(self) -> Dict[PinholeCameraType, PinholeCameraMetadata]: + """Dictionary of :class:`~py123d.datatypes.sensors.PinholeCameraType` + to :class:`~py123d.datatypes.sensors.PinholeCameraMetadata`. + """ + return self._pinhole_camera_metadata + + @property + def fisheye_mei_camera_metadata(self) -> Dict[FisheyeMEICameraType, FisheyeMEICameraMetadata]: + """Dictionary of :class:`~py123d.datatypes.sensors.FisheyeMEICameraType` + to :class:`~py123d.datatypes.sensors.FisheyeMEICameraMetadata`. + """ + return self._fisheye_mei_camera_metadata + + @property + def lidar_metadata(self) -> Dict[LiDARType, LiDARMetadata]: + """Dictionary of :class:`~py123d.datatypes.sensors.LiDARType` + to :class:`~py123d.datatypes.sensors.LiDARMetadata`. + """ + return self._lidar_metadata + + @property + def map_metadata(self) -> Optional[MapMetadata]: + """The :class:`~py123d.datatypes.metadata.MapMetadata` associated with the log, if available.""" + return self._map_metadata + + @property + def version(self) -> str: + """Version of the py123d library used to create this log metadata (not used currently).""" + return self._version @classmethod def from_dict(cls, data_dict: Dict) -> LogMetadata: + """Create a :class:`LogMetadata` instance from a Python dictionary. + + :param data_dict: Dictionary containing log metadata. + :raises ValueError: If the dictionary is missing required fields. + :return: A :class:`LogMetadata` instance. + """ # Ego Vehicle Parameters if data_dict["vehicle_parameters"] is not None: @@ -50,7 +168,7 @@ def from_dict(cls, data_dict: Dict) -> LogMetadata: # Pinhole Camera Metadata pinhole_camera_metadata = {} for key, value in data_dict.get("pinhole_camera_metadata", {}).items(): - pinhole_camera_metadata[PinholeCameraType.deserialize(key)] = PinholeMetadata.from_dict(value) + pinhole_camera_metadata[PinholeCameraType.deserialize(key)] = PinholeCameraMetadata.from_dict(value) data_dict["pinhole_camera_metadata"] = pinhole_camera_metadata # Fisheye MEI Camera Metadata @@ -74,7 +192,13 @@ def from_dict(cls, data_dict: Dict) -> LogMetadata: return LogMetadata(**data_dict) def to_dict(self) -> Dict: - data_dict = asdict(self) + """Convert the :class:`LogMetadata` instance to a Python dictionary. + + :return: A dictionary representation of the log metadata. + """ + data_dict = {slot.lstrip("_"): getattr(self, slot) for slot in self.__slots__} + + # Override complex types with their dictionary representations data_dict["vehicle_parameters"] = self.vehicle_parameters.to_dict() if self.vehicle_parameters else None if self.box_detection_label_class is not None: data_dict["box_detection_label_class"] = self.box_detection_label_class.__name__ diff --git a/src/py123d/datatypes/metadata/map_metadata.py b/src/py123d/datatypes/metadata/map_metadata.py index e7de01d5..de04b360 100644 --- a/src/py123d/datatypes/metadata/map_metadata.py +++ b/src/py123d/datatypes/metadata/map_metadata.py @@ -1,25 +1,92 @@ from __future__ import annotations -from dataclasses import asdict, dataclass from typing import Any, Dict, Optional import py123d -@dataclass class MapMetadata: """Class to hold metadata information about a map.""" - dataset: str - split: Optional[str] # None, if map is not per log - log_name: Optional[str] # None, if map is per log - location: str - map_has_z: bool - map_is_local: bool # True, if map is per log - version: str = str(py123d.__version__) + __slots__ = ("_dataset", "_split", "_log_name", "_location", "_map_has_z", "_map_is_local", "_version") - def to_dict(self) -> dict: - return asdict(self) + def __init__( + self, + dataset: str, + location: str, + map_has_z: bool, + map_is_local: bool, + split: Optional[str] = None, + log_name: Optional[str] = None, + version: str = str(py123d.__version__), + ): + """Initialize a MapMetadata instance. - def from_dict(data_dict: Dict[str, Any]) -> MapMetadata: + :param dataset: The dataset name in lowercase. + :param location: The location of the map data. + :param map_has_z: Indicates if the map includes Z (elevation) data. + :param map_is_local: Indicates if the map is local (map for each log) or + global (map for multiple logs in dataset). + :param split: Data split name, typically ``{dataset_name}_{train/val/test}``, defaults to None + :param log_name: Name of the log file, defaults to None + :param version: Version of the py123d library used to create this map metadata, + defaults to str(py123d.__version__) + """ + self._dataset = dataset + self._split = split + self._log_name = log_name + self._location = location + self._map_has_z = map_has_z + self._map_is_local = map_is_local + self._version = version + + @property + def dataset(self) -> str: + """The dataset name in lowercase.""" + return self._dataset + + @property + def split(self) -> Optional[str]: + """Data split name, typically ``{dataset_name}_{train/val/test}``.""" + return self._split + + @property + def log_name(self) -> Optional[str]: + """Name of the log file.""" + return self._log_name + + @property + def location(self) -> str: + """Location of the map data.""" + return self._location + + @property + def map_has_z(self) -> bool: + """Indicates if the map includes Z (elevation) data.""" + return self._map_has_z + + @property + def map_is_local(self) -> bool: + """Indicates if the map is local (map for each log) or global (map for multiple logs in dataset).""" + return self._map_is_local + + @property + def version(self) -> str: + """Version of the py123d library used to create this map metadata.""" + return self._version + + @classmethod + def from_dict(cls, data_dict: Dict[str, Any]) -> MapMetadata: + """Create a MapMetadata instance from a dictionary. + + :param data_dict: A dictionary representation of a MapMetadata instance. + :return: A MapMetadata instance. + """ return MapMetadata(**data_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert the MapMetadata instance to a dictionary. + + :return: A dictionary representation of the MapMetadata instance. + """ + return {slot.lstrip("_"): getattr(self, slot) for slot in self.__slots__} diff --git a/src/py123d/datatypes/sensors/__init__.py b/src/py123d/datatypes/sensors/__init__.py index ace7984f..6ce7fd0c 100644 --- a/src/py123d/datatypes/sensors/__init__.py +++ b/src/py123d/datatypes/sensors/__init__.py @@ -5,7 +5,7 @@ PinholeIntrinsics, PinholeDistortionIndex, PinholeDistortion, - PinholeMetadata, + PinholeCameraMetadata, ) from py123d.datatypes.sensors.fisheye_mei_camera import ( FisheyeMEICameraType, diff --git a/src/py123d/datatypes/sensors/pinhole_camera.py b/src/py123d/datatypes/sensors/pinhole_camera.py index 635b338b..683ada0a 100644 --- a/src/py123d/datatypes/sensors/pinhole_camera.py +++ b/src/py123d/datatypes/sensors/pinhole_camera.py @@ -52,7 +52,7 @@ class PinholeCamera: def __init__( self, - metadata: PinholeMetadata, + metadata: PinholeCameraMetadata, image: npt.NDArray[np.uint8], extrinsic: PoseSE3, ) -> None: @@ -67,8 +67,8 @@ def __init__( self._extrinsic = extrinsic @property - def metadata(self) -> PinholeMetadata: - """The static :class:`PinholeMetadata` associated with the pinhole camera.""" + def metadata(self) -> PinholeCameraMetadata: + """The static :class:`PinholeCameraMetadata` associated with the pinhole camera.""" return self._metadata @property @@ -285,7 +285,7 @@ def k3(self) -> float: return self._array[PinholeDistortionIndex.K3] -class PinholeMetadata: +class PinholeCameraMetadata: """Static metadata for a pinhole camera, stored in a log.""" __slots__ = ("_camera_type", "_intrinsics", "_distortion", "_width", "_height") @@ -298,7 +298,7 @@ def __init__( width: int, height: int, ) -> None: - """Initialize a :class:`PinholeMetadata` instance. + """Initialize a :class:`PinholeCameraMetadata` instance. :param camera_type: The type of the pinhole camera. :param intrinsics: The :class:`PinholeIntrinsics` of the pinhole camera. @@ -313,11 +313,11 @@ def __init__( self._height = height @classmethod - def from_dict(cls, data_dict: Dict[str, Any]) -> PinholeMetadata: - """Create a :class:`PinholeMetadata` from a dictionary. + def from_dict(cls, data_dict: Dict[str, Any]) -> PinholeCameraMetadata: + """Create a :class:`PinholeCameraMetadata` from a dictionary. :param data_dict: A dictionary containing the metadata. - :return: A PinholeMetadata instance. + :return: A PinholeCameraMetadata instance. """ data_dict["camera_type"] = PinholeCameraType(data_dict["camera_type"]) data_dict["intrinsics"] = ( @@ -326,12 +326,12 @@ def from_dict(cls, data_dict: Dict[str, Any]) -> PinholeMetadata: data_dict["distortion"] = ( PinholeDistortion.from_list(data_dict["distortion"]) if data_dict["distortion"] is not None else None ) - return PinholeMetadata(**data_dict) + return PinholeCameraMetadata(**data_dict) def to_dict(self) -> Dict[str, Any]: - """Converts the :class:`PinholeMetadata` to a dictionary. + """Converts the :class:`PinholeCameraMetadata` to a dictionary. - :return: A dictionary representation of the PinholeMetadata instance, with default Python types. + :return: A dictionary representation of the PinholeCameraMetadata instance, with default Python types. """ data_dict = {} data_dict["camera_type"] = int(self.camera_type) diff --git a/tests/unit/datatypes/metadata/__init__.py b/tests/unit/datatypes/metadata/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/datatypes/metadata/test_log_metadata.py b/tests/unit/datatypes/metadata/test_log_metadata.py new file mode 100644 index 00000000..df6e1589 --- /dev/null +++ b/tests/unit/datatypes/metadata/test_log_metadata.py @@ -0,0 +1,153 @@ +import unittest +from unittest.mock import MagicMock, patch + +from py123d.datatypes.metadata.log_metadata import LogMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata +from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters + + +class TestLogMetadata(unittest.TestCase): + + def test_init_minimal(self): + """Test LogMetadata initialization with minimal required fields.""" + log_metadata = LogMetadata( + dataset="test_dataset", split="train", log_name="log_001", location="test_location", timestep_seconds=0.1 + ) + self.assertEqual(log_metadata.dataset, "test_dataset") + self.assertEqual(log_metadata.split, "train") + self.assertEqual(log_metadata.log_name, "log_001") + self.assertEqual(log_metadata.location, "test_location") + self.assertEqual(log_metadata.timestep_seconds, 0.1) + self.assertIsNone(log_metadata.vehicle_parameters) + self.assertIsNone(log_metadata.box_detection_label_class) + self.assertEqual(log_metadata.pinhole_camera_metadata, {}) + self.assertEqual(log_metadata.fisheye_mei_camera_metadata, {}) + self.assertEqual(log_metadata.lidar_metadata, {}) + self.assertIsNone(log_metadata.map_metadata) + + def test_to_dict_minimal(self): + """Test to_dict with minimal fields.""" + log_metadata = LogMetadata( + dataset="test_dataset", split="train", log_name="log_001", location="test_location", timestep_seconds=0.1 + ) + result = log_metadata.to_dict() + self.assertEqual(result["dataset"], "test_dataset") + self.assertEqual(result["split"], "train") + self.assertEqual(result["log_name"], "log_001") + self.assertEqual(result["location"], "test_location") + self.assertEqual(result["timestep_seconds"], 0.1) + self.assertIsNone(result["vehicle_parameters"]) + self.assertIsNone(result["box_detection_label_class"]) + self.assertEqual(result["pinhole_camera_metadata"], {}) + + def test_from_dict_minimal(self): + """Test from_dict with minimal fields.""" + data_dict = { + "dataset": "test_dataset", + "split": "train", + "log_name": "log_001", + "location": "test_location", + "timestep_seconds": 0.1, + "vehicle_parameters": None, + "box_detection_label_class": None, + "map_metadata": None, + "version": "1.0.0", + } + log_metadata = LogMetadata.from_dict(data_dict) + self.assertEqual(log_metadata.dataset, "test_dataset") + self.assertEqual(log_metadata.split, "train") + self.assertIsNone(log_metadata.vehicle_parameters) + + @patch.object(VehicleParameters, "from_dict") + def test_from_dict_with_vehicle_parameters(self, mock_vehicle_params): + """Test from_dict with vehicle parameters.""" + mock_vehicle = MagicMock() + mock_vehicle_params.return_value = mock_vehicle + + data_dict = { + "dataset": "test_dataset", + "split": "train", + "log_name": "log_001", + "location": "test_location", + "timestep_seconds": 0.1, + "vehicle_parameters": {"some": "data"}, + "box_detection_label_class": None, + "map_metadata": None, + "version": "1.0.0", + } + log_metadata = LogMetadata.from_dict(data_dict) + mock_vehicle_params.assert_called_once_with({"some": "data"}) + self.assertEqual(log_metadata.vehicle_parameters, mock_vehicle) + + @patch("py123d.datatypes.metadata.log_metadata.BOX_DETECTION_LABEL_REGISTRY", {"TestLabel": MagicMock}) + def test_from_dict_with_box_detection_label(self): + """Test from_dict with box detection label class.""" + data_dict = { + "dataset": "test_dataset", + "split": "train", + "log_name": "log_001", + "location": "test_location", + "timestep_seconds": 0.1, + "vehicle_parameters": None, + "box_detection_label_class": "TestLabel", + "map_metadata": None, + "version": "1.0.0", + } + log_metadata = LogMetadata.from_dict(data_dict) + self.assertIsNotNone(log_metadata.box_detection_label_class) + + def test_from_dict_with_invalid_box_detection_label(self): + """Test from_dict with invalid box detection label class.""" + data_dict = { + "dataset": "test_dataset", + "split": "train", + "log_name": "log_001", + "location": "test_location", + "timestep_seconds": 0.1, + "vehicle_parameters": None, + "box_detection_label_class": "InvalidLabel", + "map_metadata": None, + "version": "1.0.0", + } + with self.assertRaises(ValueError) as context: + LogMetadata.from_dict(data_dict) + self.assertIn("Unknown box detection label class", str(context.exception)) + + @patch.object(MapMetadata, "from_dict") + def test_from_dict_with_map_metadata(self, mock_map_metadata): + """Test from_dict with map metadata.""" + mock_map = MagicMock() + mock_map_metadata.return_value = mock_map + + data_dict = { + "dataset": "test_dataset", + "split": "train", + "log_name": "log_001", + "location": "test_location", + "timestep_seconds": 0.1, + "vehicle_parameters": None, + "box_detection_label_class": None, + "map_metadata": {"some": "data"}, + "version": "1.0.0", + } + log_metadata = LogMetadata.from_dict(data_dict) + mock_map_metadata.assert_called_once_with({"some": "data"}) + self.assertEqual(log_metadata.map_metadata, mock_map) + + def test_roundtrip_serialization(self): + """Test that to_dict and from_dict are inverses.""" + original = LogMetadata( + dataset="test_dataset", + split="train", + log_name="log_001", + location="test_location", + timestep_seconds=0.1, + ) + data_dict = original.to_dict() + reconstructed = LogMetadata.from_dict(data_dict) + + self.assertEqual(original.dataset, reconstructed.dataset) + self.assertEqual(original.split, reconstructed.split) + self.assertEqual(original.log_name, reconstructed.log_name) + self.assertEqual(original.location, reconstructed.location) + self.assertEqual(original.timestep_seconds, reconstructed.timestep_seconds) diff --git a/tests/unit/datatypes/metadata/test_map_metadata.py b/tests/unit/datatypes/metadata/test_map_metadata.py new file mode 100644 index 00000000..4e0b71d2 --- /dev/null +++ b/tests/unit/datatypes/metadata/test_map_metadata.py @@ -0,0 +1,107 @@ +import unittest + +from py123d.datatypes.metadata.map_metadata import MapMetadata + + +class TestMapMetadata(unittest.TestCase): + + def test_map_metadata_initialization(self): + """Test that MapMetadata can be initialized with required fields.""" + metadata = MapMetadata( + dataset="test_dataset", + split="train", + log_name="log_001", + location="test_location", + map_has_z=True, + map_is_local=False, + ) + + assert metadata.dataset == "test_dataset" + assert metadata.split == "train" + assert metadata.log_name == "log_001" + assert metadata.location == "test_location" + assert metadata.map_has_z is True + assert metadata.map_is_local is False + assert metadata.version is not None + + def test_map_metadata_to_dict(self): + """Test conversion of MapMetadata to dictionary.""" + metadata = MapMetadata( + dataset="test_dataset", + split="val", + log_name="log_002", + location="test_location", + map_has_z=False, + map_is_local=True, + ) + + result = metadata.to_dict() + + assert isinstance(result, dict) + assert result["dataset"] == "test_dataset" + assert result["split"] == "val" + assert result["log_name"] == "log_002" + assert result["location"] == "test_location" + assert result["map_has_z"] is False + assert result["map_is_local"] is True + assert "version" in result + + def test_map_metadata_from_dict(self): + """Test creation of MapMetadata from dictionary.""" + data = { + "dataset": "test_dataset", + "split": "test", + "log_name": "log_003", + "location": "test_location", + "map_has_z": True, + "map_is_local": False, + "version": "1.0.0", + } + + metadata = MapMetadata.from_dict(data) + + assert metadata.dataset == "test_dataset" + assert metadata.split == "test" + assert metadata.log_name == "log_003" + assert metadata.location == "test_location" + assert metadata.map_has_z is True + assert metadata.map_is_local is False + assert metadata.version == "1.0.0" + + def test_map_metadata_with_none_values(self): + """Test MapMetadata with None values for optional fields.""" + metadata = MapMetadata( + dataset="test_dataset", + split=None, + log_name=None, + location="test_location", + map_has_z=True, + map_is_local=True, + ) + + assert metadata.split is None + assert metadata.log_name is None + assert metadata.dataset == "test_dataset" + + def test_map_metadata_roundtrip(self): + """Test that converting to dict and back preserves data.""" + original = MapMetadata( + dataset="roundtrip_dataset", + split="train", + log_name="log_roundtrip", + location="location_test", + map_has_z=False, + map_is_local=True, + version="2.0.0", + ) + + data_dict = original.to_dict() + restored = MapMetadata.from_dict(data_dict) + + assert restored.dataset == original.dataset + assert restored.split == original.split + assert restored.log_name == original.log_name + assert restored.location == original.location + assert restored.map_has_z == original.map_has_z + assert restored.map_is_local == original.map_is_local + assert restored.version == original.version diff --git a/tests/unit/datatypes/sensors/test_pinhole_camera.py b/tests/unit/datatypes/sensors/test_pinhole_camera.py index de1a73ef..3571c051 100644 --- a/tests/unit/datatypes/sensors/test_pinhole_camera.py +++ b/tests/unit/datatypes/sensors/test_pinhole_camera.py @@ -4,11 +4,11 @@ from py123d.datatypes.sensors.pinhole_camera import ( PinholeCamera, + PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeDistortionIndex, PinholeIntrinsics, - PinholeMetadata, ) from py123d.geometry import PoseSE3 @@ -286,7 +286,7 @@ def test_metadata_from_dict_with_none_intrinsics(self): "height": 600, } - metadata = PinholeMetadata.from_dict(data_dict) + metadata = PinholeCameraMetadata.from_dict(data_dict) self.assertEqual(metadata.camera_type, PinholeCameraType.PCAM_B0) self.assertIsNone(metadata.intrinsics) @@ -304,7 +304,7 @@ def test_metadata_from_dict_with_none_distortion(self): "height": 600, } - metadata = PinholeMetadata.from_dict(data_dict) + metadata = PinholeCameraMetadata.from_dict(data_dict) self.assertEqual(metadata.camera_type, PinholeCameraType.PCAM_L0) self.assertIsNotNone(metadata.intrinsics) @@ -315,13 +315,13 @@ def test_metadata_different_aspect_ratios(self): intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) # 16:9 aspect ratio - metadata_16_9 = PinholeMetadata( + metadata_16_9 = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=1920, height=1080 ) self.assertAlmostEqual(metadata_16_9.aspect_ratio, 16 / 9) # 4:3 aspect ratio - metadata_4_3 = PinholeMetadata( + metadata_4_3 = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 ) self.assertAlmostEqual(metadata_4_3.aspect_ratio, 4 / 3) @@ -331,10 +331,10 @@ def test_metadata_fov_with_different_focal_lengths(self): intrinsics_narrow = PinholeIntrinsics(fx=1000.0, fy=1000.0, cx=320.0, cy=240.0) intrinsics_wide = PinholeIntrinsics(fx=250.0, fy=250.0, cx=320.0, cy=240.0) - metadata_narrow = PinholeMetadata( + metadata_narrow = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics_narrow, distortion=None, width=640, height=480 ) - metadata_wide = PinholeMetadata( + metadata_wide = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics_wide, distortion=None, width=640, height=480 ) @@ -347,7 +347,7 @@ def test_metadata_to_dict_preserves_types(self): intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_R1, intrinsics=intrinsics, distortion=distortion, width=1280, height=720 ) @@ -364,7 +364,7 @@ def test_metadata_all_camera_types(self): intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) for camera_type in PinholeCameraType: - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=camera_type, intrinsics=intrinsics, distortion=None, width=640, height=480 ) self.assertEqual(metadata.camera_type, camera_type) @@ -372,7 +372,7 @@ def test_metadata_all_camera_types(self): def test_metadata_square_image(self): """Test metadata with square image dimensions.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=256.0, cy=256.0) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=512, height=512 ) @@ -382,7 +382,7 @@ def test_metadata_square_image(self): def test_metadata_non_square_pixels(self): """Test metadata with non-square pixels (different fx and fy).""" intrinsics = PinholeIntrinsics(fx=500.0, fy=600.0, cx=320.0, cy=240.0) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 ) @@ -400,7 +400,7 @@ def test_pinhole_camera_creation(self): """Test creating PinholeCamera instance.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 ) image = np.zeros((480, 640, 3), dtype=np.uint8) @@ -416,7 +416,7 @@ def test_pinhole_camera_with_color_image(self): """Test PinholeCamera with color image.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 ) image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8) @@ -431,7 +431,7 @@ def test_pinhole_camera_with_grayscale_image(self): """Test PinholeCamera with grayscale image.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_L0, intrinsics=intrinsics, distortion=None, width=640, height=480 ) image = np.random.randint(0, 255, (480, 640), dtype=np.uint8) @@ -446,7 +446,7 @@ def test_pinhole_camera_with_distortion(self): intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=distortion, @@ -474,7 +474,7 @@ def test_pinhole_camera_different_types(self): PinholeCameraType.PCAM_STEREO_L, PinholeCameraType.PCAM_STEREO_R, ]: - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=camera_type, intrinsics=intrinsics, distortion=None, width=640, height=480 ) camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) @@ -488,7 +488,7 @@ def test_pinhole_camera_with_different_resolutions(self): for width, height in resolutions: intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=width / 2, cy=height / 2) - metadata = PinholeMetadata( + metadata = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, From 6cf877b030826907155a6c12b78641e76f3b04a6 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 10 Nov 2025 20:04:29 +0100 Subject: [PATCH 19/50] Add docstrings for map and scene api. --- src/py123d/api/map/gpkg/gpkg_map_api.py | 52 ++++-- src/py123d/api/map/gpkg/gpkg_utils.py | 8 +- src/py123d/api/map/map_api.py | 174 +++++++++++++++--- src/py123d/api/scene/arrow/arrow_scene.py | 2 +- src/py123d/api/scene/scene_api.py | 157 ++++++++++++---- src/py123d/visualization/color/color.py | 12 ++ src/py123d/visualization/color/default.py | 18 -- src/py123d/visualization/matplotlib/plots.py | 2 +- .../viser/elements/map_elements.py | 2 +- .../visualization/viser/viser_viewer.py | 4 +- .../datatypes/map_objects/mock_map_api.py | 8 +- 11 files changed, 322 insertions(+), 117 deletions(-) diff --git a/src/py123d/api/map/gpkg/gpkg_map_api.py b/src/py123d/api/map/gpkg/gpkg_map_api.py index e7b27a9f..adb7ba01 100644 --- a/src/py123d/api/map/gpkg/gpkg_map_api.py +++ b/src/py123d/api/map/gpkg/gpkg_map_api.py @@ -27,18 +27,35 @@ Walkway, ) from py123d.datatypes.metadata.map_metadata import MapMetadata -from py123d.geometry import Point2D -from py123d.geometry.polyline import Polyline3D +from py123d.geometry import Point2D, Point3D, Polyline3D from py123d.script.utils.dataset_path_utils import get_dataset_paths +# TODO: add to some configs USE_ARROW: bool = True -MAX_LRU_CACHED_TABLES: Final[int] = 128 # TODO: add to some configs +MAX_LRU_CACHED_TABLES: Final[int] = 128 class GPKGMapAPI(MapAPI): - def __init__(self, file_path: Path) -> None: + """The GeoPackage (GPKG) implementation of the :class:`~py123d.api.MapAPI`. - self._file_path = file_path + Notes + ----- + The current implementation is inspired by the nuPlan GPKG map API [1]_. Ideally, the 123D implementation could + be replaced in the future by a more lightweight solution based on GeoParquet or GeoArrow. + + References + ---------- + .. [1] https://github.com/motional/nuplan-devkit/blob/master/nuplan/common/maps/nuplan_map/nuplan_map.py + + """ + + def __init__(self, file_path: Union[Path, str]) -> None: + """Initialize a GPKGMapAPI instance. + + :param file_path: The file path to the GeoPackage. + """ + + self._file_path = Path(file_path) self._map_object_getter: Dict[MapLayer, Callable[[str], Optional[BaseMapObject]]] = { MapLayer.LANE: self._get_lane, MapLayer.LANE_GROUP: self._get_lane_group, @@ -56,7 +73,7 @@ def __init__(self, file_path: Path) -> None: self._map_metadata: Optional[MapMetadata] = None def _initialize(self) -> None: - """Inherited, see superclass.""" + """Loads all available map layers and metadata from the GPKG file into GeoDataFrames.""" if len(self._gpd_dataframes) == 0: available_layers = list(gpd.list_layers(self._file_path).name) for map_layer in list(MapLayer): @@ -81,11 +98,11 @@ def _initialize(self) -> None: self._map_metadata = MapMetadata.from_dict(metadata_gdf.iloc[0].to_dict()) def _assert_initialize(self) -> None: - "Checks if `.initialize()` was called, before retrieving data." + """Checks if `.initialize()` was called, before retrieving data.""" assert len(self._gpd_dataframes) > 0, "GPKGMap: Call `.initialize()` before retrieving data!" def _assert_layer_available(self, layer: MapLayer) -> None: - "Checks if layer is available." + """Checks if layer is available.""" assert layer in self.get_available_map_layers(), f"GPKGMap: MapLayer {layer.name} is unavailable." def get_map_metadata(self): @@ -107,19 +124,14 @@ def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[BaseMapObj except KeyError: raise ValueError(f"Object representation for layer: {layer.name} object: {object_id} is unavailable") - def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[BaseMapObject]: - """Inherited, see superclass.""" - raise NotImplementedError - - def is_in_layer(self, point: Point2D, layer: MapLayer) -> bool: - """Inherited, see superclass.""" - raise NotImplementedError - - def get_proximal_map_objects( - self, point_2d: Point2D, radius: float, layers: List[MapLayer] + def get_map_objects_in_radius( + self, + point: Union[Point2D, Point3D], + radius: float, + layers: List[MapLayer], ) -> Dict[MapLayer, List[BaseMapObject]]: """Inherited, see superclass.""" - center_point = geom.Point(point_2d.x, point_2d.y) + center_point = point.shapely_point patch = center_point.buffer(radius) return self.query(geometry=patch, layers=layers, predicate="intersects") @@ -474,6 +486,7 @@ def _get_road_line(self, id: str) -> Optional[RoadLine]: @lru_cache(maxsize=MAX_LRU_CACHED_TABLES) def get_global_map_api(dataset: str, location: str) -> GPKGMapAPI: + """Get the global map API for a given dataset and location.""" PY123D_MAPS_ROOT: Path = Path(get_dataset_paths().py123d_maps_root) gpkg_path = PY123D_MAPS_ROOT / dataset / f"{dataset}_{location}.gpkg" assert gpkg_path.is_file(), f"{dataset}_{location}.gpkg not found in {str(PY123D_MAPS_ROOT)}." @@ -483,6 +496,7 @@ def get_global_map_api(dataset: str, location: str) -> GPKGMapAPI: def get_local_map_api(split_name: str, log_name: str) -> GPKGMapAPI: + """Get the local map API for a given split name and log name.""" PY123D_MAPS_ROOT: Path = Path(get_dataset_paths().py123d_maps_root) gpkg_path = PY123D_MAPS_ROOT / split_name / f"{log_name}.gpkg" assert gpkg_path.is_file(), f"{log_name}.gpkg not found in {str(PY123D_MAPS_ROOT)}." diff --git a/src/py123d/api/map/gpkg/gpkg_utils.py b/src/py123d/api/map/gpkg/gpkg_utils.py index e7d81fa3..b3b95eec 100644 --- a/src/py123d/api/map/gpkg/gpkg_utils.py +++ b/src/py123d/api/map/gpkg/gpkg_utils.py @@ -20,8 +20,8 @@ def load_gdf_with_geometry_columns(gdf: gpd.GeoDataFrame, geometry_column_names: def get_all_rows_with_value( elements: gpd.geodataframe.GeoDataFrame, column_label: str, desired_value: str ) -> Optional[gpd.geodataframe.GeoDataFrame]: - """ - Extract all matching elements. Note, if no matching desired_key is found and empty list is returned. + """Extract all matching elements. Note, if no matching desired_key is found and empty list is returned. + :param elements: data frame from MapsDb. :param column_label: key to extract from a column. :param desired_value: key which is compared with the values of column_label entry. @@ -36,8 +36,8 @@ def get_all_rows_with_value( def get_row_with_value( elements: gpd.geodataframe.GeoDataFrame, column_label: str, desired_value: str ) -> Optional[gpd.GeoSeries]: - """ - Extract a matching element. + """Extract a matching element. + :param elements: data frame from MapsDb. :param column_label: key to extract from a column. :param desired_value: key which is compared with the values of column_label entry. diff --git a/src/py123d/api/map/map_api.py b/src/py123d/api/map/map_api.py index 2bb274f0..c26a40ea 100644 --- a/src/py123d/api/map/map_api.py +++ b/src/py123d/api/map/map_api.py @@ -1,60 +1,106 @@ from __future__ import annotations import abc -from typing import Dict, Iterable, List, Optional, Union +from typing import Dict, Iterable, List, Literal, Optional, Union import shapely -from py123d.datatypes.map_objects.base_map_objects import BaseMapObject -from py123d.datatypes.map_objects.map_layer_types import MapLayer -from py123d.datatypes.metadata.map_metadata import MapMetadata +from py123d.datatypes.map_objects import BaseMapObject, MapLayer +from py123d.datatypes.metadata import MapMetadata from py123d.geometry import Point2D - -# TODO: -# - add docstrings -# - rename methods? -# - Combine query and query_object_ids into one method with an additional parameter to specify whether to return objects or IDs? -# - Add stop pads or stop lines. +from py123d.geometry.point import Point3D class MapAPI(abc.ABC): + """The base class for all map APIs in 123D.""" + + # Abstract Methods, to be implemented by subclasses + # ------------------------------------------------------------------------------------------------------------------ @abc.abstractmethod def get_map_metadata(self) -> MapMetadata: - pass + """Returns the :class:`~p123d.datatypes.metadata.MapMetadata` of the map api. + + :return: The map metadata, e.g. location, dataset, etc. + """ @abc.abstractmethod def get_available_map_layers(self) -> List[MapLayer]: - pass + """Returns the available :class:`~p123d.datatypes.map_objects.map_layer_types.MapLayer`, + e.g. LANE, LANE_GROUP, etc. + + :return: A list of available map layers. + """ @abc.abstractmethod def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[BaseMapObject]: - pass + """Returns a :class:`~p123d.datatypes.map_objects.base_map_object.BaseMapObject` by its ID + and :class:`~p123d.datatypes.map_objects.map_layer_types.MapLayer`. - @abc.abstractmethod - def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[BaseMapObject]: - pass + :param object_id: The ID of the map object. + :param layer: The layer the map object belongs to. + :return: The map object if found, None otherwise. + """ @abc.abstractmethod - def is_in_layer(self, point: Point2D, layer: MapLayer) -> bool: - pass - - @abc.abstractmethod - def get_proximal_map_objects( - self, point: Point2D, radius: float, layers: List[MapLayer] + def get_map_objects_in_radius( + self, + point: Union[Point2D, Point3D], + radius: float, + layers: List[MapLayer], ) -> Dict[MapLayer, List[BaseMapObject]]: - pass + """Returns a dictionary of :class:`~p123d.datatypes.map_objects.map_layer_types.MapLayer` to a list of + :class:`~p123d.datatypes.map_objects.base_map_object.BaseMapObject` within a given radius + around a center point. + + :param point: The center point to search around. + :param radius: The radius to search within. + :param layers: The map layers to search in. + :return: A dictionary mapping each layer to a list of map objects within the radius. + """ @abc.abstractmethod def query( self, geometry: Union[shapely.Geometry, Iterable[shapely.Geometry]], layers: List[MapLayer], - predicate: Optional[str] = None, + predicate: Optional[ + Literal[ + "contains", + "contains_properly", + "covered_by", + "covers", + "crosses", + "intersects", + "overlaps", + "touches", + "within", + "dwithin", + ] + ] = None, sort: bool = False, distance: Optional[float] = None, ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: - pass + """Queries geometries against the map objects in the specified layers using an optional spatial predicate. + + Notes + ----- + The implementation is aligned with the geopandas spatial index query method [1]_, + used in the :class:`GPKGMapAPI`. It is likely this method will be removed or improved in + future versions of 123D. + + References + ---------- + [1] https://geopandas.org/en/stable/docs/reference/api/geopandas.sindex.SpatialIndex.query.html + + :param geometry: A shapely geometry or an iterable of shapely geometries to query against. + :param layers: The map layers to query against. + :param predicate: An optional spatial predicate to filter the results. + :param sort: Whether to sort the results by distance, defaults to False. + :param distance: An optional maximum distance to filter the results, defaults to None. + :return: A dictionary mapping each layer to a list of map objects or a dictionary of indices to + lists of map objects. + """ @abc.abstractmethod def query_object_ids( @@ -64,8 +110,29 @@ def query_object_ids( predicate: Optional[str] = None, sort: bool = False, distance: Optional[float] = None, - ) -> Dict[MapLayer, Union[List[str], Dict[int, List[str]]]]: - pass + ) -> Dict[MapLayer, Union[List[int], Dict[int, List[int]]]]: + """Queries geometries against the map objects in the specified layers using an optional spatial predicate. + Instead of returning the map objects, it returns their IDs only. + + Notes + ----- + The implementation is aligned with the geopandas spatial index query method [1]_, + used in the :class:`GPKGMapAPI`. It is likely this method will be removed or improved in + future versions of 123D. + + References + ---------- + [1] https://geopandas.org/en/stable/docs/reference/api/geopandas.sindex.SpatialIndex.query.html + + + :param geometry: A shapely geometry or an iterable of shapely geometries to query against. + :param layers: The map layers to query against. + :param predicate: An optional spatial predicate to filter the results. + :param sort: Whether to sort the results by distance, defaults to False. + :param distance: An optional maximum distance to filter the results, defaults to None. + :return: A dictionary mapping each layer to a list of map object IDs or a dictionary of indices to + lists of map object IDs. + """ @abc.abstractmethod def query_nearest( @@ -77,8 +144,57 @@ def query_nearest( return_distance: bool = False, exclusive: bool = False, ) -> Dict[MapLayer, Union[List[BaseMapObject], Dict[int, List[BaseMapObject]]]]: - pass + """Return the nearest map objects in a spatial tree for each input geometry in + + Notes + ----- + The implementation is aligned with the geopandas spatial index nearest method [1]_, + used in the :class:`GPKGMapAPI`. It is likely this method will be removed or improved in + future versions of 123D. + + References + ---------- + [1] https://geopandas.org/en/stable/docs/reference/api/geopandas.sindex.SpatialIndex.nearest.html + + :param geometry: A shapely geometry or an iterable of shapely geometries to query against. + :param layers: The map layers to query against. + :param return_all: Whether to return all matching objects or just the closest one, defaults to True. + :param max_distance: An optional maximum distance to filter the results, defaults to None. + :param return_distance: Whether to return the distance to the nearest object, defaults to False. + :param exclusive: Whether to exclude the input geometries from the results, defaults to False. + :return: A dictionary mapping each layer to a list of nearest map objects or a dictionary of indices to + lists of nearest map objects. + """ + + # Syntactic Sugar / Properties, for easier access to common attributes + # ------------------------------------------------------------------------------------------------------------------ + + @property + def map_metadata(self) -> MapMetadata: + """The :class:`~py123d.datatypes.metadata.MapMetadata` of the map api.""" + return self.get_map_metadata() + + @property + def dataset(self) -> str: + """The dataset name from the map metadata.""" + return self.map_metadata.dataset @property def location(self) -> str: - return self.get_map_metadata().location + """The location from the map metadata.""" + return self.map_metadata.location + + @property + def map_is_local(self) -> bool: + """Indicates if the map is local (map for each log) or global (map for multiple logs in dataset).""" + return self.map_metadata.map_is_local + + @property + def map_has_z(self) -> bool: + """Indicates if the map includes Z (elevation) data.""" + return self.map_metadata.map_has_z + + @property + def version(self) -> str: + """The version of the py123d library used to create this map metadata.""" + return self.map_metadata.version diff --git a/src/py123d/api/scene/arrow/arrow_scene.py b/src/py123d/api/scene/arrow/arrow_scene.py index 7ea2af01..993545de 100644 --- a/src/py123d/api/scene/arrow/arrow_scene.py +++ b/src/py123d/api/scene/arrow/arrow_scene.py @@ -89,7 +89,7 @@ def _get_table_index(self, iteration: int) -> int: def get_log_metadata(self) -> LogMetadata: return self._log_metadata - def get_scene_extraction_metadata(self) -> SceneMetadata: + def get_scene_metadata(self) -> SceneMetadata: return self._scene_extraction_metadata def get_map_api(self) -> Optional[MapAPI]: diff --git a/src/py123d/api/scene/scene_api.py b/src/py123d/api/scene/scene_api.py index 9029b5d3..5949449a 100644 --- a/src/py123d/api/scene/scene_api.py +++ b/src/py123d/api/scene/scene_api.py @@ -8,6 +8,7 @@ from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper from py123d.datatypes.metadata.log_metadata import LogMetadata +from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICamera, FisheyeMEICameraType from py123d.datatypes.sensors.lidar import LiDAR, LiDARType from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType @@ -18,100 +19,186 @@ class SceneAPI(abc.ABC): - #################################################################################################################### # Abstract Methods, to be implemented by subclasses - #################################################################################################################### + # ------------------------------------------------------------------------------------------------------------------ @abc.abstractmethod def get_log_metadata(self) -> LogMetadata: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.metadata.LogMetadata` of the scene. + + :return: The log metadata. + """ @abc.abstractmethod - def get_scene_extraction_metadata(self) -> SceneMetadata: - raise NotImplementedError + def get_scene_metadata(self) -> SceneMetadata: + """Returns the :class:`~py123d.api.scene.scene_metadata.SceneMetadata` of the scene. + + :return: The scene metadata. + """ @abc.abstractmethod def get_map_api(self) -> Optional[MapAPI]: - raise NotImplementedError + """Returns the :class:`~py123d.api.MapAPI` of the scene, if available. + + :return: The map API, or None if not available. + """ @abc.abstractmethod def get_timepoint_at_iteration(self, iteration: int) -> TimePoint: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.time.TimePoint` at a given iteration. + + :param iteration: The iteration to get the timepoint for. + :return: The timepoint at the given iteration. + """ @abc.abstractmethod def get_ego_state_at_iteration(self, iteration: int) -> Optional[EgoStateSE3]: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.vehicle_state.EgoStateSE3` at a given iteration, if available. + + :param iteration: The iteration to get the ego state for. + :return: The ego state at the given iteration, or None if not available. + """ @abc.abstractmethod def get_box_detections_at_iteration(self, iteration: int) -> Optional[BoxDetectionWrapper]: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.detections.BoxDetectionWrapper` at a given iteration, if available. + + :param iteration: The iteration to get the box detections for. + :return: The box detections at the given iteration, or None if not available. + """ @abc.abstractmethod def get_traffic_light_detections_at_iteration(self, iteration: int) -> Optional[TrafficLightDetectionWrapper]: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.detections.TrafficLightDetectionWrapper` at a given iteration, + if available. + + :param iteration: The iteration to get the traffic light detections for. + :return: The traffic light detections at the given iteration, or None if not available. + """ @abc.abstractmethod def get_route_lane_group_ids(self, iteration: int) -> Optional[List[int]]: - raise NotImplementedError + """Returns the list of route lane group IDs at a given iteration, if available. + + :param iteration: The iteration to get the route lane group IDs for. + :return: The list of route lane group IDs at the given iteration, or None if not available. + """ @abc.abstractmethod def get_pinhole_camera_at_iteration( - self, iteration: int, camera_type: PinholeCameraType + self, + iteration: int, + camera_type: PinholeCameraType, ) -> Optional[PinholeCamera]: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.sensors.PinholeCamera` of a given \ + :class:`~py123d.datatypes.sensors.PinholeCameraType` at a given iteration, if available. + + :param iteration: The iteration to get the pinhole camera for. + :param camera_type: The :type:`~py123d.datatypes.sensors.PinholeCameraType` of the pinhole camera. + :return: The pinhole camera, or None if not available. + """ @abc.abstractmethod def get_fisheye_mei_camera_at_iteration( self, iteration: int, camera_type: FisheyeMEICameraType ) -> Optional[FisheyeMEICamera]: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.sensors.FisheyeMEICamera` of a given \ + :class:`~py123d.datatypes.sensors.FisheyeMEICameraType` at a given iteration, if available. + + :param iteration: The iteration to get the fisheye MEI camera for. + :param camera_type: The :type:`~py123d.datatypes.sensors.FisheyeMEICameraType` of the fisheye MEI camera. + :return: The fisheye MEI camera, or None if not available. + """ @abc.abstractmethod def get_lidar_at_iteration(self, iteration: int, lidar_type: LiDARType) -> Optional[LiDAR]: - raise NotImplementedError + """Returns the :class:`~py123d.datatypes.sensors.LiDAR` of a given :class:`~py123d.datatypes.sensors.LiDARType`\ + at a given iteration, if available. + + :param iteration: The iteration to get the LiDAR for. + :param lidar_type: The :type:`~py123d.datatypes.sensors.LiDARType` of the LiDAR. + :return: The LiDAR, or None if not available. + """ - #################################################################################################################### # Syntactic Sugar / Properties, for easier access to common attributes - #################################################################################################################### + # ------------------------------------------------------------------------------------------------------------------ - # 1. Log Metadata properties @property def log_metadata(self) -> LogMetadata: + """The :class:`~py123d.datatypes.metadata.LogMetadata` of the scene.""" return self.get_log_metadata() @property - def log_name(self) -> str: - return self.log_metadata.log_name + def scene_metadata(self) -> SceneMetadata: + """The :class:`~py123d.api.scene.SceneMetadata` of the scene.""" + return self.get_scene_metadata() @property - def vehicle_parameters(self) -> VehicleParameters: - return self.log_metadata.vehicle_parameters + def map_metadata(self) -> Optional[MapMetadata]: + """The :class:`~py123d.datatypes.metadata.MapMetadata` of the scene, if available.""" + return self.log_metadata.map_metadata @property - def available_pinhole_camera_types(self) -> List[PinholeCameraType]: - return list(self.log_metadata.pinhole_camera_metadata.keys()) + def map_api(self) -> Optional[MapAPI]: + """The :class:`~py123d.api.map.MapAPI` of the scene, if available.""" + return self.get_map_api() @property - def available_fisheye_mei_camera_types(self) -> List[FisheyeMEICameraType]: - return list(self.log_metadata.fisheye_mei_camera_metadata.keys()) + def dataset(self) -> str: + """The dataset name from the log metadata.""" + return self.log_metadata.dataset @property - def available_lidar_types(self) -> List[LiDARType]: - return list(self.log_metadata.lidar_metadata.keys()) + def split(self) -> str: + """The data split name from the log metadata.""" + return self.log_metadata.split - # 2. Scene Extraction Metadata properties @property - def scene_extraction_metadata(self) -> SceneMetadata: - return self.get_scene_extraction_metadata() + def location(self) -> str: + """The location from the log metadata.""" + return self.log_metadata.location @property - def uuid(self) -> str: - return self.scene_extraction_metadata.initial_uuid + def log_name(self) -> str: + """The log name from the log metadata.""" + return self.log_metadata.log_name + + @property + def version(self) -> str: + """The version of the py123d library used to create this log metadata.""" + return self.log_metadata.version + + @property + def scene_uuid(self) -> str: + """The UUID of the scene.""" + return self.scene_metadata.initial_uuid @property def number_of_iterations(self) -> int: - return self.scene_extraction_metadata.number_of_iterations + """The number of iterations in the scene.""" + return self.scene_metadata.number_of_iterations @property def number_of_history_iterations(self) -> int: - return self.scene_extraction_metadata.number_of_history_iterations + """The number of history iterations in the scene.""" + return self.scene_metadata.number_of_history_iterations + + @property + def vehicle_parameters(self) -> Optional[VehicleParameters]: + """The :class:`~py123d.datatypes.vehicle_state.VehicleParameters` of the ego vehicle, if available.""" + return self.log_metadata.vehicle_parameters + + @property + def available_pinhole_camera_types(self) -> List[PinholeCameraType]: + """List of available :class:`~py123d.datatypes.sensors.PinholeCameraType` in the log metadata.""" + return list(self.log_metadata.pinhole_camera_metadata.keys()) + + @property + def available_fisheye_mei_camera_types(self) -> List[FisheyeMEICameraType]: + """List of available :class:`~py123d.datatypes.sensors.FisheyeMEICameraType` in the log metadata.""" + return list(self.log_metadata.fisheye_mei_camera_metadata.keys()) + + @property + def available_lidar_types(self) -> List[LiDARType]: + """List of available :class:`~py123d.datatypes.sensors.LiDARType` in the log metadata.""" + return list(self.log_metadata.lidar_metadata.keys()) diff --git a/src/py123d/visualization/color/color.py b/src/py123d/visualization/color/color.py index 6cbc4967..23db6327 100644 --- a/src/py123d/visualization/color/color.py +++ b/src/py123d/visualization/color/color.py @@ -8,31 +8,38 @@ @dataclass(frozen=True) class Color: + """Class representing a color in hexadecimal format.""" hex: str @classmethod def from_rgb(cls, rgb: Tuple[int, int, int]) -> Color: + """Create a Color instance from an RGB tuple.""" r, g, b = rgb return cls(f"#{r:02x}{g:02x}{b:02x}") @property def rgb(self) -> Tuple[int, int, int]: + """The RGB representation of the color.""" return ImageColor.getcolor(self.hex, "RGB") @property def rgba(self) -> Tuple[int, int, int]: + """The RGBA representation of the color.""" return ImageColor.getcolor(self.hex, "RGBA") @property def rgb_norm(self) -> Tuple[float, float, float]: + """The normalized RGB representation of the color.""" return tuple([c / 255 for c in self.rgb]) @property def rgba_norm(self) -> Tuple[float, float, float]: + """The normalized RGBA representation of the color.""" return tuple([c / 255 for c in self.rgba]) def set_brightness(self, factor: float) -> Color: + """Return a new Color with adjusted brightness.""" r, g, b = self.rgb return Color.from_rgb( ( @@ -43,8 +50,13 @@ def set_brightness(self, factor: float) -> Color: ) def __str__(self) -> str: + """Return the string representation of the color.""" return self.hex + def __repr__(self) -> str: + """Return the official string representation of the color.""" + return f"Color(hex='{self.hex}')" + BLACK: Color = Color("#000000") WHITE: Color = Color("#FFFFFF") diff --git a/src/py123d/visualization/color/default.py b/src/py123d/visualization/color/default.py index 31d4a072..26b902b5 100644 --- a/src/py123d/visualization/color/default.py +++ b/src/py123d/visualization/color/default.py @@ -82,24 +82,6 @@ zorder=1, ), } -# # Vehicles -# EGO = 0 -# VEHICLE = 1 -# TRAIN = 2 - -# # Vulnerable Road Users -# BICYCLE = 3 -# PERSON = 4 -# ANIMAL = 5 - -# # Traffic Control -# TRAFFIC_SIGN = 6 -# TRAFFIC_CONE = 7 -# TRAFFIC_LIGHT = 8 - -# # Other Obstacles -# BARRIER = 9 -# GENERIC_OBJECT = 10 BOX_DETECTION_CONFIG: Dict[DefaultBoxDetectionLabel, PlotConfig] = { diff --git a/src/py123d/visualization/matplotlib/plots.py b/src/py123d/visualization/matplotlib/plots.py index 60785b4a..4419082b 100644 --- a/src/py123d/visualization/matplotlib/plots.py +++ b/src/py123d/visualization/matplotlib/plots.py @@ -76,5 +76,5 @@ def update(i): pbar = tqdm(total=len(frames), desc=f"Rendering {scene.log_name} as {format}") ani = animation.FuncAnimation(fig, update, frames=frames, repeat=False) - ani.save(output_path / f"{scene.log_name}_{scene.uuid}.{format}", writer="ffmpeg", fps=fps, dpi=dpi) + ani.save(output_path / f"{scene.log_name}_{scene.scene_uuid}.{format}", writer="ffmpeg", fps=fps, dpi=dpi) plt.close(fig) diff --git a/src/py123d/visualization/viser/elements/map_elements.py b/src/py123d/visualization/viser/elements/map_elements.py index 297dafa3..88a76182 100644 --- a/src/py123d/visualization/viser/elements/map_elements.py +++ b/src/py123d/visualization/viser/elements/map_elements.py @@ -91,7 +91,7 @@ def _get_map_trimesh_dict( ] map_api = scene.get_map_api() if map_api is not None: - map_objects_dict = map_api.get_proximal_map_objects( + map_objects_dict = map_api.get_map_objects_in_radius( scene_query_position.point_2d, radius=viser_config.map_radius, layers=map_layers, diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index 75722f5f..f097dc6e 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -316,7 +316,7 @@ def _(event: viser.GuiEvent) -> None: elif format == "mp4": iio.imwrite(buffer, images, extension=".mp4", fps=20) content = buffer.getvalue() - scene_name = f"{scene.log_metadata.split}_{scene.uuid}" + scene_name = f"{scene.log_metadata.split}_{scene.scene_uuid}" client.send_file_download(f"{scene_name}.{format}", content, save_immediately=True) server_rendering = False @@ -396,6 +396,6 @@ def _get_scene_info_markdown(scene: SceneAPI) -> str: markdown = f""" - Dataset: {scene.log_metadata.split} - Location: {scene.log_metadata.location if scene.log_metadata.location else 'N/A'} - - UUID: {scene.uuid} + - UUID: {scene.scene_uuid} """ return markdown diff --git a/tests/unit/datatypes/map_objects/mock_map_api.py b/tests/unit/datatypes/map_objects/mock_map_api.py index 9aae9fb7..a5cdf27f 100644 --- a/tests/unit/datatypes/map_objects/mock_map_api.py +++ b/tests/unit/datatypes/map_objects/mock_map_api.py @@ -83,13 +83,7 @@ def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[BaseMapObj break return map_object - def get_all_map_objects(self, point_2d: Point2D, layer: MapLayer) -> List[BaseMapObject]: - return [] - - def is_in_layer(self, point: Point2D, layer: MapLayer) -> bool: - return False - - def get_proximal_map_objects( + def get_map_objects_in_radius( self, point: Point2D, radius: float, layers: List[MapLayer] ) -> Dict[MapLayer, List[BaseMapObject]]: return {} From 6438a1f4a4e64c2fc9df8e5c23e37c06b7a3554a Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Tue, 11 Nov 2025 22:02:25 +0100 Subject: [PATCH 20/50] Fixing a bug in the scene querying. Should be faster now. --- examples/01_viser.py | 5 +- notebooks/01_scene_tutorial.ipynb | 165 ++++++++++++++++++ src/py123d/api/scene/arrow/arrow_scene.py | 39 +++-- .../api/scene/arrow/arrow_scene_builder.py | 29 +-- .../scene/arrow/utils/arrow_metadata_utils.py | 11 +- src/py123d/common/utils/arrow_helper.py | 2 +- .../datasets/av2/av2_map_conversion.py | 4 +- .../datasets/nuplan/nuplan_map_conversion.py | 18 +- .../conversion/log_writer/arrow_log_writer.py | 2 +- .../datasets/av2_sensor_dataset.yaml | 4 +- .../conversion/datasets/kitti360_dataset.yaml | 2 +- .../conversion/datasets/nuplan_dataset.yaml | 4 +- .../datasets/nuplan_mini_dataset.yaml | 4 +- .../conversion/datasets/nuscenes_dataset.yaml | 4 +- .../datasets/nuscenes_mini_dataset.yaml | 4 +- 15 files changed, 234 insertions(+), 63 deletions(-) create mode 100644 notebooks/01_scene_tutorial.ipynb diff --git a/examples/01_viser.py b/examples/01_viser.py index 5394a828..17ce1fd5 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -16,6 +16,7 @@ # log_names = ["2013_05_28_drive_0000_sync"] # log_names = ["2013_05_28_drive_0000_sync"] log_names = None + splits = None # scene_uuids = ["87bf69e4-f2fb-5491-99fa-8b7e89fb697c"] scene_uuids = None @@ -23,9 +24,9 @@ split_names=splits, log_names=log_names, scene_uuids=scene_uuids, - duration_s=None, + duration_s=5.0, history_s=0.0, - timestamp_threshold_s=None, + timestamp_threshold_s=5.0, shuffle=True, # pinhole_camera_types=[PinholeCameraType.PCAM_F0], ) diff --git a/notebooks/01_scene_tutorial.ipynb b/notebooks/01_scene_tutorial.ipynb new file mode 100644 index 00000000..c199b5a9 --- /dev/null +++ b/notebooks/01_scene_tutorial.ipynb @@ -0,0 +1,165 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "

\n", + " \n", + " \n", + " \n", + " \"Logo\"\n", + " \n", + "

123D: One Library for 2D and 3D Driving Datasets

\n", + "

" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## 1. Download Demo Logs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## 2. Create Scenes by filtering the datasets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.api.scene.scene_filter import SceneFilter\n", + "\n", + "\n", + "# from py123d.common.multithreading.worker_ray import RayDistributed\n", + "# from py123d.common.multithreading.worker_ray import RayDistributed\n", + "from py123d.common.multithreading.worker_sequential import Sequential\n", + "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType\n", + "# from py123d.datatypes.sensors.pinhole_camera_type import PinholeCameraType\n", + "\n", + "# splits = [\"kitti360_train\"]\n", + "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", + "splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", + "# splits = [\"carla_test\"]\n", + "# splits = [\"wopd_val\"]\n", + "# splits = [\"av2-sensor_train\"]\n", + "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", + "\n", + "splits = None\n", + "log_names = None\n", + "scene_uuids = None\n", + "\n", + "scene_filter = SceneFilter(\n", + " split_names=splits,\n", + " log_names=log_names,\n", + " scene_uuids=scene_uuids,\n", + " duration_s=5.0,\n", + " history_s=0.0,\n", + " timestamp_threshold_s=5.0,\n", + " shuffle=True,\n", + " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", + ")\n", + "scene_builder = ArrowSceneBuilder()\n", + "worker = Sequential()\n", + "\n", + "# worker = RayDistributed()\n", + "scenes = scene_builder.get_scenes(scene_filter, worker)\n", + "print(f\"Found {len(scenes)} scenes\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "# 3. Inspecting the Scene" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "scene = scenes[0]\n", + "\n", + "log_metadata = scene.get_log_metadata()\n", + "\n", + "print(log_metadata.to_dict())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "5.038450e-02" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.visualization.viser.viser_viewer import ViserViewer\n", + "\n", + "\n", + "visualization_server = ViserViewer(scenes, scene_index=0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "py123d", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/py123d/api/scene/arrow/arrow_scene.py b/src/py123d/api/scene/arrow/arrow_scene.py index 993545de..c1cf80c5 100644 --- a/src/py123d/api/scene/arrow/arrow_scene.py +++ b/src/py123d/api/scene/arrow/arrow_scene.py @@ -14,9 +14,10 @@ get_timepoint_from_arrow_table, get_traffic_light_detections_from_arrow_table, ) -from py123d.api.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow +from py123d.api.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow_file from py123d.api.scene.scene_api import SceneAPI from py123d.api.scene.scene_metadata import SceneMetadata +from py123d.common.utils.arrow_column_names import UUID_COLUMN from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper @@ -28,6 +29,19 @@ from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 +def _get_complete_log_scene_metadata(arrow_file_path: Union[Path, str], log_metadata: LogMetadata) -> SceneMetadata: + table = get_lru_cached_arrow_table(arrow_file_path) + initial_uuid = table[UUID_COLUMN][0].as_py() + num_rows = table.num_rows + return SceneMetadata( + initial_uuid=initial_uuid, + initial_idx=0, + duration_s=log_metadata.timestep_seconds * num_rows, + history_s=0.0, + iteration_duration_s=log_metadata.timestep_seconds, + ) + + class ArrowSceneAPI(SceneAPI): def __init__( @@ -37,23 +51,12 @@ def __init__( ) -> None: self._arrow_file_path: Path = Path(arrow_file_path) - self._log_metadata: LogMetadata = get_log_metadata_from_arrow(arrow_file_path) - - with pa.memory_map(str(self._arrow_file_path), "r") as source: - reader = pa.ipc.open_file(source) - table = reader.read_all() - num_rows = table.num_rows - initial_uuid = table["uuid"][0].as_py() - - if scene_extraction_metadata is None: - scene_extraction_metadata = SceneMetadata( - initial_uuid=initial_uuid, - initial_idx=0, - duration_s=self._log_metadata.timestep_seconds * num_rows, - history_s=0.0, - iteration_duration_s=self._log_metadata.timestep_seconds, - ) - self._scene_extraction_metadata: SceneMetadata = scene_extraction_metadata + self._log_metadata: LogMetadata = get_log_metadata_from_arrow_file(str(arrow_file_path)) + self._scene_extraction_metadata: SceneMetadata = ( + scene_extraction_metadata + if scene_extraction_metadata is not None + else _get_complete_log_scene_metadata(arrow_file_path, self._log_metadata) + ) # NOTE: Lazy load a log-specific map API, and keep reference. # Global maps are LRU cached internally. diff --git a/src/py123d/api/scene/arrow/arrow_scene_builder.py b/src/py123d/api/scene/arrow/arrow_scene_builder.py index 8f0d3e69..3cc5eb88 100644 --- a/src/py123d/api/scene/arrow/arrow_scene_builder.py +++ b/src/py123d/api/scene/arrow/arrow_scene_builder.py @@ -4,7 +4,7 @@ from typing import Iterator, List, Optional, Set, Union from py123d.api.scene.arrow.arrow_scene import ArrowSceneAPI -from py123d.api.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow +from py123d.api.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow_table from py123d.api.scene.scene_api import SceneAPI from py123d.api.scene.scene_builder import SceneBuilder from py123d.api.scene.scene_filter import SceneFilter @@ -42,16 +42,16 @@ def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[SceneA ) filter_log_names = set(filter.log_names) if filter.log_names else None log_paths = _discover_log_paths(self._logs_root, split_names, filter_log_names) + if len(log_paths) == 0: return [] - scenes = worker_map(worker, partial(_extract_scenes_from_logs, filter=filter), log_paths) + scenes = worker_map(worker, partial(_extract_scenes_from_logs, filter=filter), log_paths) if filter.shuffle: random.shuffle(scenes) if filter.max_num_scenes is not None: scenes = scenes[: filter.max_num_scenes] - return scenes @@ -99,9 +99,8 @@ def _extract_scenes_from_logs(log_paths: List[Path], filter: SceneFilter) -> Lis def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFilter) -> List[SceneMetadata]: scene_extraction_metadatas: List[SceneMetadata] = [] - - recording_table = get_lru_cached_arrow_table(log_path) - log_metadata = get_log_metadata_from_arrow(log_path) + recording_table = get_lru_cached_arrow_table(str(log_path)) + log_metadata = get_log_metadata_from_arrow_table(recording_table) start_idx = int(filter.history_s / log_metadata.timestep_seconds) if filter.history_s is not None else 0 end_idx = ( @@ -130,20 +129,24 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil ) else: scene_uuid_set = set(filter.scene_uuids) if filter.scene_uuids is not None else None - for idx in range(start_idx, end_idx): + step_idx = int(filter.duration_s / log_metadata.timestep_seconds) + all_row_uuids = recording_table[UUID_COLUMN].to_pylist() + + for idx in range(start_idx, end_idx, step_idx): scene_extraction_metadata: Optional[SceneMetadata] = None + current_uuid = str(all_row_uuids[idx]) if scene_uuid_set is None: scene_extraction_metadata = SceneMetadata( - initial_uuid=str(recording_table["uuid"][idx].as_py()), + initial_uuid=current_uuid, initial_idx=idx, duration_s=filter.duration_s, history_s=filter.history_s, iteration_duration_s=log_metadata.timestep_seconds, ) - elif str(recording_table["uuid"][idx]) in scene_uuid_set: + elif current_uuid in scene_uuid_set: scene_extraction_metadata = SceneMetadata( - initial_uuid=str(recording_table["uuid"][idx].as_py()), + initial_uuid=current_uuid, initial_idx=idx, duration_s=filter.duration_s, history_s=filter.history_s, @@ -195,7 +198,5 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil if add_scene: scene_extraction_metadatas_.append(scene_extraction_metadata) - scene_extraction_metadatas = scene_extraction_metadatas_ - - del recording_table, log_metadata - return scene_extraction_metadatas + # scene_extraction_metadata = scene_extraction_metadatas_ + return scene_extraction_metadatas_ diff --git a/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py b/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py index e99c3c18..d61cbc35 100644 --- a/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py +++ b/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py @@ -1,5 +1,4 @@ import json -from functools import lru_cache from pathlib import Path from typing import Union @@ -9,11 +8,13 @@ from py123d.datatypes.metadata.log_metadata import LogMetadata -@lru_cache(maxsize=10000) -def get_log_metadata_from_arrow(arrow_file_path: Union[Path, str]) -> LogMetadata: +def get_log_metadata_from_arrow_file(arrow_file_path: Union[Path, str]) -> LogMetadata: table = get_lru_cached_arrow_table(arrow_file_path) - log_metadata = LogMetadata.from_dict(json.loads(table.schema.metadata[b"log_metadata"].decode())) - return log_metadata + return get_log_metadata_from_arrow_table(table) + + +def get_log_metadata_from_arrow_table(arrow_table: pa.Table) -> LogMetadata: + return LogMetadata.from_dict(json.loads(arrow_table.schema.metadata[b"log_metadata"].decode())) def add_log_metadata_to_arrow_schema(schema: pa.schema, log_metadata: LogMetadata) -> pa.schema: diff --git a/src/py123d/common/utils/arrow_helper.py b/src/py123d/common/utils/arrow_helper.py index 7e1b8e47..7604cb0e 100644 --- a/src/py123d/common/utils/arrow_helper.py +++ b/src/py123d/common/utils/arrow_helper.py @@ -5,7 +5,7 @@ import pyarrow as pa # TODO: Tune Parameters and add to config? -MAX_LRU_CACHED_TABLES: Final[int] = 4096 +MAX_LRU_CACHED_TABLES: Final[int] = 1_000_000 def open_arrow_table(arrow_file_path: Union[str, Path]) -> pa.Table: diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index 33bde033..6e9f0f6b 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -130,7 +130,7 @@ def _get_centerline_from_boundaries( successor_ids=lane_dict["successors"], speed_limit_mps=None, outline=None, # Inferred from boundaries - geometry=None, + shapely_polygon=None, ) ) @@ -149,7 +149,7 @@ def _write_av2_lane_group(lane_group_dict: Dict[int, Any], map_writer: AbstractM predecessor_ids=lane_group_values["predecessor_ids"], successor_ids=lane_group_values["successor_ids"], outline=None, - geometry=None, + shapely_polygon=None, ) ) diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py index dab9917e..ad4ca22f 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py @@ -117,7 +117,7 @@ def _write_nuplan_lanes(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: Abs successor_ids=successor_ids, speed_limit_mps=all_speed_limits_mps[idx], outline=None, - geometry=all_geometries[idx], + shapely_polygon=all_geometries[idx], ) ) @@ -170,7 +170,7 @@ def _write_nuplan_lane_connectors(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_w successor_ids=successor_ids, speed_limit_mps=all_speed_limits_mps[idx], outline=None, - geometry=lane_connector_polygons_row.geometry, + shapely_polygon=lane_connector_polygons_row.geometry, ) ) @@ -225,7 +225,7 @@ def _write_nuplan_lane_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_write predecessor_ids=predecessor_lane_group_ids, successor_ids=successor_lane_group_ids, outline=None, - geometry=lane_group_row.geometry, + shapely_polygon=lane_group_row.geometry, ) ) @@ -266,7 +266,7 @@ def _write_nuplan_lane_connector_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], predecessor_ids=predecessor_lane_group_ids, successor_ids=successor_lane_group_ids, outline=None, - geometry=lane_group_connector_row.geometry, + shapely_polygon=lane_group_connector_row.geometry, ) ) @@ -284,7 +284,7 @@ def _write_nuplan_intersections(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_wri Intersection( object_id=intersection_id, lane_group_ids=lane_group_connector_ids, - geometry=all_geometries[idx], + shapely_polygon=all_geometries[idx], ) ) @@ -292,19 +292,19 @@ def _write_nuplan_intersections(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_wri def _write_nuplan_crosswalks(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: # NOTE: drops: creator_id, intersection_fids, lane_fids, is_marked (?) for id, geometry in zip(nuplan_gdf["crosswalks"].fid.to_list(), nuplan_gdf["crosswalks"].geometry.to_list()): - map_writer.write_crosswalk(Crosswalk(object_id=id, geometry=geometry)) + map_writer.write_crosswalk(Crosswalk(object_id=id, shapely_polygon=geometry)) def _write_nuplan_walkways(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: # NOTE: drops: creator_id for id, geometry in zip(nuplan_gdf["walkways"].fid.to_list(), nuplan_gdf["walkways"].geometry.to_list()): - map_writer.write_walkway(Walkway(object_id=id, geometry=geometry)) + map_writer.write_walkway(Walkway(object_id=id, shapely_polygon=geometry)) def _write_nuplan_carparks(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: # NOTE: drops: creator_id for id, geometry in zip(nuplan_gdf["carpark_areas"].fid.to_list(), nuplan_gdf["carpark_areas"].geometry.to_list()): - map_writer.write_carpark(Carpark(object_id=id, geometry=geometry)) + map_writer.write_carpark(Carpark(object_id=id, shapely_polygon=geometry)) def _write_nuplan_generic_drivables(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: @@ -312,7 +312,7 @@ def _write_nuplan_generic_drivables(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map for id, geometry in zip( nuplan_gdf["generic_drivable_areas"].fid.to_list(), nuplan_gdf["generic_drivable_areas"].geometry.to_list() ): - map_writer.write_generic_drivable(GenericDrivable(object_id=id, geometry=geometry)) + map_writer.write_generic_drivable(GenericDrivable(object_id=id, shapely_polygon=geometry)) def _write_nuplan_road_edges(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index 96a6b545..12f5a4dd 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -193,7 +193,7 @@ def write( box_detection_num_lidar_points = [] for box_detection in box_detections: - box_detection_state.append(box_detection.bounding_box_se2) + box_detection_state.append(box_detection.bounding_box_se3) box_detection_token.append(box_detection.metadata.track_token) box_detection_label.append(int(box_detection.metadata.label)) box_detection_velocity.append(box_detection.velocity_3d) diff --git a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml index f0d5f6ac..06538673 100644 --- a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml @@ -23,11 +23,11 @@ av2_sensor_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_traffic_lights: false diff --git a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml index e5b58d7d..2a342f37 100644 --- a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml @@ -31,7 +31,7 @@ kitti360_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "png_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # Fisheye Cameras include_fisheye_mei_cameras: true diff --git a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml index fc3c1b9a..25d8932c 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml @@ -28,11 +28,11 @@ nuplan_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Scenario tag / Route include_scenario_tags: true diff --git a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml index e6fac609..3509ee5d 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml @@ -28,11 +28,11 @@ nuplan_mini_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Scenario tag / Route include_scenario_tags: true diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml index b7768e63..2549022d 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml @@ -26,11 +26,11 @@ nuscenes_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_fisheye_mei_cameras: false diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml index 8d9c7163..52150a32 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml @@ -26,11 +26,11 @@ nuscenes_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_fisheye_mei_cameras: false From 5c408db595c08a4140a535328c5683c5be892b7b Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Wed, 12 Nov 2025 11:17:38 +0100 Subject: [PATCH 21/50] Add `favicon` and slightly modify the docs. --- assets/logo/123D_favicon.svg | 91 +++++++++++++++++++++++++++++++++++ docs/_static/123D_favicon.svg | 91 +++++++++++++++++++++++++++++++++++ docs/conf.py | 3 ++ pyproject.toml | 1 + 4 files changed, 186 insertions(+) create mode 100644 assets/logo/123D_favicon.svg create mode 100644 docs/_static/123D_favicon.svg diff --git a/assets/logo/123D_favicon.svg b/assets/logo/123D_favicon.svg new file mode 100644 index 00000000..af4a91d3 --- /dev/null +++ b/assets/logo/123D_favicon.svg @@ -0,0 +1,91 @@ + + + + diff --git a/docs/_static/123D_favicon.svg b/docs/_static/123D_favicon.svg new file mode 100644 index 00000000..af4a91d3 --- /dev/null +++ b/docs/_static/123D_favicon.svg @@ -0,0 +1,91 @@ + + + + diff --git a/docs/conf.py b/docs/conf.py index f5806570..bae517bb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,7 @@ "sphinx.ext.napoleon", "sphinx_copybutton", "sphinx_inline_tabs", + "sphinx_autodoc_typehints", "myst_parser", ] @@ -31,6 +32,7 @@ "rtd": ("https://docs.readthedocs.io/en/stable/", None), "python": ("https://docs.python.org/3/", None), "sphinx": ("https://www.sphinx-doc.org/en/master/", None), + "numpy": ("https://numpy.org/doc/stable/", None), } intersphinx_disabled_domains = ["std"] @@ -56,6 +58,7 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +html_favicon = "_static/123D_favicon.svg" html_theme_options = {} diff --git a/pyproject.toml b/pyproject.toml index 8e01bae1..a977a8a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ docs = [ "myst-parser", "furo", "autoclasstoc", + "sphinx-autodoc-typehints", ] nuplan = [ "nuplan-devkit @ git+https://github.com/motional/nuplan-devkit/@nuplan-devkit-v1.2", From 302901b883a3fda64855d18ded321532e6d9c815 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Wed, 12 Nov 2025 23:13:16 +0100 Subject: [PATCH 22/50] Refactor `geometry` documentation. --- .../geometry/01_primitives/06_polylines.rst | 2 + notebooks/01_scene_tutorial.ipynb | 19 ++ .../datasets/av2/av2_sensor_converter.py | 4 +- .../nuplan/utils/nuplan_sql_helper.py | 2 +- .../datasets/nuscenes/nuscenes_converter.py | 2 +- .../datasets/pandaset/pandaset_converter.py | 4 +- .../datasets/wopd/wopd_converter.py | 4 +- .../datatypes/detections/box_detections.py | 2 +- .../datatypes/map_objects/base_map_objects.py | 8 +- .../datatypes/map_objects/map_objects.py | 16 +- .../datatypes/vehicle_state/ego_state.py | 4 +- src/py123d/geometry/bounding_box.py | 183 ++++------- src/py123d/geometry/geometry_index.py | 95 +++--- src/py123d/geometry/occupancy_map.py | 21 +- src/py123d/geometry/point.py | 128 ++++---- src/py123d/geometry/polyline.py | 302 ++++++++++-------- src/py123d/geometry/pose.py | 222 ++++++------- src/py123d/geometry/rotation.py | 156 +++++---- .../geometry/transform/transform_euler_se3.py | 2 +- .../geometry/utils/bounding_box_utils.py | 14 +- src/py123d/geometry/utils/polyline_utils.py | 56 +++- src/py123d/geometry/utils/rotation_utils.py | 107 +++++-- src/py123d/geometry/vector.py | 4 + src/py123d/visualization/matplotlib/camera.py | 4 +- .../detections/test_box_detections.py | 16 +- .../map_objects/test_base_map_objects.py | 6 +- tests/unit/geometry/test_bounding_box.py | 20 +- tests/unit/geometry/test_point.py | 12 - tests/unit/geometry/test_polyline.py | 19 +- tests/unit/geometry/test_rotation.py | 28 -- .../geometry/utils/test_bounding_box_utils.py | 6 +- 31 files changed, 738 insertions(+), 730 deletions(-) diff --git a/docs/api/geometry/01_primitives/06_polylines.rst b/docs/api/geometry/01_primitives/06_polylines.rst index 6754ffee..fd572eb4 100644 --- a/docs/api/geometry/01_primitives/06_polylines.rst +++ b/docs/api/geometry/01_primitives/06_polylines.rst @@ -7,8 +7,10 @@ Polylines .. autoclass:: py123d.geometry.PolylineSE2 :members: + :exclude-members: __init__ :autoclasstoc: .. autoclass:: py123d.geometry.Polyline3D :members: + :exclude-members: __init__ :autoclasstoc: diff --git a/notebooks/01_scene_tutorial.ipynb b/notebooks/01_scene_tutorial.ipynb index c199b5a9..225aea5e 100644 --- a/notebooks/01_scene_tutorial.ipynb +++ b/notebooks/01_scene_tutorial.ipynb @@ -138,6 +138,25 @@ "id": "9", "metadata": {}, "outputs": [], + "source": [ + "from py123d.geometry import EulerAngles\n", + "import numpy as np\n", + "euler_angles = EulerAngles(roll=0.0, pitch=0.0, yaw=np.pi)\n", + "euler_angles.roll\n", + "# 0.0\n", + "euler_angles.yaw\n", + "# 3.141592653589793\n", + "# euler_angles.array\n", + "# array([0.0, 0.0, 3.14159265])\n", + "EulerAngles.from_rotation_matrix(euler_angles.rotation_matrix).yaw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], "source": [] } ], diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 969c8df5..3d03524a 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -264,9 +264,9 @@ def _extract_av2_sensor_box_detections( detections_labels.append(AV2SensorBoxDetectionLabel.deserialize(row["category"])) detections_num_lidar_points.append(int(row["num_interior_pts"])) - detections_state[:, BoundingBoxSE3Index.POSE_SE3] = convert_relative_to_absolute_se3_array( + detections_state[:, BoundingBoxSE3Index.SE3] = convert_relative_to_absolute_se3_array( origin=ego_state_se3.rear_axle_se3, - se3_array=detections_state[:, BoundingBoxSE3Index.POSE_SE3], + se3_array=detections_state[:, BoundingBoxSE3Index.SE3], ) box_detections: List[BoxDetectionSE3] = [] diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py index d283ae57..b4a08a3b 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py @@ -42,7 +42,7 @@ def get_box_detections_for_lidarpc_token_from_db(log_file: str, token: str) -> L for row in execute_many(query, (bytearray.fromhex(token),), log_file): quaternion = EulerAngles(roll=DEFAULT_ROLL, pitch=DEFAULT_PITCH, yaw=row["yaw"]).quaternion bounding_box = BoundingBoxSE3( - center=PoseSE3( + center_se3=PoseSE3( x=row["x"], y=row["y"], z=row["z"], diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index fb9e0844..5bc4ad4f 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -345,7 +345,7 @@ def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> center_quat.z, ) bounding_box = BoundingBoxSE3( - center=center, + center_se3=center, length=box.wlh[1], width=box.wlh[0], height=box.wlh[2], diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index 12d97baa..4c3fb3e9 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -300,8 +300,8 @@ def _extract_pandaset_box_detections( # Convert coordinates to ISO 8855 # NOTE: This would be faster over a batch operation. - box_se3_array[box_idx, BoundingBoxSE3Index.POSE_SE3] = rotate_pandaset_pose_to_iso_coordinates( - PoseSE3.from_array(box_se3_array[box_idx, BoundingBoxSE3Index.POSE_SE3], copy=False) + box_se3_array[box_idx, BoundingBoxSE3Index.SE3] = rotate_pandaset_pose_to_iso_coordinates( + PoseSE3.from_array(box_se3_array[box_idx, BoundingBoxSE3Index.SE3], copy=False) ).array box_detection_se3 = BoxDetectionSE3( diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 023a84ce..30845e21 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -347,8 +347,8 @@ def _extract_wopd_box_detections( detections_types.append(WOPD_DETECTION_NAME_DICT[detection.type]) detections_token.append(str(detection.id)) - detections_state[:, BoundingBoxSE3Index.POSE_SE3] = convert_relative_to_absolute_se3_array( - origin=ego_pose_se3, se3_array=detections_state[:, BoundingBoxSE3Index.POSE_SE3] + detections_state[:, BoundingBoxSE3Index.SE3] = convert_relative_to_absolute_se3_array( + origin=ego_pose_se3, se3_array=detections_state[:, BoundingBoxSE3Index.SE3] ) if zero_roll_pitch: euler_array = get_euler_array_from_quaternion_array(detections_state[:, BoundingBoxSE3Index.QUATERNION]) diff --git a/src/py123d/datatypes/detections/box_detections.py b/src/py123d/datatypes/detections/box_detections.py index db87a275..98056b9c 100644 --- a/src/py123d/datatypes/detections/box_detections.py +++ b/src/py123d/datatypes/detections/box_detections.py @@ -103,7 +103,7 @@ def velocity_2d(self) -> Optional[Vector2D]: @property def center_se2(self) -> PoseSE2: """The :class:`~py123d.geometry.PoseSE2` representing the center of the bounding box.""" - return self.bounding_box_se2.center + return self.bounding_box_se2.center_se2 @property def shapely_polygon(self) -> shapely.geometry.Polygon: diff --git a/src/py123d/datatypes/map_objects/base_map_objects.py b/src/py123d/datatypes/map_objects/base_map_objects.py index dedee17a..72ac5fb9 100644 --- a/src/py123d/datatypes/map_objects/base_map_objects.py +++ b/src/py123d/datatypes/map_objects/base_map_objects.py @@ -58,9 +58,9 @@ def __init__( if outline is None and shapely_polygon is None: raise ValueError("Either outline or shapely_polygon must be provided.") - elif outline is None: - outline = Polyline3D.from_linestring(shapely_polygon.exterior) - elif shapely_polygon is None: + if outline is None: + outline = Polyline3D.from_linestring(shapely_polygon.exterior) # type: ignore + if shapely_polygon is None: shapely_polygon = geom.Polygon(outline.array[:, :2]) self._object_id = object_id @@ -76,7 +76,7 @@ def outline(self) -> Union[Polyline2D, Polyline3D]: @property def outline_2d(self) -> Polyline2D: """The outline of the surface as :class:`~py123d.geometry.Polyline2D`.""" - if isinstance(self.outline, Polyline2D): + if isinstance(self._outline, Polyline2D): return self._outline # Converts 3D polyline to 2D by dropping the z-coordinate return Polyline2D.from_linestring(self._outline.linestring) diff --git a/src/py123d/datatypes/map_objects/map_objects.py b/src/py123d/datatypes/map_objects/map_objects.py index eca8a1ef..81d5be9f 100644 --- a/src/py123d/datatypes/map_objects/map_objects.py +++ b/src/py123d/datatypes/map_objects/map_objects.py @@ -105,7 +105,7 @@ def lane_group_id(self) -> MapObjectIDType: def lane_group(self) -> Optional[LaneGroup]: """The :class:`LaneGroup` this lane belongs to.""" if self._map_api is not None: - return self._map_api.get_map_object(self.lane_group_id, MapLayer.LANE_GROUP) + return self._map_api.get_map_object(self.lane_group_id, MapLayer.LANE_GROUP) # type: ignore return None @property @@ -132,7 +132,7 @@ def left_lane_id(self) -> Optional[MapObjectIDType]: def left_lane(self) -> Optional[Lane]: """The left neighboring :class:`Lane`, if available.""" if self._map_api is not None and self.left_lane_id is not None: - return self._map_api.get_map_object(self.left_lane_id, self.layer) + return self._map_api.get_map_object(self.left_lane_id, self.layer) # type: ignore return None @property @@ -144,7 +144,7 @@ def right_lane_id(self) -> Optional[MapObjectIDType]: def right_lane(self) -> Optional[Lane]: """The right neighboring :class:`Lane`, if available.""" if self._map_api is not None and self.right_lane_id is not None: - return self._map_api.get_map_object(self.right_lane_id, self.layer) + return self._map_api.get_map_object(self.right_lane_id, self.layer) # type: ignore return None @property @@ -157,7 +157,7 @@ def predecessors(self) -> Optional[List[Lane]]: """List of predecessor :class:`Lane` instances.""" predecessors: Optional[List[Lane]] = None if self._map_api is not None: - predecessors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.predecessor_ids] + predecessors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.predecessor_ids] # type: ignore return predecessors @property @@ -170,7 +170,7 @@ def successors(self) -> Optional[List[Lane]]: """List of successor :class:`Lane` instances.""" successors: Optional[List[Lane]] = None if self._map_api is not None: - successors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.successor_ids] + successors = [self._map_api.get_map_object(lane_id, self.layer) for lane_id in self.successor_ids] # type: ignore return successors @property @@ -263,8 +263,8 @@ def lanes(self) -> List[Lane]: """List of :class:`Lane` instances in the group.""" lanes: Optional[List[Lane]] = None if self._map_api is not None: - lanes = [self._map_api.get_map_object(lane_id, MapLayer.LANE) for lane_id in self.lane_ids] - return lanes + lanes = [self._map_api.get_map_object(lane_id, MapLayer.LANE) for lane_id in self.lane_ids] # type: ignore + return lanes # type: ignore @property def left_boundary(self) -> Polyline3D: @@ -286,7 +286,7 @@ def intersection(self) -> Optional[Intersection]: """The :class:`Intersection` the lane group belongs to, if available.""" intersection: Optional[Intersection] = None if self._map_api is not None and self.intersection_id is not None: - intersection = self._map_api.get_map_object(self.intersection_id, MapLayer.INTERSECTION) + intersection = self._map_api.get_map_object(self.intersection_id, MapLayer.INTERSECTION) # type: ignore return intersection @property diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index 7b3a521c..c25013a5 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -168,7 +168,7 @@ def bounding_box(self) -> BoundingBoxSE3: def bounding_box_se3(self) -> BoundingBoxSE3: """The :class:`~py123d.geometry.BoundingBoxSE3` of the ego vehicle.""" return BoundingBoxSE3( - center=self.center_se3, + center_se3=self.center_se3, length=self.vehicle_parameters.length, width=self.vehicle_parameters.width, height=self.vehicle_parameters.height, @@ -351,7 +351,7 @@ def bounding_box(self) -> BoundingBoxSE2: def bounding_box_se2(self) -> BoundingBoxSE2: """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" return BoundingBoxSE2( - center=self.center_se2, + center_se2=self.center_se2, length=self.vehicle_parameters.length, width=self.vehicle_parameters.width, ) diff --git a/src/py123d/geometry/bounding_box.py b/src/py123d/geometry/bounding_box.py index 4efbbd7b..e36b5661 100644 --- a/src/py123d/geometry/bounding_box.py +++ b/src/py123d/geometry/bounding_box.py @@ -1,7 +1,6 @@ from __future__ import annotations -from ast import Dict -from typing import Union +from typing import Dict, Union import numpy as np import numpy.typing as npt @@ -16,11 +15,11 @@ class BoundingBoxSE2(ArrayMixin): """ - Rotated bounding box in 2D defined by center (StateSE2), length and width. + Rotated bounding box in 2D defined by a center :class:`~py123d.geometry.PoseSE2`, length and width. Example: - >>> from py123d.geometry import StateSE2 - >>> bbox = BoundingBoxSE2(center=StateSE2(1.0, 2.0, 0.5), length=4.0, width=2.0) + >>> from py123d.geometry import PoseSE2, BoundingBoxSE2 + >>> bbox = BoundingBoxSE2(center_se2=PoseSE2(1.0, 2.0, 0.5), length=4.0, width=2.0) >>> bbox.array array([1. , 2. , 0.5, 4. , 2. ]) >>> bbox.corners_array.shape @@ -29,29 +28,30 @@ class BoundingBoxSE2(ArrayMixin): 8.0 """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] - def __init__(self, center: PoseSE2, length: float, width: float): - """Initialize BoundingBoxSE2 with center (StateSE2), length and width. + def __init__(self, center_se2: PoseSE2, length: float, width: float): + """Initialize :class:`BoundingBoxSE2` with :class:`~py123d.geometry.PoseSE2` center, length and width. - :param center: Center of the bounding box as a StateSE2 instance. + :param center_se2: Center of the bounding box as a :class:`~py123d.geometry.PoseSE2` instance. :param length: Length of the bounding box along the x-axis in the local frame. :param width: Width of the bounding box along the y-axis in the local frame. """ array = np.zeros(len(BoundingBoxSE2Index), dtype=np.float64) - array[BoundingBoxSE2Index.SE2] = center.array + array[BoundingBoxSE2Index.SE2] = center_se2.array array[BoundingBoxSE2Index.LENGTH] = length array[BoundingBoxSE2Index.WIDTH] = width object.__setattr__(self, "_array", array) @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> BoundingBoxSE2: - """Create a BoundingBoxSE2 from a numpy array. + """Create a :class:`BoundingBoxSE2` from a (5,) numpy array, \ + indexed by :class:`~py123d.geometry.BoundingBoxSE2Index`. - :param array: A 1D numpy array containing the bounding box parameters, indexed by \ - :class:`~py123d.geometry.BoundingBoxSE2Index`. + :param array: A 1D numpy array containing the bounding box parameters. :param copy: Whether to copy the input array. Defaults to True. - :return: A BoundingBoxSE2 instance. + :return: A :class:`BoundingBoxSE2` instance. """ assert array.ndim == 1 assert array.shape[-1] == len(BoundingBoxSE2Index) @@ -59,88 +59,59 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Boundi object.__setattr__(instance, "_array", array.copy() if copy else array) return instance - @property - def center(self) -> PoseSE2: - """The center of the bounding box as a StateSE2 instance. - - :return: The center of the bounding box as a StateSE2 instance. - """ - return PoseSE2.from_array(self._array[BoundingBoxSE2Index.SE2]) - @property def center_se2(self) -> PoseSE2: - """The center of the bounding box as a StateSE2 instance. - - :return: The center of the bounding box as a StateSE2 instance. - """ - return self.center + """The center of the bounding box as a :class:`~py123d.geometry.PoseSE2` instance.""" + return PoseSE2.from_array(self._array[BoundingBoxSE2Index.SE2]) @property def length(self) -> float: - """The length of the bounding box along the x-axis in the local frame. - - :return: The length of the bounding box. - """ + """Length of the bounding box along the x-axis in the local frame.""" return self._array[BoundingBoxSE2Index.LENGTH] @property def width(self) -> float: - """The width of the bounding box along the y-axis in the local frame. - - :return: The width of the bounding box. - """ + """Width of the bounding box along the y-axis in the local frame.""" return self._array[BoundingBoxSE2Index.WIDTH] @property def array(self) -> npt.NDArray[np.float64]: - """Converts the BoundingBoxSE2 instance to a numpy array, indexed by :class:`~py123d.geometry.BoundingBoxSE2Index`. - - :return: A numpy array of shape (5,) containing the bounding box parameters [x, y, yaw, length, width]. - """ + """The numpy array representation of shape (5,), indexed by :class:`~py123d.geometry.BoundingBoxSE2Index`.""" return self._array - @property - def shapely_polygon(self) -> geom.Polygon: - """Return a Shapely polygon representation of the bounding box. - - :return: A Shapely polygon representing the bounding box. - """ - return geom.Polygon(self.corners_array) - - @property - def bounding_box_se2(self) -> BoundingBoxSE2: - """Returns bounding box itself for polymorphism. - - :return: A BoundingBoxSE2 instance representing the 2D bounding box. - """ - return self - @property def corners_array(self) -> npt.NDArray[np.float64]: - """Returns the corner points of the bounding box as a numpy array. - - :return: A numpy array of shape (4, 2) containing the corner points of the bounding box, \ - indexed by :class:`~py123d.geometry.Corners2DIndex` and :class:`~py123d.geometry.Point2DIndex`. + """The corner points of the bounding box as a numpy array of shape (4, 2), indexed by \ + :class:`~py123d.geometry.Corners2DIndex` and :class:`~py123d.geometry.Point2DIndex`, respectively. """ return bbse2_array_to_corners_array(self.array) @property def corners_dict(self) -> Dict[Corners2DIndex, Point2D]: - """Returns the corner points of the bounding box as a dictionary. - - :return: A dictionary mapping :class:`~py123d.geometry.Corners2DIndex` to :class:`~py123d.geometry.Point2D` instances. + """Dictionary of corner points of the bounding box, mapping :class:`~py123d.geometry.Corners2DIndex` to \ + :class:`~py123d.geometry.Point2D` instances. """ corners_array = self.corners_array return {index: Point2D.from_array(corners_array[index]) for index in Corners2DIndex} + @property + def shapely_polygon(self) -> geom.Polygon: + """The shapely polygon representation of the bounding box.""" + return geom.Polygon(self.corners_array) + + @property + def bounding_box_se2(self) -> BoundingBoxSE2: + """The :class:`BoundingBoxSE2` instance itself.""" + return self + class BoundingBoxSE3(ArrayMixin): """ Rotated bounding box in 3D defined by center with quaternion rotation (PoseSE3), length, width and height. Example: - >>> from py123d.geometry import PoseSE3 - >>> bbox = BoundingBoxSE3(center=PoseSE3(1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0), length=4.0, width=2.0, height=1.5) + >>> from py123d.geometry import PoseSE3, BoundingBoxSE3 + >>> bbox = BoundingBoxSE3(center_se3=PoseSE3(1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0), length=4.0, width=2.0, height=1.5) >>> bbox.array array([1. , 2. , 3. , 1. , 0. , 0. , 0. , 4. , 2. , 1.5]) >>> bbox.bounding_box_se2.array @@ -149,18 +120,19 @@ class BoundingBoxSE3(ArrayMixin): 8.0 """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] - def __init__(self, center: PoseSE3, length: float, width: float, height: float): - """Initialize BoundingBoxSE3 with center (PoseSE3), length, width and height. + def __init__(self, center_se3: PoseSE3, length: float, width: float, height: float): + """Initialize :class:`BoundingBoxSE3` with :class:`~py123d.geometry.PoseSE3` center, length, width and height. - :param center: Center of the bounding box as a PoseSE3 instance. + :param center_se3: Center of the bounding box as a :class:`~py123d.geometry.PoseSE3` instance. :param length: Length of the bounding box along the x-axis in the local frame. :param width: Width of the bounding box along the y-axis in the local frame. :param height: Height of the bounding box along the z-axis in the local frame. """ array = np.zeros(len(BoundingBoxSE3Index), dtype=np.float64) - array[BoundingBoxSE3Index.POSE_SE3] = center.array + array[BoundingBoxSE3Index.SE3] = center_se3.array array[BoundingBoxSE3Index.LENGTH] = length array[BoundingBoxSE3Index.WIDTH] = width array[BoundingBoxSE3Index.HEIGHT] = height @@ -168,10 +140,10 @@ def __init__(self, center: PoseSE3, length: float, width: float, height: float): @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> BoundingBoxSE3: - """Create a BoundingBoxSE3 from a numpy array. + """Create a :class:`BoundingBoxSE3` from a (10,) numpy array, \ + indexed by :class:`~py123d.geometry.BoundingBoxSE3Index`. - :param array: A 1D numpy array containing the bounding box parameters, indexed by \ - :class:`~py123d.geometry.BoundingBoxSE3Index`. + :param array: A (10,) numpy array containing the bounding box parameters. :param copy: Whether to copy the input array. Defaults to True. :return: A BoundingBoxSE3 instance. """ @@ -181,101 +153,64 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Boundi object.__setattr__(instance, "_array", array.copy() if copy else array) return instance - @property - def center(self) -> PoseSE3: - """The center of the bounding box as a PoseSE3 instance. - - :return: The center of the bounding box as a PoseSE3 instance. - """ - return PoseSE3.from_array(self._array[BoundingBoxSE3Index.POSE_SE3]) - @property def center_se3(self) -> PoseSE3: - """The center of the bounding box as a PoseSE3 instance. - - :return: The center of the bounding box as a PoseSE3 instance. - """ - return self.center + """The center of the bounding box as a :class:`~py123d.geometry.PoseSE3` instance.""" + return PoseSE3.from_array(self._array[BoundingBoxSE3Index.SE3]) @property def center_se2(self) -> PoseSE2: - """The center of the bounding box as a StateSE2 instance. - - :return: The center of the bounding box as a StateSE2 instance. - """ + """The center of the bounding box as a :class:`~py123d.geometry.PoseSE2` instance.""" return self.center_se3.pose_se2 @property def length(self) -> float: - """The length of the bounding box along the x-axis in the local frame. - - :return: The length of the bounding box. - """ + """The length of the bounding box along the x-axis in the local frame.""" return self._array[BoundingBoxSE3Index.LENGTH] @property def width(self) -> float: - """The width of the bounding box along the y-axis in the local frame. - - :return: The width of the bounding box. - """ + """The width of the bounding box along the y-axis in the local frame.""" return self._array[BoundingBoxSE3Index.WIDTH] @property def height(self) -> float: - """The height of the bounding box along the z-axis in the local frame. - - :return: The height of the bounding box. - """ + """The height of the bounding box along the z-axis in the local frame.""" return self._array[BoundingBoxSE3Index.HEIGHT] @property def array(self) -> npt.NDArray[np.float64]: - """Convert the BoundingBoxSE3 instance to a numpy array. - - :return: A 1D numpy array containing the bounding box parameters, indexed by \ - :class:`~py123d.geometry.BoundingBoxSE3Index`. - """ + """The numpy array representation of shape (10,), indexed by :class:`~py123d.geometry.BoundingBoxSE3Index`.""" return self._array @property def bounding_box_se2(self) -> BoundingBoxSE2: - """Converts the 3D bounding box to a 2D bounding box by dropping the z, roll and pitch components. - - :return: A BoundingBoxSE2 instance. - """ + """The SE2 projection :class:`~py123d.geometry.BoundingBoxSE2` of the bounding box.""" return BoundingBoxSE2( - center=self.center_se2, + center_se2=self.center_se2, length=self.length, width=self.width, ) - @property - def shapely_polygon(self) -> geom.Polygon: - """Return a Shapely polygon representation of the 2D projection of the bounding box. - - :return: A shapely polygon representing the 2D bounding box. - """ - return self.bounding_box_se2.shapely_polygon - @property def corners_array(self) -> npt.NDArray[np.float64]: - """Returns the corner points of the bounding box as a numpy array, shape (8, 3). - - :return: A numpy array of shape (8, 3) containing the corner points of the bounding box, \ - indexed by :class:`~py123d.geometry.Corners3DIndex` and :class:`~py123d.geometry.Point3DIndex`. + """The corner points of the bounding box as a numpy array of shape (8, 3), indexed by \ + :class:`~py123d.geometry.Corners3DIndex` and :class:`~py123d.geometry.Point3DIndex`, respectively. """ return bbse3_array_to_corners_array(self.array) @property def corners_dict(self) -> Dict[Corners3DIndex, Point3D]: - """Returns the corner points of the bounding box as a dictionary. - - :return: A dictionary mapping :class:`~py123d.geometry.Corners3DIndex` to \ + """Dictionary of corner points of the bounding box, mapping :class:`~py123d.geometry.Corners3DIndex` to \ :class:`~py123d.geometry.Point3D` instances. """ corners_array = self.corners_array return {index: Point3D.from_array(corners_array[index]) for index in Corners3DIndex} + @property + def shapely_polygon(self) -> geom.Polygon: + """The shapely polygon representation of the SE2 projection of the bounding box.""" + return self.bounding_box_se2.shapely_polygon + BoundingBox = Union[BoundingBoxSE2, BoundingBoxSE3] diff --git a/src/py123d/geometry/geometry_index.py b/src/py123d/geometry/geometry_index.py index b81032b1..8928d852 100644 --- a/src/py123d/geometry/geometry_index.py +++ b/src/py123d/geometry/geometry_index.py @@ -4,35 +4,31 @@ class Point2DIndex(IntEnum): - """ - Indexes array-like representations of 2D points (x,y). - """ + """Indexing enum for array-like representations of 2D points (x,y).""" X = 0 Y = 1 @classproperty def XY(cls) -> slice: + """Slice for accessing (x,y) coordinates.""" return slice(cls.X, cls.Y + 1) class Vector2DIndex(IntEnum): - """ - Indexes array-like representations of 2D vectors (x,y). - """ + """Indexing enum for array-like representations of 2D vectors (x,y).""" X = 0 Y = 1 @classproperty def XY(cls) -> slice: + """Slice for accessing (x,y) vector components.""" return slice(cls.X, cls.Y + 1) class PoseSE2Index(IntEnum): - """ - Indexes array-like representations of SE2 states (x,y,yaw). - """ + """Indexing enum for array-like representations of SE2 poses (x,y,yaw).""" X = 0 Y = 1 @@ -40,13 +36,17 @@ class PoseSE2Index(IntEnum): @classproperty def XY(cls) -> slice: + """Slice for accessing (x,y) coordinates.""" return slice(cls.X, cls.Y + 1) + @classproperty + def SE2(cls) -> slice: + """Slice for accessing (x,y,yaw) pose components.""" + return slice(cls.X, cls.YAW + 1) + class Point3DIndex(IntEnum): - """ - Indexes array-like representations of 3D points (x,y,z). - """ + """Indexing enum for array-like representations of 3D points (x,y,z).""" X = 0 Y = 1 @@ -54,17 +54,17 @@ class Point3DIndex(IntEnum): @classproperty def XY(cls) -> slice: + """Slice for accessing (x,y) coordinates.""" return slice(cls.X, cls.Y + 1) @classproperty def XYZ(cls) -> slice: + """Slice for accessing (x,y,z) coordinates.""" return slice(cls.X, cls.Z + 1) class Vector3DIndex(IntEnum): - """ - Indexes array-like representations of 3D vectors (x,y,z). - """ + """Indexing enum for array-like representations of 3D vectors (x,y,z).""" X = 0 Y = 1 @@ -72,13 +72,12 @@ class Vector3DIndex(IntEnum): @classproperty def XYZ(cls) -> slice: + """Slice for accessing (x,y,z) vector components.""" return slice(cls.X, cls.Z + 1) class EulerAnglesIndex(IntEnum): - """ - Indexes array-like representations of Euler angles (roll,pitch,yaw). - """ + """Indexing enum for array-like representations of Euler angles (roll,pitch,yaw).""" ROLL = 0 PITCH = 1 @@ -86,9 +85,7 @@ class EulerAnglesIndex(IntEnum): class QuaternionIndex(IntEnum): - """ - Indexes array-like representations of quaternions (qw,qx,qy,qz). - """ + """Indexing enum for array-like representations of quaternions (qw,qx,qy,qz), scalar-first.""" QW = 0 QX = 1 @@ -97,17 +94,22 @@ class QuaternionIndex(IntEnum): @classproperty def SCALAR(cls) -> int: + """Index for the scalar part of the quaternion.""" return cls.QW @classproperty def VECTOR(cls) -> slice: + """Slice for accessing the imaginary vector part of the quaternion.""" return slice(cls.QX, cls.QZ + 1) class EulerStateSE3Index(IntEnum): - """ - Indexes array-like representations of SE3 states (x,y,z,roll,pitch,yaw). - TODO: Use quaternions for rotation. + """Indexing enum for array-like representations of SE3 states with Euler angles (x,y,z,roll,pitch,yaw). + + Notes + ----- + Representing a pose with Euler angles is deprecated but left in for testing purposes. + """ X = 0 @@ -119,21 +121,22 @@ class EulerStateSE3Index(IntEnum): @classproperty def XY(cls) -> slice: + """Slice for accessing (x,y) coordinates.""" return slice(cls.X, cls.Y + 1) @classproperty def XYZ(cls) -> slice: + """Slice for accessing (x,y,z) coordinates.""" return slice(cls.X, cls.Z + 1) @classproperty def EULER_ANGLES(cls) -> slice: + """Slice for accessing (roll,pitch,yaw) Euler angles.""" return slice(cls.ROLL, cls.YAW + 1) class PoseSE3Index(IntEnum): - """ - Indexes array-like representations of SE3 states with quaternions (x,y,z,qw,qx,qy,qz). - """ + """Indexing enum for array-like representations of SE3 poses (x,y,z,qw,qx,qy,qz).""" X = 0 Y = 1 @@ -145,28 +148,35 @@ class PoseSE3Index(IntEnum): @classproperty def XY(cls) -> slice: + """Slice for accessing (x,y) coordinates.""" return slice(cls.X, cls.Y + 1) @classproperty def XYZ(cls) -> slice: + """Slice for accessing (x,y,z) coordinates.""" return slice(cls.X, cls.Z + 1) @classproperty def QUATERNION(cls) -> slice: + """Slice for accessing (qw,qx,qy,qz) quaternion components.""" return slice(cls.QW, cls.QZ + 1) @classproperty def SCALAR(cls) -> slice: + """Slice for accessing the scalar part of the quaternion.""" return slice(cls.QW, cls.QW + 1) @classproperty def VECTOR(cls) -> slice: + """Slice for accessing the vector part of the quaternion.""" return slice(cls.QX, cls.QZ + 1) class BoundingBoxSE2Index(IntEnum): - """ - Indexes array-like representations of rotated 2D bounding boxes (x,y,yaw,length,width). + """Indexing enum for array-like representations of bounding boxes in SE2 + - center point (x,y). + - yaw rotation. + - extent (length,width). """ X = 0 @@ -177,21 +187,22 @@ class BoundingBoxSE2Index(IntEnum): @classproperty def XY(cls) -> slice: + """Slice for accessing (x,y) coordinates.""" return slice(cls.X, cls.Y + 1) @classproperty def SE2(cls) -> slice: + """Slice for accessing (x,y,yaw) SE2 representation.""" return slice(cls.X, cls.YAW + 1) @classproperty def EXTENT(cls) -> slice: + """Slice for accessing (length,width) extent.""" return slice(cls.LENGTH, cls.WIDTH + 1) class Corners2DIndex(IntEnum): - """ - Indexes the corners of a BoundingBoxSE2 in the order: front-left, front-right, back-right, back-left. - """ + """Indexes the corners of a bounding boxes in SE2 in the order: front-left, front-right, back-right, back-left.""" FRONT_LEFT = 0 FRONT_RIGHT = 1 @@ -202,8 +213,8 @@ class Corners2DIndex(IntEnum): class BoundingBoxSE3Index(IntEnum): """ Indexes array-like representations of rotated 3D bounding boxes - - center (x,y,z). - - rotation (qw,qx,qy,qz). + - center point (x,y,z). + - quaternion rotation (qw,qx,qy,qz). - extent (length,width,height). """ @@ -220,34 +231,40 @@ class BoundingBoxSE3Index(IntEnum): @classproperty def XYZ(cls) -> slice: + """Slice for accessing (x,y,z) coordinates.""" return slice(cls.X, cls.Z + 1) @classproperty - def POSE_SE3(cls) -> slice: + def SE3(cls) -> slice: + """Slice for accessing the full SE3 pose representation.""" return slice(cls.X, cls.QZ + 1) @classproperty def QUATERNION(cls) -> slice: + """Slice for accessing (qw,qx,qy,qz) quaternion components.""" return slice(cls.QW, cls.QZ + 1) @classproperty def EXTENT(cls) -> slice: + """Slice for accessing (length,width,height) extent.""" return slice(cls.LENGTH, cls.HEIGHT + 1) @classproperty def SCALAR(cls) -> slice: + """Slice for accessing the scalar part of the quaternion.""" return slice(cls.QW, cls.QW + 1) @classproperty def VECTOR(cls) -> slice: + """Slice for accessing the vector part of the quaternion.""" return slice(cls.QX, cls.QZ + 1) class Corners3DIndex(IntEnum): """ - Indexes the corners of a BoundingBoxSE3 in the order: - front-left-bottom, front-right-bottom, back-right-bottom, back-left-bottom, - front-left-top, front-right-top, back-right-top, back-left-top. + Indexes the corners of a BoundingBoxSE3 in the order: \ + front-left-bottom, front-right-bottom, back-right-bottom, back-left-bottom,\ + front-left-top, front-right-top, back-right-top, back-left-top. """ FRONT_LEFT_BOTTOM = 0 @@ -261,8 +278,10 @@ class Corners3DIndex(IntEnum): @classproperty def BOTTOM(cls) -> slice: + """Slice for accessing the four bottom corners.""" return slice(cls.FRONT_LEFT_BOTTOM, cls.BACK_LEFT_BOTTOM + 1) @classproperty def TOP(cls) -> slice: + """Slice for accessing the four top corners.""" return slice(cls.FRONT_LEFT_TOP, cls.BACK_LEFT_TOP + 1) diff --git a/src/py123d/geometry/occupancy_map.py b/src/py123d/geometry/occupancy_map.py index a0e4021a..a92a888c 100644 --- a/src/py123d/geometry/occupancy_map.py +++ b/src/py123d/geometry/occupancy_map.py @@ -12,10 +12,12 @@ class OccupancyMap2D: + """Class to represent a 2D occupancy map of shapely geometries using an str-tree for efficient spatial queries.""" + def __init__( self, geometries: Sequence[BaseGeometry], - ids: Optional[Union[List[str], List[int]]] = None, + ids: Optional[Union[Sequence[str], Sequence[int]]] = None, node_capacity: int = 10, ): """Constructs a 2D occupancy map of shapely geometries using an str-tree for efficient spatial queries. @@ -26,10 +28,10 @@ def __init__( """ assert ids is None or len(ids) == len(geometries), "Length of ids must match length of geometries" + if ids is not None: + assert all(isinstance(id, (str, int)) for id in ids), "IDs must be either strings or integers" - self._ids: Union[List[str], List[int]] = ( - ids if ids is not None else [str(idx) for idx in range(len(geometries))] - ) + self._ids: Sequence[Union[str, int]] = ids if ids is not None else [str(idx) for idx in range(len(geometries))] self._id_to_idx: Dict[Union[str, int], int] = {id: idx for idx, id in enumerate(self._ids)} self._geometries = geometries @@ -37,7 +39,11 @@ def __init__( self._str_tree = STRtree(self._geometries, node_capacity) @classmethod - def from_dict(cls, geometry_dict: Dict[Union[str, int], BaseGeometry], node_capacity: int = 10) -> OccupancyMap2D: + def from_dict( + cls, + geometry_dict: Union[Dict[str, BaseGeometry], Dict[int, BaseGeometry]], + node_capacity: int = 10, + ) -> OccupancyMap2D: """Constructs a 2D occupancy map from a dictionary of geometries. :param geometry_dict: Dictionary mapping geometry identifiers to shapely geometries @@ -153,7 +159,10 @@ def query_nearest( def contains_vectorized(self, points: npt.NDArray[np.float64]) -> npt.NDArray[np.bool_]: """Determines wether input-points are in geometries (i.e. polygons) of the occupancy map. - NOTE: This function can be significantly faster than using the str-tree, if the number of geometries is + + Notes + ----- + This function can be significantly faster than using the str-tree, if the number of geometries is relatively small compared to the number of input-points. :param points: array of shape (num_points, 2), indexed by :class:`~py123d.geometry.Point2DIndex`. diff --git a/src/py123d/geometry/point.py b/src/py123d/geometry/point.py index 7558bef0..76aad41a 100644 --- a/src/py123d/geometry/point.py +++ b/src/py123d/geometry/point.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Iterable - import numpy as np import numpy.typing as npt import shapely.geometry as geom @@ -11,12 +9,26 @@ class Point2D(ArrayMixin): - """Class to represents 2D points.""" - + """Class presenting a 2D point. + + Example: + >>> from py123d.geometry import Point2D + >>> point_2d = Point2D(1.0, 2.0) + >>> point_2d.x, point_2d.y + (1.0, 2.0) + >>> point_2d.array + array([1., 2.]) + """ + + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float): - """Initialize StateSE2 with x, y, yaw coordinates.""" + """Initializes :class:`Point2D` with x, y coordinates. + + :param x: The x coordinate. + :param y: The y coordinate. + """ array = np.zeros(len(Point2DIndex), dtype=np.float64) array[Point2DIndex.X] = x array[Point2DIndex.Y] = y @@ -24,10 +36,9 @@ def __init__(self, x: float, y: float): @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Point2D: - """Constructs a Point2D from a numpy array. + """Creates a :class:`Point2D` from a (2,) shaped numpy array, indexed by :class:`~py123d.geometry.Point2DIndex`. - :param array: Array of shape (2,) representing the point coordinates [x, y], indexed by \ - :class:`~py123d.geometry.Point2DIndex`. + :param array: A (2,) shaped numpy array representing the point coordinates (x,y). :param copy: Whether to copy the input array. Defaults to True. :return: A Point2D instance. """ @@ -39,53 +50,53 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Point2 @property def x(self) -> float: - """The x coordinate of the point. - - :return: The x coordinate of the point. - """ + """The x coordinate of the point.""" return self._array[Point2DIndex.X] @property def y(self) -> float: - """The y coordinate of the point. - - :return: The y coordinate of the point. - """ + """The y coordinate of the point.""" return self._array[Point2DIndex.Y] @property def array(self) -> npt.NDArray[np.float64]: - """The array representation of the point. - - :return: A numpy array of shape (2,) containing the point coordinates [x, y], indexed by \ - :class:`~py123d.geometry.Point2DIndex`. - """ + """The array representation of shape (2,), indexed by :class:`~py123d.geometry.Point2DIndex`.""" return self._array @property def shapely_point(self) -> geom.Point: - """The Shapely Point representation of the 2D point. - - :return: A Shapely Point representation of the 2D point. - """ + """The shapely point representation of the 2D point.""" return geom.Point(self.x, self.y) - def __iter__(self) -> Iterable[float]: - """Iterator over point coordinates.""" - return iter((self.x, self.y)) - - def __hash__(self) -> int: - """Hash method""" - return hash((self.x, self.y)) + @property + def point_2d(self) -> Point2D: + """Returns the :class:`Point2D` instance itself.""" + return self class Point3D(ArrayMixin): - """Class to represents 3D points.""" + """Class presenting a 3D point. + + Example: + >>> from py123d.geometry import Point3D + >>> point_3d = Point3D(1.0, 2.0, 3.0) + >>> point_3d.x, point_3d.y, point_3d.z + (1.0, 2.0, 3.0) + >>> point_3d.array + array([1., 2., 3.]) + + """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, z: float): - """Initialize Point3D with x, y, z coordinates.""" + """Initializes :class:`Point3D` with x, y, z coordinates. + + :param x: The x coordinate. + :param y: The y coordinate. + :param z: The z coordinate. + """ array = np.zeros(len(Point3DIndex), dtype=np.float64) array[Point3DIndex.X] = x array[Point3DIndex.Y] = y @@ -94,12 +105,10 @@ def __init__(self, x: float, y: float, z: float): @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Point3D: - """Constructs a Point3D from a numpy array. + """Creates a :class:`Point3D` from a (3,) shaped numpy array, indexed by :class:`~py123d.geometry.Point3DIndex`. - :param array: Array of shape (3,) representing the point coordinates [x, y, z], indexed by \ - :class:`~py123d.geometry.Point3DIndex`. + :param array: A (3,) shaped numpy array representing the point coordinates (x,y,z). :param copy: Whether to copy the input array. Defaults to True. - :return: A Point3D instance. """ assert array.ndim == 1 assert array.shape[0] == len(Point3DIndex) @@ -109,58 +118,35 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Point3 @property def x(self) -> float: - """The x coordinate of the point. - - :return: The x coordinate of the point. - """ + """The x coordinate of the point.""" return self._array[Point3DIndex.X] @property def y(self) -> float: - """The y coordinate of the point. - - :return: The y coordinate of the point. - """ + """The y coordinate of the point.""" return self._array[Point3DIndex.Y] @property def z(self) -> float: - """The z coordinate of the point. - - :return: The z coordinate of the point. - """ + """The z coordinate of the point.""" return self._array[Point3DIndex.Z] @property def array(self) -> npt.NDArray[np.float64]: - """The array representation of the point. - - :return: A numpy array of shape (3,) containing the point coordinates [x, y, z], indexed by \ - :class:`~py123d.geometry.Point3DIndex`. - """ + """The array representation of shape (3,), indexed by :class:`~py123d.geometry.Point3DIndex`.""" return self._array @property def point_2d(self) -> Point2D: - """The 2D projection of the 3D point. - - :return: A Point2D instance representing the 2D projection of the 3D point. - """ + """The 2D projection of the 3D point as a :class:`Point2D` instance.""" return Point2D.from_array(self.array[Point3DIndex.XY], copy=False) @property def shapely_point(self) -> geom.Point: - """The Shapely Point representation of the 3D point. \ - This geometry contains the z-coordinate, but many Shapely operations ignore it. - - :return: A Shapely Point representation of the 3D point. - """ + """The shapely point representation of the 3D point.""" return geom.Point(self.x, self.y, self.z) - def __iter__(self) -> Iterable[float]: - """Iterator over the point coordinates (x, y, z).""" - return iter((self.x, self.y, self.z)) - - def __hash__(self) -> int: - """Hash method""" - return hash((self.x, self.y, self.z)) + @property + def point_3d(self) -> Point3D: + """Returns the :class:`Point3D` instance itself.""" + return self diff --git a/src/py123d/geometry/polyline.py b/src/py123d/geometry/polyline.py index 6e369be5..b7bd48de 100644 --- a/src/py123d/geometry/polyline.py +++ b/src/py123d/geometry/polyline.py @@ -1,7 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import List, Optional, Union +from typing import Optional, Union import numpy as np import numpy.typing as npt @@ -14,65 +13,74 @@ from py123d.geometry.point import Point2D, Point3D from py123d.geometry.pose import PoseSE2 from py123d.geometry.utils.constants import DEFAULT_Z -from py123d.geometry.utils.polyline_utils import get_linestring_yaws, get_path_progress +from py123d.geometry.utils.polyline_utils import get_linestring_yaws, get_path_progress_2d, get_path_progress_3d from py123d.geometry.utils.rotation_utils import normalize_angle -# TODO: Implement PolylineSE3 -# TODO: Benchmark interpolation performance and reconsider reliance on LineString - -@dataclass class Polyline2D(ArrayMixin): - """Represents a interpolatable 2D polyline.""" + """Represents a interpolatable 2D polyline. + + Example: + >>> import numpy as np + >>> from py123d.geometry import Polyline2D + >>> polyline = Polyline2D.from_array(np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])) + >>> polyline.length + 2.8284271247461903 + >>> polyline.interpolate(np.sqrt(2)) + Point2D(array=[1. 1.]) - linestring: geom.LineString + """ + + __slots__ = ("_linestring",) + _linestring: geom.LineString @classmethod def from_linestring(cls, linestring: geom.LineString) -> Polyline2D: - """Creates a Polyline2D from a Shapely LineString. If the LineString has Z-coordinates, they are ignored. + """Creates a :class:`Polyline2D` from a Shapely LineString. If the LineString has Z-coordinates, they are ignored. - :param linestring: A Shapely LineString object. + :param linestring: A shapely LineString object. :return: A Polyline2D instance. """ if linestring.has_z: linestring_ = geom_creation.linestrings(*linestring.xy) else: linestring_ = linestring - return Polyline2D(linestring_) + + instance = object.__new__(cls) + object.__setattr__(instance, "_linestring", linestring_) + return instance @classmethod def from_array(cls, polyline_array: npt.NDArray[np.float32]) -> Polyline2D: - """Creates a Polyline2D from a numpy array. + """Creates a :class:`Polyline2D` from a (N, 2) or (N, 3) shaped numpy array. \ + Assumes [...,:2] slices are XY coordinates. :param polyline_array: A numpy array of shape (N, 2) or (N, 3), e.g. indexed by \ :class:`~py123d.geometry.Point2DIndex` or :class:`~py123d.geometry.Point3DIndex`. :raises ValueError: If the input array is not of the expected shape. - :return: A Polyline2D instance. + :return: A :class:`Polyline2D` instance. """ assert polyline_array.ndim == 2 - linestring: Optional[geom.LineString] = None + linestring_: Optional[geom.LineString] = None if polyline_array.shape[-1] == len(Point2DIndex): - linestring = geom_creation.linestrings(polyline_array) + linestring_ = geom.LineString(polyline_array) elif polyline_array.shape[-1] == len(Point3DIndex): - linestring = geom_creation.linestrings(polyline_array[:, Point3DIndex.XY]) + linestring_ = geom.LineString(polyline_array[:, Point3DIndex.XY]) else: raise ValueError("Array must have shape (N, 2) or (N, 3) for Point2D or Point3D respectively.") - return Polyline2D(linestring) - def from_discrete_points(cls, discrete_points: List[Point2D]) -> Polyline2D: - """Creates a Polyline2D from a list of discrete 2D points. + instance = object.__new__(cls) + object.__setattr__(instance, "_linestring", linestring_) + return instance - :param discrete_points: A list of Point2D instances. - :return: A Polyline2D instance. - """ - return Polyline2D.from_array(np.array(discrete_points, dtype=np.float64)) + @property + def linestring(self) -> geom.LineString: + """The shapely LineString representation of the polyline.""" + return self._linestring @property def array(self) -> npt.NDArray[np.float64]: - """Converts the polyline to a numpy array, indexed by :class:`~py123d.geometry.Point2DIndex`. - - :return: A numpy array of shape (N, 2) representing the polyline. - """ + """The numpy array representation of shape (N, 2), indexed by :class:`~py123d.geometry.Point2DIndex`.""" x, y = self.linestring.xy array = np.zeros((len(x), len(Point2DIndex)), dtype=np.float64) array[:, Point2DIndex.X] = x @@ -81,18 +89,12 @@ def array(self) -> npt.NDArray[np.float64]: @property def polyline_se2(self) -> PolylineSE2: - """Converts the 2D polyline to a 2D SE(2) polyline and retrieves the yaw angles. - - :return: A PolylineSE2 instance representing the 2D polyline. - """ - return PolylineSE2.from_linestring(self.linestring) + """The :class:`~py123d.geometry.PolylineSE2` representation of the polyline, with inferred yaw angles.""" + return PolylineSE2.from_linestring(self._linestring) @property def length(self) -> float: - """Returns the length of the polyline. - - :return: The length of the polyline. - """ + """Returns the length of the polyline.""" return self.linestring.length def interpolate( @@ -100,9 +102,9 @@ def interpolate( distances: Union[float, npt.NDArray[np.float64]], normalized: bool = False, ) -> Union[Point2D, npt.NDArray[np.float64]]: - """Interpolates the polyline at the given distances. + """Interpolates the :class:`Polyline2D` at the given distances. - :param distances: The distances at which to interpolate the polyline. + :param distances: Array-like or float distances along the polyline to interpolate. :return: The interpolated point(s) on the polyline. """ @@ -110,13 +112,17 @@ def interpolate( point = self.linestring.interpolate(distances, normalized=normalized) return Point2D(point.x, point.y) else: - distances_ = np.asarray(distances, dtype=np.float64) points = self.linestring.interpolate(distances, normalized=normalized) return np.array([[p.x, p.y] for p in points], dtype=np.float64) def project( self, - point: Union[geom.Point, Point2D, PoseSE2, npt.NDArray[np.float64]], + point: Union[ + geom.Point, + Point2D, + PoseSE2, + npt.NDArray[np.float64], + ], normalized: bool = False, ) -> npt.NDArray[np.float64]: """Projects a point onto the polyline and returns the distance along the polyline to the closest point. @@ -131,50 +137,65 @@ def project( point_ = point else: point_ = np.array(point, dtype=np.float64) - return self.linestring.project(point_, normalized=normalized) + return self._linestring.project(point_, normalized=normalized) # type: ignore -@dataclass class PolylineSE2(ArrayMixin): - """Represents a interpolatable SE2 polyline.""" + """Represents a interpolatable SE2 polyline. - _array: npt.NDArray[np.float64] - linestring: Optional[geom.LineString] = None + Example: + >>> import numpy as np + >>> from py123d.geometry import PolylineSE2 + >>> polyline_se2 = PolylineSE2.from_array(np.array([[0.0, 0.0, 0.0], [1.0, 1.0, np.pi/4], [2.0, 0.0, 0.0]])) + >>> polyline_se2.length + 2.8284271247461903 + >>> polyline_se2.interpolate(np.sqrt(2)) + PoseSE2(array=[1. 1. 0.78539816]) - _progress: Optional[npt.NDArray[np.float64]] = None - _interpolator: Optional[interp1d] = None + """ - def __post_init__(self): - assert self._array is not None + __slots__ = ("_array", "_progress", "_linestring") - if self.linestring is None: - self.linestring = geom_creation.linestrings(self._array[..., PoseSE2Index.XY]) + def __init__( + self, + array: npt.NDArray[np.float64], + linestring: Optional[geom.LineString] = None, + ): + """Initializes :class:`PolylineSE2` with a numpy array of SE2 states. + + :param array: A numpy array of shape (N, 3) representing SE2 states, indexed by \ + :class:`~py123d.geometry.PoseSE2Index`. + :param linestring: Optional shapely LineString representing the XY path. If not provided,\ + it will be created from the array. + """ + self._array = array self._array[:, PoseSE2Index.YAW] = np.unwrap(self._array[:, PoseSE2Index.YAW], axis=0) - self._progress = get_path_progress(self._array) - self._interpolator = interp1d(self._progress, self._array, axis=0, bounds_error=False, fill_value=0.0) + self._progress = get_path_progress_2d(self._array) + self._linestring = geom.LineString(self._array[..., PoseSE2Index.XY]) if linestring is None else linestring @classmethod def from_linestring(cls, linestring: geom.LineString) -> PolylineSE2: - """Creates a PolylineSE2 from a LineString. This requires computing the yaw angles along the path. + """Creates a :class:`PolylineSE2` from a shapely LineString. \ + The yaw angles are inferred from the LineString coordinates. :param linestring: The LineString to convert. - :return: A PolylineSE2 representing the same path as the LineString. + :return: A :class:`PolylineSE2` representing the same path as the LineString. """ - points_2d = np.array(linestring.coords, dtype=np.float64)[..., PoseSE2Index.XY] - se2_array = np.zeros((len(points_2d), len(PoseSE2Index)), dtype=np.float64) - se2_array[:, PoseSE2Index.XY] = points_2d + points_2d_array = np.array(linestring.coords, dtype=np.float64)[..., PoseSE2Index.XY] + se2_array = np.zeros((len(points_2d_array), len(PoseSE2Index)), dtype=np.float64) + se2_array[:, PoseSE2Index.XY] = points_2d_array se2_array[:, PoseSE2Index.YAW] = get_linestring_yaws(linestring) return PolylineSE2(se2_array, linestring) @classmethod def from_array(cls, polyline_array: npt.NDArray[np.float32]) -> PolylineSE2: - """Creates a PolylineSE2 from a numpy array. + """Creates a :class:`PolylineSE2` from a numpy array. :param polyline_array: The input numpy array representing, either indexed by \ :class:`~py123d.geometry.Point2DIndex` or :class:`~py123d.geometry.PoseSE2Index`. :raises ValueError: If the input array is not of the expected shape. - :return: A PolylineSE2 representing the same path as the input array. + :return: A :class:`PolylineSE2` representing the same path as the input array. """ assert polyline_array.ndim == 2 if polyline_array.shape[-1] == len(Point2DIndex): @@ -184,32 +205,23 @@ def from_array(cls, polyline_array: npt.NDArray[np.float32]) -> PolylineSE2: elif polyline_array.shape[-1] == len(PoseSE2Index): se2_array = np.array(polyline_array, dtype=np.float64) else: - raise ValueError("Invalid polyline array shape.") + raise ValueError(f"Invalid polyline array shape, expected (N, 2) or (N, 3), got {polyline_array.shape}.") return PolylineSE2(se2_array) - @classmethod - def from_discrete_se2(cls, discrete_se2: List[PoseSE2]) -> PolylineSE2: - """Creates a PolylineSE2 from a list of discrete SE2 states. - - :param discrete_se2: The list of discrete SE2 states. - :return: A PolylineSE2 representing the same path as the discrete SE2 states. - """ - return PolylineSE2.from_array(np.array(discrete_se2, dtype=np.float64)) + @property + def linestring(self) -> geom.LineString: + """The shapely LineString representation of the polyline.""" + return self._linestring @property def array(self) -> npt.NDArray[np.float64]: - """Converts the polyline to a numpy array, indexed by :class:`~py123d.geometry.PoseSE2Index`. - - :return: A numpy array of shape (N, 3) representing the polyline. - """ + """The numpy array representation of shape (N, 3), indexed by :class:`~py123d.geometry.PoseSE2Index`.""" return self._array @property def length(self) -> float: - """Returns the length of the polyline. - - :return: The length of the polyline. - """ + """Returns the length of the polyline.""" + assert self._progress is not None return float(self._progress[-1]) def interpolate( @@ -223,11 +235,11 @@ def interpolate( :param normalized: Whether the distances are normalized (0 to 1), defaults to False :return: The interpolated StateSE2 or an array of interpolated states, according to """ - + _interpolator = interp1d(self._progress, self._array, axis=0, bounds_error=False, fill_value=0.0) distances_ = distances * self.length if normalized else distances clipped_distances = np.clip(distances_, 1e-8, self.length) - interpolated_se2_array = self._interpolator(clipped_distances) + interpolated_se2_array = _interpolator(clipped_distances) interpolated_se2_array[..., PoseSE2Index.YAW] = normalize_angle(interpolated_se2_array[..., PoseSE2Index.YAW]) if clipped_distances.ndim == 0: @@ -237,7 +249,12 @@ def interpolate( def project( self, - point: Union[geom.Point, Point2D, PoseSE2, npt.NDArray[np.float64]], + point: Union[ + geom.Point, + Point2D, + PoseSE2, + npt.NDArray[np.float64], + ], normalized: bool = False, ) -> npt.NDArray[np.float64]: """Projects a point onto the polyline and returns the distance along the polyline to the closest point. @@ -252,76 +269,96 @@ def project( point_ = point else: point_ = np.array(point, dtype=np.float64) - return self.linestring.project(point_, normalized=normalized) + return self.linestring.project(point_, normalized=normalized) # type: ignore -@dataclass class Polyline3D(ArrayMixin): - """Represents a interpolatable 3D polyline.""" + """Represents a interpolatable 3D polyline. + + Example: + >>> import numpy as np + >>> from py123d.geometry import Polyline3D + >>> polyline_3d = Polyline3D.from_array(np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [2.0, 0.0, 0.0]])) + >>> polyline_3d.length + 3.4641016151377544 + >>> polyline_3d.interpolate(np.sqrt(3)) + Point3D(array=[1. 1. 1.]) + + """ - linestring: geom.LineString + __slots__ = ("_array", "_progress", "_linestring") + + def __init__(self, array: npt.NDArray[np.float64], linestring: Optional[geom.LineString] = None): + """Initializes :class:`Polyline3D` with a numpy array of 3D points. + + :param array: A numpy array of shape (N, 3) representing 3D points, e.g. indexed by \ + :class:`~py123d.geometry.Point3DIndex`. + :param linestring: Optional shapely LineString representing the 3D path. If not provided,\ + it will be created from the array. + """ + assert len(array.shape) == 2 and array.shape[1] == len(Point3DIndex) + self._array = array + self._progress = get_path_progress_3d(self._array[:, Point3DIndex.XYZ]) + self._linestring = geom.LineString(self._array) if linestring is None else linestring @classmethod def from_linestring(cls, linestring: geom.LineString) -> Polyline3D: - """Creates a Polyline3D from a Shapely LineString. If the LineString does not have Z-coordinates, \ - a default Z-value is added. + """Creates a :class:`Polyline3D` from a shapely LineString. If the LineString does not have Z-coordinates, \ + the coordinate is zero-padded. :param linestring: The input LineString. - :return: A Polyline3D instance. + :return: A :class:`Polyline3D` instance. """ - return ( - Polyline3D(linestring) - if linestring.has_z - else Polyline3D(geom_creation.linestrings(*linestring.xy, z=DEFAULT_Z)) - ) + if linestring.has_z: + linestring_ = linestring + else: + linestring_ = geom_creation.linestrings(*linestring.xy, z=DEFAULT_Z) # type: ignore + array = np.array(linestring_.coords, dtype=np.float64) + return Polyline3D(array, linestring_) @classmethod def from_array(cls, array: npt.NDArray[np.float64]) -> Polyline3D: - """Creates a Polyline3D from a numpy array. + """Creates a :class:`Polyline3D` from a numpy array. :param array: A numpy array of shape (N, 3) representing 3D points, e.g. indexed by \ :class:`~py123d.geometry.Point3DIndex`. - :return: A Polyline3D instance. + :return: A :class:`Polyline3D` instance. """ assert array.ndim == 2, "Array must be 2D with shape (N, 3) or (N, 2)." if array.shape[1] == len(Point2DIndex): array = np.hstack((array, np.full((array.shape[0], 1), DEFAULT_Z))) elif array.shape[1] != len(Point3DIndex): raise ValueError("Array must have shape (N, 3) for Point3D.") - linestring = geom_creation.linestrings(*array.T) - return Polyline3D(linestring) + return Polyline3D(array) @property - def polyline_2d(self) -> Polyline2D: - """Converts the 3D polyline to a 2D polyline by dropping the Z-coordinates. - - :return: A Polyline2D instance. - """ - return Polyline2D(geom_creation.linestrings(*self.linestring.xy)) + def linestring(self) -> geom.LineString: + """The shapely LineString representation of the 3D polyline.""" + if not self._linestring.has_z: + linestring_ = geom_creation.linestrings(*self._array.T) # type: ignore + object.__setattr__(self, "_linestring", linestring_) + return self._linestring @property - def polyline_se2(self) -> PolylineSE2: - """Converts the 3D polyline to a 2D SE(2) polyline. - - :return: A PolylineSE2 instance. - """ - return PolylineSE2.from_linestring(self.linestring) + def array(self) -> npt.NDArray[np.float64]: + """The numpy array representation of shape (N, 3), indexed by :class:`~py123d.geometry.Point3DIndex`.""" + return np.array(self.linestring.coords, dtype=np.float64) @property - def array(self) -> npt.NDArray[np.float64]: - """Converts the 3D polyline to the discrete 3D points. + def polyline_2d(self) -> Polyline2D: + """The :class:`~py123d.geometry.Polyline2D` representation of the 3D polyline.""" + return Polyline2D.from_linestring(geom_creation.linestrings(*self.linestring.xy)) # type: ignore - :return: A numpy array of shape (N, 3), indexed by :class:`~py123d.geometry.Point3DIndex`. - """ - return np.array(self.linestring.coords, dtype=np.float64) + @property + def polyline_se2(self) -> PolylineSE2: + """The :class:`~py123d.geometry.PolylineSE2` representation of the 3D polyline.""" + return PolylineSE2.from_linestring(self.linestring) # type: ignore @property def length(self) -> float: - """Returns the length of the 3D polyline. - - :return: The length of the polyline. - """ - return self.linestring.length + """Returns the length of the 3D polyline.""" + array = self.array + return np.linalg.norm(array[:-1, :] - array[1:, :], axis=1).sum() def interpolate( self, @@ -335,13 +372,15 @@ def interpolate( :return: A Point3D instance or a numpy array of shape (N, 3) representing the interpolated points. """ - if isinstance(distances, float) or isinstance(distances, int): - point = self.linestring.interpolate(distances, normalized=normalized) - return Point3D(point.x, point.y, point.z) + _interpolator = interp1d(self._progress, self._array, axis=0, bounds_error=False, fill_value=0.0) + distances_ = distances * self.length if normalized else distances + clipped_distances = np.clip(distances_, 1e-8, self.length) + + interpolated_3d_array = _interpolator(clipped_distances) + if clipped_distances.ndim == 0: + return Point3D(*interpolated_3d_array) else: - distances = np.asarray(distances, dtype=np.float64) - points = self.linestring.interpolate(distances, normalized=normalized) - return np.array([[p.x, p.y, p.z] for p in points], dtype=np.float64) + return interpolated_3d_array def project( self, @@ -360,15 +399,4 @@ def project( point_ = point else: point_ = np.array(point, dtype=np.float64) - return self.linestring.project(point_, normalized=normalized) - - -@dataclass -class PolylineSE3: - # TODO: Implement PolylineSE3 once quaternions are used in PoseSE3 - # Interpolating along SE3 states (i.e., 3D position + orientation) is meaningful, - # but more complex than SE2 due to 3D rotations (quaternions or rotation matrices). - # Linear interpolation of positions is straightforward, but orientation interpolation - # should use SLERP (spherical linear interpolation) for quaternions. - # This is commonly needed in robotics, animation, and path planning. - pass + return self.linestring.project(point_, normalized=normalized) # type: ignore diff --git a/src/py123d/geometry/pose.py b/src/py123d/geometry/pose.py index 6cb1a734..7db97ea4 100644 --- a/src/py123d/geometry/pose.py +++ b/src/py123d/geometry/pose.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Iterable - import numpy as np import numpy.typing as npt import shapely.geometry as geom @@ -13,12 +11,29 @@ class PoseSE2(ArrayMixin): - """Class to represents a 2D pose as SE2 (x, y, yaw).""" + """Class to represents a 2D pose as SE2 (x, y, yaw). + + Examples: + >>> from py123d.geometry import PoseSE2 + >>> pose = PoseSE2(x=1.0, y=2.0, yaw=0.5) + >>> print(pose.x, pose.y, pose.yaw) + 1.0 2.0 0.5 + >>> print(pose.rotation_matrix) + [[ 0.87758256 -0.47942554] + [ 0.47942554 0.87758256]] + """ + + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, yaw: float): - """Initialize StateSE2 with x, y, yaw coordinates.""" + """Init :class:`PoseSE2` with x, y, yaw coordinates. + + :param x: The x-coordinate. + :param y: The y-coordinate. + :param yaw: The yaw angle in radians. + """ array = np.zeros(len(PoseSE2Index), dtype=np.float64) array[PoseSE2Index.X] = x array[PoseSE2Index.Y] = y @@ -27,12 +42,12 @@ def __init__(self, x: float, y: float, yaw: float): @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PoseSE2: - """Constructs a StateSE2 from a numpy array. + """Constructs a PoseSE2 from a numpy array. :param array: Array of shape (3,) representing the state [x, y, yaw], indexed by \ :class:`~py123d.geometry.geometry_index.PoseSE2Index`. :param copy: Whether to copy the input array. Defaults to True. - :return: A StateSE2 instance. + :return: A PoseSE2 instance. """ assert array.ndim == 1 assert array.shape[0] == len(PoseSE2Index) @@ -42,57 +57,39 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PoseSE @property def x(self) -> float: + """The x-coordinate of the pose.""" return self._array[PoseSE2Index.X] @property def y(self) -> float: + """The y-coordinate of the pose.""" return self._array[PoseSE2Index.Y] @property def yaw(self) -> float: + """The yaw angle of the pose.""" return self._array[PoseSE2Index.YAW] @property def array(self) -> npt.NDArray[np.float64]: - """Converts the StateSE2 instance to a numpy array - - :return: A numpy array of shape (3,) containing the state, indexed by \ - :class:`~py123d.geometry.geometry_index.PoseSE2Index`. - """ + """Pose as numpy array of shape (3,), indexed by :class:`~py123d.geometry.geometry_index.PoseSE2Index`.""" return self._array - @property - def pose_se2(self) -> PoseSE2: - """The 2D pose itself. Helpful for polymorphism. - - :return: A StateSE2 instance representing the 2D pose. - """ - return self - @property def point_2d(self) -> Point2D: - """The 2D projection of the 2D pose. - - :return: A Point2D instance representing the 2D projection of the 2D pose. - """ + """The :class:`~py123d.geometry.Point2D` of the pose, i.e. the translation part.""" return Point2D.from_array(self.array[PoseSE2Index.XY]) @property def rotation_matrix(self) -> npt.NDArray[np.float64]: - """Returns the 2x2 rotation matrix representation of the state's orientation. - - :return: A 2x2 numpy array representing the rotation matrix. - """ + """The 2x2 rotation matrix representation of the pose.""" cos_yaw = np.cos(self.yaw) sin_yaw = np.sin(self.yaw) return np.array([[cos_yaw, -sin_yaw], [sin_yaw, cos_yaw]], dtype=np.float64) @property def transformation_matrix(self) -> npt.NDArray[np.float64]: - """Returns the 3x3 transformation matrix representation of the state. - - :return: A 3x3 numpy array representing the transformation matrix. - """ + """The 3x3 transformation matrix representation of the pose.""" matrix = np.zeros((3, 3), dtype=np.float64) matrix[:2, :2] = self.rotation_matrix matrix[0, 2] = self.x @@ -101,26 +98,48 @@ def transformation_matrix(self) -> npt.NDArray[np.float64]: @property def shapely_point(self) -> geom.Point: + """The Shapely point representation of the pose.""" return geom.Point(self.x, self.y) + @property + def pose_se2(self) -> PoseSE2: + """Returns self to match interface of other pose classes.""" + return self + class PoseSE3(ArrayMixin): """Class representing a quaternion in SE3 space Examples: - >>> pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=0.7071, qx=0.0, qy=0.7071, qz=0.0) - >>> print(pose.x, pose.y, pose.z) - 1.0 2.0 3.0 - >>> print(pose.qw, pose.qx, pose.qy, pose.qz) - 0.7071 0.0 0.7071 0.0 - >>> print(pose.yaw) - 1.5707963267948966 + >>> from py123d.geometry import PoseSE3 + >>> pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + >>> pose.point_3d + Point3D(array=[1. 2. 3.]) + >>> pose.transformation_matrix + array([[1., 0., 0., 1.], + [0., 1., 0., 2.], + [0., 0., 1., 3.], + [0., 0., 0., 1.]]) + >>> PoseSE3.from_transformation_matrix(pose.transformation_matrix) == pose + True + >>> print(pose.yaw, pose.pitch, pose.roll) + 0.0 0.0 0.0 """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, z: float, qw: float, qx: float, qy: float, qz: float): - """Initialize PoseSE3 with x, y, z, qw, qx, qy, qz coordinates.""" + """Initialize :class:`PoseSE3` with x, y, z, qw, qx, qy, qz coordinates. + + :param x: The x-coordinate. + :param y: The y-coordinate. + :param z: The z-coordinate. + :param qw: The w-coordinate of the quaternion, representing the scalar part. + :param qx: The x-coordinate of the quaternion, representing the first component of the vector part. + :param qy: The y-coordinate of the quaternion, representing the second component of the vector part. + :param qz: The z-coordinate of the quaternion, representing the third component of the vector part. + """ array = np.zeros(len(PoseSE3Index), dtype=np.float64) array[PoseSE3Index.X] = x array[PoseSE3Index.Y] = y @@ -133,11 +152,12 @@ def __init__(self, x: float, y: float, z: float, qw: float, qx: float, qy: float @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PoseSE3: - """Constructs a PoseSE3 from a numpy array. + """Constructs a :class:`PoseSE3` from a numpy array of shape (7,), \ + indexed by :class:`~py123d.geometry.geometry_index.PoseSE3Index`. - :param array: Array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.PoseSE3Index`. + :param array: Array of shape (7,) representing the state [x, y, z, qw, qx, qy, qz]. :param copy: Whether to copy the input array. Defaults to True. - :return: A PoseSE3 instance. + :return: A :class:`PoseSE3` instance. """ assert array.ndim == 1 assert array.shape[0] == len(PoseSE3Index) @@ -147,10 +167,10 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> PoseSE @classmethod def from_transformation_matrix(cls, transformation_matrix: npt.NDArray[np.float64]) -> PoseSE3: - """Constructs a PoseSE3 from a 4x4 transformation matrix. + """Constructs a :class:`PoseSE3` from a 4x4 transformation matrix. :param transformation_matrix: A 4x4 numpy array representing the transformation matrix. - :return: A PoseSE3 instance. + :return: A :class:`PoseSE3` instance. """ assert transformation_matrix.ndim == 2 assert transformation_matrix.shape == (4, 4) @@ -161,156 +181,98 @@ def from_transformation_matrix(cls, transformation_matrix: npt.NDArray[np.float6 @property def x(self) -> float: - """Returns the x-coordinate of the quaternion. - - :return: The x-coordinate. - """ + """The x-coordinate of the pose.""" return self._array[PoseSE3Index.X] @property def y(self) -> float: - """Returns the y-coordinate of the quaternion. - - :return: The y-coordinate. - """ + """The y-coordinate of the pose.""" return self._array[PoseSE3Index.Y] @property def z(self) -> float: - """Returns the z-coordinate of the quaternion. - - :return: The z-coordinate. - """ + """The z-coordinate of the pose.""" return self._array[PoseSE3Index.Z] @property def qw(self) -> float: - """Returns the w-coordinate of the quaternion. - - :return: The w-coordinate. - """ + """The w-coordinate of the quaternion, representing the scalar part.""" return self._array[PoseSE3Index.QW] @property def qx(self) -> float: - """Returns the x-coordinate of the quaternion. - - :return: The x-coordinate. - """ + """The x-coordinate of the quaternion, representing the first component of the vector part.""" return self._array[PoseSE3Index.QX] @property def qy(self) -> float: - """Returns the y-coordinate of the quaternion. - - :return: The y-coordinate. - """ + """The y-coordinate of the quaternion, representing the second component of the vector part.""" return self._array[PoseSE3Index.QY] @property def qz(self) -> float: - """Returns the z-coordinate of the quaternion. - - :return: The z-coordinate. - """ + """The z-coordinate of the quaternion, representing the third component of the vector part.""" return self._array[PoseSE3Index.QZ] @property def array(self) -> npt.NDArray[np.float64]: - """Converts the PoseSE3 instance to a numpy array. - - :return: A numpy array of shape (7,), indexed by :class:`~py123d.geometry.geometry_index.PoseSE3Index`. - """ + """The numpy array representation of the pose with shape (7,), \ + indexed by :class:`~py123d.geometry.geometry_index.PoseSE3Index`""" return self._array @property def pose_se2(self) -> PoseSE2: - """Returns the SE2 pose of ... - - :return: A StateSE2 instance representing the 2D projection of the 3D state. - """ - # Convert quaternion to yaw angle - yaw = self.quaternion.euler_angles.yaw - return PoseSE2(self.x, self.y, yaw) + """The :class:`PoseSE2` representation of the SE3 pose.""" + return PoseSE2(self.x, self.y, self.yaw) @property def point_3d(self) -> Point3D: - """Returns the 3D point representation of the state. - - :return: A Point3D instance representing the 3D point. - """ + """The :class:`Point3D` representation of the SE3 pose, i.e. the translation part.""" return Point3D(self.x, self.y, self.z) @property def point_2d(self) -> Point2D: - """Returns the 2D point representation of the state. - - :return: A Point2D instance representing the 2D point. - """ + """The :class:`Point2D` representation of the SE3 pose, i.e. the translation part.""" return Point2D(self.x, self.y) @property def shapely_point(self) -> geom.Point: - """Returns the Shapely point representation of the state. - - :return: A Shapely Point instance representing the 3D point. - """ + """The Shapely point representation, of the translation part of the SE3 pose.""" return self.point_3d.shapely_point @property def quaternion(self) -> Quaternion: - """Returns the quaternion (w, x, y, z) representation of the state's orientation. - - :return: A Quaternion instance representing the quaternion. - """ + """The :class:`~py123d.geometry.Quaternion` representation of the state's orientation.""" return Quaternion.from_array(self.array[PoseSE3Index.QUATERNION]) @property def euler_angles(self) -> EulerAngles: - """Returns the Euler angles (roll, pitch, yaw) representation of the state's orientation. - - :return: An EulerAngles instance representing the Euler angles. - """ + """The :class:`~py123d.geometry.EulerAngles` representation of the state's orientation.""" return self.quaternion.euler_angles @property def roll(self) -> float: - """The roll (x-axis rotation) angle in radians. - - :return: The roll angle in radians. - """ + """The roll (x-axis rotation) angle in radians.""" return self.euler_angles.roll @property def pitch(self) -> float: - """The pitch (y-axis rotation) angle in radians. - - :return: The pitch angle in radians. - """ + """The pitch (y-axis rotation) angle in radians.""" return self.euler_angles.pitch @property def yaw(self) -> float: - """The yaw (z-axis rotation) angle in radians. - - :return: The yaw angle in radians. - """ + """The yaw (z-axis rotation) angle in radians.""" return self.euler_angles.yaw @property def rotation_matrix(self) -> npt.NDArray[np.float64]: - """Returns the 3x3 rotation matrix representation of the state's orientation. - - :return: A 3x3 numpy array representing the rotation matrix. - """ + """Returns the 3x3 rotation matrix representation of the state's orientation.""" return self.quaternion.rotation_matrix @property def transformation_matrix(self) -> npt.NDArray[np.float64]: - """Returns the 4x4 transformation matrix representation of the state. - - :return: A 4x4 numpy array representing the transformation matrix. - """ + """Returns the 4x4 transformation matrix representation of the state.""" transformation_matrix = np.eye(4, dtype=np.float64) transformation_matrix[:3, :3] = self.rotation_matrix transformation_matrix[:3, 3] = self.array[PoseSE3Index.XYZ] @@ -320,9 +282,13 @@ def transformation_matrix(self) -> npt.NDArray[np.float64]: class EulerStateSE3(ArrayMixin): """ Class to represents a 3D pose as SE3 (x, y, z, roll, pitch, yaw). - NOTE: This class is deprecated, use :class:`~py123d.geometry.PoseSE3` instead (quaternion based). + + Notes + ----- + This class is deprecated, use :class:`~py123d.geometry.PoseSE3` instead (quaternion based). """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, z: float, roll: float, pitch: float, yaw: float): @@ -495,11 +461,3 @@ def pose_se3(self) -> PoseSE3: @property def quaternion(self) -> Quaternion: return Quaternion.from_euler_angles(self.euler_angles) - - def __iter__(self) -> Iterable[float]: - """Iterator over the state coordinates (x, y, z, roll, pitch, yaw).""" - return iter((self.x, self.y, self.z, self.roll, self.pitch, self.yaw)) - - def __hash__(self) -> int: - """Hash method""" - return hash((self.x, self.y, self.z, self.roll, self.pitch, self.yaw)) diff --git a/src/py123d/geometry/rotation.py b/src/py123d/geometry/rotation.py index 1f54431a..1a9bcab1 100644 --- a/src/py123d/geometry/rotation.py +++ b/src/py123d/geometry/rotation.py @@ -18,14 +18,41 @@ class EulerAngles(ArrayMixin): """Class to represent 3D rotation using Euler angles (roll, pitch, yaw) in radians. - NOTE: The rotation order is intrinsic Z-Y'-X'' (yaw-pitch-roll). - See https://en.wikipedia.org/wiki/Euler_angles for more details. + + Examples + -------- + >>> import numpy as np + >>> from py123d.geometry import EulerAngles + >>> euler_angles = EulerAngles(roll=0.0, pitch=0.0, yaw=np.pi) + >>> euler_angles.roll + 0.0 + >>> euler_angles.yaw + 3.141592653589793 + >>> euler_angles.array + array([0.0, 0.0, 3.14159265]) + >>> EulerAngles.from_rotation_matrix(euler_angles.rotation_matrix).yaw + 3.141592653589793 + + Notes + ----- + The rotation order is intrinsic Z-Y'-X'' (yaw-pitch-roll) [1]_. + + References + ---------- + .. [1] https://en.wikipedia.org/wiki/Euler_angles + """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, roll: float, pitch: float, yaw: float): - """Initialize EulerAngles with roll, pitch, yaw coordinates.""" + """Initialize EulerAngles with roll, pitch, yaw angles in radians. + + :param roll: The roll (x-axis rotation) angle in radians. + :param pitch: The pitch (y-axis rotation) angle in radians. + :param yaw: The yaw (z-axis rotation) angle in radians. + """ array = np.zeros(len(EulerAnglesIndex), dtype=np.float64) array[EulerAnglesIndex.ROLL] = roll array[EulerAnglesIndex.PITCH] = pitch @@ -34,12 +61,12 @@ def __init__(self, roll: float, pitch: float, yaw: float): @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> EulerAngles: - """Constructs a EulerAngles from a numpy array. - - :param array: Array of shape (3,) representing the euler angles [roll, pitch, yaw], indexed by \ + """Constructs a :class:`EulerAngles` from a numpy array of shape (3,) representing, indexed by \ :class:`~py123d.geometry.EulerAnglesIndex`. + + :param array: Array of shape (3,) representing the euler angles [roll, pitch, yaw]. :param copy: Whether to copy the input array. Defaults to True. - :return: A EulerAngles instance. + :return: A :class:`EulerAngles` instance. """ assert array.ndim == 1 assert array.shape[0] == len(EulerAnglesIndex) @@ -49,11 +76,10 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> EulerA @classmethod def from_rotation_matrix(cls, rotation_matrix: npt.NDArray[np.float64]) -> EulerAngles: - """Constructs a EulerAngles from a 3x3 rotation matrix. - NOTE: The rotation order is intrinsic Z-Y'-X'' (yaw-pitch-roll). + """Constructs a :class:`EulerAngles` from a 3x3 rotation matrix. :param rotation_matrix: A 3x3 numpy array representing the rotation matrix. - :return: A EulerAngles instance. + :return: A :class:`EulerAngles` instance. """ assert rotation_matrix.ndim == 2 assert rotation_matrix.shape == (3, 3) @@ -61,68 +87,70 @@ def from_rotation_matrix(cls, rotation_matrix: npt.NDArray[np.float64]) -> Euler @property def roll(self) -> float: - """The roll (x-axis rotation) angle in radians. - - :return: The roll angle in radians. - """ + """The roll (x-axis rotation) angle in radians.""" return self._array[EulerAnglesIndex.ROLL] @property def pitch(self) -> float: - """The pitch (y-axis rotation) angle in radians. - - :return: The pitch angle in radians. - """ + """The pitch (y-axis rotation) angle in radians.""" return self._array[EulerAnglesIndex.PITCH] @property def yaw(self) -> float: - """The yaw (z-axis rotation) angle in radians. - - :return: The yaw angle in radians. - """ + """The yaw (z-axis rotation) angle in radians.""" return self._array[EulerAnglesIndex.YAW] @property def array(self) -> npt.NDArray[np.float64]: - """Converts the EulerAngles instance to a numpy array. - - :return: A numpy array of shape (3,) containing the Euler angles [roll, pitch, yaw], indexed by \ - :class:`~py123d.geometry.EulerAnglesIndex`. + """Converts the EulerAngles instance to a numpy array of shape (3,),\ + indexed by :class:`~py123d.geometry.EulerAnglesIndex`. """ return self._array @property def quaternion(self) -> Quaternion: + """The :class:`Quaternion` representation of the Euler angles.""" return Quaternion.from_euler_angles(self) @property def rotation_matrix(self) -> npt.NDArray[np.float64]: - """Returns the 3x3 rotation matrix representation of the Euler angles. - NOTE: The rotation order is intrinsic Z-Y'-X'' (yaw-pitch-roll). - - :return: A 3x3 numpy array representing the rotation matrix. - """ + """Returns the 3x3 rotation matrix representation of the Euler angles.""" return get_rotation_matrix_from_euler_array(self.array) - def __iter__(self): - """Iterator over euler angles.""" - return iter((self.roll, self.pitch, self.yaw)) - - def __hash__(self): - """Hash function for euler angles.""" - return hash((self.roll, self.pitch, self.yaw)) - class Quaternion(ArrayMixin): """ Represents a quaternion for 3D rotations. + + Examples + -------- + >>> import numpy as np + >>> from py123d.geometry import Quaternion + >>> quat = Quaternion(1.0, 0.0, 0.0, 0.0) + >>> quat.qw + 1.0 + >>> quat.qx + 0.0 + >>> quat.array + array([1.0, 0.0, 0.0, 0.0]) + >>> quat.rotation_matrix + array([[1., 0., 0.], + [0., 1., 0.], + [0., 0., 1.]]) + """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, qw: float, qx: float, qy: float, qz: float): - """Initialize Quaternion with qw, qx, qy, qz components.""" + """Initialize Quaternion with components. + + :param qw: The scalar component of the quaternion. + :param qx: The x component of the quaternion. + :param qy: The y component of the quaternion. + :param qz: The z component of the quaternion. + """ array = np.zeros(len(QuaternionIndex), dtype=np.float64) array[QuaternionIndex.QW] = qw array[QuaternionIndex.QX] = qx @@ -167,74 +195,42 @@ def from_euler_angles(cls, euler_angles: EulerAngles) -> Quaternion: @property def qw(self) -> float: - """The scalar part of the quaternion. - - :return: The qw component. - """ + """The scalar component of the quaternion.""" return self._array[QuaternionIndex.QW] @property def qx(self) -> float: - """The x component of the quaternion. - - :return: The qx component. - """ + """The x component of the quaternion.""" return self._array[QuaternionIndex.QX] @property def qy(self) -> float: - """The y component of the quaternion. - - :return: The qy component. - """ + """The y component of the quaternion.""" return self._array[QuaternionIndex.QY] @property def qz(self) -> float: - """The z component of the quaternion. - - :return: The qz component. - """ + """The z component of the quaternion.""" return self._array[QuaternionIndex.QZ] @property def array(self) -> npt.NDArray[np.float64]: - """Converts the Quaternion instance to a numpy array. - - :return: A numpy array of shape (4,) containing the quaternion [qw, qx, qy, qz], indexed by \ + """The numpy array of shape (4,) containing the quaternion [qw, qx, qy, qz], indexed by \ :class:`~py123d.geometry.QuaternionIndex`. """ return self._array @property def pyquaternion(self) -> pyquaternion.Quaternion: - """Returns the pyquaternion.Quaternion representation of the quaternion. - - :return: A pyquaternion.Quaternion representation of the quaternion. - """ + """The pyquaternion.Quaternion representation of the quaternion.""" return pyquaternion.Quaternion(array=self.array) @property def euler_angles(self) -> EulerAngles: - """Returns the Euler angles (roll, pitch, yaw) representation of the quaternion. - NOTE: The rotation order is intrinsic Z-Y'-X'' (yaw-pitch-roll). - - :return: An EulerAngles instance representing the Euler angles. - """ + """The :class:`EulerAngles` representation of the quaternion.""" return EulerAngles.from_array(get_euler_array_from_quaternion_array(self.array), copy=False) @property def rotation_matrix(self) -> npt.NDArray[np.float64]: - """Returns the 3x3 rotation matrix representation of the quaternion. - - :return: A 3x3 numpy array representing the rotation matrix. - """ + """Returns the 3x3 rotation matrix representation of the quaternion.""" return get_rotation_matrix_from_quaternion_array(self.array) - - def __iter__(self): - """Iterator over quaternion components.""" - return iter((self.qw, self.qx, self.qy, self.qz)) - - def __hash__(self): - """Hash function for quaternion.""" - return hash((self.qw, self.qx, self.qy, self.qz)) diff --git a/src/py123d/geometry/transform/transform_euler_se3.py b/src/py123d/geometry/transform/transform_euler_se3.py index 48210bec..4454f066 100644 --- a/src/py123d/geometry/transform/transform_euler_se3.py +++ b/src/py123d/geometry/transform/transform_euler_se3.py @@ -166,5 +166,5 @@ def convert_relative_to_absolute_points_3d_array( assert points_3d_array.shape[-1] == len(Point3DIndex) R = EulerAngles.from_array(origin_array[EulerStateSE3Index.EULER_ANGLES]).rotation_matrix - absolute_points = points_3d_array @ R.T + origin.point_3d.array + absolute_points = points_3d_array @ R.T + origin_array[EulerStateSE3Index.XYZ] return absolute_points diff --git a/src/py123d/geometry/utils/bounding_box_utils.py b/src/py123d/geometry/utils/bounding_box_utils.py index d31058bd..bedaba37 100644 --- a/src/py123d/geometry/utils/bounding_box_utils.py +++ b/src/py123d/geometry/utils/bounding_box_utils.py @@ -97,8 +97,7 @@ def corners_2d_array_to_polygon_array(corners_array: npt.NDArray[np.float64]) -> :param corners_array: Array of shape (..., 4, 2) where 4 is the number of corners. :return: Array of shapely Polygons. """ - polygons = shapely.creation.polygons(corners_array) - return polygons + return shapely.creation.polygons(corners_array) # type: ignore def bbse2_array_to_polygon_array(bbse2: npt.NDArray[np.float64]) -> npt.NDArray[np.object_]: @@ -144,6 +143,11 @@ def bbse3_array_to_corners_array(bbse3_array: npt.NDArray[np.float64]) -> npt.ND def corners_array_to_3d_mesh( corners_array: npt.NDArray[np.float64], ) -> Tuple[npt.NDArray[np.float64], npt.NDArray[np.int32]]: + """Creates a triangular mesh representation of boxes defined by their corner points. + + :param corners_array: An array of shape (..., 8, 3) representing the corners of the boxes. + :return: A tuple containing the vertices and faces of the mesh. + """ num_boxes = corners_array.shape[0] vertices = corners_array.reshape(-1, 3) @@ -180,6 +184,12 @@ def corners_array_to_3d_mesh( def corners_array_to_edge_lines(corners_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Creates line segments representing the edges of boxes defined by their corner points. + + :param corners_array: An array of shape (..., 8, 3) representing the corners of the boxes. + :return: An array of shape (..., 12, 2, 3) representing the edge lines of the boxes. + """ + assert corners_array.shape[-1] == len(Point3DIndex) assert corners_array.shape[-2] == len(Corners3DIndex) assert corners_array.ndim >= 2 diff --git a/src/py123d/geometry/utils/polyline_utils.py b/src/py123d/geometry/utils/polyline_utils.py index b721e274..9a65b3af 100644 --- a/src/py123d/geometry/utils/polyline_utils.py +++ b/src/py123d/geometry/utils/polyline_utils.py @@ -2,14 +2,14 @@ import numpy.typing as npt from shapely.geometry import LineString -from py123d.geometry.geometry_index import Point2DIndex, PoseSE2Index +from py123d.geometry.geometry_index import Point2DIndex, Point3DIndex, PoseSE2Index from py123d.geometry.transform.transform_se2 import translate_2d_along_body_frame def get_linestring_yaws(linestring: LineString) -> npt.NDArray[np.float64]: - """ - Compute the heading of each coordinate to its successor coordinate. The last coordinate will have the same heading - as the second last coordinate. + """Compute the heading of each coordinate to its successor coordinate. The last coordinate \ + will have the same heading as the second last coordinate. + :param linestring: linestring as a shapely LineString. :return: a list of headings associated to each starting coordinate. """ @@ -18,6 +18,12 @@ def get_linestring_yaws(linestring: LineString) -> npt.NDArray[np.float64]: def get_points_2d_yaws(points_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Compute the heading of each 2D point to its successor point. The last point \ + will have the same heading as the second last point. + + :param points_array: Array of shape (..., 2) representing 2D points. + :return: Array of shape (...,) representing the yaw angles of the points. + """ assert points_array.ndim == 2 assert points_array.shape[-1] == len(Point2DIndex) vectors = np.diff(points_array, axis=0) @@ -27,7 +33,13 @@ def get_points_2d_yaws(points_array: npt.NDArray[np.float64]) -> npt.NDArray[np. return yaws -def get_path_progress(points_array: npt.NDArray[np.float64]) -> list[float]: +def get_path_progress_2d(points_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Compute the cumulative path progress along a series of 2D points. + + :param points_array: Array of shape (..., 2) representing 2D points. + :raises ValueError: If the input points_array is not valid. + :return: Array of shape (...) representing the cumulative path progress. + """ if points_array.shape[-1] == len(Point2DIndex): x_diff = np.diff(points_array[..., Point2DIndex.X]) y_diff = np.diff(points_array[..., Point2DIndex.X]) @@ -41,10 +53,38 @@ def get_path_progress(points_array: npt.NDArray[np.float64]) -> list[float]: ) points_diff: npt.NDArray[np.float64] = np.concatenate(([x_diff], [y_diff]), axis=0, dtype=np.float64) progress_diff = np.append(0.0, np.linalg.norm(points_diff, axis=0)) - return np.cumsum(progress_diff, dtype=np.float64) # type: ignore + return np.cumsum(progress_diff, dtype=np.float64) + + +def get_path_progress_3d(points_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Compute the cumulative path progress along a series of 3D points. + + :param points_array: Array of shape (..., 3) representing 3D points. + :raises ValueError: If the input points_array is not valid. + :return: Array of shape (...) representing the cumulative path progress. + """ + if points_array.shape[-1] == len(Point3DIndex): + x_diff = np.diff(points_array[..., Point3DIndex.X]) + y_diff = np.diff(points_array[..., Point3DIndex.Y]) + z_diff = np.diff(points_array[..., Point3DIndex.Z]) + else: + raise ValueError( + f"Invalid points_array shape: {points_array.shape}. Expected last dimension to be {len(Point3DIndex)}." + ) + points_diff: npt.NDArray[np.float64] = np.concatenate(([x_diff], [y_diff], [z_diff]), axis=0, dtype=np.float64) + progress_diff = np.append(0.0, np.linalg.norm(points_diff, axis=0)) + return np.cumsum(progress_diff, dtype=np.float64) def offset_points_perpendicular(points_array: npt.NDArray[np.float64], offset: float) -> npt.NDArray[np.float64]: + """Offset 2D points or SE2 poses perpendicularly by a given offset. + + :param points_array: Array points of shape (..., 2) representing 2D points \ + or shape (..., 3) representing SE2 poses. + :param offset: Offset distance to apply perpendicularly. + :raises ValueError: If the input points_array is not valid. + :return: Array of shape (..., 2) representing the offset points. + """ if points_array.shape[-1] == len(Point2DIndex): xy = points_array[..., Point2DIndex.XY] yaws = get_points_2d_yaws(points_array[..., Point2DIndex.XY]) @@ -60,6 +100,6 @@ def offset_points_perpendicular(points_array: npt.NDArray[np.float64], offset: f return translate_2d_along_body_frame( points_2d=xy, yaws=yaws, - y_translate=offset, - x_translate=0.0, + y_translate=offset, # type: ignore + x_translate=0.0, # type: ignore ) diff --git a/src/py123d/geometry/utils/rotation_utils.py b/src/py123d/geometry/utils/rotation_utils.py index 2429ffda..08433f89 100644 --- a/src/py123d/geometry/utils/rotation_utils.py +++ b/src/py123d/geometry/utils/rotation_utils.py @@ -24,20 +24,20 @@ def batch_matmul(A: npt.NDArray[np.float64], B: npt.NDArray[np.float64]) -> npt. def normalize_angle(angle: Union[float, npt.NDArray[np.float64]]) -> Union[float, npt.NDArray[np.float64]]: - """ - Map a angle in range [-π, π] - :param angle: any angle as float or array of floats - :return: normalized angle or array of normalized angles + """Normalizes an angle or array of angles to the range [-pi, pi]. + + :param angle: Angle or array of angles in radians to normalize. + :return: Normalized angle or array of angles. """ return ((angle + np.pi) % (2 * np.pi)) - np.pi def get_rotation_matrices_from_euler_array(euler_angles_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: - """ - Convert Euler angles to rotation matrices using Tait-Bryan ZYX convention (yaw-pitch-roll). + """Convert Euler angles to rotation matrices using Tait-Bryan ZYX convention (yaw-pitch-roll). - Convention: Intrinsic rotations in order Z-Y-X (yaw, pitch, roll) - Equivalent to: R = R_z(yaw) @ R_y(pitch) @ R_x(roll) + :param euler_angles_array: Array of Euler angles of shape (..., 3), \ + indexed by :class:`~py123d.geometry.EulerAnglesIndex` + :return: Array of rotation matrices of shape (..., 3, 3) """ assert euler_angles_array.ndim >= 1 and euler_angles_array.shape[-1] == len(EulerAnglesIndex) @@ -128,11 +128,21 @@ def get_euler_array_from_rotation_matrices(rotation_matrices: npt.NDArray[np.flo def get_euler_array_from_rotation_matrix(rotation_matrix: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Convert a rotation matrix to Euler angles using Tait-Bryan ZYX convention (yaw-pitch-roll). + + :param rotation_matrix: Rotation matrix of shape (3, 3) + :return: Euler angles of shape (3,), indexed by :class:`~py123d.geometry.EulerAnglesIndex` + """ assert rotation_matrix.ndim == 2 and rotation_matrix.shape == (3, 3) return get_euler_array_from_rotation_matrices(rotation_matrix[None, ...])[0] def get_quaternion_array_from_rotation_matrices(rotation_matrices: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Convert rotation matrices to quaternions. + + :param rotation_matrices: Rotation matrices of shape (..., 3, 3) + :return: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + """ assert rotation_matrices.ndim >= 2 assert rotation_matrices.shape[-1] == rotation_matrices.shape[-2] == 3 # http://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/ @@ -198,11 +208,21 @@ def get_quaternion_array_from_rotation_matrices(rotation_matrices: npt.NDArray[n def get_quaternion_array_from_rotation_matrix(rotation_matrix: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Convert a rotation matrix to a quaternion. + + :param rotation_matrix: Rotation matrix of shape (3, 3) + :return: Quaternion of shape (4,), indexed by :class:`~py123d.geometry.QuaternionIndex`. + """ assert rotation_matrix.ndim == 2 and rotation_matrix.shape == (3, 3) return get_quaternion_array_from_rotation_matrices(rotation_matrix[None, ...])[0] def get_quaternion_array_from_euler_array(euler_angles: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Converts array of euler angles to array of quaternions. + + :param euler_angles: Euler angles of shape (..., 3), indexed by :class:`~py123d.geometry.EulerAnglesIndex` + :return: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + """ assert euler_angles.ndim >= 1 and euler_angles.shape[-1] == len(EulerAnglesIndex) # Store original shape for reshaping later @@ -245,15 +265,25 @@ def get_quaternion_array_from_euler_array(euler_angles: npt.NDArray[np.float64]) if len(original_shape) > 1: quaternions = quaternions.reshape(original_shape + (len(QuaternionIndex),)) - return normalize_quaternion_array(quaternions) + return normalize_quaternion_array(quaternions) # type: ignore def get_rotation_matrix_from_euler_array(euler_angles: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Convert Euler angles to rotation matrix using Tait-Bryan ZYX convention (yaw-pitch-roll). + + :param euler_angles: Euler angles of shape (3,), indexed by :class:`~py123d.geometry.EulerAnglesIndex` + :return: Rotation matrix of shape (3, 3) + """ assert euler_angles.ndim == 1 and euler_angles.shape[0] == len(EulerAnglesIndex) return get_rotation_matrices_from_euler_array(euler_angles[None, ...])[0] def get_rotation_matrices_from_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Convert array of quaternions to array of rotation matrices. + + :param quaternion_array: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Rotation matrices of shape (..., 3, 3) + """ assert quaternion_array.ndim >= 1 and quaternion_array.shape[-1] == len(QuaternionIndex) # Store original shape for reshaping later @@ -279,12 +309,21 @@ def get_rotation_matrices_from_quaternion_array(quaternion_array: npt.NDArray[np def get_rotation_matrix_from_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: - # TODO: Check if this function is necessary or batch-wise function is universally applicable + """Convert a quaternion to a rotation matrix. + + :param quaternion_array: Quaternion of shape (4,), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Rotation matrix of shape (3, 3) + """ assert quaternion_array.ndim == 1 and quaternion_array.shape[0] == len(QuaternionIndex) return get_rotation_matrices_from_quaternion_array(quaternion_array[None, :])[0] def get_euler_array_from_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Converts array of quaternions to array of euler angles. + + :param quaternion_array: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Euler angles of shape (..., 3), indexed by :class:`~py123d.geometry.EulerAnglesIndex` + """ assert quaternion_array.ndim >= 1 and quaternion_array.shape[-1] == len(QuaternionIndex) norm_quaternion = normalize_quaternion_array(quaternion_array) QW, QX, QY, QZ = ( @@ -311,11 +350,12 @@ def get_euler_array_from_quaternion_array(quaternion_array: npt.NDArray[np.float def conjugate_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: - """Computes the conjugate of an array of quaternions. - in the order [qw, qx, qy, qz]. - :param quaternion: Array of quaternions. - :return: Array of conjugated quaternions. + """Computes the conjugate of an array of quaternions, i.e. negating the vector part. + + :param quaternion_array: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Conjugated quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` """ + assert quaternion_array.ndim >= 1 assert quaternion_array.shape[-1] == len(QuaternionIndex) conjugated_quaternions = np.zeros_like(quaternion_array) @@ -325,10 +365,10 @@ def conjugate_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt def invert_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: - """Computes the inverse of an array of quaternions. - in the order [qw, qx, qy, qz]. - :param quaternion: Array of quaternions. - :return: Array of inverted quaternions. + """Computes the inverse of an array of quaternions, i.e. conjugate divided by norm squared. + + :param quaternion_array: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Inverted quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` """ assert quaternion_array.ndim >= 1 assert quaternion_array.shape[-1] == len(QuaternionIndex) @@ -340,10 +380,10 @@ def invert_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt.ND def normalize_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: - """Normalizes an array of quaternions. - in the order [qw, qx, qy, qz]. - :param quaternion: Array of quaternions. - :return: Array of normalized quaternions. + """Normalizes an array of quaternions to unit length. + + :param quaternion_array: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Normalized quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` """ assert quaternion_array.ndim >= 1 assert quaternion_array.shape[-1] == len(QuaternionIndex) @@ -354,11 +394,12 @@ def normalize_quaternion_array(quaternion_array: npt.NDArray[np.float64]) -> npt def multiply_quaternion_arrays(q1: npt.NDArray[np.float64], q2: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: - """Multiplies two arrays of quaternions element-wise. - in the order [qw, qx, qy, qz]. - :param q1: First array of quaternions. - :param q2: Second array of quaternions. - :return: Array of resulting quaternions after multiplication. + """Multiplies two arrays of quaternions. + + :param q1: First array of quaternions, indexed by :class:`~py123d.geometry.QuaternionIndex` in the last dim. + :param q2: Second array of quaternions, indexed by :class:`~py123d.geometry.QuaternionIndex` in the last dim. + :return: Array of resulting quaternions after multiplication, \ + indexed by :class:`~py123d.geometry.QuaternionIndex` in the last dim. """ assert q1.ndim >= 1 assert q2.ndim >= 1 @@ -392,9 +433,9 @@ def multiply_quaternion_arrays(q1: npt.NDArray[np.float64], q2: npt.NDArray[np.f def get_q_matrices(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: """Computes the Q matrices for an array of quaternions. - in the order [qw, qx, qy, qz]. - :param quaternion: Array of quaternions. - :return: Array of Q matrices. + + :param quaternion_array: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Array of Q matrices of shape (..., 4, 4) """ assert quaternion_array.ndim >= 1 assert quaternion_array.shape[-1] == len(QuaternionIndex) @@ -432,9 +473,9 @@ def get_q_matrices(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np. def get_q_bar_matrices(quaternion_array: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: """Computes the Q-bar matrices for an array of quaternions. - in the order [qw, qx, qy, qz]. - :param quaternion: Array of quaternions. - :return: Array of Q-bar matrices. + + :param quaternion_array: Quaternions of shape (..., 4), indexed by :class:`~py123d.geometry.QuaternionIndex` + :return: Array of Q-bar matrices of shape (..., 4, 4) """ assert quaternion_array.ndim >= 1 assert quaternion_array.shape[-1] == len(QuaternionIndex) diff --git a/src/py123d/geometry/vector.py b/src/py123d/geometry/vector.py index 2706f4f2..dba85431 100644 --- a/src/py123d/geometry/vector.py +++ b/src/py123d/geometry/vector.py @@ -14,6 +14,7 @@ class Vector2D(ArrayMixin): Class to represents 2D vectors, in x, y direction. Example: + >>> from py123d.geometry import Vector2D >>> v1 = Vector2D(3.0, 4.0) >>> v2 = Vector2D(1.0, 2.0) >>> v3 = v1 + v2 @@ -25,6 +26,7 @@ class Vector2D(ArrayMixin): 5.0 """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float): @@ -139,6 +141,7 @@ class Vector3D(ArrayMixin): Class to represents 3D vectors, in x, y, z direction. Example: + >>> from py123d.geometry import Vector3D >>> v1 = Vector3D(1.0, 2.0, 3.0) >>> v2 = Vector3D(4.0, 5.0, 6.0) >>> v3 = v1 + v2 @@ -150,6 +153,7 @@ class Vector3D(ArrayMixin): 3.7416573867739413 """ + __slots__ = ("_array",) _array: npt.NDArray[np.float64] def __init__(self, x: float, y: float, z: float): diff --git a/src/py123d/visualization/matplotlib/camera.py b/src/py123d/visualization/matplotlib/camera.py index 91c363b9..a69a1413 100644 --- a/src/py123d/visualization/matplotlib/camera.py +++ b/src/py123d/visualization/matplotlib/camera.py @@ -87,8 +87,8 @@ def add_box_detections_to_camera_ax( box_detection_array[idx] = box_detection.bounding_box_se3.array # FIXME - box_detection_array[..., BoundingBoxSE3Index.POSE_SE3] = convert_absolute_to_relative_se3_array( - ego_state_se3.rear_axle_se3, box_detection_array[..., BoundingBoxSE3Index.POSE_SE3] + box_detection_array[..., BoundingBoxSE3Index.SE3] = convert_absolute_to_relative_se3_array( + ego_state_se3.rear_axle_se3, box_detection_array[..., BoundingBoxSE3Index.SE3] ) # box_detection_array[..., BoundingBoxSE3Index.XYZ] -= ego_state_se3.rear_axle_se3.point_3d.array detection_positions, detection_extents, detection_yaws = _transform_annotations_to_camera( diff --git a/tests/unit/datatypes/detections/test_box_detections.py b/tests/unit/datatypes/detections/test_box_detections.py index 52efe229..02c30501 100644 --- a/tests/unit/datatypes/detections/test_box_detections.py +++ b/tests/unit/datatypes/detections/test_box_detections.py @@ -98,7 +98,7 @@ class TestBoxDetectionSE2(unittest.TestCase): def setUp(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se2 = BoundingBoxSE2( - center=PoseSE2(x=0.0, y=0.0, yaw=0.0), + center_se2=PoseSE2(x=0.0, y=0.0, yaw=0.0), length=4.0, width=2.0, ) @@ -122,7 +122,7 @@ def test_properties(self): velocity_2d=self.velocity, ) self.assertEqual(box_detection.shapely_polygon, self.bounding_box_se2.shapely_polygon) - self.assertEqual(box_detection.center_se2, self.bounding_box_se2.center) + self.assertEqual(box_detection.center_se2, self.bounding_box_se2.center_se2) self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se2) def test_optional_velocity(self): @@ -147,7 +147,7 @@ class TestBoxBoxDetectionSE3(unittest.TestCase): def setUp(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se3 = BoundingBoxSE3( - center=PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), + center_se3=PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), length=4.0, width=2.0, height=1.5, @@ -200,12 +200,12 @@ def test_box_detection_se3_conversion(self): box_detection_se3 = BoxDetectionSE3( metadata=box_detection_se2.metadata, bounding_box_se3=self.bounding_box_se3, - velocity=Vector2D(x=1.0, y=0.0), + velocity=Vector3D(x=1.0, y=0.0, z=0.0), ) self.assertIsInstance(box_detection_se3, BoxDetectionSE3) self.assertEqual(box_detection_se3.metadata, box_detection_se2.metadata) self.assertEqual(box_detection_se3.bounding_box_se3, self.bounding_box_se3) - self.assertEqual(box_detection_se3.velocity_3d, Vector2D(x=1.0, y=0.0)) + self.assertEqual(box_detection_se3.velocity_2d, Vector2D(x=1.0, y=0.0)) box_detection_se3_converted = box_detection_se3.box_detection_se2 self.assertIsInstance(box_detection_se3_converted, BoxDetectionSE2) @@ -255,7 +255,7 @@ def setUp(self): self.box_detection1 = BoxDetectionSE2( metadata=self.metadata1, bounding_box_se2=BoundingBoxSE2( - center=PoseSE2(x=0.0, y=0.0, yaw=0.0), + center_se2=PoseSE2(x=0.0, y=0.0, yaw=0.0), length=4.0, width=2.0, ), @@ -264,7 +264,7 @@ def setUp(self): self.box_detection2 = BoxDetectionSE2( metadata=self.metadata2, bounding_box_se2=BoundingBoxSE2( - center=PoseSE2(x=5.0, y=5.0, yaw=0.0), + center_se2=PoseSE2(x=5.0, y=5.0, yaw=0.0), length=1.0, width=0.5, ), @@ -273,7 +273,7 @@ def setUp(self): self.box_detection3 = BoxDetectionSE3( metadata=self.metadata3, bounding_box_se3=BoundingBoxSE3( - center=PoseSE3(x=10.0, y=10.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), + center_se3=PoseSE3(x=10.0, y=10.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), length=2.0, width=1.0, height=1.5, diff --git a/tests/unit/datatypes/map_objects/test_base_map_objects.py b/tests/unit/datatypes/map_objects/test_base_map_objects.py index 01e5e28e..8d02a727 100644 --- a/tests/unit/datatypes/map_objects/test_base_map_objects.py +++ b/tests/unit/datatypes/map_objects/test_base_map_objects.py @@ -150,7 +150,7 @@ class TestBaseMapLineObject(unittest.TestCase): def test_init_with_polyline2d(self): coords = np.array([[0, 0], [1, 1], [2, 2]]) - polyline = Polyline2D(coords) + polyline = Polyline2D.from_array(coords) obj = ConcreteMapLineObject("line_1", polyline) assert obj.object_id == "line_1" assert isinstance(obj.polyline, Polyline2D) @@ -163,9 +163,9 @@ def test_init_with_polyline3d(self): def test_polyline_property(self): coords = np.array([[0, 0], [1, 1], [2, 2]]) - polyline = Polyline2D(coords) + polyline = Polyline2D.from_array(coords) obj = ConcreteMapLineObject("line_3", polyline) - assert obj.polyline is polyline + assert obj.polyline == polyline def test_polyline_2d_from_2d_polyline(self): coords = np.array([[0, 0], [1, 1], [2, 2]]) diff --git a/tests/unit/geometry/test_bounding_box.py b/tests/unit/geometry/test_bounding_box.py index 7aba0821..df157211 100644 --- a/tests/unit/geometry/test_bounding_box.py +++ b/tests/unit/geometry/test_bounding_box.py @@ -29,7 +29,7 @@ def test_init(self): bbox = BoundingBoxSE2(self.center, self.length, self.width) self.assertEqual(bbox.length, self.length) self.assertEqual(bbox.width, self.width) - np.testing.assert_array_equal(bbox.center.array, self.center.array) + np.testing.assert_array_equal(bbox.center_se2.array, self.center.array) def test_from_array(self): """Test BoundingBoxSE2.from_array method.""" @@ -51,7 +51,6 @@ def test_properties(self): """Test BoundingBoxSE2 properties.""" self.assertEqual(self.bbox.length, self.length) self.assertEqual(self.bbox.width, self.width) - np.testing.assert_array_equal(self.bbox.center.array, self.center.array) np.testing.assert_array_equal(self.bbox.center_se2.array, self.center.array) def test_array_property(self): @@ -110,19 +109,19 @@ class TestBoundingBoxSE3(unittest.TestCase): def setUp(self): """Set up test fixtures.""" self.array = np.array([1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393, 4.0, 2.0, 1.5]) - self.center = PoseSE3(1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393) + self.center_se3 = PoseSE3(1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393) self.length = 4.0 self.width = 2.0 self.height = 1.5 - self.bbox = BoundingBoxSE3(self.center, self.length, self.width, self.height) + self.bbox = BoundingBoxSE3(self.center_se3, self.length, self.width, self.height) def test_init(self): """Test BoundingBoxSE3 initialization.""" - bbox = BoundingBoxSE3(self.center, self.length, self.width, self.height) + bbox = BoundingBoxSE3(self.center_se3, self.length, self.width, self.height) self.assertEqual(bbox.length, self.length) self.assertEqual(bbox.width, self.width) self.assertEqual(bbox.height, self.height) - np.testing.assert_array_equal(bbox.center.array, self.center.array) + np.testing.assert_array_equal(bbox.center_se3.array, self.center_se3.array) def test_from_array(self): """Test BoundingBoxSE3.from_array method.""" @@ -145,8 +144,7 @@ def test_properties(self): self.assertEqual(self.bbox.length, self.length) self.assertEqual(self.bbox.width, self.width) self.assertEqual(self.bbox.height, self.height) - np.testing.assert_array_equal(self.bbox.center.array, self.center.array) - np.testing.assert_array_equal(self.bbox.center_se3.array, self.center.array) + np.testing.assert_array_equal(self.bbox.center_se3.array, self.center_se3.array) def test_array_property(self): """Test array property.""" @@ -169,9 +167,9 @@ def test_bounding_box_se2_property(self): self.assertIsInstance(bbox_2d, BoundingBoxSE2) self.assertEqual(bbox_2d.length, self.length) self.assertEqual(bbox_2d.width, self.width) - self.assertEqual(bbox_2d.center.x, self.center.x) - self.assertEqual(bbox_2d.center.y, self.center.y) - self.assertEqual(bbox_2d.center.yaw, self.center.euler_angles.yaw) + self.assertEqual(bbox_2d.center_se2.x, self.center_se3.x) + self.assertEqual(bbox_2d.center_se2.y, self.center_se3.y) + self.assertEqual(bbox_2d.center_se2.yaw, self.center_se3.euler_angles.yaw) def test_corners_array(self): """Test corners_array property.""" diff --git a/tests/unit/geometry/test_point.py b/tests/unit/geometry/test_point.py index 15932540..fc68a0e2 100644 --- a/tests/unit/geometry/test_point.py +++ b/tests/unit/geometry/test_point.py @@ -92,12 +92,6 @@ def test_iter(self): self.assertEqual(x, self.x_coord) self.assertEqual(y, self.y_coord) - def test_hash(self): - """Test the __hash__ method.""" - point_dict = {self.point: "test"} - self.assertIn(self.point, point_dict) - self.assertEqual(point_dict[self.point], "test") - class TestPoint3D(unittest.TestCase): """Unit tests for Point3D class.""" @@ -189,12 +183,6 @@ def test_iter(self): self.assertEqual(y, self.y_coord) self.assertEqual(z, self.z_coord) - def test_hash(self): - """Test the __hash__ method.""" - point_dict = {self.point: "test"} - self.assertIn(self.point, point_dict) - self.assertEqual(point_dict[self.point], "test") - if __name__ == "__main__": unittest.main() diff --git a/tests/unit/geometry/test_polyline.py b/tests/unit/geometry/test_polyline.py index c001d150..449d351a 100644 --- a/tests/unit/geometry/test_polyline.py +++ b/tests/unit/geometry/test_polyline.py @@ -152,13 +152,6 @@ def test_from_array_invalid_shape(self): with self.assertRaises(ValueError): PolylineSE2.from_array(array) - def test_from_discrete_se2(self): - """Test creating PolylineSE2 from discrete SE2 states.""" - states = [PoseSE2(0.0, 0.0, 0.0), PoseSE2(1.0, 0.0, 0.0), PoseSE2(2.0, 0.0, 0.0)] - polyline = PolylineSE2.from_discrete_se2(states) - self.assertIsInstance(polyline, PolylineSE2) - self.assertEqual(polyline.array.shape, (3, 3)) - def test_length_property(self): """Test length property.""" array = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) @@ -278,12 +271,22 @@ def test_length_property(self): polyline = Polyline3D.from_linestring(linestring) self.assertEqual(polyline.length, 2.0) + coords = [(0.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 0.0, 2.0)] + linestring = geom.LineString(coords) + polyline = Polyline3D.from_linestring(linestring) + self.assertEqual(polyline.length, 2.0) + + coords = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (2.0, 2.0, 2.0)] + linestring = geom.LineString(coords) + polyline = Polyline3D.from_linestring(linestring) + self.assertEqual(polyline.length, 2 * np.sqrt(3)) + def test_interpolate_single_distance(self): """Test interpolation with single distance.""" coords = [(0.0, 0.0, 0.0), (2.0, 0.0, 2.0)] linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) - point = polyline.interpolate(1.0) + point = polyline.interpolate(np.sqrt(2)) self.assertIsInstance(point, Point3D) self.assertEqual(point.x, 1.0) self.assertEqual(point.y, 0.0) diff --git a/tests/unit/geometry/test_rotation.py b/tests/unit/geometry/test_rotation.py index 66f51c49..1e226fd7 100644 --- a/tests/unit/geometry/test_rotation.py +++ b/tests/unit/geometry/test_rotation.py @@ -75,20 +75,6 @@ def test_array_property(self): self.assertEqual(array[EulerAnglesIndex.PITCH], self.pitch) self.assertEqual(array[EulerAnglesIndex.YAW], self.yaw) - def test_iterator(self): - """Test iterator functionality.""" - values = list(self.euler_angles) - self.assertEqual(values, [self.roll, self.pitch, self.yaw]) - - def test_hash(self): - """Test hash functionality.""" - euler1 = EulerAngles(0.1, 0.2, 0.3) - euler2 = EulerAngles(0.1, 0.2, 0.3) - euler3 = EulerAngles(0.1, 0.2, 0.4) - - self.assertEqual(hash(euler1), hash(euler2)) - self.assertNotEqual(hash(euler1), hash(euler3)) - class TestQuaternion(unittest.TestCase): """Unit tests for Quaternion class.""" @@ -192,20 +178,6 @@ def test_rotation_matrix_property(self): self.assertEqual(rot_matrix.shape, (3, 3)) np.testing.assert_array_almost_equal(rot_matrix, np.eye(3)) - def test_iterator(self): - """Test iterator functionality.""" - values = list(self.quaternion) - self.assertEqual(values, [self.qw, self.qx, self.qy, self.qz]) - - def test_hash(self): - """Test hash functionality.""" - quat1 = Quaternion(1.0, 0.0, 0.0, 0.0) - quat2 = Quaternion(1.0, 0.0, 0.0, 0.0) - quat3 = Quaternion(0.0, 1.0, 0.0, 0.0) - - self.assertEqual(hash(quat1), hash(quat2)) - self.assertNotEqual(hash(quat1), hash(quat3)) - if __name__ == "__main__": unittest.main() diff --git a/tests/unit/geometry/utils/test_bounding_box_utils.py b/tests/unit/geometry/utils/test_bounding_box_utils.py index 4e160087..9b912b19 100644 --- a/tests/unit/geometry/utils/test_bounding_box_utils.py +++ b/tests/unit/geometry/utils/test_bounding_box_utils.py @@ -205,7 +205,7 @@ def test_bbse3_array_to_corners_array_one_dim_rotation(self): bounding_box_se3_array = np.zeros((len(BoundingBoxSE3Index),), dtype=np.float64) length, width, height = np.random.uniform(0.0, self._max_extent, size=3) - bounding_box_se3_array[BoundingBoxSE3Index.POSE_SE3] = se3_array + bounding_box_se3_array[BoundingBoxSE3Index.SE3] = se3_array bounding_box_se3_array[BoundingBoxSE3Index.LENGTH] = length bounding_box_se3_array[BoundingBoxSE3Index.WIDTH] = width bounding_box_se3_array[BoundingBoxSE3Index.HEIGHT] = height @@ -233,7 +233,7 @@ def test_bbse3_array_to_corners_array_n_dim(self): bounding_box_se3_array = np.zeros((N, len(BoundingBoxSE3Index)), dtype=np.float64) lengths, widths, heights = np.random.uniform(0.0, self._max_extent, size=(3, N)) - bounding_box_se3_array[:, BoundingBoxSE3Index.POSE_SE3] = se3_state_array + bounding_box_se3_array[:, BoundingBoxSE3Index.SE3] = se3_state_array bounding_box_se3_array[:, BoundingBoxSE3Index.LENGTH] = lengths bounding_box_se3_array[:, BoundingBoxSE3Index.WIDTH] = widths bounding_box_se3_array[:, BoundingBoxSE3Index.HEIGHT] = heights @@ -249,7 +249,7 @@ def test_bbse3_array_to_corners_array_n_dim(self): np.testing.assert_allclose( corners_array[obj_idx, corner_idx], translate_se3_along_body_frame( - PoseSE3.from_array(bounding_box_se3_array[obj_idx, BoundingBoxSE3Index.POSE_SE3]), + PoseSE3.from_array(bounding_box_se3_array[obj_idx, BoundingBoxSE3Index.SE3]), body_translate_vector, ).point_3d.array, atol=1e-6, From 130064e1b2049b80b03cd64aa367931d75cf0fd5 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Thu, 13 Nov 2025 21:23:22 +0100 Subject: [PATCH 23/50] Add docstrings, review chode, cleaning up. --- src/py123d/api/map/gpkg/gpkg_utils.py | 7 +- src/py123d/api/scene/arrow/arrow_scene.py | 43 ++++-- .../api/scene/arrow/arrow_scene_builder.py | 38 ++++-- .../api/scene/arrow/utils/arrow_getters.py | 129 +++++++++++++++--- .../scene/arrow/utils/arrow_metadata_utils.py | 5 +- src/py123d/api/scene/scene_api.py | 1 + src/py123d/api/scene/scene_builder.py | 12 +- src/py123d/api/scene/scene_filter.py | 32 ++++- src/py123d/api/scene/scene_metadata.py | 13 ++ .../common/multithreading/ray_execution.py | 21 +-- .../common/multithreading/worker_parallel.py | 5 + .../common/multithreading/worker_pool.py | 5 + .../common/multithreading/worker_ray.py | 5 + .../multithreading/worker_sequential.py | 5 + .../common/multithreading/worker_utils.py | 5 + src/py123d/common/utils/arrow_helper.py | 17 ++- src/py123d/common/utils/enums.py | 9 +- src/py123d/common/utils/mixin.py | 34 ++++- src/py123d/common/utils/timer.py | 49 ++++--- src/py123d/common/utils/uuid_utils.py | 4 +- .../datasets/av2/av2_map_conversion.py | 90 ++++++++---- .../datasets/av2/av2_sensor_converter.py | 60 ++++---- .../conversion/datasets/av2/av2_sensor_io.py | 2 + .../datasets/av2/utils/av2_constants.py | 10 +- .../datasets/av2/utils/av2_helper.py | 9 ++ .../datasets/kitti360/kitti360_converter.py | 68 +++++---- .../kitti360/kitti360_map_conversion.py | 7 +- .../datasets/kitti360/kitti360_sensor_io.py | 2 + .../datasets/nuplan/nuplan_converter.py | 102 +++++++------- .../datasets/nuplan/nuplan_map_conversion.py | 23 +++- .../datasets/nuplan/nuplan_sensor_io.py | 6 +- .../datasets/nuplan/utils/nuplan_constants.py | 10 +- .../nuplan/utils/nuplan_sql_helper.py | 7 +- .../datasets/nuscenes/nuscenes_converter.py | 47 +++---- .../nuscenes/nuscenes_map_conversion.py | 20 +-- .../datasets/nuscenes/nuscenes_sensor_io.py | 2 + .../datasets/pandaset/pandaset_converter.py | 89 ++++++------ .../datasets/pandaset/pandaset_sensor_io.py | 8 +- .../datasets/pandaset/utils/pandaset_utlis.py | 5 +- .../datasets/wopd/wopd_converter.py | 45 +++--- .../datasets/wopd/wopd_sensor_io.py | 5 +- .../log_writer/abstract_log_writer.py | 26 +++- .../conversion/log_writer/arrow_log_writer.py | 37 +++++ .../conversion/map_writer/utils/gpkg_utils.py | 21 +-- .../registry/box_detection_label_registry.py | 15 +- .../registry/lidar_index_registry.py | 24 +++- .../sensor_io/camera/jpeg_camera_io.py | 4 + .../sensor_io/camera/mp4_camera_io.py | 50 +++---- .../sensor_io/camera/png_camera_io.py | 4 + .../sensor_io/lidar/file_lidar_io.py | 14 +- .../opendrive/opendrive_map_conversion.py | 20 +-- .../datatypes/map_objects/map_objects.py | 10 +- src/py123d/geometry/polyline.py | 2 +- 53 files changed, 850 insertions(+), 433 deletions(-) diff --git a/src/py123d/api/map/gpkg/gpkg_utils.py b/src/py123d/api/map/gpkg/gpkg_utils.py index b3b95eec..e165fc3c 100644 --- a/src/py123d/api/map/gpkg/gpkg_utils.py +++ b/src/py123d/api/map/gpkg/gpkg_utils.py @@ -7,7 +7,12 @@ def load_gdf_with_geometry_columns(gdf: gpd.GeoDataFrame, geometry_column_names: List[str] = []): - # TODO: refactor + """Convert geometry columns stored as wkt back to shapely geometries. + + :param gdf: input GeoDataFrame. + :param geometry_column_names: List of geometry column names to convert, defaults to [] + """ + # Convert string geometry columns back to shapely objects for col in geometry_column_names: if col in gdf.columns and len(gdf) > 0 and isinstance(gdf[col].iloc[0], str): diff --git a/src/py123d/api/scene/arrow/arrow_scene.py b/src/py123d/api/scene/arrow/arrow_scene.py index c1cf80c5..1472c907 100644 --- a/src/py123d/api/scene/arrow/arrow_scene.py +++ b/src/py123d/api/scene/arrow/arrow_scene.py @@ -30,6 +30,7 @@ def _get_complete_log_scene_metadata(arrow_file_path: Union[Path, str], log_metadata: LogMetadata) -> SceneMetadata: + """Helper function to get the scene metadata for a complete log of an Arrow file.""" table = get_lru_cached_arrow_table(arrow_file_path) initial_uuid = table[UUID_COLUMN][0].as_py() num_rows = table.num_rows @@ -43,18 +44,24 @@ def _get_complete_log_scene_metadata(arrow_file_path: Union[Path, str], log_meta class ArrowSceneAPI(SceneAPI): + """Scene API for Arrow-based scenes. Provides access to all data modalities in an Arrow scene.""" def __init__( self, arrow_file_path: Union[Path, str], - scene_extraction_metadata: Optional[SceneMetadata] = None, + scene_metadata: Optional[SceneMetadata] = None, ) -> None: + """Initializes the :class:`ArrowSceneAPI`. + + :param arrow_file_path: Path to the Arrow file. + :param scene_metadata: Scene metadata, defaults to None + """ self._arrow_file_path: Path = Path(arrow_file_path) self._log_metadata: LogMetadata = get_log_metadata_from_arrow_file(str(arrow_file_path)) - self._scene_extraction_metadata: SceneMetadata = ( - scene_extraction_metadata - if scene_extraction_metadata is not None + self._scene_metadata: SceneMetadata = ( + scene_metadata + if scene_metadata is not None else _get_complete_log_scene_metadata(arrow_file_path, self._log_metadata) ) @@ -62,9 +69,8 @@ def __init__( # Global maps are LRU cached internally. self._local_map_api: Optional[MapAPI] = None - #################################################################################################################### - # Helpers for ArrowScene - #################################################################################################################### + # Helper methods + # ------------------------------------------------------------------------------------------------------------------ def __reduce__(self): """Helper for pickling the object.""" @@ -72,7 +78,7 @@ def __reduce__(self): self.__class__, ( self._arrow_file_path, - self._scene_extraction_metadata, + self._scene_metadata, ), ) @@ -81,21 +87,24 @@ def _get_recording_table(self) -> pa.Table: return get_lru_cached_arrow_table(self._arrow_file_path) def _get_table_index(self, iteration: int) -> int: + """Helper function to get the table index for a given iteration.""" assert -self.number_of_history_iterations <= iteration < self.number_of_iterations, "Iteration out of bounds" - table_index = self._scene_extraction_metadata.initial_idx + iteration + table_index = self._scene_metadata.initial_idx + iteration return table_index - #################################################################################################################### - # Implementation of AbstractScene - #################################################################################################################### + # Implementation of abstract methods + # ------------------------------------------------------------------------------------------------------------------ def get_log_metadata(self) -> LogMetadata: + """Inherited, see superclass.""" return self._log_metadata def get_scene_metadata(self) -> SceneMetadata: - return self._scene_extraction_metadata + """Inherited, see superclass.""" + return self._scene_metadata def get_map_api(self) -> Optional[MapAPI]: + """Inherited, see superclass.""" map_api: Optional[MapAPI] = None if self.log_metadata.map_metadata is not None: if self.log_metadata.map_metadata.map_is_local: @@ -109,9 +118,11 @@ def get_map_api(self) -> Optional[MapAPI]: return map_api def get_timepoint_at_iteration(self, iteration: int) -> TimePoint: + """Inherited, see superclass.""" return get_timepoint_from_arrow_table(self._get_recording_table(), self._get_table_index(iteration)) def get_ego_state_at_iteration(self, iteration: int) -> Optional[EgoStateSE3]: + """Inherited, see superclass.""" return get_ego_state_se3_from_arrow_table( self._get_recording_table(), self._get_table_index(iteration), @@ -119,6 +130,7 @@ def get_ego_state_at_iteration(self, iteration: int) -> Optional[EgoStateSE3]: ) def get_box_detections_at_iteration(self, iteration: int) -> Optional[BoxDetectionWrapper]: + """Inherited, see superclass.""" return get_box_detections_se3_from_arrow_table( self._get_recording_table(), self._get_table_index(iteration), @@ -126,16 +138,19 @@ def get_box_detections_at_iteration(self, iteration: int) -> Optional[BoxDetecti ) def get_traffic_light_detections_at_iteration(self, iteration: int) -> Optional[TrafficLightDetectionWrapper]: + """Inherited, see superclass.""" return get_traffic_light_detections_from_arrow_table( self._get_recording_table(), self._get_table_index(iteration) ) def get_route_lane_group_ids(self, iteration: int) -> Optional[List[int]]: + """Inherited, see superclass.""" return get_route_lane_group_ids_from_arrow_table(self._get_recording_table(), self._get_table_index(iteration)) def get_pinhole_camera_at_iteration( self, iteration: int, camera_type: PinholeCameraType ) -> Optional[PinholeCamera]: + """Inherited, see superclass.""" pinhole_camera: Optional[PinholeCamera] = None if camera_type in self.available_pinhole_camera_types: pinhole_camera = get_camera_from_arrow_table( @@ -149,6 +164,7 @@ def get_pinhole_camera_at_iteration( def get_fisheye_mei_camera_at_iteration( self, iteration: int, camera_type: FisheyeMEICameraType ) -> Optional[FisheyeMEICamera]: + """Inherited, see superclass.""" fisheye_mei_camera: Optional[FisheyeMEICamera] = None if camera_type in self.available_fisheye_mei_camera_types: fisheye_mei_camera = get_camera_from_arrow_table( @@ -160,6 +176,7 @@ def get_fisheye_mei_camera_at_iteration( return fisheye_mei_camera def get_lidar_at_iteration(self, iteration: int, lidar_type: LiDARType) -> Optional[LiDAR]: + """Inherited, see superclass.""" lidar: Optional[LiDAR] = None if lidar_type in self.available_lidar_types or lidar_type == LiDARType.LIDAR_MERGED: lidar = get_lidar_from_arrow_table( diff --git a/src/py123d/api/scene/arrow/arrow_scene_builder.py b/src/py123d/api/scene/arrow/arrow_scene_builder.py index 3cc5eb88..cccac275 100644 --- a/src/py123d/api/scene/arrow/arrow_scene_builder.py +++ b/src/py123d/api/scene/arrow/arrow_scene_builder.py @@ -1,7 +1,7 @@ import random from functools import partial from pathlib import Path -from typing import Iterator, List, Optional, Set, Union +from typing import List, Optional, Set, Union from py123d.api.scene.arrow.arrow_scene import ArrowSceneAPI from py123d.api.scene.arrow.utils.arrow_metadata_utils import get_log_metadata_from_arrow_table @@ -16,15 +16,18 @@ class ArrowSceneBuilder(SceneBuilder): - """ - A class to build a scene from a dataset. - """ + """Class for building scenes from Arrow log files.""" def __init__( self, logs_root: Optional[Union[str, Path]] = None, maps_root: Optional[Union[str, Path]] = None, ): + """Initializes the ArrowSceneBuilder. + + :param logs_root: The root directory fo log files, defaults to None + :param maps_root: The root directory for map files, defaults to None + """ if logs_root is None: logs_root = get_dataset_paths().py123d_logs_root if maps_root is None: @@ -33,8 +36,8 @@ def __init__( self._logs_root = Path(logs_root) self._maps_root = Path(maps_root) - def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[SceneAPI]: - """See superclass.""" + def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> List[SceneAPI]: + """Inherited, see superclass.""" split_types = set(filter.split_types) if filter.split_types else {"train", "val", "test"} split_names = ( @@ -56,6 +59,7 @@ def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[SceneA def _discover_split_names(logs_root: Path, split_types: Set[str]) -> Set[str]: + """Discovers split names in the logs root directory based on the specified split types.""" assert set(split_types).issubset( {"train", "val", "test"} ), f"Invalid split types: {split_types}. Valid split types are 'train', 'val', 'test'." @@ -70,6 +74,7 @@ def _discover_split_names(logs_root: Path, split_types: Set[str]) -> Set[str]: def _discover_log_paths(logs_root: Path, split_names: Set[str], log_names: Optional[List[str]]) -> List[Path]: + """Discovers log file paths in the logs root directory based on the specified split names and log names.""" log_paths: List[Path] = [] for split_name in split_names: for log_path in (logs_root / split_name).iterdir(): @@ -80,6 +85,7 @@ def _discover_log_paths(logs_root: Path, split_names: Set[str], log_names: Optio def _extract_scenes_from_logs(log_paths: List[Path], filter: SceneFilter) -> List[SceneAPI]: + """Extracts scenes from log files based on the given filter.""" scenes: List[SceneAPI] = [] for log_path in log_paths: try: @@ -91,14 +97,15 @@ def _extract_scenes_from_logs(log_paths: List[Path], filter: SceneFilter) -> Lis scenes.append( ArrowSceneAPI( arrow_file_path=log_path, - scene_extraction_metadata=scene_extraction_metadata, + scene_metadata=scene_extraction_metadata, ) ) return scenes def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFilter) -> List[SceneMetadata]: - scene_extraction_metadatas: List[SceneMetadata] = [] + """Gets the scene metadatas from a log file based on the given filter.""" + scene_metadatas: List[SceneMetadata] = [] recording_table = get_lru_cached_arrow_table(str(log_path)) log_metadata = get_log_metadata_from_arrow_table(recording_table) @@ -118,7 +125,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil pass elif filter.duration_s is None: - scene_extraction_metadatas.append( + scene_metadatas.append( SceneMetadata( initial_uuid=str(recording_table[UUID_COLUMN][start_idx].as_py()), initial_idx=start_idx, @@ -131,6 +138,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil scene_uuid_set = set(filter.scene_uuids) if filter.scene_uuids is not None else None step_idx = int(filter.duration_s / log_metadata.timestep_seconds) all_row_uuids = recording_table[UUID_COLUMN].to_pylist() + history_s = filter.history_s if filter.history_s is not None else 0.0 for idx in range(start_idx, end_idx, step_idx): scene_extraction_metadata: Optional[SceneMetadata] = None @@ -141,7 +149,7 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil initial_uuid=current_uuid, initial_idx=idx, duration_s=filter.duration_s, - history_s=filter.history_s, + history_s=history_s, iteration_duration_s=log_metadata.timestep_seconds, ) elif current_uuid in scene_uuid_set: @@ -149,21 +157,21 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil initial_uuid=current_uuid, initial_idx=idx, duration_s=filter.duration_s, - history_s=filter.history_s, + history_s=history_s, iteration_duration_s=log_metadata.timestep_seconds, ) if scene_extraction_metadata is not None: # Check of timestamp threshold exceeded between previous scene, if specified in filter - if filter.timestamp_threshold_s is not None and len(scene_extraction_metadatas) > 0: - iteration_delta = idx - scene_extraction_metadatas[-1].initial_idx + if filter.timestamp_threshold_s is not None and len(scene_metadatas) > 0: + iteration_delta = idx - scene_metadatas[-1].initial_idx if (iteration_delta * log_metadata.timestep_seconds) < filter.timestamp_threshold_s: continue - scene_extraction_metadatas.append(scene_extraction_metadata) + scene_metadatas.append(scene_extraction_metadata) scene_extraction_metadatas_ = [] - for scene_extraction_metadata in scene_extraction_metadatas: + for scene_extraction_metadata in scene_metadatas: add_scene = True start_idx = scene_extraction_metadata.initial_idx diff --git a/src/py123d/api/scene/arrow/utils/arrow_getters.py b/src/py123d/api/scene/arrow/utils/arrow_getters.py index a195d46b..e56204a0 100644 --- a/src/py123d/api/scene/arrow/utils/arrow_getters.py +++ b/src/py123d/api/scene/arrow/utils/arrow_getters.py @@ -29,7 +29,7 @@ TRAFFIC_LIGHTS_STATUS_COLUMN, ) from py123d.common.utils.mixin import ArrayMixin -from py123d.conversion.registry.lidar_index_registry import DefaultLiDARIndex +from py123d.conversion.registry import DefaultLiDARIndex from py123d.conversion.sensor_io.camera.jpeg_camera_io import ( decode_image_from_jpeg_binary, is_jpeg_binary, @@ -40,24 +40,26 @@ from py123d.conversion.sensor_io.lidar.draco_lidar_io import is_draco_binary, load_lidar_from_draco_binary from py123d.conversion.sensor_io.lidar.file_lidar_io import load_lidar_pcs_from_file from py123d.conversion.sensor_io.lidar.laz_lidar_io import is_laz_binary, load_lidar_from_laz_binary -from py123d.datatypes.detections.box_detections import ( +from py123d.datatypes.detections import ( BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper, -) -from py123d.datatypes.detections.traffic_light_detections import ( TrafficLightDetection, TrafficLightDetectionWrapper, TrafficLightStatus, ) -from py123d.datatypes.metadata.log_metadata import LogMetadata -from py123d.datatypes.sensors.fisheye_mei_camera import FisheyeMEICamera, FisheyeMEICameraType -from py123d.datatypes.sensors.lidar import LiDAR, LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeCameraType -from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.dynamic_state import DynamicStateSE3 -from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters +from py123d.datatypes.metadata import LogMetadata +from py123d.datatypes.sensors import ( + FisheyeMEICamera, + FisheyeMEICameraType, + LiDAR, + LiDARMetadata, + LiDARType, + PinholeCamera, + PinholeCameraType, +) +from py123d.datatypes.time import TimePoint +from py123d.datatypes.vehicle_state import DynamicStateSE3, EgoStateSE3, VehicleParameters from py123d.geometry import BoundingBoxSE3, PoseSE3, Vector3D from py123d.script.utils.dataset_path_utils import get_dataset_paths @@ -73,6 +75,12 @@ def get_timepoint_from_arrow_table(arrow_table: pa.Table, index: int) -> TimePoint: + """Builds a :class:`~py123d.datatypes.time.TimePoint` from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the timepoint data. + :param index: The index to extract the timepoint from. + :return: The TimePoint at the given index. + """ assert TIMESTAMP_US_COLUMN in arrow_table.schema.names, "Timestamp column not found in Arrow table." return TimePoint.from_us(arrow_table[TIMESTAMP_US_COLUMN][index].as_py()) @@ -80,19 +88,28 @@ def get_timepoint_from_arrow_table(arrow_table: pa.Table, index: int) -> TimePoi def get_ego_state_se3_from_arrow_table( arrow_table: pa.Table, index: int, - vehicle_parameters: VehicleParameters, + vehicle_parameters: Optional[VehicleParameters], ) -> Optional[EgoStateSE3]: + """Builds a :class:`~py123d.datatypes.vehicle_state.EgoStateSE3` from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the ego state data. + :param index: The index to extract the ego state from. + :param vehicle_parameters: The vehicle parameters used to build the ego state. + :return: The ego state at the given index, or None if not available. + """ ego_state_se3: Optional[EgoStateSE3] = None - if _all_columns_in_schema(arrow_table, EGO_STATE_SE3_COLUMNS): + if _all_columns_in_schema(arrow_table, EGO_STATE_SE3_COLUMNS) and vehicle_parameters is not None: timepoint = get_timepoint_from_arrow_table(arrow_table, index) rear_axle_se3 = PoseSE3.from_list(arrow_table[EGO_REAR_AXLE_SE3_COLUMN][index].as_py()) + dynamic_state_se3 = _get_optional_array_mixin( + arrow_table[EGO_DYNAMIC_STATE_SE3_COLUMN][index].as_py(), + DynamicStateSE3, + ) ego_state_se3 = EgoStateSE3.from_rear_axle( rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters, - dynamic_state_se3=_get_optional_array_mixin( - arrow_table[EGO_DYNAMIC_STATE_SE3_COLUMN][index].as_py(), DynamicStateSE3 - ), + dynamic_state_se3=dynamic_state_se3, timepoint=timepoint, ) return ego_state_se3 @@ -103,12 +120,20 @@ def get_box_detections_se3_from_arrow_table( index: int, log_metadata: LogMetadata, ) -> BoxDetectionWrapper: + """Builds a :class:`~py123d.datatypes.detections.BoxDetectionWrapper` from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the box detections data. + :param index: The index to extract the box detections from. + :param log_metadata: The log metadata, contained the label class information. + :return: The BoxDetectionWrapper at the given index. + """ box_detections: Optional[BoxDetectionWrapper] = None if _all_columns_in_schema(arrow_table, BOX_DETECTIONS_SE3_COLUMNS): timepoint = get_timepoint_from_arrow_table(arrow_table, index) box_detections_list: List[BoxDetectionSE3] = [] box_detection_label_class = log_metadata.box_detection_label_class + assert box_detection_label_class is not None, "Box detection label class mapping not found in log metadata." for _bounding_box_se3, _token, _label, _velocity, _num_lidar_points in zip( arrow_table[BOX_DETECTIONS_BOUNDING_BOX_SE3_COLUMN][index].as_py(), arrow_table[BOX_DETECTIONS_TOKEN_COLUMN][index].as_py(), @@ -133,8 +158,17 @@ def get_box_detections_se3_from_arrow_table( return box_detections -def get_traffic_light_detections_from_arrow_table(arrow_table: pa.Table, index: int) -> TrafficLightDetectionWrapper: - traffic_lights: Optional[List[TrafficLightDetection]] = None +def get_traffic_light_detections_from_arrow_table( + arrow_table: pa.Table, + index: int, +) -> Optional[TrafficLightDetectionWrapper]: + """Builds a :class:`~py123d.datatypes.detections.TrafficLightDetectionWrapper` from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the traffic light detections data. + :param index: The index to extract the traffic light detections from. + :return: The TrafficLightDetectionWrapper at the given index, or None if not available. + """ + traffic_lights: Optional[TrafficLightDetectionWrapper] = None if _all_columns_in_schema(arrow_table, TRAFFIC_LIGHTS_COLUMNS): timepoint = get_timepoint_from_arrow_table(arrow_table, index) traffic_light_detections: List[TrafficLightDetection] = [] @@ -159,6 +193,17 @@ def get_camera_from_arrow_table( camera_type: Union[PinholeCameraType, FisheyeMEICameraType], log_metadata: LogMetadata, ) -> Optional[Union[PinholeCamera, FisheyeMEICamera]]: + """Builds a camera object from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the camera data. + :param index: The index to extract the camera data from. + :param camera_type: The type of camera to build (Pinhole or FisheyeMEI). + :param log_metadata: Metadata about the log, including dataset information. + :raises ValueError: If the camera data format is unsupported. + :raises NotImplementedError: If the camera data type is not supported. + :return: The constructed camera object, or None if not available. + """ + assert isinstance( camera_type, (PinholeCameraType, FisheyeMEICameraType) ), f"camera_type must be PinholeCameraType or FisheyeMEICameraType, got {type(camera_type)}" @@ -206,7 +251,6 @@ def get_camera_from_arrow_table( raise NotImplementedError( f"Only string file paths, bytes, or int frame indices are supported for camera data, got {type(table_data)}" ) - # extrinsic = PoseSE3.from_list(arrow_table[camera_extrinsic_column][index].as_py()) if is_pinhole: camera_metadata = log_metadata.pinhole_camera_metadata[camera_type] @@ -232,9 +276,19 @@ def get_lidar_from_arrow_table( lidar_type: LiDARType, log_metadata: LogMetadata, ) -> LiDAR: + """Builds a LiDAR object from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the LiDAR data. + :param index: The index to extract the LiDAR data from. + :param lidar_type: The type of LiDAR to build. + :param log_metadata: Metadata about the log, including the LiDAR metadata. + :raises ValueError: If the LiDAR data format is unsupported. + :raises NotImplementedError: If the LiDAR data type is not supported. + :return: The constructed LiDAR object, or None if not available. + """ lidar: Optional[LiDAR] = None - # NOTE @DanielDauner: Some LiDAR are stored together and are seperated only during loading. + # NOTE @DanielDauner: Some LiDAR are stored together and are separated only during loading. # In this case, we need to use the merged LiDAR column name. lidar_column_name = LIDAR_DATA_COLUMN(lidar_type.serialize()) @@ -283,6 +337,12 @@ def get_lidar_from_arrow_table( def get_route_lane_group_ids_from_arrow_table(arrow_table: pa.Table, index: int) -> Optional[List[int]]: + """Gets the route lane group IDs from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the route lane group IDs data. + :param index: The index to extract the route lane group IDs from. + :return: The route lane group IDs at the given index, or None if not available + """ route_lane_group_ids: Optional[List[int]] = None if _all_columns_in_schema(arrow_table, [ROUTE_LANE_GROUP_IDS_COLUMN]): route_lane_group_ids = arrow_table[ROUTE_LANE_GROUP_IDS_COLUMN][index].as_py() @@ -290,6 +350,12 @@ def get_route_lane_group_ids_from_arrow_table(arrow_table: pa.Table, index: int) def get_scenario_tags_from_arrow_table(arrow_table: pa.Table, index: int) -> Optional[List[int]]: + """Gets the scenario tags from an Arrow table at a given index. + + :param arrow_table: The Arrow table containing the scenario tags data. + :param index: The index to extract the scenario tags from. + :return: The scenario tags at the given index, or None if not available + """ scenario_tags: Optional[List[int]] = None if _all_columns_in_schema(arrow_table, [SCENARIO_TAGS_COLUMN]): scenario_tags = arrow_table[SCENARIO_TAGS_COLUMN][index].as_py() @@ -297,7 +363,13 @@ def get_scenario_tags_from_arrow_table(arrow_table: pa.Table, index: int) -> Opt def _unoptimized_demo_mp4_read(log_metadata: LogMetadata, camera_name: str, frame_index: int) -> Optional[np.ndarray]: - """A quick and dirty MP4 reader for testing purposes only. Not optimized for performance.""" + """Reads a frame from an MP4 file for demonstration purposes. This features is not optimized for performance. + + :param log_metadata: The metadata of the log containing the MP4 file. + :param camera_name: The name of the camera whose MP4 file is to be read. + :param frame_index: The index of the frame to read from the MP4 file. + :return: The image frame as a numpy array, or None if the file does not exist. + """ image: Optional[npt.NDArray[np.uint8]] = None py123d_sensor_root = Path(DATASET_PATHS.py123d_sensors_root) @@ -310,6 +382,13 @@ def _unoptimized_demo_mp4_read(log_metadata: LogMetadata, camera_name: str, fram def _get_optional_array_mixin(data: Optional[Union[List, npt.NDArray]], cls: Type[ArrayMixin]) -> Optional[ArrayMixin]: + """Builds an optional ArrayMixin if data is provided. + + :param data: The data to convert into an ArrayMixin. + :param cls: The ArrayMixin class to instantiate. + :raises ValueError: If the data type is unsupported. + :return: The instantiated ArrayMixin, or None if data is None. + """ if data is None: return None if isinstance(data, list): @@ -321,4 +400,10 @@ def _get_optional_array_mixin(data: Optional[Union[List, npt.NDArray]], cls: Typ def _all_columns_in_schema(arrow_table: pa.Table, columns: List[str]) -> bool: + """Checks if all specified columns are present in the Arrow table schema. + + :param arrow_table: The Arrow table to check. + :param columns: The list of column names to check for. + :return: True if all columns are present, False otherwise. + """ return all(column in arrow_table.schema.names for column in columns) diff --git a/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py b/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py index d61cbc35..2dd17daf 100644 --- a/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py +++ b/src/py123d/api/scene/arrow/utils/arrow_metadata_utils.py @@ -5,18 +5,21 @@ import pyarrow as pa from py123d.common.utils.arrow_helper import get_lru_cached_arrow_table -from py123d.datatypes.metadata.log_metadata import LogMetadata +from py123d.datatypes.metadata import LogMetadata def get_log_metadata_from_arrow_file(arrow_file_path: Union[Path, str]) -> LogMetadata: + """Gets the log metadata from an Arrow file.""" table = get_lru_cached_arrow_table(arrow_file_path) return get_log_metadata_from_arrow_table(table) def get_log_metadata_from_arrow_table(arrow_table: pa.Table) -> LogMetadata: + """Gets the log metadata from an Arrow table.""" return LogMetadata.from_dict(json.loads(arrow_table.schema.metadata[b"log_metadata"].decode())) def add_log_metadata_to_arrow_schema(schema: pa.schema, log_metadata: LogMetadata) -> pa.schema: + """Adds log metadata to an Arrow schema.""" schema = schema.with_metadata({"log_metadata": json.dumps(log_metadata.to_dict())}) return schema diff --git a/src/py123d/api/scene/scene_api.py b/src/py123d/api/scene/scene_api.py index 5949449a..1277a5c4 100644 --- a/src/py123d/api/scene/scene_api.py +++ b/src/py123d/api/scene/scene_api.py @@ -18,6 +18,7 @@ class SceneAPI(abc.ABC): + """Base class for all scene APIs. The scene API provides access to all data modalities at in a scene.""" # Abstract Methods, to be implemented by subclasses # ------------------------------------------------------------------------------------------------------------------ diff --git a/src/py123d/api/scene/scene_builder.py b/src/py123d/api/scene/scene_builder.py index c65b8e22..d6961ad9 100644 --- a/src/py123d/api/scene/scene_builder.py +++ b/src/py123d/api/scene/scene_builder.py @@ -1,5 +1,5 @@ import abc -from typing import Iterator +from typing import List from py123d.api.scene.scene_api import SceneAPI from py123d.api.scene.scene_filter import SceneFilter @@ -7,14 +7,14 @@ class SceneBuilder(abc.ABC): - """ - Abstract base class for building scenes from a dataset. + """Base class for all scene builders. The scene builder is responsible for building scene given a \ + :class:`~py123d.api.scene.scene_filter.SceneFilter`. """ @abc.abstractmethod - def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> Iterator[SceneAPI]: - """ - Returns an iterator over scenes that match the given filter. + def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> List[SceneAPI]: + """Returns a list of scenes that match the given filter. + :param filter: SceneFilter object to filter the scenes. :param worker: WorkerPool to parallelize the scene extraction. :return: Iterator over AbstractScene objects. diff --git a/src/py123d/api/scene/scene_filter.py b/src/py123d/api/scene/scene_filter.py index 62ad9301..118005a1 100644 --- a/src/py123d/api/scene/scene_filter.py +++ b/src/py123d/api/scene/scene_filter.py @@ -10,34 +10,52 @@ @dataclass class SceneFilter: + """Class to filter scenes when building scenes from logs.""" split_types: Optional[List[str]] = None + """List of split types to filter scenes by (e.g. `train`, `val`, `test`).""" + split_names: Optional[List[str]] = None + """List of split names to filter scenes by (in the form `{dataset_name}_{split_type}`).""" + log_names: Optional[List[str]] = None + """Name of logs to include scenes from.""" + + locations: Optional[List[str]] = None + """List of locations to filter scenes by.""" - locations: Optional[List[str]] = None # TODO: - scene_uuids: Optional[List[str]] = None # TODO: + scene_uuids: Optional[List[str]] = None + """List of scene UUIDs to include.""" - timestamp_threshold_s: Optional[float] = None # TODO: - ego_displacement_minimum_m: Optional[float] = None # TODO: + timestamp_threshold_s: Optional[float] = None + """Minimum time between the start timestamps of two consecutive scenes.""" duration_s: Optional[float] = 10.0 + """Duration of each scene in seconds.""" + history_s: Optional[float] = 3.0 + """History duration of each scene in seconds.""" pinhole_camera_types: Optional[List[PinholeCameraType]] = None + """List of :class:`PinholeCameraType` to include in the scenes.""" + fisheye_mei_camera_types: Optional[List[FisheyeMEICameraType]] = None + """List of :class:`FisheyeMEICameraType` to include in the scenes.""" max_num_scenes: Optional[int] = None + """Maximum number of scenes to return.""" + shuffle: bool = False + """Whether to shuffle the returned scenes.""" def __post_init__(self): def _resolve_enum_arguments( - serial_enum_cls: SerialIntEnum, input: Optional[List[Union[int, str, SerialIntEnum]]] - ) -> List[SerialIntEnum]: + serial_enum_cls: SerialIntEnum, + input: Optional[List[Union[int, str, SerialIntEnum]]], + ) -> Optional[List[SerialIntEnum]]: if input is None: return None - assert isinstance(input, list), f"input must be a list of {serial_enum_cls.__name__}" return [serial_enum_cls.from_arbitrary(value) for value in input] self.pinhole_camera_types = _resolve_enum_arguments(PinholeCameraType, self.pinhole_camera_types) diff --git a/src/py123d/api/scene/scene_metadata.py b/src/py123d/api/scene/scene_metadata.py index 103a1d7e..0bbe2e1d 100644 --- a/src/py123d/api/scene/scene_metadata.py +++ b/src/py123d/api/scene/scene_metadata.py @@ -3,21 +3,34 @@ @dataclass(frozen=True) class SceneMetadata: + """Metadata for a scene extracted from a log.""" initial_uuid: str + """UUID of the scene, i.e., the UUID of the starting frame of the scene.""" + initial_idx: int + """Index of the starting frame of the scene in the log.""" + duration_s: float + """Duration of the scene in seconds.""" + history_s: float + """History duration of the scene in seconds.""" + iteration_duration_s: float + """Duration of each iteration in seconds.""" @property def number_of_iterations(self) -> int: + """Number of iterations in the scene.""" return round(self.duration_s / self.iteration_duration_s) @property def number_of_history_iterations(self) -> int: + """Number of history iterations in the scene.""" return round(self.history_s / self.iteration_duration_s) @property def end_idx(self) -> int: + """Index of the end frame of the scene.""" return self.initial_idx + self.number_of_iterations diff --git a/src/py123d/common/multithreading/ray_execution.py b/src/py123d/common/multithreading/ray_execution.py index 2ca1d472..b11badc5 100644 --- a/src/py123d/common/multithreading/ray_execution.py +++ b/src/py123d/common/multithreading/ray_execution.py @@ -1,3 +1,8 @@ +""" +Multi-threading execution code. +Code is adapted from the nuplan-devkit: https://github.com/motional/nuplan-devkit +""" + import logging import traceback from functools import partial @@ -14,8 +19,8 @@ def _ray_object_iterator(initial_ids: List[ray.ObjectRef]) -> Iterator[Tuple[ray.ObjectRef, Any]]: - """ - Iterator that waits for each ray object in the input object list to be completed and fetches the result. + """Iterator that waits for each ray object in the input object list to be completed and fetches the result. + :param initial_ids: list of ray object ids :yield: result of worker """ @@ -31,8 +36,8 @@ def _ray_object_iterator(initial_ids: List[ray.ObjectRef]) -> Iterator[Tuple[ray def wrap_function(fn: Callable[..., Any], log_dir: Optional[Path] = None) -> Callable[..., Any]: - """ - Wraps a function to save its logs to a unique file inside the log directory. + """Wraps a function to save its logs to a unique file inside the log directory. + :param fn: function to be wrapped. :param log_dir: directory to store logs (wrapper function does nothing if it's not set). :return: wrapped function which changes logging settings while it runs. @@ -68,8 +73,8 @@ def wrapped_fn(*args: Any, **kwargs: Any) -> Any: def _ray_map_items(task: Task, *item_lists: Iterable[List[Any]], log_dir: Optional[Path] = None) -> List[Any]: - """ - Map each item of a list of arguments to a callable and executes in parallel. + """Map each item of a list of arguments to a callable and executes in parallel. + :param fn: callable to be run :param item_list: items to be parallelized :param log_dir: directory to store worker logs @@ -106,8 +111,8 @@ def _ray_map_items(task: Task, *item_lists: Iterable[List[Any]], log_dir: Option def ray_map(task: Task, *item_lists: Iterable[List[Any]], log_dir: Optional[Path] = None) -> List[Any]: - """ - Initialize ray, align item lists and map each item of a list of arguments to a callable and executes in parallel. + """Initialize ray, align item lists and map each item of a list of arguments to a callable and executes in parallel. + :param task: callable to be run :param item_lists: items to be parallelized :param log_dir: directory to store worker logs diff --git a/src/py123d/common/multithreading/worker_parallel.py b/src/py123d/common/multithreading/worker_parallel.py index 9183a0fc..2af320e5 100644 --- a/src/py123d/common/multithreading/worker_parallel.py +++ b/src/py123d/common/multithreading/worker_parallel.py @@ -1,3 +1,8 @@ +""" +Multi-threading execution code. +Code is adapted from the nuplan-devkit: https://github.com/motional/nuplan-devkit +""" + import concurrent import concurrent.futures import logging diff --git a/src/py123d/common/multithreading/worker_pool.py b/src/py123d/common/multithreading/worker_pool.py index 8ffea8ed..716b37d9 100644 --- a/src/py123d/common/multithreading/worker_pool.py +++ b/src/py123d/common/multithreading/worker_pool.py @@ -1,3 +1,8 @@ +""" +Multi-threading execution code. +Code is adapted from the nuplan-devkit: https://github.com/motional/nuplan-devkit +""" + import abc import logging from concurrent.futures import Future diff --git a/src/py123d/common/multithreading/worker_ray.py b/src/py123d/common/multithreading/worker_ray.py index 48b06f77..0ac47dc9 100644 --- a/src/py123d/common/multithreading/worker_ray.py +++ b/src/py123d/common/multithreading/worker_ray.py @@ -1,3 +1,8 @@ +""" +Multi-threading execution code. +Code is adapted from the nuplan-devkit: https://github.com/motional/nuplan-devkit +""" + import logging import os from concurrent.futures import Future diff --git a/src/py123d/common/multithreading/worker_sequential.py b/src/py123d/common/multithreading/worker_sequential.py index c0106e86..792f0d4e 100644 --- a/src/py123d/common/multithreading/worker_sequential.py +++ b/src/py123d/common/multithreading/worker_sequential.py @@ -1,3 +1,8 @@ +""" +Multi-threading execution code. +Code is adapted from the nuplan-devkit: https://github.com/motional/nuplan-devkit +""" + import logging from concurrent.futures import Future from typing import Any, Iterable, List diff --git a/src/py123d/common/multithreading/worker_utils.py b/src/py123d/common/multithreading/worker_utils.py index ce79d6df..ae077aea 100644 --- a/src/py123d/common/multithreading/worker_utils.py +++ b/src/py123d/common/multithreading/worker_utils.py @@ -1,3 +1,8 @@ +""" +Multi-threading execution code. +Code is adapted from the nuplan-devkit: https://github.com/motional/nuplan-devkit +""" + from typing import Any, Callable, List, Optional import numpy as np diff --git a/src/py123d/common/utils/arrow_helper.py b/src/py123d/common/utils/arrow_helper.py index 7604cb0e..75e98f52 100644 --- a/src/py123d/common/utils/arrow_helper.py +++ b/src/py123d/common/utils/arrow_helper.py @@ -5,19 +5,28 @@ import pyarrow as pa # TODO: Tune Parameters and add to config? -MAX_LRU_CACHED_TABLES: Final[int] = 1_000_000 +MAX_LRU_CACHED_TABLES: Final[int] = 50_000 def open_arrow_table(arrow_file_path: Union[str, Path]) -> pa.Table: + """Open an `.arrow` file as memory map. + + :param arrow_file_path: The file path, defined as string or Path. + :return: The memory-mapped arrow table.s + """ + with pa.memory_map(str(arrow_file_path), "rb") as source: table: pa.Table = pa.ipc.open_file(source).read_all() return table def write_arrow_table(table: pa.Table, arrow_file_path: Union[str, Path]) -> None: - # compression: Optional[Literal["lz4", "zstd"]] = "lz4" - # codec = pa.Codec("zstd", compression_level=100) if compression is not None else None - # options = pa.ipc.IpcWriteOptions(compression=codec) + """Writes an arrow table to the file path. + + :param table: The arrow table to write. + :param arrow_file_path: The file path, defined as string or Path. + """ + with pa.OSFile(str(arrow_file_path), "wb") as sink: # with pa.ipc.new_file(sink, table.schema, options=options) as writer: with pa.ipc.new_file(sink, table.schema) as writer: diff --git a/src/py123d/common/utils/enums.py b/src/py123d/common/utils/enums.py index 1da767ab..453b0945 100644 --- a/src/py123d/common/utils/enums.py +++ b/src/py123d/common/utils/enums.py @@ -1,21 +1,26 @@ from __future__ import annotations import enum - -from pyparsing import Union +from typing import Union class classproperty(object): + """Decorator for class-level properties.""" + def __init__(self, f): + """Initialize the classproperty with the given function.""" self.f = f def __get__(self, obj, owner): + """Get the property value.""" return self.f(owner) class SerialIntEnum(enum.Enum): + """Base class for serializable integer enums.""" def __int__(self) -> int: + """Get the integer value of the enum.""" return self.value def serialize(self, lower: bool = True) -> str: diff --git a/src/py123d/common/utils/mixin.py b/src/py123d/common/utils/mixin.py index 5f0d21fe..be2a86d8 100644 --- a/src/py123d/common/utils/mixin.py +++ b/src/py123d/common/utils/mixin.py @@ -1,23 +1,45 @@ from __future__ import annotations +from typing import Self + import numpy as np import numpy.typing as npt -# import pyarrow as pa - class ArrayMixin: - """Mixin class for object entities.""" + """Mixin class to provide array-like behavior for classes. + + Example: + >>> import numpy as np + >>> from py123d.common.utils.mixin import ArrayMixin + >>> class MyVector(ArrayMixin): + ... def __init__(self, x: float, y: float): + ... self._array = np.array([x, y], dtype=np.float64) + ... @property + ... def array(self) -> npt.NDArray[np.float64]: + ... return self._array + ... @classmethod + ... def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> MyVector: + ... if copy: + ... array = array.copy() + ... return cls(array[0], array[1]) + >>> vec = MyVector(1.0, 2.0) + >>> print(vec) + MyVector(array=[1. 2.]) + >>> np.array(vec, dtype=np.float32) + array([1., 2.], dtype=float32) + + """ __slots__ = () @classmethod - def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> ArrayMixin: + def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Self: """Create an instance from a NumPy array.""" raise NotImplementedError @classmethod - def from_list(cls, values: list) -> ArrayMixin: + def from_list(cls, values: list) -> Self: """Create an instance from a list of values.""" return cls.from_array(np.array(values, dtype=np.float64), copy=False) @@ -26,7 +48,7 @@ def array(self) -> npt.NDArray[np.float64]: """The array representation of the geometric entity.""" raise NotImplementedError - def __array__(self, dtype: npt.DtypeLike = None, copy: bool = False) -> npt.NDArray: + def __array__(self, dtype: npt.DTypeLike = None, copy: bool = False) -> npt.NDArray: array = self.array return array if dtype is None else array.astype(dtype=dtype, copy=copy) diff --git a/src/py123d/common/utils/timer.py b/src/py123d/common/utils/timer.py index 17558977..4159f556 100644 --- a/src/py123d/common/utils/timer.py +++ b/src/py123d/common/utils/timer.py @@ -6,18 +6,30 @@ class Timer: - """ - A simple timer class to measure execution time of different parts of the code. + """Simple Timer class to log time taken by code blocks. + + Example + ------- + >>> timer = Timer() + >>> timer.start() + >>> time.sleep(0.1) # Simulate code block + >>> timer.log("block_1") + >>> time.sleep(0.2) # Simulate another code block + >>> timer.log("block_2") + >>> timer.end() + >>> print(timer) # Displays timing statistics (with some variation) + mean min max argmax median + block_1 0.100123 0.100123 0.100123 0 0.100123 + block_2 0.200456 0.200456 0.200456 0 0.200456 + total 0.300579 0.300579 0.300579 0 0.300579 + """ - def __init__(self, name: Optional[str] = None, end_key: str = "total"): - """ - Initializes the Timer instance. - :param name: Name of the Timer, defaults to None - :param end_key: name of the final row, defaults to "total" - """ + def __init__(self, end_key: str = "total"): + """Initializes the :class:`Timer` - self._name = name + :param end_key: The key used to log the total time, defaults to "total" + """ self._end_key: str = end_key self._statistic_functions = { "mean": np.mean, @@ -33,15 +45,17 @@ def __init__(self, name: Optional[str] = None, end_key: str = "total"): self._iteration_time: Optional[float] = None def start(self) -> None: - """Called during the start of the timer .""" + """Called at the start of the timer.""" self._start_time = time.perf_counter() self._iteration_time = time.perf_counter() def log(self, key: str) -> None: """ Called after code block execution. Logs the time taken for the block, given the name (key). - :param key: Name of the code block to log the time for. + :param key: Unique identifier of the code block to log the time for. """ + assert self._iteration_time is not None, "Timer has not been started. Call start() before logging." + if key not in self._time_logs.keys(): self._time_logs[key] = [] @@ -50,14 +64,15 @@ def log(self, key: str) -> None: def end(self) -> None: """Called at the end of the timer.""" + assert self._start_time is not None, "Timer has not been started. Call start() before logging." if self._end_key not in self._time_logs.keys(): self._time_logs[self._end_key] = [] self._time_logs[self._end_key].append(time.perf_counter() - self._start_time) def to_pandas(self) -> Optional[pd.DataFrame]: - """ - Returns a DataFrame with statistics of the logged times. + """Returns a DataFrame with statistics of the logged times. + :return: pandas dataframe. """ @@ -73,13 +88,15 @@ def to_pandas(self) -> Optional[pd.DataFrame]: return dataframe def info(self) -> Dict[str, float]: - """ - Summarized information about the timings. + """Summarized information about the timings. + :return: Dictionary with the mean of each timing. """ info = {} for key, timings in self._time_logs.items(): - info[key] = np.array(timings).mean() + info[key] = {} + for name, function in self._statistic_functions.items(): + info[key][name] = function(np.array(timings)) return info def flush(self) -> None: diff --git a/src/py123d/common/utils/uuid_utils.py b/src/py123d/common/utils/uuid_utils.py index d4d2678a..3c928a08 100644 --- a/src/py123d/common/utils/uuid_utils.py +++ b/src/py123d/common/utils/uuid_utils.py @@ -1,11 +1,11 @@ import uuid -from typing import Final +from typing import Final, Optional # Fixed namespace UUID for all UUIDs generated by 123D, do not change! UUID_NAMESPACE_123D: Final[uuid.UUID] = uuid.UUID("123D123D-123D-123D-123D-123D123D123D") -def create_deterministic_uuid(split: str, log_name: str, timestamp_us: int, misc: str = None) -> uuid.UUID: +def create_deterministic_uuid(split: str, log_name: str, timestamp_us: int, misc: Optional[str] = None) -> uuid.UUID: """Create a universally unique identifier (UUID) based on identifying fields. :param split: The data split (in the format {dataset_name}_{train, val, test}) diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index 6e9f0f6b..8335ad23 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -34,12 +34,18 @@ "SOLID_DASH_WHITE", "SOLID_WHITE", ] -MAX_ROAD_EDGE_LENGTH: Final[float] = 100.0 # TODO: Add to config +MAX_ROAD_EDGE_LENGTH: Final[float] = 100.0 def convert_av2_map(source_log_path: Path, map_writer: AbstractMapWriter) -> None: + """Converts the AV2 map objects to the 123D objects and writes them using the provided map writer. + + :param source_log_path: Path to the AV2 source log folder. + :param map_writer: An instance of AbstractMapWriter to write the converted map objects. + """ def _extract_polyline(data: List[Dict[str, float]], close: bool = False) -> Polyline3D: + """Helper to instantiate a Polyline3D from AV2 coordinate dicts.""" polyline = np.array([[p["x"], p["y"], p["z"]] for p in data], dtype=np.float64) if close: polyline = np.vstack([polyline, polyline[0]]) @@ -57,27 +63,31 @@ def _extract_polyline(data: List[Dict[str, float]], close: bool = False) -> Poly # keys: ["area_boundary", "id"] drivable_areas[int(drivable_area_id)] = _extract_polyline(drivable_area_dict["area_boundary"], close=True) - for lane_segment_id, lane_segment_dict in log_map_archive["lane_segments"].items(): - # keys = [ - # "id", - # "is_intersection", - # "lane_type", - # "left_lane_boundary", - # "left_lane_mark_type", - # "right_lane_boundary", - # "right_lane_mark_type", - # "successors", - # "predecessors", - # "right_neighbor_id", - # "left_neighbor_id", - # ] + for _, lane_segment_dict in log_map_archive["lane_segments"].items(): + # Available keys: + # - "id", + # - "is_intersection", + # - "lane_type", + # - "left_lane_boundary", + # - "left_lane_mark_type", + # - "right_lane_boundary", + # - "right_lane_mark_type", + # - "successors", + # - "predecessors", + # - "right_neighbor_id", + # - "left_neighbor_id", + + # Convert polyline dicts to Polyline3D objects. lane_segment_dict["left_lane_boundary"] = _extract_polyline(lane_segment_dict["left_lane_boundary"]) lane_segment_dict["right_lane_boundary"] = _extract_polyline(lane_segment_dict["right_lane_boundary"]) - for crosswalk_id, crosswalk_dict in log_map_archive["pedestrian_crossings"].items(): - # keys = ["id", "outline"] - # https://github.com/argoverse/av2-api/blob/6b22766247eda941cb1953d6a58e8d5631c561da/src/av2/map/pedestrian_crossing.py + for _, crosswalk_dict in log_map_archive["pedestrian_crossings"].items(): + # Available keys: + # - "id" + # - "edge1" + # - "edge2" + # Convert edge dicts to Polyline3D objects. p1, p2 = np.array([[p["x"], p["y"], p["z"]] for p in crosswalk_dict["edge1"]], dtype=np.float64) p3, p4 = np.array([[p["x"], p["y"], p["z"]] for p in crosswalk_dict["edge2"]], dtype=np.float64) crosswalk_dict["outline"] = Polyline3D.from_array(np.array([p1, p2, p4, p3, p1], dtype=np.float64)) @@ -95,10 +105,14 @@ def _extract_polyline(data: List[Dict[str, float]], close: bool = False) -> Poly def _write_av2_lanes(lanes: Dict[int, Any], map_writer: AbstractMapWriter) -> None: + """Helper to write lanes to map writer.""" def _get_centerline_from_boundaries( - left_boundary: Polyline3D, right_boundary: Polyline3D, resolution: float = 0.1 + left_boundary: Polyline3D, + right_boundary: Polyline3D, + resolution: float = 0.1, ) -> Polyline3D: + """Helper to compute centerline from left and right lane boundaries.""" points_per_meter = 1 / resolution num_points = int(np.ceil(max([right_boundary.length, left_boundary.length]) * points_per_meter)) @@ -136,9 +150,8 @@ def _get_centerline_from_boundaries( def _write_av2_lane_group(lane_group_dict: Dict[int, Any], map_writer: AbstractMapWriter) -> None: - + """Helper to write lane groups to map writer.""" for lane_group_id, lane_group_values in lane_group_dict.items(): - map_writer.write_lane_group( LaneGroup( object_id=lane_group_id, @@ -155,6 +168,7 @@ def _write_av2_lane_group(lane_group_dict: Dict[int, Any], map_writer: AbstractM def _write_av2_intersections(intersection_dict: Dict[int, Any], map_writer: AbstractMapWriter) -> None: + """Helper to write intersections to map writer.""" for intersection_id, intersection_values in intersection_dict.items(): map_writer.write_intersection( Intersection( @@ -166,6 +180,7 @@ def _write_av2_intersections(intersection_dict: Dict[int, Any], map_writer: Abst def _write_av2_crosswalks(crosswalks: Dict[int, npt.NDArray[np.float64]], map_writer: AbstractMapWriter) -> None: + """Helper to write crosswalks to map writer.""" for cross_walk_id, crosswalk_dict in crosswalks.items(): map_writer.write_crosswalk( Crosswalk( @@ -176,6 +191,7 @@ def _write_av2_crosswalks(crosswalks: Dict[int, npt.NDArray[np.float64]], map_wr def _write_av2_generic_drivable(drivable_areas: Dict[int, Polyline3D], map_writer: AbstractMapWriter) -> None: + """Helper to write generic drivable areas to map writer.""" for drivable_area_id, drivable_area_outline in drivable_areas.items(): map_writer.write_generic_drivable( GenericDrivable( @@ -186,10 +202,10 @@ def _write_av2_generic_drivable(drivable_areas: Dict[int, Polyline3D], map_write def _write_av2_road_edge(drivable_areas: Dict[int, Polyline3D], map_writer: AbstractMapWriter) -> None: + """Helper to write road edges to map writer.""" # NOTE @DanielDauner: We merge all drivable areas in 2D and lift the outlines to 3D. # Currently the method assumes that the drivable areas do not overlap and all road surfaces are included. - drivable_polygons = [geom.Polygon(drivable_area.array[:, :2]) for drivable_area in drivable_areas.values()] road_edges_2d = get_road_edge_linear_rings(drivable_polygons) non_conflicting_road_edges = lift_road_edges_to_3d(road_edges_2d, list(drivable_areas.values())) @@ -197,7 +213,6 @@ def _write_av2_road_edge(drivable_areas: Dict[int, Polyline3D], map_writer: Abst road_edges = split_line_geometry_by_max_length(non_conflicting_road_edges_linestrings, MAX_ROAD_EDGE_LENGTH) for idx, road_edge in enumerate(road_edges): - # TODO @DanielDauner: Figure out if other road edge types should/could be assigned here. map_writer.write_road_edge( RoadEdge( @@ -209,14 +224,13 @@ def _write_av2_road_edge(drivable_areas: Dict[int, Polyline3D], map_writer: Abst def _write_av2_road_lines(lanes: Dict[int, Any], map_writer: AbstractMapWriter) -> None: - + """Helper to write road lines to map writer.""" running_road_line_id = 0 for lane in lanes.values(): for side in ["left", "right"]: # NOTE @DanielDauner: We currently ignore lane markings that are NONE in the AV2 dataset. if lane[f"{side}_lane_mark_type"] == "NONE": continue - map_writer.write_road_line( RoadLine( object_id=running_road_line_id, @@ -224,15 +238,19 @@ def _write_av2_road_lines(lanes: Dict[int, Any], map_writer: AbstractMapWriter) polyline=lane[f"{side}_lane_boundary"], ) ) - running_road_line_id += 1 -def _extract_lane_group_dict(lanes: Dict[int, Any]) -> gpd.GeoDataFrame: +def _extract_lane_group_dict(lanes: Dict[int, Any]) -> Dict[int, Any]: + """Collect lane groups from neighboring lanes. This function first extracts lane groups by traversing + neighboring lanes and then builds a dictionary with lane group information, e.g. boundaries, + predecessors, successors. + :param lanes: Dictionary of lane information, e.g. boundaries, and neighboring lanes. + :return: Dictionary of lane group information. + """ lane_group_sets = _extract_lane_group(lanes) lane_group_set_dict = {i: lane_group for i, lane_group in enumerate(lane_group_sets)} - lane_group_dict: Dict[int, Dict[str, Any]] = {} def _get_lane_group_ids_of_lanes_ids(lane_ids: List[str]) -> List[int]: @@ -279,6 +297,11 @@ def _get_lane_group_ids_of_lanes_ids(lane_ids: List[str]) -> List[int]: def _extract_lane_group(lanes) -> List[List[str]]: + """Extract lane groups by traversing neighboring lanes. + + :param lanes: Dictionary of lane information, e.g. boundaries, and neighboring lanes. + :return: List of lane groups, where each lane group is a list of lane IDs + """ visited = set() lane_groups = [] @@ -331,8 +354,17 @@ def _traverse_group(start_lane_id): def _extract_intersection_dict( - lanes: Dict[int, Any], lane_group_dict: Dict[int, Any], max_distance: float = 0.01 + lanes: Dict[int, Any], + lane_group_dict: Dict[int, Any], + max_distance: float = 0.01, ) -> Dict[str, Any]: + """Extract intersection outlines from lane groups. + + :param lanes: Dictionary of lane information, e.g. boundaries, and whether lane is part of intersection. + :param lane_group_dict: Dictionary of lane group information. + :param max_distance: Maximum distance to consider for intersection boundaries, defaults to 0.01 + :return: Dictionary of intersection information. + """ def _interpolate_z_on_segment(point: shapely.Point, segment_coords: npt.NDArray[np.float64]) -> float: """Interpolate Z coordinate along a 3D line segment.""" diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 3d03524a..713a311b 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -16,61 +16,60 @@ ) from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.registry.box_detection_label_registry import AV2SensorBoxDetectionLabel -from py123d.conversion.registry.lidar_index_registry import AVSensorLiDARIndex -from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.metadata import LogMetadata -from py123d.datatypes.metadata.map_metadata import MapMetadata -from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import ( +from py123d.conversion.registry import AV2SensorBoxDetectionLabel, AVSensorLiDARIndex +from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper +from py123d.datatypes.metadata import LogMetadata, MapMetadata +from py123d.datatypes.sensors import ( + LiDARMetadata, + LiDARType, PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, ) -from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 -from py123d.datatypes.vehicle_state.vehicle_parameters import ( - get_av2_ford_fusion_hybrid_parameters, -) -from py123d.geometry import BoundingBoxSE3Index, PoseSE3, Vector3D, Vector3DIndex -from py123d.geometry.bounding_box import BoundingBoxSE3 -from py123d.geometry.transform.transform_se3 import convert_relative_to_absolute_se3_array +from py123d.datatypes.time import TimePoint +from py123d.datatypes.vehicle_state import EgoStateSE3 +from py123d.datatypes.vehicle_state.vehicle_parameters import get_av2_ford_fusion_hybrid_parameters +from py123d.geometry import BoundingBoxSE3, BoundingBoxSE3Index, PoseSE3, Vector3D, Vector3DIndex +from py123d.geometry.transform import convert_relative_to_absolute_se3_array class AV2SensorConverter(AbstractDatasetConverter): + """Dataset converter for the AV2 sensor dataset.""" + def __init__( self, splits: List[str], av2_data_root: Union[Path, str], dataset_converter_config: DatasetConverterConfig, ) -> None: + """Initializes the AV2SensorConverter. + + :param splits: List of dataset splits to convert, e.g. ["av2-sensor_train", "av2-sensor_val", "av2-sensor_test"] + :param av2_data_root: Root directory of the AV2 sensor dataset. + :param dataset_converter_config: Configuration for the dataset converter. + """ super().__init__(dataset_converter_config) assert av2_data_root is not None, "The variable `av2_data_root` must be provided." for split in splits: - assert ( - split in AV2_SENSOR_SPLITS - ), f"Split {split} is not available. Available splits: {self.available_splits}" + assert split in AV2_SENSOR_SPLITS, f"Split {split} is not available. Available splits: {AV2_SENSOR_SPLITS}" self._splits: List[str] = splits self._av2_data_root: Path = Path(av2_data_root) - self._log_paths_and_split: Dict[str, List[Path]] = self._collect_log_paths() + self._log_paths_and_split: List[Tuple[Path, str]] = self._collect_log_paths() - def _collect_log_paths(self) -> Dict[str, List[Path]]: + def _collect_log_paths(self) -> List[Tuple[Path, str]]: + """Collects source log folder paths for the specified splits.""" log_paths_and_split: List[Tuple[Path, str]] = [] - for split in self._splits: dataset_name = split.split("_")[0] split_type = split.split("_")[-1] assert split_type in ["train", "val", "test"] - if "av2-sensor" == dataset_name: log_folder = self._av2_data_root / "sensor" / split_type else: raise ValueError(f"Unknown dataset name {dataset_name} in split {split}.") - log_paths_and_split.extend([(log_path, split) for log_path in log_folder.iterdir()]) - return log_paths_and_split def get_number_of_maps(self) -> int: @@ -166,6 +165,7 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: def _get_av2_sensor_map_metadata(split: str, source_log_path: Path) -> MapMetadata: + """Helper to get map metadata for AV2 sensor dataset.""" # NOTE: We need to get the city name from the map folder. # see: https://github.com/argoverse/av2-api/blob/main/src/av2/datasets/sensor/av2_sensor_dataloader.py#L163 map_folder = source_log_path / "map" @@ -184,7 +184,7 @@ def _get_av2_sensor_map_metadata(split: str, source_log_path: Path) -> MapMetada def _get_av2_pinhole_camera_metadata( source_log_path: Path, dataset_converter_config: DatasetConverterConfig ) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - + """Helper to get pinhole camera metadata for AV2 sensor dataset.""" pinhole_camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} if dataset_converter_config.include_pinhole_cameras: intrinsics_file = source_log_path / "calibration" / "intrinsics.feather" @@ -205,9 +205,9 @@ def _get_av2_pinhole_camera_metadata( def _get_av2_lidar_metadata( source_log_path: Path, dataset_converter_config: DatasetConverterConfig ) -> Dict[LiDARType, LiDARMetadata]: + """Helper to get LiDAR metadata for AV2 sensor dataset.""" metadata: Dict[LiDARType, LiDARMetadata] = {} - if dataset_converter_config.include_lidars: # Load calibration feather file @@ -237,10 +237,9 @@ def _get_av2_lidar_metadata( def _extract_av2_sensor_box_detections( - annotations_df: Optional[pd.DataFrame], - lidar_timestamp_ns: int, - ego_state_se3: EgoStateSE3, + annotations_df: Optional[pd.DataFrame], lidar_timestamp_ns: int, ego_state_se3: EgoStateSE3 ) -> BoxDetectionWrapper: + """Extract box detections from AV2 sensor dataset annotations.""" # TODO: Extract velocity from annotations_df if available. @@ -287,6 +286,7 @@ def _extract_av2_sensor_box_detections( def _extract_av2_sensor_ego_state(city_se3_egovehicle_df: pd.DataFrame, lidar_timestamp_ns: int) -> EgoStateSE3: + """Extract ego state from AV2 sensor dataset city_SE3_egovehicle dataframe.""" ego_state_slice = get_slice_with_timestamp_ns(city_se3_egovehicle_df, lidar_timestamp_ns) assert ( len(ego_state_slice) == 1 @@ -315,6 +315,7 @@ def _extract_av2_sensor_pinhole_cameras( source_log_path: Path, dataset_converter_config: DatasetConverterConfig, ) -> List[CameraData]: + """Extract pinhole camera data from AV2 sensor dataset.""" camera_data_list: List[CameraData] = [] split = source_log_path.parent.name @@ -357,6 +358,7 @@ def _extract_av2_sensor_pinhole_cameras( def _extract_av2_sensor_lidars( source_log_path: Path, lidar_timestamp_ns: int, dataset_converter_config: DatasetConverterConfig ) -> List[LiDARData]: + """Extract LiDAR data from AV2 sensor dataset.""" lidars: List[LiDARData] = [] if dataset_converter_config.include_lidars: av2_sensor_data_root = source_log_path.parent.parent diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_io.py b/src/py123d/conversion/datasets/av2/av2_sensor_io.py index 81a3de3a..348a6b8f 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_io.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_io.py @@ -8,6 +8,8 @@ def load_av2_sensor_lidar_pcs_from_file(feather_path: Union[Path, str]) -> Dict[LiDARType, np.ndarray]: + """Loads AV2 sensor LiDAR point clouds from a feather file.""" + # NOTE: The AV2 dataset stores both top and down LiDAR data in the same feather file. # We need to separate them based on the laser_number field. # See here: https://github.com/argoverse/av2-api/issues/77#issuecomment-1178040867 diff --git a/src/py123d/conversion/datasets/av2/utils/av2_constants.py b/src/py123d/conversion/datasets/av2/utils/av2_constants.py index 68859d2c..569691aa 100644 --- a/src/py123d/conversion/datasets/av2/utils/av2_constants.py +++ b/src/py123d/conversion/datasets/av2/utils/av2_constants.py @@ -1,11 +1,11 @@ from typing import Dict, Final, Set -from py123d.datatypes.map_objects.map_layer_types import RoadLineType -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType +from py123d.datatypes.map_objects import RoadLineType +from py123d.datatypes.sensors import PinholeCameraType AV2_SENSOR_SPLITS: Set[str] = {"av2-sensor_train", "av2-sensor_val", "av2-sensor_test"} - +# Mapping from AV2 camera names to PinholeCameraType enums. AV2_CAMERA_TYPE_MAPPING: Dict[str, PinholeCameraType] = { "ring_front_center": PinholeCameraType.PCAM_F0, "ring_front_left": PinholeCameraType.PCAM_L0, @@ -18,9 +18,7 @@ "stereo_front_right": PinholeCameraType.PCAM_STEREO_R, } -# AV2_LIDAR_TYPES: Dict[str, str] = { - - +# Mapping from AV2 road line types to RoadLineType enums. AV2_ROAD_LINE_TYPE_MAPPING: Dict[str, RoadLineType] = { "NONE": RoadLineType.NONE, "UNKNOWN": RoadLineType.UNKNOWN, diff --git a/src/py123d/conversion/datasets/av2/utils/av2_helper.py b/src/py123d/conversion/datasets/av2/utils/av2_helper.py index cd0c1f62..a2937778 100644 --- a/src/py123d/conversion/datasets/av2/utils/av2_helper.py +++ b/src/py123d/conversion/datasets/av2/utils/av2_helper.py @@ -11,6 +11,7 @@ def get_dataframe_from_file(file_path: Path) -> pd.DataFrame: + """Get a Pandas DataFrame from parquet or feather files.""" if file_path.suffix == ".parquet": import pyarrow.parquet as pq @@ -29,6 +30,7 @@ def get_slice_with_timestamp_ns(dataframe: pd.DataFrame, timestamp_ns: int): def build_sensor_dataframe(source_log_path: Path) -> pd.DataFrame: + """Builds a sensor dataframe from the AV2 source log path.""" # https://github.com/argoverse/av2-api/blob/main/src/av2/datasets/sensor/sensor_dataloader.py#L209 @@ -64,6 +66,12 @@ def build_synchronization_dataframe( sensor_dataframe: pd.DataFrame, matching_criterion: Literal["nearest", "forward"] = "nearest", ) -> pd.DataFrame: + """Builds a synchronization dataframe, between sensors observations in a log. + + :param sensor_dataframe: DataFrame containing sensor data. + :param matching_criterion: Criterion for matching timestamps, defaults to "nearest" + :return: DataFrame containing synchronized sensor data. + """ # https://github.com/argoverse/av2-api/blob/main/src/av2/datasets/sensor/sensor_dataloader.py#L382 @@ -113,6 +121,7 @@ def build_synchronization_dataframe( def populate_sensor_records(sensor_path: Path, split: str, log_id: str) -> pd.DataFrame: + """Populate sensor records from a sensor path.""" sensor_name = sensor_path.name sensor_files = list(sensor_path.iterdir()) diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 500b117a..333edbf0 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -26,8 +26,7 @@ from py123d.conversion.datasets.kitti360.utils.preprocess_detection import process_detection from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.registry.box_detection_label_registry import KITTI360BoxDetectionLabel -from py123d.conversion.registry.lidar_index_registry import Kitti360LiDARIndex +from py123d.conversion.registry import KITTI360BoxDetectionLabel, Kitti360LiDARIndex from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.metadata import LogMetadata, MapMetadata from py123d.datatypes.sensors import ( @@ -42,13 +41,11 @@ PinholeDistortion, PinholeIntrinsics, ) -from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 -from py123d.datatypes.vehicle_state.vehicle_parameters import ( - get_kitti360_vw_passat_parameters, -) +from py123d.datatypes.time import TimePoint +from py123d.datatypes.vehicle_state import DynamicStateSE3, EgoStateSE3 +from py123d.datatypes.vehicle_state.vehicle_parameters import get_kitti360_vw_passat_parameters from py123d.geometry import BoundingBoxSE3, PoseSE3, Quaternion, Vector3D -from py123d.geometry.transform.transform_se3 import convert_se3_array_between_origins, translate_se3_along_body_frame +from py123d.geometry.transform import convert_se3_array_between_origins, translate_se3_along_body_frame KITTI360_DT: Final[float] = 0.1 @@ -110,6 +107,8 @@ def _get_kitti360_required_modality_roots(kitti360_folders: Dict[str, Path]) -> class Kitti360Converter(AbstractDatasetConverter): + """Converter class for KITTI-360 dataset.""" + def __init__( self, splits: List[str], @@ -121,6 +120,18 @@ def __init__( val_sequences: List[str], test_sequences: List[str], ) -> None: + """Initializes the Kitti360Converter. + + :param splits: List of splits to include in the conversion, e.g. `kitti360_train`, `kitti360_val`, `kitti360_test` + :param kitti360_data_root: Path to the KITTI-360 dataset root directory + :param detection_cache_root: Path to the detection cache directory + :param detection_radius: Radius for the box detections to include. + :param dataset_converter_config: Dataset converter configuration + :param train_sequences: List of sequences to include in the training split + :param val_sequences: List of sequences to include in the validation split + :param test_sequences: List of sequences to include in the test split + """ + assert kitti360_data_root is not None, "The variable `kitti360_data_root` must be provided." super().__init__(dataset_converter_config) for split in splits: @@ -199,19 +210,15 @@ def _has_modality(seq_name: str, modality_name: str, root: Path) -> bool: return log_paths_and_split def get_number_of_maps(self) -> int: - """Returns the number of available raw data maps for conversion.""" + """Inherited, see superclass.""" return self._total_maps def get_number_of_logs(self) -> int: - """Returns the number of available raw data logs for conversion.""" + """Inherited, see superclass.""" return self._total_logs def convert_map(self, map_index: int, map_writer: AbstractMapWriter) -> None: - """ - Convert a single map in raw data format to the uniform 123D format. - :param map_index: The index of the map to convert. - :param map_writer: The map writer to use for writing the converted map. - """ + """Inherited, see superclass.""" log_name, split = self._log_names_and_split[map_index] map_metadata = _get_kitti360_map_metadata(split, log_name) map_needs_writing = map_writer.reset(self.dataset_converter_config, map_metadata) @@ -220,11 +227,7 @@ def convert_map(self, map_index: int, map_writer: AbstractMapWriter) -> None: map_writer.close() def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: - """ - Convert a single log in raw data format to the uniform 123D format. - :param log_index: The index of the log to convert. - :param log_writer: The log writer to use for writing the converted log. - """ + """Inherited, see superclass.""" log_name, split = self._log_names_and_split[log_index] # Create log metadata @@ -309,6 +312,7 @@ def _get_kitti360_pinhole_camera_metadata( kitti360_folders: Dict[str, Path], dataset_converter_config: DatasetConverterConfig, ) -> Dict[PinholeCameraType, PinholeCameraMetadata]: + """Gets the KITTI-360 pinhole camera metadata from calibration files.""" pinhole_cam_metadatas: Dict[PinholeCameraType, PinholeCameraMetadata] = {} if dataset_converter_config.include_pinhole_cameras: @@ -344,6 +348,8 @@ def _get_kitti360_fisheye_mei_camera_metadata( kitti360_folders: Dict[str, Path], dataset_converter_config: DatasetConverterConfig, ) -> Dict[FisheyeMEICameraType, FisheyeMEICameraMetadata]: + """Gets the KITTI-360 fisheye MEI camera metadata from calibration files.""" + fisheye_cam_metadatas: Dict[FisheyeMEICameraType, FisheyeMEICameraMetadata] = {} if dataset_converter_config.include_fisheye_mei_cameras: @@ -386,6 +392,7 @@ def _get_kitti360_fisheye_mei_camera_metadata( def _get_kitti360_map_metadata(split: str, log_name: str) -> MapMetadata: + """Gets the KITTI-360 map metadata.""" return MapMetadata( dataset="kitti360", split=split, @@ -397,6 +404,7 @@ def _get_kitti360_map_metadata(split: str, log_name: str) -> MapMetadata: def _read_projection_matrix(p_line: str) -> np.ndarray: + """Helper function to read projection matrix from calibration file line.""" parts = p_line.split(" ", 1) if len(parts) != 2: raise ValueError(f"Bad projection line: {p_line}") @@ -407,7 +415,7 @@ def _read_projection_matrix(p_line: str) -> np.ndarray: def _readYAMLFile(fileName: Path) -> Dict[str, Any]: - """make OpenCV YAML file compatible with python""" + """Make OpenCV YAML file compatible with python""" ret = {} skip_lines = 1 # Skip the first line which says "%YAML:1.0". Or replace it with "%YAML 1.0" with open(fileName) as fin: @@ -421,9 +429,9 @@ def _readYAMLFile(fileName: Path) -> Dict[str, Any]: def _get_kitti360_lidar_metadata( - kitti360_folders: Dict[str, Path], - dataset_converter_config: DatasetConverterConfig, + kitti360_folders: Dict[str, Path], dataset_converter_config: DatasetConverterConfig ) -> Dict[LiDARType, LiDARMetadata]: + """Gets the KITTI-360 LiDAR metadata from calibration files.""" metadata: Dict[LiDARType, LiDARMetadata] = {} if dataset_converter_config.include_lidars: extrinsic = get_kitti360_lidar_extrinsic(kitti360_folders[DIR_CALIB]) @@ -438,9 +446,7 @@ def _get_kitti360_lidar_metadata( def _read_timestamps(log_name: str, kitti360_folders: Dict[str, Path]) -> Optional[List[TimePoint]]: - """ - Read KITTI-360 timestamps for the given sequence and return Unix epoch timestamps. - """ + """Read KITTI-360 timestamps for the given sequence and return Unix epoch timestamps.""" ts_files = [ kitti360_folders[DIR_3D_RAW] / log_name / "velodyne_points" / "timestamps.txt", kitti360_folders[DIR_2D_RAW] / log_name / "image_00" / "timestamps.txt", @@ -472,8 +478,9 @@ def _read_timestamps(log_name: str, kitti360_folders: Dict[str, Path]) -> Option def _extract_ego_state_all(log_name: str, kitti360_folders: Dict[str, Path]) -> Tuple[List[EgoStateSE3], List[int]]: + """Extracts all ego states for the given sequence.""" - ego_state_all: List[List[float]] = [] + ego_state_all: List[EgoStateSE3] = [] pose_file = kitti360_folders[DIR_POSES] / log_name / "poses.txt" if not pose_file.exists(): raise FileNotFoundError(f"Pose file not found: {pose_file}") @@ -540,6 +547,7 @@ def _extract_kitti360_box_detections_all( detection_cache_root: Path, detection_radius: float, ) -> List[BoxDetectionWrapper]: + """Extracts all KITTI-360 box detections for the given sequence.""" detections_states: List[List[List[float]]] = [[] for _ in range(ts_len)] detections_velocity: List[List[List[float]]] = [[] for _ in range(ts_len)] @@ -669,6 +677,7 @@ def _extract_kitti360_lidar( kitti360_folders: Dict[str, Path], data_converter_config: DatasetConverterConfig, ) -> List[LiDARData]: + """Extracts KITTI-360 LiDAR data for the given sequence and index.""" lidars: List[LiDARData] = [] if data_converter_config.include_lidars: @@ -700,6 +709,7 @@ def _extract_kitti360_pinhole_cameras( kitti360_folders: Dict[str, Path], data_converter_config: DatasetConverterConfig, ) -> List[CameraData]: + """Extracts KITTI-360 pinhole camera data for the given sequence and index.""" pinhole_camera_data_list: List[CameraData] = [] if data_converter_config.include_pinhole_cameras: @@ -726,7 +736,7 @@ def _extract_kitti360_fisheye_mei_cameras( kitti360_folders: Dict[str, Path], data_converter_config: DatasetConverterConfig, ) -> List[CameraData]: - + """Extracts KITTI-360 fisheye MEI camera data for the given sequence and index.""" fisheye_camera_data_list: List[CameraData] = [] if data_converter_config.include_fisheye_mei_cameras: for camera_type, cam_dir_name in KITTI360_FISHEYE_MEI_CAMERA_TYPES.items(): @@ -745,6 +755,7 @@ def _extract_kitti360_fisheye_mei_cameras( def _load_kitti_360_calibration(kitti_360_data_root: Path) -> Dict[str, PoseSE3]: + """Helper function to load KITTI-360 camera to IMU calibration.""" calib_file = kitti_360_data_root / DIR_CALIB / "calib_cam_to_pose.txt" if not calib_file.exists(): raise FileNotFoundError(f"Calibration file not found: {calib_file}") @@ -766,6 +777,7 @@ def _load_kitti_360_calibration(kitti_360_data_root: Path) -> Dict[str, PoseSE3] def _extrinsic_from_imu_to_rear_axle(extrinsic: PoseSE3) -> PoseSE3: + """Convert extrinsic from IMU origin to rear axle origin.""" imu_se3 = PoseSE3(x=-0.05, y=0.32, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) rear_axle_se3 = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) return PoseSE3.from_array(convert_se3_array_between_origins(imu_se3, rear_axle_se3, extrinsic.array)) diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py index d694dd28..ab7012f9 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py @@ -23,13 +23,9 @@ from py123d.geometry.polyline import Polyline3D MAX_ROAD_EDGE_LENGTH = 100.0 # meters, used to filter out very long road edges - KITTI360_DATA_ROOT = Path(os.environ["KITTI360_DATA_ROOT"]) - DIR_3D_BBOX = "data_3d_bboxes" - PATH_3D_BBOX_ROOT: Path = KITTI360_DATA_ROOT / DIR_3D_BBOX - KITTI360_MAP_BBOX = [ "road", "sidewalk", @@ -40,8 +36,7 @@ def convert_kitti360_map_with_writer(log_name: str, map_writer: AbstractMapWriter) -> None: - """ - Convert KITTI-360 map data using the provided map writer. + """Convert KITTI-360 map data using the provided map writer. This function extracts map data from KITTI-360 XML files and writes them using the map writer interface. :param log_name: The name of the log to convert diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py index 9b4d27d9..0baa5542 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py @@ -12,6 +12,8 @@ def load_kitti360_lidar_pcs_from_file(filepath: Path, log_metadata: LogMetadata) -> Dict[LiDARType, np.ndarray]: + """Loads KITTI-360 LiDAR point clouds the original binary files.""" + if not filepath.exists(): logging.warning(f"LiDAR file does not exist: {filepath}. Returning empty point cloud.") return {LiDARType.LIDAR_TOP: np.zeros((1, len(Kitti360LiDARIndex)), dtype=np.float32)} diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index 873d3ec7..5a589e7d 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -1,6 +1,6 @@ import pickle from pathlib import Path -from typing import Dict, Final, List, Optional, Tuple, Union +from typing import Dict, Final, List, Tuple, Union import numpy as np import yaml @@ -24,24 +24,25 @@ ) from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.registry.box_detection_label_registry import NuPlanBoxDetectionLabel -from py123d.conversion.registry.lidar_index_registry import NuPlanLiDARIndex -from py123d.datatypes.detections.box_detections import BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetection, TrafficLightDetectionWrapper -from py123d.datatypes.metadata import LogMetadata -from py123d.datatypes.metadata.map_metadata import MapMetadata -from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import ( +from py123d.conversion.registry import NuPlanBoxDetectionLabel, NuPlanLiDARIndex +from py123d.datatypes.detections import ( + BoxDetectionSE3, + BoxDetectionWrapper, + TrafficLightDetection, + TrafficLightDetectionWrapper, +) +from py123d.datatypes.metadata import LogMetadata, MapMetadata +from py123d.datatypes.sensors import ( + LiDARMetadata, + LiDARType, PinholeCameraMetadata, PinholeCameraType, PinholeDistortion, PinholeIntrinsics, ) -from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 -from py123d.datatypes.vehicle_state.vehicle_parameters import ( - get_nuplan_chrysler_pacifica_parameters, -) +from py123d.datatypes.time import TimePoint +from py123d.datatypes.vehicle_state import DynamicStateSE3, EgoStateSE3 +from py123d.datatypes.vehicle_state.vehicle_parameters import get_nuplan_chrysler_pacifica_parameters from py123d.geometry import PoseSE3, Vector3D check_dependencies(["nuplan"], "nuplan") @@ -75,6 +76,8 @@ def create_splits_logs() -> Dict[str, List[str]]: class NuPlanConverter(AbstractDatasetConverter): + """Converter class for the nuPlan dataset.""" + def __init__( self, splits: List[str], @@ -83,6 +86,15 @@ def __init__( nuplan_sensor_root: Union[Path, str], dataset_converter_config: DatasetConverterConfig, ) -> None: + """Initializes the NuPlanConverter. + + :param splits: List of splits to convert, i.e., ["nuplan_train", "nuplan_val", "nuplan_test"] + :param nuplan_data_root: Root directory of the nuPlan data. + :param nuplan_maps_root: Root directory of the nuPlan maps. + :param nuplan_sensor_root: Root directory of the nuPlan sensor data. + :param dataset_converter_config: Configuration for the dataset converter. + """ + super().__init__(dataset_converter_config) assert nuplan_data_root is not None, "The variable `nuplan_data_root` must be provided." assert nuplan_maps_root is not None, "The variable `nuplan_maps_root` must be provided." @@ -97,13 +109,15 @@ def __init__( self._nuplan_maps_root: Path = Path(nuplan_maps_root) self._nuplan_sensor_root: Path = Path(nuplan_sensor_root) - self._split_log_path_pairs: List[Tuple[str, List[Path]]] = self._collect_split_log_path_pairs() + self._split_log_path_pairs: List[Tuple[str, Path]] = self._collect_split_log_path_pairs() + + def _collect_split_log_path_pairs(self) -> List[Tuple[str, Path]]: + """Collects the (split, log_path) pairs for the specified splits.""" - def _collect_split_log_path_pairs(self) -> List[Tuple[str, List[Path]]]: # NOTE: the nuplan mini folder has an internal train, val, test structure, all stored in "mini". # The complete dataset is saved in the "trainval" folder (train and val), or in the "test" folder (for test). # Thus, we need filter the logs in a split, based on the internal nuPlan configuration. - split_log_path_pairs: List[Tuple[str, List[Path]]] = [] + split_log_path_pairs: List[Tuple[str, Path]] = [] log_names_per_split = create_splits_logs() for split in self._splits: @@ -116,20 +130,13 @@ def _collect_split_log_path_pairs(self) -> List[Tuple[str, List[Path]]]: nuplan_split_folder = self._nuplan_data_root / "nuplan-v1.1" / "splits" / "test" elif split in ["nuplan-mini_train", "nuplan-mini_val", "nuplan-mini_test"]: nuplan_split_folder = self._nuplan_data_root / "nuplan-v1.1" / "splits" / "mini" - elif split == "nuplan-private_test": - # TODO: Remove private split - nuplan_split_folder = self._nuplan_data_root / "nuplan-v1.1" / "splits" / "private_test" + else: + raise ValueError(f"Unknown nuPlan split: {split}") all_log_files_in_path = [log_file for log_file in nuplan_split_folder.glob("*.db")] - - # TODO: Remove private split - if split == "nuplan-private_test": - valid_log_names = [str(log_file.stem) for log_file in all_log_files_in_path] - else: - all_log_files_in_path = [log_file for log_file in nuplan_split_folder.glob("*.db")] - all_log_names = set([str(log_file.stem) for log_file in all_log_files_in_path]) - log_names_in_split = set(log_names_per_split[split_type]) - valid_log_names = list(all_log_names & log_names_in_split) + all_log_names = set([str(log_file.stem) for log_file in all_log_files_in_path]) + log_names_in_split = set(log_names_per_split[split_type]) + valid_log_names = list(all_log_names & log_names_in_split) for log_name in valid_log_names: log_path = nuplan_split_folder / f"{log_name}.db" @@ -165,9 +172,7 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: """Inherited, see superclass.""" split, source_log_path = self._split_log_path_pairs[log_index] - - nuplan_log_db = NuPlanDB(self._nuplan_data_root, str(source_log_path), None) - + nuplan_log_db = NuPlanDB(str(self._nuplan_data_root), str(source_log_path), None) log_name = nuplan_log_db.log_name # 1. Initialize log metadata @@ -180,10 +185,14 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: vehicle_parameters=get_nuplan_chrysler_pacifica_parameters(), box_detection_label_class=NuPlanBoxDetectionLabel, pinhole_camera_metadata=_get_nuplan_camera_metadata( - source_log_path, self._nuplan_sensor_root, self.dataset_converter_config + source_log_path, + self._nuplan_sensor_root, + self.dataset_converter_config, ), lidar_metadata=_get_nuplan_lidar_metadata( - self._nuplan_sensor_root, log_name, self.dataset_converter_config + self._nuplan_sensor_root, + log_name, + self.dataset_converter_config, ), map_metadata=_get_nuplan_map_metadata(nuplan_log_db.log.map_version), ) @@ -228,6 +237,7 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: def _get_nuplan_map_metadata(location: str) -> MapMetadata: + """Gets the nuPlan map metadata for a given location.""" return MapMetadata( dataset="nuplan", split=None, @@ -243,6 +253,7 @@ def _get_nuplan_camera_metadata( nuplan_sensor_root: Path, dataset_converter_config: DatasetConverterConfig, ) -> Dict[PinholeCameraType, PinholeCameraMetadata]: + """Extracts the nuPlan camera metadata for a given log.""" def _get_camera_metadata(camera_type: PinholeCameraType) -> PinholeCameraMetadata: cam = list(get_cameras(source_log_path, [str(NUPLAN_CAMERA_MAPPING[camera_type].value)]))[0] @@ -277,10 +288,9 @@ def _get_nuplan_lidar_metadata( log_name: str, dataset_converter_config: DatasetConverterConfig, ) -> Dict[LiDARType, LiDARMetadata]: - + """Extracts the nuPlan LiDAR metadata for a given log.""" metadata: Dict[LiDARType, LiDARMetadata] = {} log_lidar_folder = nuplan_sensor_root / log_name / "MergedPointCloud" - # NOTE: We first need to check if the LiDAR folder exists, as not all logs have LiDAR data if log_lidar_folder.exists() and log_lidar_folder.is_dir() and dataset_converter_config.include_lidars: for lidar_type in NUPLAN_LIDAR_DICT.values(): @@ -293,6 +303,7 @@ def _get_nuplan_lidar_metadata( def _extract_nuplan_ego_state(nuplan_lidar_pc: LidarPc) -> EgoStateSE3: + """Extracts the nuPlan ego state from a given LidarPc database objects.""" vehicle_parameters = get_nuplan_chrysler_pacifica_parameters() rear_axle_pose = PoseSE3( @@ -329,13 +340,15 @@ def _extract_nuplan_ego_state(nuplan_lidar_pc: LidarPc) -> EgoStateSE3: def _extract_nuplan_box_detections(lidar_pc: LidarPc, source_log_path: Path) -> BoxDetectionWrapper: - box_detections: List[BoxDetectionSE3] = list( - get_box_detections_for_lidarpc_token_from_db(source_log_path, lidar_pc.token) + """Extracts the nuPlan box detections from a given LidarPc database objects.""" + box_detections: List[BoxDetectionSE3] = get_box_detections_for_lidarpc_token_from_db( + str(source_log_path), lidar_pc.token ) return BoxDetectionWrapper(box_detections=box_detections) def _extract_nuplan_traffic_lights(log_db: NuPlanDB, lidar_pc_token: str) -> TrafficLightDetectionWrapper: + """Extracts the nuPlan traffic light detections from a given LidarPc database objects.""" traffic_lights_detections: List[TrafficLightDetection] = [ TrafficLightDetection( timepoint=None, # NOTE: Timepoint is not needed during writing, set to None @@ -354,15 +367,13 @@ def _extract_nuplan_cameras( nuplan_sensor_root: Path, dataset_converter_config: DatasetConverterConfig, ) -> List[CameraData]: - + """Extracts the nuPlan camera data from a given LidarPc database objects.""" camera_data_list: List[CameraData] = [] - if dataset_converter_config.include_pinhole_cameras: log_cam_infos = {camera.token: camera for camera in nuplan_log_db.log.cameras} for camera_type, camera_channel in NUPLAN_CAMERA_MAPPING.items(): - camera_data: Optional[Union[str, bytes]] = None image_class = list( - get_images_from_lidar_tokens(source_log_path, [nuplan_lidar_pc.token], [str(camera_channel.value)]) + get_images_from_lidar_tokens(str(source_log_path), [nuplan_lidar_pc.token], [str(camera_channel.value)]) ) if len(image_class) != 0: @@ -401,7 +412,6 @@ def _extract_nuplan_cameras( relative_path=filename_jpg.relative_to(nuplan_sensor_root), ) ) - return camera_data_list @@ -410,10 +420,9 @@ def _extract_nuplan_lidars( nuplan_sensor_root: Path, dataset_converter_config: DatasetConverterConfig, ) -> List[LiDARData]: - + """Extracts the nuPlan LiDAR data from a given LidarPc database objects.""" lidars: List[LiDARData] = [] if dataset_converter_config.include_lidars: - lidar_full_path = nuplan_sensor_root / nuplan_lidar_pc.filename if lidar_full_path.exists() and lidar_full_path.is_file(): lidars.append( @@ -423,11 +432,11 @@ def _extract_nuplan_lidars( relative_path=nuplan_lidar_pc.filename, ) ) - return lidars def _extract_nuplan_scenario_tag(nuplan_log_db: NuPlanDB, lidar_pc_token: str) -> List[str]: + """Extracts the nuPlan scenario tags from a given LidarPc database objects.""" scenario_tags = [ scenario_tag.type for scenario_tag in nuplan_log_db.scenario_tag.select_many(lidar_pc_token=lidar_pc_token) ] @@ -437,6 +446,7 @@ def _extract_nuplan_scenario_tag(nuplan_log_db: NuPlanDB, lidar_pc_token: str) - def _extract_nuplan_route_lane_group_ids(nuplan_lidar_pc: LidarPc) -> List[int]: + """Extracts the nuPlan route lane group IDs from a given LidarPc database objects.""" return [ int(roadblock_id) for roadblock_id in str(nuplan_lidar_pc.scene.roadblock_ids).split(" ") diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py index ad4ca22f..d72f081a 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py @@ -30,12 +30,13 @@ RoadLine, Walkway, ) -from py123d.geometry.polyline import Polyline2D, Polyline3D +from py123d.geometry import Polyline2D, Polyline3D -MAX_ROAD_EDGE_LENGTH: Final[float] = 100.0 # meters, used to filter out very long road edges. TODO @add to config? +MAX_ROAD_EDGE_LENGTH: Final[float] = 100.0 # meters, used to filter out very long road edges. TODO add to config? def write_nuplan_map(nuplan_maps_root: Path, location: str, map_writer: AbstractMapWriter) -> None: + """Convert nuPlan map data using the provided map writer.""" assert location in NUPLAN_MAP_LOCATION_FILES.keys(), f"Map name {location} is not supported." source_map_path = nuplan_maps_root / NUPLAN_MAP_LOCATION_FILES[location] assert source_map_path.exists(), f"Map file {source_map_path} does not exist." @@ -55,6 +56,7 @@ def write_nuplan_map(nuplan_maps_root: Path, location: str, map_writer: Abstract def _write_nuplan_lanes(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan lanes to the map writer.""" # NOTE: drops: lane_index (?), creator_id, name (?), road_type_fid (?), lane_type_fid (?), width (?), # left_offset (?), right_offset (?), min_speed (?), max_speed (?), stops, left_has_reflectors (?), @@ -123,6 +125,7 @@ def _write_nuplan_lanes(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: Abs def _write_nuplan_lane_connectors(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan lane connectors (lanes on intersections) to the map writer.""" # NOTE: drops: exit_lane_group_fid, entry_lane_group_fid, to_edge_fid, # turn_type_fid (?), bulb_fids (?), traffic_light_stop_line_fids (?), overlap (?), creator_id @@ -176,6 +179,8 @@ def _write_nuplan_lane_connectors(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_w def _write_nuplan_lane_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan lane groups to the map writer.""" + # NOTE: drops: creator_id, from_edge_fid, to_edge_fid ids = nuplan_gdf["lane_groups_polygons"].fid.to_list() # all_geometries = nuplan_gdf["lane_groups_polygons"].geometry.to_list() @@ -231,6 +236,8 @@ def _write_nuplan_lane_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_write def _write_nuplan_lane_connector_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan lane connector groups (lane groups on intersections) to the map writer.""" + # NOTE: drops: creator_id, from_edge_fid, to_edge_fid, intersection_fid ids = nuplan_gdf["lane_group_connectors"].fid.to_list() all_intersection_ids = nuplan_gdf["lane_group_connectors"].intersection_fid.to_list() @@ -272,6 +279,7 @@ def _write_nuplan_lane_connector_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], def _write_nuplan_intersections(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan intersections to the map writer.""" # NOTE: drops: creator_id, intersection_type_fid (?), is_mini (?) all_ids = nuplan_gdf["intersections"].fid.to_list() all_geometries = nuplan_gdf["intersections"].geometry.to_list() @@ -290,24 +298,28 @@ def _write_nuplan_intersections(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_wri def _write_nuplan_crosswalks(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan crosswalks to the map writer.""" # NOTE: drops: creator_id, intersection_fids, lane_fids, is_marked (?) for id, geometry in zip(nuplan_gdf["crosswalks"].fid.to_list(), nuplan_gdf["crosswalks"].geometry.to_list()): map_writer.write_crosswalk(Crosswalk(object_id=id, shapely_polygon=geometry)) def _write_nuplan_walkways(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan walkways to the map writer.""" # NOTE: drops: creator_id for id, geometry in zip(nuplan_gdf["walkways"].fid.to_list(), nuplan_gdf["walkways"].geometry.to_list()): map_writer.write_walkway(Walkway(object_id=id, shapely_polygon=geometry)) def _write_nuplan_carparks(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan carparks to the map writer.""" # NOTE: drops: creator_id for id, geometry in zip(nuplan_gdf["carpark_areas"].fid.to_list(), nuplan_gdf["carpark_areas"].geometry.to_list()): map_writer.write_carpark(Carpark(object_id=id, shapely_polygon=geometry)) def _write_nuplan_generic_drivables(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan generic drivable areas to the map writer.""" # NOTE: drops: creator_id for id, geometry in zip( nuplan_gdf["generic_drivable_areas"].fid.to_list(), nuplan_gdf["generic_drivable_areas"].geometry.to_list() @@ -316,6 +328,7 @@ def _write_nuplan_generic_drivables(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map def _write_nuplan_road_edges(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan road edges to the map writer.""" drivable_polygons = ( nuplan_gdf["intersections"].geometry.to_list() + nuplan_gdf["lane_groups_polygons"].geometry.to_list() @@ -336,6 +349,7 @@ def _write_nuplan_road_edges(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer def _write_nuplan_road_lines(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: AbstractMapWriter) -> None: + """Write nuPlan road lines to the map writer.""" boundaries = nuplan_gdf["boundaries"].geometry.to_list() fids = nuplan_gdf["boundaries"].fid.to_list() boundary_types = nuplan_gdf["boundaries"].boundary_type_fid.to_list() @@ -351,6 +365,7 @@ def _write_nuplan_road_lines(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer def _load_nuplan_gdf(map_file_path: Path) -> Dict[str, gpd.GeoDataFrame]: + """Load nuPlan map data from a GPKG file into a dictionary of GeoDataFrames.""" # The projected coordinate system depends on which UTM zone the mapped location is in. map_meta = gpd.read_file(map_file_path, layer="meta", engine="pyogrio") @@ -377,11 +392,14 @@ def _load_nuplan_gdf(map_file_path: Path) -> Dict[str, gpd.GeoDataFrame]: def _flip_linestring(linestring: LineString) -> LineString: + """Flips the direction of a shapely LineString.""" # TODO: move somewhere more appropriate or implement in Polyline2D, PolylineSE2, etc. return LineString(linestring.coords[::-1]) def lines_same_direction(centerline: LineString, boundary: LineString) -> bool: + """Check if the boundary LineString is in the same direction as the centerline LineString.""" + # TODO: refactor helper function. center_start = np.array(centerline.coords[0]) center_end = np.array(centerline.coords[-1]) @@ -396,6 +414,7 @@ def lines_same_direction(centerline: LineString, boundary: LineString) -> bool: def align_boundary_direction(centerline: LineString, boundary: LineString) -> LineString: + """Aligns the boundary LineString direction to be the same as the centerline LineString direction.""" # TODO: refactor helper function. if not lines_same_direction(centerline, boundary): return _flip_linestring(boundary) diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_sensor_io.py b/src/py123d/conversion/datasets/nuplan/nuplan_sensor_io.py index 8c2506f0..5cd1ffbc 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_sensor_io.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_sensor_io.py @@ -6,14 +6,16 @@ from py123d.common.utils.dependencies import check_dependencies from py123d.conversion.datasets.nuplan.utils.nuplan_constants import NUPLAN_LIDAR_DICT -from py123d.conversion.registry.lidar_index_registry import NuPlanLiDARIndex -from py123d.datatypes.sensors.lidar import LiDARType +from py123d.conversion.registry import NuPlanLiDARIndex +from py123d.datatypes.sensors import LiDARType check_dependencies(["nuplan"], "nuplan") from nuplan.database.utils.pointclouds.lidar import LidarPointCloud def load_nuplan_lidar_pcs_from_file(pcd_path: Path) -> Dict[LiDARType, np.ndarray]: + """Loads nuPlan LiDAR point clouds from a ``.pcd`` file.""" + assert pcd_path.exists(), f"LiDAR file not found: {pcd_path}" with open(pcd_path, "rb") as fp: buffer = io.BytesIO(fp.read()) diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py index 4470821b..fd655ff5 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_constants.py @@ -1,10 +1,10 @@ from typing import Dict, Final, List, Set -from py123d.conversion.registry.box_detection_label_registry import NuPlanBoxDetectionLabel -from py123d.datatypes.detections.traffic_light_detections import TrafficLightStatus -from py123d.datatypes.map_objects.map_layer_types import RoadLineType -from py123d.datatypes.sensors.lidar import LiDARType -from py123d.datatypes.time.time_point import TimePoint +from py123d.conversion.registry import NuPlanBoxDetectionLabel +from py123d.datatypes.detections import TrafficLightStatus +from py123d.datatypes.map_objects import RoadLineType +from py123d.datatypes.sensors import LiDARType +from py123d.datatypes.time import TimePoint NUPLAN_DEFAULT_DT: Final[float] = 0.05 diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py index b4a08a3b..b2fa144f 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py @@ -11,6 +11,7 @@ def get_box_detections_for_lidarpc_token_from_db(log_file: str, token: str) -> List[BoxDetectionSE3]: + """Gets the box detections for a given LiDAR point cloud token from the NuPlan database.""" query = """ SELECT c.name AS category_name, @@ -69,6 +70,7 @@ def get_box_detections_for_lidarpc_token_from_db(log_file: str, token: str) -> L def get_ego_pose_for_timestamp_from_db(log_file: str, timestamp: int) -> PoseSE3: + """Gets the ego pose for a given timestamp from the NuPlan database.""" query = """ SELECT ep.x, @@ -89,9 +91,7 @@ def get_ego_pose_for_timestamp_from_db(log_file: str, timestamp: int) -> PoseSE3 """ row = execute_one(query, (timestamp,), log_file) - if row is None: - return None - + assert row is not None, f"No ego pose found for timestamp {timestamp} in log file {log_file}" return PoseSE3(x=row["x"], y=row["y"], z=row["z"], qw=row["qw"], qx=row["qx"], qy=row["qy"], qz=row["qz"]) @@ -102,6 +102,7 @@ def get_nearest_ego_pose_for_timestamp_from_db( lookahead_window_us: int = 50000, lookback_window_us: int = 50000, ) -> PoseSE3: + """Gets the nearest ego pose for a given timestamp from the NuPlan database within a lookahead and lookback window.""" query = f""" SELECT ep.x, diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 5bc4ad4f..72865d85 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -42,6 +42,8 @@ class NuScenesConverter(AbstractDatasetConverter): + """Dataset converter for the nuScenes dataset.""" + def __init__( self, splits: List[str], @@ -52,6 +54,16 @@ def __init__( dataset_converter_config: DatasetConverterConfig, version: str = "v1.0-mini", ) -> None: + """Initializes the :class:`NuScenesConverter`. + + :param splits: List of splits to include in the conversion, e.g., ["nuscenes_train", "nuscenes_val"] + :param nuscenes_data_root: Path to the root directory of the nuScenes dataset + :param nuscenes_map_root: Path to the root directory of the nuScenes map data + :param nuscenes_lanelet2_root: Path to the root directory of the nuScenes Lanelet2 data + :param use_lanelet2: Whether to use Lanelet2 data for map conversion + :param dataset_converter_config: Configuration for the dataset converter + :param version: Version of the nuScenes dataset, defaults to "v1.0-mini" + """ super().__init__(dataset_converter_config) assert nuscenes_data_root is not None, "The variable `nuscenes_data_root` must be provided." @@ -61,12 +73,6 @@ def __init__( split in NUSCENES_DATA_SPLITS ), f"Split {split} is not available. Available splits: {NUSCENES_DATA_SPLITS}" - if dataset_converter_config.include_lidars: - assert dataset_converter_config.lidar_store_option in ["path", "binary"], ( - f"Invalid lidar_store_option: {dataset_converter_config.lidar_store_option}. " - f"Supported options are 'path' and 'binary'." - ) - self._splits: List[str] = splits self._nuscenes_data_root: Path = Path(nuscenes_data_root) @@ -78,6 +84,7 @@ def __init__( self._scene_tokens_per_split: Dict[str, List[str]] = self._collect_scene_tokens() def _collect_scene_tokens(self) -> Dict[str, List[str]]: + """Collects scene tokens for the specified splits.""" scene_tokens_per_split: Dict[str, List[str]] = {} nusc = NuScenes(version=self._version, dataroot=str(self._nuscenes_data_root), verbose=False) @@ -205,21 +212,18 @@ def _get_nuscenes_pinhole_camera_metadata( scene: Dict[str, Any], dataset_converter_config: DatasetConverterConfig, ) -> Dict[PinholeCameraType, PinholeCameraMetadata]: + """Extracts the pinhole camera metadata from a nuScenes scene.""" camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} - if dataset_converter_config.include_pinhole_cameras: first_sample_token = scene["first_sample_token"] first_sample = nusc.get("sample", first_sample_token) - for camera_type, camera_channel in NUSCENES_CAMERA_TYPES.items(): cam_token = first_sample["data"][camera_channel] cam_data = nusc.get("sample_data", cam_token) calib = nusc.get("calibrated_sensor", cam_data["calibrated_sensor_token"]) - intrinsic_matrix = np.array(calib["camera_intrinsic"]) intrinsic = PinholeIntrinsics.from_camera_matrix(intrinsic_matrix) distortion = PinholeDistortion.from_array(np.zeros(5), copy=False) - camera_metadata[camera_type] = PinholeCameraMetadata( camera_type=camera_type, width=cam_data["width"], @@ -227,7 +231,6 @@ def _get_nuscenes_pinhole_camera_metadata( intrinsics=intrinsic, distortion=distortion, ) - return camera_metadata @@ -236,32 +239,30 @@ def _get_nuscenes_lidar_metadata( scene: Dict[str, Any], dataset_converter_config: DatasetConverterConfig, ) -> Dict[LiDARType, LiDARMetadata]: + """Extracts the LiDAR metadata from a nuScenes scene.""" metadata: Dict[LiDARType, LiDARMetadata] = {} - if dataset_converter_config.include_lidars: first_sample_token = scene["first_sample_token"] first_sample = nusc.get("sample", first_sample_token) lidar_token = first_sample["data"]["LIDAR_TOP"] lidar_data = nusc.get("sample_data", lidar_token) calib = nusc.get("calibrated_sensor", lidar_data["calibrated_sensor_token"]) - translation = np.array(calib["translation"]) rotation = Quaternion(calib["rotation"]).rotation_matrix extrinsic = np.eye(4) extrinsic[:3, :3] = rotation extrinsic[:3, 3] = translation extrinsic = PoseSE3.from_transformation_matrix(extrinsic) - metadata[LiDARType.LIDAR_TOP] = LiDARMetadata( lidar_type=LiDARType.LIDAR_TOP, lidar_index=NuScenesLiDARIndex, extrinsic=extrinsic, ) - return metadata def _get_nuscenes_map_metadata(location): + """Creates nuScenes map metadata for a given location.""" return MapMetadata( dataset="nuscenes", split=None, @@ -273,11 +274,10 @@ def _get_nuscenes_map_metadata(location): def _extract_nuscenes_ego_state(nusc, sample, can_bus) -> EgoStateSE3: + """Extracts the ego state from a nuScenes sample.""" lidar_data = nusc.get("sample_data", sample["data"]["LIDAR_TOP"]) ego_pose = nusc.get("ego_pose", lidar_data["ego_pose_token"]) - quat = Quaternion(ego_pose["rotation"]) - vehicle_parameters = get_nuscenes_renault_zoe_parameters() imu_pose = PoseSE3( x=ego_pose["translation"][0], @@ -288,14 +288,11 @@ def _extract_nuscenes_ego_state(nusc, sample, can_bus) -> EgoStateSE3: qy=quat.y, qz=quat.z, ) - scene_name = nusc.get("scene", sample["scene_token"])["name"] - try: pose_msgs = can_bus.get_messages(scene_name, "pose") except Exception: pose_msgs = [] - if pose_msgs: closest_msg = None min_time_diff = float("inf") @@ -327,8 +324,8 @@ def _extract_nuscenes_ego_state(nusc, sample, can_bus) -> EgoStateSE3: def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> BoxDetectionWrapper: + """Extracts the box detections from a nuScenes sample.""" box_detections: List[BoxDetectionSE3] = [] - for ann_token in sample["anns"]: ann = nusc.get("sample_annotation", ann_token) box = Box(ann["translation"], ann["size"], Quaternion(ann["rotation"])) @@ -364,14 +361,12 @@ def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> timepoint=TimePoint.from_us(sample["timestamp"]), num_lidar_points=ann.get("num_lidar_pts", 0), ) - box_detection = BoxDetectionSE3( metadata=metadata, bounding_box_se3=bounding_box, velocity=velocity_3d, ) box_detections.append(box_detection) - return BoxDetectionWrapper(box_detections=box_detections) @@ -381,8 +376,8 @@ def _extract_nuscenes_cameras( nuscenes_data_root: Path, dataset_converter_config: DatasetConverterConfig, ) -> List[CameraData]: + """Extracts the pinhole camera metadata from a nuScenes scene.""" camera_data_list: List[CameraData] = [] - if dataset_converter_config.include_pinhole_cameras: for camera_type, camera_channel in NUSCENES_CAMERA_TYPES.items(): cam_token = sample["data"][camera_channel] @@ -402,7 +397,6 @@ def _extract_nuscenes_cameras( extrinsic = PoseSE3.from_transformation_matrix(extrinsic_matrix) cam_path = nuscenes_data_root / str(cam_data["filename"]) - if cam_path.exists() and cam_path.is_file(): # camera_dict[camera_type] = (camera_data, extrinsic) camera_data_list.append( @@ -423,13 +417,12 @@ def _extract_nuscenes_lidars( nuscenes_data_root: Path, dataset_converter_config: DatasetConverterConfig, ) -> List[LiDARData]: + """Extracts the LiDAR data from a nuScenes sample.""" lidars: List[LiDARData] = [] - if dataset_converter_config.include_lidars: lidar_token = sample["data"]["LIDAR_TOP"] lidar_data = nusc.get("sample_data", lidar_token) absolute_lidar_path = nuscenes_data_root / lidar_data["filename"] - if absolute_lidar_path.exists() and absolute_lidar_path.is_file(): lidar = LiDARData( lidar_type=LiDARType.LIDAR_TOP, diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py index d392e7c1..8a0cb348 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py @@ -152,7 +152,7 @@ def _extract_nuscenes_lanes(nuscenes_map: NuScenesMap) -> List[Lane]: successor_ids=outgoing, speed_limit_mps=None, outline=None, - geometry=None, + shapely_polygon=None, ) ) @@ -200,7 +200,7 @@ def _extract_nuscenes_lane_connectors(nuscenes_map: NuScenesMap, road_edges: Lis successor_ids=successor_ids, speed_limit_mps=None, # Default value outline=None, - geometry=None, + shapely_polygon=None, ) ) @@ -266,7 +266,7 @@ def _extract_nuscenes_lane_groups( predecessor_ids=list(predecessor_ids), successor_ids=list(successor_ids), outline=None, - geometry=None, + shapely_polygon=None, ) ) @@ -304,7 +304,7 @@ def _write_nuscenes_intersections( object_id=idx, lane_group_ids=intersecting_lane_connector_ids, outline=None, - geometry=intersection_polygon, + shapely_polygon=intersection_polygon, ) ) @@ -321,7 +321,7 @@ def _write_nuscenes_crosswalks(nuscenes_map: NuScenesMap, map_writer: AbstractMa crosswalk_polygons.append(polygon) for idx, polygon in enumerate(crosswalk_polygons): - map_writer.write_crosswalk(Crosswalk(object_id=idx, geometry=polygon)) + map_writer.write_crosswalk(Crosswalk(object_id=idx, shapely_polygon=polygon)) def _write_nuscenes_walkways(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: @@ -333,7 +333,7 @@ def _write_nuscenes_walkways(nuscenes_map: NuScenesMap, map_writer: AbstractMapW walkway_polygons.append(polygon) for idx, polygon in enumerate(walkway_polygons): - map_writer.write_walkway(Walkway(object_id=idx, geometry=polygon)) + map_writer.write_walkway(Walkway(object_id=idx, shapely_polygon=polygon)) def _write_nuscenes_carparks(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: @@ -345,7 +345,7 @@ def _write_nuscenes_carparks(nuscenes_map: NuScenesMap, map_writer: AbstractMapW carpark_polygons.append(polygon) for idx, polygon in enumerate(carpark_polygons): - map_writer.write_carpark(Carpark(object_id=idx, geometry=polygon)) + map_writer.write_carpark(Carpark(object_id=idx, shapely_polygon=polygon)) def _write_nuscenes_generic_drivables(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: @@ -362,7 +362,7 @@ def _write_nuscenes_generic_drivables(nuscenes_map: NuScenesMap, map_writer: Abs # drivable_polygons.append(polygon) for idx, geometry in enumerate(drivable_polygons): - map_writer.write_generic_drivable(GenericDrivable(object_id=idx, geometry=geometry)) + map_writer.write_generic_drivable(GenericDrivable(object_id=idx, shapely_polygon=geometry)) def _write_nuscenes_stop_zones(nuscenes_map: NuScenesMap, map_writer: AbstractMapWriter) -> None: @@ -406,7 +406,7 @@ def _write_nuscenes_road_lines(nuscenes_map: NuScenesMap, map_writer: AbstractMa RoadLine( object_id=running_idx, road_line_type=line_type, - polyline=Polyline3D(LineString(line.coords)), + polyline=Polyline3D.from_linestring(LineString(line.coords)), ) ) running_idx += 1 @@ -421,7 +421,7 @@ def _write_nuscenes_road_lines(nuscenes_map: NuScenesMap, map_writer: AbstractMa RoadLine( object_id=running_idx, road_line_type=line_type, - polyline=Polyline3D(LineString(line.coords)), + polyline=Polyline3D.from_linestring(LineString(line.coords)), ) ) running_idx += 1 diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py index f6f0757f..00ff2397 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_sensor_io.py @@ -11,6 +11,8 @@ def load_nuscenes_lidar_pcs_from_file(pcd_path: Path, log_metadata: LogMetadata) -> Dict[LiDARType, np.ndarray]: + """Loads nuScenes LiDAR point clouds from the original binary files.""" + lidar_pc = np.fromfile(pcd_path, dtype=np.float32).reshape(-1, len(NuScenesLiDARIndex)) # convert lidar to ego frame diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index 4c3fb3e9..d46ce762 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -24,22 +24,28 @@ ) from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.registry.box_detection_label_registry import PandasetBoxDetectionLabel -from py123d.conversion.registry.lidar_index_registry import PandasetLiDARIndex -from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper +from py123d.conversion.registry import PandasetBoxDetectionLabel, PandasetLiDARIndex +from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.metadata import LogMetadata -from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType -from py123d.datatypes.sensors.pinhole_camera import PinholeCameraMetadata, PinholeCameraType, PinholeIntrinsics -from py123d.datatypes.time.time_point import TimePoint -from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 +from py123d.datatypes.sensors import ( + LiDARMetadata, + LiDARType, + PinholeCameraMetadata, + PinholeCameraType, + PinholeIntrinsics, +) +from py123d.datatypes.time import TimePoint +from py123d.datatypes.vehicle_state import EgoStateSE3 from py123d.datatypes.vehicle_state.vehicle_parameters import get_pandaset_chrysler_pacifica_parameters -from py123d.geometry import BoundingBoxSE3, BoundingBoxSE3Index, EulerAnglesIndex, PoseSE3, Vector3D -from py123d.geometry.transform.transform_se3 import convert_absolute_to_relative_se3_array +from py123d.geometry import BoundingBoxSE3, BoundingBoxSE3Index, EulerAnglesIndex, PoseSE3 +from py123d.geometry.transform import convert_absolute_to_relative_se3_array from py123d.geometry.utils.constants import DEFAULT_PITCH, DEFAULT_ROLL from py123d.geometry.utils.rotation_utils import get_quaternion_array_from_euler_array class PandasetConverter(AbstractDatasetConverter): + """Converter for the Pandaset dataset.""" + def __init__( self, splits: List[str], @@ -49,6 +55,16 @@ def __init__( val_log_names: List[str], test_log_names: List[str], ) -> None: + """Initializes the :class:`PandasetConverter`. + + :param splits: List of splits to include in the conversion. \ + Available splits: 'pandaset_train', 'pandaset_val', 'pandaset_test'. + :param pandaset_data_root: Path to the root directory of the Pandaset dataset + :param dataset_converter_config: Configuration for the dataset converter + :param train_log_names: List of log names to include in the training split + :param val_log_names: List of log names to include in the validation split + :param test_log_names: List of log names to include in the test split + """ super().__init__(dataset_converter_config) for split in splits: assert split in PANDASET_SPLITS, f"Split {split} is not available. Available splits: {PANDASET_SPLITS}" @@ -60,9 +76,9 @@ def __init__( self._train_log_names: List[str] = train_log_names self._val_log_names: List[str] = val_log_names self._test_log_names: List[str] = test_log_names - self._log_paths_and_split: Dict[str, List[Path]] = self._collect_log_paths() + self._log_paths_and_split: List[Tuple[Path, str]] = self._collect_log_paths() - def _collect_log_paths(self) -> Dict[str, List[Path]]: + def _collect_log_paths(self) -> List[Tuple[Path, str]]: log_paths_and_split: List[Tuple[Path, str]] = [] for log_folder in self._pandaset_data_root.iterdir(): @@ -82,7 +98,7 @@ def _collect_log_paths(self) -> Dict[str, List[Path]]: def get_number_of_maps(self) -> int: """Inherited, see superclass.""" - return 0 # NOTE: Pandaset does not have maps. + return 0 # NOTE @DanielDauner: Pandaset does not have maps. def get_number_of_logs(self) -> int: """Inherited, see superclass.""" @@ -90,7 +106,7 @@ def get_number_of_logs(self) -> int: def convert_map(self, map_index: int, map_writer: AbstractMapWriter) -> None: """Inherited, see superclass.""" - return None # NOTE: Pandaset does not have maps. + return None # NOTE @DanielDauner: Pandaset does not have maps. def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: """Inherited, see superclass.""" @@ -107,8 +123,8 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: vehicle_parameters=get_pandaset_chrysler_pacifica_parameters(), box_detection_label_class=PandasetBoxDetectionLabel, pinhole_camera_metadata=_get_pandaset_camera_metadata(source_log_path, self.dataset_converter_config), - lidar_metadata=_get_pandaset_lidar_metadata(source_log_path, self.dataset_converter_config), - map_metadata=None, # NOTE: Pandaset does not have maps. + lidar_metadata=_get_pandaset_lidar_metadata(self.dataset_converter_config), + map_metadata=None, # NOTE @DanielDauner: Pandaset does not have maps. ) # 2. Prepare log writer @@ -133,7 +149,7 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: log_writer.write( timestamp=TimePoint.from_s(timestep_s), ego_state=ego_state, - box_detections=_extract_pandaset_box_detections(source_log_path, iteration, ego_state), + box_detections=_extract_pandaset_box_detections(source_log_path, iteration), pinhole_cameras=_extract_pandaset_sensor_camera( source_log_path, iteration, @@ -144,7 +160,6 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: lidars=_extract_pandaset_lidar( source_log_path, iteration, - ego_state, self.dataset_converter_config, ), ) @@ -156,21 +171,21 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: def _get_pandaset_camera_metadata( source_log_path: Path, dataset_config: DatasetConverterConfig ) -> Dict[PinholeCameraType, PinholeCameraMetadata]: + """Extracts the pinhole camera metadata from a Pandaset log folder.""" camera_metadata: Dict[PinholeCameraType, PinholeCameraMetadata] = {} - if dataset_config.include_pinhole_cameras: all_cameras_folder = source_log_path / "camera" for camera_folder in all_cameras_folder.iterdir(): - camera_name = camera_folder.name + camera_name = camera_folder.name assert camera_name in PANDASET_CAMERA_MAPPING.keys(), f"Camera name {camera_name} is not recognized." - camera_type = PANDASET_CAMERA_MAPPING[camera_name] + camera_type = PANDASET_CAMERA_MAPPING[camera_name] intrinsics_file = camera_folder / "intrinsics.json" assert intrinsics_file.exists(), f"Camera intrinsics file {intrinsics_file} does not exist." - intrinsics_data = read_json(intrinsics_file) + intrinsics_data = read_json(intrinsics_file) camera_metadata[camera_type] = PinholeCameraMetadata( camera_type=camera_type, width=1920, @@ -187,33 +202,25 @@ def _get_pandaset_camera_metadata( return camera_metadata -def _get_pandaset_lidar_metadata( - log_path: Path, dataset_config: DatasetConverterConfig -) -> Dict[LiDARType, LiDARMetadata]: +def _get_pandaset_lidar_metadata(dataset_config: DatasetConverterConfig) -> Dict[LiDARType, LiDARMetadata]: + """Extracts the LiDAR metadata from a Pandaset log folder.""" lidar_metadata: Dict[LiDARType, LiDARMetadata] = {} - if dataset_config.include_lidars: for lidar_name, lidar_type in PANDASET_LIDAR_MAPPING.items(): lidar_metadata[lidar_type] = LiDARMetadata( lidar_type=lidar_type, lidar_index=PandasetLiDARIndex, - extrinsic=PANDASET_LIDAR_EXTRINSICS[ - lidar_name - ], # TODO: These extrinsics are incorrect, and need to be transformed correctly. + extrinsic=PANDASET_LIDAR_EXTRINSICS[lidar_name], ) return lidar_metadata def _extract_pandaset_sensor_ego_state(gps: Dict[str, float], lidar_pose: Dict[str, Dict[str, float]]) -> EgoStateSE3: - + """Extracts the ego state from Pandaset GPS and LiDAR pose data.""" rear_axle_se3 = main_lidar_to_rear_axle(pandaset_pose_dict_to_pose_se3(lidar_pose)) - vehicle_parameters = get_pandaset_chrysler_pacifica_parameters() - - # TODO: Add script to calculate the dynamic state from log sequence. dynamic_state_se3 = None - return EgoStateSE3.from_rear_axle( rear_axle_se3=rear_axle_se3, vehicle_parameters=vehicle_parameters, @@ -222,11 +229,10 @@ def _extract_pandaset_sensor_ego_state(gps: Dict[str, float], lidar_pose: Dict[s ) -def _extract_pandaset_box_detections( - source_log_path: Path, iteration: int, ego_state_se3: EgoStateSE3 -) -> BoxDetectionWrapper: +def _extract_pandaset_box_detections(source_log_path: Path, iteration: int) -> BoxDetectionWrapper: + """Extracts the box detections from a Pandaset log folder at a given iteration.""" - # NOTE: The following provided quboids annotations are not stored in 123D + # NOTE @DanielDauner: The following provided quboids annotations are not stored in 123D # - stationary # - camera_used # - attributes.object_motion @@ -272,7 +278,7 @@ def _extract_pandaset_box_detections( box_se3_array[:, BoundingBoxSE3Index.QUATERNION] = get_quaternion_array_from_euler_array(box_euler_angles_array) box_se3_array[:, BoundingBoxSE3Index.EXTENT] = np.stack([box_lengths, box_widths, box_heights], axis=-1) - # NOTE: Pandaset annotates moving bounding boxes twice (for synchronization reasons), + # NOTE @DanielDauner: Pandaset annotates moving bounding boxes twice (for synchronization reasons), # if they are in the overlap area between the top 360° lidar and the front-facing lidar (and moving). # The value in `cuboids.sensor_id` is either # - `0` (mechanical 360° LiDAR) @@ -310,7 +316,7 @@ def _extract_pandaset_box_detections( track_token=box_uuids[box_idx], ), bounding_box_se3=BoundingBoxSE3.from_array(box_se3_array[box_idx]), - velocity=Vector3D(0.0, 0.0, 0.0), # TODO: Add velocity + velocity=None, ) box_detections.append(box_detection_se3) @@ -324,6 +330,7 @@ def _extract_pandaset_sensor_camera( camera_poses: Dict[str, List[Dict[str, Dict[str, float]]]], dataset_converter_config: DatasetConverterConfig, ) -> List[CameraData]: + """Extracts the pinhole camera metadata from a Pandaset scene at a given iteration.""" camera_data_list: List[CameraData] = [] iteration_str = f"{iteration:02d}" @@ -336,7 +343,6 @@ def _extract_pandaset_sensor_camera( camera_pose_dict = camera_poses[camera_name][iteration] camera_extrinsic = pandaset_pose_dict_to_pose_se3(camera_pose_dict) - camera_extrinsic = PoseSE3.from_array( convert_absolute_to_relative_se3_array(ego_state_se3.rear_axle_se3, camera_extrinsic.array), copy=True ) @@ -353,8 +359,9 @@ def _extract_pandaset_sensor_camera( def _extract_pandaset_lidar( - source_log_path: Path, iteration: int, ego_state_se3: EgoStateSE3, dataset_converter_config: DatasetConverterConfig + source_log_path: Path, iteration: int, dataset_converter_config: DatasetConverterConfig ) -> List[LiDARData]: + """Extracts the LiDAR data from a Pandaset scene at a given iteration.""" lidars: List[LiDARData] = [] if dataset_converter_config.include_lidars: diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py b/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py index 107a7d43..71f7cc6f 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_sensor_io.py @@ -16,6 +16,8 @@ def load_pandaset_global_lidar_pc_from_path(pkl_gz_path: Union[Path, str]) -> Dict[LiDARType, np.ndarray]: + """Loads Pandaset LiDAR point clouds from a gzip-pickle file (pickled pandas DataFrame).""" + # NOTE: The Pandaset dataset stores both front and top LiDAR data in the same gzip-pickle file. # We need to separate them based on the laser_number field. # See here: https://github.com/scaleapi/pandaset-devkit/blob/master/python/pandaset/sensors.py#L160 @@ -34,17 +36,15 @@ def load_pandaset_global_lidar_pc_from_path(pkl_gz_path: Union[Path, str]) -> Di def load_pandaset_lidars_pcs_from_file( pkl_gz_path: Union[Path, str], iteration: Optional[int], -) -> np.ndarray: +) -> Dict[LiDARType, np.ndarray]: + """Loads Pandaset LiDAR point clouds from a gzip-pickle file and converts them to ego frame.""" pkl_gz_path = Path(pkl_gz_path) assert pkl_gz_path.exists(), f"Pandaset LiDAR file not found: {pkl_gz_path}" - lidar_pc_dict = load_pandaset_global_lidar_pc_from_path(pkl_gz_path) - ego_pose = main_lidar_to_rear_axle( pandaset_pose_dict_to_pose_se3(read_json(pkl_gz_path.parent / "poses.json")[iteration]) ) - for lidar_type in lidar_pc_dict.keys(): lidar_pc_dict[lidar_type][..., PandasetLiDARIndex.XYZ] = convert_absolute_to_relative_points_3d_array( ego_pose, diff --git a/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py b/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py index 316a67ab..ba0bfd36 100644 --- a/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py +++ b/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py @@ -5,19 +5,20 @@ from typing import Dict import numpy as np +from pyparsing import Union from py123d.geometry import PoseSE3, Vector3D from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame -def read_json(json_file: Path): +def read_json(json_file: Union[Path, str]): """Helper function to read a json file as dict.""" with open(json_file, "r") as f: json_data = json.load(f) return json_data -def read_pkl_gz(pkl_gz_file: Path): +def read_pkl_gz(pkl_gz_file: Union[Path, str]): """Helper function to read a pkl.gz file as dict.""" with gzip.open(pkl_gz_file, "rb") as f: pkl_data = pickle.load(f) diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 30845e21..2cff87ed 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -66,6 +66,8 @@ class WOPDConverter(AbstractDatasetConverter): + """Converter for the Waymo Open Perception Dataset (WOPD).""" + def __init__( self, splits: List[str], @@ -75,6 +77,16 @@ def __init__( add_map_pose_offset: bool, dataset_converter_config: DatasetConverterConfig, ) -> None: + """Initializes the :class:`WOPDConverter`. + + :param splits: List of splits to convert, i.e. ``["wopd_train", "wopd_val", "wopd_test"]``. + :param wopd_data_root: Path to the root directory of the WOPD dataset + :param zero_roll_pitch: Whether to zero out roll and pitch angles in the vehicle pose + :param keep_polar_features: Whether to keep polar features in the LiDAR point clouds + :param add_map_pose_offset: Whether to add a pose offset to the map + :param dataset_converter_config: Configuration for the dataset converter + """ + super().__init__(dataset_converter_config) for split in splits: assert ( @@ -85,14 +97,14 @@ def __init__( self._wopd_data_root: Path = Path(wopd_data_root) self._zero_roll_pitch: bool = zero_roll_pitch self._keep_polar_features: bool = keep_polar_features - self._add_map_pose_offset: bool = add_map_pose_offset # TODO: Implement this feature + self._add_map_pose_offset: bool = add_map_pose_offset - self._split_tf_record_pairs: List[Tuple[str, List[Path]]] = self._collect_split_tf_record_pairs() + self._split_tf_record_pairs: List[Tuple[str, Path]] = self._collect_split_tf_record_pairs() - def _collect_split_tf_record_pairs(self) -> Dict[str, List[Path]]: + def _collect_split_tf_record_pairs(self) -> List[Tuple[str, Path]]: """Helper to collect the pairings of the split names and the corresponding tf record file.""" - split_tf_record_pairs: List[Tuple[str, List[Path]]] = [] + split_tf_record_pairs: List[Tuple[str, Path]] = [] split_name_mapping: Dict[str, str] = { "wopd_train": "training", "wopd_val": "validation", @@ -202,6 +214,8 @@ def _get_initial_frame_from_tfrecord( tf_record_path: Path, keep_dataset: bool = False, ) -> Union[dataset_pb2.Frame, Tuple[dataset_pb2.Frame, tf.data.TFRecordDataset]]: + """Helper to get the initial frame from a tf record file.""" + dataset = tf.data.TFRecordDataset(tf_record_path, compression_type="") for data in dataset: initial_frame = dataset_pb2.Frame() @@ -216,25 +230,23 @@ def _get_initial_frame_from_tfrecord( def _get_wopd_map_metadata(initial_frame: dataset_pb2.Frame, split: str) -> MapMetadata: - + """Gets the WOPD map metadata from the initial frame.""" map_metadata = MapMetadata( dataset="wopd", split=split, log_name=str(initial_frame.context.name), location=None, # TODO: Add location information. map_has_z=True, - map_is_local=True, # True, if map is per log + map_is_local=True, ) - return map_metadata def _get_wopd_camera_metadata( initial_frame: dataset_pb2.Frame, dataset_converter_config: DatasetConverterConfig ) -> Dict[PinholeCameraType, PinholeCameraMetadata]: - + """Get the WOPD camera metadata from the initial frame.""" camera_metadata_dict: Dict[PinholeCameraType, PinholeCameraMetadata] = {} - if dataset_converter_config.pinhole_camera_store_option is not None: for calibration in initial_frame.context.camera_calibrations: camera_type = WOPD_CAMERA_TYPES[calibration.name] @@ -251,7 +263,6 @@ def _get_wopd_camera_metadata( intrinsics=intrinsics, distortion=distortion, ) - return camera_metadata_dict @@ -260,16 +271,14 @@ def _get_wopd_lidar_metadata( keep_polar_features: bool, dataset_converter_config: DatasetConverterConfig, ) -> Dict[LiDARType, LiDARMetadata]: + """Get the WOPD LiDAR metadata from the initial frame.""" laser_metadatas: Dict[LiDARType, LiDARMetadata] = {} - - # NOTE: Using lidar_index = WOPDLiDARIndex if keep_polar_features else DefaultLiDARIndex if dataset_converter_config.lidar_store_option is not None: for laser_calibration in initial_frame.context.laser_calibrations: lidar_type = WOPD_LIDAR_TYPES[laser_calibration.name] - extrinsic: Optional[PoseSE3] = None if laser_calibration.extrinsic: extrinsic_transform = np.array(laser_calibration.extrinsic.transform, dtype=np.float64).reshape(4, 4) @@ -285,6 +294,7 @@ def _get_wopd_lidar_metadata( def _get_ego_pose_se3(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) -> PoseSE3: + """Helper to get the ego pose SE3 from a WOPD frame.""" ego_pose_matrix = np.array(frame.pose.transform, dtype=np.float64).reshape(4, 4) ego_pose_se3 = PoseSE3.from_transformation_matrix(ego_pose_matrix) ego_pose_se3.array[PoseSE3Index.XYZ] += map_pose_offset.array[Vector3DIndex.XYZ] @@ -292,6 +302,7 @@ def _get_ego_pose_se3(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) -> Po def _extract_wopd_ego_state(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) -> List[float]: + """Extracts the ego state from a WOPD frame.""" rear_axle_pose = _get_ego_pose_se3(frame, map_pose_offset) vehicle_parameters = get_wopd_chrysler_pacifica_parameters() @@ -310,6 +321,7 @@ def _extract_wopd_ego_state(frame: dataset_pb2.Frame, map_pose_offset: Vector3D) def _extract_wopd_box_detections( frame: dataset_pb2.Frame, map_pose_offset: Vector3D, zero_roll_pitch: bool = True ) -> BoxDetectionWrapper: + """Extracts the box detections from a WOPD frame.""" ego_pose_se3 = _get_ego_pose_se3(frame, map_pose_offset) @@ -376,14 +388,13 @@ def _extract_wopd_box_detections( def _extract_wopd_cameras( frame: dataset_pb2.Frame, dataset_converter_config: DatasetConverterConfig ) -> List[CameraData]: + """Extracts the camera data from a WOPD frame.""" camera_data_list: List[CameraData] = [] - if dataset_converter_config.include_pinhole_cameras: # NOTE @DanielDauner: The extrinsic matrix in frame.context.camera_calibration is fixed to model the ego to camera transformation. # The poses in frame.images[idx] are the motion compensated ego poses when the camera triggers. - # TODO: Verify if this is correct. camera_extrinsic: Dict[str, PoseSE3] = {} for calibration in frame.context.camera_calibrations: camera_type = WOPD_CAMERA_TYPES[calibration.name] @@ -419,11 +430,10 @@ def _extract_wopd_lidars( absolute_tf_record_path: Path, wopd_data_root: Path, ) -> Dict[LiDARType, npt.NDArray[np.float32]]: + """Extracts the LiDAR data from a WOPD frame.""" lidars: List[LiDARData] = [] - if dataset_converter_config.include_lidars: - relative_path = absolute_tf_record_path.relative_to(wopd_data_root) lidars.append( LiDARData( @@ -433,5 +443,4 @@ def _extract_wopd_lidars( relative_path=relative_path, ) ) - return lidars diff --git a/src/py123d/conversion/datasets/wopd/wopd_sensor_io.py b/src/py123d/conversion/datasets/wopd/wopd_sensor_io.py index cca4bc53..14dae656 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_sensor_io.py +++ b/src/py123d/conversion/datasets/wopd/wopd_sensor_io.py @@ -33,6 +33,7 @@ def load_jpeg_binary_from_tf_record_file( iteration: int, pinhole_camera_type: PinholeCameraType, ) -> bytes: + """Loads the JPEG binary of a specific pinhole camera from a Waymo TFRecord file at a given iteration.""" frame = _get_frame_at_iteration(tf_record_path, iteration) assert frame is not None, f"Frame at iteration {iteration} not found in Waymo file: {tf_record_path}" @@ -48,12 +49,11 @@ def load_jpeg_binary_from_tf_record_file( def load_wopd_lidar_pcs_from_file( tf_record_path: Path, index: int, keep_polar_features: bool = False ) -> Dict[LiDARType, np.ndarray]: + """Loads Waymo Open Perception Dataset (WOPD) LiDAR point clouds from a TFRecord file at a given iteration.""" frame = _get_frame_at_iteration(tf_record_path, index) assert frame is not None, f"Frame at iteration {index} not found in Waymo file: {tf_record_path}" - (range_images, camera_projections, _, range_image_top_pose) = parse_range_image_and_camera_projection(frame) - points, cp_points = frame_utils.convert_range_image_to_point_cloud( frame=frame, range_images=range_images, @@ -61,7 +61,6 @@ def load_wopd_lidar_pcs_from_file( range_image_top_pose=range_image_top_pose, keep_polar_features=keep_polar_features, ) - lidar_pcs_dict: Dict[LiDARType, np.ndarray] = {} for lidar_idx, frame_lidar in enumerate(frame.lasers): lidar_type = WOPD_LIDAR_TYPES[frame_lidar.name] diff --git a/src/py123d/conversion/log_writer/abstract_log_writer.py b/src/py123d/conversion/log_writer/abstract_log_writer.py index aff5cf41..de1e5bc9 100644 --- a/src/py123d/conversion/log_writer/abstract_log_writer.py +++ b/src/py123d/conversion/log_writer/abstract_log_writer.py @@ -32,9 +32,12 @@ def reset( self, dataset_converter_config: DatasetConverterConfig, log_metadata: LogMetadata, - ) -> None: - """ - Reset the log writer for a new log. + ) -> bool: + """Resets the log writer to start writing a new log according to the provided configuration and metadata. + + :param dataset_converter_config: The dataset converter configuration. + :param log_metadata: The metadata for the log. + :return: True if the current logs needs to be written, False otherwise. """ @abc.abstractmethod @@ -51,15 +54,27 @@ def write( route_lane_group_ids: Optional[List[int]] = None, **kwargs, ) -> None: - pass + """Writes a single iteration of data to the log. + + :param timestamp: Required, the timestamp of the iteration. + :param ego_state: Optional, the ego state of the vehicle, defaults to None. + :param box_detections: Optional, the box detections, defaults to None + :param traffic_lights: Optional, the traffic light detections, defaults to None + :param pinhole_cameras: Optional, the pinhole camera data, defaults to None + :param fisheye_mei_cameras: Optional, the fisheye MEI camera data, defaults to None + :param lidars: Optional, the LiDAR data, defaults to None + :param scenario_tags: Optional, the scenario tags, defaults to None + :param route_lane_group_ids: Optional, the route lane group IDs, defaults to None + """ @abc.abstractmethod def close(self) -> None: - pass + """Closes the log writer and finalizes the log io operations.""" @dataclass class LiDARData: + """Helper dataclass to pass LiDAR data to log writers.""" lidar_type: LiDARType @@ -85,6 +100,7 @@ def has_point_cloud(self) -> bool: @dataclass class CameraData: + """Helper dataclass to pass Camera data to log writers.""" camera_type: Union[PinholeCameraType, FisheyeMEICameraType] extrinsic: PoseSE3 diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index 12f5a4dd..8a7a3b29 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -70,6 +70,7 @@ def _get_sensors_root() -> Path: def _store_option_to_arrow_type( store_option: Literal["path", "jpeg_binary", "png_binary", "laz_binary"], ) -> pa.DataType: + """Maps the store option literal to the corresponding Arrow data type.""" data_type_map = { "path": pa.string(), "jpeg_binary": pa.binary(), @@ -82,6 +83,7 @@ def _store_option_to_arrow_type( class ArrowLogWriter(AbstractLogWriter): + """Log writer for Arrow-based logs. Writes log data to an Arrow IPC file format.""" def __init__( self, @@ -90,6 +92,13 @@ def __init__( ipc_compression: Optional[Literal["lz4", "zstd"]] = None, ipc_compression_level: Optional[int] = None, ) -> None: + """Initializes the :class:`ArrowLogWriter`. + + :param logs_root: The root directory for logs, defaults to None + :param sensors_root: The root directory for sensors (i.e. in case of re-writing sensor files), defaults to None + :param ipc_compression: The IPC compression method, defaults to None + :param ipc_compression_level: The IPC compression level, defaults to None + """ self._logs_root = Path(logs_root) if logs_root is not None else _get_logs_root() self._sensors_root = Path(sensors_root) if sensors_root is not None else _get_sensors_root() @@ -106,6 +115,7 @@ def __init__( self._fisheye_mei_mp4_writers: Dict[str, MP4Writer] = {} def reset(self, dataset_converter_config: DatasetConverterConfig, log_metadata: LogMetadata) -> bool: + """Inherited, see superclass.""" log_needs_writing: bool = False sink_log_path: Path = self._logs_root / log_metadata.split / f"{log_metadata.log_name}.arrow" @@ -152,7 +162,9 @@ def write( lidars: Optional[List[LiDARData]] = None, scenario_tags: Optional[List[str]] = None, route_lane_group_ids: Optional[List[int]] = None, + **kwargs, ) -> None: + """Inherited, see superclass.""" assert self._dataset_converter_config is not None, "Log writer is not initialized." assert self._log_metadata is not None, "Log writer is not initialized." @@ -329,6 +341,7 @@ def write( self._record_batch_writer.write_batch(record_batch) def close(self) -> None: + """Inherited, see superclass.""" if self._record_batch_writer is not None: self._record_batch_writer.close() self._record_batch_writer: Optional[pa.ipc.RecordBatchWriter] = None @@ -350,6 +363,12 @@ def close(self) -> None: @staticmethod def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata: LogMetadata) -> pa.Schema: + """Builds the schema for the Arrow table, specifying datatypes and modalities to be stored. + + :param dataset_converter_config: The dataset converter configuration. + :param log_metadata: The metadata for the log. + :return: The Arrow schema object. + """ schema_list: List[Tuple[str, pa.DataType]] = [ (UUID_COLUMN, pa.uuid()), @@ -474,6 +493,12 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata return add_log_metadata_to_arrow_schema(pa.schema(schema_list), log_metadata) def _prepare_lidar_data_dict(self, lidars: List[LiDARData]) -> Dict[LiDARType, Union[str, bytes]]: + """Helper function to prepare LiDAR data dictionary for the target storage option. + + :param lidars: List of LiDARData objects to be processed. + :return: Dictionary mapping LiDARType to either file path (str) or binary data (bytes) depending on storage option. + """ + lidar_data_dict: Dict[LiDARType, Union[str, bytes]] = {} if self._dataset_converter_config.lidar_store_option == "path": @@ -506,6 +531,10 @@ def _prepare_lidar_data_dict(self, lidars: List[LiDARData]) -> Dict[LiDARType, U binary = encode_lidar_pc_as_draco_binary(point_cloud, lidar_metadata) elif self._dataset_converter_config.lidar_store_option == "laz_binary": binary = encode_lidar_pc_as_laz_binary(point_cloud, lidar_metadata) + else: + raise NotImplementedError( + f"Unsupported LiDAR store option: {self._dataset_converter_config.lidar_store_option}" + ) lidar_data_dict[lidar_type] = binary return lidar_data_dict @@ -513,6 +542,14 @@ def _prepare_lidar_data_dict(self, lidars: List[LiDARData]) -> Dict[LiDARType, U def _prepare_camera_data_dict( self, cameras: List[CameraData], store_option: Literal["path", "binary"] ) -> Dict[PinholeCameraType, Union[str, bytes]]: + """Helper function to prepare camera data dictionary for the target storage option. + + :param cameras: List of CameraData objects to be processed. + :param store_option: The storage option for camera data, either "path" or "binary". + :raises NotImplementedError: If the storage option is not supported. + :raises NotImplementedError: If the camera data does not support the specified storage option. + :return: Dictionary mapping PinholeCameraType to either file path (str) or binary data (bytes) depending on storage option. + """ camera_data_dict: Dict[PinholeCameraType, Union[str, int, bytes]] = {} for camera_data in cameras: diff --git a/src/py123d/conversion/map_writer/utils/gpkg_utils.py b/src/py123d/conversion/map_writer/utils/gpkg_utils.py index 2b68affa..87b99132 100644 --- a/src/py123d/conversion/map_writer/utils/gpkg_utils.py +++ b/src/py123d/conversion/map_writer/utils/gpkg_utils.py @@ -8,6 +8,7 @@ @dataclass class IntIDMapping: + """Class to map string IDs to integer IDs and vice versa.""" str_to_int: Dict[str, int] @@ -16,12 +17,16 @@ def __post_init__(self): @classmethod def from_series(cls, series: pd.Series) -> IntIDMapping: + """Creates an IntIDMapping from a pandas Series of string-like IDs.""" + # Drop NaN values and convert all to strings unique_ids = series.dropna().astype(str).unique() str_to_int = {str_id: idx for idx, str_id in enumerate(unique_ids)} return IntIDMapping(str_to_int) def map(self, str_like: Any) -> Optional[int]: + """Maps a string-like ID to its corresponding integer ID.""" + # NOTE: We need to convert a string-like input to an integer ID if pd.isna(str_like) or str_like is None: return None @@ -34,6 +39,7 @@ def map(self, str_like: Any) -> Optional[int]: return self.str_to_int.get(key, None) def map_list(self, id_list: Optional[List[str]]) -> List[int]: + """Maps a list of string-like IDs to their corresponding integer IDs.""" if id_list is None: return [] list_ = [] @@ -42,18 +48,3 @@ def map_list(self, id_list: Optional[List[str]]) -> List[int]: if mapped_id is not None: list_.append(mapped_id) return list_ - - -class IncrementalIntIDMapping: - - def __init__(self): - self.str_to_int: Dict[str, int] = {} - self.int_to_str: Dict[int, str] = {} - self.next_id: int = 0 - - def get_int_id(self, str_id: str) -> int: - if str_id not in self.str_to_int: - self.str_to_int[str_id] = self.next_id - self.int_to_str[self.next_id] = str_id - self.next_id += 1 - return self.str_to_int[str_id] diff --git a/src/py123d/conversion/registry/box_detection_label_registry.py b/src/py123d/conversion/registry/box_detection_label_registry.py index 5befcbfc..e3dd1e41 100644 --- a/src/py123d/conversion/registry/box_detection_label_registry.py +++ b/src/py123d/conversion/registry/box_detection_label_registry.py @@ -8,22 +8,23 @@ def register_box_detection_label(enum_class): + """Decorator to register a BoxDetectionLabel enum class.""" BOX_DETECTION_LABEL_REGISTRY[enum_class.__name__] = enum_class return enum_class class BoxDetectionLabel(SerialIntEnum): + """Base class for all box detection label enums.""" @abc.abstractmethod def to_default(self) -> DefaultBoxDetectionLabel: + """Convert to the default box detection label.""" raise NotImplementedError("Subclasses must implement this method.") @register_box_detection_label class DefaultBoxDetectionLabel(BoxDetectionLabel): - """ - Enum for agents in py123d. - """ + """Default box detection labels used in 123D. Common labels across datasets.""" # Vehicles EGO = 0 @@ -51,7 +52,7 @@ def to_default(self) -> DefaultBoxDetectionLabel: @register_box_detection_label class AV2SensorBoxDetectionLabel(BoxDetectionLabel): - """Sensor dataset annotation categories.""" + """Argoverse 2 Sensor dataset annotation categories.""" ANIMAL = 0 ARTICULATED_BUS = 1 @@ -123,6 +124,7 @@ def to_default(self) -> DefaultBoxDetectionLabel: @register_box_detection_label class KITTI360BoxDetectionLabel(BoxDetectionLabel): + """KITTI-360 dataset annotation categories.""" BICYCLE = 0 BOX = 1 @@ -145,6 +147,7 @@ class KITTI360BoxDetectionLabel(BoxDetectionLabel): VENDING_MACHINE = 18 def to_default(self) -> DefaultBoxDetectionLabel: + """Inherited, see superclass.""" mapping = { KITTI360BoxDetectionLabel.BICYCLE: DefaultBoxDetectionLabel.BICYCLE, KITTI360BoxDetectionLabel.BOX: DefaultBoxDetectionLabel.GENERIC_OBJECT, @@ -193,6 +196,7 @@ class NuPlanBoxDetectionLabel(BoxDetectionLabel): GENERIC_OBJECT = 6 def to_default(self) -> DefaultBoxDetectionLabel: + """Inherited, see superclass.""" mapping = { NuPlanBoxDetectionLabel.VEHICLE: DefaultBoxDetectionLabel.VEHICLE, NuPlanBoxDetectionLabel.BICYCLE: DefaultBoxDetectionLabel.BICYCLE, @@ -237,6 +241,7 @@ class NuScenesBoxDetectionLabel(BoxDetectionLabel): ANIMAL = 22 def to_default(self): + """Inherited, see superclass.""" mapping = { NuScenesBoxDetectionLabel.VEHICLE_CAR: DefaultBoxDetectionLabel.VEHICLE, NuScenesBoxDetectionLabel.VEHICLE_TRUCK: DefaultBoxDetectionLabel.VEHICLE, @@ -301,6 +306,7 @@ class PandasetBoxDetectionLabel(BoxDetectionLabel): TRAM_SUBWAY = 26 def to_default(self) -> DefaultBoxDetectionLabel: + """Inherited, see superclass.""" mapping = { PandasetBoxDetectionLabel.ANIMALS_BIRD: DefaultBoxDetectionLabel.ANIMAL, PandasetBoxDetectionLabel.ANIMALS_OTHER: DefaultBoxDetectionLabel.ANIMAL, @@ -348,6 +354,7 @@ class WOPDBoxDetectionLabel(BoxDetectionLabel): TYPE_CYCLIST = 4 def to_default(self) -> DefaultBoxDetectionLabel: + """Inherited, see superclass.""" mapping = { WOPDBoxDetectionLabel.TYPE_UNKNOWN: DefaultBoxDetectionLabel.GENERIC_OBJECT, WOPDBoxDetectionLabel.TYPE_VEHICLE: DefaultBoxDetectionLabel.VEHICLE, diff --git a/src/py123d/conversion/registry/lidar_index_registry.py b/src/py123d/conversion/registry/lidar_index_registry.py index a65903b4..d02395dc 100644 --- a/src/py123d/conversion/registry/lidar_index_registry.py +++ b/src/py123d/conversion/registry/lidar_index_registry.py @@ -1,16 +1,21 @@ +from __future__ import annotations + from enum import IntEnum +from typing import Dict from py123d.common.utils.enums import classproperty -LIDAR_INDEX_REGISTRY = {} +LIDAR_INDEX_REGISTRY: Dict[str, LiDARIndex] = {} def register_lidar_index(enum_class): + """Decorator to register a LiDARIndex enum class.""" LIDAR_INDEX_REGISTRY[enum_class.__name__] = enum_class return enum_class class LiDARIndex(IntEnum): + """Base class for all LiDAR Index enums. Defines common indices for LiDAR point clouds.""" @classproperty def XY(self) -> slice: @@ -29,6 +34,8 @@ def XYZ(self) -> slice: @register_lidar_index class DefaultLiDARIndex(LiDARIndex): + """Default LiDAR indices for XYZ point clouds.""" + X = 0 Y = 1 Z = 2 @@ -36,6 +43,8 @@ class DefaultLiDARIndex(LiDARIndex): @register_lidar_index class NuPlanLiDARIndex(LiDARIndex): + """LiDAR Indexing Scheme for the nuPlan dataset.""" + X = 0 Y = 1 Z = 2 @@ -45,6 +54,8 @@ class NuPlanLiDARIndex(LiDARIndex): @register_lidar_index class CARLALiDARIndex(LiDARIndex): + """LiDAR Indexing Scheme for the CARLA.""" + X = 0 Y = 1 Z = 2 @@ -53,6 +64,8 @@ class CARLALiDARIndex(LiDARIndex): @register_lidar_index class WOPDLiDARIndex(LiDARIndex): + """Waymo Open Perception Dataset (WOPD) LiDAR Indexing Scheme, with polar features.""" + RANGE = 0 INTENSITY = 1 ELONGATION = 2 @@ -63,6 +76,8 @@ class WOPDLiDARIndex(LiDARIndex): @register_lidar_index class Kitti360LiDARIndex(LiDARIndex): + """KITTI-360 LiDAR Indexing Scheme.""" + X = 0 Y = 1 Z = 2 @@ -71,10 +86,7 @@ class Kitti360LiDARIndex(LiDARIndex): @register_lidar_index class AVSensorLiDARIndex(LiDARIndex): - """Argoverse Sensor LiDAR Indexing Scheme. - - NOTE: The LiDAR files also include, 'offset_ns', which we do not currently include. - """ + """Argoverse 2 Sensor LiDAR Indexing Scheme.""" X = 0 Y = 1 @@ -94,6 +106,8 @@ class PandasetLiDARIndex(LiDARIndex): @register_lidar_index class NuScenesLiDARIndex(LiDARIndex): + """NuScenes LiDAR Indexing Scheme.""" + X = 0 Y = 1 Z = 2 diff --git a/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py b/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py index afb9f041..c2fc4d17 100644 --- a/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py +++ b/src/py123d/conversion/sensor_io/camera/jpeg_camera_io.py @@ -18,24 +18,28 @@ def is_jpeg_binary(jpeg_binary: bytes) -> bool: def encode_image_as_jpeg_binary(image: npt.NDArray[np.uint8]) -> bytes: + """Encodes a numpy image as JPEG binary.""" _, encoded_img = cv2.imencode(".jpg", image) jpeg_binary = encoded_img.tobytes() return jpeg_binary def decode_image_from_jpeg_binary(jpeg_binary: bytes) -> npt.NDArray[np.uint8]: + """Decodes a numpy image from JPEG binary.""" image = cv2.imdecode(np.frombuffer(jpeg_binary, np.uint8), cv2.IMREAD_UNCHANGED) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) return image def load_jpeg_binary_from_jpeg_file(jpeg_path: Path) -> bytes: + """Loads JPEG binary data from a JPEG file.""" with open(jpeg_path, "rb") as f: jpeg_binary = f.read() return jpeg_binary def load_image_from_jpeg_file(jpeg_path: Path) -> npt.NDArray[np.uint8]: + """Loads a numpy image from a JPEG file.""" image = cv2.imread(str(jpeg_path), cv2.IMREAD_COLOR) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) return image diff --git a/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py b/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py index 9cb91aad..5c156cdf 100644 --- a/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py +++ b/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py @@ -12,16 +12,15 @@ def load_image_from_mp4_file() -> None: class MP4Writer: - """Write images sequentially to an MP4 video file.""" + """Simple implementation of an MP4 video writer, based on OpenCV.""" def __init__(self, output_path: Union[str, Path], fps: float = 30.0, codec: str = "mp4v"): """ Initialize MP4 writer. - Args: - output_path: Path to output MP4 file - fps: Frames per second - codec: Video codec ('mp4v', 'avc1', 'h264') + :param output_path: The output path for the MP4 file. + :param fps: Frames per second, defaults to 30.0 + :param codec: Video codec, defaults to "mp4v" """ self.output_path = Path(output_path) self.fps = fps @@ -31,11 +30,11 @@ def __init__(self, output_path: Union[str, Path], fps: float = 30.0, codec: str self.frame_count = 0 def write_frame(self, frame: np.ndarray) -> int: - """ - Write a single frame to the video. + """Write a single frame to the video. - Args: - frame: Image as numpy array (RGB format) + :param frame: Image as numpy array (RGB format) + :raises ValueError: If frame size does not match video size + :return: Index of the written frame """ frame_idx = int(self.frame_count) if self.writer is None: @@ -55,21 +54,22 @@ def write_frame(self, frame: np.ndarray) -> int: return frame_idx def close(self): - """Release the video writer.""" + """Close the video writer and finalize the MP4 file.""" if self.writer is not None: self.writer.release() self.writer = None class MP4Reader: - """Read MP4 video with random frame access.""" + """Simple implementation of an MP4 video reader, based on OpenCV.""" def __init__(self, video_path: Union[str, Path], read_all: bool = False): - """ - Initialize MP4 reader. + """Initializes the MP4Reader. - Args: - video_path: Path to MP4 file + :param video_path: Path to the MP4 video file. + :param read_all: Whether to read all frames into memory, defaults to False + :raises FileNotFoundError: If the video file does not exist + :raises ValueError: If the video file cannot be opened """ self.video_path = video_path if not Path(video_path).exists(): @@ -98,14 +98,11 @@ def __init__(self, video_path: Union[str, Path], read_all: bool = False): self.cap = None def get_frame(self, frame_index: int) -> Optional[np.ndarray]: - """ - Get a specific frame by index. - - Args: - frame_index: Zero-based frame index + """Get a specific frame, an RBG image as numpy array, by its index. - Returns: - Frame as numpy array (RGB format) or None if invalid index + :param frame_index: Index of the frame to retrieve. + :raises IndexError: If the frame index is out of range. + :return: The frame as a numpy array (RGB format) or None if the frame could not be read. """ if frame_index < 0 or frame_index >= self.frame_count: @@ -123,9 +120,12 @@ def get_frame(self, frame_index: int) -> Optional[np.ndarray]: return frame if ret else None - def __getitem__(self, index: int) -> np.ndarray: - """Allow indexing like reader[10]""" - return self.get_frame(index) + def __del__(self): + """Destructor to release the video capture.""" + if self.cap is not None: + self.cap.release() + if self.read_all: + self.frames = [] @lru_cache(maxsize=64) diff --git a/src/py123d/conversion/sensor_io/camera/png_camera_io.py b/src/py123d/conversion/sensor_io/camera/png_camera_io.py index e3149c4d..6ab08599 100644 --- a/src/py123d/conversion/sensor_io/camera/png_camera_io.py +++ b/src/py123d/conversion/sensor_io/camera/png_camera_io.py @@ -17,24 +17,28 @@ def is_png_binary(png_binary: bytes) -> bool: def encode_image_as_png_binary(image: npt.NDArray[np.uint8]) -> bytes: + """Encodes a numpy image as PNG binary.""" _, encoded_img = cv2.imencode(".png", image) png_binary = encoded_img.tobytes() return png_binary def decode_image_from_png_binary(png_binary: bytes) -> npt.NDArray[np.uint8]: + """Decodes a numpy image from PNG binary.""" image = cv2.imdecode(np.frombuffer(png_binary, np.uint8), cv2.IMREAD_UNCHANGED) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) return image def load_png_binary_from_png_file(png_path: Path) -> bytes: + """Loads PNG binary data from a PNG file.""" with open(png_path, "rb") as f: png_binary = f.read() return png_binary def load_image_from_png_file(png_path: Path) -> npt.NDArray[np.uint8]: + """Loads a numpy image from a PNG file.""" image = cv2.imread(str(png_path), cv2.IMREAD_COLOR) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) return image diff --git a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py index 6e3c508e..c276e0a7 100644 --- a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py +++ b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py @@ -26,8 +26,20 @@ def load_lidar_pcs_from_file( index: Optional[int] = None, sensor_root: Optional[Union[str, Path]] = None, ) -> Dict[LiDARType, npt.NDArray[np.float32]]: - assert relative_path is not None, "Relative path to LiDAR file must be provided." + """Loads LiDAR point clouds from a file, based on the dataset specified in the log metadata. + + :param relative_path: Relative path to the LiDAR file. + :param log_metadata: Metadata containing dataset information. + :param index: Optional index for datasets that require it, defaults to None + :param sensor_root: Optional root path for sensor data, defaults to None + :raises NotImplementedError: If the dataset is not supported + :return: Dictionary mapping LiDAR types to their point cloud numpy arrays + """ + # NOTE @DanielDauner: This function is designed s.t. it can load multiple lidar types at the same time. + # Several datasets (e.g., PandaSet, nuScenes) have multiple LiDAR sensors stored in one file. + # Returning this as a dict allows us to handle this case without unnucessary io overhead. + assert relative_path is not None, "Relative path to LiDAR file must be provided." if sensor_root is None: assert ( log_metadata.dataset in DATASET_SENSOR_ROOT.keys() diff --git a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py index 09e8a10f..5449ba9e 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py @@ -48,6 +48,13 @@ def convert_xodr_map( interpolation_step_size: float = 1.0, connection_distance_threshold: float = 0.1, ) -> None: + """Converts an OpenDRIVE map file and the map objects to an 123D map using a map writer. + + :param xordr_file: Path to the OpenDRIVE (.xodr) file. + :param map_writer: Map writer to write the extracted map objects. + :param interpolation_step_size: Step size for interpolating polylines, defaults to 1.0 + :param connection_distance_threshold: Distance threshold for connecting road elements, defaults to 0.1 + """ opendrive = XODR.parse_from_file(xordr_file) @@ -81,6 +88,7 @@ def _extract_and_write_lanes( lane_group_helper_dict: Dict[str, OpenDriveLaneGroupHelper], map_writer: AbstractMapWriter, ) -> List[Lane]: + """Extracts lanes from lane group helpers and writes them using the map writer.""" lanes: List[Lane] = [] for lane_group_helper in lane_group_helper_dict.values(): @@ -114,6 +122,7 @@ def _extract_and_write_lanes( def _extract_and_write_lane_groups( lane_group_helper_dict: Dict[str, OpenDriveLaneGroupHelper], map_writer: AbstractMapWriter ) -> List[LaneGroup]: + """Extracts lane groups from lane group helpers and writes them using the map writer.""" lane_groups: List[LaneGroup] = [] for lane_group_helper in lane_group_helper_dict.values(): @@ -127,7 +136,6 @@ def _extract_and_write_lane_groups( predecessor_ids=lane_group_helper.predecessor_lane_group_ids, successor_ids=lane_group_helper.successor_lane_group_ids, outline=lane_group_helper.outline_polyline_3d, - geometry=None, ) lane_groups.append(lane_group) map_writer.write_lane_group(lane_group) @@ -136,13 +144,13 @@ def _extract_and_write_lane_groups( def _write_walkways(lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer: AbstractMapWriter) -> None: + """Writes walkways from lane helpers using the map writer.""" for lane_helper in lane_helper_dict.values(): if lane_helper.type == "sidewalk": map_writer.write_walkway( Walkway( object_id=lane_helper.lane_id, outline=lane_helper.outline_polyline_3d, - geometry=None, ) ) @@ -150,24 +158,23 @@ def _write_walkways(lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer def _extract_and_write_carparks( lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer: AbstractMapWriter ) -> List[Carpark]: - + """Extracts carparks from lane helpers and writes them using the map writer.""" carparks: List[Carpark] = [] for lane_helper in lane_helper_dict.values(): if lane_helper.type == "parking": carpark = Carpark( object_id=lane_helper.lane_id, outline=lane_helper.outline_polyline_3d, - geometry=None, ) carparks.append(carpark) map_writer.write_carpark(carpark) - return carparks def _extract_and_write_generic_drivables( lane_helper_dict: Dict[str, OpenDriveLaneHelper], map_writer: AbstractMapWriter ) -> List[GenericDrivable]: + """Extracts generic drivables from lane helpers and writes them using the map writer.""" generic_drivables: List[GenericDrivable] = [] for lane_helper in lane_helper_dict.values(): @@ -175,7 +182,6 @@ def _extract_and_write_generic_drivables( generic_drivable = GenericDrivable( object_id=lane_helper.lane_id, outline=lane_helper.outline_polyline_3d, - geometry=None, ) generic_drivables.append(generic_drivable) map_writer.write_generic_drivable(generic_drivable) @@ -209,7 +215,6 @@ def _find_lane_group_helpers_with_junction_id(junction_id: int) -> List[OpenDriv object_id=junction.id, lane_group_ids=lane_group_ids_, outline=outline, - geometry=None, ) ) @@ -220,7 +225,6 @@ def _write_crosswalks(object_helper_dict: Dict[int, OpenDriveObjectHelper], map_ Crosswalk( object_id=object_helper.object_id, outline=object_helper.outline_polyline_3d, - geometry=None, ) ) diff --git a/src/py123d/datatypes/map_objects/map_objects.py b/src/py123d/datatypes/map_objects/map_objects.py index 81d5be9f..f722a985 100644 --- a/src/py123d/datatypes/map_objects/map_objects.py +++ b/src/py123d/datatypes/map_objects/map_objects.py @@ -7,7 +7,7 @@ from trimesh import Trimesh from py123d.datatypes.map_objects.base_map_objects import BaseMapLineObject, BaseMapSurfaceObject, MapObjectIDType -from py123d.datatypes.map_objects.map_layer_types import MapLayer +from py123d.datatypes.map_objects.map_layer_types import MapLayer, RoadEdgeType, RoadLineType from py123d.datatypes.map_objects.utils import get_trimesh_from_boundaries from py123d.geometry import Polyline2D, Polyline3D @@ -530,7 +530,7 @@ class RoadEdge(BaseMapLineObject): def __init__( self, object_id: MapObjectIDType, - road_edge_type: int, + road_edge_type: RoadEdgeType, polyline: Union[Polyline2D, Polyline3D], ): """Initialize a RoadEdge instance. @@ -547,7 +547,7 @@ def layer(self) -> MapLayer: return MapLayer.ROAD_EDGE @property - def road_edge_type(self) -> int: + def road_edge_type(self) -> RoadEdgeType: """The type of road edge, according to :class:`~py123d.datatypes.map_objects.map_layer_types.RoadEdgeType`.""" return self._road_edge_type @@ -560,7 +560,7 @@ class RoadLine(BaseMapLineObject): def __init__( self, object_id: MapObjectIDType, - road_line_type: int, + road_line_type: RoadLineType, polyline: Union[Polyline2D, Polyline3D], ): """Initialize a RoadLine instance. @@ -578,6 +578,6 @@ def layer(self) -> MapLayer: return MapLayer.ROAD_LINE @property - def road_line_type(self) -> int: + def road_line_type(self) -> RoadLineType: """The type of road edge, according to :class:`~py123d.datatypes.map_objects.map_layer_types.RoadLineType`.""" return self._road_line_type diff --git a/src/py123d/geometry/polyline.py b/src/py123d/geometry/polyline.py index b7bd48de..a165a71e 100644 --- a/src/py123d/geometry/polyline.py +++ b/src/py123d/geometry/polyline.py @@ -372,7 +372,7 @@ def interpolate( :return: A Point3D instance or a numpy array of shape (N, 3) representing the interpolated points. """ - _interpolator = interp1d(self._progress, self._array, axis=0, bounds_error=False, fill_value=0.0) + _interpolator = interp1d(self._progress, self._array, axis=0, bounds_error=False, fill_value="extrapolate") distances_ = distances * self.length if normalized else distances clipped_distances = np.clip(distances_, 1e-8, self.length) From 8a60401bde8c3572f1f8ba67b5b5617fce815428 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 14 Nov 2025 18:39:43 +0100 Subject: [PATCH 24/50] Fix wrong KITTI-360 spelling (#66) --- docs/datasets/kitti-360.rst | 4 ++-- .../conversion/datasets/kitti360/kitti360_converter.py | 8 ++++---- .../conversion/datasets/kitti360/utils/kitti360_labels.py | 2 +- .../datasets/kitti360/utils/preprocess_detection.py | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/datasets/kitti-360.rst b/docs/datasets/kitti-360.rst index d4fc8a9f..43db60b4 100644 --- a/docs/datasets/kitti-360.rst +++ b/docs/datasets/kitti-360.rst @@ -1,4 +1,4 @@ -KiTTI-360 +KITTI-360 --------- .. sidebar:: Dataset Name @@ -75,7 +75,7 @@ Dataset Specific Issues Citation ~~~~~~~~ -If you use KiTTI-360 in your research, please cite: +If you use KITTI-360 in your research, please cite: .. code-block:: bibtex diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 333edbf0..2f4a0ea4 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -20,7 +20,7 @@ ) from py123d.conversion.datasets.kitti360.utils.kitti360_labels import ( BBOX_LABLES_TO_DETECTION_NAME_DICT, - KIITI360_DETECTION_NAME_DICT, + KITTI360_DETECTION_NAME_DICT, kittiId2label, ) from py123d.conversion.datasets.kitti360.utils.preprocess_detection import process_detection @@ -588,7 +588,7 @@ def _extract_kitti360_box_detections_all( else: label = child.find("label").text name = BBOX_LABLES_TO_DETECTION_NAME_DICT.get(label, "unknown") - if child.find("transform") is None or name not in KIITI360_DETECTION_NAME_DICT.keys(): + if child.find("transform") is None or name not in KITTI360_DETECTION_NAME_DICT.keys(): continue obj = KITTI360Bbox3D() obj.parseBbox(child) @@ -604,7 +604,7 @@ def _extract_kitti360_box_detections_all( detections_states[frame].append(obj.get_state_array()) detections_velocity[frame].append(np.array([0.0, 0.0, 0.0])) detections_tokens[frame].append(str(obj.globalID)) - detections_labels[frame].append(KIITI360_DETECTION_NAME_DICT[obj.name]) + detections_labels[frame].append(KITTI360_DETECTION_NAME_DICT[obj.name]) else: global_ID = obj.globalID dynamic_objs[global_ID].append(obj) @@ -641,7 +641,7 @@ def _extract_kitti360_box_detections_all( detections_states[frame].append(obj.get_state_array()) detections_velocity[frame].append(vel) detections_tokens[frame].append(str(obj.globalID)) - detections_labels[frame].append(KIITI360_DETECTION_NAME_DICT[obj.name]) + detections_labels[frame].append(KITTI360_DETECTION_NAME_DICT[obj.name]) box_detection_wrapper_all: List[BoxDetectionWrapper] = [] for frame in range(ts_len): diff --git a/src/py123d/conversion/datasets/kitti360/utils/kitti360_labels.py b/src/py123d/conversion/datasets/kitti360/utils/kitti360_labels.py index a40cffca..06aa8524 100644 --- a/src/py123d/conversion/datasets/kitti360/utils/kitti360_labels.py +++ b/src/py123d/conversion/datasets/kitti360/utils/kitti360_labels.py @@ -185,7 +185,7 @@ def assureSingleInstanceName(name): "vendingMachine": "vending machine", } -KIITI360_DETECTION_NAME_DICT = { +KITTI360_DETECTION_NAME_DICT = { "bicycle": KITTI360BoxDetectionLabel.BICYCLE, "box": KITTI360BoxDetectionLabel.BOX, "bus": KITTI360BoxDetectionLabel.BUS, diff --git a/src/py123d/conversion/datasets/kitti360/utils/preprocess_detection.py b/src/py123d/conversion/datasets/kitti360/utils/preprocess_detection.py index 1c2beeca..c6195c5a 100644 --- a/src/py123d/conversion/datasets/kitti360/utils/preprocess_detection.py +++ b/src/py123d/conversion/datasets/kitti360/utils/preprocess_detection.py @@ -28,7 +28,7 @@ ) from py123d.conversion.datasets.kitti360.utils.kitti360_labels import ( BBOX_LABLES_TO_DETECTION_NAME_DICT, - KIITI360_DETECTION_NAME_DICT, + KITTI360_DETECTION_NAME_DICT, kittiId2label, ) @@ -76,7 +76,7 @@ def _collect_static_objects(kitti360_dataset_root: Path, log_name: str) -> List[ label = child.find("label").text name = BBOX_LABLES_TO_DETECTION_NAME_DICT.get(label, "unknown") timestamp = int(child.find("timestamp").text) # -1 for static objects - if child.find("transform") is None or name not in KIITI360_DETECTION_NAME_DICT.keys() or timestamp != -1: + if child.find("transform") is None or name not in KITTI360_DETECTION_NAME_DICT.keys() or timestamp != -1: continue obj = KITTI360Bbox3D() obj.parseBbox(child) From 9e9de92517e580315f830bf504136215b808f56a Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 14 Nov 2025 18:44:17 +0100 Subject: [PATCH 25/50] Add pre-commit to github actions. --- .github/workflows/pre-commit.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/pre-commit.yaml diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 00000000..2b11178b --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,14 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - uses: pre-commit/action@v3.0.1 From 50da96c66af4f0219d32b1865dce17a2cb9269b6 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 16 Nov 2025 18:39:08 +0100 Subject: [PATCH 26/50] Switch from `unittest` to `pytest` --- .../test_box_detection_label_registry.py | 16 +- .../registry/test_lidar_registry.py | 15 +- .../detections/test_box_detections.py | 186 +++---- .../detections/test_traffic_lights.py | 20 +- .../map_objects/test_base_map_objects.py | 13 +- .../datatypes/map_objects/test_map_objects.py | 518 +++++++++--------- .../datatypes/metadata/test_log_metadata.py | 68 +-- .../datatypes/metadata/test_map_metadata.py | 4 +- .../sensors/test_fisheye_mei_camera.py | 175 +++--- tests/unit/datatypes/sensors/test_lidar.py | 16 +- .../datatypes/sensors/test_pinhole_camera.py | 259 +++++---- tests/unit/datatypes/time/test_time.py | 8 +- .../vehicle_state/test_dynamic_state.py | 6 +- .../datatypes/vehicle_state/test_ego_state.py | 148 ++--- .../vehicle_state/test_vehicle_parameters.py | 44 +- tests/unit/geometry/test_bounding_box.py | 107 ++-- tests/unit/geometry/test_occupancy_map.py | 161 +++--- tests/unit/geometry/test_point.py | 79 +-- tests/unit/geometry/test_polyline.py | 139 +++-- tests/unit/geometry/test_rotation.py | 113 ++-- tests/unit/geometry/test_vector.py | 83 +-- .../transform/test_transform_consistency.py | 12 +- .../transform/test_transform_euler_se3.py | 6 +- .../geometry/transform/test_transform_se2.py | 6 +- .../geometry/transform/test_transform_se3.py | 4 +- .../geometry/utils/test_bounding_box_utils.py | 14 +- .../geometry/utils/test_rotation_utils.py | 82 +-- 27 files changed, 1143 insertions(+), 1159 deletions(-) diff --git a/tests/unit/conversion/registry/test_box_detection_label_registry.py b/tests/unit/conversion/registry/test_box_detection_label_registry.py index 5ba59473..384b0b96 100644 --- a/tests/unit/conversion/registry/test_box_detection_label_registry.py +++ b/tests/unit/conversion/registry/test_box_detection_label_registry.py @@ -1,14 +1,12 @@ -import unittest - from py123d.conversion.registry.box_detection_label_registry import BOX_DETECTION_LABEL_REGISTRY, BoxDetectionLabel -class TestBoxDetectionLabelRegistry(unittest.TestCase): +class TestBoxDetectionLabelRegistry: def test_correct_type(self): """Test that all registered box detection labels are of correct type.""" for label_class in BOX_DETECTION_LABEL_REGISTRY.values(): - self.assertTrue(issubclass(label_class, BoxDetectionLabel)) + assert issubclass(label_class, BoxDetectionLabel) def test_initialize_all_labels(self): """Test that all registered box detection labels can be initialized.""" @@ -17,8 +15,8 @@ def test_initialize_all_labels(self): for integer in range(len(label_enum_class)): label_a = label_enum_class.from_int(integer) label_b = label_enum_class(integer) - self.assertIsInstance(label_a, label_enum_class) - self.assertIsInstance(label_b, label_enum_class) + assert isinstance(label_a, label_enum_class) + assert isinstance(label_b, label_enum_class) def test_serialize_deserialize(self): """Test that all registered box detection labels can be serialized and deserialized.""" @@ -30,8 +28,8 @@ def test_serialize_deserialize(self): serialized_upper = label.serialize(lower=False) deserialized_lower = label_enum_class.deserialize(serialized_lower) deserialized_upper = label_enum_class.deserialize(serialized_upper) - self.assertEqual(label, deserialized_lower) - self.assertEqual(label, deserialized_upper) + assert label == deserialized_lower + assert label == deserialized_upper def test_to_default(self): """Test that all registered box detection labels can be converted to DefaultBoxDetectionLabel.""" @@ -42,4 +40,4 @@ def test_to_default(self): for integer in range(len(label_enum_class)): label = label_enum_class.from_int(integer) default_label = label.to_default() - self.assertIsInstance(default_label, DefaultBoxDetectionLabel) + assert isinstance(default_label, DefaultBoxDetectionLabel) diff --git a/tests/unit/conversion/registry/test_lidar_registry.py b/tests/unit/conversion/registry/test_lidar_registry.py index 4fd2ea57..eca6bd59 100644 --- a/tests/unit/conversion/registry/test_lidar_registry.py +++ b/tests/unit/conversion/registry/test_lidar_registry.py @@ -1,4 +1,3 @@ -import unittest from enum import IntEnum import numpy as np @@ -6,12 +5,12 @@ from py123d.conversion.registry.lidar_index_registry import LIDAR_INDEX_REGISTRY, LiDARIndex -class TestLiDARRegistry(unittest.TestCase): +class TestLiDARRegistry: def test_registered_types(self): """Test that all registered LiDAR types are of correct type.""" for lidar_class in LIDAR_INDEX_REGISTRY.values(): - self.assertTrue(issubclass(lidar_class, LiDARIndex)) + assert issubclass(lidar_class, LiDARIndex) def test_initialize_all_types(self): """Test that all registered LiDAR types can be initialized.""" @@ -19,9 +18,9 @@ def test_initialize_all_types(self): lidar_enum_class: LiDARIndex for integer in range(len(lidar_enum_class)): lidar_pc_index = lidar_enum_class(integer) - self.assertIsInstance(lidar_pc_index, LiDARIndex) - self.assertIsInstance(lidar_pc_index, IntEnum) - self.assertIsInstance(lidar_pc_index, int) + assert isinstance(lidar_pc_index, LiDARIndex) + assert isinstance(lidar_pc_index, IntEnum) + assert isinstance(lidar_pc_index, int) def test_xy_slice(self): """Test that all registered LiDAR types have correct xy slice.""" @@ -29,7 +28,7 @@ def test_xy_slice(self): lidar_enum_class: LiDARIndex dummy_lidar_pc = np.zeros((42, len(lidar_enum_class)), dtype=np.float32) lidar_pc_xy_slice = dummy_lidar_pc[..., lidar_enum_class.XY] - self.assertEqual(lidar_pc_xy_slice.shape[-1], 2) + assert lidar_pc_xy_slice.shape[-1] == 2 def test_xyz_slice(self): """Test that all registered LiDAR types have correct xyz slice.""" @@ -37,4 +36,4 @@ def test_xyz_slice(self): lidar_enum_class: LiDARIndex dummy_lidar_pc = np.zeros((42, len(lidar_enum_class)), dtype=np.float32) lidar_pc_xyz_slice = dummy_lidar_pc[..., lidar_enum_class.XYZ] - self.assertEqual(lidar_pc_xyz_slice.shape[-1], 3) + assert lidar_pc_xyz_slice.shape[-1] == 3 diff --git a/tests/unit/datatypes/detections/test_box_detections.py b/tests/unit/datatypes/detections/test_box_detections.py index 02c30501..cdcee382 100644 --- a/tests/unit/datatypes/detections/test_box_detections.py +++ b/tests/unit/datatypes/detections/test_box_detections.py @@ -1,4 +1,4 @@ -import unittest +import pytest from py123d.conversion.registry.box_detection_label_registry import BoxDetectionLabel, DefaultBoxDetectionLabel from py123d.datatypes.detections import ( @@ -34,23 +34,23 @@ def to_default(self): } -class TestBoxDetectionMetadata(unittest.TestCase): +class TestBoxDetectionMetadata: def test_initialization(self): metadata = BoxDetectionMetadata(**sample_metadata_args) - self.assertIsInstance(metadata, BoxDetectionMetadata) - self.assertEqual(metadata.label, DummyBoxDetectionLabel.CAR) - self.assertEqual(metadata.track_token, "sample_token") - self.assertEqual(metadata.num_lidar_points, 10) - self.assertIsInstance(metadata.timepoint, TimePoint) + assert isinstance(metadata, BoxDetectionMetadata) + assert metadata.label == DummyBoxDetectionLabel.CAR + assert metadata.track_token == "sample_token" + assert metadata.num_lidar_points == 10 + assert isinstance(metadata.timepoint, TimePoint) def test_default_label(self): metadata = BoxDetectionMetadata(**sample_metadata_args) label = metadata.label default_label = metadata.default_label - self.assertEqual(label, DummyBoxDetectionLabel.CAR) - self.assertEqual(label.to_default(), DefaultBoxDetectionLabel.VEHICLE) - self.assertEqual(default_label, DefaultBoxDetectionLabel.VEHICLE) + assert label == DummyBoxDetectionLabel.CAR + assert label.to_default() == DefaultBoxDetectionLabel.VEHICLE + assert default_label == DefaultBoxDetectionLabel.VEHICLE def test_default_label_with_default_label(self): sample_args = sample_metadata_args.copy() @@ -58,8 +58,8 @@ def test_default_label_with_default_label(self): metadata = BoxDetectionMetadata(**sample_args) label = metadata.label default_label = metadata.default_label - self.assertEqual(label, DefaultBoxDetectionLabel.PERSON) - self.assertEqual(default_label, DefaultBoxDetectionLabel.PERSON) + assert label == DefaultBoxDetectionLabel.PERSON + assert default_label == DefaultBoxDetectionLabel.PERSON def test_optional_args(self): sample_args = { @@ -67,35 +67,35 @@ def test_optional_args(self): "track_token": "another_token", } metadata = BoxDetectionMetadata(**sample_args) - self.assertIsInstance(metadata, BoxDetectionMetadata) - self.assertEqual(metadata.label, DummyBoxDetectionLabel.BICYCLE) - self.assertEqual(metadata.track_token, "another_token") - self.assertIsNone(metadata.num_lidar_points) - self.assertIsNone(metadata.timepoint) + assert isinstance(metadata, BoxDetectionMetadata) + assert metadata.label == DummyBoxDetectionLabel.BICYCLE + assert metadata.track_token == "another_token" + assert metadata.num_lidar_points is None + assert metadata.timepoint is None def test_missing_args(self): sample_args = { "label": DummyBoxDetectionLabel.CAR, } - with self.assertRaises(TypeError): + with pytest.raises(TypeError): BoxDetectionMetadata(**sample_args) sample_args = { "track_token": "token_only", } - with self.assertRaises(TypeError): + with pytest.raises(TypeError): BoxDetectionMetadata(**sample_args) sample_args = { "timepoint": TimePoint.from_s(0.0), } - with self.assertRaises(TypeError): + with pytest.raises(TypeError): BoxDetectionMetadata(**sample_args) -class TestBoxDetectionSE2(unittest.TestCase): +class TestBoxDetectionSE2: - def setUp(self): + def setup_method(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se2 = BoundingBoxSE2( center_se2=PoseSE2(x=0.0, y=0.0, yaw=0.0), @@ -110,10 +110,10 @@ def test_initialization(self): bounding_box_se2=self.bounding_box_se2, velocity_2d=self.velocity, ) - self.assertIsInstance(box_detection, BoxDetectionSE2) - self.assertEqual(box_detection.metadata, self.metadata) - self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se2) - self.assertIsNone(box_detection.velocity_2d) + assert isinstance(box_detection, BoxDetectionSE2) + assert box_detection.metadata == self.metadata + assert box_detection.bounding_box_se2 == self.bounding_box_se2 + assert box_detection.velocity_2d is None def test_properties(self): box_detection = BoxDetectionSE2( @@ -121,30 +121,30 @@ def test_properties(self): bounding_box_se2=self.bounding_box_se2, velocity_2d=self.velocity, ) - self.assertEqual(box_detection.shapely_polygon, self.bounding_box_se2.shapely_polygon) - self.assertEqual(box_detection.center_se2, self.bounding_box_se2.center_se2) - self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se2) + assert box_detection.shapely_polygon == self.bounding_box_se2.shapely_polygon + assert box_detection.center_se2 == self.bounding_box_se2.center_se2 + assert box_detection.bounding_box_se2 == self.bounding_box_se2 def test_optional_velocity(self): box_detection_no_velo = BoxDetectionSE2( metadata=self.metadata, bounding_box_se2=self.bounding_box_se2, ) - self.assertIsInstance(box_detection_no_velo, BoxDetectionSE2) - self.assertIsNone(box_detection_no_velo.velocity_2d) + assert isinstance(box_detection_no_velo, BoxDetectionSE2) + assert box_detection_no_velo.velocity_2d is None box_detection_velo = BoxDetectionSE2( metadata=self.metadata, bounding_box_se2=self.bounding_box_se2, velocity_2d=Vector2D(x=1.0, y=0.0), ) - self.assertIsInstance(box_detection_velo, BoxDetectionSE2) - self.assertEqual(box_detection_velo.velocity_2d, Vector2D(x=1.0, y=0.0)) + assert isinstance(box_detection_velo, BoxDetectionSE2) + assert box_detection_velo.velocity_2d == Vector2D(x=1.0, y=0.0) -class TestBoxBoxDetectionSE3(unittest.TestCase): +class TestBoxBoxDetectionSE3: - def setUp(self): + def setup_method(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se3 = BoundingBoxSE3( center_se3=PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0), @@ -160,10 +160,10 @@ def test_initialization(self): bounding_box_se3=self.bounding_box_se3, velocity=self.velocity, ) - self.assertIsInstance(box_detection, BoxDetectionSE3) - self.assertEqual(box_detection.metadata, self.metadata) - self.assertEqual(box_detection.bounding_box_se3, self.bounding_box_se3) - self.assertEqual(box_detection.velocity_3d, self.velocity) + assert isinstance(box_detection, BoxDetectionSE3) + assert box_detection.metadata == self.metadata + assert box_detection.bounding_box_se3 == self.bounding_box_se3 + assert box_detection.velocity_3d == self.velocity def test_properties(self): box_detection = BoxDetectionSE3( @@ -171,13 +171,13 @@ def test_properties(self): bounding_box_se3=self.bounding_box_se3, velocity=self.velocity, ) - self.assertEqual(box_detection.shapely_polygon, self.bounding_box_se3.shapely_polygon) - self.assertEqual(box_detection.center_se3, self.bounding_box_se3.center_se3) - self.assertEqual(box_detection.center_se2, self.bounding_box_se3.center_se2) - self.assertEqual(box_detection.bounding_box_se3, self.bounding_box_se3) - self.assertEqual(box_detection.bounding_box_se2, self.bounding_box_se3.bounding_box_se2) - self.assertEqual(box_detection.velocity_3d, self.velocity) - self.assertEqual(box_detection.velocity_2d, self.velocity.vector_2d) + assert box_detection.shapely_polygon == self.bounding_box_se3.shapely_polygon + assert box_detection.center_se3 == self.bounding_box_se3.center_se3 + assert box_detection.center_se2 == self.bounding_box_se3.center_se2 + assert box_detection.bounding_box_se3 == self.bounding_box_se3 + assert box_detection.bounding_box_se2 == self.bounding_box_se3.bounding_box_se2 + assert box_detection.velocity_3d == self.velocity + assert box_detection.velocity_2d == self.velocity.vector_2d def test_box_detection_se2_conversion(self): box_detection = BoxDetectionSE3( @@ -186,10 +186,10 @@ def test_box_detection_se2_conversion(self): velocity=Vector3D(x=1.0, y=0.0, z=0.0), ) box_detection_se2 = box_detection.box_detection_se2 - self.assertIsInstance(box_detection_se2, BoxDetectionSE2) - self.assertEqual(box_detection_se2.metadata, self.metadata) - self.assertEqual(box_detection_se2.bounding_box_se2, self.bounding_box_se3.bounding_box_se2) - self.assertEqual(box_detection_se2.velocity_2d, Vector2D(x=1.0, y=0.0)) + assert isinstance(box_detection_se2, BoxDetectionSE2) + assert box_detection_se2.metadata == self.metadata + assert box_detection_se2.bounding_box_se2 == self.bounding_box_se3.bounding_box_se2 + assert box_detection_se2.velocity_2d == Vector2D(x=1.0, y=0.0) def test_box_detection_se3_conversion(self): box_detection_se2 = BoxDetectionSE2( @@ -202,37 +202,37 @@ def test_box_detection_se3_conversion(self): bounding_box_se3=self.bounding_box_se3, velocity=Vector3D(x=1.0, y=0.0, z=0.0), ) - self.assertIsInstance(box_detection_se3, BoxDetectionSE3) - self.assertEqual(box_detection_se3.metadata, box_detection_se2.metadata) - self.assertEqual(box_detection_se3.bounding_box_se3, self.bounding_box_se3) - self.assertEqual(box_detection_se3.velocity_2d, Vector2D(x=1.0, y=0.0)) + assert isinstance(box_detection_se3, BoxDetectionSE3) + assert box_detection_se3.metadata == box_detection_se2.metadata + assert box_detection_se3.bounding_box_se3 == self.bounding_box_se3 + assert box_detection_se3.velocity_2d == Vector2D(x=1.0, y=0.0) box_detection_se3_converted = box_detection_se3.box_detection_se2 - self.assertIsInstance(box_detection_se3_converted, BoxDetectionSE2) - self.assertEqual(box_detection_se3_converted.metadata, box_detection_se2.metadata) - self.assertEqual(box_detection_se3_converted.bounding_box_se2, box_detection_se2.bounding_box_se2) - self.assertEqual(box_detection_se3_converted.velocity_2d, box_detection_se2.velocity_2d) + assert isinstance(box_detection_se3_converted, BoxDetectionSE2) + assert box_detection_se3_converted.metadata == box_detection_se2.metadata + assert box_detection_se3_converted.bounding_box_se2 == box_detection_se2.bounding_box_se2 + assert box_detection_se3_converted.velocity_2d == box_detection_se2.velocity_2d def test_optional_velocity(self): box_detection_no_velo = BoxDetectionSE3( metadata=self.metadata, bounding_box_se3=self.bounding_box_se3, ) - self.assertIsInstance(box_detection_no_velo, BoxDetectionSE3) - self.assertIsNone(box_detection_no_velo.velocity_3d) + assert isinstance(box_detection_no_velo, BoxDetectionSE3) + assert box_detection_no_velo.velocity_3d is None box_detection_velo = BoxDetectionSE3( metadata=self.metadata, bounding_box_se3=self.bounding_box_se3, velocity=Vector3D(x=1.0, y=0.0, z=0.0), ) - self.assertIsInstance(box_detection_velo, BoxDetectionSE3) - self.assertEqual(box_detection_velo.velocity_3d, Vector3D(x=1.0, y=0.0, z=0.0)) + assert isinstance(box_detection_velo, BoxDetectionSE3) + assert box_detection_velo.velocity_3d == Vector3D(x=1.0, y=0.0, z=0.0) -class TestBoxDetectionWrapper(unittest.TestCase): +class TestBoxDetectionWrapper: - def setUp(self): + def setup_method(self): self.metadata1 = BoxDetectionMetadata( label=DummyBoxDetectionLabel.CAR, track_token="token1", @@ -283,80 +283,80 @@ def setUp(self): def test_initialization(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) - self.assertIsInstance(wrapper, BoxDetectionWrapper) - self.assertEqual(len(wrapper.box_detections), 2) + assert isinstance(wrapper, BoxDetectionWrapper) + assert len(wrapper.box_detections) == 2 def test_empty_initialization(self): wrapper = BoxDetectionWrapper(box_detections=[]) - self.assertIsInstance(wrapper, BoxDetectionWrapper) - self.assertEqual(len(wrapper.box_detections), 0) + assert isinstance(wrapper, BoxDetectionWrapper) + assert len(wrapper.box_detections) == 0 def test_getitem(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) - self.assertEqual(wrapper[0], self.box_detection1) - self.assertEqual(wrapper[1], self.box_detection2) + assert wrapper[0] == self.box_detection1 + assert wrapper[1] == self.box_detection2 def test_getitem_out_of_range(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1]) - with self.assertRaises(IndexError): + with pytest.raises(IndexError): _ = wrapper[1] def test_len(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2, self.box_detection3]) - self.assertEqual(len(wrapper), 3) + assert len(wrapper) == 3 def test_len_empty(self): wrapper = BoxDetectionWrapper(box_detections=[]) - self.assertEqual(len(wrapper), 0) + assert len(wrapper) == 0 def test_iter(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) detections = list(wrapper) - self.assertEqual(len(detections), 2) - self.assertEqual(detections[0], self.box_detection1) - self.assertEqual(detections[1], self.box_detection2) + assert len(detections) == 2 + assert detections[0] == self.box_detection1 + assert detections[1] == self.box_detection2 def test_get_detection_by_track_token_found(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2, self.box_detection3]) detection = wrapper.get_detection_by_track_token("token2") - self.assertIsNotNone(detection) - self.assertEqual(detection, self.box_detection2) - self.assertEqual(detection.metadata.track_token, "token2") + assert detection is not None + assert detection == self.box_detection2 + assert detection.metadata.track_token == "token2" def test_get_detection_by_track_token_not_found(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) detection = wrapper.get_detection_by_track_token("nonexistent_token") - self.assertIsNone(detection) + assert detection is None def test_get_detection_by_track_token_empty_wrapper(self): wrapper = BoxDetectionWrapper(box_detections=[]) detection = wrapper.get_detection_by_track_token("token1") - self.assertIsNone(detection) + assert detection is None def test_occupancy_map(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) occupancy_map = wrapper.occupancy_map_2d - self.assertIsNotNone(occupancy_map) - self.assertEqual(len(occupancy_map.geometries), 2) - self.assertEqual(len(occupancy_map.ids), 2) - self.assertIn("token1", occupancy_map.ids) - self.assertIn("token2", occupancy_map.ids) + assert occupancy_map is not None + assert len(occupancy_map.geometries) == 2 + assert len(occupancy_map.ids) == 2 + assert "token1" in occupancy_map.ids + assert "token2" in occupancy_map.ids def test_occupancy_map_cached(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection2]) occupancy_map1 = wrapper.occupancy_map_2d occupancy_map2 = wrapper.occupancy_map_2d - self.assertIs(occupancy_map1, occupancy_map2) + assert occupancy_map1 is occupancy_map2 def test_occupancy_map_empty(self): wrapper = BoxDetectionWrapper(box_detections=[]) occupancy_map = wrapper.occupancy_map_2d - self.assertIsNotNone(occupancy_map) - self.assertEqual(len(occupancy_map.geometries), 0) - self.assertEqual(len(occupancy_map.ids), 0) + assert occupancy_map is not None + assert len(occupancy_map.geometries) == 0 + assert len(occupancy_map.ids) == 0 def test_mixed_detection_types(self): wrapper = BoxDetectionWrapper(box_detections=[self.box_detection1, self.box_detection3]) - self.assertEqual(len(wrapper), 2) - self.assertIsInstance(wrapper[0], BoxDetectionSE2) - self.assertIsInstance(wrapper[1], BoxDetectionSE3) + assert len(wrapper) == 2 + assert isinstance(wrapper[0], BoxDetectionSE2) + assert isinstance(wrapper[1], BoxDetectionSE3) diff --git a/tests/unit/datatypes/detections/test_traffic_lights.py b/tests/unit/datatypes/detections/test_traffic_lights.py index 118d413b..958bfe51 100644 --- a/tests/unit/datatypes/detections/test_traffic_lights.py +++ b/tests/unit/datatypes/detections/test_traffic_lights.py @@ -1,20 +1,18 @@ -import unittest - from py123d.datatypes.detections import TrafficLightDetection, TrafficLightDetectionWrapper, TrafficLightStatus from py123d.datatypes.time.time_point import TimePoint -class TestTrafficLightStatus(unittest.TestCase): +class TestTrafficLightStatus: def test_status_values(self): """Test that TrafficLightStatus enum has correct values.""" - self.assertEqual(TrafficLightStatus.GREEN.value, 0) - self.assertEqual(TrafficLightStatus.YELLOW.value, 1) - self.assertEqual(TrafficLightStatus.RED.value, 2) - self.assertEqual(TrafficLightStatus.OFF.value, 3) - self.assertEqual(TrafficLightStatus.UNKNOWN.value, 4) + assert TrafficLightStatus.GREEN.value == 0 + assert TrafficLightStatus.YELLOW.value == 1 + assert TrafficLightStatus.RED.value == 2 + assert TrafficLightStatus.OFF.value == 3 + assert TrafficLightStatus.UNKNOWN.value == 4 -class TestTrafficLightDetection(unittest.TestCase): +class TestTrafficLightDetection: def test_creation_with_required_fields(self): """Test that TrafficLightDetection can be created with required fields.""" detection = TrafficLightDetection(lane_id=1, status=TrafficLightStatus.GREEN) @@ -35,8 +33,8 @@ def test_creation_with_timepoint(self): assert detection.timepoint == timepoint -class TestTrafficLightDetectionWrapper(unittest.TestCase): - def setUp(self): +class TestTrafficLightDetectionWrapper: + def setup_method(self): self.detection1 = TrafficLightDetection(lane_id=1, status=TrafficLightStatus.GREEN) self.detection2 = TrafficLightDetection(lane_id=2, status=TrafficLightStatus.RED) self.detection3 = TrafficLightDetection(lane_id=3, status=TrafficLightStatus.YELLOW) diff --git a/tests/unit/datatypes/map_objects/test_base_map_objects.py b/tests/unit/datatypes/map_objects/test_base_map_objects.py index 8d02a727..8e22dea8 100644 --- a/tests/unit/datatypes/map_objects/test_base_map_objects.py +++ b/tests/unit/datatypes/map_objects/test_base_map_objects.py @@ -1,6 +1,5 @@ -import unittest - import numpy as np +import pytest import shapely.geometry as geom from py123d.datatypes.map_objects.base_map_objects import BaseMapLineObject, BaseMapObject, BaseMapSurfaceObject @@ -44,7 +43,7 @@ def layer(self) -> MapLayer: return self._layer -class TestBaseMapObject(unittest.TestCase): +class TestBaseMapObject: """Test cases for BaseMapObject class.""" def test_init_with_string_id(self): @@ -69,11 +68,11 @@ def test_layer_property(self): def test_abstract_instantiation_fails(self): """Test that instantiating BaseMapObject directly raises TypeError.""" - with self.assertRaises(TypeError): + with pytest.raises(TypeError): BaseMapObject("test_id") -class TestBaseMapSurfaceObject(unittest.TestCase): +class TestBaseMapSurfaceObject: """Test cases for BaseMapSurfaceObject class.""" def test_init_with_polyline2d(self): @@ -95,7 +94,7 @@ def test_init_with_shapely_polygon(self): assert obj.shapely_polygon.equals(polygon) def test_init_without_outline_or_polygon_raises_error(self): - with self.assertRaises(ValueError): + with pytest.raises(ValueError): ConcreteMapSurfaceObject("surf_4") def test_outline_property(self): @@ -145,7 +144,7 @@ def test_trimesh_mesh_property(self): assert len(mesh.faces) > 0 -class TestBaseMapLineObject(unittest.TestCase): +class TestBaseMapLineObject: """Test cases for BaseMapLineObject class.""" def test_init_with_polyline2d(self): diff --git a/tests/unit/datatypes/map_objects/test_map_objects.py b/tests/unit/datatypes/map_objects/test_map_objects.py index dbda5460..ed36ffc3 100644 --- a/tests/unit/datatypes/map_objects/test_map_objects.py +++ b/tests/unit/datatypes/map_objects/test_map_objects.py @@ -1,7 +1,7 @@ -import unittest from typing import List, Tuple import numpy as np +import pytest import shapely import trimesh @@ -184,8 +184,8 @@ def _get_linked_map_object_setup() -> Tuple[List[Lane], List[LaneGroup], List[In return lanes, lane_groups, intersections -class TestLane(unittest.TestCase): - def setUp(self) -> None: +class TestLane: + def setup_method(self) -> None: lanes, lane_groups, intersections = _get_linked_map_object_setup() self.lanes = lanes self.lane_groups = lane_groups @@ -193,34 +193,34 @@ def setUp(self) -> None: def test_set_up(self): """Test that the setup function creates the correct number of map objects.""" - self.assertEqual(len(self.lanes), 5) - self.assertEqual(len(self.lane_groups), 3) - self.assertEqual(len(self.intersections), 2) + assert len(self.lanes) == 5 + assert len(self.lane_groups) == 3 + assert len(self.intersections) == 2 def test_properties(self): """Test that the properties of the Lane objects are correct.""" lane0 = self.lanes[0] - self.assertEqual(lane0.layer, MapLayer.LANE) - self.assertEqual(lane0.lane_group_id, 0) - self.assertIsInstance(lane0.left_boundary, Polyline3D) - self.assertIsInstance(lane0.right_boundary, Polyline3D) - self.assertIsInstance(lane0.centerline, Polyline3D) - - self.assertEqual(lane0.left_lane_id, 1) - self.assertEqual(lane0.right_lane_id, 2) - self.assertEqual(lane0.predecessor_ids, [3]) - self.assertEqual(lane0.successor_ids, [4]) - self.assertEqual(lane0.speed_limit_mps, 0.0) - self.assertIsInstance(lane0.trimesh_mesh, trimesh.base.Trimesh) + assert lane0.layer == MapLayer.LANE + assert lane0.lane_group_id == 0 + assert isinstance(lane0.left_boundary, Polyline3D) + assert isinstance(lane0.right_boundary, Polyline3D) + assert isinstance(lane0.centerline, Polyline3D) + + assert lane0.left_lane_id == 1 + assert lane0.right_lane_id == 2 + assert lane0.predecessor_ids == [3] + assert lane0.successor_ids == [4] + assert lane0.speed_limit_mps == 0.0 + assert isinstance(lane0.trimesh_mesh, trimesh.base.Trimesh) def test_base_properties(self): """Test that the base_surface property of the Lane objects is correct.""" lane0 = self.lanes[0] - self.assertEqual(lane0.object_id, 0) - self.assertIsInstance(lane0.outline, Polyline3D) - self.assertIsInstance(lane0.outline_2d, Polyline2D) - self.assertIsInstance(lane0.outline_3d, Polyline3D) - self.assertIsInstance(lane0.shapely_polygon, shapely.Polygon) + assert lane0.object_id == 0 + assert isinstance(lane0.outline, Polyline3D) + assert isinstance(lane0.outline_2d, Polyline2D) + assert isinstance(lane0.outline_3d, Polyline3D) + assert isinstance(lane0.shapely_polygon, shapely.Polygon) def test_left_links(self): """Test that the left neighboring lanes are correctly linked.""" @@ -232,17 +232,17 @@ def test_left_links(self): ) def _no_left_neighbor(lane: Lane): - self.assertIsNotNone(lane) - self.assertIsNone(lane.left_lane) - self.assertIsNone(lane.left_lane_id) + assert lane is not None + assert lane.left_lane is None + assert lane.left_lane_id is None # Middle Lane 0 lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) - self.assertIsNotNone(lane0) - self.assertIsNotNone(lane0.left_lane) - self.assertIsInstance(lane0.left_lane, Lane) - self.assertEqual(lane0.left_lane.object_id, 1) - self.assertEqual(lane0.left_lane.object_id, lane0.left_lane_id) + assert lane0 is not None + assert lane0.left_lane is not None + assert isinstance(lane0.left_lane, Lane) + assert lane0.left_lane.object_id == 1 + assert lane0.left_lane.object_id == lane0.left_lane_id # Left Lane 1 lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) @@ -250,11 +250,11 @@ def _no_left_neighbor(lane: Lane): # Right Lane 2 lane2: Lane = map_api.get_map_object(2, MapLayer.LANE) - self.assertIsNotNone(lane2) - self.assertIsNotNone(lane2.left_lane) - self.assertIsInstance(lane2.left_lane, Lane) - self.assertEqual(lane2.left_lane.object_id, 0) - self.assertEqual(lane2.left_lane.object_id, lane2.left_lane_id) + assert lane2 is not None + assert lane2.left_lane is not None + assert isinstance(lane2.left_lane, Lane) + assert lane2.left_lane.object_id == 0 + assert lane2.left_lane.object_id == lane2.left_lane_id # Predecessor Lane 3 lane3: Lane = map_api.get_map_object(3, MapLayer.LANE) @@ -274,25 +274,25 @@ def test_right_links(self): ) def _no_right_neighbor(lane: Lane): - self.assertIsNotNone(lane) - self.assertIsNone(lane.right_lane) - self.assertIsNone(lane.right_lane_id) + assert lane is not None + assert lane.right_lane is None + assert lane.right_lane_id is None # Middle Lane 0 lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) - self.assertIsNotNone(lane0) - self.assertIsNotNone(lane0.right_lane) - self.assertIsInstance(lane0.right_lane, Lane) - self.assertEqual(lane0.right_lane.object_id, 2) - self.assertEqual(lane0.right_lane.object_id, lane0.right_lane_id) + assert lane0 is not None + assert lane0.right_lane is not None + assert isinstance(lane0.right_lane, Lane) + assert lane0.right_lane.object_id == 2 + assert lane0.right_lane.object_id == lane0.right_lane_id # Left Lane 1 lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) - self.assertIsNotNone(lane1) - self.assertIsNotNone(lane1.right_lane) - self.assertIsInstance(lane1.right_lane, Lane) - self.assertEqual(lane1.right_lane.object_id, 0) - self.assertEqual(lane1.right_lane.object_id, lane1.right_lane_id) + assert lane1 is not None + assert lane1.right_lane is not None + assert isinstance(lane1.right_lane, Lane) + assert lane1.right_lane.object_id == 0 + assert lane1.right_lane.object_id == lane1.right_lane_id # Right Lane 2 lane2: Lane = map_api.get_map_object(2, MapLayer.LANE) @@ -316,18 +316,18 @@ def test_predecessor_links(self): ) def _no_predecessors(lane: Lane): - self.assertIsNotNone(lane) - self.assertEqual(lane.predecessors, []) - self.assertEqual(lane.predecessor_ids, []) + assert lane is not None + assert lane.predecessors == [] + assert lane.predecessor_ids == [] # Middle Lane 0 lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) - self.assertIsNotNone(lane0) - self.assertIsNotNone(lane0.predecessors) - self.assertEqual(len(lane0.predecessors), 1) - self.assertIsInstance(lane0.predecessors[0], Lane) - self.assertEqual(lane0.predecessors[0].object_id, 3) - self.assertEqual(lane0.predecessor_ids, [3]) + assert lane0 is not None + assert lane0.predecessors is not None + assert len(lane0.predecessors) == 1 + assert isinstance(lane0.predecessors[0], Lane) + assert lane0.predecessors[0].object_id == 3 + assert lane0.predecessor_ids == [3] # Left Lane 1 lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) @@ -343,12 +343,12 @@ def _no_predecessors(lane: Lane): # Successor Lane 4 lane4: Lane = map_api.get_map_object(4, MapLayer.LANE) - self.assertIsNotNone(lane4) - self.assertIsNotNone(lane4.predecessors) - self.assertEqual(len(lane4.predecessors), 1) - self.assertIsInstance(lane4.predecessors[0], Lane) - self.assertEqual(lane4.predecessors[0].object_id, 0) - self.assertEqual(lane4.predecessor_ids, [0]) + assert lane4 is not None + assert lane4.predecessors is not None + assert len(lane4.predecessors) == 1 + assert isinstance(lane4.predecessors[0], Lane) + assert lane4.predecessors[0].object_id == 0 + assert lane4.predecessor_ids == [0] def test_successor_links(self): """Test that the successor lanes are correctly linked.""" @@ -360,18 +360,18 @@ def test_successor_links(self): ) def _no_successors(lane: Lane): - self.assertIsNotNone(lane) - self.assertEqual(lane.successors, []) - self.assertEqual(lane.successor_ids, []) + assert lane is not None + assert lane.successors == [] + assert lane.successor_ids == [] # Middle Lane 0 lane0: Lane = map_api.get_map_object(0, MapLayer.LANE) - self.assertIsNotNone(lane0) - self.assertIsNotNone(lane0.successors) - self.assertEqual(len(lane0.successors), 1) - self.assertIsInstance(lane0.successors[0], Lane) - self.assertEqual(lane0.successors[0].object_id, 4) - self.assertEqual(lane0.successor_ids, [4]) + assert lane0 is not None + assert lane0.successors is not None + assert len(lane0.successors) == 1 + assert isinstance(lane0.successors[0], Lane) + assert lane0.successors[0].object_id == 4 + assert lane0.successor_ids == [4] # Left Lane 1 lane1: Lane = map_api.get_map_object(1, MapLayer.LANE) @@ -383,12 +383,12 @@ def _no_successors(lane: Lane): # Predecessor Lane 3 lane3: Lane = map_api.get_map_object(3, MapLayer.LANE) - self.assertIsNotNone(lane3) - self.assertIsNotNone(lane3.successors) - self.assertEqual(len(lane3.successors), 1) - self.assertIsInstance(lane3.successors[0], Lane) - self.assertEqual(lane3.successors[0].object_id, 0) - self.assertEqual(lane3.successor_ids, [0]) + assert lane3 is not None + assert lane3.successors is not None + assert len(lane3.successors) == 1 + assert isinstance(lane3.successors[0], Lane) + assert lane3.successors[0].object_id == 0 + assert lane3.successor_ids == [0] # Successor Lane 4 lane4: Lane = map_api.get_map_object(4, MapLayer.LANE) @@ -403,11 +403,11 @@ def test_no_links(self): ) for lane in self.lanes: lane_from_api: Lane = map_api.get_map_object(lane.object_id, MapLayer.LANE) - self.assertIsNotNone(lane_from_api) - self.assertIsNone(lane_from_api.left_lane) - self.assertIsNone(lane_from_api.right_lane) - self.assertIsNone(lane_from_api.predecessors) - self.assertIsNone(lane_from_api.successors) + assert lane_from_api is not None + assert lane_from_api.left_lane is None + assert lane_from_api.right_lane is None + assert lane_from_api.predecessors is None + assert lane_from_api.successors is None def test_lane_group_links(self): """Test that the lane group links are correct.""" @@ -420,15 +420,15 @@ def test_lane_group_links(self): for lane in self.lanes: lane_from_api: Lane = map_api.get_map_object(lane.object_id, MapLayer.LANE) - self.assertIsNotNone(lane_from_api) - self.assertIsNotNone(lane_from_api.lane_group) - self.assertIsInstance(lane_from_api.lane_group, LaneGroup) - self.assertEqual(lane_from_api.lane_group.object_id, lane_from_api.lane_group_id) + assert lane_from_api is not None + assert lane_from_api.lane_group is not None + assert isinstance(lane_from_api.lane_group, LaneGroup) + assert lane_from_api.lane_group.object_id == lane_from_api.lane_group_id -class TestLaneGroup(unittest.TestCase): +class TestLaneGroup: - def setUp(self): + def setup_method(self): lanes, lane_groups, intersections = _get_linked_map_object_setup() self.lanes = lanes self.lane_groups = lane_groups @@ -437,23 +437,23 @@ def setUp(self): def test_properties(self): """Test that the properties of the LaneGroup objects are correct.""" lane_group0 = self.lane_groups[0] - self.assertEqual(lane_group0.layer, MapLayer.LANE_GROUP) - self.assertEqual(lane_group0.lane_ids, [0, 1, 2]) - self.assertIsInstance(lane_group0.left_boundary, Polyline3D) - self.assertIsInstance(lane_group0.right_boundary, Polyline3D) - self.assertEqual(lane_group0.intersection_id, None) - self.assertEqual(lane_group0.predecessor_ids, [1]) - self.assertEqual(lane_group0.successor_ids, [2]) - self.assertIsInstance(lane_group0.trimesh_mesh, trimesh.base.Trimesh) + assert lane_group0.layer == MapLayer.LANE_GROUP + assert lane_group0.lane_ids == [0, 1, 2] + assert isinstance(lane_group0.left_boundary, Polyline3D) + assert isinstance(lane_group0.right_boundary, Polyline3D) + assert lane_group0.intersection_id is None + assert lane_group0.predecessor_ids == [1] + assert lane_group0.successor_ids == [2] + assert isinstance(lane_group0.trimesh_mesh, trimesh.base.Trimesh) def test_base_properties(self): """Test that the base surface properties of the LaneGroup objects are correct.""" lane_group0 = self.lane_groups[0] - self.assertEqual(lane_group0.object_id, 0) - self.assertIsInstance(lane_group0.outline, Polyline3D) - self.assertIsInstance(lane_group0.outline_2d, Polyline2D) - self.assertIsInstance(lane_group0.outline_3d, Polyline3D) - self.assertIsInstance(lane_group0.shapely_polygon, shapely.Polygon) + assert lane_group0.object_id == 0 + assert isinstance(lane_group0.outline, Polyline3D) + assert isinstance(lane_group0.outline_2d, Polyline2D) + assert isinstance(lane_group0.outline_3d, Polyline3D) + assert isinstance(lane_group0.shapely_polygon, shapely.Polygon) def test_lane_links(self): """Test that the lanes are correctly linked to the lane group.""" @@ -466,28 +466,28 @@ def test_lane_links(self): # Lane group 0 contains lanes 0, 1, 2 lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group0) - self.assertIsNotNone(lane_group0.lanes) - self.assertEqual(len(lane_group0.lanes), 3) + assert lane_group0 is not None + assert lane_group0.lanes is not None + assert len(lane_group0.lanes) == 3 for i, lane in enumerate(lane_group0.lanes): - self.assertIsInstance(lane, Lane) - self.assertEqual(lane.object_id, i) + assert isinstance(lane, Lane) + assert lane.object_id == i # Lane group 1 contains lane 3 lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group1) - self.assertIsNotNone(lane_group1.lanes) - self.assertEqual(len(lane_group1.lanes), 1) - self.assertIsInstance(lane_group1.lanes[0], Lane) - self.assertEqual(lane_group1.lanes[0].object_id, 3) + assert lane_group1 is not None + assert lane_group1.lanes is not None + assert len(lane_group1.lanes) == 1 + assert isinstance(lane_group1.lanes[0], Lane) + assert lane_group1.lanes[0].object_id == 3 # Lane group 2 contains lane 4 lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group2) - self.assertIsNotNone(lane_group2.lanes) - self.assertEqual(len(lane_group2.lanes), 1) - self.assertIsInstance(lane_group2.lanes[0], Lane) - self.assertEqual(lane_group2.lanes[0].object_id, 4) + assert lane_group2 is not None + assert lane_group2.lanes is not None + assert len(lane_group2.lanes) == 1 + assert isinstance(lane_group2.lanes[0], Lane) + assert lane_group2.lanes[0].object_id == 4 def test_predecessor_links(self): """Test that the predecessor lane groups are correctly linked.""" @@ -499,18 +499,18 @@ def test_predecessor_links(self): ) def _no_predecessors(lane_group: LaneGroup): - self.assertIsNotNone(lane_group) - self.assertEqual(lane_group.predecessors, []) - self.assertEqual(lane_group.predecessor_ids, []) + assert lane_group is not None + assert lane_group.predecessors == [] + assert lane_group.predecessor_ids == [] # Lane group 0 has predecessor lane group 1 lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group0) - self.assertIsNotNone(lane_group0.predecessors) - self.assertEqual(len(lane_group0.predecessors), 1) - self.assertIsInstance(lane_group0.predecessors[0], LaneGroup) - self.assertEqual(lane_group0.predecessors[0].object_id, 1) - self.assertEqual(lane_group0.predecessor_ids, [1]) + assert lane_group0 is not None + assert lane_group0.predecessors is not None + assert len(lane_group0.predecessors) == 1 + assert isinstance(lane_group0.predecessors[0], LaneGroup) + assert lane_group0.predecessors[0].object_id == 1 + assert lane_group0.predecessor_ids == [1] # Lane group 1 has no predecessors lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) @@ -518,12 +518,12 @@ def _no_predecessors(lane_group: LaneGroup): # Lane group 2 has predecessor lane group 0 lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group2) - self.assertIsNotNone(lane_group2.predecessors) - self.assertEqual(len(lane_group2.predecessors), 1) - self.assertIsInstance(lane_group2.predecessors[0], LaneGroup) - self.assertEqual(lane_group2.predecessors[0].object_id, 0) - self.assertEqual(lane_group2.predecessor_ids, [0]) + assert lane_group2 is not None + assert lane_group2.predecessors is not None + assert len(lane_group2.predecessors) == 1 + assert isinstance(lane_group2.predecessors[0], LaneGroup) + assert lane_group2.predecessors[0].object_id == 0 + assert lane_group2.predecessor_ids == [0] def test_successor_links(self): """Test that the successor lane groups are correctly linked.""" @@ -535,27 +535,27 @@ def test_successor_links(self): ) def _no_successors(lane_group: LaneGroup): - self.assertIsNotNone(lane_group) - self.assertEqual(lane_group.successors, []) - self.assertEqual(lane_group.successor_ids, []) + assert lane_group is not None + assert lane_group.successors == [] + assert lane_group.successor_ids == [] # Lane group 0 has successor lane group 2 lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group0) - self.assertIsNotNone(lane_group0.successors) - self.assertEqual(len(lane_group0.successors), 1) - self.assertIsInstance(lane_group0.successors[0], LaneGroup) - self.assertEqual(lane_group0.successors[0].object_id, 2) - self.assertEqual(lane_group0.successor_ids, [2]) + assert lane_group0 is not None + assert lane_group0.successors is not None + assert len(lane_group0.successors) == 1 + assert isinstance(lane_group0.successors[0], LaneGroup) + assert lane_group0.successors[0].object_id == 2 + assert lane_group0.successor_ids == [2] # Lane group 1 has successor lane group 0 lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group1) - self.assertIsNotNone(lane_group1.successors) - self.assertEqual(len(lane_group1.successors), 1) - self.assertIsInstance(lane_group1.successors[0], LaneGroup) - self.assertEqual(lane_group1.successors[0].object_id, 0) - self.assertEqual(lane_group1.successor_ids, [0]) + assert lane_group1 is not None + assert lane_group1.successors is not None + assert len(lane_group1.successors) == 1 + assert isinstance(lane_group1.successors[0], LaneGroup) + assert lane_group1.successors[0].object_id == 0 + assert lane_group1.successor_ids == [0] # Lane group 2 has no successors lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) @@ -572,25 +572,25 @@ def test_intersection_links(self): # Lane group 0 has no intersection lane_group0: LaneGroup = map_api.get_map_object(0, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group0) - self.assertIsNone(lane_group0.intersection_id) - self.assertIsNone(lane_group0.intersection) + assert lane_group0 is not None + assert lane_group0.intersection_id is None + assert lane_group0.intersection is None # Lane group 1 has intersection 0 lane_group1: LaneGroup = map_api.get_map_object(1, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group1) - self.assertEqual(lane_group1.intersection_id, 0) - self.assertIsNotNone(lane_group1.intersection) - self.assertIsInstance(lane_group1.intersection, Intersection) - self.assertEqual(lane_group1.intersection.object_id, 0) + assert lane_group1 is not None + assert lane_group1.intersection_id == 0 + assert lane_group1.intersection is not None + assert isinstance(lane_group1.intersection, Intersection) + assert lane_group1.intersection.object_id == 0 # Lane group 2 has intersection 1 lane_group2: LaneGroup = map_api.get_map_object(2, MapLayer.LANE_GROUP) - self.assertIsNotNone(lane_group2) - self.assertEqual(lane_group2.intersection_id, 1) - self.assertIsNotNone(lane_group2.intersection) - self.assertIsInstance(lane_group2.intersection, Intersection) - self.assertEqual(lane_group2.intersection.object_id, 1) + assert lane_group2 is not None + assert lane_group2.intersection_id == 1 + assert lane_group2.intersection is not None + assert isinstance(lane_group2.intersection, Intersection) + assert lane_group2.intersection.object_id == 1 def test_no_links(self): """Test that when map_api is not provided, no links are available.""" @@ -602,16 +602,16 @@ def test_no_links(self): ) for lane_group in self.lane_groups: lg_from_api: LaneGroup = map_api.get_map_object(lane_group.object_id, MapLayer.LANE_GROUP) - self.assertIsNotNone(lg_from_api) - self.assertIsNone(lg_from_api.lanes) - self.assertIsNone(lg_from_api.predecessors) - self.assertIsNone(lg_from_api.successors) - self.assertIsNone(lg_from_api.intersection) + assert lg_from_api is not None + assert lg_from_api.lanes is None + assert lg_from_api.predecessors is None + assert lg_from_api.successors is None + assert lg_from_api.intersection is None -class TestIntersection(unittest.TestCase): +class TestIntersection: - def setUp(self): + def setup_method(self): lanes, lane_groups, intersections = _get_linked_map_object_setup() self.lanes = lanes self.lane_groups = lane_groups @@ -620,18 +620,18 @@ def setUp(self): def test_properties(self): """Test that the properties of the Intersection objects are correct.""" intersection0 = self.intersections[0] - self.assertEqual(intersection0.layer, MapLayer.INTERSECTION) - self.assertEqual(intersection0.lane_group_ids, [1]) - self.assertIsInstance(intersection0.outline, Polyline3D) + assert intersection0.layer == MapLayer.INTERSECTION + assert intersection0.lane_group_ids == [1] + assert isinstance(intersection0.outline, Polyline3D) def test_base_properties(self): """Test that the base surface properties of the Intersection objects are correct.""" intersection0 = self.intersections[0] - self.assertEqual(intersection0.object_id, 0) - self.assertIsInstance(intersection0.outline, Polyline3D) - self.assertIsInstance(intersection0.outline_2d, Polyline2D) - self.assertIsInstance(intersection0.outline_3d, Polyline3D) - self.assertIsInstance(intersection0.shapely_polygon, shapely.Polygon) + assert intersection0.object_id == 0 + assert isinstance(intersection0.outline, Polyline3D) + assert isinstance(intersection0.outline_2d, Polyline2D) + assert isinstance(intersection0.outline_3d, Polyline3D) + assert isinstance(intersection0.shapely_polygon, shapely.Polygon) def test_lane_group_links(self): """Test that the lane groups are correctly linked to the intersection.""" @@ -644,19 +644,19 @@ def test_lane_group_links(self): # Intersection 0 contains lane group 1 intersection0: Intersection = map_api.get_map_object(0, MapLayer.INTERSECTION) - self.assertIsNotNone(intersection0) - self.assertIsNotNone(intersection0.lane_groups) - self.assertEqual(len(intersection0.lane_groups), 1) - self.assertIsInstance(intersection0.lane_groups[0], LaneGroup) - self.assertEqual(intersection0.lane_groups[0].object_id, 1) + assert intersection0 is not None + assert intersection0.lane_groups is not None + assert len(intersection0.lane_groups) == 1 + assert isinstance(intersection0.lane_groups[0], LaneGroup) + assert intersection0.lane_groups[0].object_id == 1 # Intersection 1 contains lane group 2 intersection1: Intersection = map_api.get_map_object(1, MapLayer.INTERSECTION) - self.assertIsNotNone(intersection1) - self.assertIsNotNone(intersection1.lane_groups) - self.assertEqual(len(intersection1.lane_groups), 1) - self.assertIsInstance(intersection1.lane_groups[0], LaneGroup) - self.assertEqual(intersection1.lane_groups[0].object_id, 2) + assert intersection1 is not None + assert intersection1.lane_groups is not None + assert len(intersection1.lane_groups) == 1 + assert isinstance(intersection1.lane_groups[0], LaneGroup) + assert intersection1.lane_groups[0].object_id == 2 def test_no_links(self): """Test that when map_api is not provided, no links are available.""" @@ -668,36 +668,36 @@ def test_no_links(self): ) for intersection in self.intersections: int_from_api: Intersection = map_api.get_map_object(intersection.object_id, MapLayer.INTERSECTION) - self.assertIsNotNone(int_from_api) - self.assertIsNone(int_from_api.lane_groups) + assert int_from_api is not None + assert int_from_api.lane_groups is None -class TestCrosswalk(unittest.TestCase): +class TestCrosswalk: def test_properties(self): """Test that the properties of the Crosswalk object are correct.""" outline = Polyline3D.from_array( np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]) ) crosswalk = Crosswalk(object_id=0, outline=outline) - self.assertEqual(crosswalk.layer, MapLayer.CROSSWALK) - self.assertEqual(crosswalk.object_id, 0) - self.assertIsInstance(crosswalk.outline, Polyline3D) - self.assertIsInstance(crosswalk.shapely_polygon, shapely.Polygon) + assert crosswalk.layer == MapLayer.CROSSWALK + assert crosswalk.object_id == 0 + assert isinstance(crosswalk.outline, Polyline3D) + assert isinstance(crosswalk.shapely_polygon, shapely.Polygon) def test_init_with_shapely_polygon(self): """Test initialization with shapely polygon.""" shapely_polygon = shapely.Polygon([(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]) crosswalk = Crosswalk(object_id=0, shapely_polygon=shapely_polygon) - self.assertEqual(crosswalk.object_id, 0) - self.assertIsInstance(crosswalk.shapely_polygon, shapely.Polygon) - self.assertIsInstance(crosswalk.outline_2d, Polyline2D) + assert crosswalk.object_id == 0 + assert isinstance(crosswalk.shapely_polygon, shapely.Polygon) + assert isinstance(crosswalk.outline_2d, Polyline2D) def test_init_with_polyline2d(self): """Test initialization with Polyline2D outline.""" outline = Polyline2D.from_array(np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]])) crosswalk = Crosswalk(object_id=0, outline=outline) - self.assertIsInstance(crosswalk.outline_2d, Polyline2D) - self.assertIsInstance(crosswalk.shapely_polygon, shapely.Polygon) + assert isinstance(crosswalk.outline_2d, Polyline2D) + assert isinstance(crosswalk.shapely_polygon, shapely.Polygon) def test_base_surface_properties(self): """Test base surface object properties.""" @@ -705,36 +705,36 @@ def test_base_surface_properties(self): np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]) ) crosswalk = Crosswalk(object_id=0, outline=outline) - self.assertIsInstance(crosswalk.outline_3d, Polyline3D) - self.assertTrue(crosswalk.shapely_polygon.is_valid) + assert isinstance(crosswalk.outline_3d, Polyline3D) + assert crosswalk.shapely_polygon.is_valid -class TestCarpark(unittest.TestCase): +class TestCarpark: def test_properties(self): """Test that the properties of the Carpark object are correct.""" outline = Polyline3D.from_array( np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 2.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 0.0]]) ) carpark = Carpark(object_id=1, outline=outline) - self.assertEqual(carpark.layer, MapLayer.CARPARK) - self.assertEqual(carpark.object_id, 1) - self.assertIsInstance(carpark.outline, Polyline3D) - self.assertIsInstance(carpark.shapely_polygon, shapely.Polygon) + assert carpark.layer == MapLayer.CARPARK + assert carpark.object_id == 1 + assert isinstance(carpark.outline, Polyline3D) + assert isinstance(carpark.shapely_polygon, shapely.Polygon) def test_init_with_shapely_polygon(self): """Test initialization with shapely polygon.""" shapely_polygon = shapely.Polygon([(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]) carpark = Carpark(object_id=1, shapely_polygon=shapely_polygon) - self.assertEqual(carpark.object_id, 1) - self.assertIsInstance(carpark.shapely_polygon, shapely.Polygon) - self.assertIsInstance(carpark.outline_2d, Polyline2D) + assert carpark.object_id == 1 + assert isinstance(carpark.shapely_polygon, shapely.Polygon) + assert isinstance(carpark.outline_2d, Polyline2D) def test_init_with_polyline2d(self): """Test initialization with Polyline2D outline.""" outline = Polyline2D.from_array(np.array([[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]])) carpark = Carpark(object_id=1, outline=outline) - self.assertIsInstance(carpark.outline_2d, Polyline2D) - self.assertIsInstance(carpark.shapely_polygon, shapely.Polygon) + assert isinstance(carpark.outline_2d, Polyline2D) + assert isinstance(carpark.shapely_polygon, shapely.Polygon) def test_polygon_area(self): """Test that the polygon area is calculated correctly.""" @@ -742,25 +742,25 @@ def test_polygon_area(self): np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 2.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 0.0]]) ) carpark = Carpark(object_id=1, outline=outline) - self.assertAlmostEqual(carpark.shapely_polygon.area, 4.0) + assert carpark.shapely_polygon.area == pytest.approx(4.0) -class TestWalkway(unittest.TestCase): +class TestWalkway: def test_properties(self): """Test that the properties of the Walkway object are correct.""" outline = Polyline2D.from_array(np.array([[0.0, 0.0], [3.0, 0.0], [3.0, 1.0], [0.0, 1.0], [0.0, 0.0]])) walkway = Walkway(object_id=2, outline=outline) - self.assertEqual(walkway.layer, MapLayer.WALKWAY) - self.assertEqual(walkway.object_id, 2) - self.assertIsInstance(walkway.outline_2d, Polyline2D) - self.assertIsInstance(walkway.shapely_polygon, shapely.Polygon) + assert walkway.layer == MapLayer.WALKWAY + assert walkway.object_id == 2 + assert isinstance(walkway.outline_2d, Polyline2D) + assert isinstance(walkway.shapely_polygon, shapely.Polygon) def test_init_with_shapely_polygon(self): """Test initialization with shapely polygon.""" shapely_polygon = shapely.Polygon([(0.0, 0.0), (3.0, 0.0), (3.0, 1.0), (0.0, 1.0)]) walkway = Walkway(object_id=2, shapely_polygon=shapely_polygon) - self.assertEqual(walkway.object_id, 2) - self.assertIsInstance(walkway.shapely_polygon, shapely.Polygon) + assert walkway.object_id == 2 + assert isinstance(walkway.shapely_polygon, shapely.Polygon) def test_init_with_polyline3d(self): """Test initialization with Polyline3D outline.""" @@ -768,42 +768,42 @@ def test_init_with_polyline3d(self): np.array([[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [3.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]) ) walkway = Walkway(object_id=2, outline=outline) - self.assertIsInstance(walkway.outline_3d, Polyline3D) - self.assertIsInstance(walkway.shapely_polygon, shapely.Polygon) + assert isinstance(walkway.outline_3d, Polyline3D) + assert isinstance(walkway.shapely_polygon, shapely.Polygon) def test_polygon_bounds(self): """Test that polygon bounds are correct.""" outline = Polyline2D.from_array(np.array([[0.0, 0.0], [3.0, 0.0], [3.0, 1.0], [0.0, 1.0], [0.0, 0.0]])) walkway = Walkway(object_id=2, outline=outline) bounds = walkway.shapely_polygon.bounds - self.assertEqual(bounds, (0.0, 0.0, 3.0, 1.0)) + assert bounds == (0.0, 0.0, 3.0, 1.0) -class TestGenericDrivable(unittest.TestCase): +class TestGenericDrivable: def test_properties(self): """Test that the properties of the GenericDrivable object are correct.""" outline = Polyline3D.from_array( np.array([[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [5.0, 3.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 0.0]]) ) generic_drivable = GenericDrivable(object_id=3, outline=outline) - self.assertEqual(generic_drivable.layer, MapLayer.GENERIC_DRIVABLE) - self.assertEqual(generic_drivable.object_id, 3) - self.assertIsInstance(generic_drivable.outline, Polyline3D) - self.assertIsInstance(generic_drivable.shapely_polygon, shapely.Polygon) + assert generic_drivable.layer == MapLayer.GENERIC_DRIVABLE + assert generic_drivable.object_id == 3 + assert isinstance(generic_drivable.outline, Polyline3D) + assert isinstance(generic_drivable.shapely_polygon, shapely.Polygon) def test_init_with_shapely_polygon(self): """Test initialization with shapely polygon.""" shapely_polygon = shapely.Polygon([(0.0, 0.0), (5.0, 0.0), (5.0, 3.0), (0.0, 3.0)]) generic_drivable = GenericDrivable(object_id=3, shapely_polygon=shapely_polygon) - self.assertEqual(generic_drivable.object_id, 3) - self.assertIsInstance(generic_drivable.shapely_polygon, shapely.Polygon) + assert generic_drivable.object_id == 3 + assert isinstance(generic_drivable.shapely_polygon, shapely.Polygon) def test_init_with_polyline2d(self): """Test initialization with Polyline2D outline.""" outline = Polyline2D.from_array(np.array([[0.0, 0.0], [5.0, 0.0], [5.0, 3.0], [0.0, 3.0], [0.0, 0.0]])) generic_drivable = GenericDrivable(object_id=3, outline=outline) - self.assertIsInstance(generic_drivable.outline_2d, Polyline2D) - self.assertIsInstance(generic_drivable.shapely_polygon, shapely.Polygon) + assert isinstance(generic_drivable.outline_2d, Polyline2D) + assert isinstance(generic_drivable.shapely_polygon, shapely.Polygon) def test_polygon_area(self): """Test that the polygon area is calculated correctly.""" @@ -811,18 +811,18 @@ def test_polygon_area(self): np.array([[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [5.0, 3.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 0.0]]) ) generic_drivable = GenericDrivable(object_id=3, outline=outline) - self.assertAlmostEqual(generic_drivable.shapely_polygon.area, 15.0) + assert generic_drivable.shapely_polygon.area == pytest.approx(15.0) -class TestStopZone(unittest.TestCase): +class TestStopZone: def test_properties(self): """Test that the properties of the StopZone object are correct.""" shapely_polygon = shapely.Polygon([(0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)]) stop_zone = StopZone(object_id=4, shapely_polygon=shapely_polygon) - self.assertEqual(stop_zone.layer, MapLayer.STOP_ZONE) - self.assertEqual(stop_zone.object_id, 4) - self.assertIsInstance(stop_zone.shapely_polygon, shapely.Polygon) - self.assertIsInstance(stop_zone.outline_2d, Polyline2D) + assert stop_zone.layer == MapLayer.STOP_ZONE + assert stop_zone.object_id == 4 + assert isinstance(stop_zone.shapely_polygon, shapely.Polygon) + assert isinstance(stop_zone.outline_2d, Polyline2D) def test_init_with_polyline3d(self): """Test initialization with Polyline3D outline.""" @@ -830,80 +830,80 @@ def test_init_with_polyline3d(self): np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.5, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.0]]) ) stop_zone = StopZone(object_id=4, outline=outline) - self.assertIsInstance(stop_zone.outline, Polyline3D) - self.assertIsInstance(stop_zone.shapely_polygon, shapely.Polygon) + assert isinstance(stop_zone.outline, Polyline3D) + assert isinstance(stop_zone.shapely_polygon, shapely.Polygon) def test_init_with_polyline2d(self): """Test initialization with Polyline2D outline.""" outline = Polyline2D.from_array(np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5], [0.0, 0.0]])) stop_zone = StopZone(object_id=4, outline=outline) - self.assertIsInstance(stop_zone.outline_2d, Polyline2D) - self.assertIsInstance(stop_zone.shapely_polygon, shapely.Polygon) + assert isinstance(stop_zone.outline_2d, Polyline2D) + assert isinstance(stop_zone.shapely_polygon, shapely.Polygon) def test_polygon_area(self): """Test that the polygon area is calculated correctly.""" shapely_polygon = shapely.Polygon([(0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)]) stop_zone = StopZone(object_id=4, shapely_polygon=shapely_polygon) - self.assertAlmostEqual(stop_zone.shapely_polygon.area, 0.5) + assert stop_zone.shapely_polygon.area == pytest.approx(0.5) -class TestRoadEdge(unittest.TestCase): +class TestRoadEdge: def test_properties(self): """Test that the properties of the RoadEdge object are correct.""" polyline = Polyline3D.from_array(np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0]])) road_edge = RoadEdge(object_id=5, road_edge_type=1, polyline=polyline) - self.assertEqual(road_edge.layer, MapLayer.ROAD_EDGE) - self.assertEqual(road_edge.object_id, 5) - self.assertEqual(road_edge.road_edge_type, 1) - self.assertIsInstance(road_edge.polyline, Polyline3D) + assert road_edge.layer == MapLayer.ROAD_EDGE + assert road_edge.object_id == 5 + assert road_edge.road_edge_type == 1 + assert isinstance(road_edge.polyline, Polyline3D) def test_init_with_polyline2d(self): """Test initialization with Polyline2D.""" polyline = Polyline2D.from_array(np.array([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]])) road_edge = RoadEdge(object_id=5, road_edge_type=1, polyline=polyline) - self.assertIsInstance(road_edge.polyline, Polyline2D) - self.assertEqual(road_edge.road_edge_type, 1) + assert isinstance(road_edge.polyline, Polyline2D) + assert road_edge.road_edge_type == 1 def test_polyline_length(self): """Test that the polyline has correct number of points.""" polyline = Polyline3D.from_array(np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0]])) road_edge = RoadEdge(object_id=5, road_edge_type=1, polyline=polyline) - self.assertEqual(len(road_edge.polyline.array), 3) + assert len(road_edge.polyline.array) == 3 def test_different_road_edge_types(self): """Test different road edge types.""" polyline = Polyline3D.from_array(np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]])) for edge_type in RoadEdgeType: road_edge = RoadEdge(object_id=5, road_edge_type=edge_type, polyline=polyline) - self.assertEqual(road_edge.road_edge_type, edge_type) + assert road_edge.road_edge_type == edge_type -class TestRoadLine(unittest.TestCase): +class TestRoadLine: def test_properties(self): """Test that the properties of the RoadLine object are correct.""" polyline = Polyline2D.from_array(np.array([[0.0, 1.0], [10.0, 1.0], [20.0, 1.0]])) road_line = RoadLine(object_id=6, road_line_type=2, polyline=polyline) - self.assertEqual(road_line.layer, MapLayer.ROAD_LINE) - self.assertEqual(road_line.object_id, 6) - self.assertEqual(road_line.road_line_type, 2) - self.assertIsInstance(road_line.polyline, Polyline2D) + assert road_line.layer == MapLayer.ROAD_LINE + assert road_line.object_id == 6 + assert road_line.road_line_type == 2 + assert isinstance(road_line.polyline, Polyline2D) def test_init_with_polyline3d(self): """Test initialization with Polyline3D.""" polyline = Polyline3D.from_array(np.array([[0.0, 1.0, 0.0], [10.0, 1.0, 0.0], [20.0, 1.0, 0.0]])) road_line = RoadLine(object_id=6, road_line_type=2, polyline=polyline) - self.assertIsInstance(road_line.polyline, Polyline3D) - self.assertEqual(road_line.road_line_type, 2) + assert isinstance(road_line.polyline, Polyline3D) + assert road_line.road_line_type == 2 def test_polyline_length(self): """Test that the polyline has correct number of points.""" polyline = Polyline2D.from_array(np.array([[0.0, 1.0], [10.0, 1.0], [20.0, 1.0], [30.0, 1.0]])) road_line = RoadLine(object_id=6, road_line_type=2, polyline=polyline) - self.assertEqual(len(road_line.polyline.array), 4) + assert len(road_line.polyline.array) == 4 def test_different_road_line_types(self): """Test different road line types.""" polyline = Polyline2D.from_array(np.array([[0.0, 1.0], [10.0, 1.0]])) for line_type in RoadLineType: road_line = RoadLine(object_id=6, road_line_type=line_type, polyline=polyline) - self.assertEqual(road_line.road_line_type, line_type) + assert road_line.road_line_type == line_type diff --git a/tests/unit/datatypes/metadata/test_log_metadata.py b/tests/unit/datatypes/metadata/test_log_metadata.py index df6e1589..7afb0bac 100644 --- a/tests/unit/datatypes/metadata/test_log_metadata.py +++ b/tests/unit/datatypes/metadata/test_log_metadata.py @@ -1,29 +1,30 @@ -import unittest from unittest.mock import MagicMock, patch +import pytest + from py123d.datatypes.metadata.log_metadata import LogMetadata from py123d.datatypes.metadata.map_metadata import MapMetadata from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters -class TestLogMetadata(unittest.TestCase): +class TestLogMetadata: def test_init_minimal(self): """Test LogMetadata initialization with minimal required fields.""" log_metadata = LogMetadata( dataset="test_dataset", split="train", log_name="log_001", location="test_location", timestep_seconds=0.1 ) - self.assertEqual(log_metadata.dataset, "test_dataset") - self.assertEqual(log_metadata.split, "train") - self.assertEqual(log_metadata.log_name, "log_001") - self.assertEqual(log_metadata.location, "test_location") - self.assertEqual(log_metadata.timestep_seconds, 0.1) - self.assertIsNone(log_metadata.vehicle_parameters) - self.assertIsNone(log_metadata.box_detection_label_class) - self.assertEqual(log_metadata.pinhole_camera_metadata, {}) - self.assertEqual(log_metadata.fisheye_mei_camera_metadata, {}) - self.assertEqual(log_metadata.lidar_metadata, {}) - self.assertIsNone(log_metadata.map_metadata) + assert log_metadata.dataset == "test_dataset" + assert log_metadata.split == "train" + assert log_metadata.log_name == "log_001" + assert log_metadata.location == "test_location" + assert log_metadata.timestep_seconds == 0.1 + assert log_metadata.vehicle_parameters is None + assert log_metadata.box_detection_label_class is None + assert log_metadata.pinhole_camera_metadata == {} + assert log_metadata.fisheye_mei_camera_metadata == {} + assert log_metadata.lidar_metadata == {} + assert log_metadata.map_metadata is None def test_to_dict_minimal(self): """Test to_dict with minimal fields.""" @@ -31,14 +32,14 @@ def test_to_dict_minimal(self): dataset="test_dataset", split="train", log_name="log_001", location="test_location", timestep_seconds=0.1 ) result = log_metadata.to_dict() - self.assertEqual(result["dataset"], "test_dataset") - self.assertEqual(result["split"], "train") - self.assertEqual(result["log_name"], "log_001") - self.assertEqual(result["location"], "test_location") - self.assertEqual(result["timestep_seconds"], 0.1) - self.assertIsNone(result["vehicle_parameters"]) - self.assertIsNone(result["box_detection_label_class"]) - self.assertEqual(result["pinhole_camera_metadata"], {}) + assert result["dataset"] == "test_dataset" + assert result["split"] == "train" + assert result["log_name"] == "log_001" + assert result["location"] == "test_location" + assert result["timestep_seconds"] == 0.1 + assert result["vehicle_parameters"] is None + assert result["box_detection_label_class"] is None + assert result["pinhole_camera_metadata"] == {} def test_from_dict_minimal(self): """Test from_dict with minimal fields.""" @@ -54,9 +55,9 @@ def test_from_dict_minimal(self): "version": "1.0.0", } log_metadata = LogMetadata.from_dict(data_dict) - self.assertEqual(log_metadata.dataset, "test_dataset") - self.assertEqual(log_metadata.split, "train") - self.assertIsNone(log_metadata.vehicle_parameters) + assert log_metadata.dataset == "test_dataset" + assert log_metadata.split == "train" + assert log_metadata.vehicle_parameters is None @patch.object(VehicleParameters, "from_dict") def test_from_dict_with_vehicle_parameters(self, mock_vehicle_params): @@ -77,7 +78,7 @@ def test_from_dict_with_vehicle_parameters(self, mock_vehicle_params): } log_metadata = LogMetadata.from_dict(data_dict) mock_vehicle_params.assert_called_once_with({"some": "data"}) - self.assertEqual(log_metadata.vehicle_parameters, mock_vehicle) + assert log_metadata.vehicle_parameters == mock_vehicle @patch("py123d.datatypes.metadata.log_metadata.BOX_DETECTION_LABEL_REGISTRY", {"TestLabel": MagicMock}) def test_from_dict_with_box_detection_label(self): @@ -94,7 +95,7 @@ def test_from_dict_with_box_detection_label(self): "version": "1.0.0", } log_metadata = LogMetadata.from_dict(data_dict) - self.assertIsNotNone(log_metadata.box_detection_label_class) + assert log_metadata.box_detection_label_class is not None def test_from_dict_with_invalid_box_detection_label(self): """Test from_dict with invalid box detection label class.""" @@ -109,9 +110,8 @@ def test_from_dict_with_invalid_box_detection_label(self): "map_metadata": None, "version": "1.0.0", } - with self.assertRaises(ValueError) as context: + with pytest.raises(ValueError): LogMetadata.from_dict(data_dict) - self.assertIn("Unknown box detection label class", str(context.exception)) @patch.object(MapMetadata, "from_dict") def test_from_dict_with_map_metadata(self, mock_map_metadata): @@ -132,7 +132,7 @@ def test_from_dict_with_map_metadata(self, mock_map_metadata): } log_metadata = LogMetadata.from_dict(data_dict) mock_map_metadata.assert_called_once_with({"some": "data"}) - self.assertEqual(log_metadata.map_metadata, mock_map) + assert log_metadata.map_metadata == mock_map def test_roundtrip_serialization(self): """Test that to_dict and from_dict are inverses.""" @@ -146,8 +146,8 @@ def test_roundtrip_serialization(self): data_dict = original.to_dict() reconstructed = LogMetadata.from_dict(data_dict) - self.assertEqual(original.dataset, reconstructed.dataset) - self.assertEqual(original.split, reconstructed.split) - self.assertEqual(original.log_name, reconstructed.log_name) - self.assertEqual(original.location, reconstructed.location) - self.assertEqual(original.timestep_seconds, reconstructed.timestep_seconds) + assert original.dataset == reconstructed.dataset + assert original.split == reconstructed.split + assert original.log_name == reconstructed.log_name + assert original.location == reconstructed.location + assert original.timestep_seconds == reconstructed.timestep_seconds diff --git a/tests/unit/datatypes/metadata/test_map_metadata.py b/tests/unit/datatypes/metadata/test_map_metadata.py index 4e0b71d2..a5ad7279 100644 --- a/tests/unit/datatypes/metadata/test_map_metadata.py +++ b/tests/unit/datatypes/metadata/test_map_metadata.py @@ -1,9 +1,7 @@ -import unittest - from py123d.datatypes.metadata.map_metadata import MapMetadata -class TestMapMetadata(unittest.TestCase): +class TestMapMetadata: def test_map_metadata_initialization(self): """Test that MapMetadata can be initialized with required fields.""" diff --git a/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py b/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py index 829270e8..7cf5d635 100644 --- a/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py +++ b/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py @@ -1,6 +1,5 @@ -import unittest - import numpy as np +import pytest from py123d.datatypes.sensors.fisheye_mei_camera import ( FisheyeMEICamera, @@ -14,130 +13,130 @@ from py123d.geometry import PoseSE3 -class TestFisheyeMEICameraType(unittest.TestCase): +class TestFisheyeMEICameraType: def test_camera_type_values(self): """Test that camera type enum has expected values.""" - self.assertEqual(FisheyeMEICameraType.FCAM_L.value, 0) - self.assertEqual(FisheyeMEICameraType.FCAM_R.value, 1) + assert FisheyeMEICameraType.FCAM_L.value == 0 + assert FisheyeMEICameraType.FCAM_R.value == 1 def test_camera_type_from_int(self): """Test creating camera type from integer values.""" - self.assertEqual(FisheyeMEICameraType(0), FisheyeMEICameraType.FCAM_L) - self.assertEqual(FisheyeMEICameraType(1), FisheyeMEICameraType.FCAM_R) + assert FisheyeMEICameraType(0) == FisheyeMEICameraType.FCAM_L + assert FisheyeMEICameraType(1) == FisheyeMEICameraType.FCAM_R def test_camera_type_members(self): """Test that all expected members exist.""" members = list(FisheyeMEICameraType) - self.assertEqual(len(members), 2) - self.assertIn(FisheyeMEICameraType.FCAM_L, members) - self.assertIn(FisheyeMEICameraType.FCAM_R, members) + assert len(members) == 2 + assert FisheyeMEICameraType.FCAM_L in members + assert FisheyeMEICameraType.FCAM_R in members def test_camera_type_comparison(self): """Test comparison between camera types.""" - self.assertNotEqual(FisheyeMEICameraType.FCAM_L, FisheyeMEICameraType.FCAM_R) - self.assertEqual(FisheyeMEICameraType.FCAM_L, FisheyeMEICameraType.FCAM_L) + assert FisheyeMEICameraType.FCAM_L != FisheyeMEICameraType.FCAM_R + assert FisheyeMEICameraType.FCAM_L == FisheyeMEICameraType.FCAM_L -class TestFisheyeMEIDistortion(unittest.TestCase): +class TestFisheyeMEIDistortion: def test_distortion_initialization(self): """Test distortion parameter initialization.""" distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) - self.assertEqual(distortion.k1, 0.1) - self.assertEqual(distortion.k2, 0.2) - self.assertEqual(distortion.p1, 0.3) - self.assertEqual(distortion.p2, 0.4) + assert distortion.k1 == 0.1 + assert distortion.k2 == 0.2 + assert distortion.p1 == 0.3 + assert distortion.p2 == 0.4 def test_distortion_from_array(self): """Test creating distortion from array.""" array = np.array([0.1, 0.2, 0.3, 0.4]) distortion = FisheyeMEIDistortion.from_array(array) - self.assertEqual(distortion.k1, 0.1) - self.assertEqual(distortion.k2, 0.2) - self.assertEqual(distortion.p1, 0.3) - self.assertEqual(distortion.p2, 0.4) + assert distortion.k1 == 0.1 + assert distortion.k2 == 0.2 + assert distortion.p1 == 0.3 + assert distortion.p2 == 0.4 def test_distortion_from_array_copy(self): """Test that from_array copies data by default.""" array = np.array([0.1, 0.2, 0.3, 0.4]) distortion = FisheyeMEIDistortion.from_array(array, copy=True) array[0] = 999.0 - self.assertEqual(distortion.k1, 0.1) + assert distortion.k1 == 0.1 def test_distortion_from_array_no_copy(self): """Test that from_array can avoid copying.""" array = np.array([0.1, 0.2, 0.3, 0.4]) distortion = FisheyeMEIDistortion.from_array(array, copy=False) array[0] = 999.0 - self.assertEqual(distortion.k1, 999.0) + assert distortion.k1 == 999.0 def test_distortion_array_property(self): """Test array property returns correct values.""" distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) array = distortion.array - self.assertEqual(len(array), 4) + assert len(array) == 4 np.testing.assert_array_equal(array, [0.1, 0.2, 0.3, 0.4]) def test_distortion_index_mapping(self): """Test that distortion indices map correctly.""" distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) - self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.K1], 0.1) - self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.K2], 0.2) - self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.P1], 0.3) - self.assertEqual(distortion.array[FisheyeMEIDistortionIndex.P2], 0.4) + assert distortion.array[FisheyeMEIDistortionIndex.K1] == 0.1 + assert distortion.array[FisheyeMEIDistortionIndex.K2] == 0.2 + assert distortion.array[FisheyeMEIDistortionIndex.P1] == 0.3 + assert distortion.array[FisheyeMEIDistortionIndex.P2] == 0.4 -class TestFisheyeMEIProjection(unittest.TestCase): +class TestFisheyeMEIProjection: def test_projection_initialization(self): """Test projection parameter initialization.""" projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) - self.assertEqual(projection.gamma1, 1.0) - self.assertEqual(projection.gamma2, 2.0) - self.assertEqual(projection.u0, 3.0) - self.assertEqual(projection.v0, 4.0) + assert projection.gamma1 == 1.0 + assert projection.gamma2 == 2.0 + assert projection.u0 == 3.0 + assert projection.v0 == 4.0 def test_projection_from_array(self): """Test creating projection from array.""" array = np.array([1.0, 2.0, 3.0, 4.0]) projection = FisheyeMEIProjection.from_array(array) - self.assertEqual(projection.gamma1, 1.0) - self.assertEqual(projection.gamma2, 2.0) - self.assertEqual(projection.u0, 3.0) - self.assertEqual(projection.v0, 4.0) + assert projection.gamma1 == 1.0 + assert projection.gamma2 == 2.0 + assert projection.u0 == 3.0 + assert projection.v0 == 4.0 def test_projection_from_array_copy(self): """Test that from_array copies data by default.""" array = np.array([1.0, 2.0, 3.0, 4.0]) projection = FisheyeMEIProjection.from_array(array, copy=True) array[0] = 999.0 - self.assertEqual(projection.gamma1, 1.0) + assert projection.gamma1 == 1.0 def test_projection_from_array_no_copy(self): """Test that from_array can avoid copying.""" array = np.array([1.0, 2.0, 3.0, 4.0]) projection = FisheyeMEIProjection.from_array(array, copy=False) array[0] = 999.0 - self.assertEqual(projection.gamma1, 999.0) + assert projection.gamma1 == 999.0 def test_projection_array_property(self): """Test array property returns correct values.""" projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) array = projection.array - self.assertEqual(len(array), 4) + assert len(array) == 4 np.testing.assert_array_equal(array, [1.0, 2.0, 3.0, 4.0]) def test_projection_index_mapping(self): """Test that projection indices map correctly.""" projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) - self.assertEqual(projection.array[FisheyeMEIProjectionIndex.GAMMA1], 1.0) - self.assertEqual(projection.array[FisheyeMEIProjectionIndex.GAMMA2], 2.0) - self.assertEqual(projection.array[FisheyeMEIProjectionIndex.U0], 3.0) - self.assertEqual(projection.array[FisheyeMEIProjectionIndex.V0], 4.0) + assert projection.array[FisheyeMEIProjectionIndex.GAMMA1] == 1.0 + assert projection.array[FisheyeMEIProjectionIndex.GAMMA2] == 2.0 + assert projection.array[FisheyeMEIProjectionIndex.U0] == 3.0 + assert projection.array[FisheyeMEIProjectionIndex.V0] == 4.0 -class TestFisheyeMEICameraMetadata(unittest.TestCase): +class TestFisheyeMEICameraMetadata: def test_metadata_initialization(self): """Test metadata initialization with all parameters.""" @@ -151,11 +150,11 @@ def test_metadata_initialization(self): width=1920, height=1080, ) - self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_L) - self.assertEqual(metadata.mirror_parameter, 0.5) - self.assertEqual(metadata.distortion, distortion) - self.assertEqual(metadata.projection, projection) - self.assertEqual(metadata.aspect_ratio, 1920 / 1080) + assert metadata.camera_type == FisheyeMEICameraType.FCAM_L + assert metadata.mirror_parameter == 0.5 + assert metadata.distortion == distortion + assert metadata.projection == projection + assert metadata.aspect_ratio == 1920 / 1080 def test_metadata_initialization_with_none(self): """Test metadata initialization with None distortion and projection.""" @@ -167,11 +166,11 @@ def test_metadata_initialization_with_none(self): width=640, height=480, ) - self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_R) - self.assertIsNone(metadata.mirror_parameter) - self.assertIsNone(metadata.distortion) - self.assertIsNone(metadata.projection) - self.assertEqual(metadata.aspect_ratio, 640 / 480) + assert metadata.camera_type == FisheyeMEICameraType.FCAM_R + assert metadata.mirror_parameter is None + assert metadata.distortion is None + assert metadata.projection is None + assert metadata.aspect_ratio == 640 / 480 def test_metadata_to_dict(self): """Test converting metadata to dictionary.""" @@ -186,12 +185,12 @@ def test_metadata_to_dict(self): height=1080, ) result = metadata.to_dict() - self.assertEqual(result["camera_type"], 0) - self.assertEqual(result["mirror_parameter"], 0.5) - self.assertEqual(result["distortion"], [0.1, 0.2, 0.3, 0.4]) - self.assertEqual(result["projection"], [1.0, 2.0, 3.0, 4.0]) - self.assertEqual(result["width"], 1920) - self.assertEqual(result["height"], 1080) + assert result["camera_type"] == 0 + assert result["mirror_parameter"] == 0.5 + assert result["distortion"] == [0.1, 0.2, 0.3, 0.4] + assert result["projection"] == [1.0, 2.0, 3.0, 4.0] + assert result["width"] == 1920 + assert result["height"] == 1080 def test_metadata_to_dict_with_none(self): """Test converting metadata with None values to dictionary.""" @@ -204,12 +203,12 @@ def test_metadata_to_dict_with_none(self): height=480, ) result = metadata.to_dict() - self.assertEqual(result["camera_type"], 1) - self.assertIsNone(result["mirror_parameter"]) - self.assertIsNone(result["distortion"]) - self.assertIsNone(result["projection"]) - self.assertEqual(result["width"], 640) - self.assertEqual(result["height"], 480) + assert result["camera_type"] == 1 + assert result["mirror_parameter"] is None + assert result["distortion"] is None + assert result["projection"] is None + assert result["width"] == 640 + assert result["height"] == 480 def test_metadata_from_dict(self): """Test creating metadata from dictionary.""" @@ -222,11 +221,11 @@ def test_metadata_from_dict(self): "height": 1080, } metadata = FisheyeMEICameraMetadata.from_dict(data) - self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_L) - self.assertEqual(metadata.mirror_parameter, 0.5) - self.assertEqual(metadata.distortion.k1, 0.1) - self.assertEqual(metadata.projection.gamma1, 1.0) - self.assertEqual(metadata.aspect_ratio, 1920 / 1080) + assert metadata.camera_type == FisheyeMEICameraType.FCAM_L + assert metadata.mirror_parameter == 0.5 + assert metadata.distortion.k1 == 0.1 + assert metadata.projection.gamma1 == 1.0 + assert metadata.aspect_ratio == 1920 / 1080 def test_metadata_from_dict_with_none(self): """Test creating metadata from dictionary with None values.""" @@ -239,10 +238,10 @@ def test_metadata_from_dict_with_none(self): "height": 480, } metadata = FisheyeMEICameraMetadata.from_dict(data) - self.assertEqual(metadata.camera_type, FisheyeMEICameraType.FCAM_R) - self.assertIsNone(metadata.mirror_parameter) - self.assertIsNone(metadata.distortion) - self.assertIsNone(metadata.projection) + assert metadata.camera_type == FisheyeMEICameraType.FCAM_R + assert metadata.mirror_parameter is None + assert metadata.distortion is None + assert metadata.projection is None def test_metadata_roundtrip(self): """Test that to_dict and from_dict are inverses.""" @@ -258,11 +257,11 @@ def test_metadata_roundtrip(self): ) data_dict = metadata.to_dict() metadata_restored = FisheyeMEICameraMetadata.from_dict(data_dict) - self.assertEqual(metadata.camera_type, metadata_restored.camera_type) - self.assertEqual(metadata.mirror_parameter, metadata_restored.mirror_parameter) + assert metadata.camera_type == metadata_restored.camera_type + assert metadata.mirror_parameter == metadata_restored.mirror_parameter np.testing.assert_array_equal(metadata.distortion.array, metadata_restored.distortion.array) np.testing.assert_array_equal(metadata.projection.array, metadata_restored.projection.array) - self.assertEqual(metadata.aspect_ratio, metadata_restored.aspect_ratio) + assert metadata.aspect_ratio == metadata_restored.aspect_ratio def test_aspect_ratio_calculation(self): """Test aspect ratio calculation.""" @@ -274,10 +273,10 @@ def test_aspect_ratio_calculation(self): width=1920, height=1080, ) - self.assertAlmostEqual(metadata.aspect_ratio, 16 / 9, places=5) + assert metadata.aspect_ratio == pytest.approx(16 / 9, abs=1e-05) -class TestFisheyeMEICamera(unittest.TestCase): +class TestFisheyeMEICamera: def test_camera_initialization(self): """Test FisheyeMEICamera initialization.""" @@ -295,9 +294,9 @@ def test_camera_initialization(self): camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertEqual(camera.metadata, metadata) + assert camera.metadata == metadata np.testing.assert_array_equal(camera.image, image) - self.assertEqual(camera.extrinsic, extrinsic) + assert camera.extrinsic == extrinsic def test_camera_metadata_property(self): """Test that metadata property returns correct metadata.""" @@ -315,8 +314,8 @@ def test_camera_metadata_property(self): camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertIs(camera.metadata, metadata) - self.assertEqual(camera.metadata.camera_type, FisheyeMEICameraType.FCAM_R) + assert camera.metadata is metadata + assert camera.metadata.camera_type == FisheyeMEICameraType.FCAM_R def test_camera_image_property(self): """Test that image property returns correct image.""" @@ -335,7 +334,7 @@ def test_camera_image_property(self): camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) np.testing.assert_array_equal(camera.image, image) - self.assertEqual(camera.image.dtype, np.uint8) + assert camera.image.dtype == np.uint8 def test_camera_extrinsic_property(self): """Test that extrinsic property returns correct pose.""" @@ -353,7 +352,7 @@ def test_camera_extrinsic_property(self): camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertIs(camera.extrinsic, extrinsic) + assert camera.extrinsic is extrinsic def test_camera_with_color_image(self): """Test camera with color (3-channel) image.""" @@ -371,4 +370,4 @@ def test_camera_with_color_image(self): camera = FisheyeMEICamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertEqual(camera.image.shape, (480, 640, 3)) + assert camera.image.shape == (480, 640, 3) diff --git a/tests/unit/datatypes/sensors/test_lidar.py b/tests/unit/datatypes/sensors/test_lidar.py index 4163dbd5..211b9e42 100644 --- a/tests/unit/datatypes/sensors/test_lidar.py +++ b/tests/unit/datatypes/sensors/test_lidar.py @@ -1,13 +1,12 @@ -import unittest - import numpy as np +import pytest from py123d.conversion.registry.lidar_index_registry import LIDAR_INDEX_REGISTRY from py123d.datatypes.sensors.lidar import LiDAR, LiDARMetadata, LiDARType from py123d.geometry import PoseSE3 -class TestLiDARType(unittest.TestCase): +class TestLiDARType: def test_lidar_type_enum_values(self): """Test that LiDARType enum has correct values.""" assert LiDARType.LIDAR_UNKNOWN.value == 0 @@ -51,8 +50,8 @@ def test_lidar_type_count(self): assert len(LiDARType) == 8 -class TestLiDARMetadata(unittest.TestCase): - def setUp(self): +class TestLiDARMetadata: + def setup_method(self): """Set up test fixtures.""" # Get a lidar index class from registry (assuming at least one exists) @@ -147,13 +146,12 @@ def test_lidar_metadata_roundtrip_without_extrinsic(self): def test_lidar_metadata_from_dict_unknown_index_raises_error(self): """Test that unknown lidar index raises ValueError.""" data_dict = {"lidar_type": self.lidar_type.name, "lidar_index": "UnknownLiDARIndex", "extrinsic": None} - with self.assertRaises(ValueError) as context: + with pytest.raises(ValueError): LiDARMetadata.from_dict(data_dict) - assert "Unknown lidar index" in str(context.exception) -class TestLiDAR(unittest.TestCase): - def setUp(self): +class TestLiDAR: + def setup_method(self): """Set up test fixtures.""" # Get a lidar index class from registry diff --git a/tests/unit/datatypes/sensors/test_pinhole_camera.py b/tests/unit/datatypes/sensors/test_pinhole_camera.py index 3571c051..e0811a9e 100644 --- a/tests/unit/datatypes/sensors/test_pinhole_camera.py +++ b/tests/unit/datatypes/sensors/test_pinhole_camera.py @@ -1,6 +1,5 @@ -import unittest - import numpy as np +import pytest from py123d.datatypes.sensors.pinhole_camera import ( PinholeCamera, @@ -13,66 +12,66 @@ from py123d.geometry import PoseSE3 -class TestPinholeCameraType(unittest.TestCase): +class TestPinholeCameraType: def test_camera_type_values(self): """Test that camera type enum has expected values.""" - self.assertEqual(PinholeCameraType.PCAM_F0, PinholeCameraType.PCAM_F0) - self.assertEqual(PinholeCameraType.PCAM_B0, PinholeCameraType.PCAM_B0) - self.assertEqual(PinholeCameraType.PCAM_L0, PinholeCameraType.PCAM_L0) - self.assertEqual(PinholeCameraType.PCAM_L1, PinholeCameraType.PCAM_L1) - self.assertEqual(PinholeCameraType.PCAM_L2, PinholeCameraType.PCAM_L2) - self.assertEqual(PinholeCameraType.PCAM_R0, PinholeCameraType.PCAM_R0) - self.assertEqual(PinholeCameraType.PCAM_R1, PinholeCameraType.PCAM_R1) - self.assertEqual(PinholeCameraType.PCAM_R2, PinholeCameraType.PCAM_R2) - self.assertEqual(PinholeCameraType.PCAM_STEREO_L, PinholeCameraType.PCAM_STEREO_L) - self.assertEqual(PinholeCameraType.PCAM_STEREO_R, PinholeCameraType.PCAM_STEREO_R) + assert PinholeCameraType.PCAM_F0 == PinholeCameraType.PCAM_F0 + assert PinholeCameraType.PCAM_B0 == PinholeCameraType.PCAM_B0 + assert PinholeCameraType.PCAM_L0 == PinholeCameraType.PCAM_L0 + assert PinholeCameraType.PCAM_L1 == PinholeCameraType.PCAM_L1 + assert PinholeCameraType.PCAM_L2 == PinholeCameraType.PCAM_L2 + assert PinholeCameraType.PCAM_R0 == PinholeCameraType.PCAM_R0 + assert PinholeCameraType.PCAM_R1 == PinholeCameraType.PCAM_R1 + assert PinholeCameraType.PCAM_R2 == PinholeCameraType.PCAM_R2 + assert PinholeCameraType.PCAM_STEREO_L == PinholeCameraType.PCAM_STEREO_L + assert PinholeCameraType.PCAM_STEREO_R == PinholeCameraType.PCAM_STEREO_R def test_camera_type_from_int(self): """Test creating camera type from integer.""" - self.assertEqual(PinholeCameraType(0), PinholeCameraType.PCAM_F0) - self.assertEqual(PinholeCameraType(5), PinholeCameraType.PCAM_R0) - self.assertEqual(PinholeCameraType(9), PinholeCameraType.PCAM_STEREO_R) + assert PinholeCameraType(0) == PinholeCameraType.PCAM_F0 + assert PinholeCameraType(5) == PinholeCameraType.PCAM_R0 + assert PinholeCameraType(9) == PinholeCameraType.PCAM_STEREO_R def test_camera_type_count(self): """Test that all camera types are defined.""" camera_types = list(PinholeCameraType) - self.assertEqual(len(camera_types), 10) + assert len(camera_types) == 10 def test_camera_type_unique_values(self): """Test that all camera type values are unique.""" values = [ct.value for ct in PinholeCameraType] - self.assertEqual(len(values), len(set(values))) + assert len(values) == len(set(values)) -class TestPinholeIntrinsics(unittest.TestCase): +class TestPinholeIntrinsics: def test_intrinsics_creation(self): """Test creating PinholeIntrinsics instance.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0, skew=0.0) - self.assertEqual(intrinsics.fx, 500.0) - self.assertEqual(intrinsics.fy, 500.0) - self.assertEqual(intrinsics.cx, 320.0) - self.assertEqual(intrinsics.cy, 240.0) - self.assertEqual(intrinsics.skew, 0.0) + assert intrinsics.fx == 500.0 + assert intrinsics.fy == 500.0 + assert intrinsics.cx == 320.0 + assert intrinsics.cy == 240.0 + assert intrinsics.skew == 0.0 def test_intrinsics_default_skew(self): """Test that skew defaults to 0.0 when not provided.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0) - self.assertEqual(intrinsics.skew, 0.0) + assert intrinsics.skew == 0.0 def test_intrinsics_from_array(self): """Test creating intrinsics from array.""" array = np.array([500.0, 500.0, 320.0, 240.0, 0.0], dtype=np.float64) intrinsics = PinholeIntrinsics.from_array(array) - self.assertEqual(intrinsics.fx, 500.0) - self.assertEqual(intrinsics.fy, 500.0) - self.assertEqual(intrinsics.cx, 320.0) - self.assertEqual(intrinsics.cy, 240.0) - self.assertEqual(intrinsics.skew, 0.0) + assert intrinsics.fx == 500.0 + assert intrinsics.fy == 500.0 + assert intrinsics.cx == 320.0 + assert intrinsics.cy == 240.0 + assert intrinsics.skew == 0.0 def test_intrinsics_from_array_copy(self): """Test that from_array creates a copy by default.""" @@ -83,7 +82,7 @@ def test_intrinsics_from_array_copy(self): array[0] = 1000.0 # Intrinsics should still have original value - self.assertEqual(intrinsics.fx, 500.0) + assert intrinsics.fx == 500.0 def test_intrinsics_from_array_no_copy(self): """Test that from_array can avoid copying.""" @@ -94,7 +93,7 @@ def test_intrinsics_from_array_no_copy(self): array[0] = 1000.0 # Intrinsics should reflect the change - self.assertEqual(intrinsics.fx, 1000.0) + assert intrinsics.fx == 1000.0 def test_intrinsics_from_camera_matrix(self): """Test creating intrinsics from 3x3 camera matrix.""" @@ -102,11 +101,11 @@ def test_intrinsics_from_camera_matrix(self): intrinsics = PinholeIntrinsics.from_camera_matrix(K) - self.assertEqual(intrinsics.fx, 500.0) - self.assertEqual(intrinsics.fy, 500.0) - self.assertEqual(intrinsics.cx, 320.0) - self.assertEqual(intrinsics.cy, 240.0) - self.assertEqual(intrinsics.skew, 0.5) + assert intrinsics.fx == 500.0 + assert intrinsics.fy == 500.0 + assert intrinsics.cx == 320.0 + assert intrinsics.cy == 240.0 + assert intrinsics.skew == 0.5 def test_intrinsics_camera_matrix_property(self): """Test getting camera matrix from intrinsics.""" @@ -131,8 +130,8 @@ def test_intrinsics_array_property(self): intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0, skew=0.5) array = intrinsics.array - self.assertIsInstance(array, np.ndarray) - self.assertEqual(array.shape, (5,)) + assert isinstance(array, np.ndarray) + assert array.shape == (5,) np.testing.assert_array_almost_equal(array, [500.0, 500.0, 320.0, 240.0, 0.5]) def test_intrinsics_from_list(self): @@ -140,63 +139,63 @@ def test_intrinsics_from_list(self): intrinsics_list = [500.0, 500.0, 320.0, 240.0, 0.0] intrinsics = PinholeIntrinsics.from_list(intrinsics_list) - self.assertEqual(intrinsics.fx, 500.0) - self.assertEqual(intrinsics.fy, 500.0) - self.assertEqual(intrinsics.cx, 320.0) - self.assertEqual(intrinsics.cy, 240.0) - self.assertEqual(intrinsics.skew, 0.0) + assert intrinsics.fx == 500.0 + assert intrinsics.fy == 500.0 + assert intrinsics.cx == 320.0 + assert intrinsics.cy == 240.0 + assert intrinsics.skew == 0.0 def test_intrinsics_tolist(self): """Test converting intrinsics to list.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0, skew=0.5) intrinsics_list = intrinsics.tolist() - self.assertIsInstance(intrinsics_list, list) - self.assertEqual(len(intrinsics_list), 5) - self.assertAlmostEqual(intrinsics_list[0], 500.0) - self.assertAlmostEqual(intrinsics_list[1], 500.0) - self.assertAlmostEqual(intrinsics_list[2], 320.0) - self.assertAlmostEqual(intrinsics_list[3], 240.0) - self.assertAlmostEqual(intrinsics_list[4], 0.5) + assert isinstance(intrinsics_list, list) + assert len(intrinsics_list) == 5 + assert intrinsics_list[0] == pytest.approx(500.0) + assert intrinsics_list[1] == pytest.approx(500.0) + assert intrinsics_list[2] == pytest.approx(320.0) + assert intrinsics_list[3] == pytest.approx(240.0) + assert intrinsics_list[4] == pytest.approx(0.5) def test_intrinsics_different_fx_fy(self): """Test intrinsics with different focal lengths.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=600.0, cx=320.0, cy=240.0) - self.assertEqual(intrinsics.fx, 500.0) - self.assertEqual(intrinsics.fy, 600.0) - self.assertNotEqual(intrinsics.fx, intrinsics.fy) + assert intrinsics.fx == 500.0 + assert intrinsics.fy == 600.0 + assert intrinsics.fx != intrinsics.fy def test_intrinsics_non_centered_principal_point(self): """Test intrinsics with non-centered principal point.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=100.0, cy=100.0) - self.assertEqual(intrinsics.cx, 100.0) - self.assertEqual(intrinsics.cy, 100.0) + assert intrinsics.cx == 100.0 + assert intrinsics.cy == 100.0 -class TestPinholeDistortion(unittest.TestCase): +class TestPinholeDistortion: def test_distortion_creation(self): """Test creating PinholeDistortion instance.""" distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) - self.assertEqual(distortion.k1, 0.1) - self.assertEqual(distortion.k2, 0.01) - self.assertEqual(distortion.p1, 0.001) - self.assertEqual(distortion.p2, 0.001) - self.assertEqual(distortion.k3, 0.001) + assert distortion.k1 == 0.1 + assert distortion.k2 == 0.01 + assert distortion.p1 == 0.001 + assert distortion.p2 == 0.001 + assert distortion.k3 == 0.001 def test_distortion_from_array(self): """Test creating distortion from array.""" array = np.array([0.1, 0.01, 0.001, 0.001, 0.001], dtype=np.float64) distortion = PinholeDistortion.from_array(array) - self.assertEqual(distortion.k1, 0.1) - self.assertEqual(distortion.k2, 0.01) - self.assertEqual(distortion.p1, 0.001) - self.assertEqual(distortion.p2, 0.001) - self.assertEqual(distortion.k3, 0.001) + assert distortion.k1 == 0.1 + assert distortion.k2 == 0.01 + assert distortion.p1 == 0.001 + assert distortion.p2 == 0.001 + assert distortion.k3 == 0.001 def test_distortion_from_array_copy(self): """Test that from_array creates a copy by default.""" @@ -207,7 +206,7 @@ def test_distortion_from_array_copy(self): array[0] = 0.5 # Distortion should still have original value - self.assertEqual(distortion.k1, 0.1) + assert distortion.k1 == 0.1 def test_distortion_from_array_no_copy(self): """Test that from_array can avoid copying.""" @@ -218,63 +217,63 @@ def test_distortion_from_array_no_copy(self): array[0] = 0.5 # Distortion should reflect the change - self.assertEqual(distortion.k1, 0.5) + assert distortion.k1 == 0.5 def test_distortion_array_property(self): """Test accessing the underlying array.""" distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) array = distortion.array - self.assertIsInstance(array, np.ndarray) - self.assertEqual(array.shape, (len(PinholeDistortionIndex),)) + assert isinstance(array, np.ndarray) + assert array.shape == (len(PinholeDistortionIndex),) np.testing.assert_array_almost_equal(array, [0.1, 0.01, 0.001, 0.001, 0.001]) def test_distortion_zero_values(self): """Test distortion with zero values.""" distortion = PinholeDistortion(k1=0.0, k2=0.0, p1=0.0, p2=0.0, k3=0.0) - self.assertEqual(distortion.k1, 0.0) - self.assertEqual(distortion.k2, 0.0) - self.assertEqual(distortion.p1, 0.0) - self.assertEqual(distortion.p2, 0.0) - self.assertEqual(distortion.k3, 0.0) + assert distortion.k1 == 0.0 + assert distortion.k2 == 0.0 + assert distortion.p1 == 0.0 + assert distortion.p2 == 0.0 + assert distortion.k3 == 0.0 def test_distortion_negative_values(self): """Test distortion with negative values.""" distortion = PinholeDistortion(k1=-0.1, k2=-0.01, p1=-0.001, p2=-0.001, k3=-0.001) - self.assertEqual(distortion.k1, -0.1) - self.assertEqual(distortion.k2, -0.01) - self.assertEqual(distortion.p1, -0.001) - self.assertEqual(distortion.p2, -0.001) - self.assertEqual(distortion.k3, -0.001) + assert distortion.k1 == -0.1 + assert distortion.k2 == -0.01 + assert distortion.p1 == -0.001 + assert distortion.p2 == -0.001 + assert distortion.k3 == -0.001 def test_distortion_from_list(self): """Test creating distortion from list via from_list method.""" distortion_list = [0.1, 0.01, 0.001, 0.001, 0.001] distortion = PinholeDistortion.from_list(distortion_list) - self.assertEqual(distortion.k1, 0.1) - self.assertEqual(distortion.k2, 0.01) - self.assertEqual(distortion.p1, 0.001) - self.assertEqual(distortion.p2, 0.001) - self.assertEqual(distortion.k3, 0.001) + assert distortion.k1 == 0.1 + assert distortion.k2 == 0.01 + assert distortion.p1 == 0.001 + assert distortion.p2 == 0.001 + assert distortion.k3 == 0.001 def test_distortion_tolist(self): """Test converting distortion to list.""" distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) distortion_list = distortion.tolist() - self.assertIsInstance(distortion_list, list) - self.assertEqual(len(distortion_list), 5) - self.assertAlmostEqual(distortion_list[0], 0.1) - self.assertAlmostEqual(distortion_list[1], 0.01) - self.assertAlmostEqual(distortion_list[2], 0.001) - self.assertAlmostEqual(distortion_list[3], 0.001) - self.assertAlmostEqual(distortion_list[4], 0.001) + assert isinstance(distortion_list, list) + assert len(distortion_list) == 5 + assert distortion_list[0] == pytest.approx(0.1) + assert distortion_list[1] == pytest.approx(0.01) + assert distortion_list[2] == pytest.approx(0.001) + assert distortion_list[3] == pytest.approx(0.001) + assert distortion_list[4] == pytest.approx(0.001) -class TestPinholeMetadata(unittest.TestCase): +class TestPinholeMetadata: def test_metadata_from_dict_with_none_intrinsics(self): """Test creating metadata from dict with None intrinsics.""" @@ -288,11 +287,11 @@ def test_metadata_from_dict_with_none_intrinsics(self): metadata = PinholeCameraMetadata.from_dict(data_dict) - self.assertEqual(metadata.camera_type, PinholeCameraType.PCAM_B0) - self.assertIsNone(metadata.intrinsics) - self.assertIsNotNone(metadata.distortion) - self.assertEqual(metadata.width, 800) - self.assertEqual(metadata.height, 600) + assert metadata.camera_type == PinholeCameraType.PCAM_B0 + assert metadata.intrinsics is None + assert metadata.distortion is not None + assert metadata.width == 800 + assert metadata.height == 600 def test_metadata_from_dict_with_none_distortion(self): """Test creating metadata from dict with None distortion.""" @@ -306,9 +305,9 @@ def test_metadata_from_dict_with_none_distortion(self): metadata = PinholeCameraMetadata.from_dict(data_dict) - self.assertEqual(metadata.camera_type, PinholeCameraType.PCAM_L0) - self.assertIsNotNone(metadata.intrinsics) - self.assertIsNone(metadata.distortion) + assert metadata.camera_type == PinholeCameraType.PCAM_L0 + assert metadata.intrinsics is not None + assert metadata.distortion is None def test_metadata_different_aspect_ratios(self): """Test metadata with different aspect ratios.""" @@ -318,13 +317,13 @@ def test_metadata_different_aspect_ratios(self): metadata_16_9 = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=1920, height=1080 ) - self.assertAlmostEqual(metadata_16_9.aspect_ratio, 16 / 9) + assert metadata_16_9.aspect_ratio == pytest.approx(16 / 9) # 4:3 aspect ratio metadata_4_3 = PinholeCameraMetadata( camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=640, height=480 ) - self.assertAlmostEqual(metadata_4_3.aspect_ratio, 4 / 3) + assert metadata_4_3.aspect_ratio == pytest.approx(4 / 3) def test_metadata_fov_with_different_focal_lengths(self): """Test FOV calculation with different focal lengths.""" @@ -339,8 +338,8 @@ def test_metadata_fov_with_different_focal_lengths(self): ) # Wider focal length should result in larger FOV - self.assertGreater(metadata_wide.fov_x, metadata_narrow.fov_x) - self.assertGreater(metadata_wide.fov_y, metadata_narrow.fov_y) + assert metadata_wide.fov_x > metadata_narrow.fov_x + assert metadata_wide.fov_y > metadata_narrow.fov_y def test_metadata_to_dict_preserves_types(self): """Test that to_dict preserves correct types.""" @@ -353,11 +352,11 @@ def test_metadata_to_dict_preserves_types(self): data_dict = metadata.to_dict() - self.assertIsInstance(data_dict["camera_type"], int) - self.assertIsInstance(data_dict["width"], int) - self.assertIsInstance(data_dict["height"], int) - self.assertIsInstance(data_dict["intrinsics"], list) - self.assertIsInstance(data_dict["distortion"], list) + assert isinstance(data_dict["camera_type"], int) + assert isinstance(data_dict["width"], int) + assert isinstance(data_dict["height"], int) + assert isinstance(data_dict["intrinsics"], list) + assert isinstance(data_dict["distortion"], list) def test_metadata_all_camera_types(self): """Test metadata creation with all camera types.""" @@ -367,7 +366,7 @@ def test_metadata_all_camera_types(self): metadata = PinholeCameraMetadata( camera_type=camera_type, intrinsics=intrinsics, distortion=None, width=640, height=480 ) - self.assertEqual(metadata.camera_type, camera_type) + assert metadata.camera_type == camera_type def test_metadata_square_image(self): """Test metadata with square image dimensions.""" @@ -376,8 +375,8 @@ def test_metadata_square_image(self): camera_type=PinholeCameraType.PCAM_F0, intrinsics=intrinsics, distortion=None, width=512, height=512 ) - self.assertEqual(metadata.aspect_ratio, 1.0) - self.assertAlmostEqual(metadata.fov_x, metadata.fov_y) + assert metadata.aspect_ratio == 1.0 + assert metadata.fov_x == pytest.approx(metadata.fov_y) def test_metadata_non_square_pixels(self): """Test metadata with non-square pixels (different fx and fy).""" @@ -389,12 +388,12 @@ def test_metadata_non_square_pixels(self): expected_fov_x = 2 * np.arctan(640 / (2 * 500.0)) expected_fov_y = 2 * np.arctan(480 / (2 * 600.0)) - self.assertAlmostEqual(metadata.fov_x, expected_fov_x) - self.assertAlmostEqual(metadata.fov_y, expected_fov_y) - self.assertNotAlmostEqual(metadata.fov_x, metadata.fov_y) + assert metadata.fov_x == pytest.approx(expected_fov_x) + assert metadata.fov_y == pytest.approx(expected_fov_y) + assert metadata.fov_x != pytest.approx(metadata.fov_y) -class TestPinholeCamera(unittest.TestCase): +class TestPinholeCamera: def test_pinhole_camera_creation(self): """Test creating PinholeCamera instance.""" @@ -408,9 +407,9 @@ def test_pinhole_camera_creation(self): camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertEqual(camera.metadata, metadata) - self.assertTrue(np.array_equal(camera.image, image)) - self.assertEqual(camera.extrinsic, extrinsic) + assert camera.metadata == metadata + assert np.array_equal(camera.image, image) + assert camera.extrinsic == extrinsic def test_pinhole_camera_with_color_image(self): """Test PinholeCamera with color image.""" @@ -424,8 +423,8 @@ def test_pinhole_camera_with_color_image(self): camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertEqual(camera.image.shape, (480, 640, 3)) - self.assertEqual(camera.image.dtype, np.uint8) + assert camera.image.shape == (480, 640, 3) + assert camera.image.dtype == np.uint8 def test_pinhole_camera_with_grayscale_image(self): """Test PinholeCamera with grayscale image.""" @@ -439,7 +438,7 @@ def test_pinhole_camera_with_grayscale_image(self): camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertEqual(camera.image.shape, (480, 640)) + assert camera.image.shape == (480, 640) def test_pinhole_camera_with_distortion(self): """Test PinholeCamera with distortion parameters.""" @@ -458,8 +457,8 @@ def test_pinhole_camera_with_distortion(self): camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertIsNotNone(camera.metadata.distortion) - self.assertEqual(camera.metadata.distortion.k1, 0.1) + assert camera.metadata.distortion is not None + assert camera.metadata.distortion.k1 == 0.1 def test_pinhole_camera_different_types(self): """Test PinholeCamera with different camera types.""" @@ -478,7 +477,7 @@ def test_pinhole_camera_different_types(self): camera_type=camera_type, intrinsics=intrinsics, distortion=None, width=640, height=480 ) camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertEqual(camera.metadata.camera_type, camera_type) + assert camera.metadata.camera_type == camera_type def test_pinhole_camera_with_different_resolutions(self): """Test PinholeCamera with different image resolutions.""" @@ -498,6 +497,6 @@ def test_pinhole_camera_with_different_resolutions(self): image = np.zeros((height, width, 3), dtype=np.uint8) camera = PinholeCamera(metadata=metadata, image=image, extrinsic=extrinsic) - self.assertEqual(camera.metadata.width, width) - self.assertEqual(camera.metadata.height, height) - self.assertEqual(camera.image.shape[:2], (height, width)) + assert camera.metadata.width == width + assert camera.metadata.height == height + assert camera.image.shape[:2] == (height, width) diff --git a/tests/unit/datatypes/time/test_time.py b/tests/unit/datatypes/time/test_time.py index a61d4028..1a15a73c 100644 --- a/tests/unit/datatypes/time/test_time.py +++ b/tests/unit/datatypes/time/test_time.py @@ -1,9 +1,9 @@ -import unittest +import pytest from py123d.datatypes.time.time_point import TimePoint -class TestTimePoint(unittest.TestCase): +class TestTimePoint: def test_from_ns(self): """Test constructing TimePoint from nanoseconds.""" @@ -51,12 +51,12 @@ def test_time_s_property(self): def test_from_ns_integer_assertion(self): """Test that from_ns raises AssertionError for non-integer input.""" - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): TimePoint.from_ns(1000.5) def test_from_us_integer_assertion(self): """Test that from_us raises AssertionError for non-integer input.""" - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): TimePoint.from_us(1000.5) def test_conversion_chain(self): diff --git a/tests/unit/datatypes/vehicle_state/test_dynamic_state.py b/tests/unit/datatypes/vehicle_state/test_dynamic_state.py index d9a0fced..0f1cb472 100644 --- a/tests/unit/datatypes/vehicle_state/test_dynamic_state.py +++ b/tests/unit/datatypes/vehicle_state/test_dynamic_state.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np from py123d.datatypes.vehicle_state.dynamic_state import ( @@ -11,7 +9,7 @@ from py123d.geometry import Vector2D, Vector3D -class TestDynamicStateSE3(unittest.TestCase): +class TestDynamicStateSE3: def test_init(self): velocity = Vector3D(1.0, 2.0, 3.0) acceleration = Vector3D(4.0, 5.0, 6.0) @@ -64,7 +62,7 @@ def test_dynamic_state_se2_projection(self): assert np.isclose(state_se2.angular_velocity, 9.0) -class TestDynamicStateSE2(unittest.TestCase): +class TestDynamicStateSE2: def test_init(self): velocity = Vector2D(1.0, 2.0) acceleration = Vector2D(3.0, 4.0) diff --git a/tests/unit/datatypes/vehicle_state/test_ego_state.py b/tests/unit/datatypes/vehicle_state/test_ego_state.py index 89b447b1..e4e8d46c 100644 --- a/tests/unit/datatypes/vehicle_state/test_ego_state.py +++ b/tests/unit/datatypes/vehicle_state/test_ego_state.py @@ -1,4 +1,4 @@ -import unittest +import pytest from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.time import TimePoint @@ -13,8 +13,8 @@ from py123d.geometry import PoseSE2, PoseSE3, Vector2D, Vector3D -class TestEgoStateSE2(unittest.TestCase): - def setUp(self): +class TestEgoStateSE2: + def setup_method(self): """Set up test fixtures for EgoStateSE2 tests.""" self.rear_axle_pose = PoseSE2(x=0.0, y=0.0, yaw=0.0) self.vehicle_params = VehicleParameters( @@ -44,11 +44,11 @@ def test_init(self): tire_steering_angle=self.tire_steering_angle, ) - self.assertEqual(ego_state.rear_axle_se2, self.rear_axle_pose) - self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) - self.assertEqual(ego_state.dynamic_state_se2, self.dynamic_state) - self.assertEqual(ego_state.timepoint, self.timepoint) - self.assertEqual(ego_state.tire_steering_angle, self.tire_steering_angle) + assert ego_state.rear_axle_se2 == self.rear_axle_pose + assert ego_state.vehicle_parameters == self.vehicle_params + assert ego_state.dynamic_state_se2 == self.dynamic_state + assert ego_state.timepoint == self.timepoint + assert ego_state.tire_steering_angle == self.tire_steering_angle def test_from_rear_axle(self): """Test creating EgoStateSE2 from rear axle.""" @@ -60,8 +60,8 @@ def test_from_rear_axle(self): tire_steering_angle=self.tire_steering_angle, ) - self.assertEqual(ego_state.rear_axle_se2, self.rear_axle_pose) - self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + assert ego_state.rear_axle_se2 == self.rear_axle_pose + assert ego_state.vehicle_parameters == self.vehicle_params def test_from_center(self): """Test creating EgoStateSE2 from center pose.""" @@ -74,35 +74,35 @@ def test_from_center(self): tire_steering_angle=self.tire_steering_angle, ) - self.assertIsNotNone(ego_state.rear_axle_se2) - self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + assert ego_state.rear_axle_se2 is not None + assert ego_state.vehicle_parameters == self.vehicle_params def test_rear_axle_property(self): """Test rear_axle property.""" ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) - self.assertEqual(ego_state.rear_axle, self.rear_axle_pose) - self.assertEqual(ego_state.rear_axle_se2, self.rear_axle_pose) + assert ego_state.rear_axle == self.rear_axle_pose + assert ego_state.rear_axle_se2 == self.rear_axle_pose def test_center_property(self): """Test center property calculation.""" ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) center = ego_state.center_se2 - self.assertIsNotNone(center) - self.assertAlmostEqual(center.x, self.vehicle_params.rear_axle_to_center_longitudinal) - self.assertAlmostEqual(center.y, 0.0) - self.assertAlmostEqual(center.yaw, 0.0) + assert center is not None + assert center.x == pytest.approx(self.vehicle_params.rear_axle_to_center_longitudinal) + assert center.y == pytest.approx(0.0) + assert center.yaw == pytest.approx(0.0) def test_bounding_box_property(self): """Test bounding box properties.""" ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) bbox = ego_state.bounding_box_se2 - self.assertIsNotNone(bbox) - self.assertEqual(bbox.length, self.vehicle_params.length) - self.assertEqual(bbox.width, self.vehicle_params.width) - self.assertEqual(ego_state.bounding_box, bbox) + assert bbox is not None + assert bbox.length == self.vehicle_params.length + assert bbox.width == self.vehicle_params.width + assert ego_state.bounding_box == bbox def test_box_detection_property(self): """Test box detection properties.""" @@ -114,16 +114,16 @@ def test_box_detection_property(self): ) box_det = ego_state.box_detection_se2 - self.assertIsNotNone(box_det) - self.assertEqual(box_det.metadata.label, DefaultBoxDetectionLabel.EGO) - self.assertEqual(box_det.metadata.track_token, EGO_TRACK_TOKEN) - self.assertEqual(box_det.metadata.timepoint, self.timepoint) + assert box_det is not None + assert box_det.metadata.label == DefaultBoxDetectionLabel.EGO + assert box_det.metadata.track_token == EGO_TRACK_TOKEN + assert box_det.metadata.timepoint == self.timepoint box_det = ego_state.box_detection - self.assertIsNotNone(box_det) - self.assertEqual(box_det.metadata.label, DefaultBoxDetectionLabel.EGO) - self.assertEqual(box_det.metadata.track_token, EGO_TRACK_TOKEN) - self.assertEqual(box_det.metadata.timepoint, self.timepoint) + assert box_det is not None + assert box_det.metadata.label == DefaultBoxDetectionLabel.EGO + assert box_det.metadata.track_token == EGO_TRACK_TOKEN + assert box_det.metadata.timepoint == self.timepoint def test_optional_parameters_none(self): """Test EgoStateSE2 with optional parameters as None.""" @@ -135,19 +135,19 @@ def test_optional_parameters_none(self): tire_steering_angle=None, ) - self.assertIsNone(ego_state.dynamic_state_se2) - self.assertIsNone(ego_state.timepoint) - self.assertIsNone(ego_state.tire_steering_angle) + assert ego_state.dynamic_state_se2 is None + assert ego_state.timepoint is None + assert ego_state.tire_steering_angle is None def test_default_tire_steering_angle(self): """Test default tire steering angle value.""" ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) - self.assertEqual(ego_state.tire_steering_angle, 0.0) + assert ego_state.tire_steering_angle == 0.0 -class TestEgoStateSE3(unittest.TestCase): - def setUp(self): +class TestEgoStateSE3: + def setup_method(self): """Set up test fixtures for EgoStateSE3 tests.""" self.rear_axle_pose = PoseSE3(x=0.0, y=0.0, z=0.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) @@ -178,11 +178,11 @@ def test_init(self): tire_steering_angle=self.tire_steering_angle, ) - self.assertEqual(ego_state.rear_axle_se3, self.rear_axle_pose) - self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) - self.assertEqual(ego_state.dynamic_state_se3, self.dynamic_state) - self.assertEqual(ego_state.timepoint, self.timepoint) - self.assertEqual(ego_state.tire_steering_angle, self.tire_steering_angle) + assert ego_state.rear_axle_se3 == self.rear_axle_pose + assert ego_state.vehicle_parameters == self.vehicle_params + assert ego_state.dynamic_state_se3 == self.dynamic_state + assert ego_state.timepoint == self.timepoint + assert ego_state.tire_steering_angle == self.tire_steering_angle def test_from_rear_axle(self): """Test creating EgoStateSE3 from rear axle.""" @@ -194,8 +194,8 @@ def test_from_rear_axle(self): tire_steering_angle=self.tire_steering_angle, ) - self.assertEqual(ego_state.rear_axle_se3, self.rear_axle_pose) - self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + assert ego_state.rear_axle_se3 == self.rear_axle_pose + assert ego_state.vehicle_parameters == self.vehicle_params def test_from_center(self): """Test creating EgoStateSE3 from center pose.""" @@ -208,43 +208,43 @@ def test_from_center(self): tire_steering_angle=self.tire_steering_angle, ) - self.assertIsNotNone(ego_state.rear_axle_se3) - self.assertEqual(ego_state.vehicle_parameters, self.vehicle_params) + assert ego_state.rear_axle_se3 is not None + assert ego_state.vehicle_parameters == self.vehicle_params def test_rear_axle_properties(self): """Test rear_axle properties.""" ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) - self.assertEqual(ego_state.rear_axle, self.rear_axle_pose) - self.assertEqual(ego_state.rear_axle_se3, self.rear_axle_pose) - self.assertIsNotNone(ego_state.rear_axle_se2) + assert ego_state.rear_axle == self.rear_axle_pose + assert ego_state.rear_axle_se3 == self.rear_axle_pose + assert ego_state.rear_axle_se2 is not None def test_center_properties(self): """Test center property calculation.""" ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) center = ego_state.center_se3 - self.assertIsNotNone(center) - self.assertAlmostEqual(center.x, self.vehicle_params.rear_axle_to_center_longitudinal) - self.assertAlmostEqual(center.y, 0.0) + assert center is not None + assert center.x == pytest.approx(self.vehicle_params.rear_axle_to_center_longitudinal) + assert center.y == pytest.approx(0.0) center_se2 = ego_state.center_se2 - self.assertIsNotNone(center_se2) - self.assertEqual(ego_state.center, ego_state.center_se3) + assert center_se2 is not None + assert ego_state.center == ego_state.center_se3 def test_bounding_box_properties(self): """Test bounding box properties.""" ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) bbox_se3 = ego_state.bounding_box_se3 - self.assertIsNotNone(bbox_se3) - self.assertEqual(bbox_se3.length, self.vehicle_params.length) - self.assertEqual(bbox_se3.width, self.vehicle_params.width) - self.assertEqual(bbox_se3.height, self.vehicle_params.height) + assert bbox_se3 is not None + assert bbox_se3.length == self.vehicle_params.length + assert bbox_se3.width == self.vehicle_params.width + assert bbox_se3.height == self.vehicle_params.height bbox_se2 = ego_state.bounding_box_se2 - self.assertIsNotNone(bbox_se2) - self.assertEqual(ego_state.bounding_box, bbox_se3) + assert bbox_se2 is not None + assert ego_state.bounding_box == bbox_se3 def test_box_detection_properties(self): """Test box detection properties.""" @@ -256,14 +256,14 @@ def test_box_detection_properties(self): ) box_det_se3 = ego_state.box_detection_se3 - self.assertIsNotNone(box_det_se3) - self.assertEqual(box_det_se3.metadata.label, DefaultBoxDetectionLabel.EGO) - self.assertEqual(box_det_se3.metadata.track_token, EGO_TRACK_TOKEN) - self.assertEqual(box_det_se3.metadata.timepoint, self.timepoint) + assert box_det_se3 is not None + assert box_det_se3.metadata.label == DefaultBoxDetectionLabel.EGO + assert box_det_se3.metadata.track_token == EGO_TRACK_TOKEN + assert box_det_se3.metadata.timepoint == self.timepoint box_det_se2 = ego_state.box_detection_se2 - self.assertIsNotNone(box_det_se2) - self.assertEqual(ego_state.box_detection, box_det_se3) + assert box_det_se2 is not None + assert ego_state.box_detection == box_det_se3 def test_ego_state_se2_projection(self): """Test projection to EgoStateSE2.""" @@ -276,11 +276,11 @@ def test_ego_state_se2_projection(self): ) ego_state_se2 = ego_state.ego_state_se2 - self.assertIsNotNone(ego_state_se2) - self.assertIsInstance(ego_state_se2, EgoStateSE2) - self.assertEqual(ego_state_se2.vehicle_parameters, self.vehicle_params) - self.assertEqual(ego_state_se2.timepoint, self.timepoint) - self.assertEqual(ego_state_se2.tire_steering_angle, self.tire_steering_angle) + assert ego_state_se2 is not None + assert isinstance(ego_state_se2, EgoStateSE2) + assert ego_state_se2.vehicle_parameters == self.vehicle_params + assert ego_state_se2.timepoint == self.timepoint + assert ego_state_se2.tire_steering_angle == self.tire_steering_angle def test_optional_parameters_none(self): """Test EgoStateSE3 with optional parameters as None.""" @@ -292,12 +292,12 @@ def test_optional_parameters_none(self): tire_steering_angle=None, ) - self.assertIsNone(ego_state.dynamic_state_se3) - self.assertIsNone(ego_state.timepoint) - self.assertIsNone(ego_state.tire_steering_angle) + assert ego_state.dynamic_state_se3 is None + assert ego_state.timepoint is None + assert ego_state.tire_steering_angle is None def test_default_tire_steering_angle(self): """Test default tire steering angle value.""" ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) - self.assertEqual(ego_state.tire_steering_angle, 0.0) + assert ego_state.tire_steering_angle == 0.0 diff --git a/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py index dc569a0a..d678b6f7 100644 --- a/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py +++ b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py @@ -1,11 +1,9 @@ -import unittest - from py123d.datatypes.vehicle_state.vehicle_parameters import VehicleParameters -class TestVehicleParameters(unittest.TestCase): +class TestVehicleParameters: - def setUp(self): + def setup_method(self): self.params = VehicleParameters( vehicle_name="test_vehicle", width=2.0, @@ -18,25 +16,25 @@ def setUp(self): def test_initialization(self): """Test that VehicleParameters initializes correctly.""" - self.assertEqual(self.params.vehicle_name, "test_vehicle") - self.assertEqual(self.params.width, 2.0) - self.assertEqual(self.params.length, 5.0) - self.assertEqual(self.params.height, 1.8) - self.assertEqual(self.params.wheel_base, 3.0) - self.assertEqual(self.params.rear_axle_to_center_vertical, 0.5) - self.assertEqual(self.params.rear_axle_to_center_longitudinal, 1.5) + assert self.params.vehicle_name == "test_vehicle" + assert self.params.width == 2.0 + assert self.params.length == 5.0 + assert self.params.height == 1.8 + assert self.params.wheel_base == 3.0 + assert self.params.rear_axle_to_center_vertical == 0.5 + assert self.params.rear_axle_to_center_longitudinal == 1.5 def test_half_width(self): """Test half_width property.""" - self.assertEqual(self.params.half_width, 1.0) + assert self.params.half_width == 1.0 def test_half_length(self): """Test half_length property.""" - self.assertEqual(self.params.half_length, 2.5) + assert self.params.half_length == 2.5 def test_half_height(self): """Test half_height property.""" - self.assertEqual(self.params.half_height, 0.9) + assert self.params.half_height == 0.9 def test_to_dict(self): """Test to_dict method.""" @@ -50,7 +48,7 @@ def test_to_dict(self): "rear_axle_to_center_vertical": 0.5, "rear_axle_to_center_longitudinal": 1.5, } - self.assertEqual(result, expected) + assert result == expected def test_from_dict(self): """Test from_dict method.""" @@ -64,17 +62,17 @@ def test_from_dict(self): "rear_axle_to_center_longitudinal": 1.2, } params = VehicleParameters.from_dict(data) - self.assertEqual(params.vehicle_name, "from_dict_vehicle") - self.assertEqual(params.width, 1.5) - self.assertEqual(params.length, 4.0) - self.assertEqual(params.height, 1.6) - self.assertEqual(params.wheel_base, 2.5) - self.assertEqual(params.rear_axle_to_center_vertical, 0.4) - self.assertEqual(params.rear_axle_to_center_longitudinal, 1.2) + assert params.vehicle_name == "from_dict_vehicle" + assert params.width == 1.5 + assert params.length == 4.0 + assert params.height == 1.6 + assert params.wheel_base == 2.5 + assert params.rear_axle_to_center_vertical == 0.4 + assert params.rear_axle_to_center_longitudinal == 1.2 def test_from_dict_to_dict_round_trip(self): """Test that from_dict and to_dict are inverses.""" original_dict = self.params.to_dict() recreated_params = VehicleParameters.from_dict(original_dict) recreated_dict = recreated_params.to_dict() - self.assertEqual(original_dict, recreated_dict) + assert original_dict == recreated_dict diff --git a/tests/unit/geometry/test_bounding_box.py b/tests/unit/geometry/test_bounding_box.py index df157211..bb5b5513 100644 --- a/tests/unit/geometry/test_bounding_box.py +++ b/tests/unit/geometry/test_bounding_box.py @@ -1,6 +1,7 @@ import unittest import numpy as np +import pytest import shapely.geometry as geom from py123d.common.utils.mixin import ArrayMixin @@ -14,10 +15,10 @@ ) -class TestBoundingBoxSE2(unittest.TestCase): +class TestBoundingBoxSE2: """Unit tests for BoundingBoxSE2 class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.center = PoseSE2(1.0, 2.0, 0.5) self.length = 4.0 @@ -27,8 +28,8 @@ def setUp(self): def test_init(self): """Test BoundingBoxSE2 initialization.""" bbox = BoundingBoxSE2(self.center, self.length, self.width) - self.assertEqual(bbox.length, self.length) - self.assertEqual(bbox.width, self.width) + assert bbox.length == self.length + assert bbox.width == self.width np.testing.assert_array_equal(bbox.center_se2.array, self.center.array) def test_from_array(self): @@ -44,13 +45,13 @@ def test_from_array_copy(self): bbox_no_copy = BoundingBoxSE2.from_array(array, copy=False) array[0] = 999.0 - self.assertNotEqual(bbox_copy.array[0], 999.0) - self.assertEqual(bbox_no_copy.array[0], 999.0) + assert bbox_copy.array[0] != 999.0 + assert bbox_no_copy.array[0] == 999.0 def test_properties(self): """Test BoundingBoxSE2 properties.""" - self.assertEqual(self.bbox.length, self.length) - self.assertEqual(self.bbox.width, self.width) + assert self.bbox.length == self.length + assert self.bbox.width == self.width np.testing.assert_array_equal(self.bbox.center_se2.array, self.center.array) def test_array_property(self): @@ -60,53 +61,53 @@ def test_array_property(self): def test_array_mixin(self): """Test that BoundingBoxSE2 is an instance of ArrayMixin.""" - self.assertIsInstance(self.bbox, ArrayMixin) + assert isinstance(self.bbox, ArrayMixin) expected = np.array([1.0, 2.0, 0.5, 4.0, 2.0], dtype=np.float16) output_array = np.array(self.bbox, dtype=np.float16) np.testing.assert_array_equal(output_array, expected) - self.assertEqual(output_array.dtype, np.float16) - self.assertEqual(output_array.shape, (len(BoundingBoxSE2Index),)) + assert output_array.dtype == np.float16 + assert output_array.shape == (len(BoundingBoxSE2Index),) def test_bounding_box_se2_property(self): """Test bounding_box_se2 property returns self.""" - self.assertIs(self.bbox.bounding_box_se2, self.bbox) + assert self.bbox.bounding_box_se2 is self.bbox def test_corners_array(self): """Test corners_array property.""" corners = self.bbox.corners_array - self.assertEqual(corners.shape, (len(Corners2DIndex), len(Point2DIndex))) - self.assertIsInstance(corners, np.ndarray) + assert corners.shape == (len(Corners2DIndex), len(Point2DIndex)) + assert isinstance(corners, np.ndarray) def test_corners_dict(self): """Test corners_dict property.""" corners_dict = self.bbox.corners_dict - self.assertEqual(len(corners_dict), len(Corners2DIndex)) + assert len(corners_dict) == len(Corners2DIndex) for index in Corners2DIndex: - self.assertIn(index, corners_dict) - self.assertIsInstance(corners_dict[index], Point2D) + assert index in corners_dict + assert isinstance(corners_dict[index], Point2D) def test_shapely_polygon(self): """Test shapely_polygon property.""" polygon = self.bbox.shapely_polygon - self.assertIsInstance(polygon, geom.Polygon) - self.assertAlmostEqual(polygon.area, self.length * self.width) + assert isinstance(polygon, geom.Polygon) + assert polygon.area == pytest.approx(self.length * self.width) def test_array_assertions(self): """Test array assertions in from_array.""" # Test 2D array - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): BoundingBoxSE2.from_array(np.array([[1, 2, 3, 4, 5]])) # Test wrong size - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): BoundingBoxSE2.from_array(np.array([1, 2, 3, 4])) -class TestBoundingBoxSE3(unittest.TestCase): +class TestBoundingBoxSE3: """Unit tests for BoundingBoxSE3 class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.array = np.array([1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393, 4.0, 2.0, 1.5]) self.center_se3 = PoseSE3(1.0, 2.0, 3.0, 0.98185617, 0.06407135, 0.09115755, 0.1534393) @@ -118,9 +119,9 @@ def setUp(self): def test_init(self): """Test BoundingBoxSE3 initialization.""" bbox = BoundingBoxSE3(self.center_se3, self.length, self.width, self.height) - self.assertEqual(bbox.length, self.length) - self.assertEqual(bbox.width, self.width) - self.assertEqual(bbox.height, self.height) + assert bbox.length == self.length + assert bbox.width == self.width + assert bbox.height == self.height np.testing.assert_array_equal(bbox.center_se3.array, self.center_se3.array) def test_from_array(self): @@ -136,14 +137,14 @@ def test_from_array_copy(self): bbox_no_copy = BoundingBoxSE3.from_array(array, copy=False) array[0] = 999.0 - self.assertNotEqual(bbox_copy.array[0], 999.0) - self.assertEqual(bbox_no_copy.array[0], 999.0) + assert bbox_copy.array[0] != 999.0 + assert bbox_no_copy.array[0] == 999.0 def test_properties(self): """Test BoundingBoxSE3 properties.""" - self.assertEqual(self.bbox.length, self.length) - self.assertEqual(self.bbox.width, self.width) - self.assertEqual(self.bbox.height, self.height) + assert self.bbox.length == self.length + assert self.bbox.width == self.width + assert self.bbox.height == self.height np.testing.assert_array_equal(self.bbox.center_se3.array, self.center_se3.array) def test_array_property(self): @@ -153,65 +154,65 @@ def test_array_property(self): def test_array_mixin(self): """Test that BoundingBoxSE3 is an instance of ArrayMixin.""" - self.assertIsInstance(self.bbox, ArrayMixin) + assert isinstance(self.bbox, ArrayMixin) expected = np.array(self.array, dtype=np.float16) output_array = np.array(self.bbox, dtype=np.float16) np.testing.assert_array_equal(output_array, expected) - self.assertEqual(output_array.dtype, np.float16) - self.assertEqual(output_array.shape, (len(BoundingBoxSE3Index),)) + assert output_array.dtype == np.float16 + assert output_array.shape == (len(BoundingBoxSE3Index),) def test_bounding_box_se2_property(self): """Test bounding_box_se2 property.""" bbox_2d = self.bbox.bounding_box_se2 - self.assertIsInstance(bbox_2d, BoundingBoxSE2) - self.assertEqual(bbox_2d.length, self.length) - self.assertEqual(bbox_2d.width, self.width) - self.assertEqual(bbox_2d.center_se2.x, self.center_se3.x) - self.assertEqual(bbox_2d.center_se2.y, self.center_se3.y) - self.assertEqual(bbox_2d.center_se2.yaw, self.center_se3.euler_angles.yaw) + assert isinstance(bbox_2d, BoundingBoxSE2) + assert bbox_2d.length == self.length + assert bbox_2d.width == self.width + assert bbox_2d.center_se2.x == self.center_se3.x + assert bbox_2d.center_se2.y == self.center_se3.y + assert bbox_2d.center_se2.yaw == self.center_se3.euler_angles.yaw def test_corners_array(self): """Test corners_array property.""" corners = self.bbox.corners_array - self.assertEqual(corners.shape, (8, 3)) - self.assertIsInstance(corners, np.ndarray) + assert corners.shape == (8, 3) + assert isinstance(corners, np.ndarray) def test_corners_dict(self): """Test corners_dict property.""" corners_dict = self.bbox.corners_dict - self.assertEqual(len(corners_dict), 8) + assert len(corners_dict) == 8 for index in Corners3DIndex: - self.assertIn(index, corners_dict) - self.assertIsInstance(corners_dict[index], Point3D) + assert index in corners_dict + assert isinstance(corners_dict[index], Point3D) def test_shapely_polygon(self): """Test shapely_polygon property.""" polygon = self.bbox.shapely_polygon - self.assertIsInstance(polygon, geom.Polygon) - self.assertAlmostEqual(polygon.area, self.length * self.width) + assert isinstance(polygon, geom.Polygon) + assert polygon.area == pytest.approx(self.length * self.width) def test_array_assertions(self): """Test array assertions in from_array.""" # Test 2D array - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): BoundingBoxSE3.from_array(np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]])) # Test wrong size, less than required - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): BoundingBoxSE3.from_array(np.array([1, 2, 3, 4, 5, 6, 7, 8, 9])) # Test wrong size, greater than required - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): BoundingBoxSE3.from_array(np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])) def test_zero_dimensions(self): """Test bounding box with zero dimensions.""" center = PoseSE3(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) bbox = BoundingBoxSE3(center, 0.0, 0.0, 0.0) - self.assertEqual(bbox.length, 0.0) - self.assertEqual(bbox.width, 0.0) - self.assertEqual(bbox.height, 0.0) + assert bbox.length == 0.0 + assert bbox.width == 0.0 + assert bbox.height == 0.0 if __name__ == "__main__": diff --git a/tests/unit/geometry/test_occupancy_map.py b/tests/unit/geometry/test_occupancy_map.py index 2390300d..37094349 100644 --- a/tests/unit/geometry/test_occupancy_map.py +++ b/tests/unit/geometry/test_occupancy_map.py @@ -1,15 +1,16 @@ import unittest import numpy as np +import pytest import shapely.geometry as geom from py123d.geometry import OccupancyMap2D -class TestOccupancyMap2D(unittest.TestCase): +class TestOccupancyMap2D: """Unit tests for OccupancyMap2D class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures with various geometries.""" self.square1 = geom.Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) self.square2 = geom.Polygon([(3, 3), (5, 3), (5, 5), (3, 5)]) @@ -24,35 +25,35 @@ def test_init_with_default_ids(self): """Test initialization with default string IDs.""" occ_map = OccupancyMap2D(self.geometries) - self.assertEqual(len(occ_map), 4) - self.assertEqual(occ_map.ids, ["0", "1", "2", "3"]) - self.assertEqual(len(occ_map.geometries), 4) + assert len(occ_map) == 4 + assert occ_map.ids == ["0", "1", "2", "3"] + assert len(occ_map.geometries) == 4 def test_init_with_string_ids(self): """Test initialization with custom string IDs.""" occ_map = OccupancyMap2D(self.geometries, ids=self.string_ids) - self.assertEqual(len(occ_map), 4) - self.assertEqual(occ_map.ids, self.string_ids) - self.assertEqual(occ_map["square1"], self.square1) + assert len(occ_map) == 4 + assert occ_map.ids == self.string_ids + assert occ_map["square1"] == self.square1 def test_init_with_int_ids(self): """Test initialization with integer IDs.""" occ_map = OccupancyMap2D(self.geometries, ids=self.int_ids) - self.assertEqual(len(occ_map), 4) - self.assertEqual(occ_map.ids, self.int_ids) - self.assertEqual(occ_map[1], self.square1) + assert len(occ_map) == 4 + assert occ_map.ids == self.int_ids + assert occ_map[1] == self.square1 def test_init_with_mismatched_ids_length(self): """Test that initialization fails with mismatched IDs length.""" - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): OccupancyMap2D(self.geometries, ids=["id1", "id2"]) def test_init_with_custom_node_capacity(self): """Test initialization with custom node capacity.""" occ_map = OccupancyMap2D(self.geometries, node_capacity=5) - self.assertEqual(occ_map._node_capacity, 5) + assert occ_map._node_capacity == 5 def test_from_dict_constructor(self): """Test construction from dictionary.""" @@ -60,54 +61,54 @@ def test_from_dict_constructor(self): occ_map = OccupancyMap2D.from_dict(geometry_dict) - self.assertEqual(len(occ_map), 3) - self.assertEqual(set(occ_map.ids), set(["square", "circle", "line"])) - self.assertEqual(occ_map["square"], self.square1) + assert len(occ_map) == 3 + assert set(occ_map.ids) == set(["square", "circle", "line"]) + assert occ_map["square"] == self.square1 def test_getitem_string_id(self): """Test geometry retrieval by string ID.""" occ_map = OccupancyMap2D(self.geometries, ids=self.string_ids) - self.assertEqual(occ_map["square1"], self.square1) - self.assertEqual(occ_map["circle"], self.circle) + assert occ_map["square1"] == self.square1 + assert occ_map["circle"] == self.circle def test_getitem_int_id(self): """Test geometry retrieval by integer ID.""" occ_map = OccupancyMap2D(self.geometries, ids=self.int_ids) - self.assertEqual(occ_map[1], self.square1) - self.assertEqual(occ_map[3], self.circle) + assert occ_map[1] == self.square1 + assert occ_map[3] == self.circle def test_getitem_invalid_id(self): """Test that invalid ID raises KeyError.""" occ_map = OccupancyMap2D(self.geometries, ids=self.string_ids) - with self.assertRaises(KeyError): + with pytest.raises(KeyError): _ = occ_map["nonexistent"] def test_len(self): """Test length property.""" occ_map = OccupancyMap2D(self.geometries) - self.assertEqual(len(occ_map), 4) + assert len(occ_map) == 4 empty_map = OccupancyMap2D([]) - self.assertEqual(len(empty_map), 0) + assert len(empty_map) == 0 def test_ids_property(self): """Test IDs property getter.""" occ_map = OccupancyMap2D(self.geometries, ids=self.string_ids) - self.assertEqual(occ_map.ids, self.string_ids) + assert occ_map.ids == self.string_ids def test_geometries_property(self): """Test geometries property getter.""" occ_map = OccupancyMap2D(self.geometries) - self.assertEqual(list(occ_map.geometries), self.geometries) + assert list(occ_map.geometries) == self.geometries def test_id_to_idx_property(self): """Test id_to_idx property.""" occ_map = OccupancyMap2D(self.geometries, ids=self.string_ids) expected_mapping = {"square1": 0, "square2": 1, "circle": 2, "line": 3} - self.assertEqual(occ_map.id_to_idx, expected_mapping) + assert occ_map.id_to_idx == expected_mapping def test_intersects_with_overlapping_geometry(self): """Test intersects method with overlapping geometry.""" @@ -118,10 +119,10 @@ def test_intersects_with_overlapping_geometry(self): intersecting_ids = occ_map.intersects(query_geom) # NOTE: square2 does not intersect with the query geometry, the rest does. - self.assertIn("square1", intersecting_ids) - self.assertIn("circle", intersecting_ids) - self.assertIn("line", intersecting_ids) - self.assertEqual(len(intersecting_ids), 3) + assert "square1" in intersecting_ids + assert "circle" in intersecting_ids + assert "line" in intersecting_ids + assert len(intersecting_ids) == 3 def test_intersects_with_non_overlapping_geometry(self): """Test intersects method with non-overlapping geometry.""" @@ -131,7 +132,7 @@ def test_intersects_with_non_overlapping_geometry(self): query_geom = geom.Polygon([(10, 10), (12, 10), (12, 12), (10, 12)]) intersecting_ids = occ_map.intersects(query_geom) - self.assertEqual(len(intersecting_ids), 0) + assert len(intersecting_ids) == 0 def test_query_with_intersects_predicate(self): """Test query method with intersects predicate.""" @@ -139,11 +140,11 @@ def test_query_with_intersects_predicate(self): query_geom = geom.Point(1, 1) indices = occ_map.query(query_geom, predicate="intersects") - self.assertIsInstance(indices, np.ndarray) - self.assertIn(occ_map.id_to_idx["square1"], indices) - self.assertIn(occ_map.id_to_idx["circle"], indices) - self.assertIn(occ_map.id_to_idx["line"], indices) - self.assertNotIn(occ_map.id_to_idx["square2"], indices) + assert isinstance(indices, np.ndarray) + assert occ_map.id_to_idx["square1"] in indices + assert occ_map.id_to_idx["circle"] in indices + assert occ_map.id_to_idx["line"] in indices + assert occ_map.id_to_idx["square2"] not in indices def test_query_with_contains_predicate(self): """Test query method with contains predicate.""" @@ -152,11 +153,11 @@ def test_query_with_contains_predicate(self): query_geom = geom.Point(4, 4) indices = occ_map.query(query_geom, predicate="within") - self.assertIsInstance(indices, np.ndarray) - self.assertIn(occ_map.id_to_idx["square2"], indices) - self.assertNotIn(occ_map.id_to_idx["square1"], indices) - self.assertNotIn(occ_map.id_to_idx["circle"], indices) - self.assertNotIn(occ_map.id_to_idx["line"], indices) + assert isinstance(indices, np.ndarray) + assert occ_map.id_to_idx["square2"] in indices + assert occ_map.id_to_idx["square1"] not in indices + assert occ_map.id_to_idx["circle"] not in indices + assert occ_map.id_to_idx["line"] not in indices def test_query_with_distance(self): """Test query method with distance parameter.""" @@ -165,11 +166,11 @@ def test_query_with_distance(self): query_geom = geom.Point(4, 4) indices = occ_map.query(query_geom, predicate="dwithin", distance=3.0) - self.assertIsInstance(indices, np.ndarray) - self.assertIn(occ_map.id_to_idx["square2"], indices) - self.assertIn(occ_map.id_to_idx["square1"], indices) - self.assertNotIn(occ_map.id_to_idx["circle"], indices) - self.assertNotIn(occ_map.id_to_idx["line"], indices) + assert isinstance(indices, np.ndarray) + assert occ_map.id_to_idx["square2"] in indices + assert occ_map.id_to_idx["square1"] in indices + assert occ_map.id_to_idx["circle"] not in indices + assert occ_map.id_to_idx["line"] not in indices def test_query_nearest_basic(self): """Test query_nearest method basic functionality.""" @@ -178,7 +179,7 @@ def test_query_nearest_basic(self): query_geom = geom.Point(4, 4) nearest_indices = occ_map.query_nearest(query_geom) - self.assertIsInstance(nearest_indices, np.ndarray) + assert isinstance(nearest_indices, np.ndarray) def test_query_nearest_with_distance(self): """Test query_nearest method with return_distance=True.""" @@ -187,11 +188,11 @@ def test_query_nearest_with_distance(self): query_geom = geom.Point(1, 1) result = occ_map.query_nearest(query_geom, return_distance=True) - self.assertIsInstance(result, tuple) - self.assertEqual(len(result), 2) + assert isinstance(result, tuple) + assert len(result) == 2 indices, distances = result - self.assertIsInstance(indices, np.ndarray) - self.assertIsInstance(distances, np.ndarray) + assert isinstance(indices, np.ndarray) + assert isinstance(distances, np.ndarray) def test_query_nearest_with_max_distance(self): """Test query_nearest method with max_distance.""" @@ -200,12 +201,12 @@ def test_query_nearest_with_max_distance(self): query_geom = geom.Point(10, 10) nearest_indices = occ_map.query_nearest(query_geom, max_distance=1.0) - self.assertIsInstance(nearest_indices, np.ndarray) - self.assertEqual(len(nearest_indices), 0) + assert isinstance(nearest_indices, np.ndarray) + assert len(nearest_indices) == 0 nearest_indices = occ_map.query_nearest(query_geom, max_distance=10.0) - self.assertIsInstance(nearest_indices, np.ndarray) - self.assertTrue(len(nearest_indices) > 0) + assert isinstance(nearest_indices, np.ndarray) + assert len(nearest_indices) > 0 def test_contains_vectorized_single_point(self): """Test contains_vectorized with a single point.""" @@ -214,9 +215,9 @@ def test_contains_vectorized_single_point(self): points = np.array([[1.0, 1.0]]) # Point inside square1 and circle result = occ_map.contains_vectorized(points) - self.assertEqual(result.shape, (4, 1)) - self.assertIsInstance(result, np.ndarray) - self.assertEqual(result.dtype, bool) + assert result.shape == (4, 1) + assert isinstance(result, np.ndarray) + assert result.dtype == bool def test_contains_vectorized_multiple_points(self): """Test contains_vectorized with multiple points.""" @@ -231,28 +232,28 @@ def test_contains_vectorized_multiple_points(self): ) result = occ_map.contains_vectorized(points) - self.assertEqual(result.shape, (4, 3)) - self.assertIsInstance(result, np.ndarray) - self.assertEqual(result.dtype, bool) + assert result.shape == (4, 3) + assert isinstance(result, np.ndarray) + assert result.dtype == bool # Check specific containment results # Point [1.0, 1.0] should be in square1 (index 0) and circle (index 2) - self.assertTrue(result[0, 0]) # square1 contains point 0 - self.assertFalse(result[1, 0]) # square2 does not contain point 0 - self.assertTrue(result[2, 0]) # circle contains point 0 - self.assertFalse(result[3, 0]) # line does not contain point 0 + assert result[0, 0] # square1 contains point 0 + assert not result[1, 0] # square2 does not contain point 0 + assert result[2, 0] # circle contains point 0 + assert not result[3, 0] # line does not contain point 0 # Point [4.0, 4.0] should be in square2 (index 1) only - self.assertFalse(result[0, 1]) # square1 does not contain point 1 - self.assertTrue(result[1, 1]) # square2 contains point 1 - self.assertFalse(result[2, 1]) # circle does not contain point 1 - self.assertFalse(result[3, 1]) # line does not contain point 1 + assert not result[0, 1] # square1 does not contain point 1 + assert result[1, 1] # square2 contains point 1 + assert not result[2, 1] # circle does not contain point 1 + assert not result[3, 1] # line does not contain point 1 # Point [10.0, 10.0] should not be in any geometry - self.assertFalse(result[0, 2]) # square1 does not contain point 2 - self.assertFalse(result[1, 2]) # square2 does not contain point 2 - self.assertFalse(result[2, 2]) # circle does not contain point 2 - self.assertFalse(result[3, 2]) # line does not contain point 2 + assert not result[0, 2] # square1 does not contain point 2 + assert not result[1, 2] # square2 does not contain point 2 + assert not result[2, 2] # circle does not contain point 2 + assert not result[3, 2] # line does not contain point 2 def test_contains_vectorized_empty_points(self): """Test contains_vectorized with empty points array.""" @@ -261,23 +262,23 @@ def test_contains_vectorized_empty_points(self): points = np.empty((0, 2)) result = occ_map.contains_vectorized(points) - self.assertEqual(result.shape, (4, 0)) + assert result.shape == (4, 0) def test_empty_occupancy_map(self): """Test behavior with empty geometry list.""" occ_map = OccupancyMap2D([]) - self.assertEqual(len(occ_map), 0) - self.assertEqual(occ_map.ids, []) - self.assertEqual(len(occ_map.geometries), 0) + assert len(occ_map) == 0 + assert occ_map.ids == [] + assert len(occ_map.geometries) == 0 def test_single_geometry_map(self): """Test behavior with single geometry.""" occ_map = OccupancyMap2D([self.square1], ids=["single"]) - self.assertEqual(len(occ_map), 1) - self.assertEqual(occ_map.ids, ["single"]) - self.assertEqual(occ_map["single"], self.square1) + assert len(occ_map) == 1 + assert occ_map.ids == ["single"] + assert occ_map["single"] == self.square1 if __name__ == "__main__": diff --git a/tests/unit/geometry/test_point.py b/tests/unit/geometry/test_point.py index fc68a0e2..b7dfb558 100644 --- a/tests/unit/geometry/test_point.py +++ b/tests/unit/geometry/test_point.py @@ -2,15 +2,16 @@ from unittest.mock import MagicMock, patch import numpy as np +import pytest from py123d.geometry import Point2D, Point3D from py123d.geometry.geometry_index import Point2DIndex, Point3DIndex -class TestPoint2D(unittest.TestCase): +class TestPoint2D: """Unit tests for Point2D class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.x_coord = 3.5 self.y_coord = 4.2 @@ -22,54 +23,54 @@ def setUp(self): def test_init(self): """Test Point2D initialization.""" point = Point2D(1.0, 2.0) - self.assertEqual(point.x, 1.0) - self.assertEqual(point.y, 2.0) + assert point.x == 1.0 + assert point.y == 2.0 def test_from_array_valid(self): """Test from_array class method with valid input.""" # Mock Point2DIndex enum values point = Point2D.from_array(self.test_array) - self.assertEqual(point.x, self.x_coord) - self.assertEqual(point.y, self.y_coord) + assert point.x == self.x_coord + assert point.y == self.y_coord def test_from_array_invalid_dimensions(self): """Test from_array with invalid array dimensions.""" # 2D array should raise assertion error array_2d = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point2D.from_array(array_2d) # 3D array should raise assertion error array_3d = np.array([[[1.0]]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point2D.from_array(array_3d) def test_from_array_invalid_shape(self): """Test from_array with invalid array shape.""" array_wrong_length = np.array([1.0, 2.0, 3.0], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point2D.from_array(array_wrong_length) # Empty array empty_array = np.array([], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point2D.from_array(empty_array) def test_array_property(self): """Test the array property.""" expected_array = np.array([self.x_coord, self.y_coord], dtype=np.float64) np.testing.assert_array_equal(self.point.array, expected_array) - self.assertEqual(self.point.array.dtype, np.float64) - self.assertEqual(self.point.array.shape, (2,)) + assert self.point.array.dtype == np.float64 + assert self.point.array.shape == (2,) def test_array_like(self): """Test the __array__ behavior.""" expected_array = np.array([self.x_coord, self.y_coord], dtype=np.float32) output_array = np.array(self.point, dtype=np.float32) np.testing.assert_array_equal(output_array, expected_array) - self.assertEqual(output_array.dtype, np.float32) - self.assertEqual(output_array.shape, (2,)) + assert output_array.dtype == np.float32 + assert output_array.shape == (2,) def test_shapely_point_property(self): """Test the shapely_point property.""" @@ -80,23 +81,23 @@ def test_shapely_point_property(self): result = self.point.shapely_point mock_point.assert_called_once_with(self.x_coord, self.y_coord) - self.assertEqual(result, mock_point_instance) + assert result == mock_point_instance def test_iter(self): """Test the __iter__ method.""" coords = list(self.point) - self.assertEqual(coords, [self.x_coord, self.y_coord]) + assert coords == [self.x_coord, self.y_coord] # Test that it's actually iterable x, y = self.point - self.assertEqual(x, self.x_coord) - self.assertEqual(y, self.y_coord) + assert x == self.x_coord + assert y == self.y_coord -class TestPoint3D(unittest.TestCase): +class TestPoint3D: """Unit tests for Point3D class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.x_coord = 3.5 self.y_coord = 4.2 @@ -110,56 +111,56 @@ def setUp(self): def test_init(self): """Test Point3D initialization.""" point = Point3D(1.0, 2.0, 3.0) - self.assertEqual(point.x, 1.0) - self.assertEqual(point.y, 2.0) - self.assertEqual(point.z, 3.0) + assert point.x == 1.0 + assert point.y == 2.0 + assert point.z == 3.0 def test_from_array_valid(self): """Test from_array class method with valid input.""" # Mock Point3DIndex enum values point = Point3D.from_array(self.test_array) - self.assertEqual(point.x, self.x_coord) - self.assertEqual(point.y, self.y_coord) - self.assertEqual(point.z, self.z_coord) + assert point.x == self.x_coord + assert point.y == self.y_coord + assert point.z == self.z_coord def test_from_array_invalid_dimensions(self): """Test from_array with invalid array dimensions.""" # 2D array should raise assertion error array_2d = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point3D.from_array(array_2d) # 3D array should raise assertion error array_3d = np.array([[[1.0]]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point3D.from_array(array_3d) def test_from_array_invalid_shape(self): """Test from_array with invalid array shape.""" array_wrong_length = np.array([1.0, 2.0], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point3D.from_array(array_wrong_length) # Empty array empty_array = np.array([], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Point3D.from_array(empty_array) def test_array_property(self): """Test the array property.""" expected_array = np.array([self.x_coord, self.y_coord, self.z_coord], dtype=np.float64) np.testing.assert_array_equal(self.point.array, expected_array) - self.assertEqual(self.point.array.dtype, np.float64) - self.assertEqual(self.point.array.shape, (3,)) + assert self.point.array.dtype == np.float64 + assert self.point.array.shape == (3,) def test_array_like(self): """Test the __array__ behavior.""" expected_array = np.array([self.x_coord, self.y_coord, self.z_coord], dtype=np.float32) output_array = np.array(self.point, dtype=np.float32) np.testing.assert_array_equal(output_array, expected_array) - self.assertEqual(output_array.dtype, np.float32) - self.assertEqual(output_array.shape, (3,)) + assert output_array.dtype == np.float32 + assert output_array.shape == (3,) def test_shapely_point_property(self): """Test the shapely_point property.""" @@ -170,18 +171,18 @@ def test_shapely_point_property(self): result = self.point.shapely_point mock_point.assert_called_once_with(self.x_coord, self.y_coord, self.z_coord) - self.assertEqual(result, mock_point_instance) + assert result == mock_point_instance def test_iter(self): """Test the __iter__ method.""" coords = list(self.point) - self.assertEqual(coords, [self.x_coord, self.y_coord, self.z_coord]) + assert coords == [self.x_coord, self.y_coord, self.z_coord] # Test that it's actually iterable x, y, z = self.point - self.assertEqual(x, self.x_coord) - self.assertEqual(y, self.y_coord) - self.assertEqual(z, self.z_coord) + assert x == self.x_coord + assert y == self.y_coord + assert z == self.z_coord if __name__ == "__main__": diff --git a/tests/unit/geometry/test_polyline.py b/tests/unit/geometry/test_polyline.py index 449d351a..f5fe56db 100644 --- a/tests/unit/geometry/test_polyline.py +++ b/tests/unit/geometry/test_polyline.py @@ -1,12 +1,11 @@ -import unittest - import numpy as np +import pytest import shapely.geometry as geom from py123d.geometry import Point2D, Point3D, Polyline2D, Polyline3D, PolylineSE2, PoseSE2 -class TestPolyline2D(unittest.TestCase): +class TestPolyline2D: """Test class for Polyline2D.""" def test_from_linestring(self): @@ -14,36 +13,36 @@ def test_from_linestring(self): coords = [(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)] linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) - self.assertIsInstance(polyline, Polyline2D) - self.assertTrue(polyline.linestring.equals(linestring)) + assert isinstance(polyline, Polyline2D) + assert polyline.linestring.equals(linestring) def test_from_linestring_with_z(self): """Test creating Polyline2D from LineString with Z coordinates.""" coords = [(0.0, 0.0, 1.0), (1.0, 1.0, 2.0), (2.0, 0.0, 3.0)] linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) - self.assertIsInstance(polyline, Polyline2D) - self.assertFalse(polyline.linestring.has_z) + assert isinstance(polyline, Polyline2D) + assert not polyline.linestring.has_z def test_from_array_2d(self): """Test creating Polyline2D from 2D array.""" array = np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]], dtype=np.float32) polyline = Polyline2D.from_array(array) - self.assertIsInstance(polyline, Polyline2D) + assert isinstance(polyline, Polyline2D) np.testing.assert_array_almost_equal(polyline.array, array) def test_from_array_3d(self): """Test creating Polyline2D from 3D array.""" array = np.array([[0.0, 0.0, 1.0], [1.0, 1.0, 2.0], [2.0, 0.0, 3.0]], dtype=np.float32) polyline = Polyline2D.from_array(array) - self.assertIsInstance(polyline, Polyline2D) + assert isinstance(polyline, Polyline2D) expected = array[:, :2] np.testing.assert_array_almost_equal(polyline.array, expected) def test_from_array_invalid_shape(self): """Test creating Polyline2D from invalid array shape.""" array = np.array([[0.0], [1.0], [2.0]], dtype=np.float32) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): Polyline2D.from_array(array) def test_array_property(self): @@ -52,8 +51,8 @@ def test_array_property(self): linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) array = polyline.array - self.assertEqual(array.shape, (3, 2)) - self.assertEqual(array.dtype, np.float64) + assert array.shape == (3, 2) + assert array.dtype == np.float64 np.testing.assert_array_almost_equal(array, coords) def test_length_property(self): @@ -61,7 +60,7 @@ def test_length_property(self): coords = [(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)] linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) - self.assertEqual(polyline.length, 2.0) + assert polyline.length == 2.0 def test_interpolate_single_distance(self): """Test interpolation with single distance.""" @@ -69,9 +68,9 @@ def test_interpolate_single_distance(self): linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) point = polyline.interpolate(1.0) - self.assertIsInstance(point, Point2D) - self.assertEqual(point.x, 1.0) - self.assertEqual(point.y, 0.0) + assert isinstance(point, Point2D) + assert point.x == 1.0 + assert point.y == 0.0 def test_interpolate_multiple_distances(self): """Test interpolation with multiple distances.""" @@ -79,8 +78,8 @@ def test_interpolate_multiple_distances(self): linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) points = polyline.interpolate(np.array([0.0, 1.0, 2.0])) - self.assertIsInstance(points, np.ndarray) - self.assertEqual(points.shape, (3, 2)) + assert isinstance(points, np.ndarray) + assert points.shape == (3, 2) expected = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) np.testing.assert_array_almost_equal(points, expected) @@ -90,9 +89,9 @@ def test_interpolate_normalized(self): linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) point = polyline.interpolate(0.5, normalized=True) - self.assertIsInstance(point, Point2D) - self.assertEqual(point.x, 1.0) - self.assertEqual(point.y, 0.0) + assert isinstance(point, Point2D) + assert point.x == 1.0 + assert point.y == 0.0 def test_project_point2d(self): """Test projecting Point2D onto polyline.""" @@ -101,7 +100,7 @@ def test_project_point2d(self): polyline = Polyline2D.from_linestring(linestring) point = Point2D(1.0, 1.0) distance = polyline.project(point) - self.assertEqual(distance, 1.0) + assert distance == 1.0 def test_project_statese2(self): """Test projecting StateSE2 onto polyline.""" @@ -110,7 +109,7 @@ def test_project_statese2(self): polyline = Polyline2D.from_linestring(linestring) state = PoseSE2(1.0, 1.0, 0.0) distance = polyline.project(state) - self.assertEqual(distance, 1.0) + assert distance == 1.0 def test_polyline_se2_property(self): """Test polyline_se2 property.""" @@ -118,10 +117,10 @@ def test_polyline_se2_property(self): linestring = geom.LineString(coords) polyline = Polyline2D.from_linestring(linestring) polyline_se2 = polyline.polyline_se2 - self.assertIsInstance(polyline_se2, PolylineSE2) + assert isinstance(polyline_se2, PolylineSE2) -class TestPolylineSE2(unittest.TestCase): +class TestPolylineSE2: """Test class for PolylineSE2.""" def test_from_linestring(self): @@ -129,60 +128,60 @@ def test_from_linestring(self): coords = [(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)] linestring = geom.LineString(coords) polyline = PolylineSE2.from_linestring(linestring) - self.assertIsInstance(polyline, PolylineSE2) - self.assertEqual(polyline.array.shape, (3, 3)) + assert isinstance(polyline, PolylineSE2) + assert polyline.array.shape == (3, 3) def test_from_array_2d(self): """Test creating PolylineSE2 from 2D array.""" array = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]], dtype=np.float32) polyline = PolylineSE2.from_array(array) - self.assertIsInstance(polyline, PolylineSE2) - self.assertEqual(polyline.array.shape, (3, 3)) + assert isinstance(polyline, PolylineSE2) + assert polyline.array.shape == (3, 3) def test_from_array_se2(self): """Test creating PolylineSE2 from SE2 array.""" array = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float32) polyline = PolylineSE2.from_array(array) - self.assertIsInstance(polyline, PolylineSE2) + assert isinstance(polyline, PolylineSE2) np.testing.assert_array_almost_equal(polyline.array, array) def test_from_array_invalid_shape(self): """Test creating PolylineSE2 from invalid array shape.""" array = np.array([[0.0], [1.0], [2.0]], dtype=np.float32) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): PolylineSE2.from_array(array) def test_length_property(self): """Test length property.""" array = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) polyline = PolylineSE2.from_array(array) - self.assertEqual(polyline.length, 2.0) + assert polyline.length == 2.0 def test_interpolate_single_distance(self): """Test interpolation with single distance.""" array = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) polyline = PolylineSE2.from_array(array) state = polyline.interpolate(1.0) - self.assertIsInstance(state, PoseSE2) - self.assertEqual(state.x, 1.0) - self.assertEqual(state.y, 0.0) + assert isinstance(state, PoseSE2) + assert state.x == 1.0 + assert state.y == 0.0 def test_interpolate_multiple_distances(self): """Test interpolation with multiple distances.""" array = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) polyline = PolylineSE2.from_array(array) states = polyline.interpolate(np.array([0.0, 1.0, 2.0])) - self.assertIsInstance(states, np.ndarray) - self.assertEqual(states.shape, (3, 3)) + assert isinstance(states, np.ndarray) + assert states.shape == (3, 3) def test_interpolate_normalized(self): """Test normalized interpolation.""" array = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=np.float64) polyline = PolylineSE2.from_array(array) state = polyline.interpolate(0.5, normalized=True) - self.assertIsInstance(state, PoseSE2) - self.assertEqual(state.x, 1.0) - self.assertEqual(state.y, 0.0) + assert isinstance(state, PoseSE2) + assert state.x == 1.0 + assert state.y == 0.0 def test_project_point2d(self): """Test projecting Point2D onto SE2 polyline.""" @@ -190,7 +189,7 @@ def test_project_point2d(self): polyline = PolylineSE2.from_array(array) point = Point2D(1.0, 1.0) distance = polyline.project(point) - self.assertEqual(distance, 1.0) + assert distance == 1.0 def test_project_statese2(self): """Test projecting StateSE2 onto SE2 polyline.""" @@ -198,10 +197,10 @@ def test_project_statese2(self): polyline = PolylineSE2.from_array(array) state = PoseSE2(1.0, 1.0, 0.0) distance = polyline.project(state) - self.assertEqual(distance, 1.0) + assert distance == 1.0 -class TestPolyline3D(unittest.TestCase): +class TestPolyline3D: """Test class for Polyline3D.""" def test_from_linestring_with_z(self): @@ -209,32 +208,32 @@ def test_from_linestring_with_z(self): coords = [(0.0, 0.0, 1.0), (1.0, 1.0, 2.0), (2.0, 0.0, 3.0)] linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) - self.assertIsInstance(polyline, Polyline3D) - self.assertTrue(polyline.linestring.has_z) + assert isinstance(polyline, Polyline3D) + assert polyline.linestring.has_z def test_from_linestring_without_z(self): """Test creating Polyline3D from LineString without Z coordinates.""" coords = [(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)] linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) - self.assertIsInstance(polyline, Polyline3D) - self.assertTrue(polyline.linestring.has_z) + assert isinstance(polyline, Polyline3D) + assert polyline.linestring.has_z def test_from_array(self): """Test creating Polyline3D from 3D array.""" array = np.array([[0.0, 0.0, 1.0], [1.0, 1.0, 2.0], [2.0, 0.0, 3.0]], dtype=np.float64) polyline = Polyline3D.from_array(array) - self.assertIsInstance(polyline, Polyline3D) + assert isinstance(polyline, Polyline3D) np.testing.assert_array_almost_equal(polyline.array, array) def test_from_array_invalid_shape(self): """Test creating Polyline3D from invalid array shape.""" array = np.array([[0.0, 0.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0]], dtype=np.float64) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): Polyline3D.from_array(array) array = np.array([[0.0], [1.0]], dtype=np.float64) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): Polyline3D.from_array(array) def test_array_property(self): @@ -243,8 +242,8 @@ def test_array_property(self): linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) array = polyline.array - self.assertEqual(array.shape, (3, 3)) - self.assertEqual(array.dtype, np.float64) + assert array.shape == (3, 3) + assert array.dtype == np.float64 np.testing.assert_array_almost_equal(array, coords) def test_polyline_2d_property(self): @@ -253,8 +252,8 @@ def test_polyline_2d_property(self): linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) polyline_2d = polyline.polyline_2d - self.assertIsInstance(polyline_2d, Polyline2D) - self.assertFalse(polyline_2d.linestring.has_z) + assert isinstance(polyline_2d, Polyline2D) + assert not polyline_2d.linestring.has_z def test_polyline_se2_property(self): """Test polyline_se2 property.""" @@ -262,24 +261,24 @@ def test_polyline_se2_property(self): linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) polyline_se2 = polyline.polyline_se2 - self.assertIsInstance(polyline_se2, PolylineSE2) + assert isinstance(polyline_se2, PolylineSE2) def test_length_property(self): """Test length property.""" coords = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)] linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) - self.assertEqual(polyline.length, 2.0) + assert polyline.length == 2.0 coords = [(0.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 0.0, 2.0)] linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) - self.assertEqual(polyline.length, 2.0) + assert polyline.length == 2.0 coords = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (2.0, 2.0, 2.0)] linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) - self.assertEqual(polyline.length, 2 * np.sqrt(3)) + assert polyline.length == 2 * np.sqrt(3) def test_interpolate_single_distance(self): """Test interpolation with single distance.""" @@ -287,10 +286,10 @@ def test_interpolate_single_distance(self): linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) point = polyline.interpolate(np.sqrt(2)) - self.assertIsInstance(point, Point3D) - self.assertEqual(point.x, 1.0) - self.assertEqual(point.y, 0.0) - self.assertEqual(point.z, 1.0) + assert isinstance(point, Point3D) + assert point.x == 1.0 + assert point.y == 0.0 + assert point.z == 1.0 def test_interpolate_multiple_distances(self): """Test interpolation with multiple distances.""" @@ -298,8 +297,8 @@ def test_interpolate_multiple_distances(self): linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) points = polyline.interpolate(np.array([0.0, 1.0, 2.0])) - self.assertIsInstance(points, np.ndarray) - self.assertEqual(points.shape, (3, 3)) + assert isinstance(points, np.ndarray) + assert points.shape == (3, 3) def test_interpolate_normalized(self): """Test normalized interpolation.""" @@ -307,10 +306,10 @@ def test_interpolate_normalized(self): linestring = geom.LineString(coords) polyline = Polyline3D.from_linestring(linestring) point = polyline.interpolate(0.5, normalized=True) - self.assertIsInstance(point, Point3D) - self.assertEqual(point.x, 1.0) - self.assertEqual(point.y, 0.0) - self.assertEqual(point.z, 1.0) + assert isinstance(point, Point3D) + assert point.x == 1.0 + assert point.y == 0.0 + assert point.z == 1.0 def test_project_point2d(self): """Test projecting Point2D onto 3D polyline.""" @@ -319,7 +318,7 @@ def test_project_point2d(self): polyline = Polyline3D.from_linestring(linestring) point = Point2D(1.0, 1.0) distance = polyline.project(point) - self.assertEqual(distance, 1.0) + assert distance == 1.0 def test_project_point3d(self): """Test projecting Point3D onto 3D polyline.""" @@ -328,4 +327,4 @@ def test_project_point3d(self): polyline = Polyline3D.from_linestring(linestring) point = Point3D(1.0, 1.0, 1.0) distance = polyline.project(point) - self.assertEqual(distance, 1.0) + assert distance == 1.0 diff --git a/tests/unit/geometry/test_rotation.py b/tests/unit/geometry/test_rotation.py index 1e226fd7..3b7d6d1d 100644 --- a/tests/unit/geometry/test_rotation.py +++ b/tests/unit/geometry/test_rotation.py @@ -1,15 +1,16 @@ import unittest import numpy as np +import pytest from py123d.geometry.geometry_index import EulerAnglesIndex, QuaternionIndex from py123d.geometry.rotation import EulerAngles, Quaternion -class TestEulerAngles(unittest.TestCase): +class TestEulerAngles: """Unit tests for EulerAngles class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.roll = 0.1 self.pitch = 0.2 @@ -23,23 +24,23 @@ def setUp(self): def test_init(self): """Test EulerAngles initialization.""" euler = EulerAngles(roll=0.1, pitch=0.2, yaw=0.3) - self.assertEqual(euler.roll, 0.1) - self.assertEqual(euler.pitch, 0.2) - self.assertEqual(euler.yaw, 0.3) + assert euler.roll == 0.1 + assert euler.pitch == 0.2 + assert euler.yaw == 0.3 def test_from_array_valid(self): """Test from_array class method with valid input.""" euler = EulerAngles.from_array(self.test_array) - self.assertIsInstance(euler, EulerAngles) - self.assertAlmostEqual(euler.roll, self.roll) - self.assertAlmostEqual(euler.pitch, self.pitch) - self.assertAlmostEqual(euler.yaw, self.yaw) + assert isinstance(euler, EulerAngles) + assert euler.roll == pytest.approx(self.roll) + assert euler.pitch == pytest.approx(self.pitch) + assert euler.yaw == pytest.approx(self.yaw) def test_from_array_invalid_shape(self): """Test from_array with invalid array shape.""" - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): EulerAngles.from_array(np.array([1, 2])) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): EulerAngles.from_array(np.array([[1, 2, 3]])) def test_from_array_copy(self): @@ -49,37 +50,37 @@ def test_from_array_copy(self): euler_no_copy = EulerAngles.from_array(original_array, copy=False) original_array[0] = 999.0 - self.assertNotEqual(euler_copy.roll, 999.0) - self.assertEqual(euler_no_copy.roll, 999.0) + assert euler_copy.roll != 999.0 + assert euler_no_copy.roll == 999.0 def test_from_rotation_matrix(self): """Test from_rotation_matrix class method.""" identity_matrix = np.eye(3) euler = EulerAngles.from_rotation_matrix(identity_matrix) - self.assertAlmostEqual(euler.roll, 0.0, places=10) - self.assertAlmostEqual(euler.pitch, 0.0, places=10) - self.assertAlmostEqual(euler.yaw, 0.0, places=10) + assert euler.roll == pytest.approx(0.0, abs=1e-10) + assert euler.pitch == pytest.approx(0.0, abs=1e-10) + assert euler.yaw == pytest.approx(0.0, abs=1e-10) def test_from_rotation_matrix_invalid(self): """Test from_rotation_matrix with invalid input.""" - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): EulerAngles.from_rotation_matrix(np.array([[1, 2]])) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): EulerAngles.from_rotation_matrix(np.array([1, 2, 3])) def test_array_property(self): """Test array property.""" array = self.euler_angles.array - self.assertEqual(array.shape, (3,)) - self.assertEqual(array[EulerAnglesIndex.ROLL], self.roll) - self.assertEqual(array[EulerAnglesIndex.PITCH], self.pitch) - self.assertEqual(array[EulerAnglesIndex.YAW], self.yaw) + assert array.shape == (3,) + assert array[EulerAnglesIndex.ROLL] == self.roll + assert array[EulerAnglesIndex.PITCH] == self.pitch + assert array[EulerAnglesIndex.YAW] == self.yaw -class TestQuaternion(unittest.TestCase): +class TestQuaternion: """Unit tests for Quaternion class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.qw = 1.0 self.qx = 0.0 @@ -95,24 +96,24 @@ def setUp(self): def test_init(self): """Test Quaternion initialization.""" quat = Quaternion(1.0, 0.0, 0.0, 0.0) - self.assertEqual(quat.qw, 1.0) - self.assertEqual(quat.qx, 0.0) - self.assertEqual(quat.qy, 0.0) - self.assertEqual(quat.qz, 0.0) + assert quat.qw == 1.0 + assert quat.qx == 0.0 + assert quat.qy == 0.0 + assert quat.qz == 0.0 def test_from_array_valid(self): """Test from_array class method with valid input.""" quat = Quaternion.from_array(self.test_array) - self.assertAlmostEqual(quat.qw, self.qw) - self.assertAlmostEqual(quat.qx, self.qx) - self.assertAlmostEqual(quat.qy, self.qy) - self.assertAlmostEqual(quat.qz, self.qz) + assert quat.qw == pytest.approx(self.qw) + assert quat.qx == pytest.approx(self.qx) + assert quat.qy == pytest.approx(self.qy) + assert quat.qz == pytest.approx(self.qz) def test_from_array_invalid_shape(self): """Test from_array with invalid array shape.""" - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Quaternion.from_array(np.array([1, 2, 3])) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Quaternion.from_array(np.array([[1, 2, 3, 4]])) def test_from_array_copy(self): @@ -122,60 +123,60 @@ def test_from_array_copy(self): quat_no_copy = Quaternion.from_array(original_array, copy=False) original_array[0] = 999.0 - self.assertNotEqual(quat_copy.qw, 999.0) - self.assertEqual(quat_no_copy.qw, 999.0) + assert quat_copy.qw != 999.0 + assert quat_no_copy.qw == 999.0 def test_from_rotation_matrix(self): """Test from_rotation_matrix class method.""" identity_matrix = np.eye(3) quat = Quaternion.from_rotation_matrix(identity_matrix) - self.assertAlmostEqual(quat.qw, 1.0, places=10) - self.assertAlmostEqual(quat.qx, 0.0, places=10) - self.assertAlmostEqual(quat.qy, 0.0, places=10) - self.assertAlmostEqual(quat.qz, 0.0, places=10) + assert quat.qw == pytest.approx(1.0, abs=1e-10) + assert quat.qx == pytest.approx(0.0, abs=1e-10) + assert quat.qy == pytest.approx(0.0, abs=1e-10) + assert quat.qz == pytest.approx(0.0, abs=1e-10) def test_from_rotation_matrix_invalid(self): """Test from_rotation_matrix with invalid input.""" - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Quaternion.from_rotation_matrix(np.array([[1, 2]])) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Quaternion.from_rotation_matrix(np.array([1, 2, 3])) def test_from_euler_angles(self): """Test from_euler_angles class method.""" euler = EulerAngles(0.0, 0.0, 0.0) quat = Quaternion.from_euler_angles(euler) - self.assertAlmostEqual(quat.qw, 1.0, places=10) - self.assertAlmostEqual(quat.qx, 0.0, places=10) - self.assertAlmostEqual(quat.qy, 0.0, places=10) - self.assertAlmostEqual(quat.qz, 0.0, places=10) + assert quat.qw == pytest.approx(1.0, abs=1e-10) + assert quat.qx == pytest.approx(0.0, abs=1e-10) + assert quat.qy == pytest.approx(0.0, abs=1e-10) + assert quat.qz == pytest.approx(0.0, abs=1e-10) def test_array_property(self): """Test array property.""" array = self.quaternion.array - self.assertEqual(array.shape, (4,)) + assert array.shape == (4,) np.testing.assert_array_equal(array, self.test_array) def test_pyquaternion_property(self): """Test pyquaternion property.""" pyquat = self.quaternion.pyquaternion - self.assertEqual(pyquat.w, self.qw) - self.assertEqual(pyquat.x, self.qx) - self.assertEqual(pyquat.y, self.qy) - self.assertEqual(pyquat.z, self.qz) + assert pyquat.w == self.qw + assert pyquat.x == self.qx + assert pyquat.y == self.qy + assert pyquat.z == self.qz def test_euler_angles_property(self): """Test euler_angles property.""" euler = self.quaternion.euler_angles - self.assertIsInstance(euler, EulerAngles) - self.assertAlmostEqual(euler.roll, 0.0, places=10) - self.assertAlmostEqual(euler.pitch, 0.0, places=10) - self.assertAlmostEqual(euler.yaw, 0.0, places=10) + assert isinstance(euler, EulerAngles) + assert euler.roll == pytest.approx(0.0, abs=1e-10) + assert euler.pitch == pytest.approx(0.0, abs=1e-10) + assert euler.yaw == pytest.approx(0.0, abs=1e-10) def test_rotation_matrix_property(self): """Test rotation_matrix property.""" rot_matrix = self.quaternion.rotation_matrix - self.assertEqual(rot_matrix.shape, (3, 3)) + assert rot_matrix.shape == (3, 3) np.testing.assert_array_almost_equal(rot_matrix, np.eye(3)) diff --git a/tests/unit/geometry/test_vector.py b/tests/unit/geometry/test_vector.py index 4f3b159e..d8f48ce8 100644 --- a/tests/unit/geometry/test_vector.py +++ b/tests/unit/geometry/test_vector.py @@ -1,14 +1,15 @@ import unittest import numpy as np +import pytest from py123d.geometry import Vector2D, Vector2DIndex, Vector3D, Vector3DIndex -class TestVector2D(unittest.TestCase): +class TestVector2D: """Unit tests for Vector2D class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.x_coord = 3.5 self.y_coord = 4.2 @@ -20,74 +21,74 @@ def setUp(self): def test_init(self): """Test Vector2D initialization.""" vector = Vector2D(1.0, 2.0) - self.assertEqual(vector.x, 1.0) - self.assertEqual(vector.y, 2.0) + assert vector.x == 1.0 + assert vector.y == 2.0 def test_from_array_valid(self): """Test from_array class method with valid input.""" vector = Vector2D.from_array(self.test_array) - self.assertEqual(vector.x, self.x_coord) - self.assertEqual(vector.y, self.y_coord) + assert vector.x == self.x_coord + assert vector.y == self.y_coord def test_from_array_invalid_dimensions(self): """Test from_array with invalid array dimensions.""" # 2D array should raise assertion error array_2d = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector2D.from_array(array_2d) # 3D array should raise assertion error array_3d = np.array([[[1.0]]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector2D.from_array(array_3d) def test_from_array_invalid_shape(self): """Test from_array with invalid array shape.""" array_wrong_length = np.array([1.0, 2.0, 3.0], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector2D.from_array(array_wrong_length) # Empty array empty_array = np.array([], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector2D.from_array(empty_array) def test_array_property(self): """Test the array property.""" expected_array = np.array([self.x_coord, self.y_coord], dtype=np.float64) np.testing.assert_array_equal(self.vector.array, expected_array) - self.assertEqual(self.vector.array.dtype, np.float64) - self.assertEqual(self.vector.array.shape, (2,)) + assert self.vector.array.dtype == np.float64 + assert self.vector.array.shape == (2,) def test_array_like(self): """Test the __array__ behavior.""" expected_array = np.array([self.x_coord, self.y_coord], dtype=np.float32) output_array = np.array(self.vector, dtype=np.float32) np.testing.assert_array_equal(output_array, expected_array) - self.assertEqual(output_array.dtype, np.float32) - self.assertEqual(output_array.shape, (2,)) + assert output_array.dtype == np.float32 + assert output_array.shape == (2,) def test_iter(self): """Test the __iter__ method.""" coords = list(self.vector) - self.assertEqual(coords, [self.x_coord, self.y_coord]) + assert coords == [self.x_coord, self.y_coord] # Test that it's actually iterable x, y = self.vector - self.assertEqual(x, self.x_coord) - self.assertEqual(y, self.y_coord) + assert x == self.x_coord + assert y == self.y_coord def test_hash(self): """Test the __hash__ method.""" vector_dict = {self.vector: "test"} - self.assertIn(self.vector, vector_dict) - self.assertEqual(vector_dict[self.vector], "test") + assert self.vector in vector_dict + assert vector_dict[self.vector] == "test" -class TestVector3D(unittest.TestCase): +class TestVector3D: """Unit tests for Vector3D class.""" - def setUp(self): + def setup_method(self): """Set up test fixtures.""" self.x_coord = 3.5 self.y_coord = 4.2 @@ -101,71 +102,71 @@ def setUp(self): def test_init(self): """Test Vector3D initialization.""" vector = Vector3D(1.0, 2.0, 3.0) - self.assertEqual(vector.x, 1.0) - self.assertEqual(vector.y, 2.0) - self.assertEqual(vector.z, 3.0) + assert vector.x == 1.0 + assert vector.y == 2.0 + assert vector.z == 3.0 def test_from_array_valid(self): """Test from_array class method with valid input.""" vector = Vector3D.from_array(self.test_array) - self.assertEqual(vector.x, self.x_coord) - self.assertEqual(vector.y, self.y_coord) - self.assertEqual(vector.z, self.z_coord) + assert vector.x == self.x_coord + assert vector.y == self.y_coord + assert vector.z == self.z_coord def test_from_array_invalid_dimensions(self): """Test from_array with invalid array dimensions.""" # 2D array should raise assertion error array_2d = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector3D.from_array(array_2d) # 3D array should raise assertion error array_3d = np.array([[[1.0]]], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector3D.from_array(array_3d) def test_from_array_invalid_shape(self): """Test from_array with invalid array shape.""" array_wrong_length = np.array([1.0, 2.0], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector3D.from_array(array_wrong_length) # Empty array empty_array = np.array([], dtype=np.float64) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): Vector3D.from_array(empty_array) def test_array_property(self): """Test the array property.""" expected_array = np.array([self.x_coord, self.y_coord, self.z_coord], dtype=np.float64) np.testing.assert_array_equal(self.vector.array, expected_array) - self.assertEqual(self.vector.array.dtype, np.float64) - self.assertEqual(self.vector.array.shape, (3,)) + assert self.vector.array.dtype == np.float64 + assert self.vector.array.shape == (3,) def test_array_like(self): """Test the __array__ behavior.""" expected_array = np.array([self.x_coord, self.y_coord, self.z_coord], dtype=np.float32) output_array = np.array(self.vector, dtype=np.float32) np.testing.assert_array_equal(output_array, expected_array) - self.assertEqual(output_array.dtype, np.float32) - self.assertEqual(output_array.shape, (3,)) + assert output_array.dtype == np.float32 + assert output_array.shape == (3,) def test_iter(self): """Test the __iter__ method.""" coords = list(self.vector) - self.assertEqual(coords, [self.x_coord, self.y_coord, self.z_coord]) + assert coords == [self.x_coord, self.y_coord, self.z_coord] # Test that it's actually iterable x, y, z = self.vector - self.assertEqual(x, self.x_coord) - self.assertEqual(y, self.y_coord) - self.assertEqual(z, self.z_coord) + assert x == self.x_coord + assert y == self.y_coord + assert z == self.z_coord def test_hash(self): """Test the __hash__ method.""" vector_dict = {self.vector: "test"} - self.assertIn(self.vector, vector_dict) - self.assertEqual(vector_dict[self.vector], "test") + assert self.vector in vector_dict + assert vector_dict[self.vector] == "test" if __name__ == "__main__": diff --git a/tests/unit/geometry/transform/test_transform_consistency.py b/tests/unit/geometry/transform/test_transform_consistency.py index a50afcc5..fcd64726 100644 --- a/tests/unit/geometry/transform/test_transform_consistency.py +++ b/tests/unit/geometry/transform/test_transform_consistency.py @@ -27,10 +27,10 @@ from py123d.geometry.utils.rotation_utils import get_rotation_matrices_from_euler_array -class TestTransformConsistency(unittest.TestCase): +class TestTransformConsistency: """Tests to ensure consistency between different transformation functions.""" - def setUp(self): + def setup_method(self): self.decimal = 4 # Decimal places for np.testing.assert_array_almost_equal self.num_consistency_tests = 10 # Number of random test cases for consistency checks @@ -397,8 +397,8 @@ def test_transform_empty_arrays(self) -> None: result_se2_poses = convert_absolute_to_relative_se2_array(reference_se2, empty_se2_poses) result_2d_points = convert_absolute_to_relative_points_2d_array(reference_se2, empty_2d_points) - self.assertEqual(result_se2_poses.shape, (0, len(PoseSE2Index))) - self.assertEqual(result_2d_points.shape, (0, len(Point2DIndex))) + assert result_se2_poses.shape == (0, len(PoseSE2Index)) + assert result_2d_points.shape == (0, len(Point2DIndex)) # Test SE3 empty arrays empty_se3_poses = np.array([], dtype=np.float64).reshape(0, len(EulerStateSE3Index)) @@ -407,8 +407,8 @@ def test_transform_empty_arrays(self) -> None: result_se3_poses = convert_absolute_to_relative_euler_se3_array(reference_se3, empty_se3_poses) result_3d_points = convert_absolute_to_relative_points_3d_array(reference_se3, empty_3d_points) - self.assertEqual(result_se3_poses.shape, (0, len(EulerStateSE3Index))) - self.assertEqual(result_3d_points.shape, (0, len(Point3DIndex))) + assert result_se3_poses.shape == (0, len(EulerStateSE3Index)) + assert result_3d_points.shape == (0, len(Point3DIndex)) def test_transform_identity_operations(self) -> None: """Test that transforms with identity reference frames work correctly""" diff --git a/tests/unit/geometry/transform/test_transform_euler_se3.py b/tests/unit/geometry/transform/test_transform_euler_se3.py index 8b146fd1..6ccbc21e 100644 --- a/tests/unit/geometry/transform/test_transform_euler_se3.py +++ b/tests/unit/geometry/transform/test_transform_euler_se3.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import numpy.typing as npt @@ -16,9 +14,9 @@ ) -class TestTransformEulerSE3(unittest.TestCase): +class TestTransformEulerSE3: - def setUp(self): + def setup_method(self): self.decimal = 6 # Decimal places for np.testing.assert_array_almost_equal self.num_consistency_tests = 10 # Number of random test cases for consistency checks diff --git a/tests/unit/geometry/transform/test_transform_se2.py b/tests/unit/geometry/transform/test_transform_se2.py index dc9d9f24..28f20983 100644 --- a/tests/unit/geometry/transform/test_transform_se2.py +++ b/tests/unit/geometry/transform/test_transform_se2.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import numpy.typing as npt @@ -19,9 +17,9 @@ ) -class TestTransformSE2(unittest.TestCase): +class TestTransformSE2: - def setUp(self): + def setup_method(self): self.decimal = 6 # Decimal places for np.testing.assert_array_almost_equal def _get_random_se2_array(self, num_poses: int) -> npt.NDArray[np.float64]: diff --git a/tests/unit/geometry/transform/test_transform_se3.py b/tests/unit/geometry/transform/test_transform_se3.py index 4f6916f3..e982e09b 100644 --- a/tests/unit/geometry/transform/test_transform_se3.py +++ b/tests/unit/geometry/transform/test_transform_se3.py @@ -24,9 +24,9 @@ ) -class TestTransformSE3(unittest.TestCase): +class TestTransformSE3: - def setUp(self): + def setup_method(self): euler_se3_a = EulerStateSE3( x=1.0, y=2.0, diff --git a/tests/unit/geometry/utils/test_bounding_box_utils.py b/tests/unit/geometry/utils/test_bounding_box_utils.py index 9b912b19..93ddfc16 100644 --- a/tests/unit/geometry/utils/test_bounding_box_utils.py +++ b/tests/unit/geometry/utils/test_bounding_box_utils.py @@ -24,9 +24,9 @@ from py123d.geometry.vector import Vector3D -class TestBoundingBoxUtils(unittest.TestCase): +class TestBoundingBoxUtils: - def setUp(self): + def setup_method(self): self._num_consistency_checks = 10 self._max_pose_xyz = 100.0 self._max_extent = 200.0 @@ -106,7 +106,7 @@ def test_corners_2d_array_to_polygon_array_one_dim(self): expected_polygon = shapely.geometry.Polygon(corners_array) np.testing.assert_allclose(polygon.area, expected_polygon.area, atol=1e-6) - self.assertTrue(polygon.equals(expected_polygon)) + assert polygon.equals(expected_polygon) def test_corners_2d_array_to_polygon_array_n_dim(self): corners_array = np.array( @@ -131,10 +131,10 @@ def test_corners_2d_array_to_polygon_array_n_dim(self): expected_polygon_2 = shapely.geometry.Polygon(corners_array[1]) np.testing.assert_allclose(polygons[0].area, expected_polygon_1.area, atol=1e-6) - self.assertTrue(polygons[0].equals(expected_polygon_1)) + assert polygons[0].equals(expected_polygon_1) np.testing.assert_allclose(polygons[1].area, expected_polygon_2.area, atol=1e-6) - self.assertTrue(polygons[1].equals(expected_polygon_2)) + assert polygons[1].equals(expected_polygon_2) def test_corners_2d_array_to_polygon_array_zero_dim(self): corners_array = np.zeros((0, 4, 2), dtype=np.float64) @@ -154,7 +154,7 @@ def test_bbse2_array_to_polygon_array_one_dim(self): expected_polygon = shapely.geometry.Polygon(expected_corners) np.testing.assert_allclose(polygon.area, expected_polygon.area, atol=1e-6) - self.assertTrue(polygon.equals(expected_polygon)) + assert polygon.equals(expected_polygon) def test_bbse2_array_to_polygon_array_n_dim(self): bounding_box_se2_array = np.array([1.0, 2.0, 0.0, 4.0, 2.0]) @@ -171,7 +171,7 @@ def test_bbse2_array_to_polygon_array_n_dim(self): for polygon in polygons: np.testing.assert_allclose(polygon.area, expected_polygon.area, atol=1e-6) - self.assertTrue(polygon.equals(expected_polygon)) + assert polygon.equals(expected_polygon) def test_bbse2_array_to_polygon_array_zero_dim(self): bounding_box_se2_array = np.zeros((0, 5), dtype=np.float64) diff --git a/tests/unit/geometry/utils/test_rotation_utils.py b/tests/unit/geometry/utils/test_rotation_utils.py index f298af7a..5483e976 100644 --- a/tests/unit/geometry/utils/test_rotation_utils.py +++ b/tests/unit/geometry/utils/test_rotation_utils.py @@ -3,6 +3,7 @@ import numpy as np import numpy.typing as npt +import pytest from pyquaternion import Quaternion as PyQuaternion from py123d.geometry.geometry_index import EulerAnglesIndex, QuaternionIndex @@ -62,9 +63,9 @@ def _get_rotation_matrix_helper(euler_array: npt.NDArray[np.float64]) -> npt.NDA return R_z @ R_y @ R_x -class TestRotationUtils(unittest.TestCase): +class TestRotationUtils: - def setUp(self): + def setup_method(self): pass def _get_random_quaternion(self) -> npt.NDArray[np.float64]: @@ -135,11 +136,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) conjugate_quaternion_array(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) conjugate_quaternion_array(invalid_quat) @@ -177,11 +178,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) get_euler_array_from_quaternion_array(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) get_euler_array_from_quaternion_array(invalid_quat) @@ -221,11 +222,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((0, 3)) # (0, 3) rotation matrix shape (invalid) get_euler_array_from_rotation_matrices(invalid_rot) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((3, 3, 8)) # (3, 3, 8) rotation matrix shape (invalid) get_euler_array_from_rotation_matrices(invalid_rot) @@ -246,11 +247,11 @@ def test_get_euler_array_from_rotation_matrix(self): ) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((3,)) # (0, 3) rotation matrix shape (invalid) get_euler_array_from_rotation_matrix(invalid_rot) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((3, 8)) # (3, 8) rotation matrix shape (invalid) get_euler_array_from_rotation_matrix(invalid_rot) @@ -290,11 +291,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) get_q_bar_matrices(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) get_q_bar_matrices(invalid_quat) @@ -333,11 +334,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) get_q_matrices(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) get_q_matrices(invalid_quat) @@ -387,11 +388,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_euler = np.zeros((0,)) # Zero euler angles (invalid) get_quaternion_array_from_euler_array(invalid_euler) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_euler = np.zeros((3, 8)) # Zero euler angles (invalid) get_quaternion_array_from_euler_array(invalid_euler) @@ -436,11 +437,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((0, 3)) # (0, 3) rotation matrix shape (invalid) get_quaternion_array_from_rotation_matrices(invalid_rot) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((3, 3, 8)) # (3, 3, 8) rotation matrix shape (invalid) get_quaternion_array_from_rotation_matrices(invalid_rot) @@ -465,11 +466,11 @@ def test_get_quaternion_array_from_rotation_matrix(self): ) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((3,)) # (0, 3) rotation matrix shape (invalid) get_quaternion_array_from_rotation_matrix(invalid_rot) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_rot = np.zeros((3, 8)) # (3, 8) rotation matrix shape (invalid) get_quaternion_array_from_rotation_matrix(invalid_rot) @@ -504,11 +505,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) normalize_quaternion_array(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) normalize_quaternion_array(invalid_quat) @@ -545,11 +546,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_euler = np.zeros((0, 5)) # Zero euler angles (invalid) get_rotation_matrices_from_euler_array(invalid_euler) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_euler = np.zeros((3, 8)) # Zero euler angles (invalid) get_rotation_matrices_from_euler_array(invalid_euler) @@ -589,11 +590,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) get_rotation_matrices_from_quaternion_array(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) get_rotation_matrices_from_quaternion_array(invalid_quat) @@ -615,11 +616,11 @@ def test_get_rotation_matrix_from_euler_array(self): ) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_euler = np.zeros((0,)) # Zero euler angles (invalid) get_rotation_matrix_from_euler_array(invalid_euler) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_euler = np.zeros((8,)) # Zero euler angles (invalid) get_rotation_matrix_from_euler_array(invalid_euler) @@ -641,11 +642,11 @@ def test_get_rotation_matrix_from_quaternion_array(self): ) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) get_rotation_matrix_from_quaternion_array(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((8,)) # Zero quaternion (invalid) get_rotation_matrix_from_quaternion_array(invalid_quat) @@ -686,11 +687,11 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((0,)) # Zero quaternion (invalid) invert_quaternion_array(invalid_quat) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) invert_quaternion_array(invalid_quat) @@ -735,12 +736,12 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test invalid input - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat1 = np.zeros((0,)) # Zero quaternion (invalid) invalid_quat2 = np.zeros((0,)) # Zero quaternion (invalid) multiply_quaternion_arrays(invalid_quat1, invalid_quat2) - with self.assertRaises(AssertionError): + with pytest.raises(AssertionError): invalid_quat1 = np.zeros((len(QuaternionIndex), 8)) # Zero quaternion (invalid) invalid_quat2 = np.zeros((len(QuaternionIndex), 4)) # Zero quaternion (invalid) multiply_quaternion_arrays(invalid_quat1, invalid_quat2) @@ -762,8 +763,8 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: # Check if each angle is within [-pi, pi] for i in range(N): angle = normalized_angles_flat[i] - self.assertGreaterEqual(angle, -np.pi - 1e-8) - self.assertLessEqual(angle, np.pi + 1e-8) + assert angle >= -np.pi - 1e-8 + assert angle <= np.pi + 1e-8 # Test single-dim shape _test_by_shape((1,)) @@ -776,11 +777,10 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: _test_by_shape((0,)) # Test float - with self.subTest("Test float input"): - angle = 4 * np.pi - normalized_angle = normalize_angle(angle) - self.assertGreaterEqual(normalized_angle, -np.pi - 1e-8) - self.assertLessEqual(normalized_angle, np.pi + 1e-8) + angle = 4 * np.pi + normalized_angle = normalize_angle(angle) + assert normalized_angle >= -np.pi - 1e-8 + assert normalized_angle <= np.pi + 1e-8 if __name__ == "__main__": From a3fe95938e425615fefa80ff67202cb873b23983 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 16 Nov 2025 22:16:03 +0100 Subject: [PATCH 27/50] Switching to ruff --- .pre-commit-config.yaml | 29 ++---- docs/conf.py | 3 +- notebooks/01_scene_tutorial.ipynb | 10 +-- notebooks/bev_matplotlib.ipynb | 30 +++---- notebooks/bev_render.ipynb | 7 +- notebooks/camera_matplotlib.ipynb | 4 +- notebooks/camera_render.ipynb | 11 ++- pyproject.toml | 90 ++++++++++--------- src/py123d/api/map/gpkg/gpkg_map_api.py | 8 -- src/py123d/api/map/gpkg/gpkg_utils.py | 1 - src/py123d/api/map/map_api.py | 2 +- .../api/scene/arrow/arrow_scene_builder.py | 1 - src/py123d/api/scene/scene_filter.py | 3 +- .../common/multithreading/ray_execution.py | 3 +- src/py123d/common/utils/mixin.py | 3 +- .../conversion/dataset_converter_config.py | 2 - .../datasets/av2/av2_map_conversion.py | 1 - .../datasets/av2/av2_sensor_converter.py | 2 - .../datasets/av2/utils/av2_helper.py | 2 +- .../datasets/kitti360/kitti360_converter.py | 2 - .../kitti360/kitti360_map_conversion.py | 8 +- .../kitti360/utils/kitti360_helper.py | 2 - .../datasets/nuplan/nuplan_converter.py | 2 - .../datasets/nuplan/nuplan_map_conversion.py | 4 - .../nuscenes/nuscenes_map_conversion.py | 1 - .../nuscenes/utils/nuscenes_map_utils.py | 1 - .../datasets/pandaset/pandaset_converter.py | 6 -- .../datasets/pandaset/utils/pandaset_utlis.py | 1 - .../wopd/utils/womp_boundary_utils.py | 5 -- .../datasets/wopd/wopd_converter.py | 3 - .../datasets/wopd/wopd_map_conversion.py | 5 -- .../conversion/log_writer/arrow_log_writer.py | 6 +- .../sensor_io/camera/mp4_camera_io.py | 5 -- .../opendrive/opendrive_map_conversion.py | 4 - .../map_utils/opendrive/parser/geometry.py | 3 - .../map_utils/opendrive/parser/objects.py | 1 - .../map_utils/opendrive/parser/opendrive.py | 2 - .../map_utils/opendrive/parser/reference.py | 12 +-- .../map_utils/opendrive/utils/collection.py | 5 -- .../map_utils/opendrive/utils/lane_helper.py | 5 -- .../opendrive/utils/objects_helper.py | 2 - .../map_utils/road_edge/road_edge_3d_utils.py | 2 - .../datatypes/map_objects/base_map_objects.py | 3 +- .../datatypes/map_objects/map_objects.py | 1 - src/py123d/datatypes/map_objects/utils.py | 3 +- .../datatypes/sensors/fisheye_mei_camera.py | 3 +- src/py123d/datatypes/time/time_point.py | 2 +- .../datatypes/vehicle_state/ego_state.py | 14 +-- src/py123d/geometry/occupancy_map.py | 1 + src/py123d/geometry/polyline.py | 14 +-- .../geometry/transform/transform_euler_se3.py | 8 -- src/py123d/script/run_conversion.py | 3 - src/py123d/script/run_viser.py | 1 - src/py123d/script/utils/dataset_path_utils.py | 4 +- src/py123d/visualization/matplotlib/camera.py | 12 +-- .../visualization/matplotlib/observation.py | 47 ++-------- src/py123d/visualization/matplotlib/plots.py | 7 +- src/py123d/visualization/matplotlib/utils.py | 4 +- .../viser/elements/detection_elements.py | 2 - .../viser/elements/map_elements.py | 4 +- .../viser/elements/render_elements.py | 1 - .../viser/elements/sensor_elements.py | 9 -- .../visualization/viser/viser_config.py | 2 - .../visualization/viser/viser_viewer.py | 8 +- .../test_box_detection_label_registry.py | 1 - .../registry/test_lidar_registry.py | 1 - .../detections/test_box_detections.py | 5 -- .../datatypes/map_objects/mock_map_api.py | 2 - .../datatypes/map_objects/test_map_objects.py | 2 - .../datatypes/metadata/test_log_metadata.py | 1 - .../datatypes/metadata/test_map_metadata.py | 1 - .../sensors/test_fisheye_mei_camera.py | 5 -- .../datatypes/sensors/test_pinhole_camera.py | 5 -- tests/unit/datatypes/time/test_time.py | 1 - .../datatypes/vehicle_state/test_ego_state.py | 3 +- .../vehicle_state/test_vehicle_parameters.py | 1 - tests/unit/geometry/test_bounding_box.py | 6 -- tests/unit/geometry/test_occupancy_map.py | 7 -- tests/unit/geometry/test_point.py | 5 -- tests/unit/geometry/test_rotation.py | 6 -- tests/unit/geometry/test_vector.py | 6 -- .../transform/test_transform_consistency.py | 6 -- .../transform/test_transform_euler_se3.py | 1 - .../geometry/transform/test_transform_se2.py | 6 +- .../geometry/transform/test_transform_se3.py | 32 +++---- .../geometry/utils/test_bounding_box_utils.py | 7 -- .../geometry/utils/test_rotation_utils.py | 8 -- 87 files changed, 147 insertions(+), 423 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 18eca0ba..fe86c1fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,31 +18,14 @@ repos: exclude: /README\.rst$|\.pot?$|\.ipynb$ args: ['--no-sort-keys', "--autofix"] -- repo: https://github.com/pycqa/isort - rev: 6.0.1 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.2 hooks: - - id: isort - name: isort (python) - args: ["--profile", "black", "--filter-files", '--line-length', '120'] + - id: ruff + args: [--fix] + exclude: __init__.py$ + - id: ruff-format exclude: __init__.py$ -- repo: https://github.com/ambv/black - rev: 25.1.0 - hooks: - - id: black - language_version: python3.12 - args: ['--line-length=120'] - files: "\\.py$" -- repo: https://github.com/myint/autoflake - rev: v2.3.1 - hooks: - - id: autoflake - args: ['--in-place', '--remove-all-unused-imports', '--remove-unused-variable'] - exclude: __init__.py$ - language_version: python3.12 -- repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - repo: https://github.com/kynan/nbstripout rev: 0.8.1 hooks: diff --git a/docs/conf.py b/docs/conf.py index bae517bb..5841ae21 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,7 @@ project = "py123d" copyright = "2025, 123D Contributors" author = "123D Contributors" -release = "v0.0.7" +release = "v0.0.8" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -23,7 +23,6 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx_copybutton", - "sphinx_inline_tabs", "sphinx_autodoc_typehints", "myst_parser", ] diff --git a/notebooks/01_scene_tutorial.ipynb b/notebooks/01_scene_tutorial.ipynb index 225aea5e..3b7b9717 100644 --- a/notebooks/01_scene_tutorial.ipynb +++ b/notebooks/01_scene_tutorial.ipynb @@ -49,11 +49,11 @@ "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", "from py123d.api.scene.scene_filter import SceneFilter\n", "\n", - "\n", "# from py123d.common.multithreading.worker_ray import RayDistributed\n", "# from py123d.common.multithreading.worker_ray import RayDistributed\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType\n", + "\n", "# from py123d.datatypes.sensors.pinhole_camera_type import PinholeCameraType\n", "\n", "# splits = [\"kitti360_train\"]\n", @@ -83,7 +83,7 @@ "\n", "# worker = RayDistributed()\n", "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "print(f\"Found {len(scenes)} scenes\")\n" + "print(f\"Found {len(scenes)} scenes\")" ] }, { @@ -101,7 +101,6 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "scene = scenes[0]\n", "\n", "log_metadata = scene.get_log_metadata()\n", @@ -128,7 +127,6 @@ "source": [ "from py123d.visualization.viser.viser_viewer import ViserViewer\n", "\n", - "\n", "visualization_server = ViserViewer(scenes, scene_index=0)" ] }, @@ -139,8 +137,10 @@ "metadata": {}, "outputs": [], "source": [ - "from py123d.geometry import EulerAngles\n", "import numpy as np\n", + "\n", + "from py123d.geometry import EulerAngles\n", + "\n", "euler_angles = EulerAngles(roll=0.0, pitch=0.0, yaw=np.pi)\n", "euler_angles.roll\n", "# 0.0\n", diff --git a/notebooks/bev_matplotlib.ipynb b/notebooks/bev_matplotlib.ipynb index db3aa151..a5716ca4 100644 --- a/notebooks/bev_matplotlib.ipynb +++ b/notebooks/bev_matplotlib.ipynb @@ -9,8 +9,6 @@ "source": [ "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", "from py123d.api.scene.scene_filter import SceneFilter\n", - "\n", - "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", "# from py123d.datatypes.sensors.pinhole_camera_type import PinholeCameraType" ] @@ -24,11 +22,12 @@ "source": [ "# splits = [\"kitti360_train\"]\n", "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", - "splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", + "# splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", "# splits = [\"carla_test\"]\n", "# splits = [\"wopd_val\"]\n", "# splits = [\"av2-sensor_train\"]\n", "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", + "splits = None\n", "log_names = None\n", "scene_uuids = None\n", "\n", @@ -45,7 +44,7 @@ "scene_builder = ArrowSceneBuilder()\n", "worker = Sequential()\n", "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "print(f\"Found {len(scenes)} scenes\")\n" + "print(f\"Found {len(scenes)} scenes\")" ] }, { @@ -59,25 +58,22 @@ "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", + "import shapely.geometry as geom\n", "\n", "from py123d.api.map.map_api import MapAPI\n", + "from py123d.api.scene.scene_api import SceneAPI\n", "from py123d.datatypes.map_objects.map_layer_types import MapLayer\n", - "from py123d.datatypes.map_objects.map_objects import Lane, LaneGroup\n", + "from py123d.datatypes.map_objects.map_objects import LaneGroup\n", "from py123d.geometry import Point2D\n", - "from py123d.visualization.color.color import BLACK, DARK_GREY, DARKER_GREY, LIGHT_GREY, NEW_TAB_10, TAB_10\n", + "from py123d.visualization.color.color import BLACK, DARKER_GREY, NEW_TAB_10, TAB_10\n", "from py123d.visualization.color.config import PlotConfig\n", "from py123d.visualization.color.default import CENTERLINE_CONFIG, MAP_SURFACE_CONFIG, ROUTE_CONFIG\n", "from py123d.visualization.matplotlib.observation import (\n", " add_box_detections_to_ax,\n", " add_default_map_on_ax,\n", " add_ego_vehicle_to_ax,\n", - " add_traffic_lights_to_ax,\n", ")\n", "from py123d.visualization.matplotlib.utils import add_shapely_linestring_to_ax, add_shapely_polygon_to_ax\n", - "from py123d.api.scene.scene_api import SceneAPI\n", - "\n", - "\n", - "import shapely.geometry as geom\n", "\n", "LEFT_CONFIG: PlotConfig = PlotConfig(\n", " fill_color=TAB_10[2],\n", @@ -154,7 +150,6 @@ " patch = geom.box(x_min, y_min, x_max, y_max)\n", " map_objects_dict = map_api.query(geometry=patch, layers=layers, predicate=\"intersects\")\n", "\n", - "\n", " for layer, map_objects in map_objects_dict.items():\n", " for map_object in map_objects:\n", " try:\n", @@ -192,7 +187,6 @@ " # line_color=NEW_TAB_10[line_type % (len(NEW_TAB_10) - 1)],\n", " # line_color_alpha=1.0,\n", " # line_width=1.5,\n", - " # line_style=\"-\",\n", " # zorder=10,\n", " # )\n", " add_shapely_linestring_to_ax(ax, map_object.polyline_3d.linestring, ROAD_LINE_CONFIG)\n", @@ -207,12 +201,11 @@ "\n", "\n", "def _plot_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: float = 80) -> plt.Axes:\n", - "\n", " ego_vehicle_state = scene.get_ego_state_at_iteration(iteration)\n", " box_detections = scene.get_box_detections_at_iteration(iteration)\n", " map_api = scene.get_map_api()\n", "\n", - " point_2d = ego_vehicle_state.bounding_box.center.point_2d\n", + " point_2d = ego_vehicle_state.bounding_box.center_se2.point_2d\n", " if map_api is not None:\n", " # add_debug_map_on_ax(ax, scene.get_map_api(), point_2d, radius=radius, route_lane_group_ids=None)\n", "\n", @@ -230,10 +223,7 @@ " return ax\n", "\n", "\n", - "def plot_scene_at_iteration(\n", - " scene: SceneAPI, iteration: int = 0, radius: float = 80\n", - ") -> Tuple[plt.Figure, plt.Axes]:\n", - "\n", + "def plot_scene_at_iteration(scene: SceneAPI, iteration: int = 0, radius: float = 80) -> Tuple[plt.Figure, plt.Axes]:\n", " size = 10\n", "\n", " fig, ax = plt.subplots(figsize=(size, size))\n", @@ -277,7 +267,7 @@ ], "metadata": { "kernelspec": { - "display_name": "py123d", + "display_name": "py123d_dev", "language": "python", "name": "python3" }, diff --git a/notebooks/bev_render.ipynb b/notebooks/bev_render.ipynb index 4fb28d29..e519ac78 100644 --- a/notebooks/bev_render.ipynb +++ b/notebooks/bev_render.ipynb @@ -9,9 +9,8 @@ "source": [ "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", "from py123d.api.scene.scene_filter import SceneFilter\n", - "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", - "# from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType " + "# from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType" ] }, { @@ -49,8 +48,8 @@ "worker = Sequential()\n", "scenes = scene_builder.get_scenes(scene_filter, worker)\n", "\n", - "scenes = [scene for scene in scenes if scene.uuid in scene_uuids]\n", - "print(f\"Found {len(scenes)} scenes\")\n" + "scenes = [scene for scene in scenes if scene.uuid in scene_uuids]\n", + "print(f\"Found {len(scenes)} scenes\")" ] }, { diff --git a/notebooks/camera_matplotlib.ipynb b/notebooks/camera_matplotlib.ipynb index f03c8d37..4ee3ca0b 100644 --- a/notebooks/camera_matplotlib.ipynb +++ b/notebooks/camera_matplotlib.ipynb @@ -9,7 +9,6 @@ "source": [ "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", "from py123d.api.scene.scene_filter import SceneFilter\n", - "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType" ] @@ -60,8 +59,9 @@ "outputs": [], "source": [ "from matplotlib import pyplot as plt\n", + "\n", "from py123d.api.scene.abstract_scene import AbstractScene\n", - "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax, add_camera_ax\n", + "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax\n", "\n", "iteration = 0\n", "scene = scenes[0]\n", diff --git a/notebooks/camera_render.ipynb b/notebooks/camera_render.ipynb index dca62416..4f6f616e 100644 --- a/notebooks/camera_render.ipynb +++ b/notebooks/camera_render.ipynb @@ -9,7 +9,6 @@ "source": [ "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", "from py123d.api.scene.scene_filter import SceneFilter\n", - "\n", "from py123d.common.multithreading.worker_sequential import Sequential\n", "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType\n", "\n", @@ -51,8 +50,8 @@ "worker = Sequential()\n", "scenes = scene_builder.get_scenes(scene_filter, worker)\n", "\n", - "scenes = [scene for scene in scenes if scene.uuid in scene_uuids]\n", - "print(f\"Found {len(scenes)} scenes\")\n" + "scenes = [scene for scene in scenes if scene.uuid in scene_uuids]\n", + "print(f\"Found {len(scenes)} scenes\")" ] }, { @@ -62,11 +61,11 @@ "metadata": {}, "outputs": [], "source": [ + "import imageio\n", "from matplotlib import pyplot as plt\n", + "\n", "from py123d.api.scene.abstract_scene import AbstractScene\n", - "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax, add_camera_ax\n", - "import imageio\n", - "import numpy as np\n", + "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax\n", "\n", "iteration = 0\n", "scene = scenes[0]\n", diff --git a/pyproject.toml b/pyproject.toml index a977a8a6..405610fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,20 +9,22 @@ build-backend = "setuptools.build_meta" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: Apache Software License", ] name = "py123d" -version = "v0.0.7" +version = "v0.0.8" authors = [{ name = "Daniel Dauner", email = "daniel.dauner@gmail.com" }] description = "TODO" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.8" license = {text = "Apache-2.0"} dependencies = [ - "bokeh", "geopandas", - "joblib", "matplotlib", "numpy", "opencv-python", @@ -32,23 +34,20 @@ dependencies = [ "pyarrow", "pyogrio", "pyquaternion", - "pytest", - "rasterio", "ray", - "rtree", "scipy", - "setuptools", "shapely>=2.0.0", "tqdm", "notebook", - "pre-commit", "hydra_colorlog", "hydra-core", - "lxml", "trimesh", "viser", "laspy[lazrs]", "DracoPy", + "omegaconf", + "typing-extensions", + "requests", ] [project.scripts] @@ -57,16 +56,16 @@ py123d-conversion = "py123d.script.run_conversion:main" [project.optional-dependencies] dev = [ - "black", - "isort", - "flake8", + "pyright", + "ruff", "pre-commit", + "pytest", + "pytest-cov", ] docs = [ "Sphinx", "sphinx-rtd-theme", "sphinx-autobuild", - "sphinx-inline-tabs", "sphinx-copybutton", "myst-parser", "furo", @@ -75,39 +74,12 @@ docs = [ ] nuplan = [ "nuplan-devkit @ git+https://github.com/motional/nuplan-devkit/@nuplan-devkit-v1.2", - "ujson", - "tornado", - "sympy", - "SQLAlchemy==1.4.27", - "selenium", - "nest_asyncio", - "cachetools", - "aioboto3", - "aiofiles", - "casadi", - "control", - "pyinstrument", - "Fiona", - "guppy3", - "retry", ] nuscenes = [ - "lanelet2", - "nuscenes-devkit==1.2.0", -] -nuscenes_expanded = [ "nuscenes-devkit==1.2.0", - "pycocotools==2.0.10", - "laspy==2.6.1", - "embreex==2.17.7.post6", - "lanelet2==1.2.2", - "protobuf==4.25.3", - "pycollada==0.9.2", - "vhacdx==0.0.8.post2", - "yourdfpy==0.0.58", + "lanelet2", ] waymo = [ - "protobuf==4.21.0", "tensorflow==2.13.0", "waymo-open-dataset-tf-2-12-0==1.6.6", ] @@ -121,3 +93,37 @@ where = ["src"] [project.urls] "Homepage" = "https://github.com/DanielDauner/py123d" "Bug Tracker" = "https://github.com/DanielDauner/py123d/issues" + + +[tool.ruff] +line-length = 120 +lint.select = [ + "E", # pycodestyle errors. + "F", # Pyflakes rules. + "PLC", # Pylint convention warnings. + "PLE", # Pylint errors. + "PLR", # Pylint refactor recommendations. + "PLW", # Pylint warnings. + "I", # Import sorting. +] +lint.ignore = [ + "E731", # Do not assign a lambda expression, use a def. + "E741", # Ambiguous variable name. (l, O, or I) + "E501", # Line too long. + "E402", # Module level import not at top of file + "PLR2004", # Magic value used in comparison. + "PLR0915", # Too many statements. + "PLR0913", # Too many arguments. + "PLC0414", # Import alias does not rename variable. (this is used for exporting names) + "PLC0415", # Import should be at the top-level of a file. + "PLC1901", # Use falsey strings. + "PLR5501", # Use `elif` instead of `else if`. + "PLR0911", # Too many return statements. + "PLR0912", # Too many branches. + "PLW0603", # Global statement updates are discouraged. + "PLW2901", # For loop variable overwritten. + "PLW0642", # Reassigned self in instance method. +] + +fixable = ["ALL"] +unfixable = [] diff --git a/src/py123d/api/map/gpkg/gpkg_map_api.py b/src/py123d/api/map/gpkg/gpkg_map_api.py index adb7ba01..e8f6699d 100644 --- a/src/py123d/api/map/gpkg/gpkg_map_api.py +++ b/src/py123d/api/map/gpkg/gpkg_map_api.py @@ -315,7 +315,6 @@ def _get_lane_group(self, id: str) -> Optional[LaneGroup]: lane_group: Optional[LaneGroup] = None lane_group_row = get_row_with_value(self._gpd_dataframes[MapLayer.LANE_GROUP], "id", id) if lane_group_row is not None: - object_id: str = lane_group_row["id"] lane_ids: List[str] = ast.literal_eval(lane_group_row.lane_ids) left_boundary: Polyline3D = Polyline3D.from_linestring(lane_group_row["left_boundary"]) @@ -349,7 +348,6 @@ def _get_intersection(self, id: str) -> Optional[Intersection]: intersection: Optional[Intersection] = None intersection_row = get_row_with_value(self._gpd_dataframes[MapLayer.INTERSECTION], "id", id) if intersection_row is not None: - object_id: str = intersection_row["id"] lane_group_ids: List[str] = ast.literal_eval(intersection_row.lane_group_ids) outline: Optional[Polyline3D] = ( @@ -375,7 +373,6 @@ def _get_crosswalk(self, id: str) -> Optional[Crosswalk]: crosswalk: Optional[Crosswalk] = None crosswalk_row = get_row_with_value(self._gpd_dataframes[MapLayer.CROSSWALK], "id", id) if crosswalk_row is not None: - object_id: str = crosswalk_row["id"] outline: Polyline3D = Polyline3D.from_linestring(crosswalk_row["outline"]) geometry: geom.Polygon = crosswalk_row["geometry"] @@ -394,7 +391,6 @@ def _get_walkway(self, id: str) -> Optional[Walkway]: walkway: Optional[Walkway] = None walkway_row = get_row_with_value(self._gpd_dataframes[MapLayer.WALKWAY], "id", id) if walkway_row is not None: - object_id: str = walkway_row["id"] outline: Polyline3D = Polyline3D.from_linestring(walkway_row["outline"]) geometry: geom.Polygon = walkway_row["geometry"] @@ -413,7 +409,6 @@ def _get_carpark(self, id: str) -> Optional[Carpark]: carpark: Optional[Carpark] = None carpark_row = get_row_with_value(self._gpd_dataframes[MapLayer.CARPARK], "id", id) if carpark_row is not None: - object_id: str = carpark_row["id"] outline: Polyline3D = Polyline3D.from_linestring(carpark_row["outline"]) geometry: geom.Polygon = carpark_row["geometry"] @@ -432,7 +427,6 @@ def _get_generic_drivable(self, id: str) -> Optional[GenericDrivable]: generic_drivable: Optional[GenericDrivable] = None generic_drivable_row = get_row_with_value(self._gpd_dataframes[MapLayer.GENERIC_DRIVABLE], "id", id) if generic_drivable_row is not None: - object_id: str = generic_drivable_row["id"] outline: Polyline3D = Polyline3D.from_linestring(generic_drivable_row["outline"]) geometry: geom.Polygon = generic_drivable_row["geometry"] @@ -451,7 +445,6 @@ def _get_road_edge(self, id: str) -> Optional[RoadEdge]: road_edge: Optional[RoadEdge] = None road_edge_row = get_row_with_value(self._gpd_dataframes[MapLayer.ROAD_EDGE], "id", id) if road_edge_row is not None: - object_id: str = road_edge_row["id"] polyline: Polyline3D = Polyline3D.from_linestring(road_edge_row["geometry"]) road_edge_type: RoadEdgeType = RoadEdgeType(road_edge_row["road_edge_type"]) @@ -470,7 +463,6 @@ def _get_road_line(self, id: str) -> Optional[RoadLine]: road_line: Optional[RoadLine] = None road_line_row = get_row_with_value(self._gpd_dataframes[MapLayer.ROAD_LINE], "id", id) if road_line_row is not None: - object_id: str = road_line_row["id"] polyline: Polyline3D = Polyline3D.from_linestring(road_line_row["geometry"]) road_line_type: RoadLineType = RoadLineType(road_line_row["road_line_type"]) diff --git a/src/py123d/api/map/gpkg/gpkg_utils.py b/src/py123d/api/map/gpkg/gpkg_utils.py index e165fc3c..9d8e8548 100644 --- a/src/py123d/api/map/gpkg/gpkg_utils.py +++ b/src/py123d/api/map/gpkg/gpkg_utils.py @@ -54,7 +54,6 @@ def get_row_with_value( geo_series: Optional[gpd.GeoSeries] = None matching_rows = get_all_rows_with_value(elements, column_label, desired_value) if matching_rows is not None: - assert len(matching_rows) > 0, f"Could not find the desired key = {desired_value}" assert len(matching_rows) == 1, ( f"{len(matching_rows)} matching keys found. Expected to only find one." "Try using get_all_rows_with_value" diff --git a/src/py123d/api/map/map_api.py b/src/py123d/api/map/map_api.py index c26a40ea..bfd1d131 100644 --- a/src/py123d/api/map/map_api.py +++ b/src/py123d/api/map/map_api.py @@ -33,7 +33,7 @@ def get_available_map_layers(self) -> List[MapLayer]: """ @abc.abstractmethod - def get_map_object(self, object_id: str, layer: MapLayer) -> Optional[BaseMapObject]: + def get_map_object(self, object_id: Union[str, int], layer: MapLayer) -> Optional[BaseMapObject]: """Returns a :class:`~p123d.datatypes.map_objects.base_map_object.BaseMapObject` by its ID and :class:`~p123d.datatypes.map_objects.map_layer_types.MapLayer`. diff --git a/src/py123d/api/scene/arrow/arrow_scene_builder.py b/src/py123d/api/scene/arrow/arrow_scene_builder.py index cccac275..12b55fed 100644 --- a/src/py123d/api/scene/arrow/arrow_scene_builder.py +++ b/src/py123d/api/scene/arrow/arrow_scene_builder.py @@ -172,7 +172,6 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil scene_extraction_metadatas_ = [] for scene_extraction_metadata in scene_metadatas: - add_scene = True start_idx = scene_extraction_metadata.initial_idx if filter.pinhole_camera_types is not None: diff --git a/src/py123d/api/scene/scene_filter.py b/src/py123d/api/scene/scene_filter.py index 118005a1..a875e91c 100644 --- a/src/py123d/api/scene/scene_filter.py +++ b/src/py123d/api/scene/scene_filter.py @@ -52,8 +52,7 @@ def __post_init__(self): def _resolve_enum_arguments( serial_enum_cls: SerialIntEnum, input: Optional[List[Union[int, str, SerialIntEnum]]], - ) -> Optional[List[SerialIntEnum]]: - + ): if input is None: return None return [serial_enum_cls.from_arbitrary(value) for value in input] diff --git a/src/py123d/common/multithreading/ray_execution.py b/src/py123d/common/multithreading/ray_execution.py index b11badc5..eb769ca9 100644 --- a/src/py123d/common/multithreading/ray_execution.py +++ b/src/py123d/common/multithreading/ray_execution.py @@ -83,7 +83,8 @@ def _ray_map_items(task: Task, *item_lists: Iterable[List[Any]], log_dir: Option assert len(item_lists) > 0, "No map arguments received for mapping" assert all(isinstance(items, list) for items in item_lists), "All map arguments must be lists" assert all( - len(cast(List, items)) == len(item_lists[0]) for items in item_lists # type: ignore + len(cast(List, items)) == len(item_lists[0]) + for items in item_lists # type: ignore ), "All lists must have equal size" fn = task.fn # Wrap function in remote decorator and create ray objects diff --git a/src/py123d/common/utils/mixin.py b/src/py123d/common/utils/mixin.py index be2a86d8..3813ef12 100644 --- a/src/py123d/common/utils/mixin.py +++ b/src/py123d/common/utils/mixin.py @@ -1,9 +1,8 @@ from __future__ import annotations -from typing import Self - import numpy as np import numpy.typing as npt +from typing_extensions import Self class ArrayMixin: diff --git a/src/py123d/conversion/dataset_converter_config.py b/src/py123d/conversion/dataset_converter_config.py index 099a9bd4..ea1cfe77 100644 --- a/src/py123d/conversion/dataset_converter_config.py +++ b/src/py123d/conversion/dataset_converter_config.py @@ -6,7 +6,6 @@ @dataclass class DatasetConverterConfig: - force_log_conversion: bool = False force_map_conversion: bool = False @@ -41,7 +40,6 @@ class DatasetConverterConfig: include_route: bool = False def __post_init__(self): - assert self.pinhole_camera_store_option in [ "path", "jpeg_binary", diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index 8335ad23..da51ed62 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -262,7 +262,6 @@ def _get_lane_group_ids_of_lanes_ids(lane_ids: List[str]) -> List[int]: return list(set(lane_group_ids_)) for lane_group_id, lane_group_set in lane_group_set_dict.items(): - lane_group_dict[lane_group_id] = {} lane_group_dict[lane_group_id]["id"] = lane_group_id lane_group_dict[lane_group_id]["lane_ids"] = [int(lane_id) for lane_id in lane_group_set] diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 713a311b..252b4fe9 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -123,7 +123,6 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: # 3. Process source log data if log_needs_writing: - sensor_df = build_sensor_dataframe(source_log_path) synchronization_df = build_synchronization_dataframe(sensor_df) @@ -209,7 +208,6 @@ def _get_av2_lidar_metadata( metadata: Dict[LiDARType, LiDARMetadata] = {} if dataset_converter_config.include_lidars: - # Load calibration feather file calibration_file = source_log_path / "calibration" / "egovehicle_SE3_sensor.feather" calibration_df = pd.read_feather(calibration_file) diff --git a/src/py123d/conversion/datasets/av2/utils/av2_helper.py b/src/py123d/conversion/datasets/av2/utils/av2_helper.py index a2937778..0cfb7f15 100644 --- a/src/py123d/conversion/datasets/av2/utils/av2_helper.py +++ b/src/py123d/conversion/datasets/av2/utils/av2_helper.py @@ -17,7 +17,7 @@ def get_dataframe_from_file(file_path: Path) -> pd.DataFrame: return pq.read_table(file_path) elif file_path.suffix == ".feather": - import pyarrow.feather as feather + from pyarrow import feather return feather.read_feather(file_path) else: diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 2f4a0ea4..0485160f 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -352,7 +352,6 @@ def _get_kitti360_fisheye_mei_camera_metadata( fisheye_cam_metadatas: Dict[FisheyeMEICameraType, FisheyeMEICameraMetadata] = {} if dataset_converter_config.include_fisheye_mei_cameras: - fisheye_camera02_path = kitti360_folders[DIR_CALIB] / "image_02.yaml" fisheye_camera03_path = kitti360_folders[DIR_CALIB] / "image_03.yaml" @@ -362,7 +361,6 @@ def _get_kitti360_fisheye_mei_camera_metadata( fisheye_result = {"image_02": fisheye02, "image_03": fisheye03} for fcam_type, fcam_name in KITTI360_FISHEYE_MEI_CAMERA_TYPES.items(): - distortion_params = fisheye_result[fcam_name]["distortion_parameters"] distortion = FisheyeMEIDistortion( k1=distortion_params["k1"], diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py index ab7012f9..29f91431 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_map_conversion.py @@ -6,13 +6,17 @@ import numpy as np import shapely.geometry as geom -from py123d.conversion.datasets.kitti360.utils.kitti360_helper import KITTI360_MAP_Bbox3D +from py123d.conversion.datasets.kitti360.utils.kitti360_helper import ( + KITTI360_MAP_Bbox3D, +) from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter from py123d.conversion.utils.map_utils.road_edge.road_edge_2d_utils import ( get_road_edge_linear_rings, split_line_geometry_by_max_length, ) -from py123d.conversion.utils.map_utils.road_edge.road_edge_3d_utils import lift_road_edges_to_3d +from py123d.conversion.utils.map_utils.road_edge.road_edge_3d_utils import ( + lift_road_edges_to_3d, +) from py123d.datatypes.map_objects.map_layer_types import RoadEdgeType from py123d.datatypes.map_objects.map_objects import ( Carpark, diff --git a/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py b/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py index 89cfcd1a..beb66b1d 100644 --- a/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py +++ b/src/py123d/conversion/datasets/kitti360/utils/kitti360_helper.py @@ -42,14 +42,12 @@ def global2local(globalId: int) -> Tuple[int, int]: class KITTI360Bbox3D: - # global id(only used for sequence 0004) dynamic_global_id = 2000000 static_global_id = 1000000 # Constructor def __init__(self): - # the ID of the corresponding object self.semanticId = -1 self.instanceId = -1 diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index 5a589e7d..70259bd1 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -203,7 +203,6 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: if log_needs_writing: step_interval: float = int(TARGET_DT / NUPLAN_DEFAULT_DT) for nuplan_lidar_pc in nuplan_log_db.lidar_pc[::step_interval]: - lidar_pc_token: str = nuplan_lidar_pc.token log_writer.write( timestamp=TimePoint.from_us(nuplan_lidar_pc.timestamp), @@ -380,7 +379,6 @@ def _extract_nuplan_cameras( image = image_class[0] filename_jpg = nuplan_sensor_root / image.filename_jpg if filename_jpg.exists() and filename_jpg.is_file(): - # NOTE: This part of the modified from the MTGS code # In MTGS, a slower method is used to find the nearest ego pose. # The code below uses a direct SQL query to find the nearest ego pose, in a given window. diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py index d72f081a..a27f42dc 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_map_conversion.py @@ -68,7 +68,6 @@ def _write_nuplan_lanes(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_writer: Abs all_geometries = nuplan_gdf["lanes_polygons"].geometry.to_list() for idx, lane_id in enumerate(all_ids): - # 1. predecessor_ids, successor_ids predecessor_ids = get_all_rows_with_value( nuplan_gdf["lane_connectors"], @@ -135,7 +134,6 @@ def _write_nuplan_lane_connectors(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_w all_speed_limits_mps = nuplan_gdf["lane_connectors"].speed_limit_mps.to_list() for idx, lane_id in enumerate(all_ids): - # 1. predecessor_ids, successor_ids lane_connector_row = get_row_with_value(nuplan_gdf["lane_connectors"], "fid", str(lane_id)) predecessor_ids = [lane_connector_row["entry_lane_fid"]] @@ -186,7 +184,6 @@ def _write_nuplan_lane_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], map_write # all_geometries = nuplan_gdf["lane_groups_polygons"].geometry.to_list() for lane_group_id in ids: - # 1. lane_ids lane_ids = get_all_rows_with_value( nuplan_gdf["lanes_polygons"], @@ -244,7 +241,6 @@ def _write_nuplan_lane_connector_groups(nuplan_gdf: Dict[str, gpd.GeoDataFrame], # all_geometries = nuplan_gdf["lane_group_connectors"].geometry.to_list() for idx, lane_group_connector_id in enumerate(ids): - # 1. lane_ids lane_ids = get_all_rows_with_value( nuplan_gdf["lane_connectors"], "lane_group_connector_fid", lane_group_connector_id diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py index 8a0cb348..ce17b87a 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_map_conversion.py @@ -221,7 +221,6 @@ def _extract_nuscenes_lane_groups( lane_group_lane_dict[lane.lane_group_id].append(lane.object_id) for lane_group_id, lane_ids in lane_group_lane_dict.items(): - if len(lane_ids) > 1: lane_centerlines: List[Polyline2D] = [lanes_dict[lane_id].centerline for lane_id in lane_ids] ordered_lane_indices = order_lanes_left_to_right(lane_centerlines) diff --git a/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_map_utils.py b/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_map_utils.py index a512dc18..4d229d68 100644 --- a/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_map_utils.py +++ b/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_map_utils.py @@ -158,7 +158,6 @@ def order_lanes_left_to_right(polylines: List[Polyline2D]) -> List[int]: # Step 1: Compute the average direction vector across all lanes all_directions = [] for polyline in polylines: - polyline_array = polyline.array if len(polyline_array) < 2: continue diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index d46ce762..be374d65 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -132,7 +132,6 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: # 3. Process source log data if log_needs_writing: - # Read files from pandaset timesteps = read_json(source_log_path / "meta" / "timestamps.json") gps: List[Dict[str, float]] = read_json(source_log_path / "meta" / "gps.json") @@ -144,7 +143,6 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: # Write data to log writer for iteration, timestep_s in enumerate(timesteps): - ego_state = _extract_pandaset_sensor_ego_state(gps[iteration], lidar_poses[iteration]) log_writer.write( timestamp=TimePoint.from_s(timestep_s), @@ -177,7 +175,6 @@ def _get_pandaset_camera_metadata( if dataset_config.include_pinhole_cameras: all_cameras_folder = source_log_path / "camera" for camera_folder in all_cameras_folder.iterdir(): - camera_name = camera_folder.name assert camera_name in PANDASET_CAMERA_MAPPING.keys(), f"Camera name {camera_name} is not recognized." @@ -297,7 +294,6 @@ def _extract_pandaset_box_detections(source_log_path: Path, iteration: int) -> B # Fill bounding box detections and return box_detections: List[BoxDetectionSE3] = [] for box_idx in range(num_boxes): - # Skip duplicate box detections from front lidar if sibling exists in top lidar if sensor_ids[box_idx] == 1 and sibling_ids[box_idx] in top_lidar_uuids: continue @@ -335,9 +331,7 @@ def _extract_pandaset_sensor_camera( iteration_str = f"{iteration:02d}" if dataset_converter_config.include_pinhole_cameras: - for camera_name, camera_type in PANDASET_CAMERA_MAPPING.items(): - image_abs_path = source_log_path / f"camera/{camera_name}/{iteration_str}.jpg" assert image_abs_path.exists(), f"Camera image file {str(image_abs_path)} does not exist." diff --git a/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py b/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py index ba0bfd36..4b34d159 100644 --- a/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py +++ b/src/py123d/conversion/datasets/pandaset/utils/pandaset_utlis.py @@ -66,7 +66,6 @@ def rotate_pandaset_pose_to_iso_coordinates(pose: PoseSE3) -> PoseSE3: def main_lidar_to_rear_axle(pose: PoseSE3) -> PoseSE3: - F = np.array( [ [0.0, 1.0, 0.0], # new X = old Y (forward) diff --git a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py index a920ae0b..5c9b7a8c 100644 --- a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py +++ b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py @@ -147,10 +147,8 @@ def _filter_perpendicular_hits( perpendicular_hits: List[PerpendicularHit], lane_point_3d: Point3D, ) -> List[PerpendicularHit]: - filtered_hits = [] for hit in perpendicular_hits: - # 1. filter hits too far in the vertical direction z_distance = np.abs(hit.hit_point_3d.z - lane_point_3d.z) if z_distance > MAX_Z_DISTANCE: @@ -226,7 +224,6 @@ def fill_lane_boundaries( for sign in [1.0, -1.0]: boundary_points_3d: List[Optional[Point3D]] = [] for lane_query_se2, lane_query_3d in zip(lane_queries_se2, lane_queries_3d): - perpendicular_hits = _collect_perpendicular_hits( lane_query_se2=lane_query_se2, lane_token=current_lane_token, @@ -250,12 +247,10 @@ def fill_lane_boundaries( elif first_hit.hit_polyline_type == "road-line": boundary_point_3d = first_hit.hit_point_3d elif first_hit.hit_polyline_type == "lane": - for hit in perpendicular_hits: if hit.hit_polyline_type == "road-edge": continue if hit.hit_polyline_type == "lane": - lane_data_dict[lane_id].predecessor_ids has_same_predecessor = ( diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 2cff87ed..453a8c82 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -277,7 +277,6 @@ def _get_wopd_lidar_metadata( lidar_index = WOPDLiDARIndex if keep_polar_features else DefaultLiDARIndex if dataset_converter_config.lidar_store_option is not None: for laser_calibration in initial_frame.context.laser_calibrations: - lidar_type = WOPD_LIDAR_TYPES[laser_calibration.name] extrinsic: Optional[PoseSE3] = None if laser_calibration.extrinsic: @@ -332,7 +331,6 @@ def _extract_wopd_box_detections( detections_token: List[str] = [] for detection_idx, detection in enumerate(frame.laser_labels): - detection_quaternion = EulerAngles( roll=DEFAULT_ROLL, pitch=DEFAULT_PITCH, @@ -392,7 +390,6 @@ def _extract_wopd_cameras( camera_data_list: List[CameraData] = [] if dataset_converter_config.include_pinhole_cameras: - # NOTE @DanielDauner: The extrinsic matrix in frame.context.camera_calibration is fixed to model the ego to camera transformation. # The poses in frame.images[idx] are the motion compensated ego poses when the camera triggers. camera_extrinsic: Dict[str, PoseSE3] = {} diff --git a/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py index 6287c2e7..c7301428 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py +++ b/src/py123d/conversion/datasets/wopd/wopd_map_conversion.py @@ -26,7 +26,6 @@ def convert_wopd_map(frame: dataset_pb2.Frame, map_writer: AbstractMapWriter) -> None: - # We first extract all road lines, road edges, and lanes, and write them to the map writer. # NOTE: road lines and edges are used needed to extract lane boundaries. road_lines = _write_and_get_waymo_road_lines(frame, map_writer) @@ -89,7 +88,6 @@ def _write_and_get_waymo_road_edges(frame: dataset_pb2.Frame, map_writer: Abstra def _write_and_get_waymo_lanes( frame: dataset_pb2.Frame, road_lines: List[RoadLine], road_edges: List[RoadEdge], map_writer: AbstractMapWriter ) -> List[Lane]: - # 1. Load lane data from Waymo frame proto lane_data_dict: Dict[int, WaymoLaneData] = {} for map_feature in frame.map_features: @@ -127,7 +125,6 @@ def _get_majority_neighbor(neighbors: List[Dict[str, int]]) -> Optional[int]: lanes: List[Lane] = [] for lane_data in lane_data_dict.values(): - # Skip lanes without boundaries if lane_data.left_boundary is None or lane_data.right_boundary is None: continue @@ -154,7 +151,6 @@ def _get_majority_neighbor(neighbors: List[Dict[str, int]]) -> Optional[int]: def _write_waymo_lane_groups(lanes: List[Lane], map_writer: AbstractMapWriter) -> None: - # NOTE: WOPD does not provide lane groups, so we create a lane group for each lane. for lane in lanes: map_writer.write_lane_group( @@ -172,7 +168,6 @@ def _write_waymo_lane_groups(lanes: List[Lane], map_writer: AbstractMapWriter) - def _write_waymo_misc_surfaces(frame: dataset_pb2.Frame, map_writer: AbstractMapWriter) -> None: - for map_feature in frame.map_features: if map_feature.HasField("driveway"): # NOTE: We currently only handle classify driveways as carparks. diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index 8a7a3b29..324aad69 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -54,21 +54,21 @@ def _get_logs_root() -> Path: - from py123d.script.utils.dataset_path_utils import get_dataset_paths + from py123d.script.utils.dataset_path_utils import get_dataset_paths # noqa: PLC0415 DATASET_PATHS = get_dataset_paths() return Path(DATASET_PATHS.py123d_logs_root) def _get_sensors_root() -> Path: - from py123d.script.utils.dataset_path_utils import get_dataset_paths + from py123d.script.utils.dataset_path_utils import get_dataset_paths # noqa: PLC0415 DATASET_PATHS = get_dataset_paths() return Path(DATASET_PATHS.py123d_sensors_root) def _store_option_to_arrow_type( - store_option: Literal["path", "jpeg_binary", "png_binary", "laz_binary"], + store_option: Literal["path", "jpeg_binary", "png_binary", "laz_binary", "draco_binary", "mp4"], ) -> pa.DataType: """Maps the store option literal to the corresponding Arrow data type.""" data_type_map = { diff --git a/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py b/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py index 5c156cdf..bf6941e8 100644 --- a/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py +++ b/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py @@ -1,8 +1,3 @@ -# TODO: add method of handling camera mp4 io -def load_image_from_mp4_file() -> None: - raise NotImplementedError - - from functools import lru_cache from pathlib import Path from typing import Optional, Union diff --git a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py index 5449ba9e..1ce4defb 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py @@ -193,7 +193,6 @@ def _write_intersections( lane_group_helper_dict: Dict[str, OpenDriveLaneGroupHelper], map_writer: AbstractMapWriter, ) -> None: - def _find_lane_group_helpers_with_junction_id(junction_id: int) -> List[OpenDriveLaneGroupHelper]: return [ lane_group_helper @@ -230,7 +229,6 @@ def _write_crosswalks(object_helper_dict: Dict[int, OpenDriveObjectHelper], map_ def _write_road_lines(lanes: List[Lane], lane_groups: List[LaneGroup], map_writer: AbstractMapWriter) -> None: - # NOTE @DanielDauner: This method of extracting road lines is very simplistic and needs improvement. # The OpenDRIVE format provides lane boundary types that could be used here. # Additionally, the logic of inferring road lines is somewhat flawed, e.g, assuming constant types/colors of lines. @@ -245,7 +243,6 @@ def _write_road_lines(lanes: List[Lane], lane_groups: List[LaneGroup], map_write running_id = 0 for lane in lanes: - on_intersection = lane_group_on_intersection.get(lane.lane_group_id, False) if on_intersection: # Skip road lines on intersections @@ -281,7 +278,6 @@ def _write_road_edges( generic_drivables: List[GenericDrivable], map_writer: AbstractMapWriter, ) -> None: - road_edges_ = get_road_edges_3d_from_drivable_surfaces( lanes=lanes, lane_groups=lane_groups, diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py index d5374d00..bc8ed734 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/geometry.py @@ -47,7 +47,6 @@ def parse(cls, geometry_element: Element) -> XODRGeometry: return cls(**args) def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: - interpolated_se2 = self.start_se2.copy() interpolated_se2[PoseSE2Index.X] += s * np.cos(self.hdg) interpolated_se2[PoseSE2Index.Y] += s * np.sin(self.hdg) @@ -78,7 +77,6 @@ def parse(cls, geometry_element: Element) -> XODRGeometry: return cls(**args) def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: - kappa = self.curvature radius = 1.0 / kappa if kappa != 0 else float("inf") @@ -142,7 +140,6 @@ def interpolate_se2(self, s: float, t: float = 0.0) -> npt.NDArray[np.float64]: return interpolated_se2 def _compute_spiral_position(self, s: float, gamma: float) -> Tuple[float, float]: - # Transform to normalized Fresnel spiral parameter # Standard Fresnel spiral has κ(u) = u, so we need to scale # Our spiral: κ(s) = κ₀ + γs diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py index 8c1b17af..bd844895 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/objects.py @@ -32,7 +32,6 @@ def __post_init__(self): @classmethod def parse(cls, object_element: Optional[Element]) -> XODRObject: - args = {} args["id"] = int(object_element.get("id")) args["name"] = object_element.get("name") diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py index 3dfac7f1..7ee2fce9 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/opendrive.py @@ -23,7 +23,6 @@ class XODR: @classmethod def parse(cls, root_element: Element) -> XODR: - args = {} args["header"] = Header.parse(root_element.find("header")) @@ -111,7 +110,6 @@ class Controller: @classmethod def parse(cls, controller_element: Optional[Element]) -> Junction: - args = {} args["name"] = controller_element.get("name") args["id"] = float(controller_element.get("id")) diff --git a/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py b/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py index b94a7af0..236ba5a0 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/parser/reference.py @@ -20,7 +20,6 @@ @dataclass class XODRPlanView: - geometries: List[XODRGeometry] def __post_init__(self): @@ -72,7 +71,6 @@ def interpolate_se2(self, s: float, t: float = 0.0, lane_section_end: bool = Fal @dataclass class XODRReferenceLine: - reference_line: Union[XODRReferenceLine, XODRPlanView] width_polynomials: List[XODRPolynomial] elevations: List[XODRElevation] @@ -119,17 +117,15 @@ def from_reference_line( @staticmethod def _find_polynomial(s: float, polynomials: List[XODRPolynomial], lane_section_end: bool = False) -> XODRPolynomial: - out_polynomial = polynomials[-1] for polynomial in polynomials[::-1]: if lane_section_end: if polynomial.s < s: out_polynomial = polynomial break - else: - if polynomial.s <= s: - out_polynomial = polynomial - break + elif polynomial.s <= s: + out_polynomial = polynomial + break # s_values = np.array([poly.s for poly in polynomials]) # side = "left" if lane_section_end else "right" @@ -139,7 +135,6 @@ def _find_polynomial(s: float, polynomials: List[XODRPolynomial], lane_section_e return out_polynomial def interpolate_se2(self, s: float, t: float = 0.0, lane_section_end: bool = False) -> npt.NDArray[np.float64]: - width_polynomial = self._find_polynomial(s, self.width_polynomials, lane_section_end=lane_section_end) t_offset = width_polynomial.get_value(s - width_polynomial.s) se2 = self.reference_line.interpolate_se2(self.s_offset + s, t=t_offset + t, lane_section_end=lane_section_end) @@ -147,7 +142,6 @@ def interpolate_se2(self, s: float, t: float = 0.0, lane_section_end: bool = Fal return se2 def interpolate_3d(self, s: float, t: float = 0.0, lane_section_end: bool = False) -> npt.NDArray[np.float64]: - se2 = self.interpolate_se2(s, t, lane_section_end=lane_section_end) elevation_polynomial = self._find_polynomial(s, self.elevations, lane_section_end=lane_section_end) diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py index 7a4368c5..32090d2b 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py @@ -33,7 +33,6 @@ def collect_element_helpers( Dict[str, OpenDriveLaneGroupHelper], Dict[int, OpenDriveObjectHelper], ]: - # 1. Fill the road and junction dictionaries road_dict: Dict[int, XODRRoad] = {road.id: road for road in opendrive.roads} junction_dict: Dict[int, Junction] = {junction.id: junction for junction in opendrive.junctions} @@ -182,7 +181,6 @@ def _update_connection_from_junctions( connecting_road = road_dict[connection.connecting_road] for lane_link in connection.lane_links: - incoming_lane_id: Optional[str] = None connecting_lane_id: Optional[str] = None @@ -247,7 +245,6 @@ def _post_process_connections( successor_centerline = lane_helper_dict[successor_lane_id].center_polyline_se2 distance = np.linalg.norm(centerline[-1, :2] - successor_centerline[0, :2]) if distance > connection_distance_threshold: - logger.debug( f"OpenDRIVE: Removing connection {lane_id} -> {successor_lane_id} with distance {distance}" ) @@ -273,7 +270,6 @@ def _collect_lane_groups( junction_dict: Dict[int, Junction], road_dict: Dict[int, XODRRoad], ) -> None: - lane_group_helper_dict: Dict[str, OpenDriveLaneGroupHelper] = {} def _collect_lane_helper_of_id(lane_group_id: str) -> List[OpenDriveLaneHelper]: @@ -309,7 +305,6 @@ def _collect_lane_group_ids_of_road(road_id: int) -> List[str]: def _collect_crosswalks(opendrive: XODR) -> Dict[int, OpenDriveObjectHelper]: - object_helper_dict: Dict[int, OpenDriveObjectHelper] = {} for road in opendrive.roads: if len(road.objects) == 0: diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py index 27098773..e7083a98 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/lane_helper.py @@ -21,7 +21,6 @@ @dataclass class OpenDriveLaneHelper: - lane_id: str open_drive_lane: XODRLane s_inner_offset: float @@ -172,7 +171,6 @@ def shapely_polygon(self) -> shapely.Polygon: @dataclass class OpenDriveLaneGroupHelper: - lane_group_id: str lane_helpers: List[OpenDriveLaneHelper] @@ -182,7 +180,6 @@ class OpenDriveLaneGroupHelper: junction_id: Optional[int] = None def __post_init__(self): - predecessor_lane_group_ids = [] successor_lane_group__ids = [] for lane_helper in self.lane_helpers: @@ -262,7 +259,6 @@ def lane_section_to_lane_helpers( road_types: List[XODRRoadType], interpolation_step_size: float, ) -> Dict[str, OpenDriveLaneHelper]: - lane_helpers: Dict[str, OpenDriveLaneHelper] = {} for lanes, t_sign, side in zip([lane_section.left_lanes, lane_section.right_lanes], [1.0, -1.0], ["left", "right"]): @@ -295,7 +291,6 @@ def lane_section_to_lane_helpers( def _get_speed_limit_mps(s: float, road_types: List[XODRRoadType]) -> Optional[float]: - # NOTE: Likely not correct way to extract speed limit from CARLA maps, but serves as a placeholder speed_limit_mps: Optional[float] = None s_road_types = [road_type.s for road_type in road_types] + [float("inf")] diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py index 97033812..899a7491 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/objects_helper.py @@ -16,7 +16,6 @@ @dataclass class OpenDriveObjectHelper: - object_id: int outline_3d: npt.NDArray[np.float64] @@ -34,7 +33,6 @@ def shapely_polygon(self) -> shapely.Polygon: def get_object_helper(object: XODRObject, reference_line: XODRReferenceLine) -> OpenDriveObjectHelper: - object_helper: Optional[OpenDriveObjectHelper] = None # 1. Extract object position in frenet frame of the reference line diff --git a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py index f777dff4..3852149d 100644 --- a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py +++ b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py @@ -162,7 +162,6 @@ def lift_road_edges_to_3d( road_edges_3d: List[Polyline3D] = [] if len(road_edges_2d) >= 1 and len(boundaries) >= 1: - # 1. Build comprehensive spatial index with all boundary segments # NOTE @DanielDauner: We split each boundary polyline into small segments. # The spatial indexing uses axis-aligned bounding boxes, where small geometries lead to better performance. @@ -275,7 +274,6 @@ def _resolve_conflicting_lane_groups( road_edges_3d: List[Polyline3D] = [] for non_conflicting_set in non_conflicting_sets: - # Collect 2D polygons of non-conflicting lane group set and their neighbors merge_lane_group_data: Dict[MapObjectIDType, geom.Polygon] = {} for lane_group_id in non_conflicting_set: diff --git a/src/py123d/datatypes/map_objects/base_map_objects.py b/src/py123d/datatypes/map_objects/base_map_objects.py index 72ac5fb9..f1d70d7b 100644 --- a/src/py123d/datatypes/map_objects/base_map_objects.py +++ b/src/py123d/datatypes/map_objects/base_map_objects.py @@ -1,11 +1,12 @@ from __future__ import annotations import abc -from typing import Optional, TypeAlias, Union +from typing import Optional, Union import numpy as np import shapely.geometry as geom import trimesh +from typing_extensions import TypeAlias from py123d.datatypes.map_objects.map_layer_types import MapLayer from py123d.geometry import Point3DIndex, Polyline2D, Polyline3D diff --git a/src/py123d/datatypes/map_objects/map_objects.py b/src/py123d/datatypes/map_objects/map_objects.py index f722a985..60298905 100644 --- a/src/py123d/datatypes/map_objects/map_objects.py +++ b/src/py123d/datatypes/map_objects/map_objects.py @@ -436,7 +436,6 @@ def layer(self) -> MapLayer: class Walkway(BaseMapSurfaceObject): - __slots__ = () def __init__( diff --git a/src/py123d/datatypes/map_objects/utils.py b/src/py123d/datatypes/map_objects/utils.py index 88eb47b5..7d5557d1 100644 --- a/src/py123d/datatypes/map_objects/utils.py +++ b/src/py123d/datatypes/map_objects/utils.py @@ -18,8 +18,7 @@ def get_trimesh_from_boundaries( def _interpolate_polyline(polyline_3d: Polyline3D, num_samples: int) -> npt.NDArray[np.float64]: """Helper function to interpolate a polyline to a fixed number of samples.""" - if num_samples < 2: - num_samples = 2 + num_samples = max(num_samples, 2) distances = np.linspace(0, polyline_3d.length, num=num_samples, endpoint=True, dtype=np.float64) return polyline_3d.interpolate(distances) diff --git a/src/py123d/datatypes/sensors/fisheye_mei_camera.py b/src/py123d/datatypes/sensors/fisheye_mei_camera.py index 808304ea..ffbeccf4 100644 --- a/src/py123d/datatypes/sensors/fisheye_mei_camera.py +++ b/src/py123d/datatypes/sensors/fisheye_mei_camera.py @@ -1,11 +1,11 @@ from __future__ import annotations from dataclasses import dataclass +from enum import IntEnum from typing import Any, Dict, Optional import numpy as np import numpy.typing as npt -from zmq import IntEnum from py123d.common.utils.enums import SerialIntEnum from py123d.common.utils.mixin import ArrayMixin @@ -76,7 +76,6 @@ class FisheyeMEIDistortionIndex(IntEnum): class FisheyeMEIDistortion(ArrayMixin): - __slots__ = ("_array",) _array: npt.NDArray[np.float64] diff --git a/src/py123d/datatypes/time/time_point.py b/src/py123d/datatypes/time/time_point.py index 1564fe95..be927d8e 100644 --- a/src/py123d/datatypes/time/time_point.py +++ b/src/py123d/datatypes/time/time_point.py @@ -4,7 +4,7 @@ class TimePoint: """Time instance in a time series.""" - __slots__ = "_time_us" + __slots__ = ("_time_us",) _time_us: int # [micro seconds] time since epoch in micro seconds @classmethod diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index c25013a5..c6afc059 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -106,11 +106,6 @@ def from_rear_axle( tire_steering_angle=tire_steering_angle, ) - @property - def rear_axle(self) -> PoseSE3: - """The :class:`~py123d.geometry.PoseSE3` of the rear axle in SE3.""" - return self._rear_axle_se3 - @property def rear_axle_se3(self) -> PoseSE3: """The :class:`~py123d.geometry.PoseSE3` of the rear axle in SE3.""" @@ -159,11 +154,6 @@ def center_se2(self) -> PoseSE2: """The :class:`~py123d.geometry.PoseSE2` of the vehicle center in SE2.""" return self.center_se3.pose_se2 - @property - def bounding_box(self) -> BoundingBoxSE3: - """The :class:`~py123d.geometry.BoundingBoxSE3` of the ego vehicle.""" - return self.bounding_box_se3 - @property def bounding_box_se3(self) -> BoundingBoxSE3: """The :class:`~py123d.geometry.BoundingBoxSE3` of the ego vehicle.""" @@ -177,7 +167,7 @@ def bounding_box_se3(self) -> BoundingBoxSE3: @property def bounding_box_se2(self) -> BoundingBoxSE2: """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" - return self.bounding_box.bounding_box_se2 + return self.bounding_box_se3.bounding_box_se2 @property def box_detection(self) -> BoxDetectionSE3: @@ -194,7 +184,7 @@ def box_detection_se3(self) -> BoxDetectionSE3: track_token=EGO_TRACK_TOKEN, num_lidar_points=None, ), - bounding_box_se3=self.bounding_box, + bounding_box_se3=self.bounding_box_se3, velocity=self.dynamic_state_se3.velocity, ) diff --git a/src/py123d/geometry/occupancy_map.py b/src/py123d/geometry/occupancy_map.py index a92a888c..8ee6ed32 100644 --- a/src/py123d/geometry/occupancy_map.py +++ b/src/py123d/geometry/occupancy_map.py @@ -108,6 +108,7 @@ def query( Literal[ "intersects", "within", + "dwithin", "contains", "overlaps", "crosses", diff --git a/src/py123d/geometry/polyline.py b/src/py123d/geometry/polyline.py index a165a71e..90974f5e 100644 --- a/src/py123d/geometry/polyline.py +++ b/src/py123d/geometry/polyline.py @@ -42,7 +42,7 @@ def from_linestring(cls, linestring: geom.LineString) -> Polyline2D: :return: A Polyline2D instance. """ if linestring.has_z: - linestring_ = geom_creation.linestrings(*linestring.xy) + linestring_ = geom_creation.linestrings(*linestring.xy) # pyright: ignore[reportUnknownMemberType] else: linestring_ = linestring @@ -51,7 +51,7 @@ def from_linestring(cls, linestring: geom.LineString) -> Polyline2D: return instance @classmethod - def from_array(cls, polyline_array: npt.NDArray[np.float32]) -> Polyline2D: + def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Polyline2D: """Creates a :class:`Polyline2D` from a (N, 2) or (N, 3) shaped numpy array. \ Assumes [...,:2] slices are XY coordinates. @@ -60,12 +60,12 @@ def from_array(cls, polyline_array: npt.NDArray[np.float32]) -> Polyline2D: :raises ValueError: If the input array is not of the expected shape. :return: A :class:`Polyline2D` instance. """ - assert polyline_array.ndim == 2 + assert array.ndim == 2 linestring_: Optional[geom.LineString] = None - if polyline_array.shape[-1] == len(Point2DIndex): - linestring_ = geom.LineString(polyline_array) - elif polyline_array.shape[-1] == len(Point3DIndex): - linestring_ = geom.LineString(polyline_array[:, Point3DIndex.XY]) + if array.shape[-1] == len(Point2DIndex): + linestring_ = geom.LineString(array) + elif array.shape[-1] == len(Point3DIndex): + linestring_ = geom.LineString(array[:, Point3DIndex.XY]) # pyright: ignore[reportUnknownMemberType] else: raise ValueError("Array must have shape (N, 2) or (N, 3) for Point2D or Point3D respectively.") diff --git a/src/py123d/geometry/transform/transform_euler_se3.py b/src/py123d/geometry/transform/transform_euler_se3.py index 4454f066..625831a2 100644 --- a/src/py123d/geometry/transform/transform_euler_se3.py +++ b/src/py123d/geometry/transform/transform_euler_se3.py @@ -12,7 +12,6 @@ def translate_euler_se3_along_z(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: - R = pose_se3.rotation_matrix z_axis = R[:, 2] @@ -22,7 +21,6 @@ def translate_euler_se3_along_z(pose_se3: EulerStateSE3, distance: float) -> Eul def translate_euler_se3_along_y(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: - R = pose_se3.rotation_matrix y_axis = R[:, 1] @@ -32,7 +30,6 @@ def translate_euler_se3_along_y(pose_se3: EulerStateSE3, distance: float) -> Eul def translate_euler_se3_along_x(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: - R = pose_se3.rotation_matrix x_axis = R[:, 0] @@ -42,7 +39,6 @@ def translate_euler_se3_along_x(pose_se3: EulerStateSE3, distance: float) -> Eul def translate_euler_se3_along_body_frame(pose_se3: EulerStateSE3, vector_3d: Vector3D) -> EulerStateSE3: - R = pose_se3.rotation_matrix world_translation = R @ vector_3d.array @@ -54,7 +50,6 @@ def translate_euler_se3_along_body_frame(pose_se3: EulerStateSE3, vector_3d: Vec def convert_absolute_to_relative_euler_se3_array( origin: Union[EulerStateSE3, npt.NDArray[np.float64]], se3_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): origin_array = origin.array t_origin = origin.point_3d.array @@ -95,7 +90,6 @@ def convert_absolute_to_relative_euler_se3_array( def convert_relative_to_absolute_euler_se3_array( origin: EulerStateSE3, se3_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): origin_array = origin.array t_origin = origin.point_3d.array @@ -132,7 +126,6 @@ def convert_relative_to_absolute_euler_se3_array( def convert_absolute_to_relative_points_3d_array( origin: Union[EulerStateSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): t_origin = origin.point_3d.array R_origin = origin.rotation_matrix @@ -154,7 +147,6 @@ def convert_absolute_to_relative_points_3d_array( def convert_relative_to_absolute_points_3d_array( origin: Union[EulerStateSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): origin_array = origin.array elif isinstance(origin, np.ndarray): diff --git a/src/py123d/script/run_conversion.py b/src/py123d/script/run_conversion.py index 52ed5cd6..e1504b4a 100644 --- a/src/py123d/script/run_conversion.py +++ b/src/py123d/script/run_conversion.py @@ -32,7 +32,6 @@ def main(cfg: DictConfig) -> None: dataset_converters: List[AbstractDatasetConverter] = build_dataset_converters(cfg.datasets) for dataset_converter in dataset_converters: - worker = build_worker(cfg) logger.info(f"Processing dataset: {dataset_converter.__class__.__name__}") @@ -55,7 +54,6 @@ def main(cfg: DictConfig) -> None: def _convert_maps(args: List[Dict[str, int]], cfg: DictConfig, dataset_converter: AbstractDatasetConverter) -> List: - map_writer = build_map_writer(cfg.map_writer) for arg in args: dataset_converter.convert_map(arg["map_index"], map_writer) @@ -63,7 +61,6 @@ def _convert_maps(args: List[Dict[str, int]], cfg: DictConfig, dataset_converter def _convert_logs(args: List[Dict[str, int]], cfg: DictConfig, dataset_converter: AbstractDatasetConverter) -> None: - setup_dataset_paths(cfg.dataset_paths) def _internal_convert_log(args: Dict[str, int], dataset_converter_: AbstractDatasetConverter) -> int: diff --git a/src/py123d/script/run_viser.py b/src/py123d/script/run_viser.py index df14382c..0d76b33d 100644 --- a/src/py123d/script/run_viser.py +++ b/src/py123d/script/run_viser.py @@ -18,7 +18,6 @@ @hydra.main(config_path=CONFIG_PATH, config_name=CONFIG_NAME, version_base=None) def main(cfg: DictConfig) -> None: - # Initialize dataset paths setup_dataset_paths(cfg.dataset_paths) diff --git a/src/py123d/script/utils/dataset_path_utils.py b/src/py123d/script/utils/dataset_path_utils.py index 393c05f4..d557246c 100644 --- a/src/py123d/script/utils/dataset_path_utils.py +++ b/src/py123d/script/utils/dataset_path_utils.py @@ -15,7 +15,7 @@ def setup_dataset_paths(cfg: DictConfig) -> None: :return: None """ - global _global_dataset_paths + global _global_dataset_paths # noqa: PLW0603 if _global_dataset_paths is None: # Make it immutable @@ -23,8 +23,6 @@ def setup_dataset_paths(cfg: DictConfig) -> None: OmegaConf.set_readonly(cfg, True) # Prevents any modifications _global_dataset_paths = cfg - return None - def get_dataset_paths() -> DictConfig: """Get the global dataset paths from anywhere in your code. diff --git a/src/py123d/visualization/matplotlib/camera.py b/src/py123d/visualization/matplotlib/camera.py index a69a1413..949c5266 100644 --- a/src/py123d/visualization/matplotlib/camera.py +++ b/src/py123d/visualization/matplotlib/camera.py @@ -75,7 +75,6 @@ def add_box_detections_to_camera_ax( ego_state_se3: EgoStateSE3, return_image: bool = False, ) -> plt.Axes: - box_detection_array = np.zeros((len(box_detections.box_detections), len(BoundingBoxSE3Index)), dtype=np.float64) default_labels = np.array( [detection.metadata.default_label for detection in box_detections.box_detections], dtype=object @@ -120,12 +119,7 @@ def add_box_detections_to_camera_ax( return ax -def _transform_annotations_to_camera( - boxes: npt.NDArray[np.float32], - # sensor2lidar_rotation: npt.NDArray[np.float32], - # sensor2lidar_translation: npt.NDArray[np.float32], - extrinsic: npt.NDArray[np.float32], -) -> npt.NDArray[np.float32]: +def _transform_annotations_to_camera(boxes: npt.NDArray, extrinsic: npt.NDArray) -> npt.NDArray: """ Helper function to transform bounding boxes into camera frame TODO: Refactor @@ -187,7 +181,7 @@ def _rotation_3d_in_axis(points: npt.NDArray[np.float32], angles: npt.NDArray[np np.stack([rot_sin, zeros, rot_cos]), ] ) - elif axis == 2 or axis == -1: + elif axis in [2, -1]: rot_mat_T = np.stack( [ np.stack([rot_cos, -rot_sin, zeros]), @@ -209,7 +203,7 @@ def _rotation_3d_in_axis(points: npt.NDArray[np.float32], angles: npt.NDArray[np def _plot_rect_3d_on_img( - image: npt.NDArray[np.float32], + image: npt.NDArray[np.uint8], box_corners: npt.NDArray[np.float32], labels: List[DefaultBoxDetectionLabel], thickness: int = 3, diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index 85e0628d..884e6c39 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -1,3 +1,4 @@ +import traceback from typing import List, Optional, Union import matplotlib.pyplot as plt @@ -5,8 +6,6 @@ import shapely.geometry as geom from py123d.api.map.map_api import MapAPI -from py123d.api.scene.scene_api import SceneAPI -from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper from py123d.datatypes.map_objects.map_layer_types import MapLayer @@ -72,8 +71,6 @@ def add_default_map_on_ax( map_object: Lane add_shapely_linestring_to_ax(ax, map_object.centerline.linestring, CENTERLINE_CONFIG) except Exception: - import traceback - print(f"Error adding map object of type {layer.name} and id {map_object.object_id}") traceback.print_exc() @@ -89,39 +86,6 @@ def add_box_detections_to_ax(ax: plt.Axes, box_detections: BoxDetectionWrapper) add_bounding_box_to_ax(ax, box_detection.bounding_box_se2, plot_config) -def add_box_future_detections_to_ax(ax: plt.Axes, scene: SceneAPI, iteration: int) -> None: - - # TODO: Refactor this function - initial_agents = scene.get_box_detections_at_iteration(iteration) - agents_poses = { - agent.metadata.track_token: [agent.center_se3] - for agent in initial_agents - if agent.metadata.default_label == DefaultBoxDetectionLabel.VEHICLE - } - frequency = 1 - for iteration in range(iteration + frequency, scene.number_of_iterations, frequency): - agents = scene.get_box_detections_at_iteration(iteration) - for agent in agents: - if agent.metadata.track_token in agents_poses: - agents_poses[agent.metadata.track_token].append(agent.center_se3) - - for track_token, poses in agents_poses.items(): - if len(poses) < 2: - continue - poses = np.array([pose.point_2d.array for pose in poses]) - num_poses = poses.shape[0] - alphas = 1 - np.linspace(0.2, 1.0, num_poses) # Start low, end high - for i in range(num_poses - 1): - ax.plot( - poses[i : i + 2, 0], - poses[i : i + 2, 1], - color=BOX_DETECTION_CONFIG[DefaultBoxDetectionLabel.VEHICLE].fill_color.hex, - alpha=alphas[i + 1], - linewidth=BOX_DETECTION_CONFIG[DefaultBoxDetectionLabel.VEHICLE].line_width * 5, - zorder=BOX_DETECTION_CONFIG[DefaultBoxDetectionLabel.VEHICLE].zorder, - ) - - def add_ego_vehicle_to_ax(ax: plt.Axes, ego_vehicle_state: Union[EgoStateSE3, EgoStateSE2]) -> None: add_bounding_box_to_ax(ax, ego_vehicle_state.bounding_box, EGO_VEHICLE_CONFIG) @@ -130,7 +94,8 @@ def add_traffic_lights_to_ax( ax: plt.Axes, traffic_light_detections: TrafficLightDetectionWrapper, map_api: MapAPI ) -> None: for traffic_light_detection in traffic_light_detections: - lane: Lane = map_api.get_map_object(str(traffic_light_detection.lane_id), MapLayer.LANE) + lane = map_api.get_map_object(traffic_light_detection.lane_id, MapLayer.LANE) + assert isinstance(lane, Lane), f"Lane with id {traffic_light_detection.lane_id} not found." if lane is not None: add_shapely_linestring_to_ax( ax, @@ -146,7 +111,6 @@ def add_bounding_box_to_ax( bounding_box: Union[BoundingBoxSE2, BoundingBoxSE3], plot_config: PlotConfig, ) -> None: - add_shapely_polygon_to_ax(ax, bounding_box.shapely_polygon, plot_config) if plot_config.marker_style is not None: @@ -169,9 +133,10 @@ def add_bounding_box_to_ax( linestyle=plot_config.line_style, ) elif plot_config.marker_style == "^": - marker_size = min(plot_config.marker_size, min(bounding_box.length, bounding_box.width)) + min_extent = min(bounding_box.length, bounding_box.width) + marker_size = min(plot_config.marker_size, min_extent) marker_polygon = get_pose_triangle(marker_size) - global_marker_polygon = shapely_geometry_local_coords(marker_polygon, bounding_box.center) + global_marker_polygon = shapely_geometry_local_coords(marker_polygon, bounding_box.center_se2) add_shapely_polygon_to_ax(ax, global_marker_polygon, plot_config, disable_smoothing=True) else: raise ValueError(f"Unknown marker style: {plot_config.marker_style}") diff --git a/src/py123d/visualization/matplotlib/plots.py b/src/py123d/visualization/matplotlib/plots.py index 4419082b..99741006 100644 --- a/src/py123d/visualization/matplotlib/plots.py +++ b/src/py123d/visualization/matplotlib/plots.py @@ -1,8 +1,8 @@ from pathlib import Path from typing import Optional, Tuple, Union -import matplotlib.animation as animation import matplotlib.pyplot as plt +from matplotlib import animation from tqdm import tqdm from py123d.api.scene.scene_api import SceneAPI @@ -15,15 +15,15 @@ def _plot_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: float = 80) -> plt.Axes: - ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) box_detections = scene.get_box_detections_at_iteration(iteration) traffic_light_detections = scene.get_traffic_light_detections_at_iteration(iteration) route_lane_group_ids = scene.get_route_lane_group_ids(iteration) map_api = scene.get_map_api() - point_2d = ego_vehicle_state.bounding_box.center.pose_se2.point_2d + assert ego_vehicle_state is not None, "Ego vehicle state is required to plot the scene." if map_api is not None: + point_2d = ego_vehicle_state.bounding_box_se2.center_se2.pose_se2.point_2d add_default_map_on_ax(ax, map_api, point_2d, radius=radius, route_lane_group_ids=route_lane_group_ids) if traffic_light_detections is not None: add_traffic_lights_to_ax(ax, traffic_light_detections, map_api) @@ -39,7 +39,6 @@ def _plot_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: def plot_scene_at_iteration(scene: SceneAPI, iteration: int = 0, radius: float = 80) -> Tuple[plt.Figure, plt.Axes]: - fig, ax = plt.subplots(figsize=(10, 10)) _plot_scene_on_ax(ax, scene, iteration, radius) return fig, ax diff --git a/src/py123d/visualization/matplotlib/utils.py b/src/py123d/visualization/matplotlib/utils.py index e0a81566..bffdfd40 100644 --- a/src/py123d/visualization/matplotlib/utils.py +++ b/src/py123d/visualization/matplotlib/utils.py @@ -1,11 +1,11 @@ from typing import Union -import matplotlib.patches as patches import matplotlib.pyplot as plt import numpy as np -import shapely.affinity as affinity import shapely.geometry as geom +from matplotlib import patches from matplotlib.path import Path +from shapely import affinity from py123d.geometry import PoseSE2, PoseSE3 from py123d.visualization.color.config import PlotConfig diff --git a/src/py123d/visualization/viser/elements/detection_elements.py b/src/py123d/visualization/viser/elements/detection_elements.py index bc94505e..c77679c4 100644 --- a/src/py123d/visualization/viser/elements/detection_elements.py +++ b/src/py123d/visualization/viser/elements/detection_elements.py @@ -68,7 +68,6 @@ def add_box_detections_to_viser_server( def _get_bounding_box_meshes(scene: SceneAPI, iteration: int, initial_ego_state: EgoStateSE3) -> trimesh.Trimesh: - ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) box_detections = scene.get_box_detections_at_iteration(iteration) @@ -129,7 +128,6 @@ def _get_bounding_box_meshes(scene: SceneAPI, iteration: int, initial_ego_state: def _get_bounding_box_outlines( scene: SceneAPI, iteration: int, initial_ego_state: EgoStateSE3 ) -> npt.NDArray[np.float64]: - ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) box_detections = scene.get_box_detections_at_iteration(iteration) diff --git a/src/py123d/visualization/viser/elements/map_elements.py b/src/py123d/visualization/viser/elements/map_elements.py index 88a76182..b3a02aa7 100644 --- a/src/py123d/visualization/viser/elements/map_elements.py +++ b/src/py123d/visualization/viser/elements/map_elements.py @@ -23,10 +23,9 @@ def add_map_to_viser_server( viser_config: ViserConfig, map_handles: Dict[MapLayer, viser.GlbHandle], ) -> None: - global last_query_position + global last_query_position # noqa: PLW0603 if viser_config.map_visible: - map_trimesh_dict: Optional[Dict[MapLayer, trimesh.Trimesh]] = None if len(map_handles) == 0 or viser_config._force_map_update: @@ -71,7 +70,6 @@ def _get_map_trimesh_dict( current_ego_state: Optional[EgoStateSE3], viser_config: ViserConfig, ) -> Dict[MapLayer, trimesh.Trimesh]: - # Dictionary to hold the output trimesh meshes. output_trimesh_dict: Dict[MapLayer, trimesh.Trimesh] = {} diff --git a/src/py123d/visualization/viser/elements/render_elements.py b/src/py123d/visualization/viser/elements/render_elements.py index a8d91a64..6f247c9f 100644 --- a/src/py123d/visualization/viser/elements/render_elements.py +++ b/src/py123d/visualization/viser/elements/render_elements.py @@ -57,7 +57,6 @@ def get_ego_bev_view_position( def _pitch_se3_by_degrees(pose_se3: PoseSE3, degrees: float) -> PoseSE3: - quaternion = EulerAngles(0.0, np.deg2rad(degrees), pose_se3.yaw).quaternion return PoseSE3( diff --git a/src/py123d/visualization/viser/elements/sensor_elements.py b/src/py123d/visualization/viser/elements/sensor_elements.py index 25014a43..b39399b2 100644 --- a/src/py123d/visualization/viser/elements/sensor_elements.py +++ b/src/py123d/visualization/viser/elements/sensor_elements.py @@ -33,7 +33,6 @@ def add_camera_frustums_to_viser_server( viser_config: ViserConfig, camera_frustum_handles: Dict[PinholeCameraType, viser.CameraFrustumHandle], ) -> None: - if viser_config.camera_frustum_visible: scene_center_array = initial_ego_state.center.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array @@ -62,8 +61,6 @@ def _add_camera_frustums_to_viser_server(camera_type: PinholeCameraType) -> None wxyz=camera_quaternion, ) - return None - # NOTE; In order to speed up adding camera frustums, we use multithreading and resize the images. with concurrent.futures.ThreadPoolExecutor(max_workers=len(viser_config.camera_frustum_types)) as executor: future_to_camera = { @@ -118,8 +115,6 @@ def _add_fisheye_frustums_to_viser_server(fisheye_camera_type: FisheyeMEICameraT wxyz=fcam_quaternion, ) - return None - # NOTE; In order to speed up adding camera frustums, we use multithreading and resize the images. with concurrent.futures.ThreadPoolExecutor( max_workers=len(viser_config.fisheye_mei_camera_frustum_types) @@ -166,7 +161,6 @@ def add_lidar_pc_to_viser_server( lidar_pc_handles: Dict[LiDARType, Optional[viser.PointCloudHandle]], ) -> None: if viser_config.lidar_visible: - scene_center_array = initial_ego_state.center.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array ego_pose[PoseSE3Index.XYZ] -= scene_center_array @@ -269,9 +263,6 @@ def _rescale_image(image: npt.NDArray[np.uint8], scale: float) -> npt.NDArray[np return downscaled_image -import numpy as np - - def calculate_fov(metadata: FisheyeMEICameraMetadata) -> tuple[float, float]: """ Calculate horizontal and vertical FOV in degrees. diff --git a/src/py123d/visualization/viser/viser_config.py b/src/py123d/visualization/viser/viser_config.py index 2bd4871f..2907a45d 100644 --- a/src/py123d/visualization/viser/viser_config.py +++ b/src/py123d/visualization/viser/viser_config.py @@ -33,7 +33,6 @@ @dataclass class ViserConfig: - # Server server_host: str = "localhost" server_port: int = 8080 @@ -98,7 +97,6 @@ def __post_init__(self): def _resolve_enum_arguments( serial_enum_cls: SerialIntEnum, input: Optional[List[Union[int, str, SerialIntEnum]]] ) -> List[SerialIntEnum]: - if input is None: return None assert isinstance(input, list), f"input must be a list of {serial_enum_cls.__name__}" diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index f097dc6e..ed020dcb 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -101,12 +101,14 @@ def next(self) -> None: def set_scene(self, scene: SceneAPI) -> None: num_frames = scene.number_of_iterations - initial_ego_state: EgoStateSE3 = scene.get_ego_state_at_iteration(0) + initial_ego_state = scene.get_ego_state_at_iteration(0) + assert initial_ego_state is not None and isinstance(initial_ego_state, EgoStateSE3) + server_playing = True server_rendering = False with self._viser_server.gui.add_folder("Playback"): - gui_info = self._viser_server.gui.add_markdown(content=_get_scene_info_markdown(scene)) + self._viser_server.gui.add_markdown(content=_get_scene_info_markdown(scene)) gui_timestep = self._viser_server.gui.add_slider( "Timestep", @@ -395,7 +397,7 @@ def _(event: viser.GuiEvent) -> None: def _get_scene_info_markdown(scene: SceneAPI) -> str: markdown = f""" - Dataset: {scene.log_metadata.split} - - Location: {scene.log_metadata.location if scene.log_metadata.location else 'N/A'} + - Location: {scene.log_metadata.location if scene.log_metadata.location else "N/A"} - UUID: {scene.scene_uuid} """ return markdown diff --git a/tests/unit/conversion/registry/test_box_detection_label_registry.py b/tests/unit/conversion/registry/test_box_detection_label_registry.py index 384b0b96..393e3527 100644 --- a/tests/unit/conversion/registry/test_box_detection_label_registry.py +++ b/tests/unit/conversion/registry/test_box_detection_label_registry.py @@ -2,7 +2,6 @@ class TestBoxDetectionLabelRegistry: - def test_correct_type(self): """Test that all registered box detection labels are of correct type.""" for label_class in BOX_DETECTION_LABEL_REGISTRY.values(): diff --git a/tests/unit/conversion/registry/test_lidar_registry.py b/tests/unit/conversion/registry/test_lidar_registry.py index eca6bd59..c93bc538 100644 --- a/tests/unit/conversion/registry/test_lidar_registry.py +++ b/tests/unit/conversion/registry/test_lidar_registry.py @@ -6,7 +6,6 @@ class TestLiDARRegistry: - def test_registered_types(self): """Test that all registered LiDAR types are of correct type.""" for lidar_class in LIDAR_INDEX_REGISTRY.values(): diff --git a/tests/unit/datatypes/detections/test_box_detections.py b/tests/unit/datatypes/detections/test_box_detections.py index cdcee382..59509efa 100644 --- a/tests/unit/datatypes/detections/test_box_detections.py +++ b/tests/unit/datatypes/detections/test_box_detections.py @@ -12,7 +12,6 @@ class DummyBoxDetectionLabel(BoxDetectionLabel): - CAR = 1 PEDESTRIAN = 2 BICYCLE = 3 @@ -35,7 +34,6 @@ def to_default(self): class TestBoxDetectionMetadata: - def test_initialization(self): metadata = BoxDetectionMetadata(**sample_metadata_args) assert isinstance(metadata, BoxDetectionMetadata) @@ -94,7 +92,6 @@ def test_missing_args(self): class TestBoxDetectionSE2: - def setup_method(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se2 = BoundingBoxSE2( @@ -143,7 +140,6 @@ def test_optional_velocity(self): class TestBoxBoxDetectionSE3: - def setup_method(self): self.metadata = BoxDetectionMetadata(**sample_metadata_args) self.bounding_box_se3 = BoundingBoxSE3( @@ -231,7 +227,6 @@ def test_optional_velocity(self): class TestBoxDetectionWrapper: - def setup_method(self): self.metadata1 = BoxDetectionMetadata( label=DummyBoxDetectionLabel.CAR, diff --git a/tests/unit/datatypes/map_objects/mock_map_api.py b/tests/unit/datatypes/map_objects/mock_map_api.py index a5cdf27f..9158df1f 100644 --- a/tests/unit/datatypes/map_objects/mock_map_api.py +++ b/tests/unit/datatypes/map_objects/mock_map_api.py @@ -20,7 +20,6 @@ class MockMapAPI(MapAPI): - def __init__( self, lanes: List[Lane] = [], @@ -35,7 +34,6 @@ def __init__( road_lines: List[RoadLine] = [], add_map_api_links: bool = False, ): - self._layers: Dict[MapLayer, List[BaseMapObject]] = { MapLayer.LANE: lanes, MapLayer.LANE_GROUP: lane_groups, diff --git a/tests/unit/datatypes/map_objects/test_map_objects.py b/tests/unit/datatypes/map_objects/test_map_objects.py index ed36ffc3..252500cd 100644 --- a/tests/unit/datatypes/map_objects/test_map_objects.py +++ b/tests/unit/datatypes/map_objects/test_map_objects.py @@ -427,7 +427,6 @@ def test_lane_group_links(self): class TestLaneGroup: - def setup_method(self): lanes, lane_groups, intersections = _get_linked_map_object_setup() self.lanes = lanes @@ -610,7 +609,6 @@ def test_no_links(self): class TestIntersection: - def setup_method(self): lanes, lane_groups, intersections = _get_linked_map_object_setup() self.lanes = lanes diff --git a/tests/unit/datatypes/metadata/test_log_metadata.py b/tests/unit/datatypes/metadata/test_log_metadata.py index 7afb0bac..8d8348b8 100644 --- a/tests/unit/datatypes/metadata/test_log_metadata.py +++ b/tests/unit/datatypes/metadata/test_log_metadata.py @@ -8,7 +8,6 @@ class TestLogMetadata: - def test_init_minimal(self): """Test LogMetadata initialization with minimal required fields.""" log_metadata = LogMetadata( diff --git a/tests/unit/datatypes/metadata/test_map_metadata.py b/tests/unit/datatypes/metadata/test_map_metadata.py index a5ad7279..2af85186 100644 --- a/tests/unit/datatypes/metadata/test_map_metadata.py +++ b/tests/unit/datatypes/metadata/test_map_metadata.py @@ -2,7 +2,6 @@ class TestMapMetadata: - def test_map_metadata_initialization(self): """Test that MapMetadata can be initialized with required fields.""" metadata = MapMetadata( diff --git a/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py b/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py index 7cf5d635..0731b56d 100644 --- a/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py +++ b/tests/unit/datatypes/sensors/test_fisheye_mei_camera.py @@ -14,7 +14,6 @@ class TestFisheyeMEICameraType: - def test_camera_type_values(self): """Test that camera type enum has expected values.""" assert FisheyeMEICameraType.FCAM_L.value == 0 @@ -39,7 +38,6 @@ def test_camera_type_comparison(self): class TestFisheyeMEIDistortion: - def test_distortion_initialization(self): """Test distortion parameter initialization.""" distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) @@ -88,7 +86,6 @@ def test_distortion_index_mapping(self): class TestFisheyeMEIProjection: - def test_projection_initialization(self): """Test projection parameter initialization.""" projection = FisheyeMEIProjection(gamma1=1.0, gamma2=2.0, u0=3.0, v0=4.0) @@ -137,7 +134,6 @@ def test_projection_index_mapping(self): class TestFisheyeMEICameraMetadata: - def test_metadata_initialization(self): """Test metadata initialization with all parameters.""" distortion = FisheyeMEIDistortion(k1=0.1, k2=0.2, p1=0.3, p2=0.4) @@ -277,7 +273,6 @@ def test_aspect_ratio_calculation(self): class TestFisheyeMEICamera: - def test_camera_initialization(self): """Test FisheyeMEICamera initialization.""" diff --git a/tests/unit/datatypes/sensors/test_pinhole_camera.py b/tests/unit/datatypes/sensors/test_pinhole_camera.py index e0811a9e..f05ab970 100644 --- a/tests/unit/datatypes/sensors/test_pinhole_camera.py +++ b/tests/unit/datatypes/sensors/test_pinhole_camera.py @@ -13,7 +13,6 @@ class TestPinholeCameraType: - def test_camera_type_values(self): """Test that camera type enum has expected values.""" assert PinholeCameraType.PCAM_F0 == PinholeCameraType.PCAM_F0 @@ -45,7 +44,6 @@ def test_camera_type_unique_values(self): class TestPinholeIntrinsics: - def test_intrinsics_creation(self): """Test creating PinholeIntrinsics instance.""" intrinsics = PinholeIntrinsics(fx=500.0, fy=500.0, cx=320.0, cy=240.0, skew=0.0) @@ -175,7 +173,6 @@ def test_intrinsics_non_centered_principal_point(self): class TestPinholeDistortion: - def test_distortion_creation(self): """Test creating PinholeDistortion instance.""" distortion = PinholeDistortion(k1=0.1, k2=0.01, p1=0.001, p2=0.001, k3=0.001) @@ -274,7 +271,6 @@ def test_distortion_tolist(self): class TestPinholeMetadata: - def test_metadata_from_dict_with_none_intrinsics(self): """Test creating metadata from dict with None intrinsics.""" data_dict = { @@ -394,7 +390,6 @@ def test_metadata_non_square_pixels(self): class TestPinholeCamera: - def test_pinhole_camera_creation(self): """Test creating PinholeCamera instance.""" diff --git a/tests/unit/datatypes/time/test_time.py b/tests/unit/datatypes/time/test_time.py index 1a15a73c..69ab6166 100644 --- a/tests/unit/datatypes/time/test_time.py +++ b/tests/unit/datatypes/time/test_time.py @@ -4,7 +4,6 @@ class TestTimePoint: - def test_from_ns(self): """Test constructing TimePoint from nanoseconds.""" tp = TimePoint.from_ns(1000000) diff --git a/tests/unit/datatypes/vehicle_state/test_ego_state.py b/tests/unit/datatypes/vehicle_state/test_ego_state.py index e4e8d46c..671505ec 100644 --- a/tests/unit/datatypes/vehicle_state/test_ego_state.py +++ b/tests/unit/datatypes/vehicle_state/test_ego_state.py @@ -215,7 +215,6 @@ def test_rear_axle_properties(self): """Test rear_axle properties.""" ego_state = EgoStateSE3(rear_axle_se3=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) - assert ego_state.rear_axle == self.rear_axle_pose assert ego_state.rear_axle_se3 == self.rear_axle_pose assert ego_state.rear_axle_se2 is not None @@ -244,7 +243,7 @@ def test_bounding_box_properties(self): bbox_se2 = ego_state.bounding_box_se2 assert bbox_se2 is not None - assert ego_state.bounding_box == bbox_se3 + assert ego_state.bounding_box_se3 == bbox_se3 def test_box_detection_properties(self): """Test box detection properties.""" diff --git a/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py index d678b6f7..f04b5cff 100644 --- a/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py +++ b/tests/unit/datatypes/vehicle_state/test_vehicle_parameters.py @@ -2,7 +2,6 @@ class TestVehicleParameters: - def setup_method(self): self.params = VehicleParameters( vehicle_name="test_vehicle", diff --git a/tests/unit/geometry/test_bounding_box.py b/tests/unit/geometry/test_bounding_box.py index bb5b5513..c58ae8f7 100644 --- a/tests/unit/geometry/test_bounding_box.py +++ b/tests/unit/geometry/test_bounding_box.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import pytest import shapely.geometry as geom @@ -213,7 +211,3 @@ def test_zero_dimensions(self): assert bbox.length == 0.0 assert bbox.width == 0.0 assert bbox.height == 0.0 - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/geometry/test_occupancy_map.py b/tests/unit/geometry/test_occupancy_map.py index 37094349..a50190b4 100644 --- a/tests/unit/geometry/test_occupancy_map.py +++ b/tests/unit/geometry/test_occupancy_map.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import pytest import shapely.geometry as geom @@ -279,8 +277,3 @@ def test_single_geometry_map(self): assert len(occ_map) == 1 assert occ_map.ids == ["single"] assert occ_map["single"] == self.square1 - - -if __name__ == "__main__": - - unittest.main() diff --git a/tests/unit/geometry/test_point.py b/tests/unit/geometry/test_point.py index b7dfb558..d293f8b5 100644 --- a/tests/unit/geometry/test_point.py +++ b/tests/unit/geometry/test_point.py @@ -1,4 +1,3 @@ -import unittest from unittest.mock import MagicMock, patch import numpy as np @@ -183,7 +182,3 @@ def test_iter(self): assert x == self.x_coord assert y == self.y_coord assert z == self.z_coord - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/geometry/test_rotation.py b/tests/unit/geometry/test_rotation.py index 3b7d6d1d..a7fc2e09 100644 --- a/tests/unit/geometry/test_rotation.py +++ b/tests/unit/geometry/test_rotation.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import pytest @@ -178,7 +176,3 @@ def test_rotation_matrix_property(self): rot_matrix = self.quaternion.rotation_matrix assert rot_matrix.shape == (3, 3) np.testing.assert_array_almost_equal(rot_matrix, np.eye(3)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/geometry/test_vector.py b/tests/unit/geometry/test_vector.py index d8f48ce8..71379c57 100644 --- a/tests/unit/geometry/test_vector.py +++ b/tests/unit/geometry/test_vector.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import pytest @@ -167,7 +165,3 @@ def test_hash(self): vector_dict = {self.vector: "test"} assert self.vector in vector_dict assert vector_dict[self.vector] == "test" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/geometry/transform/test_transform_consistency.py b/tests/unit/geometry/transform/test_transform_consistency.py index fcd64726..b4476a4f 100644 --- a/tests/unit/geometry/transform/test_transform_consistency.py +++ b/tests/unit/geometry/transform/test_transform_consistency.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import numpy.typing as npt @@ -486,7 +484,3 @@ def test_transform_large_rotations(self) -> None: ) np.testing.assert_array_almost_equal(test_2d_points, recovered_2d_points, decimal=self.decimal) np.testing.assert_array_almost_equal(test_3d_points, recovered_3d_points, decimal=self.decimal) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/geometry/transform/test_transform_euler_se3.py b/tests/unit/geometry/transform/test_transform_euler_se3.py index 6ccbc21e..2901918d 100644 --- a/tests/unit/geometry/transform/test_transform_euler_se3.py +++ b/tests/unit/geometry/transform/test_transform_euler_se3.py @@ -15,7 +15,6 @@ class TestTransformEulerSE3: - def setup_method(self): self.decimal = 6 # Decimal places for np.testing.assert_array_almost_equal self.num_consistency_tests = 10 # Number of random test cases for consistency checks diff --git a/tests/unit/geometry/transform/test_transform_se2.py b/tests/unit/geometry/transform/test_transform_se2.py index 28f20983..ec8836aa 100644 --- a/tests/unit/geometry/transform/test_transform_se2.py +++ b/tests/unit/geometry/transform/test_transform_se2.py @@ -1,9 +1,8 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import PoseSE2, Vector2D -from py123d.geometry.geometry_index import PoseSE2Index -from py123d.geometry.transform.transform_se2 import ( +from py123d.geometry import PoseSE2, PoseSE2Index, Vector2D +from py123d.geometry.transform import ( convert_absolute_to_relative_points_2d_array, convert_absolute_to_relative_se2_array, convert_points_2d_array_between_origins, @@ -18,7 +17,6 @@ class TestTransformSE2: - def setup_method(self): self.decimal = 6 # Decimal places for np.testing.assert_array_almost_equal diff --git a/tests/unit/geometry/transform/test_transform_se3.py b/tests/unit/geometry/transform/test_transform_se3.py index e982e09b..1564384a 100644 --- a/tests/unit/geometry/transform/test_transform_se3.py +++ b/tests/unit/geometry/transform/test_transform_se3.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import numpy.typing as npt @@ -25,7 +23,6 @@ class TestTransformSE3: - def setup_method(self): euler_se3_a = EulerStateSE3( x=1.0, @@ -92,17 +89,16 @@ def _get_random_quat_se3_array(self, size: int) -> npt.NDArray[np.float64]: def test_sanity(self): for quat_se3, euler_se3 in zip(self.quat_se3, self.euler_se3): - for quat_se3, euler_se3 in zip(self.quat_se3, self.euler_se3): - np.testing.assert_allclose( - quat_se3.point_3d.array, - euler_se3.point_3d.array, - atol=1e-6, - ) - np.testing.assert_allclose( - quat_se3.rotation_matrix, - euler_se3.rotation_matrix, - atol=1e-6, - ) + np.testing.assert_allclose( + quat_se3.point_3d.array, + euler_se3.point_3d.array, + atol=1e-6, + ) + np.testing.assert_allclose( + quat_se3.rotation_matrix, + euler_se3.rotation_matrix, + atol=1e-6, + ) def test_random_sanity(self): for _ in range(10): @@ -123,7 +119,6 @@ def test_random_sanity(self): np.testing.assert_allclose(euler_rotation_matrices, quat_rotation_matrices, atol=1e-6) def test_convert_absolute_to_relative_points_3d_array(self): - random_points_3d = np.random.rand(10, 3) for quat_se3, euler_se3 in zip(self.quat_se3, self.euler_se3): rel_points_quat = convert_absolute_to_relative_points_3d_array(quat_se3, random_points_3d) @@ -133,7 +128,6 @@ def test_convert_absolute_to_relative_points_3d_array(self): np.testing.assert_allclose(rel_points_quat, rel_points_euler, atol=1e-6) def test_convert_absolute_to_relative_se3_array(self): - for quat_se3, euler_se3 in zip(self.quat_se3, self.euler_se3): random_euler_se3_array = self._get_random_euler_se3_array(np.random.randint(1, 10)) random_quat_se3_array = self._convert_euler_se3_array_to_quat_se3_array(random_euler_se3_array) @@ -155,7 +149,6 @@ def test_convert_absolute_to_relative_se3_array(self): np.testing.assert_allclose(quat_rotation_matrices, euler_rotation_matrices, atol=1e-6) def test_convert_relative_to_absolute_points_3d_array(self): - random_points_3d = np.random.rand(10, 3) for quat_se3, euler_se3 in zip(self.quat_se3, self.euler_se3): rel_points_quat = convert_relative_to_absolute_points_3d_array(quat_se3, random_points_3d) @@ -165,7 +158,6 @@ def test_convert_relative_to_absolute_points_3d_array(self): np.testing.assert_allclose(rel_points_quat, rel_points_euler, atol=1e-6) def test_convert_relative_to_absolute_se3_array(self): - for quat_se3, euler_se3 in zip(self.quat_se3, self.euler_se3): random_euler_se3_array = self._get_random_euler_se3_array(np.random.randint(1, 10)) random_quat_se3_array = self._convert_euler_se3_array_to_quat_se3_array(random_euler_se3_array) @@ -300,7 +292,3 @@ def test_translate_se3_along_body_frame(self): np.testing.assert_allclose(translated_quat.point_3d.array, translated_euler.point_3d.array, atol=1e-6) np.testing.assert_allclose(translated_quat.rotation_matrix, translated_euler.rotation_matrix, atol=1e-6) np.testing.assert_allclose(quat_se3.quaternion.array, translated_quat.quaternion.array, atol=1e-6) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/geometry/utils/test_bounding_box_utils.py b/tests/unit/geometry/utils/test_bounding_box_utils.py index 93ddfc16..d9475469 100644 --- a/tests/unit/geometry/utils/test_bounding_box_utils.py +++ b/tests/unit/geometry/utils/test_bounding_box_utils.py @@ -1,5 +1,3 @@ -import unittest - import numpy as np import numpy.typing as npt import shapely @@ -25,7 +23,6 @@ class TestBoundingBoxUtils: - def setup_method(self): self._num_consistency_checks = 10 self._max_pose_xyz = 100.0 @@ -260,7 +257,3 @@ def test_bbse3_array_to_corners_array_zero_dim(self): corners_array = bbse3_array_to_corners_array(bounding_box_se3_array) expected_corners = np.zeros((0, 8, 3), dtype=np.float64) np.testing.assert_allclose(corners_array, expected_corners, atol=1e-6) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/geometry/utils/test_rotation_utils.py b/tests/unit/geometry/utils/test_rotation_utils.py index 5483e976..92c4d1b7 100644 --- a/tests/unit/geometry/utils/test_rotation_utils.py +++ b/tests/unit/geometry/utils/test_rotation_utils.py @@ -1,4 +1,3 @@ -import unittest from typing import Tuple import numpy as np @@ -64,7 +63,6 @@ def _get_rotation_matrix_helper(euler_array: npt.NDArray[np.float64]) -> npt.NDA class TestRotationUtils: - def setup_method(self): pass @@ -300,7 +298,6 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: get_q_bar_matrices(invalid_quat) def test_get_q_matrices(self): - def _test_by_shape(shape: Tuple[int, ...]) -> None: for _ in range(10): N = np.prod(shape) @@ -397,7 +394,6 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: get_quaternion_array_from_euler_array(invalid_euler) def test_get_quaternion_array_from_rotation_matrices(self): - def _test_by_shape(shape: Tuple[int, ...]) -> None: for _ in range(10): N = np.prod(shape) @@ -781,7 +777,3 @@ def _test_by_shape(shape: Tuple[int, ...]) -> None: normalized_angle = normalize_angle(angle) assert normalized_angle >= -np.pi - 1e-8 assert normalized_angle <= np.pi + 1e-8 - - -if __name__ == "__main__": - unittest.main() From b17b3304820ae7c2cf19bfb00750eaf19a7266b1 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 16 Nov 2025 22:19:20 +0100 Subject: [PATCH 28/50] Test to add pytest to github workflow --- .github/workflows/pytest.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/pytest.yaml diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml new file mode 100644 index 00000000..6ad9067d --- /dev/null +++ b/.github/workflows/pytest.yaml @@ -0,0 +1,26 @@ +name: pytest + +on: + push: + branches: [dev_v0.0.8_ruff] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install uv + uv pip install --system -e ".[dev]" + - name: Test with pytest + run: | + pytest From a661539e1a97673a9673b2e16d62c4e3881eee1c Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 16 Nov 2025 22:30:09 +0100 Subject: [PATCH 29/50] Remove python 3.8 --- .flake8 | 22 ---------------------- .github/workflows/pytest.yaml | 2 +- .gitignore | 4 ++++ .isort.cfg | 7 ------- pyproject.toml | 1 - 5 files changed, 5 insertions(+), 31 deletions(-) delete mode 100644 .flake8 delete mode 100644 .isort.cfg diff --git a/.flake8 b/.flake8 deleted file mode 100644 index c77f18b6..00000000 --- a/.flake8 +++ /dev/null @@ -1,22 +0,0 @@ -[flake8] -ignore = - # Whitespace before ':' - E203, - # Module level import not at top of file - E402, - # Line break occurred before a binary operator - W503, - # Line break occurred after a binary operator - W504 - # line break before binary operator - E203 - # line too long - E501 - # No lambdas — too strict - E731 - # Local variable name is assigned to but never used - F841 -per-file-ignores = - # imported but unused - __init__.py: F401 -max-line-length = 120 diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 6ad9067d..75a11a4b 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 426cc468..244dafcc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ docs/_build/ docs/build/ _build/ .doctrees/ + + +# ruff +.ruff_cache/* diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index c168c36f..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[tool.isort] -include_trailing_comma = true -known_first_party = [] -line_length = 120 -multi_line_output = 3 -profile = "black" -use_parentheses = true diff --git a/pyproject.toml b/pyproject.toml index 405610fc..abc145af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ build-backend = "setuptools.build_meta" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From 9fe5efc6ef12b4974d1d695995dfb6ace4136a9a Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sun, 16 Nov 2025 22:34:20 +0100 Subject: [PATCH 30/50] Adjust workflows --- .github/workflows/pre-commit.yaml | 2 +- .github/workflows/pytest.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 2b11178b..1d1b74a2 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -3,7 +3,7 @@ name: pre-commit on: pull_request: push: - branches: [main] + branches: [dev_v0.0.8] jobs: pre-commit: diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 75a11a4b..d0c101ad 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -2,7 +2,7 @@ name: pytest on: push: - branches: [dev_v0.0.8_ruff] + branches: [dev_v0.0.8] jobs: build: From 0c49aaa9a6467c8adf54acda0208fac5a7fe342d Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Mon, 17 Nov 2025 09:01:28 +0100 Subject: [PATCH 31/50] Minor refactorings, adding some tests. --- .gitignore | 1 + notebooks/01_scene_tutorial.ipynb | 2 +- pyproject.toml | 5 + src/py123d/api/scene/scene_builder.py | 1 - .../registry/box_detection_label_registry.py | 1 - src/py123d/geometry/__init__.py | 4 +- src/py123d/geometry/geometry_index.py | 2 +- src/py123d/geometry/pose.py | 60 ++- .../geometry/transform/transform_euler_se3.py | 92 ++-- tests/unit/api/__init__.py | 0 tests/unit/api/api/__init__.py | 0 tests/unit/api/api/test_scene_api.py | 172 +++++++ tests/unit/api/scene/__init__.py | 0 tests/unit/geometry/test_pose.py | 422 ++++++++++++++++++ .../transform/test_transform_consistency.py | 92 ++-- .../transform/test_transform_euler_se3.py | 134 +++--- .../geometry/transform/test_transform_se3.py | 36 +- .../geometry/utils/test_bounding_box_utils.py | 18 +- 18 files changed, 823 insertions(+), 219 deletions(-) create mode 100644 tests/unit/api/__init__.py create mode 100644 tests/unit/api/api/__init__.py create mode 100644 tests/unit/api/api/test_scene_api.py create mode 100644 tests/unit/api/scene/__init__.py create mode 100644 tests/unit/geometry/test_pose.py diff --git a/.gitignore b/.gitignore index 244dafcc..c3da1bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ *.log *.mp4 exp/ +.coverage # Sphinx documentation docs/_build/ diff --git a/notebooks/01_scene_tutorial.ipynb b/notebooks/01_scene_tutorial.ipynb index 3b7b9717..ff3385b6 100644 --- a/notebooks/01_scene_tutorial.ipynb +++ b/notebooks/01_scene_tutorial.ipynb @@ -162,7 +162,7 @@ ], "metadata": { "kernelspec": { - "display_name": "py123d", + "display_name": "py123d_dev", "language": "python", "name": "python3" }, diff --git a/pyproject.toml b/pyproject.toml index abc145af..37cd5e70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,11 @@ docs = [ ] nuplan = [ "nuplan-devkit @ git+https://github.com/motional/nuplan-devkit/@nuplan-devkit-v1.2", + "SQLAlchemy==1.4.27", + "rasterio", + "aioboto3", + "retry", + "cachetools", ] nuscenes = [ "nuscenes-devkit==1.2.0", diff --git a/src/py123d/api/scene/scene_builder.py b/src/py123d/api/scene/scene_builder.py index d6961ad9..0e7cedf4 100644 --- a/src/py123d/api/scene/scene_builder.py +++ b/src/py123d/api/scene/scene_builder.py @@ -19,4 +19,3 @@ def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> List[SceneAPI]: :param worker: WorkerPool to parallelize the scene extraction. :return: Iterator over AbstractScene objects. """ - raise NotImplementedError diff --git a/src/py123d/conversion/registry/box_detection_label_registry.py b/src/py123d/conversion/registry/box_detection_label_registry.py index e3dd1e41..76ac2279 100644 --- a/src/py123d/conversion/registry/box_detection_label_registry.py +++ b/src/py123d/conversion/registry/box_detection_label_registry.py @@ -19,7 +19,6 @@ class BoxDetectionLabel(SerialIntEnum): @abc.abstractmethod def to_default(self) -> DefaultBoxDetectionLabel: """Convert to the default box detection label.""" - raise NotImplementedError("Subclasses must implement this method.") @register_box_detection_label diff --git a/src/py123d/geometry/__init__.py b/src/py123d/geometry/__init__.py index 0d3dafb7..0de0f86f 100644 --- a/src/py123d/geometry/__init__.py +++ b/src/py123d/geometry/__init__.py @@ -6,7 +6,7 @@ Corners2DIndex, Corners3DIndex, EulerAnglesIndex, - EulerStateSE3Index, + EulerPoseSE3Index, QuaternionIndex, PoseSE2Index, PoseSE3Index, @@ -16,7 +16,7 @@ from py123d.geometry.point import Point2D, Point3D from py123d.geometry.vector import Vector2D, Vector3D from py123d.geometry.rotation import EulerAngles, Quaternion -from py123d.geometry.pose import EulerStateSE3, PoseSE2, PoseSE3 +from py123d.geometry.pose import EulerPoseSE3, PoseSE2, PoseSE3 from py123d.geometry.bounding_box import BoundingBoxSE2, BoundingBoxSE3 from py123d.geometry.polyline import Polyline2D, Polyline3D, PolylineSE2 from py123d.geometry.occupancy_map import OccupancyMap2D diff --git a/src/py123d/geometry/geometry_index.py b/src/py123d/geometry/geometry_index.py index 8928d852..2acb5f07 100644 --- a/src/py123d/geometry/geometry_index.py +++ b/src/py123d/geometry/geometry_index.py @@ -103,7 +103,7 @@ def VECTOR(cls) -> slice: return slice(cls.QX, cls.QZ + 1) -class EulerStateSE3Index(IntEnum): +class EulerPoseSE3Index(IntEnum): """Indexing enum for array-like representations of SE3 states with Euler angles (x,y,z,roll,pitch,yaw). Notes diff --git a/src/py123d/geometry/pose.py b/src/py123d/geometry/pose.py index 7db97ea4..2d69f355 100644 --- a/src/py123d/geometry/pose.py +++ b/src/py123d/geometry/pose.py @@ -5,7 +5,7 @@ import shapely.geometry as geom from py123d.common.utils.mixin import ArrayMixin -from py123d.geometry.geometry_index import EulerStateSE3Index, Point3DIndex, PoseSE2Index, PoseSE3Index +from py123d.geometry.geometry_index import EulerPoseSE3Index, Point3DIndex, PoseSE2Index, PoseSE3Index from py123d.geometry.point import Point2D, Point3D from py123d.geometry.rotation import EulerAngles, Quaternion @@ -279,7 +279,7 @@ def transformation_matrix(self) -> npt.NDArray[np.float64]: return transformation_matrix -class EulerStateSE3(ArrayMixin): +class EulerPoseSE3(ArrayMixin): """ Class to represents a 3D pose as SE3 (x, y, z, roll, pitch, yaw). @@ -293,17 +293,17 @@ class EulerStateSE3(ArrayMixin): def __init__(self, x: float, y: float, z: float, roll: float, pitch: float, yaw: float): """Initialize PoseSE3 with x, y, z, roll, pitch, yaw coordinates.""" - array = np.zeros(len(EulerStateSE3Index), dtype=np.float64) - array[EulerStateSE3Index.X] = x - array[EulerStateSE3Index.Y] = y - array[EulerStateSE3Index.Z] = z - array[EulerStateSE3Index.ROLL] = roll - array[EulerStateSE3Index.PITCH] = pitch - array[EulerStateSE3Index.YAW] = yaw + array = np.zeros(len(EulerPoseSE3Index), dtype=np.float64) + array[EulerPoseSE3Index.X] = x + array[EulerPoseSE3Index.Y] = y + array[EulerPoseSE3Index.Z] = z + array[EulerPoseSE3Index.ROLL] = roll + array[EulerPoseSE3Index.PITCH] = pitch + array[EulerPoseSE3Index.YAW] = yaw object.__setattr__(self, "_array", array) @classmethod - def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> EulerStateSE3: + def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> EulerPoseSE3: """Constructs a PoseSE3 from a numpy array. :param array: Array of shape (6,) representing the state [x, y, z, roll, pitch, yaw], indexed by \ @@ -312,24 +312,24 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> EulerS :return: A PoseSE3 instance. """ assert array.ndim == 1 - assert array.shape[0] == len(EulerStateSE3Index) + assert array.shape[0] == len(EulerPoseSE3Index) instance = object.__new__(cls) object.__setattr__(instance, "_array", array.copy() if copy else array) return instance @classmethod - def from_transformation_matrix(cls, transformation_matrix: npt.NDArray[np.float64]) -> EulerStateSE3: - """Constructs a EulerStateSE3 from a 4x4 transformation matrix. + def from_transformation_matrix(cls, transformation_matrix: npt.NDArray[np.float64]) -> EulerPoseSE3: + """Constructs a EulerPoseSE3 from a 4x4 transformation matrix. :param array: A 4x4 numpy array representing the transformation matrix. - :return: A EulerStateSE3 instance. + :return: A EulerPoseSE3 instance. """ assert transformation_matrix.ndim == 2 assert transformation_matrix.shape == (4, 4) translation = transformation_matrix[:3, 3] rotation = transformation_matrix[:3, :3] roll, pitch, yaw = EulerAngles.from_rotation_matrix(rotation) - return EulerStateSE3( + return EulerPoseSE3( x=translation[Point3DIndex.X], y=translation[Point3DIndex.Y], z=translation[Point3DIndex.Z], @@ -344,7 +344,7 @@ def x(self) -> float: :return: The x-coordinate. """ - return self._array[EulerStateSE3Index.X] + return self._array[EulerPoseSE3Index.X] @property def y(self) -> float: @@ -352,7 +352,7 @@ def y(self) -> float: :return: The y-coordinate. """ - return self._array[EulerStateSE3Index.Y] + return self._array[EulerPoseSE3Index.Y] @property def z(self) -> float: @@ -360,7 +360,7 @@ def z(self) -> float: :return: The z-coordinate. """ - return self._array[EulerStateSE3Index.Z] + return self._array[EulerPoseSE3Index.Z] @property def roll(self) -> float: @@ -368,7 +368,7 @@ def roll(self) -> float: :return: The roll angle. """ - return self._array[EulerStateSE3Index.ROLL] + return self._array[EulerPoseSE3Index.ROLL] @property def pitch(self) -> float: @@ -376,7 +376,7 @@ def pitch(self) -> float: :return: The pitch angle. """ - return self._array[EulerStateSE3Index.PITCH] + return self._array[EulerPoseSE3Index.PITCH] @property def yaw(self) -> float: @@ -384,7 +384,7 @@ def yaw(self) -> float: :return: The yaw angle. """ - return self._array[EulerStateSE3Index.YAW] + return self._array[EulerPoseSE3Index.YAW] @property def array(self) -> npt.NDArray[np.float64]: @@ -444,20 +444,32 @@ def transformation_matrix(self) -> npt.NDArray[np.float64]: rotation_matrix = self.rotation_matrix transformation_matrix = np.eye(4, dtype=np.float64) transformation_matrix[:3, :3] = rotation_matrix - transformation_matrix[:3, 3] = self.array[EulerStateSE3Index.XYZ] + transformation_matrix[:3, 3] = self.array[EulerPoseSE3Index.XYZ] return transformation_matrix @property def euler_angles(self) -> EulerAngles: - return EulerAngles.from_array(self.array[EulerStateSE3Index.EULER_ANGLES]) + """Returns the :class:`~py123d.geometry.EulerAngles` representation of the state's orientation. + + :return: An EulerAngles instance representing the state's orientation. + """ + return EulerAngles.from_array(self.array[EulerPoseSE3Index.EULER_ANGLES]) @property def pose_se3(self) -> PoseSE3: + """Returns the :class:`~py123d.geometry.PoseSE3` representation of the state. + + :return: A PoseSE3 instance representing the state. + """ quaternion_se3_array = np.zeros(len(PoseSE3Index), dtype=np.float64) - quaternion_se3_array[PoseSE3Index.XYZ] = self.array[EulerStateSE3Index.XYZ] + quaternion_se3_array[PoseSE3Index.XYZ] = self.array[EulerPoseSE3Index.XYZ] quaternion_se3_array[PoseSE3Index.QUATERNION] = Quaternion.from_euler_angles(self.euler_angles) return PoseSE3.from_array(quaternion_se3_array, copy=False) @property def quaternion(self) -> Quaternion: + """Returns the :class:`~py123d.geometry.Quaternion` representation of the state's orientation. + + :return: A Quaternion instance representing the state's orientation. + """ return Quaternion.from_euler_angles(self.euler_angles) diff --git a/src/py123d/geometry/transform/transform_euler_se3.py b/src/py123d/geometry/transform/transform_euler_se3.py index 625831a2..5d6e02a5 100644 --- a/src/py123d/geometry/transform/transform_euler_se3.py +++ b/src/py123d/geometry/transform/transform_euler_se3.py @@ -3,7 +3,7 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import EulerAngles, EulerStateSE3, EulerStateSE3Index, Point3DIndex, Vector3D, Vector3DIndex +from py123d.geometry import EulerAngles, EulerPoseSE3, EulerPoseSE3Index, Point3DIndex, Vector3D, Vector3DIndex from py123d.geometry.utils.rotation_utils import ( get_euler_array_from_rotation_matrices, get_rotation_matrices_from_euler_array, @@ -11,76 +11,74 @@ ) -def translate_euler_se3_along_z(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: +def translate_euler_se3_along_z(pose_se3: EulerPoseSE3, distance: float) -> EulerPoseSE3: R = pose_se3.rotation_matrix z_axis = R[:, 2] pose_se3_array = pose_se3.array.copy() - pose_se3_array[EulerStateSE3Index.XYZ] += distance * z_axis[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(pose_se3_array, copy=False) + pose_se3_array[EulerPoseSE3Index.XYZ] += distance * z_axis[Vector3DIndex.XYZ] + return EulerPoseSE3.from_array(pose_se3_array, copy=False) -def translate_euler_se3_along_y(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: +def translate_euler_se3_along_y(pose_se3: EulerPoseSE3, distance: float) -> EulerPoseSE3: R = pose_se3.rotation_matrix y_axis = R[:, 1] pose_se3_array = pose_se3.array.copy() - pose_se3_array[EulerStateSE3Index.XYZ] += distance * y_axis[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(pose_se3_array, copy=False) + pose_se3_array[EulerPoseSE3Index.XYZ] += distance * y_axis[Vector3DIndex.XYZ] + return EulerPoseSE3.from_array(pose_se3_array, copy=False) -def translate_euler_se3_along_x(pose_se3: EulerStateSE3, distance: float) -> EulerStateSE3: +def translate_euler_se3_along_x(pose_se3: EulerPoseSE3, distance: float) -> EulerPoseSE3: R = pose_se3.rotation_matrix x_axis = R[:, 0] pose_se3_array = pose_se3.array.copy() - pose_se3_array[EulerStateSE3Index.XYZ] += distance * x_axis[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(pose_se3_array, copy=False) + pose_se3_array[EulerPoseSE3Index.XYZ] += distance * x_axis[Vector3DIndex.XYZ] + return EulerPoseSE3.from_array(pose_se3_array, copy=False) -def translate_euler_se3_along_body_frame(pose_se3: EulerStateSE3, vector_3d: Vector3D) -> EulerStateSE3: +def translate_euler_se3_along_body_frame(pose_se3: EulerPoseSE3, vector_3d: Vector3D) -> EulerPoseSE3: R = pose_se3.rotation_matrix world_translation = R @ vector_3d.array pose_se3_array = pose_se3.array.copy() - pose_se3_array[EulerStateSE3Index.XYZ] += world_translation[Vector3DIndex.XYZ] - return EulerStateSE3.from_array(pose_se3_array, copy=False) + pose_se3_array[EulerPoseSE3Index.XYZ] += world_translation[Vector3DIndex.XYZ] + return EulerPoseSE3.from_array(pose_se3_array, copy=False) def convert_absolute_to_relative_euler_se3_array( - origin: Union[EulerStateSE3, npt.NDArray[np.float64]], se3_array: npt.NDArray[np.float64] + origin: Union[EulerPoseSE3, npt.NDArray[np.float64]], se3_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): + if isinstance(origin, EulerPoseSE3): origin_array = origin.array t_origin = origin.point_3d.array R_origin = origin.rotation_matrix elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(EulerStateSE3Index) + assert origin.ndim == 1 and origin.shape[-1] == len(EulerPoseSE3Index) origin_array = origin - t_origin = origin_array[EulerStateSE3Index.XYZ] - R_origin = get_rotation_matrix_from_euler_array(origin_array[EulerStateSE3Index.EULER_ANGLES]) + t_origin = origin_array[EulerPoseSE3Index.XYZ] + R_origin = get_rotation_matrix_from_euler_array(origin_array[EulerPoseSE3Index.EULER_ANGLES]) else: raise TypeError(f"Expected PoseSE3 or np.ndarray, got {type(origin)}") assert se3_array.ndim >= 1 - assert se3_array.shape[-1] == len(EulerStateSE3Index) + assert se3_array.shape[-1] == len(EulerPoseSE3Index) # Prepare output array rel_se3_array = se3_array.copy() # Vectorized relative position calculation - abs_positions = se3_array[..., EulerStateSE3Index.XYZ] + abs_positions = se3_array[..., EulerPoseSE3Index.XYZ] rel_positions = (abs_positions - t_origin) @ R_origin - rel_se3_array[..., EulerStateSE3Index.XYZ] = rel_positions + rel_se3_array[..., EulerPoseSE3Index.XYZ] = rel_positions # Convert absolute rotation matrices to relative rotation matrices - abs_rotation_matrices = get_rotation_matrices_from_euler_array(se3_array[..., EulerStateSE3Index.EULER_ANGLES]) + abs_rotation_matrices = get_rotation_matrices_from_euler_array(se3_array[..., EulerPoseSE3Index.EULER_ANGLES]) rel_rotation_matrices = np.einsum("ij,...jk->...ik", R_origin.T, abs_rotation_matrices) if se3_array.shape[0] != 0: - # rel_euler_angles = np.array([EulerAngles.from_rotation_matrix(R).array for R in rel_rotation_matrices]) - # rel_se3_array[..., EulerStateSE3Index.EULER_ANGLES] = normalize_angle(rel_euler_angles) - rel_se3_array[..., EulerStateSE3Index.EULER_ANGLES] = get_euler_array_from_rotation_matrices( + rel_se3_array[..., EulerPoseSE3Index.EULER_ANGLES] = get_euler_array_from_rotation_matrices( rel_rotation_matrices ) @@ -88,53 +86,53 @@ def convert_absolute_to_relative_euler_se3_array( def convert_relative_to_absolute_euler_se3_array( - origin: EulerStateSE3, se3_array: npt.NDArray[np.float64] + origin: EulerPoseSE3, se3_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): + if isinstance(origin, EulerPoseSE3): origin_array = origin.array t_origin = origin.point_3d.array R_origin = origin.rotation_matrix elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(EulerStateSE3Index) + assert origin.ndim == 1 and origin.shape[-1] == len(EulerPoseSE3Index) origin_array = origin - t_origin = origin_array[EulerStateSE3Index.XYZ] - R_origin = get_rotation_matrix_from_euler_array(origin_array[EulerStateSE3Index.EULER_ANGLES]) + t_origin = origin_array[EulerPoseSE3Index.XYZ] + R_origin = get_rotation_matrix_from_euler_array(origin_array[EulerPoseSE3Index.EULER_ANGLES]) else: raise TypeError(f"Expected PoseSE3 or np.ndarray, got {type(origin)}") assert se3_array.ndim >= 1 - assert se3_array.shape[-1] == len(EulerStateSE3Index) + assert se3_array.shape[-1] == len(EulerPoseSE3Index) # Prepare output array abs_se3_array = se3_array.copy() # Vectorized absolute position calculation: rotate and translate - rel_positions = se3_array[..., EulerStateSE3Index.XYZ] + rel_positions = se3_array[..., EulerPoseSE3Index.XYZ] abs_positions = (rel_positions @ R_origin.T) + t_origin - abs_se3_array[..., EulerStateSE3Index.XYZ] = abs_positions + abs_se3_array[..., EulerPoseSE3Index.XYZ] = abs_positions # Convert relative rotation matrices to absolute rotation matrices - rel_rotation_matrices = get_rotation_matrices_from_euler_array(se3_array[..., EulerStateSE3Index.EULER_ANGLES]) + rel_rotation_matrices = get_rotation_matrices_from_euler_array(se3_array[..., EulerPoseSE3Index.EULER_ANGLES]) abs_rotation_matrices = np.einsum("ij,...jk->...ik", R_origin, rel_rotation_matrices) if se3_array.shape[0] != 0: - abs_se3_array[..., EulerStateSE3Index.EULER_ANGLES] = get_euler_array_from_rotation_matrices( + abs_se3_array[..., EulerPoseSE3Index.EULER_ANGLES] = get_euler_array_from_rotation_matrices( abs_rotation_matrices ) return abs_se3_array def convert_absolute_to_relative_points_3d_array( - origin: Union[EulerStateSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] + origin: Union[EulerPoseSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): + if isinstance(origin, EulerPoseSE3): t_origin = origin.point_3d.array R_origin = origin.rotation_matrix elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(EulerStateSE3Index) - t_origin = origin[EulerStateSE3Index.XYZ] - R_origin = get_rotation_matrix_from_euler_array(origin[EulerStateSE3Index.EULER_ANGLES]) + assert origin.ndim == 1 and origin.shape[-1] == len(EulerPoseSE3Index) + t_origin = origin[EulerPoseSE3Index.XYZ] + R_origin = get_rotation_matrix_from_euler_array(origin[EulerPoseSE3Index.EULER_ANGLES]) else: - raise TypeError(f"Expected PoseSE3 or np.ndarray, got {type(origin)}") + raise TypeError(f"Expected EulerPoseSE3 or np.ndarray, got {type(origin)}") assert points_3d_array.ndim >= 1 assert points_3d_array.shape[-1] == len(Point3DIndex) @@ -145,18 +143,18 @@ def convert_absolute_to_relative_points_3d_array( def convert_relative_to_absolute_points_3d_array( - origin: Union[EulerStateSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] + origin: Union[EulerPoseSE3, npt.NDArray[np.float64]], points_3d_array: npt.NDArray[np.float64] ) -> npt.NDArray[np.float64]: - if isinstance(origin, EulerStateSE3): + if isinstance(origin, EulerPoseSE3): origin_array = origin.array elif isinstance(origin, np.ndarray): - assert origin.ndim == 1 and origin.shape[-1] == len(EulerStateSE3Index) + assert origin.ndim == 1 and origin.shape[-1] == len(EulerPoseSE3Index) origin_array = origin else: - raise TypeError(f"Expected EulerStateSE3 or np.ndarray, got {type(origin)}") + raise TypeError(f"Expected EulerPoseSE3 or np.ndarray, got {type(origin)}") assert points_3d_array.shape[-1] == len(Point3DIndex) - R = EulerAngles.from_array(origin_array[EulerStateSE3Index.EULER_ANGLES]).rotation_matrix - absolute_points = points_3d_array @ R.T + origin_array[EulerStateSE3Index.XYZ] + R = EulerAngles.from_array(origin_array[EulerPoseSE3Index.EULER_ANGLES]).rotation_matrix + absolute_points = points_3d_array @ R.T + origin_array[EulerPoseSE3Index.XYZ] return absolute_points diff --git a/tests/unit/api/__init__.py b/tests/unit/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/api/api/__init__.py b/tests/unit/api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/api/api/test_scene_api.py b/tests/unit/api/api/test_scene_api.py new file mode 100644 index 00000000..b3764aa8 --- /dev/null +++ b/tests/unit/api/api/test_scene_api.py @@ -0,0 +1,172 @@ +from typing import Optional +from unittest.mock import Mock + +import pytest + +from py123d.api import MapAPI, SceneAPI, SceneMetadata +from py123d.datatypes.detections import BoxDetectionWrapper, TrafficLightDetectionWrapper +from py123d.datatypes.metadata import LogMetadata, MapMetadata +from py123d.datatypes.sensors import ( + FisheyeMEICamera, + FisheyeMEICameraType, + LiDAR, + LiDARType, + PinholeCamera, + PinholeCameraType, +) +from py123d.datatypes.time import TimePoint +from py123d.datatypes.vehicle_state import EgoStateSE3, VehicleParameters + + +class ConcreteSceneAPI(SceneAPI): + """Concrete implementation for testing purposes.""" + + def __init__(self): + self._log_metadata = Mock(spec=LogMetadata) + self._scene_metadata = Mock(spec=SceneMetadata) + self._map_api = Mock(spec=MapAPI) + + def get_log_metadata(self) -> LogMetadata: + return self._log_metadata + + def get_scene_metadata(self) -> SceneMetadata: + return self._scene_metadata + + def get_map_api(self) -> Optional[MapAPI]: + return self._map_api + + def get_timepoint_at_iteration(self, iteration: int) -> TimePoint: + return Mock(spec=TimePoint) + + def get_ego_state_at_iteration(self, iteration: int) -> Optional[EgoStateSE3]: + return Mock(spec=EgoStateSE3) + + def get_box_detections_at_iteration(self, iteration: int) -> Optional[BoxDetectionWrapper]: + return Mock(spec=BoxDetectionWrapper) + + def get_traffic_light_detections_at_iteration(self, iteration: int) -> Optional[TrafficLightDetectionWrapper]: + return Mock(spec=TrafficLightDetectionWrapper) + + def get_route_lane_group_ids(self, iteration: int) -> Optional[list]: + return [1, 2, 3] + + def get_pinhole_camera_at_iteration( + self, iteration: int, camera_type: PinholeCameraType + ) -> Optional[PinholeCamera]: + return Mock(spec=PinholeCamera) + + def get_fisheye_mei_camera_at_iteration( + self, iteration: int, camera_type: FisheyeMEICameraType + ) -> Optional[FisheyeMEICamera]: + return Mock(spec=FisheyeMEICamera) + + def get_lidar_at_iteration(self, iteration: int, lidar_type: LiDARType) -> Optional[LiDAR]: + return Mock(spec=LiDAR) + + +@pytest.fixture +def scene_api(): + """Fixture providing a concrete SceneAPI instance.""" + api = ConcreteSceneAPI() + api._log_metadata.dataset = "test_dataset" + api._log_metadata.split = "test_split" + api._log_metadata.location = "test_location" + api._log_metadata.log_name = "test_log" + api._log_metadata.version = "1.0.0" + api._log_metadata.map_metadata = Mock(spec=MapMetadata) + api._log_metadata.vehicle_parameters = Mock(spec=VehicleParameters) + api._log_metadata.pinhole_camera_metadata = {PinholeCameraType.PCAM_B0: Mock()} + api._log_metadata.fisheye_mei_camera_metadata = {FisheyeMEICameraType.FCAM_L: Mock()} + api._log_metadata.lidar_metadata = {LiDARType.LIDAR_TOP: Mock()} + api._scene_metadata.initial_uuid = "test-uuid-123" + api._scene_metadata.number_of_iterations = 100 + api._scene_metadata.number_of_history_iterations = 10 + return api + + +class TestSceneAPIProperties: + """Test property accessors of SceneAPI.""" + + def test_log_metadata(self, scene_api): + assert scene_api.log_metadata == scene_api._log_metadata + + def test_scene_metadata(self, scene_api): + assert scene_api.scene_metadata == scene_api._scene_metadata + + def test_map_metadata(self, scene_api): + assert scene_api.map_metadata == scene_api._log_metadata.map_metadata + + def test_map_api(self, scene_api): + assert scene_api.map_api == scene_api._map_api + + def test_dataset(self, scene_api): + assert scene_api.dataset == "test_dataset" + + def test_split(self, scene_api): + assert scene_api.split == "test_split" + + def test_location(self, scene_api): + assert scene_api.location == "test_location" + + def test_log_name(self, scene_api): + assert scene_api.log_name == "test_log" + + def test_version(self, scene_api): + assert scene_api.version == "1.0.0" + + def test_scene_uuid(self, scene_api): + assert scene_api.scene_uuid == "test-uuid-123" + + def test_number_of_iterations(self, scene_api): + assert scene_api.number_of_iterations == 100 + + def test_number_of_history_iterations(self, scene_api): + assert scene_api.number_of_history_iterations == 10 + + def test_vehicle_parameters(self, scene_api): + assert scene_api.vehicle_parameters == scene_api._log_metadata.vehicle_parameters + + def test_available_pinhole_camera_types(self, scene_api): + assert scene_api.available_pinhole_camera_types == [PinholeCameraType.PCAM_B0] + + def test_available_fisheye_mei_camera_types(self, scene_api): + assert scene_api.available_fisheye_mei_camera_types == [FisheyeMEICameraType.FCAM_L] + + def test_available_lidar_types(self, scene_api): + assert scene_api.available_lidar_types == [LiDARType.LIDAR_TOP] + + +class TestSceneAPIMethods: + """Test abstract method implementations.""" + + def test_get_timepoint_at_iteration(self, scene_api): + result = scene_api.get_timepoint_at_iteration(0) + assert isinstance(result, Mock) + + def test_get_ego_state_at_iteration(self, scene_api): + result = scene_api.get_ego_state_at_iteration(0) + assert result is not None + + def test_get_box_detections_at_iteration(self, scene_api): + result = scene_api.get_box_detections_at_iteration(0) + assert result is not None + + def test_get_traffic_light_detections_at_iteration(self, scene_api): + result = scene_api.get_traffic_light_detections_at_iteration(0) + assert result is not None + + def test_get_route_lane_group_ids(self, scene_api): + result = scene_api.get_route_lane_group_ids(0) + assert result == [1, 2, 3] + + def test_get_pinhole_camera_at_iteration(self, scene_api): + result = scene_api.get_pinhole_camera_at_iteration(0, PinholeCameraType.PCAM_B0) + assert result is not None + + def test_get_fisheye_mei_camera_at_iteration(self, scene_api): + result = scene_api.get_fisheye_mei_camera_at_iteration(0, FisheyeMEICameraType.FCAM_L) + assert result is not None + + def test_get_lidar_at_iteration(self, scene_api): + result = scene_api.get_lidar_at_iteration(0, LiDARType.LIDAR_TOP) + assert result is not None diff --git a/tests/unit/api/scene/__init__.py b/tests/unit/api/scene/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/geometry/test_pose.py b/tests/unit/geometry/test_pose.py new file mode 100644 index 00000000..1936cbc2 --- /dev/null +++ b/tests/unit/geometry/test_pose.py @@ -0,0 +1,422 @@ +import numpy as np +import pytest + +from py123d.geometry import Point2D, PoseSE2, PoseSE3 +from py123d.geometry.geometry_index import PoseSE2Index +from py123d.geometry.pose import EulerPoseSE3 + + +class TestPoseSE2: + def test_init(self): + """Test basic initialization with explicit x, y, yaw values.""" + pose = PoseSE2(x=1.0, y=2.0, yaw=0.5) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.yaw == 0.5 + + def test_from_array(self): + """Test creation from numpy array.""" + array = np.array([1.0, 2.0, 0.5]) + pose = PoseSE2.from_array(array) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.yaw == 0.5 + + def test_from_array_copy(self): + """Test that copy=True creates independent pose from array.""" + array = np.array([1.0, 2.0, 0.5]) + pose = PoseSE2.from_array(array, copy=True) + array[0] = 99.0 + assert pose.x == 1.0 + + def test_from_array_no_copy(self): + """Test that copy=False links pose to original array.""" + array = np.array([1.0, 2.0, 0.5]) + pose = PoseSE2.from_array(array, copy=False) + array[0] = 99.0 + assert pose.x == 99.0 + + def test_properties(self): + """Test access to individual pose component properties.""" + pose = PoseSE2(x=3.0, y=4.0, yaw=np.pi / 4) + assert pose.x == 3.0 + assert pose.y == 4.0 + assert pytest.approx(pose.yaw) == np.pi / 4 + + def test_array_property(self): + """Test that the array property returns correct numpy array.""" + pose = PoseSE2(x=1.0, y=2.0, yaw=0.5) + array = pose.array + assert array.shape == (3,) + assert array[PoseSE2Index.X] == 1.0 + assert array[PoseSE2Index.Y] == 2.0 + assert array[PoseSE2Index.YAW] == 0.5 + + def test_point_2d(self): + """Test extraction of 2D position as Point2D.""" + pose = PoseSE2(x=1.0, y=2.0, yaw=0.5) + point = pose.point_2d + assert isinstance(point, Point2D) + assert point.x == 1.0 + assert point.y == 2.0 + + def test_rotation_matrix(self): + """Test extraction of 2x2 rotation matrix.""" + pose = PoseSE2(x=1.0, y=2.0, yaw=0.0) + rot_mat = pose.rotation_matrix + expected = np.array([[1.0, 0.0], [0.0, 1.0]]) + np.testing.assert_allclose(rot_mat, expected) + + def test_rotation_matrix_pi_half(self): + """Test extraction of 2x2 rotation matrix for 90 degree rotation.""" + pose = PoseSE2(x=0.0, y=0.0, yaw=np.pi / 2) + rot_mat = pose.rotation_matrix + expected = np.array([[0.0, -1.0], [1.0, 0.0]]) + np.testing.assert_allclose(rot_mat, expected, atol=1e-10) + + def test_transformation_matrix(self): + """Test extraction of 3x3 transformation matrix.""" + pose = PoseSE2(x=1.0, y=2.0, yaw=0.0) + trans_mat = pose.transformation_matrix + assert trans_mat.shape == (3, 3) + expected = np.array([[1.0, 0.0, 1.0], [0.0, 1.0, 2.0], [0.0, 0.0, 0.0]]) + np.testing.assert_allclose(trans_mat, expected) + + def test_shapely_point(self): + """Test extraction of Shapely Point representation.""" + pose = PoseSE2(x=1.0, y=2.0, yaw=0.5) + shapely_point = pose.shapely_point + assert shapely_point.x == 1.0 + assert shapely_point.y == 2.0 + + def test_pose_se2_property(self): + """Test that pose_se2 property returns self.""" + pose = PoseSE2(x=1.0, y=2.0, yaw=0.5) + assert pose.pose_se2 is pose + + def test_equality(self): + """Test equality comparison of PoseSE2 instances.""" + pose1 = PoseSE2(x=1.0, y=2.0, yaw=0.5) + pose2 = PoseSE2(x=1.0, y=2.0, yaw=0.5) + assert pose1 == pose2 + + def test_inequality(self): + """Test inequality comparison of PoseSE2 instances.""" + pose1 = PoseSE2(x=1.0, y=2.0, yaw=0.5) + pose2 = PoseSE2(x=1.0, y=2.0, yaw=0.6) + assert pose1 != pose2 + + +class TestPoseSE3: + def test_init(self): + """Test basic initialization with explicit x, y, z, and quaternion values.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pose.qw == 1.0 + assert pose.qx == 0.0 + assert pose.qy == 0.0 + assert pose.qz == 0.0 + + def test_from_array(self): + """Test creation from numpy array.""" + array = np.array([1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0]) + pose = PoseSE3.from_array(array) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pose.qw == 1.0 + assert pose.qx == 0.0 + assert pose.qy == 0.0 + assert pose.qz == 0.0 + + def test_from_array_copy(self): + """Test that copy=True creates independent pose from array.""" + array = np.array([1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0]) + pose = PoseSE3.from_array(array, copy=True) + array[0] = 99.0 + assert pose.x == 1.0 + + def test_from_array_no_copy(self): + """Test that copy=False links pose to original array.""" + array = np.array([1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0]) + pose = PoseSE3.from_array(array, copy=False) + array[0] = 99.0 + assert pose.x == 99.0 + + def test_from_transformation_matrix(self): + """Test creation from 4x4 transformation matrix.""" + trans_mat = np.eye(4) + trans_mat[:3, 3] = [1.0, 2.0, 3.0] + pose = PoseSE3.from_transformation_matrix(trans_mat) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pose.qw == 1.0 + assert pose.qx == 0.0 + assert pose.qy == 0.0 + assert pose.qz == 0.0 + + def test_properties(self): + """Test access to individual pose component properties.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pose.qw == 1.0 + assert pose.qx == 0.0 + assert pose.qy == 0.0 + assert pose.qz == 0.0 + + def test_array_property(self): + """Test that the array property returns the correct numpy array representation.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + array = pose.array + assert array.shape == (7,) + np.testing.assert_allclose(array, [1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0]) + + def test_pose_se2(self): + """Test extraction of 2D pose from 3D pose.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + pose_2d = pose.pose_se2 + assert isinstance(pose_2d, PoseSE2) + assert pose_2d.x == 1.0 + assert pose_2d.y == 2.0 + assert pytest.approx(pose_2d.yaw) == 0.0 + + def test_point_3d(self): + """Test extraction of 3D point from 3D pose.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + point = pose.point_3d + assert point.x == 1.0 + assert point.y == 2.0 + assert point.z == 3.0 + + def test_point_2d(self): + """Test extraction of 2D point from 3D pose.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + point = pose.point_2d + assert isinstance(point, Point2D) + assert point.x == 1.0 + assert point.y == 2.0 + + def test_shapely_point(self): + """Test extraction of Shapely Point representation.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + shapely_point = pose.shapely_point + assert shapely_point.x == 1.0 + assert shapely_point.y == 2.0 + assert shapely_point.z == 3.0 + + def test_rotation_matrix(self): + """Test extraction of 3x3 rotation matrix.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + rot_mat = pose.rotation_matrix + expected = np.eye(3) + np.testing.assert_allclose(rot_mat, expected) + + def test_transformation_matrix(self): + """Test extraction of 4x4 transformation matrix.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + trans_mat = pose.transformation_matrix + assert trans_mat.shape == (4, 4) + expected = np.eye(4) + expected[:3, 3] = [1.0, 2.0, 3.0] + np.testing.assert_allclose(trans_mat, expected) + + def test_transformation_matrix_roundtrip(self): + """Test round-trip conversion between pose and transformation matrix.""" + pose1 = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + trans_mat = pose1.transformation_matrix + pose2 = PoseSE3.from_transformation_matrix(trans_mat) + assert pose1 == pose2 + + def test_euler_angles(self): + """Test extraction of Euler angles from quaternion.""" + pose = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + assert pytest.approx(pose.roll) == 0.0 + assert pytest.approx(pose.pitch) == 0.0 + assert pytest.approx(pose.yaw) == 0.0 + + def test_equality(self): + """Test equality comparison of PoseSE3 instances.""" + pose1 = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + pose2 = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + assert pose1 == pose2 + + def test_inequality(self): + """Test inequality comparison of PoseSE3 instances.""" + pose1 = PoseSE3(x=1.0, y=2.0, z=3.0, qw=1.0, qx=0.0, qy=0.0, qz=0.0) + pose2 = PoseSE3(x=1.0, y=2.0, z=3.0, qw=0.9, qx=0.1, qy=0.0, qz=0.0) + assert pose1 != pose2 + + +class TestEulerPoseSE3: + def test_init(self): + """Test initialization of EulerPoseSE3 with position and orientation.""" + + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pose.roll == 0.1 + assert pose.pitch == 0.2 + assert pose.yaw == 0.3 + + def test_from_array(self): + """Test creation of EulerPoseSE3 from numpy array.""" + + array = np.array([1.0, 2.0, 3.0, 0.1, 0.2, 0.3]) + pose = EulerPoseSE3.from_array(array) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pose.roll == 0.1 + assert pose.pitch == 0.2 + assert pose.yaw == 0.3 + + def test_from_array_copy(self): + """Test that copy=True creates independent pose from array.""" + + array = np.array([1.0, 2.0, 3.0, 0.1, 0.2, 0.3]) + pose = EulerPoseSE3.from_array(array, copy=True) + array[0] = 99.0 + assert pose.x == 1.0 + + def test_from_array_no_copy(self): + """Test that copy=False links pose to original array.""" + + array = np.array([1.0, 2.0, 3.0, 0.1, 0.2, 0.3]) + pose = EulerPoseSE3.from_array(array, copy=False) + array[0] = 99.0 + assert pose.x == 99.0 + + def test_from_transformation_matrix(self): + """Test creation of EulerPoseSE3 from 4x4 transformation matrix.""" + trans_mat = np.eye(4) + trans_mat[:3, 3] = [1.0, 2.0, 3.0] + pose = EulerPoseSE3.from_transformation_matrix(trans_mat) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pytest.approx(pose.roll) == 0.0 + assert pytest.approx(pose.pitch) == 0.0 + assert pytest.approx(pose.yaw) == 0.0 + + def test_properties(self): + """Test access to individual pose component properties.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + assert pose.x == 1.0 + assert pose.y == 2.0 + assert pose.z == 3.0 + assert pose.roll == 0.1 + assert pose.pitch == 0.2 + assert pose.yaw == 0.3 + + def test_array_property(self): + """Test that the array property returns the correct numpy array representation.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + array = pose.array + assert array.shape == (6,) + np.testing.assert_allclose(array, [1.0, 2.0, 3.0, 0.1, 0.2, 0.3]) + + def test_pose_se2(self): + """Test extraction of 2D pose from 3D Euler pose.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + pose_2d = pose.pose_se2 + assert isinstance(pose_2d, PoseSE2) + assert pose_2d.x == 1.0 + assert pose_2d.y == 2.0 + assert pose_2d.yaw == 0.3 + + def test_point_3d(self): + """Test extraction of 3D point from 3D Euler pose.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + point = pose.point_3d + assert point.x == 1.0 + assert point.y == 2.0 + assert point.z == 3.0 + + def test_point_2d(self): + """Test extraction of 2D point from 3D Euler pose.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + point = pose.point_2d + assert isinstance(point, Point2D) + assert point.x == 1.0 + assert point.y == 2.0 + + def test_shapely_point(self): + """Test extraction of Shapely Point representation.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + shapely_point = pose.shapely_point + assert shapely_point.x == 1.0 + assert shapely_point.y == 2.0 + assert shapely_point.z == 3.0 + + def test_rotation_matrix(self): + """Test the rotation matrix property of EulerPoseSE3.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.0, pitch=0.0, yaw=0.0) + rot_mat = pose.rotation_matrix + expected = np.eye(3) + np.testing.assert_allclose(rot_mat, expected) + + def test_transformation_matrix(self): + """Test the transformation matrix property of EulerPoseSE3.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.0, pitch=0.0, yaw=0.0) + trans_mat = pose.transformation_matrix + assert trans_mat.shape == (4, 4) + expected = np.eye(4) + expected[:3, 3] = [1.0, 2.0, 3.0] + np.testing.assert_allclose(trans_mat, expected) + + def test_transformation_matrix_roundtrip(self): + """Test round-trip conversion between EulerPoseSE3 and transformation matrix.""" + pose1 = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.0, pitch=0.0, yaw=0.0) + trans_mat = pose1.transformation_matrix + pose2 = EulerPoseSE3.from_transformation_matrix(trans_mat) + np.testing.assert_allclose(pose1.array, pose2.array) + + def test_euler_angles(self): + """Test the euler_angles property of EulerPoseSE3.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + euler = pose.euler_angles + assert pytest.approx(euler.roll) == 0.1 + assert pytest.approx(euler.pitch) == 0.2 + assert pytest.approx(euler.yaw) == 0.3 + + def test_pose_se3_conversion(self): + """Test conversion from EulerPoseSE3 to PoseSE3.""" + euler_pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.0, pitch=0.0, yaw=0.0) + quat_pose = euler_pose.pose_se3 + assert isinstance(quat_pose, PoseSE3) + assert quat_pose.x == 1.0 + assert quat_pose.y == 2.0 + assert quat_pose.z == 3.0 + assert pytest.approx(quat_pose.qw) == 1.0 + assert pytest.approx(quat_pose.qx) == 0.0 + assert pytest.approx(quat_pose.qy) == 0.0 + assert pytest.approx(quat_pose.qz) == 0.0 + + def test_quaternion(self): + """Test the quaternion property of EulerPoseSE3.""" + pose = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.0, pitch=0.0, yaw=0.0) + quat = pose.quaternion + assert pytest.approx(quat.qw) == 1.0 + assert pytest.approx(quat.qx) == 0.0 + assert pytest.approx(quat.qy) == 0.0 + assert pytest.approx(quat.qz) == 0.0 + + def test_equality(self): + """Test equality comparison of EulerPoseSE3 instances.""" + + pose1 = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + pose2 = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + assert pose1 == pose2 + + def test_inequality(self): + """Test inequality comparison of EulerPoseSE3 instances.""" + + pose1 = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.3) + pose2 = EulerPoseSE3(x=1.0, y=2.0, z=3.0, roll=0.1, pitch=0.2, yaw=0.4) + assert pose1 != pose2 diff --git a/tests/unit/geometry/transform/test_transform_consistency.py b/tests/unit/geometry/transform/test_transform_consistency.py index b4476a4f..2b31c110 100644 --- a/tests/unit/geometry/transform/test_transform_consistency.py +++ b/tests/unit/geometry/transform/test_transform_consistency.py @@ -1,8 +1,8 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import EulerStateSE3, PoseSE2, Vector2D, Vector3D -from py123d.geometry.geometry_index import EulerStateSE3Index, Point2DIndex, Point3DIndex, PoseSE2Index +from py123d.geometry import EulerPoseSE3, PoseSE2, Vector2D, Vector3D +from py123d.geometry.geometry_index import EulerPoseSE3Index, Point2DIndex, Point3DIndex, PoseSE2Index from py123d.geometry.transform.transform_euler_se3 import ( convert_absolute_to_relative_euler_se3_array, convert_absolute_to_relative_points_3d_array, @@ -44,13 +44,11 @@ def _get_random_se2_array(self, size: int) -> npt.NDArray[np.float64]: def _get_random_se3_array(self, size: int) -> npt.NDArray[np.float64]: """Generate a random SE3 poses""" - random_se3_array = np.zeros((size, len(EulerStateSE3Index)), dtype=np.float64) - random_se3_array[:, EulerStateSE3Index.XYZ] = np.random.uniform( - -self.max_pose_xyz, self.max_pose_xyz, (size, 3) - ) - random_se3_array[:, EulerStateSE3Index.YAW] = np.random.uniform(-np.pi, np.pi, size) - random_se3_array[:, EulerStateSE3Index.PITCH] = np.random.uniform(-np.pi / 2, np.pi / 2, size) - random_se3_array[:, EulerStateSE3Index.ROLL] = np.random.uniform(-np.pi, np.pi, size) + random_se3_array = np.zeros((size, len(EulerPoseSE3Index)), dtype=np.float64) + random_se3_array[:, EulerPoseSE3Index.XYZ] = np.random.uniform(-self.max_pose_xyz, self.max_pose_xyz, (size, 3)) + random_se3_array[:, EulerPoseSE3Index.YAW] = np.random.uniform(-np.pi, np.pi, size) + random_se3_array[:, EulerPoseSE3Index.PITCH] = np.random.uniform(-np.pi / 2, np.pi / 2, size) + random_se3_array[:, EulerPoseSE3Index.ROLL] = np.random.uniform(-np.pi, np.pi, size) return random_se3_array @@ -140,7 +138,7 @@ def test_se3_absolute_relative_conversion_consistency(self) -> None: """Test that converting absolute->relative->absolute returns original poses""" for _ in range(self.num_consistency_tests): # Generate random reference pose - reference = EulerStateSE3.from_array(self._get_random_se3_array(1)[0]) + reference = EulerPoseSE3.from_array(self._get_random_se3_array(1)[0]) # Generate random absolute poses num_poses = np.random.randint(self.min_random_poses, self.max_random_poses) @@ -151,16 +149,16 @@ def test_se3_absolute_relative_conversion_consistency(self) -> None: recovered_absolute = convert_relative_to_absolute_euler_se3_array(reference, relative_poses) np.testing.assert_array_almost_equal( - absolute_poses[..., EulerStateSE3Index.XYZ], - recovered_absolute[..., EulerStateSE3Index.XYZ], + absolute_poses[..., EulerPoseSE3Index.XYZ], + recovered_absolute[..., EulerPoseSE3Index.XYZ], decimal=self.decimal, ) absolute_rotation_matrices = get_rotation_matrices_from_euler_array( - absolute_poses[..., EulerStateSE3Index.EULER_ANGLES] + absolute_poses[..., EulerPoseSE3Index.EULER_ANGLES] ) recovered_rotation_matrices = get_rotation_matrices_from_euler_array( - recovered_absolute[..., EulerStateSE3Index.EULER_ANGLES] + recovered_absolute[..., EulerPoseSE3Index.EULER_ANGLES] ) np.testing.assert_array_almost_equal( @@ -173,11 +171,11 @@ def test_se3_points_absolute_relative_conversion_consistency(self) -> None: """Test that converting absolute->relative->absolute returns original points""" for _ in range(self.num_consistency_tests): # Generate random reference pose - reference = EulerStateSE3.from_array(self._get_random_se3_array(1)[0]) + reference = EulerPoseSE3.from_array(self._get_random_se3_array(1)[0]) # Generate random absolute points num_points = np.random.randint(self.min_random_poses, self.max_random_poses) - absolute_points = self._get_random_se3_array(num_points)[:, EulerStateSE3Index.XYZ] + absolute_points = self._get_random_se3_array(num_points)[:, EulerPoseSE3Index.XYZ] # Convert absolute -> relative -> absolute relative_points = convert_absolute_to_relative_points_3d_array(reference, absolute_points) @@ -189,7 +187,7 @@ def test_se3_points_consistency(self) -> None: """Test whether SE3 point and pose conversions are consistent""" for _ in range(self.num_consistency_tests): # Generate random reference pose - reference = EulerStateSE3.from_array(self._get_random_se3_array(1)[0]) + reference = EulerPoseSE3.from_array(self._get_random_se3_array(1)[0]) # Generate random absolute points num_poses = np.random.randint(self.min_random_poses, self.max_random_poses) @@ -198,16 +196,16 @@ def test_se3_points_consistency(self) -> None: # Convert absolute -> relative -> absolute relative_se3 = convert_absolute_to_relative_euler_se3_array(reference, absolute_se3) relative_points = convert_absolute_to_relative_points_3d_array( - reference, absolute_se3[..., EulerStateSE3Index.XYZ] + reference, absolute_se3[..., EulerPoseSE3Index.XYZ] ) np.testing.assert_array_almost_equal( - relative_se3[..., EulerStateSE3Index.XYZ], relative_points, decimal=self.decimal + relative_se3[..., EulerPoseSE3Index.XYZ], relative_points, decimal=self.decimal ) recovered_absolute_se3 = convert_relative_to_absolute_euler_se3_array(reference, relative_se3) absolute_points = convert_relative_to_absolute_points_3d_array(reference, relative_points) np.testing.assert_array_almost_equal( - recovered_absolute_se3[..., EulerStateSE3Index.XYZ], absolute_points, decimal=self.decimal + recovered_absolute_se3[..., EulerPoseSE3Index.XYZ], absolute_points, decimal=self.decimal ) def test_se2_se3_translation_along_body_consistency(self) -> None: @@ -216,7 +214,7 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: # Create equivalent SE2 and SE3 poses (SE3 with z=0 and no rotations except yaw) pose_se2 = PoseSE2.from_array(self._get_random_se2_array(1)[0]) - pose_se3 = EulerStateSE3.from_array( + pose_se3 = EulerPoseSE3.from_array( np.array([pose_se2.x, pose_se2.y, 0.0, 0.0, 0.0, pose_se2.yaw], dtype=np.float64) ) @@ -227,12 +225,12 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: np.testing.assert_array_almost_equal( translated_se2_x.array[PoseSE2Index.XY], - translated_se3_x.array[EulerStateSE3Index.XY], + translated_se3_x.array[EulerPoseSE3Index.XY], decimal=self.decimal, ) np.testing.assert_almost_equal( translated_se2_x.array[PoseSE2Index.YAW], - translated_se3_x.array[EulerStateSE3Index.YAW], + translated_se3_x.array[EulerPoseSE3Index.YAW], decimal=self.decimal, ) @@ -243,12 +241,12 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: np.testing.assert_array_almost_equal( translated_se2_y.array[PoseSE2Index.XY], - translated_se3_y.array[EulerStateSE3Index.XY], + translated_se3_y.array[EulerPoseSE3Index.XY], decimal=self.decimal, ) np.testing.assert_almost_equal( translated_se2_y.array[PoseSE2Index.YAW], - translated_se3_y.array[EulerStateSE3Index.YAW], + translated_se3_y.array[EulerPoseSE3Index.YAW], decimal=self.decimal, ) @@ -259,12 +257,12 @@ def test_se2_se3_translation_along_body_consistency(self) -> None: translated_se3_xy = translate_euler_se3_along_body_frame(pose_se3, Vector3D(dx, dy, 0.0)) np.testing.assert_array_almost_equal( translated_se2_xy.array[PoseSE2Index.XY], - translated_se3_xy.array[EulerStateSE3Index.XY], + translated_se3_xy.array[EulerPoseSE3Index.XY], decimal=self.decimal, ) np.testing.assert_almost_equal( translated_se2_xy.array[PoseSE2Index.YAW], - translated_se3_xy.array[EulerStateSE3Index.YAW], + translated_se3_xy.array[EulerPoseSE3Index.YAW], decimal=self.decimal, ) @@ -277,7 +275,7 @@ def test_se2_se3_point_conversion_consistency(self) -> None: yaw = np.random.uniform(-np.pi, np.pi) reference_se2 = PoseSE2.from_array(np.array([x, y, yaw], dtype=np.float64)) - reference_se3 = EulerStateSE3.from_array(np.array([x, y, 0.0, 0.0, 0.0, yaw], dtype=np.float64)) + reference_se3 = EulerPoseSE3.from_array(np.array([x, y, 0.0, 0.0, 0.0, yaw], dtype=np.float64)) # Generate 2D points and embed them in 3D with z=0 num_points = np.random.randint(1, 8) @@ -315,14 +313,14 @@ def test_se2_se3_pose_conversion_consistency(self) -> None: yaw = np.random.uniform(-np.pi, np.pi) reference_se2 = PoseSE2.from_array(np.array([x, y, yaw], dtype=np.float64)) - reference_se3 = EulerStateSE3.from_array(np.array([x, y, 0.0, 0.0, 0.0, yaw], dtype=np.float64)) + reference_se3 = EulerPoseSE3.from_array(np.array([x, y, 0.0, 0.0, 0.0, yaw], dtype=np.float64)) # Generate 2D poses and embed them in 3D with z=0 and zero roll/pitch num_poses = np.random.randint(1, 8) pose_2d = self._get_random_se2_array(num_poses) - pose_3d = np.zeros((num_poses, len(EulerStateSE3Index)), dtype=np.float64) - pose_3d[:, EulerStateSE3Index.XY] = pose_2d[:, PoseSE2Index.XY] - pose_3d[:, EulerStateSE3Index.YAW] = pose_2d[:, PoseSE2Index.YAW] + pose_3d = np.zeros((num_poses, len(EulerPoseSE3Index)), dtype=np.float64) + pose_3d[:, EulerPoseSE3Index.XY] = pose_2d[:, PoseSE2Index.XY] + pose_3d[:, EulerPoseSE3Index.YAW] = pose_2d[:, PoseSE2Index.YAW] # Convert using SE2 functions relative_se2 = convert_absolute_to_relative_se2_array(reference_se2, pose_2d) @@ -334,20 +332,20 @@ def test_se2_se3_pose_conversion_consistency(self) -> None: # Check that SE2 and SE3 conversions are consistent for the x,y components np.testing.assert_array_almost_equal( - relative_se2[:, PoseSE2Index.XY], relative_se3[:, EulerStateSE3Index.XY], decimal=self.decimal + relative_se2[:, PoseSE2Index.XY], relative_se3[:, EulerPoseSE3Index.XY], decimal=self.decimal ) np.testing.assert_array_almost_equal( absolute_se2_recovered[:, PoseSE2Index.XY], - absolute_se3_recovered[:, EulerStateSE3Index.XY], + absolute_se3_recovered[:, EulerPoseSE3Index.XY], decimal=self.decimal, ) # Check that SE2 and SE3 conversions are consistent for the yaw component np.testing.assert_array_almost_equal( - relative_se2[:, PoseSE2Index.YAW], relative_se3[:, EulerStateSE3Index.YAW], decimal=self.decimal + relative_se2[:, PoseSE2Index.YAW], relative_se3[:, EulerPoseSE3Index.YAW], decimal=self.decimal ) np.testing.assert_array_almost_equal( absolute_se2_recovered[:, PoseSE2Index.YAW], - absolute_se3_recovered[:, EulerStateSE3Index.YAW], + absolute_se3_recovered[:, EulerPoseSE3Index.YAW], decimal=self.decimal, ) @@ -386,7 +384,7 @@ def test_se2_array_translation_consistency(self) -> None: def test_transform_empty_arrays(self) -> None: """Test that transform functions handle empty arrays correctly""" reference_se2 = PoseSE2.from_array(np.array([1.0, 2.0, np.pi / 4], dtype=np.float64)) - reference_se3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.1, 0.2, 0.3], dtype=np.float64)) + reference_se3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.1, 0.2, 0.3], dtype=np.float64)) # Test SE2 empty arrays empty_se2_poses = np.array([], dtype=np.float64).reshape(0, len(PoseSE2Index)) @@ -399,20 +397,20 @@ def test_transform_empty_arrays(self) -> None: assert result_2d_points.shape == (0, len(Point2DIndex)) # Test SE3 empty arrays - empty_se3_poses = np.array([], dtype=np.float64).reshape(0, len(EulerStateSE3Index)) + empty_se3_poses = np.array([], dtype=np.float64).reshape(0, len(EulerPoseSE3Index)) empty_3d_points = np.array([], dtype=np.float64).reshape(0, len(Point3DIndex)) result_se3_poses = convert_absolute_to_relative_euler_se3_array(reference_se3, empty_se3_poses) result_3d_points = convert_absolute_to_relative_points_3d_array(reference_se3, empty_3d_points) - assert result_se3_poses.shape == (0, len(EulerStateSE3Index)) + assert result_se3_poses.shape == (0, len(EulerPoseSE3Index)) assert result_3d_points.shape == (0, len(Point3DIndex)) def test_transform_identity_operations(self) -> None: """Test that transforms with identity reference frames work correctly""" # Identity SE2 pose identity_se2 = PoseSE2.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) - identity_se3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + identity_se3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) for _ in range(self.num_consistency_tests): # Test SE2 identity transforms @@ -428,14 +426,14 @@ def test_transform_identity_operations(self) -> None: # Test SE3 identity transforms se3_poses = self._get_random_se3_array(num_poses) - se3_points = se3_poses[:, EulerStateSE3Index.XYZ] + se3_points = se3_poses[:, EulerPoseSE3Index.XYZ] relative_se3_poses = convert_absolute_to_relative_euler_se3_array(identity_se3, se3_poses) relative_se3_points = convert_absolute_to_relative_points_3d_array(identity_se3, se3_points) np.testing.assert_array_almost_equal( - se3_poses[..., EulerStateSE3Index.EULER_ANGLES], - relative_se3_poses[..., EulerStateSE3Index.EULER_ANGLES], + se3_poses[..., EulerPoseSE3Index.EULER_ANGLES], + relative_se3_poses[..., EulerPoseSE3Index.EULER_ANGLES], decimal=self.decimal, ) np.testing.assert_array_almost_equal(se3_points, relative_se3_points, decimal=self.decimal) @@ -448,7 +446,7 @@ def test_transform_large_rotations(self) -> None: large_euler_se3 = np.random.uniform(-4 * np.pi, 4 * np.pi, 3) reference_se2 = PoseSE2.from_array(np.array([0.0, 0.0, large_yaw_se2], dtype=np.float64)) - reference_se3 = EulerStateSE3.from_array( + reference_se3 = EulerPoseSE3.from_array( np.array([0.0, 0.0, 0.0, large_euler_se3[0], large_euler_se3[1], large_euler_se3[2]], dtype=np.float64) ) @@ -456,7 +454,7 @@ def test_transform_large_rotations(self) -> None: test_se2_poses = self._get_random_se2_array(5) test_se3_poses = self._get_random_se3_array(5) test_2d_points = test_se2_poses[:, PoseSE2Index.XY] - test_3d_points = test_se3_poses[:, EulerStateSE3Index.XYZ] + test_3d_points = test_se3_poses[:, EulerPoseSE3Index.XYZ] # Test round-trip conversions should still work relative_se2 = convert_absolute_to_relative_se2_array(reference_se2, test_se2_poses) @@ -478,8 +476,8 @@ def test_transform_large_rotations(self) -> None: decimal=self.decimal, ) np.testing.assert_array_almost_equal( - test_se3_poses[:, EulerStateSE3Index.XYZ], - recovered_se3[:, EulerStateSE3Index.XYZ], + test_se3_poses[:, EulerPoseSE3Index.XYZ], + recovered_se3[:, EulerPoseSE3Index.XYZ], decimal=self.decimal, ) np.testing.assert_array_almost_equal(test_2d_points, recovered_2d_points, decimal=self.decimal) diff --git a/tests/unit/geometry/transform/test_transform_euler_se3.py b/tests/unit/geometry/transform/test_transform_euler_se3.py index 2901918d..1785acef 100644 --- a/tests/unit/geometry/transform/test_transform_euler_se3.py +++ b/tests/unit/geometry/transform/test_transform_euler_se3.py @@ -1,7 +1,7 @@ import numpy as np import numpy.typing as npt -from py123d.geometry import EulerStateSE3, Vector3D +from py123d.geometry import EulerPoseSE3, Vector3D from py123d.geometry.transform.transform_euler_se3 import ( convert_absolute_to_relative_euler_se3_array, convert_absolute_to_relative_points_3d_array, @@ -21,130 +21,130 @@ def setup_method(self): def test_translate_se3_along_x(self) -> None: """Tests translating a SE3 state along the body frame forward direction.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = 1.0 - result: EulerStateSE3 = translate_euler_se3_along_x(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_x(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_x_negative(self) -> None: """Tests translating a SE3 state along the body frame backward direction.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = -0.5 - result: EulerStateSE3 = translate_euler_se3_along_x(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.5, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_x(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.5, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_x_with_rotation(self) -> None: """Tests translating a SE3 state along the body frame forward direction with yaw rotation.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64)) distance: float = 2.5 - result: EulerStateSE3 = translate_euler_se3_along_x(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array( + result: EulerPoseSE3 = translate_euler_se3_along_x(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([0.0, 2.5, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64) ) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_y(self) -> None: """Tests translating a SE3 state along the body frame lateral direction.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = 1.0 - result: EulerStateSE3 = translate_euler_se3_along_y(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 1.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_y(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 1.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_y_with_existing_position(self) -> None: """Tests translating a SE3 state along the body frame lateral direction with existing position.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = 2.5 - result: EulerStateSE3 = translate_euler_se3_along_y(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 4.5, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_y(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 4.5, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_y_negative(self) -> None: """Tests translating a SE3 state along the body frame lateral direction in the negative direction.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = -1.0 - result: EulerStateSE3 = translate_euler_se3_along_y(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 1.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_y(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 1.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_y_with_rotation(self) -> None: """Tests translating a SE3 state along the body frame lateral direction with roll rotation.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, np.pi / 2, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, np.pi / 2, 0.0, 0.0], dtype=np.float64)) distance: float = -1.0 - result: EulerStateSE3 = translate_euler_se3_along_y(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array( + result: EulerPoseSE3 = translate_euler_se3_along_y(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([1.0, 2.0, 2.0, np.pi / 2, 0.0, 0.0], dtype=np.float64) ) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_z(self) -> None: """Tests translating a SE3 state along the body frame vertical direction.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = 1.0 - result: EulerStateSE3 = translate_euler_se3_along_z(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_z(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_z_large_distance(self) -> None: """Tests translating a SE3 state along the body frame vertical direction with a large distance.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 5.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 5.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = 10.0 - result: EulerStateSE3 = translate_euler_se3_along_z(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 15.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_z(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 15.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_z_negative(self) -> None: """Tests translating a SE3 state along the body frame vertical direction in the negative direction.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 5.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 5.0, 0.0, 0.0, 0.0], dtype=np.float64)) distance: float = -2.0 - result: EulerStateSE3 = translate_euler_se3_along_z(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_z(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_z_with_rotation(self) -> None: """Tests translating a SE3 state along the body frame vertical direction with pitch rotation.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, np.pi / 2, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, np.pi / 2, 0.0], dtype=np.float64)) distance: float = 2.0 - result: EulerStateSE3 = translate_euler_se3_along_z(pose, distance) - expected: EulerStateSE3 = EulerStateSE3.from_array( + result: EulerPoseSE3 = translate_euler_se3_along_z(pose, distance) + expected: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([3.0, 2.0, 3.0, 0.0, np.pi / 2, 0.0], dtype=np.float64) ) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_body_frame(self) -> None: """Tests translating a SE3 state along the body frame forward direction.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) translation: Vector3D = Vector3D.from_array(np.array([1.0, 0.0, 0.0], dtype=np.float64)) - result: EulerStateSE3 = translate_euler_se3_along_body_frame(pose, translation) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_body_frame(pose, translation) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_body_frame_multiple_axes(self) -> None: """Tests translating a SE3 state along the body frame in multiple axes.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) translation: Vector3D = Vector3D.from_array(np.array([0.5, -1.0, 2.0], dtype=np.float64)) - result: EulerStateSE3 = translate_euler_se3_along_body_frame(pose, translation) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.5, 1.0, 5.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_body_frame(pose, translation) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.5, 1.0, 5.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_body_frame_zero_translation(self) -> None: """Tests translating a SE3 state along the body frame with zero translation.""" - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) translation: Vector3D = Vector3D.from_array(np.array([0.0, 0.0, 0.0], dtype=np.float64)) - result: EulerStateSE3 = translate_euler_se3_along_body_frame(pose, translation) - expected: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) + result: EulerPoseSE3 = translate_euler_se3_along_body_frame(pose, translation) + expected: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 2.0, 3.0, 0.0, 0.0, 0.0], dtype=np.float64)) np.testing.assert_array_almost_equal(result.array, expected.array) def test_translate_se3_along_body_frame_with_rotation(self) -> None: """Tests translating a SE3 state along the body frame forward direction with yaw rotation.""" # Rotate 90 degrees around z-axis, then translate 1 unit along body x-axis - pose: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64)) + pose: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64)) translation: Vector3D = Vector3D.from_array(np.array([1.0, 0.0, 0.0], dtype=np.float64)) - result: EulerStateSE3 = translate_euler_se3_along_body_frame(pose, translation) + result: EulerPoseSE3 = translate_euler_se3_along_body_frame(pose, translation) # Should move in +Y direction in world frame - expected: EulerStateSE3 = EulerStateSE3.from_array( + expected: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([0.0, 1.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64) ) np.testing.assert_array_almost_equal(result.array, expected.array, decimal=self.decimal) @@ -166,7 +166,7 @@ def test_translate_se3_along_body_frame_consistency(self) -> None: start_pitch: float = np.random.uniform(-np.pi, np.pi) start_yaw: float = np.random.uniform(-np.pi, np.pi) - original_pose: EulerStateSE3 = EulerStateSE3.from_array( + original_pose: EulerPoseSE3 = EulerPoseSE3.from_array( np.array( [ start_x, @@ -182,37 +182,37 @@ def test_translate_se3_along_body_frame_consistency(self) -> None: # x-axis translation translation_x: Vector3D = Vector3D.from_array(np.array([x_distance, 0.0, 0.0], dtype=np.float64)) - result_body_frame_x: EulerStateSE3 = translate_euler_se3_along_body_frame(original_pose, translation_x) - result_axis_x: EulerStateSE3 = translate_euler_se3_along_x(original_pose, x_distance) + result_body_frame_x: EulerPoseSE3 = translate_euler_se3_along_body_frame(original_pose, translation_x) + result_axis_x: EulerPoseSE3 = translate_euler_se3_along_x(original_pose, x_distance) np.testing.assert_array_almost_equal(result_body_frame_x.array, result_axis_x.array, decimal=self.decimal) # y-axis translation translation_y: Vector3D = Vector3D.from_array(np.array([0.0, y_distance, 0.0], dtype=np.float64)) - result_body_frame_y: EulerStateSE3 = translate_euler_se3_along_body_frame(original_pose, translation_y) - result_axis_y: EulerStateSE3 = translate_euler_se3_along_y(original_pose, y_distance) + result_body_frame_y: EulerPoseSE3 = translate_euler_se3_along_body_frame(original_pose, translation_y) + result_axis_y: EulerPoseSE3 = translate_euler_se3_along_y(original_pose, y_distance) np.testing.assert_array_almost_equal(result_body_frame_y.array, result_axis_y.array, decimal=self.decimal) # z-axis translation translation_z: Vector3D = Vector3D.from_array(np.array([0.0, 0.0, z_distance], dtype=np.float64)) - result_body_frame_z: EulerStateSE3 = translate_euler_se3_along_body_frame(original_pose, translation_z) - result_axis_z: EulerStateSE3 = translate_euler_se3_along_z(original_pose, z_distance) + result_body_frame_z: EulerPoseSE3 = translate_euler_se3_along_body_frame(original_pose, translation_z) + result_axis_z: EulerPoseSE3 = translate_euler_se3_along_z(original_pose, z_distance) np.testing.assert_array_almost_equal(result_body_frame_z.array, result_axis_z.array, decimal=self.decimal) # all axes translation translation_all: Vector3D = Vector3D.from_array( np.array([x_distance, y_distance, z_distance], dtype=np.float64) ) - result_body_frame_all: EulerStateSE3 = translate_euler_se3_along_body_frame(original_pose, translation_all) - intermediate_pose: EulerStateSE3 = translate_euler_se3_along_x(original_pose, x_distance) + result_body_frame_all: EulerPoseSE3 = translate_euler_se3_along_body_frame(original_pose, translation_all) + intermediate_pose: EulerPoseSE3 = translate_euler_se3_along_x(original_pose, x_distance) intermediate_pose = translate_euler_se3_along_y(intermediate_pose, y_distance) - result_axis_all: EulerStateSE3 = translate_euler_se3_along_z(intermediate_pose, z_distance) + result_axis_all: EulerPoseSE3 = translate_euler_se3_along_z(intermediate_pose, z_distance) np.testing.assert_array_almost_equal( result_body_frame_all.array, result_axis_all.array, decimal=self.decimal ) def test_convert_absolute_to_relative_se3_array(self) -> None: """Tests converting absolute SE3 poses to relative SE3 poses.""" - reference: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) + reference: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) absolute_poses: npt.NDArray[np.float64] = np.array( [ [2.0, 2.0, 2.0, 0.0, 0.0, 0.0], @@ -232,7 +232,7 @@ def test_convert_absolute_to_relative_se3_array(self) -> None: def test_convert_absolute_to_relative_se3_array_single_pose(self) -> None: """Tests converting a single absolute SE3 pose to a relative SE3 pose.""" - reference: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + reference: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) absolute_poses: npt.NDArray[np.float64] = np.array([[1.0, 2.0, 3.0, 0.0, 0.0, 0.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_euler_se3_array(reference, absolute_poses) expected: npt.NDArray[np.float64] = np.array([[1.0, 2.0, 3.0, 0.0, 0.0, 0.0]], dtype=np.float64) @@ -240,7 +240,7 @@ def test_convert_absolute_to_relative_se3_array_single_pose(self) -> None: def test_convert_absolute_to_relative_se3_array_with_rotation(self) -> None: """Tests converting absolute SE3 poses to relative SE3 poses with 90 degree yaw rotation.""" - reference: EulerStateSE3 = EulerStateSE3.from_array( + reference: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([0.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64) ) absolute_poses: npt.NDArray[np.float64] = np.array([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=np.float64) @@ -250,7 +250,7 @@ def test_convert_absolute_to_relative_se3_array_with_rotation(self) -> None: def test_convert_relative_to_absolute_se3_array(self) -> None: """Tests converting relative SE3 poses to absolute SE3 poses.""" - reference: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) + reference: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) relative_poses: npt.NDArray[np.float64] = np.array( [ [1.0, 1.0, 1.0, 0.0, 0.0, 0.0], @@ -270,7 +270,7 @@ def test_convert_relative_to_absolute_se3_array(self) -> None: def test_convert_relative_to_absolute_se3_array_with_rotation(self) -> None: """Tests converting relative SE3 poses to absolute SE3 poses with 90 degree yaw rotation.""" - reference: EulerStateSE3 = EulerStateSE3.from_array( + reference: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([1.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64) ) relative_poses: npt.NDArray[np.float64] = np.array([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=np.float64) @@ -280,7 +280,7 @@ def test_convert_relative_to_absolute_se3_array_with_rotation(self) -> None: def test_convert_absolute_to_relative_points_3d_array(self) -> None: """Tests converting absolute 3D points to relative 3D points.""" - reference: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) + reference: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([[2.0, 2.0, 2.0], [0.0, 1.0, 0.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_points_3d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 1.0, 1.0], [-1.0, 0.0, -1.0]], dtype=np.float64) @@ -288,7 +288,7 @@ def test_convert_absolute_to_relative_points_3d_array(self) -> None: def test_convert_absolute_to_relative_points_3d_array_origin_reference(self) -> None: """Tests converting absolute 3D points to relative 3D points with origin reference.""" - reference: EulerStateSE3 = EulerStateSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) + reference: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)) absolute_points: npt.NDArray[np.float64] = np.array([[1.0, 2.0, 3.0], [-1.0, -2.0, -3.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_absolute_to_relative_points_3d_array(reference, absolute_points) expected: npt.NDArray[np.float64] = np.array([[1.0, 2.0, 3.0], [-1.0, -2.0, -3.0]], dtype=np.float64) @@ -296,7 +296,7 @@ def test_convert_absolute_to_relative_points_3d_array_origin_reference(self) -> def test_convert_absolute_to_relative_points_3d_array_with_rotation(self) -> None: """Tests converting absolute 3D points to relative 3D points with 90 degree yaw rotation.""" - reference: EulerStateSE3 = EulerStateSE3.from_array( + reference: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([0.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64) ) absolute_points: npt.NDArray[np.float64] = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 1.0]], dtype=np.float64) @@ -306,7 +306,7 @@ def test_convert_absolute_to_relative_points_3d_array_with_rotation(self) -> Non def test_convert_relative_to_absolute_points_3d_array(self) -> None: """Tests converting relative 3D points to absolute 3D points.""" - reference: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) + reference: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) relative_points: npt.NDArray[np.float64] = np.array([[1.0, 1.0, 1.0], [-1.0, 0.0, -1.0]], dtype=np.float64) result: npt.NDArray[np.float64] = convert_relative_to_absolute_points_3d_array(reference, relative_points) expected: npt.NDArray[np.float64] = np.array([[2.0, 2.0, 2.0], [0.0, 1.0, 0.0]], dtype=np.float64) @@ -314,7 +314,7 @@ def test_convert_relative_to_absolute_points_3d_array(self) -> None: def test_convert_relative_to_absolute_points_3d_array_empty(self) -> None: """Tests converting an empty array of relative 3D points to absolute 3D points.""" - reference: EulerStateSE3 = EulerStateSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) + reference: EulerPoseSE3 = EulerPoseSE3.from_array(np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64)) relative_points: npt.NDArray[np.float64] = np.array([], dtype=np.float64).reshape(0, 3) result: npt.NDArray[np.float64] = convert_relative_to_absolute_points_3d_array(reference, relative_points) expected: npt.NDArray[np.float64] = np.array([], dtype=np.float64).reshape(0, 3) @@ -322,7 +322,7 @@ def test_convert_relative_to_absolute_points_3d_array_empty(self) -> None: def test_convert_relative_to_absolute_points_3d_array_with_rotation(self) -> None: """Tests converting relative 3D points to absolute 3D points with 90 degree yaw rotation.""" - reference: EulerStateSE3 = EulerStateSE3.from_array( + reference: EulerPoseSE3 = EulerPoseSE3.from_array( np.array([1.0, 0.0, 0.0, 0.0, 0.0, np.pi / 2], dtype=np.float64) ) relative_points: npt.NDArray[np.float64] = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 1.0]], dtype=np.float64) diff --git a/tests/unit/geometry/transform/test_transform_se3.py b/tests/unit/geometry/transform/test_transform_se3.py index 1564384a..f425e066 100644 --- a/tests/unit/geometry/transform/test_transform_se3.py +++ b/tests/unit/geometry/transform/test_transform_se3.py @@ -2,7 +2,7 @@ import numpy.typing as npt import py123d.geometry.transform.transform_euler_se3 as euler_transform_se3 -from py123d.geometry import EulerStateSE3, EulerStateSE3Index, Point3D, PoseSE3, PoseSE3Index +from py123d.geometry import EulerPoseSE3, EulerPoseSE3Index, Point3D, PoseSE3, PoseSE3Index from py123d.geometry.transform.transform_se3 import ( convert_absolute_to_relative_points_3d_array, convert_absolute_to_relative_se3_array, @@ -24,7 +24,7 @@ class TestTransformSE3: def setup_method(self): - euler_se3_a = EulerStateSE3( + euler_se3_a = EulerPoseSE3( x=1.0, y=2.0, z=3.0, @@ -32,7 +32,7 @@ def setup_method(self): pitch=0.0, yaw=0.0, ) - euler_se3_b = EulerStateSE3( + euler_se3_b = EulerPoseSE3( x=1.0, y=-2.0, z=3.0, @@ -40,7 +40,7 @@ def setup_method(self): pitch=np.deg2rad(90), yaw=0.0, ) - euler_se3_c = EulerStateSE3( + euler_se3_c = EulerPoseSE3( x=-1.0, y=2.0, z=-3.0, @@ -60,13 +60,11 @@ def setup_method(self): def _get_random_euler_se3_array(self, size: int) -> npt.NDArray[np.float64]: """Generate a random SE3 poses""" - random_se3_array = np.zeros((size, len(EulerStateSE3Index)), dtype=np.float64) - random_se3_array[:, EulerStateSE3Index.XYZ] = np.random.uniform( - -self.max_pose_xyz, self.max_pose_xyz, (size, 3) - ) - random_se3_array[:, EulerStateSE3Index.YAW] = np.random.uniform(-np.pi, np.pi, size) - random_se3_array[:, EulerStateSE3Index.PITCH] = np.random.uniform(-np.pi / 2, np.pi / 2, size) - random_se3_array[:, EulerStateSE3Index.ROLL] = np.random.uniform(-np.pi, np.pi, size) + random_se3_array = np.zeros((size, len(EulerPoseSE3Index)), dtype=np.float64) + random_se3_array[:, EulerPoseSE3Index.XYZ] = np.random.uniform(-self.max_pose_xyz, self.max_pose_xyz, (size, 3)) + random_se3_array[:, EulerPoseSE3Index.YAW] = np.random.uniform(-np.pi, np.pi, size) + random_se3_array[:, EulerPoseSE3Index.PITCH] = np.random.uniform(-np.pi / 2, np.pi / 2, size) + random_se3_array[:, EulerPoseSE3Index.ROLL] = np.random.uniform(-np.pi, np.pi, size) return random_se3_array @@ -75,9 +73,9 @@ def _convert_euler_se3_array_to_quat_se3_array( ) -> npt.NDArray[np.float64]: """Convert an array of SE3 poses from Euler angles to Quaternion representation""" quat_se3_array = np.zeros((euler_se3_array.shape[0], len(PoseSE3Index)), dtype=np.float64) - quat_se3_array[:, PoseSE3Index.XYZ] = euler_se3_array[:, EulerStateSE3Index.XYZ] + quat_se3_array[:, PoseSE3Index.XYZ] = euler_se3_array[:, EulerPoseSE3Index.XYZ] quat_se3_array[:, PoseSE3Index.QUATERNION] = get_quaternion_array_from_euler_array( - euler_se3_array[:, EulerStateSE3Index.EULER_ANGLES] + euler_se3_array[:, EulerPoseSE3Index.EULER_ANGLES] ) return quat_se3_array @@ -106,7 +104,7 @@ def test_random_sanity(self): random_quat_se3_array = self._convert_euler_se3_array_to_quat_se3_array(random_euler_se3_array) np.testing.assert_allclose( - random_euler_se3_array[:, EulerStateSE3Index.XYZ], + random_euler_se3_array[:, EulerPoseSE3Index.XYZ], random_quat_se3_array[:, PoseSE3Index.XYZ], atol=1e-6, ) @@ -114,7 +112,7 @@ def test_random_sanity(self): random_quat_se3_array[:, PoseSE3Index.QUATERNION] ) euler_rotation_matrices = get_rotation_matrices_from_euler_array( - random_euler_se3_array[:, EulerStateSE3Index.EULER_ANGLES] + random_euler_se3_array[:, EulerPoseSE3Index.EULER_ANGLES] ) np.testing.assert_allclose(euler_rotation_matrices, quat_rotation_matrices, atol=1e-6) @@ -137,14 +135,14 @@ def test_convert_absolute_to_relative_se3_array(self): euler_se3, random_euler_se3_array ) np.testing.assert_allclose( - rel_se3_euler[..., EulerStateSE3Index.XYZ], rel_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6 + rel_se3_euler[..., EulerPoseSE3Index.XYZ], rel_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6 ) # We compare rotation matrices to avoid issues with quaternion sign ambiguity quat_rotation_matrices = get_rotation_matrices_from_quaternion_array( rel_se3_quat[..., PoseSE3Index.QUATERNION] ) euler_rotation_matrices = get_rotation_matrices_from_euler_array( - rel_se3_euler[..., EulerStateSE3Index.EULER_ANGLES] + rel_se3_euler[..., EulerPoseSE3Index.EULER_ANGLES] ) np.testing.assert_allclose(quat_rotation_matrices, euler_rotation_matrices, atol=1e-6) @@ -167,7 +165,7 @@ def test_convert_relative_to_absolute_se3_array(self): euler_se3, random_euler_se3_array ) np.testing.assert_allclose( - abs_se3_euler[..., EulerStateSE3Index.XYZ], abs_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6 + abs_se3_euler[..., EulerPoseSE3Index.XYZ], abs_se3_quat[..., PoseSE3Index.XYZ], atol=1e-6 ) # We compare rotation matrices to avoid issues with quaternion sign ambiguity @@ -175,7 +173,7 @@ def test_convert_relative_to_absolute_se3_array(self): abs_se3_quat[..., PoseSE3Index.QUATERNION] ) euler_rotation_matrices = get_rotation_matrices_from_euler_array( - abs_se3_euler[..., EulerStateSE3Index.EULER_ANGLES] + abs_se3_euler[..., EulerPoseSE3Index.EULER_ANGLES] ) np.testing.assert_allclose(quat_rotation_matrices, euler_rotation_matrices, atol=1e-6) # convert_points_3d_array_between_origins(quat_se3, random_quat_se3_array) diff --git a/tests/unit/geometry/utils/test_bounding_box_utils.py b/tests/unit/geometry/utils/test_bounding_box_utils.py index d9475469..dd049b41 100644 --- a/tests/unit/geometry/utils/test_bounding_box_utils.py +++ b/tests/unit/geometry/utils/test_bounding_box_utils.py @@ -6,11 +6,11 @@ BoundingBoxSE3Index, Corners2DIndex, Corners3DIndex, - EulerStateSE3Index, + EulerPoseSE3Index, Point2DIndex, Point3DIndex, ) -from py123d.geometry.pose import EulerStateSE3, PoseSE3 +from py123d.geometry.pose import EulerPoseSE3, PoseSE3 from py123d.geometry.transform.transform_se3 import translate_se3_along_body_frame from py123d.geometry.utils.bounding_box_utils import ( bbse2_array_to_corners_array, @@ -30,15 +30,15 @@ def setup_method(self): def _get_random_euler_se3_array(self, size: int) -> npt.NDArray[np.float64]: """Generate random SE3 poses""" - random_se3_array = np.zeros((size, len(EulerStateSE3Index)), dtype=np.float64) - random_se3_array[:, EulerStateSE3Index.XYZ] = np.random.uniform( + random_se3_array = np.zeros((size, len(EulerPoseSE3Index)), dtype=np.float64) + random_se3_array[:, EulerPoseSE3Index.XYZ] = np.random.uniform( -self._max_pose_xyz, self._max_pose_xyz, (size, len(Point3DIndex)), ) - random_se3_array[:, EulerStateSE3Index.YAW] = np.random.uniform(-np.pi, np.pi, size) - random_se3_array[:, EulerStateSE3Index.PITCH] = np.random.uniform(-np.pi / 2, np.pi / 2, size) - random_se3_array[:, EulerStateSE3Index.ROLL] = np.random.uniform(-np.pi, np.pi, size) + random_se3_array[:, EulerPoseSE3Index.YAW] = np.random.uniform(-np.pi, np.pi, size) + random_se3_array[:, EulerPoseSE3Index.PITCH] = np.random.uniform(-np.pi / 2, np.pi / 2, size) + random_se3_array[:, EulerPoseSE3Index.ROLL] = np.random.uniform(-np.pi, np.pi, size) return random_se3_array @@ -195,7 +195,7 @@ def test_bbse3_array_to_corners_array_one_dim(self): def test_bbse3_array_to_corners_array_one_dim_rotation(self): for _ in range(self._num_consistency_checks): - se3_state = EulerStateSE3.from_array(self._get_random_euler_se3_array(1)[0]).pose_se3 + se3_state = EulerPoseSE3.from_array(self._get_random_euler_se3_array(1)[0]).pose_se3 se3_array = se3_state.array # construct a bounding box @@ -224,7 +224,7 @@ def test_bbse3_array_to_corners_array_n_dim(self): for _ in range(self._num_consistency_checks): N = np.random.randint(1, 20) se3_array = self._get_random_euler_se3_array(N) - se3_state_array = np.array([EulerStateSE3.from_array(arr).pose_se3.array for arr in se3_array]) + se3_state_array = np.array([EulerPoseSE3.from_array(arr).pose_se3.array for arr in se3_array]) # construct a bounding box bounding_box_se3_array = np.zeros((N, len(BoundingBoxSE3Index)), dtype=np.float64) From 0e3cdbcb3814a28f84493b4a720182ad5a712395 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Tue, 18 Nov 2025 15:01:46 +0100 Subject: [PATCH 32/50] Update docs, primarily the dataset descriptions. --- docs/_static/custom.css | 3 + docs/conf.py | 2 + docs/datasets/av2.rst | 207 ++++++++---- docs/datasets/carla.rst | 88 ------ docs/datasets/index.rst | 2 +- docs/datasets/nuplan.rst | 294 +++++++++++++++--- docs/datasets/nuscenes.rst | 209 +++++++++---- docs/datasets/template.rst | 143 +++++---- docs/datasets/wodp.rst | 178 +++++++++++ docs/datasets/wopd.rst | 99 ------ docs/development/index.rst | 7 - docs/index.rst | 35 ++- docs/installation.md | 72 +++-- docs/{development => notes}/contributing.md | 3 +- docs/notes/conventions.rst | 0 docs/visualization.md | 11 - examples/01_viser.py | 5 +- pyproject.toml | 5 +- scripts/download/download_av2.sh | 4 - scripts/download/download_nuplan_logs.sh | 38 ++- .../datasets/av2/av2_map_conversion.py | 15 +- .../datasets/av2/av2_sensor_converter.py | 6 +- .../wopd/utils/womp_boundary_utils.py | 14 +- .../conversion/log_writer/arrow_log_writer.py | 12 +- src/py123d/conversion/registry/__init__.py | 2 +- .../registry/box_detection_label_registry.py | 26 +- .../registry/lidar_index_registry.py | 2 +- .../datatypes/detections/box_detections.py | 2 - .../datasets/av2_sensor_dataset.yaml | 2 +- .../datatypes/vehicle_state/test_ego_state.py | 4 +- 30 files changed, 986 insertions(+), 504 deletions(-) create mode 100644 docs/_static/custom.css create mode 100644 docs/datasets/wodp.rst delete mode 100644 docs/datasets/wopd.rst delete mode 100644 docs/development/index.rst rename docs/{development => notes}/contributing.md (99%) create mode 100644 docs/notes/conventions.rst delete mode 100644 docs/visualization.md diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 00000000..1c0c529f --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,3 @@ +.small-table { + font-size: 0.9em; +} diff --git a/docs/conf.py b/docs/conf.py index 5841ae21..939fa684 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,8 @@ "sphinx.ext.napoleon", "sphinx_copybutton", "sphinx_autodoc_typehints", + "sphinxcontrib.youtube", + "sphinx_design", "myst_parser", ] diff --git a/docs/datasets/av2.rst b/docs/datasets/av2.rst index 3c09f329..af1567d3 100644 --- a/docs/datasets/av2.rst +++ b/docs/datasets/av2.rst @@ -1,75 +1,175 @@ -Argoverse 2 ------------ +Argoverse 2 - Sensor +-------------------- -.. sidebar:: Dataset Name +Argoverse 2 (AV2) is a collection of three datasets. +The *Sensor Dataset* includes 1000 logs of ~20 second duration, including multi-view cameras, LiDAR point clouds, maps, ego-vehicle data, and bounding boxes. +This dataset is intended to train 3D perception models for autonomous vehicles. - .. image:: https://www.argoverse.org/assets/images/reference_images/av2_vehicle.jpg - :alt: Dataset sample image +.. dropdown:: Overview + :open: - | **Paper:** `Name of Paper `_ - | **Download:** `Documentation `_ - | **Code:** [Code] - | **Documentation:** [License type] - | **License:** [License type] - | **Duration:** [Duration here] - | **Supported Versions:** [Yes/No/Conditions] - | **Redistribution:** [Yes/No/Conditions] + .. list-table:: + :header-rows: 0 + :widths: 20 60 -Description -~~~~~~~~~~~ + * - + - + * - :octicon:`file` Paper + - + `Argoverse 2: Next Generation Datasets for Self-Driving Perception and Forecasting `_ -[Provide a detailed description of the dataset here, including its purpose, collection methodology, and key characteristics.] + * - :octicon:`download` Download + - `argoverse.org `_ + * - :octicon:`mark-github` Code + - `argoverse/av2-api `_ + * - :octicon:`law` License + - + `CC BY-NC-SA 4.0 `_ -Installation -~~~~~~~~~~~~ - -[Instructions for installing or accessing the dataset] + `Argoverse Terms of Use `_ -.. code-block:: bash + MIT License + * - :octicon:`database` Available splits + - ``av2-sensor_train``, ``av2-sensor_val``, ``av2-sensor_test`` - # Example installation commands - pip install py123d[dataset_name] - # or - wget https://example.com/dataset.zip -Available Data -~~~~~~~~~~~~~~ +Available Modalities +~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 - :widths: 30 5 70 - + :widths: 30 5 65 * - **Name** - **Available** - **Description** * - Ego Vehicle - - X - - [Description of ego vehicle data] + - ✓ + - State of the ego vehicle, including poses, and vehicle parameters, see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. * - Map - - X - - [Description of ego vehicle data] + - (✓) + - The HD-Maps are in 3D, but may have artifacts due to polyline to polygon conversion (see below). For more information, see :class:`~py123d.api.MapAPI`. * - Bounding Boxes - - X - - [Description of ego vehicle data] + - ✓ + - The bounding boxes are available with the :class:`~py123d.conversion.registry.AV2SensorBoxDetectionLabel`. For more information, :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. * - Traffic Lights - X - - [Description of ego vehicle data] - * - Cameras + - n/a + * - Pinhole Cameras + - ✓ + - + Includes 9 cameras, see :class:`~py123d.datatypes.sensors.PinholeCamera`: + + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_F0` (ring_front_center) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R0` (ring_front_right) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R1` (ring_side_right) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R2` (ring_rear_right) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L0` (ring_front_left) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L1` (ring_side_left) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L2` (ring_rear_left) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_STEREO_R` (stereo_front_right) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_STEREO_L` (stereo_front_left) + + + * - Fisheye Cameras - X - - [Description of ego vehicle data] + - n/a * - LiDARs - - X - - [Description of ego vehicle data] + - ✓ + - + Includes 2 LiDARs, see :class:`~py123d.datatypes.sensors.LiDAR`: + + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_TOP` (top up) + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_DOWN` (top down) + + +.. dropdown:: Dataset Specific + + .. autoclass:: py123d.conversion.registry.AV2SensorBoxDetectionLabel + :members: + :no-index: + :no-inherited-members: + + .. autoclass:: py123d.conversion.registry.AV2SensorLiDARIndex + :members: + :no-index: + :no-inherited-members: + +Download +~~~~~~~~ + +You can download the Argoverse 2 Sensor dataset from the `Argoverse website `_. +You can also use directly the dataset from AWS. For that, you first need to install `s5cmd `_: + +.. code-block:: bash + + pip install s5cmd -Dataset Specific Issues -~~~~~~~~~~~~~~~~~~~~~~~ -[Document any known issues, limitations, or considerations when using this dataset] +Next, you can run the following bash script to download the dataset: + +.. code-block:: bash + + DATASET_NAME="sensor" # "sensor" "lidar" "motion-forecasting" "tbv" + AV2_SENSOR_ROOT="/path/to/argoverse/sensor" + + mkdir -p "$AV2_SENSOR_ROOT" + s5cmd --no-sign-request cp "s3://argoverse/datasets/av2/$DATASET_NAME/*" "$AV2_SENSOR_ROOT" + + +The downloaded dataset should have the following structure: + +.. code-block:: none + + $AV2_SENSOR_ROOT + ├── train + │ ├── 00a6ffc1-6ce9-3bc3-a060-6006e9893a1a + │ │ ├── annotations.feather + │ │ ├── calibration + │ │ │ ├── egovehicle_SE3_sensor.feather + │ │ │ └── intrinsics.feather + │ │ ├── city_SE3_egovehicle.feather + │ │ ├── map + │ │ │ ├── 00a6ffc1-6ce9-3bc3-a060-6006e9893a1a_ground_height_surface____PIT.npy + │ │ │ ├── 00a6ffc1-6ce9-3bc3-a060-6006e9893a1a___img_Sim2_city.json + │ │ │ └── log_map_archive_00a6ffc1-6ce9-3bc3-a060-6006e9893a1a____PIT_city_31785.json + │ │ └── sensors + │ │ ├── cameras + │ │ │ └──... + │ │ └── lidar + │ │ └──... + │ └── ... + ├── test + │ └── ... + └── val + └── ... + + +Installation +~~~~~~~~~~~~ + +No additional installation steps are required beyond the standard ``py123d`` installation. + + +Conversion +~~~~~~~~~~ + +To run the conversion, you either need to set the environment variable ``$AV2_SENSOR_ROOT`` or ``$AV2_SENSOR_ROOT``. +You can also override the file path and run: + +.. code-block:: bash + + py123d-conversion datasets=["av2_sensor_dataset"] \ + dataset_paths.av2_sensor_data_root=$AV2_SENSOR_ROOT # optional if env variable is set + + + + +Dataset Issues +~~~~~~~~~~~~~~ + +n/a -* Issue 1: Description -* Issue 2: Description -* Issue 3: Description Citation ~~~~~~~~ @@ -78,12 +178,9 @@ If you use this dataset in your research, please cite: .. code-block:: bibtex - @article{AuthorYearConference, - title={Dataset Title}, - author={Author, First and Author, Second}, - journal={Journal Name}, - year={2023}, - volume={1}, - pages={1-10}, - doi={10.1000/example} - } + @article{Wilson2023NEURIPS, + author = {Benjamin Wilson and William Qi and Tanmay Agarwal and John Lambert and Jagjeet Singh and Siddhesh Khandelwal and Bowen Pan and Ratnesh Kumar and Andrew Hartnett and Jhony Kaesemodel Pontes and Deva Ramanan and Peter Carr and James Hays}, + title = {Argoverse 2: Next Generation Datasets for Self-Driving Perception and Forecasting}, + booktitle = {Proceedings of the Neural Information Processing Systems Track on Datasets and Benchmarks (NeurIPS Datasets and Benchmarks 2021)}, + year = {2021} + } diff --git a/docs/datasets/carla.rst b/docs/datasets/carla.rst index ccc921f1..1de05e5a 100644 --- a/docs/datasets/carla.rst +++ b/docs/datasets/carla.rst @@ -1,90 +1,2 @@ CARLA ----- - -.. sidebar:: Dataset Name - - .. image:: https://carla.org/img/services/getty_center_400_400.jpg - :alt: Dataset sample image - :width: 290px - - | **Paper:** `Name of Paper `_ - | **Download:** `Documentation `_ - | **Code:** [Code] - | **Documentation:** [License type] - | **License:** [License type] - | **Duration:** [Duration here] - | **Supported Versions:** [Yes/No/Conditions] - | **Redistribution:** [Yes/No/Conditions] - -Description -~~~~~~~~~~~ - -[Provide a detailed description of the dataset here, including its purpose, collection methodology, and key characteristics.] - -Installation -~~~~~~~~~~~~ - -[Instructions for installing or accessing the dataset] - -.. code-block:: bash - - # Example installation commands - pip install py123d[dataset_name] - # or - wget https://example.com/dataset.zip - -Available Data -~~~~~~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - :widths: 30 5 70 - - - * - **Name** - - **Available** - - **Description** - * - Ego Vehicle - - X - - [Description of ego vehicle data] - * - Map - - X - - [Description of ego vehicle data] - * - Bounding Boxes - - X - - [Description of ego vehicle data] - * - Traffic Lights - - X - - [Description of ego vehicle data] - * - Cameras - - X - - [Description of ego vehicle data] - * - LiDARs - - X - - [Description of ego vehicle data] - -Dataset Specific Issues -~~~~~~~~~~~~~~~~~~~~~~~ - -[Document any known issues, limitations, or considerations when using this dataset] - -* Issue 1: Description -* Issue 2: Description -* Issue 3: Description - -Citation -~~~~~~~~ - -If you use this dataset in your research, please cite: - -.. code-block:: bibtex - - @article{AuthorYearConference, - title={Dataset Title}, - author={Author, First and Author, Second}, - journal={Journal Name}, - year={2023}, - volume={1}, - pages={1-10}, - doi={10.1000/example} - } diff --git a/docs/datasets/index.rst b/docs/datasets/index.rst index 851fbf32..40a404e0 100644 --- a/docs/datasets/index.rst +++ b/docs/datasets/index.rst @@ -13,5 +13,5 @@ This section provides comprehensive documentation for various autonomous driving nuscenes carla kitti-360 - wopd + wodp template diff --git a/docs/datasets/nuplan.rst b/docs/datasets/nuplan.rst index 94b50f08..3e0c5e4e 100644 --- a/docs/datasets/nuplan.rst +++ b/docs/datasets/nuplan.rst @@ -1,40 +1,41 @@ nuPlan ------ -.. sidebar:: nuPlan +nuPlan is a planning simulator that comes with a large-scale dataset for autonomous vehicle research. +This dataset contains ~1282 hours of driving logs, including ego-vehicle data, HD maps, and auto-labeled bounding boxes, spanning 4 cities. +About 120 hours of nuPlan include sensor data from 8 cameras and 5 LiDARs. - .. image:: https://www.nuplan.org/static/media/nuPlan_final.3fde7586.png - :alt: Dataset sample image - :width: 290px +.. dropdown:: Overview + :open: - | **Paper:** `Towards learning-based planning:The nuPlan benchmark for real-world autonomous driving `_ - | **Download:** `www.nuscenes.org/nuplan `_ - | **Code:** `www.github.com/motional/nuplan-devkit `_ - | **Documentation:** `nuPlan Documentation `_ - | **License:** `CC BY-NC-SA 4.0 `_, `nuPlan Dataset License `_ - | **Duration:** 1282 hours (120 hours of sensor data) - | **Supported Versions:** [TODO] - | **Redistribution:** [TODO] + .. list-table:: + :header-rows: 0 + :widths: 20 60 -Description -~~~~~~~~~~~ + * - + - + * - :octicon:`file` Papers + - + `Towards learning-based planning: The nuplan benchmark for real-world autonomous driving `_ -[Provide a detailed description of the dataset here, including its purpose, collection methodology, and key characteristics.] + `nuplan: A closed-loop ml-based planning benchmark for autonomous vehicles `_ + * - :octicon:`download` Download + - `nuplan.org `_ + * - :octicon:`mark-github` Code + - `nuplan-devkit `_ + * - :octicon:`law` License + - + `CC BY-NC-SA 4.0 `_ -Installation -~~~~~~~~~~~~ + `nuPlan Terms of Use `_ -[Instructions for installing or accessing the dataset] + Apache License 2.0 + * - :octicon:`database` Available splits + - ``nuplan_train``, ``nuplan_val``, ``nuplan_test``, ``nuplan-mini_train``, ``nuplan-mini_val``, ``nuplan-mini_test`` -.. code-block:: bash - # Example installation commands - pip install py123d[dataset_name] - # or - wget https://example.com/dataset.zip - -Available Data -~~~~~~~~~~~~~~ +Available Modalities +~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 @@ -45,37 +46,240 @@ Available Data - **Description** * - Ego Vehicle - ✓ - - [Description of ego vehicle data] + - State of the ego vehicle, including poses, dynamic state, and vehicle parameters, see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. * - Map - - ✓ - - [Description of map data] + - (✓) + - The HD-Maps are in 2D vector format and defined per-location. For more information, see :class:`~py123d.api.MapAPI`. * - Bounding Boxes - - X - - [Description of bounding boxes data] + - ✓ + - The bounding boxes are available, see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. * - Traffic Lights + - ✓ + - Traffic lights include the status and the lane id they are associated with, see :class:`~py123d.datatypes.detections.TrafficLightDetectionWrapper`. + * - Pinhole Cameras + - (✓) + - + Subset of nuPlan includes 8x :class:`~py123d.datatypes.sensors.PinholeCamera`: + + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_F0`: Front camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R0`: Right front camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R1`: Right middle camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R2`: Right rear camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L0`: Left front camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L1`: Left middle camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L2`: Left rear camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_B0`: Back camera + * - Fisheye Cameras - X - - [Description of traffic lights data] - * - Cameras - - X - - [Description of cameras data] + - * - LiDARs - - X - - [Description of LiDARs data] + - (✓) + - + Subset of nuPlan includes 5x :class:`~py123d.datatypes.sensors.LiDAR`: -Dataset Specific Issues -~~~~~~~~~~~~~~~~~~~~~~~ + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_TOP`: Top + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_FRONT`: Front + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_SIDE_LEFT`: Side left + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_SIDE_RIGHT`: Side right + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_BACK`: Rear -[Document any known issues, limitations, or considerations when using this dataset] +.. dropdown:: Dataset Specific -* Issue 1: Description -* Issue 2: Description -* Issue 3: Description + .. autoclass:: py123d.conversion.registry.NuPlanBoxDetectionLabel + :members: + :no-index: + :no-inherited-members: -Citation + .. autoclass:: py123d.conversion.registry.NuPlanLiDARIndex + :members: + :no-index: + :no-inherited-members: + + + +Download ~~~~~~~~ +You can install the nuPlan dataset either by downloading the files from the `official website `_ or by using the following bash script: + +.. dropdown:: Download Scripts + + **License**: + + .. code-block:: bash + + # NOTE: Please check the LICENSE file when downloading the nuPlan dataset + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/LICENSE + + **Maps** (required for ``nuplan`` and ``nuplan-mini``): + + .. code-block:: bash + + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-maps-v1.1.zip + + + **Logs**: + + .. code-block:: bash + + # 1. nuplan_train + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_boston.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_pittsburgh.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_singapore.zip + for split in {1..6}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_vegas_${split}.zip + done + + # 2. nuplan_val + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_test.zip + + # 3. nuplan_test + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_val.zip + + # 4. nuplan-mini_train, nuplan-mini_val, nuplan-mini_test + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_mini.zip + + + **Sensors**: + + .. code-block:: bash + + # 1. nuplan_train + for split in {0..42}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/train_set/nuplan-v1.1_train_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/train_set/nuplan-v1.1_train_lidar_${split}.zip + done + + # 2. nuplan_val + for split in {0..11}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/val_set/nuplan-v1.1_val_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/val_set/nuplan-v1.1_val_lidar_${split}.zip + done + + # 3. nuplan_test + for split in {0..11}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/test_set/nuplan-v1.1_test_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/test_set/nuplan-v1.1_test_lidar_${split}.zip + done + + # 4. nuplan_mini_train, nuplan_mini_val, nuplan_mini_test + for split in {0..8}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_lidar_${split}.zip + done + +The 123D conversion expects the following directory structure: + +.. code-block:: none + + $NUPLAN_DATA_ROOT + ├── maps (or $NUPLAN_MAPS_ROOT) + │ ├── nuplan-maps-v1.0.json + │ ├── sg-one-north + │ │ └── 9.17.1964 + │ │ └── map.gpkg + │ ├── us-ma-boston + │ │ └── 9.12.1817 + │ │ └── map.gpkg + │ ├── us-nv-las-vegas-strip + │ │ └── 9.15.1915 + │ │ └── map.gpkg + │ └── us-pa-pittsburgh-hazelwood + │ └── 9.17.1937 + │ └── map.gpkg + └── nuplan-v1.1 + ├── splits + │ ├── mini + │ │ ├── 2021.05.12.22.00.38_veh-35_01008_01518.db + │ │ ├── 2021.06.09.17.23.18_veh-38_00773_01140.db + │ │ ├── ... + │ │ └── 2021.10.11.08.31.07_veh-50_01750_01948.db + │ ├── test + │ │ └── ... + │ └── trainval + │ ├── 2021.05.12.22.00.38_veh-35_01008_01518.db + │ ├── 2021.06.09.17.23.18_veh-38_00773_01140.db + │ ├── ... + │ └── 2021.10.11.08.31.07_veh-50_01750_01948.db + └── sensor_blobs (or $NUPLAN_SENSOR_ROOT) + ├── 2021.05.12.22.00.38_veh-35_01008_01518 + │ ├── CAM_F0 + │ │ ├── c082c104b7ac5a71.jpg + │ │ ├── af380db4b4ca5d63.jpg + │ │ ├── ... + │ │ └── 2270fccfb44858b3.jpg + │ ├── CAM_B0 + │ ├── CAM_L0 + │ ├── CAM_L1 + │ ├── CAM_L2 + │ ├── CAM_R0 + │ ├── CAM_R1 + │ ├── CAM_R2 + │ └──MergedPointCloud + │ ├── 03fafcf2c0865668.pcd + │ ├── 5aee37ce29665f1b.pcd + │ ├── ... + │ └── 5fe65ef6a97f5caf.pcd + │ + ├── 2021.06.09.17.23.18_veh-38_00773_01140 + ├── ... + └── 2021.10.11.08.31.07_veh-50_01750_01948 + + +Lastly, you need to add the following environment variables to your ``~/.bashrc`` according to your installation paths: + +.. code-block:: bash + + export NUPLAN_DATA_ROOT=/path/to/nuplan/data/root + export NUPLAN_MAPS_ROOT=/path/to/nuplan/data/root/maps + export NUPLAN_SENSOR_ROOT=/path/to/nuplan/data/root/nuplan-v1.1/sensor_blobs + +Or configure the config ``py123d/script/config/common/default_dataset_paths.yaml`` accordingly. + +Installation +~~~~~~~~~~~~ + +For nuPlan, additional installation that are included as optional dependencies in ``py123d`` are required. You can install them via: + +.. tab-set:: + + .. tab-item:: PyPI + + .. code-block:: bash + + pip install py123d[nuplan] + + .. tab-item:: Source + + .. code-block:: bash + + pip install -e .[nuplan] + +Conversion +~~~~~~~~~~~~ + +You can convert the nuPlan dataset (or mini dataset) by running: + +.. code-block:: bash + + py123d-conversion datasets=["nuplan_dataset"] + # or + py123d-conversion datasets=["nuplan_mini_dataset"] + + + +Dataset Issues +~~~~~~~~~~~~~~ + +* **Map:** The HD-Maps are only available in 2D. +* **Camera & LiDAR:** There are synchronization issues between the sensors and the ego vehicle state. +* **Bounding Boxes:** Due to the auto-labeling process of nuPlan, some bounding boxes may be noisy. +* **Traffic Lights:** The status of the traffic lights are inferred from the vehicle movements. As such, there may be incorrect labels. + +Citation +~~~~~~~~ -If you use this dataset in your research, please cite: +If you use nuPlan in your research, please cite: .. code-block:: bibtex diff --git a/docs/datasets/nuscenes.rst b/docs/datasets/nuscenes.rst index 638c5ad6..f00b871c 100644 --- a/docs/datasets/nuscenes.rst +++ b/docs/datasets/nuscenes.rst @@ -1,90 +1,191 @@ nuScenes -------- -.. sidebar:: nuScenes +The nuScenes dataset is multi-modal autonomous driving dataset that includes data from cameras, LiDARs, and radars, along with detailed annotations from Boston and Singapore. +In total, the dataset contains 1000 driving logs, each of 20 second duration, resulting in 5.5 hours of data. +All logs include ego-vehicle data, camera images, LiDAR point clouds, bounding boxes, and map data. - .. image:: https://ar5iv.labs.arxiv.org/html/1903.11027/assets/figures/sensors.jpg - :alt: Dataset sample image - :width: 290px - | **Paper:** `Name of Paper `_ - | **Download:** `Documentation `_ - | **Code:** [Code] - | **Documentation:** [License type] - | **License:** [License type] - | **Duration:** [Duration here] - | **Supported Versions:** [Yes/No/Conditions] - | **Redistribution:** [Yes/No/Conditions] +.. dropdown:: Overview + :open: -Description -~~~~~~~~~~~ + .. list-table:: + :header-rows: 0 + :widths: 20 60 -[Provide a detailed description of the dataset here, including its purpose, collection methodology, and key characteristics.] + * - + - + * - :octicon:`file` Papers + - + `nuscenes: A multimodal dataset for autonomous driving `_ + * - :octicon:`download` Download + - `nuscenes.org `_ + * - :octicon:`mark-github` Code + - `nuscenes-devkit `_ + * - :octicon:`law` License + - + `CC BY-NC-SA 4.0 `_ -Installation -~~~~~~~~~~~~ + `nuScenes Terms of Use `_ -[Instructions for installing or accessing the dataset] + Apache License 2.0 + * - :octicon:`database` Available splits + - ``nuscenes_train``, ``nuscenes_val``, ``nuscenes_test``, ``nuscenes-mini_train``, ``nuscenes-mini_val``, ``nuscenes-mini_test`` -.. code-block:: bash - # Example installation commands - pip install py123d[dataset_name] - # or - wget https://example.com/dataset.zip - -Available Data -~~~~~~~~~~~~~~ +Available Modalities +~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 30 5 70 - * - **Name** - **Available** - **Description** * - Ego Vehicle - - X - - [Description of ego vehicle data] + - ✓ + - State of the ego vehicle, including poses, dynamic state, and vehicle parameters, see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. * - Map - - X - - [Description of ego vehicle data] + - (✓) + - The HD-Maps are in 2D vector format and defined per-location. For more information, see :class:`~py123d.api.MapAPI`. * - Bounding Boxes - - X - - [Description of ego vehicle data] + - ✓ + - The bounding boxes are available with the :class:`~py123d.conversion.registry.NuScenesBoxDetectionLabel`. For more information, see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. * - Traffic Lights - X - - [Description of ego vehicle data] - * - Cameras + - + * - Pinhole Cameras + - ✓ + - + nuScenes includes 6x :class:`~py123d.datatypes.sensors.PinholeCamera`: + + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_F0`: CAM_FRONT + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R0`: CAM_FRONT_RIGHT + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R1`: CAM_BACK_RIGHT + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L0`: CAM_FRONT_LEFT + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L1`: CAM_BACK_LEFT + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_B0`: CAM_BACK + * - Fisheye Cameras - X - - [Description of ego vehicle data] + - * - LiDARs - - X - - [Description of ego vehicle data] + - ✓ + - nuScenes has one :class:`~py123d.datatypes.sensors.LiDAR` of type :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_TOP`. +.. dropdown:: Dataset Specific + + .. autoclass:: py123d.conversion.registry.NuScenesBoxDetectionLabel + :members: + :no-index: + :no-inherited-members: + + .. autoclass:: py123d.conversion.registry.NuScenesLiDARIndex + :members: + :no-index: + :no-inherited-members: + + +Download +~~~~~~~~ + +You need to install the nuScenes dataset from the `official website `_. +The 123D conversion expects the following directory structure: + +.. code-block:: none + + $NUSCENES_DATA_ROOT + ├── can_bus/ + │ ├── scene-0001_meta.json + │ ├── ... + │ └── scene-1110_zoe_veh_info.json + ├── maps/ + │ ├── 36092f0b03a857c6a3403e25b4b7aab3.png + │ ├── ... + │ ├── 93406b464a165eaba6d9de76ca09f5da.png + │ ├── basemap/ + │ │ └── ... + │ ├── expansion/ + │ │ └── ... + │ └── prediction/ + │ └── ... + ├── samples/ + │ ├── CAM_BACK/ + │ │ └── ... + │ ├── ... + │ └── RADAR_FRONT_RIGHT/ + │ └── ... + ├── sweeps/ + │ └── ... + ├── v1.0-mini/ + │ ├── attribute.json + │ ├── ... + │ └── visibility.json + ├── v1.0-test/ + │ ├── attribute.json + │ ├── ... + │ └── visibility.json + └── v1.0-trainval/ + ├── attribute.json + ├── ... + └── visibility.json + +Lastly, you need to add the following environment variables to your ``~/.bashrc`` according to your installation paths: + +.. code-block:: bash + + export NUSCENES_DATA_ROOT=/path/to/nuplan/data/root + +Or configure the config ``py123d/script/config/common/default_dataset_paths.yaml`` accordingly. -Dataset Specific Issues -~~~~~~~~~~~~~~~~~~~~~~~ +Installation +~~~~~~~~~~~~ + +For nuScenes, additional installation that are included as optional dependencies in ``py123d`` are required. You can install them via: + +.. tab-set:: + + .. tab-item:: PyPI + + .. code-block:: bash + + pip install py123d[nuscenes] + + .. tab-item:: Source + + .. code-block:: bash + + pip install -e .[nuscenes] + +Conversion +~~~~~~~~~~~~ + +You can convert the nuScenes dataset (or mini dataset) by running: + +.. code-block:: bash + + py123d-conversion datasets=["nuscenes_dataset"] + # or + py123d-conversion datasets=["nuscenes_mini_dataset"] + + + +Dataset Issues +~~~~~~~~~~~~~~ -[Document any known issues, limitations, or considerations when using this dataset] +* **Map:** The HD-Maps are only available in 2D. +* ... -* Issue 1: Description -* Issue 2: Description -* Issue 3: Description Citation ~~~~~~~~ -If you use this dataset in your research, please cite: +If you use nuPlan in your research, please cite: .. code-block:: bibtex - @article{AuthorYearConference, - title={Dataset Title}, - author={Author, First and Author, Second}, - journal={Journal Name}, - year={2023}, - volume={1}, - pages={1-10}, - doi={10.1000/example} - } + @article{Caesar2020CVPR, + title={nuscenes: A multimodal dataset for autonomous driving}, + author={Caesar, Holger and Bankiti, Varun and Lang, Alex H and Vora, Sourabh and Liong, Venice Erin and Xu, Qiang and Krishnan, Anush and Pan, Yu and Baldan, Giancarlo and Beijbom, Oscar}, + booktitle={Proceedings of the IEEE/CVF conference on computer vision and pattern recognition}, + year={2020} + } diff --git a/docs/datasets/template.rst b/docs/datasets/template.rst index 29797269..cac5cde0 100644 --- a/docs/datasets/template.rst +++ b/docs/datasets/template.rst @@ -1,70 +1,103 @@ Template -------- +... -.. sidebar:: Dataset Name +.. dropdown:: Quick Links + :open: - .. image:: https://www.nuplan.org/static/media/nuPlan_final.3fde7586.png - :alt: Dataset sample image - :width: 290px + .. list-table:: + :header-rows: 0 + :widths: 20 60 - | **Paper:** `Name of Paper `_ - | **Download:** `Documentation `_ - | **Code:** [Code] - | **Documentation:** [License type] - | **License:** [License type] - | **Duration:** [Duration here] - | **Supported Versions:** [Yes/No/Conditions] - | **Redistribution:** [Yes/No/Conditions] + * - + - + * - :octicon:`file` Paper + - ... + * - :octicon:`download` Download + - ... + * - :octicon:`mark-github` Code + - ... + * - :octicon:`law` License + - ... + * - :octicon:`database` Available splits + - ... -Description -~~~~~~~~~~~ -[Provide a detailed description of the dataset here, including its purpose, collection methodology, and key characteristics.] - -Installation -~~~~~~~~~~~~ - -[Instructions for installing or accessing the dataset] - -.. code-block:: bash - - # Example installation commands - pip install py123d[dataset_name] - # or - wget https://example.com/dataset.zip - -Available Data -~~~~~~~~~~~~~~ +Available Modalities +~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 30 5 70 - * - **Name** - **Available** - **Description** * - Ego Vehicle - - X - - [Description of ego vehicle data] + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. * - Map - - X - - [Description of ego vehicle data] + - ✓ / (✓) / X + - ..., see :class:`~py123d.api.MapAPI`. * - Bounding Boxes - - X - - [Description of ego vehicle data] + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. * - Traffic Lights - - X - - [Description of ego vehicle data] - * - Cameras - - X - - [Description of ego vehicle data] + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.detections.TrafficLightDetectionWrapper`. + * - Pinhole Cameras + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.sensors.PinholeCamera`. + * - Fisheye Cameras + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.sensors.FisheyeCamera`. * - LiDARs - - X - - [Description of ego vehicle data] + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.sensors.LiDAR`. + + +Download +~~~~~~~~ + +... + +The 123D conversion expects the following directory structure: + +Installation +~~~~~~~~~~~~ -Dataset Specific Issues -~~~~~~~~~~~~~~~~~~~~~~~ +For *Template*, additional installation that are included as optional dependencies in ``py123d`` are required. You can install them via: + +.. code-block:: bash + + pip install py123d[template] + +Or if you are installing from source: + +.. code-block:: bash + + pip install -e .[template] + + +Dataset Specific +~~~~~~~~~~~~~~~~ + +.. dropdown:: Box Detection Labels + + .. autoclass:: py123d.conversion.registry.DefaultBoxDetectionLabel + :members: + :no-inherited-members: + +.. dropdown:: LiDAR Index + + .. autoclass:: py123d.conversion.registry.DefaultLiDARIndex + :members: + :no-inherited-members: + + + +Dataset Issues +~~~~~~~~~~~~~~ [Document any known issues, limitations, or considerations when using this dataset] @@ -72,19 +105,17 @@ Dataset Specific Issues * Issue 2: Description * Issue 3: Description + Citation ~~~~~~~~ -If you use this dataset in your research, please cite: +If you use *Template* in your research, please cite: .. code-block:: bibtex - @article{AuthorYearConference, - title={Dataset Title}, - author={Author, First and Author, Second}, - journal={Journal Name}, - year={2023}, - volume={1}, - pages={1-10}, - doi={10.1000/example} - } + @article{AuthorYearConference, + title={Template: Some Dataset for Autonomous Driving}, + author={}, + booktitle={}, + year={} + } diff --git a/docs/datasets/wodp.rst b/docs/datasets/wodp.rst new file mode 100644 index 00000000..72d15a93 --- /dev/null +++ b/docs/datasets/wodp.rst @@ -0,0 +1,178 @@ +Waymo Open Dataset - Perception +------------------------------- + +The Waymo Open Dataset (WOD) is a collective term for publicly available datasets from Waymo. +The *Perception Dataset*, abbreviated as WOD-P, is a high-quality dataset targeted for perceptions tasks, such as +With 1150 logs each spanning 20 seconds, the dataset includes about 6.4 hours + +.. dropdown:: Overview + :open: + + .. list-table:: + :header-rows: 0 + :widths: 20 60 + + * - + - + * - :octicon:`file` Paper + - `Scalability in Perception for Autonomous Driving: Waymo Open Dataset `_ + * - :octicon:`download` Download + - `waymo.com/open `_ + * - :octicon:`mark-github` Code + - `waymo-open-dataset `_ + * - :octicon:`law` License + - + `Waymo Dataset License Agreement for Non-Commercial Use `_ + + Apache License 2.0 + `Code Specific Licenses `_ + + * - :octicon:`database` Available splits + - ``wodp_train``, ``wodp_val``, ``wodp_test`` + + +Available Modalities +~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 5 75 + + * - **Name** + - **Available** + - **Description** + * - Ego Vehicle + - ✓ + - State of the ego vehicle, including poses, and vehicle parameters, see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. + * - Map + - (✓) + - The HD-Maps are in 3D, but may have artifacts due to polyline to polygon conversion (see below). For more information, see :class:`~py123d.api.MapAPI`. + * - Bounding Boxes + - ✓ + - The bounding boxes are available with the :class:`~py123d.conversion.registry.WOPDBoxDetectionLabel`. For more information, :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. + * - Traffic Lights + - X + - n/a + * - Pinhole Cameras + - ✓ + - + Includes 5 cameras, see :class:`~py123d.datatypes.sensors.PinholeCamera`: + + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_F0` (front_camera) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L0` (front_left_camera) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R0` (front_right_camera) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L1` (left_camera) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R1` (right_camera) + + * - Fisheye Cameras + - X + - n/a + * - LiDARs + - ✓ + - + Includes 5 LiDARs, see :class:`~py123d.datatypes.sensors.LiDAR`: + + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_TOP` (top) + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_FRONT` (front) + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_SIDE_LEFT` (side_left) + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_SIDE_RIGHT` (side_right) + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_BACK` (rear) + +.. dropdown:: Dataset Specific + + + .. autoclass:: py123d.conversion.registry.WOPDBoxDetectionLabel + :members: + :no-inherited-members: + + .. autoclass:: py123d.conversion.registry.WOPDLiDARIndex + :members: + :no-inherited-members: + + + +Download +~~~~~~~~ + +To download the Waymo Open Dataset for Perception, please visit the `official website `_ and follow the instructions provided there. +You will need to register and download the Perception Dataset ``V1.4.3``. +(We currently do not support ``V2.0.1`` due to the missing maps.) +The expected directory structure after downloading and extracting the dataset is as follows: + +.. code-block:: text + + $WODP_DATA_ROOT + ├── testing/ + | ├── segment-10084636266401282188_1120_000_1140_000_with_camera_labels.tfrecord + | ├── ... + | └── segment-9806821842001738961_4460_000_4480_000_with_camera_labels.tfrecord + ├── training/ + | ├── segment-10017090168044687777_6380_000_6400_000_with_camera_labels.tfrecord + | ├── ... + | └── segment-9985243312780923024_3049_720_3069_720_with_camera_labels.tfrecord + └── validation/ + ├── segment-10203656353524179475_7625_000_7645_000_with_camera_labels.tfrecord + ├── ... + └── segment-967082162553397800_5102_900_5122_900_with_camera_labels.tfrecord + +You can add the dataset root directory to the environment variable ``WODP_DATA_ROOT`` for easier access. + +.. code-block:: bash + + export WODP_DATA_ROOT=/path/to/wodp_dataset_root + +Optionally, you can adjust the ``py123d/script/config/common/default_dataset_paths.yaml`` accordingly. + +Installation +~~~~~~~~~~~~ + +The Waymo Open Dataset requires additional dependencies that are included as optional dependencies in ``py123d``. You can install them via: + +.. tab-set:: + + .. tab-item:: PyPI + + .. code-block:: bash + + pip install py123d[waymo] + + .. tab-item:: Source + + .. code-block:: bash + + pip install -e .[waymo] + +These dependencies are notoriously difficult to install due to compatibility issues. +We recommend using a dedicated conda environment for this purpose. Using `uv `_ can significantly speed up the installation. +Here is an example of how to set it up: + +.. code-block:: bash + + conda create -n py123d_waymo python=3.10 + conda activate py123d_waymo + uv pip install -e .[waymo] + # If something goes wrong: conda deactivate; conda remove -n py123d_waymo --all + +You only need the Waymo Open Dataset specific dependencies if you convert the dataset or read from the raw TFRecord files. +After conversion, you may use any other ``py123d`` installation. + + +Dataset Specific Issues +~~~~~~~~~~~~~~~~~~~~~~~ + + +* **Map:** The HD-Map in Waymo has bugs ... + +Citation +~~~~~~~~ + +If you use this dataset in your research, please cite: + +.. code-block:: bibtex + + @inproceedings{Sun2020CVPR, + title={Scalability in perception for autonomous driving: Waymo open dataset}, + author={Sun, Pei and Kretzschmar, Henrik and Dotiwalla, Xerxes and Chouard, Aurelien and Patnaik, Vijaysai and Tsui, Paul and Guo, James and Zhou, Yin and Chai, Yuning and Caine, Benjamin and others}, + booktitle={Proceedings of the IEEE/CVF conference on computer vision and pattern recognition}, + pages={2446--2454}, + year={2020} + } diff --git a/docs/datasets/wopd.rst b/docs/datasets/wopd.rst deleted file mode 100644 index 3b00d2e6..00000000 --- a/docs/datasets/wopd.rst +++ /dev/null @@ -1,99 +0,0 @@ -Waymo Open Perception Dataset (WOPD) ------------------------------------- - -.. sidebar:: WOPD - - .. image:: https://images.ctfassets.net/e6t5diu0txbw/4LpraC18sHNvS87OFnEGKB/63de105d4ce623d91cfdbc23f77d6a37/Open_Dataset_Download_Hero.jpg?fm=webp&q=90 - :alt: Dataset sample image - :width: 290px - - | **Paper:** `Name of Paper `_ - | **Download:** `Documentation `_ - | **Code:** [Code] - | **Documentation:** [License type] - | **License:** [License type] - | **Duration:** [Duration here] - | **Supported Versions:** [Yes/No/Conditions] - | **Redistribution:** [Yes/No/Conditions] - -Description -~~~~~~~~~~~ - -[Provide a detailed description of the dataset here, including its purpose, collection methodology, and key characteristics.] - -Installation -~~~~~~~~~~~~ - -[Instructions for installing or accessing the dataset] - -.. code-block:: bash - - # Example installation commands - pip install py123d[dataset_name] - # or - wget https://example.com/dataset.zip - - -.. code-block:: bash - - conda create -n py123d_waymo python=3.10 - conda activate py123d_waymo - pip install -e .[waymo] - - # pip install protobuf==6.30.2 - # pip install tensorflow==2.13.0 - # pip install waymo-open-dataset-tf-2-12-0==1.6.6 - -Available Data -~~~~~~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - :widths: 30 5 70 - - - * - **Name** - - **Available** - - **Description** - * - Ego Vehicle - - X - - [Description of ego vehicle data] - * - Map - - X - - [Description of ego vehicle data] - * - Bounding Boxes - - X - - [Description of ego vehicle data] - * - Traffic Lights - - X - - [Description of ego vehicle data] - * - Cameras - - X - - [Description of ego vehicle data] - * - LiDARs - - X - - [Description of ego vehicle data] - -Dataset Specific Issues -~~~~~~~~~~~~~~~~~~~~~~~ - -[Document any known issues, limitations, or considerations when using this dataset] - -* Issue 1: Description -* Issue 2: Description -* Issue 3: Description - -Citation -~~~~~~~~ - -If you use this dataset in your research, please cite: - -.. code-block:: bibtex - - @inproceedings{Sun2020CVPR, - title={Scalability in perception for autonomous driving: Waymo open dataset}, - author={Sun, Pei and Kretzschmar, Henrik and Dotiwalla, Xerxes and Chouard, Aurelien and Patnaik, Vijaysai and Tsui, Paul and Guo, James and Zhou, Yin and Chai, Yuning and Caine, Benjamin and others}, - booktitle={Proceedings of the IEEE/CVF conference on computer vision and pattern recognition}, - pages={2446--2454}, - year={2020} - } diff --git a/docs/development/index.rst b/docs/development/index.rst deleted file mode 100644 index 579ccee4..00000000 --- a/docs/development/index.rst +++ /dev/null @@ -1,7 +0,0 @@ -Development -=========== - -.. toctree:: - :maxdepth: 0 - - contributing diff --git a/docs/index.rst b/docs/index.rst index b2f00085..e1604f61 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,18 +1,25 @@ -.. 123d documentation master file, created by - sphinx-quickstart on Wed Aug 13 16:57:48 2025. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - 123D Documentation ================== -Add your content using ``reStructuredText`` syntax. See the -`reStructuredText `_ -documentation for details. +Welcome to the official documentation for 123D, a library for driving datasets in 2D and 3D. + +Features include: + +- Unified API for driving data, including sensor data, maps, and labels. +- Support for multiple sensors storage formats. +- Fast dataformat based on `Apache Arrow `_ +- Visualization tools with `matplotlib `_ and `Viser `_. + + +.. youtube:: Q4q29fpXnx8 + :width: 800 + :height: 450 + :align: center .. toctree:: :maxdepth: 1 + :hidden: :caption: Overview: installation @@ -21,6 +28,7 @@ documentation for details. .. toctree:: :maxdepth: 2 + :hidden: :caption: API Reference: api/scene/index @@ -30,12 +38,7 @@ documentation for details. .. toctree:: :maxdepth: 1 - :caption: Visualization: - - visualization - -.. toctree:: - :maxdepth: 1 - :caption: Development: + :hidden: + :caption: Notes - development/index + notes/contributing diff --git a/docs/installation.md b/docs/installation.md index 39846467..9cca6ac0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,37 +1,55 @@ # Installation -Note, the following installation assumes the following folder structure: TODO UPDATE -``` -~/py123d_workspace -├── py123d -├── exp -│ └── ... -└── data - ├── maps - │ ├── carla_town01.gpkg - │ ├── carla_town02.gpkg - │ ├── ... - │ └── nuplan_us-pa-pittsburgh-hazelwood.gpkg - ├── nuplan_mini_test - │ ├── 2021.05.25.14.16.10_veh-35_01690_02183.arrow - │ ├── 2021.06.03.12.02.06_veh-35_00233_00609.arrow - │ ├── ... - │ └── 2021.10.06.07.26.10_veh-52_00006_00398.arrow - ├── nuplan_mini_train - │ └── ... - └── nuplan_mini_test - └── ... +## Pip-Install +You can simply install `py123d` for Python versions >=3.9 via PyPI with +```bash +pip install py123d ``` - - -First you need to create a new conda environment and install `py123d` as editable pip package. +or as editable pip package with ```bash -conda create -n py123d_dev python=3.12 -conda activate py123d_dev +git clone git@github.com:autonomousvision/py123d.git +cd py123d pip install -e . ``` -Next, you need add the following environment variables in your `.bashrc`: + +## File Structure & Storage +The 123D library converts driving datasets to a unified format. By default, all data is stored in directory of the environment variable `$PY123D_DATA_ROOT`. ```bash export PY123D_DATA_ROOT="$HOME/py123d_workspace/data" ``` +which can be added to your `~/.bashrc` or to your bash scripts. Optionally, you can adjust all dataset paths in the hydra config: `py123d/script/config/common/default_dataset_paths.yaml`. + +The 123D conversion includes: +- **Logs:** The logs store continuous driving recordings in a single file, including modalities such as timestamps, ego states, bounding boxes, and sensor references. Logs are stored as `.arrow` files. +- **Maps:** The maps are static and store our unified HD-Map API. Maps can either be defined per-log (e.g. in AV2, Waymo) or globally for a certain location (e.g. nuPlan, nuScenes, CARLA). In the current implementation, we store maps as `.gpkg` files. +- **Sensors:** There are multiple options to store sensor data. Cameras and LiDAR point clouds can either (1) be read from the original dataset or (2) stored within the log file. For cameras, we also support (3) compression with MP4 files, which are written into the `/sensors` directory. + +For example, when converting `nuplan-mini` with MP4 compression, the file structure should look the following: +``` +$PY123D_DATA_ROOT +├── logs +│ ├── nuplan-mini_test +│ │ ├── 2021.05.25.14.16.10_veh-35_01690_02183.arrow +│ │ ├── ... +│ │ └── 2021.10.06.07.26.10_veh-52_00006_00398.arrow +│ ├── nuplan-mini_train +│ │ └── ... +│ ├── nuplan-mini_train +│ │ └── ... +│ └── ... +├── maps +│ ├── nuplan +│ │ ├── nuplan_sg-one-north.gpkg +│ │ ├── ... +│ │ └── nuplan_us-pa-pittsburgh-hazelwood.gpkg +│ └── ... +└── sensors + ├── nuplan-mini_test + │ ├── 2021.05.25.14.16.10_veh-35_01690_02183 + │ │ ├── pcam_b0.mp4 + │ │ ├── ... + │ │ └── pcam_r2.mp4 + │ └── ... + └── ... +``` diff --git a/docs/development/contributing.md b/docs/notes/contributing.md similarity index 99% rename from docs/development/contributing.md rename to docs/notes/contributing.md index e7ac92fe..2f564162 100644 --- a/docs/development/contributing.md +++ b/docs/notes/contributing.md @@ -1,5 +1,4 @@ - -# Contributing to 123D +# Contributing Contributions to 123D are highly encouraged! This guide will help you get started with the development process. diff --git a/docs/notes/conventions.rst b/docs/notes/conventions.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/visualization.md b/docs/visualization.md deleted file mode 100644 index 9fe2e3cf..00000000 --- a/docs/visualization.md +++ /dev/null @@ -1,11 +0,0 @@ - -# Visualization - - -## Matplotlib - - -## Viser - - -## Bokeh diff --git a/examples/01_viser.py b/examples/01_viser.py index 17ce1fd5..cc150618 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -9,14 +9,15 @@ # splits = ["nuplan-mini_test", "nuplan-mini_train", "nuplan-mini_val"] # splits = ["nuplan_private_test"] # splits = ["carla_test"] - splits = ["wopd_val"] + # splits = ["wopd_val"] # splits = ["av2-sensor_train"] + splits = ["av2-sensor_test", "av2-sensor_train", "av2-sensor_val"] # splits = ["pandaset_test", "pandaset_val", "pandaset_train"] # log_names = ["2021.08.24.13.12.55_veh-45_00386_00472"] # log_names = ["2013_05_28_drive_0000_sync"] # log_names = ["2013_05_28_drive_0000_sync"] log_names = None - splits = None + # splits = None # scene_uuids = ["87bf69e4-f2fb-5491-99fa-8b7e89fb697c"] scene_uuids = None diff --git a/pyproject.toml b/pyproject.toml index 37cd5e70..55684c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,8 @@ docs = [ "furo", "autoclasstoc", "sphinx-autodoc-typehints", + "sphinxcontrib-youtube", + "sphinx-design", ] nuplan = [ "nuplan-devkit @ git+https://github.com/motional/nuplan-devkit/@nuplan-devkit-v1.2", @@ -84,8 +86,7 @@ nuscenes = [ "lanelet2", ] waymo = [ - "tensorflow==2.13.0", - "waymo-open-dataset-tf-2-12-0==1.6.6", + "waymo-open-dataset-tf-2-12-0==1.6.7", ] ffmpeg = [ "imageio[ffmpeg]", diff --git a/scripts/download/download_av2.sh b/scripts/download/download_av2.sh index 039de648..a6e7474c 100644 --- a/scripts/download/download_av2.sh +++ b/scripts/download/download_av2.sh @@ -13,7 +13,3 @@ for DATASET_NAME in "${DATASET_NAMES[@]}"; do mkdir -p "$TARGET_DIR/$DATASET_NAME" s5cmd --no-sign-request cp "s3://argoverse/datasets/av2/$DATASET_NAME/*" "$TARGET_DIR/$DATASET_NAME" done - - -# wget -r s3://argoverse/datasets/av2/sensor/test/0f0cdd79-bc6c-35cd-9d99-7ae2fc7e165c/sensors/cameras/ring_front_center/315965893599927217.jpg -# wget http://argoverse.s3.amazonaws.com/datasets/av2/sensor/test/0f0cdd79-bc6c-35cd-9d99-7ae2fc7e165c/sensors/cameras/ring_front_center/315965893599927217.jpg diff --git a/scripts/download/download_nuplan_logs.sh b/scripts/download/download_nuplan_logs.sh index 5eac6ddb..fa48c71b 100644 --- a/scripts/download/download_nuplan_logs.sh +++ b/scripts/download/download_nuplan_logs.sh @@ -1,10 +1,11 @@ # NOTE: Please check the LICENSE file when downloading the nuPlan dataset wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/LICENSE -# maps +# 1. Maps (required for nuplan and nuplan-mini) wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-maps-v1.1.zip -# train: nuplan_train +# 2. Logs +# 2.1 nuplan_train wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_boston.zip wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_pittsburgh.zip wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_singapore.zip @@ -12,11 +13,38 @@ for split in {1..6}; do wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_vegas_${split}.zip done -# val: nuplan_val +# 2.2 nuplan_val wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_test.zip -# test: nuplan_test +# 2.3 nuplan_test wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_val.zip -# mini: nuplan_mini_train, nuplan_mini_val, nuplan_mini_test +# 2.4 nuplan-mini_train, nuplan-mini_val, nuplan-mini_test wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_mini.zip + + +# 3. Sensor blobs +# 3.1 train: nuplan_train +for split in {0..42}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/train_set/nuplan-v1.1_train_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/train_set/nuplan-v1.1_train_lidar_${split}.zip +done + +# 3.2 val: nuplan_val +for split in {0..11}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/val_set/nuplan-v1.1_val_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/val_set/nuplan-v1.1_val_lidar_${split}.zip +done + +# 3.3 test: nuplan_test +for split in {0..11}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/test_set/nuplan-v1.1_test_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/test_set/nuplan-v1.1_test_lidar_${split}.zip +done + + +# 3.4 mini: nuplan_mini_train, nuplan_mini_val, nuplan_mini_test +for split in {0..8}; do + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_camera_${split}.zip + wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_lidar_${split}.zip +done diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index da51ed62..f9026faa 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -1,4 +1,5 @@ import json +import logging from pathlib import Path from typing import Any, Dict, Final, List @@ -7,6 +8,7 @@ import numpy.typing as npt import shapely import shapely.geometry as geom +from pandas import isna from py123d.conversion.datasets.av2.utils.av2_constants import AV2_ROAD_LINE_TYPE_MAPPING from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter @@ -37,6 +39,9 @@ MAX_ROAD_EDGE_LENGTH: Final[float] = 100.0 +logger = logging.getLogger(__name__) + + def convert_av2_map(source_log_path: Path, map_writer: AbstractMapWriter) -> None: """Converts the AV2 map objects to the 123D objects and writes them using the provided map writer. @@ -50,6 +55,15 @@ def _extract_polyline(data: List[Dict[str, float]], close: bool = False) -> Poly if close: polyline = np.vstack([polyline, polyline[0]]) + # NOTE @DanielDauner: AV2 map can have NaN values in the Z axis. + # In this case we replace NaNs with zeros with the median (or zeros). + if np.isnan(polyline).any(): + median_xyz = np.nanmedian(polyline[:, 2], axis=-1) + logger.warning(f"Found NaN values {source_log_path} polyline data: {polyline}. Replacing NaNs with zeros.") + for i in range(polyline.shape[0]): + if isna(polyline[i, 2]): + polyline[i, 2] = median_xyz if not isna(median_xyz) else 0.0 + return Polyline3D.from_array(polyline) map_folder = source_log_path / "map" @@ -118,7 +132,6 @@ def _get_centerline_from_boundaries( num_points = int(np.ceil(max([right_boundary.length, left_boundary.length]) * points_per_meter)) right_array = right_boundary.interpolate(np.linspace(0, right_boundary.length, num_points, endpoint=True)) left_array = left_boundary.interpolate(np.linspace(0, left_boundary.length, num_points, endpoint=True)) - return Polyline3D.from_array(np.mean([right_array, left_array], axis=0)) for lane_id, lane_dict in lanes.items(): diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 252b4fe9..73fcfe95 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -16,7 +16,7 @@ ) from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.registry import AV2SensorBoxDetectionLabel, AVSensorLiDARIndex +from py123d.conversion.registry import AV2SensorBoxDetectionLabel, AV2SensorLiDARIndex from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.metadata import LogMetadata, MapMetadata from py123d.datatypes.sensors import ( @@ -218,7 +218,7 @@ def _get_av2_lidar_metadata( # top lidar: metadata[LiDARType.LIDAR_TOP] = LiDARMetadata( lidar_type=LiDARType.LIDAR_TOP, - lidar_index=AVSensorLiDARIndex, + lidar_index=AV2SensorLiDARIndex, extrinsic=_row_dict_to_pose_se3( calibration_df[calibration_df["sensor_name"] == "up_lidar"].iloc[0].to_dict() ), @@ -226,7 +226,7 @@ def _get_av2_lidar_metadata( # down lidar: metadata[LiDARType.LIDAR_DOWN] = LiDARMetadata( lidar_type=LiDARType.LIDAR_DOWN, - lidar_index=AVSensorLiDARIndex, + lidar_index=AV2SensorLiDARIndex, extrinsic=_row_dict_to_pose_se3( calibration_df[calibration_df["sensor_name"] == "down_lidar"].iloc[0].to_dict() ), diff --git a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py index 5c9b7a8c..b1d204dd 100644 --- a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py +++ b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py @@ -207,19 +207,19 @@ def fill_lane_boundaries( lane_polyline_se2 = lane_polyline_se2_dict[current_lane_token] # 1. sample poses along centerline - distances_se2 = np.linspace( - 0, lane_polyline_se2.length, int(lane_polyline_se2.length / BOUNDARY_STEP_SIZE) + 1, endpoint=True - ) + num_samples = int(lane_polyline.length / BOUNDARY_STEP_SIZE) + 1 + + distances_se2 = np.linspace(0, lane_polyline_se2.length, num_samples, endpoint=True) lane_queries_se2 = [ PoseSE2.from_array(pose_se2_array) for pose_se2_array in lane_polyline_se2.interpolate(distances_se2) ] - distances_3d = np.linspace( - 0, lane_polyline.length, int(lane_polyline.length / BOUNDARY_STEP_SIZE) + 1, endpoint=True - ) + distances_3d = np.linspace(0, lane_polyline.length, num_samples, endpoint=True) lane_queries_3d = [ Point3D.from_array(point_3d_array) for point_3d_array in lane_polyline.interpolate(distances_3d) ] - assert len(lane_queries_se2) == len(lane_queries_3d) + assert len(lane_queries_se2) == len( + lane_queries_3d + ), f"Number of sampled SE2 poses {len(lane_queries_se2)} and 3D points {len(lane_queries_3d)} must be the same" for sign in [1.0, -1.0]: boundary_points_3d: List[Optional[Point3D]] = [] diff --git a/src/py123d/conversion/log_writer/arrow_log_writer.py b/src/py123d/conversion/log_writer/arrow_log_writer.py index 324aad69..979cfe55 100644 --- a/src/py123d/conversion/log_writer/arrow_log_writer.py +++ b/src/py123d/conversion/log_writer/arrow_log_writer.py @@ -82,6 +82,16 @@ def _store_option_to_arrow_type( return data_type_map[store_option] +def _get_uuid_arrow_type(): + """Gets the appropriate Arrow UUID data type based on pyarrow version.""" + # NOTE @DanielDauner: pyarrow introduced native UUID type in version 18.0.0 + # Easiest option is to require this version or higher, but thanks to the Waymo dataset that's not possible. :( + if pa.__version__ >= "18.0.0": + return pa.uuid() + else: + return pa.binary(16) + + class ArrowLogWriter(AbstractLogWriter): """Log writer for Arrow-based logs. Writes log data to an Arrow IPC file format.""" @@ -371,7 +381,7 @@ def _build_schema(dataset_converter_config: DatasetConverterConfig, log_metadata """ schema_list: List[Tuple[str, pa.DataType]] = [ - (UUID_COLUMN, pa.uuid()), + (UUID_COLUMN, _get_uuid_arrow_type()), (TIMESTAMP_US_COLUMN, pa.int64()), ] diff --git a/src/py123d/conversion/registry/__init__.py b/src/py123d/conversion/registry/__init__.py index ac702849..47cf1160 100644 --- a/src/py123d/conversion/registry/__init__.py +++ b/src/py123d/conversion/registry/__init__.py @@ -11,7 +11,7 @@ ) from py123d.conversion.registry.lidar_index_registry import ( LIDAR_INDEX_REGISTRY, - AVSensorLiDARIndex, + AV2SensorLiDARIndex, CARLALiDARIndex, DefaultLiDARIndex, Kitti360LiDARIndex, diff --git a/src/py123d/conversion/registry/box_detection_label_registry.py b/src/py123d/conversion/registry/box_detection_label_registry.py index 76ac2279..5cbfc98a 100644 --- a/src/py123d/conversion/registry/box_detection_label_registry.py +++ b/src/py123d/conversion/registry/box_detection_label_registry.py @@ -173,26 +173,28 @@ def to_default(self) -> DefaultBoxDetectionLabel: @register_box_detection_label class NuPlanBoxDetectionLabel(BoxDetectionLabel): - """ - Semantic labels for nuPlan bounding box detections. - - Descriptions in `.db` files: - - vehicle: Includes all four or more wheeled vehicles, as well as trailers. - - bicycle: Includes bicycles, motorcycles and tricycles. - - pedestrian: All types of pedestrians, incl. strollers and wheelchairs. - - traffic_cone: Cones that are temporarily placed to control the flow of traffic. - - barrier: Solid barriers that can be either temporary or permanent. - - czone_sign: Temporary signs that indicate construction zones. - - generic_object: Animals, debris, pushable/pullable objects, permanent poles. - """ + """Semantic labels for nuPlan bounding box detections.""" VEHICLE = 0 + """Includes all four or more wheeled vehicles, as well as trailers.""" + BICYCLE = 1 + """Includes bicycles, motorcycles and tricycles.""" + PEDESTRIAN = 2 + """All types of pedestrians, incl. strollers and wheelchairs.""" + TRAFFIC_CONE = 3 + """Cones that are temporarily placed to control the flow of traffic.""" + BARRIER = 4 + """Solid barriers that can be either temporary or permanent.""" + CZONE_SIGN = 5 + """Temporary signs that indicate construction zones.""" + GENERIC_OBJECT = 6 + """Animals, debris, pushable/pullable objects, permanent poles.""" def to_default(self) -> DefaultBoxDetectionLabel: """Inherited, see superclass.""" diff --git a/src/py123d/conversion/registry/lidar_index_registry.py b/src/py123d/conversion/registry/lidar_index_registry.py index d02395dc..2fe26ebb 100644 --- a/src/py123d/conversion/registry/lidar_index_registry.py +++ b/src/py123d/conversion/registry/lidar_index_registry.py @@ -85,7 +85,7 @@ class Kitti360LiDARIndex(LiDARIndex): @register_lidar_index -class AVSensorLiDARIndex(LiDARIndex): +class AV2SensorLiDARIndex(LiDARIndex): """Argoverse 2 Sensor LiDAR Indexing Scheme.""" X = 0 diff --git a/src/py123d/datatypes/detections/box_detections.py b/src/py123d/datatypes/detections/box_detections.py index 98056b9c..c5cad085 100644 --- a/src/py123d/datatypes/detections/box_detections.py +++ b/src/py123d/datatypes/detections/box_detections.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from functools import cached_property from typing import List, Optional, Union @@ -116,7 +115,6 @@ def box_detection_se2(self) -> BoxDetectionSE2: return self -@dataclass class BoxDetectionSE3: """Detected, tracked, and oriented bounding box 3D space.""" diff --git a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml index 06538673..e699fe4d 100644 --- a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml @@ -2,7 +2,7 @@ av2_sensor_dataset: _target_: py123d.conversion.datasets.av2.av2_sensor_converter.AV2SensorConverter _convert_: 'all' - splits: ["av2-sensor_train"] + splits: ["av2-sensor_train", "av2-sensor_val", "av2-sensor_test"] av2_data_root: ${dataset_paths.av2_data_root} dataset_converter_config: diff --git a/tests/unit/datatypes/vehicle_state/test_ego_state.py b/tests/unit/datatypes/vehicle_state/test_ego_state.py index 671505ec..407f6a18 100644 --- a/tests/unit/datatypes/vehicle_state/test_ego_state.py +++ b/tests/unit/datatypes/vehicle_state/test_ego_state.py @@ -262,7 +262,9 @@ def test_box_detection_properties(self): box_det_se2 = ego_state.box_detection_se2 assert box_det_se2 is not None - assert ego_state.box_detection == box_det_se3 + assert box_det_se2.metadata.label == DefaultBoxDetectionLabel.EGO + assert box_det_se2.metadata.track_token == EGO_TRACK_TOKEN + assert box_det_se2.metadata.timepoint == self.timepoint def test_ego_state_se2_projection(self): """Test projection to EgoStateSE2.""" From 2867e73c0e1271f2347837babd18543fea92d13d Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Tue, 18 Nov 2025 16:16:52 +0100 Subject: [PATCH 33/50] Add initial version of PandaSet and CARLA to the documentation. --- docs/datasets/carla.rst | 110 +++++++++ docs/datasets/index.rst | 6 +- docs/datasets/pandaset.rst | 211 ++++++++++++++++++ .../pandaset/utils/pandaset_constants.py | 5 +- 4 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 docs/datasets/pandaset.rst diff --git a/docs/datasets/carla.rst b/docs/datasets/carla.rst index 1de05e5a..3cb1571a 100644 --- a/docs/datasets/carla.rst +++ b/docs/datasets/carla.rst @@ -1,2 +1,112 @@ CARLA ----- + +CARLA is an open-source simulator for autonomous driving research. +As such CARLA data is synthetic and can be generated with varying sensor and environmental conditions. + +.. dropdown:: Quick Links + :open: + + .. list-table:: + :header-rows: 0 + :widths: 40 60 + + * - + - + * - :octicon:`file` Paper + - `CARLA: An Open Urban Driving Simulator `_ + * - :octicon:`globe` Website + - `carla.org/ `_ + * - :octicon:`mark-github` Code + - `github.com/carla-simulator/carla `_ + * - :octicon:`law` License + - MIT License + * - :octicon:`database` Available splits + - n/a + + +Available Modalities +~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 5 70 + + * - **Name** + - **Available** + - **Description** + * - Ego Vehicle + - ✓ + - Depending on the collected dataset. For further information, see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. + * - Map + - ✓ + - We included a conversion method of OpenDRIVE maps. For further information, see :class:`~py123d.api.MapAPI`. + * - Bounding Boxes + - ✓ + - Depending on the collected dataset. For further information, see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. + * - Traffic Lights + - X + - TODO + * - Pinhole Cameras + - ✓ + - Depending on the collected dataset. For further information, see :class:`~py123d.datatypes.sensors.PinholeCamera`. + * - Fisheye Cameras + - X + - n/a + * - LiDARs + - ✓ + - Depending on the collected dataset. For further information, see :class:`~py123d.datatypes.sensors.LiDAR`. + + +Download +~~~~~~~~ + +n/a + +Installation +~~~~~~~~~~~~ + +n/a + +Dataset Specific +~~~~~~~~~~~~~~~~ + +.. dropdown:: Box Detection Labels + + .. autoclass:: py123d.conversion.registry.DefaultBoxDetectionLabel + :members: + :no-index: + :no-inherited-members: + +.. dropdown:: LiDAR Index + + .. autoclass:: py123d.conversion.registry.DefaultLiDARIndex + :members: + :no-index: + :no-inherited-members: + + + +Dataset Issues +~~~~~~~~~~~~~~ + +[Document any known issues, limitations, or considerations when using this dataset] + +* Issue 1: Description +* Issue 2: Description +* Issue 3: Description + + +Citation +~~~~~~~~ + +If you use CARLA in your research, please cite: + +.. code-block:: bibtex + + @article{Dosovitskiy2017CORL, + title = {{CARLA}: {An} Open Urban Driving Simulator}, + author = {Alexey Dosovitskiy and German Ros and Felipe Codevilla and Antonio Lopez and Vladlen Koltun}, + booktitle = {Proceedings of the 1st Annual Conference on Robot Learning}, + year = {2017} + } diff --git a/docs/datasets/index.rst b/docs/datasets/index.rst index 40a404e0..b0e7bba2 100644 --- a/docs/datasets/index.rst +++ b/docs/datasets/index.rst @@ -9,9 +9,9 @@ This section provides comprehensive documentation for various autonomous driving :maxdepth: 1 av2 - nuplan - nuscenes carla kitti-360 + nuplan + nuscenes + pandaset wodp - template diff --git a/docs/datasets/pandaset.rst b/docs/datasets/pandaset.rst new file mode 100644 index 00000000..4041ecca --- /dev/null +++ b/docs/datasets/pandaset.rst @@ -0,0 +1,211 @@ +PandaSet +-------- + +The PandaSet dataset is a multi-modal dataset that includes data from cameras and LiDARs, along with detailed 3D bounding box annotations. +It includes 103 logs of 8 second duration, resulting in about 0.2 hours of data. +PandaSet stands out, due to its no-cost commercial license. + + +.. dropdown:: Overview + :open: + + .. list-table:: + :header-rows: 0 + :widths: 20 60 + + * - + - + * - :octicon:`file` Paper + - `PandaSet: Advanced Sensor Suite Dataset for Autonomous Driving `_ + * - :octicon:`download` Download + - + - `scale.com/open-av-datasets/pandaset `_ (official but discontinued). + - `huggingface.co/datasets/georghess/pandaset `_ (unofficial). + * - :octicon:`mark-github` Code + - `github.com/scaleapi/pandaset-devkit `_ + * - :octicon:`law` License + - + - `CC BY 4.0 `_ + - No-cost commercial license* + - Apache License 2.0 + * - :octicon:`database` Available splits + - n/a + +.. dropdown:: Dataset Terms of Use* + + Dataset Terms of Use + Scale AI, Inc. and Hesai Photonics Technology Co., Ltd and their affiliates (hereinafter "Licensors") strive to enhance public access to and use of data that they collect, annotate, and publish. The data are organized in datasets (the “Datasets”) listed at pandaset.org (the “Website”). The Datasets are collections of data, managed by Licensors and provided in a number of machine-readable formats. Licensors provide any individual or entity (hereinafter You” or “Your”) with access to the Datasets free of charge subject to the terms of this agreement (hereinafter “Dataset Terms”). Use of any data derived from the Datasets, which may appear in any format such as tables, charts, devkit, documentation, or code, is also subject to these Dataset Terms. By downloading any Datasets or using any Datasets, You are agreeing to be bound by the Dataset Terms. If you are downloading any Datasets or using any Datasets for an organization, you are agreeing to these Dataset Terms on behalf of that organization. If you do not have the right to agree to these Dataset Terms, do not download or use the Datasets. + + Licenses + Unless specifically labeled otherwise, these Datasets are provided to You under a Creative Commons Attribution 4.0 International Public License (“CC BY 4.0”), with the additional terms included in these Dataset Terms. The CC BY 4.0 may be accessed at https://creativecommons.org/licenses/by/4.0/. When You download or use the Datasets from the Website or elsewhere, You are agreeing to comply with the terms of CC BY 4.0. Where these Dataset Terms conflict with the terms of CC BY 4.0, these Dataset Terms will control. + + Privacy + Licensors prohibit You from using the Datasets in any manner to identify or invade the privacy of any person whose personally identifiable information or personal data may have been incidentally collected in the creation of this Dataset, even when such use is otherwise legal. An individual with any privacy concerns, including a request to remove your personally identifiable information or personal data from the Dataset, may contact us by sending an e-mail to privacy@scaleapi.com. + + No Publicity Rights + You may not use the name, any trademark, official mark, official emblem, or logo of either Licensor, or any of either Licensor’s other means of promotion or publicity without the applicable Licensor’s prior written consent nor in any event to represent or imply an association or affiliation with a Licensor, except as required to comply with the attribution requirements of the CC BY 4.0 license. + + Termination + Licensors may terminate Your access to all or any part of the Datasets or the Website at any time, with or without cause, with or without notice, effective immediately. All provisions of the Dataset Terms which by their nature should survive termination will survive termination, including, without limitation, warranty disclaimers, indemnity, and limitations of liability. + + Indemnification + You will indemnify and hold Licensors harmless from and against any and all claims, loss, cost, expense, liability, or damage, including, without limitation, all reasonable attorneys’ fees and court costs, arising from (i) Your use or misuse of the Website or the Datasets; (ii) Your access to the Website; (iii) Your violation of the Dataset Terms; or (iv) infringement by You, or any third party using Your account, of any intellectual property or other right of any person or entity. Such losses, costs, expenses, damages, or liabilities will include, without limitation, all actual, general, special, indirect, incidental, and consequential damages. + + Dispute Resolution + These Dataset Terms will be governed by and interpreted in accordance with the laws of California (excluding the conflict of laws rules thereof). All disputes under these Dataset Terms will be resolved in the applicable state or federal courts of San Francisco, California. You consent to the jurisdiction of such courts and waive any jurisdictional or venue defenses otherwise available. + + Miscellaneous + You agree that it is Your responsibility to comply with all applicable laws with respect to Your use and publication of the Datasets or derivatives thereof, including any applicable privacy, data protection, security, and export control laws. These Dataset Terms constitute the entire agreement between You and Licensors with respect to the subject matter of these Dataset Terms and supersedes any prior or contemporaneous agreements whether written or oral. If a court of competent jurisdiction finds any term of these Dataset Terms to be unenforceable, the unenforceable term will be modified to reflect the parties’ intention and only to the extent necessary to make the term enforceable. The remaining provisions of these Dataset Terms will remain in effect. You may not assign these Dataset Terms without the prior written consent of the Licensors. The Licensors may assign, transfer, or delegate any of their rights and obligations under these Dataset Terms without consent. The parties are independent contractors. No failure or delay by either party in exercising a right under these Dataset Terms will constitute a waiver of that right. A waiver of a default is not a waiver of any subsequent default. These Dataset Terms may be amended by the Licensors from time to time in our discretion. If an update affects your use of the Dataset, Licensors will notify you before the updated terms are effective for your use. + + +Available Modalities +~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 30 5 70 + + * - **Name** + - **Available** + - **Description** + * - Ego Vehicle + - ✓ + - The poses and vehicle parameters are provided or inferred from the documentation, see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. + * - Map + - X + - n/a + * - Bounding Boxes + - ✓ + - Bounding boxes are available with the :class:`~py123d.conversion.registry.PandasetBoxDetectionLabel`. For more information, see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. + * - Traffic Lights + - X + - n/a + * - Pinhole Cameras + - ✓ + - + Pandaset has 6x :class:`~py123d.datatypes.sensors.PinholeCamera`: + + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_F0`: front_camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L0`: front_left_camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R0`: front_right_camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_L1`: left_camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_R1`: right_camera + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_B0`: back_camera + + * - Fisheye Cameras + - X + - n/a + * - LiDARs + - ✓ + - + Pandaset has 2x :class:`~py123d.datatypes.sensors.LiDAR`: + + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_TOP`: main_pandar64 + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_FRONT`: front_gt + + +.. dropdown:: Dataset Specific + + .. autoclass:: py123d.conversion.registry.PandasetBoxDetectionLabel + :members: + :no-index: + :no-inherited-members: + + .. autoclass:: py123d.conversion.registry.PandasetLiDARIndex + :members: + :no-index: + :no-inherited-members: + + +Download +~~~~~~~~ + +Since a few years, the official PandaSet dataset download no longer available (see `Issue #151 `_). +However, unofficial copies of the dataset can be found on `Hugging Face `_ or `Kaggle `_. + +The 123D conversion expects the following directory structure: + +.. code-block:: text + + $PANDASET_DATA_ROOT/ + ├── 001/ + │ ├── annotations/ + │ │ ├── cuboids/ + │ │ │ ├── 00.pkl.gz + │ │ │ ├── ... + │ │ │ └── 79.pkl.gz + │ │ └── semseg/ (currently not used) + │ │ ├── 00.pkl.gz + │ │ ├── ... + │ │ ├── 79.pkl.gz + │ │ └── classes.json + │ ├── camera/ + │ │ ├── back_camera/ + │ │ │ ├── 00.jpg + │ │ │ ├── ... + │ │ │ ├── 79.jpg + │ │ │ ├── intrinsics.json + │ │ │ ├── poses.json + │ │ │ └── timestamps.json + │ │ ├── front_camera/ + │ │ │ └── ... + │ │ ├── front_left_camera/ + │ │ │ └── ... + │ │ ├── front_right_camera/ + │ │ │ └── ... + │ │ ├── left_camera/ + │ │ │ └── ... + │ │ └── right_camera/ + │ │ └── ... + │ ├── LICENSE.txt + │ ├── lidar/ + │ │ ├── 00.pkl.gz + │ │ ├── ... + │ │ ├── 79.pkl.gz + │ │ ├── poses.json + │ │ └── timestamps.json + │ └── meta/ + │ ├── gps.json + │ └── timestamps.json + ├── ... + └── 158/ + └── ... + + + +Installation +~~~~~~~~~~~~ + +No additional installation steps are required beyond the standard ``py123d`` installation. + + +Conversion +~~~~~~~~~~~~ + +You can convert the PandaSet by running: + +.. code-block:: bash + + py123d-conversion datasets=["pandaset_dataset"] + + +Dataset Issues +~~~~~~~~~~~~~~ + +* **Ego Vehicle:** The ego vehicle parameters are estimates from the vehicle model. The exact location of the IMU/GPS sensor and the bounding box dimensions of the ego vehicle may not be accurate. +* **Bounding Boxes:** PandaSet provides bounding boxes that fall in the overlap of the LiDAR region twice (for each point cloud). The current implementation only uses the bounding boxes of the top LiDAR sensor. +* **LiDAR:** PandaSet does not motion compensate the LiDAR sweeps (in contrast to other datasets). Artifacts remain visible. + +Citation +~~~~~~~~ + +If you use PandaSet in your research, please cite: + +.. code-block:: bibtex + + @article{Xiao2021ITSC, + title={Pandaset: Advanced sensor suite dataset for autonomous driving}, + author={Xiao, Pengchuan and Shao, Zhenlei and Hao, Steven and Zhang, Zishuo and Chai, Xiaolin and Jiao, Judy and Li, Zesong and Wu, Jian and Sun, Kai and Jiang, Kun and others}, + booktitle={2021 IEEE international intelligent transportation systems conference (ITSC)}, + year={2021}, + } diff --git a/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py b/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py index bda3efd1..52bb8d16 100644 --- a/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py +++ b/src/py123d/conversion/datasets/pandaset/utils/pandaset_constants.py @@ -16,7 +16,10 @@ "right_camera": PinholeCameraType.PCAM_R1, } -PANDASET_LIDAR_MAPPING: Dict[str, LiDARType] = {"main_pandar64": LiDARType.LIDAR_TOP, "front_gt": LiDARType.LIDAR_FRONT} +PANDASET_LIDAR_MAPPING: Dict[str, LiDARType] = { + "main_pandar64": LiDARType.LIDAR_TOP, + "front_gt": LiDARType.LIDAR_FRONT, +} PANDASET_BOX_DETECTION_FROM_STR: Dict[str, PandasetBoxDetectionLabel] = { From 81b302fa31a79d76636bf191cb27af36d9e659b0 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Tue, 18 Nov 2025 21:15:02 +0100 Subject: [PATCH 34/50] Add some basic information to KITTI-360 docs. --- docs/datasets/kitti-360.rst | 146 ++++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 48 deletions(-) diff --git a/docs/datasets/kitti-360.rst b/docs/datasets/kitti-360.rst index 43db60b4..3322df23 100644 --- a/docs/datasets/kitti-360.rst +++ b/docs/datasets/kitti-360.rst @@ -1,70 +1,104 @@ KITTI-360 --------- -.. sidebar:: Dataset Name +.. dropdown:: Quick Links + :open: + + .. list-table:: + :header-rows: 0 + :widths: 20 60 + + * - + - + * - :octicon:`file` Paper + - `KITTI-360: A Novel Dataset and Benchmarks for Urban Scene Understanding in 2D and 3D `_ + * - :octicon:`download` Download + - `cvlibs.net/datasets/kitti-360 `_ + * - :octicon:`mark-github` Code + - `github.com/autonomousvision/kitti360scripts `_ + * - :octicon:`law` License + - + - `CC BY-NC-SA 3.0 `_ + - MIT License + * - :octicon:`database` Available splits + - n/a + + +Available Modalities +~~~~~~~~~~~~~~~~~~~~ - .. image:: https://www.cvlibs.net/datasets/kitti-360/images/example/3d/semantic/02400.jpg - :alt: Dataset sample image - :width: 290px +.. list-table:: + :header-rows: 1 + :widths: 30 5 70 - | **Paper:** `KITTI-360: A Novel Dataset and Benchmarks for Urban Scene Understanding in 2D and 3D `_ - | **Download:** `www.cvlibs.net/datasets/kitti-360 `_ - | **Code:** `www.github.com/autonomousvision/kitti360Scripts `_ - | **Documentation:** `kitti-360 Document`_ - | **License:** [License type] - | **Duration:** 320k image - | **Supported Versions:** [Yes/No/Conditions] - | **Redistribution:** [Yes/No/Conditions] + * - **Name** + - **Available** + - **Description** + * - Ego Vehicle + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. + * - Map + - ✓ / (✓) / X + - ..., see :class:`~py123d.api.MapAPI`. + * - Bounding Boxes + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. + * - Traffic Lights + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.detections.TrafficLightDetectionWrapper`. + * - Pinhole Cameras + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.sensors.PinholeCamera`. + * - Fisheye Cameras + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.sensors.FisheyeCamera`. + * - LiDARs + - ✓ / (✓) / X + - ..., see :class:`~py123d.datatypes.sensors.LiDAR`. -Description -~~~~~~~~~~~ -[Provide a detailed description of the dataset here, including its purpose, collection methodology, and key characteristics.] +Download +~~~~~~~~ + +... + +The 123D conversion expects the following directory structure: Installation ~~~~~~~~~~~~ -[Instructions for installing or accessing the dataset] +For *Template*, additional installation that are included as optional dependencies in ``py123d`` are required. You can install them via: .. code-block:: bash - # Example installation commands - pip install py123d[dataset_name] - # or - wget https://example.com/dataset.zip + pip install py123d[template] -Available Data -~~~~~~~~~~~~~~ +Or if you are installing from source: -.. list-table:: - :header-rows: 1 - :widths: 30 5 70 +.. code-block:: bash + + pip install -e .[template] + + +Dataset Specific +~~~~~~~~~~~~~~~~ + +.. dropdown:: Box Detection Labels + + .. autoclass:: py123d.conversion.registry.DefaultBoxDetectionLabel + :members: + :no-inherited-members: + +.. dropdown:: LiDAR Index + + .. autoclass:: py123d.conversion.registry.DefaultLiDARIndex + :members: + :no-inherited-members: - * - **Name** - - **Available** - - **Description** - * - Ego Vehicle - - X - - [Description of ego vehicle data] - * - Map - - X - - [Description of ego vehicle data] - * - Bounding Boxes - - X - - [Description of ego vehicle data] - * - Traffic Lights - - X - - [Description of ego vehicle data] - * - Cameras - - X - - [Description of ego vehicle data] - * - LiDARs - - X - - [Description of ego vehicle data] -Dataset Specific Issues -~~~~~~~~~~~~~~~~~~~~~~~ +Dataset Issues +~~~~~~~~~~~~~~ [Document any known issues, limitations, or considerations when using this dataset] @@ -72,6 +106,22 @@ Dataset Specific Issues * Issue 2: Description * Issue 3: Description + +Citation +~~~~~~~~ + +If you use *Template* in your research, please cite: + +.. code-block:: bibtex + + @article{AuthorYearConference, + title={Template: Some Dataset for Autonomous Driving}, + author={}, + booktitle={}, + year={} + } + + Citation ~~~~~~~~ From 33e299a373a627b8eeaa82ec6758985bef120083 Mon Sep 17 00:00:00 2001 From: DanielDauner Date: Thu, 20 Nov 2025 10:22:02 +0100 Subject: [PATCH 35/50] Sync some code for fixing memory consumption of nuScenes --- docs/datasets/nuscenes.rst | 14 ++++++++++- examples/01_viser.py | 6 ++--- scripts/conversion/nuscenes_conversion.sh | 3 +++ .../datasets/nuscenes/nuscenes_converter.py | 25 +++++++++++++------ .../nuscenes/utils/nuscenes_constants.py | 10 +++++++- .../datasets/nuscenes_mini_dataset.yaml | 2 +- 6 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 scripts/conversion/nuscenes_conversion.sh diff --git a/docs/datasets/nuscenes.rst b/docs/datasets/nuscenes.rst index f00b871c..5264901b 100644 --- a/docs/datasets/nuscenes.rst +++ b/docs/datasets/nuscenes.rst @@ -88,7 +88,19 @@ Available Modalities Download ~~~~~~~~ -You need to install the nuScenes dataset from the `official website `_. +You need to download the nuScenes dataset from the `official website `_. +From there, you need the following parts: + +* CAN bus expansion pack +* Map expansion pack (v1.3) +* Full dataset (v1.0) + + * Mini dataset (v1.0-mini) (for quick testing) + * Train/Val split (v1.0-trainval) (for the complete dataset) + * Test split (v1.0-test) (for the complete dataset) + + + The 123D conversion expects the following directory structure: .. code-block:: none diff --git a/examples/01_viser.py b/examples/01_viser.py index cc150618..c5da7ad1 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -10,15 +10,13 @@ # splits = ["nuplan_private_test"] # splits = ["carla_test"] # splits = ["wopd_val"] - # splits = ["av2-sensor_train"] - splits = ["av2-sensor_test", "av2-sensor_train", "av2-sensor_val"] + splits = ["nuscenes_train"] + # splits = ["av2-sensor_test", "av2-sensor_train", "av2-sensor_val"] # splits = ["pandaset_test", "pandaset_val", "pandaset_train"] # log_names = ["2021.08.24.13.12.55_veh-45_00386_00472"] # log_names = ["2013_05_28_drive_0000_sync"] # log_names = ["2013_05_28_drive_0000_sync"] log_names = None - # splits = None - # scene_uuids = ["87bf69e4-f2fb-5491-99fa-8b7e89fb697c"] scene_uuids = None scene_filter = SceneFilter( diff --git a/scripts/conversion/nuscenes_conversion.sh b/scripts/conversion/nuscenes_conversion.sh new file mode 100644 index 00000000..ff4f51d3 --- /dev/null +++ b/scripts/conversion/nuscenes_conversion.sh @@ -0,0 +1,3 @@ +export NUSCENES_DATA_ROOT="/mnt/elements_1/nuscenes" + +py123d-conversion datasets=["nuscenes_mini_dataset"] diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 72865d85..912ff075 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -1,4 +1,5 @@ import gc +from functools import lru_cache from pathlib import Path from typing import Any, Dict, List, Union @@ -12,6 +13,7 @@ from py123d.conversion.datasets.nuscenes.utils.nuscenes_constants import ( NUSCENES_CAMERA_TYPES, NUSCENES_DATA_SPLITS, + NUSCENES_DATABASE_VERSION_MAPPING, NUSCENES_DETECTION_NAME_DICT, NUSCENES_DT, ) @@ -41,6 +43,12 @@ from nuscenes.utils.splits import create_splits_scenes +@lru_cache(maxsize=3) +def _get_nuscenes_database(version: str, dataroot: str) -> NuScenes: + """Creates a NuScenes database instance.""" + return NuScenes(version=version, dataroot=str(dataroot), verbose=False) + + class NuScenesConverter(AbstractDatasetConverter): """Dataset converter for the nuScenes dataset.""" @@ -52,7 +60,6 @@ def __init__( nuscenes_lanelet2_root: Union[Path, str], use_lanelet2: bool, dataset_converter_config: DatasetConverterConfig, - version: str = "v1.0-mini", ) -> None: """Initializes the :class:`NuScenesConverter`. @@ -62,7 +69,6 @@ def __init__( :param nuscenes_lanelet2_root: Path to the root directory of the nuScenes Lanelet2 data :param use_lanelet2: Whether to use Lanelet2 data for map conversion :param dataset_converter_config: Configuration for the dataset converter - :param version: Version of the nuScenes dataset, defaults to "v1.0-mini" """ super().__init__(dataset_converter_config) @@ -80,15 +86,15 @@ def __init__( self._nuscenes_lanelet2_root: Path = Path(nuscenes_lanelet2_root) self._use_lanelet2 = use_lanelet2 - self._version = version + self._nuscenes_dbs: Dict[str, NuScenes] = {} self._scene_tokens_per_split: Dict[str, List[str]] = self._collect_scene_tokens() def _collect_scene_tokens(self) -> Dict[str, List[str]]: """Collects scene tokens for the specified splits.""" scene_tokens_per_split: Dict[str, List[str]] = {} - nusc = NuScenes(version=self._version, dataroot=str(self._nuscenes_data_root), verbose=False) + # Conversion from nuScenes internal split names to our split names nuscenes_split_name_mapping = { "nuscenes_train": "train", "nuscenes_val": "val", @@ -97,16 +103,21 @@ def _collect_scene_tokens(self) -> Dict[str, List[str]]: "nuscenes-mini_val": "mini_val", } + # Loads the mapping from split names to scene names in nuScenes scene_splits = create_splits_scenes() - available_scenes = [scene for scene in nusc.scene] + # Iterate over split names, for split in self._splits: + database_version = NUSCENES_DATABASE_VERSION_MAPPING[split] + nusc = _get_nuscenes_database(version=database_version, dataroot=str(self._nuscenes_data_root)) + available_scenes = [scene for scene in nusc.scene] nuscenes_split = nuscenes_split_name_mapping[split] scene_names = scene_splits.get(nuscenes_split, []) # get token scene_tokens = [scene["token"] for scene in available_scenes if scene["name"] in scene_names] scene_tokens_per_split[split] = scene_tokens + self._nuscenes_dbs[database_version] = nusc return scene_tokens_per_split @@ -148,7 +159,8 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: split, scene_token = all_scene_tokens[log_index] - nusc = NuScenes(version=self._version, dataroot=str(self._nuscenes_data_root), verbose=False) + database_version = NUSCENES_DATABASE_VERSION_MAPPING[split] + nusc = self._nuscenes_dbs[database_version] scene = nusc.get("scene", scene_token) log_record = nusc.get("log", scene["log_token"]) @@ -398,7 +410,6 @@ def _extract_nuscenes_cameras( cam_path = nuscenes_data_root / str(cam_data["filename"]) if cam_path.exists() and cam_path.is_file(): - # camera_dict[camera_type] = (camera_data, extrinsic) camera_data_list.append( CameraData( camera_type=camera_type, diff --git a/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_constants.py b/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_constants.py index 5cafb870..cd89b6ea 100644 --- a/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_constants.py +++ b/src/py123d/conversion/datasets/nuscenes/utils/nuscenes_constants.py @@ -1,4 +1,4 @@ -from typing import Final, List +from typing import Dict, Final, List from py123d.conversion.registry.box_detection_label_registry import NuScenesBoxDetectionLabel from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType @@ -48,6 +48,14 @@ } +NUSCENES_DATABASE_VERSION_MAPPING: Dict[str, str] = { + "nuscenes_train": "v1.0-trainval", + "nuscenes_val": "v1.0-trainval", + "nuscenes_test": "v1.0-test", + "nuscenes-mini_train": "v1.0-mini", + "nuscenes-mini_val": "v1.0-mini", +} + NUSCENES_CAMERA_TYPES = { PinholeCameraType.PCAM_F0: "CAM_FRONT", PinholeCameraType.PCAM_B0: "CAM_BACK", diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml index 52150a32..8965eab1 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml @@ -1,4 +1,4 @@ -nuscenes_dataset: +nuscenes_mini_dataset: _target_: py123d.conversion.datasets.nuscenes.nuscenes_converter.NuScenesConverter _convert_: 'all' From 1b8ce0262722d7dab0ba3fbe9fbc478af8a03b4b Mon Sep 17 00:00:00 2001 From: DanielDauner Date: Thu, 20 Nov 2025 14:52:30 +0100 Subject: [PATCH 36/50] Fix some stuff of nuscenes and argoverse 2. --- docs/datasets/av2.rst | 1 + examples/01_viser.py | 7 +- notebooks/01_scene_tutorial.ipynb | 15 +++-- .../datasets/av2/av2_map_conversion.py | 59 ++++++++++------- .../datasets/av2/av2_sensor_converter.py | 4 +- .../datasets/nuscenes/nuscenes_converter.py | 66 +++++++++++++------ .../conversion/map_writer/gpkg_map_writer.py | 3 + .../worker/single_machine_thread_pool.yaml | 4 +- .../datasets/av2_sensor_dataset.yaml | 7 +- .../conversion/datasets/kitti360_dataset.yaml | 6 +- .../conversion/datasets/nuplan_dataset.yaml | 4 +- .../datasets/nuplan_mini_dataset.yaml | 5 +- .../conversion/datasets/nuscenes_dataset.yaml | 4 +- .../datasets/nuscenes_mini_dataset.yaml | 4 +- .../conversion/datasets/pandaset_dataset.yaml | 4 +- .../conversion/datasets/wopd_dataset.yaml | 4 +- .../config/conversion/default_conversion.yaml | 6 +- src/py123d/script/run_conversion.py | 39 ++++++----- 18 files changed, 153 insertions(+), 89 deletions(-) diff --git a/docs/datasets/av2.rst b/docs/datasets/av2.rst index af1567d3..5773e0d8 100644 --- a/docs/datasets/av2.rst +++ b/docs/datasets/av2.rst @@ -115,6 +115,7 @@ Next, you can run the following bash script to download the dataset: mkdir -p "$AV2_SENSOR_ROOT" s5cmd --no-sign-request cp "s3://argoverse/datasets/av2/$DATASET_NAME/*" "$AV2_SENSOR_ROOT" + # or: s5cmd --no-sign-request sync "s3://argoverse/datasets/av2/$DATASET_NAME/*" "$AV2_SENSOR_ROOT" The downloaded dataset should have the following structure: diff --git a/examples/01_viser.py b/examples/01_viser.py index c5da7ad1..408a7069 100644 --- a/examples/01_viser.py +++ b/examples/01_viser.py @@ -10,8 +10,9 @@ # splits = ["nuplan_private_test"] # splits = ["carla_test"] # splits = ["wopd_val"] - splits = ["nuscenes_train"] + # splits = ["nuscenes_train"] # splits = ["av2-sensor_test", "av2-sensor_train", "av2-sensor_val"] + splits = ["av2-sensor_val"] # splits = ["pandaset_test", "pandaset_val", "pandaset_train"] # log_names = ["2021.08.24.13.12.55_veh-45_00386_00472"] # log_names = ["2013_05_28_drive_0000_sync"] @@ -23,9 +24,9 @@ split_names=splits, log_names=log_names, scene_uuids=scene_uuids, - duration_s=5.0, + duration_s=15.0, history_s=0.0, - timestamp_threshold_s=5.0, + timestamp_threshold_s=15.0, shuffle=True, # pinhole_camera_types=[PinholeCameraType.PCAM_F0], ) diff --git a/notebooks/01_scene_tutorial.ipynb b/notebooks/01_scene_tutorial.ipynb index ff3385b6..e5fc4357 100644 --- a/notebooks/01_scene_tutorial.ipynb +++ b/notebooks/01_scene_tutorial.ipynb @@ -29,7 +29,12 @@ "id": "2", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "# set environment variable\n", + "import os\n", + "\n", + "os.environ[\"AV2_DATA_ROOT\"] = \"/mnt/nvme2/av2\"" + ] }, { "cell_type": "markdown", @@ -58,10 +63,10 @@ "\n", "# splits = [\"kitti360_train\"]\n", "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", - "splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", + "# splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", "# splits = [\"carla_test\"]\n", "# splits = [\"wopd_val\"]\n", - "# splits = [\"av2-sensor_train\"]\n", + "splits = [\"av2-sensor_train\"]\n", "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", "\n", "splits = None\n", @@ -72,9 +77,9 @@ " split_names=splits,\n", " log_names=log_names,\n", " scene_uuids=scene_uuids,\n", - " duration_s=5.0,\n", + " duration_s=15.0,\n", " history_s=0.0,\n", - " timestamp_threshold_s=5.0,\n", + " timestamp_threshold_s=15.0,\n", " shuffle=True,\n", " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", ")\n", diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index f9026faa..504ffb63 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -406,15 +406,27 @@ def _interpolate_z_on_segment(point: shapely.Point, segment_coords: npt.NDArray[ lane_group_intersection_dict[lane_group_id] = lane_group # 2. Merge polygons of lane groups that are marked as intersections. - lane_group_intersection_geometry = { - lane_group_id: shapely.Polygon(lane_group["outline"].array[:, Point3DIndex.XY]) - for lane_group_id, lane_group in lane_group_intersection_dict.items() - } + # lane_group_intersection_geometry = { + # lane_group_id: shapely.Polygon(lane_group["outline"].array[:, Point3DIndex.XY]) + # for lane_group_id, lane_group in lane_group_intersection_dict.items() + # } + lane_group_intersection_geometry = {} + for lane_group_id, lane_group in lane_group_intersection_dict.items(): + lane_group_polygon_2d = shapely.Polygon(lane_group["outline"].array[:, Point3DIndex.XY]) + if lane_group_polygon_2d.is_valid: + lane_group_intersection_geometry[lane_group_id] = lane_group_polygon_2d + intersection_polygons = gpd.GeoSeries(lane_group_intersection_geometry).union_all() # 3. Collect all intersection polygons and their lane group IDs. + geometries = [] + if isinstance(intersection_polygons, geom.Polygon): + geometries.append(intersection_polygons) + elif isinstance(intersection_polygons, geom.MultiPolygon): + geometries.extend(intersection_polygons.geoms) + intersection_dict = {} - for intersection_idx, intersection_polygon in enumerate(intersection_polygons.geoms): + for intersection_idx, intersection_polygon in enumerate(geometries): if intersection_polygon.is_empty: continue lane_group_ids = [ @@ -438,23 +450,24 @@ def _interpolate_z_on_segment(point: shapely.Point, segment_coords: npt.NDArray[ segment_coords_boundary = np.concatenate([coords[:-1], coords[1:]], axis=1) boundary_segments.append(segment_coords_boundary) - boundary_segments = np.concatenate(boundary_segments, axis=0) - boundary_segment_linestrings = shapely.creation.linestrings(boundary_segments) - occupancy_map = OccupancyMap2D(boundary_segment_linestrings) - - for intersection_id, intersection_data in intersection_dict.items(): - points_2d = intersection_data["outline_2d"].array - points_3d = np.zeros((len(points_2d), 3), dtype=np.float64) - points_3d[:, :2] = points_2d - - query_points = shapely.creation.points(points_2d) - results = occupancy_map.query_nearest(query_points, max_distance=max_distance, exclusive=True) - for query_idx, geometry_idx in zip(*results): - query_point = query_points[query_idx] - segment_coords = boundary_segments[geometry_idx] - best_z = _interpolate_z_on_segment(query_point, segment_coords) - points_3d[query_idx, 2] = best_z - - intersection_dict[intersection_id]["outline_3d"] = Polyline3D.from_array(points_3d) + if len(boundary_segments) >= 1: + boundary_segments = np.concatenate(boundary_segments, axis=0) + boundary_segment_linestrings = shapely.creation.linestrings(boundary_segments) + occupancy_map = OccupancyMap2D(boundary_segment_linestrings) + + for intersection_id, intersection_data in intersection_dict.items(): + points_2d = intersection_data["outline_2d"].array + points_3d = np.zeros((len(points_2d), 3), dtype=np.float64) + points_3d[:, :2] = points_2d + + query_points = shapely.creation.points(points_2d) + results = occupancy_map.query_nearest(query_points, max_distance=max_distance, exclusive=True) + for query_idx, geometry_idx in zip(*results): + query_point = query_points[query_idx] + segment_coords = boundary_segments[geometry_idx] + best_z = _interpolate_z_on_segment(query_point, segment_coords) + points_3d[query_idx, 2] = best_z + + intersection_dict[intersection_id]["outline_3d"] = Polyline3D.from_array(points_3d) return intersection_dict diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 73fcfe95..e05277a2 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -167,8 +167,10 @@ def _get_av2_sensor_map_metadata(split: str, source_log_path: Path) -> MapMetada """Helper to get map metadata for AV2 sensor dataset.""" # NOTE: We need to get the city name from the map folder. # see: https://github.com/argoverse/av2-api/blob/main/src/av2/datasets/sensor/av2_sensor_dataloader.py#L163 + map_folder = source_log_path / "map" - log_map_archive_path = next(map_folder.glob("log_map_archive_*.json")) + log_map_archive_path = next(map_folder.glob("log_map_archive_*.json"), None) + assert log_map_archive_path is not None, f"Log map archive file not found in {map_folder}." location = log_map_archive_path.name.split("____")[1].split("_")[0] return MapMetadata( dataset="av2-sensor", diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index 912ff075..c13bc83c 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -1,7 +1,6 @@ import gc -from functools import lru_cache from pathlib import Path -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, Union import numpy as np from pyquaternion import Quaternion @@ -9,7 +8,10 @@ from py123d.common.utils.dependencies import check_dependencies from py123d.conversion.abstract_dataset_converter import AbstractDatasetConverter from py123d.conversion.dataset_converter_config import DatasetConverterConfig -from py123d.conversion.datasets.nuscenes.nuscenes_map_conversion import NUSCENES_MAPS, write_nuscenes_map +from py123d.conversion.datasets.nuscenes.nuscenes_map_conversion import ( + NUSCENES_MAPS, + write_nuscenes_map, +) from py123d.conversion.datasets.nuscenes.utils.nuscenes_constants import ( NUSCENES_CAMERA_TYPES, NUSCENES_DATA_SPLITS, @@ -17,11 +19,21 @@ NUSCENES_DETECTION_NAME_DICT, NUSCENES_DT, ) -from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData +from py123d.conversion.log_writer.abstract_log_writer import ( + AbstractLogWriter, + CameraData, + LiDARData, +) from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.registry.box_detection_label_registry import NuScenesBoxDetectionLabel +from py123d.conversion.registry.box_detection_label_registry import ( + NuScenesBoxDetectionLabel, +) from py123d.conversion.registry.lidar_index_registry import NuScenesLiDARIndex -from py123d.datatypes.detections.box_detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper +from py123d.datatypes.detections.box_detections import ( + BoxDetectionMetadata, + BoxDetectionSE3, + BoxDetectionWrapper, +) from py123d.datatypes.metadata import LogMetadata, MapMetadata from py123d.datatypes.sensors.lidar import LiDARMetadata, LiDARType from py123d.datatypes.sensors.pinhole_camera import ( @@ -32,7 +44,9 @@ ) from py123d.datatypes.time.time_point import TimePoint from py123d.datatypes.vehicle_state.ego_state import DynamicStateSE3, EgoStateSE3 -from py123d.datatypes.vehicle_state.vehicle_parameters import get_nuscenes_renault_zoe_parameters +from py123d.datatypes.vehicle_state.vehicle_parameters import ( + get_nuscenes_renault_zoe_parameters, +) from py123d.geometry import BoundingBoxSE3, PoseSE3 from py123d.geometry.vector import Vector3D @@ -43,12 +57,6 @@ from nuscenes.utils.splits import create_splits_scenes -@lru_cache(maxsize=3) -def _get_nuscenes_database(version: str, dataroot: str) -> NuScenes: - """Creates a NuScenes database instance.""" - return NuScenes(version=version, dataroot=str(dataroot), verbose=False) - - class NuScenesConverter(AbstractDatasetConverter): """Dataset converter for the nuScenes dataset.""" @@ -60,6 +68,7 @@ def __init__( nuscenes_lanelet2_root: Union[Path, str], use_lanelet2: bool, dataset_converter_config: DatasetConverterConfig, + nuscenes_dbs: Optional[Dict[str, NuScenes]] = None, ) -> None: """Initializes the :class:`NuScenesConverter`. @@ -84,16 +93,29 @@ def __init__( self._nuscenes_data_root: Path = Path(nuscenes_data_root) self._nuscenes_map_root: Path = Path(nuscenes_map_root) self._nuscenes_lanelet2_root: Path = Path(nuscenes_lanelet2_root) - self._use_lanelet2 = use_lanelet2 - self._nuscenes_dbs: Dict[str, NuScenes] = {} + + self._nuscenes_dbs: Dict[str, NuScenes] = nuscenes_dbs if nuscenes_dbs is not None else {} self._scene_tokens_per_split: Dict[str, List[str]] = self._collect_scene_tokens() + def __reduce__(self): + return ( + self.__class__, + ( + self._splits, + self._nuscenes_data_root, + self._nuscenes_map_root, + self._nuscenes_lanelet2_root, + self._use_lanelet2, + self.dataset_converter_config, + self._nuscenes_dbs, + ), + ) + def _collect_scene_tokens(self) -> Dict[str, List[str]]: """Collects scene tokens for the specified splits.""" scene_tokens_per_split: Dict[str, List[str]] = {} - # Conversion from nuScenes internal split names to our split names nuscenes_split_name_mapping = { "nuscenes_train": "train", @@ -109,7 +131,15 @@ def _collect_scene_tokens(self) -> Dict[str, List[str]]: # Iterate over split names, for split in self._splits: database_version = NUSCENES_DATABASE_VERSION_MAPPING[split] - nusc = _get_nuscenes_database(version=database_version, dataroot=str(self._nuscenes_data_root)) + nusc = self._nuscenes_dbs.get(database_version) + if nusc is None: + nusc = NuScenes( + version=database_version, + dataroot=str(self._nuscenes_data_root), + verbose=False, + ) + self._nuscenes_dbs[database_version] = nusc + available_scenes = [scene for scene in nusc.scene] nuscenes_split = nuscenes_split_name_mapping[split] scene_names = scene_splits.get(nuscenes_split, []) @@ -117,8 +147,6 @@ def _collect_scene_tokens(self) -> Dict[str, List[str]]: # get token scene_tokens = [scene["token"] for scene in available_scenes if scene["name"] in scene_names] scene_tokens_per_split[split] = scene_tokens - self._nuscenes_dbs[database_version] = nusc - return scene_tokens_per_split def get_number_of_maps(self) -> int: diff --git a/src/py123d/conversion/map_writer/gpkg_map_writer.py b/src/py123d/conversion/map_writer/gpkg_map_writer.py index 58f9d159..63d91781 100644 --- a/src/py123d/conversion/map_writer/gpkg_map_writer.py +++ b/src/py123d/conversion/map_writer/gpkg_map_writer.py @@ -1,3 +1,4 @@ +import logging from collections import defaultdict from pathlib import Path from typing import Dict, List, Optional, Union @@ -29,6 +30,8 @@ MAP_OBJECT_DATA = Dict[str, List[Union[str, int, float, bool, geom.base.BaseGeometry]]] +logging.getLogger("pyogrio._io").disabled = True + class GPKGMapWriter(AbstractMapWriter): """Abstract base class for map writers.""" diff --git a/src/py123d/script/config/common/worker/single_machine_thread_pool.yaml b/src/py123d/script/config/common/worker/single_machine_thread_pool.yaml index 1344c762..308a00a3 100644 --- a/src/py123d/script/config/common/worker/single_machine_thread_pool.yaml +++ b/src/py123d/script/config/common/worker/single_machine_thread_pool.yaml @@ -1,4 +1,4 @@ _target_: py123d.common.multithreading.worker_parallel.SingleMachineParallelExecutor _convert_: 'all' -use_process_pool: True # If true, use ProcessPoolExecutor as the backend, otherwise uses ThreadPoolExecutor -max_workers: 16 # Number of CPU workers (threads/processes) to use per node, "null" means all available +use_process_pool: False # If true, use ProcessPoolExecutor as the backend, otherwise uses ThreadPoolExecutor +max_workers: null # Number of CPU workers (threads/processes) to use per node, "null" means all available diff --git a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml index e699fe4d..4a6d57ed 100644 --- a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml @@ -2,7 +2,7 @@ av2_sensor_dataset: _target_: py123d.conversion.datasets.av2.av2_sensor_converter.AV2SensorConverter _convert_: 'all' - splits: ["av2-sensor_train", "av2-sensor_val", "av2-sensor_test"] + splits: ["av2-sensor_val"] # ["av2-sensor_train", "av2-sensor_val", "av2-sensor_test"] av2_data_root: ${dataset_paths.av2_data_root} dataset_converter_config: @@ -23,12 +23,11 @@ av2_sensor_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" - + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_traffic_lights: false include_scenario_tags: false diff --git a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml index 2a342f37..5c7e23b5 100644 --- a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml @@ -31,15 +31,15 @@ kitti360_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # Fisheye Cameras include_fisheye_mei_cameras: true - fisheye_mei_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + fisheye_mei_camera_store_option: ${fisheye_mei_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_traffic_lights: false diff --git a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml index 25d8932c..3c872c87 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml @@ -28,11 +28,11 @@ nuplan_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Scenario tag / Route include_scenario_tags: true diff --git a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml index 3509ee5d..9aa267a9 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml @@ -28,12 +28,11 @@ nuplan_mini_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" - + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Scenario tag / Route include_scenario_tags: true include_route: true diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml index 2549022d..fed54eb1 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml @@ -26,11 +26,11 @@ nuscenes_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_fisheye_mei_cameras: false diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml index 8965eab1..baf3d1b6 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml @@ -26,11 +26,11 @@ nuscenes_mini_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_fisheye_mei_cameras: false diff --git a/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml b/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml index 1ab5ea02..aa71dd85 100644 --- a/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/pandaset_dataset.yaml @@ -20,11 +20,11 @@ pandaset_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_map: false diff --git a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml index f1b4632c..79ccc8d9 100644 --- a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml @@ -27,11 +27,11 @@ wopd_dataset: # Pinhole Cameras include_pinhole_cameras: true - pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" + pinhole_camera_store_option: ${pinhole_camera_store_option} # "path", "jpeg_binary", "png_binary", "mp4" # LiDARs include_lidars: true - lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" + lidar_store_option: ${lidar_store_option} # "path", "path_merged", "laz_binary", "draco_binary" # Not available: include_traffic_lights: false diff --git a/src/py123d/script/config/conversion/default_conversion.yaml b/src/py123d/script/config/conversion/default_conversion.yaml index 48e55dcc..fe3be70a 100644 --- a/src/py123d/script/config/conversion/default_conversion.yaml +++ b/src/py123d/script/config/conversion/default_conversion.yaml @@ -16,7 +16,7 @@ defaults: - log_writer: arrow_log_writer - map_writer: gpkg_map_writer - datasets: - - kitti360_dataset + - av2_sensor_dataset - _self_ @@ -24,3 +24,7 @@ terminate_on_exception: True force_map_conversion: True force_log_conversion: True + +pinhole_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" +fisheye_mei_camera_store_option: "jpeg_binary" # "path", "jpeg_binary", "png_binary", "mp4" +lidar_store_option: "laz_binary" # "path", "path_merged", "laz_binary", "draco_binary" diff --git a/src/py123d/script/run_conversion.py b/src/py123d/script/run_conversion.py index e1504b4a..08cd75aa 100644 --- a/src/py123d/script/run_conversion.py +++ b/src/py123d/script/run_conversion.py @@ -1,5 +1,6 @@ import gc import logging +import traceback from functools import partial from typing import Dict, List @@ -13,7 +14,7 @@ from py123d.script.utils.dataset_path_utils import setup_dataset_paths logging.basicConfig(level=logging.INFO) -logger = logging.getLogger() +logger = logging.getLogger(__name__) CONFIG_PATH = "config/conversion" CONFIG_NAME = "default_conversion" @@ -54,26 +55,34 @@ def main(cfg: DictConfig) -> None: def _convert_maps(args: List[Dict[str, int]], cfg: DictConfig, dataset_converter: AbstractDatasetConverter) -> List: + setup_dataset_paths(cfg.dataset_paths) map_writer = build_map_writer(cfg.map_writer) for arg in args: - dataset_converter.convert_map(arg["map_index"], map_writer) + try: + dataset_converter.convert_map(arg["map_index"], map_writer) + except Exception as e: + logger.error(f"Error converting map index {arg['map_index']}: {e}") + logger.error(traceback.format_exc()) # noqa: F821 + map_writer.close() + gc.collect() + if cfg.terminate_on_failure: + raise e return [] -def _convert_logs(args: List[Dict[str, int]], cfg: DictConfig, dataset_converter: AbstractDatasetConverter) -> None: +def _convert_logs(args: List[Dict[str, int]], cfg: DictConfig, dataset_converter: AbstractDatasetConverter) -> List: setup_dataset_paths(cfg.dataset_paths) - - def _internal_convert_log(args: Dict[str, int], dataset_converter_: AbstractDatasetConverter) -> int: - # for i2 in tqdm(range(300), leave=False) - log_writer = build_log_writer(cfg.log_writer) - for arg in args: - dataset_converter_.convert_log(arg["log_index"], log_writer) - del log_writer - gc.collect() - - # for arg in : - _internal_convert_log(args, dataset_converter) - gc.collect() + log_writer = build_log_writer(cfg.log_writer) + for arg in args: + try: + dataset_converter.convert_log(arg["log_index"], log_writer) + except Exception as e: + logger.error(f"Error converting log index {arg['log_index']}: {e}") + logger.error(traceback.format_exc()) # noqa: F821 + log_writer.close() + gc.collect() + if cfg.terminate_on_failure: + raise e return [] From 83a11b9f1376076367e53de66d61ca8bd987fb27 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Thu, 20 Nov 2025 15:21:47 +0100 Subject: [PATCH 37/50] Delete old examples, notebooks and scripts. --- examples/01_viser.py | 37 --- notebooks/01_scene_tutorial.ipynb | 189 ------------ notebooks/bev_matplotlib.ipynb | 289 ------------------ notebooks/bev_render.ipynb | 130 -------- notebooks/camera_matplotlib.ipynb | 162 ---------- notebooks/camera_render.ipynb | 164 ---------- scripts/conversion/av2_sensor_conversion.sh | 2 - scripts/conversion/kitti360_conversion.sh | 3 - scripts/conversion/nuplan_mini_conversion.sh | 1 - scripts/conversion/nuscenes_conversion.sh | 3 - .../conversion/nuscenes_mini_conversion.sh | 3 - scripts/conversion/pandaset_conversion.sh | 1 - scripts/conversion/wopd_conversion.sh | 6 - scripts/download/download_av2.sh | 15 - scripts/download/download_kitti_360.sh | 86 ------ scripts/download/download_lyft.sh | 22 -- scripts/download/download_nuplan_logs.sh | 50 --- scripts/download/download_nuplan_sensor.sh | 14 - scripts/viz/run_viser.sh | 6 - 19 files changed, 1183 deletions(-) delete mode 100644 examples/01_viser.py delete mode 100644 notebooks/01_scene_tutorial.ipynb delete mode 100644 notebooks/bev_matplotlib.ipynb delete mode 100644 notebooks/bev_render.ipynb delete mode 100644 notebooks/camera_matplotlib.ipynb delete mode 100644 notebooks/camera_render.ipynb delete mode 100644 scripts/conversion/av2_sensor_conversion.sh delete mode 100644 scripts/conversion/kitti360_conversion.sh delete mode 100644 scripts/conversion/nuplan_mini_conversion.sh delete mode 100644 scripts/conversion/nuscenes_conversion.sh delete mode 100644 scripts/conversion/nuscenes_mini_conversion.sh delete mode 100644 scripts/conversion/pandaset_conversion.sh delete mode 100644 scripts/conversion/wopd_conversion.sh delete mode 100644 scripts/download/download_av2.sh delete mode 100644 scripts/download/download_kitti_360.sh delete mode 100644 scripts/download/download_lyft.sh delete mode 100644 scripts/download/download_nuplan_logs.sh delete mode 100644 scripts/download/download_nuplan_sensor.sh delete mode 100644 scripts/viz/run_viser.sh diff --git a/examples/01_viser.py b/examples/01_viser.py deleted file mode 100644 index 408a7069..00000000 --- a/examples/01_viser.py +++ /dev/null @@ -1,37 +0,0 @@ -from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder -from py123d.api.scene.scene_filter import SceneFilter -from py123d.common.multithreading.worker_sequential import Sequential -from py123d.visualization.viser.viser_viewer import ViserViewer - -if __name__ == "__main__": - # splits = ["kitti360_train"] - # splits = ["nuscenes-mini_val", "nuscenes-mini_train"] - # splits = ["nuplan-mini_test", "nuplan-mini_train", "nuplan-mini_val"] - # splits = ["nuplan_private_test"] - # splits = ["carla_test"] - # splits = ["wopd_val"] - # splits = ["nuscenes_train"] - # splits = ["av2-sensor_test", "av2-sensor_train", "av2-sensor_val"] - splits = ["av2-sensor_val"] - # splits = ["pandaset_test", "pandaset_val", "pandaset_train"] - # log_names = ["2021.08.24.13.12.55_veh-45_00386_00472"] - # log_names = ["2013_05_28_drive_0000_sync"] - # log_names = ["2013_05_28_drive_0000_sync"] - log_names = None - scene_uuids = None - - scene_filter = SceneFilter( - split_names=splits, - log_names=log_names, - scene_uuids=scene_uuids, - duration_s=15.0, - history_s=0.0, - timestamp_threshold_s=15.0, - shuffle=True, - # pinhole_camera_types=[PinholeCameraType.PCAM_F0], - ) - scene_builder = ArrowSceneBuilder() - worker = Sequential() - scenes = scene_builder.get_scenes(scene_filter, worker) - print(f"Found {len(scenes)} scenes") - visualization_server = ViserViewer(scenes, scene_index=0) diff --git a/notebooks/01_scene_tutorial.ipynb b/notebooks/01_scene_tutorial.ipynb deleted file mode 100644 index e5fc4357..00000000 --- a/notebooks/01_scene_tutorial.ipynb +++ /dev/null @@ -1,189 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "

\n", - " \n", - " \n", - " \n", - " \"Logo\"\n", - " \n", - "

123D: One Library for 2D and 3D Driving Datasets

\n", - "" - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## 1. Download Demo Logs" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "# set environment variable\n", - "import os\n", - "\n", - "os.environ[\"AV2_DATA_ROOT\"] = \"/mnt/nvme2/av2\"" - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 2. Create Scenes by filtering the datasets" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.api.scene.scene_filter import SceneFilter\n", - "\n", - "# from py123d.common.multithreading.worker_ray import RayDistributed\n", - "# from py123d.common.multithreading.worker_ray import RayDistributed\n", - "from py123d.common.multithreading.worker_sequential import Sequential\n", - "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType\n", - "\n", - "# from py123d.datatypes.sensors.pinhole_camera_type import PinholeCameraType\n", - "\n", - "# splits = [\"kitti360_train\"]\n", - "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", - "# splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", - "# splits = [\"carla_test\"]\n", - "# splits = [\"wopd_val\"]\n", - "splits = [\"av2-sensor_train\"]\n", - "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", - "\n", - "splits = None\n", - "log_names = None\n", - "scene_uuids = None\n", - "\n", - "scene_filter = SceneFilter(\n", - " split_names=splits,\n", - " log_names=log_names,\n", - " scene_uuids=scene_uuids,\n", - " duration_s=15.0,\n", - " history_s=0.0,\n", - " timestamp_threshold_s=15.0,\n", - " shuffle=True,\n", - " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", - ")\n", - "scene_builder = ArrowSceneBuilder()\n", - "worker = Sequential()\n", - "\n", - "# worker = RayDistributed()\n", - "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "print(f\"Found {len(scenes)} scenes\")" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "# 3. Inspecting the Scene" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "scene = scenes[0]\n", - "\n", - "log_metadata = scene.get_log_metadata()\n", - "\n", - "print(log_metadata.to_dict())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "5.038450e-02" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "from py123d.visualization.viser.viser_viewer import ViserViewer\n", - "\n", - "visualization_server = ViserViewer(scenes, scene_index=0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "\n", - "from py123d.geometry import EulerAngles\n", - "\n", - "euler_angles = EulerAngles(roll=0.0, pitch=0.0, yaw=np.pi)\n", - "euler_angles.roll\n", - "# 0.0\n", - "euler_angles.yaw\n", - "# 3.141592653589793\n", - "# euler_angles.array\n", - "# array([0.0, 0.0, 3.14159265])\n", - "EulerAngles.from_rotation_matrix(euler_angles.rotation_matrix).yaw" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "py123d_dev", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/bev_matplotlib.ipynb b/notebooks/bev_matplotlib.ipynb deleted file mode 100644 index a5716ca4..00000000 --- a/notebooks/bev_matplotlib.ipynb +++ /dev/null @@ -1,289 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": {}, - "outputs": [], - "source": [ - "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.api.scene.scene_filter import SceneFilter\n", - "from py123d.common.multithreading.worker_sequential import Sequential\n", - "# from py123d.datatypes.sensors.pinhole_camera_type import PinholeCameraType" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "# splits = [\"kitti360_train\"]\n", - "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", - "# splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", - "# splits = [\"carla_test\"]\n", - "# splits = [\"wopd_val\"]\n", - "# splits = [\"av2-sensor_train\"]\n", - "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", - "splits = None\n", - "log_names = None\n", - "scene_uuids = None\n", - "\n", - "scene_filter = SceneFilter(\n", - " split_names=splits,\n", - " log_names=log_names,\n", - " scene_uuids=scene_uuids,\n", - " duration_s=None,\n", - " history_s=0.0,\n", - " timestamp_threshold_s=30.0,\n", - " shuffle=True,\n", - " # camera_types=[PinholeCameraType.CAM_F0],\n", - ")\n", - "scene_builder = ArrowSceneBuilder()\n", - "worker = Sequential()\n", - "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "print(f\"Found {len(scenes)} scenes\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "from typing import List, Optional, Tuple\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import shapely.geometry as geom\n", - "\n", - "from py123d.api.map.map_api import MapAPI\n", - "from py123d.api.scene.scene_api import SceneAPI\n", - "from py123d.datatypes.map_objects.map_layer_types import MapLayer\n", - "from py123d.datatypes.map_objects.map_objects import LaneGroup\n", - "from py123d.geometry import Point2D\n", - "from py123d.visualization.color.color import BLACK, DARKER_GREY, NEW_TAB_10, TAB_10\n", - "from py123d.visualization.color.config import PlotConfig\n", - "from py123d.visualization.color.default import CENTERLINE_CONFIG, MAP_SURFACE_CONFIG, ROUTE_CONFIG\n", - "from py123d.visualization.matplotlib.observation import (\n", - " add_box_detections_to_ax,\n", - " add_default_map_on_ax,\n", - " add_ego_vehicle_to_ax,\n", - ")\n", - "from py123d.visualization.matplotlib.utils import add_shapely_linestring_to_ax, add_shapely_polygon_to_ax\n", - "\n", - "LEFT_CONFIG: PlotConfig = PlotConfig(\n", - " fill_color=TAB_10[2],\n", - " fill_color_alpha=1.0,\n", - " line_color=TAB_10[2],\n", - " line_color_alpha=0.5,\n", - " line_width=1.0,\n", - " line_style=\"-\",\n", - " zorder=3,\n", - ")\n", - "\n", - "RIGHT_CONFIG: PlotConfig = PlotConfig(\n", - " fill_color=TAB_10[3],\n", - " fill_color_alpha=1.0,\n", - " line_color=TAB_10[3],\n", - " line_color_alpha=0.5,\n", - " line_width=1.0,\n", - " line_style=\"-\",\n", - " zorder=22,\n", - ")\n", - "\n", - "\n", - "LANE_CONFIG: PlotConfig = PlotConfig(\n", - " fill_color=BLACK,\n", - " fill_color_alpha=1.0,\n", - " line_color=BLACK,\n", - " line_color_alpha=0.0,\n", - " line_width=0.0,\n", - " line_style=\"-\",\n", - " zorder=5,\n", - ")\n", - "\n", - "ROAD_EDGE_CONFIG: PlotConfig = PlotConfig(\n", - " fill_color=DARKER_GREY,\n", - " fill_color_alpha=1.0,\n", - " line_color=DARKER_GREY,\n", - " line_color_alpha=1.0,\n", - " line_width=1.0,\n", - " line_style=\"-\",\n", - " zorder=3,\n", - ")\n", - "\n", - "ROAD_LINE_CONFIG: PlotConfig = PlotConfig(\n", - " fill_color=NEW_TAB_10[5],\n", - " fill_color_alpha=1.0,\n", - " line_color=NEW_TAB_10[5],\n", - " line_color_alpha=1.0,\n", - " line_width=1.5,\n", - " line_style=\"-\",\n", - " zorder=3,\n", - ")\n", - "\n", - "\n", - "def add_debug_map_on_ax(\n", - " ax: plt.Axes,\n", - " map_api: MapAPI,\n", - " point_2d: Point2D,\n", - " radius: float,\n", - " route_lane_group_ids: Optional[List[int]] = None,\n", - ") -> None:\n", - " layers: List[MapLayer] = [\n", - " # MapLayer.LANE,\n", - " MapLayer.LANE_GROUP,\n", - " # MapLayer.GENERIC_DRIVABLE,\n", - " # MapLayer.CARPARK,\n", - " # MapLayer.CROSSWALK,\n", - " # MapLayer.INTERSECTION,\n", - " # MapLayer.WALKWAY,\n", - " # MapLayer.ROAD_EDGE,\n", - " # MapLayer.ROAD_LINE,\n", - " ]\n", - " x_min, x_max = point_2d.x - radius, point_2d.x + radius\n", - " y_min, y_max = point_2d.y - radius, point_2d.y + radius\n", - " patch = geom.box(x_min, y_min, x_max, y_max)\n", - " map_objects_dict = map_api.query(geometry=patch, layers=layers, predicate=\"intersects\")\n", - "\n", - " for layer, map_objects in map_objects_dict.items():\n", - " for map_object in map_objects:\n", - " try:\n", - " if layer in [\n", - " MapLayer.GENERIC_DRIVABLE,\n", - " MapLayer.CARPARK,\n", - " MapLayer.CROSSWALK,\n", - " MapLayer.INTERSECTION,\n", - " MapLayer.WALKWAY,\n", - " ]:\n", - " add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", - "\n", - " if layer in [MapLayer.LANE_GROUP]:\n", - " map_object: LaneGroup\n", - " # add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", - " if map_object.intersection is not None:\n", - " add_shapely_polygon_to_ax(ax, map_object.intersection.shapely_polygon, ROUTE_CONFIG)\n", - "\n", - " for lane in map_object.lanes:\n", - " # print(lane.object_id)\n", - " add_shapely_linestring_to_ax(ax, lane.centerline.linestring, CENTERLINE_CONFIG)\n", - "\n", - " if layer in [MapLayer.LANE]:\n", - " add_shapely_linestring_to_ax(ax, map_object.centerline.linestring, CENTERLINE_CONFIG)\n", - " add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer])\n", - "\n", - " if layer in [MapLayer.ROAD_EDGE]:\n", - " add_shapely_linestring_to_ax(ax, map_object.polyline_3d.linestring, ROAD_EDGE_CONFIG)\n", - "\n", - " if layer in [MapLayer.ROAD_LINE]:\n", - " # line_type = int(map_object.road_line_type)\n", - " # plt_config = PlotConfig(\n", - " # fill_color=NEW_TAB_10[line_type % (len(NEW_TAB_10) - 1)],\n", - " # fill_color_alpha=1.0,\n", - " # line_color=NEW_TAB_10[line_type % (len(NEW_TAB_10) - 1)],\n", - " # line_color_alpha=1.0,\n", - " # line_width=1.5,\n", - " # zorder=10,\n", - " # )\n", - " add_shapely_linestring_to_ax(ax, map_object.polyline_3d.linestring, ROAD_LINE_CONFIG)\n", - "\n", - " except Exception:\n", - " import traceback\n", - "\n", - " print(f\"Error adding map object of type {layer.name} and id {map_object.object_id}\")\n", - " traceback.print_exc()\n", - "\n", - " # ax.set_title(f\"Map: {map_api.map_name}\")\n", - "\n", - "\n", - "def _plot_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: float = 80) -> plt.Axes:\n", - " ego_vehicle_state = scene.get_ego_state_at_iteration(iteration)\n", - " box_detections = scene.get_box_detections_at_iteration(iteration)\n", - " map_api = scene.get_map_api()\n", - "\n", - " point_2d = ego_vehicle_state.bounding_box.center_se2.point_2d\n", - " if map_api is not None:\n", - " # add_debug_map_on_ax(ax, scene.get_map_api(), point_2d, radius=radius, route_lane_group_ids=None)\n", - "\n", - " add_default_map_on_ax(ax, map_api, point_2d, radius=radius, route_lane_group_ids=None)\n", - " # add_traffic_lights_to_ax(ax, traffic_light_detections, scene.get_map_api())\n", - "\n", - " add_box_detections_to_ax(ax, box_detections)\n", - " add_ego_vehicle_to_ax(ax, ego_vehicle_state)\n", - "\n", - " zoom = 1.0\n", - " ax.set_xlim(point_2d.x - radius * zoom, point_2d.x + radius * zoom)\n", - " ax.set_ylim(point_2d.y - radius * zoom, point_2d.y + radius * zoom)\n", - "\n", - " ax.set_aspect(\"equal\", adjustable=\"box\")\n", - " return ax\n", - "\n", - "\n", - "def plot_scene_at_iteration(scene: SceneAPI, iteration: int = 0, radius: float = 80) -> Tuple[plt.Figure, plt.Axes]:\n", - " size = 10\n", - "\n", - " fig, ax = plt.subplots(figsize=(size, size))\n", - " _plot_scene_on_ax(ax, scene, iteration, radius)\n", - " return fig, ax\n", - "\n", - "\n", - "# scene_index =\n", - "iteration = 1\n", - "\n", - "scale = 20\n", - "fig, ax = plt.subplots(1, 3, figsize=(scale, scale))\n", - "scene = np.random.choice(scenes)\n", - "_plot_scene_on_ax(ax[0], scene, iteration, radius=20)\n", - "_plot_scene_on_ax(ax[1], scene, iteration, radius=50)\n", - "_plot_scene_on_ax(ax[2], scene, iteration, radius=100)\n", - "\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "map_api = scene.get_map_api()\n", - "\n", - "map_api.get_map" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "py123d_dev", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/bev_render.ipynb b/notebooks/bev_render.ipynb deleted file mode 100644 index e519ac78..00000000 --- a/notebooks/bev_render.ipynb +++ /dev/null @@ -1,130 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": {}, - "outputs": [], - "source": [ - "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.api.scene.scene_filter import SceneFilter\n", - "from py123d.common.multithreading.worker_sequential import Sequential\n", - "# from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "# splits = [\"kitti360\"]\n", - "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", - "# splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", - "# splits = [\"nuplan_private_test\"]\n", - "# splits = [\"carla_test\"]\n", - "splits = [\"wopd_val\"]\n", - "# splits = [\"av2-sensor_train\"]\n", - "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", - "# log_names = [\"2021.08.24.13.12.55_veh-45_00386_00472\"]\n", - "# log_names = [\"2013_05_28_drive_0000_sync\"]\n", - "# log_names = [\"2013_05_28_drive_0000_sync\"]\n", - "log_names = None\n", - "scene_uuids = [\"9727e2b3-46b0-51bd-84a9-c516c0993045\"]\n", - "\n", - "scene_filter = SceneFilter(\n", - " split_names=splits,\n", - " log_names=log_names,\n", - " scene_uuids=scene_uuids,\n", - " duration_s=None,\n", - " history_s=0.0,\n", - " timestamp_threshold_s=None,\n", - " shuffle=True,\n", - " # camera_types=[PinholeCameraType.CAM_F0],\n", - ")\n", - "scene_builder = ArrowSceneBuilder()\n", - "worker = Sequential()\n", - "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "\n", - "scenes = [scene for scene in scenes if scene.uuid in scene_uuids]\n", - "print(f\"Found {len(scenes)} scenes\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "from py123d.visualization.matplotlib.plots import render_scene_animation\n", - "\n", - "for i in [0]:\n", - " render_scene_animation(scenes[i], output_path=\"test\", format=\"mp4\", fps=20, step=1, radius=50)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "py123d", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/camera_matplotlib.ipynb b/notebooks/camera_matplotlib.ipynb deleted file mode 100644 index 4ee3ca0b..00000000 --- a/notebooks/camera_matplotlib.ipynb +++ /dev/null @@ -1,162 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": {}, - "outputs": [], - "source": [ - "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.api.scene.scene_filter import SceneFilter\n", - "from py123d.common.multithreading.worker_sequential import Sequential\n", - "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "# splits = [\"wopd_val\"]\n", - "# splits = [\"carla_test\"]\n", - "# splits = [\"nuplan-mini_test\"]\n", - "# splits = [\"av2-sensor-mini_train\"]\n", - "\n", - "\n", - "splits = [\"pandaset_train\"]\n", - "# log_names = None\n", - "\n", - "\n", - "log_names = None\n", - "scene_uuids = None\n", - "\n", - "scene_filter = SceneFilter(\n", - " split_names=splits,\n", - " log_names=log_names,\n", - " scene_uuids=scene_uuids,\n", - " duration_s=6.0,\n", - " history_s=0.0,\n", - " timestamp_threshold_s=20,\n", - " shuffle=True,\n", - " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", - ")\n", - "scene_builder = ArrowSceneBuilder()\n", - "worker = Sequential()\n", - "# worker = RayDistributed()\n", - "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "\n", - "print(f\"Found {len(scenes)} scenes\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "from matplotlib import pyplot as plt\n", - "\n", - "from py123d.api.scene.abstract_scene import AbstractScene\n", - "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax\n", - "\n", - "iteration = 0\n", - "scene = scenes[0]\n", - "\n", - "scene: AbstractScene\n", - "print(scene.uuid, scene.available_pinhole_camera_types)\n", - "\n", - "scale = 3.0\n", - "fig, ax = plt.subplots(2, 3, figsize=(scale * 6, scale * 2.5))\n", - "\n", - "\n", - "camera_ax_mapping = {\n", - " PinholeCameraType.PCAM_L0: ax[0, 0],\n", - " PinholeCameraType.PCAM_F0: ax[0, 1],\n", - " PinholeCameraType.PCAM_R0: ax[0, 2],\n", - " PinholeCameraType.PCAM_L1: ax[1, 0],\n", - " PinholeCameraType.PCAM_B0: ax[1, 1],\n", - " PinholeCameraType.PCAM_R1: ax[1, 2],\n", - "}\n", - "\n", - "\n", - "for camera_type, ax_ in camera_ax_mapping.items():\n", - " camera = scene.get_pinhole_camera_at_iteration(iteration, camera_type)\n", - " box_detections = scene.get_box_detections_at_iteration(iteration)\n", - " ego_state = scene.get_ego_state_at_iteration(iteration)\n", - "\n", - " add_box_detections_to_camera_ax(\n", - " ax_,\n", - " camera,\n", - " box_detections,\n", - " ego_state,\n", - " )\n", - " ax_.set_title(f\"Camera: {camera_type.name}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "py123d", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/camera_render.ipynb b/notebooks/camera_render.ipynb deleted file mode 100644 index 4f6f616e..00000000 --- a/notebooks/camera_render.ipynb +++ /dev/null @@ -1,164 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": {}, - "outputs": [], - "source": [ - "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.api.scene.scene_filter import SceneFilter\n", - "from py123d.common.multithreading.worker_sequential import Sequential\n", - "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType\n", - "\n", - "KITTI360_DATA_ROOT = \"/home/daniel/kitti_360/KITTI-360\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "# splits = [\"kitti360\"]\n", - "# splits = [\"nuscenes-mini_val\", \"nuscenes-mini_train\"]\n", - "# splits = [\"nuplan-mini_test\", \"nuplan-mini_train\", \"nuplan-mini_val\"]\n", - "# splits = [\"nuplan_private_test\"]\n", - "# splits = [\"carla_test\"]\n", - "splits = [\"wopd_val\"]\n", - "# splits = [\"av2-sensor_train\"]\n", - "# splits = [\"pandaset_test\", \"pandaset_val\", \"pandaset_train\"]\n", - "# log_names = [\"2021.08.24.13.12.55_veh-45_00386_00472\"]\n", - "# log_names = [\"2013_05_28_drive_0000_sync\"]\n", - "# log_names = [\"2013_05_28_drive_0000_sync\"]\n", - "log_names = None\n", - "scene_uuids = [\"9727e2b3-46b0-51bd-84a9-c516c0993045\"]\n", - "\n", - "scene_filter = SceneFilter(\n", - " split_names=splits,\n", - " log_names=log_names,\n", - " scene_uuids=scene_uuids,\n", - " duration_s=None,\n", - " history_s=0.0,\n", - " timestamp_threshold_s=None,\n", - " shuffle=True,\n", - " # camera_types=[PinholeCameraType.CAM_F0],\n", - ")\n", - "scene_builder = ArrowSceneBuilder()\n", - "worker = Sequential()\n", - "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "\n", - "scenes = [scene for scene in scenes if scene.uuid in scene_uuids]\n", - "print(f\"Found {len(scenes)} scenes\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "import imageio\n", - "from matplotlib import pyplot as plt\n", - "\n", - "from py123d.api.scene.abstract_scene import AbstractScene\n", - "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax\n", - "\n", - "iteration = 0\n", - "scene = scenes[0]\n", - "\n", - "scene: AbstractScene\n", - "fps = 15 # frames per second\n", - "output_file = f\"camera_{scene.log_metadata.split}_{scene.uuid}.mp4\"\n", - "\n", - "writer = imageio.get_writer(output_file, fps=fps)\n", - "\n", - "scale = 3.0\n", - "fig, ax = plt.subplots(2, 3, figsize=(scale * 6, scale * 2.5))\n", - "\n", - "\n", - "camera_type = PinholeCameraType.CAM_F0\n", - "\n", - "for i in range(scene.number_of_iterations):\n", - " camera = scene.get_camera_at_iteration(i, camera_type)\n", - " box_detections = scene.get_box_detections_at_iteration(i)\n", - " ego_state = scene.get_ego_state_at_iteration(i)\n", - "\n", - " _, image = add_box_detections_to_camera_ax(\n", - " None,\n", - " camera,\n", - " box_detections,\n", - " ego_state,\n", - " return_image=True,\n", - " )\n", - " writer.append_data(image)\n", - "\n", - "writer.close()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "py123d", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/scripts/conversion/av2_sensor_conversion.sh b/scripts/conversion/av2_sensor_conversion.sh deleted file mode 100644 index b386972e..00000000 --- a/scripts/conversion/av2_sensor_conversion.sh +++ /dev/null @@ -1,2 +0,0 @@ -py123d-conversion datasets=["av2_sensor_dataset"] \ -dataset_paths.av2_data_root="/media/nvme1/argoverse" diff --git a/scripts/conversion/kitti360_conversion.sh b/scripts/conversion/kitti360_conversion.sh deleted file mode 100644 index 1e939ad5..00000000 --- a/scripts/conversion/kitti360_conversion.sh +++ /dev/null @@ -1,3 +0,0 @@ -export KITTI360_DATA_ROOT="/home/daniel/kitti_360/KITTI-360" - -py123d-conversion datasets=["kitti360_dataset"] map_writer.remap_ids=true diff --git a/scripts/conversion/nuplan_mini_conversion.sh b/scripts/conversion/nuplan_mini_conversion.sh deleted file mode 100644 index 13ec7a53..00000000 --- a/scripts/conversion/nuplan_mini_conversion.sh +++ /dev/null @@ -1 +0,0 @@ -py123d-conversion datasets=["nuplan_mini_dataset"] diff --git a/scripts/conversion/nuscenes_conversion.sh b/scripts/conversion/nuscenes_conversion.sh deleted file mode 100644 index ff4f51d3..00000000 --- a/scripts/conversion/nuscenes_conversion.sh +++ /dev/null @@ -1,3 +0,0 @@ -export NUSCENES_DATA_ROOT="/mnt/elements_1/nuscenes" - -py123d-conversion datasets=["nuscenes_mini_dataset"] diff --git a/scripts/conversion/nuscenes_mini_conversion.sh b/scripts/conversion/nuscenes_mini_conversion.sh deleted file mode 100644 index b9a9a7d1..00000000 --- a/scripts/conversion/nuscenes_mini_conversion.sh +++ /dev/null @@ -1,3 +0,0 @@ -export NUSCENES_DATA_ROOT="/home/daniel/nuscenes_mini/" - -py123d-conversion datasets=["nuscenes_mini_dataset"] map_writer.remap_ids=true diff --git a/scripts/conversion/pandaset_conversion.sh b/scripts/conversion/pandaset_conversion.sh deleted file mode 100644 index 0131e60f..00000000 --- a/scripts/conversion/pandaset_conversion.sh +++ /dev/null @@ -1 +0,0 @@ -py123d-conversion datasets=[pandaset_dataset] diff --git a/scripts/conversion/wopd_conversion.sh b/scripts/conversion/wopd_conversion.sh deleted file mode 100644 index e4f4b3d4..00000000 --- a/scripts/conversion/wopd_conversion.sh +++ /dev/null @@ -1,6 +0,0 @@ -py123d-conversion datasets=[wopd_dataset] - - -# pip install protobuf==6.30.2 -# pip install tensorflow==2.13.0 -# pip install waymo-open-dataset-tf-2-12-0==1.6.6 diff --git a/scripts/download/download_av2.sh b/scripts/download/download_av2.sh deleted file mode 100644 index a6e7474c..00000000 --- a/scripts/download/download_av2.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -# Dataset URIs -# s3://argoverse/datasets/av2/sensor/ -# s3://argoverse/datasets/av2/lidar/ -# s3://argoverse/datasets/av2/motion-forecasting/ -# s3://argoverse/datasets/av2/tbv/ - -DATASET_NAMES=("sensor" "lidar" "motion-forecasting" "tbv") -TARGET_DIR="/path/to/argoverse" - -for DATASET_NAME in "${DATASET_NAMES[@]}"; do - mkdir -p "$TARGET_DIR/$DATASET_NAME" - s5cmd --no-sign-request cp "s3://argoverse/datasets/av2/$DATASET_NAME/*" "$TARGET_DIR/$DATASET_NAME" -done diff --git a/scripts/download/download_kitti_360.sh b/scripts/download/download_kitti_360.sh deleted file mode 100644 index 1cb3e540..00000000 --- a/scripts/download/download_kitti_360.sh +++ /dev/null @@ -1,86 +0,0 @@ -# 2D data & labels -# ---------------------------------------------------------------------------------------------------------------------- - -# Fisheye Images (355G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/a1d81d9f7fc7195c937f9ad12e2a2c66441ecb4e/download_2d_fisheye.zip - -# Fisheye Calibration Images (11G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/data_2d_raw/data_fisheye_calibration.zip - - -# Perspective Images for Train & Val (128G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/a1d81d9f7fc7195c937f9ad12e2a2c66441ecb4e/download_2d_perspective.zip - -# Test Semantic (1.5G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/data_2d_raw/data_2d_test.zip - -# Test NVS 50% Drop (0.3G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/71f967e900f4e7c2e036a542f150effa31909b53/data_2d_nvs_drop50.zip - -# est NVS 90% Drop (0.2G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/71f967e900f4e7c2e036a542f150effa31909b53/data_2d_nvs_drop90.zip - -# Test SLAM (14G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/data_2d_raw/data_2d_test_slam.zip - - -# Semantics of Left Perspective Camera (1.8G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/ed180d24c0a144f2f1ac71c2c655a3e986517ed8/data_2d_semantics.zip - -# Semantics of Right Perspective Camera (1.8G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/ed180d24c0a144f2f1ac71c2c655a3e986517ed8/data_2d_semantics_image_01.zip - - -# Confidence of Left Perspective Camera (44G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/ed180d24c0a144f2f1ac71c2c655a3e986517ed8/data_2d_confidence.zip - -# Confidence of Right Perspective Camera (44G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/ed180d24c0a144f2f1ac71c2c655a3e986517ed8/data_2d_confidence_image_01.zip - - - -# 3D data & labels -# ---------------------------------------------------------------------------------------------------------------------- - -# Raw Velodyne Scans (119G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/a1d81d9f7fc7195c937f9ad12e2a2c66441ecb4e/download_3d_velodyne.zip - -# Test SLAM (12G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/data_3d_raw/data_3d_test_slam.zip - -# Test Completion (35M) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/6489aabd632d115c4280b978b2dcf72cb0142ad9/data_3d_ssc_test.zip - - -# Raw SICK Scans (0.4G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/a1d81d9f7fc7195c937f9ad12e2a2c66441ecb4e/download_3d_sick.zip - - -# Accumulated Point Clouds for Train & Val (12G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/6489aabd632d115c4280b978b2dcf72cb0142ad9/data_3d_semantics.zip - -# Test Semantic (1.2G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/6489aabd632d115c4280b978b2dcf72cb0142ad9/data_3d_semantics_test.zip - - -# 3D Bounding Boxes (30M) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/ffa164387078f48a20f0188aa31b0384bb19ce60/data_3d_bboxes.zip - - - -# Calibrations & Poses -# ---------------------------------------------------------------------------------------------------------------------- - -# Calibrations (3K) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/384509ed5413ccc81328cf8c55cc6af078b8c444/calibration.zip - - -# Vechicle Poses (8.9M) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/89a6bae3c8a6f789e12de4807fc1e8fdcf182cf4/data_poses.zip - - -# OXTS Sync Measurements (37.3M) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/89a6bae3c8a6f789e12de4807fc1e8fdcf182cf4/data_poses_oxts.zip - -# OXTS Raw Measurements (0.4G) -wget https://s3.eu-central-1.amazonaws.com/avg-projects/KITTI-360/89a6bae3c8a6f789e12de4807fc1e8fdcf182cf4/data_poses_oxts_extract.zip diff --git a/scripts/download/download_lyft.sh b/scripts/download/download_lyft.sh deleted file mode 100644 index f2f1e268..00000000 --- a/scripts/download/download_lyft.sh +++ /dev/null @@ -1,22 +0,0 @@ -# Sample dataset (51 MB) -wget https://woven.toyota/common/assets/data/prediction-sample.tar - - -# Training dataset - Part 1/2 (8.4 GB) -wget https://woven.toyota/common/assets/data/prediction-train.tar - - -# Training dataset - Part 2/2 (70 GB) -wget https://woven.toyota/common/assets/data/prediction-train_full.tar - - -# Validation dataset (8.2 GB) -wget https://woven.toyota/common/assets/data/prediction-validate.tar - - -# Aerial Map (2 GB) -wget https://woven.toyota/common/assets/data/prediction-aerial_map.tar - - -# Semantic Map (3 MB) -wget https://woven.toyota/common/assets/data/prediction-semantic_map.tar diff --git a/scripts/download/download_nuplan_logs.sh b/scripts/download/download_nuplan_logs.sh deleted file mode 100644 index fa48c71b..00000000 --- a/scripts/download/download_nuplan_logs.sh +++ /dev/null @@ -1,50 +0,0 @@ -# NOTE: Please check the LICENSE file when downloading the nuPlan dataset -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/LICENSE - -# 1. Maps (required for nuplan and nuplan-mini) -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-maps-v1.1.zip - -# 2. Logs -# 2.1 nuplan_train -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_boston.zip -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_pittsburgh.zip -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_singapore.zip -for split in {1..6}; do - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_train_vegas_${split}.zip -done - -# 2.2 nuplan_val -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_test.zip - -# 2.3 nuplan_test -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_val.zip - -# 2.4 nuplan-mini_train, nuplan-mini_val, nuplan-mini_test -wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/nuplan-v1.1_mini.zip - - -# 3. Sensor blobs -# 3.1 train: nuplan_train -for split in {0..42}; do - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/train_set/nuplan-v1.1_train_camera_${split}.zip - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/train_set/nuplan-v1.1_train_lidar_${split}.zip -done - -# 3.2 val: nuplan_val -for split in {0..11}; do - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/val_set/nuplan-v1.1_val_camera_${split}.zip - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/val_set/nuplan-v1.1_val_lidar_${split}.zip -done - -# 3.3 test: nuplan_test -for split in {0..11}; do - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/test_set/nuplan-v1.1_test_camera_${split}.zip - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/test_set/nuplan-v1.1_test_lidar_${split}.zip -done - - -# 3.4 mini: nuplan_mini_train, nuplan_mini_val, nuplan_mini_test -for split in {0..8}; do - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_camera_${split}.zip - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_lidar_${split}.zip -done diff --git a/scripts/download/download_nuplan_sensor.sh b/scripts/download/download_nuplan_sensor.sh deleted file mode 100644 index 6cd3d246..00000000 --- a/scripts/download/download_nuplan_sensor.sh +++ /dev/null @@ -1,14 +0,0 @@ -# NOTE: Please check the LICENSE file when downloading the nuPlan dataset -# wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/LICENSE - -# train: nuplan_train - -# val: nuplan_val - -# test: nuplan_test - -# mini: nuplan_mini_train, nuplan_mini_val, nuplan_mini_test -for split in {0..8}; do - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_camera_${split}.zip - wget https://motional-nuplan.s3-ap-northeast-1.amazonaws.com/public/nuplan-v1.1/sensor_blobs/mini_set/nuplan-v1.1_mini_lidar_${split}.zip -done diff --git a/scripts/viz/run_viser.sh b/scripts/viz/run_viser.sh deleted file mode 100644 index 436e7643..00000000 --- a/scripts/viz/run_viser.sh +++ /dev/null @@ -1,6 +0,0 @@ - - -py123d-viser \ -scene_filter=log_scenes \ -scene_filter.shuffle=True \ -worker=sequential From e9121e273199b9d3883090b5a5a84b8931abed07 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Thu, 20 Nov 2025 15:37:16 +0100 Subject: [PATCH 38/50] Clean up syntax of 2D/3D properties. (#69) --- .../datasets/kitti360/kitti360_converter.py | 4 +- .../datatypes/vehicle_state/dynamic_state.py | 28 +----- .../datatypes/vehicle_state/ego_state.py | 30 ------ src/py123d/geometry/point.py | 10 +- src/py123d/geometry/pose.py | 15 ++- src/py123d/geometry/vector.py | 92 +++++-------------- .../viser/elements/map_elements.py | 4 +- .../viser/elements/render_elements.py | 6 +- .../viser/elements/sensor_elements.py | 6 +- 9 files changed, 52 insertions(+), 143 deletions(-) diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 0485160f..7a443e47 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -259,7 +259,9 @@ def convert_log(self, log_index: int, log_writer: AbstractLogWriter) -> None: if log_needs_writing: ts_list: List[TimePoint] = _read_timestamps(log_name, self._kitti360_folders) ego_state_all, valid_timestamp = _extract_ego_state_all(log_name, self._kitti360_folders) - ego_states_xyz = np.array([ego_state.center.array[:3] for ego_state in ego_state_all], dtype=np.float64) + ego_states_xyz = np.array( + [ego_state.center_se3.point_3d.array[:3] for ego_state in ego_state_all], dtype=np.float64 + ) box_detection_wrapper_all = _extract_kitti360_box_detections_all( log_name, len(ts_list), diff --git a/src/py123d/datatypes/vehicle_state/dynamic_state.py b/src/py123d/datatypes/vehicle_state/dynamic_state.py index 14907d2e..942fd7ef 100644 --- a/src/py123d/datatypes/vehicle_state/dynamic_state.py +++ b/src/py123d/datatypes/vehicle_state/dynamic_state.py @@ -105,30 +105,20 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Dynami object.__setattr__(instance, "_array", array.copy() if copy else array) return instance - @property - def velocity(self) -> Vector3D: - """3D velocity vector.""" - return Vector3D.from_array(self._array[DynamicStateSE3Index.VELOCITY_3D], copy=False) - @property def velocity_3d(self) -> Vector3D: """3D velocity vector.""" - return self.velocity + return Vector3D.from_array(self._array[DynamicStateSE3Index.VELOCITY_3D], copy=False) @property def velocity_2d(self) -> Vector2D: """2D velocity vector.""" return Vector2D.from_array(self._array[DynamicStateSE3Index.VELOCITY_2D], copy=False) - @property - def acceleration(self) -> Vector3D: - """3D acceleration vector.""" - return Vector3D.from_array(self._array[DynamicStateSE3Index.ACCELERATION_3D], copy=False) - @property def acceleration_3d(self) -> Vector3D: """3D acceleration vector.""" - return self.acceleration + return Vector3D.from_array(self._array[DynamicStateSE3Index.ACCELERATION_3D], copy=False) @property def acceleration_2d(self) -> Vector2D: @@ -228,25 +218,15 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Dynami object.__setattr__(instance, "_array", array.copy() if copy else array) return instance - @property - def velocity(self) -> Vector2D: - """2D velocity vector.""" - return Vector2D.from_array(self._array[DynamicStateSE2Index.VELOCITY_2D], copy=False) - @property def velocity_2d(self) -> Vector2D: """2D velocity vector.""" - return self.velocity - - @property - def acceleration(self) -> Vector2D: - """2D acceleration vector.""" - return Vector2D.from_array(self._array[DynamicStateSE2Index.ACCELERATION_2D], copy=False) + return Vector2D.from_array(self._array[DynamicStateSE2Index.VELOCITY_2D], copy=False) @property def acceleration_2d(self) -> Vector2D: """2D acceleration vector.""" - return self.acceleration + return Vector2D.from_array(self._array[DynamicStateSE2Index.ACCELERATION_2D], copy=False) @property def angular_velocity(self) -> float: diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index c6afc059..8bfa5f31 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -136,11 +136,6 @@ def tire_steering_angle(self) -> Optional[float]: """The tire steering angle of the ego state, if available.""" return self._tire_steering_angle - @property - def center(self) -> PoseSE3: - """The :class:`~py123d.geometry.PoseSE3` of the vehicle center in SE3.""" - return self.center_se3 - @property def center_se3(self) -> PoseSE3: """The :class:`~py123d.geometry.PoseSE3` of the vehicle center in SE3.""" @@ -169,11 +164,6 @@ def bounding_box_se2(self) -> BoundingBoxSE2: """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" return self.bounding_box_se3.bounding_box_se2 - @property - def box_detection(self) -> BoxDetectionSE3: - """The :class:`~py123d.datatypes.detections.BoxDetectionSE3` projection of the ego vehicle.""" - return self.box_detection_se3 - @property def box_detection_se3(self) -> BoxDetectionSE3: """The :class:`~py123d.datatypes.detections.BoxDetectionSE3` projection of the ego vehicle.""" @@ -292,11 +282,6 @@ def from_center( tire_steering_angle=tire_steering_angle, ) - @property - def rear_axle(self) -> PoseSE2: - """The :class:`~py123d.geometry.PoseSE2` of the rear axle in SE2.""" - return self.rear_axle_se2 - @property def rear_axle_se2(self) -> PoseSE2: """The :class:`~py123d.geometry.PoseSE2` of the rear axle in SE2.""" @@ -322,21 +307,11 @@ def tire_steering_angle(self) -> Optional[float]: """The tire steering angle of the ego state, if available.""" return self._tire_steering_angle - @property - def center(self) -> PoseSE3: - """The :class:`~py123d.geometry.PoseSE2` of the center in SE2.""" - return self.center_se2 - @property def center_se2(self) -> PoseSE2: """The :class:`~py123d.geometry.PoseSE2` of the center in SE2.""" return rear_axle_se2_to_center_se2(rear_axle_se2=self.rear_axle_se2, vehicle_parameters=self.vehicle_parameters) - @property - def bounding_box(self) -> BoundingBoxSE2: - """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" - return self.bounding_box_se2 - @property def bounding_box_se2(self) -> BoundingBoxSE2: """The :class:`~py123d.geometry.BoundingBoxSE2` of the ego vehicle.""" @@ -346,11 +321,6 @@ def bounding_box_se2(self) -> BoundingBoxSE2: width=self.vehicle_parameters.width, ) - @property - def box_detection(self) -> BoxDetectionSE2: - """The :class:`~py123d.datatypes.detections.BoxDetectionSE2` projection of the ego vehicle.""" - return self.box_detection_se2 - @property def box_detection_se2(self) -> BoxDetectionSE2: """The :class:`~py123d.datatypes.detections.BoxDetectionSE2` projection of the ego vehicle.""" diff --git a/src/py123d/geometry/point.py b/src/py123d/geometry/point.py index 76aad41a..98ec405c 100644 --- a/src/py123d/geometry/point.py +++ b/src/py123d/geometry/point.py @@ -136,6 +136,11 @@ def array(self) -> npt.NDArray[np.float64]: """The array representation of shape (3,), indexed by :class:`~py123d.geometry.Point3DIndex`.""" return self._array + @property + def point_3d(self) -> Point3D: + """Returns the :class:`Point3D` instance itself.""" + return self + @property def point_2d(self) -> Point2D: """The 2D projection of the 3D point as a :class:`Point2D` instance.""" @@ -145,8 +150,3 @@ def point_2d(self) -> Point2D: def shapely_point(self) -> geom.Point: """The shapely point representation of the 3D point.""" return geom.Point(self.x, self.y, self.z) - - @property - def point_3d(self) -> Point3D: - """Returns the :class:`Point3D` instance itself.""" - return self diff --git a/src/py123d/geometry/pose.py b/src/py123d/geometry/pose.py index 2d69f355..4cf9ab08 100644 --- a/src/py123d/geometry/pose.py +++ b/src/py123d/geometry/pose.py @@ -75,6 +75,11 @@ def array(self) -> npt.NDArray[np.float64]: """Pose as numpy array of shape (3,), indexed by :class:`~py123d.geometry.geometry_index.PoseSE2Index`.""" return self._array + @property + def pose_se2(self) -> PoseSE2: + """Returns self to match interface of other pose classes.""" + return self + @property def point_2d(self) -> Point2D: """The :class:`~py123d.geometry.Point2D` of the pose, i.e. the translation part.""" @@ -101,11 +106,6 @@ def shapely_point(self) -> geom.Point: """The Shapely point representation of the pose.""" return geom.Point(self.x, self.y) - @property - def pose_se2(self) -> PoseSE2: - """Returns self to match interface of other pose classes.""" - return self - class PoseSE3(ArrayMixin): """Class representing a quaternion in SE3 space @@ -220,6 +220,11 @@ def array(self) -> npt.NDArray[np.float64]: indexed by :class:`~py123d.geometry.geometry_index.PoseSE3Index`""" return self._array + @property + def pose_se3(self) -> PoseSE3: + """The :class:`PoseSE3` itself.""" + return self + @property def pose_se2(self) -> PoseSE2: """The :class:`PoseSE2` representation of the SE3 pose.""" diff --git a/src/py123d/geometry/vector.py b/src/py123d/geometry/vector.py index dba85431..4e31fb4e 100644 --- a/src/py123d/geometry/vector.py +++ b/src/py123d/geometry/vector.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Iterable - import numpy as np import numpy.typing as npt @@ -38,10 +36,10 @@ def __init__(self, x: float, y: float): @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Vector2D: - """Constructs a Vector2D from a numpy array. + """Constructs a :class:`Vector2D` from a numpy array of shape (2,), \ + indexed by :class:`~py123d.geometry.geometry_index.Vector2DIndex`. - :param array: Array of shape (2,) representing the vector components [x, y], indexed by \ - :class:`~py123d.geometry.Vector2DIndex`. + :param array: The array of shape (2,) with the x,y components. :param copy: Whether to copy the input array. Defaults to True. :return: A Vector2D instance. """ @@ -53,27 +51,17 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Vector @property def x(self) -> float: - """The x component of the vector. - - :return: The x component of the vector. - """ + """The x component of the vector.""" return self._array[Vector2DIndex.X] @property def y(self) -> float: - """The y component of the vector. - - :return: The y component of the vector. - """ + """The y component of the vector.""" return self._array[Vector2DIndex.Y] @property def array(self) -> npt.NDArray[np.float64]: - """The array representation of the 2D vector. - - :return: A numpy array of shape (2,) containing the vector components [x, y], indexed by \ - :class:`~py123d.geometry.Vector2DIndex`. - """ + """The vector as array of shape (2,), indexed by :class:`~py123d.geometry.Vector2DIndex`.""" array = np.zeros(len(Vector2DIndex), dtype=np.float64) array[Vector2DIndex.X] = self.x array[Vector2DIndex.Y] = self.y @@ -81,18 +69,12 @@ def array(self) -> npt.NDArray[np.float64]: @property def magnitude(self) -> float: - """Calculates the magnitude (length) of the 2D vector. - - :return: The magnitude of the vector. - """ + """The magnitude (length) of the 2D vector.""" return float(np.linalg.norm(self.array)) @property def vector_2d(self) -> Vector2D: - """The 2D vector itself. Handy for polymorphism. - - :return: A Vector2D instance representing the 2D vector. - """ + """The :class:`Vector2D` itself.""" return self def __add__(self, other: Vector2D) -> Vector2D: @@ -127,14 +109,6 @@ def __truediv__(self, scalar: float) -> Vector2D: """ return Vector2D(self.x / scalar, self.y / scalar) - def __iter__(self) -> Iterable[float]: - """Iterator over vector components.""" - return iter((self.x, self.y)) - - def __hash__(self) -> int: - """Hash method""" - return hash((self.x, self.y)) - class Vector3D(ArrayMixin): """ @@ -166,9 +140,10 @@ def __init__(self, x: float, y: float, z: float): @classmethod def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Vector3D: - """Constructs a Vector3D from a numpy array. + """Constructs a :class:`Vector3D` from a numpy array of shape (3,), \ + indexed by :class:`~py123d.geometry.geometry_index.Vector3DIndex`. - :param array: Array of shape (3,), indexed by :class:`~py123d.geometry.geometry_index.Vector3DIndex`. + :param array: The array of shape (3,) with the x,y,z components. :param copy: Whether to copy the input array. Defaults to True. :return: A Vector3D instance. """ @@ -180,52 +155,37 @@ def from_array(cls, array: npt.NDArray[np.float64], copy: bool = True) -> Vector @property def x(self) -> float: - """The x component of the vector. - - :return: The x component of the vector. - """ + """The x component of the vector.""" return self._array[Vector3DIndex.X] @property def y(self) -> float: - """The y component of the vector. - - :return: The y component of the vector. - """ + """The y component of the vector.""" return self._array[Vector3DIndex.Y] @property def z(self) -> float: - """The z component of the vector. - - :return: The z component of the vector. - """ + """The z component of the vector.""" return self._array[Vector3DIndex.Z] @property def array(self) -> npt.NDArray[np.float64]: - """ - Returns the vector components as a numpy array - - :return: A numpy array representing the vector components [x, y, z], indexed by \ - :class:`~py123d.geometry.geometry_index.Vector3DIndex`. - """ + """The vector as array of shape (3,), indexed by :class:`~py123d.geometry.Vector3DIndex`.""" return self._array @property def magnitude(self) -> float: - """Calculates the magnitude (length) of the 3D vector. - - :return: The magnitude of the vector. - """ + """The magnitude (length) of the 3D vector.""" return float(np.linalg.norm(self.array)) @property - def vector_2d(self) -> Vector2D: - """Returns the 2D vector projection (x, y) of the 3D vector. + def vector_3d(self) -> Vector3D: + """The :class:`Vector3D` itself.""" + return self - :return: A Vector2D instance representing the 2D projection. - """ + @property + def vector_2d(self) -> Vector2D: + """The 2D vector projection (x, y) of the 3D vector.""" return Vector2D(self.x, self.y) def __add__(self, other: Vector3D) -> Vector3D: @@ -259,11 +219,3 @@ def __truediv__(self, scalar: float) -> Vector3D: :return: A new Vector3D instance representing the divided vector. """ return Vector3D(self.x / scalar, self.y / scalar, self.z / scalar) - - def __iter__(self) -> Iterable[float]: - """Iterator over vector components.""" - return iter((self.x, self.y, self.z)) - - def __hash__(self) -> int: - """Hash method""" - return hash((self.x, self.y, self.z)) diff --git a/src/py123d/visualization/viser/elements/map_elements.py b/src/py123d/visualization/viser/elements/map_elements.py index b3a02aa7..c211b982 100644 --- a/src/py123d/visualization/viser/elements/map_elements.py +++ b/src/py123d/visualization/viser/elements/map_elements.py @@ -74,9 +74,9 @@ def _get_map_trimesh_dict( output_trimesh_dict: Dict[MapLayer, trimesh.Trimesh] = {} # Unpack scene center for translation of map objects. - scene_center: Point3D = initial_ego_state.center.point_3d + scene_center: Point3D = initial_ego_state.center_se3.point_3d scene_center_array = scene_center.array - scene_query_position = current_ego_state.center.point_3d + scene_query_position = current_ego_state.center_se3.point_3d # Load map objects within a certain radius around the scene center. map_layers = [ diff --git a/src/py123d/visualization/viser/elements/render_elements.py b/src/py123d/visualization/viser/elements/render_elements.py index 6f247c9f..47784cb2 100644 --- a/src/py123d/visualization/viser/elements/render_elements.py +++ b/src/py123d/visualization/viser/elements/render_elements.py @@ -12,7 +12,7 @@ def get_ego_3rd_person_view_position( iteration: int, initial_ego_state: EgoStateSE3, ) -> PoseSE3: - scene_center_array = initial_ego_state.center.point_3d.array + scene_center_array = initial_ego_state.center_se3.point_3d.array ego_pose = scene.get_ego_state_at_iteration(iteration).rear_axle_se3.array ego_pose[PoseSE3Index.XYZ] -= scene_center_array ego_pose_se3 = PoseSE3.from_array(ego_pose) @@ -37,8 +37,8 @@ def get_ego_bev_view_position( iteration: int, initial_ego_state: EgoStateSE3, ) -> PoseSE3: - scene_center_array = initial_ego_state.center.point_3d.array - ego_center = scene.get_ego_state_at_iteration(iteration).center.array + scene_center_array = initial_ego_state.center_se3.point_3d.array + ego_center = scene.get_ego_state_at_iteration(iteration).center_se3.array ego_center[PoseSE3Index.XYZ] -= scene_center_array ego_center_planar = PoseSE3.from_array(ego_center) diff --git a/src/py123d/visualization/viser/elements/sensor_elements.py b/src/py123d/visualization/viser/elements/sensor_elements.py index b39399b2..3751da6a 100644 --- a/src/py123d/visualization/viser/elements/sensor_elements.py +++ b/src/py123d/visualization/viser/elements/sensor_elements.py @@ -34,7 +34,7 @@ def add_camera_frustums_to_viser_server( camera_frustum_handles: Dict[PinholeCameraType, viser.CameraFrustumHandle], ) -> None: if viser_config.camera_frustum_visible: - scene_center_array = initial_ego_state.center.point_3d.array + scene_center_array = initial_ego_state.center_se3.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array ego_pose[PoseSE3Index.XYZ] -= scene_center_array @@ -86,7 +86,7 @@ def add_fisheye_frustums_to_viser_server( fisheye_frustum_handles: Dict[FisheyeMEICameraType, viser.CameraFrustumHandle], ) -> None: if viser_config.fisheye_frustum_visible: - scene_center_array = initial_ego_state.center.point_3d.array + scene_center_array = initial_ego_state.center_se3.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array ego_pose[PoseSE3Index.XYZ] -= scene_center_array @@ -161,7 +161,7 @@ def add_lidar_pc_to_viser_server( lidar_pc_handles: Dict[LiDARType, Optional[viser.PointCloudHandle]], ) -> None: if viser_config.lidar_visible: - scene_center_array = initial_ego_state.center.point_3d.array + scene_center_array = initial_ego_state.center_se3.point_3d.array ego_pose = scene.get_ego_state_at_iteration(scene_interation).rear_axle_se3.array ego_pose[PoseSE3Index.XYZ] -= scene_center_array From 7065afbb17421640d7c0fb625b678b9df90cbfc2 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Thu, 20 Nov 2025 15:46:00 +0100 Subject: [PATCH 39/50] Fix test after syntax cleanups (#69) --- .../api/scene/arrow/utils/arrow_getters.py | 2 +- .../datasets/av2/av2_sensor_converter.py | 2 +- .../datasets/kitti360/kitti360_converter.py | 2 +- .../datasets/nuplan/utils/nuplan_sql_helper.py | 2 +- .../datasets/nuscenes/nuscenes_converter.py | 2 +- .../datasets/pandaset/pandaset_converter.py | 2 +- .../conversion/datasets/wopd/wopd_converter.py | 2 +- .../datatypes/detections/box_detections.py | 6 +++--- src/py123d/datatypes/vehicle_state/ego_state.py | 6 +++--- .../datatypes/detections/test_box_detections.py | 12 ++++++------ .../vehicle_state/test_dynamic_state.py | 16 ++++++---------- .../datatypes/vehicle_state/test_ego_state.py | 13 +++---------- tests/unit/geometry/test_vector.py | 12 ------------ 13 files changed, 28 insertions(+), 51 deletions(-) diff --git a/src/py123d/api/scene/arrow/utils/arrow_getters.py b/src/py123d/api/scene/arrow/utils/arrow_getters.py index e56204a0..49a24112 100644 --- a/src/py123d/api/scene/arrow/utils/arrow_getters.py +++ b/src/py123d/api/scene/arrow/utils/arrow_getters.py @@ -150,7 +150,7 @@ def get_box_detections_se3_from_arrow_table( timepoint=timepoint, ), bounding_box_se3=BoundingBoxSE3.from_list(_bounding_box_se3), - velocity=_get_optional_array_mixin(_velocity, Vector3D), + velocity_3d=_get_optional_array_mixin(_velocity, Vector3D), ) ) box_detections = BoxDetectionWrapper(box_detections=box_detections_list) diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index e05277a2..3e76fc40 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -278,7 +278,7 @@ def _extract_av2_sensor_box_detections( num_lidar_points=detections_num_lidar_points[detection_idx], ), bounding_box_se3=BoundingBoxSE3.from_array(detections_state[detection_idx]), - velocity=Vector3D.from_array(detections_velocity[detection_idx]), + velocity_3d=Vector3D.from_array(detections_velocity[detection_idx]), ) ) diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index 7a443e47..b58c6a7d 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -664,7 +664,7 @@ def _extract_kitti360_box_detections_all( box_detection = BoxDetectionSE3( metadata=detection_metadata, bounding_box_se3=bounding_box_se3, - velocity=velocity_vector, + velocity_3d=velocity_vector, ) box_detections.append(box_detection) box_detection_wrapper_all.append(BoxDetectionWrapper(box_detections=box_detections)) diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py index b2fa144f..fc70a979 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py @@ -62,7 +62,7 @@ def get_box_detections_for_lidarpc_token_from_db(log_file: str, token: str) -> L track_token=row["track_token"].hex(), ), bounding_box_se3=bounding_box, - velocity=Vector3D(x=row["vx"], y=row["vy"], z=row["vz"]), + velocity_3d=Vector3D(x=row["vx"], y=row["vy"], z=row["vz"]), ) box_detections.append(box_detection) diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index c13bc83c..d2391888 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -404,7 +404,7 @@ def _extract_nuscenes_box_detections(nusc: NuScenes, sample: Dict[str, Any]) -> box_detection = BoxDetectionSE3( metadata=metadata, bounding_box_se3=bounding_box, - velocity=velocity_3d, + velocity_3d=velocity_3d, ) box_detections.append(box_detection) return BoxDetectionWrapper(box_detections=box_detections) diff --git a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py index be374d65..2b0192a6 100644 --- a/src/py123d/conversion/datasets/pandaset/pandaset_converter.py +++ b/src/py123d/conversion/datasets/pandaset/pandaset_converter.py @@ -312,7 +312,7 @@ def _extract_pandaset_box_detections(source_log_path: Path, iteration: int) -> B track_token=box_uuids[box_idx], ), bounding_box_se3=BoundingBoxSE3.from_array(box_se3_array[box_idx]), - velocity=None, + velocity_3d=None, ) box_detections.append(box_detection_se3) diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 453a8c82..5ef60c6d 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -376,7 +376,7 @@ def _extract_wopd_box_detections( track_token=detections_token[detection_idx], ), bounding_box_se3=BoundingBoxSE3.from_array(detections_state[detection_idx]), - velocity=Vector3D.from_array(detections_velocity[detection_idx]), + velocity_3d=Vector3D.from_array(detections_velocity[detection_idx]), ) ) diff --git a/src/py123d/datatypes/detections/box_detections.py b/src/py123d/datatypes/detections/box_detections.py index c5cad085..dd3d9c6f 100644 --- a/src/py123d/datatypes/detections/box_detections.py +++ b/src/py123d/datatypes/detections/box_detections.py @@ -124,17 +124,17 @@ def __init__( self, metadata: BoxDetectionMetadata, bounding_box_se3: BoundingBoxSE3, - velocity: Optional[Vector3D] = None, + velocity_3d: Optional[Vector3D] = None, ) -> None: """Initialize a BoxDetectionSE3 instance. :param metadata: The :class:`BoxDetectionMetadata` of the detection. :param bounding_box_se3: The :class:`~py123d.datatypes.geometry.BoundingBoxSE3` of the detection. - :param velocity: Optionally, a :class:`~py123d.geometry.Vector3D` representing the velocity. + :param velocity_3d: Optionally, a :class:`~py123d.geometry.Vector3D` representing the velocity. """ self._metadata = metadata self._bounding_box_se3 = bounding_box_se3 - self._velocity = velocity + self._velocity = velocity_3d @property def metadata(self) -> BoxDetectionMetadata: diff --git a/src/py123d/datatypes/vehicle_state/ego_state.py b/src/py123d/datatypes/vehicle_state/ego_state.py index 8bfa5f31..f6b40497 100644 --- a/src/py123d/datatypes/vehicle_state/ego_state.py +++ b/src/py123d/datatypes/vehicle_state/ego_state.py @@ -175,7 +175,7 @@ def box_detection_se3(self) -> BoxDetectionSE3: num_lidar_points=None, ), bounding_box_se3=self.bounding_box_se3, - velocity=self.dynamic_state_se3.velocity, + velocity_3d=self.dynamic_state_se3.velocity_3d if self.dynamic_state_se3 else None, ) @property @@ -331,6 +331,6 @@ def box_detection_se2(self) -> BoxDetectionSE2: track_token=EGO_TRACK_TOKEN, num_lidar_points=None, ), - bounding_box_se2=self.bounding_box, - velocity_2d=self.dynamic_state_se2.velocity, + bounding_box_se2=self.bounding_box_se2, + velocity_2d=self.dynamic_state_se2.velocity_2d if self.dynamic_state_se2 else None, ) diff --git a/tests/unit/datatypes/detections/test_box_detections.py b/tests/unit/datatypes/detections/test_box_detections.py index 59509efa..891bed1c 100644 --- a/tests/unit/datatypes/detections/test_box_detections.py +++ b/tests/unit/datatypes/detections/test_box_detections.py @@ -154,7 +154,7 @@ def test_initialization(self): box_detection = BoxDetectionSE3( metadata=self.metadata, bounding_box_se3=self.bounding_box_se3, - velocity=self.velocity, + velocity_3d=self.velocity, ) assert isinstance(box_detection, BoxDetectionSE3) assert box_detection.metadata == self.metadata @@ -165,7 +165,7 @@ def test_properties(self): box_detection = BoxDetectionSE3( metadata=self.metadata, bounding_box_se3=self.bounding_box_se3, - velocity=self.velocity, + velocity_3d=self.velocity, ) assert box_detection.shapely_polygon == self.bounding_box_se3.shapely_polygon assert box_detection.center_se3 == self.bounding_box_se3.center_se3 @@ -179,7 +179,7 @@ def test_box_detection_se2_conversion(self): box_detection = BoxDetectionSE3( metadata=self.metadata, bounding_box_se3=self.bounding_box_se3, - velocity=Vector3D(x=1.0, y=0.0, z=0.0), + velocity_3d=Vector3D(x=1.0, y=0.0, z=0.0), ) box_detection_se2 = box_detection.box_detection_se2 assert isinstance(box_detection_se2, BoxDetectionSE2) @@ -196,7 +196,7 @@ def test_box_detection_se3_conversion(self): box_detection_se3 = BoxDetectionSE3( metadata=box_detection_se2.metadata, bounding_box_se3=self.bounding_box_se3, - velocity=Vector3D(x=1.0, y=0.0, z=0.0), + velocity_3d=Vector3D(x=1.0, y=0.0, z=0.0), ) assert isinstance(box_detection_se3, BoxDetectionSE3) assert box_detection_se3.metadata == box_detection_se2.metadata @@ -220,7 +220,7 @@ def test_optional_velocity(self): box_detection_velo = BoxDetectionSE3( metadata=self.metadata, bounding_box_se3=self.bounding_box_se3, - velocity=Vector3D(x=1.0, y=0.0, z=0.0), + velocity_3d=Vector3D(x=1.0, y=0.0, z=0.0), ) assert isinstance(box_detection_velo, BoxDetectionSE3) assert box_detection_velo.velocity_3d == Vector3D(x=1.0, y=0.0, z=0.0) @@ -273,7 +273,7 @@ def setup_method(self): width=1.0, height=1.5, ), - velocity=Vector3D(x=0.0, y=1.0, z=0.0), + velocity_3d=Vector3D(x=0.0, y=1.0, z=0.0), ) def test_initialization(self): diff --git a/tests/unit/datatypes/vehicle_state/test_dynamic_state.py b/tests/unit/datatypes/vehicle_state/test_dynamic_state.py index 0f1cb472..b89950d1 100644 --- a/tests/unit/datatypes/vehicle_state/test_dynamic_state.py +++ b/tests/unit/datatypes/vehicle_state/test_dynamic_state.py @@ -17,8 +17,8 @@ def test_init(self): state = DynamicStateSE3(velocity, acceleration, angular_velocity) - assert np.allclose(state.velocity.array, velocity.array) - assert np.allclose(state.acceleration.array, acceleration.array) + assert np.allclose(state.velocity_3d.array, velocity.array) + assert np.allclose(state.acceleration_3d.array, acceleration.array) assert np.allclose(state.angular_velocity.array, angular_velocity.array) def test_from_array(self): @@ -57,8 +57,8 @@ def test_dynamic_state_se2_projection(self): state_se3 = DynamicStateSE3(velocity, acceleration, angular_velocity) state_se2 = state_se3.dynamic_state_se2 - assert np.allclose(state_se2.velocity.array, [1.0, 2.0]) - assert np.allclose(state_se2.acceleration.array, [4.0, 5.0]) + assert np.allclose(state_se2.velocity_2d.array, [1.0, 2.0]) + assert np.allclose(state_se2.acceleration_2d.array, [4.0, 5.0]) assert np.isclose(state_se2.angular_velocity, 9.0) @@ -70,8 +70,8 @@ def test_init(self): state = DynamicStateSE2(velocity, acceleration, angular_velocity) - assert np.allclose(state.velocity.array, velocity.array) - assert np.allclose(state.acceleration.array, acceleration.array) + assert np.allclose(state.velocity_2d.array, velocity.array) + assert np.allclose(state.acceleration_2d.array, acceleration.array) assert np.isclose(state.angular_velocity, angular_velocity) assert len(state.array) == len(DynamicStateSE2Index) @@ -84,15 +84,11 @@ def test_from_array(self): def test_velocity_properties(self): velocity = Vector2D(1.0, 2.0) state = DynamicStateSE2(velocity, Vector2D(0, 0), 0.0) - - assert np.allclose(state.velocity.array, [1.0, 2.0]) assert np.allclose(state.velocity_2d.array, [1.0, 2.0]) def test_acceleration_properties(self): acceleration = Vector2D(3.0, 4.0) state = DynamicStateSE2(Vector2D(0, 0), acceleration, 0.0) - - assert np.allclose(state.acceleration.array, [3.0, 4.0]) assert np.allclose(state.acceleration_2d.array, [3.0, 4.0]) def test_angular_velocity_property(self): diff --git a/tests/unit/datatypes/vehicle_state/test_ego_state.py b/tests/unit/datatypes/vehicle_state/test_ego_state.py index 407f6a18..d07de432 100644 --- a/tests/unit/datatypes/vehicle_state/test_ego_state.py +++ b/tests/unit/datatypes/vehicle_state/test_ego_state.py @@ -11,6 +11,7 @@ ) from py123d.datatypes.vehicle_state.ego_state import EGO_TRACK_TOKEN from py123d.geometry import PoseSE2, PoseSE3, Vector2D, Vector3D +from py123d.geometry.bounding_box import BoundingBoxSE2 class TestEgoStateSE2: @@ -80,8 +81,6 @@ def test_from_center(self): def test_rear_axle_property(self): """Test rear_axle property.""" ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) - - assert ego_state.rear_axle == self.rear_axle_pose assert ego_state.rear_axle_se2 == self.rear_axle_pose def test_center_property(self): @@ -99,10 +98,11 @@ def test_bounding_box_property(self): ego_state = EgoStateSE2(rear_axle_se2=self.rear_axle_pose, vehicle_parameters=self.vehicle_params) bbox = ego_state.bounding_box_se2 + bbox_center = BoundingBoxSE2(ego_state.center_se2, self.vehicle_params.length, self.vehicle_params.width) assert bbox is not None assert bbox.length == self.vehicle_params.length assert bbox.width == self.vehicle_params.width - assert ego_state.bounding_box == bbox + assert ego_state.bounding_box_se2 == bbox_center def test_box_detection_property(self): """Test box detection properties.""" @@ -119,12 +119,6 @@ def test_box_detection_property(self): assert box_det.metadata.track_token == EGO_TRACK_TOKEN assert box_det.metadata.timepoint == self.timepoint - box_det = ego_state.box_detection - assert box_det is not None - assert box_det.metadata.label == DefaultBoxDetectionLabel.EGO - assert box_det.metadata.track_token == EGO_TRACK_TOKEN - assert box_det.metadata.timepoint == self.timepoint - def test_optional_parameters_none(self): """Test EgoStateSE2 with optional parameters as None.""" ego_state = EgoStateSE2( @@ -229,7 +223,6 @@ def test_center_properties(self): center_se2 = ego_state.center_se2 assert center_se2 is not None - assert ego_state.center == ego_state.center_se3 def test_bounding_box_properties(self): """Test bounding box properties.""" diff --git a/tests/unit/geometry/test_vector.py b/tests/unit/geometry/test_vector.py index 71379c57..60e5e051 100644 --- a/tests/unit/geometry/test_vector.py +++ b/tests/unit/geometry/test_vector.py @@ -76,12 +76,6 @@ def test_iter(self): assert x == self.x_coord assert y == self.y_coord - def test_hash(self): - """Test the __hash__ method.""" - vector_dict = {self.vector: "test"} - assert self.vector in vector_dict - assert vector_dict[self.vector] == "test" - class TestVector3D: """Unit tests for Vector3D class.""" @@ -159,9 +153,3 @@ def test_iter(self): assert x == self.x_coord assert y == self.y_coord assert z == self.z_coord - - def test_hash(self): - """Test the __hash__ method.""" - vector_dict = {self.vector: "test"} - assert self.vector in vector_dict - assert vector_dict[self.vector] == "test" From 51bec69127880caaabe39988ecbfa596cf232ce2 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Thu, 20 Nov 2025 15:53:24 +0100 Subject: [PATCH 40/50] Try adding python 3.14 (probably failing) --- .github/workflows/pytest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index d0c101ad..51c3f568 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v2 From e2ec6320d7c8c305b2268f735aba03cab338b7ad Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 00:07:40 +0100 Subject: [PATCH 41/50] Add tutorial for scene and map (#70) --- .github/workflows/pytest.yaml | 2 +- .../api/scene/arrow/arrow_scene_builder.py | 6 +- src/py123d/api/scene/scene_filter.py | 3 + src/py123d/api/scene/scene_metadata.py | 7 + src/py123d/common/utils/mixin.py | 15 + .../datasets/av2/av2_map_conversion.py | 4 +- src/py123d/datatypes/metadata/log_metadata.py | 12 + src/py123d/datatypes/metadata/map_metadata.py | 13 + .../datatypes/sensors/fisheye_mei_camera.py | 10 +- .../datatypes/sensors/pinhole_camera.py | 10 +- src/py123d/datatypes/time/time_point.py | 4 + src/py123d/geometry/bounding_box.py | 10 +- src/py123d/geometry/point.py | 10 +- src/py123d/geometry/polyline.py | 2 +- src/py123d/geometry/pose.py | 14 +- src/py123d/geometry/rotation.py | 10 +- src/py123d/geometry/vector.py | 10 +- src/py123d/visualization/color/default.py | 9 + .../visualization/matplotlib/observation.py | 21 +- src/py123d/visualization/matplotlib/utils.py | 4 + tutorial/01_scene_tutorial.ipynb | 440 ++++++++++ tutorial/02_map_tutorial.ipynb | 805 ++++++++++++++++++ 22 files changed, 1405 insertions(+), 16 deletions(-) create mode 100644 tutorial/01_scene_tutorial.ipynb create mode 100644 tutorial/02_map_tutorial.ipynb diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 51c3f568..d0c101ad 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v2 diff --git a/src/py123d/api/scene/arrow/arrow_scene_builder.py b/src/py123d/api/scene/arrow/arrow_scene_builder.py index 12b55fed..eba90b24 100644 --- a/src/py123d/api/scene/arrow/arrow_scene_builder.py +++ b/src/py123d/api/scene/arrow/arrow_scene_builder.py @@ -116,8 +116,10 @@ def _get_scene_extraction_metadatas(log_path: Union[str, Path], filter: SceneFil else len(recording_table) ) - # 1. Filter location - if ( + # 1. Filter location & whether map API is required + if filter.map_api_required and log_metadata.map_metadata is None: + pass + elif ( filter.locations is not None and log_metadata.map_metadata is not None and log_metadata.map_metadata.location not in filter.locations diff --git a/src/py123d/api/scene/scene_filter.py b/src/py123d/api/scene/scene_filter.py index a875e91c..a11c7b03 100644 --- a/src/py123d/api/scene/scene_filter.py +++ b/src/py123d/api/scene/scene_filter.py @@ -45,6 +45,9 @@ class SceneFilter: max_num_scenes: Optional[int] = None """Maximum number of scenes to return.""" + map_api_required: bool = False + """Whether to only include scenes with an available map API.""" + shuffle: bool = False """Whether to shuffle the returned scenes.""" diff --git a/src/py123d/api/scene/scene_metadata.py b/src/py123d/api/scene/scene_metadata.py index 0bbe2e1d..735ea931 100644 --- a/src/py123d/api/scene/scene_metadata.py +++ b/src/py123d/api/scene/scene_metadata.py @@ -34,3 +34,10 @@ def number_of_history_iterations(self) -> int: def end_idx(self) -> int: """Index of the end frame of the scene.""" return self.initial_idx + self.number_of_iterations + + def __repr__(self) -> str: + return ( + f"SceneMetadata(initial_uuid={self.initial_uuid}, initial_idx={self.initial_idx}, " + f"duration_s={self.duration_s}, history_s={self.history_s}, " + f"iteration_duration_s={self.iteration_duration_s})" + ) diff --git a/src/py123d/common/utils/mixin.py b/src/py123d/common/utils/mixin.py index 3813ef12..a2f0a1fd 100644 --- a/src/py123d/common/utils/mixin.py +++ b/src/py123d/common/utils/mixin.py @@ -1,5 +1,7 @@ from __future__ import annotations +from enum import IntEnum + import numpy as np import numpy.typing as npt from typing_extensions import Self @@ -85,3 +87,16 @@ def copy(self) -> ArrayMixin: def __repr__(self) -> str: """String representation of the ArrayMixin instance.""" return f"{self.__class__.__name__}(array={self.array})" + + +def indexed_array_repr(array_mixin: ArrayMixin, indexing: IntEnum) -> str: + """Generate a string representation of an ArrayMixin instance using an indexing enum. + + :param array_mixin: An instance of ArrayMixin. + :param indexing: An IntEnum used for indexing the array. + :return: A string representation of the ArrayMixin instance with named fields. + """ + args = ", ".join( + f"{index.name.lower()}={array_mixin.array[index.value]}" for index in indexing.__members__.values() + ) + return f"{array_mixin.__class__.__name__}({args})" diff --git a/src/py123d/conversion/datasets/av2/av2_map_conversion.py b/src/py123d/conversion/datasets/av2/av2_map_conversion.py index 504ffb63..ecbfd466 100644 --- a/src/py123d/conversion/datasets/av2/av2_map_conversion.py +++ b/src/py123d/conversion/datasets/av2/av2_map_conversion.py @@ -141,8 +141,8 @@ def _get_centerline_from_boundaries( ) # NOTE @DanielDauner: Some neighbor lane IDs might not be present in the dataset. - left_lane_id = lane_dict["left_neighbor_id"] if lane_dict["left_neighbor_id"] in lanes else None - right_lane_id = lane_dict["right_neighbor_id"] if lane_dict["right_neighbor_id"] in lanes else None + left_lane_id = lane_dict["left_neighbor_id"] if str(lane_dict["left_neighbor_id"]) in lanes else None + right_lane_id = lane_dict["right_neighbor_id"] if str(lane_dict["right_neighbor_id"]) in lanes else None map_writer.write_lane( Lane( diff --git a/src/py123d/datatypes/metadata/log_metadata.py b/src/py123d/datatypes/metadata/log_metadata.py index 53c80a4e..d78b5317 100644 --- a/src/py123d/datatypes/metadata/log_metadata.py +++ b/src/py123d/datatypes/metadata/log_metadata.py @@ -211,3 +211,15 @@ def to_dict(self) -> Dict: data_dict["lidar_metadata"] = {key.serialize(): value.to_dict() for key, value in self.lidar_metadata.items()} data_dict["map_metadata"] = self.map_metadata.to_dict() if self.map_metadata else None return data_dict + + def __repr__(self) -> str: + return ( + f"LogMetadata(dataset={self.dataset}, split={self.split}, log_name={self.log_name}, " + f"location={self.location}, timestep_seconds={self.timestep_seconds}, " + f"vehicle_parameters={self.vehicle_parameters}, " + f"box_detection_label_class={self.box_detection_label_class}, " + f"pinhole_camera_metadata={self.pinhole_camera_metadata}, " + f"fisheye_mei_camera_metadata={self.fisheye_mei_camera_metadata}, " + f"lidar_metadata={self.lidar_metadata}, map_metadata={self.map_metadata}, " + f"version={self.version})" + ) diff --git a/src/py123d/datatypes/metadata/map_metadata.py b/src/py123d/datatypes/metadata/map_metadata.py index de04b360..9cd6511a 100644 --- a/src/py123d/datatypes/metadata/map_metadata.py +++ b/src/py123d/datatypes/metadata/map_metadata.py @@ -90,3 +90,16 @@ def to_dict(self) -> Dict[str, Any]: :return: A dictionary representation of the MapMetadata instance. """ return {slot.lstrip("_"): getattr(self, slot) for slot in self.__slots__} + + def __repr__(self) -> str: + return ( + f"MapMetadata(" + f"dataset={self.dataset!r}, " + f"split={self.split!r}, " + f"log_name={self.log_name!r}, " + f"location={self.location!r}, " + f"map_has_z={self.map_has_z}, " + f"map_is_local={self.map_is_local}, " + f"version={self.version!r}" + f")" + ) diff --git a/src/py123d/datatypes/sensors/fisheye_mei_camera.py b/src/py123d/datatypes/sensors/fisheye_mei_camera.py index ffbeccf4..32e70ced 100644 --- a/src/py123d/datatypes/sensors/fisheye_mei_camera.py +++ b/src/py123d/datatypes/sensors/fisheye_mei_camera.py @@ -8,7 +8,7 @@ import numpy.typing as npt from py123d.common.utils.enums import SerialIntEnum -from py123d.common.utils.mixin import ArrayMixin +from py123d.common.utils.mixin import ArrayMixin, indexed_array_repr from py123d.geometry.pose import PoseSE3 @@ -134,6 +134,10 @@ def p2(self) -> float: """Tangential distortion coefficient.""" return self._array[FisheyeMEIDistortionIndex.P2] + def __repr__(self) -> str: + """String representation of :class:`FisheyeMEIDistortion`.""" + return indexed_array_repr(self, FisheyeMEIDistortionIndex) + class FisheyeMEIProjectionIndex(IntEnum): """Indexing for fisheye MEI projection parameters.""" @@ -212,6 +216,10 @@ def v0(self) -> float: """Principal point y-coordinate.""" return self._array[FisheyeMEIProjectionIndex.V0] + def __repr__(self) -> str: + """String representation of :class:`FisheyeMEIProjection`.""" + return indexed_array_repr(self, FisheyeMEIProjectionIndex) + @dataclass class FisheyeMEICameraMetadata: diff --git a/src/py123d/datatypes/sensors/pinhole_camera.py b/src/py123d/datatypes/sensors/pinhole_camera.py index 683ada0a..5755c944 100644 --- a/src/py123d/datatypes/sensors/pinhole_camera.py +++ b/src/py123d/datatypes/sensors/pinhole_camera.py @@ -7,7 +7,7 @@ import numpy.typing as npt from py123d.common.utils.enums import SerialIntEnum -from py123d.common.utils.mixin import ArrayMixin +from py123d.common.utils.mixin import ArrayMixin, indexed_array_repr from py123d.geometry import PoseSE3 @@ -197,6 +197,10 @@ def camera_matrix(self) -> npt.NDArray[np.float64]: ) return K + def __repr__(self) -> str: + """String representation of :class:`PinholeIntrinsics`.""" + return indexed_array_repr(self, PinholeIntrinsicsIndex) + class PinholeDistortionIndex(IntEnum): """Enumeration of pinhole camera distortion parameters.""" @@ -284,6 +288,10 @@ def k3(self) -> float: """Radial distortion coefficient k3.""" return self._array[PinholeDistortionIndex.K3] + def __repr__(self) -> str: + """String representation of :class:`PinholeDistortion`.""" + return indexed_array_repr(self, PinholeDistortionIndex) + class PinholeCameraMetadata: """Static metadata for a pinhole camera, stored in a log.""" diff --git a/src/py123d/datatypes/time/time_point.py b/src/py123d/datatypes/time/time_point.py index be927d8e..2162fb02 100644 --- a/src/py123d/datatypes/time/time_point.py +++ b/src/py123d/datatypes/time/time_point.py @@ -72,3 +72,7 @@ def time_ms(self) -> float: def time_s(self) -> float: """The timepoint in seconds [s].""" return self._time_us / 1e6 + + def __repr__(self): + """String representation of :class:`TimePoint`.""" + return f"TimePoint(time_us={self._time_us})" diff --git a/src/py123d/geometry/bounding_box.py b/src/py123d/geometry/bounding_box.py index e36b5661..3ac375fc 100644 --- a/src/py123d/geometry/bounding_box.py +++ b/src/py123d/geometry/bounding_box.py @@ -6,7 +6,7 @@ import numpy.typing as npt import shapely.geometry as geom -from py123d.common.utils.mixin import ArrayMixin +from py123d.common.utils.mixin import ArrayMixin, indexed_array_repr from py123d.geometry.geometry_index import BoundingBoxSE2Index, BoundingBoxSE3Index, Corners2DIndex, Corners3DIndex from py123d.geometry.point import Point2D, Point3D from py123d.geometry.pose import PoseSE2, PoseSE3 @@ -104,6 +104,10 @@ def bounding_box_se2(self) -> BoundingBoxSE2: """The :class:`BoundingBoxSE2` instance itself.""" return self + def __repr__(self) -> str: + """String representation of :class:`BoundingBoxSE2`.""" + return indexed_array_repr(self, BoundingBoxSE2Index) + class BoundingBoxSE3(ArrayMixin): """ @@ -212,5 +216,9 @@ def shapely_polygon(self) -> geom.Polygon: """The shapely polygon representation of the SE2 projection of the bounding box.""" return self.bounding_box_se2.shapely_polygon + def __repr__(self) -> str: + """String representation of :class:`BoundingBoxSE3`.""" + return indexed_array_repr(self, BoundingBoxSE3Index) + BoundingBox = Union[BoundingBoxSE2, BoundingBoxSE3] diff --git a/src/py123d/geometry/point.py b/src/py123d/geometry/point.py index 98ec405c..14cf74c1 100644 --- a/src/py123d/geometry/point.py +++ b/src/py123d/geometry/point.py @@ -4,7 +4,7 @@ import numpy.typing as npt import shapely.geometry as geom -from py123d.common.utils.mixin import ArrayMixin +from py123d.common.utils.mixin import ArrayMixin, indexed_array_repr from py123d.geometry.geometry_index import Point2DIndex, Point3DIndex @@ -73,6 +73,10 @@ def point_2d(self) -> Point2D: """Returns the :class:`Point2D` instance itself.""" return self + def __repr__(self) -> str: + """String representation of :class:`Point2D`.""" + return indexed_array_repr(self, Point2DIndex) + class Point3D(ArrayMixin): """Class presenting a 3D point. @@ -150,3 +154,7 @@ def point_2d(self) -> Point2D: def shapely_point(self) -> geom.Point: """The shapely point representation of the 3D point.""" return geom.Point(self.x, self.y, self.z) + + def __repr__(self) -> str: + """String representation of :class:`Point3D`.""" + return indexed_array_repr(self, Point3DIndex) diff --git a/src/py123d/geometry/polyline.py b/src/py123d/geometry/polyline.py index 90974f5e..5bd61eb7 100644 --- a/src/py123d/geometry/polyline.py +++ b/src/py123d/geometry/polyline.py @@ -352,7 +352,7 @@ def polyline_2d(self) -> Polyline2D: @property def polyline_se2(self) -> PolylineSE2: """The :class:`~py123d.geometry.PolylineSE2` representation of the 3D polyline.""" - return PolylineSE2.from_linestring(self.linestring) # type: ignore + return PolylineSE2.from_linestring(self.linestring) # type: ignore\ @property def length(self) -> float: diff --git a/src/py123d/geometry/pose.py b/src/py123d/geometry/pose.py index 4cf9ab08..802ee116 100644 --- a/src/py123d/geometry/pose.py +++ b/src/py123d/geometry/pose.py @@ -4,7 +4,7 @@ import numpy.typing as npt import shapely.geometry as geom -from py123d.common.utils.mixin import ArrayMixin +from py123d.common.utils.mixin import ArrayMixin, indexed_array_repr from py123d.geometry.geometry_index import EulerPoseSE3Index, Point3DIndex, PoseSE2Index, PoseSE3Index from py123d.geometry.point import Point2D, Point3D from py123d.geometry.rotation import EulerAngles, Quaternion @@ -106,6 +106,10 @@ def shapely_point(self) -> geom.Point: """The Shapely point representation of the pose.""" return geom.Point(self.x, self.y) + def __repr__(self) -> str: + """String representation of :class:`PoseSE2`.""" + return indexed_array_repr(self, PoseSE2Index) + class PoseSE3(ArrayMixin): """Class representing a quaternion in SE3 space @@ -283,6 +287,10 @@ def transformation_matrix(self) -> npt.NDArray[np.float64]: transformation_matrix[:3, 3] = self.array[PoseSE3Index.XYZ] return transformation_matrix + def __repr__(self) -> str: + """String representation of :class:`PoseSE3`.""" + return indexed_array_repr(self, PoseSE3Index) + class EulerPoseSE3(ArrayMixin): """ @@ -478,3 +486,7 @@ def quaternion(self) -> Quaternion: :return: A Quaternion instance representing the state's orientation. """ return Quaternion.from_euler_angles(self.euler_angles) + + def __repr__(self) -> str: + """String representation of :class:`EulerPoseSE3`.""" + return indexed_array_repr(self, EulerPoseSE3Index) diff --git a/src/py123d/geometry/rotation.py b/src/py123d/geometry/rotation.py index 1a9bcab1..29607098 100644 --- a/src/py123d/geometry/rotation.py +++ b/src/py123d/geometry/rotation.py @@ -4,7 +4,7 @@ import numpy.typing as npt import pyquaternion -from py123d.common.utils.mixin import ArrayMixin +from py123d.common.utils.mixin import ArrayMixin, indexed_array_repr from py123d.geometry.geometry_index import EulerAnglesIndex, QuaternionIndex from py123d.geometry.utils.rotation_utils import ( get_euler_array_from_quaternion_array, @@ -117,6 +117,10 @@ def rotation_matrix(self) -> npt.NDArray[np.float64]: """Returns the 3x3 rotation matrix representation of the Euler angles.""" return get_rotation_matrix_from_euler_array(self.array) + def __repr__(self) -> str: + """String representation of :class:`EulerAngles`.""" + return indexed_array_repr(self, EulerAnglesIndex) + class Quaternion(ArrayMixin): """ @@ -234,3 +238,7 @@ def euler_angles(self) -> EulerAngles: def rotation_matrix(self) -> npt.NDArray[np.float64]: """Returns the 3x3 rotation matrix representation of the quaternion.""" return get_rotation_matrix_from_quaternion_array(self.array) + + def __repr__(self) -> str: + """String representation of :class:`Quaternion`.""" + return indexed_array_repr(self, QuaternionIndex) diff --git a/src/py123d/geometry/vector.py b/src/py123d/geometry/vector.py index 4e31fb4e..90809c19 100644 --- a/src/py123d/geometry/vector.py +++ b/src/py123d/geometry/vector.py @@ -3,7 +3,7 @@ import numpy as np import numpy.typing as npt -from py123d.common.utils.mixin import ArrayMixin +from py123d.common.utils.mixin import ArrayMixin, indexed_array_repr from py123d.geometry.geometry_index import Vector2DIndex, Vector3DIndex @@ -109,6 +109,10 @@ def __truediv__(self, scalar: float) -> Vector2D: """ return Vector2D(self.x / scalar, self.y / scalar) + def __repr__(self) -> str: + """String representation of :class:`Vector2D`.""" + return indexed_array_repr(self, Vector2DIndex) + class Vector3D(ArrayMixin): """ @@ -219,3 +223,7 @@ def __truediv__(self, scalar: float) -> Vector3D: :return: A new Vector3D instance representing the divided vector. """ return Vector3D(self.x / scalar, self.y / scalar, self.z / scalar) + + def __repr__(self) -> str: + """String representation of :class:`Vector3D`.""" + return indexed_array_repr(self, Vector3DIndex) diff --git a/src/py123d/visualization/color/default.py b/src/py123d/visualization/color/default.py index 26b902b5..37dea1f6 100644 --- a/src/py123d/visualization/color/default.py +++ b/src/py123d/visualization/color/default.py @@ -81,6 +81,15 @@ line_style="-", zorder=1, ), + MapLayer.STOP_ZONE: PlotConfig( + fill_color=TAB_10[3], + fill_color_alpha=1.0, + line_color=TAB_10[3], + line_color_alpha=0.0, + line_width=1.0, + line_style="-", + zorder=1, + ), } diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index 884e6c39..fca429b1 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -58,7 +58,12 @@ def add_default_map_on_ax( if route_lane_group_ids is not None and int(map_object.object_id) in route_lane_group_ids: add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, ROUTE_CONFIG) else: - add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer]) + add_shapely_polygon_to_ax( + ax, + map_object.shapely_polygon, + MAP_SURFACE_CONFIG[layer], + label=layer.serialize(), + ) if layer in [ MapLayer.GENERIC_DRIVABLE, MapLayer.CARPARK, @@ -66,10 +71,20 @@ def add_default_map_on_ax( MapLayer.INTERSECTION, MapLayer.WALKWAY, ]: - add_shapely_polygon_to_ax(ax, map_object.shapely_polygon, MAP_SURFACE_CONFIG[layer]) + add_shapely_polygon_to_ax( + ax, + map_object.shapely_polygon, + MAP_SURFACE_CONFIG[layer], + label=layer.serialize(), + ) if layer in [MapLayer.LANE]: map_object: Lane - add_shapely_linestring_to_ax(ax, map_object.centerline.linestring, CENTERLINE_CONFIG) + add_shapely_linestring_to_ax( + ax, + map_object.centerline.linestring, + CENTERLINE_CONFIG, + label=layer.serialize(), + ) except Exception: print(f"Error adding map object of type {layer.name} and id {map_object.object_id}") traceback.print_exc() diff --git a/src/py123d/visualization/matplotlib/utils.py b/src/py123d/visualization/matplotlib/utils.py index bffdfd40..a4cfeab0 100644 --- a/src/py123d/visualization/matplotlib/utils.py +++ b/src/py123d/visualization/matplotlib/utils.py @@ -16,6 +16,7 @@ def add_shapely_polygon_to_ax( polygon: geom.Polygon, plot_config: PlotConfig, disable_smoothing: bool = False, + label: str = None, ) -> plt.Axes: """ Adds shapely polygon to birds-eye-view visualization with proper hole handling @@ -59,6 +60,7 @@ def create_polygon_path(polygon): edgecolor=plot_config.line_color.hex, linewidth=plot_config.line_width, zorder=plot_config.zorder, + label=label, ) ax.add_patch(patch) @@ -77,6 +79,7 @@ def add_shapely_linestring_to_ax( ax: plt.Axes, linestring: geom.LineString, plot_config: PlotConfig, + label: str = None, ) -> plt.Axes: """ Adds shapely linestring (polyline) to birds-eye-view visualization @@ -95,6 +98,7 @@ def add_shapely_linestring_to_ax( linewidth=plot_config.line_width, linestyle=plot_config.line_style, zorder=plot_config.zorder, + label=label, ) return ax diff --git a/tutorial/01_scene_tutorial.ipynb b/tutorial/01_scene_tutorial.ipynb new file mode 100644 index 00000000..1951c8e2 --- /dev/null +++ b/tutorial/01_scene_tutorial.ipynb @@ -0,0 +1,440 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "

\n", + " \n", + " \n", + " \n", + " \"Logo\"\n", + " \n", + "

123D: Scene Tutorial

\n", + "" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from py123d.api import SceneAPI, SceneFilter\n", + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.common.multithreading.worker_parallel import SingleMachineParallelExecutor" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## 1.1 Download Demo Logs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 1.2 Create Scenes by filtering the datasets\n", + "\n", + "\n", + "\n", + "The logs store continuous driving recordings. Scenes in 123D are sequences that are extracted from a log, e.g. given a predefined duration and history.\n", + "\n", + "In the example below, we filter some scenes from all logs with 8 second duration and 8 seconds temporal distance (making the scenes non-overlapping). If `None` is passed to the duration, the scene will contain the complete log.\n", + "\n", + "This `SceneFilter` object is passed to a `SceneBuilder` object to query `SceneAPI`'s from the dataset.\n", + "\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.datatypes.sensors import PinholeCamera, PinholeCameraType\n", + "\n", + "pinhole_camera_types = [PinholeCameraType.PCAM_B0]\n", + "scene_filter = SceneFilter(\n", + " split_names=None,\n", + " log_names=None,\n", + " scene_uuids=None,\n", + " duration_s=8.0,\n", + " history_s=0.0,\n", + " timestamp_threshold_s=8.0,\n", + " pinhole_camera_types=[PinholeCameraType.PCAM_B0],\n", + " shuffle=True,\n", + ")\n", + "scene_builder = ArrowSceneBuilder()\n", + "worker = SingleMachineParallelExecutor()\n", + "\n", + "# worker = RayDistributed()\n", + "scenes = scene_builder.get_scenes(scene_filter, worker)\n", + "print(f\"Found {len(scenes)} scenes.\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 1.2 Inspecting the Scene\n", + "\n", + "Let's inspect a random scenefrom our dataset.\n", + "\n", + "A scene has several different metadata objects attached to it:\n", + "\n", + "`SceneMetadata`: Information how the scene was extracted from the log. Each timestep in the log has universally unique identifier (UUID). The UUID of the initial timestep also serves as identifier for scene filtering." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "scene: SceneAPI = np.random.choice(scenes)\n", + "scene_metadata = scene.scene_metadata\n", + "print(scene_metadata)\n", + "print(\"\\nInitial UUID:\", scene_metadata.initial_uuid)\n", + "print(\"Number of iterations:\", scene_metadata.number_of_iterations)\n", + "print(\"Number of history iterations:\", scene_metadata.number_of_history_iterations)\n", + "print(\"Duration (s):\", scene_metadata.duration_s)\n", + "print(\"Iteration duration (s):\", scene_metadata.iteration_duration_s)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "`LogMetadata`: Information of the log the scene was extracted from. This object also includes data about the map (if available), or static information of the ego vehicle, e.g. the included sensors and vehicle parameters" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "log_metadata = scene.log_metadata\n", + "log_metadata" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "`MapMetadata`: If the map is available, this object includes information about the location, wether the map is 3D (`map_has_z`), of the the map is defined per log (`map_is_local`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "map_metadata = scene.map_metadata\n", + "map_metadata" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## 1.3 Retrieving data from the `SceneAPI`\n", + "\n", + "Different datasets might provide different modalities. In general, you can load data using a `scene.get_modality_at_iteration(iteration=...)`\n", + "\n", + "If a modality is not available, the return will be `None`. The `TimePoint` is the only modality that is strictly required to be available in a `Scene\n", + "\n", + "Let's look at some examples:\n" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "### 1.3.1 `TimePoint`\n", + "\n", + "Current time step in microseconds." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.datatypes.time import TimePoint\n", + "\n", + "iteration = 10\n", + "timepoint: TimePoint = scene.get_timepoint_at_iteration(iteration=iteration)\n", + "print(f\"Time at iteration {iteration}:\", timepoint)" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "### 1.3.2 `EgoStateSE3` \n", + "State of the ego vehicle in 3D with location and orientation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.datatypes.vehicle_state import EgoStateSE3\n", + "\n", + "if (ego_state := scene.get_ego_state_at_iteration(iteration=iteration)) is not None:\n", + " ego_state: EgoStateSE3\n", + "\n", + " print(\"Vehicle parameters\\t\", ego_state.vehicle_parameters)\n", + "\n", + " # The ego vehicles coordinate system is defined by it's rear-axle / IMU location.\n", + " print(\"Rear axle location:\\t\", ego_state.rear_axle_se3.point_3d)\n", + " print(\"Rear axle orientation:\\t\", ego_state.rear_axle_se3.quaternion)\n", + "\n", + " # You can also use the center pose\n", + " print(\"Center location:\\t\", ego_state.center_se3.point_3d)\n", + " print(\"Center orientation:\\t\", ego_state.center_se3.quaternion)" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "### 1.3.3 `BoxDetectionWrapper`\n", + "\n", + "Object that contains all bounding boxes in the current time step" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.datatypes.detections import BoxDetectionWrapper\n", + "\n", + "if (box_detections := scene.get_box_detections_at_iteration(iteration=iteration)) is not None:\n", + " box_detections: BoxDetectionWrapper\n", + "\n", + " print(f\"Number of boxes:{len(box_detections)}\")\n", + "\n", + " if len(box_detections) > 0:\n", + " box_detection = box_detections[0]\n", + " print(\"\\nFirst box:\")\n", + " print(\"Dataset Label:\\t\", box_detection.metadata.label)\n", + " print(\"Default Label:\\t\", box_detection.metadata.default_label)\n", + " print(\"Parameters:\\t\", box_detection.bounding_box_se3)" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "### 1.3.4 `PinholeCamera`\n", + "Object containing the camera observation with a pinhole model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "available_pinhole_types = scene.available_pinhole_camera_types\n", + "print(\"Available pinhole camera types:\\t\", available_pinhole_types)\n", + "\n", + "if len(available_pinhole_types) > 0:\n", + " camera_type = np.random.choice(available_pinhole_types)\n", + "else:\n", + " camera_type = PinholeCameraType.PCAM_F0 # Front facing camera\n", + "\n", + "# NOTE: If a camera is not available, the return will be None\n", + "if (pinhole_camera := scene.get_pinhole_camera_at_iteration(iteration=iteration, camera_type=camera_type)) is not None:\n", + " pinhole_camera: PinholeCamera\n", + "\n", + " print(f\"\\nCamera type:\\t{camera_type}\")\n", + "\n", + " print(f\"Image shape:\\t{pinhole_camera.image.shape}\")\n", + " print(f\"Intrinsics:\\t{pinhole_camera.metadata.intrinsics}\")\n", + " print(f\"Distortion:\\t{pinhole_camera.metadata.distortion}\")\n", + "\n", + " plt.imshow(pinhole_camera.image)\n", + " plt.title(f\"Camera Type: {camera_type}\")\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "### 1.3.5 `LiDAR`\n", + "Object containing a point cloud of a single laser scanner." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.datatypes.sensors import LiDAR, LiDARType\n", + "\n", + "available_lidar_types = scene.available_lidar_types\n", + "print(\"Available LiDAR types:\\t\", available_lidar_types)\n", + "\n", + "if len(available_lidar_types) > 0:\n", + " lidar_type = np.random.choice(available_lidar_types)\n", + "else:\n", + " lidar_type = LiDARType.LIDAR_TOP # Top mounted LiDAR\n", + "\n", + "if (lidar := scene.get_lidar_at_iteration(iteration=iteration, lidar_type=lidar_type)) is not None:\n", + " lidar: LiDAR\n", + "\n", + " print(f\"\\nLiDAR type:\\t{lidar_type}\")\n", + " print(f\"Shape (NxM):\\t{lidar.point_cloud.shape}\")\n", + " print(f\"Features (M):\\t{[(enum.name, enum.value) for enum in lidar.metadata.lidar_index]}\")\n", + "\n", + " xy = lidar.xy\n", + "\n", + " plt.scatter(xy[:, 0], xy[:, 1], s=0.1, alpha=0.25, c=\"black\")\n", + " plt.title(f\"LiDAR Type: {lidar_type}\")\n", + " plt.xlabel(\"X-forward [m]\")\n", + " plt.ylabel(\"Y-left [m]\")\n", + " plt.axis(\"equal\")\n", + "\n", + " range_limit = 100 # meters\n", + " plt.xlim(-range_limit, range_limit)\n", + " plt.ylim(-range_limit, range_limit)\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "23", + "metadata": {}, + "source": [ + "### 1.3.6 `MapAPI`\n", + "\n", + "The `MapAPI` can get retrieved from a scene directly. If the map is available, we plot the map with our default plotting function.\n", + "For further information, you can visit the map or visualization tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.api import MapAPI\n", + "from py123d.geometry import Point2D\n", + "from py123d.visualization.matplotlib.observation import add_default_map_on_ax\n", + "from py123d.visualization.matplotlib.utils import add_non_repeating_legend_to_ax\n", + "\n", + "\n", + "def simple_map_visualization(map_api: MapAPI, center_2d: Point2D, map_radius: float = 100.0):\n", + " \"\"\"Helper to plot the map using matplotlib.\n", + "\n", + " :param map_api: The MapAPI to visualize\n", + " :param center_2d: The center point of the map visualization\n", + " :param map_radius: The radius around the center point to visualize\n", + " \"\"\"\n", + "\n", + " fsize = 8\n", + " _, ax = plt.subplots(figsize=(fsize, fsize))\n", + " add_default_map_on_ax(ax, map_api=map_api, point_2d=center_2d, radius=map_radius)\n", + " add_non_repeating_legend_to_ax(ax)\n", + " ax.set_aspect(\"equal\")\n", + " ax.set_xlim(center_2d.x - map_radius, center_2d.x + map_radius)\n", + " ax.set_ylim(center_2d.y - map_radius, center_2d.y + map_radius)\n", + " plt.show()\n", + "\n", + "\n", + "if (map_api := scene.get_map_api()) is not None:\n", + " map_api: MapAPI\n", + "\n", + " if (ego_state := scene.get_ego_state_at_iteration(iteration=iteration)) is not None:\n", + " center_2d = ego_state.center_se3.point_2d\n", + " else:\n", + " center_2d = Point2D.from_array(np.array([0.0, 0.0]))\n", + "\n", + " print(\"\\nMapAPI is available.\")\n", + " print(\"Map Metadata:\", map_api.map_metadata)\n", + " simple_map_visualization(map_api=map_api, center_2d=center_2d)" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "### 1.3.7 Others:\n", + "\n", + "You can find further modalities in the documentation of [`SceneAPI`](todo)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "py123d_dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorial/02_map_tutorial.ipynb b/tutorial/02_map_tutorial.ipynb new file mode 100644 index 00000000..dc865881 --- /dev/null +++ b/tutorial/02_map_tutorial.ipynb @@ -0,0 +1,805 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "

\n", + " \n", + " \n", + " \n", + " \"Logo\"\n", + " \n", + "

123D: Map Tutorial

\n", + "" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List, Optional\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from py123d.api import MapAPI, SceneAPI, SceneFilter\n", + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.common.multithreading.worker_parallel import SingleMachineParallelExecutor\n", + "from py123d.datatypes.map_objects import (\n", + " BaseMapLineObject,\n", + " BaseMapObject,\n", + " BaseMapSurfaceObject,\n", + " Intersection,\n", + " Lane,\n", + " LaneGroup,\n", + " MapLayer,\n", + " RoadEdgeType,\n", + " RoadLineType,\n", + ")\n", + "from py123d.geometry import Point3D, Polyline2D\n", + "from py123d.visualization.color.default import MAP_SURFACE_CONFIG\n", + "from py123d.visualization.matplotlib.utils import add_non_repeating_legend_to_ax\n", + "\n", + "# Set some default visualization parameters\n", + "DEFAULT_FIGSIZE = (9, 9)\n", + "SURFACE_ALPHA = 0.3" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## 2.1 Download Demo Logs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 2.2 Create Scenes by filtering the datasets\n", + "\n", + "We create some scenes for easy access to some `MapAPI`'s. We use the option `map_api_required=True` to only include scenes/logs with maps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "scene_filter = SceneFilter(\n", + " split_names=None,\n", + " duration_s=None, # No duration means that the scene will include the complete log.\n", + " shuffle=True,\n", + " map_api_required=True, # Only include scenes/logs with an available map API.\n", + ")\n", + "worker = SingleMachineParallelExecutor()\n", + "\n", + "# worker = RayDistributed()\n", + "scenes = ArrowSceneBuilder().get_scenes(scene_filter, worker)\n", + "print(f\"Found {len(scenes)} scenes.\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 2.2 Inspecting the `MapAPI`\n" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "`MapMetadata`: If the map is available, this object includes information about the location, whether the map is 3D (`map_has_z`), of the the map is defined per log (`map_is_local`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "scene: SceneAPI = np.random.choice(scenes)\n", + "map_api: MapAPI = scene.get_map_api()\n", + "\n", + "map_metadata = scene.map_metadata\n", + "map_metadata" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## 2.3 Querying map objects\n", + "\n", + "There are multiple categories, also called layers, of map objects in 123D. You can get the available layers with:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "map_api.get_available_map_layers()" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "These layers can be divided into:\n", + "- `BaseMapSurfaceObject`: Objects/layers that defined a surface , e.g. a polygon or triangle mesh. Examples are lanes, lane groups, crosswalks, etc.\n", + "- `BaseMapLineObject`: Objects/layers that define a polyline in the map, e.g. a road edge or road line.\n", + "\n", + "All map objects are of type `BaseMapObject` which merely requires a `map_object_id` that is unique in each layer.\n", + "\n", + "The features of a map object depend on the type. You can query map objects with several functions, e.g. given a query point and radius." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "# You can define a query point and radius to search for map objects around that point.\n", + "query_point: Optional[Point3D] = None # e.g. Point(x=0.0, y=0.0, z=0.0)\n", + "query_radius: float = 100.0 # meters\n", + "\n", + "# Otherwise, we use the ego vehicle position, or the origin if no ego state is available.\n", + "if query_point is None:\n", + " if (ego_state := scene.get_ego_state_at_iteration(iteration=0)) is not None:\n", + " query_point = ego_state.center_se3.point_3d\n", + " else:\n", + " query_point = Point3D(0.0, 0.0, 0.0)\n", + "\n", + "\n", + "# For this example, we will query all available map layers.\n", + "map_layers = [\n", + " MapLayer.LANE, # Lanes (surface)\n", + " MapLayer.LANE_GROUP, # Lane groups (surface)\n", + " MapLayer.INTERSECTION, # Intersections (surface)\n", + " MapLayer.CROSSWALK, # Crosswalks (surface)\n", + " MapLayer.WALKWAY, # Walkways (surface)\n", + " MapLayer.CARPARK, # Carparks (surface)\n", + " MapLayer.GENERIC_DRIVABLE, # Generic drivable (surface)\n", + " MapLayer.STOP_ZONE, # Stop zones (surface)\n", + " MapLayer.ROAD_EDGE, # Road edges (lines)\n", + " MapLayer.ROAD_LINE, # Road lines (lines)\n", + "]\n", + "\n", + "# Query map objects: returns a dictionary mapping each map layer to a list of map objects in that layer.\n", + "map_object_dict = map_api.get_map_objects_in_radius(point=query_point, radius=query_radius, layers=map_layers)\n", + "\n", + "print(f\"Map objects found in radius {query_radius} around point {query_point}:\")\n", + "for map_layer, map_objects in map_object_dict.items():\n", + " print(f\"- {map_layer.name}: {len(map_objects)} objects\")" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "Before we inspect the map objects, let's define some helper functions to visualize them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "def add_map_object_id(ax: plt.Axes, map_object: BaseMapObject) -> None:\n", + " \"\"\"Helper to add the map object ID as text at the centroid of the map object.\"\"\"\n", + " if isinstance(map_object, BaseMapSurfaceObject):\n", + " centroid = map_object.shapely_polygon.centroid\n", + " elif isinstance(map_object, BaseMapLineObject):\n", + " centroid = map_object.polyline_2d.interpolate(0.5, normalized=True).shapely_point\n", + " else:\n", + " raise TypeError(f\"Unsupported map object type of type {type(map_object)}.\")\n", + "\n", + " ax.text(\n", + " centroid.x,\n", + " centroid.y,\n", + " str(map_object.object_id),\n", + " fontsize=8,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " color=\"black\",\n", + " bbox=dict(boxstyle=\"round,pad=0.3\", facecolor=\"white\", edgecolor=\"black\", alpha=0.7),\n", + " )\n", + "\n", + "\n", + "def add_map_surface_object(\n", + " ax: plt.Axes,\n", + " map_surface_object: BaseMapSurfaceObject,\n", + " add_id: bool = True,\n", + " alpha: float = SURFACE_ALPHA,\n", + " **plot_kwargs,\n", + ") -> None:\n", + " \"\"\"Helper to plot a map surface object.\"\"\"\n", + " x, y = map_surface_object.shapely_polygon.exterior.xy\n", + " ax.fill(x, y, alpha=alpha, **plot_kwargs)\n", + " if add_id:\n", + " add_map_object_id(ax, map_surface_object)\n", + "\n", + "\n", + "def add_map_line_object(ax: plt.Axes, map_line_object: BaseMapLineObject, add_id: bool = True, **plot_kwargs) -> None:\n", + " \"\"\"Helper to plot a map line object.\"\"\"\n", + " polyline_array = map_line_object.polyline_2d.array\n", + " ax.plot(polyline_array[:, 0], polyline_array[:, 1], **plot_kwargs)\n", + " if add_id:\n", + " add_map_object_id(ax, map_line_object)\n", + "\n", + "\n", + "def add_polyline(ax: plt.Axes, polyline: Polyline2D, add_start_end: bool = False, **plot_kwargs) -> None:\n", + " \"\"\"Helper to plot a polyline.\"\"\"\n", + " polyline_array = polyline.array\n", + " ax.plot(polyline_array[:, 0], polyline_array[:, 1], **plot_kwargs)\n", + " if add_start_end:\n", + " ax.plot(polyline.array[0, 0], polyline.array[0, 1], \"o\", label=\"Start\", color=\"black\")\n", + " ax.plot(polyline.array[-1, 0], polyline.array[-1, 1], \"x\", label=\"End\", color=\"black\")\n", + "\n", + "\n", + "def adjust_aspect_custom(ax: plt.Axes) -> None:\n", + " \"\"\"Helper to adjust the aspect ratio of a matplotlib Axes.\"\"\"\n", + " x_limits = ax.get_xlim()\n", + " y_limits = ax.get_ylim()\n", + " x_range = x_limits[1] - x_limits[0]\n", + " y_range = y_limits[1] - y_limits[0]\n", + " max_range = max(x_range, y_range)\n", + "\n", + " x_center = (x_limits[0] + x_limits[1]) / 2\n", + " y_center = (y_limits[0] + y_limits[1]) / 2\n", + "\n", + " ax.set_xlim(x_center - max_range / 2, x_center + max_range / 2)\n", + " ax.set_ylim(y_center - max_range / 2, y_center + max_range / 2)\n", + " ax.set_aspect(\"equal\", adjustable=\"box\")" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## 2.4 Map Objects in 123D\n", + "\n", + "### 2.3.1 `Lane`\n", + "\n", + "Let's start with the `Lane` object. Each lane can have multiple features, such as polylines from boundaries or the lane center, relational properties that point to neighboring map objects, or other features, such as the speed limit. \n", + "We can sample a lane and have a look:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "# Sample a random lane\n", + "if (lanes := map_object_dict[MapLayer.LANE]) is not None and len(lanes) > 0:\n", + " lane: Lane = np.random.choice(lanes)\n", + "\n", + "if lane is not None:\n", + " fig, ax = plt.subplots(figsize=DEFAULT_FIGSIZE)\n", + "\n", + " centerline = lane.centerline\n", + " right_boundary = lane.right_boundary\n", + " left_boundary = lane.left_boundary\n", + "\n", + " # Plot centerline and boundaries\n", + " add_polyline(ax, centerline, label=\"Centerline\", color=\"blue\", add_start_end=True)\n", + " add_polyline(ax, right_boundary, label=\"Right Boundary\", color=\"red\")\n", + " add_polyline(ax, left_boundary, label=\"Left Boundary\", color=\"green\")\n", + "\n", + " # Plot lane surface\n", + " add_map_surface_object(ax, lane, label=\"Surface\", color=\"grey\")\n", + "\n", + " speed_limit_mps = round(lane.speed_limit_mps, 2) if lane.speed_limit_mps is not None else \"N/A\"\n", + " ax.set_title(f\"Lane {lane.object_id}, speed limit: {speed_limit_mps} m/s\")\n", + " centroid = lane.shapely_polygon.centroid\n", + "\n", + " # Surface of the lane\n", + " adjust_aspect_custom(ax)\n", + "\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "Lanes can have neighbors, which can either be accessed as IDs or directly. These neighbors include:\n", + "- List of successor / predecessor lanes (`None` if not available)\n", + "- Single lane to the left or right (`None` if not available) " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "# Sample a random lane\n", + "if (lanes := map_object_dict[MapLayer.LANE]) is not None and len(lanes) > 0:\n", + " lane: Lane = np.random.choice(lanes)\n", + "\n", + "if lane is not None:\n", + " size = (8, 8)\n", + "\n", + " fig, ax = plt.subplots(figsize=DEFAULT_FIGSIZE)\n", + "\n", + " # Add the current lane:\n", + " add_map_surface_object(ax, lane, label=f\"Current lane {lane.object_id}\", color=\"grey\")\n", + " add_polyline(ax, lane.centerline, label=\"Centerline\", color=\"grey\", add_start_end=True)\n", + "\n", + " # Add left neighbor lane:\n", + " if lane.left_lane is not None:\n", + " add_map_surface_object(ax, lane.left_lane, label=f\"Left lane {lane.left_lane.object_id}\", color=\"green\")\n", + "\n", + " if lane.right_lane is not None:\n", + " add_map_surface_object(ax, lane.right_lane, label=f\"Right lane {lane.right_lane.object_id}\", color=\"red\")\n", + "\n", + " # Add successor lanes:\n", + " for successor_lane in lane.successors:\n", + " add_map_surface_object(ax, successor_lane, label=f\"Successor lane {successor_lane.object_id}\", color=\"blue\")\n", + "\n", + " for predecessor in lane.predecessors:\n", + " add_map_surface_object(ax, predecessor, label=f\"Predecessor lane {predecessor.object_id}\", color=\"orange\")\n", + "\n", + " adjust_aspect_custom(ax)\n", + " add_non_repeating_legend_to_ax(ax)" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "### 2.3.2 `LaneGroup`" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "A lane can be part of a lane group. Lane groups are sets of lanes that go in the same direction. The lane group can be accessed from the lane directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "if lane is not None and lane.lane_group is not None:\n", + " lane_group: LaneGroup = lane.lane_group\n", + "\n", + " fig, ax = plt.subplots(figsize=(8, 8))\n", + " ax.set_title(\n", + " f\"Lane Group {lane_group.object_id} includes [{', '.join(str(l.object_id) for l in lane_group.lanes)}]\"\n", + " )\n", + "\n", + " # Original lane\n", + " add_map_surface_object(ax, lane, label=f\"Current Lane {lane.object_id}\", color=\"grey\")\n", + " add_polyline(ax, lane.centerline, label=\"Centerline\", color=\"grey\", add_start_end=True)\n", + "\n", + " # Other lanes in the lane group\n", + " for group_lane in lane_group.lanes:\n", + " if group_lane.object_id != lane.object_id:\n", + " add_map_surface_object(ax, group_lane, label=f\"Other lane {group_lane.object_id}\", color=\"black\")\n", + "\n", + " adjust_aspect_custom(ax)\n", + " add_non_repeating_legend_to_ax(ax)" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "Lane groups are surfaces, with neighboring relationships. Let's sample another lane group and have a look:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "if len(map_object_dict[MapLayer.LANE_GROUP]) > 0:\n", + " lane_group: LaneGroup = np.random.choice(map_object_dict[MapLayer.LANE_GROUP])\n", + "\n", + " fig, ax = plt.subplots(figsize=DEFAULT_FIGSIZE)\n", + " ax.set_title(f\"Lane Group {lane_group.object_id}\")\n", + "\n", + " all_lanes: List[Lane] = lane_group.lanes\n", + "\n", + " # Current lane group\n", + " add_map_surface_object(ax, lane_group, label=f\"Lane Group {lane_group.object_id}\", color=\"grey\")\n", + "\n", + " # Predecessor lane groups\n", + " for predecessor in lane_group.predecessors:\n", + " add_map_surface_object(ax, predecessor, label=f\"Predecessor Lane Group {predecessor.object_id}\", color=\"orange\")\n", + " all_lanes += predecessor.lanes\n", + "\n", + " # Successor lane groups\n", + " for successor in lane_group.successors:\n", + " add_map_surface_object(ax, successor, label=f\"Successor Lane Group {successor.object_id}\", color=\"blue\")\n", + " all_lanes += successor.lanes\n", + "\n", + " # Adding all centerline\n", + " for lane in all_lanes:\n", + " centerline = lane.centerline\n", + " ax.plot(*centerline.array.T[:2], label=\"All centerlines\", color=\"darkgrey\")\n", + "\n", + " adjust_aspect_custom(ax)\n", + " add_non_repeating_legend_to_ax(ax)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "### 2.3.3 `Intersection`\n", + "\n", + "Intersections are map surfaces that include multiple lane groups. Let's look at a random example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "if len(map_object_dict[MapLayer.INTERSECTION]) > 0:\n", + " intersection: Intersection = np.random.choice(map_object_dict[MapLayer.INTERSECTION])\n", + "\n", + " fig, ax = plt.subplots(ncols=2, figsize=(14, 7))\n", + " ax[0].set_title(f\"Intersection {intersection.object_id}\")\n", + " add_map_surface_object(ax[0], intersection, label=\"Intersection\", color=\"blue\")\n", + "\n", + " lane_groups: List[LaneGroup] = intersection.lane_groups\n", + " ax[1].set_title(f\"Lane Groups of Intersection {intersection.object_id}\")\n", + " for lane_group in lane_groups:\n", + " add_map_surface_object(ax[1], lane_group, label=\"Lane Group\", color=\"grey\")\n", + "\n", + " for ax_ in ax:\n", + " add_non_repeating_legend_to_ax(ax_)\n", + " adjust_aspect_custom(ax_)\n", + "\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, + "source": [ + "### 2.3.4 Other map surfaces\n", + "\n", + "Besides lanes, lane groups, and intersection, the 123D map has a few simpler map surface objects. These include:\n", + "\n", + "- `Crosswalk`\n", + "- `Walkway`\n", + "- `Carpark`\n", + "- `GenericDrivable`\n", + "- `StopZone`\n", + "\n", + "The availability of these objects depend on the dataset. For now, we can just plot all objects we found in our previous query." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "misc_map_surfaces: List[MapLayer] = [\n", + " MapLayer.CROSSWALK,\n", + " MapLayer.WALKWAY,\n", + " MapLayer.CARPARK,\n", + " MapLayer.GENERIC_DRIVABLE,\n", + " MapLayer.STOP_ZONE,\n", + "]\n", + "\n", + "size = 8\n", + "fig, ax = plt.subplots(figsize=(size, size))\n", + "for map_layer in misc_map_surfaces:\n", + " map_surface_objects: List[BaseMapSurfaceObject] = map_object_dict[map_layer]\n", + "\n", + " # Here, we just use the default 123D colors for each map layer.\n", + " map_layer_color = str(MAP_SURFACE_CONFIG[map_layer].fill_color)\n", + "\n", + " for map_surface_object in map_surface_objects:\n", + " add_map_surface_object(\n", + " ax,\n", + " map_surface_object,\n", + " label=f\"{map_layer.name}\",\n", + " alpha=1.0,\n", + " color=map_layer_color,\n", + " add_id=False,\n", + " )\n", + "\n", + "adjust_aspect_custom(ax)\n", + "add_non_repeating_legend_to_ax(ax)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "### 2.3.5 `RoadEdge`\n", + "\n", + "In contrast to all previous road objects, we now have a look at map objects that are lines. \n", + "\n", + "Road edges are polylines (either in 2D or 3D) that mark the edge between drivable surfaces and non-drivable areas.\n", + "The concept of road edges is based on the [Waymo Open Datasets (Motion / Perception)](https://waymo.com/open/), where drivable areas are **not** represented by outlines or polygons.\n", + "While we have a method to convert the Waymo map to a more polygon-based representation, this conversion method cannot fully recover the drivable area.\n", + "\n", + "Therefore, we added road edges to the 123D map. The road edges of non-Waymo maps are extracted from the 3D/2D surfaces of the maps. \n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "\n", + "\n", + "Road edges have a `RoadEdgeType`, which is one of:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "for road_edege_type in RoadEdgeType:\n", + " print(f\"- {road_edege_type}\")" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "We will plot some road edges from our previous query. We will also add other drivable surfaces as polygons." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "drivable_surface_layers = [\n", + " MapLayer.LANE_GROUP,\n", + " MapLayer.INTERSECTION,\n", + " MapLayer.GENERIC_DRIVABLE,\n", + " MapLayer.CARPARK,\n", + "]\n", + "\n", + "road_edge_color_map = {\n", + " RoadEdgeType.UNKNOWN: \"red\",\n", + " RoadEdgeType.ROAD_EDGE_BOUNDARY: \"orange\",\n", + " RoadEdgeType.ROAD_EDGE_MEDIAN: \"blue\",\n", + "}\n", + "\n", + "fig, ax = plt.subplots(figsize=DEFAULT_FIGSIZE)\n", + "\n", + "for drivable_surface_layer in drivable_surface_layers:\n", + " for drivable_surface_object in map_object_dict[drivable_surface_layer]:\n", + " add_map_surface_object(\n", + " ax,\n", + " drivable_surface_object,\n", + " label=\"All drivable surfaces\",\n", + " color=\"lightgrey\",\n", + " alpha=1.0,\n", + " add_id=False,\n", + " zorder=0,\n", + " )\n", + "\n", + "for road_edge in map_object_dict[MapLayer.ROAD_EDGE]:\n", + " add_map_line_object(\n", + " ax,\n", + " road_edge,\n", + " label=f\"Road Edge: {road_edge.road_edge_type.name}\",\n", + " color=road_edge_color_map.get(road_edge.road_edge_type, \"black\"),\n", + " linewidth=2.0,\n", + " add_id=False,\n", + " zorder=1,\n", + " )\n", + "\n", + "add_non_repeating_legend_to_ax(ax)\n", + "ax.set_xlim(query_point.x - query_radius / 2, query_point.x + query_radius / 2)\n", + "ax.set_ylim(query_point.y - query_radius / 2, query_point.y + query_radius / 2)\n", + "\n", + "# Change background color to see the lines better\n", + "ax.set_facecolor(\"darkseagreen\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "### 2.3.6 `RoadLine`\n", + "\n", + "Road lines are lane markings that are in the map, either defined by a 3D or 2D polyline. A road line has a `RoadLineType`, which is one of:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "for road_line_type in RoadLineType:\n", + " print(f\"- {road_line_type}\")" + ] + }, + { + "cell_type": "markdown", + "id": "35", + "metadata": {}, + "source": [ + "In some datasets, the road line is equivalent to left/right boundaries. A lane can have various road lines along its boundaries, making referencing to lanes difficult. We currently include that as separate map objects." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "road_line_color_map = {\n", + " RoadLineType.NONE: \"grey\",\n", + " RoadLineType.UNKNOWN: \"red\",\n", + " RoadLineType.DASH_SOLID_YELLOW: \"yellow\",\n", + " RoadLineType.DASH_SOLID_WHITE: \"white\",\n", + " RoadLineType.DASHED_WHITE: \"white\",\n", + " RoadLineType.DASHED_YELLOW: \"yellow\",\n", + " RoadLineType.DOUBLE_SOLID_YELLOW: \"yellow\",\n", + " RoadLineType.DOUBLE_SOLID_WHITE: \"white\",\n", + " RoadLineType.DOUBLE_DASH_YELLOW: \"yellow\",\n", + " RoadLineType.DOUBLE_DASH_WHITE: \"white\",\n", + " RoadLineType.SOLID_YELLOW: \"yellow\",\n", + " RoadLineType.SOLID_WHITE: \"white\",\n", + " RoadLineType.SOLID_DASH_WHITE: \"white\",\n", + " RoadLineType.SOLID_DASH_YELLOW: \"yellow\",\n", + " RoadLineType.SOLID_BLUE: \"blue\",\n", + "}\n", + "\n", + "road_line_style_map = {\n", + " RoadLineType.DASHED_WHITE: \"--\",\n", + " RoadLineType.DASHED_YELLOW: \"--\",\n", + " RoadLineType.DOUBLE_DASH_YELLOW: \"--\",\n", + " RoadLineType.DOUBLE_DASH_WHITE: \"--\",\n", + "}\n", + "\n", + "road_line_width_map = {\n", + " RoadLineType.DOUBLE_SOLID_YELLOW: 3.0,\n", + " RoadLineType.DOUBLE_SOLID_WHITE: 3.0,\n", + " RoadLineType.DOUBLE_DASH_YELLOW: 3.0,\n", + " RoadLineType.DOUBLE_DASH_WHITE: 3.0,\n", + "}\n", + "\n", + "fig, ax = plt.subplots(figsize=DEFAULT_FIGSIZE)\n", + "\n", + "for drivable_surface_layer in drivable_surface_layers:\n", + " for drivable_surface_object in map_object_dict[drivable_surface_layer]:\n", + " add_map_surface_object(\n", + " ax,\n", + " drivable_surface_object,\n", + " label=\"All drivable surfaces\",\n", + " color=\"lightgrey\",\n", + " alpha=1.0,\n", + " add_id=False,\n", + " zorder=0,\n", + " )\n", + "\n", + "for road_line in map_object_dict[MapLayer.ROAD_LINE]:\n", + " line_color = road_line_color_map.get(road_line.road_line_type, \"black\")\n", + " line_style = road_line_style_map.get(road_line.road_line_type, \"-\")\n", + " line_width = road_line_width_map.get(road_line.road_line_type, 2.0)\n", + "\n", + " add_map_line_object(\n", + " ax,\n", + " road_line,\n", + " label=f\"Road Line: {road_line.road_line_type.name}\",\n", + " color=line_color,\n", + " linestyle=line_style,\n", + " linewidth=line_width,\n", + " add_id=False,\n", + " zorder=1,\n", + " )\n", + "\n", + "add_non_repeating_legend_to_ax(ax)\n", + "ax.set_xlim(query_point.x - query_radius / 2, query_point.x + query_radius / 2)\n", + "ax.set_ylim(query_point.y - query_radius / 2, query_point.y + query_radius / 2)\n", + "\n", + "# Change background color to see the road lines better\n", + "ax.set_facecolor(\"darkseagreen\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "37", + "metadata": {}, + "source": [ + "You made it to end. You can repeat the tutorial for different datasets and filtering settings." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "py123d_dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 1c8eb89c04bd1d5ba1dd3d3a44bf4d235c30d5ac Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 00:29:52 +0100 Subject: [PATCH 42/50] Adjust some repository links and references. --- docs/notes/contributing.md | 2 +- pyproject.toml | 4 ++-- src/py123d/visualization/viser/viser_viewer.py | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/notes/contributing.md b/docs/notes/contributing.md index 2f564162..7ed2d499 100644 --- a/docs/notes/contributing.md +++ b/docs/notes/contributing.md @@ -7,7 +7,7 @@ Contributions to 123D are highly encouraged! This guide will help you get starte ### 1. Clone the Repository ```sh -git clone git@github.com:DanielDauner/py123d.git +git clone git@github.com:autonomousvision/py123d.git cd py123d ``` diff --git a/pyproject.toml b/pyproject.toml index 55684c0f..6578cf9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,8 +96,8 @@ ffmpeg = [ where = ["src"] [project.urls] -"Homepage" = "https://github.com/DanielDauner/py123d" -"Bug Tracker" = "https://github.com/DanielDauner/py123d/issues" +"Homepage" = "https://github.com/autonomousvision/py123d" +"Bug Tracker" = "https://github.com/autonomousvision/py123d/issues" [tool.ruff] diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index ed020dcb..777d0374 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -43,24 +43,24 @@ def _build_viser_server(viser_config: ViserConfig) -> viser.ViserServer: TitlebarButton( text="Getting Started", icon=None, - href="https://danieldauner.github.io/py123d", + href="https://autonomousvision.github.io/py123d", ), TitlebarButton( text="Github", icon="GitHub", - href="https://github.com/DanielDauner/py123d", + href="https://github.com/autonomousvision/py123d", ), TitlebarButton( text="Documentation", icon="Description", - href="https://danieldauner.github.io/py123d", + href="https://autonomousvision.github.io/py123d", ), ) image = TitlebarImage( - image_url_light="https://danieldauner.github.io/py123d/_static/logo_black.png", - image_url_dark="https://danieldauner.github.io/py123d/_static/logo_white.png", + image_url_light="https://autonomousvision.github.io/py123d/_static/logo_black.png", + image_url_dark="https://autonomousvision.github.io/py123d/_static/logo_white.png", image_alt="123D", - href="https://danieldauner.github.io/py123d/", + href="https://autonomousvision.github.io/py123d/", ) titlebar_theme = TitlebarConfig(buttons=buttons, image=image) From aa81aeceed248953f00d21ace74175a452a3b85e Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 15:52:25 +0100 Subject: [PATCH 43/50] Add visualization notebook (#70) --- .../conversion/dataset_converter_config.py | 1 + .../conversion/map_writer/gpkg_map_writer.py | 8 +- src/py123d/datatypes/sensors/lidar.py | 2 +- .../datasets/av2_sensor_dataset.yaml | 1 + .../conversion/datasets/kitti360_dataset.yaml | 1 + .../conversion/datasets/nuplan_dataset.yaml | 1 + .../datasets/nuplan_mini_dataset.yaml | 1 + .../conversion/datasets/nuscenes_dataset.yaml | 1 + .../datasets/nuscenes_mini_dataset.yaml | 1 + .../conversion/datasets/wopd_dataset.yaml | 1 + .../map_writer/gpkg_map_writer.yaml | 1 - src/py123d/visualization/color/color.py | 25 +- src/py123d/visualization/matplotlib/camera.py | 266 +++++++--------- src/py123d/visualization/matplotlib/lidar.py | 108 +++---- .../visualization/matplotlib/observation.py | 29 +- src/py123d/visualization/matplotlib/plots.py | 37 +-- src/py123d/visualization/matplotlib/utils.py | 30 +- tests/unit/api/api/test_scene_api.py | 35 +++ tutorial/01_scene_tutorial.ipynb | 2 +- tutorial/02_map_tutorial.ipynb | 267 +++++++++++++--- tutorial/03_visualization.ipynb | 297 ++++++++++++++++++ 21 files changed, 784 insertions(+), 331 deletions(-) create mode 100644 tutorial/03_visualization.ipynb diff --git a/src/py123d/conversion/dataset_converter_config.py b/src/py123d/conversion/dataset_converter_config.py index ea1cfe77..cfb28942 100644 --- a/src/py123d/conversion/dataset_converter_config.py +++ b/src/py123d/conversion/dataset_converter_config.py @@ -11,6 +11,7 @@ class DatasetConverterConfig: # Map include_map: bool = False + remap_map_ids: bool = False # Ego include_ego: bool = False diff --git a/src/py123d/conversion/map_writer/gpkg_map_writer.py b/src/py123d/conversion/map_writer/gpkg_map_writer.py index 63d91781..09f73636 100644 --- a/src/py123d/conversion/map_writer/gpkg_map_writer.py +++ b/src/py123d/conversion/map_writer/gpkg_map_writer.py @@ -36,15 +36,15 @@ class GPKGMapWriter(AbstractMapWriter): """Abstract base class for map writers.""" - def __init__(self, maps_root: Union[str, Path], remap_ids: bool = False) -> None: + def __init__(self, maps_root: Union[str, Path]) -> None: self._maps_root = Path(maps_root) self._crs: str = "EPSG:4326" # WGS84 - self._remap_ids = remap_ids # Data to be written to the map for each object type self._map_data: Optional[Dict[MapLayer, MAP_OBJECT_DATA]] = None self._map_file: Optional[Path] = None self._map_metadata: Optional[MapMetadata] = None + self._remap_map_ids: Optional[bool] = None def reset(self, dataset_converter_config: DatasetConverterConfig, map_metadata: MapMetadata) -> bool: """Inherited, see superclass.""" @@ -65,6 +65,7 @@ def reset(self, dataset_converter_config: DatasetConverterConfig, map_metadata: self._map_data = {map_layer: defaultdict(list) for map_layer in MapLayer} self._map_file = map_file self._map_metadata = map_metadata + self._remap_map_ids = dataset_converter_config.remap_map_ids return map_needs_writing @@ -146,7 +147,7 @@ def close(self) -> None: ) # Optionally remap string IDs to integers - if self._remap_ids: + if self._remap_map_ids: _map_ids_to_integer(map_gdf) # Write each map layer to the GPKG file @@ -166,6 +167,7 @@ def _assert_initialized(self) -> None: assert self._map_data is not None, "Call reset() before writing data." assert self._map_file is not None, "Call reset() before writing data." assert self._map_metadata is not None, "Call reset() before writing data." + assert self._remap_map_ids is not None, "Call reset() before writing data." def _write_surface_layer(self, layer: MapLayer, surface_object: BaseMapSurfaceObject) -> None: """Helper to write surface map objects. diff --git a/src/py123d/datatypes/sensors/lidar.py b/src/py123d/datatypes/sensors/lidar.py index 3509d6bd..4167cb2d 100644 --- a/src/py123d/datatypes/sensors/lidar.py +++ b/src/py123d/datatypes/sensors/lidar.py @@ -65,7 +65,7 @@ def lidar_type(self) -> LiDARType: return self._lidar_type @property - def lidar_index(self) -> Type[LiDARIndex]: + def lidar_index(self) -> LiDARIndex: """The indexing schema of the LiDAR point cloud.""" return self._lidar_index diff --git a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml index 4a6d57ed..92e10e79 100644 --- a/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/av2_sensor_dataset.yaml @@ -14,6 +14,7 @@ av2_sensor_dataset: # Map include_map: true + remap_map_ids: true # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml index 5c7e23b5..6e62f2c6 100644 --- a/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/kitti360_dataset.yaml @@ -22,6 +22,7 @@ kitti360_dataset: # Map include_map: true + remap_map_ids: true # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml index 3c872c87..5f40b985 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_dataset.yaml @@ -16,6 +16,7 @@ nuplan_dataset: # Map include_map: true + remap_map_ids: false # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml index 9aa267a9..c57c17ee 100644 --- a/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuplan_mini_dataset.yaml @@ -16,6 +16,7 @@ nuplan_mini_dataset: # Map include_map: true + remap_map_ids: false # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml index fed54eb1..f2dcf697 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml @@ -17,6 +17,7 @@ nuscenes_dataset: # Map include_map: true + remap_map_ids: false # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml index baf3d1b6..90cfb69a 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml @@ -17,6 +17,7 @@ nuscenes_mini_dataset: # Map include_map: true + remap_map_ids: false # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml index 79ccc8d9..5cc6a590 100644 --- a/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/wopd_dataset.yaml @@ -18,6 +18,7 @@ wopd_dataset: # Map include_map: true + remap_map_ids: false # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/map_writer/gpkg_map_writer.yaml b/src/py123d/script/config/conversion/map_writer/gpkg_map_writer.yaml index 86bf8e0b..6bfb4877 100644 --- a/src/py123d/script/config/conversion/map_writer/gpkg_map_writer.yaml +++ b/src/py123d/script/config/conversion/map_writer/gpkg_map_writer.yaml @@ -2,4 +2,3 @@ _target_: py123d.conversion.map_writer.gpkg_map_writer.GPKGMapWriter _convert_: 'all' maps_root: ${dataset_paths.py123d_maps_root} -remap_ids: true diff --git a/src/py123d/visualization/color/color.py b/src/py123d/visualization/color/color.py index 23db6327..19dc03a7 100644 --- a/src/py123d/visualization/color/color.py +++ b/src/py123d/visualization/color/color.py @@ -24,30 +24,30 @@ def rgb(self) -> Tuple[int, int, int]: return ImageColor.getcolor(self.hex, "RGB") @property - def rgba(self) -> Tuple[int, int, int]: + def rgba(self) -> Tuple[int, int, int, int]: """The RGBA representation of the color.""" return ImageColor.getcolor(self.hex, "RGBA") @property def rgb_norm(self) -> Tuple[float, float, float]: """The normalized RGB representation of the color.""" - return tuple([c / 255 for c in self.rgb]) + r, g, b = self.rgb + return (r / 255, g / 255, b / 255) @property - def rgba_norm(self) -> Tuple[float, float, float]: + def rgba_norm(self) -> Tuple[float, float, float, float]: """The normalized RGBA representation of the color.""" - return tuple([c / 255 for c in self.rgba]) + r, g, b, a = self.rgba + return (r / 255, g / 255, b / 255, a / 255) def set_brightness(self, factor: float) -> Color: """Return a new Color with adjusted brightness.""" r, g, b = self.rgb - return Color.from_rgb( - ( - max(min(int(r * factor), 255), 0), - max(min(int(g * factor), 255), 0), - max(min(int(b * factor), 255), 0), - ) - ) + return Color.from_rgb(( + max(min(int(r * factor), 255), 0), + max(min(int(g * factor), 255), 0), + max(min(int(b * factor), 255), 0), + )) def __str__(self) -> str: """Return the string representation of the color.""" @@ -55,7 +55,8 @@ def __str__(self) -> str: def __repr__(self) -> str: """Return the official string representation of the color.""" - return f"Color(hex='{self.hex}')" + r, g, b = self.rgb + return f"Color(hex='\x1b[48;2;{r};{g};{b}m{self.hex}\x1b[0m')" BLACK: Color = Color("#000000") diff --git a/src/py123d/visualization/matplotlib/camera.py b/src/py123d/visualization/matplotlib/camera.py index 949c5266..ef8be171 100644 --- a/src/py123d/visualization/matplotlib/camera.py +++ b/src/py123d/visualization/matplotlib/camera.py @@ -1,71 +1,54 @@ -# from typing import List, Optional, Tuple - from typing import List, Optional, Tuple import cv2 import matplotlib.pyplot as plt import numpy as np import numpy.typing as npt - -# from PIL import ImageColor from pyquaternion import Quaternion -from py123d.conversion.registry.box_detection_label_registry import DefaultBoxDetectionLabel -from py123d.datatypes.detections.box_detections import BoxDetectionSE3, BoxDetectionWrapper -from py123d.datatypes.sensors.pinhole_camera import PinholeCamera, PinholeIntrinsics -from py123d.datatypes.vehicle_state.ego_state import EgoStateSE3 +from py123d.conversion.registry import DefaultBoxDetectionLabel, LiDARIndex +from py123d.datatypes.detections import BoxDetectionSE3, BoxDetectionWrapper +from py123d.datatypes.sensors import LiDAR, PinholeCamera, PinholeIntrinsics +from py123d.datatypes.vehicle_state import EgoStateSE3 from py123d.geometry import BoundingBoxSE3Index, Corners3DIndex -from py123d.geometry.transform.transform_se3 import convert_absolute_to_relative_se3_array +from py123d.geometry.transform import convert_absolute_to_relative_se3_array from py123d.visualization.color.default import BOX_DETECTION_CONFIG +from py123d.visualization.matplotlib.lidar import get_lidar_pc_color -# from navsim.common.dataclasses import Annotations, Camera, Lidar -# from navsim.common.enums import BoundingBoxIndex, LidarIndex -# from navsim.planning.scenario_builder.navsim_scenario_utils import tracked_object_types -# from navsim.visualization.config import AGENT_CONFIG -# from navsim.visualization.lidar import filter_lidar_pc, get_lidar_pc_color +def add_pinhole_camera_ax(ax: plt.Axes, pinhole_camera: PinholeCamera) -> plt.Axes: + """Add pinhole camera image to matplotlib axis -def add_camera_ax(ax: plt.Axes, camera: PinholeCamera) -> plt.Axes: - """ - Adds camera image to matplotlib ax object - :param ax: matplotlib ax object - :param camera: navsim camera dataclass - :return: ax object with image + :param ax: matplotlib axis + :param pinhole_camera: pinhole camera object + :return: matplotlib axis with image """ - ax.imshow(camera.image) + ax.imshow(pinhole_camera.image) return ax -# FIXME: -# def add_lidar_to_camera_ax(ax: plt.Axes, camera: Camera, lidar: Lidar) -> plt.Axes: -# """ -# Adds camera image with lidar point cloud on matplotlib ax object -# :param ax: matplotlib ax object -# :param camera: navsim camera dataclass -# :param lidar: navsim lidar dataclass -# :return: ax object with image -# """ +def add_lidar_to_camera_ax(ax: plt.Axes, camera: PinholeCamera, lidar: LiDAR) -> plt.Axes: + """Add lidar point cloud to camera image on matplotlib axis -# image, lidar_pc = camera.image.copy(), lidar.lidar_pc.copy() -# image_height, image_width = image.shape[:2] + :param ax: matplotlib axis + :param camera: pinhole camera object + :param lidar: lidar object + :return: matplotlib axis with lidar points overlaid on camera image + """ -# lidar_pc = filter_lidar_pc(lidar_pc) -# lidar_pc_colors = np.array(get_lidar_pc_color(lidar_pc)) + image, lidar_pc = camera.image.copy(), lidar.point_cloud.copy() + lidar_index = lidar.metadata.lidar_index -# pc_in_cam, pc_in_fov_mask = _transform_pcs_to_images( -# lidar_pc, -# camera.sensor2lidar_rotation, -# camera.sensor2lidar_translation, -# camera.intrinsics, -# img_shape=(image_height, image_width), -# ) + # lidar_pc = filter_lidar_pc(lidar_pc) + lidar_pc_colors = np.array(get_lidar_pc_color(lidar_pc, lidar_index, feature="distance")) + pc_in_cam, pc_in_fov_mask = _transform_pcs_to_images(lidar_pc, lidar_index, camera) -# for (x, y), color in zip(pc_in_cam[pc_in_fov_mask], lidar_pc_colors[pc_in_fov_mask]): -# color = (int(color[0]), int(color[1]), int(color[2])) -# cv2.circle(image, (int(x), int(y)), 5, color, -1) + for (x, y), color in zip(pc_in_cam[pc_in_fov_mask], lidar_pc_colors[pc_in_fov_mask]): + color = (int(color[0]), int(color[1]), int(color[2])) + cv2.circle(image, (int(x), int(y)), 5, color, -1) -# ax.imshow(image) -# return ax + ax.imshow(image) + return ax def add_box_detections_to_camera_ax( @@ -73,16 +56,24 @@ def add_box_detections_to_camera_ax( camera: PinholeCamera, box_detections: BoxDetectionWrapper, ego_state_se3: EgoStateSE3, - return_image: bool = False, ) -> plt.Axes: + """Add box detections to camera image on matplotlib axis + + :param ax: matplotlib axis + :param camera: pinhole camera object + :param box_detections: box detection wrapper object + :param ego_state_se3: ego state object + :return: matplotlib axis with box detections overlaid on camera image + """ + box_detection_array = np.zeros((len(box_detections.box_detections), len(BoundingBoxSE3Index)), dtype=np.float64) default_labels = np.array( [detection.metadata.default_label for detection in box_detections.box_detections], dtype=object ) for idx, box_detection in enumerate(box_detections.box_detections): - assert isinstance( - box_detection, BoxDetectionSE3 - ), f"Box detection must be of type BoxDetectionSE3, got {type(box_detection)}" + assert isinstance(box_detection, BoxDetectionSE3), ( + f"Box detection must be of type BoxDetectionSE3, got {type(box_detection)}" + ) box_detection_array[idx] = box_detection.bounding_box_se3.array # FIXME @@ -111,22 +102,16 @@ def add_box_detections_to_camera_ax( box_corners, default_labels = box_corners[valid_corners], default_labels[valid_corners] image = _plot_rect_3d_on_img(camera.image.copy(), box_corners, default_labels) - if return_image: - # ax.imshow(image) - return ax, image - ax.imshow(image) return ax def _transform_annotations_to_camera(boxes: npt.NDArray, extrinsic: npt.NDArray) -> npt.NDArray: - """ - Helper function to transform bounding boxes into camera frame - TODO: Refactor - :param boxes: array representation of bounding boxes - :param sensor2lidar_rotation: camera rotation - :param sensor2lidar_translation: camera translation - :return: bounding boxes in camera coordinates + """Transforms the box annotations from sensor frame to camera frame. + + :param boxes: array of bounding box parameters. + :param extrinsic: The (4x4) transformation matrix from ego to camera frame. + :return: transformed bounding box parameters in camera frame. """ sensor2lidar_rotation = extrinsic[:3, :3] sensor2lidar_translation = extrinsic[:3, 3] @@ -159,44 +144,29 @@ def _transform_annotations_to_camera(boxes: npt.NDArray, extrinsic: npt.NDArray) def _rotation_3d_in_axis(points: npt.NDArray[np.float32], angles: npt.NDArray[np.float32], axis: int = 0): - """ - Rotate 3D points by angles according to axis. - TODO: Refactor - :param points: array of points - :param angles: array of angles - :param axis: axis to perform rotation, defaults to 0 - :raises value: _description_ - :raises ValueError: if axis invalid - :return: rotated points - """ + """Rotate points in 3D along specific axis.""" rot_sin = np.sin(angles) rot_cos = np.cos(angles) ones = np.ones_like(rot_cos) zeros = np.zeros_like(rot_cos) if axis == 1: - rot_mat_T = np.stack( - [ - np.stack([rot_cos, zeros, -rot_sin]), - np.stack([zeros, ones, zeros]), - np.stack([rot_sin, zeros, rot_cos]), - ] - ) + rot_mat_T = np.stack([ + np.stack([rot_cos, zeros, -rot_sin]), + np.stack([zeros, ones, zeros]), + np.stack([rot_sin, zeros, rot_cos]), + ]) elif axis in [2, -1]: - rot_mat_T = np.stack( - [ - np.stack([rot_cos, -rot_sin, zeros]), - np.stack([rot_sin, rot_cos, zeros]), - np.stack([zeros, zeros, ones]), - ] - ) + rot_mat_T = np.stack([ + np.stack([rot_cos, -rot_sin, zeros]), + np.stack([rot_sin, rot_cos, zeros]), + np.stack([zeros, zeros, ones]), + ]) elif axis == 0: - rot_mat_T = np.stack( - [ - np.stack([zeros, rot_cos, -rot_sin]), - np.stack([zeros, rot_sin, rot_cos]), - np.stack([ones, zeros, zeros]), - ] - ) + rot_mat_T = np.stack([ + np.stack([zeros, rot_cos, -rot_sin]), + np.stack([zeros, rot_sin, rot_cos]), + np.stack([ones, zeros, zeros]), + ]) else: raise ValueError(f"axis should in range [0, 1, 2], got {axis}") return np.einsum("aij,jka->aik", points, rot_mat_T) @@ -208,15 +178,16 @@ def _plot_rect_3d_on_img( labels: List[DefaultBoxDetectionLabel], thickness: int = 3, ) -> npt.NDArray[np.uint8]: - """ - Plot the boundary lines of 3D rectangular on 2D images. + """Plot 3D bounding boxes on image + TODO: refactor - :param image: The numpy array of image. - :param box_corners: Coordinates of the corners of 3D, shape of [N, 8, 2]. - :param box_labels: labels of boxes for coloring - :param thickness: pixel width of liens, defaults to 3 - :return: image with 3D bounding boxes + :param image: The image to plot on + :param box_corners: The corners of the boxes to plot + :param labels: The labels of the boxes to plot + :param thickness: The thickness of the lines, defaults to 3 + :return: The image with 3D bounding boxes plotted """ + line_indices = ( (0, 1), (0, 3), @@ -252,8 +223,8 @@ def _transform_points_to_image( image_shape: Optional[Tuple[int, int]] = None, eps: float = 1e-3, ) -> Tuple[npt.NDArray[np.float32], npt.NDArray[np.bool_]]: - """ - Transforms points in camera frame to image pixel coordinates + """Transforms points in camera frame to image pixel coordinates + TODO: refactor :param points: points in camera frame :param intrinsic: camera intrinsics @@ -286,50 +257,49 @@ def _transform_points_to_image( return pc_img, cur_pc_in_fov -# def _transform_pcs_to_images( -# lidar_pc: npt.NDArray[np.float32], -# sensor2lidar_rotation: npt.NDArray[np.float32], -# sensor2lidar_translation: npt.NDArray[np.float32], -# intrinsic: npt.NDArray[np.float32], -# img_shape: Optional[Tuple[int, int]] = None, -# eps: float = 1e-3, -# ) -> Tuple[npt.NDArray[np.float32], npt.NDArray[np.bool_]]: -# """ -# Transforms points in camera frame to image pixel coordinates -# TODO: refactor -# :param lidar_pc: lidar point cloud -# :param sensor2lidar_rotation: camera rotation -# :param sensor2lidar_translation: camera translation -# :param intrinsic: camera intrinsics -# :param img_shape: image shape in pixels, defaults to None -# :param eps: threshold for lidar pc height, defaults to 1e-3 -# :return: lidar pc in pixel coordinates, mask of values in frame -# """ -# pc_xyz = lidar_pc[LidarIndex.POSITION, :].T - -# lidar2cam_r = np.linalg.inv(sensor2lidar_rotation) -# lidar2cam_t = sensor2lidar_translation @ lidar2cam_r.T -# lidar2cam_rt = np.eye(4) -# lidar2cam_rt[:3, :3] = lidar2cam_r.T -# lidar2cam_rt[3, :3] = -lidar2cam_t - -# viewpad = np.eye(4) -# viewpad[: intrinsic.shape[0], : intrinsic.shape[1]] = intrinsic -# lidar2img_rt = viewpad @ lidar2cam_rt.T - -# cur_pc_xyz = np.concatenate([pc_xyz, np.ones_like(pc_xyz)[:, :1]], -1) -# cur_pc_cam = lidar2img_rt @ cur_pc_xyz.T -# cur_pc_cam = cur_pc_cam.T -# cur_pc_in_fov = cur_pc_cam[:, 2] > eps -# cur_pc_cam = cur_pc_cam[..., 0:2] / np.maximum(cur_pc_cam[..., 2:3], np.ones_like(cur_pc_cam[..., 2:3]) * eps) - -# if img_shape is not None: -# img_h, img_w = img_shape -# cur_pc_in_fov = ( -# cur_pc_in_fov -# & (cur_pc_cam[:, 0] < (img_w - 1)) -# & (cur_pc_cam[:, 0] > 0) -# & (cur_pc_cam[:, 1] < (img_h - 1)) -# & (cur_pc_cam[:, 1] > 0) -# ) -# return cur_pc_cam, cur_pc_in_fov +def _transform_pcs_to_images( + lidar_pc: npt.NDArray[np.float32], + lidar_index: LiDARIndex, + camera: PinholeCamera, + eps: float = 1e-3, +) -> Tuple[npt.NDArray[np.float32], npt.NDArray[np.bool_]]: + """Transforms lidar point cloud to image pixel coordinates + + TODO: refactor + :param lidar_pc: lidar point cloud + :param lidar_index: lidar index + :param camera: pinhole camera + :param eps: lower threshold of points, defaults to 1e-3 + :return: points in pixel coordinates, mask of values in frame + """ + + pc_xyz = lidar_pc[..., lidar_index.XYZ] + + lidar2cam_r = np.linalg.inv(camera.extrinsic.rotation_matrix) + lidar2cam_t = camera.extrinsic.point_3d @ lidar2cam_r.T + lidar2cam_rt = np.eye(4) + lidar2cam_rt[:3, :3] = lidar2cam_r.T + lidar2cam_rt[3, :3] = -lidar2cam_t + + camera_matrix = camera.metadata.intrinsics.camera_matrix + viewpad = np.eye(4) + viewpad[: camera_matrix.shape[0], : camera_matrix.shape[1]] = camera_matrix + lidar2img_rt = viewpad @ lidar2cam_rt.T + img_shape = camera.image.shape[:2] + + cur_pc_xyz = np.concatenate([pc_xyz, np.ones_like(pc_xyz)[:, :1]], -1) + cur_pc_cam = lidar2img_rt @ cur_pc_xyz.T + cur_pc_cam = cur_pc_cam.T + cur_pc_in_fov = cur_pc_cam[:, 2] > eps + cur_pc_cam = cur_pc_cam[..., 0:2] / np.maximum(cur_pc_cam[..., 2:3], np.ones_like(cur_pc_cam[..., 2:3]) * eps) + + if img_shape is not None: + img_h, img_w = img_shape + cur_pc_in_fov = ( + cur_pc_in_fov + & (cur_pc_cam[:, 0] < (img_w - 1)) + & (cur_pc_cam[:, 0] > 0) + & (cur_pc_cam[:, 1] < (img_h - 1)) + & (cur_pc_cam[:, 1] > 0) + ) + return cur_pc_cam, cur_pc_in_fov diff --git a/src/py123d/visualization/matplotlib/lidar.py b/src/py123d/visualization/matplotlib/lidar.py index 29ffeea8..b51ab89a 100644 --- a/src/py123d/visualization/matplotlib/lidar.py +++ b/src/py123d/visualization/matplotlib/lidar.py @@ -1,70 +1,38 @@ -# TODO: implement -# from typing import Any, List - -# import matplotlib -# import matplotlib.pyplot as plt -# import numpy as np -# import numpy.typing as npt - -# from navsim.common.enums import LidarIndex -# from navsim.visualization.config import LIDAR_CONFIG - - -# def filter_lidar_pc(lidar_pc: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: -# """ -# Filter lidar point cloud according to global configuration -# :param lidar_pc: numpy array of shape (6,n) -# :return: filtered point cloud -# """ - -# pc = lidar_pc.T -# mask = ( -# np.ones((len(pc)), dtype=bool) -# & (pc[:, LidarIndex.X] > LIDAR_CONFIG["x_lim"][0]) -# & (pc[:, LidarIndex.X] < LIDAR_CONFIG["x_lim"][1]) -# & (pc[:, LidarIndex.Y] > LIDAR_CONFIG["y_lim"][0]) -# & (pc[:, LidarIndex.Y] < LIDAR_CONFIG["y_lim"][1]) -# & (pc[:, LidarIndex.Z] > LIDAR_CONFIG["z_lim"][0]) -# & (pc[:, LidarIndex.Z] < LIDAR_CONFIG["z_lim"][1]) -# ) -# pc = pc[mask] -# return pc.T - - -# def get_lidar_pc_color(lidar_pc: npt.NDArray[np.float32], as_hex: bool = False) -> List[Any]: -# """ -# Compute color map of lidar point cloud according to global configuration -# :param lidar_pc: numpy array of shape (6,n) -# :param as_hex: whether to return hex values, defaults to False -# :return: list of RGB or hex values -# """ - -# pc = lidar_pc.T -# if LIDAR_CONFIG["color_element"] == "none": -# colors_rgb = np.zeros((len(pc), 3), dtype=np.uin8) - -# else: -# if LIDAR_CONFIG["color_element"] == "distance": -# color_intensities = np.linalg.norm(pc[:, LidarIndex.POSITION], axis=-1) -# else: -# color_element_map = { -# "x": LidarIndex.X, -# "y": LidarIndex.Y, -# "z": LidarIndex.Z, -# "intensity": LidarIndex.INTENSITY, -# "ring": LidarIndex.RING, -# "id": LidarIndex.ID, -# } -# color_intensities = pc[:, color_element_map[LIDAR_CONFIG["color_element"]]] - -# min, max = color_intensities.min(), color_intensities.max() -# norm_intensities = [(value - min) / (max - min) for value in color_intensities] -# colormap = plt.get_cmap("viridis") -# colors_rgb = np.array([colormap(value) for value in norm_intensities]) -# colors_rgb = (colors_rgb[:, :3] * 255).astype(np.uint8) - -# assert len(colors_rgb) == len(pc) -# if as_hex: -# return [matplotlib.colors.to_hex(tuple(c / 255.0 for c in rgb)) for rgb in colors_rgb] - -# return [tuple(value) for value in colors_rgb] +from typing import Literal + +import matplotlib.pyplot as plt +import numpy as np +import numpy.typing as npt + +from py123d.conversion.registry.lidar_index_registry import LiDARIndex + + +def get_lidar_pc_color( + lidar_pc: npt.NDArray[np.float32], + lidar_index: LiDARIndex, + feature: Literal["none", "distance", "intensity"], +) -> npt.NDArray[np.uint8]: + """ + Compute color map of lidar point cloud according to global configuration + :param lidar_pc: numpy array of shape (6,n) + :param as_hex: whether to return hex values, defaults to False + :return: list of RGB or hex values + """ + + lidar_xyz = lidar_pc[:, lidar_index.XYZ] + if feature == "none": + colors_rgb = np.zeros((len(lidar_xyz), 3), dtype=np.uin8) + else: + if feature == "distance": + color_intensities = np.linalg.norm(lidar_xyz, axis=-1) + elif feature == "intensity": + assert lidar_index.INTENSITY is not None, "LiDARIndex.INTENSITY is not defined" + color_intensities = lidar_pc[:, lidar_index.INTENSITY] + + min, max = color_intensities.min(), color_intensities.max() + norm_intensities = [(value - min) / (max - min) for value in color_intensities] + colormap = plt.get_cmap("viridis") + colors_rgb = np.array([colormap(value) for value in norm_intensities]) + colors_rgb = (colors_rgb[:, :3] * 255).astype(np.uint8) + + return colors_rgb diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index fca429b1..98db2bed 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -5,7 +5,7 @@ import numpy as np import shapely.geometry as geom -from py123d.api.map.map_api import MapAPI +from py123d.api import MapAPI, SceneAPI from py123d.datatypes.detections.box_detections import BoxDetectionWrapper from py123d.datatypes.detections.traffic_light_detections import TrafficLightDetectionWrapper from py123d.datatypes.map_objects.map_layer_types import MapLayer @@ -30,6 +30,28 @@ ) +def add_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: float = 80) -> plt.Axes: + ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) + box_detections = scene.get_box_detections_at_iteration(iteration) + traffic_light_detections = scene.get_traffic_light_detections_at_iteration(iteration) + map_api = scene.get_map_api() + + assert ego_vehicle_state is not None, "Ego vehicle state is required to plot the scene." + if map_api is not None: + point_2d = ego_vehicle_state.bounding_box_se2.center_se2.pose_se2.point_2d + add_default_map_on_ax(ax, map_api, point_2d, radius=radius) + if traffic_light_detections is not None: + add_traffic_lights_to_ax(ax, traffic_light_detections, map_api) + + add_box_detections_to_ax(ax, box_detections) + add_ego_vehicle_to_ax(ax, ego_vehicle_state) + + ax.set_xlim(point_2d.x - radius, point_2d.x + radius) + ax.set_ylim(point_2d.y - radius, point_2d.y + radius) + ax.set_aspect("equal", adjustable="box") + return ax + + def add_default_map_on_ax( ax: plt.Axes, map_api: MapAPI, @@ -94,15 +116,12 @@ def add_default_map_on_ax( def add_box_detections_to_ax(ax: plt.Axes, box_detections: BoxDetectionWrapper) -> None: for box_detection in box_detections: - # TODO: Optionally, continue on boxes outside of plot. - # if box_detection.metadata.detection_type == DetectionType.GENERIC_OBJECT: - # continue plot_config = BOX_DETECTION_CONFIG[box_detection.metadata.default_label] add_bounding_box_to_ax(ax, box_detection.bounding_box_se2, plot_config) def add_ego_vehicle_to_ax(ax: plt.Axes, ego_vehicle_state: Union[EgoStateSE3, EgoStateSE2]) -> None: - add_bounding_box_to_ax(ax, ego_vehicle_state.bounding_box, EGO_VEHICLE_CONFIG) + add_bounding_box_to_ax(ax, ego_vehicle_state.bounding_box_se2, EGO_VEHICLE_CONFIG) def add_traffic_lights_to_ax( diff --git a/src/py123d/visualization/matplotlib/plots.py b/src/py123d/visualization/matplotlib/plots.py index 99741006..aa3e23d7 100644 --- a/src/py123d/visualization/matplotlib/plots.py +++ b/src/py123d/visualization/matplotlib/plots.py @@ -6,41 +6,12 @@ from tqdm import tqdm from py123d.api.scene.scene_api import SceneAPI -from py123d.visualization.matplotlib.observation import ( - add_box_detections_to_ax, - add_default_map_on_ax, - add_ego_vehicle_to_ax, - add_traffic_lights_to_ax, -) - - -def _plot_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: float = 80) -> plt.Axes: - ego_vehicle_state = scene.get_ego_state_at_iteration(iteration) - box_detections = scene.get_box_detections_at_iteration(iteration) - traffic_light_detections = scene.get_traffic_light_detections_at_iteration(iteration) - route_lane_group_ids = scene.get_route_lane_group_ids(iteration) - map_api = scene.get_map_api() - - assert ego_vehicle_state is not None, "Ego vehicle state is required to plot the scene." - if map_api is not None: - point_2d = ego_vehicle_state.bounding_box_se2.center_se2.pose_se2.point_2d - add_default_map_on_ax(ax, map_api, point_2d, radius=radius, route_lane_group_ids=route_lane_group_ids) - if traffic_light_detections is not None: - add_traffic_lights_to_ax(ax, traffic_light_detections, map_api) - - add_box_detections_to_ax(ax, box_detections) - add_ego_vehicle_to_ax(ax, ego_vehicle_state) - - ax.set_xlim(point_2d.x - radius, point_2d.x + radius) - ax.set_ylim(point_2d.y - radius, point_2d.y + radius) - - ax.set_aspect("equal", adjustable="box") - return ax +from py123d.visualization.matplotlib.observation import add_scene_on_ax def plot_scene_at_iteration(scene: SceneAPI, iteration: int = 0, radius: float = 80) -> Tuple[plt.Figure, plt.Axes]: fig, ax = plt.subplots(figsize=(10, 10)) - _plot_scene_on_ax(ax, scene, iteration, radius) + add_scene_on_ax(ax, scene, iteration, radius) return fig, ax @@ -53,7 +24,7 @@ def render_scene_animation( fps: float = 20.0, dpi: int = 300, format: str = "mp4", - radius: float = 100, + radius: float = 80, ) -> None: assert format in ["mp4", "gif"], "Format must be either 'mp4' or 'gif'." output_path = Path(output_path) @@ -67,7 +38,7 @@ def render_scene_animation( def update(i): ax.clear() - _plot_scene_on_ax(ax, scene, i, radius) + add_scene_on_ax(ax, scene, i, radius) plt.subplots_adjust(left=0.05, right=0.95, top=0.95, bottom=0.05) pbar.update(1) diff --git a/src/py123d/visualization/matplotlib/utils.py b/src/py123d/visualization/matplotlib/utils.py index a4cfeab0..c305bf1a 100644 --- a/src/py123d/visualization/matplotlib/utils.py +++ b/src/py123d/visualization/matplotlib/utils.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union import matplotlib.pyplot as plt import numpy as np @@ -16,10 +16,10 @@ def add_shapely_polygon_to_ax( polygon: geom.Polygon, plot_config: PlotConfig, disable_smoothing: bool = False, - label: str = None, + label: Optional[str] = None, ) -> plt.Axes: - """ - Adds shapely polygon to birds-eye-view visualization with proper hole handling + """Adds shapely polygon to birds-eye-view visualization with proper hole handling + :param ax: matplotlib ax object :param polygon: shapely Polygon :param plot_config: dictionary containing plot parameters @@ -79,13 +79,13 @@ def add_shapely_linestring_to_ax( ax: plt.Axes, linestring: geom.LineString, plot_config: PlotConfig, - label: str = None, + label: Optional[str] = None, ) -> plt.Axes: - """ - Adds shapely linestring (polyline) to birds-eye-view visualization + """Adds shapely linestring (polyline) to birds-eye-view visualization + :param ax: matplotlib ax object :param linestring: shapely LineString - :param config: dictionary containing plot parameters + :param plot_config: dictionary containing plot parameters :return: ax with plot """ @@ -106,14 +106,12 @@ def add_shapely_linestring_to_ax( def get_pose_triangle(size: float) -> geom.Polygon: """Create a triangle shape for the pose.""" half_size = size / 2 - return geom.Polygon( - [ - [-half_size, -half_size], - [half_size, 0], - [-half_size, half_size], - [-size / 4, 0], - ] - ) + return geom.Polygon([ + [-half_size, -half_size], + [half_size, 0], + [-half_size, half_size], + [-size / 4, 0], + ]) def shapely_geometry_local_coords( diff --git a/tests/unit/api/api/test_scene_api.py b/tests/unit/api/api/test_scene_api.py index b3764aa8..91ad1e26 100644 --- a/tests/unit/api/api/test_scene_api.py +++ b/tests/unit/api/api/test_scene_api.py @@ -27,40 +27,51 @@ def __init__(self): self._map_api = Mock(spec=MapAPI) def get_log_metadata(self) -> LogMetadata: + """Inherited, see super class.""" return self._log_metadata def get_scene_metadata(self) -> SceneMetadata: + """Inherited, see super class.""" return self._scene_metadata def get_map_api(self) -> Optional[MapAPI]: + """Inherited, see super class.""" return self._map_api def get_timepoint_at_iteration(self, iteration: int) -> TimePoint: + """Inherited, see super class.""" return Mock(spec=TimePoint) def get_ego_state_at_iteration(self, iteration: int) -> Optional[EgoStateSE3]: + """Inherited, see super class.""" return Mock(spec=EgoStateSE3) def get_box_detections_at_iteration(self, iteration: int) -> Optional[BoxDetectionWrapper]: + """Inherited, see super class.""" return Mock(spec=BoxDetectionWrapper) def get_traffic_light_detections_at_iteration(self, iteration: int) -> Optional[TrafficLightDetectionWrapper]: + """Inherited, see super class.""" return Mock(spec=TrafficLightDetectionWrapper) def get_route_lane_group_ids(self, iteration: int) -> Optional[list]: + """Inherited, see super class.""" return [1, 2, 3] def get_pinhole_camera_at_iteration( self, iteration: int, camera_type: PinholeCameraType ) -> Optional[PinholeCamera]: + """Inherited, see super class.""" return Mock(spec=PinholeCamera) def get_fisheye_mei_camera_at_iteration( self, iteration: int, camera_type: FisheyeMEICameraType ) -> Optional[FisheyeMEICamera]: + """Inherited, see super class.""" return Mock(spec=FisheyeMEICamera) def get_lidar_at_iteration(self, iteration: int, lidar_type: LiDARType) -> Optional[LiDAR]: + """Inherited, see super class.""" return Mock(spec=LiDAR) @@ -88,51 +99,67 @@ class TestSceneAPIProperties: """Test property accessors of SceneAPI.""" def test_log_metadata(self, scene_api): + """Test log_metadata property.""" assert scene_api.log_metadata == scene_api._log_metadata def test_scene_metadata(self, scene_api): + """Test scene_metadata property.""" assert scene_api.scene_metadata == scene_api._scene_metadata def test_map_metadata(self, scene_api): + """Test map_metadata property.""" assert scene_api.map_metadata == scene_api._log_metadata.map_metadata def test_map_api(self, scene_api): + """Test map_api property.""" assert scene_api.map_api == scene_api._map_api def test_dataset(self, scene_api): + """Test dataset property.""" assert scene_api.dataset == "test_dataset" def test_split(self, scene_api): + """Test split property.""" assert scene_api.split == "test_split" def test_location(self, scene_api): + """Test location property.""" assert scene_api.location == "test_location" def test_log_name(self, scene_api): + """Test log_name property.""" assert scene_api.log_name == "test_log" def test_version(self, scene_api): + """Test version property.""" assert scene_api.version == "1.0.0" def test_scene_uuid(self, scene_api): + """Test scene_uuid property.""" assert scene_api.scene_uuid == "test-uuid-123" def test_number_of_iterations(self, scene_api): + """Test number_of_iterations property.""" assert scene_api.number_of_iterations == 100 def test_number_of_history_iterations(self, scene_api): + """Test number_of_history_iterations property.""" assert scene_api.number_of_history_iterations == 10 def test_vehicle_parameters(self, scene_api): + """Test vehicle_parameters property.""" assert scene_api.vehicle_parameters == scene_api._log_metadata.vehicle_parameters def test_available_pinhole_camera_types(self, scene_api): + """Test available_pinhole_camera_types property.""" assert scene_api.available_pinhole_camera_types == [PinholeCameraType.PCAM_B0] def test_available_fisheye_mei_camera_types(self, scene_api): + """Test available_fisheye_mei_camera_types property.""" assert scene_api.available_fisheye_mei_camera_types == [FisheyeMEICameraType.FCAM_L] def test_available_lidar_types(self, scene_api): + """Test available_lidar_types property.""" assert scene_api.available_lidar_types == [LiDARType.LIDAR_TOP] @@ -140,33 +167,41 @@ class TestSceneAPIMethods: """Test abstract method implementations.""" def test_get_timepoint_at_iteration(self, scene_api): + """Test get_timepoint_at_iteration method.""" result = scene_api.get_timepoint_at_iteration(0) assert isinstance(result, Mock) def test_get_ego_state_at_iteration(self, scene_api): + """Test get_ego_state_at_iteration method.""" result = scene_api.get_ego_state_at_iteration(0) assert result is not None def test_get_box_detections_at_iteration(self, scene_api): + """Test get_box_detections_at_iteration method.""" result = scene_api.get_box_detections_at_iteration(0) assert result is not None def test_get_traffic_light_detections_at_iteration(self, scene_api): + """Test get_traffic_light_detections_at_iteration method.""" result = scene_api.get_traffic_light_detections_at_iteration(0) assert result is not None def test_get_route_lane_group_ids(self, scene_api): + """Test get_route_lane_group_ids method.""" result = scene_api.get_route_lane_group_ids(0) assert result == [1, 2, 3] def test_get_pinhole_camera_at_iteration(self, scene_api): + """Test get_pinhole_camera_at_iteration method.""" result = scene_api.get_pinhole_camera_at_iteration(0, PinholeCameraType.PCAM_B0) assert result is not None def test_get_fisheye_mei_camera_at_iteration(self, scene_api): + """Test get_fisheye_mei_camera_at_iteration method.""" result = scene_api.get_fisheye_mei_camera_at_iteration(0, FisheyeMEICameraType.FCAM_L) assert result is not None def test_get_lidar_at_iteration(self, scene_api): + """Test get_lidar_at_iteration method.""" result = scene_api.get_lidar_at_iteration(0, LiDARType.LIDAR_TOP) assert result is not None diff --git a/tutorial/01_scene_tutorial.ipynb b/tutorial/01_scene_tutorial.ipynb index 1951c8e2..9aa57d3b 100644 --- a/tutorial/01_scene_tutorial.ipynb +++ b/tutorial/01_scene_tutorial.ipynb @@ -195,7 +195,7 @@ "source": [ "from py123d.datatypes.time import TimePoint\n", "\n", - "iteration = 10\n", + "iteration = 0\n", "timepoint: TimePoint = scene.get_timepoint_at_iteration(iteration=iteration)\n", "print(f\"Time at iteration {iteration}:\", timepoint)" ] diff --git a/tutorial/02_map_tutorial.ipynb b/tutorial/02_map_tutorial.ipynb index dc865881..4085b489 100644 --- a/tutorial/02_map_tutorial.ipynb +++ b/tutorial/02_map_tutorial.ipynb @@ -17,10 +17,18 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "1", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Dataset paths not set. Using default config: /home/daniel/py123d_workspace/py123d/src/py123d/script/config/common/default_dataset_paths.yaml\n" + ] + } + ], "source": [ "from typing import List, Optional\n", "\n", @@ -60,7 +68,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "3", "metadata": {}, "outputs": [], @@ -80,10 +88,18 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "5", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found 74 scenes.\n" + ] + } + ], "source": [ "scene_filter = SceneFilter(\n", " split_names=None,\n", @@ -103,7 +119,7 @@ "id": "6", "metadata": {}, "source": [ - "## 2.2 Inspecting the `MapAPI`\n" + "## 2.3 Inspecting the `MapAPI`\n" ] }, { @@ -116,10 +132,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "8", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "MapMetadata(dataset='nuplan', split=None, log_name=None, location='us-nv-las-vegas-strip', map_has_z=False, map_is_local=False, version='0.0.8')" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "scene: SceneAPI = np.random.choice(scenes)\n", "map_api: MapAPI = scene.get_map_api()\n", @@ -133,17 +160,37 @@ "id": "9", "metadata": {}, "source": [ - "## 2.3 Querying map objects\n", + "## 2.4 Querying map objects\n", "\n", "There are multiple categories, also called layers, of map objects in 123D. You can get the available layers with:" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "10", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "[,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "map_api.get_available_map_layers()" ] @@ -164,10 +211,28 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "12", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Map objects found in radius 100.0 around point Point3D(x=664457.8851674196, y=3998094.9338303064, z=615.8749372468315):\n", + "- LANE: 87 objects\n", + "- LANE_GROUP: 29 objects\n", + "- INTERSECTION: 10 objects\n", + "- CROSSWALK: 3 objects\n", + "- WALKWAY: 4 objects\n", + "- CARPARK: 0 objects\n", + "- GENERIC_DRIVABLE: 0 objects\n", + "- STOP_ZONE: 0 objects\n", + "- ROAD_EDGE: 14 objects\n", + "- ROAD_LINE: 215 objects\n" + ] + } + ], "source": [ "# You can define a query point and radius to search for map objects around that point.\n", "query_point: Optional[Point3D] = None # e.g. Point(x=0.0, y=0.0, z=0.0)\n", @@ -213,7 +278,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "14", "metadata": {}, "outputs": [], @@ -291,9 +356,9 @@ "id": "15", "metadata": {}, "source": [ - "## 2.4 Map Objects in 123D\n", + "## 2.5 Map Objects in 123D\n", "\n", - "### 2.3.1 `Lane`\n", + "### 2.5.1 `Lane`\n", "\n", "Let's start with the `Lane` object. Each lane can have multiple features, such as polylines from boundaries or the lane center, relational properties that point to neighboring map objects, or other features, such as the speed limit. \n", "We can sample a lane and have a look:" @@ -301,10 +366,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "16", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAtsAAAMJCAYAAADS8fo4AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAYFpJREFUeJzt3XmYFNXd9vG7uodZHYZFVtlRQVGiLBJE3AWNQdAoRDEqGo0RBXGJ8BoBURYD5sFHiRI16BMxxCgYt+ASxAWJoqISBUQFxYVF2bdZus/7R0/VdPf0AINT032mvp/r6qu71j7TNQx3/frUKccYYwQAAACgxoXS3QAAAACgriJsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDQACNHz9ejuPsc73LLrtM7dq1S5jnOI7Gjx9fY21Zs2aNHMfRI488UmP7BIBMQdgGLPXII4/IcRy9++676W5KtYwaNUrdunVTo0aNlJ+fryOOOELjx4/Xjh07EtZbsmSJrr32WnXp0kUFBQVq06aNBg8erE8//bTSPi+77DI5jlPp0blz54T13FCX6jFnzhxff25UzwsvvFAjgX7Pnj2aPHmyjjzySOXn5+uQQw7RBRdcoI8//ni/tv/ss890/vnnq2HDhsrPz9cJJ5ygV199da/blJaW6sgjj5TjOJo2bdqP/hmq49lnn1UoFNK6detq9X0BVC0r3Q0AECxLlixR3759NWzYMOXm5mrp0qWaMmWKXnnlFb3++usKhWI1gLvuukuLFi3SBRdcoK5du2rdunW677771K1bN/3nP//RUUcdlbDfnJwcPfTQQwnzioqKUrbhwgsv1M9+9rOEeb17967Bn7Ju2717t7Kyau6/j7Zt22r37t2qV6+eN++FF17QjBkzfnTgHjp0qJ555hldeeWV6tatm7799lvNmDFDvXv31rJly9S2bdsqt127dq169+6tcDism2++WQUFBZo1a5b69eunf//73zrxxBNTbnfvvffqq6+++lHtPlDPP/+8unfvrubNm6fl/QFURtgGUKvefPPNSvM6duyom266Se+8845++tOfSpJuuOEGPf7448rOzvbWGzJkiI4++mhNmTJFjz32WMI+srKydPHFF+9XG7p167bf66Ky3NzcGt2f4zg1vk9J+uabbzR37lzddNNNmjp1qje/b9++OvXUUzV37lyNGjWqyu2nTJmiLVu26L///a86deokSbryyivVuXNnjRo1Su+9916lbTZs2KAJEybolltu0dixY2v8Z9qXF154QZdffnmtvy+AqtGNBKjDSkpKNHbsWHXv3l1FRUUqKChQ3759K30N7navmDZtmv785z+rY8eOysnJUc+ePbVkyZJK+12xYoXOP/98NWrUSLm5uerRo4eeeeaZA26n2yd4y5Yt3rzjjz8+IWhL0mGHHaYuXbpo+fLlKfcTiUS0bdu2/XrPnTt3qqSk5IDam+zee+9Vly5dlJ+fr4YNG6pHjx56/PHHveVu/+gVK1Zo8ODBql+/vho3bqyRI0dqz549lfb32GOPqXv37srLy1OjRo30y1/+UmvXrq203ttvv60zzzxTRUVFys/P10knnaRFixZVWu/NN99Uz549lZubq44dO2rmzJk/6udN7rPt/nyffvqpLr74YhUVFalJkya67bbbZIzR2rVrNXDgQNWvX1/NmzfX3XffnbC/5D7bl112mWbMmOG9l/twfffdd1qxYoVKS0v32s7t27dLkpo1a5Ywv0WLFpKkvLy8vW7/xhtv6Nhjj/WCtiTl5+frnHPO0fvvv69Vq1ZV2mb06NHq1KlTtU7m4v/9zZgxQx06dFB+fr769euntWvXyhijO+64Q61atVJeXp4GDhyoTZs2VdrPsmXLtHbtWp199tnevH39bgLwH2EbqMO2bdumhx56SCeffLLuuusujR8/Xhs3blT//v31wQcfVFr/8ccf19SpU/Wb3/xGd955p9asWaPzzjsvIdR8/PHH+ulPf6rly5dr9OjRuvvuu1VQUKBBgwZp3rx5+9WusrIyff/99/r222/10ksv6fe//70KCwt13HHH7XU7Y4zWr1+vgw8+uNKyXbt2qX79+ioqKlKjRo00fPjwSv3AXbfffrsOOugg5ebmqmfPnnrppZf2q92pPPjggxoxYoSOPPJITZ8+XbfffruOOeYYvf3225XWHTx4sNeH+Gc/+5n+93//V1dddVXCOhMnTtQll1yiww47TH/84x91/fXXe10W4k9GFixYoBNPPFHbtm3TuHHjNGnSJG3ZskWnnnqq3nnnHW+9ZcuWqV+/ftqwYYPGjx+vYcOGady4cft9rKpjyJAhikajmjJlinr16qU777xT06dP1xlnnKFDDjlEd911lw499FDddNNNev3116vcz29+8xudccYZkqS//vWv3sM1ZswYHXHEEfrmm2/22p6OHTuqVatWuvvuu/Xss8/q66+/1jvvvKOrr75a7du31y9/+cu9bl9cXJwykOfn50tSpcr2O++8o0cffVTTp0/fr4tPk82ePVt/+tOfdN111+nGG2/Ua6+9psGDB+v3v/+95s+fr1tuuUVXXXWVnn32Wd10002Vtn/hhRfUtGlT9ejRQ1L1fjcB+MgAsNKsWbOMJLNkyZIq1ykrKzPFxcUJ8zZv3myaNWtmLr/8cm/e6tWrjSTTuHFjs2nTJm/+P//5TyPJPPvss9680047zRx99NFmz5493rxoNGqOP/54c9hhh+1X2xcvXmwkeY9OnTqZV199dZ/b/fWvfzWSzMMPP5wwf/To0eaWW24xf//7383f/vY3c+mllxpJpk+fPqa0tNRb78svvzT9+vUz999/v3nmmWfM9OnTTZs2bUwoFDLPPffcfrU92cCBA02XLl32us64ceOMJHPOOeckzL/mmmuMJPPhhx8aY4xZs2aNCYfDZuLEiQnrLVu2zGRlZXnzo9GoOeyww0z//v1NNBr11tu1a5dp3769OeOMM7x5gwYNMrm5uebLL7/05n3yyScmHA6b/fkv4NJLLzVt27ZNmCfJjBs3rtLPd9VVV3nzysrKTKtWrYzjOGbKlCne/M2bN5u8vDxz6aWXevPc379Zs2Z584YPH15l+9zju3r16n22/+233zYdO3ZM+H3r3r27+e677/a57YABA0yDBg3Mtm3bEub37t3bSDLTpk3z5kWjUXPccceZCy+8MOFnmjp16j7fx123SZMmZsuWLd78MWPGGEnmJz/5ScLv8YUXXmiys7MT/g0aY0zfvn0TPtf9+d0E4D8q20AdFg6Hva4Y0WhUmzZtUllZmXr06KH333+/0vpDhgxRw4YNvem+fftKkr744gtJ0qZNm7RgwQINHjxY27dv1/fff6/vv/9eP/zwg/r3769Vq1bts9ooSUceeaRefvllPf300/rd736ngoKCKqvQrhUrVmj48OHq3bu3Lr300oRlkydP1pQpUzR48GD98pe/1COPPKKJEydq0aJFevLJJ7312rRpoxdffFFXX321BgwYoJEjR2rp0qVq0qSJbrzxxn22O5UGDRro66+/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P/ywSkpKtHv3bj388MM64ogjvP8cFy9erBNPPDFhiLL+/ftr5cqVCV/xTZgwQU2bNk3ouxjv2muv1eLFizVnzhx99NFHuuCCC3TmmWd6/0k+++yz6tChg5577jm1b99e7dq1069//esDqsQhONyKWHwF7O2339bixYsPaH9NmzbVySefrJkzZ+q7776rtHzjxo173X7Lli0ph2tzb0bjjqIgxQLSkCFDtHjxYv3jH/+o8qYze/bs8YZ4i3fHHXfIGKMzzzxzr+375ptv9Je//EVdu3b1hoSrjh9++CFhOjs7W0ceeaSMMZV+Vnc4O9e9994rKdZdQYpV+MPhsG6//fZKlV9jjPde3bt3V8eOHTVt2rSUfd3dnzMcDqt///56+umnE26ysnz5cq/bQaYqKCiQpEqFCWn/h/5zT1iSvzF55plntHPnTh177LHevK1bt2rFihUpu1fFe+uttzR37lxdccUVXhV5xIgRmjdvXsLD/Rbisssu07x583wtjixZskQbNmxI6K8tVe93E4B/rKhsS7G+Z/fdd5927dqln/70p3ruueeqXPenP/2pCgoKdMstt2jSpEkyxmj06NGKRCJeQDj11FN1ww03aOrUqRo5cqR27typ0aNHS5K3TmFhoRYuXKhBgwbpjjvukBTrc/niiy96F6usW7eu0h9Rd0zXdevWqWHDhnrzzTf18MMPpxxqTYpVRWbNmqWvvvrK+4r8pptu0vz58zVr1ixNmjRJX3zxhb788kv94x//0P/93/8pEolo1KhROv/887VgwYID/FRRF/zlL3/R/PnzK80fOXKkfv7zn2vu3Lk699xzdfbZZ2v16tV64IEHdOSRR+7zgsSqzJgxQyeccIKOPvpoXXnllerQoYPWr1+vxYsX6+uvv66y37UkLVy4UCNGjND555+vww47TCUlJXrjjTc0d+5c9ejRI2Fs4htvvFHPPPOMBgwYoE2bNlW6iY277rp163Tsscfqwgsv9G7P/uKLL+qFF17QmWeeqYEDB3rb/O53v9Pnn3+u0047TS1bttSaNWs0c+ZM7dy5U/fcc0/C/h955BENGzZMs2bNSuhelqxfv35q3ry5+vTpo2bNmmn58uW67777dPbZZ6uwsDBh3dWrV+ucc87RmWeeqcWLF+uxxx7TRRddpJ/85CeSYhXrO++8U2PGjNGaNWs0aNAgFRYWavXq1Zo3b56uuuoq3XTTTQqFQnrooYd01llnqUuXLho2bJgOOeQQffPNN3r11VdVv359Pfvss5JiwxzOnz9fffv21TXXXKOysjJv7OWPPvqoyp8r3bp37y4pFmT79++vcDjsDdU3ZswYPfroo1q9evVeL5IcMGCAunTpogkTJujLL7/UT3/6U3322We677771KJFi4Tix7x58yod7y+//FKDBw/WOeeco+bNm+vjjz/2vgGdNGmSt223bt3UrVu3hPd2++F36dLlgK7RqY7nn39e7dq1q/TNT3V+NwH4qLaHP3FNnDjRFBQUeI9QKGRycnIS5sUPVbVx40azcuVK89JLL5k+ffqYn/3sZwlDXiV78cUXTYcOHYzjOCYcDpuLL77YdOvWzVx99dXeOrNnzzbNmjUz4XDYZGdnm5tuusk0a9bMG6Zq165d5rjjjjOXXHKJeeedd8zixYvNL37xC9OlSxeza9cuY4wxZ5xxRsJwV8YY8/HHHxtJ5pNPPjHbtm0z7dq1My+88IK3/NJLLzUDBw70pp977jkjKeFnLygoMFlZWWbw4MHGGGOuvPJKI8msXLnS2+69994zksyKFSsO4AjAdu7Qf1U91q5da6LRqJk0aZJp27atycnJMccee6x57rnnKg3ntrdhypQ0zJsxxnz++efmkksuMc2bNzf16tUzhxxyiPn5z39unnzyyb22+bPPPjOXXHKJ6dChg8nLyzO5ubmmS5cuZty4cWbHjh0J65500kl7/flcmzdvNhdffLE59NBDTX5+vsnJyTFdunQxkyZNMiUlJQn7fPzxx82JJ55omjRpYrKysszBBx9szj33XPPee+9Vauu9995rJJn58+fv9WeaOXOmOfHEE03jxo1NTk6O6dixo7n55pvN1q1bvXXcofE++eQTc/7555vCwkLTsGFDc+2115rdu3dX2udTTz1lTjjhBO9vQefOnc3w4cMT/v0bY8zSpUvNeeed571327ZtzeDBg82///3vhPVee+010717d5OdnW06dOhgHnjgAa9N+1Kdof82btxYaduCgoJK+zzppJMShqRLNfRfWVmZue6660yTJk2M4zgJba3O0H+bNm0yo0aNMocffrjJyckxBx98sPnlL39pvvjii4T13H9P8W3YtGmTGThwoGnevLnJzs427du3N7fcckuloQBTOZCh/5LXffXVV40k849//CNlW91hP3v06GGuueaaSvvdn99NAP5LW9j+4YcfzKpVq7zHcccdZ+66666EefHjisZbu3atkWTeeuutfb7Pxo0bzebNm40xxjRr1sz84Q9/qLTOunXrzPbt282OHTtMKBQyTzzxhDHGmIceesg0bdrURCIRb93i4mKTn59v/va3vxljjPnVr36VEJyNMWbBggVGktm0aZNZunSpkWTC4bD3cBzHOwn47LPPzJw5c0w4HDYrVqxI+PlXrVrljQU7duxYk5WVlfA+u3btMpLMSy+9tM/PAUD1XHDBBaZnz541sq+qwijwY61bt844jmOef/75dDcFQBXS1o2kUaNGatSokTedl5enpk2b7tdIBu6V+MXFxftc1x1yasGCBdqwYUOlq9Klim4ff/nLX5Sbm+vduWzXrl0KhUIJF+q4024bevfurVtvvTXhYs2XX35ZnTp1UsOGDZWXl6dly5YlvN/vf/97bd++Xffcc49at26tSCSiSCSiDRs2VDlSSZ8+fVRWVqbPP//cuzjq008/lRS7aBRAzTHGaOHChZW6rgCZZuvWrRo7dqxOOeWUdDcFQBUy/gLJt99+W/fdd58++OADffnll1qwYIEuvPBCdezY0btg6ptvvlHnzp0TblE8a9Ys/ec//9Hnn3+uxx57TBdccIFGjRqVMLbsfffdp/fff1+ffvqpZsyYoWuvvVaTJ09WgwYNJElnnHGGNm/erOHDh2v58uX6+OOPNWzYMGVlZXl/2C666CJlZ2friiuu0Mcff6y///3vuueee3TDDTdIil2YedRRRyU8GjRooMLCQh111FHKzs7W4YcfrqFDh+qSSy7R3LlztXr1ar3zzjuaPHmynn/+eUnS6aefrm7duunyyy/X0qVL9d5773m3NN7bqAUAqs9xHG3YsCFhzGIgEx1++OEaP358ytvKA8gMGR+28/PzNXfuXJ122mnq1KmTrrjiCnXt2lWvvfaaN9pIaWmpVq5cqV27dnnbrVy5UoMGDdIRRxyhCRMm6NZbb9W0adMS9v3OO+/ojDPO0NFHH60///nPmjlzpkaMGOEt79y5s5599ll99NFH6t27t/r27atvv/1W8+fP90YtKCoq0ksvvaTVq1ere/fuuvHGGzV27NiEYf/2x6xZs3TJJZfoxhtvVKdOnTRo0CAtWbLEu8lBKBTSs88+q4MP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HtEHH3ygTp06qW3btrX698kYo++++06zZ8/WsmXLdMUVVygUCvTIsAAykK3dSKoVtvv06aOVK1cmzPv000/Vtm3bGm1UbaHPNoBMsGrVKi1dulQ33HCD+vbtm7Z2LFq0SNOmTdOqVavUqVOntLUDAKpiY2arVuli1KhR+s9//qNJkybps88+0+OPP64///nPGj58uF/t8xWVbQCZYPny5WrUqJFOOOGEtLbj+OOP18EHH6xPPvkkre0AgLqkWpXtnj17at68eRozZowmTJig9u3ba/r06Ro6dKhf7asVNp4lAag7du3apYYNG1YqAAwcOFDr169XKBTSQQcdpKlTp6pTp0667LLLtGLFCuXl5alJkyb6n//5H3Xs2FGSdNZZZ2nt2rVe/8GLLrpI11577T6XSbECRMOGDbVr167a+LEBoFoikYgWLVqkkpIStWjRQn379rXiGpNqhW1J+vnPf66f//znfrSl1rndSKLRaLqbAgCVPProo2rQoIGk2NCrV199tV599VUNGzZM/fr1k+M4mjlzpq699lr961//8rabPHmyBgwYkHKfe1sm8Y0fgMz00ksv6Y477tDGjRu9ea1atdI999yj8847L40t27dAXQEzfvx43XHHHd60G7Yl6U9/+pPuvffedDUNACpxg7YUu+rdcRzl5uaqf//+3t+unj176quvvkpTCwHAfy+99JJGjhyZELQl6ZtvvtH555+vuXPnpqll+6falW2bhcNhjR07VpJ02223SYoF7gcffFAPPPCARowYkc7mSZKiUSkSkSIRp8rnaNRRWZn267li29jraLRivjH7npf4HL+e25aq51W1jTGx1+467s/tzotfXvF678vddaJRZy/7i/188dtIya8rqnqpl1e9TnXXTRZfUKyquOg4JuU61X0dLxQychzFPZKnK+aHQhXTqbetent3Xbct7rS7z1CoYv/hsKk0P/ao2CYcrnidap1wOHH9qtZxfy73Pd3twuHYvPhpv+YXF1f8jsZ/vpJ01VVX6fXXX5eklCM/3X///Tr77LMT5o0bN0533nmnOnfurPHjxyfc6XdvywAg00QiEU2aNClll19jjBzH0fXXX6+BAwdmbJeSQIVtN2CPHTtWq1dLPXvepscf/6vefPMB9elzi0KhGzVjRiyolpU5KiuLhcQDfR0/7YbesrJYwHSfk8N0fDgD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jRw5Ui+//LJyc3PT3Zw6hbBdRxx88MEKh8Nav359wvz169erefPmaWoVgExz7bXX6rnnntPrr7+uVq1apbs5SLPs7GwdeuihkqTu3btryZIluueeezRz5sw0twy17b333tOGDRvUrVs3b14kEtHrr7+u++67T8XFxQqHw2lsob3os11HZGdnq3v37vr3v//tzYtGo/r3v/9N/zsAMsbo2muv1bx587RgwQK1b98+3U1CBopGoyouLk53M5AGp512mpYtW6YPPvjAe/To0UNDhw7VBx98QND+Eahs1yE33HCDLr30UvXo0UPHHXecpk+frp07d2rYsGHpbhrSYMeOHfrss8+86dWrV+uDDz5Qo0aN1KZNmzS2DOkwfPhwPf744/rnP/+pwsJCrVu3TpJUVFSkvLy8NLcO6TBmzBidddZZatOmjbZv367HH39cCxcu1IsvvpjupiENCgsLK13DUVBQoMaNG3Ntx49E2K5DhgwZoo0bN2rs2LFat26djjnmGM2fP7/SRZMIhnfffVennHKKN33DDTdIki699FI98sgjaWoV0uX++++XJJ188skJ82fNmqXLLrus9huEtNuwYYMuueQSfffddyoqKlLXrl314osv6owzzkh304A6hXG2AQAAAJ/QZxsAAADwCWEbAAAA8AlhGwAAAPAJYRsAAADwCWEbAAAA8AlhGwAAAPAJYRsAAADwCWEbAAAAB+T5559Xr169lJeXp4YNG2rQoEH73Gb58uU655xzVFRUpIKCAvXs2VNfffVVpfWMMTrrrLPkOI6efvrplPv64Ycf1KpVKzmOoy1btiQsmz17tn7yk58oPz9fLVq00OWXX64ffvihWj9fu3bt5DhOwmPKlCnV2gdhGwAAACmdfPLJVd51+KmnntKvfvUrDRs2TB9++KEWLVqkiy66aK/7+/zzz3XCCSeoc+fOWrhwoT766CPddtttys3NrbTu9OnT5TjOXvd3xRVXqGvXrpXmL1q0SJdccomuuOIKffzxx/rHP/6hd955R1deeeVe95fKhAkT9N1333mP6667rlrbc7t2AAAAVEtZWZlGjhypqVOn6oorrvDmH3nkkXvd7tZbb9XPfvYz/eEPf/DmdezYsdJ6H3zwge6++269++67atGiRcp93X///dqyZYvGjh2rf/3rXwnLFi9erHbt2mnEiBGSpPbt2+s3v/mN7rrrroT1HnroId19991avXq1t/4111yTsE5hYaGaN2++159rb6hsAwAAoFref/99ffPNNwqFQjr22GPVokULnXXWWfrvf/9b5TbRaFTPP/+8Dj/8cPXv319NmzZVr169KnUR2bVrly666CLNmDGjypD7ySefaMKECfq///s/hUKV42zv3r21du1avfDCCzLGaP369XryySf1s5/9zFtn9uzZGjt2rCZOnKjly5dr0qRJuu222/Too48m7GvKlClq3Lixjj32WE2dOlVlZWXV+KQI2wAAAKimL774QpI0fvx4/f73v9dzzz2nhg0b6uSTT9amTZtSbrNhwwbt2LFDU6ZM0ZlnnqmXXnpJ5557rs477zy99tpr3nqjRo3S8ccfr4EDB6bcT3FxsS688EJNnTpVbdq0SblOnz59NHv2bA0ZMkTZ2dlq3ry5ioqKNGPGDG+dcePG6e6779Z5552n9u3b67zzztOoUaM0c+ZMb50RI0Zozpw5evXVV/Wb3/xGkyZN0u9+97vqfVgGAAAAMMZMnDjRFBQUeI9QKGRycnIS5n355Zdm9uzZRpKZOXOmt+2ePXvMwQcfbB544IGU+/7mm2+MJHPhhRcmzB8wYID55S9/aYwx5p///Kc59NBDzfbt273lksy8efO86VGjRpkhQ4Z406+++qqRZDZv3uzN+/jjj02LFi3MH/7wB/Phhx+a+fPnm6OPPtpcfvnlxhhjduzYYSSZvLy8hJ8tJyfHNG3atMrP5+GHHzZZWVlmz549+/4wy9FnGwAAAJKkq6++WoMHD/amhw4dql/84hc677zzvHktW7b0+lHH99HOyclRhw4dUo4sIkkHH3ywsrKyKvXrPuKII/Tmm29KkhYsWKDPP/9cDRo0SFjnF7/4hfr27auFCxdqwYIFWrZsmZ588klJsVFL3P3feuutuv322zV58mT16dNHN998sySpa9euKigoUN++fXXnnXd6XU8efPBB9erVK+G9wuFwlZ9Pr169VFZWpjVr1qhTp05VrhePsA0AAABJUqNGjdSoUSNvOi8vT02bNtWhhx6asF737t2Vk5OjlStX6oQTTpAklZaWas2aNWrbtm3KfWdnZ6tnz55auXJlwvxPP/3U22b06NH69a9/nbD86KOP1v/8z/9owIABkmKjoOzevdtbvmTJEl1++eV64403vIstd+3apaysxJjrhmhjjJo1a6aWLVvqiy++0NChQ/fvw1Hsws1QKKSmTZvu9zaEbQAAAFRL/fr1dfXVV2vcuHFq3bq12rZtq6lTp0qSLrjgAm+9zp07a/LkyTr33HMlSTfffLOGDBmiE088Uaeccormz5+vZ599VgsXLpQkNW/ePOVFkW3atFH79u0lVR695Pvvv5cUq5C7FfEBAwboyiuv1P3336/+/fvru+++0/XXX6/jjjtOLVu2lCTdfvvtGjFihIqKinTmmWequLhY7777rjZv3qwbbrhBixcv1ttvv61TTjlFhYWFWrx4sUaNGqWLL75YDRs23O/PirANAACAaps6daqysrL0q1/9Srt371avXr20YMGChCC6cuVKbd261Zs+99xz9cADD2jy5MkaMWKEOnXqpKeeesqrjteUyy67TNu3b9d9992nG2+8UQ0aNNCpp56aMPTfr3/9a+Xn52vq1Km6+eabVVBQoKOPPlrXX3+9pFi3mDlz5mj8+PEqLi5W+/btNWrUKN1www3Vaotj3I4uAAAAAGoUQ/8BAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA++f804YTTIlRiMwAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Sample a random lane\n", "if (lanes := map_object_dict[MapLayer.LANE]) is not None and len(lanes) > 0:\n", @@ -347,10 +423,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "18", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "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93Pv3btXRqNRdevWtbqGjRs3WvU/Jz179tQvv/yijRs3qm7dunrssceUmpoq6Ub1IyMjQ7NmzVJUVJTuu+8+ff755zpy5Ih+/PFHSdLTTz+twYMH65///Kfc3d1133336fHHH5ckubjc3rfUTHoGAABwsDp16shgMOQ7sTnrRvTmgHHzeP6b5TS06e/vpaSkqEuXLpoyZUq2tkFBQZY///3RsAaDQSaTKc++5mfx4sV66aWXNH36dEVGRsrX11fTpk3T9u3brdrlde6UlBS5urpq9+7dVqFCknx8fPI8v7+/v/z9/VWnTh3dd999KlOmjFasWKEePXpYrr1+/fqW9uXLl1e5cuUsgcZgMGjKlCmaOHGiEhISVL58eUvlo1atWrfwiZQcBAYAAAAHCwwMVFRUlN59910NGTIk24395cuXFRAQYBnuEx8fr6ZNm0qS1QTowmrWrJmWLVumGjVqZHuKUmGUKlWq0I+Q3bJli5o3b65nn33W8l5+VYG/a9q0qYxGoy5cuKCWLVsWat+bmc1mmc1mpaWlSZJatGghSTp06JCqVKki6cY8kz///FPVq1e32tfV1VXBwcGSpM8//1yRkZEFHpZVUt3e9RMAAHDHy8zMVHp6ul1fhX2kqnRjeI7RaNQ999yjZcuW6ciRIzp48KBmzZqlyMhISTcm1d53332WycwbN27UqFGjbvmzGDRokC5duqQePXpo586dOnbsmGJjY9W3b99CBYAaNWpo3bp1SkhIUGJiYoH2qVOnjnbt2qXY2FgdPnxYr732WoEnTGepW7euevbsqV69emn58uU6ceKEduzYoUmTJmn16tU57nP8+HFNmjRJu3fv1qlTp7R161Y9+uij8vLyUqdOnSzH7dq1q55//nlt3bpV+/btU+/evRUWFqbWrVtLkv7880+9//77+u233xQXF6fnn39eS5Ys0dtvv211vri4OMXFxSklJUV//PGH4uLidODAgUJdZ3FDYAAAALclV1dXeXl5KTMzU6mpqXZ9ZWZmysvLK9swmbzUqlVLP//8s1q3bq0XX3xRDRs2VLt27bRu3TrNmTPH0m7evHnKzMxURESEhg4dqjfeeOOWP5PKlStry5YtMhqNat++vRo1aqShQ4cqICCgUOPwp0+frrVr16pq1aqWykd+/u///k/dunVT9+7dde+99+rixYtW1YaC+vjjj9WrVy+9+OKLCg0N1UMPPaSdO3eqWrVqObb39PTUpk2b1KlTJ9WuXVvdu3eXr6+vtm7dqgoVKljaffLJJ7r33nvVuXNn3X///SpVqpTWrFljNURqwYIFuuuuu9SiRQvt379fGzZs0D333GN1vqZNm6pp06bavXu3PvvsMzVt2tQSTEoqg9kes25uY8nJyfL391dSUpL8/Pyc3R0AAO54qampOnHihGrWrJntsZcZGRmFHjpzq1xdXbONvwecKa+/G4W5p2UOAwAAuG2VKlWKm3jARgxJAgAAAJArAgMAAACAXBEYAAAAAOSKwAAAAAAgV0x6BgDYV8YV6fwGyZSWT0ND7j97VpDKN5cMfM8FAI5GYAAA2Ff6JSn5sFTK59b2z7wuZVyWyt1LYAAAJyAwAADs7H+VAq/Kt7Z7eqJkLvwqugCAosFXNQAA+zP8fbjRLWCdUQBwCgIDAMAxuOEHbhsGg0ErV66UJJ08eVIGg0FxcXFO7RPsh8AAALCzIqguALexhIQEPffcc6pVq5Y8PDxUtWpVdenSRevWrSuyczzwwAMaOnRokR3vZlWrVlV8fLwaNmxol+PD+ZjDAABwELMID4C1kydPqkWLFgoICNC0adPUqFEjZWRkKDY2VoMGDdJvv/3m7C5aSU9Pl7u7u9V7rq6uqlSpkpN6BEegwgAAsK+imL8AFJLZbFZ6errDX+ZCDr179tlnZTAYtGPHDkVHR6tu3bpq0KCBhg0bpp9++kmSdPnyZfXv31/ly5eXn5+f2rRpoz179liOMXbsWDVp0kQLFy5UjRo15O/vr8cff1xXrlyRJPXp00cbN27UzJkzZTAYZDAYdPLkSUnSvn371LFjR/n4+KhixYp68skn9eeff1qO/cADD2jw4MEaOnSoypUrp6ioqGzX8PchSRs2bJDBYNC6det01113qXTp0mrevLkOHTpktd9XX32lZs2aydPTU7Vq1dLrr7+uzEwecFAcUWEAAAC3nYyMDE2aNMnh542Jicn2DXxuLl26pDVr1mjChAny9vbOtj0gIECS9Oijj8rLy0vfffed/P39NXfuXD344IM6fPiwAgMDJUnHjh3TypUr9c033ygxMVGPPfaYJk+erAkTJmjmzJk6fPiwGjZsqHHjxkmSypcvr8uXL6tNmzbq37+/3nrrLV2/fl2vvPKKHnvsMa1fv97SjwULFuiZZ57Rli1bCvVZjBw5UtOnT1f58uU1cOBAPfXUU5ZjbNq0Sb169dKsWbPUsmVLHTt2TAMGDJAkjRkzplDngf0RGAAAdmb4X5WBSc/AzY4ePSqz2aywsLBc22zevFk7duzQhQsX5OHhIUl68803tXLlSi1dutRyk20ymTR//nz5+vpKkp588kmtW7dOEyZMkL+/v9zd3VW6dGmroUOzZ89W06ZNNXHiRMt78+bNU9WqVXX48GHVrVtXklSnTh1NnTq10Nc3YcIE3X///ZKkESNGqHPnzkpNTZWnp6def/11jRgxQr1795Yk1apVS+PHj9fLL79MYCiGCAwAAOC2U6pUKcXExDjlvAVVkOFLe/bsUUpKisqWLWv1/vXr13Xs2DHLzzVq1LCEBUkKCgrShQsX8j32jz/+KB+f7IsqHjt2zBIYIiIi8u1nTsLDw636I0kXLlxQtWrVtGfPHm3ZskUTJkywtDEajUpNTdW1a9dUunTpWzon7IPAAABwAOYxwLEMBkOBhwY5S506dWQwGPKc2JySkqKgoCBt2LAh27asIUtS9qBiMBhkMpnyPH9KSoq6dOmiKVOmZNuWdYMvKcfhUgVxc58M/5vLlNWnlJQUvf766+rWrVu2/Tw9PW/pfLAfAgMAAIATBAYGKioqSu+++66GDBmS7cb88uXLatasmRISEuTm5qYaNWrc8rnc3d1lNBqt3mvWrJmWLVumGjVqyM3NsbeEzZo106FDh1S7dm2Hnhe3hqckAQDsrKiqC8yBwO3n3XffldFo1D333KNly5bpyJEjOnjwoGbNmqXIyEi1bdtWkZGReuihh/T999/r5MmT2rp1q0aOHKldu3YV+Dw1atTQ9u3bdfLkSf35558ymUwaNGiQLl26pB49emjnzp06duyYYmNj1bdv32zhoqiNHj1an3zyiV5//XXt379fBw8e1OLFizVq1Ci7nhe3hsAAAHAMVnoGsqlVq5Z+/vlntW7dWi+++KIaNmyodu3aad26dZozZ44MBoO+/fZbtWrVSn379lXdunX1+OOP6/fff1fFihULfJ6XXnpJrq6uql+/vsqXL69Tp06pcuXK2rJli4xGo9q3b69GjRpp6NChCggIkIuLfW8Ro6Ki9M033+j777/X3Xffrfvuu09vvfWWqlevbtfz4tYYzIV9YPAdLjk5Wf7+/kpKSpKfn5+zuwMAxd/1eOn3L6TS1SWXWxj2kJ4omTOlGj0lV4+i7x9KvNTUVJ04cUI1a9Zk/Dtwk7z+bhTmnpYKAwDAAZj0DAAlFYEBAGBnWWGBgjYAlEQEBgBACUDYAABnITAAAByAIUkAUFIRGAAAdkZYAICSjMAAAHAQhhUBQElEYAAAlBAEDgBwBgIDAMC+DAxJAoCSjMAAALA/g8GGlZ4JHADgTLew5CYAAIXBDT9KBqPRqE2bNik+Pl5BQUFq2bKlXF1dnd0twOmoMAAAHIDQgOJt+fLlqlGjhlq3bq0nnnhCrVu3Vo0aNbR8+XK7nvePP/7QM888o2rVqsnDw0OVKlVSVFSUtmzZIkkyGAxauXJlkZzr5MmTMhgMiouLK5Lj4c5BhQEAUPzd8nAmIH/Lly/XI488IvPf/j87e/asHnnkES1dulTdunWzy7mjo6OVnp6uBQsWqFatWjp//rzWrVunixcvFul50tPTi/R4uLNQYQAA2BnVBRRfRqNRzz//fLawIMny3tChQ2U0Gov83JcvX9amTZs0ZcoUtW7dWtWrV9c999yjmJgY/etf/1KNGjUkSQ8//LAMBoPl52PHjqlr166qWLGifHx8dPfdd+uHH36wOnaNGjU0fvx49erVS35+fhowYIBq1qwpSWratKkMBoMeeOCBIr8m3J4IDAAABzCIx6KiONq0aZPOnDmT63az2azTp09r06ZNRX5uHx8f+fj4aOXKlUpLS8u2fefOnZKkjz/+WPHx8ZafU1JS1KlTJ61bt06//PKLOnTooC5duujUqVNW+7/55ptq3LixfvnlF7322mvasWOHJOmHH35QfHy83Ydb4fZBYAAA2BePVUUxFh8fX6TtCsPNzU3z58/XggULFBAQoBYtWujVV1/Vr7/+KkkqX768JCkgIECVKlWy/Ny4cWP93//9nxo2bKg6depo/PjxCgkJ0apVq6yO36ZNG7344osKCQlRSEiIZf+yZcuqUqVKCgwMLPJrwu2JwAAAAO5YQUFBRdqusKKjo3Xu3DmtWrVKHTp00IYNG9SsWTPNnz8/131SUlL00ksvqV69egoICJCPj48OHjyYrcJw11132aXPuPMQGAAA9meQGJKE4qhly5aqUqWKDLlUwgwGg6pWraqWLVvarQ+enp5q166dXnvtNW3dulV9+vTRmDFjcm3/0ksvacWKFZo4caI2bdqkuLg4NWrUKNvEZm9vb7v1GXcWAgMAwM5sHZLEkCbYj6urq2bOnClJ2UJD1s9vv/22Q9djqF+/vq5evSpJKlWqVLYJ11u2bFGfPn308MMPq1GjRqpUqZJOnjyZ73Hd3d0lyS4TuHF7IzAAABzAlpWeAfvq1q2bli5dquDgYKv3q1SpYtdHql68eFFt2rTRokWL9Ouvv+rEiRNasmSJpk6dqq5du0q68bSjdevWKSEhQYmJiZKkOnXqaPny5YqLi9OePXv0xBNPyGQy5Xu+ChUqyMvLS2vWrNH58+eVlJRkl+vC7adQgWHOnDkKDw+Xn5+f/Pz8FBkZqe+++y7X9hkZGRo3bpxCQkLk6empxo0ba82aNVZtrly5oqFDh6p69ery8vJS8+bNLU8ByJKSkqLBgwerSpUq8vLyUv369fX+++9btUlNTdWgQYNUtmxZ+fj4KDo6WufPn7ds37Nnj3r06KGqVavKy8tL9erVs3yjAAAoAQgcsKNu3brp5MmT+vHHH/XZZ5/pxx9/1IkTJ+wWFqQbT0m699579dZbb6lVq1Zq2LChXnvtNT399NOaPXu2JGn69Olau3atqlatqqZNm0qSZsyYoTJlyqh58+bq0qWLoqKi1KxZs3zP5+bmplmzZmnu3LmqXLmyJZQA+TGYc3rwcC6+/vprubq6qk6dOjKbzVqwYIGmTZumX375RQ0aNMjW/pVXXtGiRYv0n//8R2FhYYqNjdWwYcO0detWy//03bt31759+zRnzhxVrlxZixYt0ltvvaUDBw5Ykv6AAQO0fv16ffjhh6pRo4a+//57Pfvss1q+fLn+9a9/SZKeeeYZrV69WvPnz5e/v78GDx4sFxcXy0qJ8+bN0549e9StWzdVrVpVW7du1YABAzR16lQNHjy4wB9YcnKy/P39lZSUJD8/vwLvBwB3rPRE6eRiyT1Qcit9C/snSaZUqUZPyc2r6PuHEi81NVUnTpxQzZo15enp6ezuAMVGXn83CnNPW6jAkJPAwEBNmzZN/fr1y7atcuXKGjlypAYNGmR5Lzo6Wl5eXlq0aJGuX78uX19fffXVV+rcubOlTUREhDp27Kg33nhDktSwYUN1795dr732Wo5tkpKSVL58eX322Wd65JFHJEm//fab6tWrp23btum+++7Lse+DBg3SwYMHtX79+gJfL4EBAAqJwAA7IzAAOSuqwHDLcxiMRqMWL16sq1evKjIyMsc2aWlp2Trn5eWlzZs3S5IyMzNlNBrzbCNJzZs316pVq3T27FmZzWb9+OOPOnz4sNq3by9J2r17tzIyMtS2bVvLPmFhYapWrZq2bduW6zUkJSXl+wzitLQ0JScnW70AAIXBpGUAKMkKHRj27t0rHx8feXh4aODAgVqxYoXq16+fY9uoqCjNmDFDR44ckclk0tq1a7V8+XLL4ie+vr6KjIzU+PHjde7cORmNRi1atEjbtm2zWiDlnXfeUf369VWlShW5u7urQ4cOevfdd9WqVStJUkJCgtzd3RUQEGB1/ooVKyohISHHvm3dulVffPGFBgwYkOf1Tpo0Sf7+/pZX1apVC/pRAQCykBkAoMQqdGAIDQ1VXFyctm/frmeeeUa9e/fWgQMHcmw7c+ZM1alTR2FhYXJ3d9fgwYPVt29fubj8ddqFCxfKbDYrODhYHh4emjVrlnr06GHV5p133tFPP/2kVatWaffu3Zo+fboGDRqkH3744RYuWdq3b5+6du2qMWPGWKoUuYmJiVFSUpLldfr06Vs6JwDcubLSApOWAaAkcivsDu7u7qpdu7akG/MIdu7cqZkzZ2ru3LnZ2pYvX14rV65UamqqLl68qMqVK2vEiBGqVauWpU1ISIg2btyoq1evKjk5WUFBQerevbulzfXr1/Xqq69qxYoVlnkO4eHhiouL05tvvqm2bduqUqVKSk9P1+XLl62qDOfPn1elSpWs+nTgwAE9+OCDGjBggEaNGpXv9Xp4eMjDw6OwHxMAAABwW7B5HQaTyaS0tLQ823h6eio4OFiZmZlatmxZjo/x8vb2VlBQkBITExUbG2tpk5GRoYyMDKuKg3RjoZWsZw5HRESoVKlSWrdunWX7oUOHdOrUKav5Ffv371fr1q3Vu3dvTZgw4ZavGQBQWAZRYQCAkqlQFYaYmBh17NhR1apV05UrV/TZZ59pw4YNio2NlST16tVLwcHBmjRpkiRp+/btOnv2rJo0aaKzZ89q7NixMplMevnlly3HjI2NldlsVmhoqI4eParhw4crLCxMffv2lST5+fnp/vvv1/Dhw+Xl5aXq1atr48aN+uSTTzRjxgxJkr+/v/r166dhw4YpMDBQfn5+eu655xQZGWl5QtK+ffvUpk0bRUVFadiwYZa5Da6uripfvryNHyMAIFcGJjAAQElWqMBw4cIF9erVS/Hx8fL391d4eLhiY2PVrl07SdKpU6esKgGpqakaNWqUjh8/Lh8fH3Xq1EkLFy60GjaUlJSkmJgYnTlzRoGBgYqOjtaECRNUqlQpS5vFixcrJiZGPXv21KVLl1S9enVNmDBBAwcOtLR566235OLioujoaKWlpSkqKkrvvfeeZfvSpUv1xx9/aNGiRVq0aJHl/erVqxdoOXUAgC0MRVBgoEIBAM5g8zoMdxrWYQCAQspIlk5+Lrn5SaV8Cr9/epJkuv6/dRhuYR0H3PZYhwHImdPXYQAAoGBsHJLEkCagUDZs2CCDwaDLly87uyu4TRAYAAAOQkEbxdPYsWM1fvz4HLeNHz9eY8eOtdu5+/TpI4PBkO3VoUMHu50TKCwCAwDAzqgQoHhzdXXV6NGjs4WG8ePHa/To0XJ1dbXr+Tt06KD4+Hir1+eff27XcwKFQWAAADgAoQHF12uvvaZx48ZZhYassDBu3Di99tprdj2/h4eHKlWqZPUqU6aMJMlgMOjDDz/Uww8/rNKlS6tOnTpatWqV1f7ffvut6tatKy8vL7Vu3ZqHuaDIERgAAPZlYKVnFH83hwYPDw+HhYWCeP311/XYY4/p119/VadOnSxPjZSk06dPq1u3burSpYvi4uLUv39/jRgxwsk9xu2GwAAAAKAbocHd3V3p6elyd3d3WFj45ptv5OPjY/WaOHGiZXufPn3Uo0cP1a5dWxMnTlRKSop27NghSZozZ45CQkI0ffp0hYaGqmfPnurTp49D+o07R6HWYQAA4JbwpCOUAOPHj7eEhfT0dI0fP94hoaF169aaM2eO1XuBgYGWP4eHh1v+7O3tLT8/P124cEGSdPDgQd17771W+0ZGRtqxt7gTERgAAHZGWEDx9/c5C1k/S7J7aPD29lbt2rVz3X7zYrbSjXkNJpPJrn0CbkZgAAA4hs3rhDIHAvaR0wTnrP86KjTcqnr16mWbBP3TTz85qTe4XREYAAAOQJUBxZfRaMxxgnPWz0aj0a7nT0tLU0JCgtV7bm5uKleuXL77Dhw4UNOnT9fw4cPVv39/7d69W/Pnz7dTT3GnIjAAAOyMsIDiLa+F2RxRWVizZo2CgoKs3gsNDdVvv/2W777VqlXTsmXL9MILL+idd97RPffco4kTJ+qpp56yV3dxBzKYzTbXiO8oycnJ8vf3V1JSkvz8/JzdHQAo/jKvSyc/lVw8JPeAwu+fkSxlXpVq9pTcvIu8eyj5UlNTdeLECdWsWVOenp7O7g5QbOT1d6Mw97RUGAAARS4tLU3x8fHKzMyUjGnSmQuSwV0qda3wB8tMkYzXJeMxya203NzcFBQUJA8Pj6LvOAAgGwIDAKDIGI1GffDBB1q/fr1SU1NvvGk2SemXJLlIBtfCH9RslGSS3LdJhhvLB3l6eqpNmzYaMGCAXF1v4ZgAgAIjMAAAisxHH32k2NhYPf7442rSpInc3d1v3PBfPy/JILncwq8dc6ZkMkleFSUXN6WnpysuLk6LFy+Wq6urBgwYUOTXAQD4C4EBAFAkMjMztX79ekVHR+vxxx//a4PZKF3zkORiQ2AwSqWDLfuHhYUpIyNDq1ev1lNPPSU3N36dAYC9uDi7AwCA28Off/6pq1evqlGjRg45X6NGjXT16lX9+eefDjkfANypCAwAgCKR9ax6d3f3Ij5yzo9lzTqPvZ+RDwB3OgIDAMDu1qzdoLtadlL4PW103/2dtefX/ZKkiVNnKrTxP+TiXVkrV32X477rN2yRq38tvT1zpiO7DAD4HwZ9AgDsKjExUT37Pa//rlmqBg0baNOWn9TzqUHat2uD2rZupccffUhPDXwhx32TkpI1YvREdWrf2sG9BgBkITAAAOzq2LFjKhtYRg3qh0qSWra4T6dOn9XPv/yqe+5umue+g4e9qlGvPK/lK1c7oqu4DV2/LqWnO+Zc7u6Sl5djznUn6dOnjy5fvqyVK1c6uyt3LAIDAMCu6tSpo4uXErX1p11q3vw+rfomVleupOjk76fVrGl4rvstXfGNXFxc9K/O7QkMuCXXr0tffSUlJjrmfGXKSF27Fjw0/PHHHxo9erRWr16t8+fPq0yZMmrcuLFGjx6tFi1a2LezuCWffvqppk6dqiNHjsjf318dO3bUtGnTVLZsWUnSf/7zH33yySfat2+fJCkiIkITJ07UPffcYznG8uXL9f7772v37t26dOmSfvnlFzVp0sQZl1NgBAYAgF35+/tr6cI5ihkzWSlXryny3rtUv17dPB+FmpBwQW9MflsbYpc5sKe43aSn3wgLXl6Sp6d9z5WaeuNc6ekFDwzR0dFKT0/XggULVKtWLZ0/f17r1q3TxYsX7dvZYio9Pd0OD00oOlu2bFGvXr301ltvqUuXLjp79qwGDhyop59+WsuXL5ckbdiwQT169FDz5s3l6empKVOmqH379tq/f7+Cg4MlSVevXtU//vEPPfbYY3r66aedeUkFxqRnAIDdtb6/uTbGLtXurd9r+uQxOhd/XvXr1c21/e5fflX8+fNqcl9b1ah3n5Z+9Z3GjZ+gkSNHOrDXuF14ekre3vZ9FTaQXL58WZs2bdKUKVPUunVrVa9eXffcc49iYmL0r3/9S5J08uRJGQwGxcXFWe1nMBi0YcMGy3v79+/XP//5T/n5+cnX11ctW7bUsWPHLNvnzZunBg0ayMPDQ0FBQRo8eLDV8fr376/y5cvLz89Pbdq00Z49eyzb9+zZo9atW8vX11d+fn6KiIjQrl27JEm///67unTpojJlysjb21sNGjTQt99+a9l348aNuueeeyznHTFihDIzMy3bH3jgAQ0ePFhDhw5VuXLlFBUVVaDPbs2aNfrHP/6hgIAAlS1bVv/85z+trjfrc1u+fLlat26t0qVLq3Hjxtq2bZvVcTZv3qyWLVvKy8tLVatW1ZAhQ3T16tVcz7tt2zbVqFFDQ4YMUc2aNfWPf/xD//d//6cdO3ZY2nz66ad69tln1aRJE4WFhenDDz+UyWTSunXrLG2efPJJjR49Wm3bti3Q9RYHBAYAgN3FJ5y3/Hn8pLfU5v4Wqh1SM9f2nTu21fmTe3Xyt506efAnPdK1o0a/NlITJkxwRHcBu/Px8ZGPj49WrlyptLS0Wz7O2bNn1apVK3l4eGj9+vXavXu3nnrqKcuN+Zw5czRo0CANGDBAe/fu1apVq1S7dm3L/o8++qguXLig7777Trt371azZs304IMP6tKlS5Kknj17qkqVKtq5c6d2796tESNGqFSpUpKkQYMGKS0tTf/973+1d+9eTZkyRT4+PpZ+derUSXfffbf27NmjOXPm6KOPPtIbb7xh1f8FCxbI3d1dW7Zs0fvvv1+ga7569aqGDRumXbt2ad26dXJxcdHDDz8sk8lk1W7kyJF66aWXFBcXp7p166pHjx6Wz+XYsWPq0KGDoqOj9euvv+qLL77Q5s2brcLU30VGRur06dP69ttvZTabdf78eS1dulSdOnXKdZ9r164pIyNDgYGBBbq24oohSQAAuxv9xgxt2rJTmUajIu+N0EdzZkiS3pj8lt7/cKH++POi9h34TYOHjdQv275X+fLlnNxjwL7c3Nw0f/58Pf3003r//ffVrFkz3X///Xr88ccVHp773J6/e/fdd+Xv76/FixdbbuTr1v2revfGG2/oxRdf1PPPP2957+6775Z04xv2HTt26MKFC/Lw8JAkvfnmm1q5cqWWLl2qAQMG6NSpUxo+fLjCwsIk3ZiTlOXUqVOKjo62LNZYq1Yty7b33ntPVatW1ezZs2UwGBQWFqZz587plVde0ejRo+Xi4mI53tSpUwv12UVHR1v9PG/ePJUvX14HDhxQw4YNLe+/9NJL6ty5syTp9ddfV4MGDXT06FGFhYVp0qRJ6tmzp4YOHWrpx6xZs3T//fdrzpw58syhZNSiRQt9+umn6t69u1JTU5WZmakuXbro3XffzbWvr7zyiipXrlyiqgk5ocIAAChSZrP5b29I/5k9Rb/9skFH923Two9mKyDAX5I0asQLOnP0Z6Vd/l1/nj6gM0d/zjEszJ87XUNvuuHJ8TxACRMdHa1z585p1apV6tChgzZs2KBmzZpp/vz5BT5GXFycWrZsaQkLN7tw4YLOnTunBx98MMd99+zZo5SUFJUtW9ZS8fDx8dGJEycsQ3yGDRum/v37q23btpo8ebLV0J8hQ4bojTfeUIsWLTRmzBj9+uuvlm0HDx5UZGSkDIa/Fl5s0aKFUlJSdObMGct7ERERBb7WLEeOHFGPHj1Uq1Yt+fn5qUaNGpJuBJib3Ry8goKCLJ9J1rXPnz/f6rqjoqJkMpl04sSJHM974MABPf/88xo9erR2796tNWvW6OTJkxo4cGCO7SdPnqzFixdrxYoVOQaQkoTAAAAoElm/EJOSkhxyvqzzePEcS5Rgnp6eateunV577TVt3bpVffr00ZgxYyTJ8i38zeE4IyPDav+8/v/P7+9GSkqKgoKCFBcXZ/U6dOiQhg8fLkkaO3as9u/fr86dO2v9+vWqX7++VqxYIUnq37+/jh8/rieffFJ79+7VXXfdpXfeeadQ1+/t7V2o9pLUpUsXXbp0Sf/5z3+0fft2bd++XdKNSdM3uzlEZQWXrGFLKSkp+r//+z+r696zZ4+OHDmikJCQHM87adIktWjRQsOHD1d4eLiioqL03nvvad68eYqPj7dq++abb2ry5Mn6/vvvC1UxKq4IDACAIhEYGKjg4GD98MMP2cYSF42/bppMJpN++OEHValSRWXKlLHDuQDnqF+/vmXibfny5SXJ6mb05gnQ0o1v0Tdt2pQtSEiSr6+vatSoYTXh9mbNmjVTQkKC3NzcVLt2batXuXJ/Vfrq1q2rF154Qd9//726deumjz/+2LKtatWqGjhwoJYvX64XX3xR//nPfyRJ9erV07Zt26zCzpYtW+Tr66sqVaoU8lP5y8WLF3Xo0CGNGjVKDz74oOrVq6fEW3hubrNmzXTgwIFs1127du1cn9R07do1S4jL4urqKsk61E2dOlXjx4/XmjVrdNdddxW6b8URcxgAAEXCYDDoySef1JQpUzR48GA1adLkxi9ek0nKSJRkkAyuhT+w2SSZjZJ7GcnFVenp6YqLi9OZM2f0yiuvWA15AEqKixcv6tFHH9VTTz2l8PBw+fr6ateuXZo6daq6du0q6UaF4L777tPkyZNVs2ZNXbhwQaNGjbI6zuDBg/XOO+/o8ccfV0xMjPz9/fXTTz/pnnvuUWhoqMaOHauBAweqQoUK6tixo65cuaItW7boueeeU9u2bRUZGamHHnpIU6dOVd26dXXu3DmtXr1aDz/8sBo0aKDhw4frkUceUc2aNXXmzBnt3LnTModg6NCh6tixo+rWravExET9+OOPqlevniTp2Wef1dtvv63nnntOgwcP1qFDhzRmzBgNGzYs2013YZQpU0Zly5bVBx98oKCgIJ06dUojRowo9HFeeeUV3XfffRo8eLD69+8vb29vHThwQGvXrtXs2bNz3KdLly56+umnNWfOHEVFRSk+Pl5Dhw7VPffco8qVK0uSpkyZotGjR+uzzz5TjRo1lJCQIOmvSe6SdOnSJZ06dUrnzp2TJB06dEiSVKlSJVWqVKnQ1+IIBAYAQJFp0aKFJkyYoNjYWMXFxd14IonJKF09cSMsuGQfZ50vU6ZkzpC8a0gupeTm5qZatWrpmWeesUy2BPKSmlr8zuHj46N7771Xb731lo4dO6aMjAxVrVpVTz/9tF599VVLu3nz5qlfv36KiIhQaGiopk6dqvbt21u2ly1bVuvXr9fw4cN1//33y9XVVU2aNLEs/Na7d2+lpqbqrbfe0ksvvaRy5crpkUcekXQj5H/77bcaOXKk+vbtqz/++EOVKlVSq1atVLFiRbm6uurixYvq1auXzp8/r3Llyqlbt256/fXXJUlGo1GDBg3SmTNn5Ofnpw4dOuitt96SJAUHB+vbb7/V8OHD1bhxYwUGBqpfv37ZAk9hubi4aPHixRoyZIgaNmyo0NBQzZo1Sw888EChjhMeHq6NGzdq5MiRatmypcxms0JCQtS9e/dc9+nTp4+uXLmi2bNn68UXX1RAQIDatGmjKVOmWNrMmTNH6enpls84y5gxYzR27FhJ0qpVq9S3b1/Ltscffzxbm+LGYGbWWKEkJyfL399fSUlJ8vPzc3Z3AKD4y7wunfxUcvGU3P1vYf8UKSNJqvGEVIp/d5FdamqqTpw4oZo1a1pNLi3uKz0D9pbb3w2pcPe0VBgAAMBtycvrxg383+bC2o27O2EBtycCAwAAuG15eXETD9iKpyQBAAAAyBWBAQBQzBkkmSWm3AGAUxAYAAAAAOSKwAAAAAAgVwQGAAAAALkiMAAAAADIFYEBAFDMGf73XyY9A4AzsA4DAKD4IyvgVmVel0wOWrnNxV1yK56LPvTp00eXL1/WypUrnd0Vh9mwYYNat26txMREBQQEOLs7JRqBAQAA3J4yr0tnvpIyEh1zvlJlpCpdCxwa+vTpowULFtzYtVQpVatWTb169dKrr74qNzdu0W4nP//8s1555RXt3LlTrq6uio6O1owZM+Tj42NpM2TIEG3ZskX79u1TvXr1FBcXl+vxjh49qqZNm8rV1VWXL1+2e/8ZkgQAAG5PpvQbYcHF68bNvD1fLl43zlXIakaHDh0UHx+vI0eO6MUXX9TYsWM1bdq0HNumpzuoUlLCGI1GmUwmZ3cjV+fOnVPbtm1Vu3Ztbd++XWvWrNH+/fvVp0+fbG2feuopde/ePc/jZWRkqEePHmrZsqWdepwdgQEAANzeXD0lN2/7vlw9b6lrHh4eqlSpkqpXr65nnnlGbdu21apVqyTdqEA89NBDmjBhgipXrqzQ0FBJ0unTp/XYY48pICBAgYGB6tq1q06ePGk5ptFo1LBhwxQQEKCyZcvq5ZdflvlvCx+aTCZNmjRJNWvWlJeXlxo3bqylS5datdm/f7/++c9/ys/PT76+vmrZsqWOHTtm2f7hhx+qXr168vT0VFhYmN577z3LtvT0dA0ePFhBQUHy9PRU9erVNWnSJEmS2WzW2LFjVa1aNXl4eKhy5coaMmSIZd/ExET16tVLZcqUUenSpdWxY0cdOXLEsn3+/PkKCAjQqlWrVL9+fXl4eOjUqVP5ftYXL15Ujx49FBwcrNKlS6tRo0b6/PPPrdo88MADGjJkiF5++WUFBgaqUqVKGjt2rFWby5cvq3///ipfvrz8/PzUpk0b7dmzJ9fzfvPNNypVqpTeffddhYaG6u6779b777+vZcuW6ejRo5Z2s2bN0qBBg1SrVq08r2PUqFEKCwvTY489lu81FxUCAwCghGAiA25/Xl5eVpWEdevW6dChQ1q7dq2++eYbZWRkKCoqSr6+vtq0aZO2bNkiHx8fdejQwbLf9OnTNX/+fM2bN0+bN2/WpUuXtGLFCqvzTJo0SZ988onef/997d+/Xy+88IL+/e9/a+PGjZKks2fPqlWrVvLw8ND69eu1e/duPfXUU8rMzJQkffrppxo9erQmTJiggwcPauLEiXrttdcsQ6xmzZqlVatW6csvv9ShQ4f06aefqkaNGpKkZcuW6a233tLcuXN15MgRrVy5Uo0aNbL0rU+fPtq1a5dWrVqlbdu2yWw2q1OnTsrIyLC0uXbtmqZMmaIPP/xQ+/fvV4UKFfL9bFNTUxUREaHVq1dr3759GjBggJ588knt2LHDqt2CBQvk7e2t7du3a+rUqRo3bpzWrl1r2f7oo4/qwoUL+u6777R79241a9ZMDz74oC5dupTjedPS0uTu7i4Xl79uu728bgxb27x5c779vtn69eu1ZMkSvfvuu4Xaz1YMkAMAAHAys9msdevWKTY2Vs8995zlfW9vb3344Ydyd3eXJC1atEgmk0kffvihDIYbTxD7+OOPFRAQoA0bNqh9+/Z6++23FRMTo27dukmS3n//fcXGxlqOmZaWpokTJ+qHH35QZGSkJKlWrVravHmz5s6dq/vvv1/vvvuu/P39tXjxYpUqVUqSVLduXcsxxowZo+nTp1vOUbNmTR04cEBz585V7969derUKdWpU0f/+Mc/ZDAYVL16dcu+p06dUqVKldS2bVvL3I177rlHknTkyBGtWrVKW7ZsUfPmzSXdCCdVq1bVypUr9eijj0q6MSznvffeU+PGjQv8GQcHB+ull16y/Pzcc88pNjZWX375peX8khQeHq4xY8ZIkurUqaPZs2dr3bp1ateunTZv3qwdO3bowoUL8vDwkCS9+eabWrlypZYuXaoBAwZkO2+bNm00bNgwTZs2Tc8//7yuXr2qESNGSJLi4+ML3P+LFy+qT58+WrRokfz8/Aq8X1EgMAAAADjJN998Ix8fH2VkZMhkMumJJ56wGgLTqFEjS1iQpD179ujo0aPy9fW1Ok5qaqqOHTumpKQkxcfH695777Vsc3Nz01133WUZlnT06FFdu3ZN7dq1szpGenq6mjZtKkmKi4tTy5YtLWHhZlevXtWxY8fUr18/Pf3005b3MzMz5e/vL+lGlaBdu3YKDQ1Vhw4d9M9//lPt27eXdOMb+rffflu1atVShw4d1KlTJ3Xp0kVubm46ePCg3NzcrPpftmxZhYaG6uDBg5b33N3dFR4eXrAP+X+MRqMmTpyoL7/8UmfPnlV6errS0tJUunRpq3Z/P25QUJAuXLgg6cbnn5KSorJly1q1uX79utVwrZs1aNBACxYs0LBhwxQTEyNXV1cNGTJEFStWtKo65Ofpp5/WE088oVatWhV4n6JCYAAAlAAMR8LtqXXr1pozZ47c3d1VuXLlbE9H8vb2tvo5JSVFERER+vTTT7Mdq3z58gU6Z0pKiiRp9erVCg4OttqW9a151pCZvPb/z3/+Y3VjL0murq6SpGbNmunEiRP67rvv9MMPP+ixxx5T27ZttXTpUlWtWlWHDh3SDz/8oLVr1+rZZ5/VtGnTLMOhCsLLy8tSYSmoadOmaebMmXr77bfVqFEjeXt7a+jQodkmk/89JBkMBsuk6pSUFAUFBWnDhg3Zjp/Xo1ufeOIJPfHEEzp//ry8vb1lMBg0Y8aMfOcr3Gz9+vVatWqV3nzzTUk3qlImk0lubm764IMP9NRTTxX4WIVFYAAAAHASb29v1a5du8DtmzVrpi+++EIVKlTIdVhKUFCQtm/fbvkmOjMz0zLWXpLVROH7778/x2OEh4drwYIFysjIyHYDXbFiRVWuXFnHjx9Xz549c+2rn5+funfvru7du+uRRx5Rhw4ddOnSJQUGBsrLy0tdunRRly5dNGjQIIWFhWnv3r2qV6+eMjMztX37dsuQpIsXL+rQoUOqX79+gT+nnGzZskVdu3bVv//9b0k3Jn4fPny4UMdt1qyZEhIS5ObmZpmTURgVK1aUJM2bN0+enp7Zqjx52bZtm4xGo+Xnr776SlOmTNHWrVuzBb+iRmAAAJQMZqoMQM+ePTVt2jR17dpV48aNU5UqVfT7779r+fLlevnll1WlShU9//zzmjx5surUqaOwsDDNmDHD6ln9vr6+eumll/TCCy/IZDLpH//4h5KSkrRlyxb5+fmpd+/eGjx4sN555x09/vjjiomJkb+/v3766Sfdc889Cg0N1euvv64hQ4bI399fHTp0UFpamnbt2qXExEQNGzZMM2bMUFBQkJo2bSoXFxctWbJElSpVUkBAgObPny+j0ah7771XpUuX1qJFi+Tl5aXq1aurbNmy6tq1q55++mnNnTtXvr6+GjFihIKDg9W1a1ebPrs6depo6dKl2rp1q8qUKaMZM2bo/PnzhQoMbdu2VWRkpB566CFNnTpVdevW1blz57R69Wo9/PDDuuuuu3Lcb/bs2WrevLl8fHy0du1aDR8+XJMnT7aqShw9elQpKSlKSEjQ9evXLesw1K9fX+7u7qpXr57VMXft2iUXFxc1bNiw0J9FYREYAADA7c2YenucQ1Lp0qX13//+V6+88oq6deumK1euKDg4WA8++KCl4vDiiy8qPj5evXv3louLi5566ik9/PDDSkpKshxn/PjxKl++vCZNmqTjx48rICBAzZo106uvvirpxryB9evXa/jw4br//vvl6uqqJk2aqEWLFpKk/v37q3Tp0po2bZqGDx8ub29vNWrUSEOHDpV0I5RMnTpVR44ckaurq+6++259++23cnFxUUBAgCZPnqxhw4bJaDSqUaNG+vrrry3zAj7++GM9//zz+uc//6n09HS1atVK3377bY7zKQpj1KhROn78uKKiolS6dGkNGDBADz30kNXnkh+DwaBvv/1WI0eOVN++ffXHH3+oUqVKatWqlaV6kJMdO3ZozJgxSklJUVhYmObOnasnn3zSqk3//v2thmVlzSc5ceLELVUzipLB/PcH8yJPycnJ8vf3V1JSksNnqANAiZR5XTr5qeTiKbn7F35/43Up7U+p+uOSR2DR9w8lXmpqqk6cOKGaNWvK0/Om9RCK+UrPgL3l+ndDhbunpcIAACgB+G4Lt8DN68YNfCFXX75lLu6EBdyWCAwAAOD25eYliZt4wBas9AwAAAAgVwQGAAAAALkiMAAASgjmMSBvPMcFsFZUfycIDACAYq5wq7nizpP1uM1r1645uSdA8ZL1d8LWR9Iy6RkAUPzxxTHy4OrqqoCAAF24cEHSjbUKDAaCJu5cZrNZ165d04ULFxQQECBXV1ebjkdgAAAAJV6lSpUkyRIaAEgBAQGWvxu2IDAAAIASz2AwKCgoSBUqVFBGRoazuwM4XalSpWyuLGQhMAAAgNuGq6trkd0kAbiBSc8AgJKBJ+AAgFMQGAAAAADkisAAACjmeNoNADgTgQEAUAIwHAkAnIXAAAAAACBXBAYAQAlBlQEAnIHAAAAAACBXBAYAgJ1RGQCAkozAAAAoAQgdAOAsBAYAAAAAuSIwAAAAAMgVgQEAAABArggMAIDizZC10jPzGADAGQgMAIDiz0xYAABnITAAAAAAyBWBAQAAAECuCAwAAAAAckVgAACUDMxjAACnIDAAAAAAyBWBAQAAAECuCAwAgGLOkH8TAIDdEBgAAA7A/AMAKKkKFRjmzJmj8PBw+fn5yc/PT5GRkfruu+9ybZ+RkaFx48YpJCREnp6eaty4sdasWWPV5sqVKxo6dKiqV68uLy8vNW/eXDt37rRqk5KSosGDB6tKlSry8vJS/fr19f7771u1SU1N1aBBg1S2bFn5+PgoOjpa58+ft2pz6tQpde7cWaVLl1aFChU0fPhwZWZmFuYjAADcKsNflYKxMz7T+LcX59hs/NuLNXbGZzlsIXQAgDMUKjBUqVJFkydP1u7du7Vr1y61adNGXbt21f79+3NsP2rUKM2dO1fvvPOODhw4oIEDB+rhhx/WL7/8YmnTv39/rV27VgsXLtTevXvVvn17tW3bVmfPnrW0GTZsmNasWaNFixbp4MGDGjp0qAYPHqxVq1ZZ2rzwwgv6+uuvtWTJEm3cuFHnzp1Tt27dLNuNRqM6d+6s9PR0bd26VQsWLND8+fM1evTownwEAIBCy36j7+riotHTs4eG8W8v1ujpn8nVJadfTwxNAgCnMNuoTJky5g8//DDHbUFBQebZs2dbvdetWzdzz549zWaz2Xzt2jWzq6ur+ZtvvrFq06xZM/PIkSMtPzdo0MA8bty4XNtcvnzZXKpUKfOSJUss2w8ePGiWZN62bZvZbDabv/32W7OLi4s5ISHB0mbOnDlmPz8/c1paWq7Xl5qaak5KSrK8Tp8+bZZkTkpKynUfAMBNMlLM5sNzzebji8zm06ssr3EvPmGWZB45pLs5Yc9i86jnHzdLMo978Qmrdubfl5nNB2eazdcS8j8XAKBAkpKSCnxPe8tzGIxGoxYvXqyrV68qMjIyxzZpaWny9PS0es/Ly0ubN2+WJGVmZspoNObZRpKaN2+uVatW6ezZszKbzfrxxx91+PBhtW/fXpK0e/duZWRkqG3btpZ9wsLCVK1aNW3btk2StG3bNjVq1EgVK1a0tImKilJycnKuFRJJmjRpkvz9/S2vqlWrFuTjAQD8j9ls1qXLSfrj9K/648RWy2tg12p6pV9LTZj1hYKb9dQbMxfrlX4tNbBrNf1xYstfr5M7dDExWUaTydmXAgB3JLfC7rB3715FRkYqNTVVPj4+WrFiherXr59j26ioKM2YMUOtWrVSSEiI1q1bp+XLl8toNEqSfH19FRkZqfHjx6tevXqqWLGiPv/8c23btk21a9e2HOedd97RgAEDVKVKFbm5ucnFxUX/+c9/1KpVK0lSQkKC3N3dFRAQYHX+ihUrKiEhwdLm5rCQtT1rW25iYmI0bNgwy8/JycmEBgAohAyzu85m1pXRFCSXvw01eqJXI01fsFWZmUa5ubmpZ+9n9fd/kU0yyZxZSp5mX3k7rtsAgP8pdGAIDQ1VXFyckpKStHTpUvXu3VsbN27MMTTMnDlTTz/9tMLCwmQwGBQSEqK+fftq3rx5ljYLFy7UU089peDgYLm6uqpZs2bq0aOHdu/ebWnzzjvv6KefftKqVatUvXp1/fe//9WgQYNUuXJlq6qCPXh4eMjDw8Ou5wCA2126exWV8i6V7d/T9957T5mZRrm6uiozM1MfLt2mZ5991qqN0WjUlStXrCZNAwAcp9CBwd3d3fLtf0REhHbu3KmZM2dq7ty52dqWL19eK1euVGpqqi5evKjKlStrxIgRqlWrlqVNSEiINm7cqKtXryo5OVlBQUHq3r27pc3169f16quvasWKFercubMkKTw8XHFxcXrzzTfVtm1bVapUSenp6bp8+bJVleH8+fOqVKmSJKlSpUrasWOHVf+ynqKU1QYA4DjvvfeeZs2apejoaDVq1EgHDx7UrFmzJClbaAAAOI/N6zCYTCalpaXl2cbT01PBwcHKzMzUsmXL1LVr12xtvL29FRQUpMTERMXGxlraZGRkKCMjI1sZ29XVVab/jWeNiIhQqVKltG7dOsv2Q4cO6dSpU5b5FZGRkdq7d68uXLhgabN27Vr5+fnlOqQKAGAfWWFhyJAhevjhhyVJjzzyiIYMGaJZs2bpvffec3IPAQBZClVhiImJUceOHVWtWjVduXJFn332mTZs2KDY2FhJUq9evRQcHKxJkyZJkrZv366zZ8+qSZMmOnv2rMaOHSuTyaSXX37ZcszY2FiZzWaFhobq6NGjGj58uMLCwtS3b19Jkp+fn+6//34NHz5cXl5eql69ujZu3KhPPvlEM2bMkCT5+/urX79+GjZsmAIDA+Xn56fnnntOkZGRuu+++yRJ7du3V/369fXkk09q6tSpSkhI0KhRozRo0CCGHAGAgxmNRg0ZMkTPPvusdu3aZXk/q7KQNdcNAOB8hQoMFy5cUK9evRQfHy9/f3+Fh4crNjZW7dq1k3RjYbSbKwGpqakaNWqUjh8/Lh8fH3Xq1EkLFy60GjaUlJSkmJgYnTlzRoGBgYqOjtaECRNUqlQpS5vFixcrJiZGPXv21KVLl1S9enVNmDBBAwcOtLR566235OLioujoaKWlpSkqKsrqGypXV1d98803euaZZxQZGSlvb2/17t1b48aNK/SHBgCwzXPPPWf5s+FvcxMYjgQAxYvBbDazdGYhJCcny9/fX0lJSfLz83N2dwCg2MvIyNCvv/6qUqWyT3qWbjwa+8iRI6pfv77Cw8Ozbc+a9NywYUN5e/OcJAAoCoW5p7V5DgMAAACA2xeBAQDgELkVtP8+JKmw+wMA7IvAAAAoFggEAFA8ERgAAMUeYQIAnIfAAACwK4PBkOewo4IOSQIAOAeBAQBQLFBFAIDiicAAAAAAIFcEBgCAUzEkCQCKNwIDAMDuDAZDvkOOGJIEAMUTgQEAYFdUEACgZCMwAACcikABAMUbgQEAUCwwJAkAiicCAwAAAIBcERgAAHaV35Cjgm6nAgEAzkFgAAA4BE9JAoCSicAAAHAqJj0DQPFGYAAA2F1eoYAhRwBQvBEYAAB2xRwFACjZCAwAAIewdQ4DgQIAnIPAAACwO1uHJBEWAMB5CAwAALtiSBIAlGwEBgCAU+UXGHiKEgA4F4EBAGB3BRmSBAAonggMAACHyK+CkN8cBoYsAYBzEBgAAE7FHAYAKN4IDAAAu3Nxyf/XDYEBAIonAgMAwKmY9AwAxRuBAQBgdwaDgUAAACUUgQEA4FQFncPAkCUAcA4CAwDA7mxd6RkA4DwEBgBAsUBgAIDiicAAALC7gsxhYEgSABRPBAYAgFMxJAkAijcCAwDA7vJah4HAAADFG4EBAOAQtj5WlUABAM5BYAAA2B1PSQKAkovAAACwOwIDAJRcBAYAgN0VZNhRXoEhr6csAQDsi8AAAHAIWx6rSlgAAOchMAAA7K4gT0kCABRPBAYAgEOwcBsAlEwEBgCA3THpGQBKLgIDAMDuimLSMwDAOQgMAACHsHXSMxUIAHAOAgMAwO6Y9AwAJReBAQBgdwWZw2AymfJsQ4UBAJyDwAAAcAjWYQCAkonAAACwO1ufksSwJQBwHgIDAMDu8rrhz5rfwKRnACieCAwAALuzdQ4DAMB5CAwAAKfKqjDkFxioMACAcxAYAAB2Z+uQJACA8xAYAAB2V5DAQIUBAIonAgMAwKlufkoSoQAAih8CAwDA7gpSYZBYiwEAiiMCAwDAIXILDTe/z5OSAKD4ITAAAOzOYDDkWiG4ucKQV2CgwgAAzkFgAADYXUHWYZAIBQBQHBEYAABOVdAKA8OVAMA5CAwAALvLq8Ig5f9o1fz2BwDYD4EBAOAQti7exnAlAHAOAgMAwO7ymvSctV1i0jMAFEcEBgCA3RV0SFJuoSC/wAEAsB8CAwDA7vILDFQYAKD4IjAAAJwuv0nPEoEBAJyFwAAAsLuCVhjyGpIEAHAOAgMAwO5sfaxqftsAAPZDYAAAOExuFYSCrMPAkCQAcA4CAwDA7gwGQ543/azDAADFF4EBAGB3WYEhr+0Sk54BoDgiMAAAHOZWKwxMegYA5yEwAADsrijWYWDSMwA4B4EBAGB3+Q1JYtIzABRfBAYAgN0VdNIzcxgAoPghMAAAHKIgk555ShIAFD8EBgCA3eUXCAoyJIk5DADgHAQGAIDdFTQwUEUAgOKHwAAAsDtb12Fg0jMAOA+BAQBgd0Ux6VmiAgEAzkBgAAA4XX5DlqgwAIDzEBgAAHZn66TnrH0JDQDgeAQGAIDdFcXCbRJDkgDAGQgMAAC7y28OA4EAAIovAgMAwOkKUmFgSBIAOEehAsOcOXMUHh4uPz8/+fn5KTIyUt99912u7TMyMjRu3DiFhITI09NTjRs31po1a6zaXLlyRUOHDlX16tXl5eWl5s2ba+fOnVZtsr6Z+vtr2rRpljY///yz2rVrp4CAAJUtW1YDBgxQSkqK1XF27typBx98UAEBASpTpoyioqK0Z8+ewnwEAIBbUBTrMBAYAMA5ChUYqlSposmTJ2v37t3atWuX2rRpo65du2r//v05th81apTmzp2rd955RwcOHNDAgQP18MMP65dffrG06d+/v9auXauFCxdq7969at++vdq2bauzZ89a2sTHx1u95s2bJ4PBoOjoaEnSuXPn1LZtW9WuXVvbt2/XmjVrtH//fvXp08dyjJSUFHXo0EHVqlXT9u3btXnzZvn6+ioqKkoZGRmF+RgAAIVU0CFJ+c1hAAA4nsFs49c1gYGBmjZtmvr165dtW+XKlTVy5EgNGjTI8l50dLS8vLy0aNEiXb9+Xb6+vvrqq6/UuXNnS5uIiAh17NhRb7zxRo7nfOihh3TlyhWtW7dOkvTBBx/otddeU3x8vOVbqr179yo8PFxHjhxR7dq1tWvXLt199906deqUqlatmmObgkhOTpa/v7+SkpLk5+dXsA8JAKA9e/bIaDTK29s727YDBw7o119/Vc2aNXXvvfdm256WlqaMjAw1atRI7u7ujuguANzWCnNPe8tzGIxGoxYvXqyrV68qMjIyxzZpaWny9PS0es/Ly0ubN2+WJGVmZspoNObZ5u/Onz+v1atXWwWUtLQ0ubu7W8JC1jEkWY4TGhqqsmXL6qOPPlJ6erquX7+ujz76SPXq1VONGjVyvc60tDQlJydbvQAAhWfLwm3MYQAA5yl0YNi7d698fHzk4eGhgQMHasWKFapfv36ObaOiojRjxgwdOXJEJpNJa9eu1fLlyxUfHy9J8vX1VWRkpMaPH69z587JaDRq0aJF2rZtm6XN3y1YsEC+vr7q1q2b5b02bdooISFB06ZNU3p6uhITEzVixAhJsjrXhg0btGjRInl5ecnHx0dr1qzRd999Jzc3t1yvd9KkSfL397e8sqoTAIDCuflLnb/jKUkAUHwVOjCEhoYqLi5O27dv1zPPPKPevXvrwIEDObadOXOm6tSpo7CwMLm7u2vw4MHq27ev1S+NhQsXymw2Kzg4WB4eHpo1a5Z69OiR6y+WefPmqWfPnlZViQYNGmjBggWaPn26SpcurUqVKqlmzZqqWLGi5TjXr19Xv3791KJFC/3000/asmWLGjZsqM6dO+v69eu5Xm9MTIySkpIsr9OnTxf2IwMAiAoDAJRUuX+1ngt3d3fLeP+IiAjt3LlTM2fO1Ny5c7O1LV++vFauXKnU1FRdvHhRlStX1ogRI1SrVi1Lm5CQEG3cuFFXr15VcnKygoKC1L17d6s2WTZt2qRDhw7piy++yLbtiSee0BNPPKHz58/L29tbBoNBM2bMsBzns88+08mTJ7Vt2zbLL6bPPvtMZcqU0VdffaXHH388x+v18PCQh4dHYT8mAMDfFGThtvwCAYEBABzP5nUYTCaT0tLS8mzj6emp4OBgZWZmatmyZeratWu2Nt7e3goKClJiYqJiY2NzbPPRRx8pIiJCjRs3zvVcFStWlI+Pj7744gt5enqqXbt2kqRr167JxcXF6hdW1s+5faMFACg6tjwlKQuBAQAcr1CBISYmRv/973918uRJ7d27VzExMdqwYYN69uwpSerVq5diYmIs7bdv367ly5fr+PHj2rRpkzp06CCTyaSXX37Z0iY2NlZr1qzRiRMntHbtWrVu3VphYWHq27ev1bmTk5O1ZMkS9e/fP8e+zZ49Wz///LMOHz6sd999V4MHD9akSZMUEBAgSWrXrp0SExM1aNAgHTx4UPv371ffvn3l5uam1q1bF+ZjAADcgrzmMBR0SBIAwPEKNSTpwoUL6tWrl+Lj4+Xv76/w8HDFxsZavsU/deqU1S+E1NRUjRo1SsePH5ePj486deqkhQsXWm7iJSkpKUkxMTE6c+aMAgMDFR0drQkTJqhUqVJW5168eLHMZrN69OiRY9927NihMWPGKCUlRWFhYZo7d66efPJJy/awsDB9/fXXev311xUZGSkXFxc1bdpUa9asUVBQUGE+BgDALSjIHAYWbgOA4sfmdRjuNKzDAAC35siRI0pMTJS/v3+2badPn9aWLVtUrlw5tW3bNtt2o9GoK1euqGHDhjmu4wAAKByHrMMAAEBh2PKUJIkKAwA4C4EBAOAQtqzDwDoNAOA8BAYAgEPYWmGQCAwA4AwEBgCAQ9gy6ZmnJAGA8xAYAAAOUZAhScxhAIDih8AAAHAIW4Yk5bVKNADAvggMAACHKMjCbflVEKgwAIDjERgAAA6RV5WgIEOSJAIDADhDoVZ6BgAgN2azWcePH9e+ffuUlpaWbXtiYqIuXLggX1/fbNuuXbumffv2yc3NTZ6enjke/8qVK/rtt9/k4+OTbZuLi4uqVaumJk2ayN3d3faLAQBYEBgAADbLyMjQpEmTtHPnTrm7u8vLyytbm8zMTGVkZOQ4NMlkMiklJUUGg0FbtmzJ8Rwmk0kHDhyQq6trjse+evWqAgMDNWHCBFWpUsX2iwIASCIwAACKwLJlyxQXF6eXX35ZkZGRcnPL/uslNTVV165dy7ECYDQalZCQIIPBoMqVK+d4jvT0dPn4+ORaQTh16pSmTJmiadOmaebMmbZdEADAgjkMAACbbdu2Tc2bN1fLli1zDAuS/Z90VK1aNf373//W8ePHlZCQYNdzAcCdhMAAALDZn3/+qerVq9/y/llhwtZJzdWqVbP0BwBQNAgMAACbmc3mHOcmtG/fXuHh4WrSpInatGmjX3/9VZKUlpamF198UU2aNNG9996rp59+2rLP999/r1atWum+++5T69attXfvXsu2yZMnKzQ0VC4uLlq5cmW28+W3ngMAoPCYwwAAsJsvv/xSAQEBkqQlS5Zo8ODB+umnnzRmzBgZDAb98ssvMhgMSkhIkNFoVFJSkvr376/Y2FjVq1dPW7ZsUb9+/bRjxw5JUps2bfTvf/9bTz31lBOvCgDuLAQGAIDdZIUFSUpKSpLBYNDVq1f1ySef6LfffrMMRapYsaLOnTunU6dOKTAwUPXq1ZMktWjRQmfOnFFcXJzq16+vu+++Wx4eHs64FAC4YzEkCQBgV7169VLVqlX1+uuva86cOTpx4oTKlCmjN998U61atVL79u21ceNGSVKNGjV06dIl/fTTT5Kk1atX68qVK/r999+deQkAcEcjMAAA7OqTTz7R6dOnNXbsWL3++uvKzMzUqVOnFBYWpv/+97+aNm2aevfurYsXL8rX11cLFizQ2LFj1bJlS61fv15hYWGWJy+x0jMAOB5DkgAADvHkk09q8ODBqly5slxcXNS9e3dJUuPGjVWjRg0dPnxYkZGRatWqlVq3bi3pxuTo2rVrKywszJldB4A7GhUGAIBdXL58WefOnbP8/NVXX6lMmTIqX768HnjgAf3www+SpJMnT+rkyZMKCQmRJMXHx1v2mTJlilq1amXZBgBwPCoMAAC7SEpK0qOPPqrr16/LxcVF5cqV0+effy6DwaC3335bgwYN0ujRo+Xi4qJZs2apUqVKMplMmjRpkrZt2yaj0ah77rlH7777ruWYkydP1ocffqg//vhD+/bt0+DBg/XLL7+ofPnyTrxSALi9ERgAAHZRvXp1y+NQJSkjI0NXrlyRJNWsWVPffvutVfus1ZnffvttlSpVKsdjjhgxQmPHjrVPhwEAOWJIEgCgSBTVhOS8jpPfObK2Zz2uFQBgOwIDAMBmZcqUsZqvkBODwSCDwWDXJx1l9aFMmTJ2OwcA3GkIDAAAm917773atGmT9u3bd8uBwNaqwOXLl7V48WIFBwcrODjYpmMBAP7CHAYAgM0eeeQR7d27VzExMSpXrpx8fHyyBQCTyaTU1FS5urrmeIyUlBSZTCZ5e3vn2MZoNKpUqVI5zm/IyMjQ2bNn5eXlpfHjxzMkCQCKkMHMKjiFkpycLH9/fyUlJcnPz8/Z3QGAYiMzM1NxcXHav3+/UlNTs21PS0vTuXPn5OHhIReX7AXu/fv36/r166pbt26O/75eu3ZN/v7+Klu2bLZtrq6uqlq1qiIjI/m3GQAKoDD3tFQYAABFws3NTXfddZfuuuuuHLdfu3ZN+/btk7e3t2Xl5pvFxsYqMTFRrVq1UuXKlbNtT0xMVMWKFVWzZs0i7zsAIHfMYQAAOER+k56zqg62PCUJAFD0CAwAgGIha96ByWTKdXtu2wAA9kNgAAA4RFYgyK/CkFdgAAA4HoEBAOAQWUOS8tou5R4Y8tsGALAPAgMAwCGYwwAAJROBAQDgEPkNKWJIEgAUTwQGAIDD5FVhyG+Og8SQJABwBgIDAMAhiqLCwJAkAHA8AgMAwCGYwwAAJROBAQDgEPlVGAqyDgOBAQAcj8AAAHCIglYYeKwqABQvBAYAgMPYEhh4ShIAOAeBAQDgEAUdksQcBgAoXggMAACHsXVIktlsJjQAgIMRGAAADpMVCnKSX4UhK2wQGADAsQgMAACHYQ4DAJQ8BAYAgMPkddNfkCFJEvMYAMDRCAwAAIexZeE2hiQBgHMQGAAADlOQOQystQAAxQuBAQDgMDwlCQBKHgIDAMBhXFxc8hxyJOU9JCmv7QAA+yAwAACKhYJOegYAOBaBAQDgMHlVGJj0DADFE4EBAOAwtk56JjAAgOMRGAAADsPCbQBQ8hAYAAAOk1eFgYXbAKB4IjAAABwmrwpDQZ6SxJAkAHA8AgMAwGHyGlZEhQEAiicCAwDAYfIKDAWtMAAAHIvAAABwGFsrDAxJAgDHIzAAABymKIYkAQAci8AAAHAYg8GQa2goyJCkvLYDAOyDwAAAKBYYkgQAxROBAQDgMCzcBgAlD4EBAOAwtjwlKWsbFQYAcCwCAwDAYWyZ9MwcBgBwDgIDAMBhbJn0DABwDgIDAKBYuLnCkN+wJACA4xAYAAAOU5BJz1L+8xgAAI5DYAAAOExB5jBIea/FQGAAAMciMAAAHKYgT0mScp/4TFgAAMcjMAAAHKagFYb8Fm8DADgOgQEA4HA53fTfHCbyGpIEAHAsAgMAwGGyHquaW2DICgR5DUmiwgAAjkVgAAA4TF7rMEj5L94mMSQJAByNwAAAcJj8FmcryHYCAwA4FoEBAOAwtlYYCAsA4HgEBgCAQxVk8TaGJAFA8UFgAAA4TH5POcpvSFJ+2wAARY/AAABwmLyekiQVrMIAAHAsAgMAwGHyqzAwJAkAih8CAwDAYYqiwkBgAADHIjAAAIqNgsxhAAA4FoEBAOAwRVFhYH4DADgWgQEA4DD5VRDyCwws3AYAjkdgAAA4TH4Lt/FYVQAofggMAACHsmVIEhUGAHC8QgWGOXPmKDw8XH5+fvLz81NkZKS+++67XNtnZGRo3LhxCgkJkaenpxo3bqw1a9ZYtbly5YqGDh2q6tWry8vLS82bN9fOnTut2mR9I/X317Rp0yxtfv75Z7Vr104BAQEqW7asBgwYoJSUlGx9mj9/vsLDw+Xp6akKFSpo0KBBhfkIAAA2KIrHqjKHAQAcq1CBoUqVKpo8ebJ2796tXbt2qU2bNuratav279+fY/tRo0Zp7ty5euedd3TgwAENHDhQDz/8sH755RdLm/79+2vt2rVauHCh9u7dq/bt26tt27Y6e/aspU18fLzVa968eTIYDIqOjpYknTt3Tm3btlXt2rW1fft2rVmzRvv371efPn2s+jNjxgyNHDlSI0aM0P79+/XDDz8oKiqqMB8BAMAGRTGHAQDgWAazjbXdwMBATZs2Tf369cu2rXLlyho5cqTVt/jR0dHy8vLSokWLdP36dfn6+uqrr75S586dLW0iIiLUsWNHvfHGGzme86GHHtKVK1e0bt06SdIHH3yg1157TfHx8ZZfNnv37lV4eLiOHDmi2rVrKzExUcHBwfr666/14IMPFvj60tLSlJaWZvk5OTlZVatWVVJSkvz8/Ap8HADAjaCwZ88emc1mlS5dOtv2zZs368yZM4qIiFCdOnWybb98+bLKli2r2rVrO6K7AHDbSk5Olr+/f4HuaW95DoPRaNTixYt19epVRUZG5tgmLS1Nnp6eVu95eXlp8+bNkqTMzEwZjcY82/zd+fPntXr1aquAkpaWJnd3d0tYyDqGJMtx1q5dK5PJpLNnz6pevXqqUqWKHnvsMZ0+fTrP65w0aZL8/f0tr6pVq+bZHgCQO1sfq8ocBgBwvEIHhr1798rHx0ceHh4aOHCgVqxYofr16+fYNioqSjNmzNCRI0dkMpm0du1aLV++XPHx8ZIkX19fRUZGavz48Tp37pyMRqMWLVqkbdu2Wdr83YIFC+Tr66tu3bpZ3mvTpo0SEhI0bdo0paenKzExUSNGjJAky3GOHz8uk8mkiRMn6u2339bSpUt16dIltWvXTunp6bleb0xMjJKSkiyv/AIGACBvrPQMACVLoQNDaGio4uLitH37dj3zzDPq3bu3Dhw4kGPbmTNnqk6dOgoLC5O7u7sGDx6svn37WlUCFi5cKLPZrODgYHl4eGjWrFnq0aOHVZubzZs3Tz179rSqSjRo0EALFizQ9OnTVbp0aVWqVEk1a9ZUxYoVrX75ZGRkaNasWYqKitJ9992nzz//XEeOHNGPP/6Y6/V6eHhYJnlnvQAAt64gj1WlwgAAxUehA4O7u7tq166tiIgITZo0SY0bN9bMmTNzbFu+fHmtXLlSV69e1e+//67ffvtNPj4+qlWrlqVNSEiINm7cqJSUFJ0+fVo7duxQRkaGVZssmzZt0qFDh9S/f/9s25544gklJCTo7NmzunjxosaOHas//vjDcpygoCBJsqqGlC9fXuXKldOpU6cK+zEAAG5RQSoMeYUCnpIEAI5l8zoMJpPJalJwTjw9PRUcHKzMzEwtW7ZMXbt2zdbG29tbQUFBSkxMVGxsbI5tPvroI0VERKhx48a5nqtixYry8fHRF198IU9PT7Vr106S1KJFC0nSoUOHLG0vXbqkP//8U9WrVy/QtQIAbJdbBfnmbYQCACg+3ArTOCYmRh07dlS1atV05coVffbZZ9qwYYNiY2MlSb169VJwcLAmTZokSdq+fbvOnj2rJk2a6OzZsxo7dqxMJpNefvllyzFjY2NlNpsVGhqqo0ePavjw4QoLC1Pfvn2tzp2cnKwlS5Zo+vTpOfZt9uzZat68uXx8fLR27VoNHz5ckydPVkBAgCSpbt266tq1q55//nl98MEH8vPzU0xMjMLCwtS6devCfAwAABvYOumZMAEAjlWowHDhwgX16tVL8fHx8vf3V3h4uGJjYy3f4p86dcrqm6PU1FSNGjVKx48fl4+Pjzp16qSFCxdabuIlKSkpSTExMTpz5owCAwMVHR2tCRMmqFSpUlbnXrx4scxms3r06JFj33bs2KExY8YoJSVFYWFhmjt3rp588kmrNp988oleeOEFde7cWS4uLrr//vu1Zs2abOcCANhPXhWG/NZpyG8bAKDo2bwOw52mMM+sBQBkd+jQISUlJcnf3z/btl9//VUHDhxQnTp1FBERkW17SkqKPDw81LBhQ0d0FQBuWw5ZhwEAgFth6xwGhiQBgGMRGAAADpXXHAYeqwoAxQ+BAQDgUAWpMOQXCggNAOA4BAYAgEPZ+pQkwgIAOBaBAQDgUHmt9FyQOQxms5nQAAAORGAAADiUi4tLvnMYbnU7AKDoERgAAA5ly5AkAIDjERgAAA5ly2NVs8IGFQYAcBwCAwDAoZjDAAAlC4EBAOBQeQWGgs5hAAA4DoEBAFBsFHQOAxUGAHAcAgMAwKFsHZIkERgAwJEIDAAAhyrIkCQWbgOA4oPAAABwKIPBkGtoyKow5BUKmPQMAI5FYAAAFBsFeayqxJAkAHAkAgMAwKFYuA0AShYCAwDAoYpiDgMVBgBwHAIDAMChCvKUpPwCAYEBAByHwAAAcChbHqvKU5IAwPEIDACAYiO/IUkST0kCAEcjMAAAHCqvCoOrq6uk3Icc8ZQkAHA8AgMAwKEKMumZKgIAFB8EBgCAQ+VVJciawyDlPiyJMAEAjkVgAAA4VEFWepZyDgw8VhUAHI/AAABwuNyednRzkMhvOwDAMQgMAACHKshjVaX8n5QEAHAMAgMAwKHymsNw83AlAgMAFA8EBgCAQ+U1h0HKf/E2AIBjERgAAA6VFRhsWWuBCgMAOA6BAQDgcLZWGAgMAOA4BAYAgEPlV0HILzDwpCQAcCwCAwDAofILDPltZx0GAHAsAgMAwKGKYtIzgQEAHIfAAABwqPwmPTMkCQCKFwIDAKBYyS8wMCQJAByLwAAAcChb5zDktw0AULQIDAAAh7J1DkNew5kAAEWPwAAAcChb5zAQFgDAsdyc3QEAwJ0lr+qC0WjUkSNHdObMGXl7e6ty5cpydXXN1o7QAACOQ4UBAOBwOVUYvv/+ez344IOaOXOmli1bpuHDh+vBBx/U999/n21/AgMAOA6BAQDgUDkNSfr+++/1/PPPKyEhwart+fPn9fzzz+cYGgAAjkFgAAA41N+HJBmNRk2cODHHqkHWexMnTpTRaMz2PgDA/pjDAABwuOvXXXThQqbc3NK1Z8+ubJWFm5nNZiUkJGjRom0KDb1baWke8vWVgoMd2GEAuIMRGAAADrdvXwX99luGzGaDDh68XqB9DhwwysWlilJTXRQS4mHnHgIAshAYAAAO5+dXQSEhZlWubFDZsvX13Xf573PPPXXVqFGgjh2TSpWyfx8BADcwhwEA4HCurga5uLjIxcWg+vVbqmzZKpJye9yqQeXKVVX9+i1v/GSQclmiAQBgBwQGAIDDubr+ddPv6uqqp5+e+b8tfw8NN37u3/9ty3oMBoN00/xnAICdERgAAA7397XbmjfvphEjlqpsWeuZzOXKVdGIEUvVvHk3q30JDADgOMxhAAA4nKur9PcnozZv3k333ttVBw5s0qVL8QoMDFL9+i2zrfTMkCQAcCwCAwDA4VxcsgcG6cbwpEaNHshzXyoMAOBYDEkCADhcboGhIKgwAIBjERgAAA7n5mZbYKDCAACOQ2AAADicwUCFAQBKCgIDAMDhcpr0XFAEBgBwLAIDAMDhbJ3DwJAkAHAcAgMAwOGY9AwAJQeBAQDgcFQYAKDkIDAAABzub2uxFYqLCxUGAHAkAgMAwOFsqTBIN/YlNACAYxAYAAAO52LDb5+ssGFL4AAAFByBAQDgcLaswyBRYQAARyIwAAAczmC48boVVBgAwLEIDAAAh7NlSJJEYAAARyIwAAAc7larC9JfFQaGJAGAYxAYAAAOZ0tgyJr/QIUBAByDwAAAcDhbhiRlBQYqDADgGAQGAIDDUWEAgJKDwAAAcLiimPRMhQEAHIPAAABwuKKY9EyFAQAcg8AAAHA4WwKDRIUBAByJwAAAcDgqDABQchAYAAAOx1OSAKDkIDAAABzO1qckmUxUGADAUQgMAACHs7XCIFFhAABHITAAAByOCgMAlBwEBgCAwxkMf81FuJV9JSoMAOAoBAYAgMNlDUm61cBAhQEAHIfAAABwOIPhr8ej3sq+EhUGAHAUAgMAwOGybvqpMABA8UdgAAA4nIvLrc9hkP4KDQAA+yMwAAAczpZJzxIrPQOAIxEYAAAOVxQVBgIDADgGgQEA4HBZk55twZAkAHAMAgMAwOFsmfSchQoDADhGoQLDnDlzFB4eLj8/P/n5+SkyMlLfffddru0zMjI0btw4hYSEyNPTU40bN9aaNWus2ly5ckVDhw5V9erV5eXlpebNm2vnzp1WbQwGQ46vadOmWdr8/PPPateunQICAlS2bFkNGDBAKSkpOfbr4sWLqlKligwGgy5fvlyYjwAAUASyhiTZUiWgwgAAjlGowFClShVNnjxZu3fv1q5du9SmTRt17dpV+/fvz7H9qFGjNHfuXL3zzjs6cOCABg4cqIcffli//PKLpU3//v21du1aLVy4UHv37lX79u3Vtm1bnT171tImPj7e6jVv3jwZDAZFR0dLks6dO6e2bduqdu3a2r59u9asWaP9+/erT58+OfarX79+Cg8PL8ylAwCKUNakZ1tQYQAAxzCYzbb9kxsYGKhp06apX79+2bZVrlxZI0eO1KBBgyzvRUdHy8vLS4sWLdL169fl6+urr776Sp07d7a0iYiIUMeOHfXGG2/keM6HHnpIV65c0bp16yRJH3zwgV577TXFx8fL5X+DYvfu3avw8HAdOXJEtWvXtuw7Z84cffHFFxo9erQefPBBJSYmKiAgoMDXm5ycLH9/fyUlJcnPz6/A+wEA/pKcLH32meTvL/n4FH7/w4elDh2kBg2Kvm8AcCcozD2t262exGg0asmSJbp69aoiIyNzbJOWliZPT0+r97y8vLR582ZJUmZmpoxGY55t/u78+fNavXq1FixYYHUed3d3S1jIOoYkbd682RIYDhw4oHHjxmn79u06fvx4ga4zLS1NaWlplp+Tk5MLtB8AIHdUGACg5Cj0pOe9e/fKx8dHHh4eGjhwoFasWKH69evn2DYqKkozZszQkSNHZDKZtHbtWi1fvlzx8fGSJF9fX0VGRmr8+PE6d+6cjEajFi1apG3btlna/N2CBQvk6+urbt26Wd5r06aNEhISNG3aNKWnpysxMVEjRoyQJMtx0tLS1KNHD02bNk3VqlUr8PVOmjRJ/v7+llfVqlULvC8AIGe2rsMgMYcBAByl0IEhNDRUcXFx2r59u5555hn17t1bBw4cyLHtzJkzVadOHYWFhcnd3V2DBw9W3759rSoBCxculNlsVnBwsDw8PDRr1iz16NHDqs3N5s2bp549e1pVJRo0aKAFCxZo+vTpKl26tCpVqqSaNWuqYsWKluPExMSoXr16+ve//12o642JiVFSUpLldfr06ULtDwDIrigCAxUGAHAMm+cwtG3bViEhIZo7d26ubVJTU3Xx4kVVrlxZI0aM0DfffJNtovTVq1eVnJysoKAgde/eXSkpKVq9erVVm02bNqlVq1aKi4tT48aNczzX+fPn5e3tLYPBID8/Py1evFiPPvqomjRpor1798rwvxq42WyWyWSSq6urRo4cqddff71A18sc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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Sample a random lane\n", "if (lanes := map_object_dict[MapLayer.LANE]) is not None and len(lanes) > 0:\n", @@ -388,7 +475,7 @@ "id": "19", "metadata": {}, "source": [ - "### 2.3.2 `LaneGroup`" + "### 2.5.2 `LaneGroup`" ] }, { @@ -401,10 +488,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "21", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAqgAAALLCAYAAAA12HCoAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAA/xhJREFUeJzsnXdYFNf3/9/LLlvovdjoKBobahQ0wQ6JiiUWFGts+apRSNRoFMECaGzRqLGLLUrsCSb22CtRNFEsoIhRUREQ6W1+f/ib+bDu0mTLsJzX8+yjzNy999zZ2d33nnvOuQKGYRgQBEEQBEEQBE/Q07YBBEEQBEEQBFEaEqgEQRAEQRAEryCBShAEQRAEQfAKEqgEQRAEQRAEryCBShAEQRAEQfAKEqgEQRAEQRAEryCBShAEQRAEQfAKEqgEQRAEQRAEryCBShAEQRAEQfAKEqgEUYMYOXIkHB0d1dZ/VFQUBAIBkpKSeN1nRXTs2BECgQACgQA9e/bU2LgEQRCaICgoiPuMMzIy0rY5aoEEqhphv5hjY2O1bUqVyc/Px08//YQOHTrA3NwcYrEYderUgb+/P3bt2oXi4mJtm/jBhIeHw9/fH7a2thAIBAgLC1Pabv/+/Rg0aBCcnZ1hYGCAhg0b4ttvv0VGRoZcu9evX2Px4sX49NNPYW1tDTMzM7Rr1w7R0dEKfZ4+fZr7UHn/cfnyZTXMtvbSqFEjbN++HVOnTpU7HhwcDE9PT1hYWMDAwAAeHh4ICwtDVlaWXLtr165h0qRJaNKkCQwNDdGgQQMMHDgQ9+/fVxhrw4YN8PHxga2tLSQSCZycnDBq1CgFUZ6bm4vRo0fjo48+gqmpKYyMjNC8eXOsWLEChYWFHzzXiIgItGvXDtbW1pBKpXBzc0NQUBBevXpV7vN27txZ5hdcZecEAG/evMH06dPh5uYGmUwGBwcHjB49GsnJyR88p8q+Th/6nsrIyICNjQ0EAgH27t2rcD4/Px/fffcd6tSpA5lMhrZt2+L48eMfPJ9nz55h6NChaNiwIYyNjWFmZoaPP/4YW7duRVk7jkdHR8PLywuGhoYwMzODt7c3Tp06JdfmxYsXGDVqFGxsbCCTyeDp6Yk9e/Yo9HXv3j0EBwfD29sbUqlUJT8ab9++jQEDBnCfkVZWVvj000/x+++/K23/66+/ol27djAzM4OlpSV8fHxw+PBhpW0TExMxZMgQbl5ubm6YNWuWXJuRI0cqfd0bNWr0wXM6cOAAfH19UadOHUgkEtSrVw/9+/fHv//+q9A2KysLQUFBqFevHiQSCTw8PPDzzz8rtCv9g/n9h76+vkL7t2/fYvr06XBycoJEIkHdunXRv39/5OTkcG2GDRuG7du345NPPvngufIdkbYNIPjHq1ev8Nlnn+Hvv/+Gr68vZs+eDQsLC6SkpODEiRMYMmQIEhISEBISom1TP4jZs2fDzs4OLVu2xNGjR8tsN27cONSpUwdDhw5FgwYN8M8//2DVqlX4448/cP36dchkMgDApUuXMGvWLHz++eeYPXs2RCIR9u3bh4CAANy5cwdz585V6Hvy5Mlo06aN3DFXV9cKbd+wYQNKSkqqOOPaia2tLYYOHapw/Nq1a/jkk08watQoSKVS3LhxAwsXLsSJEydw9uxZ6Om9+92+aNEiXLhwAQMGDECzZs2QkpKCVatWwdPTE5cvX8ZHH33E9Xnjxg04OTnB398f5ubmePToETZs2ICYmBjcvHkTderUAfBOoN6+fRuff/45HB0doaenh4sXLyI4OBhXrlzBL7/88kFz/fvvv9GiRQsEBATA2NgY8fHx2LBhAw4fPoy4uDgYGhoqPCcrKwvTp09Xeq4qcyopKUG3bt1w584dTJgwAe7u7khISMCaNWtw9OhRxMfHw9jYuMpzquzrxFLV99ScOXPkvvDfZ+TIkdi7dy+CgoLg5uaGqKgofP755/jrr7/QoUOHKs8nNTUV//33H/r3748GDRqgsLAQx48fx8iRI3Hv3j1ERETItQ8LC8O8efPQv39/jBw5EoWFhfj333/x9OlTrk1mZiY6dOiAFy9eYMqUKbCzs8Ovv/6KgQMHYufOnRgyZAjX9tKlS1i5ciUaN24MDw8PxMXFVXkO7/P48WO8ffsWI0aMQJ06dZCTk4N9+/bB398f69atw7hx47i2P/30EyZPnowePXpg4cKFyMvLQ1RUFHr27Il9+/ahX79+XNu4uDh07NgRdevWxbfffgtLS0skJyfjyZMnCjZIJBJs3LhR7pipqekHz+mff/6Bubk5pkyZAisrK6SkpGDz5s34+OOPcenSJTRv3hwAUFxcDF9fX8TGxmLixIlwc3PD0aNHMWHCBKSnp+P777/n+pw1axbGjBkjN052dja++uordO/eXe74mzdv4OPjg//++w/jxo2Dq6srXr16hXPnziE/Px8GBgYAgFatWqFVq1Y4ceIErl+//sHz5TUMoTa2bNnCAGCuXbumbVOqhK+vL6Onp8fs27dP6flr164xO3bsKLeP3Nxcpri4WB3mVZtHjx4xDMMwr169YgAwoaGhStv99ddfCse2bt3KAGA2bNjAHXv48CGTlJQk166kpITp3LkzI5FImKysLLk+ATB79uyp9jzUAXvPsteIr31WhI+PD+Pj41Pp9kuWLGEAMJcuXeKOXbhwgcnPz5drd//+fUYikTCBgYEV9hkbG8sAYCIjIytsO2nSJAYA8/z580rbXBF79+5lADC7du1Sev67775jGjZsyAQGBjKGhoaV6lPZnC5cuMAAYFatWiXXdvPmzQwAZv/+/R8+ifdQ9jp9yHvqn3/+YUQiETNv3jylz71y5QoDgFm8eDF3LDc3l3FxcWG8vLyqP5FS9OzZkzE0NGSKioq4Y5cuXWIEAgGzbNmycp/7ww8/MACYkydPcseKi4uZNm3aMHZ2dnL37+vXr5nMzEyGYRhm8eLFantPFhUVMc2bN2caNmwod9zNzY1p06YNU1JSwh178+YNY2RkxPj7+8vZ/9FHHzFt27ZlcnJyyh1rxIgRlb53q0NKSgojEomY8ePHc8d+/fVXBgCzadMmubZffPEFI5VKmRcvXpTb5/bt2xkAzM6dO+WO/9///R9jZmbGPHz4sFK2aeoaaANa4tcyBQUFmDNnDlq1agVTU1MYGhrik08+wV9//SXXLikpCQKBAEuWLMH69evh4uICiUSCNm3a4Nq1awr93r17F/3794eFhQWkUilat26N3377rUJ7Ll26hKNHj2LcuHFyv2hL07p1awQGBnJ/s0tsu3fvxuzZs1G3bl0YGBggMzMTALBnzx60atUKMpkMVlZWGDp0qJwXAHi3BNKxY0eFsd6PuSx9HZYvXw4HBwfIZDL4+PgoXYJRRmVjOJXZ07dvXwBAfHw8d8zJyQkODg5y7QQCAfr06YP8/Hw8fPhQaf9v375FUVFRpWxhKe96VPa+GDhwIKytrSGTydCwYUOFZbP3KSsMwtHRESNHjpQ7dvv2bXTu3BkymQz16tXDggULyvT4/vnnn/jkk09gaGgIY2Nj9OjRA7dv35Zrk5KSglGjRnFLaPb29ujdu7fK41nZa1o6fMPb2xtisViunZubG5o0aSL3+lelT1W0rSzl9fngwQMsX74cy5Ytg0hU+YU0ZX2y73NbW1u5tvb29gDArTSogoquU2XfU1OmTEHfvn3LXB7du3cvhEKhnAdQKpVi9OjRuHTpklJP3ofi6OiInJwcFBQUcMd+/PFH2NnZYcqUKWAYRiGsgeXcuXOwtrZG586duWN6enoYOHAgUlJScObMGe64hYXFB3myq4pQKET9+vUVXqPMzEwupILFxMQERkZGcvfIsWPH8O+//yI0NBQymQw5OTkVhpQVFxdz96E6sLGxgYGBgdyczp07BwAICAiQaxsQEIC8vDwcOnSo3D5/+eUXGBoaonfv3tyxjIwMbNmyBePGjYOTkxMKCgqQn5+vuonUMEigapnMzExs3LgRHTt2xKJFixAWFoZXr17B19dX6RLML7/8gsWLF2P8+PFYsGABkpKS0K9fP7n4tdu3b6Ndu3aIj4/HjBkzsHTpUhgaGqJPnz44cOBAufawsUPKlkYrYv78+Th8+DCmTp2KiIgIiMViREVFYeDAgRAKhYiMjMTYsWOxf/9+dOjQoVpfxtu2bcPKlSsxceJEzJw5E//++y86d+6MFy9efHCflSElJQUAYGVlVa22o0aNgomJCaRSKTp16lTtOOXK3Be3bt1C27ZtcerUKYwdOxYrVqxAnz59yowXqyopKSno1KkT4uLiMGPGDAQFBWHbtm1YsWKFQtvt27ejR48eMDIywqJFixASEoI7d+6gQ4cOcuLziy++wIEDBzBq1CisWbMGkydPxtu3b6sV2wgARUVFSE1NxbNnz3Ds2DHMnj0bxsbG+Pjjj8t9HsMwePHiRZmv/+vXr/Hy5UvExsZi1KhRAIAuXbootCsoKEBqaiqePHmCAwcOYMmSJXBwcKhUmEd5tqWmpiIlJQXnzp3D5MmTIRQKlf7QCgoKQqdOnfD5559X2G9Fc2rdujUMDQ0REhKCU6dO4enTpzhz5gymT5+ONm3aoGvXrh88p6q8TpV9T+3ZswcXL17EDz/8UOa4N27cgLu7O0xMTOSOs+NWZ3k8NzcXqampSEpKwtatW7FlyxZ4eXnJibSTJ0+iTZs2WLlyJaytrWFsbAx7e3usWrVKrq/8/HylPwDYZeC///77g+2sCtnZ2UhNTUViYiKWL1+OP//8U+G+79ixI44cOYKffvoJSUlJuHv3LiZOnIg3b95gypQpXLsTJ04AeLd0z95bBgYGCAgIQFpamsLYOTk5MDExgampKSwsLDBx4sQyBX1VyMjIwKtXr/DPP/9gzJgxyMzMlJtTfn4+hEKhwo/Yylz7V69e4fjx4+jTp49ciM358+eRl5cHV1dX9O/fHwYGBpDJZGjfvr1KQjJqHFr24Oo0lVniLyoqUlhGTE9PZ2xtbZkvv/ySO/bo0SMGAGNpacmkpaVxxw8dOsQAYH7//XfuWJcuXZimTZsyeXl53LGSkhLG29ubcXNzK9fmvn37MgCYjIwMueO5ubnMq1evuEd6ejp3jl1ic3Z2lluSKSgoYGxsbJiPPvqIyc3N5Y7HxMQwAJg5c+Zwx8pakh0xYgTj4OCgcB1kMhnz33//ccfZJbng4OBy51eaipb4lTF69GhGKBQy9+/fL7fd69evGRsbG+aTTz6RO37hwgXmiy++YDZt2sQcOnSIiYyMZCwtLRmpVMpcv369wvHLuh6VuS8+/fRTxtjYmHn8+LFcn6WX3JQtx5d1jRwcHJgRI0ZwfwcFBTEAmCtXrnDHXr58yZiamsr1+fbtW8bMzIwZO3asXH8pKSmMqakpdzw9PV1hmbWyVLTEf+nSJQYA92jYsKHSkI73YZfl3l/WY5FIJFyflpaWzMqVK5W227Vrl9z4rVu3Zm7dulWZqZXJ8+fP5fqsV68eEx0drdAuJiaGEYlEzO3btxmGqXiJsDJziomJYezt7eXG9/X1Zd6+fVutOVXmdarKeyonJ4dp0KABM3PmTIZhyg4PaNKkCdO5c2cFe27fvs0AYNauXfvBc4qMjJSbU5cuXZjk5GTufFpaGnetjYyMmMWLFzPR0dGMn5+fwthff/01o6enpxBiFBAQwABgJk2apNQGVS/xjx8/npuPnp4e079/f7nPI4ZhmBcvXjBdunSRm7uVlRVz8eJFuXb+/v7c/AMDA5m9e/cyISEhjEgkYry9veU+r2bMmMF89913THR0NLNr1y5mxIgRDACmffv2TGFhYbXm1LBhQ85OIyMjZvbs2XJha0uXLmUAMOfOnZN73owZMxgATM+ePcvs+6effmIAMH/88Yfc8WXLlnFz//jjj5mdO3cya9asYWxtbRlzc3Pm2bNnCn3p8hI/JUlpGaFQCKFQCOBdskFGRgZKSkrQunVrpYHPgwYNgrm5Ofc3u0TFLiOnpaXh1KlTmDdvHt6+fYu3b99ybX19fREaGoqnT5+ibt26Su1hl0nez+pdu3YtgoODub+bNGmisKQ+YsQIuV/zsbGxePnyJcLCwiCVSrnjPXr0QKNGjXD48GGlCUSVoU+fPnJz+Pjjj9G2bVv88ccfWLZs2Qf1WRG//PILNm3axGUrl0VJSQkCAwORkZGBn376Se6ct7c3vL29ub/9/f3Rv39/NGvWDDNnzsSRI0c+yLaK7otXr17h7NmzmDJlCho0aCD33NJLbtXhjz/+QLt27eS8W9bW1ggMDMSaNWu4Y8ePH0dGRgYGDx6M1NRU7rhQKETbtm258BaZTAaxWIzTp09j9OjRcvOrLo0bN8bx48eRnZ2Nixcv4sSJExV6XViPj5eXF0aMGKG0zZ9//om8vDzEx8djx44dyM7OVtquU6dO3HU4efIkbt68WWbbymJhYYHjx48jLy8PN27cwP79+xXmVFBQgODgYHz11Vdo3LhxpfqtzJysra3RsmVLrupBXFwcfvjhB4waNUppRnllqczrVJX31MKFC1FYWCiXwKKM3NxcSCQShePs51hubu4Hz2nw4MFo3bo1Xr16hZiYGLx48UKuP3Z+r1+/xu7duzFo0CAAQP/+/dG0aVMsWLAA48ePBwCMGTMGa9euxcCBA7F8+XLY2tri119/5VbKqmNnVQgKCkL//v3x7Nkz/PrrryguLpYLWQDAVUKpV68eevbsibdv32L58uXo168fzp07x60esPNv06YNduzYAeDdSoqBgQFmzpyJkydPcl75yMhIuTECAgLg7u6OWbNmYe/evQrL71Vhy5YtyMzMxMOHD7Flyxbk5uaiuLiYS84bMmQI5s2bhy+//BKrV6+Gm5sbjh07xn3WlXftf/nlF1hbW6Nbt25yx9m5CwQCnDx5kvsebtmyJby8vLB69WosWLDgg+dU49C2QtZlKpskFRUVxTRt2pTR19eX+3Xp5OTEtWE9ZQsXLlR4PgAmLCyMYZj/eRLLe5TnqevTp49SD2pycjJz/Phx5vjx40yzZs2YJk2acOdYL8S2bdvknsN6iUoH8Jcex8rKivu7qh7U0t5XlmHDhjESiaTMub1PVTyoZ8+eZaRSKePr61vhL/MJEyYovR7lERAQwIjFYrlECWWUdT0qui8uX76skNyljOp4UCUSCTNs2DCFditWrJDrc9GiReXenyYmJtxzly9fzujp6TH6+vrMJ598wixatKhSiURVTZLauXMno6enx8TFxSk9//z5c8bZ2ZmpX78+8/Tp00r1mZCQwEilUuann36qsG14eDhjZGSk0iQpNnmptBd94cKFjLm5OfP69WvuWFU8MMrmlJiYyBgYGDB79+6VaxsVFaXUS1QdKnqdSvP+e+rRo0eMTCZjNm/ezLXRhgf1fcaOHcvUr1+fW31iP5f09fUVPg/mzp3LAJBbBdmzZw9jaWnJvX/s7OyYn3/+mQHATJkyRemY6kySYhiG6datm0JClJ+fn4JX8fXr14yFhQUzcOBA7liPHj0YAMzWrVvl2j5+/JgBwMydO7fcsXNychg9PT1m9OjRKpjJO9LS0hhbW1vm22+/lTt+5swZpkGDBnKfXWwibe/evZX2lZiYWKZ3m31dRo0apXDOycmJ6dSpk8JxXfagUgyqltmxYwdGjhwJFxcXbNq0CUeOHMHx48fRuXNnpcklrLf1fZj/X0ePfc7UqVNx/PhxpY/y4tzY+nHve0fr16+Prl27omvXrmV6sqqTDFGWF48P9VZv3rwJf39/fPTRR9i7d2+5SSVz587FmjVrsHDhQgwbNqzSY9SvXx8FBQUf7EWr6L5QBx/62rD36Pbt25Xen6WTC4KCgnD//n1ERkZCKpUiJCQEHh4euHHjhkrmwMImBO7evVvh3Js3b/DZZ58hIyMDR44c4corVYSLiwtatmyJnTt3Vti2f//+yMrKqjCxoip4e3vD3t6eG//NmzdYsGABxo4di8zMTCQlJSEpKQlZWVlgGAZJSUl4+fJluX0qm1NUVBTy8vIUNkTw9/cHAFy4cEFlcyrvdXqf999Tc+bMQd26ddGxY0du7myc+KtXr5CUlMTdm/b29nj+/LlCn+yxyt4DlaF///548uQJzp49CwBcYqulpaXC+9rGxgYAkJ6eLvf8Z8+e4erVq7h06RIeP34MZ2dnAIC7u7vK7KwK/fv3x7Vr17iawQ8fPsSRI0e4e4LFwsICHTp0kLtH2Gv7ftKdsrkrQyaTwdLSUmm86odibm6Ozp07K7yXP/30Uzx8+BA3btzA+fPn8fTpU7Rr1w5A2deeLSVXOtGYpay5A+/mX9HcdQ1a4tcye/fuhbOzM/bv3y8n0kJDQz+oP/aDSV9f/4OSE3r27ImFCxdi586daN++/QfZwMJmtt+7d08uy5Q9Vjrz3dzcXGm2++PHj5X2/eDBA4Vj9+/fV/kuS4mJifDz84ONjQ3++OOPcnfsWL16NcLCwhAUFITvvvuuSuM8fPgQUqlUbTuCsPdFZSsdlMbc3Fwhoa2goEDhC9zBwUHp63Lv3j25v11cXAC8+8CtzD3q4uKCb7/9Ft9++y0ePHiAFi1aYOnSpdzynyrIz89HSUkJ3rx5I3c8Ly8PvXr1wv3793HixIlKL4uz5ObmVioLl10OfH/86pKXl8f1mZ6ejqysLPzwww9KE4ScnJzQu3dvHDx4sEJbS8/pxYsXYBhG4QcLm6BX1UoV5VHW66SM999TycnJSEhI4N4LpZkwYQKAd9fIzMwMLVq0wF9//YXMzEy5RKkrV64AAFq0aKGC2bzj/ddeT08PLVq0wLVr11BQUCCXhPPs2TMA70IqSiMWi+VqwLKJRtVJUKsO78+JTV5V9qO2sLBQ7h5p1aoVNmzYoFDppay5v8/bt2+RmppaYbuqkpubq/S+EwqFcvdDRdf+l19+gYuLCydkS9OqVSsAUJg78G7+1dmAoCZCHlQtw/5CLu3punLlCi5duvRB/dnY2KBjx45Yt26dUg9ARTvLtG/fHt26dcP69evL9OZU1ivXunVr2NjYYO3atXJfaH/++Sfi4+PRo0cP7piLiwvu3r0rZ9/NmzfL9L4cPHhQ7k189epVXLlyBZ999lmlbKsMKSkp6N69O/T09HD06NFyP/Cio6MxefJkBAYGlhsDq+z637x5E7/99hs3ljqwtrbGp59+is2bNytkwFf0erq4uHDeHZb169crfNl8/vnnuHz5Mq5evcode/XqlYLXwdfXFyYmJoiIiFC6exJ7jXJycpCXl6dgi7Gx8QeXXsnIyFA6Jlvou3Xr1tyx4uJiDBo0CJcuXcKePXvg5eWltM+ioiKlno2rV6/in3/+keszNTVV6fVWNn5lyc7OVlpwft++fUhPT+f6tLGxwYEDBxQenTp1glQqxYEDBzBz5swqz8nd3R0Mw+DXX3+Va7tr1y4A7+LnqkpVXqfKvqcWLFigMPf58+cDAKZPn44DBw5wGdX9+/dHcXEx1q9fz/WZn5+PLVu2oG3btqhfv36V51TWZ++mTZsgEAjg6enJHRs0aBCKi4uxdetW7lheXh527tyJxo0bl+vBffDgAdauXYuePXuq3YOqzONeWFiIbdu2QSaTcT/oXF1doaenh+joaLn7/7///sO5c+fk7pHevXtDIpFgy5YtcquI7GvPxm3m5eXJ5ViwzJ8/HwzDwM/PT2VzSkpKwsmTJyt8f7569QqLFi1Cs2bNlArUGzduID4+Xm4DhdI0bNgQzZs3x6FDh+Ti848dO4YnT54oxKzqOuRB1QCbN29WmvwyZcoU9OzZE/v370ffvn3Ro0cPPHr0CGvXrkXjxo0/uFTG6tWr0aFDBzRt2hRjx46Fs7MzXrx4gUuXLuG///7DzZs3y33+jh074Ofnhz59+uCzzz7jlvXZnaTOnj1bKSGor6+PRYsWYdSoUfDx8cHgwYPx4sULrFixAo6OjnJJV19++SWWLVsGX19fjB49Gi9fvsTatWvRpEkTpfXtXF1d0aFDB/zf//0f8vPz8eOPP8LS0hLTp0+v0K7t27fj8ePH3Jf62bNnucDzYcOGcZ5dPz8/PHz4ENOnT8f58+dx/vx5rg9bW1vuw+Lq1asYPnw4LC0t0aVLFwVB5u3tzXltBg0aBJlMBm9vb9jY2ODOnTtYv349DAwMsHDhwgptrw4rV65Ehw4d4OnpydXZS0pK4nYbKosxY8bgq6++whdffIFu3brh5s2bOHr0qEKppenTp2P79u3w8/PDlClTYGhoiPXr18PBwQG3bt3i2pmYmODnn3/GsGHD4OnpiYCAAFhbWyM5ORmHDx9G+/btsWrVKty/fx9dunTBwIED0bhxY4hEIhw4cAAvXrz44OSH06dPY/Lkyejfvz/c3NxQUFCAc+fOYf/+/WjdurVcebVvv/0Wv/32G3r16oW0tDQFjy3bNisrC/Xr18egQYO4bVH/+ecfbNmyBaampnI7ru3YsQNr165Fnz594OzsjLdv3+Lo0aM4fvw4evXqJbfSkJSUBCcnJ4wYMQJRUVFlzunBgwfo2rUrBg0ahEaNGkFPTw+xsbHYsWMHHB0duRI+BgYG6NOnj8LzDx48iKtXr8qdq8qcRo4ciSVLlmD8+PG4ceMGmjRpguvXr2Pjxo1o0qQJVzuYvf6dOnVCaGhomVsMV/V1qux7StnuT2ZmZgDeJeSUnn/btm0xYMAAzJw5Ey9fvoSrqyu2bt2KpKQkbNq0Sa6PsLAwzJ07F3/99ZfSkl4s4eHhuHDhAvz8/NCgQQOkpaVh3759uHbtGr7++mu50Kvx48dj48aNmDhxIu7fv48GDRpwn1vvl4Vr3LgxBgwYgAYNGuDRo0f4+eefYWFhgbVr18q1e/PmDZe0yf7wX7VqFczMzGBmZoZJkyZxbUeOHImtW7fi0aNH5a5KjR8/HpmZmfj0009Rt25dpKSkYOfOnbh79y6WLl3Kea+tra3x5ZdfYuPGjejSpQv69euHt2/fYs2aNcjNzeV+GAGAnZ0dZs2ahTlz5nDfQzdv3sSGDRswePBgzlOckpKCli1bYvDgwZxn8ejRo/jjjz/g5+cnV18U+F8N3YpqKDdt2hRdunRBixYtYG5ujgcPHmDTpk0oLCxU+Iz28fGBl5cXXF1dkZKSgvXr1yMrKwsxMTFKnQ3sd4Oy5X2W5cuXo1u3bujQoQPGjx+PN2/eYNmyZXB3d8f//d//lWu7zqGl2NdaAZtwUtbjyZMnTElJCRMREcE4ODgwEomEadmyJRMTE1NmMoyykjtQksSSmJjIDB8+nLGzs2P09fWZunXrMj179lRIZCiL3Nxc5scff2S8vLwYExMTRiQSMXZ2dkzPnj2ZnTt3ygXvV7STS3R0NNOyZUtGIpEwFhYWTGBgoFyJKJYdO3Ywzs7OjFgsZlq0aMEcPXq03OuwdOlSpn79+oxEImE++eQT5ubNm5Wam4+PT5mvSekSNuW9dqUTcCp6nbds2cK1XbFiBfPxxx8zFhYWjEgkYuzt7ZmhQ4cyDx48qJTt1b0v/v33X6Zv376MmZkZI5VKmYYNGzIhISEKcymdPFFcXMx89913jJWVFWNgYMD4+voyCQkJCklSDMMwt27dYnx8fBipVMrUrVuXmT9/PrNp0yalCRl//fUX4+vry5iamjJSqZRxcXFhRo4cycTGxjIMwzCpqanMxIkTmUaNGjGGhoaMqakp07ZtW+bXX3+t8DqVlSSVkJDADB8+nHF2dmZkMhkjlUqZJk2aMKGhoXI7frF9lPe6suTn5zNTpkxhmjVrxpiYmDD6+vqMg4MDM3r0aIU5X7t2jRkwYADToEEDRiKRMIaGhoynpyezbNkyheS7f/75hwHAzJgxo9y5vnr1ihk3bhx3ncRiMePm5sYEBQUxr169qvBaKUuyqMqcGIZh/vvvP+bLL79knJycGLFYzNjb2zNjx45VGP/333+vVJJRVV6n6rynyvvsys3NZaZOncrY2dkxEomEadOmDXPkyBGFdt9++y0jEAiY+Pj4csc6duwY07NnT6ZOnTqMvr4+Y2xszLRv357ZsmWLXDIRy4sXL5gRI0YwFhYWjEQiYdq2bat0/ICAAKZ+/fqMWCxm6tSpw3z11VdKdzFiPyuUPUp/pjDMu92QZDKZXDlBZezatYvp2rUrY2try4hEIsbc3Jzp2rUrc+jQIYW2hYWFzE8//cS0aNGCMTIyYoyMjJhOnToxp06dUmhbUlLC/PTTT4y7uzujr6/P1K9fn5k9ezZTUFDAtUlPT2eGDh3KuLq6MgYGBoxEImGaNGnCREREyLVjsbKyYtq1a1fufBiGYUJDQ5nWrVsz5ubmjEgkYurUqcMEBAQoLQMXHBzMODs7MxKJhLG2tmaGDBnCJCYmKu23uLiYqVu3LuPp6VmhDcePH2fatWvHSKVSxsLCghk2bFiZCZS6nCQlYBg1ZlEQhIphvUqLFy/G1KlTtW0OwVM6duyIwsJCHDp0CGKxWKHgek1gzZo1mD59OhITE5UmTdREpk+fjl27diEhIUFpGaeayMcffwwHB4dqldPiG7a2thg+fDgWL16sbVNUwp07d9CkSRPExMTIhZbVZLKzs5Gbm4uvv/4av//+u0o2J+AbFINKEIROcvHiRVhbW5cZ78V3/vrrL0yePFlnxCnwbk4hISE6I04zMzNx8+ZNzJs3T9umqIzbt28jNze3yomefOavv/6Cl5eXzohTAJg1axasra0rVdGipkIeVKJGQR5UojL8/fffXJKPtbU1mjdvrmWLCIIgVMf9+/e5hFeRSFRu/HNNhZKkCILQOdhyLQRBELqIu7u71urcagryoBIEQRAEQRC8gmJQCYIgCIIgCF5BApUgCIIgCILgFSRQCYIgCIIgCF5BApUgCIIgCILgFbVSoPr7+6NBgwaQSqWwt7fHsGHD8OzZs3Kfk5iYiL59+8La2homJiYYOHAgXrx4Idfm+vXr6NatG8zMzGBpaYlx48YpFM+9du0aunTpAjMzM5ibm8PX17fCrUeVER8fD39/f5iamsLQ0BBt2rRR2GOdIAiCIAiiJqKzArVjx45l7l/dqVMn/Prrr7h37x727duHxMRE9O/fv8y+srOz0b17dwgEApw6dQoXLlxAQUEBevXqhZKSEgDAs2fP0LVrV7i6uuLKlSs4cuQIbt++jZEjR3L9ZGVlcfswX7lyBefPn4exsTF8fX1RWFhY6bklJiaiQ4cOaNSoEU6fPo1bt24hJCQEUqm00n0QBEEQBEHwFm3us6pOfHx85PZAL49Dhw4xAoFA6f69DMMwR48eZfT09Jg3b95wxzIyMhiBQMAcP36cYRiGWbduHWNjY8MUFxdzbW7dusUA4PaEvnbtGgOASU5OLrMNwzDMuXPnmA4dOjBSqZSpV68e8/XXX8vtPz1o0CBm6NChlZobQRAEQRBETUNnPaiVJS0tDTt37oS3tzf09fWVtsnPz4dAIJDbnk8qlUJPTw/nz5/n2ojFYujp/e+SymQyAODaNGzYEJaWlti0aRMKCgqQm5uLTZs2wcPDA46OjgDeeUf9/PzwxRdf4NatW4iOjsb58+cxadIkAEBJSQkOHz4Md3d3+Pr6wsbGBm3btsXBgwdVfWkIgiAIgiC0Qq0VqN999x0MDQ1haWmJ5ORkHDp0qMy27dq1g6GhIb777jvk5OQgOzsbU6dORXFxMZ4/fw4A6Ny5M1JSUrB48WIUFBQgPT0dM2bMAACujbGxMU6fPo0dO3ZAJpPByMgIR44cwZ9//gmR6N2mXpGRkQgMDERQUBDc3Nzg7e2NlStXYtu2bcjLy8PLly+RlZWFhQsXws/PD8eOHUPfvn3Rr18/nDlzRs1XjSAIgiAIQv3ojECNiIiAkZER9zh37hy++uoruWOlk4imTZuGGzdu4NixYxAKhRg+fDiYMjbVsra2xp49e/D777/DyMgIpqamyMjIgKenJ+cxbdKkCbZu3YqlS5fCwMAAdnZ2cHJygq2tLdcmNzcXo0ePRvv27XH58mVcuHABH330EXr06IHc3FwAwM2bNxEVFSVnt6+vL0pKSvDo0SMu5rV3794IDg5GixYtMGPGDPTs2RNr165V5yUmCIIgCILQCCJtG6AqvvrqKwwcOJD7OzAwEF988QX69evHHatTpw73fysrK1hZWcHd3R0eHh6oX78+Ll++DC8vL6X9d+/eHYmJiUhNTYVIJIKZmRns7Ozg7OzMtRkyZAiGDBmCFy9ewNDQEAKBAMuWLePa/PLLL0hKSsKlS5c40frLL7/A3Nwchw4dQkBAALKysjB+/HhMnjxZwYYGDRoAAEQiERo3bix3zsPDgwslIAiCIAiCqMnojEC1sLCAhYUF97dMJoONjQ1cXV0rfC7rlczPz6+wrZWVFQDg1KlTePnyJfz9/RXa2NraAgA2b94MqVSKbt26AQBycnKgp6cHgUDAtWX/Zm3w9PTEnTt3yrW7TZs2uHfvntyx+/fvw8HBoUL7CYIgCIIg+I7OLPFXlitXrmDVqlWIi4vD48ePcerUKQwePBguLi6c9/Tp06do1KgRrl69yj1vy5YtuHz5MhITE7Fjxw4MGDAAwcHBaNiwIddm1apVuH79Ou7fv4/Vq1dj0qRJiIyMhJmZGQCgW7duSE9Px8SJExEfH4/bt29j1KhREIlE6NSpE4B3sbEXL17EpEmTEBcXhwcPHuDQoUNckhTwLjwhOjoaGzZsQEJCAlatWoXff/8dEyZM0MAVJAiCIAiCUC8640GtLAYGBti/fz9CQ0ORnZ0Ne3t7+Pn5Yfbs2VyWfmFhIe7du4ecnBzueffu3cPMmTORlpYGR0dHzJo1C8HBwXJ9X716FaGhocjKykKjRo2wbt06DBs2jDvfqFEj/P7775g7dy68vLygp6eHli1b4siRI7C3twcANGvWDGfOnMGsWbPwySefgGEYuLi4YNCgQVw/ffv2xdq1axEZGYnJkyejYcOG2LdvHzp06KDOS0cQBEEQBKERBExZmUEEQRAEQRAEoQVq3RI/QRAEQRAEwW9IoBIEQRAEQRC8osbHoJaUlODZs2cwNjaWy44nCIIgCIIgtAPDMHj79i3q1Kkjt8tmZanxAvXZs2eoX7++ts0gCIIgCIIg3uPJkyeoV69elZ9X4wWqsbExgHcXwMTERMvWEARBEARBEJmZmahfvz6n06pKjReo7LK+iYkJCVSCIAiCIAge8aHhl5QkRRAEQRAEQfAKEqgEQRAEQRAEryCBShAEQRAEQfCKGh+DShAEQRA1keLiYhQWFmrbDIL4IPT19SEUCtXWPwlUgiAIgtAgDMMgJSUFGRkZ2jaFIKqFmZkZ7Ozs1FKHngQqQRAEQWgQVpza2NjAwMCANpkhahwMwyAnJwcvX74EANjb26t8DBKoBEEQBKEhiouLOXFqaWmpbXMI4oORyWQAgJcvX8LGxkbly/2UJEUQBEEQGoKNOTUwMNCyJQRRfdj7WB2x1CRQCYIgCELD0LI+oQuo8z4mgUoQBEEQBEHwCopBJQiCIAgeUFhYiOLiYo2MJRQKoa+vr5GxCOJDIIFKEARBEFqmsLAQ9+7dQ25urkbGk8lkaNiwYZVEakpKCsLDw3H48GE8ffoUNjY2aNGiBYKCgtClSxc1Wls9BAIBDhw4gD59+qiknabx9/dHXFwcXr58CXNzc3Tt2hWLFi1CnTp1uDYMw2Dp0qVYv349Hj9+DCsrK0yYMAGzZs3i2qxevRqrVq1CUlISGjRogFmzZmH48OHc+du3b2POnDn4+++/8fjxYyxfvhxBQUGanKocJFAJgiAIQssUFxcjNzcXIpEIIpF6v5qLioqQm5uL4uLiSgvUpKQktG/fHmZmZli8eDGaNm2KwsJCHD16FBMnTsTdu3c/yBaGYVBcXKww54KCAojF4g/qU9fo1KkTvv/+e9jb2+Pp06eYOnUq+vfvj4sXL3JtpkyZgmPHjmHJkiVo2rQp0tLSkJaWxp3/+eefMXPmTGzYsAFt2rTB1atXMXbsWJibm6NXr14AgJycHDg7O2PAgAEIDg7W+Dzfh2JQCYIgCIIniEQiiMVitT4+RABPmDABAoEAV69exRdffAF3d3c0adIE33zzDS5fvgzgnYgVCASIi4vjnpeRkQGBQIDTp08DAE6fPg2BQIA///wTrVq1gkQiwfnz59GxY0dMmjQJQUFBsLKygq+vLwDg33//xWeffQYjIyPY2tpi2LBhSE1N5frv2LEjJk+ejOnTp8PCwgJ2dnYICwvjzjs6OgIA+vbtC4FAwP1dVV6/fo3Bgwejbt26MDAwQNOmTbFr1y65NhXZwl6PMWPGwNraGiYmJujcuTNu3rxZ7tjBwcFo164dHBwc4O3tjRkzZuDy5ctc5nx8fDx+/vlnHDp0CP7+/nByckKrVq3QrVs3ro/t27dj/PjxGDRoEJydnREQEIBx48Zh0aJFXJs2bdpg8eLFCAgIgEQi+aDrpEpIoBIEQRAEUSZpaWk4cuQIJk6cCENDQ4XzZmZmVe5zxowZWLhwIeLj49GsWTMAwNatWyEWi3HhwgWsXbsWGRkZ6Ny5M1q2bInY2FgcOXIEL168wMCBA+X62rp1KwwNDXHlyhX88MMPmDdvHo4fPw4AuHbtGgBgy5YteP78Ofd3VcnLy0OrVq1w+PBh/Pvvvxg3bhyGDRuGq1evVtoWABgwYABevnyJP//8E3///Tc8PT3RpUsXOW9neaSlpWHnzp3w9vbmvN+///47nJ2dERMTAycnJzg6OmLMmDFyfebn50Mqlcr1JZPJcPXqVd5ut0sClSAIgiCIMklISADDMGjUqJHK+pw3bx66desGFxcXWFhYAADc3Nzwww8/oGHDhmjYsCFWrVqFli1bIiIiAo0aNULLli2xefNm/PXXX7h//z7XV7NmzRAaGgo3NzcMHz4crVu3xsmTJwEA1tbWAP63JSf7d1WpW7cupk6dihYtWsDZ2Rlff/01/Pz88Ouvv8q1K8+W8+fP4+rVq9izZw9at24NNzc3LFmyBGZmZti7d2+543/33XcwNDSEpaUlkpOTcejQIe7cw4cP8fjxY+zZswfbtm1DVFQU/v77b/Tv359r4+vri40bN+Lvv/8GwzCIjY3Fxo0bUVhYKOeR5hMkUAmCIAiCKBOGYVTeZ+vWrRWOtWrVSu7vmzdv4q+//oKRkRH3YEVyYmIi1471wLLY29tzW3CqiuLiYsyfPx9NmzaFhYUFjIyMcPToUSQnJ8u1K8+WmzdvIisrC5aWlnJzevTokdx8lDFt2jTcuHEDx44dg1AoxPDhw7nXpaSkBPn5+di2bRs++eQTdOzYEZs2bcJff/2Fe/fuAQBCQkLw2WefoV27dtDX10fv3r0xYsQIAICeHj+lICVJEQRBEARRJm5ubhAIBBUmQrFCp7SgLWv5WFmowPvHsrKy0KtXL7k4SZbSe7+/n+glEAhQUlJSrq1VZfHixVixYgV+/PFHNG3aFIaGhggKCkJBQYFcu/JsycrKgr29PRePW5qKwiSsrKxgZWUFd3d3eHh4oH79+rh8+TK8vLxgb28PkUgEd3d3rr2HhwcAIDk5GQ0bNoRMJsPmzZuxbt06vHjxAvb29li/fj2MjY0/2KusbkigEgRBEARRJhYWFvD19cXq1asxefJkBSGZkZEBMzMzTug8f/4cLVu2BAC5hKmq4unpiX379sHR0bFalQ309fWrXV/2woUL6N27N4YOHQrgndfy/v37aNy4caX78PT0REpKCkQi0Qcna7FjA+/iSgGgffv2KCoqQmJiIlxcXACAC4FwcHCQe66+vj7q1asHANi9ezd69uzJWw8qP60iCIIgCII3rF69GsXFxfj444+xb98+PHjwAPHx8Vi5ciW8vLwAvEu6adeuHZf8dObMGcyePfuDx5w4cSLS0tIwePBgXLt2DYmJiTh69ChGjRpVJcHp6OiIkydPIiUlBenp6eW2ffToEeLi4uQe2dnZcHNzw/Hjx3Hx4kXEx8dj/PjxePHiRZXm07VrV3h5eaFPnz44duwYkpKScPHiRcyaNQuxsbFKn3PlyhWsWrUKcXFxePz4MU6dOoXBgwfDxcWFu+5du3aFp6cnvvzyS9y4cQN///03xo8fj27dunFe1fv372PHjh148OABrl69ioCAAPz777+IiIjgxiooKODmXFBQgKdPnyIuLg4JCQlVmqeq4IVAXb16NRwdHSGVStG2bVuFrDiCIAiCqA0UFRWhoKBArY+ioqIq2+Xs7Izr16+jU6dO+Pbbb/HRRx+hW7duOHnyJH7++Weu3ebNm1FUVIRWrVohKCgICxYs+OBrUadOHVy4cAHFxcXo3r07mjZtiqCgIJiZmVXJ67d06VIcP34c9evX5zy7ZfHNN9+gZcuWco8bN25g9uzZ8PT0hK+vLzp27Ag7O7sqF/QXCAT4448/8Omnn2LUqFFwd3dHQEAAHj9+DFtbW6XPMTAwwP79+9GlSxc0bNgQo0ePRrNmzXDmzBmuFJSenh5+//13WFlZ4dNPP0WPHj3g4eGB3bt3c/0UFxdj6dKlaN68Obp164a8vDxcvHhRzpP77Nkzbs7Pnz/HkiVL0LJlS4wZM6ZK81QVAkYd0c9VIDo6GsOHD8fatWvRtm1b/Pjjj9izZw/u3bsHGxubCp+fmZkJU1NTvHnzBiYmJhqwmCAIQruEhYVBKBQiJCRE4dz8+fNRXFysUH+R4Ad5eXl49OgRnJyc5Mr+1ISdpAjifcq6n4Hq6zOtx6AuW7YMY8eOxahRowAAa9euxeHDh7F582bMmDFDy9YRBEHwD6FQiDlz5gCAnEidP38+5syZg3nz5mnLNOID0dfXR8OGDasdK1lZhEIhiVOC12hVoBYUFODvv//GzJkzuWN6enro2rUrLl26pPQ5+fn5XGAw8E6hEwRB1CZYUVpapJYWp8o8qwT/0dfXJ9FIEP8frQrU1NRUFBcXK8Re2NrallnOIjIyEnPnztWEeQRRI0hJSUFGRoa2zZCDYRikp6dzBbjLQiAQfPD5ip77ftucnBxuG8nKRDaV16asc1Xpt6ioCIaGhjA2Nq7wOcqeP378eGRnZ2POnDmYO3cuiouL8d1332H8+PGVqgGpzNaioiI8e/ZMYamOpTLXvKqvS3mkpaXB3Ny8Sn1qigYNGsDIyEjbZhCEzqL1Jf6qMnPmTHzzzTfc35mZmahfv74WLSII7VFSUoKTJ0/i6dOnEAqF2jaHIyMjA8nJyWjSpAlv7EpMTIREIuFKrGib9PR02NraKhQnf5/yxFnv3r2xdOlSFBUVQSQSwd/fXy7jtiJh975Iffr0KWJjYysV/6/MTlWmNJSUlOCff/5BgwYNYG5urrJ+VUFBQQE6d+6Mtm3batsUgtBZtCpQraysIBQKFUo1vHjxAnZ2dkqfI5FIuMw1giDeeb2srKx4VWz54cOHSEhIUBo4ry0SEhIUillrk5s3bwIALC0tP7iPNWvWoKioCEKhEEVFRYiOjsaECRM+uD92VYsP16igoADXr1+HpaUlXF1dtW2OHA8ePFDL7koEQfwPrZaZEovFaNWqFbdPLfA/jxBb34sgCEIXqe6y9Zo1a7By5Ur07dsXISEhGDx4MFauXIk1a9aoyEKCIAjtofUl/m+++QYjRoxA69at8fHHH+PHH39EdnY2l9VPEETZ8DE2j6g8H+qFY8Xp5MmT0bJlSzx+/BiDBg2CtbU1Vq5cCQDV8qQSBEFoG60L1EGDBuHVq1eYM2cOUlJS0KJFCxw5cqTMorUEQchDIrX2UVxcjMmTJ2PChAm4fPkyd5wVpZoqVUQQBKEutC5QAWDSpEmYNGmSts0gCIKoEXz99dcKx1hvLHlOCYLQBXix1SlBEB+GQCBQefY0oTnodSMI9SMQCHDw4EEAQFJSEgQCAeLi4rRqE1ExJFAJooYjFApRUlKibTPkYMMO+GaXLkIhHoQmSUlJwddffw1nZ2dIJBLUr18fvXr1kkt2ri4dO3ZEUFCQyvorTf369fH8+XN89NFHaumfUB28WOInCOLDIYFSc1Hla0feWELdJCUloX379jAzM8PixYvRtGlTFBYW4ujRo5g4cWKZG+xoi4KCAojFYrljQqGwzDKWBL8gDypB1HBEIhGJE4Ig1M6ECRMgEAhw9epVfPHFF3B3d0eTJk3wzTffcMl6GRkZGDNmDKytrWFiYoLOnTtzNX8BICwsDC1atMD27dvh6OgIU1NTBAQE4O3btwCAkSNH4syZM1ixYgUXwpSUlAQA+Pfff/HZZ5/ByMgItra2GDZsGFJTU7m+O3bsiEmTJiEoKAhWVlbw9fVVmMP7S/ynT5+GQCDAyZMn0bp1axgYGMDb2xv37t2Te96hQ4fg6ekJqVQKZ2dnzJ07F0VFRaq8vMR7kEAliBoOxaBWDj5eI1XYxHph+Tg/onIwDIOCggKNP6pyz6SlpeHIkSOYOHEiDA0NFc6bmZkBAAYMGICXL1/izz//xN9//w1PT0906dIFaWlpXNvExEQcPHgQMTExiImJwZkzZ7Bw4UIAwIoVK+Dl5YWxY8fi+fPneP78OerXr4+MjAx07twZLVu2RGxsLI4cOYIXL15g4MCBcnZs3boVYrEYFy5cwNq1ays9v1mzZmHp0qWIjY2FSCTCl19+yZ07d+4chg8fjilTpuDOnTtYt24doqKiEB4eXun+iapDS/wEUcPhoweVFc18souPoRB8uj6E9igsLERkZKTGx505c6bCEnhZJCQkgGEYNGrUqMw258+fx9WrV/Hy5Utux8clS5bg4MGD2Lt3L8aNGwfgXWx6VFQUjI2NAQDDhg3DyZMnER4eDlNTU4jFYhgYGMgtxa9atQotW7ZEREQEd2zz5s2oX78+7t+/z+1+5ubmhh9++KFqFwJAeHg4fHx8AAAzZsxAjx49kJeXB6lUirlz52LGjBkYMWIEAMDZ2Rnz58/H9OnTERoaWuWxiMpBApUgajh89KDq6b1bnOGbXQRBfBiVeS/fvHkTWVlZCtv35ubmIjExkfvb0dGRE6cAYG9vj5cvX1bY919//QUjIyOFc4mJiZxAbdWqVYV2KqNZs2Zy9gDAy5cv0aBBA9y8eRMXLlyQ85gWFxcjLy8POTk5MDAw+KAxifIhgUoQNRyhUMhbIchXu3QJXV/i19V5lUZfXx8zZ87UyriVxc3NDQKBoNxEqKysLNjb2+P06dMK59gQAGXjCgSCCit+ZGVloVevXli0aJHCOVZQAlAaflAZStv0fhWSrKwszJ07F/369VN4nlQq/aDxiIohgUoQNRw9PT3efYnzVTTxzR6ibPi4MqAuBAJBpZfatYWFhQV8fX2xevVqTJ48WUEIZmRkwNPTEykpKRCJRHB0dPzgscRiscJuaJ6enti3bx8cHR0hEmlWunh6euLevXtwdXXV6Li1HUqSIogaDnlQayZshrIq+iEITbB69WoUFxfj448/xr59+/DgwQPEx8dj5cqV8PLyQteuXeHl5YU+ffrg2LFjSEpKwsWLFzFr1izExsZWehxHR0dcuXIFSUlJSE1NRUlJCSZOnIi0tDQMHjwY165dQ2JiIo4ePYpRo0apfWvfOXPmYNu2bZg7dy5u376N+Ph47N69G7Nnz1bruLUdEqgEUcPhY6F+NgaVT+i6kKMfA4S6cXZ2xvXr19GpUyd8++23+Oijj9CtWzecPHkSP//8MwQCAf744w98+umnGDVqFNzd3REQEIDHjx/D1ta20uNMnToVQqEQjRs3hrW1NZKTk1GnTh1cuHABxcXF6N69O5o2bYqgoCCYmZmp/fPG19cXMTExOHbsGNq0aYN27dph+fLlcHBwUOu4tR1a4ieIGo5QKNS2CQrQEn/F1KYlbEJ3sLe3x6pVq7Bq1Sql542NjbFy5UqsXLlS6fmwsDCEhYXJHQsKCpLbOcrd3R2XLl1SeK6bmxv2799fpm3KYl8B+fe9o6Oj3N8dO3ZUeB+2aNFC4Zivr6/SuqqE+uCfm4MgiCrBRw8qK774ZpcuQp5hgiB0ERKoBFHDoSSpysM3e1SJrs1N14U3QRDlQwKVIGo4fCzUz4pmPnlQSfAQBEHUHEigEkQNh49Z/Hwt1M8nwawqSHgTBKGLkEAliBoOnwUK3wQqn+xR9eumirnx6V7ia5gIQRCagQQqQdRw+FjSifXq8s1jyTexw7frQxAEwRf4981GEESV4KNA5WMMKt+uk6q8lXzyeqoSXZ0XQRCVg1+f2ARBVBm+1kGtzP7amoZvHlRV2sO3uREEQVQHEqgEUcPho0ClJX5CVdBrRhC1ExKoBFHD4aNA5WOhfl3duYmPyVaqgJKkCKJ2QwKVIGo4fBWofBSEfBLMqkbXsviJ8ikuLsbp06exa9cunD59GsXFxWof89WrV/i///s/NGjQABKJBHZ2dvD19cWFCxcAvLt/Dh48qJKxkpKSIBAIEBcXp5L+iJqHSNsGEARRPfgqUAGgqKhIy5b8D11NklIlfLOJb/bwhf3792PKlCn477//uGP16tXDihUr0K9fP7WN+8UXX6CgoABbt26Fs7MzXrx4gZMnT+L169cqHaegoECl/RE1E359YhMEUWX4KlDJg1oxqrBHl0Uc3+4fPrB//370799fTpwCwNOnT9G/f3/s379fLeNmZGTg3LlzWLRoETp16gQHBwd8/PHHmDlzJvz9/eHo6AgA6Nu3LwQCAfd3YmIievfuDVtbWxgZGaFNmzY4ceKEXN+Ojo6YP38+hg8fDhMTE4wbNw5OTk4AgJYtW0IgEKBjx45qmRfBX0igEkQNRygUQk9PTyNLfFWFT4KQbx5UQLXiUtfEHPsjh/gfxcXFmDJlitLXmj0WFBSkls8CIyMjGBkZ4eDBg8jPz1c4f+3aNQDAli1b8Pz5c+7vrKwsfP755zh58iRu3LgBPz8/9OrVC8nJyXLPX7JkCZo3b44bN24gJCQEV69eBQCcOHECz58/V5vwJvgL/z6xCYKoEqxA5ZMY5GuZKT7Zw1fxpWtCV5c4d+6cgue0NAzD4MmTJzh37pzKxxaJRIiKisLWrVthZmaG9u3b4/vvv8etW7cAANbW1gAAMzMz2NnZcX83b94c48ePx0cffQQ3NzfMnz8fLi4u+O233+T679y5M7799lu4uLjAxcWFe76lpSXs7OxgYWGh8jkR/IYEKkHUcPgqUAF+xaDqasgBX4WuquDba6ZNnj9/rtJ2VeWLL77As2fP8Ntvv8HPzw+nT5+Gp6cnoqKiynxOVlYWpk6dCg8PD5iZmcHIyAjx8fEKHtTWrVurxWai5kIClSBqOHwUqAB450Hlmz2qRleFnK7O60Owt7dXabsPQSqVolu3bggJCcHFixcxcuRIhIaGltl+6tSpOHDgACIiInDu3DnExcWhadOmColQhoaGarOZqJmQQCWIGo5IJIJQKORVDCrr1eOTIOSjB5UgqsInn3yCevXqlek1FwgEqF+/Pj755BON2dS4cWNkZ2cDAPT19RU+hy5cuICRI0eib9++aNq0Kezs7JCUlFRhv2KxGAB49blGaBYSqARRw2EFKt/EoEAg4NWXC988qKpamtf1JX7ifwiFQqxYsQKA4uvO/v3jjz+qpbLH69ev0blzZ+zYsQO3bt3Co0ePsGfPHvzwww/o3bs3gHfZ+CdPnkRKSgrS09MBAG5ubti/fz/i4uJw8+ZNDBkypFLvQxsbG8hkMhw5cgQvXrzAmzdvVD4ngt+QQCWIGg5fPah8rCzANw+qKu3h29wI9dCvXz/s3bsXdevWlTter1497N27V211UI2MjNC2bVssX74cn376KT766COEhIRg7NixWLVqFQBg6dKlOH78OOrXr4+WLVsCAJYtWwZzc3N4e3ujV69e8PX1haenZ4XjiUQirFy5EuvWrUOdOnU4EUzUHqhQP0HUcEQiEe/EICtQCwsLtW0KBy3x1zzoNVNOv3790Lt3b5w7dw7Pnz+Hvb09PvnkE7XWRJZIJIiMjERkZGSZbXr16oVevXrJHXN0dMSpU6fkjk2cOFHu77KW/MeMGYMxY8Z8mMFEjYcEKkHUcPT09KCvr4/c3Fxtm8LBClTK4i8bVYUcqHqJn7WLL3Vj+fSa8QmhUEjF6wmdhh+fQARBVAuxWMwrDyrw7guUTx5UPlY6UCWqEHIUz0oQBF8ggUoQOoBYLOad+OJjXCzDMLzxyJEYrBi+eb0JgtAcJFAJQgeQSCS8EoMA/zyofBOoBEEQRNmQQCUIHYCvArW4uJg3gpCNqeSLPYBql+WpIgBBELoECVSC0AH4KlBLSkp4E3rAJv/wyR6+CUG+JEaVhm/XiCAIzcC/TyOCIKqMWCzm3Re5SCRCcXExrwQhQRAEUTMggUoQOoC+vr62TVCAXeLni2dXT08PDMPwRjCzVPeHhTqW+PkCHz26BEFoBnr3E4QOwO5bzSdEIhFKSkp4I1AB8CpJio9L/Cx8sotPthAEoTlIoBKEDsBHDyrfBCrfkqQEAoFKwg5UGbqgy95Ygp+cPn0aAoEAGRkZ2jaF4BkkUAlCB2AFKp+EBStQ+bKkzscyU5R5XzG6Oq+ayMiRI7kfVqUffn5+2jaN0EFoq1OC0AH09fW5mE+RiB9va319fV7FoLIClS+CGfhfyEF1vKDq2OqUL/A5DEJbhIWFQSgUIiQkROHc/PnzUVxcjLCwMLWN7+fnhy1btsgdk0gkahuPqL2QB5UgdACxWMxlzfMFWuIvH1ULQdrqtHYgFAoxZ84czJ8/X+74/PnzMWfOHAiFQrWOL5FIYGdnJ/cwNzcH8O7+2bhxI/r27QsDAwO4ubnht99+k3v+H3/8AXd3d8hkMnTq1AlJSUlqtZeouZBAJQgdgPWgFhUVadsUDjZrni826WodVCrUX7sICQnBvHnz5EQqK07nzZun1LOqSebOnYuBAwfi1q1b+PzzzxEYGIi0tDQAwJMnT9CvXz/06tULcXFxGDNmDGbMmKFVewn+QgKVIHQAfX19iEQi3ohBAJwnhy+CkG8eVFWhy15PXZ5bdSgtUiUSiUbFaUxMDIyMjOQeERER3PmRI0di8ODBcHV1RUREBLKysnD16lUAwM8//wwXFxcsXboUDRs2RGBgIEaOHKl2m4maCT+C1QiCqBZ8FKisuOCLTXxLkiIPauXgky18IiQkBAsWLEBBQQHEYrHGPKedOnXCzz//LHfMwsKC+3+zZs24/xsaGsLExAQvX74EAMTHx6Nt27Zyz/Xy8lKjtURNhjyoBKEDiEQi3sWgssKJLzax9vDFo8uiKgGmqzGoJFCVM3/+fE6cFhQUKMSkqgtDQ0O4urrKPUoL1PdL3rGhNQRRVUigEoQOIBAIIJPJeOOtBP63pM4Xm/i4k5SuikpCvZSOOc3Pz1eISeUrHh4e3HI/y+XLl7VkDcF3aImfIHQEAwMDvHjxQttmcOjp6UFPTw+FhYXaNgUAP5f4VdmPqsQun0o7sT8qiP+hLCGK/XfOnDlyf6uD/Px8pKSkyB0TiUSwsrKq8LlfffUVli5dimnTpmHMmDH4+++/ERUVpSZLiZoOCVSC0BEMDQ15IwaBd2JHT08PBQUF2jYFgO7vkkTzqh0UFxcrTYhi/1Z3SM2RI0dgb28vd6xhw4a4e/duhc9t0KAB9u3bh+DgYPz000/4+OOPERERgS+//FJd5hI1GBKoBKEjGBgY8CbeE/ifB5UvApWFL0v8qvIOqlp4U8gAvymvCL+6E6WioqLK9Xgquwff38K0Z8+e6Nmzp9yxUaNGqcI8QsegGFSC0BHEYrG2TZCD9aDm5+dr2xQA/1u+5otAZamusFSloOTT8j5BELUbEqgEoSPwbbtBVqAWFRXxShTyTYCpSqDqYsIV3+whCEJzkEAlCB2Bbx5UdomfT9ud8imLX9VL87paB5UvrxdBEJqFBCpB6AgSiQR6enq8EYOsQC0uLuZNqSmAP4KHr4X6+eS15JMtBEFoFhKoBKEjiMViXu0mxUeByscYSz7GoPLlGvHx9SIIQjOQQCUIHUEsFkNfX583paYEAgEnmPng1WXFDl88qCx8ikFl++OLKOSTWCYIQrOQQCUIHYGPHlShUIiioiLeiGaAP0v8LHyMQeULtMRPELUXEqgEoSNIJBJeeVBZgcqXJCm2zBQfbAFU5/nU5Sx+giBqLyRQCUJH0NPTg4GBAa8EKrtczBevrkAg4I0tenqq+fhVdQwqwB9vLAlmgqi90E5SBKFDGBsb48WLF9o2A4D8vu58EM1886Cy8MmDWro/PsDHjRXUSW5ursZ2XhOLxZDJZBoZC3i3C1VQUJDCzlLqQiAQ4MCBA+jTp49GxiNUDwlUgtAhjIyMeCEGgf9l8fPNg8onwcO32qV89KDyxRZ1k5ubi0OHDiE9PV0j45mbm6N3795VEqlPnjxBaGgojhw5gtTUVNjb26NPnz6YM2cOLC0tuXaOjo4ICgpCUFCQGiyvWYSFhWH37t148uQJxGIxWrVqhfDwcLRt2xYAkJSUhPnz5+PUqVNISUlBnTp1MHToUMyaNYurbX3v3j189dVXuHPnDt68eYM6depgyJAhCA0Nhb6+PgBg//79iIiIQEJCAgoLC+Hm5oZvv/0Ww4YN09rcqwsJVILQIQwNDXkjwNglfoFAwKvtTvkkllUhwPjk8VQ1tUWcAkBBQQHS09Mhk8kglUrVOlZeXh7S09NRUFBQaYH68OFDeHl5wd3dHbt27YKTkxNu376NadOm4c8//8Tly5dhYWGhVruVUVhYyIk0PuLu7o5Vq1bB2dkZubm5WL58Obp3746EhARYW1vj7t27KCkpwbp16+Dq6op///0XY8eORXZ2NpYsWQIA0NfXx/Dhw+Hp6QkzMzPcvHkTY8eORUlJCSIiIgAAFhYWmDVrFho1agSxWIyYmBiMGjUKNjY28PX11eYl+GAoBpUgdAg+bXfKClShUMgrgcqXJX5V1Rzlm9dTldQmDyqLVCqFoaGhWh8fIoAnTpwIsViMY8eOwcfHBw0aNMBnn32GEydO4OnTp5g1axYAoGPHjnj8+DGCg4O5H2GlOXr0KDw8PGBkZAQ/Pz88f/5c7vzGjRvh4eEBqVSKRo0aYc2aNdy5pKQkCAQCREdHw8fHB1KpFDt37qyU/d999x3c3d1hYGAAZ2dnhISEyK02hYWFoUWLFti+fTscHR1hamqKgIAAvH37lmtTUlKCyMhIODk5QSaToXnz5ti7d2+54w4ZMgRdu3aFs7MzmjRpgmXLliEzMxO3bt0CAPj5+WHLli3o3r07nJ2d4e/vj6lTp2L//v1cH87Ozhg1ahSaN28OBwcH+Pv7IzAwEOfOnePadOzYEX379oWHhwdcXFwwZcoUNGvWDOfPn6/U9eEjJFAJQodQt+elKpT2oObl5WnbHN7FoCr78q4OurrET2iftLQ0HD16FBMmTFDwuNrZ2SEwMBDR0dFgGAb79+9HvXr1MG/ePDx//lxOgObk5GDJkiXYvn07zp49i+TkZEydOpU7v3PnTsyZMwfh4eGIj49HREQEQkJCsHXrVrkxZ8yYgSlTpiA+Pr7S3kFjY2NERUXhzp07WLFiBTZs2IDly5fLtUlMTMTBgwcRExODmJgYnDlzBgsXLuTOR0ZGYtu2bVi7di1u376N4OBgDB06FGfOnKmUDQUFBVi/fj1MTU3RvHnzMtu9efOmXG90QkICjhw5Ah8fH6XnGYbByZMnce/ePXz66aeVso2P0BI/QegQEokEQqEQxcXFEAqFWrWFjUEVCAQoKCgAwzBaFRysIOTLEj+gmkL0ul5mii9iuTbz4MEDMAwDDw8Ppec9PDyQnp6OV69ewcbGBkKhEMbGxrCzs5NrV1hYiLVr18LFxQUAMGnSJMybN487HxoaiqVLl6Jfv34AACcnJ9y5cwfr1q3DiBEjuHZBQUFcm8oye/Zs7v+Ojo6YOnUqdu/ejenTp3PHS0pKEBUVBWNjYwDAsGHDcPLkSYSHhyM/Px8RERE4ceIEvLy8ALzzbJ4/fx7r1q0rUywCQExMDAICApCTkwN7e3scP34cVlZWStsmJCTgp59+4pb3S+Pt7Y3r168jPz8f48aNk7t2wDthW7duXeTn50MoFGLNmjXo1q1b5S8SzyCBShA6hFQqhb6+fpViy9RFaQ9qUVERioqKtB4rxm69yhdUKVBVAd8EKt/sqe1U9141MDDgxCkA2Nvb4+XLlwCA7OxsJCYmYvTo0Rg7dizXpqioCKampnL9tG7duspjR0dHY+XKlUhMTERWVhaKiopgYmIi18bR0ZETp+/bl5CQgJycHAXBV1BQgJYtW5Y7dqdOnRAXF4fU1FRs2LABAwcOxJUrV2BjYyPX7unTp/Dz88OAAQPkrkHpObx9+xY3b97EtGnTsGTJEjmBbWxsjLi4OGRlZeHkyZP45ptv4OzsjI4dO1bqGvENEqgEoUOwArWwsFDrAhUARCIRJwr5IFDZmEY+eJhZ+LaTFN/iPvlkS23F1dUVAoEA8fHx6Nu3r8L5+Ph4mJubw9rautx+3n//l77XsrKyAAAbNmzgMtxZ3n+vGhoaVsn+S5cuITAwEHPnzoWvry9MTU2xe/duLF26tEL72KRT1r7Dhw+jbt26cu0qiv03NDSEq6srXF1d0a5dO7i5uWHTpk2YOXMm1+bZs2fo1KkTvL29sX79eqX91K9fHwDQuHFjFBcXY9y4cfj222+566OnpwdXV1cAQIsWLRAfH4/IyEgSqARBaB+JRAKxWMybUlNCoZCL++SDaGa/cEpKSrQuUNkSXNUVYGzBf76VrCJ0B0tLS3Tr1g1r1qxBcHCw3Ps4JSUFO3fuxPDhwzmPt1gsrvJKha2tLerUqYOHDx8iMDBQpfZfvHgRDg4OXCIXADx+/LhKfTRu3BgSiQTJycnlLudXhpKSErnE0adPn6JTp05o1aoVtmzZUqlNPEpKSlBYWFjuZ9n749Q0SKAShA4hEokgkUjkMk+1CetBLSoq0rpoLl3WiQ+luFS9fK2LolJPT48Xr5Um0URC4YeMsWrVKnh7e8PX1xcLFiyQKzNVt25dhIeHc20dHR1x9uxZBAQEQCKRlBlv+T5z587F5MmTYWpqCj8/P+Tn5yM2Nhbp6en45ptvqmwzi5ubG5KTk7F79260adMGhw8fxoEDB6rUh7GxMaZOnYrg4GCUlJSgQ4cOePPmDS5cuAATExO5GFmW7OxshIeHw9/fH/b29khNTcXq1avx9OlTDBgwAMA7cdqxY0c4ODhgyZIlePXqFfd8NoZ3586d0NfXR9OmTSGRSBAbG4uZM2di0KBBnNc3MjISrVu3houLC/Lz8/HHH39g+/bt+Pnnnz/0smkdEqgEoWOYmJggLS1N22YA+J8HtaSkROvJSaxAZT2o2kZVyU2qTJJShze2uvDJFnUiFothbm6O9PR05Obmqn08c3NzrhB8ZXBzc0NsbCxCQ0MxcOBApKWlwc7ODn369EFoaKhc1vm8efMwfvx4TixV9jUcM2YMDAwMsHjxYkybNg2GhoZo2rRptQv++/v7Izg4GJMmTUJ+fj569OiBkJAQhIWFVamf+fPnw9raGpGRkXj48CHMzMzg6emJ77//Xml7oVCIu3fvYuvWrUhNTYWlpSXatGmDc+fOoUmTJgCA48ePIyEhAQkJCahXr57c89nrJhKJsGjRIty/fx8Mw8DBwQGTJk1CcHAw1zY7OxsTJkzAf//9B5lMhkaNGmHHjh0YNGhQlebIJwRMDX/3Z2ZmwtTUFG/evFEIeCaI2siZM2dw48YNuWQEbREXF4cnT56gqKgIbdq0UfgA1iTPnj3D+fPnYWRkBB8fnyrHsamajIwM3L17Fz179qxWgfO0tDQcO3YMBgYG8Pf3r5ZNaWlpOHToEDw8PLRSdP19jhw5ApFIhK5du2rbFDkePHgAb29vtGvXrsrPzcvLw6NHj+Dk5KRQFk6XtzoldJPy7ufq6jPyoBKEjmFkZMSbTHWhUMh5AbS9xA+Al0v8qrJFF8tMsXG6tQWZTEaikSD+P1SonyB0DD4V6xeJRJwA44tApSX+ivvjkyjkky0EQWgOEqgEoWPwabtTkejdIo1QKNRIXF15lE6S4ouHGVCdQFUF5EElCIIvkEAlCB1DKpXyZktPNulGJBLxQqCyWeF88KAC/NuelG9bnQL8soUgCM1BApUgdAy2Fqq2s+aB/3nAhEIhb+rx8SUGVVV1UFl0MQaVb/YQBKE5SKAShI5RertTbcMWkBaJRCgoKOCFaAbAC+8yX2NQ+URlCpYTBKGb0LufIHSM0tudahs9PT0IBAKIRCJuNyk+wAcPKgufYlD5RumtJgmCqF2QQCUIHUNfXx9SqZQXHlTWAyYUClFUVKRVm0oLOT6IHlV7PlW1xM8nUci3igIEQWgOEqgEoYMYGhryYjmdjbMUiURa3+60tEClJf6y4Zsg5Js9BEFoBirUTxA6iImJCZKSkrRtBrfVKesJIw/q/1DVtqKqXOJnQzL4Qm3zoOryTlJRUVEICgpCRkaGRsYTCAQ4cOAA+vTpo5HxCNVDApUgdBAjIyPeeFBLiwxtx6DysQ6qqlClkOOTKOSTYFYnubm5OHToENLT0zUynrm5OXr37l0lkfrkyROEhobiyJEjSE1Nhb29Pfr06YM5c+bA0tKSa+fo6IigoCAEBQWpwfKaRVhYGHbv3o0nT55ALBajVatWCA8PR9u2bRXa5ufno23btrh58yZu3LiBFi1aAADu3buHr776Cnfu3MGbN29Qp04dDBkyBKGhodDX1+ee/+OPP+Lnn39GcnIyrKys0L9/f0RGRvJq85aqQAKVIHQQvnwg6enpcbVHAfAiLhYAL8Q7S3XFoKo8sQD/KgKUvnd0nYKCAqSnp0Mmk6n9/ZuXl4f09HQUFBRUWqA+fPgQXl5ecHd3x65du+Dk5ITbt29j2rRp+PPPP3H58mVYWFio1W5lFBYWyok0vuHu7o5Vq1bB2dkZubm5WL58Obp3746EhARYW1vLtZ0+fTrq1KmDmzdvyh3X19fH8OHD4enpCTMzM9y8eRNjx45FSUkJIiIiAAC//PILZsyYgc2bN8Pb2xv379/HyJEjIRAIsGzZMo3NV5VQDCpB6CB8EahCoZATGXzYTQp4J3r4IFD5HDvKF4EK8MuW0qhLOEulUhgaGqr18SGfDxMnToRYLMaxY8fg4+ODBg0a4LPPPsOJEyfw9OlTzJo1CwDQsWNHPH78GMHBwVx4T2mOHj0KDw8PGBkZwc/PD8+fP5c7v3HjRnh4eEAqlaJRo0ZYs2YNdy4pKQkCgQDR0dHw8fGBVCrFzp07K2X/d999B3d3dxgYGMDZ2RkhISFyKzphYWFo0aIFtm/fDkdHR5iamiIgIABv377l2pSUlCAyMhJOTk6QyWRo3rw59u7dW+64Q4YMQdeuXeHs7IwmTZpg2bJlyMzMxK1bt+Ta/fnnnzh27BiWLFmi0IezszNGjRqF5s2bw8HBAf7+/ggMDMS5c+e4NhcvXkT79u0xZMgQODo6onv37hg8eDCuXr1aqevDR0igEoQOIpFIeJGNzcaglpSUQCQSIScnR2u2lP6y1CWBWloAqKIvPi2p88mW0tS22Ni0tDQcPXoUEyZMUPC42tnZITAwENHR0WAYBvv370e9evUwb948PH/+XE6A5uTkYMmSJdi+fTvOnj2L5ORkTJ06lTu/c+dOzJkzB+Hh4YiPj0dERARCQkKwdetWuTFnzJiBKVOmID4+Hr6+vpWag7GxMaKionDnzh2sWLECGzZswPLly+XaJCYm4uDBg4iJiUFMTAzOnDmDhQsXcucjIyOxbds2rF27Frdv30ZwcDCGDh2KM2fOVMqGgoICrF+/HqampmjevDl3/MWLFxg7diy2b98OAwODCvtJSEjAkSNH4OPjwx3z9vbG33//zQnShw8f4o8//sDnn39eKdv4CC3xE4QOUroWqkQi0ZodpZf42e1OGYbRivBgxxQKhVqPhS1NdX9EqPJa8k0Q8lUI8tUudfHgwQMwDAMPDw+l5z08PJCeno5Xr17BxsYGQqEQxsbGsLOzk2tXWFiItWvXwsXFBQAwadIkzJs3jzsfGhqKpUuXol+/fgAAJycn3LlzB+vWrcOIESO4dkFBQVybyjJ79mzu/46Ojpg6dSp2796N6dOnc8dLSkoQFRUFY2NjAMCwYcNw8uRJhIeHIz8/HxEREThx4gS8vLwAvPNsnj9/HuvWrZMTi+8TExODgIAA5OTkwN7eHsePH4eVlRWAdz8qR44cia+++gqtW7cuN7nV29sb169fR35+PsaNGyd37YYMGYLU1FR06NABDMOgqKgIX331Fb7//vsqXSc+QR5UgtBB+FKsn13iL11qStveSz09Pa1fF3Wha6KJz0JQ26sT2qC6r4WBgQEnTgHA3t4eL1++BABkZ2cjMTERo0ePhpGREfdYsGABEhMT5fpp3bp1lceOjo5G+/btYWdnByMjI8yePRvJyclybRwdHTlx+r59CQkJyMnJQbdu3eTs27Ztm4J979OpUyfExcXh4sWL8PPzw8CBA7l+f/rpJ7x9+xYzZ86s1ByuX7+OX375BYcPH5YLBzh9+jQiIiKwZs0aXL9+Hfv378fhw4cxf/78Sl8jvkEeVILQQSQSCcRisdaFWGkPqr6+PvLy8pCfn6+VpIbS3kFti2SAv0v8quhHVfDNo1savlwjTeDq6gqBQID4+Hj07dtX4Xx8fDzMzc0Vkn7e5/33fekfIFlZWQCADRs2KGS4s1smsxgaGlbJ/kuXLiEwMBBz586Fr68vTE1NsXv3bixdurRC+9gfIqx9hw8fRt26deXaVbRKZWhoCFdXV7i6uqJdu3Zwc3PDpk2bMHPmTJw6dQqXLl1S6KN169YIDAyUC2+oX78+AKBx48YoLi7GuHHj8O2330IoFCIkJATDhg3DmDFjAABNmzZFdnY2xo0bh1mzZtXIbYNJoBKEDiIWi6Gvr4/8/Hyt2sF6UIuLi3mx3alAIODs0VaoQWlb+NQP2xcfYpdLw0chyGfPrjqwtLREt27dsGbNGgQHB8vFoaakpGDnzp0YPnw4dy+KxeIql3KztbVFnTp18PDhQwQGBqrU/osXL8LBwYFL5AKAx48fV6mPxo0bQyKRIDk5udzl/MpQUlLCfTavXLkSCxYs4M49e/YMvr6+iI6OVlqKqnQfhYWFXAJqTk6OgghlhX1NvVdJoBKEDiIQCGBsbMz96temHWzMp7a3O2XFF+vRZUWzNmEYRqVisKZ+EZUFX4WgOu3Ky8tTS7/VHWPVqlXw9vaGr68vFixYIFdmqm7duggPD+faOjo64uzZswgICIBEIuHiLSti7ty5mDx5MkxNTeHn54f8/HzExsYiPT0d33zzTZVtZnFzc0NycjJ2796NNm3a4PDhwzhw4ECV+jA2NsbUqVMRHByMkpISdOjQAW/evMGFCxdgYmIiFyPLkp2djfDwcPj7+8Pe3h6pqalYvXo1nj59igEDBgAAGjRoIPccIyMjAICLiwvq1asH4F3ymL6+Ppo2bQqJRILY2FjMnDkTgwYN4ry+vXr1wrJly9CyZUu0bdsWCQkJCAkJQa9evRQ80DUFEqgEoaMYGxsrxFhpA319feTm5nLeFW3XQmU9hNoWqHxc4ufbMiDf7CmNqr3MYrEY5ubmSE9P10g5NnNzc4jF4kq3d3NzQ2xsLEJDQzFw4ECkpaXBzs4Offr0QWhoqFwN1Hnz5mH8+PFwcXFBfn5+pe/LMWPGwMDAAIsXL8a0adNgaGiIpk2bVrvgv7+/P4KDgzFp0iTk5+ejR48eCAkJQVhYWJX6mT9/PqytrREZGYmHDx/CzMwMnp6eZSYiCYVC3L17F1u3bkVqaiosLS3Rpk0bnDt3Dk2aNKn0uCKRCIsWLcL9+/fBMAwcHBwwadIkBAcHc21mz54NgUCA2bNn4+nTp7C2tkavXr3kfjjUNAQMH3+eVoHMzEyYmprizZs3MDEx0bY5BMEbrl69inPnzsHd3V3rdqSmpsLKygpPnz5Fs2bN5BIlNEVGRgbOnTsHAwMDFBYWwsfHR6NbPb5PSUkJLl26hA4dOsDNze2D+ykuLsaePXsAAP369auS6FDW1y+//IK6dety3httcu3aNSQlJXHeJr7w6NEjNG7cGF27dq3yc/Py8vDo0SM4OTkp1CPV5a1OCd2kvPu5uvqMPKgEoaNUpp6eJhCJRJy3SU9PT+vF+ksv8WsT1vOpyjJTqvA3qDrsoDrw1YOqriV+mUxGopEg/j/8fPcTBFFt+LKblL6+Pid49PX1tRYXy8agll7i1ybqWOKvrrDU09PjdeY8X+BrbCxB6BIkUAlCR5FKpVzGujYpLVBLF+vXNKzwEggEKC4u1vp1YW1RRR+qFLuUxV8xfLtGBKGLkEAlCB1FJpPxohaqSCTiRAa7eYC2M/kZhuGFQAVUk2zDt/qlqoLd5IGPkEAlCPWiNoEaHh4Ob29vGBgYwMzMTGmb5ORk9OjRAwYGBrCxscG0adN4UUCbIHQBqVQKsVis9ax5oVDICSh2Nylt2sSKHl0SGKoUqHxavuaTLQRBaBa1JUkVFBRgwIAB8PLywqZNmxTOFxcXo0ePHrCzs8PFixfx/PlzDB8+HPr6+oiIiFCXWQRRa2C3O+WDQOWDB7W09xQAbzyoqhKVqu6LD/BVoPLVLoLQJdTmQZ07dy6Cg4PRtGlTpeePHTuGO3fuYMeOHWjRogU+++wzzJ8/H6tXr9b6FypB6AJCoRCGhoZafz+VLhLNZtBrY4er94UXXwQq35b4+SRQ+QrFoBKE+tFaDOqlS5fQtGlT2Nracsd8fX2RmZmJ27dvl/m8/Px8ZGZmyj0IglCOqakprwQqi7a3YAXAi3AiVcVYsuWYVOXV44t3kGJQCaL2ojWBmpKSIidOAXB/p6SklPm8yMhImJqaco/69eur1U6CqMkYGxtrPUmKjUFlhYZAINDIdo7v876XUZcEhq7HoPLFHoIgNEeVBOqMGTPkagkqe9y9e1ddtgIAZs6ciTdv3nCPJ0+eqHU8gqjJGBgYaP3Lna2tqe1aqKXLMQkEAq0LdxZVLvGrsi8+oe17+H3UJeJzc3Plvt/U+dD0hhlRUVFlJkyrA4FAgIMHD2psPEL1VClJ6ttvv8XIkSPLbePs7Fypvuzs7HD16lW5Yy9evODOlYVEIoFEIqnUGARR2+HDblIikYiLPRUKhRCJRMjJyQHDMBoXQ6ywEAgEvFjiV5XQUaUHlX2t+AAfxTKgnhjU3NxcHDp0COnp6SrttyzMzc3Ru3fvKu1c9eTJE4SGhuLIkSNITU2Fvb09+vTpgzlz5sDS0pJr5+joiKCgIAQFBanB8ppFWFgYdu/ejSdPnkAsFqNVq1YIDw9H27ZtuTaOjo54/Pix3PMiIyMxY8YMhf4SEhLQsmVLCIVCZGRkyJ3bs2cPQkJCkJSUBDc3NyxatAiff/65WualCaokUK2trWFtba2Sgb28vBAeHo6XL1/CxsYGAHD8+HGYmJigcePGKhmDIGo7BgYGEAqFKCoqgkiknZ2NhUIhhEKhnAc1Pz8fhYWF1do3vro2aTs2F+CnQFVlP9WFz/VdVS1QCwoKkJ6eDplMpvZd4PLy8pCeno6CgoJKC9SHDx/Cy8sL7u7u2LVrF5ycnHD79m1MmzYNf/75Jy5fvgwLCwu12q2MwsJC6Ovra3zcyuLu7o5Vq1bB2dkZubm5WL58Obp3746EhAQ5PTVv3jyMHTuW+9vY2Fihr8LCQgwePBiffPIJLl68KHfu4sWLGDx4MCIjI9GzZ0/88ssv6NOnD65fv46PPvpIfRNUI2qLQU1OTkZcXBySk5NRXFyMuLg4xMXFcUt73bt3R+PGjTFs2DDcvHkTR48exezZszFx4kTykBKEijA0NIREItFqUpKenp6cV05fXx9FRUUat6m0N05PT48XAhWgMlPlwSdvrqaQSqUwNDRU6+NDBPDEiRMhFotx7Ngx+Pj4oEGDBvjss89w4sQJPH36FLNmzQIAdOzYEY8fP0ZwcLBcWA3L0aNH4eHhASMjI/j5+eH58+dy5zdu3AgPDw9IpVI0atQIa9as4c4lJSVBIBAgOjoaPj4+kEql2LlzZ6Xs/+677+Du7g4DAwM4OzsjJCRELswnLCwMLVq0wPbt2+Ho6AhTU1MEBATg7du3XJuSkhJERkbCyckJMpkMzZs3x969e8sdd8iQIejatSucnZ3RpEkTLFu2DJmZmbh165ZcO2NjY9jZ2XEPQ0NDhb5mz56NRo0aYeDAgQrnVqxYAT8/P0ybNg0eHh6YP38+PD09sWrVqkpdHz6iNoE6Z84ctGzZEqGhocjKykLLli3RsmVLxMbGAnjnwYiJiYFQKISXlxeGDh2K4cOHY968eeoyiSBqHQYGBloXqKWX+IH/1ULVhkBlPZZ6enq0xF9BX3yArx5UPiWSaYK0tDQcPXoUEyZMUPC42tnZITAwENHR0WAYBvv370e9evUwb948PH/+XE6A5uTkYMmSJdi+fTvOnj2L5ORkTJ06lTu/c+dOzJkzB+Hh4YiPj0dERARCQkKwdetWuTFnzJiBKVOmID4+Hr6+vpWag7GxMaKionDnzh2sWLECGzZswPLly+XaJCYm4uDBg4iJiUFMTAzOnDmDhQsXcucjIyOxbds2rF27Frdv30ZwcDCGDh2KM2fOVMqGgoICrF+/HqampmjevLncuYULF8LS0hItW7bE4sWLFT6fTp06hT179mD16tVK+7506RK6du0qd8zX1xeXLl2qlG18RG1rflFRUYiKiiq3jYODA/744w91mUAQtR6xWAxjY2OkpaVpzQZ2iZ+tO6qtWqilhZdQKERhYaFW4mDfh29lpvgkvvgsUGuTZ/fBgwdgGAYeHh5Kz3t4eCA9PR2vXr2CjY0NhEIh5xEsTWFhIdauXQsXFxcAwKRJk+ScUqGhoVi6dCn69esHAHBycsKdO3ewbt06jBgxgmsXFBTEtakss2fP5v7v6OiIqVOnYvfu3Zg+fTp3vKSkBFFRUdzy+rBhw3Dy5EmEh4cjPz8fEREROHHiBLy8vAC8y7k5f/481q1bBx8fnzLHjomJQUBAAHJycmBvb4/jx4/DysqKOz958mR4enrCwsICFy9exMyZM/H8+XMsW7YMAPD69WuMHDkSO3bsgImJidIxyqqMVF5VJL6jnaA0giA0hrm5ucIymiYRCoXQ09NTyJrXlleX9aAWFxdziVs1HV0uM8VXapNAZanufWFgYMCJUwCwt7fHy5cvAQDZ2dlITEzE6NGj5WIxi4qKYGpqKtdP69atqzx2dHQ0Vq5cicTERGRlZaGoqEhB7Dk6OsrFfpa2LyEhATk5OejWrZvccwoKCtCyZctyx+7UqRPi4uKQmpqKDRs2YODAgbhy5QqXf/PNN99wbZs1awaxWIzx48cjMjISEokEY8eOxZAhQ/Dpp59Wed41GRKoBKHjmJmZab2kEpsYxaKnp4ecnByN2lA6Ho4VzEVFRTolUFVVZopvApUv9tRWXF1dIRAIEB8fj759+yqcj4+Ph7m5eYVJ1O8nM5W+19j8lA0bNshluAOKm30oi88sj0uXLiEwMBBz586Fr68vTE1NsXv3bixdurRC+9j3FGvf4cOHUbduXbl2FeXNGBoawtXVFa6urmjXrh3c3NywadMmzJw5U2n7tm3boqioCElJSWjYsCFOnTqF3377DUuWLAHw7v1QUlICkUiE9evX48svv4SdnR1XCYnlxYsX5VZF4jskUAlCx6nqh7k60NfXlxNP2qiFWlrssFUFtL3dqaqWinU9BpVv3ko+iXhNYGlpiW7dumHNmjUIDg6Wi0NNSUnBzp07MXz4cO71EovFVX5v2draok6dOnj48CECAwNVav/Fixfh4ODAJXIBUCjrVBGNGzeGRCJBcnJyucv5laGiEKe4uDjo6elxHtZLly7JXc9Dhw5h0aJFuHjxIieWvby8cPLkSbnSXsePH+fCEWoiJFAJQscxNDTkhBAbq6hplAnU7OxsFBcXa9SDyX6BsjGx2haoAP+y+PmSQAbw14OqzhhUTeyy9iFjrFq1Ct7e3vD19cWCBQvkykzVrVsX4eHhXFtHR0ecPXsWAQEBkEgkcvGW5TF37lxMnjwZpqam8PPzQ35+PmJjY5Geni63DF5V3NzckJycjN27d6NNmzY4fPgwDhw4UKU+jI2NMXXqVAQHB6OkpAQdOnTAmzdvcOHCBZiYmMjFyLJkZ2cjPDwc/v7+sLe3R2pqKlavXo2nT59iwIABAN6JzytXrqBTp04wNjbGpUuXuOQrc3NzAFCI/Y2NjYWenp5c+agpU6bAx8cHS5cuRY8ePbB7927ExsZi/fr1Vb1cvIEEKkHoOGwmf0FBgdrrK5aFRCKRE4NisRi5ublVqsNYXUqLHTZRiwSqInwSg3wVqIDqbRKLxTA3N0d6erpGdnkyNzevUh1iNzc3xMbGIjQ0FAMHDkRaWhrs7OzQp08fhIaGytVAnTdvHsaPHw8XFxfk5+dX+lqNGTMGBgYGWLx4MaZNmwZDQ0M0bdq02gX//f39ERwcjEmTJiE/Px89evRASEgIwsLCqtTP/PnzYW1tjcjISDx8+BBmZmbw9PTE999/r7S9UCjE3bt3sXXrVqSmpsLS0hJt2rTBuXPn0KRJEwDvPht3796NsLAw5Ofnw8nJCcHBwVUW5N7e3vjll18we/ZsfP/993Bzc8PBgwdrbA1UABAwfHznV4HMzEyYmprizZs3ZWa3EURtJicnB7/88gskEolGtxoszf3793H79m1uOaqoqAivX79Ghw4dOC+BuikqKsLp06chEAhgaGiIlJQUdOjQodLeHXUQGxsLJyenai/DnTx5Eq9evYK3tzcaNGhQrb5iYmJQUFDAiy+2x48f4+zZs+jXrx8vQlVYXr16BZFIhOHDh1f5uXl5eXj06BGcnJwUfjCyP9o0gVgs1tiPQ0J3Ke9+rq4+Iw8qQeg4MpkMMplM43tvl+b9ZXyRSKTxYv3v10FlGEbrHlSqg1o5+OhHYRhG5WXK2PcqQRBqLNRPEAQ/EAgEMDMz03qxfmVf5JoUzcrG13asparEjapjUPkiCFVZ31WV8Dn0gCB0BRKoBFELsLCw0KpAFQqFCl/mAoFAK17d0nZo24MKqCZDnRVyulpmim9Z/ACJU4JQNyRQCaIWYGRkpNUvVJFIMZpILBbL7XOtbpTtC65tD6qqUHWSFF/EF189lXy1iyB0CRKoBFEL0HaCiVAoVPDMsaWmNOUdU7aczgcPKt9iUGmJv3LwScgThC5CApUgagEGBgbQ19fXWIbw+4hEIq60Ewtrj6YTpVhRoaenp9WwB9YePhbq55Pw4qMQZK8R3+wiCF2CBCpB1AIMDQ0hkUi0JsiEQiFXHJ9FLBajsLBQI4XJWUp7B9ntTrUJH7P4tbWZgzL09PTUWhT/Q6ElfoJQP/z5JCIIQm2wxfq1JVCVeVDZUlOaFKill/mFQiEvPKiqCDNQtQeVL4KQ70KQr3YRhC5AdVAJohYgFAphamqKlJQUrY2vp6enVIxpWqCyokIoFGrdg6oq+C7kPhS+zktdS/xUqJ8g/gcJVIKoJVhYWODx48daGVskEkEoFCp45vT09JCdna0xO0p7UPX09FBQUICSkhKtLWurKiFJl5f4+RrrqQ5xeujQIaSnp6u037IwNzdH7969NSZSo6KiEBQUhIyMDI2MJxAIcODAAfTp00cj4xGqhz+fRARBqBVjY2OtZa0ri0EF3iVKZWZmasyO95f4S0pKtJrJX5ZX+UP6AXSzDiqfQg5Y1OHZLSgoQHp6OmQyGczNzdX6kMlkSE9Pr7K39smTJ/jyyy9Rp04diMViODg4YMqUKXj9+rVcO0dHR/z4448quzY1mbCwMDRq1AiGhoYwNzdH165dceXKFbk29+/fR+/evWFlZQUTExN06NABf/31l0JfUVFRaNasGaRSKWxsbDBx4kTuXFJSEvd+Kf24fPmy2ueoLsiDShC1BG0v54nFYoWYT4lEgpycHBQVFSmtlapq3s/iLygoQFFREfT19dU+dln28DGLny+CsLYt8QOAVCrVSFm4qm6S8fDhQ3h5ecHd3R27du2Ck5MTbt++jWnTpuHPP//E5cuXYWFhoSZry6awsFBr79/K4O7ujlWrVsHZ2Rm5ublYvnw5unfvjoSEBFhbWwMAevbsCTc3N5w6dQoymQw//vgjevbsicTERNjZ2QEAli1bhqVLl2Lx4sVo27YtsrOzkZSUpDDeiRMn0KRJE+5vS0tLjcxTHZAHlSBqCaxA1daXvVgsVupBLSgo0FgcKt88qKpClUJO2a5f2oLvS/x8tEtdTJw4EWKxGMeOHYOPjw8aNGiAzz77DCdOnMDTp08xa9YsAEDHjh3x+PFjBAcHK90c4+jRo/Dw8ICRkRH8/Pzw/PlzufMbN26Eh4cHpFIpGjVqhDVr1nDnWC9hdHQ0fHx8IJVKsXPnzkrZ/91338Hd3R0GBgZwdnZGSEiIXAx6WFgYWrRoge3bt8PR0RGmpqYICAiQ20ykpKQEkZGRcHJygkwmQ/PmzbF3795yxx0yZAi6du0KZ2dnNGnSBMuWLUNmZiZu3boFAEhNTcWDBw8wY8YMNGvWDG5ubli4cCFycnLw77//AgDS09Mxe/ZsbNu2DUOGDIGLiwuaNWsGf39/hfEsLS1hZ2fHPfgs3iuCBCpB1BIMDAwgFou1VgtVLBYreOZYezS15WnpmE825ECbu0mpaomfr57G6sLnJf7aJFDT0tJw9OhRTJgwQWElxs7ODoGBgYiOjgbDMNi/fz/q1auHefPm4fnz53ICNCcnB0uWLMH27dtx9uxZJCcnY+rUqdz5nTt3Ys6cOQgPD0d8fDwiIiIQEhKCrVu3yo05Y8YMTJkyBfHx8fD19a3UHIyNjREVFYU7d+5gxYoV2LBhA5YvXy7XJjExEQcPHkRMTAxiYmJw5swZLFy4kDsfGRmJbdu2Ye3atbh9+zaCg4MxdOhQnDlzplI2FBQUYP369TA1NUXz5s0BvBOUDRs2xLZt25CdnY2ioiKsW7cONjY2aNWqFQDg+PHjKCkpwdOnT+Hh4YF69eph4MCBePLkicIY/v7+sLGxQYcOHfDbb79Vyi6+Qkv8BFFLkMlknCCUSCQaH1+ZB5UVjJrM5C89trYFKgvDMEp3uqosql7i5wt83UlKV38QlMWDBw/AMAw8PDyUnvfw8EB6ejpevXoFGxsbCIVCGBsbc8vTLIWFhVi7di1cXFwAAJMmTcK8efO486GhoVi6dCn69esHAHBycsKdO3ewbt06jBgxgmsXFBTEtakss2fP5v7v6OiIqVOnYvfu3Zg+fTp3vKSkBFFRUTA2NgYADBs2DCdPnkR4eDjy8/MRERGBEydOwMvLCwDg7OyM8+fPY926dfDx8Slz7JiYGAQEBCAnJwf29vY4fvw4rKysALy7l06cOIE+ffrA2NgYenp6sLGxwZEjR2Bubg7gXXhFSUkJIiIisGLFCpiammL27Nno1q0bbt26BbFYDCMjIyxduhTt27eHnp4e9u3bhz59+uDgwYNKPa01ARKoBFFLkMlkkEgkWvWgloU2PKjs/7WdJMW3LH6+JUnx1VPJV7vUSXXna2BgwIlTALC3t8fLly8BANnZ2UhMTMTo0aMxduxYrk1RURFMTU3l+mndunWVx46OjsbKlSuRmJiIrKwsFBUVwcTERK6No6MjJ07fty8hIQE5OTno1q2b3HMKCgrQsmXLcsfu1KkT4uLikJqaig0bNmDgwIG4cuUKbGxswDAMJk6cCBsbG5w7dw4ymQwbN25Er169cO3aNdjb26OkpASFhYVYuXIlunfvDgDYtWsX7Ozs8Ndff8HX1xdWVlb45ptvuDHbtGmDZ8+eYfHixSRQCYLgNyKRCIaGhkhLS9PK+GXFNmoyk1+Zd1CbtVDZQv188qDyqcwUOy9a4tcurq6uEAgEiI+PR9++fRXOx8fHw9zcnEv6KYv34yFL/xjKysoCAGzYsAFt27aVaycUCuX+rmoS2aVLlxAYGIi5c+fC19cXpqam2L17N5YuXVqhfey9x9p3+PBh1K1bV65dRStShoaGcHV1haurK9q1awc3Nzds2rQJM2fOxKlTpxATE4P09HROMK9ZswbHjx/H1q1bMWPGDNjb2wMAGjduzPVpbW0NKysrJCcnlzlu27Ztcfz48XJt4zMkUAmiFmFubq6QlKApysrSF4vFePv2rUbqkSrzWGo7BlWVolJVgokvwovd6pQv9rDUtiV+S0tLdOvWDWvWrEFwcLBcHGpKSgp27tyJ4cOHc9dFWThPRdja2qJOnTp4+PAhAgMDVWr/xYsX4eDgwCVyAahyTejGjRtDIpEgOTm53OX8ylBSUsJVNMnJyQGg+MOw9M577du3BwDcu3cP9erVA/AuLjg1NRUODg5ljhMXF8eJ25oICVSCqEWYmJhozWNYnkDNy8tDfn6+2kthKRPA2s7i52OZKb7AdyGoDrs0EY/9IWOsWrUK3t7e8PX1xYIFC+TKTNWtWxfh4eFcW0dHR5w9exYBAQGQSCRcvGVFzJ07F5MnT4apqSn8/PyQn5+P2NhYpKenyy1fVxU3NzckJydj9+7daNOmDQ4fPowDBw5UqQ9jY2NMnToVwcHBKCkpQYcOHfDmzRtcuHABJiYmcjGyLNnZ2QgPD4e/vz/s7e2RmpqK1atX4+nTpxgwYAAAwMvLC+bm5hgxYgTmzJkDmUyGDRs24NGjR+jRoweAd6WqevfujSlTpmD9+vUwMTHBzJkz0ahRI3Tq1AkAsHXrVojFYi7cYP/+/di8eTM2btz4wddN25BAJYhahCbqK5aFSCTivGGlRZBYLEZmZiZyc3M1Uqu1tKgQCARai8llx1fFUrEuL/EzDMO7JX5A9TGoYrEY5ubmSE9P10hMtrm5eblx4e/j5uaG2NhYhIaGYuDAgUhLS4OdnR369OmD0NBQuRqo8+bNw/jx4+Hi4oL8/PxKX6cxY8bAwMAAixcvxrRp02BoaIimTZsiKCioqtOTw9/fH8HBwZg0aRLy8/PRo0cPhISEICwsrEr9zJ8/H9bW1oiMjMTDhw9hZmYGT09PfP/990rbC4VC3L17F1u3bkVqaiosLS3Rpk0bnDt3jqtVamVlhSNHjmDWrFno3LkzCgsL0aRJExw6dIjL9AeAbdu2ITg4GD169ICenh58fHxw5MgRubCE+fPn4/HjxxCJRGjUqBGio6PRv3//ql8wniBg+PrTtJJkZmbC1NQUb968UQh4JghCnocPH+LAgQNwd3fX+Njp6ek4f/48LC0tFbypT58+xccff6wQ26Vqbt26hcePH8PW1hYA8OLFC9SvXx8tWrRQ67hl8ejRI7x9+xZ9+/ZViLOrCrdv38Y///wDZ2dnfPzxx9Wy6dq1a7h9+za8vb2r1Y8qKCwsxO7du9G2bVut3LNlkZubi1evXmHw4MFVLk6fl5eHR48ewcnJCVKpVKFfTf1gEovFWt+8g6j5lHc/V1efkQeVIGoRMpkMIpFIK7uv6Ovrc7VHlS33a2Jp8/14RqFQqLC7lSZh48z45EHl0xI/C9/8KOoKPZDJZCQaCeL/w5+1HIIg1E7pWqiaRigUllmYXiQSaSST//2kJHa7U22hqgQgVS/x80UQsoX6+WIPS23L4icIbUAClSBqEQYGBpBIJFrxGopEIs6D+j5isRhv3rxR+xf++95BoVCIgoICrcU48lGg8smDytcyU0DtrINKEJqEBCpB1CLYuDNteA1ZgapMbEgkEuTl5al9mV/ZEn9xcbHWMvlV5Ynj645LqoA8qARROyGBShC1DGtra43t3FQagUBQZn1ENuxA3Xa9n6HOCmZt1UJVledTl5f4AX56UAHd/EFAEHyBBCpB1DKsrKy0VgtVIpGUGYNaXFysEYGqzIOqrevB2qMqgarKmqp8gM8xqAAJVIJQJyRQCaKWYWJiorXlybIEKgu7q4qmYAWqtj2o1Q0x0FXBxApUPqJr15og+AYJVIKoZZiYmEAmk2mkrNP7lCdQ9fX18ebNG7WO/77YYcs8aUugqsqDyoYuqMqDyifxxcdC/RSDShDqh+qgEkQtgxWoOTk5Gq+5WF7tVYlEgrdv36KkpERtuxm975FjhYa2BCoLn5b4+RSDCsjvSc4X1OWxpkL9BPE/SKASRC1DLBbDwsICz58/1/jYygr0s4jFYuTk5CA3N1dtW7KW5R3U9hI/ZfGXDd88uiyq9qDm5ubi0KFDSE9PV1mf5WFubo7evXtrTKRGRUUhKCgIGRkZGhlPIBDgwIED6NOnj0bGI1QPLfETRC3E1tZWK5n8IpGozJhCiUSi9kz+ssbWVpKUqvaaV+USP988qAD/hLc6lvgLCgqQnp4OmUwGc3NztT5kMhnS09Or7K198uQJvvzyS9SpUwdisRgODg6YMmUKXr9+LdfO0dERP/74o8quTU0mLCwMjRo1gqGhIczNzdG1a1dcuXJFrs3169fRrVs3mJmZwdLSEuPGjUNWVhZ3Pioqilv9ef/x8uVLrl1+fj5mzZoFBwcHSCQSODo6YvPmzRqbq6ohDypB1ELMzMy08qWvr6/PfbGXFQ+q7kSp98cVCARaz+JXlUBVVaF+PgnC2rTEDwBSqVRtKwilqeoPwYcPH8LLywvu7u7YtWsXnJyccPv2bUybNg1//vknLl++DAsLCzVZWzba2La5Kri7u2PVqlVwdnZGbm4uli9fju7duyMhIQHW1tZ49uwZunbtikGDBmHVqlXIzMxEUFAQRo4cib179wIABg0aBD8/P7l+R44ciby8PNjY2HDHBg4ciBcvXmDTpk1wdXXF8+fPeffeqQrkQSWIWoiJiQmEQqHGl7bLK9YPvPviL+05UDXKxJdQKNRKwhhrD8C/GFQ+Zc7zyRYWXa2aUB4TJ06EWCzGsWPH4OPjgwYNGuCzzz7DiRMn8PTpU8yaNQsA0LFjRzx+/BjBwcFKqzAcPXoUHh4eMDIygp+fn0Ko0caNG+Hh4QGpVIpGjRphzZo13LmkpCQIBAJER0fDx8cHUqkUO3furJT93333Hdzd3WFgYABnZ2eEhITI/TANCwtDixYtsH37djg6OsLU1BQBAQF4+/Yt16akpASRkZFwcnKCTCZD8+bNORFZFkOGDEHXrl3h7OyMJk2aYNmyZcjMzMStW7cAADExMdDX18fq1avRsGFDtGnTBmvXrsW+ffuQkJAA4N0W1XZ2dtxDKBTi1KlTGD16NDfOkSNHcObMGfzxxx/o2rUrHB0d4eXlhfbt21fq+vAREqgEUQsxNjaGTCbT+DJ/edudAv/b8lRdKBNf2haolMVfPgKBgJdeoNqUxZ+WloajR49iwoQJCjGrdnZ2CAwMRHR0NBiGwf79+1GvXj3MmzcPz58/lxOgOTk5WLJkCbZv346zZ88iOTkZU6dO5c7v3LkTc+bMQXh4OOLj4xEREYGQkBBs3bpVbswZM2ZgypQpiI+Ph6+vb6XmYGxsjKioKNy5cwcrVqzAhg0bsHz5crk2iYmJOHjwIGJiYhATE4MzZ85g4cKF3PnIyEhs27YNa9euxe3btxEcHIyhQ4fizJkzlbKhoKAA69evh6mpKZo3bw7g3bK8WCyWSwxlr/H58+eV9rNt2zYYGBigf//+3LHffvsNrVu3xg8//IC6devC3d0dU6dO1Uool6qgJX6CqIUYGRnByMgIOTk5MDY21ti4FQlUiUSCrKwstS7bKfOg5ufnq2WsimC/lKpbB1XVS/xsX3zxXvJRoAK1x4P64MEDMAwDDw8Ppec9PDyQnp6OV69ewcbGBkKhEMbGxrCzs5NrV1hYiLVr18LFxQUAMGnSJMybN487HxoaiqVLl6Jfv34AACcnJ9y5cwfr1q3DiBEjuHZBQUFcm8oye/Zs7v+Ojo6YOnUqdu/ejenTp3PHS0pKEBUVxX0mDhs2DCdPnkR4eDjy8/MRERGBEydOwMvLCwDg7OyM8+fPY926dfDx8Slz7JiYGAQEBCAnJwf29vY4fvw4rKysAACdO3fGN998g8WLF2PKlCnIzs7GjBkzAKDMRNZNmzZhyJAhcj8WHj58iPPnz0MqleLAgQNITU3FhAkT8Pr1a2zZsqVK14ovkEAliFqInp4ebGxsEB8fr9FxWYFaVmiBRCJBRkYGcnJyYGpqqvLxlQkukUiEoqIiFBcXQygUqnzMythDHtSyUVfJMaLqVPe+MDAw4MQpANjb23NJPtnZ2UhMTMTo0aMxduxYrk1RUZHCZ0Hr1q2rPHZ0dDRWrlyJxMREZGVloaioCCYmJnJtHB0d5X6wl7YvISEBOTk56Natm9xzCgoK0LJly3LH7tSpE+Li4pCamooNGzZg4MCBuHLlCmxsbNCkSRNs3boV33zzDWbOnAmhUIjJkyfD1tZW6b1/6dIlxMfHY/v27XLHS0pKIBAIsHPnTu56LVu2DP3798eaNWtqZEkxEqgEUUuxsrLSWM1FlooEqr6+PgoLC9UqUJUlZxUWFqKoqEjjApXPW52WlJRo/Hoog49JUix8EvLqxNXVFQKBAPHx8ejbt6/C+fj4eJibm8Pa2rrcft5fFSn9Y4iNPd+wYQPatm0r1+79+7CqSWSXLl1CYGAg5s6dC19fX5iammL37t1YunRphfax9x5r3+HDh1G3bl25dhKJpNzxDQ0N4erqCldXV7Rr1w5ubm7YtGkTZs6cCeBdnOqQIUPw4sULGBoaQiAQYNmyZXB2dlboa+PGjWjRogVatWold9ze3h5169aV+9z08PAAwzD477//4ObmVq6NfIQEKkHUUkxNTTmBpKmlXIFAwJWTKus8oN4tT5Ut8efm5qKwsLDCLxpVoyphqeoyU3zzovLJltLw1S5VY2lpiW7dumHNmjUIDg6W88alpKRg586dGD58OHc/i8XiKoet2Nraok6dOnj48CECAwNVav/Fixfh4ODAJXIBwOPHj6vUR+PGjSGRSJCcnFzucn5lKCkpURpWZGtrCwDYvHkzpFKpgrc2KysLv/76KyIjIxWe2759e+zZswdZWVkwMjICANy/fx96enqoV69etezVFiRQCaKWYmpqColEgtzcXBgYGGhsXHYZvyxEIpHaEqWULZmJRCIUFxdrpVg/X8tMAfyJ+xQIBNWO0VUX6hComkjY+5AxVq1aBW9vb/j6+mLBggVyZabq1q2L8PBwrq2joyPOnj2LgIAASCQSLt6yIubOnYvJkyfD1NQUfn5+yM/PR2xsLNLT0/HNN99U2WYWNzc3JCcnY/fu3WjTpg0OHz6MAwcOVKkPY2NjTJ06FcHBwSgpKUGHDh3w5s0bXLhwASYmJnIxsizZ2dkIDw+Hv78/7O3tkZqaitWrV+Pp06cYMGAA1469tkZGRjh+/DimTZuGhQsXwszMTK6/6OhoFBUVYejQoQpjDRkyBPPnz8eoUaMwd+5cpKamYtq0afjyyy9r5PI+QAKVIGotJiYmMDQ0RE5OjkYFqlQqLVcMSiQSvHnzRq1bnpZGT08PxcXFWqmFqirPtapFJZ88qLVliV8sFsPc3Bzp6ekaybw2NzeHWCyudHs3NzfExsYiNDQUAwcORFpaGuzs7NCnTx+EhobK1UCdN28exo8fDxcXF+Tn51f6Oo0ZMwYGBgZYvHgxpk2bBkNDQzRt2hRBQUFVnZ4c/v7+CA4OxqRJk5Cfn48ePXogJCQEYWFhVepn/vz5sLa2RmRkJB4+fAgzMzN4enri+++/V9peKBTi7t272Lp1K1JTU2FpaYk2bdrg3LlzaNKkCdfu6tWrCA0NRVZWFho1aoR169Zh2LBhCv1t2rQJ/fr1UxCuADhx+/XXX6N169awtLTEwIEDsWDBgirNkU8IGL58Cn0gmZmZMDU1xZs3bxQCngmCKJ/Dhw8jKSkJDRo00NiYDx48wL///qsQx8WSm5uL7OxsfPrpp9xSlap4/vw5Ll++rDD206dP0bZtW9SpU0el41VEQUEBYmNj0blz52q9BtnZ2fj9998hFArlPDMfwpMnT3DkyBG0a9dO4yEPyvjjjz8gkUjQpUsXbZsix/379+Hv71/l2L68vDw8evQITk5OkEqlcudyc3M1FhcuFotrrGeN4A/l3c/V1WfkQSWIWoytrS3u3r2r0TFFovI/diQSCedFUrVALc9jqY0lfj7GoLKJZHzxXfC1Diqg+iV+mUxGopEg/j9Uv4MgajHslqeaFCMV1Tdl4zKzs7NVPnZ5AlVb250CqttJim8VAVRBeTuPaRu+iHiC0EVIoBJELcbU1BRSqVSjheor8qAC70RqZmamyscuS6AKBAKtxqCqyoOqir5YDypfRCGfbHkfvtpFELoACVSCqMWYmprCwMBALd7KstDX1+cSk8pCKpUiPT1d5R6qspavtbXdqaoL9auiL77sHsVSW5KkCIKQhwQqQdRixGIxLCws1Fp39H309fW50k5lwZa/UrVoLEt8aVOgqqLMVOl5Vbcvtig6X8SXrgpUvlxfgqgO6ryPSaASRC3H3t5eI2VtWNjdpCoSqPn5+Sr37JbnQdVkmAOLquqXqmOJny8Cis8C9UPsYmOwNfmjkCDUBXsfV5Rb8CFQFj9B1HKU1dRTJ/r6+hUKVDYxRl1f4u+LL5FIxG13WpkYWVWiihjL0qJSlQlXfIBNmuMjH2KXUCiEmZkZt8e7gYEB78IqCKIiGIZBTk4OXr58CTMzM7Vsi0wClSBqOaamptDX10d+fr5G6l6KRCJOEJaHOhKl2G0834dd4i8sLNS4QAVUIwbZuF5VhQvwRRTyyZv7Ph9ql52dHQBwIpUgaipmZmbc/axqSKASRC3HzMyM21FKEwJVIBBAIpFUGPMpkUjw+vVrMAyjUg+TMsHDxsQWFhZqvA6lqrLUVVURgG9Z/Lq2xA+8u8b29vawsbHRankzgqgO7GqYuiCBShC1HKlUyi05mpuba2zMtLS0CtuwiVKqEo1lxXwKhUIUFRVprdSUKgSYqor1qyouVlXwefm7utdIKBSq9QueIGoylCRFEITGE6WkUmmFOzepI1GK9Q6+Dxvzqq3dpFQhBlVZsopPy+q66EElCKJiSKASBAFzc3ONChKJRFLheKxoVEcmf1lja2u5lU8eVBY+CVS+2FIaPol4gtBFSKASBAFTU9NKJS6pisqWJFF1olRFJZS0tcSvqiQpQHUlq/jkHeSrEOTTNSIIXYMEKkEQGt9RqrKZ8mysqqqEQHnxjAKBAAUFBSoZpyrwLQa1rDAIbSEUCnkrUMsrlUYQRPUggUoQBAwMDGBiYqKx4uH6+vqV8hyWTpRSBeXFV2prNylANR5CVWXx882Dytclfj7HxhKELkAClSAICAQC2NnZaVSgspnz5cGWo1J1olRZAlWTiWKlUeUSv655UIF3c+KjSCUPKkGoDxKoBEEAACwtLTX2hSsWi7nao+XBes+ysrJUMm55wktbHlRVL/GraicpvngH+Vb2ioVPtWIJQhchgUoQBIB3cajsbkTqRiQSVcqDyrbNyMhQybjlLfGX3u5U0/BtiZ9P4ovPApU8qAShPkigEgQB4J1AZXeUUjf6+vqV8qAC7+JQ09PTVSoGyhKo7G5SNRFVLvHzSQyy9vDJJoA8qAShbkigEgQBADAyMoKRkZFGMvnZ7U4rK1Dz8vJUIpzLKzMlFAq1JlD5tNWpnp4erxKA2DAPvglUPl0jgtBFSKASBAHg3ReujY2NxhKFZDJZpZbTxWIx8vPzVRKHWl4CkEgk0sp2p3yrg6qqHalUBTsvPkJL/AShPvj7zicIQuNYW1trTKBVVqCygkmVAlWZ+GI9YpquhcrXOqh8Eah8S9pioSV+glAvJFAJguAwNTUFoBkxIBaLKy2CJBIJXr9+Xe0xKyO+NO1BVdVSsSqTifhUe5RPtpSGBCpBqBcSqARBcJiamkImk2mk3FJltzsF3sWhZmZmVtu7WZnl65q6xK9KTyOfPKh8jUElgUoQ6oUE6v9r787D66rK/YF/995n7zNlHpqkbdIJ2gJSpAVrGSpckKKoCIjIJAiCPOKV6SqFXoEiUGwFKXhlUBGHckVAREEEpMAPsCAgtbTQSrFQKB3pkPlM2b8/ctfmnOQkOcM6yXtOvp/nyUNzhp2Vc5rw7bvWehcReSoqKobtyFPHcTJ+rNoole80v2qhNNj9kUgkr6+RLV3tinSeACUpfEme4ucaVKLCYUAlIo/P50N9ff2wbJSybTvj6W3VAkrXOtSBqnE+n2/YTtNSOMU/OGlHryqSqsxEpYgBlYhSNDQ0DNsUv9o5nwnTNNHa2pr31x0sfI3EaVK6DkfQPcUvheSAOhKHOhCNFgyoRJSisrJyWCpDtm1nfJoU0DvNv2PHDi19PgeroEYikWGdutXdZkpXNVZKIJTW9iqZxDERlQoGVCJKUVlZiUAgUPC1mI7jZFVBDQQC6OzszHsKfrC+miPRC1Vamymgt5IsJaBKraAO17HARKMVAyoRpaisrByWjVKWZcFxnIz/J+/3+xGNRvNehzrUGtR4PD6svVBV0Mm3Gqf7zHop1UHLsgDIC6hSj2AlKhUMqESUwu/3o7q6elg2C2XarB/4aGq+ra0tr685WAVVLTkY7gqqzuvoqqBKCV6Sd/EzoBIVDgMqEfXT2Ng4LDv5A4FAVhtN/H4/duzYkdfXHKyCqqa2hzOgSqygStrFz4BKNDoxoBJRP9XV1cPyP95gMJhV8AgEAmhra8trfWwm1cHhnuLXufO+1Br1qyl+KeNJ5rquuOBMVCoYUImon+rqajiOMywbpbIRDAbR1dWV1zR/JtXB4T5NSkclrtR38Utr6ST1hCuiUsGASkT9VFdXIxwOa2mMP5hsjjsFPpqCzzegDnX/cCxvUNT3JCmgSqygSsSASlQ4DKhE1I/jOBgzZkzBd/I7jpN1tc7n82HXrl05f82hwpfP5xvWgCrxJClpm6QkTqWrCqq0cRGVCgZUIkqrqamp4EEt29OkgN5p/l27duU85TvUFP9IHHcK5F/5LOU1qLp6xerETVJEhcWASkRp1dTUFDwYZHuaFNAbUDs7O3NefpBJQI3FYsO25lHXWsZSXoMqtVLJgEpUOAyoRJRWVVUVQqFQQauJjuPAtu2swqB6fK7rUDMJqMPZC1WNJ99TiUp5il9SYFYkB2eiUsCASkRpVVZWoqKioqAbpdRpUtlWK03TxJ49e3L6mkM1xh/u06R0BctSneJnH1Si0YkBlYjSMk0TY8eOLfhGqWAwmHX1MBgMYvv27TlVHYeqDqolB8MVUHUFMN1T/FKCl2EYogKzwgoqUWExoBLRgMaMGVPwtZjZHHea/JzOzs6ChGcVzoZril9X0Cn1gCotCKp/WEh5nYhKDQMqEQ2ouroatm0XtJoYCASyDh+O4yAajea0DnWoPqhKoQ8pUHS1KyrVwCR5il9H/1oiSo8BlYgGVFNTg/Ly8oKuQ3UcZ8h1oX2pqtru3bsLMibDMIZ1DarEXfxSgpf0Cqq0cRGVCgZUIhpQIBDAmDFj8jq5aSi2becUhgKBAHbs2JF1QDBNc8hAbFnWsPVCVWORtIs/0yrzcJC61pMVVKLCkvNbiIhEGjduXEGnu/1+PyzLymmjVEdHR9brUDPZcGPb9rAFVF0V1FLexS+5zZSU14mo1DCgEtGgamtrYZpm3hW+gajTpLLdlBQIBBCJRLKu7mZaQe3u7h6WUKRral73FL8UUtfWcoqfqLDk/BYiIpFqampQVlZWsGl+x3GyPu4UyG8daiYV1OFqNSVxF7+kCiqAgv4DKVec4icqLAZUIhpUWVkZ6urqCrZRyrbtrE+TUgKBALZv355VKMukgjqczfotywKgr4KqIzBlu2mt0KSNB2AFlajQGFCJaEjjx48vWMN+wzBy6oUK5L4OdagQN5zHneoKOjoDk6Rd/AArqESjEQMqEQ2prq6uoCEhFArlXEHNdh1qJusr1aac4eiFqmuNZSlP8UsbDyB3bSxRqShYQH3nnXdw7rnnYtKkSQgGg5gyZQquvvrqflNmq1atwuGHH45AIIDm5mYsXry4UEMiohzV1tYiHA4XrIqay3GnQOH7oRbTGlSdgUnSJimg93uTWEGV2P6KqFT4CnXhtWvXoqenB3feeSf22msvrF69Gueddx46Ojrwwx/+EADQ2tqKY445BkcffTTuuOMOvP766zjnnHNQVVWF888/v1BDI6IslZeXo6amBh9++CEqKiq0X9/v9+f83OR1qJlWRzM1XAEVkLeLX1LwkrbkIJnUcREVu4IF1GOPPRbHHnus9/nkyZOxbt063H777V5AXbZsGaLRKO6++244joP99tsPK1euxM0338yASiSIYRhoaWnBu+++W5Dr27ad83NDoRDa29vR3t6uNTz7fL6CVYyT6d7Fr3pzStxYlCvTNHNaAjIcJAV5olIyrPM4e/bsQU1Njff5ihUrMHfuXDiO4902b948rFu3Drt27Up7jUgkgtbW1pQPIiq8+vr6gh056ThOzmtc/X6/93shE5lWUIerWb9apqBrDSqgbz2rFJZlia1USh0XUbEbtt9C69evx2233YZvfOMb3m1btmxBQ0NDyuPU51u2bEl7nUWLFqGystL7aG5uLtygichTW1uLsrKygrSb8vv9ObeaynYdaqaB0Ofzobu7u+BrH3UddZpcMdVRjZV0SpLEXfyKlNeIqNRkHVDnz5/v/YIf6GPt2rUpz9m0aROOPfZYnHzyyTjvvPPyGvAVV1yBPXv2eB/vvfdeXtcjosxUVFSgpqamIA37c23Wr4RCIWzfvj2jEJPp1Lc63Wo4Wk0BequeOjZcFapangtpa2KTSR0XUbHLeg3qZZddhrPPPnvQx0yePNn78wcffIAjjzwShxxyCO66666UxzU2NmLr1q0pt6nPGxsb017b7/fntaGCiHKj1qFu3LhR+7VVs/5cw2AoFMKePXvQ1taGqqqqQR+bXEEdLKyqwByJRBAIBHIaVzZ0rUEF9IVdKdVBy7LErkGV8hoRlZqsA2p9fT3q6+szeuymTZtw5JFHYtasWfjFL37Rb13TnDlzsGDBAsRiMW+TxJNPPolp06ahuro626ERUYGpn/1Md8xnyjAMBAIBdHV15fR8x3EQjUbR2tqaUUAFMm/WPxw7+XVUCJODt6SOADpIqub2JXVcRMWuYGtQN23ahCOOOAItLS344Q9/iO3bt2PLli0pa0tPO+00OI6Dc889F2vWrMF9992HpUuX4tJLLy3UsIgoD3V1dQVbhxoOh/OqkpmmiQ8//HDIxxmGkVHbIvWY4WjWD+g9olRXs34pLMsSuwaVAZWoMArWZurJJ5/E+vXrsX79eowfPz7lPvWLuLKyEk888QQuvPBCzJo1C3V1dbjqqqvYYopIKNUPdceOHdr7oYZCobxCSDgcxs6dO1NmZNLJNngNxxpUXZuAVCU239BkWRYAOdPX7INKNPoULKCeffbZQ65VBYAZM2bgueeeK9QwiEgjwzAwYcKEgvRDTW43l4tQKIQdO3agtbUVtbW1Az4um7ZOhmHkvOwgG7qqlbrWjuqsxOrACirR6COr2R0RiVdfX1+Qtj/5BlS1ZnSofqjZBFTbtoelWT+Qf5spQF+wVMsgpIQvrkElGn0YUIkoK/X19SgvL9e+DtXv9+fVagroDbnbt28f9DGmaWYcUH0+37A169cxVay7gipl+lpyo34GVKLCYEAloqyUlZWhrq5O+ylu+fZCBXqn+Xfv3o3u7m4tY7JtG9FotODrUHUFVF0VVGlrUCVjQCUqDAZUIspaS0uL9sqiCqj5hMFQKITOzk7s2bNnwMdkW0GNxWLDspNfR9DR1R5KWqN+VlCJRh8GVCLK2pgxY7Q3T7dtG36/P+9WU67rDnnsaTYBdbh6oeqgM6ACciqoksJyMsMwxB4gQFTsGFCJKGt1dXWoqKjQPs0fCoXy/h9+MBjEtm3bBgw02azT9Pl8SCQSBQ+o0tagqiqzlFAotYJaiM2CRNSLAZWIshYMBtHU1KQ9oIbD4bzXewaDQbS1tQ24+z6bXfxKsUzx61qDKu2oUxWWpYxHkRTiiUoNAyoR5aS5uVl7cNNx5n0gEEAkEhlwHaoKqNkodEDVFXR0TfFns053OEgLzAqn+IkKhwGViHJSX18Px3G0hjfHcbS0SBrs2NNsK6g+n6/gvVB1B1RdQU5KIJQcUDnFT1QYDKhElJPa2lpUVlZqneb3+/1awpo6VSrdcoFsNwANR7N+XU3xdU/xS5m+VpvfpAVU0zRZQSUqEAZUIsqJbduYMGHCoC2dsuU4Dmzbzvt/+uFwGO3t7WnDs6qwZhNQI5FIQYOIrrPmS3WKX41FyngUrkElKhwGVCLKWVNTExKJhLbgoE6TynejlM/nQ09PT9p2U9muP1XjKfROfh1TxToDKiBnSl3aeBRO8RMVDgMqEeWsvr4e4XBY2xS4bdtwHEfLyU1+vx9bt27tF2qyXYNq23bBA6qudkU6+5dKqqBKnuJnQCUqDAZUIspZVVUVampqtK1DNU1TSy9UoHcdamtra7/wnG1AVQcSFHInv2VZonbxq6NOpUxfSw2orKASFQ4DKhHlzDRNTJw4Ee3t7dquGQqFtFRQA4EAurq60q6RzWbNp6pKFrrVlM4pfh2dEHRcRxdpm7YUtpkiKhwGVCLKS0NDg9ZKUigU0lZNNE0TO3fuTHtftuGru7s77zENRNcmKZ27+CVtAOIaVKLRhwGViPJSX1+v9dhTv9+v5TpAb9jdtm1bv4psLhulCtlqSgUdHUeUAqW3i199X9LCoGoPJuV1IiolDKhElJdwOIympiZt7aZUL1Qd/9MfqN1UthXLQvdC1RV0dE6FSwqouirDukk9gpWoFDCgElHeWlpatE2Bq16oOtahqnZTu3btSrldBblM2baN7u7ugq03zHY8Q11HV09VKYFQ2qYtRWpwJioFDKhElLcxY8YgEAhoCamqF6quMJiu3VS21UHVC7VQG6V0TfHrDEy61sXqIHWTlArx0sZFVAoYUIkob7W1taiurk7bGD9bOiuoQO80/549e1I6DeQyxV/ogCqpzZS6FgPq4NQ/dKSNi6gUMKASUd58Ph8mTpyItra2vK+leqHqCqiBQACRSCRljaxlWVlXUBOJRMGa9avxSGoPxTWoQ5N6BCtRKWBAJSItmpqa4Lqulp3W4XBYW0A1DAOmaWL79u3ebbmu+SxUqymd7aF0XEddS0rwUmtQpYxHUZVvacGZqBQwoBKRFmPGjEFFRYWWKqquXqhKWVkZduzY4U3R51IdNAyjoAFVx1Sx7oAqJXhJbjPFKX6iwmBAJSItysvL0djYqKXdlM5eqEBv4O3o6PDWyOZSHbRtW+uJWcl09i8F9E3xSwlekhv1s4JKVBgMqESkzYQJE7Tt5Ne5BlKt8fzwww8B5BZQVbP+QoQkXWtHde/il0LyJilA3riISoGc30BEVPQaGhrg9/vzDql+v1/rTn6gt4q6detWJBKJnCuokUhE65gUFaAlTfFL2iQlNaCyzRRR4TCgEpE2dXV1qKmpybvdVCECqjpVas+ePVkfdQr0BtR4PF6wVlM6A6qOYGlZlpjgZRiGqCUHCttMERUOAyoRaWNZlpZ2U7p7oaprxuNx7N69O+s2U0BvQI1GowUJqLoqqDqnnCVVUCUHVFZQiQqDAZWItBo7diyA/HZcG4aBsrIy7dPpfr8fW7ZsySl4qWUBhdjJzzZTg1NhWeoufimvE1EpYUAlIq3GjBmDyspKtLa25nWdQgTUcDiM3bt3o7u7O6dpfgAFqaDq3sVfam2mVC9baUGQU/xEhcOASkRahcNhjBs3Lu91qIFAQHsgUadKtbe351xF7erq0jom4KOp4ny/X91tpqQEQqm75TnFT1Q4DKhEpF1LSwui0WheAUd3L1SgN1D4fD7s3Lkz541S+VaG09HViF5nkMtlnW6hqDWoUsajsFE/UeEwoBKRdg0NDQiHw+jo6Mj5GoFAAKZpal93GA6H0draimg0mvVzHcdBV1eX9jHpqnyWaqN+FbylrUFVpLxORKWEAZWItKupqUFdXV1e0/x+vx+O42hfhxoKhdDd3Z3TVL3qLKB7HaquymepVlBN0xR1cEAySUGeqJTI/IknoqJmGAYmT56cVwW1EL1Qgd6wYxgGOjs7s35uoQKqrt3gpdqoX5FYQeUUP1FhMKASUUE0NTV5py/lwufzIRAIFOTkplAohPb2dsTj8azHFI/Htbea0tVGSWdAtSwr72voJKmrQF/SgjxRKWBAJaKCqK+vz/tUqUK0mgI+mubPpYoK6G81pabmJa1BlUbyVLrEyi5RsWNAJaKCsG0bkydPxp49e3K+RllZWdZVzkzYtg3XddHe3p71c3NdHjDUNQFZa1ClVSwlLjlQpI6LqJgxoBJRwYwdOxaGYeRcYSpEqynFtm3s3r076xBm23ZOwXYwEhv153qQQaFIC8zJpI6LqJgxoBJRwTQ2NqKqqirnaf5AIFCQyplhGHAcB52dnVmvJ7VtGx0dHVpDicQpfmknN0muoDKgEunnG+kBEFHpCgaDaG5uxpo1a1BbW5v185N38juOo3VsPp8PsVgMHR0dCIVCGT/PcRxEIhFEo1EEAgGtY0okEohEInjxxRexZs2arLsgxGIxbNiwAaZp4t///nfWX980TVRVVWHmzJlZP7fQpFZQJa+NJSpmDKhEVFAtLS1YtWoVenp6su5lWaiAqk4mMk0Te/bsQX19fcbPtW0bbW1t6O7u1hpQDcNAJBLBbbfdhvfeew/7778/mpqasppq7+npwbhx4wAAlZWVWY+hp6cHGzduxM9//nNMnjwZ1dXVWV+jUPJZKlJIbDNFVBgMqERUUI2NjSgrK0N7ezsqKioQi8Xw0ksv4V//+hc6OzuHnLbdsWMHenp6YNu2lvFYloVQKIREIoHKykq0tbUhGo1mHIBVq6lC7ORfs2YNNm7ciBtuuAHTpk3L+hqJRAJbtmwBAC+o5uIPf/gDfvKTn+DAAw/M+Rq69a2gRqNRvPPOO3n12s1VeXk5JkyYANu2WUElKhAGVCIqqMrKSjQ2NuL9999HeXk5HnjgAWzfvh2zZ89GU1PTkFXVzs5OxONx+Hwf/bpqa2vD+vXrsX379qzWJbqui0gkgo0bN2LXrl2or69HPB7HBx98kHWjfJ/Ph7322ivjxw90jaamJpSVlcEwDGzYsAFTp07NKZwCqRubXNfNeaPTsccei5///OfYtm0bXNfFtm3b8NZbb2nvXjAUwzAQCoWw9957pxx7u3r1ajz22GNeFXy4qUB6/PHHs4JKVCAMqERUcJMnT8Zbb72F999/H++//z4uv/xyzJo1K6PndnR0oK2tzZtO37RpE5YuXQrDMDBr1iwEg8GsxxOPx7Ft2zYkEgmsXr0ara2t3oasoaiQ+8ILL2DNmjV5VXZVc/6pU6eioaEB3d3dOa3V1S0QCCAUCiESiWD58uV45ZVXUFFRgerq6mENhD09PXjrrbfw/PPPw+/3Y99998WHH36IRx55BEcffTROPPFE1NfXD2vHAdd1sXnzZvzud7/DQw89hHnz5jGgEhUAAyoRFVxjYyOCwSDWrVuHioqKrDbh9A1EDz/8MOrr63H99dejrKwsp/H09PRgw4YNuPvuu1FVVYX/+q//wsyZMzNuaxWPx7Fx40bEYjGMHz8+pzEAQHd3N1auXIl77rkH27ZtG3BN6/HHH4+tW7fCNE2UlZVhyZIlOOCAAxCJRHDllVfiqaeegt/vx8c+9jFce+21AIAnn3wS1113HaLRKILBIG699Vbsv//+AIAlS5bg3nvvxdtvv41ly5bh85//fL+vaZomWltb8eqrr+Lss8/Gsccem1LFHi6xWAyPPfYYfvzjH2PHjh148803UVlZiXPPPVf7xrlMGIaBcePG4fzzz8err76Kd999V+TaWKJix4BKRAVXW1uLuro6vPzyywiHw1lVvJKP3HRdF+vWrcNZZ52VczgFPpoKf+ONN3DyySfjyCOPhM/ny3hcPT09aG5uxsaNGxEKhXKuKpaVleHoo4/G1q1bce+992LChAlpH/fLX/4SVVVVAIA//vGPuOCCC7BixQpcffXVMAwDr732GgzDwJYtW5BIJLBnzx58/etfx+OPP4599tkHL7zwAs4991z8/e9/BwAceeSR+NKXvoRvfvObg45v165dmDp1Ko477rgR64tq2zY+//nP4/e//z22bNmC8vJyNDc3j0g4TRYOh9HQ0JDTkblENDQGVCIqONM0MXnyZHR3d6cNOqeccgq2b98O0zQRDodx3XXXedU+0zTx8MMP45prrsHNN9+MRCKBmpoavPnmm2hra/N25E+ePBk1NTUAgNbWVrz11lvo6elBT08Pmpqa0NLSkvI1Ozo6EI1Gsddee3nrCDM9f94wDG9qP5FI5D3tPWXKFMTj8QGDjgqnQO/3ZhgGOjo68Ktf/Qpr1671XtPGxkZs2rQJGzduRE1NDfbZZx8AwKGHHor3338fK1euxMc//nEcdNBBGY0rGo16hy2MJMMw0NTUhLVr1w64YS7d36G9994bF1xwAd566y0EAgHU1dXhxhtvxKRJkwAAF198MVatWgXTNOHz+bBgwQIcfvjhQ96nqI1brKAS6ceASkTDorGxMWWjS7K77rrLa4v05z//GRdffDGeeuopAL1rTh966CEccMABKc/Za6+9vKDS1taG4447DtFo1AuLixYtwqc+9Snsu+++6OnpQVVVFUzTxGWXXYYTTzzRWzdoWZa3CehjH/sY6uvrcfzxx2PLli1er9NvfetbOO644/DSSy/h+9//Pnp6ehCNRnHSSSfhggsuANBb3b3pppvw0EMPwXEc1NTU4MEHHwQAnHjiiXj//fdRUVEBADj55JPxjW98w/teLMsashH9+eefj//3//4fAODBBx/Ehg0bUF1djR/+8Id45plnEAgEcOWVV2Lq1KmYOHEidu7ciRdffBGf/OQn8eijj6KtrQ3vvvsuPv7xj2f1vqUL3wP9g2L58uX4wQ9+gFgshmAwiMWLF2O//fYDAHz2s59FNBoF0LtEYt26dXjqqaew7777AgDuuece/PznP4fP54Npmnj00UdTljwM1Qc13d+hRx99FGeeeSb+4z/+A4Zh4O6778Zll12G3//+9wCAhQsXes95/fXX8eUvfxlr1qyBaZqD3tcXAyqRfgyoRDQsGhoaEAqFsGfPnn73JffsVFVRoHcq/Tvf+Q6uvPJKLFmyJOU5yVW0eDyOK6+8EvPmzYNhGLj55pvxX//1X3j55ZcBAAsWLMCXv/xlb41pchCMRCLYunUrysvLvdt++ctfYsuWLfD7/Xj99ddx3XXXYeLEifj617+Oxx57DB/72MewZs0azJ07F2eccQaqq6vxs5/9DG+88QaefvppOI6Dbdu2pYx34cKF+MxnPjPoazRYQL3rrrsAAMuWLcNVV12F733ve9i4cSOmT5+Oa6+9Fv/85z/xhS98AY888ghqamrwy1/+Etdccw06OjrwiU98AtOnT89qDelgp1ulC4MPPvggvvWtb+Ghhx7CtGnT8OKLL+LCCy/EM8884z1OeeSRR3DTTTd54fQvf/kLfv/73+PRRx9FRUUFduzY0a9KOlSAT/d3KBAI4KijjvJunzlzJm6//fYBnzPY9QZimian+IkKgAGViIaF4zioq6vDzp07097/n//5n/jb3/4GAPjNb34DALjzzjtx8MEH96ueKm+//Ta2b9+OeDyO2bNne6GqoqIC0WgUK1asQCQSwbhx4wbcALVx40Yce+yx+Pe//+0FoKqqKqxatQqzZ8/GCy+8AJ/P5z1fHdva1taGiooKby3k7bffjvvvv9/7fMyYMdm+RBm1uTr99NNx8cUXY+zYsTBNE6eccgoA4IADDsDEiROxbt06zJkzB4cffjiOPPJIAL0hfK+99sL06dOzHlM66cLgO++8g+rqaq9F1ic/+Uls2rQJq1atwowZM1Kef++99+LUU0/1Pv/JT36CSy+91Ksw19XVpf26Q70+6f4OJfvZz36GefPmpdx2/fXX409/+hP27NmDn/3sZykV0sHuS8YKKpF+w99AjohGrcFOJrrtttvw6quv4vLLL8d1112HtWvX4tFHH8XFF18My7LShpMpU6bgk5/8JPbdd1+8/fbbOO+88zB9+nQsWrQIt912G+bMmQO/34+LL74YBx98ML75zW9i+/bt3vN37tyJsrKyfhuuotEofvjDH+KAAw7Addddh5/+9KcIBoP40Y9+hNNPPx377rsvTjjhBFxxxRVwHAdtbW3Yvn07Hn/8cXz2s5/FZz/7WTz88MMp17z++utx5JFH4hvf+AbefffdjF+z3bt3Y/Pmzd7nf/rTn1BTU4P6+nocccQR+Otf/woAeOedd/DOO+94vVmTn/ODH/wAc+fOxZQpUzL+ukP5z//8T8yaNQuLFy/GbbfdhsmTJ2PXrl1e1frxxx9He3s73nvvvZTnbdq0CStWrMBJJ53k3fbWW295FeB58+bhZz/7WdqvOVRA7ft3KNnSpUvxzjvv4Morr0y5fcGCBXjxxRdx55134vvf/763DGGo+xTDMFhBJSoABlQiGjaVlZVDTol++ctfxt/+9jf85S9/wXvvvYdDDjkERxxxBF5//XV8//vfx44dO/o9p6amBolEAjfffDNWrVqF008/HUuXLgXQG5TuvfdePPDAA6itrfXWjP773/9GW1sbGhsbveskr3G87LLLsHbtWnzve9/DVVddhXg8jttvvx3Lli3DG2+8gfvuuw/XX389du7c6W1w6u7uxp///GfceeeduPrqq7FmzRoAvcHp+eefx/LlyzF79myceeaZGb9mra2tOPXUUzF79mzMmTMHd911F+6//34YhoFbbrkFS5cuxezZs3Hqqafi1ltv9b6fRYsWYebMmTjggAPw3nvv4X/+53+8ay5evBjTpk3D3//+d3zrW9/CtGnTUoJ7JvqGwYqKCvz0pz/FDTfcgGOOOQbPPvsspk6d2m9ZwX333YdPf/rTKf1eVduuhx56CPfeey9+/etf48knn0x53lBT/MnU3yFVrb/99tvx5z//GcuWLUMoFEr7nLlz56K9vR1vvvlmVvcBYB9UogLgFD8RDZtgMAifz4dYLOYFlz179qCrq8sLVo899hiqq6tx0UUX4eKLLwbQO0V90kkn4fTTT8ejjz4K13XR2dnphY3W1lbEYjEEAgH4fD4cc8wx+MlPfoIPP/wQDQ0NeP/999Hc3IwLL7zQO77zn//8J2KxGN544w3EYjFEo1GsX78e8Xgc48ePh2EYiEQi3pT6qlWrsH37dhx22GEAgAMPPBD19fVYvXo1jjrqKITDYa8q2NzcjIMPPhgrV67Efvvt5x07ahgGzjnnHFx77bXYuXOn13VgMC0tLd46zr4mTZqUsrYTgHfU6S233DJgK6bvfve7+O53vzvk185k9/6Xv/xlXH755di5cycOPfRQHHrooQB637MDDjgAU6dO9R7rui7uu+8+3HjjjSnXGDduHE444QRYloXa2locddRRePXVV/HpT396yK8PDPx3qLq6GnfccQceeugh/O53v0tZmhCLxfD+++97O/pfe+01fPjhh5gwYcKg96V7jTjFT6QfAyoRDSvbtlMqTq2trTj//PPR3d0N0zRRW1uLX/3qVynhSLV/UhU013Wxdu1axONxGIaBzs5OjBkzxttY895776G8vBxr1qxBPB7H3nvvjcrKSvzqV7/y1kOecMIJeOWVV7DffvvhgAMOwKpVqzB27Fg4joPNmzejvr4eH3zwAVavXo2qqirU1NRg+/btWLt2LaZPn44NGzbggw8+8ELMF7/4RTz99NM4++yzsWvXLrz22mv45je/iXg87h2rCvRuEKqrq+sXTlWFMN+wo6sllKpy961aDhYGt27dioaGBgDAj370Ixx66KHe6wMAzz//POLxOD71qU+lXPOEE07A008/jcMOOwxdXV3429/+hgsvvLDfmAaqoA70d2jz5s1YuHAhJkyYgC996UsAetdC//nPf0YsFsNFF12E1tZW+Hw+hEIh/PSnP0VVVRU6OzsHvK8vBlSiwmBAJaJhpdoI9fT0wDRNNDc347HHHhv0OaZp4he/+AVisRj+/Oc/wzTNlNOoNm7ciK9+9avo6uqCaZqoq6vDww8/jPLycpxxxhlIJBJwXRcTJ070dsMPZM+ePfja176Gzs5ORKNRVFRUYOHChTjkkENw66234qyzzvKWKXz7299GU1MTAODKK6/EJZdcgnvuuQcA8K1vfQsHHnggOjs7ccYZZ3gtsNQO+3QqKyvxr3/9C4lEIuOerH0Ntvs+Uxs3bkRXV1fajWWD/YNi8eLFeOmll5BIJDBr1izcfPPN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pteT/ztUyDNNM+W/yX9ODnx971fX7vtnwzAwd+5c1NbWevcN9vi+f87kPmm9X4lGi4I36iciKhamaSIWi+HNN9/0wtpQG3CG2tmuApwKP+q2vhU8dXvfPzc1NWHvvff2gl7fMJV8veT/vvLKKxl9z47jYOrUqd4Y+34k377//vtjzpw5XphM93XT/Zkhj4iyxYBKRPR/jjjiCKxfvx6WZcFxHNi2Ddu24TiOV9Wzbdur9Kk/27btTe2qCmDf22zb9v6sruM4jve5+hqO48BxHO82NY5sQ97++++f0eOmTp2K2trajK8bDoezGgcRUS4MV9qCqyy1traisrISe/bsQUVFxUgPh4hIhEQigYkTJ2LTpk1pK7yGYWD8+PHYsGGD2M1hRFS88s1nBT/qlIiIhp9lWVi6dCkA9Ku+qs9vueUWhlMiEokBlYioRJ144ol44IEHMG7cuJTbx48fjwceeAAnnnjiCI2MiGhwnOInIipxiUQCzz33HDZv3oympiYcfvjhrJwSUUHlm8+4SYqIqMRZloUjjjhipIdBRJQxTvETERERkSgMqEREREQkCgMqEREREYnCgEpEREREojCgEhEREZEoDKhEREREJAoDKhERERGJwoBKRERERKIwoBIRERGRKAyoRERERCQKAyoRERERicKASkRERESiMKASERERkSgMqEREREQkCgMqEREREYnCgEpEREREojCgEhEREZEoDKhEREREJAoDKhERERGJwoBKRERERKIwoBIRERGRKL6RHkC+XNcFALS2to7wSIiIiIgI+CiXqZyWraIPqG1tbQCA5ubmER4JERERESVra2tDZWVl1s8z3FyjrRA9PT344IMPUF5eDsMwRno4BdXa2orm5ma89957qKioGOnhUBb43hUvvnfFie9b8eJ7V7yS37vy8nK0tbVh7NixMM3sV5QWfQXVNE2MHz9+pIcxrCoqKvhDW6T43hUvvnfFie9b8eJ7V7zUe5dL5VThJikiIiIiEoUBlYiIiIhEYUAtIn6/H1dffTX8fv9ID4WyxPeuePG9K05834oX37vipfO9K/pNUkRERERUWlhBJSIiIiJRGFCJiIiISBQGVCIiIiIShQGViIiIiERhQBXo9ttvx4wZM7xGt3PmzMFjjz3m3d/d3Y0LL7wQtbW1KCsrw0knnYStW7eO4IgpnRtvvBGGYeDiiy/2buN7J9M111wDwzBSPqZPn+7dz/dNrk2bNuGMM85AbW0tgsEg9t9/f7zyyive/a7r4qqrrkJTUxOCwSCOPvpovPXWWyM4YgKAiRMn9vuZMwwDF154IQD+zEmWSCTwve99D5MmTUIwGMSUKVPw/e9/H8l77nX83DGgCjR+/HjceOONePXVV/HKK6/gP/7jP3D88cdjzZo1AIBLLrkEf/rTn3D//ffj2WefxQcffIATTzxxhEdNyV5++WXceeedmDFjRsrtfO/k2m+//bB582bv4/nnn/fu4/sm065du3DooYfCtm089thjeOONN3DTTTehurrae8zixYtx66234o477sBLL72EcDiMefPmobu7ewRHTi+//HLKz9uTTz4JADj55JMB8GdOsh/84Ae4/fbb8eMf/xhvvvkmfvCDH2Dx4sW47bbbvMdo+blzqShUV1e7P/vZz9zdu3e7tm27999/v3ffm2++6QJwV6xYMYIjJKWtrc3de++93SeffNL91Kc+5V500UWu67p87wS7+uqr3QMOOCDtfXzf5Lr88svdww47bMD7e3p63MbGRnfJkiXebbt373b9fr/7v//7v8MxRMrQRRdd5E6ZMsXt6enhz5xwxx13nHvOOeek3HbiiSe6p59+uuu6+n7uWEEVLpFI4Le//S06OjowZ84cvPrqq4jFYjj66KO9x0yfPh0tLS1YsWLFCI6UlAsvvBDHHXdcynsEgO+dcG+99RbGjh2LyZMn4/TTT8fGjRsB8H2T7I9//CMOOuggnHzyyRgzZgwOPPBA/PSnP/Xu37BhA7Zs2ZLy3lVWVmL27Nl87wSJRqP4zW9+g3POOQeGYfBnTrhDDjkETz31FP71r38BAP75z3/i+eefx2c+8xkA+n7ufHqHTbq8/vrrmDNnDrq7u1FWVoaHHnoI++67L1auXAnHcVBVVZXy+IaGBmzZsmVkBkue3/72t/jHP/6Bl19+ud99W7Zs4Xsn1OzZs3HPPfdg2rRp2Lx5MxYuXIjDDz8cq1ev5vsm2L///W/cfvvtuPTSS3HllVfi5Zdfxre//W04joOzzjrLe38aGhpSnsf3TpY//OEP2L17N84++2wA/F0p3fz589Ha2orp06fDsiwkEglcf/31OP300wFA288dA6pQ06ZNw8qVK7Fnzx488MADOOuss/Dss8+O9LBoEO+99x4uuugiPPnkkwgEAiM9HMqC+pc/AMyYMQOzZ8/GhAkT8Lvf/Q7BYHAER0aD6enpwUEHHYQbbrgBAHDggQdi9erVuOOOO3DWWWeN8OgoUz//+c/xmc98BmPHjh3poVAGfve732HZsmW49957sd9++2HlypW4+OKLMXbsWK0/d5ziF8pxHOy1116YNWsWFi1ahAMOOABLly5FY2MjotEodu/enfL4rVu3orGxcWQGSwB6p4K3bduGmTNnwufzwefz4dlnn8Wtt94Kn8+HhoYGvndFoqqqClOnTsX69ev5MydYU1MT9t1335Tb9tlnH295hnp/+u7+5nsnx7vvvou//vWv+PrXv+7dxp852b7zne9g/vz5+MpXvoL9998fZ555Ji655BIsWrQIgL6fOwbUItHT04NIJIJZs2bBtm089dRT3n3r1q3Dxo0bMWfOnBEcIR111FF4/fXXsXLlSu/joIMOwumnn+79me9dcWhvb8fbb7+NpqYm/swJduihh2LdunUpt/3rX//ChAkTAACTJk1CY2NjynvX2tqKl156ie+dEL/4xS8wZswYHHfccd5t/JmTrbOzE6aZGh8ty0JPTw8AjT93evZ0kU7z5893n332WXfDhg3uqlWr3Pnz57uGYbhPPPGE67que8EFF7gtLS3u8uXL3VdeecWdM2eOO2fOnBEeNaWTvIvfdfneSXXZZZe5zzzzjLthwwb3hRdecI8++mi3rq7O3bZtm+u6fN+k+vvf/+76fD73+uuvd9966y132bJlbigUcn/zm994j7nxxhvdqqoq9+GHH3ZXrVrlHn/88e6kSZPcrq6uERw5ua7rJhIJt6Wlxb388sv73cefObnOOussd9y4ce4jjzzibtiwwf3973/v1tXVud/97ne9x+j4uWNAFeicc85xJ0yY4DqO49bX17tHHXWUF05d13W7urrcb37zm251dbUbCoXcE044wd28efMIjpgG0jeg8r2T6ZRTTnGbmppcx3HccePGuaeccoq7fv16736+b3L96U9/cj/2sY+5fr/fnT59unvXXXel3N/T0+N+73vfcxsaGly/3+8eddRR7rp160ZotJTs8ccfdwGkfT/4MydXa2ure9FFF7ktLS1uIBBwJ0+e7C5YsMCNRCLeY3T83Bmum9T6n4iIiIhohHENKhERERGJwoBKRERERKIwoBIRERGRKAyoRERERCQKAyoRERERicKASkRERESiMKASERERkSgMqEREREQj5NFHH8Xs2bMRDAZRXV2NL37xi0M+580338QXvvAFVFZWIhwO4+CDD8bGjRv7Pc51XXzmM5+BYRj4wx/+kPZaH374IcaPHw/DMLB79+6sxn7EEUfAMIyUjwsuuCCrawyEAZWIiIioQI444gjcc889ae978MEHceaZZ+JrX/sa/vnPf+KFF17AaaedNuj13n77bRx22GGYPn06nnnmGaxatQrf+973EAgE+j32lltugWEYg17v3HPPxYwZMzL+fvo677zzsHnzZu9j8eLFOV8rmU/LVYiIiIgoY/F4HBdddBGWLFmCc88917t93333HfR5CxYswGc/+9mUIDhlypR+j1u5ciVuuukmvPLKK2hqakp7rdtvvx27d+/GVVddhccee6zf/Q8//DAWLlyIN954A2PHjsVZZ52FBQsWwOf7KD6GQiE0NjYO+f1mixVUIiIiomH2j3/8A5s2bYJpmjjwwAPR1NSEz3zmM1i9evWAz+np6cGjjz6KqVOnYt68eRgzZgxmz57db/q+s7MTp512Gv7nf/5nwPD4xhtv4Nprr8WvfvUrmGb/OPjcc8/hq1/9Ki666CK88cYbuPPOO3HPPffg+uuvT3ncsmXLUFdXh4997GO44oor0NnZmf2LkQYDKhEREdEw+/e//w0AuOaaa/Df//3feOSRR1BdXY0jjjgCO3fuTPucbdu2ob29HTfeeCOOPfZYPPHEEzjhhBNw4okn4tlnn/Ued8kll+CQQw7B8ccfn/Y6kUgEp556KpYsWYKWlpa0j1m4cCHmz5+Ps846C5MnT8anP/1pfP/738edd97pPea0007Db37zGzz99NO44oor8Otf/xpnnHFGri9JKpeIiIiItLj++uvdcDjsfZim6fr9/pTb3n33XXfZsmUuAPfOO+/0ntvd3e3W1dW5d9xxR9prb9q0yQXgnnrqqSm3f/7zn3e/8pWvuK7rug8//LC71157uW1tbd79ANyHHnrI+/ySSy5xTznlFO/zp59+2gXg7tq1y7utrq7ODQQCKeMOBAIuALejoyPt+J566ikXgLt+/fqMX6+BcA0qERERkSYXXHABvvzlL3ufn3766TjppJNw4oknereNHTvWWxeavObU7/dj8uTJaXfkA0BdXR18Pl+/dar77LMPnn/+eQDA8uXL8fbbb6OqqirlMSeddBIOP/xwPPPMM1i+fDlef/11PPDAAwB6d/ur6y9YsAALFy5Ee3s7Fi5cmDJuJd2GLACYPXs2AGD9+vVp18VmgwGViIiISJOamhrU1NR4nweDQYwZMwZ77bVXyuNmzZoFv9+PdevW4bDDDgMAxGIxvPPOO5gwYULaazuOg4MPPhjr1q1Luf1f//qX95z58+fj61//esr9+++/P370ox/h85//PIDe7gFdXV3e/S+//DLOOeccPPfcc16wnDlzJtatW9dv3INZuXIlAAy4KSsbDKhEREREw6yiogIXXHABrr76ajQ3N2PChAlYsmQJAODkk0/2Hjd9+nQsWrQIJ5xwAgDgO9/5Dk455RTMnTsXRx55JP7yl7/gT3/6E5555hkAQGNjY9qNUS0tLZg0aRKA/rv+d+zYAaC3Eqsqr1dddRU+97nPoaWlBV/60pdgmib++c9/YvXq1bjuuuvw9ttv495778VnP/tZ1NbWYtWqVbjkkkswd+7cvNpWKQyoRERERCNgyZIl8Pl8OPPMM9HV1YXZs2dj+fLlqK6u9h6zbt067Nmzx/v8hBNOwB133IFFixbh29/+NqZNm4YHH3zQq8LqMm/ePDzyyCO49tpr8YMf/AC2bWP69OleddZxHPz1r3/FLbfcgo6ODjQ3N+Okk07Cf//3f2v5+oarFh4QEREREQnANlNEREREJAoDKhERERGJwoBKRERERKIwoBIRERGRKAyoRERERCQKAyoRERERicKASkRERESiMKASERERkSgMqEREREQkCgMqEREREYnCgEpEREREojCgEhEREZEo/x+QVd/ZhVRGkgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "if lane is not None and lane.lane_group is not None:\n", " lane_group: LaneGroup = lane.lane_group\n", @@ -437,10 +535,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "23", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAuQAAAMJCAYAAABLCSONAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAnHxJREFUeJzs3Xd4VFX+x/H3nZn0SkIglNC7gkhRWZoCChYEQbHgYm+IBRARfyogCIgFu7grll11ragollUUFRZpAtKM9F4T0tuU+/vjJiOBAAmE3EnyeT3PPMnce+fOmUky+cyZ7znHME3TREREREREbOGwuwEiIiIiItWZArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFpNp56623MAyDZcuW2d2UMsvPz+fFF1+kW7du1KhRg+DgYOrWrcvll1/Of/7zH7xer91NPCl//PEHDz74IO3btycqKoo6depw6aWXlvgz+vTTT+nbty9169YlJCSE+vXrc+WVV7JmzZqjjv3ggw+4/vrrad68OYZhcP7551fAoxERKRuX3Q0QEZHSOXDgABdffDHLly+nb9++PPLII8TFxbF3716+//57rrvuOjZu3Mijjz5qd1PL7PXXX2fWrFkMHjyY4cOHk56ezmuvvcZ5553HN998Q58+ffzHrl69mho1anDfffdRs2ZN9u7dyxtvvME555zDokWLOOuss/zHvvrqqyxfvpzOnTuTkpJix0MTETkhwzRN0+5GiIhUpLfeeoubbrqJpUuX0qlTJ7ubU2r9+vXju+++46OPPmLQoEFH7V+2bBnJyckMHTr0mOfIy8sjODgYhyOwPiBdvnw5LVu2JDIy0r8tJSWF1q1b06JFCxYsWHDc2+/bt4/69etzyy23MHPmTP/2HTt2UK9ePRwOB2eeeSY1a9Zk/vz5p+thiIiclMB6RRYRCRAFBQU89thjdOzYkZiYGCIiIujevTs//vhjseO2bt2KYRg8/fTT/OMf/6Bp06aEhITQuXNnli5detR5//jjD6688kri4uIIDQ2lU6dOzJkz54TtWbRoEd9++y233357iWEcoFOnTsXC+Pz58zEMg/fff59HHnmEevXqER4eTkZGBgAfffQRHTt2JCwsjJo1a3L99deza9euYuc8//zzSyzzuPHGG2nUqFGJz8OMGTNo2LAhYWFh9OzZs8RSkiN17NixWBgHiI+Pp3v37qxfv/6Et69Vqxbh4eGkpaUV256UlBRwbz5ERI6kkhURkRJkZGTw+uuvc+2113LbbbeRmZnJrFmz6Nu3L0uWLKF9+/bFjn/vvffIzMzkjjvuwDAMpk+fzqBBg9i8eTNBQUEArF27lq5du1KvXj0eeughIiIi+PDDDxk4cCCffPIJV1xxxTHb88UXXwBw/fXXl/mxTJo0ieDgYB544AHy8/MJDg72f0rQuXNnpk6dyr59+3j++edZuHAhK1asIDY2tsz3A/Cvf/2LzMxM7r77bvLy8nj++efp1asXq1evpnbt2mU+3969e6lZs2aJ+9LS0nC73ezdu5fnnnuOjIwMevfufVLtFhGxkwK5iEgJatSowdatWwkODvZvu+2222jVqhUvvvgis2bNKnb89u3b2bBhAzVq1ACgZcuWDBgwgG+//ZbLLrsMgPvuu48GDRqwdOlSQkJCABg+fDjdunVj7Nixxw3kf/zxBwBnnnlmse15eXlkZWX5r7tcrqPCdF5eHsuWLSMsLAwAt9vN2LFjOfPMM/n5558JDQ0FoFu3blx22WXMmDGDiRMnlvq5OtzGjRvZsGED9erVA6wym3PPPZcnn3ySZ599tkzn+uWXX1i0aBGPPPJIifvPO+88kpOTAYiMjOSRRx7hlltuOal2i4jYSZ/jiYiUwOl0+sO4z+cjNTUVj8dDp06d+O233446/uqrr/aHcYDu3bsDsHnzZgBSU1P54YcfGDJkCJmZmRw8eJCDBw+SkpJC37592bBhw1HlIocrKjM5sqxj5syZJCQk+C/dunU76rY33HCDP4yDVWu+f/9+hg8f7g/jAJdeeimtWrVi7ty5J3x+jmXgwIH+MA5wzjnncO655/LVV1+V6Tz79+/nuuuuo3Hjxjz44IMlHvPmm2/yzTff8Morr9C6dWtyc3Mr7SwzIlK9VZlAfvnll9OgQQNCQ0OpU6cOf//739m9e/dxb7Np0yauuOIKEhISiI6OZsiQIezbt6/YMb/99hsXXnghsbGxxMfHc/vttxfrjQJYunQpvXv3JjY2lho1atC3b19WrVpV5sewfv16Lr/8cn+9aufOndm+fXuZzyMi5ePtt9+mXbt2hIaGEh8fT0JCAnPnziU9Pf2oYxs0aFDselE4P3ToEGD1HJumyaOPPlosQCckJDB+/HjACqHHEhUVBXDU68/gwYP57rvv+O6772jXrl2Jt23cuHGx69u2bQOsXvwjtWrVyr//ZDRv3vyobS1atGDr1q2lPkd2djaXXXYZmZmZfP7550e9CSnSpUsX+vbty1133cW3337LO++8w7hx40626SIitqlUgfz888/nrbfeKnHfBRdcwIcffkhycjKffPIJmzZt4sorrzzmubKzs7noooswDIMffviBhQsXUlBQQP/+/fH5fADs3r2bPn360KxZMxYvXsw333zD2rVrufHGG/3nycrKol+/fjRo0IDFixezYMECoqKi6Nu3L263u9SPbdOmTXTr1o1WrVoxf/58fv/9dx599NFivVciUnHeeecdbrzxRpo2bcqsWbP45ptv+O677+jVq5f/NeJwTqezxPMUTWRVdJsHHnjAH6CPvDRr1uyY7WnVqhXAUQMkk5KS6NOnD3369CnWQ3+4w3vHy8owjBK3n66e6IKCAgYNGsTvv//O559/flSJzrHUqFGDXr168e67756WdomInE5VpoZ85MiR/u8bNmzIQw89xMCBA3G73f4BVYdbuHAhW7duZcWKFURHRwNWb1iNGjX44Ycf6NOnD19++SVBQUG8/PLL/lH6M2fOpF27dmzcuJFmzZrxxx9/kJqayuOPP05SUhIA48ePp127dmzbts3/D3bBggWMGzeOZcuWUbNmTa644gqmTp1KREQEAP/3f//HJZdcwvTp0/1tbNq06el5skTkhD7++GOaNGnC7Nmzi4XSot7ssmrSpAkAQUFBxebULq3LLruMadOm8e6779K1a9eTakORhg0bApCcnEyvXr2K7UtOTvbvByvoFpXdHO5YvegbNmw4atuff/5ZbEaWY/H5fAwbNox58+bx4Ycf0rNnzxPe5nC5ubklfnohIhLoKlUPeWmlpqby7rvv8re//a3EMA7WaneGYfgHVgGEhobicDj8890WzUZw+JRZRT1NRce0bNmS+Ph4Zs2aRUFBAbm5ucyaNYvWrVv7/wFt2rSJfv36MXjwYH7//Xc++OADFixYwIgRIwDrn9DcuXNp0aIFffv2pVatWpx77rl89tln5f3UiEgpFfV4H75Uw+LFi1m0aNFJna9WrVqcf/75vPbaa+zZs+eo/QcOHDju7bt27cqFF17IP/7xDz7//PMSjyntshKdOnWiVq1azJw5k/z8fP/2r7/+mvXr13PppZf6tzVt2pQ//vijWPtWrVrFwoULSzz3Z599VqwWfsmSJSxevJiLL774hO265557+OCDD3jllVeOObUjlFzas3XrVubNm1ep5pUXESlSZXrIAcaOHctLL71ETk4O5513Hl9++eUxjz3vvPOIiIhg7NixTJkyBdM0eeihh/B6vf5/lr169WLUqFE89dRT3HfffWRnZ/PQQw8B+I+Jiopi/vz5DBw4kEmTJgFWDeW3336Ly2U9vVOnTmXo0KHcf//9/v0vvPACPXv25NVXXyUtLY2srCymTZvG5MmTefLJJ/nmm28YNGgQP/74Y5l7iUSkdN544w2++eabo7bfd999XHbZZcyePZsrrriCSy+9lC1btjBz5kzatGlzVB13ab388st069aNtm3bctttt9GkSRP27dvHokWL2Llz5wnHnrzzzjv069ePgQMHcvHFF/vLVIpW6vz5559LFXyDgoJ48sknuemmm+jZsyfXXnutf9rDRo0aFfvE8eabb+bZZ5+lb9++3HLLLezfv5+ZM2dyxhln+AeaHq5Zs2Z069aNu+66i/z8fJ577jni4+OPOTCzyHPPPccrr7xCly5dCA8P55133im2/4orrvB/oti2bVt69+5N+/btqVGjBhs2bGDWrFm43W6mTZtW7HY///wzP//8M2C96cnOzmby5MkA9OjRgx49epzw+RIROe3MAPbEE0+YERER/ovD4TBDQkKKbdu2bZv/+AMHDpjJycnmf//7X7Nr167mJZdcYvp8vmOe/9tvvzWbNGliGoZhOp1O8/rrrzc7dOhg3nnnnf5j3n33XbN27dqm0+k0g4ODzQceeMCsXbu2OW3aNNM0TTMnJ8c855xzzGHDhplLliwxFy1aZA4ePNg844wzzJycHNM0TbNTp05mcHBwsXaHh4ebgLlu3Tpz165dJmBee+21xdrXv39/85prrinPp1RETNN88803TeCYlx07dpg+n8+cMmWK2bBhQzMkJMQ8++yzzS+//NK84YYbzIYNG/rPtWXLFhMwn3rqqaPuBzDHjx9fbNumTZvMYcOGmYmJiWZQUJBZr14987LLLjM//vjjUrU9NzfXfO6558wuXbqY0dHRpsvlMhMTE83LLrvMfPfdd02Px+M/9scffzQB86OPPirxXB988IF59tlnmyEhIWZcXJw5dOhQc+fOnUcd984775hNmjQxg4ODzfbt25vffvvtcZ+HZ555xkxKSjJDQkLM7t27m6tWrTrh47rhhhuO+zPZsmWL/9jx48ebnTp1MmvUqGG6XC6zbt265jXXXGP+/vvvR513/PjxxzznkT8bERG7GKZZys84bZCamkpqaqr/+tChQxk8eHCxjzIbNWrk74k+3M6dO0lKSuJ///sfXbp0Oe79HDx40D93b2JiIqNHj2bMmDHFjtm3bx8REREYhkF0dDTvv/8+V111FbNmzeLhhx9mz549/tKWgoICatSowaxZs7jmmmto3bo1F154Iffee+9R9100M0NERATjx48vNt/u2LFjWbBgwTE/GhYRCRRbt26lcePGPPXUUzzwwAN2N0dEpFIJ6JKVuLg44uLi/NfDwsKoVavWcWciKFI0o8Hh9ZHHUrQK3A8//MD+/fu5/PLLjzqmaIW5N954g9DQUC688EIAcnJycDgcxQZ9FV0vakOHDh1Yt27dcdvduXNn/wIXRf78889ig6tEREREpOqpEoM6Fy9ezEsvvcTKlSvZtm0bP/zwA9deey1Nmzb1947v2rWLVq1asWTJEv/t3nzzTX799Vc2bdrEO++8w1VXXcXIkSOLzc370ksv8dtvv/Hnn3/y8ssvM2LECKZOnepfCe/CCy/k0KFD3H333axfv561a9dy00034XK5uOCCCwCrp/t///sfI0aMYOXKlWzYsIHPP//cP6gTYMyYMXzwwQf885//ZOPGjbz00kt88cUXDB8+vAKeQRERERGxS0D3kJdWeHg4s2fPZvz48WRnZ1OnTh369evHI4884p9Fxe12k5ycTE5Ojv92ycnJjBs3jtTUVBo1asT//d//FRvMBNYMAePHjycrK4tWrVrx2muv8fe//92/v1WrVnzxxRdMnDiRLl264HA4OPvss/nmm2+oU6cOAO3ateOnn37i//7v/+jevTumadK0aVOuvvpq/3muuOIKZs6cydSpU7n33ntp2bIln3zySYmr7omIiIhI1RHQNeQiIiIiIlVdmUtWdu3axfXXX098fDxhYWG0bduWZcuW+febpsljjz1GnTp1CAsLo0+fPiUuFCEiIiIiImUM5IcOHaJr164EBQXx9ddfs27dOp555pliyzVPnz6dF154gZkzZ7J48WIiIiLo27cveXl55d54EREREZHKrkwlKw899BALFy7kl19+KXG/aZrUrVuX0aNH+6e9Sk9Pp3bt2rz11ltcc801J7wPn8/H7t27iYqKKjZziYiIiIhIZWGaJpmZmdStW7fYqu8lKVMgb9OmDX379mXnzp389NNP1KtXj+HDh3PbbbcBsHnzZpo2bcqKFSto3769/3Y9e/akffv2PP/880edMz8/v9jUhLt27aJNmzalbZKIiIiISMDasWMH9evXP+4xZZplZfPmzbz66quMGjWKhx9+mKVLl3LvvfcSHBzMDTfcwN69e4G/5uwuUrt2bf++I02dOpWJEyeW2Pjo6OiyNE9EREREJCBkZGSQlJREVFTUCY8tUyD3+Xx06tSJKVOmAHD22WezZs0aZs6cyQ033HBSjR03bhyjRo3yXy9qfHR0tAK5iIiIiFRqpSnBLtOgzjp16hxVTtK6dWu2b98OQGJiImAtM3+4ffv2+fcdKSQkxB++FcJFREREpLopUyDv2rXrcZd3b9y4MYmJicybN8+/PyMjg8WLF/tXzBQRERERkb+UqWRl5MiR/O1vf2PKlCkMGTKEJUuW8I9//IN//OMfgNUlf//99zN58mSaN29O48aNefTRR6lbty4DBw48He0XEREREanUyhTIO3fuzKeffsq4ceN4/PHHady4Mc899xxDhw71H/Pggw+SnZ3N7bffTlpaGt26deObb74hNDS03BsvIiISCLxeL2632+5miEgFCw4OPuGUhqVRpmkPK0JGRgYxMTGkp6ernlxERAKaaZrs3buXtLQ0u5siIjZwOBw0btyY4ODgo/aVJdOWqYdcRERE/lIUxmvVqkV4eLgWtBOpRooWs9yzZw8NGjQ4pb9/BXIREZGT4PV6/WE8Pj7e7uaIiA0SEhLYvXs3Ho+HoKCgkz7PqRe9iIiIVENFNePh4eE2t0RE7FJUquL1ek/pPArkIiIip0BlKiLVV3n9/SuQi4iIiIjYSDXkIiIi5cjtdp/yx9dl4XQ6T6l2VUTsp0AuIiJSTtxuN8nJyeTm5lbYfYaFhdGyZctSh/Ibb7yRtLQ0Pvvss9PbsJOwd+9epk6dyty5c9m5cycxMTE0a9aM66+/nhtuuCEg6/VTU1MZP348//3vf9m+fTsJCQkMHDiQSZMmERMTA0BKSgpDhw7l999/JyUlhVq1ajFgwACmTJninw5vz549jB49mmXLlrFx40buvfdennvuORsfmVQkBXIREZFy4vV6yc3NxeVy4XKd/n+xHo+H3NxcvF5vpe8l37x5M127diU2NpYpU6bQtm1bQkJCWL16Nf/4xz+oV68el19+eYm3dbvdtj3+3bt3s3v3bp5++mnatGnDtm3buPPOO9m9ezcff/wxYM1VPWDAACZPnkxCQgIbN27k7rvvJjU1lffeew+A/Px8EhISeOSRR5gxY4Ytj0XsoxpyERGRcuZyuQgODj7tl9MR+p999lnatm1LREQESUlJDB8+nKysLP/+t956i9jYWL799ltat25NZGQk/fr1Y8+ePcXO8/rrr9O6dWtCQ0Np1aoVr7zyynHvd/jw4bhcLpYtW8aQIUNo3bo1TZo0YcCAAcydO5f+/fv7jzUMg1dffZXLL7+ciIgInnjiCQBeffVVmjZtSnBwMC1btuTf//63/zZbt27FMAxWrlzp35aWloZhGMyfPx+A+fPnYxgGc+fOpV27doSGhnLeeeexZs2aY7b7zDPP5JNPPqF///40bdqUXr168cQTT/DFF1/g8XgAqFGjBnfddRedOnWiYcOG9O7dm+HDh/PLL7/4z9OoUSOef/55hg0b5u9Zl+pDgVxERET8HA4HL7zwAmvXruXtt9/mhx9+4MEHHyx2TE5ODk8//TT//ve/+fnnn9m+fTsPPPCAf/+7777LY489xhNPPMH69euZMmUKjz76KG+//XaJ95mSksJ///tf7r77biIiIko85sjZLCZMmMAVV1zB6tWrufnmm/n000+57777GD16NGvWrOGOO+7gpptu4scffyzzczBmzBieeeYZli5dSkJCAv379/dPc1kaRSszHusN0+7du5k9ezY9e/Ysc9ukalIgFxEREb/777+fCy64gEaNGtGrVy8mT57Mhx9+WOwYt9vNzJkz6dSpEx06dGDEiBHMmzfPv3/8+PE888wzDBo0iMaNGzNo0CBGjhzJa6+9VuJ9bty4EdM0admyZbHtNWvWJDIyksjISMaOHVts33XXXcdNN91EkyZNaNCgAU8//TQ33ngjw4cPp0WLFowaNYpBgwbx9NNPl/k5GD9+PBdeeCFt27bl7bffZt++fXz66aeluu3BgweZNGkSt99++1H7rr32WsLDw6lXrx7R0dG8/vrrZW6bVE0K5CIiIuL3/fff07t3b+rVq0dUVBR///vfSUlJIScnx39MeHg4TZs29V+vU6cO+/fvByA7O5tNmzZxyy23+MN0ZGQkkydPZtOmTWVqy5IlS1i5ciVnnHEG+fn5xfZ16tSp2PX169fTtWvXYtu6du3K+vXry3SfAF26dPF/HxcXR8uWLUt1noyMDC699FLatGnDhAkTjto/Y8YMfvvtNz7//HM2bdrEqFGjytw2qZo0qFNEREQAq876sssu46677uKJJ54gLi6OBQsWcMstt1BQUOCf5eTIAZSGYWCaJoC/3vyf//wn5557brHjnE5niffbrFkzDMMgOTm52PYmTZoA1kwyRzpWacuxOBxWH2RRO4EylaGcSGZmJv369SMqKopPP/20xEGmiYmJJCYm0qpVK+Li4ujevTuPPvooderUKbd2SOWkHnIREREBYPny5fh8Pp555hnOO+88WrRowe7du8t0jtq1a1O3bl02b95Ms2bNil0aN25c4m3i4+O58MILeemll8jOzj6ptrdu3ZqFCxcW27Zw4ULatGkDQEJCAkCxwaeHD/A83K+//ur//tChQ/z555+0bt36mPedkZHBRRddRHBwMHPmzCE0NPSE7fX5fABH9fxL9aQechERkWomPT39qDAaHx9Ps2bNcLvdvPjii/Tv35+FCxcyc+bMMp9/4sSJ3HvvvcTExNCvXz/y8/NZtmwZhw4dOmaZxiuvvELXrl3p1KkTEyZMoF27djgcDpYuXcoff/xBx44dj3ufY8aMYciQIZx99tn06dOHL774gtmzZ/P9998DVi/7eeedx7Rp02jcuDH79+/nkUceKfFcjz/+OPHx8dSuXZv/+7//o2bNmgwcOLDEY4vCeE5ODu+88w4ZGRlkZGQA1psAp9PJV199xb59++jcuTORkZGsXbuWMWPG0LVrVxo1auQ/V9HPJCsriwMHDrBy5UqCg4P9byqk6lIgFxERKWdF090F6v3Mnz+fs88+u9i2W265hddff51nn32WJ598knHjxtGjRw+mTp3KsGHDynT+W2+9lfDwcJ566inGjBlDREQEbdu25f777z/mbZo2bcqKFSuYMmUK48aNY+fOnYSEhNCmTRseeOABhg8fftz7HDhwIM8//zxPP/009913H40bN+bNN9/k/PPP9x/zxhtvcMstt9CxY0datmzJ9OnTueiii44617Rp07jvvvvYsGED7du354svviA4OLjE+/3tt99YvHgxYJXeHG7Lli00atSIsLAw/vnPfzJy5Ejy8/NJSkpi0KBBPPTQQ8WOP/xnsnz5ct577z0aNmzI1q1bj/vYpfIzzMOLqQJARkYGMTEx/imDREREAlFeXh5btmyhcePG/hKFyrBSpxzb/PnzueCCCzh06BCxsbF2N0cqgZJeB4qUJdOqh1xERKScBAUF0bJlS7xeb4Xdp9PpVBgXqeQUyEVERMpRUFCQArKIlIkCuYiIiAhw/vnnE2CVvFJNaNpDEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNNO2hiIhIefLkgq+g4u7PEQyusIq7vzK48cYbSUtL47PPPrO7KSIBTYFcRESkvHhyYefn4D5UcfcZVAPqDyh1KL/xxht5++23rZsGBdGgQQOGDRvGww8/jMulWHCqtm7dSuPGjVmxYgXt27e3uzlH+eSTT3j55ZdZsWIFeXl5NGjQgK5du3LPPfdw9tln2928Eu3Zs4fRo0ezbNkyNm7cyL333stzzz1X7Jjzzz+fn3766ajbXnLJJcydOxeA2bNnM3PmTJYvX05qamqJP6OSznPHHXcwc+bMcn1MR1LJioiISHnxFVhh3BFmBeXTfXGEWfdXxh75fv36sWfPHjZs2MDo0aOZMGECTz31VInHFhRUYG9/JeL1evH5fHY3o0zGjh3L1VdfTfv27ZkzZw7Jycm89957NGnShHHjxh3zdnb/DuTn55OQkMAjjzzCWWedVeIxs2fPZs+ePf7LmjVrcDqdXHXVVf5jsrOz6datG08++eRx7++2224rdq7p06eX6+MpiQK5iIhIeXOGgivi9F+coSfVvJCQEBITE2nYsCF33XUXffr0Yc6cOYDVgz5w4ECeeOIJ6tatS8uWLQHYsWMHQ4YMITY2lri4OAYMGMDWrVv95/R6vYwaNYrY2Fji4+N58MEHj1r10ufzMXXqVBo3bkxYWBhnnXUWH3/8cbFj1q5dy2WXXUZ0dDRRUVF0796dTZs2+fe//vrrtG7dmtDQUFq1asUrr7zi31dQUMCIESOoU6cOoaGhNGzYkKlTpwJgmiYTJkygQYMGhISEULduXe69917/bQ8dOsSwYcOoUaMG4eHhXHzxxWzYsMG//6233iI2NpY5c+bQpk0bQkJC2L59e5mf+02bNjFgwABq165NZGQknTt35vvvvy92TKNGjZgyZQo333wzUVFRNGjQgH/84x/FjjnRz+NIv/76K9OnT+fZZ5/l2WefpXv37jRo0ICOHTvyyCOP8PXXX/uPnTBhAu3bt+f111+ncePGhIZav2fbt29nwIABREZGEh0dzZAhQ9i3b5//dkW/O4e7//77Of/88/3Xzz//fEaMGMGIESOIiYmhZs2aPProo8ddIbVRo0Y8//zzDBs2jJiYmBKPiYuLIzEx0X/57rvvCA8PLxbI//73v/PYY4/Rp0+fY94XQHh4eLFzRUdHH/f48qBALiIiUs2FhYUV6wWdN28eycnJfPfdd3z55Ze43W769u1LVFQUv/zyCwsXLiQyMpJ+/fr5b/fMM8/w1ltv8cYbb7BgwQJSU1P59NNPi93P1KlT+de//sXMmTNZu3YtI0eO5Prrr/eXCOzatYsePXoQEhLCDz/8wPLly7n55pvxeDwAvPvuuzz22GM88cQTrF+/nilTpvDoo4/6S3BeeOEF5syZw4cffkhycjLvvvsujRo1AqxSjRkzZvDaa6+xYcMGPvvsM9q2betv24033siyZcuYM2cOixYtwjRNLrnkEtxut/+YnJwcnnzySV5//XXWrl1LrVq1yvxcZ2VlcckllzBv3jxWrFhBv3796N+//1Hh/plnnqFTp06sWLGC4cOHc9ddd5GcnAxQqp/Hkf7zn/8QGRnJ8OHDS9xvGEax6xs3buSTTz5h9uzZrFy5Ep/Px4ABA0hNTeWnn37iu+++Y/PmzVx99dVlfg7efvttXC4XS5Ys4fnnn+fZZ5/l9ddfL/N5jmfWrFlcc801RERElPm27777LjVr1uTMM89k3Lhx5OTklGvbSqJiMRERkWrKNE3mzZvHt99+yz333OPfHhERweuvv05wcDAA77zzDj6fj9dff90f3N58801iY2OZP38+F110Ec899xzjxo1j0KBBAMycOZNvv/3Wf878/HymTJnC999/T5cuXQBo0qQJCxYs4LXXXqNnz568/PLLxMTE8P777xMUFARAixYt/OcYP348zzzzjP8+GjduzLp163jttde44YYb2L59O82bN6dbt24YhkHDhg39t92+fTuJiYn06dPHXzt/zjnnALBhwwbmzJnDwoUL+dvf/gZYoSwpKYnPPvvM38vqdrt55ZVXjlk2URpnnXVWsdtPmjSJTz/9lDlz5jBixAj/9ksuucQfnseOHcuMGTP48ccfadmyJR988MEJfx5H+vPPP2nSpEmxcQLPPvssjz32mP/6rl27/D3QBQUF/Otf/yIhIQGA7777jtWrV7NlyxaSkpIA+Ne//sUZZ5zB0qVL6dy5c6mfg6SkJGbMmIFhGLRs2ZLVq1czY8YMbrvttlKf43iWLFnCmjVrmDVrVplve91119GwYUPq1q3L77//ztixY0lOTmb27Nnl0rZjUSAXERGpZr788ksiIyNxu934fD6uu+46JkyY4N/ftm1bfxgHWLVqFRs3biQqKqrYefLy8ti0aRPp6ens2bOHc88917/P5XLRqVMnfynCxo0bycnJ4cILLyx2joKCAv9gwpUrV9K9e3d/GD9cdnY2mzZt4pZbbikW3Dwejz9E3njjjVx44YW0bNmSfv36cdlll/nD6VVXXcVzzz1HkyZN6NevH5dccgn9+/fH5XKxfv16XC5XsfbHx8fTsmVL1q9f798WHBxMu3btSvckH0NWVhYTJkxg7ty57NmzB4/HQ25u7lE95Iffj2EYJCYmsn//fuDEP4/Suvnmm7n88stZvHgx119/fbGykYYNG/rDOMD69etJSkryh3GANm3aEBsby/r168sUyM8777xiPfJdunThmWeewev14nQ6S32eY5k1axZt27b1v+Eqi9tvv93/fdu2balTpw69e/dm06ZNNG3a9JTbdiwK5CIiItXMBRdcwKuvvkpwcDB169Y9anaVIz/mz8rKomPHjrz77rtHnevw0HY8WVlZAMydO5d69eoV2xcSEgJYpTMnuv0///nPYsEZ8Ie4Dh06sGXLFr7++mu+//57hgwZQp8+ffj4449JSkoiOTmZ77//nu+++47hw4fz1FNPlTgzx7GEhYUdVdpRVg888ADfffcdTz/9NM2aNSMsLIwrr7zyqFKTI9+UGIbhH0R6Mj+P5s2bs2DBAtxut//csbGxxMbGsnPnzqOOP5lSD4fDcVQt+OElPxUhOzub999/n8cff7xczlf0u7Zx40YFchERESk/ERERNGvWrNTHd+jQgQ8++IBatWodc4BbnTp1WLx4MT169ACsnuvly5fToUMHgGIDIXv27FniOdq1a8fbb79dLDQWqV27NnXr1mXz5s0MHTr0mG2Njo7m6quv5uqrr+bKK6+kX79+pKamEhcXR1hYGP3796d///7cfffdtGrVitWrV9O6dWs8Hg+LFy/2l6ykpKSQnJxMmzZtSv08lcbChQu58cYbueKKKwArXB9vMGZJSvPzONK1117Liy++yCuvvMJ9991X1mbTunVrduzYwY4dO/y95OvWrSMtLc3/HCUkJLBmzZpit1u5cuVRP8vFixcXu/7rr7/SvHnzcukd/+ijj8jPz+f6668/5XOB1X6wfr9PJwVyEREROa6hQ4fy1FNPMWDAAB5//HHq16/Ptm3bmD17Ng8++CD169fnvvvuY9q0aTRv3pxWrVrx7LPPkpaW5j9HVFQUDzzwACNHjsTn89GtWzfS09NZuHAh0dHR3HDDDYwYMYIXX3yRa665hnHjxhETE8Ovv/7KOeecQ8uWLZk4cSL33nsvMTEx9OvXj/z8fJYtW8ahQ4cYNWoUzz77LHXq1OHss8/G4XDw0UcfkZiYSGxsLG+99RZer5dzzz2X8PBw3nnnHcLCwmjYsCHx8fEMGDCA2267jddee42oqCgeeugh6tWrx4ABA07qOSsagHm4M844g+bNmzN79mz69++PYRg8+uijZZ4+sTQ/jyN16dKF0aNHM3r0aLZt28agQYNISkpiz549zJo1C8MwcDiOPddHnz59aNu2LUOHDuW5557D4/EwfPhwevbsSadOnQDo1asXTz31FP/617/o0qUL77zzDmvWrDlqfvPt27czatQo7rjjDn777TdefPFFnnnmmeM+5qJgnJWVxYEDB1i5ciXBwcFHvWGaNWsWAwcOJD4+/qhzpKamsn37dnbv3g389TMqmk1l06ZNvPfee1xyySXEx8fz+++/M3LkSHr06HHKpUonokAuIiJS3rx5Vep+wsPD+fnnnxk7diyDBg0iMzOTevXq0bt3b38P7ejRo9mzZw833HADDoeDm2++mSuuuIL09HT/eSZNmkRCQgJTp05l8+bNxMbG0qFDBx5++GHAqtv+4YcfGDNmDD179sTpdNK+fXu6du0KwK233kp4eDhPPfUUY8aMISIigrZt23L//fcDVuifPn06GzZswOl00rlzZ7766iscDgexsbFMmzaNUaNG4fV6adu2LV988YU/uL355pvcd999XHbZZRQUFNCjRw+++uqrEuvZS+Oaa645atuOHTt49tlnufnmm/nb3/5GzZo1GTt2LBkZGWU6d2l+HiV5+umnOeecc3j11Vd54403yMnJoXbt2vTo0YNFixYd97aGYfD5559zzz330KNHDxwOB/369ePFF1/0H9O3b18effRRHnzwQfLy8rj55psZNmwYq1evLnauYcOGkZubyznnnIPT6eS+++4rVrtdksND/fLly3nvvfdo2LBhsU8XkpOTWbBgAf/9739LPMecOXO46aab/NeLfkbjx49nwoQJBAcH8/333/Pcc8+RnZ1NUlISgwcP5pFHHjlu28qDYR5v4kcbZGRkEBMTQ3p6eoXM+ygiInIy8vLy2LJlS7F5mivDSp0idjr//PNp3779USttVlYlvg4UKkumVQ+5iIhIeXGFWeG4jCtnnhJHsMK4SCWnQC4iIlKeXGGAArKIlJ4CuYiIiIhUiPnz59vdhIB07OG0IiIiIiJy2imQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjTTtoYiISDnKzYWCClwXKDgYwjTtuUilpkAuIiJSTnJz4fPP4dChirvPGjVgwIDSh/IDBw7w2GOPMXfuXPbt20eNGjU466yzeOyxx+jatevpbWwlMmHCBD777DNWrlxpd1OOkpGRwVNPPcXs2bPZvHkz4eHhNGnShKuuuorbbruNGjVq2N3EEs2ePZuZM2eyfPlyUlNTWbFiBe3bty92zB133MH333/P7t27iYyM5G9/+xtPPvkkrVq1AiAlJYWhQ4fy+++/k5KSQq1atRgwYABTpkwptjx9fn4+jz/+OO+88w579+6lTp06PPbYY9x8880V+ZBLTYFcRESknBQUWGE8LAxCQ0///eXlWfdXUFD6QD548GAKCgp4++23adKkCfv27WPevHmkpKSc3sYGqIKCAoKDg+1uRqmlpqbSrVs3MjIymDRpEh07diQmJobk5GTefPNN3nvvPe6+++4Sb2v3Y83OzqZbt24MGTKE2267rcRjOnbsyNChQ2nQoAGpqalMmDCBiy66iC1btuB0OnE4HAwYMIDJkyeTkJDAxo0bufvuu0lNTeW9997zn2fIkCHs27ePWbNm0axZM/bs2YPP56uoh1pmqiEXEREpZ6GhEBFx+i9lDf1paWn88ssvPPnkk1xwwQU0bNiQc845h3HjxnH55ZcDsHXrVgzDKNYznJaWhmEYxVZZXLt2LZdddhnR0dFERUXRvXt3Nm3a5N//xhtvcMYZZxASEkKdOnUYMWJEsfPdeuutJCQkEB0dTa9evVi1apV//6pVq7jggguIiooiOjqajh07smzZMgC2bdtG//79qVGjBhEREZxxxhl89dVX/tv+9NNPnHPOOf77feihh/B4PP79559/PiNGjOD++++nZs2a9O3bt2xPYqF///vfdOrUiaioKBITE7nuuuvYv3+/f//8+fMxDIN58+bRqVMnwsPD+dvf/kZycnKx83z++ed06NCB0NBQmjRpwsSJE4u190gPP/ww27dvZ8mSJdx00020a9eOhg0bctFFF/Gf//yH4cOH+49t1KgRkyZNYtiwYURHR3P77bcD8Mknn/h/No0aNeKZZ54pdh+GYfDZZ58V2xYbG8tbb70F/PU78v777/O3v/2N0NBQzjzzTH766afjPmd///vfeeyxx+jTp88xj7n99tvp0aMHjRo1okOHDkyePJkdO3awdetWAGrUqMFdd91Fp06daNiwIb1792b48OH88ssv/nN88803/PTTT3z11Vf06dOHRo0a0aVLl4D+BEiBXEREpJqIjIwkMjKSzz77jPz8/JM+z65du+jRowchISH88MMPLF++nJtvvtkfJF999VXuvvtubr/9dlavXs2cOXNo1qyZ//ZXXXUV+/fv5+uvv2b58uV06NCB3r17k5qaCsDQoUOpX78+S5cuZfny5Tz00EMEBQUBcPfdd5Ofn8/PP//M6tWrefLJJ4mMjPS365JLLqFz586sWrWKV199lVmzZjF58uRi7X/77bcJDg5m4cKFzJw586SeA7fbzaRJk1i1ahWfffYZW7du5cYbbzzquP/7v//jmWeeYdmyZbhcrmIlE7/88gvDhg3jvvvuY926dbz22mu89dZbPPHEEyXep8/n44MPPuD666+nbt26JR5jGEax608//TRnnXUWK1as4NFHH2X58uUMGTKEa665htWrVzNhwgQeffRRf9guizFjxjB69GhWrFhBly5d6N+/f7l+0pKdnc2bb75J48aNSUpKKvGY3bt3M3v2bHr27OnfNmfOHDp16sT06dOpV68eLVq04IEHHiA3N7fc2lbeVLIiIiJSTbhcLt566y1uu+02Zs6cSYcOHejZsyfXXHMN7dq1K/V5Xn75ZWJiYnj//ff9QblFixb+/ZMnT2b06NHcd999/m2dO3cGYMGCBSxZsoT9+/cTEhICWKHxs88+4+OPP+b2229n+/btjBkzxl833Lx5c/95tm/fzuDBg2nbti0ATZo08e975ZVXSEpK4qWXXsIwDFq1asXu3bsZO3Ysjz32GA6Hw3++6dOnl+m5O9LhwbpJkya88MILdO7cmaysLP8bBIAnnnjCHxYfeughLr30UvLy8ggNDWXixIk89NBD3HDDDf7zTJo0iQcffJDx48cfdZ8HDhwgLS2Nli1bFtvesWNHf897//79+c9//uPf16tXL0aPHu2/PnToUHr37s2jjz4KWD+3devW8dRTT5X4huJ4RowYweDBgwHrTdg333zDrFmzePDBB8t0niO98sorPPjgg2RnZ9OyZUu+++67o0ptrr32Wj7//HNyc3Pp378/r7/+un/f5s2bWbBgAaGhoXz66accPHiQ4cOHk5KSwptvvnlKbTtd1EMuIiJSjQwePJjdu3czZ84c+vXrx/z58+nQoUOZekhXrlxJ9+7d/WH8cPv372f37t307t27xNuuWrWKrKws4uPj/T32kZGRbNmyxV/yMmrUKG699Vb69OnDtGnTipXC3HvvvUyePJmuXbsyfvx4fv/9d/++9evX06VLl2K9xF27diUrK4udO3f6t3Xs2LHUj/VYli9fTv/+/WnQoAFRUVH+0L19+/Zixx3+RqdOnToA/tKWVatW8fjjjxd7Hm677Tb27NlDTk5Oqdvy6aefsnLlSvr27XtUL3CnTp2KXV+/fv1RpRtdu3Zlw4YNeL3eUt8nQJcuXfzfu1wuOnXqxPr168t0jpIMHTqUFStW8NNPP9GiRQuGDBlCXl5esWNmzJjBb7/9xueff86mTZsYNWqUf5/P58MwDN59913OOeccLrnkEp599lnefvvtgO0lVyAXERGpZkJDQ7nwwgt59NFH+d///seNN97o75Et6kU2TdN/vNvtLnb7sOOMID3ePoCsrCzq1KnDypUri12Sk5MZM2YMYM1wsnbtWi699FJ++OEH2rRpw6effgrArbfeyubNm/n73//O6tWr6dSpEy+++GKZHn9ERESZjj9SdnY2ffv2JTo6mnfffZelS5f621dwxJyXh79pKXqjUDS4MCsri4kTJxZ7HlavXs2GDRsILWGAQEJCArGxsUfVoTdo0IBmzZoRFRVVLo/VMIxiP384+nfgdIqJiaF58+b06NGDjz/+mD/++MP//BZJTEykVatWXH755bz22mu8+uqr7NmzB7De+NSrV4+YmBj/8a1bt8Y0zWJvzAKJArmIiEg116ZNG7KzswEr9AH+cAMcNfVfu3bt+OWXX0oMaVFRUTRq1Ih58+aVeF8dOnRg7969uFwumjVrVuxSs2ZN/3EtWrRg5MiR/Pe//2XQoEHFSg2SkpK48847mT17NqNHj+af//wnYIWuRYsWFQuTCxcuJCoqivr165fxWTm2P/74g5SUFKZNm0b37t1p1apVsQGdpdWhQweSk5OPeh6aNWvmf2N0OIfDwZAhQ3jnnXfYvXv3SbW9devWLFy4sNi2hQsX0qJFC5xOJ2D9Dhz+89+wYUOJPfa//vqr/3uPx8Py5ctp3br1SbXrWEzTxDTN4455KHqDU3RM165d2b17N1lZWf5j/vzzTxwOR7n+HpQn1ZCLiIhUEykpKVx11VXcfPPNtGvXjqioKJYtW8b06dMZMGAAYPVwn3feeUybNo3GjRuzf/9+HnnkkWLnGTFiBC+++CLXXHMN48aNIyYmhl9//ZVzzjmHli1bMmHCBO68805q1arFxRdfTGZmJgsXLuSee+6hT58+dOnShYEDBzJ9+nRatGjB7t27mTt3LldccQVnnHEGY8aM4corr6Rx48bs3LmTpUuX+muV77//fi6++GJatGjBoUOH+PHHH/0hcPjw4Tz33HPcc889jBgxguTkZMaPH8+oUaNKDLgnkpube9SbkaioKBo0aEBwcDAvvvgid955J2vWrGHSpEllPv9jjz3GZZddRoMGDbjyyitxOBysWrWKNWvWHDUQtciUKVOYP38+55xzDo8//jidOnUiIiKC33//nUWLFnHmmWce9z5Hjx5N586dmTRpEldffTWLFi3ipZde4pVXXvEf06tXL1566SW6dOmC1+tl7NixJZYnvfzyyzRv3pzWrVszY8YMDh06dNx5vlNTU9m+fbv/zURRT39iYiKJiYls3ryZDz74gIsuuoiEhAR27tzJtGnTCAsL45JLLgHgq6++Yt++fXTu3JnIyEjWrl3LmDFj6Nq1K40aNQLguuuuY9KkSdx0001MnDiRgwcPMmbMGG6++eYTfoJjFwVyERGRcnZEuWvA3E9kZCTnnnsuM2bMYNOmTbjdbpKSkrjtttt4+OGH/ce98cYb3HLLLXTs2JGWLVsyffp0LrroIv/++Ph4fvjhB8aMGUPPnj1xOp20b9/eX5t8ww03kJeXx4wZM3jggQeoWbMmV155JWCVQ3z11Vf83//9HzfddBMHDhwgMTGRHj16ULt2bZxOJykpKQwbNox9+/ZRs2ZNBg0axMSJEwHwer3cfffd7Ny5k+joaPr168eMGTMAqFevHl999RVjxozhrLPOIi4ujltuueWoNxSl9eeff3L22WcX29a7d2++//573nrrLR5++GFeeOEFOnTowNNPP+2fOrK0+vbty5dffsnjjz/Ok08+SVBQEK1ateLWW2895m3i4+NZsmQJTz75JE899RRbtmzB4XDQvHlzrr76au6///7j3meHDh348MMPeeyxx5g0aRJ16tTh8ccfLzag85lnnuGmm26ie/fu1K1bl+eff57ly5cfda5p06Yxbdo0Vq5cSbNmzZgzZ06xTzmONGfOHG666Sb/9WuuuQaA8ePHM2HCBEJDQ/nll1947rnnOHToELVr16ZHjx7873//o1atWoD1hvGf//wnI0eOJD8/n6SkJAYNGsRDDz3kP29kZCTfffcd99xzD506dSI+Pp4hQ4Yc801OIDDMI4uEbJaRkUFMTAzp6enFVlwSEREJJHl5eWzZsoXGjRv7630rw0qdIqdq69atNG7cuMSVNqubkl4HipQl06qHXEREpJyEhVnh+IhxfadVcLDCuEhlp0AuIiJSjsLCFJBFpGwUyEVERESk1Bo1anTUtIhyajTtoYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiUsz8+fMxDIO0tDQA3nrrLWJjY21tU3mbMGFCsVUmb7zxRgYOHGhbe6R6UyAXERGphhYtWoTT6eTSSy+1uymlcuSbhPL2/PPP89Zbb52Wc4uciAK5iIhINTRr1izuuecefv75Z3bv3m13cyqMaZp4PJ6jtsfExFS5TwGk8lAgFxERqWaysrL44IMPuOuuu7j00kvLpWd4586dXHvttcTFxREREUGnTp1YvHixf//nn39Ohw4dCA0NpUmTJkycOLFYMDYMg9dff50rrriC8PBwmjdvzpw5cwDYunUrF1xwAQA1atTAMAxuvPFGAHw+H1OnTqVx48aEhYVx1lln8fHHH/vPW9Sz/vXXX9OxY0dCQkJYsGDBUe0/smTl/PPP59577+XBBx8kLi6OxMREJkyYUOw2aWlp3HrrrSQkJBAdHU2vXr1YtWqVf/+qVau44IILiIqKIjo6mo4dO7Js2bKTfo6l6lIgFxERKQdFPa92XMq6jPmHH35Iq1ataNmyJddffz1vvPHGKS2FnpWVRc+ePdm1axdz5sxh1apVPPjgg/h8PgB++eUXhg0bxn333ce6det47bXXeOutt3jiiSeKnWfixIkMGTKE33//nUsuuYShQ4eSmppKUlISn3zyCQDJycns2bOH559/HoCpU6fyr3/9i5kzZ7J27VpGjhzJ9ddfz08//VTs3A899BDTpk1j/fr1tGvXrlSP6+233yYiIoLFixczffp0Hn/8cb777jv//quuuor9+/fz9ddfs3z5cjp06EDv3r1JTU0FYOjQodSvX5+lS5eyfPlyHnroIYKCgk7uSZYqzWV3A0RERKoCr9fL7NmzbbnvQYMG4XKV/l/6rFmzuP766wHo168f6enp/PTTT5x//vkndf/vvfceBw4cYOnSpcTFxQHQrFkz//6JEyfy0EMPccMNNwDQpEkTJk2axIMPPsj48eP9x914441ce+21AEyZMoUXXniBJUuW0K9fP/95a9Wq5S8tyc/PZ8qUKXz//fd06dLFf+4FCxbw2muv0bNnT/+5H3/8cS688MIyPa527dr529e8eXNeeukl5s2bx4UXXsiCBQtYsmQJ+/fvJyQkBICnn36azz77jI8//pjbb7+d7du3M2bMGFq1auU/h0hJFMhFRESqkeTkZJYsWcKnn34KgMvl4uqrr2bWrFknHchXrlzJ2Wef7Q/NR1q1ahULFy4s1iPu9XrJy8sjJyeH8PBwgGI91xEREURHR7N///5j3u/GjRvJyck5KmgXFBRw9tlnF9vWqVOnMj+uI3vS69Sp42/PqlWryMrKIj4+vtgxubm5bNq0CYBRo0Zx66238u9//5s+ffpw1VVX0bRp0zK3Q6o+BXIREZFy4HQ6GTRokG33XVqzZs3C4/FQt25d/zbTNAkJCeGll14iJiamzPcfFhZ23P1ZWVlMnDixxOcnNDTU//2R5RyGYfjLXo51XoC5c+dSr169YvuKeq2LREREHLeNJTlee7KysqhTpw7z588/6nZFPfgTJkzguuuuY+7cuXz99deMHz+e999/nyuuuKLMbZGqTYFcRESkHBiGUaayETt4PB7+9a9/8cwzz3DRRRcV2zdw4ED+85//cOedd5b5vO3ateP1118nNTW1xF7yDh06kJycXKyMpayCg4MBq2e9SJs2bQgJCWH79u3FylMqQocOHdi7dy8ul4tGjRod87gWLVrQokULRo4cybXXXsubb76pQC5HCexXDhERESk3X375JYcOHeKWW245qid88ODBzJo166QC+bXXXsuUKVMYOHAgU6dOpU6dOqxYsYK6devSpUsXHnvsMS677DIaNGjAlVdeicPhYNWqVaxZs4bJkyeX6j4aNmyIYRh8+eWXXHLJJYSFhREVFcUDDzzAyJEj8fl8dOvWjfT0dBYuXEh0dLS/Zv106NOnD126dGHgwIFMnz6dFi1asHv3bubOncsVV1zBGWecwZgxY7jyyitp3LgxO3fuZOnSpQwePPi0tUkqL82yIiIiUk3MmjWLPn36lFiWMnjwYJYtW8bvv/9e5vMGBwfz3//+l1q1anHJJZfQtm1bpk2b5i+l6du3L19++SX//e9/6dy5M+eddx4zZsygYcOGpb6PevXq+QeH1q5dmxEjRgAwadIkHn30UaZOnUrr1q3p168fc+fOpXHjxmV+HGVhGAZfffUVPXr04KabbqJFixZcc801bNu2jdq1a+N0OklJSWHYsGG0aNGCIUOGcPHFFzNx4sTT2i6pnAzzVOY5Og0yMjKIiYkhPT2d6Ohou5sjIiJSory8PLZs2ULjxo2L1UGLSPVxvNeBsmRa9ZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIjIKQiwuRFEpAKV19+/ArmIiMhJKFrFMScnx+aWiIhdCgoKgLKtllsSLQwkIiJyEpxOJ7Gxsezfvx+A8PBwDMOwuVUiUlF8Ph8HDhwgPDz8lFfpVSAXERE5SYmJiQD+UC4i1YvD4aBBgwan/GZcgVxEROQkGYZBnTp1qFWrFm632+7miEgFCw4OxuE49QpwBXIREZFT5HQ6T7mGVESqLw3qFBERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNipTIJ8wYQKGYRS7tGrVyr8/Ly+Pu+++m/j4eCIjIxk8eDD79u0r90aLiIiIiFQVZe4hP+OMM9izZ4//smDBAv++kSNH8sUXX/DRRx/x008/sXv3bgYNGlSuDRYRERERqUpcZb6By0ViYuJR29PT05k1axbvvfcevXr1AuDNN9+kdevW/Prrr5x33nmn3loRERERkSqmzD3kGzZsoG7dujRp0oShQ4eyfft2AJYvX47b7aZPnz7+Y1u1akWDBg1YtGjRMc+Xn59PRkZGsYuIiIiISHVRpkB+7rnn8tZbb/HNN9/w6quvsmXLFrp3705mZiZ79+4lODiY2NjYYrepXbs2e/fuPeY5p06dSkxMjP+SlJR0Ug9ERERERKQyKlPJysUXX+z/vl27dpx77rk0bNiQDz/8kLCwsJNqwLhx4xg1apT/ekZGhkK5iIiIiFQbpzTtYWxsLC1atGDjxo0kJiZSUFBAWlpasWP27dtXYs15kZCQEKKjo4tdRERERESqi1MK5FlZWWzatIk6derQsWNHgoKCmDdvnn9/cnIy27dvp0uXLqfcUBERERGRqqhMJSsPPPAA/fv3p2HDhuzevZvx48fjdDq59tpriYmJ4ZZbbmHUqFHExcURHR3NPffcQ5cuXTTDioiIiIjIMZQpkO/cuZNrr72WlJQUEhIS6NatG7/++isJCQkAzJgxA4fDweDBg8nPz6dv37688sorp6XhIiIiIiJVgWGapml3Iw6XkZFBTEwM6enpqicXERERkUqpLJn2lGrIRURERETk1CiQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERu57G6AiEi1Z5rgc4OvoPBSwvemF0yPdd1buM8s3G96rXPgA9N32PcmGAbgAMMBhtO6bjit644gcISAI7jwEgSGy9rvCAbn4fsOP8aw+QkTEalaFMhFRE43bx54csCba33vzbO+d2eBJ9366ssHn6cwdBd+xSzhZIcFasOJFbaNv/ZR+L1x2Pemr3C/aV3Moq++wjBf+NU47DCj8KvDZV0MV2EYD4KgKAiKsb46w8AZan11hYEzApzB5f4UiohUZQrkIiLlwZMLnizwZP91KUiFgkN/BXFfQWHQNqxQ7HAV9lAXBl1XSGHwLQzAhs1VhaZptffwNwm+AsjdA9nbCh8LxR+LMwRcURBcA0JqWAHdFWGFd1cUOJz2PiYRkQCkQC4iUhbePHBnWOHbnQn5qZC/vzCM51o93SZWD7UjCByhVkgNjre+GpUokBqG9UaBIDhRs32FYd2bZ70Ryd1jXcewzuMKA0cYhNSE0FoQFF3Y0x4NrkiVwYhItaZALiJyLJ4ccKdDQZoVvPP2gjvNCt7e/MIKEWdh2UYYhMZYddbVMVwWlba4wo/eZ3r/KtvJ3gaZyYXlME5whluhPLyu9aYlONa6uCIq+AGIiNhHgVxEBKyBkgWHrN7d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QEGCHAAAChLkAABQkCAHAICCBDkAABQkyAEAoKAepSfwRlVVJUmam5sLzwQAALbNay37Wttuzk4X5C+99FKSZPjw4YVnAgAAb81LL72Ufv36bXZMTbU12b4DtbS0ZPHixdltt91SU1NTejp0Ac3NzRk+fHieeuqp9O3bt/R06OTsT3Q0+xQdzT61c6iqKi+99FKGDRuWbt02f5X4TneGvFu3btlrr71KT4MuqG/fvg5MdBj7Ex3NPkVHs0+Vt6Uz469xUycAABQkyAEAoCBBTpdXW1ubiy++OLW1taWnQhdgf6Kj2afoaPapzmenu6kTAADeTpwhBwCAggQ5AAAUJMgBAKAgQQ4AAAUJcrqESy65JDU1NW1ejY2NrctXr16dyZMnZ+DAgenTp0/Gjx+fZcuWFZwxncHTTz+dz372sxk4cGDq6upywAEH5N57721dXlVVLrroogwdOjR1dXU5+uij8+ijjxacMTuzd7zjHRsdp2pqajJ58uQkjlO0z/r163PhhRdmxIgRqauryz777JNvf/vbef2zOhyjOg9BTpfxnve8J0uWLGl9zZs3r3XZ1KlTM2vWrNx0002ZO3duFi9enHHjxhWcLTu7F198MYcddlh69uyZO+64IwsXLsyVV16ZAQMGtI654oor8v3vfz/XXHNN7r777uy6664ZM2ZMVq9eXXDm7KzuueeeNseoP//5z0mSE088MYnjFO1z+eWX5+qrr84Pf/jDPPTQQ7n88stzxRVX5Ac/+EHrGMeoTqSCLuDiiy+u3vve925y2fLly6uePXtWN910U+t7Dz30UJWkmj9//g6aIZ3NN77xjerwww9/0+UtLS1VfX19NWPGjNb3li9fXtXW1lY33njjjpgindyUKVOqffbZp2ppaXGcot2OO+646rTTTmvz3rhx46oJEyZUVeUY1dk4Q06X8eijj2bYsGEZOXJkJkyYkCeffDJJct9992XdunU5+uijW8c2NjamoaEh8+fPLzVddnK///3vc8ghh+TEE0/M4MGDc9BBB+WnP/1p6/InnngiS5cubbNf9evXL6NGjbJfsUVr167N9ddfn9NOOy01NTWOU7TbBz7wgdx555155JFHkiQPPPBA5s2bl2OOOSaJY1Rn06P0BKAjjBo1Ktddd1323XffLFmyJNOmTcsRRxyRBQsWZOnSpenVq1f69+/f5jNDhgzJ0qVLy0yYnd7jjz+eq6++OmeddVa++c1v5p577slXv/rV9OrVKxMnTmzdd4YMGdLmc/YrtsYtt9yS5cuX59RTT00Sxyna7dxzz01zc3MaGxvTvXv3rF+/PpdeemkmTJiQJI5RnYwgp0t47YxAkhx44IEZNWpU9t577/z6179OXV1dwZnRWbW0tOSQQw7JZZddliQ56KCDsmDBglxzzTWZOHFi4dnR2f3sZz/LMccck2HDhpWeCp3Ur3/969xwww2ZOXNm3vOe9+T+++/P1772tQwbNswxqhNyyQpdUv/+/fOud70rjz32WOrr67N27dosX768zZhly5alvr6+zATZ6Q0dOjTvfve727y33377tV4K9dq+88anYNiv2JL//ve/+ctf/pIzzjij9T3HKdrrnHPOybnnnptPf/rTOeCAA3LKKadk6tSpmT59ehLHqM5GkNMlrVy5MosWLcrQoUNz8MEHp2fPnrnzzjtblzc1NeXJJ5/M6NGjC86Sndlhhx2WpqamNu898sgj2XvvvZMkI0aMSH19fZv9qrm5OXfffbf9is36+c9/nsGDB+e4445rfc9xivZ65ZVX0q1b24zr3r17WlpakjhGdTql7yqFjnD22WdXc+bMqZ544onqrrvuqo4++uhq0KBB1TPPPFNVVVVNmjSpamhoqGbPnl3de++91ejRo6vRo0cXnjU7s3/84x9Vjx49qksvvbR69NFHqxtuuKHq3bt3df3117eO+e53v1v179+/uvXWW6sHH3ywOv7446sRI0ZUq1atKjhzdmbr16+vGhoaqm984xsbLXOcoj0mTpxY7bnnntVtt91WPfHEE9Xvfve7atCgQdXXv/711jGOUZ2HIKdLOOmkk6qhQ4dWvXr1qvbcc8/qpJNOqh577LHW5atWraq+/OUvVwMGDKh69+5djR07tlqyZEnBGdMZzJo1q9p///2r2traqrGxsfrJT37SZnlLS0t14YUXVkOGDKlqa2urD33oQ1VTU1Oh2dIZ/PGPf6ySbHI/cZyiPZqbm6spU6ZUDQ0N1S677FKNHDmyOv/886s1a9a0jnGM6jxqqup1X+kEAADsUK4hBwCAggQ5AAAUJMgBAKAgQQ4AAAUJcgAAKEiQAwBAQYIcAAAKEuQAABRz++23Z9SoUamrq8uAAQNywgknbPEzDz30UD7xiU+kX79+2XXXXXPooYfmySef3GhcVVU55phjUlNTk1tuuWWT63r++eez1157paamJsuXL2/X3I866qjU1NS0eU2aNKld60gEOQAA29FRRx2V6667bpPLfvvb3+aUU07J5z//+TzwwAO56667cvLJJ292fYsWLcrhhx+exsbGzJkzJw8++GAuvPDC7LLLLhuNveqqq1JTU7PZ9Z1++uk58MADt/r3eaMvfOELWbJkSevriiuuaPc6emzz1gEAYBu9+uqrmTJlSmbMmJHTTz+99f13v/vdm/3c+eefn2OPPbZN+O6zzz4bjbv//vtz5ZVX5t57783QoUM3ua6rr746y5cvz0UXXZQ77rhjo+W33nprpk2bloULF2bYsGGZOHFizj///PTo8f8Tunfv3qmvr9/i77s5zpADALDD/fOf/8zTTz+dbt265aCDDsrQoUNzzDHHZMGCBW/6mZaWltx+++1517velTFjxmTw4MEZNWrURpejvPLKKzn55JPzox/96E1jeeHChfnWt76VX/ziF+nWbeMk/tvf/pbPfe5zmTJlShYuXJhrr7021113XS699NI242644YYMGjQo+++/f84777y88sor7f5bCHIAAHa4xx9/PElyySWX5IILLshtt92WAQMG5KijjsoLL7ywyc8888wzWblyZb773e/mox/9aP70pz9l7NixGTduXObOnds6burUqfnABz6Q448/fpPrWbNmTT7zmc9kxowZaWho2OSYadOm5dxzz83EiRMzcuTIfPjDH863v/3tXHvtta1jTj755Fx//fX561//mvPOOy+//OUv89nPfrbdf4uaqqqqdn8KAAA24bLLLstll13W+vOqVavSs2fPNpd5LFy4MPPmzcuECRNy7bXX5otf/GKSDaG811575Tvf+U6+9KUvbbTuxYsXZ88998xnPvOZzJw5s/X9T3ziE9l1111z44035ve//33OPvvs/Otf/0qfPn2SJDU1Nbn55ptbbxg966yzsnjx4vzqV79KksyZMycf/OAH8+KLL6Z///5Jkj322CMrV65M9+7dW7ezfv36rF69Oi+//HJ69+690fxmz56dD33oQ3nsscc2eRnNm3ENOQAAHWbSpEn51Kc+1frzhAkTMn78+IwbN671vWHDhrVe1/36a8Zra2szcuTITT4xJUkGDRqUHj16bHSd+X777Zd58+Yl2RDFixYtag3r14wfPz5HHHFE5syZk9mzZ+ff//53fvOb3yTZ8DSW19Z//vnnZ9q0aVm5cmWmTZvWZt6v2dQNpEkyatSoJBHkAACUs/vuu2f33Xdv/bmuri6DBw/OO9/5zjbjDj744NTW1qapqSmHH354kmTdunX5z3/+k7333nuT6+7Vq1cOPfTQNDU1tXn/kUceaf3MueeemzPOOKPN8gMOOCDf+9738vGPfzzJhqe7rFq1qnX5Pffck9NOOy1/+9vfWkP6fe97X5qamjaa9+bcf//9SfKmN5G+GUEOAMAO17dv30yaNCkXX3xxhg8fnr333jszZsxIkpx44omt4xobGzN9+vSMHTs2SXLOOefkpJNOypFHHpkPfvCD+cMf/pBZs2Zlzpw5SZL6+vpN3sjZ0NCQESNGJNn4qSzPPfdckg1n2l87s37RRRflYx/7WBoaGvLJT34y3bp1ywMPPJAFCxbkO9/5ThYtWpSZM2fm2GOPzcCBA/Pggw9m6tSpOfLII9v9GEVBDgBAETNmzEiPHj1yyimnZNWqVRk1alRmz56dAQMGtI5pamrKihUrWn8eO3ZsrrnmmkyfPj1f/epXs+++++a3v/1t61n2jjJmzJjcdttt+da3vpXLL788PXv2TGNjY+vZ9169euUvf/lLrrrqqrz88ssZPnx4xo8fnwsuuKDd23JTJwAAFOSxhwAAUJAgBwCAggQ5AAAUJMgBAKAgQQ4AAAUJcgAAKEiQAwBAQYIcAAAKEuQAAFCQIAcAgIIEOQAAFCTIAQCgoP8Hnrue3Z+PCTEAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "if len(map_object_dict[MapLayer.LANE_GROUP]) > 0:\n", " lane_group: LaneGroup = np.random.choice(map_object_dict[MapLayer.LANE_GROUP])\n", @@ -478,17 +587,28 @@ "id": "24", "metadata": {}, "source": [ - "### 2.3.3 `Intersection`\n", + "### 2.5.3 `Intersection`\n", "\n", "Intersections are map surfaces that include multiple lane groups. Let's look at a random example:" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "25", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABH4AAAJBCAYAAAAwSXJbAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAiUxJREFUeJzs3XeYU9Xa/vF7p2cynWmUoSNNEQVFVBAFRSyIvQuIclR8RUUUjkpRERXrUV85dl/l/Oy9ggIW5CgWQBRQUNCDNKUMMDB1//6Yk0iYQmZIZmcn3891zQWT7EnW7ATy5M6z1jJM0zQFAAAAAACAhOOwegAAAAAAAACIDYIfAAAAAACABEXwAwAAAAAAkKAIfgAAAAAAABIUwQ8AAAAAAECCIvgBAAAAAABIUAQ/AAAAAAAACYrgBwAAAAAAIEER/AAAAAAAACQogh8AttSvXz/169fP6mEAAADE1IIFC3T44YcrEAjIMAwtXLjQ6iHZ2rBhw9S6dWurhwE0KoIfIM48/fTTMgxDX331Vb1/tri4WJMmTdLcuXOjPzAL/PDDD5o0aZJWrVpl9VDCTJkyRYMHD1Z+fr4Mw9CkSZNqPO7VV1/V2WefrbZt2yolJUUdO3bUmDFjtGXLlmrHXnPNNTr44IOVnZ2tlJQUde7cWZMmTdL27dtj+8sAAGxtX+oGq5WUlOjBBx/UkUceqaysLHk8HjVr1kyDBw/W//t//08VFRVWD9FyZWVlOvPMM7Vp0ybdd999evbZZ9WqVasaj507d64Mw9DLL7/coPu6/fbb9frrr+/DaOPH77//rkmTJsVdSPbII4/ozDPPVMuWLWUYhoYNG1bjcR999JEuvvhi7bfffkpJSVHbtm11ySWXaO3atdWOvf3223XYYYcpNzdXPp9PHTp00NVXX62NGzfG+LeBnbisHgCA6CkuLtbkyZMlKSG6YX744QdNnjxZ/fr1q/bJzMyZM60ZlKSbbrpJBQUFOuigg/TBBx/UetzIkSPVrFkzXXDBBWrZsqW+++47PfTQQ3r33Xf1zTffyO/3h45dsGCB+vTpo+HDh8vn8+nbb7/VHXfcoQ8//FCffPKJHA5yegBA4ti4caMGDRqkr7/+WgMHDtRNN92k7OxsrVu3Th9++KHOO+88rVixQjfffLPVQ7XUypUrtXr1aj322GO65JJLYnpft99+u8444wwNGTIkpvfTGH7//XdNnjxZrVu3Vvfu3cOue+yxx1RZWWnJuO68805t27ZNhx56aI0hTtANN9ygTZs26cwzz1SHDh30888/66GHHtLbb7+thQsXqqCgIHTs119/re7du+ucc85RWlqali5dqscee0zvvPOOFi5cqEAg0Bi/GuIcwQ+AvdqxY0fcvWh4PB7L7vuXX35R69at9ccffyg3N7fW415++eVqAVyPHj00dOhQzZgxI6yA++yzz6r9fLt27XTdddfpyy+/1GGHHRa18QMAYLULL7xQ3377rV555RWddtppYdeNHz9eX331lZYvX17nbezatUsejyehPxzZsGGDJCkzM9PagTRQPD5Gbrfbsvv++OOPQ90+qamptR5377336sgjjww7b8cff7yOOuooPfTQQ7rttttCl7/yyivVfr53794644wz9NZbb+mcc86J7i8BW4qff4EAajVs2DClpqZqzZo1GjJkiFJTU5Wbm6vrrrsu1Aa9atWqUAgxefJkGYZRbRrSsmXLdMYZZyg7O1s+n089e/bUm2++GXZfwZbxjz/+WFdccYXy8vLUokULSdK2bdt09dVXq3Xr1vJ6vcrLy9Oxxx6rb775Juw2vvjiCx1//PHKyMhQSkqKjjrqKM2bN6/a77VmzRqNGDFCzZo1k9frVZs2bXT55ZertLRUTz/9tM4880xJ0tFHHx36fYLT2Gpa42fDhg0aMWKE8vPz5fP5dOCBB+qZZ54JO2bVqlUyDEN33323Hn30UbVr105er1eHHHKIFixYENHjEem88Jq6rk499VRJ0tKlSyO+n5qmhgEAEKnS0lJNmDBBPXr0UEZGhgKBgPr06aM5c+aEHVff18hI6oqazJ8/Xx988IFGjhxZLfQJ6tmzp84///zQ98FpTM8//7xuuukmNW/eXCkpKSoqKpIkvfTSS+rRo4f8fr9ycnJ0wQUXaM2aNWG3Wdv6gHuu+bL7ebjvvvvUqlUr+f1+HXXUUVqyZEnYz65bt07Dhw9XixYt5PV61bRpU51yyikRTVOfPXu2+vTpo0AgoMzMTJ1yyilh9cGwYcN01FFHSZLOPPNMGYZR747uSZMmyTAMrVixQsOGDVNmZqYyMjI0fPhwFRcXh44zDEM7duzQM888E6q5dp+GtGbNGl188cXKz8+X1+tV165d9eSTT4bdV12PUVlZmSZPnqwOHTrI5/OpSZMmOvLIIzVr1qyw24j0ObVlyxZdc801oZq0RYsWuuiii/THH39o7ty5OuSQQyRJw4cPD/0+Tz/9dOi87lnL7dixQ2PGjFFhYaG8Xq86duyou+++W6Zphh1nGIauvPJKvf7669p///1D5+L999+P6PFo1aqVDMPY63F9+/atFpb17dtX2dnZ1JBoEDp+AJuoqKjQwIED1atXL91999368MMPdc8996hdu3a6/PLLlZubq0ceeUSXX365Tj311FAh1a1bN0nS999/ryOOOELNmzfXuHHjFAgE9OKLL2rIkCF65ZVXQoFE0BVXXKHc3FxNmDBBO3bskCRddtllevnll3XllVeqS5cu+vPPP/XZZ59p6dKlOvjggyVVFTGDBg1Sjx49NHHiRDkcDj311FM65phj9Omnn+rQQw+VVNWCe+ihh2rLli0aOXKkOnXqpDVr1ujll19WcXGx+vbtq6uuukr/+Mc/9Pe//12dO3eWpNCfe9q5c6f69eunFStW6Morr1SbNm300ksvadiwYdqyZYtGjx4ddvy//vUvbdu2TX/7299kGIbuuusunXbaafr5559j+knQunXrJEk5OTnVrisvL9eWLVtUWlqqJUuW6KabblJaWlronAEA0BBFRUV6/PHHde655+rSSy/Vtm3b9MQTT2jgwIH68ssvq02FieQ1sr51xe7eeustSdIFF1xQ79/l1ltvlcfj0XXXXaeSkhJ5PB49/fTTGj58uA455BBNnTpV69ev1wMPPKB58+bp22+/bXC3zP/93/9p27ZtGjVqlHbt2qUHHnhAxxxzjL777jvl5+dLkk4//XR9//33+p//+R+1bt1aGzZs0KxZs/Trr7/W+UHRhx9+qEGDBqlt27aaNGmSdu7cqQcffFBHHHGEvvnmG7Vu3Vp/+9vf1Lx5c91+++266qqrdMghh4Tut77OOusstWnTRlOnTtU333yjxx9/XHl5ebrzzjslSc8++6wuueQSHXrooRo5cqSkqs5jSVq/fr0OO+ywUOiRm5ur9957TyNGjFBRUZGuvvrqsPuq6TGaNGmSpk6dGrqPoqIiffXVV/rmm2907LHHSor8ObV9+3b16dNHS5cu1cUXX6yDDz5Yf/zxh95880395z//UefOnXXLLbdowoQJGjlypPr06SNJOvzww2s8N6ZpavDgwZozZ45G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6ZJLLqnXc6pdu3ZRryE//PBDbdu2LSxYsbqG/PPPP3XccceppKREH330kZo2bVqvn9+1axc1JEIStuMnUps2bdKMGTN0+OGH17qTT0lJiQzDkNfrDV3m8/nkcDj02WefhY7xeDxhbziDaXfwmI4dO6pJkyZ64oknVFpaqp07d+qJJ55Q586dQy8gwTeJp59+uhYvXqwXXnhBn332ma688kpJVWn4O++8o/32208DBw5UXl6eevXqpddffz3apwY2lJKSIqn61o15eXnq16+f/vnPf2rt2rXVfm7jxo17ve2KiopqLx55eXlq1qyZSkpKJEk9evRQu3btdPfdd9c4rzh4Pw6HQ0OGDNFbb72lr776qtpxwU9MgkVPJFtRnnDCCVq3bp1eeOGF0GXl5eV68MEHlZqaGtoOtTGtW7dOxx13nBwOhz744INqU/WCtmzZUuOn1sFPwWpaBwkArET9ZC/BDoXdOxK++OILzZ8/v0G3t691xRFHHKFjjz1Wjz76qN54440aj4m0e6Jnz57Ky8vT9OnTQ/WIJL333ntaunSpTjzxxNBl7dq107Jly8LGt2jRIs2bN6/G23799dfD1ir68ssv9cUXX2jQoEGSpOLiYu3atSvsZ9q1a6e0tLSwseypadOm6t69u5555pmwGmfJkiWaOXOmTjjhhIh+92gLBALVai6n06nTTz9dr7zySo1hSyQ1pFQVYuwuNTVV7du3D52n+jynTj/9dC1atEivvfZateMaWkNWVFTooYceCrv8vvvuk2EYoce7Me3YsUMnnHCC1qxZo3fffbfGaYfB44qLi6td/sorr2jz5s3UkAhJyo4fSbrhhhv00EMPqbi4WIcddlid8x8PO+wwBQIB3XDDDbr99ttlmqbGjRunioqK0H9MxxxzjK699lpNmzZNo0eP1o4dOzRu3DhJCh2TlpamuXPnasiQIbr11lslVc0d/uCDD0JTP6ZOnarzzz8/tC1ihw4d9I9//ENHHXWUHnnkEW3ZskXbt2/XHXfcodtuu0133nmn3n//fZ122mmaM2eOJW9uET/8fr+6dOmiF154Qfvtt5+ys7O1//77a//999fDDz+sI488UgcccIAuvfRStW3bVuvXr9f8+fP1n//8Z69rJWzbtk0tWrTQGWecoQMPPFCpqan68MMPtWDBAt1zzz2SqgKdxx9/XIMGDVLXrl01fPhwNW/eXGvWrNGcOXOUnp4e2sL19ttv18yZM3XUUUdp5MiR6ty5s9auXauXXnpJn332mTIzM9W9e3c5nU7deeed2rp1q7xer4455hjl5eVVG9/IkSP1z3/+U8OGDdPXX3+t1q1b6+WXX9a8efN0//33R9waG4lnn31Wq1evDr3QfvLJJ7rtttskSRdeeGHok6Hjjz9eP//8s66//np99tlnoTcxkpSfnx/avnTu3Lm66qqrdMYZZ6hDhw4qLS3Vp59+qldffVU9e/Zs0Ha3ABAL1E/x68knn9T7779f7fLRo0frpJNO0quvvqpTTz1VJ554on755RdNnz5dXbp0afACsPtaVzz33HM6/vjjNWTIEA0aNCg0vWvdunX68MMP9cknn0T0htvtduvOO+/U8OHDddRRR+ncc88NbefeunXrsE61iy++WPfee68GDhyoESNGaMOGDZo+fbq6du1a4yLH7du315FHHqnLL79cJSUluv/++9WkSRNdf/31kqq6f/r376+zzjpLXbp0kcvl0muvvab169frnHPOqXPc06ZN06BBg9S7d2+NGDEitJ17RkaGJk2atNffOxZ69OihDz/8UPfee6+aNWumNm3aqFevXrrjjjs0Z84c9erVS5deeqm6dOmiTZs26ZtvvtGHH36oTZs27fW2u3Tpon79+qlHjx7Kzs7WV199pZdffjkUzkqRP6fGjh2rl19+WWeeeaYuvvhi9ejRQ5s2bdKbb76p6dOn68ADD1S7du2UmZmp6dOnKy0tTYFAQL169apx/aGTTz5ZRx99tG688UatWrVKBx54oGbOnKk33nhDV199dVQ7A996663Q71FWVqbFixeHasjBgweH1rY6//zz9eWXX+riiy/W0qVLtXTp0tBtpKamasiQIZKqutIGDBigs88+W506dZLD4dBXX32l5557Tq1bt9bo0aOjNnbYXONvJBYbU6ZMMQOBQOjL4XCYXq837LLVq1eHjt+4caO5fPlyc+bMmeYRRxxhnnDCCWHbT+/pgw8+MNu2bWsahmE6nU7zggsuMA8++GDzsssuCx0zY8YMMz8/33Q6nabH4zGvu+46Mz8/37zjjjtM06za0vrQQw81L7roIvPLL78058+fb55++ulm165dQ1se9uzZ0/R4PGHjTklJMSWZP/zwg7lmzRpTknnuueeGje/kk082zznnnGieUliktu3cA4FAtWODW3Tu7vPPPzd79OhhejyealuNr1y50rzooovMgoIC0+12m82bNzdPOukk8+WXX67z/k3TNEtKSsyxY8eaBx54oJmWlmYGAgHzwAMPNP/3f/+32ri+/fZb87TTTjObNGlier1es1WrVuZZZ51VbfvJ1atXmxdddJGZm5trer1es23btuaoUaPCtpx97LHHzLZt25pOpzNsa/eatmRdv369OXz4cDMnJ8f0eDzmAQccUG0bz+AWrTVtvbrn+arNUUcdFbZl5u5fu2+/XtsxksLGvmLFCvOiiy4y27Zta/r9ftPn85ldu3Y1J06caG7fvn2v4wGAhqJ+sn/9FHzdru3rt99+MysrK83bb7/dbNWqlen1es2DDjrIfPvtt2vdxjzS18hI6oq67Ny507z//vvN3r17m+np6abL5TILCgrMk046yZwxY4ZZXl4eOnZv25q/8MIL5kEHHWR6vV4zOzvbPP/888O2Yg967rnnzLZt25oej8fs3r27+cEHH9R5Hu655x6zsLDQ9Hq9Zp8+fcxFixaFjvvjjz/MUaNGmZ06dTIDgYCZkZFh9urVy3zxxRcj+v0//PBD84gjjjD9fr+Znp5unnzyyeYPP/wQdky0tnPfuHFj2LHB580vv/wSumzZsmVm3759Tb/fb0oK22p8/fr15qhRo8zCwkLT7XabBQUFZv/+/c1HH300orHedttt5qGHHmpmZmaafr/f7NSpkzllyhSztLQ07LhIn1N//vmneeWVV5rNmzc3PR6P2aJFC3Po0KFhW86/8cYbZpcuXUyXyxW2tfuej7dpmua2bdvMa665xmzWrJnpdrvNDh06mNOmTav2/5skc9SoUdV+v1atWkW0NfvQoUNr/be6e83aqlWrWo/bfewbN240R44cGXoOejwes0OHDubVV19d7TFHcjNMMwarUFlg06ZNYWnz+eefr9NPPz20OKoktW7dusZFVf/zn/+osLBQn3/+uXr37l3n/fzxxx9yuVzKzMxUQUGBxowZo7Fjx4Yds379egUCARmGofT0dD3//PM688wz9cQTT+jvf/+71q5dG2ppLi0tVVZWlp544gmdc8456ty5s4499thq2wlKf60gHwgENHHiRN10002h62644QZ99tlntbaqAgAA7In6ifoJ1a1atUpt2rTRtGnTdN1111k9HADYZwkz1Ss7O1vZ2dmh7/1+v/Ly8iJa/T24inxdc3GDcnJyJEmzZ8/Whg0bNHjw4GrH5OfnS6pqufX5fKHpHMXFxXI4HGGLjQW/D47h4IMP1g8//FDnuA855JBqi8H9+OOPli08BgAA7In6ifoJAJD4km5x5y+++EIPPfSQFi5cqNWrV2v27Nk699xz1a5du9CnVWvWrFGnTp305Zdfhn7uqaee0r///W+tXLlSzz33nM4880xdc8016tixY+iYhx56SN98841+/PFHPfzww7ryyis1depUZWZmSpKOPfZYbd68WaNGjdLSpUv1/fffa/jw4XK5XDr66KMlVX3y9Pnnn+vKK6/UwoUL9dNPP+mNN94Im/86duxYvfDCC3rssce0YsUKPfTQQ3rrrbd0xRVXNMIZBAAAyYb6CQAAG7N6rlmsHHXUUdXW9jBN01y8eLF59NFHm9nZ2abX6zVbt25tXnbZZWFzgIPzendfq+OGG24w8/PzQ3M+77nnnmpzPi+88EIzOzvb9Hg8Zrdu3cz/+7//q3b/wTnxGRkZZlZWlnnMMceY8+fPDzvmyy+/NI899lgzNTXVDAQCZrdu3cwpU6aEHfPEE0+Y7du3N30+n3nggQear7/+egPOEgAAwF+on4C61zoCADtKmDV+AAAAAAAAEC7ppnoBAAAAAAAkC4IfAAAAAACABGX7Xb0qKyv1+++/Ky0tLWy3BwAAEF9M09S2bdvUrFmz0LbcsAb1EwAA9hCN+sn2wc/vv/+uwsJCq4cBAAAi9Ntvv6lFixZWDyOpUT8BAGAv+1I/2T74SUtLk1R1EtLT0y0eDQAAqE1RUZEKCwtDr92wDvUTAAD2EI36yfbBT7A9OT09ncIFAAAbYGqR9aifAACwl32pn5hgDwAAAAAAkKAIfgAAAAAAABIUwQ8AAAAAAECCsv0aPwCA+FFRUaGysjKrhwGLuN1uOZ1Oq4cBAIDtVFZWqrS01OphwAKNUT8R/AAA9plpmlq3bp22bNli9VBgsczMTBUUFLCAMwAAESotLdUvv/yiyspKq4cCi8S6fiL4AQDss2Dok5eXp5SUFN70JyHTNFVcXKwNGzZIkpo2bWrxiAAAiH+maWrt2rVyOp0qLCyUw8FqLMmkseongh8AwD6pqKgIhT5NmjSxejiwkN/vlyRt2LBBeXl5TPsCAGAvysvLVVxcrGbNmiklJcXq4cACjVE/EScCAPZJcE0fihVIfz0PWOsJAIC9q6iokCR5PB6LRwIrxbp+IvgBAEQF07sg8TwAAKAheP1MbrF+/Al+AAAAAAAAEhRr/AAAYmLnTqkxdyX1eKT/TpFGLSZNmqTXX39dCxcutHooAACgDmVlZaFpYI3B6XTK7XY32v2hcRH8AACibudO6Y03pM2bG+8+s7KkU06pX/gzbNgwbdmyRa+//npExxuGoddee01Dhgxp0BgbU01jve666/Q///M/1g0KAADsVVlZmZYvX66dO3c22n36/X517Ngx4vCnvjVUY1q3bp2mTp2qd955R//5z3+UkZGh9u3b64ILLtDQoUOTcl1Kgh8AQNSVllaFPn6/5PPF/v527aq6v9JSe3T9lJWVWfKpWmpqqlJTUxv9fgEAQOQqKiq0c+dOuVwuuVyxf8teXl6unTt3qqKiwvZdPz///LOOOOIIZWZm6vbbb9cBBxwgr9er7777To8++qiaN2+uwYMH1/izVtVnjYE1fgAAMePzSYFA7L+iES7169dPV111la6//nplZ2eroKBAkyZNCl3funVrSdKpp54qwzBC30vSG2+8oYMPPlg+n09t27bV5MmTVV5eHrreMAw98sgjGjx4sAKBgKZMmaLNmzfr/PPPV25urvx+vzp06KCnnnoq9DO//fabzjrrLGVmZio7O1unnHKKVq1aFTbmJ598Ul27dpXX61XTpk115ZVX1jnWSZMmqXv37qGfr6ys1C233KIWLVrI6/Wqe/fuev/990PXr1q1SoZh6NVXX9XRRx+tlJQUHXjggZo/f37DTzQAAIiIy+WSx+OJ+VcswqV7771XBxxwgAKBgAoLC3XFFVdo+/btoeuffvppZWZm6oMPPlDnzp2Vmpqq448/XmvXrg27nccff1ydO3eWz+dTp06d9L//+7913u8VV1whl8ulr776SmeddZY6d+6stm3b6pRTTtE777yjk08+OXRsTfWZJD3yyCNq166dPB6POnbsqGeffTb0M8HaaPdp81u2bJFhGJo7d64kae7cuTIMQ++88466desmn8+nww47TEuWLGno6dxncRH8PPzww2rdurV8Pp969eqlL7/80uohAQCS0DPPPKNAIKAvvvhCd911l2655RbNmjVLkrRgwQJJ0lNPPaW1a9eGvv/000910UUXafTo0frhhx/0z3/+U08//XSoeAiaNGmSTj31VH333Xe6+OKLdfPNN+uHH37Qe++9p6VLl+qRRx5RTk6OpKpPnAYOHKi0tDR9+umnmjdvXqggKv3vwkmPPPKIRo0apZEjR+q7777Tm2++qfbt29c51j098MADuueee3T33Xdr8eLFGjhwoAYPHqyffvop7Lgbb7xR1113nRYuXKj99ttP5557bliwBWtQPwEA4pXD4dA//vEPff/993rmmWc0e/ZsXX/99WHHFBcX6+6779azzz6rTz75RL/++quuu+660PUzZszQhAkTNGXKFC1dulS33367br75Zj3zzDM13ueff/6pmTNnatSoUQoEAjUes+fuWXvWZ6+99ppGjx6tMWPGaMmSJfrb3/6m4cOHa86cOfU+B2PHjtU999yjBQsWKDc3VyeffHLMtmvfG8uner3wwgu69tprNX36dPXq1Uv333+/Bg4cqOXLlysvL8/q4QEAkki3bt00ceJESVKHDh300EMP6aOPPtKxxx6r3NxcSVJmZqYKCgpCPzN58mSNGzdOQ4cOlSS1bdtWt956q66//vrQbUnSeeedp+HDh4e+//XXX3XQQQepZ8+ekhTWQfTCCy+osrJSjz/+eKhAeeqpp5SZmam5c+fquOOO02233aYxY8Zo9OjRoZ875JBDJKnWse7p7rvv1g033KBzzjlHknTnnXdqzpw5uv/++/Xwww+Hjrvuuut04oknhn7frl27asWKFerUqVNE5xXRR/0EAIhnV199dejvrVu31m233abLLrssrGOnrKxM06dPV7t27SRJV155pW655ZbQ9RMnTtQ999yj0047TZLUpk2b0IdswbprdytWrJBpmurYsWPY5Tk5Odq1a5ckadSoUbrzzjtD1+1Zn5177rkaNmyYrrjiCknStddeq3//+9+6++67dfTRR9frHEycOFHHHnuspKoPF1u0aKHXXntNZ511Vr1uJxos7/i59957demll2r48OHq0qWLpk+frpSUFD355JNWDw0AkGS6desW9n3Tpk21YcOGOn9m0aJFuuWWW0Lr56SmpurSSy/V2rVrVVxcHDouGPAEXX755Xr++efVvXt3XX/99fr888/DbnPFihVKS0sL3WZ2drZ27dqllStXasOGDfr999/Vv3//Bv+uRUVF+v3333XEEUeEXX7EEUdo6dKlYZftfl6aNm0qSXs9L4gt6icAQDz78MMP1b9/fzVv3lxpaWm68MIL9eeff4bVRikpKaHQRwqvu3bs2KGVK1dqxIgRYTXWbbfdppUrV9ZrLF9++aUWLlyorl27qqSkJOy6PeuzpUuXRlQbRaJ3796hv2dnZ6tjx44Nup1osLTjp7S0VF9//bXGjx8fuszhcGjAgAG1rh9QUlIS9mAVFRXFfJwAgOSw54J+hmGosrKyzp/Zvn27Jk+eHPo0ane+3RYf2rPleNCgQVq9erXeffddzZo1S/3799eoUaN09913a/v27erRo4dmzJhR7TZzc3PlcDTu5za7n5dgB9Lezgtih/oJABDPVq1apZNOOkmXX365pkyZouzsbH322WcaMWKESktLQ7tq1VR3maYpSaH1gB577DH16tUr7Din01nj/bZv316GYWj58uVhl7dt21ZS1c5le6ptSlhtgjVYcJySLJu+VR+WBj9//PGHKioqlJ+fH3Z5fn6+li1bVuPPTJ06VZMnT26M4QFhNm6U4ul9zh7TU+t9fX2P3e3/tgYf19Dr6nNcNO4jKBrnuD6PQ6z5/VJGhtWjsDe3262Kioqwyw4++GAtX748tL5OfeTm5mro0KEaOnSo+vTpo7Fjx+ruu+/WwQcfrBdeeEF5eXlKT0+v8Wdbt26tjz76qNa245rGurv09HQ1a9ZM8+bN01FHHRW6fN68eTr00EPr/bug8VA/wS5M09SOHTusHkaYPdf3iPVtmfUtPuLwNurzs9E4v9F8jKIhuAAyIvf111+rsrJS99xzTygoefHFF+t1G/n5+WrWrJl+/vlnnX/++RH9TJMmTXTsscfqoYce0v/8z//UO9SRpM6dO2vevHlhU8nmzZunLl26SPprOv3atWt10EEHSVLYQs+7+/e//62WLVtKkjZv3qwff/xRnTt3rveYosHyNX7qa/z48br22mtD3xcVFamwsNDCESEZ/Oc/0rvvVm0ZHc/2Naio6fpYBzkN/fko1EAxEck5tmrsOTnSaac17nbnjfVvprHuJxi2HHHEEfJ6vcrKytKECRN00kknqWXLljrjjDPkcDi0aNEiLVmyRLfddluttzVhwgT16NEj1Hb89ttvh4qB888/X9OmTdMpp5wS2nVr9erVevXVV3X99derRYsWmjRpki677DLl5eVp0KBB2rZtm+bNm6f/+Z//qXWsexo7dqwmTpyodu3aqXv37nrqqae0cOHCGjuNYG/UT7DCxo0btWrVKss7BPc1SIhmENGQACYawQ8aLjMz07I17RprI4WG3s/WrVurhR5NmjRR+/btVVZWpgcffFAnn3yy5s2bp+nTp9f79idPnqyrrrpKGRkZOv7441VSUqKvvvpKmzdvDntN293//u//6ogjjlDPnj01adIkdevWTQ6HQwsWLNCyZcvUo0ePOu9z7NixOuuss3TQQQdpwIABeuutt/Tqq6/qww8/lFTVNXTYYYfpjjvuUJs2bbRhwwbddNNNNd7WLbfcoiZNmig/P1833nijcnJyNGTIkHqfh2iwNPjJycmR0+nU+vXrwy5fv359rYtRer1eeb3exhgeEFJaKhUVSf/tErRMtLpiIvn5htY40exE2pefjeWHRfU9v/FQr23fXvVVRwNIVHk8UlaWtHmztHNn49xnVlbV/cbSPffco2uvvVaPPfaYmjdvrlWrVmngwIF6++23dcstt+jOO++U2+1Wp06ddMkll9R5Wx6PR+PHj9eqVavk9/vVp08fPf/885Kq5rx/8sknuuGGG3Taaadp27Ztat68ufr37x/qABo6dKh27dql++67T9ddd51ycnJ0xhln1DnWPV111VXaunWrxowZow0bNqhLly5688031aFDh+idNEQd9RPsoqKiQuXl5crMzLR6KBGxKmCJdYdLvHXQRCoeAq/i4uLQbpqNyel0yu/3a+fOnY0W/vj9/lqnUNVm7ty5oa6XoBEjRujxxx/XvffeqzvvvFPjx49X3759NXXqVF100UX1uv1LLrlEKSkpmjZtmsaOHatAIKADDjggbOHoPbVr107ffvutbr/9do0fP17/+c9/5PV61aVLF1133XWhRZtrM2TIED3wwAO6++67NXr0aLVp00ZPPfWU+vXrFzrmySef1IgRI9SjRw917NhRd911l4477rhqt3XHHXdo9OjR+umnn9S9e3e99dZblnWPGabF/6J69eqlQw89VA8++KCkqjUDWrZsqSuvvFLjxo3b688XFRUpIyNDW7durbUdHthXP/8svf66xHsh2NW2bVVf558vpaZG97Z37dqlX375RW3atAlb02bnzqrQtLF4PI3bzYSa1fZ8kHjNjibqJ9jBunXr9PPPP6tJkyZWDwVokO3bt8vtdlfb/CGaanvdLCsrq3PK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30Aca9lSSpL1CffZI4/crXfffVUzZrxXbZHDoL59B2j79m1auvS7Rh5dfHE46PgBACSutLQ0ORwO1mOpwQMPPKC33npLr776arV66ZVXXtEdd9yhN998U3l5eaHLmzVrFuqWHjx4sNLS0vTTTz816rjjwe7BD2BHBD9AnMrLk7xeiW7luk2ffq9ee+3/6YUXZoWt6VNWVha2w9e3336pP//coFat2lowyvhhGKzxAwBIXIFAQF6vl+lee3jwwQf18ssv64033lBmZmbYda+++qpuvfVWvfnmmyosLAy7bs2aNaG/f/nll9q0aZPatk3eWorgB3bFVC8gTuXkVE3z2rKlatpXshs79m/66KN3tGHDOp177kClpqbplVfmavLkMWrVqq3OOONoSZLH49W7736hsrIyjR49VEVFW+VyuZSSEtBjj72szMzknjsXXOOHugUAkIicTqcyMzO1bt06BQIBq4fT6K666ip98MEHWr9+vYYMGaK0tDS9++67+vvf/642bdqE1vbxer2aM2eOJGnEiBHKz8/XOeecE7qdt956S02aNNFll12mDRs2yOl0yufz6dlnn1VGRoYlv5uVDMOotjMsYCcEP0CccrmkNm2kL74g+JGkadP+WePla9fWnGCkpKTozTfnxXJItkTHDwAg0aWnp2vt2rVJuR7LP/7xjxov37ZtW60/s3nz5lqve+utt/Z5TImAqV6wO6Z6AXGsadOqN+lMU0e0sJ07ACDRpaamyuv1qoTFEhtFMoUhyfS7IrEQ/ABxLC+valv3Oj6kSTgul1slJSWN9sK6a9dOOZ3uRrmveMB27gCAROf1epWamprQ6/y43VW1S3FxscUjUeg8B8eUiOj4gd0R/ABxLDW1qusnmXYlbdZsPxUXl+iHHxbH/L6qdvlaqubN94v5fcULtnMHACSDzMxMlZeXWz2MmMnKylJaWpoWLFhg9VC0YMECpaWlKTs72+qhxAzBD+yONX6AONeqlbRsmdWjaDyFhZ2Vnd1eDzxwp44//iS1aNFKTqczqvdRWVmpTZv+0Ecfva/SUo/23/+oqN5+PKPjBwCQDFJTU+V2u1VWVpaQnSiGYeiYY47RG2+8oeLiYh144IHyer2NOobS0lItWrRIn3zyiU4++eSkWE+J4Ad2RfADxLm8PMnnq9rW3eezejSxZxiGzj33Fs2a9bheeukN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giL4sZDX69XEiRPl9XqtHkrC4hzHHuc4tji/scc5ji3OL6KN51TscY5ji/Mbe5zj2OMcx1a0z6/tF3cGAAAAAABAzej4AQAAAAAASFAEPwAAAAAAAAmK4AcAAAAAACBBEfwAAAAAAAAkKIKfRvDII4+oW7duSk9PV3p6unr37q333nsvdP2uXbs0atQoNWnSRKmpqTr99NO1fv16C0dsb3fccYcMw9DVV18duoxzvG8mTZokwzDCvjp16hS6nvO779asWaMLLrhATZo0kd/v1wEHHKCvvvoqdL1pmpowYYKaNm0qv9+vAQMG6KeffrJwxPbSunXras9hwzA0atQoSTyHo6GiokI333yz2rRpI7/fr3bt2unWW2/V7ntI8DxGfVA/NS7qp+ijfoo96qfYon6KrUatnUzE3Jtvvmm+88475o8//mguX77c/Pvf/2663W5zyZIlpmma5mWXXWYWFhaaH330kfnVV1+Zhx12mHn44YdbPGp7+vLLL83WrVub3bp1M0ePHh26nHO8byZOnGh27drVXLt2behr48aNoes5v/tm06ZNZqtWrcxhw4aZX3zxhfnzzz+bH3zwgblixYrQMXfccYeZkZFhvv766+aiRYvMwYMHm23atDF37txp4cjtY8OGDWHP31mzZpmSzDlz5pimyXM4GqZMmWI2adLEfPvtt81ffvnFfOmll8zU1FTzgQceCB3D8xj1Qf3UeKifYoP6Kbaon2KP+im2GrN2IvixSFZWlvn444+bW7ZsMd1ut/nSSy+Frlu6dKkpyZw/f76FI7Sfbdu2mR06dDBnzZplHnXUUaHChXO87yZOnGgeeOCBNV7H+d13N9xwg3nkkUfWen1lZaVZUFBgTps2LXTZli1bTK/Xa/6///f/GmOICWf06NFmu3btzMrKSp7DUXLiiSeaF198cdhlp512mnn++eebpsnzGNFB/RR91E+xQ/0UW9RPjY/6Kboas3Ziqlcjq6io0PPPP68dO3aod+/e+vrrr1VWVqYBAwaEjunUqZNatmyp+fPnWzhS+xk1apROPPHEsHMpiXMcJT/99JOaNWumtm3b6vzzz9evv/4qifMbDW+++aZ69uypM888U3l5eTrooIP02GOPha7/5ZdftG7durBznJGRoV69enGOG6C0tFTPPfecLr74YhmGwXM4Sg4//HB99NFH+vHHHyVJixYt0meffaZBgwZJ4nmMfUP9FDvUT7FF/RQ71E+Ni/op+hqzdnJFb9ioy3fffafevXtr165dSk1N1WuvvaYuXbpo4cKF8ng8yszMDDs+Pz9f69ats2awNvT888/rm2++0YIFC6pdt27dOs7xPurVq5eefvppdezYUWvXrtXkyZPVp08fLVmyhPMbBT///LMeeeQRXXvttfr73/+uBQsW6KqrrpLH49HQoUND5zE/Pz/s5zjHDfP6669ry5YtGjZsmCT+j4iWcePGqaioSJ06dZLT6VRFRYWmTJmi888/X5J4HqNBqJ9ii/optqifYov6qXFRP0VfY9ZOBD+NpGPHjlq4cKG2bt2ql19+WUOHDtXHH39s9bASwm+//abRo0dr1qxZ8vl8Vg8nIQVTZ0nq1q2bevXqpVatWunFF1+U3++3cGSJobKyUj179tTtt98uSTrooIO0ZMkSTZ8+XUOHDrV4dInniSee0KBBg9SsWTOrh5JQXnzxRc2YMUP/+te/1LVrVy1cuFBXX321mjVrxvMYDUb9FDvUT7FH/RRb1E+Ni/op+hqzdmKqVyPxeDxq3769evTooalTp+rAAw/UAw88oIKCApWWlmrLli1hx69fv14FBQXWDNZmvv76a23YsEEHH3ywXC6XXC6XPv74Y/3jH/+Qy+VSfn4+5zjKMjMztd9++2nFihU8h6OgadOm6tKlS9hlnTt3DrWDB8/jnrskcI7rb/Xq1frwww91ySWXhC7jORwdY8eO1bhx43TOOefogAMO0IUXXqhrrrlGU6dOlcTzGA1D/RQ71E+Nj/opuqifGg/1U2w0Zu1E8GORyspKlZSUqEePHnK73froo49C1y1fvly//vqrevfubeEI7aN///767rvvtHDhwtBXz549df7554f+zjmOru3bt2vlypVq2rQpz+EoOOKII7R8+fKwy3788Ue1atVKktSmTRsVFBSEneOioiJ98cUXnON6euqpp5SXl6cTTzwxdBnP4egoLi6WwxFeVjidTlVWVkrieYzooH6KHuqnxkf9FF3UT42H+ik2GrV22ve1qLE348aNMz/++GPzl19+MRcvXmyOGzfONAzDnDlzpmmaVdvgtWzZ0pw9e7b51Vdfmb179zZ79+5t8ajtbfddKUyTc7yvxowZY86dO9f85ZdfzHnz5pkDBgwwc3JyzA0bNpimyfndV19++aXpcrnMKVOmmD/99JM5Y8YMMyUlxXzuuedCx9xxxx1mZmam+cYbb5iLFy82TznlFLYjraeKigqzZcuW5g033FDtOp7D+27o0KFm8+bNQ1uSvvrqq2ZOTo55/fXXh47heYz6oH5qfNRP0UX9FFvUT42D+il2GrN2IvhpBBdffLHZqlUr0+PxmLm5uWb//v1DRYtpmubOnTvNK664wszKyjJTUlLMU0891Vy7dq2FI7a/PQsXzvG+Ofvss82mTZuaHo/HbN68uXn22WebK1asCF3P+d13b731lrn//vubXq/X7NSpk/noo4+GXV9ZWWnefPPNZn5+vun1es3+/fuby5cvt2i09vTBBx+Ykmo8bzyH911RUZE5evRos2XLlqbP5zPbtm1r3njjjWZJSUnoGJ7HqA/qp8ZH/RRd1E+xR/0Ue9RPsdOYtZNhmqa5jx1KAAAAAAAAiEOs8QMAAAAAAJCgCH4AAAAAAAASFMEPAAAAAABAgiL4AQAAAAAASFAEPwAAAAAAAAmK4AcAAAAAACBBEfwAAAAAAAAkKIIfAADizDvvvKNevXrJ7/crKytLQ4YM2evPLF26VIMHD1ZGRoYCgYAOOeQQ/frrr9WOM01TgwYNkmEYev3112u8rT///FMtWrSQYRjasmVLvcber18/GYYR9nXZZZfV6zYAAADqi/qpdgQ/AAA0sn79+unpp5+u8bpXXnlFF154oYYPH65FixZp3rx5Ou+88+q8vZUrV+rII49Up06dNHfuXC1evFg333yzfD5ftWPvv/9+GYZR5+2NGDFC3bp1i/j32dOll16qtWvXhr7uuuuuBt8WAACARP20L1xRuyUAALBPysvLNXr0aE2bNk0jRowIXd6lS5c6f+7GG2/UCSecEFYgtGvXrtpxCxcu1D333KOvvvpKTZs2rfG2HnnkEW3ZskUTJkzQe++9V+36N954Q5MnT9YPP/ygZs2aaejQobrxxhvlcv1VUqSkpKigoGCvvy8AAMC+on7aOzp+AACIE998843WrFkjh8Ohgw46SE2bNtWgQYO0ZMmSWn+msrJS77zzjvbbbz8NHDhQeXl56tWrV7U25OLiYp133nl6+OGHay0qfvjhB91yyy36v//7Pzkc1UuETz/9VBdddJFGjx6tH374Qf/85z/19NNPa8qUKWHHzZgxQzk5Odp///01fvx4FRcX1/9kAAAARID6ae8IfgAAiBM///yzJGnSpEm66aab9PbbbysrK0v9+vXTpk2bavyZDRs2aPv27brjjjt0/PHHa+bMmTr11FN12mmn6eOPPw4dd8011+jwww/XKaecUuPtlJSU6Nxzz9W0adPUsmXLGo+ZPHmyxo0bp6FDh6pt27Y69thjdeutt+qf//xn6JjzzjtPzz33nObMmaPx48fr2Wef1QUXXNDQUwIAAFAn6qcImAAAIKamTJliBgKB0JfD4TC9Xm/YZatXrzZnzJhhSjL/+c9/hn52165dZk5Ojjl9+vQab3vNmjWmJPPcc88Nu/zkk082zznnHNM0TfONN94w27dvb27bti10vSTztddeC31/zTXXmGeffXbo+zlz5piSzM2bN4cuy8nJMX0+X9i4fT6fKcncsWNHjeP76KOPTEnmihUrIj5fAAAA1E/Rq59Y4wcAgBi77LLLdNZZZ4W+P//883X66afrtNNOC13WrFmz0Lzx3eeke71etW3btsYdJiQpJydHLper2jz2zp0767PPPpMkzZ49WytXrlRmZmbYMaeffrr69OmjuXPnavbs2fruu+/08ssvS6ravSJ4+zfeeKMmT56s7du3a/LkyWHjDqppIURJ6tWrlyRpxYoVNc6bBwAAqAn1U/TqJ4IfAABiLDs7W9nZ2aHv/X6/8vLy1L59+7DjevToIa/Xq+XLl+vII4+UJJWVlWnVqlVq1apVjbft8Xh0yCGHaPny5WGX//jjj6GfGTdunC655JKw6w844ADdd999OvnkkyVV7Yaxc+fO0PULFizQxRdfrE8//TRUcBx88MFavnx5tXHXZeHChZJU62KIAAAANaF+il79RPADAECcSE9P12WXXaaJEyeqsLBQrVq10rRp0yRJZ555Zui4Tp06aerUqTr11FMlSWPHjtXZZ5+tvn376uijj9b777+vt956S3PnzpUkFRQU1LggYcuWLdWmTRtJ1Xex+OOPPyRVffIV/KRrwoQJOumkk9SyZUudccYZcjgcWrRokZYsWaLbbrtNK1eu1L/+9S+dcMIJatKkiRYvXqxrrrlGffv23aftTQEAAGpD/bR3BD8AAMSRadOmyeVy6cILL9TOnTvVq1cvzZ49W1lZWaFjli9frq1bt4a+P/XUUzV9+nRNnTpVV111lTp27KhXXnkl9KlXtAwcOPD/t3eHNgzDQBhGryDZIjz7ZJ0gM7MMY+i1glvUqqio6Nd7Exy0vtPJNcao1lr13mtZltr3/bMNW9e15px1XVfd913bttVxHHWe51/nAAD45v302+P5PkIDAAAAIIrv3AEAAABCCT8AAAAAoYQfAAAAgFDCDwAAAEAo4QcAAAAglPADAAAAEEr4AQAAAAgl/AAAAACEEn4AAAAAQgk/AAAAAKGEHwAAAIBQwg8AAABAqBflDIf01kEZwQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "if len(map_object_dict[MapLayer.INTERSECTION]) > 0:\n", " intersection: Intersection = np.random.choice(map_object_dict[MapLayer.INTERSECTION])\n", @@ -514,7 +634,7 @@ "id": "26", "metadata": {}, "source": [ - "### 2.3.4 Other map surfaces\n", + "### 2.5.4 Other map surfaces\n", "\n", "Besides lanes, lane groups, and intersection, the 123D map has a few simpler map surface objects. These include:\n", "\n", @@ -529,10 +649,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "27", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAqAAAAKiCAYAAAAJ9G7OAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAX9tJREFUeJzt3Xl8VOXd///3mZlkspGEAElYAgQQERfUiohYIQoF6xe1ohWrd23duoB31dbt/lVt7UKrX2tvLWrrjVK/dQOtu9J6A0EFRBBxAwERWYSEJWTfZ67fH4GBIYEsczLnnOT1fDymMGfOnPOZOTymbz/Xuc6xjDFGAAAAQJz4nC4AAAAA3QsBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHHVrQLoBRdcoIEDByopKUl9+/bVf/zHf2jHjh1Hfc+mTZv0ne98R3369FF6erq++93vqri4OGqd1atXa9KkScrMzFSvXr10/fXXq7KyMmqdlStX6txzz1VmZqZ69uypyZMn66OPPmr3Z1i3bp0uuOACZWRkKDU1VaNHj9bWrVvbvR0AAACndLkAOmHCBM2dO7fF1woKCjRv3jytX79eL7zwgjZt2qRLLrnkiNuqqqrSt771LVmWpUWLFmnp0qWqr6/X1KlTFQ6HJUk7duzQxIkTNWzYMK1YsUILFizQZ599ph/84AeR7VRWVmrKlCkaOHCgVqxYoXfffVc9evTQ5MmT1dDQ0ObPtmnTJp111lkaMWKECgsL9fHHH+vOO+9UUlJSm7cBAADgONPFjB8/3jzxxBNtWvfll182lmWZ+vr6Fl//17/+ZXw+nykrK4ssKy0tNZZlmbfeessYY8xf//pXk52dbUKhUGSdjz/+2EgyGzduNMYYs3LlSiPJbN269YjrGGPMO++8Y8466yyTlJRkBgwYYG644QZTWVkZef2yyy4zV155ZZs+GwAAgFt1uQ5oW5WUlOipp57SmWeeqYSEhBbXqaurk2VZCgaDkWVJSUny+Xx69913I+skJibK5zv4VSYnJ0tSZJ1jjz1WvXr10pw5c1RfX6+amhrNmTNHxx13nAYPHiypqbs5ZcoUTZs2TR9//LGee+45vfvuu5o5c6YkKRwO6/XXX9fw4cM1efJkZWdna8yYMXrppZfs/moAAAA6VbcLoLfddptSU1PVq1cvbd26VS+//PIR1z3jjDOUmpqq2267TdXV1aqqqtIvfvELhUIh7dy5U5J0zjnnqKioSPfdd5/q6+u1b98+3X777ZIUWadHjx4qLCzUP/7xDyUnJystLU0LFizQm2++qUAgIEmaNWuWrrjiCt1444065phjdOaZZ+rBBx/Uk08+qdraWu3atUuVlZX6wx/+oClTpujf//63vvOd7+jiiy/WkiVLOvlbAwAAsI/nA+jvf/97paWlRR7vvPOOfvzjH0ctO3SSzi233KIPP/xQ//73v+X3+/X9739fxpgWt92nTx/Nnz9fr776qtLS0pSRkaHS0lKdeuqpkY7n8ccfr7///e+6//77lZKSotzcXOXn5ysnJyeyTk1Nja655hqNGzdO7733npYuXaoTTjhB559/vmpqaiRJH330kebOnRtV9+TJkxUOh7V58+bIOacXXnihbrrpJp188sm6/fbb9X/+z//Ro48+2plfMQAAgK0CThcQqx//+Mf67ne/G3l+xRVXaNq0abr44osjy/r16xf5e+/evdW7d28NHz5cxx13nPLy8vTee+9p7NixLW7/W9/6ljZt2qQ9e/YoEAgoMzNTubm5GjJkSGSd733ve/re976n4uJipaamyrIs/elPf4qs8/TTT+urr77S8uXLI6H06aefVs+ePfXyyy9r+vTpqqys1I9+9CP953/+Z7MaBg4cKEkKBAIaOXJk1GvHHXdcZKgfAADACzwfQLOyspSVlRV5npycrOzsbA0bNqzV9x7oKtbV1bW6bu/evSVJixYt0q5du3TBBRc0WycnJ0eS9PjjjyspKUmTJk2SJFVXV8vn88myrMi6B54fqOHUU0/V2rVrj1r36NGjtX79+qhlGzZs0KBBg1qtHwAAwC08PwTfVitWrNBf/vIXrVmzRlu2bNGiRYt0+eWXa+jQoZHu59dff60RI0bo/fffj7zviSee0HvvvadNmzbpH//4hy699FLddNNNOvbYYyPr/OUvf9Hq1au1YcMGzZ49WzNnztSsWbOUmZkpSZo0aZL27dunGTNmaN26dfrss8/0wx/+UIFAQAUFBZKazk1dtmyZZs6cqTVr1mjjxo16+eWXI5OQpKbTB5577jk99thj+uKLL/SXv/xFr776qn7605/G4RsEAACwidPT8O12pMswffzxx6agoMBkZWWZYDBoBg8ebH784x+b7du3R9bZvHmzkWQWL14cWXbbbbeZnJwck5CQYI455hhz//33m3A4HLXt//iP/zBZWVkmMTHRnHTSSebJJ59stv9///vfZty4cSYjI8P07NnTnHPOOWb58uVR67z//vtm0qRJJi0tzaSmppqTTjrJ/O53v4taZ86cOWbYsGEmKSnJjBo1yrz00ksd+JYAAACcYxlzhBk4AAAAQCfoNkPwAAAAcAdPTkIKh8PasWOHevToETWxBwAAAM4wxqiiokL9+vWLukFPSzwZQHfs2KG8vDynywAAAMBhtm3bpgEDBhx1HU8G0B49ekhq+oDp6ekOVwMAAIDy8nLl5eVFctrReDKAHhh2T09PJ4ACAAC4SFtOj2QSEgAAAOKKAAoAAIC4IoACAAAgrjx5DigAAOhcoVBIDQ0NTpcBF0lISJDf77dlWwRQAAAQYYxRUVGRSktLnS4FLpSZmanc3NyYr8NOAAUAABEHwmd2drZSUlK44QskNf2HSXV1tXbt2iVJ6tu3b0zbI4ACAABJTcPuB8Jnr169nC4HLpOcnCxJ2rVrl7Kzs2MajmcSEgAAkKTIOZ8pKSkOVwK3OvBvI9bzgwmgAAAgCsPuOBK7/m0QQAEAABBXnAMKAABaVV9Tp8aGxrjsK5AQUGJyMC77gjMIoAAA4Kjqa+r0+dJPZMImLvuzfJZGjDuRENqFMQQPAACOqrGhMW7hU5JM2HSo21pUVKQbbrhBQ4YMUTAYVF5enqZOnaqFCxdKkgYPHizLsmRZllJSUnTiiSfqf/7nf5ptJxQK6YEHHtCJJ56opKQk9ezZU+edd56WLl3abL0//OEPGjFihJKTk5WVlaUxY8ZEbXP37t36yU9+ooEDByoYDCo3N1eTJ0+ObGv69OmaMmVK1HYXLFggy7L0q1/9Kmr5r371Kw0cODBq2axZs+T3+3Xfffc1+xxz585VZmbmEb+vH/zgB7rooouilj3//PNKSkrS/ffff8T32YEACgAAPO+rr77SN77xDS1atEj33XefPvnkEy1YsEAFBQWaMWNGZL177rlHO3fu1Keffqorr7xS1113nd58883I68YYTZ8+Xffcc49+9rOfad26dSosLFReXp4mTJigl156KbLur3/9az3wwAP6zW9+o7Vr12rx4sW6/vrroy7iP23aNH344Yf6+9//rg0bNuiVV17RhAkTtHfvXklSQUGBli5dqsbGg4F78eLFysvLU2FhYdRnXLx4sQoKCqKWPf7447r11lv1+OOPx/wd/s///I+uuOIKPfLII/r5z38e8/aOhiF4AADgeT/96U9lWZbef/99paamRpYff/zxuvrqqyPPe/ToodzcXEnSbbfdpnvvvVdvvfWWzjvvPEnSvHnz9Pzzz+uVV17R1KlTI+/729/+pr179+raa6/VpEmTlJqaqldeeUU//elPdemll0bWGzVqVOTvpaWleuedd1RYWKjx48dLkgYNGqTTTz89sk5BQYEqKyu1atUqnXHGGZKkwsJC3X777fr5z3+u2tpaJSUlqba2VitWrNAPf/jDyHuXLFmimpoa3XPPPXryySe1bNkynXnmmR36/u69917dfffdevbZZ/Wd73ynQ9toDzqgAADA00pKSrRgwQLNmDEjKnwe0NIwdDgc1gsvvKB9+/YpMTExsvzpp5/W8OHDo8LnAT//+c+1d+9evfXWW5Kk3NxcLVq0SLt3726xrrS0NKWlpemll15SXV1di+sMHz5c/fr10+LFiyVJFRUVWr16tS699FINHjxYy5cvlyQtW7ZMdXV1UR3QOXPm6PLLL1dCQoIuv/xyzZkz5wjf0NHddttt+s1vfqPXXnstLuFTIoACAACP++KLL2SM0YgRI1pd97bbblNaWpqCwaAuueQS9ezZU9dee23k9Q0bNui4445r8b0Hlm/YsEGS9Kc//Um7d+9Wbm6uTjrpJP34xz+OGs4PBAKaO3eu/v73vyszM1Pjxo3Tf/3Xf+njjz+O2m5BQUFkuP2dd97R8OHD1adPH5199tmR5YWFhcrPz9egQYMkSeXl5Xr++ed15ZVXSpKuvPJKzZs3T5WVlW34xg568803de+99+rll1/Wueee2673xoIACgAAPM2Ytk+QuuWWW7RmzRotWrRIY8aM0QMPPKBhw4Z1aHsjR47Up59+qvfee09XX321du3apalTp0YF2mnTpmnHjh165ZVXNGXKFBUWFurUU0/V3LlzI+tMmDBBS5cuVUNDgwoLCzVhwgRJ0vjx46MC6KHdz2eeeUZDhw6NDPmffPLJGjRokJ577rk2fxeSdNJJJ2nw4MG6++672x1eY0EABQAAnnbMMcfIsix9/vnnra7bu3dvDRs2TN/85jc1f/58/ed/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IK4IoAAAAIgrAigAAADiigAKAACAuCKAAgAAIK4IoAAAAIgrAigAAADiigAKAACAuCKAAgAAIK4IoAAAAIgrAigAAADiigAKAACAuAo4XUBHGGMkSeXl5Q5XAgAAAOlgLjuQ047GkwG0oqJCkpSXl+dwJQAAADhURUWFMjIyjrqOZdoSU10mHA5rx44d6tGjhyzLktSUuvPy8rRt2zalp6c7XCEOxbFxL46Ne3Fs3Itj404cF+cZY1RRUaF+/frJ5zv6WZ6e7ID6fD4NGDCgxdfS09P5h+dSHBv34ti4F8fGvTg27sRxcVZrnc8DmIQEAACAuCKAAgAAIK66TAANBoO6++67FQwGnS4Fh+HYuBfHxr04Nu7FsXEnjou3eHISEgAAALyry3RAAQAA4A0EUAAAAMQVARQAAABxRQAFAABAXBFAAQAAEFdxDaBff/21rrzySvXq1UvJyck68cQTtWrVqqh11q1bpwsuuEAZGRlKTU3V6NGjtXXr1mbbMsbovPPOk2VZeumll1rc3969ezVgwABZlqXS0tLI8n/+85+aNGmS+vTpo/T0dI0dO1b/+te/mr1/9uzZGjx4sJKSkjRmzBi9//77MX1+N3PLsTnU0qVLFQgEdPLJJzd7rbscGzcdl7q6Ov1//9//p0GDBikYDGrw4MF6/PHHo9aZP3++RowYoaSkJJ144ol64403Yvr8buamY/PUU09p1KhRSklJUd++fXX11Vdr7969UetwbOw/NpZlNXs8++yzUesUFhbq1FNPVTAY1LBhwzR37txm++guv2eSe44NOcB5cQug+/bt07hx45SQkKA333xTa9eu1f3336+ePXtG1tm0aZPOOussjRgxQoWFhfr444915513Kikpqdn2/vznP0fuA38k11xzjU466aRmy99++21NmjRJb7zxhj744AMVFBRo6tSp+vDDDyPrPPfcc7r55pt19913a/Xq1Ro1apQmT56sXbt2xfAtuJObjs0BpaWl+v73v69zzz232Wvd5di47bh897vf1cKFCzVnzhytX79ezzzzjI499tjI68uWLdPll1+ua665Rh9++KEuuugiXXTRRfr00087+A24l5uOzdKlS/X9739f11xzjT777DPNnz9f77//vq677rrIOhybzjs2TzzxhHbu3Bl5XHTRRZHXNm/erPPPP18FBQVas2aNbrzxRl177bVRQae7/J5J7jo25AAXMHFy2223mbPOOuuo61x22WXmyiuvbHVbH374oenfv7/ZuXOnkWRefPHFZus8/PDDZvz48WbhwoVGktm3b99Rtzly5Ejz61//OvL89NNPNzNmzIg8D4VCpl+/fmbWrFmt1uc1bjw2l112mfnlL39p7r77bjNq1Kio17rLsXHTcXnzzTdNRkaG2bt37xH38d3vftecf/75UcvGjBljfvSjH7Van9e46djcd999ZsiQIVHrP/jgg6Z///6R5xybaHYdmyMdrwNuvfVWc/zxxzfb9+TJkyPPu8vvmTHuOjYt6c45wAlx64C+8sorOu2003TppZcqOztbp5xyih577LHI6+FwWK+//rqGDx+uyZMnKzs7W2PGjGnWVq+urtb3vvc9zZ49W7m5uS3ua+3atbrnnnv05JNPyudr/SOGw2FVVFQoKytLklRfX68PPvhAEydOjKzj8/k0ceJELV++vAOf3t3cdmyeeOIJffnll7r77rubvdadjo2bjsuBWu699171799fw4cP1y9+8QvV1NRE1lm+fHnUcZGkyZMnd7njIrnr2IwdO1bbtm3TG2+8IWOMiouL9fzzz+vb3/52ZB2OTeccG0maMWOGevfurdNPP12PP/64zCH3dmnte+9Ov2eSu47N4bp7DnBEvJJuMBg0wWDQ3HHHHWb16tXmr3/9q0lKSjJz5841xpjIf8WkpKSYP/3pT+bDDz80s2bNMpZlmcLCwsh2rr/+enPNNddEnuuw/8qpra01J510kvl//+//GWOMWbx4casd0D/+8Y+mZ8+epri42BhjzNdff20kmWXLlkWtd8stt5jTTz891q/Cddx0bDZs2GCys7PN+vXrjTGmWQe0Ox0bNx2XyZMnm2AwaM4//3yzYsUK8/rrr5tBgwaZH/zgB5F1EhISzNNPPx31GWbPnm2ys7Pt/FpcwU3Hxhhj5s2bZ9LS0kwgEDCSzNSpU019fX3kdY6N/cfGGGPuuece8+6775rVq1ebP/zhDyYYDJr//u//jrx+zDHHmN///vdR73n99deNJFNdXd2tfs+McdexOVx3zwFOiFsATUhIMGPHjo1adsMNN5gzzjjDGHPwYF9++eVR60ydOtVMnz7dGGPMyy+/bIYNG2YqKioirx/+D++mm24yl112WeR5awH0qaeeMikpKeatt96KLOtu//DccmwaGxvNaaedZh555JHIOt05gLrluBhjzKRJk0xSUpIpLS2NLHvhhReMZVmmuro6Um93CTluOjafffaZ6du3r7n33nvNRx99ZBYsWGBOPPFEc/XVV0fVy7Gx99i05M477zQDBgyIPCeARnPTsTkUOcAZcRuC79u3r0aOHBm17LjjjovMbOvdu7cCgcBR11m0aJE2bdqkzMxMBQIBBQIBSdK0adM0YcKEyDrz58+PvH5gEkvv3r2bDek+++yzuvbaazVv3ryoNnvv3r3l9/tVXFwctX5xcfFR2/1e5ZZjU1FRoVWrVmnmzJmRde655x599NFHCgQCWrRoUbc6Nm45Lgdq6d+/vzIyMqL2Y4zR9u3bJUm5ubnd4rhI7jo2s2bN0rhx43TLLbfopJNO0uTJk/Xwww/r8ccf186dOyVxbDrj2LRkzJgx2r59u+rq6iQd+XtPT09XcnJyt/o9k9x1bA4gBzgnEK8djRs3TuvXr49atmHDBg0aNEiSlJiYqNGjRx91ndtvv13XXntt1OsnnniiHnjgAU2dOlWS9MILL0Sdl7Zy5UpdffXVeueddzR06NDI8meeeUZXX321nn32WZ1//vlR20xMTNQ3vvENLVy4MDJrLhwOa+HChZo5c2YM34I7ueXYpKen65NPPonaxsMPP6xFixbp+eefV35+frc6Nm45LgdqmT9/viorK5WWlhbZj8/n04ABAyQ1nYu4cOFC3XjjjZFtvfXWWxo7dmysX4XruOnYVFdXR/5P+AC/3y9JkXPeODb2H5uWrFmzRj179lQwGJTU9L0ffrmrQ7/37vR7Jrnr2EjkAMfFq9X6/vvvm0AgYH73u9+ZjRs3Rlre//jHPyLr/POf/zQJCQnmb3/7m9m4caN56KGHjN/vN++8884Rt6tWWu8tDVk99dRTJhAImNmzZ5udO3dGHocOLz777LMmGAyauXPnmrVr15rrr7/eZGZmmqKiopi+Bzdy07E5XEuz4LvLsXHTcamoqDADBgwwl1xyifnss8/MkiVLzDHHHGOuvfbayDpLly41gUDA/N//+3/NunXrzN13320SEhLMJ598EtP34EZuOjZPPPGECQQC5uGHHzabNm0y7777rjnttNOihgk5NvYfm1deecU89thj5pNPPjEbN240Dz/8sElJSTF33XVXZJ0vv/zSpKSkmFtuucWsW7fOzJ492/j9frNgwYLIOt3l98wYdx0bcoDz4hZAjTHm1VdfNSeccIIJBoNmxIgR5m9/+1uzdebMmWOGDRtmkpKSzKhRo8xLL7101G125Ad7/PjxRlKzx1VXXRX13oceesgMHDjQJCYmmtNPP92899577fm4nuKWY3O4lgKoMd3n2LjpuKxbt85MnDjRJCcnmwEDBpibb745cv7nAfPmzTPDhw83iYmJ5vjjjzevv/56mz+r17jp2Dz44INm5MiRJjk52fTt29dcccUVZvv27VHrcGyixXps3nzzTXPyySebtLQ0k5qaakaNGmUeffRREwqFot63ePFic/LJJ5vExEQzZMgQ88QTTzTbdnf5PTPGPceGHOA8y5ijXJcAAAAAsBn3ggcAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxNX/D/ZNPOwbeLaBAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "misc_map_surfaces: List[MapLayer] = [\n", " MapLayer.CROSSWALK,\n", @@ -570,7 +701,7 @@ "id": "28", "metadata": {}, "source": [ - "### 2.3.5 `RoadEdge`\n", + "### 2.5.5 `RoadEdge`\n", "\n", "In contrast to all previous road objects, we now have a look at map objects that are lines. \n", "\n", @@ -594,10 +725,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "30", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "- RoadEdgeType.UNKNOWN\n", + "- RoadEdgeType.ROAD_EDGE_BOUNDARY\n", + "- RoadEdgeType.ROAD_EDGE_MEDIAN\n" + ] + } + ], "source": [ "for road_edege_type in RoadEdgeType:\n", " print(f\"- {road_edege_type}\")" @@ -613,10 +754,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "32", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvEAAALvCAYAAADs5JoKAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAp+5JREFUeJzs3Xd4HNXVx/HfzK66LLlbNtjG2AZM74SWGDCY3gyEEkISahJCgJAACYSEFAcIoYRiyEtCKKYldELvEDAdEnBwAYyxccNFlqy2O/P+Ie1oRl3eu7vS3e/neYRXu6PRlTTMnDl77rmO7/u+AAAAAPQbbq4HAAAAAKB3COIBAACAfoYgHgAAAOhnCOIBAACAfoYgHgAAAOhnCOIBAACAfoYgHgAAAOhnCOIBAACAfoYgHgAAAOhnCOIBAACAfiavgvhDDz1UY8aMUXFxsUaOHKkTTzxRixcv7vJr5s+fryOOOELDhg1TRUWFjjnmGC1dujSyzTvvvKN9991XAwcO1JAhQ3TaaaeppqYmss2bb76pffbZRwMHDtSgQYM0depUvf/++73+GWbPnq1DDz1UlZWVKisr00477aTPP/+81/sBAABA/2VdED958mTdeuutHb6211576d5779XHH3+sf/7zn5o/f76OOuqoTvdVW1ur/fbbT47j6LnnntOrr76qxsZGHXLIIfI8T5K0ePFiTZkyRRMmTNCsWbP0xBNP6MMPP9R3vvOdYD81NTXaf//9NWbMGM2aNUuvvPKKBgwYoKlTp6qpqanHP9v8+fO1xx57aLPNNtMLL7ygDz74QBdffLGKi4t7vA8AAAD0f47v+36uB2HS5MmT9Z3vfCcSRHfm4Ycf1uGHH66GhgYVFBS0e/2pp57SAQccoFWrVqmiokKStGbNGg0aNEhPPfWUpkyZoptvvlkXX3yxvvzyS7lu8z3Rf/7zH2299daaO3euJkyYoLfeeivImI8ePbrDbSTplVde0YUXXqi33npLQ4cO1RFHHKHp06errKxMknTssceqoKBAt99+u4lfFQAAAPop6zLxPbVy5Urdeeed2m233ToM4CWpoaFBjuOoqKgoeK64uFiu6+qVV14JtiksLAwCeEkqKSmRpGCbTTfdVEOGDNEtt9yixsZG1dXV6ZZbbtGkSZO00UYbSWrOsu+///6aNm2aPvjgA91zzz165ZVXdOaZZ0qSPM/TY489pk022URTp07V8OHDtcsuu+jBBx80/asBAABAH5d3Qfz555+vsrIyDRkyRJ9//rkeeuihTrf92te+prKyMp1//vlat26damtrdd555ymZTOrLL7+UJO29995asmSJrrjiCjU2NmrVqlW64IILJCnYZsCAAXrhhRd0xx13qKSkROXl5XriiSf0+OOPKx6PS5KmT5+uE044QWeffbYmTpyo3XbbTddee61uu+021dfXa9myZaqpqdEf/vAH7b///nrqqad0xBFH6Mgjj9SLL76Y4d8aAAAA+pJ+H8T//ve/V3l5efDx8ssv64wzzog8F574+dOf/lTvvvuunnrqKcViMX37299WZxVFw4YN03333adHHnlE5eXlqqys1OrVq7X99tsHmfcttthCf//733XllVeqtLRUVVVVGjdunEaMGBFsU1dXp5NPPlm77767Xn/9db366qvacsstddBBB6murk6S9P777+vWW2+NjHvq1KnyPE+ffvppUIN/2GGH6ZxzztG2226rCy64QAcffLBmzJiRyV8xAAAA+ph4rgeQrjPOOEPHHHNM8PkJJ5ygadOm6cgjjwyeGzVqVPB46NChGjp0qDbZZBNNmjRJo0eP1uuvv65dd921w/3vt99+mj9/vlasWKF4PK6BAweqqqpKG2+8cbDN8ccfr+OPP15Lly5VWVmZHMfRn/70p2CbmTNn6rPPPtNrr70WBPYzZ87UoEGD9NBDD+nYY49VTU2NTj/9dJ111lntxjBmzBhJUjwe1+abbx55bdKkSUHZDgAAAPJDvw/iBw8erMGDBwefl5SUaPjw4cFk0a6kstsNDQ3dbjt06FBJ0nPPPadly5bp0EMPbbfNiBEjJEl//etfVVxcrH333VeStG7dOrmuK8dxgm1Tn6fGsP322+ujjz7qctw77bSTPv7448hzc+bM0dixY7sdPwAAAOzR78tpemrWrFm67rrr9N5772nBggV67rnndNxxx2n8+PFBFn7RokXabLPN9MYbbwRf97e//U2vv/665s+frzvuuENHH320zjnnHG266abBNtddd53eeecdzZkzR9dff73OPPNMTZ8+XQMHDpQk7bvvvlq1apV++MMfavbs2frwww/13e9+V/F4XHvttZek5lr9f//73zrzzDP13nvvae7cuXrooYeCia1ScynQPffc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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "drivable_surface_layers = [\n", " MapLayer.LANE_GROUP,\n", @@ -670,17 +822,39 @@ "id": "33", "metadata": {}, "source": [ - "### 2.3.6 `RoadLine`\n", + "### 2.5.6 `RoadLine`\n", "\n", "Road lines are lane markings that are in the map, either defined by a 3D or 2D polyline. A road line has a `RoadLineType`, which is one of:" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "34", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "- RoadLineType.NONE\n", + "- RoadLineType.UNKNOWN\n", + "- RoadLineType.DASH_SOLID_YELLOW\n", + "- RoadLineType.DASH_SOLID_WHITE\n", + "- RoadLineType.DASHED_WHITE\n", + "- RoadLineType.DASHED_YELLOW\n", + "- RoadLineType.DOUBLE_SOLID_YELLOW\n", + "- RoadLineType.DOUBLE_SOLID_WHITE\n", + "- RoadLineType.DOUBLE_DASH_YELLOW\n", + "- RoadLineType.DOUBLE_DASH_WHITE\n", + "- RoadLineType.SOLID_YELLOW\n", + "- RoadLineType.SOLID_WHITE\n", + "- RoadLineType.SOLID_DASH_WHITE\n", + "- RoadLineType.SOLID_DASH_YELLOW\n", + "- RoadLineType.SOLID_BLUE\n" + ] + } + ], "source": [ "for road_line_type in RoadLineType:\n", " print(f\"- {road_line_type}\")" @@ -696,10 +870,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "36", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "road_line_color_map = {\n", " RoadLineType.NONE: \"grey\",\n", diff --git a/tutorial/03_visualization.ipynb b/tutorial/03_visualization.ipynb new file mode 100644 index 00000000..bec0f7e3 --- /dev/null +++ b/tutorial/03_visualization.ipynb @@ -0,0 +1,297 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "

\n", + " \n", + " \n", + " \n", + " \"Logo\"\n", + " \n", + "

123D: Visualization Tutorial

\n", + "" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List, Optional\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from py123d.api import MapAPI, SceneAPI, SceneFilter\n", + "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", + "from py123d.common.multithreading.worker_parallel import SingleMachineParallelExecutor\n", + "from py123d.datatypes.map_objects import (\n", + " BaseMapLineObject,\n", + " BaseMapObject,\n", + " BaseMapSurfaceObject,\n", + " Intersection,\n", + " Lane,\n", + " LaneGroup,\n", + " MapLayer,\n", + " RoadEdgeType,\n", + " RoadLineType,\n", + ")\n", + "from py123d.geometry import Point3D, Polyline2D\n", + "from py123d.visualization.color.default import MAP_SURFACE_CONFIG\n", + "from py123d.visualization.matplotlib.utils import add_non_repeating_legend_to_ax\n" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## 3.1 Download Demo Logs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 3.2 Create Scenes by filtering the datasets\n", + "\n", + "We create some scenes for easy access to some `MapAPI`'s. We use the option `map_api_required=True` to only include scenes/logs with maps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.datatypes.sensors.pinhole_camera import PinholeCameraType\n", + "\n", + "scene_filter = SceneFilter(\n", + " split_names=None,\n", + " duration_s=8.0, # No duration means that the scene will include the complete log.\n", + " timestamp_threshold_s=8.0,\n", + " shuffle=True,\n", + " map_api_required=True, # Only include scenes/logs with an available map API.\n", + " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", + "\n", + ")\n", + "worker = SingleMachineParallelExecutor()\n", + "scenes = ArrowSceneBuilder().get_scenes(scene_filter, worker)\n", + "print(f\"Found {len(scenes)} scenes.\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 3.3 Matplotlib\n" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### 3.3.1 Plots in 2D\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.visualization.matplotlib.plots import add_scene_on_ax\n", + "\n", + "scene: SceneAPI = np.random.choice(scenes)\n", + "map_api: MapAPI = scene.get_map_api()\n", + "fig, ax = plt.subplots(figsize=(10, 10))\n", + "add_scene_on_ax(ax, scene, iteration=0, radius=80)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d8014d7f", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from py123d.visualization.matplotlib.plots import render_scene_animation\n", + "\n", + "save_path = Path(\"./visualization\")\n", + "save_path.mkdir(parents=True, exist_ok=True)\n", + "\n", + "fps = 1 / scene.log_metadata.timestep_seconds \n", + "\n", + "render_scene_animation(\n", + " scene=scene,\n", + " output_path=save_path,\n", + " start_idx=0,\n", + " end_idx=None,\n", + " step=1,\n", + " fps=fps * 2, # Let's speed it up a bit\n", + " dpi=300,\n", + " format=\"mp4\",\n", + " radius=80,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "03dc6b4d", + "metadata": {}, + "source": [ + "### 3.3.2 Plots of Cameras" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f152013f", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.visualization.matplotlib.camera import add_pinhole_camera_ax\n", + "\n", + "iteration = 0\n", + "available_pinhole_cameras = scene.available_pinhole_camera_types\n", + "if len(available_pinhole_cameras) > 1:\n", + " pinhole_camera_type = np.random.choice(available_pinhole_cameras)\n", + " pinhole_camera = scene.get_pinhole_camera_at_iteration(iteration=iteration, camera_type=pinhole_camera_type)\n", + "\n", + " fig, ax = plt.subplots(figsize=(8, 6))\n", + " add_pinhole_camera_ax(ax, pinhole_camera)\n", + " ax.set_title(f\"{pinhole_camera_type} at Iteration {iteration}\")\n", + " plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9305efbf", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.visualization.matplotlib.camera import add_box_detections_to_camera_ax\n", + "\n", + "iteration = 0\n", + "available_pinhole_cameras = scene.available_pinhole_camera_types\n", + "if len(available_pinhole_cameras) > 1:\n", + " pinhole_camera_type = np.random.choice(available_pinhole_cameras)\n", + " pinhole_camera = scene.get_pinhole_camera_at_iteration(iteration=iteration, camera_type=pinhole_camera_type)\n", + " box_detections = scene.get_box_detections_at_iteration(iteration=iteration)\n", + " ego_state = scene.get_ego_state_at_iteration(iteration=iteration)\n", + "\n", + " fig, ax = plt.subplots(figsize=(8, 6))\n", + " add_box_detections_to_camera_ax(ax, pinhole_camera, box_detections, ego_state)\n", + " ax.set_title(f\"{pinhole_camera_type} at Iteration {iteration}\")\n", + " plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c50619fe", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.visualization.matplotlib.camera import add_lidar_to_camera_ax\n", + "\n", + "available_pinhole_cameras = scene.available_pinhole_camera_types\n", + "available_lidars = scene.available_lidar_types\n", + "\n", + "iteration = 20\n", + "if len(available_pinhole_cameras) > 1 and len(available_lidars) > 0:\n", + "\n", + " pinhole_camera_type = np.random.choice(available_pinhole_cameras)\n", + " pinhole_camera = scene.get_pinhole_camera_at_iteration(iteration=iteration, camera_type=pinhole_camera_type)\n", + " lidar_type = np.random.choice(available_lidars)\n", + " lidar = scene.get_lidar_at_iteration(iteration=iteration, lidar_type=lidar_type)\n", + "\n", + " fig, ax = plt.subplots(figsize=(8, 6))\n", + " add_lidar_to_camera_ax(ax, pinhole_camera, lidar)\n", + " ax.set_title(f\"{pinhole_camera_type} & {lidar_type} at Iteration {iteration}\")\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "70888c8b", + "metadata": {}, + "source": [ + "### 3.4. Visualization in 3D with Viser\n", + "\n", + "Can also be run in the `example/01_viser.py` or by running \n", + "\n", + "```sh\n", + "py123d-viser scene_filter=...\n", + "```\n", + "\n", + "in your terminal. By default, you can open viewer via: [`http://localhost:8080`](http://localhost:8080 )\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7463bfba", + "metadata": {}, + "outputs": [], + "source": [ + "from py123d.visualization.viser.viser_viewer import ViserViewer\n", + "\n", + "ViserViewer(scenes)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ace08e04", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "py123d_dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From bde0dd874d0a320772e20f15076a067dd36e9319 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 16:03:37 +0100 Subject: [PATCH 44/50] Run pre-commit manually. --- src/py123d/visualization/color/color.py | 12 +- src/py123d/visualization/matplotlib/camera.py | 42 +-- src/py123d/visualization/matplotlib/utils.py | 14 +- tutorial/02_map_tutorial.ipynb | 249 +++--------------- tutorial/03_visualization.ipynb | 40 +-- 5 files changed, 82 insertions(+), 275 deletions(-) diff --git a/src/py123d/visualization/color/color.py b/src/py123d/visualization/color/color.py index 19dc03a7..1d9329d7 100644 --- a/src/py123d/visualization/color/color.py +++ b/src/py123d/visualization/color/color.py @@ -43,11 +43,13 @@ def rgba_norm(self) -> Tuple[float, float, float, float]: def set_brightness(self, factor: float) -> Color: """Return a new Color with adjusted brightness.""" r, g, b = self.rgb - return Color.from_rgb(( - max(min(int(r * factor), 255), 0), - max(min(int(g * factor), 255), 0), - max(min(int(b * factor), 255), 0), - )) + return Color.from_rgb( + ( + max(min(int(r * factor), 255), 0), + max(min(int(g * factor), 255), 0), + max(min(int(b * factor), 255), 0), + ) + ) def __str__(self) -> str: """Return the string representation of the color.""" diff --git a/src/py123d/visualization/matplotlib/camera.py b/src/py123d/visualization/matplotlib/camera.py index ef8be171..8bd8b064 100644 --- a/src/py123d/visualization/matplotlib/camera.py +++ b/src/py123d/visualization/matplotlib/camera.py @@ -71,9 +71,9 @@ def add_box_detections_to_camera_ax( [detection.metadata.default_label for detection in box_detections.box_detections], dtype=object ) for idx, box_detection in enumerate(box_detections.box_detections): - assert isinstance(box_detection, BoxDetectionSE3), ( - f"Box detection must be of type BoxDetectionSE3, got {type(box_detection)}" - ) + assert isinstance( + box_detection, BoxDetectionSE3 + ), f"Box detection must be of type BoxDetectionSE3, got {type(box_detection)}" box_detection_array[idx] = box_detection.bounding_box_se3.array # FIXME @@ -150,23 +150,29 @@ def _rotation_3d_in_axis(points: npt.NDArray[np.float32], angles: npt.NDArray[np ones = np.ones_like(rot_cos) zeros = np.zeros_like(rot_cos) if axis == 1: - rot_mat_T = np.stack([ - np.stack([rot_cos, zeros, -rot_sin]), - np.stack([zeros, ones, zeros]), - np.stack([rot_sin, zeros, rot_cos]), - ]) + rot_mat_T = np.stack( + [ + np.stack([rot_cos, zeros, -rot_sin]), + np.stack([zeros, ones, zeros]), + np.stack([rot_sin, zeros, rot_cos]), + ] + ) elif axis in [2, -1]: - rot_mat_T = np.stack([ - np.stack([rot_cos, -rot_sin, zeros]), - np.stack([rot_sin, rot_cos, zeros]), - np.stack([zeros, zeros, ones]), - ]) + rot_mat_T = np.stack( + [ + np.stack([rot_cos, -rot_sin, zeros]), + np.stack([rot_sin, rot_cos, zeros]), + np.stack([zeros, zeros, ones]), + ] + ) elif axis == 0: - rot_mat_T = np.stack([ - np.stack([zeros, rot_cos, -rot_sin]), - np.stack([zeros, rot_sin, rot_cos]), - np.stack([ones, zeros, zeros]), - ]) + rot_mat_T = np.stack( + [ + np.stack([zeros, rot_cos, -rot_sin]), + np.stack([zeros, rot_sin, rot_cos]), + np.stack([ones, zeros, zeros]), + ] + ) else: raise ValueError(f"axis should in range [0, 1, 2], got {axis}") return np.einsum("aij,jka->aik", points, rot_mat_T) diff --git a/src/py123d/visualization/matplotlib/utils.py b/src/py123d/visualization/matplotlib/utils.py index c305bf1a..b888d67b 100644 --- a/src/py123d/visualization/matplotlib/utils.py +++ b/src/py123d/visualization/matplotlib/utils.py @@ -106,12 +106,14 @@ def add_shapely_linestring_to_ax( def get_pose_triangle(size: float) -> geom.Polygon: """Create a triangle shape for the pose.""" half_size = size / 2 - return geom.Polygon([ - [-half_size, -half_size], - [half_size, 0], - [-half_size, half_size], - [-size / 4, 0], - ]) + return geom.Polygon( + [ + [-half_size, -half_size], + [half_size, 0], + [-half_size, half_size], + [-size / 4, 0], + ] + ) def shapely_geometry_local_coords( diff --git a/tutorial/02_map_tutorial.ipynb b/tutorial/02_map_tutorial.ipynb index 4085b489..2236573b 100644 --- a/tutorial/02_map_tutorial.ipynb +++ b/tutorial/02_map_tutorial.ipynb @@ -17,18 +17,10 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "1", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dataset paths not set. Using default config: /home/daniel/py123d_workspace/py123d/src/py123d/script/config/common/default_dataset_paths.yaml\n" - ] - } - ], + "outputs": [], "source": [ "from typing import List, Optional\n", "\n", @@ -68,7 +60,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "3", "metadata": {}, "outputs": [], @@ -88,18 +80,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "5", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Found 74 scenes.\n" - ] - } - ], + "outputs": [], "source": [ "scene_filter = SceneFilter(\n", " split_names=None,\n", @@ -132,21 +116,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "8", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "MapMetadata(dataset='nuplan', split=None, log_name=None, location='us-nv-las-vegas-strip', map_has_z=False, map_is_local=False, version='0.0.8')" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "scene: SceneAPI = np.random.choice(scenes)\n", "map_api: MapAPI = scene.get_map_api()\n", @@ -167,30 +140,10 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "10", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "map_api.get_available_map_layers()" ] @@ -211,28 +164,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "12", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Map objects found in radius 100.0 around point Point3D(x=664457.8851674196, y=3998094.9338303064, z=615.8749372468315):\n", - "- LANE: 87 objects\n", - "- LANE_GROUP: 29 objects\n", - "- INTERSECTION: 10 objects\n", - "- CROSSWALK: 3 objects\n", - "- WALKWAY: 4 objects\n", - "- CARPARK: 0 objects\n", - "- GENERIC_DRIVABLE: 0 objects\n", - "- STOP_ZONE: 0 objects\n", - "- ROAD_EDGE: 14 objects\n", - "- ROAD_LINE: 215 objects\n" - ] - } - ], + "outputs": [], "source": [ "# You can define a query point and radius to search for map objects around that point.\n", "query_point: Optional[Point3D] = None # e.g. Point(x=0.0, y=0.0, z=0.0)\n", @@ -278,7 +213,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "14", "metadata": {}, "outputs": [], @@ -366,21 +301,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "16", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAtsAAAMJCAYAAADS8fo4AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAYFpJREFUeJzt3XmYFNXd9vG7uodZHYZFVtlRQVGiLBJE3AWNQdAoRDEqGo0RBXGJ8BoBURYD5sFHiRI16BMxxCgYt+ASxAWJoqISBUQFxYVF2bdZus/7R0/VdPf0AINT032mvp/r6qu71j7TNQx3/frUKccYYwQAAACgxoXS3QAAAACgriJsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDQACNHz9ejuPsc73LLrtM7dq1S5jnOI7Gjx9fY21Zs2aNHMfRI488UmP7BIBMQdgGLPXII4/IcRy9++676W5KtYwaNUrdunVTo0aNlJ+fryOOOELjx4/Xjh07EtZbsmSJrr32WnXp0kUFBQVq06aNBg8erE8//bTSPi+77DI5jlPp0blz54T13FCX6jFnzhxff25UzwsvvFAjgX7Pnj2aPHmyjjzySOXn5+uQQw7RBRdcoI8//ni/tv/ss890/vnnq2HDhsrPz9cJJ5ygV199da/blJaW6sgjj5TjOJo2bdqP/hmq49lnn1UoFNK6detq9X0BVC0r3Q0AECxLlixR3759NWzYMOXm5mrp0qWaMmWKXnnlFb3++usKhWI1gLvuukuLFi3SBRdcoK5du2rdunW677771K1bN/3nP//RUUcdlbDfnJwcPfTQQwnzioqKUrbhwgsv1M9+9rOEeb17967Bn7Ju2717t7Kyau6/j7Zt22r37t2qV6+eN++FF17QjBkzfnTgHjp0qJ555hldeeWV6tatm7799lvNmDFDvXv31rJly9S2bdsqt127dq169+6tcDism2++WQUFBZo1a5b69eunf//73zrxxBNTbnfvvffqq6+++lHtPlDPP/+8unfvrubNm6fl/QFURtgGUKvefPPNSvM6duyom266Se+8845++tOfSpJuuOEGPf7448rOzvbWGzJkiI4++mhNmTJFjz32WMI+srKydPHFF+9XG7p167bf66Ky3NzcGt2f4zg1vk9J+uabbzR37lzddNNNmjp1qje/b9++OvXUUzV37lyNGjWqyu2nTJmiLVu26L///a86deokSbryyivVuXNnjRo1Su+9916lbTZs2KAJEybolltu0dixY2v8Z9qXF154QZdffnmtvy+AqtGNBKjDSkpKNHbsWHXv3l1FRUUqKChQ3759K30N7navmDZtmv785z+rY8eOysnJUc+ePbVkyZJK+12xYoXOP/98NWrUSLm5uerRo4eeeeaZA26n2yd4y5Yt3rzjjz8+IWhL0mGHHaYuXbpo+fLlKfcTiUS0bdu2/XrPnTt3qqSk5IDam+zee+9Vly5dlJ+fr4YNG6pHjx56/PHHveVu/+gVK1Zo8ODBql+/vho3bqyRI0dqz549lfb32GOPqXv37srLy1OjRo30y1/+UmvXrq203ttvv60zzzxTRUVFys/P10knnaRFixZVWu/NN99Uz549lZubq44dO2rmzJk/6udN7rPt/nyffvqpLr74YhUVFalJkya67bbbZIzR2rVrNXDgQNWvX1/NmzfX3XffnbC/5D7bl112mWbMmOG9l/twfffdd1qxYoVKS0v32s7t27dLkpo1a5Ywv0WLFpKkvLy8vW7/xhtv6Nhjj/WCtiTl5+frnHPO0fvvv69Vq1ZV2mb06NHq1KlTtU7m4v/9zZgxQx06dFB+fr769euntWvXyhijO+64Q61atVJeXp4GDhyoTZs2VdrPsmXLtHbtWp199tnevH39bgLwH2EbqMO2bdumhx56SCeffLLuuusujR8/Xhs3blT//v31wQcfVFr/8ccf19SpU/Wb3/xGd955p9asWaPzzjsvIdR8/PHH+ulPf6rly5dr9OjRuvvuu1VQUKBBgwZp3rx5+9WusrIyff/99/r222/10ksv6fe//70KCwt13HHH7XU7Y4zWr1+vgw8+uNKyXbt2qX79+ioqKlKjRo00fPjwSv3AXbfffrsOOugg5ebmqmfPnnrppZf2q92pPPjggxoxYoSOPPJITZ8+XbfffruOOeYYvf3225XWHTx4sNeH+Gc/+5n+93//V1dddVXCOhMnTtQll1yiww47TH/84x91/fXXe10W4k9GFixYoBNPPFHbtm3TuHHjNGnSJG3ZskWnnnqq3nnnHW+9ZcuWqV+/ftqwYYPGjx+vYcOGady4cft9rKpjyJAhikajmjJlinr16qU777xT06dP1xlnnKFDDjlEd911lw499FDddNNNev3116vcz29+8xudccYZkqS//vWv3sM1ZswYHXHEEfrmm2/22p6OHTuqVatWuvvuu/Xss8/q66+/1jvvvKOrr75a7du31y9/+cu9bl9cXJwykOfn50tSpcr2O++8o0cffVTTp0/fr4tPk82ePVt/+tOfdN111+nGG2/Ua6+9psGDB+v3v/+95s+fr1tuuUVXXXWVnn32Wd10002Vtn/hhRfUtGlT9ejRQ1L1fjcB+MgAsNKsWbOMJLNkyZIq1ykrKzPFxcUJ8zZv3myaNWtmLr/8cm/e6tWrjSTTuHFjs2nTJm/+P//5TyPJPPvss9680047zRx99NFmz5493rxoNGqOP/54c9hhh+1X2xcvXmwkeY9OnTqZV199dZ/b/fWvfzWSzMMPP5wwf/To0eaWW24xf//7383f/vY3c+mllxpJpk+fPqa0tNRb78svvzT9+vUz999/v3nmmWfM9OnTTZs2bUwoFDLPPffcfrU92cCBA02XLl32us64ceOMJHPOOeckzL/mmmuMJPPhhx8aY4xZs2aNCYfDZuLEiQnrLVu2zGRlZXnzo9GoOeyww0z//v1NNBr11tu1a5dp3769OeOMM7x5gwYNMrm5uebLL7/05n3yyScmHA6b/fkv4NJLLzVt27ZNmCfJjBs3rtLPd9VVV3nzysrKTKtWrYzjOGbKlCne/M2bN5u8vDxz6aWXevPc379Zs2Z584YPH15l+9zju3r16n22/+233zYdO3ZM+H3r3r27+e677/a57YABA0yDBg3Mtm3bEub37t3bSDLTpk3z5kWjUXPccceZCy+8MOFnmjp16j7fx123SZMmZsuWLd78MWPGGEnmJz/5ScLv8YUXXmiys7MT/g0aY0zfvn0TPtf9+d0E4D8q20AdFg6Hva4Y0WhUmzZtUllZmXr06KH333+/0vpDhgxRw4YNvem+fftKkr744gtJ0qZNm7RgwQINHjxY27dv1/fff6/vv/9eP/zwg/r3769Vq1bts9ooSUceeaRefvllPf300/rd736ngoKCKqvQrhUrVmj48OHq3bu3Lr300oRlkydP1pQpUzR48GD98pe/1COPPKKJEydq0aJFevLJJ7312rRpoxdffFFXX321BgwYoJEjR2rp0qVq0qSJbrzxxn22O5UGDRro66+/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P/ywSkpKtHv3bj388MM64ogjvP8cFy9erBNPPDFhiLL+/ftr5cqVCV/xTZgwQU2bNk3ouxjv2muv1eLFizVnzhx99NFHuuCCC3TmmWd6/0k+++yz6tChg5577jm1b99e7dq1069//esDqsQhONyKWHwF7O2339bixYsPaH9NmzbVySefrJkzZ+q7776rtHzjxo173X7Lli0ph2tzb0bjjqIgxQLSkCFDtHjxYv3jH/+o8qYze/bs8YZ4i3fHHXfIGKMzzzxzr+375ptv9Je//EVdu3b1hoSrjh9++CFhOjs7W0ceeaSMMZV+Vnc4O9e9994rKdZdQYpV+MPhsG6//fZKlV9jjPde3bt3V8eOHTVt2rSUfd3dnzMcDqt///56+umnE26ysnz5cq/bQaYqKCiQpEqFCWn/h/5zT1iSvzF55plntHPnTh177LHevK1bt2rFihUpu1fFe+uttzR37lxdccUVXhV5xIgRmjdvXsLD/Rbisssu07x583wtjixZskQbNmxI6K8tVe93E4B/rKhsS7G+Z/fdd5927dqln/70p3ruueeqXPenP/2pCgoKdMstt2jSpEkyxmj06NGKRCJeQDj11FN1ww03aOrUqRo5cqR27typ0aNHS5K3TmFhoRYuXKhBgwbpjjvukBTrc/niiy96F6usW7eu0h9Rd0zXdevWqWHDhnrzzTf18MMPpxxqTYpVRWbNmqWvvvrK+4r8pptu0vz58zVr1ixNmjRJX3zxhb788kv94x//0P/93/8pEolo1KhROv/887VgwYID/FRRF/zlL3/R/PnzK80fOXKkfv7zn2vu3Lk699xzdfbZZ2v16tV64IEHdOSRR+7zgsSqzJgxQyeccIKOPvpoXXnllerQoYPWr1+vxYsX6+uvv66y37UkLVy4UCNGjND555+vww47TCUlJXrjjTc0d+5c9ejRI2Fs4htvvFHPPPOMBgwYoE2bNlW6iY277rp163Tsscfqwgsv9G7P/uKLL+qFF17QmWeeqYEDB3rb/O53v9Pnn3+u0047TS1bttSaNWs0c+ZM7dy5U/fcc0/C/h955BENGzZMs2bNSuhelqxfv35q3ry5+vTpo2bNmmn58uW67777dPbZZ6uwsDBh3dWrV+ucc87RmWeeqcWLF+uxxx7TRRddpJ/85CeSYhXrO++8U2PGjNGaNWs0aNAgFRYWavXq1Zo3b56uuuoq3XTTTQqFQnrooYd01llnqUuXLho2bJgOOeQQffPNN3r11VdVv359Pfvss5JiwxzOnz9fffv21TXXXKOysjJv7OWPPvqoyp8r3bp37y4pFmT79++vcDjsDdU3ZswYPfroo1q9evVeL5IcMGCAunTpogkTJujLL7/UT3/6U3322We677771KJFi4Tix7x58yod7y+//FKDBw/WOeeco+bNm+vjjz/2vgGdNGmSt223bt3UrVu3hPd2++F36dLlgK7RqY7nn39e7dq1q/TNT3V+NwH4qLaHP3FNnDjRFBQUeI9QKGRycnIS5sUPVbVx40azcuVK89JLL5k+ffqYn/3sZwlDXiV78cUXTYcOHYzjOCYcDpuLL77YdOvWzVx99dXeOrNnzzbNmjUz4XDYZGdnm5tuusk0a9bMG6Zq165d5rjjjjOXXHKJeeedd8zixYvNL37xC9OlSxeza9cuY4wxZ5xxRsJwV8YY8/HHHxtJ5pNPPjHbtm0z7dq1My+88IK3/NJLLzUDBw70pp977jkjKeFnLygoMFlZWWbw4MHGGGOuvPJKI8msXLnS2+69994zksyKFSsO4AjAdu7Qf1U91q5da6LRqJk0aZJp27atycnJMccee6x57rnnKg3ntrdhypQ0zJsxxnz++efmkksuMc2bNzf16tUzhxxyiPn5z39unnzyyb22+bPPPjOXXHKJ6dChg8nLyzO5ubmmS5cuZty4cWbHjh0J65500kl7/flcmzdvNhdffLE59NBDTX5+vsnJyTFdunQxkyZNMiUlJQn7fPzxx82JJ55omjRpYrKysszBBx9szj33XPPee+9Vauu9995rJJn58+fv9WeaOXOmOfHEE03jxo1NTk6O6dixo7n55pvN1q1bvXXcofE++eQTc/7555vCwkLTsGFDc+2115rdu3dX2udTTz1lTjjhBO9vQefOnc3w4cMT/v0bY8zSpUvNeeed571327ZtzeDBg82///3vhPVee+010717d5OdnW06dOhgHnjgAa9N+1Kdof82btxYaduCgoJK+zzppJMShqRLNfRfWVmZue6660yTJk2M4zgJba3O0H+bNm0yo0aNMocffrjJyckxBx98sPnlL39pvvjii4T13H9P8W3YtGmTGThwoGnevLnJzs427du3N7fcckuloQBTOZCh/5LXffXVV40k849//CNlW91hP3v06GGuueaaSvvdn99NAP5LW9j+4YcfzKpVq7zHcccdZ+66666EefHjisZbu3atkWTeeuutfb7Pxo0bzebNm40xxjRr1sz84Q9/qLTOunXrzPbt282OHTtMKBQyTzzxhDHGmIceesg0bdrURCIRb93i4mKTn59v/va3vxljjPnVr36VEJyNMWbBggVGktm0aZNZunSpkWTC4bD3cBzHOwn47LPPzJw5c0w4HDYrVqxI+PlXrVrljQU7duxYk5WVlfA+u3btMpLMSy+9tM/PAUD1XHDBBaZnz541sq+qwijwY61bt844jmOef/75dDcFQBXS1o2kUaNGatSokTedl5enpk2b7tdIBu6V+MXFxftc1x1yasGCBdqwYUOlq9Klim4ff/nLX5Sbm+vduWzXrl0KhUIJF+q4024bevfurVtvvTXhYs2XX35ZnTp1UsOGDZWXl6dly5YlvN/vf/97bd++Xffcc49at26tSCSiSCSiDRs2VDlSSZ8+fVRWVqbPP//cuzjq008/lRS7aBRAzTHGaOHChZW6rgCZZuvWrRo7dqxOOeWUdDcFQBUy/gLJt99+W/fdd58++OADffnll1qwYIEuvPBCdezY0btg6ptvvlHnzp0TblE8a9Ys/ec//9Hnn3+uxx57TBdccIFGjRqVMLbsfffdp/fff1+ffvqpZsyYoWuvvVaTJ09WgwYNJElnnHGGNm/erOHDh2v58uX6+OOPNWzYMGVlZXl/2C666CJlZ2friiuu0Mcff6y///3vuueee3TDDTdIil2YedRRRyU8GjRooMLCQh111FHKzs7W4YcfrqFDh+qSSy7R3LlztXr1ar3zzjuaPHmynn/+eUnS6aefrm7duunyyy/X0qVL9d5773m3NN7bqAUAqs9xHG3YsCFhzGIgEx1++OEaP358ytvKA8gMGR+28/PzNXfuXJ122mnq1KmTrrjiCnXt2lWvvfaaN9pIaWmpVq5cqV27dnnbrVy5UoMGDdIRRxyhCRMm6NZbb9W0adMS9v3OO+/ojDPO0NFHH60///nPmjlzpkaMGOEt79y5s5599ll99NFH6t27t/r27atvv/1W8+fP90YtKCoq0ksvvaTVq1ere/fuuvHGGzV27NiEYf/2x6xZs3TJJZfoxhtvVKdOnTRo0CAtWbLEu8lBKBTSs88+q4MPPlgnnniizj77bB1xxBEpxyUGAABAZnCMqYU7CwAAAAABlPGVbQAAAMBWhG0AAADAJ7U+Gkk0GtW3336rwsLCGrkdLwAAAFDbjDHavn27WrZsmXBH8mS1Hra//fZbtW7durbfFgAAAKhxa9euVatWrapcXuth271F7Nq1a1W/fv3afnsAAADgR9u2bZtat27tZduq1HrYdruO1K9fn7ANAAAAq+2rWzQXSAIAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD6pVtiORCK67bbb1L59e+Xl5aljx4664447ZIzxq30AAACAtbKqs/Jdd92l+++/X48++qi6dOmid999V8OGDVNRUZFGjBjhVxsBAAAAK1UrbL/11lsaOHCgzj77bElSu3bt9Le//U3vvPOOL40DAAAAbFatbiTHH3+8/v3vf+vTTz+VJH344Yd68803ddZZZ/nSOAAAAMBm1apsjx49Wtu2bVPnzp0VDocViUQ0ceJEDR06tMptiouLVVxc7E1v27btwFsLAAAAWKRale0nnnhCs2fP1uOPP673339fjz76qKZNm6ZHH320ym0mT56soqIi79G6desf3WgAAADABo6pxlAirVu31ujRozV8+HBv3p133qnHHntMK1asSLlNqsp269attXXrVtWvX/9HNB0AAABIj23btqmoqGifmbZa3Uh27dqlUCixGB4OhxWNRqvcJicnRzk5OdV5GwAAAKBOqFbYHjBggCZOnKg2bdqoS5cuWrp0qf74xz/q8ssv96t9AAAAgLWq1Y1k+/btuu222zRv3jxt2LBBLVu21IUXXqixY8cqOzt7v/axvyV3AAAAIFPtb6atVtiuCYRtAAAA2G5/M221RiMBAAAAsP8I2wAAAIBPCNsAAACATwjbAAAAgE8I2wAAAIBPCNsAAACATwjbAAAAgE8I2wAAAIBPCNsAAACATwjbAAAAgE8I2wAAAIBPCNsAAACATwjbAAAAgE8I2wAAAIBPCNsAAACATwjbAAAAgE8I2wAAAIBPstLdgNpkrr9ekV27pFCo4hEOVzw7jhQOyymfZ0Ih73XCuvHz45eHQnKyshLXSVperekfs22qfcU/HCfdhwMAAKDOC1bYfvhhZe3Yke5mZARTfmLhBm8TH8RDocTp+HmpgnvcPJNinvfIyqp4hMNysrJksrJiJyhJy1SvnuQuq1fPW99dx3GXly9T8j7i95Vqfvn2qlev8oOTEQAAUEMCFbZ3jhypzd9+q9zsbCkalRONSu7DGDmRiGSMVP4cv9xJeq60TdIyp6rlVWzjLkuYV8U6+9rGiUb3+Vk4xkhlZbGHJKJlIlMeyk15ADfuCUC9erFl8WE9LrSbKgK8k2K+k50tk50tJzs7Np2TI2Vny8nJic2Lf9SrV73pcDjdHyEAAFDAwvbuESP09WefqXHjxuluiv+STw4ikdjrFM/eentZZ7/3E4kk7NNEIrF50ahUVibHnS4ri21b/jr52d2X464Xv375c/w8J2mbhPWStqn0bEylj88pPxFx9uxJw8H78YzjSNnZsZOF7OzYSUB5KDfx4dx9zsmJzc/JkVP+2snJkXJypNzcWPhPeu3k5srJzfW2V/nJgvd6b9MhLhcBAARDoMK2E6SuAeV9teNjZOVICUkVYb60tCKAu9Plr51IJHHaDePxr0tLvW8L3NdO+TZKWi9h/6WlFQ93ftx08vJQ/Pzy51AkkvAjOcZIxcVyiovT9KHunXG/NYgL+SYu0CsnR8Z9XT7txL1Wbq6Ulxf7FiAvL3YSkJvrvVbStlW+5hsAAIDPAhW2pYAFbuwft695To69JyTRaOWgniKsp1xeUiKVlMSei4tj88un3YdKShLnJ70Oxc8rLY2dEJSUxJ7Lp+N5Jym7d6fpA4sxWVmxUO+G+/iQn5cnU/7sTisvLxb68/Kk/PxYwM/Pj83Py/NeK8V2Ca+zsrguAAACIlBh23EcmRRdBgDrhUKxynB2dmaeMBgTC/tuAE8V2t1HeUXeDf+h4mKvSu/OT3guP0EIxb12iotjJwAlJbHn4mKF3HXirmnwQv/OnbX7cYRCXpB3w71xK+7xIT8/vyLAFxTEQn1+vpzy16GDDvLWk7te/LP7mm47AJA2gQvbANLAcbyTgci+1/ZXeTU/PpDHB3kvtO/Z4z1rz57Y/D17EuY7e/bEtnHnlZ8chOJfly8PlZR4TXCiUTm7dkm7dtXKj2xycmTiQrwpr7CbFAHdq9S74b6gICHge+smB3u3ek+wB4AEgQvbBG4g4MqHnDT5+dr3uD01yJhYsC8P5qG4QJ/8Wnv2yNm9Ozav/Dnkhvvdu73X3nNxcSzQx7+OD/e12H/f5OZWBPvc3Figd5+Tg70b2t1A7z6Sg31ytxz3OTub7jgAMl6gwrYkupEASA/HiVWYc3Ikyf8KfzTqBfFUAd3Zs0ehpEDv7N5d8dpdXlxc8exu7752n8uHEJXkbVsbjOPE+tnHdcMx7sWvqfrcuxfRxvW/d8pPBpz48J+8XdyFu94z/e4B7KdAhW2q2gACIxSSyc9XJD9fatjQ3/cqHybTrdBXqrzHBfzkir0TH+jjg3x81d7tluN28ykvmjjGxL4FSMMQnSYUSn1xbdyQmSYppDvJo+G4gd8dcce9ADd59JxUw2vGz2NUHSCjEbYBAD9OVpbMQQcpctBB/r+XMVL8BbHlXWYSuuRU1ec+aX6l7d3+93F990PlF9c6xcUJQ2w60aic3bvTPqKOFAv+ys6Ohft69RKfy18nDK8ZF9ad5HlJY+l7N9pyl8ffQCvV66qWc2deBBhhGwBgj/IbNkWzs6XCwtp970gkYdSchAts45/dR3mgV/m63qg6qS7MdUffids+fiQdb4jNsrKE/vhSLPinq8JfHca90Va9et4Nt7zX7iP+5ltuYI+/8275nXodd1753Xmd8n07cdPxy9xtlbRdyulUy+If4TDdiFAtgQrbLmMMwRsAUD3hcOziz7w8SbXQ774qxlSMqpM0xn3KeXHz48fS98bPTxpfX26wdx+pbsLl3lAr6WZf7vLkG21Jqth/HWFCoVjwDodjd+ktD+LGDeNZWbHuXMkhPenZZGXJid9H/Drlr53y/Trl7yf3vcvfo8p1ypc77j7j2uzE7Vvxj7h1Ks0rv2HePh/7u+7+rOc41p/YBCpsMxoJAMB6juNVhE1BQbpbk1p5d59KN9dy77Bb1c233GDvhnz3pCDpTr9u+E+4O28kUumuvd5Y+u7dgd3lcXcLjr9zcKXpSCRhbP54TjQqRaOxttTyxxs4paWxEwdL2dvyA+CGbSrbAAD4qLy7j8nUG21VRyQSC+fRaEWod+dFIt48d9qbt5/ru9PGDfnl87z3dfdVPi1jKrZ355cHfydpXqXX5euofL43b3+m3WdjYq/d51Tzyp/d1055u73pKk5gqmT5+P2BCtsAAADV4nYVKZ+0/uQhUyQFcvd1/PTmH35Q60MOUQvLC6SBCttuNZuxtgEAANLIcSqdyLjc6bKyMpU1bGh9n2276/LVRJ9tAAAAO9SVzBaosC3J67MNAACAzFYXMlugwnZdOUMCAACo6xzHUbS6F1NmoMCFbSrbAAAAdqgLmS1wYRsAAACZr64USAMVtqW6c+AAAADqOrqRWIbKNgAAgB3qSm4LXNimsg0AAGCHupDZAhW2AQAAYA+6kViGO0gCAADYgW4kFuIOkgAAAPagsm0Z+mwDAADYoa5ktkCFbQAAANiDsG0Z+mwDAADYgcq2heizDQAAYA/CtoXqylkSAABAXVcXMlugwjZVbQAAADvUlQJpoMK2VHcOHAAAQF1njLE+twUubIdCgfuRAQAArFNXeiQELnlS2QYAAMh8bmazPbcFMmwDAADADoRty1DZBgAAQG0JXNimzzYAAEDmoxuJxWw/aAAAAEFA2LYQlW0AAIDMV1euswtc8qTPNgAAgD1sz22BC9tUtgEAADIffbYtZvtBAwAAgB0CF7apbAMAAGQ+KtuWos82AACAPWzPbYEL21S2AQAAUFsCmTxtP0MCAACo6+hGYikq2wAAAHYgbFuorgyQDgAAUJfVlcwWuLANAAAAO1DZtlBdOUsCAACoy9zMRti2DGEbAAAAtSWQYZvADQAAkNkYjcRith80AACAICBsW4iqNgAAQOarK5mNsA0AAICMRWXbMvTZBgAAsAdh20K2HzQAAADYIXBhm6o2AACAPWwvkgYybBO4AQAA7EDYtpDtBw0AACAI6kKBNHBhuy4cNAAAgCBgnG0LEbYBAADsQdi2DGEbAADADnUhtwUubLtsP0sCAACo6+hGYiF3NBLbDxwAAEAQ2J7ZAhu2AQAAkNnqQmYLXNgGAACAHehGYiH3DMn2AwcAABAEtme2QIbtuvCVBAAAADJf4MK2JC6QBAAAsITtmS1wYZuqNgAAgD0I25Zh6D8AAAB72J7ZAhm2AQAAkPnqQoE0cGFbqhsHDgAAIAhsz2yBC9tUtgEAAOwRjUbT3YQfJZBhm8o2AABA5qsLRdLAhW0AAADYg8q2ZbiDJAAAgB2obFuIO0gCAADYg8q2ZeizDQAAYIe6kNkCF7YBAABgD8K2ZeizDQAAYAcq2xaizzYAAIA96LNtobpwlgQAAFDX1YUCaeDCdl04aAAAAEFhe4E0cGFborINAABgC9szWyDDdigUyB8bAADAKnWhQBrI1FkXDhwAAEAQcIGkhei3DQAAkPnqQoE0sGHb9gMHAAAQFDbntkCGbfpsAwAAZL66UCANZOqsCwcOAAAgCIwxVue2wIZtAAAAZDY3sxG2LUNlGwAAALUhkGGbPtsAAACZzy2Q2lwkDWTqpLINAACA2hDYsA0AAIDMRmXbUlS2AQAA7GFzbgtk2KbPNgAAAGpDYFOnzWdIAAAAQUA3EktR2QYAALBDoMJ2u3bt5DhOpcfw4cP9ap8vuEASAAAg89WFzJZVnZWXLFmiSCTiTf/3v//VGWecoQsuuKDGG+anunDgAAAAgsLmyna1wnaTJk0SpqdMmaKOHTvqpJNOqtFGAQAAAHWhz3a1wna8kpISPfbYY7rhhhv2WikuLi5WcXGxN71t27YDfcsaQ2UbAAAAteGArxR8+umntWXLFl122WV7XW/y5MkqKiryHq1btz7Qt6wxbl9zAAAAZK66UNk+4LD98MMP66yzzlLLli33ut6YMWO0detW77F27doDfUsAAAAEjO1h+4C6kXz55Zd65ZVXNHfu3H2um5OTo5ycnAN5G99wB0kAAADUhgOqbM+aNUtNmzbV2WefXdPtqRV0IQEAAMh8bmazuUha7bAdjUY1a9YsXXrppcrKOuDrK9OKPtsAAAB2sL0bSbXD9iuvvKKvvvpKl19+uR/tqTU2HzQAAIAgqAvF0WqXpvv162d9UK0LBw4AACAIAlfZrgsI2wAAAJkvkH22AQAAAOyfQIZtKtsAAAD2oLJtGcI2AACAPQjbliFsAwAA2MH23BbIsO2y+SwJAAAgCBiNxELuTW1sPnAAAABBYXNmC3TYBgAAQGazPbMFMmy7bD5LAgAACAK6kVjI9jMkAACAICFsW4ZuJAAAAHawPbMFOmzbfJYEAAAQBHQjAQAAAHxE2LYMlW0AAADUhsCGbQAAANjB5gJpYMM2lW0AAAA72JzZAhm2AQAAgNoQyLBNZRsAAMAeNme2wIZtAAAA2CEajaa7CQcskGFbEpVtAAAAC9heJA1k2Lb9oAEAAASJzQXSwIZtKtsAAACZz3EcupEAAAAAfrG5QBrIsO12I7H5wAEAAASB7b0RAhu2bT9wAAAAQUE3Esu4YRsAAACZzfYCaSDDtsSIJAAAAPBfYMO2RJ9tAAAAG9CNxEKhUGB/dAAAAGvY3hshsInT9v4/AAAAQUFl20K2nyUBAAAEge0F0kCHbZsPHAAAQFDYnNkCG7bpsw0AAJD5bC+QBjZx2n7gAAAAgoI+2xaizzYAAEDmsz2zBTpsU9kGAADIfDZntsCGbfpsAwAA2MEYY23gDmzipLINAACQ+dxuJLbmtkCHbQAAAGQ22wukgQ7bNh84AACAILE1twU2bNNnGwAAwB6EbQvZetAAAACCwvbeCIEN21S2AQAA7MBoJBbiAkkAAIDMx2gkliJsAwAAwG+BDdsAAADIfG6fbSrblqGyDQAAAL8RtgEAAJCxqGxbjMANAABgB8K2ZWwfsxEAAACZL9BhGwAAAJmNbiSWchyHwA0AAGABwralbD1oAAAAQWF7cTSwYdv2AwcAABAUVLYtRNgGAADIfNyuHQAAAEBKgQ3bVLYBAAAyH6ORWIqwDQAAYAfCtoUI2wAAAJnP9swW2LAtcRdJAAAAW9ia2QIbtm2/shUAACBIbM1sgQ7btn8tAQAAgMwW6LAt2XuWBAAAECS2ZrbAhm0AAADYg7BtGbcbia0HDgAAIChs7vob+LANAACAzMY425YibAMAANiBsG0ZLpAEAACwg80F0sCHbQAAAGQ2upFYiAskAQAA7GFrZgts2AYAAIAdbO6RENiwTWUbAADADnQjsZDNZ0gAAABBQ9i2EJVtAAAA+CmwYZvKNgAAgD1sLZAGOmxT2QYAALCDrZktsGEbAAAA9iBsW4Y7SAIAANjB5t4IgQ7bNh84AACAILE1swU+bAMAACCzOY6jaDSa7mYckMCGbcnuryQAAACQ+QIdtgEAAGAHKtsWCoUC/eMDAABYweauv4FOm3QjAQAAsAOVbQvZfJYEAAAQFDYXSAMftm09cAAAAEFCZdtC9NkGAADIfDb3Rgh02qSyDQAAYAcq2xay+SwJAAAgKGwukAY+bNt64AAAAJD5Ah226bMNAABgB7qRWIjKNgAAQOazObMFPmwDAAAg8xG2LRQKhaw9cAAAAEFBZdtSVLYBAADsYWPgDnTYluw8aAAAAEFCZdtSjEYCAABgB2OMlYE70GmTbiQAAACZz81shG3LELYBAADgp0CHbQAAAGQ+t882lW3LUNkGAACwB2HbMoRtAACAzMdoJJZyHIfADQAAYAkbA3egw7Zk50EDAAAIIhtzW6DDNlVtAACAzEc3EkvRjQQAAMAOjEYCAAAA+ICb2ljK5q8kAAAAkPkCH7YBAACQ2bipjaUI2wAAAPYgbAMAAAA1zOauv4EO21S2AQAA7EA3EgsRtgEAAOCnQIdtye6vJQAAAIKAof8sZfOBAwAACBK6kViIO0gCAABkPpvzWuDDtkRlGwAAINNR2QYAAAB8YHOBNNBh2+1GYuOBAwAACBobMxth2+I+QAAAAMhsgQ7bkt0d7gEAAIKEyrZlbO7/AwAAECS2dv2tdtj+5ptvdPHFF6tx48bKy8vT0UcfrXfffdePtvmOqjYAAIAdbAzakpRVnZU3b96sPn366JRTTtG//vUvNWnSRKtWrVLDhg39ap+vuEASAADAHjZmtmqF7bvuukutW7fWrFmzvHnt27ev8UYBAAAA8WztkVCtbiTPPPOMevTooQsuuEBNmzbVscceqwcffHCv2xQXF2vbtm0Jj0xBZRsAAMAOgbipzRdffKH7779fhx12mF588UX99re/1YgRI/Too49Wuc3kyZNVVFTkPVq3bv2jG11TbD1DAgAACCIbw7ZjqtHq7Oxs9ejRQ2+99ZY3b8SIEVqyZIkWL16ccpvi4mIVFxd709u2bVPr1q21detW1a9f/0c0/ccrLS3VsmXLFA6HlZubm9a2AAi2SCSid999Vx999JG2b99eq/+hOI6jwsJCHX300erZs6fC4XCtvTcA7K9NmzapY8eOatq0abqbIimWaYuKivaZaavVZ7tFixY68sgjE+YdccQReuqpp6rcJicnRzk5OdV5m1pDZRtAJjDG6JFHHtEHH3ygTp06qW3btrX698kYo++++06zZ8/WsmXLdMUVVygUCvTIsAAykK3dSKoVtvv06aOVK1cmzPv000/Vtm3bGm1UbaHPNoBMsGrVKi1dulQ33HCD+vbtm7Z2LFq0SNOmTdOqVavUqVOntLUDAKpiY2arVuli1KhR+s9//qNJkybps88+0+OPP64///nPGj58uF/t8xWVbQCZYPny5WrUqJFOOOGEtLbj+OOP18EHH6xPPvkkre0AgLqkWpXtnj17at68eRozZowmTJig9u3ba/r06Ro6dKhf7asVNp4lAag7du3apYYNG1YqAAwcOFDr169XKBTSQQcdpKlTp6pTp0667LLLtGLFCuXl5alJkyb6n//5H3Xs2FGSdNZZZ2nt2rVe/8GLLrpI11577T6XSbECRMOGDbVr167a+LEBoFoikYgWLVqkkpIStWjRQn379rXiGpNqhW1J+vnPf66f//znfrSl1rndSKLRaLqbAgCVPProo2rQoIGk2NCrV199tV599VUNGzZM/fr1k+M4mjlzpq699lr961//8rabPHmyBgwYkHKfe1sm8Y0fgMz00ksv6Y477tDGjRu9ea1atdI999yj8847L40t27dAXQEzfvx43XHHHd60G7Yl6U9/+pPuvffedDUNACpxg7YUu+rdcRzl5uaqf//+3t+unj176quvvkpTCwHAfy+99JJGjhyZELQl6ZtvvtH555+vuXPnpqll+6falW2bhcNhjR07VpJ02223SYoF7gcffFAPPPCARowYkc7mSZKiUSkSkSIRp8rnaNRRWZn267li29jraLRivjH7npf4HL+e25aq51W1jTGx1+467s/tzotfXvF678vddaJRZy/7i/188dtIya8rqnqpl1e9TnXXTRZfUKyquOg4JuU61X0dLxQychzFPZKnK+aHQhXTqbetent3Xbct7rS7z1CoYv/hsKk0P/ao2CYcrnidap1wOHH9qtZxfy73Pd3twuHYvPhpv+YXF1f8jsZ/vpJ01VVX6fXXX5eklCM/3X///Tr77LMT5o0bN0533nmnOnfurPHjxyfc6XdvywAg00QiEU2aNClll19jjBzH0fXXX6+BAwdmbJeSQIVtN2CPHTtWq1dLPXvepscf/6vefPMB9elzi0KhGzVjRiyolpU5KiuLhcQDfR0/7YbesrJYwHSfk8N0fDgD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jRw5Ui+//LJyc3PT3Zw6hbBdRxx88MEKh8Nav359wvz169erefPmaWoVgExz7bXX6rnnntPrr7+uVq1apbs5SLPs7GwdeuihkqTu3btryZIluueeezRz5sw0twy17b333tOGDRvUrVs3b14kEtHrr7+u++67T8XFxQqHw2lsob3os11HZGdnq3v37vr3v//tzYtGo/r3v/9N/zsAMsbo2muv1bx587RgwQK1b98+3U1CBopGoyouLk53M5AGp512mpYtW6YPPvjAe/To0UNDhw7VBx98QND+Eahs1yE33HCDLr30UvXo0UPHHXecpk+frp07d2rYsGHpbhrSYMeOHfrss8+86dWrV+uDDz5Qo0aN1KZNmzS2DOkwfPhwPf744/rnP/+pwsJCrVu3TpJUVFSkvLy8NLcO6TBmzBidddZZatOmjbZv367HH39cCxcu1IsvvpjupiENCgsLK13DUVBQoMaNG3Ntx49E2K5DhgwZoo0bN2rs2LFat26djjnmGM2fP7/SRZMIhnfffVennHKKN33DDTdIki699FI98sgjaWoV0uX++++XJJ188skJ82fNmqXLLrus9huEtNuwYYMuueQSfffddyoqKlLXrl314osv6owzzkh304A6hXG2AQAAAJ/QZxsAAADwCWEbAAAA8AlhGwAAAPAJYRsAAADwCWEbAAAA8AlhGwAAAPAJYRsAAADwCWEbAAAAB+T5559Xr169lJeXp4YNG2rQoEH73Gb58uU655xzVFRUpIKCAvXs2VNfffVVpfWMMTrrrLPkOI6efvrplPv64Ycf1KpVKzmOoy1btiQsmz17tn7yk58oPz9fLVq00OWXX64ffvihWj9fu3bt5DhOwmPKlCnV2gdhGwAAACmdfPLJVd51+KmnntKvfvUrDRs2TB9++KEWLVqkiy66aK/7+/zzz3XCCSeoc+fOWrhwoT766CPddtttys3NrbTu9OnT5TjOXvd3xRVXqGvXrpXmL1q0SJdccomuuOIKffzxx/rHP/6hd955R1deeeVe95fKhAkT9N1333mP6667rlrbc7t2AAAAVEtZWZlGjhypqVOn6oorrvDmH3nkkXvd7tZbb9XPfvYz/eEPf/DmdezYsdJ6H3zwge6++269++67atGiRcp93X///dqyZYvGjh2rf/3rXwnLFi9erHbt2mnEiBGSpPbt2+s3v/mN7rrrroT1HnroId19991avXq1t/4111yTsE5hYaGaN2++159rb6hsAwAAoFref/99ffPNNwqFQjr22GPVokULnXXWWfrvf/9b5TbRaFTPP/+8Dj/8cPXv319NmzZVr169KnUR2bVrly666CLNmDGjypD7ySefaMKECfq///s/hUKV42zv3r21du1avfDCCzLGaP369XryySf1s5/9zFtn9uzZGjt2rCZOnKjly5dr0qRJuu222/Too48m7GvKlClq3Lixjj32WE2dOlVlZWXV+KQI2wAAAKimL774QpI0fvx4/f73v9dzzz2nhg0b6uSTT9amTZtSbrNhwwbt2LFDU6ZM0ZlnnqmXXnpJ5557rs477zy99tpr3nqjRo3S8ccfr4EDB6bcT3FxsS688EJNnTpVbdq0SblOnz59NHv2bA0ZMkTZ2dlq3ry5ioqKNGPGDG+dcePG6e6779Z5552n9u3b67zzztOoUaM0c+ZMb50RI0Zozpw5evXVV/Wb3/xGkyZN0u9+97vqfVgGAAAAMMZMnDjRFBQUeI9QKGRycnIS5n355Zdm9uzZRpKZOXOmt+2ePXvMwQcfbB544IGU+/7mm2+MJHPhhRcmzB8wYID55S9/aYwx5p///Kc59NBDzfbt273lksy8efO86VGjRpkhQ4Z406+++qqRZDZv3uzN+/jjj02LFi3MH/7wB/Phhx+a+fPnm6OPPtpcfvnlxhhjduzYYSSZvLy8hJ8tJyfHNG3atMrP5+GHHzZZWVlmz549+/4wy9FnGwAAAJKkq6++WoMHD/amhw4dql/84hc677zzvHktW7b0+lHH99HOyclRhw4dUo4sIkkHH3ywsrKyKvXrPuKII/Tmm29KkhYsWKDPP/9cDRo0SFjnF7/4hfr27auFCxdqwYIFWrZsmZ588klJsVFL3P3feuutuv322zV58mT16dNHN998sySpa9euKigoUN++fXXnnXd6XU8efPBB9erVK+G9wuFwlZ9Pr169VFZWpjVr1qhTp05VrhePsA0AAABJUqNGjdSoUSNvOi8vT02bNtWhhx6asF737t2Vk5OjlStX6oQTTpAklZaWas2aNWrbtm3KfWdnZ6tnz55auXJlwvxPP/3U22b06NH69a9/nbD86KOP1v/8z/9owIABkmKjoOzevdtbvmTJEl1++eV64403vIstd+3apaysxJjrhmhjjJo1a6aWLVvqiy++0NChQ/fvw1Hsws1QKKSmTZvu9zaEbQAAAFRL/fr1dfXVV2vcuHFq3bq12rZtq6lTp0qSLrjgAm+9zp07a/LkyTr33HMlSTfffLOGDBmiE088Uaeccormz5+vZ599VgsXLpQkNW/ePOVFkW3atFH79u0lVR695Pvvv5cUq5C7FfEBAwboyiuv1P3336/+/fvru+++0/XXX6/jjjtOLVu2lCTdfvvtGjFihIqKinTmmWequLhY7777rjZv3qwbbrhBixcv1ttvv61TTjlFhYWFWrx4sUaNGqWLL75YDRs23O/PirANAACAaps6daqysrL0q1/9Srt371avXr20YMGChCC6cuVKbd261Zs+99xz9cADD2jy5MkaMWKEOnXqpKeeesqrjteUyy67TNu3b9d9992nG2+8UQ0aNNCpp56aMPTfr3/9a+Xn52vq1Km6+eabVVBQoKOPPlrXX3+9pFi3mDlz5mj8+PEqLi5W+/btNWrUKN1www3Vaotj3I4uAAAAAGoUQ/8BAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA+IWwDAAAAPiFsAwAAAD4hbAMAAAA++f804YTTIlRiMwAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Sample a random lane\n", "if (lanes := map_object_dict[MapLayer.LANE]) is not None and len(lanes) > 0:\n", @@ -423,21 +347,10 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "18", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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93Pv3btXRqNRdevWtbqGjRs3WvU/Jz179tQvv/yijRs3qm7dunrssceUmpoq6Ub1IyMjQ7NmzVJUVJTuu+8+ff755zpy5Ih+/PFHSdLTTz+twYMH65///Kfc3d1133336fHHH5ckubjc3rfUTHoGAABwsDp16shgMOQ7sTnrRvTmgHHzeP6b5TS06e/vpaSkqEuXLpoyZUq2tkFBQZY///3RsAaDQSaTKc++5mfx4sV66aWXNH36dEVGRsrX11fTpk3T9u3brdrlde6UlBS5urpq9+7dVqFCknx8fPI8v7+/v/z9/VWnTh3dd999KlOmjFasWKEePXpYrr1+/fqW9uXLl1e5cuUsgcZgMGjKlCmaOHGiEhISVL58eUvlo1atWrfwiZQcBAYAAAAHCwwMVFRUlN59910NGTIk24395cuXFRAQYBnuEx8fr6ZNm0qS1QTowmrWrJmWLVumGjVqZHuKUmGUKlWq0I+Q3bJli5o3b65nn33W8l5+VYG/a9q0qYxGoy5cuKCWLVsWat+bmc1mmc1mpaWlSZJatGghSTp06JCqVKki6cY8kz///FPVq1e32tfV1VXBwcGSpM8//1yRkZEFHpZVUt3e9RMAAHDHy8zMVHp6ul1fhX2kqnRjeI7RaNQ999yjZcuW6ciRIzp48KBmzZqlyMhISTcm1d53332WycwbN27UqFGjbvmzGDRokC5duqQePXpo586dOnbsmGJjY9W3b99CBYAaNWpo3bp1SkhIUGJiYoH2qVOnjnbt2qXY2FgdPnxYr732WoEnTGepW7euevbsqV69emn58uU6ceKEduzYoUmTJmn16tU57nP8+HFNmjRJu3fv1qlTp7R161Y9+uij8vLyUqdOnSzH7dq1q55//nlt3bpV+/btU+/evRUWFqbWrVtLkv7880+9//77+u233xQXF6fnn39eS5Ys0dtvv211vri4OMXFxSklJUV//PGH4uLidODAgUJdZ3FDYAAAALclV1dXeXl5KTMzU6mpqXZ9ZWZmysvLK9swmbzUqlVLP//8s1q3bq0XX3xRDRs2VLt27bRu3TrNmTPH0m7evHnKzMxURESEhg4dqjfeeOOWP5PKlStry5YtMhqNat++vRo1aqShQ4cqICCgUOPwp0+frrVr16pq1aqWykd+/u///k/dunVT9+7dde+99+rixYtW1YaC+vjj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d+/eGjx4sN555x09/vjjiomJkb+/v3766Sfdc889Cg0N1euvv64hQ4bI399fHTp0UFpamnbt2qXExEQNGzZMM2bMUFBQkJo2bSoXFxctWbJElSpVUkBAgObPny+j0ah7771XpUuX1qJFi+Tl5aXq1aurbNmy6tq1q55++mnNnTtXvr6+GjFihIKDg9W1a1ebPrs6depo6dKl2rp1q8qUKaMZM2bo/PnzhQoMbdu2VWRkpB566CFNnTpVdevW1blz57R69Wo9/PDDuuuuu3Lcb/bs2WrevLl8fHy0du1aDR8+XJMnT7aqShw9elQpKSlKSEjQ9evXLesw1K9fX+7u7qpXr57VMXft2iUXFxc1bNiw0J9FYREYAADA7c2YenucQ1Lp0qX13//+V6+88oq6deumK1euKDg4WA8++KCl4vDiiy8qPj5evXv3louLi5566ik9/PDDSkpKshxn/PjxKl++vCZNmqTjx48rICBAzZo106uvvirpxryB9evXa/jw4br//vvl6uqqJk2aqEWLFpKk/v37q3Tp0po2bZqGDx8ub29vNWrUSEOHDpV0I5RMnTpVR44ckaurq+6++259++23cnFxUUBAgCZPnqxhw4bJaDSqUaNG+vrrry3zAj7++GM9//zz+uc//6n09HS1atVK3377bY7zKQpj1KhROn78uKKiolS6dGkNGDBADz30kNXnkh+DwaBvv/1WI0eOVN++ffXHH3+oUqVKatWqlaV6kJMdO3ZozJgxSklJUVhYmObOnasnn3zSqk3//v2thmVlzSc5ceLELVUzipLB/PcH8yJPycnJ8vf3V1JSksNnqANAiZR5XTr5qeTiKbn7F35/43Up7U+p+uOSR2DR9w8lXmpqqk6cOKGaNWvK0/Om9RCK+UrPgL3l+ndDhbunpcIAACgB+G4Lt8DN68YNfCFXX75lLu6EBdyWCAwAAOD25eYliZt4wBas9AwAAAAgVwQGAAAAALkiMAAASgjmMSBvPMcFsFZUfycIDACAYq5wq7nizpP1uM1r1645uSdA8ZL1d8LWR9Iy6RkAUPzxxTHy4OrqqoCAAF24cEHSjbUKDAaCJu5cZrNZ165d04ULFxQQECBXV1ebjkdgAAAAJV6lSpUkyRIaAEgBAQGWvxu2IDAAAIASz2AwKCgoSBUqVFBGRoazuwM4XalSpWyuLGQhMAAA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1q5du9SmTRt17dpV+/fvz7H9qFGjNHfuXL3zzjs6cOCABg4cqIcffli//PKLpU3//v21du1aLVy4UHv37lX79u3Vtm1bnT171tImPj7e6jVv3jwZDAZFR0dLks6dO6e2bduqdu3a2r59u9asWaP9+/erT58+OfarX79+Cg8PL8ylAwCKUNakZ1tQYQAAxzCYzbb9kxsYGKhp06apX79+2bZVrlxZI0eO1KBBgyzvRUdHy8vLS4sWLdL169fl6+urr776Sp07d7a0iYiIUMeOHfXGG2/keM6HHnpIV65c0bp16yRJH3zwgV577TXFx8fL5X+DYvfu3avw8HAdOXJEtWvXtuw7Z84cffHFFxo9erQefPBBJSYmKiAgoMDXm5ycLH9/fyUlJcnPz6/A+wEA/pKcLH32meTvL/n4FH7/w4elDh2kBg2Kvm8AcCcozD2t262exGg0asmSJbp69aoiIyNzbJOWliZPT0+r97y8vLR582ZJUmZmpoxGY55t/u78+fNavXq1FixYYHUed3d3S1jIOoYkbd682RIYDhw4oHHjxmn79u06fvx4ga4zLS1NaWlplp+Tk5MLtB8AIHdUGACg5Cj0pOe9e/fKx8dHHh4eGjhwoFasWKH69evn2DYqKkozZszQkSNHZDKZtHbtWi1fvlzx8fGSJF9fX0VGRmr8+PE6d+6cjEajFi1apG3btlna/N2CBQvk6+urbt26Wd5r06aNEhISNG3aNKWnpysxMVEjRoyQJMtx0tLS1KNHD02bNk3VqlUr8PVOmjRJ/v7+llfVqlULvC8AIGe2rsMgMYcBAByl0IEhNDRUcXFx2r59u5555hn17t1bBw4cyLHtzJkzVadOHYWFhcnd3V2DBw9W3759rSoBCxculNlsVnBwsDw8PDRr1iz16NHDqs3N5s2bp549e1pVJRo0aKAFCxZo+vTpKl26tCpVqqSaNWuqYsWKluPExMSoXr16+ve//12o642JiVFSUpLldfr06ULtDwDIrigCAxUGAHAMm+cwtG3bViEhIZo7d26ubVJTU3Xx4kVVrlxZI0aM0DfffJNtovTVq1eVnJysoKAgde/eXSkpKVq9erVVm02bNqlVq1aKi4tT48aNczzX+fPn5e3tLYPBID8/Py1evFiPPvqomjRpor1798rwvxq42WyWyWSSq6urRo4cqddff71A18sc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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Sample a random lane\n", "if (lanes := map_object_dict[MapLayer.LANE]) is not None and len(lanes) > 0:\n", @@ -488,21 +401,10 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "21", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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rDbh9A1EDz/8MOrr63H99dejrKwsp/H09PRgw4YNuPvuu1FVVYX/+q//wsyZMzNuaxWPx7Fx40bEYjGMHz8+pzEAQHd3N1auXIl77rkH27ZtG3BN6/HHH4+tW7fCNE2UlZVhyZIlOOCAAxCJRHDllVfiqaeegt/vx8c+9jFce+21AIAnn3wS1113HaLRKILBIG699Vbsv//+AIAlS5bg3nvvxdtvv41ly5bh85//fL+vaZomWltb8eqrr+Lss8/Gsccem1LFHi6xWAyPPfYYfvzjH2PHjh148803UVlZiXPPPVf7xrlMGIaBcePG4fzzz8err76Kd999V+TaWKJix4BKRAVXW1uLuro6vPzyywiHw1lVvJKP3HRdF+vWrcNZZ52VczgFPpoKf+ONN3DyySfjyCOPhM/ny3hcPT09aG5uxsaNGxEKhXKuKpaVleHoo4/G1q1bce+992LChAlpH/fLX/4SVVVVAIA//vGPuOCCC7BixQpcffXVMAwDr732GgzDwJYtW5BIJLBnzx58/etfx+OPP4599tkHL7zwAs4991z8/e9/BwAceeSR+NKXvoRvfvObg45v165dmDp1Ko477rgR64tq2zY+//nP4/e//z22bNmC8vJyNDc3j0g4TRYOh9HQ0JDTkblENDQGVCIqONM0MXnyZHR3d6cNOqeccgq2b98O0zQRDodx3XXXedU+0zTx8MMP45prrsHNN9+MRCKBmpoavPnmm2hra/N25E+ePBk1NTUAgNbWVrz11lvo6elBT08Pmpqa0NLSkvI1Ozo6EI1Gsddee3nrCDM9f94wDG9qP5FI5D3tPWXKFMTj8QGDjgqnQO/3ZhgGOjo68Ktf/Qpr1671XtPGxkZs2rQJGzduRE1NDfbZZx8AwKGHHor3338fK1euxMc//nEcdNBBGY0rGo16hy2MJMMw0NTUhLVr1w64YS7d36G9994bF1xwAd566y0EAgHU1dXhxhtvxKRJkwAAF198MVatWgXTNOHz+bBgwQIcfvjhQ96nqI1brKAS6ceASkTDorGxMWWjS7K77rrLa4v05z//GRdffDGeeuopAL1rTh966CEccMABKc/Za6+9vKDS1taG4447DtFo1AuLixYtwqc+9Snsu+++6OnpQVVVFUzTxGWXXYYTTzzRWzdoWZa3CehjH/sY6uvrcfzxx2PLli1er9NvfetbOO644/DSSy/h+9//Pnp6ehCNRnHSSSfhggsuANBb3b3pppvw0EMPwXEc1NTU4MEHHwQAnHjiiXj//fdRUVEBADj55JPxjW98w/teLMsashH9+eefj//3//4fAODBBx/Ehg0bUF1djR/+8Id45plnEAgEcOWVV2Lq1KmYOHEidu7ciRdffBGf/OQn8eijj6KtrQ3vvvsuPv7xj2f1vqUL3wP9g2L58uX4wQ9+gFgshmAwiMWLF2O//fYDAHz2s59FNBoF0LtEYt26dXjqqaew7777AgDuuece/PznP4fP54Npmnj00UdTljwM1Qc13d+hRx99FGeeeSb+4z/+A4Zh4O6778Zll12G3//+9wCAhQsXes95/fXX8eUvfxlr1qyBaZqD3tcXAyqRfgyoRDQsGhoaEAqFsGfPnn73JffsVFVRoHcq/Tvf+Q6uvPJKLFmyJOU5yVW0eDyOK6+8EvPmzYNhGLj55pvxX//1X3j55ZcBAAsWLMCXv/xlb41pchCMRCLYunUrysvLvdt++ctfYsuWLfD7/Xj99ddx3XXXYeLEifj617+Oxx57DB/72MewZs0azJ07F2eccQaqq6vxs5/9DG+88QaefvppOI6Dbdu2pYx34cKF+MxnPjPoazRYQL3rrrsAAMuWLcNVV12F733ve9i4cSOmT5+Oa6+9Fv/85z/xhS98AY888ghqamrwy1/+Etdccw06OjrwiU98AtOnT89qDelgp1ulC4MPPvggvvWtb+Ghhx7CtGnT8OKLL+LCCy/EM8884z1OeeSRR3DTTTd54fQvf/kLfv/73+PRRx9FRUUFduzY0a9KOlSAT/d3KBAI4KijjvJunzlzJm6//fYBnzPY9QZimian+IkKgAGViIaF4zioq6vDzp07097/n//5n/jb3/4GAPjNb34DALjzzjtx8MEH96ueKm+//Ta2b9+OeDyO2bNne6GqoqIC0WgUK1asQCQSwbhx4wbcALVx40Yce+yx+Pe//+0FoKqqKqxatQqzZ8/GCy+8AJ/P5z1fHdva1taGiooKby3k7bffjvvvv9/7fMyYMdm+RBm1uTr99NNx8cUXY+zYsTBNE6eccgoA4IADDsDEiROxbt06zJkzB4cffjiOPPJIAL0hfK+99sL06dOzHlM66cLgO++8g+rqaq9F1ic/+Uls2rQJq1atwowZM1Kef++99+LUU0/1Pv/JT36CSy+91Ksw19XVpf26Q70+6f4OJfvZz36GefPmpdx2/fXX409/+hP27NmDn/3sZykV0sHuS8YKKpF+w99AjohGrcFOJrrtttvw6quv4vLLL8d1112HtWvX4tFHH8XFF18My7LShpMpU6bgk5/8JPbdd1+8/fbbOO+88zB9+nQsWrQIt912G+bMmQO/34+LL74YBx98ML75zW9i+/bt3vN37tyJsrKyfhuuotEofvjDH+KAAw7Addddh5/+9KcIBoP40Y9+hNNPPx377rsvTjjhBFxxxRVwHAdtbW3Yvn07Hn/8cXz2s5/FZz/7WTz88MMp17z++utx5JFH4hvf+AbefffdjF+z3bt3Y/Pmzd7nf/rTn1BTU4P6+nocccQR+Otf/woAeOedd/DOO+94vVmTn/ODH/wAc+fOxZQpUzL+ukP5z//8T8yaNQuLFy/GbbfdhsmTJ2PXrl1e1frxxx9He3s73nvvvZTnbdq0CStWrMBJJ53k3fbWW295FeB58+bhZz/7WdqvOVRA7ft3KNnSpUvxzjvv4Morr0y5fcGCBXjxxRdx55134vvf/763DGGo+xTDMFhBJSoABlQiGjaVlZVDTol++ctfxt/+9jf85S9/wXvvvYdDDjkERxxxBF5//XV8//vfx44dO/o9p6amBolEAjfffDNWrVqF008/HUuXLgXQG5TuvfdePPDAA6itrfXWjP773/9GW1sbGhsbveskr3G87LLLsHbtWnzve9/DVVddhXg8jttvvx3Lli3DG2+8gfvuuw/XX389du7c6W1w6u7uxp///GfceeeduPrqq7FmzRoAvcHp+eefx/LlyzF79myceeaZGb9mra2tOPXUUzF79mzMmTMHd911F+6//34YhoFbbrkFS5cuxezZs3Hqqafi1ltv9b6fRYsWYebMmTjggAPw3nvv4X/+53+8ay5evBjTpk3D3//+d3zrW9/CtGnTUoJ7JvqGwYqKCvz0pz/FDTfcgGOOOQbPPvsspk6d2m9ZwX333YdPf/rTKf1eVduuhx56CPfeey9+/etf48knn0x53lBT/MnU3yFVrb/99tvx5z//GcuWLUMoFEr7nLlz56K9vR1vvvlmVvcBYB9UogLgFD8RDZtgMAifz4dYLOYFlz179qCrq8sLVo899hiqq6tx0UUX4eKLLwbQO0V90kkn4fTTT8ejjz4K13XR2dnphY3W1lbEYjEEAgH4fD4cc8wx+MlPfoIPP/wQDQ0NeP/999Hc3IwLL7zQO77zn//8J2KxGN544w3EYjFEo1GsX78e8Xgc48ePh2EYiEQi3pT6qlWrsH37dhx22GEAgAMPPBD19fVYvXo1jjrqKITDYa8q2NzcjIMPPhgrV67Efvvt5x07ahgGzjnnHFx77bXYuXOn13VgMC0tLd46zr4mTZqUsrYTgHfU6S233DJgK6bvfve7+O53vzvk185k9/6Xv/xlXH755di5cycOPfRQHHrooQB637MDDjgAU6dO9R7rui7uu+8+3HjjjSnXGDduHE444QRYloXa2locddRRePXVV/HpT396yK8PDPx3qLq6GnfccQceeugh/O53v0tZmhCLxfD+++97O/pfe+01fPjhh5gwYcKg96V7jTjFT6QfAyoRDSvbtlMqTq2trTj//PPR3d0N0zRRW1uLX/3qVynhSLV/UhU013Wxdu1axONxGIaBzs5OjBkzxttY895776G8vBxr1qxBPB7H3nvvjcrKSvzqV7/y1kOecMIJeOWVV7DffvvhgAMOwKpVqzB27Fg4joPNmzejvr4eH3zwAVavXo2qqirU1NRg+/btWLt2LaZPn44NGzbggw8+8ELMF7/4RTz99NM4++yzsWvXLrz22mv45je/iXg87h2rCvRuEKqrq+sXTlWFMN+wo6sllKpy961aDhYGt27dioaGBgDAj370Ixx66KHe6wMAzz//POLxOD71qU+lXPOEE07A008/jcMOOwxdXV3429/+hgsvvLDfmAaqoA70d2jz5s1YuHAhJkyYgC996UsAetdC//nPf0YsFsNFF12E1tZW+Hw+hEIh/PSnP0VVVRU6OzsHvK8vBlSiwmBAJaJhpdoI9fT0wDRNNDc347HHHhv0OaZp4he/+AVisRj+/Oc/wzTNlNOoNm7ciK9+9avo6uqCaZqoq6vDww8/jPLycpxxxhlIJBJwXRcTJ070dsMPZM+ePfja176Gzs5ORKNRVFRUYOHChTjkkENw66234qyzzvKWKXz7299GU1MTAODKK6/EJZdcgnvuuQcA8K1vfQsHHnggOjs7ccYZZ3gtsNQO+3QqKyvxr3/9C4lEIuOerH0Ntvs+Uxs3bkRXV1fajWWD/YNi8eLFeOmll5BIJDBr1izcfPPN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pteT/ztUyDNNM+W/yX9ODnx971fX7vtnwzAwd+5c1NbWevcN9vi+f87kPmm9X4lGi4I36iciKhamaSIWi+HNN9/0wtpQG3CG2tmuApwKP+q2vhU8dXvfPzc1NWHvvff2gl7fMJV8veT/vvLKKxl9z47jYOrUqd4Y+34k377//vtjzpw5XphM93XT/Zkhj4iyxYBKRPR/jjjiCKxfvx6WZcFxHNi2Ddu24TiOV9Wzbdur9Kk/27btTe2qCmDf22zb9v6sruM4jve5+hqO48BxHO82NY5sQ97++++f0eOmTp2K2trajK8bDoezGgcRUS4MV9qCqyy1traisrISe/bsQUVFxUgPh4hIhEQigYkTJ2LTpk1pK7yGYWD8+PHYsGGD2M1hRFS88s1nBT/qlIiIhp9lWVi6dCkA9Ku+qs9vueUWhlMiEokBlYioRJ144ol44IEHMG7cuJTbx48fjwceeAAnnnjiCI2MiGhwnOInIipxiUQCzz33HDZv3oympiYcfvjhrJwSUUHlm8+4SYqIqMRZloUjjjhipIdBRJQxTvETERERkSgMqEREREQkCgMqEREREYnCgEpEREREojCgEhEREZEoDKhEREREJAoDKhERERGJwoBKRERERKIwoBIRERGRKAyoRERERCQKAyoRERERicKASkRERESiMKASERERkSgMqEREREQkCgMqEREREYnCgEpEREREojCgEhEREZEoDKhEREREJAoDKhERERGJwoBKRERERKIwoBIRERGRKL6RHkC+XNcFALS2to7wSIiIiIgI+CiXqZyWraIPqG1tbQCA5ubmER4JERERESVra2tDZWVl1s8z3FyjrRA9PT344IMPUF5eDsMwRno4BdXa2orm5ma89957qKioGOnhUBb43hUvvnfFie9b8eJ7V7yS37vy8nK0tbVh7NixMM3sV5QWfQXVNE2MHz9+pIcxrCoqKvhDW6T43hUvvnfFie9b8eJ7V7zUe5dL5VThJikiIiIiEoUBlYiIiIhEYUAtIn6/H1dffTX8fv9ID4WyxPeuePG9K05834oX37vipfO9K/pNUkRERERUWlhBJSIiIiJRGFCJiIiISBQGVCIiIiIShQGViIiIiERhQBXo9ttvx4wZM7xGt3PmzMFjjz3m3d/d3Y0LL7wQtbW1KCsrw0knnYStW7eO4IgpnRtvvBGGYeDiiy/2buN7J9M111wDwzBSPqZPn+7dz/dNrk2bNuGMM85AbW0tgsEg9t9/f7zyyive/a7r4qqrrkJTUxOCwSCOPvpovPXWWyM4YgKAiRMn9vuZMwwDF154IQD+zEmWSCTwve99D5MmTUIwGMSUKVPw/e9/H8l77nX83DGgCjR+/HjceOONePXVV/HKK6/gP/7jP3D88cdjzZo1AIBLLrkEf/rTn3D//ffj2WefxQcffIATTzxxhEdNyV5++WXceeedmDFjRsrtfO/k2m+//bB582bv4/nnn/fu4/sm065du3DooYfCtm089thjeOONN3DTTTehurrae8zixYtx66234o477sBLL72EcDiMefPmobu7ewRHTi+//HLKz9uTTz4JADj55JMB8GdOsh/84Ae4/fbb8eMf/xhvvvkmfvCDH2Dx4sW47bbbvMdo+blzqShUV1e7P/vZz9zdu3e7tm27999/v3ffm2++6QJwV6xYMYIjJKWtrc3de++93SeffNL91Kc+5V500UWu67p87wS7+uqr3QMOOCDtfXzf5Lr88svdww47bMD7e3p63MbGRnfJkiXebbt373b9fr/7v//7v8MxRMrQRRdd5E6ZMsXt6enhz5xwxx13nHvOOeek3HbiiSe6p59+uuu6+n7uWEEVLpFI4Le//S06OjowZ84cvPrqq4jFYjj66KO9x0yfPh0tLS1YsWLFCI6UlAsvvBDHHXdcynsEgO+dcG+99RbGjh2LyZMn4/TTT8fGjRsB8H2T7I9//CMOOuggnHzyyRgzZgwOPPBA/PSnP/Xu37BhA7Zs2ZLy3lVWVmL27Nl87wSJRqP4zW9+g3POOQeGYfBnTrhDDjkETz31FP71r38BAP75z3/i+eefx2c+8xkA+n7ufHqHTbq8/vrrmDNnDrq7u1FWVoaHHnoI++67L1auXAnHcVBVVZXy+IaGBmzZsmVkBkue3/72t/jHP/6Bl19+ud99W7Zs4Xsn1OzZs3HPPfdg2rRp2Lx5MxYuXIjDDz8cq1ev5vsm2L///W/cfvvtuPTSS3HllVfi5Zdfxre//W04joOzzjrLe38aGhpSnsf3TpY//OEP2L17N84++2wA/F0p3fz589Ha2orp06fDsiwkEglcf/31OP300wFA288dA6pQ06ZNw8qVK7Fnzx488MADOOuss/Dss8+O9LBoEO+99x4uuugiPPnkkwgEAiM9HMqC+pc/AMyYMQOzZ8/GhAkT8Lvf/Q7BYHAER0aD6enpwUEHHYQbbrgBAHDggQdi9erVuOOOO3DWWWeN8OgoUz//+c/xmc98BmPHjh3poVAGfve732HZsmW49957sd9++2HlypW4+OKLMXbsWK0/d5ziF8pxHOy1116YNWsWFi1ahAMOOABLly5FY2MjotEodu/enfL4rVu3orGxcWQGSwB6p4K3bduGmTNnwufzwefz4dlnn8Wtt94Kn8+HhoYGvndFoqqqClOnTsX69ev5MydYU1MT9t1335Tb9tlnH295hnp/+u7+5nsnx7vvvou//vWv+PrXv+7dxp852b7zne9g/vz5+MpXvoL9998fZ555Ji655BIsWrQIgL6fOwbUItHT04NIJIJZs2bBtm089dRT3n3r1q3Dxo0bMWfOnBEcIR111FF4/fXXsXLlSu/joIMOwumnn+79me9dcWhvb8fbb7+NpqYm/swJduihh2LdunUpt/3rX//ChAkTAACTJk1CY2NjynvX2tqKl156ie+dEL/4xS8wZswYHHfccd5t/JmTrbOzE6aZGh8ty0JPTw8AjT93evZ0kU7z5893n332WXfDhg3uqlWr3Pnz57uGYbhPPPGE67que8EFF7gtLS3u8uXL3VdeecWdM2eOO2fOnBEeNaWTvIvfdfneSXXZZZe5zzzzjLthwwb3hRdecI8++mi3rq7O3bZtm+u6fN+k+vvf/+76fD73+uuvd9966y132bJlbigUcn/zm994j7nxxhvdqqoq9+GHH3ZXrVrlHn/88e6kSZPcrq6uERw5ua7rJhIJt6Wlxb388sv73cefObnOOussd9y4ce4jjzzibtiwwf3973/v1tXVud/97ne9x+j4uWNAFeicc85xJ0yY4DqO49bX17tHHXWUF05d13W7urrcb37zm251dbUbCoXcE044wd28efMIjpgG0jeg8r2T6ZRTTnGbmppcx3HccePGuaeccoq7fv16736+b3L96U9/cj/2sY+5fr/fnT59unvXXXel3N/T0+N+73vfcxsaGly/3+8eddRR7rp160ZotJTs8ccfdwGkfT/4MydXa2ure9FFF7ktLS1uIBBwJ0+e7C5YsMCNRCLeY3T83Bmum9T6n4iIiIhohHENKhERERGJwoBKRERERKIwoBIRERGRKAyoRERERCQKAyoRERERicKASkRERESiMKASERERkSgMqEREREQj5NFHH8Xs2bMRDAZRXV2NL37xi0M+580338QXvvAFVFZWIhwO4+CDD8bGjRv7Pc51XXzmM5+BYRj4wx/+kPZaH374IcaPHw/DMLB79+6sxn7EEUfAMIyUjwsuuCCrawyEAZWIiIioQI444gjcc889ae978MEHceaZZ+JrX/sa/vnPf+KFF17AaaedNuj13n77bRx22GGYPn06nnnmGaxatQrf+973EAgE+j32lltugWEYg17v3HPPxYwZMzL+fvo677zzsHnzZu9j8eLFOV8rmU/LVYiIiIgoY/F4HBdddBGWLFmCc88917t93333HfR5CxYswGc/+9mUIDhlypR+j1u5ciVuuukmvPLKK2hqakp7rdtvvx27d+/GVVddhccee6zf/Q8//DAWLlyIN954A2PHjsVZZ52FBQsWwOf7KD6GQiE0NjYO+f1mixVUIiIiomH2j3/8A5s2bYJpmjjwwAPR1NSEz3zmM1i9evWAz+np6cGjjz6KqVOnYt68eRgzZgxmz57db/q+s7MTp512Gv7nf/5nwPD4xhtv4Nprr8WvfvUrmGb/OPjcc8/hq1/9Ki666CK88cYbuPPOO3HPPffg+uuvT3ncsmXLUFdXh4997GO44oor0NnZmf2LkQYDKhEREdEw+/e//w0AuOaaa/Df//3feOSRR1BdXY0jjjgCO3fuTPucbdu2ob29HTfeeCOOPfZYPPHEEzjhhBNw4okn4tlnn/Ued8kll+CQQw7B8ccfn/Y6kUgEp556KpYsWYKWlpa0j1m4cCHmz5+Ps846C5MnT8anP/1pfP/738edd97pPea0007Db37zGzz99NO44oor8Otf/xpnnHFGri9JKpeIiIiItLj++uvdcDjsfZim6fr9/pTb3n33XXfZsmUuAPfOO+/0ntvd3e3W1dW5d9xxR9prb9q0yQXgnnrqqSm3f/7zn3e/8pWvuK7rug8//LC71157uW1tbd79ANyHHnrI+/ySSy5xTznlFO/zp59+2gXg7tq1y7utrq7ODQQCKeMOBAIuALejoyPt+J566ikXgLt+/fqMX6+BcA0qERERkSYXXHABvvzlL3ufn3766TjppJNw4oknereNHTvWWxeavObU7/dj8uTJaXfkA0BdXR18Pl+/dar77LMPnn/+eQDA8uXL8fbbb6OqqirlMSeddBIOP/xwPPPMM1i+fDlef/11PPDAAwB6d/ur6y9YsAALFy5Ee3s7Fi5cmDJuJd2GLACYPXs2AGD9+vVp18VmgwGViIiISJOamhrU1NR4nweDQYwZMwZ77bVXyuNmzZoFv9+PdevW4bDDDgMAxGIxvPPOO5gwYULaazuOg4MPPhjr1q1Luf1f//qX95z58+fj61//esr9+++/P370ox/h85//PIDe7gFdXV3e/S+//DLOOeccPPfcc16wnDlzJtatW9dv3INZuXIlAAy4KSsbDKhEREREw6yiogIXXHABrr76ajQ3N2PChAlYsmQJAODkk0/2Hjd9+nQsWrQIJ5xwAgDgO9/5Dk455RTMnTsXRx55JP7yl7/gT3/6E5555hkAQGNjY9qNUS0tLZg0aRKA/rv+d+zYAaC3Eqsqr1dddRU+97nPoaWlBV/60pdgmib++c9/YvXq1bjuuuvw9ttv495778VnP/tZ1NbWYtWqVbjkkkswd+7cvNpWKQyoRERERCNgyZIl8Pl8OPPMM9HV1YXZs2dj+fLlqK6u9h6zbt067Nmzx/v8hBNOwB133IFFixbh29/+NqZNm4YHH3zQq8LqMm/ePDzyyCO49tpr8YMf/AC2bWP69OleddZxHPz1r3/FLbfcgo6ODjQ3N+Okk07Cf//3f2v5+oarFh4QEREREQnANlNEREREJAoDKhERERGJwoBKRERERKIwoBIRERGRKAyoRERERCQKAyoRERERicKASkRERESiMKASERERkSgMqEREREQkCgMqEREREYnCgEpEREREojCgEhEREZEo/x+QVd/ZhVRGkgAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "if lane is not None and lane.lane_group is not None:\n", " lane_group: LaneGroup = lane.lane_group\n", @@ -535,21 +437,10 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "23", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAuQAAAMJCAYAAABLCSONAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAnHxJREFUeJzs3Xd4VFX+x/H3nZn0SkIglNC7gkhRWZoCChYEQbHgYm+IBRARfyogCIgFu7grll11ragollUUFRZpAtKM9F4T0tuU+/vjJiOBAAmE3EnyeT3PPMnce+fOmUky+cyZ7znHME3TREREREREbOGwuwEiIiIiItWZArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFpNp56623MAyDZcuW2d2UMsvPz+fFF1+kW7du1KhRg+DgYOrWrcvll1/Of/7zH7xer91NPCl//PEHDz74IO3btycqKoo6depw6aWXlvgz+vTTT+nbty9169YlJCSE+vXrc+WVV7JmzZqjjv3ggw+4/vrrad68OYZhcP7551fAoxERKRuX3Q0QEZHSOXDgABdffDHLly+nb9++PPLII8TFxbF3716+//57rrvuOjZu3Mijjz5qd1PL7PXXX2fWrFkMHjyY4cOHk56ezmuvvcZ5553HN998Q58+ffzHrl69mho1anDfffdRs2ZN9u7dyxtvvME555zDokWLOOuss/zHvvrqqyxfvpzOnTuTkpJix0MTETkhwzRN0+5GiIhUpLfeeoubbrqJpUuX0qlTJ7ubU2r9+vXju+++46OPPmLQoEFH7V+2bBnJyckMHTr0mOfIy8sjODgYhyOwPiBdvnw5LVu2JDIy0r8tJSWF1q1b06JFCxYsWHDc2+/bt4/69etzyy23MHPmTP/2HTt2UK9ePRwOB2eeeSY1a9Zk/vz5p+thiIiclMB6RRYRCRAFBQU89thjdOzYkZiYGCIiIujevTs//vhjseO2bt2KYRg8/fTT/OMf/6Bp06aEhITQuXNnli5detR5//jjD6688kri4uIIDQ2lU6dOzJkz54TtWbRoEd9++y233357iWEcoFOnTsXC+Pz58zEMg/fff59HHnmEevXqER4eTkZGBgAfffQRHTt2JCwsjJo1a3L99deza9euYuc8//zzSyzzuPHGG2nUqFGJz8OMGTNo2LAhYWFh9OzZs8RSkiN17NixWBgHiI+Pp3v37qxfv/6Et69Vqxbh4eGkpaUV256UlBRwbz5ERI6kkhURkRJkZGTw+uuvc+2113LbbbeRmZnJrFmz6Nu3L0uWLKF9+/bFjn/vvffIzMzkjjvuwDAMpk+fzqBBg9i8eTNBQUEArF27lq5du1KvXj0eeughIiIi+PDDDxk4cCCffPIJV1xxxTHb88UXXwBw/fXXl/mxTJo0ieDgYB544AHy8/MJDg72f0rQuXNnpk6dyr59+3j++edZuHAhK1asIDY2tsz3A/Cvf/2LzMxM7r77bvLy8nj++efp1asXq1evpnbt2mU+3969e6lZs2aJ+9LS0nC73ezdu5fnnnuOjIwMevfufVLtFhGxkwK5iEgJatSowdatWwkODvZvu+2222jVqhUvvvgis2bNKnb89u3b2bBhAzVq1ACgZcuWDBgwgG+//ZbLLrsMgPvuu48GDRqwdOlSQkJCABg+fDjdunVj7Nixxw3kf/zxBwBnnnlmse15eXlkZWX5r7tcrqPCdF5eHsuWLSMsLAwAt9vN2LFjOfPMM/n5558JDQ0FoFu3blx22WXMmDGDiRMnlvq5OtzGjRvZsGED9erVA6wym3PPPZcnn3ySZ599tkzn+uWXX1i0aBGPPPJIifvPO+88kpOTAYiMjOSRRx7hlltuOal2i4jYSZ/jiYiUwOl0+sO4z+cjNTUVj8dDp06d+O233446/uqrr/aHcYDu3bsDsHnzZgBSU1P54YcfGDJkCJmZmRw8eJCDBw+SkpJC37592bBhw1HlIocrKjM5sqxj5syZJCQk+C/dunU76rY33HCDP4yDVWu+f/9+hg8f7g/jAJdeeimtWrVi7ty5J3x+jmXgwIH+MA5wzjnncO655/LVV1+V6Tz79+/nuuuuo3Hjxjz44IMlHvPmm2/yzTff8Morr9C6dWtyc3Mr7SwzIlK9VZlAfvnll9OgQQNCQ0OpU6cOf//739m9e/dxb7Np0yauuOIKEhISiI6OZsiQIezbt6/YMb/99hsXXnghsbGxxMfHc/vttxfrjQJYunQpvXv3JjY2lho1atC3b19WrVpV5sewfv16Lr/8cn+9aufOndm+fXuZzyMi5ePtt9+mXbt2hIaGEh8fT0JCAnPnziU9Pf2oYxs0aFDselE4P3ToEGD1HJumyaOPPlosQCckJDB+/HjACqHHEhUVBXDU68/gwYP57rvv+O6772jXrl2Jt23cuHGx69u2bQOsXvwjtWrVyr//ZDRv3vyobS1atGDr1q2lPkd2djaXXXYZmZmZfP7550e9CSnSpUsX+vbty1133cW3337LO++8w7hx40626SIitqlUgfz888/nrbfeKnHfBRdcwIcffkhycjKffPIJmzZt4sorrzzmubKzs7noooswDIMffviBhQsXUlBQQP/+/fH5fADs3r2bPn360KxZMxYvXsw333zD2rVrufHGG/3nycrKol+/fjRo0IDFixezYMECoqKi6Nu3L263u9SPbdOmTXTr1o1WrVoxf/58fv/9dx599NFivVciUnHeeecdbrzxRpo2bcqsWbP45ptv+O677+jVq5f/NeJwTqezxPMUTWRVdJsHHnjAH6CPvDRr1uyY7WnVqhXAUQMkk5KS6NOnD3369CnWQ3+4w3vHy8owjBK3n66e6IKCAgYNGsTvv//O559/flSJzrHUqFGDXr168e67756WdomInE5VpoZ85MiR/u8bNmzIQw89xMCBA3G73f4BVYdbuHAhW7duZcWKFURHRwNWb1iNGjX44Ycf6NOnD19++SVBQUG8/PLL/lH6M2fOpF27dmzcuJFmzZrxxx9/kJqayuOPP05SUhIA48ePp127dmzbts3/D3bBggWMGzeOZcuWUbNmTa644gqmTp1KREQEAP/3f//HJZdcwvTp0/1tbNq06el5skTkhD7++GOaNGnC7Nmzi4XSot7ssmrSpAkAQUFBxebULq3LLruMadOm8e6779K1a9eTakORhg0bApCcnEyvXr2K7UtOTvbvByvoFpXdHO5YvegbNmw4atuff/5ZbEaWY/H5fAwbNox58+bx4Ycf0rNnzxPe5nC5ubklfnohIhLoKlUPeWmlpqby7rvv8re//a3EMA7WaneGYfgHVgGEhobicDj8890WzUZw+JRZRT1NRce0bNmS+Ph4Zs2aRUFBAbm5ucyaNYvWrVv7/wFt2rSJfv36MXjwYH7//Xc++OADFixYwIgRIwDrn9DcuXNp0aIFffv2pVatWpx77rl89tln5f3UiEgpFfV4H75Uw+LFi1m0aNFJna9WrVqcf/75vPbaa+zZs+eo/QcOHDju7bt27cqFF17IP/7xDz7//PMSjyntshKdOnWiVq1azJw5k/z8fP/2r7/+mvXr13PppZf6tzVt2pQ//vijWPtWrVrFwoULSzz3Z599VqwWfsmSJSxevJiLL774hO265557+OCDD3jllVeOObUjlFzas3XrVubNm1ep5pUXESlSZXrIAcaOHctLL71ETk4O5513Hl9++eUxjz3vvPOIiIhg7NixTJkyBdM0eeihh/B6vf5/lr169WLUqFE89dRT3HfffWRnZ/PQQw8B+I+Jiopi/vz5DBw4kEmTJgFWDeW3336Ly2U9vVOnTmXo0KHcf//9/v0vvPACPXv25NVXXyUtLY2srCymTZvG5MmTefLJJ/nmm28YNGgQP/74Y5l7iUSkdN544w2++eabo7bfd999XHbZZcyePZsrrriCSy+9lC1btjBz5kzatGlzVB13ab388st069aNtm3bctttt9GkSRP27dvHokWL2Llz5wnHnrzzzjv069ePgQMHcvHFF/vLVIpW6vz5559LFXyDgoJ48sknuemmm+jZsyfXXnutf9rDRo0aFfvE8eabb+bZZ5+lb9++3HLLLezfv5+ZM2dyxhln+AeaHq5Zs2Z069aNu+66i/z8fJ577jni4+OPOTCzyHPPPccrr7xCly5dCA8P55133im2/4orrvB/oti2bVt69+5N+/btqVGjBhs2bGDWrFm43W6mTZtW7HY///wzP//8M2C96cnOzmby5MkA9OjRgx49epzw+RIROe3MAPbEE0+YERER/ovD4TBDQkKKbdu2bZv/+AMHDpjJycnmf//7X7Nr167mJZdcYvp8vmOe/9tvvzWbNGliGoZhOp1O8/rrrzc7dOhg3nnnnf5j3n33XbN27dqm0+k0g4ODzQceeMCsXbu2OW3aNNM0TTMnJ8c855xzzGHDhplLliwxFy1aZA4ePNg844wzzJycHNM0TbNTp05mcHBwsXaHh4ebgLlu3Tpz165dJmBee+21xdrXv39/85prrinPp1RETNN88803TeCYlx07dpg+n8+cMmWK2bBhQzMkJMQ8++yzzS+//NK84YYbzIYNG/rPtWXLFhMwn3rqqaPuBzDHjx9fbNumTZvMYcOGmYmJiWZQUJBZr14987LLLjM//vjjUrU9NzfXfO6558wuXbqY0dHRpsvlMhMTE83LLrvMfPfdd02Px+M/9scffzQB86OPPirxXB988IF59tlnmyEhIWZcXJw5dOhQc+fOnUcd984775hNmjQxg4ODzfbt25vffvvtcZ+HZ555xkxKSjJDQkLM7t27m6tWrTrh47rhhhuO+zPZsmWL/9jx48ebnTp1MmvUqGG6XC6zbt265jXXXGP+/vvvR513/PjxxzznkT8bERG7GKZZys84bZCamkpqaqr/+tChQxk8eHCxjzIbNWrk74k+3M6dO0lKSuJ///sfXbp0Oe79HDx40D93b2JiIqNHj2bMmDHFjtm3bx8REREYhkF0dDTvv/8+V111FbNmzeLhhx9mz549/tKWgoICatSowaxZs7jmmmto3bo1F154Iffee+9R9100M0NERATjx48vNt/u2LFjWbBgwTE/GhYRCRRbt26lcePGPPXUUzzwwAN2N0dEpFIJ6JKVuLg44uLi/NfDwsKoVavWcWciKFI0o8Hh9ZHHUrQK3A8//MD+/fu5/PLLjzqmaIW5N954g9DQUC688EIAcnJycDgcxQZ9FV0vakOHDh1Yt27dcdvduXNn/wIXRf78889ig6tEREREpOqpEoM6Fy9ezEsvvcTKlSvZtm0bP/zwA9deey1Nmzb1947v2rWLVq1asWTJEv/t3nzzTX799Vc2bdrEO++8w1VXXcXIkSOLzc370ksv8dtvv/Hnn3/y8ssvM2LECKZOnepfCe/CCy/k0KFD3H333axfv561a9dy00034XK5uOCCCwCrp/t///sfI0aMYOXKlWzYsIHPP//cP6gTYMyYMXzwwQf885//ZOPGjbz00kt88cUXDB8+vAKeQRERERGxS0D3kJdWeHg4s2fPZvz48WRnZ1OnTh369evHI4884p9Fxe12k5ycTE5Ojv92ycnJjBs3jtTUVBo1asT//d//FRvMBNYMAePHjycrK4tWrVrx2muv8fe//92/v1WrVnzxxRdMnDiRLl264HA4OPvss/nmm2+oU6cOAO3ateOnn37i//7v/+jevTumadK0aVOuvvpq/3muuOIKZs6cydSpU7n33ntp2bIln3zySYmr7omIiIhI1RHQNeQiIiIiIlVdmUtWdu3axfXXX098fDxhYWG0bduWZcuW+febpsljjz1GnTp1CAsLo0+fPiUuFCEiIiIiImUM5IcOHaJr164EBQXx9ddfs27dOp555pliyzVPnz6dF154gZkzZ7J48WIiIiLo27cveXl55d54EREREZHKrkwlKw899BALFy7kl19+KXG/aZrUrVuX0aNH+6e9Sk9Pp3bt2rz11ltcc801J7wPn8/H7t27iYqKKjZziYiIiIhIZWGaJpmZmdStW7fYqu8lKVMgb9OmDX379mXnzp389NNP1KtXj+HDh3PbbbcBsHnzZpo2bcqKFSto3769/3Y9e/akffv2PP/880edMz8/v9jUhLt27aJNmzalbZKIiIiISMDasWMH9evXP+4xZZplZfPmzbz66quMGjWKhx9+mKVLl3LvvfcSHBzMDTfcwN69e4G/5uwuUrt2bf++I02dOpWJEyeW2Pjo6OiyNE9EREREJCBkZGSQlJREVFTUCY8tUyD3+Xx06tSJKVOmAHD22WezZs0aZs6cyQ033HBSjR03bhyjRo3yXy9qfHR0tAK5iIiIiFRqpSnBLtOgzjp16hxVTtK6dWu2b98OQGJiImAtM3+4ffv2+fcdKSQkxB++FcJFREREpLopUyDv2rXrcZd3b9y4MYmJicybN8+/PyMjg8WLF/tXzBQRERERkb+UqWRl5MiR/O1vf2PKlCkMGTKEJUuW8I9//IN//OMfgNUlf//99zN58mSaN29O48aNefTRR6lbty4DBw48He0XEREREanUyhTIO3fuzKeffsq4ceN4/PHHady4Mc899xxDhw71H/Pggw+SnZ3N7bffTlpaGt26deObb74hNDS03BsvIiISCLxeL2632+5miEgFCw4OPuGUhqVRpmkPK0JGRgYxMTGkp6ernlxERAKaaZrs3buXtLQ0u5siIjZwOBw0btyY4ODgo/aVJdOWqYdcRERE/lIUxmvVqkV4eLgWtBOpRooWs9yzZw8NGjQ4pb9/BXIREZGT4PV6/WE8Pj7e7uaIiA0SEhLYvXs3Ho+HoKCgkz7PqRe9iIiIVENFNePh4eE2t0RE7FJUquL1ek/pPArkIiIip0BlKiLVV3n9/SuQi4iIiIjYSDXkIiIi5cjtdp/yx9dl4XQ6T6l2VUTsp0AuIiJSTtxuN8nJyeTm5lbYfYaFhdGyZctSh/Ibb7yRtLQ0Pvvss9PbsJOwd+9epk6dyty5c9m5cycxMTE0a9aM66+/nhtuuCEg6/VTU1MZP348//3vf9m+fTsJCQkMHDiQSZMmERMTA0BKSgpDhw7l999/JyUlhVq1ajFgwACmTJninw5vz549jB49mmXLlrFx40buvfdennvuORsfmVQkBXIREZFy4vV6yc3NxeVy4XKd/n+xHo+H3NxcvF5vpe8l37x5M127diU2NpYpU6bQtm1bQkJCWL16Nf/4xz+oV68el19+eYm3dbvdtj3+3bt3s3v3bp5++mnatGnDtm3buPPOO9m9ezcff/wxYM1VPWDAACZPnkxCQgIbN27k7rvvJjU1lffeew+A/Px8EhISeOSRR5gxY4Ytj0XsoxpyERGRcuZyuQgODj7tl9MR+p999lnatm1LREQESUlJDB8+nKysLP/+t956i9jYWL799ltat25NZGQk/fr1Y8+ePcXO8/rrr9O6dWtCQ0Np1aoVr7zyynHvd/jw4bhcLpYtW8aQIUNo3bo1TZo0YcCAAcydO5f+/fv7jzUMg1dffZXLL7+ciIgInnjiCQBeffVVmjZtSnBwMC1btuTf//63/zZbt27FMAxWrlzp35aWloZhGMyfPx+A+fPnYxgGc+fOpV27doSGhnLeeeexZs2aY7b7zDPP5JNPPqF///40bdqUXr168cQTT/DFF1/g8XgAqFGjBnfddRedOnWiYcOG9O7dm+HDh/PLL7/4z9OoUSOef/55hg0b5u9Zl+pDgVxERET8HA4HL7zwAmvXruXtt9/mhx9+4MEHHyx2TE5ODk8//TT//ve/+fnnn9m+fTsPPPCAf/+7777LY489xhNPPMH69euZMmUKjz76KG+//XaJ95mSksJ///tf7r77biIiIko85sjZLCZMmMAVV1zB6tWrufnmm/n000+57777GD16NGvWrOGOO+7gpptu4scffyzzczBmzBieeeYZli5dSkJCAv379/dPc1kaRSszHusN0+7du5k9ezY9e/Ysc9ukalIgFxEREb/777+fCy64gEaNGtGrVy8mT57Mhx9+WOwYt9vNzJkz6dSpEx06dGDEiBHMmzfPv3/8+PE888wzDBo0iMaNGzNo0CBGjhzJa6+9VuJ9bty4EdM0admyZbHtNWvWJDIyksjISMaOHVts33XXXcdNN91EkyZNaNCgAU8//TQ33ngjw4cPp0WLFowaNYpBgwbx9NNPl/k5GD9+PBdeeCFt27bl7bffZt++fXz66aeluu3BgweZNGkSt99++1H7rr32WsLDw6lXrx7R0dG8/vrrZW6bVE0K5CIiIuL3/fff07t3b+rVq0dUVBR///vfSUlJIScnx39MeHg4TZs29V+vU6cO+/fvByA7O5tNmzZxyy23+MN0ZGQkkydPZtOmTWVqy5IlS1i5ciVnnHEG+fn5xfZ16tSp2PX169fTtWvXYtu6du3K+vXry3SfAF26dPF/HxcXR8uWLUt1noyMDC699FLatGnDhAkTjto/Y8YMfvvtNz7//HM2bdrEqFGjytw2qZo0qFNEREQAq876sssu46677uKJJ54gLi6OBQsWcMstt1BQUOCf5eTIAZSGYWCaJoC/3vyf//wn5557brHjnE5niffbrFkzDMMgOTm52PYmTZoA1kwyRzpWacuxOBxWH2RRO4EylaGcSGZmJv369SMqKopPP/20xEGmiYmJJCYm0qpVK+Li4ujevTuPPvooderUKbd2SOWkHnIREREBYPny5fh8Pp555hnOO+88WrRowe7du8t0jtq1a1O3bl02b95Ms2bNil0aN25c4m3i4+O58MILeemll8jOzj6ptrdu3ZqFCxcW27Zw4ULatGkDQEJCAkCxwaeHD/A83K+//ur//tChQ/z555+0bt36mPedkZHBRRddRHBwMHPmzCE0NPSE7fX5fABH9fxL9aQechERkWomPT39qDAaHx9Ps2bNcLvdvPjii/Tv35+FCxcyc+bMMp9/4sSJ3HvvvcTExNCvXz/y8/NZtmwZhw4dOmaZxiuvvELXrl3p1KkTEyZMoF27djgcDpYuXcoff/xBx44dj3ufY8aMYciQIZx99tn06dOHL774gtmzZ/P9998DVi/7eeedx7Rp02jcuDH79+/nkUceKfFcjz/+OPHx8dSuXZv/+7//o2bNmgwcOLDEY4vCeE5ODu+88w4ZGRlkZGQA1psAp9PJV199xb59++jcuTORkZGsXbuWMWPG0LVrVxo1auQ/V9HPJCsriwMHDrBy5UqCg4P9byqk6lIgFxERKWdF090F6v3Mnz+fs88+u9i2W265hddff51nn32WJ598knHjxtGjRw+mTp3KsGHDynT+W2+9lfDwcJ566inGjBlDREQEbdu25f777z/mbZo2bcqKFSuYMmUK48aNY+fOnYSEhNCmTRseeOABhg8fftz7HDhwIM8//zxPP/009913H40bN+bNN9/k/PPP9x/zxhtvcMstt9CxY0datmzJ9OnTueiii44617Rp07jvvvvYsGED7du354svviA4OLjE+/3tt99YvHgxYJXeHG7Lli00atSIsLAw/vnPfzJy5Ejy8/NJSkpi0KBBPPTQQ8WOP/xnsnz5ct577z0aNmzI1q1bj/vYpfIzzMOLqQJARkYGMTEx/imDREREAlFeXh5btmyhcePG/hKFyrBSpxzb/PnzueCCCzh06BCxsbF2N0cqgZJeB4qUJdOqh1xERKScBAUF0bJlS7xeb4Xdp9PpVBgXqeQUyEVERMpRUFCQArKIlIkCuYiIiAhw/vnnE2CVvFJNaNpDEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNNO2hiIhIefLkgq+g4u7PEQyusIq7vzK48cYbSUtL47PPPrO7KSIBTYFcRESkvHhyYefn4D5UcfcZVAPqDyh1KL/xxht5++23rZsGBdGgQQOGDRvGww8/jMulWHCqtm7dSuPGjVmxYgXt27e3uzlH+eSTT3j55ZdZsWIFeXl5NGjQgK5du3LPPfdw9tln2928Eu3Zs4fRo0ezbNkyNm7cyL333stzzz1X7Jjzzz+fn3766ajbXnLJJcydOxeA2bNnM3PmTJYvX05qamqJP6OSznPHHXcwc+bMcn1MR1LJioiISHnxFVhh3BFmBeXTfXGEWfdXxh75fv36sWfPHjZs2MDo0aOZMGECTz31VInHFhRUYG9/JeL1evH5fHY3o0zGjh3L1VdfTfv27ZkzZw7Jycm89957NGnShHHjxh3zdnb/DuTn55OQkMAjjzzCWWedVeIxs2fPZs+ePf7LmjVrcDqdXHXVVf5jsrOz6datG08++eRx7++2224rdq7p06eX6+MpiQK5iIhIeXOGgivi9F+coSfVvJCQEBITE2nYsCF33XUXffr0Yc6cOYDVgz5w4ECeeOIJ6tatS8uWLQHYsWMHQ4YMITY2lri4OAYMGMDWrVv95/R6vYwaNYrY2Fji4+N58MEHj1r10ufzMXXqVBo3bkxYWBhnnXUWH3/8cbFj1q5dy2WXXUZ0dDRRUVF0796dTZs2+fe//vrrtG7dmtDQUFq1asUrr7zi31dQUMCIESOoU6cOoaGhNGzYkKlTpwJgmiYTJkygQYMGhISEULduXe69917/bQ8dOsSwYcOoUaMG4eHhXHzxxWzYsMG//6233iI2NpY5c+bQpk0bQkJC2L59e5mf+02bNjFgwABq165NZGQknTt35vvvvy92TKNGjZgyZQo333wzUVFRNGjQgH/84x/FjjnRz+NIv/76K9OnT+fZZ5/l2WefpXv37jRo0ICOHTvyyCOP8PXXX/uPnTBhAu3bt+f111+ncePGhIZav2fbt29nwIABREZGEh0dzZAhQ9i3b5//dkW/O4e7//77Of/88/3Xzz//fEaMGMGIESOIiYmhZs2aPProo8ddIbVRo0Y8//zzDBs2jJiYmBKPiYuLIzEx0X/57rvvCA8PLxbI//73v/PYY4/Rp0+fY94XQHh4eLFzRUdHH/f48qBALiIiUs2FhYUV6wWdN28eycnJfPfdd3z55Ze43W769u1LVFQUv/zyCwsXLiQyMpJ+/fr5b/fMM8/w1ltv8cYbb7BgwQJSU1P59NNPi93P1KlT+de//sXMmTNZu3YtI0eO5Prrr/eXCOzatYsePXoQEhLCDz/8wPLly7n55pvxeDwAvPvuuzz22GM88cQTrF+/nilTpvDoo4/6S3BeeOEF5syZw4cffkhycjLvvvsujRo1AqxSjRkzZvDaa6+xYcMGPvvsM9q2betv24033siyZcuYM2cOixYtwjRNLrnkEtxut/+YnJwcnnzySV5//XXWrl1LrVq1yvxcZ2VlcckllzBv3jxWrFhBv3796N+//1Hh/plnnqFTp06sWLGC4cOHc9ddd5GcnAxQqp/Hkf7zn/8QGRnJ8OHDS9xvGEax6xs3buSTTz5h9uzZrFy5Ep/Px4ABA0hNTeWnn37iu+++Y/PmzVx99dVlfg7efvttXC4XS5Ys4fnnn+fZZ5/l9ddfL/N5jmfWrFlcc801RERElPm27777LjVr1uTMM89k3Lhx5OTklGvbSqJiMRERkWrKNE3mzZvHt99+yz333OPfHhERweuvv05wcDAA77zzDj6fj9dff90f3N58801iY2OZP38+F110Ec899xzjxo1j0KBBAMycOZNvv/3Wf878/HymTJnC999/T5cuXQBo0qQJCxYs4LXXXqNnz568/PLLxMTE8P777xMUFARAixYt/OcYP348zzzzjP8+GjduzLp163jttde44YYb2L59O82bN6dbt24YhkHDhg39t92+fTuJiYn06dPHXzt/zjnnALBhwwbmzJnDwoUL+dvf/gZYoSwpKYnPPvvM38vqdrt55ZVXjlk2URpnnXVWsdtPmjSJTz/9lDlz5jBixAj/9ksuucQfnseOHcuMGTP48ccfadmyJR988MEJfx5H+vPPP2nSpEmxcQLPPvssjz32mP/6rl27/D3QBQUF/Otf/yIhIQGA7777jtWrV7NlyxaSkpIA+Ne//sUZZ5zB0qVL6dy5c6mfg6SkJGbMmIFhGLRs2ZLVq1czY8YMbrvttlKf43iWLFnCmjVrmDVrVplve91119GwYUPq1q3L77//ztixY0lOTmb27Nnl0rZjUSAXERGpZr788ksiIyNxu934fD6uu+46JkyY4N/ftm1bfxgHWLVqFRs3biQqKqrYefLy8ti0aRPp6ens2bOHc88917/P5XLRqVMnfynCxo0bycnJ4cILLyx2joKCAv9gwpUrV9K9e3d/GD9cdnY2mzZt4pZbbikW3Dwejz9E3njjjVx44YW0bNmSfv36cdlll/nD6VVXXcVzzz1HkyZN6NevH5dccgn9+/fH5XKxfv16XC5XsfbHx8fTsmVL1q9f798WHBxMu3btSvckH0NWVhYTJkxg7ty57NmzB4/HQ25u7lE95Iffj2EYJCYmsn//fuDEP4/Suvnmm7n88stZvHgx119/fbGykYYNG/rDOMD69etJSkryh3GANm3aEBsby/r168sUyM8777xiPfJdunThmWeewev14nQ6S32eY5k1axZt27b1v+Eqi9tvv93/fdu2balTpw69e/dm06ZNNG3a9JTbdiwK5CIiItXMBRdcwKuvvkpwcDB169Y9anaVIz/mz8rKomPHjrz77rtHnevw0HY8WVlZAMydO5d69eoV2xcSEgJYpTMnuv0///nPYsEZ8Ie4Dh06sGXLFr7++mu+//57hgwZQp8+ffj4449JSkoiOTmZ77//nu+++47hw4fz1FNPlTgzx7GEhYUdVdpRVg888ADfffcdTz/9NM2aNSMsLIwrr7zyqFKTI9+UGIbhH0R6Mj+P5s2bs2DBAtxut//csbGxxMbGsnPnzqOOP5lSD4fDcVQt+OElPxUhOzub999/n8cff7xczlf0u7Zx40YFchERESk/ERERNGvWrNTHd+jQgQ8++IBatWodc4BbnTp1WLx4MT169ACsnuvly5fToUMHgGIDIXv27FniOdq1a8fbb79dLDQWqV27NnXr1mXz5s0MHTr0mG2Njo7m6quv5uqrr+bKK6+kX79+pKamEhcXR1hYGP3796d///7cfffdtGrVitWrV9O6dWs8Hg+LFy/2l6ykpKSQnJxMmzZtSv08lcbChQu58cYbueKKKwArXB9vMGZJSvPzONK1117Liy++yCuvvMJ9991X1mbTunVrduzYwY4dO/y95OvWrSMtLc3/HCUkJLBmzZpit1u5cuVRP8vFixcXu/7rr7/SvHnzcukd/+ijj8jPz+f6668/5XOB1X6wfr9PJwVyEREROa6hQ4fy1FNPMWDAAB5//HHq16/Ptm3bmD17Ng8++CD169fnvvvuY9q0aTRv3pxWrVrx7LPPkpaW5j9HVFQUDzzwACNHjsTn89GtWzfS09NZuHAh0dHR3HDDDYwYMYIXX3yRa665hnHjxhETE8Ovv/7KOeecQ8uWLZk4cSL33nsvMTEx9OvXj/z8fJYtW8ahQ4cYNWoUzz77LHXq1OHss8/G4XDw0UcfkZiYSGxsLG+99RZer5dzzz2X8PBw3nnnHcLCwmjYsCHx8fEMGDCA2267jddee42oqCgeeugh6tWrx4ABA07qOSsagHm4M844g+bNmzN79mz69++PYRg8+uijZZ4+sTQ/jyN16dKF0aNHM3r0aLZt28agQYNISkpiz549zJo1C8MwcDiOPddHnz59aNu2LUOHDuW5557D4/EwfPhwevbsSadOnQDo1asXTz31FP/617/o0qUL77zzDmvWrDlqfvPt27czatQo7rjjDn777TdefPFFnnnmmeM+5qJgnJWVxYEDB1i5ciXBwcFHvWGaNWsWAwcOJD4+/qhzpKamsn37dnbv3g389TMqmk1l06ZNvPfee1xyySXEx8fz+++/M3LkSHr06HHKpUonokAuIiJS3rx5Vep+wsPD+fnnnxk7diyDBg0iMzOTevXq0bt3b38P7ejRo9mzZw833HADDoeDm2++mSuuuIL09HT/eSZNmkRCQgJTp05l8+bNxMbG0qFDBx5++GHAqtv+4YcfGDNmDD179sTpdNK+fXu6du0KwK233kp4eDhPPfUUY8aMISIigrZt23L//fcDVuifPn06GzZswOl00rlzZ7766iscDgexsbFMmzaNUaNG4fV6adu2LV988YU/uL355pvcd999XHbZZRQUFNCjRw+++uqrEuvZS+Oaa645atuOHTt49tlnufnmm/nb3/5GzZo1GTt2LBkZGWU6d2l+HiV5+umnOeecc3j11Vd54403yMnJoXbt2vTo0YNFixYd97aGYfD5559zzz330KNHDxwOB/369ePFF1/0H9O3b18effRRHnzwQfLy8rj55psZNmwYq1evLnauYcOGkZubyznnnIPT6eS+++4rVrtdksND/fLly3nvvfdo2LBhsU8XkpOTWbBgAf/9739LPMecOXO46aab/NeLfkbjx49nwoQJBAcH8/333/Pcc8+RnZ1NUlISgwcP5pFHHjlu28qDYR5v4kcbZGRkEBMTQ3p6eoXM+ygiInIy8vLy2LJlS7F5mivDSp0idjr//PNp3779USttVlYlvg4UKkumVQ+5iIhIeXGFWeG4jCtnnhJHsMK4SCWnQC4iIlKeXGGAArKIlJ4CuYiIiIhUiPnz59vdhIB07OG0IiIiIiJy2imQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjTTtoYiISDnKzYWCClwXKDgYwjTtuUilpkAuIiJSTnJz4fPP4dChirvPGjVgwIDSh/IDBw7w2GOPMXfuXPbt20eNGjU466yzeOyxx+jatevpbWwlMmHCBD777DNWrlxpd1OOkpGRwVNPPcXs2bPZvHkz4eHhNGnShKuuuorbbruNGjVq2N3EEs2ePZuZM2eyfPlyUlNTWbFiBe3bty92zB133MH333/P7t27iYyM5G9/+xtPPvkkrVq1AiAlJYWhQ4fy+++/k5KSQq1atRgwYABTpkwptjx9fn4+jz/+OO+88w579+6lTp06PPbYY9x8880V+ZBLTYFcRESknBQUWGE8LAxCQ0///eXlWfdXUFD6QD548GAKCgp4++23adKkCfv27WPevHmkpKSc3sYGqIKCAoKDg+1uRqmlpqbSrVs3MjIymDRpEh07diQmJobk5GTefPNN3nvvPe6+++4Sb2v3Y83OzqZbt24MGTKE2267rcRjOnbsyNChQ2nQoAGpqalMmDCBiy66iC1btuB0OnE4HAwYMIDJkyeTkJDAxo0bufvuu0lNTeW9997zn2fIkCHs27ePWbNm0axZM/bs2YPP56uoh1pmqiEXEREpZ6GhEBFx+i9lDf1paWn88ssvPPnkk1xwwQU0bNiQc845h3HjxnH55ZcDsHXrVgzDKNYznJaWhmEYxVZZXLt2LZdddhnR0dFERUXRvXt3Nm3a5N//xhtvcMYZZxASEkKdOnUYMWJEsfPdeuutJCQkEB0dTa9evVi1apV//6pVq7jggguIiooiOjqajh07smzZMgC2bdtG//79qVGjBhEREZxxxhl89dVX/tv+9NNPnHPOOf77feihh/B4PP79559/PiNGjOD++++nZs2a9O3bt2xPYqF///vfdOrUiaioKBITE7nuuuvYv3+/f//8+fMxDIN58+bRqVMnwsPD+dvf/kZycnKx83z++ed06NCB0NBQmjRpwsSJE4u190gPP/ww27dvZ8mSJdx00020a9eOhg0bctFFF/Gf//yH4cOH+49t1KgRkyZNYtiwYURHR3P77bcD8Mknn/h/No0aNeKZZ54pdh+GYfDZZ58V2xYbG8tbb70F/PU78v777/O3v/2N0NBQzjzzTH766afjPmd///vfeeyxx+jTp88xj7n99tvp0aMHjRo1okOHDkyePJkdO3awdetWAGrUqMFdd91Fp06daNiwIb1792b48OH88ssv/nN88803/PTTT3z11Vf06dOHRo0a0aVLl4D+BEiBXEREpJqIjIwkMjKSzz77jPz8/JM+z65du+jRowchISH88MMPLF++nJtvvtkfJF999VXuvvtubr/9dlavXs2cOXNo1qyZ//ZXXXUV+/fv5+uvv2b58uV06NCB3r17k5qaCsDQoUOpX78+S5cuZfny5Tz00EMEBQUBcPfdd5Ofn8/PP//M6tWrefLJJ4mMjPS365JLLqFz586sWrWKV199lVmzZjF58uRi7X/77bcJDg5m4cKFzJw586SeA7fbzaRJk1i1ahWfffYZW7du5cYbbzzquP/7v//jmWeeYdmyZbhcrmIlE7/88gvDhg3jvvvuY926dbz22mu89dZbPPHEEyXep8/n44MPPuD666+nbt26JR5jGEax608//TRnnXUWK1as4NFHH2X58uUMGTKEa665htWrVzNhwgQeffRRf9guizFjxjB69GhWrFhBly5d6N+/f7l+0pKdnc2bb75J48aNSUpKKvGY3bt3M3v2bHr27OnfNmfOHDp16sT06dOpV68eLVq04IEHHiA3N7fc2lbeVLIiIiJSTbhcLt566y1uu+02Zs6cSYcOHejZsyfXXHMN7dq1K/V5Xn75ZWJiYnj//ff9QblFixb+/ZMnT2b06NHcd999/m2dO3cGYMGCBSxZsoT9+/cTEhICWKHxs88+4+OPP+b2229n+/btjBkzxl833Lx5c/95tm/fzuDBg2nbti0ATZo08e975ZVXSEpK4qWXXsIwDFq1asXu3bsZO3Ysjz32GA6Hw3++6dOnl+m5O9LhwbpJkya88MILdO7cmaysLP8bBIAnnnjCHxYfeughLr30UvLy8ggNDWXixIk89NBD3HDDDf7zTJo0iQcffJDx48cfdZ8HDhwgLS2Nli1bFtvesWNHf897//79+c9//uPf16tXL0aPHu2/PnToUHr37s2jjz4KWD+3devW8dRTT5X4huJ4RowYweDBgwHrTdg333zDrFmzePDBB8t0niO98sorPPjgg2RnZ9OyZUu+++67o0ptrr32Wj7//HNyc3Pp378/r7/+un/f5s2bWbBgAaGhoXz66accPHiQ4cOHk5KSwptvvnlKbTtd1EMuIiJSjQwePJjdu3czZ84c+vXrx/z58+nQoUOZekhXrlxJ9+7d/WH8cPv372f37t307t27xNuuWrWKrKws4uPj/T32kZGRbNmyxV/yMmrUKG699Vb69OnDtGnTipXC3HvvvUyePJmuXbsyfvx4fv/9d/++9evX06VLl2K9xF27diUrK4udO3f6t3Xs2LHUj/VYli9fTv/+/WnQoAFRUVH+0L19+/Zixx3+RqdOnToA/tKWVatW8fjjjxd7Hm677Tb27NlDTk5Oqdvy6aefsnLlSvr27XtUL3CnTp2KXV+/fv1RpRtdu3Zlw4YNeL3eUt8nQJcuXfzfu1wuOnXqxPr168t0jpIMHTqUFStW8NNPP9GiRQuGDBlCXl5esWNmzJjBb7/9xueff86mTZsYNWqUf5/P58MwDN59913OOeccLrnkEp599lnefvvtgO0lVyAXERGpZkJDQ7nwwgt59NFH+d///seNN97o75Et6kU2TdN/vNvtLnb7sOOMID3ePoCsrCzq1KnDypUri12Sk5MZM2YMYM1wsnbtWi699FJ++OEH2rRpw6effgrArbfeyubNm/n73//O6tWr6dSpEy+++GKZHn9ERESZjj9SdnY2ffv2JTo6mnfffZelS5f621dwxJyXh79pKXqjUDS4MCsri4kTJxZ7HlavXs2GDRsILWGAQEJCArGxsUfVoTdo0IBmzZoRFRVVLo/VMIxiP384+nfgdIqJiaF58+b06NGDjz/+mD/++MP//BZJTEykVatWXH755bz22mu8+uqr7NmzB7De+NSrV4+YmBj/8a1bt8Y0zWJvzAKJArmIiEg116ZNG7KzswEr9AH+cAMcNfVfu3bt+OWXX0oMaVFRUTRq1Ih58+aVeF8dOnRg7969uFwumjVrVuxSs2ZN/3EtWrRg5MiR/Pe//2XQoEHFSg2SkpK48847mT17NqNHj+af//wnYIWuRYsWFQuTCxcuJCoqivr165fxWTm2P/74g5SUFKZNm0b37t1p1apVsQGdpdWhQweSk5OPeh6aNWvmf2N0OIfDwZAhQ3jnnXfYvXv3SbW9devWLFy4sNi2hQsX0qJFC5xOJ2D9Dhz+89+wYUOJPfa//vqr/3uPx8Py5ctp3br1SbXrWEzTxDTN4455KHqDU3RM165d2b17N1lZWf5j/vzzTxwOR7n+HpQn1ZCLiIhUEykpKVx11VXcfPPNtGvXjqioKJYtW8b06dMZMGAAYPVwn3feeUybNo3GjRuzf/9+HnnkkWLnGTFiBC+++CLXXHMN48aNIyYmhl9//ZVzzjmHli1bMmHCBO68805q1arFxRdfTGZmJgsXLuSee+6hT58+dOnShYEDBzJ9+nRatGjB7t27mTt3LldccQVnnHEGY8aM4corr6Rx48bs3LmTpUuX+muV77//fi6++GJatGjBoUOH+PHHH/0hcPjw4Tz33HPcc889jBgxguTkZMaPH8+oUaNKDLgnkpube9SbkaioKBo0aEBwcDAvvvgid955J2vWrGHSpEllPv9jjz3GZZddRoMGDbjyyitxOBysWrWKNWvWHDUQtciUKVOYP38+55xzDo8//jidOnUiIiKC33//nUWLFnHmmWce9z5Hjx5N586dmTRpEldffTWLFi3ipZde4pVXXvEf06tXL1566SW6dOmC1+tl7NixJZYnvfzyyzRv3pzWrVszY8YMDh06dNx5vlNTU9m+fbv/zURRT39iYiKJiYls3ryZDz74gIsuuoiEhAR27tzJtGnTCAsL45JLLgHgq6++Yt++fXTu3JnIyEjWrl3LmDFj6Nq1K40aNQLguuuuY9KkSdx0001MnDiRgwcPMmbMGG6++eYTfoJjFwVyERGRcnZEuWvA3E9kZCTnnnsuM2bMYNOmTbjdbpKSkrjtttt4+OGH/ce98cYb3HLLLXTs2JGWLVsyffp0LrroIv/++Ph4fvjhB8aMGUPPnj1xOp20b9/eX5t8ww03kJeXx4wZM3jggQeoWbMmV155JWCVQ3z11Vf83//9HzfddBMHDhwgMTGRHj16ULt2bZxOJykpKQwbNox9+/ZRs2ZNBg0axMSJEwHwer3cfffd7Ny5k+joaPr168eMGTMAqFevHl999RVjxozhrLPOIi4ujltuueWoNxSl9eeff3L22WcX29a7d2++//573nrrLR5++GFeeOEFOnTowNNPP+2fOrK0+vbty5dffsnjjz/Ok08+SVBQEK1ateLWW2895m3i4+NZsmQJTz75JE899RRbtmzB4XDQvHlzrr76au6///7j3meHDh348MMPeeyxx5g0aRJ16tTh8ccfLzag85lnnuGmm26ie/fu1K1bl+eff57ly5cfda5p06Yxbdo0Vq5cSbNmzZgzZ06xTzmONGfOHG666Sb/9WuuuQaA8ePHM2HCBEJDQ/nll1947rnnOHToELVr16ZHjx7873//o1atWoD1hvGf//wnI0eOJD8/n6SkJAYNGsRDDz3kP29kZCTfffcd99xzD506dSI+Pp4hQ4Yc801OIDDMI4uEbJaRkUFMTAzp6enFVlwSEREJJHl5eWzZsoXGjRv7630rw0qdIqdq69atNG7cuMSVNqubkl4HipQl06qHXEREpJyEhVnh+IhxfadVcLDCuEhlp0AuIiJSjsLCFJBFpGwUyEVERESk1Bo1anTUtIhyajTtoYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiUsz8+fMxDIO0tDQA3nrrLWJjY21tU3mbMGFCsVUmb7zxRgYOHGhbe6R6UyAXERGphhYtWoTT6eTSSy+1uymlcuSbhPL2/PPP89Zbb52Wc4uciAK5iIhINTRr1izuuecefv75Z3bv3m13cyqMaZp4PJ6jtsfExFS5TwGk8lAgFxERqWaysrL44IMPuOuuu7j00kvLpWd4586dXHvttcTFxREREUGnTp1YvHixf//nn39Ohw4dCA0NpUmTJkycOLFYMDYMg9dff50rrriC8PBwmjdvzpw5cwDYunUrF1xwAQA1atTAMAxuvPFGAHw+H1OnTqVx48aEhYVx1lln8fHHH/vPW9Sz/vXXX9OxY0dCQkJYsGDBUe0/smTl/PPP59577+XBBx8kLi6OxMREJkyYUOw2aWlp3HrrrSQkJBAdHU2vXr1YtWqVf/+qVau44IILiIqKIjo6mo4dO7Js2bKTfo6l6lIgFxERKQdFPa92XMq6jPmHH35Iq1ataNmyJddffz1vvPHGKS2FnpWVRc+ePdm1axdz5sxh1apVPPjgg/h8PgB++eUXhg0bxn333ce6det47bXXeOutt3jiiSeKnWfixIkMGTKE33//nUsuuYShQ4eSmppKUlISn3zyCQDJycns2bOH559/HoCpU6fyr3/9i5kzZ7J27VpGjhzJ9ddfz08//VTs3A899BDTpk1j/fr1tGvXrlSP6+233yYiIoLFixczffp0Hn/8cb777jv//quuuor9+/fz9ddfs3z5cjp06EDv3r1JTU0FYOjQodSvX5+lS5eyfPlyHnroIYKCgk7uSZYqzWV3A0RERKoCr9fL7NmzbbnvQYMG4XKV/l/6rFmzuP766wHo168f6enp/PTTT5x//vkndf/vvfceBw4cYOnSpcTFxQHQrFkz//6JEyfy0EMPccMNNwDQpEkTJk2axIMPPsj48eP9x914441ce+21AEyZMoUXXniBJUuW0K9fP/95a9Wq5S8tyc/PZ8qUKXz//fd06dLFf+4FCxbw2muv0bNnT/+5H3/8cS688MIyPa527dr529e8eXNeeukl5s2bx4UXXsiCBQtYsmQJ+/fvJyQkBICnn36azz77jI8//pjbb7+d7du3M2bMGFq1auU/h0hJFMhFRESqkeTkZJYsWcKnn34KgMvl4uqrr2bWrFknHchXrlzJ2Wef7Q/NR1q1ahULFy4s1iPu9XrJy8sjJyeH8PBwgGI91xEREURHR7N///5j3u/GjRvJyck5KmgXFBRw9tlnF9vWqVOnMj+uI3vS69Sp42/PqlWryMrKIj4+vtgxubm5bNq0CYBRo0Zx66238u9//5s+ffpw1VVX0bRp0zK3Q6o+BXIREZFy4HQ6GTRokG33XVqzZs3C4/FQt25d/zbTNAkJCeGll14iJiamzPcfFhZ23P1ZWVlMnDixxOcnNDTU//2R5RyGYfjLXo51XoC5c+dSr169YvuKeq2LREREHLeNJTlee7KysqhTpw7z588/6nZFPfgTJkzguuuuY+7cuXz99deMHz+e999/nyuuuKLMbZGqTYFcRESkHBiGUaayETt4PB7+9a9/8cwzz3DRRRcV2zdw4ED+85//cOedd5b5vO3ateP1118nNTW1xF7yDh06kJycXKyMpayCg4MBq2e9SJs2bQgJCWH79u3FylMqQocOHdi7dy8ul4tGjRod87gWLVrQokULRo4cybXXXsubb76pQC5HCexXDhERESk3X375JYcOHeKWW245qid88ODBzJo166QC+bXXXsuUKVMYOHAgU6dOpU6dOqxYsYK6devSpUsXHnvsMS677DIaNGjAlVdeicPhYNWqVaxZs4bJkyeX6j4aNmyIYRh8+eWXXHLJJYSFhREVFcUDDzzAyJEj8fl8dOvWjfT0dBYuXEh0dLS/Zv106NOnD126dGHgwIFMnz6dFi1asHv3bubOncsVV1zBGWecwZgxY7jyyitp3LgxO3fuZOnSpQwePPi0tUkqL82yIiIiUk3MmjWLPn36lFiWMnjwYJYtW8bvv/9e5vMGBwfz3//+l1q1anHJJZfQtm1bpk2b5i+l6du3L19++SX//e9/6dy5M+eddx4zZsygYcOGpb6PevXq+QeH1q5dmxEjRgAwadIkHn30UaZOnUrr1q3p168fc+fOpXHjxmV+HGVhGAZfffUVPXr04KabbqJFixZcc801bNu2jdq1a+N0OklJSWHYsGG0aNGCIUOGcPHFFzNx4sTT2i6pnAzzVOY5Og0yMjKIiYkhPT2d6Ohou5sjIiJSory8PLZs2ULjxo2L1UGLSPVxvNeBsmRa9ZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIjIKQiwuRFEpAKV19+/ArmIiMhJKFrFMScnx+aWiIhdCgoKgLKtllsSLQwkIiJyEpxOJ7Gxsezfvx+A8PBwDMOwuVUiUlF8Ph8HDhwgPDz8lFfpVSAXERE5SYmJiQD+UC4i1YvD4aBBgwan/GZcgVxEROQkGYZBnTp1qFWrFm632+7miEgFCw4OxuE49QpwBXIREZFT5HQ6T7mGVESqLw3qFBERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNipTIJ8wYQKGYRS7tGrVyr8/Ly+Pu+++m/j4eCIjIxk8eDD79u0r90aLiIiIiFQVZe4hP+OMM9izZ4//smDBAv++kSNH8sUXX/DRRx/x008/sXv3bgYNGlSuDRYRERERqUpcZb6By0ViYuJR29PT05k1axbvvfcevXr1AuDNN9+kdevW/Prrr5x33nmn3loRERERkSqmzD3kGzZsoG7dujRp0oShQ4eyfft2AJYvX47b7aZPnz7+Y1u1akWDBg1YtGjRMc+Xn59PRkZGsYuIiIiISHVRpkB+7rnn8tZbb/HNN9/w6quvsmXLFrp3705mZiZ79+4lODiY2NjYYrepXbs2e/fuPeY5p06dSkxMjP+SlJR0Ug9ERERERKQyKlPJysUXX+z/vl27dpx77rk0bNiQDz/8kLCwsJNqwLhx4xg1apT/ekZGhkK5iIiIiFQbpzTtYWxsLC1atGDjxo0kJiZSUFBAWlpasWP27dtXYs15kZCQEKKjo4tdRERERESqi1MK5FlZWWzatIk6derQsWNHgoKCmDdvnn9/cnIy27dvp0uXLqfcUBERERGRqqhMJSsPPPAA/fv3p2HDhuzevZvx48fjdDq59tpriYmJ4ZZbbmHUqFHExcURHR3NPffcQ5cuXTTDioiIiIjIMZQpkO/cuZNrr72WlJQUEhIS6NatG7/++isJCQkAzJgxA4fDweDBg8nPz6dv37688sorp6XhIiIiIiJVgWGapml3Iw6XkZFBTEwM6enpqicXERERkUqpLJn2lGrIRURERETk1CiQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERspkIuIiIiI2EiBXERERETERgrkIiIiIiI2UiAXEREREbGRArmIiIiIiI0UyEVEREREbKRALiIiIiJiIwVyEREREREbKZCLiIiIiNhIgVxERERExEYK5CIiIiIiNlIgFxERERGxkQK5iIiIiIiNFMhFRERERGykQC4iIiIiYiMFchERERERGymQi4iIiIjYSIFcRERERMRGCuQiIiIiIjZSIBcRERERsZECuYiIiIiIjRTIRURERERspEAuIiIiImIjBXIRERERERu57G6AiEi1Z5rgc4OvoPBSwvemF0yPdd1buM8s3G96rXPgA9N32PcmGAbgAMMBhtO6bjit644gcISAI7jwEgSGy9rvCAbn4fsOP8aw+QkTEalaFMhFRE43bx54csCba33vzbO+d2eBJ9366ssHn6cwdBd+xSzhZIcFasOJFbaNv/ZR+L1x2Pemr3C/aV3Moq++wjBf+NU47DCj8KvDZV0MV2EYD4KgKAiKsb46w8AZan11hYEzApzB5f4UiohUZQrkIiLlwZMLnizwZP91KUiFgkN/BXFfQWHQNqxQ7HAV9lAXBl1XSGHwLQzAhs1VhaZptffwNwm+AsjdA9nbCh8LxR+LMwRcURBcA0JqWAHdFWGFd1cUOJz2PiYRkQCkQC4iUhbePHBnWOHbnQn5qZC/vzCM51o93SZWD7UjCByhVkgNjre+GpUokBqG9UaBIDhRs32FYd2bZ70Ryd1jXcewzuMKA0cYhNSE0FoQFF3Y0x4NrkiVwYhItaZALiJyLJ4ccKdDQZoVvPP2gjvNCt7e/MIKEWdh2UYYhMZYddbVMVwWlba4wo/eZ3r/KtvJ3gaZyYXlME5whluhPLyu9aYlONa6uCIq+AGIiNhHgVxEBKyBkgWHrN7d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QEGCHAAAChLkAABQkCAHAICCBDkAABQkyAEAoKAepSfwRlVVJUmam5sLzwQAALbNay37Wttuzk4X5C+99FKSZPjw4YVnAgAAb81LL72Ufv36bXZMTbU12b4DtbS0ZPHixdltt91SU1NTejp0Ac3NzRk+fHieeuqp9O3bt/R06OTsT3Q0+xQdzT61c6iqKi+99FKGDRuWbt02f5X4TneGvFu3btlrr71KT4MuqG/fvg5MdBj7Ex3NPkVHs0+Vt6Uz469xUycAABQkyAEAoCBBTpdXW1ubiy++OLW1taWnQhdgf6Kj2afoaPapzmenu6kTAADeTpwhBwCAggQ5AAAUJMgBAKAgQQ4AAAUJcrqESy65JDU1NW1ejY2NrctXr16dyZMnZ+DAgenTp0/Gjx+fZcuWFZwxncHTTz+dz372sxk4cGDq6upywAEH5N57721dXlVVLrroogwdOjR1dXU5+uij8+ijjxacMTuzd7zjHRsdp2pqajJ58uQkjlO0z/r163PhhRdmxIgRqauryz777JNvf/vbef2zOhyjOg9BTpfxnve8J0uWLGl9zZs3r3XZ1KlTM2vWrNx0002ZO3duFi9enHHjxhWcLTu7F198MYcddlh69uyZO+64IwsXLsyVV16ZAQMGtI654oor8v3vfz/XXHNN7r777uy6664ZM2ZMVq9eXXDm7KzuueeeNseoP//5z0mSE088MYnjFO1z+eWX5+qrr84Pf/jDPPTQQ7n88stzxRVX5Ac/+EHrGMeoTqSCLuDiiy+u3vve925y2fLly6uePXtWN910U+t7Dz30UJWkmj9//g6aIZ3NN77xjerwww9/0+UtLS1VfX19NWPGjNb3li9fXtXW1lY33njjjpgindyUKVOqffbZp2ppaXGcot2OO+646rTTTmvz3rhx46oJEyZUVeUY1dk4Q06X8eijj2bYsGEZOXJkJkyYkCeffDJJct9992XdunU5+uijW8c2NjamoaEh8+fPLzVddnK///3vc8ghh+TEE0/M4MGDc9BBB+WnP/1p6/InnngiS5cubbNf9evXL6NGjbJfsUVr167N9ddfn9NOOy01NTWOU7TbBz7wgdx555155JFHkiQPPPBA5s2bl2OOOSaJY1Rn06P0BKAjjBo1Ktddd1323XffLFmyJNOmTcsRRxyRBQsWZOnSpenVq1f69+/f5jNDhgzJ0qVLy0yYnd7jjz+eq6++OmeddVa++c1v5p577slXv/rV9OrVKxMnTmzdd4YMGdLmc/YrtsYtt9yS5cuX59RTT00Sxyna7dxzz01zc3MaGxvTvXv3rF+/PpdeemkmTJiQJI5RnYwgp0t47YxAkhx44IEZNWpU9t577/z6179OXV1dwZnRWbW0tOSQQw7JZZddliQ56KCDsmDBglxzzTWZOHFi4dnR2f3sZz/LMccck2HDhpWeCp3Ur3/969xwww2ZOXNm3vOe9+T+++/P1772tQwbNswxqhNyyQpdUv/+/fOud70rjz32WOrr67N27dosX768zZhly5alvr6+zATZ6Q0dOjTvfve727y33377tV4K9dq+88anYNiv2JL//ve/+ctf/pIzzjij9T3HKdrrnHPOybnnnptPf/rTOeCAA3LKKadk6tSpmT59ehLHqM5GkNMlrVy5MosWLcrQoUNz8MEHp2fPnrnzzjtblzc1NeXJJ5/M6NGjC86Sndlhhx2WpqamNu898sgj2XvvvZMkI0aMSH19fZv9qrm5OXfffbf9is36+c9/nsGDB+e4445rfc9xivZ65ZVX0q1b24zr3r17WlpakjhGdTql7yqFjnD22WdXc+bMqZ544onqrrvuqo4++uhq0KBB1TPPPFNVVVVNmjSpamhoqGbPnl3de++91ejRo6vRo0cXnjU7s3/84x9Vjx49qksvvbR69NFHqxtuuKHq3bt3df3117eO+e53v1v179+/uvXWW6sHH3ywOv7446sRI0ZUq1atKjhzdmbr16+vGhoaqm984xsbLXOcoj0mTpxY7bnnntVtt91WPfHEE9Xvfve7atCgQdXXv/711jGOUZ2HIKdLOOmkk6qhQ4dWvXr1qvbcc8/qpJNOqh577LHW5atWraq+/OUvVwMGDKh69+5djR07tlqyZEnBGdMZzJo1q9p///2r2traqrGxsfrJT37SZnlLS0t14YUXVkOGDKlqa2urD33oQ1VTU1Oh2dIZ/PGPf6ySbHI/cZyiPZqbm6spU6ZUDQ0N1S677FKNHDmyOv/886s1a9a0jnGM6jxqqup1X+kEAADsUK4hBwCAggQ5AAAUJMgBAKAgQQ4AAAUJcgAAKEiQAwBAQYIcAAAKEuQAABRz++23Z9SoUamrq8uAAQNywgknbPEzDz30UD7xiU+kX79+2XXXXXPooYfmySef3GhcVVU55phjUlNTk1tuuWWT63r++eez1157paamJsuXL2/X3I866qjU1NS0eU2aNKld60gEOQAA29FRRx2V6667bpPLfvvb3+aUU07J5z//+TzwwAO56667cvLJJ292fYsWLcrhhx+exsbGzJkzJw8++GAuvPDC7LLLLhuNveqqq1JTU7PZ9Z1++uk58MADt/r3eaMvfOELWbJkSevriiuuaPc6emzz1gEAYBu9+uqrmTJlSmbMmJHTTz+99f13v/vdm/3c+eefn2OPPbZN+O6zzz4bjbv//vtz5ZVX5t57783QoUM3ua6rr746y5cvz0UXXZQ77rhjo+W33nprpk2bloULF2bYsGGZOHFizj///PTo8f8Tunfv3qmvr9/i77s5zpADALDD/fOf/8zTTz+dbt265aCDDsrQoUNzzDHHZMGCBW/6mZaWltx+++1517velTFjxmTw4MEZNWrURpejvPLKKzn55JPzox/96E1jeeHChfnWt76VX/ziF+nWbeMk/tvf/pbPfe5zmTJlShYuXJhrr7021113XS699NI242644YYMGjQo+++/f84777y88sor7f5bCHIAAHa4xx9/PElyySWX5IILLshtt92WAQMG5KijjsoLL7ywyc8888wzWblyZb773e/mox/9aP70pz9l7NixGTduXObOnds6burUqfnABz6Q448/fpPrWbNmTT7zmc9kxowZaWho2OSYadOm5dxzz83EiRMzcuTIfPjDH863v/3tXHvtta1jTj755Fx//fX561//mvPOOy+//OUv89nPfrbdf4uaqqqqdn8KAAA24bLLLstll13W+vOqVavSs2fPNpd5LFy4MPPmzcuECRNy7bXX5otf/GKSDaG811575Tvf+U6+9KUvbbTuxYsXZ88998xnPvOZzJw5s/X9T3ziE9l1111z44035ve//33OPvvs/Otf/0qfPn2SJDU1Nbn55ptbbxg966yzsnjx4vzqV79KksyZMycf/OAH8+KLL6Z///5Jkj322CMrV65M9+7dW7ezfv36rF69Oi+//HJ69+690fxmz56dD33oQ3nsscc2eRnNm3ENOQAAHWbSpEn51Kc+1frzhAkTMn78+IwbN671vWHDhrVe1/36a8Zra2szcuTITT4xJUkGDRqUHj16bHSd+X777Zd58+Yl2RDFixYtag3r14wfPz5HHHFE5syZk9mzZ+ff//53fvOb3yTZ8DSW19Z//vnnZ9q0aVm5cmWmTZvWZt6v2dQNpEkyatSoJBHkAACUs/vuu2f33Xdv/bmuri6DBw/OO9/5zjbjDj744NTW1qapqSmHH354kmTdunX5z3/+k7333nuT6+7Vq1cOPfTQNDU1tXn/kUceaf3MueeemzPOOKPN8gMOOCDf+9738vGPfzzJhqe7rFq1qnX5Pffck9NOOy1/+9vfWkP6fe97X5qamjaa9+bcf//9SfKmN5G+GUEOAMAO17dv30yaNCkXX3xxhg8fnr333jszZsxIkpx44omt4xobGzN9+vSMHTs2SXLOOefkpJNOypFHHpkPfvCD+cMf/pBZs2Zlzpw5SZL6+vpN3sjZ0NCQESNGJNn4qSzPPfdckg1n2l87s37RRRflYx/7WBoaGvLJT34y3bp1ywMPPJAFCxbkO9/5ThYtWpSZM2fm2GOPzcCBA/Pggw9m6tSpOfLII9v9GEVBDgBAETNmzEiPHj1yyimnZNWqVRk1alRmz56dAQMGtI5pamrKihUrWn8eO3ZsrrnmmkyfPj1f/epXs+++++a3v/1t61n2jjJmzJjcdttt+da3vpXLL788PXv2TGNjY+vZ9169euUvf/lLrrrqqrz88ssZPnx4xo8fnwsuuKDd23JTJwAAFOSxhwAAUJAgBwCAggQ5AAAUJMgBAKAgQQ4AAAUJcgAAKEiQAwBAQYIcAAAKEuQAAFCQIAcAgIIEOQAAFCTIAQCgoP8Hnrue3Z+PCTEAAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "if len(map_object_dict[MapLayer.LANE_GROUP]) > 0:\n", " lane_group: LaneGroup = np.random.choice(map_object_dict[MapLayer.LANE_GROUP])\n", @@ -594,21 +485,10 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "25", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABH4AAAJBCAYAAAAwSXJbAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAiUxJREFUeJzs3XeYU9Xa/vF7p2cynWmUoSNNEQVFVBAFRSyIvQuIclR8RUUUjkpRERXrUV85dl/l/Oy9ggIW5CgWQBRQUNCDNKUMMDB1//6Yk0iYQmZIZmcn3891zQWT7EnW7ATy5M6z1jJM0zQFAAAAAACAhOOwegAAAAAAAACIDYIfAAAAAACABEXwAwAAAAAAkKAIfgAAAAAAABIUwQ8AAAAAAECCIvgBAAAAAABIUAQ/AAAAAAAACYrgBwAAAAAAIEER/AAAAAAAACQogh8AttSvXz/169fP6mEAAADE1IIFC3T44YcrEAjIMAwtXLjQ6iHZ2rBhw9S6dWurhwE0KoIfIM48/fTTMgxDX331Vb1/tri4WJMmTdLcuXOjPzAL/PDDD5o0aZJWrVpl9VDCTJkyRYMHD1Z+fr4Mw9CkSZNqPO7VV1/V2WefrbZt2yolJUUdO3bUmDFjtGXLlmrHXnPNNTr44IOVnZ2tlJQUde7cWZMmTdL27dtj+8sAAGxtX+oGq5WUlOjBBx/UkUceqaysLHk8HjVr1kyDBw/W//t//08VFRVWD9FyZWVlOvPMM7Vp0ybdd999evbZZ9WqVasaj507d64Mw9DLL7/coPu6/fbb9frrr+/DaOPH77//rkmTJsVdSPbII4/ozDPPVMuWLWUYhoYNG1bjcR999JEuvvhi7bfffkpJSVHbtm11ySWXaO3atdWOvf3223XYYYcpNzdXPp9PHTp00NVXX62NGzfG+LeBnbisHgCA6CkuLtbkyZMlKSG6YX744QdNnjxZ/fr1q/bJzMyZM60ZlKSbbrpJBQUFOuigg/TBBx/UetzIkSPVrFkzXXDBBWrZsqW+++47PfTQQ3r33Xf1zTffyO/3h45dsGCB+vTpo+HDh8vn8+nbb7/VHXfcoQ8//FCffPKJHA5yegBA4ti4caMGDRqkr7/+WgMHDtRNN92k7OxsrVu3Th9++KHOO+88rVixQjfffLPVQ7XUypUrtXr1aj322GO65JJLYnpft99+u8444wwNGTIkpvfTGH7//XdNnjxZrVu3Vvfu3cOue+yxx1RZWWnJuO68805t27ZNhx56aI0hTtANN9ygTZs26cwzz1SHDh30888/66GHHtLbb7+thQsXqqCgIHTs119/re7du+ucc85RWlqali5dqscee0zvvPOOFi5cqEAg0Bi/GuIcwQ+AvdqxY0fcvWh4PB7L7vuXX35R69at9ccffyg3N7fW415++eVqAVyPHj00dOhQzZgxI6yA++yzz6r9fLt27XTdddfpyy+/1GGHHRa18QMAYLULL7xQ3377rV555RWddtppYdeNHz9eX331lZYvX17nbezatUsejyehPxzZsGGDJCkzM9PagTRQPD5Gbrfbsvv++OOPQ90+qamptR5377336sgjjww7b8cff7yOOuooPfTQQ7rttttCl7/yyivVfr53794644wz9NZbb+mcc86J7i8BW4qff4EAajVs2DClpqZqzZo1GjJkiFJTU5Wbm6vrrrsu1Aa9atWqUAgxefJkGYZRbRrSsmXLdMYZZyg7O1s+n089e/bUm2++GXZfwZbxjz/+WFdccYXy8vLUokULSdK2bdt09dVXq3Xr1vJ6vcrLy9Oxxx6rb775Juw2vvjiCx1//PHKyMhQSkqKjjrqKM2bN6/a77VmzRqNGDFCzZo1k9frVZs2bXT55ZertLRUTz/9tM4880xJ0tFHHx36fYLT2Gpa42fDhg0aMWKE8vPz5fP5dOCBB+qZZ54JO2bVqlUyDEN33323Hn30UbVr105er1eHHHKIFixYENHjEem88Jq6rk499VRJ0tKlSyO+n5qmhgEAEKnS0lJNmDBBPXr0UEZGhgKBgPr06aM5c+aEHVff18hI6oqazJ8/Xx988IFGjhxZLfQJ6tmzp84///zQ98FpTM8//7xuuukmNW/eXCkpKSoqKpIkvfTSS+rRo4f8fr9ycnJ0wQUXaM2aNWG3Wdv6gHuu+bL7ebjvvvvUqlUr+f1+HXXUUVqyZEnYz65bt07Dhw9XixYt5PV61bRpU51yyikRTVOfPXu2+vTpo0AgoMzMTJ1yyilh9cGwYcN01FFHSZLOPPNMGYZR747uSZMmyTAMrVixQsOGDVNmZqYyMjI0fPhwFRcXh44zDEM7duzQM888E6q5dp+GtGbNGl188cXKz8+X1+tV165d9eSTT4bdV12PUVlZmSZPnqwOHTrI5/OpSZMmOvLIIzVr1qyw24j0ObVlyxZdc801oZq0RYsWuuiii/THH39o7ty5OuSQQyRJw4cPD/0+Tz/9dOi87lnL7dixQ2PGjFFhYaG8Xq86duyou+++W6Zphh1nGIauvPJKvf7669p///1D5+L999+P6PFo1aqVDMPY63F9+/atFpb17dtX2dnZ1JBoEDp+AJuoqKjQwIED1atXL91999368MMPdc8996hdu3a6/PLLlZubq0ceeUSXX365Tj311FAh1a1bN0nS999/ryOOOELNmzfXuHHjFAgE9OKLL2rIkCF65ZVXQoFE0BVXXKHc3FxNmDBBO3bskCRddtllevnll3XllVeqS5cu+vPPP/XZZ59p6dKlOvjggyVVFTGDBg1Sjx49NHHiRDkcDj311FM65phj9Omnn+rQQw+VVNWCe+ihh2rLli0aOXKkOnXqpDVr1ujll19WcXGx+vbtq6uuukr/+Mc/9Pe//12dO3eWpNCfe9q5c6f69eunFStW6Morr1SbNm300ksvadiwYdqyZYtGjx4ddvy//vUvbdu2TX/7299kGIbuuusunXbaafr5559j+knQunXrJEk5OTnVrisvL9eWLVtUWlqqJUuW6KabblJaWlronAEA0BBFRUV6/PHHde655+rSSy/Vtm3b9MQTT2jgwIH68ssvq02FieQ1sr51xe7eeustSdIFF1xQ79/l1ltvlcfj0XXXXaeSkhJ5PB49/fTTGj58uA455BBNnTpV69ev1wMPPKB58+bp22+/bXC3zP/93/9p27ZtGjVqlHbt2qUHHnhAxxxzjL777jvl5+dLkk4//XR9//33+p//+R+1bt1aGzZs0KxZs/Trr7/W+UHRhx9+qEGDBqlt27aaNGmSdu7cqQcffFBHHHGEvvnmG7Vu3Vp/+9vf1Lx5c91+++266qqrdMghh4Tut77OOusstWnTRlOnTtU333yjxx9/XHl5ebrzzjslSc8++6wuueQSHXrooRo5cqSkqs5jSVq/fr0OO+ywUOiRm5ur9957TyNGjFBRUZGuvvrqsPuq6TGaNGmSpk6dGrqPoqIiffXVV/rmm2907LHHSor8ObV9+3b16dNHS5cu1cUXX6yDDz5Yf/zxh95880395z//UefOnXXLLbdowoQJGjlypPr06SNJOvzww2s8N6ZpavDgwZozZ45GjBih7t2764MPPtDYsWO1Zs0a3XfffWHHf/bZZ3r11Vd1xRVXKC0tTf/4xz90+umn69dff1WTJk0a9PhEYvv27dq+fXuNNaRpmvrzzz9VXl6un376SePGjZPT6UyIpR8QJSaAuPLUU0+ZkswFCxaELhs6dKgpybzlllvCjj3ooIPMHj16hL7fuHGjKcmcOHFitdvt37+/ecABB5i7du0KXVZZWWkefvjhZocOHard/5FHHmmWl5eH3UZGRoY5atSoWsdeWVlpdujQwRw4cKBZWVkZury4uNhs06aNeeyxx4Yuu+iii0yHwxH2e+5+O6Zpmi+99JIpyZwzZ061Y4466ijzqKOOCn1///33m5LM5557LnRZaWmp2bt3bzM1NdUsKioyTdM0f/nlF1OS2aRJE3PTpk2hY9944w1TkvnWW2/V+vvtqa7zXZsRI0aYTqfT/PHHH6tdN3/+fFNS6Ktjx441/u4AAATVVDfsqby83CwpKQm7bPPmzWZ+fr558cUXhy6rz2tkpHVFTU499VRTkrlly5awy3fu3Glu3Lgx9LV58+bQdXPmzDElmW3btjWLi4tDl5eWlpp5eXnm/vvvb+7cuTN0+dtvv21KMidMmBC6bM/aIWjo0KFmq1atqp0Hv99v/uc//wld/sUXX5iSzGuuucY0zapzKMmcNm1anb9vTbp3727m5eWZf/75Z+iyRYsWmQ6Hw7zooouq/d4vvfTSXm+zpmMnTpxoSgp7nE2z6jFo0qRJ2GWBQMAcOnRotdsdMWKE2bRpU/OPP/4Iu/ycc84xMzIyQo9HbY+RaZrmgQceaJ544ol1jj/S59SECRNMSearr75a7TaCNeSCBQtMSeZTTz1V7Zg9H+/XX3/dlGTedtttYcedccYZpmEY5ooVK0KXSTI9Hk/YZYsWLTIlmQ8++GCdv9+eajvftbn11ltNSeZHH31U7bq1a9eG1ZAtWrQwX3jhhXqNB4mNqV6AjVx22WVh3/fp00c///zzXn9u06ZNmj17ts466yxt27ZNf/zxh/744w/9+eefGjhwoH766adq7dCXXnqpnE5n2GWZmZn64osv9Pvvv9d4PwsXLtRPP/2k8847T3/++Wfofnbs2KH+/fvrk08+UWVlpSorK/X666/r5JNPVs+ePavdTiQtsHt69913VVBQoHPPPTd0mdvt1lVXXaXt27fr448/Djv+7LPPVlZWVuj74KdBkZzPhvrXv/6lJ554QmPGjFGHDh2qXd+lSxfNmjVLr7/+uq6//noFAgF29QIA7DOn0xlaG6+yslKbNm1SeXm5evbsWW26trT318iG1BW7C07P2nONk+nTpys3Nzf0deSRR1b72aFDh4ZtjvDVV19pw4YNuuKKK+Tz+UKXn3jiierUqZPeeeedvZ6f2gwZMkTNmzcPfX/ooYeqV69eevfddyVJfr9fHo9Hc+fO1ebNmyO+3bVr12rhwoUaNmyYsrOzQ5d369ZNxx57bOj2o6mmGvLPP/8MPRa1MU1Tr7zyik4++WSZphl6rP/44w8NHDhQW7durfYc2vMxkqpqyO+//14//fRTjfdTn+fUK6+8ogMPPLDGrrKG1pBOp1NXXXVV2OVjxoyRaZp67733wi4fMGBAqBtKqnrc0tPTY1pDfvLJJ5o8ebLOOussHXPMMdWuz87O1qxZs/TWW2/plltuUU5ODjUkwiRl8DN48GC1bNlSPp9PTZs21YUXXljrG9mglStX6tRTT1Vubq7S09N11llnaf369WHHBFsVMzMz1aRJE40cObLaP7gFCxaof//+yszMVFZWlgYOHKhFixbV+3dYunSpBg8eHJqnfcghh+jXX3+t9+3APnw+X7WFhLOysiIqNFasWCHTNHXzzTeHFVS5ubmaOHGipL8WDwxq06ZNtdu56667tGTJEhUWFurQQw/VpEmTwl7kgi/mQ4cOrXY/jz/+uEpKSrR161Zt3LhRRUVF2n///et9HmqzevVqdejQodp86ODUsNWrV4dd3rJly7DvgwVufQq3+vj00081YsQIDRw4UFOmTKnxmPT0dA0YMECnnHKK7rzzTo0ZM0annHJKg/6PAIBoo36yt2eeeUbdunULra+Sm5urd955R1u3bq127N5eIxtSV+wuLS1Nkqo9zqeffrpmzZqlWbNmhaaq72nP+iT4+t6xY8dqx3bq1Kna63991PQhzX777Rdav8fr9erOO+/Ue++9p/z8fPXt21d33XVXaFp3beoac+fOnUMfmkVTQ+uejRs3asuWLXr00UerPdbDhw+XFFkNecstt2jLli3ab7/9dMABB2js2LFavHhx6Pr6PKdWrlwZ9RqyWbNmoedlUKQ1pBR5Td4Qy5Yt06mnnqr9999fjz/+eI3HeDweDRgwQCeddJJuvvlmPfzwwxoxYoTefvvtmIwJ9pOwwU+/fv1CC3jt6eijj9aLL76o5cuX65VXXtHKlSt1xhln1HpbO3bs0HHHHSfDMDR79mzNmzdPpaWlOvnkk0NbAf7+++8aMGCA2rdvry+++ELvv/++vv/++7BF0bZv367jjz9eLVu21BdffKHPPvtMaWlpGjhwoMrKyiL+3VauXKkjjzxSnTp10ty5c7V48WLdfPPNYZ9yIPHs2X1TH8Hn6XXXXRcqqPb8at++fdjP7PlJjVQ1P/znn3/Wgw8+qGbNmmnatGnq2rVr6JOQ4P1Mmzat1vupaweDxlTb+TT3WMQvGhYtWqTBgwdr//3318svvyyXK7Ll1YLrND3//PNRHxMA1IT6KTE999xzGjZsmNq1a6cnnnhC77//vmbNmqVjjjmmxm2t9/Ya2ZC6YnedOnWSpGoLJRcWFmrAgAEaMGBAWMfR7mqqTyJVWzdIcKOMhrj66qv1448/aurUqfL5fLr55pvVuXNnffvttw2+zVhoaN0TfKwvuOCCWh/rI444IuxnanqM+vbtq5UrV+rJJ58MBRgHH3xwKMjY1+dUY2rMGvK3337Tcccdp4yMDL377rvVwqnaHH744WratKlmzJgR9THBnpJycedrrrkm9PdWrVpp3LhxGjJkiMrKympc1HXevHlatWqVvv32W6Wnp0uq+tQkKytLs2fP1oABA/T222/L7Xbr4YcfDnUcTJ8+Xd26ddOKFSvUvn17LVu2TJs2bdItt9yiwsJCSdLEiRPVrVs3rV69OvSf2WeffRbaxjInJ0ennnqqpk6dGtpO+8Ybb9QJJ5ygu+66KzTG3dsNkbxqK2jatm0rqWrq04ABA/bpPpo2baorrrhCV1xxhTZs2KCDDz5YU6ZM0aBBg0LPw2DnSm2Cn/zuWfDtqT7tuq1atdLixYtVWVkZ1vWzbNmy0PVWWLlypY4//njl5eXp3XffrVfwVVJSosrKyho/jQWAxkb9ZF8vv/yy2rZtq1dffTXstTXYSVFf+1pXnHTSSbrjjjs0Y8aMaqFBfQVf35cvX15tCszy5cvDXv+zsrJqnI5TW1dQTdOSfvzxx2qLNrdr105jxozRmDFj9NNPP6l79+6655579Nxzz+11zHtatmyZcnJyQs/bxlRT3ZWbm6u0tDRVVFTscw2ZnZ2t4cOHa/jw4dq+fbv69u2rSZMm6ZJLLqnXc6pdu3ZRryE//PBDbdu2LSxYsbqG/PPPP3XccceppKREH330kZo2bVqvn9+1axc1JEIStuMnUps2bdKMGTN0+OGH17qTT0lJiQzDkNfrDV3m8/nkcDj02WefhY7xeDxhbziDaXfwmI4dO6pJkyZ64oknVFpaqp07d+qJJ55Q586dQy8gwTeJp59+uhYvXqwXXnhBn332ma688kpJVWn4O++8o/32208DBw5UXl6eevXqpddffz3apwY2lJKSIqn61o15eXnq16+f/vnPf2rt2rXVfm7jxo17ve2KiopqLx55eXlq1qyZSkpKJEk9evRQu3btdPfdd9c4rzh4Pw6HQ0OGDNFbb72lr776qtpxwU9MgkVPJFtRnnDCCVq3bp1eeOGF0GXl5eV68MEHlZqaGtoOtTGtW7dOxx13nBwOhz744INqU/WCtmzZUuOn1sFPwWpaBwkArET9ZC/BDoXdOxK++OILzZ8/v0G3t691xRFHHKFjjz1Wjz76qN54440aj4m0e6Jnz57Ky8vT9OnTQ/WIJL333ntaunSpTjzxxNBl7dq107Jly8LGt2jRIs2bN6/G23799dfD1ir68ssv9cUXX2jQoEGSpOLiYu3atSvsZ9q1a6e0tLSwseypadOm6t69u5555pmwGmfJkiWaOXOmTjjhhIh+92gLBALVai6n06nTTz9dr7zySo1hSyQ1pFQVYuwuNTVV7du3D52n+jynTj/9dC1atEivvfZateMaWkNWVFTooYceCrv8vvvuk2EYoce7Me3YsUMnnHCC1qxZo3fffbfGaYfB44qLi6td/sorr2jz5s3UkAhJyo4fSbrhhhv00EMPqbi4WIcddlid8x8PO+wwBQIB3XDDDbr99ttlmqbGjRunioqK0H9MxxxzjK699lpNmzZNo0eP1o4dOzRu3DhJCh2TlpamuXPnasiQIbr11lslVc0d/uCDD0JTP6ZOnarzzz8/tC1ihw4d9I9//ENHHXWUHnnkEW3ZskXbt2/XHXfcodtuu0133nmn3n//fZ122mmaM2eOJW9uET/8fr+6dOmiF154Qfvtt5+ys7O1//77a//999fDDz+sI488UgcccIAuvfRStW3bVuvXr9f8+fP1n//8Z69rJWzbtk0tWrTQGWecoQMPPFCpqan68MMPtWDBAt1zzz2SqgKdxx9/XIMGDVLXrl01fPhwNW/eXGvWrNGcOXOUnp4e2sL19ttv18yZM3XUUUdp5MiR6ty5s9auXauXXnpJn332mTIzM9W9e3c5nU7deeed2rp1q7xer4455hjl5eVVG9/IkSP1z3/+U8OGDdPXX3+t1q1b6+WXX9a8efN0//33R9waG4lnn31Wq1evDr3QfvLJJ7rtttskSRdeeGHok6Hjjz9eP//8s66//np99tlnoTcxkpSfnx/avnTu3Lm66qqrdMYZZ6hDhw4qLS3Vp59+qldffVU9e/Zs0Ha3ABAL1E/x68knn9T7779f7fLRo0frpJNO0quvvqpTTz1VJ554on755RdNnz5dXbp0afACsPtaVzz33HM6/vjjNWTIEA0aNCg0vWvdunX68MMP9cknn0T0htvtduvOO+/U8OHDddRRR+ncc88NbefeunXrsE61iy++WPfee68GDhyoESNGaMOGDZo+fbq6du1a4yLH7du315FHHqnLL79cJSUluv/++9WkSRNdf/31kqq6f/r376+zzjpLXbp0kcvl0muvvab169frnHPOqXPc06ZN06BBg9S7d2+NGDEitJ17RkaGJk2atNffOxZ69OihDz/8UPfee6+aNWumNm3aqFevXrrjjjs0Z84c9erVS5deeqm6dOmiTZs26ZtvvtGHH36oTZs27fW2u3Tpon79+qlHjx7Kzs7WV199pZdffjkUzkqRP6fGjh2rl19+WWeeeaYuvvhi9ejRQ5s2bdKbb76p6dOn68ADD1S7du2UmZmp6dOnKy0tTYFAQL169apx/aGTTz5ZRx99tG688UatWrVKBx54oGbOnKk33nhDV199dVQ7A996663Q71FWVqbFixeHasjBgweH1rY6//zz9eWXX+riiy/W0qVLtXTp0tBtpKamasiQIZKqutIGDBigs88+W506dZLD4dBXX32l5557Tq1bt9bo0aOjNnbYXONvJBYbU6ZMMQOBQOjL4XCYXq837LLVq1eHjt+4caO5fPlyc+bMmeYRRxxhnnDCCWHbT+/pgw8+MNu2bWsahmE6nU7zggsuMA8++GDzsssuCx0zY8YMMz8/33Q6nabH4zGvu+46Mz8/37zjjjtM06za0vrQQw81L7roIvPLL78058+fb55++ulm165dQ1se9uzZ0/R4PGHjTklJMSWZP/zwg7lmzRpTknnuueeGje/kk082zznnnGieUliktu3cA4FAtWODW3Tu7vPPPzd79OhhejyealuNr1y50rzooovMgoIC0+12m82bNzdPOukk8+WXX67z/k3TNEtKSsyxY8eaBx54oJmWlmYGAgHzwAMPNP/3f/+32ri+/fZb87TTTjObNGlier1es1WrVuZZZ51VbfvJ1atXmxdddJGZm5trer1es23btuaoUaPCtpx97LHHzLZt25pOpzNsa/eatmRdv369OXz4cDMnJ8f0eDzmAQccUG0bz+AWrTVtvbrn+arNUUcdFbZl5u5fu2+/XtsxksLGvmLFCvOiiy4y27Zta/r9ftPn85ldu3Y1J06caG7fvn2v4wGAhqJ+sn/9FHzdru3rt99+MysrK83bb7/dbNWqlen1es2DDjrIfPvtt2vdxjzS18hI6oq67Ny507z//vvN3r17m+np6abL5TILCgrMk046yZwxY4ZZXl4eOnZv25q/8MIL5kEHHWR6vV4zOzvbPP/888O2Yg967rnnzLZt25oej8fs3r27+cEHH9R5Hu655x6zsLDQ9Hq9Zp8+fcxFixaFjvvjjz/MUaNGmZ06dTIDgYCZkZFh9urVy3zxxRcj+v0//PBD84gjjjD9fr+Znp5unnzyyeYPP/wQdky0tnPfuHFj2LHB580vv/wSumzZsmVm3759Tb/fb0oK22p8/fr15qhRo8zCwkLT7XabBQUFZv/+/c1HH300orHedttt5qGHHmpmZmaafr/f7NSpkzllyhSztLQ07LhIn1N//vmneeWVV5rNmzc3PR6P2aJFC3Po0KFhW86/8cYbZpcuXUyXyxW2tfuej7dpmua2bdvMa665xmzWrJnpdrvNDh06mNOmTav2/5skc9SoUdV+v1atWkW0NfvQoUNr/be6e83aqlWrWo/bfewbN240R44cGXoOejwes0OHDubVV19d7TFHcjNMMwarUFlg06ZNYWnz+eefr9NPPz20OKoktW7dusZFVf/zn/+osLBQn3/+uXr37l3n/fzxxx9yuVzKzMxUQUGBxowZo7Fjx4Yds379egUCARmGofT0dD3//PM688wz9cQTT+jvf/+71q5dG2ppLi0tVVZWlp544gmdc8456ty5s4499thq2wlKf60gHwgENHHiRN10002h62644QZ99tlntbaqAgAA7In6ifoJ1a1atUpt2rTRtGnTdN1111k9HADYZwkz1Ss7O1vZ2dmh7/1+v/Ly8iJa/T24inxdc3GDcnJyJEmzZ8/Whg0bNHjw4GrH5OfnS6pqufX5fKHpHMXFxXI4HGGLjQW/D47h4IMP1g8//FDnuA855JBqi8H9+OOPli08BgAA7In6ifoJAJD4km5x5y+++EIPPfSQFi5cqNWrV2v27Nk699xz1a5du9CnVWvWrFGnTp305Zdfhn7uqaee0r///W+tXLlSzz33nM4880xdc8016tixY+iYhx56SN98841+/PFHPfzww7ryyis1depUZWZmSpKOPfZYbd68WaNGjdLSpUv1/fffa/jw4XK5XDr66KMlVX3y9Pnnn+vKK6/UwoUL9dNPP+mNN94Im/86duxYvfDCC3rssce0YsUKPfTQQ3rrrbd0xRVXNMIZBAAAyYb6CQAAG7N6rlmsHHXUUdXW9jBN01y8eLF59NFHm9nZ2abX6zVbt25tXnbZZWFzgIPzendfq+OGG24w8/PzQ3M+77nnnmpzPi+88EIzOzvb9Hg8Zrdu3cz/+7//q3b/wTnxGRkZZlZWlnnMMceY8+fPDzvmyy+/NI899lgzNTXVDAQCZrdu3cwpU6aEHfPEE0+Y7du3N30+n3nggQear7/+egPOEgAAwF+on4C61zoCADtKmDV+AAAAAAAAEC7ppnoBAAAAAAAkC4IfAAAAAACABGX7Xb0qKyv1+++/Ky0tLWy3BwAAEF9M09S2bdvUrFmz0LbcsAb1EwAA9hCN+sn2wc/vv/+uwsJCq4cBAAAi9Ntvv6lFixZWDyOpUT8BAGAv+1I/2T74SUtLk1R1EtLT0y0eDQAAqE1RUZEKCwtDr92wDvUTAAD2EI36yfbBT7A9OT09ncIFAAAbYGqR9aifAACwl32pn5hgDwAAAAAAkKAIfgAAAAAAABIUwQ8AAAAAAECCsv0aPwCA+FFRUaGysjKrhwGLuN1uOZ1Oq4cBAIDtVFZWqrS01OphwAKNUT8R/AAA9plpmlq3bp22bNli9VBgsczMTBUUFLCAMwAAESotLdUvv/yiyspKq4cCi8S6fiL4AQDss2Dok5eXp5SUFN70JyHTNFVcXKwNGzZIkpo2bWrxiAAAiH+maWrt2rVyOp0qLCyUw8FqLMmkseongh8AwD6pqKgIhT5NmjSxejiwkN/vlyRt2LBBeXl5TPsCAGAvysvLVVxcrGbNmiklJcXq4cACjVE/EScCAPZJcE0fihVIfz0PWOsJAIC9q6iokCR5PB6LRwIrxbp+IvgBAEQF07sg8TwAAKAheP1MbrF+/Al+AAAAAAAAEhRr/AAAYmLnTqkxdyX1eKT/TpFGLSZNmqTXX39dCxcutHooAACgDmVlZaFpYI3B6XTK7XY32v2hcRH8AACibudO6Y03pM2bG+8+s7KkU06pX/gzbNgwbdmyRa+//npExxuGoddee01Dhgxp0BgbU01jve666/Q///M/1g0KAADsVVlZmZYvX66dO3c22n36/X517Ngx4vCnvjVUY1q3bp2mTp2qd955R//5z3+UkZGh9u3b64ILLtDQoUOTcl1Kgh8AQNSVllaFPn6/5PPF/v527aq6v9JSe3T9lJWVWfKpWmpqqlJTUxv9fgEAQOQqKiq0c+dOuVwuuVyxf8teXl6unTt3qqKiwvZdPz///LOOOOIIZWZm6vbbb9cBBxwgr9er7777To8++qiaN2+uwYMH1/izVtVnjYE1fgAAMePzSYFA7L+iES7169dPV111la6//nplZ2eroKBAkyZNCl3funVrSdKpp54qwzBC30vSG2+8oYMPPlg+n09t27bV5MmTVV5eHrreMAw98sgjGjx4sAKBgKZMmaLNmzfr/PPPV25urvx+vzp06KCnnnoq9DO//fabzjrrLGVmZio7O1unnHKKVq1aFTbmJ598Ul27dpXX61XTpk115ZVX1jnWSZMmqXv37qGfr6ys1C233KIWLVrI6/Wqe/fuev/990PXr1q1SoZh6NVXX9XRRx+tlJQUHXjggZo/f37DTzQAAIiIy+WSx+OJ+VcswqV7771XBxxwgAKBgAoLC3XFFVdo+/btoeuffvppZWZm6oMPPlDnzp2Vmpqq448/XmvXrg27nccff1ydO3eWz+dTp06d9L//+7913u8VV1whl8ulr776SmeddZY6d+6stm3b6pRTTtE777yjk08+OXRsTfWZJD3yyCNq166dPB6POnbsqGeffTb0M8HaaPdp81u2bJFhGJo7d64kae7cuTIMQ++88466desmn8+nww47TEuWLGno6dxncRH8PPzww2rdurV8Pp969eqlL7/80uohAQCS0DPPPKNAIKAvvvhCd911l2655RbNmjVLkrRgwQJJ0lNPPaW1a9eGvv/000910UUXafTo0frhhx/0z3/+U08//XSoeAiaNGmSTj31VH333Xe6+OKLdfPNN+uHH37Qe++9p6VLl+qRRx5RTk6OpKpPnAYOHKi0tDR9+umnmjdvXqggKv3vwkmPPPKIRo0apZEjR+q7777Tm2++qfbt29c51j098MADuueee3T33Xdr8eLFGjhwoAYPHqyffvop7Lgbb7xR1113nRYuXKj99ttP5557bliwBWtQPwEA4pXD4dA//vEPff/993rmmWc0e/ZsXX/99WHHFBcX6+6779azzz6rTz75RL/++quuu+660PUzZszQhAkTNGXKFC1dulS33367br75Zj3zzDM13ueff/6pmTNnatSoUQoEAjUes+fuWXvWZ6+99ppGjx6tMWPGaMmSJfrb3/6m4cOHa86cOfU+B2PHjtU999yjBQsWKDc3VyeffHLMtmvfG8uner3wwgu69tprNX36dPXq1Uv333+/Bg4cqOXLlysvL8/q4QEAkki3bt00ceJESVKHDh300EMP6aOPPtKxxx6r3NxcSVJmZqYKCgpCPzN58mSNGzdOQ4cOlSS1bdtWt956q66//vrQbUnSeeedp+HDh4e+//XXX3XQQQepZ8+ekhTWQfTCCy+osrJSjz/+eKhAeeqpp5SZmam5c+fquOOO02233aYxY8Zo9OjRoZ875JBDJKnWse7p7rvv1g033KBzzjlHknTnnXdqzpw5uv/++/Xwww+Hjrvuuut04oknhn7frl27asWKFerUqVNE5xXRR/0EAIhnV199dejvrVu31m233abLLrssrGOnrKxM06dPV7t27SRJV155pW655ZbQ9RMnTtQ999yj0047TZLUpk2b0IdswbprdytWrJBpmurYsWPY5Tk5Odq1a5ckadSoUbrzzjtD1+1Zn5177rkaNmyYrrjiCknStddeq3//+9+6++67dfTRR9frHEycOFHHHnuspKoPF1u0aKHXXntNZ511Vr1uJxos7/i59957demll2r48OHq0qWLpk+frpSUFD355JNWDw0AkGS6desW9n3Tpk21YcOGOn9m0aJFuuWWW0Lr56SmpurSSy/V2rVrVVxcHDouGPAEXX755Xr++efVvXt3XX/99fr888/DbnPFihVKS0sL3WZ2drZ27dqllStXasOGDfr999/Vv3//Bv+uRUVF+v3333XEEUeEXX7EEUdo6dKlYZftfl6aNm0qSXs9L4gt6icAQDz78MMP1b9/fzVv3lxpaWm68MIL9eeff4bVRikpKaHQRwqvu3bs2KGVK1dqxIgRYTXWbbfdppUrV9ZrLF9++aUWLlyorl27qqSkJOy6PeuzpUuXRlQbRaJ3796hv2dnZ6tjx44Nup1osLTjp7S0VF9//bXGjx8fuszhcGjAgAG1rh9QUlIS9mAVFRXFfJwAgOSw54J+hmGosrKyzp/Zvn27Jk+eHPo0ane+3RYf2rPleNCgQVq9erXeffddzZo1S/3799eoUaN09913a/v27erRo4dmzJhR7TZzc3PlcDTu5za7n5dgB9Lezgtih/oJABDPVq1apZNOOkmXX365pkyZouzsbH322WcaMWKESktLQ7tq1VR3maYpSaH1gB577DH16tUr7Din01nj/bZv316GYWj58uVhl7dt21ZS1c5le6ptSlhtgjVYcJySLJu+VR+WBj9//PGHKioqlJ+fH3Z5fn6+li1bVuPPTJ06VZMnT26M4QFhNm6U4ul9zh7TU+t9fX2P3e3/tgYf19Dr6nNcNO4jKBrnuD6PQ6z5/VJGhtWjsDe3262Kioqwyw4++GAtX748tL5OfeTm5mro0KEaOnSo+vTpo7Fjx+ruu+/WwQcfrBdeeEF5eXlKT0+v8Wdbt26tjz76qNa245rGurv09HQ1a9ZM8+bN01FHHRW6fN68eTr00EPr/bug8VA/wS5M09SOHTusHkaYPdf3iPVtmfUtPuLwNurzs9E4v9F8jKIhuAAyIvf111+rsrJS99xzTygoefHFF+t1G/n5+WrWrJl+/vlnnX/++RH9TJMmTXTsscfqoYce0v/8z//UO9SRpM6dO2vevHlhU8nmzZunLl26SPprOv3atWt10EEHSVLYQs+7+/e//62WLVtKkjZv3qwff/xRnTt3rveYosHyNX7qa/z48br22mtD3xcVFamwsNDCESEZ/Oc/0rvvVm0ZHc/2Naio6fpYBzkN/fko1EAxEck5tmrsOTnSaac17nbnjfVvprHuJxi2HHHEEfJ6vcrKytKECRN00kknqWXLljrjjDPkcDi0aNEiLVmyRLfddluttzVhwgT16NEj1Hb89ttvh4qB888/X9OmTdMpp5wS2nVr9erVevXVV3X99derRYsWmjRpki677DLl5eVp0KBB2rZtm+bNm6f/+Z//qXWsexo7dqwmTpyodu3aqXv37nrqqae0cOHCGjuNYG/UT7DCxo0btWrVKss7BPc1SIhmENGQACYawQ8aLjMz07I17RprI4WG3s/WrVurhR5NmjRR+/btVVZWpgcffFAnn3yy5s2bp+nTp9f79idPnqyrrrpKGRkZOv7441VSUqKvvvpKmzdvDntN293//u//6ogjjlDPnj01adIkdevWTQ6HQwsWLNCyZcvUo0ePOu9z7NixOuuss3TQQQdpwIABeuutt/Tqq6/qww8/lFTVNXTYYYfpjjvuUJs2bbRhwwbddNNNNd7WLbfcoiZNmig/P1833nijcnJyNGTIkHqfh2iwNPjJycmR0+nU+vXrwy5fv359rYtRer1eeb3exhgeEFJaKhUVSf/tErRMtLpiIvn5htY40exE2pefjeWHRfU9v/FQr23fXvVVRwNIVHk8UlaWtHmztHNn49xnVlbV/cbSPffco2uvvVaPPfaYmjdvrlWrVmngwIF6++23dcstt+jOO++U2+1Wp06ddMkll9R5Wx6PR+PHj9eqVavk9/vVp08fPf/885Kq5rx/8sknuuGGG3Taaadp27Ztat68ufr37x/qABo6dKh27dql++67T9ddd51ycnJ0xhln1DnWPV111VXaunWrxowZow0bNqhLly5688031aFDh+idNEQd9RPsoqKiQuXl5crMzLR6KBGxKmCJdYdLvHXQRCoeAq/i4uLQbpqNyel0yu/3a+fOnY0W/vj9/lqnUNVm7ty5oa6XoBEjRujxxx/XvffeqzvvvFPjx49X3759NXXqVF100UX1uv1LLrlEKSkpmjZtmsaOHatAIKADDjggbOHoPbVr107ffvutbr/9do0fP17/+c9/5PV61aVLF1133XWhRZtrM2TIED3wwAO6++67NXr0aLVp00ZPPfWU+vXrFzrmySef1IgRI9SjRw917NhRd911l4477rhqt3XHHXdo9OjR+umnn9S9e3e99dZblnWPGabF/6J69eqlQw89VA8++KCkqjUDWrZsqSuvvFLjxo3b688XFRUpIyNDW7durbUdHthXP/8svf66xHsh2NW2bVVf558vpaZG97Z37dqlX375RW3atAlb02bnzqrQtLF4PI3bzYSa1fZ8kHjNjibqJ9jBunXr9PPPP6tJkyZWDwVokO3bt8vtdlfb/CGaanvdLCsrq3PK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30Aca9lSSpL1CffZI4/crXfffVUzZrxXbZHDoL59B2j79m1auvS7Rh5dfHE46PgBACSutLQ0ORwO1mOpwQMPPKC33npLr776arV66ZVXXtEdd9yhN998U3l5eaHLmzVrFuqWHjx4sNLS0vTTTz816rjjwe7BD2BHBD9AnMrLk7xeiW7luk2ffq9ee+3/6YUXZoWt6VNWVha2w9e3336pP//coFat2lowyvhhGKzxAwBIXIFAQF6vl+lee3jwwQf18ssv64033lBmZmbYda+++qpuvfVWvfnmmyosLAy7bs2aNaG/f/nll9q0aZPatk3eWorgB3bFVC8gTuXkVE3z2rKlatpXshs79m/66KN3tGHDOp177kClpqbplVfmavLkMWrVqq3OOONoSZLH49W7736hsrIyjR49VEVFW+VyuZSSEtBjj72szMzknjsXXOOHugUAkIicTqcyMzO1bt06BQIBq4fT6K666ip98MEHWr9+vYYMGaK0tDS9++67+vvf/642bdqE1vbxer2aM2eOJGnEiBHKz8/XOeecE7qdt956S02aNNFll12mDRs2yOl0yufz6dlnn1VGRoYlv5uVDMOotjMsYCcEP0CccrmkNm2kL74g+JGkadP+WePla9fWnGCkpKTozTfnxXJItkTHDwAg0aWnp2vt2rVJuR7LP/7xjxov37ZtW60/s3nz5lqve+utt/Z5TImAqV6wO6Z6AXGsadOqN+lMU0e0sJ07ACDRpaamyuv1qoTFEhtFMoUhyfS7IrEQ/ABxLC+valv3Oj6kSTgul1slJSWN9sK6a9dOOZ3uRrmveMB27gCAROf1epWamprQ6/y43VW1S3FxscUjUeg8B8eUiOj4gd0R/ABxLDW1qusnmXYlbdZsPxUXl+iHHxbH/L6qdvlaqubN94v5fcULtnMHACSDzMxMlZeXWz2MmMnKylJaWpoWLFhg9VC0YMECpaWlKTs72+qhxAzBD+yONX6AONeqlbRsmdWjaDyFhZ2Vnd1eDzxwp44//iS1aNFKTqczqvdRWVmpTZv+0Ecfva/SUo/23/+oqN5+PKPjBwCQDFJTU+V2u1VWVpaQnSiGYeiYY47RG2+8oeLiYh144IHyer2NOobS0lItWrRIn3zyiU4++eSkWE+J4Ad2RfADxLm8PMnnq9rW3eezejSxZxiGzj33Fs2a9bheeukN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giL4sZDX69XEiRPl9XqtHkrC4hzHHuc4tji/scc5ji3OL6KN51TscY5ji/Mbe5zj2OMcx1a0z6/tF3cGAAAAAABAzej4AQAAAAAASFAEPwAAAAAAAAmK4AcAAAAAACBBEfwAAAAAAAAkKIKfRvDII4+oW7duSk9PV3p6unr37q333nsvdP2uXbs0atQoNWnSRKmpqTr99NO1fv16C0dsb3fccYcMw9DVV18duoxzvG8mTZokwzDCvjp16hS6nvO779asWaMLLrhATZo0kd/v1wEHHKCvvvoqdL1pmpowYYKaNm0qv9+vAQMG6KeffrJwxPbSunXras9hwzA0atQoSTyHo6GiokI333yz2rRpI7/fr3bt2unWW2/V7ntI8DxGfVA/NS7qp+ijfoo96qfYon6KrUatnUzE3Jtvvmm+88475o8//mguX77c/Pvf/2663W5zyZIlpmma5mWXXWYWFhaaH330kfnVV1+Zhx12mHn44YdbPGp7+vLLL83WrVub3bp1M0ePHh26nHO8byZOnGh27drVXLt2behr48aNoes5v/tm06ZNZqtWrcxhw4aZX3zxhfnzzz+bH3zwgblixYrQMXfccYeZkZFhvv766+aiRYvMwYMHm23atDF37txp4cjtY8OGDWHP31mzZpmSzDlz5pimyXM4GqZMmWI2adLEfPvtt81ffvnFfOmll8zU1FTzgQceCB3D8xj1Qf3UeKifYoP6Kbaon2KP+im2GrN2IvixSFZWlvn444+bW7ZsMd1ut/nSSy+Frlu6dKkpyZw/f76FI7Sfbdu2mR06dDBnzZplHnXUUaHChXO87yZOnGgeeOCBNV7H+d13N9xwg3nkkUfWen1lZaVZUFBgTps2LXTZli1bTK/Xa/6///f/GmOICWf06NFmu3btzMrKSp7DUXLiiSeaF198cdhlp512mnn++eebpsnzGNFB/RR91E+xQ/0UW9RPjY/6Kboas3Ziqlcjq6io0PPPP68dO3aod+/e+vrrr1VWVqYBAwaEjunUqZNatmyp+fPnWzhS+xk1apROPPHEsHMpiXMcJT/99JOaNWumtm3b6vzzz9evv/4qifMbDW+++aZ69uypM888U3l5eTrooIP02GOPha7/5ZdftG7durBznJGRoV69enGOG6C0tFTPPfecLr74YhmGwXM4Sg4//HB99NFH+vHHHyVJixYt0meffaZBgwZJ4nmMfUP9FDvUT7FF/RQ71E+Ni/op+hqzdnJFb9ioy3fffafevXtr165dSk1N1WuvvaYuXbpo4cKF8ng8yszMDDs+Pz9f69ats2awNvT888/rm2++0YIFC6pdt27dOs7xPurVq5eefvppdezYUWvXrtXkyZPVp08fLVmyhPMbBT///LMeeeQRXXvttfr73/+uBQsW6KqrrpLH49HQoUND5zE/Pz/s5zjHDfP6669ry5YtGjZsmCT+j4iWcePGqaioSJ06dZLT6VRFRYWmTJmi888/X5J4HqNBqJ9ii/optqifYov6qXFRP0VfY9ZOBD+NpGPHjlq4cKG2bt2ql19+WUOHDtXHH39s9bASwm+//abRo0dr1qxZ8vl8Vg8nIQVTZ0nq1q2bevXqpVatWunFF1+U3++3cGSJobKyUj179tTtt98uSTrooIO0ZMkSTZ8+XUOHDrV4dInniSee0KBBg9SsWTOrh5JQXnzxRc2YMUP/+te/1LVrVy1cuFBXX321mjVrxvMYDUb9FDvUT7FH/RRb1E+Ni/op+hqzdmKqVyPxeDxq3769evTooalTp+rAAw/UAw88oIKCApWWlmrLli1hx69fv14FBQXWDNZmvv76a23YsEEHH3ywXC6XXC6XPv74Y/3jH/+Qy+VSfn4+5zjKMjMztd9++2nFihU8h6OgadOm6tKlS9hlnTt3DrWDB8/jnrskcI7rb/Xq1frwww91ySWXhC7jORwdY8eO1bhx43TOOefogAMO0IUXXqhrrrlGU6dOlcTzGA1D/RQ71E+Nj/opuqifGg/1U2w0Zu1E8GORyspKlZSUqEePHnK73froo49C1y1fvly//vqrevfubeEI7aN///767rvvtHDhwtBXz549df7554f+zjmOru3bt2vlypVq2rQpz+EoOOKII7R8+fKwy3788Ue1atVKktSmTRsVFBSEneOioiJ98cUXnON6euqpp5SXl6cTTzwxdBnP4egoLi6WwxFeVjidTlVWVkrieYzooH6KHuqnxkf9FF3UT42H+ik2GrV22ve1qLE348aNMz/++GPzl19+MRcvXmyOGzfONAzDnDlzpmmaVdvgtWzZ0pw9e7b51Vdfmb179zZ79+5t8ajtbfddKUyTc7yvxowZY86dO9f85ZdfzHnz5pkDBgwwc3JyzA0bNpimyfndV19++aXpcrnMKVOmmD/99JM5Y8YMMyUlxXzuuedCx9xxxx1mZmam+cYbb5iLFy82TznlFLYjraeKigqzZcuW5g033FDtOp7D+27o0KFm8+bNQ1uSvvrqq2ZOTo55/fXXh47heYz6oH5qfNRP0UX9FFvUT42D+il2GrN2IvhpBBdffLHZqlUr0+PxmLm5uWb//v1DRYtpmubOnTvNK664wszKyjJTUlLMU0891Vy7dq2FI7a/PQsXzvG+Ofvss82mTZuaHo/HbN68uXn22WebK1asCF3P+d13b731lrn//vubXq/X7NSpk/noo4+GXV9ZWWnefPPNZn5+vun1es3+/fuby5cvt2i09vTBBx+Ykmo8bzyH911RUZE5evRos2XLlqbP5zPbtm1r3njjjWZJSUnoGJ7HqA/qp8ZH/RRd1E+xR/0Ue9RPsdOYtZNhmqa5jx1KAAAAAAAAiEOs8QMAAAAAAJCgCH4AAAAAAAASFMEPAAAAAABAgiL4AQAAAAAASFAEPwAAAAAAAAmK4AcAAAAAACBBEfwAAAAAAAAkKIIfAADizDvvvKNevXrJ7/crKytLQ4YM2evPLF26VIMHD1ZGRoYCgYAOOeQQ/frrr9WOM01TgwYNkmEYev3112u8rT///FMtWrSQYRjasmVLvcber18/GYYR9nXZZZfV6zYAAADqi/qpdgQ/AAA0sn79+unpp5+u8bpXXnlFF154oYYPH65FixZp3rx5Ou+88+q8vZUrV+rII49Up06dNHfuXC1evFg333yzfD5ftWPvv/9+GYZR5+2NGDFC3bp1i/j32dOll16qtWvXhr7uuuuuBt8WAACARP20L1xRuyUAALBPysvLNXr0aE2bNk0jRowIXd6lS5c6f+7GG2/UCSecEFYgtGvXrtpxCxcu1D333KOvvvpKTZs2rfG2HnnkEW3ZskUTJkzQe++9V+36N954Q5MnT9YPP/ygZs2aaejQobrxxhvlcv1VUqSkpKigoGCvvy8AAMC+on7aOzp+AACIE998843WrFkjh8Ohgw46SE2bNtWgQYO0ZMmSWn+msrJS77zzjvbbbz8NHDhQeXl56tWrV7U25OLiYp133nl6+OGHay0qfvjhB91yyy36v//7Pzkc1UuETz/9VBdddJFGjx6tH374Qf/85z/19NNPa8qUKWHHzZgxQzk5Odp///01fvx4FRcX1/9kAAAARID6ae8IfgAAiBM///yzJGnSpEm66aab9PbbbysrK0v9+vXTpk2bavyZDRs2aPv27brjjjt0/PHHa+bMmTr11FN12mmn6eOPPw4dd8011+jwww/XKaecUuPtlJSU6Nxzz9W0adPUsmXLGo+ZPHmyxo0bp6FDh6pt27Y69thjdeutt+qf//xn6JjzzjtPzz33nObMmaPx48fr2Wef1QUXXNDQUwIAAFAn6qcImAAAIKamTJliBgKB0JfD4TC9Xm/YZatXrzZnzJhhSjL/+c9/hn52165dZk5Ojjl9+vQab3vNmjWmJPPcc88Nu/zkk082zznnHNM0TfONN94w27dvb27bti10vSTztddeC31/zTXXmGeffXbo+zlz5piSzM2bN4cuy8nJMX0+X9i4fT6fKcncsWNHjeP76KOPTEnmihUrIj5fAAAA1E/Rq59Y4wcAgBi77LLLdNZZZ4W+P//883X66afrtNNOC13WrFmz0Lzx3eeke71etW3btsYdJiQpJydHLper2jz2zp0767PPPpMkzZ49WytXrlRmZmbYMaeffrr69OmjuXPnavbs2fruu+/08ssvS6ravSJ4+zfeeKMmT56s7du3a/LkyWHjDqppIURJ6tWrlyRpxYoVNc6bBwAAqAn1U/TqJ4IfAABiLDs7W9nZ2aHv/X6/8vLy1L59+7DjevToIa/Xq+XLl+vII4+UJJWVlWnVqlVq1apVjbft8Xh0yCGHaPny5WGX//jjj6GfGTdunC655JKw6w844ADdd999OvnkkyVV7Yaxc+fO0PULFizQxRdfrE8//TRUcBx88MFavnx5tXHXZeHChZJU62KIAAAANaF+il79RPADAECcSE9P12WXXaaJEyeqsLBQrVq10rRp0yRJZ555Zui4Tp06aerUqTr11FMlSWPHjtXZZ5+tvn376uijj9b777+vt956S3PnzpUkFRQU1LggYcuWLdWmTRtJ1Xex+OOPPyRVffIV/KRrwoQJOumkk9SyZUudccYZcjgcWrRokZYsWaLbbrtNK1eu1L/+9S+dcMIJatKkiRYvXqxrrrlGffv23aftTQEAAGpD/bR3BD8AAMSRadOmyeVy6cILL9TOnTvVq1cvzZ49W1lZWaFjli9frq1bt4a+P/XUUzV9+nRNnTpVV111lTp27KhXXnkl9KlXtAwcOPD/t3eHNgzDQBhGryDZIjz7ZJ0gM7MMY+i1glvUqqio6Nd7Exy0vtPJNcao1lr13mtZltr3/bMNW9e15px1XVfd913bttVxHHWe51/nAAD45v302+P5PkIDAAAAIIrv3AEAAABCCT8AAAAAoYQfAAAAgFDCDwAAAEAo4QcAAAAglPADAAAAEEr4AQAAAAgl/AAAAACEEn4AAAAAQgk/AAAAAKGEHwAAAIBQwg8AAABAqBflDIf01kEZwQAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "if len(map_object_dict[MapLayer.INTERSECTION]) > 0:\n", " intersection: Intersection = np.random.choice(map_object_dict[MapLayer.INTERSECTION])\n", @@ -649,21 +529,10 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "27", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAqAAAAKiCAYAAAAJ9G7OAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAX9tJREFUeJzt3Xl8VOXd///3mZlkspGEAElYAgQQERfUiohYIQoF6xe1ohWrd23duoB31dbt/lVt7UKrX2tvLWrrjVK/dQOtu9J6A0EFRBBxAwERWYSEJWTfZ67fH4GBIYEsczLnnOT1fDymMGfOnPOZOTymbz/Xuc6xjDFGAAAAQJz4nC4AAAAA3QsBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHHVrQLoBRdcoIEDByopKUl9+/bVf/zHf2jHjh1Hfc+mTZv0ne98R3369FF6erq++93vqri4OGqd1atXa9KkScrMzFSvXr10/fXXq7KyMmqdlStX6txzz1VmZqZ69uypyZMn66OPPmr3Z1i3bp0uuOACZWRkKDU1VaNHj9bWrVvbvR0AAACndLkAOmHCBM2dO7fF1woKCjRv3jytX79eL7zwgjZt2qRLLrnkiNuqqqrSt771LVmWpUWLFmnp0qWqr6/X1KlTFQ6HJUk7duzQxIkTNWzYMK1YsUILFizQZ599ph/84AeR7VRWVmrKlCkaOHCgVqxYoXfffVc9evTQ5MmT1dDQ0ObPtmnTJp111lkaMWKECgsL9fHHH+vOO+9UUlJSm7cBAADgONPFjB8/3jzxxBNtWvfll182lmWZ+vr6Fl//17/+ZXw+nykrK4ssKy0tNZZlmbfeessYY8xf//pXk52dbUKhUGSdjz/+2EgyGzduNMYYs3LlSiPJbN269YjrGGPMO++8Y8466yyTlJRkBgwYYG644QZTWVkZef2yyy4zV155ZZs+GwAAgFt1uQ5oW5WUlOipp57SmWeeqYSEhBbXqaurk2VZCgaDkWVJSUny+Xx69913I+skJibK5zv4VSYnJ0tSZJ1jjz1WvXr10pw5c1RfX6+amhrNmTNHxx13nAYPHiypqbs5ZcoUTZs2TR9//LGee+45vfvuu5o5c6YkKRwO6/XXX9fw4cM1efJkZWdna8yYMXrppZfs/moAAAA6VbcLoLfddptSU1PVq1cvbd26VS+//PIR1z3jjDOUmpqq2267TdXV1aqqqtIvfvELhUIh7dy5U5J0zjnnqKioSPfdd5/q6+u1b98+3X777ZIUWadHjx4qLCzUP/7xDyUnJystLU0LFizQm2++qUAgIEmaNWuWrrjiCt1444065phjdOaZZ+rBBx/Uk08+qdraWu3atUuVlZX6wx/+oClTpujf//63vvOd7+jiiy/WkiVLOvlbAwAAsI/nA+jvf/97paWlRR7vvPOOfvzjH0ctO3SSzi233KIPP/xQ//73v+X3+/X9739fxpgWt92nTx/Nnz9fr776qtLS0pSRkaHS0lKdeuqpkY7n8ccfr7///e+6//77lZKSotzcXOXn5ysnJyeyTk1Nja655hqNGzdO7733npYuXaoTTjhB559/vmpqaiRJH330kebOnRtV9+TJkxUOh7V58+bIOacXXnihbrrpJp188sm6/fbb9X/+z//Ro48+2plfMQAAgK0CThcQqx//+Mf67ne/G3l+xRVXaNq0abr44osjy/r16xf5e+/evdW7d28NHz5cxx13nPLy8vTee+9p7NixLW7/W9/6ljZt2qQ9e/YoEAgoMzNTubm5GjJkSGSd733ve/re976n4uJipaamyrIs/elPf4qs8/TTT+urr77S8uXLI6H06aefVs+ePfXyyy9r+vTpqqys1I9+9CP953/+Z7MaBg4cKEkKBAIaOXJk1GvHHXdcZKgfAADACzwfQLOyspSVlRV5npycrOzsbA0bNqzV9x7oKtbV1bW6bu/evSVJixYt0q5du3TBBRc0WycnJ0eS9PjjjyspKUmTJk2SJFVXV8vn88myrMi6B54fqOHUU0/V2rVrj1r36NGjtX79+qhlGzZs0KBBg1qtHwAAwC08PwTfVitWrNBf/vIXrVmzRlu2bNGiRYt0+eWXa+jQoZHu59dff60RI0bo/fffj7zviSee0HvvvadNmzbpH//4hy699FLddNNNOvbYYyPr/OUvf9Hq1au1YcMGzZ49WzNnztSsWbOUmZkpSZo0aZL27dunGTNmaN26dfrss8/0wx/+UIFAQAUFBZKazk1dtmyZZs6cqTVr1mjjxo16+eWXI5OQpKbTB5577jk99thj+uKLL/SXv/xFr776qn7605/G4RsEAACwidPT8O12pMswffzxx6agoMBkZWWZYDBoBg8ebH784x+b7du3R9bZvHmzkWQWL14cWXbbbbeZnJwck5CQYI455hhz//33m3A4HLXt//iP/zBZWVkmMTHRnHTSSebJJ59stv9///vfZty4cSYjI8P07NnTnHPOOWb58uVR67z//vtm0qRJJi0tzaSmppqTTjrJ/O53v4taZ86cOWbYsGEmKSnJjBo1yrz00ksd+JYAAACcYxlzhBk4AAAAQCfoNkPwAAAAcAdPTkIKh8PasWOHevToETWxBwAAAM4wxqiiokL9+vWLukFPSzwZQHfs2KG8vDynywAAAMBhtm3bpgEDBhx1HU8G0B49ekhq+oDp6ekOVwMAAIDy8nLl5eVFctrReDKAHhh2T09PJ4ACAAC4SFtOj2QSEgAAAOKKAAoAAIC4IoACAAAgrjx5DigAAOhcoVBIDQ0NTpcBF0lISJDf77dlWwRQAAAQYYxRUVGRSktLnS4FLpSZmanc3NyYr8NOAAUAABEHwmd2drZSUlK44QskNf2HSXV1tXbt2iVJ6tu3b0zbI4ACAABJTcPuB8Jnr169nC4HLpOcnCxJ2rVrl7Kzs2MajmcSEgAAkKTIOZ8pKSkOVwK3OvBvI9bzgwmgAAAgCsPuOBK7/m0QQAEAABBXnAMKAABaVV9Tp8aGxrjsK5AQUGJyMC77gjMIoAAA4Kjqa+r0+dJPZMImLvuzfJZGjDuRENqFMQQPAACOqrGhMW7hU5JM2HSo21pUVKQbbrhBQ4YMUTAYVF5enqZOnaqFCxdKkgYPHizLsmRZllJSUnTiiSfqf/7nf5ptJxQK6YEHHtCJJ56opKQk9ezZU+edd56WLl3abL0//OEPGjFihJKTk5WVlaUxY8ZEbXP37t36yU9+ooEDByoYDCo3N1eTJ0+ObGv69OmaMmVK1HYXLFggy7L0q1/9Kmr5r371Kw0cODBq2axZs+T3+3Xfffc1+xxz585VZmbmEb+vH/zgB7rooouilj3//PNKSkrS/ffff8T32YEACgAAPO+rr77SN77xDS1atEj33XefPvnkEy1YsEAFBQWaMWNGZL177rlHO3fu1Keffqorr7xS1113nd58883I68YYTZ8+Xffcc49+9rOfad26dSosLFReXp4mTJigl156KbLur3/9az3wwAP6zW9+o7Vr12rx4sW6/vrroy7iP23aNH344Yf6+9//rg0bNuiVV17RhAkTtHfvXklSQUGBli5dqsbGg4F78eLFysvLU2FhYdRnXLx4sQoKCqKWPf7447r11lv1+OOPx/wd/s///I+uuOIKPfLII/r5z38e8/aOhiF4AADgeT/96U9lWZbef/99paamRpYff/zxuvrqqyPPe/ToodzcXEnSbbfdpnvvvVdvvfWWzjvvPEnSvHnz9Pzzz+uVV17R1KlTI+/729/+pr179+raa6/VpEmTlJqaqldeeUU//elPdemll0bWGzVqVOTvpaWleuedd1RYWKjx48dLkgYNGqTTTz89sk5BQYEqKyu1atUqnXHGGZKkwsJC3X777fr5z3+u2tpaJSUlqba2VitWrNAPf/jDyHuXLFmimpoa3XPPPXryySe1bNkynXnmmR36/u69917dfffdevbZZ/Wd73ynQ9toDzqgAADA00pKSrRgwQLNmDEjKnwe0NIwdDgc1gsvvKB9+/YpMTExsvzpp5/W8OHDo8LnAT//+c+1d+9evfXWW5Kk3NxcLVq0SLt3726xrrS0NKWlpemll15SXV1di+sMHz5c/fr10+LFiyVJFRUVWr16tS699FINHjxYy5cvlyQtW7ZMdXV1UR3QOXPm6PLLL1dCQoIuv/xyzZkz5wjf0NHddttt+s1vfqPXXnstLuFTIoACAACP++KLL2SM0YgRI1pd97bbblNaWpqCwaAuueQS9ezZU9dee23k9Q0bNui4445r8b0Hlm/YsEGS9Kc//Um7d+9Wbm6uTjrpJP34xz+OGs4PBAKaO3eu/v73vyszM1Pjxo3Tf/3Xf+njjz+O2m5BQUFkuP2dd97R8OHD1adPH5199tmR5YWFhcrPz9egQYMkSeXl5Xr++ed15ZVXSpKuvPJKzZs3T5WVlW34xg568803de+99+rll1/Wueee2673xoIACgAAPM2Ytk+QuuWWW7RmzRotWrRIY8aM0QMPPKBhw4Z1aHsjR47Up59+qvfee09XX321du3apalTp0YF2mnTpmnHjh165ZVXNGXKFBUWFurUU0/V3LlzI+tMmDBBS5cuVUNDgwoLCzVhwgRJ0vjx46MC6KHdz2eeeUZDhw6NDPmffPLJGjRokJ577rk2fxeSdNJJJ2nw4MG6++672x1eY0EABQAAnnbMMcfIsix9/vnnra7bu3dvDRs2TN/85jc1f/58/ed/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IK4IoAAAAIgrAigAAADiigAKAACAuCKAAgAAIK4IoAAAAIgrAigAAADiigAKAACAuCKAAgAAIK4IoAAAAIgrAigAAADiigAKAACAuAo4XUBHGGMkSeXl5Q5XAgAAAOlgLjuQ047GkwG0oqJCkpSXl+dwJQAAADhURUWFMjIyjrqOZdoSU10mHA5rx44d6tGjhyzLktSUuvPy8rRt2zalp6c7XCEOxbFxL46Ne3Fs3Itj404cF+cZY1RRUaF+/frJ5zv6WZ6e7ID6fD4NGDCgxdfS09P5h+dSHBv34ti4F8fGvTg27sRxcVZrnc8DmIQEAACAuCKAAgAAIK66TAANBoO6++67FQwGnS4Fh+HYuBfHxr04Nu7FsXEnjou3eHISEgAAALyry3RAAQAA4A0EUAAAAMQVARQAAABxRQAFAABAXBFAAQAAEFdxDaBff/21rrzySvXq1UvJyck68cQTtWrVqqh11q1bpwsuuEAZGRlKTU3V6NGjtXXr1mbbMsbovPPOk2VZeumll1rc3969ezVgwABZlqXS0tLI8n/+85+aNGmS+vTpo/T0dI0dO1b/+te/mr1/9uzZGjx4sJKSkjRmzBi9//77MX1+N3PLsTnU0qVLFQgEdPLJJzd7rbscGzcdl7q6Ov1//9//p0GDBikYDGrw4MF6/PHHo9aZP3++RowYoaSkJJ144ol64403Yvr8buamY/PUU09p1KhRSklJUd++fXX11Vdr7969UetwbOw/NpZlNXs8++yzUesUFhbq1FNPVTAY1LBhwzR37txm++guv2eSe44NOcB5cQug+/bt07hx45SQkKA333xTa9eu1f3336+ePXtG1tm0aZPOOussjRgxQoWFhfr444915513Kikpqdn2/vznP0fuA38k11xzjU466aRmy99++21NmjRJb7zxhj744AMVFBRo6tSp+vDDDyPrPPfcc7r55pt19913a/Xq1Ro1apQmT56sXbt2xfAtuJObjs0BpaWl+v73v69zzz232Wvd5di47bh897vf1cKFCzVnzhytX79ezzzzjI499tjI68uWLdPll1+ua665Rh9++KEuuugiXXTRRfr00087+A24l5uOzdKlS/X9739f11xzjT777DPNnz9f77//vq677rrIOhybzjs2TzzxhHbu3Bl5XHTRRZHXNm/erPPPP18FBQVas2aNbrzxRl177bVRQae7/J5J7jo25AAXMHFy2223mbPOOuuo61x22WXmyiuvbHVbH374oenfv7/ZuXOnkWRefPHFZus8/PDDZvz48WbhwoVGktm3b99Rtzly5Ejz61//OvL89NNPNzNmzIg8D4VCpl+/fmbWrFmt1uc1bjw2l112mfnlL39p7r77bjNq1Kio17rLsXHTcXnzzTdNRkaG2bt37xH38d3vftecf/75UcvGjBljfvSjH7Van9e46djcd999ZsiQIVHrP/jgg6Z///6R5xybaHYdmyMdrwNuvfVWc/zxxzfb9+TJkyPPu8vvmTHuOjYt6c45wAlx64C+8sorOu2003TppZcqOztbp5xyih577LHI6+FwWK+//rqGDx+uyZMnKzs7W2PGjGnWVq+urtb3vvc9zZ49W7m5uS3ua+3atbrnnnv05JNPyudr/SOGw2FVVFQoKytLklRfX68PPvhAEydOjKzj8/k0ceJELV++vAOf3t3cdmyeeOIJffnll7r77rubvdadjo2bjsuBWu699171799fw4cP1y9+8QvV1NRE1lm+fHnUcZGkyZMnd7njIrnr2IwdO1bbtm3TG2+8IWOMiouL9fzzz+vb3/52ZB2OTeccG0maMWOGevfurdNPP12PP/64zCH3dmnte+9Ov2eSu47N4bp7DnBEvJJuMBg0wWDQ3HHHHWb16tXmr3/9q0lKSjJz5841xpjIf8WkpKSYP/3pT+bDDz80s2bNMpZlmcLCwsh2rr/+enPNNddEnuuw/8qpra01J510kvl//+//GWOMWbx4casd0D/+8Y+mZ8+epri42BhjzNdff20kmWXLlkWtd8stt5jTTz891q/Cddx0bDZs2GCys7PN+vXrjTGmWQe0Ox0bNx2XyZMnm2AwaM4//3yzYsUK8/rrr5tBgwaZH/zgB5F1EhISzNNPPx31GWbPnm2ys7Pt/FpcwU3Hxhhj5s2bZ9LS0kwgEDCSzNSpU019fX3kdY6N/cfGGGPuuece8+6775rVq1ebP/zhDyYYDJr//u//jrx+zDHHmN///vdR73n99deNJFNdXd2tfs+McdexOVx3zwFOiFsATUhIMGPHjo1adsMNN5gzzjjDGHPwYF9++eVR60ydOtVMnz7dGGPMyy+/bIYNG2YqKioirx/+D++mm24yl112WeR5awH0qaeeMikpKeatt96KLOtu//DccmwaGxvNaaedZh555JHIOt05gLrluBhjzKRJk0xSUpIpLS2NLHvhhReMZVmmuro6Um93CTluOjafffaZ6du3r7n33nvNRx99ZBYsWGBOPPFEc/XVV0fVy7Gx99i05M477zQDBgyIPCeARnPTsTkUOcAZcRuC79u3r0aOHBm17LjjjovMbOvdu7cCgcBR11m0aJE2bdqkzMxMBQIBBQIBSdK0adM0YcKEyDrz58+PvH5gEkvv3r2bDek+++yzuvbaazVv3ryoNnvv3r3l9/tVXFwctX5xcfFR2/1e5ZZjU1FRoVWrVmnmzJmRde655x599NFHCgQCWrRoUbc6Nm45Lgdq6d+/vzIyMqL2Y4zR9u3bJUm5ubnd4rhI7jo2s2bN0rhx43TLLbfopJNO0uTJk/Xwww/r8ccf186dOyVxbDrj2LRkzJgx2r59u+rq6iQd+XtPT09XcnJyt/o9k9x1bA4gBzgnEK8djRs3TuvXr49atmHDBg0aNEiSlJiYqNGjRx91ndtvv13XXntt1OsnnniiHnjgAU2dOlWS9MILL0Sdl7Zy5UpdffXVeueddzR06NDI8meeeUZXX321nn32WZ1//vlR20xMTNQ3vvENLVy4MDJrLhwOa+HChZo5c2YM34I7ueXYpKen65NPPonaxsMPP6xFixbp+eefV35+frc6Nm45LgdqmT9/viorK5WWlhbZj8/n04ABAyQ1nYu4cOFC3XjjjZFtvfXWWxo7dmysX4XruOnYVFdXR/5P+AC/3y9JkXPeODb2H5uWrFmzRj179lQwGJTU9L0ffrmrQ7/37vR7Jrnr2EjkAMfFq9X6/vvvm0AgYH73u9+ZjRs3Rlre//jHPyLr/POf/zQJCQnmb3/7m9m4caN56KGHjN/vN++8884Rt6tWWu8tDVk99dRTJhAImNmzZ5udO3dGHocOLz777LMmGAyauXPnmrVr15rrr7/eZGZmmqKiopi+Bzdy07E5XEuz4LvLsXHTcamoqDADBgwwl1xyifnss8/MkiVLzDHHHGOuvfbayDpLly41gUDA/N//+3/NunXrzN13320SEhLMJ598EtP34EZuOjZPPPGECQQC5uGHHzabNm0y7777rjnttNOihgk5NvYfm1deecU89thj5pNPPjEbN240Dz/8sElJSTF33XVXZJ0vv/zSpKSkmFtuucWsW7fOzJ492/j9frNgwYLIOt3l98wYdx0bcoDz4hZAjTHm1VdfNSeccIIJBoNmxIgR5m9/+1uzdebMmWOGDRtmkpKSzKhRo8xLL7101G125Ad7/PjxRlKzx1VXXRX13oceesgMHDjQJCYmmtNPP92899577fm4nuKWY3O4lgKoMd3n2LjpuKxbt85MnDjRJCcnmwEDBpibb745cv7nAfPmzTPDhw83iYmJ5vjjjzevv/56mz+r17jp2Dz44INm5MiRJjk52fTt29dcccUVZvv27VHrcGyixXps3nzzTXPyySebtLQ0k5qaakaNGmUeffRREwqFot63ePFic/LJJ5vExEQzZMgQ88QTTzTbdnf5PTPGPceGHOA8y5ijXJcAAAAAsBn3ggcAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxBUBFAAAAHFFAAUAAEBcEUABAAAQVwRQAAAAxNX/D/ZNPOwbeLaBAAAAAElFTkSuQmCC", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "misc_map_surfaces: List[MapLayer] = [\n", " MapLayer.CROSSWALK,\n", @@ -725,20 +594,10 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "30", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "- RoadEdgeType.UNKNOWN\n", - "- RoadEdgeType.ROAD_EDGE_BOUNDARY\n", - "- RoadEdgeType.ROAD_EDGE_MEDIAN\n" - ] - } - ], + "outputs": [], "source": [ "for road_edege_type in RoadEdgeType:\n", " print(f\"- {road_edege_type}\")" @@ -754,21 +613,10 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "32", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvEAAALvCAYAAADs5JoKAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAp+5JREFUeJzs3Xd4HNXVx/HfzK66LLlbNtjG2AZM74SWGDCY3gyEEkISahJCgJAACYSEFAcIoYRiyEtCKKYldELvEDAdEnBwAYyxccNFlqy2O/P+Ie1oRl3eu7vS3e/neYRXu6PRlTTMnDl77rmO7/u+AAAAAPQbbq4HAAAAAKB3COIBAACAfoYgHgAAAOhnCOIBAACAfoYgHgAAAOhnCOIBAACAfoYgHgAAAOhnCOIBAACAfoYgHgAAAOhnCOIBAACAfiavgvhDDz1UY8aMUXFxsUaOHKkTTzxRixcv7vJr5s+fryOOOELDhg1TRUWFjjnmGC1dujSyzTvvvKN9991XAwcO1JAhQ3TaaaeppqYmss2bb76pffbZRwMHDtSgQYM0depUvf/++73+GWbPnq1DDz1UlZWVKisr00477aTPP/+81/sBAABA/2VdED958mTdeuutHb6211576d5779XHH3+sf/7zn5o/f76OOuqoTvdVW1ur/fbbT47j6LnnntOrr76qxsZGHXLIIfI8T5K0ePFiTZkyRRMmTNCsWbP0xBNP6MMPP9R3vvOdYD81NTXaf//9NWbMGM2aNUuvvPKKBgwYoKlTp6qpqanHP9v8+fO1xx57aLPNNtMLL7ygDz74QBdffLGKi4t7vA8AAAD0f47v+36uB2HS5MmT9Z3vfCcSRHfm4Ycf1uGHH66GhgYVFBS0e/2pp57SAQccoFWrVqmiokKStGbNGg0aNEhPPfWUpkyZoptvvlkXX3yxvvzyS7lu8z3Rf/7zH2299daaO3euJkyYoLfeeivImI8ePbrDbSTplVde0YUXXqi33npLQ4cO1RFHHKHp06errKxMknTssceqoKBAt99+u4lfFQAAAPop6zLxPbVy5Urdeeed2m233ToM4CWpoaFBjuOoqKgoeK64uFiu6+qVV14JtiksLAwCeEkqKSmRpGCbTTfdVEOGDNEtt9yixsZG1dXV6ZZbbtGkSZO00UYbSWrOsu+///6aNm2aPvjgA91zzz165ZVXdOaZZ0qSPM/TY489pk022URTp07V8OHDtcsuu+jBBx80/asBAABAH5d3Qfz555+vsrIyDRkyRJ9//rkeeuihTrf92te+prKyMp1//vlat26damtrdd555ymZTOrLL7+UJO29995asmSJrrjiCjU2NmrVqlW64IILJCnYZsCAAXrhhRd0xx13qKSkROXl5XriiSf0+OOPKx6PS5KmT5+uE044QWeffbYmTpyo3XbbTddee61uu+021dfXa9myZaqpqdEf/vAH7b///nrqqad0xBFH6Mgjj9SLL76Y4d8aAAAA+pJ+H8T//ve/V3l5efDx8ssv64wzzog8F574+dOf/lTvvvuunnrqKcViMX37299WZxVFw4YN03333adHHnlE5eXlqqys1OrVq7X99tsHmfcttthCf//733XllVeqtLRUVVVVGjdunEaMGBFsU1dXp5NPPlm77767Xn/9db366qvacsstddBBB6murk6S9P777+vWW2+NjHvq1KnyPE+ffvppUIN/2GGH6ZxzztG2226rCy64QAcffLBmzJiRyV8xAAAA+ph4rgeQrjPOOEPHHHNM8PkJJ5ygadOm6cgjjwyeGzVqVPB46NChGjp0qDbZZBNNmjRJo0eP1uuvv65dd921w/3vt99+mj9/vlasWKF4PK6BAweqqqpKG2+8cbDN8ccfr+OPP15Lly5VWVmZHMfRn/70p2CbmTNn6rPPPtNrr70WBPYzZ87UoEGD9NBDD+nYY49VTU2NTj/9dJ111lntxjBmzBhJUjwe1+abbx55bdKkSUHZDgAAAPJDvw/iBw8erMGDBwefl5SUaPjw4cFk0a6kstsNDQ3dbjt06FBJ0nPPPadly5bp0EMPbbfNiBEjJEl//etfVVxcrH333VeStG7dOrmuK8dxgm1Tn6fGsP322+ujjz7qctw77bSTPv7448hzc+bM0dixY7sdPwAAAOzR78tpemrWrFm67rrr9N5772nBggV67rnndNxxx2n8+PFBFn7RokXabLPN9MYbbwRf97e//U2vv/665s+frzvuuENHH320zjnnHG266abBNtddd53eeecdzZkzR9dff73OPPNMTZ8+XQMHDpQk7bvvvlq1apV++MMfavbs2frwww/13e9+V/F4XHvttZek5lr9f//73zrzzDP13nvvae7cuXrooYeCia1ScynQPffc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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "drivable_surface_layers = [\n", " MapLayer.LANE_GROUP,\n", @@ -829,32 +677,10 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "34", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "- RoadLineType.NONE\n", - "- RoadLineType.UNKNOWN\n", - "- RoadLineType.DASH_SOLID_YELLOW\n", - "- RoadLineType.DASH_SOLID_WHITE\n", - "- RoadLineType.DASHED_WHITE\n", - "- RoadLineType.DASHED_YELLOW\n", - "- RoadLineType.DOUBLE_SOLID_YELLOW\n", - "- RoadLineType.DOUBLE_SOLID_WHITE\n", - "- RoadLineType.DOUBLE_DASH_YELLOW\n", - "- RoadLineType.DOUBLE_DASH_WHITE\n", - "- RoadLineType.SOLID_YELLOW\n", - "- RoadLineType.SOLID_WHITE\n", - "- RoadLineType.SOLID_DASH_WHITE\n", - "- RoadLineType.SOLID_DASH_YELLOW\n", - "- RoadLineType.SOLID_BLUE\n" - ] - } - ], + "outputs": [], "source": [ "for road_line_type in RoadLineType:\n", " print(f\"- {road_line_type}\")" @@ -870,21 +696,10 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "36", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "road_line_color_map = {\n", " RoadLineType.NONE: \"grey\",\n", diff --git a/tutorial/03_visualization.ipynb b/tutorial/03_visualization.ipynb index bec0f7e3..5394862f 100644 --- a/tutorial/03_visualization.ipynb +++ b/tutorial/03_visualization.ipynb @@ -22,28 +22,12 @@ "metadata": {}, "outputs": [], "source": [ - "from typing import List, Optional\n", - "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", "from py123d.api import MapAPI, SceneAPI, SceneFilter\n", "from py123d.api.scene.arrow.arrow_scene_builder import ArrowSceneBuilder\n", - "from py123d.common.multithreading.worker_parallel import SingleMachineParallelExecutor\n", - "from py123d.datatypes.map_objects import (\n", - " BaseMapLineObject,\n", - " BaseMapObject,\n", - " BaseMapSurfaceObject,\n", - " Intersection,\n", - " Lane,\n", - " LaneGroup,\n", - " MapLayer,\n", - " RoadEdgeType,\n", - " RoadLineType,\n", - ")\n", - "from py123d.geometry import Point3D, Polyline2D\n", - "from py123d.visualization.color.default import MAP_SURFACE_CONFIG\n", - "from py123d.visualization.matplotlib.utils import add_non_repeating_legend_to_ax\n" + "from py123d.common.multithreading.worker_parallel import SingleMachineParallelExecutor" ] }, { @@ -90,7 +74,6 @@ " shuffle=True,\n", " map_api_required=True, # Only include scenes/logs with an available map API.\n", " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", - "\n", ")\n", "worker = SingleMachineParallelExecutor()\n", "scenes = ArrowSceneBuilder().get_scenes(scene_filter, worker)\n", @@ -132,7 +115,7 @@ { "cell_type": "code", "execution_count": null, - "id": "d8014d7f", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -143,7 +126,7 @@ "save_path = Path(\"./visualization\")\n", "save_path.mkdir(parents=True, exist_ok=True)\n", "\n", - "fps = 1 / scene.log_metadata.timestep_seconds \n", + "fps = 1 / scene.log_metadata.timestep_seconds\n", "\n", "render_scene_animation(\n", " scene=scene,\n", @@ -151,7 +134,7 @@ " start_idx=0,\n", " end_idx=None,\n", " step=1,\n", - " fps=fps * 2, # Let's speed it up a bit\n", + " fps=fps * 2, # Let's speed it up a bit\n", " dpi=300,\n", " format=\"mp4\",\n", " radius=80,\n", @@ -160,7 +143,7 @@ }, { "cell_type": "markdown", - "id": "03dc6b4d", + "id": "10", "metadata": {}, "source": [ "### 3.3.2 Plots of Cameras" @@ -169,7 +152,7 @@ { "cell_type": "code", "execution_count": null, - "id": "f152013f", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -190,7 +173,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9305efbf", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -213,7 +196,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c50619fe", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -224,7 +207,6 @@ "\n", "iteration = 20\n", "if len(available_pinhole_cameras) > 1 and len(available_lidars) > 0:\n", - "\n", " pinhole_camera_type = np.random.choice(available_pinhole_cameras)\n", " pinhole_camera = scene.get_pinhole_camera_at_iteration(iteration=iteration, camera_type=pinhole_camera_type)\n", " lidar_type = np.random.choice(available_lidars)\n", @@ -238,7 +220,7 @@ }, { "cell_type": "markdown", - "id": "70888c8b", + "id": "14", "metadata": {}, "source": [ "### 3.4. Visualization in 3D with Viser\n", @@ -255,7 +237,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7463bfba", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -267,7 +249,7 @@ { "cell_type": "code", "execution_count": null, - "id": "ace08e04", + "id": "16", "metadata": {}, "outputs": [], "source": [] From 0fd0b03e334eab11683ae755a4367f517030f629 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 18:54:31 +0100 Subject: [PATCH 45/50] Update documentation, update pre-commit and linter/formatter, some minor refactorings (#70) --- .pre-commit-config.yaml | 6 +- docs/README.md | 10 + docs/contributing.md | 108 +++++++++++ docs/datasets/av2.rst | 10 +- docs/datasets/carla.rst | 10 +- docs/datasets/index.rst | 3 +- docs/datasets/kitti-360.rst | 172 ++++++++++++------ docs/index.rst | 9 +- docs/notes/contributing.md | 117 ------------ pyproject.toml | 1 + src/py123d/api/map/gpkg/gpkg_utils.py | 2 +- .../api/scene/arrow/arrow_scene_builder.py | 6 +- .../api/scene/arrow/utils/arrow_getters.py | 12 +- src/py123d/common/utils/mixin.py | 4 + .../datasets/av2/av2_sensor_converter.py | 6 +- .../datasets/kitti360/kitti360_converter.py | 4 +- .../datasets/kitti360/kitti360_sensor_io.py | 10 +- .../datasets/nuplan/nuplan_converter.py | 6 +- .../nuplan/utils/nuplan_sql_helper.py | 2 +- .../datasets/nuscenes/nuscenes_converter.py | 6 +- .../wopd/utils/womp_boundary_utils.py | 6 +- .../datasets/wopd/wopd_converter.py | 6 +- .../log_writer/abstract_log_writer.py | 12 +- src/py123d/conversion/registry/__init__.py | 2 +- .../registry/lidar_index_registry.py | 2 +- .../sensor_io/camera/mp4_camera_io.py | 4 +- .../sensor_io/lidar/file_lidar_io.py | 6 +- .../map_utils/opendrive/utils/collection.py | 6 +- .../map_utils/road_edge/road_edge_3d_utils.py | 6 +- src/py123d/geometry/utils/rotation_utils.py | 6 +- .../script/builders/utils/utils_type.py | 6 +- src/py123d/visualization/matplotlib/camera.py | 6 +- tutorial/03_visualization.ipynb | 4 +- 33 files changed, 323 insertions(+), 253 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/contributing.md delete mode 100644 docs/notes/contributing.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe86c1fe..5bdfe331 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,12 +19,12 @@ repos: args: ['--no-sort-keys', "--autofix"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.2 + rev: v0.14.6 hooks: - - id: ruff + - id: ruff-check # Run the linter. args: [--fix] exclude: __init__.py$ - - id: ruff-format + - id: ruff-format # Run the formatter. exclude: __init__.py$ - repo: https://github.com/kynan/nbstripout rev: 0.8.1 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..5a9168d6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,10 @@ +You can install relevant dependencies for editing the public documentation via: +```sh +pip install -e .[docs] +``` + +It is recommended to uses [sphinx-autobuild](https://github.com/sphinx-doc/sphinx-autobuild) (installed above) to edit and view the documentation. You can run: + +```sh +sphinx-autobuild docs docs/_build/html +``` diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..e26675e3 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,108 @@ +## Contributing + +Contributions to 123D are highly encouraged! This guide will help you get started with the development process. + +### Getting Started + +#### 1. Clone the Repository + +```sh +git clone git@github.com:autonomousvision/py123d.git +cd py123d +``` + +#### 2. Installation + +```sh +conda create -n py123d_dev python=3.12 # Optional +conda activate py123d_dev +pip install -e .[dev] +pre-commit install +``` + +The above installation should also include linting, formatting, type-checking in the pre-commit. +We use [`ruff`](https://docs.astral.sh/ruff/) as linter/formatter, for which you can run: +```sh +ruff check --fix . +ruff format . +``` +Type checking is not strictly enforced, but ideally added with [`pyright`](https://github.com/microsoft/pyright). + + +#### 3. Managing dependencies + +We try to keep dependencies minimal to ensure quick and easy installations. +However, various datasets require dependencies in order to load or preprocess the dataset. +In this case, you can add optional dependencies to the `pyproject.toml` install file. +You can follow examples of nuPlan or nuScenes. These optional dependencies can be install with + +```sh +pip install -e .[dev,nuplan,nuscenes] +``` +where you can combined the different optional dependencies. + +The optional dependencies should only be required for data pre-processing. +When writing a dataset conversion method, you can check if the necessary dependencies are installed by calling with the `check_dependencies` function. + +```python +from py123d.common.utils.dependencies import check_dependencies + +check_dependencies(["optional_package_a", "optional_package_b"], "optional_dataset") +import optional_package_a +import optional_package_b + +def load_camera_from_outdated_dataset(...) -> ...: + optional_package_a.module(...) + optional_package_b.module(...) + pass +``` +This will notify the user if `optional_dataset` is not included in the 123D install. + +Also ensure that functions/modules that require optional installs are only imported when necessary, e.g: + +```python +def load_camera_from_file(file_path: str, dataset: str) -> ...: + ... + if dataset == "optional_dataset": + from py123d.some_module import load_camera_from_outdated_dataset + + return load_camera_from_outdated_dataset(...) + ... +``` + +#### 4. Other useful tools + +If you are using VSCode, it is recommended to install: +- [autodocstring](https://marketplace.visualstudio.com/items?itemName=njpwerner.autodocstring) - Creating docstrings (please set `"autoDocstring.docstringFormat": "sphinx-notypes"`). +- [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - A basic spell checker. + +Or other similar plugins depending on your preference/editor. + +### Documentation Requirements + +#### Docstrings +- **Development:** Docstrings are encouraged but not strictly required during active development +- **Format:** Use [Sphinx-style docstrings](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html) + + +#### Sphinx documentation + +All datasets should be included in the `/docs/datasets/` documentation. Please follow the documentation format of other datasets. + +You can install relevant dependencies for editing the public documentation via: +```sh +pip install -e .[docs] +``` + +It is recommended to uses [sphinx-autobuild](https://github.com/sphinx-doc/sphinx-autobuild) (installed above) to edit and view the documentation. You can run: +```sh +sphinx-autobuild docs docs/_build/html +``` + +### Adding new datasets +TODO + + +### Questions? + +If you have any questions about contributing, please open an issue or reach out to the maintainers. diff --git a/docs/datasets/av2.rst b/docs/datasets/av2.rst index 5773e0d8..9b9a4020 100644 --- a/docs/datasets/av2.rst +++ b/docs/datasets/av2.rst @@ -149,27 +149,27 @@ The downloaded dataset should have the following structure: Installation ~~~~~~~~~~~~ -No additional installation steps are required beyond the standard ``py123d`` installation. +No additional installation steps are required beyond the standard `py123d`` installation. Conversion ~~~~~~~~~~ -To run the conversion, you either need to set the environment variable ``$AV2_SENSOR_ROOT`` or ``$AV2_SENSOR_ROOT``. +To run the conversion, you either need to set the environment variable ``$AV2_DATA_ROOT`` or ``$AV2_SENSOR_ROOT``. You can also override the file path and run: .. code-block:: bash py123d-conversion datasets=["av2_sensor_dataset"] \ - dataset_paths.av2_sensor_data_root=$AV2_SENSOR_ROOT # optional if env variable is set - + dataset_paths.av2_data_root=$AV2_DATA_ROOT # optional if env variable is set Dataset Issues ~~~~~~~~~~~~~~ -n/a +- **Ego Vehicle:** The vehicle parameters are partially estimated and may be subject to inaccuracies. + Citation diff --git a/docs/datasets/carla.rst b/docs/datasets/carla.rst index 3cb1571a..387013f8 100644 --- a/docs/datasets/carla.rst +++ b/docs/datasets/carla.rst @@ -3,6 +3,7 @@ CARLA CARLA is an open-source simulator for autonomous driving research. As such CARLA data is synthetic and can be generated with varying sensor and environmental conditions. +The following documentation is largely incomplete and merely describes the provided demo data. .. dropdown:: Quick Links :open: @@ -46,7 +47,7 @@ Available Modalities - Depending on the collected dataset. For further information, see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. * - Traffic Lights - X - - TODO + - n/a * - Pinhole Cameras - ✓ - Depending on the collected dataset. For further information, see :class:`~py123d.datatypes.sensors.PinholeCamera`. @@ -90,12 +91,7 @@ Dataset Specific Dataset Issues ~~~~~~~~~~~~~~ -[Document any known issues, limitations, or considerations when using this dataset] - -* Issue 1: Description -* Issue 2: Description -* Issue 3: Description - +n/a Citation ~~~~~~~~ diff --git a/docs/datasets/index.rst b/docs/datasets/index.rst index b0e7bba2..7242d523 100644 --- a/docs/datasets/index.rst +++ b/docs/datasets/index.rst @@ -3,7 +3,8 @@ Datasets Brief overview of the datasets section... -This section provides comprehensive documentation for various autonomous driving and computer vision datasets. Each dataset entry includes installation instructions, available data types, known issues, and proper citation formats. +This section provides comprehensive documentation for various autonomous driving and computer vision datasets. +Each dataset entry includes installation instructions, available data types, known issues, and references for further reading. .. toctree:: :maxdepth: 1 diff --git a/docs/datasets/kitti-360.rst b/docs/datasets/kitti-360.rst index 3322df23..e1565c8e 100644 --- a/docs/datasets/kitti-360.rst +++ b/docs/datasets/kitti-360.rst @@ -1,6 +1,9 @@ KITTI-360 --------- +The KITTI-360 dataset is an extension of the popular KITTI dataset, designed for various perception tasks in autonomous driving. +The dataset includes 9 logs (called "sequences") of varying length with stereo cameras, fisheye cameras, LiDAR data, 3D primitives, and semantic annotations. + .. dropdown:: Quick Links :open: @@ -35,91 +38,148 @@ Available Modalities - **Available** - **Description** * - Ego Vehicle - - ✓ / (✓) / X - - ..., see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. + - ✓ + - State of the ego vehicle, including poses, dynamic state, and vehicle parameters, see :class:`~py123d.datatypes.vehicle_state.EgoStateSE3`. * - Map - - ✓ / (✓) / X - - ..., see :class:`~py123d.api.MapAPI`. + - ✓ + - The maps are in 3D vector format and defined per log, see :class:`~py123d.api.MapAPI`. The map does not include lane-level information. * - Bounding Boxes - - ✓ / (✓) / X - - ..., see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. + - ✓ + - The bounding boxes are available and labeled with :class:`~py123d.conversion.registry.KITTI360BoxDetectionLabel`. For further information, see :class:`~py123d.datatypes.detections.BoxDetectionWrapper`. * - Traffic Lights - - ✓ / (✓) / X - - ..., see :class:`~py123d.datatypes.detections.TrafficLightDetectionWrapper`. + - X + - n/a * - Pinhole Cameras - - ✓ / (✓) / X - - ..., see :class:`~py123d.datatypes.sensors.PinholeCamera`. + - ✓ + - The dataset has two :class:`~py123d.datatypes.sensors.PinholeCamera` in a stereo setup: + + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_STEREO_L` (image_00) + - :class:`~py123d.datatypes.sensors.PinholeCameraType.PCAM_STEREO_R` (image_01) + * - Fisheye Cameras - - ✓ / (✓) / X - - ..., see :class:`~py123d.datatypes.sensors.FisheyeCamera`. + - ✓ + - The dataset has two :class:`~py123d.datatypes.sensors.FisheyeMEICamera`: + + - :class:`~py123d.datatypes.sensors.FisheyeMEICameraType.FCAM_L` (image_02) + - :class:`~py123d.datatypes.sensors.FisheyeMEICameraType.FCAM_R` (image_03) * - LiDARs - - ✓ / (✓) / X - - ..., see :class:`~py123d.datatypes.sensors.LiDAR`. + - ✓ + - The dataset has :class:`~py123d.datatypes.sensors.LiDAR` mounted on the roof: + + - :class:`~py123d.datatypes.sensors.LiDARType.LIDAR_TOP` (velodyne_points) + +.. dropdown:: Dataset Specific + + .. autoclass:: py123d.conversion.registry.KITTI360BoxDetectionLabel + :members: + :no-index: + :no-inherited-members: + + .. autoclass:: py123d.conversion.registry.KITTI360LiDARIndex + :members: + :no-index: + :no-inherited-members: Download ~~~~~~~~ -... +You can download the KITTI-360 dataset from the `official website `_. Please follow the instructions provided there to obtain the data. +The 123D library supports expect the dataset in the following directory structure: + +.. code-block:: text + + $KITTI360_DATA_ROOT/ + ├── calibration/ + │ ├── calib_cam_to_pose.txt + │ ├── calib_cam_to_velo.txt + │ ├── calib_sick_to_velo.txt + │ ├── image_02.yaml + │ ├── image_03.yaml + │ └── perspective.txt + ├── data_2d_raw/ + │ ├── 2013_05_28_drive_0000_sync/ + │ │ ├── image_00/ + │ │ │ ├── data_rect + │ │ │ │ ├── 0000000000.png + │ │ │ │ ├── ... + │ │ │ │ └── 0000011517.png + │ │ │ └── timestamps.txt + │ │ ├── image_01/ + │ │ │ └── ... + │ │ ├── image_02/ + │ │ │ ├── data_rgb + │ │ │ │ ├── 0000000000.png + │ │ │ │ ├── ... + │ │ │ │ └── 0000011517.png + │ │ │ └── timestamps.txt + │ │ └── image_03/ + │ │ └── ... + │ ├── ... + │ └── 2013_05_28_drive_0018_sync/ + │ └── ... + ├── data_2d_semantics/ (not yet supported) + │ └── ... + ├── data_3d_bboxes/ + │ ├── train + │ │ ├── 2013_05_28_drive_0000_sync.xml + │ │ ├── ... + │ │ └── 2013_05_28_drive_0010_sync.xml + │ └── train_full + │ ├── 2013_05_28_drive_0000_sync.xml + │ ├── ... + │ └── 2013_05_28_drive_0010_sync.xml + ├── data_3d_raw/ + │ ├── 2013_05_28_drive_0000_sync/ + │ │ └── velodyne_points/ + │ │ ├── data + │ │ │ ├── 0000000000.bin + │ │ │ ├── ... + │ │ │ └── 0000011517.bin + │ │ └── timestamps.txt + │ ├── ... + │ └── 2013_05_28_drive_0018_sync/ + │ └── ... + ├── data_3d_semantics/ (not yet supported) + │ └── ... + └── data_poses/ + ├── 2013_05_28_drive_0000_sync/ + │ ├── cam0_to_world.txt + │ ├── oxts/ + │ │ └── ... + │ └── poses.txt + ├── ... + └── 2013_05_28_drive_0018_sync/ + └── ... + +Note that not all data modalities are currently supported in 123D. For example, semantic 2D and 3D data are not yet integrated. -The 123D conversion expects the following directory structure: Installation ~~~~~~~~~~~~ -For *Template*, additional installation that are included as optional dependencies in ``py123d`` are required. You can install them via: +No additional installation steps are required beyond the standard `py123d`` installation. -.. code-block:: bash - pip install py123d[template] +Conversion +~~~~~~~~~~ -Or if you are installing from source: +You can convert the KITTI-360 dataset by running: .. code-block:: bash - pip install -e .[template] - - -Dataset Specific -~~~~~~~~~~~~~~~~ - -.. dropdown:: Box Detection Labels + py123d-conversion datasets=["kitti360_dataset"] - .. autoclass:: py123d.conversion.registry.DefaultBoxDetectionLabel - :members: - :no-inherited-members: - -.. dropdown:: LiDAR Index - - .. autoclass:: py123d.conversion.registry.DefaultLiDARIndex - :members: - :no-inherited-members: +Note, that you can assign the logs of KITTI-360 to different splits (e.g., "train", "val", "test") in the ``kitti360_dataset.yaml`` config. Dataset Issues ~~~~~~~~~~~~~~ -[Document any known issues, limitations, or considerations when using this dataset] - -* Issue 1: Description -* Issue 2: Description -* Issue 3: Description - - -Citation -~~~~~~~~ - -If you use *Template* in your research, please cite: - -.. code-block:: bibtex - - @article{AuthorYearConference, - title={Template: Some Dataset for Autonomous Driving}, - author={}, - booktitle={}, - year={} - } +* **Ego Vehicle:** The vehicle parameters from the VW station wagon are partially estimated and may be subject to inaccuracies. +* **Map:** The ground primitives in KITTI-360 only cover surfaces, e.g. of the road, but not lane-level information. Drivable areas, road edges, walkways, driveways are included. +* **Bounding Boxes:** Bounding boxes in KITTI-360 annotated globally. We therefore determine which boxes are visible in each frame on the number of LiDAR points contained in the box. Citation diff --git a/docs/index.rst b/docs/index.rst index e1604f61..95fb81ce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,12 @@ Features include: - Visualization tools with `matplotlib `_ and `Viser `_. +.. warning:: + + This library is under active development and not stable. The API and features may change in future releases. + Please report issues, feature requests, or other feedback by opening an issue on the project's GitHub repository. + + .. youtube:: Q4q29fpXnx8 :width: 800 :height: 450 @@ -41,4 +47,5 @@ Features include: :hidden: :caption: Notes - notes/contributing + notes/conventions + contributing diff --git a/docs/notes/contributing.md b/docs/notes/contributing.md deleted file mode 100644 index 7ed2d499..00000000 --- a/docs/notes/contributing.md +++ /dev/null @@ -1,117 +0,0 @@ -# Contributing - -Contributions to 123D are highly encouraged! This guide will help you get started with the development process. - -## Getting Started - -### 1. Clone the Repository - -```sh -git clone git@github.com:autonomousvision/py123d.git -cd py123d -``` - -### 2. Install the pip-package - -```sh -conda env create -f environment.yml --name py123d_dev # Optional -conda activate py123d_dev -pip install -e .[dev] -pre-commit install -``` - -.. note:: - We might remove the conda environment in the future, but leave the file in the repo during development. - - -### 3. Managing dependencies - -One principal of 123D is to keep *minimal dependencies*. However, various datasets require dependencies in order to load or preprocess the dataset. In this case, you can add optional dependencies to the `pyproject.toml` install file. You can follow examples of Waymo/nuPlan. These optional dependencies can be install with - -```sh -pip install -e .[dev,waymo,nuplan] -``` -where you can combined the different optional dependencies. - -The optional dependencies should only be required for data pre-processing. If a dataset allows to load sensor data dynamically from the original dataset, please encapsule the import accordingly, e.g. - -```python -import numpy as np -import numpy.typing as npt - -def load_camera_from_outdated_dataset(file_path: str) -> npt.NDArray[np.uint8]: - try: - from optional_dataset import load_camera_image - except ImportError: - raise ImportError( - "Optional dependency 'outdated_dataset' is required to load camera images from this dataset. " - "Please install it using: pip install .[outdated_dataset]" - ) - return load_camera_image(file_path) -``` - - -## Code Style and Formatting - -We maintain consistent code quality using the following tools: -- **[Black](https://black.readthedocs.io/)** - Code formatter -- **[isort](https://pycqa.github.io/isort/)** - Import statement formatter -- **[flake8](https://flake8.pycqa.github.io/)** - Style guide enforcement -- **[pytest](https://docs.pytest.org/)** - Testing framework for unit and integration tests -- **[pre-commit](https://pre-commit.com/)** - Framework for managing and running Git hooks to automate code quality checks - - -.. note:: - If you're using VSCode, it is recommended to install the [Black](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter), [isort](https://marketplace.visualstudio.com/items?itemName=ms-python.isort), and [Flake8](https://marketplace.visualstudio.com/items?itemName=ms-python.flake8) plugins. - - - -### Editor Setup - -**VS Code Users:** -If you're using VSCode, it is recommended to install the following plguins: -- [Black](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter) - see above. -- [isort](https://marketplace.visualstudio.com/items?itemName=ms-python.isort) - see above. -- [Flake8](https://marketplace.visualstudio.com/items?itemName=ms-python.flake8) - see above. -- [autodocstring](https://marketplace.visualstudio.com/items?itemName=njpwerner.autodocstring) - Creating docstrings (please set `"autoDocstring.docstringFormat": "sphinx-notypes"`). -- [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - A basic spell checker. - - -**Other Editors:** -Similar plugins are available for most popular editors including PyCharm, Vim, Emacs, and Sublime Text. - - -## Documentation Requirements - -### Docstrings -- **Development:** Docstrings are encouraged but not strictly required during active development -- **Release:** All public functions, classes, and modules must have comprehensive docstrings before release -- **Format:** Use [Sphinx-style docstrings](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html) - -**VS Code Users:** The [autoDocstring extension](https://marketplace.visualstudio.com/items?itemName=njpwerner.autodocstring) can help generate properly formatted docstrings. - -### Type Hints -- **Required:** All function parameters and return values must include type hints -- **Style:** Follow [PEP 484](https://peps.python.org/pep-0484/) conventions - -### Sphinx documentation - -All datasets should be included in the `/docs/datasets.md` documentation. Please follow the documentation format of other datasets. - -You can install relevant dependencies for editing the public documentation via: -```sh -pip install -e .[docs] -``` - -It is recommended to uses [sphinx-autobuild](https://github.com/sphinx-doc/sphinx-autobuild) (installed above) to edit and view the documentation. You can run: -```sh -sphinx-autobuild docs docs/_build/html -``` - -## Adding new datasets -TODO - - -## Questions? - -If you have any questions about contributing, please open an issue or reach out to the maintainers. diff --git a/pyproject.toml b/pyproject.toml index 6578cf9a..eb0687be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,5 +130,6 @@ lint.ignore = [ "PLW0642", # Reassigned self in instance method. ] +exclude = ["__init__.py"] fixable = ["ALL"] unfixable = [] diff --git a/src/py123d/api/map/gpkg/gpkg_utils.py b/src/py123d/api/map/gpkg/gpkg_utils.py index 9d8e8548..ed9e16a7 100644 --- a/src/py123d/api/map/gpkg/gpkg_utils.py +++ b/src/py123d/api/map/gpkg/gpkg_utils.py @@ -56,7 +56,7 @@ def get_row_with_value( if matching_rows is not None: assert len(matching_rows) > 0, f"Could not find the desired key = {desired_value}" assert len(matching_rows) == 1, ( - f"{len(matching_rows)} matching keys found. Expected to only find one." "Try using get_all_rows_with_value" + f"{len(matching_rows)} matching keys found. Expected to only find one.Try using get_all_rows_with_value" ) geo_series = matching_rows.iloc[0] return geo_series diff --git a/src/py123d/api/scene/arrow/arrow_scene_builder.py b/src/py123d/api/scene/arrow/arrow_scene_builder.py index eba90b24..2326fb7e 100644 --- a/src/py123d/api/scene/arrow/arrow_scene_builder.py +++ b/src/py123d/api/scene/arrow/arrow_scene_builder.py @@ -60,9 +60,9 @@ def get_scenes(self, filter: SceneFilter, worker: WorkerPool) -> List[SceneAPI]: def _discover_split_names(logs_root: Path, split_types: Set[str]) -> Set[str]: """Discovers split names in the logs root directory based on the specified split types.""" - assert set(split_types).issubset( - {"train", "val", "test"} - ), f"Invalid split types: {split_types}. Valid split types are 'train', 'val', 'test'." + assert set(split_types).issubset({"train", "val", "test"}), ( + f"Invalid split types: {split_types}. Valid split types are 'train', 'val', 'test'." + ) split_names: List[str] = [] for split in logs_root.iterdir(): split_name = split.name diff --git a/src/py123d/api/scene/arrow/utils/arrow_getters.py b/src/py123d/api/scene/arrow/utils/arrow_getters.py index 49a24112..c0257917 100644 --- a/src/py123d/api/scene/arrow/utils/arrow_getters.py +++ b/src/py123d/api/scene/arrow/utils/arrow_getters.py @@ -204,9 +204,9 @@ def get_camera_from_arrow_table( :return: The constructed camera object, or None if not available. """ - assert isinstance( - camera_type, (PinholeCameraType, FisheyeMEICameraType) - ), f"camera_type must be PinholeCameraType or FisheyeMEICameraType, got {type(camera_type)}" + assert isinstance(camera_type, (PinholeCameraType, FisheyeMEICameraType)), ( + f"camera_type must be PinholeCameraType or FisheyeMEICameraType, got {type(camera_type)}" + ) camera: Optional[Union[PinholeCamera, FisheyeMEICamera]] = None @@ -230,9 +230,9 @@ def get_camera_from_arrow_table( if isinstance(table_data, str): sensor_root = DATASET_SENSOR_ROOT[log_metadata.dataset] - assert ( - sensor_root is not None - ), f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}" + assert sensor_root is not None, ( + f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}" + ) full_image_path = Path(sensor_root) / table_data assert full_image_path.exists(), f"Camera file not found: {full_image_path}" diff --git a/src/py123d/common/utils/mixin.py b/src/py123d/common/utils/mixin.py index a2f0a1fd..d4452980 100644 --- a/src/py123d/common/utils/mixin.py +++ b/src/py123d/common/utils/mixin.py @@ -88,6 +88,10 @@ def __repr__(self) -> str: """String representation of the ArrayMixin instance.""" return f"{self.__class__.__name__}(array={self.array})" + def __hash__(self): + """Hash based on the array values.""" + return hash(self.array.tobytes()) + def indexed_array_repr(array_mixin: ArrayMixin, indexing: IntEnum) -> str: """Generate a string representation of an ArrayMixin instance using an indexing enum. diff --git a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py index 3e76fc40..48b444a1 100644 --- a/src/py123d/conversion/datasets/av2/av2_sensor_converter.py +++ b/src/py123d/conversion/datasets/av2/av2_sensor_converter.py @@ -288,9 +288,9 @@ def _extract_av2_sensor_box_detections( def _extract_av2_sensor_ego_state(city_se3_egovehicle_df: pd.DataFrame, lidar_timestamp_ns: int) -> EgoStateSE3: """Extract ego state from AV2 sensor dataset city_SE3_egovehicle dataframe.""" ego_state_slice = get_slice_with_timestamp_ns(city_se3_egovehicle_df, lidar_timestamp_ns) - assert ( - len(ego_state_slice) == 1 - ), f"Expected exactly one ego state for timestamp {lidar_timestamp_ns}, got {len(ego_state_slice)}." + assert len(ego_state_slice) == 1, ( + f"Expected exactly one ego state for timestamp {lidar_timestamp_ns}, got {len(ego_state_slice)}." + ) ego_pose_dict = ego_state_slice.iloc[0].to_dict() rear_axle_pose = _row_dict_to_pose_se3(ego_pose_dict) diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py index b58c6a7d..438c9123 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_converter.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_converter.py @@ -26,7 +26,7 @@ from py123d.conversion.datasets.kitti360.utils.preprocess_detection import process_detection from py123d.conversion.log_writer.abstract_log_writer import AbstractLogWriter, CameraData, LiDARData from py123d.conversion.map_writer.abstract_map_writer import AbstractMapWriter -from py123d.conversion.registry import KITTI360BoxDetectionLabel, Kitti360LiDARIndex +from py123d.conversion.registry import KITTI360BoxDetectionLabel, KITTI360LiDARIndex from py123d.datatypes.detections import BoxDetectionMetadata, BoxDetectionSE3, BoxDetectionWrapper from py123d.datatypes.metadata import LogMetadata, MapMetadata from py123d.datatypes.sensors import ( @@ -439,7 +439,7 @@ def _get_kitti360_lidar_metadata( extrinsic_pose_se3 = _extrinsic_from_imu_to_rear_axle(extrinsic_pose_se3) metadata[LiDARType.LIDAR_TOP] = LiDARMetadata( lidar_type=LiDARType.LIDAR_TOP, - lidar_index=Kitti360LiDARIndex, + lidar_index=KITTI360LiDARIndex, extrinsic=extrinsic_pose_se3, ) return metadata diff --git a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py index 0baa5542..f96dc84a 100644 --- a/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py +++ b/src/py123d/conversion/datasets/kitti360/kitti360_sensor_io.py @@ -4,7 +4,7 @@ import numpy as np -from py123d.conversion.registry.lidar_index_registry import Kitti360LiDARIndex +from py123d.conversion.registry.lidar_index_registry import KITTI360LiDARIndex from py123d.datatypes.metadata import LogMetadata from py123d.datatypes.sensors.lidar import LiDARType from py123d.geometry.pose import PoseSE3 @@ -16,16 +16,16 @@ def load_kitti360_lidar_pcs_from_file(filepath: Path, log_metadata: LogMetadata) if not filepath.exists(): logging.warning(f"LiDAR file does not exist: {filepath}. Returning empty point cloud.") - return {LiDARType.LIDAR_TOP: np.zeros((1, len(Kitti360LiDARIndex)), dtype=np.float32)} + return {LiDARType.LIDAR_TOP: np.zeros((1, len(KITTI360LiDARIndex)), dtype=np.float32)} lidar_extrinsic = log_metadata.lidar_metadata[LiDARType.LIDAR_TOP].extrinsic lidar_pc = np.fromfile(filepath, dtype=np.float32) - lidar_pc = np.reshape(lidar_pc, [-1, len(Kitti360LiDARIndex)]) + lidar_pc = np.reshape(lidar_pc, [-1, len(KITTI360LiDARIndex)]) - lidar_pc[..., Kitti360LiDARIndex.XYZ] = convert_points_3d_array_between_origins( + lidar_pc[..., KITTI360LiDARIndex.XYZ] = convert_points_3d_array_between_origins( from_origin=lidar_extrinsic, to_origin=PoseSE3(0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0), - points_3d_array=lidar_pc[..., Kitti360LiDARIndex.XYZ], + points_3d_array=lidar_pc[..., KITTI360LiDARIndex.XYZ], ) return {LiDARType.LIDAR_TOP: lidar_pc} diff --git a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py index 70259bd1..d1e23add 100644 --- a/src/py123d/conversion/datasets/nuplan/nuplan_converter.py +++ b/src/py123d/conversion/datasets/nuplan/nuplan_converter.py @@ -100,9 +100,9 @@ def __init__( assert nuplan_maps_root is not None, "The variable `nuplan_maps_root` must be provided." assert nuplan_sensor_root is not None, "The variable `nuplan_sensor_root` must be provided." for split in splits: - assert ( - split in NUPLAN_DATA_SPLITS - ), f"Split {split} is not available. Available splits: {NUPLAN_DATA_SPLITS}" + assert split in NUPLAN_DATA_SPLITS, ( + f"Split {split} is not available. Available splits: {NUPLAN_DATA_SPLITS}" + ) self._splits: List[str] = splits self._nuplan_data_root: Path = Path(nuplan_data_root) diff --git a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py index fc70a979..62c6128f 100644 --- a/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py +++ b/src/py123d/conversion/datasets/nuplan/utils/nuplan_sql_helper.py @@ -116,7 +116,7 @@ def get_nearest_ego_pose_for_timestamp_from_db( INNER JOIN lidar_pc AS lpc ON ep.timestamp <= lpc.timestamp + ? AND ep.timestamp >= lpc.timestamp - ? - WHERE lpc.token IN ({('?,'*len(tokens))[:-1]}) + WHERE lpc.token IN ({("?," * len(tokens))[:-1]}) ORDER BY ABS(ep.timestamp - ?) LIMIT 1 """ # noqa: E226 diff --git a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py index d2391888..33706bf6 100644 --- a/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py +++ b/src/py123d/conversion/datasets/nuscenes/nuscenes_converter.py @@ -84,9 +84,9 @@ def __init__( assert nuscenes_data_root is not None, "The variable `nuscenes_data_root` must be provided." assert nuscenes_map_root is not None, "The variable `nuscenes_map_root` must be provided." for split in splits: - assert ( - split in NUSCENES_DATA_SPLITS - ), f"Split {split} is not available. Available splits: {NUSCENES_DATA_SPLITS}" + assert split in NUSCENES_DATA_SPLITS, ( + f"Split {split} is not available. Available splits: {NUSCENES_DATA_SPLITS}" + ) self._splits: List[str] = splits diff --git a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py index b1d204dd..c73591c0 100644 --- a/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py +++ b/src/py123d/conversion/datasets/wopd/utils/womp_boundary_utils.py @@ -217,9 +217,9 @@ def fill_lane_boundaries( lane_queries_3d = [ Point3D.from_array(point_3d_array) for point_3d_array in lane_polyline.interpolate(distances_3d) ] - assert len(lane_queries_se2) == len( - lane_queries_3d - ), f"Number of sampled SE2 poses {len(lane_queries_se2)} and 3D points {len(lane_queries_3d)} must be the same" + assert len(lane_queries_se2) == len(lane_queries_3d), ( + f"Number of sampled SE2 poses {len(lane_queries_se2)} and 3D points {len(lane_queries_3d)} must be the same" + ) for sign in [1.0, -1.0]: boundary_points_3d: List[Optional[Point3D]] = [] diff --git a/src/py123d/conversion/datasets/wopd/wopd_converter.py b/src/py123d/conversion/datasets/wopd/wopd_converter.py index 5ef60c6d..9e486a11 100644 --- a/src/py123d/conversion/datasets/wopd/wopd_converter.py +++ b/src/py123d/conversion/datasets/wopd/wopd_converter.py @@ -89,9 +89,9 @@ def __init__( super().__init__(dataset_converter_config) for split in splits: - assert ( - split in WOPD_AVAILABLE_SPLITS - ), f"Split {split} is not available. Available splits: {WOPD_AVAILABLE_SPLITS}" + assert split in WOPD_AVAILABLE_SPLITS, ( + f"Split {split} is not available. Available splits: {WOPD_AVAILABLE_SPLITS}" + ) self._splits: List[str] = splits self._wopd_data_root: Path = Path(wopd_data_root) diff --git a/src/py123d/conversion/log_writer/abstract_log_writer.py b/src/py123d/conversion/log_writer/abstract_log_writer.py index de1e5bc9..446cd4c1 100644 --- a/src/py123d/conversion/log_writer/abstract_log_writer.py +++ b/src/py123d/conversion/log_writer/abstract_log_writer.py @@ -85,9 +85,9 @@ class LiDARData: point_cloud: Optional[npt.NDArray[np.float32]] = None def __post_init__(self): - assert ( - self.has_file_path or self.has_point_cloud - ), "Either file path (dataset_root and relative_path) or point_cloud must be provided for LiDARData." + assert self.has_file_path or self.has_point_cloud, ( + "Either file path (dataset_root and relative_path) or point_cloud must be provided for LiDARData." + ) @property def has_file_path(self) -> bool: @@ -112,9 +112,9 @@ class CameraData: relative_path: Optional[Union[str, Path]] = None def __post_init__(self): - assert ( - self.has_file_path or self.has_jpeg_binary or self.has_numpy_image - ), "Either file path (dataset_root and relative_path) or jpeg_binary or numpy_image must be provided for CameraData." + assert self.has_file_path or self.has_jpeg_binary or self.has_numpy_image, ( + "Either file path (dataset_root and relative_path) or jpeg_binary or numpy_image must be provided for CameraData." + ) if self.has_file_path: absolute_path = Path(self.dataset_root) / self.relative_path diff --git a/src/py123d/conversion/registry/__init__.py b/src/py123d/conversion/registry/__init__.py index 47cf1160..48dd1b45 100644 --- a/src/py123d/conversion/registry/__init__.py +++ b/src/py123d/conversion/registry/__init__.py @@ -14,7 +14,7 @@ AV2SensorLiDARIndex, CARLALiDARIndex, DefaultLiDARIndex, - Kitti360LiDARIndex, + KITTI360LiDARIndex, LiDARIndex, NuPlanLiDARIndex, NuScenesLiDARIndex, diff --git a/src/py123d/conversion/registry/lidar_index_registry.py b/src/py123d/conversion/registry/lidar_index_registry.py index 2fe26ebb..593a47c6 100644 --- a/src/py123d/conversion/registry/lidar_index_registry.py +++ b/src/py123d/conversion/registry/lidar_index_registry.py @@ -75,7 +75,7 @@ class WOPDLiDARIndex(LiDARIndex): @register_lidar_index -class Kitti360LiDARIndex(LiDARIndex): +class KITTI360LiDARIndex(LiDARIndex): """KITTI-360 LiDAR Indexing Scheme.""" X = 0 diff --git a/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py b/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py index bf6941e8..50ce5563 100644 --- a/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py +++ b/src/py123d/conversion/sensor_io/camera/mp4_camera_io.py @@ -41,7 +41,7 @@ def write_frame(self, frame: np.ndarray) -> int: self.writer = cv2.VideoWriter(self.output_path, fourcc, self.fps, self.frame_size) if frame.shape[:2][::-1] != self.frame_size: - raise ValueError(f"Frame size {frame.shape[:2][::-1]} doesn't match " f"video size {self.frame_size}") + raise ValueError(f"Frame size {frame.shape[:2][::-1]} doesn't match video size {self.frame_size}") frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) self.writer.write(frame) @@ -101,7 +101,7 @@ def get_frame(self, frame_index: int) -> Optional[np.ndarray]: """ if frame_index < 0 or frame_index >= self.frame_count: - raise IndexError(f"Frame index {frame_index} out of range " f"[0, {len(self.frames)})") + raise IndexError(f"Frame index {frame_index} out of range [0, {len(self.frames)})") if self.read_all: return self.frames[frame_index] diff --git a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py index c276e0a7..bfc7ad83 100644 --- a/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py +++ b/src/py123d/conversion/sensor_io/lidar/file_lidar_io.py @@ -41,9 +41,9 @@ def load_lidar_pcs_from_file( assert relative_path is not None, "Relative path to LiDAR file must be provided." if sensor_root is None: - assert ( - log_metadata.dataset in DATASET_SENSOR_ROOT.keys() - ), f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}." + assert log_metadata.dataset in DATASET_SENSOR_ROOT.keys(), ( + f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}." + ) sensor_root = DATASET_SENSOR_ROOT[log_metadata.dataset] assert sensor_root is not None, f"Dataset path for sensor loading not found for dataset: {log_metadata.dataset}" diff --git a/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py b/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py index 32090d2b..efeb5a29 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/utils/collection.py @@ -89,7 +89,7 @@ def _update_connection_from_links( :param road_dict: Dictionary of roads indexed by road id. """ - for lane_id in lane_helper_dict.keys(): + for lane_id in lane_helper_dict.keys(): # noqa: PLC0206 road_idx, lane_section_idx, _, lane_idx = lane_id.split("_") road_idx, lane_section_idx, lane_idx = int(road_idx), int(lane_section_idx), int(lane_idx) @@ -216,7 +216,7 @@ def _flip_and_set_connections(lane_helper_dict: Dict[str, OpenDriveLaneHelper]) :param lane_helper_dict: Dictionary mapping lane ids to their helper objects. """ - for lane_id in lane_helper_dict.keys(): + for lane_id in lane_helper_dict.keys(): # noqa: PLC0206 if lane_helper_dict[lane_id].id > 0: successors_temp = lane_helper_dict[lane_id].successor_lane_ids lane_helper_dict[lane_id].successor_lane_ids = lane_helper_dict[lane_id].predecessor_lane_ids @@ -235,7 +235,7 @@ def _post_process_connections( :param connection_distance_threshold: Threshold distance for valid connections. """ - for lane_id in lane_helper_dict.keys(): + for lane_id in lane_helper_dict.keys(): # noqa: PLC0206 lane_helper_dict[lane_id] centerline = lane_helper_dict[lane_id].center_polyline_se2 diff --git a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py index 3852149d..c5591635 100644 --- a/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py +++ b/src/py123d/conversion/utils/map_utils/road_edge/road_edge_3d_utils.py @@ -301,9 +301,9 @@ def _resolve_conflicting_lane_groups( def _get_nearest_z_from_points_3d(points_3d: npt.NDArray[np.float64], query_point: npt.NDArray[np.float64]) -> float: """Helpers function to get the Z-value of the nearest 3D point to a query point.""" - assert points_3d.ndim == 2 and points_3d.shape[1] == len( - Point3DIndex - ), "points_3d must be a 2D array with shape (N, 3)" + assert points_3d.ndim == 2 and points_3d.shape[1] == len(Point3DIndex), ( + "points_3d must be a 2D array with shape (N, 3)" + ) distances = np.linalg.norm(points_3d[..., Point3DIndex.XY] - query_point[..., Point3DIndex.XY], axis=1) closest_point = points_3d[np.argmin(distances)] return closest_point[2] diff --git a/src/py123d/geometry/utils/rotation_utils.py b/src/py123d/geometry/utils/rotation_utils.py index 08433f89..6a035f5b 100644 --- a/src/py123d/geometry/utils/rotation_utils.py +++ b/src/py123d/geometry/utils/rotation_utils.py @@ -17,9 +17,9 @@ def batch_matmul(A: npt.NDArray[np.float64], B: npt.NDArray[np.float64]) -> npt. :return: Array of shape (..., M, P) resulting from batch matrix multiplication of A and B. """ assert A.ndim >= 2 and B.ndim >= 2 - assert ( - A.shape[-1] == B.shape[-2] - ), f"Inner dimensions must match for matrix multiplication, got {A.shape} and {B.shape}" + assert A.shape[-1] == B.shape[-2], ( + f"Inner dimensions must match for matrix multiplication, got {A.shape} and {B.shape}" + ) return np.einsum("...ij,...jk->...ik", A, B) diff --git a/src/py123d/script/builders/utils/utils_type.py b/src/py123d/script/builders/utils/utils_type.py index 0fe5e9fd..53e700de 100644 --- a/src/py123d/script/builders/utils/utils_type.py +++ b/src/py123d/script/builders/utils/utils_type.py @@ -29,9 +29,9 @@ def validate_type(instantiated_class: Any, desired_type: Type[Any]) -> None: :param instantiated_class: class that was created :param desired_type: type that the created class should have """ - assert isinstance( - instantiated_class, desired_type - ), f"Class to be of type {desired_type}, but is {type(instantiated_class)}!" + assert isinstance(instantiated_class, desired_type), ( + f"Class to be of type {desired_type}, but is {type(instantiated_class)}!" + ) def are_the_same_type(lhs: Any, rhs: Any) -> None: diff --git a/src/py123d/visualization/matplotlib/camera.py b/src/py123d/visualization/matplotlib/camera.py index 8bd8b064..078fdc98 100644 --- a/src/py123d/visualization/matplotlib/camera.py +++ b/src/py123d/visualization/matplotlib/camera.py @@ -71,9 +71,9 @@ def add_box_detections_to_camera_ax( [detection.metadata.default_label for detection in box_detections.box_detections], dtype=object ) for idx, box_detection in enumerate(box_detections.box_detections): - assert isinstance( - box_detection, BoxDetectionSE3 - ), f"Box detection must be of type BoxDetectionSE3, got {type(box_detection)}" + assert isinstance(box_detection, BoxDetectionSE3), ( + f"Box detection must be of type BoxDetectionSE3, got {type(box_detection)}" + ) box_detection_array[idx] = box_detection.bounding_box_se3.array # FIXME diff --git a/tutorial/03_visualization.ipynb b/tutorial/03_visualization.ipynb index 5394862f..83d76169 100644 --- a/tutorial/03_visualization.ipynb +++ b/tutorial/03_visualization.ipynb @@ -55,7 +55,7 @@ "source": [ "## 3.2 Create Scenes by filtering the datasets\n", "\n", - "We create some scenes for easy access to some `MapAPI`'s. We use the option `map_api_required=True` to only include scenes/logs with maps." + "As in other tutorials, we first query some scenes fro visualization. " ] }, { @@ -223,7 +223,7 @@ "id": "14", "metadata": {}, "source": [ - "### 3.4. Visualization in 3D with Viser\n", + "## 3.4. Viser\n", "\n", "Can also be run in the `example/01_viser.py` or by running \n", "\n", From 3c4eda68fd4b2b4e4da292fedb80864d4b9c6c16 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 20:31:05 +0100 Subject: [PATCH 46/50] Minor bug fixes, improve the tutorials (#70) --- src/py123d/api/scene/scene_filter.py | 2 +- .../opendrive/opendrive_map_conversion.py | 1 - .../conversion/datasets/nuscenes_dataset.yaml | 2 +- .../datasets/nuscenes_mini_dataset.yaml | 2 +- .../visualization/matplotlib/observation.py | 2 +- .../visualization/viser/viser_viewer.py | 1 + .../01_scene_tutorial.ipynb | 63 ++++++++++++------- {tutorial => tutorials}/02_map_tutorial.ipynb | 0 .../03_visualizations_tutorial.ipynb | 14 ++++- 9 files changed, 55 insertions(+), 32 deletions(-) rename {tutorial => tutorials}/01_scene_tutorial.ipynb (96%) rename {tutorial => tutorials}/02_map_tutorial.ipynb (100%) rename tutorial/03_visualization.ipynb => tutorials/03_visualizations_tutorial.ipynb (95%) diff --git a/src/py123d/api/scene/scene_filter.py b/src/py123d/api/scene/scene_filter.py index a11c7b03..20b2dc44 100644 --- a/src/py123d/api/scene/scene_filter.py +++ b/src/py123d/api/scene/scene_filter.py @@ -33,7 +33,7 @@ class SceneFilter: duration_s: Optional[float] = 10.0 """Duration of each scene in seconds.""" - history_s: Optional[float] = 3.0 + history_s: Optional[float] = 0.0 """History duration of each scene in seconds.""" pinhole_camera_types: Optional[List[PinholeCameraType]] = None diff --git a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py index 1ce4defb..0d1ccd78 100644 --- a/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py +++ b/src/py123d/conversion/utils/map_utils/opendrive/opendrive_map_conversion.py @@ -111,7 +111,6 @@ def _extract_and_write_lanes( successor_ids=lane_helper.successor_lane_ids, speed_limit_mps=lane_helper.speed_limit_mps, outline=lane_helper.outline_polyline_3d, - geometry=None, ) lanes.append(lane) map_writer.write_lane(lane) diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml index f2dcf697..3e368d05 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_dataset.yaml @@ -17,7 +17,7 @@ nuscenes_dataset: # Map include_map: true - remap_map_ids: false + remap_map_ids: true # Ego include_ego: true diff --git a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml index 90cfb69a..ac7b6a4a 100644 --- a/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml +++ b/src/py123d/script/config/conversion/datasets/nuscenes_mini_dataset.yaml @@ -17,7 +17,7 @@ nuscenes_mini_dataset: # Map include_map: true - remap_map_ids: false + remap_map_ids: true # Ego include_ego: true diff --git a/src/py123d/visualization/matplotlib/observation.py b/src/py123d/visualization/matplotlib/observation.py index 98db2bed..6d3ca8ff 100644 --- a/src/py123d/visualization/matplotlib/observation.py +++ b/src/py123d/visualization/matplotlib/observation.py @@ -37,8 +37,8 @@ def add_scene_on_ax(ax: plt.Axes, scene: SceneAPI, iteration: int = 0, radius: f map_api = scene.get_map_api() assert ego_vehicle_state is not None, "Ego vehicle state is required to plot the scene." + point_2d = ego_vehicle_state.bounding_box_se2.center_se2.pose_se2.point_2d if map_api is not None: - point_2d = ego_vehicle_state.bounding_box_se2.center_se2.pose_se2.point_2d add_default_map_on_ax(ax, map_api, point_2d, radius=radius) if traffic_light_detections is not None: add_traffic_lights_to_ax(ax, traffic_light_detections, map_api) diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index 777d0374..f1e0ba23 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -398,6 +398,7 @@ def _get_scene_info_markdown(scene: SceneAPI) -> str: markdown = f""" - Dataset: {scene.log_metadata.split} - Location: {scene.log_metadata.location if scene.log_metadata.location else "N/A"} + - Log: {scene.log_metadata.log_name} - UUID: {scene.scene_uuid} """ return markdown diff --git a/tutorial/01_scene_tutorial.ipynb b/tutorials/01_scene_tutorial.ipynb similarity index 96% rename from tutorial/01_scene_tutorial.ipynb rename to tutorials/01_scene_tutorial.ipynb index 9aa57d3b..338eff3c 100644 --- a/tutorial/01_scene_tutorial.ipynb +++ b/tutorials/01_scene_tutorial.ipynb @@ -74,15 +74,14 @@ "source": [ "from py123d.datatypes.sensors import PinholeCamera, PinholeCameraType\n", "\n", - "pinhole_camera_types = [PinholeCameraType.PCAM_B0]\n", "scene_filter = SceneFilter(\n", " split_names=None,\n", " log_names=None,\n", " scene_uuids=None,\n", - " duration_s=8.0,\n", + " duration_s=7.0,\n", " history_s=0.0,\n", - " timestamp_threshold_s=8.0,\n", - " pinhole_camera_types=[PinholeCameraType.PCAM_B0],\n", + " timestamp_threshold_s=7.0,\n", + " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", " shuffle=True,\n", ")\n", "scene_builder = ArrowSceneBuilder()\n", @@ -94,9 +93,25 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "6", "metadata": {}, + "outputs": [], + "source": [ + "datasplits = []\n", + "\n", + "for scene in scenes:\n", + " datasplits.append(scene.log_metadata.split)\n", + "\n", + "\n", + "print(set(datasplits))" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, "source": [ "## 1.2 Inspecting the Scene\n", "\n", @@ -110,7 +125,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -126,7 +141,7 @@ }, { "cell_type": "markdown", - "id": "8", + "id": "9", "metadata": {}, "source": [ "`LogMetadata`: Information of the log the scene was extracted from. This object also includes data about the map (if available), or static information of the ego vehicle, e.g. the included sensors and vehicle parameters" @@ -135,7 +150,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -145,7 +160,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "11", "metadata": {}, "source": [ "`MapMetadata`: If the map is available, this object includes information about the location, wether the map is 3D (`map_has_z`), of the the map is defined per log (`map_is_local`)." @@ -154,7 +169,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -164,7 +179,7 @@ }, { "cell_type": "markdown", - "id": "12", + "id": "13", "metadata": {}, "source": [ "## 1.3 Retrieving data from the `SceneAPI`\n", @@ -178,7 +193,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "14", "metadata": {}, "source": [ "### 1.3.1 `TimePoint`\n", @@ -189,7 +204,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -202,7 +217,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "### 1.3.2 `EgoStateSE3` \n", @@ -212,7 +227,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -234,7 +249,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "18", "metadata": {}, "source": [ "### 1.3.3 `BoxDetectionWrapper`\n", @@ -245,7 +260,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -266,7 +281,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "20", "metadata": {}, "source": [ "### 1.3.4 `PinholeCamera`\n", @@ -276,7 +291,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -305,7 +320,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "22", "metadata": {}, "source": [ "### 1.3.5 `LiDAR`\n", @@ -315,7 +330,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -352,7 +367,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "24", "metadata": {}, "source": [ "### 1.3.6 `MapAPI`\n", @@ -364,7 +379,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -407,7 +422,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "26", "metadata": {}, "source": [ "### 1.3.7 Others:\n", diff --git a/tutorial/02_map_tutorial.ipynb b/tutorials/02_map_tutorial.ipynb similarity index 100% rename from tutorial/02_map_tutorial.ipynb rename to tutorials/02_map_tutorial.ipynb diff --git a/tutorial/03_visualization.ipynb b/tutorials/03_visualizations_tutorial.ipynb similarity index 95% rename from tutorial/03_visualization.ipynb rename to tutorials/03_visualizations_tutorial.ipynb index 83d76169..8efd9c57 100644 --- a/tutorial/03_visualization.ipynb +++ b/tutorials/03_visualizations_tutorial.ipynb @@ -69,10 +69,10 @@ "\n", "scene_filter = SceneFilter(\n", " split_names=None,\n", - " duration_s=8.0, # No duration means that the scene will include the complete log.\n", - " timestamp_threshold_s=8.0,\n", + " duration_s=7.0, # No duration means that the scene will include the complete log.\n", + " timestamp_threshold_s=0.0,\n", " shuffle=True,\n", - " map_api_required=True, # Only include scenes/logs with an available map API.\n", + " map_api_required=False, # Only include scenes/logs with an available map API.\n", " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", ")\n", "worker = SingleMachineParallelExecutor()\n", @@ -253,6 +253,14 @@ "metadata": {}, "outputs": [], "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { From b6b9f366757dbae68e15c2fa5377fa8e25e08d5a Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 20:55:34 +0100 Subject: [PATCH 47/50] Add `s5cmd` to dependencies --- docs/datasets/av2.rst | 6 +++--- pyproject.toml | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/datasets/av2.rst b/docs/datasets/av2.rst index 9b9a4020..fbdab4f2 100644 --- a/docs/datasets/av2.rst +++ b/docs/datasets/av2.rst @@ -99,11 +99,11 @@ Download ~~~~~~~~ You can download the Argoverse 2 Sensor dataset from the `Argoverse website `_. -You can also use directly the dataset from AWS. For that, you first need to install `s5cmd `_: +.. You can also use directly the dataset from AWS. For that, you first need to install `s5cmd `_: -.. code-block:: bash +.. .. code-block:: bash - pip install s5cmd +.. pip install s5cmd Next, you can run the following bash script to download the dataset: diff --git a/pyproject.toml b/pyproject.toml index eb0687be..da03475d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "omegaconf", "typing-extensions", "requests", + "s5cmd", ] [project.scripts] From f0e4c1c38db08a9a9c264371e12ebe262aa183a8 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Fri, 21 Nov 2025 21:43:32 +0100 Subject: [PATCH 48/50] Update `README.md` and change tutorials/docs (#71, #70) --- README.md | 30 +++++++++++++++++++--- docs/datasets/av2.rst | 7 +---- docs/installation.md | 7 +++++ tutorials/01_scene_tutorial.ipynb | 6 ++--- tutorials/02_map_tutorial.ipynb | 6 ++--- tutorials/03_visualizations_tutorial.ipynb | 6 ++--- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 46440e04..ee955393 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,30 @@

- - - Logo + + + Logo -

123D: One Library for 2D and 3D Driving Datasets

+

123D: A Library for Driving Datasets

+

Video | Documentation

+ + +## Features + +- Unified API for driving data, including sensor data, maps, and labels. +- Support for multiple sensors storage formats. +- Fast dataformat based on [Apache Arrow](https://arrow.apache.org/). +- Visualization tools with [matplotlib](https://matplotlib.org/) and [Viser](https://viser.studio/main/). + + +> **Warning** +> +> This library is under active development and not stable. The API and features may change in future releases. +> Please report issues, feature requests, or other feedback by opening an issue. + + +## Changelog + +- **`[2025/11/21]`** v0.0.8 (silent release) + - Release of package and documentation. + - Demo data for tutorials. diff --git a/docs/datasets/av2.rst b/docs/datasets/av2.rst index fbdab4f2..9f73e255 100644 --- a/docs/datasets/av2.rst +++ b/docs/datasets/av2.rst @@ -99,12 +99,7 @@ Download ~~~~~~~~ You can download the Argoverse 2 Sensor dataset from the `Argoverse website `_. -.. You can also use directly the dataset from AWS. For that, you first need to install `s5cmd `_: - -.. .. code-block:: bash - -.. pip install s5cmd - +You can also use directly the dataset from AWS. Next, you can run the following bash script to download the dataset: diff --git a/docs/installation.md b/docs/installation.md index 9cca6ac0..292c6bdc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -53,3 +53,10 @@ $PY123D_DATA_ROOT │ └── ... └── ... ``` + +You can download some demo data using: + + diff --git a/tutorials/01_scene_tutorial.ipynb b/tutorials/01_scene_tutorial.ipynb index 338eff3c..9530c0b2 100644 --- a/tutorials/01_scene_tutorial.ipynb +++ b/tutorials/01_scene_tutorial.ipynb @@ -7,9 +7,9 @@ "source": [ "

\n", " \n", - " \n", - " \n", - " \"Logo\"\n", + " \n", + " \n", + " \"Logo\"\n", " \n", "

123D: Scene Tutorial

\n", "" diff --git a/tutorials/02_map_tutorial.ipynb b/tutorials/02_map_tutorial.ipynb index 2236573b..df100b0e 100644 --- a/tutorials/02_map_tutorial.ipynb +++ b/tutorials/02_map_tutorial.ipynb @@ -7,9 +7,9 @@ "source": [ "

\n", " \n", - " \n", - " \n", - " \"Logo\"\n", + " \n", + " \n", + " \"Logo\"\n", " \n", "

123D: Map Tutorial

\n", "" diff --git a/tutorials/03_visualizations_tutorial.ipynb b/tutorials/03_visualizations_tutorial.ipynb index 8efd9c57..fd6ffe2f 100644 --- a/tutorials/03_visualizations_tutorial.ipynb +++ b/tutorials/03_visualizations_tutorial.ipynb @@ -7,9 +7,9 @@ "source": [ "

\n", " \n", - " \n", - " \n", - " \"Logo\"\n", + " \n", + " \n", + " \"Logo\"\n", " \n", "

123D: Visualization Tutorial

\n", "" From 21f656b8c0732474a97d964f0c23c43a350eec75 Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sat, 22 Nov 2025 00:05:02 +0100 Subject: [PATCH 49/50] Update documentation, tutorials and `README.md`. Include demo data (#70, #71) --- README.md | 2 +- docs/conf.py | 17 +++- docs/datasets/av2.rst | 9 +- docs/datasets/carla.rst | 2 + docs/datasets/nuplan.rst | 2 + docs/datasets/nuscenes.rst | 2 + docs/datasets/pandaset.rst | 2 + docs/index.rst | 1 - docs/installation.md | 75 +++++++++------ pyproject.toml | 1 - .../common/scene_filter/log_scenes.yaml | 20 ---- .../common/scene_filter/nuplan_logs.yaml | 19 ---- .../common/scene_filter/nuplan_mini_logs.yaml | 19 ---- .../common/scene_filter/viser_scenes.yaml | 1 - .../script/config/viser/default_viser.yaml | 3 +- .../visualization/viser/viser_viewer.py | 4 +- tutorials/01_scene_tutorial.ipynb | 81 ++++++---------- tutorials/02_map_tutorial.ipynb | 90 ++++++++---------- tutorials/03_visualizations_tutorial.ipynb | 95 ++++++++++--------- 19 files changed, 206 insertions(+), 239 deletions(-) delete mode 100644 src/py123d/script/config/common/scene_filter/log_scenes.yaml delete mode 100644 src/py123d/script/config/common/scene_filter/nuplan_logs.yaml delete mode 100644 src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml diff --git a/README.md b/README.md index ee955393..e3ab4f2d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,6 @@ ## Changelog -- **`[2025/11/21]`** v0.0.8 (silent release) +- **`[2025-11-21]`** v0.0.8 (silent release) - Release of package and documentation. - Demo data for tutorials. diff --git a/docs/conf.py b/docs/conf.py index 939fa684..a1929722 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,8 +8,8 @@ project = "py123d" -copyright = "2025, 123D Contributors" -author = "123D Contributors" +copyright = "2025" +author = "DanielDauner" release = "v0.0.8" # -- General configuration --------------------------------------------------- @@ -63,6 +63,7 @@ html_theme_options = {} + autodoc_typehints = "both" autodoc_class_signature = "separated" autodoc_default_options = { @@ -98,6 +99,18 @@ "light_logo": "123D_logo_transparent_black.svg", "dark_logo": "123D_logo_transparent_white.svg", "sidebar_hide_name": True, + "footer_icons": [ + { + "name": "GitHub", + "url": "https://github.com/autonomousvision/py123d", + "html": """ + + + + """, + "class": "", + }, + ], } ) diff --git a/docs/datasets/av2.rst b/docs/datasets/av2.rst index 9f73e255..f8ff3993 100644 --- a/docs/datasets/av2.rst +++ b/docs/datasets/av2.rst @@ -1,3 +1,5 @@ +.. _av2_sensor: + Argoverse 2 - Sensor -------------------- @@ -99,7 +101,12 @@ Download ~~~~~~~~ You can download the Argoverse 2 Sensor dataset from the `Argoverse website `_. -You can also use directly the dataset from AWS. +You can also use directly the dataset from AWS. For that, you first need to install `s5cmd `_: + +.. code-block:: bash + + pip install s5cmd + Next, you can run the following bash script to download the dataset: diff --git a/docs/datasets/carla.rst b/docs/datasets/carla.rst index 387013f8..68629072 100644 --- a/docs/datasets/carla.rst +++ b/docs/datasets/carla.rst @@ -1,3 +1,5 @@ +.. _carla: + CARLA ----- diff --git a/docs/datasets/nuplan.rst b/docs/datasets/nuplan.rst index 3e0c5e4e..bc5d9c82 100644 --- a/docs/datasets/nuplan.rst +++ b/docs/datasets/nuplan.rst @@ -1,3 +1,5 @@ +.. _nuplan: + nuPlan ------ diff --git a/docs/datasets/nuscenes.rst b/docs/datasets/nuscenes.rst index 5264901b..a8bcf2f9 100644 --- a/docs/datasets/nuscenes.rst +++ b/docs/datasets/nuscenes.rst @@ -1,3 +1,5 @@ +.. _nuscenes: + nuScenes -------- diff --git a/docs/datasets/pandaset.rst b/docs/datasets/pandaset.rst index 4041ecca..8879cf8b 100644 --- a/docs/datasets/pandaset.rst +++ b/docs/datasets/pandaset.rst @@ -1,3 +1,5 @@ +.. _pandaset: + PandaSet -------- diff --git a/docs/index.rst b/docs/index.rst index 95fb81ce..c878edf2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -47,5 +47,4 @@ Features include: :hidden: :caption: Notes - notes/conventions contributing diff --git a/docs/installation.md b/docs/installation.md index 292c6bdc..3216ad85 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,14 +7,16 @@ pip install py123d ``` or as editable pip package with ```bash +mkdir -p $HOME/py123d_workspace; cd $HOME/py123d_workspace # Optional git clone git@github.com:autonomousvision/py123d.git cd py123d pip install -e . ``` - ## File Structure & Storage The 123D library converts driving datasets to a unified format. By default, all data is stored in directory of the environment variable `$PY123D_DATA_ROOT`. +For example, you can use. + ```bash export PY123D_DATA_ROOT="$HOME/py123d_workspace/data" ``` @@ -25,38 +27,53 @@ The 123D conversion includes: - **Maps:** The maps are static and store our unified HD-Map API. Maps can either be defined per-log (e.g. in AV2, Waymo) or globally for a certain location (e.g. nuPlan, nuScenes, CARLA). In the current implementation, we store maps as `.gpkg` files. - **Sensors:** There are multiple options to store sensor data. Cameras and LiDAR point clouds can either (1) be read from the original dataset or (2) stored within the log file. For cameras, we also support (3) compression with MP4 files, which are written into the `/sensors` directory. -For example, when converting `nuplan-mini` with MP4 compression, the file structure should look the following: +For example, when converting `nuplan-mini` with MP4 compression and using `PY123D_DATA_ROOT="$HOME/py123d_workspace/data"`, the file structure would look the following way: ``` -$PY123D_DATA_ROOT -├── logs -│ ├── nuplan-mini_test -│ │ ├── 2021.05.25.14.16.10_veh-35_01690_02183.arrow -│ │ ├── ... -│ │ └── 2021.10.06.07.26.10_veh-52_00006_00398.arrow -│ ├── nuplan-mini_train +~/py123d_workspace/ +├── data/ +│ ├── logs +│ │ ├── nuplan-mini_test +│ │ │ ├── 2021.05.25.14.16.10_veh-35_01690_02183.arrow +│ │ │ ├── ... +│ │ │ └── 2021.10.06.07.26.10_veh-52_00006_00398.arrow +│ │ ├── nuplan-mini_train +│ │ │ └── ... +│ │ ├── nuplan-mini_train +│ │ │ └── ... │ │ └── ... -│ ├── nuplan-mini_train +│ ├── maps +│ │ ├── nuplan +│ │ │ ├── nuplan_sg-one-north.gpkg +│ │ │ ├── ... +│ │ │ └── nuplan_us-pa-pittsburgh-hazelwood.gpkg │ │ └── ... -│ └── ... -├── maps -│ ├── nuplan -│ │ ├── nuplan_sg-one-north.gpkg -│ │ ├── ... -│ │ └── nuplan_us-pa-pittsburgh-hazelwood.gpkg -│ └── ... -└── sensors - ├── nuplan-mini_test - │ ├── 2021.05.25.14.16.10_veh-35_01690_02183 - │ │ ├── pcam_b0.mp4 - │ │ ├── ... - │ │ └── pcam_r2.mp4 - │ └── ... +│ └── sensors +│ ├── nuplan-mini_test +│ │ ├── 2021.05.25.14.16.10_veh-35_01690_02183 +│ │ │ ├── pcam_b0.mp4 +│ │ │ ├── ... +│ │ │ └── pcam_r2.mp4 +│ │ └── ... +│ └── ... +└── py123d/ (repository) └── ... ``` -You can download some demo data using: +## Demo data + + +You can test 123D with demo data from [nuPlan](nuplan), [nuScenes](nuscenes), [PandaSet](pandaset), [Argoverse 2 - Sensor](av2_sensor), and [CARLA](carla). Please be aware of the respective licenses, that are included in the download. You can use the following script: - +```bash +# Create the data root and a temporary folder. +mkdir -p $PY123D_DATA_ROOT +mkdir -p ./temp + +# Download the demo data. +wget https://s3.eu-central-1.amazonaws.com/avg-projects-2/123d/demo_v0.0.8/data.zip + +# Unzip, sync, and clean up. +unzip -o data.zip -d ./temp +rsync -av ./temp/data/* $PY123D_DATA_ROOT +rm -r ./temp & rm -r data.zip +``` diff --git a/pyproject.toml b/pyproject.toml index da03475d..eb0687be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,6 @@ dependencies = [ "omegaconf", "typing-extensions", "requests", - "s5cmd", ] [project.scripts] diff --git a/src/py123d/script/config/common/scene_filter/log_scenes.yaml b/src/py123d/script/config/common/scene_filter/log_scenes.yaml deleted file mode 100644 index c9761a1d..00000000 --- a/src/py123d/script/config/common/scene_filter/log_scenes.yaml +++ /dev/null @@ -1,20 +0,0 @@ -_target_: py123d.api.scene.scene_filter.SceneFilter -_convert_: 'all' - -split_types: null -split_names: null -log_names: null - - -locations: null -scene_uuids: null -timestamp_threshold_s: null -ego_displacement_minimum_m: null - -duration_s: null -history_s: 1.0 - -camera_types: null - -max_num_scenes: null -shuffle: false diff --git a/src/py123d/script/config/common/scene_filter/nuplan_logs.yaml b/src/py123d/script/config/common/scene_filter/nuplan_logs.yaml deleted file mode 100644 index 5df7f925..00000000 --- a/src/py123d/script/config/common/scene_filter/nuplan_logs.yaml +++ /dev/null @@ -1,19 +0,0 @@ -_target_: py123d.api.scene.scene_filter.SceneFilter -_convert_: 'all' - -split_types: null -split_names: - - "nuplan_train" - - "nuplan_val" - - "nuplan_test" -log_names: null - - -locations: null -scene_uuids: null -timestamp_threshold_s: null -ego_displacement_minimum_m: null -max_num_scenes: null - -duration_s: null -history_s: null diff --git a/src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml b/src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml deleted file mode 100644 index 160413fe..00000000 --- a/src/py123d/script/config/common/scene_filter/nuplan_mini_logs.yaml +++ /dev/null @@ -1,19 +0,0 @@ -_target_: py123d.api.scene.scene_filter.SceneFilter -_convert_: 'all' - -split_types: null -split_names: - - "nuplan_mini_train" - - "nuplan_mini_val" - - "nuplan_mini_test" -log_names: null - - -locations: null -scene_uuids: null -timestamp_threshold_s: null -ego_displacement_minimum_m: null -max_num_scenes: null - -duration_s: null -history_s: null diff --git a/src/py123d/script/config/common/scene_filter/viser_scenes.yaml b/src/py123d/script/config/common/scene_filter/viser_scenes.yaml index 7e150d18..8ed7eb3a 100644 --- a/src/py123d/script/config/common/scene_filter/viser_scenes.yaml +++ b/src/py123d/script/config/common/scene_filter/viser_scenes.yaml @@ -9,7 +9,6 @@ log_names: null locations: null scene_uuids: null timestamp_threshold_s: null -ego_displacement_minimum_m: null duration_s: null history_s: null diff --git a/src/py123d/script/config/viser/default_viser.yaml b/src/py123d/script/config/viser/default_viser.yaml index b9ff6f9b..18798ffd 100644 --- a/src/py123d/script/config/viser/default_viser.yaml +++ b/src/py123d/script/config/viser/default_viser.yaml @@ -12,6 +12,7 @@ defaults: - default_common - default_dataset_paths - override scene_filter: viser_scenes + - override worker: single_machine_thread_pool - _self_ viser_config: @@ -56,7 +57,7 @@ viser_config: # -> GUI camera_gui_visible: true - camera_gui_types: [] + # camera_gui_types: null # Loads front camera by default camera_gui_image_scale: 0.25 # Fisheye MEI Cameras diff --git a/src/py123d/visualization/viser/viser_viewer.py b/src/py123d/visualization/viser/viser_viewer.py index f1e0ba23..5b3dc13c 100644 --- a/src/py123d/visualization/viser/viser_viewer.py +++ b/src/py123d/visualization/viser/viser_viewer.py @@ -57,8 +57,8 @@ def _build_viser_server(viser_config: ViserConfig) -> viser.ViserServer: ), ) image = TitlebarImage( - image_url_light="https://autonomousvision.github.io/py123d/_static/logo_black.png", - image_url_dark="https://autonomousvision.github.io/py123d/_static/logo_white.png", + image_url_light="https://autonomousvision.github.io/py123d/_static/123D_logo_transparent_black.svg", + image_url_dark="https://autonomousvision.github.io/py123d/_static/123D_logo_transparent_white.svg", image_alt="123D", href="https://autonomousvision.github.io/py123d/", ) diff --git a/tutorials/01_scene_tutorial.ipynb b/tutorials/01_scene_tutorial.ipynb index 9530c0b2..c520bf74 100644 --- a/tutorials/01_scene_tutorial.ipynb +++ b/tutorials/01_scene_tutorial.ipynb @@ -35,20 +35,14 @@ "id": "2", "metadata": {}, "source": [ - "## 1.1 Download Demo Logs" + "## 1.1 Download Demo Logs\n", + "\n", + "You can download demo logs for 123D as described in the [documentation](https://autonomousvision.github.io/py123d/installation/). After the installation and download, you can start with the tutorial." ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [] - }, { "cell_type": "markdown", - "id": "4", + "id": "3", "metadata": {}, "source": [ "## 1.2 Create Scenes by filtering the datasets\n", @@ -68,7 +62,7 @@ { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -84,33 +78,18 @@ " pinhole_camera_types=[PinholeCameraType.PCAM_F0],\n", " shuffle=True,\n", ")\n", - "scene_builder = ArrowSceneBuilder()\n", "worker = SingleMachineParallelExecutor()\n", + "scenes = ArrowSceneBuilder().get_scenes(scene_filter, worker)\n", "\n", - "# worker = RayDistributed()\n", - "scenes = scene_builder.get_scenes(scene_filter, worker)\n", - "print(f\"Found {len(scenes)} scenes.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "datasplits = []\n", - "\n", - "for scene in scenes:\n", - " datasplits.append(scene.log_metadata.split)\n", - "\n", - "\n", - "print(set(datasplits))" + "dataset_splits = set(scene.log_metadata.split for scene in scenes)\n", + "print(f\"Found {len(scenes)} scenes from {len(dataset_splits)} datasplits:\")\n", + "for split in dataset_splits:\n", + " print(f\" - {split}\")" ] }, { "cell_type": "markdown", - "id": "7", + "id": "5", "metadata": {}, "source": [ "## 1.2 Inspecting the Scene\n", @@ -125,7 +104,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -141,7 +120,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "7", "metadata": {}, "source": [ "`LogMetadata`: Information of the log the scene was extracted from. This object also includes data about the map (if available), or static information of the ego vehicle, e.g. the included sensors and vehicle parameters" @@ -150,7 +129,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -160,7 +139,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "9", "metadata": {}, "source": [ "`MapMetadata`: If the map is available, this object includes information about the location, wether the map is 3D (`map_has_z`), of the the map is defined per log (`map_is_local`)." @@ -169,7 +148,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -179,7 +158,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "11", "metadata": {}, "source": [ "## 1.3 Retrieving data from the `SceneAPI`\n", @@ -193,7 +172,7 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "12", "metadata": {}, "source": [ "### 1.3.1 `TimePoint`\n", @@ -204,7 +183,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -217,7 +196,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "14", "metadata": {}, "source": [ "### 1.3.2 `EgoStateSE3` \n", @@ -227,7 +206,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -249,7 +228,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "16", "metadata": {}, "source": [ "### 1.3.3 `BoxDetectionWrapper`\n", @@ -260,7 +239,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -281,7 +260,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "18", "metadata": {}, "source": [ "### 1.3.4 `PinholeCamera`\n", @@ -291,7 +270,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -320,7 +299,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "20", "metadata": {}, "source": [ "### 1.3.5 `LiDAR`\n", @@ -330,7 +309,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -367,7 +346,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "22", "metadata": {}, "source": [ "### 1.3.6 `MapAPI`\n", @@ -379,7 +358,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -422,7 +401,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "24", "metadata": {}, "source": [ "### 1.3.7 Others:\n", diff --git a/tutorials/02_map_tutorial.ipynb b/tutorials/02_map_tutorial.ipynb index df100b0e..bc0aab2e 100644 --- a/tutorials/02_map_tutorial.ipynb +++ b/tutorials/02_map_tutorial.ipynb @@ -55,22 +55,14 @@ "id": "2", "metadata": {}, "source": [ - "## 2.1 Download Demo Logs" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "# TODO" + "## 2.1 Download Demo Logs\n", + "\n", + "You can download demo logs for 123D as described in the [documentation](https://autonomousvision.github.io/py123d/installation/). After the installation and download, you can start with the tutorial." ] }, { "cell_type": "markdown", - "id": "4", + "id": "3", "metadata": {}, "source": [ "## 2.2 Create Scenes by filtering the datasets\n", @@ -81,7 +73,7 @@ { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -92,15 +84,17 @@ " map_api_required=True, # Only include scenes/logs with an available map API.\n", ")\n", "worker = SingleMachineParallelExecutor()\n", - "\n", - "# worker = RayDistributed()\n", "scenes = ArrowSceneBuilder().get_scenes(scene_filter, worker)\n", - "print(f\"Found {len(scenes)} scenes.\")" + "\n", + "dataset_splits = set(scene.log_metadata.split for scene in scenes)\n", + "print(f\"Found {len(scenes)} scenes from {len(dataset_splits)} datasplits:\")\n", + "for split in dataset_splits:\n", + " print(f\" - {split}\")" ] }, { "cell_type": "markdown", - "id": "6", + "id": "5", "metadata": {}, "source": [ "## 2.3 Inspecting the `MapAPI`\n" @@ -108,7 +102,7 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "6", "metadata": {}, "source": [ "`MapMetadata`: If the map is available, this object includes information about the location, whether the map is 3D (`map_has_z`), of the the map is defined per log (`map_is_local`)." @@ -117,7 +111,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -130,7 +124,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "8", "metadata": {}, "source": [ "## 2.4 Querying map objects\n", @@ -141,7 +135,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -150,7 +144,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "10", "metadata": {}, "source": [ "These layers can be divided into:\n", @@ -165,7 +159,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -205,7 +199,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "12", "metadata": {}, "source": [ "Before we inspect the map objects, let's define some helper functions to visualize them." @@ -214,7 +208,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -288,7 +282,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "14", "metadata": {}, "source": [ "## 2.5 Map Objects in 123D\n", @@ -302,7 +296,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -337,7 +331,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "16", "metadata": {}, "source": [ "Lanes can have neighbors, which can either be accessed as IDs or directly. These neighbors include:\n", @@ -348,7 +342,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -385,7 +379,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "18", "metadata": {}, "source": [ "### 2.5.2 `LaneGroup`" @@ -393,7 +387,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "19", "metadata": {}, "source": [ "A lane can be part of a lane group. Lane groups are sets of lanes that go in the same direction. The lane group can be accessed from the lane directly." @@ -402,7 +396,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -429,7 +423,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "21", "metadata": {}, "source": [ "Lane groups are surfaces, with neighboring relationships. Let's sample another lane group and have a look:" @@ -438,7 +432,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -475,7 +469,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "23", "metadata": {}, "source": [ "### 2.5.3 `Intersection`\n", @@ -486,7 +480,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -511,7 +505,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "25", "metadata": {}, "source": [ "### 2.5.4 Other map surfaces\n", @@ -530,7 +524,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -567,7 +561,7 @@ }, { "cell_type": "markdown", - "id": "28", + "id": "27", "metadata": {}, "source": [ "### 2.5.5 `RoadEdge`\n", @@ -584,7 +578,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "28", "metadata": {}, "source": [ "\n", @@ -595,7 +589,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -605,7 +599,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "30", "metadata": {}, "source": [ "We will plot some road edges from our previous query. We will also add other drivable surfaces as polygons." @@ -614,7 +608,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -667,7 +661,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "32", "metadata": {}, "source": [ "### 2.5.6 `RoadLine`\n", @@ -678,7 +672,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -688,7 +682,7 @@ }, { "cell_type": "markdown", - "id": "35", + "id": "34", "metadata": {}, "source": [ "In some datasets, the road line is equivalent to left/right boundaries. A lane can have various road lines along its boundaries, making referencing to lanes difficult. We currently include that as separate map objects." @@ -697,7 +691,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -774,7 +768,7 @@ }, { "cell_type": "markdown", - "id": "37", + "id": "36", "metadata": {}, "source": [ "You made it to end. You can repeat the tutorial for different datasets and filtering settings." diff --git a/tutorials/03_visualizations_tutorial.ipynb b/tutorials/03_visualizations_tutorial.ipynb index fd6ffe2f..eee28ea2 100644 --- a/tutorials/03_visualizations_tutorial.ipynb +++ b/tutorials/03_visualizations_tutorial.ipynb @@ -35,22 +35,14 @@ "id": "2", "metadata": {}, "source": [ - "## 3.1 Download Demo Logs" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "# TODO" + "## 3.1 Download Demo Logs\n", + "\n", + "You can download demo logs for 123D as described in the [documentation](https://autonomousvision.github.io/py123d/installation/). After the installation and download, you can start with the tutorial.\n" ] }, { "cell_type": "markdown", - "id": "4", + "id": "3", "metadata": {}, "source": [ "## 3.2 Create Scenes by filtering the datasets\n", @@ -61,7 +53,7 @@ { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -77,12 +69,16 @@ ")\n", "worker = SingleMachineParallelExecutor()\n", "scenes = ArrowSceneBuilder().get_scenes(scene_filter, worker)\n", - "print(f\"Found {len(scenes)} scenes.\")" + "\n", + "dataset_splits = set(scene.log_metadata.split for scene in scenes)\n", + "print(f\"Found {len(scenes)} scenes from {len(dataset_splits)} datasplits:\")\n", + "for split in dataset_splits:\n", + " print(f\" - {split}\")" ] }, { "cell_type": "markdown", - "id": "6", + "id": "5", "metadata": {}, "source": [ "## 3.3 Matplotlib\n" @@ -90,16 +86,18 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "6", "metadata": {}, "source": [ - "### 3.3.1 Plots in 2D\n" + "### 3.3.1 Plots in 2D\n", + "\n", + "Below, we added some standard plot to visualize an iteration of a scene in birds-eye-view:" ] }, { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -112,6 +110,14 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "We can also render an animation of the scene with the `render_scene_animation` function. You can use `mp4` or `gif` formats. The current scene will be saved in `./visualization`." + ] + }, { "cell_type": "code", "execution_count": null, @@ -146,7 +152,9 @@ "id": "10", "metadata": {}, "source": [ - "### 3.3.2 Plots of Cameras" + "### 3.3.2 Plots of Cameras\n", + "\n", + "Below, we have some plot to visualize camera observations with matplotlib. In the simples form, you can retrieve a random camera and add it to a plot:" ] }, { @@ -170,10 +178,18 @@ " plt.show()" ] }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "You can also visualize the bounding boxes in overlaid in the image with:" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -193,10 +209,18 @@ " plt.show()" ] }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "The same applies for the LiDAR point cloud. Here we add the points to the image of a random camera and LiDAR scanner. The color map reflects the distance to the ego vehicle." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -205,7 +229,7 @@ "available_pinhole_cameras = scene.available_pinhole_camera_types\n", "available_lidars = scene.available_lidar_types\n", "\n", - "iteration = 20\n", + "iteration = 0\n", "if len(available_pinhole_cameras) > 1 and len(available_lidars) > 0:\n", " pinhole_camera_type = np.random.choice(available_pinhole_cameras)\n", " pinhole_camera = scene.get_pinhole_camera_at_iteration(iteration=iteration, camera_type=pinhole_camera_type)\n", @@ -220,24 +244,25 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "16", "metadata": {}, "source": [ "## 3.4. Viser\n", "\n", - "Can also be run in the `example/01_viser.py` or by running \n", - "\n", + "You can visualize a scene in 3D with our viser viewer. \n", + "You can also run viser with:\n", "```sh\n", "py123d-viser scene_filter=...\n", "```\n", + "By default, you can open viewer via: [`http://localhost:8080`](http://localhost:8080 )\n", "\n", - "in your terminal. By default, you can open viewer via: [`http://localhost:8080`](http://localhost:8080 )\n" + "Check out the [viser documentation](https://viser.studio/main/) for further information." ] }, { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -245,22 +270,6 @@ "\n", "ViserViewer(scenes)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { From c64aed2a1389e7f599415010ffe77e0d385b24fe Mon Sep 17 00:00:00 2001 From: Daniel Dauner Date: Sat, 22 Nov 2025 00:09:34 +0100 Subject: [PATCH 50/50] Change workflow branches to `main`. --- .github/workflows/deploy-docs.yaml | 2 +- .github/workflows/pre-commit.yaml | 2 +- .github/workflows/pytest.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 40c83866..bc90412f 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -3,7 +3,7 @@ name: docs on: push: branches: - - dev_v0.0.8 # Change this to your branch name (e.g., docs, dev, etc.) + - main workflow_dispatch: # Allows manual triggering permissions: diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 1d1b74a2..2b11178b 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -3,7 +3,7 @@ name: pre-commit on: pull_request: push: - branches: [dev_v0.0.8] + branches: [main] jobs: pre-commit: diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index d0c101ad..399d5ee6 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -2,7 +2,7 @@ name: pytest on: push: - branches: [dev_v0.0.8] + branches: [main] jobs: build: