"""
Scene Editor Module
===================
This module provides the main entry point for scene manipulation through the
:class:`SceneEditor` class. It orchestrates all editing operations on scenes,
actors, sensors, and the ego vehicle.
The ``SceneEditor`` is the central hub that provides:
- Access to sub-editors (:class:`~mimic_scene_edition.editors.ego_editor.EgoEditor`,
:class:`~mimic_scene_edition.editors.actor_editor.ActorEditor`,
:class:`~mimic_scene_edition.editors.sensor_editor.SensorEditor`)
- Scene-level operations (reset, actor insertion/removal)
- Actor manipulation shortcuts
- Sensor access and configuration
- Modification tracking
In scripts sent to ``/scene/python_scene_edition``, the ``SceneEditor`` is
available as the ``scene`` variable.
Example
-------
Basic scene manipulation workflow::
# Access via the 'scene' variable in edition scripts
# Edit the ego vehicle
scene.ego.shift(forward=5).rotate(yaw=15)
# Edit an actor
scene.actor(4).displace(forward=10).shift(left=2)
# Edit a sensor
scene.camera("FRONT").shift(up=0.05).rotate(pitch=-2)
# Insert a new actor on ego trajectory
new_id = scene.insert_actor_on_ego_trajectory(4, forward=20, left=1.5)
# Get all modifications
mods = scene.get_modifications()
See Also
--------
:class:`~mimic_scene_edition.editors.ego_editor.EgoEditor` : Ego vehicle editor
:class:`~mimic_scene_edition.editors.actor_editor.ActorEditor` : Individual actor editor
:class:`~mimic_scene_edition.editors.sensor_editor.SensorEditor` : Sensor editor
:class:`~mimic_scene_edition.editors.trajectory_editor.TrajectoryEditor` : Low-level trajectory editor
"""
from __future__ import annotations
from copy import deepcopy
from functools import cached_property
from pathlib import Path
from typing import Mapping
import numpy as np
from scipy.spatial import cKDTree
from mimic_data_api.scene import Scene
from mimic_data_api.actor import Actor
from mimic_data_api.camera import Camera
from mimic_data_api.lidar import Lidar
from mimic_data_api.radar import Radar
# One definition of "which scene actor does this name mean", shared with the
# recorder. `oplist.ir` is a stdlib-only leaf module; importing it here is what
# keeps `actor("actor_21")` from meaning one thing while recording and another
# thing while editing, which is the property the equivalence lane rests on.
from mimic_scene_edition.oplist.ir import scene_actor_index
from mimic_scene_edition.editors.base import log_method, check_value_limits, EditionDict
from mimic_scene_edition.editors.ego_editor import EgoEditor
from mimic_scene_edition.editors.sensor_editor import SensorEditor
from mimic_scene_edition.editors.actor_editor import ActorEditor, RemovedActorEditor
from mimic_scene_edition.editors.trajectory_editor import TrajectoryEditor
from mimic_scene_edition.core import trajectory
from mimic_scene_edition.core.limits import (
DEFAULT_VALUE_LIMITS,
DEFAULT_EGO_VALUE_LIMITS,
DEFAULT_SCENE_ACTOR_VALUE_LIMITS,
DEFAULT_EXTERNAL_ACTOR_VALUE_LIMITS,
)
from mimic_scene_edition.core.velocity import (
recompute_velocities,
estimate_velocities_world,
)
from mimic_scene_edition.utils.sampling import find_point_offset_by_distance
__all__ = ["SceneEditor"]
[docs]
class SceneEditor:
"""
Main entry point for scene manipulation.
The ``SceneEditor`` provides a unified interface for editing all aspects of
a driving scene, including the ego vehicle, actors (other vehicles, pedestrians,
etc.), and sensors (cameras, LiDARs, radars).
Parameters
----------
scene : Scene
The scene object to edit.
limited_mode : bool, optional
If True, enforce value limits on parameters to prevent extreme edits.
Default is False.
value_limits : dict[str, tuple[float, float]] or None, optional
Custom parameter limits as ``{param_name: (min, max)}``.
If None, uses :data:`DEFAULT_VALUE_LIMITS`.
Attributes
----------
scene : Scene
The underlying scene object being edited.
ego : EgoEditor
Editor for the ego vehicle and attached sensors.
editions : list[EditionDict]
List of all modifications applied to the scene.
limited_mode : bool
Whether value limiting is enabled.
value_limits : dict
Current parameter limits.
Notes
-----
The ``SceneEditor`` maintains a backup of the original scene state, which
can be restored using :meth:`reset`. All modifications are tracked in the
:attr:`editions` list for logging and debugging purposes.
Actor IDs are preserved across edits. When inserting new actors, unique IDs
are automatically assigned.
Examples
--------
Create an editor and perform basic operations::
>>> editor = SceneEditor(scene)
>>> editor.ego.shift(forward=5)
>>> editor.actor(4).rotate(yaw=90)
>>> editor.camera("FRONT").shift(up=0.1)
Use limited mode for safer edits::
>>> editor = SceneEditor(scene, limited_mode=True)
>>> editor.ego.shift(forward=100) # May raise if exceeds limits
Chain multiple operations::
>>> scene.ego.shift(forward=5).rotate(yaw=15).stop(at_idx=50)
We keep an original id for each actor that originates in the scene::
>>> actor_0 = scene.actor(0).actor
>>> print("ID before replacement:", scene.actor(4).actor.original_id)
>>> scene.replace_actor(4, actor_0)
>>> print("ID after replacement:", scene.actor(4).actor.original_id)
See Also
--------
:meth:`reset` : Restore original scene state
:meth:`get_modifications` : Get list of all edits
:meth:`as_scene` : Get the underlying Scene object
"""
[docs]
def __init__(
self,
scene: Scene,
*,
limited_mode: bool = False,
value_limits: dict[str, tuple[float, float]] | None = None,
ego_value_limits: dict[str, tuple[float, float]] | None = None,
scene_actor_value_limits: dict[str, tuple[float, float]] | None = None,
external_actor_value_limits: dict[str, tuple[float, float]] | None = None,
):
self.scene = scene
# for actor edition we need to make sure we keep track of original_id
for actor in self.scene.actors:
actor.original_id = (
actor.original_id if actor.original_id is not None else actor.id
)
self.limited_mode = limited_mode
up_vectors = [
np.asarray(actor.trajectory.object2world)[:, :3, 2]
for actor in self.scene.actors
] or [np.asarray(self.scene.ego_vehicle.trajectory.object2world)[:, :3, 2]]
self._scene_up = np.median(np.concatenate(up_vectors), axis=0)
self._scene_up /= np.linalg.norm(self._scene_up)
# Per-category limits. If the caller supplied the legacy single
# ``value_limits`` dict, use it as the default for all three
# categories; otherwise fall back to the dedicated defaults.
fallback = value_limits
self.ego_value_limits = ego_value_limits or (
fallback.copy() if fallback else DEFAULT_EGO_VALUE_LIMITS.copy()
)
self.scene_actor_value_limits = scene_actor_value_limits or (
fallback.copy() if fallback else DEFAULT_SCENE_ACTOR_VALUE_LIMITS.copy()
)
# External actors always get their own dedicated defaults regardless of the
# legacy ``value_limits`` fallback – they are a distinct category with
# intentionally wider limits.
self.external_actor_value_limits = (
external_actor_value_limits or DEFAULT_EXTERNAL_ACTOR_VALUE_LIMITS.copy()
)
# Backward-compatible attribute (points to ego limits).
self.value_limits = self.ego_value_limits
self._bak = deepcopy(scene)
self._editions: list[EditionDict] = []
# Ids this editor took out of the scene. ``Removed`` is sticky, so a
# handle that outlives its actor still resolves — to a no-op editor
# rather than to a ``KeyError``. An id the scene hands out again wins:
# ``actor`` looks the scene up first, so a re-inserted actor is live.
self._removed_actor_ids: set[int] = set()
# Renderer-supplied hook: fn(actor_id: int | None, enabled: bool) -> None.
# ``actor_id is None`` means the whole scene. Left as None in a pure
# offline editor (edits are still recorded in ``editions``); the render
# pipeline installs a real sink so the toggle flips in place. See
# ``object_appearance`` / ``_apply_object_appearance``.
self._object_appearance_sink = None
self._ego_editor = EgoEditor(
self.scene,
limited_mode=self.limited_mode,
value_limits=self.ego_value_limits,
editions=self._editions,
)
# ==================== Properties ====================
@property
def editions(self) -> list[EditionDict]:
"""
List of all modifications applied to the scene.
Returns
-------
list[EditionDict]
Each entry contains ``method``, ``params``, and ``description`` keys
documenting an individual edit operation.
Examples
--------
>>> scene.ego.shift(forward=5)
>>> scene.actor(4).rotate(yaw=90)
>>> for edit in scene.editions:
... print(edit['description'])
"""
return self._editions
@property
def ego(self) -> EgoEditor:
"""
Editor for the ego vehicle.
Returns
-------
EgoEditor
The ego vehicle editor instance. All sensor positions are relative
to the ego, so ego transformations affect the entire sensor rig.
Examples
--------
>>> scene.ego.shift(forward=5, left=2)
>>> scene.ego.rotate(yaw=15)
>>> scene.ego.stop(at_idx=50, duration=20)
See Also
--------
:class:`~mimic_scene_edition.editors.ego_editor.EgoEditor` : Full ego editor API
"""
return self._ego_editor
# TODO later, probable makes more sense to have it this way:
# @property
# def ego(self) -> EgoEditor:
# return self._ego_editor.ego
# @property
# def ego_editor(self) -> EgoEditor:
# return self._ego_editor
@property
def _actors_ids(self) -> list[int]:
"""List of all current actor IDs in the scene."""
return [a.id for a in self.scene.actors]
# ==================== Actor Access ====================
[docs]
def actor(self, ref: int | str | Actor) -> ActorEditor:
"""
Get an editor for a specific actor.
Parameters
----------
ref : int, str or Actor
Actor ID (int), a name ending in it (``"actor_21"``), or an Actor
object.
Returns
-------
ActorEditor
Editor instance for the specified actor.
Raises
------
KeyError
If no actor exists with the given ID and none was ever removed
under it.
Notes
-----
An id this editor removed resolves to a
:class:`~mimic_scene_edition.editors.actor_editor.RemovedActorEditor`:
``Removed`` is sticky, so a handle held past the removal still answers,
and every op through it is a no-op. An id that was never in the scene
still raises.
Examples
--------
Access by ID::
>>> scene.actor(4).shift(left=2)
Access by object::
>>> actor_obj = scene.scene.actors[0]
>>> scene.actor(actor_obj).rotate(yaw=90)
Method chaining::
>>> scene.actor(4).displace(forward=10).shift(left=2).stop(at_idx=50)
See Also
--------
:meth:`actors` : Get editors for all actors
:class:`~mimic_scene_edition.editors.actor_editor.ActorEditor` : Full actor editor API
"""
if isinstance(ref, Actor):
actor_id = ref.id
return ActorEditor(
self,
actor_id=actor_id,
limited_mode=self.limited_mode,
value_limits=self._actor_value_limits(actor_id),
editions=self._editions,
)
ref = self._scene_actor_ref(ref)
if ref not in self._actors_ids and ref in self._removed_actor_ids:
return RemovedActorEditor(
self,
actor_id=ref,
limited_mode=self.limited_mode,
editions=self._editions,
)
else:
actor_id = self._id_from_ref(ref)
return ActorEditor(
self,
actor_id=actor_id,
limited_mode=self.limited_mode,
value_limits=self._actor_value_limits(actor_id),
editions=self._editions,
)
[docs]
def actors(self) -> list[ActorEditor]:
"""
Get editors for all actors in the scene.
Returns
-------
list[ActorEditor]
List of actor editors, one for each actor.
Examples
--------
Apply same transformation to all actors::
>>> for actor_ed in scene.actors():
... actor_ed.shift(up=0.1)
Filter and modify specific actors::
>>> for actor_ed in scene.actors():
... if actor_ed.actor.category == "vehicle":
... actor_ed.speed(multiplier=0.5)
See Also
--------
:meth:`actor` : Get editor for a specific actor
:meth:`actors_by_id` : Get actors as ID-indexed mapping
"""
return [self.actor(a.id) for a in self.scene.actors]
[docs]
def actors_by_id(self) -> Mapping[int, Actor]:
"""
Get all actors indexed by their ID.
Returns
-------
Mapping[int, Actor]
Dictionary mapping actor IDs to Actor objects.
Examples
--------
>>> actors_map = scene.actors_by_id()
>>> actor_4 = actors_map[4]
>>> print(f"Actor 4 category: {actor_4.category}")
"""
return {a.id: a for a in self.scene.actors}
# ==================== Sensor Access ====================
[docs]
def camera(self, ref: int | str | Camera) -> SensorEditor:
"""
Get an editor for a specific camera.
Parameters
----------
ref : int, str, or Camera
Camera reference - can be:
- ``int``: Camera ID or index
- ``str``: Camera name (e.g., "FRONT", "FRONT_LEFT")
- ``Camera``: Camera object directly
Returns
-------
SensorEditor
Sensor editor instance for the camera.
Raises
------
KeyError
If no camera matches the reference.
Examples
--------
Access by name::
>>> scene.camera("FRONT").shift(up=0.05)
Access by ID::
>>> scene.camera(0).rotate(pitch=-2)
Method chaining::
>>> scene.camera("FRONT").shift(up=0.05).rotate(pitch=-1).scale_camera_resolution(0.5)
See Also
--------
:meth:`cameras` : Get editors for all cameras
:meth:`lidar` : Get editor for a LiDAR
:meth:`radar` : Get editor for a radar
"""
return self._resolve_sensor("camera", ref)
[docs]
def lidar(self, ref: int | str | Lidar) -> SensorEditor:
"""
Get an editor for a specific LiDAR.
Parameters
----------
ref : int, str, or Lidar
LiDAR reference - can be:
- ``int``: LiDAR ID or index
- ``str``: LiDAR name (e.g., "LIDAR_TOP")
- ``Lidar``: Lidar object directly
Returns
-------
SensorEditor
Sensor editor instance for the LiDAR.
Raises
------
KeyError
If no LiDAR matches the reference.
Examples
--------
>>> scene.lidar("LIDAR_TOP").shift(up=0.1)
>>> scene.lidar(0).set_lidar_intrinsics(max_range=150)
See Also
--------
:meth:`lidars` : Get editors for all LiDARs
:meth:`camera` : Get editor for a camera
"""
return self._resolve_sensor("lidar", ref)
[docs]
def radar(self, ref: int | str | Radar) -> SensorEditor:
"""
Get an editor for a specific radar.
Parameters
----------
ref : int, str, or Radar
Radar reference - can be:
- ``int``: Radar ID or index
- ``str``: Radar name (e.g., "RADAR_FRONT")
- ``Radar``: Radar object directly
Returns
-------
SensorEditor
Sensor editor instance for the radar.
Raises
------
KeyError
If no radar matches the reference.
Examples
--------
>>> scene.radar("RADAR_FRONT").set_radar_intrinsics(range=200)
See Also
--------
:meth:`radars` : Get editors for all radars
:meth:`camera` : Get editor for a camera
"""
return self._resolve_sensor("radar", ref)
[docs]
def cameras(self) -> list[SensorEditor]:
"""
Get editors for all cameras in the scene.
Returns
-------
list[SensorEditor]
List of sensor editors, one for each camera.
Examples
--------
Scale all cameras to half resolution::
>>> for cam in scene.cameras():
... cam.scale_camera_resolution(0.5)
See Also
--------
:meth:`camera` : Get editor for a specific camera
"""
cams = self.scene.ego_vehicle.sensors.cameras or []
return [self.camera(c.id) for c in cams]
[docs]
def lidars(self) -> list[SensorEditor]:
"""
Get editors for all LiDARs in the scene.
Returns
-------
list[SensorEditor]
List of sensor editors, one for each LiDAR.
Examples
--------
>>> for lidar in scene.lidars():
... lidar.set_lidar_intrinsics(max_range=200)
See Also
--------
:meth:`lidar` : Get editor for a specific LiDAR
"""
lids = self.scene.ego_vehicle.sensors.lidars or []
return [self.lidar(l.id) for l in lids]
[docs]
def radars(self) -> list[SensorEditor]:
"""
Get editors for all radars in the scene.
Returns
-------
list[SensorEditor]
List of sensor editors, one for each radar.
Examples
--------
>>> for radar in scene.radars():
... radar.set_radar_intrinsics(azimuth_fov=2.0)
See Also
--------
:meth:`radar` : Get editor for a specific radar
"""
rads = self.scene.ego_vehicle.sensors.radars or []
return [self.radar(r.id) for r in rads]
# ==================== Mode Configuration ====================
[docs]
def switch_limited_mode(
self,
limited_mode: bool,
new_limits_or_self=None,
*,
ego_value_limits: dict[str, tuple[float, float]] | None = None,
scene_actor_value_limits: dict[str, tuple[float, float]] | None = None,
external_actor_value_limits: dict[str, tuple[float, float]] | None = None,
) -> "SceneEditor":
"""
Enable or disable limited mode and optionally update limits.
Parameters
----------
limited_mode : bool
Whether to enable value limiting.
new_limits_or_self : dict or None, optional
Legacy: single limits dict applied to **all** categories.
If None, keeps existing limits.
ego_value_limits : dict or None, optional
New ego limits. If None, keeps existing ego limits.
scene_actor_value_limits : dict or None, optional
New scene-actor limits. If None, keeps existing.
external_actor_value_limits : dict or None, optional
New external-actor limits. If None, keeps existing.
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
Enable limited mode::
>>> scene.switch_limited_mode(True)
Enable with custom limits::
>>> custom_limits = {'forward': (0, 50), 'yaw': (-45, 45)}
>>> scene.switch_limited_mode(True, custom_limits)
Disable limited mode::
>>> scene.switch_limited_mode(False)
"""
self.limited_mode = limited_mode
if new_limits_or_self is not None:
self.ego_value_limits = new_limits_or_self
self.scene_actor_value_limits = new_limits_or_self
# Do NOT apply the legacy single-dict to external actors — they
# are a distinct category with intentionally wider limits.
if ego_value_limits is not None:
self.ego_value_limits = ego_value_limits
if scene_actor_value_limits is not None:
self.scene_actor_value_limits = scene_actor_value_limits
if external_actor_value_limits is not None:
self.external_actor_value_limits = external_actor_value_limits
self.value_limits = self.ego_value_limits
self._ego_editor.limited_mode = limited_mode
self._ego_editor.value_limits = self.ego_value_limits
return self
# ==================== Scene Operations ====================
[docs]
def reset(self) -> "SceneEditor":
"""
Reset the scene to its original state.
Undoes all modifications and clears the editions list. The scene is
restored to the state it was in when the ``SceneEditor`` was created.
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
>>> scene.ego.shift(forward=100)
>>> scene.actor(4).rotate(yaw=180)
>>> # Undo all changes
>>> scene.reset()
>>> len(scene.editions)
0
"""
self.scene = deepcopy(self._bak)
self._editions.clear()
self._removed_actor_ids.clear()
self._ego_editor = EgoEditor(
self.scene,
limited_mode=self.limited_mode,
value_limits=self.ego_value_limits,
editions=self._editions,
)
return self
[docs]
def recompute_all_sensor_world_poses(self) -> "SceneEditor":
"""
Recompute world poses for all sensors.
Updates the world trajectory for all cameras, LiDARs, and radars based
on current ego trajectory and sensor extrinsics. Call this after modifying
ego trajectory or sensor extrinsics to ensure consistency.
The world pose is computed as::
S2W(t) = E2W(t) @ S2E
Where:
- ``S2W``: Sensor-to-world transform (the result)
- ``E2W``: Ego-to-world transform (interpolated to sensor timestamps)
- ``S2E``: Sensor-to-ego extrinsics
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
>>> scene.ego.shift(forward=10)
>>> scene.recompute_all_sensor_world_poses()
See Also
--------
:meth:`SensorEditor.recompute_world_poses` : Recompute for individual sensor
"""
for ed in [*self.cameras(), *self.lidars(), *self.radars()]:
ed.recompute_world_poses()
return self
# ==================== Sensor Convenience Methods ====================
[docs]
def convert_camera_convention(
self,
ref: int | str | Camera,
*,
from_convention: str,
to_convention: str = "vehicle",
) -> "SceneEditor":
"""
Convert a camera's extrinsics between coordinate conventions.
This is a convenience wrapper that converts convention and recomputes
world poses in a single call.
Parameters
----------
ref : int, str, or Camera
Camera reference (ID, name, or object).
from_convention : str
Current convention: ``"vehicle"`` or ``"opencv"``.
to_convention : str, optional
Target convention. Default is ``"vehicle"``.
Returns
-------
SceneEditor
Self for method chaining.
Notes
-----
Convention definitions:
- **Vehicle**: X-forward, Y-left, Z-up
- **OpenCV**: X-right, Y-down, Z-forward
Examples
--------
>>> scene.convert_camera_convention("FRONT", from_convention="opencv")
See Also
--------
:meth:`SensorEditor.convert_convention` : Full convention conversion API
"""
self.camera(ref).convert_convention(
from_convention=from_convention, to_convention=to_convention
).recompute_world_poses()
return self
[docs]
def normalize_camera_conventions(self, to: str) -> "SceneEditor":
"""
Normalize all camera extrinsics to a consistent convention.
Parameters
----------
to : str
Target convention for all cameras: ``"vehicle"`` or ``"opencv"``.
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
Convert all cameras to vehicle convention::
>>> scene.normalize_camera_conventions(to="vehicle")
See Also
--------
:meth:`EgoEditor.normalize_camera_conventions` : Underlying implementation
"""
self.ego.normalize_camera_conventions(to=to)
return self
[docs]
def scale_camera(
self,
ref: int | str | Camera,
scale: float = 1.0,
) -> "SceneEditor":
"""
Scale a camera's resolution.
Adjusts both the image dimensions and intrinsic parameters (focal length,
principal point) proportionally.
Parameters
----------
ref : int, str, or Camera
Camera reference (ID, name, or object).
scale : float, optional
Scale factor. Default is 1.0 (no change).
- ``0.5``: Half resolution
- ``2.0``: Double resolution
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
>>> scene.scale_camera("FRONT", scale=0.5) # Half resolution
>>> scene.scale_camera("FRONT", scale=2.0) # Double resolution
See Also
--------
:meth:`SensorEditor.scale_camera_resolution` : Full scaling API
"""
return self.camera(ref).scale_camera_resolution(scale=scale)
# ==================== Actor Insertion ====================
[docs]
@log_method
def insert_actor(self, actor: Actor, *, lidar_source: int = -1) -> int:
"""
Insert an actor into the scene.
The actor is deep-copied and assigned a new unique ID.
Parameters
----------
actor : Actor
The actor to insert.
lidar_source : int, optional
ID of another actor to use as the lidar source for this actor.
``-1`` means the source is selected automatically at runtime.
Default is ``-1``.
Returns
-------
int
The ID assigned to the newly inserted actor.
Examples
--------
>>> new_actor = existing_actor.model_copy(deep=True)
>>> new_id = scene.insert_actor(new_actor)
>>> scene.actor(new_id).shift(forward=10)
See Also
--------
:meth:`insert_actor_on_ego_trajectory` : Insert relative to ego
:meth:`insert_static_actor` : Insert a stationary actor
:meth:`duplicate_actor` : Duplicate an existing actor
"""
a = actor.model_copy(deep=True)
# if a.id in self._actor_idx:
a.id = self._next_actor_id()
if hasattr(a, "lidar_source"):
a.lidar_source = lidar_source # type: ignore[attr-defined]
# The copy carries the velocity arrays of the scene this actor was authored in
lin, ang = estimate_velocities_world(a.trajectory)
a.trajectory.linear_velocity = np.nan_to_num(lin)
a.trajectory.angular_velocity = np.nan_to_num(ang)
self.scene.actors.append(a)
# self._reindex_actors()
return a.id
[docs]
@log_method
def remove_actor(self, actor_id: int | str) -> Actor:
"""
Remove an actor from the scene.
Parameters
----------
actor_id : int or str
ID of the actor to remove, or a name ending in it.
Returns
-------
Actor
The removed actor object.
Raises
------
KeyError
If no actor exists with the given ID.
Examples
--------
>>> removed = scene.remove_actor(4)
>>> print(f"Removed actor: {removed.category}")
The id may also be written as the name that carries it::
>>> removed = scene.remove_actor("actor_4")
"""
idx = self._idx_from_ref(self._scene_actor_ref(actor_id))
removed = self.scene.actors.pop(idx)
self._removed_actor_ids.add(removed.id)
# self._reindex_actors()
return removed
[docs]
@log_method
def remove_all_actors(self) -> list[Actor]:
"""
Remove all actors from the scene.
Returns
-------
list[Actor]
List of all removed actors.
Examples
--------
>>> removed_actors = scene.remove_all_actors()
>>> print(f"Removed {len(removed_actors)} actors")
"""
removed = self.scene.actors[:]
self._removed_actor_ids.update(a.id for a in removed)
self.scene.actors.clear()
# self._reindex_actors()
return removed
[docs]
@check_value_limits
@log_method
def duplicate_actor(
self,
actor_id: int | Actor,
*,
forward: float = 0.0,
backward: float = 0.0,
left: float = 0.0,
right: float = 0.0,
) -> int:
"""
Duplicate an actor with optional displacement.
Creates a deep copy of the actor and optionally displaces it along its
trajectory path and/or laterally.
Parameters
----------
actor_id : int or Actor
Actor to duplicate (ID or object).
forward : float, optional
Distance to displace forward along the path (meters). Default is 0.0.
backward : float, optional
Distance to displace backward along the path (meters). Default is 0.0.
left : float, optional
Lateral shift to the left (meters). Default is 0.0.
right : float, optional
Lateral shift to the right (meters). Default is 0.0.
Returns
-------
int
ID of the newly created actor.
Examples
--------
Simple duplication::
>>> new_id = scene.duplicate_actor(4)
Duplicate 20m ahead::
>>> new_id = scene.duplicate_actor(4, forward=20)
Duplicate in adjacent lane::
>>> new_id = scene.duplicate_actor(4, left=3.5)
Combined displacement::
>>> new_id = scene.duplicate_actor(4, forward=30, left=3.5)
See Also
--------
:meth:`ActorEditor.duplicate` : Actor-level duplication
:meth:`insert_actor` : Insert a new actor
"""
if isinstance(actor_id, int):
base_actor = self.actor(actor_id).actor
elif isinstance(actor_id, Actor):
base_actor = actor_id
dup = base_actor.model_copy(deep=True)
dup.original_id = dup.original_id
# displace along path, then shift laterally
has_arc = abs(forward - backward) > 1e-12
has_lateral = abs(left - right) > 1e-12
if has_arc or has_lateral:
te = self._make_trajectory_editor(
dup.trajectory,
value_limits=self.external_actor_value_limits
if isinstance(actor_id, Actor)
else self._actor_value_limits(base_actor),
)
if has_arc:
te.displace(forward=forward, backward=backward)
if has_lateral:
te.transform(left=left, right=right, referential="local")
dup.trajectory = te.traj
dup.id = self._next_actor_id()
if hasattr(dup, "lidar_source"):
dup.lidar_source = base_actor.id
self.scene.actors.append(dup)
return dup.id
[docs]
@log_method
def replace_actor(self, actor_id: int, new_actor: Actor) -> "SceneEditor":
"""
Replace an actor with a different actor.
The new actor inherits the old actor's trajectory, with Z-offset adjusted
for different heights.
Parameters
----------
actor_id : int
ID of the actor to replace.
new_actor : Actor
The replacement actor.
Returns
-------
SceneEditor
Self for method chaining.
Notes
-----
The Z position is automatically adjusted based on the height difference
between the old and new actors to maintain ground contact.
Examples
--------
>>> # Replace a vehicle with a truck
>>> scene.replace_actor(4, truck_actor)
"""
idx = self._idx_from_ref(actor_id)
old = self.scene.actors[idx]
na = new_actor.model_copy(deep=True)
z_off = (self._height(na) - self._height(old)) * 0.5
traj = (
trajectory.shift_world(old.trajectory, np.array([0.0, 0.0, z_off]))
if z_off
else old.trajectory
)
na.trajectory = traj
na.id = old.id
# Preserve scene lineage only when the replacement is not itself an
# external actor. If the caller passes an external actor (original_id
# is None) the external identity must be kept so that the correct
# (looser) value-limits are applied to subsequent edits.
if not self._is_external_actor(na):
na.original_id = getattr(old, "original_id", old.id)
self.scene.actors[idx] = na
return self
[docs]
@log_method
def swap_actors(self, a_idx: int, b_idx: int) -> "SceneEditor":
"""
Swap the positions of two actors.
Each actor takes the other's trajectory, with Z-offsets adjusted for
different heights.
Parameters
----------
a_idx : int
Index of the first actor.
b_idx : int
Index of the second actor.
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
>>> scene.swap_actors(0, 1)
"""
if a_idx == b_idx:
return self
A, B = self.scene.actors[a_idx], self.scene.actors[b_idx]
hA, hB = self._height(A), self._height(B)
# swap trajectories with Z center adjustments
A_tr = trajectory.shift_world(
B.trajectory, np.array([0.0, 0.0, (hA - hB) * 0.5])
)
B_tr = trajectory.shift_world(
A.trajectory, np.array([0.0, 0.0, (hB - hA) * 0.5])
)
A2, B2 = A.model_copy(deep=True), B.model_copy(deep=True)
A2.trajectory, B2.trajectory = A_tr, B_tr
A2.dimensions, B2.dimensions = B2.dimensions, A2.dimensions
self.scene.actors[a_idx], self.scene.actors[b_idx] = B2, A2
return self
[docs]
@check_value_limits
@log_method
def offset_actor(self, actor_id: int | str, *, offset_frame: int) -> "SceneEditor":
"""
Shift an actor's timeline by a number of frames.
This is a convenience wrapper around :meth:`ActorEditor.offset_frames`.
Parameters
----------
actor_id : int or str
ID of the actor to offset, or a name ending in it.
offset_frame : int
Number of frames to shift (positive = later, negative = earlier).
Uses circular shifting.
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
>>> scene.offset_actor(4, offset_frame=10)
See Also
--------
:meth:`ActorEditor.offset_frames` : Underlying implementation
"""
self.actor(actor_id).offset_frames(int(offset_frame))
return self
[docs]
@check_value_limits
@log_method
def insert_actor_on_different_actor_trajectory(
self,
actor_id: int | Actor,
reference_actor_id: int,
*,
forward: float = 0.0,
backward: float = 0.0,
left: float = 0.0,
right: float = 0.0,
meters_behind: float = 0.0,
) -> int:
"""
Insert an actor positioned relative to another actor's trajectory.
The new actor follows the reference actor's path with optional offsets.
Parameters
----------
actor_id : int or Actor
Actor to insert (ID or object). This actor's appearance/category
is used, but trajectory comes from the reference.
reference_actor_id : int
ID of the actor whose trajectory to follow.
forward : float, optional
Arc-length displacement forward along path (meters). Default is 0.0.
backward : float, optional
Arc-length displacement backward along path (meters). Default is 0.0.
left : float, optional
Lateral shift to the left (meters). Default is 0.0.
right : float, optional
Lateral shift to the right (meters). Default is 0.0.
meters_behind : float, optional
World-space offset behind the reference (meters). This differs from
``backward`` in that it's applied in world coordinates at the
trajectory midpoint. Default is 0.0.
Returns
-------
int
ID of the newly inserted actor.
Examples
--------
Insert a vehicle following another vehicle::
>>> new_id = scene.insert_actor_on_different_actor_trajectory(
... 4, # source actor
... 5, # reference actor to follow
... forward=10,
... left=3.5
... )
See Also
--------
:meth:`insert_actor_on_ego_trajectory` : Insert relative to ego
:meth:`duplicate_actor` : Duplicate along own trajectory
"""
base = actor_id if isinstance(actor_id, Actor) else self.actor(actor_id).actor
ref_idx = self._idx_from_ref(reference_actor_id)
ref = self.scene.actors[ref_idx]
new_actor = base.model_copy(deep=True)
new_actor.id = self._next_actor_id()
if hasattr(new_actor, "lidar_source"):
new_actor.lidar_source = reference_actor_id
z_off = (self._height(new_actor) - self._height(ref)) * 0.5
limits = (
self.external_actor_value_limits
if isinstance(actor_id, Actor)
else self._actor_value_limits(actor_id)
)
te = self._make_trajectory_editor(ref.trajectory, value_limits=limits)
if meters_behind > 0:
traj = te.traj
mid = len(traj.timestamps) // 2
anchor_idx = find_point_offset_by_distance(traj, mid, meters_behind)
p_anchor = traj.object2world[anchor_idx, :3, 3]
p_mid = traj.object2world[mid, :3, 3]
d_world = p_anchor - p_mid # << behind = shift backward
te.traj = trajectory.shift_world(te.traj, d_world)
# Use displace() for arc-length, then shift for lateral offset
has_arc = abs(forward - backward) > 1e-12
has_lateral = abs(left - right) > 1e-12
if has_arc:
te.displace(forward=forward, backward=backward)
if has_lateral:
te.transform(left=left, right=right, referential="local")
if z_off:
te.traj = trajectory.shift_world(te.traj, np.array([0.0, 0.0, z_off]))
new_actor.trajectory = te.traj
self.scene.actors.append(new_actor)
# self._reindex_actors()
return new_actor.id
[docs]
@check_value_limits
@log_method
def insert_actor_on_ego_trajectory(
self,
actor_id: int | Actor,
*,
forward: float = 0.0,
backward: float = 0.0,
left: float = 0.0,
right: float = 0.0,
lidar_source: int = -1,
) -> int:
"""
Insert an actor positioned relative to the ego vehicle's trajectory.
The new actor follows the ego's path with optional longitudinal and
lateral offsets. This is useful for placing vehicles ahead of or
behind the ego in the same or adjacent lanes.
Parameters
----------
actor_id : int or Actor
Actor to insert (ID or object).
forward : float, optional
Distance to move forward along the ego's trajectory (meters).
Default is 0.0.
backward : float, optional
Distance to move backward along the ego's trajectory (meters).
Default is 0.0.
left : float, optional
Lateral shift to the left (meters). Default is 0.0.
right : float, optional
Lateral shift to the right (meters). Default is 0.0.
lidar_source : int, optional
ID of another actor to use as the lidar source for this actor.
``-1`` means the source is selected automatically at runtime.
Default is ``-1``.
Returns
-------
int
ID of the newly inserted actor.
Notes
-----
The Z position is automatically adjusted based on the actor's height
to place its bottom at the ego's ground level.
Examples
--------
Place actor 20m ahead of ego::
>>> new_id = scene.insert_actor_on_ego_trajectory(4, forward=20)
Place actor in adjacent lane::
>>> new_id = scene.insert_actor_on_ego_trajectory(4, forward=20, left=3.5)
Place actor behind ego::
>>> new_id = scene.insert_actor_on_ego_trajectory(4, backward=15)
See Also
--------
:meth:`insert_static_actor` : Insert a stationary actor
:meth:`insert_and_stop_actor_on_ego_trajectory` : Insert and immediately stop
"""
base = actor_id if isinstance(actor_id, Actor) else self.actor(actor_id).actor
ego = self.scene.ego_vehicle
new_actor = base.model_copy(deep=True)
new_actor.id = self._next_actor_id()
z_off = self._height(new_actor) * 0.5 # assume ego is at ground level
limits = (
self.external_actor_value_limits
if isinstance(actor_id, Actor)
else self._actor_value_limits(actor_id)
)
te = self._make_trajectory_editor(ego.trajectory, value_limits=limits)
# displace() for arc-length, then shift for lateral offset
has_arc = abs(forward - backward) > 1e-12
has_lateral = abs(left - right) > 1e-12
if has_arc:
te.displace(forward=forward, backward=backward)
if has_lateral:
te.transform(left=left, right=right, referential="local")
if self._is_external_actor(new_actor):
te.traj = self._align_trajectory_to_scene_up(te.traj)
te.traj = self._ground_actor_trajectory(
te.traj,
ground_offset=z_off,
fallback_z_offset=z_off,
)
new_actor.trajectory = te.traj
if hasattr(new_actor, "lidar_source"):
new_actor.lidar_source = lidar_source # type: ignore[attr-defined]
self.scene.actors.append(new_actor)
return new_actor.id
[docs]
@check_value_limits
@log_method
def insert_static_actor(
self,
actor_id: int | Actor,
*,
at_idx: int = 0, # position at that frame index of ego trajectory
forward: float = 0.0,
backward: float = 0.0,
left: float = 0.0,
right: float = 0.0,
up: float = 0.0,
down: float = 0.0,
yaw: float = 0.0,
use_ego_timegrid: bool = True,
ground: bool = True,
lidar_source: int = -1,
) -> int:
"""
Insert a static (non-moving) actor at a position relative to ego.
The actor is placed at a fixed world pose derived from the ego's pose
at a specific frame index, then offset by the specified amounts.
Parameters
----------
actor_id : int or Actor
Actor to insert (ID or object).
at_idx : int, optional
Frame index on ego trajectory for reference position. Default is 0.
forward : float, optional
Forward offset from ego position (meters). Default is 0.0.
backward : float, optional
Backward offset from ego position (meters). Default is 0.0.
left : float, optional
Leftward offset from ego position (meters). Default is 0.0.
right : float, optional
Rightward offset from ego position (meters). Default is 0.0.
up : float, optional
Upward offset (meters). Default is 0.0.
down : float, optional
Downward offset (meters). Default is 0.0.
yaw : float, optional
Rotation around Z-axis relative to ego heading (degrees). Default is 0.0.
use_ego_timegrid : bool, optional
If True, use ego's timestamps for the static trajectory. Default is True.
ground : bool, optional
If True, adjust Z to place actor on ground (uses ego's Z as reference).
Default is True.
lidar_source : int, optional
ID of another actor to use as the lidar source for this actor.
``-1`` means the source is selected automatically at runtime.
Default is ``-1``.
Returns
-------
int
ID of the newly inserted actor.
Notes
-----
The actor's trajectory will be constant (same pose at all timestamps).
The ``at_idx`` parameter determines which frame of the ego trajectory
to use as the spatial reference for positioning.
Examples
--------
Place a parked car 10m ahead::
>>> new_id = scene.insert_static_actor(4, forward=10)
Place a pedestrian on the sidewalk::
>>> new_id = scene.insert_static_actor(pedestrian_id, forward=15, left=5)
Place actor facing perpendicular to ego::
>>> new_id = scene.insert_static_actor(4, forward=10, yaw=90)
See Also
--------
:meth:`insert_actor_on_ego_trajectory` : Insert a moving actor
:meth:`crossing` : Insert a crossing pedestrian
"""
base = self.actor(actor_id).actor if isinstance(actor_id, int) else actor_id
new_actor = base.model_copy(deep=True)
ego_traj = self.scene.ego_vehicle.trajectory
ts = ego_traj.timestamps if use_ego_timegrid else None
new_traj = trajectory.build_static_from_reference(
ego_traj,
at_idx=at_idx,
forward=forward,
backward=backward,
left=left,
right=right,
up=up,
down=down,
yaw_deg=yaw,
timestamps=ts,
)
if self._is_external_actor(new_actor):
new_traj = self._align_trajectory_to_scene_up(new_traj)
if ground:
# ego assumed at ground level, so the z-bias baseline is half-height
half_height = self._height(new_actor) * 0.5
new_traj = self._ground_actor_trajectory(
new_traj,
ground_offset=half_height + up - down,
fallback_z_offset=half_height,
)
new_actor.trajectory = new_traj
new_actor.id = self._next_actor_id()
if hasattr(new_actor, "lidar_source"):
new_actor.lidar_source = lidar_source # type: ignore[attr-defined]
self.scene.actors.append(new_actor)
return new_actor.id
[docs]
def crossing(
self,
actor: int | Actor,
*,
start: int,
end: int,
forward: float = 8.0,
lateral: float = 0.0,
direction: str = "right_to_left",
speed: float = 2.0,
ground: bool = True,
graceful_stop: bool = False,
) -> int:
"""
Insert an actor that crosses perpendicular to the ego's path.
Creates a crossing scenario (e.g., pedestrian crossing the road)
where the actor moves perpendicular to the ego's direction of travel.
Parameters
----------
actor : int or Actor
Actor to insert (typically a pedestrian).
start : int
Frame index when the crossing starts.
end : int
Frame index when the crossing ends.
forward : float, optional
Distance ahead of ego at spawn time (meters). Default is 8.0.
lateral : float, optional
Lateral offset from ego centerline at spawn (meters).
Positive = left, negative = right. Default is 0.0.
direction : str, optional
Crossing direction: ``"right_to_left"`` or ``"left_to_right"``.
Default is ``"right_to_left"``.
speed : float, optional
Crossing speed in m/s. Default is 2.0.
ground : bool, optional
If True, place actor on ground level. Default is True.
graceful_stop : bool, optional
If True, apply gradual deceleration at end. Default is False.
Returns
-------
int
ID of the newly inserted crossing actor.
Notes
-----
The actor's local +X axis is aligned with the crossing direction,
meaning the actor faces the direction it's walking.
Examples
--------
Basic pedestrian crossing::
>>> new_id = scene.crossing(
... pedestrian_id,
... start=20,
... end=60,
... forward=15,
... direction="right_to_left"
... )
Slow-walking pedestrian::
>>> new_id = scene.crossing(
... pedestrian_id,
... start=30,
... end=80,
... forward=10,
... speed=1.0
... )
Crossing from the left side::
>>> new_id = scene.crossing(
... pedestrian_id,
... start=20,
... end=50,
... forward=12,
... lateral=5,
... direction="left_to_right"
... )
See Also
--------
:meth:`insert_static_actor` : Insert a stationary actor
:meth:`insert_actor_on_ego_trajectory` : Insert actor on ego path
"""
ego_traj = self.scene.ego_vehicle.trajectory
N = len(ego_traj.timestamps)
if N == 0:
raise ValueError("Ego trajectory is empty.")
if end - start <= 0:
raise ValueError("end_idx must be > at_idx and within trajectory bounds.")
# left_to_right: move toward -Y => yaw = -90°
# right_to_left: move toward +Y => yaw = +90°
yaw_deg = -90.0 if direction == "left_to_right" else +90.0
left = lateral if lateral > 0 else 0.0
right = -lateral if lateral < 0 else 0.0
# Spawn actor at ego frame start with desired offsets & yaw.
# Temporarily disable limited_mode: yaw=±90 is an internal
# implementation detail (perpendicular orientation), not a
# user-supplied value that should be subject to angle limits.
_was_limited = self.limited_mode
self.limited_mode = False
try:
new_id = self.insert_static_actor(
actor,
at_idx=start,
forward=forward,
left=left,
right=right,
yaw=yaw_deg,
use_ego_timegrid=True,
ground=ground,
)
finally:
self.limited_mode = _was_limited
# Animate crossing
a = self.actor(new_id)
a.move_forward(start_idx=start, speed=speed)
a.stop(at_idx=end)
return new_id
[docs]
@check_value_limits
@log_method
def insert_and_stop_actor_on_ego_trajectory(
self,
actor_ref: int | Actor,
*,
forward: float = 0.0,
backward: float = 0.0,
left: float = 0.0,
right: float = 0.0,
start_idx: int = 0,
) -> int:
"""
Insert an actor on the ego trajectory and immediately stop it.
This is a convenience method that combines :meth:`insert_actor_on_ego_trajectory`
and :meth:`ActorEditor.stop` into a single call.
Parameters
----------
actor_ref : int or Actor
Actor to insert (ID or object).
forward : float, optional
Forward offset along ego trajectory (meters). Default is 0.0.
backward : float, optional
Backward offset along ego trajectory (meters). Default is 0.0.
left : float, optional
Lateral offset to the left (meters). Default is 0.0.
right : float, optional
Lateral offset to the right (meters). Default is 0.0.
start_idx : int, optional
Frame index at which the actor stops. Default is 0.
Returns
-------
int
ID of the newly inserted actor.
Examples
--------
Insert a stopped vehicle ahead::
>>> new_id = scene.insert_and_stop_actor_on_ego_trajectory(
... 4,
... forward=30,
... start_idx=0
... )
See Also
--------
:meth:`insert_actor_on_ego_trajectory` : Insert without stopping
:meth:`insert_static_actor` : Insert a purely static actor
"""
new_id = self.insert_actor_on_ego_trajectory(
actor_ref,
forward=forward,
backward=backward,
left=left,
right=right,
)
a = self.actor(new_id)
a.stop(at_idx=start_idx)
return new_id
# ==================== Actor Transformation Shortcuts ====================
[docs]
@check_value_limits
@log_method
def rotate_actor(
self, actor_id: int, *, roll: float = 0.0, pitch: float = 0.0, yaw: float = 0.0
) -> "SceneEditor":
"""
Rotate an actor using Euler angles.
This is a convenience wrapper around :meth:`ActorEditor.rotate`.
Parameters
----------
actor_id : int
ID of the actor to rotate.
roll : float, optional
Rotation around X (forward) axis in degrees. Default is 0.0.
pitch : float, optional
Rotation around Y (left) axis in degrees. Default is 0.0.
yaw : float, optional
Rotation around Z (up) axis in degrees. Default is 0.0.
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
>>> scene.rotate_actor(4, yaw=90) # Turn actor 90° left
See Also
--------
:meth:`ActorEditor.rotate` : Full rotation API
:meth:`transform_actor` : Combined translation and rotation
"""
self.actor(actor_id).rotate(roll=roll, pitch=pitch, yaw=yaw)
return self
[docs]
def object_appearance(self, enabled: bool) -> "SceneEditor":
"""Enable or disable the AutoSplat per-frame appearance residual for every actor.
The appearance residual carries each actor's dynamic, per-frame look
(brake lights, blinkers, and other view-dependent overlays). Muting it
renders every actor with its static baseline appearance only.
This toggle only takes visible effect inside the rendering pipeline,
which installs a callback via ``scene._object_appearance_sink``. In a
pure offline editor the call is still recorded in :attr:`editions` but
is otherwise a no-op.
Parameters
----------
enabled : bool
``True`` to render the per-frame appearance residual for all
actors, ``False`` to mute it everywhere.
Returns
-------
SceneEditor
Self for method chaining.
Examples
--------
>>> scene.object_appearance(False) # mute all actors
>>> scene.actor(28).object_appearance(True) # re-enable one
See Also
--------
ActorEditor.object_appearance : Toggle a single actor.
"""
self._apply_object_appearance(None, bool(enabled))
return self
def _apply_object_appearance(self, actor_id: int | None, enabled: bool) -> None:
"""Record an ``object_appearance`` edit and forward it to the render sink.
Shared by :meth:`object_appearance` (``actor_id is None`` → whole scene)
and :meth:`ActorEditor.object_appearance` (a single actor id). The edit
is always appended to :attr:`editions`; the sink call is what makes it
live when a renderer is attached.
"""
scope = str(actor_id) if actor_id is not None else "all"
self._editions.append(
EditionDict(
method="object_appearance",
params={"actor_id": actor_id, "enabled": enabled},
description=f"object_appearance(actor={scope}, enabled={enabled})",
)
)
sink = getattr(self, "_object_appearance_sink", None)
if sink is not None:
sink(actor_id, enabled)
# ==================== Output Methods ====================
[docs]
def get_modifications(self) -> list[dict]:
"""
Get a list of all modifications applied to the scene.
Returns
-------
list[dict]
List of edition dictionaries, each containing:
- ``method``: Name of the method called
- ``params``: Dictionary of parameters passed
- ``description``: Human-readable description of the edit
Examples
--------
>>> scene.ego.shift(forward=5)
>>> scene.actor(4).rotate(yaw=90)
>>> mods = scene.get_modifications()
>>> for mod in mods:
... print(mod['description'])
See Also
--------
:attr:`editions` : Direct access to the editions list
"""
return self._editions
[docs]
def as_scene(self) -> Scene:
"""
Get the underlying Scene object.
Returns
-------
Scene
The scene object with all modifications applied.
Examples
--------
>>> modified_scene = scene.as_scene()
"""
return self.scene
# ==================== Internal Helpers ====================
def _align_trajectory_to_scene_up(self, traj):
"""Preserve heading while rotating poses around the scene's up axis."""
out = traj.model_copy(deep=True)
poses = np.asarray(out.object2world, dtype=float).copy()
forward = poses[:, :3, 0]
forward -= np.outer(forward @ self._scene_up, self._scene_up)
forward /= np.linalg.norm(forward, axis=1, keepdims=True)
left = np.cross(self._scene_up, forward)
left /= np.linalg.norm(left, axis=1, keepdims=True)
poses[:, :3, 0] = forward
poses[:, :3, 1] = left
poses[:, :3, 2] = self._scene_up
out.object2world = poses
return out
@cached_property
def _ground_surface(self) -> tuple[np.ndarray, cKDTree] | None:
"""Load and index the scene's road points on first grounded insertion."""
paths = getattr(self.scene, "background_pcd_paths", None)
pointcloud = getattr(paths, "background_road_pcd", None)
if pointcloud is not None:
points = np.asarray(pointcloud.points, dtype=float)[:, :3]
else:
filepath = getattr(paths, "background_road_pcd_filepath", None)
if not filepath:
return None
path = Path(filepath)
if not path.is_absolute():
base_folder = getattr(self.scene, "base_folderpath", None)
if not base_folder:
return None
path = Path(base_folder) / path
if not path.is_file():
return None
import open3d as o3d
points = np.asarray(o3d.io.read_point_cloud(str(path)).points, dtype=float)[
:, :3
]
points = points[np.isfinite(points).all(axis=1)]
if not len(points):
return None
return points, cKDTree(points[:, :2])
def _ground_actor_trajectory(
self,
traj,
*,
ground_offset: float,
fallback_z_offset: float,
):
out = (
trajectory.shift_world(traj, np.array([0.0, 0.0, fallback_z_offset]))
if fallback_z_offset
else traj.model_copy(deep=True)
)
if self._ground_surface is None:
return out
points, tree = self._ground_surface
poses = np.asarray(out.object2world, dtype=float).copy()
distances, indices = (
np.reshape(a, (len(poses), -1))
for a in tree.query(poses[:, :2, 3], k=min(8, len(points)))
)
ground_z = np.median(points[indices, 2], axis=1)
valid = distances[:, 0] <= 2.0
half_height = ground_offset * poses[:, 2, 2]
poses[valid, 2, 3] = ground_z[valid] + half_height[valid]
out.object2world = poses
return out
def _next_actor_id(self) -> int:
"""Generate the next available actor ID."""
return max(self._actors_ids) + 1 if self.scene.actors else 0
@staticmethod
def _scene_actor_ref(ref: "int | str") -> int:
"""A scene actor id, from the id itself or from a name ending in it.
``"actor_21"`` and ``21`` are the same actor. A string that carries no
trailing number is not an id at all, and is reported as such here rather
than reaching a lookup that would blame the scene for not holding it.
"""
index = scene_actor_index(ref)
if index is None:
raise KeyError(
f"No actor for ref {ref!r}: an actor is named by its id, or by "
f"a name ending in it such as 'actor_21'"
)
return index
def _idx_from_ref(self, ref: int) -> int:
"""Convert actor ID to list index."""
if ref in self._actors_ids:
return [a.id == ref for a in self.scene.actors].index(True)
raise KeyError(
f"No actor for ref {ref} (neither id nor index). "
f"actors_ids in scene {[a.id for a in self.scene.actors]}"
)
def _id_from_ref(self, ref: int) -> int:
"""Validate and return actor ID."""
if ref in [a.id for a in self.scene.actors]:
return next(a.id for a in self.scene.actors if a.id == ref)
raise KeyError(
f"No actor for ref {ref} (neither id nor index). "
f"actors_ids in scene {[a.id for a in self.scene.actors]}"
)
@staticmethod
def _height(actor: Actor) -> float:
"""Get actor's height dimension."""
return float(actor.dimensions.height)
@staticmethod
def _is_external_actor(actor: Actor) -> bool:
"""Return True if *actor* is an external actor (has no ``original_id`` or has a ``base_folderpath``)."""
if actor.original_id is None:
return True
if getattr(actor, "base_folderpath", None) is not None:
return True
return False
def _actor_value_limits(
self, actor_id: int | Actor
) -> dict[str, tuple[float, float]]:
"""Return the appropriate value-limits dict for *actor_id*."""
if isinstance(actor_id, Actor):
actor = actor_id
else:
idx = self._idx_from_ref(actor_id)
actor = self.scene.actors[idx]
if self._is_external_actor(actor):
return self.external_actor_value_limits
return self.scene_actor_value_limits
def _resolve_value_limits(self, params: dict) -> dict[str, tuple[float, float]]:
"""Pick the right limits dict based on the method's bound params.
Used by the ``check_value_limits`` decorator. If the params contain
an ``actor_id`` or ``actor_ref`` key we resolve by actor type;
otherwise we fall back to scene-actor limits.
"""
# Check for actor reference in params (accept either "actor_id" or "actor_ref")
actor_ref = (
params.get("actor_id") if "actor_id" in params else params.get("actor_ref")
)
if actor_ref is not None:
# An Actor object passed directly signals an external actor — the caller
# is supplying the actor from outside the scene, not looking it up by ID.
if isinstance(actor_ref, Actor):
return self.external_actor_value_limits
try:
return self._actor_value_limits(actor_ref)
except (KeyError, IndexError):
pass
return self.scene_actor_value_limits
def _make_trajectory_editor(
self, traj, *, value_limits: dict[str, tuple[float, float]] | None = None
) -> TrajectoryEditor:
"""
Create a TrajectoryEditor with scene-level settings.
Parameters
----------
traj : WorldTrajectory
The trajectory to wrap.
value_limits : dict or None, optional
Override value limits. Defaults to scene-actor limits.
Returns
-------
TrajectoryEditor
Editor configured with scene's limited_mode and value_limits.
"""
return TrajectoryEditor(
traj=traj,
limited_mode=self.limited_mode,
value_limits=value_limits or self.scene_actor_value_limits,
editions=self._editions,
)
def _resolve_sensor(self, kind: str, ref):
"""
Resolve a sensor reference to a SensorEditor.
Parameters
----------
kind : str
Sensor type: ``"camera"``, ``"lidar"``, or ``"radar"``.
ref : int, str, or sensor object
Sensor reference (ID, name, index, or object).
Returns
-------
SensorEditor
Editor for the resolved sensor.
Raises
------
KeyError
If the sensor cannot be found.
"""
s = self.scene.ego_vehicle.sensors
lst = {
"camera": (s.cameras or []),
"lidar": (s.lidars or []),
"radar": (s.radars or []),
}[kind]
# Accept object, id, index, or name
if kind == "camera" and isinstance(ref, Camera):
obj = ref
elif kind == "lidar" and isinstance(ref, Lidar):
obj = ref
elif kind == "radar" and isinstance(ref, Radar):
obj = ref
elif isinstance(ref, int):
obj = next((x for x in lst if getattr(x, "id", None) == ref), None)
if obj is None and 0 <= ref < len(lst):
obj = lst[ref] # Index fallback
elif isinstance(ref, str):
obj = next((x for x in lst if getattr(x, "name", None) == ref), None)
else:
raise KeyError(f"Unsupported sensor ref: {ref!r}")
if obj is None:
raise KeyError(f"No {kind} for ref {ref!r}")
return SensorEditor(
scene=self.scene,
sensor=obj,
limited_mode=self.limited_mode,
value_limits=self.ego_value_limits,
editions=self._editions,
)