Source code for mimic_scene_edition.editors.scene_editor

"""
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] @check_value_limits @log_method def transform_actor(self, actor_id: int, **xyz_rpy) -> "SceneEditor": """ Transform an actor with translation and rotation. This is a convenience wrapper around :meth:`ActorEditor.transform`. Parameters ---------- actor_id : int ID of the actor to transform. **xyz_rpy : float Transformation parameters: - Translation: ``forward``, ``backward``, ``left``, ``right``, ``up``, ``down`` - Rotation: ``roll``, ``pitch``, ``yaw`` (degrees) - ``referential``: ``"local"`` or ``"world"`` - ``start_idx``, ``end_idx``: Frame range Returns ------- SceneEditor Self for method chaining. Examples -------- >>> scene.transform_actor(4, forward=5, left=2, yaw=15) See Also -------- :meth:`ActorEditor.transform` : Full transformation API :meth:`rotate_actor` : Rotation only """ self.actor(actor_id).transform(**xyz_rpy) 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, )