"""
Actor editing for scene manipulation.
The :class:`ActorEditor` provides methods to edit individual actors
(vehicles, pedestrians, etc.) in a scene. Access via ``scene.actor(id)``.
Example
-------
>>> # Access an actor and apply transformations
>>> scene.actor(4).shift(left=2)
>>> scene.actor(4).displace(forward=10).rotate(yaw=15)
>>> # Method chaining for complex edits
>>> scene.actor(4) \\
... .displace(forward=10) \\
... .shift(left=2) \\
... .rotate(yaw=15) \\
... .stop(at_idx=50)
"""
from __future__ import annotations
from typing import Optional, Literal
import numpy as np
from mimic_data_api.scene import Scene
from mimic_data_api.actor import Actor
from mimic_data_api.trajectory_numpy import WorldTrajectory
from mimic_scene_edition.editors.base import (
log_method,
check_value_limits,
EditionDict,
validate_referential,
Referential,
Axis,
)
from mimic_scene_edition.editors.trajectory_editor import TrajectoryEditor
from mimic_scene_edition.strategies.stop import build_graceful_stop_T_seq
__all__ = ["ActorEditor"]
#: How close an actor timestamp has to be to a frame's to count as that frame.
_GRID_ATOL = 1e-6
[docs]
class ActorEditor:
"""Editor for manipulating individual actors in a scene.
Provides comprehensive methods for transforming actor trajectories,
controlling motion, and modifying actor properties. All transformation
methods return ``self`` to enable fluent method chaining.
Parameters
----------
root : SceneEditor
The parent scene editor that owns this actor.
actor_id : int
The unique identifier of the actor to edit.
limited_mode : bool, optional
If True, enable parameter value limit checking. Default is False.
value_limits : dict[str, tuple[float, float]] | None, optional
Dictionary mapping parameter names to (min, max) bounds.
editions : list[EditionDict] | None, optional
List to append edition records to.
Attributes
----------
actor_id : int
The actor's unique identifier.
actor : Actor
The underlying Actor object being edited.
traj : WorldTrajectory
The actor's trajectory.
Notes
-----
ActorEditor instances should be obtained through ``SceneEditor.actor(id)``
rather than instantiated directly.
Examples
--------
Basic translation:
>>> scene.actor(4).shift(left=2) # Move 2m left
>>> scene.actor(4).shift(forward=5, up=0.5) # Forward and up
Path displacement (moves along trajectory path):
>>> scene.actor(4).displace(forward=10) # 10m ahead on path
>>> scene.actor(4).displace(forward=10).shift(left=2) # Combine with lateral offset
Stop the actor:
>>> scene.actor(4).stop(at_idx=30) # Stop at frame 30
>>> scene.actor(4).stop(at_idx=30, decel=0) # Instant stop
Method chaining:
>>> scene.actor(4) \\
... .displace(forward=10) \\
... .shift(left=2) \\
... .rotate(yaw=15) \\
... .stop(at_idx=50)
"""
[docs]
def __init__(
self,
root: "SceneEditor",
actor_id: int,
*,
limited_mode: bool = False,
value_limits: dict[str, tuple[float, float]] | None = None,
editions: list[EditionDict] | None = None,
):
self._root = root
self._id = int(actor_id)
self.limited_mode = limited_mode
self.value_limits = value_limits or {}
self._editions = editions if editions is not None else []
@property
def actor_id(self) -> int:
"""The actor's unique identifier.
Returns
-------
int
The actor ID.
"""
return self._id
@property
def scene(self) -> Scene:
"""The scene containing this actor.
Returns
-------
Scene
The parent scene object.
"""
return self._root.scene
@property
def actor(self) -> Actor:
"""The underlying Actor object.
Returns
-------
Actor
The actor being edited.
"""
idx = self._root._idx_from_ref(self._id) # id -> current index
return self.scene.actors[idx]
@property
def traj(self) -> WorldTrajectory:
"""The actor's trajectory.
Returns
-------
WorldTrajectory
The actor's world trajectory.
"""
return self.actor.trajectory
# ---------- helpers ----------
def _te(self) -> TrajectoryEditor:
"""Create a fresh TrajectoryEditor bound to the live trajectory.
Returns
-------
TrajectoryEditor
A new editor for the actor's trajectory.
"""
return TrajectoryEditor(
traj=self.traj,
limited_mode=self.limited_mode,
value_limits=self.value_limits,
editions=self._editions,
)
def _commit_traj(self, te: TrajectoryEditor) -> None:
"""Commit trajectory changes from the editor to the actor.
Parameters
----------
te : TrajectoryEditor
The trajectory editor with modifications.
"""
self.actor.trajectory = te.traj
@staticmethod
def _window(
N: int, start_idx: Optional[int], end_idx: Optional[int]
) -> tuple[int, int]:
"""Validate and normalize frame index window bounds.
Parameters
----------
N : int
Total number of frames.
start_idx : int | None
Start index (None means 0).
end_idx : int | None
End index exclusive (None means N).
Returns
-------
tuple[int, int]
Validated (start, end) indices.
Raises
------
ValueError
If the window bounds are invalid.
"""
i0 = 0 if start_idx is None else int(start_idx)
i1 = N if end_idx is None else int(end_idx)
if not (0 <= i0 <= i1 <= N):
raise ValueError(f"Invalid window [{i0}, {i1}) for length {N}.")
return i0, i1
# ---------- frame indices ----------
# Every index an op takes counts the scene's frames — the ego's samples —
# not this actor's own. Annotations are often coarser than the rig: an
# actor boxed at 2 Hz under a 10 Hz ego has 21 samples beside 100 frames,
# and frame 20 is its sample 4. An actor sampled on the frames keeps its
# indices untouched.
def _off_grid(self) -> Optional[np.ndarray]:
"""The scene's frame times, or ``None`` when this actor is sampled on them."""
tf = np.asarray(self.scene.ego_vehicle.trajectory.timestamps, float)
ta = np.asarray(self.traj.timestamps, float)
if len(tf) == 0 or (
len(tf) == len(ta) and np.allclose(tf, ta, rtol=0.0, atol=_GRID_ATOL)
):
return None
return tf
def _sample(self, frame: int) -> int:
"""The actor sample a frame stops on: the last one at or before its time.
Raises
------
ValueError
If the scene has no such frame.
"""
tf = self._off_grid()
if tf is None:
return int(frame)
i = int(frame)
if not 0 <= i < len(tf):
raise ValueError(f"frame {i} is outside the scene's {len(tf)} frames")
ta = np.asarray(self.traj.timestamps, float)
return max(0, int(np.searchsorted(ta, tf[i] + _GRID_ATOL, side="right")) - 1)
def _span(
self, start_idx: Optional[int], end_idx: Optional[int]
) -> tuple[Optional[int], Optional[int]]:
"""The actor samples a frame window ``[start_idx, end_idx)`` covers.
Those whose time falls inside it; a window that starts at the first
frame or ends at the last also keeps the samples beyond it. ``None``
stays ``None``.
"""
tf = self._off_grid()
if tf is None:
return start_idx, end_idx
n = len(tf)
i0 = 0 if start_idx is None else int(start_idx)
i1 = n if end_idx is None else int(end_idx)
if not (0 <= i0 <= i1 <= n):
raise ValueError(f"Invalid window [{i0}, {i1}) for the scene's {n} frames.")
ta = np.asarray(self.traj.timestamps, float)
def edge(i: int) -> int:
if i == 0:
return 0
if i == n:
return len(ta)
return int(np.searchsorted(ta, tf[i] - _GRID_ATOL, side="left"))
return (
None if start_idx is None else edge(i0),
None if end_idx is None else edge(i1),
)
def _frames_held(self) -> str:
"""The scene frames this actor has samples in, for an error message."""
ta = np.asarray(self.traj.timestamps, float)
if len(ta) == 0:
return "it has none"
tf = self._off_grid()
if tf is None:
return f"it is in frames 0-{len(ta) - 1}"
first = int(np.searchsorted(tf, ta[0] - _GRID_ATOL, side="left"))
last = int(np.searchsorted(tf, ta[-1] + _GRID_ATOL, side="right")) - 1
return f"it is in frames {first}-{last}"
def _per_sample(
self, T: np.ndarray, start_idx: Optional[int], end_idx: Optional[int]
) -> tuple[np.ndarray, Optional[int], Optional[int]]:
"""Re-index a per-frame ``T`` onto the samples its frame window covers.
Each sample reads the transform of the frame it falls on. A single
``(4, 4)`` transform needs no re-indexing, only the window does.
"""
T = np.asarray(T, float)
s0, s1 = self._span(start_idx, end_idx)
tf = self._off_grid()
if tf is None or T.ndim != 3:
return T, s0, s1
i0 = 0 if start_idx is None else int(start_idx)
i1 = len(tf) if end_idx is None else int(end_idx)
if T.shape[0] != i1 - i0:
raise ValueError(
f"T must be (4,4) or (K,4,4) with K={i1 - i0}, the frames in "
f"[{i0}, {i1}); got {T.shape}."
)
ta = np.asarray(self.traj.timestamps, float)[s0 or 0 : s1]
frames = np.searchsorted(tf, ta + _GRID_ATOL, side="right") - 1
return T[np.clip(frames, i0, i1 - 1) - i0], s0, s1
[docs]
@check_value_limits
@log_method
def translate(self, **xyz: float) -> "ActorEditor":
"""Translate the actor using semantic direction components.
Alias for :meth:`shift`. Applies a local-frame translation
using intuitive direction names.
Parameters
----------
**xyz : float
Named translation components:
- ``forward``: Distance forward (positive X) in meters
- ``backward``: Distance backward (negative X) in meters
- ``left``: Distance left (positive Y) in meters
- ``right``: Distance right (negative Y) in meters
- ``up``: Distance up (positive Z) in meters
- ``down``: Distance down (negative Z) in meters
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).translate(forward=5, left=2)
See Also
--------
shift : Primary translation method.
"""
te = self._te()
te.translate(**xyz)
self._commit_traj(te)
return self
[docs]
@check_value_limits
@log_method
@validate_referential("referential")
def rotate(
self,
*,
roll: float = 0.0,
pitch: float = 0.0,
yaw: float = 0.0,
referential: Referential = "local",
start_idx: Optional[int] = None,
end_idx: Optional[int] = None,
) -> "ActorEditor":
"""Rotate the actor using Euler angles.
Applies a rotation to the actor's trajectory using roll-pitch-yaw
Euler angles in degrees.
Parameters
----------
roll : float, optional
Rotation around the X (forward) axis in degrees. Default is 0.
pitch : float, optional
Rotation around the Y (left) axis in degrees. Default is 0.
yaw : float, optional
Rotation around the Z (up) axis in degrees. Default is 0.
referential : {"local", "world"}, optional
Coordinate frame for the rotation:
- ``"local"``: Rotation in actor's local frame (default).
- ``"world"``: Rotation in world frame.
start_idx : int | None, optional
First frame to rotate (inclusive). None means all frames.
end_idx : int | None, optional
Last frame to rotate (exclusive). None means all frames.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).rotate(yaw=90) # Turn 90° left
>>> scene.actor(4).rotate(yaw=180) # Face opposite direction
See Also
--------
transform : Combined translation and rotation.
"""
start_idx, end_idx = self._span(start_idx, end_idx)
te = self._te()
te.rotate(
roll=roll,
pitch=pitch,
yaw=yaw,
referential=referential,
start_idx=start_idx,
end_idx=end_idx,
)
self._commit_traj(te)
return self
[docs]
@check_value_limits
@log_method
@validate_referential("referential")
def shift(
self,
*,
forward: float = 0.0,
backward: float = 0.0,
left: float = 0.0,
right: float = 0.0,
up: float = 0.0,
down: float = 0.0,
referential: Referential = "local",
start_idx: Optional[int] = None,
end_idx: Optional[int] = None,
) -> "ActorEditor":
"""Translate the actor using semantic direction components.
Pure translation using intuitive direction names. The coordinate
system follows vehicle convention (x-forward, y-left, z-up).
Parameters
----------
forward : float, optional
Distance forward (positive X) in meters. Default is 0.
backward : float, optional
Distance backward (negative X) in meters. Default is 0.
left : float, optional
Distance left (positive Y) in meters. Default is 0.
right : float, optional
Distance right (negative Y) in meters. Default is 0.
up : float, optional
Distance up (positive Z) in meters. Default is 0.
down : float, optional
Distance down (negative Z) in meters. Default is 0.
referential : {"local", "world"}, optional
Coordinate frame:
- ``"local"``: Components in actor's local axes (default).
Applied as post-multiplication.
- ``"world"``: Components in world axes.
Applied as pre-multiplication.
start_idx : int | None, optional
First frame to shift (inclusive). None means all frames.
end_idx : int | None, optional
Last frame to shift (exclusive). None means all frames.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).shift(left=2) # Move 2m left
>>> scene.actor(4).shift(forward=5, up=0.5) # Forward and up
>>> scene.actor(4).shift(right=3, referential="world") # In world coords
See Also
--------
translate : Alias for shift.
displace : Move along trajectory path.
"""
start_idx, end_idx = self._span(start_idx, end_idx)
te = self._te()
te.transform(
forward=forward,
backward=backward,
left=left,
right=right,
up=up,
down=down,
referential=referential,
start_idx=start_idx,
end_idx=end_idx,
)
self._commit_traj(te)
return self
[docs]
def object_appearance(self, enabled: bool) -> "ActorEditor":
"""Enable or disable the AutoSplat per-frame appearance residual for this actor.
The appearance residual carries the actor's dynamic, per-frame look
(brake lights, blinkers, and other view-dependent overlays). Muting it
renders the 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`
(so it replays with the rest of the edit history) but is otherwise a
no-op.
Parameters
----------
enabled : bool
``True`` to render the per-frame appearance residual for this
actor, ``False`` to mute it.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(28).object_appearance(False) # mute this actor
>>> scene.actor(28).object_appearance(True) # restore it
See Also
--------
SceneEditor.object_appearance : Toggle every actor at once.
"""
self._root._apply_object_appearance(self._id, bool(enabled))
return self
[docs]
@log_method
@validate_referential("referential")
def apply_T_sequence(
self,
T: np.ndarray,
*,
start_idx: Optional[int] = None,
end_idx: Optional[int] = None,
referential: Referential = "local",
) -> "ActorEditor":
"""Apply a sequence of 4x4 transformation matrices.
Applies SE(3) transformation matrices to the actor's trajectory
poses in the specified frame range.
Parameters
----------
T : np.ndarray
Transformation matrices with shape ``(4, 4)`` for a single
transform applied to all frames, or ``(N, 4, 4)`` for
per-frame transforms, one per scene frame in the window. An
actor sampled off the frames reads each sample's transform at
the frame it falls on.
start_idx : int | None, optional
First frame to transform (inclusive). None means 0.
end_idx : int | None, optional
Last frame to transform (exclusive). None means end.
referential : {"local", "world"}, optional
How to apply the transformation:
- ``"local"``: Right-multiply (pose @ T). Default.
- ``"world"``: Left-multiply (T @ pose).
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> T = np.eye(4)
>>> T[:3, 3] = [1, 0, 0] # Translation
>>> scene.actor(4).apply_T_sequence(T)
"""
T, start_idx, end_idx = self._per_sample(T, start_idx, end_idx)
te = self._te()
te.apply_T_sequence(
T, start_idx=start_idx, end_idx=end_idx, referential=referential
)
self._commit_traj(te)
return self
# ---------- Strategies / maneuvers ----------
[docs]
@log_method
def displace_along_forward(
self,
meters: float,
*,
start_idx: Optional[int] = None,
end_idx: Optional[int] = None,
) -> "ActorEditor":
"""Displace the actor along its trajectory path by arc-length.
Shortcut for ``displace(forward=meters)``.
Parameters
----------
meters : float
Distance to displace in meters. Positive moves forward.
start_idx : int | None, optional
First frame to displace (inclusive). None means all frames.
end_idx : int | None, optional
Last frame to displace (exclusive). None means all frames.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).displace_along_forward(10) # 10m ahead on path
See Also
--------
displace : Displacement with named direction parameters.
"""
start_idx, end_idx = self._span(start_idx, end_idx)
te = self._te()
te.displace_along_forward(meters, start_idx=start_idx, end_idx=end_idx)
self._commit_traj(te)
return self
[docs]
@check_value_limits
@log_method
def displace(
self,
*,
forward: float = 0.0,
backward: float = 0.0,
start_idx: Optional[int] = None,
end_idx: Optional[int] = None,
) -> "ActorEditor":
"""Move the actor along its trajectory path by arc-length.
Unlike :meth:`shift`, this moves the actor along the *path it's
already following*, not in a straight line. This is useful for
positioning actors relative to their motion path.
Parameters
----------
forward : float, optional
Distance to move forward along path in meters. Default is 0.
backward : float, optional
Distance to move backward along path in meters. Default is 0.
start_idx : int | None, optional
First frame to displace (inclusive). None means all frames.
end_idx : int | None, optional
Last frame to displace (exclusive). None means all frames.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).displace(forward=10) # 10m ahead on path
>>> scene.actor(4).displace(backward=5) # 5m behind on path
>>> # Combine with shift for lateral offset
>>> scene.actor(4).displace(forward=10).shift(left=2)
See Also
--------
shift : Straight-line translation.
displace_along_forward : Shortcut with single distance parameter.
"""
start_idx, end_idx = self._span(start_idx, end_idx)
te = self._te()
te.displace(
forward=forward,
backward=backward,
start_idx=start_idx,
end_idx=end_idx,
)
self._commit_traj(te)
return self
[docs]
@log_method
def move_forward(
self,
*,
start_idx: int,
speed: float,
) -> "ActorEditor":
"""Make the actor move forward at a constant speed.
Modifies the trajectory so the actor moves forward at the
specified speed from the given frame index.
Parameters
----------
start_idx : int
Frame index to start the constant motion.
speed : float
Speed in meters per second.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).move_forward(start_idx=10, speed=5.0) # 5 m/s from frame 10
"""
# The sequence is built on the actor's own samples, so it is applied
# there rather than through :meth:`apply_T_sequence`, which reads frames.
start, _ = self._span(start_idx, None)
te = self._te()
T_seq = te.move_local_with_speed(start_idx=start, speed=speed, axis="x")
te.apply_T_sequence(T_seq, start_idx=start, referential="local")
self._commit_traj(te)
return self
[docs]
@log_method
def stop(
self,
at_idx: int,
*,
duration: int | None = None,
decel: int = 10,
profile: Literal["linear", "min_jerk"] = "min_jerk",
) -> "ActorEditor":
"""Stop the actor at a specific frame.
Brings the actor to a stop at the specified frame index,
optionally with a smooth deceleration profile.
Parameters
----------
at_idx : int
Frame index to stop at. If ``decel > 0``, this is where
the deceleration ends (actor becomes stationary).
duration : int | None, optional
Number of frames to stay stopped. If None, the actor
remains stopped until the end of the trajectory.
decel : int, optional
Number of frames for gradual deceleration. Use 0 for
instant stop. Default is 10.
profile : {"linear", "min_jerk"}, optional
Deceleration profile:
- ``"linear"``: Linear velocity reduction.
- ``"min_jerk"``: Smooth minimum-jerk profile (default).
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).stop(at_idx=30) # Stop at frame 30
>>> scene.actor(4).stop(at_idx=30, decel=0) # Instant stop
>>> scene.actor(4).stop(at_idx=30, duration=50) # Stop for 50 frames
See Also
--------
make_static_at : Make completely static at a position.
"""
te = self._te()
k = self._sample(at_idx)
if decel > 0:
# ``decel`` counts frames too: the ramp starts on the sample the
# frame ``decel`` before the stop lands on.
d = k - self._sample(max(0, at_idx - decel))
T_seq, i_start, i_end = build_graceful_stop_T_seq(
te.traj, at_idx=k, decel=d, profile=profile
)
if T_seq.shape[0] > 0:
te.apply_T_sequence(
T_seq, start_idx=i_start, end_idx=i_end, referential="local"
)
if duration is None:
te.freeze_from(k)
else:
N = len(te.traj.object2world)
tf = self._off_grid()
frames = N if tf is None else len(tf)
_, end = self._span(at_idx, min(frames, at_idx + duration))
te.freeze_window(k, min(N, end), persist_tail=True)
self._commit_traj(te)
return self
[docs]
@log_method
def spin(
self,
*,
period_s: Optional[float] = None,
rpm: Optional[float] = None,
axis: Axis = "z",
start_idx: Optional[int] = None,
end_idx: Optional[int] = None,
) -> "ActorEditor":
"""Apply continuous rotation (spinning) to the actor.
Makes the actor rotate continuously around the specified axis.
Parameters
----------
period_s : float | None, optional
Seconds per full revolution. Mutually exclusive with ``rpm``.
rpm : float | None, optional
Revolutions per minute. Mutually exclusive with ``period_s``.
axis : {"x", "y", "z"}, optional
Rotation axis. Default is ``"z"`` (vertical).
start_idx : int | None, optional
First frame to apply spin (inclusive). None means all frames.
end_idx : int | None, optional
Last frame to apply spin (exclusive). None means all frames.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).spin(rpm=60, axis="z") # Spin at 60 RPM
"""
start_idx, end_idx = self._span(start_idx, end_idx)
te = self._te()
te.spin(
period_s=period_s, rpm=rpm, axis=axis, start_idx=start_idx, end_idx=end_idx
)
self._commit_traj(te)
return self
[docs]
@log_method
def make_static_at(self, *, at_idx: int = 0) -> "ActorEditor":
"""Make the actor completely static at a specific frame's position.
Anchors the actor's trajectory at the specified frame, making
the actor remain completely stationary at that position for
the entire duration.
Parameters
----------
at_idx : int, optional
Frame index to anchor the trajectory at. Default is 0.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).make_static_at(at_idx=0) # Freeze at initial position
See Also
--------
stop : Stop with optional deceleration.
"""
tr = self.traj
t = np.asarray(tr.timestamps, float)
N = len(t)
if len(t) == 0:
return self
frames = self._off_grid()
if frames is not None:
at_idx = self._sample(int(np.clip(at_idx, 0, len(frames) - 1)))
i = int(np.clip(at_idx, 0, len(t) - 1))
anchor = np.asarray(tr.object2world[i], float)
new_traj = tr.model_copy(deep=True)
new_traj.object2world = np.broadcast_to(anchor, (N, 4, 4)).copy()
new_traj.linear_velocity = np.zeros((N, 3), float)
new_traj.angular_velocity = np.zeros((N, 3), float)
self.replace_trajectory(new_traj)
return self
# ---------- Temporal ----------
[docs]
@log_method
def offset_frames(self, offset_frames: int) -> "ActorEditor":
"""Shift the actor's timeline by a number of frames.
Performs a circular shift of the trajectory, wrapping
around at the boundaries.
Parameters
----------
offset_frames : int
Number of frames to shift. Positive values shift forward.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).offset_frames(5) # Shift 5 frames forward
"""
te = self._te()
te.offset_frames(int(offset_frames))
self._commit_traj(te)
return self
[docs]
@log_method
def resample(self, hz: float) -> "ActorEditor":
"""Resample the trajectory to a target frame rate.
Interpolates the trajectory to produce poses at a uniform
temporal spacing determined by the specified frequency.
Parameters
----------
hz : float
Target frame rate in Hz (frames per second).
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).resample(hz=20) # Resample to 20 Hz
See Also
--------
resample_to_times : Resample to specific timestamps.
"""
te = self._te()
te.resample_to_rate(float(hz))
self._commit_traj(te)
return self
[docs]
@log_method
def resample_to_times(self, new_t: np.ndarray) -> "ActorEditor":
"""Resample the trajectory to specific timestamps.
Interpolates poses to the specified query timestamps.
Parameters
----------
new_t : np.ndarray
Array of target timestamps to sample at.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> new_times = np.linspace(0, 10, 100)
>>> scene.actor(4).resample_to_times(new_times)
See Also
--------
resample : Resample to a uniform frame rate.
"""
te = self._te()
te.resample_to_times(np.asarray(new_t, float))
self._commit_traj(te)
return self
[docs]
@log_method
def slice(self, start_idx: int, end_idx: int) -> "ActorEditor":
"""Keep only frames in the specified range.
Truncates the trajectory to contain only poses within
the specified index range.
Parameters
----------
start_idx : int
First frame to keep (inclusive).
end_idx : int
Last frame to keep (exclusive).
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).slice(10, 50) # Keep frames 10-49
Raises
------
ValueError
If the window holds none of the actor's samples — which
would leave it with no trajectory at all.
"""
s0, s1 = self._span(int(start_idx), int(end_idx))
if s0 == s1:
raise ValueError(
f"Frames [{start_idx}, {end_idx}) hold none of actor {self.actor.id}'s "
f"samples ({self._frames_held()}); slice would leave it with no trajectory."
)
te = self._te()
te.slice(int(s0), int(s1))
self._commit_traj(te)
return self
[docs]
@check_value_limits
@log_method
def speed(self, multiplier: float) -> "ActorEditor":
"""Change the actor's apparent speed by retiming timestamps.
Adjusts trajectory timestamps to change perceived speed
without modifying the spatial path.
Parameters
----------
multiplier : float
Speed scaling factor:
- ``> 1``: Faster (double speed with 2.0)
- ``< 1``: Slower (half speed with 0.5)
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).speed(multiplier=2.0) # Double speed
>>> scene.actor(4).speed(multiplier=0.5) # Half speed
"""
te = self._te()
te.scale_speed(multiplier=multiplier)
self._commit_traj(te)
return self
# ---------- Utilities ----------
[docs]
@log_method
def replace_trajectory(self, traj: WorldTrajectory) -> "ActorEditor":
"""Replace the actor's entire trajectory.
Substitutes the current trajectory with a new one.
Velocities are recomputed automatically.
Parameters
----------
traj : WorldTrajectory
The new trajectory to use.
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> new_traj = create_custom_trajectory()
>>> scene.actor(4).replace_trajectory(new_traj)
"""
te = self._te()
te.replace(traj)
self._commit_traj(te)
return self
[docs]
@log_method
def set_dimensions(
self,
*,
length: float | None = None,
width: float | None = None,
height: float | None = None,
) -> "ActorEditor":
"""Modify the actor's bounding box dimensions.
Updates the actor's physical dimensions. Only provided
parameters are updated.
Parameters
----------
length : float | None, optional
New length in meters (X dimension).
width : float | None, optional
New width in meters (Y dimension).
height : float | None, optional
New height in meters (Z dimension).
Returns
-------
ActorEditor
Self for method chaining.
Examples
--------
>>> scene.actor(4).set_dimensions(length=4.5, width=1.8)
"""
a = self.actor
if length is not None:
a.dimensions.length = float(length)
if width is not None:
a.dimensions.width = float(width)
if height is not None:
a.dimensions.height = float(height)
return self
[docs]
@log_method
def duplicate(self, *, forward: float = 0.0, backward: float = 0.0) -> Actor:
"""Create a copy of the actor with optional displacement.
Creates a deep copy of the actor, optionally displaced
along its trajectory path. The copy is **not** inserted
into the scene; use ``scene.insert_actor()`` to add it.
Parameters
----------
forward : float, optional
Distance to displace forward on path in meters. Default is 0.
backward : float, optional
Distance to displace backward on path in meters. Default is 0.
Returns
-------
Actor
A new Actor object (not yet in scene).
Examples
--------
>>> new_actor = scene.actor(4).duplicate(forward=20)
>>> new_id = scene.insert_actor(new_actor)
See Also
--------
SceneEditor.insert_actor : Insert the duplicated actor.
SceneEditor.duplicate_actor : Duplicate and insert in one step.
"""
base = self.actor
dup = base.model_copy(deep=True)
dist = float(forward or -backward)
if abs(dist) > 1e-12:
te = TrajectoryEditor(
traj=dup.trajectory,
limited_mode=self.limited_mode,
value_limits=self.value_limits,
editions=self._editions,
)
te.displace_along_forward(dist)
dup.trajectory = te.traj
return dup
class RemovedActorEditor(ActorEditor):
"""What :meth:`SceneEditor.actor` hands back for an actor that is gone.
``Removed`` is sticky. A script that keeps the Python name a removal
invalidated and edits through it does not bring the actor back, so every op
on this receiver is a no-op that returns the receiver — a fluent chain still
chains, and the scene is left exactly as the removal left it.
The alternative, raising, would make the rule unrepresentable: the recorder
accepts the stale edit by design (see :mod:`mimic_scene_edition.oplist.ir`),
and a record no receiver can execute is a record the op-list cannot replay.
"""
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"RemovedActorEditor(actor_id={self._id})"
def _ignored(name: str):
"""A no-op stand-in for ``ActorEditor.<name>`` that keeps the chain going."""
def op(self, *args, **kwargs):
return self
op.__name__ = name
op.__qualname__ = f"RemovedActorEditor.{name}"
op.__doc__ = "No-op: the actor this editor names was removed from the scene."
return op
# Every public op, tombstoned in one place: a method added to ``ActorEditor``
# later is silently covered rather than silently resurrecting a removed actor.
for _name, _member in list(vars(ActorEditor).items()):
if not _name.startswith("_") and callable(_member):
setattr(RemovedActorEditor, _name, _ignored(_name))
del _name, _member