diff --git a/CHANGELOG.md b/CHANGELOG.md
index d923298..04b22e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Common: `CameraParams` (intrinsic `K`, per-frame `world_to_camera`, width, height) and the optional `SensorFrame.camera_params` field — camera geometry a fusion strategy needs to project world-frame `lidar` into the image. Extrinsic targets the OpenCV optical frame (x right, y down, z forward). Additive: `WorldBridge.get_camera_params()` defaults to `None`, so no existing bridge changes; bridges declaring `CAMERA_RGB` / `CAMERA_DEPTH` should override it
+- Execution: `c40_start_pose` (`[x, y, theta]` or null) — profile-defined ego start; factory falls back to the global-plan start point when null
+- Perception: `State.set_start()` — capture current pose as the snapshot restored by `reset()` (via `get_copy` / `copy_from`)
+- Visualizer: **Save Start** on the Execution state row — writes live ego pose into `c40_start_pose` and the active profile YAML
+- Plugin registry: optional `display_name` — human-readable plugin title in the Plugins browser and the docs store; falls back to `name`, which stays the install-folder / import identifier
+- Plugin registry: optional `site_url` — **Open Website** button plus a Website row in the plugin details window, and a **Site** button on the docs plugin cards
+
+### Changed
+- Perception: `State` / `AgentState` reset snapshot is a polymorphic copy of all fields (drops per-field `__init_*` / `AgentState.reset` override)
+- Execution: `BasicSim.reset()` restores ego and NPC start poses (and NPC controllers) instead of clearing agents
+- Execution: drop duplicate `world.reset()` call in `ExecutionStrategy.reset()`
+- Docs / README: Tk visualizer demo uses a looping video (`docs/imgs/tk_visualizer.mp4`) instead of a static screenshot; landing shot fills the content column
+- Docs: call out pause / step / interactive debug early (landing value strip, Overview features, Quick Start)
+- Docs: Community Plugins cards are no longer whole-card GitHub links — explicit **Site** / **Repo** buttons sit above the GitHub stats footer, dependency notes clamp to two lines (full text on hover), and the links carry per-plugin aria-labels
+- Docs: plugin registry field tables list every field with a required column (README, Overview, Plugin Development)
+
+### Fixed
+- Execution: `AsyncThreadedExecuter.step` no longer recreates workers while `stopped` is set — `StopExecAtGoalTask` (and any cooperative stop) stays stopped instead of being cleared by the next poll
+- Visualizer: Execution loop mirrors `executer.stopped` into `exec_running` / Stop UI so task-driven stops end the Tk run (headless already checked the flag)
+- Execution: async combined perception/planning worker localizes and perceives before replan on the shared snapshot — same order as `SyncExecuter`, so planning sees this iteration's ego/obstacles
+- Common: `TrajectoryTracker` / `slice_trajectory_horizon` tolerate a 1-point path (final waypoint) — Frenet conversion no longer indexes `next_wp=1` and crashes `VelocityLocalPlanner.replan` at path end
+- Visualizer: Control **Step** / Steer / Accel apply plant control and sync stack PM via `apply_world_control` (same dual-write as teleport after the world/stack ego split)
+- Visualizer: **Save Start** snapshots velocity 0 so Reset matches a cold profile start (live speed is preserved while driving)
+- Execution: perception, planning, and control share one `SensorFrame` per tick instead of each fetching its own — the stack no longer assumes the world holds still between stages, so bridges whose sensors evolve independently (CARLA async mode) stay coherent. `_localization_step` / `_perception_step` / `_replan_step` / `_control_step` now take the snapshot as an argument; each executer loop resolves its pacing gates first and fetches at most once (skipping the fetch entirely when no stage is due)
+- Visualizer: Control **Align** teleports plant ego and syncs stack PM (stack-only writes were undone by GT localization after the world/stack ego split)
+- Common: `TrajectoryTracker.update_waypoint_by_wp` / `update_to_next_waypoint` clamp `next_wp` at the path end — `%` precedence previously left `next_wp == len(path)` and crashed plot/step at the final waypoint
+- Common: `create_quintic_trajectory_sd` b-vector matches the constraint matrix (end 1st / start 2nd derivatives were swapped)
+
## [0.5.3] - 2026-07-24
### Added
diff --git a/README.md b/README.md
index 3a9fb71..13fd9b1 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -39,7 +39,7 @@
Install ·
Quick Start ·
Architecture ·
- Documentation ·
+ Documentation ·
Plugins ·
Community
@@ -51,7 +51,10 @@
> **ROS2 & Autoware Ready** — Optional ROS2 executer plugin with native Autoware message support (`Trajectory`, `ControlCommand`, etc.).
-
+
+
## Architecture Overview
@@ -256,12 +259,14 @@ See the [Plugin Development Guide — Publish via pull request](docs/plugin-deve
```yaml
plugins:
- name: my_perception_plugin
+ display_name: My Perception Plugin # optional, defaults to name
description: One-line summary of what the plugin does
repository: https://github.com/your-org/your-plugin-repo
version: latest # or a tag/commit SHA
author: your-org
category:
- PerceptionStrategy
+ site_url: "" # optional project website
```
4. Open a pull request. Once merged it shows up in every user's
@@ -367,7 +372,7 @@ Distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
Built with care by AV-Lab ·
- Documentation ·
+ Documentation ·
Report a bug ·
Request a feature
diff --git a/avlite/c10_perception/c11_perception_model.py b/avlite/c10_perception/c11_perception_model.py
index ed7f61e..ff934c4 100644
--- a/avlite/c10_perception/c11_perception_model.py
+++ b/avlite/c10_perception/c11_perception_model.py
@@ -134,11 +134,11 @@ class State:
length: float = 4.5
def __post_init__(self):
- # initial x,y position, useful for reset
- self.__init_x = self.x
- self.__init_y = self.y
- self.__init_theta = self.theta
+ self.__start = self.get_copy()
+ def set_start(self):
+ """Capture the current state as the snapshot restored by :meth:`reset`."""
+ self.__start.copy_from(self)
def get_bb_corners(self) -> np.ndarray:
"""Get the bounding box corners of the vehicle in world coordinates."""
@@ -173,9 +173,7 @@ def get_bb_corners(self) -> np.ndarray:
return np.c_[rotated_corners_x, rotated_corners_y]
def reset(self):
- self.x = self.__init_x
- self.y = self.__init_y
- self.theta = self.__init_theta
+ self.copy_from(self.__start)
def copy_from(self, other: State) -> None:
"""Copy dataclass fields from *other* in place (preserves object identity)."""
@@ -205,15 +203,6 @@ class AgentState(State):
agent_id: int = -1
agent_type: AgentType = AgentType.ACKERMANN
- def __post_init__(self):
- super().__post_init__()
- self.__init_speed = self.velocity
-
- def reset(self):
- super().reset()
- self.velocity = self.__init_speed
-
-
@dataclass
class EgoState(AgentState):
diff --git a/avlite/c40_execution/c41_world_bridge.py b/avlite/c40_execution/c41_world_bridge.py
index 1078e8f..42983b9 100644
--- a/avlite/c40_execution/c41_world_bridge.py
+++ b/avlite/c40_execution/c41_world_bridge.py
@@ -11,6 +11,7 @@
from avlite.c50_common.c53_stack_datatypes import control_type_for_agent
from avlite.c50_common.c52_world_sensor_datatypes import (
WORLD_CAPABILITY_SENSOR_FIELDS,
+ CameraParams,
GnssReading,
ImuReading,
SensorFrame,
@@ -20,6 +21,10 @@
RgbImage,
)
+import logging
+
+log = logging.getLogger(__name__)
+
@dataclass
class WorldBridge(ABC):
@@ -118,6 +123,15 @@ def get_depth_image(self, agent_id: int = EGO_AGENT_ID) -> DepthImage | None:
self._require_ego_agent(agent_id, "depth")
return None
+ def get_camera_params(self, agent_id: int = EGO_AGENT_ID) -> CameraParams | None:
+ """Returns geometry for the rgb/depth camera. Layout: ``CameraParams``.
+
+ Bridges exposing CAMERA_RGB or CAMERA_DEPTH must override this; without
+ it, world-frame LiDAR cannot be projected into the image.
+ """
+ self._require_ego_agent(agent_id, "camera params")
+ return None
+
def get_lidar_data(self, agent_id: int = EGO_AGENT_ID) -> LidarCloud | None:
"""Returns the lidar point cloud. Layout: ``LidarCloud`` in c52_world_sensor_datatypes."""
self._require_ego_agent(agent_id, "lidar")
@@ -145,6 +159,7 @@ def get_sensor_frame(self, agent_id: int = EGO_AGENT_ID) -> SensorFrame:
frame = SensorFrame(
rgb=self.get_rgb_image(),
depth=self.get_depth_image(),
+ camera_params=self.get_camera_params(),
lidar=self.get_lidar_data(),
imu=self.get_imu(),
gnss=self.get_gnss(),
@@ -154,13 +169,19 @@ def get_sensor_frame(self, agent_id: int = EGO_AGENT_ID) -> SensorFrame:
frame = SensorFrame(
rgb=self.get_rgb_image(agent_id=agent_id),
depth=self.get_depth_image(agent_id=agent_id),
+ camera_params=self.get_camera_params(agent_id=agent_id),
lidar=self.get_lidar_data(agent_id=agent_id),
imu=self.get_imu(agent_id=agent_id),
gnss=self.get_gnss(agent_id=agent_id),
wheel_odometry=self.get_wheel_odometry(agent_id=agent_id),
)
+
+ log.debug("Sensor frame before world capability filter: %s", frame)
return self._apply_world_capability_filter(frame)
+ def reset(self):
+ pass
+
@staticmethod
def _apply_world_capability_filter(frame: SensorFrame) -> SensorFrame:
"""Null sensor fields whose world capabilities are disabled in Bridge Setting."""
@@ -177,6 +198,12 @@ def _apply_world_capability_filter(frame: SensorFrame) -> SensorFrame:
continue
setattr(frame, field, None)
cleared.add(field)
+ # Camera geometry describes rgb/depth: drop it when neither is provided.
+ if not any(
+ is_world_capability_enabled(c)
+ for c in (WorldCapability.CAMERA_RGB, WorldCapability.CAMERA_DEPTH)
+ ):
+ frame.camera_params = None
return frame
def _require_ego_agent(self, agent_id: int, method: str) -> None:
@@ -185,8 +212,6 @@ def _require_ego_agent(self, agent_id: int, method: str) -> None:
f"{type(self).__name__} does not support {method} for agent {agent_id}"
)
- def reset(self):
- pass
def __init_subclass__(cls, abstract=False, **kwargs):
super().__init_subclass__(**kwargs)
diff --git a/avlite/c40_execution/c42_execution_strategy.py b/avlite/c40_execution/c42_execution_strategy.py
index 51c7dc0..0ab6517 100644
--- a/avlite/c40_execution/c42_execution_strategy.py
+++ b/avlite/c40_execution/c42_execution_strategy.py
@@ -22,6 +22,7 @@
TaskStrategy,
)
from avlite.c50_common.c51_capabilities import StackCapability, satisfies_requirements
+from avlite.c50_common.c52_world_sensor_datatypes import SensorFrame
from avlite.c50_common.c56_fps_tracker import FpsTracker
log = logging.getLogger(__name__)
@@ -156,7 +157,6 @@ def reset(self):
self.local_planner.reset()
if self.controller:
self.controller.reset()
- self.world.reset()
self.elapsed_real_time = 0
self.elapsed_sim_time = 0
self._last_cmd = None
@@ -252,15 +252,14 @@ def _can_actuate(self) -> bool:
# --- tick helpers ---
- def _localization_step(self) -> None:
- """Run one localization iteration using the current world capabilities."""
+ def _localization_step(self, sensors: SensorFrame) -> None:
+ """Run one localization iteration on the caller-supplied tick snapshot."""
if not self.localization:
return
world_ok = satisfies_requirements(self.localization.world_requirements, self.world.world_capabilities)
stack_ok = satisfies_requirements(self.localization.stack_requirements, self.available_stack_capabilities())
if world_ok and stack_ok:
- sensors = self.world.get_sensor_frame()
self.localization.localize(perception_model=self.pm, sensors=sensors)
self.localization_fps = self._localization_fps_tracker.tick()
# Harvest optional stack_event stamp on PerceptionModel (notify once, then clear).
@@ -276,8 +275,8 @@ def _localization_step(self) -> None:
f"Skipping."
)
- def _perception_step(self) -> None:
- """Run one perception iteration and update fps tracking."""
+ def _perception_step(self, sensors: SensorFrame) -> None:
+ """Run one perception iteration on the caller-supplied tick snapshot."""
if not self.perception:
log.debug("Perception strategy is not set. Skipping perception step.")
return
@@ -304,7 +303,6 @@ def _perception_step(self) -> None:
else:
self.pm.agent_vehicles = []
- sensors = self.world.get_sensor_frame()
self.perception.perceive(perception_model=self.pm, sensors=sensors)
self.perception_fps = self._perception_fps_tracker.tick()
@@ -314,11 +312,10 @@ def _perception_step(self) -> None:
self.pm.stack_event = None
self.task_runner.notify(event)
- def _replan_step(self) -> None:
- """Run one planning iteration (replan) and update FPS."""
+ def _replan_step(self, sensors: SensorFrame) -> None:
+ """Run one planning iteration (replan) on the caller-supplied tick snapshot."""
if not self.local_planner:
return
- sensors = self.world.get_sensor_frame()
self.local_planner.replan(perception_model=self.pm, sensors=sensors)
self.planner_fps = self._planner_fps_tracker.tick()
# Harvest optional stack_event stamps from plan artifacts (notify once, then clear).
@@ -338,13 +335,15 @@ def _replan_step(self) -> None:
gp.stack_event = None
self.task_runner.notify(event)
- def _control_step(self, sim_dt: float) -> None:
- """Recompute control command into ``_last_cmd`` (no world integrate)."""
+ def _control_step(self, sim_dt: float, sensors: SensorFrame) -> None:
+ """Recompute control command into ``_last_cmd`` (no world integrate).
+
+ Uses the caller-supplied tick snapshot.
+ """
if not self.controller or not self.local_planner:
return
if not self._can_actuate():
return
- sensors = self.world.get_sensor_frame()
local_plan = self.local_planner.get_local_plan()
cmd = self.controller.control(
self.pm.ego_vehicle, local_plan, control_dt=sim_dt,
diff --git a/avlite/c40_execution/c44_sync_executer.py b/avlite/c40_execution/c44_sync_executer.py
index c7d0382..7679067 100644
--- a/avlite/c40_execution/c44_sync_executer.py
+++ b/avlite/c40_execution/c44_sync_executer.py
@@ -72,48 +72,82 @@ def step(
pln_time_txt, cn_time_txt, pr_time_txt, loc_time_txt, sim_time_txt = "", "", "", "", ""
t0 = time.time()
+ # Ground-truth localization bypasses the localization strategy entirely.
+ gt_localization = is_world_stack_capability_enabled(StackCapability.LOCALIZATION)
+
+ # Resolve every pacing gate before running any stage, so the tick can take a
+ # single sensor snapshot and hand the same world instant to each stage. Gates
+ # include module presence so an unassembled stage never triggers a fetch.
+ do_localize = (
+ call_localize
+ and self.localization is not None
+ and not gt_localization
+ and self.elapsed_sim_time - self.__localization_last_time >= localization_dt
+ )
+ do_perceive = (
+ call_perceive
+ and self.perception is not None
+ and (
+ (not pace_perception)
+ or (self.elapsed_sim_time - self.__perception_last_time >= perception_dt)
+ )
+ )
+ do_replan = (
+ call_replan
+ and self.local_planner is not None
+ and (
+ (not pace_replan)
+ or (self.elapsed_sim_time - self.__planner_last_time >= replan_dt)
+ )
+ )
+ do_control = (
+ call_control
+ and self.controller is not None
+ and self.local_planner is not None
+ and (
+ (not pace_control)
+ or (self.elapsed_sim_time - self.__controller_last_time >= control_dt)
+ )
+ )
+
+ sensors = (
+ self.world.get_sensor_frame()
+ if (do_localize or do_perceive or do_replan or do_control)
+ else None
+ )
+
# Pose update: GT world → PM, or localization strategy → PM (mutually exclusive).
t_loc = time.time()
- if is_world_stack_capability_enabled(StackCapability.LOCALIZATION):
+ if gt_localization:
self.pm.ego_vehicle.copy_from(self.world.get_ego_state())
- elif call_localize and self.localization:
- if self.elapsed_sim_time - self.__localization_last_time >= localization_dt:
- self.__localization_last_time = self.elapsed_sim_time
- self._localization_step()
- loc_time_txt = f" LOC: {(time.time() - t_loc):.4f} sec,"
+ elif do_localize:
+ self.__localization_last_time = self.elapsed_sim_time
+ self._localization_step(sensors)
+ loc_time_txt = f" LOC: {(time.time() - t_loc):.4f} sec,"
# Perceive first so that planning and the visualization both operate on the
# same perception snapshot. Running perception after replan caused the planner
# to react to the previous frame's obstacles while the UI rendered the new
# perception model, making obstacles appear "detected but not visualized".
t2 = time.time()
- if call_perceive:
- if (not pace_perception) or (
- self.elapsed_sim_time - self.__perception_last_time >= perception_dt
- ):
- self.__perception_last_time = self.elapsed_sim_time
- self._perception_step()
- pr_time_txt = f" PR: {(time.time() - t2):.4f} sec,"
-
- if call_replan:
- if (not pace_replan) or (
- self.elapsed_sim_time - self.__planner_last_time >= replan_dt
- ):
- self.__planner_last_time = self.elapsed_sim_time
- self._replan_step()
- pln_time_txt = f" P: {(time.time() - t0):.2} sec,"
+ if do_perceive:
+ self.__perception_last_time = self.elapsed_sim_time
+ self._perception_step(sensors)
+ pr_time_txt = f" PR: {(time.time() - t2):.4f} sec,"
+
+ if do_replan:
+ self.__planner_last_time = self.elapsed_sim_time
+ self._replan_step(sensors)
+ pln_time_txt = f" P: {(time.time() - t0):.2} sec,"
if self.local_planner:
self.local_planner.step(self.pm.ego_vehicle)
t1 = time.time()
- if call_control:
- if (not pace_control) or (
- self.elapsed_sim_time - self.__controller_last_time >= control_dt
- ):
- self.__controller_last_time = self.elapsed_sim_time
- self._control_step(sim_dt)
- cn_time_txt = f"C: {(time.time() - t1):.4f} sec,"
+ if do_control:
+ self.__controller_last_time = self.elapsed_sim_time
+ self._control_step(sim_dt, sensors)
+ cn_time_txt = f"C: {(time.time() - t1):.4f} sec,"
# Free-run: advance sim and real by the same wall interval so the UI clocks match.
# Paced: fixed sim_dt; real time is measured separately over the full step.
diff --git a/avlite/c40_execution/c45_async_threaded_executer.py b/avlite/c40_execution/c45_async_threaded_executer.py
index 32ec37d..c6c8a9a 100644
--- a/avlite/c40_execution/c45_async_threaded_executer.py
+++ b/avlite/c40_execution/c45_async_threaded_executer.py
@@ -109,6 +109,13 @@ def step(
self.pace_control = pace_control
self.pace_sim = pace_sim
+ # Cooperative stop (e.g. StopExecAtGoalTask) must win over auto-restart.
+ # Callers that want to resume clear ``stopped`` first (Visualizer Start,
+ # headless reset). Otherwise the Tk poll loop would revive workers and
+ # clear the stop flag via start_threads().
+ if self.stopped:
+ return
+
if not self.threads_started:
log.info(f"Threads not started yet. Creating and starting threads.")
self.create_threads()
@@ -149,12 +156,61 @@ def worker_planning(self):
dt = t1 - self.__planner_last_step_time
self.__planner_elapsed_time += time.time() - self.__planner_start_time
- do_replan = (not self.pace_replan) or (dt > self.replan_dt)
- if dt > 10 * self.replan_dt:
+ # Resolve every gate before running any stage, so the iteration can take a
+ # single sensor snapshot and hand the same world instant to each stage. Gates
+ # include module presence so an unassembled stage never triggers a fetch.
+ replan_stalled = dt > 10 * self.replan_dt
+ if replan_stalled:
self.__planner_last_step_time = t1
- elif do_replan:
+ do_replan = (
+ not replan_stalled
+ and self.local_planner is not None
+ and ((not self.pace_replan) or (dt > self.replan_dt))
+ )
+
+ # Localization owns PM ego only when GT localization is not enabled.
+ do_localize = (
+ self.call_localize
+ and self.localization is not None
+ and not is_world_stack_capability_enabled(StackCapability.LOCALIZATION)
+ and t1 - __localize_last_t >= self.localization_dt
+ )
+
+ # Perception runs alongside planning, rate-limited by perception_dt.
+ # Only active when combined mode is on; in separate-thread mode the
+ # dedicated worker_perception thread handles this instead.
+ do_perceive = False
+ if self.call_perceive and self.perception and self._combined_perception_planning:
+ dt_p = t1 - self._perception_fps_tracker.last
+ if dt_p > 10 * self.perception_dt:
+ self._perception_fps_tracker.last = t1
+ else:
+ do_perceive = (not self.pace_perception) or (dt_p >= self.perception_dt)
+
+ sensors = (
+ self.world.get_sensor_frame()
+ if (do_replan or do_localize or do_perceive)
+ else None
+ )
+
+ # Match SyncExecuter: localize → perceive → replan on the shared
+ # snapshot so planning sees this iteration's ego/obstacles.
+ if do_localize:
+ try:
+ self._localization_step(sensors)
+ __localize_last_t = t1
+ except Exception as e:
+ log.error(f"Error in localization step: {e}", exc_info=True)
+
+ if do_perceive:
+ try:
+ self._perception_step(sensors)
+ except Exception as e:
+ log.error(f"Error in perception step: {e}", exc_info=True)
+
+ if do_replan:
self.__planner_last_step_time = time.time()
- self._replan_step()
+ self._replan_step(sensors)
if self.local_planner and self.controller:
self.controller.set_plan(self.local_planner.get_local_plan())
@@ -169,31 +225,6 @@ def worker_planning(self):
t2 = time.time()
log.debug("Planner iteration: dt=%.3fs, execution time=%.3fs", dt, t2 - t1)
- # Localization owns PM ego only when GT localization is not enabled.
- if (
- self.call_localize
- and not is_world_stack_capability_enabled(StackCapability.LOCALIZATION)
- ):
- if t1 - __localize_last_t >= self.localization_dt:
- try:
- self._localization_step()
- __localize_last_t = t1
- except Exception as e:
- log.error(f"Error in localization step: {e}", exc_info=True)
-
- # Perception runs alongside planning, rate-limited by perception_dt.
- # Only active when combined mode is on; in separate-thread mode the
- # dedicated worker_perception thread handles this instead.
- if self.call_perceive and self._combined_perception_planning:
- dt_p = time.time() - self._perception_fps_tracker.last
- if dt_p > 10 * self.perception_dt:
- self._perception_fps_tracker.last = time.time()
- elif (not self.pace_perception) or (dt_p >= self.perception_dt):
- try:
- self._perception_step()
- except Exception as e:
- log.error(f"Error in perception step: {e}", exc_info=True)
-
if self.pace_replan:
time.sleep(max(0, self.replan_dt - (time.time() - t1)))
else:
@@ -220,7 +251,8 @@ def worker_control(self):
with self.lock_world:
if is_world_stack_capability_enabled(StackCapability.LOCALIZATION):
self.pm.ego_vehicle.copy_from(self.world.get_ego_state())
- self._control_step(self.sim_dt)
+ if self.controller and self.local_planner:
+ self._control_step(self.sim_dt, self.world.get_sensor_frame())
# Free-run: sim and real share the same wall interval (start-of-iter stamps).
# Paced: fixed sim_dt; real accumulates full loop wall including sleep.
@@ -265,7 +297,7 @@ def worker_perception(self):
try:
t1 = time.time()
if self.perception and self.call_perceive:
- self._perception_step()
+ self._perception_step(self.world.get_sensor_frame())
t2 = time.time()
log.debug("Perception iteration: dt=%.3fs", t2 - t1)
if self.pace_perception:
diff --git a/avlite/c40_execution/c46_basic_sim.py b/avlite/c40_execution/c46_basic_sim.py
index 570102c..511b83a 100644
--- a/avlite/c40_execution/c46_basic_sim.py
+++ b/avlite/c40_execution/c46_basic_sim.py
@@ -103,23 +103,21 @@ def spawn_agent(
id = self.pm.add_agent_vehicle(agent_state)
ref = global_plan.trajectory if global_plan is not None else None
- if not self.npc_control or ref is None or len(ref.path) == 0:
- if self.npc_control and (ref is None or len(ref.path) == 0):
- log.warning("spawn_agent: no global plan available; NPC will not be controlled")
- return
-
- tj = TrajectoryTracker(
- path=list(ref.path),
- velocity=[v * self.speed_factor for v in ref.velocity],
- )
- tj.update_waypoint_by_xy(agent_state.x, agent_state.y)
- agent_state.velocity = tj.velocity[tj.current_wp]
-
- controller = StanleyController(tj=tj)
- controller.reset()
- self.npc_controllers[id] = controller
+ if self.npc_control and ref is not None and len(ref.path) > 0:
+ tj = TrajectoryTracker(
+ path=list(ref.path),
+ velocity=[v * self.speed_factor for v in ref.velocity],
+ )
+ tj.update_waypoint_by_xy(agent_state.x, agent_state.y)
+ agent_state.velocity = tj.velocity[tj.current_wp]
-
+ controller = StanleyController(tj=tj)
+ controller.reset()
+ self.npc_controllers[id] = controller
+ elif self.npc_control:
+ log.warning("spawn_agent: no global plan available; NPC will not be controlled")
+
+ agent_state.set_start()
def get_ego_state(self):
@@ -136,10 +134,12 @@ def get_ground_truth_perception_model(self) -> PerceptionModel:
return self.pm
def reset(self):
- """Clear simulated NPC agents and their controllers."""
- if self.pm is not None:
- self.pm.reset()
- self.npc_controllers = {}
+ """Restore the ego and simulated NPCs to their start poses."""
+ self.ego_state.reset()
+ for agent in self.pm.agent_vehicles if self.pm is not None else []:
+ agent.reset()
+ if agent.agent_id in self.npc_controllers:
+ self.npc_controllers[agent.agent_id].reset()
# ------------------------------------------------------------------
# 2D LiDAR simulation
diff --git a/avlite/c40_execution/c49_settings.py b/avlite/c40_execution/c49_settings.py
index f4efd99..7adb992 100644
--- a/avlite/c40_execution/c49_settings.py
+++ b/avlite/c40_execution/c49_settings.py
@@ -84,6 +84,10 @@ class ExecutionSettingsSchema(SettingsSchema):
default_factory=lambda: [24.46992202098782, 54.60522506805341],
description="WGS84 map origin (lat, lon) in degrees; derived from selected map or set manually.",
)
+ c40_start_pose: list[float] | None = Field(
+ default=None,
+ description="Ego start pose [x, y, theta]; null starts at the global plan start point.",
+ )
c40_async_combined_perception_planning: bool = Field(default=True, description="Run perception and planning concurrently.")
c40_log_level: str = Field(default="INFO", description="Python logging level.")
c40_log_to_file: bool = Field(default=False, description="Write logs to file.")
diff --git a/avlite/c50_common/c52_world_sensor_datatypes.py b/avlite/c50_common/c52_world_sensor_datatypes.py
index fd73c90..f4ceb6a 100644
--- a/avlite/c50_common/c52_world_sensor_datatypes.py
+++ b/avlite/c50_common/c52_world_sensor_datatypes.py
@@ -8,6 +8,7 @@
-----------------
rgb (H, W, 3) uint8, row-major RGB
depth (H, W) float32, metres
+camera_params CameraParams — intrinsic K + world-to-camera extrinsic for rgb/depth
lidar (N, 4) float32, [x, y, z, intensity] world frame
imu ImuReading — linear accel + angular velocity, sensor frame
gnss GnssReading — WGS84 lat/lon/alt + optional map x/y/z
@@ -81,6 +82,43 @@ class WheelOdometry:
yaw_rate: float # heading change rate, rad/s (+ = counter-clockwise)
+@dataclass
+class CameraParams:
+ """Pinhole geometry for one camera image.
+
+ Frame convention: ``world_to_camera`` maps homogeneous world (map) points
+ into the OpenCV optical camera frame — x right, y down, z forward along the
+ optical axis, z > 0 in front of the camera::
+
+ p_cam = world_to_camera @ [x_world, y_world, z_world, 1]
+ u = fx * X / Z + cx, v = fy * Y / Z + cy
+
+ World coordinates are the same frame as EgoState.x/y/z and
+ SensorFrame.lidar, so projecting a LiDAR cloud into the image needs no
+ other transform. ``world_to_camera`` is the pose at capture time and
+ therefore changes every frame as the ego moves.
+
+ Self-contained per camera: an instance carries everything needed to project
+ into its own image, so it stays valid unchanged if AVLite later grows a
+ multi-camera collection.
+ """
+
+ intrinsic: np.ndarray # (3, 3) float64 K = [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
+ world_to_camera: np.ndarray # (4, 4) float64 world → camera optical frame
+ width: int # pixels; resolution the intrinsic is valid for
+ height: int # pixels; resolution the intrinsic is valid for
+
+ def __post_init__(self) -> None:
+ self.intrinsic = np.asarray(self.intrinsic, dtype=np.float64)
+ self.world_to_camera = np.asarray(self.world_to_camera, dtype=np.float64)
+ if self.intrinsic.shape != (3, 3):
+ raise ValueError(f"expected (3, 3) intrinsic, got shape {self.intrinsic.shape}")
+ if self.world_to_camera.shape != (4, 4):
+ raise ValueError(
+ f"expected (4, 4) world_to_camera, got shape {self.world_to_camera.shape}"
+ )
+
+
@dataclass
class SensorFrame:
"""Snapshot of all sensor readings for one execution tick.
@@ -89,16 +127,22 @@ class SensorFrame:
gated off by the ExecutionSettings.c41_world_capabilities filter.
"""
- # Camera: colour image from the ego-mounted RGB camera.
+ # Camera: colour image from the primary camera.
# Shape (H, W, 3), dtype uint8, channels in RGB order (not BGR).
# H and W vary by camera; algorithms must not assume fixed resolution.
rgb: RgbImage | None = None
- # Camera: per-pixel distance from the camera plane.
+ # Camera: per-pixel distance from the primary camera's image plane.
# Shape (H, W), dtype float32, values in metres.
# Must match rgb height/width when both are present.
depth: DepthImage | None = None
+ # Geometry of the primary camera, i.e. the one that produced rgb/depth:
+ # intrinsic K + world-to-camera extrinsic at capture time. None when the
+ # bridge exposes no camera. Required to project world-frame lidar points
+ # into the image.
+ camera_params: CameraParams | None = None
+
# LiDAR: point cloud in the world (map) frame.
# Shape (N, 4), dtype float32, columns [x, y, z, intensity].
# x, y, z in metres; intensity is sensor-specific reflectance (0+).
diff --git a/avlite/c50_common/c54_trajectory_tracker.py b/avlite/c50_common/c54_trajectory_tracker.py
index 928facf..5eb5f30 100644
--- a/avlite/c50_common/c54_trajectory_tracker.py
+++ b/avlite/c50_common/c54_trajectory_tracker.py
@@ -194,7 +194,7 @@ def update_waypoint_by_xy(self, x_current: float, y_current: float) -> None:
if self.path_s[closest_wp] <= s_[0]:
if closest_wp < len(self.__reference_path) - 1:
self.current_wp = closest_wp
- self.next_wp = closest_wp + 1 % len(self.__reference_path)
+ self.next_wp = closest_wp + 1
elif closest_wp == len(self.__reference_path) - 1:
self.current_wp = closest_wp
self.next_wp = closest_wp
@@ -223,10 +223,27 @@ def update_waypoint_by_xy_forward(
self.next_wp = self.current_wp
def update_waypoint_by_wp(self, current_wp: int) -> None:
- self.current_wp = current_wp % len(self.__reference_path)
- self.next_wp = current_wp + 1 % len(self.__reference_path)
+ n = len(self.__reference_path)
+ if n == 0:
+ self.current_wp = 0
+ self.next_wp = 0
+ return
+ # Clamp at the final waypoint (same semantics as update_waypoint_by_xy).
+ # Do not use ``current_wp + 1 % n``: ``%`` binds tighter than ``+``, so that
+ # expression is ``current_wp + (1 % n)`` and leaves next_wp == n (OOB).
+ self.current_wp = current_wp % n
+ if self.current_wp < n - 1:
+ self.next_wp = self.current_wp + 1
+ else:
+ self.next_wp = self.current_wp
def update_to_next_waypoint(self) -> None:
+ n = len(self.__reference_path)
+ if n == 0:
+ return
+ if self.current_wp >= n - 1:
+ self.next_wp = self.current_wp
+ return
self.update_waypoint_by_wp(self.current_wp + 1)
def is_traversed(self) -> bool:
@@ -344,7 +361,7 @@ def create_quintic_trajectory_sd(
]
)
- b = np.array([d_start, d_end, start_d_1st_derv, start_d_2nd_derv, end_d_2nd_derv, end_d_2nd_derv])
+ b = np.array([d_start, d_end, start_d_1st_derv, end_d_1st_derv, start_d_2nd_derv, end_d_2nd_derv])
# Solve for the polynomial coefficients
coefficients = np.linalg.solve(A, b)
@@ -597,6 +614,16 @@ def convert_xy_to_sd(self, x: float, y: float) -> tuple[float, float]:
# # # s,d need to be current
def convert_sd_to_xy(self, s: float, d: float) -> tuple[float, float]:
closest_wp = self.get_closest_waypoint_frm_sd(s, d)
+ n = len(self.path_x)
+
+ if n < 2:
+ # Degenerate single-point path: no tangent; apply d with the same
+ # left-hand normal convention as the multi-point branch below.
+ heading = float(self.path_heading[0]) if len(self.path_heading) else 0.0
+ perp_heading = heading - math.pi / 2
+ x = float(self.path_x[0]) - d * math.cos(perp_heading)
+ y = float(self.path_y[0]) - d * math.sin(perp_heading)
+ return x, y
if closest_wp == 0:
next_wp = 1
@@ -605,33 +632,18 @@ def convert_sd_to_xy(self, s: float, d: float) -> tuple[float, float]:
next_wp = closest_wp
prev_wp = next_wp - 1
- if self.path_x[next_wp] == self.path_x[prev_wp] or self.path_y[next_wp] == self.path_y[prev_wp]:
- log.warning("The next and previous waypoints are the same, returning the previous waypoint coordinates.")
-
# Calculate the heading of the track at the previous waypoint
heading = math.atan2(
self.path_y[next_wp] - self.path_y[prev_wp],
self.path_x[next_wp] - self.path_x[prev_wp],
)
- # Calculate the x and y coordinates on the reference path
- if 0 <= prev_wp < len(self.path_x) and 0 <= self.path_s[prev_wp] <= s:
- # x = self.path_x[prev_wp] + (s - self.path_s[prev_wp]) * math.cos(heading)
- # y = self.path_y[prev_wp] + (s - self.path_s[prev_wp]) * math.sin(heading)
- # Ratio of progress between prev and next
- s0 = self.path_s[prev_wp]
- s1 = self.path_s[next_wp]
- if s1 == s0:
- ratio = 0
- else:
- ratio = (s - s0) / (s1 - s0)
-
- # Linear interpolation between path_x and path_y
- x = self.path_x[prev_wp] + ratio * (self.path_x[next_wp] - self.path_x[prev_wp])
- y = self.path_y[prev_wp] + ratio * (self.path_y[next_wp] - self.path_y[prev_wp])
- else:
- log.warning(f"Waypoint index {prev_wp} is out of bounds for path_x and path_y.")
- x = self.path_x[prev_wp]
- y = self.path_y[prev_wp]
+ # Linear interpolation along the segment; a ratio outside [0, 1] extrapolates
+ # past the segment ends, e.g. an s before the start of the path.
+ s0 = self.path_s[prev_wp]
+ s1 = self.path_s[next_wp]
+ ratio = 0.0 if s1 == s0 else (s - s0) / (s1 - s0)
+ x = self.path_x[prev_wp] + ratio * (self.path_x[next_wp] - self.path_x[prev_wp])
+ y = self.path_y[prev_wp] + ratio * (self.path_y[next_wp] - self.path_y[prev_wp])
# Calculate the perpendicular heading
perp_heading = heading - math.pi / 2
@@ -665,14 +677,16 @@ def convert_xy_path_to_sd_path(self, points):
_, closest_wps = self.__xy_kdtree.query(points_array) # shape (m,) — O(m log n)
reference_path = self.__reference_path
+ n_ref = len(reference_path)
frenet_coords = []
cumulative_distances = self.__cumulative_distances
for idx, point in enumerate(points_array):
- closest_wp = closest_wps[idx]
+ closest_wp = int(closest_wps[idx])
+ # Single-point (or last-wp slice) paths have no tangent segment; keep next==prev.
if closest_wp == 0:
- next_wp = 1
+ next_wp = 1 if n_ref > 1 else 0
prev_wp = 0
else:
next_wp = closest_wp
@@ -684,11 +698,12 @@ def convert_xy_path_to_sd_path(self, points):
x_y = point[1] - reference_path[prev_wp, 1]
# Compute the projection of the point onto the reference path
- if (n_x * n_x + n_y * n_y) == 0:
- proj_x = 0
- proj_y = 0
+ seg_len_sq = n_x * n_x + n_y * n_y
+ if seg_len_sq == 0:
+ proj_x = 0.0
+ proj_y = 0.0
else:
- proj_norm = (x_x * n_x + x_y * n_y) / (n_x * n_x + n_y * n_y) # normalized projection
+ proj_norm = (x_x * n_x + x_y * n_y) / seg_len_sq # normalized projection
proj_x = proj_norm * n_x
proj_y = proj_norm * n_y
@@ -700,7 +715,12 @@ def convert_xy_path_to_sd_path(self, points):
normal = np.array([-n_y, n_x]) # Rotate tangent vector by 90 degrees (left-hand normal)
vec_to_point = np.array([x_x - proj_x, x_y - proj_y])
- d = np.dot(vec_to_point, normal) / np.linalg.norm(normal)
+ norm_mag = float(np.linalg.norm(normal))
+ if norm_mag == 0.0:
+ # Degenerate segment (1-point path or duplicate waypoints): unsigned range.
+ d = float(np.hypot(vec_to_point[0], vec_to_point[1]))
+ else:
+ d = float(np.dot(vec_to_point, normal) / norm_mag)
frenet_coords.append((s, d))
@@ -715,8 +735,11 @@ def convert_xy_path_to_sd_path_np(self, points):
points_array = np.asarray(points, dtype=float) # (m, 2)
_, closest_wps = self.__xy_kdtree.query(points_array) # (m,) — O(m log n)
+ n_ref = len(self.__reference_path)
+ # When n_ref == 1, clamp next to 0 so indexing stays in bounds.
+ next_when_zero = 1 if n_ref > 1 else 0
prev_wps = np.where(closest_wps == 0, 0, closest_wps - 1) # (m,)
- next_wps = np.where(closest_wps == 0, 1, closest_wps) # (m,)
+ next_wps = np.where(closest_wps == 0, next_when_zero, closest_wps) # (m,)
seg_n = self.__reference_path[next_wps] - self.__reference_path[prev_wps] # (m, 2)
seg_vec = points_array - self.__reference_path[prev_wps] # (m, 2)
diff --git a/avlite/c60_apps/c62_factory.py b/avlite/c60_apps/c62_factory.py
index 3cb6d7f..454942d 100644
--- a/avlite/c60_apps/c62_factory.py
+++ b/avlite/c60_apps/c62_factory.py
@@ -113,7 +113,12 @@ def executor_factory(
default_global_plan = GlobalPlan()
log.debug("No default global plan; using empty GlobalPlan")
- stack_ego = EgoState(x=default_global_plan.start_point[0], y=default_global_plan.start_point[1])
+ start_pose = ExecutionSettings.c40_start_pose
+ stack_ego = (
+ EgoState(x=start_pose[0], y=start_pose[1], theta=start_pose[2])
+ if start_pose
+ else EgoState(x=default_global_plan.start_point[0], y=default_global_plan.start_point[1])
+ )
stack_ego.agent_id = EGO_AGENT_ID
pm = PerceptionModel(ego_vehicle=stack_ego)
diff --git a/avlite/configs/SAN Campus.yaml b/avlite/configs/SAN Campus.yaml
index e00631f..0db3f15 100644
--- a/avlite/configs/SAN Campus.yaml
+++ b/avlite/configs/SAN Campus.yaml
@@ -115,6 +115,7 @@ c40_execution:
c40_control_dt: 0.01
c40_controller: StanleyController
c40_executer_type: AsyncThreadedExecuter
+ c40_execution_tasks: []
c40_global_planner: HDMapGlobalPlanner
c40_global_trajectory: data/san_campus.json
c40_local_planner: VelocityLocalPlanner
@@ -124,11 +125,16 @@ c40_execution:
c40_log_to_file: false
c40_map: data/san_campus.xodr
c40_mapping: MapReader
+ c40_pace_control: true
+ c40_pace_perception: true
+ c40_pace_replan: true
+ c40_pace_sim: false
c40_perception: PerceptionPipeline
c40_perception_dt: 0.01
c40_reference_point: null
c40_replan_dt: 0.01
c40_sim_dt: 0.01
+ c40_start_pose: []
c41_world_capabilities:
- IMU
- GNSS
diff --git a/avlite/configs/default.yaml b/avlite/configs/default.yaml
index c03bb7f..a0bd958 100644
--- a/avlite/configs/default.yaml
+++ b/avlite/configs/default.yaml
@@ -139,6 +139,10 @@ c40_execution:
- 54.60522506805341
c40_replan_dt: 0.01
c40_sim_dt: 0.01
+ c40_start_pose:
+ - 276.3601612740704
+ - -714.886208468183
+ - -0.44511255835862823
c41_world_capabilities:
- LIDAR_2D
- LIDAR_3D
@@ -194,7 +198,7 @@ plugins:
p60_next_profile: perception
p60_shortcut_mode: false
p66_frenet_view_follow_planner: false
- p66_frenet_zoom: 50.0
+ p66_frenet_zoom: 30.0
p66_global_plan_velocity_scale: relative
p66_global_view_follow_planner: false
p66_global_zoom: 810.8967345284556
diff --git a/avlite/configs/global planning.yaml b/avlite/configs/global planning.yaml
index 53f456f..83aab73 100644
--- a/avlite/configs/global planning.yaml
+++ b/avlite/configs/global planning.yaml
@@ -115,6 +115,7 @@ c40_execution:
c40_control_dt: 0.01
c40_controller: StanleyController
c40_executer_type: SyncExecuter
+ c40_execution_tasks: []
c40_global_planner: GlobalRacePlanner
c40_global_trajectory: data/yas_marina_real_race_line_mue_0_5_3_m_margin.json
c40_local_planner: GreedyLatticePlanner
@@ -124,6 +125,10 @@ c40_execution:
c40_log_to_file: false
c40_map: data/race_boundary_yas_marina.map.json
c40_mapping: MapReader
+ c40_pace_control: true
+ c40_pace_perception: true
+ c40_pace_replan: true
+ c40_pace_sim: false
c40_perception: PerceptionPipeline
c40_perception_dt: 0.01
c40_reference_point:
@@ -131,6 +136,7 @@ c40_execution:
- 54.60522506805341
c40_replan_dt: 0.01
c40_sim_dt: 0.01
+ c40_start_pose: []
c41_world_capabilities: []
c41_world_stack_capabilities:
- DETECTION
diff --git a/avlite/configs/hdmap.yaml b/avlite/configs/hdmap.yaml
index da2b5c7..10733fe 100644
--- a/avlite/configs/hdmap.yaml
+++ b/avlite/configs/hdmap.yaml
@@ -121,7 +121,7 @@ c40_execution:
c40_local_planner: ShortestPathLatticePlanner
c40_localization: ''
c40_localization_dt: 0.1
- c40_log_level: WARN
+ c40_log_level: INFO
c40_log_to_file: false
c40_map: data/Town10HD_Opt.xodr
c40_mapping: MapReader
@@ -173,7 +173,7 @@ plugins:
p60_next_profile: hdmap
p60_shortcut_mode: false
p66_frenet_view_follow_planner: false
- p66_frenet_zoom: 30.0
+ p66_frenet_zoom: 20.0
p66_global_plan_velocity_scale: relative
p66_global_view_follow_planner: false
p66_global_zoom: 239.3495010406173
diff --git a/avlite/configs/local planning.yaml b/avlite/configs/local planning.yaml
index 0e69caf..a0acfb5 100644
--- a/avlite/configs/local planning.yaml
+++ b/avlite/configs/local planning.yaml
@@ -115,6 +115,7 @@ c40_execution:
c40_control_dt: 0.01
c40_controller: StanleyController
c40_executer_type: AsyncThreadedExecuter
+ c40_execution_tasks: []
c40_global_planner: GlobalCenterlineRacePlanner
c40_global_trajectory: data/yas_marina_real_race_line_mue_0_5_3_m_margin.json
c40_local_planner: GreedyLatticePlanner
@@ -124,6 +125,10 @@ c40_execution:
c40_log_to_file: false
c40_map: data/race_boundary_yas_marina.map.json
c40_mapping: MapReader
+ c40_pace_control: true
+ c40_pace_perception: true
+ c40_pace_replan: true
+ c40_pace_sim: false
c40_perception: PerceptionPipeline
c40_perception_dt: 0.01
c40_reference_point:
@@ -131,6 +136,10 @@ c40_execution:
- 54.60522506805341
c40_replan_dt: 0.01
c40_sim_dt: 0.01
+ c40_start_pose:
+ - 280.63221536066163
+ - -710.5963458529651
+ - -0.7758054245025982
c41_world_capabilities: []
c41_world_stack_capabilities:
- DETECTION
diff --git a/avlite/configs/perception.yaml b/avlite/configs/perception.yaml
index 4553428..2e0fa53 100644
--- a/avlite/configs/perception.yaml
+++ b/avlite/configs/perception.yaml
@@ -117,6 +117,7 @@ c40_execution:
c40_control_dt: 0.01
c40_controller: StanleyController
c40_executer_type: AsyncThreadedExecuter
+ c40_execution_tasks: []
c40_global_planner: GlobalCenterlineRacePlanner
c40_global_trajectory: data/yas_marina_real_race_line_mue_0_5_3_m_margin.json
c40_local_planner: GreedyLatticePlanner
@@ -126,6 +127,10 @@ c40_execution:
c40_log_to_file: false
c40_map: data/race_boundary_yas_marina.map.json
c40_mapping: MapReader
+ c40_pace_control: true
+ c40_pace_perception: true
+ c40_pace_replan: true
+ c40_pace_sim: false
c40_perception: PerceptionPipeline
c40_perception_dt: 0.01
c40_reference_point:
@@ -133,6 +138,7 @@ c40_execution:
- 54.60522506805341
c40_replan_dt: 0.01
c40_sim_dt: 0.01
+ c40_start_pose: null
c41_world_capabilities:
- LIDAR_2D
c41_world_stack_capabilities:
diff --git a/avlite/plugins/p60_visualizer_tk/p61_visualizer_app.py b/avlite/plugins/p60_visualizer_tk/p61_visualizer_app.py
index 5da1e10..6df0a6b 100644
--- a/avlite/plugins/p60_visualizer_tk/p61_visualizer_app.py
+++ b/avlite/plugins/p60_visualizer_tk/p61_visualizer_app.py
@@ -714,6 +714,16 @@ def teleport_ego(self, x, y, theta=None):
self.exec.world.teleport_ego(x, y, theta)
self.exec.pm.ego_vehicle.copy_from(self.exec.world.get_ego_state())
+ def apply_world_control(self, cmd, dt):
+ """Apply a control command to the plant and sync stack PM.
+
+ After the world/stack ego split, mutating only the plant leaves
+ ``pm.ego_vehicle`` stale until the next GT localization tick. Manual
+ Control Step / Steer must dual-write like :meth:`teleport_ego`.
+ """
+ self.exec.world.control_ego_state(cmd=cmd, dt=dt)
+ self.exec.pm.ego_vehicle.copy_from(self.exec.world.get_ego_state())
+
def spawn_agent(self, agent_state: AgentState) -> None:
"""Spawn an agent in the world using the ego's current global plan."""
if self.exec is None:
diff --git a/avlite/plugins/p60_visualizer_tk/p63_plugins_app.py b/avlite/plugins/p60_visualizer_tk/p63_plugins_app.py
index fdc7205..a19ef06 100644
--- a/avlite/plugins/p60_visualizer_tk/p63_plugins_app.py
+++ b/avlite/plugins/p60_visualizer_tk/p63_plugins_app.py
@@ -86,6 +86,11 @@
)
+def _display_name(entry: Optional[dict], name: str) -> str:
+ """Registry ``display_name``, falling back to the plugin identifier."""
+ return str((entry or {}).get("display_name") or "").strip() or name
+
+
class GitHubApiError(Exception):
"""GitHub REST API failure with optional SAML SSO authorize URL."""
@@ -507,6 +512,11 @@ def dependency_notes(entry: dict) -> str:
"""Return stripped ``dependency_notes``, or ``""`` when absent/blank."""
return str(entry.get("dependency_notes") or "").strip()
+ @staticmethod
+ def site_url(entry: dict) -> str:
+ """Return stripped ``site_url``, or ``""`` when absent/blank."""
+ return str(entry.get("site_url") or "").strip()
+
@staticmethod
def check_plugin_update(
plugin_path: Path,
@@ -833,7 +843,7 @@ def __init__(
self._repo_url = _PluginOperations.get_plugin_repository_url(registry_entry, install_path)
self._dpi_scale = dpi_scale
self.window = tk.Toplevel(app.window)
- self.window.title(name)
+ self.window.title(_display_name(registry_entry, name))
self.window.transient(app.window)
self.window.geometry(f"{DpiScale.scaled(700, dpi_scale)}x{DpiScale.scaled(500, dpi_scale)}")
self.window.minsize(DpiScale.scaled(400, dpi_scale), DpiScale.scaled(300, dpi_scale))
@@ -847,7 +857,12 @@ def __init__(
meta = ttk.Frame(outer)
meta.grid(row=0, column=0, sticky="ew", pady=(0, 8))
- for label, key in (("Author", "author"), ("Version", "version"), ("Description", "description")):
+ for label, key in (
+ ("Author", "author"),
+ ("Version", "version"),
+ ("Description", "description"),
+ ("Website", "site_url"),
+ ):
row = ttk.Frame(meta)
row.pack(fill=tk.X, anchor=tk.W, pady=1)
ttk.Label(row, text=f"{label}:", width=12).pack(side=tk.LEFT)
@@ -868,6 +883,15 @@ def __init__(
actions = ttk.Frame(footer)
actions.pack(side=tk.LEFT)
+ site = _PluginOperations.site_url(entry)
+ if site:
+ btn_site = ttk.Button(
+ actions,
+ text="Open Website",
+ command=lambda: webbrowser.open(site),
+ )
+ btn_site.pack(side=tk.LEFT, padx=(0, 6))
+ HoverTooltip.attach(btn_site, BUTTON_TOOLTIPS["cp_site"])
if self._repo_url:
btn_github = ttk.Button(
actions,
@@ -1383,7 +1407,7 @@ def _fmt_category(category) -> str:
tk.END,
iid=name,
values=(
- name,
+ _display_name(entry, name),
_fmt_category(entry.get("category", "")),
entry.get("author", ""),
entry.get("version", ""),
diff --git a/avlite/plugins/p60_visualizer_tk/p65_ui_lib.py b/avlite/plugins/p60_visualizer_tk/p65_ui_lib.py
index 54aef86..f96690c 100644
--- a/avlite/plugins/p60_visualizer_tk/p65_ui_lib.py
+++ b/avlite/plugins/p60_visualizer_tk/p65_ui_lib.py
@@ -494,6 +494,7 @@ def _hide(self, _event=None) -> None:
"exec_stop": "Stop the loop and halt the world bridge.",
"exec_step": "Advance one execution tick without continuous run.",
"exec_reset": "Reset the executer and world to the initial state.",
+ "exec_set_start": "Save the current ego pose as the profile start position (written to the active profile YAML).",
# Planning
"plan_global_replan": "Recompute the global route from the map and planner.",
"plan_save_global": "Save the current global plan to a JSON file.",
@@ -549,6 +550,7 @@ def _hide(self, _event=None) -> None:
"cp_update": "Update the selected plugin to the latest version.",
"cp_update_all": "Update all installed plugins that have updates.",
"cp_github": "Open the plugin repository on GitHub.",
+ "cp_site": "Open the plugin project website.",
"cp_open_folder": "Open the plugin install folder in the file manager.",
"cp_close": "Close this window.",
"cp_sign_in": "Sign in with GitHub to browse member-only plugins.",
diff --git a/avlite/plugins/p60_visualizer_tk/p66_plot_views.py b/avlite/plugins/p60_visualizer_tk/p66_plot_views.py
index 40a507e..2199b7b 100644
--- a/avlite/plugins/p60_visualizer_tk/p66_plot_views.py
+++ b/avlite/plugins/p60_visualizer_tk/p66_plot_views.py
@@ -551,6 +551,8 @@ def plot(self):
if not _canvas_ready(canvas_widget):
self.root.after_idle(self.plot)
return
+ if self.root.setting.exec_running:
+ self.local_plot.hide_distance_ruler()
width = canvas_widget.winfo_width()
height = canvas_widget.winfo_height()
aspect_ratio = width / height
diff --git a/avlite/plugins/p60_visualizer_tk/p67_stack_views.py b/avlite/plugins/p60_visualizer_tk/p67_stack_views.py
index aa37f33..6c3bb6c 100644
--- a/avlite/plugins/p60_visualizer_tk/p67_stack_views.py
+++ b/avlite/plugins/p60_visualizer_tk/p67_stack_views.py
@@ -34,6 +34,7 @@
from avlite.c40_execution.c43_task_strategy import TaskStrategy
from avlite.c40_execution.c49_settings import ExecutionSettings
from avlite.c60_apps.c69_settings import AppSettings
+from avlite.c60_apps.c65_setting_utils import save_setting
from avlite.plugins.p60_visualizer_tk.p65_ui_lib import (
ValueGauge,
DataPicker,
@@ -619,45 +620,48 @@ def step_control(self):
sensors=self.root.exec.world.get_sensor_frame(),
)
- self.root.exec.world.control_ego_state(
- cmd=cmd, dt=self.root.setting.sim_dt.get())
+ self.root.apply_world_control(cmd, dt=self.root.setting.sim_dt.get())
self.root.update_ui()
def align_control(self):
+ """Snap plant + stack ego to the plan location (same dual-write as teleport)."""
if not self.root.exec or not self.root.exec.controller or not self.root.exec.local_planner:
return
- self.root.exec.ego_state.x, self.root.exec.ego_state.y = self.root.exec.local_planner.location_xy
+ x, y = self.root.exec.local_planner.location_xy
+ # Must move world ego and sync stack PM — mutating only exec.ego_state is undone
+ # on the next GT-localization tick after the world/stack ego split.
+ self.root.teleport_ego(x, y)
self.root.exec.controller.reset()
self.root.update_ui()
def step_steer_left(self):
log.debug("Steer right")
- self.root.exec.world.control_ego_state(cmd=ControlCommand(
- steer=0.7), dt=self.root.setting.sim_dt.get())
+ self.root.apply_world_control(
+ ControlCommand(steer=0.7), dt=self.root.setting.sim_dt.get())
self.root.update_ui()
def step_steer_right(self):
log.debug("Steer right")
- self.root.exec.world.control_ego_state(cmd=ControlCommand(
- steer=-0.7), dt=self.root.setting.sim_dt.get())
+ self.root.apply_world_control(
+ ControlCommand(steer=-0.7), dt=self.root.setting.sim_dt.get())
self.root.update_ui()
def reset_steer(self):
log.debug("Reset steer")
- self.root.exec.world.control_ego_state(cmd=ControlCommand(
- steer=0), dt=self.root.setting.sim_dt.get())
+ self.root.apply_world_control(
+ ControlCommand(steer=0), dt=self.root.setting.sim_dt.get())
self.root.update_ui()
def step_acc(self):
acc = 3
- self.root.exec.world.control_ego_state(
- cmd=ControlCommand(acceleration=acc), dt=self.root.setting.sim_dt.get())
+ self.root.apply_world_control(
+ ControlCommand(acceleration=acc), dt=self.root.setting.sim_dt.get())
self.root.update_ui()
def step_dec(self):
acc = -3
- self.root.exec.world.control_ego_state(
- cmd=ControlCommand(acceleration=acc), dt=self.root.setting.sim_dt.get())
+ self.root.apply_world_control(
+ ControlCommand(acceleration=acc), dt=self.root.setting.sim_dt.get())
self.root.update_ui()
# --------------------------------------------------------------------------------------------
@@ -816,8 +820,13 @@ def __init__(self, root: VisualizerApp):
self.root.setting.execution_tasks.trace_add("write", lambda *_: self._rebuild_task_chips())
self._rebuild_task_chips()
- vehicle_state_label = ttk.Label(exec_third_frame, font=self.root.small_font, textvariable=self.root.setting.vehicle_state)
- vehicle_state_label.pack(side=tk.TOP, fill=tk.X, padx=5, pady=1)
+ state_row = ttk.Frame(exec_third_frame)
+ state_row.pack(side=tk.TOP, fill=tk.X, padx=5, pady=1)
+ btn_set_start = ttk.Button(state_row, text="Save Start", width=10, command=self.set_start)
+ btn_set_start.pack(side=tk.RIGHT, padx=(2, 0))
+ HoverTooltip.attach(btn_set_start, BUTTON_TOOLTIPS["exec_set_start"])
+ vehicle_state_label = ttk.Label(state_row, font=self.root.small_font, textvariable=self.root.setting.vehicle_state)
+ vehicle_state_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
def _rebuild_task_chips(self, event=None) -> None:
@@ -917,6 +926,10 @@ def toggle_exec(self):
self.stop_exec()
return
self.root.setting.exec_running = True
+ # Cooperative stop (StopExecAtGoalTask) leaves executer.stopped set; clear
+ # it so AsyncThreadedExecuter.step may create workers again on Start.
+ if self.root.exec is not None:
+ self.root.exec.stopped = False
# self.start_exec_button.config(state=tk.DISABLED)
self.start_exec_button.state(['disabled'])
self.root.update_ui()
@@ -924,6 +937,13 @@ def toggle_exec(self):
def _exec_loop(self):
if self.root.setting.exec_running:
+ # Task-driven stop (e.g. StopExecAtGoalTask) flips executer.stopped but
+ # not exec_running. Mirror it into the UI so we do not keep polling
+ # step() — async step used to recreate workers and clear stopped.
+ if self.root.exec is not None and self.root.exec.stopped:
+ self.stop_exec()
+ return
+
current_time = time.time()
cn_dt = float(self.root.setting.control_dt.get())
pl_dt = float(self.root.setting.replan_dt.get())
@@ -944,7 +964,11 @@ def _exec_loop(self):
pace_replan=bool(self.root.setting.pace_replan.get()),
pace_control=bool(self.root.setting.pace_control.get()),
pace_sim=pace_sim,
- ),
+ )
+
+ if self.root.exec.stopped:
+ self.stop_exec()
+ return
# Throttle UI updates to 20 Hz regardless of step() speed.
# This decouples simulation rate from widget redraw rate.
@@ -1013,6 +1037,23 @@ def reset_exec(self):
self.root.exec.reset()
self.root.update_ui()
+ def set_start(self):
+ ego = self.root.exec.world.get_ego_state()
+ stack = self.root.exec.ego_state
+ ExecutionSettings.c40_start_pose = [ego.x, ego.y, ego.theta]
+ # Profile YAML stores pose only; snapshot velocity at 0 so Reset matches a
+ # cold start (NPC spawn still captures non-zero velocity via set_start).
+ world_v, stack_v = ego.velocity, stack.velocity
+ ego.velocity = 0.0
+ stack.velocity = 0.0
+ ego.set_start()
+ stack.set_start()
+ ego.velocity = world_v
+ stack.velocity = stack_v
+ profile = self.root.setting.c60_selected_profile.get()
+ save_setting(ExecutionSettings, profile=profile)
+ log.info(f"Start pose saved to profile {profile!r}: ({ego.x:.2f}, {ego.y:.2f}, {ego.theta:.2f})")
+
class ExecSettingsFrame(ttk.LabelFrame):
def __init__(self, root: VisualizerApp, view):
super().__init__(view, text="Executables")
diff --git a/docs/CNAME b/docs/CNAME
new file mode 100644
index 0000000..1e0f421
--- /dev/null
+++ b/docs/CNAME
@@ -0,0 +1 @@
+avlite.org
diff --git a/docs/architecture.md b/docs/architecture.md
index 2c65d14..3bf684c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -155,6 +155,8 @@ class MyLocalPlanner(LocalPlanningStrategy):
- `AGENT_SPAWN` - Bridge can spawn NPC agents
- `AGENT_CONTROL` - Bridge can actuate spawned NPC agents via `control_agent` (opt-in; separate from `AGENT_SPAWN`)
+A bridge declaring `CAMERA_RGB` or `CAMERA_DEPTH` must also populate `SensorFrame.camera_params` via `get_camera_params()`. LiDAR reaches the stack already transformed to the world frame, but an image cannot be, so the camera intrinsic and per-frame world-to-camera extrinsic are what let a fusion strategy project world-frame points into the image. See [Plugin Development → Camera geometry](plugin-development.md#worldbridge-api-phase-1-vs-future).
+
**Stack Capabilities** (`StackCapability`) — what a stack module produces, used both as a module's `stack_capabilities` and as another module's `stack_requirements`:
- `DETECTION` - Object detection
@@ -242,7 +244,7 @@ CLI and GUI entry points, each an `AppStrategy` (see [App Strategy](#app-strateg
### **Common**
-YAML profile load/save, hot reload, plugin discovery (`c63_plugins`), path resolution (`c68_paths`), capability enums, canonical sensor layouts (rgb, depth, lidar, imu, gnss between bridge and perception), collision checking, and settings validation (`c64_settings_schema`).
+YAML profile load/save, hot reload, plugin discovery (`c63_plugins`), path resolution (`c68_paths`), capability enums, canonical sensor layouts (rgb, depth, camera_params, lidar, imu, gnss between bridge and perception), collision checking, and settings validation (`c64_settings_schema`).
## Data Flow
diff --git a/docs/imgs/logo-icon.png b/docs/imgs/logo-icon.png
index 7a405c4..4bf6f1b 100644
Binary files a/docs/imgs/logo-icon.png and b/docs/imgs/logo-icon.png differ
diff --git a/docs/imgs/tk_visualizer.mp4 b/docs/imgs/tk_visualizer.mp4
new file mode 100644
index 0000000..2611415
Binary files /dev/null and b/docs/imgs/tk_visualizer.mp4 differ
diff --git a/docs/imgs/tk_visualizer.png b/docs/imgs/tk_visualizer.png
index 2b79ea6..0347737 100644
Binary files a/docs/imgs/tk_visualizer.png and b/docs/imgs/tk_visualizer.png differ
diff --git a/docs/index.md b/docs/index.md
index 43040c0..3072a22 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -6,7 +6,10 @@ hide:
-{ .hero-logo }
+
+
+
+
AVLite
@@ -27,11 +30,13 @@ avlite # launch the visualizer
[Community](community.md){ .md-button target=_blank rel=noopener }
[GitHub](https://github.com/AV-Lab/avlite){ .md-button }
+
-
-
-
-
+
+
+
+
@@ -114,7 +119,11 @@ avlite # launch the visualizer
-
+
+
+
Real-time Tk visualizer: live plots, per-layer tuning, and profile management.
diff --git a/docs/javascripts/community.js b/docs/javascripts/community.js
index 0ccdb09..f4fda7b 100644
--- a/docs/javascripts/community.js
+++ b/docs/javascripts/community.js
@@ -2,12 +2,15 @@
// (plugins.yaml) and public GitHub repo stats, then renders a searchable,
// filterable, sortable card grid. Loaded site-wide via extra_javascript, so it
// only acts when the page contains #store-grid, and re-initializes on
-// Material's instant navigation via the document$ observable.
+// Material's instant navigation via the document$ observable. Its js-yaml
+// dependency is fetched on demand so other pages never download it.
(function () {
"use strict";
var REGISTRY_URL =
"https://raw.githubusercontent.com/AV-Lab/avlite-community-plugins/main/plugins.yaml";
+ var JS_YAML_URL =
+ "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js";
var CACHE_PREFIX = "avlite-store:";
var CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -38,11 +41,35 @@
// ----------------------------------------------------------------- fetch
+ var yamlLoading = null;
+
+ // Fetched here rather than site-wide, so the other pages never pay for it.
+ // A warm registry cache skips the parser entirely.
+ function loadJsYaml() {
+ if (window.jsyaml) return Promise.resolve();
+ if (yamlLoading) return yamlLoading;
+
+ yamlLoading = new Promise(function (resolve, reject) {
+ var script = document.createElement("script");
+ script.src = JS_YAML_URL;
+ script.async = true;
+ script.onload = resolve;
+ script.onerror = function () {
+ yamlLoading = null;
+ reject(new Error("failed to load js-yaml"));
+ };
+ document.head.appendChild(script);
+ });
+
+ return yamlLoading;
+ }
+
function fetchRegistry() {
var cached = cacheGet("registry");
if (cached) return Promise.resolve(cached);
- return fetch(REGISTRY_URL)
- .then(function (res) {
+ return Promise.all([fetch(REGISTRY_URL), loadJsYaml()])
+ .then(function (results) {
+ var res = results[0];
if (!res.ok) throw new Error("registry HTTP " + res.status);
return res.text();
})
@@ -163,6 +190,7 @@
var stats = p._stats;
var cats = Array.isArray(p.category) ? p.category : [p.category];
var author = p.author || "";
+ var label = p.display_name || p.name || "";
var statsHtml = "";
if (stats) {
@@ -179,7 +207,11 @@
}
var notes = p.dependency_notes
- ? '' + escapeHtml(p.dependency_notes) + "
"
+ ? '' +
+ escapeHtml(p.dependency_notes) +
+ "
"
: "";
var minVer = p.min_avlite_version
@@ -188,16 +220,30 @@
""
: "";
- return (
- '' +
+ (p.site_url
+ ? ' Site '
+ : "") +
+ '' +
+ '" target="_blank" rel="noopener" aria-label="' +
+ escapeHtml(label) +
+ ' repository">Repo ' +
+ "";
+
+ return (
+ '' +
'
' +
'
' +
"
" +
- '' + escapeHtml(p.name) + " " +
+ '' + escapeHtml(label) + " " +
'by ' + escapeHtml(author) + " " +
"
" +
"
" +
@@ -211,8 +257,9 @@
minVer +
"
" +
notes +
+ actions +
statsHtml +
- ""
+ ""
);
}
@@ -242,6 +289,7 @@
if (!q) return true;
return (
(p.name || "").toLowerCase().indexOf(q) !== -1 ||
+ (p.display_name || "").toLowerCase().indexOf(q) !== -1 ||
(p.description || "").toLowerCase().indexOf(q) !== -1 ||
(p.author || "").toLowerCase().indexOf(q) !== -1
);
@@ -258,7 +306,9 @@
var tb = sb && sb.pushed_at ? new Date(sb.pushed_at).getTime() : 0;
return tb - ta;
}
- return (a.name || "").localeCompare(b.name || "");
+ return (a.display_name || a.name || "").localeCompare(
+ b.display_name || b.name || ""
+ );
});
grid.innerHTML = shown.length
diff --git a/docs/javascripts/landing.js b/docs/javascripts/landing.js
index 769fcde..21c1dbd 100644
--- a/docs/javascripts/landing.js
+++ b/docs/javascripts/landing.js
@@ -1,21 +1,180 @@
-// Landing-page header treatment: transparent/blurred over the hero at the top,
-// solid (primary) once scrolled or on any inner page. Works with Material's
-// instant navigation via the document$ observable.
+// Landing-page header treatment + subtle ASCII smoke over the hero logo.
+// Works with Material's instant navigation via the document$ observable.
(function () {
- function update() {
+ var COLS = 15;
+ var ROWS = 12;
+ // Light density ramp — deliberately stops short of heavy glyphs so the
+ // plume stays a soft wisp rather than a solid block.
+ var CHARS = " .·:;+*";
+ var MAX_PARTICLES = 48;
+ var TICK_MS = 70;
+ // Soft splat so neighbouring cells connect into a continuous column
+ // instead of reading as scattered dots.
+ var SPLAT = [
+ [0, 0, 1],
+ [-1, 0, 0.4],
+ [1, 0, 0.4],
+ [0, -1, 0.3],
+ [0, 1, 0.3],
+ [-1, -1, 0.14],
+ [-1, 1, 0.14],
+ ];
+
+ var smokeTimer = null;
+ var particles = [];
+ var smokeEl = null;
+
+ // On , not , so overrides/main.html can set landing-home before
+ // first paint and avoid a flash of the wrong header.
+ function updateHeader() {
+ var root = document.documentElement;
var isLanding = !!document.querySelector(".hero");
- document.body.classList.toggle("landing-home", isLanding);
- document.body.classList.toggle(
+ root.classList.toggle("landing-home", isLanding);
+ root.classList.toggle(
"landing-scrolled",
isLanding && window.scrollY > 40
);
}
- window.addEventListener("scroll", update, { passive: true });
+ function restartHeroEnter() {
+ var nodes = document.querySelectorAll(
+ ".hero-logo-wrap, .hero .hero-wordmark"
+ );
+ if (!nodes.length) return;
+ for (var i = 0; i < nodes.length; i++) {
+ nodes[i].style.animation = "none";
+ }
+ void document.body.offsetWidth;
+ for (var j = 0; j < nodes.length; j++) {
+ nodes[j].style.animation = "";
+ }
+ }
+
+ function prefersReducedMotion() {
+ return (
+ window.matchMedia &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+ }
+
+ function stopAsciiSmoke() {
+ if (smokeTimer) {
+ clearInterval(smokeTimer);
+ smokeTimer = null;
+ }
+ particles = [];
+ smokeEl = null;
+ }
+
+ function spawnParticle(atBase) {
+ particles.push({
+ x: COLS * 0.5 - 0.7 + Math.random() * 1.4,
+ // Stagger seeded particles up the column so the plume is continuous
+ // from the first frame.
+ y: atBase ? ROWS - 0.5 - Math.random() : Math.random() * ROWS,
+ vx: (Math.random() - 0.5) * 0.14,
+ vy: -0.16 - Math.random() * 0.16,
+ life: 1,
+ // Slow enough to survive the full climb up the taller column.
+ decay: 0.007 + Math.random() * 0.008,
+ dens: 0.45 + Math.random() * 0.35,
+ wobble: Math.random() * Math.PI * 2,
+ });
+ }
+
+ function renderAsciiSmoke(el) {
+ var grid = [];
+ var r, c, i, s, p, row, col, dens, idx;
+
+ for (r = 0; r < ROWS; r++) {
+ grid[r] = [];
+ for (c = 0; c < COLS; c++) grid[r][c] = 0;
+ }
+
+ for (i = 0; i < particles.length; i++) {
+ p = particles[i];
+ row = Math.floor(p.y + 0.5);
+ col = Math.floor(p.x + 0.5);
+ dens = p.life * p.dens;
+ for (s = 0; s < SPLAT.length; s++) {
+ var rr = row + SPLAT[s][0];
+ var cc = col + SPLAT[s][1];
+ if (rr < 0 || rr >= ROWS || cc < 0 || cc >= COLS) continue;
+ grid[rr][cc] += dens * SPLAT[s][2] * 0.6;
+ }
+ }
+
+ var lines = [];
+ for (r = 0; r < ROWS; r++) {
+ var line = "";
+ // Thin out toward the top so the plume dissipates as it rises.
+ var rowFade = 0.5 + 0.5 * (r / (ROWS - 1));
+ for (c = 0; c < COLS; c++) {
+ // Cap below 1 so the densest glyph stays rare.
+ dens = Math.min(0.92, grid[r][c] * rowFade);
+ idx = Math.floor(dens * CHARS.length);
+ line += CHARS.charAt(Math.min(CHARS.length - 1, idx));
+ }
+ lines.push(line);
+ }
+ el.textContent = lines.join("\n");
+ }
+
+ function tickAsciiSmoke() {
+ if (!smokeEl || !smokeEl.isConnected) {
+ stopAsciiSmoke();
+ return;
+ }
+
+ var i, p;
+
+ if (particles.length < MAX_PARTICLES && Math.random() < 0.8) {
+ spawnParticle(true);
+ }
+
+ for (i = particles.length - 1; i >= 0; i--) {
+ p = particles[i];
+ p.wobble += 0.13;
+ p.vx += Math.sin(p.wobble) * 0.007;
+ p.x += p.vx;
+ p.y += p.vy;
+ p.vx *= 0.99;
+ p.life -= p.decay;
+ if (p.life <= 0 || p.y < -0.5) particles.splice(i, 1);
+ }
+
+ renderAsciiSmoke(smokeEl);
+ }
+
+ function startAsciiSmoke() {
+ stopAsciiSmoke();
+ if (prefersReducedMotion()) return;
+
+ smokeEl = document.querySelector(".hero-smoke-ascii");
+ if (!smokeEl) return;
+
+ for (var i = 0; i < 36; i++) spawnParticle(false);
+ renderAsciiSmoke(smokeEl);
+ // The element starts transparent, so the already-seeded plume eases in
+ // rather than snapping into place whenever this script finally runs.
+ smokeEl.style.opacity = "1";
+ smokeTimer = setInterval(tickAsciiSmoke, TICK_MS);
+ }
+
+ var navigated = false;
+
+ function onNavigate() {
+ updateHeader();
+ if (navigated) restartHeroEnter();
+ navigated = true;
+ startAsciiSmoke();
+ }
+
+ window.addEventListener("scroll", updateHeader, { passive: true });
if (window.document$) {
- window.document$.subscribe(update);
+ window.document$.subscribe(onNavigate);
} else {
- document.addEventListener("DOMContentLoaded", update);
+ document.addEventListener("DOMContentLoaded", onNavigate);
}
})();
diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js
index 117b046..7ad1897 100644
--- a/docs/javascripts/mathjax.js
+++ b/docs/javascripts/mathjax.js
@@ -1,3 +1,14 @@
+// MathJax, loaded only on pages that actually contain math. The library is
+// ~1 MB, and only one page uses it, so pulling it in from extra_javascript
+// made every other page (including the landing page) wait on it.
+//
+// pymdownx.arithmatex with `generic: true` wraps every expression in
+// .arithmatex, so that class is a reliable per-page signal. The check runs on
+// document$ rather than once at startup, because Material's instant navigation
+// swaps pages without a full document load.
+
+var MATHJAX_SRC = "https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js";
+
window.MathJax = {
tex: {
inlineMath: [["\\(", "\\)"]],
@@ -11,6 +22,45 @@ window.MathJax = {
},
};
-document$.subscribe(() => {
- MathJax.typesetPromise();
-});
+(function () {
+ "use strict";
+
+ var loading = null;
+
+ function loadMathJax() {
+ if (loading) return loading;
+
+ loading = new Promise(function (resolve, reject) {
+ var script = document.createElement("script");
+ script.src = MATHJAX_SRC;
+ script.async = true;
+ script.onload = resolve;
+ script.onerror = function () {
+ // Allow a later page to retry rather than staying permanently broken.
+ loading = null;
+ reject(new Error("failed to load MathJax"));
+ };
+ document.head.appendChild(script);
+ });
+
+ return loading;
+ }
+
+ function typeset() {
+ if (!document.querySelector(".arithmatex")) return;
+
+ loadMathJax()
+ .then(function () {
+ return window.MathJax.typesetPromise();
+ })
+ .catch(function () {
+ /* leave the raw TeX visible rather than blanking the page */
+ });
+ }
+
+ if (window.document$) {
+ window.document$.subscribe(typeset);
+ } else {
+ document.addEventListener("DOMContentLoaded", typeset);
+ }
+})();
diff --git a/docs/overrides/main.html b/docs/overrides/main.html
new file mode 100644
index 0000000..17f0d95
--- /dev/null
+++ b/docs/overrides/main.html
@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+
+{#
+ Social sharing metadata. Material does not emit Open Graph tags without the
+ optional `social` plugin (which needs cairo/pillow), so declare them here.
+#}
+{% block extrahead %}
+ {% set social_title = config.site_name if page.is_homepage else page.title ~ " - " ~ config.site_name %}
+ {% set social_description = page.meta.description if page.meta and page.meta.description else config.site_description %}
+ {% set social_image = (config.site_url or "") ~ "imgs/logo-black-bg.png" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if page and page.is_homepage %}
+ {#
+ The hero logo is the LCP element, so start it before the stylesheet has
+ been parsed. The badges are cross-origin, so warm that connection early.
+ #}
+
+
+
+ {#
+ Landing header chrome is glass-on-dark rather than the solid banner used
+ elsewhere. landing.js also sets this, but not until it has downloaded and
+ run, which paints the wrong header for a beat on a slow connection.
+ #}
+
+ {% endif %}
+{% endblock %}
diff --git a/docs/overview.md b/docs/overview.md
index cd0d0ab..35ff58a 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -122,12 +122,14 @@ See [Plugin Development — Publish to the community registry](plugin-developmen
```yaml
plugins:
- name: my_perception_plugin
+ display_name: My Perception Plugin # optional, defaults to name
description: One-line summary of what the plugin does
repository: https://github.com/your-org/your-plugin-repo
version: latest # or a tag/commit SHA
author: your-org
category:
- PerceptionStrategy
+ site_url: "" # optional project website
```
4. Open a pull request. Once merged, the plugin appears in every user's
diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index 9985bad..c11742a 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -382,6 +382,7 @@ Set `agent_type` when spawning non-car NPCs. Do not infer platform type from `ag
| `control_agent(id, cmd)` | Default: ego delegates to `control_ego_state`; NPC raises `NotImplementedError` | Override + declare `WorldCapability.AGENT_CONTROL` |
| `teleport_agent(agent_state)` | Default: ego delegates to `teleport_ego` using pose (`x`, `y`, `theta`) from `agent_state`; NPC raises `NotImplementedError`. Identity is `agent_state.agent_id`; velocity/size/type are not applied | Override for sim teleport of any agent |
| `get_*(agent_id=EGO_AGENT_ID)` | Default: ego returns data or `None`; NPC raises `NotImplementedError` | Per-agent sensors in Carla / ROS bridges |
+| `get_camera_params(agent_id=...)` | Default `None`; required when the bridge declares `CAMERA_RGB` / `CAMERA_DEPTH` | Multi-camera collection alongside the primary camera |
| `get_sensor_frame(agent_id=...)` | Ego: calls legacy `get_*()` with no kwargs (BasicSim-compatible) | Non-ego: passes `agent_id` to each getter |
| `step(dt)` | Default no-op; executer does not call it yet | Physics tick with held command; executer sub-stepping |
@@ -389,6 +390,20 @@ Set `agent_type` when spawning non-car NPCs. Do not infer platform type from `ag
**Multi-agent sensors:** override getters with an `agent_id` parameter when your bridge serves more than ego. Ego-only bridges (e.g. BasicSim) need no update — `get_sensor_frame()` uses the legacy no-kwargs call path for ego.
+**Camera geometry:** a bridge declaring `WorldCapability.CAMERA_RGB` or `CAMERA_DEPTH` must also override `get_camera_params()`. An image cannot be pre-transformed into the world frame the way a point cloud can, so `CameraParams` is the only way a fusion strategy can project world-frame `sensors.lidar` into the image:
+
+```python
+def get_camera_params(self, agent_id=EGO_AGENT_ID) -> CameraParams:
+ return CameraParams(
+ intrinsic=self._K, # (3, 3) [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
+ world_to_camera=self._extrinsic(), # (4, 4), recomputed each tick as the ego moves
+ width=self._width,
+ height=self._height,
+ )
+```
+
+`world_to_camera` maps homogeneous world (map) points — same frame as `EgoState.x/y/z` and `SensorFrame.lidar` — into the **OpenCV optical frame**: x right, y down, z forward along the optical axis, with z > 0 in front of the camera. Getting this convention wrong produces a plausible-looking but incorrect projection, so convert in the bridge rather than passing simulator or ROS axes through. The LiDAR mounting pose is not needed anywhere: the bridge already consumed it when transforming points to world frame.
+
### State model — today vs future
**Today:** `AgentState` uses pose (`x`, `y`, `z`, `theta`) plus scalar **`velocity`** (speed along heading). This matches the car-centric stack (planning, collision checking, BasicSim, visualization).
@@ -525,22 +540,30 @@ Fork [avlite-community-plugins](https://github.com/AV-Lab/avlite-community-plugi
```yaml
plugins:
- name: my_perception_plugin
+ display_name: My Perception Plugin # optional
description: One-line summary of what the plugin does
repository: https://github.com/your-org/your-plugin-repo
version: latest # or a git tag / commit SHA
author: your-org
category:
- PerceptionStrategy
+ min_avlite_version: "0.4.5" # optional
+ dependency_notes: "" # optional
+ site_url: "" # optional
```
-| Field | Notes |
-|-------|-------|
-| `name` | Unique registry id; also the install folder name under `~/.local/share/avlite/plugins/`. Use lowercase with underscores. |
-| `description` | Short text in the plugin list. |
-| `repository` | HTTPS Git URL (GitHub is supported for README preview in the browser). |
-| `version` | `latest` clones the default branch; pin a tag or SHA for reproducible installs. |
-| `author` | Display name, handle, or organization. |
-| `category` | List of strategy types this plugin provides (see table below). Shown in the Plugins browser **Category** column. |
+| Field | Required | Notes |
+|-------|:--------:|-------|
+| `name` | yes | Unique registry id; also the install folder name under `~/.local/share/avlite/plugins/`, the `avlite.plugins.` import path (dashes become underscores), and the `plugin_.yaml` settings basename. Use lowercase with underscores, no spaces, and don't change it once published. |
+| `display_name` | no | Human-readable name shown in the Plugins browser and the online plugin store, e.g. `My Perception Plugin`. Omit it to display `name` instead. |
+| `description` | yes | Short text in the plugin list. |
+| `repository` | yes | HTTPS Git URL (GitHub is supported for README preview in the browser). |
+| `version` | yes | `latest` clones the default branch; pin a tag or SHA for reproducible installs. |
+| `author` | yes | Display name, handle, or organization. |
+| `category` | yes | List of strategy types this plugin provides (see table below). Shown in the Plugins browser **Category** column. |
+| `min_avlite_version` | no | Minimum AVLite version (semver, e.g. `0.4.5`). Installs are blocked below it. Omit or leave empty if unknown. |
+| `dependency_notes` | no | Extra setup beyond `requirements.txt` (system packages, ROS, simulators). Shown after install. Use `""` when pip-only. |
+| `site_url` | no | Project website or documentation page. Adds a **Site** link in the plugin store and an **Open Website** button in the Plugins browser. Use `""` when the repository is the only home. |
**Category values** (use the names from [avlite-community-plugins](https://github.com/AV-Lab/avlite-community-plugins)):
@@ -580,7 +603,7 @@ You do not need a new AVLite release for registry-only changes.
### Updating your listing
- **New plugin version** — push to your repo; users click **Update** in the Plugins browser (or reinstall). Bump `version` in `plugins.yaml` if you want to pin a new tag/SHA for fresh installs.
-- **Change metadata** — open another PR on avlite-community-plugins to edit `description`, `author`, `category`, or `version`.
+- **Change metadata** — open another PR on avlite-community-plugins to edit `display_name`, `description`, `author`, `category`, `version`, or `site_url`. Avoid changing `name`: it is the install folder and settings-file identifier, so renaming it orphans existing installs.
## 12. Built-in plugin naming (`pNx`)
diff --git a/docs/quick-start.md b/docs/quick-start.md
index 973f01f..d7522c0 100644
--- a/docs/quick-start.md
+++ b/docs/quick-start.md
@@ -39,7 +39,11 @@ avlite
```
- { width="720" }
+
+
+
The Tk visualizer with real-time plots and configuration panels.
diff --git a/docs/robots.txt b/docs/robots.txt
new file mode 100644
index 0000000..892d9ac
--- /dev/null
+++ b/docs/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://avlite.org/sitemap.xml
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
index a24ce59..0f6e54d 100644
--- a/docs/stylesheets/extra.css
+++ b/docs/stylesheets/extra.css
@@ -48,15 +48,108 @@
margin: 3rem 0 0;
}
+/* Logo + ASCII character smoke rising from the mark. */
+.hero-logo-wrap {
+ position: relative;
+ width: 128px;
+ max-width: 40vw;
+ margin: 0.25rem auto 0.35rem;
+ /* Reserve just enough room for the plume; the rest overlaps the logo. */
+ padding-top: 2.25rem;
+ overflow: visible;
+ animation: hero-enter 0.9s ease-out both;
+}
+
.hero-logo {
- width: 96px;
+ position: relative;
+ z-index: 1;
+ display: block;
+ width: 100%;
height: auto;
- max-width: 36vw;
- margin: 0 auto 0.15rem;
+ /* Reserve the square before the PNG arrives; without this the hero
+ collapses and everything below it jumps down on first load. */
+ aspect-ratio: 1 / 1;
/* Brighter, punchier mark with a stronger cyan bloom. */
filter: brightness(1.12) saturate(1.1)
drop-shadow(0 0 10px rgba(0, 172, 225, 0.55))
drop-shadow(0 8px 26px rgba(0, 172, 225, 0.45));
+ animation: hero-glow-breathe 5.5s ease-in-out infinite;
+}
+
+/* Div (not pre) so Material's .md-typeset pre rules cannot break layout. */
+.md-typeset .hero-smoke-ascii,
+.hero .hero-smoke-ascii {
+ position: absolute;
+ left: 50%;
+ /* Rises into the hero's own top padding, so a taller plume costs no
+ extra layout height. */
+ top: -2rem;
+ /* max-content (not Nch) so letter-spacing is included in the width;
+ otherwise the row overflows right and the plume sits off-centre. */
+ width: max-content;
+ /* Exactly the rendered rows, so no dead space above the plume. */
+ height: 13.2em;
+ margin: 0;
+ padding: 0;
+ transform: translateX(-50%);
+ z-index: 2;
+ pointer-events: none;
+ overflow: hidden;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ color: rgba(130, 212, 236, 0.62);
+ font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas,
+ monospace;
+ font-size: 11px;
+ font-weight: 400;
+ line-height: 1.1;
+ letter-spacing: 0.08em;
+ text-align: left;
+ text-shadow: 0 0 7px rgba(0, 172, 225, 0.38);
+ white-space: pre;
+ user-select: none;
+ /* landing.js fades this in once the first frame is rendered. */
+ opacity: 0;
+ transition: opacity 700ms ease;
+ /* Dissolve the plume as it rises instead of clipping at the box edge. */
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ transparent 0%,
+ rgba(0, 0, 0, 0.45) 22%,
+ #000 55%
+ );
+ mask-image: linear-gradient(
+ to bottom,
+ transparent 0%,
+ rgba(0, 0, 0, 0.45) 22%,
+ #000 55%
+ );
+}
+
+@keyframes hero-glow-breathe {
+ 0%,
+ 100% {
+ filter: brightness(1.12) saturate(1.1)
+ drop-shadow(0 0 10px rgba(0, 172, 225, 0.5))
+ drop-shadow(0 8px 26px rgba(0, 172, 225, 0.4));
+ }
+ 50% {
+ filter: brightness(1.18) saturate(1.15)
+ drop-shadow(0 0 18px rgba(0, 172, 225, 0.75))
+ drop-shadow(0 10px 32px rgba(0, 172, 225, 0.55));
+ }
+}
+
+@keyframes hero-enter {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
/* Clean typographic wordmark, replacing the baked-in logo text. */
@@ -69,6 +162,36 @@
letter-spacing: 0.02em;
line-height: 1;
text-shadow: 0 2px 18px rgba(0, 172, 225, 0.25);
+ animation: hero-enter 0.9s ease-out 0.12s both;
+}
+
+@media (max-width: 44.9375em) {
+ .hero-logo-wrap {
+ padding-top: 2rem;
+ }
+
+ .md-typeset .hero-smoke-ascii,
+ .hero .hero-smoke-ascii {
+ font-size: 10px;
+ top: -1.75rem;
+ height: 13.2em;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hero-logo-wrap,
+ .hero .hero-wordmark {
+ animation: none;
+ }
+
+ .hero-logo {
+ animation: none;
+ }
+
+ .md-typeset .hero-smoke-ascii,
+ .hero .hero-smoke-ascii {
+ display: none;
+ }
}
.hero h1,
@@ -162,14 +285,15 @@
/* Landing screenshot as a product shot with faux window chrome. */
.md-typeset figure.shot {
- margin: 0 auto 1.5rem;
+ margin: 0 0 1.5rem;
+ width: 100%;
}
.shot-frame {
- display: inline-block;
+ display: block;
width: 100%;
- max-width: 560px;
- margin: 0 auto;
+ max-width: none;
+ margin: 0;
border: 1px solid rgba(0, 172, 225, 0.25);
border-radius: 8px;
overflow: hidden;
@@ -199,13 +323,21 @@
.landing-shot {
display: block;
width: 100%;
+ height: auto;
+ /* Matches the mp4 (1280×1416); wrong AR letterboxes inside the chrome. */
+ aspect-ratio: 1280 / 1416;
+ object-fit: cover;
+ vertical-align: top;
}
/* ---------------------------------------------------------------------------
Header chrome. No top tabs — peer pages live in the left sidebar.
Dark mode: simple black banner (landing uses glass until scroll).
Light mode: brand primary (landing included — dark glass muddies on white).
- `landing-home` / `landing-scrolled` come from javascripts/landing.js.
+ `landing-home` / `landing-scrolled` sit on : overrides/main.html sets
+ the first one before paint so the banner never flashes, and landing.js keeps
+ both current across instant navigation. The colour scheme attribute is on
+ , hence the html-then-body descendant chain below.
--------------------------------------------------------------------------- */
[data-md-color-scheme="slate"] .md-header {
@@ -213,7 +345,7 @@
box-shadow: none;
}
-[data-md-color-scheme="slate"] body.landing-home .md-header {
+.landing-home [data-md-color-scheme="slate"] .md-header {
background-color: rgba(5, 7, 10, 0.55);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
@@ -221,13 +353,13 @@
transition: background-color 250ms ease, box-shadow 250ms ease;
}
-[data-md-color-scheme="slate"] body.landing-home.landing-scrolled .md-header {
+.landing-home.landing-scrolled [data-md-color-scheme="slate"] .md-header {
background-color: #000000;
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
-[data-md-color-scheme="default"] body.landing-home .md-header {
+.landing-home [data-md-color-scheme="default"] .md-header {
background-color: var(--md-primary-fg-color);
-webkit-backdrop-filter: none;
backdrop-filter: none;
@@ -425,7 +557,7 @@
margin: 0 0 2rem;
}
-.md-typeset a.store-card {
+.md-typeset .store-card {
display: flex;
flex-direction: column;
gap: 0.55rem;
@@ -437,8 +569,8 @@
transition: border-color 125ms, box-shadow 125ms, transform 125ms;
}
-.md-typeset a.store-card:hover,
-.md-typeset a.store-card:focus {
+.md-typeset .store-card:not(.store-card--skeleton):hover,
+.md-typeset .store-card:focus-within {
color: var(--md-typeset-color);
border-color: var(--md-accent-fg-color);
box-shadow: 0 14px 34px -20px rgba(0, 172, 225, 0.55);
@@ -478,7 +610,6 @@
font-size: 0.72rem;
line-height: 1.45;
color: var(--md-default-fg-color--light);
- flex-grow: 1;
}
.store-card-tags {
@@ -488,6 +619,10 @@
}
.store-card-notes {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
margin: 0;
font-size: 0.62rem;
line-height: 1.4;
@@ -516,6 +651,43 @@
opacity: 0.75;
}
+/* Buttons and the stats footer form the pinned bottom block. */
+.store-card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: auto;
+ padding-top: 0.2rem;
+}
+
+.md-typeset a.store-btn {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.2rem 0.7rem;
+ font-size: 0.65rem;
+ font-weight: 700;
+ line-height: 1.6;
+ color: var(--md-accent-fg-color);
+ border: 1px solid var(--md-accent-fg-color);
+ border-radius: 1rem;
+ transition: background-color 125ms, color 125ms;
+}
+
+.md-typeset a.store-btn:hover,
+.md-typeset a.store-btn:focus {
+ background: color-mix(in srgb, var(--md-accent-fg-color) 12%, transparent);
+}
+
+.md-typeset a.store-btn--primary {
+ color: var(--md-primary-bg-color, #fff);
+ background: var(--md-accent-fg-color);
+}
+
+.md-typeset a.store-btn--primary:hover,
+.md-typeset a.store-btn--primary:focus {
+ background: color-mix(in srgb, var(--md-accent-fg-color) 82%, #000);
+}
+
/* Loading skeletons. */
.store-card--skeleton {
display: flex;
@@ -594,4 +766,9 @@
.md-typeset .value-strip {
font-size: 0.7rem;
}
+ /* Keep the install snippet (incl. trailing comments) on screen. */
+ .hero .highlight code,
+ .hero-cta .highlight code {
+ font-size: 11px;
+ }
}
diff --git a/mkdocs.yml b/mkdocs.yml
index 7d6df12..17e1bcc 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,4 +1,5 @@
site_name: AVLite
+site_url: https://avlite.org/
site_description: Modular Autonomous Vehicle Stack
site_author: AV-Lab
@@ -20,6 +21,7 @@ validation:
theme:
name: material
+ custom_dir: docs/overrides
logo: imgs/logo-icon.png
icon:
repo: fontawesome/brands/github
@@ -58,11 +60,11 @@ theme:
extra_css:
- stylesheets/extra.css
+# Only small local scripts load site-wide. Their heavy CDN dependencies
+# (MathJax, js-yaml) are injected on demand by the page that needs them.
extra_javascript:
- javascripts/landing.js
- javascripts/mathjax.js
- - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js
- - https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js
- javascripts/community.js
extra:
diff --git a/pyproject.toml b/pyproject.toml
index 40f9ac8..cd61efc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,13 @@ dev = [
[project.scripts]
avlite = "avlite.__main__:main"
+[project.urls]
+Homepage = "https://avlite.org/"
+Documentation = "https://avlite.org/"
+Repository = "https://github.com/AV-Lab/avlite"
+Issues = "https://github.com/AV-Lab/avlite/issues"
+Changelog = "https://github.com/AV-Lab/avlite/blob/main/CHANGELOG.md"
+
[tool.setuptools.packages.find]
include = ["avlite", "avlite.*"]
diff --git a/test/c10_perception/test_c11_save_start_velocity.py b/test/c10_perception/test_c11_save_start_velocity.py
new file mode 100644
index 0000000..7741de5
--- /dev/null
+++ b/test/c10_perception/test_c11_save_start_velocity.py
@@ -0,0 +1,22 @@
+"""Save Start must snapshot velocity 0 so Reset matches a cold profile start."""
+
+from avlite.c10_perception.c11_perception_model import EgoState
+
+
+def test_save_start_snapshot_zeros_velocity_while_preserving_live_speed():
+ """Mirrors ExecView.set_start: capture pose with v=0, keep live velocity."""
+ ego = EgoState(x=10.0, y=20.0, theta=0.5, velocity=0.0)
+ ego.velocity = 12.5
+
+ live_v = ego.velocity
+ ego.velocity = 0.0
+ ego.set_start()
+ ego.velocity = live_v
+
+ assert ego.velocity == 12.5
+ ego.x, ego.y, ego.theta, ego.velocity = 99.0, 99.0, 0.0, 0.0
+ ego.reset()
+ assert ego.x == 10.0
+ assert ego.y == 20.0
+ assert ego.theta == 0.5
+ assert ego.velocity == 0.0
diff --git a/test/c20_planning/test_c27_velocity_planner_end_of_path.py b/test/c20_planning/test_c27_velocity_planner_end_of_path.py
new file mode 100644
index 0000000..2d5ad68
--- /dev/null
+++ b/test/c20_planning/test_c27_velocity_planner_end_of_path.py
@@ -0,0 +1,30 @@
+"""VelocityLocalPlanner must survive a replan at the final global waypoint."""
+
+from avlite.c10_perception.c11_perception_model import EgoState, PerceptionModel
+from avlite.c20_planning.c21_planning_model import GlobalPlan
+from avlite.c20_planning.c27_local_behavioral_and_velocity_planners import VelocityLocalPlanner
+from avlite.c50_common.c54_trajectory_tracker import TrajectoryTracker
+
+
+def test_velocity_local_planner_replan_at_final_waypoint():
+ path = [(float(i), 0.0) for i in range(20)]
+ velocity = [8.0] * 20
+ tj = TrajectoryTracker(path=path, velocity=velocity)
+ ego = EgoState(x=19.0, y=0.0, theta=0.0, velocity=5.0)
+ gp = GlobalPlan(
+ trajectory=tj,
+ path=path,
+ velocity=velocity,
+ start_point=(0.0, 0.0),
+ goal_point=(19.0, 0.0),
+ )
+ pm = PerceptionModel(ego_vehicle=ego)
+ planner = VelocityLocalPlanner(global_plan=gp, env=pm)
+ planner.global_trajectory.update_waypoint_by_xy(19.0, 0.0)
+ assert planner.global_trajectory.current_wp == len(path) - 1
+
+ planner.replan(perception_model=pm)
+ local = planner.get_local_plan()
+ assert local is not None
+ assert local.trajectory is not None
+ assert len(local.trajectory.path) == 1
diff --git a/test/c40_execution/test_c41_world_capability_filters.py b/test/c40_execution/test_c41_world_capability_filters.py
index d4379c1..cdb6268 100644
--- a/test/c40_execution/test_c41_world_capability_filters.py
+++ b/test/c40_execution/test_c41_world_capability_filters.py
@@ -14,13 +14,15 @@
)
from avlite.c40_execution.c49_settings import ExecutionSettings
from avlite.c50_common.c51_capabilities import StackCapability, WorldCapability
-from avlite.c50_common.c52_world_sensor_datatypes import GnssReading, LidarCloud
+from avlite.c50_common.c52_world_sensor_datatypes import CameraParams, GnssReading, LidarCloud
@dataclass
class _StubSensorBridge(WorldBridge):
ego_state: EgoState = None # type: ignore[assignment]
- world_capabilities = frozenset({WorldCapability.LIDAR_2D, WorldCapability.GNSS})
+ world_capabilities = frozenset(
+ {WorldCapability.LIDAR_2D, WorldCapability.GNSS, WorldCapability.CAMERA_RGB}
+ )
stack_capabilities = frozenset()
def __post_init__(self):
@@ -36,6 +38,14 @@ def get_lidar_data(self, agent_id=0) -> LidarCloud:
def get_gnss(self, agent_id=0) -> GnssReading:
return GnssReading(latitude=1.0, longitude=2.0, altitude=0.0)
+ def get_rgb_image(self, agent_id=0):
+ return np.zeros((4, 4, 3), dtype=np.uint8)
+
+ def get_camera_params(self, agent_id=0) -> CameraParams:
+ return CameraParams(
+ intrinsic=np.eye(3), world_to_camera=np.eye(4), width=4, height=4
+ )
+
def test_world_capability_none_means_all_enabled():
ExecutionSettings.c41_world_capabilities = None
@@ -72,6 +82,40 @@ def test_get_sensor_frame_keeps_lidar_if_either_2d_or_3d_enabled():
ExecutionSettings.c41_world_capabilities = None
+def test_get_sensor_frame_keeps_camera_params_when_camera_enabled():
+ bridge = _StubSensorBridge()
+ ExecutionSettings.c41_world_capabilities = ["CAMERA_RGB"]
+ try:
+ frame = bridge.get_sensor_frame()
+ assert frame.rgb is not None
+ assert frame.camera_params is not None
+ assert frame.camera_params.width == 4
+ finally:
+ ExecutionSettings.c41_world_capabilities = None
+
+
+def test_get_sensor_frame_nulls_camera_params_when_camera_disabled():
+ bridge = _StubSensorBridge()
+ ExecutionSettings.c41_world_capabilities = ["LIDAR_2D"]
+ try:
+ frame = bridge.get_sensor_frame()
+ assert frame.rgb is None
+ assert frame.camera_params is None
+ finally:
+ ExecutionSettings.c41_world_capabilities = None
+
+
+def test_get_sensor_frame_keeps_camera_params_for_depth_only_camera():
+ bridge = _StubSensorBridge()
+ ExecutionSettings.c41_world_capabilities = ["CAMERA_DEPTH"]
+ try:
+ frame = bridge.get_sensor_frame()
+ assert frame.rgb is None
+ assert frame.camera_params is not None
+ finally:
+ ExecutionSettings.c41_world_capabilities = None
+
+
def test_world_stack_capability_none_means_all_enabled():
ExecutionSettings.c41_world_stack_capabilities = None
assert is_world_stack_capability_enabled(StackCapability.DETECTION)
diff --git a/test/c40_execution/test_c42_sensor_snapshot.py b/test/c40_execution/test_c42_sensor_snapshot.py
new file mode 100644
index 0000000..6c9b242
--- /dev/null
+++ b/test/c40_execution/test_c42_sensor_snapshot.py
@@ -0,0 +1,130 @@
+"""One sensor snapshot per executer tick, shared by every stage."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from types import SimpleNamespace
+from typing import Optional
+
+import pytest
+
+from avlite.c10_perception.c11_perception_model import EGO_AGENT_ID, EgoState, PerceptionModel
+from avlite.c20_planning.c21_planning_model import LocalPlan
+from avlite.c30_control.c31_control_model import AckermannControlCommand
+from avlite.c40_execution.c44_sync_executer import SyncExecuter
+from avlite.c40_execution.c41_world_bridge import WorldBridge
+from avlite.c40_execution.c49_settings import ExecutionSettings
+from avlite.c50_common.c51_capabilities import StackCapability
+from avlite.c50_common.c52_world_sensor_datatypes import SensorFrame
+
+
+@dataclass
+class _CountingWorld(WorldBridge):
+ """Hands out a distinct SensorFrame per fetch, so sharing is observable by identity."""
+
+ ego_state: EgoState = field(default_factory=lambda: EgoState(x=0.0, y=0.0, theta=0.0))
+ perception_model: Optional[PerceptionModel] = None
+ world_capabilities = frozenset()
+ stack_capabilities = frozenset()
+
+ def __post_init__(self):
+ self.fetches: list[SensorFrame] = []
+
+ def control_ego_state(self, cmd, dt: Optional[float] = 0.01):
+ pass
+
+ def get_sensor_frame(self, agent_id: int = EGO_AGENT_ID) -> SensorFrame:
+ frame = SensorFrame(stamp=float(len(self.fetches)))
+ self.fetches.append(frame)
+ return frame
+
+
+def _make_exec(world: _CountingWorld, seen: dict) -> SyncExecuter:
+ """Full stack of stubs that record the SensorFrame each stage was handed."""
+
+ def perceive(*, perception_model=None, sensors=None):
+ seen["perceive"] = sensors
+
+ def localize(*, perception_model=None, sensors=None):
+ seen["localize"] = sensors
+
+ def replan(*, perception_model=None, sensors=None):
+ seen["replan"] = sensors
+
+ def control(ego, plan=None, control_dt=None, perception_model=None, sensors=None):
+ seen["control"] = sensors
+ return AckermannControlCommand()
+
+ return SyncExecuter(
+ perception_model=PerceptionModel(ego_vehicle=EgoState(x=0.0, y=0.0)),
+ world=world,
+ perception=SimpleNamespace(
+ world_requirements=frozenset(),
+ stack_requirements=frozenset(),
+ stack_capabilities=frozenset(),
+ perceive=perceive,
+ ),
+ localization=SimpleNamespace(
+ world_requirements=frozenset(),
+ stack_requirements=frozenset(),
+ stack_capabilities=frozenset({StackCapability.LOCALIZATION}),
+ localize=localize,
+ ),
+ global_planner=None,
+ local_planner=SimpleNamespace(
+ world_requirements=frozenset(),
+ stack_requirements=frozenset(),
+ stack_capabilities=frozenset(),
+ replan=replan,
+ get_local_plan=lambda: LocalPlan(),
+ step=lambda state: None,
+ global_plan=None,
+ ),
+ controller=SimpleNamespace(
+ world_requirements=frozenset(),
+ stack_requirements=frozenset(),
+ stack_capabilities=frozenset({StackCapability.CONTROL}),
+ control=control,
+ ),
+ )
+
+
+@pytest.fixture(autouse=True)
+def _restore_stack_cap_filter():
+ prev = ExecutionSettings.c41_world_stack_capabilities
+ # Disable world ground truth so the localization stage runs from sensors.
+ ExecutionSettings.c41_world_stack_capabilities = []
+ yield
+ ExecutionSettings.c41_world_stack_capabilities = prev
+
+
+def test_one_tick_fetches_one_frame_shared_by_every_stage():
+ world = _CountingWorld()
+ seen: dict = {}
+ exec_ = _make_exec(world, seen)
+
+ exec_.step(
+ sim_dt=0.01, perception_dt=0.0, replan_dt=0.0, control_dt=0.0, localization_dt=0.0,
+ )
+
+ assert len(world.fetches) == 1
+ frame = world.fetches[0]
+ assert set(seen) == {"localize", "perceive", "replan", "control"}
+ assert all(s is frame for s in seen.values())
+
+
+def test_tick_with_no_stage_due_skips_the_fetch():
+ world = _CountingWorld()
+ seen: dict = {}
+ exec_ = _make_exec(world, seen)
+
+ exec_.step(
+ sim_dt=0.01, perception_dt=0.0, replan_dt=0.0, control_dt=0.0, localization_dt=0.0,
+ )
+ assert len(world.fetches) == 1
+
+ # Every period now far exceeds the elapsed sim time, so nothing is due.
+ exec_.step(
+ sim_dt=0.01, perception_dt=1e6, replan_dt=1e6, control_dt=1e6, localization_dt=1e6,
+ )
+ assert len(world.fetches) == 1
diff --git a/test/c40_execution/test_c43_task_strategy.py b/test/c40_execution/test_c43_task_strategy.py
index 514b1a4..7a4a205 100644
--- a/test/c40_execution/test_c43_task_strategy.py
+++ b/test/c40_execution/test_c43_task_strategy.py
@@ -229,12 +229,12 @@ def test_harvest_plan_stack_event_notifies_once():
local_planner=local_planner,
controller=None,
)
- executer._replan_step()
+ executer._replan_step(world.get_sensor_frame())
assert LocalPlanFailedListener.calls == [StackEvent.LOCAL_PLAN_FAILED]
assert plan.stack_event is None
LocalPlanFailedListener.calls = []
- executer._replan_step()
+ executer._replan_step(world.get_sensor_frame())
assert LocalPlanFailedListener.calls == []
@@ -264,12 +264,12 @@ def perceive(*, perception_model=None, sensors=None):
local_planner=None,
controller=None,
)
- executer._perception_step()
+ executer._perception_step(world.get_sensor_frame())
assert ParkingZoneListener.calls == [StackEvent.PARKING_ZONE_ENTERED]
assert pm.stack_event is None
ParkingZoneListener.calls = []
- executer._perception_step()
+ executer._perception_step(world.get_sensor_frame())
assert ParkingZoneListener.calls == []
@@ -299,12 +299,12 @@ def test_harvest_control_stack_event_notifies_once():
local_planner=local_planner,
controller=controller,
)
- executer._control_step(sim_dt=0.01)
+ executer._control_step(sim_dt=0.01, sensors=world.get_sensor_frame())
assert ControlHaltedListener.calls == [StackEvent.CONTROL_HALTED]
assert cmd.stack_event is None
ControlHaltedListener.calls = []
- executer._control_step(sim_dt=0.01)
+ executer._control_step(sim_dt=0.01, sensors=world.get_sensor_frame())
assert ControlHaltedListener.calls == []
diff --git a/test/c40_execution/test_c44_pm_ego_pose.py b/test/c40_execution/test_c44_pm_ego_pose.py
index 9e82b1c..66de3a8 100644
--- a/test/c40_execution/test_c44_pm_ego_pose.py
+++ b/test/c40_execution/test_c44_pm_ego_pose.py
@@ -240,3 +240,63 @@ def test_factory_world_ego_is_not_pm_ego(minimal_corridor_map_path):
assert exec_.world.get_ego_state() is not exec_.pm.ego_vehicle
assert exec_.world.get_ego_state().x == pytest.approx(exec_.pm.ego_vehicle.x)
assert exec_.world.get_ego_state().y == pytest.approx(exec_.pm.ego_vehicle.y)
+
+
+def test_control_align_must_teleport_world_or_gt_undoes_stack_only_write():
+ """Control Align used to assign only ``exec.ego_state`` (stack PM).
+
+ After the world/stack ego split, GT localization copies world → PM each tick,
+ so a stack-only write is discarded. Align must teleport the plant and sync PM
+ (same dual-write as ``VisualizerApp.teleport_ego``).
+ """
+ ExecutionSettings.c41_world_stack_capabilities = None # GT LOCALIZATION on
+ world_ego = EgoState(x=10.0, y=20.0, theta=0.0)
+ stack_ego = EgoState(x=10.0, y=20.0, theta=0.0)
+ world = _PlantWorld(ego_state=world_ego, advance_dx=0.0)
+ pm = PerceptionModel(ego_vehicle=stack_ego)
+ exec_ = _make_exec(world=world, pm=pm)
+
+ # Broken Align pattern (stack only) — undone by the next GT tick.
+ exec_.ego_state.x, exec_.ego_state.y = 100.0, 200.0
+ exec_.step(
+ sim_dt=0.01, control_dt=0.01, replan_dt=99, localization_dt=0,
+ call_replan=False, call_perceive=False, call_localize=False, call_control=False,
+ )
+ assert pm.ego_vehicle.x == pytest.approx(10.0)
+ assert world_ego.x == pytest.approx(10.0)
+
+ # Correct Align pattern: move plant, then sync stack (teleport_ego).
+ world_ego.x, world_ego.y = 100.0, 200.0
+ pm.ego_vehicle.copy_from(world.get_ego_state())
+ exec_.step(
+ sim_dt=0.01, control_dt=0.01, replan_dt=99, localization_dt=0,
+ call_replan=False, call_perceive=False, call_localize=False, call_control=False,
+ )
+ assert world_ego.x == pytest.approx(100.0)
+ assert world_ego.y == pytest.approx(200.0)
+ assert pm.ego_vehicle.x == pytest.approx(100.0)
+ assert pm.ego_vehicle.y == pytest.approx(200.0)
+
+
+def test_manual_world_control_must_sync_stack_pm():
+ """Control Step / Steer used to call ``world.control_ego_state`` only.
+
+ Stack PM ego then lagged until the next GT tick, so the UI and subsequent
+ manual control steps read a stale pose. Dual-write like teleport.
+ """
+ world_ego = EgoState(x=0.0, y=0.0, theta=0.0, velocity=5.0)
+ stack_ego = EgoState(x=0.0, y=0.0, theta=0.0, velocity=5.0)
+ world = _PlantWorld(ego_state=world_ego, advance_dx=1.5)
+ pm = PerceptionModel(ego_vehicle=stack_ego)
+
+ # Broken pattern: plant moves, stack stays put.
+ world.control_ego_state(ControlCommand(), dt=0.01)
+ assert world_ego.x == pytest.approx(1.5)
+ assert pm.ego_vehicle.x == pytest.approx(0.0)
+
+ # Correct pattern (VisualizerApp.apply_world_control).
+ world.control_ego_state(ControlCommand(), dt=0.01)
+ pm.ego_vehicle.copy_from(world.get_ego_state())
+ assert world_ego.x == pytest.approx(3.0)
+ assert pm.ego_vehicle.x == pytest.approx(3.0)
+ assert pm.ego_vehicle.y == pytest.approx(world_ego.y)
diff --git a/test/c40_execution/test_c45_async_stage_order.py b/test/c40_execution/test_c45_async_stage_order.py
new file mode 100644
index 0000000..b3d0d8a
--- /dev/null
+++ b/test/c40_execution/test_c45_async_stage_order.py
@@ -0,0 +1,145 @@
+"""Async combined planner worker must localize/perceive before replan."""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from types import SimpleNamespace
+from typing import Optional
+
+from avlite.c10_perception.c11_perception_model import EgoState, PerceptionModel
+from avlite.c20_planning.c21_planning_model import LocalPlan
+from avlite.c20_planning.c23_local_planning_strategy import LocalPlanningStrategy
+from avlite.c30_control.c31_control_model import ControlCommand
+from avlite.c30_control.c32_control_strategy import ControlStrategy
+from avlite.c40_execution.c41_world_bridge import WorldBridge
+from avlite.c40_execution.c45_async_threaded_executer import AsyncThreadedExecuter
+from avlite.c40_execution.c49_settings import ExecutionSettings
+from avlite.c50_common.c51_capabilities import StackCapability
+from avlite.c50_common.c52_world_sensor_datatypes import SensorFrame
+
+
+@dataclass
+class _StubWorld(WorldBridge):
+ ego_state: EgoState = field(default_factory=lambda: EgoState(x=0, y=0, theta=0))
+ perception_model: Optional[PerceptionModel] = None
+ world_capabilities = frozenset()
+ stack_capabilities = frozenset()
+
+ def control_ego_state(self, cmd: ControlCommand, dt: float = 0.01):
+ pass
+
+ def get_sensor_frame(self, agent_id: int = 0) -> SensorFrame:
+ return SensorFrame(stamp=0.0)
+
+
+class _RecordingPlanner(LocalPlanningStrategy):
+ world_requirements = frozenset()
+ stack_requirements = frozenset()
+ stack_capabilities = frozenset({StackCapability.LOCAL_PLAN})
+
+ def __init__(self, order: list[str]):
+ self.order = order
+ self.lap = 0
+
+ def replan(self, perception_model=None, sensors=None):
+ self.order.append("replan")
+
+ def step(self, ego_state):
+ pass
+
+ def get_local_plan(self):
+ return LocalPlan()
+
+ def reset(self):
+ pass
+
+ def __init_subclass__(cls, **kwargs):
+ pass
+
+
+class _StubController(ControlStrategy, abstract=True):
+ def control(
+ self, ego, plan=None, control_dt=None, perception_model=None, sensors=None,
+ ) -> ControlCommand:
+ return ControlCommand()
+
+ def reset(self):
+ pass
+
+
+def _has_ordered_triple(order: list[str]) -> bool:
+ """True if some localize→perceive→replan subsequence appears in that order."""
+ try:
+ i_loc = order.index("localize")
+ i_pr = order.index("perceive", i_loc)
+ i_rp = order.index("replan", i_pr)
+ except ValueError:
+ return False
+ return i_loc < i_pr < i_rp
+
+
+def test_combined_worker_localizes_and_perceives_before_replan():
+ order: list[str] = []
+ prev = ExecutionSettings.c41_world_stack_capabilities
+ # Empty filter disables world GT localization so the localization stage runs.
+ ExecutionSettings.c41_world_stack_capabilities = []
+ try:
+ localization = SimpleNamespace(
+ world_requirements=frozenset(),
+ stack_requirements=frozenset(),
+ stack_capabilities=frozenset({StackCapability.LOCALIZATION}),
+ localize=lambda **kwargs: order.append("localize"),
+ reset=lambda: None,
+ )
+ perception = SimpleNamespace(
+ world_requirements=frozenset(),
+ stack_requirements=frozenset(),
+ stack_capabilities=frozenset(),
+ perceive=lambda **kwargs: order.append("perceive"),
+ reset=lambda: None,
+ )
+ exec_ = AsyncThreadedExecuter(
+ perception_model=PerceptionModel(),
+ perception=perception,
+ localization=localization,
+ global_planner=None,
+ local_planner=_RecordingPlanner(order),
+ controller=_StubController(),
+ world=_StubWorld(),
+ combined_perception_planning=True,
+ control_dt=0.05,
+ replan_dt=0.01,
+ perception_dt=0.01,
+ localization_dt=0.0,
+ )
+ exec_.step(
+ call_replan=True,
+ call_control=False,
+ call_perceive=True,
+ call_localize=True,
+ pace_replan=False,
+ pace_perception=False,
+ pace_control=False,
+ pace_sim=False,
+ replan_dt=0.01,
+ perception_dt=0.01,
+ localization_dt=0.0,
+ control_dt=0.05,
+ sim_dt=0.01,
+ )
+ deadline = time.time() + 2.0
+ while time.time() < deadline and not _has_ordered_triple(order):
+ time.sleep(0.01)
+ exec_.stop()
+
+ assert _has_ordered_triple(order), f"missing localize→perceive→replan; order={order[:30]}"
+ # Whenever perceive and replan both fire, perceive must not follow replan
+ # in the same iteration. After the FPS warm-up skip, the stable pattern is
+ # localize, perceive, replan (possibly with an initial localize, replan).
+ for i in range(len(order) - 1):
+ if order[i] == "perceive":
+ # Next stage in the same iteration is replan (localize already ran).
+ assert order[i + 1] == "replan", order[i : i + 3]
+ finally:
+ ExecutionSettings.c41_world_stack_capabilities = prev
diff --git a/test/c40_execution/test_c45_async_stop.py b/test/c40_execution/test_c45_async_stop.py
index 7235fa2..1cc9f52 100644
--- a/test/c40_execution/test_c45_async_stop.py
+++ b/test/c40_execution/test_c45_async_stop.py
@@ -159,3 +159,60 @@ def test_create_threads_recreates_dead_planner():
assert exec_.planner_thread is not dead
assert exec_.planner_thread in exec_.threads
assert exec_.planner_thread.name == "Planner"
+
+
+def test_step_after_cooperative_stop_does_not_restart_workers():
+ """Tk kept polling step() after StopExecAtGoalTask; step used to revive threads."""
+ exec_ = _make_async_executer()
+ assert not exec_.threads_started
+
+ exec_.step(
+ control_dt=0.05,
+ replan_dt=0.05,
+ perception_dt=0.05,
+ sim_dt=0.01,
+ call_replan=True,
+ call_control=True,
+ call_perceive=False,
+ pace_replan=False,
+ pace_control=False,
+ pace_sim=False,
+ )
+ assert exec_.threads_started
+ time.sleep(0.05)
+
+ exec_.stop()
+ assert exec_.stopped
+ assert exec_.threads_started is False
+
+ # Stale UI poll — must not clear stopped or recreate workers.
+ exec_.step(
+ control_dt=0.05,
+ replan_dt=0.05,
+ perception_dt=0.05,
+ sim_dt=0.01,
+ call_replan=True,
+ call_control=True,
+ call_perceive=False,
+ )
+ assert exec_.stopped
+ assert exec_.threads_started is False
+ assert exec_.threads == []
+
+ # Intentional Start clears the flag, then step may create workers again.
+ exec_.stopped = False
+ exec_.step(
+ control_dt=0.05,
+ replan_dt=0.05,
+ perception_dt=0.05,
+ sim_dt=0.01,
+ call_replan=True,
+ call_control=True,
+ call_perceive=False,
+ pace_replan=False,
+ pace_control=False,
+ pace_sim=False,
+ )
+ assert exec_.threads_started
+ assert not exec_.stopped
+ exec_.stop()
diff --git a/test/c50_common/test_c52_world_sensor_datatypes.py b/test/c50_common/test_c52_world_sensor_datatypes.py
index f25552a..8632cdf 100644
--- a/test/c50_common/test_c52_world_sensor_datatypes.py
+++ b/test/c50_common/test_c52_world_sensor_datatypes.py
@@ -2,6 +2,7 @@
import pytest
from avlite.c50_common.c52_world_sensor_datatypes import (
+ CameraParams,
GnssDatum,
GnssReading,
ImuReading,
@@ -29,6 +30,43 @@ def test_sensor_frame_defaults():
frame = SensorFrame()
assert frame.rgb is None
assert frame.lidar is None
+ assert frame.camera_params is None
+
+
+def test_camera_params_coerces_to_float64():
+ params = CameraParams(
+ intrinsic=[[400, 0, 320], [0, 400, 240], [0, 0, 1]],
+ world_to_camera=np.eye(4, dtype=np.float32),
+ width=640,
+ height=480,
+ )
+ assert params.intrinsic.shape == (3, 3)
+ assert params.intrinsic.dtype == np.float64
+ assert params.world_to_camera.dtype == np.float64
+ assert params.intrinsic[0, 2] == pytest.approx(320.0)
+
+ frame = SensorFrame(camera_params=params)
+ assert frame.camera_params.width == 640
+
+
+def test_camera_params_rejects_bad_intrinsic_shape():
+ with pytest.raises(ValueError, match=r"\(3, 3\) intrinsic"):
+ CameraParams(
+ intrinsic=np.zeros((3, 4)),
+ world_to_camera=np.eye(4),
+ width=640,
+ height=480,
+ )
+
+
+def test_camera_params_rejects_bad_extrinsic_shape():
+ with pytest.raises(ValueError, match=r"\(4, 4\) world_to_camera"):
+ CameraParams(
+ intrinsic=np.eye(3),
+ world_to_camera=np.eye(3),
+ width=640,
+ height=480,
+ )
def test_world_capability_sensor_fields_cover_all_caps():
diff --git a/test/c50_common/test_c54_trajectory_horizon_slice.py b/test/c50_common/test_c54_trajectory_horizon_slice.py
index f75c9f6..e078b7c 100644
--- a/test/c50_common/test_c54_trajectory_horizon_slice.py
+++ b/test/c50_common/test_c54_trajectory_horizon_slice.py
@@ -2,6 +2,8 @@
import json
+import pytest
+
from avlite.c50_common.c54_trajectory_tracker import TrajectoryTracker, slice_trajectory_horizon
@@ -59,3 +61,22 @@ def test_slice_trajectory_zero_max_points_returns_remainder():
sliced = slice_trajectory_horizon(traj, max_points=0)
assert len(sliced.path) == 90
assert sliced.path[0] == traj.path[10]
+
+
+def test_slice_trajectory_at_final_waypoint_is_one_point():
+ """Last waypoint used to IndexError: convert_xy assumed next_wp=1 on a 1-pt path."""
+ traj = _long_trajectory(n=100, start_wp=99)
+ traj.next_wp = 99
+ sliced = slice_trajectory_horizon(traj, max_points=50)
+ assert len(sliced.path) == 1
+ assert sliced.path[0] == traj.path[99]
+ assert sliced.current_wp == 0
+ assert sliced.next_wp == 0
+ assert sliced.is_initialized
+
+
+def test_single_point_trajectory_tracker_initializes():
+ traj = TrajectoryTracker(path=[(3.0, 4.0)], velocity=[2.0])
+ assert traj.is_initialized
+ assert list(traj.path_s) == [0.0]
+ assert traj.convert_sd_to_xy(0.0, 0.0) == pytest.approx((3.0, 4.0))
diff --git a/test/c50_common/test_c54_trajectory_waypoint_update.py b/test/c50_common/test_c54_trajectory_waypoint_update.py
new file mode 100644
index 0000000..11e8f2d
--- /dev/null
+++ b/test/c50_common/test_c54_trajectory_waypoint_update.py
@@ -0,0 +1,66 @@
+"""Regression tests for TrajectoryTracker waypoint index updates."""
+
+import pytest
+
+from avlite.c50_common.c54_trajectory_tracker import TrajectoryTracker
+
+
+def _path_tj(n: int = 3) -> TrajectoryTracker:
+ path = [(float(i) * 10.0, 0.0) for i in range(n)]
+ return TrajectoryTracker(path=path, velocity=[1.0] * n)
+
+
+def test_update_waypoint_by_wp_at_last_index_keeps_next_in_bounds():
+ """``current_wp + 1 % n`` mis-parses as ``current_wp + 1`` and sets next_wp == n."""
+ tj = _path_tj(3)
+ tj.update_waypoint_by_wp(2)
+ assert tj.current_wp == 2
+ assert tj.next_wp == 2
+ # Must be indexable by plot / step_wp / convert_sd_orientation helpers.
+ _ = tj.path_x[tj.next_wp]
+ _ = tj.path_y[tj.next_wp]
+
+
+def test_update_to_next_waypoint_clamps_at_end():
+ tj = _path_tj(3)
+ tj.update_waypoint_by_wp(1)
+ tj.update_to_next_waypoint()
+ assert tj.current_wp == 2
+ assert tj.next_wp == 2
+ tj.update_to_next_waypoint()
+ assert tj.current_wp == 2
+ assert tj.next_wp == 2
+
+
+def test_update_waypoint_by_wp_mid_path_advances_next():
+ tj = _path_tj(5)
+ tj.update_waypoint_by_wp(2)
+ assert tj.current_wp == 2
+ assert tj.next_wp == 3
+
+
+def test_create_quintic_trajectory_sd_honors_boundary_derivatives():
+ """b-vector must match A rows: value, value, 1st, 1st, 2nd, 2nd (start then end)."""
+ path = [(float(i), 0.0) for i in range(40)]
+ tj = TrajectoryTracker(path=path, velocity=[5.0] * 40)
+ s0, d0, s1, d1 = 5.0, 0.5, 15.0, -0.25
+ local = tj.create_quintic_trajectory_sd(
+ s_start=s0,
+ d_start=d0,
+ s_end=s1,
+ d_end=d1,
+ start_d_1st_derv=0.2,
+ end_d_1st_derv=-0.1,
+ start_d_2nd_derv=0.3,
+ end_d_2nd_derv=0.05,
+ num_points=20,
+ )
+ poly = local.poly_d
+ d1p = poly.deriv(1)
+ d2p = poly.deriv(2)
+ assert poly(s0) == pytest.approx(d0, abs=1e-9)
+ assert poly(s1) == pytest.approx(d1, abs=1e-9)
+ assert d1p(s0) == pytest.approx(0.2, abs=1e-9)
+ assert d1p(s1) == pytest.approx(-0.1, abs=1e-9)
+ assert d2p(s0) == pytest.approx(0.3, abs=1e-9)
+ assert d2p(s1) == pytest.approx(0.05, abs=1e-9)
diff --git a/test/plugins/test_p60_visualizer_tk/test_p63_plugins_app.py b/test/plugins/test_p60_visualizer_tk/test_p63_plugins_app.py
index f3ea6b5..4fd797c 100644
--- a/test/plugins/test_p60_visualizer_tk/test_p63_plugins_app.py
+++ b/test/plugins/test_p60_visualizer_tk/test_p63_plugins_app.py
@@ -262,4 +262,25 @@ def test_dependency_notes():
{"dependency_notes": " Source ROS 2 before running. "}
)
== "Source ROS 2 before running."
- )
\ No newline at end of file
+ )
+
+
+def test_site_url():
+ assert cp._PluginOperations.site_url({}) == ""
+ assert cp._PluginOperations.site_url({"site_url": None}) == ""
+ assert cp._PluginOperations.site_url({"site_url": " "}) == ""
+ assert (
+ cp._PluginOperations.site_url({"site_url": " https://avlab.io/plugin/ "})
+ == "https://avlab.io/plugin/"
+ )
+
+
+def test_display_name_falls_back_to_identifier():
+ assert cp._display_name(None, "avlite-executer-ROS2") == "avlite-executer-ROS2"
+ assert cp._display_name({}, "basic_predictor") == "basic_predictor"
+ assert cp._display_name({"display_name": " "}, "basic_predictor") == "basic_predictor"
+
+
+def test_display_name_uses_registry_value():
+ entry = {"name": "avlite-executer-ROS2", "display_name": " AVLite ROS2 Executer "}
+ assert cp._display_name(entry, "avlite-executer-ROS2") == "AVLite ROS2 Executer"
\ No newline at end of file