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 @@ PyPI Downloads Python - Documentation + Documentation License

@@ -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.).

- AVLite Tk Visualizer +

## 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:
-![AVLite](imgs/logo-icon.png){ .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 } +

- PyPI version - Python 3.10+ - License - GitHub stars + PyPI version + Python 3.10+ + License + GitHub stars

@@ -114,7 +119,11 @@ avlite # launch the visualizer
- AVLite Tk 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 ```
- ![AVLite Tk visualizer](imgs/tk_visualizer.png){ 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