From b1b1e08d0224a3a2adf4811834a9626bc1eaf1ee Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 00:11:44 +0000 Subject: [PATCH 01/62] Rewrite Crazy Robotaxi for FlashDreams V2 Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 40 + .../crazy_robotaxi/crazy_robotaxi/__init__.py | 8 + .../crazy_robotaxi/crazy_robotaxi/__main__.py | 12 + .../crazy_robotaxi/application.py | 208 ++ apps/crazy_robotaxi/crazy_robotaxi/config.py | 130 + .../crazy_robotaxi/configs/default_game.yaml | 74 + .../configs/default_renderer.yaml | 23 + .../crazy_robotaxi/crazy_robotaxi/dynamics.py | 341 ++ apps/crazy_robotaxi/crazy_robotaxi/factory.py | 120 + .../crazy_robotaxi/high_scores.py | 201 ++ apps/crazy_robotaxi/crazy_robotaxi/hud.py | 243 ++ .../crazy_robotaxi/crazy_robotaxi/map_tool.py | 66 + .../maps/boulevard_district.robotaxi.yaml | 408 +++ .../maps/minimal_loop.robotaxi.yaml | 100 + .../crazy_robotaxi/navigation.py | 813 +++++ .../crazy_robotaxi/passengers.py | 121 + apps/crazy_robotaxi/crazy_robotaxi/physics.py | 179 + apps/crazy_robotaxi/crazy_robotaxi/rules.py | 1150 +++++++ apps/crazy_robotaxi/crazy_robotaxi/scene.py | 169 + apps/crazy_robotaxi/crazy_robotaxi/session.py | 223 ++ apps/crazy_robotaxi/pyproject.toml | 40 + apps/crazy_robotaxi/tests/test_application.py | 100 + apps/crazy_robotaxi/tests/test_gameplay.py | 165 + apps/crazy_robotaxi/tests/test_hud.py | 54 + apps/crazy_robotaxi/tests/test_maps.py | 104 + apps/omnidreams_game_engine/README.md | 8 + .../omnidreams_game_engine/__init__.py | 9 + .../omnidreams_game_engine/camera.py | 135 + .../omnidreams_game_engine/camera_defaults.py | 137 + .../omnidreams_game_engine/colors.py | 109 + .../omnidreams_game_engine/conditioning.py | 238 ++ .../omnidreams_game_engine/config.py | 113 + .../omnidreams_game_engine/contracts.py | 91 + .../omnidreams_game_engine/dynamic_scene.py | 237 ++ .../omnidreams_game_engine/engine.py | 92 + .../game_map/__init__.py | 76 + .../game_map/_schema.py | 389 +++ .../game_map/compiler.py | 401 +++ .../omnidreams_game_engine/game_map/loader.py | 3029 +++++++++++++++++ .../game_map/preview.py | 190 ++ .../game_map/spawn_render.py | 342 ++ .../game_map/traffic.py | 963 ++++++ .../omnidreams_game_engine/game_map/types.py | 850 +++++ .../game_map/vicinity.py | 196 ++ .../omnidreams_game_engine/input.py | 149 + .../omnidreams_game_engine/math3d.py | 141 + .../omnidreams_game_engine/math_utils.py | 244 ++ .../omnidreams_game_engine/model.py | 154 + .../omnidreams_game_engine/patterns.py | 341 ++ .../omnidreams_game_engine/ply_io.py | 312 ++ .../renderer_settings.py | 110 + .../omnidreams_game_engine/scene.py | 37 + .../omnidreams_game_engine/scene_fixture.py | 766 +++++ .../omnidreams_game_engine/scene_loader.py | 856 +++++ .../omnidreams_game_engine/screenshot.jpg | Bin 0 -> 223728 bytes .../simulation/__init__.py | 4 + .../simulation/components.py | 565 +++ .../simulation/ego_vehicle_kinematics.py | 573 ++++ .../simulation/game_physics.py | 973 ++++++ .../simulation/ground_snap.py | 271 ++ .../simulation/map_traffic.py | 527 +++ .../omnidreams_game_engine/types.py | 231 ++ .../omnidreams_game_engine/yaml_config.py | 101 + apps/omnidreams_game_engine/pyproject.toml | 39 + .../tests/test_engine.py | 128 + .../tests/test_input.py | 102 + .../tests/test_model.py | 147 + .../crazy_robotaxi_legacy_architecture.md | 169 + docs/design/crazy_robotaxi_v2_architecture.md | 307 ++ .../ludus_renderer/_cpp/physx/bindings.cpp | 38 +- .../ludus-renderer/ludus_renderer/physx.py | 67 +- pyproject.toml | 15 + uv.lock | 77 + 73 files changed, 20135 insertions(+), 6 deletions(-) create mode 100644 apps/crazy_robotaxi/README.md create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/__init__.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/__main__.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/application.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/config.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/configs/default_game.yaml create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/dynamics.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/factory.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/high_scores.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/hud.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/map_tool.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/navigation.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/passengers.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/physics.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/rules.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/scene.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/session.py create mode 100644 apps/crazy_robotaxi/pyproject.toml create mode 100644 apps/crazy_robotaxi/tests/test_application.py create mode 100644 apps/crazy_robotaxi/tests/test_gameplay.py create mode 100644 apps/crazy_robotaxi/tests/test_hud.py create mode 100644 apps/crazy_robotaxi/tests/test_maps.py create mode 100644 apps/omnidreams_game_engine/README.md create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/__init__.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/camera.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/camera_defaults.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/colors.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/config.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/contracts.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/engine.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/preview.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/spawn_render.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/traffic.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/game_map/vicinity.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/input.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/math3d.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/math_utils.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/model.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/patterns.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/ply_io.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/renderer_settings.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/scene.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/scene_fixture.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/screenshot.jpg create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/__init__.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/components.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ground_snap.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/types.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py create mode 100644 apps/omnidreams_game_engine/pyproject.toml create mode 100644 apps/omnidreams_game_engine/tests/test_engine.py create mode 100644 apps/omnidreams_game_engine/tests/test_input.py create mode 100644 apps/omnidreams_game_engine/tests/test_model.py create mode 100644 docs/design/crazy_robotaxi_legacy_architecture.md create mode 100644 docs/design/crazy_robotaxi_v2_architecture.md diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md new file mode 100644 index 000000000..7875fb428 --- /dev/null +++ b/apps/crazy_robotaxi/README.md @@ -0,0 +1,40 @@ +# Crazy Robotaxi + +Crazy Robotaxi is a FlashDreams V2 application built on the model-thread +`omnidreams-game-engine`. FlashDreams owns input collection, reset generations, +presentation buffering, client windows, and the two-thread runtime. + +```bash +uv sync --package crazy-robotaxi +uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc +``` + +Application arguments follow `--`: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ + --map /path/to/city.robotaxi.yaml \ + --model-preset perf \ + --game-time-s 90 +``` + +Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application +options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. The V2 +client's reset event rebuilds simulation, game rules, traffic, conditioning, +and the autoregressive cache together while retaining the loaded model. + +## Authored maps + +Maps are strict semantic `.robotaxi.yaml` documents. Validate or compile them +without loading a model: + +```bash +uv run crazy-robotaxi-map validate path/to/city.robotaxi.yaml +uv run crazy-robotaxi-map compile path/to/city.robotaxi.yaml +uv run crazy-robotaxi-map preview path/to/city.robotaxi.yaml --output city.svg +uv run crazy-robotaxi-map preview-spawn path/to/city.robotaxi.yaml \ + --spawn taxi_start --output taxi_start.png +``` + +The architectural source of truth for this rewrite is +[`../../docs/design/crazy_robotaxi_v2_architecture.md`](../../docs/design/crazy_robotaxi_v2_architecture.md). diff --git a/apps/crazy_robotaxi/crazy_robotaxi/__init__.py b/apps/crazy_robotaxi/crazy_robotaxi/__init__.py new file mode 100644 index 000000000..db81976e5 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Crazy Robotaxi FlashDreams V2 application.""" + +from crazy_robotaxi.application import CrazyRobotaxiApplication, create_app + +__all__ = ["CrazyRobotaxiApplication", "create_app"] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/__main__.py b/apps/crazy_robotaxi/crazy_robotaxi/__main__.py new file mode 100644 index 000000000..6743642eb --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/__main__.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run Crazy Robotaxi through the canonical FlashDreams V2 host.""" + +import sys + +from flashdreams.runtime_v2.cli import entrypoint + + +if __name__ == "__main__": + entrypoint(["crazy-robotaxi", *sys.argv[1:]]) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py new file mode 100644 index 000000000..3170ba2d0 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -0,0 +1,208 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Process-lifetime Crazy Robotaxi application composition.""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +from omnidreams.config import ( + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, +) + +from crazy_robotaxi.config import load_game_settings +from crazy_robotaxi.high_scores import default_high_scores_path +from crazy_robotaxi.rules import TaxiGameConfig +from crazy_robotaxi.session import CrazyRobotaxiSession +from flashdreams.api_v2.application import IApplication +from flashdreams.api_v2.session import ISession +from flashdreams.infra.config import derive_config +from flashdreams.runtime_v2.session_desc import SessionDesc +from flashdreams.runtime_v2.video_tensor import VideoTensorLayout +from omnidreams_game_engine.renderer_settings import ( + RendererSettings, + load_renderer_settings, +) +from omnidreams_game_engine.config import DriverInputConfig +from omnidreams_game_engine.scene import SceneRequest, load_scene +from omnidreams_game_engine.types import SceneDefinition + +_ROOT = Path(__file__).resolve().parent +_DEFAULT_MAP = _ROOT / "maps" / "boulevard_district.robotaxi.yaml" +_DEFAULT_GAME = _ROOT / "configs" / "default_game.yaml" +_DEFAULT_RENDERER = _ROOT / "configs" / "default_renderer.yaml" +_MODEL_PRESETS = { + "standard": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline, + "perf": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline, + "native-perf": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline, +} + + +@dataclass(frozen=True, slots=True) +class ApplicationConfig: + """Validated options shared by sessions created by one application.""" + + scene_request: SceneRequest + renderer: RendererSettings + game: TaxiGameConfig + driver_input: DriverInputConfig + device: str + total_blocks: int | None + + +PipelineFactory = Callable[[Any, str], Any] +SceneFactory = Callable[[SceneRequest, Any], SceneDefinition] + + +class CrazyRobotaxiApplication(IApplication): + """Load one model and create isolated V2 game sessions.""" + + def __init__( + self, + *, + pipeline_factory: PipelineFactory | None = None, + scene_factory: SceneFactory | None = None, + ) -> None: + self._defaults = load_renderer_settings(_DEFAULT_RENDERER) + self._pipeline_factory = pipeline_factory or _build_pipeline + self._scene_factory = scene_factory or load_scene + self._pipeline_config: Any = _MODEL_PRESETS["standard"] + self._pipeline: Any | None = None + self._config: ApplicationConfig | None = None + + def session_desc(self) -> SessionDesc: + """Declare the trained single-view output contract without loading.""" + raster = self._defaults.raster + return SessionDesc( + output_layout=VideoTensorLayout.tchw, + frames_per_second_for_ui=60, + frames_per_second_for_step=30, + video_width=raster.width, + video_height=raster.height, + ) + + def init(self, commandline_args: Sequence[str]) -> None: + """Parse application options without starting another runtime.""" + args = _parser().parse_args(list(commandline_args)) + if args.total_blocks is not None and args.total_blocks <= 0: + raise ValueError("--total-blocks must be positive") + if args.game_time_s is not None and args.game_time_s <= 0.0: + raise ValueError("--game-time-s must be positive") + renderer = load_renderer_settings(args.renderer_config) + settings = load_game_settings(args.game_config) + game = settings.game + game = replace( + game, + seed=args.seed, + global_time_s=( + game.global_time_s if args.game_time_s is None else args.game_time_s + ), + high_scores_path=( + default_high_scores_path() + if args.high_scores is None + else args.high_scores.expanduser() + ), + ) + pipeline_config = _MODEL_PRESETS[args.model_preset] + if args.compile is not None: + pipeline_config = derive_config( + pipeline_config, + diffusion_model={ + "transformer": {"compile_network": bool(args.compile)} + }, + ) + if args.seed is not None: + pipeline_config = derive_config( + pipeline_config, + diffusion_model={"seed": int(args.seed)}, + ) + self._pipeline_config = pipeline_config + self._config = ApplicationConfig( + scene_request=SceneRequest( + map_path=args.map.expanduser(), + camera_name=args.camera, + variant=args.variant, + prompt=args.prompt, + force_recompile=args.force_map_recompile, + ), + renderer=renderer, + game=game, + driver_input=settings.driver_input, + device=args.device, + total_blocks=args.total_blocks, + ) + + def create_session(self, session_desc: SessionDesc) -> ISession: + """Create one session after validating its fixed model geometry.""" + config = self._config + if config is None: + raise RuntimeError("init() must run before create_session()") + if session_desc.output_layout is not VideoTensorLayout.tchw: + raise ValueError("Crazy Robotaxi produces tchw output") + if session_desc.frames_per_second_for_step != 30: + raise ValueError("Crazy Robotaxi produces video at 30 frames per second") + expected = config.renderer.raster.resolution_wh + actual = session_desc.video_width, session_desc.video_height + if actual != expected: + raise ValueError( + f"Session dimensions {actual} do not match renderer {expected}" + ) + if self._pipeline is None: + self._pipeline = self._pipeline_factory( + self._pipeline_config, + config.device, + ) + scene = self._scene_factory(config.scene_request, config.renderer.raster) + return CrazyRobotaxiSession( + pipeline=self._pipeline, + scene=scene, + config=config, + session_desc=session_desc, + ) + + def close(self) -> None: + """Release process-lifetime model resources.""" + pipeline = self._pipeline + self._pipeline = None + self._config = None + close = getattr(pipeline, "close", None) + if callable(close): + close() + + +def create_app() -> IApplication: + """Return a fresh V2 application for entry-point discovery.""" + return CrazyRobotaxiApplication() + + +def _build_pipeline(config: Any, device: str) -> Any: + return config.setup().to(device).eval() + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="flashdreams-run-v2 crazy-robotaxi --", + description="Drive Crazy Robotaxi on an authored semantic map.", + ) + parser.add_argument("--map", type=Path, default=_DEFAULT_MAP) + parser.add_argument("--game-config", type=Path, default=_DEFAULT_GAME) + parser.add_argument("--renderer-config", type=Path, default=_DEFAULT_RENDERER) + parser.add_argument("--camera", default="camera_front_wide_120fov") + parser.add_argument("--variant", default="default") + parser.add_argument("--prompt") + parser.add_argument("--force-map-recompile", action="store_true") + parser.add_argument("--model-preset", choices=tuple(_MODEL_PRESETS), default="standard") + parser.add_argument("--device", default="cuda") + parser.add_argument("--total-blocks", type=int) + parser.add_argument("--game-time-s", type=float) + parser.add_argument("--seed", type=int) + parser.add_argument("--high-scores", type=Path) + parser.add_argument("--compile", action=argparse.BooleanOptionalAction) + return parser diff --git a/apps/crazy_robotaxi/crazy_robotaxi/config.py b/apps/crazy_robotaxi/crazy_robotaxi/config.py new file mode 100644 index 000000000..13a2953d1 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/config.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Strict Crazy Robotaxi gameplay configuration loading.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields +from pathlib import Path +from typing import Any, cast + +from omnidreams_game_engine.yaml_config import ( + StrictConfigError, + load_yaml_mapping, + require_bool, + require_exact_keys, + require_float, + require_mapping, + require_version, +) +from omnidreams_game_engine.config import DriverInputConfig + +from crazy_robotaxi.dynamics import TaxiVehicleConfig +from crazy_robotaxi.rules import TaxiGameConfig + +_RUNTIME_GAME_FIELDS = { + "seed", + "high_scores_path", +} +_RULE_FIELDS = ( + {field.name for field in fields(TaxiGameConfig)} + - _RUNTIME_GAME_FIELDS + - {"vehicle"} +) +_VEHICLE_FIELDS = {field.name for field in fields(TaxiVehicleConfig)} +_VEHICLE_BOOL_FIELDS = { + "speed_limit_enabled", + "actor_collision_enabled", + "static_collision_enabled", +} +_INPUT_FIELDS = {field.name for field in fields(DriverInputConfig)} + + +@dataclass(frozen=True, slots=True) +class TaxiSettings: + """Game rules, driver input, and vehicle dynamics loaded together.""" + + game: TaxiGameConfig + driver_input: DriverInputConfig + + +def load_game_settings(path: Path) -> TaxiSettings: + """Load a complete Crazy Robotaxi game YAML document. + + Args: + path: Game YAML path. + + Returns: + Validated game rules, V2 input reduction, and taxi dynamics. + """ + doc = load_yaml_mapping(path) + require_exact_keys( + doc, + {"schema_version", "rules", "input", "vehicle"}, + "game", + ) + require_version(doc, "game") + raw_rules = require_mapping(doc["rules"], "game.rules") + require_exact_keys(raw_rules, _RULE_FIELDS, "game.rules") + rules = { + name: require_float(raw_rules[name], f"game.rules.{name}", minimum=0.0) + for name in _RULE_FIELDS + } + for integer_name in ("base_fare_points", "bonus_points_per_second"): + value = raw_rules[integer_name] + if type(value) is not int or value < 0: + raise StrictConfigError( + f"game.rules.{integer_name} must be a nonnegative integer" + ) + rules[integer_name] = value + if rules["fare_min_route_distance_m"] > rules["fare_max_route_distance_m"]: + raise StrictConfigError( + "game.rules.fare_min_route_distance_m must not exceed fare_max_route_distance_m" + ) + if rules["min_time_s"] > rules["max_time_s"]: + raise StrictConfigError("game.rules.min_time_s must not exceed max_time_s") + + raw_input = require_mapping(doc["input"], "game.input") + require_exact_keys(raw_input, _INPUT_FIELDS, "game.input") + input_values = { + name: require_float(raw_input[name], f"game.input.{name}", minimum=0.0) + for name in _INPUT_FIELDS + } + if input_values["steering_scale"] <= 0.0: + raise StrictConfigError("game.input.steering_scale must be positive") + + raw_vehicle = require_mapping(doc["vehicle"], "game.vehicle") + require_exact_keys(raw_vehicle, _VEHICLE_FIELDS, "game.vehicle") + vehicle_values = { + name: require_bool(raw_vehicle[name], f"game.vehicle.{name}") + if name in _VEHICLE_BOOL_FIELDS + else require_float(raw_vehicle[name], f"game.vehicle.{name}", minimum=0.0) + for name in _VEHICLE_FIELDS + } + for name in ( + "wheel_base_m", + "max_speed_mps", + "aabb_length_m", + "aabb_width_m", + "aabb_height_m", + "speed_taper_knee_fraction", + ): + if vehicle_values[name] <= 0.0: + raise StrictConfigError(f"game.vehicle.{name} must be positive") + for name in ( + "speed_taper_knee_fraction", + "speed_taper_low_floor", + "speed_taper_high_floor", + "curb_collision_restitution", + "curb_forward_momentum_retention", + ): + if vehicle_values[name] > 1.0: + raise StrictConfigError(f"game.vehicle.{name} must be at most 1") + return TaxiSettings( + game=TaxiGameConfig( + vehicle=TaxiVehicleConfig(**cast(Any, vehicle_values)), + **cast(Any, rules), + ), + driver_input=DriverInputConfig(**input_values), + ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/default_game.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/default_game.yaml new file mode 100644 index 000000000..eb447772f --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/configs/default_game.yaml @@ -0,0 +1,74 @@ +schema_version: 1 +rules: + waypoint_spacing_m: 10.0 + pickup_grid_spacing_m: 60.0 + pickup_min_distance_m: 20.0 + initial_pickup_max_distance_m: 200.0 + pickup_radius_m: 5.0 + dropoff_radius_m: 6.0 + fare_min_route_distance_m: 200.0 + fare_max_route_distance_m: 250.0 + target_speed_mps: 10.0 + grace_s: 8.0 + min_time_s: 12.0 + max_time_s: 45.0 + trip_time_multiplier: 2.0 + base_fare_points: 500 + bonus_points_per_second: 100 + event_banner_s: 2.0 + global_time_s: 60.0 + dropoff_time_bonus_s: 30.0 + ground_snap_max_absolute_rotation_deg: 10.0 + ground_snap_settle_fraction: 0.25 +input: + steering_scale: 1.0 + steering_rate_per_s: 3.5 + steering_return_rate_per_s: 5.0 +vehicle: + wheel_base_m: 2.8 + max_steer_rad: 0.5 + steer_rate_rad_per_s: 0.55 + steer_return_rate_rad_per_s: 0.9 + speed_limit_enabled: true + max_speed_mps: 31.2928 + max_reverse_speed_mps: 6.0 + max_accel_mps2: 10.0 + max_brake_mps2: 14.0 + max_lateral_accel_mps2: 8.5 + drag_mps2: 0.7 + mass_kg: 1550.0 + tire_grip: 1.35 + rolling_resistance: 0.015 + aero_drag_coefficient: 0.42 + collision_restitution: 0.22 + collision_friction: 0.65 + max_collision_yaw_rate_radps: 0.35 + suspension_stiffness: 42.0 + suspension_damping: 9.0 + suspension_travel_m: 0.22 + suspension_visual_gain: 0.15 + max_body_roll_rad: 0.16 + max_body_pitch_rad: 0.5 + actor_collision_enabled: true + static_collision_enabled: true + aabb_length_m: 4.8 + aabb_width_m: 2.0 + aabb_height_m: 1.6 + reverse_accel_mps2: 10.0 + handbrake_decel_mps2: 18.0 + handbrake_yaw_gain: 3.25 + max_handbrake_yaw_rate_radps: 1.5 + curb_collision_restitution: 0.45 + curb_forward_momentum_retention: 0.85 + input_activation_threshold: 0.01 + direction_change_accel_multiplier: 1.5 + speed_taper_knee_fraction: 0.62 + speed_taper_low_floor: 0.2 + speed_taper_high_floor: 0.05 + speed_taper_exponent: 3.0 + manual_coast_decel_mps2: 0.5 + ragdoll_grip_rate: 4.0 + ragdoll_yaw_response_rate: 8.0 + handbrake_yaw_response_rate: 4.0 + handbrake_lateral_damping_rate: 2.0 + handbrake_lateral_accel_scale: 0.35 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml new file mode 100644 index 000000000..0102d848a --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml @@ -0,0 +1,23 @@ +schema_version: 1 +raster: + width: 1280 + height: 704 + near_plane_m: 0.1 + far_plane_m: 200.0 + fog_start_m: 40.0 + fog_end_m: 140.0 + fog_power: 1.5 + triangle_raytrace_distance_m: 25.0 + triangle_raytrace_edge_samples: 8 + lane_segment_interval_m: 0.05 + polyline_segment_interval_m: 0.8 + line_width_px: 12.0 + pole_width_px: 5.0 + dual_line_offset_m: 0.1 +bev: + enabled: true + width: 1024 + height: 1024 + height_m: 75.0 + fov_deg: 60.0 + tilt_deg: 0.0 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py b/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py new file mode 100644 index 000000000..611cc439d --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Taxi-game-only arcade vehicle integration.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import numpy as np +from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.simulation.components import ( + vehicle_dynamics_from_config, +) +from omnidreams_game_engine.types import DriverCommand, VehicleState + + +@dataclass(frozen=True) +class TaxiVehicleConfig(VehicleConfig): + """Arcade vehicle values used only when the Taxi game is active.""" + + max_accel_mps2: float = 10.0 + reverse_accel_mps2: float = 10.0 + max_brake_mps2: float = 14.0 + handbrake_decel_mps2: float = 18.0 + handbrake_yaw_gain: float = 3.25 + max_handbrake_yaw_rate_radps: float = 1.5 + max_lateral_accel_mps2: float = 8.5 + max_body_roll_rad: float = 0.16 + curb_collision_restitution: float = 0.45 + """Rebound coefficient for map curbs and other static barriers.""" + + curb_forward_momentum_retention: float = 0.85 + """Minimum forward-speed fraction retained through a glancing curb impact.""" + + input_activation_threshold: float = 0.01 + """Minimum pedal or steering magnitude treated as active input.""" + + direction_change_accel_multiplier: float = 1.5 + """Braking multiplier while changing travel direction.""" + + speed_taper_knee_fraction: float = 0.62 + """Fraction of maximum speed where acceleration tapering changes regime.""" + + speed_taper_low_floor: float = 0.2 + """Minimum acceleration fraction below the speed-taper knee.""" + + speed_taper_high_floor: float = 0.05 + """Minimum acceleration fraction above the speed-taper knee.""" + + speed_taper_exponent: float = 3.0 + """Acceleration falloff exponent above the speed-taper knee.""" + + manual_coast_decel_mps2: float = 0.5 + """Manual-control deceleration while neither pedal is active.""" + + ragdoll_grip_rate: float = 4.0 + """Lateral-velocity damping rate during collision recovery.""" + + ragdoll_yaw_response_rate: float = 8.0 + """Yaw response rate during collision recovery.""" + + handbrake_yaw_response_rate: float = 4.0 + """Yaw response rate while the handbrake is active.""" + + handbrake_lateral_damping_rate: float = 2.0 + """Lateral-velocity damping rate while the handbrake is active.""" + + handbrake_lateral_accel_scale: float = 0.35 + """Body-roll acceleration scale while the handbrake is active.""" + + speed_limit_enabled: bool = True + actor_collision_enabled: bool = True + static_collision_enabled: bool = True + + +def _move_towards(current: float, target: float, max_delta: float) -> float: + if current < target: + return min(current + max_delta, target) + return max(current - max_delta, target) + + +def _apply_brake_or_reverse( + speed_mps: float, + command: DriverCommand, + *, + dt_s: float, + brake_decel_mps2: float, + reverse_accel_mps2: float, + max_reverse_speed_mps: float, +) -> float: + brake_delta = brake_decel_mps2 * command.brake * dt_s + if command.throttle > 0.01 or command.reverse: + return _move_towards(speed_mps, 0.0, brake_delta) + reverse_dt_s = dt_s + if speed_mps > 0.0: + if brake_delta <= speed_mps: + return max(0.0, speed_mps - brake_delta) + reverse_dt_s -= speed_mps / (brake_decel_mps2 * command.brake) + reverse_delta = reverse_accel_mps2 * command.brake * reverse_dt_s + return max(-max_reverse_speed_mps, min(0.0, speed_mps) - reverse_delta) + + +def integrate_taxi_vehicle( + state: VehicleState, + command: DriverCommand, + dt_s: float, + vehicle: TaxiVehicleConfig, +) -> VehicleState: + steer_rad = state.steer_rad + if command.steer_is_direct: + steer_rad = command.steer * vehicle.max_steer_rad + elif abs(command.steer) > 1e-5: + steer_rad += command.steer * vehicle.steer_rate_rad_per_s * dt_s + else: + steer_rad = _move_towards( + steer_rad, 0.0, vehicle.steer_return_rate_rad_per_s * dt_s + ) + steer_rad = float(np.clip(steer_rad, -vehicle.max_steer_rad, vehicle.max_steer_rad)) + + speed = state.speed_mps + if command.stop: + speed = 0.0 + elif command.handbrake: + speed = _move_towards(speed, 0.0, vehicle.handbrake_decel_mps2 * dt_s) + elif command.manual_control: + intended_direction = -1.0 if command.reverse else 1.0 + if command.brake > vehicle.input_activation_threshold: + speed = _apply_brake_or_reverse( + speed, + command, + dt_s=dt_s, + brake_decel_mps2=vehicle.max_brake_mps2, + reverse_accel_mps2=vehicle.reverse_accel_mps2, + max_reverse_speed_mps=vehicle.max_reverse_speed_mps, + ) + elif command.throttle > vehicle.input_activation_threshold: + accel = vehicle.max_accel_mps2 * command.throttle * dt_s + if intended_direction < 0.0: + speed -= accel + elif vehicle.speed_limit_enabled: + max_speed = vehicle.max_speed_mps + current = abs(speed) + high_speed_knee = max_speed * vehicle.speed_taper_knee_fraction + if current < high_speed_knee: + taper = max( + vehicle.speed_taper_low_floor, + 1.0 - (current / high_speed_knee) ** 2 * 0.5, + ) + else: + excess = (current - high_speed_knee) / max( + 1e-6, max_speed - high_speed_knee + ) + taper = max( + vehicle.speed_taper_high_floor, + 0.5 * (1.0 - excess) ** vehicle.speed_taper_exponent, + ) + speed += accel * taper + else: + speed += accel + else: + speed = _move_towards(speed, 0.0, vehicle.manual_coast_decel_mps2 * dt_s) + if vehicle.speed_limit_enabled: + speed = float( + np.clip(speed, -vehicle.max_reverse_speed_mps, vehicle.max_speed_mps) + ) + else: + if command.brake > vehicle.input_activation_threshold: + speed = _apply_brake_or_reverse( + speed, + command, + dt_s=dt_s, + brake_decel_mps2=vehicle.max_brake_mps2, + reverse_accel_mps2=vehicle.reverse_accel_mps2, + max_reverse_speed_mps=vehicle.max_reverse_speed_mps, + ) + elif command.throttle > vehicle.input_activation_threshold: + intended_direction = -1.0 if command.reverse else 1.0 + accel_delta = command.throttle * vehicle.max_accel_mps2 * dt_s + if speed * intended_direction < 0.0: + speed = _move_towards( + speed, + 0.0, + accel_delta * vehicle.direction_change_accel_multiplier, + ) + else: + speed += intended_direction * accel_delta + else: + if speed > 0.0: + speed = max(0.0, speed - vehicle.drag_mps2 * dt_s) + else: + speed = min(0.0, speed + vehicle.drag_mps2 * dt_s) + if vehicle.speed_limit_enabled: + speed = float( + np.clip(speed, -vehicle.max_reverse_speed_mps, vehicle.max_speed_mps) + ) + + commanded_yaw_rate = 0.0 + if abs(steer_rad) > 1e-5 and abs(speed) > 1e-5: + commanded_yaw_rate = speed / vehicle.wheel_base_m * math.tan(steer_rad) + if command.handbrake: + commanded_yaw_rate *= vehicle.handbrake_yaw_gain + max_yaw_rate = vehicle.max_handbrake_yaw_rate_radps + else: + # A fixed steering angle becomes unrealistically aggressive as speed + # rises because bicycle-model lateral acceleration scales with v^2. + # Limit yaw rate by the configured grip envelope while preserving the + # full steering response at parking and neighbourhood speeds. + max_yaw_rate = vehicle.max_lateral_accel_mps2 / abs(speed) + commanded_yaw_rate = float( + np.clip(commanded_yaw_rate, -max_yaw_rate, max_yaw_rate) + ) + + design = vehicle_dynamics_from_config(vehicle) + forward = np.asarray( + [math.cos(state.yaw_rad), math.sin(state.yaw_rad)], dtype=np.float32 + ) + left = np.asarray([-forward[1], forward[0]], dtype=np.float32) + velocity = np.asarray( + [ + state.velocity_x_mps + if state.velocity_x_mps is not None + else forward[0] * state.speed_mps, + state.velocity_y_mps + if state.velocity_y_mps is not None + else forward[1] * state.speed_mps, + ], + dtype=np.float32, + ) + if state.ragdoll_active: + lateral_speed = float(np.dot(velocity, left)) + grip = float( + np.clip(vehicle.tire_grip * dt_s * vehicle.ragdoll_grip_rate, 0.0, 1.0) + ) + velocity -= left * lateral_speed * grip + longitudinal_speed = float(np.dot(velocity, forward)) + velocity += forward * (speed - longitudinal_speed) + response = 1.0 - math.exp(-vehicle.ragdoll_yaw_response_rate * dt_s) + yaw_rate = ( + state.yaw_rate_radps + + (commanded_yaw_rate - state.yaw_rate_radps) * response + ) + elif command.handbrake: + response = 1.0 - math.exp(-vehicle.handbrake_yaw_response_rate * dt_s) + yaw_rate = ( + state.yaw_rate_radps + + (commanded_yaw_rate - state.yaw_rate_radps) * response + ) + lateral_speed = float(np.dot(velocity, left)) + lateral_speed *= max(0.0, 1.0 - vehicle.handbrake_lateral_damping_rate * dt_s) + else: + # Normal steering is an arcade control target, while PhysX remains + # responsible for contact impulses and tire forces. Running a second + # stateful tire-slip model here made the same input depend on speed, + # residual side-slip, and collision history before PhysX saw it. + # Publish the driver's target directly; the PhysX follower supplies + # the one physical response curve. Smoothing here as well created two + # serial low-pass filters and made steering unexpectedly stiff. + yaw_rate = commanded_yaw_rate + lateral_speed = design.rear_axle_to_cg_m * yaw_rate + + yaw = state.yaw_rad + yaw_rate * dt_s + if not state.ragdoll_active: + new_forward = np.asarray([math.cos(yaw), math.sin(yaw)], dtype=np.float32) + new_left = np.asarray([-new_forward[1], new_forward[0]], dtype=np.float32) + velocity = new_forward * np.float32(speed) + new_left * np.float32( + lateral_speed + ) + x_m = state.x_m + float(velocity[0]) * dt_s + y_m = state.y_m + float(velocity[1]) * dt_s + + longitudinal_accel = (speed - state.speed_mps) / max(dt_s, 1e-6) + lateral_accel = ( + speed + * yaw_rate + * (vehicle.handbrake_lateral_accel_scale if command.handbrake else 1.0) + ) + target_pitch = float( + np.clip( + -longitudinal_accel + / 9.81 + * vehicle.suspension_visual_gain + * vehicle.max_body_pitch_rad, + -vehicle.max_body_pitch_rad, + vehicle.max_body_pitch_rad, + ) + ) + target_roll = float( + np.clip( + -lateral_accel + / 9.81 + * vehicle.suspension_visual_gain + * vehicle.max_body_roll_rad, + -vehicle.max_body_roll_rad, + vehicle.max_body_roll_rad, + ) + ) + pitch_accel = ( + vehicle.suspension_stiffness * (target_pitch - state.suspension_pitch_rad) + - vehicle.suspension_damping * state.suspension_pitch_rate_radps + ) + roll_accel = ( + vehicle.suspension_stiffness * (target_roll - state.suspension_roll_rad) + - vehicle.suspension_damping * state.suspension_roll_rate_radps + ) + pitch_rate = state.suspension_pitch_rate_radps + pitch_accel * dt_s + roll_rate = state.suspension_roll_rate_radps + roll_accel * dt_s + suspension_pitch = float( + np.clip( + state.suspension_pitch_rad + pitch_rate * dt_s, + -vehicle.max_body_pitch_rad, + vehicle.max_body_pitch_rad, + ) + ) + suspension_roll = float( + np.clip( + state.suspension_roll_rad + roll_rate * dt_s, + -vehicle.max_body_roll_rad, + vehicle.max_body_roll_rad, + ) + ) + + return VehicleState( + x_m=x_m, + y_m=y_m, + z_m=state.z_m, + yaw_rad=yaw, + speed_mps=speed, + steer_rad=steer_rad, + pitch_rad=state.pitch_rad, + roll_rad=state.roll_rad, + velocity_x_mps=float(velocity[0]), + velocity_y_mps=float(velocity[1]), + yaw_rate_radps=yaw_rate, + suspension_pitch_rad=suspension_pitch, + suspension_roll_rad=suspension_roll, + suspension_pitch_rate_radps=pitch_rate, + suspension_roll_rate_radps=roll_rate, + ragdoll_active=state.ragdoll_active, + ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/factory.py b/apps/crazy_robotaxi/crazy_robotaxi/factory.py new file mode 100644 index 000000000..76d334907 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/factory.py @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Crazy Robotaxi composition inside the generic model-thread engine.""" + +from __future__ import annotations + +from dataclasses import replace +from functools import partial + +from crazy_robotaxi.dynamics import TaxiVehicleConfig, integrate_taxi_vehicle +from crazy_robotaxi.physics import TaxiPhysicsWorld, step_taxi_physics_world +from crazy_robotaxi.rules import TaxiGameConfig, TaxiGameController, TaxiGameRules +from crazy_robotaxi.scene import load_scene_data +from omnidreams_game_engine.conditioning import LudusConditionRenderer +from omnidreams_game_engine.config import BevConfig, RasterConfig, VehicleConfig +from omnidreams_game_engine.engine import GameEngine +from omnidreams_game_engine.game_map.vicinity import GameMapVicinityResolver +from omnidreams_game_engine.simulation.ego_vehicle_kinematics import ( + EgoVehicleKinematics, + state_from_initial_pose, +) +from omnidreams_game_engine.simulation.ground_snap import GroundSnapper +from omnidreams_game_engine.types import DriverCommand, SceneDefinition, VehicleState + + +def build_taxi_engine( + *, + scene: SceneDefinition, + game_config: TaxiGameConfig, + raster: RasterConfig, + bev: BevConfig, + frame_interval_s: float, + device: str, +) -> GameEngine: + """Construct every mutable Taxi subsystem on the calling model thread.""" + if scene.game_map is None: + raise ValueError("Crazy Robotaxi requires a compiled semantic game map") + scene_data = load_scene_data(scene) + ground_snapper = _build_ground_snapper(scene, game_config) + simulation = EgoVehicleKinematics( + initial_state=state_from_initial_pose( + initial_rig_to_world=scene.initial_rig_to_world, + initial_yaw_rad=scene.initial_yaw_rad, + initial_speed_mps=0.0, + ), + vehicle_config=game_config.vehicle, + ground_snapper=ground_snapper, + initial_timestamp_us=scene.initial_timestamp_us, + scene=scene, + integrate_fn=_integrate_taxi_vehicle, + physics_world_factory=lambda active_scene, vehicle: TaxiPhysicsWorld( + active_scene, + game_config.vehicle, + curb_segments_world=scene_data.curb_segments_world, + ), + physics_step_fn=step_taxi_physics_world, + include_initial_state_in_first_chunk=True, + ) + controller = TaxiGameController( + scene_id=scene.scene_id, + reference_route_world=scene_data.reference_route_world, + navigation_lanes=scene_data.navigation_lanes, + fare_regions=scene_data.fare_regions, + initial_state=simulation.current_state, + config=game_config, + initial_camera=scene.selected_camera, + vicinity_resolver=GameMapVicinityResolver(scene.game_map), + ) + renderer = LudusConditionRenderer(raster, bev, device=device) + renderer.load_scene(scene) + return GameEngine( + simulation=simulation, + rules=TaxiGameRules(controller), + condition_renderer=renderer, + frame_interval_s=frame_interval_s, + ) + + +def _integrate_taxi_vehicle( + state: VehicleState, + command: DriverCommand, + dt_s: float, + vehicle: VehicleConfig, +) -> VehicleState: + if not isinstance(vehicle, TaxiVehicleConfig): + raise TypeError("Crazy Robotaxi requires TaxiVehicleConfig") + return integrate_taxi_vehicle(state, command, dt_s, vehicle) + + +def _build_ground_snapper( + scene: SceneDefinition, + config: TaxiGameConfig, +) -> GroundSnapper | None: + if scene.ground_mesh_vertices is None or scene.ground_mesh_faces is None: + return None + return GroundSnapper( + scene.ground_mesh_vertices, + scene.ground_mesh_faces, + max_absolute_rotation_deg=config.ground_snap_max_absolute_rotation_deg, + invalid_sample_handler=partial( + settle_invalid_ground_attitude, + settle_fraction=config.ground_snap_settle_fraction, + ), + ) + + +def settle_invalid_ground_attitude( + state: VehicleState, + *, + settle_fraction: float = 0.25, +) -> VehicleState: + """Ease stale ground attitude toward level after an invalid sample.""" + pitch = state.pitch_rad * (1.0 - settle_fraction) + roll = state.roll_rad * (1.0 - settle_fraction) + return replace( + state, + pitch_rad=0.0 if abs(pitch) < 1.0e-4 else pitch, + roll_rad=0.0 if abs(roll) < 1.0e-4 else roll, + ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py new file mode 100644 index 000000000..d2074a24e --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py @@ -0,0 +1,201 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Persistent taxi-game high-score storage.""" + +from __future__ import annotations + +import csv +import os +import re +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from filelock import FileLock +from loguru import logger + +_CSV_FIELDS = ("name", "score", "achieved_at_utc") +_PLAYER_NAME_RE = re.compile(r"[A-Za-z0-9 _-]{1,12}") + + +def default_high_scores_path() -> Path: + """Return the default persistent taxi leaderboard path.""" + from omnidreams.scenes import FLASHDREAMS_CACHE_DIR + + return FLASHDREAMS_CACHE_DIR / "crazy-robotaxi" / "highscores.csv" + + +def validate_player_name(name: str) -> str: + """Normalize and validate a leaderboard player name. + + Args: + name: Candidate player name. + + Returns: + Name with surrounding whitespace removed. + + Raises: + ValueError: The normalized name is empty, too long, or contains an + unsupported character. + """ + normalized = name.strip() + if _PLAYER_NAME_RE.fullmatch(normalized) is None: + raise ValueError( + "Name must be 1-12 characters using letters, numbers, spaces, " + "hyphens, or underscores." + ) + return normalized + + +@dataclass(frozen=True) +class HighScoreEntry: + """One persisted leaderboard result.""" + + name: str + """Player name shown on the leaderboard.""" + + score: int + """Final game score.""" + + achieved_at_utc: str + """UTC ISO-8601 timestamp used to order tied scores.""" + + def as_dict(self) -> dict[str, object]: + """Return a JSON-serializable representation of the entry.""" + return { + "name": self.name, + "score": self.score, + "achieved_at_utc": self.achieved_at_utc, + } + + +class HighScoreStore: + """Read and atomically update a top-ten CSV leaderboard.""" + + def __init__(self, path: Path, *, limit: int = 10) -> None: + self._path = path + self._limit = limit + self._lock_path = path.with_suffix(f"{path.suffix}.lock") + + @property + def path(self) -> Path: + """Return the leaderboard CSV path.""" + return self._path + + def read(self) -> tuple[HighScoreEntry, ...]: + """Return the sorted leaderboard while tolerating malformed rows.""" + if not self._path.exists(): + return () + try: + with FileLock(self._lock_path): + return self._read_unlocked() + except OSError as exc: + logger.warning(f"[taxi] could not lock high scores at {self._path}: {exc}") + return self._read_unlocked() + + def qualifying_rank(self, score: int) -> int | None: + """Return the prospective rank for ``score``, or ``None`` if excluded.""" + if score <= 0: + return None + entries = self.read() + if len(entries) >= self._limit and score <= entries[-1].score: + return None + return 1 + sum(entry.score >= score for entry in entries) + + def record( + self, + name: str, + score: int, + *, + achieved_at_utc: str | None = None, + ) -> tuple[HighScoreEntry | None, tuple[HighScoreEntry, ...]]: + """Insert a qualifying score and return it with the updated board. + + Args: + name: Player name to validate and persist. + score: Final game score. + achieved_at_utc: Optional ISO-8601 timestamp for deterministic tests. + + Returns: + Inserted entry, or ``None`` if a concurrent update displaced the + score, together with the current top-ten leaderboard. + """ + normalized_name = validate_player_name(name) + if score <= 0: + return None, self.read() + timestamp = achieved_at_utc or datetime.now(timezone.utc).isoformat( + timespec="seconds" + ) + entry = HighScoreEntry(normalized_name, int(score), timestamp) + self._path.parent.mkdir(parents=True, exist_ok=True) + with FileLock(self._lock_path): + entries = list(self._read_unlocked()) + inserted: HighScoreEntry | None = entry + if len(entries) >= self._limit and score <= entries[-1].score: + inserted = None + else: + entries.append(entry) + board = self._sort(entries) + self._write_unlocked(board) + return inserted, board + + def _read_unlocked(self) -> tuple[HighScoreEntry, ...]: + if not self._path.exists(): + return () + entries: list[HighScoreEntry] = [] + try: + with self._path.open(newline="", encoding="utf-8") as csv_file: + for row_number, row in enumerate(csv.DictReader(csv_file), start=2): + try: + name = validate_player_name(row.get("name", "")) + score = int(row.get("score", "")) + timestamp = row.get("achieved_at_utc", "") + datetime.fromisoformat(timestamp) + except (TypeError, ValueError): + logger.warning( + f"[taxi] ignoring malformed high-score row {row_number} " + f"in {self._path}" + ) + continue + if score <= 0: + continue + entries.append(HighScoreEntry(name, score, timestamp)) + except (OSError, csv.Error) as exc: + logger.warning( + f"[taxi] could not read high scores from {self._path}: {exc}" + ) + return () + return self._sort(entries) + + def _sort(self, entries: list[HighScoreEntry]) -> tuple[HighScoreEntry, ...]: + return tuple( + sorted(entries, key=lambda entry: (-entry.score, entry.achieved_at_utc))[ + : self._limit + ] + ) + + def _write_unlocked(self, entries: tuple[HighScoreEntry, ...]) -> None: + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + newline="", + encoding="utf-8", + dir=self._path.parent, + prefix=f".{self._path.name}.", + suffix=".tmp", + delete=False, + ) as csv_file: + temporary_path = Path(csv_file.name) + writer = csv.DictWriter(csv_file, fieldnames=_CSV_FIELDS) + writer.writeheader() + for entry in entries: + writer.writerow(entry.as_dict()) + csv_file.flush() + os.fsync(csv_file.fileno()) + os.replace(temporary_path, self._path) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() diff --git a/apps/crazy_robotaxi/crazy_robotaxi/hud.py b/apps/crazy_robotaxi/crazy_robotaxi/hud.py new file mode 100644 index 000000000..43ba2991f --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/hud.py @@ -0,0 +1,243 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Frame-aligned RGBA HUD generation for V2 result composition.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +import numpy as np +import torch +from PIL import Image, ImageDraw, ImageFont +from torch import Tensor + +from crazy_robotaxi.rules import ( + TaxiGameSnapshot, + project_taxi_markers_to_camera, +) +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.types import CameraCalibration + +_PANEL = (8, 12, 20, 214) +_PICKUP = (255, 210, 48, 255) +_DROPOFF = (48, 232, 132, 255) + + +def render_hud( + snapshots: Sequence[object], + *, + rig_poses_world: np.ndarray, + calibration: CameraCalibration, + bev_tchw: Tensor | None, + width: int, + height: int, + device: torch.device | str, + dtype: torch.dtype, + player_name: str = "", +) -> Tensor: + """Return synchronized floating-point RGBA layers in ``[T,4,H,W]``.""" + if len(snapshots) != len(rig_poses_world): + raise ValueError("HUD snapshots and camera poses must align") + bev_images = _bev_images(bev_tchw, len(snapshots)) + camera = FThetaCameraModel( + calibration, + output_width=width, + output_height=height, + ) + frames = [ + _render_frame( + snapshot if isinstance(snapshot, TaxiGameSnapshot) else None, + rig_pose=rig_poses_world[index], + camera=camera, + bev=bev_images[index], + width=width, + height=height, + player_name=player_name, + ) + for index, snapshot in enumerate(snapshots) + ] + rgba = torch.from_numpy(np.stack(frames)).permute(0, 3, 1, 2) + color = rgba[:, :3].float() / 127.5 - 1.0 + alpha = rgba[:, 3:4].float() / 255.0 + return torch.cat((color, alpha), dim=1).to(device=device, dtype=dtype) + + +def _bev_images(bev: Tensor | None, count: int) -> list[Image.Image | None]: + if bev is None: + return [None] * count + if int(bev.shape[0]) != count: + raise ValueError("BEV and HUD frame counts must align") + pixels = ( + ((bev.detach().float().clamp(-1.0, 1.0) + 1.0) * 127.5) + .byte() + .permute(0, 2, 3, 1) + .cpu() + .numpy() + ) + return [Image.fromarray(frame, mode="RGB") for frame in pixels] + + +def _render_frame( + snapshot: TaxiGameSnapshot | None, + *, + rig_pose: np.ndarray, + camera: FThetaCameraModel, + bev: Image.Image | None, + width: int, + height: int, + player_name: str, +) -> np.ndarray: + image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + if snapshot is None: + return np.asarray(image) + draw = ImageDraw.Draw(image) + font = ImageFont.load_default(size=max(14, height // 34)) + small = ImageFont.load_default(size=max(12, height // 48)) + margin = max(12, width // 80) + color = _PICKUP if snapshot.phase == "seeking_pickup" else _DROPOFF + label = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" + panel_width = max(310, width // 3) + draw.rounded_rectangle( + (margin, margin, margin + panel_width, margin + 94), + radius=12, + fill=_PANEL, + ) + draw.text( + (margin + 14, margin + 12), + f"SCORE {snapshot.score:06d}", + font=font, + fill="white", + ) + draw.text( + (margin + 14, margin + 52), + f"TIME {snapshot.global_remaining_time_s:05.1f}s", + font=small, + fill="white", + ) + draw.text( + (margin + panel_width // 2, margin + 52), + f"{label} {snapshot.distance_m:04.0f}m", + font=small, + fill=color, + ) + _draw_direction(draw, snapshot.relative_bearing_rad, width, margin, color) + _draw_world_markers(draw, snapshot, rig_pose, camera, width, height, color) + if bev is not None: + _draw_bev(image, draw, bev, snapshot, width, height, color) + if snapshot.event is not None: + event = snapshot.event.replace("_", " ").upper() + draw.text((width // 2, height // 6), event, font=font, anchor="mm", fill=color) + if snapshot.session_state == "awaiting_name": + _draw_center_panel( + draw, + width, + height, + "NEW HIGH SCORE", + (f"ENTER NAME: {player_name or '_'}", "ENTER TO SUBMIT"), + font, + small, + ) + elif snapshot.session_state == "leaderboard": + lines = tuple( + f"{index:>2}. {entry.name:<12} {entry.score:>7}" + for index, entry in enumerate(snapshot.leaderboard, start=1) + ) or ("NO SCORES YET",) + _draw_center_panel(draw, width, height, "LEADERBOARD", lines, font, small) + return np.asarray(image, dtype=np.uint8) + + +def _draw_direction( + draw: ImageDraw.ImageDraw, + bearing: float, + width: int, + margin: int, + color: tuple[int, int, int, int], +) -> None: + angle = bearing - math.pi / 2.0 + center = (width / 2.0, margin + 44.0) + radius = 26.0 + points = [ + ( + center[0] + math.cos(angle + offset) * radius * scale, + center[1] + math.sin(angle + offset) * radius * scale, + ) + for offset, scale in ((0.0, 1.0), (2.45, 0.55), (-2.45, 0.55)) + ] + draw.polygon(points, fill=color) + + +def _draw_world_markers( + draw: ImageDraw.ImageDraw, + snapshot: TaxiGameSnapshot, + rig_pose: np.ndarray, + camera: FThetaCameraModel, + width: int, + height: int, + color: tuple[int, int, int, int], +) -> None: + for projection in project_taxi_markers_to_camera( + snapshot, + rig_pose, + camera, + image_width=width, + image_height=height, + ): + for edge in projection.ring_edges_uv: + draw.line(edge, fill=color, width=3) + if projection.beacon_top_uv is not None: + draw.line( + (projection.anchor_uv, projection.beacon_top_uv), + fill=color, + width=4, + ) + + +def _draw_bev( + image: Image.Image, + draw: ImageDraw.ImageDraw, + bev: Image.Image, + snapshot: TaxiGameSnapshot, + width: int, + height: int, + color: tuple[int, int, int, int], +) -> None: + size = min(width // 4, height // 3) + left = width - size - 18 + top = height - size - 18 + panel = bev.resize((size, size), Image.Resampling.BILINEAR).convert("RGBA") + panel.putalpha(220) + image.alpha_composite(panel, (left, top)) + draw.rectangle((left, top, left + size, top + size), outline="white", width=2) + cx, cy = left + size // 2, top + size // 2 + draw.polygon(((cx, cy - 9), (cx - 6, cy + 7), (cx + 6, cy + 7)), fill="white") + angle = snapshot.relative_bearing_rad - math.pi / 2.0 + radius = size * 0.38 + target = (cx + math.cos(angle) * radius, cy + math.sin(angle) * radius) + draw.ellipse((target[0] - 6, target[1] - 6, target[0] + 6, target[1] + 6), fill=color) + + +def _draw_center_panel( + draw: ImageDraw.ImageDraw, + width: int, + height: int, + title: str, + lines: Sequence[str], + font: ImageFont.ImageFont | ImageFont.FreeTypeFont, + small: ImageFont.ImageFont | ImageFont.FreeTypeFont, +) -> None: + panel_width = min(width - 40, max(440, width // 2)) + panel_height = min(height - 40, 100 + len(lines) * 28) + left = (width - panel_width) // 2 + top = (height - panel_height) // 2 + draw.rounded_rectangle( + (left, top, left + panel_width, top + panel_height), + radius=18, + fill=(5, 8, 15, 238), + outline=_PICKUP, + width=3, + ) + draw.text((left + 24, top + 18), title, font=font, fill=_PICKUP) + for index, line in enumerate(lines): + draw.text((left + 24, top + 58 + index * 28), line, font=small, fill="white") diff --git a/apps/crazy_robotaxi/crazy_robotaxi/map_tool.py b/apps/crazy_robotaxi/crazy_robotaxi/map_tool.py new file mode 100644 index 000000000..8ebbcf08e --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/map_tool.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Offline validation, compilation, and preview commands for game maps.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from omnidreams_game_engine.game_map import ( + compile_game_map, + load_game_map, + write_game_map_preview, + write_spawn_first_frame_preview, +) + + +def main(argv: Sequence[str] | None = None) -> None: + """Run one map-authoring command without constructing a model.""" + parser = _parser() + args = parser.parse_args(argv) + if args.command == "validate": + game_map = load_game_map(args.map) + print(f"valid: {game_map.map_id}") + return + if args.command == "compile": + result = compile_game_map(args.map, force=args.force_map_recompile) + print(result.archive_path) + return + if args.command == "preview": + write_game_map_preview(args.map, args.output) + print(args.output) + return + if args.command == "preview-spawn": + write_spawn_first_frame_preview( + args.map, + args.output, + spawn_id=args.spawn, + ) + print(args.output) + return + parser.error(f"Unknown command: {args.command}") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="crazy-robotaxi-map") + subparsers = parser.add_subparsers(dest="command", required=True) + validate = subparsers.add_parser("validate") + validate.add_argument("map", type=Path) + compile_parser = subparsers.add_parser("compile") + compile_parser.add_argument("map", type=Path) + compile_parser.add_argument("--force-map-recompile", action="store_true") + preview = subparsers.add_parser("preview") + preview.add_argument("map", type=Path) + preview.add_argument("--output", type=Path, required=True) + spawn = subparsers.add_parser("preview-spawn") + spawn.add_argument("map", type=Path) + spawn.add_argument("--spawn", required=True) + spawn.add_argument("--output", type=Path, required=True) + return parser + + +if __name__ == "__main__": + main() diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml new file mode 100644 index 000000000..a9d265ac5 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml @@ -0,0 +1,408 @@ +schema_version: 1 +id: crazy-robotaxi-boulevard-district +name: Original Boulevard District + +compiler: + sample_spacing_m: 2.0 + ground_margin_m: 20.0 + intersection_connector_samples: 8 + +profiles: + arterial: + lane_width_m: 3.6 + curb_offset_m: 0.6 + lanes: [backward, backward, forward, forward] + speed_limit_mps: 15.6 + lane_marking: {style: DASHED_SINGLE, color: WHITE} + divider_markings: + - {style: DASHED_SINGLE, color: WHITE} + - {style: SOLID_GROUP, color: YELLOW} + - {style: DASHED_SINGLE, color: WHITE} + street: + lane_width_m: 3.6 + curb_offset_m: 0.6 + lanes: [backward, forward] + speed_limit_mps: 11.2 + lane_marking: {style: SOLID_GROUP, color: YELLOW} + divider_markings: + - {style: SOLID_GROUP, color: YELLOW} + local: + lane_width_m: 3.2 + curb_offset_m: 0.5 + lanes: [backward, forward] + speed_limit_mps: 8.9 + lane_marking: {style: SOLID_GROUP, color: YELLOW} + divider_markings: + - {style: SOLID_GROUP, color: YELLOW} + +nodes: + # Original spawn-area arterial and northwest surface-street grid. The + # elevated highway and its ramps are deliberately omitted. + - {id: west_arterial_end, type: cul_de_sac, pose: {x_m: -180, y_m: 0}, culdesac_radius_m: 12} + - {id: west_arterial_crossing, type: intersection, pose: {x_m: -128, y_m: 0}} + - {id: central_arterial_crossing, type: intersection, pose: {x_m: 75, y_m: 0}} + - {id: diagonal_arterial_crossing, type: intersection, pose: {x_m: 284, y_m: 0}, lane_transition_length_m: 20} + + - {id: west_north_crossing, type: intersection, pose: {x_m: -123, y_m: 100}} + - {id: west_upper_crossing, type: intersection, pose: {x_m: -123, y_m: 198}} + - {id: west_north_end, type: cul_de_sac, pose: {x_m: -123, y_m: 235}, culdesac_radius_m: 9} + - {id: central_north_crossing, type: intersection, pose: {x_m: 75, y_m: 93}} + - {id: central_upper_crossing, type: intersection, pose: {x_m: 75, y_m: 198}, lane_transition_length_m: 20} + - {id: central_north_end, type: cul_de_sac, pose: {x_m: 75, y_m: 260}, culdesac_radius_m: 9} + + - {id: west_south_crossing, type: intersection, pose: {x_m: -128, y_m: -84}} + - {id: west_lower_crossing, type: road_joint, pose: {x_m: -128, y_m: -184}, lane_transition_length_m: 20} + - {id: west_south_end, type: cul_de_sac, pose: {x_m: -128, y_m: -235}, culdesac_radius_m: 9} + - {id: southwest_crossing, type: intersection, pose: {x_m: -44, y_m: -84}} + - {id: southwest_north_end, type: cul_de_sac, pose: {x_m: -44, y_m: -30}, culdesac_radius_m: 8} + - {id: southwest_lot_driveway, type: driveway, pose: {x_m: 20, y_m: -85}} + - {id: central_south_crossing, type: intersection, pose: {x_m: 75, y_m: -85}, lane_transition_length_m: 20} + - {id: central_lower_crossing, type: intersection, pose: {x_m: 75, y_m: -120}} + - {id: central_south_end, type: cul_de_sac, pose: {x_m: 75, y_m: -200}, culdesac_radius_m: 9} + - {id: south_lot_driveway, type: driveway, pose: {x_m: 150, y_m: -120}} + - {id: south_local_end, type: cul_de_sac, pose: {x_m: 210, y_m: -120}, culdesac_radius_m: 9} + + # Diagonal north street and the arterial split visible east of the spawn. + - {id: diagonal_bend_lower, type: road_joint, pose: {x_m: 299, y_m: 95}} + - {id: diagonal_north_crossing, type: intersection, pose: {x_m: 307, y_m: 133}} + - {id: diagonal_bend_upper, type: road_joint, pose: {x_m: 314, y_m: 207}} + - {id: diagonal_north_end, type: cul_de_sac, pose: {x_m: 326, y_m: 285}, culdesac_radius_m: 9} + - {id: southwest_merge, type: intersection, pose: {x_m: 277, y_m: -122}} + - {id: southwest_boulevard_end, type: cul_de_sac, pose: {x_m: 185, y_m: -310}, culdesac_radius_m: 14} + - {id: arterial_merge_crossing, type: intersection, pose: {x_m: 410, y_m: -88}} + + # Northern cross street and its repeated north/south loops. + - {id: north_crossing_470, type: intersection, pose: {x_m: 470, y_m: 133}} + - {id: north_end_470, type: cul_de_sac, pose: {x_m: 470, y_m: 220}, culdesac_radius_m: 9} + - {id: north_crossing_550, type: intersection, pose: {x_m: 550, y_m: 130}} + - {id: north_crossing_630, type: intersection, pose: {x_m: 630, y_m: 126}} + - {id: north_crossing_687, type: road_joint, pose: {x_m: 687, y_m: 129}} + + # Long eastern arterial. + - {id: arterial_crossing_470, type: intersection, pose: {x_m: 470, y_m: -88}} + - {id: arterial_crossing_550, type: intersection, pose: {x_m: 550, y_m: -84}} + - {id: arterial_crossing_630, type: intersection, pose: {x_m: 630, y_m: -86}} + - {id: arterial_crossing_710, type: intersection, pose: {x_m: 710, y_m: -87}} + - {id: arterial_crossing_800, type: intersection, pose: {x_m: 800, y_m: -86}} + - {id: arterial_crossing_860, type: intersection, pose: {x_m: 860, y_m: -88}} + - {id: arterial_crossing_895, type: intersection, pose: {x_m: 895, y_m: -87}} + - {id: east_corner_lot_driveway, type: driveway, pose: {x_m: 955, y_m: -88}} + - {id: arterial_crossing_990, type: intersection, pose: {x_m: 990, y_m: -88}} + - {id: arterial_crossing_1082, type: intersection, pose: {x_m: 1082, y_m: -85}} + - {id: arterial_crossing_1170, type: intersection, pose: {x_m: 1170, y_m: -87}} + - {id: east_arterial_end, type: cul_de_sac, pose: {x_m: 1220, y_m: -88}, culdesac_radius_m: 12} + + # Southern commercial grid. + - {id: south_crossing_630, type: road_joint, pose: {x_m: 630, y_m: -303}, lane_transition_length_m: 20} + - {id: south_crossing_710, type: intersection, pose: {x_m: 710, y_m: -176}} + - {id: lower_crossing_710, type: intersection, pose: {x_m: 710, y_m: -262}, lane_transition_length_m: 20} + - {id: south_end_710, type: cul_de_sac, pose: {x_m: 710, y_m: -310}, culdesac_radius_m: 9} + - {id: south_crossing_860, type: intersection, pose: {x_m: 860, y_m: -176}, lane_transition_length_m: 20} + - {id: lower_crossing_860, type: intersection, pose: {x_m: 860, y_m: -262}} + - {id: south_upper_lot_driveway, type: driveway, pose: {x_m: 895, y_m: -176}} + - {id: south_crossing_930, type: intersection, pose: {x_m: 930, y_m: -176}} + - {id: lower_crossing_930, type: intersection, pose: {x_m: 930, y_m: -266}} + - {id: south_crossing_990, type: intersection, pose: {x_m: 990, y_m: -177}, lane_transition_length_m: 20} + - {id: lower_crossing_990, type: intersection, pose: {x_m: 990, y_m: -265}} + - {id: lower_lot_driveway, type: driveway, pose: {x_m: 785, y_m: -262}} + - {id: lower_crossing_1080, type: road_joint, pose: {x_m: 1080, y_m: -267}} + - {id: south_cul_de_sac_1080, type: cul_de_sac, pose: {x_m: 1082, y_m: -222}, culdesac_radius_m: 10} + - {id: bottom_lot_driveway, type: driveway, pose: {x_m: 550, y_m: -310}} + - {id: bottom_west_end, type: cul_de_sac, pose: {x_m: 470, y_m: -330}, culdesac_radius_m: 9} + + # Far-east surface loop. + - {id: far_east_north_crossing, type: intersection, pose: {x_m: 1082, y_m: -33}} + - {id: far_east_north_end, type: cul_de_sac, pose: {x_m: 1082, y_m: 71}, culdesac_radius_m: 9} + - {id: far_east_bend_southeast, type: driveway, pose: {x_m: 1170, y_m: -33}} + - {id: far_east_bend_northeast, type: road_joint, pose: {x_m: 1170, y_m: 75}} + - {id: far_east_bend_northwest, type: road_joint, pose: {x_m: 1120, y_m: 75}} + - {id: far_east_bend_southwest, type: road_joint, pose: {x_m: 1120, y_m: -33}} + + # Parking masks use original-scene scale and are connected by inferred + # access surfaces rather than authored road edges. + - id: southwest_parking_lot + type: parking_lot + connected_to: southwest_lot_driveway + opening_vertex: 2 + vertices: + - {x_m: -24, y_m: -136} + - {x_m: 14, y_m: -136} + - {x_m: 26, y_m: -136} + - {x_m: 65, y_m: -136} + - {x_m: 65, y_m: -189} + - {x_m: -24, y_m: -189} + - id: south_parking_lot + type: parking_lot + connected_to: south_lot_driveway + opening_vertex: 2 + vertices: + - {x_m: 135, y_m: -150} + - {x_m: 145, y_m: -150} + - {x_m: 157, y_m: -150} + - {x_m: 175, y_m: -150} + - {x_m: 175, y_m: -205} + - {x_m: 135, y_m: -205} + - id: central_bottom_parking_lot + type: parking_lot + connected_to: bottom_lot_driveway + opening_vertex: 4 + vertices: + - {x_m: 510, y_m: -266} + - {x_m: 570, y_m: -266} + - {x_m: 570, y_m: -296} + - {x_m: 556, y_m: -296} + - {x_m: 544, y_m: -296} + - {x_m: 510, y_m: -296} + - id: east_upper_parking_lot + type: parking_lot + connected_to: arterial_crossing_895 + opening_vertex: 2 + vertices: + - {x_m: 870, y_m: -115} + - {x_m: 889, y_m: -115} + - {x_m: 901, y_m: -115} + - {x_m: 920, y_m: -115} + - {x_m: 920, y_m: -165} + - {x_m: 870, y_m: -165} + - id: east_corner_parking_lot + type: parking_lot + connected_to: east_corner_lot_driveway + opening_vertex: 2 + vertices: + - {x_m: 935, y_m: -115} + - {x_m: 949, y_m: -115} + - {x_m: 961, y_m: -115} + - {x_m: 975, y_m: -115} + - {x_m: 975, y_m: -165} + - {x_m: 935, y_m: -165} + - id: east_lower_parking_lot + type: parking_lot + connected_to: south_upper_lot_driveway + opening_vertex: 2 + vertices: + - {x_m: 870, y_m: -195} + - {x_m: 889, y_m: -195} + - {x_m: 901, y_m: -195} + - {x_m: 920, y_m: -195} + - {x_m: 920, y_m: -245} + - {x_m: 870, y_m: -245} + - id: long_lower_parking_lot + type: parking_lot + connected_to: lower_lot_driveway + opening_vertex: 2 + vertices: + - {x_m: 721, y_m: -275} + - {x_m: 779, y_m: -275} + - {x_m: 791, y_m: -275} + - {x_m: 850, y_m: -275} + - {x_m: 850, y_m: -290} + - {x_m: 721, y_m: -290} + - id: far_east_parking_lot + type: parking_lot + connected_to: far_east_bend_southeast + opening_vertex: 5 + vertices: + - {x_m: 1192, y_m: -5} + - {x_m: 1222, y_m: -5} + - {x_m: 1222, y_m: -60} + - {x_m: 1192, y_m: -60} + - {x_m: 1192, y_m: -39} + - {x_m: 1192, y_m: -27} + +roads: + # Spawn-area arterial and neighborhood grid. + - {id: west_arterial_approach, from: west_arterial_end, to: west_arterial_crossing, profile: arterial} + - {id: spawn_arterial, from: west_arterial_crossing, to: central_arterial_crossing, profile: arterial} + - {id: central_arterial, from: central_arterial_crossing, to: diagonal_arterial_crossing, profile: arterial} + - {id: west_north_lower, from: west_arterial_crossing, to: west_north_crossing, profile: street} + - {id: west_north_upper, from: west_north_crossing, to: west_upper_crossing, profile: street} + - {id: west_north_tail, from: west_upper_crossing, to: west_north_end, profile: street} + - {id: central_north_lower, from: central_arterial_crossing, to: central_north_crossing, profile: street} + - {id: central_north_upper, from: central_north_crossing, to: central_upper_crossing, profile: street} + - {id: central_north_tail, from: central_upper_crossing, to: central_north_end, profile: local} + - {id: north_cross_street, from: west_north_crossing, to: central_north_crossing, profile: street} + - {id: upper_cross_street, from: west_upper_crossing, to: central_upper_crossing, profile: street} + - {id: west_south_upper, from: west_arterial_crossing, to: west_south_crossing, profile: street} + - {id: west_south_lower, from: west_south_crossing, to: west_lower_crossing, profile: street} + - {id: west_south_tail, from: west_lower_crossing, to: west_south_end, profile: local} + - {id: southwest_cross_west, from: west_south_crossing, to: southwest_crossing, profile: local} + - {id: southwest_cross_center, from: southwest_crossing, to: southwest_lot_driveway, profile: local} + - {id: southwest_cross_east, from: southwest_lot_driveway, to: central_south_crossing, profile: local} + - {id: southwest_north_stub, from: southwest_crossing, to: southwest_north_end, profile: local} + - {id: central_south_upper, from: central_arterial_crossing, to: central_south_crossing, profile: street} + - {id: central_south_middle, from: central_south_crossing, to: central_lower_crossing, profile: local} + - {id: central_south_tail, from: central_lower_crossing, to: central_south_end, profile: local} + - {id: south_local_west, from: central_lower_crossing, to: south_lot_driveway, profile: local} + - {id: south_local_east, from: south_lot_driveway, to: south_local_end, profile: local} + + # Diagonal street and the two broad arterial branches. + - {id: diagonal_north_lower, from: diagonal_arterial_crossing, to: diagonal_bend_lower, profile: street} + - {id: diagonal_north_middle, from: diagonal_bend_lower, to: diagonal_north_crossing, profile: street} + - {id: diagonal_north_upper, from: diagonal_north_crossing, to: diagonal_bend_upper, profile: street} + - {id: diagonal_north_tail, from: diagonal_bend_upper, to: diagonal_north_end, profile: street} + - {id: diagonal_south_link, from: diagonal_arterial_crossing, to: southwest_merge, profile: arterial} + - id: southwest_boulevard + from: southwest_boulevard_end + to: southwest_merge + profile: arterial + path: + - {x_m: 184, y_m: -255} + - {x_m: 205, y_m: -190} + - {x_m: 242, y_m: -145} + - id: arterial_sweep + from: diagonal_arterial_crossing + to: arterial_merge_crossing + profile: arterial + path: + - {x_m: 332, y_m: -8} + - {x_m: 370, y_m: -39} + - {x_m: 397, y_m: -72} + - id: merge_ramp + from: southwest_merge + to: arterial_merge_crossing + profile: arterial + path: + - {x_m: 322, y_m: -121} + - {x_m: 365, y_m: -108} + - {id: arterial_merge_connector, from: arterial_merge_crossing, to: arterial_crossing_470, profile: arterial} + + # Northern cross street. + - {id: north_cross_307_470, from: diagonal_north_crossing, to: north_crossing_470, profile: street} + - {id: north_cross_470_550, from: north_crossing_470, to: north_crossing_550, profile: street} + - {id: north_cross_550_630, from: north_crossing_550, to: north_crossing_630, profile: street} + - {id: north_cross_630_687, from: north_crossing_630, to: north_crossing_687, profile: street} + - {id: north_470_tail, from: north_crossing_470, to: north_end_470, profile: street} + - id: west_north_loop_leg + from: arterial_crossing_470 + to: north_crossing_470 + profile: street + path: + - {x_m: 470, y_m: -5} + - {x_m: 451, y_m: 35} + - {x_m: 451, y_m: 95} + - {id: north_loop_leg_550, from: arterial_crossing_550, to: north_crossing_550, profile: street} + - {id: north_loop_leg_630, from: arterial_crossing_630, to: north_crossing_630, profile: street} + - id: east_north_loop_leg + from: north_crossing_687 + to: arterial_crossing_710 + profile: street + path: + - {x_m: 696, y_m: 83} + - {x_m: 710, y_m: 45} + + # Eastern arterial and large northern loop. + - {id: arterial_470_550, from: arterial_crossing_470, to: arterial_crossing_550, profile: arterial} + - {id: arterial_550_630, from: arterial_crossing_550, to: arterial_crossing_630, profile: arterial} + - {id: arterial_630_710, from: arterial_crossing_630, to: arterial_crossing_710, profile: arterial} + - {id: arterial_710_800, from: arterial_crossing_710, to: arterial_crossing_800, profile: arterial} + - {id: arterial_800_860, from: arterial_crossing_800, to: arterial_crossing_860, profile: arterial} + - {id: arterial_860_895, from: arterial_crossing_860, to: arterial_crossing_895, profile: arterial} + - {id: arterial_895_955, from: arterial_crossing_895, to: east_corner_lot_driveway, profile: arterial} + - {id: arterial_955_990, from: east_corner_lot_driveway, to: arterial_crossing_990, profile: arterial} + - {id: arterial_990_1082, from: arterial_crossing_990, to: arterial_crossing_1082, profile: arterial} + - {id: arterial_1082_1170, from: arterial_crossing_1082, to: arterial_crossing_1170, profile: arterial} + - {id: arterial_1170_end, from: arterial_crossing_1170, to: east_arterial_end, profile: arterial} + - id: east_north_loop + from: arterial_crossing_800 + to: arterial_crossing_895 + profile: street + path: + - {x_m: 800, y_m: 30} + - {x_m: 800, y_m: 90} + - {x_m: 895, y_m: 90} + - {x_m: 895, y_m: 30} + + # Southern commercial grid and parking courts. + - {id: south_spine_630, from: arterial_crossing_630, to: south_crossing_630, profile: street} + - {id: south_710_upper, from: arterial_crossing_710, to: south_crossing_710, profile: street} + - {id: south_710_lower, from: south_crossing_710, to: lower_crossing_710, profile: street} + - {id: south_710_tail, from: lower_crossing_710, to: south_end_710, profile: local} + - {id: south_860_upper, from: arterial_crossing_860, to: south_crossing_860, profile: street} + - {id: south_860_lower, from: south_crossing_860, to: lower_crossing_860, profile: local} + - {id: south_990_upper, from: arterial_crossing_990, to: south_crossing_990, profile: street} + - {id: south_990_lower, from: south_crossing_990, to: lower_crossing_990, profile: local} + - {id: upper_commercial_west, from: south_crossing_710, to: south_crossing_860, profile: local} + - {id: upper_commercial_center_west, from: south_crossing_860, to: south_upper_lot_driveway, profile: local} + - {id: upper_commercial_center_east, from: south_upper_lot_driveway, to: south_crossing_930, profile: local} + - {id: upper_commercial_east, from: south_crossing_930, to: south_crossing_990, profile: local} + - {id: commercial_930_spine, from: south_crossing_930, to: lower_crossing_930, profile: local} + - {id: lower_commercial_west, from: lower_crossing_710, to: lower_lot_driveway, profile: local} + - {id: lower_commercial_midwest, from: lower_lot_driveway, to: lower_crossing_860, profile: local} + - {id: lower_commercial_center, from: lower_crossing_860, to: lower_crossing_930, profile: local} + - {id: lower_commercial_east, from: lower_crossing_930, to: lower_crossing_990, profile: local} + - {id: lower_commercial_tail, from: lower_crossing_990, to: lower_crossing_1080, profile: local} + - {id: east_south_cul_de_sac, from: lower_crossing_1080, to: south_cul_de_sac_1080, profile: local} + - id: bottom_west_road + from: bottom_west_end + to: bottom_lot_driveway + profile: local + path: + - {x_m: 500, y_m: -320} + - {id: bottom_east_road, from: bottom_lot_driveway, to: south_crossing_630, profile: local} + + # Far-east surface loop and local northern spur. + - {id: far_east_north_spur, from: arterial_crossing_1082, to: far_east_north_crossing, profile: local} + - {id: far_east_north_tail, from: far_east_north_crossing, to: far_east_north_end, profile: local} + - {id: far_east_loop_entry, from: arterial_crossing_1170, to: far_east_bend_southeast, profile: local} + - {id: far_east_loop_east, from: far_east_bend_southeast, to: far_east_bend_northeast, profile: local} + - {id: far_east_loop_north, from: far_east_bend_northeast, to: far_east_bend_northwest, profile: local} + - {id: far_east_loop_west, from: far_east_bend_northwest, to: far_east_bend_southwest, profile: local} + - {id: far_east_loop_exit, from: far_east_bend_southwest, to: far_east_north_crossing, profile: local} + +traffic_count: 100 + +traffic: + - id: arterial_eastbound + nodes: [west_arterial_crossing, arterial_crossing_1170] + end_behavior: reverse + - id: arterial_westbound + nodes: [arterial_crossing_1170, west_arterial_crossing] + end_behavior: reverse + + - id: northwest_clockwise + nodes: [west_arterial_crossing, west_north_crossing, west_upper_crossing, central_upper_crossing, central_north_crossing, central_arterial_crossing] + end_behavior: wrap + - id: northwest_counterclockwise + nodes: [central_upper_crossing, west_upper_crossing, west_north_crossing, west_arterial_crossing, central_arterial_crossing, central_north_crossing] + end_behavior: wrap + + - id: diagonal_clockwise + nodes: [diagonal_arterial_crossing, diagonal_north_crossing, north_crossing_470, arterial_crossing_470, arterial_merge_crossing] + end_behavior: wrap + - id: diagonal_counterclockwise + nodes: [north_crossing_470, diagonal_north_crossing, diagonal_arterial_crossing, arterial_merge_crossing, arterial_crossing_470] + end_behavior: wrap + + - id: southwest_clockwise + nodes: [west_arterial_crossing, west_south_crossing, southwest_crossing, southwest_lot_driveway, central_south_crossing, central_arterial_crossing] + end_behavior: wrap + - id: southwest_counterclockwise + nodes: [southwest_lot_driveway, southwest_crossing, west_south_crossing, west_arterial_crossing, central_arterial_crossing, central_south_crossing] + end_behavior: wrap + + - id: south_grid_clockwise + nodes: [arterial_crossing_710, south_crossing_710, south_crossing_860, lower_crossing_860, lower_crossing_930, lower_crossing_990, south_crossing_990, arterial_crossing_990, arterial_crossing_860, arterial_crossing_800] + end_behavior: wrap + - id: south_grid_counterclockwise + nodes: [lower_crossing_930, lower_crossing_860, south_crossing_860, south_crossing_710, arterial_crossing_710, arterial_crossing_800, arterial_crossing_860, arterial_crossing_990, south_crossing_990, lower_crossing_990] + end_behavior: wrap + + - id: far_east_clockwise + nodes: [arterial_crossing_1082, arterial_crossing_1170, far_east_bend_southeast, far_east_bend_northeast, far_east_bend_northwest, far_east_bend_southwest, far_east_north_crossing] + end_behavior: wrap + - id: far_east_counterclockwise + nodes: [far_east_bend_northwest, far_east_bend_northeast, far_east_bend_southeast, arterial_crossing_1170, arterial_crossing_1082, far_east_north_crossing, far_east_bend_southwest] + end_behavior: wrap + +spawns: + - id: original_area_start + road: spawn_arterial + lane: 2 + distance_m: 128 + variants: + default: + image: package://omnidreams_game_engine/screenshot.jpg + prompt: >- + A forward-facing view from a taxi moving through a quiet suburban + district in daylight, with low commercial buildings, houses, + landscaping, and parked cars. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml new file mode 100644 index 000000000..664411c9b --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml @@ -0,0 +1,100 @@ +schema_version: 1 +id: crazy-robotaxi-minimal-loop +name: Minimal Loop and Parking Lot + +compiler: + sample_spacing_m: 2.0 + ground_margin_m: 20.0 + intersection_connector_samples: 8 + +profiles: + neighborhood: + lane_width_m: 3.6 + curb_offset_m: 0.6 + lanes: [backward, forward] + speed_limit_mps: 13.4 + lane_marking: {style: SOLID_GROUP, color: YELLOW} + divider_markings: + - {style: SOLID_GROUP, color: YELLOW} + +nodes: + - id: hub + type: intersection + pose: {x_m: 0, y_m: 0} + + - id: dead_end + type: cul_de_sac + pose: {x_m: 0, y_m: 30} + culdesac_radius_m: 10 + + - id: neighborhood_lot + type: parking_lot + connected_to: hub + opening_vertex: 2 + vertices: + - {x_m: -35, y_m: -7} + - {x_m: -3, y_m: -7} + - {x_m: 3, y_m: -7} + - {x_m: 35, y_m: -7} + - {x_m: 35, y_m: -27} + - {x_m: -35, y_m: -27} + + - id: neighborhood_loop_northeast_corner + type: road_joint + pose: {x_m: -50, y_m: 75} + + - id: neighborhood_loop_northwest_corner + type: road_joint + pose: {x_m: 50, y_m: 75} + + - id: neighborhood_loop_southeast_corner + type: road_joint + pose: {x_m: -50, y_m: 0} + + - id: neighborhood_loop_southwest_corner + type: road_joint + pose: {x_m: 50, y_m: 0} + +roads: + - id: neighborhood_loop_north + from: neighborhood_loop_northeast_corner + to: neighborhood_loop_northwest_corner + profile: neighborhood + + - id: neighborhood_loop_southeast + from: neighborhood_loop_southeast_corner + to: hub + profile: neighborhood + + - id: neighborhood_loop_southwest + from: neighborhood_loop_southwest_corner + to: hub + profile: neighborhood + + - id: neighborhood_loop_east + from: neighborhood_loop_northeast_corner + to: neighborhood_loop_southeast_corner + profile: neighborhood + + - id: neighborhood_loop_west + from: neighborhood_loop_northwest_corner + to: neighborhood_loop_southwest_corner + profile: neighborhood + + - id: dead_end_road + from: hub + to: dead_end + profile: neighborhood + +spawns: + - id: taxi_start + road: neighborhood_loop_southwest + lane: 1 + distance_m: 5 + variants: + default: + image: package://omnidreams_game_engine/screenshot.jpg + prompt: >- + A forward-facing view from a taxi moving through a quiet suburban + neighborhood in daylight, with houses, low commercial buildings, + landscaping, and parked cars. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/navigation.py b/apps/crazy_robotaxi/crazy_robotaxi/navigation.py new file mode 100644 index 000000000..b7a7363b6 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/navigation.py @@ -0,0 +1,813 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Directed road routing for Crazy Robotaxi.""" + +from __future__ import annotations + +import heapq +import math +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +_MIN_SEGMENT_LENGTH_M = 1.0e-4 + +_ROAD_MARKER_EDGE_INSET_M = 1.0 +"""Maximum distance from a mapped road edge to a Taxi marker center.""" + +_PASSENGER_EDGE_OFFSET_M = 0.75 +"""Distance a waiting passenger stands beyond a mapped road edge.""" + +_INFERRED_LANE_HALF_WIDTH_M = 2.0 +"""Half-width used when a legacy scene provides only a recorded route.""" + + +def _triangulate_clockwise_polygon(polygon: np.ndarray) -> tuple[np.ndarray, ...]: + """Triangulate a simple clockwise polygon while preserving its full area.""" + vertices = [np.asarray(point[:2], dtype=np.float64) for point in polygon] + triangles: list[np.ndarray] = [] + + def cross(first: np.ndarray, second: np.ndarray, third: np.ndarray) -> float: + first_edge = second - first + second_edge = third - first + return float(first_edge[0] * second_edge[1] - first_edge[1] * second_edge[0]) + + while len(vertices) > 3: + removed = False + for index, current in enumerate(vertices): + previous_index = (index - 1) % len(vertices) + following_index = (index + 1) % len(vertices) + previous = vertices[previous_index] + following = vertices[following_index] + signed_area = cross(previous, current, following) + if abs(signed_area) <= _MIN_SEGMENT_LENGTH_M: + vertices.pop(index) + removed = True + break + if signed_area > 0.0: + continue + contains_vertex = any( + other_index not in {previous_index, index, following_index} + and cross(previous, current, other) < -_MIN_SEGMENT_LENGTH_M + and cross(current, following, other) < -_MIN_SEGMENT_LENGTH_M + and cross(following, previous, other) < -_MIN_SEGMENT_LENGTH_M + for other_index, other in enumerate(vertices) + ) + if contains_vertex: + continue + triangles.append(np.asarray((previous, current, following))) + vertices.pop(index) + removed = True + break + if not removed: + raise ValueError("Could not triangulate fare-region polygon") + triangles.append(np.asarray(vertices)) + return tuple(triangles) + + +@dataclass(frozen=True) +class NavigationLane: + """Directed lane centerline.""" + + centerline_world: npt.NDArray[np.float32] + """Directed lane-center polyline in world coordinates.""" + + road_edge_world: npt.NDArray[np.float32] | None = None + """Curb or outer road-edge polyline suitable for a roadside stop.""" + + allows_taxi_stops: bool = True + """Whether pickup and dropoff candidates may be sampled from this lane.""" + + lane_id: str | None = None + """Stable semantic lane identifier; ``None`` denotes legacy geometry.""" + + successor_ids: tuple[str, ...] | None = None + """Explicit legal successors; ``None`` enables legacy endpoint inference.""" + + element_id: str | None = None + """Owning semantic road or node identifier when sourced from a game map.""" + + +@dataclass(frozen=True) +class NavigationFareRegion: + """Surface geometry from which non-road fare targets are sampled.""" + + region_id: str + """Stable source element identifier.""" + + kind: str + """Sampling mode: ``area`` for polygons or ``boundary`` for polylines.""" + + geometry_world: tuple[npt.NDArray[np.float32], ...] + """World-space polygon or exposed-boundary polylines.""" + + arrival_lane_ids: tuple[str, ...] + """Lane endpoints where routed travel to the region stops.""" + + departure_lane_ids: tuple[str, ...] + """Lane endpoints where routed travel from the region begins.""" + + +@dataclass(frozen=True) +class NavigationWaypoint: + """Physical fare target with directed routing anchors.""" + + xyz_m: npt.NDArray[np.float32] + """World-space waypoint position.""" + + lane_index: int + """Index of the source lane in the navigation map.""" + + distance_along_lane_m: float + """Arc distance from the source lane's directed start.""" + + passenger_xyz_m: npt.NDArray[np.float32] | None = None + """Waiting-passenger ground point, or ``None`` to use ``xyz_m``.""" + + arrival_anchors: tuple[LanePosition, ...] = () + """Possible road-graph endpoints used when routing to this target.""" + + departure_anchors: tuple[LanePosition, ...] = () + """Possible road-graph origins used when routing away from this target.""" + + element_id: str | None = None + """Road or node whose vicinity controls passenger conditioning visibility.""" + + +@dataclass(frozen=True) +class LanePosition: + """Closest directed-lane location for a vehicle pose.""" + + lane_index: int + """Index of the matched navigation lane.""" + + distance_along_lane_m: float + """Arc distance from the lane's directed start.""" + + lateral_distance_m: float + """XY distance between the vehicle and the matched centerline.""" + + heading_error_rad: float + """Absolute difference between vehicle and lane headings.""" + + +@dataclass(frozen=True) +class RoutePlan: + """Shortest legal lane path to one destination waypoint.""" + + lane_indices: tuple[int, ...] + """Directed lanes traversed from the current position to the target.""" + + distance_m: float + """Total routed road distance to the destination.""" + + +class TaxiNavigationMap: + """Directed lane graph for one Taxi scene.""" + + def __init__( + self, + lanes: tuple[NavigationLane, ...], + *, + endpoint_snap_tolerance_m: float = 1.0, + ) -> None: + """Build routing indexes for a scene. + + Args: + lanes: Directed car-lane centerlines. + endpoint_snap_tolerance_m: Maximum endpoint gap connected by the graph. + + Raises: + ValueError: No lane contains usable travel distance or the endpoint + tolerance is not positive. + """ + if endpoint_snap_tolerance_m <= 0.0: + raise ValueError("Taxi endpoint snap tolerance must be positive.") + + normalized_lanes: list[NavigationLane] = [] + cumulative_distances: list[npt.NDArray[np.float32]] = [] + road_edge_cumulative_distances: list[npt.NDArray[np.float32] | None] = [] + for lane in lanes: + points = _normalize_polyline(lane.centerline_world) + if points is None: + continue + segment_lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + cumulative = np.concatenate(([0.0], np.cumsum(segment_lengths))).astype( + np.float32 + ) + road_edge = ( + None + if lane.road_edge_world is None + else _normalize_polyline(lane.road_edge_world) + ) + normalized_lanes.append( + NavigationLane( + points, + road_edge, + lane.allows_taxi_stops, + lane.lane_id, + lane.successor_ids, + lane.element_id, + ) + ) + cumulative_distances.append(cumulative) + road_edge_cumulative_distances.append( + None if road_edge is None else _cumulative_distances(road_edge) + ) + if not normalized_lanes: + raise ValueError("Taxi navigation geometry has no usable travel distance.") + + self._lanes = tuple(normalized_lanes) + self._cumulative_distances = tuple(cumulative_distances) + self._road_edge_cumulative_distances = tuple(road_edge_cumulative_distances) + self._lane_lengths = np.asarray( + [float(cumulative[-1]) for cumulative in cumulative_distances], + dtype=np.float64, + ) + self._adjacency = self._build_adjacency(endpoint_snap_tolerance_m) + self._build_segment_index() + + @classmethod + def from_polylines( + cls, + routes_world: tuple[npt.NDArray[np.float32], ...], + *, + bidirectional: bool, + ) -> TaxiNavigationMap: + """Build a navigation map from route polylines. + + Args: + routes_world: Route polylines in world coordinates. + bidirectional: Whether to add a reversed lane for every route. + + Returns: + Navigation map containing the supplied route directions. + """ + lanes: list[NavigationLane] = [] + for route in routes_world: + route_array = np.asarray(route, dtype=np.float32) + lanes.append( + NavigationLane( + route_array, + _infer_right_road_edge(route_array), + ) + ) + if bidirectional: + reversed_route = route_array[::-1].copy() + lanes.append( + NavigationLane( + reversed_route, + _infer_right_road_edge(reversed_route), + ) + ) + return cls(tuple(lanes)) + + @property + def lanes(self) -> tuple[NavigationLane, ...]: + """Return the normalized directed lanes.""" + return self._lanes + + def sample_waypoints( + self, spacing_m: float, offset_m: float + ) -> tuple[NavigationWaypoint, ...]: + """Sample spatially distinct target candidates across the lane graph. + + Args: + spacing_m: Arc distance between samples on each lane. + offset_m: Shared sampling offset in ``[0, spacing_m)``. + + Returns: + Deduplicated waypoint candidates with source-lane locations. + + Raises: + ValueError: ``spacing_m`` is not positive or fewer than two distinct + waypoints can be produced. + """ + if spacing_m <= 0.0: + raise ValueError("Taxi waypoint spacing must be positive.") + sampled: list[NavigationWaypoint] = [] + occupied_cells: set[tuple[int, int]] = set() + for lane_index, lane_length in enumerate(self._lane_lengths): + if not self._lanes[lane_index].allows_taxi_stops: + continue + sample_distances = np.arange( + offset_m, float(lane_length) + 1.0e-6, spacing_m + ) + if len(sample_distances) < 2: + sample_distances = np.asarray([0.0, lane_length], dtype=np.float32) + for distance_m in sample_distances: + point, passenger_point = self._taxi_stop_points_at( + lane_index, float(distance_m) + ) + cell = ( + int(round(float(point[0]) * 2.0)), + int(round(float(point[1]) * 2.0)), + ) + if cell in occupied_cells: + continue + occupied_cells.add(cell) + sampled.append( + NavigationWaypoint( + point, + lane_index, + float(distance_m), + passenger_point, + element_id=self._lanes[lane_index].element_id, + ) + ) + if len(sampled) < 2: + raise ValueError("Taxi mode requires at least two distinct road waypoints.") + return tuple(sampled) + + def sample_fare_regions( + self, + regions: tuple[NavigationFareRegion, ...], + spacing_m: float, + rng: np.random.Generator, + ) -> tuple[NavigationWaypoint, ...]: + """Sample physical targets from node boundaries and parking-lot areas. + + Args: + regions: Compiled surface regions and their routing endpoints. + spacing_m: Nominal spacing controlling target density. + rng: Random generator used for reproducible placement. + + Returns: + Physical targets carrying arrival and departure routing anchors. + """ + lane_indices = { + lane.lane_id: index + for index, lane in enumerate(self._lanes) + if lane.lane_id is not None + } + + def anchors( + lane_ids: tuple[str, ...], *, arrival: bool + ) -> tuple[LanePosition, ...]: + return tuple( + LanePosition( + lane_index=index, + distance_along_lane_m=( + float(self._lane_lengths[index]) if arrival else 0.0 + ), + lateral_distance_m=0.0, + heading_error_rad=0.0, + ) + for lane_id in lane_ids + for index in (lane_indices.get(lane_id),) + if index is not None + ) + + result: list[NavigationWaypoint] = [] + for region in regions: + arrivals = anchors(region.arrival_lane_ids, arrival=True) + departures = anchors(region.departure_lane_ids, arrival=False) + if not arrivals or not departures: + continue + points: list[np.ndarray] = [] + if region.kind == "area": + polygon = np.asarray(region.geometry_world[0], dtype=np.float64) + area = abs( + 0.5 + * float( + np.dot(polygon[:, 0], np.roll(polygon[:, 1], -1)) + - np.dot(polygon[:, 1], np.roll(polygon[:, 0], -1)) + ) + ) + count = max(1, int(math.ceil(area / (spacing_m * spacing_m)))) + triangles = _triangulate_clockwise_polygon(polygon[:, :2]) + triangle_areas = np.asarray( + [ + abs( + (triangle[1, 0] - triangle[0, 0]) + * (triangle[2, 1] - triangle[0, 1]) + - (triangle[1, 1] - triangle[0, 1]) + * (triangle[2, 0] - triangle[0, 0]) + ) + * 0.5 + for triangle in triangles + ] + ) + probabilities = triangle_areas / np.sum(triangle_areas) + for _index in range(count): + triangle = triangles[ + int(rng.choice(len(triangles), p=probabilities)) + ] + first_random, second_random = rng.random(2) + root = math.sqrt(float(first_random)) + candidate = ( + (1.0 - root) * triangle[0] + + root * (1.0 - second_random) * triangle[1] + + root * second_random * triangle[2] + ) + points.append(np.asarray([candidate[0], candidate[1], 0.0])) + elif region.kind == "boundary": + offset = float(rng.uniform(0.0, spacing_m)) + for polyline in region.geometry_world: + line = np.asarray(polyline, dtype=np.float32) + lengths = np.linalg.norm(np.diff(line[:, :2], axis=0), axis=1) + total = float(np.sum(lengths)) + distances = np.arange(offset, total + 1.0e-6, spacing_m) + if not len(distances): + distances = np.asarray([total * 0.5]) + cumulative = np.concatenate(([0.0], np.cumsum(lengths))) + points.extend( + _point_at_distance(line, cumulative, float(distance)) + for distance in distances + ) + else: + raise ValueError(f"Unsupported fare-region kind {region.kind!r}") + primary = arrivals[0] + result.extend( + NavigationWaypoint( + xyz_m=np.asarray(point, dtype=np.float32), + lane_index=primary.lane_index, + distance_along_lane_m=primary.distance_along_lane_m, + passenger_xyz_m=np.asarray(point, dtype=np.float32), + arrival_anchors=arrivals, + departure_anchors=departures, + element_id=region.region_id, + ) + for point in points + ) + return tuple(result) + + def point_at( + self, lane_index: int, distance_along_lane_m: float + ) -> npt.NDArray[np.float32]: + """Interpolate a world point along a directed lane.""" + lane = self._lanes[lane_index].centerline_world + cumulative = self._cumulative_distances[lane_index] + distance_m = float(np.clip(distance_along_lane_m, 0.0, float(cumulative[-1]))) + return _point_at_distance(lane, cumulative, distance_m) + + def _taxi_stop_points_at( + self, lane_index: int, distance_along_lane_m: float + ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: + center = self.point_at(lane_index, distance_along_lane_m) + lane = self._lanes[lane_index] + edge_cumulative = self._road_edge_cumulative_distances[lane_index] + if lane.road_edge_world is None or edge_cumulative is None: + return center, center.copy() + + lane_fraction = float( + np.clip( + distance_along_lane_m / self._lane_lengths[lane_index], + 0.0, + 1.0, + ) + ) + edge = _point_at_distance( + lane.road_edge_world, + edge_cumulative, + lane_fraction * float(edge_cumulative[-1]), + ) + inward_xy = center[:2] - edge[:2] + half_width_m = float(np.linalg.norm(inward_xy)) + if half_width_m <= _MIN_SEGMENT_LENGTH_M: + return center, center.copy() + + inward_unit_xy = inward_xy / half_width_m + marker_inset_m = min(_ROAD_MARKER_EDGE_INSET_M, 0.5 * half_width_m) + marker = edge.copy() + marker[:2] += marker_inset_m * inward_unit_xy + passenger = edge.copy() + passenger[:2] -= _PASSENGER_EDGE_OFFSET_M * inward_unit_xy + return marker.astype(np.float32), passenger.astype(np.float32) + + def nearest_lane_positions( + self, + x_m: float, + y_m: float, + yaw_rad: float, + *, + limit: int = 8, + ) -> tuple[LanePosition, ...]: + """Return nearby lane matches ordered by distance and heading agreement.""" + if limit <= 0: + return () + query = np.asarray([x_m, y_m], dtype=np.float32) + relative = query[None, :] - self._segment_starts_xy + parameter = np.clip( + np.sum(relative * self._segment_vectors_xy, axis=1) + / self._segment_lengths_sq, + 0.0, + 1.0, + ) + closest = ( + self._segment_starts_xy + parameter[:, None] * self._segment_vectors_xy + ) + distances = np.linalg.norm(closest - query[None, :], axis=1) + heading_errors = np.abs( + _normalize_angles(self._segment_headings_rad - float(yaw_rad)) + ) + scores = distances + np.where(heading_errors <= math.pi * 0.55, 0.0, 20.0) + candidate_count = min(len(scores), max(limit * 12, limit)) + candidate_segments = np.argpartition(scores, candidate_count - 1)[ + :candidate_count + ] + candidate_segments = candidate_segments[ + np.argsort(scores[candidate_segments], kind="stable") + ] + + matches: list[LanePosition] = [] + matched_lanes: set[int] = set() + for segment_index in candidate_segments: + lane_index = int(self._segment_lane_indices[segment_index]) + if lane_index in matched_lanes: + continue + matched_lanes.add(lane_index) + matches.append( + LanePosition( + lane_index=lane_index, + distance_along_lane_m=float( + self._segment_start_distances_m[segment_index] + + parameter[segment_index] + * math.sqrt(float(self._segment_lengths_sq[segment_index])) + ), + lateral_distance_m=float(distances[segment_index]), + heading_error_rad=float(heading_errors[segment_index]), + ) + ) + if len(matches) >= limit: + break + return tuple(matches) + + def route( + self, start: LanePosition, destination: NavigationWaypoint + ) -> RoutePlan | None: + """Return the shortest directed route between two lane positions.""" + distances_to_start, predecessors = self._shortest_tree(start) + anchors = destination.arrival_anchors or ( + LanePosition( + destination.lane_index, + destination.distance_along_lane_m, + 0.0, + 0.0, + ), + ) + plans: list[RoutePlan] = [] + for anchor in anchors: + direct_distance = math.inf + if ( + anchor.lane_index == start.lane_index + and anchor.distance_along_lane_m >= start.distance_along_lane_m + ): + direct_distance = ( + anchor.distance_along_lane_m - start.distance_along_lane_m + ) + graph_distance = ( + float(distances_to_start[anchor.lane_index]) + + anchor.distance_along_lane_m + ) + if math.isfinite(direct_distance) and direct_distance <= graph_distance: + plans.append(RoutePlan((start.lane_index,), direct_distance)) + elif math.isfinite(graph_distance): + lane_path = self._reconstruct_path( + start.lane_index, anchor.lane_index, predecessors + ) + if lane_path: + plans.append(RoutePlan(lane_path, graph_distance)) + return min(plans, key=lambda plan: plan.distance_m, default=None) + + def route_distances( + self, + start: LanePosition, + destinations: tuple[NavigationWaypoint, ...], + ) -> tuple[float, ...]: + """Return shortest directed distances to candidate waypoints.""" + distances_to_start, _predecessors = self._shortest_tree(start) + result: list[float] = [] + for destination in destinations: + anchors = destination.arrival_anchors or ( + LanePosition( + destination.lane_index, + destination.distance_along_lane_m, + 0.0, + 0.0, + ), + ) + distances: list[float] = [] + for anchor in anchors: + direct_distance = math.inf + if ( + anchor.lane_index == start.lane_index + and anchor.distance_along_lane_m >= start.distance_along_lane_m + ): + direct_distance = ( + anchor.distance_along_lane_m - start.distance_along_lane_m + ) + graph_distance = ( + float(distances_to_start[anchor.lane_index]) + + anchor.distance_along_lane_m + ) + distances.append(min(direct_distance, graph_distance)) + result.append(min(distances, default=math.inf)) + return tuple(result) + + def _build_adjacency( + self, endpoint_snap_tolerance_m: float + ) -> tuple[tuple[tuple[int, float], ...], ...]: + if all( + lane.lane_id is not None and lane.successor_ids is not None + for lane in self._lanes + ): + indices = { + lane.lane_id: index + for index, lane in enumerate(self._lanes) + if lane.lane_id is not None + } + return tuple( + tuple( + (indices[successor], 0.0) + for successor in lane.successor_ids or () + if successor in indices + ) + for lane in self._lanes + ) + cell_size = endpoint_snap_tolerance_m + start_buckets: dict[tuple[int, int], list[int]] = {} + for lane_index, lane in enumerate(self._lanes): + start = lane.centerline_world[0, :2] + cell = ( + math.floor(float(start[0]) / cell_size), + math.floor(float(start[1]) / cell_size), + ) + start_buckets.setdefault(cell, []).append(lane_index) + + adjacency: list[tuple[tuple[int, float], ...]] = [] + for lane_index, lane in enumerate(self._lanes): + end = lane.centerline_world[-1, :2] + end_cell = ( + math.floor(float(end[0]) / cell_size), + math.floor(float(end[1]) / cell_size), + ) + connected: list[tuple[int, float]] = [] + for offset_x in (-1, 0, 1): + for offset_y in (-1, 0, 1): + for successor in start_buckets.get( + (end_cell[0] + offset_x, end_cell[1] + offset_y), () + ): + if successor == lane_index: + continue + gap = float( + np.linalg.norm( + end - self._lanes[successor].centerline_world[0, :2] + ) + ) + if gap <= endpoint_snap_tolerance_m: + connected.append((successor, gap)) + adjacency.append(tuple(sorted(set(connected)))) + return tuple(adjacency) + + def _build_segment_index(self) -> None: + starts: list[npt.NDArray[np.float32]] = [] + vectors: list[npt.NDArray[np.float32]] = [] + lane_indices: list[int] = [] + start_distances: list[float] = [] + for lane_index, lane in enumerate(self._lanes): + points = lane.centerline_world + starts.extend(points[:-1, :2]) + vectors.extend(np.diff(points[:, :2], axis=0)) + lane_indices.extend([lane_index] * (len(points) - 1)) + start_distances.extend(self._cumulative_distances[lane_index][:-1]) + self._segment_starts_xy = np.asarray(starts, dtype=np.float32) + self._segment_vectors_xy = np.asarray(vectors, dtype=np.float32) + self._segment_lengths_sq = np.sum( + self._segment_vectors_xy * self._segment_vectors_xy, axis=1 + ) + self._segment_lane_indices = np.asarray(lane_indices, dtype=np.int32) + self._segment_start_distances_m = np.asarray(start_distances, dtype=np.float32) + self._segment_headings_rad = np.arctan2( + self._segment_vectors_xy[:, 1], self._segment_vectors_xy[:, 0] + ) + + def _shortest_tree( + self, start: LanePosition + ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.int32]]: + lane_count = len(self._lanes) + distances = np.full(lane_count, math.inf, dtype=np.float64) + predecessors = np.full(lane_count, -1, dtype=np.int32) + queue: list[tuple[float, int]] = [] + source_lane = start.lane_index + remaining_source_distance = max( + 0.0, self._lane_lengths[source_lane] - start.distance_along_lane_m + ) + for successor, gap in self._adjacency[source_lane]: + distance = remaining_source_distance + gap + if distance < distances[successor]: + distances[successor] = distance + predecessors[successor] = source_lane + heapq.heappush(queue, (distance, successor)) + + while queue: + distance, lane_index = heapq.heappop(queue) + if distance > distances[lane_index] + 1.0e-9: + continue + exit_distance = distance + self._lane_lengths[lane_index] + for successor, gap in self._adjacency[lane_index]: + candidate = exit_distance + gap + if candidate + 1.0e-9 >= distances[successor]: + continue + distances[successor] = candidate + predecessors[successor] = lane_index + heapq.heappush(queue, (candidate, successor)) + return distances, predecessors + + def _reconstruct_path( + self, + source_lane: int, + destination_lane: int, + predecessors: npt.NDArray[np.int32], + ) -> tuple[int, ...]: + if predecessors[destination_lane] < 0: + return () + reversed_path = [destination_lane] + current = destination_lane + for _ in range(len(self._lanes) + 1): + predecessor = int(predecessors[current]) + if predecessor < 0: + return () + reversed_path.append(predecessor) + if predecessor == source_lane: + return tuple(reversed(reversed_path)) + current = predecessor + return () + + +def _point_at_distance( + points: npt.NDArray[np.float32], + cumulative: npt.NDArray[np.float32], + distance_m: float, +) -> npt.NDArray[np.float32]: + distance_m = float(np.clip(distance_m, 0.0, float(cumulative[-1]))) + right = int(np.searchsorted(cumulative, distance_m, side="right")) + right = min(max(1, right), len(points) - 1) + left = right - 1 + span = float(cumulative[right] - cumulative[left]) + alpha = 0.0 if span <= 1.0e-6 else (distance_m - cumulative[left]) / span + return ((1.0 - alpha) * points[left] + alpha * points[right]).astype(np.float32) + + +def _cumulative_distances( + points: npt.NDArray[np.float32], +) -> npt.NDArray[np.float32]: + segment_lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + return np.concatenate(([0.0], np.cumsum(segment_lengths))).astype(np.float32) + + +def _infer_right_road_edge( + centerline_world: npt.NDArray[np.float32], +) -> npt.NDArray[np.float32] | None: + centerline = _normalize_polyline(centerline_world) + if centerline is None: + return None + tangent_xy = np.empty((len(centerline), 2), dtype=np.float32) + tangent_xy[0] = centerline[1, :2] - centerline[0, :2] + tangent_xy[-1] = centerline[-1, :2] - centerline[-2, :2] + if len(centerline) > 2: + tangent_xy[1:-1] = centerline[2:, :2] - centerline[:-2, :2] + tangent_lengths = np.linalg.norm(tangent_xy, axis=1) + if np.any(tangent_lengths <= _MIN_SEGMENT_LENGTH_M): + return None + right_normal_xy = ( + np.stack((tangent_xy[:, 1], -tangent_xy[:, 0]), axis=1) + / tangent_lengths[:, None] + ) + road_edge = centerline.copy() + road_edge[:, :2] += _INFERRED_LANE_HALF_WIDTH_M * right_normal_xy + return road_edge.astype(np.float32) + + +def _normalize_polyline( + points_world: npt.NDArray[np.float32], +) -> npt.NDArray[np.float32] | None: + points = np.asarray(points_world, dtype=np.float32) + if points.ndim != 2 or points.shape[1] != 3 or len(points) < 2: + return None + if not np.isfinite(points).all(): + return None + segment_lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + keep = np.concatenate(([True], segment_lengths > _MIN_SEGMENT_LENGTH_M)) + points = points[keep] + if len(points) < 2: + return None + return points + + +def _normalize_angles(angles_rad: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + return (angles_rad + math.pi) % (2.0 * math.pi) - math.pi diff --git a/apps/crazy_robotaxi/crazy_robotaxi/passengers.py b/apps/crazy_robotaxi/crazy_robotaxi/passengers.py new file mode 100644 index 000000000..c77485c8f --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/passengers.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pedestrian conditioning tracks for Crazy Robotaxi pickup targets.""" + +from __future__ import annotations + +import hashlib +import struct +from collections.abc import Sequence + +import numpy as np +import numpy.typing as npt +from omnidreams_game_engine.types import DynamicActorTrajectory + +from crazy_robotaxi.rules import TaxiGameSnapshot + +_PASSENGER_DIMENSIONS_LWH_M = np.array([0.6, 0.6, 1.8], dtype=np.float32) +"""Full dimensions of one pedestrian conditioning box in metres.""" + +_PASSENGER_CENTER_HEIGHT_M = 0.9 +"""Height of a grounded passenger box center above its pickup target.""" + + +def _target_key(target_xyz_m: tuple[float, float, float]) -> bytes: + return struct.pack("<3f", *target_xyz_m) + + +def _passenger_track( + target_xyz_m: tuple[float, float, float], + timestamps_us: npt.NDArray[np.int64], +) -> DynamicActorTrajectory: + target = np.asarray(target_xyz_m, dtype=np.float32) + center = target + np.array([0.0, 0.0, _PASSENGER_CENTER_HEIGHT_M], dtype=np.float32) + track_length = len(timestamps_us) + coordinate_digest = hashlib.sha256(_target_key(target_xyz_m)).hexdigest()[:16] + return DynamicActorTrajectory( + entity_id=f"taxi-passenger-{coordinate_digest}", + object_type="Pedestrian", + timestamps_us=timestamps_us.copy(), + translations_world=np.repeat(center[None, :], track_length, axis=0), + orientations_xyzw=np.repeat( + np.array([[0.0, 0.0, 0.0, 1.0]], dtype=np.float32), + track_length, + axis=0, + ), + dimensions_lwh=_PASSENGER_DIMENSIONS_LWH_M.copy(), + is_simulated=True, + ) + + +def build_pickup_passenger_trajectories( + snapshots: Sequence[TaxiGameSnapshot], + timestamps_us: npt.NDArray[np.int64], +) -> tuple[DynamicActorTrajectory, ...]: + """Build stationary pedestrian tracks for visible pickup targets. + + Full-chunk visibility uses one stationary track. Partial visibility uses + one-sample tracks because Ludus extrapolates multi-sample object tracks + beyond their endpoints. + + Args: + snapshots: Taxi state synchronized to each generated frame. + timestamps_us: Timestamps for the same frames. + + Returns: + Contiguous passenger visibility tracks in first-visible order. + + Raises: + ValueError: ``snapshots`` and ``timestamps_us`` have different lengths. + """ + if len(snapshots) != len(timestamps_us): + raise ValueError( + "snapshots must match timestamps_us; got " + f"{len(snapshots)} snapshots for {len(timestamps_us)} timestamps" + ) + + open_tracks: dict[bytes, tuple[int, tuple[float, float, float]]] = {} + completed_tracks: list[tuple[int, int, tuple[float, float, float]]] = [] + for frame_index, snapshot in enumerate(snapshots): + visible_targets = ( + (snapshot.pickup_passengers_xyz_m or snapshot.pickup_targets_xyz_m) + if snapshot.session_state == "playing" + else () + ) + visible_by_key = { + _target_key(target_xyz_m): target_xyz_m for target_xyz_m in visible_targets + } + + for key in open_tracks.keys() - visible_by_key.keys(): + start_index, target_xyz_m = open_tracks.pop(key) + completed_tracks.append((start_index, frame_index, target_xyz_m)) + for key, target_xyz_m in visible_by_key.items(): + open_tracks.setdefault(key, (frame_index, target_xyz_m)) + + for start_index, target_xyz_m in open_tracks.values(): + completed_tracks.append((start_index, len(snapshots), target_xyz_m)) + + completed_tracks.sort(key=lambda track: (track[0], _target_key(track[2]))) + passenger_tracks: list[DynamicActorTrajectory] = [] + for start_index, end_index, target_xyz_m in completed_tracks: + if start_index == 0 and end_index == len(snapshots): + passenger_tracks.append(_passenger_track(target_xyz_m, timestamps_us)) + continue + passenger_tracks.extend( + _passenger_track(target_xyz_m, timestamps_us[index : index + 1]) + for index in range(start_index, end_index) + ) + return tuple(passenger_tracks) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/physics.py b/apps/crazy_robotaxi/crazy_robotaxi/physics.py new file mode 100644 index 000000000..83f14469f --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/physics.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Taxi-game policy adapter around the reusable model-thread PhysX world.""" + +from __future__ import annotations + +import math +from dataclasses import replace + +import numpy as np +from loguru import logger +from ludus_renderer import RigidBodyModel +from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld +from omnidreams_game_engine.types import ( + DriverCommand, + PhysicsDebugFrame, + SceneDefinition, + VehicleState, +) + +from crazy_robotaxi.dynamics import TaxiVehicleConfig + +_CHASSIS_INSET_M = 0.16 + + +def inset_vehicle_chassis(model: RigidBodyModel) -> RigidBodyModel: + """Inset Taxi vehicle boxes to approximate beveled corners app-side.""" + if model.vehicle is None: + return model + x_m, y_m, z_m = model.vehicle.chassis_half_extents_m + vehicle = replace( + model.vehicle, + chassis_half_extents_m=( + max(0.25, x_m - _CHASSIS_INSET_M), + max(0.25, y_m - _CHASSIS_INSET_M), + z_m, + ), + ) + return replace(model, vehicle=vehicle) + + +class TaxiPhysicsWorld(GamePhysicsWorld): + """Apply Taxi policy around an otherwise unmodified generic PhysX world.""" + + def __init__( + self, + scene: SceneDefinition, + vehicle: TaxiVehicleConfig, + *, + curb_segments_world: np.ndarray | None = None, + ) -> None: + curb_segments = np.asarray( + curb_segments_world + if curb_segments_world is not None + else np.empty((0, 2, 3), dtype=np.float32), + dtype=np.float32, + ) + if curb_segments.ndim != 3 or curb_segments.shape[1:] != (2, 3): + raise ValueError("Taxi curb segments must have shape (N, 2, 3).") + super().__init__( + scene, + vehicle, + model_adapter=inset_vehicle_chassis, + static_barrier_segments_world=( + curb_segments + if getattr(scene, "game_map", None) is not None or len(curb_segments) + else None + ), + static_barrier_restitution=vehicle.curb_collision_restitution, + ) + self._taxi_vehicle = vehicle + logger.info( + "[crazy-robotaxi] Taxi physics active: app-authoritative heading, " + "arcade handbrake, inset chassis, curb_segments={}", + len(curb_segments), + ) + self._last_contact_resolved_state: VehicleState | None = None + + def step_with_command( + self, + state: VehicleState, + command: DriverCommand, + timestamp_us: int, + dt_s: float, + ) -> tuple[VehicleState, tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]]: + """Resolve contacts while keeping Taxi drive intent authoritative.""" + resolved, samples = super().step(state, timestamp_us, dt_s) + self._last_contact_resolved_state = resolved + if command.handbrake and not resolved.ragdoll_active: + velocity_x_mps = state.velocity_x_mps + velocity_y_mps = state.velocity_y_mps + else: + velocity_x_mps = resolved.velocity_x_mps + velocity_y_mps = resolved.velocity_y_mps + forward = np.asarray( + [math.cos(state.yaw_rad), math.sin(state.yaw_rad)], dtype=np.float32 + ) + velocity = np.asarray( + [ + velocity_x_mps if velocity_x_mps is not None else 0.0, + velocity_y_mps if velocity_y_mps is not None else 0.0, + ], + dtype=np.float32, + ) + forward_speed_mps = float(np.dot(velocity, forward)) + if ( + getattr(self, "last_step_static_barrier_collision", False) + and not command.handbrake + and command.brake <= 0.01 + and not command.stop + and state.speed_mps * forward_speed_mps > 0.0 + ): + retained_speed_mps = ( + abs(state.speed_mps) + * self._taxi_vehicle.curb_forward_momentum_retention + ) + if abs(forward_speed_mps) < retained_speed_mps: + forward_speed_mps = math.copysign(retained_speed_mps, state.speed_mps) + resolved = replace( + resolved, + yaw_rad=state.yaw_rad, + yaw_rate_radps=state.yaw_rate_radps, + speed_mps=forward_speed_mps, + velocity_x_mps=float(velocity[0]), + velocity_y_mps=float(velocity[1]), + ) + self.synchronize_ego_state(resolved) + return resolved, samples + + def debug_frame(self, state: VehicleState) -> PhysicsDebugFrame: + """Capture topology with the pre-policy PhysX contact pose for the ego.""" + debug = super().debug_frame(state) + contact_state = getattr(self, "_last_contact_resolved_state", None) + if contact_state is None: + return debug + half_yaw = contact_state.yaw_rad * 0.5 + return replace( + debug, + ego_position_m=np.asarray( + [ + contact_state.x_m, + contact_state.y_m, + contact_state.z_m + self._ego_model.half_extents_m[2], + ], + dtype=np.float32, + ), + ego_orientation_xyzw=np.asarray( + [0.0, 0.0, math.sin(half_yaw), math.cos(half_yaw)], + dtype=np.float32, + ), + ) + + def step( + self, + state: VehicleState, + timestamp_us: int, + dt_s: float, + ) -> tuple[VehicleState, tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]]: + """Resolve a commandless compatibility step with Taxi heading policy.""" + return self.step_with_command( + state, + DriverCommand(), + timestamp_us, + dt_s, + ) + + +def step_taxi_physics_world( + physics_world: GamePhysicsWorld, + state: VehicleState, + command: DriverCommand, + timestamp_us: int, + dt_s: float, +) -> tuple[VehicleState, tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]]: + """Advance one Taxi-only command-aware physics step.""" + if not isinstance(physics_world, TaxiPhysicsWorld): + raise TypeError("Taxi physics step requires TaxiPhysicsWorld") + return physics_world.step_with_command(state, command, timestamp_us, dt_s) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/rules.py b/apps/crazy_robotaxi/crazy_robotaxi/rules.py new file mode 100644 index 000000000..ade076ab3 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/rules.py @@ -0,0 +1,1150 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Taxi-game state, waypoint generation, and HUD projection helpers.""" + +from __future__ import annotations + +import hashlib +import math +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +import numpy as np +import numpy.typing as npt +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.contracts import GameUpdate +from omnidreams_game_engine.game_map.vicinity import ( + GameMapVicinity, + GameMapVicinityResolver, +) +from omnidreams_game_engine.math3d import ( + extract_yaw_from_transform, + invert_transform, + level_rig_pose_from_vehicle_state, + rig_pose_from_state, + rig_pose_from_vehicle_state, +) +from omnidreams_game_engine.types import ( + CameraCalibration, + TrajectoryChunk, + VehicleState, +) + +from crazy_robotaxi.dynamics import ( + TaxiVehicleConfig, +) +from crazy_robotaxi.high_scores import ( + HighScoreEntry, + HighScoreStore, + default_high_scores_path, +) +from crazy_robotaxi.navigation import ( + LanePosition, + NavigationFareRegion, + NavigationLane, + NavigationWaypoint, + RoutePlan, + TaxiNavigationMap, +) + +if TYPE_CHECKING: + from omnidreams_game_engine.config import BevConfig + +TaxiPhase = Literal["seeking_pickup", "to_dropoff"] +TaxiEvent = Literal["pickup_complete", "fare_complete", "time_expired"] +TaxiSessionState = Literal["playing", "awaiting_name", "leaderboard"] + + +@dataclass(frozen=True) +class TaxiGameConfig: + """Configuration for Crazy Robotaxi rules and presentation.""" + + vehicle: TaxiVehicleConfig = TaxiVehicleConfig() + """Taxi-only control and vehicle-dynamics configuration.""" + + seed: int | None = None + """Debug seed mixed with the scene ID; ``None`` uses fresh entropy.""" + + waypoint_spacing_m: float = 10.0 + """Arc-length spacing between candidates sampled from each navigation route.""" + + pickup_grid_spacing_m: float = 60.0 + """Grid spacing used to distribute simultaneous pickup points across the map.""" + + pickup_min_distance_m: float = 20.0 + """Minimum straight-line distance from the ego to a newly selected pickup.""" + + initial_pickup_max_distance_m: float = 200.0 + """Maximum preferred distance to the camera-visible initial pickup.""" + + pickup_radius_m: float = 5.0 + """Distance at which the ego collects a pickup.""" + + dropoff_radius_m: float = 6.0 + """Distance at which the ego completes a dropoff.""" + + fare_min_route_distance_m: float = 200.0 + """Preferred minimum routed distance between fare endpoints.""" + + fare_max_route_distance_m: float = 250.0 + """Preferred maximum straight-line distance between fare endpoints.""" + + target_speed_mps: float = 10.0 + """Nominal travel speed used to derive the fare deadline.""" + + grace_s: float = 8.0 + """Fixed time added to the distance-derived fare deadline.""" + + min_time_s: float = 12.0 + """Minimum fare deadline.""" + + max_time_s: float = 45.0 + """Maximum fare deadline.""" + + trip_time_multiplier: float = 2.0 + """Multiplier applied after deriving and clamping the fare deadline.""" + + base_fare_points: int = 500 + """Points awarded for every successful fare.""" + + bonus_points_per_second: int = 100 + """Additional points awarded per whole second remaining.""" + + event_banner_s: float = 2.0 + """Simulation-time duration of completion and failure banners.""" + + global_time_s: float = 60.0 + """Simulation-time duration of a new game.""" + + dropoff_time_bonus_s: float = 30.0 + """Global time added after each successful dropoff.""" + + high_scores_path: Path = field(default_factory=default_high_scores_path) + """CSV path used to persist the global top-ten leaderboard.""" + + ground_snap_max_absolute_rotation_deg: float = 10.0 + """Maximum ground rotation accepted by the taxi ground snapper.""" + + ground_snap_settle_fraction: float = 0.25 + """Fraction of stale ground attitude removed after an invalid sample.""" + + def __post_init__(self) -> None: + """Validate Taxi-only values at configuration time.""" + if self.pickup_grid_spacing_m <= 0.0: + raise ValueError("pickup_grid_spacing_m must be positive") + + +@dataclass(frozen=True) +class TaxiGameSnapshot: + """Immutable taxi-game state published to HUD consumers.""" + + phase: TaxiPhase + """Current pickup or dropoff phase.""" + + target_xyz_m: tuple[float, float, float] + """Active target position in scene world coordinates.""" + + distance_m: float + """Straight-line XY distance from the ego to the active target.""" + + relative_bearing_rad: float + """Target bearing relative to ego heading; positive angles point left.""" + + target_radius_m: float + """World-space radius that activates the current target.""" + + remaining_time_s: float | None + """Dropoff time remaining, or ``None`` while seeking a pickup.""" + + score: int + """Total points earned during the current rollout.""" + + high_score: int | None = None + """Best persisted score, or ``None`` when the leaderboard is empty.""" + + global_remaining_time_s: float = 0.0 + """Simulation time remaining before the game ends.""" + + session_state: TaxiSessionState = "playing" + """Current play, name-entry, or leaderboard state.""" + + leaderboard: tuple[HighScoreEntry, ...] = () + """Current top-ten entries after the game ends.""" + + high_score_rank: int | None = None + """Prospective or recorded rank for the finished score.""" + + event: TaxiEvent | None = None + """Most recent fare result while its banner remains visible.""" + + awarded_points: int = 0 + """Points awarded by the visible completion event.""" + + awarded_global_time_s: float = 0.0 + """Global time awarded by the visible completion event.""" + + pickup_targets_xyz_m: tuple[tuple[float, float, float], ...] = () + """All pickup positions available during the pickup phase.""" + + pickup_passengers_xyz_m: tuple[tuple[float, float, float], ...] = () + """Waiting-passenger ground positions aligned with the pickup targets.""" + + def as_dict(self) -> dict[str, object]: + """Return a JSON-serializable representation of the snapshot.""" + return { + "phase": self.phase, + "target_xyz_m": list(self.target_xyz_m), + "distance_m": self.distance_m, + "relative_bearing_rad": self.relative_bearing_rad, + "target_radius_m": self.target_radius_m, + "remaining_time_s": self.remaining_time_s, + "score": self.score, + "high_score": self.high_score, + "global_remaining_time_s": self.global_remaining_time_s, + "session_state": self.session_state, + "leaderboard": [entry.as_dict() for entry in self.leaderboard], + "high_score_rank": self.high_score_rank, + "event": self.event, + "awarded_points": self.awarded_points, + "awarded_global_time_s": self.awarded_global_time_s, + "pickup_targets_xyz_m": [ + list(target) for target in self.pickup_targets_xyz_m + ], + "pickup_passengers_xyz_m": [ + list(target) for target in self.pickup_passengers_xyz_m + ], + } + + +@dataclass(frozen=True) +class TaxiCameraMarkerProjection: + """Projected world-marker geometry in camera image pixels.""" + + anchor_uv: tuple[float, float] + """Exact image location of the active waypoint.""" + + beacon_top_uv: tuple[float, float] | None + """Projected top of the vertical beacon, when visible.""" + + ring_edges_uv: tuple[tuple[tuple[float, float], tuple[float, float]], ...] + """Visible line segments forming the target's activation-radius ring.""" + + distance_m: float + """Horizontal distance from the displayed camera pose to the target.""" + + +def _stable_seed(scene_id: str, seed: int) -> int: + digest = hashlib.sha256(f"{scene_id}:{seed}".encode("utf-8")).digest() + return int.from_bytes(digest[:8], byteorder="big", signed=False) + + +def normalize_angle_rad(angle_rad: float) -> float: + """Wrap an angle to the interval ``[-pi, pi)``.""" + return (float(angle_rad) + math.pi) % (2.0 * math.pi) - math.pi + + +def relative_target_bearing_rad( + ego_x_m: float, + ego_y_m: float, + ego_yaw_rad: float, + target_x_m: float, + target_y_m: float, +) -> float: + """Return the target bearing relative to ego heading.""" + world_bearing = math.atan2(target_y_m - ego_y_m, target_x_m - ego_x_m) + return normalize_angle_rad(world_bearing - ego_yaw_rad) + + +def project_target_to_bev( + target_xyz_m: tuple[float, float, float], + vehicle_state: VehicleState, + bev: BevConfig, +) -> tuple[float, float, bool]: + """Project a world target into normalized BEV image coordinates. + + Returns: + Horizontal coordinate, vertical coordinate, and whether the point is + inside the BEV camera frustum. + """ + rig_to_world = level_rig_pose_from_vehicle_state(vehicle_state) + return project_target_pose_to_bev(target_xyz_m, rig_to_world, bev) + + +def project_target_pose_to_bev( + target_xyz_m: tuple[float, float, float], + rig_to_world: npt.NDArray[np.float32], + bev: BevConfig, +) -> tuple[float, float, bool]: + """Project a target using the exact rig pose that produced a BEV image.""" + world_to_sensor = _bev_world_to_sensor(rig_to_world, bev) + target_h = np.array([*target_xyz_m, 1.0], dtype=np.float32) + target_sensor_flu = (world_to_sensor @ target_h)[:3] + projected = _project_bev_sensor_point(target_sensor_flu, bev) + if projected is None: + return 0.5, 0.5, False + u, v = projected + return u, v, 0.0 <= u <= 1.0 and 0.0 <= v <= 1.0 + + +def project_segment_pose_to_bev( + segment_world: npt.NDArray[np.float32], + rig_to_world: npt.NDArray[np.float32], + bev: BevConfig, +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Project and viewport-clip one world-space enclosure segment.""" + segment = np.asarray(segment_world, dtype=np.float32) + if segment.shape != (2, 3) or not np.isfinite(segment).all(): + raise ValueError("BEV segment must have finite shape (2, 3).") + world_to_sensor = _bev_world_to_sensor(rig_to_world, bev) + homogeneous = np.concatenate((segment, np.ones((2, 1), dtype=np.float32)), axis=1) + sensor_points = (world_to_sensor @ homogeneous.T).T[:, :3] + near_depth = 1.0e-5 + depths = sensor_points[:, 0] + if bool(np.all(depths <= near_depth)): + return None + if bool(np.any(depths <= near_depth)): + behind = int(np.argmin(depths)) + ahead = 1 - behind + span = float(depths[ahead] - depths[behind]) + if span <= 0.0: + return None + alpha = (near_depth - float(depths[behind])) / span + sensor_points[behind] = sensor_points[behind] + alpha * ( + sensor_points[ahead] - sensor_points[behind] + ) + projected = tuple(_project_bev_sensor_point(point, bev) for point in sensor_points) + if projected[0] is None or projected[1] is None: + return None + return _clip_normalized_segment(projected[0], projected[1]) + + +def _bev_world_to_sensor( + rig_to_world: npt.NDArray[np.float32], bev: BevConfig +) -> npt.NDArray[np.float32]: + leveled_rig_to_world = rig_pose_from_state( + float(rig_to_world[0, 3]), + float(rig_to_world[1, 3]), + float(rig_to_world[2, 3]), + extract_yaw_from_transform(rig_to_world), + ) + theta = math.radians(float(bev.tilt_deg)) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + sensor_to_rig = np.array( + [ + [sin_t, 0.0, cos_t, 0.0], + [0.0, 1.0, 0.0, 0.0], + [-cos_t, 0.0, sin_t, float(bev.height_m)], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ) + return invert_transform(leveled_rig_to_world @ sensor_to_rig) + + +def _project_bev_sensor_point( + point_sensor_flu: npt.NDArray[np.float32], bev: BevConfig +) -> tuple[float, float] | None: + depth = float(point_sensor_flu[0]) + if depth <= 1e-5: + return None + + focal = (float(bev.height) / 2.0) / math.tan(math.radians(float(bev.fov_deg)) / 2.0) + u_px = float(bev.width) / 2.0 - focal * float(point_sensor_flu[1]) / depth + v_px = float(bev.height) / 2.0 - focal * float(point_sensor_flu[2]) / depth + return u_px / float(bev.width), v_px / float(bev.height) + + +def _clip_normalized_segment( + start: tuple[float, float], end: tuple[float, float] +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Clip a 2D segment to the unit square with Liang-Barsky.""" + x0, y0 = start + dx, dy = end[0] - x0, end[1] - y0 + lower, upper = 0.0, 1.0 + for p, q in ((-dx, x0), (dx, 1.0 - x0), (-dy, y0), (dy, 1.0 - y0)): + if abs(p) <= 1.0e-12: + if q < 0.0: + return None + continue + ratio = q / p + if p < 0.0: + lower = max(lower, ratio) + else: + upper = min(upper, ratio) + if lower > upper: + return None + return ( + (x0 + lower * dx, y0 + lower * dy), + (x0 + upper * dx, y0 + upper * dy), + ) + + +def project_taxi_marker_to_camera( + snapshot: TaxiGameSnapshot, + rig_to_world: npt.NDArray[np.float32], + camera_model: FThetaCameraModel, + *, + image_width: int, + image_height: int, + ring_samples: int = 32, + beacon_height_m: float = 3.5, +) -> TaxiCameraMarkerProjection | None: + """Project the active taxi target into a camera image. + + Return ``None`` when the target anchor is behind the camera or outside the + image. This deliberately does not clamp off-screen targets to an edge; the + always-visible direction arrow already covers that case. + """ + if image_width <= 0 or image_height <= 0: + raise ValueError("Taxi camera image dimensions must be positive.") + if ring_samples < 3: + raise ValueError("Taxi target ring requires at least three samples.") + + target = np.asarray(snapshot.target_xyz_m, dtype=np.float32) + angles = np.linspace( + 0.0, 2.0 * math.pi, ring_samples, endpoint=False, dtype=np.float32 + ) + ring = np.repeat(target[None, :], ring_samples, axis=0) + ring[:, 0] += np.float32(snapshot.target_radius_m) * np.cos(angles) + ring[:, 1] += np.float32(snapshot.target_radius_m) * np.sin(angles) + points = np.concatenate( + ( + target[None, :], + (target + np.array([0.0, 0.0, beacon_height_m], dtype=np.float32))[None, :], + ring, + ), + axis=0, + ) + uv, _depth, forward = camera_model.project_world(points, rig_to_world) + inside = ( + forward + & (uv[:, 0] >= 0.0) + & (uv[:, 0] < float(image_width)) + & (uv[:, 1] >= 0.0) + & (uv[:, 1] < float(image_height)) + ) + if not bool(inside[0]): + return None + + ring_edges: list[tuple[tuple[float, float], tuple[float, float]]] = [] + for index in range(ring_samples): + left = 2 + index + right = 2 + ((index + 1) % ring_samples) + if bool(inside[left] and inside[right]): + ring_edges.append( + ( + (float(uv[left, 0]), float(uv[left, 1])), + (float(uv[right, 0]), float(uv[right, 1])), + ) + ) + + return TaxiCameraMarkerProjection( + anchor_uv=(float(uv[0, 0]), float(uv[0, 1])), + beacon_top_uv=((float(uv[1, 0]), float(uv[1, 1])) if bool(inside[1]) else None), + ring_edges_uv=tuple(ring_edges), + distance_m=math.hypot( + float(target[0]) - float(rig_to_world[0, 3]), + float(target[1]) - float(rig_to_world[1, 3]), + ), + ) + + +def project_taxi_markers_to_camera( + snapshot: TaxiGameSnapshot, + rig_to_world: npt.NDArray[np.float32], + camera_model: FThetaCameraModel, + *, + image_width: int, + image_height: int, +) -> tuple[TaxiCameraMarkerProjection, ...]: + """Project the nearest three visible pickups or the active dropoff.""" + targets = ( + snapshot.pickup_targets_xyz_m + if snapshot.phase == "seeking_pickup" and snapshot.pickup_targets_xyz_m + else (snapshot.target_xyz_m,) + ) + projections = [ + project_taxi_marker_to_camera( + replace(snapshot, target_xyz_m=target), + rig_to_world, + camera_model, + image_width=image_width, + image_height=image_height, + ) + for target in targets + ] + visible = [projection for projection in projections if projection is not None] + if snapshot.phase == "seeking_pickup": + visible.sort(key=lambda projection: projection.distance_m) + del visible[3:] + return tuple(visible) + + +def _xyz_tuple(point: npt.NDArray[np.float32]) -> tuple[float, float, float]: + return float(point[0]), float(point[1]), float(point[2]) + + +def _passenger_xyz_tuple( + waypoint: NavigationWaypoint, +) -> tuple[float, float, float]: + point = ( + waypoint.passenger_xyz_m + if waypoint.passenger_xyz_m is not None + else waypoint.xyz_m + ) + return _xyz_tuple(point) + + +class TaxiGameController: + """Advance taxi fares over scene navigation routes.""" + + def __init__( + self, + *, + scene_id: str, + reference_route_world: npt.NDArray[np.float32], + navigation_routes_world: tuple[npt.NDArray[np.float32], ...] = (), + navigation_lanes: tuple[NavigationLane, ...] = (), + fare_regions: tuple[NavigationFareRegion, ...] = (), + initial_state: VehicleState, + config: TaxiGameConfig, + initial_camera: CameraCalibration | None = None, + high_score_store: HighScoreStore | None = None, + vicinity_resolver: GameMapVicinityResolver | None = None, + ) -> None: + self._config = config + rng_seed = None if config.seed is None else _stable_seed(scene_id, config.seed) + self._rng = np.random.default_rng(rng_seed) + self._vicinity_resolver = vicinity_resolver + self._vicinity: GameMapVicinity | None = None + offset = float(self._rng.uniform(0.0, config.waypoint_spacing_m)) + if navigation_lanes: + self._navigation = TaxiNavigationMap(navigation_lanes) + else: + routes_world = navigation_routes_world or (reference_route_world,) + self._navigation = TaxiNavigationMap.from_polylines( + routes_world, + bidirectional=True, + ) + self._waypoints = self._navigation.sample_waypoints( + config.waypoint_spacing_m, offset + ) + self._navigation.sample_fare_regions( + fare_regions, config.waypoint_spacing_m, self._rng + ) + self._eligible_waypoint_indices = tuple(range(len(self._waypoints))) + self._pickup_point_indices = self._sample_pickup_point_indices() + self._phase: TaxiPhase = "seeking_pickup" + self._session_state: TaxiSessionState = "playing" + self._score = 0 + self._global_remaining_time_s = config.global_time_s + self._remaining_time_s: float | None = None + self._event: TaxiEvent | None = None + self._event_remaining_s = 0.0 + self._awarded_points = 0 + self._awarded_global_time_s = 0.0 + self._pickup_index: int | None = None + self._dropoff_index: int | None = None + self._high_score_store = high_score_store or HighScoreStore( + config.high_scores_path + ) + existing_scores = self._high_score_store.read() + self._high_score = existing_scores[0].score if existing_scores else None + self._leaderboard: tuple[HighScoreEntry, ...] = () + self._high_score_rank: int | None = None + self._target_index, _initial_route = self._select_initial_pickup( + initial_state, + initial_camera, + ) + self._available_pickup_indices = self._pickup_indices( + initial_state, + excluded=frozenset(), + ) + if self._target_index not in self._available_pickup_indices: + self._available_pickup_indices += (self._target_index,) + + @property + def config(self) -> TaxiGameConfig: + """Return the immutable game configuration.""" + return self._config + + @property + def is_playing(self) -> bool: + """Return whether driving and simulation should continue.""" + return self._session_state == "playing" + + def submit_high_score_name(self, name: str) -> None: + """Persist the finished score and transition to the leaderboard. + + Args: + name: Valid player name supplied by the active presenter. + + Raises: + RuntimeError: The game is not waiting for a player name. + ValueError: ``name`` does not satisfy leaderboard validation. + """ + if self._session_state != "awaiting_name": + raise RuntimeError("Taxi game is not waiting for a high-score name.") + inserted, self._leaderboard = self._high_score_store.record(name, self._score) + self._high_score = ( + self._leaderboard[0].score if self._leaderboard else self._high_score + ) + self._high_score_rank = ( + next( + index + for index, entry in enumerate(self._leaderboard, start=1) + if entry is inserted + ) + if inserted is not None + else None + ) + self._session_state = "leaderboard" + + def advance(self, trajectory: TrajectoryChunk, frame_interval_s: float) -> None: + """Advance game state over every simulated pose in a chunk. + + Args: + trajectory: Authoritative simulated poses for the requested chunk. + frame_interval_s: Simulation duration represented by each pose. + """ + self.advance_frames(trajectory, frame_interval_s) + + def advance_frames( + self, trajectory: TrajectoryChunk, frame_interval_s: float + ) -> tuple[TaxiGameSnapshot, ...]: + """Advance the game and return state synchronized to every pose.""" + if frame_interval_s < 0.0: + raise ValueError("Taxi frame interval must be non-negative.") + snapshots: list[TaxiGameSnapshot] = [] + for vehicle_state in trajectory.vehicle_states: + x_m = vehicle_state.x_m + y_m = vehicle_state.y_m + yaw_rad = vehicle_state.yaw_rad + if self._session_state != "playing": + snapshots.append(self._snapshot_for_pose(x_m, y_m, yaw_rad)) + continue + self._advance_banner(frame_interval_s) + if self._phase == "seeking_pickup": + pickup_index = self._collected_pickup_index(x_m, y_m) + if pickup_index is not None: + self._start_fare(pickup_index, vehicle_state) + else: + target = self._waypoints[self._target_index] + distance = math.hypot( + float(target.xyz_m[0]) - x_m, + float(target.xyz_m[1]) - y_m, + ) + if distance <= self._config.dropoff_radius_m: + self._complete_fare(vehicle_state) + else: + assert self._remaining_time_s is not None + self._remaining_time_s = max( + 0.0, self._remaining_time_s - frame_interval_s + ) + if self._remaining_time_s <= 0.0: + self._expire_fare(vehicle_state) + + self._global_remaining_time_s = max( + 0.0, self._global_remaining_time_s - frame_interval_s + ) + if self._global_remaining_time_s <= 0.0: + self._end_game() + + snapshots.append(self._snapshot_for_pose(x_m, y_m, yaw_rad)) + return tuple(snapshots) + + def snapshot(self, vehicle_state: VehicleState) -> TaxiGameSnapshot: + """Return the HUD snapshot relative to the supplied ego state.""" + return self._snapshot_for_pose( + vehicle_state.x_m, vehicle_state.y_m, vehicle_state.yaw_rad + ) + + def _snapshot_for_pose( + self, x_m: float, y_m: float, yaw_rad: float + ) -> TaxiGameSnapshot: + if self._vicinity_resolver is not None: + self._vicinity = self._vicinity_resolver.resolve( + x_m, + y_m, + previous=self._vicinity, + ) + target_index = ( + min( + self._available_pickup_indices, + key=lambda index: ( + math.hypot( + float(self._waypoints[index].xyz_m[0]) - x_m, + float(self._waypoints[index].xyz_m[1]) - y_m, + ), + index, + ), + ) + if self._phase == "seeking_pickup" and self._available_pickup_indices + else self._target_index + ) + target = self._waypoints[target_index].xyz_m + distance = math.hypot( + float(target[0]) - x_m, + float(target[1]) - y_m, + ) + bearing = relative_target_bearing_rad( + x_m, + y_m, + yaw_rad, + float(target[0]), + float(target[1]), + ) + vicinity = self._vicinity + passenger_indices = tuple( + index + for index in self._available_pickup_indices + if self._waypoints[index].element_id is None + or ( + vicinity is not None + and self._waypoints[index].element_id + in vicinity.pedestrian_element_ids + ) + ) + return TaxiGameSnapshot( + phase=self._phase, + target_xyz_m=(float(target[0]), float(target[1]), float(target[2])), + distance_m=distance, + relative_bearing_rad=bearing, + target_radius_m=( + self._config.pickup_radius_m + if self._phase == "seeking_pickup" + else self._config.dropoff_radius_m + ), + remaining_time_s=self._remaining_time_s, + score=self._score, + high_score=self._high_score, + global_remaining_time_s=self._global_remaining_time_s, + session_state=self._session_state, + leaderboard=self._leaderboard, + high_score_rank=self._high_score_rank, + event=self._event if self._event_remaining_s > 0.0 else None, + awarded_points=( + self._awarded_points if self._event_remaining_s > 0.0 else 0 + ), + awarded_global_time_s=( + self._awarded_global_time_s if self._event_remaining_s > 0.0 else 0.0 + ), + pickup_targets_xyz_m=( + tuple( + _xyz_tuple(self._waypoints[index].xyz_m) + for index in self._available_pickup_indices + ) + if self._phase == "seeking_pickup" + else () + ), + pickup_passengers_xyz_m=( + tuple( + _passenger_xyz_tuple(self._waypoints[index]) + for index in passenger_indices + ) + if self._phase == "seeking_pickup" + else () + ), + ) + + def _pickup_indices( + self, + vehicle_state: VehicleState, + *, + excluded: frozenset[int], + ) -> tuple[int, ...]: + """Return every pickup that is available from the current position.""" + _distances, eligible = self._pickup_candidates( + vehicle_state.x_m, + vehicle_state.y_m, + excluded=excluded, + ) + if eligible: + return tuple(eligible) + return tuple( + index for index in self._pickup_point_indices if index not in excluded + ) + + def _sample_pickup_point_indices(self) -> tuple[int, ...]: + """Choose one stable pickup point per world-space grid cell.""" + cell_size = self._config.pickup_grid_spacing_m + candidates_by_cell: dict[tuple[int, int], list[int]] = {} + for index in self._eligible_waypoint_indices: + waypoint = self._waypoints[index] + point = waypoint.xyz_m + cell = ( + math.floor(float(point[0]) / cell_size), + math.floor(float(point[1]) / cell_size), + ) + candidates_by_cell.setdefault(cell, []).append(index) + selected = tuple( + sorted( + min( + candidates, + key=lambda index: ( + ( + float(self._waypoints[index].xyz_m[0]) + - (cell[0] + 0.5) * cell_size + ) + ** 2 + + ( + float(self._waypoints[index].xyz_m[1]) + - (cell[1] + 0.5) * cell_size + ) + ** 2, + index, + ), + ) + for cell, candidates in candidates_by_cell.items() + ) + ) + if len(selected) >= 2: + return selected + return self._eligible_waypoint_indices[:2] + + def _collected_pickup_index(self, x_m: float, y_m: float) -> int | None: + """Return the closest available pickup inside its activation radius.""" + candidates = ( + ( + math.hypot( + float(self._waypoints[index].xyz_m[0]) - x_m, + float(self._waypoints[index].xyz_m[1]) - y_m, + ), + index, + ) + for index in self._available_pickup_indices + ) + distance, index = min(candidates, default=(math.inf, -1)) + return index if distance <= self._config.pickup_radius_m else None + + def _select_pickup( + self, + vehicle_state: VehicleState, + *, + excluded: frozenset[int], + ) -> tuple[int, RoutePlan | None]: + """Choose a reachable pickup and its shortest legal route.""" + if len(excluded) >= len(self._waypoints): + excluded = frozenset() + distances, eligible = self._pickup_candidates( + vehicle_state.x_m, + vehicle_state.y_m, + excluded=excluded, + ) + for source in self._route_sources(vehicle_state): + route_distances = self._navigation.route_distances(source, self._waypoints) + pickup_indices = frozenset(self._pickup_point_indices) + reachable = [ + index + for index, route_distance in enumerate(route_distances) + if index in pickup_indices + and index not in excluded + and math.isfinite(route_distance) + and distances[index] > 1.0 + ] + preferred_candidates = [ + index for index in eligible if index in frozenset(reachable) + ] + candidates = preferred_candidates or reachable + if not candidates: + continue + pickup_index = ( + int(self._rng.choice(candidates)) + if preferred_candidates + else max(candidates, key=distances.__getitem__) + ) + plan = self._navigation.route(source, self._waypoints[pickup_index]) + if plan is not None: + return pickup_index, plan + fallback = [ + index + for index in self._pickup_point_indices + if index not in excluded and distances[index] > 1.0 + ] + if not fallback: + fallback = [ + index for index in self._pickup_point_indices if distances[index] > 1.0 + ] + if not fallback: + fallback = list(self._pickup_point_indices) + return min(fallback, key=distances.__getitem__), None + + def _select_initial_pickup( + self, + initial_state: VehicleState, + initial_camera: CameraCalibration | None, + ) -> tuple[int, RoutePlan | None]: + """Select the only pickup constrained by the player's initial view.""" + x_m = initial_state.x_m + y_m = initial_state.y_m + distances, eligible = self._pickup_candidates(x_m, y_m, excluded=frozenset()) + + if initial_camera is not None: + camera_model = FThetaCameraModel(initial_camera) + points = np.stack([point.xyz_m for point in self._waypoints]) + uv, _depth, forward = camera_model.project_world( + points, + rig_pose_from_vehicle_state(initial_state), + ) + visible = [ + index + for index in self._pickup_point_indices + if bool(forward[index]) + and 0.0 <= float(uv[index, 0]) < float(initial_camera.width) + and 0.0 <= float(uv[index, 1]) < float(initial_camera.height) + ] + else: + visible = [ + index + for index in self._pickup_point_indices + for point in (self._waypoints[index],) + if abs( + relative_target_bearing_rad( + x_m, + y_m, + initial_state.yaw_rad, + float(point.xyz_m[0]), + float(point.xyz_m[1]), + ) + ) + < math.pi * 0.5 + ] + + eligible_set = frozenset(eligible) + ideal_distance_m = self._config.initial_pickup_max_distance_m + for source in self._route_sources(initial_state): + route_distances = self._navigation.route_distances(source, self._waypoints) + reachable = frozenset( + index + for index, route_distance in enumerate(route_distances) + if math.isfinite(route_distance) and distances[index] > 1.0 + ) + candidate_groups = ( + [ + index + for index in visible + if index in eligible_set and index in reachable + ], + [index for index in visible if index in reachable], + ) + for candidates in candidate_groups: + if not candidates: + continue + pickup_index = min( + candidates, + key=lambda index: ( + abs(distances[index] - ideal_distance_m), + distances[index], + index, + ), + ) + plan = self._navigation.route(source, self._waypoints[pickup_index]) + if plan is not None: + return pickup_index, plan + return self._select_pickup(initial_state, excluded=frozenset()) + + def _pickup_candidates( + self, + x_m: float, + y_m: float, + *, + excluded: frozenset[int], + ) -> tuple[list[float], list[int]]: + """Return distances and valid pickup indices for a vehicle position.""" + distances = [ + math.hypot(float(point.xyz_m[0]) - x_m, float(point.xyz_m[1]) - y_m) + for point in self._waypoints + ] + eligible = [ + index + for index in self._pickup_point_indices + if index not in excluded + and distances[index] >= self._config.pickup_min_distance_m + ] + return distances, eligible + + def _select_dropoff( + self, pickup_index: int, vehicle_state: VehicleState + ) -> tuple[int, RoutePlan]: + """Choose a reachable dropoff and its shortest legal route.""" + sources = self._navigation.nearest_lane_positions( + vehicle_state.x_m, + vehicle_state.y_m, + vehicle_state.yaw_rad, + ) + pickup = self._waypoints[pickup_index] + fallback_sources = pickup.departure_anchors or ( + LanePosition( + lane_index=pickup.lane_index, + distance_along_lane_m=pickup.distance_along_lane_m, + lateral_distance_m=0.0, + heading_error_rad=0.0, + ), + ) + source_candidates = ( + fallback_sources + if pickup.departure_anchors + else tuple( + source for source in sources if source.lateral_distance_m <= 12.0 + ) + or fallback_sources + ) + + for source in source_candidates: + route_distances = self._navigation.route_distances(source, self._waypoints) + reachable = [ + index + for index, distance in enumerate(route_distances) + if index in self._eligible_waypoint_indices + and index != pickup_index + and math.isfinite(distance) + and distance > 1.0 + ] + if not reachable: + continue + preferred = [ + index + for index in reachable + if self._config.fare_min_route_distance_m + <= route_distances[index] + <= self._config.fare_max_route_distance_m + ] + far_enough = [ + index + for index in reachable + if route_distances[index] >= self._config.fare_min_route_distance_m + ] + dropoff_index = int(self._rng.choice(preferred or far_enough or reachable)) + plan = self._navigation.route(source, self._waypoints[dropoff_index]) + if plan is not None: + return dropoff_index, plan + raise RuntimeError("Taxi pickup has no reachable dropoff destination.") + + def _route_sources(self, vehicle_state: VehicleState) -> tuple[LanePosition, ...]: + """Return nearby heading-compatible route origins.""" + matches = self._navigation.nearest_lane_positions( + vehicle_state.x_m, + vehicle_state.y_m, + vehicle_state.yaw_rad, + ) + nearby = tuple( + source for source in matches if source.lateral_distance_m <= 12.0 + ) + return nearby or matches + + def _start_fare(self, pickup_index: int, vehicle_state: VehicleState) -> None: + self._pickup_index = pickup_index + self._dropoff_index, route_plan = self._select_dropoff( + self._pickup_index, vehicle_state + ) + self._target_index = self._dropoff_index + self._phase = "to_dropoff" + self._available_pickup_indices = () + raw_time = route_plan.distance_m / max(self._config.target_speed_mps, 1e-6) + raw_time += self._config.grace_s + clamped_time = float( + np.clip(raw_time, self._config.min_time_s, self._config.max_time_s) + ) + self._remaining_time_s = clamped_time * self._config.trip_time_multiplier + self._set_event("pickup_complete", 0) + + def _complete_fare(self, vehicle_state: VehicleState) -> None: + assert self._remaining_time_s is not None + awarded = self._config.base_fare_points + ( + math.floor(self._remaining_time_s) * self._config.bonus_points_per_second + ) + self._score += awarded + self._global_remaining_time_s += self._config.dropoff_time_bonus_s + self._set_event( + "fare_complete", + awarded, + awarded_global_time_s=self._config.dropoff_time_bonus_s, + ) + self._activate_next_pickup(vehicle_state) + + def _expire_fare(self, vehicle_state: VehicleState) -> None: + self._set_event("time_expired", 0) + self._activate_next_pickup(vehicle_state) + + def _activate_next_pickup(self, vehicle_state: VehicleState) -> None: + excluded = frozenset( + index + for index in (self._pickup_index, self._dropoff_index) + if index is not None + ) + self._target_index, _pickup_route = self._select_pickup( + vehicle_state, + excluded=excluded, + ) + self._available_pickup_indices = self._pickup_indices( + vehicle_state, + excluded=excluded, + ) + if self._target_index not in self._available_pickup_indices: + self._available_pickup_indices += (self._target_index,) + self._phase = "seeking_pickup" + self._remaining_time_s = None + + def _set_event( + self, + event: TaxiEvent, + awarded_points: int, + *, + awarded_global_time_s: float = 0.0, + ) -> None: + self._event = event + self._awarded_points = awarded_points + self._awarded_global_time_s = awarded_global_time_s + self._event_remaining_s = self._config.event_banner_s + + def _advance_banner(self, frame_interval_s: float) -> None: + self._event_remaining_s = max(0.0, self._event_remaining_s - frame_interval_s) + + def _end_game(self) -> None: + self._global_remaining_time_s = 0.0 + self._leaderboard = self._high_score_store.read() + self._high_score = ( + self._leaderboard[0].score if self._leaderboard else self._high_score + ) + self._high_score_rank = self._high_score_store.qualifying_rank(self._score) + self._session_state = ( + "awaiting_name" if self._high_score_rank is not None else "leaderboard" + ) + + +class TaxiGameRules: + """Game-engine rules adapter for fares, passengers, and high scores.""" + + def __init__(self, controller: TaxiGameController) -> None: + self.controller = controller + + @property + def is_running(self) -> bool: + return self.controller.is_playing + + def snapshot(self, vehicle_state: VehicleState) -> TaxiGameSnapshot: + return self.controller.snapshot(vehicle_state) + + def advance_frames( + self, + trajectory: TrajectoryChunk, + frame_interval_s: float, + ) -> GameUpdate: + from crazy_robotaxi.passengers import build_pickup_passenger_trajectories + + frames = self.controller.advance_frames(trajectory, frame_interval_s) + passengers = build_pickup_passenger_trajectories( + frames, + trajectory.timestamps_us, + ) + return GameUpdate(frames=frames, dynamic_actors=passengers) + + def submit_text( + self, + value: str, + vehicle_state: VehicleState, + ) -> TaxiGameSnapshot: + self.controller.submit_high_score_name(value) + return self.controller.snapshot(vehicle_state) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/scene.py b/apps/crazy_robotaxi/crazy_robotaxi/scene.py new file mode 100644 index 000000000..5c20371b0 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/scene.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Crazy Robotaxi navigation geometry loading.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt +from omnidreams_game_engine.types import SceneDefinition + +from crazy_robotaxi.navigation import NavigationFareRegion, NavigationLane + + +@dataclass(frozen=True) +class CrazyRobotaxiSceneData: + """Navigation geometry loaded only when Crazy Robotaxi is selected.""" + + reference_route_world: np.ndarray + """Route used to initialize navigation.""" + + navigation_lanes: tuple[NavigationLane, ...] + """Directed car-lane centerlines used for target routing.""" + + fare_regions: tuple[NavigationFareRegion, ...] + """Parking areas and exposed node edges used for fare placement.""" + + curb_segments_world: npt.NDArray[np.float32] + """Physical curb segments compiled from map-element boundaries.""" + + @property + def navigation_routes_world(self) -> tuple[np.ndarray, ...]: + """Return centerline arrays for compatibility with route consumers.""" + return tuple(lane.centerline_world for lane in self.navigation_lanes) + + +def load_scene_data(scene: SceneDefinition) -> CrazyRobotaxiSceneData: + """Load Crazy Robotaxi navigation geometry from the compiled game map.""" + game_map = scene.game_map + assert game_map is not None, "compiled game-map metadata is required" + lanes = tuple( + NavigationLane( + centerline_world=lane.centerline_world, + road_edge_world=( + lane.roadside_edge_world if lane.allows_taxi_stops else None + ), + allows_taxi_stops=lane.allows_taxi_stops, + lane_id=lane.lane_id, + successor_ids=lane.successor_ids, + element_id=lane.element_id, + ) + for lane in game_map.lanes + ) + spawn_lane = next( + lane + for lane in game_map.lanes + if lane.lane_id == game_map.default_spawn.lane_id + ) + curb_segments = [ + np.stack((start, end)) + for element in game_map.elements + for curb in element.curbs + for start, end in zip( + curb.polyline_world[:-1], curb.polyline_world[1:], strict=True + ) + ] + runtime_lanes = {lane.lane_id: lane for lane in game_map.lanes} + roads_by_node: dict[str, list[str]] = { + node.node_id: [] for node in game_map.topology.nodes + } + for road in game_map.topology.roads: + roads_by_node[road.from_node_id].append(road.road_id) + roads_by_node[road.to_node_id].append(road.road_id) + + def node_anchors(node_id: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + node = next(item for item in game_map.topology.nodes if item.node_id == node_id) + center = np.asarray([node.x_m, node.y_m], dtype=np.float32) + arrivals: list[str] = [] + departures: list[str] = [] + for road_id in roads_by_node[node_id]: + for lane_id, lane in runtime_lanes.items(): + if lane.element_id != road_id: + continue + start_distance = float( + np.linalg.norm(lane.centerline_world[0, :2] - center) + ) + end_distance = float( + np.linalg.norm(lane.centerline_world[-1, :2] - center) + ) + if end_distance <= start_distance: + arrivals.append(lane_id) + if start_distance <= end_distance: + departures.append(lane_id) + return tuple(dict.fromkeys(arrivals)), tuple(dict.fromkeys(departures)) + + node_anchor_cache = { + node.node_id: node_anchors(node.node_id) + for node in game_map.topology.nodes + if node.node_type != "parking_lot" + } + accesses_by_lot: dict[str, list[str]] = {} + for access in game_map.topology.parking_accesses: + accesses_by_lot.setdefault(access.parking_lot_node_id, []).append( + access.source_node_id + ) + elements = {element.element_id: element for element in game_map.elements} + fare_regions: list[NavigationFareRegion] = [] + for node in game_map.topology.nodes: + element = elements[node.node_id] + if node.node_type == "parking_lot": + source_ids = accesses_by_lot[node.node_id] + arrivals = tuple( + lane_id + for source_id in source_ids + for lane_id in node_anchor_cache[source_id][0] + ) + departures = tuple( + lane_id + for source_id in source_ids + for lane_id in node_anchor_cache[source_id][1] + ) + fare_regions.append( + NavigationFareRegion( + node.node_id, + "area", + (element.surface_world,), + tuple(dict.fromkeys(arrivals)), + tuple(dict.fromkeys(departures)), + ) + ) + continue + arrivals, departures = node_anchor_cache[node.node_id] + boundaries = tuple( + boundary.polyline_world for boundary in element.road_boundaries + ) + if boundaries and arrivals and departures: + fare_regions.append( + NavigationFareRegion( + node.node_id, + "boundary", + boundaries, + arrivals, + departures, + ) + ) + return CrazyRobotaxiSceneData( + reference_route_world=spawn_lane.centerline_world, + navigation_lanes=lanes, + fare_regions=tuple(fare_regions), + curb_segments_world=( + np.asarray(curb_segments, dtype=np.float32) + if curb_segments + else np.empty((0, 2, 3), dtype=np.float32) + ), + ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py new file mode 100644 index 000000000..efb615801 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -0,0 +1,223 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Crazy Robotaxi V2 session and sole model-thread implementation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np +import torch + +from crazy_robotaxi.factory import build_taxi_engine +from crazy_robotaxi.hud import render_hud +from crazy_robotaxi.rules import TaxiGameSnapshot +from flashdreams.api_v2.session import ISession +from flashdreams.api_v2.thread import IThread +from flashdreams.runtime_v2.session_desc import SessionDesc +from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_events import UserInputEvents +from flashdreams.runtime_v2.video_tensor import VideoTensorLayout +from omnidreams_game_engine.input import DriverInput +from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state +from omnidreams_game_engine.model import WorldModelRollout +from omnidreams_game_engine.types import SceneDefinition + +if TYPE_CHECKING: + from crazy_robotaxi.application import ApplicationConfig + + +@dataclass(slots=True) +class ModelState: + """All mutable state owned by the one V2 model thread.""" + + pipeline: Any + scene: SceneDefinition + config: ApplicationConfig + session_desc: SessionDesc + driver_input: DriverInput + rollout: WorldModelRollout | None = None + last_video: torch.Tensor | None = None + last_bev: torch.Tensor | None = None + last_pose: np.ndarray | None = None + blocks_generated: int = 0 + finished: bool = False + + def ensure_rollout(self) -> WorldModelRollout: + """Build renderer, PhysX, game, and cache on the model thread.""" + if self.rollout is None: + frame_interval_s = 1.0 / self.session_desc.frames_per_second_for_step + self.rollout = WorldModelRollout( + pipeline=self.pipeline, + scene=self.scene, + engine_factory=lambda: build_taxi_engine( + scene=self.scene, + game_config=self.config.game, + raster=self.config.renderer.raster, + bev=self.config.renderer.bev, + frame_interval_s=frame_interval_s, + device=self.config.device, + ), + ) + return self.rollout + + def reset(self) -> None: + self.driver_input.reset() + self.blocks_generated = 0 + self.finished = False + self.last_video = None + self.last_bev = None + self.last_pose = None + if self.rollout is not None: + self.rollout.reset() + + def close(self) -> None: + rollout = self.rollout + self.rollout = None + if rollout is not None: + rollout.close() + + +class CrazyRobotaxiModelThread(IThread[ModelState]): + """Run simulation, rules, conditioning, and generation in one V2 step.""" + + def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: + state = self.state + rollout = state.ensure_rollout() + snapshot = rollout.engine.current_game_frame + if not isinstance(snapshot, TaxiGameSnapshot): + raise TypeError("Taxi engine returned a non-taxi game frame") + accepting_text = snapshot.session_state == "awaiting_name" + + if snapshot.session_state == "playing": + frame_count = rollout.frame_count(state.blocks_generated) + input_batch = state.driver_input.reduce( + events, + frame_count=frame_count, + frame_interval_s=( + 1.0 / state.session_desc.frames_per_second_for_step + ), + accepting_text=False, + ) + generated = rollout.step( + autoregressive_index=state.blocks_generated, + commands=input_batch.commands, + ) + state.blocks_generated += 1 + video = generated.video_bvtchw[0, 0] + expected_shape = ( + 3, + state.session_desc.video_height, + state.session_desc.video_width, + ) + if tuple(video.shape[1:]) != expected_shape: + raise ValueError( + "Generated video channels and geometry do not match the session: " + f"expected {expected_shape}, got {tuple(video.shape[1:])}" + ) + engine_step = generated.engine + game_frames = engine_step.game_frames + poses = engine_step.trajectory.rig_poses_world + bev = engine_step.condition.bev_tchw + metrics = dict(generated.metrics) + state.last_video = video[-1:].detach() + state.last_bev = None if bev is None else bev[-1:].detach() + state.last_pose = poses[-1].copy() + else: + input_batch = state.driver_input.reduce( + events, + frame_count=1, + frame_interval_s=( + 1.0 / state.session_desc.frames_per_second_for_step + ), + accepting_text=accepting_text, + ) + if accepting_text and input_batch.submitted_text is not None: + snapshot = rollout.engine.submit_text(input_batch.submitted_text) + if state.last_video is None or state.last_pose is None: + raise RuntimeError("Terminal game state has no generated frame") + video = state.last_video + game_frames = (snapshot,) + poses = state.last_pose[None, ...] + bev = state.last_bev + metrics = {} + + overlay = render_hud( + game_frames, + rig_poses_world=poses, + calibration=state.scene.selected_camera, + bev_tchw=bev, + width=int(video.shape[-1]), + height=int(video.shape[-2]), + device=video.device, + dtype=video.dtype, + player_name=state.driver_input.text, + ) + latest = game_frames[-1] + if isinstance(latest, TaxiGameSnapshot) and latest.session_state == "leaderboard": + state.finished = True + if ( + state.config.total_blocks is not None + and state.blocks_generated >= state.config.total_blocks + ): + state.finished = True + count = int(video.shape[0]) + return [ + StepResult( + step_index=step_index, + output=video, + frame_count=count, + output_layout=VideoTensorLayout.tchw, + metrics=metrics, + ), + StepResult( + step_index=step_index, + output=overlay, + frame_count=count, + output_layout=VideoTensorLayout.tchw, + ), + ] + + def is_finished(self) -> bool: + return self.state.finished + + def reset(self) -> None: + self.state.reset() + + def close(self) -> None: + self.state.close() + + +class CrazyRobotaxiSession(ISession): + """Register one model thread and use V2's compositing UI thread.""" + + def __init__( + self, + *, + pipeline: Any, + scene: SceneDefinition, + config: ApplicationConfig, + session_desc: SessionDesc, + ) -> None: + self._pipeline = pipeline + self._scene = scene + self._config = config + self._session_desc = session_desc + + @property + def session_desc(self) -> SessionDesc: + return self._session_desc + + def init(self) -> None: + self.register_model_thread( + CrazyRobotaxiModelThread, + state=ModelState( + pipeline=self._pipeline, + scene=self._scene, + config=self._config, + session_desc=self._session_desc, + driver_input=DriverInput(self._config.driver_input), + ), + ) diff --git a/apps/crazy_robotaxi/pyproject.toml b/apps/crazy_robotaxi/pyproject.toml new file mode 100644 index 000000000..7a1dd11f8 --- /dev/null +++ b/apps/crazy_robotaxi/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "crazy-robotaxi" +version = "0.1.0" +description = "Crazy Robotaxi application for FlashDreams V2" +readme = "README.md" +requires-python = ">=3.10,<3.13" +dependencies = [ + "flashdreams", + "flashdreams-omnidreams", + "omnidreams-game-engine", + "pillow", + "torch", +] + +[project.scripts] +crazy-robotaxi-map = "crazy_robotaxi.map_tool:main" + +[project.entry-points."flashdreams.applications_v2"] +crazy-robotaxi = "crazy_robotaxi:create_app" + +[tool.uv.sources] +flashdreams = { workspace = true } +flashdreams-omnidreams = { workspace = true } +omnidreams-game-engine = { workspace = true } + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-manual-marker>=2.0"] + +[tool.setuptools.packages.find] +include = ["crazy_robotaxi*"] + +[tool.setuptools.package-data] +crazy_robotaxi = ["configs/*.yaml", "maps/*.robotaxi.yaml"] + +[tool.uv] +managed = true diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py new file mode 100644 index 000000000..c9ad15bea --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU tests for Crazy Robotaxi's application boundary against FlashDreams V2.""" + +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pytest + +from crazy_robotaxi.application import CrazyRobotaxiApplication +from crazy_robotaxi.session import CrazyRobotaxiModelThread +from flashdreams.api_v2.thread import BlitModelOutputToScreenThread +from flashdreams.runtime_v2.video_tensor import VideoTensorLayout +from omnidreams_game_engine.types import CameraCalibration, SceneDefinition + +pytestmark = pytest.mark.ci_cpu + + +def _scene() -> SceneDefinition: + calibration = CameraCalibration( + clipgt_name="front", + logical_name="camera_front_wide_120fov", + width=1280, + height=704, + cx=640.0, + cy=352.0, + polynomial=np.zeros(6, dtype=np.float32), + is_backward_polynomial=False, + linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), + sensor_to_rig_flu=np.eye(4, dtype=np.float32), + ) + return SceneDefinition( + scene_path=Path("scene.arrow"), + scene_id="test", + metadata={}, + selected_camera=calibration, + initial_rig_to_world=np.eye(4, dtype=np.float32), + initial_timestamp_us=0, + initial_yaw_rad=0.0, + initial_speed_mps=0.0, + initial_rgb=np.zeros((704, 1280, 3), dtype=np.uint8), + prompt="taxi", + line_layers=(), + triangle_layers=(), + ) + + +def test_application_registers_one_model_thread_and_uses_v2_ui() -> None: + pipeline = object() + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: pipeline, + scene_factory=lambda request, raster: _scene(), + ) + desc = app.session_desc() + app.init(["--device", "cpu", "--total-blocks", "2"]) + + session = app.create_session(desc) + session.init() + ui_thread, model_thread = session._take_threads() + + assert desc.output_layout is VideoTensorLayout.tchw + assert isinstance(model_thread, CrazyRobotaxiModelThread) + assert isinstance(ui_thread, BlitModelOutputToScreenThread) + assert model_thread.state.pipeline is pipeline + assert model_thread.state.rollout is None + + +def test_application_rejects_geometry_the_model_does_not_produce() -> None: + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: object(), + scene_factory=lambda request, raster: _scene(), + ) + app.init([]) + desc = app.session_desc() + desc = type(desc)( + output_layout=desc.output_layout, + presentation_mode=desc.presentation_mode, + frames_per_second_for_ui=desc.frames_per_second_for_ui, + frames_per_second_for_step=desc.frames_per_second_for_step, + video_width=640, + video_height=desc.video_height, + ) + + with pytest.raises(ValueError, match="do not match renderer"): + app.create_session(desc) + + +def test_application_rejects_a_frame_rate_the_model_was_not_trained_for() -> None: + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: object(), + scene_factory=lambda request, raster: _scene(), + ) + app.init([]) + + with pytest.raises(ValueError, match="30 frames per second"): + app.create_session( + replace(app.session_desc(), frames_per_second_for_step=60) + ) diff --git a/apps/crazy_robotaxi/tests/test_gameplay.py b/apps/crazy_robotaxi/tests/test_gameplay.py new file mode 100644 index 000000000..ee23b569f --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_gameplay.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""CPU regressions for taxi-specific rules, navigation, and dynamics.""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import pytest + +from crazy_robotaxi.dynamics import TaxiVehicleConfig, integrate_taxi_vehicle +from crazy_robotaxi.high_scores import HighScoreStore, validate_player_name +from crazy_robotaxi.navigation import ( + LanePosition, + NavigationLane, + NavigationWaypoint, + TaxiNavigationMap, +) +from crazy_robotaxi.passengers import build_pickup_passenger_trajectories +from crazy_robotaxi.rules import TaxiGameConfig, TaxiGameController, TaxiGameSnapshot +from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state +from omnidreams_game_engine.types import DriverCommand, TrajectoryChunk, VehicleState + +pytestmark = pytest.mark.ci_cpu + + +def _state(x_m: float = 0.0, y_m: float = 0.0) -> VehicleState: + return VehicleState(x_m, y_m, 0.0, 0.0, 0.0, 0.0) + + +def _trajectory(*positions_xy: tuple[float, float]) -> TrajectoryChunk: + states = tuple(_state(*position) for position in positions_xy) + return TrajectoryChunk( + timestamps_us=np.arange(len(states), dtype=np.int64), + rig_poses_world=np.stack( + [rig_pose_from_vehicle_state(state) for state in states] + ), + vehicle_states=states, + boundary_state_after_chunk=states[-1], + ) + + +def _controller( + config: TaxiGameConfig | None = None, + *, + high_score_store: HighScoreStore | None = None, +) -> TaxiGameController: + return TaxiGameController( + scene_id="taxi-test", + reference_route_world=np.asarray( + [[0.0, 0.0, 0.0], [100.0, 0.0, 0.0]], dtype=np.float32 + ), + initial_state=_state(), + config=config or TaxiGameConfig(waypoint_spacing_m=1000.0), + high_score_store=high_score_store, + ) + + +def _lane(start: tuple[float, float], end: tuple[float, float]) -> NavigationLane: + return NavigationLane( + np.asarray([[*start, 0.0], [*end, 0.0]], dtype=np.float32) + ) + + +def test_navigation_uses_directed_road_distance() -> None: + navigation = TaxiNavigationMap( + ( + _lane((0.0, 0.0), (10.0, 0.0)), + _lane((10.0, 0.0), (20.0, 10.0)), + _lane((20.0, 10.0), (20.0, 20.0)), + ) + ) + destination = NavigationWaypoint( + np.asarray([20.0, 20.0, 0.0], dtype=np.float32), + lane_index=2, + distance_along_lane_m=10.0, + ) + + route = navigation.route(LanePosition(0, 0.0, 0.0, 0.0), destination) + + assert route is not None + assert route.lane_indices == (0, 1, 2) + assert route.distance_m == pytest.approx(20.0 + math.sqrt(200.0)) + + +def test_taxi_brake_from_rest_enters_reverse() -> None: + result = integrate_taxi_vehicle( + _state(), + DriverCommand(brake=1.0, manual_control=True), + dt_s=0.1, + vehicle=TaxiVehicleConfig(), + ) + + assert result.speed_mps < 0.0 + + +def test_fare_and_game_over_flow_reaches_v2_name_entry(tmp_path: Path) -> None: + store = HighScoreStore(tmp_path / "scores.csv") + controller = _controller( + TaxiGameConfig( + waypoint_spacing_m=1000.0, + global_time_s=1.0, + dropoff_time_bonus_s=0.0, + high_scores_path=store.path, + ), + high_score_store=store, + ) + + controller.advance(_trajectory((100.0, 0.0), (0.0, 0.0)), 0.0) + controller.advance(_trajectory((0.0, 0.0)), 1.0) + game_over = controller.snapshot(_state()) + + assert not controller.is_playing + assert game_over.score == 4100 + assert game_over.session_state == "awaiting_name" + + controller.submit_high_score_name("PLAYER 1") + leaderboard = controller.snapshot(_state()) + assert leaderboard.session_state == "leaderboard" + assert [(entry.name, entry.score) for entry in leaderboard.leaderboard] == [ + ("PLAYER 1", 4100) + ] + + +def test_high_scores_order_by_score_then_timestamp(tmp_path: Path) -> None: + store = HighScoreStore(tmp_path / "scores.csv") + store.record("LATER", 900, achieved_at_utc="2026-08-10T12:00:01+00:00") + store.record("HIGH", 1200, achieved_at_utc="2026-08-10T12:00:02+00:00") + store.record("EARLIER", 900, achieved_at_utc="2026-08-10T12:00:00+00:00") + + assert validate_player_name(" A-B_C ") == "A-B_C" + assert [(entry.name, entry.score) for entry in store.read()] == [ + ("HIGH", 1200), + ("EARLIER", 900), + ("LATER", 900), + ] + + +def test_passenger_tracks_follow_snapshot_visibility() -> None: + target = (1.0, 2.0, 0.25) + + def snapshot(*targets: tuple[float, float, float]) -> TaxiGameSnapshot: + return TaxiGameSnapshot( + phase="seeking_pickup" if targets else "to_dropoff", + target_xyz_m=targets[0] if targets else (0.0, 0.0, 0.0), + distance_m=0.0, + relative_bearing_rad=0.0, + target_radius_m=5.0, + remaining_time_s=None, + score=0, + pickup_targets_xyz_m=targets, + ) + + actors = build_pickup_passenger_trajectories( + (snapshot(target), snapshot(), snapshot(target)), + np.asarray([100, 200, 300], dtype=np.int64), + ) + + assert len(actors) == 2 + assert actors[0].entity_id == actors[1].entity_id + np.testing.assert_array_equal(actors[0].timestamps_us, [100]) + np.testing.assert_array_equal(actors[1].timestamps_us, [300]) diff --git a/apps/crazy_robotaxi/tests/test_hud.py b/apps/crazy_robotaxi/tests/test_hud.py new file mode 100644 index 000000000..5fb0fe65b --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_hud.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU tests for frame-aligned V2 HUD channels.""" + +import numpy as np +import pytest +import torch + +from crazy_robotaxi.hud import render_hud +from crazy_robotaxi.rules import TaxiGameSnapshot +from omnidreams_game_engine.types import CameraCalibration + +pytestmark = pytest.mark.ci_cpu + + +def test_hud_returns_one_rgba_layer_per_generated_frame() -> None: + calibration = CameraCalibration( + clipgt_name="front", + logical_name="front", + width=160, + height=96, + cx=80.0, + cy=48.0, + polynomial=np.asarray([0.0, 100.0, 0.0, 0.0], dtype=np.float32), + is_backward_polynomial=False, + linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), + sensor_to_rig_flu=np.eye(4, dtype=np.float32), + ) + snapshot = TaxiGameSnapshot( + phase="seeking_pickup", + target_xyz_m=(25.0, 0.0, 0.0), + distance_m=25.0, + relative_bearing_rad=0.0, + target_radius_m=5.0, + remaining_time_s=None, + score=0, + global_remaining_time_s=60.0, + ) + + result = render_hud( + (snapshot, snapshot), + rig_poses_world=np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0), + calibration=calibration, + bev_tchw=None, + width=160, + height=96, + device="cpu", + dtype=torch.float32, + ) + + assert result.shape == (2, 4, 96, 160) + assert torch.all((0.0 <= result[:, 3]) & (result[:, 3] <= 1.0)) + assert torch.any(result[:, 3] > 0.0) diff --git a/apps/crazy_robotaxi/tests/test_maps.py b/apps/crazy_robotaxi/tests/test_maps.py new file mode 100644 index 000000000..70a1a88f4 --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_maps.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU validation for shipped semantic maps.""" + +import math +from pathlib import Path + +import numpy as np +import pytest + +from omnidreams_game_engine.game_map import load_game_map + +pytestmark = pytest.mark.ci_cpu + + +@pytest.mark.parametrize( + "filename", + ["minimal_loop.robotaxi.yaml", "boulevard_district.robotaxi.yaml"], +) +def test_shipped_map_is_valid(filename: str) -> None: + path = Path(__file__).parents[1] / "crazy_robotaxi" / "maps" / filename + game_map = load_game_map(path) + + assert game_map.map_id.startswith("crazy-robotaxi-") + assert game_map.spawns + assert game_map.lanes + + +def test_boulevard_traffic_turns_are_continuous_and_physically_limited() -> None: + path = ( + Path(__file__).parents[1] + / "crazy_robotaxi" + / "maps" + / "boulevard_district.robotaxi.yaml" + ) + game_map = load_game_map(path) + lanes = {lane.lane_id: lane for lane in game_map.lanes} + node_types = {node.node_id: node.node_type for node in game_map.topology.nodes} + + connectors = ( + lane + for lane in game_map.lanes + if ":connector:" in lane.lane_id + and node_types[lane.element_id] != "cul_de_sac" + ) + for connector in connectors: + sources = [ + lane for lane in game_map.lanes if connector.lane_id in lane.successor_ids + ] + assert len(sources) == 1 + target = lanes[connector.successor_ids[0]] + tangent_pairs = ( + ( + sources[0].centerline_world[-1, :2] + - sources[0].centerline_world[-2, :2], + connector.centerline_world[1, :2] + - connector.centerline_world[0, :2], + ), + ( + connector.centerline_world[-1, :2] + - connector.centerline_world[-2, :2], + target.centerline_world[1, :2] - target.centerline_world[0, :2], + ), + ) + for first, second in tangent_pairs: + cosine = float( + np.dot(first, second) / (np.linalg.norm(first) * np.linalg.norm(second)) + ) + assert cosine >= 0.97, connector.lane_id + + cul_de_sacs = { + node.node_id + for node in game_map.topology.nodes + if node.node_type == "cul_de_sac" + } + for vehicle in game_map.traffic: + segments = np.diff(vehicle.centerline_world[:, :2], axis=0) + lengths = np.linalg.norm(segments, axis=1) + assert np.all(lengths >= 0.25 - 1.0e-5), vehicle.vehicle_id + + headings = np.arctan2(segments[:, 1], segments[:, 0]) + heading_changes = np.abs( + (headings - np.roll(headings, 1) + np.pi) % (2.0 * np.pi) - np.pi + ) + previous_lengths = np.roll(lengths, 1) + previous_speeds = np.roll(vehicle.speed_limits_mps[:-1], 1) + segment_speeds = np.maximum( + np.minimum(previous_speeds, vehicle.speed_limits_mps[:-1]), 0.1 + ) + yaw_rates = heading_changes * segment_speeds / previous_lengths + assert np.max(yaw_rates) <= 1.201, vehicle.vehicle_id + + for index, heading_change in enumerate(heading_changes): + previous = (index - 1) % len(vehicle.route_element_ids) + current = index % len(vehicle.route_element_ids) + if { + vehicle.route_element_ids[previous], + vehicle.route_element_ids[current], + }.isdisjoint(cul_de_sacs): + assert heading_change <= math.radians(30.0), ( + vehicle.vehicle_id, + index, + ) diff --git a/apps/omnidreams_game_engine/README.md b/apps/omnidreams_game_engine/README.md new file mode 100644 index 000000000..ced170de0 --- /dev/null +++ b/apps/omnidreams_game_engine/README.md @@ -0,0 +1,8 @@ +# OmniDreams Game Engine + +Reusable model-thread simulation, authored-map, physics, and conditioning +components for FlashDreams V2 applications. The engine does not own a runtime +loop, presentation backend, or worker thread. + +See the target architecture in +[`../../docs/design/crazy_robotaxi_v2_architecture.md`](../../docs/design/crazy_robotaxi_v2_architecture.md). diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/__init__.py b/apps/omnidreams_game_engine/omnidreams_game_engine/__init__.py new file mode 100644 index 000000000..a44b48f34 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Reusable model-thread game engine for FlashDreams V2.""" + +from omnidreams_game_engine.engine import EngineStep, GameEngine +from omnidreams_game_engine.model import WorldModelRollout + +__all__ = ["EngineStep", "GameEngine", "WorldModelRollout"] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/camera.py b/apps/omnidreams_game_engine/omnidreams_game_engine/camera.py new file mode 100644 index 000000000..a846e8f8a --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/camera.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import numpy.typing as npt + +from omnidreams_game_engine.math3d import invert_transform, transform_points +from omnidreams_game_engine.types import CameraCalibration + + +@dataclass +class FThetaCameraModel: + calibration: CameraCalibration + output_width: int | None = None + output_height: int | None = None + radius_lut: npt.NDArray[np.float32] = field(init=False) + theta_lut: npt.NDArray[np.float32] = field(init=False) + max_angle_rad: float = field(init=False) + max_radius_px: float = field(init=False) + tail_slope_px_per_rad: float = field(init=False) + linear_matrix: npt.NDArray[np.float32] = field(init=False) + uv_scale: npt.NDArray[np.float32] = field(init=False) + + def __post_init__(self) -> None: + max_radius = float( + np.hypot( + max(self.calibration.cx, self.calibration.width - self.calibration.cx), + max(self.calibration.cy, self.calibration.height - self.calibration.cy), + ) + ) + self.radius_lut = np.linspace(0.0, max_radius * 1.10, 4096, dtype=np.float32) + self.theta_lut = self._poly_eval(self.radius_lut) + self.theta_lut = np.maximum.accumulate(self.theta_lut).astype(np.float32) + self.max_angle_rad = float(self.theta_lut[-1]) + self.max_radius_px = float(self.radius_lut[-1]) + theta_step = float(self.theta_lut[-1] - self.theta_lut[-2]) + radius_step = float(self.radius_lut[-1] - self.radius_lut[-2]) + self.tail_slope_px_per_rad = radius_step / max(theta_step, 1e-6) + + c, d, e = self.calibration.linear_cde.tolist() + self.linear_matrix = np.array([[c, d], [e, 1.0]], dtype=np.float32) + target_width = float(self.output_width or self.calibration.width) + target_height = float(self.output_height or self.calibration.height) + self.uv_scale = np.array( + [ + target_width / float(self.calibration.width), + target_height / float(self.calibration.height), + ], + dtype=np.float32, + ) + + def _poly_eval( + self, value: npt.NDArray[np.float32] | float + ) -> npt.NDArray[np.float32]: + result = np.zeros_like(np.asarray(value, dtype=np.float32), dtype=np.float32) + for power, coefficient in enumerate(self.calibration.polynomial): + result = result + np.float32(coefficient) * np.power( + value, power, dtype=np.float32 + ) + return result.astype(np.float32) + + def angle_to_radius( + self, angle_rad: npt.NDArray[np.float32] + ) -> npt.NDArray[np.float32]: + if not self.calibration.is_backward_polynomial: + return self._poly_eval(angle_rad) + + angles = np.asarray(angle_rad, dtype=np.float32) + flat_angles = angles.reshape(-1) + clipped = np.clip(flat_angles, self.theta_lut[0], self.theta_lut[-1]) + radii = np.interp(clipped, self.theta_lut, self.radius_lut).astype(np.float32) + + high_mask = flat_angles > self.max_angle_rad + if np.any(high_mask): + radii[high_mask] = ( + self.max_radius_px + + (flat_angles[high_mask] - self.max_angle_rad) + * self.tail_slope_px_per_rad + ) + + return radii.reshape(angles.shape).astype(np.float32) + + def project_camera_rdf( + self, points_camera_rdf: npt.NDArray[np.float32] + ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: + xy_norm = np.linalg.norm(points_camera_rdf[:, :2], axis=1).astype(np.float32) + ray_norm = np.linalg.norm(points_camera_rdf, axis=1).astype(np.float32) + cos_alpha = np.divide( + points_camera_rdf[:, 2], + np.maximum(ray_norm, 1e-6), + out=np.zeros_like(ray_norm), + where=ray_norm > 1e-6, + ).astype(np.float32) + cos_alpha = np.clip(cos_alpha, -1.0, 1.0) + alpha = np.arccos(cos_alpha).astype(np.float32) + radius = self.angle_to_radius(alpha) + + scale = np.divide( + radius, + np.maximum(xy_norm, 1e-6), + out=np.zeros_like(radius), + where=xy_norm > 1e-6, + ) + pixels_rel = points_camera_rdf[:, :2] * scale[:, None] + pixels_rel[xy_norm <= 1e-6] = 0.0 + uv = (pixels_rel @ self.linear_matrix.T) + np.array( + [self.calibration.cx, self.calibration.cy], dtype=np.float32 + ) + uv = uv * self.uv_scale + depth = points_camera_rdf[:, 2].astype(np.float32) + return uv.astype(np.float32), depth + + def project_world( + self, + points_world_xyz: npt.NDArray[np.float32], + rig_to_world: npt.NDArray[np.float32], + ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32], npt.NDArray[np.bool_]]: + sensor_to_world = rig_to_world @ self.calibration.sensor_to_rig_flu + world_to_sensor = invert_transform(sensor_to_world) + points_sensor_flu = transform_points(world_to_sensor, points_world_xyz) + points_camera_rdf = np.stack( + [ + -points_sensor_flu[:, 1], + -points_sensor_flu[:, 2], + points_sensor_flu[:, 0], + ], + axis=1, + ).astype(np.float32) + uv, depth = self.project_camera_rdf(points_camera_rdf) + valid = depth > 0.0 + return uv, depth, valid diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/camera_defaults.py b/apps/omnidreams_game_engine/omnidreams_game_engine/camera_defaults.py new file mode 100644 index 000000000..bcd16b7f7 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/camera_defaults.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Canonical front-camera calibration for compiled semantic maps.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from omnidreams_game_engine.math3d import ( + euler_xyz_degrees_to_matrix, + transform_from_rt, +) + +if TYPE_CHECKING: + from omnidreams_game_engine.types import CameraCalibration + +DEFAULT_FRONT_CAMERA_CLIPGT_NAME = "camera:front:wide:120fov" +"""ClipGT sensor name embedded in compiled semantic-map archives.""" + +DEFAULT_FRONT_CAMERA_LOGICAL_NAME = "camera_front_wide_120fov" +"""Filesystem-safe name for the canonical front camera.""" + +DEFAULT_FIRST_FRAME_RESOLUTION_WH = (1280, 704) +"""Pixel resolution used by generated and authored first frames.""" + +_NATIVE_RESOLUTION_WH = (3848, 2168) +_PRINCIPAL_POINT_XY = (1921.318705874846, 1076.978854184438) +_POLYNOMIAL = ( + 0.0, + 0.0005385247479413695, + -1.598462177407655e-09, + 6.250864794463573e-12, + -2.194585699335322e-15, + 4.525222700710391e-19, +) +_POLYNOMIAL_TEXT = ( + "0 0.0005385247479413695 -1.598462177407655e-09 " + "6.250864794463573e-12 -2.194585699335322e-15 " + "4.525222700710391e-19" +) +_NOMINAL_RPY_DEG = ( + 0.292217969894409, + 0.464194804430008, + -0.191304489970207, +) +_CORRECTION_RPY_DEG = ( + -0.1592078059911728, + 0.11539523303508759, + 0.5026581287384033, +) +_NOMINAL_TRANSLATION_M = ( + 1.69035196304321, + 0.00553808081895113, + 1.45306670665741, +) +_CORRECTION_TRANSLATION_M = ( + -0.057110343128442764, + -0.0032010308932513, + 0.008508340455591679, +) + + +def default_front_camera_calibration() -> CameraCalibration: + """Build the canonical compiled-map front-camera calibration.""" + from omnidreams_game_engine.types import CameraCalibration + + nominal_rotation = euler_xyz_degrees_to_matrix(_NOMINAL_RPY_DEG) + correction_rotation = euler_xyz_degrees_to_matrix(_CORRECTION_RPY_DEG) + rotation = (nominal_rotation @ correction_rotation).astype(np.float32) + translation = np.asarray(_NOMINAL_TRANSLATION_M, dtype=np.float32) + np.asarray( + _CORRECTION_TRANSLATION_M, dtype=np.float32 + ) + return CameraCalibration( + clipgt_name=DEFAULT_FRONT_CAMERA_CLIPGT_NAME, + logical_name=DEFAULT_FRONT_CAMERA_LOGICAL_NAME, + width=_NATIVE_RESOLUTION_WH[0], + height=_NATIVE_RESOLUTION_WH[1], + cx=_PRINCIPAL_POINT_XY[0], + cy=_PRINCIPAL_POINT_XY[1], + polynomial=np.asarray(_POLYNOMIAL, dtype=np.float32), + is_backward_polynomial=True, + linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), + sensor_to_rig_flu=transform_from_rt(rotation, translation.tolist()), + ) + + +def default_front_camera_rig() -> dict[str, object]: + """Build the ClipGT rig record for the canonical front camera.""" + return { + "rig": { + "properties": {}, + "vehicle": {}, + "vehicleio": {}, + "sensors": [ + { + "name": DEFAULT_FRONT_CAMERA_CLIPGT_NAME, + "protocol": "camera.virtual", + "parameter": ("video=synthetic/camera_front_wide_120fov.mp4"), + "nominalSensor2Rig_FLU": { + "roll-pitch-yaw": list(_NOMINAL_RPY_DEG), + "t": list(_NOMINAL_TRANSLATION_M), + }, + "correction_sensor_R_FLU": { + "roll-pitch-yaw": list(_CORRECTION_RPY_DEG), + }, + "correction_rig_T": list(_CORRECTION_TRANSLATION_M), + "properties": { + "width": str(_NATIVE_RESOLUTION_WH[0]), + "height": str(_NATIVE_RESOLUTION_WH[1]), + "cx": str(_PRINCIPAL_POINT_XY[0]), + "cy": str(_PRINCIPAL_POINT_XY[1]), + "Model": "ftheta", + "polynomial-type": "pixeldistance-to-angle", + "polynomial": _POLYNOMIAL_TEXT, + "linear-c": "1.000000", + "linear-d": "0.000000", + "linear-e": "0.000000", + }, + } + ], + } + } diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/colors.py b/apps/omnidreams_game_engine/omnidreams_game_engine/colors.py new file mode 100644 index 000000000..c89554317 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/colors.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from __future__ import annotations + +LANE_LINE_STYLE_CONFIG: dict[str, dict[str, object]] = { + "WHITE SOLID_SINGLE": { + "color": (1.0, 1.0, 1.0, 1.0), + "pattern": "solid", + "width_scale": 1.0, + }, + "WHITE LONG_DASHED_SINGLE": { + "color": (1.0, 1.0, 1.0, 1.0), + "pattern": "long_dashed", + "width_scale": 1.0, + }, + "WHITE SHORT_DASHED_SINGLE": { + "color": (1.0, 1.0, 1.0, 1.0), + "pattern": "short_dashed", + "width_scale": 1.0, + }, + "WHITE DOT_DASHED_SINGLE": { + "color": (1.0, 1.0, 1.0, 1.0), + "pattern": "dot_dashed", + "width_scale": 1.0, + }, + "WHITE SOLID_GROUP": { + "color": (1.0, 1.0, 1.0, 1.0), + "pattern": "dual", + "dual_pattern": ("solid", "solid"), + "width_scale": 1.0, + }, + "YELLOW SOLID_SINGLE": { + "color": (1.0, 1.0, 0.0, 1.0), + "pattern": "solid", + "width_scale": 1.0, + }, + "YELLOW LONG_DASHED_SINGLE": { + "color": (1.0, 1.0, 0.0, 1.0), + "pattern": "long_dashed", + "width_scale": 1.0, + }, + "YELLOW DASHED_SOLID": { + "color": (1.0, 1.0, 0.0, 1.0), + "pattern": "dual", + "dual_pattern": ("solid", "long_dashed"), + "width_scale": 1.0, + }, + "YELLOW SOLID_DASHED": { + "color": (1.0, 1.0, 0.0, 1.0), + "pattern": "dual", + "dual_pattern": ("long_dashed", "solid"), + "width_scale": 1.0, + }, + "YELLOW DOT_SOLID_SINGLE": { + "color": (1.0, 1.0, 0.0, 1.0), + "pattern": "dotted_1_9", + "width_scale": 1.0, + }, + "YELLOW SOLID_GROUP": { + "color": (1.0, 1.0, 0.0, 1.0), + "pattern": "dual", + "dual_pattern": ("solid", "solid"), + "width_scale": 1.0, + }, + "OTHER": { + "color": (181.0 / 255.0, 164.0 / 255.0, 71.0 / 255.0, 1.0), + "pattern": "solid", + "width_scale": 1.0, + }, +} + +HDMAP_V3_COLORS: dict[str, tuple[float, float, float, float]] = { + "lanelines": (98.0 / 255.0, 183.0 / 255.0, 249.0 / 255.0, 1.0), + "road_boundaries": (253.0 / 255.0, 1.0 / 255.0, 232.0 / 255.0, 1.0), + "wait_lines": (108.0 / 255.0, 179.0 / 255.0, 59.0 / 255.0, 1.0), + "crosswalks": (139.0 / 255.0, 93.0 / 255.0, 1.0, 1.0), + "road_markings": (20.0 / 255.0, 254.0 / 255.0, 185.0 / 255.0, 1.0), + "poles": (183.0 / 255.0, 69.0 / 255.0, 177.0 / 255.0, 1.0), + "traffic_signs": (8.0 / 255.0, 2.0 / 255.0, 1.0, 1.0), + "traffic_lights": (100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 1.0), + "intersection_areas": (87.0 / 255.0, 110.0 / 255.0, 1.0, 0.95), + "road_islands": (1.0, 155.0 / 255.0, 37.0 / 255.0, 0.95), +} + +BBOX_V3_COLORS: dict[ + str, tuple[tuple[float, float, float], tuple[float, float, float]] +] = { + "Car": ( + (0.0 / 255.0, 46.0 / 255.0, 136.0 / 255.0), + (126.0 / 255.0, 206.0 / 255.0, 255.0 / 255.0), + ), + "Truck": ( + (204.0 / 255.0, 55.0 / 255.0, 0.0 / 255.0), + (255.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0), + ), + "Pedestrian": ( + (148.0 / 255.0, 0.0 / 255.0, 62.0 / 255.0), + (255.0 / 255.0, 124.0 / 255.0, 171.0 / 255.0), + ), + "Cyclist": ( + (0.0 / 255.0, 80.0 / 255.0, 66.0 / 255.0), + (102.0 / 255.0, 208.0 / 255.0, 198.0 / 255.0), + ), + "Others": ( + (53.0 / 255.0, 26.0 / 255.0, 20.0 / 255.0), + (166.0 / 255.0, 136.0 / 255.0, 125.0 / 255.0), + ), +} diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py new file mode 100644 index 000000000..dc5803a24 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py @@ -0,0 +1,238 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Single-threaded Ludus conditioning owned by the V2 model thread.""" + +from __future__ import annotations + +import math + +import numpy as np +import torch +from ludus_renderer import FThetaCamera, LudusCudaTimestampedContext +from ludus_renderer import load_scene as load_ludus_scene +from ludus_renderer.render_utils import SceneAdapter +from ludus_renderer.torch.ops import CAMERA_TYPE_BEV, CAMERA_TYPE_REGULAR +from torch import Tensor + +from omnidreams_game_engine.config import BevConfig, RasterConfig +from omnidreams_game_engine.contracts import ConditionRenderer +from omnidreams_game_engine.dynamic_scene import MutableObjectSceneBuffer +from omnidreams_game_engine.types import ConditionBatch, SceneDefinition, TrajectoryChunk + +_BEV_CAMERA_NAME = "game_engine_bev" + + +class LudusConditionRenderer(ConditionRenderer): + """Render semantic frames without creating an internal worker thread.""" + + def __init__( + self, + raster: RasterConfig, + bev: BevConfig = BevConfig(), + *, + device: torch.device | str = "cuda", + ) -> None: + if not torch.cuda.is_available(): + raise RuntimeError("CUDA is required for Ludus conditioning") + self._raster = raster + self._bev = bev + self._device = torch.device(device) + if self._device.type != "cuda": + raise ValueError("Ludus conditioning requires a CUDA device") + self._context = LudusCudaTimestampedContext(device=self._device) + self._context.set_depth_scaling(True) + self._context.set_msaa_samples(4) + self._context.set_max_tessellation_levels(cube=3) + self._context.set_line_widths( + polyline_regular=float(raster.line_width_px), + polyline_bev=max(1.5, float(raster.line_width_px) * 0.2), + ego_traj_regular=float(raster.pole_width_px), + ego_traj_bev=max(1.5, float(raster.pole_width_px) * 0.4), + wireframe=4.0, + ) + self._scene_id: int | None = None + self._dynamic_scene: MutableObjectSceneBuffer | None = None + self._camera_ids: dict[str, int] = {} + self._sensor_to_rig: dict[str, Tensor] = {} + self._selected_camera: str | None = None + self._bev_camera_id: int | None = None + self._bev_sensor_pose: Tensor | None = None + self._closed = False + + def load_scene(self, scene: SceneDefinition) -> None: + """Upload scene geometry and main/BEV cameras on the current thread.""" + if self._closed: + raise RuntimeError("Condition renderer is closed") + self._context.clear_scenes() + loaded = load_ludus_scene( + scene.scene_path, + device=self._device, + target_resolution=self._raster.resolution_wh, + include_ego_trajectory=False, + include_ego_obstacle=False, + ) + SceneAdapter(loaded) + cameras = list(loaded.cameras) + self._camera_ids = dict(loaded.camera_name_to_id) + self._sensor_to_rig = dict(loaded.sensor_to_rig) + self._selected_camera = scene.selected_camera.clipgt_name + if self._bev.enabled: + self._bev_camera_id = len(cameras) + cameras.append(_build_bev_camera(self._bev, self._device)) + self._camera_ids[_BEV_CAMERA_NAME] = self._bev_camera_id + self._bev_sensor_pose = _bev_sensor_to_rig( + height_m=self._bev.height_m, + tilt_deg=self._bev.tilt_deg, + device=self._device, + ) + self._sensor_to_rig[_BEV_CAMERA_NAME] = self._bev_sensor_pose + self._context.upload_cameras(cameras) + base_scene = loaded.timestamped_scene + self._scene_id = self._context.upload_scene(base_scene) + self._dynamic_scene = MutableObjectSceneBuffer( + self._context, + self._scene_id, + base_scene, + device=self._device, + ) + + def render(self, trajectory: TrajectoryChunk) -> ConditionBatch: + """Render a model-ready camera tensor and an optional HUD BEV tensor.""" + if self._closed: + raise RuntimeError("Condition renderer is closed") + if ( + self._scene_id is None + or self._dynamic_scene is None + or self._selected_camera is None + ): + raise RuntimeError("load_scene() must run before render()") + self._dynamic_scene.update(trajectory.dynamic_actors) + self._scene_id = self._dynamic_scene.scene_id + poses = torch.from_numpy( + np.ascontiguousarray(trajectory.rig_poses_world, dtype=np.float32) + ).to(self._device) + main = self._render_camera( + poses=poses, + timestamps_us=trajectory.timestamps_us, + camera_id=self._camera_ids[self._selected_camera], + sensor_to_rig=self._sensor_to_rig[self._selected_camera], + camera_type=CAMERA_TYPE_REGULAR, + resolution=(self._raster.height, self._raster.width), + ) + hdmap = _normalize_hwc(main).unsqueeze(0).unsqueeze(0) + bev = None + if self._bev_camera_id is not None and self._bev_sensor_pose is not None: + bev_hwc = self._render_camera( + poses=_level_rig_poses_for_bev(poses), + timestamps_us=trajectory.timestamps_us, + camera_id=self._bev_camera_id, + sensor_to_rig=self._bev_sensor_pose, + camera_type=CAMERA_TYPE_BEV, + resolution=(self._bev.height, self._bev.width), + ) + bev = _normalize_hwc(bev_hwc) + return ConditionBatch(hdmap_bvtchw=hdmap, bev_tchw=bev) + + def close(self) -> None: + """Release all scene references on the owning thread.""" + if self._closed: + return + self._closed = True + self._dynamic_scene = None + self._context.clear_scenes() + + def _render_camera( + self, + *, + poses: Tensor, + timestamps_us: np.ndarray, + camera_id: int, + sensor_to_rig: Tensor, + camera_type: int, + resolution: tuple[int, int], + ) -> Tensor: + assert self._scene_id is not None + camera_to_world = torch.einsum( + "nij,jk->nik", + poses, + sensor_to_rig.to(self._device), + ) + images = self._context.render_uniform( + scene_id=self._scene_id, + camera_id=camera_id, + timestamps_us=timestamps_us.tolist(), + camera_type_id=camera_type, + camera_poses=torch.linalg.inv(camera_to_world), + resolution=resolution, + ) + rgb = images[..., :3] + if self._context.needs_vflip: + rgb = rgb.flip(1) + if rgb.dtype != torch.uint8: + rgb = (rgb.clamp(0.0, 1.0) * 255.0 + 0.5).to(torch.uint8) + return rgb.detach().contiguous() + + +def _normalize_hwc(frames: Tensor) -> Tensor: + return frames.permute(0, 3, 1, 2).to(torch.bfloat16) / 127.5 - 1.0 + + +def _build_bev_camera(bev: BevConfig, device: torch.device) -> FThetaCamera: + cx = bev.width / 2.0 + cy = bev.height / 2.0 + focal = (bev.height / 2.0) / math.tan(math.radians(bev.fov_deg) / 2.0) + diagonal = math.hypot(bev.width / 2.0, bev.height / 2.0) + return FThetaCamera( + principal_point=torch.tensor([cx, cy], device=device, dtype=torch.float32), + image_size=torch.tensor( + [float(bev.width), float(bev.height)], + device=device, + dtype=torch.float32, + ), + fw_poly=torch.tensor( + [0.0, focal, 0.0, focal / 3.0, 0.0, 2.0 * focal / 15.0], + device=device, + dtype=torch.float32, + ), + max_ray_angle=math.atan(diagonal / focal), + depth_max=max(150.0, bev.height_m * 4.0), + ) + + +def _level_rig_poses_for_bev(poses: Tensor) -> Tensor: + rotation = poses[..., :3, :3] + forward_xy = rotation[..., :2, 0] + left_xy = rotation[..., :2, 1] + yaw = torch.where( + torch.linalg.vector_norm(forward_xy, dim=-1) > 1.0e-4, + torch.atan2(forward_xy[..., 1], forward_xy[..., 0]), + torch.atan2(-left_xy[..., 0], left_xy[..., 1]), + ) + result = torch.zeros_like(poses) + result[..., 0, 0] = torch.cos(yaw) + result[..., 0, 1] = -torch.sin(yaw) + result[..., 1, 0] = torch.sin(yaw) + result[..., 1, 1] = torch.cos(yaw) + result[..., 2, 2] = 1.0 + result[..., :3, 3] = poses[..., :3, 3] + result[..., 3, 3] = 1.0 + return result + + +def _bev_sensor_to_rig( + *, height_m: float, tilt_deg: float, device: torch.device +) -> Tensor: + theta = math.radians(tilt_deg) + cos_theta = math.cos(theta) + sin_theta = math.sin(theta) + return torch.tensor( + [ + [sin_theta, 0.0, cos_theta, 0.0], + [0.0, 1.0, 0.0, 0.0], + [-cos_theta, 0.0, sin_theta, height_m], + [0.0, 0.0, 0.0, 1.0], + ], + device=device, + dtype=torch.float32, + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/config.py b/apps/omnidreams_game_engine/omnidreams_game_engine/config.py new file mode 100644 index 000000000..2645614a4 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/config.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Configuration for simulation and conditioning components.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +ComputeDeviceName = Literal["automatic", "cuda", "vulkan"] + + +@dataclass(frozen=True, slots=True) +class ChunkConfig: + """Frame cadence used by low-level trajectory helpers.""" + + fps: int = 30 + initial_chunk_frames: int = 5 + chunk_frames: int = 8 + + @property + def frame_interval_s(self) -> float: + return 1.0 / self.fps + + @property + def frame_interval_us(self) -> int: + return round(1_000_000 / self.fps) + + +@dataclass(frozen=True, slots=True) +class RasterConfig: + """Main-camera semantic raster settings.""" + + width: int = 1280 + height: int = 704 + compute_device: ComputeDeviceName = "cuda" + sync_gpu_timing: bool = False + perf_log_interval_frames: int = 20 + near_plane_m: float = 0.1 + far_plane_m: float = 200.0 + fog_start_m: float = 40.0 + fog_end_m: float = 140.0 + fog_power: float = 1.5 + triangle_raytrace_distance_m: float = 25.0 + triangle_raytrace_edge_samples: int = 8 + lane_segment_interval_m: float = 0.05 + polyline_segment_interval_m: float = 0.8 + line_width_px: float = 12.0 + pole_width_px: float = 5.0 + dual_line_offset_m: float = 0.10 + depth_clear_m: float = 1.0e6 + + @property + def resolution_wh(self) -> tuple[int, int]: + """Return width and height in image-library order.""" + return self.width, self.height + + +@dataclass(frozen=True, slots=True) +class BevConfig: + """Top-down semantic view used by the taxi HUD.""" + + enabled: bool = True + width: int = 1024 + height: int = 1024 + height_m: float = 75.0 + fov_deg: float = 60.0 + tilt_deg: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class VehicleConfig: + """Generic vehicle and rigid-body tuning.""" + + wheel_base_m: float = 2.8 + max_steer_rad: float = 0.5 + steer_rate_rad_per_s: float = 0.55 + steer_return_rate_rad_per_s: float = 0.9 + speed_limit_enabled: bool = True + max_speed_mps: float = 31.2928 + max_reverse_speed_mps: float = 6.0 + max_accel_mps2: float = 3.5 + max_brake_mps2: float = 6.0 + max_lateral_accel_mps2: float = 6.2 + drag_mps2: float = 0.7 + mass_kg: float = 1_550.0 + tire_grip: float = 1.35 + rolling_resistance: float = 0.015 + aero_drag_coefficient: float = 0.42 + collision_restitution: float = 0.22 + collision_friction: float = 0.65 + max_collision_yaw_rate_radps: float = 0.35 + suspension_stiffness: float = 42.0 + suspension_damping: float = 9.0 + suspension_travel_m: float = 0.22 + suspension_visual_gain: float = 0.15 + max_body_roll_rad: float = 0.5 + max_body_pitch_rad: float = 0.5 + actor_collision_enabled: bool = True + static_collision_enabled: bool = True + aabb_length_m: float = 4.8 + aabb_width_m: float = 2.0 + aabb_height_m: float = 1.6 + + +@dataclass(frozen=True, slots=True) +class DriverInputConfig: + """Deterministic keyboard-to-command tuning.""" + + steering_scale: float = 0.75 + steering_rate_per_s: float = 0.6 + steering_return_rate_per_s: float = 1.4 diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/contracts.py b/apps/omnidreams_game_engine/omnidreams_game_engine/contracts.py new file mode 100644 index 000000000..2f09f0c9e --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/contracts.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Narrow dependency-injection contracts for model-thread games.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from omnidreams_game_engine.types import ( + ConditionBatch, + DriverCommand, + DynamicActorTrajectory, + SceneDefinition, + TrajectoryChunk, + VehicleState, +) + + +class SimulationWorld(Protocol): + """Mutable vehicle/physics simulation owned by the model thread.""" + + @property + def current_state(self) -> VehicleState: + """Return the current boundary state.""" + ... + + def pose_chunk( + self, + *, + commands: tuple[DriverCommand, ...], + chunk_size: int, + frame_interval_s: float, + extrapolation_offset_s: float, + ) -> TrajectoryChunk: + """Advance a frame-aligned trajectory chunk.""" + ... + + def close(self) -> None: + """Release simulation resources.""" + ... + + +@dataclass(frozen=True, slots=True) +class GameUpdate: + """Game-owned results aligned with one simulated trajectory.""" + + frames: tuple[object, ...] + dynamic_actors: tuple[DynamicActorTrajectory, ...] = () + + +class GameRules(Protocol): + """Application rules injected into the reusable game engine.""" + + @property + def is_running(self) -> bool: + """Whether another world-model block should be generated.""" + ... + + def snapshot(self, vehicle_state: VehicleState) -> object: + """Return immutable game state at a simulation boundary.""" + ... + + def advance_frames( + self, + trajectory: TrajectoryChunk, + frame_interval_s: float, + ) -> GameUpdate: + """Advance rules once per simulated frame.""" + ... + + def submit_text(self, value: str, vehicle_state: VehicleState) -> object: + """Consume application text such as a leaderboard name.""" + ... + + +class ConditionRenderer(Protocol): + """Render model conditioning on the owning model thread.""" + + def load_scene(self, scene: SceneDefinition) -> None: + """Upload immutable scene data.""" + ... + + def render(self, trajectory: TrajectoryChunk) -> ConditionBatch: + """Render synchronized semantic camera and optional BEV frames.""" + ... + + def close(self) -> None: + """Release renderer resources.""" + ... diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py b/apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py new file mode 100644 index 000000000..fb02c99e4 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CUDA HD-map box construction for simulated object-graph trajectories.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, Protocol + +import numpy as np +import torch + +from ludus_renderer._ops.primitives import ( + CUBE_FLAG_WIREFRAME, + PRIM_OBSTACLE, + CubePool, + TimestampedScene, +) +from omnidreams_game_engine.colors import BBOX_V3_COLORS + + +def _get_obstacle_color( + object_type: str, +) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + """Return the canonical dark/light semantic colors for an actor type.""" + return BBOX_V3_COLORS.get(object_type, BBOX_V3_COLORS["Others"]) + + +class ObjectTrajectory(Protocol): + """Structural input accepted from simulation clients.""" + + entity_id: str + object_type: str + timestamps_us: Any + translations_world: Any + orientations_xyzw: Any + dimensions_lwh: Any + is_simulated: bool + + +class MutableSceneContext(Protocol): + """Rendering operations required by :class:`MutableObjectSceneBuffer`.""" + + def update_cube_pool( + self, scene_id: int, prim_type_id: int, pool: CubePool + ) -> bool: ... + + def update_cube_pool_at_index( + self, scene_id: int, pool_index: int, pool: CubePool + ) -> bool: ... + + def replace_scene(self, scene_id: int, scene: TimestampedScene) -> int: ... + + +class MutableObjectSceneBuffer: + """Own dynamic-object topology and reuse policy for one CUDA scene slot.""" + + def __init__( + self, + context: MutableSceneContext, + scene_id: int, + base_scene: TimestampedScene, + *, + device: torch.device, + ) -> None: + self._context = context + self._scene_id = scene_id + self._base_scene = base_scene + self._device = device + self._initialized = False + self._actor_partition: tuple[tuple[str, ...], tuple[str, ...]] | None = None + self._dynamic_pool_index: int | None = None + + @property + def scene_id(self) -> int: + """Return the stable renderer scene slot.""" + return self._scene_id + + def update(self, actors: Sequence[ObjectTrajectory]) -> None: + """Update simulated tracks while retaining immutable tracks on CUDA.""" + if not actors: + if self._initialized: + static_pools = [ + pool + for pool in (self._base_scene.cube_pools or []) + if pool.prim_type_id != PRIM_OBSTACLE + ] + replacement = TimestampedScene( + polyline_pools=self._base_scene.polyline_pools, + polygon_pools=self._base_scene.polygon_pools, + cube_pools=static_pools, + ) + self._scene_id = self._context.replace_scene( + self._scene_id, replacement + ) + self._initialized = False + self._actor_partition = None + self._dynamic_pool_index = None + return + + static_actors = tuple( + actor for actor in actors if not getattr(actor, "is_simulated", False) + ) + dynamic_actors = tuple( + actor for actor in actors if getattr(actor, "is_simulated", False) + ) + partition = ( + tuple(actor.entity_id for actor in static_actors), + tuple(actor.entity_id for actor in dynamic_actors), + ) + if ( + self._initialized + and partition == self._actor_partition + and self._dynamic_pool_index is None + ): + return + if ( + self._initialized + and partition == self._actor_partition + and self._dynamic_pool_index is not None + ): + dynamic_pool = build_hdmap_object_pool(dynamic_actors, device=self._device) + if self._context.update_cube_pool_at_index( + self._scene_id, self._dynamic_pool_index, dynamic_pool + ): + return + + static_pools = [ + pool + for pool in (self._base_scene.cube_pools or []) + if pool.prim_type_id != PRIM_OBSTACLE + ] + actor_pools = [] + if static_actors: + actor_pools.append( + build_hdmap_object_pool(static_actors, device=self._device) + ) + dynamic_pool_index = None + if dynamic_actors: + dynamic_pool_index = len(static_pools) + len(actor_pools) + actor_pools.append( + build_hdmap_object_pool(dynamic_actors, device=self._device) + ) + replacement = TimestampedScene( + polyline_pools=self._base_scene.polyline_pools, + polygon_pools=self._base_scene.polygon_pools, + cube_pools=[*static_pools, *actor_pools], + ) + self._scene_id = self._context.replace_scene(self._scene_id, replacement) + self._initialized = True + self._actor_partition = partition + self._dynamic_pool_index = dynamic_pool_index + + +def build_hdmap_object_pool( + actors: Sequence[ObjectTrajectory], *, device: torch.device +) -> CubePool: + """Build the CUDA box pool consumed by RGB and BEV model inputs. + + Simulation clients normally provide NumPy arrays. Stage those arrays in + contiguous batches so an update performs one host-to-device transfer per + field instead of several small transfers per object. + """ + if not actors: + raise ValueError("actors must not be empty") + + def _host_array(value: Any, dtype: np.dtype[Any]) -> np.ndarray: + if isinstance(value, torch.Tensor): + value = value.detach().cpu().numpy() + return np.asarray(value, dtype=dtype) + + track_lengths_host = np.fromiter( + (len(actor.timestamps_us) for actor in actors), + dtype=np.int32, + count=len(actors), + ) + track_timestamps_host = np.concatenate( + [_host_array(actor.timestamps_us, np.dtype(np.int64)) for actor in actors] + ) + translations_host = np.concatenate( + [ + _host_array(actor.translations_world, np.dtype(np.float32)) + for actor in actors + ] + ) + quaternions_host = np.concatenate( + [_host_array(actor.orientations_xyzw, np.dtype(np.float32)) for actor in actors] + ) + scales_host = np.stack( + [_host_array(actor.dimensions_lwh, np.dtype(np.float32)) for actor in actors] + ) + colors_host = np.asarray( + [ + np.asarray(_get_obstacle_color(actor.object_type)).reshape(-1) + for actor in actors + ], + dtype=np.float32, + ) + + track_lengths = torch.as_tensor(track_lengths_host, device=device) + track_timestamps = torch.as_tensor(track_timestamps_host, device=device) + translations = torch.as_tensor(translations_host, device=device) + quaternions = torch.as_tensor(quaternions_host, device=device) + scales = torch.as_tensor(scales_host, device=device) + colors = torch.as_tensor(colors_host, device=device) + return CubePool( + timestamps_us=torch.unique(track_timestamps).sort()[0], + cube_ts_prefix_sum=torch.cumsum(track_lengths, dim=0, dtype=torch.int32), + track_timestamps_us=track_timestamps, + translations=translations, + quaternions=quaternions, + scales=scales, + colors=colors, + prim_type_id=PRIM_OBSTACLE, + render_flags=CUBE_FLAG_WIREFRAME, + ) + + +__all__ = [ + "MutableObjectSceneBuffer", + "MutableSceneContext", + "ObjectTrajectory", + "build_hdmap_object_pool", +] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py b/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py new file mode 100644 index 000000000..592a377a2 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Model-thread sequencing for simulation, rules, and conditioning.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +from omnidreams_game_engine.contracts import ConditionRenderer, GameRules, SimulationWorld +from omnidreams_game_engine.types import ConditionBatch, DriverCommand, TrajectoryChunk + + +@dataclass(frozen=True, slots=True) +class EngineStep: + """All synchronized non-model results for one autoregressive step.""" + + trajectory: TrajectoryChunk + game_frames: tuple[object, ...] + condition: ConditionBatch + + +class GameEngine: + """Advance one session's mutable game state on the model thread.""" + + def __init__( + self, + *, + simulation: SimulationWorld, + rules: GameRules, + condition_renderer: ConditionRenderer, + frame_interval_s: float, + ) -> None: + if frame_interval_s <= 0.0: + raise ValueError("frame_interval_s must be positive") + self.simulation = simulation + self.rules = rules + self.condition_renderer = condition_renderer + self.frame_interval_s = float(frame_interval_s) + self._closed = False + + @property + def is_running(self) -> bool: + """Return whether rules accept another generated block.""" + return not self._closed and self.rules.is_running + + @property + def current_game_frame(self) -> object: + """Return game state coherent with the current vehicle boundary.""" + return self.rules.snapshot(self.simulation.current_state) + + def submit_text(self, value: str) -> object: + """Forward application text at the current simulation boundary.""" + return self.rules.submit_text(value, self.simulation.current_state) + + def step(self, commands: tuple[DriverCommand, ...]) -> EngineStep: + """Advance exactly one model block.""" + if self._closed: + raise RuntimeError("GameEngine is closed") + if not commands: + raise ValueError("A game-engine step requires at least one command") + trajectory = self.simulation.pose_chunk( + commands=commands, + chunk_size=len(commands), + frame_interval_s=self.frame_interval_s, + extrapolation_offset_s=0.0, + ) + update = self.rules.advance_frames(trajectory, self.frame_interval_s) + if len(update.frames) != len(commands): + raise ValueError("Game frames must align with simulated commands") + trajectory = replace( + trajectory, + dynamic_actors=(*trajectory.dynamic_actors, *update.dynamic_actors), + ) + condition = self.condition_renderer.render(trajectory) + if int(condition.hdmap_bvtchw.shape[2]) != len(commands): + raise ValueError("Condition frames must align with simulated commands") + return EngineStep( + trajectory=trajectory, + game_frames=update.frames, + condition=condition, + ) + + def close(self) -> None: + """Release session-local physics and renderer resources.""" + if self._closed: + return + self._closed = True + try: + self.simulation.close() + finally: + self.condition_renderer.close() diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py new file mode 100644 index 000000000..0726e064f --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Semantic game-map loading, compilation, and previews.""" + +from omnidreams_game_engine.game_map._schema import ( + GAME_MAP_SUFFIX, + GameMapError, + GameMapHeader, + load_game_map_header, + resolve_seed_asset, +) +from omnidreams_game_engine.game_map.compiler import ( + CompiledGameMap, + compile_game_map, +) +from omnidreams_game_engine.game_map.loader import load_game_map +from omnidreams_game_engine.game_map.preview import write_game_map_preview +from omnidreams_game_engine.game_map.spawn_render import ( + SPAWN_RENDERER_VERSION, + render_spawn_first_frame, + write_spawn_first_frame_preview, +) +from omnidreams_game_engine.game_map.types import ( + GameMapBoundaryAttributes, + GameMapCurb, + GameMapElement, + GameMapLane, + GameMapLaneDivider, + GameMapLinearAttributes, + GameMapLineMarking, + GameMapNode, + GameMapParkingAccess, + GameMapRoad, + GameMapRoadBoundary, + GameMapSpawn, + GameMapTopology, + GameMapTrafficVehicle, + ResolvedGameMap, +) +from omnidreams_game_engine.game_map.vicinity import ( + GameMapVicinity, + GameMapVicinityResolver, +) + +__all__ = [ + "CompiledGameMap", + "GAME_MAP_SUFFIX", + "GameMapError", + "GameMapBoundaryAttributes", + "GameMapCurb", + "GameMapElement", + "GameMapHeader", + "GameMapLane", + "GameMapLaneDivider", + "GameMapLinearAttributes", + "GameMapLineMarking", + "GameMapNode", + "GameMapParkingAccess", + "GameMapRoad", + "GameMapRoadBoundary", + "GameMapSpawn", + "GameMapTrafficVehicle", + "GameMapTopology", + "GameMapVicinity", + "GameMapVicinityResolver", + "ResolvedGameMap", + "SPAWN_RENDERER_VERSION", + "compile_game_map", + "load_game_map", + "load_game_map_header", + "resolve_seed_asset", + "render_spawn_first_frame", + "write_game_map_preview", + "write_spawn_first_frame_preview", +] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py new file mode 100644 index 000000000..9b587db5a --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py @@ -0,0 +1,389 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Strict field, profile, and shared configuration parsing for game maps.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from importlib import resources +from pathlib import Path +from typing import Any + +import numpy as np +import yaml + +from omnidreams_game_engine.game_map.types import ( + GameMapLinearAttributes, + GameMapVisualVariant, +) + +GAME_MAP_SUFFIX = ".robotaxi.yaml" +"""Filename suffix for authored node-graph game maps.""" + +_SCHEMA_VERSION = 1 +_REQUIRED_ROOT_FIELDS = frozenset( + { + "schema_version", + "id", + "name", + "compiler", + "nodes", + "roads", + "spawns", + } +) +_OPTIONAL_ROOT_FIELDS = frozenset({"profiles", "traffic", "traffic_count"}) + +_PROFILE_ATTRIBUTE_FIELDS = frozenset( + { + "lane_width_m", + "curb_offset_m", + "lanes", + "speed_limit_mps", + "curb", + "lane_marking", + "divider_markings", + "culdesac_radius_m", + } +) + + +class GameMapError(ValueError): + """Invalid semantic game-map definition.""" + + +@dataclass(frozen=True) +class GameMapHeader: + """Game-map metadata read without compiling geometry.""" + + map_id: str + name: str + variants: tuple[GameMapVisualVariant, ...] + source_path: Path + + +@dataclass(frozen=True) +class _CompilerSettings: + sample_spacing_m: float + ground_margin_m: float + intersection_connector_samples: int + + def as_dict(self) -> dict[str, object]: + """Return settings as stable cache metadata.""" + return dict(self.__dict__) + + +@dataclass(frozen=True) +class _Profile: + """Partial reusable defaults for resolved element attributes.""" + + profile_id: str + """Stable author-defined profile identifier.""" + + values: dict[str, object] + """Validated partial attribute values.""" + + +@dataclass +class _LaneBuild: + lane_id: str + element_id: str + centerline: np.ndarray + left_edge: np.ndarray + right_edge: np.ndarray + roadside_edge: np.ndarray + speed_limit_mps: float + marking_style: str + marking_color: str + start_endpoint: str + end_endpoint: str + successors: list[str] + allows_taxi_stops: bool + left_marking_style: str | None = None + left_marking_color: str | None = None + right_marking_style: str | None = None + right_marking_color: str | None = None + conditioning_visible: bool = True + + +def _mapping(value: object, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise GameMapError(f"{context} must be a mapping") + if any(not isinstance(key, str) for key in value): + raise GameMapError(f"{context} keys must be strings") + return dict(value) + + +def _sequence(value: object, context: str) -> list[Any]: + if not isinstance(value, list): + raise GameMapError(f"{context} must be a sequence") + return value + + +def _positive_float(value: object, context: str) -> float: + number = _finite_float(value, context) + if number <= 0.0: + raise GameMapError(f"{context} must be positive") + return number + + +def _nonnegative_float(value: object, context: str) -> float: + number = _finite_float(value, context) + if number < 0.0: + raise GameMapError(f"{context} must be nonnegative") + return number + + +def _finite_float(value: object, context: str) -> float: + if isinstance(value, bool): + raise GameMapError(f"{context} must be a number") + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise GameMapError(f"{context} must be a number") from exc + if not math.isfinite(number): + raise GameMapError(f"{context} must be finite") + return number + + +def _read_document(path: Path) -> dict[str, Any]: + path = Path(path).expanduser().resolve() + if not path.is_file(): + raise GameMapError(f"Game-map path does not exist or is not a file: {path}") + if not path.name.endswith(GAME_MAP_SUFFIX): + raise GameMapError(f"Game maps must use the {GAME_MAP_SUFFIX} suffix") + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise GameMapError(f"Could not parse {path}: {exc}") from exc + return _mapping(raw, "map document") + + +def _parse_map_identity(doc: dict[str, Any]) -> tuple[str, str]: + version = doc.get("schema_version") + if version != _SCHEMA_VERSION: + raise GameMapError( + f"Unsupported schema_version {version!r}; expected {_SCHEMA_VERSION}" + ) + fields = set(doc) + missing = _REQUIRED_ROOT_FIELDS - fields + if missing: + raise GameMapError(f"Map is missing required fields {sorted(missing)}") + unknown = fields - (_REQUIRED_ROOT_FIELDS | _OPTIONAL_ROOT_FIELDS) + if unknown: + raise GameMapError(f"Map has unknown fields {sorted(unknown)}") + map_id = str(doc["id"]).strip() + name = str(doc["name"]).strip() + if not map_id: + raise GameMapError("Map id must not be empty") + if not name: + raise GameMapError("Map name must not be empty") + return map_id, name + + +def _parse_variants( + raw_spawn: dict[str, Any], source_path: Path +) -> tuple[GameMapVisualVariant, ...]: + variants_raw = _mapping(raw_spawn.get("variants"), "spawn.variants") + if "default" not in variants_raw: + raise GameMapError("Every spawn must define a default visual variant") + variants: list[GameMapVisualVariant] = [] + for name, raw_variant in variants_raw.items(): + variant = _mapping(raw_variant, f"variant {name!r}") + unknown = set(variant) - {"image", "prompt"} + if unknown: + raise GameMapError(f"Variant {name!r} has unknown fields {sorted(unknown)}") + image_value = variant.get("image") + image = None if image_value is None else str(image_value).strip() + prompt = str(variant.get("prompt", "")).strip() + if not prompt: + raise GameMapError(f"Variant {name!r} requires a non-empty prompt") + if image_value is not None and not image: + raise GameMapError(f"Variant {name!r} image must not be empty") + if image is not None: + resolve_seed_asset(source_path, image) + variants.append(GameMapVisualVariant(name=name, image=image, prompt=prompt)) + variants.sort(key=lambda item: (item.name != "default", item.name)) + return tuple(variants) + + +def load_game_map_header(path: Path) -> GameMapHeader: + """Load map name and default-spawn variants without resolving geometry.""" + source_path = Path(path).expanduser().resolve() + doc = _read_document(source_path) + map_id, name = _parse_map_identity(doc) + spawns = _sequence(doc.get("spawns"), "spawns") + if not spawns: + raise GameMapError("Map must define at least one spawn") + first_spawn = _mapping(spawns[0], "spawns[0]") + return GameMapHeader( + map_id=map_id, + name=name, + variants=_parse_variants(first_spawn, source_path), + source_path=source_path, + ) + + +def resolve_seed_asset(source_path: Path, reference: str) -> Path: + """Resolve a map-relative or package seed-image reference.""" + if reference.startswith("package://"): + location = reference.removeprefix("package://") + package, separator, resource = location.partition("/") + if not separator or not package or not resource: + raise GameMapError( + "Package assets must use package://package/path/to/resource" + ) + traversable = resources.files(package).joinpath(resource) + if not traversable.is_file(): + raise GameMapError(f"Seed image does not exist: {reference}") + return Path(str(traversable)) + path = Path(reference).expanduser() + if not path.is_absolute(): + path = source_path.parent / path + path = path.resolve() + if not path.is_file(): + raise GameMapError(f"Seed image does not exist: {path}") + return path + + +def _parse_attribute_values(raw: dict[str, Any], context: str) -> dict[str, object]: + """Validate and normalize partial profile-compatible attributes.""" + unknown = set(raw) - _PROFILE_ATTRIBUTE_FIELDS + if unknown: + raise GameMapError(f"{context} has unknown attributes {sorted(unknown)}") + result: dict[str, object] = {} + for key in ( + "lane_width_m", + "speed_limit_mps", + "culdesac_radius_m", + ): + if key in raw: + result[key] = _positive_float(raw[key], f"{context}.{key}") + if "curb_offset_m" in raw: + result["curb_offset_m"] = _nonnegative_float( + raw["curb_offset_m"], f"{context}.curb_offset_m" + ) + if "curb" in raw: + if type(raw["curb"]) is not bool: + raise GameMapError(f"{context}.curb must be a boolean") + result["curb"] = raw["curb"] + if "lanes" in raw: + directions = tuple( + str(value).lower() for value in _sequence(raw["lanes"], f"{context}.lanes") + ) + if not directions or any( + value not in {"forward", "backward"} for value in directions + ): + raise GameMapError(f"{context}.lanes must contain forward/backward values") + result["lanes"] = directions + if "lane_marking" in raw: + marking = _mapping(raw["lane_marking"], f"{context}.lane_marking") + if set(marking) != {"style", "color"}: + raise GameMapError(f"{context}.lane_marking requires style and color") + result["lane_marking"] = ( + str(marking["style"]).upper(), + str(marking["color"]).upper(), + ) + if "divider_markings" in raw: + dividers: list[tuple[str, str]] = [] + for index, value in enumerate( + _sequence(raw["divider_markings"], f"{context}.divider_markings") + ): + divider = _mapping(value, f"{context}.divider_markings[{index}]") + if set(divider) != {"style", "color"}: + raise GameMapError( + f"{context}.divider_markings[{index}] requires style and color" + ) + dividers.append( + (str(divider["style"]).upper(), str(divider["color"]).upper()) + ) + result["divider_markings"] = tuple(dividers) + return result + + +def _parse_profiles(doc: dict[str, Any]) -> dict[str, _Profile]: + """Parse optional partial profile defaults.""" + raw_profiles = _mapping(doc.get("profiles", {}), "profiles") + profiles: dict[str, _Profile] = {} + for profile_id, raw_value in raw_profiles.items(): + if not profile_id: + raise GameMapError("Profile ids must not be empty") + raw = _mapping(raw_value, f"profile {profile_id!r}") + profiles[profile_id] = _Profile( + profile_id=profile_id, + values=_parse_attribute_values(raw, f"profile {profile_id!r}"), + ) + return profiles + + +def _parse_compiler_settings(doc: dict[str, Any]) -> _CompilerSettings: + raw = _mapping(doc.get("compiler"), "compiler") + expected = { + "sample_spacing_m", + "ground_margin_m", + "intersection_connector_samples", + } + if set(raw) != expected: + raise GameMapError(f"compiler must contain exactly {sorted(expected)}") + samples = raw["intersection_connector_samples"] + if type(samples) is not int or samples < 2: + raise GameMapError( + "compiler.intersection_connector_samples must be an integer >= 2" + ) + return _CompilerSettings( + sample_spacing_m=_positive_float( + raw["sample_spacing_m"], "compiler.sample_spacing_m" + ), + ground_margin_m=_nonnegative_float( + raw["ground_margin_m"], "compiler.ground_margin_m" + ), + intersection_connector_samples=samples, + ) + + +def _offset_polyline(points: np.ndarray, offset_m: float) -> np.ndarray: + tangents = np.gradient(points, axis=0) + lengths = np.linalg.norm(tangents, axis=1) + tangents = tangents / np.maximum(lengths[:, None], 1.0e-9) + normals = np.column_stack((-tangents[:, 1], tangents[:, 0])) + return points + normals * offset_m + + +def _xyz(points_xy: np.ndarray) -> np.ndarray: + return np.column_stack((points_xy, np.zeros(len(points_xy)))).astype(np.float32) + + +def _surface_for_road(centerline: np.ndarray, width_m: float) -> np.ndarray: + left = _offset_polyline(centerline, width_m * 0.5) + right = _offset_polyline(centerline, -width_m * 0.5) + return _xyz(np.concatenate((left, right[::-1], left[:1]), axis=0)) + + +def _segments(points: np.ndarray) -> np.ndarray: + if len(points) < 2: + return np.empty((0, 2, 3), dtype=np.float32) + return np.stack((points[:-1], points[1:]), axis=1).astype(np.float32) + + +def _lane_edge_markings( + attributes: GameMapLinearAttributes, index: int, direction: str +) -> tuple[tuple[str, str], tuple[str, str]]: + virtual = ("VIRTUAL", "WHITE") + above = attributes.divider_markings[index - 1] if index > 0 else virtual + below = ( + attributes.divider_markings[index] + if index < len(attributes.directions) - 1 + else virtual + ) + return (below, above) if direction == "backward" else (above, below) + + +def _bezier( + start: np.ndarray, control: np.ndarray, end: np.ndarray, samples: int +) -> np.ndarray: + t = np.linspace(0.0, 1.0, samples, dtype=np.float32)[:, None] + return ((1.0 - t) ** 2 * start + 2.0 * (1.0 - t) * t * control + t**2 * end).astype( + np.float32 + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py new file mode 100644 index 000000000..3a5b9b3dd --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py @@ -0,0 +1,401 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Content-addressed ClipGT compilation for semantic game maps.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +import yaml +from filelock import FileLock +from PIL import Image + +from omnidreams_game_engine import camera_defaults +from omnidreams_game_engine.camera_defaults import DEFAULT_FRONT_CAMERA_LOGICAL_NAME +from omnidreams_game_engine.game_map import spawn_render +from omnidreams_game_engine.game_map._schema import resolve_seed_asset +from omnidreams_game_engine.game_map.loader import load_game_map +from omnidreams_game_engine.game_map.types import ( + ResolvedGameMap, + game_map_to_dict, +) +from omnidreams_game_engine.math3d import rig_pose_from_state +from omnidreams_game_engine.ply_io import save_mesh_vf +from omnidreams_game_engine.scene_fixture import _calibration_row + +# Pre-release maps and compiler output stay at version 1. Do not increment this +# during development; a future release process owns version changes. +_COMPILER_VERSION = "1" +_START_TIMESTAMP_US = 1_700_000_000_000_000 + + +@dataclass(frozen=True) +class CompiledGameMap: + """Resolved map and its private renderer archive.""" + + source_path: Path + """Canonical semantic YAML path.""" + + archive_path: Path + """Content-addressed private USDZ/ClipGT archive.""" + + game_map: ResolvedGameMap + """Resolved semantic runtime map.""" + + cache_hit: bool + """Whether compilation reused an existing archive.""" + + +def _cache_root() -> Path: + return ( + Path( + os.path.expanduser( + os.environ.get("FLASHDREAMS_CACHE_DIR", "~/.cache/flashdreams") + ) + ) + / "omnidreams-game-engine" + / "game-maps" + ) + + +def _digest(game_map: ResolvedGameMap) -> str: + hasher = hashlib.sha256() + hasher.update(_COMPILER_VERSION.encode()) + hasher.update(Path(__file__).read_bytes()) + hasher.update(game_map.source_path.read_bytes()) + resolved = game_map_to_dict(game_map) + resolved.pop("source_path", None) + hasher.update(json.dumps(resolved, sort_keys=True, separators=(",", ":")).encode()) + for spawn in game_map.spawns: + for variant in spawn.variants: + hasher.update(variant.name.encode()) + hasher.update(variant.prompt.encode()) + if variant.image is None: + hasher.update(b"generated-spawn-first-frame") + hasher.update(spawn_render.SPAWN_RENDERER_VERSION.encode()) + hasher.update(Path(spawn_render.__file__).read_bytes()) + hasher.update(Path(camera_defaults.__file__).read_bytes()) + else: + asset = resolve_seed_asset(game_map.source_path, variant.image) + hasher.update(asset.read_bytes()) + return hasher.hexdigest() + + +def _point(point: np.ndarray) -> dict[str, float]: + return {"x": float(point[0]), "y": float(point[1]), "z": float(point[2])} + + +def _key(game_map: ResolvedGameMap, label: str) -> dict[str, str]: + return { + "clip_id": game_map.map_id, + "label_class_id": label, + "map_id": game_map.map_id, + "map_id_version": f"v{game_map.schema_version}", + } + + +def _lane_rows(game_map: ResolvedGameMap) -> list[dict[str, object]]: + shared_edges = { + lane_edge + for divider in game_map.lane_dividers + for lane_edge in divider.lane_edges + } + rows: list[dict[str, object]] = [] + for lane in game_map.lanes: + if not lane.conditioning_visible: + continue + left_shared = (lane.lane_id, "left") in shared_edges + right_shared = (lane.lane_id, "right") in shared_edges + left_style, left_color = lane.left_marking_style, lane.left_marking_color + right_style, right_color = lane.right_marking_style, lane.right_marking_color + rows.append( + { + "key": _key(game_map, lane.lane_id), + "lane": { + "left_rail": [_point(point) for point in lane.left_edge_world], + "right_rail": [_point(point) for point in lane.right_edge_world], + "vehicle_types": ["CAR"], + "map_end": "NONE", + "use_types": [], + "left_edge_styles": ( + [left_style if left_shared else "VIRTUAL"] + if lane.allows_taxi_stops + else [] + ), + "right_edge_styles": ( + [right_style if right_shared else "VIRTUAL"] + if lane.allows_taxi_stops + else [] + ), + "left_edge_colors": [left_color if left_shared else "WHITE"], + "right_edge_colors": [right_color if right_shared else "WHITE"], + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ) + return rows + + +def _lane_line_rows(game_map: ResolvedGameMap) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for divider in game_map.lane_dividers: + rows.append( + { + "key": _key(game_map, f"lane_line:{divider.divider_id}"), + "lane_line": { + "line_rail": [_point(point) for point in divider.polyline_world], + "styles": [divider.style], + "colors": [divider.color], + "left_driving_direction": ["FORWARD"], + "right_driving_direction": ["FORWARD"], + "is_first_point_physical_end": "false", + "is_last_point_physical_end": "false", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ) + for marking in game_map.line_markings: + rows.append( + { + "key": _key(game_map, f"lane_line:{marking.marking_id}"), + "lane_line": { + "line_rail": [_point(point) for point in marking.polyline_world], + "styles": [marking.style], + "colors": [marking.color], + "left_driving_direction": ["FORWARD"], + "right_driving_direction": ["FORWARD"], + "is_first_point_physical_end": "true", + "is_last_point_physical_end": "true", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ) + return rows + + +def _boundary_rows(game_map: ResolvedGameMap) -> list[dict[str, object]]: + return [ + { + "key": _key(game_map, boundary.boundary_id), + "road_boundary": { + "location": [_point(point) for point in boundary.polyline_world], + "category": "road_boundary", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + for element in game_map.elements + for boundary in element.road_boundaries + ] + + +def _intersection_rows(game_map: ResolvedGameMap) -> list[dict[str, object]]: + return [ + { + "key": _key(game_map, f"intersection:{element.element_id}"), + "intersection_area": { + "location": [_point(point) for point in element.surface_world], + "category": "intersection", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + for element in game_map.elements + if element.element_type == "intersection" + ] + + +def _road_marking_rows(game_map: ResolvedGameMap) -> list[dict[str, object]]: + roadnet_masks = [ + { + "key": _key(game_map, f"roadnet_mask:{element.element_id}"), + "road_marking": { + "location": [_point(point) for point in element.surface_world], + "category": "ROI_POLYGON_ROADNET_MASK", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + for element in game_map.elements + if element.element_type == "parking_lot" + ] + parking_space_markings = [ + { + "key": _key(game_map, f"road_marking:{index}"), + "road_marking": { + "location": [_point(point) for point in polygon], + "category": "ROI_POLYGON_ROAD_MARKING", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + for index, polygon in enumerate(game_map.road_marking_polygons_world) + ] + return roadnet_masks + parking_space_markings + + +def _write_parquet( + archive: zipfile.ZipFile, name: str, rows: list[dict[str, object]] +) -> None: + if not rows: + return + buffer = io.BytesIO() + pq.write_table(pa.Table.from_pylist(rows), buffer) + archive.writestr(name, buffer.getvalue()) + + +def _write_image(archive: zipfile.ZipFile, name: str, source: Path) -> None: + buffer = io.BytesIO() + with Image.open(source) as image: + image.convert("RGB").save(buffer, format="PNG") + archive.writestr(name, buffer.getvalue()) + + +def _write_image_array( + archive: zipfile.ZipFile, name: str, image_array: np.ndarray +) -> None: + buffer = io.BytesIO() + Image.fromarray(image_array).save(buffer, format="PNG") + archive.writestr(name, buffer.getvalue()) + + +def _metadata(game_map: ResolvedGameMap) -> dict[str, object]: + return { + "scene_id": game_map.map_id, + "dataset_hash": "semantic-game-map", + "is_resumable": False, + "sensors": { + "camera_ids": [DEFAULT_FRONT_CAMERA_LOGICAL_NAME], + "lidar_ids": [], + }, + "time_range": { + "start": _START_TIMESTAMP_US, + "end": _START_TIMESTAMP_US + 33_333, + }, + "version_string": f"omnidreams-game-map-{_COMPILER_VERSION}", + } + + +def _trajectory(game_map: ResolvedGameMap) -> dict[str, object]: + spawn = game_map.default_spawn + pose = rig_pose_from_state( + float(spawn.position_world[0]), + float(spawn.position_world[1]), + float(spawn.position_world[2]), + spawn.yaw_rad, + ).tolist() + return { + "rig_trajectories": [ + { + "T_rig_worlds": [pose, pose], + "T_rig_world_timestamps_us": [ + _START_TIMESTAMP_US, + _START_TIMESTAMP_US + 33_333, + ], + } + ] + } + + +def _write_archive(path: Path, game_map: ResolvedGameMap) -> None: + spawn = game_map.default_spawn + generated_image: np.ndarray | None = None + with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_STORED) as archive: + archive.writestr( + "metadata.yaml", yaml.safe_dump(_metadata(game_map), sort_keys=True) + ) + archive.writestr("rig_trajectories.json", json.dumps(_trajectory(game_map))) + archive.writestr( + "game_map.json", + json.dumps(game_map_to_dict(game_map), separators=(",", ":")), + ) + archive.writestr( + "mesh_ground.ply", + save_mesh_vf(game_map.ground_vertices, game_map.ground_faces), + ) + for variant in spawn.variants: + suffix = "" if variant.name == "default" else f"_{variant.name}" + archive.writestr(f"prompt{suffix}.txt", variant.prompt) + image_name = f"first_image{suffix}.png" + if variant.image is None: + if generated_image is None: + generated_image = spawn_render.render_spawn_first_frame( + game_map, spawn + ) + _write_image_array( + archive, + image_name, + generated_image, + ) + else: + _write_image( + archive, + image_name, + resolve_seed_asset(game_map.source_path, variant.image), + ) + _write_parquet( + archive, "clipgt/calibration_estimate.parquet", _calibration_row() + ) + _write_parquet(archive, "clipgt/lane.parquet", _lane_rows(game_map)) + _write_parquet(archive, "clipgt/lane_line.parquet", _lane_line_rows(game_map)) + _write_parquet( + archive, "clipgt/road_boundary.parquet", _boundary_rows(game_map) + ) + _write_parquet( + archive, "clipgt/intersection_area.parquet", _intersection_rows(game_map) + ) + _write_parquet( + archive, "clipgt/road_marking.parquet", _road_marking_rows(game_map) + ) + + +def compile_game_map( + path: Path, + *, + cache_root: Path | None = None, + force: bool = False, +) -> CompiledGameMap: + """Compile a map, optionally replacing its valid cached archive.""" + game_map = load_game_map(path) + digest = _digest(game_map) + root = _cache_root() if cache_root is None else Path(cache_root) + output_dir = root / digest + archive_path = output_dir / f"{game_map.map_id}.usdz" + lock = FileLock(str(root / f"{digest}.lock")) + root.mkdir(parents=True, exist_ok=True) + with lock: + if archive_path.is_file() and not force: + try: + with zipfile.ZipFile(archive_path, "r") as archive: + if "game_map.json" in archive.namelist(): + return CompiledGameMap( + game_map.source_path, archive_path, game_map, True + ) + except (OSError, zipfile.BadZipFile): + pass + output_dir.mkdir(parents=True, exist_ok=True) + file_descriptor, temporary_name = tempfile.mkstemp( + dir=output_dir, prefix=".map-", suffix=".usdz" + ) + os.close(file_descriptor) + temporary = Path(temporary_name) + try: + _write_archive(temporary, game_map) + temporary.replace(archive_path) + finally: + temporary.unlink(missing_ok=True) + return CompiledGameMap(game_map.source_path, archive_path, game_map, False) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py new file mode 100644 index 000000000..f756ec97c --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py @@ -0,0 +1,3029 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Node-graph game-map loading and geometry compilation.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +import numpy as np +from shapely import is_valid_reason +from shapely.geometry import LineString, Point, Polygon +from shapely.geometry.base import BaseGeometry +from shapely.ops import polygonize, substring, unary_union + +from omnidreams_game_engine.game_map._schema import ( + _SCHEMA_VERSION, + GameMapError, + _bezier, + _finite_float, + _lane_edge_markings, + _LaneBuild, + _mapping, + _nonnegative_float, + _offset_polyline, + _parse_attribute_values, + _parse_compiler_settings, + _parse_map_identity, + _parse_profiles, + _parse_variants, + _positive_float, + _Profile, + _read_document, + _sequence, + _xyz, +) +from omnidreams_game_engine.game_map.traffic import compile_traffic +from omnidreams_game_engine.game_map.types import ( + GameMapBoundaryAttributes, + GameMapCurb, + GameMapElement, + GameMapLane, + GameMapLaneDivider, + GameMapLinearAttributes, + GameMapNode, + GameMapParkingAccess, + GameMapRoad, + GameMapRoadBoundary, + GameMapSpawn, + GameMapTopology, + ResolvedGameMap, +) + +_POSITION_TOLERANCE_M = 0.05 +_AREA_TOLERANCE_M2 = 1.0e-4 +_LINE_TOLERANCE_M = 1.0e-4 +_OPENING_TOLERANCE_M = 1.0e-2 +"""Maximum numeric drift when matching separately materialized seam polylines.""" + +_BOUNDARY_CLEARANCE_M = 1.0e-2 +"""Outward clearance that keeps sampled roadside corners behind their openings.""" + +_INTERSECTION_TURN_HANDLE_RATIO = 0.4 +"""Cubic handle length as a fraction of the connector endpoint chord.""" + +_LINEAR_ATTRIBUTE_FIELDS = frozenset( + { + "lane_width_m", + "curb_offset_m", + "lanes", + "speed_limit_mps", + "curb", + "lane_marking", + "divider_markings", + } +) +_REQUIRED_LINEAR_ATTRIBUTE_FIELDS = _LINEAR_ATTRIBUTE_FIELDS - {"curb"} + +_NODE_ATTRIBUTE_FIELDS = { + "intersection": frozenset({"curb"}), + "road_joint": frozenset(), + "cul_de_sac": frozenset({"curb", "culdesac_radius_m"}), + "parking_lot": frozenset(), + "driveway": frozenset(), +} + + +@dataclass(frozen=True) +class _RoadSpec: + road: GameMapRoad + spans_xy: tuple[np.ndarray, ...] + + +@dataclass +class _LaneIncidence: + lane: _LaneBuild + node_id: str + kind: str + edge_ref: str + + +@dataclass(frozen=True) +class _Connection: + """Exact shared opening between two resolved surface elements.""" + + connection_id: str + """Stable topology-derived connection identifier.""" + + first_element_id: str + """First connected surface element.""" + + second_element_id: str + """Second connected surface element.""" + + opening_xy: np.ndarray + """Shared boundary polyline with shape ``[N, 2]``.""" + + +@dataclass(frozen=True) +class _RoadArm: + """One road cross-section oriented outward from an incident node.""" + + node_id: str + """Identifier of the node that owns the arm.""" + + road: GameMapRoad + """Authored road incident to the node.""" + + path_xy: np.ndarray + """Sampled road centerline oriented outward from the node.""" + + attributes: GameMapLinearAttributes + """Authored cross-section oriented outward from the node.""" + + +@dataclass(frozen=True) +class _ArmTransition: + """One node-owned transition from a local to an authored cross-section.""" + + arm: _RoadArm + """Road arm whose authored cross-section differs from the node.""" + + local_attributes: GameMapLinearAttributes + """Dominant cross-section used at the node opening.""" + + length_m: float + """Distance over which the node cross-section becomes the road cross-section.""" + + +@dataclass(frozen=True) +class _TransitionGeometry: + """Resolved centerline and cross-sections for one tapered node arm.""" + + transition: _ArmTransition + """Semantic transition resolved for this arm.""" + + path_xy: np.ndarray + """Sampled centerline from the node opening to the authored road.""" + + +@dataclass(frozen=True) +class _BoundaryArmGeometry: + """Boundary rails from a node core to one connected surface.""" + + reference_id: str + """Identifier of the road or inferred parking access.""" + + left_xy: np.ndarray + """Left roadside rail oriented from the core to the opening.""" + + right_xy: np.ndarray + """Right roadside rail oriented from the core to the opening.""" + + +def _point(value: object, context: str) -> np.ndarray: + raw = _mapping(value, context) + if set(raw) != {"x_m", "y_m"}: + raise GameMapError(f"{context} requires exactly x_m and y_m") + return np.asarray( + [ + _finite_float(raw["x_m"], f"{context}.x_m"), + _finite_float(raw["y_m"], f"{context}.y_m"), + ], + dtype=np.float64, + ) + + +def _resolve_attribute_values( + raw: dict[str, Any], + profiles: dict[str, _Profile], + *, + structural_fields: set[str], + allowed_fields: frozenset[str], + required_fields: frozenset[str], + context: str, +) -> tuple[str | None, dict[str, object]]: + """Resolve direct attributes over optional partial profile defaults.""" + profile_id = None if "profile" not in raw else str(raw["profile"]).strip() + if profile_id is not None and profile_id not in profiles: + raise GameMapError(f"{context} references unknown profile {profile_id!r}") + direct_raw = { + key: value + for key, value in raw.items() + if key not in structural_fields and key != "profile" + } + unknown = set(direct_raw) - allowed_fields + if unknown: + raise GameMapError(f"{context} has unknown attributes {sorted(unknown)}") + direct = _parse_attribute_values(direct_raw, context) + values = { + key: value + for key, value in ( + profiles[profile_id].values.items() if profile_id is not None else () + ) + if key in allowed_fields + } + values.update(direct) + if "curb" in allowed_fields: + values.setdefault("curb", True) + missing = required_fields - set(values) + if missing: + raise GameMapError(f"{context} is missing attributes {sorted(missing)}") + return profile_id, values + + +def _linear_attributes( + values: dict[str, object], context: str +) -> GameMapLinearAttributes: + """Build a complete linear attribute bundle.""" + directions = tuple(str(value) for value in values["lanes"]) + dividers = tuple( + (str(value[0]), str(value[1])) for value in values["divider_markings"] + ) + if len(dividers) != len(directions) - 1: + raise GameMapError( + f"{context}.divider_markings must contain one entry per adjacent lane pair" + ) + marking = tuple(str(value) for value in values["lane_marking"]) + return GameMapLinearAttributes( + curb=bool(values["curb"]), + lane_width_m=float(values["lane_width_m"]), + curb_offset_m=float(values["curb_offset_m"]), + directions=directions, + speed_limit_mps=float(values["speed_limit_mps"]), + marking_style=marking[0], + marking_color=marking[1], + divider_markings=dividers, + ) + + +def _parse_nodes( + doc: dict[str, Any], profiles: dict[str, _Profile] +) -> tuple[GameMapNode, ...]: + nodes: list[GameMapNode] = [] + ids: set[str] = set() + for index, value in enumerate(_sequence(doc.get("nodes"), "nodes")): + raw = _mapping(value, f"nodes[{index}]") + node_type = str(raw.get("type", "")) + if node_type not in _NODE_ATTRIBUTE_FIELDS: + raise GameMapError(f"nodes[{index}] has unsupported type {node_type!r}") + node_id = str(raw["id"]).strip() + if not node_id or node_id in ids: + raise GameMapError(f"Node id {node_id!r} is empty or duplicated") + ids.add(node_id) + context = f"node {node_id!r}" + if node_type == "parking_lot": + expected = { + "id", + "type", + "vertices", + "connected_to", + "opening_vertex", + } + if set(raw) != expected: + raise GameMapError( + f"{context} requires exactly id, type, vertices, " + "connected_to, and opening_vertex" + ) + vertices = tuple( + tuple( + float(item) + for item in _point(value, f"{context}.vertices[{vertex_index}]") + ) + for vertex_index, value in enumerate( + _sequence(raw["vertices"], f"{context}.vertices") + ) + ) + if len(vertices) < 3: + raise GameMapError(f"{context}.vertices requires at least three points") + polygon = Polygon(vertices) + if not polygon.is_valid or polygon.area <= _AREA_TOLERANCE_M2: + raise GameMapError(f"{context}.vertices must form a simple polygon") + if polygon.exterior.is_ccw: + raise GameMapError(f"{context}.vertices must be clockwise") + if len(set(vertices)) != len(vertices): + raise GameMapError(f"{context}.vertices contains duplicate points") + if not str(raw["connected_to"]).strip(): + raise GameMapError(f"{context}.connected_to must not be empty") + opening_value = raw["opening_vertex"] + if type(opening_value) is not int: + raise GameMapError(f"{context}.opening_vertex must be an integer") + if opening_value < 1 or opening_value > len(vertices): + raise GameMapError( + f"{context}.opening_vertex must be between 1 and {len(vertices)}" + ) + centroid = polygon.centroid + nodes.append( + GameMapNode( + node_id=node_id, + node_type=node_type, + x_m=float(centroid.x), + y_m=float(centroid.y), + profile_id=None, + attributes=GameMapBoundaryAttributes(curb=True), + geometry={}, + polygon_vertices_xy=vertices, + ) + ) + continue + if not {"id", "type", "pose"} <= set(raw): + raise GameMapError(f"nodes[{index}] requires id, type, and pose") + pose = _mapping(raw["pose"], f"node {node_id!r}.pose") + if set(pose) != {"x_m", "y_m"}: + raise GameMapError(f"Node {node_id!r}.pose requires x_m and y_m") + if node_type in {"road_joint", "driveway"}: + expected = {"id", "type", "pose"} + allowed = set(expected) + if node_type == "road_joint": + allowed.add("lane_transition_length_m") + missing = expected - set(raw) + unknown = set(raw) - allowed + if missing: + raise GameMapError(f"{context} is missing attributes {sorted(missing)}") + if unknown: + raise GameMapError( + f"{context} has unknown attributes {sorted(unknown)}" + ) + profile_id = None + geometry = ( + { + "lane_transition_length_m": _nonnegative_float( + raw.get("lane_transition_length_m", 0.0), + f"{context}.lane_transition_length_m", + ), + } + if node_type == "road_joint" + else {} + ) + attributes: GameMapBoundaryAttributes | GameMapLinearAttributes + attributes = GameMapBoundaryAttributes(curb=False) + else: + allowed = _NODE_ATTRIBUTE_FIELDS[node_type] + required = { + "intersection": frozenset(), + "cul_de_sac": frozenset({"culdesac_radius_m"}), + }[node_type] + profile_id, values = _resolve_attribute_values( + raw, + profiles, + structural_fields={"id", "type", "pose"} + | ( + {"lane_transition_length_m"} + if node_type == "intersection" + else set() + ), + allowed_fields=allowed, + required_fields=required, + context=context, + ) + geometry = { + key: float(item) + for key, item in values.items() + if key in {"culdesac_radius_m"} + } + if node_type == "intersection": + geometry["lane_transition_length_m"] = _nonnegative_float( + raw.get("lane_transition_length_m", 0.0), + f"{context}.lane_transition_length_m", + ) + attributes = GameMapBoundaryAttributes(curb=bool(values["curb"])) + nodes.append( + GameMapNode( + node_id=node_id, + node_type=node_type, + x_m=_finite_float(pose["x_m"], f"node {node_id!r}.pose.x_m"), + y_m=_finite_float(pose["y_m"], f"node {node_id!r}.pose.y_m"), + profile_id=profile_id, + attributes=attributes, + geometry=geometry, + ) + ) + if not nodes: + raise GameMapError("Map must define at least one node") + return tuple(nodes) + + +def _path_point_spans( + start: np.ndarray, path_points: list[np.ndarray], end: np.ndarray, road_id: str +) -> tuple[np.ndarray, ...]: + points = np.asarray([start, *path_points, end], dtype=np.float64) + segment_lengths = np.linalg.norm(np.diff(points, axis=0), axis=1) + for index, length in enumerate(segment_lengths): + if length <= _POSITION_TOLERANCE_M: + raise GameMapError( + f"Road {road_id!r}.path creates a degenerate segment at index {index}" + ) + + tangents = np.empty_like(points) + is_closed = np.linalg.norm(start - end) <= _POSITION_TOLERANCE_M + if is_closed: + if len(path_points) < 2: + raise GameMapError( + f"Self-loop road {road_id!r} requires at least two path points" + ) + loop_tangent = 0.5 * (points[1] - points[-2]) + tangents[0] = loop_tangent + tangents[-1] = loop_tangent + else: + tangents[0] = points[1] - points[0] + tangents[-1] = points[-1] - points[-2] + if len(points) > 2: + tangents[1:-1] = 0.5 * (points[2:] - points[:-2]) + + for index, tangent in enumerate(tangents): + if np.linalg.norm(tangent) <= _POSITION_TOLERANCE_M: + raise GameMapError( + f"Road {road_id!r}.path creates a degenerate tangent at point {index}" + ) + + spans: list[np.ndarray] = [] + for index in range(len(points) - 1): + control_1 = points[index] + tangents[index] / 3.0 + control_2 = points[index + 1] - tangents[index + 1] / 3.0 + spans.append( + np.vstack((points[index], control_1, control_2, points[index + 1])) + ) + return tuple(spans) + + +def _bezier_spans( + value: object, start: np.ndarray, end: np.ndarray, road_id: str +) -> tuple[np.ndarray, ...]: + bezier = _sequence(value, f"road {road_id!r}.bezier") + if not bezier: + raise GameMapError(f"Road {road_id!r}.bezier must not be empty") + spans: list[np.ndarray] = [] + cursor = start + for span_index, span_value in enumerate(bezier): + span = _mapping(span_value, f"road {road_id!r}.bezier[{span_index}]") + if set(span) != {"control_points", "end"}: + raise GameMapError( + f"Road {road_id!r} Bezier spans require control_points and end" + ) + controls = _sequence( + span["control_points"], + f"road {road_id!r}.bezier[{span_index}].control_points", + ) + if len(controls) != 2: + raise GameMapError( + f"Road {road_id!r} Bezier spans require exactly two control points" + ) + control_1 = _point( + controls[0], + f"road {road_id!r}.bezier[{span_index}].control_points[0]", + ) + control_2 = _point( + controls[1], + f"road {road_id!r}.bezier[{span_index}].control_points[1]", + ) + span_end = _point(span["end"], f"road {road_id!r}.bezier[{span_index}].end") + if np.linalg.norm(control_1 - cursor) <= _POSITION_TOLERANCE_M: + raise GameMapError( + f"Road {road_id!r} span {span_index} has a degenerate start tangent" + ) + if np.linalg.norm(span_end - control_2) <= _POSITION_TOLERANCE_M: + raise GameMapError( + f"Road {road_id!r} span {span_index} has a degenerate end tangent" + ) + spans.append(np.vstack((cursor, control_1, control_2, span_end))) + cursor = span_end + if np.linalg.norm(cursor - end) > _POSITION_TOLERANCE_M: + raise GameMapError( + f"Road {road_id!r} final Bezier endpoint must match its to-node pose " + f"within {_POSITION_TOLERANCE_M:g}m" + ) + return tuple(spans) + + +def _path_spans( + value: object, start: np.ndarray, end: np.ndarray, road_id: str +) -> tuple[np.ndarray, ...]: + path = _sequence(value, f"road {road_id!r}.path") + if not path: + raise GameMapError(f"Road {road_id!r}.path must not be empty") + path_points: list[np.ndarray] = [] + for index, item in enumerate(path): + raw = _mapping(item, f"road {road_id!r}.path[{index}]") + if "control_points" in raw or "end" in raw: + raise GameMapError( + f"Road {road_id!r}.path accepts path points only; " + "put explicit spans under bezier" + ) + path_points.append(_point(raw, f"road {road_id!r}.path[{index}]")) + return _path_point_spans(start, path_points, end, road_id) + + +def _parse_roads( + doc: dict[str, Any], nodes: dict[str, GameMapNode], profiles: dict[str, _Profile] +) -> tuple[_RoadSpec, ...]: + roads: list[_RoadSpec] = [] + ids: set[str] = set() + for index, value in enumerate(_sequence(doc.get("roads"), "roads")): + raw = _mapping(value, f"roads[{index}]") + if not {"id", "from", "to"} <= set(raw): + raise GameMapError(f"roads[{index}] requires id, from, and to") + road_id = str(raw["id"]).strip() + if not road_id or road_id in ids: + raise GameMapError(f"Road id {road_id!r} is empty or duplicated") + ids.add(road_id) + from_id, to_id = str(raw["from"]), str(raw["to"]) + for endpoint in (from_id, to_id): + if endpoint not in nodes: + raise GameMapError( + f"Road {road_id!r} references unknown node {endpoint!r}" + ) + if nodes[endpoint].node_type not in { + "intersection", + "road_joint", + "driveway", + "cul_de_sac", + }: + raise GameMapError( + f"Road {road_id!r} may connect only intersections, road joints, " + "driveways, and cul-de-sacs" + ) + context = f"road {road_id!r}" + profile_id, values = _resolve_attribute_values( + raw, + profiles, + structural_fields={"id", "from", "to", "path", "bezier"}, + allowed_fields=_LINEAR_ATTRIBUTE_FIELDS, + required_fields=_REQUIRED_LINEAR_ATTRIBUTE_FIELDS, + context=context, + ) + attributes = _linear_attributes(values, context) + start = np.asarray([nodes[from_id].x_m, nodes[from_id].y_m]) + end = np.asarray([nodes[to_id].x_m, nodes[to_id].y_m]) + path_spans: tuple[np.ndarray, ...] = () + if "path" in raw: + path_spans = _path_spans(raw["path"], start, end, road_id) + bezier_spans: tuple[np.ndarray, ...] = () + if "bezier" in raw: + bezier_spans = _bezier_spans(raw["bezier"], start, end, road_id) + if "bezier" in raw: + spans = bezier_spans + elif "path" in raw: + spans = path_spans + else: + spans = () + if np.linalg.norm(start - end) <= _POSITION_TOLERANCE_M: + raise GameMapError( + f"Self-loop road {road_id!r} requires path or bezier" + ) + runtime_spans = tuple( + np.column_stack((span, np.zeros(4))).astype(np.float32) for span in spans + ) + roads.append( + _RoadSpec( + GameMapRoad( + road_id=road_id, + from_node_id=from_id, + to_node_id=to_id, + profile_id=profile_id, + attributes=attributes, + bezier_spans_world=runtime_spans, + ), + tuple(spans), + ) + ) + if not roads: + raise GameMapError("Map must define at least one road") + return tuple(roads) + + +def _reverse_direction(direction: str) -> str: + return "backward" if direction == "forward" else "forward" + + +def _oriented_joint_attributes( + road: GameMapRoad, + *, + reverse: bool, +) -> GameMapLinearAttributes: + """Orient road attributes along the canonical path through a joint.""" + attributes = road.attributes + return _reversed_attributes(attributes) if reverse else attributes + + +def _reversed_attributes( + attributes: GameMapLinearAttributes, +) -> GameMapLinearAttributes: + """Reverse linear attributes with their physical cross-section order.""" + return replace( + attributes, + directions=tuple( + _reverse_direction(direction) + for direction in reversed(attributes.directions) + ), + divider_markings=tuple(reversed(attributes.divider_markings)), + ) + + +def _outward_attributes(road: GameMapRoad, node_id: str) -> GameMapLinearAttributes: + """Orient road attributes along its centerline away from ``node_id``.""" + return _oriented_joint_attributes( + road, + reverse=road.to_node_id == node_id, + ) + + +def _direction_block_count(attributes: GameMapLinearAttributes) -> int: + return 1 + sum( + first != second + for first, second in zip( + attributes.directions[:-1], + attributes.directions[1:], + strict=True, + ) + ) + + +def _opposing_divider( + attributes: GameMapLinearAttributes, +) -> tuple[str, str] | None: + indices = [ + index + for index, (first, second) in enumerate( + zip( + attributes.directions[:-1], + attributes.directions[1:], + strict=True, + ) + ) + if first != second + ] + return None if not indices else attributes.divider_markings[indices[0]] + + +def _dominant_cross_section( + first: GameMapLinearAttributes, + second: GameMapLinearAttributes, + context: str, +) -> GameMapLinearAttributes: + """Select the node-side profile that can contain both road profiles.""" + direction_set = {"backward", "forward"} + compatible = ( + set(first.directions) == set(second.directions) + and set(first.directions) <= direction_set + and _direction_block_count(first) <= 2 + and _direction_block_count(second) <= 2 + and _opposing_divider(first) == _opposing_divider(second) + ) + if not compatible: + raise GameMapError( + f"{context} requires compatible direction ordering and opposing dividers" + ) + + first_counts = { + direction: first.directions.count(direction) for direction in direction_set + } + second_counts = { + direction: second.directions.count(direction) for direction in direction_set + } + first_dominates = all( + first_counts[direction] >= second_counts[direction] + for direction in direction_set + ) + second_dominates = all( + second_counts[direction] >= first_counts[direction] + for direction in direction_set + ) + if not first_dominates and not second_dominates: + raise GameMapError( + f"{context} has conflicting directional lane counts; one road must " + "have at least as many lanes in both directions" + ) + if first_dominates and not second_dominates: + dominant = first + elif second_dominates and not first_dominates: + dominant = second + else: + dominant = first if first.lane_width_m >= second.lane_width_m else second + return replace( + dominant, + speed_limit_mps=min(first.speed_limit_mps, second.speed_limit_mps), + ) + + +def _cross_section_changes( + first: GameMapLinearAttributes, + second: GameMapLinearAttributes, +) -> bool: + return ( + first.directions != second.directions + or first.lane_width_m != second.lane_width_m + ) + + +def _lane_layout_for_arm( + layout: GameMapLinearAttributes, + arm: GameMapLinearAttributes, +) -> GameMapLinearAttributes: + return replace( + layout, + curb_offset_m=arm.curb_offset_m, + curb=arm.curb, + speed_limit_mps=arm.speed_limit_mps, + ) + + +def _resolve_linear_joint_nodes( + nodes: tuple[GameMapNode, ...], + road_specs: tuple[_RoadSpec, ...], +) -> tuple[GameMapNode, ...]: + """Infer linear attributes for every degree-two road joint and driveway.""" + incident: dict[str, list[GameMapRoad]] = {node.node_id: [] for node in nodes} + for spec in road_specs: + incident[spec.road.from_node_id].append(spec.road) + incident[spec.road.to_node_id].append(spec.road) + + resolved: list[GameMapNode] = [] + for node in nodes: + if node.node_type not in {"road_joint", "driveway"}: + resolved.append(node) + continue + roads = sorted(incident[node.node_id], key=lambda road: road.road_id) + if len(roads) != 2 or any( + road.from_node_id == road.to_node_id for road in roads + ): + raise GameMapError( + f"{node.node_type.replace('_', ' ').title()} {node.node_id!r} " + "must connect exactly two distinct roads" + ) + first = _oriented_joint_attributes( + roads[0], reverse=roads[0].from_node_id == node.node_id + ) + second = _oriented_joint_attributes( + roads[1], reverse=roads[1].to_node_id == node.node_id + ) + context = f"{node.node_type.replace('_', ' ').title()} {node.node_id!r}" + if node.node_type == "driveway": + compatible = ( + first.lane_width_m == second.lane_width_m + and first.curb_offset_m == second.curb_offset_m + and first.directions == second.directions + and first.curb == second.curb + and first.marking_style == second.marking_style + and first.marking_color == second.marking_color + and first.divider_markings == second.divider_markings + ) + if not compatible: + raise GameMapError( + f"{context} requires compatible road cross-sections, " + "markings, and curb modes" + ) + dominant = replace( + first, + speed_limit_mps=min(first.speed_limit_mps, second.speed_limit_mps), + ) + else: + dominant = _dominant_cross_section(first, second, context) + if ( + _cross_section_changes(first, second) + and node.geometry["lane_transition_length_m"] <= 0.0 + ): + raise GameMapError( + f"{context} changes lane count or width and requires a " + "positive lane_transition_length_m" + ) + resolved.append( + replace( + node, + attributes=dominant, + ) + ) + return tuple(resolved) + + +def _arm_for_road( + road: GameMapRoad, + node_id: str, + raw_roads: dict[str, np.ndarray], +) -> _RoadArm: + return _RoadArm( + node_id=node_id, + road=road, + path_xy=_road_path_from_node(road, raw_roads[road.road_id], node_id), + attributes=_outward_attributes(road, node_id), + ) + + +def _mutual_opposite_pairs(arms: list[_RoadArm]) -> list[tuple[_RoadArm, _RoadArm]]: + """Pair mutually straightest intersection arms within 45 degrees.""" + if len(arms) < 2: + return [] + directions: list[np.ndarray] = [] + for arm in arms: + vector = arm.path_xy[1] - arm.path_xy[0] + directions.append(vector / max(float(np.linalg.norm(vector)), 1.0e-9)) + best: dict[int, int] = {} + for first_index, first_direction in enumerate(directions): + candidates = [ + (float(np.dot(first_direction, second_direction)), second_index) + for second_index, second_direction in enumerate(directions) + if second_index != first_index + ] + dot, second_index = min(candidates) + if dot <= -math.cos(math.radians(45.0)): + best[first_index] = second_index + return [ + (arms[first_index], arms[second_index]) + for first_index, second_index in sorted(best.items()) + if first_index < second_index and best.get(second_index) == first_index + ] + + +def _cross_section_transitions( + topology: GameMapTopology, + raw_roads: dict[str, np.ndarray], +) -> dict[tuple[str, str], _ArmTransition]: + """Plan every node arm that must taper to its authored road profile.""" + incident: dict[str, list[GameMapRoad]] = { + node.node_id: [] for node in topology.nodes + } + for road in topology.roads: + incident[road.from_node_id].append(road) + if road.to_node_id != road.from_node_id: + incident[road.to_node_id].append(road) + + transitions: dict[tuple[str, str], _ArmTransition] = {} + for node in topology.nodes: + if node.node_type == "road_joint": + assert isinstance(node.attributes, GameMapLinearAttributes) + roads = sorted(incident[node.node_id], key=lambda road: road.road_id) + arms = [_arm_for_road(road, node.node_id, raw_roads) for road in roads] + local_attributes = ( + _reversed_attributes(node.attributes), + node.attributes, + ) + for arm, local in zip(arms, local_attributes, strict=True): + local = _lane_layout_for_arm(local, arm.attributes) + if _cross_section_changes(local, arm.attributes): + transitions[(node.node_id, arm.road.road_id)] = _ArmTransition( + arm, + local, + node.geometry["lane_transition_length_m"], + ) + continue + if node.node_type != "intersection": + continue + arms = [ + _arm_for_road(road, node.node_id, raw_roads) + for road in incident[node.node_id] + if road.from_node_id != road.to_node_id + ] + for first, second in _mutual_opposite_pairs(arms): + second_through = _reversed_attributes(second.attributes) + if not _cross_section_changes(first.attributes, second_through): + continue + context = ( + f"Intersection {node.node_id!r} through roads " + f"{first.road.road_id!r} and {second.road.road_id!r}" + ) + dominant = _dominant_cross_section( + first.attributes, + second_through, + context, + ) + length = node.geometry["lane_transition_length_m"] + if length <= 0.0: + raise GameMapError( + f"{context} changes lane count or width and requires a " + "positive lane_transition_length_m" + ) + local_values = ( + dominant, + _reversed_attributes(dominant), + ) + for arm, local in zip((first, second), local_values, strict=True): + local = _lane_layout_for_arm(local, arm.attributes) + if _cross_section_changes(local, arm.attributes): + transitions[(node.node_id, arm.road.road_id)] = _ArmTransition( + arm, + local, + length, + ) + return transitions + + +def _parking_accesses_from_nodes( + doc: dict[str, Any], nodes: dict[str, GameMapNode] +) -> tuple[GameMapParkingAccess, ...]: + accesses: list[GameMapParkingAccess] = [] + for index, value in enumerate(_sequence(doc.get("nodes"), "nodes")): + raw = _mapping(value, f"nodes[{index}]") + if raw.get("type") != "parking_lot": + continue + lot_id = str(raw["id"]) + source_id = str(raw["connected_to"]) + opening_value = raw["opening_vertex"] + if source_id not in nodes or nodes[source_id].node_type not in { + "intersection", + "driveway", + }: + raise GameMapError( + f"Parking lot {lot_id!r}.connected_to must reference an " + "intersection or driveway" + ) + opening_index = opening_value - 1 + accesses.append( + GameMapParkingAccess(f"{lot_id}:access", source_id, lot_id, opening_index) + ) + return tuple(accesses) + + +def _validate_element_ids(topology: GameMapTopology) -> None: + owners: dict[str, str] = {} + identifiers = ( + *((node.node_id, "node") for node in topology.nodes), + *((road.road_id, "road") for road in topology.roads), + *((access.access_id, "parking access") for access in topology.parking_accesses), + ) + for identifier, kind in identifiers: + previous = owners.setdefault(identifier, kind) + if previous != kind: + raise GameMapError( + f"Map element id {identifier!r} is shared by a {previous} and {kind}" + ) + + +def _validate_topology(topology: GameMapTopology) -> None: + _validate_element_ids(topology) + nodes = {node.node_id: node for node in topology.nodes} + road_degree = {node_id: 0 for node_id in nodes} + for road in topology.roads: + road_degree[road.from_node_id] += 1 + road_degree[road.to_node_id] += 1 + source_accesses: dict[str, list[GameMapParkingAccess]] = { + node_id: [] for node_id in nodes + } + lot_accesses: dict[str, list[GameMapParkingAccess]] = { + node_id: [] for node_id in nodes + } + for access in topology.parking_accesses: + source_accesses[access.source_node_id].append(access) + lot_accesses[access.parking_lot_node_id].append(access) + for node in topology.nodes: + if node.node_type == "intersection" and road_degree[node.node_id] < 3: + raise GameMapError( + f"Intersection {node.node_id!r} must connect at least three road " + f"arms (found {road_degree[node.node_id]})" + ) + if node.node_type == "cul_de_sac" and road_degree[node.node_id] != 1: + raise GameMapError( + f"Cul-de-sac {node.node_id!r} must terminate exactly one road" + ) + if node.node_type == "parking_lot" and road_degree[node.node_id]: + raise GameMapError( + f"Parking lot {node.node_id!r} cannot be an authored road endpoint" + ) + if node.node_type == "parking_lot" and not lot_accesses[node.node_id]: + raise GameMapError( + f"Parking lot {node.node_id!r} must have at least one parking access" + ) + if node.node_type == "cul_de_sac" and road_degree[node.node_id] == 1: + road = next( + road + for road in topology.roads + if node.node_id in {road.from_node_id, road.to_node_id} + ) + minimum_radius = road.attributes.surface_width_m * 0.5 + if node.geometry["culdesac_radius_m"] <= minimum_radius: + raise GameMapError( + f"Cul-de-sac {node.node_id!r} culdesac_radius_m must exceed " + f"half the incident road width ({minimum_radius:.2f} m)" + ) + if node.node_type == "driveway": + if road_degree[node.node_id] != 2: + raise GameMapError( + f"Driveway {node.node_id!r} must connect exactly two roads" + ) + if len(source_accesses[node.node_id]) != 1: + raise GameMapError( + f"Driveway {node.node_id!r} must have exactly one parking access" + ) + + +def _sample_road(spec: _RoadSpec, spacing_m: float) -> np.ndarray: + if not spec.spans_xy: + raise AssertionError("Straight road sampling requires node positions") + groups: list[np.ndarray] = [] + for span in spec.spans_xy: + estimate = sum( + float(np.linalg.norm(span[index + 1] - span[index])) for index in range(3) + ) + samples = max(3, int(math.ceil(estimate / spacing_m)) + 1) + t = np.linspace(0.0, 1.0, samples)[:, None] + points = ( + (1.0 - t) ** 3 * span[0] + + 3.0 * (1.0 - t) ** 2 * t * span[1] + + 3.0 * (1.0 - t) * t**2 * span[2] + + t**3 * span[3] + ) + groups.append(points if not groups else points[1:]) + return np.concatenate(groups, axis=0) + + +def _road_path_from_node( + road: GameMapRoad, + points: np.ndarray, + node_id: str, +) -> np.ndarray: + """Orient a road centerline outward from one endpoint node.""" + return points if road.from_node_id == node_id else points[::-1] + + +def _trimmed_road_paths_and_joints( + topology: GameMapTopology, + raw_roads: dict[str, np.ndarray], + spacing_m: float, +) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]: + """Trim incident roads and build compact tangent joint centerlines.""" + nodes = {node.node_id: node for node in topology.nodes} + incident: dict[str, list[GameMapRoad]] = { + node.node_id: [] for node in topology.nodes + } + for road in topology.roads: + incident[road.from_node_id].append(road) + incident[road.to_node_id].append(road) + + joint_trims: dict[tuple[str, str], float] = {} + for node in topology.nodes: + if node.node_type != "road_joint": + continue + assert isinstance(node.attributes, GameMapLinearAttributes) + roads = sorted(incident[node.node_id], key=lambda road: road.road_id) + layouts = (_reversed_attributes(node.attributes), node.attributes) + arms: list[tuple[np.ndarray, float]] = [] + for road, layout in zip(roads, layouts, strict=True): + path = _road_path_from_node(road, raw_roads[road.road_id], node.node_id) + attributes = _lane_layout_for_arm( + layout, + _outward_attributes(road, node.node_id), + ) + arms.append((path, attributes.surface_width_m)) + reaches, _order, _corners = _inferred_intersection_arm_reaches(arms) + for road, reach in zip(roads, reaches, strict=True): + joint_trims[(node.node_id, road.road_id)] = reach + + trimmed: dict[str, np.ndarray] = {} + for road in topology.roads: + line = LineString(raw_roads[road.road_id]) + start_node = nodes[road.from_node_id] + end_node = nodes[road.to_node_id] + + def trim_for(node: GameMapNode) -> float: + if node.node_type == "road_joint": + return joint_trims[(node.node_id, road.road_id)] + if node.node_type == "driveway": + access = next( + item + for item in topology.parking_accesses + if item.source_node_id == node.node_id + ) + lot = nodes[access.parking_lot_node_id] + vertices = np.asarray(lot.polygon_vertices_xy) + return 0.5 * float( + np.linalg.norm( + vertices[(access.opening_vertex_index + 1) % len(vertices)] + - vertices[access.opening_vertex_index] + ) + ) + return 0.0 + + start_trim = trim_for(start_node) + end_trim = trim_for(end_node) + if start_trim + end_trim >= line.length - _POSITION_TOLERANCE_M: + raise GameMapError( + f"Road {road.road_id!r} is too short for road-joint trims " + f"{start_trim:g} m and {end_trim:g} m " + f"(centerline length {line.length:.3f} m)" + ) + remaining = substring(line, start_trim, line.length - end_trim) + if remaining.geom_type != "LineString": + raise GameMapError( + f"Road {road.road_id!r} does not retain one centerline after trimming" + ) + trimmed[road.road_id] = np.asarray(remaining.coords, dtype=np.float64) + + joints: dict[str, np.ndarray] = {} + for node in topology.nodes: + if node.node_type not in {"road_joint", "driveway"}: + continue + if node.node_type == "driveway": + access = next( + item + for item in topology.parking_accesses + if item.source_node_id == node.node_id + ) + lot = nodes[access.parking_lot_node_id] + vertices = np.asarray(lot.polygon_vertices_xy) + length = 0.5 * float( + np.linalg.norm( + vertices[(access.opening_vertex_index + 1) % len(vertices)] + - vertices[access.opening_vertex_index] + ) + ) + first_road, second_road = sorted( + incident[node.node_id], key=lambda road: road.road_id + ) + if node.node_type == "driveway": + lengths = (length, length) + else: + lengths = ( + joint_trims[(node.node_id, first_road.road_id)], + joint_trims[(node.node_id, second_road.road_id)], + ) + first_path = _road_path_from_node( + first_road, raw_roads[first_road.road_id], node.node_id + ) + second_path = _road_path_from_node( + second_road, raw_roads[second_road.road_id], node.node_id + ) + prefixes = [ + _polyline_prefix(path, trim) + for path, trim in zip((first_path, second_path), lengths, strict=True) + ] + cuts = [prefix[-1] for prefix in prefixes] + outward_tangents: list[np.ndarray] = [] + for prefix in prefixes: + tangent = prefix[-1] - prefix[-2] + tangent /= max(float(np.linalg.norm(tangent)), 1.0e-9) + outward_tangents.append(tangent) + if node.node_type == "driveway": + centerline = np.asarray( + [cuts[0], [node.x_m, node.y_m], cuts[1]], dtype=np.float64 + ) + if not LineString(centerline).is_simple: + raise GameMapError( + f"Driveway {node.node_id!r} produces a self-intersecting join" + ) + joints[node.node_id] = centerline + continue + incoming_tangent = -outward_tangents[0] + outgoing_tangent = outward_tangents[1] + turn_angle = math.acos( + float(np.clip(np.dot(incoming_tangent, outgoing_tangent), -1.0, 1.0)) + ) + if turn_angle >= math.pi - 1.0e-6: + raise GameMapError( + f"Road joint {node.node_id!r} cannot form a tangent U-turn" + ) + if turn_angle <= 1.0e-6: + handle_ratio = 2.0 / 3.0 + else: + handle_ratio = ( + 4.0 / 3.0 * math.tan(turn_angle * 0.25) / math.tan(turn_angle * 0.5) + ) + handle_lengths = [trim * handle_ratio for trim in lengths] + controls = [ + cut - outward_tangent * handle + for cut, outward_tangent, handle in zip( + cuts, outward_tangents, handle_lengths, strict=True + ) + ] + span = np.asarray( + [cuts[0], controls[0], controls[1], cuts[1]], dtype=np.float64 + ) + estimate = sum( + float(np.linalg.norm(span[index + 1] - span[index])) for index in range(3) + ) + samples = max(3, int(math.ceil(estimate / spacing_m)) + 1) + t = np.linspace(0.0, 1.0, samples)[:, None] + centerline = ( + (1.0 - t) ** 3 * span[0] + + 3.0 * (1.0 - t) ** 2 * t * span[1] + + 3.0 * (1.0 - t) * t**2 * span[2] + + t**3 * span[3] + ) + tangent_epsilons = [ + min(spacing_m * 0.5, handle * 0.25) for handle in handle_lengths + ] + centerline = np.concatenate( + ( + centerline[:1], + (cuts[0] - outward_tangents[0] * tangent_epsilons[0])[None, :], + centerline[1:-1], + (cuts[1] - outward_tangents[1] * tangent_epsilons[1])[None, :], + centerline[-1:], + ), + axis=0, + ) + line = LineString(centerline) + if line.length <= _POSITION_TOLERANCE_M or not line.is_simple: + raise GameMapError( + f"Road joint {node.node_id!r} produces a degenerate or " + "self-intersecting curve" + ) + joints[node.node_id] = centerline + return trimmed, joints + + +def _line_parts(geometry: BaseGeometry) -> list[np.ndarray]: + if geometry.is_empty: + return [] + if geometry.geom_type == "LineString": + values = [geometry] + elif geometry.geom_type == "MultiLineString": + values = list(geometry.geoms) + elif geometry.geom_type == "GeometryCollection": + values = [item for item in geometry.geoms if item.geom_type == "LineString"] + else: + return [] + return [np.asarray(item.coords, dtype=np.float64) for item in values if item.length] + + +def _trim_line( + points: np.ndarray, + start: Polygon | None, + end: Polygon | None, + context: str, +) -> np.ndarray: + remaining: BaseGeometry = LineString(points) + if start is not None: + remaining = remaining.difference(start.buffer(1.0e-5)) + if end is not None: + remaining = remaining.difference(end.buffer(1.0e-5)) + parts = _line_parts(remaining) + if not parts: + raise GameMapError( + f"{context} is completely contained by its endpoint footprints" + ) + result = max(parts, key=lambda item: LineString(item).length) + original_start = points[0] + if np.linalg.norm(result[0] - original_start) > np.linalg.norm( + result[-1] - original_start + ): + result = result[::-1] + return result + + +def _polyline_prefix(points: np.ndarray, length_m: float) -> np.ndarray: + """Return the exact prefix of a polyline through ``length_m``.""" + line = LineString(points) + prefix = substring(line, 0.0, min(length_m, line.length)) + if prefix.geom_type != "LineString" or prefix.length <= 0.0: + raise GameMapError("Intersection arm path is degenerate") + return np.asarray(prefix.coords, dtype=np.float64) + + +def _polyline_end_tangent(points: np.ndarray) -> np.ndarray: + """Return a stable unit tangent at the end of a sampled polyline.""" + for index in range(len(points) - 1, 0, -1): + vector = points[-1] - points[index - 1] + length = float(np.linalg.norm(vector)) + if length > _POSITION_TOLERANCE_M: + return vector / length + raise GameMapError("Polyline endpoint has no stable tangent") + + +def _inferred_intersection_arm_reaches( + incident: list[tuple[np.ndarray, float]], +) -> tuple[list[float], list[int], list[np.ndarray | None]]: + """Infer arm openings and roadside corners from approach geometry.""" + directions: list[np.ndarray] = [] + left_normals: list[np.ndarray] = [] + centerlines: list[LineString] = [] + center_distances: list[np.ndarray] = [] + left_boundary_distances: list[np.ndarray] = [] + right_boundary_distances: list[np.ndarray] = [] + left_boundaries: list[LineString] = [] + right_boundaries: list[LineString] = [] + for path, _width in incident: + direction = path[1] - path[0] + direction /= max(float(np.linalg.norm(direction)), 1.0e-9) + directions.append(direction) + left_normals.append(np.asarray([-direction[1], direction[0]])) + for path, width in incident: + widths = np.full(len(path), width, dtype=np.float64) + left = _variable_offset_polyline(path, widths * 0.5) + right = _variable_offset_polyline(path, -widths * 0.5) + centerlines.append(LineString(path)) + center_distances.append( + np.concatenate( + ([0.0], np.cumsum(np.linalg.norm(np.diff(path, axis=0), axis=1))) + ) + ) + left_boundary_distances.append( + np.concatenate( + ([0.0], np.cumsum(np.linalg.norm(np.diff(left, axis=0), axis=1))) + ) + ) + right_boundary_distances.append( + np.concatenate( + ([0.0], np.cumsum(np.linalg.norm(np.diff(right, axis=0), axis=1))) + ) + ) + left_boundaries.append(LineString(left)) + right_boundaries.append(LineString(right)) + + reaches = [0.0 for _path, _width in incident] + order = sorted( + range(len(incident)), + key=lambda index: math.atan2(directions[index][1], directions[index][0]), + ) + bearings = [math.atan2(direction[1], direction[0]) for direction in directions] + corners: list[np.ndarray | None] = [] + for order_index, first_index in enumerate(order): + second_index = order[(order_index + 1) % len(order)] + first_direction = directions[first_index] + second_direction = directions[second_index] + sector_angle = (bearings[second_index] - bearings[first_index]) % ( + 2.0 * math.pi + ) + crossing: BaseGeometry = Point() + if sector_angle < math.pi - 1.0e-6: + crossing = left_boundaries[first_index].intersection( + right_boundaries[second_index] + ) + crossing_points: list[np.ndarray] = [] + if crossing.geom_type == "Point" and not crossing.is_empty: + crossing_points.append(np.asarray(crossing.coords[0], dtype=np.float64)) + elif crossing.geom_type in {"MultiPoint", "GeometryCollection"}: + crossing_points.extend( + np.asarray(part.coords[0], dtype=np.float64) + for part in crossing.geoms + if part.geom_type == "Point" + ) + if crossing_points: + corner = min( + crossing_points, + key=lambda point: centerlines[first_index].project(Point(point)) + + centerlines[second_index].project(Point(point)), + ) + first_boundary_distance = left_boundaries[first_index].project( + Point(corner) + ) + second_boundary_distance = right_boundaries[second_index].project( + Point(corner) + ) + first_center_distance = float( + np.interp( + first_boundary_distance, + left_boundary_distances[first_index], + center_distances[first_index], + ) + ) + second_center_distance = float( + np.interp( + second_boundary_distance, + right_boundary_distances[second_index], + center_distances[second_index], + ) + ) + if ( + first_center_distance + < centerlines[first_index].length - _POSITION_TOLERANCE_M + and second_center_distance + < centerlines[second_index].length - _POSITION_TOLERANCE_M + ): + corners.append(corner) + reaches[first_index] = max(reaches[first_index], first_center_distance) + reaches[second_index] = max( + reaches[second_index], second_center_distance + ) + continue + if sector_angle >= math.pi - 1.0e-6: + corners.append(None) + continue + matrix = np.column_stack((first_direction, -second_direction)) + if abs(float(np.linalg.det(matrix))) <= 1.0e-9: + corners.append(None) + continue + first_width = incident[first_index][1] + second_width = incident[second_index][1] + first_edge = left_normals[first_index] * first_width * 0.5 + second_edge = -left_normals[second_index] * second_width * 0.5 + first_reach, second_reach = np.linalg.solve(matrix, second_edge - first_edge) + if ( + first_reach < -_POSITION_TOLERANCE_M + or second_reach < -_POSITION_TOLERANCE_M + or first_reach >= centerlines[first_index].length - _POSITION_TOLERANCE_M + or second_reach >= centerlines[second_index].length - _POSITION_TOLERANCE_M + ): + corners.append(None) + continue + corner = ( + incident[first_index][0][0] + first_edge + (first_direction * first_reach) + ) + corners.append(corner) + if first_reach > 0.0: + reaches[first_index] = max(reaches[first_index], float(first_reach)) + if second_reach > 0.0: + reaches[second_index] = max(reaches[second_index], float(second_reach)) + + if len(incident) == 2 and max(reaches) <= _POSITION_TOLERANCE_M: + fallback = max(width for _path, width in incident) * 0.5 + reaches = [fallback, fallback] + reaches = [ + reach + _BOUNDARY_CLEARANCE_M if reach > 0.0 else reach for reach in reaches + ] + return reaches, order, corners + + +def _parking_access_path( + access: GameMapParkingAccess, + nodes: dict[str, GameMapNode], + spacing_m: float, +) -> tuple[np.ndarray, float]: + """Infer a tangent cubic from a road node to one authored lot edge.""" + source = nodes[access.source_node_id] + lot = nodes[access.parking_lot_node_id] + vertices = np.asarray(lot.polygon_vertices_xy, dtype=np.float64) + first = vertices[access.opening_vertex_index] + second = vertices[(access.opening_vertex_index + 1) % len(vertices)] + edge = second - first + width = float(np.linalg.norm(edge)) + if width <= _POSITION_TOLERANCE_M: + raise GameMapError( + f"Parking access {access.access_id!r} has a degenerate opening edge" + ) + edge_direction = edge / width + outward = np.asarray([-edge_direction[1], edge_direction[0]]) + end = 0.5 * (first + second) + start = np.asarray([source.x_m, source.y_m], dtype=np.float64) + chord = end - start + chord_length = float(np.linalg.norm(chord)) + if chord_length <= _POSITION_TOLERANCE_M: + raise GameMapError(f"Parking access {access.access_id!r} is degenerate") + if float(np.dot(start - end, outward)) <= _POSITION_TOLERANCE_M: + raise GameMapError( + f"Parking access {access.access_id!r} source must be outside the lot " + "on the opening edge's exterior side" + ) + handle = chord_length / 3.0 + control_1 = start + chord / chord_length * handle + inward = -outward + control_2 = end - inward * handle + span = np.asarray([start, control_1, control_2, end]) + estimate = sum( + float(np.linalg.norm(span[index + 1] - span[index])) for index in range(3) + ) + samples = max(8, int(math.ceil(estimate / spacing_m)) + 1) + t = np.linspace(0.0, 1.0, samples)[:, None] + path = ( + (1.0 - t) ** 3 * span[0] + + 3.0 * (1.0 - t) ** 2 * t * span[1] + + 3.0 * (1.0 - t) * t**2 * span[2] + + t**3 * span[3] + ) + if not LineString(path).is_simple: + raise GameMapError( + f"Parking access {access.access_id!r} produces a self-intersecting curve" + ) + return path, width + + +def _polyline_section( + points: np.ndarray, + start_m: float, + end_m: float, + context: str, +) -> np.ndarray: + line = LineString(points) + if end_m >= line.length - _POSITION_TOLERANCE_M: + raise GameMapError( + f"{context} transition length {end_m - start_m:g} m consumes its " + f"road arm (available length {max(0.0, line.length - start_m):.3f} m)" + ) + section = substring(line, start_m, end_m) + if section.geom_type != "LineString" or section.length <= _POSITION_TOLERANCE_M: + raise GameMapError(f"{context} produces a degenerate lane transition") + coordinates = np.asarray(section.coords, dtype=np.float64) + cleaned = [coordinates[0]] + for index, point in enumerate(coordinates[1:], start=1): + if np.linalg.norm(point - cleaned[-1]) > _POSITION_TOLERANCE_M: + cleaned.append(point) + elif index == len(coordinates) - 1 and len(cleaned) > 1: + cleaned[-1] = point + if len(cleaned) < 2: + raise GameMapError(f"{context} produces a degenerate lane transition") + return np.asarray(cleaned, dtype=np.float64) + + +def _variable_offset_polyline( + points: np.ndarray, + offsets: np.ndarray, +) -> np.ndarray: + tangents = np.empty_like(points) + tangents[0] = points[1] - points[0] + tangents[-1] = points[-1] - points[-2] + if len(points) > 2: + tangents[1:-1] = points[2:] - points[:-2] + lengths = np.linalg.norm(tangents, axis=1) + if np.any(lengths <= 1.0e-9): + raise GameMapError("Lane transition has a degenerate centerline tangent") + normals = np.column_stack((-tangents[:, 1], tangents[:, 0])) / lengths[:, None] + return points + normals * offsets[:, None] + + +def _ribbon_sides( + points: np.ndarray, + widths_m: np.ndarray, + context: str, + start_opening_xy: np.ndarray | None = None, + end_opening_xy: np.ndarray | None = None, +) -> tuple[np.ndarray, np.ndarray]: + """Offset both sides of a centerline and optionally pin its openings.""" + if len(points) != len(widths_m): + raise AssertionError(f"{context} has mismatched path and width samples") + left = _remove_rail_loops(_variable_offset_polyline(points, widths_m * 0.5)) + right = _remove_rail_loops(_variable_offset_polyline(points, -widths_m * 0.5)) + if start_opening_xy is not None: + first = start_opening_xy[0].copy() + second = start_opening_xy[1].copy() + direct = np.linalg.norm(left[0] - first) + np.linalg.norm(right[0] - second) + reverse = np.linalg.norm(left[0] - second) + np.linalg.norm(right[0] - first) + if direct <= reverse: + left[0], right[0] = first, second + else: + left[0], right[0] = second, first + if end_opening_xy is not None: + first = end_opening_xy[0].copy() + second = end_opening_xy[1].copy() + direct = np.linalg.norm(left[-1] - first) + np.linalg.norm(right[-1] - second) + reverse = np.linalg.norm(left[-1] - second) + np.linalg.norm(right[-1] - first) + if direct <= reverse: + left[-1], right[-1] = first, second + else: + left[-1], right[-1] = second, first + return left, right + + +def _remove_rail_loops(points: np.ndarray) -> np.ndarray: + """Trim self-intersecting loops from an offset boundary rail.""" + cleaned: list[np.ndarray] = [points[0], points[1]] + for point in points[2:]: + current = LineString((cleaned[-1], point)) + crossing_index: int | None = None + crossing_point: np.ndarray | None = None + for index in range(len(cleaned) - 2): + crossing = current.intersection( + LineString((cleaned[index], cleaned[index + 1])) + ) + if crossing.geom_type == "Point" and not crossing.is_empty: + crossing_index = index + crossing_point = np.asarray(crossing.coords[0], dtype=np.float64) + break + if crossing_index is not None and crossing_point is not None: + cleaned = [*cleaned[: crossing_index + 1], crossing_point, point] + else: + cleaned.append(point) + return np.asarray(cleaned) + + +def _polygon_from_ribbon( + left: np.ndarray, + right: np.ndarray, + context: str, +) -> Polygon: + """Build one explicit surface from paired boundary rails.""" + polygon = Polygon(np.vstack((left, right[::-1]))) + if not polygon.is_valid or polygon.area <= _AREA_TOLERANCE_M2 or polygon.interiors: + raise GameMapError( + f"{context} produces an invalid boundary ribbon: {is_valid_reason(polygon)}" + ) + return polygon + + +def _road_joint_ribbon( + points: np.ndarray, + widths_m: np.ndarray, + context: str, +) -> tuple[np.ndarray, np.ndarray, Polygon]: + """Build a compact joint ribbon while preserving its curved outside rail. + + Args: + points: Sampled joint centerline. + widths_m: Paved width at each centerline sample. + context: Element description used in validation errors. + + Returns: + Left and right roadside rails with their enclosed surface polygon. + + Raises: + GameMapError: The rails cannot form one valid surface. + """ + left, right = _ribbon_sides(points, widths_m, context) + polygon = Polygon(np.vstack((left, right[::-1]))) + if polygon.is_valid and polygon.area > _AREA_TOLERANCE_M2: + return left, right, polygon + + start_direction = points[1] - points[0] + end_direction = points[-1] - points[-2] + turn = float( + start_direction[0] * end_direction[1] - start_direction[1] * end_direction[0] + ) + inner = left if turn > 0.0 else right + first_tangent = inner[1] - inner[0] + last_tangent = inner[-1] - inner[-2] + matrix = np.column_stack((first_tangent, -last_tangent)) + if abs(float(np.linalg.det(matrix))) <= 1.0e-9: + return left, right, _polygon_from_ribbon(left, right, context) + first_distance, _last_distance = np.linalg.solve( + matrix, + inner[-1] - inner[0], + ) + vertex = inner[0] + first_tangent * first_distance + mitered = np.asarray((inner[0], vertex, inner[-1]), dtype=np.float64) + if turn > 0.0: + left = mitered + else: + right = mitered + return left, right, _polygon_from_ribbon(left, right, context) + + +def _taper_polygon( + points: np.ndarray, + start_width_m: float, + end_width_m: float, + context: str, +) -> Polygon: + segment_lengths = np.linalg.norm(np.diff(points, axis=0), axis=1) + distances = np.concatenate(([0.0], np.cumsum(segment_lengths))) + alpha = distances / max(float(distances[-1]), 1.0e-9) + widths = start_width_m + alpha * (end_width_m - start_width_m) + left, right = _ribbon_sides(points, widths, context) + return _polygon_from_ribbon(left, right, context) + + +def _linear_width_samples( + points: np.ndarray, + start_width_m: float, + end_width_m: float, +) -> np.ndarray: + """Interpolate surface widths by distance along a sampled centerline.""" + distances = np.concatenate( + ([0.0], np.cumsum(np.linalg.norm(np.diff(points, axis=0), axis=1))) + ) + alpha = distances / max(float(distances[-1]), 1.0e-9) + return start_width_m + alpha * (end_width_m - start_width_m) + + +def _multiarm_node_polygon( + node: GameMapNode, + incident: list[tuple[np.ndarray, float, str]], + transitions: dict[tuple[str, str], _ArmTransition], +) -> tuple[ + Polygon, + dict[str, np.ndarray], + dict[tuple[str, str], _TransitionGeometry], +]: + """Trace a multi-arm node from connected roadside boundaries.""" + reaches, order, corners = _inferred_intersection_arm_reaches( + [(path, width) for path, width, _reference_id in incident] + ) + arms: dict[int, _BoundaryArmGeometry] = {} + openings: dict[str, np.ndarray] = {} + transition_geometry: dict[tuple[str, str], _TransitionGeometry] = {} + for index, (path, width, reference_id) in enumerate(incident): + reach = reaches[index] + path_length = LineString(path).length + if reach >= path_length - _POSITION_TOLERANCE_M: + raise GameMapError( + f"Node {node.node_id!r} opening for {reference_id!r} consumes " + f"its approach ({reach:.3f} m required, {path_length:.3f} m available)" + ) + core_path = _polyline_prefix(path, max(reach, _POSITION_TOLERANCE_M * 2.0)) + tangent = _polyline_end_tangent(core_path) + normal = np.asarray([-tangent[1], tangent[0]]) + core_left = core_path[-1] + normal * width * 0.5 + core_right = core_path[-1] - normal * width * 0.5 + + transition = transitions.get((node.node_id, reference_id)) + if transition is None: + left = core_left[None, :] + right = core_right[None, :] + else: + context = f"Node {node.node_id!r} road {reference_id!r}" + transition_path = _polyline_section( + path, + reach, + reach + transition.length_m, + context, + ) + widths = _linear_width_samples( + transition_path, + transition.local_attributes.surface_width_m, + transition.arm.attributes.surface_width_m, + ) + left, right = _ribbon_sides(transition_path, widths, context) + left[0] = core_left + right[0] = core_right + transition_geometry[(node.node_id, reference_id)] = _TransitionGeometry( + transition, + transition_path, + ) + arms[index] = _BoundaryArmGeometry(reference_id, left, right) + opening = np.asarray([right[-1], left[-1]]) + if np.linalg.norm(opening[1] - opening[0]) <= _LINE_TOLERANCE_M: + raise GameMapError( + f"Node {node.node_id!r} produces a degenerate opening for " + f"{reference_id!r}" + ) + openings[reference_id] = opening + + first = arms[order[0]] + perimeter: list[np.ndarray] = [first.right_xy[-1], first.left_xy[-1]] + perimeter.extend(first.left_xy[-2::-1]) + for order_index in range(len(order)): + corner = corners[order_index] + if corner is not None: + perimeter.append(corner) + next_index = order[(order_index + 1) % len(order)] + next_arm = arms[next_index] + perimeter.extend(next_arm.right_xy) + if next_index == order[0]: + break + perimeter.append(next_arm.left_xy[-1]) + perimeter.extend(next_arm.left_xy[-2::-1]) + + cleaned = [perimeter[0]] + for point in perimeter[1:]: + if np.linalg.norm(point - cleaned[-1]) > _POSITION_TOLERANCE_M: + cleaned.append(point) + if len(cleaned) > 1 and np.linalg.norm(cleaned[0] - cleaned[-1]) <= ( + _POSITION_TOLERANCE_M + ): + cleaned.pop() + polygon = Polygon(cleaned) + if not polygon.is_valid: + linework = unary_union(LineString(np.vstack((cleaned, cleaned[0])))) + candidates = list(polygonize(linework)) + resolved = unary_union(candidates) + if isinstance(resolved, Polygon): + polygon = resolved + extent = max( + 100.0, + max(float(np.linalg.norm(point - [node.x_m, node.y_m])) for point in cleaned) + * 4.0, + ) + for opening in openings.values(): + opening_center = np.mean(opening, axis=0) + opening_tangent = opening[1] - opening[0] + opening_tangent /= float(np.linalg.norm(opening_tangent)) + outward = np.asarray([opening_tangent[1], -opening_tangent[0]]) + if np.dot(outward, opening_center - [node.x_m, node.y_m]) < 0.0: + outward *= -1.0 + clip = Polygon( + ( + opening_center + opening_tangent * extent, + opening_center - opening_tangent * extent, + opening_center - opening_tangent * extent - outward * extent, + opening_center + opening_tangent * extent - outward * extent, + ) + ) + polygon = polygon.intersection(clip) + support = Polygon( + ( + opening[0], + opening[1], + opening[1] - outward * _POSITION_TOLERANCE_M, + opening[0] - outward * _POSITION_TOLERANCE_M, + ) + ) + supported = polygon.union(support) + if isinstance(supported, Polygon): + polygon = supported + if ( + not isinstance(polygon, Polygon) + or not polygon.is_valid + or polygon.area <= _AREA_TOLERANCE_M2 + or polygon.interiors + ): + raise GameMapError( + f"Node {node.node_id!r} produces an invalid boundary-driven footprint: " + f"{is_valid_reason(polygon)}" + ) + return polygon, openings, transition_geometry + + +def _node_polygons( + topology: GameMapTopology, + raw_roads: dict[str, np.ndarray], + road_joint_centerlines: dict[str, np.ndarray], + parking_access_paths: dict[str, tuple[np.ndarray, float]], + transitions: dict[tuple[str, str], _ArmTransition], +) -> tuple[ + dict[str, Polygon], + dict[tuple[str, str], _TransitionGeometry], + dict[tuple[str, str], np.ndarray], +]: + incidences: dict[str, list[tuple[np.ndarray, float, str | None]]] = { + node.node_id: [] for node in topology.nodes + } + for road in topology.roads: + points = raw_roads[road.road_id] + for endpoint, node_id, path in ( + ("from", road.from_node_id, points), + ("to", road.to_node_id, points[::-1]), + ): + transition = transitions.get((node_id, road.road_id)) + width = ( + transition.local_attributes.surface_width_m + if transition is not None + else road.attributes.surface_width_m + ) + reference_id = ( + f"{road.road_id}:{endpoint}" + if road.from_node_id == road.to_node_id + else road.road_id + ) + incidences[node_id].append((path, width, reference_id)) + for access in topology.parking_accesses: + path, width = parking_access_paths[access.access_id] + incidences[access.source_node_id].append((path, width, access.access_id)) + polygons: dict[str, Polygon] = {} + transition_geometry: dict[tuple[str, str], _TransitionGeometry] = {} + node_openings: dict[tuple[str, str], np.ndarray] = {} + for node in topology.nodes: + center = np.asarray([node.x_m, node.y_m]) + if node.node_type == "driveway": + assert isinstance(node.attributes, GameMapLinearAttributes) + joint_roads = sorted( + ( + road + for road in topology.roads + if node.node_id in {road.from_node_id, road.to_node_id} + ), + key=lambda road: road.road_id, + ) + centerline = road_joint_centerlines[node.node_id] + driveway_incident: list[tuple[np.ndarray, float, str]] = [] + for road, cut in zip( + joint_roads, (centerline[0], centerline[-1]), strict=True + ): + outward = _road_path_from_node( + road, raw_roads[road.road_id], node.node_id + ) + branch = np.vstack((center, cut, outward[1:])) + driveway_incident.append( + (branch, node.attributes.surface_width_m, road.road_id) + ) + access = next( + access + for access in topology.parking_accesses + if access.source_node_id == node.node_id + ) + access_path, access_width = parking_access_paths[access.access_id] + driveway_incident.append((access_path, access_width, access.access_id)) + polygon, openings, resolved_transitions = _multiarm_node_polygon( + node, + driveway_incident, + transitions, + ) + node_openings.update( + ((node.node_id, reference_id), opening) + for reference_id, opening in openings.items() + ) + transition_geometry.update(resolved_transitions) + elif node.node_type == "intersection": + incident = incidences[node.node_id] + if not incident: + raise GameMapError( + f"Node {node.node_id!r} must have at least one incidence" + ) + polygon, openings, resolved_transitions = _multiarm_node_polygon( + node, + [ + (path, width, reference_id) + for path, width, reference_id in incident + if reference_id is not None + ], + transitions, + ) + node_openings.update( + ((node.node_id, reference_id), opening) + for reference_id, opening in openings.items() + ) + transition_geometry.update(resolved_transitions) + elif node.node_type == "road_joint": + assert isinstance(node.attributes, GameMapLinearAttributes) + centerline = road_joint_centerlines[node.node_id] + joint_roads = sorted( + ( + road + for road in topology.roads + if node.node_id in {road.from_node_id, road.to_node_id} + ), + key=lambda road: road.road_id, + ) + endpoint_layouts = ( + _reversed_attributes(node.attributes), + node.attributes, + ) + endpoint_widths = [ + _lane_layout_for_arm( + layout, + _outward_attributes(road, node.node_id), + ).surface_width_m + for road, layout in zip( + joint_roads, + endpoint_layouts, + strict=True, + ) + ] + path_parts: list[np.ndarray] = [] + width_parts: list[np.ndarray] = [] + first_transition = transitions.get((node.node_id, joint_roads[0].road_id)) + if first_transition is not None: + context = f"Road joint {node.node_id!r} road {joint_roads[0].road_id!r}" + outward = _road_path_from_node( + joint_roads[0], raw_roads[joint_roads[0].road_id], node.node_id + ) + transition_path = _polyline_section( + outward, 0.0, first_transition.length_m, context + ) + transition_geometry[(node.node_id, joint_roads[0].road_id)] = ( + _TransitionGeometry(first_transition, transition_path) + ) + path_parts.append(transition_path[::-1]) + width_parts.append( + _linear_width_samples( + transition_path, + first_transition.local_attributes.surface_width_m, + first_transition.arm.attributes.surface_width_m, + )[::-1] + ) + path_parts.append(centerline) + width_parts.append( + _linear_width_samples( + centerline, endpoint_widths[0], endpoint_widths[1] + ) + ) + second_transition = transitions.get((node.node_id, joint_roads[1].road_id)) + if second_transition is not None: + context = f"Road joint {node.node_id!r} road {joint_roads[1].road_id!r}" + outward = _road_path_from_node( + joint_roads[1], raw_roads[joint_roads[1].road_id], node.node_id + ) + transition_path = _polyline_section( + outward, 0.0, second_transition.length_m, context + ) + transition_geometry[(node.node_id, joint_roads[1].road_id)] = ( + _TransitionGeometry(second_transition, transition_path) + ) + path_parts.append(transition_path) + width_parts.append( + _linear_width_samples( + transition_path, + second_transition.local_attributes.surface_width_m, + second_transition.arm.attributes.surface_width_m, + ) + ) + combined_path = path_parts[0] + combined_widths = width_parts[0] + for path_part, width_part in zip( + path_parts[1:], width_parts[1:], strict=True + ): + combined_path = np.vstack((combined_path, path_part[1:])) + combined_widths = np.concatenate((combined_widths, width_part[1:])) + context = f"Road joint {node.node_id!r}" + left, right, polygon = _road_joint_ribbon( + combined_path, + combined_widths, + context, + ) + node_openings[(node.node_id, joint_roads[0].road_id)] = np.asarray( + [right[0], left[0]] + ) + node_openings[(node.node_id, joint_roads[1].road_id)] = np.asarray( + [right[-1], left[-1]] + ) + elif node.node_type == "cul_de_sac": + radius = node.geometry["culdesac_radius_m"] + path, opening_width, road_id = incidences[node.node_id][0] + assert road_id is not None + vector = path[1] - path[0] + direction = vector / max(float(np.linalg.norm(vector)), 1.0e-9) + normal = np.asarray([-direction[1], direction[0]]) + chord_distance = math.sqrt(radius**2 - (opening_width * 0.5) ** 2) + opening_center = center + direction * chord_distance + right = opening_center - normal * opening_width * 0.5 + left = opening_center + normal * opening_width * 0.5 + bearing = math.atan2(direction[1], direction[0]) + half_angle = math.asin(opening_width * 0.5 / radius) + angles = np.linspace( + bearing + half_angle, + bearing + 2.0 * math.pi - half_angle, + 129, + ) + arc = center + radius * np.column_stack((np.cos(angles), np.sin(angles))) + polygon = Polygon(np.vstack((right, left, arc[1:-1]))) + node_openings[(node.node_id, road_id)] = np.asarray([right, left]) + elif node.node_type == "parking_lot": + polygon = Polygon(node.polygon_vertices_xy) + vertices = np.asarray(node.polygon_vertices_xy, dtype=np.float64) + for access in topology.parking_accesses: + if access.parking_lot_node_id != node.node_id: + continue + first = vertices[access.opening_vertex_index] + second = vertices[(access.opening_vertex_index + 1) % len(vertices)] + node_openings[(node.node_id, access.access_id)] = np.asarray( + [first, second] + ) + else: + raise AssertionError(f"Unsupported footprint node {node.node_type!r}") + if polygon.geom_type == "MultiPolygon": + parts = [part for part in polygon.geoms if part.area > _AREA_TOLERANCE_M2] + if len(parts) == 1: + polygon = parts[0] + if not isinstance(polygon, Polygon) or polygon.area <= 0.0: + raise GameMapError(f"Node {node.node_id!r} has an invalid footprint") + polygons[node.node_id] = polygon + return polygons, transition_geometry, node_openings + + +def _surface_array(polygon: Polygon) -> np.ndarray: + """Convert a resolved surface polygon to world coordinates.""" + points = np.asarray(polygon.exterior.coords, dtype=np.float64) + return np.column_stack((points, np.zeros(len(points), dtype=np.float64))) + + +def _exclude_connected_footprints( + surface: Polygon, + excluded: tuple[Polygon, ...], + context: str, +) -> Polygon: + """Trim numeric seam overlap from an explicit corridor ribbon.""" + geometry: BaseGeometry = surface + for footprint in excluded: + geometry = geometry.difference(footprint) + if isinstance(geometry, Polygon): + return geometry + parts = [ + part + for part in getattr(geometry, "geoms", ()) + if isinstance(part, Polygon) and part.area > _AREA_TOLERANCE_M2 + ] + if len(parts) != 1: + raise GameMapError(f"{context} does not retain one connected surface") + return parts[0] + + +def _boundaries_for_elements( + elements: list[GameMapElement], + connections: list[_Connection], + permitted_boundary_contacts: set[tuple[str, str]] | None = None, + curb_regions: dict[str, list[tuple[BaseGeometry, bool]]] | None = None, +) -> list[GameMapElement]: + """Validate contacts and attach semantic boundaries and physical curbs.""" + polygons = { + element.element_id: Polygon(element.surface_world[:, :2]) + for element in elements + } + for element_id, polygon in polygons.items(): + if not polygon.is_valid: + raise GameMapError( + f"Element {element_id!r} has an invalid surface: " + f"{is_valid_reason(polygon)}" + ) + connection_groups: dict[tuple[str, str], list[_Connection]] = {} + for connection in connections: + pair = tuple( + sorted((connection.first_element_id, connection.second_element_id)) + ) + connection_groups.setdefault(pair, []).append(connection) + + openings: dict[str, list[BaseGeometry]] = { + element.element_id: [] for element in elements + } + element_ids = [element.element_id for element in elements] + for first_index, first_id in enumerate(element_ids): + first = polygons[first_id] + for second_id in element_ids[first_index + 1 :]: + second = polygons[second_id] + pair = tuple(sorted((first_id, second_id))) + overlap_area = first.intersection(second).area + declared = connection_groups.get(pair) + if declared is None: + if overlap_area > _AREA_TOLERANCE_M2: + raise GameMapError( + f"Unrelated elements {first_id!r} and {second_id!r} overlap " + f"by {overlap_area:.6f} m^2" + ) + if pair not in (permitted_boundary_contacts or set()) and ( + first.boundary.intersection(second.boundary).length + > _LINE_TOLERANCE_M + ): + raise GameMapError( + f"Unrelated elements {first_id!r} and {second_id!r} " + "share a boundary" + ) + continue + if overlap_area > _AREA_TOLERANCE_M2: + labels = ", ".join(item.connection_id for item in declared) + raise GameMapError( + f"Connected elements {first_id!r} and {second_id!r} overlap " + f"by {overlap_area:.6f} m^2 at {labels}" + ) + for connection in declared: + opening = LineString(connection.opening_xy) + first_error = opening.difference( + first.boundary.buffer(_OPENING_TOLERANCE_M) + ).length + second_error = opening.difference( + second.boundary.buffer(_OPENING_TOLERANCE_M) + ).length + if ( + opening.length <= _LINE_TOLERANCE_M + or first_error > _OPENING_TOLERANCE_M + or second_error > _OPENING_TOLERANCE_M + ): + raise GameMapError( + f"Connection {connection.connection_id!r} between " + f"{first_id!r} and {second_id!r} has mismatched openings " + f"({first_error:.9f}/{second_error:.9f} m outside boundaries, " + f"{opening.length:.9f} m long)" + ) + openings[first_id].append(opening) + openings[second_id].append(opening) + + resolved: list[GameMapElement] = [] + for element in elements: + boundary: BaseGeometry = polygons[element.element_id].boundary + for opening in openings[element.element_id]: + boundary = boundary.difference( + opening.buffer( + _OPENING_TOLERANCE_M, + cap_style=2, + join_style=2, + ) + ) + parts = sorted( + ( + points + for points in _line_parts(boundary) + if LineString(points).length > _OPENING_TOLERANCE_M * 2.0 + ), + key=lambda points: ( + round(float(np.min(points[:, 0])), 6), + round(float(np.min(points[:, 1])), 6), + round(float(np.max(points[:, 0])), 6), + round(float(np.max(points[:, 1])), 6), + ), + ) + road_boundaries = tuple( + GameMapRoadBoundary( + boundary_id=f"{element.element_id}:road_boundary:{index}", + polyline_world=_xyz(points), + ) + for index, points in enumerate(parts) + if len(points) >= 2 + ) + remaining_curb_boundary = boundary + selected_curb_parts: list[np.ndarray] = [] + for region, enabled in (curb_regions or {}).get(element.element_id, []): + selected = remaining_curb_boundary.intersection(region) + if enabled: + selected_curb_parts.extend(_line_parts(selected)) + remaining_curb_boundary = remaining_curb_boundary.difference(region) + if element.attributes.curb: + selected_curb_parts.extend(_line_parts(remaining_curb_boundary)) + selected_curb_parts.sort( + key=lambda points: ( + round(float(np.min(points[:, 0])), 6), + round(float(np.min(points[:, 1])), 6), + round(float(np.max(points[:, 0])), 6), + round(float(np.max(points[:, 1])), 6), + ) + ) + curbs = tuple( + GameMapCurb( + curb_id=f"{element.element_id}:curb:{index}", + polyline_world=_xyz(points), + ) + for index, points in enumerate(selected_curb_parts) + if len(points) >= 2 + and LineString(points).length > _OPENING_TOLERANCE_M * 2.0 + ) + resolved.append(replace(element, road_boundaries=road_boundaries, curbs=curbs)) + return resolved + + +def _build_linear_lanes( + element_id: str, + points: np.ndarray, + attributes: GameMapLinearAttributes, + allows_taxi_stops: bool, +) -> list[_LaneBuild]: + lanes: list[_LaneBuild] = [] + for index, direction in enumerate(attributes.directions): + left_marking, right_marking = _lane_edge_markings(attributes, index, direction) + offset = ( + len(attributes.directions) - 1 + ) * attributes.lane_width_m * 0.5 - index * attributes.lane_width_m + center = _offset_polyline(points, offset) + start_endpoint, end_endpoint = "from", "to" + if direction == "backward": + center = center[::-1] + start_endpoint, end_endpoint = end_endpoint, start_endpoint + left = _offset_polyline(center, attributes.lane_width_m * 0.5) + right = _offset_polyline(center, -attributes.lane_width_m * 0.5) + roadside = _offset_polyline( + center, + -(attributes.lane_width_m * 0.5 + attributes.curb_offset_m), + ) + lanes.append( + _LaneBuild( + lane_id=f"{element_id}:lane:{index}", + element_id=element_id, + centerline=_xyz(center), + left_edge=_xyz(left), + right_edge=_xyz(right), + roadside_edge=_xyz(roadside), + speed_limit_mps=attributes.speed_limit_mps, + marking_style=attributes.marking_style, + marking_color=attributes.marking_color, + start_endpoint=start_endpoint, + end_endpoint=end_endpoint, + successors=[], + allows_taxi_stops=allows_taxi_stops, + left_marking_style=left_marking[0], + left_marking_color=left_marking[1], + right_marking_style=right_marking[0], + right_marking_color=right_marking[1], + ) + ) + return lanes + + +def _lane_boundary_offsets(attributes: GameMapLinearAttributes) -> np.ndarray: + lane_count = len(attributes.directions) + return np.linspace( + lane_count * attributes.lane_width_m * 0.5, + -lane_count * attributes.lane_width_m * 0.5, + lane_count + 1, + ) + + +def _direction_groups(directions: tuple[str, ...]) -> list[tuple[str, int, int]]: + groups: list[tuple[str, int, int]] = [] + start = 0 + for index in range(1, len(directions) + 1): + if index == len(directions) or directions[index] != directions[start]: + groups.append((directions[start], start, index - start)) + start = index + return groups + + +def _transition_boundary_mapping( + local: GameMapLinearAttributes, + road: GameMapLinearAttributes, +) -> list[int]: + local_groups = _direction_groups(local.directions) + road_groups = _direction_groups(road.directions) + if [group[0] for group in local_groups] != [group[0] for group in road_groups]: + raise GameMapError("Lane transition changes directional lane ordering") + mapping = [0] * (len(local.directions) + 1) + for group_index, (local_group, road_group) in enumerate( + zip(local_groups, road_groups, strict=True) + ): + _direction, local_start, local_count = local_group + _road_direction, road_start, road_count = road_group + extra = local_count - road_count + if extra < 0: + raise GameMapError("Lane transition local profile is not dominant") + for boundary in range(local_count + 1): + if group_index == 0: + road_boundary = max(0, boundary - extra) + else: + road_boundary = min(boundary, road_count) + mapping[local_start + boundary] = road_start + road_boundary + return mapping + + +def _build_transition_lanes( + geometry: _TransitionGeometry, +) -> list[_LaneBuild]: + transition = geometry.transition + local = transition.local_attributes + road = transition.arm.attributes + path = geometry.path_xy + segment_lengths = np.linalg.norm(np.diff(path, axis=0), axis=1) + distances = np.concatenate(([0.0], np.cumsum(segment_lengths))) + alpha = distances / max(float(distances[-1]), 1.0e-9) + local_offsets = _lane_boundary_offsets(local) + road_offsets = _lane_boundary_offsets(road) + mapping = _transition_boundary_mapping(local, road) + boundaries = [ + _variable_offset_polyline( + path, + local_offsets[index] + + alpha * (road_offsets[remote_index] - local_offsets[index]), + ) + for index, remote_index in enumerate(mapping) + ] + + lanes: list[_LaneBuild] = [] + for index, direction in enumerate(local.directions): + upper = boundaries[index] + lower = boundaries[index + 1] + center = 0.5 * (upper + lower) + left, right = upper, lower + roadside_offsets = ( + local_offsets[index + 1] + + alpha * (road_offsets[mapping[index + 1]] - local_offsets[index + 1]) + - road.curb_offset_m + ) + roadside = _variable_offset_polyline(path, roadside_offsets) + kind = "start" + if direction == "backward": + center = center[::-1] + left, right = lower[::-1], upper[::-1] + roadside_offsets = ( + local_offsets[index] + + alpha * (road_offsets[mapping[index]] - local_offsets[index]) + + road.curb_offset_m + ) + roadside = _variable_offset_polyline(path, roadside_offsets)[::-1] + kind = "end" + left_marking, right_marking = _lane_edge_markings(local, index, direction) + lanes.append( + _LaneBuild( + lane_id=( + f"{transition.arm.node_id}:transition:" + f"{transition.arm.road.road_id}:lane:{index}" + ), + element_id=transition.arm.node_id, + centerline=_xyz(center), + left_edge=_xyz(left), + right_edge=_xyz(right), + roadside_edge=_xyz(roadside), + speed_limit_mps=road.speed_limit_mps, + marking_style=local.marking_style, + marking_color=local.marking_color, + start_endpoint="from" if kind == "start" else "to", + end_endpoint="to" if kind == "start" else "from", + successors=[], + allows_taxi_stops=False, + left_marking_style=left_marking[0], + left_marking_color=left_marking[1], + right_marking_style=right_marking[0], + right_marking_color=right_marking[1], + ) + ) + return lanes + + +def _splice_transition_lanes( + transition_geometry: dict[tuple[str, str], _TransitionGeometry], + incidences: dict[str, list[_LaneIncidence]], + lanes: list[_LaneBuild], + lane_dividers: list[GameMapLaneDivider], +) -> None: + """Replace narrow road incidences with visible node transition lanes.""" + for (node_id, road_id), geometry in sorted(transition_geometry.items()): + road_incidences = [ + incidence + for incidence in incidences[node_id] + if incidence.lane.element_id == road_id + ] + if not road_incidences: + raise AssertionError(f"Missing road incidences for {node_id!r}/{road_id!r}") + incidences[node_id] = [ + incidence + for incidence in incidences[node_id] + if incidence.lane.element_id != road_id + ] + built = _build_transition_lanes(geometry) + for lane, direction in zip( + built, + geometry.transition.local_attributes.directions, + strict=True, + ): + kind = "start" if direction == "forward" else "end" + candidates = [item for item in road_incidences if item.kind == kind] + transition_far = ( + lane.centerline[-1, :2] + if direction == "forward" + else lane.centerline[0, :2] + ) + target = min( + candidates, + key=lambda incidence: float( + np.linalg.norm( + ( + incidence.lane.centerline[0, :2] + if kind == "start" + else incidence.lane.centerline[-1, :2] + ) + - transition_far + ) + ), + ) + if direction == "forward": + lane.successors.append(target.lane.lane_id) + else: + target.lane.successors.append(lane.lane_id) + incidences[node_id].append( + _LaneIncidence(lane, node_id, kind, target.edge_ref) + ) + lanes.extend(built) + lane_dividers.extend( + _build_lane_dividers(built, geometry.transition.local_attributes) + ) + + +def _build_lane_dividers( + lanes: list[_LaneBuild], attributes: GameMapLinearAttributes +) -> list[GameMapLaneDivider]: + """Resolve authored profile dividers without rediscovering them geometrically.""" + dividers: list[GameMapLaneDivider] = [] + for index, (style, color) in enumerate(attributes.divider_markings): + if style == "VIRTUAL": + continue + first = lanes[index] + second = lanes[index + 1] + first_side = "right" if attributes.directions[index] == "forward" else "left" + second_side = ( + "left" if attributes.directions[index + 1] == "forward" else "right" + ) + first_edge = first.right_edge if first_side == "right" else first.left_edge + second_edge = second.right_edge if second_side == "right" else second.left_edge + if first_edge.shape != second_edge.shape: + raise GameMapError( + f"Adjacent lanes in {first.element_id!r} have mismatched samples" + ) + direct_error = float(np.linalg.norm(first_edge - second_edge, axis=1).max()) + reverse_error = float( + np.linalg.norm(first_edge - second_edge[::-1], axis=1).max() + ) + aligned_second = ( + second_edge if direct_error <= reverse_error else second_edge[::-1] + ) + lane_edges = ((first.lane_id, first_side), (second.lane_id, second_side)) + dividers.append( + GameMapLaneDivider( + divider_id=":".join(sorted((first.lane_id, second.lane_id))), + lane_edges=lane_edges, + polyline_world=np.mean((first_edge, aligned_second), axis=0).astype( + np.float32 + ), + style=style, + color=color, + ) + ) + return dividers + + +def _incidences_for_lanes( + lanes: list[_LaneBuild], a: str, b: str, edge_ref: str +) -> list[_LaneIncidence]: + result: list[_LaneIncidence] = [] + for lane in lanes: + if lane.start_endpoint == "from": + result.extend( + ( + _LaneIncidence(lane, a, "start", f"{edge_ref}:a"), + _LaneIncidence(lane, b, "end", f"{edge_ref}:b"), + ) + ) + else: + result.extend( + ( + _LaneIncidence(lane, b, "start", f"{edge_ref}:b"), + _LaneIncidence(lane, a, "end", f"{edge_ref}:a"), + ) + ) + return result + + +def _wire_node( + node: GameMapNode, + incidences: list[_LaneIncidence], + lanes: list[_LaneBuild], + connector_samples: int, + *, + access_turns_only: bool = False, +) -> None: + incoming = [item for item in incidences if item.kind == "end"] + outgoing = [item for item in incidences if item.kind == "start"] + connector_count = 0 + for source in incoming: + for target in outgoing: + source_is_access = source.edge_ref.startswith("parking_access:") + target_is_access = target.edge_ref.startswith("parking_access:") + if access_turns_only and source_is_access == target_is_access: + continue + if source.edge_ref == target.edge_ref and node.node_type != "cul_de_sac": + continue + if node.node_type == "parking_lot": + source.lane.successors.append(target.lane.lane_id) + continue + center = np.asarray([node.x_m, node.y_m, 0.0], dtype=np.float32) + if node.node_type == "cul_de_sac": + centerline = _bezier( + source.lane.centerline[-1], + center, + target.lane.centerline[0], + connector_samples, + ) + else: + start = source.lane.centerline[-1] + end = target.lane.centerline[0] + incoming_tangent = start - source.lane.centerline[-2] + outgoing_tangent = target.lane.centerline[1] - end + incoming_tangent /= max(float(np.linalg.norm(incoming_tangent)), 1.0e-9) + outgoing_tangent /= max(float(np.linalg.norm(outgoing_tangent)), 1.0e-9) + chord_length = float(np.linalg.norm(end - start)) + handle_length = chord_length * _INTERSECTION_TURN_HANDLE_RATIO + first_control = start + incoming_tangent * handle_length + second_control = end - outgoing_tangent * handle_length + t = np.linspace(0.0, 1.0, connector_samples, dtype=np.float32)[:, None] + centerline = ( + (1.0 - t) ** 3 * start + + 3.0 * (1.0 - t) ** 2 * t * first_control + + 3.0 * (1.0 - t) * t**2 * second_control + + t**3 * end + ).astype(np.float32) + width = float( + np.linalg.norm(source.lane.left_edge[-1] - source.lane.right_edge[-1]) + ) + left = _xyz(_offset_polyline(centerline[:, :2], width * 0.5)) + right = _xyz(_offset_polyline(centerline[:, :2], -width * 0.5)) + connector_id = f"{node.node_id}:connector:{connector_count}" + connector_count += 1 + connector = _LaneBuild( + lane_id=connector_id, + element_id=node.node_id, + centerline=centerline, + left_edge=left, + right_edge=right, + roadside_edge=right, + speed_limit_mps=source.lane.speed_limit_mps, + marking_style="VIRTUAL", + marking_color="WHITE", + start_endpoint="", + end_endpoint="", + successors=[target.lane.lane_id], + allows_taxi_stops=False, + conditioning_visible=False, + ) + lanes.append(connector) + source.lane.successors.append(connector_id) + + +def _wire_road_joint( + node: GameMapNode, + incidences: list[_LaneIncidence], + centerline_xy: np.ndarray, + lanes: list[_LaneBuild], + lane_dividers: list[GameMapLaneDivider], +) -> None: + """Build conditioning-visible lanes through one road joint.""" + assert isinstance(node.attributes, GameMapLinearAttributes) + joint_lanes = _build_linear_lanes( + node.node_id, + centerline_xy, + node.attributes, + False, + ) + incoming = [incidence for incidence in incidences if incidence.kind == "end"] + outgoing = [incidence for incidence in incidences if incidence.kind == "start"] + if len(incoming) != len(joint_lanes) or len(outgoing) != len(joint_lanes): + raise GameMapError( + f"Road joint {node.node_id!r} cannot pair its directed road lanes" + ) + + unused_incoming = list(incoming) + unused_outgoing = list(outgoing) + for joint_lane in joint_lanes: + source = min( + unused_incoming, + key=lambda incidence: float( + np.linalg.norm( + incidence.lane.centerline[-1, :2] - joint_lane.centerline[0, :2] + ) + ), + ) + target = min( + unused_outgoing, + key=lambda incidence: float( + np.linalg.norm( + incidence.lane.centerline[0, :2] - joint_lane.centerline[-1, :2] + ) + ), + ) + unused_incoming.remove(source) + unused_outgoing.remove(target) + joint_lane.speed_limit_mps = source.lane.speed_limit_mps + joint_lane.successors.append(target.lane.lane_id) + source.lane.successors.append(joint_lane.lane_id) + + lanes.extend(joint_lanes) + lane_dividers.extend(_build_lane_dividers(joint_lanes, node.attributes)) + + +def _spawn( + raw: dict[str, Any], + source_path: Path, + lane_by_id: dict[str, _LaneBuild], +) -> GameMapSpawn: + if set(raw) != {"id", "road", "lane", "distance_m", "variants"}: + raise GameMapError( + "Spawns require exactly id, road, lane, distance_m, and variants" + ) + spawn_id = str(raw["id"]).strip() + if not spawn_id: + raise GameMapError("Spawn id must not be empty") + lane_index = raw["lane"] + if type(lane_index) is not int or lane_index < 0: + raise GameMapError("spawn.lane must be a nonnegative integer") + lane_id = f"{str(raw['road'])}:lane:{lane_index}" + if lane_id not in lane_by_id or not lane_by_id[lane_id].allows_taxi_stops: + raise GameMapError(f"Spawn references unavailable road lane {lane_id!r}") + lane = lane_by_id[lane_id] + distance = _positive_float(raw["distance_m"], "spawn.distance_m") + points = lane.centerline + lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + total = float(np.sum(lengths)) + if distance >= total: + raise GameMapError( + f"Spawn distance {distance} must be below lane length {total}" + ) + cumulative = np.concatenate(([0.0], np.cumsum(lengths))) + segment = min( + int(np.searchsorted(cumulative, distance, side="right") - 1), len(lengths) - 1 + ) + alpha = (distance - cumulative[segment]) / max(float(lengths[segment]), 1.0e-9) + position = points[segment] + alpha * (points[segment + 1] - points[segment]) + direction = points[segment + 1] - points[segment] + return GameMapSpawn( + spawn_id=spawn_id, + lane_id=lane_id, + distance_m=distance, + position_world=position.astype(np.float32), + yaw_rad=math.atan2(float(direction[1]), float(direction[0])), + variants=_parse_variants(raw, source_path), + ) + + +def load_game_map(path: Path) -> ResolvedGameMap: + """Parse and compile the current node-graph schema into runtime geometry.""" + source_path = Path(path).expanduser().resolve() + doc = _read_document(source_path) + map_id, map_name = _parse_map_identity(doc) + settings = _parse_compiler_settings(doc) + profiles = _parse_profiles(doc) + node_values = _parse_nodes(doc, profiles) + nodes = {node.node_id: node for node in node_values} + road_specs = _parse_roads(doc, nodes, profiles) + node_values = _resolve_linear_joint_nodes(node_values, road_specs) + nodes = {node.node_id: node for node in node_values} + parking_accesses = _parking_accesses_from_nodes(doc, nodes) + adjacency: dict[str, list[str]] = {node_id: [] for node_id in nodes} + for spec in road_specs: + adjacency[spec.road.from_node_id].append(f"road:{spec.road.road_id}") + adjacency[spec.road.to_node_id].append(f"road:{spec.road.road_id}") + for access in parking_accesses: + reference = f"parking_access:{access.access_id}" + adjacency[access.source_node_id].append(reference) + adjacency[access.parking_lot_node_id].append(reference) + topology = GameMapTopology( + nodes=node_values, + roads=tuple(spec.road for spec in road_specs), + parking_accesses=parking_accesses, + adjacency=tuple( + (node_id, tuple(sorted(references))) + for node_id, references in adjacency.items() + ), + ) + _validate_topology(topology) + + raw_roads: dict[str, np.ndarray] = {} + for spec in road_specs: + if spec.spans_xy: + raw_roads[spec.road.road_id] = _sample_road(spec, settings.sample_spacing_m) + else: + start = nodes[spec.road.from_node_id] + end = nodes[spec.road.to_node_id] + length = math.hypot(end.x_m - start.x_m, end.y_m - start.y_m) + samples = max(2, int(math.ceil(length / settings.sample_spacing_m)) + 1) + raw_roads[spec.road.road_id] = np.linspace( + [start.x_m, start.y_m], [end.x_m, end.y_m], samples + ) + parking_access_paths = { + access.access_id: _parking_access_path(access, nodes, settings.sample_spacing_m) + for access in parking_accesses + } + transitions = _cross_section_transitions(topology, raw_roads) + raw_roads, road_joint_centerlines = _trimmed_road_paths_and_joints( + topology, raw_roads, settings.sample_spacing_m + ) + polygons, transition_geometry, node_openings = _node_polygons( + topology, + raw_roads, + road_joint_centerlines, + parking_access_paths, + transitions, + ) + elements: list[GameMapElement] = [] + connections: list[_Connection] = [] + lanes: list[_LaneBuild] = [] + lane_dividers: list[GameMapLaneDivider] = [] + incidences: dict[str, list[_LaneIncidence]] = {node_id: [] for node_id in nodes} + + for spec in road_specs: + road = spec.road + attributes = road.attributes + from_reference = ( + f"{road.road_id}:from" + if road.from_node_id == road.to_node_id + else road.road_id + ) + to_reference = ( + f"{road.road_id}:to" + if road.from_node_id == road.to_node_id + else road.road_id + ) + try: + points = _trim_line( + raw_roads[road.road_id], + polygons[road.from_node_id], + polygons[road.to_node_id], + f"Road {road.road_id!r}", + ) + except GameMapError as error: + transition_nodes = [ + node_id + for node_id in (road.from_node_id, road.to_node_id) + if (node_id, road.road_id) in transition_geometry + ] + if transition_nodes and "completely contained" in str(error): + raise GameMapError( + f"Node {transition_nodes[0]!r} transition consumes its road arm " + f"on {road.road_id!r}" + ) from error + raise + built = _build_linear_lanes(road.road_id, points, attributes, True) + lanes.extend(built) + lane_dividers.extend(_build_lane_dividers(built, attributes)) + for incidence in _incidences_for_lanes( + built, road.from_node_id, road.to_node_id, f"road:{road.road_id}" + ): + incidences[incidence.node_id].append(incidence) + context = f"Road {road.road_id!r}" + widths = np.full(len(points), attributes.surface_width_m, dtype=np.float64) + left, right = _ribbon_sides( + points, + widths, + context, + start_opening_xy=node_openings[(road.from_node_id, from_reference)], + end_opening_xy=node_openings[(road.to_node_id, to_reference)], + ) + surface = _polygon_from_ribbon(left, right, context) + surface = _exclude_connected_footprints( + surface, + (polygons[road.from_node_id], polygons[road.to_node_id]), + context, + ) + elements.append( + GameMapElement( + element_id=road.road_id, + element_type="road", + profile_id=road.profile_id, + attributes=attributes, + surface_world=_surface_array(surface), + road_boundaries=(), + curbs=(), + ) + ) + connections.extend( + _Connection( + connection_id=f"road:{road.road_id}:{endpoint}", + first_element_id=road.road_id, + second_element_id=node_id, + opening_xy=node_openings[ + ( + node_id, + f"{road.road_id}:{endpoint}" + if road.from_node_id == road.to_node_id + else road.road_id, + ) + ], + ) + for endpoint, node_id in ( + ("from", road.from_node_id), + ("to", road.to_node_id), + ) + ) + for access in parking_accesses: + source = nodes[access.source_node_id] + lot = nodes[access.parking_lot_node_id] + centerline, opening_width = parking_access_paths[access.access_id] + attributes = GameMapLinearAttributes( + curb=True, + lane_width_m=opening_width * 0.5, + curb_offset_m=0.0, + directions=("forward", "backward"), + speed_limit_mps=5.5, + marking_style="VIRTUAL", + marking_color="WHITE", + divider_markings=(("VIRTUAL", "WHITE"),), + ) + points = _trim_line( + centerline, + polygons[source.node_id], + polygons[lot.node_id], + f"Parking access {access.access_id!r}", + ) + built = _build_linear_lanes(access.access_id, points, attributes, False) + lanes.extend(built) + lane_dividers.extend(_build_lane_dividers(built, attributes)) + for incidence in _incidences_for_lanes( + built, + source.node_id, + lot.node_id, + f"parking_access:{access.access_id}", + ): + if incidence.node_id == source.node_id: + incidences[incidence.node_id].append(incidence) + context = f"Parking access {access.access_id!r}" + widths = np.full(len(points), opening_width, dtype=np.float64) + left, right = _ribbon_sides( + points, + widths, + context, + start_opening_xy=node_openings[(source.node_id, access.access_id)], + end_opening_xy=node_openings[(lot.node_id, access.access_id)], + ) + surface = _polygon_from_ribbon(left, right, context) + surface = _exclude_connected_footprints( + surface, + (polygons[source.node_id], polygons[lot.node_id]), + context, + ) + elements.append( + GameMapElement( + element_id=access.access_id, + element_type="parking_access", + profile_id=None, + attributes=attributes, + surface_world=_surface_array(surface), + road_boundaries=(), + curbs=(), + ) + ) + connections.extend( + ( + _Connection( + connection_id=f"parking_access:{access.access_id}:source", + first_element_id=access.access_id, + second_element_id=source.node_id, + opening_xy=node_openings[(source.node_id, access.access_id)], + ), + _Connection( + connection_id=f"parking_access:{access.access_id}:lot", + first_element_id=access.access_id, + second_element_id=lot.node_id, + opening_xy=node_openings[(lot.node_id, access.access_id)], + ), + ) + ) + + _splice_transition_lanes( + transition_geometry, + incidences, + lanes, + lane_dividers, + ) + + for node in node_values: + polygon = polygons[node.node_id] + elements.append( + GameMapElement( + element_id=node.node_id, + element_type=node.node_type, + profile_id=node.profile_id, + attributes=node.attributes, + surface_world=_surface_array(polygon), + road_boundaries=(), + curbs=(), + ) + ) + + for node in node_values: + if node.node_type in {"road_joint", "driveway"}: + centerline = road_joint_centerlines[node.node_id] + _wire_road_joint( + node, + [ + incidence + for incidence in incidences[node.node_id] + if incidence.edge_ref.startswith("road:") + ], + centerline, + lanes, + lane_dividers, + ) + if node.node_type == "driveway": + _wire_node( + node, + incidences[node.node_id], + lanes, + settings.intersection_connector_samples, + access_turns_only=True, + ) + else: + _wire_node( + node, + incidences[node.node_id], + lanes, + settings.intersection_connector_samples, + ) + + lane_by_id = {lane.lane_id: lane for lane in lanes} + spawn_values = _sequence(doc["spawns"], "spawns") + if not spawn_values: + raise GameMapError("Map must define at least one spawn") + spawns = tuple( + _spawn(_mapping(value, f"spawns[{index}]"), source_path, lane_by_id) + for index, value in enumerate(spawn_values) + ) + spawn_ids = [spawn.spawn_id for spawn in spawns] + if len(set(spawn_ids)) != len(spawn_ids): + raise GameMapError("Spawn ids must be non-empty and unique") + runtime_lanes = tuple( + GameMapLane( + lane_id=lane.lane_id, + element_id=lane.element_id, + centerline_world=lane.centerline, + left_edge_world=lane.left_edge, + right_edge_world=lane.right_edge, + roadside_edge_world=lane.roadside_edge, + speed_limit_mps=lane.speed_limit_mps, + marking_style=lane.marking_style, + marking_color=lane.marking_color, + left_marking_style=lane.left_marking_style or lane.marking_style, + left_marking_color=lane.left_marking_color or lane.marking_color, + right_marking_style=lane.right_marking_style or lane.marking_style, + right_marking_color=lane.right_marking_color or lane.marking_color, + successor_ids=tuple(dict.fromkeys(lane.successors)), + allows_taxi_stops=lane.allows_taxi_stops, + conditioning_visible=lane.conditioning_visible, + ) + for lane in lanes + ) + traffic = compile_traffic( + doc.get("traffic"), + topology, + runtime_lanes, + traffic_count=doc.get("traffic_count"), + map_id=map_id, + spawns=spawns, + ) + permitted_boundary_contacts = { + tuple(sorted((access.access_id, road.road_id))) + for access in parking_accesses + for road in topology.roads + if access.source_node_id in {road.from_node_id, road.to_node_id} + } + transition_curb_regions: dict[str, list[tuple[BaseGeometry, bool]]] = {} + for (node_id, _road_id), geometry in transition_geometry.items(): + transition = geometry.transition + region = _taper_polygon( + geometry.path_xy, + transition.local_attributes.surface_width_m, + transition.arm.attributes.surface_width_m, + f"Node {node_id!r} curb region", + ).buffer(_POSITION_TOLERANCE_M) + transition_curb_regions.setdefault(node_id, []).append( + (region, transition.arm.attributes.curb) + ) + elements = _boundaries_for_elements( + elements, + connections, + permitted_boundary_contacts, + transition_curb_regions, + ) + elements = [ + replace( + element, + surface_world=np.asarray(element.surface_world, dtype=np.float32), + ) + for element in elements + ] + all_points = np.concatenate([element.surface_world for element in elements]) + minimum = np.min(all_points[:, :2], axis=0) - settings.ground_margin_m + maximum = np.max(all_points[:, :2], axis=0) + settings.ground_margin_m + ground_vertices = np.asarray( + [ + [minimum[0], minimum[1], 0.0], + [maximum[0], minimum[1], 0.0], + [maximum[0], maximum[1], 0.0], + [minimum[0], maximum[1], 0.0], + ], + dtype=np.float32, + ) + return ResolvedGameMap( + schema_version=_SCHEMA_VERSION, + map_id=map_id, + name=map_name, + source_path=source_path, + compiler_settings=settings.as_dict(), + topology=topology, + lanes=runtime_lanes, + elements=tuple(elements), + road_marking_polygons_world=(), + lane_dividers=tuple(lane_dividers), + line_markings=(), + ground_vertices=ground_vertices, + ground_faces=np.asarray([[0, 1, 2], [0, 2, 3]], dtype=np.int32), + spawns=spawns, + traffic=traffic, + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/preview.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/preview.py new file mode 100644 index 000000000..f3198b343 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/preview.py @@ -0,0 +1,190 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""SVG previews for semantic game maps.""" + +from __future__ import annotations + +import html +from pathlib import Path + +import numpy as np + +from omnidreams_game_engine.game_map.loader import load_game_map + + +def _points(points: np.ndarray, transform: object) -> str: + convert = transform + return " ".join(f"{x:.2f},{y:.2f}" for x, y in (convert(point) for point in points)) + + +def _label(text: str, point: np.ndarray, transform: object, color: str) -> str: + x, y = transform(point) + return ( + f'{html.escape(text)}' + ) + + +def _point_at_distance( + points: np.ndarray, distance_m: float +) -> tuple[np.ndarray, np.ndarray]: + lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + cumulative = np.concatenate((np.zeros(1), np.cumsum(lengths))) + index = min( + int(np.searchsorted(cumulative, distance_m, side="right") - 1), + len(lengths) - 1, + ) + index = max(0, index) + alpha = (distance_m - cumulative[index]) / max(float(lengths[index]), 1.0e-9) + point = points[index] + alpha * (points[index + 1] - points[index]) + tangent = points[index + 1, :2] - points[index, :2] + tangent /= max(float(np.linalg.norm(tangent)), 1.0e-9) + return point, tangent + + +def write_game_map_preview( + source: Path, destination: Path, *, include_annotations: bool = True +) -> Path: + """Render a top-down semantic-map preview as SVG.""" + game_map = load_game_map(source) + points = np.concatenate( + [element.surface_world[:, :2] for element in game_map.elements], axis=0 + ) + x_min, y_min = np.min(points, axis=0) - 8.0 + x_max, y_max = np.max(points, axis=0) + 8.0 + width = max(1.0, float(x_max - x_min)) + height = max(1.0, float(y_max - y_min)) + scale = min(1000.0 / width, 800.0 / height) + + def convert(point: np.ndarray) -> tuple[float, float]: + return ( + (float(point[0]) - float(x_min)) * scale, + (float(y_max) - float(point[1])) * scale, + ) + + lines = [ + '', + '', + ] + for element in game_map.elements: + fill = "#14a878" if element.element_type == "parking_lot" else "#4b4f55" + lines.append( + f'' + ) + for polygon in game_map.road_marking_polygons_world: + lines.append( + f'' + ) + for marking in game_map.line_markings: + color = "#ffd60a" if marking.color == "YELLOW" else "#f4f4f4" + lines.append( + f'' + ) + for divider in game_map.lane_dividers: + color = "#ffd60a" if divider.color == "YELLOW" else "#f4f4f4" + lines.append( + f'' + ) + for element in game_map.elements: + for boundary in element.road_boundaries: + lines.append( + f'' + ) + for curb in element.curbs: + lines.append( + f'' + ) + for traffic in game_map.traffic: + lines.append( + f'' + ) + point, forward = _point_at_distance( + traffic.centerline_world, traffic.start_distance_m + ) + left = np.asarray([-forward[1], forward[0]]) + half_length = traffic.dimensions_lwh_m[0] * 0.5 + half_width = traffic.dimensions_lwh_m[1] * 0.5 + corners = np.asarray( + [ + point[:2] + forward * half_length + left * half_width, + point[:2] + forward * half_length - left * half_width, + point[:2] - forward * half_length - left * half_width, + point[:2] - forward * half_length + left * half_width, + ] + ) + lines.append( + f'' + ) + if include_annotations: + lines.append(_label(traffic.vehicle_id, point, convert, "#9f1239")) + if include_annotations: + lane_by_element = { + lane.element_id: lane + for lane in game_map.lanes + if lane.conditioning_visible and ":connector:" not in lane.lane_id + } + for road in game_map.topology.roads: + lane = lane_by_element[road.road_id] + point = lane.centerline_world[len(lane.centerline_world) // 2, :2] + lines.append( + _label( + f"{road.road_id} [road:{road.profile_id}; " + f"{road.from_node_id}→{road.to_node_id}]", + point, + convert, + "#17233d", + ) + ) + for access in game_map.topology.parking_accesses: + lane = lane_by_element[access.access_id] + point = lane.centerline_world[len(lane.centerline_world) // 2, :2] + lines.append( + _label( + f"{access.access_id} [parking access; " + f"{access.source_node_id}→{access.parking_lot_node_id}]", + point, + convert, + "#064e3b", + ) + ) + node_colors = { + "intersection": "#2d6cdf", + "road_joint": "#0891b2", + "cul_de_sac": "#8b5cf6", + "driveway": "#f59e0b", + "parking_lot": "#059669", + } + for node in game_map.topology.nodes: + point = np.asarray([node.x_m, node.y_m]) + x, y = convert(point) + lines.append( + f'' + ) + lines.append( + _label( + f"{node.node_id} [node:{node.node_type}]", + point, + convert, + "#111827", + ) + ) + lines.append("") + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("\n".join(lines) + "\n", encoding="utf-8") + return destination diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/spawn_render.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/spawn_render.py new file mode 100644 index 000000000..4cf701905 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/spawn_render.py @@ -0,0 +1,342 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic first-person fallback renders for semantic-map spawns.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import shapely +from PIL import Image +from shapely.geometry import LineString, Polygon +from shapely.geometry.base import BaseGeometry +from shapely.ops import unary_union + +from omnidreams_game_engine.camera_defaults import ( + DEFAULT_FIRST_FRAME_RESOLUTION_WH, + default_front_camera_calibration, +) +from omnidreams_game_engine.game_map.types import ( + GameMapSpawn, + ResolvedGameMap, +) +from omnidreams_game_engine.math3d import rig_pose_from_state + +SPAWN_RENDERER_VERSION = "1" +"""Version included in compiled-map cache keys for fallback rendering.""" + +_MAX_GROUND_DISTANCE_M = 600.0 +_PAINT_WIDTH_M = 0.12 +_BOUNDARY_WIDTH_M = 0.10 +_CURB_WIDTH_M = 0.28 + +_SKY_TOP_RGB = np.asarray([104, 154, 202], dtype=np.float32) +_SKY_HORIZON_RGB = np.asarray([208, 222, 226], dtype=np.float32) +_TERRAIN_RGB = np.asarray([116, 125, 83], dtype=np.float32) +_ROAD_RGB = np.asarray([64, 67, 69], dtype=np.uint8) +_PARKING_RGB = np.asarray([72, 75, 76], dtype=np.uint8) +_BOUNDARY_RGB = np.asarray([52, 54, 55], dtype=np.uint8) +_CURB_RGB = np.asarray([151, 151, 145], dtype=np.uint8) +_WHITE_PAINT_RGB = np.asarray([226, 225, 213], dtype=np.uint8) +_YELLOW_PAINT_RGB = np.asarray([222, 177, 47], dtype=np.uint8) + + +def _camera_ground_intersections( + spawn: GameMapSpawn, width: int, height: int +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + calibration = default_front_camera_calibration() + u, v = np.meshgrid( + np.arange(width, dtype=np.float32) + np.float32(0.5), + np.arange(height, dtype=np.float32) + np.float32(0.5), + ) + pixels = np.column_stack((u.reshape(-1), v.reshape(-1))) + + scale = np.asarray( + [width / calibration.width, height / calibration.height], dtype=np.float32 + ) + native_pixels = pixels / scale + relative = native_pixels - np.asarray( + [calibration.cx, calibration.cy], dtype=np.float32 + ) + relative = ( + relative + @ np.linalg.inv( + np.asarray( + [ + [calibration.linear_cde[0], calibration.linear_cde[1]], + [calibration.linear_cde[2], 1.0], + ], + dtype=np.float32, + ) + ).T + ) + radius = np.linalg.norm(relative, axis=1).astype(np.float32) + angle = np.zeros_like(radius) + for power, coefficient in enumerate(calibration.polynomial): + angle += np.float32(coefficient) * np.power(radius, power, dtype=np.float32) + sin_angle = np.sin(angle).astype(np.float32) + radial_scale = np.divide( + sin_angle, + np.maximum(radius, np.float32(1.0e-6)), + out=np.zeros_like(radius), + where=radius > np.float32(1.0e-6), + ) + directions_rdf = np.column_stack( + ( + relative[:, 0] * radial_scale, + relative[:, 1] * radial_scale, + np.cos(angle), + ) + ).astype(np.float32) + directions_sensor_flu = np.column_stack( + ( + directions_rdf[:, 2], + -directions_rdf[:, 0], + -directions_rdf[:, 1], + ) + ).astype(np.float32) + + rig_to_world = rig_pose_from_state( + float(spawn.position_world[0]), + float(spawn.position_world[1]), + float(spawn.position_world[2]), + spawn.yaw_rad, + ) + sensor_to_world = rig_to_world @ calibration.sensor_to_rig_flu + directions_world = directions_sensor_flu @ sensor_to_world[:3, :3].T + origin_world = sensor_to_world[:3, 3] + ground_z = float(spawn.position_world[2]) + distance_along_ray = np.divide( + np.float32(ground_z) - origin_world[2], + directions_world[:, 2], + out=np.full(len(directions_world), np.float32(-1.0)), + where=np.abs(directions_world[:, 2]) > np.float32(1.0e-6), + ) + valid = ( + (directions_sensor_flu[:, 0] > 0.0) + & (distance_along_ray > 0.0) + & (distance_along_ray <= _MAX_GROUND_DISTANCE_M) + ) + points_world = origin_world[None, :] + ( + directions_world * distance_along_ray[:, None] + ) + planar_distance = np.linalg.norm(points_world[:, :2] - origin_world[:2], axis=1) + return points_world[:, 0], points_world[:, 1], planar_distance, valid + + +def _polygon_geometry(polygons: list[np.ndarray]) -> BaseGeometry: + geometries = [ + Polygon(np.asarray(points, dtype=np.float64)[:, :2]) + for points in polygons + if len(points) >= 3 + ] + return unary_union(geometries) if geometries else Polygon() + + +def _line_geometry(polylines: list[np.ndarray], width_m: float) -> BaseGeometry: + geometries = [ + LineString(np.asarray(points, dtype=np.float64)[:, :2]).buffer( + width_m * 0.5, cap_style="flat", join_style="round" + ) + for points in polylines + if len(points) >= 2 + ] + return unary_union(geometries) if geometries else Polygon() + + +def _paint( + image_flat: np.ndarray, + ground_indices: np.ndarray, + ground_x: np.ndarray, + ground_y: np.ndarray, + geometry: BaseGeometry, + color: np.ndarray, +) -> None: + if geometry.is_empty: + return + covered = shapely.intersects_xy(geometry, ground_x, ground_y) + image_flat[ground_indices[covered]] = color + + +def render_spawn_first_frame( + game_map: ResolvedGameMap, + spawn: GameMapSpawn, + *, + resolution_wh: tuple[int, int] = DEFAULT_FIRST_FRAME_RESOLUTION_WH, +) -> np.ndarray: + """Render a deterministic synthetic first frame aligned to ``spawn``. + + Args: + game_map: Resolved semantic map containing drawable surfaces and lines. + spawn: Spawn supplying the camera position and heading. + resolution_wh: Output resolution as ``(width, height)``. + + Returns: + RGB image with shape ``[height, width, 3]`` and dtype ``uint8``. + """ + width, height = (int(resolution_wh[0]), int(resolution_wh[1])) + if width <= 0 or height <= 0: + raise ValueError(f"resolution_wh must be positive, got {resolution_wh!r}") + + vertical = np.linspace(0.0, 1.0, height, dtype=np.float32)[:, None, None] + sky = ( + _SKY_TOP_RGB[None, None, :] * (1.0 - vertical) + + _SKY_HORIZON_RGB[None, None, :] * vertical + ) + image = np.broadcast_to(sky, (height, width, 3)).copy() + + ground_x_all, ground_y_all, distance_all, valid_ground = ( + _camera_ground_intersections(spawn, width, height) + ) + flat = image.reshape(-1, 3) + ground_indices = np.flatnonzero(valid_ground) + ground_x = ground_x_all[valid_ground] + ground_y = ground_y_all[valid_ground] + distance = distance_all[valid_ground] + terrain_shade = np.clip(1.0 - distance / 1400.0, 0.72, 1.0)[:, None] + texture = 3.0 * np.sin(ground_x[:, None] * 0.37) * np.cos(ground_y[:, None] * 0.29) + flat[ground_indices] = np.clip( + _TERRAIN_RGB[None, :] * terrain_shade + texture, 0.0, 255.0 + ) + + parking_surfaces = [ + element.surface_world + for element in game_map.elements + if element.element_type == "parking_lot" + ] + road_surfaces = [ + element.surface_world + for element in game_map.elements + if element.element_type != "parking_lot" + ] + _paint( + flat, + ground_indices, + ground_x, + ground_y, + _polygon_geometry(road_surfaces), + _ROAD_RGB, + ) + _paint( + flat, + ground_indices, + ground_x, + ground_y, + _polygon_geometry(parking_surfaces), + _PARKING_RGB, + ) + + boundaries = [ + boundary.polyline_world + for element in game_map.elements + for boundary in element.road_boundaries + ] + curbs = [ + curb.polyline_world for element in game_map.elements for curb in element.curbs + ] + _paint( + flat, + ground_indices, + ground_x, + ground_y, + _line_geometry(boundaries, _BOUNDARY_WIDTH_M), + _BOUNDARY_RGB, + ) + _paint( + flat, + ground_indices, + ground_x, + ground_y, + _line_geometry(curbs, _CURB_WIDTH_M), + _CURB_RGB, + ) + + _paint( + flat, + ground_indices, + ground_x, + ground_y, + _polygon_geometry(list(game_map.road_marking_polygons_world)), + _WHITE_PAINT_RGB, + ) + for color_name, color in ( + ("WHITE", _WHITE_PAINT_RGB), + ("YELLOW", _YELLOW_PAINT_RGB), + ): + polylines = [ + divider.polyline_world + for divider in game_map.lane_dividers + if divider.color == color_name + ] + [ + marking.polyline_world + for marking in game_map.line_markings + if marking.color == color_name + ] + _paint( + flat, + ground_indices, + ground_x, + ground_y, + _line_geometry(polylines, _PAINT_WIDTH_M), + color, + ) + return np.clip(image, 0.0, 255.0).astype(np.uint8) + + +def write_spawn_first_frame_preview( + source: Path, + destination: Path, + *, + spawn_id: str | None = None, +) -> Path: + """Write the deterministic fallback render for one authored spawn. + + Args: + source: Semantic map YAML path. + destination: PNG path to create. + spawn_id: Spawn identifier; ``None`` selects the first spawn. + + Returns: + Resolved output path. + + Raises: + GameMapError: ``spawn_id`` does not identify a map spawn. + """ + from omnidreams_game_engine.game_map._schema import GameMapError + from omnidreams_game_engine.game_map.loader import load_game_map + + game_map = load_game_map(source) + if spawn_id is None: + spawn = game_map.default_spawn + else: + spawn = next( + ( + candidate + for candidate in game_map.spawns + if candidate.spawn_id == spawn_id + ), + None, + ) + if spawn is None: + available = ", ".join(item.spawn_id for item in game_map.spawns) + raise GameMapError( + f"Unknown spawn {spawn_id!r}; available spawns: {available}" + ) + output = Path(destination).expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(render_spawn_first_frame(game_map, spawn)).save(output) + return output diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/traffic.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/traffic.py new file mode 100644 index 000000000..552e97ce9 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/traffic.py @@ -0,0 +1,963 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Compile authored traffic waypoints onto the directed public-road graph.""" + +from __future__ import annotations + +import hashlib +import heapq +import math +from collections import deque +from dataclasses import dataclass + +import numpy as np + +from omnidreams_game_engine.game_map._schema import GameMapError +from omnidreams_game_engine.game_map.types import ( + GameMapLane, + GameMapSpawn, + GameMapTopology, + GameMapTrafficVehicle, +) + +_VEHICLE_DIMENSIONS_LWH_M = { + "car": (4.5, 1.8, 1.5), + "truck": (7.0, 2.5, 3.0), + "bus": (12.0, 2.55, 3.2), +} +_TURN_THRESHOLD_RAD = math.radians(35.0) +_GENERATED_SLOT_SPACING_M = 2.0 +_GENERATED_FOOTPRINT_BUFFER_M = 0.5 +_GENERATED_SPAWN_CLEARANCE_M = 8.0 +_HEADWAY_MIN_CLEARANCE_M = 2.0 +_HEADWAY_TIME_S = 1.25 +_HEADWAY_LANE_CORRIDOR_M = 2.25 +_HEADWAY_MAX_ANGLE_RAD = math.radians(40.0) +_MIN_ROUTE_POINT_SPACING_M = 0.25 +_LANE_SEAM_TOLERANCE_M = 0.05 +_MAX_ROUTE_YAW_RATE_RADPS = 1.2 +_MAX_ROUTE_LATERAL_ACCEL_MPS2 = 2.5 +_MAX_ROUTE_ACCEL_MPS2 = 2.5 +_MAX_ROUTE_BRAKING_MPS2 = 4.0 + + +@dataclass(frozen=True) +class _RouteTemplate: + node_ids: tuple[str, ...] + end_behavior: str + centerline_world: np.ndarray + speed_limits_mps: np.ndarray + route_element_ids: tuple[str, ...] + + +@dataclass(frozen=True) +class _Placement: + position_xy: np.ndarray + forward_xy: np.ndarray + speed_mps: float + half_length_m: float + half_width_m: float + + +def _polyline_length(points: np.ndarray) -> float: + return float(np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1).sum()) + + +def _resample(points: np.ndarray, count: int) -> np.ndarray: + lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + cumulative = np.concatenate(([0.0], np.cumsum(lengths))) + total = float(cumulative[-1]) + distances = np.linspace(0.0, total, count) + result = np.empty((count, 3), dtype=np.float64) + for index, distance in enumerate(distances): + segment = min( + int(np.searchsorted(cumulative, distance, side="right") - 1), + len(lengths) - 1, + ) + segment = max(0, segment) + alpha = (distance - cumulative[segment]) / max(float(lengths[segment]), 1.0e-9) + result[index] = points[segment] + alpha * ( + points[segment + 1] - points[segment] + ) + return result + + +def _append_path( + points: list[np.ndarray], + speeds: list[float], + element_ids: list[str], + path: np.ndarray, + speed_mps: float, + element_id: str, +) -> None: + for point in path: + if ( + points + and float(np.linalg.norm(points[-1][:2] - point[:2])) + <= _MIN_ROUTE_POINT_SPACING_M + ): + speeds[-1] = min(speeds[-1], speed_mps) + continue + if points: + element_ids.append(element_id) + points.append(np.asarray(point, dtype=np.float64)) + speeds.append(speed_mps) + + +def _curve_limited_speeds(points: np.ndarray, speeds: np.ndarray) -> np.ndarray: + """Limit a closed route to speeds its physical follower can turn through.""" + if len(points) < 4: + return speeds + closed = float(np.linalg.norm(points[-1, :2] - points[0, :2])) <= 1.0e-4 + if not closed: + return speeds + route_points = points[:-1] + route_speeds = speeds[:-1].astype(np.float64, copy=True) + count = len(route_points) + if count < 3: + return speeds + + segment_vectors = np.roll(route_points[:, :2], -1, axis=0) - route_points[:, :2] + segment_lengths = np.linalg.norm(segment_vectors, axis=1) + headings = np.arctan2(segment_vectors[:, 1], segment_vectors[:, 0]) + heading_changes = np.abs( + (headings - np.roll(headings, 1) + math.pi) % (2.0 * math.pi) - math.pi + ) + previous_lengths = np.roll(segment_lengths, 1) + local_lengths = np.maximum( + 0.5 * (previous_lengths + segment_lengths), _MIN_ROUTE_POINT_SPACING_M + ) + curvature = heading_changes / local_lengths + turning = curvature > 1.0e-9 + lateral_caps = np.full(count, math.inf, dtype=np.float64) + lateral_caps[turning] = np.sqrt(_MAX_ROUTE_LATERAL_ACCEL_MPS2 / curvature[turning]) + yaw_caps = np.full(count, math.inf, dtype=np.float64) + changing_heading = heading_changes > 1.0e-9 + yaw_caps[changing_heading] = ( + _MAX_ROUTE_YAW_RATE_RADPS + * previous_lengths[changing_heading] + / heading_changes[changing_heading] + ) + route_speeds = np.minimum(route_speeds, np.minimum(lateral_caps, yaw_caps)) + + # Propagate each turn's limit backward through its braking distance and + # forward through acceleration, including across the cyclic route seam. + for _ in range(count): + previous_speeds = route_speeds.copy() + for index in range(count): + following = (index + 1) % count + acceleration_cap = math.sqrt( + route_speeds[index] ** 2 + + 2.0 * _MAX_ROUTE_ACCEL_MPS2 * segment_lengths[index] + ) + route_speeds[following] = min(route_speeds[following], acceleration_cap) + for index in range(count - 1, -1, -1): + following = (index + 1) % count + braking_cap = math.sqrt( + route_speeds[following] ** 2 + + 2.0 * _MAX_ROUTE_BRAKING_MPS2 * segment_lengths[index] + ) + route_speeds[index] = min(route_speeds[index], braking_cap) + if np.array_equal(route_speeds, previous_speeds): + break + + route_speeds = np.concatenate((route_speeds, route_speeds[:1])) + return route_speeds.astype(np.float32) + + +def _directed_road_lanes( + topology: GameMapTopology, lanes: tuple[GameMapLane, ...] +) -> dict[tuple[str, str, str], list[GameMapLane]]: + nodes = {node.node_id: node for node in topology.nodes} + result: dict[tuple[str, str, str], list[GameMapLane]] = {} + for road in topology.roads: + road_lanes = [lane for lane in lanes if lane.element_id == road.road_id] + for start_id, end_id in ( + (road.from_node_id, road.to_node_id), + (road.to_node_id, road.from_node_id), + ): + start = np.asarray([nodes[start_id].x_m, nodes[start_id].y_m]) + end = np.asarray([nodes[end_id].x_m, nodes[end_id].y_m]) + directed = [ + lane + for lane in road_lanes + if float(np.linalg.norm(lane.centerline_world[0, :2] - start)) + < float(np.linalg.norm(lane.centerline_world[-1, :2] - start)) + and float(np.linalg.norm(lane.centerline_world[-1, :2] - end)) + < float(np.linalg.norm(lane.centerline_world[0, :2] - end)) + ] + if not directed: + continue + tangent = ( + directed[0].centerline_world[-1, :2] + - directed[0].centerline_world[0, :2] + ) + tangent /= max(float(np.linalg.norm(tangent)), 1.0e-9) + right = np.asarray([tangent[1], -tangent[0]]) + directed.sort( + key=lambda lane: -float( + np.dot( + lane.centerline_world[len(lane.centerline_world) // 2, :2], + right, + ) + ) + ) + result[(road.road_id, start_id, end_id)] = directed + return result + + +def _shortest_roads( + start_id: str, + end_id: str, + topology: GameMapTopology, + directed: dict[tuple[str, str, str], list[GameMapLane]], +) -> list[tuple[str, str, str]]: + if start_id == end_id: + return [] + outgoing: dict[str, list[tuple[str, str, float]]] = {} + for road in topology.roads: + for a, b in ( + (road.from_node_id, road.to_node_id), + (road.to_node_id, road.from_node_id), + ): + road_lanes = directed.get((road.road_id, a, b)) + if not road_lanes: + continue + weight = min(_polyline_length(lane.centerline_world) for lane in road_lanes) + outgoing.setdefault(a, []).append((b, road.road_id, weight)) + queue: list[tuple[float, str]] = [(0.0, start_id)] + distance = {start_id: 0.0} + previous: dict[str, tuple[str, str]] = {} + while queue: + cost, node_id = heapq.heappop(queue) + if cost != distance.get(node_id): + continue + if node_id == end_id: + break + for target_id, road_id, weight in sorted(outgoing.get(node_id, ())): + candidate = cost + weight + if candidate + 1.0e-9 < distance.get(target_id, math.inf): + distance[target_id] = candidate + previous[target_id] = (node_id, road_id) + heapq.heappush(queue, (candidate, target_id)) + if end_id not in previous: + raise GameMapError( + f"Traffic route cannot reach node {end_id!r} from {start_id!r}" + ) + reversed_path: list[tuple[str, str, str]] = [] + node_id = end_id + while node_id != start_id: + source_id, road_id = previous[node_id] + reversed_path.append((road_id, source_id, node_id)) + node_id = source_id + return list(reversed(reversed_path)) + + +def _turn_kind(current: list[GameMapLane], following: list[GameMapLane]) -> str: + incoming = current[0].centerline_world + outgoing = following[0].centerline_world + first = incoming[-1, :2] - incoming[-2, :2] + second = outgoing[1, :2] - outgoing[0, :2] + first /= max(float(np.linalg.norm(first)), 1.0e-9) + second /= max(float(np.linalg.norm(second)), 1.0e-9) + angle = math.atan2( + float(first[0] * second[1] - first[1] * second[0]), float(np.dot(first, second)) + ) + if abs(angle) <= _TURN_THRESHOLD_RAD: + return "straight" + return "left" if angle > 0.0 else "right" + + +def _connector_path( + source_id: str, + target_id: str, + lane_by_id: dict[str, GameMapLane], + public_road_ids: set[str], + parking_access_ids: set[str], +) -> list[GameMapLane]: + queue: list[tuple[float, float, float, str, tuple[str, ...]]] = [ + (0.0, 0.0, 0.0, source_id, (source_id,)) + ] + best = {source_id: (0.0, 0.0, 0.0)} + while queue: + seam_cost, heading_cost, length_cost, lane_id, path = heapq.heappop(queue) + if (seam_cost, heading_cost, length_cost) != best.get(lane_id): + continue + if lane_id == target_id: + return [lane_by_id[item] for item in path] + lane = lane_by_id[lane_id] + source_tangent = lane.centerline_world[-1, :2] - lane.centerline_world[-2, :2] + source_tangent /= max(float(np.linalg.norm(source_tangent)), 1.0e-9) + for successor in lane.successor_ids: + if successor not in lane_by_id: + continue + following = lane_by_id[successor] + if following.element_id in public_road_ids and successor != target_id: + continue + if following.element_id in parking_access_ids: + continue + seam_distance = float( + np.linalg.norm( + lane.centerline_world[-1, :2] - following.centerline_world[0, :2] + ) + ) + target_tangent = ( + following.centerline_world[1, :2] - following.centerline_world[0, :2] + ) + target_tangent /= max(float(np.linalg.norm(target_tangent)), 1.0e-9) + heading_delta = math.acos( + float(np.clip(np.dot(source_tangent, target_tangent), -1.0, 1.0)) + ) + cost = ( + seam_cost + max(0.0, seam_distance - _LANE_SEAM_TOLERANCE_M), + heading_cost + heading_delta, + length_cost + + ( + 0.0 + if successor == target_id + else _polyline_length(following.centerline_world) + ), + ) + if cost >= best.get(successor, (math.inf, math.inf, math.inf)): + continue + best[successor] = cost + heapq.heappush(queue, (*cost, successor, (*path, successor))) + raise GameMapError( + f"Traffic route has no legal lane connection from {source_id!r} to {target_id!r}" + ) + + +def _compile_route( + traversals: list[tuple[str, str, str]], + topology: GameMapTopology, + lanes: tuple[GameMapLane, ...], + speed_cap_mps: float | None, +) -> tuple[np.ndarray, np.ndarray, tuple[str, ...]]: + if not traversals: + raise GameMapError("Traffic routes must contain travel between distinct nodes") + directed = _directed_road_lanes(topology, lanes) + candidates = [directed[item] for item in traversals] + node_types = {node.node_id: node.node_type for node in topology.nodes} + entry: list[int | None] = [None] * len(traversals) + exit_lane: list[int | None] = [None] * len(traversals) + for index, current in enumerate(candidates): + following_index = (index + 1) % len(candidates) + following = candidates[following_index] + node_id = traversals[index][2] + kind = ( + "straight" + if node_types[node_id] in {"road_joint", "driveway"} + else _turn_kind(current, following) + ) + if kind == "right": + exit_lane[index] = 0 + entry[following_index] = 0 + elif kind == "left": + exit_lane[index] = len(current) - 1 + entry[following_index] = len(following) - 1 + for _ in range(2): + for index, current in enumerate(candidates): + following_index = (index + 1) % len(candidates) + following = candidates[following_index] + if exit_lane[index] is None: + exit_lane[index] = entry[index] if entry[index] is not None else 0 + if entry[following_index] is None: + rank = ( + 0.0 if len(current) == 1 else exit_lane[index] / (len(current) - 1) + ) + entry[following_index] = round(rank * (len(following) - 1)) + lane_by_id = {lane.lane_id: lane for lane in lanes} + public_road_ids = {road.road_id for road in topology.roads} + parking_access_ids = {access.access_id for access in topology.parking_accesses} + points: list[np.ndarray] = [] + speeds: list[float] = [] + element_ids: list[str] = [] + for index, road_candidates in enumerate(candidates): + incoming_lane = road_candidates[int(entry[index] or 0)] + outgoing_lane = road_candidates[int(exit_lane[index] or 0)] + count = max( + len(incoming_lane.centerline_world), len(outgoing_lane.centerline_world), 8 + ) + incoming_points = _resample(incoming_lane.centerline_world, count) + outgoing_points = _resample(outgoing_lane.centerline_world, count) + alpha = np.linspace(0.0, 1.0, count) + smooth = alpha * alpha * alpha * (10.0 + alpha * (-15.0 + 6.0 * alpha)) + road_path = ( + incoming_points * (1.0 - smooth[:, None]) + + outgoing_points * smooth[:, None] + ) + road_speed = min(incoming_lane.speed_limit_mps, outgoing_lane.speed_limit_mps) + if speed_cap_mps is not None: + road_speed = min(road_speed, speed_cap_mps) + _append_path( + points, + speeds, + element_ids, + road_path, + road_speed, + traversals[index][0], + ) + + following_index = (index + 1) % len(candidates) + target_lane = candidates[following_index][int(entry[following_index] or 0)] + connector = _connector_path( + outgoing_lane.lane_id, + target_lane.lane_id, + lane_by_id, + public_road_ids, + parking_access_ids, + ) + for lane in connector[1:-1]: + connector_speed = lane.speed_limit_mps + if speed_cap_mps is not None: + connector_speed = min(connector_speed, speed_cap_mps) + _append_path( + points, + speeds, + element_ids, + lane.centerline_world, + connector_speed, + lane.element_id, + ) + closure_distance = float(np.linalg.norm(points[-1][:2] - points[0][:2])) + if closure_distance > _MIN_ROUTE_POINT_SPACING_M: + element_ids.append(element_ids[-1]) + points.append(points[0].copy()) + speeds.append(speeds[0]) + elif closure_distance > 1.0e-4: + points[-1] = points[0].copy() + speeds[-1] = min(speeds[-1], speeds[0]) + if len(points) < 3 or _polyline_length(np.asarray(points)) <= 1.0: + raise GameMapError("Traffic route resolves to degenerate geometry") + if len(element_ids) != len(points) - 1: + raise GameMapError("Traffic route element metadata is misaligned") + route_points = np.asarray(points, dtype=np.float32) + route_speeds = _curve_limited_speeds( + route_points, np.asarray(speeds, dtype=np.float32) + ) + return route_points, route_speeds, tuple(element_ids) + + +def _insert_turnarounds( + traversals: list[tuple[str, str, str]], + topology: GameMapTopology, + directed: dict[tuple[str, str, str], list[GameMapLane]], +) -> list[tuple[str, str, str]]: + """Route immediate reversals through an incident cul-de-sac arm.""" + node_types = {node.node_id: node.node_type for node in topology.nodes} + roads = list(topology.roads) + result: list[tuple[str, str, str]] = [] + for index, current in enumerate(traversals): + result.append(current) + following = traversals[(index + 1) % len(traversals)] + if current[2] != following[1] or current[0] != following[0]: + continue + node_id = current[2] + if node_types[node_id] == "cul_de_sac": + continue + candidates: list[tuple[str, str]] = [] + for road in roads: + if road.road_id == current[0]: + continue + if road.from_node_id == node_id: + remote = road.to_node_id + elif road.to_node_id == node_id: + remote = road.from_node_id + else: + continue + if ( + node_types[remote] == "cul_de_sac" + and (road.road_id, node_id, remote) in directed + and (road.road_id, remote, node_id) in directed + ): + candidates.append((road.road_id, remote)) + if not candidates: + raise GameMapError( + f"Traffic route cannot reverse direction at node {node_id!r}; " + "add a waypoint loop or use a cul-de-sac endpoint" + ) + road_id, remote = sorted(candidates)[0] + result.extend(((road_id, node_id, remote), (road_id, remote, node_id))) + return result + + +def _compile_waypoint_route( + node_ids: tuple[str, ...], + end_behavior: str, + topology: GameMapTopology, + lanes: tuple[GameMapLane, ...], + directed: dict[tuple[str, str, str], list[GameMapLane]], + speed_mps: float | None, +) -> tuple[np.ndarray, np.ndarray, tuple[str, ...]]: + waypoint_cycle = list(node_ids) + if end_behavior == "reverse": + waypoint_cycle.extend(reversed(node_ids[:-1])) + legs = list(zip(waypoint_cycle, waypoint_cycle[1:])) + if end_behavior == "wrap": + legs.append((waypoint_cycle[-1], waypoint_cycle[0])) + traversals: list[tuple[str, str, str]] = [] + for source_id, target_id in legs: + traversals.extend(_shortest_roads(source_id, target_id, topology, directed)) + traversals = _insert_turnarounds(traversals, topology, directed) + return _compile_route(traversals, topology, lanes, speed_mps) + + +def _tree_path( + start_id: str, + end_id: str, + adjacency: dict[str, list[tuple[str, str]]], +) -> list[str]: + queue = deque([start_id]) + previous: dict[str, str | None] = {start_id: None} + while queue: + node_id = queue.popleft() + if node_id == end_id: + break + for neighbor_id, _ in adjacency.get(node_id, ()): + if neighbor_id in previous: + continue + previous[neighbor_id] = node_id + queue.append(neighbor_id) + if end_id not in previous: + return [] + path: list[str] = [] + node_id: str | None = end_id + while node_id is not None: + path.append(node_id) + node_id = previous[node_id] + return list(reversed(path)) + + +def _fundamental_cycles(topology: GameMapTopology) -> list[tuple[str, ...]]: + parent: dict[str, str] = {} + + def find(node_id: str) -> str: + parent.setdefault(node_id, node_id) + while parent[node_id] != node_id: + parent[node_id] = parent[parent[node_id]] + node_id = parent[node_id] + return node_id + + tree: dict[str, list[tuple[str, str]]] = {} + cycles: list[tuple[str, ...]] = [] + for road in sorted(topology.roads, key=lambda value: value.road_id): + first = road.from_node_id + second = road.to_node_id + first_root = find(first) + second_root = find(second) + if first_root != second_root: + parent[second_root] = first_root + tree.setdefault(first, []).append((second, road.road_id)) + tree.setdefault(second, []).append((first, road.road_id)) + continue + path = _tree_path(second, first, tree) + if len(path) >= 3: + cycles.append(tuple([first, *path[:-1]])) + return cycles + + +def _nearest_cul_de_sac_pairs( + topology: GameMapTopology, + directed: dict[tuple[str, str, str], list[GameMapLane]], +) -> list[tuple[str, str]]: + cul_de_sacs = sorted( + node.node_id for node in topology.nodes if node.node_type == "cul_de_sac" + ) + pairs: set[tuple[str, str]] = set() + for source_id in cul_de_sacs: + candidates: list[tuple[float, str]] = [] + for target_id in cul_de_sacs: + if target_id == source_id: + continue + try: + route = _shortest_roads(source_id, target_id, topology, directed) + except GameMapError: + continue + length = sum( + min( + _polyline_length(lane.centerline_world) + for lane in directed[traversal] + ) + for traversal in route + ) + candidates.append((length, target_id)) + if candidates: + target_id = min(candidates)[1] + pairs.add(tuple(sorted((source_id, target_id)))) + return sorted(pairs) + + +def _generated_route_templates( + topology: GameMapTopology, + lanes: tuple[GameMapLane, ...], + directed: dict[tuple[str, str, str], list[GameMapLane]], +) -> list[_RouteTemplate]: + candidates: list[tuple[tuple[str, ...], str]] = [] + for cycle in _fundamental_cycles(topology): + candidates.append((cycle, "wrap")) + candidates.append((tuple([cycle[0], *reversed(cycle[1:])]), "wrap")) + candidates.extend( + ((pair, "reverse") for pair in _nearest_cul_de_sac_pairs(topology, directed)) + ) + templates: list[_RouteTemplate] = [] + seen_geometry: set[bytes] = set() + for node_ids, end_behavior in candidates: + try: + centerline, speed_limits, route_element_ids = _compile_waypoint_route( + node_ids, + end_behavior, + topology, + lanes, + directed, + None, + ) + except GameMapError: + continue + fingerprint = hashlib.sha256(centerline.tobytes()).digest() + if fingerprint in seen_geometry: + continue + seen_geometry.add(fingerprint) + templates.append( + _RouteTemplate( + node_ids=node_ids, + end_behavior=end_behavior, + centerline_world=centerline, + speed_limits_mps=speed_limits, + route_element_ids=route_element_ids, + ) + ) + return templates + + +def _placement_at_distance( + centerline: np.ndarray, + speeds: np.ndarray, + distance_m: float, + dimensions_lwh_m: tuple[float, float, float], +) -> _Placement: + lengths = np.linalg.norm(np.diff(centerline[:, :2], axis=0), axis=1) + cumulative = np.concatenate(([0.0], np.cumsum(lengths))) + segment = min( + max(int(np.searchsorted(cumulative, distance_m, side="right") - 1), 0), + len(lengths) - 1, + ) + alpha = (distance_m - cumulative[segment]) / max(float(lengths[segment]), 1e-9) + position = centerline[segment, :2] + alpha * ( + centerline[segment + 1, :2] - centerline[segment, :2] + ) + forward = centerline[segment + 1, :2] - centerline[segment, :2] + forward /= max(float(np.linalg.norm(forward)), 1e-9) + speed = float(speeds[segment] * (1.0 - alpha) + speeds[segment + 1] * alpha) + return _Placement( + position_xy=np.asarray(position, dtype=np.float64), + forward_xy=np.asarray(forward, dtype=np.float64), + speed_mps=speed, + half_length_m=dimensions_lwh_m[0] * 0.5, + half_width_m=dimensions_lwh_m[1] * 0.5, + ) + + +def _footprints_overlap(first: _Placement, second: _Placement) -> bool: + first_left = np.asarray([-first.forward_xy[1], first.forward_xy[0]]) + second_left = np.asarray([-second.forward_xy[1], second.forward_xy[0]]) + delta = second.position_xy - first.position_xy + axes = (first.forward_xy, first_left, second.forward_xy, second_left) + first_extents = ( + first.half_length_m + _GENERATED_FOOTPRINT_BUFFER_M, + first.half_width_m + _GENERATED_FOOTPRINT_BUFFER_M, + ) + second_extents = ( + second.half_length_m + _GENERATED_FOOTPRINT_BUFFER_M, + second.half_width_m + _GENERATED_FOOTPRINT_BUFFER_M, + ) + for axis in axes: + first_radius = first_extents[0] * abs(float(np.dot(first.forward_xy, axis))) + first_radius += first_extents[1] * abs(float(np.dot(first_left, axis))) + second_radius = second_extents[0] * abs(float(np.dot(second.forward_xy, axis))) + second_radius += second_extents[1] * abs(float(np.dot(second_left, axis))) + if abs(float(np.dot(delta, axis))) >= first_radius + second_radius: + return False + return True + + +def _placement_is_safe( + candidate: _Placement, + occupied: list[_Placement], + spawn_positions: tuple[np.ndarray, ...], +) -> bool: + if any( + float(np.linalg.norm(candidate.position_xy - spawn_position)) + < _GENERATED_SPAWN_CLEARANCE_M + for spawn_position in spawn_positions + ): + return False + for other in occupied: + if _footprints_overlap(candidate, other): + return False + heading_dot = float(np.dot(candidate.forward_xy, other.forward_xy)) + heading_dot = float(np.clip(heading_dot, -1.0, 1.0)) + if math.acos(heading_dot) > _HEADWAY_MAX_ANGLE_RAD: + continue + delta = other.position_xy - candidate.position_xy + lateral = abs( + float( + candidate.forward_xy[0] * delta[1] - candidate.forward_xy[1] * delta[0] + ) + ) + if lateral > _HEADWAY_LANE_CORRIDOR_M: + continue + longitudinal = abs(float(np.dot(delta, candidate.forward_xy))) + required = ( + candidate.half_length_m + + other.half_length_m + + _HEADWAY_MIN_CLEARANCE_M + + _HEADWAY_TIME_S * max(candidate.speed_mps, other.speed_mps) + ) + if longitudinal < required: + return False + return True + + +def _generate_traffic( + count: int, + authored: list[GameMapTrafficVehicle], + topology: GameMapTopology, + lanes: tuple[GameMapLane, ...], + directed: dict[tuple[str, str, str], list[GameMapLane]], + map_id: str, + spawns: tuple[GameMapSpawn, ...], +) -> list[GameMapTrafficVehicle]: + templates = _generated_route_templates(topology, lanes, directed) + dimensions = _VEHICLE_DIMENSIONS_LWH_M["car"] + occupied = [ + _placement_at_distance( + vehicle.centerline_world, + vehicle.speed_limits_mps, + vehicle.start_distance_m, + vehicle.dimensions_lwh_m, + ) + for vehicle in authored + ] + spawn_positions = tuple( + np.asarray(spawn.position_world[:2], dtype=np.float64) for spawn in spawns + ) + slots: list[tuple[bytes, _RouteTemplate, float]] = [] + for template in templates: + route_length = _polyline_length(template.centerline_world) + signature = "|".join((*template.node_ids, template.end_behavior)).encode() + for offset_m in np.arange(0.0, route_length, _GENERATED_SLOT_SPACING_M): + key = hashlib.sha256( + map_id.encode() + + b"|" + + signature + + b"|" + + f"{float(offset_m):.3f}".encode() + ).digest() + slots.append((key, template, float(offset_m))) + accepted: list[tuple[_RouteTemplate, float]] = [] + for _, template, offset_m in sorted(slots, key=lambda item: item[0]): + placement = _placement_at_distance( + template.centerline_world, + template.speed_limits_mps, + offset_m, + dimensions, + ) + if not _placement_is_safe(placement, occupied, spawn_positions): + continue + occupied.append(placement) + accepted.append((template, offset_m)) + if len(accepted) == count: + break + if len(accepted) < count: + maximum = len(authored) + len(accepted) + raise GameMapError( + f"traffic_count requests {len(authored) + count} vehicles, but this " + f"map has safe capacity for {maximum}" + ) + used_ids = {vehicle.vehicle_id for vehicle in authored} + generated: list[GameMapTrafficVehicle] = [] + next_id = 1 + for template, offset_m in accepted[:count]: + while True: + vehicle_id = f"generated-traffic-{next_id:04d}" + next_id += 1 + if vehicle_id not in used_ids: + break + used_ids.add(vehicle_id) + generated.append( + GameMapTrafficVehicle( + vehicle_id=vehicle_id, + node_ids=template.node_ids, + end_behavior=template.end_behavior, + vehicle_type="car", + dimensions_lwh_m=dimensions, + speed_mps=None, + start_distance_m=offset_m, + centerline_world=template.centerline_world, + speed_limits_mps=template.speed_limits_mps, + route_element_ids=template.route_element_ids, + ) + ) + return generated + + +def compile_traffic( + raw_values: object, + topology: GameMapTopology, + lanes: tuple[GameMapLane, ...], + *, + traffic_count: object = None, + map_id: str = "", + spawns: tuple[GameMapSpawn, ...] = (), +) -> tuple[GameMapTrafficVehicle, ...]: + """Validate and compile optional traffic definitions.""" + if traffic_count is not None and ( + isinstance(traffic_count, bool) + or not isinstance(traffic_count, int) + or traffic_count < 0 + ): + raise GameMapError("traffic_count must be a nonnegative integer") + if raw_values is None: + raw_values = [] + if not isinstance(raw_values, list): + raise GameMapError("traffic must be a sequence") + nodes = {node.node_id: node for node in topology.nodes} + directed = _directed_road_lanes(topology, lanes) + results: list[GameMapTrafficVehicle] = [] + seen_ids: set[str] = set() + allowed = { + "id", + "nodes", + "end_behavior", + "vehicle_type", + "dimensions_lwh_m", + "speed_mps", + "start_distance_m", + } + for index, raw_value in enumerate(raw_values): + if not isinstance(raw_value, dict): + raise GameMapError(f"traffic[{index}] must be a mapping") + unknown = set(raw_value) - allowed + missing = {"id", "nodes", "end_behavior"} - set(raw_value) + if unknown or missing: + detail = ( + f"unknown fields {sorted(unknown)}" + if unknown + else f"missing fields {sorted(missing)}" + ) + raise GameMapError(f"traffic[{index}] has {detail}") + vehicle_id = str(raw_value["id"]).strip() + if not vehicle_id or vehicle_id in seen_ids: + raise GameMapError(f"Traffic id {vehicle_id!r} is empty or duplicated") + seen_ids.add(vehicle_id) + raw_nodes = raw_value["nodes"] + if not isinstance(raw_nodes, list) or len(raw_nodes) < 2: + raise GameMapError( + f"Traffic {vehicle_id!r}.nodes requires at least two nodes" + ) + node_ids = tuple(str(item).strip() for item in raw_nodes) + for node_id in node_ids: + if node_id not in nodes: + raise GameMapError( + f"Traffic {vehicle_id!r} references unknown node {node_id!r}" + ) + if nodes[node_id].node_type == "parking_lot": + raise GameMapError( + f"Traffic {vehicle_id!r} cannot visit parking-lot node {node_id!r}" + ) + end_behavior = str(raw_value["end_behavior"]).strip() + if end_behavior not in {"reverse", "wrap"}: + raise GameMapError( + f"Traffic {vehicle_id!r}.end_behavior must be reverse or wrap" + ) + vehicle_type = str(raw_value.get("vehicle_type", "car")).strip().lower() + if vehicle_type not in _VEHICLE_DIMENSIONS_LWH_M: + raise GameMapError( + f"Traffic {vehicle_id!r}.vehicle_type must be car, truck, or bus" + ) + dimensions_raw = raw_value.get( + "dimensions_lwh_m", _VEHICLE_DIMENSIONS_LWH_M[vehicle_type] + ) + if not isinstance(dimensions_raw, (list, tuple)) or len(dimensions_raw) != 3: + raise GameMapError( + f"Traffic {vehicle_id!r}.dimensions_lwh_m requires three values" + ) + try: + dimensions = tuple(float(item) for item in dimensions_raw) + except (TypeError, ValueError) as exc: + raise GameMapError( + f"Traffic {vehicle_id!r}.dimensions_lwh_m must be numeric" + ) from exc + if any(not math.isfinite(item) or item <= 0.0 for item in dimensions): + raise GameMapError( + f"Traffic {vehicle_id!r}.dimensions_lwh_m must be positive and finite" + ) + speed_value = raw_value.get("speed_mps") + try: + speed_mps = None if speed_value is None else float(speed_value) + start_distance_m = float(raw_value.get("start_distance_m", 0.0)) + except (TypeError, ValueError) as exc: + raise GameMapError( + f"Traffic {vehicle_id!r} speed_mps and start_distance_m must be numeric" + ) from exc + if speed_mps is not None and (not math.isfinite(speed_mps) or speed_mps <= 0.0): + raise GameMapError( + f"Traffic {vehicle_id!r}.speed_mps must be positive and finite" + ) + if not math.isfinite(start_distance_m) or start_distance_m < 0.0: + raise GameMapError( + f"Traffic {vehicle_id!r}.start_distance_m must be nonnegative and finite" + ) + + centerline, speed_limits, route_element_ids = _compile_waypoint_route( + node_ids, + end_behavior, + topology, + lanes, + directed, + speed_mps, + ) + route_length = _polyline_length(centerline) + if start_distance_m >= route_length: + raise GameMapError( + f"Traffic {vehicle_id!r}.start_distance_m must be less than route length {route_length:.2f} m" + ) + results.append( + GameMapTrafficVehicle( + vehicle_id=vehicle_id, + node_ids=node_ids, + end_behavior=end_behavior, + vehicle_type=vehicle_type, + dimensions_lwh_m=dimensions, + speed_mps=speed_mps, + start_distance_m=start_distance_m, + centerline_world=centerline, + speed_limits_mps=speed_limits, + route_element_ids=route_element_ids, + ) + ) + if traffic_count is None: + return tuple(results) + if traffic_count < len(results): + raise GameMapError( + f"traffic_count is {traffic_count}, but traffic defines " + f"{len(results)} vehicles; remove entries or increase traffic_count" + ) + generated_count = traffic_count - len(results) + if generated_count: + results.extend( + _generate_traffic( + generated_count, + results, + topology, + lanes, + directed, + map_id, + spawns, + ) + ) + return tuple(results) + + +__all__ = ["compile_traffic"] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py new file mode 100644 index 000000000..98ac547c3 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py @@ -0,0 +1,850 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Runtime types for resolved semantic game maps.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import numpy.typing as npt + +FloatArray = npt.NDArray[np.float32] + + +@dataclass(frozen=True) +class GameMapBoundaryAttributes: + """Resolved attributes for a structural map element.""" + + curb: bool + """Whether the element emits physical curb boundaries.""" + + +@dataclass(frozen=True) +class GameMapLinearAttributes(GameMapBoundaryAttributes): + """Resolved lane, surface, and marking attributes.""" + + lane_width_m: float + """Width of one directed lane in metres.""" + + curb_offset_m: float + """Paved offset between the outer lane edge and curb.""" + + directions: tuple[str, ...] + """Ordered lane directions across the element.""" + + speed_limit_mps: float + """Lane speed limit in metres per second.""" + + marking_style: str + """ClipGT-compatible outer lane-marking style.""" + + marking_color: str + """ClipGT-compatible outer lane-marking color.""" + + divider_markings: tuple[tuple[str, str], ...] + """Style and color for each adjacent lane pair.""" + + @property + def lane_width_total_m(self) -> float: + """Return the total width occupied by lanes.""" + return self.lane_width_m * len(self.directions) + + @property + def surface_width_m(self) -> float: + """Return the curb-to-curb paved width.""" + return self.lane_width_total_m + 2.0 * self.curb_offset_m + + +@dataclass(frozen=True) +class GameMapNode: + """One explicitly posed node in the authored road network.""" + + node_id: str + """Stable author-defined node identifier.""" + + node_type: str + """Node discriminator such as intersection, road joint, or parking lot.""" + + x_m: float + """Map-space x coordinate of the node origin.""" + + y_m: float + """Map-space y coordinate of the node origin.""" + + profile_id: str | None + """Optional source profile used to resolve node attributes.""" + + attributes: GameMapBoundaryAttributes | GameMapLinearAttributes + """Effective node attributes after applying profile defaults.""" + + geometry: dict[str, float] + """Validated node-type-specific geometry parameters.""" + + polygon_vertices_xy: tuple[tuple[float, float], ...] = () + """Authored map-space polygon vertices for a parking-lot node.""" + + +@dataclass(frozen=True, eq=False) +class GameMapRoad: + """One topological road edge between two structural nodes.""" + + road_id: str + """Stable author-defined road identifier.""" + + from_node_id: str + """Node at the beginning of the authored road geometry.""" + + to_node_id: str + """Node at the end of the authored road geometry.""" + + profile_id: str | None + """Optional source profile used to resolve road attributes.""" + + attributes: GameMapLinearAttributes + """Effective road attributes after applying profile defaults.""" + + bezier_spans_world: tuple[FloatArray, ...] + """Compiler-generated map-space cubic spans shaped ``[4, 3]``; empty is straight.""" + + def __eq__(self, other: object) -> bool: + """Compare road metadata and cubic span values.""" + if not isinstance(other, GameMapRoad): + return NotImplemented + return ( + self.road_id == other.road_id + and self.from_node_id == other.from_node_id + and self.to_node_id == other.to_node_id + and self.profile_id == other.profile_id + and self.attributes == other.attributes + and len(self.bezier_spans_world) == len(other.bezier_spans_world) + and all( + np.array_equal(first, second) + for first, second in zip( + self.bezier_spans_world, + other.bezier_spans_world, + strict=True, + ) + ) + ) + + +@dataclass(frozen=True) +class GameMapParkingAccess: + """A parking-lot node's inferred access corridor.""" + + access_id: str + """Stable identifier derived from the parking-lot node identifier.""" + + source_node_id: str + """Intersection or driveway node at the road end of the access.""" + + parking_lot_node_id: str + """Parking-lot node reached by the access.""" + + opening_vertex_index: int + """Zero-based runtime index of the first vertex in the opening edge.""" + + +@dataclass(frozen=True) +class GameMapTopology: + """Persisted node graph and its derived adjacency.""" + + nodes: tuple[GameMapNode, ...] + """Typed, explicitly posed graph nodes.""" + + roads: tuple[GameMapRoad, ...] + """Authored topological road edges.""" + + parking_accesses: tuple[GameMapParkingAccess, ...] + """Access corridors derived from parking-lot node connections.""" + + adjacency: tuple[tuple[str, tuple[str, ...]], ...] + """Node identifiers paired with stable incident edge/link references.""" + + +@dataclass(frozen=True) +class GameMapVisualVariant: + """Optional seed image and prompt for one visual variant.""" + + name: str + """Variant slug used to select this visual conditioning.""" + + image: str | None + """Optional map-relative or ``package://`` seed-image reference.""" + + prompt: str + """World-model text prompt paired with the seed image.""" + + +@dataclass(frozen=True) +class GameMapSpawn: + """Vehicle spawn resolved onto a directed lane.""" + + spawn_id: str + """Stable author-defined spawn identifier.""" + + lane_id: str + """Directed lane containing the spawn.""" + + distance_m: float + """Distance from the directed lane start.""" + + position_world: FloatArray + """World position with shape ``[3]``.""" + + yaw_rad: float + """World heading following the directed lane.""" + + variants: tuple[GameMapVisualVariant, ...] + """Available visual seed variants; ``default`` is always present.""" + + +@dataclass(frozen=True) +class GameMapTrafficVehicle: + """One map-authored vehicle and its compiled cyclic route.""" + + vehicle_id: str + """Stable author-defined traffic identifier.""" + + node_ids: tuple[str, ...] + """Ordered author-defined node waypoints.""" + + end_behavior: str + """Whether the waypoint list wraps or is traversed in reverse.""" + + vehicle_type: str + """Motor-vehicle category used by conditioning and physics.""" + + dimensions_lwh_m: tuple[float, float, float] + """Full vehicle length, width, and height in metres.""" + + speed_mps: float | None + """Optional maximum speed; ``None`` follows lane speed limits.""" + + start_distance_m: float + """Initial arc distance along the resolved cyclic route.""" + + centerline_world: FloatArray + """Closed, directed route centerline with shape ``[N, 3]``.""" + + speed_limits_mps: FloatArray + """Per-route-sample target speeds with shape ``[N]``.""" + + route_element_ids: tuple[str, ...] + """Owning road or node identifier for each route segment.""" + + def __post_init__(self) -> None: + if len(self.route_element_ids) != len(self.centerline_world) - 1: + raise ValueError( + "route_element_ids must contain one identifier per route segment" + ) + + +@dataclass(frozen=True) +class GameMapLane: + """Explicit directed lane and its legal successors.""" + + lane_id: str + """Stable compiler-generated lane identifier.""" + + element_id: str + """Owning routable map-element identifier.""" + + centerline_world: FloatArray + """Directed centerline with shape ``[N, 3]``.""" + + left_edge_world: FloatArray + """Left rail in travel direction with shape ``[N, 3]``.""" + + right_edge_world: FloatArray + """Right rail in travel direction with shape ``[N, 3]``.""" + + roadside_edge_world: FloatArray + """Physical roadside edge to the right of travel with shape ``[N, 3]``.""" + + speed_limit_mps: float + """Authored speed limit for this lane.""" + + marking_style: str + """ClipGT-compatible lane-marking style.""" + + marking_color: str + """ClipGT-compatible lane-marking color.""" + + left_marking_style: str + """ClipGT-compatible marking style for the directed left rail.""" + + left_marking_color: str + """ClipGT-compatible marking color for the directed left rail.""" + + right_marking_style: str + """ClipGT-compatible marking style for the directed right rail.""" + + right_marking_color: str + """ClipGT-compatible marking color for the directed right rail.""" + + successor_ids: tuple[str, ...] + """Legal successor lane identifiers.""" + + allows_taxi_stops: bool = True + """Whether taxi targets may be sampled from this lane.""" + + conditioning_visible: bool = True + """Whether the lane is emitted into world-model map conditioning.""" + + +@dataclass(frozen=True) +class GameMapElement: + """Resolved map-element geometry used by previews and diagnostics.""" + + element_id: str + """Stable author-defined identifier.""" + + element_type: str + """Schema discriminator such as ``road`` or ``intersection``.""" + + profile_id: str | None + """Optional source profile used to resolve element attributes.""" + + attributes: GameMapBoundaryAttributes | GameMapLinearAttributes + """Effective attributes controlling this element.""" + + surface_world: FloatArray + """Closed surface polygon with shape ``[N, 3]``.""" + + road_boundaries: tuple[GameMapRoadBoundary, ...] + """Element-owned semantic boundary polylines excluding declared openings.""" + + curbs: tuple[GameMapCurb, ...] + """Physical curb polylines used as collision barriers.""" + + +@dataclass(frozen=True) +class GameMapRoadBoundary: + """One semantic road-boundary polyline owned by a resolved map element.""" + + boundary_id: str + """Stable compiler-generated identifier scoped to the owning element.""" + + polyline_world: FloatArray + """World-space boundary points with shape ``[N, 3]``.""" + + +@dataclass(frozen=True) +class GameMapCurb: + """One stable curb polyline owned by a resolved map element.""" + + curb_id: str + """Stable compiler-generated identifier scoped to the owning element.""" + + polyline_world: FloatArray + """World-space curb points with shape ``[N, 3]``.""" + + +@dataclass(frozen=True) +class GameMapLineMarking: + """Resolved line marking emitted into model conditioning.""" + + marking_id: str + """Stable compiler-generated marking identifier.""" + + polyline_world: FloatArray + """World-space marking centerline with shape ``[N, 3]``.""" + + style: str + """ClipGT-compatible lane-line style.""" + + color: str + """ClipGT-compatible lane-line color.""" + + +@dataclass(frozen=True) +class GameMapLaneDivider: + """One resolved divider shared by two adjacent authored lanes.""" + + divider_id: str + """Stable compiler-generated divider identifier.""" + + lane_edges: tuple[tuple[str, str], tuple[str, str]] + """Adjacent ``(lane_id, side)`` pairs represented by the divider.""" + + polyline_world: FloatArray + """World-space divider centerline with shape ``[N, 3]``.""" + + style: str + """ClipGT-compatible lane-line style.""" + + color: str + """ClipGT-compatible lane-line color.""" + + +@dataclass(frozen=True) +class ResolvedGameMap: + """Validated semantic map with generated runtime geometry.""" + + schema_version: int + """Authoring schema version.""" + + map_id: str + """Stable map identifier.""" + + name: str + """Human-readable map name.""" + + source_path: Path + """Canonical YAML source path.""" + + compiler_settings: dict[str, object] + """Resolved authoring settings that affect generated map geometry.""" + + topology: GameMapTopology + """First-class authored topology retained alongside derived lane geometry.""" + + lanes: tuple[GameMapLane, ...] + """Directed road and intersection lanes.""" + + elements: tuple[GameMapElement, ...] + """Resolved element surfaces used by conditioning and previews.""" + + road_marking_polygons_world: tuple[FloatArray, ...] + """Closed road-marking polygons used by conditioning and previews.""" + + lane_dividers: tuple[GameMapLaneDivider, ...] + """Resolved non-virtual dividers between adjacent authored lanes.""" + + line_markings: tuple[GameMapLineMarking, ...] + """Standalone painted lines used by conditioning and previews.""" + + ground_vertices: FloatArray + """Flat ground-mesh vertices.""" + + ground_faces: npt.NDArray[np.int32] + """Ground-mesh triangle indices.""" + + spawns: tuple[GameMapSpawn, ...] + """Playable vehicle spawns.""" + + traffic: tuple[GameMapTrafficVehicle, ...] = () + """Map-authored vehicles with compiled cyclic public-road routes.""" + + @property + def default_spawn(self) -> GameMapSpawn: + """Return the first declared spawn.""" + return self.spawns[0] + + @property + def variants(self) -> tuple[str, ...]: + """Return variants available at the default spawn.""" + names = [variant.name for variant in self.default_spawn.variants] + return tuple(names) + + +def game_map_to_dict(game_map: ResolvedGameMap) -> dict[str, Any]: + """Serialize a resolved map into JSON-compatible values.""" + return { + "schema_version": game_map.schema_version, + "map_id": game_map.map_id, + "name": game_map.name, + "source_path": str(game_map.source_path), + "compiler_settings": game_map.compiler_settings, + "topology": { + "nodes": [ + { + "node_id": node.node_id, + "node_type": node.node_type, + "x_m": node.x_m, + "y_m": node.y_m, + "profile_id": node.profile_id, + "attributes": _attributes_to_dict(node.attributes), + "geometry": node.geometry, + "polygon_vertices_xy": [ + list(point) for point in node.polygon_vertices_xy + ], + } + for node in game_map.topology.nodes + ], + "roads": [ + { + "road_id": road.road_id, + "from_node_id": road.from_node_id, + "to_node_id": road.to_node_id, + "profile_id": road.profile_id, + "attributes": _attributes_to_dict(road.attributes), + "bezier_spans_world": [ + span.tolist() for span in road.bezier_spans_world + ], + } + for road in game_map.topology.roads + ], + "parking_accesses": [ + { + "access_id": access.access_id, + "source_node_id": access.source_node_id, + "parking_lot_node_id": access.parking_lot_node_id, + "opening_vertex_index": access.opening_vertex_index, + } + for access in game_map.topology.parking_accesses + ], + "adjacency": [ + [node_id, list(references)] + for node_id, references in game_map.topology.adjacency + ], + }, + "lanes": [ + { + "lane_id": lane.lane_id, + "element_id": lane.element_id, + "centerline_world": lane.centerline_world.tolist(), + "left_edge_world": lane.left_edge_world.tolist(), + "right_edge_world": lane.right_edge_world.tolist(), + "roadside_edge_world": lane.roadside_edge_world.tolist(), + "speed_limit_mps": lane.speed_limit_mps, + "marking_style": lane.marking_style, + "marking_color": lane.marking_color, + "left_marking_style": lane.left_marking_style, + "left_marking_color": lane.left_marking_color, + "right_marking_style": lane.right_marking_style, + "right_marking_color": lane.right_marking_color, + "successor_ids": list(lane.successor_ids), + "allows_taxi_stops": lane.allows_taxi_stops, + "conditioning_visible": lane.conditioning_visible, + } + for lane in game_map.lanes + ], + "elements": [ + { + "element_id": element.element_id, + "element_type": element.element_type, + "profile_id": element.profile_id, + "attributes": _attributes_to_dict(element.attributes), + "surface_world": element.surface_world.tolist(), + "road_boundaries": [ + { + "boundary_id": boundary.boundary_id, + "polyline_world": boundary.polyline_world.tolist(), + } + for boundary in element.road_boundaries + ], + "curbs": [ + { + "curb_id": curb.curb_id, + "polyline_world": curb.polyline_world.tolist(), + } + for curb in element.curbs + ], + } + for element in game_map.elements + ], + "road_marking_polygons_world": [ + polygon.tolist() for polygon in game_map.road_marking_polygons_world + ], + "lane_dividers": [ + { + "divider_id": divider.divider_id, + "lane_edges": [list(edge) for edge in divider.lane_edges], + "polyline_world": divider.polyline_world.tolist(), + "style": divider.style, + "color": divider.color, + } + for divider in game_map.lane_dividers + ], + "line_markings": [ + { + "marking_id": marking.marking_id, + "polyline_world": marking.polyline_world.tolist(), + "style": marking.style, + "color": marking.color, + } + for marking in game_map.line_markings + ], + "ground_vertices": game_map.ground_vertices.tolist(), + "ground_faces": game_map.ground_faces.tolist(), + "spawns": [ + { + "spawn_id": spawn.spawn_id, + "lane_id": spawn.lane_id, + "distance_m": spawn.distance_m, + "position_world": spawn.position_world.tolist(), + "yaw_rad": spawn.yaw_rad, + "variants": [ + { + "name": variant.name, + "image": variant.image, + "prompt": variant.prompt, + } + for variant in spawn.variants + ], + } + for spawn in game_map.spawns + ], + "traffic": [ + { + "vehicle_id": vehicle.vehicle_id, + "node_ids": list(vehicle.node_ids), + "end_behavior": vehicle.end_behavior, + "vehicle_type": vehicle.vehicle_type, + "dimensions_lwh_m": list(vehicle.dimensions_lwh_m), + "speed_mps": vehicle.speed_mps, + "start_distance_m": vehicle.start_distance_m, + "centerline_world": vehicle.centerline_world.tolist(), + "speed_limits_mps": vehicle.speed_limits_mps.tolist(), + "route_element_ids": list(vehicle.route_element_ids), + } + for vehicle in game_map.traffic + ], + } + + +def _attributes_to_dict( + attributes: GameMapBoundaryAttributes | GameMapLinearAttributes, +) -> dict[str, Any]: + """Serialize resolved element attributes.""" + result: dict[str, Any] = {"curb": attributes.curb} + if isinstance(attributes, GameMapLinearAttributes): + result.update( + { + "lane_width_m": attributes.lane_width_m, + "curb_offset_m": attributes.curb_offset_m, + "directions": list(attributes.directions), + "speed_limit_mps": attributes.speed_limit_mps, + "marking_style": attributes.marking_style, + "marking_color": attributes.marking_color, + "divider_markings": [ + list(marking) for marking in attributes.divider_markings + ], + } + ) + return result + + +def _attributes_from_dict( + raw: dict[str, Any], *, linear: bool +) -> GameMapBoundaryAttributes | GameMapLinearAttributes: + """Deserialize resolved attributes for one map element.""" + if not linear: + return GameMapBoundaryAttributes(curb=bool(raw["curb"])) + return GameMapLinearAttributes( + curb=bool(raw["curb"]), + lane_width_m=float(raw["lane_width_m"]), + curb_offset_m=float(raw["curb_offset_m"]), + directions=tuple(str(value) for value in raw["directions"]), + speed_limit_mps=float(raw["speed_limit_mps"]), + marking_style=str(raw["marking_style"]), + marking_color=str(raw["marking_color"]), + divider_markings=tuple( + (str(value[0]), str(value[1])) for value in raw["divider_markings"] + ), + ) + + +def _lane_divider_from_dict(raw: dict[str, Any]) -> GameMapLaneDivider: + edges = list(raw["lane_edges"]) + if len(edges) != 2 or any(len(edge) != 2 for edge in edges): + raise ValueError("lane_dividers[].lane_edges must contain exactly two pairs") + return GameMapLaneDivider( + divider_id=str(raw["divider_id"]), + lane_edges=( + (str(edges[0][0]), str(edges[0][1])), + (str(edges[1][0]), str(edges[1][1])), + ), + polyline_world=np.asarray(raw["polyline_world"], dtype=np.float32), + style=str(raw["style"]), + color=str(raw["color"]), + ) + + +def game_map_from_dict(value: dict[str, Any]) -> ResolvedGameMap: + """Deserialize embedded semantic-map metadata.""" + raw_topology = dict(value["topology"]) + topology = GameMapTopology( + nodes=tuple( + GameMapNode( + node_id=str(raw["node_id"]), + node_type=str(raw["node_type"]), + x_m=float(raw["x_m"]), + y_m=float(raw["y_m"]), + profile_id=( + None if raw.get("profile_id") is None else str(raw["profile_id"]) + ), + attributes=_attributes_from_dict( + dict(raw["attributes"]), + linear=str(raw["node_type"]) in {"driveway", "road_joint"}, + ), + geometry={ + str(key): float(item) for key, item in raw["geometry"].items() + }, + polygon_vertices_xy=tuple( + (float(point[0]), float(point[1])) + for point in raw.get("polygon_vertices_xy", ()) + ), + ) + for raw in raw_topology["nodes"] + ), + roads=tuple( + GameMapRoad( + road_id=str(raw["road_id"]), + from_node_id=str(raw["from_node_id"]), + to_node_id=str(raw["to_node_id"]), + profile_id=( + None if raw.get("profile_id") is None else str(raw["profile_id"]) + ), + attributes=_attributes_from_dict(dict(raw["attributes"]), linear=True), + bezier_spans_world=tuple( + np.asarray(span, dtype=np.float32) + for span in raw["bezier_spans_world"] + ), + ) + for raw in raw_topology["roads"] + ), + parking_accesses=tuple( + GameMapParkingAccess( + access_id=str(raw["access_id"]), + source_node_id=str(raw["source_node_id"]), + parking_lot_node_id=str(raw["parking_lot_node_id"]), + opening_vertex_index=int(raw["opening_vertex_index"]), + ) + for raw in raw_topology["parking_accesses"] + ), + adjacency=tuple( + (str(raw[0]), tuple(str(reference) for reference in raw[1])) + for raw in raw_topology["adjacency"] + ), + ) + lanes = tuple( + GameMapLane( + lane_id=str(raw["lane_id"]), + element_id=str(raw["element_id"]), + centerline_world=np.asarray(raw["centerline_world"], dtype=np.float32), + left_edge_world=np.asarray(raw["left_edge_world"], dtype=np.float32), + right_edge_world=np.asarray(raw["right_edge_world"], dtype=np.float32), + roadside_edge_world=np.asarray( + raw.get("roadside_edge_world", raw["right_edge_world"]), + dtype=np.float32, + ), + speed_limit_mps=float(raw["speed_limit_mps"]), + marking_style=str(raw["marking_style"]), + marking_color=str(raw["marking_color"]), + left_marking_style=str(raw.get("left_marking_style", raw["marking_style"])), + left_marking_color=str(raw.get("left_marking_color", raw["marking_color"])), + right_marking_style=str( + raw.get("right_marking_style", raw["marking_style"]) + ), + right_marking_color=str( + raw.get("right_marking_color", raw["marking_color"]) + ), + successor_ids=tuple(str(item) for item in raw["successor_ids"]), + allows_taxi_stops=bool(raw["allows_taxi_stops"]), + conditioning_visible=bool(raw.get("conditioning_visible", True)), + ) + for raw in value["lanes"] + ) + elements = tuple( + GameMapElement( + element_id=str(raw["element_id"]), + element_type=str(raw["element_type"]), + profile_id=( + None if raw.get("profile_id") is None else str(raw["profile_id"]) + ), + attributes=_attributes_from_dict( + dict(raw["attributes"]), + linear=str(raw["element_type"]) + in { + "road", + "road_joint", + "driveway", + "parking_access", + }, + ), + surface_world=np.asarray(raw["surface_world"], dtype=np.float32), + road_boundaries=tuple( + GameMapRoadBoundary( + boundary_id=str( + boundary.get("boundary_id", boundary.get("curb_id")) + ), + polyline_world=np.asarray( + boundary["polyline_world"], dtype=np.float32 + ), + ) + for boundary in raw.get("road_boundaries", raw.get("curbs", [])) + ), + curbs=tuple( + GameMapCurb( + curb_id=str(curb["curb_id"]), + polyline_world=np.asarray(curb["polyline_world"], dtype=np.float32), + ) + for curb in raw.get("curbs", []) + ), + ) + for raw in value["elements"] + ) + spawns = tuple( + GameMapSpawn( + spawn_id=str(raw["spawn_id"]), + lane_id=str(raw["lane_id"]), + distance_m=float(raw["distance_m"]), + position_world=np.asarray(raw["position_world"], dtype=np.float32), + yaw_rad=float(raw["yaw_rad"]), + variants=tuple( + GameMapVisualVariant( + name=str(variant["name"]), + image=( + None if variant.get("image") is None else str(variant["image"]) + ), + prompt=str(variant["prompt"]), + ) + for variant in raw["variants"] + ), + ) + for raw in value["spawns"] + ) + traffic = tuple( + GameMapTrafficVehicle( + vehicle_id=str(raw["vehicle_id"]), + node_ids=tuple(str(item) for item in raw["node_ids"]), + end_behavior=str(raw["end_behavior"]), + vehicle_type=str(raw["vehicle_type"]), + dimensions_lwh_m=tuple(float(item) for item in raw["dimensions_lwh_m"]), + speed_mps=( + None if raw.get("speed_mps") is None else float(raw["speed_mps"]) + ), + start_distance_m=float(raw["start_distance_m"]), + centerline_world=np.asarray(raw["centerline_world"], dtype=np.float32), + speed_limits_mps=np.asarray(raw["speed_limits_mps"], dtype=np.float32), + route_element_ids=tuple(str(item) for item in raw["route_element_ids"]), + ) + for raw in value.get("traffic", []) + ) + return ResolvedGameMap( + schema_version=int(value["schema_version"]), + map_id=str(value["map_id"]), + name=str(value["name"]), + source_path=Path(str(value["source_path"])), + compiler_settings=dict(value.get("compiler_settings", {})), + topology=topology, + lanes=lanes, + elements=elements, + road_marking_polygons_world=tuple( + np.asarray(polygon, dtype=np.float32) + for polygon in value.get("road_marking_polygons_world", []) + ), + lane_dividers=tuple( + _lane_divider_from_dict(raw) for raw in value.get("lane_dividers", []) + ), + line_markings=tuple( + GameMapLineMarking( + marking_id=str(raw["marking_id"]), + polyline_world=np.asarray(raw["polyline_world"], dtype=np.float32), + style=str(raw["style"]), + color=str(raw["color"]), + ) + for raw in value.get("line_markings", []) + ), + ground_vertices=np.asarray(value["ground_vertices"], dtype=np.float32), + ground_faces=np.asarray(value["ground_faces"], dtype=np.int32), + spawns=spawns, + traffic=traffic, + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/vicinity.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/vicinity.py new file mode 100644 index 000000000..d278e0af6 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/vicinity.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Resolve graph-local actor visibility around a map-space vehicle pose.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from omnidreams_game_engine.game_map.types import ResolvedGameMap + +_BOUNDARY_EPSILON_M = 1.0e-4 + + +@dataclass(frozen=True) +class GameMapVicinity: + """Semantic location and actor-visible element sets for one ego pose.""" + + location_element_id: str + traffic_element_ids: frozenset[str] + pedestrian_element_ids: frozenset[str] + + +def _polygon_contains(point: np.ndarray, polygon: np.ndarray) -> bool: + """Return whether a point is inside or on a pre-normalized polygon.""" + starts = polygon + ends = np.roll(polygon, -1, axis=0) + vectors = ends - starts + lengths_sq = np.einsum("ij,ij->i", vectors, vectors) + relative = point[None, :] - starts + alpha = np.divide( + np.einsum("ij,ij->i", relative, vectors), + lengths_sq, + out=np.zeros_like(lengths_sq), + where=lengths_sq > 1.0e-12, + ) + closest = starts + np.clip(alpha, 0.0, 1.0)[:, None] * vectors + offsets = point[None, :] - closest + if np.any(np.einsum("ij,ij->i", offsets, offsets) <= _BOUNDARY_EPSILON_M**2): + return True + + crosses_y = (starts[:, 1] > point[1]) != (ends[:, 1] > point[1]) + if not np.any(crosses_y): + return False + crossing_starts = starts[crosses_y] + crossing_vectors = vectors[crosses_y] + crossing_x = crossing_starts[:, 0] + ( + (point[1] - crossing_starts[:, 1]) + * crossing_vectors[:, 0] + / crossing_vectors[:, 1] + ) + return bool(np.count_nonzero(point[0] < crossing_x) % 2) + + +@dataclass(frozen=True) +class _PolygonLookup: + """A small vectorized bounding-box index over semantic polygons.""" + + element_ids: tuple[str, ...] + polygons: tuple[np.ndarray, ...] + minimums_xy: np.ndarray + maximums_xy: np.ndarray + + @classmethod + def build(cls, entries: tuple[tuple[str, np.ndarray], ...]) -> _PolygonLookup: + element_ids: list[str] = [] + polygons: list[np.ndarray] = [] + for element_id, polygon in entries: + vertices = np.asarray(polygon[:, :2], dtype=np.float64) + if len(vertices) > 1 and np.allclose(vertices[0], vertices[-1]): + vertices = vertices[:-1] + element_ids.append(element_id) + polygons.append(vertices) + if not polygons: + empty = np.empty((0, 2), dtype=np.float64) + return cls((), (), empty, empty.copy()) + return cls( + tuple(element_ids), + tuple(polygons), + np.asarray([polygon.min(axis=0) for polygon in polygons]), + np.asarray([polygon.max(axis=0) for polygon in polygons]), + ) + + def containing_element(self, point_xy: np.ndarray) -> str | None: + """Return the first indexed polygon containing ``point_xy``.""" + within_bounds = np.all( + (point_xy >= self.minimums_xy - _BOUNDARY_EPSILON_M) + & (point_xy <= self.maximums_xy + _BOUNDARY_EPSILON_M), + axis=1, + ) + for index in np.flatnonzero(within_bounds): + if _polygon_contains(point_xy, self.polygons[int(index)]): + return self.element_ids[int(index)] + return None + + +class GameMapVicinityResolver: + """Resolve the current road/node neighborhood from compiled map geometry.""" + + def __init__(self, game_map: ResolvedGameMap) -> None: + self._nodes = {node.node_id: node for node in game_map.topology.nodes} + self._roads = {road.road_id: road for road in game_map.topology.roads} + self._incident_roads: dict[str, set[str]] = { + node_id: set() for node_id in self._nodes + } + for road in self._roads.values(): + self._incident_roads[road.from_node_id].add(road.road_id) + self._incident_roads[road.to_node_id].add(road.road_id) + self._parking_lots_by_access_node: dict[str, set[str]] = {} + self._access_source_by_id: dict[str, str] = {} + for access in game_map.topology.parking_accesses: + self._parking_lots_by_access_node.setdefault( + access.source_node_id, set() + ).add(access.parking_lot_node_id) + self._access_source_by_id[access.access_id] = access.source_node_id + elements = {element.element_id: element for element in game_map.elements} + self._node_polygons = _PolygonLookup.build( + tuple( + (node_id, elements[node_id].surface_world) + for node_id in sorted(self._nodes) + if node_id in elements + ) + ) + self._road_polygons = _PolygonLookup.build( + tuple( + (road_id, elements[road_id].surface_world) + for road_id in sorted(self._roads) + if road_id in elements + ) + ) + self._access_polygons = _PolygonLookup.build( + tuple( + ( + self._access_source_by_id[access_id], + elements[access_id].surface_world, + ) + for access_id in sorted(self._access_source_by_id) + if access_id in elements + ) + ) + + def _location_element(self, point_xy: np.ndarray) -> str | None: + node_id = self._node_polygons.containing_element(point_xy) + if node_id is not None: + return node_id + road_id = self._road_polygons.containing_element(point_xy) + if road_id is not None: + return road_id + return self._access_polygons.containing_element(point_xy) + + def _expanded_elements(self, location: str) -> set[str]: + """Return nodes within one public-road hop and all their incident roads.""" + if location in self._roads: + road = self._roads[location] + seed_nodes = {road.from_node_id, road.to_node_id} + else: + seed_nodes = {location} + + first_roads = { + road_id + for node_id in seed_nodes + for road_id in self._incident_roads.get(node_id, ()) + } + expanded_nodes = set(seed_nodes) + for road_id in first_roads: + road = self._roads[road_id] + expanded_nodes.update((road.from_node_id, road.to_node_id)) + expanded_roads = { + road_id + for node_id in expanded_nodes + for road_id in self._incident_roads.get(node_id, ()) + } + return expanded_nodes | expanded_roads + + def resolve( + self, + x_m: float, + y_m: float, + *, + previous: GameMapVicinity | None = None, + ) -> GameMapVicinity | None: + """Return the graph neighborhood, preserving ``previous`` while off-road.""" + point_xy = np.asarray([x_m, y_m], dtype=np.float64) + location = self._location_element(point_xy) + if location is None: + return previous + traffic = self._expanded_elements(location) + pedestrians = set(traffic) + for node_id in traffic: + pedestrians.update(self._parking_lots_by_access_node.get(node_id, ())) + return GameMapVicinity(location, frozenset(traffic), frozenset(pedestrians)) + + +__all__ = ["GameMapVicinity", "GameMapVicinityResolver"] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py new file mode 100644 index 000000000..a2786c644 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deterministic V2 event reduction for model-thread driving.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from flashdreams.runtime_v2.user_input_event import ( + FocusUserInputEventData, + KeyboardInputState, + KeyboardUserInputEventData, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents +from omnidreams_game_engine.config import DriverInputConfig +from omnidreams_game_engine.types import DriverCommand + +_DRIVE_KEYS = frozenset( + {"w", "a", "s", "d", "up", "down", "left", "right", "space"} +) +_TEXT_CHARACTERS = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-" +) + + +@dataclass(frozen=True, slots=True) +class DriverInputBatch: + """Per-frame commands and optional submitted text for one model step.""" + + commands: tuple[DriverCommand, ...] + submitted_text: str | None + text: str + + +class DriverInput: + """Retain key levels while consuming V2 edge events exactly once.""" + + def __init__(self, config: DriverInputConfig = DriverInputConfig()) -> None: + self.config = config + self._pressed: set[str] = set() + self._steering = 0.0 + self._text = "" + + @property + def text(self) -> str: + return self._text + + def reduce( + self, + events: UserInputEvents, + *, + frame_count: int, + frame_interval_s: float, + accepting_text: bool, + ) -> DriverInputBatch: + """Apply input edges and produce a command for every simulated frame.""" + if frame_count <= 0: + raise ValueError("frame_count must be positive") + submitted: str | None = None + for event in events.get_events(): + data = event.get_event_data() + if isinstance(data, FocusUserInputEventData) and not data.focused: + self._pressed.clear() + continue + if not isinstance(data, KeyboardUserInputEventData): + continue + raw_key = str(data.key) + key = _normalize_key(raw_key) + pressed = data.state is KeyboardInputState.PRESSED + if accepting_text and pressed: + consumed, value = self._consume_text_key(raw_key, key) + if consumed: + submitted = value or submitted + continue + if key in _DRIVE_KEYS: + if pressed: + self._pressed.add(key) + else: + self._pressed.discard(key) + + commands: list[DriverCommand] = [] + for _ in range(frame_count): + target = self.config.steering_scale * ( + float(bool({"a", "left"} & self._pressed)) + - float(bool({"d", "right"} & self._pressed)) + ) + rate = ( + self.config.steering_rate_per_s + if target + else self.config.steering_return_rate_per_s + ) + self._steering = _move_towards( + self._steering, + target, + rate * frame_interval_s, + ) + forward = bool({"w", "up"} & self._pressed) + backward = bool({"s", "down"} & self._pressed) + commands.append( + DriverCommand( + throttle=1.0 if forward and not backward else 0.0, + brake=1.0 if backward or "space" in self._pressed else 0.0, + steer=self._steering, + handbrake="space" in self._pressed, + steer_is_direct=True, + manual_control=True, + ) + ) + return DriverInputBatch(tuple(commands), submitted, self._text) + + def reset(self) -> None: + """Clear every retained input value.""" + self._pressed.clear() + self._steering = 0.0 + self._text = "" + + def _consume_text_key(self, raw_key: str, key: str) -> tuple[bool, str | None]: + if key in {"enter", "return"}: + return True, self._text if self._text.strip() else None + if key == "backspace": + self._text = self._text[:-1] + return True, None + if key == "escape": + self._text = "DRIVER" + return True, self._text + if len(raw_key) == 1 and raw_key in _TEXT_CHARACTERS and len(self._text) < 12: + self._text += raw_key + return True, None + return False, None + + +def _normalize_key(key: str) -> str: + if key == " ": + return "space" + normalized = key.strip().lower() + return { + "arrowup": "up", + "arrowdown": "down", + "arrowleft": "left", + "arrowright": "right", + "spacebar": "space", + }.get(normalized, normalized) + + +def _move_towards(current: float, target: float, maximum_delta: float) -> float: + if current < target: + return min(current + maximum_delta, target) + return max(current - maximum_delta, target) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/math3d.py b/apps/omnidreams_game_engine/omnidreams_game_engine/math3d.py new file mode 100644 index 000000000..0afda454d --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/math3d.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from omnidreams_game_engine.types import VehicleState + + +def normalize_camera_name(name: str) -> tuple[str, str]: + if ":" in name: + clipgt_name = name + logical_name = name.replace(":", "_") + return clipgt_name, logical_name + + logical_name = name + clipgt_name = logical_name.replace("camera_", "camera:", 1).replace("_", ":") + return clipgt_name, logical_name + + +def euler_xyz_degrees_to_matrix( + rpy_deg: list[float] | tuple[float, float, float], +) -> npt.NDArray[np.float32]: + roll, pitch, yaw = [math.radians(v) for v in rpy_deg] + + cr, sr = math.cos(roll), math.sin(roll) + cp, sp = math.cos(pitch), math.sin(pitch) + cy, sy = math.cos(yaw), math.sin(yaw) + + rx = np.array([[1.0, 0.0, 0.0], [0.0, cr, -sr], [0.0, sr, cr]], dtype=np.float32) + ry = np.array([[cp, 0.0, sp], [0.0, 1.0, 0.0], [-sp, 0.0, cp]], dtype=np.float32) + rz = np.array([[cy, -sy, 0.0], [sy, cy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32) + return (rz @ ry @ rx).astype(np.float32) + + +def quaternion_to_matrix_xyzw( + quat: list[float] | tuple[float, float, float, float], +) -> npt.NDArray[np.float32]: + x, y, z, w = quat + xx = x * x + yy = y * y + zz = z * z + xy = x * y + xz = x * z + yz = y * z + wx = w * x + wy = w * y + wz = w * z + return np.array( + [ + [1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)], + [2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)], + [2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)], + ], + dtype=np.float32, + ) + + +def transform_from_rt( + rotation: npt.NDArray[np.float32], + translation_xyz: list[float] | tuple[float, float, float], +) -> npt.NDArray[np.float32]: + result = np.eye(4, dtype=np.float32) + result[:3, :3] = rotation + result[:3, 3] = np.asarray(translation_xyz, dtype=np.float32) + return result + + +def invert_transform(matrix: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + rotation = matrix[:3, :3] + translation = matrix[:3, 3] + inv = np.eye(4, dtype=np.float32) + inv[:3, :3] = rotation.T + inv[:3, 3] = -(rotation.T @ translation) + return inv + + +def transform_points( + matrix: npt.NDArray[np.float32], points_xyz: npt.NDArray[np.float32] +) -> npt.NDArray[np.float32]: + ones = np.ones((points_xyz.shape[0], 1), dtype=np.float32) + points_h = np.concatenate([points_xyz, ones], axis=1) + return (points_h @ matrix.T)[:, :3].astype(np.float32) + + +def extract_yaw_from_transform(matrix: npt.NDArray[np.float32]) -> float: + return float(math.atan2(matrix[1, 0], matrix[0, 0])) + + +def rig_pose_from_state( + x_m: float, + y_m: float, + z_m: float, + yaw_rad: float, + pitch_rad: float = 0.0, + roll_rad: float = 0.0, +) -> npt.NDArray[np.float32]: + cr, sr = math.cos(roll_rad), math.sin(roll_rad) + cp, sp = math.cos(pitch_rad), math.sin(pitch_rad) + cy, sy = math.cos(yaw_rad), math.sin(yaw_rad) + rotation = np.array( + [ + [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], + [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], + [-sp, cp * sr, cp * cr], + ], + dtype=np.float32, + ) + return transform_from_rt(rotation, [x_m, y_m, z_m]) + + +def rig_pose_from_vehicle_state( + state: VehicleState, +) -> npt.NDArray[np.float32]: + """Build the displayed rig pose from one authoritative vehicle state.""" + return rig_pose_from_state( + x_m=state.x_m, + y_m=state.y_m, + z_m=state.z_m, + yaw_rad=state.yaw_rad, + pitch_rad=state.pitch_rad + state.suspension_pitch_rad, + roll_rad=state.roll_rad + state.suspension_roll_rad, + ) + + +def level_rig_pose_from_vehicle_state( + state: VehicleState, +) -> npt.NDArray[np.float32]: + """Build a heading-up rig pose without chassis pitch or roll.""" + return rig_pose_from_state( + x_m=state.x_m, + y_m=state.y_m, + z_m=state.z_m, + yaw_rad=state.yaw_rad, + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/math_utils.py b/apps/omnidreams_game_engine/omnidreams_game_engine/math_utils.py new file mode 100644 index 000000000..20916124b --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/math_utils.py @@ -0,0 +1,244 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from __future__ import annotations + +import math +from collections.abc import Iterable + +import numpy as np + + +def clamp(value: float, lo: float, hi: float) -> float: + return max(lo, min(hi, value)) + + +def normalize(v: Iterable[float]) -> object: + arr = np.asarray(tuple(v), dtype=np.float32) + nrm = float(np.linalg.norm(arr)) + if nrm < 1e-8: + return arr + return arr / nrm + + +def quaternion_from_rpy_deg( + roll_deg: float, pitch_deg: float, yaw_deg: float +) -> object: + half = np.radians(np.array([roll_deg, pitch_deg, yaw_deg], dtype=np.float32)) * 0.5 + cr, cp, cy = np.cos(half) + sr, sp, sy = np.sin(half) + qx = sr * cp * cy - cr * sp * sy + qy = cr * sp * cy + sr * cp * sy + qz = cr * cp * sy - sr * sp * cy + qw = cr * cp * cy + sr * sp * sy + quat = np.array([qx, qy, qz, qw], dtype=np.float32) + return quat / np.linalg.norm(quat) + + +def quaternion_to_matrix_xyzw(quat_xyzw: Iterable[float]) -> object: + x, y, z, w = normalize(quat_xyzw) + xx = x * x + yy = y * y + zz = z * z + xy = x * y + xz = x * z + yz = y * z + wx = w * x + wy = w * y + wz = w * z + return np.array( + [ + [1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)], + [2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)], + [2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)], + ], + dtype=np.float32, + ) + + +def matrix_from_quaternion_translation( + quat_xyzw: Iterable[float], translation_xyz: Iterable[float] +) -> object: + matrix = np.eye(4, dtype=np.float32) + matrix[:3, :3] = quaternion_to_matrix_xyzw(quat_xyzw) + matrix[:3, 3] = np.asarray(tuple(translation_xyz), dtype=np.float32) + return matrix + + +def matrix_from_rpy_translation( + roll_deg: float, pitch_deg: float, yaw_deg: float, translation_xyz: Iterable[float] +) -> object: + quat = quaternion_from_rpy_deg(roll_deg, pitch_deg, yaw_deg) + return matrix_from_quaternion_translation(quat, translation_xyz) + + +def matrix_from_xy_yaw(x_m: float, y_m: float, z_m: float, yaw_rad: float) -> object: + cy = math.cos(yaw_rad) + sy = math.sin(yaw_rad) + matrix = np.eye(4, dtype=np.float32) + matrix[0, 0] = cy + matrix[0, 1] = -sy + matrix[1, 0] = sy + matrix[1, 1] = cy + matrix[0, 3] = x_m + matrix[1, 3] = y_m + matrix[2, 3] = z_m + return matrix + + +def compose(a: object, b: object) -> object: + return np.asarray(a, dtype=np.float32) @ np.asarray(b, dtype=np.float32) + + +def invert_rigid(transform: object) -> object: + t = np.asarray(transform, dtype=np.float32) + rot = t[:3, :3] + trans = t[:3, 3] + out = np.eye(4, dtype=np.float32) + out[:3, :3] = rot.T + out[:3, 3] = -(rot.T @ trans) + return out + + +def polyline_to_segments(points: object) -> object: + pts = np.asarray(points, dtype=np.float32) + if len(pts) < 2: + return np.empty((0, 2, 3), dtype=np.float32) + return np.stack([pts[:-1], pts[1:]], axis=1) + + +def sample_polyline(points: object, spacing_m: float) -> object: + pts = np.asarray(points, dtype=np.float32) + if len(pts) < 2: + return pts.copy() + diffs = pts[1:] - pts[:-1] + seg_lengths = np.linalg.norm(diffs, axis=1) + total_length = float(seg_lengths.sum()) + if total_length < 1e-6: + return pts[:1].copy() + distances = [0.0] + for seg_length in seg_lengths: + distances.append(distances[-1] + float(seg_length)) + target_distances = np.arange( + 0.0, total_length + spacing_m * 0.5, spacing_m, dtype=np.float32 + ) + sampled = [] + seg_index = 0 + for target in target_distances: + while seg_index + 1 < len(distances) and distances[seg_index + 1] < float( + target + ): + seg_index += 1 + if seg_index >= len(seg_lengths): + sampled.append(pts[-1]) + continue + start_d = distances[seg_index] + end_d = distances[seg_index + 1] + alpha = ( + 0.0 if end_d <= start_d else (float(target) - start_d) / (end_d - start_d) + ) + sampled.append(pts[seg_index] * (1.0 - alpha) + pts[seg_index + 1] * alpha) + if not np.allclose(sampled[-1], pts[-1]): + sampled.append(pts[-1]) + return np.asarray(sampled, dtype=np.float32) + + +def dash_polyline( + points: object, pattern: list[tuple[bool, float]], spacing_m: float = 0.25 +) -> list[object]: + sampled = sample_polyline(points, spacing_m=spacing_m) + if len(sampled) < 2: + return [] + segments = polyline_to_segments(sampled) + segment_lengths = np.linalg.norm(segments[:, 1] - segments[:, 0], axis=1) + pattern_length = sum(length for _, length in pattern) + if pattern_length <= 1e-6: + return [segments] + visible = [] + distance = 0.0 + for segment, seg_length in zip(segments, segment_lengths, strict=False): + phase = distance % pattern_length + accum = 0.0 + draw = False + for is_visible, length in pattern: + if accum <= phase < accum + length: + draw = is_visible + break + accum += length + if draw: + visible.append(segment) + distance += float(seg_length) + if not visible: + return [] + return [np.asarray(visible, dtype=np.float32)] + + +def offset_segments(segments: object, offset_m: float) -> object: + segs = np.asarray(segments, dtype=np.float32) + if len(segs) == 0: + return segs.copy() + offsets = [] + for p0, p1 in segs: + direction = p1 - p0 + direction_xy = direction[:2] + norm = float(np.linalg.norm(direction_xy)) + if norm < 1e-6: + offsets.append(np.zeros(3, dtype=np.float32)) + continue + tangent = direction_xy / norm + normal_xy = np.array([-tangent[1], tangent[0]], dtype=np.float32) + offsets.append( + np.array( + [normal_xy[0] * offset_m, normal_xy[1] * offset_m, 0.0], + dtype=np.float32, + ) + ) + offset_arr = np.asarray(offsets, dtype=np.float32) + return segs + offset_arr[:, None, :] + + +def triangle_fan(vertices: object) -> object: + verts = np.asarray(vertices, dtype=np.float32) + if len(verts) < 3: + return np.empty((0, 3, 3), dtype=np.float32) + tris = [] + anchor = verts[0] + for idx in range(1, len(verts) - 1): + tris.append([anchor, verts[idx], verts[idx + 1]]) + return np.asarray(tris, dtype=np.float32) + + +def oriented_box_triangles( + center: Iterable[float], dimensions: Iterable[float], quat_xyzw: Iterable[float] +) -> object: + cx, cy, cz = center + dx, dy, dz = dimensions + half = np.array([dx, dy, dz], dtype=np.float32) * 0.5 + local = np.array( + [ + [-half[0], -half[1], -half[2]], + [half[0], -half[1], -half[2]], + [half[0], half[1], -half[2]], + [-half[0], half[1], -half[2]], + [-half[0], -half[1], half[2]], + [half[0], -half[1], half[2]], + [half[0], half[1], half[2]], + [-half[0], half[1], half[2]], + ], + dtype=np.float32, + ) + rot = quaternion_to_matrix_xyzw(quat_xyzw) + world = (rot @ local.T).T + np.array([cx, cy, cz], dtype=np.float32) + faces = [ + (0, 1, 2, 3), + (4, 5, 6, 7), + (0, 1, 5, 4), + (1, 2, 6, 5), + (2, 3, 7, 6), + (3, 0, 4, 7), + ] + tris = [] + for i0, i1, i2, i3 in faces: + tris.append([world[i0], world[i1], world[i2]]) + tris.append([world[i0], world[i2], world[i3]]) + return np.asarray(tris, dtype=np.float32) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py new file mode 100644 index 000000000..c2d7bd116 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Direct OmniDreams pipeline bridge for a model-thread game rollout.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +import numpy as np +import torch +from torch import Tensor + +from omnidreams_game_engine.engine import EngineStep +from omnidreams_game_engine.types import DriverCommand, SceneDefinition + + +class VideoPostprocessor(Protocol): + """Optional session-local generated-video transform.""" + + def __call__(self, video: Tensor) -> Tensor: + ... + + +class RolloutEngine(Protocol): + """Engine operations needed by one direct model rollout.""" + + @property + def is_running(self) -> bool: ... + + @property + def current_game_frame(self) -> object: ... + + def step(self, commands: tuple[DriverCommand, ...]) -> EngineStep: ... + + def submit_text(self, value: str) -> object: ... + + def close(self) -> None: ... + + +EngineFactory = Callable[[], RolloutEngine] + + +@dataclass(frozen=True, slots=True) +class WorldModelStep: + """Generated video plus the engine data that produced it.""" + + video_bvtchw: Tensor + engine: EngineStep + metrics: Mapping[str, float | int] + + +class WorldModelRollout: + """Own one session's game engine and autoregressive model cache.""" + + def __init__( + self, + *, + pipeline: Any, + scene: SceneDefinition, + engine_factory: EngineFactory, + postprocess: VideoPostprocessor | None = None, + ) -> None: + self.pipeline = pipeline + self.scene = scene + self._engine_factory = engine_factory + self._postprocess = postprocess + self.engine = engine_factory() + self.cache = self._new_cache() + self._closed = False + + @property + def is_running(self) -> bool: + return not self._closed and self.engine.is_running + + def frame_count(self, autoregressive_index: int) -> int: + """Return the pipeline's authoritative output count for one step.""" + count = int(self.pipeline.get_num_frames(autoregressive_index)) + if count <= 0: + raise ValueError("The pipeline returned a non-positive frame count") + return count + + def step( + self, + *, + autoregressive_index: int, + commands: tuple[DriverCommand, ...], + ) -> WorldModelStep: + """Simulate, condition, generate, and finalize one block directly.""" + if self._closed: + raise RuntimeError("WorldModelRollout is closed") + expected = self.frame_count(autoregressive_index) + if len(commands) != expected: + raise ValueError(f"Expected {expected} commands, got {len(commands)}") + engine_step = self.engine.step(commands) + with torch.no_grad(): + video = self.pipeline.generate( + autoregressive_index=autoregressive_index, + cache=self.cache, + hdmap=engine_step.condition.hdmap_bvtchw, + ) + metrics = self.pipeline.finalize( + autoregressive_index=autoregressive_index, + cache=self.cache, + ) + if self._postprocess is not None: + video = self._postprocess(video) + if video.ndim != 6 or tuple(video.shape[:2]) != (1, 1): + raise ValueError( + "The game requires single-batch, single-view BVTCHW video; got " + f"{tuple(video.shape)}" + ) + if int(video.shape[2]) != expected: + raise ValueError("Generated video does not align with the engine step") + return WorldModelStep( + video_bvtchw=video.detach(), + engine=engine_step, + metrics=dict(metrics or {}), + ) + + def reset(self) -> None: + """Recreate all mutable rollout state while retaining model weights.""" + if self._closed: + raise RuntimeError("WorldModelRollout is closed") + self.engine.close() + self.engine = self._engine_factory() + self.cache = self._new_cache() + + def close(self) -> None: + """Release all session-local resources.""" + if self._closed: + return + self._closed = True + self.cache = None + self.engine.close() + + def _new_cache(self) -> Any: + return self.pipeline.initialize_cache( + text=[[self.scene.prompt]], + image=_initial_image_tensor( + self.scene.initial_rgb, + device=self.pipeline.device, + ), + view_names=[self.scene.selected_camera.logical_name], + ) + + +def _initial_image_tensor(image: object, *, device: torch.device | str) -> Tensor: + array = np.ascontiguousarray(np.asarray(image, dtype=np.uint8)[..., :3]) + tensor = torch.from_numpy(array).permute(2, 0, 1) + tensor = tensor.unsqueeze(0).unsqueeze(0).unsqueeze(2) + return tensor.to(device=device, dtype=torch.bfloat16) / 127.5 - 1.0 diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/patterns.py b/apps/omnidreams_game_engine/omnidreams_game_engine/patterns.py new file mode 100644 index 000000000..366f02086 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/patterns.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from __future__ import annotations + +from collections.abc import Iterable + +import numpy as np +import numpy.typing as npt + + +def subdivide_polyline( + points_xyz: npt.NDArray[np.float32], interval_m: float +) -> npt.NDArray[np.float32]: + if len(points_xyz) <= 1: + return points_xyz.astype(np.float32) + + samples: list[npt.NDArray[np.float32]] = [points_xyz[0].astype(np.float32)] + for p0, p1 in zip(points_xyz[:-1], points_xyz[1:], strict=False): + direction = p1 - p0 + length = float(np.linalg.norm(direction)) + if length < 1e-6: + continue + steps = max(1, int(np.ceil(length / interval_m))) + for i in range(1, steps + 1): + t = i / steps + samples.append((p0 + direction * t).astype(np.float32)) + return np.stack(samples, axis=0).astype(np.float32) + + +def segments_from_polyline( + points_xyz: npt.NDArray[np.float32], +) -> npt.NDArray[np.float32]: + if len(points_xyz) < 2: + return np.empty((0, 2, 3), dtype=np.float32) + return np.stack([points_xyz[:-1], points_xyz[1:]], axis=1).astype(np.float32) + + +def resample_polyline( + points_xyz: npt.NDArray[np.float32], interval_m: float +) -> npt.NDArray[np.float32]: + if len(points_xyz) <= 1: + return points_xyz.astype(np.float32) + if interval_m <= 1e-6: + return points_xyz.astype(np.float32) + + deltas = points_xyz[1:] - points_xyz[:-1] + lengths = np.linalg.norm(deltas, axis=1).astype(np.float32) + cumulative = np.concatenate( + [np.zeros((1,), dtype=np.float32), np.cumsum(lengths, dtype=np.float32)] + ) + total_length = float(cumulative[-1]) + if total_length <= interval_m: + return np.stack([points_xyz[0], points_xyz[-1]], axis=0).astype(np.float32) + + sample_distances = np.arange(0.0, total_length, interval_m, dtype=np.float32) + if total_length - float(sample_distances[-1]) > 1e-4: + sample_distances = np.concatenate( + [sample_distances, np.array([total_length], dtype=np.float32)] + ) + + sampled: list[npt.NDArray[np.float32]] = [] + segment_index = 0 + for distance in sample_distances: + while ( + segment_index < len(lengths) - 1 + and distance > cumulative[segment_index + 1] + ): + segment_index += 1 + + segment_length = float(lengths[segment_index]) + if segment_length <= 1e-6: + sampled.append(points_xyz[segment_index].astype(np.float32)) + continue + + local_t = float((distance - cumulative[segment_index]) / segment_length) + local_t = float(np.clip(local_t, 0.0, 1.0)) + point = ( + points_xyz[segment_index] * (1.0 - local_t) + + points_xyz[segment_index + 1] * local_t + ) + sampled.append(point.astype(np.float32)) + + if np.linalg.norm(sampled[-1] - points_xyz[-1]) > 1e-4: + sampled.append(points_xyz[-1].astype(np.float32)) + return np.stack(sampled, axis=0).astype(np.float32) + + +def _segment_lengths( + line_segments: npt.NDArray[np.float32], +) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32], float]: + lengths = np.linalg.norm(line_segments[:, 1] - line_segments[:, 0], axis=1).astype( + np.float32 + ) + cumulative = np.concatenate( + [np.zeros((1,), dtype=np.float32), np.cumsum(lengths, dtype=np.float32)] + ) + return lengths, cumulative, float(cumulative[-1]) + + +def _clip_pattern( + line_segments: npt.NDArray[np.float32], on_length_m: float, off_length_m: float +) -> npt.NDArray[np.float32]: + if len(line_segments) == 0: + return line_segments + + lengths, cumulative, total_length = _segment_lengths(line_segments) + result: list[npt.NDArray[np.float32]] = [] + start = 0.0 + while start < total_length: + end = min(start + on_length_m, total_length) + for index, segment in enumerate(line_segments): + seg_start = float(cumulative[index]) + seg_end = float(cumulative[index + 1]) + if seg_end <= start or seg_start >= end or lengths[index] <= 1e-6: + continue + + clip_start = max(start, seg_start) + clip_end = min(end, seg_end) + t0 = (clip_start - seg_start) / float(lengths[index]) + t1 = (clip_end - seg_start) / float(lengths[index]) + p0 = segment[0] + (segment[1] - segment[0]) * t0 + p1 = segment[0] + (segment[1] - segment[0]) * t1 + result.append(np.stack([p0, p1], axis=0).astype(np.float32)) + start += on_length_m + off_length_m + + if not result: + return np.empty((0, 2, 3), dtype=np.float32) + return np.stack(result, axis=0).astype(np.float32) + + +def offset_segments( + line_segments: npt.NDArray[np.float32], + offset_distance_m: float, +) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: + if len(line_segments) == 0: + empty = np.empty((0, 2, 3), dtype=np.float32) + return empty, empty + + left: list[npt.NDArray[np.float32]] = [] + right: list[npt.NDArray[np.float32]] = [] + for segment in line_segments: + direction = segment[1] - segment[0] + direction_xy = direction[:2] + norm = float(np.linalg.norm(direction_xy)) + if norm < 1e-6: + continue + tangent_xy = direction_xy / norm + normal = np.array([-tangent_xy[1], tangent_xy[0], 0.0], dtype=np.float32) + offset = normal * offset_distance_m + left.append((segment + offset).astype(np.float32)) + right.append((segment - offset).astype(np.float32)) + + if not left: + empty = np.empty((0, 2, 3), dtype=np.float32) + return empty, empty + return np.stack(left, axis=0).astype(np.float32), np.stack(right, axis=0).astype( + np.float32 + ) + + +def apply_pattern( + line_segments: npt.NDArray[np.float32], + pattern: str, + dual_pattern: tuple[str, str] | None = None, + dual_offset_m: float = 0.10, +) -> list[npt.NDArray[np.float32]]: + if pattern == "solid": + return [line_segments] + if pattern == "long_dashed": + return [_clip_pattern(line_segments, on_length_m=3.0, off_length_m=9.0)] + if pattern == "short_dashed": + return [_clip_pattern(line_segments, on_length_m=1.5, off_length_m=1.5)] + if pattern == "dot_dashed": + base = _clip_pattern(line_segments, on_length_m=0.91, off_length_m=2.74) + return [base[::3] if len(base) > 0 else base] + if pattern == "dotted_1_9": + return [line_segments[::10]] + if pattern == "dual": + left, right = offset_segments(line_segments, dual_offset_m) + left_pattern, right_pattern = dual_pattern or ("solid", "solid") + result: list[npt.NDArray[np.float32]] = [] + result.extend(apply_pattern(left, left_pattern, dual_offset_m=dual_offset_m)) + result.extend(apply_pattern(right, right_pattern, dual_offset_m=dual_offset_m)) + return result + return [line_segments] + + +def triangulate_polygon_fan( + points_xyz: npt.NDArray[np.float32], +) -> npt.NDArray[np.float32]: + unique_points: list[npt.NDArray[np.float32]] = [] + for point in points_xyz: + if unique_points and np.linalg.norm(unique_points[-1] - point) < 1e-4: + continue + unique_points.append(point.astype(np.float32)) + if ( + len(unique_points) >= 2 + and np.linalg.norm(unique_points[0] - unique_points[-1]) < 1e-4 + ): + unique_points = unique_points[:-1] + if len(unique_points) < 3: + return np.empty((0, 3, 3), dtype=np.float32) + + base = unique_points[0] + triangles = [ + np.stack([base, unique_points[i], unique_points[i + 1]], axis=0).astype( + np.float32 + ) + for i in range(1, len(unique_points) - 1) + ] + return np.stack(triangles, axis=0).astype(np.float32) + + +def triangulate_polygon_xy( + points_xyz: npt.NDArray[np.float32], +) -> npt.NDArray[np.float32]: + points = np.asarray(points_xyz, dtype=np.float32) + unique_points: list[npt.NDArray[np.float32]] = [] + for point in points: + if unique_points and np.linalg.norm(unique_points[-1] - point) < 1e-4: + continue + unique_points.append(point.astype(np.float32)) + if ( + len(unique_points) >= 2 + and np.linalg.norm(unique_points[0] - unique_points[-1]) < 1e-4 + ): + unique_points = unique_points[:-1] + if len(unique_points) < 3: + return np.empty((0, 3, 3), dtype=np.float32) + + polygon = np.stack(unique_points, axis=0).astype(np.float32) + polygon_xy = polygon[:, :2] + + signed_area = 0.5 * float( + np.sum( + polygon_xy[:, 0] * np.roll(polygon_xy[:, 1], -1) + - np.roll(polygon_xy[:, 0], -1) * polygon_xy[:, 1] + ) + ) + if abs(signed_area) < 1e-6: + return triangulate_polygon_fan(polygon) + if signed_area < 0.0: + polygon = polygon[::-1].copy() + polygon_xy = polygon_xy[::-1].copy() + + def cross2d( + a: npt.NDArray[np.float32], + b: npt.NDArray[np.float32], + c: npt.NDArray[np.float32], + ) -> float: + ab = b - a + ac = c - a + return float(ab[0] * ac[1] - ab[1] * ac[0]) + + def point_in_triangle( + point: npt.NDArray[np.float32], + a: npt.NDArray[np.float32], + b: npt.NDArray[np.float32], + c: npt.NDArray[np.float32], + ) -> bool: + ab = cross2d(a, b, point) + bc = cross2d(b, c, point) + ca = cross2d(c, a, point) + eps = 1e-6 + return ab >= -eps and bc >= -eps and ca >= -eps + + indices = list(range(len(polygon))) + triangles: list[npt.NDArray[np.float32]] = [] + safety_counter = 0 + max_iterations = len(indices) * len(indices) + while len(indices) > 3 and safety_counter < max_iterations: + ear_found = False + for offset, current_idx in enumerate(indices): + prev_idx = indices[(offset - 1) % len(indices)] + next_idx = indices[(offset + 1) % len(indices)] + a = polygon_xy[prev_idx] + b = polygon_xy[current_idx] + c = polygon_xy[next_idx] + if cross2d(a, b, c) <= 1e-6: + continue + + contains_other = False + for test_idx in indices: + if test_idx in (prev_idx, current_idx, next_idx): + continue + if point_in_triangle(polygon_xy[test_idx], a, b, c): + contains_other = True + break + if contains_other: + continue + + triangles.append( + np.stack( + [polygon[prev_idx], polygon[current_idx], polygon[next_idx]], axis=0 + ).astype(np.float32) + ) + del indices[offset] + ear_found = True + break + if not ear_found: + return triangulate_polygon_fan(polygon) + safety_counter += 1 + + if len(indices) == 3: + triangles.append( + np.stack( + [polygon[indices[0]], polygon[indices[1]], polygon[indices[2]]], axis=0 + ).astype(np.float32) + ) + + if not triangles: + return np.empty((0, 3, 3), dtype=np.float32) + return np.stack(triangles, axis=0).astype(np.float32) + + +def split_segment_runs( + line_segments: npt.NDArray[np.float32], atol: float = 1e-4 +) -> list[npt.NDArray[np.float32]]: + if len(line_segments) == 0: + return [] + + runs: list[list[npt.NDArray[np.float32]]] = [ + [line_segments[0, 0].astype(np.float32), line_segments[0, 1].astype(np.float32)] + ] + for segment in line_segments[1:]: + current_run = runs[-1] + if np.linalg.norm(current_run[-1] - segment[0]) <= atol: + current_run.append(segment[1].astype(np.float32)) + else: + runs.append([segment[0].astype(np.float32), segment[1].astype(np.float32)]) + return [np.stack(run, axis=0).astype(np.float32) for run in runs if len(run) >= 2] + + +def concatenate_segments( + segment_groups: Iterable[npt.NDArray[np.float32]], +) -> npt.NDArray[np.float32]: + non_empty = [group.astype(np.float32) for group in segment_groups if len(group) > 0] + if not non_empty: + return np.empty((0, 2, 3), dtype=np.float32) + return np.concatenate(non_empty, axis=0).astype(np.float32) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/ply_io.py b/apps/omnidreams_game_engine/omnidreams_game_engine/ply_io.py new file mode 100644 index 000000000..6af4c5a1b --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/ply_io.py @@ -0,0 +1,312 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Numpy-only PLY I/O for triangle meshes (vertices + faces). + +Supports ASCII, binary little-endian, and binary big-endian PLY files. +Roaddreams uses this to read ``mesh_ground.ply`` out of scene USDZ archives +for the ground-snap step (see +:mod:`omnidreams_game_engine.simulation.ground_snap`) and to emit a flat +synthetic ground mesh from :mod:`omnidreams_game_engine.scene_fixture`. + +Ported (numpy-only, no Warp) from ``alpasim_physics.ply_io``. +""" + +from __future__ import annotations + +import io +import struct +from typing import Literal + +import numpy as np + +_PLY_TYPES: dict[str, tuple[str, type[np.generic]]] = { + "char": ("b", np.int8), + "uchar": ("B", np.uint8), + "short": ("h", np.int16), + "ushort": ("H", np.uint16), + "int": ("i", np.int32), + "uint": ("I", np.uint32), + "float": ("f", np.float32), + "double": ("d", np.float64), + "int8": ("b", np.int8), + "uint8": ("B", np.uint8), + "int16": ("h", np.int16), + "uint16": ("H", np.uint16), + "int32": ("i", np.int32), + "uint32": ("I", np.uint32), + "float32": ("f", np.float32), + "float64": ("d", np.float64), +} + +_Property = tuple +_Element = dict +_Format = Literal["ascii", "binary_little_endian", "binary_big_endian"] +_Endian = Literal["<", ">"] + + +def _parse_header(stream: io.BytesIO) -> tuple[_Format, list[_Element]]: + magic = stream.readline().strip() + if magic != b"ply": + raise ValueError(f"Not a PLY file (got {magic!r})") + + fmt: _Format | None = None + elements: list[_Element] = [] + current_element: _Element | None = None + + while True: + line = stream.readline() + if not line: + raise ValueError("Unexpected end of file while reading PLY header") + + tokens = line.strip().decode("ascii").split() + if not tokens or tokens[0] in {"comment", "obj_info"}: + continue + + if tokens[0] == "end_header": + break + elif tokens[0] == "format": + valid = {"ascii", "binary_little_endian", "binary_big_endian"} + if tokens[1] not in valid: + raise ValueError( + f"Unsupported PLY format: {tokens[1]!r} (expected one of {sorted(valid)})" + ) + fmt = tokens[1] # type: ignore[assignment] + elif tokens[0] == "element": + if current_element is not None: + elements.append(current_element) + current_element = { + "name": tokens[1], + "count": int(tokens[2]), + "properties": [], + } + elif tokens[0] == "property": + if current_element is None: + raise ValueError("Unexpected 'property' line in header") + if tokens[1] == "list": + current_element["properties"].append( + ("list", tokens[2], tokens[3], tokens[4]) + ) + else: + current_element["properties"].append(("scalar", tokens[1], tokens[2])) + + if current_element is not None: + elements.append(current_element) + if fmt is None: + raise ValueError("PLY header missing 'format' line") + + return fmt, elements + + +def _find_elements(elements: list[_Element]) -> tuple[_Element, _Element]: + vertex_elem: _Element | None = None + face_elem: _Element | None = None + for elem in elements: + if elem["name"] == "vertex": + vertex_elem = elem + elif elem["name"] == "face": + face_elem = elem + if vertex_elem is None: + raise ValueError("PLY file has no 'vertex' element") + if face_elem is None: + raise ValueError("PLY file has no 'face' element") + return vertex_elem, face_elem + + +def _read_binary_vertices( + stream: io.BytesIO, elem: _Element, endian: _Endian +) -> np.ndarray: + dt_fields: list[tuple[str, np.dtype]] = [] + for prop in elem["properties"]: + if prop[0] != "scalar": + raise ValueError("List properties in vertex element are not supported") + _, type_name, prop_name = prop + dt = np.dtype(_PLY_TYPES[type_name][1]).newbyteorder(endian) + dt_fields.append((prop_name, dt)) + + vertex_dtype = np.dtype(dt_fields) + raw = stream.read(elem["count"] * vertex_dtype.itemsize) + data = np.frombuffer(raw, dtype=vertex_dtype) + + return np.column_stack( + [ + data["x"].astype(np.float32), + data["y"].astype(np.float32), + data["z"].astype(np.float32), + ] + ) + + +def _read_binary_faces( + stream: io.BytesIO, elem: _Element, endian: _Endian +) -> np.ndarray: + face_prop = next((p for p in elem["properties"] if p[0] == "list"), None) + if face_prop is None: + raise ValueError("Face element has no list property") + + _, count_type, index_type, _prop_name = face_prop + count_dt = np.dtype(_PLY_TYPES[count_type][1]).newbyteorder(endian) + index_dt = np.dtype(_PLY_TYPES[index_type][1]).newbyteorder(endian) + + face_dtype = np.dtype( + [("count", count_dt), ("i0", index_dt), ("i1", index_dt), ("i2", index_dt)] + ) + raw = stream.read(elem["count"] * face_dtype.itemsize) + face_data = np.frombuffer(raw, dtype=face_dtype) + + if not np.all(face_data["count"] == 3): + raise ValueError( + "Only triangular faces are supported (every face must have exactly 3 vertices)" + ) + + return np.column_stack( + [ + face_data["i0"].astype(np.int32), + face_data["i1"].astype(np.int32), + face_data["i2"].astype(np.int32), + ] + ) + + +def _skip_binary_element(stream: io.BytesIO, elem: _Element, endian: _Endian) -> None: + all_scalar = all(p[0] == "scalar" for p in elem["properties"]) + if all_scalar: + stride = sum(np.dtype(_PLY_TYPES[p[1]][1]).itemsize for p in elem["properties"]) + stream.read(elem["count"] * stride) + else: + for _ in range(elem["count"]): + for prop in elem["properties"]: + if prop[0] == "scalar": + stream.read(np.dtype(_PLY_TYPES[prop[1]][1]).itemsize) + else: + count_size = np.dtype(_PLY_TYPES[prop[1]][1]).itemsize + count_fmt = endian + _PLY_TYPES[prop[1]][0] + n = struct.unpack(count_fmt, stream.read(count_size))[0] + stream.read(n * np.dtype(_PLY_TYPES[prop[2]][1]).itemsize) + + +def _load_binary( + stream: io.BytesIO, + elements: list[_Element], + vertex_elem: _Element, + face_elem: _Element, + endian: _Endian, +) -> tuple[np.ndarray, np.ndarray]: + vertices: np.ndarray | None = None + faces: np.ndarray | None = None + + for elem in elements: + if elem is vertex_elem: + vertices = _read_binary_vertices(stream, elem, endian) + elif elem is face_elem: + faces = _read_binary_faces(stream, elem, endian) + else: + _skip_binary_element(stream, elem, endian) + + assert vertices is not None and faces is not None + return vertices, faces + + +def _load_ascii( + stream: io.BytesIO, + elements: list[_Element], + vertex_elem: _Element, + face_elem: _Element, +) -> tuple[np.ndarray, np.ndarray]: + vertices: np.ndarray | None = None + faces: np.ndarray | None = None + + for elem in elements: + if elem is vertex_elem: + prop_names = [p[2] for p in elem["properties"] if p[0] == "scalar"] + x_idx = prop_names.index("x") + y_idx = prop_names.index("y") + z_idx = prop_names.index("z") + + vertices = np.empty((elem["count"], 3), dtype=np.float32) + for i in range(elem["count"]): + vals = stream.readline().decode("ascii").split() + vertices[i] = [ + float(vals[x_idx]), + float(vals[y_idx]), + float(vals[z_idx]), + ] + + elif elem is face_elem: + faces = np.empty((elem["count"], 3), dtype=np.int32) + for i in range(elem["count"]): + vals = stream.readline().decode("ascii").split() + n = int(vals[0]) + if n != 3: + raise ValueError( + f"Only triangular faces are supported, got face with {n} vertices" + ) + faces[i] = [int(vals[1]), int(vals[2]), int(vals[3])] + else: + for _ in range(elem["count"]): + stream.readline() + + assert vertices is not None and faces is not None + return vertices, faces + + +def load_mesh_vf(data: bytes) -> tuple[np.ndarray, np.ndarray]: + """Load a PLY triangle mesh from raw bytes. + + Args: + data: Complete PLY file contents. + + Returns: + ``(vertices, faces)`` where *vertices* is ``(N, 3)`` float32 and + *faces* is ``(M, 3)`` int32 of triangle vertex indices. + """ + stream = io.BytesIO(data) + fmt, elements = _parse_header(stream) + vertex_elem, face_elem = _find_elements(elements) + + if fmt == "ascii": + return _load_ascii(stream, elements, vertex_elem, face_elem) + elif fmt == "binary_little_endian": + return _load_binary(stream, elements, vertex_elem, face_elem, "<") + elif fmt == "binary_big_endian": + return _load_binary(stream, elements, vertex_elem, face_elem, ">") + else: + raise TypeError(f"Unsupported PLY format: {fmt!r}") + + +def save_mesh_vf(vertices: np.ndarray, faces: np.ndarray) -> bytes: + """Save a triangle mesh as binary little-endian PLY bytes.""" + if vertices.ndim != 2 or vertices.shape[1] != 3: + raise ValueError(f"vertices must have shape (N, 3), got {vertices.shape}") + if faces.ndim != 2 or faces.shape[1] != 3: + raise ValueError( + f"faces must have shape (M, 3) for triangular meshes, got {faces.shape}" + ) + + n_vertices, n_faces = vertices.shape[0], faces.shape[0] + + header = ( + "ply\n" + "format binary_little_endian 1.0\n" + f"element vertex {n_vertices}\n" + "property float x\n" + "property float y\n" + "property float z\n" + f"element face {n_faces}\n" + "property list uchar int vertex_indices\n" + "end_header\n" + ) + + buf = io.BytesIO() + buf.write(header.encode("ascii")) + buf.write(vertices.astype(" RendererSettings: + """Load a complete renderer YAML document. + + Args: + path: Renderer YAML path. + + Returns: + Validated visual settings. + """ + doc = load_yaml_mapping(path) + require_exact_keys( + doc, {"schema_version", "raster", "bev"}, "renderer" + ) + require_version(doc, "renderer") + raw_raster = require_mapping(doc["raster"], "renderer.raster") + require_exact_keys( + raw_raster, _RASTER_FLOAT_FIELDS | _RASTER_INT_FIELDS, "renderer.raster" + ) + raster_values = { + name: require_float(raw_raster[name], f"renderer.raster.{name}", minimum=0.0) + for name in _RASTER_FLOAT_FIELDS + } + raster_values.update( + { + name: require_int(raw_raster[name], f"renderer.raster.{name}") + for name in _RASTER_INT_FIELDS + } + ) + if raster_values["near_plane_m"] >= raster_values["far_plane_m"]: + raise StrictConfigError( + "renderer.raster.near_plane_m must be less than far_plane_m" + ) + if raster_values["fog_start_m"] >= raster_values["fog_end_m"]: + raise StrictConfigError( + "renderer.raster.fog_start_m must be less than fog_end_m" + ) + + raw_bev = require_mapping(doc["bev"], "renderer.bev") + require_exact_keys( + raw_bev, {"enabled"} | _BEV_FLOAT_FIELDS | _BEV_INT_FIELDS, "renderer.bev" + ) + bev_values = { + name: require_float(raw_bev[name], f"renderer.bev.{name}", minimum=0.0) + for name in _BEV_FLOAT_FIELDS + } + bev_values.update( + { + name: require_int(raw_bev[name], f"renderer.bev.{name}") + for name in _BEV_INT_FIELDS + } + ) + bev_values["enabled"] = require_bool(raw_bev["enabled"], "renderer.bev.enabled") + if not 0.0 < bev_values["fov_deg"] < 180.0: + raise StrictConfigError("renderer.bev.fov_deg must be between 0 and 180") + return RendererSettings( + raster=RasterConfig(**cast(Any, raster_values)), + bev=BevConfig(**cast(Any, bev_values)), + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/scene.py b/apps/omnidreams_game_engine/omnidreams_game_engine/scene.py new file mode 100644 index 000000000..10faab839 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Authored-map compilation and immutable scene preparation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from omnidreams_game_engine.config import RasterConfig +from omnidreams_game_engine.game_map import compile_game_map +from omnidreams_game_engine.scene_loader import load_scene_bundle +from omnidreams_game_engine.types import SceneDefinition + + +@dataclass(frozen=True, slots=True) +class SceneRequest: + """Inputs selecting one immutable game scene.""" + + map_path: Path + camera_name: str = "camera_front_wide_120fov" + variant: str = "default" + prompt: str | None = None + force_recompile: bool = False + + +def load_scene(request: SceneRequest, raster: RasterConfig) -> SceneDefinition: + """Compile an authored map if necessary and load its runtime scene.""" + compiled = compile_game_map(request.map_path, force=request.force_recompile) + return load_scene_bundle( + scene_path=compiled.archive_path, + camera_name=request.camera_name, + variant=request.variant, + prompt_override=request.prompt, + raster=raster, + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_fixture.py b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_fixture.py new file mode 100644 index 000000000..684e8ec0e --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_fixture.py @@ -0,0 +1,766 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from __future__ import annotations + +import io +import json +import math +import zipfile +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +import yaml +from PIL import Image + +from omnidreams_game_engine.camera_defaults import ( + DEFAULT_FRONT_CAMERA_LOGICAL_NAME, + default_front_camera_rig, +) +from omnidreams_game_engine.math3d import rig_pose_from_state +from omnidreams_game_engine.ply_io import save_mesh_vf + +_SCENE_ID = "synthetic-test-scene" +_FPS = 30 +# Default trajectory length: 180 frames (6 s @ 30 fps, ~60 m) is enough for the +# scene-loader tests; the runtime helper passes a larger ``length_frames``. +_DEFAULT_TRAJECTORY_FRAMES = 180 +_START_TIMESTAMP_US = 1_700_000_000_000_000 +# Centerline = two superposed sines (long highway sweep + short drift) so the +# road curves visibly (~600 m min radius) without feeling repetitive. +_WAVE_LONG_AMPLITUDE_M = 7.0 +_WAVE_LONG_PERIOD_S = 120.0 +_WAVE_SHORT_AMPLITUDE_M = 2.0 +_WAVE_SHORT_PERIOD_S = 20.0 +_FORWARD_SPEED_MPS = 10.0 +# Lateral half-widths (m from centerline): lane lines at 1.8 m = a 3.6 m US +# lane; road boundaries at 9 m = an 18 m 2-lane carriageway plus shoulders. +_LANE_LINE_OFFSET_M = 1.8 +_ROAD_BOUNDARY_OFFSET_M = 9.0 +_POLE_OFFSET_M = 9.5 +# Periodic roadside furniture spacing, anchored to the wavy centerline. +_POLE_PERIOD_M = 50.0 +_TRAFFIC_SIGN_PERIOD_M = 200.0 +_TRAFFIC_SIGN_LATERAL_M = 7.0 +_TRAFFIC_SIGN_HEIGHT_M = 2.5 +# Off-road clutter ("trees / poles") in a wide lateral band so the HDMap stays +# non-empty when the ego leaves the road (a black conditioning frame makes the +# world model drift). Dense (one pair / 15 m), seeded for reproducibility, with +# independent per-side offsets so it isn't a parallel two-line corridor. +_OFF_ROAD_POLE_PERIOD_M = 15.0 +_OFF_ROAD_POLE_LATERAL_MIN_M = 15.0 +_OFF_ROAD_POLE_LATERAL_MAX_M = 100.0 +_OFF_ROAD_POLE_HEIGHT_MIN_M = 3.0 +_OFF_ROAD_POLE_HEIGHT_MAX_M = 8.0 +_OFF_ROAD_POLE_FORWARD_JITTER_M = 4.0 +_OFF_ROAD_POLE_RNG_SEED = 42 +# Split long lane-line / boundary polylines into ~80 m chunks so the rasterizer's +# whole-run coarsening doesn't fade distant segments (and to dodge per-polyline caps). +_LANE_LINE_CHUNK_FRAMES = 240 +_IMAGE_WIDTH = 1280 +_IMAGE_HEIGHT = 704 + + +def _point_xyz(x_m: float, y_m: float, z_m: float) -> dict[str, float]: + return {"x": float(x_m), "y": float(y_m), "z": float(z_m)} + + +def _orientation_from_yaw(yaw_rad: float) -> dict[str, float]: + half = 0.5 * yaw_rad + return {"x": 0.0, "y": 0.0, "z": float(math.sin(half)), "w": float(math.cos(half))} + + +def _trajectory_arrays( + num_frames: int = _DEFAULT_TRAJECTORY_FRAMES, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + dt_s = 1.0 / float(_FPS) + frame_ids = np.arange(int(num_frames), dtype=np.float32) + t_s = frame_ids * np.float32(dt_s) + + x_m = _FORWARD_SPEED_MPS * t_s + # Sum of two sines at different periods reads as a more naturally + # varying road than a single sine at a fixed period. + long_omega = 2.0 * np.pi / _WAVE_LONG_PERIOD_S + short_omega = 2.0 * np.pi / _WAVE_SHORT_PERIOD_S + long_phase = long_omega * t_s + short_phase = short_omega * t_s + y_m = _WAVE_LONG_AMPLITUDE_M * np.sin( + long_phase + ) + _WAVE_SHORT_AMPLITUDE_M * np.sin(short_phase) + dydt = _WAVE_LONG_AMPLITUDE_M * long_omega * np.cos( + long_phase + ) + _WAVE_SHORT_AMPLITUDE_M * short_omega * np.cos(short_phase) + yaw_rad = np.arctan2(dydt, _FORWARD_SPEED_MPS).astype(np.float32) + timestamps_us = _START_TIMESTAMP_US + np.round(t_s * 1_000_000.0).astype(np.int64) + return x_m.astype(np.float32), y_m.astype(np.float32), yaw_rad, timestamps_us + + +def _resample_centerline( + x_m: np.ndarray, + y_m: np.ndarray, + yaw_rad: np.ndarray, + stride: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + return x_m[::stride], y_m[::stride], yaw_rad[::stride] + + +def _offset_polyline( + x_m: np.ndarray, + y_m: np.ndarray, + yaw_rad: np.ndarray, + lateral_offset_m: float, +) -> list[dict[str, float]]: + nx = -np.sin(yaw_rad) * lateral_offset_m + ny = np.cos(yaw_rad) * lateral_offset_m + return [ + _point_xyz(px + ox, py + oy, 0.0) + for px, py, ox, oy in zip(x_m, y_m, nx, ny, strict=True) + ] + + +def _chunk_polyline( + points: list[dict[str, float]], + *, + chunk_size: int, +) -> list[list[dict[str, float]]]: + """Split a long polyline into overlapping ~``chunk_size``-point chunks. + + Chunks share endpoints so the rasterizer's per-row coarsening stays + continuous; splitting also avoids the distance-fade on very long strips. + """ + if chunk_size < 2: + raise ValueError(f"chunk_size must be >= 2, got {chunk_size}") + if len(points) <= chunk_size: + return [points] + chunks: list[list[dict[str, float]]] = [] + start = 0 + while start < len(points) - 1: + end = min(start + chunk_size, len(points)) + chunks.append(points[start:end]) + if end == len(points): + break + start = end - 1 # share endpoint with the next chunk + return chunks + + +def _make_key_record(label_class_id: str) -> dict[str, str]: + return { + "clip_id": _SCENE_ID, + "label_class_id": label_class_id, + "map_id": "synthetic-map", + "map_id_version": "v1", + } + + +def _lane_line_rows( + x_m: np.ndarray, y_m: np.ndarray, yaw_rad: np.ndarray +) -> list[dict[str, object]]: + center_x, center_y, center_yaw = _resample_centerline(x_m, y_m, yaw_rad, stride=3) + left_polyline = _offset_polyline( + center_x, center_y, center_yaw, lateral_offset_m=_LANE_LINE_OFFSET_M + ) + right_polyline = _offset_polyline( + center_x, center_y, center_yaw, lateral_offset_m=-_LANE_LINE_OFFSET_M + ) + chunk_size = max(2, _LANE_LINE_CHUNK_FRAMES // 3) # / 3 for the stride=3 resample + rows: list[dict[str, object]] = [] + for side, points, style, color in ( + ("left", left_polyline, "SOLID_SINGLE", "WHITE"), + ("right", right_polyline, "DASHED_SOLID", "YELLOW"), + ): + for idx, chunk in enumerate(_chunk_polyline(points, chunk_size=chunk_size)): + rows.append( + { + "key": _make_key_record(f"lane_line_{side}_{idx}"), + "lane_line": { + "line_rail": chunk, + "styles": [style], + "colors": [color], + "left_driving_direction": ["FORWARD"], + "right_driving_direction": ["FORWARD"], + "is_first_point_physical_end": "true", + "is_last_point_physical_end": "true", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ) + return rows + + +def _polyline_rows( + key_name: str, + payload_name: str, + points_name: str, + point_sets: list[list[dict[str, float]]], +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for idx, points in enumerate(point_sets): + rows.append( + { + "key": _make_key_record(f"{key_name}_{idx}"), + payload_name: { + points_name: points, + "category": key_name, + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ) + return rows + + +def _polygon_rows( + key_name: str, + payload_name: str, + points_name: str, + polygons: list[list[dict[str, float]]], +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for idx, points in enumerate(polygons): + rows.append( + { + "key": _make_key_record(f"{key_name}_{idx}"), + payload_name: { + "category": key_name, + points_name: points, + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ) + return rows + + +def _rectangle_polygon( + center_x_m: float, + center_y_m: float, + half_width_m: float, + half_height_m: float, +) -> list[dict[str, float]]: + return [ + _point_xyz(center_x_m - half_width_m, center_y_m - half_height_m, 0.0), + _point_xyz(center_x_m + half_width_m, center_y_m - half_height_m, 0.0), + _point_xyz(center_x_m + half_width_m, center_y_m + half_height_m, 0.0), + _point_xyz(center_x_m - half_width_m, center_y_m + half_height_m, 0.0), + _point_xyz(center_x_m - half_width_m, center_y_m - half_height_m, 0.0), + ] + + +def _traffic_sign_rows() -> list[dict[str, object]]: + # Dimensions use local (length, width, height). `_build_cuboid_plate_faces` + # in the loader picks the thinnest local axis as the plate normal, so a thin + # length keeps the plate's normal along world +x (facing ego) at yaw=0. + return [ + { + "key": _make_key_record("traffic_sign_right"), + "traffic_sign": { + "center": _point_xyz(25.0, -4.0, 2.0), + "dimensions": _point_xyz(0.12, 1.0, 1.2), + "orientation": _orientation_from_yaw(0.0), + "category": "speed_limit", + "egomotion_label_class_id": "ego", + }, + "version": 1, + }, + { + "key": _make_key_record("traffic_sign_left"), + "traffic_sign": { + "center": _point_xyz(32.0, 5.5, 2.5), + "dimensions": _point_xyz(0.12, 1.4, 0.8), + "orientation": _orientation_from_yaw(0.0), + "category": "warning", + "egomotion_label_class_id": "ego", + }, + "version": 1, + }, + ] + + +def _traffic_light_rows() -> list[dict[str, object]]: + return [ + { + "key": _make_key_record("traffic_light_0"), + "traffic_light": { + "center": _point_xyz(29.0, -5.5, 4.0), + "dimensions": _point_xyz(0.4, 0.6, 1.0), + "orientation": _orientation_from_yaw(0.0), + "category": "signal_head", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ] + + +def _periodic_poles( + x_m: np.ndarray, + y_m: np.ndarray, + yaw_rad: np.ndarray, + *, + period_m: float = _POLE_PERIOD_M, + lateral_offset_m: float = _POLE_OFFSET_M, + height_m: float = 5.0, +) -> list[list[dict[str, float]]]: + """Emit ``[base, top]`` line segments for streetlamp-style poles spaced + every ``period_m`` along both shoulders of the trajectory. + + Each pole is anchored to the centerline at its placement frame and + offset laterally by ``lateral_offset_m`` (positive = left side, + negative = right side), so poles follow the wavy road instead of a + straight world-frame line. + """ + if len(x_m) == 0: + return [] + poles: list[list[dict[str, float]]] = [] + next_x = period_m + for i in range(len(x_m)): + if x_m[i] < next_x: + continue + next_x += period_m + cx = float(x_m[i]) + cy = float(y_m[i]) + cyaw = float(yaw_rad[i]) + # Lateral basis at this trajectory point: rotated by yaw, offset + # in the body +Y direction. Same convention as ``_offset_polyline``. + for side in (1.0, -1.0): + dx = -math.sin(cyaw) * lateral_offset_m * side + dy = math.cos(cyaw) * lateral_offset_m * side + base = _point_xyz(cx + dx, cy + dy, 0.0) + top = _point_xyz(cx + dx, cy + dy, height_m) + poles.append([base, top]) + return poles + + +def _off_road_poles( + x_m: np.ndarray, + y_m: np.ndarray, + yaw_rad: np.ndarray, + *, + period_m: float = _OFF_ROAD_POLE_PERIOD_M, + lateral_min_m: float = _OFF_ROAD_POLE_LATERAL_MIN_M, + lateral_max_m: float = _OFF_ROAD_POLE_LATERAL_MAX_M, + height_min_m: float = _OFF_ROAD_POLE_HEIGHT_MIN_M, + height_max_m: float = _OFF_ROAD_POLE_HEIGHT_MAX_M, + forward_jitter_m: float = _OFF_ROAD_POLE_FORWARD_JITTER_M, + seed: int = _OFF_ROAD_POLE_RNG_SEED, +) -> list[list[dict[str, float]]]: + """Scatter randomized "tree / telephone pole" clutter in a wide off-road band. + + Keeps the HDMap non-empty when the ego strays off the road (black + conditioning frames make the world model drift); seeded for reproducibility. + """ + if len(x_m) == 0: + return [] + rng = np.random.default_rng(seed) + poles: list[list[dict[str, float]]] = [] + next_x = period_m + while next_x < float(x_m[-1]): + # Randomise the forward position a little so the off-road poles + # don't form a perfectly periodic grid alongside the periodic + # streetlamp poles -- looks more natural. + adjusted_x = next_x + float(rng.uniform(-forward_jitter_m, forward_jitter_m)) + i = int(np.searchsorted(x_m, adjusted_x)) + if i >= len(x_m): + break + cx = float(x_m[i]) + cy = float(y_m[i]) + cyaw = float(yaw_rad[i]) + # One pole per side with INDEPENDENT random lateral offsets so the + # two sides don't read as a parallel two-line corridor. + for side in (1.0, -1.0): + lateral = float(rng.uniform(lateral_min_m, lateral_max_m)) + height = float(rng.uniform(height_min_m, height_max_m)) + dx = -math.sin(cyaw) * lateral * side + dy = math.cos(cyaw) * lateral * side + base = _point_xyz(cx + dx, cy + dy, 0.0) + top = _point_xyz(cx + dx, cy + dy, height) + poles.append([base, top]) + next_x += period_m + return poles + + +def _periodic_traffic_signs( + x_m: np.ndarray, + y_m: np.ndarray, + yaw_rad: np.ndarray, + *, + period_m: float = _TRAFFIC_SIGN_PERIOD_M, + lateral_offset_m: float = _TRAFFIC_SIGN_LATERAL_M, + height_m: float = _TRAFFIC_SIGN_HEIGHT_M, +) -> list[dict[str, object]]: + """Emit traffic-sign records on alternating shoulders along the trajectory. + + Each entry has the same shape as ``_traffic_sign_rows`` but is + anchored to the trajectory at ``period_m`` intervals so the model + sees signage continuing alongside the road instead of just at the + start. The plate's thinnest dimension is along x (length=0.12) so + ``_build_cuboid_plate_faces`` in the loader picks the world +x axis + as the plate normal at yaw=0 -- the periodic signs face the local + centerline tangent. + """ + if len(x_m) == 0: + return [] + rows: list[dict[str, object]] = [] + next_x = period_m + side_idx = 0 + while next_x < float(x_m[-1]): + i = int(np.searchsorted(x_m, next_x)) + if i >= len(x_m): + break + side = 1.0 if side_idx % 2 == 0 else -1.0 + cx = float(x_m[i]) + cy = float(y_m[i]) + cyaw = float(yaw_rad[i]) + dx = -math.sin(cyaw) * lateral_offset_m * side + dy = math.cos(cyaw) * lateral_offset_m * side + rows.append( + { + "key": _make_key_record(f"traffic_sign_periodic_{side_idx:04d}"), + "traffic_sign": { + "center": _point_xyz(cx + dx, cy + dy, height_m), + "dimensions": _point_xyz(0.12, 1.0, 1.2), + "orientation": _orientation_from_yaw(cyaw), + "category": "speed_limit", + "egomotion_label_class_id": "ego", + }, + "version": 1, + } + ) + next_x += period_m + side_idx += 1 + return rows + + +def _calibration_row() -> list[dict[str, object]]: + return [ + { + "key": { + "clip_id": _SCENE_ID, + "timestamp_micros": int(_START_TIMESTAMP_US), + "label_class_id": "calibration", + }, + "calibration_estimate": { + "name": "default", + "rig_json": json.dumps(default_front_camera_rig()), + }, + "version": 1, + } + ] + + +def _initial_rgb() -> np.ndarray: + x_gradient = np.linspace(0.0, 1.0, _IMAGE_WIDTH, dtype=np.float32) + y_gradient = np.linspace(0.0, 1.0, _IMAGE_HEIGHT, dtype=np.float32)[:, None] + red = np.clip( + 50.0 + + 140.0 * np.broadcast_to(x_gradient[None, :], (_IMAGE_HEIGHT, _IMAGE_WIDTH)), + 0.0, + 255.0, + ) + green = np.clip( + 90.0 + 90.0 * np.broadcast_to(y_gradient, (_IMAGE_HEIGHT, _IMAGE_WIDTH)), + 0.0, + 255.0, + ) + blue = np.clip(120.0 + 60.0 * (1.0 - x_gradient[None, :] * y_gradient), 0.0, 255.0) + stacked = np.stack([red, green, blue], axis=2).astype(np.uint8) + return stacked + + +def _first_image_variant(base: np.ndarray, shift_px: int) -> np.ndarray: + shifted = np.roll(base, shift=shift_px, axis=1) + return shifted.astype(np.uint8) + + +def _normalise_rgb(rgb: np.ndarray) -> np.ndarray: + """Coerce a caller-supplied initial frame to a ``(H, W, 3) uint8`` array. + + The scene loader will resize to ``RasterConfig.resolution_wh`` at load + time, so we don't enforce a specific resolution here - we only require + that the dtype/shape are something Pillow can ``Image.fromarray(...)`` + on without raising. + """ + arr = np.asarray(rgb) + if arr.ndim != 3 or arr.shape[2] != 3: + raise ValueError(f"initial_rgb must be (H, W, 3); got shape {arr.shape}") + if arr.dtype != np.uint8: + arr = np.clip(arr, 0, 255).astype(np.uint8) + return arr + + +def _write_parquet_entry( + zf: zipfile.ZipFile, name: str, rows: list[dict[str, object]] +) -> None: + table = pa.Table.from_pylist(rows) + buffer = io.BytesIO() + pq.write_table(table, buffer) + zf.writestr(name, buffer.getvalue()) + + +def _write_png_entry(zf: zipfile.ZipFile, name: str, rgb: np.ndarray) -> None: + buffer = io.BytesIO() + Image.fromarray(rgb, mode="RGB").save(buffer, format="PNG") + zf.writestr(name, buffer.getvalue()) + + +def _metadata_doc(num_frames: int = _DEFAULT_TRAJECTORY_FRAMES) -> dict[str, object]: + return { + "scene_id": _SCENE_ID, + "dataset_hash": "synthetic-dataset-hash", + "is_resumable": False, + "sensors": { + "camera_ids": [DEFAULT_FRONT_CAMERA_LOGICAL_NAME], + "lidar_ids": [], + }, + "time_range": { + "start": int(_START_TIMESTAMP_US), + "end": int( + _START_TIMESTAMP_US + (int(num_frames) - 1) * (1_000_000 // _FPS) + ), + }, + "version_string": "synthetic-1.0.0", + } + + +def _rig_trajectory_doc( + poses: list[list[list[float]]], timestamps_us: list[int] +) -> dict[str, object]: + return { + "rig_trajectories": [ + {"T_rig_worlds": poses, "T_rig_world_timestamps_us": timestamps_us} + ] + } + + +def _synthetic_ground_mesh_ply() -> bytes: + """Build a flat ground-plane PLY at z=0 covering the synthetic route. + + Two triangles forming a 200m x 80m quad — generously larger than the + ~60m forward / +-3m lateral trajectory so any rolled or zoomed-out test + still hits the mesh. Real scenes ship a much denser ``mesh_ground.ply``; + this fixture only needs to exercise the load + snap code path. + """ + vertices = np.array( + [ + [-100.0, -40.0, 0.0], + [100.0, -40.0, 0.0], + [100.0, 40.0, 0.0], + [-100.0, 40.0, 0.0], + ], + dtype=np.float32, + ) + faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32) + return save_mesh_vf(vertices, faces) + + +def build_synthetic_scene_usdz( + path: Path, + *, + initial_rgb: np.ndarray | None = None, + prompt: str | None = None, + length_frames: int = _DEFAULT_TRAJECTORY_FRAMES, +) -> Path: + """Build a procedural USDZ that the scene loader can ingest unchanged. + + The geometry (trajectory, lane lines, road boundary, intersection, + crosswalk, poles, signs, and lights) is fixed and deterministic. + Args: + path: Destination USDZ file. + initial_rgb: ``(H, W, 3)`` ``uint8`` RGB image to embed as + ``first_image.png``. Defaults to a debug colour gradient that's + fine for tests but visually unhelpful for an actual demo. The + scene loader resizes this to ``RasterConfig.resolution_wh`` at + load time, so the shape doesn't need to match exactly. + prompt: Default text prompt embedded as ``prompt.txt``. Defaults to a + generic forward-driving description. + length_frames: How many trajectory frames the synthetic road carries. + Lane lines and road boundaries are spec'd along this trajectory, + so larger values produce more drivable road. Default 180 + (~6 s, 60 m at the default 10 m/s) keeps the + test fixture small. The + single intersection / crosswalk / road-island stay anchored at + their original near-start coordinates regardless of length. + """ + if length_frames < 2: + raise ValueError( + f"length_frames must be >= 2 (got {length_frames}); the trajectory " + "needs at least two samples for the loader to compute initial speed." + ) + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + x_m, y_m, yaw_rad, timestamps_us = _trajectory_arrays(num_frames=length_frames) + poses = [ + rig_pose_from_state(float(px), float(py), 0.0, float(pyaw)).tolist() + for px, py, pyaw in zip(x_m, y_m, yaw_rad, strict=True) + ] + + center_x, center_y, center_yaw = _resample_centerline(x_m, y_m, yaw_rad, stride=3) + road_boundary_chunk_size = max(2, _LANE_LINE_CHUNK_FRAMES // 3) + road_left_chunks = _chunk_polyline( + _offset_polyline( + center_x, center_y, center_yaw, lateral_offset_m=_ROAD_BOUNDARY_OFFSET_M + ), + chunk_size=road_boundary_chunk_size, + ) + road_right_chunks = _chunk_polyline( + _offset_polyline( + center_x, center_y, center_yaw, lateral_offset_m=-_ROAD_BOUNDARY_OFFSET_M + ), + chunk_size=road_boundary_chunk_size, + ) + # Stop/wait line across the travel lanes just before the crosswalk. + wait_line = [_point_xyz(17.0, -3.0, 0.0), _point_xyz(17.0, 3.0, 0.0)] + # Periodic streetlamp-style poles every ~50 m on both shoulders. Each + # pole is a short vertical line segment whose base sits on the + # centerline-rotated +Y axis at the configured offset, so they follow + # the wavy road instead of a straight world-frame line. The + # near-start poles at x=20 m stay where they were so the existing + # demo fixture still has the original "intersection-approach" pair. + pole_polylines: list[list[dict[str, float]]] = [ + [_point_xyz(20.0, _POLE_OFFSET_M, 0.0), _point_xyz(20.0, _POLE_OFFSET_M, 5.0)], + [ + _point_xyz(20.0, -_POLE_OFFSET_M, 0.0), + _point_xyz(20.0, -_POLE_OFFSET_M, 5.0), + ], + ] + pole_polylines.extend(_periodic_poles(x_m, y_m, yaw_rad)) + # Off-road clutter ("trees / telephone poles") scattered up to 100 m + # laterally so the HDMap stays non-empty even when the ego strays + # past the road boundary. The world model needs *some* visible + # structure to condition on; black HDMap frames produce drift. + pole_polylines.extend(_off_road_poles(x_m, y_m, yaw_rad)) + base_rgb = _initial_rgb() if initial_rgb is None else _normalise_rgb(initial_rgb) + variant1_rgb = _first_image_variant(base_rgb, shift_px=16) + variant2_rgb = _first_image_variant(base_rgb, shift_px=48) + + default_prompt = ( + "Synthetic default prompt for loader testing." if prompt is None else prompt + ) + variant1_prompt = "Synthetic prompt variant 1." if prompt is None else prompt + variant2_prompt = "Synthetic prompt variant 2." if prompt is None else prompt + + with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_STORED) as zf: + zf.writestr( + "metadata.yaml", + yaml.safe_dump(_metadata_doc(num_frames=length_frames), sort_keys=True), + ) + zf.writestr( + "rig_trajectories.json", + json.dumps(_rig_trajectory_doc(poses, timestamps_us.tolist())), + ) + zf.writestr("mesh_ground.ply", _synthetic_ground_mesh_ply()) + # Keep writing canonical underscore-separated variant numbers. + # The loader also accepts numeric legacy names like ``prompt1.txt``. + zf.writestr("prompt.txt", default_prompt) + zf.writestr("prompt_1.txt", variant1_prompt) + zf.writestr("prompt_2.txt", variant2_prompt) + + _write_png_entry(zf, "first_image.png", base_rgb) + _write_png_entry(zf, "first_image_1.png", variant1_rgb) + _write_png_entry(zf, "first_image_2.png", variant2_rgb) + + _write_parquet_entry( + zf, "clipgt/calibration_estimate.parquet", _calibration_row() + ) + _write_parquet_entry( + zf, "clipgt/lane_line.parquet", _lane_line_rows(x_m, y_m, yaw_rad) + ) + _write_parquet_entry( + zf, + "clipgt/road_boundary.parquet", + _polyline_rows( + "road_boundary", + "road_boundary", + "location", + [*road_left_chunks, *road_right_chunks], + ), + ) + _write_parquet_entry( + zf, + "clipgt/wait_line.parquet", + _polyline_rows("wait_line", "wait_line", "location", [wait_line]), + ) + _write_parquet_entry( + zf, + "clipgt/pole.parquet", + _polyline_rows("pole", "pole", "location", pole_polylines), + ) + _write_parquet_entry( + zf, + "clipgt/traffic_sign.parquet", + [*_traffic_sign_rows(), *_periodic_traffic_signs(x_m, y_m, yaw_rad)], + ) + _write_parquet_entry(zf, "clipgt/traffic_light.parquet", _traffic_light_rows()) + _write_parquet_entry( + zf, + "clipgt/crosswalk.parquet", + _polygon_rows( + "crosswalk", + "crosswalk", + "location", + [ + _rectangle_polygon( + center_x_m=15.0, + center_y_m=0.0, + half_width_m=1.5, + half_height_m=3.0, + ) + ], + ), + ) + _write_parquet_entry( + zf, + "clipgt/road_marking.parquet", + _polygon_rows( + "road_marking", + "road_marking", + "location", + [ + _rectangle_polygon( + center_x_m=22.0, + center_y_m=-1.8, + half_width_m=1.5, + half_height_m=0.25, + ) + ], + ), + ) + _write_parquet_entry( + zf, + "clipgt/intersection_area.parquet", + _polygon_rows( + "intersection_area", + "intersection_area", + "location", + [ + _rectangle_polygon( + center_x_m=38.0, + center_y_m=0.0, + half_width_m=7.5, + half_height_m=6.0, + ) + ], + ), + ) + _write_parquet_entry( + zf, + "clipgt/road_island.parquet", + _polygon_rows( + "road_island", + "road_island", + "location", + [ + _rectangle_polygon( + center_x_m=33.0, + center_y_m=4.0, + half_width_m=1.5, + half_height_m=0.8, + ) + ], + ), + ) + return path diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py new file mode 100644 index 000000000..226d0bcec --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py @@ -0,0 +1,856 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from __future__ import annotations + +import io +import json +import zipfile +from collections import defaultdict +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +import numpy as np +import pyarrow.parquet as pq +import yaml +from loguru import logger +from omnidreams.scenes import ( + SCENE_FRAME_SUFFIXES, + SCENE_FRAMES_DIRNAME, + prompt_variant_for_scene_variant, + resolve_variant_archive, + variant_from_stem, +) +from PIL import Image + +from omnidreams_game_engine.colors import ( + HDMAP_V3_COLORS, + LANE_LINE_STYLE_CONFIG, +) +from omnidreams_game_engine.config import RasterConfig +from omnidreams_game_engine.game_map.types import game_map_from_dict +from omnidreams_game_engine.math3d import ( + euler_xyz_degrees_to_matrix, + extract_yaw_from_transform, + normalize_camera_name, + quaternion_to_matrix_xyzw, + transform_from_rt, +) +from omnidreams_game_engine.patterns import ( + apply_pattern, + concatenate_segments, + resample_polyline, + segments_from_polyline, + split_segment_runs, + subdivide_polyline, + triangulate_polygon_fan, +) +from omnidreams_game_engine.ply_io import load_mesh_vf +from omnidreams_game_engine.types import ( + CameraCalibration, + SceneDefinition, + WorldLineSegments, + WorldPolygonList, + WorldTriangleList, +) + +_GROUND_MESH_NAME = "mesh_ground.ply" + + +@dataclass(frozen=True) +class _PromptEntry: + archive_name: str + text: str + + +def _read_yaml(zf: zipfile.ZipFile, name: str) -> dict[str, Any]: + return yaml.safe_load(zf.read(name)) + + +def _read_json(zf: zipfile.ZipFile, name: str) -> dict[str, Any]: + return json.loads(zf.read(name)) + + +def _read_parquet_records(zf: zipfile.ZipFile, name: str) -> list[dict[str, Any]]: + with zf.open(name) as handle: + return pq.read_table(handle).to_pylist() + + +def _points_from_records(points: list[dict[str, float]]) -> np.ndarray: + return np.array( + [[point["x"], point["y"], point["z"]] for point in points], dtype=np.float32 + ) + + +def _load_initial_image( + zf: zipfile.ZipFile, camera_name: str, variant: str, raster: RasterConfig +) -> np.ndarray: + """Seed frame: the GT first camera frame, else the ``first_image`` render. + + Prefers ``frames//.jpeg`` so generation starts from the real + capture; falls back to ``first_image[_].png`` for older / + synthetic scenes with no per-camera frames. + """ + name = _discover_initial_frame(zf, camera_name) + if name is None: + images = _discover_first_images(zf) + name = images.get(variant) or images.get("default") + if name is None: + raise FileNotFoundError( + "No frames//*.jpeg or first_image*.png found in the USDZ archive" + ) + _log_initial_frame_selection( + zf, + variant=variant, + camera_name=camera_name, + source=name, + ) + with Image.open(io.BytesIO(zf.read(name))) as image: + rgb = image.convert("RGB") + resized = rgb.resize(raster.resolution_wh, resample=Image.Resampling.BILINEAR) + return np.asarray(resized, dtype=np.uint8) + + +def _discover_initial_frame(zf: zipfile.ZipFile, camera_name: str) -> str | None: + """Earliest GT frame for ``camera_name`` (``None`` if the archive has none). + + Frames are ``frames//.jpeg``; the smallest timestamp is + the first frame. Accepts colon / underscore camera-name spellings. + """ + clipgt_name, logical_name = normalize_camera_name(camera_name) + wanted_prefixes = tuple( + { + f"{SCENE_FRAMES_DIRNAME}/{name}/" + for name in (camera_name, logical_name, clipgt_name) + } + ) + candidates = [ + name + for name in zf.namelist() + if name.startswith(wanted_prefixes) + and Path(name).suffix.lower() in SCENE_FRAME_SUFFIXES + ] + if not candidates: + return None + + def _frame_sort_key(name: str) -> tuple[int, str]: + stem = Path(name).stem + return (int(stem), name) if stem.isdigit() else (2**63 - 1, name) + + return sorted(candidates, key=_frame_sort_key)[0] + + +def _load_prompt(zf: zipfile.ZipFile, variant: str, prompt_override: str | None) -> str: + if prompt_override is not None: + _log_prompt_selection( + zf, + variant=variant, + selected_variant="override", + source="--prompt", + prompt=prompt_override, + available_variants=(), + ignored_files=(), + ) + return prompt_override + prompt_entries, ignored_files = _discover_prompt_entries(zf) + selected_variant = _select_prompt_variant(prompt_entries, variant) + prompt_entry = ( + prompt_entries[selected_variant] if selected_variant is not None else None + ) + prompt = "" if prompt_entry is None else prompt_entry.text + _log_prompt_selection( + zf, + variant=variant, + selected_variant=selected_variant, + source="" if prompt_entry is None else prompt_entry.archive_name, + prompt=prompt, + available_variants=tuple(sorted(prompt_entries.keys())), + ignored_files=ignored_files, + ) + return prompt + + +def _select_prompt_variant( + prompt_entries: dict[str, _PromptEntry], variant: str +) -> str | None: + """Pick the in-archive prompt key for the requested scene variant. + + Exact match wins first (legacy in-zip ``default`` / ``1`` / ``2``), else + the weather->prompt mapping, else ``"default"``, else ``None``. + """ + if variant in prompt_entries: + return variant + mapped = prompt_variant_for_scene_variant(variant) + if mapped in prompt_entries: + return mapped + if "default" in prompt_entries: + return "default" + return None + + +def _discover_prompts(zf: zipfile.ZipFile) -> dict[str, str]: + prompt_entries, _ = _discover_prompt_entries(zf) + return {variant: entry.text for variant, entry in prompt_entries.items()} + + +def _discover_prompt_entries( + zf: zipfile.ZipFile, +) -> tuple[dict[str, _PromptEntry], tuple[str, ...]]: + prompts: dict[str, _PromptEntry] = {} + ignored_files: list[str] = [] + for name in zf.namelist(): + if "/" in name or not name.startswith("prompt") or not name.endswith(".txt"): + continue + variant = variant_from_stem(Path(name).stem, "prompt") + if variant is None: + ignored_files.append(name) + continue + prompts[variant] = _PromptEntry( + archive_name=name, + text=zf.read(name).decode("utf-8").strip(), + ) + if "default" not in prompts and prompts: + first_key = sorted(prompts.keys())[0] + prompts["default"] = prompts[first_key] + return prompts, tuple(sorted(ignored_files)) + + +def _log_prompt_selection( + zf: zipfile.ZipFile, + *, + variant: str, + selected_variant: str | None, + source: str, + prompt: str, + available_variants: tuple[str, ...], + ignored_files: tuple[str, ...], +) -> None: + scene_name = Path(str(zf.filename)).name if zf.filename is not None else "" + prompt_text = " ".join(prompt.split()) + logger.info( + "[scene_loader] prompt " + f"scene={scene_name!r} " + f"requested_variant={variant!r} " + f"selected_variant={selected_variant or ''!r} " + f"source={source!r} " + f"available_variants={available_variants or ''!r} " + f"ignored_files={ignored_files or ''!r} " + f"length={len(prompt)} " + f"text={prompt_text!r}", + ) + + +def _log_initial_frame_selection( + zf: zipfile.ZipFile, + *, + variant: str, + camera_name: str, + source: str, +) -> None: + scene_name = Path(str(zf.filename)).name if zf.filename is not None else "" + logger.info( + "[scene_loader] initial_frame " + f"scene={scene_name!r} " + f"requested_variant={variant!r} " + f"camera={camera_name!r} " + f"source={source!r}", + ) + + +def _discover_first_images(zf: zipfile.ZipFile) -> dict[str, str]: + images: dict[str, str] = {} + for name in zf.namelist(): + if ( + "/" in name + or not name.startswith("first_image") + or not name.endswith(".png") + ): + continue + variant = variant_from_stem(Path(name).stem, "first_image") + if variant is None: + continue + images[variant] = name + if "default" not in images and images: + first_key = sorted(images.keys())[0] + images["default"] = images[first_key] + return images + + +def _select_camera( + sensor_records: list[dict[str, Any]], requested_name: str +) -> dict[str, Any]: + requested_clipgt, requested_logical = normalize_camera_name(requested_name) + for sensor in sensor_records: + if sensor["name"] in {requested_clipgt, requested_logical}: + return sensor + raise KeyError(f"Camera {requested_name!r} was not found in the calibration rig") + + +def _load_camera_calibration( + zf: zipfile.ZipFile, camera_name: str +) -> CameraCalibration: + calibration_row = _read_parquet_records(zf, "clipgt/calibration_estimate.parquet")[ + 0 + ]["calibration_estimate"] + rig = json.loads(calibration_row["rig_json"])["rig"] + sensor = _select_camera(rig["sensors"], camera_name) + + props = sensor["properties"] + poly_type = props["polynomial-type"] + is_backward = poly_type == "pixeldistance-to-angle" + polynomial = np.array( + [float(value) for value in props["polynomial"].split()], dtype=np.float32 + ) + linear_cde = np.array( + [ + float(props.get("linear-c", 1.0)), + float(props.get("linear-d", 0.0)), + float(props.get("linear-e", 0.0)), + ], + dtype=np.float32, + ) + + nominal_rotation = euler_xyz_degrees_to_matrix( + sensor["nominalSensor2Rig_FLU"]["roll-pitch-yaw"] + ) + correction_rotation = euler_xyz_degrees_to_matrix( + sensor.get("correction_sensor_R_FLU", {"roll-pitch-yaw": [0.0, 0.0, 0.0]})[ + "roll-pitch-yaw" + ] + ) + sensor_to_rig_rotation = (nominal_rotation @ correction_rotation).astype(np.float32) + + nominal_translation = np.asarray( + sensor["nominalSensor2Rig_FLU"]["t"], dtype=np.float32 + ) + correction_translation = np.asarray( + sensor.get("correction_rig_T", [0.0, 0.0, 0.0]), dtype=np.float32 + ) + sensor_to_rig_translation = (nominal_translation + correction_translation).astype( + np.float32 + ) + + clipgt_name, logical_name = normalize_camera_name(sensor["name"]) + return CameraCalibration( + clipgt_name=clipgt_name, + logical_name=logical_name, + width=int(props["width"]), + height=int(props["height"]), + cx=float(props["cx"]), + cy=float(props["cy"]), + polynomial=polynomial, + is_backward_polynomial=is_backward, + linear_cde=linear_cde, + sensor_to_rig_flu=transform_from_rt( + sensor_to_rig_rotation, sensor_to_rig_translation.tolist() + ), + ) + + +def _load_initial_state(zf: zipfile.ZipFile) -> tuple[np.ndarray, int, float, float]: + trajectory_doc = _read_json(zf, "rig_trajectories.json") + rig_trajectory = trajectory_doc["rig_trajectories"][0] + poses = np.asarray(rig_trajectory["T_rig_worlds"], dtype=np.float32) + timestamps = np.asarray(rig_trajectory["T_rig_world_timestamps_us"], dtype=np.int64) + + initial_pose = poses[0].astype(np.float32) + initial_timestamp = int(timestamps[0]) + initial_yaw = extract_yaw_from_transform(initial_pose) + + if len(poses) > 1: + delta_t_s = max(1e-6, (int(timestamps[1]) - int(timestamps[0])) / 1_000_000.0) + delta_xy = poses[1, :2, 3] - poses[0, :2, 3] + initial_speed = float(np.linalg.norm(delta_xy) / delta_t_s) + else: + initial_speed = 0.0 + + return initial_pose, initial_timestamp, initial_yaw, initial_speed + + +def _sanitize_layer_suffix(name: str) -> str: + return name.lower().replace(" ", "_") + + +def _coarsen_segment_group(segments_world: np.ndarray, interval_m: float) -> np.ndarray: + coarsened_runs: list[np.ndarray] = [] + for run in split_segment_runs(segments_world): + if len(run) <= 2: + coarsened_runs.append(segments_from_polyline(run)) + continue + sampled = resample_polyline(run, interval_m=interval_m) + coarsened_runs.append(segments_from_polyline(sampled)) + return concatenate_segments(coarsened_runs) + + +def _build_lane_segments( + rows: list[dict[str, Any]], raster: RasterConfig +) -> tuple[WorldLineSegments, ...]: + grouped_segments: dict[ + tuple[tuple[float, float, float, float], float], list[np.ndarray] + ] = defaultdict(list) + grouped_names: dict[tuple[tuple[float, float, float, float], float], set[str]] = ( + defaultdict(set) + ) + + for row in rows: + payload = row["lane_line"] + polyline = _points_from_records(payload["line_rail"]) + if len(polyline) < 2: + continue + color_name = next( + (value for value in payload.get("colors", []) if value), "OTHER" + ) + style_name = next( + (value for value in payload.get("styles", []) if value), "OTHER" + ) + lane_type = f"{color_name} {style_name}".strip() + config = LANE_LINE_STYLE_CONFIG.get(lane_type, LANE_LINE_STYLE_CONFIG["OTHER"]) + + subdivided = subdivide_polyline(polyline, raster.lane_segment_interval_m) + base_segments = segments_from_polyline(subdivided) + patterned_segments = apply_pattern( + base_segments, + pattern=str(config.get("pattern", "solid")), + dual_pattern=config.get("dual_pattern"), # type: ignore[arg-type] + dual_offset_m=raster.dual_line_offset_m, + ) + color_rgba = tuple(float(value) for value in config["color"]) # type: ignore[arg-type] + width_px = raster.line_width_px * float(config.get("width_scale", 1.0)) + group_key = (color_rgba, width_px) + for group in patterned_segments: + if len(group) == 0: + continue + grouped_segments[group_key].append( + _coarsen_segment_group( + group, interval_m=raster.polyline_segment_interval_m + ) + ) + grouped_names[group_key].add(lane_type) + + if not grouped_segments: + return tuple() + + layers: list[WorldLineSegments] = [] + for key, segment_groups in grouped_segments.items(): + color_rgba, width_px = key + style_names = "+".join( + sorted(_sanitize_layer_suffix(name) for name in grouped_names[key]) + ) + layers.append( + WorldLineSegments( + segments_world=concatenate_segments(segment_groups), + color_rgba=color_rgba, + width_px=width_px, + layer_name=f"lanelines_{style_names}", + ) + ) + return tuple(layers) + + +def _build_cuboid_corners( + center_xyz: np.ndarray, + dimensions_xyz: np.ndarray, + orientation_xyzw: tuple[float, float, float, float], +) -> np.ndarray: + rotation = quaternion_to_matrix_xyzw(orientation_xyzw) + half = dimensions_xyz * 0.5 + corners = np.array( + [ + [-half[0], -half[1], -half[2]], + [half[0], -half[1], -half[2]], + [half[0], half[1], -half[2]], + [-half[0], half[1], -half[2]], + [-half[0], -half[1], half[2]], + [half[0], -half[1], half[2]], + [half[0], half[1], half[2]], + [-half[0], half[1], half[2]], + ], + dtype=np.float32, + ) + return (corners @ rotation.T) + center_xyz + + +def _build_cuboid_plate_faces( + center_xyz: np.ndarray, + dimensions_xyz: np.ndarray, + orientation_xyzw: tuple[float, float, float, float], +) -> np.ndarray: + corners = _build_cuboid_corners(center_xyz, dimensions_xyz, orientation_xyzw) + thinnest_axis = int(np.argmin(dimensions_xyz)) + face_indices_by_axis = { + 0: ((0, 3, 7, 4), (1, 2, 6, 5)), + 1: ((0, 1, 5, 4), (3, 2, 6, 7)), + 2: ((0, 1, 2, 3), (4, 5, 6, 7)), + } + quads = [ + corners[np.array(indices, dtype=np.int32)] + for indices in face_indices_by_axis[thinnest_axis] + ] + return np.concatenate( + [triangulate_polygon_fan(quad) for quad in quads], axis=0 + ).astype(np.float32) + + +def _build_sign_face_layer( + rows: list[dict[str, Any]], payload_key: str, layer_name: str +) -> WorldTriangleList: + triangles: list[np.ndarray] = [] + for row in rows: + payload = row[payload_key] + center = np.array( + [payload["center"]["x"], payload["center"]["y"], payload["center"]["z"]], + dtype=np.float32, + ) + dims = np.array( + [ + payload["dimensions"]["x"], + payload["dimensions"]["y"], + payload["dimensions"]["z"], + ], + dtype=np.float32, + ) + orientation = ( + float(payload["orientation"]["x"]), + float(payload["orientation"]["y"]), + float(payload["orientation"]["z"]), + float(payload["orientation"]["w"]), + ) + triangles.append(_build_cuboid_plate_faces(center, dims, orientation)) + triangles_world = ( + np.concatenate(triangles, axis=0).astype(np.float32) + if triangles + else np.empty((0, 3, 3), dtype=np.float32) + ) + return WorldTriangleList( + triangles_world=triangles_world, + color_rgba=HDMAP_V3_COLORS[layer_name], + layer_name=layer_name, + ) + + +def _build_cuboid_edges( + center_xyz: np.ndarray, + dimensions_xyz: np.ndarray, + orientation_xyzw: tuple[float, float, float, float], +) -> np.ndarray: + corners = _build_cuboid_corners(center_xyz, dimensions_xyz, orientation_xyzw) + edges = [ + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (0, 4), + (1, 5), + (2, 6), + (3, 7), + ] + return np.array([[corners[a], corners[b]] for a, b in edges], dtype=np.float32) + + +def _build_cuboid_layer( + rows: list[dict[str, Any]], payload_key: str, layer_name: str, width_px: float +) -> WorldLineSegments: + groups: list[np.ndarray] = [] + null_orientation_rows = 0 + for row in rows: + payload = row[payload_key] + center = np.array( + [payload["center"]["x"], payload["center"]["y"], payload["center"]["z"]], + dtype=np.float32, + ) + dims = np.array( + [ + payload["dimensions"]["x"], + payload["dimensions"]["y"], + payload["dimensions"]["z"], + ], + dtype=np.float32, + ) + # Some ClipGT scenes publish a null orientation quaternion (upstream + # pose-fit failure); default to identity so the cuboid still loads + # axis-aligned instead of dropping out of the HDMap view. + orient = payload.get("orientation") or {} + qx = orient.get("x") + qy = orient.get("y") + qz = orient.get("z") + qw = orient.get("w") + if qx is None or qy is None or qz is None or qw is None: + qx, qy, qz, qw = 0.0, 0.0, 0.0, 1.0 + null_orientation_rows += 1 + orientation = (float(qx), float(qy), float(qz), float(qw)) + groups.append(_build_cuboid_edges(center, dims, orientation)) + if null_orientation_rows: + logger.info( + f"[scene_loader] {layer_name}: {null_orientation_rows} of " + f"{len(rows)} cuboid(s) had null orientation in the parquet; " + f"rendered as identity-rotated. Upstream dataset bug " + f"(missing pose-fit for some features).", + ) + return WorldLineSegments( + segments_world=concatenate_segments(groups), + color_rgba=HDMAP_V3_COLORS[layer_name], + width_px=width_px, + layer_name=layer_name, + ) + + +def _build_polyline_layer( + rows: list[dict[str, Any]], + payload_key: str, + points_key: str, + layer_name: str, + raster: RasterConfig, + width_px: float, +) -> WorldLineSegments: + segments: list[np.ndarray] = [] + for row in rows: + polyline = _points_from_records(row[payload_key][points_key]) + subdivided = subdivide_polyline(polyline, raster.polyline_segment_interval_m) + line_segments = segments_from_polyline(subdivided) + if len(line_segments) > 0: + segments.append(line_segments) + + return WorldLineSegments( + segments_world=concatenate_segments(segments), + color_rgba=HDMAP_V3_COLORS[layer_name], + width_px=width_px, + layer_name=layer_name, + ) + + +def _build_polygon_loop_layer( + rows: list[dict[str, Any]], + payload_key: str, + points_key: str, + layer_name: str, + raster: RasterConfig, +) -> WorldPolygonList: + polygons_world: list[np.ndarray] = [] + for row in rows: + polygon = _points_from_records(row[payload_key][points_key]) + if len(polygon) < 3: + continue + if np.linalg.norm(polygon[0] - polygon[-1]) <= 1e-4: + polygon = polygon[:-1] + if len(polygon) < 3: + continue + polygons_world.append(polygon.astype(np.float32)) + + return WorldPolygonList( + polygons_world=tuple(polygons_world), + color_rgba=HDMAP_V3_COLORS[layer_name], + layer_name=layer_name, + ) + + +def _load_map_layers( + zf: zipfile.ZipFile, + raster: RasterConfig, +) -> tuple[ + tuple[WorldLineSegments, ...], + tuple[WorldTriangleList, ...], + tuple[WorldPolygonList, ...], +]: + def has(name: str) -> bool: + return name in zf.namelist() + + line_layers: list[WorldLineSegments] = [] + triangle_layers: list[WorldTriangleList] = [] + polygon_layers: list[WorldPolygonList] = [] + + if has("clipgt/lane_line.parquet"): + line_layers.extend( + _build_lane_segments( + _read_parquet_records(zf, "clipgt/lane_line.parquet"), raster + ) + ) + if has("clipgt/road_boundary.parquet"): + line_layers.append( + _build_polyline_layer( + _read_parquet_records(zf, "clipgt/road_boundary.parquet"), + payload_key="road_boundary", + points_key="location", + layer_name="road_boundaries", + raster=raster, + width_px=raster.line_width_px, + ) + ) + if has("clipgt/wait_line.parquet"): + line_layers.append( + _build_polyline_layer( + _read_parquet_records(zf, "clipgt/wait_line.parquet"), + payload_key="wait_line", + points_key="location", + layer_name="wait_lines", + raster=raster, + width_px=raster.line_width_px, + ) + ) + if has("clipgt/pole.parquet"): + line_layers.append( + _build_polyline_layer( + _read_parquet_records(zf, "clipgt/pole.parquet"), + payload_key="pole", + points_key="location", + layer_name="poles", + raster=raster, + width_px=raster.pole_width_px, + ) + ) + if has("clipgt/traffic_sign.parquet"): + triangle_layers.append( + _build_sign_face_layer( + _read_parquet_records(zf, "clipgt/traffic_sign.parquet"), + "traffic_sign", + "traffic_signs", + ) + ) + if has("clipgt/traffic_light.parquet"): + line_layers.append( + _build_cuboid_layer( + _read_parquet_records(zf, "clipgt/traffic_light.parquet"), + "traffic_light", + "traffic_lights", + raster.line_width_px, + ) + ) + + if has("clipgt/crosswalk.parquet"): + polygon_layers.append( + _build_polygon_loop_layer( + _read_parquet_records(zf, "clipgt/crosswalk.parquet"), + "crosswalk", + "location", + "crosswalks", + raster, + ) + ) + if has("clipgt/road_marking.parquet"): + polygon_layers.append( + _build_polygon_loop_layer( + _read_parquet_records(zf, "clipgt/road_marking.parquet"), + "road_marking", + "location", + "road_markings", + raster, + ) + ) + if has("clipgt/intersection_area.parquet"): + polygon_layers.append( + _build_polygon_loop_layer( + _read_parquet_records(zf, "clipgt/intersection_area.parquet"), + "intersection_area", + "location", + "intersection_areas", + raster, + ) + ) + if has("clipgt/road_island.parquet"): + polygon_layers.append( + _build_polygon_loop_layer( + _read_parquet_records(zf, "clipgt/road_island.parquet"), + "road_island", + "location", + "road_islands", + raster, + ) + ) + + return tuple(line_layers), tuple(triangle_layers), tuple(polygon_layers) + + +def _load_ground_mesh( + zf: zipfile.ZipFile, +) -> tuple[np.ndarray, np.ndarray] | tuple[None, None]: + """Read ``mesh_ground.ply`` from the USDZ archive if present. + + Returns ``(vertices, faces)`` for use by + :class:`omnidreams_game_engine.simulation.ground_snap.GroundSnapper`, or ``(None, None)`` when the + archive ships no ground mesh (e.g. legacy fixtures), in which case + ground-snap silently no-ops at runtime. + """ + if _GROUND_MESH_NAME not in zf.namelist(): + return None, None + try: + vertices, faces = load_mesh_vf(zf.read(_GROUND_MESH_NAME)) + except (ValueError, TypeError) as exc: + logger.info( + f"[scene_loader] failed to parse {_GROUND_MESH_NAME}: {exc}; " + "ground-snap will no-op for this scene.", + ) + return None, None + return vertices.astype(np.float32), faces.astype(np.int32) + + +def load_scene_bundle( + scene_path: Path, + camera_name: str, + variant: str, + prompt_override: str | None, + raster: RasterConfig, +) -> SceneDefinition: + # Swap to the requested variant's sibling archive when present; legacy + # single-archive scenes resolve to the same path (variant picked in-zip). + scene_path = resolve_variant_archive(Path(scene_path), variant) + with zipfile.ZipFile(scene_path, "r") as zf: + metadata = _read_yaml(zf, "metadata.yaml") + camera = _load_camera_calibration(zf, camera_name) + initial_pose, initial_timestamp, initial_yaw, initial_speed = ( + _load_initial_state(zf) + ) + initial_rgb = _load_initial_image(zf, camera_name, variant, raster) + prompt = _load_prompt(zf, variant, prompt_override) + line_layers, triangle_layers, polygon_layers = _load_map_layers(zf, raster) + ground_mesh_vertices, ground_mesh_faces = _load_ground_mesh(zf) + game_map = ( + game_map_from_dict(json.loads(zf.read("game_map.json"))) + if "game_map.json" in zf.namelist() + else None + ) + + return SceneDefinition( + scene_path=scene_path, + scene_id=str(metadata.get("scene_id", scene_path.stem)), + metadata=metadata, + selected_camera=camera, + initial_rig_to_world=initial_pose, + initial_timestamp_us=initial_timestamp, + initial_yaw_rad=initial_yaw, + initial_speed_mps=initial_speed, + initial_rgb=initial_rgb, + prompt=prompt, + line_layers=line_layers, + triangle_layers=triangle_layers, + polygon_layers=polygon_layers, + ground_mesh_vertices=ground_mesh_vertices, + ground_mesh_faces=ground_mesh_faces, + game_map=game_map, + ) + + +def reseed_scene_bundle( + bundle: SceneDefinition, + scene_path: Path, + camera_name: str, + variant: str, + prompt_override: str | None, + raster: RasterConfig, +) -> SceneDefinition: + """Re-seed an already-parsed ``bundle`` for a different weather variant. + + Variants share all geometry; only the initial frame and prompt differ, so + this reads just those from the variant's archive and reuses the rest, + skipping the full re-parse and bounds/snapper rebuild. + """ + scene_path = resolve_variant_archive(Path(scene_path), variant) + with zipfile.ZipFile(scene_path, "r") as zf: + initial_rgb = _load_initial_image(zf, camera_name, variant, raster) + prompt = _load_prompt(zf, variant, prompt_override) + return replace( + bundle, scene_path=scene_path, initial_rgb=initial_rgb, prompt=prompt + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/screenshot.jpg b/apps/omnidreams_game_engine/omnidreams_game_engine/screenshot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0cda39e86f2f12958f6a48d0c2cc5b073ca3c174 GIT binary patch literal 223728 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 @ZfkfZH}5n<*m?G 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 str: + """Map scene labels to the simulation categories owned by interactive-drive.""" + normalized = object_type.strip().lower() + # Check compound and overlapping labels before their more general forms. + if "trailer" in normalized: + return "trailer" + if "motor" in normalized: + return "motorcycle" + if "bus" in normalized: + return "bus" + if "truck" in normalized: + return "truck" + if "pedestrian" in normalized or "person" in normalized: + return "pedestrian" + if "cycl" in normalized or "bicycle" in normalized: + return "cyclist" + if "car" in normalized or "vehicle" in normalized: + return "car" + return "other" + + +def _vector3(value: FloatArray, name: str) -> FloatArray: + result = np.asarray(value, dtype=np.float32) + if result.shape != (3,): + raise ValueError(f"{name} must have shape (3,), got {result.shape}") + return result.copy() + + +@dataclass +class TransformComponent: + """World-space transform component.""" + + position_m: FloatArray + """World position in metres.""" + + orientation_xyzw: FloatArray + """Normalized world orientation quaternion in ``xyzw`` order.""" + + def __post_init__(self) -> None: + self.position_m = _vector3(self.position_m, "position_m") + orientation = np.asarray(self.orientation_xyzw, dtype=np.float32) + if orientation.shape != (4,): + raise ValueError( + f"orientation_xyzw must have shape (4,), got {orientation.shape}" + ) + norm = float(np.linalg.norm(orientation)) + if norm <= 1e-8: + raise ValueError("orientation_xyzw must have non-zero norm") + self.orientation_xyzw = (orientation / norm).astype(np.float32) + + +@dataclass +class RigidBodyComponent: + """Linear and angular rigid-body state in SI units.""" + + mass_kg: float + """Positive body mass; trucks use a larger value than cars.""" + + linear_velocity_mps: FloatArray + """World linear velocity.""" + + angular_velocity_radps: FloatArray + """Body angular velocity around world axes.""" + + restitution: float = 0.2 + """Normal collision bounciness in the closed interval ``[0, 1]``.""" + + friction: float = 0.7 + """Tangential collision friction coefficient.""" + + dynamic: bool = True + """Whether impulses and integration can move the body.""" + + def __post_init__(self) -> None: + if self.mass_kg <= 0.0: + raise ValueError("mass_kg must be positive") + self.linear_velocity_mps = _vector3( + self.linear_velocity_mps, "linear_velocity_mps" + ) + self.angular_velocity_radps = _vector3( + self.angular_velocity_radps, "angular_velocity_radps" + ) + self.restitution = float(np.clip(self.restitution, 0.0, 1.0)) + self.friction = max(0.0, float(self.friction)) + + +@dataclass(frozen=True) +class BoxColliderComponent: + """Oriented box collider component.""" + + half_extents_m: tuple[float, float, float] + """Positive half dimensions ordered as length, width, and height.""" + + is_trigger: bool = False + """Whether overlap emits events without applying impulses.""" + + def __post_init__(self) -> None: + if len(self.half_extents_m) != 3 or any(v <= 0.0 for v in self.half_extents_m): + raise ValueError("half_extents_m must contain three positive values") + + +@dataclass(frozen=True) +class VehicleDynamicsComponent: + """Four-wheel game-car layout, drivetrain, and tire parameters.""" + + wheel_base_m: float + track_width_m: float + front_axle_to_cg_m: float + rear_axle_to_cg_m: float + center_of_mass_height_m: float + wheel_radius_m: float + wheel_width_m: float + max_engine_force_n: float + max_brake_force_n: float + max_lateral_accel_mps2: float + cornering_stiffness_n_per_rad: float + yaw_inertia_kg_m2: float + tire_grip: float + rolling_resistance: float + aero_drag_coefficient: float + + @property + def wheel_offsets_m(self) -> tuple[tuple[float, float, float], ...]: + """Return front-left, front-right, rear-left, and rear-right offsets.""" + half_track = self.track_width_m * 0.5 + wheel_z = -self.center_of_mass_height_m + self.wheel_radius_m + return ( + (self.front_axle_to_cg_m, half_track, wheel_z), + (self.front_axle_to_cg_m, -half_track, wheel_z), + (-self.rear_axle_to_cg_m, half_track, wheel_z), + (-self.rear_axle_to_cg_m, -half_track, wheel_z), + ) + + +@dataclass(frozen=True) +class SuspensionComponent: + """Visual spring-damper suspension parameters.""" + + stiffness: float + damping: float + travel_m: float + visual_gain: float + max_roll_rad: float + max_pitch_rad: float + + +@dataclass +class GameEntity: + """Entity composed from engine-neutral transform and physics components.""" + + entity_id: str + transform: TransformComponent + rigid_body: RigidBodyComponent + collider: BoxColliderComponent + vehicle: VehicleDynamicsComponent | None = None + suspension: SuspensionComponent | None = None + object_type: str = "Car" + detached_from_track: bool = False + + def to_game_engine_dict(self) -> dict[str, Any]: + """Return JSON-compatible component data for an external engine.""" + components: dict[str, Any] = { + "transform": { + "position_m": self.transform.position_m.tolist(), + "orientation_xyzw": self.transform.orientation_xyzw.tolist(), + }, + "rigid_body": { + "mass_kg": self.rigid_body.mass_kg, + "linear_velocity_mps": self.rigid_body.linear_velocity_mps.tolist(), + "angular_velocity_radps": self.rigid_body.angular_velocity_radps.tolist(), + "restitution": self.rigid_body.restitution, + "friction": self.rigid_body.friction, + "dynamic": self.rigid_body.dynamic, + }, + "box_collider": { + "half_extents_m": list(self.collider.half_extents_m), + "is_trigger": self.collider.is_trigger, + }, + } + if self.vehicle is not None: + components["vehicle_dynamics"] = { + "wheel_base_m": self.vehicle.wheel_base_m, + "track_width_m": self.vehicle.track_width_m, + "front_axle_to_cg_m": self.vehicle.front_axle_to_cg_m, + "rear_axle_to_cg_m": self.vehicle.rear_axle_to_cg_m, + "center_of_mass_height_m": self.vehicle.center_of_mass_height_m, + "wheel_radius_m": self.vehicle.wheel_radius_m, + "wheel_width_m": self.vehicle.wheel_width_m, + "wheel_offsets_m": self.vehicle.wheel_offsets_m, + "max_engine_force_n": self.vehicle.max_engine_force_n, + "max_brake_force_n": self.vehicle.max_brake_force_n, + "max_lateral_accel_mps2": self.vehicle.max_lateral_accel_mps2, + "cornering_stiffness_n_per_rad": ( + self.vehicle.cornering_stiffness_n_per_rad + ), + "yaw_inertia_kg_m2": self.vehicle.yaw_inertia_kg_m2, + "tire_grip": self.vehicle.tire_grip, + "rolling_resistance": self.vehicle.rolling_resistance, + "aero_drag_coefficient": self.vehicle.aero_drag_coefficient, + } + if self.suspension is not None: + components["suspension"] = { + "stiffness": self.suspension.stiffness, + "damping": self.suspension.damping, + "travel_m": self.suspension.travel_m, + "visual_gain": self.suspension.visual_gain, + "max_roll_rad": self.suspension.max_roll_rad, + "max_pitch_rad": self.suspension.max_pitch_rad, + } + return { + "entity_id": self.entity_id, + "object_type": self.object_type, + "components": components, + } + + +def rigid_body_model_for_object( + object_type: str, + dimensions_lwh: npt.ArrayLike, + *, + restitution: float = 0.22, + friction: float = 0.65, +) -> RigidBodyModel: + """Build a category-weighted PhysX body from an interactive scene label.""" + dimensions = np.asarray(dimensions_lwh, dtype=np.float32) + if dimensions.shape != (3,) or bool(np.any(dimensions <= 0.0)): + raise ValueError("dimensions_lwh must contain three positive values") + kind = canonical_object_type(object_type) + mass_kg = _FIXED_OBJECT_MASS_KG[kind] + dynamics = vehicle_dynamics_for_object(object_type, dimensions, mass_kg) + return RigidBodyModel( + mass_kg=mass_kg, + half_extents_m=( + float(dimensions[0]) * 0.5, + float(dimensions[1]) * 0.5, + float(dimensions[2]) * 0.5, + ), + restitution=float(np.clip(restitution, 0.0, 1.0)), + friction=max(0.0, float(friction)), + vehicle=( + None + if dynamics is None + else _physx_vehicle_model( + dynamics, + dimensions, + mass_kg, + suspension_travel_m=0.22 if kind == "car" else 0.32, + longitudinal_collision_padding_m=( + _VEHICLE_LONGITUDINAL_COLLISION_PADDING_M + ), + lateral_collision_padding_m=_VEHICLE_LATERAL_COLLISION_PADDING_M, + ) + ), + ) + + +def _vehicle_dynamics( + *, + kind: str, + dimensions_lwh: npt.ArrayLike, + mass_kg: float, + max_accel_mps2: float, + max_brake_mps2: float, + max_lateral_accel_mps2: float, + tire_grip: float, + rolling_resistance: float, + aero_drag_coefficient: float, + wheel_base_m: float | None = None, +) -> VehicleDynamicsComponent | None: + if kind not in {"car", "truck", "bus", "trailer"}: + return None + length, width, height = ( + float(value) for value in np.asarray(dimensions_lwh, dtype=np.float32) + ) + wheel_base = ( + float(wheel_base_m) + if wheel_base_m is not None + else float(np.clip(length * 0.58, 1.8, max(1.8, length - 0.8))) + ) + front_weight_fraction = 0.55 if kind == "car" else 0.52 + rear_axle_to_cg = wheel_base * front_weight_fraction + front_axle_to_cg = wheel_base - rear_axle_to_cg + track_width = max(0.9, width * 0.78) + wheel_radius = float(np.clip(height * 0.2125, 0.30, 0.58)) + wheel_width = 0.24 if kind == "car" else 0.34 + yaw_inertia = mass_kg * (length * length + width * width) / 12.0 + return VehicleDynamicsComponent( + wheel_base_m=wheel_base, + track_width_m=track_width, + front_axle_to_cg_m=front_axle_to_cg, + rear_axle_to_cg_m=rear_axle_to_cg, + center_of_mass_height_m=max(wheel_radius, height * 0.34), + wheel_radius_m=wheel_radius, + wheel_width_m=wheel_width, + max_engine_force_n=mass_kg * max_accel_mps2, + max_brake_force_n=mass_kg * max_brake_mps2, + max_lateral_accel_mps2=max_lateral_accel_mps2, + cornering_stiffness_n_per_rad=mass_kg * 9.81 * 5.9, + yaw_inertia_kg_m2=yaw_inertia, + tire_grip=tire_grip, + rolling_resistance=rolling_resistance, + aero_drag_coefficient=aero_drag_coefficient, + ) + + +def _physx_vehicle_model( + dynamics: VehicleDynamicsComponent, + dimensions_lwh: npt.ArrayLike, + mass_kg: float, + *, + suspension_travel_m: float, + longitudinal_collision_padding_m: float | None = None, + lateral_collision_padding_m: float | None = None, +) -> VehicleModel: + """Build a dimensioned four-wheel model at its static ride height.""" + length, width, height = ( + float(value) for value in np.asarray(dimensions_lwh, dtype=np.float32) + ) + sprung_mass_kg = mass_kg * 0.90 + natural_frequency_hz = 1.5 if mass_kg < 4_000.0 else 1.2 + angular_frequency = 2.0 * math.pi * natural_frequency_hz + spring_rate = sprung_mass_kg * angular_frequency * angular_frequency / 4.0 + corner_mass_kg = sprung_mass_kg / 4.0 + damper_rate = 2.0 * 0.70 * math.sqrt(spring_rate * corner_mass_kg) + static_compression = mass_kg * 9.81 / (4.0 * spring_rate) + rest_length = max( + suspension_travel_m, + static_compression + suspension_travel_m * 0.35, + ) + + half_track = dynamics.track_width_m * 0.5 + wheel_center_z = -height * 0.5 + dynamics.wheel_radius_m + mount_z = wheel_center_z + rest_length - static_compression + mounts = ( + (dynamics.front_axle_to_cg_m, half_track, mount_z), + (dynamics.front_axle_to_cg_m, -half_track, mount_z), + (-dynamics.rear_axle_to_cg_m, half_track, mount_z), + (-dynamics.rear_axle_to_cg_m, -half_track, mount_z), + ) + + ground_clearance = float(np.clip(height * 0.10, 0.12, 0.35)) + chassis_bottom = -height * 0.5 + ground_clearance + chassis_top = height * 0.45 + chassis_center_z = (chassis_bottom + chassis_top) * 0.5 + chassis_half_length = ( + length * 0.46 + if longitudinal_collision_padding_m is None + else length * 0.5 + longitudinal_collision_padding_m + ) + chassis_half_width = ( + width * 0.44 + if lateral_collision_padding_m is None + else width * 0.5 + lateral_collision_padding_m + ) + return VehicleModel( + chassis_half_extents_m=( + chassis_half_length, + chassis_half_width, + (chassis_top - chassis_bottom) * 0.5, + ), + chassis_offset_m=(0.0, 0.0, chassis_center_z), + suspension_mounts_m=mounts, + wheel_radius_m=dynamics.wheel_radius_m, + suspension_rest_length_m=rest_length, + suspension_max_compression_m=suspension_travel_m, + spring_stiffness_n_per_m=spring_rate, + damper_rate_n_s_per_m=damper_rate, + tire_friction=dynamics.tire_grip, + cornering_stiffness_n_per_rad=dynamics.cornering_stiffness_n_per_rad, + rolling_resistance=dynamics.rolling_resistance, + max_engine_force_n=dynamics.max_engine_force_n, + max_brake_force_n=dynamics.max_brake_force_n, + ) + + +def vehicle_dynamics_for_object( + object_type: str, + dimensions_lwh: npt.ArrayLike, + mass_kg: float, +) -> VehicleDynamicsComponent | None: + """Build the classified four-wheel design for a recorded scene object.""" + kind = canonical_object_type(object_type) + max_accel = 3.5 if kind == "car" else 2.0 + max_lateral = 5.5 if kind == "car" else 3.6 + return _vehicle_dynamics( + kind=kind, + dimensions_lwh=dimensions_lwh, + mass_kg=mass_kg, + max_accel_mps2=max_accel, + max_brake_mps2=6.0, + max_lateral_accel_mps2=max_lateral, + tire_grip=0.92 if kind == "car" else 0.82, + rolling_resistance=0.015, + aero_drag_coefficient=0.42 if kind == "car" else 0.70, + ) + + +def vehicle_dynamics_from_config(config: VehicleConfig) -> VehicleDynamicsComponent: + """Build the ego four-wheel design used by the game physics solver.""" + result = _vehicle_dynamics( + kind="car", + dimensions_lwh=( + config.aabb_length_m, + config.aabb_width_m, + config.aabb_height_m, + ), + mass_kg=config.mass_kg, + max_accel_mps2=config.max_accel_mps2, + max_brake_mps2=config.max_brake_mps2, + max_lateral_accel_mps2=config.max_lateral_accel_mps2, + tire_grip=config.tire_grip, + rolling_resistance=config.rolling_resistance, + aero_drag_coefficient=config.aero_drag_coefficient, + wheel_base_m=config.wheel_base_m, + ) + assert result is not None + return result + + +def rigid_body_model_from_vehicle_config(config: VehicleConfig) -> RigidBodyModel: + """Build the ego chassis, wheels, and suspension from its runtime config.""" + dimensions = ( + config.aabb_length_m, + config.aabb_width_m, + config.aabb_height_m, + ) + dynamics = vehicle_dynamics_from_config(config) + return RigidBodyModel( + mass_kg=config.mass_kg, + half_extents_m=tuple(value * 0.5 for value in dimensions), + restitution=config.collision_restitution, + friction=config.collision_friction, + vehicle=_physx_vehicle_model( + dynamics, + dimensions, + config.mass_kg, + suspension_travel_m=config.suspension_travel_m, + ), + ) + + +def suspension_for_object(object_type: str) -> SuspensionComponent | None: + """Return game-car suspension only for self-propelled four-wheel classes.""" + if canonical_object_type(object_type) not in {"car", "truck", "bus", "trailer"}: + return None + return SuspensionComponent( + stiffness=42.0, + damping=9.0, + travel_m=0.22, + visual_gain=0.65, + max_roll_rad=0.16, + max_pitch_rad=0.10, + ) + + +def game_entity_from_vehicle_state( + state: VehicleState, + config: VehicleConfig, + *, + entity_id: str = "ego", +) -> GameEntity: + """Build an engine-neutral ego entity from authoritative simulation state.""" + half_yaw = state.yaw_rad * 0.5 + quaternion = np.asarray( + [0.0, 0.0, math.sin(half_yaw), math.cos(half_yaw)], dtype=np.float32 + ) + velocity = np.asarray( + [ + state.velocity_x_mps + if state.velocity_x_mps is not None + else math.cos(state.yaw_rad) * state.speed_mps, + state.velocity_y_mps + if state.velocity_y_mps is not None + else math.sin(state.yaw_rad) * state.speed_mps, + 0.0, + ], + dtype=np.float32, + ) + dynamics = vehicle_dynamics_from_config(config) + return GameEntity( + entity_id=entity_id, + object_type="Car", + transform=TransformComponent( + np.asarray([state.x_m, state.y_m, state.z_m], dtype=np.float32), + quaternion, + ), + rigid_body=RigidBodyComponent( + mass_kg=config.mass_kg, + linear_velocity_mps=velocity, + angular_velocity_radps=np.asarray( + [0.0, 0.0, state.yaw_rate_radps], dtype=np.float32 + ), + restitution=config.collision_restitution, + friction=config.collision_friction, + ), + collider=BoxColliderComponent( + ( + config.aabb_length_m * 0.5, + config.aabb_width_m * 0.5, + config.aabb_height_m * 0.5, + ) + ), + vehicle=dynamics, + suspension=SuspensionComponent( + stiffness=config.suspension_stiffness, + damping=config.suspension_damping, + travel_m=config.suspension_travel_m, + visual_gain=config.suspension_visual_gain, + max_roll_rad=config.max_body_roll_rad, + max_pitch_rad=config.max_body_pitch_rad, + ), + detached_from_track=state.ragdoll_active, + ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py new file mode 100644 index 000000000..06d743688 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py @@ -0,0 +1,573 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import math +import time +from collections.abc import Callable, Sequence + +import numpy as np +from loguru import logger + +from omnidreams_game_engine.config import ChunkConfig, VehicleConfig +from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state +from omnidreams_game_engine.simulation.components import ( + GameEntity, + game_entity_from_vehicle_state, + vehicle_dynamics_from_config, +) +from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld +from omnidreams_game_engine.simulation.ground_snap import GroundSnapper +from omnidreams_game_engine.types import ( + DriverCommand, + PhysXChunkTimings, + SceneDefinition, + TrajectoryChunk, + VehicleState, +) + +PhysicsActorSamples = tuple[tuple[str, np.ndarray, np.ndarray, bool], ...] +PhysicsStepFn = Callable[ + [GamePhysicsWorld, VehicleState, DriverCommand, int, float], + tuple[VehicleState, PhysicsActorSamples], +] + + +def step_physics_world( + physics_world: GamePhysicsWorld, + state: VehicleState, + command: DriverCommand, + timestamp_us: int, + dt_s: float, +) -> tuple[VehicleState, PhysicsActorSamples]: + del command + return physics_world.step(state, timestamp_us, dt_s) + + +def _move_towards(current: float, target: float, max_delta: float) -> float: + if current < target: + return min(current + max_delta, target) + return max(current - max_delta, target) + + +def integrate_vehicle( + state: VehicleState, + command: DriverCommand, + dt_s: float, + vehicle: VehicleConfig, +) -> VehicleState: + steer_rad = state.steer_rad + if command.steer_is_direct: + max_steer = 0.4 if command.manual_control else vehicle.max_steer_rad + steer_rad = command.steer * max_steer + elif abs(command.steer) > 1e-5: + steer_rad += command.steer * vehicle.steer_rate_rad_per_s * dt_s + else: + steer_rad = _move_towards( + steer_rad, 0.0, vehicle.steer_return_rate_rad_per_s * dt_s + ) + steer_rad = float(np.clip(steer_rad, -vehicle.max_steer_rad, vehicle.max_steer_rad)) + + speed = state.speed_mps + if command.stop: + speed = 0.0 + elif command.manual_control: + intended_direction = -1.0 if command.reverse else 1.0 + # Brake wins over throttle: holding both pedals bleeds speed toward a + # stop, matching real cars and the demo.py target-speed integrators. + if command.brake > 0.01: + decel = 12.0 * command.brake * dt_s + if speed > 0: + speed = max(0.0, speed - decel) + elif speed < 0: + speed = min(0.0, speed + decel) + elif command.throttle > 0.01: + accel = 2.0 * command.throttle * dt_s + if intended_direction < 0.0: + speed -= accel + elif vehicle.speed_limit_enabled: + max_speed = vehicle.max_speed_mps + current = abs(speed) + high_speed_knee = max_speed * 0.62 + if current < high_speed_knee: + taper = max(0.2, 1.0 - (current / high_speed_knee) ** 2 * 0.5) + else: + excess = (current - high_speed_knee) / max( + 1e-6, max_speed - high_speed_knee + ) + taper = max(0.05, 0.5 * (1.0 - excess) ** 3) + speed += accel * taper + else: + speed += accel + else: + if speed > 0.0: + speed = max(0.0, speed - 0.5 * dt_s) + elif speed < 0.0: + speed = min(0.0, speed + 0.5 * dt_s) + if vehicle.speed_limit_enabled: + speed = float( + np.clip(speed, -vehicle.max_reverse_speed_mps, vehicle.max_speed_mps) + ) + else: + intended_direction = -1.0 if command.reverse else 1.0 + accel = command.throttle * vehicle.max_accel_mps2 * dt_s + brake = command.brake * vehicle.max_brake_mps2 * dt_s + if brake > 0.0: + speed = _move_towards(speed, 0.0, brake) + elif accel > 0.0: + speed += intended_direction * accel + else: + if speed > 0.0: + speed = max(0.0, speed - vehicle.drag_mps2 * dt_s) + else: + speed = min(0.0, speed + vehicle.drag_mps2 * dt_s) + if vehicle.speed_limit_enabled: + speed = float( + np.clip(speed, -vehicle.max_reverse_speed_mps, vehicle.max_speed_mps) + ) + + commanded_yaw_rate = 0.0 + if abs(steer_rad) > 1e-5 and abs(speed) > 1e-5: + commanded_yaw_rate = speed / vehicle.wheel_base_m * math.tan(steer_rad) + # A fixed steering angle becomes unrealistically aggressive as speed + # rises because bicycle-model lateral acceleration scales with v^2. + # Limit yaw rate by the configured grip envelope while preserving the + # full steering response at parking and neighbourhood speeds. + max_yaw_rate = vehicle.max_lateral_accel_mps2 / abs(speed) + commanded_yaw_rate = float( + np.clip(commanded_yaw_rate, -max_yaw_rate, max_yaw_rate) + ) + + design = vehicle_dynamics_from_config(vehicle) + forward = np.asarray( + [math.cos(state.yaw_rad), math.sin(state.yaw_rad)], dtype=np.float32 + ) + left = np.asarray([-forward[1], forward[0]], dtype=np.float32) + velocity = np.asarray( + [ + state.velocity_x_mps + if state.velocity_x_mps is not None + else forward[0] * state.speed_mps, + state.velocity_y_mps + if state.velocity_y_mps is not None + else forward[1] * state.speed_mps, + ], + dtype=np.float32, + ) + if state.ragdoll_active: + lateral_speed = float(np.dot(velocity, left)) + grip = float(np.clip(vehicle.tire_grip * dt_s * 4.0, 0.0, 1.0)) + velocity -= left * lateral_speed * grip + longitudinal_speed = float(np.dot(velocity, forward)) + velocity += forward * (speed - longitudinal_speed) + yaw_rate = state.yaw_rate_radps * max( + 0.0, 1.0 - 1.8 * dt_s + ) + commanded_yaw_rate * min(1.0, 2.5 * dt_s) + else: + speed_abs = abs(speed) + if speed_abs < 0.75 or speed < 0.0: + response = 1.0 - math.exp(-8.0 * dt_s) + yaw_rate = ( + state.yaw_rate_radps + + (commanded_yaw_rate - state.yaw_rate_radps) * response + ) + # The state pose is at the vehicle CG, not at the rear axle. In a + # no-slip bicycle turn the CG therefore has lateral velocity + # ``rear_axle_to_cg * yaw_rate``. Keeping it here also makes the + # transition into the dynamic tire model continuous. + lateral_speed = design.rear_axle_to_cg_m * yaw_rate + else: + lateral_speed = float(np.dot(velocity, left)) + front_slip = steer_rad - math.atan2( + lateral_speed + design.front_axle_to_cg_m * state.yaw_rate_radps, + speed_abs, + ) + rear_slip = -math.atan2( + lateral_speed - design.rear_axle_to_cg_m * state.yaw_rate_radps, + speed_abs, + ) + front_load_fraction = design.rear_axle_to_cg_m / design.wheel_base_m + rear_load_fraction = 1.0 - front_load_fraction + front_force = float( + np.clip( + design.cornering_stiffness_n_per_rad + * front_load_fraction + * front_slip, + -vehicle.mass_kg + * front_load_fraction + * design.max_lateral_accel_mps2, + vehicle.mass_kg + * front_load_fraction + * design.max_lateral_accel_mps2, + ) + ) + rear_force = float( + np.clip( + design.cornering_stiffness_n_per_rad + * rear_load_fraction + * rear_slip, + -vehicle.mass_kg + * rear_load_fraction + * design.max_lateral_accel_mps2, + vehicle.mass_kg + * rear_load_fraction + * design.max_lateral_accel_mps2, + ) + ) + steered_front_force = front_force * math.cos(steer_rad) + lateral_accel = ( + steered_front_force + rear_force + ) / vehicle.mass_kg - state.yaw_rate_radps * speed + yaw_accel = ( + design.front_axle_to_cg_m * steered_front_force + - design.rear_axle_to_cg_m * rear_force + ) / design.yaw_inertia_kg_m2 + lateral_speed += lateral_accel * dt_s + yaw_rate = state.yaw_rate_radps + yaw_accel * dt_s + max_yaw_rate = design.max_lateral_accel_mps2 / speed_abs + yaw_rate = float(np.clip(yaw_rate, -max_yaw_rate, max_yaw_rate)) + + yaw = state.yaw_rad + yaw_rate * dt_s + if not state.ragdoll_active: + new_forward = np.asarray([math.cos(yaw), math.sin(yaw)], dtype=np.float32) + new_left = np.asarray([-new_forward[1], new_forward[0]], dtype=np.float32) + velocity = new_forward * np.float32(speed) + new_left * np.float32( + lateral_speed + ) + x_m = state.x_m + float(velocity[0]) * dt_s + y_m = state.y_m + float(velocity[1]) * dt_s + + longitudinal_accel = (speed - state.speed_mps) / max(dt_s, 1e-6) + lateral_accel = speed * yaw_rate + target_pitch = float( + np.clip( + -longitudinal_accel + / 9.81 + * vehicle.suspension_visual_gain + * vehicle.max_body_pitch_rad, + -vehicle.max_body_pitch_rad, + vehicle.max_body_pitch_rad, + ) + ) + target_roll = float( + np.clip( + -lateral_accel + / 9.81 + * vehicle.suspension_visual_gain + * vehicle.max_body_roll_rad, + -vehicle.max_body_roll_rad, + vehicle.max_body_roll_rad, + ) + ) + pitch_accel = ( + vehicle.suspension_stiffness * (target_pitch - state.suspension_pitch_rad) + - vehicle.suspension_damping * state.suspension_pitch_rate_radps + ) + roll_accel = ( + vehicle.suspension_stiffness * (target_roll - state.suspension_roll_rad) + - vehicle.suspension_damping * state.suspension_roll_rate_radps + ) + pitch_rate = state.suspension_pitch_rate_radps + pitch_accel * dt_s + roll_rate = state.suspension_roll_rate_radps + roll_accel * dt_s + suspension_pitch = float( + np.clip( + state.suspension_pitch_rad + pitch_rate * dt_s, + -vehicle.max_body_pitch_rad, + vehicle.max_body_pitch_rad, + ) + ) + suspension_roll = float( + np.clip( + state.suspension_roll_rad + roll_rate * dt_s, + -vehicle.max_body_roll_rad, + vehicle.max_body_roll_rad, + ) + ) + + return VehicleState( + x_m=x_m, + y_m=y_m, + z_m=state.z_m, + yaw_rad=yaw, + speed_mps=speed, + steer_rad=steer_rad, + pitch_rad=state.pitch_rad, + roll_rad=state.roll_rad, + velocity_x_mps=float(velocity[0]), + velocity_y_mps=float(velocity[1]), + yaw_rate_radps=yaw_rate, + suspension_pitch_rad=suspension_pitch, + suspension_roll_rad=suspension_roll, + suspension_pitch_rate_radps=pitch_rate, + suspension_roll_rate_radps=roll_rate, + ragdoll_active=state.ragdoll_active, + ) + + +def sample_chunk_trajectory( + start_state: VehicleState, + start_timestamp_us: int, + commands: Sequence[DriverCommand], + chunk_size: int, + chunk_config: ChunkConfig, + vehicle_config: VehicleConfig, + ground_snapper: GroundSnapper | None, + physics_world: GamePhysicsWorld | None = None, + capture_physics_debug: bool = False, + integrate_fn: Callable[ + [VehicleState, DriverCommand, float, VehicleConfig], VehicleState + ] = integrate_vehicle, + physics_step_fn: PhysicsStepFn = step_physics_world, + include_start_state: bool = False, +) -> TrajectoryChunk: + if len(commands) != chunk_size: + raise ValueError( + f"commands must match chunk_size; got {len(commands)} for {chunk_size}" + ) + timestamps = np.array( + [ + start_timestamp_us + frame_idx * chunk_config.frame_interval_us + for frame_idx in range(chunk_size) + ], + dtype=np.int64, + ) + poses = np.zeros((chunk_size, 4, 4), dtype=np.float32) + + state = VehicleState(**start_state.__dict__) + vehicle_states: list[VehicleState] = [] + actor_samples: list[tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]] = [] + physics_debug_frames = [] + physx_elapsed_s = 0.0 + physx_sync_s = 0.0 + actor_update_ms = 0.0 + solver_ms = 0.0 + readback_ms = 0.0 + bridge_ms = 0.0 + max_visible_actors = 0 + max_detached_actors = 0 + actor_collision_detected = False + actor_collision_frame_index: int | None = None + static_collision_detected = False + static_collision_frame_index: int | None = None + if physics_world is not None: + physx_started_at = time.perf_counter() + physics_world.synchronize_window( + np.asarray([state.x_m, state.y_m], dtype=np.float32), + timestamp_us=start_timestamp_us, + ) + sync_elapsed_s = time.perf_counter() - physx_started_at + physx_sync_s += sync_elapsed_s + physx_elapsed_s += sync_elapsed_s + for frame_idx in range(chunk_size): + command = commands[frame_idx] + use_start_state = include_start_state and frame_idx == 0 + if not use_start_state: + state = integrate_fn( + state, command, chunk_config.frame_interval_s, vehicle_config + ) + if physics_world is not None and use_start_state: + frame_actor_samples = tuple( + ( + entity.entity_id, + entity.transform.position_m.copy(), + entity.transform.orientation_xyzw.copy(), + entity.detached_from_track, + ) + for entity in physics_world.entities + ) + actor_samples.append(frame_actor_samples) + if capture_physics_debug: + physics_debug_frames.append(physics_world.debug_frame(state)) + elif physics_world is not None: + physx_started_at = time.perf_counter() + state, frame_actor_samples = physics_step_fn( + physics_world, + state, + command, + int(timestamps[frame_idx]), + chunk_config.frame_interval_s, + ) + actor_collision_this_frame = bool( + getattr(physics_world, "last_step_actor_collision", False) + ) + actor_collision_detected |= actor_collision_this_frame + if actor_collision_this_frame and actor_collision_frame_index is None: + actor_collision_frame_index = frame_idx + static_collision_this_frame = bool( + getattr(physics_world, "last_step_static_barrier_impact", False) + ) + static_collision_detected |= static_collision_this_frame + if static_collision_this_frame and static_collision_frame_index is None: + static_collision_frame_index = frame_idx + physx_elapsed_s += time.perf_counter() - physx_started_at + step_timings = getattr(physics_world, "last_step_timings", None) + if step_timings is not None: + actor_update_ms += step_timings.actor_update_ms + solver_ms += step_timings.solver_ms + readback_ms += step_timings.readback_ms + bridge_ms += step_timings.bridge_ms + max_visible_actors = max( + max_visible_actors, step_timings.visible_actor_count + ) + max_detached_actors = max( + max_detached_actors, step_timings.detached_actor_count + ) + actor_samples.append(frame_actor_samples) + if capture_physics_debug: + physics_debug_frames.append(physics_world.debug_frame(state)) + if ground_snapper is not None: + state = ground_snapper.snap(state, vehicle_config) + vehicle_states.append(state) + poses[frame_idx] = rig_pose_from_vehicle_state(state) + + dynamic_actors = ( + physics_world.build_trajectories(timestamps, actor_samples) + if physics_world is not None + else () + ) + return TrajectoryChunk( + timestamps_us=timestamps, + rig_poses_world=poses, + vehicle_states=tuple(vehicle_states), + boundary_state_after_chunk=state, + applied_commands=tuple(commands), + dynamic_actors=dynamic_actors, + physics_debug_frames=tuple(physics_debug_frames), + actor_collision_detected=actor_collision_detected, + actor_collision_frame_index=actor_collision_frame_index, + static_collision_detected=static_collision_detected, + static_collision_frame_index=static_collision_frame_index, + physx_elapsed_s=physx_elapsed_s if physics_world is not None else None, + physx_timings=( + PhysXChunkTimings( + total_ms=physx_elapsed_s * 1000.0, + synchronize_ms=physx_sync_s * 1000.0, + actor_update_ms=actor_update_ms, + solver_ms=solver_ms, + readback_ms=readback_ms, + bridge_ms=bridge_ms, + step_count=chunk_size, + max_visible_actors=max_visible_actors, + max_detached_actors=max_detached_actors, + ) + if physics_world is not None + else None + ), + ) + + +def state_from_initial_pose( + initial_rig_to_world: np.ndarray, + initial_yaw_rad: float, + initial_speed_mps: float, +) -> VehicleState: + return VehicleState( + x_m=float(initial_rig_to_world[0, 3]), + y_m=float(initial_rig_to_world[1, 3]), + z_m=float(initial_rig_to_world[2, 3]), + yaw_rad=initial_yaw_rad, + speed_mps=initial_speed_mps, + steer_rad=0.0, + velocity_x_mps=math.cos(initial_yaw_rad) * initial_speed_mps, + velocity_y_mps=math.sin(initial_yaw_rad) * initial_speed_mps, + ) + + +def build_ground_snapper(scene: SceneDefinition) -> GroundSnapper | None: + if scene.ground_mesh_vertices is None or scene.ground_mesh_faces is None: + logger.info( + "[ego_vehicle_kinematics] no ground mesh in scene; z/pitch/roll will not be snapped.", + ) + return None + return GroundSnapper(scene.ground_mesh_vertices, scene.ground_mesh_faces) + + +class EgoVehicleKinematics: + def __init__( + self, + initial_state: VehicleState, + vehicle_config: VehicleConfig, + ground_snapper: GroundSnapper | None, + initial_timestamp_us: int, + scene: SceneDefinition | None = None, + integrate_fn: Callable[ + [VehicleState, DriverCommand, float, VehicleConfig], VehicleState + ] = integrate_vehicle, + physics_world_factory: Callable[ + [SceneDefinition, VehicleConfig], GamePhysicsWorld + ] = GamePhysicsWorld, + physics_step_fn: PhysicsStepFn = step_physics_world, + include_initial_state_in_first_chunk: bool = False, + ) -> None: + self._state = initial_state + self._vehicle_config = vehicle_config + self._ground_snapper = ground_snapper + self._next_timestamp_us = initial_timestamp_us + self._integrate_fn = integrate_fn + self._physics_step_fn = physics_step_fn + self._include_initial_state_in_next_chunk = bool( + include_initial_state_in_first_chunk + ) + self._physics_world = ( + physics_world_factory(scene, vehicle_config) if scene is not None else None + ) + self._capture_physics_debug = False + + def set_physx_debug_enabled(self, enabled: bool) -> None: + """Capture debug collider snapshots only for the active PhysX view.""" + self._capture_physics_debug = bool(enabled) + + @property + def current_state(self) -> VehicleState: + return self._state + + @property + def game_entities(self) -> tuple[GameEntity, ...]: + """Return the ego and actor objects as engine-neutral components.""" + actors = self._physics_world.entities if self._physics_world is not None else () + return ( + game_entity_from_vehicle_state(self._state, self._vehicle_config), + *actors, + ) + + def close(self) -> None: + """Release the Ludus PhysX scene owned by this rollout.""" + if self._physics_world is not None: + self._physics_world.close() + self._physics_world = None + + def pose_chunk( + self, + commands: Sequence[DriverCommand], + chunk_size: int, + frame_interval_s: float, + extrapolation_offset_s: float, + ) -> TrajectoryChunk: + if extrapolation_offset_s != 0.0: + raise NotImplementedError( + "Nonzero extrapolation_offset_s is not implemented in Stage 1." + ) + chunk_config = ChunkConfig( + fps=round(1.0 / frame_interval_s), + initial_chunk_frames=chunk_size, + chunk_frames=chunk_size, + ) + trajectory = sample_chunk_trajectory( + start_state=self._state, + start_timestamp_us=self._next_timestamp_us, + commands=commands, + chunk_size=chunk_size, + chunk_config=chunk_config, + vehicle_config=self._vehicle_config, + ground_snapper=self._ground_snapper, + physics_world=self._physics_world, + capture_physics_debug=self._capture_physics_debug, + integrate_fn=self._integrate_fn, + physics_step_fn=self._physics_step_fn, + include_start_state=self._include_initial_state_in_next_chunk, + ) + self._include_initial_state_in_next_chunk = False + self._state = trajectory.boundary_state_after_chunk + self._next_timestamp_us = int( + trajectory.timestamps_us[-1] + chunk_config.frame_interval_us + ) + return trajectory diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py new file mode 100644 index 000000000..00fe2f498 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py @@ -0,0 +1,973 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Interactive-drive adapter for the Ludus PhysX-first object graph.""" + +from __future__ import annotations + +import math +import time +from collections.abc import Callable +from dataclasses import replace + +import numpy as np +from loguru import logger +from ludus_renderer import ( + BodyState, + InvisibleBarrier, + PhysicsObjectGraph, + PhysXWorld, + RigidBodyModel, + SceneObject, +) + +from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.game_map.vicinity import ( + GameMapVicinity, + GameMapVicinityResolver, +) +from omnidreams_game_engine.simulation.components import ( + BoxColliderComponent, + GameEntity, + RigidBodyComponent, + TransformComponent, + rigid_body_model_for_object, + rigid_body_model_from_vehicle_config, + suspension_for_object, + vehicle_dynamics_for_object, +) +from omnidreams_game_engine.simulation.map_traffic import MapTrafficController +from omnidreams_game_engine.types import ( + DynamicActorTrajectory, + PhysicsDebugFrame, + SceneDefinition, + VehicleState, +) + +_BARRIER_LAYER_TOKENS = ("road_bound", "building", "house", "wall", "curb") +_PHYSX_RECENTER_DISTANCE_M = 32.0 +_PHYSX_TOPOLOGY_REFRESH_INTERVAL_US = 2_000_000 +"""Maximum time moving tracks can remain outside a stationary PhysX window.""" + +_PHYSX_BARRIER_SPACING_M = 2.0 +_BARRIER_CONTACT_SLOP_M = 0.05 +"""Extra proximity accepted when reinforcing a resolved barrier contact.""" + +_PHYSX_DEBUG_FORWARD_M = 125.0 +_PHYSX_DEBUG_REAR_M = 15.0 +_PHYSX_DEBUG_LATERAL_M = 100.0 +_VISUAL_FLARE_MIN_SPEED_DELTA_MPS = 5.0 * 0.44704 +_VISUAL_FLARE_COLLISION_WINDOW_US = 500_000 +_NON_EGO_MAX_DRIVE_SPEED_MPS = 15.0 * 0.44704 +_PHYSX_SIMULATION_RADIUS_M = 96.0 +"""Collision horizon around the last recenter point. + +The 32 m recenter threshold leaves at least 64 m of active topology around the +ego. Actors outside the horizon keep their recorded renderer trajectories until +they enter the PhysX window. +""" + + +def _yaw_from_quaternion_xyzw(quaternion: np.ndarray) -> float: + x, y, z, w = [float(value) for value in quaternion] + return math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + + +def _body_state_from_vehicle( + state: VehicleState, chassis_half_height_m: float +) -> BodyState: + """Map the ground-anchored rig state to PhysX's chassis-center pose.""" + half_yaw = state.yaw_rad * 0.5 + return BodyState( + position_m=np.asarray( + [state.x_m, state.y_m, state.z_m + chassis_half_height_m], + dtype=np.float32, + ), + orientation_xyzw=np.asarray( + [0.0, 0.0, math.sin(half_yaw), math.cos(half_yaw)], dtype=np.float32 + ), + linear_velocity_mps=np.asarray( + [ + state.velocity_x_mps + if state.velocity_x_mps is not None + else math.cos(state.yaw_rad) * state.speed_mps, + state.velocity_y_mps + if state.velocity_y_mps is not None + else math.sin(state.yaw_rad) * state.speed_mps, + 0.0, + ], + dtype=np.float32, + ), + angular_velocity_radps=np.asarray( + [0.0, 0.0, state.yaw_rate_radps], dtype=np.float32 + ), + ) + + +def _is_visual_flare_impact( + collision_occurred: bool, + before_velocity_mps: np.ndarray, + after_velocity_mps: np.ndarray, + driving_direction_xy: np.ndarray, + impact_normal_xy: np.ndarray | None = None, +) -> bool: + """Return whether a collision changed driving-axis speed by at least 5 mph.""" + speed_delta_mps = abs( + abs(float(np.dot(after_velocity_mps[:2], driving_direction_xy))) + - abs(float(np.dot(before_velocity_mps[:2], driving_direction_xy))) + ) + if impact_normal_xy is not None: + speed_delta_mps = max( + speed_delta_mps, + abs( + float( + np.dot( + after_velocity_mps[:2] - before_velocity_mps[:2], + impact_normal_xy, + ) + ) + ), + ) + meets_threshold = ( + speed_delta_mps >= _VISUAL_FLARE_MIN_SPEED_DELTA_MPS + or math.isclose( + speed_delta_mps, + _VISUAL_FLARE_MIN_SPEED_DELTA_MPS, + rel_tol=1e-6, + abs_tol=1e-6, + ) + ) + return collision_occurred and meets_threshold + + +def _ego_model(vehicle: VehicleConfig) -> RigidBodyModel: + return rigid_body_model_from_vehicle_config(vehicle) + + +def _simplify_barrier_segments(segments_world: np.ndarray) -> tuple[np.ndarray, ...]: + """Coalesce dense ordered map strokes into game-scale wall segments.""" + segments = np.asarray(segments_world, dtype=np.float32) + if len(segments) == 0: + return () + simplified: list[np.ndarray] = [] + start = segments[0, 0, :2].copy() + end = segments[0, 1, :2].copy() + for segment in segments[1:]: + next_start = segment[0, :2] + next_end = segment[1, :2] + if float(np.linalg.norm(next_start - end)) > 0.25: + if float(np.linalg.norm(end - start)) > 1e-4: + simplified.append(np.stack([start, end])) + start = next_start.copy() + end = next_end.copy() + if float(np.linalg.norm(end - start)) >= _PHYSX_BARRIER_SPACING_M: + simplified.append(np.stack([start, end])) + start = end.copy() + if float(np.linalg.norm(end - start)) > 1e-4: + simplified.append(np.stack([start, end])) + return tuple(simplified) + + +def _reinforce_static_barrier_rebound( + barriers: tuple[InvisibleBarrier, ...], + requested_ego: BodyState, + resolved_velocity_mps: np.ndarray, + ego_model: RigidBodyModel, + restitution: float, +) -> tuple[np.ndarray, bool]: + """Raise outward barrier velocity and report inward barrier contact.""" + position = np.asarray(requested_ego.position_m[:2], dtype=np.float32) + incoming_velocity = np.asarray(requested_ego.linear_velocity_mps, dtype=np.float32) + reinforced = np.asarray(resolved_velocity_mps, dtype=np.float32).copy() + yaw = _yaw_from_quaternion_xyzw(requested_ego.orientation_xyzw) + forward = np.asarray([math.cos(yaw), math.sin(yaw)], dtype=np.float32) + left = np.asarray([-forward[1], forward[0]], dtype=np.float32) + half_extents = ego_model.half_extents_m + contact_detected = False + + for barrier in barriers: + start = np.asarray(barrier.start_xy_m, dtype=np.float32) + end = np.asarray(barrier.end_xy_m, dtype=np.float32) + segment = end - start + length_squared = float(np.dot(segment, segment)) + if length_squared <= 1.0e-8: + continue + alpha = float( + np.clip(np.dot(position - start, segment) / length_squared, 0.0, 1.0) + ) + offset = position - (start + segment * alpha) + distance = float(np.linalg.norm(offset)) + if distance > 1.0e-6: + normal = offset / distance + else: + speed = float(np.linalg.norm(incoming_velocity[:2])) + normal = ( + -incoming_velocity[:2] / speed + if speed > 1.0e-6 + else np.asarray([1.0, 0.0], dtype=np.float32) + ) + support = ( + abs(float(np.dot(normal, forward))) * half_extents[0] + + abs(float(np.dot(normal, left))) * half_extents[1] + ) + contact_distance = support + barrier.thickness_m * 0.5 + if distance > contact_distance + _BARRIER_CONTACT_SLOP_M: + continue + incoming_normal_speed = float(np.dot(incoming_velocity[:2], normal)) + if incoming_normal_speed >= 0.0: + continue + contact_detected = True + target_outward_speed = -restitution * incoming_normal_speed + resolved_normal_speed = float(np.dot(reinforced[:2], normal)) + if resolved_normal_speed < target_outward_speed: + reinforced[:2] += normal * (target_outward_speed - resolved_normal_speed) + return reinforced, contact_detected + + +class GamePhysicsWorld: + """Adapt a scene bundle to Ludus and delegate all simulation to PhysX.""" + + def __init__( + self, + scene: SceneDefinition, + vehicle: VehicleConfig, + *, + model_adapter: Callable[[RigidBodyModel], RigidBodyModel] | None = None, + static_barrier_segments_world: np.ndarray | None = None, + static_barrier_restitution: float | None = None, + ) -> None: + started_at = time.perf_counter() + self._vehicle = vehicle + if static_barrier_restitution is not None and ( + not math.isfinite(static_barrier_restitution) + or not 0.0 <= static_barrier_restitution <= 1.0 + ): + raise ValueError("static_barrier_restitution must be within [0, 1]") + self._static_barrier_restitution = static_barrier_restitution + adapt_model = model_adapter or (lambda model: model) + + game_map = getattr(scene, "game_map", None) + self._map_traffic = MapTrafficController( + () if game_map is None else game_map.traffic, + vehicle, + ) + if ( + vehicle.static_collision_enabled + and static_barrier_segments_world is not None + ): + segments = np.asarray(static_barrier_segments_world, dtype=np.float32) + if segments.ndim != 3 or segments.shape[1:] != (2, 3): + raise ValueError( + "static_barrier_segments_world must have shape (N, 2, 3)" + ) + barriers = tuple( + InvisibleBarrier( + tuple(float(value) for value in segment[0]), + tuple(float(value) for value in segment[1]), + barrier_id=f"semantic-{index}", + ) + for index, segment in enumerate(_simplify_barrier_segments(segments)) + ) + else: + barriers = ( + self._build_barriers(scene) if vehicle.static_collision_enabled else () + ) + self.graph = PhysicsObjectGraph(objects=(), barriers=barriers) + initial_transform = getattr(scene, "initial_rig_to_world", None) + initial_xy = ( + np.asarray(initial_transform[:2, 3], dtype=np.float32) + if initial_transform is not None + else np.zeros(2, dtype=np.float32) + ) + initial_timestamp_us = int(getattr(scene, "initial_timestamp_us", 0)) + self._vicinity_resolver = ( + None if game_map is None else GameMapVicinityResolver(game_map) + ) + self._map_vicinity: GameMapVicinity | None = ( + None + if self._vicinity_resolver is None + else self._vicinity_resolver.resolve( + float(initial_xy[0]), float(initial_xy[1]) + ) + ) + self._map_traffic.set_vicinity(self._map_vicinity) + base_physics_graph = self.graph.copy_for_physx( + initial_xy, + _PHYSX_SIMULATION_RADIUS_M, + timestamp_us=initial_timestamp_us, + ) + self._physics_graph = self._with_active_map_traffic(base_physics_graph) + self._physics_center_xy = initial_xy.copy() + self._physics_timestamp_us = initial_timestamp_us + self._entities = [ + self._entity_from_object(obj) for obj in self._physics_graph.objects + ] + self._entities_by_id = {entity.entity_id: entity for entity in self._entities} + self._detached_entity_ids: set[str] = set() + self.last_step_timings = None + self.last_step_actor_collision = False + self.last_step_static_barrier_collision = False + self.last_step_static_barrier_impact = False + self._static_barrier_contact_active = False + self._visual_flare_collision_velocity_mps: np.ndarray | None = None + self._visual_flare_driving_direction_xy: np.ndarray | None = None + self._visual_flare_impact_normal_xy: np.ndarray | None = None + self._visual_flare_collision_deadline_us: int | None = None + self._pending_struck_vehicle_ids: set[str] = set() + self._ego_model = adapt_model(_ego_model(vehicle)) + self._world = PhysXWorld( + base_physics_graph, + self._ego_model, + actor_collision_enabled=vehicle.actor_collision_enabled, + max_actor_drive_speed_mps=_NON_EGO_MAX_DRIVE_SPEED_MPS, + max_actor_drive_speeds_mps=self._map_traffic.max_drive_speeds_mps, + ) + self._world.synchronize( + self._physics_graph, + timestamp_us=initial_timestamp_us, + initial_object_timestamps_us=self._map_traffic.active_timestamps_us, + ) + self._refresh_debug_barriers() + logger.info( + "[physics] PhysX graph ready in {:.1f} ms; objects={}/{} barriers={}/{} simulation_radius_m={:.0f}", + (time.perf_counter() - started_at) * 1000.0, + len(self._physics_graph.objects), + len(self.graph.objects) + len(self._map_traffic.objects), + len(self._physics_graph.barriers), + len(self.graph.barriers), + _PHYSX_SIMULATION_RADIUS_M, + ) + + def _with_active_map_traffic( + self, physics_graph: PhysicsObjectGraph + ) -> PhysicsObjectGraph: + """Add only graph-nearby procedural traffic to a PhysX window.""" + incoming_ids = { + scene_object.object_id for scene_object in physics_graph.objects + } + additions = tuple( + scene_object + for scene_object in self._map_traffic.active_objects + if scene_object.object_id not in incoming_ids + ) + if not additions: + return physics_graph + return PhysicsObjectGraph( + objects=physics_graph.objects + additions, + barriers=physics_graph.barriers, + ) + + @staticmethod + def _entity_from_object(scene_object: SceneObject) -> GameEntity: + dimensions = np.asarray(scene_object.model.half_extents_m) * 2.0 + return GameEntity( + entity_id=scene_object.object_id, + object_type=scene_object.object_type, + transform=TransformComponent( + scene_object.positions_m[0], scene_object.orientations_xyzw[0] + ), + rigid_body=RigidBodyComponent( + mass_kg=scene_object.model.mass_kg, + linear_velocity_mps=np.zeros(3, dtype=np.float32), + angular_velocity_radps=np.zeros(3, dtype=np.float32), + restitution=scene_object.model.restitution, + friction=scene_object.model.friction, + ), + collider=BoxColliderComponent(scene_object.model.half_extents_m), + vehicle=vehicle_dynamics_for_object( + scene_object.object_type, + dimensions, + scene_object.model.mass_kg, + ), + suspension=suspension_for_object(scene_object.object_type), + ) + + @staticmethod + def _build_barriers(scene: SceneDefinition) -> tuple[InvisibleBarrier, ...]: + barriers: list[InvisibleBarrier] = [] + for layer_index, layer in enumerate(scene.line_layers): + if any( + token in layer.layer_name.lower() for token in _BARRIER_LAYER_TOKENS + ): + for segment_index, segment in enumerate( + _simplify_barrier_segments(layer.segments_world) + ): + barriers.append( + InvisibleBarrier( + tuple(float(value) for value in segment[0]), + tuple(float(value) for value in segment[1]), + barrier_id=f"line-{layer_index}-{segment_index}", + ) + ) + for layer_index, layer in enumerate(scene.polygon_layers): + if not any( + token in layer.layer_name.lower() for token in _BARRIER_LAYER_TOKENS + ): + continue + for polygon_index, polygon in enumerate(layer.polygons_world): + points = np.asarray(polygon, dtype=np.float32) + for index in range(len(points)): + barriers.append( + InvisibleBarrier( + tuple(float(value) for value in points[index - 1, :2]), + tuple(float(value) for value in points[index, :2]), + barrier_id=( + f"polygon-{layer_index}-{polygon_index}-{index}" + ), + ) + ) + return tuple(barriers) + + @property + def entities(self) -> tuple[GameEntity, ...]: + """Return actor entities synchronized from the Ludus object graph.""" + return tuple(self._entities) + + @property + def _active_collider_ids(self) -> set[str]: + """Expose the native collider set for invariant checks and diagnostics.""" + return set(self._world.active_collider_ids) + + def synchronize_window( + self, + center_xy_m: np.ndarray, + timestamp_us: int | None = None, + ) -> bool: + """Incrementally recenter active PhysX topology when the ego moves.""" + center = np.asarray(center_xy_m, dtype=np.float32) + if center.shape != (2,): + raise ValueError("center_xy_m must have shape (2,)") + traffic_topology_changed = False + if self._vicinity_resolver is not None: + self._map_vicinity = self._vicinity_resolver.resolve( + float(center[0]), + float(center[1]), + previous=self._map_vicinity, + ) + traffic_topology_changed = self._map_traffic.set_vicinity( + self._map_vicinity + ) + center_is_current = ( + float(np.linalg.norm(center - self._physics_center_xy)) + < _PHYSX_RECENTER_DISTANCE_M + ) + timestamp_is_current = timestamp_us is None or ( + 0 + <= timestamp_us - self._physics_timestamp_us + < _PHYSX_TOPOLOGY_REFRESH_INTERVAL_US + ) + if center_is_current and timestamp_is_current and not traffic_topology_changed: + return False + physics_graph = self.graph.copy_for_physx( + center, + _PHYSX_SIMULATION_RADIUS_M, + timestamp_us=timestamp_us, + ) + physics_graph = self._with_active_map_traffic(physics_graph) + incoming_ids = {obj.object_id for obj in physics_graph.objects} + retained_detached = tuple( + obj + for obj in self._physics_graph.objects + if obj.object_id in self._detached_entity_ids + and ( + obj.object_id not in self._map_traffic.object_ids + or obj.object_id in self._map_traffic.active_object_ids + ) + and obj.object_id not in incoming_ids + ) + if retained_detached: + physics_graph = PhysicsObjectGraph( + objects=physics_graph.objects + retained_detached, + barriers=physics_graph.barriers, + ) + self._world.synchronize( + physics_graph, + timestamp_us=timestamp_us, + initial_object_timestamps_us=self._map_traffic.active_timestamps_us, + ) + existing_entities = {entity.entity_id: entity for entity in self._entities} + self._entities = [ + existing_entities.get(scene_object.object_id) + or self._entity_from_object(scene_object) + for scene_object in physics_graph.objects + ] + self._entities_by_id = {entity.entity_id: entity for entity in self._entities} + self._detached_entity_ids.intersection_update(self._entities_by_id) + self._physics_graph = physics_graph + self._physics_center_xy = center.copy() + if timestamp_us is not None: + self._physics_timestamp_us = timestamp_us + self._refresh_debug_barriers() + return True + + def _refresh_debug_barriers(self) -> None: + if self._physics_graph.barriers: + self._debug_barrier_ids = tuple( + barrier.barrier_id or f"barrier-{index}" + for index, barrier in enumerate(self._physics_graph.barriers) + ) + self._debug_barrier_segments = np.asarray( + [ + [barrier.start_xy_m, barrier.end_xy_m] + for barrier in self._physics_graph.barriers + ], + dtype=np.float32, + ) + self._debug_barrier_thicknesses = np.asarray( + [barrier.thickness_m for barrier in self._physics_graph.barriers], + dtype=np.float32, + ) + self._debug_barrier_heights = np.asarray( + [barrier.height_m for barrier in self._physics_graph.barriers], + dtype=np.float32, + ) + else: + self._debug_barrier_ids = () + self._debug_barrier_segments = np.empty((0, 2, 2), dtype=np.float32) + self._debug_barrier_thicknesses = np.empty((0,), dtype=np.float32) + self._debug_barrier_heights = np.empty((0,), dtype=np.float32) + + def debug_frame(self, state: VehicleState) -> PhysicsDebugFrame: + """Capture the active collider topology without rendering it.""" + half_yaw = state.yaw_rad * 0.5 + ego_xy = np.asarray([state.x_m, state.y_m], dtype=np.float32) + forward = np.asarray( + [math.cos(state.yaw_rad), math.sin(state.yaw_rad)], dtype=np.float32 + ) + left = np.asarray([-forward[1], forward[0]], dtype=np.float32) + ( + actor_ids, + actor_positions, + actor_orientations, + actor_dimensions, + ) = self._world.collider_state_arrays() + if len(actor_positions): + actor_delta = actor_positions[:, :2] - ego_xy + actor_forward = actor_delta @ forward + actor_lateral = actor_delta @ left + quaternion = actor_orientations + qx, qy, qz, qw = (quaternion[:, index] for index in range(4)) + # Project the full oriented collider onto the ego axes. Checking + # only its center drops long vehicles whose body crosses the view + # boundary even though the collider is still visible. + local_axes_xy = ( + np.column_stack( + ( + 1.0 - 2.0 * (qy * qy + qz * qz), + 2.0 * (qx * qy + qw * qz), + ) + ), + np.column_stack( + ( + 2.0 * (qx * qy - qw * qz), + 1.0 - 2.0 * (qx * qx + qz * qz), + ) + ), + np.column_stack( + ( + 2.0 * (qx * qz + qw * qy), + 2.0 * (qy * qz - qw * qx), + ) + ), + ) + half_dimensions = actor_dimensions * 0.5 + actor_forward_radius = sum( + half_dimensions[:, axis_index] * np.abs(local_axis @ forward) + for axis_index, local_axis in enumerate(local_axes_xy) + ) + actor_lateral_radius = sum( + half_dimensions[:, axis_index] * np.abs(local_axis @ left) + for axis_index, local_axis in enumerate(local_axes_xy) + ) + actor_visible = ( + (actor_forward + actor_forward_radius >= -_PHYSX_DEBUG_REAR_M) + & (actor_forward - actor_forward_radius <= _PHYSX_DEBUG_FORWARD_M) + & ( + np.abs(actor_lateral) - actor_lateral_radius + <= _PHYSX_DEBUG_LATERAL_M + ) + ) + actor_positions = actor_positions[actor_visible] + actor_orientations = actor_orientations[actor_visible] + actor_dimensions = actor_dimensions[actor_visible] + actor_ids = tuple( + object_id + for object_id, visible in zip(actor_ids, actor_visible, strict=True) + if bool(visible) + ) + else: + actor_positions = np.empty((0, 3), dtype=np.float32) + actor_orientations = np.empty((0, 4), dtype=np.float32) + actor_dimensions = np.empty((0, 3), dtype=np.float32) + if len(self._debug_barrier_segments): + barrier_delta = self._debug_barrier_segments - ego_xy + barrier_forward = barrier_delta @ forward + barrier_lateral = barrier_delta @ left + barrier_radius = self._debug_barrier_thicknesses * 0.5 + barrier_visible = ( + ( + np.max(barrier_forward, axis=1) + barrier_radius + >= -_PHYSX_DEBUG_REAR_M + ) + & ( + np.min(barrier_forward, axis=1) - barrier_radius + <= _PHYSX_DEBUG_FORWARD_M + ) + & ( + np.min(barrier_lateral, axis=1) - barrier_radius + <= _PHYSX_DEBUG_LATERAL_M + ) + & ( + np.max(barrier_lateral, axis=1) + barrier_radius + >= -_PHYSX_DEBUG_LATERAL_M + ) + ) + barrier_segments = self._debug_barrier_segments[barrier_visible] + barrier_thicknesses = self._debug_barrier_thicknesses[barrier_visible] + barrier_heights = self._debug_barrier_heights[barrier_visible] + barrier_ids = tuple( + barrier_id + for barrier_id, visible in zip( + self._debug_barrier_ids, barrier_visible, strict=True + ) + if bool(visible) + ) + else: + barrier_segments = self._debug_barrier_segments + barrier_thicknesses = self._debug_barrier_thicknesses + barrier_heights = self._debug_barrier_heights + barrier_ids = self._debug_barrier_ids + return PhysicsDebugFrame( + ego_position_m=np.asarray( + [ + state.x_m, + state.y_m, + state.z_m + self._ego_model.half_extents_m[2], + ], + dtype=np.float32, + ), + ego_orientation_xyzw=np.asarray( + [0.0, 0.0, math.sin(half_yaw), math.cos(half_yaw)], + dtype=np.float32, + ), + ego_dimensions_lwh=np.asarray( + self._ego_model.half_extents_m, dtype=np.float32 + ) + * 2.0, + actor_positions_m=actor_positions, + actor_orientations_xyzw=actor_orientations, + actor_dimensions_lwh=actor_dimensions, + barrier_segments_xy_m=barrier_segments, + barrier_thicknesses_m=barrier_thicknesses, + barrier_heights_m=barrier_heights, + actor_ids=actor_ids, + barrier_ids=barrier_ids, + ) + + def step( + self, state: VehicleState, timestamp_us: int, dt_s: float + ) -> tuple[VehicleState, tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]]: + """Advance the authoritative PhysX scene and return actor samples.""" + step_started_at = time.perf_counter() + ego_before_step = _body_state_from_vehicle( + state, self._ego_model.half_extents_m[2] + ) + self._map_traffic.prepare_step(self._world, ego_before_step, dt_s) + physics_step = self._world.step_compact( + ego_before_step, + timestamp_us, + dt_s, + ) + self.last_step_static_barrier_collision = False + if self._static_barrier_restitution is not None: + ( + reinforced_velocity, + self.last_step_static_barrier_collision, + ) = _reinforce_static_barrier_rebound( + self._physics_graph.barriers, + ego_before_step, + physics_step.ego.linear_velocity_mps, + self._ego_model, + self._static_barrier_restitution, + ) + physics_step = replace( + physics_step, + ego=replace( + physics_step.ego, + linear_velocity_mps=reinforced_velocity, + ), + ) + self.last_step_static_barrier_impact = ( + self.last_step_static_barrier_collision + and not self._static_barrier_contact_active + ) + self._static_barrier_contact_active = self.last_step_static_barrier_collision + active_objects = { + scene_object.object_id: scene_object + for scene_object in self._physics_graph.objects + } + if physics_step.struck_object_ids: + self._pending_struck_vehicle_ids.update(physics_step.struck_object_ids) + self._visual_flare_collision_velocity_mps = ( + ego_before_step.linear_velocity_mps.copy() + ) + self._visual_flare_driving_direction_xy = np.asarray( + [math.cos(state.yaw_rad), math.sin(state.yaw_rad)], dtype=np.float32 + ) + strongest_closing_speed_mps = _VISUAL_FLARE_MIN_SPEED_DELTA_MPS + self._visual_flare_impact_normal_xy = None + actor_bodies = { + object_id: body for object_id, body, _ in physics_step.actor_samples + } + for object_id in physics_step.struck_object_ids: + body = actor_bodies.get(object_id) + scene_object = active_objects.get(object_id) + if body is None or scene_object is None: + continue + separation_xy = body.position_m[:2] - ego_before_step.position_m[:2] + separation_m = float(np.linalg.norm(separation_xy)) + if separation_m <= 1e-6: + continue + impact_normal_xy = separation_xy / separation_m + traffic_state = self._map_traffic.state(object_id) + if traffic_state is None: + continue + _, _, track_velocity_mps = scene_object.sample( + int(traffic_state.timestamp_us) + ) + relative_velocity_xy = ( + track_velocity_mps[:2] - ego_before_step.linear_velocity_mps[:2] + ) + closing_speed_mps = -float( + np.dot(relative_velocity_xy, impact_normal_xy) + ) + if closing_speed_mps >= strongest_closing_speed_mps: + strongest_closing_speed_mps = closing_speed_mps + self._visual_flare_impact_normal_xy = impact_normal_xy.copy() + self._visual_flare_collision_deadline_us = ( + timestamp_us + _VISUAL_FLARE_COLLISION_WINDOW_US + ) + collision_window_active = ( + self._visual_flare_collision_deadline_us is not None + and timestamp_us <= self._visual_flare_collision_deadline_us + ) + flare_baseline_velocity = self._visual_flare_collision_velocity_mps + if flare_baseline_velocity is None: + flare_baseline_velocity = ego_before_step.linear_velocity_mps + flare_driving_direction = self._visual_flare_driving_direction_xy + if flare_driving_direction is None: + flare_driving_direction = np.asarray( + [math.cos(state.yaw_rad), math.sin(state.yaw_rad)], dtype=np.float32 + ) + self.last_step_actor_collision = _is_visual_flare_impact( + physics_step.impact or collision_window_active, + flare_baseline_velocity, + physics_step.ego.linear_velocity_mps, + flare_driving_direction, + self._visual_flare_impact_normal_xy, + ) + collision_window_expired = ( + self._visual_flare_collision_deadline_us is not None + and timestamp_us > self._visual_flare_collision_deadline_us + ) + if self.last_step_actor_collision or collision_window_expired: + self._visual_flare_collision_velocity_mps = None + self._visual_flare_driving_direction_xy = None + self._visual_flare_impact_normal_xy = None + self._visual_flare_collision_deadline_us = None + self._pending_struck_vehicle_ids.clear() + actor_samples = [] + pending_controls = [] + for object_id, body, _native_detached in physics_step.actor_samples: + decision = self._map_traffic.observe_physics( + object_id, + struck=object_id in physics_step.struck_object_ids, + body=body, + dt_s=dt_s, + ) + if decision is None: + raise RuntimeError(f"PhysX returned unmanaged NPC {object_id!r}") + detached = decision.detached_from_track + pending_controls.append((object_id, decision.drive_enabled, detached)) + actor_samples.append( + (object_id, body.position_m, body.orientation_xyzw, detached) + ) + self._world.apply_track_controls(tuple(pending_controls)) + + detached_ids = {sample[0] for sample in actor_samples if sample[3]} + for object_id in self._detached_entity_ids - detached_ids: + self._entities_by_id[object_id].detached_from_track = False + for object_id, position, orientation, detached in actor_samples: + entity = self._entities_by_id[object_id] + entity.transform.position_m = position.copy() + entity.transform.orientation_xyzw = orientation.copy() + traffic_state = self._map_traffic.state(object_id) + if traffic_state is None: + raise RuntimeError(f"Missing state for managed NPC {object_id!r}") + entity.rigid_body.linear_velocity_mps = ( + traffic_state.linear_velocity_mps.copy() + ) + entity.rigid_body.angular_velocity_radps = ( + traffic_state.angular_velocity_radps.copy() + ) + entity.detached_from_track = detached + self._detached_entity_ids = detached_ids + + ego = physics_step.ego + yaw = _yaw_from_quaternion_xyzw(ego.orientation_xyzw) + collision_response_active = ( + physics_step.impact + or bool(physics_step.struck_object_ids) + or state.ragdoll_active + ) + yaw_rate_radps = float(ego.angular_velocity_radps[2]) + if collision_response_active: + max_yaw_rate = self._vehicle.max_collision_yaw_rate_radps + yaw_delta = math.atan2( + math.sin(yaw - state.yaw_rad), math.cos(yaw - state.yaw_rad) + ) + yaw_delta = float( + np.clip(yaw_delta, -max_yaw_rate * dt_s, max_yaw_rate * dt_s) + ) + yaw = state.yaw_rad + yaw_delta + yaw_rate_radps = float(np.clip(yaw_rate_radps, -max_yaw_rate, max_yaw_rate)) + forward = np.asarray([math.cos(yaw), math.sin(yaw)]) + ego_height_m = float(ego.position_m[2] - self._ego_model.half_extents_m[2]) + remains_unsettled = ( + abs(ego_height_m) > 0.10 + or abs(float(ego.linear_velocity_mps[2])) > 0.50 + or float(np.linalg.norm(ego.angular_velocity_radps[:2])) > 0.25 + ) + result_state = replace( + state, + x_m=float(ego.position_m[0]), + y_m=float(ego.position_m[1]), + z_m=ego_height_m, + yaw_rad=yaw, + speed_mps=float(np.dot(ego.linear_velocity_mps[:2], forward)), + velocity_x_mps=float(ego.linear_velocity_mps[0]), + velocity_y_mps=float(ego.linear_velocity_mps[1]), + yaw_rate_radps=yaw_rate_radps, + ragdoll_active=physics_step.impact or remains_unsettled, + ) + samples = tuple( + ( + object_id, + position.copy(), + orientation.copy(), + detached, + ) + for object_id, position, orientation, detached in actor_samples + ) + step_total_ms = (time.perf_counter() - step_started_at) * 1000.0 + native_ms = ( + physics_step.timings.actor_update_ms + + physics_step.timings.solver_ms + + physics_step.timings.readback_ms + ) + self.last_step_timings = replace( + physics_step.timings, + total_ms=step_total_ms, + bridge_ms=max(0.0, step_total_ms - native_ms), + ) + return result_state, samples + + def synchronize_ego_state(self, state: VehicleState) -> None: + """Publish an app-authoritative ego state to the owned PhysX scene. + + This adapter contains the native body identifier and state-array layout so + application policies do not depend on Ludus implementation details. + + Args: + state: Authoritative vehicle state to publish. + """ + body = _body_state_from_vehicle(state, self._ego_model.half_extents_m[2]) + pose = np.concatenate((body.position_m, body.orientation_xyzw)).astype( + np.float32, copy=False + ) + self._world._scene.update_body( + 0, + pose, + np.asarray(body.linear_velocity_mps, dtype=np.float32), + np.asarray(body.angular_velocity_radps, dtype=np.float32), + False, + ) + + def close(self) -> None: + """Release the Ludus PhysX world.""" + self._world.close() + + def build_trajectories( + self, + timestamps_us: np.ndarray, + samples_by_frame: list[tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]], + ) -> tuple[DynamicActorTrajectory, ...]: + """Pack PhysX object samples for Ludus RGB and BEV HD-map rendering.""" + map_traffic = getattr(self, "_map_traffic", None) + active_map_objects = () if map_traffic is None else map_traffic.active_objects + if (not self.graph.objects and not active_map_objects) or not samples_by_frame: + return () + result: list[DynamicActorTrajectory] = [] + simulated_timestamps = np.asarray(timestamps_us, dtype=np.int64) + samples_by_id = [ + {sample[0]: sample for sample in frame} for frame in samples_by_frame + ] + simulated_ids = { + object_id for frame in samples_by_id for object_id in frame.keys() + } + render_objects = (*self.graph.objects, *active_map_objects) + for scene_object in render_objects: + physically_simulated = scene_object.object_id in simulated_ids + if physically_simulated: + detached = any( + frame[scene_object.object_id][3] + for frame in samples_by_id + if scene_object.object_id in frame + ) + frame_poses = [] + for timestamp, frame in zip( + simulated_timestamps, samples_by_id, strict=True + ): + sample = frame.get(scene_object.object_id) + if sample is None: + position, orientation, _ = scene_object.sample(int(timestamp)) + else: + position, orientation = sample[1], sample[2] + frame_poses.append((position, orientation)) + positions = np.stack([pose[0] for pose in frame_poses]).astype( + np.float32 + ) + orientations = np.stack([pose[1] for pose in frame_poses]).astype( + np.float32 + ) + trajectory_timestamps = simulated_timestamps + else: + continue + result.append( + DynamicActorTrajectory( + entity_id=scene_object.object_id, + object_type=scene_object.object_type, + timestamps_us=trajectory_timestamps, + translations_world=positions, + orientations_xyzw=orientations, + dimensions_lwh=np.asarray( + scene_object.model.half_extents_m, dtype=np.float32 + ) + * 2.0, + detached_from_track=detached, + is_simulated=True, + ) + ) + return tuple(result) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ground_snap.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ground_snap.py new file mode 100644 index 000000000..6cd0feff4 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ground_snap.py @@ -0,0 +1,271 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Ground-snap physics for model-thread vehicle simulation. + +After kinematic integration produces ``(x, y, yaw)``, this module re-aligns +``z + pitch + roll`` so the ego sits on top of the ground mesh shipped in the +USDZ as ``mesh_ground.ply``. Mirrors the alpasim physics service +(:mod:`alpasim_physics.backend.PhysicsBackend.update_pose`) using a numpy-only +CPU vertical raycaster so the engine keeps a small dependency surface and +works on the ``--backend raster`` (no GPU) path too. +""" + +import logging +import math +from collections.abc import Callable +from dataclasses import replace + +import numpy as np +import numpy.typing as npt + +from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.types import VehicleState + +logger = logging.getLogger(__name__) + +FloatArray = npt.NDArray[np.float32] +IntArray = npt.NDArray[np.int32] + + +class GroundSnapper: + def __init__( + self, + vertices_xyz: FloatArray, + faces_ijk: IntArray, + *, + grid_resolution_m: float = 2.0, + max_translation_m: float = 1.5, + max_rotation_deg: float = 10.0, + max_absolute_rotation_deg: float | None = None, + num_sample_points: int = 16, + min_intersections: int = 6, + invalid_sample_handler: Callable[[VehicleState], VehicleState] | None = None, + ) -> None: + if vertices_xyz.ndim != 2 or vertices_xyz.shape[1] != 3: + raise ValueError(f"vertices must be (N, 3), got {vertices_xyz.shape}") + if faces_ijk.ndim != 2 or faces_ijk.shape[1] != 3: + raise ValueError(f"faces must be (M, 3), got {faces_ijk.shape}") + if vertices_xyz.shape[0] == 0 or faces_ijk.shape[0] == 0: + raise ValueError("vertices and faces must be non-empty") + + self._max_translation_m = float(max_translation_m) + self._max_rotation_rad = math.radians(max_rotation_deg) + self._max_absolute_rotation_rad = ( + None + if max_absolute_rotation_deg is None + else math.radians(max_absolute_rotation_deg) + ) + self._num_sample_points = int(num_sample_points) + self._min_intersections = int(min_intersections) + self._anchor_offset_m: float | None = None + self._invalid_sample_handler = invalid_sample_handler + + vertices_d = np.asarray(vertices_xyz, dtype=np.float64) + faces_i = np.asarray(faces_ijk, dtype=np.int32) + if int(faces_i.max()) >= vertices_d.shape[0] or int(faces_i.min()) < 0: + raise ValueError("face indices out of range for given vertex array") + tri_vertices = vertices_d[faces_i] + + a = tri_vertices[:, 0, :] + b = tri_vertices[:, 1, :] + c = tri_vertices[:, 2, :] + self._a_x = a[:, 0] + self._a_y = a[:, 1] + self._a_z = a[:, 2] + self._b_z = b[:, 2] + self._c_z = c[:, 2] + self._v0x = b[:, 0] - a[:, 0] + self._v0y = b[:, 1] - a[:, 1] + self._v1x = c[:, 0] - a[:, 0] + self._v1y = c[:, 1] - a[:, 1] + denom = self._v0x * self._v1y - self._v1x * self._v0y + self._inv_denom = np.where( + np.abs(denom) >= 1e-12, 1.0 / np.where(denom != 0, denom, 1.0), 0.0 + ) + self._denom_valid = np.abs(denom) >= 1e-12 + + self._grid_resolution_m = float(grid_resolution_m) + tri_xy = tri_vertices[:, :, :2] + self._tri_xy_min = tri_xy.min(axis=1) + self._tri_xy_max = tri_xy.max(axis=1) + self._grid_origin = self._tri_xy_min.min(axis=0) + grid_extent = self._tri_xy_max.max(axis=0) - self._grid_origin + self._grid_shape = ( + max(1, int(np.ceil(grid_extent[0] / self._grid_resolution_m))), + max(1, int(np.ceil(grid_extent[1] / self._grid_resolution_m))), + ) + cell_buckets: dict[tuple[int, int], list[int]] = {} + for tri_idx in range(faces_i.shape[0]): + i_min = self._cell_x(self._tri_xy_min[tri_idx, 0]) + i_max = self._cell_x(self._tri_xy_max[tri_idx, 0]) + j_min = self._cell_y(self._tri_xy_min[tri_idx, 1]) + j_max = self._cell_y(self._tri_xy_max[tri_idx, 1]) + for i in range(i_min, i_max + 1): + for j in range(j_min, j_max + 1): + cell_buckets.setdefault((i, j), []).append(tri_idx) + self._cell_candidates: dict[tuple[int, int], np.ndarray] = { + cell: np.asarray(idxs, dtype=np.int32) + for cell, idxs in cell_buckets.items() + } + + def _cell_x(self, x: float) -> int: + i = int((x - self._grid_origin[0]) / self._grid_resolution_m) + return max(0, min(self._grid_shape[0] - 1, i)) + + def _cell_y(self, y: float) -> int: + j = int((y - self._grid_origin[1]) / self._grid_resolution_m) + return max(0, min(self._grid_shape[1] - 1, j)) + + def _ground_z_at(self, x: float, y: float, z_ref: float) -> float | None: + if ( + x < self._grid_origin[0] + or y < self._grid_origin[1] + or x > self._grid_origin[0] + self._grid_shape[0] * self._grid_resolution_m + or y > self._grid_origin[1] + self._grid_shape[1] * self._grid_resolution_m + ): + return None + candidates = self._cell_candidates.get((self._cell_x(x), self._cell_y(y))) + if candidates is None or candidates.size == 0: + return None + v2x = x - self._a_x[candidates] + v2y = y - self._a_y[candidates] + v0y_c = self._v0y[candidates] + v1x_c = self._v1x[candidates] + v0x_c = self._v0x[candidates] + v1y_c = self._v1y[candidates] + inv = self._inv_denom[candidates] + v = (v2x * v1y_c - v1x_c * v2y) * inv + w = (v0x_c * v2y - v2x * v0y_c) * inv + u = 1.0 - v - w + eps = 1e-6 + inside = self._denom_valid[candidates] & (u >= -eps) & (v >= -eps) & (w >= -eps) + if not bool(inside.any()): + return None + z = ( + u * self._a_z[candidates] + + v * self._b_z[candidates] + + w * self._c_z[candidates] + ) + z_inside = z[inside] + best = int(np.argmin(np.abs(z_inside - z_ref))) + return float(z_inside[best]) + + def _sample_body_grid(self, vehicle: VehicleConfig) -> np.ndarray: + n = max(2, int(np.ceil(self._num_sample_points**0.5))) + p = np.linspace(0.0, 1.0, n) + u, v = np.meshgrid(p, p) + return np.column_stack( + [ + u.ravel() * vehicle.aabb_length_m - vehicle.aabb_length_m * 0.5, + v.ravel() * vehicle.aabb_width_m - vehicle.aabb_width_m * 0.5, + np.full(u.size, -vehicle.aabb_height_m * 0.5), + ] + ) + + def snap(self, state: VehicleState, vehicle: VehicleConfig) -> VehicleState: + body_pts = self._sample_body_grid(vehicle) + rot = _euler_to_rotation(state.yaw_rad, state.pitch_rad, state.roll_rad) + world_pts = body_pts @ rot.T + np.array( + [state.x_m, state.y_m, state.z_m], dtype=np.float64 + ) + ground_zs = np.array( + [self._raycast(float(p[0]), float(p[1]), float(p[2])) for p in world_pts], + dtype=np.float64, + ) + mask = ~np.isnan(ground_zs) + n_hits = int(mask.sum()) + n_total = int(len(world_pts)) + if n_hits < self._min_intersections: + logger.debug( + "ground snap: %d/%d sample rays hit, below min=%d; passing through", + n_hits, + n_total, + self._min_intersections, + ) + return self._handle_invalid_sample(state) + ground_pts = np.column_stack( + [world_pts[mask, 0], world_pts[mask, 1], ground_zs[mask]] + ) + try: + centroid_g, normal_g = _fit_plane(ground_pts.T) + except _InsufficientPoints: + return self._handle_invalid_sample(state) + if normal_g[2] < 0.0: + normal_g = -normal_g + local_ground_z = float( + centroid_g[2] + - ( + normal_g[0] * (state.x_m - centroid_g[0]) + + normal_g[1] * (state.y_m - centroid_g[1]) + ) + / normal_g[2] + ) + if self._anchor_offset_m is None: + self._anchor_offset_m = float(state.z_m) - local_ground_z + new_z = local_ground_z + self._anchor_offset_m + cy = math.cos(state.yaw_rad) + sy = math.sin(state.yaw_rad) + target_x = float(cy * normal_g[0] + sy * normal_g[1]) + target_y = float(-sy * normal_g[0] + cy * normal_g[1]) + target_z = float(normal_g[2]) + new_roll = -math.asin(max(-1.0, min(1.0, target_y))) + new_pitch = math.atan2(target_x, target_z) + delta_z = abs(new_z - state.z_m) + if delta_z > self._max_translation_m: + return self._handle_invalid_sample(state) + delta_rot = max( + abs(new_pitch - state.pitch_rad), abs(new_roll - state.roll_rad) + ) + target_rot = max(abs(new_pitch), abs(new_roll)) + if delta_rot > self._max_rotation_rad or ( + self._max_absolute_rotation_rad is not None + and target_rot > self._max_absolute_rotation_rad + ): + return self._handle_invalid_sample(state) + return replace( + state, + z_m=float(new_z), + pitch_rad=float(new_pitch), + roll_rad=float(new_roll), + ) + + def _handle_invalid_sample(self, state: VehicleState) -> VehicleState: + if self._invalid_sample_handler is None: + return state + return self._invalid_sample_handler(state) + + def _raycast(self, x: float, y: float, z_ref: float) -> float: + z = self._ground_z_at(x, y, z_ref) + return float("nan") if z is None else z + + +class _InsufficientPoints(Exception): + pass + + +def _euler_to_rotation(yaw_rad: float, pitch_rad: float, roll_rad: float) -> np.ndarray: + cr, sr = math.cos(roll_rad), math.sin(roll_rad) + cp, sp = math.cos(pitch_rad), math.sin(pitch_rad) + cy, sy = math.cos(yaw_rad), math.sin(yaw_rad) + return np.array( + [ + [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], + [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], + [-sp, cp * sr, cp * cr], + ], + dtype=np.float64, + ) + + +def _fit_plane(points_3xn: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + if points_3xn.shape[0] != 3: + raise ValueError(f"points must have shape (3, N), got {points_3xn.shape}") + if points_3xn.shape[1] < 3: + raise _InsufficientPoints( + f"Need >=3 points to fit a plane, got {points_3xn.shape[1]}" + ) + centroid = points_3xn.mean(axis=1) + centered = points_3xn - centroid[:, np.newaxis] + M = centered @ centered.T + return centroid, np.linalg.svd(M)[0][:, -1] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py new file mode 100644 index 000000000..bde4a2021 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py @@ -0,0 +1,527 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Runtime tracks and simple car-following controls for authored map traffic.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import Enum + +import numpy as np +from ludus_renderer import BodyState, PhysXWorld, SceneObject + +from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.game_map.types import GameMapTrafficVehicle +from omnidreams_game_engine.game_map.vicinity import GameMapVicinity +from omnidreams_game_engine.simulation.components import rigid_body_model_for_object + +_OBJECT_ID_PREFIX = "map-traffic:" +_MIN_CLEARANCE_M = 2.0 +_TIME_HEADWAY_S = 1.25 +_BRAKING_MARGIN_M = 8.0 +_LANE_CORRIDOR_M = 2.25 +_MAX_HEADING_DELTA_RAD = math.radians(40.0) +_HEADWAY_GRID_CELL_M = 64.0 +_RESTART_AFTER_STOPPED_S = 1.0 +_STOPPED_LINEAR_SPEED_MPS = 0.10 +_STOPPED_ANGULAR_SPEED_RADPS = 0.10 +_RECOVERED_POSITION_ERROR_M = 0.60 +_RECOVERED_HEADING_ERROR_RAD = math.radians(8.0) +_RECOVERED_VELOCITY_ERROR_MPS = 0.75 +_TRACK_LOOKAHEAD_S = 0.35 + + +def _yaw_from_quaternion_xyzw(quaternion: np.ndarray) -> float: + x, y, z, w = (float(value) for value in quaternion) + return math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + + +class MapTrafficPhase(str, Enum): + """Authoritative gameplay phase for a map traffic vehicle.""" + + TRAVERSING = "traversing" + COLLISION = "collision" + RECOVERING = "recovering" + + +@dataclass(frozen=True) +class MapTrafficDecision: + """Native actuator and renderer state derived from an NPC's phase.""" + + drive_enabled: bool + detached_from_track: bool + + +@dataclass +class MapTrafficVehicleState: + """Single gameplay-owned state for one map traffic vehicle.""" + + object_id: str + scene_object: SceneObject + timestamp_us: float + duration_us: int + max_speed_mps: float + route_element_ids: tuple[str, ...] + position_m: np.ndarray + orientation_xyzw: np.ndarray + linear_velocity_mps: np.ndarray + angular_velocity_radps: np.ndarray + velocity_scale: float = 1.0 + phase: MapTrafficPhase = MapTrafficPhase.TRAVERSING + stopped_duration_s: float = 0.0 + + @property + def decision(self) -> MapTrafficDecision: + """Return control outputs derived solely from the gameplay phase.""" + return MapTrafficDecision( + drive_enabled=self.phase is not MapTrafficPhase.COLLISION, + detached_from_track=self.phase is not MapTrafficPhase.TRAVERSING, + ) + + @property + def element_id(self) -> str: + """Return the semantic element occupied by the logical route pose.""" + segment = int( + np.searchsorted( + self.scene_object.timestamps_us, int(self.timestamp_us), side="right" + ) + - 1 + ) + return self.route_element_ids[ + min(max(segment, 0), len(self.route_element_ids) - 1) + ] + + +@dataclass(frozen=True) +class _TrafficObservation: + position_xy: np.ndarray + velocity_xy: np.ndarray + half_length_m: float + + +def _route_track( + traffic: GameMapTrafficVehicle, vehicle: VehicleConfig +) -> tuple[SceneObject, int, int]: + positions = np.asarray(traffic.centerline_world, dtype=np.float32).copy() + dimensions = np.asarray(traffic.dimensions_lwh_m, dtype=np.float32) + positions[:, 2] += dimensions[2] * 0.5 + segment_lengths = np.linalg.norm(np.diff(positions, axis=0), axis=1) + segment_speeds = np.maximum( + np.minimum(traffic.speed_limits_mps[:-1], traffic.speed_limits_mps[1:]), + np.float32(0.1), + ) + durations_us = np.maximum( + np.rint(segment_lengths / segment_speeds * 1_000_000.0).astype(np.int64), + np.int64(1), + ) + timestamps_us = np.concatenate( + (np.zeros(1, dtype=np.int64), np.cumsum(durations_us, dtype=np.int64)) + ) + + tangents = np.diff(positions[:, :2], axis=0) + yaw = np.arctan2(tangents[:, 1], tangents[:, 0]) + yaw = np.concatenate((yaw, yaw[:1])) + orientations = np.zeros((len(positions), 4), dtype=np.float32) + orientations[:, 2] = np.sin(yaw * 0.5) + orientations[:, 3] = np.cos(yaw * 0.5) + + cumulative_distance = np.concatenate( + (np.zeros(1, dtype=np.float64), np.cumsum(segment_lengths, dtype=np.float64)) + ) + start_timestamp_us = int( + np.interp( + traffic.start_distance_m, + cumulative_distance, + timestamps_us.astype(np.float64), + ) + ) + object_id = f"{_OBJECT_ID_PREFIX}{traffic.vehicle_id}" + scene_object = SceneObject( + object_id=object_id, + object_type=traffic.vehicle_type, + model=rigid_body_model_for_object( + traffic.vehicle_type, + dimensions, + restitution=vehicle.collision_restitution, + friction=vehicle.collision_friction, + ), + timestamps_us=timestamps_us, + positions_m=positions, + orientations_xyzw=orientations, + ) + return scene_object, start_timestamp_us, int(timestamps_us[-1]) + + +class MapTrafficController: + """Own route, collision, recovery, and physical snapshots for map traffic.""" + + def __init__( + self, + traffic: tuple[GameMapTrafficVehicle, ...], + vehicle: VehicleConfig, + ) -> None: + states: list[MapTrafficVehicleState] = [] + for definition in traffic: + scene_object, start_timestamp_us, duration_us = _route_track( + definition, vehicle + ) + position, orientation, velocity = scene_object.sample(start_timestamp_us) + states.append( + MapTrafficVehicleState( + object_id=scene_object.object_id, + scene_object=scene_object, + timestamp_us=float(start_timestamp_us), + duration_us=duration_us, + max_speed_mps=float(np.max(definition.speed_limits_mps)), + route_element_ids=definition.route_element_ids, + position_m=position.copy(), + orientation_xyzw=orientation.copy(), + linear_velocity_mps=velocity.copy(), + angular_velocity_radps=np.zeros(3, dtype=np.float32), + ) + ) + self._states = tuple(states) + self._states_by_id = {state.object_id: state for state in states} + self._active_ids: frozenset[str] = frozenset() + + @property + def objects(self) -> tuple[SceneObject, ...]: + """Return every procedural traffic object owned by this controller.""" + return tuple(state.scene_object for state in self._states) + + @property + def active_objects(self) -> tuple[SceneObject, ...]: + """Return only traffic objects selected for the current map vicinity.""" + return tuple( + state.scene_object + for state in self._states + if state.object_id in self._active_ids + ) + + @property + def active_object_ids(self) -> frozenset[str]: + """Return traffic IDs selected for PhysX and renderer conditioning.""" + return self._active_ids + + @property + def active_timestamps_us(self) -> dict[str, int]: + """Return logical track timestamps used to initialize newly active bodies.""" + return { + object_id: int(self._states_by_id[object_id].timestamp_us) + for object_id in self._active_ids + } + + @property + def object_ids(self) -> frozenset[str]: + """Return stable IDs used to retain procedural actors across windows.""" + return frozenset(self._states_by_id) + + @property + def max_drive_speeds_mps(self) -> dict[str, float]: + """Return per-object actuator caps derived from compiled route speeds.""" + return {state.object_id: state.max_speed_mps for state in self._states} + + def state(self, object_id: str) -> MapTrafficVehicleState | None: + """Return the authoritative state for a map NPC, if owned here.""" + return self._states_by_id.get(object_id) + + @staticmethod + def _set_route_snapshot(state: MapTrafficVehicleState) -> None: + position, orientation, velocity = state.scene_object.sample( + int(state.timestamp_us) + ) + state.position_m = position.copy() + state.orientation_xyzw = orientation.copy() + state.linear_velocity_mps = velocity.copy() + state.angular_velocity_radps = np.zeros(3, dtype=np.float32) + + @classmethod + def _reset_offscreen(cls, state: MapTrafficVehicleState) -> None: + state.phase = MapTrafficPhase.TRAVERSING + state.stopped_duration_s = 0.0 + state.velocity_scale = 1.0 + cls._set_route_snapshot(state) + + def set_vicinity(self, vicinity: GameMapVicinity | None) -> bool: + """Select nearby cars and reset displaced cars once the player leaves.""" + visible_elements = ( + frozenset() if vicinity is None else vicinity.traffic_element_ids + ) + for state in self._states: + if ( + state.phase is not MapTrafficPhase.TRAVERSING + and state.element_id not in visible_elements + ): + self._reset_offscreen(state) + active_ids = frozenset( + state.object_id + for state in self._states + if state.element_id in visible_elements + ) + changed = active_ids != self._active_ids + self._active_ids = active_ids + return changed + + def _target_velocity(self, state: MapTrafficVehicleState) -> np.ndarray: + _, _, velocity = state.scene_object.sample(int(state.timestamp_us)) + return velocity + + @staticmethod + def _drive_target_timestamp_us(state: MapTrafficVehicleState) -> int: + """Return a bounded actuator target derived from physical route progress.""" + if state.phase is not MapTrafficPhase.TRAVERSING: + return int(state.timestamp_us) + lookahead_us = _TRACK_LOOKAHEAD_S * 1_000_000.0 * state.velocity_scale + return int((state.timestamp_us + lookahead_us) % state.duration_us) + + def _observation( + self, state: MapTrafficVehicleState, world: PhysXWorld + ) -> _TrafficObservation: + velocity = self._target_velocity(state) + if state.object_id in self._active_ids: + position = world.body_state(state.object_id).position_m[:2] + else: + position, _, _ = state.scene_object.sample(int(state.timestamp_us)) + position = position[:2] + return _TrafficObservation( + position_xy=np.asarray(position, dtype=np.float32), + velocity_xy=np.asarray(velocity[:2], dtype=np.float32), + half_length_m=float(state.scene_object.model.half_extents_m[0]), + ) + + @staticmethod + def _cyclic_timestamp_distance( + timestamp_us: float, reference_us: float, duration_us: int + ) -> float: + delta = abs(timestamp_us - reference_us) % duration_us + return min(delta, duration_us - delta) + + @classmethod + def _nearest_route_timestamp( + cls, state: MapTrafficVehicleState, position_xy: np.ndarray + ) -> float: + positions = state.scene_object.positions_m[:, :2] + timestamps = state.scene_object.timestamps_us + best_distance_sq = math.inf + best_progress_distance = math.inf + best_timestamp_us = state.timestamp_us + for index, (start, end) in enumerate( + zip(positions[:-1], positions[1:], strict=True) + ): + segment = end - start + length_sq = float(np.dot(segment, segment)) + alpha = ( + 0.0 + if length_sq <= 1.0e-12 + else float( + np.clip(np.dot(position_xy - start, segment) / length_sq, 0, 1) + ) + ) + projection = start + alpha * segment + distance_sq = float( + np.dot(position_xy - projection, position_xy - projection) + ) + timestamp_us = ( + float( + timestamps[index] + + alpha * (timestamps[index + 1] - timestamps[index]) + ) + % state.duration_us + ) + progress_distance = cls._cyclic_timestamp_distance( + timestamp_us, state.timestamp_us, state.duration_us + ) + if distance_sq < best_distance_sq - 1.0e-8 or ( + abs(distance_sq - best_distance_sq) <= 1.0e-8 + and progress_distance < best_progress_distance + ): + best_distance_sq = distance_sq + best_progress_distance = progress_distance + best_timestamp_us = timestamp_us + return best_timestamp_us + + @staticmethod + def _is_recovered(state: MapTrafficVehicleState, body: BodyState) -> bool: + track_position, track_orientation, track_velocity = state.scene_object.sample( + int(state.timestamp_us) + ) + track_velocity = track_velocity * state.velocity_scale + heading_error = math.atan2( + math.sin( + _yaw_from_quaternion_xyzw(track_orientation) + - _yaw_from_quaternion_xyzw(body.orientation_xyzw) + ), + math.cos( + _yaw_from_quaternion_xyzw(track_orientation) + - _yaw_from_quaternion_xyzw(body.orientation_xyzw) + ), + ) + return ( + float(np.linalg.norm(track_position[:2] - body.position_m[:2])) + <= _RECOVERED_POSITION_ERROR_M + and abs(heading_error) <= _RECOVERED_HEADING_ERROR_RAD + and float(np.linalg.norm(track_velocity[:2] - body.linear_velocity_mps[:2])) + <= _RECOVERED_VELOCITY_ERROR_MPS + ) + + def observe_physics( + self, + object_id: str, + *, + struck: bool, + body: BodyState, + dt_s: float, + ) -> MapTrafficDecision | None: + """Update one NPC from PhysX and advance its collision state machine.""" + state = self._states_by_id.get(object_id) + if state is None: + return None + state.position_m = body.position_m.copy() + state.orientation_xyzw = body.orientation_xyzw.copy() + state.linear_velocity_mps = body.linear_velocity_mps.copy() + state.angular_velocity_radps = body.angular_velocity_radps.copy() + + if struck: + state.phase = MapTrafficPhase.COLLISION + state.stopped_duration_s = 0.0 + return state.decision + + if state.phase is MapTrafficPhase.COLLISION: + linear_speed = float(np.linalg.norm(body.linear_velocity_mps[:2])) + angular_speed = float(np.linalg.norm(body.angular_velocity_radps)) + stopped = ( + linear_speed <= _STOPPED_LINEAR_SPEED_MPS + and angular_speed <= _STOPPED_ANGULAR_SPEED_RADPS + ) + state.stopped_duration_s = ( + state.stopped_duration_s + dt_s if stopped else 0.0 + ) + if state.stopped_duration_s >= _RESTART_AFTER_STOPPED_S: + state.timestamp_us = self._nearest_route_timestamp( + state, body.position_m[:2] + ) + state.phase = MapTrafficPhase.RECOVERING + state.stopped_duration_s = 0.0 + state.velocity_scale = 1.0 + elif state.phase is MapTrafficPhase.RECOVERING and self._is_recovered( + state, body + ): + state.phase = MapTrafficPhase.TRAVERSING + + return state.decision + + @staticmethod + def _grid_cell(position_xy: np.ndarray) -> tuple[int, int]: + return ( + math.floor(float(position_xy[0]) / _HEADWAY_GRID_CELL_M), + math.floor(float(position_xy[1]) / _HEADWAY_GRID_CELL_M), + ) + + def _headway_scale( + self, + observation: _TrafficObservation, + candidates: tuple[_TrafficObservation, ...], + ) -> float: + velocity = observation.velocity_xy + speed_mps = float(np.linalg.norm(velocity[:2])) + if speed_mps <= 1.0e-4: + return 0.0 + forward = velocity[:2] / speed_mps + best_clearance = math.inf + for other in candidates: + if other is observation: + continue + delta = other.position_xy - observation.position_xy + longitudinal = float(np.dot(delta, forward)) + if longitudinal <= 0.0: + continue + lateral = abs(float(forward[0] * delta[1] - forward[1] * delta[0])) + if lateral > _LANE_CORRIDOR_M: + continue + other_speed = float(np.linalg.norm(other.velocity_xy)) + if other_speed > 1.0e-4: + other_heading = other.velocity_xy / other_speed + angle = math.acos( + float(np.clip(np.dot(forward, other_heading), -1.0, 1.0)) + ) + if angle > _MAX_HEADING_DELTA_RAD: + continue + clearance = longitudinal - observation.half_length_m - other.half_length_m + best_clearance = min(best_clearance, clearance) + desired_clearance = _MIN_CLEARANCE_M + _TIME_HEADWAY_S * speed_mps + if best_clearance <= desired_clearance: + return 0.0 + return float( + np.clip( + (best_clearance - desired_clearance) / _BRAKING_MARGIN_M, + 0.0, + 1.0, + ) + ) + + def prepare_step(self, world: PhysXWorld, ego: BodyState, dt_s: float) -> None: + """Advance all logical cars and publish active tracks in one native batch.""" + for state in self._states: + if state.phase is MapTrafficPhase.TRAVERSING: + if state.object_id in self._active_ids: + body = world.body_state(state.object_id) + state.timestamp_us = self._nearest_route_timestamp( + state, body.position_m[:2] + ) + state.position_m = body.position_m.copy() + state.orientation_xyzw = body.orientation_xyzw.copy() + state.linear_velocity_mps = body.linear_velocity_mps.copy() + state.angular_velocity_radps = body.angular_velocity_radps.copy() + else: + state.timestamp_us = ( + state.timestamp_us + dt_s * 1_000_000.0 * state.velocity_scale + ) % state.duration_us + self._set_route_snapshot(state) + observations = { + state.object_id: self._observation(state, world) for state in self._states + } + ego_observation = _TrafficObservation( + position_xy=np.asarray(ego.position_m[:2], dtype=np.float32), + velocity_xy=np.asarray(ego.linear_velocity_mps[:2], dtype=np.float32), + half_length_m=float(world.ego_model.half_extents_m[0]), + ) + buckets: dict[tuple[int, int], list[_TrafficObservation]] = {} + for observation in (*observations.values(), ego_observation): + buckets.setdefault(self._grid_cell(observation.position_xy), []).append( + observation + ) + for state in self._states: + observation = observations[state.object_id] + cell_x, cell_y = self._grid_cell(observation.position_xy) + candidates = tuple( + candidate + for offset_x in (-1, 0, 1) + for offset_y in (-1, 0, 1) + for candidate in buckets.get((cell_x + offset_x, cell_y + offset_y), ()) + ) + state.velocity_scale = ( + 0.0 + if state.phase is MapTrafficPhase.COLLISION + else self._headway_scale(observation, candidates) + ) + world.apply_track_progress( + tuple( + ( + state.object_id, + self._drive_target_timestamp_us(state), + state.velocity_scale, + ) + for state in self._states + if state.object_id in self._active_ids + ) + ) + + +__all__ = [ + "MapTrafficController", + "MapTrafficDecision", + "MapTrafficPhase", + "MapTrafficVehicleState", +] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/types.py b/apps/omnidreams_game_engine/omnidreams_game_engine/types.py new file mode 100644 index 000000000..e9b4221f1 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/types.py @@ -0,0 +1,231 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Immutable scene data and frame-aligned simulation values.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import numpy.typing as npt +from torch import Tensor + +from omnidreams_game_engine.game_map.types import ResolvedGameMap + +FloatArray = npt.NDArray[np.float32] +UInt8Array = npt.NDArray[np.uint8] +Int32Array = npt.NDArray[np.int32] + + +@dataclass(frozen=True, slots=True) +class CameraCalibration: + clipgt_name: str + logical_name: str + width: int + height: int + cx: float + cy: float + polynomial: FloatArray + is_backward_polynomial: bool + linear_cde: FloatArray + sensor_to_rig_flu: FloatArray + + +@dataclass(frozen=True, slots=True) +class WorldLineSegments: + segments_world: FloatArray + color_rgba: tuple[float, float, float, float] + width_px: float + layer_name: str + + +@dataclass(frozen=True, slots=True) +class WorldTriangleList: + triangles_world: FloatArray + color_rgba: tuple[float, float, float, float] + layer_name: str + + +@dataclass(frozen=True, slots=True) +class WorldPolygonList: + polygons_world: tuple[FloatArray, ...] + color_rgba: tuple[float, float, float, float] + layer_name: str + + +@dataclass(frozen=True, slots=True) +class SceneDefinition: + """Immutable scene data shared with one model-thread rollout.""" + + scene_path: Path + scene_id: str + metadata: dict[str, Any] + selected_camera: CameraCalibration + initial_rig_to_world: FloatArray + initial_timestamp_us: int + initial_yaw_rad: float + initial_speed_mps: float + initial_rgb: UInt8Array + prompt: str + line_layers: tuple[WorldLineSegments, ...] + triangle_layers: tuple[WorldTriangleList, ...] + polygon_layers: tuple[WorldPolygonList, ...] = () + ground_mesh_vertices: FloatArray | None = None + ground_mesh_faces: Int32Array | None = None + game_map: ResolvedGameMap | None = None + + +@dataclass(frozen=True, slots=True) +class DriverCommand: + throttle: float = 0.0 + brake: float = 0.0 + steer: float = 0.0 + stop: bool = False + handbrake: bool = False + reverse: bool = False + steer_is_direct: bool = False + manual_control: bool = False + + +@dataclass(slots=True) +class VehicleState: + x_m: float + y_m: float + z_m: float + yaw_rad: float + speed_mps: float + steer_rad: float + pitch_rad: float = 0.0 + roll_rad: float = 0.0 + velocity_x_mps: float | None = None + velocity_y_mps: float | None = None + yaw_rate_radps: float = 0.0 + suspension_pitch_rad: float = 0.0 + suspension_roll_rad: float = 0.0 + suspension_pitch_rate_radps: float = 0.0 + suspension_roll_rate_radps: float = 0.0 + ragdoll_active: bool = False + + +@dataclass(frozen=True, slots=True) +class DynamicActorTrajectory: + entity_id: str + object_type: str + timestamps_us: npt.NDArray[np.int64] + translations_world: FloatArray + orientations_xyzw: FloatArray + dimensions_lwh: FloatArray + detached_from_track: bool = False + is_simulated: bool = False + + def to_game_engine_dict(self) -> dict[str, Any]: + """Return the identity, collider, and transform keyframes for Ludus.""" + keyframes = [ + { + "timestamp_us": int(timestamp), + "transform": { + "position_m": translation.tolist(), + "orientation_xyzw": orientation.tolist(), + }, + } + for timestamp, translation, orientation in zip( + self.timestamps_us, + self.translations_world, + self.orientations_xyzw, + strict=True, + ) + ] + return { + "entity_id": self.entity_id, + "object_type": self.object_type, + "components": { + "box_collider": { + "half_extents_m": (self.dimensions_lwh * 0.5).tolist() + }, + "trajectory": { + "detached_from_track": self.detached_from_track, + "keyframes": keyframes, + }, + }, + } + + +@dataclass(frozen=True, slots=True) +class PhysicsDebugFrame: + ego_position_m: FloatArray + ego_orientation_xyzw: FloatArray + ego_dimensions_lwh: FloatArray + actor_positions_m: FloatArray + actor_orientations_xyzw: FloatArray + actor_dimensions_lwh: FloatArray + barrier_segments_xy_m: FloatArray + barrier_thicknesses_m: FloatArray + barrier_heights_m: FloatArray + actor_ids: tuple[str, ...] = () + barrier_ids: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class PhysXChunkTimings: + total_ms: float = 0.0 + synchronize_ms: float = 0.0 + actor_update_ms: float = 0.0 + solver_ms: float = 0.0 + readback_ms: float = 0.0 + bridge_ms: float = 0.0 + step_count: int = 0 + max_visible_actors: int = 0 + max_detached_actors: int = 0 + + +@dataclass(frozen=True, slots=True) +class TrajectoryChunk: + timestamps_us: npt.NDArray[np.int64] + rig_poses_world: FloatArray + vehicle_states: tuple[VehicleState, ...] + boundary_state_after_chunk: VehicleState + applied_commands: tuple[DriverCommand, ...] = () + dynamic_actors: tuple[DynamicActorTrajectory, ...] = () + physics_debug_frames: tuple[PhysicsDebugFrame, ...] = () + actor_collision_detected: bool = False + actor_collision_frame_index: int | None = None + static_collision_detected: bool = False + static_collision_frame_index: int | None = None + physx_elapsed_s: float | None = None + physx_timings: PhysXChunkTimings | None = None + + def __post_init__(self) -> None: + frame_count = len(self.timestamps_us) + if frame_count <= 0: + raise ValueError("TrajectoryChunk requires at least one frame") + if not self.applied_commands: + object.__setattr__( + self, + "applied_commands", + tuple(DriverCommand() for _ in range(frame_count)), + ) + aligned = ( + self.rig_poses_world.shape == (frame_count, 4, 4) + and len(self.vehicle_states) == frame_count + and len(self.applied_commands) == frame_count + and ( + not self.physics_debug_frames + or len(self.physics_debug_frames) == frame_count + ) + ) + if not aligned: + raise ValueError("TrajectoryChunk fields must describe the same frames") + + +@dataclass(frozen=True, slots=True) +class ConditionBatch: + """Model conditioning and optional HUD data for one engine step.""" + + hdmap_bvtchw: Tensor + """Semantic main-camera frames in ``[B,V,T,C,H,W]`` and ``[-1,1]``.""" + + bev_tchw: Tensor | None = None + """Optional top-down frames in ``[T,C,H,W]`` and ``[-1,1]``.""" diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py b/apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py new file mode 100644 index 000000000..e6bd13481 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Strict YAML configuration validation helpers.""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any + +import yaml + + +class StrictConfigError(ValueError): + """Invalid strict YAML configuration.""" + + +def load_yaml_mapping(path: Path, *, suffix: str | None = None) -> dict[str, Any]: + """Load one YAML document as a mapping. + + Args: + path: YAML file to load. + suffix: Required filename suffix; ``None`` accepts any filename. + + Returns: + Parsed root mapping. + + Raises: + StrictConfigError: The path or YAML document is invalid. + """ + path = path.expanduser().resolve() + if not path.is_file(): + raise StrictConfigError(f"Configuration path does not exist: {path}") + if suffix is not None and not path.name.endswith(suffix): + raise StrictConfigError(f"Configuration must use the {suffix} suffix: {path}") + try: + value = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise StrictConfigError(f"Could not parse {path}: {exc}") from exc + return require_mapping(value, str(path)) + + +def require_mapping(value: Any, context: str) -> dict[str, Any]: + """Return ``value`` after validating that it is a string-keyed mapping.""" + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise StrictConfigError(f"{context} must be a mapping with string keys") + return value + + +def require_exact_keys(value: dict[str, Any], expected: set[str], context: str) -> None: + """Require a mapping to contain exactly ``expected`` keys.""" + missing = sorted(expected - value.keys()) + unknown = sorted(value.keys() - expected) + if missing: + raise StrictConfigError( + f"{context} is missing required keys: {', '.join(missing)}" + ) + if unknown: + raise StrictConfigError(f"{context} has unknown keys: {', '.join(unknown)}") + + +def require_version(value: dict[str, Any], context: str) -> None: + """Require schema version one.""" + version = value.get("schema_version") + if type(version) is not int or version != 1: + raise StrictConfigError(f"{context}.schema_version must be 1") + + +def require_bool(value: Any, context: str) -> bool: + """Return a strictly typed Boolean value.""" + if type(value) is not bool: + raise StrictConfigError(f"{context} must be a boolean") + return value + + +def require_int(value: Any, context: str, *, minimum: int = 1) -> int: + """Return an integer at or above ``minimum``.""" + if type(value) is not int or value < minimum: + raise StrictConfigError(f"{context} must be an integer >= {minimum}") + return value + + +def require_float( + value: Any, + context: str, + *, + minimum: float | None = None, + maximum: float | None = None, +) -> float: + """Return a finite numeric value within the requested range.""" + if type(value) not in (int, float): + raise StrictConfigError(f"{context} must be a number") + result = float(value) + if not math.isfinite(result): + raise StrictConfigError(f"{context} must be finite") + if minimum is not None and result < minimum: + raise StrictConfigError(f"{context} must be >= {minimum}") + if maximum is not None and result > maximum: + raise StrictConfigError(f"{context} must be <= {maximum}") + return result diff --git a/apps/omnidreams_game_engine/pyproject.toml b/apps/omnidreams_game_engine/pyproject.toml new file mode 100644 index 000000000..37d0cf1a9 --- /dev/null +++ b/apps/omnidreams_game_engine/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "omnidreams-game-engine" +version = "0.1.0" +description = "Model-thread game simulation and conditioning for FlashDreams V2" +readme = "README.md" +requires-python = ">=3.10,<3.13" +dependencies = [ + "filelock>=3", + "flashdreams", + "flashdreams-omnidreams", + "ludus-renderer", + "numpy", + "pillow", + "pyarrow", + "pyyaml>=6", + "shapely>=2.0", + "torch", +] + +[tool.uv.sources] +flashdreams = { workspace = true } +flashdreams-omnidreams = { workspace = true } +ludus-renderer = { workspace = true } + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-manual-marker>=2.0"] + +[tool.setuptools.packages.find] +include = ["omnidreams_game_engine*"] + +[tool.setuptools.package-data] +omnidreams_game_engine = ["screenshot.jpg"] + +[tool.uv] +managed = true diff --git a/apps/omnidreams_game_engine/tests/test_engine.py b/apps/omnidreams_game_engine/tests/test_engine.py new file mode 100644 index 000000000..23c33bd68 --- /dev/null +++ b/apps/omnidreams_game_engine/tests/test_engine.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU contract tests for the model-thread game engine.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import pytest +import torch + +from omnidreams_game_engine.contracts import GameUpdate +from omnidreams_game_engine.engine import GameEngine +from omnidreams_game_engine.types import ( + ConditionBatch, + DriverCommand, + TrajectoryChunk, + VehicleState, +) + +pytestmark = pytest.mark.ci_cpu + + +def _state(x_m: float = 0.0) -> VehicleState: + return VehicleState( + x_m=x_m, + y_m=0.0, + z_m=0.0, + yaw_rad=0.0, + speed_mps=0.0, + steer_rad=0.0, + ) + + +@dataclass +class _Simulation: + current_state: VehicleState = field(default_factory=_state) + closed: bool = False + + def pose_chunk(self, *, commands, chunk_size, frame_interval_s, **kwargs): + del kwargs, frame_interval_s + assert chunk_size == len(commands) + states = tuple(_state(float(index)) for index in range(chunk_size)) + self.current_state = states[-1] + poses = np.repeat(np.eye(4, dtype=np.float32)[None], chunk_size, axis=0) + poses[:, 0, 3] = np.arange(chunk_size, dtype=np.float32) + return TrajectoryChunk( + timestamps_us=np.arange(chunk_size, dtype=np.int64), + rig_poses_world=poses, + vehicle_states=states, + boundary_state_after_chunk=states[-1], + applied_commands=tuple(commands), + ) + + def close(self): + self.closed = True + + +class _Rules: + is_running = True + + def snapshot(self, vehicle_state): + return ("snapshot", vehicle_state.x_m) + + def advance_frames(self, trajectory, frame_interval_s): + del frame_interval_s + return GameUpdate( + tuple(("frame", state.x_m) for state in trajectory.vehicle_states) + ) + + def submit_text(self, value, vehicle_state): + return (value, vehicle_state.x_m) + + +class _Renderer: + closed = False + + def load_scene(self, scene): + del scene + + def render(self, trajectory): + count = len(trajectory.timestamps_us) + return ConditionBatch(torch.zeros(1, 1, count, 3, 4, 6)) + + def close(self): + self.closed = True + + +def test_engine_aligns_simulation_rules_and_conditioning() -> None: + simulation = _Simulation() + renderer = _Renderer() + engine = GameEngine( + simulation=simulation, + rules=_Rules(), + condition_renderer=renderer, + frame_interval_s=1.0 / 30.0, + ) + + result = engine.step((DriverCommand(throttle=1.0), DriverCommand())) + + assert len(result.trajectory.vehicle_states) == 2 + assert result.game_frames == (("frame", 0.0), ("frame", 1.0)) + assert result.condition.hdmap_bvtchw.shape == (1, 1, 2, 3, 4, 6) + assert engine.current_game_frame == ("snapshot", 1.0) + assert engine.submit_text("CAB") == ("CAB", 1.0) + + engine.close() + assert simulation.closed + assert renderer.closed + + +def test_engine_rejects_frame_misalignment() -> None: + class MisalignedRules(_Rules): + def advance_frames(self, trajectory, frame_interval_s): + del trajectory, frame_interval_s + return GameUpdate(("only-one",)) + + engine = GameEngine( + simulation=_Simulation(), + rules=MisalignedRules(), + condition_renderer=_Renderer(), + frame_interval_s=1.0, + ) + + with pytest.raises(ValueError, match="Game frames must align"): + engine.step((DriverCommand(), DriverCommand())) diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py new file mode 100644 index 000000000..c6c33986f --- /dev/null +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU tests for V2 event reduction.""" + +import numpy as np +import pytest + +from flashdreams.api_v2.user_input_event_data import UserInputEventData +from flashdreams.runtime_v2.user_input_event import ( + FocusUserInputEventData, + KeyboardInputState, + KeyboardUserInputEventData, + UserInputEvent, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents +from omnidreams_game_engine.config import DriverInputConfig +from omnidreams_game_engine.input import DriverInput + +pytestmark = pytest.mark.ci_cpu + + +def _events(*data: UserInputEventData) -> UserInputEvents: + return UserInputEvents( + [ + UserInputEvent(timestamp=np.uint64(index), event_data=value) + for index, value in enumerate(data) + ] + ) + + +def _key(key: str, state: KeyboardInputState) -> KeyboardUserInputEventData: + return KeyboardUserInputEventData(key=key, state=state) + + +def test_held_keys_produce_one_command_per_model_frame() -> None: + reducer = DriverInput( + DriverInputConfig( + steering_scale=1.0, + steering_rate_per_s=2.0, + steering_return_rate_per_s=4.0, + ) + ) + + first = reducer.reduce( + _events(_key("w", KeyboardInputState.PRESSED), _key("a", KeyboardInputState.PRESSED)), + frame_count=3, + frame_interval_s=0.1, + accepting_text=False, + ) + retained = reducer.reduce( + UserInputEvents([]), + frame_count=1, + frame_interval_s=0.1, + accepting_text=False, + ) + + assert len(first.commands) == 3 + assert [command.steer for command in first.commands] == pytest.approx([0.2, 0.4, 0.6]) + assert all(command.throttle == 1.0 for command in first.commands) + assert retained.commands[0].steer == pytest.approx(0.8) + + +def test_focus_loss_releases_drive_state() -> None: + reducer = DriverInput() + reverse = reducer.reduce( + _events(_key("ArrowDown", KeyboardInputState.PRESSED)), + frame_count=1, + frame_interval_s=1.0 / 30.0, + accepting_text=False, + ) + + assert reverse.commands[0].brake == 1.0 + assert reverse.commands[0].throttle == 0.0 + assert not reverse.commands[0].reverse + + result = reducer.reduce( + _events(FocusUserInputEventData(focused=False)), + frame_count=1, + frame_interval_s=1.0 / 30.0, + accepting_text=False, + ) + + assert result.commands[0].throttle == 0.0 + assert not result.commands[0].reverse + + +def test_terminal_text_is_reduced_from_the_same_v2_events() -> None: + reducer = DriverInput() + result = reducer.reduce( + _events( + _key("A", KeyboardInputState.PRESSED), + _key("I", KeyboardInputState.PRESSED), + _key("enter", KeyboardInputState.PRESSED), + ), + frame_count=1, + frame_interval_s=1.0 / 30.0, + accepting_text=True, + ) + + assert result.text == "AI" + assert result.submitted_text == "AI" diff --git a/apps/omnidreams_game_engine/tests/test_model.py b/apps/omnidreams_game_engine/tests/test_model.py new file mode 100644 index 000000000..1346fca91 --- /dev/null +++ b/apps/omnidreams_game_engine/tests/test_model.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU tests for the direct OmniDreams rollout contract.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pytest +import torch + +from omnidreams_game_engine.engine import EngineStep +from omnidreams_game_engine.model import WorldModelRollout +from omnidreams_game_engine.types import ( + CameraCalibration, + ConditionBatch, + DriverCommand, + SceneDefinition, + TrajectoryChunk, + VehicleState, +) + +pytestmark = pytest.mark.ci_cpu + + +def _scene() -> SceneDefinition: + calibration = CameraCalibration( + clipgt_name="front", + logical_name="camera_front_wide_120fov", + width=8, + height=4, + cx=4.0, + cy=2.0, + polynomial=np.zeros(6, dtype=np.float32), + is_backward_polynomial=False, + linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), + sensor_to_rig_flu=np.eye(4, dtype=np.float32), + ) + return SceneDefinition( + scene_path=Path("scene.arrow"), + scene_id="cpu-scene", + metadata={}, + selected_camera=calibration, + initial_rig_to_world=np.eye(4, dtype=np.float32), + initial_timestamp_us=0, + initial_yaw_rad=0.0, + initial_speed_mps=0.0, + initial_rgb=np.zeros((4, 8, 3), dtype=np.uint8), + prompt="a yellow taxi", + line_layers=(), + triangle_layers=(), + ) + + +class _Pipeline: + device = torch.device("cpu") + + def __init__(self) -> None: + self.calls: list[tuple[str, object]] = [] + + def initialize_cache(self, **kwargs): + self.calls.append(("initialize_cache", kwargs)) + return {"rollout": len(self.calls)} + + def get_num_frames(self, autoregressive_index): + self.calls.append(("get_num_frames", autoregressive_index)) + return 2 + + def generate(self, *, autoregressive_index, cache, hdmap): + self.calls.append(("generate", (autoregressive_index, cache, hdmap.shape))) + return torch.zeros(1, 1, 2, 3, 4, 8) + + def finalize(self, *, autoregressive_index, cache): + self.calls.append(("finalize", (autoregressive_index, cache))) + return {"model_ms": 1.25} + + +@dataclass +class _Engine: + closed: bool = False + is_running: bool = True + + @property + def current_game_frame(self): + return None + + def submit_text(self, value): + return value + + def step(self, commands): + count = len(commands) + state = VehicleState(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + trajectory = TrajectoryChunk( + timestamps_us=np.arange(count, dtype=np.int64), + rig_poses_world=np.repeat(np.eye(4, dtype=np.float32)[None], count, axis=0), + vehicle_states=(state,) * count, + boundary_state_after_chunk=state, + applied_commands=commands, + ) + return EngineStep( + trajectory=trajectory, + game_frames=tuple(range(count)), + condition=ConditionBatch(torch.zeros(1, 1, count, 3, 4, 8)), + ) + + def close(self): + self.closed = True + + +def test_rollout_calls_pipeline_directly_and_owns_its_cache() -> None: + pipeline = _Pipeline() + engines: list[_Engine] = [] + + def engine_factory() -> _Engine: + engine = _Engine() + engines.append(engine) + return engine + + rollout = WorldModelRollout( + pipeline=pipeline, + scene=_scene(), + engine_factory=engine_factory, + ) + result = rollout.step( + autoregressive_index=0, + commands=(DriverCommand(), DriverCommand()), + ) + + assert result.video_bvtchw.shape == (1, 1, 2, 3, 4, 8) + assert result.metrics == {"model_ms": 1.25} + assert [call[0] for call in pipeline.calls] == [ + "initialize_cache", + "get_num_frames", + "generate", + "finalize", + ] + + rollout.reset() + assert engines[0].closed + assert len(engines) == 2 + assert [call[0] for call in pipeline.calls].count("initialize_cache") == 2 + + rollout.close() + assert engines[1].closed diff --git a/docs/design/crazy_robotaxi_legacy_architecture.md b/docs/design/crazy_robotaxi_legacy_architecture.md new file mode 100644 index 000000000..5ac21c258 --- /dev/null +++ b/docs/design/crazy_robotaxi_legacy_architecture.md @@ -0,0 +1,169 @@ +# Crazy Robotaxi map-branch architecture + +This document records the as-built architecture on +`origin/dev/aidanf/game/crazy-robotaxi-map` as fetched at commit `0bda097`. It +is a behavioral reference for the rewrite, not a target implementation and not +a list of files to transplant. The earlier V2 porting attempt was not +consulted. + +## Process and threading model + +```mermaid +flowchart LR + subgraph Main["main / presentation thread"] + CLI["runner + game CLI"] + APP["InteractiveDriveApp"] + LOOP["custom run_main_loop"] + INPUT["keyboard / wheel state"] + SIM["EgoVehicleKinematics"] + GAME["CrazyRobotaxiRuntime
TaxiGameController"] + PRESENT["SlangPy or MJPEG presenter
game HUD + pacing"] + end + + subgraph Worker["custom chunk-pipeline worker"] + QUEUE["FIFO worker commands"] + BACKEND["LocalVideoModelAdapter
WorldModelRenderBackend"] + RASTER["LudusConditionRasterizer"] + MODEL["FlashdreamsWorldModelSession"] + end + + subgraph Optional["additional temporary/background threads"] + LOADER["scene loader / preloader"] + WHEEL["evdev wheel readers"] + end + + CLI --> APP --> LOOP + INPUT --> LOOP + WHEEL --> INPUT + LOOP -->|"commands for each output frame"| SIM + SIM -->|"TrajectoryChunk"| GAME + GAME -->|"decorated trajectory + per-frame game snapshots"| QUEUE + QUEUE --> BACKEND + BACKEND --> RASTER + RASTER -->|"HD-map conditioning frames"| MODEL + MODEL -->|"generated RGB frames"| BACKEND + BACKEND -->|"QueuedFrame per generated frame"| LOOP + LOOP --> PRESENT + GAME -->|"boundary telemetry through shared keyboard state"| PRESENT + LOADER -->|"SceneBundle + ground snapper"| APP +``` + +The old engine owns scheduling that FlashDreams V2 now owns. The main loop +polls the presenter, samples controls, simulates a complete model chunk, sends +that chunk through a private worker queue, drains generated frames from a +second queue, and presents them at a fixed cadence. A generation token drops +stale queued frames after resets or scene switches. + +The presenter is more than a renderer: it owns or reads control state, HUD +state, view selection, scene selection, loading screens, game-over name entry, +and local/MJPEG output. Consequently, game state crosses from the simulation +path to presentation through mutable keyboard/presenter state. + +## Scene and map preparation + +```mermaid +flowchart TD + YAML["authored .robotaxi.yaml"] + RESOLVE["schema validation + topology/geometry resolution"] + ARCHIVE["cached ClipGT-compatible archive"] + LOAD["scene loader"] + SCENE["SceneBundle
camera, seed image, prompt,
semantic layers, objects, ground mesh"] + TAXIDATA["CrazyRobotaxiSceneData
navigation lanes, fare regions, curbs"] + VICINITY["GameMapVicinityResolver"] + SNAP["GroundSnapper"] + PHYSICS["TaxiPhysicsWorld"] + CONDITION["LudusConditionRasterizer"] + + YAML --> RESOLVE --> ARCHIVE --> LOAD --> SCENE + SCENE --> TAXIDATA + SCENE --> VICINITY + SCENE --> SNAP + TAXIDATA --> PHYSICS + VICINITY --> PHYSICS + SCENE --> CONDITION +``` + +The authored node graph is compiled into distinct runtime concepts: + +- semantic surfaces and markings for model conditioning; +- directed navigation lanes and parking fare regions for route planning; +- curb and barrier geometry for physics; +- authored traffic tracks and spawn parameters; +- camera, seed-image, prompt, and ground-mesh data. + +The private archive is a derived cache. The YAML remains the authoring format. + +## Per-chunk game and model flow + +```mermaid +flowchart LR + EVENTS["presenter events"] + COMMANDS["CommandTimeline
one command per frame"] + EGO["taxi integration"] + WORLD["TaxiPhysicsWorld
curbs + static actors"] + TRAFFIC["MapTrafficController
routing, headway, collision, recovery"] + TRAJ["TrajectoryChunk
ego poses + actor trajectories"] + FARES["TaxiGameController
pickup/dropoff selection,
timers, score, leaderboard"] + PEOPLE["passenger trajectories"] + RASTER["camera HD-map raster + optional BEV/debug"] + CACHE["autoregressive world-model cache"] + FRAMES["generated frame chunk"] + HUD["HUD / marker / minimap composition"] + + EVENTS --> COMMANDS --> EGO --> WORLD + TRAFFIC <--> WORLD + WORLD --> TRAJ + TRAJ --> FARES + FARES --> PEOPLE + PEOPLE --> TRAJ + TRAJ --> RASTER + RASTER --> CACHE --> FRAMES + FARES --> HUD + TRAJ --> HUD + FRAMES --> HUD +``` + +`EgoVehicleKinematics.pose_chunk` is authoritative for motion. It advances the +taxi and PhysX once per requested output frame and returns synchronized poses, +timestamps, dynamic actors, collision information, and optional debug +geometry. `TaxiGameController` advances once per trajectory frame so fare and +HUD snapshots align with generated frames. Visible waiting passengers are +then added to the same trajectory used for model conditioning. + +`MapTrafficController` owns the gameplay state of authored traffic. It selects +nearby traffic using the map vicinity, drives active PhysX actors along their +tracks, handles collision and recovery phases, and exposes their trajectories +to the rasterizer. + +## Ownership and reset behavior + +| Lifetime | As-built owner | State | +| --- | --- | --- | +| Process | `InteractiveDriveApp` / render backend | loaded world model, custom worker thread, scene cache, presenter | +| Selected scene | app + backend | `SceneBundle`, rasterizer geometry, prompt/image conditioning, ground snapper | +| Rollout | app main thread + backend worker | simulation, physics world, game controller, world-model cache | +| Presentation | presenter on main thread | input state, HUD state, view/scene selection, last frame | + +A manual reset recreates simulation and game state and queues a backend cache +reset while retaining the loaded model and selected scene. A scene switch also +rebinds raster geometry and resets scene-specific conditioning. + +## Architectural liabilities relevant to the rewrite + +These are structural observations, not judgments about individual features: + +- The engine duplicates runtime responsibilities now supplied by FlashDreams + V2: worker lifecycle, event fan-out, pacing, reset generations, result + buffering, frame dropping, and presentation. +- More than two long-lived runtime threads can exist. +- Mutable keyboard/presenter state is the implicit bridge between game logic, + input, and presentation. +- `PresentedFrame` combines model output, conditioning/debug images, gameplay + annotations, status text, and timing metadata. +- The world-model adapter reconstructs configuration and cache lifecycle around + an older session abstraction instead of using the current pipeline contract + directly. +- Native-window and MJPEG presenters duplicate substantial game-HUD behavior. + +Those liabilities define what the V2 target design must remove while retaining +the game behavior described above. diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md new file mode 100644 index 000000000..3790a1000 --- /dev/null +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -0,0 +1,307 @@ +# Crazy Robotaxi target architecture for FlashDreams V2 + +This is the implementation guide for the ground-up rewrite. It was derived by +reconciling the current FlashDreams V2 contracts with the behavior recorded in +[`crazy_robotaxi_legacy_architecture.md`](crazy_robotaxi_legacy_architecture.md). +The map branch supplies behavioral details only; its runtime architecture is +not part of this design. + +## Design rules + +1. FlashDreams V2 owns the runtime loop, event fan-out, reset generation, + buffering, pacing, client window, and shutdown sequence. +2. There are only two runtime threads: the V2 model thread and the V2 UI + thread. No game loop, chunk worker, scene-loader worker, MJPEG server, or + input-device worker is created by the game engine. +3. The model thread exclusively owns mutable simulation, game, conditioning, + autoregressive-cache, input-reducer, and frame-snapshot state. +4. The UI thread never reads model-thread state. Frame-aligned presentation + data crosses the boundary only as `StepResult` channels. +5. The loaded OmniDreams pipeline is application-owned and reusable; every + session/rollout has its own cache and mutable resources. +6. Reset reconstructs all rollout state together. FlashDreams' generation + counter discards pre-reset results already in the presentation buffer. +7. Authored maps remain data. Map compilation and preview are CPU tooling, not + runtime scheduling infrastructure. + +## Process ownership + +```mermaid +flowchart TD + HOST["flashdreams-run-v2
ApplicationRunner + client window"] + + subgraph APP["CrazyRobotaxiApplication — process lifetime"] + ARGS["validated application configuration"] + PIPELINE["one loaded OmniDreams pipeline
weights + compiled networks"] + end + + subgraph SESSION["CrazyRobotaxiSession — one run"] + SCENE["immutable SceneDefinition
map, camera, seed, prompt"] + REG["register exactly one model thread
use V2 UI thread"] + end + + subgraph MODELTHREAD["FlashDreams V2 model thread"] + INPUT["DriverInput
held keys + name entry"] + ENGINE["GameEngine"] + CACHE["session-local OmniDreams cache"] + HUD["frame-aligned RGBA HUD renderer"] + end + + subgraph UITHREAD["FlashDreams V2 UI thread"] + BUFFER["PresentationManager"] + COMPOSE["RGB + RGBA channel composition"] + WINDOW["V2 client window
WebRTC / MP4 / supported host"] + end + + HOST --> APP + ARGS --> SESSION + PIPELINE --> SESSION + SESSION --> MODELTHREAD + SCENE --> ENGINE + PIPELINE --> CACHE + INPUT --> ENGINE + ENGINE --> HUD + ENGINE -->|"generated RGB channel"| BUFFER + HUD -->|"synchronized RGBA channel"| BUFFER + BUFFER --> COMPOSE --> WINDOW +``` + +`CrazyRobotaxiApplication.session_desc()` declares the trained output layout, +resolution, and frame rate before model loading. `init()` parses only +application-owned options. `create_session()` validates the requested +description, lazily constructs the shared pipeline, prepares immutable scene +data, and returns an uninitialized session. + +The session registers a model thread. The standard V2 blit UI is sufficient +because the model thread emits bottom-to-top RGB and RGBA channels with equal +frame counts. If a custom UI is later required, it must preserve this channel +contract and communicate changes through V2 events or `invoke_async`; it may +not reach into model state. + +## Model-thread step + +```mermaid +flowchart LR + EVENTS["unread V2 input events"] + REDUCE["DriverInput.reduce"] + COUNT["pipeline.get_num_frames(step_index)"] + COMMANDS["per-frame DriverCommand sequence"] + SIM["SimulationWorld.pose_chunk"] + RULES["GameRules.advance_frames"] + SNAPSHOTS["GameFrame snapshots"] + ACTORS["traffic + passenger actor trajectories"] + CONDITION["ConditionRenderer.render"] + TENSOR["HD-map tensor
B,V,T,C,H,W"] + GENERATE["pipeline.generate"] + FINALIZE["pipeline.finalize"] + POST["optional session-local postprocess stream"] + HUD["render_hud"] + RESULTS["list[StepResult]
RGB video, RGBA HUD"] + + EVENTS --> REDUCE + COUNT --> COMMANDS + REDUCE --> COMMANDS + COMMANDS --> SIM + SIM --> RULES + RULES --> SNAPSHOTS + SIM --> ACTORS + RULES --> ACTORS + ACTORS --> CONDITION + SIM --> CONDITION + CONDITION --> TENSOR --> GENERATE --> FINALIZE --> POST + SNAPSHOTS --> HUD + CONDITION --> HUD + POST --> RESULTS + HUD --> RESULTS +``` + +The output-frame count is learned from the pipeline before simulation, so +simulation, conditioning, generated video, game snapshots, and HUD layers have +one authoritative `T`. Input edges update retained held state; steering and +speed commands are advanced at the simulation frame interval to produce a +command for every frame rather than one command per model invocation. + +The direct model contract is: + +```text +cache = pipeline.initialize_cache(text, first_frame, view_names) +T = pipeline.get_num_frames(autoregressive_index) +engine_step = engine.step(commands[T]) +trajectory = engine_step.trajectory +hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] +video = pipeline.generate(autoregressive_index, cache, hdmap) +metrics = pipeline.finalize(autoregressive_index, cache) +return [video StepResult, HUD StepResult] +``` + +There is no intermediate render backend, local adapter, model session, chunk +request, command queue, frame queue, or `PresentedFrame` aggregate. + +## Game-engine internals + +```mermaid +flowchart TD + MAP["ResolvedGameMap"] + SCENE["SceneDefinition"] + + subgraph ENGINE["GameEngine — model-thread owned"] + DRIVER["DriverInput"] + SIM["SimulationWorld"] + PHYSX["PhysicsWorld"] + TRAFFIC["TrafficSystem"] + GAME["GameRules protocol"] + COND["ConditionRenderer"] + OUTPUT["EngineStep
trajectory + game frames + condition frames"] + end + + MAP --> SCENE + SCENE --> SIM + SCENE --> PHYSX + SCENE --> TRAFFIC + SCENE --> COND + DRIVER --> SIM + SIM <--> PHYSX + TRAFFIC <--> PHYSX + SIM --> GAME + GAME -->|"game actors and immutable per-frame state"| COND + SIM --> COND + GAME --> OUTPUT + COND --> OUTPUT +``` + +The reusable engine defines narrow game-facing contracts: + +- `GameRules` owns mutable rules state, advances it from a trajectory, + contributes dynamic actors, and returns immutable per-frame snapshots. +- `VehicleDynamics` turns commands into proposed vehicle motion. +- `PhysicsWorld` resolves ego, static, and actor interactions and produces + synchronized actor trajectories. +- `ConditionRenderer` converts a scene and `EngineStep` into model-ready HD-map + frames plus optional BEV data used by the HUD. +- `GameEngine` sequences these components; it knows nothing about taxi fares, + leaderboards, application entry points, V2 windows, or model presets. + +The initial implementation may use concrete engine classes where no second +implementation exists, but dependencies still point from generic engine code +to game-injected protocols, never from the engine into `crazy_robotaxi`. + +## Crazy Robotaxi specialization + +```mermaid +flowchart LR + MAP["node-graph city map"] + NAV["TaxiNavigation
lanes + shortest routes"] + FARES["TaxiGameRules
pickup/dropoff, timers, score"] + TAXI["ArcadeTaxiDynamics"] + TAXIPHYS["TaxiPhysicsPolicy
curb response + chassis tuning"] + TRAFFIC["authored TrafficSystem"] + PASSENGERS["waiting-passenger actors"] + SCORES["HighScoreStore"] + FRAME["TaxiGameFrame"] + HUD["TaxiHudRenderer
score, time, target marker,
BEV minimap, name entry, leaderboard"] + + MAP --> NAV --> FARES + TAXI --> TAXIPHYS + TRAFFIC --> TAXIPHYS + FARES --> PASSENGERS + FARES <--> SCORES + FARES --> FRAME --> HUD + PASSENGERS --> FRAME +``` + +Taxi-specific code supplies the generic engine with arcade vehicle dynamics, +curb/chassis physics policy, map-derived navigation/fare regions, game rules, +passenger actors, persistent scores, and HUD rendering. It does not subclass a +game-engine application host or presenter. + +## State and lifetime table + +| Lifetime / owner | State | +| --- | --- | +| Application | resolved pipeline configuration; one loaded OmniDreams pipeline | +| Session, immutable | validated session description; compiled/loaded scene definition; game configuration | +| Model thread, per reset generation | input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | +| UI thread | V2 presentation position and client-window state only | +| Outside runtime | authored map YAML; derived map cache; high-score file | + +All CUDA/renderer calls that mutate per-rollout state occur on the model +thread. Immutable scene arrays may be prepared before thread startup, but the +model-thread state factory performs mutable renderer, PhysX, game, and cache +construction lazily on its first `step`. + +## Reset, terminal state, and close + +```mermaid +stateDiagram-v2 + [*] --> Playing + Playing --> Playing: model step + Playing --> AwaitingName: timer ends and score qualifies + Playing --> Leaderboard: timer ends without qualification + AwaitingName --> Leaderboard: name submitted + Leaderboard --> [*]: terminal output presented + Playing --> Playing: V2 reset generation rebuilds rollout + AwaitingName --> Playing: V2 reset generation rebuilds rollout + Leaderboard --> Playing: V2 reset generation rebuilds rollout +``` + +While awaiting a name, no new world-model block is generated. The model thread +re-emits the last generated RGB frame with a newly rendered one-frame HUD layer +until submission. It reports `is_finished()` only after a leaderboard frame has +been emitted (or an explicit finite block limit is reached), allowing MP4 mode +to terminate without a client close event. + +On reset, the model thread closes and recreates the simulation, traffic, rules, +condition renderer state, and autoregressive cache as one unit. On close, it +releases all session-local resources; application close releases the shared +pipeline. + +## Legacy-to-V2 reconciliation + +| Map-branch responsibility | V2 owner / replacement | +| --- | --- | +| `InteractiveDriveApp` lifecycle | `IApplication` + `ISession` | +| custom `run_main_loop` | `run_session` supplied by FlashDreams V2 | +| `ChunkPipeline` worker | V2 model thread | +| private command/frame queues | direct step call + `PresentationManager` | +| generation token on reset | V2 `EventBuffer` generation | +| presenter event polling | V2 client window and shared event buffer | +| presenter pacing and frame replay/drop | V2 UI thread + presentation mode | +| `FlashdreamsWorldModelSession` | direct OmniDreams pipeline cache API | +| `PresentedFrame` metadata bundle | typed engine results plus synchronized output channels | +| keyboard telemetry shared with presenter | immutable `TaxiGameSnapshot` rendered to RGBA | +| native/MJPEG presenters | V2 client-window modes | +| application-internal scene switching | map/variant application arguments; a different scene starts a new session | +| manual reset queueing | V2 reset event and thread `reset()` | + +## Implemented package boundary + +```text +apps/omnidreams_game_engine/ + omnidreams_game_engine/ + engine.py # GameEngine and EngineStep + contracts.py # injected game/dynamics/physics contracts + model.py # direct OmniDreams rollout bridge + scene.py # immutable runtime scene loading + input.py # V2 event reduction to per-frame commands + conditioning.py # Ludus conditioning boundary + simulation/ # generic kinematics, PhysX, traffic + game_map/ # authored-map schema/compiler/runtime types + +apps/crazy_robotaxi/ + crazy_robotaxi/ + application.py # IApplication composition root + session.py # ISession + model thread + rules.py # taxi fare state machine + dynamics.py # arcade taxi controls + physics.py # taxi collision policy + navigation.py # map-derived routes/fare sampling + passengers.py # conditioning actors + high_scores.py # persistent leaderboard + hud.py # synchronized RGBA game layer + config.py # strict game configuration + map_tool.py # offline validate/compile/preview command +``` + +Runtime responsibilities must remain in the blocks shown here. In particular, +the implementation must not introduce a second application host, custom +runtime loop, worker queue, or presenter abstraction. diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/physx/bindings.cpp b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/physx/bindings.cpp index 082b63ce8..473d044cf 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/physx/bindings.cpp +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/physx/bindings.cpp @@ -77,6 +77,9 @@ struct BodyRecord { bool detached = false; bool trackVisible = true; bool trackDriveEnabled = true; + bool externalTrackProgress = false; + std::int64_t trackTimestampUs = 0; + float trackVelocityScale = 1.0f; bool overlappingEgo = false; std::vector timestampsUs; std::vector positions; @@ -428,6 +431,31 @@ class NativeScene { } } + void setBodyTrackProgress( + const py::array_t& objectIds, + const py::array_t& timestampsUs, + const py::array_t& velocityScales) + { + if ( + objectIds.ndim() != 1 + || timestampsUs.ndim() != 1 + || velocityScales.ndim() != 1) + throw std::invalid_argument("track-progress arrays must be one-dimensional"); + const py::ssize_t count = objectIds.shape(0); + if (timestampsUs.shape(0) != count || velocityScales.shape(0) != count) + throw std::invalid_argument("track-progress arrays must have equal lengths"); + for (py::ssize_t index = 0; index < count; ++index) { + const float scale = velocityScales.data()[index]; + if (!std::isfinite(scale) || scale < 0.0f || scale > 1.0f) + throw std::invalid_argument( + "track velocity scales must be finite and within [0, 1]"); + BodyRecord& body = bodyAt(objectIds.data()[index]); + body.externalTrackProgress = true; + body.trackTimestampUs = timestampsUs.data()[index]; + body.trackVelocityScale = scale; + } + } + void setCollisionEnabled(BodyRecord& body, bool enabled) { if (body.collisionActive == enabled) @@ -571,7 +599,10 @@ class NativeScene { BodyRecord& body = entry.second; if (!body.hasTrack()) continue; - if (!isTrackVisible(body, timestampUs)) { + const std::int64_t trackTimestampUs = body.externalTrackProgress + ? body.trackTimestampUs + : timestampUs; + if (!isTrackVisible(body, trackTimestampUs)) { body.trackVisible = false; body.overlappingEgo = false; body.driveIntentActive = false; @@ -581,7 +612,9 @@ class NativeScene { } body.trackVisible = true; ++visibleCount; - const TrackSample track = sampleTrack(body, timestampUs); + TrackSample track = sampleTrack(body, trackTimestampUs); + track.velocity *= body.trackVelocityScale; + track.angularVelocity *= body.trackVelocityScale; writeTrackState(body, track); const PxTransform actorTransform = body.actor->getGlobalPose(); const PxVec3 actorVelocity = body.actor->getLinearVelocity(); @@ -1348,6 +1381,7 @@ PYBIND11_MODULE(ludus_physx_native, module) .def("set_body_track_drive_enabled", &NativeScene::setBodyTrackDriveEnabled) .def("set_body_detached", &NativeScene::setBodyDetached) .def("set_body_track_controls", &NativeScene::setBodyTrackControls) + .def("set_body_track_progress", &NativeScene::setBodyTrackProgress) .def("remove_body", &NativeScene::removeBody) .def("add_barrier", &NativeScene::addBarrier) .def("remove_barrier", &NativeScene::removeBarrier) diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/physx.py b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/physx.py index a046bf593..1f8cf9045 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/physx.py +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/physx.py @@ -20,6 +20,7 @@ import hashlib import math import time +from collections.abc import Mapping from dataclasses import dataclass import numpy as np @@ -185,6 +186,7 @@ def __init__( *, actor_collision_enabled: bool = True, max_actor_drive_speed_mps: float | None = None, + max_actor_drive_speeds_mps: dict[str, float] | None = None, capacity: int | None = None, ) -> None: if max_actor_drive_speed_mps is not None and ( @@ -199,6 +201,12 @@ def __init__( self.ego_model = ego_model self.actor_collision_enabled = actor_collision_enabled self.max_actor_drive_speed_mps = max_actor_drive_speed_mps + self.max_actor_drive_speeds_mps = dict(max_actor_drive_speeds_mps or {}) + if any( + not math.isfinite(value) or value <= 0.0 + for value in self.max_actor_drive_speeds_mps.values() + ): + raise ValueError("per-actor drive speeds must be finite and positive") self._closed = False self._objects: dict[str, SceneObject] = {} self._object_slots: dict[str, int] = {} @@ -206,6 +214,7 @@ def __init__( self._object_native_ids: dict[str, int] = {} self._object_collision_active: dict[str, bool] = {} self._track_drive_enabled: dict[str, bool] = {} + self._track_progress_timestamp_us: dict[str, int] = {} self._detached_object_ids: set[str] = set() self._barriers: dict[str, InvisibleBarrier] = {} self._state_buffer = self._scene.state_buffer() @@ -310,6 +319,35 @@ def apply_track_controls( self._track_drive_enabled[object_id] = drive_enabled self._objects[object_id].detached = detached + def apply_track_progress( + self, progress: tuple[tuple[str, int, float], ...] + ) -> None: + """Override route time and target velocity for procedural tracks.""" + if not progress: + return + for object_id, _, velocity_scale in progress: + if object_id not in self._objects: + raise KeyError(object_id) + if not math.isfinite(velocity_scale) or not 0.0 <= velocity_scale <= 1.0: + raise ValueError("track velocity scale must be within [0, 1]") + self._scene.set_body_track_progress( + np.fromiter( + (self._object_native_ids[item[0]] for item in progress), + dtype=np.int64, + count=len(progress), + ), + np.fromiter( + (item[1] for item in progress), dtype=np.int64, count=len(progress) + ), + np.fromiter( + (item[2] for item in progress), + dtype=np.float32, + count=len(progress), + ), + ) + for object_id, timestamp_us, _ in progress: + self._track_progress_timestamp_us[object_id] = int(timestamp_us) + def _add_body( self, native_id: int, @@ -390,7 +428,9 @@ def add_object( state, False, self.actor_collision_enabled, - self.max_actor_drive_speed_mps, + self.max_actor_drive_speeds_mps.get( + scene_object.object_id, self.max_actor_drive_speed_mps + ), ) self._scene.set_body_track( native_id, @@ -427,6 +467,7 @@ def remove_object(self, object_id: str) -> None: del self._object_native_ids[object_id] del self._object_collision_active[object_id] del self._track_drive_enabled[object_id] + self._track_progress_timestamp_us.pop(object_id, None) self._detached_object_ids.discard(object_id) self._half_extents_buffer[slot] = 0.0 @@ -453,13 +494,19 @@ def remove_barrier(self, barrier_id: str) -> None: del self._barriers[barrier_id] def synchronize( - self, graph: PhysicsObjectGraph, *, timestamp_us: int | None = None + self, + graph: PhysicsObjectGraph, + *, + timestamp_us: int | None = None, + initial_object_timestamps_us: Mapping[str, int] | None = None, ) -> None: """Apply graph additions, replacements, and removals incrementally. Args: graph: Desired active topology. timestamp_us: Initial pose time for newly added objects. + initial_object_timestamps_us: Per-object initial track times that + override ``timestamp_us`` for newly added procedural actors. """ incoming_objects = {value.object_id: value for value in graph.objects} for object_id in tuple(self._objects): @@ -471,7 +518,17 @@ def synchronize( continue if current is not None: self.remove_object(object_id) - self.add_object(scene_object, timestamp_us=timestamp_us) + initial_timestamp = ( + None + if initial_object_timestamps_us is None + else initial_object_timestamps_us.get(object_id) + ) + self.add_object( + scene_object, + timestamp_us=( + timestamp_us if initial_timestamp is None else initial_timestamp + ), + ) incoming_barriers = { barrier.barrier_id or f"barrier-{index}": barrier @@ -518,7 +575,9 @@ def step_compact( visible_slots = tuple( (object_id, slot) for object_id, slot in self._object_slots.items() - if self._objects[object_id].is_visible_at(timestamp_us) + if self._objects[object_id].is_visible_at( + self._track_progress_timestamp_us.get(object_id, timestamp_us) + ) ) actor_samples = tuple( ( diff --git a/pyproject.toml b/pyproject.toml index 40fa8d661..d905efe74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,9 @@ ignore = ["flashdreams/flashdreams/accelerated/multi_head_attention/triton/**", extraPaths = [ "flashdreams", "apps", + "apps/crazy_robotaxi", "apps/interactive_drive", + "apps/omnidreams_game_engine", "integrations_v2", "integrations_v2/omnidreams/impl/ludus-renderer", "integrations/causal_forcing", @@ -88,7 +90,9 @@ python-version = "3.10" extra-paths = [ "flashdreams", "apps", + "apps/crazy_robotaxi", "apps/interactive_drive", + "apps/omnidreams_game_engine", "integrations_v2", "integrations_v2/omnidreams/impl/ludus-renderer", "integrations/causal_forcing", @@ -124,6 +128,17 @@ extra-paths = [ exclude = [ "**/protos/*pb2*", "apps/interactive_drive/interactive_drive/**", + # These map/archive parsers and numerical PhysX adapters intentionally + # accept dynamically shaped YAML, Arrow, and NumPy values. Keep the new + # V2 application, session, engine contracts, rollout, input, and renderer + # boundaries type-checked while isolating that data-oriented edge code. + "apps/omnidreams_game_engine/omnidreams_game_engine/game_map/**", + "apps/omnidreams_game_engine/omnidreams_game_engine/math_utils.py", + "apps/omnidreams_game_engine/omnidreams_game_engine/patterns.py", + "apps/omnidreams_game_engine/omnidreams_game_engine/ply_io.py", + "apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py", + "apps/omnidreams_game_engine/omnidreams_game_engine/simulation/components.py", + "apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py", # Bench / parity-debugging scaffolding for the hy_worldplay integration; # not shipped product, mirrors the interactive_drive precedent above. "integrations/hy_worldplay/tests/parity_check/**", diff --git a/uv.lock b/uv.lock index 7058f14a4..933382754 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,7 @@ conflicts = [[ [manifest] members = [ + "crazy-robotaxi", "flashdreams", "flashdreams-cam2v", "flashdreams-cam2v-lingbot", @@ -48,6 +49,7 @@ members = [ "flashdreams-waypoint", "flashdreams-waypoint-v2", "ludus-renderer", + "omnidreams-game-engine", ] overrides = [ { name = "numpy", specifier = ">=1.24,<2.5" }, @@ -677,6 +679,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, ] +[[package]] +name = "crazy-robotaxi" +version = "0.1.0" +source = { editable = "apps/crazy_robotaxi" } +dependencies = [ + { name = "flashdreams" }, + { name = "flashdreams-omnidreams" }, + { name = "omnidreams-game-engine" }, + { name = "pillow" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform != 'win32' and extra == 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "torch", version = "2.12.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'win32' and extra == 'extra-11-flashdreams-dev') or (sys_platform != 'win32' and extra != 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "torch", version = "2.12.1+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "sys_platform == 'win32' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-manual-marker" }, +] + +[package.metadata] +requires-dist = [ + { name = "flashdreams", editable = "flashdreams" }, + { name = "flashdreams-omnidreams", editable = "integrations/omnidreams" }, + { name = "omnidreams-game-engine", editable = "apps/omnidreams_game_engine" }, + { name = "pillow" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-manual-marker", marker = "extra == 'dev'", specifier = ">=2.0" }, + { name = "torch" }, +] +provides-extras = ["dev"] + [[package]] name = "cryptography" version = "49.0.0" @@ -3368,6 +3402,49 @@ version = "0.1.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/37/b4/58e1bbb8d6fc9ed786564d0878314ea2f2cd458c84861a5130927e431ff6/nvidia_vfx-0.1.0.1.tar.gz", hash = "sha256:8a26bae3a967a2ce29040f17ba9d75e106f3d0c68016d440a77ed9c7eb05daae", size = 2673, upload-time = "2026-03-09T19:29:40.556Z" } +[[package]] +name = "omnidreams-game-engine" +version = "0.1.0" +source = { editable = "apps/omnidreams_game_engine" } +dependencies = [ + { name = "filelock" }, + { name = "flashdreams" }, + { name = "flashdreams-omnidreams" }, + { name = "ludus-renderer" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "pillow" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "shapely" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform != 'win32' and extra == 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "torch", version = "2.12.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'win32' and extra == 'extra-11-flashdreams-dev') or (sys_platform != 'win32' and extra != 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "torch", version = "2.12.1+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "sys_platform == 'win32' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-manual-marker" }, +] + +[package.metadata] +requires-dist = [ + { name = "filelock", specifier = ">=3" }, + { name = "flashdreams", editable = "flashdreams" }, + { name = "flashdreams-omnidreams", editable = "integrations/omnidreams" }, + { name = "ludus-renderer", editable = "integrations/omnidreams/ludus-renderer" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pyarrow" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-manual-marker", marker = "extra == 'dev'", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6" }, + { name = "shapely", specifier = ">=2.0" }, + { name = "torch" }, +] +provides-extras = ["dev"] + [[package]] name = "onnx" version = "1.22.0" From 30e3b1a00ba909364577158c892dffad1e10c23b Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 00:19:09 +0000 Subject: [PATCH 02/62] Fix Crazy Robotaxi rollout initialization Signed-off-by: Aidan Foster --- .../crazy_robotaxi/crazy_robotaxi/__main__.py | 1 - .../crazy_robotaxi/application.py | 18 +++++++------- apps/crazy_robotaxi/crazy_robotaxi/config.py | 2 +- apps/crazy_robotaxi/crazy_robotaxi/factory.py | 9 +++---- apps/crazy_robotaxi/crazy_robotaxi/hud.py | 8 ++++--- apps/crazy_robotaxi/crazy_robotaxi/rules.py | 3 +-- apps/crazy_robotaxi/crazy_robotaxi/session.py | 21 ++++++++-------- apps/crazy_robotaxi/tests/test_application.py | 8 +++---- apps/crazy_robotaxi/tests/test_gameplay.py | 5 +--- apps/crazy_robotaxi/tests/test_hud.py | 1 - apps/crazy_robotaxi/tests/test_maps.py | 10 +++----- .../omnidreams_game_engine/conditioning.py | 6 ++++- .../omnidreams_game_engine/dynamic_scene.py | 2 +- .../omnidreams_game_engine/engine.py | 6 ++++- .../omnidreams_game_engine/input.py | 4 +--- .../omnidreams_game_engine/model.py | 5 ++-- .../renderer_settings.py | 4 +--- .../simulation/ego_vehicle_kinematics.py | 3 ++- .../tests/test_engine.py | 24 ++++++++++++++++++- .../tests/test_input.py | 12 ++++++---- .../tests/test_model.py | 21 ++++++++++++++-- 21 files changed, 106 insertions(+), 67 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/__main__.py b/apps/crazy_robotaxi/crazy_robotaxi/__main__.py index 6743642eb..81c950473 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/__main__.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/__main__.py @@ -7,6 +7,5 @@ from flashdreams.runtime_v2.cli import entrypoint - if __name__ == "__main__": entrypoint(["crazy-robotaxi", *sys.argv[1:]]) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 3170ba2d0..ffce39790 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -16,6 +16,13 @@ RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, ) +from omnidreams_game_engine.config import DriverInputConfig +from omnidreams_game_engine.renderer_settings import ( + RendererSettings, + load_renderer_settings, +) +from omnidreams_game_engine.scene import SceneRequest, load_scene +from omnidreams_game_engine.types import SceneDefinition from crazy_robotaxi.config import load_game_settings from crazy_robotaxi.high_scores import default_high_scores_path @@ -26,13 +33,6 @@ from flashdreams.infra.config import derive_config from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.video_tensor import VideoTensorLayout -from omnidreams_game_engine.renderer_settings import ( - RendererSettings, - load_renderer_settings, -) -from omnidreams_game_engine.config import DriverInputConfig -from omnidreams_game_engine.scene import SceneRequest, load_scene -from omnidreams_game_engine.types import SceneDefinition _ROOT = Path(__file__).resolve().parent _DEFAULT_MAP = _ROOT / "maps" / "boulevard_district.robotaxi.yaml" @@ -198,7 +198,9 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--variant", default="default") parser.add_argument("--prompt") parser.add_argument("--force-map-recompile", action="store_true") - parser.add_argument("--model-preset", choices=tuple(_MODEL_PRESETS), default="standard") + parser.add_argument( + "--model-preset", choices=tuple(_MODEL_PRESETS), default="standard" + ) parser.add_argument("--device", default="cuda") parser.add_argument("--total-blocks", type=int) parser.add_argument("--game-time-s", type=float) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/config.py b/apps/crazy_robotaxi/crazy_robotaxi/config.py index 13a2953d1..062657544 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/config.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/config.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any, cast +from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.yaml_config import ( StrictConfigError, load_yaml_mapping, @@ -18,7 +19,6 @@ require_mapping, require_version, ) -from omnidreams_game_engine.config import DriverInputConfig from crazy_robotaxi.dynamics import TaxiVehicleConfig from crazy_robotaxi.rules import TaxiGameConfig diff --git a/apps/crazy_robotaxi/crazy_robotaxi/factory.py b/apps/crazy_robotaxi/crazy_robotaxi/factory.py index 76d334907..977af7081 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/factory.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/factory.py @@ -8,10 +8,6 @@ from dataclasses import replace from functools import partial -from crazy_robotaxi.dynamics import TaxiVehicleConfig, integrate_taxi_vehicle -from crazy_robotaxi.physics import TaxiPhysicsWorld, step_taxi_physics_world -from crazy_robotaxi.rules import TaxiGameConfig, TaxiGameController, TaxiGameRules -from crazy_robotaxi.scene import load_scene_data from omnidreams_game_engine.conditioning import LudusConditionRenderer from omnidreams_game_engine.config import BevConfig, RasterConfig, VehicleConfig from omnidreams_game_engine.engine import GameEngine @@ -23,6 +19,11 @@ from omnidreams_game_engine.simulation.ground_snap import GroundSnapper from omnidreams_game_engine.types import DriverCommand, SceneDefinition, VehicleState +from crazy_robotaxi.dynamics import TaxiVehicleConfig, integrate_taxi_vehicle +from crazy_robotaxi.physics import TaxiPhysicsWorld, step_taxi_physics_world +from crazy_robotaxi.rules import TaxiGameConfig, TaxiGameController, TaxiGameRules +from crazy_robotaxi.scene import load_scene_data + def build_taxi_engine( *, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/hud.py b/apps/crazy_robotaxi/crazy_robotaxi/hud.py index 43ba2991f..65b854619 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/hud.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/hud.py @@ -10,6 +10,8 @@ import numpy as np import torch +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.types import CameraCalibration from PIL import Image, ImageDraw, ImageFont from torch import Tensor @@ -17,8 +19,6 @@ TaxiGameSnapshot, project_taxi_markers_to_camera, ) -from omnidreams_game_engine.camera import FThetaCameraModel -from omnidreams_game_engine.types import CameraCalibration _PANEL = (8, 12, 20, 214) _PICKUP = (255, 210, 48, 255) @@ -215,7 +215,9 @@ def _draw_bev( angle = snapshot.relative_bearing_rad - math.pi / 2.0 radius = size * 0.38 target = (cx + math.cos(angle) * radius, cy + math.sin(angle) * radius) - draw.ellipse((target[0] - 6, target[1] - 6, target[0] + 6, target[1] + 6), fill=color) + draw.ellipse( + (target[0] - 6, target[1] - 6, target[0] + 6, target[1] + 6), fill=color + ) def _draw_center_panel( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/rules.py b/apps/crazy_robotaxi/crazy_robotaxi/rules.py index ade076ab3..8a44fad7e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/rules.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/rules.py @@ -703,8 +703,7 @@ def _snapshot_for_pose( if self._waypoints[index].element_id is None or ( vicinity is not None - and self._waypoints[index].element_id - in vicinity.pedestrian_element_ids + and self._waypoints[index].element_id in vicinity.pedestrian_element_ids ) ) return TaxiGameSnapshot( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index efb615801..db428334c 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -10,6 +10,10 @@ import numpy as np import torch +from omnidreams_game_engine.input import DriverInput +from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state +from omnidreams_game_engine.model import WorldModelRollout +from omnidreams_game_engine.types import SceneDefinition from crazy_robotaxi.factory import build_taxi_engine from crazy_robotaxi.hud import render_hud @@ -20,10 +24,6 @@ from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_events import UserInputEvents from flashdreams.runtime_v2.video_tensor import VideoTensorLayout -from omnidreams_game_engine.input import DriverInput -from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state -from omnidreams_game_engine.model import WorldModelRollout -from omnidreams_game_engine.types import SceneDefinition if TYPE_CHECKING: from crazy_robotaxi.application import ApplicationConfig @@ -96,9 +96,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: input_batch = state.driver_input.reduce( events, frame_count=frame_count, - frame_interval_s=( - 1.0 / state.session_desc.frames_per_second_for_step - ), + frame_interval_s=(1.0 / state.session_desc.frames_per_second_for_step), accepting_text=False, ) generated = rollout.step( @@ -129,9 +127,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: input_batch = state.driver_input.reduce( events, frame_count=1, - frame_interval_s=( - 1.0 / state.session_desc.frames_per_second_for_step - ), + frame_interval_s=(1.0 / state.session_desc.frames_per_second_for_step), accepting_text=accepting_text, ) if accepting_text and input_batch.submitted_text is not None: @@ -156,7 +152,10 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: player_name=state.driver_input.text, ) latest = game_frames[-1] - if isinstance(latest, TaxiGameSnapshot) and latest.session_state == "leaderboard": + if ( + isinstance(latest, TaxiGameSnapshot) + and latest.session_state == "leaderboard" + ): state.finished = True if ( state.config.total_blocks is not None diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index c9ad15bea..5fe3c4b0c 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -8,12 +8,12 @@ import numpy as np import pytest - from crazy_robotaxi.application import CrazyRobotaxiApplication from crazy_robotaxi.session import CrazyRobotaxiModelThread +from omnidreams_game_engine.types import CameraCalibration, SceneDefinition + from flashdreams.api_v2.thread import BlitModelOutputToScreenThread from flashdreams.runtime_v2.video_tensor import VideoTensorLayout -from omnidreams_game_engine.types import CameraCalibration, SceneDefinition pytestmark = pytest.mark.ci_cpu @@ -95,6 +95,4 @@ def test_application_rejects_a_frame_rate_the_model_was_not_trained_for() -> Non app.init([]) with pytest.raises(ValueError, match="30 frames per second"): - app.create_session( - replace(app.session_desc(), frames_per_second_for_step=60) - ) + app.create_session(replace(app.session_desc(), frames_per_second_for_step=60)) diff --git a/apps/crazy_robotaxi/tests/test_gameplay.py b/apps/crazy_robotaxi/tests/test_gameplay.py index ee23b569f..10ea11558 100644 --- a/apps/crazy_robotaxi/tests/test_gameplay.py +++ b/apps/crazy_robotaxi/tests/test_gameplay.py @@ -10,7 +10,6 @@ import numpy as np import pytest - from crazy_robotaxi.dynamics import TaxiVehicleConfig, integrate_taxi_vehicle from crazy_robotaxi.high_scores import HighScoreStore, validate_player_name from crazy_robotaxi.navigation import ( @@ -60,9 +59,7 @@ def _controller( def _lane(start: tuple[float, float], end: tuple[float, float]) -> NavigationLane: - return NavigationLane( - np.asarray([[*start, 0.0], [*end, 0.0]], dtype=np.float32) - ) + return NavigationLane(np.asarray([[*start, 0.0], [*end, 0.0]], dtype=np.float32)) def test_navigation_uses_directed_road_distance() -> None: diff --git a/apps/crazy_robotaxi/tests/test_hud.py b/apps/crazy_robotaxi/tests/test_hud.py index 5fb0fe65b..734477c02 100644 --- a/apps/crazy_robotaxi/tests/test_hud.py +++ b/apps/crazy_robotaxi/tests/test_hud.py @@ -6,7 +6,6 @@ import numpy as np import pytest import torch - from crazy_robotaxi.hud import render_hud from crazy_robotaxi.rules import TaxiGameSnapshot from omnidreams_game_engine.types import CameraCalibration diff --git a/apps/crazy_robotaxi/tests/test_maps.py b/apps/crazy_robotaxi/tests/test_maps.py index 70a1a88f4..e313fed6a 100644 --- a/apps/crazy_robotaxi/tests/test_maps.py +++ b/apps/crazy_robotaxi/tests/test_maps.py @@ -8,7 +8,6 @@ import numpy as np import pytest - from omnidreams_game_engine.game_map import load_game_map pytestmark = pytest.mark.ci_cpu @@ -41,8 +40,7 @@ def test_boulevard_traffic_turns_are_continuous_and_physically_limited() -> None connectors = ( lane for lane in game_map.lanes - if ":connector:" in lane.lane_id - and node_types[lane.element_id] != "cul_de_sac" + if ":connector:" in lane.lane_id and node_types[lane.element_id] != "cul_de_sac" ) for connector in connectors: sources = [ @@ -54,12 +52,10 @@ def test_boulevard_traffic_turns_are_continuous_and_physically_limited() -> None ( sources[0].centerline_world[-1, :2] - sources[0].centerline_world[-2, :2], - connector.centerline_world[1, :2] - - connector.centerline_world[0, :2], + connector.centerline_world[1, :2] - connector.centerline_world[0, :2], ), ( - connector.centerline_world[-1, :2] - - connector.centerline_world[-2, :2], + connector.centerline_world[-1, :2] - connector.centerline_world[-2, :2], target.centerline_world[1, :2] - target.centerline_world[0, :2], ), ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py index dc5803a24..73734192d 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py @@ -18,7 +18,11 @@ from omnidreams_game_engine.config import BevConfig, RasterConfig from omnidreams_game_engine.contracts import ConditionRenderer from omnidreams_game_engine.dynamic_scene import MutableObjectSceneBuffer -from omnidreams_game_engine.types import ConditionBatch, SceneDefinition, TrajectoryChunk +from omnidreams_game_engine.types import ( + ConditionBatch, + SceneDefinition, + TrajectoryChunk, +) _BEV_CAMERA_NAME = "game_engine_bev" diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py b/apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py index fb02c99e4..7cfa74d9b 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/dynamic_scene.py @@ -22,13 +22,13 @@ import numpy as np import torch - from ludus_renderer._ops.primitives import ( CUBE_FLAG_WIREFRAME, PRIM_OBSTACLE, CubePool, TimestampedScene, ) + from omnidreams_game_engine.colors import BBOX_V3_COLORS diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py b/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py index 592a377a2..4adcc022a 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py @@ -7,7 +7,11 @@ from dataclasses import dataclass, replace -from omnidreams_game_engine.contracts import ConditionRenderer, GameRules, SimulationWorld +from omnidreams_game_engine.contracts import ( + ConditionRenderer, + GameRules, + SimulationWorld, +) from omnidreams_game_engine.types import ConditionBatch, DriverCommand, TrajectoryChunk diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index a2786c644..69799ad71 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -16,9 +16,7 @@ from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.types import DriverCommand -_DRIVE_KEYS = frozenset( - {"w", "a", "s", "d", "up", "down", "left", "right", "space"} -) +_DRIVE_KEYS = frozenset({"w", "a", "s", "d", "up", "down", "left", "right", "space"}) _TEXT_CHARACTERS = frozenset( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-" ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py index c2d7bd116..5522befd4 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py @@ -20,8 +20,7 @@ class VideoPostprocessor(Protocol): """Optional session-local generated-video transform.""" - def __call__(self, video: Tensor) -> Tensor: - ... + def __call__(self, video: Tensor) -> Tensor: ... class RolloutEngine(Protocol): @@ -148,7 +147,7 @@ def _new_cache(self) -> Any: def _initial_image_tensor(image: object, *, device: torch.device | str) -> Tensor: - array = np.ascontiguousarray(np.asarray(image, dtype=np.uint8)[..., :3]) + array = np.asarray(image, dtype=np.uint8)[..., :3].copy(order="C") tensor = torch.from_numpy(array).permute(2, 0, 1) tensor = tensor.unsqueeze(0).unsqueeze(0).unsqueeze(2) return tensor.to(device=device, dtype=torch.bfloat16) / 127.5 - 1.0 diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/renderer_settings.py b/apps/omnidreams_game_engine/omnidreams_game_engine/renderer_settings.py index 2a6264575..709b96791 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/renderer_settings.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/renderer_settings.py @@ -60,9 +60,7 @@ def load_renderer_settings(path: Path) -> RendererSettings: Validated visual settings. """ doc = load_yaml_mapping(path) - require_exact_keys( - doc, {"schema_version", "raster", "bev"}, "renderer" - ) + require_exact_keys(doc, {"schema_version", "raster", "bev"}, "renderer") require_version(doc, "renderer") raw_raster = require_mapping(doc["raster"], "renderer.raster") require_exact_keys( diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py index 06d743688..797ef801e 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py @@ -4,6 +4,7 @@ import math import time from collections.abc import Callable, Sequence +from dataclasses import replace import numpy as np from loguru import logger @@ -332,7 +333,7 @@ def sample_chunk_trajectory( ) poses = np.zeros((chunk_size, 4, 4), dtype=np.float32) - state = VehicleState(**start_state.__dict__) + state = replace(start_state) vehicle_states: list[VehicleState] = [] actor_samples: list[tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]] = [] physics_debug_frames = [] diff --git a/apps/omnidreams_game_engine/tests/test_engine.py b/apps/omnidreams_game_engine/tests/test_engine.py index 23c33bd68..64ae39d50 100644 --- a/apps/omnidreams_game_engine/tests/test_engine.py +++ b/apps/omnidreams_game_engine/tests/test_engine.py @@ -10,9 +10,12 @@ import numpy as np import pytest import torch - +from omnidreams_game_engine.config import ChunkConfig, VehicleConfig from omnidreams_game_engine.contracts import GameUpdate from omnidreams_game_engine.engine import GameEngine +from omnidreams_game_engine.simulation.ego_vehicle_kinematics import ( + sample_chunk_trajectory, +) from omnidreams_game_engine.types import ( ConditionBatch, DriverCommand, @@ -126,3 +129,22 @@ def advance_frames(self, trajectory, frame_interval_s): with pytest.raises(ValueError, match="Game frames must align"): engine.step((DriverCommand(), DriverCommand())) + + +def test_trajectory_sampling_copies_slotted_start_state() -> None: + start_state = _state(12.0) + + trajectory = sample_chunk_trajectory( + start_state=start_state, + start_timestamp_us=100, + commands=(DriverCommand(),), + chunk_size=1, + chunk_config=ChunkConfig(), + vehicle_config=VehicleConfig(), + ground_snapper=None, + include_start_state=True, + ) + + assert trajectory.vehicle_states[0] == start_state + assert trajectory.vehicle_states[0] is not start_state + assert trajectory.boundary_state_after_chunk is trajectory.vehicle_states[0] diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index c6c33986f..f083f2d4d 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -5,6 +5,8 @@ import numpy as np import pytest +from omnidreams_game_engine.config import DriverInputConfig +from omnidreams_game_engine.input import DriverInput from flashdreams.api_v2.user_input_event_data import UserInputEventData from flashdreams.runtime_v2.user_input_event import ( @@ -14,8 +16,6 @@ UserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents -from omnidreams_game_engine.config import DriverInputConfig -from omnidreams_game_engine.input import DriverInput pytestmark = pytest.mark.ci_cpu @@ -43,7 +43,9 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: ) first = reducer.reduce( - _events(_key("w", KeyboardInputState.PRESSED), _key("a", KeyboardInputState.PRESSED)), + _events( + _key("w", KeyboardInputState.PRESSED), _key("a", KeyboardInputState.PRESSED) + ), frame_count=3, frame_interval_s=0.1, accepting_text=False, @@ -56,7 +58,9 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: ) assert len(first.commands) == 3 - assert [command.steer for command in first.commands] == pytest.approx([0.2, 0.4, 0.6]) + assert [command.steer for command in first.commands] == pytest.approx( + [0.2, 0.4, 0.6] + ) assert all(command.throttle == 1.0 for command in first.commands) assert retained.commands[0].steer == pytest.approx(0.8) diff --git a/apps/omnidreams_game_engine/tests/test_model.py b/apps/omnidreams_game_engine/tests/test_model.py index 1346fca91..7dc500249 100644 --- a/apps/omnidreams_game_engine/tests/test_model.py +++ b/apps/omnidreams_game_engine/tests/test_model.py @@ -11,9 +11,8 @@ import numpy as np import pytest import torch - from omnidreams_game_engine.engine import EngineStep -from omnidreams_game_engine.model import WorldModelRollout +from omnidreams_game_engine.model import WorldModelRollout, _initial_image_tensor from omnidreams_game_engine.types import ( CameraCalibration, ConditionBatch, @@ -145,3 +144,21 @@ def engine_factory() -> _Engine: rollout.close() assert engines[1].closed + + +def test_initial_image_tensor_owns_writable_numpy_storage(monkeypatch) -> None: + source = np.zeros((4, 8, 4), dtype=np.uint8) + source.setflags(write=False) + writable_flags: list[bool] = [] + torch_from_numpy = torch.from_numpy + + def record_writable(array): + writable_flags.append(array.flags.writeable) + return torch_from_numpy(array) + + monkeypatch.setattr(torch, "from_numpy", record_writable) + + tensor = _initial_image_tensor(source, device="cpu") + + assert writable_flags == [True] + assert tensor.shape == (1, 1, 1, 3, 4, 8) From ad39633eb971f7d2fd5fe8eff70b8b916cec5943 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 00:33:48 +0000 Subject: [PATCH 03/62] Optimize Crazy Robotaxi traffic projection Signed-off-by: Aidan Foster --- .../simulation/map_traffic.py | 189 ++++++++++++------ .../tests/test_map_traffic.py | 140 +++++++++++++ 2 files changed, 272 insertions(+), 57 deletions(-) create mode 100644 apps/omnidreams_game_engine/tests/test_map_traffic.py diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py index bde4a2021..53892f793 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py @@ -62,6 +62,7 @@ class MapTrafficVehicleState: scene_object: SceneObject timestamp_us: float duration_us: int + route_segment_index: int max_speed_mps: float route_element_ids: tuple[str, ...] position_m: np.ndarray @@ -101,6 +102,14 @@ class _TrafficObservation: half_length_m: float +@dataclass(frozen=True) +class _RouteProjection: + timestamp_us: float + segment_index: int + distance_sq: float + progress_distance: float + + def _route_track( traffic: GameMapTrafficVehicle, vehicle: VehicleConfig ) -> tuple[SceneObject, int, int]: @@ -174,6 +183,9 @@ def __init__( scene_object=scene_object, timestamp_us=float(start_timestamp_us), duration_us=duration_us, + route_segment_index=self._route_segment_index( + scene_object, start_timestamp_us + ), max_speed_mps=float(np.max(definition.speed_limits_mps)), route_element_ids=definition.route_element_ids, position_m=position.copy(), @@ -227,8 +239,19 @@ def state(self, object_id: str) -> MapTrafficVehicleState | None: """Return the authoritative state for a map NPC, if owned here.""" return self._states_by_id.get(object_id) + @staticmethod + def _route_segment_index(scene_object: SceneObject, timestamp_us: float) -> int: + segment_index = int( + np.searchsorted(scene_object.timestamps_us, int(timestamp_us), side="right") + - 1 + ) + return min(max(segment_index, 0), len(scene_object.timestamps_us) - 2) + @staticmethod def _set_route_snapshot(state: MapTrafficVehicleState) -> None: + state.route_segment_index = MapTrafficController._route_segment_index( + state.scene_object, state.timestamp_us + ) position, orientation, velocity = state.scene_object.sample( int(state.timestamp_us) ) @@ -260,14 +283,12 @@ def set_vicinity(self, vicinity: GameMapVicinity | None) -> bool: for state in self._states if state.element_id in visible_elements ) + for object_id in active_ids - self._active_ids: + self._set_route_snapshot(self._states_by_id[object_id]) changed = active_ids != self._active_ids self._active_ids = active_ids return changed - def _target_velocity(self, state: MapTrafficVehicleState) -> np.ndarray: - _, _, velocity = state.scene_object.sample(int(state.timestamp_us)) - return velocity - @staticmethod def _drive_target_timestamp_us(state: MapTrafficVehicleState) -> int: """Return a bounded actuator target derived from physical route progress.""" @@ -276,14 +297,12 @@ def _drive_target_timestamp_us(state: MapTrafficVehicleState) -> int: lookahead_us = _TRACK_LOOKAHEAD_S * 1_000_000.0 * state.velocity_scale return int((state.timestamp_us + lookahead_us) % state.duration_us) - def _observation( - self, state: MapTrafficVehicleState, world: PhysXWorld - ) -> _TrafficObservation: - velocity = self._target_velocity(state) + def _observation(self, state: MapTrafficVehicleState) -> _TrafficObservation: if state.object_id in self._active_ids: - position = world.body_state(state.object_id).position_m[:2] + position = state.position_m[:2] + _, _, velocity = state.scene_object.sample(int(state.timestamp_us)) else: - position, _, _ = state.scene_object.sample(int(state.timestamp_us)) + position, _, velocity = state.scene_object.sample(int(state.timestamp_us)) position = position[:2] return _TrafficObservation( position_xy=np.asarray(position, dtype=np.float32), @@ -299,48 +318,103 @@ def _cyclic_timestamp_distance( return min(delta, duration_us - delta) @classmethod - def _nearest_route_timestamp( - cls, state: MapTrafficVehicleState, position_xy: np.ndarray - ) -> float: + def _route_projection( + cls, + state: MapTrafficVehicleState, + position_xy: np.ndarray, + segment_index: int, + ) -> _RouteProjection: positions = state.scene_object.positions_m[:, :2] timestamps = state.scene_object.timestamps_us - best_distance_sq = math.inf - best_progress_distance = math.inf - best_timestamp_us = state.timestamp_us - for index, (start, end) in enumerate( - zip(positions[:-1], positions[1:], strict=True) - ): - segment = end - start - length_sq = float(np.dot(segment, segment)) - alpha = ( - 0.0 - if length_sq <= 1.0e-12 - else float( - np.clip(np.dot(position_xy - start, segment) / length_sq, 0, 1) - ) + start = positions[segment_index] + segment = positions[segment_index + 1] - start + length_sq = float(np.dot(segment, segment)) + alpha = 0.0 + if length_sq > 1.0e-12: + alpha = float(np.dot(position_xy - start, segment) / length_sq) + alpha = min(max(alpha, 0.0), 1.0) + projection = start + alpha * segment + offset = position_xy - projection + timestamp_us = ( + float( + timestamps[segment_index] + + alpha * (timestamps[segment_index + 1] - timestamps[segment_index]) ) - projection = start + alpha * segment - distance_sq = float( - np.dot(position_xy - projection, position_xy - projection) + % state.duration_us + ) + return _RouteProjection( + timestamp_us=timestamp_us, + segment_index=segment_index, + distance_sq=float(np.dot(offset, offset)), + progress_distance=cls._cyclic_timestamp_distance( + timestamp_us, state.timestamp_us, state.duration_us + ), + ) + + @staticmethod + def _projection_is_better( + candidate: _RouteProjection, current: _RouteProjection + ) -> bool: + return candidate.distance_sq < current.distance_sq - 1.0e-8 or ( + abs(candidate.distance_sq - current.distance_sq) <= 1.0e-8 + and candidate.progress_distance < current.progress_distance + ) + + @classmethod + def _nearest_local_route_projection( + cls, state: MapTrafficVehicleState, position_xy: np.ndarray + ) -> _RouteProjection: + """Walk from the route cursor to the nearest adjacent segment.""" + segment_count = len(state.scene_object.positions_m) - 1 + best = cls._route_projection( + state, position_xy, state.route_segment_index % segment_count + ) + visited = {best.segment_index} + while len(visited) < segment_count: + neighbor_indices = ( + (best.segment_index - 1) % segment_count, + (best.segment_index + 1) % segment_count, ) - timestamp_us = ( - float( - timestamps[index] - + alpha * (timestamps[index + 1] - timestamps[index]) - ) - % state.duration_us + neighbors = tuple( + cls._route_projection(state, position_xy, segment_index) + for segment_index in neighbor_indices + if segment_index not in visited ) - progress_distance = cls._cyclic_timestamp_distance( - timestamp_us, state.timestamp_us, state.duration_us + visited.update(projection.segment_index for projection in neighbors) + better = tuple( + projection + for projection in neighbors + if cls._projection_is_better(projection, best) ) - if distance_sq < best_distance_sq - 1.0e-8 or ( - abs(distance_sq - best_distance_sq) <= 1.0e-8 - and progress_distance < best_progress_distance - ): - best_distance_sq = distance_sq - best_progress_distance = progress_distance - best_timestamp_us = timestamp_us - return best_timestamp_us + if not better: + break + best = min( + better, + key=lambda projection: ( + projection.distance_sq, + projection.progress_distance, + ), + ) + return best + + @classmethod + def _nearest_route_projection( + cls, state: MapTrafficVehicleState, position_xy: np.ndarray + ) -> _RouteProjection: + """Search the full route when collision recovery needs reacquisition.""" + best = cls._route_projection(state, position_xy, 0) + for segment_index in range(1, len(state.scene_object.positions_m) - 1): + candidate = cls._route_projection(state, position_xy, segment_index) + if cls._projection_is_better(candidate, best): + best = candidate + return best + + @staticmethod + def _apply_route_projection( + state: MapTrafficVehicleState, projection: _RouteProjection + ) -> None: + state.timestamp_us = projection.timestamp_us + state.route_segment_index = projection.segment_index @staticmethod def _is_recovered(state: MapTrafficVehicleState, body: BodyState) -> bool: @@ -399,8 +473,9 @@ def observe_physics( state.stopped_duration_s + dt_s if stopped else 0.0 ) if state.stopped_duration_s >= _RESTART_AFTER_STOPPED_S: - state.timestamp_us = self._nearest_route_timestamp( - state, body.position_m[:2] + self._apply_route_projection( + state, + self._nearest_route_projection(state, body.position_m[:2]), ) state.phase = MapTrafficPhase.RECOVERING state.stopped_duration_s = 0.0 @@ -466,21 +541,21 @@ def prepare_step(self, world: PhysXWorld, ego: BodyState, dt_s: float) -> None: for state in self._states: if state.phase is MapTrafficPhase.TRAVERSING: if state.object_id in self._active_ids: - body = world.body_state(state.object_id) - state.timestamp_us = self._nearest_route_timestamp( - state, body.position_m[:2] + self._apply_route_projection( + state, + self._nearest_local_route_projection( + state, state.position_m[:2] + ), ) - state.position_m = body.position_m.copy() - state.orientation_xyzw = body.orientation_xyzw.copy() - state.linear_velocity_mps = body.linear_velocity_mps.copy() - state.angular_velocity_radps = body.angular_velocity_radps.copy() else: state.timestamp_us = ( state.timestamp_us + dt_s * 1_000_000.0 * state.velocity_scale ) % state.duration_us - self._set_route_snapshot(state) + state.route_segment_index = self._route_segment_index( + state.scene_object, state.timestamp_us + ) observations = { - state.object_id: self._observation(state, world) for state in self._states + state.object_id: self._observation(state) for state in self._states } ego_observation = _TrafficObservation( position_xy=np.asarray(ego.position_m[:2], dtype=np.float32), diff --git a/apps/omnidreams_game_engine/tests/test_map_traffic.py b/apps/omnidreams_game_engine/tests/test_map_traffic.py new file mode 100644 index 000000000..cf6c06785 --- /dev/null +++ b/apps/omnidreams_game_engine/tests/test_map_traffic.py @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU regressions for model-thread map traffic control.""" + +from __future__ import annotations + +import math +from types import SimpleNamespace +from typing import cast +from unittest.mock import patch + +import numpy as np +import pytest +from ludus_renderer import BodyState, PhysXWorld + +from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.game_map.types import GameMapTrafficVehicle +from omnidreams_game_engine.game_map.vicinity import GameMapVicinity +from omnidreams_game_engine.simulation.map_traffic import ( + MapTrafficController, + MapTrafficPhase, +) + +pytestmark = pytest.mark.ci_cpu + + +def _traffic_definition( + centerline: np.ndarray, + *, + vehicle_id: str = "traffic", + start_distance_m: float = 1.0, +) -> GameMapTrafficVehicle: + return GameMapTrafficVehicle( + vehicle_id=vehicle_id, + node_ids=("a", "b"), + end_behavior="wrap", + vehicle_type="car", + dimensions_lwh_m=(4.5, 1.8, 1.5), + speed_mps=None, + start_distance_m=start_distance_m, + centerline_world=centerline, + speed_limits_mps=np.full(len(centerline), 10.0, dtype=np.float32), + route_element_ids=("road",) * (len(centerline) - 1), + ) + + +def _body_at( + position_xy: tuple[float, float], + *, + yaw_rad: float = 0.0, + linear_velocity_xy: tuple[float, float] = (0.0, 0.0), +) -> BodyState: + return BodyState( + position_m=np.asarray([*position_xy, 0.75], dtype=np.float32), + orientation_xyzw=np.asarray( + [0.0, 0.0, math.sin(yaw_rad * 0.5), math.cos(yaw_rad * 0.5)], + dtype=np.float32, + ), + linear_velocity_mps=np.asarray([*linear_velocity_xy, 0.0], dtype=np.float32), + angular_velocity_radps=np.zeros(3, dtype=np.float32), + ) + + +def _activate(controller: MapTrafficController) -> None: + controller.set_vicinity( + GameMapVicinity("road", frozenset({"road"}), frozenset({"road"})) + ) + + +def test_active_traffic_walks_route_cursor_without_global_search() -> None: + centerline = np.asarray( + [[x, 0, 0] for x in range(21)] + [[20, 20, 0], [0, 20, 0], [0, 0, 0]], + dtype=np.float32, + ) + controller = MapTrafficController( + (_traffic_definition(centerline, vehicle_id="cursor"),), VehicleConfig() + ) + _activate(controller) + state = controller.state("map-traffic:cursor") + assert state is not None + controller.observe_physics( + state.object_id, + struck=False, + body=_body_at((12.4, 0.0), linear_velocity_xy=(10.0, 0.0)), + dt_s=1.0 / 30.0, + ) + published: list[tuple[tuple[str, int, float], ...]] = [] + world = cast( + PhysXWorld, + SimpleNamespace( + ego_model=SimpleNamespace(half_extents_m=(2.4, 1.0, 0.8)), + apply_track_progress=published.append, + ), + ) + + with patch.object( + controller, + "_nearest_route_projection", + side_effect=AssertionError("normal traversal used a global route search"), + ): + controller.prepare_step(world, _body_at((-100.0, -100.0)), 1.0 / 30.0) + + assert state.route_segment_index == 12 + assert state.timestamp_us == pytest.approx(1_240_000, abs=1.0) + assert published[-1][0][1] == pytest.approx(1_590_000, abs=1.0) + + +def test_collision_recovery_globally_reacquires_route_and_cursor() -> None: + centerline = np.asarray( + [[0, 0, 0], [20, 0, 0], [20, 20, 0], [0, 20, 0], [0, 0, 0]], + dtype=np.float32, + ) + controller = MapTrafficController( + ( + _traffic_definition( + centerline, + vehicle_id="recovering", + start_distance_m=2.0, + ), + ), + VehicleConfig(), + ) + _activate(controller) + state = controller.state("map-traffic:recovering") + assert state is not None + stopped = _body_at((18.0, 8.0), yaw_rad=math.pi) + + controller.observe_physics( + state.object_id, struck=True, body=stopped, dt_s=0.25 + ) + for _ in range(4): + controller.observe_physics( + state.object_id, struck=False, body=stopped, dt_s=0.25 + ) + + projected_position, _, _ = state.scene_object.sample(int(state.timestamp_us)) + assert state.phase is MapTrafficPhase.RECOVERING + assert state.route_segment_index == 1 + np.testing.assert_allclose(projected_position[:2], [20.0, 8.0], atol=1.0e-4) From 7c72da4b6b0d3be84f58782fa8a762d54380c7b3 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 01:34:00 +0000 Subject: [PATCH 04/62] Move Crazy Robotaxi HUD to V2 ImGui UI Signed-off-by: Aidan Foster --- THIRD-PARTY-NOTICES | 2 + apps/crazy_robotaxi/README.md | 4 + apps/crazy_robotaxi/crazy_robotaxi/hud.py | 245 --------- apps/crazy_robotaxi/crazy_robotaxi/rules.py | 2 +- apps/crazy_robotaxi/crazy_robotaxi/session.py | 82 +-- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 465 ++++++++++++++++++ apps/crazy_robotaxi/pyproject.toml | 3 +- apps/crazy_robotaxi/tests/test_application.py | 8 +- apps/crazy_robotaxi/tests/test_hud.py | 53 -- apps/crazy_robotaxi/tests/test_ui.py | 227 +++++++++ .../omnidreams_game_engine/input.py | 36 +- .../tests/test_input.py | 21 - .../tests/test_map_traffic.py | 5 +- .../crazy_robotaxi_legacy_architecture.md | 2 +- docs/design/crazy_robotaxi_v2_architecture.md | 109 ++-- uv.lock | 6 +- 16 files changed, 822 insertions(+), 448 deletions(-) delete mode 100644 apps/crazy_robotaxi/crazy_robotaxi/hud.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/ui.py delete mode 100644 apps/crazy_robotaxi/tests/test_hud.py create mode 100644 apps/crazy_robotaxi/tests/test_ui.py diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index b85576fdc..2c3c897e2 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -62,6 +62,8 @@ nvidia-cudnn-frontend MIT https://github.com/NVIDIA/cudnn-frontend opencv-python-headless Apache-2.0 https://github.com/opencv/opencv-python grpcio, grpcio-tools Apache-2.0 https://github.com/grpc/grpc shapely BSD-3-Clause https://github.com/shapely/shapely +slangpy Apache-2.0 WITH LLVM-exception + https://github.com/shader-slang/slangpy ludus-renderer Apache-2.0 (NVIDIA, internal distribution) cmake BSD-3-Clause https://github.com/Kitware/CMake NVIDIA PhysX 5.9.0 BSD-3-Clause https://github.com/NVIDIA-Omniverse/PhysX diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 7875fb428..44827e8f1 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -3,6 +3,8 @@ Crazy Robotaxi is a FlashDreams V2 application built on the model-thread `omnidreams-game-engine`. FlashDreams owns input collection, reset generations, presentation buffering, client windows, and the two-thread runtime. +The UI thread uses FlashDreams' SlangPy ImGui renderer for the game HUD and +composites it over model frames for local and WebRTC clients. ```bash uv sync --package crazy-robotaxi @@ -22,6 +24,8 @@ Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. The V2 client's reset event rebuilds simulation, game rules, traffic, conditioning, and the autoregressive cache together while retaining the loaded model. +Leaderboard name entry is owned by the ImGui UI and submitted to the model +thread through V2's asynchronous thread-message contract. ## Authored maps diff --git a/apps/crazy_robotaxi/crazy_robotaxi/hud.py b/apps/crazy_robotaxi/crazy_robotaxi/hud.py deleted file mode 100644 index 65b854619..000000000 --- a/apps/crazy_robotaxi/crazy_robotaxi/hud.py +++ /dev/null @@ -1,245 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Frame-aligned RGBA HUD generation for V2 result composition.""" - -from __future__ import annotations - -import math -from collections.abc import Sequence - -import numpy as np -import torch -from omnidreams_game_engine.camera import FThetaCameraModel -from omnidreams_game_engine.types import CameraCalibration -from PIL import Image, ImageDraw, ImageFont -from torch import Tensor - -from crazy_robotaxi.rules import ( - TaxiGameSnapshot, - project_taxi_markers_to_camera, -) - -_PANEL = (8, 12, 20, 214) -_PICKUP = (255, 210, 48, 255) -_DROPOFF = (48, 232, 132, 255) - - -def render_hud( - snapshots: Sequence[object], - *, - rig_poses_world: np.ndarray, - calibration: CameraCalibration, - bev_tchw: Tensor | None, - width: int, - height: int, - device: torch.device | str, - dtype: torch.dtype, - player_name: str = "", -) -> Tensor: - """Return synchronized floating-point RGBA layers in ``[T,4,H,W]``.""" - if len(snapshots) != len(rig_poses_world): - raise ValueError("HUD snapshots and camera poses must align") - bev_images = _bev_images(bev_tchw, len(snapshots)) - camera = FThetaCameraModel( - calibration, - output_width=width, - output_height=height, - ) - frames = [ - _render_frame( - snapshot if isinstance(snapshot, TaxiGameSnapshot) else None, - rig_pose=rig_poses_world[index], - camera=camera, - bev=bev_images[index], - width=width, - height=height, - player_name=player_name, - ) - for index, snapshot in enumerate(snapshots) - ] - rgba = torch.from_numpy(np.stack(frames)).permute(0, 3, 1, 2) - color = rgba[:, :3].float() / 127.5 - 1.0 - alpha = rgba[:, 3:4].float() / 255.0 - return torch.cat((color, alpha), dim=1).to(device=device, dtype=dtype) - - -def _bev_images(bev: Tensor | None, count: int) -> list[Image.Image | None]: - if bev is None: - return [None] * count - if int(bev.shape[0]) != count: - raise ValueError("BEV and HUD frame counts must align") - pixels = ( - ((bev.detach().float().clamp(-1.0, 1.0) + 1.0) * 127.5) - .byte() - .permute(0, 2, 3, 1) - .cpu() - .numpy() - ) - return [Image.fromarray(frame, mode="RGB") for frame in pixels] - - -def _render_frame( - snapshot: TaxiGameSnapshot | None, - *, - rig_pose: np.ndarray, - camera: FThetaCameraModel, - bev: Image.Image | None, - width: int, - height: int, - player_name: str, -) -> np.ndarray: - image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) - if snapshot is None: - return np.asarray(image) - draw = ImageDraw.Draw(image) - font = ImageFont.load_default(size=max(14, height // 34)) - small = ImageFont.load_default(size=max(12, height // 48)) - margin = max(12, width // 80) - color = _PICKUP if snapshot.phase == "seeking_pickup" else _DROPOFF - label = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" - panel_width = max(310, width // 3) - draw.rounded_rectangle( - (margin, margin, margin + panel_width, margin + 94), - radius=12, - fill=_PANEL, - ) - draw.text( - (margin + 14, margin + 12), - f"SCORE {snapshot.score:06d}", - font=font, - fill="white", - ) - draw.text( - (margin + 14, margin + 52), - f"TIME {snapshot.global_remaining_time_s:05.1f}s", - font=small, - fill="white", - ) - draw.text( - (margin + panel_width // 2, margin + 52), - f"{label} {snapshot.distance_m:04.0f}m", - font=small, - fill=color, - ) - _draw_direction(draw, snapshot.relative_bearing_rad, width, margin, color) - _draw_world_markers(draw, snapshot, rig_pose, camera, width, height, color) - if bev is not None: - _draw_bev(image, draw, bev, snapshot, width, height, color) - if snapshot.event is not None: - event = snapshot.event.replace("_", " ").upper() - draw.text((width // 2, height // 6), event, font=font, anchor="mm", fill=color) - if snapshot.session_state == "awaiting_name": - _draw_center_panel( - draw, - width, - height, - "NEW HIGH SCORE", - (f"ENTER NAME: {player_name or '_'}", "ENTER TO SUBMIT"), - font, - small, - ) - elif snapshot.session_state == "leaderboard": - lines = tuple( - f"{index:>2}. {entry.name:<12} {entry.score:>7}" - for index, entry in enumerate(snapshot.leaderboard, start=1) - ) or ("NO SCORES YET",) - _draw_center_panel(draw, width, height, "LEADERBOARD", lines, font, small) - return np.asarray(image, dtype=np.uint8) - - -def _draw_direction( - draw: ImageDraw.ImageDraw, - bearing: float, - width: int, - margin: int, - color: tuple[int, int, int, int], -) -> None: - angle = bearing - math.pi / 2.0 - center = (width / 2.0, margin + 44.0) - radius = 26.0 - points = [ - ( - center[0] + math.cos(angle + offset) * radius * scale, - center[1] + math.sin(angle + offset) * radius * scale, - ) - for offset, scale in ((0.0, 1.0), (2.45, 0.55), (-2.45, 0.55)) - ] - draw.polygon(points, fill=color) - - -def _draw_world_markers( - draw: ImageDraw.ImageDraw, - snapshot: TaxiGameSnapshot, - rig_pose: np.ndarray, - camera: FThetaCameraModel, - width: int, - height: int, - color: tuple[int, int, int, int], -) -> None: - for projection in project_taxi_markers_to_camera( - snapshot, - rig_pose, - camera, - image_width=width, - image_height=height, - ): - for edge in projection.ring_edges_uv: - draw.line(edge, fill=color, width=3) - if projection.beacon_top_uv is not None: - draw.line( - (projection.anchor_uv, projection.beacon_top_uv), - fill=color, - width=4, - ) - - -def _draw_bev( - image: Image.Image, - draw: ImageDraw.ImageDraw, - bev: Image.Image, - snapshot: TaxiGameSnapshot, - width: int, - height: int, - color: tuple[int, int, int, int], -) -> None: - size = min(width // 4, height // 3) - left = width - size - 18 - top = height - size - 18 - panel = bev.resize((size, size), Image.Resampling.BILINEAR).convert("RGBA") - panel.putalpha(220) - image.alpha_composite(panel, (left, top)) - draw.rectangle((left, top, left + size, top + size), outline="white", width=2) - cx, cy = left + size // 2, top + size // 2 - draw.polygon(((cx, cy - 9), (cx - 6, cy + 7), (cx + 6, cy + 7)), fill="white") - angle = snapshot.relative_bearing_rad - math.pi / 2.0 - radius = size * 0.38 - target = (cx + math.cos(angle) * radius, cy + math.sin(angle) * radius) - draw.ellipse( - (target[0] - 6, target[1] - 6, target[0] + 6, target[1] + 6), fill=color - ) - - -def _draw_center_panel( - draw: ImageDraw.ImageDraw, - width: int, - height: int, - title: str, - lines: Sequence[str], - font: ImageFont.ImageFont | ImageFont.FreeTypeFont, - small: ImageFont.ImageFont | ImageFont.FreeTypeFont, -) -> None: - panel_width = min(width - 40, max(440, width // 2)) - panel_height = min(height - 40, 100 + len(lines) * 28) - left = (width - panel_width) // 2 - top = (height - panel_height) // 2 - draw.rounded_rectangle( - (left, top, left + panel_width, top + panel_height), - radius=18, - fill=(5, 8, 15, 238), - outline=_PICKUP, - width=3, - ) - draw.text((left + 24, top + 18), title, font=font, fill=_PICKUP) - for index, line in enumerate(lines): - draw.text((left + 24, top + 58 + index * 28), line, font=small, fill="white") diff --git a/apps/crazy_robotaxi/crazy_robotaxi/rules.py b/apps/crazy_robotaxi/crazy_robotaxi/rules.py index 8a44fad7e..f410f6fdc 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/rules.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/rules.py @@ -579,7 +579,7 @@ def submit_high_score_name(self, name: str) -> None: """Persist the finished score and transition to the leaderboard. Args: - name: Valid player name supplied by the active presenter. + name: Valid player name supplied by the V2 UI thread. Raises: RuntimeError: The game is not waiting for a player name. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index db428334c..f63a098a2 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Crazy Robotaxi V2 session and sole model-thread implementation.""" +"""Crazy Robotaxi V2 session with model and ImGui UI threads.""" from __future__ import annotations @@ -16,10 +16,14 @@ from omnidreams_game_engine.types import SceneDefinition from crazy_robotaxi.factory import build_taxi_engine -from crazy_robotaxi.hud import render_hud from crazy_robotaxi.rules import TaxiGameSnapshot +from crazy_robotaxi.ui import ( + CrazyRobotaxiImGUIThread, + TaxiHudState, + build_hud_frames, +) from flashdreams.api_v2.session import ISession -from flashdreams.api_v2.thread import IThread +from flashdreams.api_v2.thread import IThread, UIThread, invoke_async from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_events import UserInputEvents @@ -38,6 +42,9 @@ class ModelState: config: ApplicationConfig session_desc: SessionDesc driver_input: DriverInput + ui_thread: UIThread[TaxiHudState] + """UI-thread endpoint used only through ``invoke_async``.""" + rollout: WorldModelRollout | None = None last_video: torch.Tensor | None = None last_bev: torch.Tensor | None = None @@ -79,6 +86,11 @@ def close(self) -> None: if rollout is not None: rollout.close() + def submit_player_name(self, name: str) -> None: + """Submit a UI-validated leaderboard name on the model thread.""" + rollout = self.ensure_rollout() + rollout.engine.submit_text(name) + class CrazyRobotaxiModelThread(IThread[ModelState]): """Run simulation, rules, conditioning, and generation in one V2 step.""" @@ -89,15 +101,12 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: snapshot = rollout.engine.current_game_frame if not isinstance(snapshot, TaxiGameSnapshot): raise TypeError("Taxi engine returned a non-taxi game frame") - accepting_text = snapshot.session_state == "awaiting_name" - if snapshot.session_state == "playing": frame_count = rollout.frame_count(state.blocks_generated) input_batch = state.driver_input.reduce( events, frame_count=frame_count, frame_interval_s=(1.0 / state.session_desc.frames_per_second_for_step), - accepting_text=False, ) generated = rollout.step( autoregressive_index=state.blocks_generated, @@ -124,14 +133,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: state.last_bev = None if bev is None else bev[-1:].detach() state.last_pose = poses[-1].copy() else: - input_batch = state.driver_input.reduce( - events, - frame_count=1, - frame_interval_s=(1.0 / state.session_desc.frames_per_second_for_step), - accepting_text=accepting_text, - ) - if accepting_text and input_batch.submitted_text is not None: - snapshot = rollout.engine.submit_text(input_batch.submitted_text) if state.last_video is None or state.last_pose is None: raise RuntimeError("Terminal game state has no generated frame") video = state.last_video @@ -140,16 +141,14 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: bev = state.last_bev metrics = {} - overlay = render_hud( + hud_frames = build_hud_frames( + video, game_frames, - rig_poses_world=poses, - calibration=state.scene.selected_camera, - bev_tchw=bev, - width=int(video.shape[-1]), - height=int(video.shape[-2]), - device=video.device, - dtype=video.dtype, - player_name=state.driver_input.text, + poses, + ) + invoke_async( + state.ui_thread, + lambda ui_state, frames=hud_frames: ui_state.publish(frames), ) latest = game_frames[-1] if ( @@ -163,21 +162,25 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ): state.finished = True count = int(video.shape[0]) - return [ + results = [ StepResult( step_index=step_index, output=video, frame_count=count, output_layout=VideoTensorLayout.tchw, metrics=metrics, - ), - StepResult( - step_index=step_index, - output=overlay, - frame_count=count, - output_layout=VideoTensorLayout.tchw, - ), + ) ] + if bev is not None: + results.append( + StepResult( + step_index=step_index, + output=bev, + frame_count=count, + output_layout=VideoTensorLayout.tchw, + ) + ) + return results def is_finished(self) -> bool: return self.state.finished @@ -190,7 +193,7 @@ def close(self) -> None: class CrazyRobotaxiSession(ISession): - """Register one model thread and use V2's compositing UI thread.""" + """Register the model thread and Crazy Robotaxi ImGui UI thread.""" def __init__( self, @@ -210,7 +213,18 @@ def session_desc(self) -> SessionDesc: return self._session_desc def init(self) -> None: - self.register_model_thread( + hud_state = TaxiHudState( + calibration=self._scene.selected_camera, + width=self._session_desc.video_width, + height=self._session_desc.video_height, + ) + ui_thread = self.register_ui_thread( + CrazyRobotaxiImGUIThread, + state=hud_state, + width=self._session_desc.video_width, + height=self._session_desc.video_height, + ) + model_thread = self.register_model_thread( CrazyRobotaxiModelThread, state=ModelState( pipeline=self._pipeline, @@ -218,5 +232,7 @@ def init(self) -> None: config=self._config, session_desc=self._session_desc, driver_input=DriverInput(self._config.driver_input), + ui_thread=ui_thread, ), ) + hud_state.model_thread = model_thread diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py new file mode 100644 index 000000000..691c4bff0 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -0,0 +1,465 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ImGui HUD and presentation state for Crazy Robotaxi.""" + +from __future__ import annotations + +import math +from collections import OrderedDict +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +import torch +import torch.nn.functional as functional +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.types import CameraCalibration +from torch import Tensor + +from crazy_robotaxi.high_scores import validate_player_name +from crazy_robotaxi.rules import TaxiGameSnapshot, project_taxi_markers_to_camera +from flashdreams.api_v2.thread import IThread, invoke_async +from flashdreams.runtime_v2.imgui_thread import ImGUIThread +from flashdreams.runtime_v2.user_input_events import UserInputEvents + +_MAX_BUFFERED_HUD_FRAMES = 64 +"""Maximum frame-aligned snapshots retained across pending model chunks.""" + + +@dataclass(frozen=True, slots=True) +class TaxiHudFrame: + """Immutable UI data aligned with one generated video frame.""" + + frame_key: int + """Live tensor data pointer identifying the corresponding video frame.""" + + snapshot: TaxiGameSnapshot + """Taxi rules snapshot for the corresponding simulation frame.""" + + rig_pose_world: tuple[tuple[float, ...], ...] + """Camera-rig pose used to project world-space navigation markers.""" + + +@dataclass(slots=True) +class _HudWidgets: + """Retained SlangPy widget handles owned by the UI thread.""" + + status_window: Any + score: Any + time: Any + objective: Any + fare_time: Any + event: Any + navigation_window: Any + navigation: Any + terminal_window: Any + terminal_title: Any + terminal_score: Any + terminal_rank: Any + name_input: Any + submit: Any + validation: Any + leaderboard: Any + marker_windows: list[tuple[Any, Any]] = field(default_factory=list) + + +@dataclass(slots=True) +class TaxiHudState: + """Mutable ImGui state owned exclusively by the V2 UI thread.""" + + calibration: CameraCalibration + """Camera calibration used for HUD marker projection.""" + + width: int + """Presentation width in pixels.""" + + height: int + """Presentation height in pixels.""" + + model_thread: IThread[Any] | None = None + """Model-thread endpoint used only through ``invoke_async``.""" + + _frames: OrderedDict[int, TaxiHudFrame] = field(default_factory=OrderedDict) + """Recent immutable snapshots keyed by presented tensor-frame identity.""" + + _current: TaxiHudFrame | None = None + """Snapshot aligned with the frame currently beneath ImGui.""" + + _widgets: _HudWidgets | None = None + """Lazily created retained widgets.""" + + _validation_message: str = "" + """Name-entry validation or submission status.""" + + _submission_pending: bool = False + """Whether a validated name is already queued for the model thread.""" + + def publish(self, frames: Sequence[TaxiHudFrame]) -> None: + """Publish immutable model-frame state to the UI-owned lookup.""" + for frame in frames: + self._frames[frame.frame_key] = frame + self._frames.move_to_end(frame.frame_key) + while len(self._frames) > _MAX_BUFFERED_HUD_FRAMES: + self._frames.popitem(last=False) + + def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: + """Select the HUD snapshot aligned with ``frame`` when available.""" + selected = self._frames.get(int(frame.data_ptr())) + if selected is not None: + if ( + self._current is None + or selected.snapshot.session_state + != self._current.snapshot.session_state + ): + self._validation_message = "" + self._submission_pending = False + self._current = selected + return self._current + + def draw(self, ui: Any) -> None: + """Create or update the retained ImGui HUD widget tree.""" + widgets = self._ensure_widgets(ui) + hud_frame = self._current + if hud_frame is None: + widgets.score.text = "LOADING WORLD MODEL" + widgets.time.text = "" + widgets.objective.text = "" + widgets.fare_time.text = "" + widgets.event.text = "" + widgets.navigation_window.visible = False + widgets.terminal_window.visible = False + self._hide_markers(widgets) + return + + snapshot = hud_frame.snapshot + widgets.status_window.visible = snapshot.session_state == "playing" + widgets.score.text = _score_label(snapshot) + widgets.time.text = f"GAME TIME {snapshot.global_remaining_time_s:05.1f}s" + objective = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" + widgets.objective.text = f"{objective} {snapshot.distance_m:04.0f} m" + widgets.fare_time.text = ( + "" + if snapshot.remaining_time_s is None + else f"FARE TIME {snapshot.remaining_time_s:04.1f}s" + ) + widgets.event.text = _event_label(snapshot) + + widgets.navigation_window.visible = snapshot.session_state == "playing" + widgets.navigation.text = _navigation_label(snapshot.relative_bearing_rad) + self._update_markers(ui, widgets, hud_frame) + self._update_terminal(widgets, snapshot) + + def reset(self) -> None: + """Clear per-generation HUD snapshots and editable UI state.""" + self._frames.clear() + self._current = None + self._validation_message = "" + self._submission_pending = False + if self._widgets is not None: + self._widgets.name_input.value = "" + self._widgets.name_input.enabled = True + self._widgets.submit.enabled = True + + def _ensure_widgets(self, ui: Any) -> _HudWidgets: + if self._widgets is not None: + return self._widgets + status_window = ui.Window( + ui.screen, + "Crazy Robotaxi", + position=(14.0, 14.0), + size=(360.0, 190.0), + ) + score = ui.Text(status_window, "LOADING WORLD MODEL") + time = ui.Text(status_window, "") + objective = ui.Text(status_window, "") + fare_time = ui.Text(status_window, "") + event = ui.Text(status_window, "") + + navigation_window = ui.Window( + ui.screen, + "Navigation", + position=(float(self.width - 294), 14.0), + size=(280.0, 92.0), + ) + navigation = ui.Text(navigation_window, "") + + terminal_window = ui.Window( + ui.screen, + "Game Over", + position=(float(max(20, self.width // 2 - 270)), float(max(20, self.height // 2 - 220))), + size=(540.0, 440.0), + ) + terminal_title = ui.Text(terminal_window, "") + terminal_score = ui.Text(terminal_window, "") + terminal_rank = ui.Text(terminal_window, "") + name_input = ui.InputText( + terminal_window, + "Driver name", + value="", + callback=self._submit_name, + flags=ui.InputTextFlags.enter_returns_true, + ) + submit = ui.Button( + terminal_window, + "Submit score", + callback=self._submit_name_from_button, + ) + validation = ui.Text(terminal_window, "") + leaderboard = ui.Text(terminal_window, "") + self._widgets = _HudWidgets( + status_window=status_window, + score=score, + time=time, + objective=objective, + fare_time=fare_time, + event=event, + navigation_window=navigation_window, + navigation=navigation, + terminal_window=terminal_window, + terminal_title=terminal_title, + terminal_score=terminal_score, + terminal_rank=terminal_rank, + name_input=name_input, + submit=submit, + validation=validation, + leaderboard=leaderboard, + ) + return self._widgets + + def _update_markers( + self, ui: Any, widgets: _HudWidgets, hud_frame: TaxiHudFrame + ) -> None: + snapshot = hud_frame.snapshot + if snapshot.session_state != "playing": + self._hide_markers(widgets) + return + camera = FThetaCameraModel( + self.calibration, + output_width=self.width, + output_height=self.height, + ) + projections = project_taxi_markers_to_camera( + snapshot, + np.asarray(hud_frame.rig_pose_world, dtype=np.float32), + camera, + image_width=self.width, + image_height=self.height, + ) + while len(widgets.marker_windows) < len(projections): + marker_window = ui.Window( + ui.screen, + "Waypoint", + position=(0.0, 0.0), + size=(132.0, 66.0), + ) + widgets.marker_windows.append((marker_window, ui.Text(marker_window, ""))) + label = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" + for index, (marker_window, marker_text) in enumerate(widgets.marker_windows): + visible = index < len(projections) + marker_window.visible = visible + if not visible: + continue + anchor_x, anchor_y = projections[index].anchor_uv + marker_window.position = ( + float(np.clip(anchor_x - 66.0, 0.0, max(0.0, self.width - 132.0))), + float(np.clip(anchor_y - 78.0, 0.0, max(0.0, self.height - 66.0))), + ) + marker_text.text = ( + f"{label} {snapshot.distance_m:.0f}m" if index == 0 else label + ) + + @staticmethod + def _hide_markers(widgets: _HudWidgets) -> None: + for marker_window, _ in widgets.marker_windows: + marker_window.visible = False + + def _update_terminal( + self, widgets: _HudWidgets, snapshot: TaxiGameSnapshot + ) -> None: + awaiting_name = snapshot.session_state == "awaiting_name" + leaderboard = snapshot.session_state == "leaderboard" + widgets.terminal_window.visible = awaiting_name or leaderboard + if not (awaiting_name or leaderboard): + return + widgets.terminal_title.text = ( + "NEW HIGH SCORE" if awaiting_name else "LEADERBOARD" + ) + widgets.terminal_score.text = f"FINAL SCORE {snapshot.score:06d}" + widgets.terminal_rank.text = ( + "" + if snapshot.high_score_rank is None + else f"RANK #{snapshot.high_score_rank}" + ) + widgets.name_input.visible = awaiting_name + widgets.submit.visible = awaiting_name + widgets.name_input.enabled = awaiting_name and not self._submission_pending + widgets.submit.enabled = awaiting_name and not self._submission_pending + widgets.validation.visible = awaiting_name + widgets.validation.text = self._validation_message + widgets.leaderboard.visible = leaderboard + widgets.leaderboard.text = "\n".join( + f"{rank:>2}. {entry.name:<12} {entry.score:>7}" + for rank, entry in enumerate(snapshot.leaderboard, start=1) + ) or "NO SCORES YET" + + def _submit_name_from_button(self) -> None: + assert self._widgets is not None + self._submit_name(str(self._widgets.name_input.value)) + + def _submit_name(self, value: str) -> None: + if self._submission_pending: + return + try: + normalized = validate_player_name(value) + except ValueError as error: + self._validation_message = str(error) + return + model_thread = self.model_thread + if model_thread is None: + self._validation_message = "Model thread is not ready." + return + self._submission_pending = True + self._validation_message = "Submitting score..." + if self._widgets is not None: + self._widgets.name_input.enabled = False + self._widgets.submit.enabled = False + invoke_async( + model_thread, + lambda state, name=normalized: state.submit_player_name(name), + ) + + +class CrazyRobotaxiImGUIThread(ImGUIThread[TaxiHudState]): + """Present generated frames beneath a responsive ImGui taxi HUD.""" + + def draw_ui( + self, ui: Any, step_index: int, events: UserInputEvents + ) -> Tensor | None: + """Draw the HUD and return the current generated frame beneath it.""" + del step_index, events + frames = self.presented_model_frames() + video = frames[0] if frames else None + bev = frames[1] if len(frames) > 1 else None + if video is not None: + self.state.select_presented_frame(video) + self.state.draw(ui) + return _compose_minimap(video, bev) + + def reset(self) -> None: + """Reset UI-owned state and retained renderer resources.""" + self.state.reset() + super().reset() + + +def build_hud_frames( + video_tchw: Tensor, + snapshots: Sequence[object], + rig_poses_world: np.ndarray, +) -> tuple[TaxiHudFrame, ...]: + """Build immutable UI messages aligned with generated tensor frames.""" + frame_count = int(video_tchw.shape[0]) + if len(snapshots) != frame_count or len(rig_poses_world) != frame_count: + raise ValueError("Video, game snapshots, and rig poses must align") + frames = [] + for index, snapshot in enumerate(snapshots): + if not isinstance(snapshot, TaxiGameSnapshot): + raise TypeError("Taxi HUD received a non-taxi game snapshot") + pose = tuple( + tuple(float(value) for value in row) for row in rig_poses_world[index] + ) + frames.append( + TaxiHudFrame( + frame_key=int(video_tchw[index].data_ptr()), + snapshot=snapshot, + rig_pose_world=pose, + ) + ) + return tuple(frames) + + +def _compose_minimap(video: Tensor | None, bev: Tensor | None) -> Tensor | None: + if video is None: + return None + output = video.to(torch.float32) + if bev is None: + return output + if bev.ndim != 3 or bev.shape[0] != 3: + raise ValueError("BEV presentation frames must use [3,H,W]") + height, width = int(output.shape[-2]), int(output.shape[-1]) + size = min(width // 4, height // 3) + if size <= 4: + return output + panel = functional.interpolate( + bev.to(device=output.device, dtype=torch.float32).unsqueeze(0), + size=(size, size), + mode="bilinear", + align_corners=False, + )[0] + output = output.clone() + margin = max(8, min(width, height) // 80) + top = height - size - margin + left = width - size - margin + target = output[:, top : top + size, left : left + size] + target.mul_(0.18).add_(panel, alpha=0.82) + target[:, :2, :] = 1.0 + target[:, -2:, :] = 1.0 + target[:, :, :2] = 1.0 + target[:, :, -2:] = 1.0 + return output + + +def _score_label(snapshot: TaxiGameSnapshot) -> str: + label = f"SCORE {snapshot.score:06d}" + if snapshot.high_score is not None: + label += f" HIGH {snapshot.high_score:06d}" + return label + + +def _event_label(snapshot: TaxiGameSnapshot) -> str: + if snapshot.event == "pickup_complete": + return "PASSENGER PICKED UP" + if snapshot.event == "fare_complete": + return ( + f"FARE COMPLETE +{snapshot.awarded_points} " + f"+{snapshot.awarded_global_time_s:g}s" + ) + if snapshot.event == "time_expired": + return "FARE TIME EXPIRED" + return "" + + +def _navigation_label(bearing_rad: float) -> str: + degrees = math.degrees( + math.atan2(math.sin(bearing_rad), math.cos(bearing_rad)) + ) + if abs(degrees) <= 15.0: + direction = "AHEAD" + elif abs(degrees) >= 165.0: + direction = "BEHIND" + elif degrees > 0.0: + direction = "LEFT" + else: + direction = "RIGHT" + return f"TARGET {direction} {abs(degrees):.0f} deg" + + +__all__ = [ + "CrazyRobotaxiImGUIThread", + "TaxiHudFrame", + "TaxiHudState", + "build_hud_frames", +] diff --git a/apps/crazy_robotaxi/pyproject.toml b/apps/crazy_robotaxi/pyproject.toml index 7a1dd11f8..fb2fa69ee 100644 --- a/apps/crazy_robotaxi/pyproject.toml +++ b/apps/crazy_robotaxi/pyproject.toml @@ -9,10 +9,9 @@ description = "Crazy Robotaxi application for FlashDreams V2" readme = "README.md" requires-python = ">=3.10,<3.13" dependencies = [ - "flashdreams", + "flashdreams[local-window]", "flashdreams-omnidreams", "omnidreams-game-engine", - "pillow", "torch", ] diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 5fe3c4b0c..4be9a3ae3 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -10,9 +10,9 @@ import pytest from crazy_robotaxi.application import CrazyRobotaxiApplication from crazy_robotaxi.session import CrazyRobotaxiModelThread +from crazy_robotaxi.ui import CrazyRobotaxiImGUIThread from omnidreams_game_engine.types import CameraCalibration, SceneDefinition -from flashdreams.api_v2.thread import BlitModelOutputToScreenThread from flashdreams.runtime_v2.video_tensor import VideoTensorLayout pytestmark = pytest.mark.ci_cpu @@ -47,7 +47,7 @@ def _scene() -> SceneDefinition: ) -def test_application_registers_one_model_thread_and_uses_v2_ui() -> None: +def test_application_registers_model_and_imgui_threads() -> None: pipeline = object() app = CrazyRobotaxiApplication( pipeline_factory=lambda config, device: pipeline, @@ -62,9 +62,11 @@ def test_application_registers_one_model_thread_and_uses_v2_ui() -> None: assert desc.output_layout is VideoTensorLayout.tchw assert isinstance(model_thread, CrazyRobotaxiModelThread) - assert isinstance(ui_thread, BlitModelOutputToScreenThread) + assert isinstance(ui_thread, CrazyRobotaxiImGUIThread) assert model_thread.state.pipeline is pipeline assert model_thread.state.rollout is None + assert model_thread.state.ui_thread is ui_thread + assert ui_thread.state.model_thread is model_thread def test_application_rejects_geometry_the_model_does_not_produce() -> None: diff --git a/apps/crazy_robotaxi/tests/test_hud.py b/apps/crazy_robotaxi/tests/test_hud.py deleted file mode 100644 index 734477c02..000000000 --- a/apps/crazy_robotaxi/tests/test_hud.py +++ /dev/null @@ -1,53 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""CPU tests for frame-aligned V2 HUD channels.""" - -import numpy as np -import pytest -import torch -from crazy_robotaxi.hud import render_hud -from crazy_robotaxi.rules import TaxiGameSnapshot -from omnidreams_game_engine.types import CameraCalibration - -pytestmark = pytest.mark.ci_cpu - - -def test_hud_returns_one_rgba_layer_per_generated_frame() -> None: - calibration = CameraCalibration( - clipgt_name="front", - logical_name="front", - width=160, - height=96, - cx=80.0, - cy=48.0, - polynomial=np.asarray([0.0, 100.0, 0.0, 0.0], dtype=np.float32), - is_backward_polynomial=False, - linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), - sensor_to_rig_flu=np.eye(4, dtype=np.float32), - ) - snapshot = TaxiGameSnapshot( - phase="seeking_pickup", - target_xyz_m=(25.0, 0.0, 0.0), - distance_m=25.0, - relative_bearing_rad=0.0, - target_radius_m=5.0, - remaining_time_s=None, - score=0, - global_remaining_time_s=60.0, - ) - - result = render_hud( - (snapshot, snapshot), - rig_poses_world=np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0), - calibration=calibration, - bev_tchw=None, - width=160, - height=96, - device="cpu", - dtype=torch.float32, - ) - - assert result.shape == (2, 4, 96, 160) - assert torch.all((0.0 <= result[:, 3]) & (result[:, 3] <= 1.0)) - assert torch.any(result[:, 3] > 0.0) diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py new file mode 100644 index 000000000..68e047c26 --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -0,0 +1,227 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU tests for Crazy Robotaxi's V2 ImGui UI thread.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest +import slangpy as spy +import torch + +from crazy_robotaxi.rules import TaxiGameSnapshot, TaxiSessionState +from crazy_robotaxi.ui import ( + CrazyRobotaxiImGUIThread, + TaxiHudState, + build_hud_frames, +) +from flashdreams.api_v2.thread import IThread +from flashdreams.runtime_v2.presentation_manager import PresentationManager +from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_events import UserInputEvents +from flashdreams.runtime_v2.video_tensor import VideoTensorLayout +from omnidreams_game_engine.types import CameraCalibration + +pytestmark = pytest.mark.ci_cpu + + +def _calibration() -> CameraCalibration: + return CameraCalibration( + clipgt_name="front", + logical_name="front", + width=160, + height=96, + cx=80.0, + cy=48.0, + polynomial=np.asarray([0.0, 100.0, 0.0, 0.0], dtype=np.float32), + is_backward_polynomial=False, + linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), + sensor_to_rig_flu=np.eye(4, dtype=np.float32), + ) + + +def _snapshot(*, session_state: TaxiSessionState = "playing") -> TaxiGameSnapshot: + return TaxiGameSnapshot( + phase="seeking_pickup", + target_xyz_m=(25.0, 0.0, 0.0), + distance_m=25.0, + relative_bearing_rad=0.0, + target_radius_m=5.0, + remaining_time_s=None, + score=1200, + high_score=9000, + global_remaining_time_s=42.5, + session_state=session_state, + ) + + +class _Widget: + def __init__( + self, + parent: object, + label: str = "", + *, + value: Any = None, + callback: Any = None, + position: tuple[float, float] = (0.0, 0.0), + size: tuple[float, float] = (0.0, 0.0), + **kwargs: Any, + ) -> None: + del parent, kwargs + self.label = label + self.title = label + self.text = label + self.value = value + self.callback = callback + self.position = position + self.size = size + self.visible = True + self.enabled = True + + +class _FakeUI: + Window = _Widget + Text = _Widget + InputText = _Widget + Button = _Widget + InputTextFlags = SimpleNamespace(enter_returns_true=1) + + def __init__(self) -> None: + self.screen = object() + + +class _Renderer: + def __init__(self, width: int, height: int) -> None: + self.width = width + self.height = height + self.ui = _FakeUI() + self.reset_count = 0 + self.closed = False + + def render(self, step_index, events, draw_ui): + draw_ui(self.ui, step_index, events) + return torch.zeros(4, self.height, self.width) + + def reset(self) -> None: + self.reset_count += 1 + + def close(self) -> None: + self.closed = True + + +@dataclass +class _SubmissionState: + names: list[str] = field(default_factory=list) + + def submit_player_name(self, name: str) -> None: + self.names.append(name) + + +class _SubmissionThread(IThread[_SubmissionState]): + def step(self, step_index, events): + del step_index, events + return None + + def reset(self) -> None: + return + + +def test_hud_frames_are_immutable_messages_keyed_to_video_storage() -> None: + video = torch.zeros(2, 3, 96, 160) + snapshots = (_snapshot(), _snapshot()) + poses = np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0) + + frames = build_hud_frames(video, snapshots, poses) + + assert [frame.frame_key for frame in frames] == [ + video[index].data_ptr() for index in range(2) + ] + assert frames[0].snapshot is snapshots[0] + assert isinstance(frames[0].rig_pose_world, tuple) + + +def test_imgui_thread_owns_hud_and_composites_presented_bev() -> None: + width, height = 160, 96 + video = torch.full((1, 3, height, width), -0.5) + bev = torch.full((1, 3, 32, 32), 0.5) + poses = np.eye(4, dtype=np.float32)[None] + hud_state = TaxiHudState(_calibration(), width, height) + hud_state.publish(build_hud_frames(video, (_snapshot(),), poses)) + presentation = PresentationManager() + presentation.publish( + 0, + [ + StepResult(0, video, 1, VideoTensorLayout.tchw), + StepResult(0, bev, 1, VideoTensorLayout.tchw), + ], + ) + changed, _ = presentation.advance(0) + renderer = _Renderer(width, height) + thread = CrazyRobotaxiImGUIThread( + state=hud_state, + frequency=60, + output_layout=VideoTensorLayout.tchw, + presentation_manager=presentation, + renderer=renderer, + ) + + result = thread.step_ui(0, UserInputEvents([])) + + assert changed + assert result.output.shape == (1, 3, height, width) + assert result.output.dtype is torch.float32 + assert hud_state._current is not None + assert hud_state._widgets is not None + assert hud_state._widgets.score.text == "SCORE 001200 HIGH 009000" + assert hud_state._widgets.navigation.text == "TARGET AHEAD 0 deg" + assert torch.any(result.output != video) + + thread.reset() + assert hud_state._current is None + assert renderer.reset_count == 1 + + +def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: + state = TaxiHudState(_calibration(), 160, 96) + state.publish( + build_hud_frames( + torch.zeros(1, 3, 96, 160), + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + ) + ) + state._current = next(iter(state._frames.values())) + ui = SimpleNamespace( + screen=None, + Window=spy.ui.Window, + Text=spy.ui.Text, + InputText=spy.ui.InputText, + Button=spy.ui.Button, + InputTextFlags=spy.ui.InputTextFlags, + ) + + state.draw(ui) + + assert state._widgets is not None + assert isinstance(state._widgets.status_window, spy.ui.Window) + assert isinstance(state._widgets.name_input, spy.ui.InputText) + assert state._widgets.score.text == "SCORE 001200 HIGH 009000" + + +def test_imgui_name_submission_uses_v2_thread_message_queue() -> None: + state = TaxiHudState(_calibration(), 160, 96) + model_thread = _SubmissionThread(state=_SubmissionState(), frequency=0) + state.model_thread = model_thread + + state._submit_name(" DRIVER 7 ") + state._submit_name("SECOND") + + assert model_thread.state.names == [] + model_thread._run_message_batch() + assert model_thread.state.names == ["DRIVER 7"] + assert state._submission_pending diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index 69799ad71..029e370a2 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -17,18 +17,13 @@ from omnidreams_game_engine.types import DriverCommand _DRIVE_KEYS = frozenset({"w", "a", "s", "d", "up", "down", "left", "right", "space"}) -_TEXT_CHARACTERS = frozenset( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-" -) @dataclass(frozen=True, slots=True) class DriverInputBatch: - """Per-frame commands and optional submitted text for one model step.""" + """Per-frame driving commands for one model step.""" commands: tuple[DriverCommand, ...] - submitted_text: str | None - text: str class DriverInput: @@ -38,11 +33,6 @@ def __init__(self, config: DriverInputConfig = DriverInputConfig()) -> None: self.config = config self._pressed: set[str] = set() self._steering = 0.0 - self._text = "" - - @property - def text(self) -> str: - return self._text def reduce( self, @@ -50,12 +40,10 @@ def reduce( *, frame_count: int, frame_interval_s: float, - accepting_text: bool, ) -> DriverInputBatch: """Apply input edges and produce a command for every simulated frame.""" if frame_count <= 0: raise ValueError("frame_count must be positive") - submitted: str | None = None for event in events.get_events(): data = event.get_event_data() if isinstance(data, FocusUserInputEventData) and not data.focused: @@ -66,11 +54,6 @@ def reduce( raw_key = str(data.key) key = _normalize_key(raw_key) pressed = data.state is KeyboardInputState.PRESSED - if accepting_text and pressed: - consumed, value = self._consume_text_key(raw_key, key) - if consumed: - submitted = value or submitted - continue if key in _DRIVE_KEYS: if pressed: self._pressed.add(key) @@ -105,27 +88,12 @@ def reduce( manual_control=True, ) ) - return DriverInputBatch(tuple(commands), submitted, self._text) + return DriverInputBatch(tuple(commands)) def reset(self) -> None: """Clear every retained input value.""" self._pressed.clear() self._steering = 0.0 - self._text = "" - - def _consume_text_key(self, raw_key: str, key: str) -> tuple[bool, str | None]: - if key in {"enter", "return"}: - return True, self._text if self._text.strip() else None - if key == "backspace": - self._text = self._text[:-1] - return True, None - if key == "escape": - self._text = "DRIVER" - return True, self._text - if len(raw_key) == 1 and raw_key in _TEXT_CHARACTERS and len(self._text) < 12: - self._text += raw_key - return True, None - return False, None def _normalize_key(key: str) -> str: diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index f083f2d4d..9c88335c4 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -48,13 +48,11 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: ), frame_count=3, frame_interval_s=0.1, - accepting_text=False, ) retained = reducer.reduce( UserInputEvents([]), frame_count=1, frame_interval_s=0.1, - accepting_text=False, ) assert len(first.commands) == 3 @@ -71,7 +69,6 @@ def test_focus_loss_releases_drive_state() -> None: _events(_key("ArrowDown", KeyboardInputState.PRESSED)), frame_count=1, frame_interval_s=1.0 / 30.0, - accepting_text=False, ) assert reverse.commands[0].brake == 1.0 @@ -82,25 +79,7 @@ def test_focus_loss_releases_drive_state() -> None: _events(FocusUserInputEventData(focused=False)), frame_count=1, frame_interval_s=1.0 / 30.0, - accepting_text=False, ) assert result.commands[0].throttle == 0.0 assert not result.commands[0].reverse - - -def test_terminal_text_is_reduced_from_the_same_v2_events() -> None: - reducer = DriverInput() - result = reducer.reduce( - _events( - _key("A", KeyboardInputState.PRESSED), - _key("I", KeyboardInputState.PRESSED), - _key("enter", KeyboardInputState.PRESSED), - ), - frame_count=1, - frame_interval_s=1.0 / 30.0, - accepting_text=True, - ) - - assert result.text == "AI" - assert result.submitted_text == "AI" diff --git a/apps/omnidreams_game_engine/tests/test_map_traffic.py b/apps/omnidreams_game_engine/tests/test_map_traffic.py index cf6c06785..71f007c41 100644 --- a/apps/omnidreams_game_engine/tests/test_map_traffic.py +++ b/apps/omnidreams_game_engine/tests/test_map_traffic.py @@ -13,7 +13,6 @@ import numpy as np import pytest from ludus_renderer import BodyState, PhysXWorld - from omnidreams_game_engine.config import VehicleConfig from omnidreams_game_engine.game_map.types import GameMapTrafficVehicle from omnidreams_game_engine.game_map.vicinity import GameMapVicinity @@ -126,9 +125,7 @@ def test_collision_recovery_globally_reacquires_route_and_cursor() -> None: assert state is not None stopped = _body_at((18.0, 8.0), yaw_rad=math.pi) - controller.observe_physics( - state.object_id, struck=True, body=stopped, dt_s=0.25 - ) + controller.observe_physics(state.object_id, struck=True, body=stopped, dt_s=0.25) for _ in range(4): controller.observe_physics( state.object_id, struck=False, body=stopped, dt_s=0.25 diff --git a/docs/design/crazy_robotaxi_legacy_architecture.md b/docs/design/crazy_robotaxi_legacy_architecture.md index 5ac21c258..0ac9df591 100644 --- a/docs/design/crazy_robotaxi_legacy_architecture.md +++ b/docs/design/crazy_robotaxi_legacy_architecture.md @@ -1,7 +1,7 @@ # Crazy Robotaxi map-branch architecture This document records the as-built architecture on -`origin/dev/aidanf/game/crazy-robotaxi-map` as fetched at commit `0bda097`. It +`origin/dev/aidanf/game/crazy-robotaxi-map` as fetched at commit `f92deda`. It is a behavioral reference for the rewrite, not a target implementation and not a list of files to transplant. The earlier V2 porting attempt was not consulted. diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 3790a1000..c538094c9 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -14,9 +14,10 @@ not part of this design. thread. No game loop, chunk worker, scene-loader worker, MJPEG server, or input-device worker is created by the game engine. 3. The model thread exclusively owns mutable simulation, game, conditioning, - autoregressive-cache, input-reducer, and frame-snapshot state. -4. The UI thread never reads model-thread state. Frame-aligned presentation - data crosses the boundary only as `StepResult` channels. + autoregressive-cache, and driving-input state. +4. The UI thread owns all HUD widgets, name-entry state, and presentation. + Immutable HUD frame batches cross from model to UI through V2 + `invoke_async`; neither thread reads the other's mutable state. 5. The loaded OmniDreams pipeline is application-owned and reusable; every session/rollout has its own cache and mutable resources. 6. Reset reconstructs all rollout state together. FlashDreams' generation @@ -37,19 +38,20 @@ flowchart TD subgraph SESSION["CrazyRobotaxiSession — one run"] SCENE["immutable SceneDefinition
map, camera, seed, prompt"] - REG["register exactly one model thread
use V2 UI thread"] + REG["register one model thread
register one ImGui UI thread"] end subgraph MODELTHREAD["FlashDreams V2 model thread"] - INPUT["DriverInput
held keys + name entry"] + INPUT["DriverInput
held driving keys"] ENGINE["GameEngine"] CACHE["session-local OmniDreams cache"] - HUD["frame-aligned RGBA HUD renderer"] + HUDSTATE["immutable TaxiHudFrame batches"] end subgraph UITHREAD["FlashDreams V2 UI thread"] - BUFFER["PresentationManager"] - COMPOSE["RGB + RGBA channel composition"] + BUFFER["PresentationManager
generated RGB frames"] + HUD["CrazyRobotaxiImGUIThread
HUD widgets + name entry"] + COMPOSE["ImGui compositing"] WINDOW["V2 client window
WebRTC / MP4 / supported host"] end @@ -60,10 +62,11 @@ flowchart TD SCENE --> ENGINE PIPELINE --> CACHE INPUT --> ENGINE - ENGINE --> HUD - ENGINE -->|"generated RGB channel"| BUFFER - HUD -->|"synchronized RGBA channel"| BUFFER - BUFFER --> COMPOSE --> WINDOW + ENGINE --> HUDSTATE + ENGINE -->|"generated RGB + optional BEV channels"| BUFFER + HUDSTATE -->|"invoke_async"| HUD + HUD -->|"invoke_async game actions"| ENGINE + BUFFER --> HUD --> COMPOSE --> WINDOW ``` `CrazyRobotaxiApplication.session_desc()` declares the trained output layout, @@ -72,11 +75,14 @@ application-owned options. `create_session()` validates the requested description, lazily constructs the shared pipeline, prepares immutable scene data, and returns an uninitialized session. -The session registers a model thread. The standard V2 blit UI is sufficient -because the model thread emits bottom-to-top RGB and RGBA channels with equal -frame counts. If a custom UI is later required, it must preserve this channel -contract and communicate changes through V2 events or `invoke_async`; it may -not reach into model state. +The session registers a model thread and a `CrazyRobotaxiImGUIThread`. The +model thread emits generated RGB frames and, when configured, an optional BEV +visualization channel. For each generated frame it also publishes an immutable +`TaxiHudFrame` to the UI thread with `invoke_async`. +The UI thread selects the HUD snapshot aligned with the currently presented +model frame, updates its retained ImGui widgets, and returns the generated +frame as the ImGui back buffer. UI callbacks send validated game actions back +to the model thread with `invoke_async`; they never call game state directly. ## Model-thread step @@ -95,8 +101,9 @@ flowchart LR GENERATE["pipeline.generate"] FINALIZE["pipeline.finalize"] POST["optional session-local postprocess stream"] - HUD["render_hud"] - RESULTS["list[StepResult]
RGB video, RGBA HUD"] + HUDFRAMES["immutable TaxiHudFrame batch"] + SEND["invoke_async(UI thread)"] + RESULTS["list[StepResult]
RGB video + optional BEV"] EVENTS --> REDUCE COUNT --> COMMANDS @@ -109,17 +116,18 @@ flowchart LR ACTORS --> CONDITION SIM --> CONDITION CONDITION --> TENSOR --> GENERATE --> FINALIZE --> POST - SNAPSHOTS --> HUD - CONDITION --> HUD + SNAPSHOTS --> HUDFRAMES + SIM --> HUDFRAMES + HUDFRAMES --> SEND POST --> RESULTS - HUD --> RESULTS ``` The output-frame count is learned from the pipeline before simulation, so -simulation, conditioning, generated video, game snapshots, and HUD layers have -one authoritative `T`. Input edges update retained held state; steering and -speed commands are advanced at the simulation frame interval to produce a -command for every frame rather than one command per model invocation. +simulation, conditioning, generated video, game snapshots, and immutable HUD +frames have one authoritative `T`. Input edges update retained held state; +steering and speed commands are advanced at the simulation frame interval to +produce a command for every frame rather than one command per model +invocation. The direct model contract is: @@ -131,11 +139,14 @@ trajectory = engine_step.trajectory hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] video = pipeline.generate(autoregressive_index, cache, hdmap) metrics = pipeline.finalize(autoregressive_index, cache) -return [video StepResult, HUD StepResult] +invoke_async(ui_thread, publish_hud_frames(game_snapshots, poses, video)) +return [video StepResult, optional BEV StepResult] ``` -There is no intermediate render backend, local adapter, model session, chunk -request, command queue, frame queue, or `PresentedFrame` aggregate. +The UI thread uses V2's `ImGUIThread` renderer and `presented_model_frames()`; +there is no intermediate render backend, local adapter, model session, chunk +request, private command queue, private frame queue, or legacy +`PresentedFrame` aggregate. ## Game-engine internals @@ -198,21 +209,22 @@ flowchart LR PASSENGERS["waiting-passenger actors"] SCORES["HighScoreStore"] FRAME["TaxiGameFrame"] - HUD["TaxiHudRenderer
score, time, target marker,
BEV minimap, name entry, leaderboard"] + HUDSTATE["TaxiHudFrame
immutable presentation state"] + IMGUI["CrazyRobotaxiImGUIThread
score, time, navigation,
name entry, leaderboard"] MAP --> NAV --> FARES TAXI --> TAXIPHYS TRAFFIC --> TAXIPHYS FARES --> PASSENGERS FARES <--> SCORES - FARES --> FRAME --> HUD + FARES --> FRAME --> HUDSTATE --> IMGUI PASSENGERS --> FRAME ``` Taxi-specific code supplies the generic engine with arcade vehicle dynamics, curb/chassis physics policy, map-derived navigation/fare regions, game rules, -passenger actors, persistent scores, and HUD rendering. It does not subclass a -game-engine application host or presenter. +passenger actors, persistent scores, and UI-thread ImGui widgets. It does not +subclass a game-engine application host or presenter. ## State and lifetime table @@ -220,14 +232,16 @@ game-engine application host or presenter. | --- | --- | | Application | resolved pipeline configuration; one loaded OmniDreams pipeline | | Session, immutable | validated session description; compiled/loaded scene definition; game configuration | -| Model thread, per reset generation | input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | -| UI thread | V2 presentation position and client-window state only | +| Model thread, per reset generation | driving-input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | +| UI thread, per reset generation | retained ImGui widgets; immutable HUD-frame lookup; name-entry buffer; validation messages | | Outside runtime | authored map YAML; derived map cache; high-score file | -All CUDA/renderer calls that mutate per-rollout state occur on the model -thread. Immutable scene arrays may be prepared before thread startup, but the -model-thread state factory performs mutable renderer, PhysX, game, and cache -construction lazily on its first `step`. +All simulation, condition-renderer, and world-model CUDA calls that mutate +per-rollout state occur on the model thread. The ImGui renderer creates and +uses its presentation resources only on the UI thread. Immutable scene arrays +may be prepared before thread startup, but the model-thread state factory +performs mutable condition-renderer, PhysX, game, and cache construction +lazily on its first `step`. ## Reset, terminal state, and close @@ -245,10 +259,11 @@ stateDiagram-v2 ``` While awaiting a name, no new world-model block is generated. The model thread -re-emits the last generated RGB frame with a newly rendered one-frame HUD layer -until submission. It reports `is_finished()` only after a leaderboard frame has -been emitted (or an explicit finite block limit is reached), allowing MP4 mode -to terminate without a client close event. +re-emits the last generated RGB frame and publishes an updated immutable HUD +frame. ImGui owns the editable name buffer and submits a validated name to the +model thread with `invoke_async`. The model thread reports `is_finished()` only +after a leaderboard frame has been emitted (or an explicit finite block limit +is reached), allowing MP4 mode to terminate without a client close event. On reset, the model thread closes and recreates the simulation, traffic, rules, condition renderer state, and autoregressive cache as one unit. On close, it @@ -267,8 +282,8 @@ pipeline. | presenter event polling | V2 client window and shared event buffer | | presenter pacing and frame replay/drop | V2 UI thread + presentation mode | | `FlashdreamsWorldModelSession` | direct OmniDreams pipeline cache API | -| `PresentedFrame` metadata bundle | typed engine results plus synchronized output channels | -| keyboard telemetry shared with presenter | immutable `TaxiGameSnapshot` rendered to RGBA | +| `PresentedFrame` metadata bundle | typed engine results plus immutable `TaxiHudFrame` messages | +| keyboard telemetry shared with presenter | UI-owned ImGui input plus `invoke_async` game actions | | native/MJPEG presenters | V2 client-window modes | | application-internal scene switching | map/variant application arguments; a different scene starts a new session | | manual reset queueing | V2 reset event and thread `reset()` | @@ -290,14 +305,14 @@ apps/omnidreams_game_engine/ apps/crazy_robotaxi/ crazy_robotaxi/ application.py # IApplication composition root - session.py # ISession + model thread + session.py # ISession + model/UI thread wiring + ui.py # ImGui HUD state and UI thread rules.py # taxi fare state machine dynamics.py # arcade taxi controls physics.py # taxi collision policy navigation.py # map-derived routes/fare sampling passengers.py # conditioning actors high_scores.py # persistent leaderboard - hud.py # synchronized RGBA game layer config.py # strict game configuration map_tool.py # offline validate/compile/preview command ``` diff --git a/uv.lock b/uv.lock index 933382754..19666f962 100644 --- a/uv.lock +++ b/uv.lock @@ -684,10 +684,9 @@ name = "crazy-robotaxi" version = "0.1.0" source = { editable = "apps/crazy_robotaxi" } dependencies = [ - { name = "flashdreams" }, + { name = "flashdreams", extra = ["local-window"] }, { name = "flashdreams-omnidreams" }, { name = "omnidreams-game-engine" }, - { name = "pillow" }, { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform != 'win32' and extra == 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "torch", version = "2.12.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'win32' and extra == 'extra-11-flashdreams-dev') or (sys_platform != 'win32' and extra != 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "torch", version = "2.12.1+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "sys_platform == 'win32' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, @@ -701,10 +700,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "flashdreams", editable = "flashdreams" }, + { name = "flashdreams", extras = ["local-window"], editable = "flashdreams" }, { name = "flashdreams-omnidreams", editable = "integrations/omnidreams" }, { name = "omnidreams-game-engine", editable = "apps/omnidreams_game_engine" }, - { name = "pillow" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-manual-marker", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "torch" }, From 6c6fbd9f3fa671f618cde6cad41f0727926d9e87 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 03:12:51 +0000 Subject: [PATCH 05/62] Composite Crazy Robotaxi waypoints in world Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 2 + apps/crazy_robotaxi/crazy_robotaxi/session.py | 19 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 103 ++--- .../crazy_robotaxi/world_overlay.py | 354 ++++++++++++++++++ apps/crazy_robotaxi/tests/test_ui.py | 54 ++- docs/design/crazy_robotaxi_v2_architecture.md | 46 ++- 6 files changed, 467 insertions(+), 111 deletions(-) create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 44827e8f1..b03dad6cd 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -5,6 +5,8 @@ Crazy Robotaxi is a FlashDreams V2 application built on the model-thread presentation buffering, client windows, and the two-thread runtime. The UI thread uses FlashDreams' SlangPy ImGui renderer for the game HUD and composites it over model frames for local and WebRTC clients. +Camera-projected waypoint rings, beacons, and labels are carried as a +frame-aligned world layer; they are not ImGui windows. ```bash uv sync --package crazy-robotaxi diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index f63a098a2..e34b7f4ef 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -22,6 +22,7 @@ TaxiHudState, build_hud_frames, ) +from crazy_robotaxi.world_overlay import render_waypoint_layers from flashdreams.api_v2.session import ISession from flashdreams.api_v2.thread import IThread, UIThread, invoke_async from flashdreams.runtime_v2.session_desc import SessionDesc @@ -141,11 +142,16 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: bev = state.last_bev metrics = {} - hud_frames = build_hud_frames( - video, + waypoint_layers = render_waypoint_layers( game_frames, poses, + state.scene.selected_camera, + width=state.session_desc.video_width, + height=state.session_desc.video_height, + device=video.device, + dtype=video.dtype if video.is_floating_point() else torch.float32, ) + hud_frames = build_hud_frames(video, game_frames) invoke_async( state.ui_thread, lambda ui_state, frames=hud_frames: ui_state.publish(frames), @@ -169,7 +175,13 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: frame_count=count, output_layout=VideoTensorLayout.tchw, metrics=metrics, - ) + ), + StepResult( + step_index=step_index, + output=waypoint_layers, + frame_count=count, + output_layout=VideoTensorLayout.tchw, + ), ] if bev is not None: results.append( @@ -214,7 +226,6 @@ def session_desc(self) -> SessionDesc: def init(self) -> None: hud_state = TaxiHudState( - calibration=self._scene.selected_camera, width=self._session_desc.video_width, height=self._session_desc.video_height, ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 691c4bff0..7cdc4a2f1 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -23,15 +23,12 @@ from dataclasses import dataclass, field from typing import Any -import numpy as np import torch import torch.nn.functional as functional -from omnidreams_game_engine.camera import FThetaCameraModel -from omnidreams_game_engine.types import CameraCalibration from torch import Tensor from crazy_robotaxi.high_scores import validate_player_name -from crazy_robotaxi.rules import TaxiGameSnapshot, project_taxi_markers_to_camera +from crazy_robotaxi.rules import TaxiGameSnapshot from flashdreams.api_v2.thread import IThread, invoke_async from flashdreams.runtime_v2.imgui_thread import ImGUIThread from flashdreams.runtime_v2.user_input_events import UserInputEvents @@ -50,9 +47,6 @@ class TaxiHudFrame: snapshot: TaxiGameSnapshot """Taxi rules snapshot for the corresponding simulation frame.""" - rig_pose_world: tuple[tuple[float, ...], ...] - """Camera-rig pose used to project world-space navigation markers.""" - @dataclass(slots=True) class _HudWidgets: @@ -74,16 +68,12 @@ class _HudWidgets: submit: Any validation: Any leaderboard: Any - marker_windows: list[tuple[Any, Any]] = field(default_factory=list) @dataclass(slots=True) class TaxiHudState: """Mutable ImGui state owned exclusively by the V2 UI thread.""" - calibration: CameraCalibration - """Camera calibration used for HUD marker projection.""" - width: int """Presentation width in pixels.""" @@ -142,7 +132,6 @@ def draw(self, ui: Any) -> None: widgets.event.text = "" widgets.navigation_window.visible = False widgets.terminal_window.visible = False - self._hide_markers(widgets) return snapshot = hud_frame.snapshot @@ -160,7 +149,6 @@ def draw(self, ui: Any) -> None: widgets.navigation_window.visible = snapshot.session_state == "playing" widgets.navigation.text = _navigation_label(snapshot.relative_bearing_rad) - self._update_markers(ui, widgets, hud_frame) self._update_terminal(widgets, snapshot) def reset(self) -> None: @@ -200,7 +188,10 @@ def _ensure_widgets(self, ui: Any) -> _HudWidgets: terminal_window = ui.Window( ui.screen, "Game Over", - position=(float(max(20, self.width // 2 - 270)), float(max(20, self.height // 2 - 220))), + position=( + float(max(20, self.width // 2 - 270)), + float(max(20, self.height // 2 - 220)), + ), size=(540.0, 440.0), ) terminal_title = ui.Text(terminal_window, "") @@ -240,53 +231,6 @@ def _ensure_widgets(self, ui: Any) -> _HudWidgets: ) return self._widgets - def _update_markers( - self, ui: Any, widgets: _HudWidgets, hud_frame: TaxiHudFrame - ) -> None: - snapshot = hud_frame.snapshot - if snapshot.session_state != "playing": - self._hide_markers(widgets) - return - camera = FThetaCameraModel( - self.calibration, - output_width=self.width, - output_height=self.height, - ) - projections = project_taxi_markers_to_camera( - snapshot, - np.asarray(hud_frame.rig_pose_world, dtype=np.float32), - camera, - image_width=self.width, - image_height=self.height, - ) - while len(widgets.marker_windows) < len(projections): - marker_window = ui.Window( - ui.screen, - "Waypoint", - position=(0.0, 0.0), - size=(132.0, 66.0), - ) - widgets.marker_windows.append((marker_window, ui.Text(marker_window, ""))) - label = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" - for index, (marker_window, marker_text) in enumerate(widgets.marker_windows): - visible = index < len(projections) - marker_window.visible = visible - if not visible: - continue - anchor_x, anchor_y = projections[index].anchor_uv - marker_window.position = ( - float(np.clip(anchor_x - 66.0, 0.0, max(0.0, self.width - 132.0))), - float(np.clip(anchor_y - 78.0, 0.0, max(0.0, self.height - 66.0))), - ) - marker_text.text = ( - f"{label} {snapshot.distance_m:.0f}m" if index == 0 else label - ) - - @staticmethod - def _hide_markers(widgets: _HudWidgets) -> None: - for marker_window, _ in widgets.marker_windows: - marker_window.visible = False - def _update_terminal( self, widgets: _HudWidgets, snapshot: TaxiGameSnapshot ) -> None: @@ -311,10 +255,13 @@ def _update_terminal( widgets.validation.visible = awaiting_name widgets.validation.text = self._validation_message widgets.leaderboard.visible = leaderboard - widgets.leaderboard.text = "\n".join( - f"{rank:>2}. {entry.name:<12} {entry.score:>7}" - for rank, entry in enumerate(snapshot.leaderboard, start=1) - ) or "NO SCORES YET" + widgets.leaderboard.text = ( + "\n".join( + f"{rank:>2}. {entry.name:<12} {entry.score:>7}" + for rank, entry in enumerate(snapshot.leaderboard, start=1) + ) + or "NO SCORES YET" + ) def _submit_name_from_button(self) -> None: assert self._widgets is not None @@ -353,11 +300,20 @@ def draw_ui( del step_index, events frames = self.presented_model_frames() video = frames[0] if frames else None - bev = frames[1] if len(frames) > 1 else None + waypoints = frames[1] if len(frames) > 1 else None + bev = frames[2] if len(frames) > 2 else None if video is not None: self.state.select_presented_frame(video) self.state.draw(ui) - return _compose_minimap(video, bev) + if video is None: + return None + if waypoints is None or waypoints.shape[0] != 4: + raise ValueError("Waypoint presentation frames must use [4,H,W]") + world = self._presentation_manager.composite( + video.to(torch.float32), + waypoints.to(device=video.device, dtype=torch.float32), + ) + return _compose_minimap(world, bev) def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" @@ -368,24 +324,19 @@ def reset(self) -> None: def build_hud_frames( video_tchw: Tensor, snapshots: Sequence[object], - rig_poses_world: np.ndarray, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) - if len(snapshots) != frame_count or len(rig_poses_world) != frame_count: - raise ValueError("Video, game snapshots, and rig poses must align") + if len(snapshots) != frame_count: + raise ValueError("Video and game snapshots must align") frames = [] for index, snapshot in enumerate(snapshots): if not isinstance(snapshot, TaxiGameSnapshot): raise TypeError("Taxi HUD received a non-taxi game snapshot") - pose = tuple( - tuple(float(value) for value in row) for row in rig_poses_world[index] - ) frames.append( TaxiHudFrame( frame_key=int(video_tchw[index].data_ptr()), snapshot=snapshot, - rig_pose_world=pose, ) ) return tuple(frames) @@ -443,9 +394,7 @@ def _event_label(snapshot: TaxiGameSnapshot) -> str: def _navigation_label(bearing_rad: float) -> str: - degrees = math.degrees( - math.atan2(math.sin(bearing_rad), math.cos(bearing_rad)) - ) + degrees = math.degrees(math.atan2(math.sin(bearing_rad), math.cos(bearing_rad))) if abs(degrees) <= 15.0: direction = "AHEAD" elif abs(degrees) >= 165.0: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py new file mode 100644 index 000000000..d60037d44 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py @@ -0,0 +1,354 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Frame-aligned world-space waypoint presentation layers.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +import numpy as np +import torch +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.types import CameraCalibration +from torch import Tensor + +from crazy_robotaxi.rules import ( + TaxiCameraMarkerProjection, + TaxiGameSnapshot, + project_taxi_markers_to_camera, +) + +_PICKUP_COLOR = (-0.0745098, 0.4509804, -1.0) +"""Normalized RGB used for pickup waypoint geometry.""" + +_DROPOFF_COLOR = (0.5686275, 0.1764706, -0.60784316) +"""Normalized RGB used for drop-off waypoint geometry.""" + +_BLACK = (-1.0, -1.0, -1.0) +_WHITE = (1.0, 1.0, 1.0) +_LABEL_BACKGROUND = (-0.9372549, -0.9372549, -0.90588236) + +_GLYPHS = { + "C": ("1111", "1000", "1000", "1000", "1000", "1000", "1111"), + "D": ("1110", "1001", "1001", "1001", "1001", "1001", "1110"), + "F": ("1111", "1000", "1000", "1110", "1000", "1000", "1000"), + "I": ("111", "010", "010", "010", "010", "010", "111"), + "K": ("1001", "1010", "1100", "1100", "1010", "1001", "1001"), + "O": ("0110", "1001", "1001", "1001", "1001", "1001", "0110"), + "P": ("1110", "1001", "1001", "1110", "1000", "1000", "1000"), + "R": ("1110", "1001", "1001", "1110", "1010", "1001", "1001"), + "U": ("1001", "1001", "1001", "1001", "1001", "1001", "0110"), +} +"""Tiny dependency-free glyphs used for world-anchored marker labels.""" + + +def render_waypoint_layers( + snapshots: Sequence[object], + rig_poses_world: np.ndarray, + calibration: CameraCalibration, + *, + width: int, + height: int, + device: torch.device | str, + dtype: torch.dtype = torch.float32, +) -> Tensor: + """Render projected waypoint geometry as transparent RGBA layers. + + Args: + snapshots: Per-frame taxi game snapshots. + rig_poses_world: Camera-rig poses in ``[T,4,4]``. + calibration: Camera calibration used for world projection. + width: Output width in pixels. + height: Output height in pixels. + device: Device receiving the resulting tensor. + dtype: Floating-point output dtype. + + Returns: + Floating-point layers in ``[T,4,H,W]`` with normalized RGB and + alpha in ``[0,1]``. + + Raises: + ValueError: Inputs are not frame-aligned or output dimensions are invalid. + TypeError: A frame does not contain a taxi game snapshot. + """ + frame_count = len(snapshots) + poses = np.asarray(rig_poses_world, dtype=np.float32) + if poses.shape != (frame_count, 4, 4): + raise ValueError("Waypoint snapshots and camera poses must align") + if width <= 0 or height <= 0: + raise ValueError("Waypoint layer dimensions must be positive") + if not dtype.is_floating_point: + raise ValueError("Waypoint layers require a floating-point dtype") + camera = FThetaCameraModel( + calibration, + output_width=width, + output_height=height, + ) + layers = torch.zeros( + (frame_count, 4, height, width), + device=device, + dtype=dtype, + ) + for frame_index, snapshot in enumerate(snapshots): + if not isinstance(snapshot, TaxiGameSnapshot): + raise TypeError("Waypoint layer received a non-taxi game snapshot") + if snapshot.session_state != "playing": + continue + projections = project_taxi_markers_to_camera( + snapshot, + poses[frame_index], + camera, + image_width=width, + image_height=height, + ) + color = _PICKUP_COLOR if snapshot.phase == "seeking_pickup" else _DROPOFF_COLOR + label = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" + ring_edges = [] + beacons = [] + for projection in projections: + ring_edges.extend(projection.ring_edges_uv) + beacons.append((projection.anchor_uv, _beacon_top(projection))) + + layer = layers[frame_index] + _paint_lines(layer, ring_edges, width, height, 7, _BLACK, 220.0 / 255.0) + _paint_lines(layer, ring_edges, width, height, 4, color, 245.0 / 255.0) + _paint_lines(layer, beacons, width, height, 9, _BLACK, 235.0 / 255.0) + _paint_lines(layer, beacons, width, height, 5, color, 1.0) + for projection, (_, top) in zip(projections, beacons, strict=True): + _paint_marker_anchor(layer, projection.anchor_uv, width, height, color) + _paint_marker_label(layer, top, label, width, height, color) + return layers + + +def _beacon_top(projection: TaxiCameraMarkerProjection) -> tuple[float, float]: + anchor_x, anchor_y = projection.anchor_uv + if projection.beacon_top_uv is None: + return anchor_x, anchor_y - 64.0 + vector_x = float(projection.beacon_top_uv[0] - anchor_x) + vector_y = float(projection.beacon_top_uv[1] - anchor_y) + length = max(1.0, math.hypot(vector_x, vector_y)) + display_length = min(170.0, max(52.0, length)) + return ( + anchor_x + vector_x * display_length / length, + anchor_y + vector_y * display_length / length, + ) + + +def _paint_lines( + layer: Tensor, + lines: Sequence[tuple[tuple[float, float], tuple[float, float]]], + width: int, + height: int, + width_px: int, + color: tuple[float, float, float], + alpha: float, +) -> None: + if not lines: + return + pixels = [ + _line_pixels( + start, + end, + width=width, + height=height, + width_px=width_px, + ) + for start, end in lines + ] + _paint_pixels( + layer, + np.concatenate([points[0] for points in pixels]), + np.concatenate([points[1] for points in pixels]), + color, + alpha, + ) + + +def _paint_marker_anchor( + layer: Tensor, + anchor: tuple[float, float], + width: int, + height: int, + color: tuple[float, float, float], +) -> None: + center_x, center_y = int(round(anchor[0])), int(round(anchor[1])) + x, y = _disk_pixels(center_x, center_y, 9, width, height) + _paint_pixels(layer, x, y, color, 1.0) + x, y = _ring_pixels(center_x, center_y, 6, 9, width, height) + _paint_pixels(layer, x, y, _WHITE, 1.0) + + +def _paint_marker_label( + layer: Tensor, + top: tuple[float, float], + label: str, + width: int, + height: int, + color: tuple[float, float, float], +) -> None: + scale = max(1, min(width, height) // 360) + text_x, text_y = _text_pixels(label, scale) + text_width = int(text_x.max()) + 1 + text_height = int(text_y.max()) + 1 + center_x = int(round(top[0])) + text_left = center_x - text_width // 2 + text_top = int(round(top[1])) - text_height - 10 * scale + left = text_left - 4 * scale + top_y = text_top - 3 * scale + right = text_left + text_width + 4 * scale + bottom = text_top + text_height + 3 * scale + x, y = _rectangle_pixels(left, top_y, right, bottom, width, height) + _paint_pixels(layer, x, y, _LABEL_BACKGROUND, 225.0 / 255.0) + border = max(1, scale) + border_lines = ( + ((left, top_y), (right, top_y)), + ((right, top_y), (right, bottom)), + ((right, bottom), (left, bottom)), + ((left, bottom), (left, top_y)), + ) + _paint_lines(layer, border_lines, width, height, border, color, 1.0) + _paint_pixels(layer, text_x + text_left, text_y + text_top, color, 1.0) + + +def _paint_pixels( + layer: Tensor, + x: np.ndarray, + y: np.ndarray, + color: tuple[float, float, float], + alpha: float, +) -> None: + height, width = int(layer.shape[-2]), int(layer.shape[-1]) + valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) + x = x[valid] + y = y[valid] + if x.size == 0: + return + x_tensor = torch.from_numpy(x).to(device=layer.device) + y_tensor = torch.from_numpy(y).to(device=layer.device) + layer[:3, y_tensor, x_tensor] = torch.tensor( + color, + device=layer.device, + dtype=layer.dtype, + ).view(3, 1) + layer[3, y_tensor, x_tensor] = alpha + + +def _line_pixels( + start: tuple[float, float], + end: tuple[float, float], + *, + width: int, + height: int, + width_px: int, +) -> tuple[np.ndarray, np.ndarray]: + delta_x = float(end[0] - start[0]) + delta_y = float(end[1] - start[1]) + sample_count = max(2, int(math.ceil(max(abs(delta_x), abs(delta_y)))) + 1) + center_x = np.rint(np.linspace(start[0], end[0], sample_count)).astype(np.int32) + center_y = np.rint(np.linspace(start[1], end[1], sample_count)).astype(np.int32) + radius = max(0.0, (float(width_px) - 1.0) / 2.0) + line_x = [] + line_y = [] + integer_radius = int(math.ceil(radius)) + for offset_y in range(-integer_radius, integer_radius + 1): + for offset_x in range(-integer_radius, integer_radius + 1): + if offset_x * offset_x + offset_y * offset_y > radius * radius: + continue + x = center_x + offset_x + y = center_y + offset_y + valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) + line_x.append(x[valid]) + line_y.append(y[valid]) + return np.concatenate(line_x), np.concatenate(line_y) + + +def _disk_pixels( + center_x: int, + center_y: int, + radius: int, + width: int, + height: int, +) -> tuple[np.ndarray, np.ndarray]: + offsets = np.arange(-radius, radius + 1, dtype=np.int32) + offset_x, offset_y = np.meshgrid(offsets, offsets) + selected = offset_x * offset_x + offset_y * offset_y <= radius * radius + x = center_x + offset_x[selected] + y = center_y + offset_y[selected] + valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) + return x[valid], y[valid] + + +def _ring_pixels( + center_x: int, + center_y: int, + inner_radius: int, + outer_radius: int, + width: int, + height: int, +) -> tuple[np.ndarray, np.ndarray]: + offsets = np.arange(-outer_radius, outer_radius + 1, dtype=np.int32) + offset_x, offset_y = np.meshgrid(offsets, offsets) + distance_squared = offset_x * offset_x + offset_y * offset_y + selected = (distance_squared >= inner_radius * inner_radius) & ( + distance_squared <= outer_radius * outer_radius + ) + x = center_x + offset_x[selected] + y = center_y + offset_y[selected] + valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) + return x[valid], y[valid] + + +def _rectangle_pixels( + left: int, + top: int, + right: int, + bottom: int, + width: int, + height: int, +) -> tuple[np.ndarray, np.ndarray]: + clipped_left = max(0, left) + clipped_top = max(0, top) + clipped_right = min(width - 1, right) + clipped_bottom = min(height - 1, bottom) + if clipped_left > clipped_right or clipped_top > clipped_bottom: + return np.empty(0, dtype=np.int32), np.empty(0, dtype=np.int32) + x, y = np.meshgrid( + np.arange(clipped_left, clipped_right + 1, dtype=np.int32), + np.arange(clipped_top, clipped_bottom + 1, dtype=np.int32), + ) + return x.ravel(), y.ravel() + + +def _text_pixels(text: str, scale: int) -> tuple[np.ndarray, np.ndarray]: + x_values = [] + y_values = [] + cursor = 0 + for character in text: + glyph = _GLYPHS[character] + for row, bits in enumerate(glyph): + for column, bit in enumerate(bits): + if bit == "0": + continue + for offset_y in range(scale): + for offset_x in range(scale): + x_values.append(cursor + column * scale + offset_x) + y_values.append(row * scale + offset_y) + cursor += (len(glyph[0]) + 1) * scale + return np.asarray(x_values, dtype=np.int32), np.asarray(y_values, dtype=np.int32) + + +__all__ = ["render_waypoint_layers"] diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 68e047c26..b274c01a2 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -13,19 +13,20 @@ import pytest import slangpy as spy import torch - from crazy_robotaxi.rules import TaxiGameSnapshot, TaxiSessionState from crazy_robotaxi.ui import ( CrazyRobotaxiImGUIThread, TaxiHudState, build_hud_frames, ) +from crazy_robotaxi.world_overlay import render_waypoint_layers +from omnidreams_game_engine.types import CameraCalibration + from flashdreams.api_v2.thread import IThread from flashdreams.runtime_v2.presentation_manager import PresentationManager from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_events import UserInputEvents from flashdreams.runtime_v2.video_tensor import VideoTensorLayout -from omnidreams_game_engine.types import CameraCalibration pytestmark = pytest.mark.ci_cpu @@ -134,29 +135,57 @@ def reset(self) -> None: def test_hud_frames_are_immutable_messages_keyed_to_video_storage() -> None: video = torch.zeros(2, 3, 96, 160) snapshots = (_snapshot(), _snapshot()) - poses = np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0) - frames = build_hud_frames(video, snapshots, poses) + frames = build_hud_frames(video, snapshots) assert [frame.frame_key for frame in frames] == [ video[index].data_ptr() for index in range(2) ] assert frames[0].snapshot is snapshots[0] - assert isinstance(frames[0].rig_pose_world, tuple) -def test_imgui_thread_owns_hud_and_composites_presented_bev() -> None: +def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: + poses = np.eye(4, dtype=np.float32)[None] + + layers = render_waypoint_layers( + (_snapshot(),), + poses, + _calibration(), + width=160, + height=96, + device="cpu", + ) + + assert layers.shape == (1, 4, 96, 160) + assert layers.dtype is torch.float32 + assert torch.any(layers[:, 3] == 1.0) + + terminal = render_waypoint_layers( + (_snapshot(session_state="awaiting_name"),), + poses, + _calibration(), + width=160, + height=96, + device="cpu", + ) + assert torch.count_nonzero(terminal[:, 3]) == 0 + + +def test_imgui_thread_composites_world_waypoints_then_bev_and_imgui() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) + waypoints = torch.zeros(1, 4, height, width) + waypoints[:, 0, 10, 10] = 1.0 + waypoints[:, 3, 10, 10] = 1.0 bev = torch.full((1, 3, 32, 32), 0.5) - poses = np.eye(4, dtype=np.float32)[None] - hud_state = TaxiHudState(_calibration(), width, height) - hud_state.publish(build_hud_frames(video, (_snapshot(),), poses)) + hud_state = TaxiHudState(width, height) + hud_state.publish(build_hud_frames(video, (_snapshot(),))) presentation = PresentationManager() presentation.publish( 0, [ StepResult(0, video, 1, VideoTensorLayout.tchw), + StepResult(0, waypoints, 1, VideoTensorLayout.tchw), StepResult(0, bev, 1, VideoTensorLayout.tchw), ], ) @@ -179,6 +208,8 @@ def test_imgui_thread_owns_hud_and_composites_presented_bev() -> None: assert hud_state._widgets is not None assert hud_state._widgets.score.text == "SCORE 001200 HIGH 009000" assert hud_state._widgets.navigation.text == "TARGET AHEAD 0 deg" + assert not hasattr(hud_state._widgets, "marker_windows") + assert result.output[0, 0, 10, 10] == 1.0 assert torch.any(result.output != video) thread.reset() @@ -187,12 +218,11 @@ def test_imgui_thread_owns_hud_and_composites_presented_bev() -> None: def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: - state = TaxiHudState(_calibration(), 160, 96) + state = TaxiHudState(160, 96) state.publish( build_hud_frames( torch.zeros(1, 3, 96, 160), (_snapshot(),), - np.eye(4, dtype=np.float32)[None], ) ) state._current = next(iter(state._frames.values())) @@ -214,7 +244,7 @@ def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: def test_imgui_name_submission_uses_v2_thread_message_queue() -> None: - state = TaxiHudState(_calibration(), 160, 96) + state = TaxiHudState(160, 96) model_thread = _SubmissionThread(state=_SubmissionState(), frequency=0) state.model_thread = model_thread diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index c538094c9..1641afc74 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -49,9 +49,9 @@ flowchart TD end subgraph UITHREAD["FlashDreams V2 UI thread"] - BUFFER["PresentationManager
generated RGB frames"] + BUFFER["PresentationManager
RGB, waypoint, optional BEV channels"] HUD["CrazyRobotaxiImGUIThread
HUD widgets + name entry"] - COMPOSE["ImGui compositing"] + COMPOSE["world-waypoint + BEV composition
then ImGui HUD"] WINDOW["V2 client window
WebRTC / MP4 / supported host"] end @@ -63,7 +63,7 @@ flowchart TD PIPELINE --> CACHE INPUT --> ENGINE ENGINE --> HUDSTATE - ENGINE -->|"generated RGB + optional BEV channels"| BUFFER + ENGINE -->|"RGB + world waypoint + optional BEV channels"| BUFFER HUDSTATE -->|"invoke_async"| HUD HUD -->|"invoke_async game actions"| ENGINE BUFFER --> HUD --> COMPOSE --> WINDOW @@ -76,13 +76,15 @@ description, lazily constructs the shared pipeline, prepares immutable scene data, and returns an uninitialized session. The session registers a model thread and a `CrazyRobotaxiImGUIThread`. The -model thread emits generated RGB frames and, when configured, an optional BEV -visualization channel. For each generated frame it also publishes an immutable -`TaxiHudFrame` to the UI thread with `invoke_async`. +model thread emits generated RGB frames, a transparent camera-projected +waypoint layer, and, when configured, an optional BEV visualization channel. +For each generated frame it also publishes an immutable `TaxiHudFrame` to the +UI thread with `invoke_async`. The UI thread selects the HUD snapshot aligned with the currently presented -model frame, updates its retained ImGui widgets, and returns the generated -frame as the ImGui back buffer. UI callbacks send validated game actions back -to the model thread with `invoke_async`; they never call game state directly. +model frame, composes the in-world waypoint geometry and BEV panel onto the +generated frame, updates its retained ImGui widgets, and returns that frame as +the ImGui back buffer. UI callbacks send validated game actions back to the +model thread with `invoke_async`; they never call game state directly. ## Model-thread step @@ -101,9 +103,10 @@ flowchart LR GENERATE["pipeline.generate"] FINALIZE["pipeline.finalize"] POST["optional session-local postprocess stream"] + WAYPOINTS["camera-projected RGBA
world-waypoint layers"] HUDFRAMES["immutable TaxiHudFrame batch"] SEND["invoke_async(UI thread)"] - RESULTS["list[StepResult]
RGB video + optional BEV"] + RESULTS["list[StepResult]
RGB + waypoint + optional BEV"] EVENTS --> REDUCE COUNT --> COMMANDS @@ -116,10 +119,12 @@ flowchart LR ACTORS --> CONDITION SIM --> CONDITION CONDITION --> TENSOR --> GENERATE --> FINALIZE --> POST + SNAPSHOTS --> WAYPOINTS + SIM --> WAYPOINTS SNAPSHOTS --> HUDFRAMES - SIM --> HUDFRAMES HUDFRAMES --> SEND POST --> RESULTS + WAYPOINTS --> RESULTS ``` The output-frame count is learned from the pipeline before simulation, so @@ -139,8 +144,9 @@ trajectory = engine_step.trajectory hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] video = pipeline.generate(autoregressive_index, cache, hdmap) metrics = pipeline.finalize(autoregressive_index, cache) -invoke_async(ui_thread, publish_hud_frames(game_snapshots, poses, video)) -return [video StepResult, optional BEV StepResult] +waypoints = render_waypoint_layers(game_snapshots, poses, camera) +invoke_async(ui_thread, publish_hud_frames(game_snapshots, video)) +return [video StepResult, waypoints StepResult, optional BEV StepResult] ``` The UI thread uses V2's `ImGUIThread` renderer and `presented_model_frames()`; @@ -210,6 +216,7 @@ flowchart LR SCORES["HighScoreStore"] FRAME["TaxiGameFrame"] HUDSTATE["TaxiHudFrame
immutable presentation state"] + WAYPOINTS["camera-projected waypoint layer
ring + beacon + label"] IMGUI["CrazyRobotaxiImGUIThread
score, time, navigation,
name entry, leaderboard"] MAP --> NAV --> FARES @@ -218,6 +225,7 @@ flowchart LR FARES --> PASSENGERS FARES <--> SCORES FARES --> FRAME --> HUDSTATE --> IMGUI + FRAME --> WAYPOINTS PASSENGERS --> FRAME ``` @@ -259,11 +267,12 @@ stateDiagram-v2 ``` While awaiting a name, no new world-model block is generated. The model thread -re-emits the last generated RGB frame and publishes an updated immutable HUD -frame. ImGui owns the editable name buffer and submits a validated name to the -model thread with `invoke_async`. The model thread reports `is_finished()` only -after a leaderboard frame has been emitted (or an explicit finite block limit -is reached), allowing MP4 mode to terminate without a client close event. +re-emits the last generated RGB frame with a transparent waypoint layer and +publishes an updated immutable HUD frame. ImGui owns the editable name buffer +and submits a validated name to the model thread with `invoke_async`. The model +thread reports `is_finished()` only after a leaderboard frame has been emitted +(or an explicit finite block limit is reached), allowing MP4 mode to terminate +without a client close event. On reset, the model thread closes and recreates the simulation, traffic, rules, condition renderer state, and autoregressive cache as one unit. On close, it @@ -307,6 +316,7 @@ apps/crazy_robotaxi/ application.py # IApplication composition root session.py # ISession + model/UI thread wiring ui.py # ImGui HUD state and UI thread + world_overlay.py # composited camera-projected waypoint geometry rules.py # taxi fare state machine dynamics.py # arcade taxi controls physics.py # taxi collision policy From f1b8d070ce2104da3577fb672eaf9332a867751c Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 17:46:53 +0000 Subject: [PATCH 06/62] Adapt Crazy Robotaxi to merged V2 loop API Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 12 ++--- apps/crazy_robotaxi/crazy_robotaxi/session.py | 28 +++++------ apps/crazy_robotaxi/crazy_robotaxi/ui.py | 24 +++++----- apps/crazy_robotaxi/tests/test_application.py | 20 ++++---- apps/crazy_robotaxi/tests/test_ui.py | 48 ++++++++++++------- docs/design/crazy_robotaxi_v2_architecture.md | 46 +++++++++--------- 6 files changed, 96 insertions(+), 82 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index b03dad6cd..91c1b1ed6 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -1,9 +1,9 @@ # Crazy Robotaxi -Crazy Robotaxi is a FlashDreams V2 application built on the model-thread -`omnidreams-game-engine`. FlashDreams owns input collection, reset generations, -presentation buffering, client windows, and the two-thread runtime. -The UI thread uses FlashDreams' SlangPy ImGui renderer for the game HUD and +Crazy Robotaxi is a FlashDreams V2 application built on the model/UI-loop API +and `omnidreams-game-engine`. FlashDreams owns input collection, reset +generations, presentation buffering, client windows, and the two-thread +runtime. The UI loop uses FlashDreams' SlangPy renderer for the game HUD and composites it over model frames for local and WebRTC clients. Camera-projected waypoint rings, beacons, and labels are carried as a frame-aligned world layer; they are not ImGui windows. @@ -26,8 +26,8 @@ Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. The V2 client's reset event rebuilds simulation, game rules, traffic, conditioning, and the autoregressive cache together while retaining the loaded model. -Leaderboard name entry is owned by the ImGui UI and submitted to the model -thread through V2's asynchronous thread-message contract. +Leaderboard name entry is owned by the SlangPy UI and submitted to the model +loop through V2's asynchronous loop-message contract. ## Authored maps diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index e34b7f4ef..860e6e4b7 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Crazy Robotaxi V2 session with model and ImGui UI threads.""" +"""Crazy Robotaxi V2 session with model and SlangPy UI loops.""" from __future__ import annotations @@ -18,13 +18,13 @@ from crazy_robotaxi.factory import build_taxi_engine from crazy_robotaxi.rules import TaxiGameSnapshot from crazy_robotaxi.ui import ( - CrazyRobotaxiImGUIThread, + CrazyRobotaxiSlangPyUILoop, TaxiHudState, build_hud_frames, ) from crazy_robotaxi.world_overlay import render_waypoint_layers +from flashdreams.api_v2.loop import IModelLoop, IUILoop, invoke_async from flashdreams.api_v2.session import ISession -from flashdreams.api_v2.thread import IThread, UIThread, invoke_async from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_events import UserInputEvents @@ -43,8 +43,8 @@ class ModelState: config: ApplicationConfig session_desc: SessionDesc driver_input: DriverInput - ui_thread: UIThread[TaxiHudState] - """UI-thread endpoint used only through ``invoke_async``.""" + ui_loop: IUILoop[TaxiHudState] + """UI-loop endpoint used only through ``invoke_async``.""" rollout: WorldModelRollout | None = None last_video: torch.Tensor | None = None @@ -93,7 +93,7 @@ def submit_player_name(self, name: str) -> None: rollout.engine.submit_text(name) -class CrazyRobotaxiModelThread(IThread[ModelState]): +class CrazyRobotaxiModelLoop(IModelLoop[ModelState]): """Run simulation, rules, conditioning, and generation in one V2 step.""" def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: @@ -153,7 +153,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ) hud_frames = build_hud_frames(video, game_frames) invoke_async( - state.ui_thread, + state.ui_loop, lambda ui_state, frames=hud_frames: ui_state.publish(frames), ) latest = game_frames[-1] @@ -205,7 +205,7 @@ def close(self) -> None: class CrazyRobotaxiSession(ISession): - """Register the model thread and Crazy Robotaxi ImGui UI thread.""" + """Register the model loop and Crazy Robotaxi SlangPy UI loop.""" def __init__( self, @@ -229,21 +229,21 @@ def init(self) -> None: width=self._session_desc.video_width, height=self._session_desc.video_height, ) - ui_thread = self.register_ui_thread( - CrazyRobotaxiImGUIThread, + ui_loop = self.register_ui_loop( + CrazyRobotaxiSlangPyUILoop, state=hud_state, width=self._session_desc.video_width, height=self._session_desc.video_height, ) - model_thread = self.register_model_thread( - CrazyRobotaxiModelThread, + model_loop = self.register_model_loop( + CrazyRobotaxiModelLoop, state=ModelState( pipeline=self._pipeline, scene=self._scene, config=self._config, session_desc=self._session_desc, driver_input=DriverInput(self._config.driver_input), - ui_thread=ui_thread, + ui_loop=ui_loop, ), ) - hud_state.model_thread = model_thread + hud_state.model_loop = model_loop diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 7cdc4a2f1..dced3db13 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -29,8 +29,8 @@ from crazy_robotaxi.high_scores import validate_player_name from crazy_robotaxi.rules import TaxiGameSnapshot -from flashdreams.api_v2.thread import IThread, invoke_async -from flashdreams.runtime_v2.imgui_thread import ImGUIThread +from flashdreams.api_v2.loop import ILoop, invoke_async +from flashdreams.runtime_v2.slangpy_ui_loop import SlangPyUILoop from flashdreams.runtime_v2.user_input_events import UserInputEvents _MAX_BUFFERED_HUD_FRAMES = 64 @@ -80,8 +80,8 @@ class TaxiHudState: height: int """Presentation height in pixels.""" - model_thread: IThread[Any] | None = None - """Model-thread endpoint used only through ``invoke_async``.""" + model_loop: ILoop[Any] | None = None + """Model-loop endpoint used only through ``invoke_async``.""" _frames: OrderedDict[int, TaxiHudFrame] = field(default_factory=OrderedDict) """Recent immutable snapshots keyed by presented tensor-frame identity.""" @@ -275,9 +275,9 @@ def _submit_name(self, value: str) -> None: except ValueError as error: self._validation_message = str(error) return - model_thread = self.model_thread - if model_thread is None: - self._validation_message = "Model thread is not ready." + model_loop = self.model_loop + if model_loop is None: + self._validation_message = "Model loop is not ready." return self._submission_pending = True self._validation_message = "Submitting score..." @@ -285,15 +285,15 @@ def _submit_name(self, value: str) -> None: self._widgets.name_input.enabled = False self._widgets.submit.enabled = False invoke_async( - model_thread, + model_loop, lambda state, name=normalized: state.submit_player_name(name), ) -class CrazyRobotaxiImGUIThread(ImGUIThread[TaxiHudState]): - """Present generated frames beneath a responsive ImGui taxi HUD.""" +class CrazyRobotaxiSlangPyUILoop(SlangPyUILoop[TaxiHudState]): + """Present generated frames beneath a responsive SlangPy taxi HUD.""" - def draw_ui( + def step_ui( self, ui: Any, step_index: int, events: UserInputEvents ) -> Tensor | None: """Draw the HUD and return the current generated frame beneath it.""" @@ -407,7 +407,7 @@ def _navigation_label(bearing_rad: float) -> str: __all__ = [ - "CrazyRobotaxiImGUIThread", + "CrazyRobotaxiSlangPyUILoop", "TaxiHudFrame", "TaxiHudState", "build_hud_frames", diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 4be9a3ae3..e4b515ebb 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -9,8 +9,8 @@ import numpy as np import pytest from crazy_robotaxi.application import CrazyRobotaxiApplication -from crazy_robotaxi.session import CrazyRobotaxiModelThread -from crazy_robotaxi.ui import CrazyRobotaxiImGUIThread +from crazy_robotaxi.session import CrazyRobotaxiModelLoop +from crazy_robotaxi.ui import CrazyRobotaxiSlangPyUILoop from omnidreams_game_engine.types import CameraCalibration, SceneDefinition from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -47,7 +47,7 @@ def _scene() -> SceneDefinition: ) -def test_application_registers_model_and_imgui_threads() -> None: +def test_application_registers_model_and_slangpy_ui_loops() -> None: pipeline = object() app = CrazyRobotaxiApplication( pipeline_factory=lambda config, device: pipeline, @@ -58,15 +58,15 @@ def test_application_registers_model_and_imgui_threads() -> None: session = app.create_session(desc) session.init() - ui_thread, model_thread = session._take_threads() + ui_loop, model_loop = session._take_loops() assert desc.output_layout is VideoTensorLayout.tchw - assert isinstance(model_thread, CrazyRobotaxiModelThread) - assert isinstance(ui_thread, CrazyRobotaxiImGUIThread) - assert model_thread.state.pipeline is pipeline - assert model_thread.state.rollout is None - assert model_thread.state.ui_thread is ui_thread - assert ui_thread.state.model_thread is model_thread + assert isinstance(model_loop, CrazyRobotaxiModelLoop) + assert isinstance(ui_loop, CrazyRobotaxiSlangPyUILoop) + assert model_loop.state.pipeline is pipeline + assert model_loop.state.rollout is None + assert model_loop.state.ui_loop is ui_loop + assert ui_loop.state.model_loop is model_loop def test_application_rejects_geometry_the_model_does_not_produce() -> None: diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index b274c01a2..0f0780c09 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -1,10 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""CPU tests for Crazy Robotaxi's V2 ImGui UI thread.""" +"""CPU tests for Crazy Robotaxi's V2 SlangPy UI loop.""" from __future__ import annotations +import queue +import threading from dataclasses import dataclass, field from types import SimpleNamespace from typing import Any @@ -15,14 +17,14 @@ import torch from crazy_robotaxi.rules import TaxiGameSnapshot, TaxiSessionState from crazy_robotaxi.ui import ( - CrazyRobotaxiImGUIThread, + CrazyRobotaxiSlangPyUILoop, TaxiHudState, build_hud_frames, ) from crazy_robotaxi.world_overlay import render_waypoint_layers from omnidreams_game_engine.types import CameraCalibration -from flashdreams.api_v2.thread import IThread +from flashdreams.api_v2.loop import IModelLoop from flashdreams.runtime_v2.presentation_manager import PresentationManager from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_events import UserInputEvents @@ -104,8 +106,8 @@ def __init__(self, width: int, height: int) -> None: self.reset_count = 0 self.closed = False - def render(self, step_index, events, draw_ui): - draw_ui(self.ui, step_index, events) + def render(self, step_index, events, step_ui): + step_ui(self.ui, step_index, events) return torch.zeros(4, self.height, self.width) def reset(self) -> None: @@ -123,7 +125,7 @@ def submit_player_name(self, name: str) -> None: self.names.append(name) -class _SubmissionThread(IThread[_SubmissionState]): +class _SubmissionLoop(IModelLoop[_SubmissionState]): def step(self, step_index, events): del step_index, events return None @@ -171,7 +173,7 @@ def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: assert torch.count_nonzero(terminal[:, 3]) == 0 -def test_imgui_thread_composites_world_waypoints_then_bev_and_imgui() -> None: +def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) waypoints = torch.zeros(1, 4, height, width) @@ -191,15 +193,21 @@ def test_imgui_thread_composites_world_waypoints_then_bev_and_imgui() -> None: ) changed, _ = presentation.advance(0) renderer = _Renderer(width, height) - thread = CrazyRobotaxiImGUIThread( + loop = CrazyRobotaxiSlangPyUILoop( + renderer=renderer, + ) + loop.register_session_loop_objects( state=hud_state, frequency=60, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + loop.register_session_ui_loop_objects( output_layout=VideoTensorLayout.tchw, presentation_manager=presentation, - renderer=renderer, ) - result = thread.step_ui(0, UserInputEvents([])) + result = loop.step(0, UserInputEvents([])) assert changed assert result.output.shape == (1, 3, height, width) @@ -212,7 +220,7 @@ def test_imgui_thread_composites_world_waypoints_then_bev_and_imgui() -> None: assert result.output[0, 0, 10, 10] == 1.0 assert torch.any(result.output != video) - thread.reset() + loop.reset() assert hud_state._current is None assert renderer.reset_count == 1 @@ -243,15 +251,21 @@ def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: assert state._widgets.score.text == "SCORE 001200 HIGH 009000" -def test_imgui_name_submission_uses_v2_thread_message_queue() -> None: +def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: state = TaxiHudState(160, 96) - model_thread = _SubmissionThread(state=_SubmissionState(), frequency=0) - state.model_thread = model_thread + model_loop = _SubmissionLoop() + model_loop.register_session_loop_objects( + state=_SubmissionState(), + frequency=0, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + state.model_loop = model_loop state._submit_name(" DRIVER 7 ") state._submit_name("SECOND") - assert model_thread.state.names == [] - model_thread._run_message_batch() - assert model_thread.state.names == ["DRIVER 7"] + assert model_loop.state.names == [] + model_loop._run_message_batch() + assert model_loop.state.names == ["DRIVER 7"] assert state._submission_pending diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 1641afc74..c60c2920a 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -38,7 +38,7 @@ flowchart TD subgraph SESSION["CrazyRobotaxiSession — one run"] SCENE["immutable SceneDefinition
map, camera, seed, prompt"] - REG["register one model thread
register one ImGui UI thread"] + REG["register one model loop
register one SlangPy UI loop"] end subgraph MODELTHREAD["FlashDreams V2 model thread"] @@ -50,7 +50,7 @@ flowchart TD subgraph UITHREAD["FlashDreams V2 UI thread"] BUFFER["PresentationManager
RGB, waypoint, optional BEV channels"] - HUD["CrazyRobotaxiImGUIThread
HUD widgets + name entry"] + HUD["CrazyRobotaxiSlangPyUILoop
HUD widgets + name entry"] COMPOSE["world-waypoint + BEV composition
then ImGui HUD"] WINDOW["V2 client window
WebRTC / MP4 / supported host"] end @@ -75,18 +75,18 @@ application-owned options. `create_session()` validates the requested description, lazily constructs the shared pipeline, prepares immutable scene data, and returns an uninitialized session. -The session registers a model thread and a `CrazyRobotaxiImGUIThread`. The -model thread emits generated RGB frames, a transparent camera-projected +The session registers a model loop and a `CrazyRobotaxiSlangPyUILoop`. The +model loop emits generated RGB frames, a transparent camera-projected waypoint layer, and, when configured, an optional BEV visualization channel. For each generated frame it also publishes an immutable `TaxiHudFrame` to the -UI thread with `invoke_async`. -The UI thread selects the HUD snapshot aligned with the currently presented +UI loop with `invoke_async`. +The UI loop selects the HUD snapshot aligned with the currently presented model frame, composes the in-world waypoint geometry and BEV panel onto the generated frame, updates its retained ImGui widgets, and returns that frame as the ImGui back buffer. UI callbacks send validated game actions back to the -model thread with `invoke_async`; they never call game state directly. +model loop with `invoke_async`; they never call game state directly. -## Model-thread step +## Model-loop step ```mermaid flowchart LR @@ -105,7 +105,7 @@ flowchart LR POST["optional session-local postprocess stream"] WAYPOINTS["camera-projected RGBA
world-waypoint layers"] HUDFRAMES["immutable TaxiHudFrame batch"] - SEND["invoke_async(UI thread)"] + SEND["invoke_async(UI loop)"] RESULTS["list[StepResult]
RGB + waypoint + optional BEV"] EVENTS --> REDUCE @@ -145,11 +145,11 @@ hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] video = pipeline.generate(autoregressive_index, cache, hdmap) metrics = pipeline.finalize(autoregressive_index, cache) waypoints = render_waypoint_layers(game_snapshots, poses, camera) -invoke_async(ui_thread, publish_hud_frames(game_snapshots, video)) +invoke_async(ui_loop, publish_hud_frames(game_snapshots, video)) return [video StepResult, waypoints StepResult, optional BEV StepResult] ``` -The UI thread uses V2's `ImGUIThread` renderer and `presented_model_frames()`; +The UI loop uses V2's `SlangPyUILoop` and `presented_model_frames()`; there is no intermediate render backend, local adapter, model session, chunk request, private command queue, private frame queue, or legacy `PresentedFrame` aggregate. @@ -217,7 +217,7 @@ flowchart LR FRAME["TaxiGameFrame"] HUDSTATE["TaxiHudFrame
immutable presentation state"] WAYPOINTS["camera-projected waypoint layer
ring + beacon + label"] - IMGUI["CrazyRobotaxiImGUIThread
score, time, navigation,
name entry, leaderboard"] + IMGUI["CrazyRobotaxiSlangPyUILoop
score, time, navigation,
name entry, leaderboard"] MAP --> NAV --> FARES TAXI --> TAXIPHYS @@ -240,8 +240,8 @@ subclass a game-engine application host or presenter. | --- | --- | | Application | resolved pipeline configuration; one loaded OmniDreams pipeline | | Session, immutable | validated session description; compiled/loaded scene definition; game configuration | -| Model thread, per reset generation | driving-input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | -| UI thread, per reset generation | retained ImGui widgets; immutable HUD-frame lookup; name-entry buffer; validation messages | +| Model loop, per reset generation | driving-input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | +| UI loop, per reset generation | retained ImGui widgets; immutable HUD-frame lookup; name-entry buffer; validation messages | | Outside runtime | authored map YAML; derived map cache; high-score file | All simulation, condition-renderer, and world-model CUDA calls that mutate @@ -266,15 +266,15 @@ stateDiagram-v2 Leaderboard --> Playing: V2 reset generation rebuilds rollout ``` -While awaiting a name, no new world-model block is generated. The model thread +While awaiting a name, no new world-model block is generated. The model loop re-emits the last generated RGB frame with a transparent waypoint layer and publishes an updated immutable HUD frame. ImGui owns the editable name buffer -and submits a validated name to the model thread with `invoke_async`. The model -thread reports `is_finished()` only after a leaderboard frame has been emitted +and submits a validated name to the model loop with `invoke_async`. The model +loop reports `is_finished()` only after a leaderboard frame has been emitted (or an explicit finite block limit is reached), allowing MP4 mode to terminate without a client close event. -On reset, the model thread closes and recreates the simulation, traffic, rules, +On reset, the model loop closes and recreates the simulation, traffic, rules, condition renderer state, and autoregressive cache as one unit. On close, it releases all session-local resources; application close releases the shared pipeline. @@ -285,17 +285,17 @@ pipeline. | --- | --- | | `InteractiveDriveApp` lifecycle | `IApplication` + `ISession` | | custom `run_main_loop` | `run_session` supplied by FlashDreams V2 | -| `ChunkPipeline` worker | V2 model thread | +| `ChunkPipeline` worker | V2 model loop | | private command/frame queues | direct step call + `PresentationManager` | | generation token on reset | V2 `EventBuffer` generation | | presenter event polling | V2 client window and shared event buffer | -| presenter pacing and frame replay/drop | V2 UI thread + presentation mode | +| presenter pacing and frame replay/drop | V2 UI loop + presentation mode | | `FlashdreamsWorldModelSession` | direct OmniDreams pipeline cache API | | `PresentedFrame` metadata bundle | typed engine results plus immutable `TaxiHudFrame` messages | | keyboard telemetry shared with presenter | UI-owned ImGui input plus `invoke_async` game actions | | native/MJPEG presenters | V2 client-window modes | | application-internal scene switching | map/variant application arguments; a different scene starts a new session | -| manual reset queueing | V2 reset event and thread `reset()` | +| manual reset queueing | V2 reset event and loop `reset()` | ## Implemented package boundary @@ -314,8 +314,8 @@ apps/omnidreams_game_engine/ apps/crazy_robotaxi/ crazy_robotaxi/ application.py # IApplication composition root - session.py # ISession + model/UI thread wiring - ui.py # ImGui HUD state and UI thread + session.py # ISession + model/UI loop wiring + ui.py # ImGui HUD state and SlangPy UI loop world_overlay.py # composited camera-projected waypoint geometry rules.py # taxi fare state machine dynamics.py # arcade taxi controls From 99c53c7db69ffe5c7bf56effbb627ef3eddfd4f6 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 17:54:44 +0000 Subject: [PATCH 07/62] Composite Crazy Robotaxi BEV in SlangPy UI loop Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 5 +- apps/crazy_robotaxi/crazy_robotaxi/session.py | 25 +++++--- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 43 +++---------- .../crazy_robotaxi/world_overlay.py | 60 ++++++++++++++++++- apps/crazy_robotaxi/tests/test_ui.py | 26 +++++++- docs/design/crazy_robotaxi_v2_architecture.md | 35 +++++++---- 6 files changed, 135 insertions(+), 59 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 91c1b1ed6..fbb314459 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -6,7 +6,10 @@ generations, presentation buffering, client windows, and the two-thread runtime. The UI loop uses FlashDreams' SlangPy renderer for the game HUD and composites it over model frames for local and WebRTC clients. Camera-projected waypoint rings, beacons, and labels are carried as a -frame-aligned world layer; they are not ImGui windows. +frame-aligned world layer; they are not ImGui windows. The optional BEV view +is likewise published as a frame-aligned RGBA model-result layer. The +`CrazyRobotaxiSlangPyUILoop` composites video, waypoints, and BEV through V2's +presentation manager before the base loop applies the SlangPy ImGui HUD. ```bash uv sync --package crazy-robotaxi diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 860e6e4b7..8d04cb1ea 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -22,7 +22,7 @@ TaxiHudState, build_hud_frames, ) -from crazy_robotaxi.world_overlay import render_waypoint_layers +from crazy_robotaxi.world_overlay import render_bev_overlay, render_waypoint_layers from flashdreams.api_v2.loop import IModelLoop, IUILoop, invoke_async from flashdreams.api_v2.session import ISession from flashdreams.runtime_v2.session_desc import SessionDesc @@ -48,7 +48,7 @@ class ModelState: rollout: WorldModelRollout | None = None last_video: torch.Tensor | None = None - last_bev: torch.Tensor | None = None + last_bev_overlay: torch.Tensor | None = None last_pose: np.ndarray | None = None blocks_generated: int = 0 finished: bool = False @@ -76,7 +76,7 @@ def reset(self) -> None: self.blocks_generated = 0 self.finished = False self.last_video = None - self.last_bev = None + self.last_bev_overlay = None self.last_pose = None if self.rollout is not None: self.rollout.reset() @@ -129,9 +129,20 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: game_frames = engine_step.game_frames poses = engine_step.trajectory.rig_poses_world bev = engine_step.condition.bev_tchw + bev_overlay = ( + None + if bev is None + else render_bev_overlay( + bev, + width=state.session_desc.video_width, + height=state.session_desc.video_height, + ) + ) metrics = dict(generated.metrics) state.last_video = video[-1:].detach() - state.last_bev = None if bev is None else bev[-1:].detach() + state.last_bev_overlay = ( + None if bev_overlay is None else bev_overlay[-1:].detach() + ) state.last_pose = poses[-1].copy() else: if state.last_video is None or state.last_pose is None: @@ -139,7 +150,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: video = state.last_video game_frames = (snapshot,) poses = state.last_pose[None, ...] - bev = state.last_bev + bev_overlay = state.last_bev_overlay metrics = {} waypoint_layers = render_waypoint_layers( @@ -183,11 +194,11 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: output_layout=VideoTensorLayout.tchw, ), ] - if bev is not None: + if bev_overlay is not None: results.append( StepResult( step_index=step_index, - output=bev, + output=bev_overlay, frame_count=count, output_layout=VideoTensorLayout.tchw, ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index dced3db13..15db6d842 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -24,7 +24,6 @@ from typing import Any import torch -import torch.nn.functional as functional from torch import Tensor from crazy_robotaxi.high_scores import validate_player_name @@ -301,7 +300,7 @@ def step_ui( frames = self.presented_model_frames() video = frames[0] if frames else None waypoints = frames[1] if len(frames) > 1 else None - bev = frames[2] if len(frames) > 2 else None + bev_overlay = frames[2] if len(frames) > 2 else None if video is not None: self.state.select_presented_frame(video) self.state.draw(ui) @@ -313,7 +312,14 @@ def step_ui( video.to(torch.float32), waypoints.to(device=video.device, dtype=torch.float32), ) - return _compose_minimap(world, bev) + if bev_overlay is None: + return world + if bev_overlay.shape[0] != 4: + raise ValueError("BEV presentation frames must use [4,H,W]") + return self._presentation_manager.composite( + world, + bev_overlay.to(device=video.device, dtype=torch.float32), + ) def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" @@ -342,37 +348,6 @@ def build_hud_frames( return tuple(frames) -def _compose_minimap(video: Tensor | None, bev: Tensor | None) -> Tensor | None: - if video is None: - return None - output = video.to(torch.float32) - if bev is None: - return output - if bev.ndim != 3 or bev.shape[0] != 3: - raise ValueError("BEV presentation frames must use [3,H,W]") - height, width = int(output.shape[-2]), int(output.shape[-1]) - size = min(width // 4, height // 3) - if size <= 4: - return output - panel = functional.interpolate( - bev.to(device=output.device, dtype=torch.float32).unsqueeze(0), - size=(size, size), - mode="bilinear", - align_corners=False, - )[0] - output = output.clone() - margin = max(8, min(width, height) // 80) - top = height - size - margin - left = width - size - margin - target = output[:, top : top + size, left : left + size] - target.mul_(0.18).add_(panel, alpha=0.82) - target[:, :2, :] = 1.0 - target[:, -2:, :] = 1.0 - target[:, :, :2] = 1.0 - target[:, :, -2:] = 1.0 - return output - - def _score_label(snapshot: TaxiGameSnapshot) -> str: label = f"SCORE {snapshot.score:06d}" if snapshot.high_score is not None: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py index d60037d44..98c76a8f1 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Frame-aligned world-space waypoint presentation layers.""" +"""Frame-aligned Crazy Robotaxi presentation layers.""" from __future__ import annotations @@ -22,6 +22,7 @@ import numpy as np import torch +import torch.nn.functional as functional from omnidreams_game_engine.camera import FThetaCameraModel from omnidreams_game_engine.types import CameraCalibration from torch import Tensor @@ -56,6 +57,61 @@ """Tiny dependency-free glyphs used for world-anchored marker labels.""" +def render_bev_overlay( + bev_tchw: Tensor, + *, + width: int, + height: int, +) -> Tensor: + """Render raw BEV frames into frame-aligned transparent RGBA layers. + + Args: + bev_tchw: Floating-point BEV frames in ``[T,3,H,W]``. + width: Output width in pixels. + height: Output height in pixels. + + Returns: + Floating-point layers in ``[T,4,H,W]`` with the BEV panel in the + bottom-right corner and alpha in ``[0,1]``. + + Raises: + ValueError: The BEV tensor or output dimensions are invalid. + """ + if bev_tchw.ndim != 4 or bev_tchw.shape[1] != 3: + raise ValueError("BEV frames must use [T,3,H,W]") + if not bev_tchw.is_floating_point(): + raise ValueError("BEV overlays require floating-point frames") + if width <= 0 or height <= 0: + raise ValueError("BEV overlay dimensions must be positive") + + overlay = torch.zeros( + (int(bev_tchw.shape[0]), 4, height, width), + device=bev_tchw.device, + dtype=bev_tchw.dtype, + ) + size = min(width // 4, height // 3) + if size <= 4: + return overlay + + panel = functional.interpolate( + bev_tchw, + size=(size, size), + mode="bilinear", + align_corners=False, + ) + margin = max(8, min(width, height) // 80) + top = height - size - margin + left = width - size - margin + target = overlay[:, :, top : top + size, left : left + size] + target[:, :3].copy_(panel) + target[:, 3].fill_(0.82) + target[:, :, :2, :].fill_(1.0) + target[:, :, -2:, :].fill_(1.0) + target[:, :, :, :2].fill_(1.0) + target[:, :, :, -2:].fill_(1.0) + return overlay + + def render_waypoint_layers( snapshots: Sequence[object], rig_poses_world: np.ndarray, @@ -351,4 +407,4 @@ def _text_pixels(text: str, scale: int) -> tuple[np.ndarray, np.ndarray]: return np.asarray(x_values, dtype=np.int32), np.asarray(y_values, dtype=np.int32) -__all__ = ["render_waypoint_layers"] +__all__ = ["render_bev_overlay", "render_waypoint_layers"] diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 0f0780c09..5389ba9b3 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -21,7 +21,7 @@ TaxiHudState, build_hud_frames, ) -from crazy_robotaxi.world_overlay import render_waypoint_layers +from crazy_robotaxi.world_overlay import render_bev_overlay, render_waypoint_layers from omnidreams_game_engine.types import CameraCalibration from flashdreams.api_v2.loop import IModelLoop @@ -173,13 +173,32 @@ def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: assert torch.count_nonzero(terminal[:, 3]) == 0 +def test_bev_is_rendered_as_frame_aligned_rgba_overlay() -> None: + overlay = render_bev_overlay( + torch.full((1, 3, 32, 32), 0.5), + width=160, + height=96, + ) + + assert overlay.shape == (1, 4, 96, 160) + assert overlay.dtype is torch.float32 + assert torch.count_nonzero(overlay[:, 3, :56, :]) == 0 + assert overlay[0, 3, 56, 120] == 1.0 + assert overlay[0, 3, 58, 122] == pytest.approx(0.82) + assert torch.all(overlay[0, :3, 58, 122] == 0.5) + + def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) waypoints = torch.zeros(1, 4, height, width) waypoints[:, 0, 10, 10] = 1.0 waypoints[:, 3, 10, 10] = 1.0 - bev = torch.full((1, 3, 32, 32), 0.5) + bev_overlay = render_bev_overlay( + torch.full((1, 3, 32, 32), 0.5), + width=width, + height=height, + ) hud_state = TaxiHudState(width, height) hud_state.publish(build_hud_frames(video, (_snapshot(),))) presentation = PresentationManager() @@ -188,7 +207,7 @@ def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: [ StepResult(0, video, 1, VideoTensorLayout.tchw), StepResult(0, waypoints, 1, VideoTensorLayout.tchw), - StepResult(0, bev, 1, VideoTensorLayout.tchw), + StepResult(0, bev_overlay, 1, VideoTensorLayout.tchw), ], ) changed, _ = presentation.advance(0) @@ -218,6 +237,7 @@ def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: assert hud_state._widgets.navigation.text == "TARGET AHEAD 0 deg" assert not hasattr(hud_state._widgets, "marker_windows") assert result.output[0, 0, 10, 10] == 1.0 + assert result.output[0, 0, 58, 122] == pytest.approx(0.32) assert torch.any(result.output != video) loop.reset() diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index c60c2920a..ceafab2c5 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -49,9 +49,9 @@ flowchart TD end subgraph UITHREAD["FlashDreams V2 UI thread"] - BUFFER["PresentationManager
RGB, waypoint, optional BEV channels"] + BUFFER["PresentationManager
RGB + RGBA waypoint/BEV channels"] HUD["CrazyRobotaxiSlangPyUILoop
HUD widgets + name entry"] - COMPOSE["world-waypoint + BEV composition
then ImGui HUD"] + COMPOSE["RGB → waypoint → BEV composition
then SlangPy ImGui HUD"] WINDOW["V2 client window
WebRTC / MP4 / supported host"] end @@ -63,7 +63,7 @@ flowchart TD PIPELINE --> CACHE INPUT --> ENGINE ENGINE --> HUDSTATE - ENGINE -->|"RGB + world waypoint + optional BEV channels"| BUFFER + ENGINE -->|"RGB + RGBA waypoint + optional RGBA BEV"| BUFFER HUDSTATE -->|"invoke_async"| HUD HUD -->|"invoke_async game actions"| ENGINE BUFFER --> HUD --> COMPOSE --> WINDOW @@ -77,14 +77,16 @@ data, and returns an uninitialized session. The session registers a model loop and a `CrazyRobotaxiSlangPyUILoop`. The model loop emits generated RGB frames, a transparent camera-projected -waypoint layer, and, when configured, an optional BEV visualization channel. +waypoint layer, and, when configured, a frame-aligned transparent BEV layer. For each generated frame it also publishes an immutable `TaxiHudFrame` to the UI loop with `invoke_async`. The UI loop selects the HUD snapshot aligned with the currently presented -model frame, composes the in-world waypoint geometry and BEV panel onto the -generated frame, updates its retained ImGui widgets, and returns that frame as -the ImGui back buffer. UI callbacks send validated game actions back to the -model loop with `invoke_async`; they never call game state directly. +model frame and uses V2's `PresentationManager` to compose the in-world +waypoint geometry and BEV layer over the generated frame. It updates its +retained ImGui widgets and returns the composed frame to `SlangPyUILoop`, +which applies the widget overlay. UI callbacks send validated game actions +back to the model loop with `invoke_async`; they never call game state +directly. ## Model-loop step @@ -104,9 +106,10 @@ flowchart LR FINALIZE["pipeline.finalize"] POST["optional session-local postprocess stream"] WAYPOINTS["camera-projected RGBA
world-waypoint layers"] + BEV["raw BEV frames → frame-aligned
RGBA presentation layers"] HUDFRAMES["immutable TaxiHudFrame batch"] SEND["invoke_async(UI loop)"] - RESULTS["list[StepResult]
RGB + waypoint + optional BEV"] + RESULTS["list[StepResult]
RGB + RGBA waypoint + optional RGBA BEV"] EVENTS --> REDUCE COUNT --> COMMANDS @@ -121,10 +124,12 @@ flowchart LR CONDITION --> TENSOR --> GENERATE --> FINALIZE --> POST SNAPSHOTS --> WAYPOINTS SIM --> WAYPOINTS + CONDITION --> BEV SNAPSHOTS --> HUDFRAMES HUDFRAMES --> SEND POST --> RESULTS WAYPOINTS --> RESULTS + BEV --> RESULTS ``` The output-frame count is learned from the pipeline before simulation, so @@ -145,11 +150,16 @@ hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] video = pipeline.generate(autoregressive_index, cache, hdmap) metrics = pipeline.finalize(autoregressive_index, cache) waypoints = render_waypoint_layers(game_snapshots, poses, camera) +bev_overlay = render_bev_overlay(condition.bev_tchw, output_size) invoke_async(ui_loop, publish_hud_frames(game_snapshots, video)) -return [video StepResult, waypoints StepResult, optional BEV StepResult] +return [video StepResult, waypoints StepResult, optional bev_overlay StepResult] ``` The UI loop uses V2's `SlangPyUILoop` and `presented_model_frames()`; +its `step_ui()` method composites the frame-aligned model-result layers with +`PresentationManager.composite()`, then the base loop composites the SlangPy +widget overlay over that back buffer. Raw BEV resizing therefore happens once +per generated chunk rather than once per UI tick. There is no image widget and there is no intermediate render backend, local adapter, model session, chunk request, private command queue, private frame queue, or legacy `PresentedFrame` aggregate. @@ -194,7 +204,8 @@ The reusable engine defines narrow game-facing contracts: - `PhysicsWorld` resolves ego, static, and actor interactions and produces synchronized actor trajectories. - `ConditionRenderer` converts a scene and `EngineStep` into model-ready HD-map - frames plus optional BEV data used by the HUD. + frames plus optional raw BEV data; the application turns that data into an + RGBA presentation layer before publishing the model step. - `GameEngine` sequences these components; it knows nothing about taxi fares, leaderboards, application entry points, V2 windows, or model presets. @@ -316,7 +327,7 @@ apps/crazy_robotaxi/ application.py # IApplication composition root session.py # ISession + model/UI loop wiring ui.py # ImGui HUD state and SlangPy UI loop - world_overlay.py # composited camera-projected waypoint geometry + world_overlay.py # frame-aligned waypoint and BEV RGBA layers rules.py # taxi fare state machine dynamics.py # arcade taxi controls physics.py # taxi collision policy From 23f7e13a229bd07cf33bcaf3588cbb07689d02a0 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Tue, 25 Aug 2026 18:16:39 +0000 Subject: [PATCH 08/62] Fix Crazy Robotaxi chunk pacing and CPU overhead Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 19 ++++++ .../crazy_robotaxi/application.py | 16 +++-- apps/crazy_robotaxi/crazy_robotaxi/rules.py | 51 ++++++++++++---- apps/crazy_robotaxi/crazy_robotaxi/session.py | 60 +++++++++++++++++++ apps/crazy_robotaxi/tests/test_application.py | 15 ++++- apps/crazy_robotaxi/tests/test_ui.py | 49 ++++++++++++++- .../omnidreams_game_engine/engine.py | 36 ++++++++++- .../omnidreams_game_engine/model.py | 35 ++++++++++- .../tests/test_engine.py | 13 ++++ .../tests/test_model.py | 8 ++- 10 files changed, 281 insertions(+), 21 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index fbb314459..e5da284fd 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -32,6 +32,25 @@ and the autoregressive cache together while retaining the loaded model. Leaderboard name entry is owned by the SlangPy UI and submitted to the model loop through V2's asynchronous loop-message contract. +## Performance diagnostics + +Crazy Robotaxi emits model, engine, overlay, and complete model-step timing +metrics. Capture them while reproducing a chunk pause with: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc \ + --stats-path /tmp/crazy-robotaxi-stats.json +``` + +The runtime also warns when a model step exceeds the duration of the frames it +produces. The live `model_step_wall_ms`, `model_step_cpu_ms`, `engine_cpu_ms`, +`simulation_cpu_ms`, `rules_cpu_ms`, `conditioning_cpu_ms`, +`waypoint_overlay_cpu_ms`, and `physx_*_ms` metrics separate a throughput miss +from app-side CPU work; the pipeline's `encode_ms`, `diffuse_ms`, `decode_ms`, +and `finalize_ms` metrics identify the corresponding GPU stage. The JSON sink +normalizes `_ms` metric names to `_s`. Exclude the first chunks when judging +steady state because compilation and graph capture are startup costs. + ## Authored maps Maps are strict semantic `.robotaxi.yaml` documents. Validate or compile them diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index ffce39790..6c9fe9342 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -38,6 +38,9 @@ _DEFAULT_MAP = _ROOT / "maps" / "boulevard_district.robotaxi.yaml" _DEFAULT_GAME = _ROOT / "configs" / "default_game.yaml" _DEFAULT_RENDERER = _ROOT / "configs" / "default_renderer.yaml" +_VIDEO_FPS = 30 +"""Generation and UI cadence; each UI tick advances one generated frame.""" + _MODEL_PRESETS = { "standard": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline, "perf": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline, @@ -82,8 +85,8 @@ def session_desc(self) -> SessionDesc: raster = self._defaults.raster return SessionDesc( output_layout=VideoTensorLayout.tchw, - frames_per_second_for_ui=60, - frames_per_second_for_step=30, + frames_per_second_for_ui=_VIDEO_FPS, + frames_per_second_for_step=_VIDEO_FPS, video_width=raster.width, video_height=raster.height, ) @@ -146,8 +149,13 @@ def create_session(self, session_desc: SessionDesc) -> ISession: raise RuntimeError("init() must run before create_session()") if session_desc.output_layout is not VideoTensorLayout.tchw: raise ValueError("Crazy Robotaxi produces tchw output") - if session_desc.frames_per_second_for_step != 30: - raise ValueError("Crazy Robotaxi produces video at 30 frames per second") + if ( + session_desc.frames_per_second_for_ui != _VIDEO_FPS + or session_desc.frames_per_second_for_step != _VIDEO_FPS + ): + raise ValueError( + "Crazy Robotaxi generates and presents video at 30 frames per second" + ) expected = config.renderer.raster.resolution_wh actual = session_desc.video_width, session_desc.video_height if actual != expected: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/rules.py b/apps/crazy_robotaxi/crazy_robotaxi/rules.py index f410f6fdc..8324eff8d 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/rules.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/rules.py @@ -461,11 +461,16 @@ def project_taxi_markers_to_camera( image_height: int, ) -> tuple[TaxiCameraMarkerProjection, ...]: """Project the nearest three visible pickups or the active dropoff.""" - targets = ( - snapshot.pickup_targets_xyz_m - if snapshot.phase == "seeking_pickup" and snapshot.pickup_targets_xyz_m - else (snapshot.target_xyz_m,) - ) + if snapshot.phase == "seeking_pickup" and snapshot.pickup_targets_xyz_m: + targets = _nearest_visible_pickup_targets( + snapshot.pickup_targets_xyz_m, + rig_to_world, + camera_model, + image_width=image_width, + image_height=image_height, + ) + else: + targets = (snapshot.target_xyz_m,) projections = [ project_taxi_marker_to_camera( replace(snapshot, target_xyz_m=target), @@ -476,11 +481,37 @@ def project_taxi_markers_to_camera( ) for target in targets ] - visible = [projection for projection in projections if projection is not None] - if snapshot.phase == "seeking_pickup": - visible.sort(key=lambda projection: projection.distance_m) - del visible[3:] - return tuple(visible) + return tuple(projection for projection in projections if projection is not None) + + +def _nearest_visible_pickup_targets( + targets_xyz_m: tuple[tuple[float, float, float], ...], + rig_to_world: npt.NDArray[np.float32], + camera_model: FThetaCameraModel, + *, + image_width: int, + image_height: int, +) -> tuple[tuple[float, float, float], ...]: + targets = np.asarray(targets_xyz_m, dtype=np.float32) + uv, _depth, forward = camera_model.project_world(targets, rig_to_world) + inside = ( + forward + & (uv[:, 0] >= 0.0) + & (uv[:, 0] < float(image_width)) + & (uv[:, 1] >= 0.0) + & (uv[:, 1] < float(image_height)) + ) + visible_indices = np.flatnonzero(inside) + if visible_indices.size == 0: + return () + ego_xy = np.asarray(rig_to_world[:2, 3], dtype=np.float32) + offsets_xy = targets[:, :2] - ego_xy + distance_squared = np.einsum("ni,ni->n", offsets_xy, offsets_xy) + nearest_order = np.argsort( + distance_squared[visible_indices], + kind="stable", + )[:3] + return tuple(targets_xyz_m[int(index)] for index in visible_indices[nearest_order]) def _xyz_tuple(point: npt.NDArray[np.float32]) -> tuple[float, float, float]: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 8d04cb1ea..e62137724 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -5,6 +5,8 @@ from __future__ import annotations +import logging +import time from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -33,6 +35,8 @@ if TYPE_CHECKING: from crazy_robotaxi.application import ApplicationConfig +_LOGGER = logging.getLogger(__name__) + @dataclass(slots=True) class ModelState: @@ -52,6 +56,7 @@ class ModelState: last_pose: np.ndarray | None = None blocks_generated: int = 0 finished: bool = False + realtime_miss_count: int = 0 def ensure_rollout(self) -> WorldModelRollout: """Build renderer, PhysX, game, and cache on the model thread.""" @@ -75,6 +80,7 @@ def reset(self) -> None: self.driver_input.reset() self.blocks_generated = 0 self.finished = False + self.realtime_miss_count = 0 self.last_video = None self.last_bev_overlay = None self.last_pose = None @@ -97,6 +103,8 @@ class CrazyRobotaxiModelLoop(IModelLoop[ModelState]): """Run simulation, rules, conditioning, and generation in one V2 step.""" def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: + step_wall_started = time.perf_counter() + step_cpu_started = time.thread_time() state = self.state rollout = state.ensure_rollout() snapshot = rollout.engine.current_game_frame @@ -129,6 +137,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: game_frames = engine_step.game_frames poses = engine_step.trajectory.rig_poses_world bev = engine_step.condition.bev_tchw + bev_cpu_started = time.thread_time() bev_overlay = ( None if bev is None @@ -138,6 +147,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: height=state.session_desc.video_height, ) ) + bev_overlay_cpu_ms = (time.thread_time() - bev_cpu_started) * 1000.0 metrics = dict(generated.metrics) state.last_video = video[-1:].detach() state.last_bev_overlay = ( @@ -152,7 +162,9 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: poses = state.last_pose[None, ...] bev_overlay = state.last_bev_overlay metrics = {} + bev_overlay_cpu_ms = 0.0 + waypoint_cpu_started = time.thread_time() waypoint_layers = render_waypoint_layers( game_frames, poses, @@ -162,6 +174,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: device=video.device, dtype=video.dtype if video.is_floating_point() else torch.float32, ) + waypoint_cpu_ms = (time.thread_time() - waypoint_cpu_started) * 1000.0 hud_frames = build_hud_frames(video, game_frames) invoke_async( state.ui_loop, @@ -179,6 +192,53 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ): state.finished = True count = int(video.shape[0]) + if snapshot.session_state == "playing": + model_step_wall_ms = (time.perf_counter() - step_wall_started) * 1000.0 + model_step_cpu_ms = (time.thread_time() - step_cpu_started) * 1000.0 + chunk_duration_ms = ( + count / state.session_desc.frames_per_second_for_step * 1000.0 + ) + realtime_margin_ms = chunk_duration_ms - model_step_wall_ms + metrics.update( + { + "bev_overlay_cpu_ms": bev_overlay_cpu_ms, + "waypoint_overlay_cpu_ms": waypoint_cpu_ms, + "model_step_wall_ms": model_step_wall_ms, + "model_step_cpu_ms": model_step_cpu_ms, + "chunk_duration_ms": chunk_duration_ms, + "realtime_margin_ms": realtime_margin_ms, + } + ) + physx = engine_step.trajectory.physx_timings + if physx is not None: + metrics.update( + { + "physx_total_ms": physx.total_ms, + "physx_synchronize_ms": physx.synchronize_ms, + "physx_actor_update_ms": physx.actor_update_ms, + "physx_solver_ms": physx.solver_ms, + "physx_readback_ms": physx.readback_ms, + "physx_bridge_ms": physx.bridge_ms, + } + ) + if realtime_margin_ms < 0.0: + state.realtime_miss_count += 1 + if ( + state.realtime_miss_count <= 3 + or state.realtime_miss_count % 20 == 0 + ): + _LOGGER.warning( + "[crazy-robotaxi] chunk missed realtime budget: " + "step=%d frames=%d overrun_ms=%.1f wall_ms=%.1f " + "cpu_ms=%.1f engine_cpu_ms=%.1f waypoint_cpu_ms=%.1f", + step_index, + count, + -realtime_margin_ms, + model_step_wall_ms, + model_step_cpu_ms, + float(metrics.get("engine_cpu_ms", 0.0)), + waypoint_cpu_ms, + ) results = [ StepResult( step_index=step_index, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index e4b515ebb..85dd56487 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -61,6 +61,8 @@ def test_application_registers_model_and_slangpy_ui_loops() -> None: ui_loop, model_loop = session._take_loops() assert desc.output_layout is VideoTensorLayout.tchw + assert desc.frames_per_second_for_ui == 30 + assert desc.frames_per_second_for_step == 30 assert isinstance(model_loop, CrazyRobotaxiModelLoop) assert isinstance(ui_loop, CrazyRobotaxiSlangPyUILoop) assert model_loop.state.pipeline is pipeline @@ -89,7 +91,16 @@ def test_application_rejects_geometry_the_model_does_not_produce() -> None: app.create_session(desc) -def test_application_rejects_a_frame_rate_the_model_was_not_trained_for() -> None: +@pytest.mark.parametrize( + "override", + [ + {"frames_per_second_for_step": 60}, + {"frames_per_second_for_ui": 60}, + ], +) +def test_application_rejects_mismatched_generation_or_ui_rate( + override: dict[str, int], +) -> None: app = CrazyRobotaxiApplication( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), @@ -97,4 +108,4 @@ def test_application_rejects_a_frame_rate_the_model_was_not_trained_for() -> Non app.init([]) with pytest.raises(ValueError, match="30 frames per second"): - app.create_session(replace(app.session_desc(), frames_per_second_for_step=60)) + app.create_session(replace(app.session_desc(), **override)) diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 5389ba9b3..566820d96 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -7,7 +7,7 @@ import queue import threading -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from types import SimpleNamespace from typing import Any @@ -15,7 +15,11 @@ import pytest import slangpy as spy import torch -from crazy_robotaxi.rules import TaxiGameSnapshot, TaxiSessionState +from crazy_robotaxi.rules import ( + TaxiGameSnapshot, + TaxiSessionState, + project_taxi_markers_to_camera, +) from crazy_robotaxi.ui import ( CrazyRobotaxiSlangPyUILoop, TaxiHudState, @@ -173,6 +177,47 @@ def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: assert torch.count_nonzero(terminal[:, 3]) == 0 +def test_pickup_waypoint_projection_batches_anchors_before_rendering_rings() -> None: + class RecordingCamera: + def __init__(self) -> None: + self.point_counts: list[int] = [] + + def project_world(self, points, rig_to_world): + del rig_to_world + points = np.asarray(points) + self.point_counts.append(len(points)) + uv = np.column_stack( + ( + np.full(len(points), 80.0, dtype=np.float32), + 48.0 - points[:, 2], + ) + ) + return ( + uv, + np.ones(len(points), dtype=np.float32), + np.ones(len(points), dtype=bool), + ) + + camera: Any = RecordingCamera() + targets = tuple((float(distance), 0.0, 0.0) for distance in range(60, 0, -10)) + snapshot = replace( + _snapshot(), + target_xyz_m=targets[-1], + pickup_targets_xyz_m=targets, + ) + + projections = project_taxi_markers_to_camera( + snapshot, + np.eye(4, dtype=np.float32), + camera, + image_width=160, + image_height=96, + ) + + assert camera.point_counts == [6, 34, 34, 34] + assert [projection.distance_m for projection in projections] == [10.0, 20.0, 30.0] + + def test_bev_is_rendered_as_frame_aligned_rgba_overlay() -> None: overlay = render_bev_overlay( torch.full((1, 3, 32, 32), 0.5), diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py b/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py index 4adcc022a..6f06f752e 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/engine.py @@ -5,7 +5,9 @@ from __future__ import annotations -from dataclasses import dataclass, replace +import time +from collections.abc import Mapping +from dataclasses import dataclass, field, replace from omnidreams_game_engine.contracts import ( ConditionRenderer, @@ -22,6 +24,8 @@ class EngineStep: trajectory: TrajectoryChunk game_frames: tuple[object, ...] condition: ConditionBatch + metrics: Mapping[str, float] = field(default_factory=dict) + """Wall and model-thread CPU timings for the engine's major stages.""" class GameEngine: @@ -63,12 +67,22 @@ def step(self, commands: tuple[DriverCommand, ...]) -> EngineStep: raise RuntimeError("GameEngine is closed") if not commands: raise ValueError("A game-engine step requires at least one command") + + step_wall_started = time.perf_counter() + step_cpu_started = time.thread_time() + simulation_wall_started = time.perf_counter() + simulation_cpu_started = time.thread_time() trajectory = self.simulation.pose_chunk( commands=commands, chunk_size=len(commands), frame_interval_s=self.frame_interval_s, extrapolation_offset_s=0.0, ) + simulation_wall_ms = (time.perf_counter() - simulation_wall_started) * 1000.0 + simulation_cpu_ms = (time.thread_time() - simulation_cpu_started) * 1000.0 + + rules_wall_started = time.perf_counter() + rules_cpu_started = time.thread_time() update = self.rules.advance_frames(trajectory, self.frame_interval_s) if len(update.frames) != len(commands): raise ValueError("Game frames must align with simulated commands") @@ -76,13 +90,33 @@ def step(self, commands: tuple[DriverCommand, ...]) -> EngineStep: trajectory, dynamic_actors=(*trajectory.dynamic_actors, *update.dynamic_actors), ) + rules_wall_ms = (time.perf_counter() - rules_wall_started) * 1000.0 + rules_cpu_ms = (time.thread_time() - rules_cpu_started) * 1000.0 + + conditioning_wall_started = time.perf_counter() + conditioning_cpu_started = time.thread_time() condition = self.condition_renderer.render(trajectory) + conditioning_wall_ms = ( + time.perf_counter() - conditioning_wall_started + ) * 1000.0 + conditioning_cpu_ms = (time.thread_time() - conditioning_cpu_started) * 1000.0 if int(condition.hdmap_bvtchw.shape[2]) != len(commands): raise ValueError("Condition frames must align with simulated commands") return EngineStep( trajectory=trajectory, game_frames=update.frames, condition=condition, + metrics={ + "simulation_wall_ms": simulation_wall_ms, + "simulation_cpu_ms": simulation_cpu_ms, + "rules_wall_ms": rules_wall_ms, + "rules_cpu_ms": rules_cpu_ms, + "conditioning_wall_ms": conditioning_wall_ms, + "conditioning_cpu_ms": conditioning_cpu_ms, + "engine_step_wall_ms": (time.perf_counter() - step_wall_started) + * 1000.0, + "engine_step_cpu_ms": (time.thread_time() - step_cpu_started) * 1000.0, + }, ) def close(self) -> None: diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py index 5522befd4..c5415cba3 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py @@ -5,6 +5,7 @@ from __future__ import annotations +import time from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any, Protocol @@ -93,7 +94,17 @@ def step( expected = self.frame_count(autoregressive_index) if len(commands) != expected: raise ValueError(f"Expected {expected} commands, got {len(commands)}") + + rollout_wall_started = time.perf_counter() + rollout_cpu_started = time.thread_time() + engine_wall_started = time.perf_counter() + engine_cpu_started = time.thread_time() engine_step = self.engine.step(commands) + engine_wall_ms = (time.perf_counter() - engine_wall_started) * 1000.0 + engine_cpu_ms = (time.thread_time() - engine_cpu_started) * 1000.0 + + pipeline_wall_started = time.perf_counter() + pipeline_cpu_started = time.thread_time() with torch.no_grad(): video = self.pipeline.generate( autoregressive_index=autoregressive_index, @@ -104,8 +115,15 @@ def step( autoregressive_index=autoregressive_index, cache=self.cache, ) + pipeline_wall_ms = (time.perf_counter() - pipeline_wall_started) * 1000.0 + pipeline_cpu_ms = (time.thread_time() - pipeline_cpu_started) * 1000.0 + + postprocess_wall_started = time.perf_counter() + postprocess_cpu_started = time.thread_time() if self._postprocess is not None: video = self._postprocess(video) + postprocess_wall_ms = (time.perf_counter() - postprocess_wall_started) * 1000.0 + postprocess_cpu_ms = (time.thread_time() - postprocess_cpu_started) * 1000.0 if video.ndim != 6 or tuple(video.shape[:2]) != (1, 1): raise ValueError( "The game requires single-batch, single-view BVTCHW video; got " @@ -113,10 +131,25 @@ def step( ) if int(video.shape[2]) != expected: raise ValueError("Generated video does not align with the engine step") + step_metrics = dict(metrics or {}) + step_metrics.update(engine_step.metrics) + step_metrics.update( + { + "engine_wall_ms": engine_wall_ms, + "engine_cpu_ms": engine_cpu_ms, + "pipeline_wall_ms": pipeline_wall_ms, + "pipeline_cpu_ms": pipeline_cpu_ms, + "postprocess_wall_ms": postprocess_wall_ms, + "postprocess_cpu_ms": postprocess_cpu_ms, + "rollout_wall_ms": (time.perf_counter() - rollout_wall_started) + * 1000.0, + "rollout_cpu_ms": (time.thread_time() - rollout_cpu_started) * 1000.0, + } + ) return WorldModelStep( video_bvtchw=video.detach(), engine=engine_step, - metrics=dict(metrics or {}), + metrics=step_metrics, ) def reset(self) -> None: diff --git a/apps/omnidreams_game_engine/tests/test_engine.py b/apps/omnidreams_game_engine/tests/test_engine.py index 64ae39d50..3c97a8362 100644 --- a/apps/omnidreams_game_engine/tests/test_engine.py +++ b/apps/omnidreams_game_engine/tests/test_engine.py @@ -106,6 +106,19 @@ def test_engine_aligns_simulation_rules_and_conditioning() -> None: assert len(result.trajectory.vehicle_states) == 2 assert result.game_frames == (("frame", 0.0), ("frame", 1.0)) assert result.condition.hdmap_bvtchw.shape == (1, 1, 2, 3, 4, 6) + assert all( + result.metrics[name] >= 0.0 + for name in ( + "simulation_wall_ms", + "simulation_cpu_ms", + "rules_wall_ms", + "rules_cpu_ms", + "conditioning_wall_ms", + "conditioning_cpu_ms", + "engine_step_wall_ms", + "engine_step_cpu_ms", + ) + ) assert engine.current_game_frame == ("snapshot", 1.0) assert engine.submit_text("CAB") == ("CAB", 1.0) diff --git a/apps/omnidreams_game_engine/tests/test_model.py b/apps/omnidreams_game_engine/tests/test_model.py index 7dc500249..a244fcd8b 100644 --- a/apps/omnidreams_game_engine/tests/test_model.py +++ b/apps/omnidreams_game_engine/tests/test_model.py @@ -129,7 +129,13 @@ def engine_factory() -> _Engine: ) assert result.video_bvtchw.shape == (1, 1, 2, 3, 4, 8) - assert result.metrics == {"model_ms": 1.25} + assert result.metrics["model_ms"] == 1.25 + assert result.metrics["engine_wall_ms"] >= 0.0 + assert result.metrics["engine_cpu_ms"] >= 0.0 + assert result.metrics["pipeline_wall_ms"] >= 0.0 + assert result.metrics["pipeline_cpu_ms"] >= 0.0 + assert result.metrics["rollout_wall_ms"] >= 0.0 + assert result.metrics["rollout_cpu_ms"] >= 0.0 assert [call[0] for call in pipeline.calls] == [ "initialize_cache", "get_num_frames", From 9e933cfc63afcf3f5088a40167f60d48b3acb4c8 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 26 Aug 2026 18:58:19 +0000 Subject: [PATCH 09/62] Optimize Crazy Robotaxi frame pacing Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 46 ++-- .../crazy_robotaxi/application.py | 11 + apps/crazy_robotaxi/crazy_robotaxi/physics.py | 75 +++++- apps/crazy_robotaxi/crazy_robotaxi/rules.py | 133 ++++++---- apps/crazy_robotaxi/crazy_robotaxi/session.py | 35 +-- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 69 ++++- .../crazy_robotaxi/world_overlay.py | 242 ++++++++++++------ apps/crazy_robotaxi/tests/test_application.py | 47 +++- apps/crazy_robotaxi/tests/test_ui.py | 89 ++++++- docs/design/crazy_robotaxi_v2_architecture.md | 67 +++-- 10 files changed, 584 insertions(+), 230 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index e5da284fd..f8a1f5da7 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -5,11 +5,14 @@ and `omnidreams-game-engine`. FlashDreams owns input collection, reset generations, presentation buffering, client windows, and the two-thread runtime. The UI loop uses FlashDreams' SlangPy renderer for the game HUD and composites it over model frames for local and WebRTC clients. -Camera-projected waypoint rings, beacons, and labels are carried as a -frame-aligned world layer; they are not ImGui windows. The optional BEV view -is likewise published as a frame-aligned RGBA model-result layer. The -`CrazyRobotaxiSlangPyUILoop` composites video, waypoints, and BEV through V2's -presentation manager before the base loop applies the SlangPy ImGui HUD. +Camera-projected waypoint rings, beacons, and labels are rendered as a +frame-aligned world layer on the UI thread; they are not ImGui windows. The +model loop publishes immutable snapshot-and-pose metadata for each generated +frame, and the UI loop rasterizes each presented waypoint layer once and +caches it while that frame remains visible. The optional BEV view is published +as an RGBA model-result layer. `CrazyRobotaxiSlangPyUILoop` composites video, +waypoints, and BEV through V2's presentation manager before the base loop +applies the SlangPy ImGui HUD. ```bash uv sync --package crazy-robotaxi @@ -34,22 +37,33 @@ loop through V2's asynchronous loop-message contract. ## Performance diagnostics -Crazy Robotaxi emits model, engine, overlay, and complete model-step timing -metrics. Capture them while reproducing a chunk pause with: +Crazy Robotaxi emits lightweight model-thread, engine, overlay, and PhysX +timings during normal play. Capture synchronized model-step and GPU-stage +diagnostics while reproducing a chunk pause with: ```bash uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc \ - --stats-path /tmp/crazy-robotaxi-stats.json + --stats-path /tmp/crazy-robotaxi-stats.json -- \ + --profile-pipeline ``` -The runtime also warns when a model step exceeds the duration of the frames it -produces. The live `model_step_wall_ms`, `model_step_cpu_ms`, `engine_cpu_ms`, -`simulation_cpu_ms`, `rules_cpu_ms`, `conditioning_cpu_ms`, -`waypoint_overlay_cpu_ms`, and `physx_*_ms` metrics separate a throughput miss -from app-side CPU work; the pipeline's `encode_ms`, `diffuse_ms`, `decode_ms`, -and `finalize_ms` metrics identify the corresponding GPU stage. The JSON sink -normalizes `_ms` metric names to `_s`. Exclude the first chunks when judging -steady state because compilation and graph capture are startup costs. +Pipeline profiling is diagnostic and disabled during normal play because its +CUDA synchronization creates a CPU spin hotspot and prevents pipeline overlap. +With `--profile-pipeline`, the runtime also warns when a model step exceeds the +duration of the frames it produces. The live `model_step_wall_ms`, +`model_step_cpu_ms`, `engine_cpu_ms`, +`simulation_cpu_ms`, `rules_cpu_ms`, `conditioning_cpu_ms`, and `physx_*_ms` +metrics separate a throughput miss from model-thread CPU work; the pipeline's +`encode_ms`, `diffuse_ms`, `decode_ms`, and `finalize_ms` metrics identify the +corresponding GPU stage. Waypoint rasterization is UI-thread work and is not +included in model-step metrics. The JSON sink normalizes `_ms` metric names to +`_s`. Exclude the first chunks when judging steady state because compilation +and graph capture are startup costs. + +Presentation remains fixed at 30 fps. Disabling diagnostic synchronization +removes an avoidable pause source, but it does not make a model preset whose +steady-state throughput is below 30 fps meet that rate; use `--model-preset +perf` when its quality/performance tradeoff is appropriate. ## Authored maps diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 6c9fe9342..b7d65c7ab 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -58,6 +58,7 @@ class ApplicationConfig: driver_input: DriverInputConfig device: str total_blocks: int | None + pipeline_profiling: bool PipelineFactory = Callable[[Any, str], Any] @@ -126,6 +127,10 @@ def init(self, commandline_args: Sequence[str]) -> None: pipeline_config, diffusion_model={"seed": int(args.seed)}, ) + pipeline_config = derive_config( + pipeline_config, + enable_sync_and_profile=bool(args.profile_pipeline), + ) self._pipeline_config = pipeline_config self._config = ApplicationConfig( scene_request=SceneRequest( @@ -140,6 +145,7 @@ def init(self, commandline_args: Sequence[str]) -> None: driver_input=settings.driver_input, device=args.device, total_blocks=args.total_blocks, + pipeline_profiling=bool(args.profile_pipeline), ) def create_session(self, session_desc: SessionDesc) -> ISession: @@ -215,4 +221,9 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--seed", type=int) parser.add_argument("--high-scores", type=Path) parser.add_argument("--compile", action=argparse.BooleanOptionalAction) + parser.add_argument( + "--profile-pipeline", + action="store_true", + help="synchronize each chunk and emit diagnostic GPU stage timings", + ) return parser diff --git a/apps/crazy_robotaxi/crazy_robotaxi/physics.py b/apps/crazy_robotaxi/crazy_robotaxi/physics.py index 83f14469f..bea49319a 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/physics.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/physics.py @@ -10,7 +10,7 @@ import numpy as np from loguru import logger -from ludus_renderer import RigidBodyModel +from ludus_renderer import PhysicsObjectGraph, RigidBodyModel from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld from omnidreams_game_engine.types import ( DriverCommand, @@ -22,6 +22,8 @@ from crazy_robotaxi.dynamics import TaxiVehicleConfig _CHASSIS_INSET_M = 0.16 +_TAXI_PHYSX_RECENTER_DISTANCE_M = 32.0 +"""Taxi spatial collision horizon recenter threshold.""" def inset_vehicle_chassis(model: RigidBodyModel) -> RigidBodyModel: @@ -77,6 +79,77 @@ def __init__( ) self._last_contact_resolved_state: VehicleState | None = None + def synchronize_window( + self, + center_xy_m: np.ndarray, + timestamp_us: int | None = None, + ) -> bool: + """Refresh Taxi collision topology only when its spatial window changes. + + Crazy Robotaxi's base graph contains static semantic barriers and its + moving actors are all owned by ``MapTrafficController``. The base + implementation already detects map-traffic vicinity changes on every + call, so its periodic timestamp-only rebuild merely re-filters the same + thousands of static curb segments. Passing no timestamp retains spatial + recentering and traffic additions/removals without that redundant scan. + """ + del timestamp_us + center = np.asarray(center_xy_m, dtype=np.float32) + if center.shape != (2,): + raise ValueError("center_xy_m must have shape (2,)") + if self.graph.objects or ( + float(np.linalg.norm(center - self._physics_center_xy)) + >= _TAXI_PHYSX_RECENTER_DISTANCE_M + ): + return super().synchronize_window(center, timestamp_us=None) + + traffic_topology_changed = False + if self._vicinity_resolver is not None: + self._map_vicinity = self._vicinity_resolver.resolve( + float(center[0]), + float(center[1]), + previous=self._map_vicinity, + ) + traffic_topology_changed = self._map_traffic.set_vicinity( + self._map_vicinity + ) + if not traffic_topology_changed: + return False + + incoming = self._map_traffic.active_objects + incoming_ids = {scene_object.object_id for scene_object in incoming} + retained_detached = tuple( + scene_object + for scene_object in self._physics_graph.objects + if scene_object.object_id in self._detached_entity_ids + and scene_object.object_id in self._map_traffic.active_object_ids + and scene_object.object_id not in incoming_ids + ) + objects = incoming + retained_detached + # This graph is a desired-state message for PhysX, not a graph that is + # spatially queried. Building it from ``objects=...`` would construct a + # transient track index on every traffic-boundary crossing. + physics_graph = PhysicsObjectGraph() + physics_graph.objects = objects + physics_graph.object_index = { + scene_object.object_id: index for index, scene_object in enumerate(objects) + } + physics_graph.barriers = self._physics_graph.barriers + self._world.synchronize( + physics_graph, + initial_object_timestamps_us=self._map_traffic.active_timestamps_us, + ) + existing_entities = {entity.entity_id: entity for entity in self._entities} + self._entities = [ + existing_entities.get(scene_object.object_id) + or self._entity_from_object(scene_object) + for scene_object in physics_graph.objects + ] + self._entities_by_id = {entity.entity_id: entity for entity in self._entities} + self._detached_entity_ids.intersection_update(self._entities_by_id) + self._physics_graph = physics_graph + return True + def step_with_command( self, state: VehicleState, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/rules.py b/apps/crazy_robotaxi/crazy_robotaxi/rules.py index 8324eff8d..1a9b8f3c1 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/rules.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/rules.py @@ -7,7 +7,7 @@ import hashlib import math -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Literal @@ -398,58 +398,98 @@ def project_taxi_marker_to_camera( image. This deliberately does not clamp off-screen targets to an edge; the always-visible direction arrow already covers that case. """ + projections = _project_taxi_targets_to_camera( + (snapshot.target_xyz_m,), + target_radius_m=snapshot.target_radius_m, + rig_to_world=rig_to_world, + camera_model=camera_model, + image_width=image_width, + image_height=image_height, + ring_samples=ring_samples, + beacon_height_m=beacon_height_m, + ) + return projections[0] if projections else None + + +def _project_taxi_targets_to_camera( + targets_xyz_m: tuple[tuple[float, float, float], ...], + *, + target_radius_m: float, + rig_to_world: npt.NDArray[np.float32], + camera_model: FThetaCameraModel, + image_width: int, + image_height: int, + ring_samples: int = 32, + beacon_height_m: float = 3.5, +) -> tuple[TaxiCameraMarkerProjection, ...]: if image_width <= 0 or image_height <= 0: raise ValueError("Taxi camera image dimensions must be positive.") if ring_samples < 3: raise ValueError("Taxi target ring requires at least three samples.") + if not targets_xyz_m: + return () - target = np.asarray(snapshot.target_xyz_m, dtype=np.float32) + targets = np.asarray(targets_xyz_m, dtype=np.float32) angles = np.linspace( 0.0, 2.0 * math.pi, ring_samples, endpoint=False, dtype=np.float32 ) - ring = np.repeat(target[None, :], ring_samples, axis=0) - ring[:, 0] += np.float32(snapshot.target_radius_m) * np.cos(angles) - ring[:, 1] += np.float32(snapshot.target_radius_m) * np.sin(angles) + rings = np.repeat(targets[:, None, :], ring_samples, axis=1) + rings[:, :, 0] += np.float32(target_radius_m) * np.cos(angles) + rings[:, :, 1] += np.float32(target_radius_m) * np.sin(angles) + beacons = targets + np.asarray([0.0, 0.0, beacon_height_m], dtype=np.float32) points = np.concatenate( - ( - target[None, :], - (target + np.array([0.0, 0.0, beacon_height_m], dtype=np.float32))[None, :], - ring, - ), - axis=0, + (targets[:, None, :], beacons[:, None, :], rings), + axis=1, + ) + uv, _depth, forward = camera_model.project_world( + points.reshape(-1, 3), + rig_to_world, ) - uv, _depth, forward = camera_model.project_world(points, rig_to_world) + uv = uv.reshape(len(targets), ring_samples + 2, 2) + forward = forward.reshape(len(targets), ring_samples + 2) inside = ( forward - & (uv[:, 0] >= 0.0) - & (uv[:, 0] < float(image_width)) - & (uv[:, 1] >= 0.0) - & (uv[:, 1] < float(image_height)) + & (uv[:, :, 0] >= 0.0) + & (uv[:, :, 0] < float(image_width)) + & (uv[:, :, 1] >= 0.0) + & (uv[:, :, 1] < float(image_height)) ) - if not bool(inside[0]): - return None - ring_edges: list[tuple[tuple[float, float], tuple[float, float]]] = [] - for index in range(ring_samples): - left = 2 + index - right = 2 + ((index + 1) % ring_samples) - if bool(inside[left] and inside[right]): - ring_edges.append( - ( - (float(uv[left, 0]), float(uv[left, 1])), - (float(uv[right, 0]), float(uv[right, 1])), + projections = [] + ego_x = float(rig_to_world[0, 3]) + ego_y = float(rig_to_world[1, 3]) + for target_index, target in enumerate(targets): + if not bool(inside[target_index, 0]): + continue + target_uv = uv[target_index] + target_inside = inside[target_index] + ring_edges: list[tuple[tuple[float, float], tuple[float, float]]] = [] + for ring_index in range(ring_samples): + left = 2 + ring_index + right = 2 + ((ring_index + 1) % ring_samples) + if bool(target_inside[left] and target_inside[right]): + ring_edges.append( + ( + (float(target_uv[left, 0]), float(target_uv[left, 1])), + (float(target_uv[right, 0]), float(target_uv[right, 1])), + ) ) + projections.append( + TaxiCameraMarkerProjection( + anchor_uv=(float(target_uv[0, 0]), float(target_uv[0, 1])), + beacon_top_uv=( + (float(target_uv[1, 0]), float(target_uv[1, 1])) + if bool(target_inside[1]) + else None + ), + ring_edges_uv=tuple(ring_edges), + distance_m=math.hypot( + float(target[0]) - ego_x, + float(target[1]) - ego_y, + ), ) - - return TaxiCameraMarkerProjection( - anchor_uv=(float(uv[0, 0]), float(uv[0, 1])), - beacon_top_uv=((float(uv[1, 0]), float(uv[1, 1])) if bool(inside[1]) else None), - ring_edges_uv=tuple(ring_edges), - distance_m=math.hypot( - float(target[0]) - float(rig_to_world[0, 3]), - float(target[1]) - float(rig_to_world[1, 3]), - ), - ) + ) + return tuple(projections) def project_taxi_markers_to_camera( @@ -471,17 +511,14 @@ def project_taxi_markers_to_camera( ) else: targets = (snapshot.target_xyz_m,) - projections = [ - project_taxi_marker_to_camera( - replace(snapshot, target_xyz_m=target), - rig_to_world, - camera_model, - image_width=image_width, - image_height=image_height, - ) - for target in targets - ] - return tuple(projection for projection in projections if projection is not None) + return _project_taxi_targets_to_camera( + targets, + target_radius_m=snapshot.target_radius_m, + rig_to_world=rig_to_world, + camera_model=camera_model, + image_width=image_width, + image_height=image_height, + ) def _nearest_visible_pickup_targets( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index e62137724..a20125da9 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -24,7 +24,7 @@ TaxiHudState, build_hud_frames, ) -from crazy_robotaxi.world_overlay import render_bev_overlay, render_waypoint_layers +from crazy_robotaxi.world_overlay import render_bev_overlay from flashdreams.api_v2.loop import IModelLoop, IUILoop, invoke_async from flashdreams.api_v2.session import ISession from flashdreams.runtime_v2.session_desc import SessionDesc @@ -164,18 +164,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: metrics = {} bev_overlay_cpu_ms = 0.0 - waypoint_cpu_started = time.thread_time() - waypoint_layers = render_waypoint_layers( - game_frames, - poses, - state.scene.selected_camera, - width=state.session_desc.video_width, - height=state.session_desc.video_height, - device=video.device, - dtype=video.dtype if video.is_floating_point() else torch.float32, - ) - waypoint_cpu_ms = (time.thread_time() - waypoint_cpu_started) * 1000.0 - hud_frames = build_hud_frames(video, game_frames) + hud_frames = build_hud_frames(video, game_frames, poses) invoke_async( state.ui_loop, lambda ui_state, frames=hud_frames: ui_state.publish(frames), @@ -198,17 +187,17 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: chunk_duration_ms = ( count / state.session_desc.frames_per_second_for_step * 1000.0 ) - realtime_margin_ms = chunk_duration_ms - model_step_wall_ms metrics.update( { "bev_overlay_cpu_ms": bev_overlay_cpu_ms, - "waypoint_overlay_cpu_ms": waypoint_cpu_ms, - "model_step_wall_ms": model_step_wall_ms, "model_step_cpu_ms": model_step_cpu_ms, "chunk_duration_ms": chunk_duration_ms, - "realtime_margin_ms": realtime_margin_ms, } ) + if state.config.pipeline_profiling: + realtime_margin_ms = chunk_duration_ms - model_step_wall_ms + metrics["model_step_wall_ms"] = model_step_wall_ms + metrics["realtime_margin_ms"] = realtime_margin_ms physx = engine_step.trajectory.physx_timings if physx is not None: metrics.update( @@ -221,7 +210,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: "physx_bridge_ms": physx.bridge_ms, } ) - if realtime_margin_ms < 0.0: + if state.config.pipeline_profiling and realtime_margin_ms < 0.0: state.realtime_miss_count += 1 if ( state.realtime_miss_count <= 3 @@ -230,14 +219,13 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: _LOGGER.warning( "[crazy-robotaxi] chunk missed realtime budget: " "step=%d frames=%d overrun_ms=%.1f wall_ms=%.1f " - "cpu_ms=%.1f engine_cpu_ms=%.1f waypoint_cpu_ms=%.1f", + "cpu_ms=%.1f engine_cpu_ms=%.1f", step_index, count, -realtime_margin_ms, model_step_wall_ms, model_step_cpu_ms, float(metrics.get("engine_cpu_ms", 0.0)), - waypoint_cpu_ms, ) results = [ StepResult( @@ -247,12 +235,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: output_layout=VideoTensorLayout.tchw, metrics=metrics, ), - StepResult( - step_index=step_index, - output=waypoint_layers, - frame_count=count, - output_layout=VideoTensorLayout.tchw, - ), ] if bev_overlay is not None: results.append( @@ -299,6 +281,7 @@ def init(self) -> None: hud_state = TaxiHudState( width=self._session_desc.video_width, height=self._session_desc.video_height, + calibration=self._scene.selected_camera, ) ui_loop = self.register_ui_loop( CrazyRobotaxiSlangPyUILoop, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 15db6d842..4c493f01b 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -23,11 +23,15 @@ from dataclasses import dataclass, field from typing import Any +import numpy as np +import numpy.typing as npt import torch +from omnidreams_game_engine.types import CameraCalibration from torch import Tensor from crazy_robotaxi.high_scores import validate_player_name from crazy_robotaxi.rules import TaxiGameSnapshot +from crazy_robotaxi.world_overlay import render_waypoint_layers from flashdreams.api_v2.loop import ILoop, invoke_async from flashdreams.runtime_v2.slangpy_ui_loop import SlangPyUILoop from flashdreams.runtime_v2.user_input_events import UserInputEvents @@ -46,6 +50,9 @@ class TaxiHudFrame: snapshot: TaxiGameSnapshot """Taxi rules snapshot for the corresponding simulation frame.""" + rig_pose_world: npt.NDArray[np.float32] + """Read-only rig pose that generated the corresponding video frame.""" + @dataclass(slots=True) class _HudWidgets: @@ -79,6 +86,9 @@ class TaxiHudState: height: int """Presentation height in pixels.""" + calibration: CameraCalibration + """Camera calibration used to project world markers on the UI thread.""" + model_loop: ILoop[Any] | None = None """Model-loop endpoint used only through ``invoke_async``.""" @@ -88,6 +98,12 @@ class TaxiHudState: _current: TaxiHudFrame | None = None """Snapshot aligned with the frame currently beneath ImGui.""" + _waypoint_source: TaxiHudFrame | None = None + """Frame metadata used by the cached waypoint layer.""" + + _waypoint_layer: Tensor | None = None + """Cached world overlay for the currently presented generated frame.""" + _widgets: _HudWidgets | None = None """Lazily created retained widgets.""" @@ -119,6 +135,37 @@ def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: self._current = selected return self._current + def waypoint_layer(self, frame: Tensor) -> Tensor | None: + """Return the cached marker layer aligned with ``frame``. + + Rasterizing one presented frame on the UI thread avoids constructing a + full chunk of large RGBA tensors at the model-step boundary. The V2 + presentation manager may show the same generated frame for multiple UI + ticks, so the completed layer is retained until frame metadata changes. + """ + source = self._frames.get(int(frame.data_ptr())) + if source is None: + return None + cached = self._waypoint_layer + if ( + source is self._waypoint_source + and cached is not None + and cached.device == frame.device + ): + return cached + layer = render_waypoint_layers( + (source.snapshot,), + source.rig_pose_world[None, ...], + self.calibration, + width=self.width, + height=self.height, + device=frame.device, + dtype=torch.float32, + )[0] + self._waypoint_source = source + self._waypoint_layer = layer + return layer + def draw(self, ui: Any) -> None: """Create or update the retained ImGui HUD widget tree.""" widgets = self._ensure_widgets(ui) @@ -154,6 +201,8 @@ def reset(self) -> None: """Clear per-generation HUD snapshots and editable UI state.""" self._frames.clear() self._current = None + self._waypoint_source = None + self._waypoint_layer = None self._validation_message = "" self._submission_pending = False if self._widgets is not None: @@ -299,19 +348,16 @@ def step_ui( del step_index, events frames = self.presented_model_frames() video = frames[0] if frames else None - waypoints = frames[1] if len(frames) > 1 else None - bev_overlay = frames[2] if len(frames) > 2 else None + bev_overlay = frames[1] if len(frames) > 1 else None if video is not None: self.state.select_presented_frame(video) self.state.draw(ui) if video is None: return None - if waypoints is None or waypoints.shape[0] != 4: - raise ValueError("Waypoint presentation frames must use [4,H,W]") - world = self._presentation_manager.composite( - video.to(torch.float32), - waypoints.to(device=video.device, dtype=torch.float32), - ) + world = video.to(torch.float32) + waypoints = self.state.waypoint_layer(video) + if waypoints is not None: + world = self._presentation_manager.composite(world, waypoints) if bev_overlay is None: return world if bev_overlay.shape[0] != 4: @@ -330,19 +376,26 @@ def reset(self) -> None: def build_hud_frames( video_tchw: Tensor, snapshots: Sequence[object], + rig_poses_world: npt.NDArray[np.float32], ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) if len(snapshots) != frame_count: raise ValueError("Video and game snapshots must align") + poses = np.asarray(rig_poses_world, dtype=np.float32) + if poses.shape != (frame_count, 4, 4): + raise ValueError("Video and rig poses must align") frames = [] for index, snapshot in enumerate(snapshots): if not isinstance(snapshot, TaxiGameSnapshot): raise TypeError("Taxi HUD received a non-taxi game snapshot") + pose = poses[index].copy() + pose.setflags(write=False) frames.append( TaxiHudFrame( frame_key=int(video_tchw[index].data_ptr()), snapshot=snapshot, + rig_pose_world=pose, ) ) return tuple(frames) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py index 98c76a8f1..1d57e2f9d 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py @@ -19,6 +19,7 @@ import math from collections.abc import Sequence +from functools import lru_cache import numpy as np import torch @@ -43,6 +44,30 @@ _WHITE = (1.0, 1.0, 1.0) _LABEL_BACKGROUND = (-0.9372549, -0.9372549, -0.90588236) +_BLACK_RING = 0 +_PICKUP_RING = 1 +_DROPOFF_RING = 2 +_BLACK_BEACON = 3 +_PICKUP_SOLID = 4 +_DROPOFF_SOLID = 5 +_WHITE_SOLID = 6 +_LABEL_PANEL = 7 + +_WAYPOINT_PALETTE = np.asarray( + ( + (*_BLACK, 220.0 / 255.0), + (*_PICKUP_COLOR, 245.0 / 255.0), + (*_DROPOFF_COLOR, 245.0 / 255.0), + (*_BLACK, 235.0 / 255.0), + (*_PICKUP_COLOR, 1.0), + (*_DROPOFF_COLOR, 1.0), + (*_WHITE, 1.0), + (*_LABEL_BACKGROUND, 225.0 / 255.0), + ), + dtype=np.float32, +) +"""Painter styles uploaded once for each frame-aligned waypoint batch.""" + _GLYPHS = { "C": ("1111", "1000", "1000", "1000", "1000", "1000", "1111"), "D": ("1110", "1001", "1001", "1001", "1001", "1001", "1110"), @@ -154,11 +179,7 @@ def render_waypoint_layers( output_width=width, output_height=height, ) - layers = torch.zeros( - (frame_count, 4, height, width), - device=device, - dtype=dtype, - ) + styles = np.full((frame_count, height, width), -1, dtype=np.int8) for frame_index, snapshot in enumerate(snapshots): if not isinstance(snapshot, TaxiGameSnapshot): raise TypeError("Waypoint layer received a non-taxi game snapshot") @@ -171,7 +192,12 @@ def render_waypoint_layers( image_width=width, image_height=height, ) - color = _PICKUP_COLOR if snapshot.phase == "seeking_pickup" else _DROPOFF_COLOR + if snapshot.phase == "seeking_pickup": + ring_style = _PICKUP_RING + solid_style = _PICKUP_SOLID + else: + ring_style = _DROPOFF_RING + solid_style = _DROPOFF_SOLID label = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" ring_edges = [] beacons = [] @@ -179,15 +205,28 @@ def render_waypoint_layers( ring_edges.extend(projection.ring_edges_uv) beacons.append((projection.anchor_uv, _beacon_top(projection))) - layer = layers[frame_index] - _paint_lines(layer, ring_edges, width, height, 7, _BLACK, 220.0 / 255.0) - _paint_lines(layer, ring_edges, width, height, 4, color, 245.0 / 255.0) - _paint_lines(layer, beacons, width, height, 9, _BLACK, 235.0 / 255.0) - _paint_lines(layer, beacons, width, height, 5, color, 1.0) + layer = styles[frame_index] + _paint_lines(layer, ring_edges, width, height, 7, _BLACK_RING) + _paint_lines(layer, ring_edges, width, height, 4, ring_style) + _paint_lines(layer, beacons, width, height, 9, _BLACK_BEACON) + _paint_lines(layer, beacons, width, height, 5, solid_style) for projection, (_, top) in zip(projections, beacons, strict=True): - _paint_marker_anchor(layer, projection.anchor_uv, width, height, color) - _paint_marker_label(layer, top, label, width, height, color) - return layers + _paint_marker_anchor( + layer, + projection.anchor_uv, + width, + height, + solid_style, + ) + _paint_marker_label( + layer, + top, + label, + width, + height, + solid_style, + ) + return _materialize_waypoint_layers(styles, device=device, dtype=dtype) def _beacon_top(projection: TaxiCameraMarkerProjection) -> tuple[float, float]: @@ -205,56 +244,45 @@ def _beacon_top(projection: TaxiCameraMarkerProjection) -> tuple[float, float]: def _paint_lines( - layer: Tensor, + layer: np.ndarray, lines: Sequence[tuple[tuple[float, float], tuple[float, float]]], width: int, height: int, width_px: int, - color: tuple[float, float, float], - alpha: float, + style: int, ) -> None: if not lines: return - pixels = [ - _line_pixels( - start, - end, - width=width, - height=height, - width_px=width_px, - ) - for start, end in lines - ] + x, y = _line_pixels(lines, width=width, height=height, width_px=width_px) _paint_pixels( layer, - np.concatenate([points[0] for points in pixels]), - np.concatenate([points[1] for points in pixels]), - color, - alpha, + x, + y, + style, ) def _paint_marker_anchor( - layer: Tensor, + layer: np.ndarray, anchor: tuple[float, float], width: int, height: int, - color: tuple[float, float, float], + style: int, ) -> None: center_x, center_y = int(round(anchor[0])), int(round(anchor[1])) x, y = _disk_pixels(center_x, center_y, 9, width, height) - _paint_pixels(layer, x, y, color, 1.0) + _paint_pixels(layer, x, y, style) x, y = _ring_pixels(center_x, center_y, 6, 9, width, height) - _paint_pixels(layer, x, y, _WHITE, 1.0) + _paint_pixels(layer, x, y, _WHITE_SOLID) def _paint_marker_label( - layer: Tensor, + layer: np.ndarray, top: tuple[float, float], label: str, width: int, height: int, - color: tuple[float, float, float], + style: int, ) -> None: scale = max(1, min(width, height) // 360) text_x, text_y = _text_pixels(label, scale) @@ -268,7 +296,7 @@ def _paint_marker_label( right = text_left + text_width + 4 * scale bottom = text_top + text_height + 3 * scale x, y = _rectangle_pixels(left, top_y, right, bottom, width, height) - _paint_pixels(layer, x, y, _LABEL_BACKGROUND, 225.0 / 255.0) + _paint_pixels(layer, x, y, _LABEL_PANEL) border = max(1, scale) border_lines = ( ((left, top_y), (right, top_y)), @@ -276,60 +304,74 @@ def _paint_marker_label( ((right, bottom), (left, bottom)), ((left, bottom), (left, top_y)), ) - _paint_lines(layer, border_lines, width, height, border, color, 1.0) - _paint_pixels(layer, text_x + text_left, text_y + text_top, color, 1.0) + _paint_lines(layer, border_lines, width, height, border, style) + _paint_pixels( + layer, + text_x + text_left, + text_y + text_top, + style, + clip=True, + ) def _paint_pixels( - layer: Tensor, + layer: np.ndarray, x: np.ndarray, y: np.ndarray, - color: tuple[float, float, float], - alpha: float, + style: int, + *, + clip: bool = False, ) -> None: - height, width = int(layer.shape[-2]), int(layer.shape[-1]) - valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) - x = x[valid] - y = y[valid] + if clip: + height, width = layer.shape + valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) + x = x[valid] + y = y[valid] if x.size == 0: return - x_tensor = torch.from_numpy(x).to(device=layer.device) - y_tensor = torch.from_numpy(y).to(device=layer.device) - layer[:3, y_tensor, x_tensor] = torch.tensor( - color, - device=layer.device, - dtype=layer.dtype, - ).view(3, 1) - layer[3, y_tensor, x_tensor] = alpha + layer[y, x] = style def _line_pixels( - start: tuple[float, float], - end: tuple[float, float], + lines: Sequence[tuple[tuple[float, float], tuple[float, float]]], *, width: int, height: int, width_px: int, ) -> tuple[np.ndarray, np.ndarray]: - delta_x = float(end[0] - start[0]) - delta_y = float(end[1] - start[1]) - sample_count = max(2, int(math.ceil(max(abs(delta_x), abs(delta_y)))) + 1) - center_x = np.rint(np.linspace(start[0], end[0], sample_count)).astype(np.int32) - center_y = np.rint(np.linspace(start[1], end[1], sample_count)).astype(np.int32) + endpoints = np.asarray(lines, dtype=np.float64) + starts = endpoints[:, 0] + deltas = endpoints[:, 1] - starts + sample_counts = np.maximum( + 2, + np.ceil(np.max(np.abs(deltas), axis=1)).astype(np.int32) + 1, + ) + sample_index = np.arange(int(sample_counts.max()), dtype=np.float64)[None] + fraction = sample_index / (sample_counts[:, None] - 1) + selected_samples = sample_index < sample_counts[:, None] + center_x = np.rint(starts[:, 0, None] + deltas[:, 0, None] * fraction).astype( + np.int32 + ) + center_y = np.rint(starts[:, 1, None] + deltas[:, 1, None] * fraction).astype( + np.int32 + ) + offset_x, offset_y = _line_offsets(width_px) + x = center_x[:, :, None] + offset_x + y = center_y[:, :, None] + offset_y + valid = ( + selected_samples[:, :, None] & (x >= 0) & (x < width) & (y >= 0) & (y < height) + ) + return x[valid], y[valid] + + +@lru_cache(maxsize=None) +def _line_offsets(width_px: int) -> tuple[np.ndarray, np.ndarray]: radius = max(0.0, (float(width_px) - 1.0) / 2.0) - line_x = [] - line_y = [] integer_radius = int(math.ceil(radius)) - for offset_y in range(-integer_radius, integer_radius + 1): - for offset_x in range(-integer_radius, integer_radius + 1): - if offset_x * offset_x + offset_y * offset_y > radius * radius: - continue - x = center_x + offset_x - y = center_y + offset_y - valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) - line_x.append(x[valid]) - line_y.append(y[valid]) - return np.concatenate(line_x), np.concatenate(line_y) + offsets = np.arange(-integer_radius, integer_radius + 1, dtype=np.int32) + offset_x, offset_y = np.meshgrid(offsets, offsets) + selected = offset_x * offset_x + offset_y * offset_y <= radius * radius + return offset_x[selected], offset_y[selected] def _disk_pixels( @@ -339,13 +381,19 @@ def _disk_pixels( width: int, height: int, ) -> tuple[np.ndarray, np.ndarray]: + offset_x, offset_y = _disk_offsets(radius) + x = center_x + offset_x + y = center_y + offset_y + valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) + return x[valid], y[valid] + + +@lru_cache(maxsize=None) +def _disk_offsets(radius: int) -> tuple[np.ndarray, np.ndarray]: offsets = np.arange(-radius, radius + 1, dtype=np.int32) offset_x, offset_y = np.meshgrid(offsets, offsets) selected = offset_x * offset_x + offset_y * offset_y <= radius * radius - x = center_x + offset_x[selected] - y = center_y + offset_y[selected] - valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) - return x[valid], y[valid] + return offset_x[selected], offset_y[selected] def _ring_pixels( @@ -355,6 +403,18 @@ def _ring_pixels( outer_radius: int, width: int, height: int, +) -> tuple[np.ndarray, np.ndarray]: + offset_x, offset_y = _ring_offsets(inner_radius, outer_radius) + x = center_x + offset_x + y = center_y + offset_y + valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) + return x[valid], y[valid] + + +@lru_cache(maxsize=None) +def _ring_offsets( + inner_radius: int, + outer_radius: int, ) -> tuple[np.ndarray, np.ndarray]: offsets = np.arange(-outer_radius, outer_radius + 1, dtype=np.int32) offset_x, offset_y = np.meshgrid(offsets, offsets) @@ -362,10 +422,7 @@ def _ring_pixels( selected = (distance_squared >= inner_radius * inner_radius) & ( distance_squared <= outer_radius * outer_radius ) - x = center_x + offset_x[selected] - y = center_y + offset_y[selected] - valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) - return x[valid], y[valid] + return offset_x[selected], offset_y[selected] def _rectangle_pixels( @@ -389,6 +446,7 @@ def _rectangle_pixels( return x.ravel(), y.ravel() +@lru_cache(maxsize=None) def _text_pixels(text: str, scale: int) -> tuple[np.ndarray, np.ndarray]: x_values = [] y_values = [] @@ -407,4 +465,24 @@ def _text_pixels(text: str, scale: int) -> tuple[np.ndarray, np.ndarray]: return np.asarray(x_values, dtype=np.int32), np.asarray(y_values, dtype=np.int32) +def _materialize_waypoint_layers( + styles: np.ndarray, + *, + device: torch.device | str, + dtype: torch.dtype, +) -> Tensor: + style_tensor = torch.from_numpy(styles).to(device=device) + transparent = torch.zeros((1, 4), device=device, dtype=dtype) + palette = torch.cat( + ( + transparent, + torch.as_tensor(_WAYPOINT_PALETTE, device=device, dtype=dtype), + ) + ) + # A GPU nonzero() has a data-dependent output size and therefore waits for + # all earlier work on the stream before returning to Python. Dense palette + # lookup keeps materialization asynchronous; style -1 selects transparent. + return palette[style_tensor.to(torch.int64) + 1].permute(0, 3, 1, 2).contiguous() + + __all__ = ["render_bev_overlay", "render_waypoint_layers"] diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 85dd56487..8487148a7 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -5,12 +5,15 @@ from dataclasses import replace from pathlib import Path +from unittest.mock import patch import numpy as np import pytest -from crazy_robotaxi.application import CrazyRobotaxiApplication +from crazy_robotaxi.application import _MODEL_PRESETS, CrazyRobotaxiApplication +from crazy_robotaxi.physics import TaxiPhysicsWorld from crazy_robotaxi.session import CrazyRobotaxiModelLoop from crazy_robotaxi.ui import CrazyRobotaxiSlangPyUILoop +from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld from omnidreams_game_engine.types import CameraCalibration, SceneDefinition from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -71,6 +74,48 @@ def test_application_registers_model_and_slangpy_ui_loops() -> None: assert ui_loop.state.model_loop is model_loop +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + ([], False), + (["--profile-pipeline"], True), + ], +) +def test_pipeline_profiling_is_an_app_local_opt_in( + arguments: list[str], + expected: bool, +) -> None: + configured = [] + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: configured.append(config) or object(), + scene_factory=lambda request, raster: _scene(), + ) + app.init(arguments) + + app.create_session(app.session_desc()) + + assert configured[0].enable_sync_and_profile is expected + assert app._config is not None + assert app._config.pipeline_profiling is expected + assert _MODEL_PRESETS["standard"].enable_sync_and_profile + + +def test_taxi_physics_uses_spatial_and_traffic_topology_refreshes_only() -> None: + world = object.__new__(TaxiPhysicsWorld) + world.graph = type("Graph", (), {"objects": ()})() + world._physics_center_xy = np.zeros(2, dtype=np.float32) + center = np.asarray([40.0, -4.0], dtype=np.float32) + with patch.object( + GamePhysicsWorld, + "synchronize_window", + return_value=True, + ) as synchronize: + changed = world.synchronize_window(center, timestamp_us=2_000_000) + + assert changed + synchronize.assert_called_once_with(center, timestamp_us=None) + + def test_application_rejects_geometry_the_model_does_not_produce() -> None: app = CrazyRobotaxiApplication( pipeline_factory=lambda config, device: object(), diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 566820d96..f417e73cf 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -5,6 +5,7 @@ from __future__ import annotations +import math import queue import threading from dataclasses import dataclass, field, replace @@ -25,7 +26,11 @@ TaxiHudState, build_hud_frames, ) -from crazy_robotaxi.world_overlay import render_bev_overlay, render_waypoint_layers +from crazy_robotaxi.world_overlay import ( + _paint_lines, + render_bev_overlay, + render_waypoint_layers, +) from omnidreams_game_engine.types import CameraCalibration from flashdreams.api_v2.loop import IModelLoop @@ -67,6 +72,35 @@ def _snapshot(*, session_state: TaxiSessionState = "playing") -> TaxiGameSnapsho ) +def _legacy_line_pixels( + start: tuple[float, float], + end: tuple[float, float], + *, + width: int, + height: int, + width_px: int, +) -> tuple[np.ndarray, np.ndarray]: + delta_x = float(end[0] - start[0]) + delta_y = float(end[1] - start[1]) + sample_count = max(2, int(math.ceil(max(abs(delta_x), abs(delta_y)))) + 1) + center_x = np.rint(np.linspace(start[0], end[0], sample_count)).astype(np.int32) + center_y = np.rint(np.linspace(start[1], end[1], sample_count)).astype(np.int32) + radius = max(0.0, (float(width_px) - 1.0) / 2.0) + x_parts = [] + y_parts = [] + integer_radius = int(math.ceil(radius)) + for offset_y in range(-integer_radius, integer_radius + 1): + for offset_x in range(-integer_radius, integer_radius + 1): + if offset_x * offset_x + offset_y * offset_y > radius * radius: + continue + x = center_x + offset_x + y = center_y + offset_y + selected = (x >= 0) & (x < width) & (y >= 0) & (y < height) + x_parts.append(x[selected]) + y_parts.append(y[selected]) + return np.concatenate(x_parts), np.concatenate(y_parts) + + class _Widget: def __init__( self, @@ -141,13 +175,16 @@ def reset(self) -> None: def test_hud_frames_are_immutable_messages_keyed_to_video_storage() -> None: video = torch.zeros(2, 3, 96, 160) snapshots = (_snapshot(), _snapshot()) + poses = np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0) - frames = build_hud_frames(video, snapshots) + frames = build_hud_frames(video, snapshots, poses) assert [frame.frame_key for frame in frames] == [ video[index].data_ptr() for index in range(2) ] assert frames[0].snapshot is snapshots[0] + np.testing.assert_array_equal(frames[0].rig_pose_world, poses[0]) + assert not frames[0].rig_pose_world.flags.writeable def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: @@ -177,7 +214,25 @@ def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: assert torch.count_nonzero(terminal[:, 3]) == 0 -def test_pickup_waypoint_projection_batches_anchors_before_rendering_rings() -> None: +def test_batched_line_rasterizer_matches_the_original_geometry() -> None: + lines = ( + ((-4.25, 10.5), (35.75, 22.5)), + ((30.5, -8.0), (2.5, 28.0)), + ((5.0, 5.0), (5.0, 5.0)), + ((18.25, 29.75), (45.5, 2.25)), + ) + expected = np.full((32, 40), -1, dtype=np.int8) + for start, end in lines: + x, y = _legacy_line_pixels(start, end, width=40, height=32, width_px=7) + expected[y, x] = 3 + actual = np.full_like(expected, -1) + + _paint_lines(actual, lines, 40, 32, 7, 3) + + np.testing.assert_array_equal(actual, expected) + + +def test_pickup_waypoint_projection_batches_anchors_and_ring_geometry() -> None: class RecordingCamera: def __init__(self) -> None: self.point_counts: list[int] = [] @@ -214,7 +269,7 @@ def project_world(self, points, rig_to_world): image_height=96, ) - assert camera.point_counts == [6, 34, 34, 34] + assert camera.point_counts == [6, 102] assert [projection.distance_m for projection in projections] == [10.0, 20.0, 30.0] @@ -236,22 +291,24 @@ def test_bev_is_rendered_as_frame_aligned_rgba_overlay() -> None: def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) - waypoints = torch.zeros(1, 4, height, width) - waypoints[:, 0, 10, 10] = 1.0 - waypoints[:, 3, 10, 10] = 1.0 bev_overlay = render_bev_overlay( torch.full((1, 3, 32, 32), 0.5), width=width, height=height, ) - hud_state = TaxiHudState(width, height) - hud_state.publish(build_hud_frames(video, (_snapshot(),))) + hud_state = TaxiHudState(width, height, _calibration()) + hud_state.publish( + build_hud_frames( + video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + ) + ) presentation = PresentationManager() presentation.publish( 0, [ StepResult(0, video, 1, VideoTensorLayout.tchw), - StepResult(0, waypoints, 1, VideoTensorLayout.tchw), StepResult(0, bev_overlay, 1, VideoTensorLayout.tchw), ], ) @@ -281,21 +338,27 @@ def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: assert hud_state._widgets.score.text == "SCORE 001200 HIGH 009000" assert hud_state._widgets.navigation.text == "TARGET AHEAD 0 deg" assert not hasattr(hud_state._widgets, "marker_windows") - assert result.output[0, 0, 10, 10] == 1.0 + assert result.output[0, 0, 48, 80] != -0.5 assert result.output[0, 0, 58, 122] == pytest.approx(0.32) assert torch.any(result.output != video) + cached_waypoints = hud_state._waypoint_layer + loop.step(1, UserInputEvents([])) + assert hud_state._waypoint_layer is cached_waypoints + loop.reset() assert hud_state._current is None + assert hud_state._waypoint_layer is None assert renderer.reset_count == 1 def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: - state = TaxiHudState(160, 96) + state = TaxiHudState(160, 96, _calibration()) state.publish( build_hud_frames( torch.zeros(1, 3, 96, 160), (_snapshot(),), + np.eye(4, dtype=np.float32)[None], ) ) state._current = next(iter(state._frames.values())) @@ -317,7 +380,7 @@ def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: - state = TaxiHudState(160, 96) + state = TaxiHudState(160, 96, _calibration()) model_loop = _SubmissionLoop() model_loop.register_session_loop_objects( state=_SubmissionState(), diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index ceafab2c5..00b3e7ff3 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -49,8 +49,8 @@ flowchart TD end subgraph UITHREAD["FlashDreams V2 UI thread"] - BUFFER["PresentationManager
RGB + RGBA waypoint/BEV channels"] - HUD["CrazyRobotaxiSlangPyUILoop
HUD widgets + name entry"] + BUFFER["PresentationManager
RGB + optional RGBA BEV channels"] + HUD["CrazyRobotaxiSlangPyUILoop
waypoint cache + HUD widgets"] COMPOSE["RGB → waypoint → BEV composition
then SlangPy ImGui HUD"] WINDOW["V2 client window
WebRTC / MP4 / supported host"] end @@ -63,7 +63,7 @@ flowchart TD PIPELINE --> CACHE INPUT --> ENGINE ENGINE --> HUDSTATE - ENGINE -->|"RGB + RGBA waypoint + optional RGBA BEV"| BUFFER + ENGINE -->|"RGB + optional RGBA BEV"| BUFFER HUDSTATE -->|"invoke_async"| HUD HUD -->|"invoke_async game actions"| ENGINE BUFFER --> HUD --> COMPOSE --> WINDOW @@ -76,17 +76,17 @@ description, lazily constructs the shared pipeline, prepares immutable scene data, and returns an uninitialized session. The session registers a model loop and a `CrazyRobotaxiSlangPyUILoop`. The -model loop emits generated RGB frames, a transparent camera-projected -waypoint layer, and, when configured, a frame-aligned transparent BEV layer. -For each generated frame it also publishes an immutable `TaxiHudFrame` to the -UI loop with `invoke_async`. -The UI loop selects the HUD snapshot aligned with the currently presented -model frame and uses V2's `PresentationManager` to compose the in-world -waypoint geometry and BEV layer over the generated frame. It updates its -retained ImGui widgets and returns the composed frame to `SlangPyUILoop`, -which applies the widget overlay. UI callbacks send validated game actions -back to the model loop with `invoke_async`; they never call game state -directly. +model loop emits generated RGB frames and, when configured, a frame-aligned +transparent BEV layer. For each generated frame it publishes an immutable +`TaxiHudFrame` containing its game snapshot and camera pose to the UI loop with +`invoke_async`. The UI loop selects the metadata aligned with the currently +presented model frame, rasterizes and caches that frame's in-world waypoint +geometry, and uses V2's `PresentationManager` to compose waypoints and BEV over +the generated frame. This avoids constructing an entire chunk of full-size +waypoint tensors at the model-step boundary. It updates its retained ImGui +widgets and returns the composed frame to `SlangPyUILoop`, which applies the +widget overlay. UI callbacks send validated game actions back to the model loop +with `invoke_async`; they never call game state directly. ## Model-loop step @@ -105,11 +105,10 @@ flowchart LR GENERATE["pipeline.generate"] FINALIZE["pipeline.finalize"] POST["optional session-local postprocess stream"] - WAYPOINTS["camera-projected RGBA
world-waypoint layers"] BEV["raw BEV frames → frame-aligned
RGBA presentation layers"] - HUDFRAMES["immutable TaxiHudFrame batch"] + HUDFRAMES["immutable TaxiHudFrame batch
snapshot + camera pose"] SEND["invoke_async(UI loop)"] - RESULTS["list[StepResult]
RGB + RGBA waypoint + optional RGBA BEV"] + RESULTS["list[StepResult]
RGB + optional RGBA BEV"] EVENTS --> REDUCE COUNT --> COMMANDS @@ -122,13 +121,11 @@ flowchart LR ACTORS --> CONDITION SIM --> CONDITION CONDITION --> TENSOR --> GENERATE --> FINALIZE --> POST - SNAPSHOTS --> WAYPOINTS - SIM --> WAYPOINTS CONDITION --> BEV SNAPSHOTS --> HUDFRAMES + SIM --> HUDFRAMES HUDFRAMES --> SEND POST --> RESULTS - WAYPOINTS --> RESULTS BEV --> RESULTS ``` @@ -149,20 +146,19 @@ trajectory = engine_step.trajectory hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] video = pipeline.generate(autoregressive_index, cache, hdmap) metrics = pipeline.finalize(autoregressive_index, cache) -waypoints = render_waypoint_layers(game_snapshots, poses, camera) bev_overlay = render_bev_overlay(condition.bev_tchw, output_size) -invoke_async(ui_loop, publish_hud_frames(game_snapshots, video)) -return [video StepResult, waypoints StepResult, optional bev_overlay StepResult] +invoke_async(ui_loop, publish_hud_frames(game_snapshots, poses, video)) +return [video StepResult, optional bev_overlay StepResult] ``` The UI loop uses V2's `SlangPyUILoop` and `presented_model_frames()`; -its `step_ui()` method composites the frame-aligned model-result layers with -`PresentationManager.composite()`, then the base loop composites the SlangPy -widget overlay over that back buffer. Raw BEV resizing therefore happens once -per generated chunk rather than once per UI tick. There is no image widget and -there is no intermediate render backend, local adapter, model session, chunk -request, private command queue, private frame queue, or legacy -`PresentedFrame` aggregate. +its `step_ui()` method renders a waypoint layer only when the presented frame +changes, composites the frame-aligned layers with +`PresentationManager.composite()`, then lets the base loop composite the +SlangPy widget overlay over that back buffer. Raw BEV resizing still happens +once per generated chunk. There is no image widget and there is no intermediate +render backend, local adapter, model session, chunk request, private command +queue, private frame queue, or legacy `PresentedFrame` aggregate. ## Game-engine internals @@ -236,7 +232,7 @@ flowchart LR FARES --> PASSENGERS FARES <--> SCORES FARES --> FRAME --> HUDSTATE --> IMGUI - FRAME --> WAYPOINTS + HUDSTATE --> WAYPOINTS PASSENGERS --> FRAME ``` @@ -278,9 +274,10 @@ stateDiagram-v2 ``` While awaiting a name, no new world-model block is generated. The model loop -re-emits the last generated RGB frame with a transparent waypoint layer and -publishes an updated immutable HUD frame. ImGui owns the editable name buffer -and submits a validated name to the model loop with `invoke_async`. The model +re-emits the last generated RGB frame and publishes an updated immutable HUD +frame; the UI loop replaces its cached waypoint layer from that metadata. +ImGui owns the editable name buffer and submits a validated name to the model +loop with `invoke_async`. The model loop reports `is_finished()` only after a leaderboard frame has been emitted (or an explicit finite block limit is reached), allowing MP4 mode to terminate without a client close event. @@ -327,7 +324,7 @@ apps/crazy_robotaxi/ application.py # IApplication composition root session.py # ISession + model/UI loop wiring ui.py # ImGui HUD state and SlangPy UI loop - world_overlay.py # frame-aligned waypoint and BEV RGBA layers + world_overlay.py # UI waypoint and frame-aligned BEV RGBA layers rules.py # taxi fare state machine dynamics.py # arcade taxi controls physics.py # taxi collision policy From 701921189e3b34e375e8ff2e64f7d529bda09202 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 26 Aug 2026 19:27:42 +0000 Subject: [PATCH 10/62] Prewarm Crazy Robotaxi before presentation Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 16 ++++ .../crazy_robotaxi/application.py | 17 ++++ apps/crazy_robotaxi/crazy_robotaxi/session.py | 61 ++++++++++++++- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 26 +++++-- apps/crazy_robotaxi/tests/test_application.py | 77 ++++++++++++++++++- apps/crazy_robotaxi/tests/test_ui.py | 20 +++++ docs/design/crazy_robotaxi_v2_architecture.md | 31 ++++++++ 7 files changed, 238 insertions(+), 10 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index f8a1f5da7..2af80e206 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -65,6 +65,22 @@ removes an avoidable pause source, but it does not make a model preset whose steady-state throughput is below 30 fps meet that rate; use `--model-preset perf` when its quality/performance tradeoff is appropriate. +All model presets generate four neutral, hidden blocks before publishing the +first gameplay frame. The responsive ImGui HUD shows the current warmup block, +an animated activity marker, and elapsed time while compilation and autotuning +run. After warmup, the app recreates simulation, rules, conditioning, and the +autoregressive cache, so warmup does not consume game time, move the taxi, +advance the visible AR index, or count toward `--total-blocks`. Cache-bound +CUDA graphs safely re-arm against the new gameplay cache, so shorter first-use +hitches can remain. This moves the multi-second pauses ahead of presentation; +it does not reduce total cold-start time. Disable it for comparisons or startup +debugging with: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ + --prewarm-blocks 0 +``` + ## Authored maps Maps are strict semantic `.robotaxi.yaml` documents. Validate or compile them diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index b7d65c7ab..0d18b6d2f 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -41,6 +41,9 @@ _VIDEO_FPS = 30 """Generation and UI cadence; each UI tick advances one generated frame.""" +_DEFAULT_PREWARM_BLOCKS = 4 +"""Blocks covering chunk2 cache filling and the first steady-state AR shape.""" + _MODEL_PRESETS = { "standard": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline, "perf": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline, @@ -59,6 +62,8 @@ class ApplicationConfig: device: str total_blocks: int | None pipeline_profiling: bool + prewarm_blocks: int + """Hidden neutral blocks generated before the first presented game frame.""" PipelineFactory = Callable[[Any, str], Any] @@ -99,6 +104,8 @@ def init(self, commandline_args: Sequence[str]) -> None: raise ValueError("--total-blocks must be positive") if args.game_time_s is not None and args.game_time_s <= 0.0: raise ValueError("--game-time-s must be positive") + if args.prewarm_blocks < 0: + raise ValueError("--prewarm-blocks must be non-negative") renderer = load_renderer_settings(args.renderer_config) settings = load_game_settings(args.game_config) game = settings.game @@ -146,6 +153,7 @@ def init(self, commandline_args: Sequence[str]) -> None: device=args.device, total_blocks=args.total_blocks, pipeline_profiling=bool(args.profile_pipeline), + prewarm_blocks=args.prewarm_blocks, ) def create_session(self, session_desc: SessionDesc) -> ISession: @@ -226,4 +234,13 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="synchronize each chunk and emit diagnostic GPU stage timings", ) + parser.add_argument( + "--prewarm-blocks", + type=int, + default=_DEFAULT_PREWARM_BLOCKS, + help=( + "generate hidden neutral blocks before presentation to compile and " + "autotune AR shapes (default: 4; 0 disables)" + ), + ) return parser diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index a20125da9..0c4132560 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -15,7 +15,7 @@ from omnidreams_game_engine.input import DriverInput from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state from omnidreams_game_engine.model import WorldModelRollout -from omnidreams_game_engine.types import SceneDefinition +from omnidreams_game_engine.types import DriverCommand, SceneDefinition from crazy_robotaxi.factory import build_taxi_engine from crazy_robotaxi.rules import TaxiGameSnapshot @@ -57,9 +57,14 @@ class ModelState: blocks_generated: int = 0 finished: bool = False realtime_miss_count: int = 0 + prewarm_complete: bool = False + """Whether startup AR-shape warmup has completed for this session.""" + + prewarm_wall_ms: float = 0.0 + """Wall time spent in hidden startup generation, excluding rollout creation.""" def ensure_rollout(self) -> WorldModelRollout: - """Build renderer, PhysX, game, and cache on the model thread.""" + """Build and prewarm renderer, PhysX, game, and cache on the model thread.""" if self.rollout is None: frame_interval_s = 1.0 / self.session_desc.frames_per_second_for_step self.rollout = WorldModelRollout( @@ -74,8 +79,53 @@ def ensure_rollout(self) -> WorldModelRollout: device=self.config.device, ), ) + if not self.prewarm_complete: + self._prewarm_rollout() return self.rollout + def _prewarm_rollout(self) -> None: + rollout = self.rollout + assert rollout is not None + block_count = self.config.prewarm_blocks + if block_count == 0: + self.prewarm_complete = True + return + + started = time.perf_counter() + _LOGGER.info( + "[crazy-robotaxi] prewarming %d hidden AR blocks before presentation", + block_count, + ) + for autoregressive_index in range(block_count): + current_block = autoregressive_index + 1 + self._set_loading_status( + f"WARMING WORLD MODEL {current_block}/{block_count}" + ) + frame_count = rollout.frame_count(autoregressive_index) + generated = rollout.step( + autoregressive_index=autoregressive_index, + commands=tuple(DriverCommand() for _ in range(frame_count)), + ) + del generated + + # Retain process-lifetime compiled kernels and autotune results, but + # discard every gameplay, conditioning, and AR-cache mutation. Cache- + # bound CUDA graphs re-arm safely against the new storage. + rollout.reset() + self.prewarm_wall_ms = (time.perf_counter() - started) * 1000.0 + self.prewarm_complete = True + self._set_loading_status("STARTING GAME") + _LOGGER.info( + "[crazy-robotaxi] prewarm complete in %.1f s; rollout reset for gameplay", + self.prewarm_wall_ms / 1000.0, + ) + + def _set_loading_status(self, status: str) -> None: + invoke_async( + self.ui_loop, + lambda ui_state, value=status: ui_state.set_loading_status(value), + ) + def reset(self) -> None: self.driver_input.reset() self.blocks_generated = 0 @@ -103,10 +153,10 @@ class CrazyRobotaxiModelLoop(IModelLoop[ModelState]): """Run simulation, rules, conditioning, and generation in one V2 step.""" def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: - step_wall_started = time.perf_counter() - step_cpu_started = time.thread_time() state = self.state rollout = state.ensure_rollout() + step_wall_started = time.perf_counter() + step_cpu_started = time.thread_time() snapshot = rollout.engine.current_game_frame if not isinstance(snapshot, TaxiGameSnapshot): raise TypeError("Taxi engine returned a non-taxi game frame") @@ -149,6 +199,9 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ) bev_overlay_cpu_ms = (time.thread_time() - bev_cpu_started) * 1000.0 metrics = dict(generated.metrics) + if state.blocks_generated == 1 and state.prewarm_wall_ms > 0.0: + metrics["startup_prewarm_wall_ms"] = state.prewarm_wall_ms + metrics["startup_prewarm_blocks"] = state.config.prewarm_blocks state.last_video = video[-1:].detach() state.last_bev_overlay = ( None if bev_overlay is None else bev_overlay[-1:].detach() diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 4c493f01b..67f84799d 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -18,6 +18,7 @@ from __future__ import annotations import math +import time from collections import OrderedDict from collections.abc import Sequence from dataclasses import dataclass, field @@ -113,6 +114,12 @@ class TaxiHudState: _submission_pending: bool = False """Whether a validated name is already queued for the model thread.""" + _loading_status: str = "LOADING WORLD MODEL" + """Current startup phase shown until the first model frame is presented.""" + + _loading_started_at_s: float = field(default_factory=time.monotonic) + """Monotonic timestamp used to make startup progress visibly live.""" + def publish(self, frames: Sequence[TaxiHudFrame]) -> None: """Publish immutable model-frame state to the UI-owned lookup.""" for frame in frames: @@ -135,6 +142,10 @@ def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: self._current = selected return self._current + def set_loading_status(self, status: str) -> None: + """Update the startup phase from a model-loop message.""" + self._loading_status = status + def waypoint_layer(self, frame: Tensor) -> Tensor | None: """Return the cached marker layer aligned with ``frame``. @@ -166,13 +177,16 @@ def waypoint_layer(self, frame: Tensor) -> Tensor | None: self._waypoint_layer = layer return layer - def draw(self, ui: Any) -> None: + def draw(self, ui: Any, ui_tick: int = 0) -> None: """Create or update the retained ImGui HUD widget tree.""" widgets = self._ensure_widgets(ui) hud_frame = self._current if hud_frame is None: - widgets.score.text = "LOADING WORLD MODEL" - widgets.time.text = "" + dots = "." * (1 + (ui_tick // 15) % 3) + elapsed_s = max(0, int(time.monotonic() - self._loading_started_at_s)) + widgets.status_window.visible = True + widgets.score.text = f"{self._loading_status}{dots}" + widgets.time.text = f"ELAPSED {elapsed_s}s" widgets.objective.text = "" widgets.fare_time.text = "" widgets.event.text = "" @@ -205,6 +219,8 @@ def reset(self) -> None: self._waypoint_layer = None self._validation_message = "" self._submission_pending = False + self._loading_status = "LOADING WORLD MODEL" + self._loading_started_at_s = time.monotonic() if self._widgets is not None: self._widgets.name_input.value = "" self._widgets.name_input.enabled = True @@ -345,13 +361,13 @@ def step_ui( self, ui: Any, step_index: int, events: UserInputEvents ) -> Tensor | None: """Draw the HUD and return the current generated frame beneath it.""" - del step_index, events + del events frames = self.presented_model_frames() video = frames[0] if frames else None bev_overlay = frames[1] if len(frames) > 1 else None if video is not None: self.state.select_presented_frame(video) - self.state.draw(ui) + self.state.draw(ui, step_index) if video is None: return None world = video.to(torch.float32) diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 8487148a7..322b31255 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -14,7 +14,11 @@ from crazy_robotaxi.session import CrazyRobotaxiModelLoop from crazy_robotaxi.ui import CrazyRobotaxiSlangPyUILoop from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld -from omnidreams_game_engine.types import CameraCalibration, SceneDefinition +from omnidreams_game_engine.types import ( + CameraCalibration, + DriverCommand, + SceneDefinition, +) from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -100,6 +104,77 @@ def test_pipeline_profiling_is_an_app_local_opt_in( assert _MODEL_PRESETS["standard"].enable_sync_and_profile +@pytest.mark.parametrize("prewarm_blocks", [0, 4, 7]) +def test_application_configures_prepresentation_warmup(prewarm_blocks: int) -> None: + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: object(), + scene_factory=lambda request, raster: _scene(), + ) + app.init(["--prewarm-blocks", str(prewarm_blocks)]) + + assert app._config is not None + assert app._config.prewarm_blocks == prewarm_blocks + + +def test_application_rejects_negative_prewarm_blocks() -> None: + app = CrazyRobotaxiApplication() + + with pytest.raises(ValueError, match="must be non-negative"): + app.init(["--prewarm-blocks", "-1"]) + + +def test_model_state_prewarms_neutral_blocks_once_then_resets(monkeypatch) -> None: + class FakeRollout: + def __init__(self, **kwargs) -> None: + del kwargs + self.steps: list[tuple[int, tuple[DriverCommand, ...]]] = [] + self.reset_count = 0 + + def frame_count(self, autoregressive_index: int) -> int: + return autoregressive_index + 1 + + def step(self, *, autoregressive_index: int, commands): + self.steps.append((autoregressive_index, commands)) + return object() + + def reset(self) -> None: + self.reset_count += 1 + + def close(self) -> None: + return + + monkeypatch.setattr("crazy_robotaxi.session.WorldModelRollout", FakeRollout) + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: object(), + scene_factory=lambda request, raster: _scene(), + ) + app.init(["--device", "cpu", "--prewarm-blocks", "4"]) + session = app.create_session(app.session_desc()) + session.init() + ui_loop, model_loop = session._take_loops() + + rollout = model_loop.state.ensure_rollout() + ui_loop._run_message_batch() + + assert [index for index, _ in rollout.steps] == [0, 1, 2, 3] + assert [len(commands) for _, commands in rollout.steps] == [1, 2, 3, 4] + assert all( + command == DriverCommand() + for _, commands in rollout.steps + for command in commands + ) + assert rollout.reset_count == 1 + assert model_loop.state.blocks_generated == 0 + assert model_loop.state.prewarm_complete + assert ui_loop.state._loading_status == "STARTING GAME" + + assert model_loop.state.ensure_rollout() is rollout + assert len(rollout.steps) == 4 + model_loop.state.reset() + assert rollout.reset_count == 2 + assert len(rollout.steps) == 4 + + def test_taxi_physics_uses_spatial_and_traffic_topology_refreshes_only() -> None: world = object.__new__(TaxiPhysicsWorld) world.graph = type("Graph", (), {"objects": ()})() diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index f417e73cf..e7ce457d2 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -379,6 +379,26 @@ def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: assert state._widgets.score.text == "SCORE 001200 HIGH 009000" +def test_hud_animates_prepresentation_warmup_status() -> None: + state = TaxiHudState(160, 96, _calibration()) + state.set_loading_status("WARMING WORLD MODEL 2/4") + ui = SimpleNamespace( + screen=None, + Window=spy.ui.Window, + Text=spy.ui.Text, + InputText=spy.ui.InputText, + Button=spy.ui.Button, + InputTextFlags=spy.ui.InputTextFlags, + ) + + state.draw(ui, ui_tick=30) + + assert state._widgets is not None + assert state._widgets.status_window.visible + assert state._widgets.score.text == "WARMING WORLD MODEL 2/4..." + assert state._widgets.time.text.startswith("ELAPSED ") + + def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: state = TaxiHudState(160, 96, _calibration()) model_loop = _SubmissionLoop() diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 00b3e7ff3..56d8f16f0 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -88,6 +88,37 @@ widgets and returns the composed frame to `SlangPyUILoop`, which applies the widget overlay. UI callbacks send validated game actions back to the model loop with `invoke_async`; they never call game state directly. +## Pre-presentation startup + +```mermaid +sequenceDiagram + participant UI as V2 UI thread + participant Model as V2 model thread + participant Rollout as WorldModelRollout + + UI->>UI: Render animated loading HUD + Model->>Rollout: Construct engine and cache + loop AR shapes 0..3 + Model-->>UI: invoke_async(warmup phase) + Model->>Rollout: Neutral hidden step + Rollout->>Rollout: Compile / autotune first-use shape + end + Model->>Rollout: reset engine + cache + Model-->>UI: invoke_async(starting game) + Model->>Rollout: First gameplay step at AR index 0 + Rollout-->>UI: First presentable RGB + metadata +``` + +The default four hidden blocks cover the initial, cache-filling, and first +steady-state shapes of the chunk2/window6 model. They run on the model thread +while the independently paced UI thread continues rendering loading progress. +Process-lifetime compiled kernels and autotune results remain available after +warmup. Resetting the rollout before gameplay discards warmup simulation, game +rules, conditioning, and autoregressive-cache state. CUDA graphs whose kernels +reference that cache are safely re-armed against the fresh gameplay cache; +they are never replayed against stale warmup storage. `--prewarm-blocks 0` +preserves a direct cold-start path for diagnosis and comparison. + ## Model-loop step ```mermaid From 9feb31b539c2b99d9e82e9b507580c0cc9a8e36f Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 26 Aug 2026 20:43:09 +0000 Subject: [PATCH 11/62] Fix Crazy Robotaxi input latency handling Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 18 ++ .../crazy_robotaxi/application.py | 9 + apps/crazy_robotaxi/crazy_robotaxi/session.py | 39 +++- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 175 +++++++++++++++++- apps/crazy_robotaxi/tests/test_application.py | 30 ++- apps/crazy_robotaxi/tests/test_ui.py | 100 ++++++++++ .../omnidreams_game_engine/input.py | 155 ++++++++++++++-- .../tests/test_input.py | 81 ++++++++ docs/design/crazy_robotaxi_v2_architecture.md | 12 +- .../design/crazy_robotaxi_v2_input_latency.md | 90 +++++++++ 10 files changed, 682 insertions(+), 27 deletions(-) create mode 100644 docs/design/crazy_robotaxi_v2_input_latency.md diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 2af80e206..404c36c81 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -65,6 +65,24 @@ removes an avoidable pause source, but it does not make a model preset whose steady-state throughput is below 30 fps meet that rate; use `--model-preset perf` when its quality/performance tradeoff is appropriate. +Profile interactive input separately with: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ + --model-preset perf \ + --profile-input-latency +``` + +This opt-in adds a UI-thread key indicator and logs the time from V2 UI event +receipt to the first presented model frame carrying that transition. It also +shows resolved, redundant, and capacity-dropped transition counts. The normal +HUD does not construct these widgets when the flag is absent. The indicator's +physical-key-to-browser delay still includes WebRTC transport latency, while +the reported `UI TO MODEL FRAME` value isolates the synchronous app/model +portion. See the +[V2 latency handoff](../../docs/design/crazy_robotaxi_v2_input_latency.md) for +the known runtime boundary. + All model presets generate four neutral, hidden blocks before publishing the first gameplay frame. The responsive ImGui HUD shows the current warmup block, an animated activity marker, and elapsed time while compilation and autotuning diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 0d18b6d2f..12e9428ea 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -65,6 +65,9 @@ class ApplicationConfig: prewarm_blocks: int """Hidden neutral blocks generated before the first presented game frame.""" + profile_input_latency: bool + """Whether the UI displays and logs input-to-model-frame diagnostics.""" + PipelineFactory = Callable[[Any, str], Any] SceneFactory = Callable[[SceneRequest, Any], SceneDefinition] @@ -154,6 +157,7 @@ def init(self, commandline_args: Sequence[str]) -> None: total_blocks=args.total_blocks, pipeline_profiling=bool(args.profile_pipeline), prewarm_blocks=args.prewarm_blocks, + profile_input_latency=bool(args.profile_input_latency), ) def create_session(self, session_desc: SessionDesc) -> ISession: @@ -243,4 +247,9 @@ def _parser() -> argparse.ArgumentParser: "autotune AR shapes (default: 4; 0 disables)" ), ) + parser.add_argument( + "--profile-input-latency", + action="store_true", + help="show and log UI-input-to-model-frame latency diagnostics", + ) return parser diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 0c4132560..2fcb51036 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -63,6 +63,15 @@ class ModelState: prewarm_wall_ms: float = 0.0 """Wall time spent in hidden startup generation, excluding rollout creation.""" + input_transition_count: int = 0 + """Cumulative resolved drive transitions consumed by model steps.""" + + input_ignored_event_count: int = 0 + """Cumulative redundant drive events consumed by model steps.""" + + input_dropped_transition_count: int = 0 + """Cumulative transitions displaced by fixed-size model chunks.""" + def ensure_rollout(self) -> WorldModelRollout: """Build and prewarm renderer, PhysX, game, and cache on the model thread.""" if self.rollout is None: @@ -134,6 +143,9 @@ def reset(self) -> None: self.last_video = None self.last_bev_overlay = None self.last_pose = None + self.input_transition_count = 0 + self.input_ignored_event_count = 0 + self.input_dropped_transition_count = 0 if self.rollout is not None: self.rollout.reset() @@ -167,6 +179,9 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: frame_count=frame_count, frame_interval_s=(1.0 / state.session_desc.frames_per_second_for_step), ) + state.input_transition_count += input_batch.transition_count + state.input_ignored_event_count += input_batch.ignored_event_count + state.input_dropped_transition_count += input_batch.dropped_transition_count generated = rollout.step( autoregressive_index=state.blocks_generated, commands=input_batch.commands, @@ -199,6 +214,16 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ) bev_overlay_cpu_ms = (time.thread_time() - bev_cpu_started) * 1000.0 metrics = dict(generated.metrics) + metrics.update( + { + "input_transition_count": input_batch.transition_count, + "input_ignored_event_count": input_batch.ignored_event_count, + "input_dropped_transition_count": ( + input_batch.dropped_transition_count + ), + } + ) + transition_timestamps_us = input_batch.transition_timestamps_us if state.blocks_generated == 1 and state.prewarm_wall_ms > 0.0: metrics["startup_prewarm_wall_ms"] = state.prewarm_wall_ms metrics["startup_prewarm_blocks"] = state.config.prewarm_blocks @@ -216,8 +241,17 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: bev_overlay = state.last_bev_overlay metrics = {} bev_overlay_cpu_ms = 0.0 - - hud_frames = build_hud_frames(video, game_frames, poses) + transition_timestamps_us = (None,) * int(video.shape[0]) + + hud_frames = build_hud_frames( + video, + game_frames, + poses, + transition_timestamps_us=transition_timestamps_us, + input_transition_count=state.input_transition_count, + input_ignored_event_count=state.input_ignored_event_count, + input_dropped_transition_count=state.input_dropped_transition_count, + ) invoke_async( state.ui_loop, lambda ui_state, frames=hud_frames: ui_state.publish(frames), @@ -335,6 +369,7 @@ def init(self) -> None: width=self._session_desc.video_width, height=self._session_desc.video_height, calibration=self._scene.selected_camera, + profile_input_latency=self._config.profile_input_latency, ) ui_loop = self.register_ui_loop( CrazyRobotaxiSlangPyUILoop, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 67f84799d..bcdace4ef 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -17,6 +17,7 @@ from __future__ import annotations +import logging import math import time from collections import OrderedDict @@ -35,11 +36,24 @@ from crazy_robotaxi.world_overlay import render_waypoint_layers from flashdreams.api_v2.loop import ILoop, invoke_async from flashdreams.runtime_v2.slangpy_ui_loop import SlangPyUILoop +from flashdreams.runtime_v2.user_input_event import ( + FocusUserInputEventData, + KeyboardInputState, + KeyboardUserInputEventData, +) from flashdreams.runtime_v2.user_input_events import UserInputEvents _MAX_BUFFERED_HUD_FRAMES = 64 """Maximum frame-aligned snapshots retained across pending model chunks.""" +_MAX_BUFFERED_INPUT_EVENTS = 64 +"""Maximum diagnostic event receipts retained before model-frame correlation.""" + +_PROFILE_DRIVE_KEYS = frozenset( + {"w", "a", "s", "d", "up", "down", "left", "right", "space"} +) +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, slots=True) class TaxiHudFrame: @@ -54,6 +68,18 @@ class TaxiHudFrame: rig_pose_world: npt.NDArray[np.float32] """Read-only rig pose that generated the corresponding video frame.""" + transition_timestamp_us: int | None = None + """V2 input transition represented by this frame, when one was received.""" + + input_transition_count: int = 0 + """Cumulative resolved drive transitions consumed by the model loop.""" + + input_ignored_event_count: int = 0 + """Cumulative redundant drive events ignored by the model loop.""" + + input_dropped_transition_count: int = 0 + """Cumulative transitions displaced by fixed-size model chunks.""" + @dataclass(slots=True) class _HudWidgets: @@ -75,6 +101,9 @@ class _HudWidgets: submit: Any validation: Any leaderboard: Any + input_window: Any | None + input_state: Any | None + input_latency: Any | None @dataclass(slots=True) @@ -90,6 +119,9 @@ class TaxiHudState: calibration: CameraCalibration """Camera calibration used to project world markers on the UI thread.""" + profile_input_latency: bool = False + """Whether input arrival and model-frame latency diagnostics are visible.""" + model_loop: ILoop[Any] | None = None """Model-loop endpoint used only through ``invoke_async``.""" @@ -120,6 +152,18 @@ class TaxiHudState: _loading_started_at_s: float = field(default_factory=time.monotonic) """Monotonic timestamp used to make startup progress visibly live.""" + _profile_pressed: set[str] = field(default_factory=set) + """Normalized drive keys currently held according to UI-thread events.""" + + _input_received_at_s: OrderedDict[int, float] = field(default_factory=OrderedDict) + """UI receipt times keyed by V2 session-relative event timestamp.""" + + _reported_input_timestamps_us: set[int] = field(default_factory=set) + """Input transitions already correlated with a presented model frame.""" + + _latest_input_latency_ms: float | None = None + """Latest UI-ingress-to-model-frame-selection latency measurement.""" + def publish(self, frames: Sequence[TaxiHudFrame]) -> None: """Publish immutable model-frame state to the UI-owned lookup.""" for frame in frames: @@ -140,8 +184,57 @@ def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: self._validation_message = "" self._submission_pending = False self._current = selected + self._record_presented_input(selected) return self._current + def consume_input_events(self, events: UserInputEvents) -> None: + """Track responsive drive state and receipt times on the UI thread.""" + if not self.profile_input_latency: + return + for event in events.get_events(): + data = event.get_event_data() + recognized = False + if isinstance(data, FocusUserInputEventData) and not data.focused: + self._profile_pressed.clear() + recognized = True + elif isinstance(data, KeyboardUserInputEventData): + key = _normalize_profile_key(str(data.key)) + if key not in _PROFILE_DRIVE_KEYS: + continue + recognized = True + if data.state is KeyboardInputState.PRESSED: + self._profile_pressed.add(key) + else: + self._profile_pressed.discard(key) + if not recognized: + continue + timestamp_us = int(event.get_timestamp()) + self._input_received_at_s.setdefault(timestamp_us, time.perf_counter()) + self._input_received_at_s.move_to_end(timestamp_us) + while len(self._input_received_at_s) > _MAX_BUFFERED_INPUT_EVENTS: + self._input_received_at_s.popitem(last=False) + + def _record_presented_input(self, selected: TaxiHudFrame) -> None: + if not self.profile_input_latency: + return + timestamp_us = selected.transition_timestamp_us + if timestamp_us is None or timestamp_us in self._reported_input_timestamps_us: + return + received_at_s = self._input_received_at_s.pop(timestamp_us, None) + if received_at_s is None: + return + self._reported_input_timestamps_us.add(timestamp_us) + self._latest_input_latency_ms = (time.perf_counter() - received_at_s) * 1000.0 + _LOGGER.info( + "[crazy-robotaxi] input-to-model-frame latency: event_us=%d " + "ui_to_frame_ms=%.1f transitions=%d ignored=%d dropped=%d", + timestamp_us, + self._latest_input_latency_ms, + selected.input_transition_count, + selected.input_ignored_event_count, + selected.input_dropped_transition_count, + ) + def set_loading_status(self, status: str) -> None: """Update the startup phase from a model-loop message.""" self._loading_status = status @@ -180,6 +273,7 @@ def waypoint_layer(self, frame: Tensor) -> Tensor | None: def draw(self, ui: Any, ui_tick: int = 0) -> None: """Create or update the retained ImGui HUD widget tree.""" widgets = self._ensure_widgets(ui) + self._update_input_diagnostic(widgets) hud_frame = self._current if hud_frame is None: dots = "." * (1 + (ui_tick // 15) % 3) @@ -221,6 +315,10 @@ def reset(self) -> None: self._submission_pending = False self._loading_status = "LOADING WORLD MODEL" self._loading_started_at_s = time.monotonic() + self._profile_pressed.clear() + self._input_received_at_s.clear() + self._reported_input_timestamps_us.clear() + self._latest_input_latency_ms = None if self._widgets is not None: self._widgets.name_input.value = "" self._widgets.name_input.enabled = True @@ -275,6 +373,18 @@ def _ensure_widgets(self, ui: Any) -> _HudWidgets: ) validation = ui.Text(terminal_window, "") leaderboard = ui.Text(terminal_window, "") + input_window = None + input_state = None + input_latency = None + if self.profile_input_latency: + input_window = ui.Window( + ui.screen, + "Input Latency", + position=(14.0, float(max(14, self.height - 124))), + size=(440.0, 110.0), + ) + input_state = ui.Text(input_window, "") + input_latency = ui.Text(input_window, "") self._widgets = _HudWidgets( status_window=status_window, score=score, @@ -292,9 +402,46 @@ def _ensure_widgets(self, ui: Any) -> _HudWidgets: submit=submit, validation=validation, leaderboard=leaderboard, + input_window=input_window, + input_state=input_state, + input_latency=input_latency, ) return self._widgets + def _update_input_diagnostic(self, widgets: _HudWidgets) -> None: + if not self.profile_input_latency: + return + assert widgets.input_window is not None + assert widgets.input_state is not None + assert widgets.input_latency is not None + pressed = self._profile_pressed + widgets.input_state.text = " ".join( + f"{label} [{'X' if bool(keys & pressed) else ' '}]" + for label, keys in ( + ("W", {"w", "up"}), + ("A", {"a", "left"}), + ("S", {"s", "down"}), + ("D", {"d", "right"}), + ("SPACE", {"space"}), + ) + ) + current = self._current + latency = self._latest_input_latency_ms + if current is None: + counts = "TRANSITIONS 0 IGNORED 0 DROPPED 0" + else: + counts = ( + f"TRANSITIONS {current.input_transition_count} " + f"IGNORED {current.input_ignored_event_count} " + f"DROPPED {current.input_dropped_transition_count}" + ) + latency_label = ( + "UI TO MODEL FRAME --" + if latency is None + else f"UI TO MODEL FRAME {latency:.1f} ms" + ) + widgets.input_latency.text = f"{latency_label}\n{counts}" + def _update_terminal( self, widgets: _HudWidgets, snapshot: TaxiGameSnapshot ) -> None: @@ -361,7 +508,7 @@ def step_ui( self, ui: Any, step_index: int, events: UserInputEvents ) -> Tensor | None: """Draw the HUD and return the current generated frame beneath it.""" - del events + self.state.consume_input_events(events) frames = self.presented_model_frames() video = frames[0] if frames else None bev_overlay = frames[1] if len(frames) > 1 else None @@ -393,6 +540,11 @@ def build_hud_frames( video_tchw: Tensor, snapshots: Sequence[object], rig_poses_world: npt.NDArray[np.float32], + *, + transition_timestamps_us: Sequence[int | None] | None = None, + input_transition_count: int = 0, + input_ignored_event_count: int = 0, + input_dropped_transition_count: int = 0, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) @@ -401,6 +553,10 @@ def build_hud_frames( poses = np.asarray(rig_poses_world, dtype=np.float32) if poses.shape != (frame_count, 4, 4): raise ValueError("Video and rig poses must align") + if transition_timestamps_us is None: + transition_timestamps_us = (None,) * frame_count + if len(transition_timestamps_us) != frame_count: + raise ValueError("Input transitions and video frames must align") frames = [] for index, snapshot in enumerate(snapshots): if not isinstance(snapshot, TaxiGameSnapshot): @@ -412,11 +568,28 @@ def build_hud_frames( frame_key=int(video_tchw[index].data_ptr()), snapshot=snapshot, rig_pose_world=pose, + transition_timestamp_us=transition_timestamps_us[index], + input_transition_count=input_transition_count, + input_ignored_event_count=input_ignored_event_count, + input_dropped_transition_count=input_dropped_transition_count, ) ) return tuple(frames) +def _normalize_profile_key(key: str) -> str: + if key == " ": + return "space" + normalized = key.strip().lower() + return { + "arrowup": "up", + "arrowdown": "down", + "arrowleft": "left", + "arrowright": "right", + "spacebar": "space", + }.get(normalized, normalized) + + def _score_label(snapshot: TaxiGameSnapshot) -> str: label = f"SCORE {snapshot.score:06d}" if snapshot.high_score is not None: diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 322b31255..8c3e59ee8 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -61,7 +61,15 @@ def test_application_registers_model_and_slangpy_ui_loops() -> None: scene_factory=lambda request, raster: _scene(), ) desc = app.session_desc() - app.init(["--device", "cpu", "--total-blocks", "2"]) + app.init( + [ + "--device", + "cpu", + "--total-blocks", + "2", + "--profile-input-latency", + ] + ) session = app.create_session(desc) session.init() @@ -76,6 +84,7 @@ def test_application_registers_model_and_slangpy_ui_loops() -> None: assert model_loop.state.rollout is None assert model_loop.state.ui_loop is ui_loop assert ui_loop.state.model_loop is model_loop + assert ui_loop.state.profile_input_latency @pytest.mark.parametrize( @@ -104,6 +113,25 @@ def test_pipeline_profiling_is_an_app_local_opt_in( assert _MODEL_PRESETS["standard"].enable_sync_and_profile +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + ([], False), + (["--profile-input-latency"], True), + ], +) +def test_input_latency_profiling_is_an_app_local_opt_in( + arguments: list[str], + expected: bool, +) -> None: + app = CrazyRobotaxiApplication() + + app.init(arguments) + + assert app._config is not None + assert app._config.profile_input_latency is expected + + @pytest.mark.parametrize("prewarm_blocks", [0, 4, 7]) def test_application_configures_prepresentation_warmup(prewarm_blocks: int) -> None: app = CrazyRobotaxiApplication( diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index e7ce457d2..8276a9fa6 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -36,6 +36,11 @@ from flashdreams.api_v2.loop import IModelLoop from flashdreams.runtime_v2.presentation_manager import PresentationManager from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_event import ( + KeyboardInputState, + KeyboardUserInputEventData, + UserInputEvent, +) from flashdreams.runtime_v2.user_input_events import UserInputEvents from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -187,6 +192,37 @@ def test_hud_frames_are_immutable_messages_keyed_to_video_storage() -> None: assert not frames[0].rig_pose_world.flags.writeable +def test_hud_frames_preserve_frame_aligned_input_diagnostics() -> None: + video = torch.zeros(2, 3, 96, 160) + snapshots = (_snapshot(), _snapshot()) + poses = np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0) + + frames = build_hud_frames( + video, + snapshots, + poses, + transition_timestamps_us=(100, 200), + input_transition_count=4, + input_ignored_event_count=2, + input_dropped_transition_count=1, + ) + + assert [frame.transition_timestamp_us for frame in frames] == [100, 200] + assert frames[0].input_transition_count == 4 + assert frames[0].input_ignored_event_count == 2 + assert frames[0].input_dropped_transition_count == 1 + + +def test_hud_frames_reject_misaligned_input_diagnostics() -> None: + with pytest.raises(ValueError, match="Input transitions"): + build_hud_frames( + torch.zeros(2, 3, 96, 160), + (_snapshot(), _snapshot()), + np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0), + transition_timestamps_us=(100,), + ) + + def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: poses = np.eye(4, dtype=np.float32)[None] @@ -399,6 +435,70 @@ def test_hud_animates_prepresentation_warmup_status() -> None: assert state._widgets.time.text.startswith("ELAPSED ") +def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: + video = torch.zeros(1, 3, 96, 160) + state = TaxiHudState( + 160, + 96, + _calibration(), + profile_input_latency=True, + ) + state.consume_input_events( + UserInputEvents( + [ + UserInputEvent( + timestamp=np.uint64(100), + event_data=KeyboardUserInputEventData( + key="ArrowLeft", + state=KeyboardInputState.PRESSED, + ), + ) + ] + ) + ) + state.publish( + build_hud_frames( + video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + transition_timestamps_us=(100,), + input_transition_count=1, + input_ignored_event_count=2, + input_dropped_transition_count=0, + ) + ) + + state.select_presented_frame(video[0]) + state.draw(_FakeUI()) + + assert state._latest_input_latency_ms is not None + assert state._widgets is not None + assert state._widgets.input_window is not None + input_state = state._widgets.input_state + input_latency = state._widgets.input_latency + assert input_state is not None + assert input_latency is not None + assert "A [X]" in input_state.text + assert "UI TO MODEL FRAME" in input_latency.text + assert "TRANSITIONS 1" in input_latency.text + assert "IGNORED 2" in input_latency.text + + state.reset() + assert not state._profile_pressed + assert state._latest_input_latency_ms is None + + +def test_input_latency_widgets_are_absent_by_default() -> None: + state = TaxiHudState(160, 96, _calibration()) + + state.draw(_FakeUI()) + + assert state._widgets is not None + assert state._widgets.input_window is None + assert state._widgets.input_state is None + assert state._widgets.input_latency is None + + def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: state = TaxiHudState(160, 96, _calibration()) model_loop = _SubmissionLoop() diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index 029e370a2..663ecde1f 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -5,6 +5,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass from flashdreams.runtime_v2.user_input_event import ( @@ -17,6 +18,26 @@ from omnidreams_game_engine.types import DriverCommand _DRIVE_KEYS = frozenset({"w", "a", "s", "d", "up", "down", "left", "right", "space"}) +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class _DriveLevel: + """Resolved digital driving state at one input transition.""" + + forward: bool = False + backward: bool = False + left: bool = False + right: bool = False + handbrake: bool = False + + +@dataclass(frozen=True, slots=True) +class _TimedDriveLevel: + """Resolved driving state paired with its V2 event timestamp.""" + + timestamp_us: int + level: _DriveLevel @dataclass(frozen=True, slots=True) @@ -25,6 +46,18 @@ class DriverInputBatch: commands: tuple[DriverCommand, ...] + transition_timestamps_us: tuple[int | None, ...] + """Input transition represented by each command; ``None`` for retained state.""" + + transition_count: int + """Number of distinct driving-state transitions received in this batch.""" + + dropped_transition_count: int + """Number of oldest transitions that could not fit in the model chunk.""" + + ignored_event_count: int + """Number of redundant drive events that did not change resolved controls.""" + class DriverInput: """Retain key levels while consuming V2 edge events exactly once.""" @@ -41,30 +74,57 @@ def reduce( frame_count: int, frame_interval_s: float, ) -> DriverInputBatch: - """Apply input edges and produce a command for every simulated frame.""" + """Preserve input transitions and produce one command per simulated frame.""" if frame_count <= 0: raise ValueError("frame_count must be positive") + transitions: list[_TimedDriveLevel] = [] + ignored_event_count = 0 for event in events.get_events(): data = event.get_event_data() + previous = self._drive_level() if isinstance(data, FocusUserInputEventData) and not data.focused: self._pressed.clear() - continue - if not isinstance(data, KeyboardUserInputEventData): - continue - raw_key = str(data.key) - key = _normalize_key(raw_key) - pressed = data.state is KeyboardInputState.PRESSED - if key in _DRIVE_KEYS: - if pressed: + elif isinstance(data, KeyboardUserInputEventData): + key = _normalize_key(str(data.key)) + if key not in _DRIVE_KEYS: + continue + if data.state is KeyboardInputState.PRESSED: self._pressed.add(key) else: self._pressed.discard(key) + else: + continue + + current = self._drive_level() + if current != previous: + transitions.append( + _TimedDriveLevel( + timestamp_us=int(event.get_timestamp()), + level=current, + ) + ) + else: + ignored_event_count += 1 + + scheduled, dropped_transition_count = _schedule_drive_levels( + transitions, + retained=self._drive_level(), + frame_count=frame_count, + frame_interval_s=frame_interval_s, + ) + if dropped_transition_count: + _LOGGER.warning( + "Dropped %d oldest input transitions that could not fit in a " + "%d-frame model chunk", + dropped_transition_count, + frame_count, + ) commands: list[DriverCommand] = [] - for _ in range(frame_count): + transition_timestamps_us: list[int | None] = [] + for level, timestamp_us in scheduled: target = self.config.steering_scale * ( - float(bool({"a", "left"} & self._pressed)) - - float(bool({"d", "right"} & self._pressed)) + float(level.left) - float(level.right) ) rate = ( self.config.steering_rate_per_s @@ -76,25 +136,84 @@ def reduce( target, rate * frame_interval_s, ) - forward = bool({"w", "up"} & self._pressed) - backward = bool({"s", "down"} & self._pressed) commands.append( DriverCommand( - throttle=1.0 if forward and not backward else 0.0, - brake=1.0 if backward or "space" in self._pressed else 0.0, + throttle=1.0 if level.forward and not level.backward else 0.0, + brake=1.0 if level.backward or level.handbrake else 0.0, steer=self._steering, - handbrake="space" in self._pressed, + handbrake=level.handbrake, steer_is_direct=True, manual_control=True, ) ) - return DriverInputBatch(tuple(commands)) + transition_timestamps_us.append(timestamp_us) + return DriverInputBatch( + commands=tuple(commands), + transition_timestamps_us=tuple(transition_timestamps_us), + transition_count=len(transitions), + dropped_transition_count=dropped_transition_count, + ignored_event_count=ignored_event_count, + ) def reset(self) -> None: """Clear every retained input value.""" self._pressed.clear() self._steering = 0.0 + def _drive_level(self) -> _DriveLevel: + return _DriveLevel( + forward=bool({"w", "up"} & self._pressed), + backward=bool({"s", "down"} & self._pressed), + left=bool({"a", "left"} & self._pressed), + right=bool({"d", "right"} & self._pressed), + handbrake="space" in self._pressed, + ) + + +def _schedule_drive_levels( + transitions: list[_TimedDriveLevel], + *, + retained: _DriveLevel, + frame_count: int, + frame_interval_s: float, +) -> tuple[list[tuple[_DriveLevel, int | None]], int]: + """Map timestamped transitions onto a fixed-size future model chunk.""" + if not transitions: + return [(retained, None) for _ in range(frame_count)], 0 + + scheduled: list[tuple[_DriveLevel, int, int]] = [] + safe_frame_interval_s = max(float(frame_interval_s), 1e-9) + for index, transition in enumerate(transitions): + if index + 1 < len(transitions): + next_transition = transitions[index + 1] + duration_s = max( + 0.0, + (next_transition.timestamp_us - transition.timestamp_us) / 1_000_000.0, + ) + repeat_count = max(0, round(duration_s / safe_frame_interval_s)) + else: + repeat_count = max(1, frame_count - len(scheduled)) + scheduled.extend( + (transition.level, transition.timestamp_us, index) + for _ in range(repeat_count) + ) + + represented_before_capacity = {index for _, _, index in scheduled} + if len(scheduled) > frame_count: + scheduled = scheduled[-frame_count:] + elif len(scheduled) < frame_count: + latest = scheduled[-1] + scheduled.extend(latest for _ in range(frame_count - len(scheduled))) + + retained_transition_indexes = {index for _, _, index in scheduled} + dropped_transition_count = len( + represented_before_capacity - retained_transition_indexes + ) + return ( + [(level, timestamp_us) for level, timestamp_us, _ in scheduled], + dropped_transition_count, + ) + def _normalize_key(key: str) -> str: if key == " ": diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index 9c88335c4..3355737bd 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -29,6 +29,17 @@ def _events(*data: UserInputEventData) -> UserInputEvents: ) +def _timed_events( + *events: tuple[int, UserInputEventData], +) -> UserInputEvents: + return UserInputEvents( + [ + UserInputEvent(timestamp=np.uint64(timestamp_us), event_data=data) + for timestamp_us, data in events + ] + ) + + def _key(key: str, state: KeyboardInputState) -> KeyboardUserInputEventData: return KeyboardUserInputEventData(key=key, state=state) @@ -61,6 +72,10 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: ) assert all(command.throttle == 1.0 for command in first.commands) assert retained.commands[0].steer == pytest.approx(0.8) + assert first.transition_timestamps_us == (1, 1, 1) + assert first.transition_count == 2 + assert first.dropped_transition_count == 0 + assert retained.transition_timestamps_us == (None,) def test_focus_loss_releases_drive_state() -> None: @@ -83,3 +98,69 @@ def test_focus_loss_releases_drive_state() -> None: assert result.commands[0].throttle == 0.0 assert not result.commands[0].reverse + + +def test_short_press_and_release_are_preserved_in_the_next_chunk() -> None: + reducer = DriverInput() + + result = reducer.reduce( + _timed_events( + (1_000_000, _key("w", KeyboardInputState.PRESSED)), + (1_100_000, _key("w", KeyboardInputState.RELEASED)), + ), + frame_count=5, + frame_interval_s=0.05, + ) + + assert [command.throttle for command in result.commands] == [ + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + ] + assert result.transition_timestamps_us == ( + 1_000_000, + 1_000_000, + 1_100_000, + 1_100_000, + 1_100_000, + ) + assert result.transition_count == 2 + assert result.dropped_transition_count == 0 + + +def test_duplicate_key_events_do_not_create_transitions() -> None: + reducer = DriverInput() + + result = reducer.reduce( + _timed_events( + (10, _key("w", KeyboardInputState.PRESSED)), + (20, _key("w", KeyboardInputState.PRESSED)), + ), + frame_count=2, + frame_interval_s=1.0 / 30.0, + ) + + assert result.transition_count == 1 + assert result.transition_timestamps_us == (10, 10) + assert result.ignored_event_count == 1 + + +def test_transition_overflow_preserves_the_newest_controls() -> None: + reducer = DriverInput() + + result = reducer.reduce( + _timed_events( + (10_000, _key("w", KeyboardInputState.PRESSED)), + (50_000, _key("w", KeyboardInputState.RELEASED)), + (90_000, _key("w", KeyboardInputState.PRESSED)), + ), + frame_count=2, + frame_interval_s=1.0 / 30.0, + ) + + assert [command.throttle for command in result.commands] == [0.0, 1.0] + assert result.transition_timestamps_us == (50_000, 90_000) + assert result.transition_count == 3 + assert result.dropped_transition_count == 1 diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 56d8f16f0..bcee35927 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -42,7 +42,7 @@ flowchart TD end subgraph MODELTHREAD["FlashDreams V2 model thread"] - INPUT["DriverInput
held driving keys"] + INPUT["DriverInput
timestamped transition timeline"] ENGINE["GameEngine"] CACHE["session-local OmniDreams cache"] HUDSTATE["immutable TaxiHudFrame batches"] @@ -162,10 +162,12 @@ flowchart LR The output-frame count is learned from the pipeline before simulation, so simulation, conditioning, generated video, game snapshots, and immutable HUD -frames have one authoritative `T`. Input edges update retained held state; -steering and speed commands are advanced at the simulation frame interval to -produce a command for every frame rather than one command per model -invocation. +frames have one authoritative `T`. Input edges update retained held state and +their timestamps preserve transitions that arrive while a model step is in +flight. The next step maps those transitions onto its fixed-size command +sequence, coalescing sub-frame states and retaining the newest states if the +chunk cannot represent all of them. Steering and speed commands advance at the +simulation frame interval rather than once per model invocation. The direct model contract is: diff --git a/docs/design/crazy_robotaxi_v2_input_latency.md b/docs/design/crazy_robotaxi_v2_input_latency.md new file mode 100644 index 000000000..869dab8a5 --- /dev/null +++ b/docs/design/crazy_robotaxi_v2_input_latency.md @@ -0,0 +1,90 @@ +# Crazy Robotaxi V2 input-latency handoff + +## Status + +Crazy Robotaxi preserves timestamped input transitions on the application side +and offers opt-in UI-to-model-frame diagnostics. The remaining held-input +latency cannot be removed through the public application interfaces alone. +Shared V2 runtime changes are intentionally deferred to the API and +architecture owners. + +## Reproduction and evidence + +Run the app at its trained 1280×704, 30 fps output contract: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc \ + --stats-path /tmp/crazy-robotaxi-latency.json -- \ + --model-preset perf \ + --profile-input-latency +``` + +The investigation observed: + +- A held steering key took more than 1.5 seconds to affect displayed model + video across model presets. +- A 100–200 ms tap was often absent before timestamped transition reduction + was added. +- `--presentation-mode only_present_new` did not materially improve the held + steering delay, so repeated UI frames were not its dominant cause. +- A supplied synchronized diagnostic run produced eight-frame chunks covering + 266.7 ms of video in 733.6–788.7 ms after warmup, averaging 760.1 ms. Pipeline + profiling perturbs CPU behavior, so these numbers describe that captured + stack rather than a universal model benchmark. + +V2 reads model events immediately before calling the synchronous model-loop +`step`. An input arriving during the measured 760 ms step therefore waits for +that step to finish and for the next conditioned step to finish. This creates +an application/model response window of roughly 760–1520 ms before final +WebRTC delivery. Smaller or preemptible model work is required to reduce that +floor. + +## V2 transport boundary + +The current V2 WebRTC client window has a second, independent latency risk: + +- Its private video track uses an unbounded `asyncio.Queue` and appends every + submitted frame. +- `SessionDesc.backpressure_mode` configures `PresentationManager`, but does + not configure or bound the WebRTC queue. +- Every UI result is converted to CPU RGB and encoded by aiortc's software VP8 + path. The app cannot select the encoder, inspect queue depth or frame age, + flush stale frames, or observe browser presentation. +- The existing non-V2 WebRTC serving stack already contains bounded tracks, + stale-delivery flushing, pacing re-anchoring, delivery metrics, and optional + NVENC. V2 does not currently use those facilities. + +The application cannot safely work around this boundary without constructing +or reaching into a private client window, which would violate V2 ownership and +couple the game to one presentation mode. + +## Requested API decisions + +1. Define interactive transport backpressure end to end. Either propagate the + session policy through the client window or introduce a separate transport + policy with a bounded low-latency mode. +2. Reuse the shared WebRTC encoder/bridge abstractions, including optional + NVENC, rather than maintaining a second unbounded software-only path. +3. Expose encoder backend, conversion/encode time, queue depth, dropped or + flushed frames, and estimated frame age through runtime metrics. +4. Specify the input contract for long synchronous model steps. Sub-generation + response requires smaller chunks, cancellation/preemption, or an explicit + asynchronous conditioning mechanism; event polling alone cannot modify an + in-flight conditioned chunk. +5. Keep ordered/blocking delivery available for file and quality workflows, + while making the interactive low-latency policy explicit rather than + silently accumulating stale frames. + +## Runtime acceptance tests + +- Exercise real 1280×704 content at 30 fps, not only the existing 16×16 smoke + test. +- Simulate a slow encoder or consumer and prove the interactive queue remains + bounded, stale work is flushed according to policy, and frame age recovers + after a stall. +- Record browser-key-to-runtime-event and UI-write-to-browser-display latency; + target p95 transport overhead below 100 ms on a local loopback after warmup. +- Report best, average, p90, and worst latency together with encoder backend, + resolution, host, GPU, driver, and warmup policy. +- Verify reset and disconnect discard stale media without replaying frames from + the previous generation. From 088e57d1138e51f6950e2108acdf839dd1f1e334 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 26 Aug 2026 23:49:18 +0000 Subject: [PATCH 12/62] Add Crazy Robotaxi native window support Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 12 +++- apps/crazy_robotaxi/tests/test_application.py | 71 +++++++++++++++++++ docs/design/crazy_robotaxi_v2_architecture.md | 2 +- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 404c36c81..a27991cd5 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -4,7 +4,7 @@ Crazy Robotaxi is a FlashDreams V2 application built on the model/UI-loop API and `omnidreams-game-engine`. FlashDreams owns input collection, reset generations, presentation buffering, client windows, and the two-thread runtime. The UI loop uses FlashDreams' SlangPy renderer for the game HUD and -composites it over model frames for local and WebRTC clients. +composites it over model frames for native-window, WebRTC, and file clients. Camera-projected waypoint rings, beacons, and labels are rendered as a frame-aligned world layer on the UI thread; they are not ImGui windows. The model loop publishes immutable snapshot-and-pose metadata for each generated @@ -16,6 +16,16 @@ applies the SlangPy ImGui HUD. ```bash uv sync --package crazy-robotaxi +uv run flashdreams-run-v2 crazy-robotaxi \ + --mode native-window \ + --window-title "Crazy Robotaxi" +``` + +Native-window mode keeps the composited frame on the GPU and presents it in a +local GLFW window. It requires a local display plus SlangPy's Vulkan/CUDA +interop support. For a browser client instead, run: + +```bash uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc ``` diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 8c3e59ee8..8e233bbc1 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -5,10 +5,12 @@ from dataclasses import replace from pathlib import Path +from typing import Any, cast from unittest.mock import patch import numpy as np import pytest +import torch from crazy_robotaxi.application import _MODEL_PRESETS, CrazyRobotaxiApplication from crazy_robotaxi.physics import TaxiPhysicsWorld from crazy_robotaxi.session import CrazyRobotaxiModelLoop @@ -20,6 +22,10 @@ SceneDefinition, ) +from flashdreams.runtime_v2.native_window_client_window import ( + NativeWindowClientWindow, +) +from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.video_tensor import VideoTensorLayout pytestmark = pytest.mark.ci_cpu @@ -87,6 +93,71 @@ def test_application_registers_model_and_slangpy_ui_loops() -> None: assert ui_loop.state.profile_input_latency +def test_native_window_accepts_crazy_robotaxi_output_contract() -> None: + """Keep the app's fixed output contract compatible with V2 native output.""" + + class Presenter: + should_close = False + + def __init__(self) -> None: + self.frames: list[torch.Tensor] = [] + self.closed = False + + def set_input_callbacks(self, **callbacks: object) -> None: + assert set(callbacks) == {"on_keyboard_event", "on_mouse_event"} + + def present_frame(self, frame: torch.Tensor) -> bool: + self.frames.append(frame) + return True + + def close(self) -> None: + self.closed = True + + desc = CrazyRobotaxiApplication().session_desc() + presenter = Presenter() + presenter_arguments: dict[str, object] = {} + + def create_presenter(**arguments: object) -> Presenter: + presenter_arguments.update(arguments) + return presenter + + window = NativeWindowClientWindow( + title="Crazy Robotaxi", + presenter_factory=cast(Any, create_presenter), + ) + source = torch.zeros( + (1, 3, desc.video_height, desc.video_width), + dtype=torch.float32, + ) + + window.open(desc) + window.write( + StepResult( + step_index=0, + output=source, + frame_count=1, + output_layout=desc.output_layout, + ) + ) + window.close() + + assert presenter_arguments == { + "width": desc.video_width, + "height": desc.video_height, + "title": "Crazy Robotaxi", + } + assert len(presenter.frames) == 1 + assert presenter.frames[0].shape == ( + desc.video_height, + desc.video_width, + 3, + ) + assert presenter.frames[0].device == source.device + assert presenter.frames[0].dtype is torch.uint8 + assert torch.all(presenter.frames[0] == 128) + assert presenter.closed + + @pytest.mark.parametrize( ("arguments", "expected"), [ diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index bcee35927..7528e4d41 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -52,7 +52,7 @@ flowchart TD BUFFER["PresentationManager
RGB + optional RGBA BEV channels"] HUD["CrazyRobotaxiSlangPyUILoop
waypoint cache + HUD widgets"] COMPOSE["RGB → waypoint → BEV composition
then SlangPy ImGui HUD"] - WINDOW["V2 client window
WebRTC / MP4 / supported host"] + WINDOW["V2 client window
native / WebRTC / MP4"] end HOST --> APP From 95016514fd8e991db50dbd379a2668776df7ee96 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 01:28:39 +0000 Subject: [PATCH 13/62] Add original Crazy Robotaxi perf preset Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 35 +++++ .../crazy_robotaxi/application.py | 81 ++++++++++- apps/crazy_robotaxi/tests/test_application.py | 128 ++++++++++++++++-- 3 files changed, 230 insertions(+), 14 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index a27991cd5..55b292ba9 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -45,6 +45,41 @@ and the autoregressive cache together while retaining the loaded model. Leaderboard name entry is owned by the SlangPy UI and submitted to the model loop through V2's asynchronous loop-message contract. +## Original demo performance preset + +The opt-in `original-perf` preset reproduces the performance knobs from the +current `example_world_model_perf.yaml` without importing or depending on the +`interactive_drive` application. It uses the compiled LightVAE/LightTAE path, +the required native FP8 DiT with cuDNN attention, skips the KV-cache finalize +pass, and uses denoising timesteps `[1000, 100]`. For direct comparison with +that manifest, select its 1168x640 resolution as shown below. + +Prepare the native DiT sources once from the repository root: + +```bash +uv run --package flashdreams-omnidreams omnidreams-prepare --perf +``` + +Then run Crazy Robotaxi with the runtime resolution arguments before the +application-argument separator: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi \ + --mode native-window \ + --pixel-width 1168 \ + --pixel-height 640 \ + -- \ + --model-preset original-perf +``` + +The resolution flags are optional: without them, the preset uses the V2 +session's default resolution and adapts the game renderer to match. The preset +still fails if the required native DiT cannot be built or loaded. It keeps the +PyTorch LightVAE encoder, so it does not require a `lightvae-fp8-state.pt` +file. Use the existing `perf` and `native-perf` presets for their previous +behavior. GPU throughput and quality still need to be validated on the target +machine before treating this preset as a regression baseline. + ## Performance diagnostics Crazy Robotaxi emits lightweight model-thread, engine, overlay, and PhysX diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 12e9428ea..566a0246e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse +import logging from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from pathlib import Path @@ -41,13 +42,49 @@ _VIDEO_FPS = 30 """Generation and UI cadence; each UI tick advances one generated frame.""" +_LOGGER = logging.getLogger(__name__) + _DEFAULT_PREWARM_BLOCKS = 4 """Blocks covering chunk2 cache filling and the first steady-state AR shape.""" +_ORIGINAL_PERF_PIPELINE = derive_config( + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline, + name="crazy-robotaxi-original-perf", + diffusion_model={ + "seed": None, + "transformer": { + "compile_network": True, + "skip_finalize_kv_cache": True, + "native_dit_acceleration": "required", + "native_dit_backend": "fp8_kvcache_cudnn", + "native_dit_attention_backend": "cudnn", + }, + "scheduler": { + "denoising_timesteps": [1000, 100], + "num_inference_steps": 2, + }, + }, +) + + +@dataclass(frozen=True, slots=True) +class _ModelPreset: + """App-owned pipeline selection and renderer geometry policy.""" + + pipeline: Any + renderer_follows_session: bool = False + + _MODEL_PRESETS = { - "standard": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline, - "perf": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline, - "native-perf": RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline, + "standard": _ModelPreset(RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline), + "perf": _ModelPreset(RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline), + "native-perf": _ModelPreset( + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline + ), + "original-perf": _ModelPreset( + _ORIGINAL_PERF_PIPELINE, + renderer_follows_session=True, + ), } @@ -61,6 +98,8 @@ class ApplicationConfig: driver_input: DriverInputConfig device: str total_blocks: int | None + model_preset_name: str + renderer_follows_session: bool pipeline_profiling: bool prewarm_blocks: int """Hidden neutral blocks generated before the first presented game frame.""" @@ -85,7 +124,7 @@ def __init__( self._defaults = load_renderer_settings(_DEFAULT_RENDERER) self._pipeline_factory = pipeline_factory or _build_pipeline self._scene_factory = scene_factory or load_scene - self._pipeline_config: Any = _MODEL_PRESETS["standard"] + self._pipeline_config: Any = _MODEL_PRESETS["standard"].pipeline self._pipeline: Any | None = None self._config: ApplicationConfig | None = None @@ -124,7 +163,8 @@ def init(self, commandline_args: Sequence[str]) -> None: else args.high_scores.expanduser() ), ) - pipeline_config = _MODEL_PRESETS[args.model_preset] + model_preset = _MODEL_PRESETS[args.model_preset] + pipeline_config = model_preset.pipeline if args.compile is not None: pipeline_config = derive_config( pipeline_config, @@ -155,6 +195,8 @@ def init(self, commandline_args: Sequence[str]) -> None: driver_input=settings.driver_input, device=args.device, total_blocks=args.total_blocks, + model_preset_name=args.model_preset, + renderer_follows_session=model_preset.renderer_follows_session, pipeline_profiling=bool(args.profile_pipeline), prewarm_blocks=args.prewarm_blocks, profile_input_latency=bool(args.profile_input_latency), @@ -174,12 +216,39 @@ def create_session(self, session_desc: SessionDesc) -> ISession: raise ValueError( "Crazy Robotaxi generates and presents video at 30 frames per second" ) - expected = config.renderer.raster.resolution_wh actual = session_desc.video_width, session_desc.video_height + if config.renderer_follows_session: + config = replace( + config, + renderer=replace( + config.renderer, + raster=replace( + config.renderer.raster, + width=actual[0], + height=actual[1], + ), + ), + ) + expected = config.renderer.raster.resolution_wh if actual != expected: raise ValueError( f"Session dimensions {actual} do not match renderer {expected}" ) + transformer = self._pipeline_config.diffusion_model.transformer + scheduler = self._pipeline_config.diffusion_model.scheduler + _LOGGER.info( + "Crazy Robotaxi model preset=%s resolution=%sx%s native_dit=%s " + "native_backend=%s attention_backend=%s skip_finalize=%s " + "denoising_timesteps=%s", + config.model_preset_name, + actual[0], + actual[1], + transformer.native_dit_acceleration, + transformer.native_dit_backend, + transformer.native_dit_attention_backend, + transformer.skip_finalize_kv_cache, + list(scheduler.denoising_timesteps), + ) if self._pipeline is None: self._pipeline = self._pipeline_factory( self._pipeline_config, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 8e233bbc1..61d4a9008 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -13,8 +13,14 @@ import torch from crazy_robotaxi.application import _MODEL_PRESETS, CrazyRobotaxiApplication from crazy_robotaxi.physics import TaxiPhysicsWorld -from crazy_robotaxi.session import CrazyRobotaxiModelLoop +from crazy_robotaxi.session import CrazyRobotaxiModelLoop, CrazyRobotaxiSession from crazy_robotaxi.ui import CrazyRobotaxiSlangPyUILoop +from omnidreams.config import ( + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, +) +from omnidreams_game_engine.config import RasterConfig from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld from omnidreams_game_engine.types import ( CameraCalibration, @@ -31,14 +37,14 @@ pytestmark = pytest.mark.ci_cpu -def _scene() -> SceneDefinition: +def _scene(*, width: int = 1280, height: int = 704) -> SceneDefinition: calibration = CameraCalibration( clipgt_name="front", logical_name="camera_front_wide_120fov", - width=1280, - height=704, - cx=640.0, - cy=352.0, + width=width, + height=height, + cx=width / 2.0, + cy=height / 2.0, polynomial=np.zeros(6, dtype=np.float32), is_backward_polynomial=False, linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), @@ -53,7 +59,7 @@ def _scene() -> SceneDefinition: initial_timestamp_us=0, initial_yaw_rad=0.0, initial_speed_mps=0.0, - initial_rgb=np.zeros((704, 1280, 3), dtype=np.uint8), + initial_rgb=np.zeros((height, width, 3), dtype=np.uint8), prompt="taxi", line_layers=(), triangle_layers=(), @@ -181,7 +187,113 @@ def test_pipeline_profiling_is_an_app_local_opt_in( assert configured[0].enable_sync_and_profile is expected assert app._config is not None assert app._config.pipeline_profiling is expected - assert _MODEL_PRESETS["standard"].enable_sync_and_profile + assert _MODEL_PRESETS["standard"].pipeline.enable_sync_and_profile + + +def test_existing_model_presets_keep_their_packaged_pipeline_configs() -> None: + assert ( + _MODEL_PRESETS["standard"].pipeline + is RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline + ) + assert ( + _MODEL_PRESETS["perf"].pipeline + is RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline + ) + assert ( + _MODEL_PRESETS["native-perf"].pipeline + is RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline + ) + assert all( + not _MODEL_PRESETS[name].renderer_follows_session + for name in ("standard", "perf", "native-perf") + ) + + +def test_original_perf_matches_the_original_demo_manifest() -> None: + preset = _MODEL_PRESETS["original-perf"] + pipeline = preset.pipeline + transformer = pipeline.diffusion_model.transformer + scheduler = pipeline.diffusion_model.scheduler + + assert preset.renderer_follows_session + assert pipeline.name == "crazy-robotaxi-original-perf" + assert pipeline.diffusion_model.seed is None + assert transformer.compile_network is True + assert transformer.use_cuda_graph is True + assert transformer.window_size_t == 6 + assert transformer.sink_size_t == 0 + assert transformer.skip_finalize_kv_cache is True + assert transformer.native_dit_acceleration == "required" + assert transformer.native_dit_backend == "fp8_kvcache_cudnn" + assert transformer.native_dit_attention_backend == "cudnn" + assert scheduler.num_inference_steps == 2 + assert scheduler.denoising_timesteps == [1000, 100] + assert pipeline.image_encoder.use_compile is True + assert pipeline.encoder.use_compile is True + assert pipeline.decoder.use_compile is True + assert pipeline.image_encoder.native_vae_acceleration == "disabled" + assert pipeline.encoder.native_vae_acceleration == "disabled" + + +@pytest.mark.parametrize("resolution_wh", [(1280, 704), (1168, 640)]) +def test_original_perf_adapts_renderer_to_session_geometry( + resolution_wh: tuple[int, int], +) -> None: + configured: list[object] = [] + raster_sizes: list[tuple[int, int]] = [] + + def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: + del request + size = raster.resolution_wh + raster_sizes.append(size) + return _scene(width=size[0], height=size[1]) + + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: configured.append(config) or object(), + scene_factory=load_test_scene, + ) + app.init(["--model-preset", "original-perf"]) + desc = replace( + app.session_desc(), + video_width=resolution_wh[0], + video_height=resolution_wh[1], + ) + + session = app.create_session(desc) + + assert isinstance(session, CrazyRobotaxiSession) + assert configured == [app._pipeline_config] + assert raster_sizes == [resolution_wh] + assert session._config.renderer.raster.resolution_wh == resolution_wh + assert session._scene.initial_rgb.shape == ( + resolution_wh[1], + resolution_wh[0], + 3, + ) + + +def test_original_perf_honors_explicit_pipeline_overrides() -> None: + app = CrazyRobotaxiApplication() + + app.init( + [ + "--model-preset", + "original-perf", + "--seed", + "7", + "--no-compile", + "--profile-pipeline", + ] + ) + + pipeline = app._pipeline_config + transformer = pipeline.diffusion_model.transformer + assert pipeline.diffusion_model.seed == 7 + assert transformer.compile_network is False + assert transformer.native_dit_acceleration == "required" + assert transformer.skip_finalize_kv_cache is True + assert pipeline.diffusion_model.scheduler.denoising_timesteps == [1000, 100] + assert pipeline.enable_sync_and_profile is True @pytest.mark.parametrize( From 2a51853b9a962509ee977a171b7f4b214d2ee19a Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 02:51:39 +0000 Subject: [PATCH 14/62] Adapt Crazy Robotaxi input to V2 events Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 13 +++++---- apps/crazy_robotaxi/tests/test_ui.py | 11 +++----- .../omnidreams_game_engine/input.py | 13 +++++---- .../tests/test_input.py | 27 ++++++++++--------- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index bcdace4ef..73164aea5 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -37,9 +37,9 @@ from flashdreams.api_v2.loop import ILoop, invoke_async from flashdreams.runtime_v2.slangpy_ui_loop import SlangPyUILoop from flashdreams.runtime_v2.user_input_event import ( - FocusUserInputEventData, + FocusUserInputEvent, KeyboardInputState, - KeyboardUserInputEventData, + KeyboardUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents @@ -192,17 +192,16 @@ def consume_input_events(self, events: UserInputEvents) -> None: if not self.profile_input_latency: return for event in events.get_events(): - data = event.get_event_data() recognized = False - if isinstance(data, FocusUserInputEventData) and not data.focused: + if isinstance(event, FocusUserInputEvent) and not event.focused: self._profile_pressed.clear() recognized = True - elif isinstance(data, KeyboardUserInputEventData): - key = _normalize_profile_key(str(data.key)) + elif isinstance(event, KeyboardUserInputEvent): + key = _normalize_profile_key(str(event.key)) if key not in _PROFILE_DRIVE_KEYS: continue recognized = True - if data.state is KeyboardInputState.PRESSED: + if event.state is KeyboardInputState.PRESSED: self._profile_pressed.add(key) else: self._profile_pressed.discard(key) diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 8276a9fa6..9e8d78ca1 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -38,8 +38,7 @@ from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( KeyboardInputState, - KeyboardUserInputEventData, - UserInputEvent, + KeyboardUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -446,12 +445,10 @@ def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: state.consume_input_events( UserInputEvents( [ - UserInputEvent( + KeyboardUserInputEvent( timestamp=np.uint64(100), - event_data=KeyboardUserInputEventData( - key="ArrowLeft", - state=KeyboardInputState.PRESSED, - ), + key="ArrowLeft", + state=KeyboardInputState.PRESSED, ) ] ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index 663ecde1f..f357a6254 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -9,9 +9,9 @@ from dataclasses import dataclass from flashdreams.runtime_v2.user_input_event import ( - FocusUserInputEventData, + FocusUserInputEvent, KeyboardInputState, - KeyboardUserInputEventData, + KeyboardUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents from omnidreams_game_engine.config import DriverInputConfig @@ -80,15 +80,14 @@ def reduce( transitions: list[_TimedDriveLevel] = [] ignored_event_count = 0 for event in events.get_events(): - data = event.get_event_data() previous = self._drive_level() - if isinstance(data, FocusUserInputEventData) and not data.focused: + if isinstance(event, FocusUserInputEvent) and not event.focused: self._pressed.clear() - elif isinstance(data, KeyboardUserInputEventData): - key = _normalize_key(str(data.key)) + elif isinstance(event, KeyboardUserInputEvent): + key = _normalize_key(str(event.key)) if key not in _DRIVE_KEYS: continue - if data.state is KeyboardInputState.PRESSED: + if event.state is KeyboardInputState.PRESSED: self._pressed.add(key) else: self._pressed.discard(key) diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index 3355737bd..4db1c98bd 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -3,45 +3,46 @@ """CPU tests for V2 event reduction.""" +from dataclasses import replace + import numpy as np import pytest from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.input import DriverInput -from flashdreams.api_v2.user_input_event_data import UserInputEventData +from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.user_input_event import ( - FocusUserInputEventData, + FocusUserInputEvent, KeyboardInputState, - KeyboardUserInputEventData, - UserInputEvent, + KeyboardUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents pytestmark = pytest.mark.ci_cpu -def _events(*data: UserInputEventData) -> UserInputEvents: +def _events(*events: UserInputEvent) -> UserInputEvents: return UserInputEvents( [ - UserInputEvent(timestamp=np.uint64(index), event_data=value) - for index, value in enumerate(data) + replace(event, timestamp=np.uint64(index)) + for index, event in enumerate(events) ] ) def _timed_events( - *events: tuple[int, UserInputEventData], + *events: tuple[int, UserInputEvent], ) -> UserInputEvents: return UserInputEvents( [ - UserInputEvent(timestamp=np.uint64(timestamp_us), event_data=data) - for timestamp_us, data in events + replace(event, timestamp=np.uint64(timestamp_us)) + for timestamp_us, event in events ] ) -def _key(key: str, state: KeyboardInputState) -> KeyboardUserInputEventData: - return KeyboardUserInputEventData(key=key, state=state) +def _key(key: str, state: KeyboardInputState) -> KeyboardUserInputEvent: + return KeyboardUserInputEvent(timestamp=np.uint64(0), key=key, state=state) def test_held_keys_produce_one_command_per_model_frame() -> None: @@ -91,7 +92,7 @@ def test_focus_loss_releases_drive_state() -> None: assert not reverse.commands[0].reverse result = reducer.reduce( - _events(FocusUserInputEventData(focused=False)), + _events(FocusUserInputEvent(timestamp=np.uint64(0), focused=False)), frame_count=1, frame_interval_s=1.0 / 30.0, ) From 5525fafca2894e4b48f1e737d8ab226c756a103b Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 03:25:57 +0000 Subject: [PATCH 15/62] Add Dear ImGui UI support Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 12 +- apps/crazy_robotaxi/crazy_robotaxi/session.py | 36 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 409 +++++++++--------- .../crazy_robotaxi/world_overlay.py | 58 +-- apps/crazy_robotaxi/tests/test_application.py | 6 +- apps/crazy_robotaxi/tests/test_ui.py | 224 +++++----- docs/design/crazy_robotaxi_v2_architecture.md | 65 +-- flashdreams/flashdreams/api_v2/README.md | 11 +- flashdreams/flashdreams/runtime_v2/README.md | 8 +- flashdreams/test_v2/test_imgui_ui_renderer.py | 182 ++++++++ 10 files changed, 575 insertions(+), 436 deletions(-) create mode 100644 flashdreams/test_v2/test_imgui_ui_renderer.py diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 55b292ba9..d9f000407 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -3,16 +3,16 @@ Crazy Robotaxi is a FlashDreams V2 application built on the model/UI-loop API and `omnidreams-game-engine`. FlashDreams owns input collection, reset generations, presentation buffering, client windows, and the two-thread -runtime. The UI loop uses FlashDreams' SlangPy renderer for the game HUD and +runtime. The UI loop uses FlashDreams' Dear ImGui renderer for the game HUD and composites it over model frames for native-window, WebRTC, and file clients. Camera-projected waypoint rings, beacons, and labels are rendered as a frame-aligned world layer on the UI thread; they are not ImGui windows. The model loop publishes immutable snapshot-and-pose metadata for each generated frame, and the UI loop rasterizes each presented waypoint layer once and -caches it while that frame remains visible. The optional BEV view is published -as an RGBA model-result layer. `CrazyRobotaxiSlangPyUILoop` composites video, -waypoints, and BEV through V2's presentation manager before the base loop -applies the SlangPy ImGui HUD. +caches it while that frame remains visible. The optional raw BEV view is a +second model result displayed inside a real ImGui `Map` window. +`CrazyRobotaxiImGuiUILoop` composites the in-world waypoints over video before +the base loop applies the Dear ImGui HUD and BEV overlay. ```bash uv sync --package crazy-robotaxi @@ -42,7 +42,7 @@ Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. The V2 client's reset event rebuilds simulation, game rules, traffic, conditioning, and the autoregressive cache together while retaining the loaded model. -Leaderboard name entry is owned by the SlangPy UI and submitted to the model +Leaderboard name entry is owned by the Dear ImGui UI and submitted to the model loop through V2's asynchronous loop-message contract. ## Original demo performance preset diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 2fcb51036..24d8c6678 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Crazy Robotaxi V2 session with model and SlangPy UI loops.""" +"""Crazy Robotaxi V2 session with model and Dear ImGui UI loops.""" from __future__ import annotations @@ -20,11 +20,10 @@ from crazy_robotaxi.factory import build_taxi_engine from crazy_robotaxi.rules import TaxiGameSnapshot from crazy_robotaxi.ui import ( - CrazyRobotaxiSlangPyUILoop, + CrazyRobotaxiImGuiUILoop, TaxiHudState, build_hud_frames, ) -from crazy_robotaxi.world_overlay import render_bev_overlay from flashdreams.api_v2.loop import IModelLoop, IUILoop, invoke_async from flashdreams.api_v2.session import ISession from flashdreams.runtime_v2.session_desc import SessionDesc @@ -52,7 +51,7 @@ class ModelState: rollout: WorldModelRollout | None = None last_video: torch.Tensor | None = None - last_bev_overlay: torch.Tensor | None = None + last_bev: torch.Tensor | None = None last_pose: np.ndarray | None = None blocks_generated: int = 0 finished: bool = False @@ -141,7 +140,7 @@ def reset(self) -> None: self.finished = False self.realtime_miss_count = 0 self.last_video = None - self.last_bev_overlay = None + self.last_bev = None self.last_pose = None self.input_transition_count = 0 self.input_ignored_event_count = 0 @@ -202,17 +201,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: game_frames = engine_step.game_frames poses = engine_step.trajectory.rig_poses_world bev = engine_step.condition.bev_tchw - bev_cpu_started = time.thread_time() - bev_overlay = ( - None - if bev is None - else render_bev_overlay( - bev, - width=state.session_desc.video_width, - height=state.session_desc.video_height, - ) - ) - bev_overlay_cpu_ms = (time.thread_time() - bev_cpu_started) * 1000.0 metrics = dict(generated.metrics) metrics.update( { @@ -228,9 +216,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: metrics["startup_prewarm_wall_ms"] = state.prewarm_wall_ms metrics["startup_prewarm_blocks"] = state.config.prewarm_blocks state.last_video = video[-1:].detach() - state.last_bev_overlay = ( - None if bev_overlay is None else bev_overlay[-1:].detach() - ) + state.last_bev = None if bev is None else bev[-1:].detach() state.last_pose = poses[-1].copy() else: if state.last_video is None or state.last_pose is None: @@ -238,9 +224,8 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: video = state.last_video game_frames = (snapshot,) poses = state.last_pose[None, ...] - bev_overlay = state.last_bev_overlay + bev = state.last_bev metrics = {} - bev_overlay_cpu_ms = 0.0 transition_timestamps_us = (None,) * int(video.shape[0]) hud_frames = build_hud_frames( @@ -276,7 +261,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ) metrics.update( { - "bev_overlay_cpu_ms": bev_overlay_cpu_ms, "model_step_cpu_ms": model_step_cpu_ms, "chunk_duration_ms": chunk_duration_ms, } @@ -323,11 +307,11 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: metrics=metrics, ), ] - if bev_overlay is not None: + if bev is not None: results.append( StepResult( step_index=step_index, - output=bev_overlay, + output=bev, frame_count=count, output_layout=VideoTensorLayout.tchw, ) @@ -345,7 +329,7 @@ def close(self) -> None: class CrazyRobotaxiSession(ISession): - """Register the model loop and Crazy Robotaxi SlangPy UI loop.""" + """Register the model loop and Crazy Robotaxi Dear ImGui UI loop.""" def __init__( self, @@ -372,7 +356,7 @@ def init(self) -> None: profile_input_latency=self._config.profile_input_latency, ) ui_loop = self.register_ui_loop( - CrazyRobotaxiSlangPyUILoop, + CrazyRobotaxiImGuiUILoop, state=hud_state, width=self._session_desc.video_width, height=self._session_desc.video_height, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 73164aea5..61b82c0c1 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""ImGui HUD and presentation state for Crazy Robotaxi.""" +"""Dear ImGui HUD and presentation state for Crazy Robotaxi.""" from __future__ import annotations @@ -35,7 +35,7 @@ from crazy_robotaxi.rules import TaxiGameSnapshot from crazy_robotaxi.world_overlay import render_waypoint_layers from flashdreams.api_v2.loop import ILoop, invoke_async -from flashdreams.runtime_v2.slangpy_ui_loop import SlangPyUILoop +from flashdreams.runtime_v2.imgui_ui_loop import ImGuiUILoop from flashdreams.runtime_v2.user_input_event import ( FocusUserInputEvent, KeyboardInputState, @@ -81,34 +81,9 @@ class TaxiHudFrame: """Cumulative transitions displaced by fixed-size model chunks.""" -@dataclass(slots=True) -class _HudWidgets: - """Retained SlangPy widget handles owned by the UI thread.""" - - status_window: Any - score: Any - time: Any - objective: Any - fare_time: Any - event: Any - navigation_window: Any - navigation: Any - terminal_window: Any - terminal_title: Any - terminal_score: Any - terminal_rank: Any - name_input: Any - submit: Any - validation: Any - leaderboard: Any - input_window: Any | None - input_state: Any | None - input_latency: Any | None - - @dataclass(slots=True) class TaxiHudState: - """Mutable ImGui state owned exclusively by the V2 UI thread.""" + """Mutable Dear ImGui state owned exclusively by the V2 UI thread.""" width: int """Presentation width in pixels.""" @@ -137,8 +112,14 @@ class TaxiHudState: _waypoint_layer: Tensor | None = None """Cached world overlay for the currently presented generated frame.""" - _widgets: _HudWidgets | None = None - """Lazily created retained widgets.""" + _name_input: str = "" + """Immediate-mode name-entry buffer retained by the UI state.""" + + _bev_source_key: tuple[int, tuple[int, ...]] | None = None + """Identity and shape of the raw BEV frame cached for ImGui upload.""" + + _bev_pixels: npt.NDArray[np.uint8] | None = None + """Cached HWC RGB bytes for the currently presented BEV frame.""" _validation_message: str = "" """Name-entry validation or submission status.""" @@ -269,40 +250,58 @@ def waypoint_layer(self, frame: Tensor) -> Tensor | None: self._waypoint_layer = layer return layer - def draw(self, ui: Any, ui_tick: int = 0) -> None: - """Create or update the retained ImGui HUD widget tree.""" - widgets = self._ensure_widgets(ui) - self._update_input_diagnostic(widgets) + def draw( + self, + imgui: Any, + ui_tick: int = 0, + *, + bev_frame: Tensor | None = None, + ) -> None: + """Draw one immediate Dear ImGui HUD frame.""" hud_frame = self._current if hud_frame is None: dots = "." * (1 + (ui_tick // 15) % 3) elapsed_s = max(0, int(time.monotonic() - self._loading_started_at_s)) - widgets.status_window.visible = True - widgets.score.text = f"{self._loading_status}{dots}" - widgets.time.text = f"ELAPSED {elapsed_s}s" - widgets.objective.text = "" - widgets.fare_time.text = "" - widgets.event.text = "" - widgets.navigation_window.visible = False - widgets.terminal_window.visible = False + self._draw_text_window( + imgui, + "Crazy Robotaxi", + position=(14.0, 14.0), + size=(360.0, 104.0), + lines=(f"{self._loading_status}{dots}", f"ELAPSED {elapsed_s}s"), + ) return snapshot = hud_frame.snapshot - widgets.status_window.visible = snapshot.session_state == "playing" - widgets.score.text = _score_label(snapshot) - widgets.time.text = f"GAME TIME {snapshot.global_remaining_time_s:05.1f}s" - objective = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" - widgets.objective.text = f"{objective} {snapshot.distance_m:04.0f} m" - widgets.fare_time.text = ( - "" - if snapshot.remaining_time_s is None - else f"FARE TIME {snapshot.remaining_time_s:04.1f}s" - ) - widgets.event.text = _event_label(snapshot) - - widgets.navigation_window.visible = snapshot.session_state == "playing" - widgets.navigation.text = _navigation_label(snapshot.relative_bearing_rad) - self._update_terminal(widgets, snapshot) + if snapshot.session_state == "playing": + objective = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" + fare_time = ( + "" + if snapshot.remaining_time_s is None + else f"FARE TIME {snapshot.remaining_time_s:04.1f}s" + ) + self._draw_text_window( + imgui, + "Crazy Robotaxi", + position=(14.0, 14.0), + size=(360.0, 190.0), + lines=( + _score_label(snapshot), + f"GAME TIME {snapshot.global_remaining_time_s:05.1f}s", + f"{objective} {snapshot.distance_m:04.0f} m", + fare_time, + _event_label(snapshot), + ), + ) + self._draw_text_window( + imgui, + "Navigation", + position=(float(self.width - 294), 14.0), + size=(280.0, 92.0), + lines=(_navigation_label(snapshot.relative_bearing_rad),), + ) + self._draw_bev_window(imgui, bev_frame) + self._draw_terminal(imgui, snapshot) + self._draw_input_diagnostic(imgui) def reset(self) -> None: """Clear per-generation HUD snapshots and editable UI state.""" @@ -318,103 +317,83 @@ def reset(self) -> None: self._input_received_at_s.clear() self._reported_input_timestamps_us.clear() self._latest_input_latency_ms = None - if self._widgets is not None: - self._widgets.name_input.value = "" - self._widgets.name_input.enabled = True - self._widgets.submit.enabled = True - - def _ensure_widgets(self, ui: Any) -> _HudWidgets: - if self._widgets is not None: - return self._widgets - status_window = ui.Window( - ui.screen, - "Crazy Robotaxi", - position=(14.0, 14.0), - size=(360.0, 190.0), - ) - score = ui.Text(status_window, "LOADING WORLD MODEL") - time = ui.Text(status_window, "") - objective = ui.Text(status_window, "") - fare_time = ui.Text(status_window, "") - event = ui.Text(status_window, "") - - navigation_window = ui.Window( - ui.screen, - "Navigation", - position=(float(self.width - 294), 14.0), - size=(280.0, 92.0), - ) - navigation = ui.Text(navigation_window, "") - - terminal_window = ui.Window( - ui.screen, - "Game Over", - position=( - float(max(20, self.width // 2 - 270)), - float(max(20, self.height // 2 - 220)), - ), - size=(540.0, 440.0), - ) - terminal_title = ui.Text(terminal_window, "") - terminal_score = ui.Text(terminal_window, "") - terminal_rank = ui.Text(terminal_window, "") - name_input = ui.InputText( - terminal_window, - "Driver name", - value="", - callback=self._submit_name, - flags=ui.InputTextFlags.enter_returns_true, - ) - submit = ui.Button( - terminal_window, - "Submit score", - callback=self._submit_name_from_button, + self._name_input = "" + self._bev_source_key = None + self._bev_pixels = None + + def _draw_text_window( + self, + imgui: Any, + title: str, + *, + position: tuple[float, float], + size: tuple[float, float], + lines: Sequence[str], + ) -> None: + _prepare_window(imgui, position=position, size=size) + visible = _begin_window(imgui, title) + try: + if visible: + for line in lines: + if line: + imgui.text(line) + finally: + imgui.end() + + def _draw_bev_window(self, imgui: Any, bev_frame: Tensor | None) -> None: + if bev_frame is None: + return + image_size = min(self.width // 4, self.height // 3) + if image_size <= 4: + return + pixels = self._bev_image_pixels(bev_frame) + padding = 16 + title_height = 34 + window_size = (float(image_size + padding), float(image_size + title_height)) + margin = float(max(8, min(self.width, self.height) // 80)) + position = ( + float(self.width) - window_size[0] - margin, + float(self.height) - window_size[1] - margin, ) - validation = ui.Text(terminal_window, "") - leaderboard = ui.Text(terminal_window, "") - input_window = None - input_state = None - input_latency = None - if self.profile_input_latency: - input_window = ui.Window( - ui.screen, - "Input Latency", - position=(14.0, float(max(14, self.height - 124))), - size=(440.0, 110.0), - ) - input_state = ui.Text(input_window, "") - input_latency = ui.Text(input_window, "") - self._widgets = _HudWidgets( - status_window=status_window, - score=score, - time=time, - objective=objective, - fare_time=fare_time, - event=event, - navigation_window=navigation_window, - navigation=navigation, - terminal_window=terminal_window, - terminal_title=terminal_title, - terminal_score=terminal_score, - terminal_rank=terminal_rank, - name_input=name_input, - submit=submit, - validation=validation, - leaderboard=leaderboard, - input_window=input_window, - input_state=input_state, - input_latency=input_latency, + _prepare_window(imgui, position=position, size=window_size, alpha=0.82) + visible = _begin_window(imgui, "Map") + try: + if visible: + imgui.image( + "crazy_robotaxi_bev", + pixels, + size=(float(image_size), float(image_size)), + ) + finally: + imgui.end() + + def _bev_image_pixels(self, frame: Tensor) -> npt.NDArray[np.uint8]: + if frame.ndim != 3 or frame.shape[0] != 3: + raise ValueError("BEV presentation frames must use [3,H,W]") + source_key = (int(frame.data_ptr()), tuple(int(value) for value in frame.shape)) + if source_key == self._bev_source_key and self._bev_pixels is not None: + return self._bev_pixels + pixels = ( + frame.detach() + .to(dtype=torch.float32) + .add(1.0) + .mul(127.5) + .clamp_(0.0, 255.0) + .to(torch.uint8) + .permute(1, 2, 0) + .contiguous() + .cpu() + .numpy() ) - return self._widgets + self._bev_source_key = source_key + self._bev_pixels = np.ascontiguousarray(pixels) + return self._bev_pixels - def _update_input_diagnostic(self, widgets: _HudWidgets) -> None: + def _draw_input_diagnostic(self, imgui: Any) -> None: if not self.profile_input_latency: return - assert widgets.input_window is not None - assert widgets.input_state is not None - assert widgets.input_latency is not None pressed = self._profile_pressed - widgets.input_state.text = " ".join( + input_state = " ".join( f"{label} [{'X' if bool(keys & pressed) else ' '}]" for label, keys in ( ("W", {"w", "up"}), @@ -439,43 +418,64 @@ def _update_input_diagnostic(self, widgets: _HudWidgets) -> None: if latency is None else f"UI TO MODEL FRAME {latency:.1f} ms" ) - widgets.input_latency.text = f"{latency_label}\n{counts}" + self._draw_text_window( + imgui, + "Input Latency", + position=(14.0, float(max(14, self.height - 124))), + size=(440.0, 110.0), + lines=(input_state, latency_label, counts), + ) - def _update_terminal( - self, widgets: _HudWidgets, snapshot: TaxiGameSnapshot - ) -> None: + def _draw_terminal(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: awaiting_name = snapshot.session_state == "awaiting_name" leaderboard = snapshot.session_state == "leaderboard" - widgets.terminal_window.visible = awaiting_name or leaderboard if not (awaiting_name or leaderboard): return - widgets.terminal_title.text = ( - "NEW HIGH SCORE" if awaiting_name else "LEADERBOARD" - ) - widgets.terminal_score.text = f"FINAL SCORE {snapshot.score:06d}" - widgets.terminal_rank.text = ( - "" - if snapshot.high_score_rank is None - else f"RANK #{snapshot.high_score_rank}" - ) - widgets.name_input.visible = awaiting_name - widgets.submit.visible = awaiting_name - widgets.name_input.enabled = awaiting_name and not self._submission_pending - widgets.submit.enabled = awaiting_name and not self._submission_pending - widgets.validation.visible = awaiting_name - widgets.validation.text = self._validation_message - widgets.leaderboard.visible = leaderboard - widgets.leaderboard.text = ( - "\n".join( - f"{rank:>2}. {entry.name:<12} {entry.score:>7}" - for rank, entry in enumerate(snapshot.leaderboard, start=1) - ) - or "NO SCORES YET" + _prepare_window( + imgui, + position=( + float(max(20, self.width // 2 - 270)), + float(max(20, self.height // 2 - 220)), + ), + size=(540.0, 440.0), ) - - def _submit_name_from_button(self) -> None: - assert self._widgets is not None - self._submit_name(str(self._widgets.name_input.value)) + visible = _begin_window(imgui, "Game Over") + try: + if not visible: + return + imgui.text("NEW HIGH SCORE" if awaiting_name else "LEADERBOARD") + imgui.text(f"FINAL SCORE {snapshot.score:06d}") + if snapshot.high_score_rank is not None: + imgui.text(f"RANK #{snapshot.high_score_rank}") + if awaiting_name: + disabled = self._submission_pending + if disabled: + imgui.begin_disabled() + try: + submitted, self._name_input = imgui.input_text( + "Driver name", + self._name_input, + flags=imgui.InputTextFlags_.enter_returns_true, + ) + clicked = imgui.button("Submit score") + finally: + if disabled: + imgui.end_disabled() + if submitted or clicked: + self._submit_name(self._name_input) + if self._validation_message: + imgui.text(self._validation_message) + else: + entries = ( + "\n".join( + f"{rank:>2}. {entry.name:<12} {entry.score:>7}" + for rank, entry in enumerate(snapshot.leaderboard, start=1) + ) + or "NO SCORES YET" + ) + imgui.text(entries) + finally: + imgui.end() def _submit_name(self, value: str) -> None: if self._submission_pending: @@ -491,43 +491,33 @@ def _submit_name(self, value: str) -> None: return self._submission_pending = True self._validation_message = "Submitting score..." - if self._widgets is not None: - self._widgets.name_input.enabled = False - self._widgets.submit.enabled = False invoke_async( model_loop, lambda state, name=normalized: state.submit_player_name(name), ) -class CrazyRobotaxiSlangPyUILoop(SlangPyUILoop[TaxiHudState]): - """Present generated frames beneath a responsive SlangPy taxi HUD.""" +class CrazyRobotaxiImGuiUILoop(ImGuiUILoop[TaxiHudState]): + """Present generated frames beneath a responsive Dear ImGui taxi HUD.""" def step_ui( - self, ui: Any, step_index: int, events: UserInputEvents + self, imgui: Any, step_index: int, events: UserInputEvents ) -> Tensor | None: - """Draw the HUD and return the current generated frame beneath it.""" + """Draw the HUD and return the generated world frame beneath it.""" self.state.consume_input_events(events) frames = self.presented_model_frames() video = frames[0] if frames else None - bev_overlay = frames[1] if len(frames) > 1 else None + bev_frame = frames[1] if len(frames) > 1 else None if video is not None: self.state.select_presented_frame(video) - self.state.draw(ui, step_index) + self.state.draw(imgui, step_index, bev_frame=bev_frame) if video is None: return None world = video.to(torch.float32) waypoints = self.state.waypoint_layer(video) if waypoints is not None: world = self._presentation_manager.composite(world, waypoints) - if bev_overlay is None: - return world - if bev_overlay.shape[0] != 4: - raise ValueError("BEV presentation frames must use [4,H,W]") - return self._presentation_manager.composite( - world, - bev_overlay.to(device=video.device, dtype=torch.float32), - ) + return world def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" @@ -576,6 +566,31 @@ def build_hud_frames( return tuple(frames) +def _prepare_window( + imgui: Any, + *, + position: tuple[float, float], + size: tuple[float, float], + alpha: float = 0.72, +) -> None: + """Set deterministic overlay geometry for the next ImGui window.""" + imgui.set_next_window_pos(imgui.ImVec2(*position), imgui.Cond_.always) + imgui.set_next_window_size(imgui.ImVec2(*size), imgui.Cond_.always) + imgui.set_next_window_bg_alpha(alpha) + + +def _begin_window(imgui: Any, title: str) -> bool: + """Begin a fixed HUD window and normalize ImGui's binding return form.""" + flags = 0 + window_flags = imgui.WindowFlags_ + for name in ("no_move", "no_resize", "no_collapse", "no_saved_settings"): + flags |= int(getattr(window_flags, name)) + result = imgui.begin(title, flags=flags) + if isinstance(result, tuple): + return bool(result[0]) + return bool(result) + + def _normalize_profile_key(key: str) -> str: if key == " ": return "space" @@ -623,7 +638,7 @@ def _navigation_label(bearing_rad: float) -> str: __all__ = [ - "CrazyRobotaxiSlangPyUILoop", + "CrazyRobotaxiImGuiUILoop", "TaxiHudFrame", "TaxiHudState", "build_hud_frames", diff --git a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py index 1d57e2f9d..9abc44792 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py @@ -23,7 +23,6 @@ import numpy as np import torch -import torch.nn.functional as functional from omnidreams_game_engine.camera import FThetaCameraModel from omnidreams_game_engine.types import CameraCalibration from torch import Tensor @@ -82,61 +81,6 @@ """Tiny dependency-free glyphs used for world-anchored marker labels.""" -def render_bev_overlay( - bev_tchw: Tensor, - *, - width: int, - height: int, -) -> Tensor: - """Render raw BEV frames into frame-aligned transparent RGBA layers. - - Args: - bev_tchw: Floating-point BEV frames in ``[T,3,H,W]``. - width: Output width in pixels. - height: Output height in pixels. - - Returns: - Floating-point layers in ``[T,4,H,W]`` with the BEV panel in the - bottom-right corner and alpha in ``[0,1]``. - - Raises: - ValueError: The BEV tensor or output dimensions are invalid. - """ - if bev_tchw.ndim != 4 or bev_tchw.shape[1] != 3: - raise ValueError("BEV frames must use [T,3,H,W]") - if not bev_tchw.is_floating_point(): - raise ValueError("BEV overlays require floating-point frames") - if width <= 0 or height <= 0: - raise ValueError("BEV overlay dimensions must be positive") - - overlay = torch.zeros( - (int(bev_tchw.shape[0]), 4, height, width), - device=bev_tchw.device, - dtype=bev_tchw.dtype, - ) - size = min(width // 4, height // 3) - if size <= 4: - return overlay - - panel = functional.interpolate( - bev_tchw, - size=(size, size), - mode="bilinear", - align_corners=False, - ) - margin = max(8, min(width, height) // 80) - top = height - size - margin - left = width - size - margin - target = overlay[:, :, top : top + size, left : left + size] - target[:, :3].copy_(panel) - target[:, 3].fill_(0.82) - target[:, :, :2, :].fill_(1.0) - target[:, :, -2:, :].fill_(1.0) - target[:, :, :, :2].fill_(1.0) - target[:, :, :, -2:].fill_(1.0) - return overlay - - def render_waypoint_layers( snapshots: Sequence[object], rig_poses_world: np.ndarray, @@ -485,4 +429,4 @@ def _materialize_waypoint_layers( return palette[style_tensor.to(torch.int64) + 1].permute(0, 3, 1, 2).contiguous() -__all__ = ["render_bev_overlay", "render_waypoint_layers"] +__all__ = ["render_waypoint_layers"] diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 61d4a9008..14c9a1831 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -14,7 +14,7 @@ from crazy_robotaxi.application import _MODEL_PRESETS, CrazyRobotaxiApplication from crazy_robotaxi.physics import TaxiPhysicsWorld from crazy_robotaxi.session import CrazyRobotaxiModelLoop, CrazyRobotaxiSession -from crazy_robotaxi.ui import CrazyRobotaxiSlangPyUILoop +from crazy_robotaxi.ui import CrazyRobotaxiImGuiUILoop from omnidreams.config import ( RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, @@ -66,7 +66,7 @@ def _scene(*, width: int = 1280, height: int = 704) -> SceneDefinition: ) -def test_application_registers_model_and_slangpy_ui_loops() -> None: +def test_application_registers_model_and_imgui_ui_loops() -> None: pipeline = object() app = CrazyRobotaxiApplication( pipeline_factory=lambda config, device: pipeline, @@ -91,7 +91,7 @@ def test_application_registers_model_and_slangpy_ui_loops() -> None: assert desc.frames_per_second_for_ui == 30 assert desc.frames_per_second_for_step == 30 assert isinstance(model_loop, CrazyRobotaxiModelLoop) - assert isinstance(ui_loop, CrazyRobotaxiSlangPyUILoop) + assert isinstance(ui_loop, CrazyRobotaxiImGuiUILoop) assert model_loop.state.pipeline is pipeline assert model_loop.state.rollout is None assert model_loop.state.ui_loop is ui_loop diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 9e8d78ca1..daf53611d 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""CPU tests for Crazy Robotaxi's V2 SlangPy UI loop.""" +"""CPU tests for Crazy Robotaxi's V2 Dear ImGui UI loop.""" from __future__ import annotations @@ -14,7 +14,6 @@ import numpy as np import pytest -import slangpy as spy import torch from crazy_robotaxi.rules import ( TaxiGameSnapshot, @@ -22,13 +21,12 @@ project_taxi_markers_to_camera, ) from crazy_robotaxi.ui import ( - CrazyRobotaxiSlangPyUILoop, + CrazyRobotaxiImGuiUILoop, TaxiHudState, build_hud_frames, ) from crazy_robotaxi.world_overlay import ( _paint_lines, - render_bev_overlay, render_waypoint_layers, ) from omnidreams_game_engine.types import CameraCalibration @@ -105,46 +103,79 @@ def _legacy_line_pixels( return np.concatenate(x_parts), np.concatenate(y_parts) -class _Widget: - def __init__( +class _FakeImGui: + Cond_ = SimpleNamespace(always=1) + WindowFlags_ = SimpleNamespace( + no_move=1, + no_resize=2, + no_collapse=4, + no_saved_settings=8, + ) + InputTextFlags_ = SimpleNamespace(enter_returns_true=1) + + def __init__(self) -> None: + self.windows: dict[str, list[str]] = {} + self.images: list[tuple[str, np.ndarray, tuple[float, float]]] = [] + self.current_window: str | None = None + self.input_value = "" + self.submit_input = False + self.click_submit = False + + @staticmethod + def ImVec2(x: float, y: float) -> tuple[float, float]: + return x, y + + def set_next_window_pos(self, position, condition) -> None: + del position, condition + + def set_next_window_size(self, size, condition) -> None: + del size, condition + + def set_next_window_bg_alpha(self, alpha) -> None: + del alpha + + def begin(self, title: str, *, flags: int) -> bool: + del flags + self.current_window = title + self.windows.setdefault(title, []) + return True + + def end(self) -> None: + self.current_window = None + + def text(self, value: str) -> None: + assert self.current_window is not None + self.windows[self.current_window].append(value) + + def image( self, - parent: object, - label: str = "", + key: str, + pixels: np.ndarray, *, - value: Any = None, - callback: Any = None, - position: tuple[float, float] = (0.0, 0.0), - size: tuple[float, float] = (0.0, 0.0), - **kwargs: Any, + size: tuple[float, float], ) -> None: - del parent, kwargs - self.label = label - self.title = label - self.text = label - self.value = value - self.callback = callback - self.position = position - self.size = size - self.visible = True - self.enabled = True - - -class _FakeUI: - Window = _Widget - Text = _Widget - InputText = _Widget - Button = _Widget - InputTextFlags = SimpleNamespace(enter_returns_true=1) + self.images.append((key, pixels, size)) - def __init__(self) -> None: - self.screen = object() + def input_text(self, label: str, value: str, *, flags: int): + del label, value, flags + return self.submit_input, self.input_value + + def button(self, label: str) -> bool: + del label + return self.click_submit + + def begin_disabled(self) -> None: + return + + def end_disabled(self) -> None: + return class _Renderer: def __init__(self, width: int, height: int) -> None: self.width = width self.height = height - self.ui = _FakeUI() + self.ui = _FakeImGui() self.reset_count = 0 self.closed = False @@ -308,29 +339,10 @@ def project_world(self, points, rig_to_world): assert [projection.distance_m for projection in projections] == [10.0, 20.0, 30.0] -def test_bev_is_rendered_as_frame_aligned_rgba_overlay() -> None: - overlay = render_bev_overlay( - torch.full((1, 3, 32, 32), 0.5), - width=160, - height=96, - ) - - assert overlay.shape == (1, 4, 96, 160) - assert overlay.dtype is torch.float32 - assert torch.count_nonzero(overlay[:, 3, :56, :]) == 0 - assert overlay[0, 3, 56, 120] == 1.0 - assert overlay[0, 3, 58, 122] == pytest.approx(0.82) - assert torch.all(overlay[0, :3, 58, 122] == 0.5) - - -def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: +def test_imgui_ui_loop_composites_waypoints_and_draws_bev_in_a_window() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) - bev_overlay = render_bev_overlay( - torch.full((1, 3, 32, 32), 0.5), - width=width, - height=height, - ) + bev = torch.full((1, 3, 32, 32), 0.5) hud_state = TaxiHudState(width, height, _calibration()) hud_state.publish( build_hud_frames( @@ -344,12 +356,12 @@ def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: 0, [ StepResult(0, video, 1, VideoTensorLayout.tchw), - StepResult(0, bev_overlay, 1, VideoTensorLayout.tchw), + StepResult(0, bev, 1, VideoTensorLayout.tchw), ], ) changed, _ = presentation.advance(0) renderer = _Renderer(width, height) - loop = CrazyRobotaxiSlangPyUILoop( + loop = CrazyRobotaxiImGuiUILoop( renderer=renderer, ) loop.register_session_loop_objects( @@ -369,25 +381,31 @@ def test_slangpy_ui_loop_composites_world_waypoints_then_bev_and_ui() -> None: assert result.output.shape == (1, 3, height, width) assert result.output.dtype is torch.float32 assert hud_state._current is not None - assert hud_state._widgets is not None - assert hud_state._widgets.score.text == "SCORE 001200 HIGH 009000" - assert hud_state._widgets.navigation.text == "TARGET AHEAD 0 deg" - assert not hasattr(hud_state._widgets, "marker_windows") + assert "SCORE 001200 HIGH 009000" in renderer.ui.windows["Crazy Robotaxi"] + assert "TARGET AHEAD 0 deg" in renderer.ui.windows["Navigation"] + assert len(renderer.ui.images) == 1 + image_key, pixels, image_size = renderer.ui.images[0] + assert image_key == "crazy_robotaxi_bev" + assert pixels.shape == (32, 32, 3) + assert np.all(pixels == 191) + assert image_size == (32.0, 32.0) assert result.output[0, 0, 48, 80] != -0.5 - assert result.output[0, 0, 58, 122] == pytest.approx(0.32) assert torch.any(result.output != video) cached_waypoints = hud_state._waypoint_layer + cached_bev = hud_state._bev_pixels loop.step(1, UserInputEvents([])) assert hud_state._waypoint_layer is cached_waypoints + assert hud_state._bev_pixels is cached_bev loop.reset() assert hud_state._current is None assert hud_state._waypoint_layer is None + assert hud_state._bev_pixels is None assert renderer.reset_count == 1 -def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: +def test_hud_draws_immediate_imgui_windows() -> None: state = TaxiHudState(160, 96, _calibration()) state.publish( build_hud_frames( @@ -397,41 +415,24 @@ def test_hud_builds_real_slangpy_imgui_widgets_without_a_renderer() -> None: ) ) state._current = next(iter(state._frames.values())) - ui = SimpleNamespace( - screen=None, - Window=spy.ui.Window, - Text=spy.ui.Text, - InputText=spy.ui.InputText, - Button=spy.ui.Button, - InputTextFlags=spy.ui.InputTextFlags, - ) + imgui = _FakeImGui() - state.draw(ui) + state.draw(imgui) - assert state._widgets is not None - assert isinstance(state._widgets.status_window, spy.ui.Window) - assert isinstance(state._widgets.name_input, spy.ui.InputText) - assert state._widgets.score.text == "SCORE 001200 HIGH 009000" + assert set(imgui.windows) == {"Crazy Robotaxi", "Navigation"} + assert "SCORE 001200 HIGH 009000" in imgui.windows["Crazy Robotaxi"] def test_hud_animates_prepresentation_warmup_status() -> None: state = TaxiHudState(160, 96, _calibration()) state.set_loading_status("WARMING WORLD MODEL 2/4") - ui = SimpleNamespace( - screen=None, - Window=spy.ui.Window, - Text=spy.ui.Text, - InputText=spy.ui.InputText, - Button=spy.ui.Button, - InputTextFlags=spy.ui.InputTextFlags, - ) + imgui = _FakeImGui() - state.draw(ui, ui_tick=30) + state.draw(imgui, ui_tick=30) - assert state._widgets is not None - assert state._widgets.status_window.visible - assert state._widgets.score.text == "WARMING WORLD MODEL 2/4..." - assert state._widgets.time.text.startswith("ELAPSED ") + lines = imgui.windows["Crazy Robotaxi"] + assert lines[0] == "WARMING WORLD MODEL 2/4..." + assert lines[1].startswith("ELAPSED ") def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: @@ -466,34 +467,28 @@ def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: ) state.select_presented_frame(video[0]) - state.draw(_FakeUI()) + imgui = _FakeImGui() + state.draw(imgui) assert state._latest_input_latency_ms is not None - assert state._widgets is not None - assert state._widgets.input_window is not None - input_state = state._widgets.input_state - input_latency = state._widgets.input_latency - assert input_state is not None - assert input_latency is not None - assert "A [X]" in input_state.text - assert "UI TO MODEL FRAME" in input_latency.text - assert "TRANSITIONS 1" in input_latency.text - assert "IGNORED 2" in input_latency.text + diagnostics = imgui.windows["Input Latency"] + assert "A [X]" in diagnostics[0] + assert "UI TO MODEL FRAME" in diagnostics[1] + assert "TRANSITIONS 1" in diagnostics[2] + assert "IGNORED 2" in diagnostics[2] state.reset() assert not state._profile_pressed assert state._latest_input_latency_ms is None -def test_input_latency_widgets_are_absent_by_default() -> None: +def test_input_latency_window_is_absent_by_default() -> None: state = TaxiHudState(160, 96, _calibration()) + imgui = _FakeImGui() - state.draw(_FakeUI()) + state.draw(imgui) - assert state._widgets is not None - assert state._widgets.input_window is None - assert state._widgets.input_state is None - assert state._widgets.input_latency is None + assert "Input Latency" not in imgui.windows def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: @@ -506,11 +501,24 @@ def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: failure_queue=queue.Queue(), ) state.model_loop = model_loop + video = torch.zeros(1, 3, 96, 160) + state.publish( + build_hud_frames( + video, + (_snapshot(session_state="awaiting_name"),), + np.eye(4, dtype=np.float32)[None], + ) + ) + state.select_presented_frame(video[0]) + imgui = _FakeImGui() + imgui.input_value = " DRIVER 7 " + imgui.click_submit = True - state._submit_name(" DRIVER 7 ") - state._submit_name("SECOND") + state.draw(imgui) + state.draw(imgui) assert model_loop.state.names == [] model_loop._run_message_batch() assert model_loop.state.names == ["DRIVER 7"] assert state._submission_pending + assert "Game Over" in imgui.windows diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 7528e4d41..288ac5cab 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -15,7 +15,7 @@ not part of this design. input-device worker is created by the game engine. 3. The model thread exclusively owns mutable simulation, game, conditioning, autoregressive-cache, and driving-input state. -4. The UI thread owns all HUD widgets, name-entry state, and presentation. +4. The UI thread owns all HUD interaction state, name entry, and presentation. Immutable HUD frame batches cross from model to UI through V2 `invoke_async`; neither thread reads the other's mutable state. 5. The loaded OmniDreams pipeline is application-owned and reusable; every @@ -38,7 +38,7 @@ flowchart TD subgraph SESSION["CrazyRobotaxiSession — one run"] SCENE["immutable SceneDefinition
map, camera, seed, prompt"] - REG["register one model loop
register one SlangPy UI loop"] + REG["register one model loop
register one Dear ImGui UI loop"] end subgraph MODELTHREAD["FlashDreams V2 model thread"] @@ -49,9 +49,9 @@ flowchart TD end subgraph UITHREAD["FlashDreams V2 UI thread"] - BUFFER["PresentationManager
RGB + optional RGBA BEV channels"] - HUD["CrazyRobotaxiSlangPyUILoop
waypoint cache + HUD widgets"] - COMPOSE["RGB → waypoint → BEV composition
then SlangPy ImGui HUD"] + BUFFER["PresentationManager
RGB + optional raw BEV frames"] + HUD["CrazyRobotaxiImGuiUILoop
waypoint + BEV pixel caches"] + COMPOSE["RGB → in-world waypoint composition
then Dear ImGui HUD + BEV window"] WINDOW["V2 client window
native / WebRTC / MP4"] end @@ -63,7 +63,7 @@ flowchart TD PIPELINE --> CACHE INPUT --> ENGINE ENGINE --> HUDSTATE - ENGINE -->|"RGB + optional RGBA BEV"| BUFFER + ENGINE -->|"RGB + optional raw BEV"| BUFFER HUDSTATE -->|"invoke_async"| HUD HUD -->|"invoke_async game actions"| ENGINE BUFFER --> HUD --> COMPOSE --> WINDOW @@ -75,18 +75,18 @@ application-owned options. `create_session()` validates the requested description, lazily constructs the shared pipeline, prepares immutable scene data, and returns an uninitialized session. -The session registers a model loop and a `CrazyRobotaxiSlangPyUILoop`. The -model loop emits generated RGB frames and, when configured, a frame-aligned -transparent BEV layer. For each generated frame it publishes an immutable +The session registers a model loop and a `CrazyRobotaxiImGuiUILoop`. The model +loop emits generated RGB frames and, when configured, the synchronized raw BEV +frame. For each generated frame it publishes an immutable `TaxiHudFrame` containing its game snapshot and camera pose to the UI loop with `invoke_async`. The UI loop selects the metadata aligned with the currently presented model frame, rasterizes and caches that frame's in-world waypoint -geometry, and uses V2's `PresentationManager` to compose waypoints and BEV over -the generated frame. This avoids constructing an entire chunk of full-size -waypoint tensors at the model-step boundary. It updates its retained ImGui -widgets and returns the composed frame to `SlangPyUILoop`, which applies the -widget overlay. UI callbacks send validated game actions back to the model loop -with `invoke_async`; they never call game state directly. +geometry, and uses V2's `PresentationManager` to compose only those in-world +waypoints over the generated frame. This avoids constructing an entire chunk +of full-size waypoint tensors at the model-step boundary. It draws the HUD and +raw BEV in immediate Dear ImGui windows; `ImGuiUILoop` composites that UI layer +over the world frame. UI callbacks send validated game actions back to the +model loop with `invoke_async`; they never call game state directly. ## Pre-presentation startup @@ -136,10 +136,10 @@ flowchart LR GENERATE["pipeline.generate"] FINALIZE["pipeline.finalize"] POST["optional session-local postprocess stream"] - BEV["raw BEV frames → frame-aligned
RGBA presentation layers"] + BEV["raw BEV frames
small synchronized UI result"] HUDFRAMES["immutable TaxiHudFrame batch
snapshot + camera pose"] SEND["invoke_async(UI loop)"] - RESULTS["list[StepResult]
RGB + optional RGBA BEV"] + RESULTS["list[StepResult]
RGB + optional raw BEV"] EVENTS --> REDUCE COUNT --> COMMANDS @@ -179,19 +179,20 @@ trajectory = engine_step.trajectory hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] video = pipeline.generate(autoregressive_index, cache, hdmap) metrics = pipeline.finalize(autoregressive_index, cache) -bev_overlay = render_bev_overlay(condition.bev_tchw, output_size) invoke_async(ui_loop, publish_hud_frames(game_snapshots, poses, video)) -return [video StepResult, optional bev_overlay StepResult] +return [video StepResult, optional raw_bev StepResult] ``` -The UI loop uses V2's `SlangPyUILoop` and `presented_model_frames()`; -its `step_ui()` method renders a waypoint layer only when the presented frame -changes, composites the frame-aligned layers with -`PresentationManager.composite()`, then lets the base loop composite the -SlangPy widget overlay over that back buffer. Raw BEV resizing still happens -once per generated chunk. There is no image widget and there is no intermediate -render backend, local adapter, model session, chunk request, private command -queue, private frame queue, or legacy `PresentedFrame` aggregate. +The UI loop uses V2's `ImGuiUILoop` and `presented_model_frames()`; its +`step_ui()` method renders a waypoint layer only when the presented frame +changes and composites that in-world layer with +`PresentationManager.composite()`. It converts the small raw BEV frame to +cached RGB bytes only when the presented BEV changes and displays it through +the runtime-provided ImGui image bridge. The base loop composites the complete +Dear ImGui overlay over that world back buffer. There is no full-frame BEV +layer, retained SlangPy widget tree, intermediate app renderer, local adapter, +model session, chunk request, private command queue, private frame queue, or +legacy `PresentedFrame` aggregate. ## Game-engine internals @@ -257,7 +258,7 @@ flowchart LR FRAME["TaxiGameFrame"] HUDSTATE["TaxiHudFrame
immutable presentation state"] WAYPOINTS["camera-projected waypoint layer
ring + beacon + label"] - IMGUI["CrazyRobotaxiSlangPyUILoop
score, time, navigation,
name entry, leaderboard"] + IMGUI["CrazyRobotaxiImGuiUILoop
score, time, navigation, BEV,
name entry, leaderboard"] MAP --> NAV --> FARES TAXI --> TAXIPHYS @@ -271,7 +272,7 @@ flowchart LR Taxi-specific code supplies the generic engine with arcade vehicle dynamics, curb/chassis physics policy, map-derived navigation/fare regions, game rules, -passenger actors, persistent scores, and UI-thread ImGui widgets. It does not +passenger actors, persistent scores, and UI-thread ImGui controls. It does not subclass a game-engine application host or presenter. ## State and lifetime table @@ -281,7 +282,7 @@ subclass a game-engine application host or presenter. | Application | resolved pipeline configuration; one loaded OmniDreams pipeline | | Session, immutable | validated session description; compiled/loaded scene definition; game configuration | | Model loop, per reset generation | driving-input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | -| UI loop, per reset generation | retained ImGui widgets; immutable HUD-frame lookup; name-entry buffer; validation messages | +| UI loop, per reset generation | immediate ImGui state; immutable HUD-frame lookup; BEV pixel cache; name-entry buffer; validation messages | | Outside runtime | authored map YAML; derived map cache; high-score file | All simulation, condition-renderer, and world-model CUDA calls that mutate @@ -356,8 +357,8 @@ apps/crazy_robotaxi/ crazy_robotaxi/ application.py # IApplication composition root session.py # ISession + model/UI loop wiring - ui.py # ImGui HUD state and SlangPy UI loop - world_overlay.py # UI waypoint and frame-aligned BEV RGBA layers + ui.py # Dear ImGui HUD state and UI loop + world_overlay.py # UI-thread in-world waypoint rasterization rules.py # taxi fare state machine dynamics.py # arcade taxi controls physics.py # taxi collision policy diff --git a/flashdreams/flashdreams/api_v2/README.md b/flashdreams/flashdreams/api_v2/README.md index e871b9034..dee05ac8b 100644 --- a/flashdreams/flashdreams/api_v2/README.md +++ b/flashdreams/flashdreams/api_v2/README.md @@ -192,11 +192,14 @@ model-generation-loop produces frames: Use `PresentationMode.ON_DEMAND` with `BackpressureMode.BLOCK` when every generated model frame must be selected and written exactly once in order. -For widgets drawn over the model output, subclass `SlangPyUILoop` from -`flashdreams.runtime_v2.slangpy_ui_loop` and implement -`step_ui(ui, step_index, events)` rather than `step`. The +For full immediate Dear ImGui controls drawn over model output, subclass +`ImGuiUILoop` from `flashdreams.runtime_v2.imgui_ui_loop` and implement +`step_ui(imgui, step_index, events)` rather than `step`. Its `imgui` proxy +exposes `imgui_bundle.imgui` and an image-like pixel upload convenience form. +For SlangPy's smaller retained widget API, subclass `SlangPyUILoop` from +`flashdreams.runtime_v2.slangpy_ui_loop`. The [`slangpy_ui_demo` integration](../../../integrations_v2/slangpy_ui_demo/README.md) -is the reference, including one example that uses model output inside the UI. +remains the reference for that retained API. ## Where to go next diff --git a/flashdreams/flashdreams/runtime_v2/README.md b/flashdreams/flashdreams/runtime_v2/README.md index ee36bc996..f5aa0839e 100644 --- a/flashdreams/flashdreams/runtime_v2/README.md +++ b/flashdreams/flashdreams/runtime_v2/README.md @@ -207,9 +207,11 @@ enforces matching dimensions and device instead of silently repairing them. The default UI loop, `BlitModelOutputToScreenLoop`, composites every model channel in list order as if they were image layers and reshapes the result into -the session's layout. `SlangPyUILoop` is the alternative, for widgets drawn over -the model output; it returns a `[1, C, H, W]` frame, so a session using it -declares a `tchw` layout. +the session's layout. `SlangPyUILoop` is the alternative for SlangPy's retained +widget subset. `ImGuiUILoop` exposes the complete immediate Dear ImGui API and +adds `imgui.image(key, pixels, size=(width, height))` for app-owned RGB/RGBA +images. Both return a `[1, C, H, W]` frame, so a session using either declares +a `tchw` layout. `IClientWindow` is both an `InputSource` and an `OutputSink`, so a window is written to with the same three calls as any sink: `open` with the session diff --git a/flashdreams/test_v2/test_imgui_ui_renderer.py b/flashdreams/test_v2/test_imgui_ui_renderer.py new file mode 100644 index 000000000..95efd678a --- /dev/null +++ b/flashdreams/test_v2/test_imgui_ui_renderer.py @@ -0,0 +1,182 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU tests for the V2 Dear ImGui renderer and loop contracts.""" + +import queue +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +import numpy as np +import pytest +import torch +from numpy import uint64 + +from flashdreams.runtime_v2.imgui_ui_renderer import ( + _ImGui, + _rgba_pixels, + _route_imgui_input_events, +) +from flashdreams.runtime_v2.imgui_ui_loop import ImGuiUILoop +from flashdreams.runtime_v2.presentation_manager import PresentationManager +from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_event import ( + KeyboardInputState, + KeyboardUserInputEvent, + MouseUserInputEvent, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents +from flashdreams.runtime_v2.video_tensor import VideoTensorLayout + +pytestmark = pytest.mark.ci_cpu + + +def _slangpy_events() -> SimpleNamespace: + return SimpleNamespace( + KeyboardEvent=lambda: SimpleNamespace(), + KeyboardEventType=SimpleNamespace( + key_press="key_press", + key_release="key_release", + input="input", + ), + KeyCode=SimpleNamespace(space="space"), + KeyModifierFlags=SimpleNamespace(none="none"), + MouseButton=SimpleNamespace(left="left", middle="middle", right="right"), + MouseEvent=lambda: SimpleNamespace(), + MouseEventType=SimpleNamespace( + button_down="button_down", + button_up="button_up", + move="move", + scroll="scroll", + ), + ) + + +def test_imgui_input_uses_concrete_pr512_events() -> None: + bridge = Mock() + events = UserInputEvents( + [ + KeyboardUserInputEvent( + timestamp=uint64(0), + key=" ", + state=KeyboardInputState.PRESSED, + ), + MouseUserInputEvent( + timestamp=uint64(1), + action="button", + x=0.25, + y=0.75, + button=0, + pressed=True, + ), + ] + ) + + _route_imgui_input_events( + events, + slangpy=_slangpy_events(), + bridge=bridge, + width=400, + height=200, + ) + + key_event, text_event = [ + call.args[0] for call in bridge.handle_keyboard_event.call_args_list + ] + assert (key_event.type, key_event.key) == ("key_press", "space") + assert (text_event.type, text_event.codepoint) == ("input", ord(" ")) + mouse_event = bridge.handle_mouse_event.call_args.args[0] + assert mouse_event.type == "button_down" + assert mouse_event.button == "left" + assert mouse_event.pos == (100.0, 150.0) + + +def test_rgba_pixels_accepts_rgb_and_rejects_channel_first() -> None: + pixels = _rgba_pixels(np.full((3, 4, 3), 12, dtype=np.uint8)) + + assert pixels.shape == (3, 4, 4) + assert pixels.flags.c_contiguous + assert np.all(pixels[..., :3] == 12) + assert np.all(pixels[..., 3] == 255) + + with pytest.raises(ValueError, match="HWC RGB/RGBA"): + _rgba_pixels(torch.zeros(3, 4, 5)) + + +def test_imgui_pixel_images_reuse_textures_with_the_same_shape() -> None: + texture = Mock() + device = Mock() + device.create_texture.return_value = texture + imgui = SimpleNamespace( + ImVec2=lambda x, y: (x, y), + image=Mock(return_value="drawn"), + ) + bridge = SimpleNamespace(texture_ref=lambda value: ("texture", value)) + slangpy = SimpleNamespace( + Format=SimpleNamespace(rgba8_unorm_srgb="rgba8"), + TextureUsage=SimpleNamespace(shader_resource="shader_resource"), + ) + ui = _ImGui(device, slangpy, imgui, bridge) + pixels = np.zeros((8, 12, 3), dtype=np.uint8) + + assert ui.image("bev", pixels, size=(120.0, 80.0)) == "drawn" + ui.image("bev", pixels, size=(120.0, 80.0)) + + device.create_texture.assert_called_once() + assert texture.copy_from_numpy.call_count == 2 + imgui.image.assert_called_with(("texture", texture), (120.0, 80.0)) + + +class _Renderer: + def __init__(self) -> None: + self.reset_count = 0 + self.closed = False + + def render(self, step_index, events, step_ui): + step_ui(SimpleNamespace(), step_index, events) + return torch.zeros(4, 3, 4) + + def reset(self) -> None: + self.reset_count += 1 + + def close(self) -> None: + self.closed = True + + +class _Loop(ImGuiUILoop[None]): + def step_ui(self, imgui, step_index, events): + del imgui, step_index, events + frames = self.presented_model_frames() + return frames[0] if frames else None + + +def test_imgui_loop_composites_over_the_presented_model_frame() -> None: + video = torch.full((1, 3, 3, 4), -0.5) + presentation = PresentationManager() + presentation.publish( + 0, + [StepResult(0, video, 1, VideoTensorLayout.tchw)], + ) + presentation.advance(0) + renderer = _Renderer() + loop = _Loop(renderer=renderer) + loop.register_session_loop_objects( + state=None, + frequency=60, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + loop.register_session_ui_loop_objects( + output_layout=VideoTensorLayout.tchw, + presentation_manager=presentation, + ) + + result = loop.step(0, UserInputEvents([])) + + assert result.output.shape == (1, 3, 3, 4) + assert torch.all(result.output == -0.5) + loop.reset() + loop.close() + assert renderer.reset_count == 1 + assert renderer.closed From ee250e9ac55f8c1b23d2be1d9baed60ab8c48b75 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 03:40:02 +0000 Subject: [PATCH 16/62] Render Crazy Robotaxi waypoints with ImGui Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 16 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 67 ++- .../crazy_robotaxi/world_overlay.py | 498 +++++------------- apps/crazy_robotaxi/tests/test_ui.py | 137 +++-- docs/design/crazy_robotaxi_v2_architecture.md | 45 +- 5 files changed, 250 insertions(+), 513 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index d9f000407..f763b5c01 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -6,13 +6,13 @@ generations, presentation buffering, client windows, and the two-thread runtime. The UI loop uses FlashDreams' Dear ImGui renderer for the game HUD and composites it over model frames for native-window, WebRTC, and file clients. Camera-projected waypoint rings, beacons, and labels are rendered as a -frame-aligned world layer on the UI thread; they are not ImGui windows. The +background ImGui draw list on the UI thread; they are not windows or controls. The model loop publishes immutable snapshot-and-pose metadata for each generated -frame, and the UI loop rasterizes each presented waypoint layer once and -caches it while that frame remains visible. The optional raw BEV view is a -second model result displayed inside a real ImGui `Map` window. -`CrazyRobotaxiImGuiUILoop` composites the in-world waypoints over video before -the base loop applies the Dear ImGui HUD and BEV overlay. +frame, and the UI loop caches the projected marker geometry while that frame +remains visible. The optional raw BEV view is a second model result displayed +inside a real ImGui `Map` window. `CrazyRobotaxiImGuiUILoop` returns the video +back buffer and the base loop composites one ImGui overlay containing the +background waypoints, HUD, and BEV window. ```bash uv sync --package crazy-robotaxi @@ -100,8 +100,8 @@ duration of the frames it produces. The live `model_step_wall_ms`, `simulation_cpu_ms`, `rules_cpu_ms`, `conditioning_cpu_ms`, and `physx_*_ms` metrics separate a throughput miss from model-thread CPU work; the pipeline's `encode_ms`, `diffuse_ms`, `decode_ms`, and `finalize_ms` metrics identify the -corresponding GPU stage. Waypoint rasterization is UI-thread work and is not -included in model-step metrics. The JSON sink normalizes `_ms` metric names to +corresponding GPU stage. Waypoint projection and ImGui draw submission are +UI-thread work and are not included in model-step metrics. The JSON sink normalizes `_ms` metric names to `_s`. Exclude the first chunks when judging steady state because compilation and graph capture are startup costs. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 61b82c0c1..719fbfdc9 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -32,8 +32,13 @@ from torch import Tensor from crazy_robotaxi.high_scores import validate_player_name -from crazy_robotaxi.rules import TaxiGameSnapshot -from crazy_robotaxi.world_overlay import render_waypoint_layers +from crazy_robotaxi.rules import TaxiCameraMarkerProjection, TaxiGameSnapshot +from crazy_robotaxi.world_overlay import ( + draw_waypoints as draw_waypoint_markers, +) +from crazy_robotaxi.world_overlay import ( + project_waypoints, +) from flashdreams.api_v2.loop import ILoop, invoke_async from flashdreams.runtime_v2.imgui_ui_loop import ImGuiUILoop from flashdreams.runtime_v2.user_input_event import ( @@ -107,10 +112,10 @@ class TaxiHudState: """Snapshot aligned with the frame currently beneath ImGui.""" _waypoint_source: TaxiHudFrame | None = None - """Frame metadata used by the cached waypoint layer.""" + """Frame metadata used by the cached waypoint projections.""" - _waypoint_layer: Tensor | None = None - """Cached world overlay for the currently presented generated frame.""" + _waypoint_projections: tuple[TaxiCameraMarkerProjection, ...] = () + """Cached world-marker projections for the presented generated frame.""" _name_input: str = "" """Immediate-mode name-entry buffer retained by the UI state.""" @@ -219,36 +224,27 @@ def set_loading_status(self, status: str) -> None: """Update the startup phase from a model-loop message.""" self._loading_status = status - def waypoint_layer(self, frame: Tensor) -> Tensor | None: - """Return the cached marker layer aligned with ``frame``. - - Rasterizing one presented frame on the UI thread avoids constructing a - full chunk of large RGBA tensors at the model-step boundary. The V2 - presentation manager may show the same generated frame for multiple UI - ticks, so the completed layer is retained until frame metadata changes. - """ + def draw_waypoints(self, imgui: Any, frame: Tensor) -> None: + """Draw cached world-marker projections aligned with ``frame``.""" source = self._frames.get(int(frame.data_ptr())) if source is None: - return None - cached = self._waypoint_layer - if ( - source is self._waypoint_source - and cached is not None - and cached.device == frame.device - ): - return cached - layer = render_waypoint_layers( - (source.snapshot,), - source.rig_pose_world[None, ...], - self.calibration, + return + if source is not self._waypoint_source: + self._waypoint_projections = project_waypoints( + source.snapshot, + source.rig_pose_world, + self.calibration, + width=self.width, + height=self.height, + ) + self._waypoint_source = source + draw_waypoint_markers( + imgui, + self._waypoint_projections, + phase=source.snapshot.phase, width=self.width, height=self.height, - device=frame.device, - dtype=torch.float32, - )[0] - self._waypoint_source = source - self._waypoint_layer = layer - return layer + ) def draw( self, @@ -308,7 +304,7 @@ def reset(self) -> None: self._frames.clear() self._current = None self._waypoint_source = None - self._waypoint_layer = None + self._waypoint_projections = () self._validation_message = "" self._submission_pending = False self._loading_status = "LOADING WORLD MODEL" @@ -510,14 +506,11 @@ def step_ui( bev_frame = frames[1] if len(frames) > 1 else None if video is not None: self.state.select_presented_frame(video) + self.state.draw_waypoints(imgui, video) self.state.draw(imgui, step_index, bev_frame=bev_frame) if video is None: return None - world = video.to(torch.float32) - waypoints = self.state.waypoint_layer(video) - if waypoints is not None: - world = self._presentation_manager.composite(world, waypoints) - return world + return video.to(torch.float32) def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" diff --git a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py index 9abc44792..5cc2eff29 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/world_overlay.py @@ -1,31 +1,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Frame-aligned Crazy Robotaxi presentation layers.""" +"""ImGui draw-list geometry for world-anchored Crazy Robotaxi markers.""" from __future__ import annotations import math from collections.abc import Sequence -from functools import lru_cache +from typing import Any, Literal import numpy as np -import torch +import numpy.typing as npt from omnidreams_game_engine.camera import FThetaCameraModel from omnidreams_game_engine.types import CameraCalibration -from torch import Tensor from crazy_robotaxi.rules import ( TaxiCameraMarkerProjection, @@ -33,400 +20,161 @@ project_taxi_markers_to_camera, ) -_PICKUP_COLOR = (-0.0745098, 0.4509804, -1.0) -"""Normalized RGB used for pickup waypoint geometry.""" +_PICKUP_RGB = (118.0 / 255.0, 185.0 / 255.0, 0.0) +"""NVIDIA green used for pickup waypoint geometry.""" -_DROPOFF_COLOR = (0.5686275, 0.1764706, -0.60784316) -"""Normalized RGB used for drop-off waypoint geometry.""" +_DROPOFF_RGB = (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) +"""Amber used for drop-off waypoint geometry.""" -_BLACK = (-1.0, -1.0, -1.0) -_WHITE = (1.0, 1.0, 1.0) -_LABEL_BACKGROUND = (-0.9372549, -0.9372549, -0.90588236) +_WHITE = (1.0, 1.0, 1.0, 1.0) +_LABEL_BACKGROUND = (8.0 / 255.0, 8.0 / 255.0, 12.0 / 255.0, 225.0 / 255.0) -_BLACK_RING = 0 -_PICKUP_RING = 1 -_DROPOFF_RING = 2 -_BLACK_BEACON = 3 -_PICKUP_SOLID = 4 -_DROPOFF_SOLID = 5 -_WHITE_SOLID = 6 -_LABEL_PANEL = 7 -_WAYPOINT_PALETTE = np.asarray( - ( - (*_BLACK, 220.0 / 255.0), - (*_PICKUP_COLOR, 245.0 / 255.0), - (*_DROPOFF_COLOR, 245.0 / 255.0), - (*_BLACK, 235.0 / 255.0), - (*_PICKUP_COLOR, 1.0), - (*_DROPOFF_COLOR, 1.0), - (*_WHITE, 1.0), - (*_LABEL_BACKGROUND, 225.0 / 255.0), - ), - dtype=np.float32, -) -"""Painter styles uploaded once for each frame-aligned waypoint batch.""" - -_GLYPHS = { - "C": ("1111", "1000", "1000", "1000", "1000", "1000", "1111"), - "D": ("1110", "1001", "1001", "1001", "1001", "1001", "1110"), - "F": ("1111", "1000", "1000", "1110", "1000", "1000", "1000"), - "I": ("111", "010", "010", "010", "010", "010", "111"), - "K": ("1001", "1010", "1100", "1100", "1010", "1001", "1001"), - "O": ("0110", "1001", "1001", "1001", "1001", "1001", "0110"), - "P": ("1110", "1001", "1001", "1110", "1000", "1000", "1000"), - "R": ("1110", "1001", "1001", "1110", "1010", "1001", "1001"), - "U": ("1001", "1001", "1001", "1001", "1001", "1001", "0110"), -} -"""Tiny dependency-free glyphs used for world-anchored marker labels.""" - - -def render_waypoint_layers( - snapshots: Sequence[object], - rig_poses_world: np.ndarray, +def project_waypoints( + snapshot: TaxiGameSnapshot, + rig_to_world: npt.NDArray[np.float32], calibration: CameraCalibration, *, width: int, height: int, - device: torch.device | str, - dtype: torch.dtype = torch.float32, -) -> Tensor: - """Render projected waypoint geometry as transparent RGBA layers. - - Args: - snapshots: Per-frame taxi game snapshots. - rig_poses_world: Camera-rig poses in ``[T,4,4]``. - calibration: Camera calibration used for world projection. - width: Output width in pixels. - height: Output height in pixels. - device: Device receiving the resulting tensor. - dtype: Floating-point output dtype. - - Returns: - Floating-point layers in ``[T,4,H,W]`` with normalized RGB and - alpha in ``[0,1]``. - - Raises: - ValueError: Inputs are not frame-aligned or output dimensions are invalid. - TypeError: A frame does not contain a taxi game snapshot. - """ - frame_count = len(snapshots) - poses = np.asarray(rig_poses_world, dtype=np.float32) - if poses.shape != (frame_count, 4, 4): - raise ValueError("Waypoint snapshots and camera poses must align") +) -> tuple[TaxiCameraMarkerProjection, ...]: + """Project the current world waypoints into presentation pixels.""" if width <= 0 or height <= 0: - raise ValueError("Waypoint layer dimensions must be positive") - if not dtype.is_floating_point: - raise ValueError("Waypoint layers require a floating-point dtype") + raise ValueError("Waypoint projection dimensions must be positive") + pose = np.asarray(rig_to_world, dtype=np.float32) + if pose.shape != (4, 4): + raise ValueError("Waypoint projection requires one [4,4] rig pose") + if snapshot.session_state != "playing": + return () camera = FThetaCameraModel( calibration, output_width=width, output_height=height, ) - styles = np.full((frame_count, height, width), -1, dtype=np.int8) - for frame_index, snapshot in enumerate(snapshots): - if not isinstance(snapshot, TaxiGameSnapshot): - raise TypeError("Waypoint layer received a non-taxi game snapshot") - if snapshot.session_state != "playing": - continue - projections = project_taxi_markers_to_camera( - snapshot, - poses[frame_index], - camera, - image_width=width, - image_height=height, - ) - if snapshot.phase == "seeking_pickup": - ring_style = _PICKUP_RING - solid_style = _PICKUP_SOLID - else: - ring_style = _DROPOFF_RING - solid_style = _DROPOFF_SOLID - label = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" - ring_edges = [] - beacons = [] - for projection in projections: - ring_edges.extend(projection.ring_edges_uv) - beacons.append((projection.anchor_uv, _beacon_top(projection))) - - layer = styles[frame_index] - _paint_lines(layer, ring_edges, width, height, 7, _BLACK_RING) - _paint_lines(layer, ring_edges, width, height, 4, ring_style) - _paint_lines(layer, beacons, width, height, 9, _BLACK_BEACON) - _paint_lines(layer, beacons, width, height, 5, solid_style) - for projection, (_, top) in zip(projections, beacons, strict=True): - _paint_marker_anchor( - layer, - projection.anchor_uv, - width, - height, - solid_style, - ) - _paint_marker_label( - layer, - top, - label, - width, - height, - solid_style, - ) - return _materialize_waypoint_layers(styles, device=device, dtype=dtype) - - -def _beacon_top(projection: TaxiCameraMarkerProjection) -> tuple[float, float]: - anchor_x, anchor_y = projection.anchor_uv - if projection.beacon_top_uv is None: - return anchor_x, anchor_y - 64.0 - vector_x = float(projection.beacon_top_uv[0] - anchor_x) - vector_y = float(projection.beacon_top_uv[1] - anchor_y) - length = max(1.0, math.hypot(vector_x, vector_y)) - display_length = min(170.0, max(52.0, length)) - return ( - anchor_x + vector_x * display_length / length, - anchor_y + vector_y * display_length / length, + return project_taxi_markers_to_camera( + snapshot, + pose, + camera, + image_width=width, + image_height=height, ) -def _paint_lines( - layer: np.ndarray, - lines: Sequence[tuple[tuple[float, float], tuple[float, float]]], +def draw_waypoints( + imgui: Any, + projections: Sequence[TaxiCameraMarkerProjection], + *, + phase: Literal["seeking_pickup", "to_dropoff"], width: int, height: int, - width_px: int, - style: int, ) -> None: - if not lines: + """Draw projected world markers beneath all ImGui HUD windows.""" + if not projections: return - x, y = _line_pixels(lines, width=width, height=height, width_px=width_px) - _paint_pixels( - layer, - x, - y, - style, - ) - + draw_list = imgui.get_background_draw_list() + rgb = _PICKUP_RGB if phase == "seeking_pickup" else _DROPOFF_RGB + ring_color = _imgui_color(imgui, (*rgb, 245.0 / 255.0)) + solid_color = _imgui_color(imgui, (*rgb, 1.0)) + black_ring = _imgui_color(imgui, (0.0, 0.0, 0.0, 220.0 / 255.0)) + black_beacon = _imgui_color(imgui, (0.0, 0.0, 0.0, 235.0 / 255.0)) + white = _imgui_color(imgui, _WHITE) + panel = _imgui_color(imgui, _LABEL_BACKGROUND) + label = "PICKUP" if phase == "seeking_pickup" else "DROPOFF" + + for projection in projections: + for start, end in projection.ring_edges_uv: + draw_list.add_line( + _point(imgui, start), + _point(imgui, end), + black_ring, + 7.0, + ) + for projection in projections: + for start, end in projection.ring_edges_uv: + draw_list.add_line( + _point(imgui, start), + _point(imgui, end), + ring_color, + 4.0, + ) -def _paint_marker_anchor( - layer: np.ndarray, - anchor: tuple[float, float], - width: int, - height: int, - style: int, -) -> None: - center_x, center_y = int(round(anchor[0])), int(round(anchor[1])) - x, y = _disk_pixels(center_x, center_y, 9, width, height) - _paint_pixels(layer, x, y, style) - x, y = _ring_pixels(center_x, center_y, 6, 9, width, height) - _paint_pixels(layer, x, y, _WHITE_SOLID) + beacon_tops = tuple(_beacon_top(projection) for projection in projections) + for projection, top in zip(projections, beacon_tops, strict=True): + draw_list.add_line( + _point(imgui, projection.anchor_uv), + _point(imgui, top), + black_beacon, + 9.0, + ) + for projection, top in zip(projections, beacon_tops, strict=True): + draw_list.add_line( + _point(imgui, projection.anchor_uv), + _point(imgui, top), + solid_color, + 5.0, + ) + for projection, top in zip(projections, beacon_tops, strict=True): + anchor = _point(imgui, projection.anchor_uv) + draw_list.add_circle_filled(anchor, 9.0, solid_color) + draw_list.add_circle(anchor, 7.5, white, 0, 3.0) + _draw_label( + imgui, + draw_list, + top, + label, + color=solid_color, + panel=panel, + scale=max(1, min(width, height) // 360), + ) -def _paint_marker_label( - layer: np.ndarray, +def _draw_label( + imgui: Any, + draw_list: Any, top: tuple[float, float], label: str, - width: int, - height: int, - style: int, -) -> None: - scale = max(1, min(width, height) // 360) - text_x, text_y = _text_pixels(label, scale) - text_width = int(text_x.max()) + 1 - text_height = int(text_y.max()) + 1 - center_x = int(round(top[0])) - text_left = center_x - text_width // 2 - text_top = int(round(top[1])) - text_height - 10 * scale - left = text_left - 4 * scale - top_y = text_top - 3 * scale - right = text_left + text_width + 4 * scale - bottom = text_top + text_height + 3 * scale - x, y = _rectangle_pixels(left, top_y, right, bottom, width, height) - _paint_pixels(layer, x, y, _LABEL_PANEL) - border = max(1, scale) - border_lines = ( - ((left, top_y), (right, top_y)), - ((right, top_y), (right, bottom)), - ((right, bottom), (left, bottom)), - ((left, bottom), (left, top_y)), - ) - _paint_lines(layer, border_lines, width, height, border, style) - _paint_pixels( - layer, - text_x + text_left, - text_y + text_top, - style, - clip=True, - ) - - -def _paint_pixels( - layer: np.ndarray, - x: np.ndarray, - y: np.ndarray, - style: int, *, - clip: bool = False, + color: int, + panel: int, + scale: int, ) -> None: - if clip: - height, width = layer.shape - valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) - x = x[valid] - y = y[valid] - if x.size == 0: - return - layer[y, x] = style - - -def _line_pixels( - lines: Sequence[tuple[tuple[float, float], tuple[float, float]]], - *, - width: int, - height: int, - width_px: int, -) -> tuple[np.ndarray, np.ndarray]: - endpoints = np.asarray(lines, dtype=np.float64) - starts = endpoints[:, 0] - deltas = endpoints[:, 1] - starts - sample_counts = np.maximum( - 2, - np.ceil(np.max(np.abs(deltas), axis=1)).astype(np.int32) + 1, - ) - sample_index = np.arange(int(sample_counts.max()), dtype=np.float64)[None] - fraction = sample_index / (sample_counts[:, None] - 1) - selected_samples = sample_index < sample_counts[:, None] - center_x = np.rint(starts[:, 0, None] + deltas[:, 0, None] * fraction).astype( - np.int32 - ) - center_y = np.rint(starts[:, 1, None] + deltas[:, 1, None] * fraction).astype( - np.int32 + text_size = imgui.calc_text_size(label) + text_width = float(text_size.x) + text_height = float(text_size.y) + text_left = float(top[0]) - text_width / 2.0 + text_top = float(top[1]) - text_height - 10.0 * scale + padding_x = 4.0 * scale + padding_y = 3.0 * scale + panel_min = imgui.ImVec2(text_left - padding_x, text_top - padding_y) + panel_max = imgui.ImVec2( + text_left + text_width + padding_x, + text_top + text_height + padding_y, ) - offset_x, offset_y = _line_offsets(width_px) - x = center_x[:, :, None] + offset_x - y = center_y[:, :, None] + offset_y - valid = ( - selected_samples[:, :, None] & (x >= 0) & (x < width) & (y >= 0) & (y < height) - ) - return x[valid], y[valid] - + draw_list.add_rect_filled(panel_min, panel_max, panel) + draw_list.add_rect(panel_min, panel_max, color, 0.0, float(max(1, scale))) + draw_list.add_text(imgui.ImVec2(text_left, text_top), color, label) -@lru_cache(maxsize=None) -def _line_offsets(width_px: int) -> tuple[np.ndarray, np.ndarray]: - radius = max(0.0, (float(width_px) - 1.0) / 2.0) - integer_radius = int(math.ceil(radius)) - offsets = np.arange(-integer_radius, integer_radius + 1, dtype=np.int32) - offset_x, offset_y = np.meshgrid(offsets, offsets) - selected = offset_x * offset_x + offset_y * offset_y <= radius * radius - return offset_x[selected], offset_y[selected] - - -def _disk_pixels( - center_x: int, - center_y: int, - radius: int, - width: int, - height: int, -) -> tuple[np.ndarray, np.ndarray]: - offset_x, offset_y = _disk_offsets(radius) - x = center_x + offset_x - y = center_y + offset_y - valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) - return x[valid], y[valid] - - -@lru_cache(maxsize=None) -def _disk_offsets(radius: int) -> tuple[np.ndarray, np.ndarray]: - offsets = np.arange(-radius, radius + 1, dtype=np.int32) - offset_x, offset_y = np.meshgrid(offsets, offsets) - selected = offset_x * offset_x + offset_y * offset_y <= radius * radius - return offset_x[selected], offset_y[selected] - - -def _ring_pixels( - center_x: int, - center_y: int, - inner_radius: int, - outer_radius: int, - width: int, - height: int, -) -> tuple[np.ndarray, np.ndarray]: - offset_x, offset_y = _ring_offsets(inner_radius, outer_radius) - x = center_x + offset_x - y = center_y + offset_y - valid = (x >= 0) & (x < width) & (y >= 0) & (y < height) - return x[valid], y[valid] - -@lru_cache(maxsize=None) -def _ring_offsets( - inner_radius: int, - outer_radius: int, -) -> tuple[np.ndarray, np.ndarray]: - offsets = np.arange(-outer_radius, outer_radius + 1, dtype=np.int32) - offset_x, offset_y = np.meshgrid(offsets, offsets) - distance_squared = offset_x * offset_x + offset_y * offset_y - selected = (distance_squared >= inner_radius * inner_radius) & ( - distance_squared <= outer_radius * outer_radius - ) - return offset_x[selected], offset_y[selected] - - -def _rectangle_pixels( - left: int, - top: int, - right: int, - bottom: int, - width: int, - height: int, -) -> tuple[np.ndarray, np.ndarray]: - clipped_left = max(0, left) - clipped_top = max(0, top) - clipped_right = min(width - 1, right) - clipped_bottom = min(height - 1, bottom) - if clipped_left > clipped_right or clipped_top > clipped_bottom: - return np.empty(0, dtype=np.int32), np.empty(0, dtype=np.int32) - x, y = np.meshgrid( - np.arange(clipped_left, clipped_right + 1, dtype=np.int32), - np.arange(clipped_top, clipped_bottom + 1, dtype=np.int32), +def _beacon_top(projection: TaxiCameraMarkerProjection) -> tuple[float, float]: + anchor_x, anchor_y = projection.anchor_uv + if projection.beacon_top_uv is None: + return anchor_x, anchor_y - 64.0 + vector_x = float(projection.beacon_top_uv[0] - anchor_x) + vector_y = float(projection.beacon_top_uv[1] - anchor_y) + length = max(1.0, math.hypot(vector_x, vector_y)) + display_length = min(170.0, max(52.0, length)) + return ( + anchor_x + vector_x * display_length / length, + anchor_y + vector_y * display_length / length, ) - return x.ravel(), y.ravel() -@lru_cache(maxsize=None) -def _text_pixels(text: str, scale: int) -> tuple[np.ndarray, np.ndarray]: - x_values = [] - y_values = [] - cursor = 0 - for character in text: - glyph = _GLYPHS[character] - for row, bits in enumerate(glyph): - for column, bit in enumerate(bits): - if bit == "0": - continue - for offset_y in range(scale): - for offset_x in range(scale): - x_values.append(cursor + column * scale + offset_x) - y_values.append(row * scale + offset_y) - cursor += (len(glyph[0]) + 1) * scale - return np.asarray(x_values, dtype=np.int32), np.asarray(y_values, dtype=np.int32) +def _point(imgui: Any, value: tuple[float, float]) -> Any: + return imgui.ImVec2(float(value[0]), float(value[1])) -def _materialize_waypoint_layers( - styles: np.ndarray, - *, - device: torch.device | str, - dtype: torch.dtype, -) -> Tensor: - style_tensor = torch.from_numpy(styles).to(device=device) - transparent = torch.zeros((1, 4), device=device, dtype=dtype) - palette = torch.cat( - ( - transparent, - torch.as_tensor(_WAYPOINT_PALETTE, device=device, dtype=dtype), - ) - ) - # A GPU nonzero() has a data-dependent output size and therefore waits for - # all earlier work on the stream before returning to Python. Dense palette - # lookup keeps materialization asynchronous; style -1 selects transparent. - return palette[style_tensor.to(torch.int64) + 1].permute(0, 3, 1, 2).contiguous() +def _imgui_color(imgui: Any, rgba: tuple[float, float, float, float]) -> int: + return int(imgui.color_convert_float4_to_u32(imgui.ImVec4(*rgba))) -__all__ = ["render_waypoint_layers"] +__all__ = ["draw_waypoints", "project_waypoints"] diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index daf53611d..0ecfc07f6 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -5,7 +5,6 @@ from __future__ import annotations -import math import queue import threading from dataclasses import dataclass, field, replace @@ -25,10 +24,7 @@ TaxiHudState, build_hud_frames, ) -from crazy_robotaxi.world_overlay import ( - _paint_lines, - render_waypoint_layers, -) +from crazy_robotaxi.world_overlay import draw_waypoints, project_waypoints from omnidreams_game_engine.types import CameraCalibration from flashdreams.api_v2.loop import IModelLoop @@ -74,33 +70,27 @@ def _snapshot(*, session_state: TaxiSessionState = "playing") -> TaxiGameSnapsho ) -def _legacy_line_pixels( - start: tuple[float, float], - end: tuple[float, float], - *, - width: int, - height: int, - width_px: int, -) -> tuple[np.ndarray, np.ndarray]: - delta_x = float(end[0] - start[0]) - delta_y = float(end[1] - start[1]) - sample_count = max(2, int(math.ceil(max(abs(delta_x), abs(delta_y)))) + 1) - center_x = np.rint(np.linspace(start[0], end[0], sample_count)).astype(np.int32) - center_y = np.rint(np.linspace(start[1], end[1], sample_count)).astype(np.int32) - radius = max(0.0, (float(width_px) - 1.0) / 2.0) - x_parts = [] - y_parts = [] - integer_radius = int(math.ceil(radius)) - for offset_y in range(-integer_radius, integer_radius + 1): - for offset_x in range(-integer_radius, integer_radius + 1): - if offset_x * offset_x + offset_y * offset_y > radius * radius: - continue - x = center_x + offset_x - y = center_y + offset_y - selected = (x >= 0) & (x < width) & (y >= 0) & (y < height) - x_parts.append(x[selected]) - y_parts.append(y[selected]) - return np.concatenate(x_parts), np.concatenate(y_parts) +class _FakeDrawList: + def __init__(self) -> None: + self.commands: list[tuple[str, tuple[Any, ...]]] = [] + + def add_line(self, *args: Any) -> None: + self.commands.append(("line", args)) + + def add_circle(self, *args: Any) -> None: + self.commands.append(("circle", args)) + + def add_circle_filled(self, *args: Any) -> None: + self.commands.append(("circle_filled", args)) + + def add_rect(self, *args: Any) -> None: + self.commands.append(("rect", args)) + + def add_rect_filled(self, *args: Any) -> None: + self.commands.append(("rect_filled", args)) + + def add_text(self, *args: Any) -> None: + self.commands.append(("text", args)) class _FakeImGui: @@ -120,11 +110,27 @@ def __init__(self) -> None: self.input_value = "" self.submit_input = False self.click_submit = False + self.background_draw_list = _FakeDrawList() @staticmethod def ImVec2(x: float, y: float) -> tuple[float, float]: return x, y + @staticmethod + def ImVec4(x: float, y: float, z: float, w: float) -> tuple[float, ...]: + return x, y, z, w + + @staticmethod + def color_convert_float4_to_u32(color: tuple[float, ...]) -> int: + return hash(color) + + @staticmethod + def calc_text_size(text: str) -> SimpleNamespace: + return SimpleNamespace(x=float(len(text) * 8), y=14.0) + + def get_background_draw_list(self) -> _FakeDrawList: + return self.background_draw_list + def set_next_window_pos(self, position, condition) -> None: del position, condition @@ -253,49 +259,40 @@ def test_hud_frames_reject_misaligned_input_diagnostics() -> None: ) -def test_waypoints_are_rendered_as_frame_aligned_world_layers() -> None: - poses = np.eye(4, dtype=np.float32)[None] - - layers = render_waypoint_layers( - (_snapshot(),), - poses, +def test_waypoints_are_projected_and_drawn_on_imgui_background() -> None: + projections = project_waypoints( + _snapshot(), + np.eye(4, dtype=np.float32), _calibration(), width=160, height=96, - device="cpu", ) + imgui = _FakeImGui() - assert layers.shape == (1, 4, 96, 160) - assert layers.dtype is torch.float32 - assert torch.any(layers[:, 3] == 1.0) - - terminal = render_waypoint_layers( - (_snapshot(session_state="awaiting_name"),), - poses, - _calibration(), + draw_waypoints( + imgui, + projections, + phase="seeking_pickup", width=160, height=96, - device="cpu", ) - assert torch.count_nonzero(terminal[:, 3]) == 0 + command_names = [name for name, _ in imgui.background_draw_list.commands] + assert projections + assert "line" in command_names + assert "circle" in command_names + assert "circle_filled" in command_names + assert "rect_filled" in command_names + assert "text" in command_names -def test_batched_line_rasterizer_matches_the_original_geometry() -> None: - lines = ( - ((-4.25, 10.5), (35.75, 22.5)), - ((30.5, -8.0), (2.5, 28.0)), - ((5.0, 5.0), (5.0, 5.0)), - ((18.25, 29.75), (45.5, 2.25)), + terminal = project_waypoints( + _snapshot(session_state="awaiting_name"), + np.eye(4, dtype=np.float32), + _calibration(), + width=160, + height=96, ) - expected = np.full((32, 40), -1, dtype=np.int8) - for start, end in lines: - x, y = _legacy_line_pixels(start, end, width=40, height=32, width_px=7) - expected[y, x] = 3 - actual = np.full_like(expected, -1) - - _paint_lines(actual, lines, 40, 32, 7, 3) - - np.testing.assert_array_equal(actual, expected) + assert terminal == () def test_pickup_waypoint_projection_batches_anchors_and_ring_geometry() -> None: @@ -339,7 +336,7 @@ def project_world(self, points, rig_to_world): assert [projection.distance_m for projection in projections] == [10.0, 20.0, 30.0] -def test_imgui_ui_loop_composites_waypoints_and_draws_bev_in_a_window() -> None: +def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) bev = torch.full((1, 3, 32, 32), 0.5) @@ -389,18 +386,18 @@ def test_imgui_ui_loop_composites_waypoints_and_draws_bev_in_a_window() -> None: assert pixels.shape == (32, 32, 3) assert np.all(pixels == 191) assert image_size == (32.0, 32.0) - assert result.output[0, 0, 48, 80] != -0.5 - assert torch.any(result.output != video) + assert renderer.ui.background_draw_list.commands + assert torch.all(result.output == video) - cached_waypoints = hud_state._waypoint_layer + cached_waypoints = hud_state._waypoint_projections cached_bev = hud_state._bev_pixels loop.step(1, UserInputEvents([])) - assert hud_state._waypoint_layer is cached_waypoints + assert hud_state._waypoint_projections is cached_waypoints assert hud_state._bev_pixels is cached_bev loop.reset() assert hud_state._current is None - assert hud_state._waypoint_layer is None + assert hud_state._waypoint_projections == () assert hud_state._bev_pixels is None assert renderer.reset_count == 1 diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 288ac5cab..5a3defd9b 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -50,8 +50,8 @@ flowchart TD subgraph UITHREAD["FlashDreams V2 UI thread"] BUFFER["PresentationManager
RGB + optional raw BEV frames"] - HUD["CrazyRobotaxiImGuiUILoop
waypoint + BEV pixel caches"] - COMPOSE["RGB → in-world waypoint composition
then Dear ImGui HUD + BEV window"] + HUD["CrazyRobotaxiImGuiUILoop
waypoint projection + BEV pixel caches"] + COMPOSE["one Dear ImGui overlay
background waypoints + HUD + BEV window"] WINDOW["V2 client window
native / WebRTC / MP4"] end @@ -80,13 +80,13 @@ loop emits generated RGB frames and, when configured, the synchronized raw BEV frame. For each generated frame it publishes an immutable `TaxiHudFrame` containing its game snapshot and camera pose to the UI loop with `invoke_async`. The UI loop selects the metadata aligned with the currently -presented model frame, rasterizes and caches that frame's in-world waypoint -geometry, and uses V2's `PresentationManager` to compose only those in-world -waypoints over the generated frame. This avoids constructing an entire chunk -of full-size waypoint tensors at the model-step boundary. It draws the HUD and -raw BEV in immediate Dear ImGui windows; `ImGuiUILoop` composites that UI layer -over the world frame. UI callbacks send validated game actions back to the -model loop with `invoke_async`; they never call game state directly. +presented model frame and caches that frame's projected in-world waypoint +geometry. It submits rings, beacons, anchors, and labels to ImGui's background +draw list, beneath the HUD windows, without constructing a full-size waypoint +tensor. It draws the HUD and raw BEV in immediate Dear ImGui windows; +`ImGuiUILoop` composites that one UI layer over the world frame. UI callbacks +send validated game actions back to the model loop with `invoke_async`; they +never call game state directly. ## Pre-presentation startup @@ -183,16 +183,15 @@ invoke_async(ui_loop, publish_hud_frames(game_snapshots, poses, video)) return [video StepResult, optional raw_bev StepResult] ``` -The UI loop uses V2's `ImGuiUILoop` and `presented_model_frames()`; its -`step_ui()` method renders a waypoint layer only when the presented frame -changes and composites that in-world layer with -`PresentationManager.composite()`. It converts the small raw BEV frame to -cached RGB bytes only when the presented BEV changes and displays it through -the runtime-provided ImGui image bridge. The base loop composites the complete -Dear ImGui overlay over that world back buffer. There is no full-frame BEV -layer, retained SlangPy widget tree, intermediate app renderer, local adapter, -model session, chunk request, private command queue, private frame queue, or -legacy `PresentedFrame` aggregate. +The UI loop uses V2's `ImGuiUILoop` and `presented_model_frames()`. It projects +waypoint geometry only when the presented frame changes and submits the cached +primitives to ImGui's background draw list each UI tick. It converts the small +raw BEV frame to cached RGB bytes only when the presented BEV changes and +displays it through the runtime-provided ImGui image bridge. The base loop +composites the complete Dear ImGui overlay over the video back buffer. There is +no full-frame waypoint or BEV layer, retained SlangPy widget tree, intermediate +app renderer, local adapter, model session, chunk request, private command +queue, private frame queue, or legacy `PresentedFrame` aggregate. ## Game-engine internals @@ -257,7 +256,7 @@ flowchart LR SCORES["HighScoreStore"] FRAME["TaxiGameFrame"] HUDSTATE["TaxiHudFrame
immutable presentation state"] - WAYPOINTS["camera-projected waypoint layer
ring + beacon + label"] + WAYPOINTS["ImGui background draw list
ring + beacon + anchor + label"] IMGUI["CrazyRobotaxiImGuiUILoop
score, time, navigation, BEV,
name entry, leaderboard"] MAP --> NAV --> FARES @@ -282,7 +281,7 @@ subclass a game-engine application host or presenter. | Application | resolved pipeline configuration; one loaded OmniDreams pipeline | | Session, immutable | validated session description; compiled/loaded scene definition; game configuration | | Model loop, per reset generation | driving-input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | -| UI loop, per reset generation | immediate ImGui state; immutable HUD-frame lookup; BEV pixel cache; name-entry buffer; validation messages | +| UI loop, per reset generation | immediate ImGui state; immutable HUD-frame lookup; waypoint projection and BEV pixel caches; name-entry buffer; validation messages | | Outside runtime | authored map YAML; derived map cache; high-score file | All simulation, condition-renderer, and world-model CUDA calls that mutate @@ -309,7 +308,7 @@ stateDiagram-v2 While awaiting a name, no new world-model block is generated. The model loop re-emits the last generated RGB frame and publishes an updated immutable HUD -frame; the UI loop replaces its cached waypoint layer from that metadata. +frame; the UI loop replaces its cached waypoint projections from that metadata. ImGui owns the editable name buffer and submits a validated name to the model loop with `invoke_async`. The model loop reports `is_finished()` only after a leaderboard frame has been emitted @@ -358,7 +357,7 @@ apps/crazy_robotaxi/ application.py # IApplication composition root session.py # ISession + model/UI loop wiring ui.py # Dear ImGui HUD state and UI loop - world_overlay.py # UI-thread in-world waypoint rasterization + world_overlay.py # ImGui world-marker projection and draw geometry rules.py # taxi fare state machine dynamics.py # arcade taxi controls physics.py # taxi collision policy From a160e734b818b6668df6c7ac09ebbcfb57029456 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 04:00:19 +0000 Subject: [PATCH 17/62] Reduce Crazy Robotaxi BEV presentation overhead Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 18 ++- .../crazy_robotaxi/application.py | 40 ++++- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 51 +++++-- apps/crazy_robotaxi/tests/test_application.py | 34 ++++- apps/crazy_robotaxi/tests/test_ui.py | 2 +- .../omnidreams_game_engine/conditioning.py | 12 +- .../omnidreams_game_engine/types.py | 2 +- .../tests/test_conditioning.py | 34 +++++ docs/design/crazy_robotaxi_v2_architecture.md | 18 ++- docs/design/crazy_robotaxi_v2_performance.md | 139 ++++++++++++++++++ 10 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 apps/omnidreams_game_engine/tests/test_conditioning.py create mode 100644 docs/design/crazy_robotaxi_v2_performance.md diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index f763b5c01..b0e6adbc6 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -101,9 +101,21 @@ duration of the frames it produces. The live `model_step_wall_ms`, metrics separate a throughput miss from model-thread CPU work; the pipeline's `encode_ms`, `diffuse_ms`, `decode_ms`, and `finalize_ms` metrics identify the corresponding GPU stage. Waypoint projection and ImGui draw submission are -UI-thread work and are not included in model-step metrics. The JSON sink normalizes `_ms` metric names to -`_s`. Exclude the first chunks when judging steady state because compilation -and graph capture are startup costs. +UI-thread work and are not included in model-step metrics. The JSON sink +normalizes `_ms` metric names to `_s`. Exclude the first chunks when judging +steady state because compilation and graph capture are startup costs. + +The BEV is HUD-only data, so Crazy Robotaxi caps its raster resolution to the +actual ImGui map-image extent while preserving the authored aspect ratio. At +the default 1280x704 output this changes the default square BEV from 1024x1024 +to 234x234. The renderer's uint8 pixels remain uint8 through the game-engine +contract; only the much smaller displayed image crosses to the CPU for ImGui's +current pixel-upload helper. A fully GPU-resident BEV texture still requires a +CUDA-tensor image hook in the V2 ImGui renderer. + +The saved [performance investigation](../../docs/design/crazy_robotaxi_v2_performance.md) +records the current baseline, what the existing captures prove, and the exact +like-for-like `original-perf` rerun needed after presentation changes. Presentation remains fixed at 30 fps. Disabling diagnostic synchronization removes an avoidable pause source, but it does not make a model preset whose diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 566a0246e..431714052 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -29,6 +29,7 @@ from crazy_robotaxi.high_scores import default_high_scores_path from crazy_robotaxi.rules import TaxiGameConfig from crazy_robotaxi.session import CrazyRobotaxiSession +from crazy_robotaxi.ui import bev_display_extent from flashdreams.api_v2.application import IApplication from flashdreams.api_v2.session import ISession from flashdreams.infra.config import derive_config @@ -229,6 +230,14 @@ def create_session(self, session_desc: SessionDesc) -> ISession: ), ), ) + config = replace( + config, + renderer=_fit_bev_renderer_to_ui( + config.renderer, + video_width=actual[0], + video_height=actual[1], + ), + ) expected = config.renderer.raster.resolution_wh if actual != expected: raise ValueError( @@ -236,10 +245,12 @@ def create_session(self, session_desc: SessionDesc) -> ISession: ) transformer = self._pipeline_config.diffusion_model.transformer scheduler = self._pipeline_config.diffusion_model.scheduler + bev = config.renderer.bev + bev_resolution = f"{bev.width}x{bev.height}" if bev.enabled else "disabled" _LOGGER.info( "Crazy Robotaxi model preset=%s resolution=%sx%s native_dit=%s " "native_backend=%s attention_backend=%s skip_finalize=%s " - "denoising_timesteps=%s", + "denoising_timesteps=%s bev=%s", config.model_preset_name, actual[0], actual[1], @@ -248,6 +259,7 @@ def create_session(self, session_desc: SessionDesc) -> ISession: transformer.native_dit_attention_backend, transformer.skip_finalize_kv_cache, list(scheduler.denoising_timesteps), + bev_resolution, ) if self._pipeline is None: self._pipeline = self._pipeline_factory( @@ -281,6 +293,32 @@ def _build_pipeline(config: Any, device: str) -> Any: return config.setup().to(device).eval() +def _fit_bev_renderer_to_ui( + renderer: RendererSettings, + *, + video_width: int, + video_height: int, +) -> RendererSettings: + """Avoid rasterizing a HUD-only BEV above its presented pixel extent.""" + bev = renderer.bev + if not bev.enabled: + return renderer + maximum_width, maximum_height = bev_display_extent(video_width, video_height) + scale = min( + 1.0, + maximum_width / bev.width, + maximum_height / bev.height, + ) + if scale >= 1.0: + return renderer + fitted = replace( + bev, + width=max(1, round(bev.width * scale)), + height=max(1, round(bev.height * scale)), + ) + return replace(renderer, bev=fitted) + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="flashdreams-run-v2 crazy-robotaxi --", diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 719fbfdc9..3dde7bb3b 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -60,6 +60,12 @@ _LOGGER = logging.getLogger(__name__) +def bev_display_extent(video_width: int, video_height: int) -> tuple[int, int]: + """Return the largest BEV image extent used by the fixed HUD layout.""" + size = max(1, min(int(video_width) // 4, int(video_height) // 3)) + return size, size + + @dataclass(frozen=True, slots=True) class TaxiHudFrame: """Immutable UI data aligned with one generated video frame.""" @@ -339,13 +345,20 @@ def _draw_text_window( def _draw_bev_window(self, imgui: Any, bev_frame: Tensor | None) -> None: if bev_frame is None: return - image_size = min(self.width // 4, self.height // 3) - if image_size <= 4: + maximum_width, maximum_height = bev_display_extent(self.width, self.height) + frame_height, frame_width = (int(value) for value in bev_frame.shape[1:]) + scale = min(maximum_width / frame_width, maximum_height / frame_height) + image_width = max(1, round(frame_width * scale)) + image_height = max(1, round(frame_height * scale)) + if image_width <= 4 or image_height <= 4: return pixels = self._bev_image_pixels(bev_frame) padding = 16 title_height = 34 - window_size = (float(image_size + padding), float(image_size + title_height)) + window_size = ( + float(image_width + padding), + float(image_height + title_height), + ) margin = float(max(8, min(self.width, self.height) // 80)) position = ( float(self.width) - window_size[0] - margin, @@ -358,7 +371,7 @@ def _draw_bev_window(self, imgui: Any, bev_frame: Tensor | None) -> None: imgui.image( "crazy_robotaxi_bev", pixels, - size=(float(image_size), float(image_size)), + size=(float(image_width), float(image_height)), ) finally: imgui.end() @@ -369,18 +382,23 @@ def _bev_image_pixels(self, frame: Tensor) -> npt.NDArray[np.uint8]: source_key = (int(frame.data_ptr()), tuple(int(value) for value in frame.shape)) if source_key == self._bev_source_key and self._bev_pixels is not None: return self._bev_pixels - pixels = ( - frame.detach() - .to(dtype=torch.float32) - .add(1.0) - .mul(127.5) - .clamp_(0.0, 255.0) - .to(torch.uint8) - .permute(1, 2, 0) - .contiguous() - .cpu() - .numpy() - ) + source = frame.detach() + if source.dtype == torch.uint8: + pixels = source.permute(1, 2, 0).contiguous().cpu().numpy() + elif source.is_floating_point(): + pixels = ( + source.to(dtype=torch.float32) + .add(1.0) + .mul(127.5) + .clamp_(0.0, 255.0) + .to(torch.uint8) + .permute(1, 2, 0) + .contiguous() + .cpu() + .numpy() + ) + else: + raise ValueError("BEV presentation frames must be uint8 or floating point") self._bev_source_key = source_key self._bev_pixels = np.ascontiguousarray(pixels) return self._bev_pixels @@ -634,5 +652,6 @@ def _navigation_label(bearing_rad: float) -> str: "CrazyRobotaxiImGuiUILoop", "TaxiHudFrame", "TaxiHudState", + "bev_display_extent", "build_hud_frames", ] diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 14c9a1831..cd7f6e975 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -11,7 +11,11 @@ import numpy as np import pytest import torch -from crazy_robotaxi.application import _MODEL_PRESETS, CrazyRobotaxiApplication +from crazy_robotaxi.application import ( + _MODEL_PRESETS, + CrazyRobotaxiApplication, + _fit_bev_renderer_to_ui, +) from crazy_robotaxi.physics import TaxiPhysicsWorld from crazy_robotaxi.session import CrazyRobotaxiModelLoop, CrazyRobotaxiSession from crazy_robotaxi.ui import CrazyRobotaxiImGuiUILoop @@ -20,7 +24,8 @@ RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, ) -from omnidreams_game_engine.config import RasterConfig +from omnidreams_game_engine.config import BevConfig, RasterConfig +from omnidreams_game_engine.renderer_settings import RendererSettings from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld from omnidreams_game_engine.types import ( CameraCalibration, @@ -97,6 +102,8 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert model_loop.state.ui_loop is ui_loop assert ui_loop.state.model_loop is model_loop assert ui_loop.state.profile_input_latency + assert session._config.renderer.bev.width == 234 + assert session._config.renderer.bev.height == 234 def test_native_window_accepts_crazy_robotaxi_output_contract() -> None: @@ -265,6 +272,9 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: assert configured == [app._pipeline_config] assert raster_sizes == [resolution_wh] assert session._config.renderer.raster.resolution_wh == resolution_wh + expected_bev_size = min(resolution_wh[0] // 4, resolution_wh[1] // 3) + assert session._config.renderer.bev.width == expected_bev_size + assert session._config.renderer.bev.height == expected_bev_size assert session._scene.initial_rgb.shape == ( resolution_wh[1], resolution_wh[0], @@ -296,6 +306,26 @@ def test_original_perf_honors_explicit_pipeline_overrides() -> None: assert pipeline.enable_sync_and_profile is True +def test_bev_render_fit_preserves_authored_aspect_ratio_and_smaller_sources() -> None: + raster = RasterConfig() + wide = RendererSettings(raster=raster, bev=BevConfig(width=800, height=400)) + small = RendererSettings(raster=raster, bev=BevConfig(width=120, height=80)) + + fitted_wide = _fit_bev_renderer_to_ui( + wide, + video_width=1280, + video_height=704, + ) + fitted_small = _fit_bev_renderer_to_ui( + small, + video_width=1280, + video_height=704, + ) + + assert (fitted_wide.bev.width, fitted_wide.bev.height) == (234, 117) + assert fitted_small is small + + @pytest.mark.parametrize( ("arguments", "expected"), [ diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 0ecfc07f6..4ce406beb 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -339,7 +339,7 @@ def project_world(self, points, rig_to_world): def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) - bev = torch.full((1, 3, 32, 32), 0.5) + bev = torch.full((1, 3, 32, 32), 191, dtype=torch.uint8) hud_state = TaxiHudState(width, height, _calibration()) hud_state.publish( build_hud_frames( diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py index 73734192d..0d035fc7e 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py @@ -135,7 +135,10 @@ def render(self, trajectory: TrajectoryChunk) -> ConditionBatch: camera_type=CAMERA_TYPE_BEV, resolution=(self._bev.height, self._bev.width), ) - bev = _normalize_hwc(bev_hwc) + # BEV is a UI-only channel. Preserve the renderer's native bytes + # instead of normalizing to BF16 only for presentation to reverse + # the conversion before uploading the ImGui texture. + bev = _bev_presentation_frames(bev_hwc) return ConditionBatch(hdmap_bvtchw=hdmap, bev_tchw=bev) def close(self) -> None: @@ -182,6 +185,13 @@ def _normalize_hwc(frames: Tensor) -> Tensor: return frames.permute(0, 3, 1, 2).to(torch.bfloat16) / 127.5 - 1.0 +def _bev_presentation_frames(frames: Tensor) -> Tensor: + """Keep renderer-native BEV bytes while changing HWC to TCHW layout.""" + if frames.dtype != torch.uint8 or frames.ndim != 4 or frames.shape[-1] != 3: + raise ValueError("BEV renderer output must be uint8 THWC RGB") + return frames.permute(0, 3, 1, 2).contiguous() + + def _build_bev_camera(bev: BevConfig, device: torch.device) -> FThetaCamera: cx = bev.width / 2.0 cy = bev.height / 2.0 diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/types.py b/apps/omnidreams_game_engine/omnidreams_game_engine/types.py index e9b4221f1..7f85a70c3 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/types.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/types.py @@ -228,4 +228,4 @@ class ConditionBatch: """Semantic main-camera frames in ``[B,V,T,C,H,W]`` and ``[-1,1]``.""" bev_tchw: Tensor | None = None - """Optional top-down frames in ``[T,C,H,W]`` and ``[-1,1]``.""" + """Optional uint8 top-down UI frames in ``[T,C,H,W]``.""" diff --git a/apps/omnidreams_game_engine/tests/test_conditioning.py b/apps/omnidreams_game_engine/tests/test_conditioning.py new file mode 100644 index 000000000..e31ac83cf --- /dev/null +++ b/apps/omnidreams_game_engine/tests/test_conditioning.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU checks for model versus UI conditioning tensor contracts.""" + +import pytest +import torch +from omnidreams_game_engine.conditioning import _bev_presentation_frames + +pytestmark = pytest.mark.ci_cpu + + +def test_bev_presentation_preserves_renderer_bytes_in_tchw_layout() -> None: + source = torch.arange(2 * 3 * 4 * 3, dtype=torch.uint8).reshape(2, 3, 4, 3) + + result = _bev_presentation_frames(source) + + assert result.shape == (2, 3, 3, 4) + assert result.dtype is torch.uint8 + assert result.is_contiguous() + assert torch.equal(result.permute(0, 2, 3, 1), source) + + +@pytest.mark.parametrize( + "source", + [ + torch.zeros(1, 3, 4, 3, dtype=torch.float32), + torch.zeros(1, 3, 4, 4, dtype=torch.uint8), + torch.zeros(3, 4, 3, dtype=torch.uint8), + ], +) +def test_bev_presentation_rejects_non_renderer_contract(source: torch.Tensor) -> None: + with pytest.raises(ValueError, match="uint8 THWC RGB"): + _bev_presentation_frames(source) diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 5a3defd9b..3d40ff39b 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -76,8 +76,11 @@ description, lazily constructs the shared pipeline, prepares immutable scene data, and returns an uninitialized session. The session registers a model loop and a `CrazyRobotaxiImGuiUILoop`. The model -loop emits generated RGB frames and, when configured, the synchronized raw BEV -frame. For each generated frame it publishes an immutable +loop emits generated RGB frames and, when configured, the synchronized raw +uint8 BEV frame. Because the BEV exists only inside a HUD window, session +creation caps its raster to the actual ImGui image extent while preserving an +authored aspect ratio; model HD-map conditioning retains full output +resolution. For each generated frame it publishes an immutable `TaxiHudFrame` containing its game snapshot and camera pose to the UI loop with `invoke_async`. The UI loop selects the metadata aligned with the currently presented model frame and caches that frame's projected in-world waypoint @@ -185,9 +188,10 @@ return [video StepResult, optional raw_bev StepResult] The UI loop uses V2's `ImGuiUILoop` and `presented_model_frames()`. It projects waypoint geometry only when the presented frame changes and submits the cached -primitives to ImGui's background draw list each UI tick. It converts the small -raw BEV frame to cached RGB bytes only when the presented BEV changes and -displays it through the runtime-provided ImGui image bridge. The base loop +primitives to ImGui's background draw list each UI tick. It materializes the +small renderer-native uint8 BEV frame as cached RGB bytes only when the +presented BEV changes and displays it through the runtime-provided ImGui image +bridge. The base loop composites the complete Dear ImGui overlay over the video back buffer. There is no full-frame waypoint or BEV layer, retained SlangPy widget tree, intermediate app renderer, local adapter, model session, chunk request, private command @@ -233,8 +237,8 @@ The reusable engine defines narrow game-facing contracts: - `PhysicsWorld` resolves ego, static, and actor interactions and produces synchronized actor trajectories. - `ConditionRenderer` converts a scene and `EngineStep` into model-ready HD-map - frames plus optional raw BEV data; the application turns that data into an - RGBA presentation layer before publishing the model step. + frames plus optional renderer-native uint8 BEV data; the application + publishes that data as a second synchronized result for the UI loop. - `GameEngine` sequences these components; it knows nothing about taxi fares, leaderboards, application entry points, V2 windows, or model presets. diff --git a/docs/design/crazy_robotaxi_v2_performance.md b/docs/design/crazy_robotaxi_v2_performance.md new file mode 100644 index 000000000..c44ed274c --- /dev/null +++ b/docs/design/crazy_robotaxi_v2_performance.md @@ -0,0 +1,139 @@ +# Crazy Robotaxi V2 performance investigation + +This note separates measured facts from hypotheses for the V2 port. Raw JSON +and `/usr/bin/time` captures are local benchmark artifacts and are intentionally +not source files. + +## Evidence from the recopied captures + +The `crazy-native` and `crazy-webrtc` commands used the `perf` preset at +1280x704, seed 42, 32 visible blocks, and four hidden prewarm blocks. The +statistics below exclude steps 0-3 and report median / p90 over steps 4-31. + +| Stage | Native window | WebRTC | +| --- | ---: | ---: | +| Pipeline wall | 420.0 / 421.4 ms | 423.8 / 434.8 ms | +| Engine wall | 54.0 / 57.0 ms | 54.1 / 57.5 ms | +| Rollout wall | 473.7 / 477.8 ms | 477.0 / 491.7 ms | +| Model-thread CPU | 469.4 / 472.0 ms | 474.0 / 487.2 ms | +| Effective 8-frame throughput from median rollout | 16.9 fps | 16.8 fps | + +The model produces 8-frame steady-state chunks representing 266.7 ms at the +declared 30 fps. These captures therefore prove a throughput miss, independent +of presentation: a 474-477 ms producer cannot continuously supply 30 fps. The +native and WebRTC generation numbers are close, so WebRTC is not causing the +model-thread chunk gap in this comparison. + +The process-wide `/usr/bin/time` captures averaged 126% CPU for native window +and 160% for WebRTC. The model metrics explain about one saturated core: +pipeline CPU time is approximately equal to pipeline wall time (about 420 ms +per chunk) even with synchronized stage profiling disabled. That timing +boundary surrounds only `pipeline.generate` and `pipeline.finalize`, so the +CPU use is not attributable to taxi rules, PhysX, or HUD code. The standalone +artifact does not record thread CPU and still needs a native stack sample to +confirm the same wait there. WebRTC adds roughly one third of a CPU core in +this pair of runs, consistent with presentation and video encoding work. + +The synchronized `crazy-pipeline-perf` and `omnidreams-pipeline-perf` captures +provide the pipeline-alone versus pipeline-in-game comparison: + +| Synchronized stage | Pipeline alone | Inside Crazy Robotaxi | +| --- | ---: | ---: | +| Encode median | 60.2 ms | 47.9 ms | +| Diffuse median | 358.7 ms | 351.8 ms | +| Decode median | 17.9 ms | 17.9 ms | +| Finalize median | 179.1 ms | 179.4 ms | +| Pipeline total median | 616.0 ms | 596.5 ms | + +These independent runs do not prove a speedup inside the game, but they do +rule out a material pipeline regression caused by the Crazy Robotaxi wrapper. +The app adds a measured 55.8 ms median engine stage in the synchronized run. +In the lower-overhead native capture, the steady engine median is 54.0 ms: +about 39.0 ms simulation, 13.1 ms conditioning, and 1.5 ms rules. + +An older capture contains 127-147 ms CPU spikes from the superseded NumPy +waypoint rasterizer. Waypoints now use an ImGui background draw list, so that +specific hotspot no longer exists and must not be projected onto current runs. + +## App-side BEV correction + +The Dear ImGui BEV was rendered at 1024x1024, normalized to BF16 on the GPU, +converted back to uint8 on the UI thread, copied to the CPU, expanded to RGBA, +and uploaded to a SlangPy texture. Its default on-screen image is only 234x234 +at 1280x704. + +The corrected path caps the BEV renderer to the actual image extent while +preserving authored aspect ratio and keeps the renderer's native uint8 pixels +through `ConditionBatch`. This reduces default BEV pixels and upload bytes by +19.15x. A CPU sanity probe measured 0.727 ms for the old 1024x1024 BF16 +materialization versus 0.061 ms for the new 234x234 uint8 layout conversion. +Those CPU numbers validate the direction only; GPU renderer, synchronization, +and UI timings still require a target-machine run. + +The remaining D2H/H2D round trip is an API boundary: V2's ImGui pixel helper +accepts NumPy-like pixels and calls `texture.copy_from_numpy`. Eliminating it +cleanly requires the ImGui renderer to accept a CUDA tensor or expose a +CUDA-backed SlangPy texture registration hook. The app should not duplicate +the renderer's Vulkan/CUDA resource ownership to work around that missing API. + +## Backpressure and visible latency + +The reported `block` versus `drop_oldest` behavior is consistent with a UI +consumer falling behind the producer. `block` preserves every generated frame, +but V2 may retain the currently presented chunk plus two queued chunks. For +8-frame chunks at 30 fps, that permits roughly 800 ms of model-frame history +before transport latency. `drop_oldest` bounds staleness by evicting complete +chunks, which necessarily creates visible motion jumps. + +The app-side BEV correction targets a concrete reason the UI consumer may miss +its cadence. If a matched rerun still fills the queue, the remaining fix is an +API policy rather than a game rule: V2 needs a configurable latency bound (or +frame-based queue capacity) that can keep at most the next ordered chunk +without dropping it. The earlier pre-generation capacity wait did not change +the two-chunk capacity and therefore could not establish that bound. + +## Required like-for-like rerun + +The existing native/WebRTC captures do **not** test the new `original-perf` +preset. The original non-V2 demo uses `example_world_model_perf.yaml`, whose +important differences include 1168x640, native FP8 DiT with cuDNN attention, +`skip_finalize_kv_cache`, and denoising timesteps `[1000, 100]`. Use fresh +processes and keep ordered presentation for this comparison: + +```bash +/usr/bin/time -v -o /tmp/crazy-original-native.time \ + uv run --no-sync flashdreams-run-v2 crazy-robotaxi \ + --mode native-window \ + --pixel-width 1168 \ + --pixel-height 640 \ + --backpressure-mode block \ + --presentation-mode only_present_newest \ + --stats-path /tmp/crazy-original-native.json \ + -- \ + --model-preset original-perf \ + --seed 42 \ + --total-blocks 32 \ + --prewarm-blocks 4 +``` + +Repeat with `--mode webrtc` and different artifact names. Do not enable +`--profile-pipeline` for headline throughput because its CUDA synchronization +changes the result. Run a separate diagnostic capture with that flag only when +stage attribution is needed. + +Record GPU, driver, CUDA, PyTorch, cuDNN, checkpoint revisions, and compiler +cache state alongside the artifacts. Exclude at least steps 0-3, then report +median and p90. The remaining questions are: + +1. Does matched `original-perf` reach the original demo's measured producer + throughput on the same machine? +2. After the BEV correction, how much UI-thread CPU remains in native and + WebRTC modes? +3. If the pipeline still consumes nearly one full CPU core, which native or + CUDA wait accounts for it? That requires a native stack sample or CPU + profiler and belongs to the OmniDreams integration/API team rather than the + game loop. + +Current validation status: **Useful app-side correction, pending target-GPU +measurement**. Ordered frames and the model conditioning path are unchanged; +the unvalidated part is the magnitude of the runtime gain. From 2f9faa70cebcacdffcdd57aad379c3f4f12d9556 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 04:55:48 +0000 Subject: [PATCH 18/62] Optimize Crazy Robotaxi physics bridge Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 6 + apps/crazy_robotaxi/crazy_robotaxi/session.py | 5 + apps/crazy_robotaxi/tests/test_application.py | 1 + .../simulation/ego_vehicle_kinematics.py | 17 ++ .../simulation/game_physics.py | 204 +++++++++++++---- .../omnidreams_game_engine/types.py | 15 ++ .../tests/test_engine.py | 62 ++++++ .../tests/test_game_physics.py | 210 ++++++++++++++++++ docs/design/crazy_robotaxi_v2_performance.md | 91 +++++--- 9 files changed, 537 insertions(+), 74 deletions(-) create mode 100644 apps/omnidreams_game_engine/tests/test_game_physics.py diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index b0e6adbc6..a522c4152 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -105,6 +105,12 @@ UI-thread work and are not included in model-step metrics. The JSON sink normalizes `_ms` metric names to `_s`. Exclude the first chunks when judging steady state because compilation and graph capture are startup costs. +The PhysX split includes `physx_traffic_prepare_ms`, +`physx_barrier_rebound_ms`, `physx_traffic_update_ms`, +`physx_state_materialize_ms`, and `physx_bridge_other_ms`. Together they locate +adapter work within `physx_bridge_ms` without enabling synchronized GPU-stage +profiling. + The BEV is HUD-only data, so Crazy Robotaxi caps its raster resolution to the actual ImGui map-image extent while preserving the authored aspect ratio. At the default 1280x704 output this changes the default square BEV from 1024x1024 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 24d8c6678..6b4020924 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -279,6 +279,11 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: "physx_solver_ms": physx.solver_ms, "physx_readback_ms": physx.readback_ms, "physx_bridge_ms": physx.bridge_ms, + "physx_traffic_prepare_ms": physx.traffic_prepare_ms, + "physx_barrier_rebound_ms": physx.barrier_rebound_ms, + "physx_traffic_update_ms": physx.traffic_update_ms, + "physx_state_materialize_ms": (physx.state_materialize_ms), + "physx_bridge_other_ms": physx.bridge_other_ms, } ) if state.config.pipeline_profiling and realtime_margin_ms < 0.0: diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index cd7f6e975..df1755a83 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -89,6 +89,7 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: ) session = app.create_session(desc) + assert isinstance(session, CrazyRobotaxiSession) session.init() ui_loop, model_loop = session._take_loops() diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py index 797ef801e..79d63c933 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/ego_vehicle_kinematics.py @@ -343,6 +343,11 @@ def sample_chunk_trajectory( solver_ms = 0.0 readback_ms = 0.0 bridge_ms = 0.0 + traffic_prepare_ms = 0.0 + barrier_rebound_ms = 0.0 + traffic_update_ms = 0.0 + state_materialize_ms = 0.0 + bridge_other_ms = 0.0 max_visible_actors = 0 max_detached_actors = 0 actor_collision_detected = False @@ -412,6 +417,13 @@ def sample_chunk_trajectory( max_detached_actors = max( max_detached_actors, step_timings.detached_actor_count ) + bridge_timings = getattr(physics_world, "last_step_bridge_timings", None) + if bridge_timings is not None: + traffic_prepare_ms += bridge_timings.traffic_prepare_ms + barrier_rebound_ms += bridge_timings.barrier_rebound_ms + traffic_update_ms += bridge_timings.traffic_update_ms + state_materialize_ms += bridge_timings.state_materialize_ms + bridge_other_ms += bridge_timings.other_ms actor_samples.append(frame_actor_samples) if capture_physics_debug: physics_debug_frames.append(physics_world.debug_frame(state)) @@ -446,6 +458,11 @@ def sample_chunk_trajectory( solver_ms=solver_ms, readback_ms=readback_ms, bridge_ms=bridge_ms, + traffic_prepare_ms=traffic_prepare_ms, + barrier_rebound_ms=barrier_rebound_ms, + traffic_update_ms=traffic_update_ms, + state_materialize_ms=state_materialize_ms, + bridge_other_ms=bridge_other_ms, step_count=chunk_size, max_visible_actors=max_visible_actors, max_detached_actors=max_detached_actors, diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py index 00fe2f498..52167786c 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py @@ -20,7 +20,7 @@ import math import time from collections.abc import Callable -from dataclasses import replace +from dataclasses import dataclass, replace import numpy as np from loguru import logger @@ -180,60 +180,140 @@ def _simplify_barrier_segments(segments_world: np.ndarray) -> tuple[np.ndarray, return tuple(simplified) +@dataclass(frozen=True, slots=True) +class _BarrierReboundIndex: + """Precomputed active-barrier geometry for vectorized contact detection.""" + + starts_xy_m: np.ndarray + """Start point for each active barrier segment.""" + + vectors_xy_m: np.ndarray + """End-minus-start vector for each active barrier segment.""" + + length_squared_m2: np.ndarray + """Squared segment lengths aligned with ``starts_xy_m``.""" + + half_thickness_m: np.ndarray + """Half collision thickness for each active barrier segment.""" + + @classmethod + def from_arrays( + cls, + segments_xy_m: np.ndarray, + thicknesses_m: np.ndarray, + ) -> _BarrierReboundIndex: + """Build an index from active barrier arrays. + + Args: + segments_xy_m: Barrier endpoints with shape ``[N, 2, 2]``. + thicknesses_m: Barrier thicknesses with shape ``[N]``. + + Returns: + Contiguous arrays reused for every frame until the physics window + changes. + """ + segments = np.ascontiguousarray(segments_xy_m, dtype=np.float32) + thicknesses = np.ascontiguousarray(thicknesses_m, dtype=np.float32) + if segments.ndim != 3 or segments.shape[1:] != (2, 2): + raise ValueError("segments_xy_m must have shape [N, 2, 2]") + if thicknesses.shape != (len(segments),): + raise ValueError("thicknesses_m must have shape [N]") + starts = np.ascontiguousarray(segments[:, 0]) + vectors = np.ascontiguousarray(segments[:, 1] - starts) + length_squared = np.einsum("ni,ni->n", vectors, vectors) + return cls( + starts_xy_m=starts, + vectors_xy_m=vectors, + length_squared_m2=np.ascontiguousarray(length_squared), + half_thickness_m=np.ascontiguousarray(thicknesses * 0.5), + ) + + +@dataclass(frozen=True, slots=True) +class PhysicsBridgeTimings: + """Measured Python adapter components surrounding one native PhysX step.""" + + traffic_prepare_ms: float = 0.0 + """Tracked-traffic preparation before native simulation.""" + + barrier_rebound_ms: float = 0.0 + """Static-barrier contact detection and velocity reinforcement.""" + + traffic_update_ms: float = 0.0 + """Tracked-traffic observation and control publication.""" + + state_materialize_ms: float = 0.0 + """Ego and actor state publication into engine-owned objects.""" + + other_ms: float = 0.0 + """Remaining adapter work outside the named components.""" + + def _reinforce_static_barrier_rebound( - barriers: tuple[InvisibleBarrier, ...], + barriers: _BarrierReboundIndex, requested_ego: BodyState, resolved_velocity_mps: np.ndarray, ego_model: RigidBodyModel, restitution: float, ) -> tuple[np.ndarray, bool]: - """Raise outward barrier velocity and report inward barrier contact.""" + """Raise outward barrier velocity after vectorized contact detection.""" position = np.asarray(requested_ego.position_m[:2], dtype=np.float32) incoming_velocity = np.asarray(requested_ego.linear_velocity_mps, dtype=np.float32) reinforced = np.asarray(resolved_velocity_mps, dtype=np.float32).copy() + if len(barriers.starts_xy_m) == 0: + return reinforced, False + yaw = _yaw_from_quaternion_xyzw(requested_ego.orientation_xyzw) forward = np.asarray([math.cos(yaw), math.sin(yaw)], dtype=np.float32) left = np.asarray([-forward[1], forward[0]], dtype=np.float32) half_extents = ego_model.half_extents_m - contact_detected = False - - for barrier in barriers: - start = np.asarray(barrier.start_xy_m, dtype=np.float32) - end = np.asarray(barrier.end_xy_m, dtype=np.float32) - segment = end - start - length_squared = float(np.dot(segment, segment)) - if length_squared <= 1.0e-8: - continue - alpha = float( - np.clip(np.dot(position - start, segment) / length_squared, 0.0, 1.0) - ) - offset = position - (start + segment * alpha) - distance = float(np.linalg.norm(offset)) - if distance > 1.0e-6: - normal = offset / distance - else: - speed = float(np.linalg.norm(incoming_velocity[:2])) - normal = ( - -incoming_velocity[:2] / speed - if speed > 1.0e-6 - else np.asarray([1.0, 0.0], dtype=np.float32) - ) - support = ( - abs(float(np.dot(normal, forward))) * half_extents[0] - + abs(float(np.dot(normal, left))) * half_extents[1] - ) - contact_distance = support + barrier.thickness_m * 0.5 - if distance > contact_distance + _BARRIER_CONTACT_SLOP_M: - continue - incoming_normal_speed = float(np.dot(incoming_velocity[:2], normal)) - if incoming_normal_speed >= 0.0: - continue - contact_detected = True + + valid_segments = barriers.length_squared_m2 > 1.0e-8 + safe_length_squared = np.where(valid_segments, barriers.length_squared_m2, 1.0) + relative_position = position[None, :] - barriers.starts_xy_m + alpha = np.clip( + np.einsum("ni,ni->n", relative_position, barriers.vectors_xy_m) + / safe_length_squared, + 0.0, + 1.0, + ) + offsets = position[None, :] - ( + barriers.starts_xy_m + barriers.vectors_xy_m * alpha[:, None] + ) + distances = np.linalg.norm(offsets, axis=1) + normals = np.empty_like(offsets) + separated = distances > 1.0e-6 + normals[separated] = offsets[separated] / distances[separated, None] + if bool(np.any(~separated)): + speed = float(np.linalg.norm(incoming_velocity[:2])) + fallback_normal = ( + -incoming_velocity[:2] / speed + if speed > 1.0e-6 + else np.asarray([1.0, 0.0], dtype=np.float32) + ) + normals[~separated] = fallback_normal + + supports = ( + np.abs(normals @ forward) * half_extents[0] + + np.abs(normals @ left) * half_extents[1] + ) + incoming_normal_speeds = normals @ incoming_velocity[:2] + contact_indices = np.flatnonzero( + valid_segments + & (distances <= supports + barriers.half_thickness_m + _BARRIER_CONTACT_SLOP_M) + & (incoming_normal_speeds < 0.0) + ) + + # Apply the small candidate set in authored order so corner contacts retain + # the scalar path's response semantics. + for index in contact_indices: + normal = normals[index] + incoming_normal_speed = float(incoming_normal_speeds[index]) target_outward_speed = -restitution * incoming_normal_speed resolved_normal_speed = float(np.dot(reinforced[:2], normal)) if resolved_normal_speed < target_outward_speed: reinforced[:2] += normal * (target_outward_speed - resolved_normal_speed) - return reinforced, contact_detected + return reinforced, len(contact_indices) > 0 class GamePhysicsWorld: @@ -309,6 +389,10 @@ def __init__( timestamp_us=initial_timestamp_us, ) self._physics_graph = self._with_active_map_traffic(base_physics_graph) + self._active_objects_by_id = { + scene_object.object_id: scene_object + for scene_object in self._physics_graph.objects + } self._physics_center_xy = initial_xy.copy() self._physics_timestamp_us = initial_timestamp_us self._entities = [ @@ -317,6 +401,7 @@ def __init__( self._entities_by_id = {entity.entity_id: entity for entity in self._entities} self._detached_entity_ids: set[str] = set() self.last_step_timings = None + self.last_step_bridge_timings = None self.last_step_actor_collision = False self.last_step_static_barrier_collision = False self.last_step_static_barrier_impact = False @@ -506,6 +591,10 @@ def synchronize_window( self._entities_by_id = {entity.entity_id: entity for entity in self._entities} self._detached_entity_ids.intersection_update(self._entities_by_id) self._physics_graph = physics_graph + self._active_objects_by_id = { + scene_object.object_id: scene_object + for scene_object in self._physics_graph.objects + } self._physics_center_xy = center.copy() if timestamp_us is not None: self._physics_timestamp_us = timestamp_us @@ -538,6 +627,10 @@ def _refresh_debug_barriers(self) -> None: self._debug_barrier_segments = np.empty((0, 2, 2), dtype=np.float32) self._debug_barrier_thicknesses = np.empty((0,), dtype=np.float32) self._debug_barrier_heights = np.empty((0,), dtype=np.float32) + self._barrier_rebound_index = _BarrierReboundIndex.from_arrays( + self._debug_barrier_segments, + self._debug_barrier_thicknesses, + ) def debug_frame(self, state: VehicleState) -> PhysicsDebugFrame: """Capture the active collider topology without rendering it.""" @@ -684,19 +777,22 @@ def step( ego_before_step = _body_state_from_vehicle( state, self._ego_model.half_extents_m[2] ) + traffic_prepare_started_at = time.perf_counter() self._map_traffic.prepare_step(self._world, ego_before_step, dt_s) + traffic_prepare_ms = (time.perf_counter() - traffic_prepare_started_at) * 1000.0 physics_step = self._world.step_compact( ego_before_step, timestamp_us, dt_s, ) + barrier_rebound_started_at = time.perf_counter() self.last_step_static_barrier_collision = False if self._static_barrier_restitution is not None: ( reinforced_velocity, self.last_step_static_barrier_collision, ) = _reinforce_static_barrier_rebound( - self._physics_graph.barriers, + self._barrier_rebound_index, ego_before_step, physics_step.ego.linear_velocity_mps, self._ego_model, @@ -709,15 +805,13 @@ def step( linear_velocity_mps=reinforced_velocity, ), ) + barrier_rebound_ms = (time.perf_counter() - barrier_rebound_started_at) * 1000.0 + traffic_update_started_at = time.perf_counter() self.last_step_static_barrier_impact = ( self.last_step_static_barrier_collision and not self._static_barrier_contact_active ) self._static_barrier_contact_active = self.last_step_static_barrier_collision - active_objects = { - scene_object.object_id: scene_object - for scene_object in self._physics_graph.objects - } if physics_step.struck_object_ids: self._pending_struck_vehicle_ids.update(physics_step.struck_object_ids) self._visual_flare_collision_velocity_mps = ( @@ -733,7 +827,7 @@ def step( } for object_id in physics_step.struck_object_ids: body = actor_bodies.get(object_id) - scene_object = active_objects.get(object_id) + scene_object = self._active_objects_by_id.get(object_id) if body is None or scene_object is None: continue separation_xy = body.position_m[:2] - ego_before_step.position_m[:2] @@ -805,7 +899,9 @@ def step( (object_id, body.position_m, body.orientation_xyzw, detached) ) self._world.apply_track_controls(tuple(pending_controls)) + traffic_update_ms = (time.perf_counter() - traffic_update_started_at) * 1000.0 + state_materialize_started_at = time.perf_counter() detached_ids = {sample[0] for sample in actor_samples if sample[3]} for object_id in self._detached_entity_ids - detached_ids: self._entities_by_id[object_id].detached_from_track = False @@ -871,16 +967,34 @@ def step( ) for object_id, position, orientation, detached in actor_samples ) + state_materialize_ms = ( + time.perf_counter() - state_materialize_started_at + ) * 1000.0 step_total_ms = (time.perf_counter() - step_started_at) * 1000.0 native_ms = ( physics_step.timings.actor_update_ms + physics_step.timings.solver_ms + physics_step.timings.readback_ms ) + bridge_ms = max(0.0, step_total_ms - native_ms) self.last_step_timings = replace( physics_step.timings, total_ms=step_total_ms, - bridge_ms=max(0.0, step_total_ms - native_ms), + bridge_ms=bridge_ms, + ) + self.last_step_bridge_timings = PhysicsBridgeTimings( + traffic_prepare_ms=traffic_prepare_ms, + barrier_rebound_ms=barrier_rebound_ms, + traffic_update_ms=traffic_update_ms, + state_materialize_ms=state_materialize_ms, + other_ms=max( + 0.0, + bridge_ms + - traffic_prepare_ms + - barrier_rebound_ms + - traffic_update_ms + - state_materialize_ms, + ), ) return result_state, samples diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/types.py b/apps/omnidreams_game_engine/omnidreams_game_engine/types.py index 7f85a70c3..6630b9985 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/types.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/types.py @@ -176,6 +176,21 @@ class PhysXChunkTimings: solver_ms: float = 0.0 readback_ms: float = 0.0 bridge_ms: float = 0.0 + traffic_prepare_ms: float = 0.0 + """Time spent preparing tracked traffic before native simulation.""" + + barrier_rebound_ms: float = 0.0 + """Time spent detecting and reinforcing static-barrier contacts.""" + + traffic_update_ms: float = 0.0 + """Time spent consuming native actor states and updating traffic controls.""" + + state_materialize_ms: float = 0.0 + """Time spent publishing simulated ego and actor state to engine objects.""" + + bridge_other_ms: float = 0.0 + """Remaining adapter time outside the named bridge stages.""" + step_count: int = 0 max_visible_actors: int = 0 max_detached_actors: int = 0 diff --git a/apps/omnidreams_game_engine/tests/test_engine.py b/apps/omnidreams_game_engine/tests/test_engine.py index 3c97a8362..b51aa0c9c 100644 --- a/apps/omnidreams_game_engine/tests/test_engine.py +++ b/apps/omnidreams_game_engine/tests/test_engine.py @@ -6,6 +6,8 @@ from __future__ import annotations from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import cast import numpy as np import pytest @@ -16,6 +18,10 @@ from omnidreams_game_engine.simulation.ego_vehicle_kinematics import ( sample_chunk_trajectory, ) +from omnidreams_game_engine.simulation.game_physics import ( + GamePhysicsWorld, + PhysicsBridgeTimings, +) from omnidreams_game_engine.types import ( ConditionBatch, DriverCommand, @@ -161,3 +167,59 @@ def test_trajectory_sampling_copies_slotted_start_state() -> None: assert trajectory.vehicle_states[0] == start_state assert trajectory.vehicle_states[0] is not start_state assert trajectory.boundary_state_after_chunk is trajectory.vehicle_states[0] + + +def test_trajectory_sampling_aggregates_physics_bridge_timings() -> None: + class PhysicsWorld: + last_step_actor_collision = False + last_step_static_barrier_impact = False + last_step_timings = SimpleNamespace( + actor_update_ms=1.0, + solver_ms=2.0, + readback_ms=3.0, + bridge_ms=4.0, + visible_actor_count=5, + detached_actor_count=1, + ) + last_step_bridge_timings = PhysicsBridgeTimings( + traffic_prepare_ms=0.1, + barrier_rebound_ms=0.2, + traffic_update_ms=0.3, + state_materialize_ms=0.4, + other_ms=3.0, + ) + + def synchronize_window(self, center_xy_m, timestamp_us): + del center_xy_m, timestamp_us + + def build_trajectories(self, timestamps, actor_samples): + del timestamps, actor_samples + return () + + def step_physics(physics_world, state, command, timestamp_us, dt_s): + del physics_world, command, timestamp_us, dt_s + return state, () + + trajectory = sample_chunk_trajectory( + start_state=_state(), + start_timestamp_us=100, + commands=(DriverCommand(), DriverCommand()), + chunk_size=2, + chunk_config=ChunkConfig(initial_chunk_frames=2, chunk_frames=2), + vehicle_config=VehicleConfig(), + ground_snapper=None, + physics_world=cast(GamePhysicsWorld, PhysicsWorld()), + physics_step_fn=step_physics, + ) + + timings = trajectory.physx_timings + assert timings is not None + assert timings.actor_update_ms == pytest.approx(2.0) + assert timings.solver_ms == pytest.approx(4.0) + assert timings.readback_ms == pytest.approx(6.0) + assert timings.bridge_ms == pytest.approx(8.0) + assert timings.traffic_prepare_ms == pytest.approx(0.2) + assert timings.barrier_rebound_ms == pytest.approx(0.4) + assert timings.traffic_update_ms == pytest.approx(0.6) + assert timings.state_materialize_ms == pytest.approx(0.8) + assert timings.bridge_other_ms == pytest.approx(6.0) diff --git a/apps/omnidreams_game_engine/tests/test_game_physics.py b/apps/omnidreams_game_engine/tests/test_game_physics.py new file mode 100644 index 000000000..d6a454e6e --- /dev/null +++ b/apps/omnidreams_game_engine/tests/test_game_physics.py @@ -0,0 +1,210 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU parity tests for game-specific PhysX bridge helpers.""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest +from ludus_renderer import BodyState, InvisibleBarrier, RigidBodyModel +from omnidreams_game_engine.simulation.game_physics import ( + _BARRIER_CONTACT_SLOP_M, + _BarrierReboundIndex, + _reinforce_static_barrier_rebound, +) + +pytestmark = pytest.mark.ci_cpu + + +def _body_state( + *, + position_xy: tuple[float, float], + velocity_xy: tuple[float, float], + yaw_rad: float, +) -> BodyState: + half_yaw = yaw_rad * 0.5 + return BodyState( + position_m=np.asarray([*position_xy, 0.5], dtype=np.float32), + orientation_xyzw=np.asarray( + [0.0, 0.0, math.sin(half_yaw), math.cos(half_yaw)], + dtype=np.float32, + ), + linear_velocity_mps=np.asarray([*velocity_xy, 0.0], dtype=np.float32), + angular_velocity_radps=np.zeros(3, dtype=np.float32), + ) + + +def _index(barriers: tuple[InvisibleBarrier, ...]) -> _BarrierReboundIndex: + segments = np.asarray( + [[barrier.start_xy_m, barrier.end_xy_m] for barrier in barriers], + dtype=np.float32, + ).reshape((-1, 2, 2)) + thicknesses = np.asarray( + [barrier.thickness_m for barrier in barriers], dtype=np.float32 + ) + return _BarrierReboundIndex.from_arrays(segments, thicknesses) + + +def _scalar_rebound( + barriers: tuple[InvisibleBarrier, ...], + requested_ego: BodyState, + resolved_velocity_mps: np.ndarray, + ego_model: RigidBodyModel, + restitution: float, +) -> tuple[np.ndarray, bool]: + position = np.asarray(requested_ego.position_m[:2], dtype=np.float32) + incoming_velocity = np.asarray(requested_ego.linear_velocity_mps, dtype=np.float32) + reinforced = np.asarray(resolved_velocity_mps, dtype=np.float32).copy() + x, y, z, w = [float(value) for value in requested_ego.orientation_xyzw] + yaw = math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + forward = np.asarray([math.cos(yaw), math.sin(yaw)], dtype=np.float32) + left = np.asarray([-forward[1], forward[0]], dtype=np.float32) + contact_detected = False + + for barrier in barriers: + start = np.asarray(barrier.start_xy_m, dtype=np.float32) + end = np.asarray(barrier.end_xy_m, dtype=np.float32) + segment = end - start + length_squared = float(np.dot(segment, segment)) + if length_squared <= 1.0e-8: + continue + alpha = float( + np.clip(np.dot(position - start, segment) / length_squared, 0.0, 1.0) + ) + offset = position - (start + segment * alpha) + distance = float(np.linalg.norm(offset)) + if distance > 1.0e-6: + normal = offset / distance + else: + speed = float(np.linalg.norm(incoming_velocity[:2])) + normal = ( + -incoming_velocity[:2] / speed + if speed > 1.0e-6 + else np.asarray([1.0, 0.0], dtype=np.float32) + ) + support = ( + abs(float(np.dot(normal, forward))) * ego_model.half_extents_m[0] + + abs(float(np.dot(normal, left))) * ego_model.half_extents_m[1] + ) + if distance > support + barrier.thickness_m * 0.5 + _BARRIER_CONTACT_SLOP_M: + continue + incoming_normal_speed = float(np.dot(incoming_velocity[:2], normal)) + if incoming_normal_speed >= 0.0: + continue + contact_detected = True + target_outward_speed = -restitution * incoming_normal_speed + resolved_normal_speed = float(np.dot(reinforced[:2], normal)) + if resolved_normal_speed < target_outward_speed: + reinforced[:2] += normal * (target_outward_speed - resolved_normal_speed) + return reinforced, contact_detected + + +def test_vectorized_barrier_rebound_matches_scalar_reference() -> None: + rng = np.random.default_rng(20260827) + random_endpoints = rng.uniform(-6.0, 6.0, size=(96, 2, 2)).astype(np.float32) + random_endpoints[0, 1] = random_endpoints[0, 0] + barriers = tuple( + InvisibleBarrier( + (float(endpoints[0, 0]), float(endpoints[0, 1])), + (float(endpoints[1, 0]), float(endpoints[1, 1])), + thickness_m=float(rng.uniform(0.1, 0.8)), + ) + for endpoints in random_endpoints + ) + index = _index(barriers) + ego_model = RigidBodyModel(mass_kg=1_500.0, half_extents_m=(1.4, 0.7, 0.5)) + + for _ in range(128): + requested = _body_state( + position_xy=tuple(rng.uniform(-4.0, 4.0, size=2)), + velocity_xy=tuple(rng.uniform(-15.0, 15.0, size=2)), + yaw_rad=float(rng.uniform(-math.pi, math.pi)), + ) + resolved = rng.uniform(-15.0, 15.0, size=3).astype(np.float32) + restitution = float(rng.uniform(0.0, 1.0)) + + expected_velocity, expected_contact = _scalar_rebound( + barriers, + requested, + resolved, + ego_model, + restitution, + ) + actual_velocity, actual_contact = _reinforce_static_barrier_rebound( + index, + requested, + resolved, + ego_model, + restitution, + ) + + assert actual_contact is expected_contact + np.testing.assert_allclose( + actual_velocity, + expected_velocity, + rtol=2.0e-6, + atol=2.0e-6, + ) + + +def test_vectorized_barrier_rebound_preserves_corner_response_order() -> None: + barriers = ( + InvisibleBarrier((-5.0, 0.0), (5.0, 0.0), thickness_m=0.3), + InvisibleBarrier((0.0, -5.0), (0.0, 5.0), thickness_m=0.3), + ) + requested = _body_state( + position_xy=(0.2, 0.2), + velocity_xy=(-8.0, -6.0), + yaw_rad=0.35, + ) + resolved = np.asarray([-1.0, -2.0, 0.0], dtype=np.float32) + ego_model = RigidBodyModel(mass_kg=1_500.0, half_extents_m=(1.4, 0.7, 0.5)) + + expected = _scalar_rebound( + barriers, requested, resolved, ego_model, restitution=0.6 + ) + actual = _reinforce_static_barrier_rebound( + _index(barriers), requested, resolved, ego_model, restitution=0.6 + ) + + assert actual[1] is expected[1] + np.testing.assert_array_equal(actual[0], expected[0]) + + +def test_vectorized_barrier_rebound_reinforces_single_contact() -> None: + barriers = (InvisibleBarrier((-5.0, 0.0), (5.0, 0.0), thickness_m=0.3),) + requested = _body_state( + position_xy=(0.0, 0.2), + velocity_xy=(2.0, -6.0), + yaw_rad=0.0, + ) + resolved = np.asarray([2.0, -1.0, 0.0], dtype=np.float32) + ego_model = RigidBodyModel(mass_kg=1_500.0, half_extents_m=(1.4, 0.7, 0.5)) + + actual, contacted = _reinforce_static_barrier_rebound( + _index(barriers), requested, resolved, ego_model, restitution=0.5 + ) + + assert contacted + np.testing.assert_allclose(actual, np.asarray([2.0, 3.0, 0.0], np.float32)) + + +def test_empty_barrier_index_preserves_resolved_velocity() -> None: + requested = _body_state( + position_xy=(0.0, 0.0), + velocity_xy=(1.0, 2.0), + yaw_rad=0.0, + ) + resolved = np.asarray([3.0, 4.0, 5.0], dtype=np.float32) + ego_model = RigidBodyModel(mass_kg=1_500.0, half_extents_m=(1.4, 0.7, 0.5)) + + actual, contacted = _reinforce_static_barrier_rebound( + _index(()), requested, resolved, ego_model, restitution=0.5 + ) + + assert not contacted + np.testing.assert_array_equal(actual, resolved) + assert actual is not resolved diff --git a/docs/design/crazy_robotaxi_v2_performance.md b/docs/design/crazy_robotaxi_v2_performance.md index c44ed274c..8f956b657 100644 --- a/docs/design/crazy_robotaxi_v2_performance.md +++ b/docs/design/crazy_robotaxi_v2_performance.md @@ -12,21 +12,21 @@ statistics below exclude steps 0-3 and report median / p90 over steps 4-31. | Stage | Native window | WebRTC | | --- | ---: | ---: | -| Pipeline wall | 420.0 / 421.4 ms | 423.8 / 434.8 ms | -| Engine wall | 54.0 / 57.0 ms | 54.1 / 57.5 ms | -| Rollout wall | 473.7 / 477.8 ms | 477.0 / 491.7 ms | -| Model-thread CPU | 469.4 / 472.0 ms | 474.0 / 487.2 ms | -| Effective 8-frame throughput from median rollout | 16.9 fps | 16.8 fps | +| Pipeline wall | 436.0 / 439.9 ms | 430.8 / 436.8 ms | +| Engine wall | 54.9 / 59.3 ms | 53.0 / 59.3 ms | +| Rollout wall | 491.1 / 498.7 ms | 484.2 / 511.1 ms | +| Model-thread CPU | 485.5 / 491.5 ms | 477.2 / 486.3 ms | +| Effective 8-frame throughput from median rollout | 16.3 fps | 16.5 fps | The model produces 8-frame steady-state chunks representing 266.7 ms at the declared 30 fps. These captures therefore prove a throughput miss, independent -of presentation: a 474-477 ms producer cannot continuously supply 30 fps. The +of presentation: a 484-491 ms producer cannot continuously supply 30 fps. The native and WebRTC generation numbers are close, so WebRTC is not causing the model-thread chunk gap in this comparison. -The process-wide `/usr/bin/time` captures averaged 126% CPU for native window -and 160% for WebRTC. The model metrics explain about one saturated core: -pipeline CPU time is approximately equal to pipeline wall time (about 420 ms +The process-wide `/usr/bin/time` captures averaged 136% CPU for native window +and 156% for WebRTC. The model metrics explain about one saturated core: +pipeline CPU time is approximately equal to pipeline wall time (about 430 ms per chunk) even with synchronized stage profiling disabled. That timing boundary surrounds only `pipeline.generate` and `pipeline.finalize`, so the CPU use is not attributable to taxi rules, PhysX, or HUD code. The standalone @@ -39,17 +39,25 @@ provide the pipeline-alone versus pipeline-in-game comparison: | Synchronized stage | Pipeline alone | Inside Crazy Robotaxi | | --- | ---: | ---: | -| Encode median | 60.2 ms | 47.9 ms | -| Diffuse median | 358.7 ms | 351.8 ms | +| Encode median | 60.8 ms | 47.2 ms | +| Diffuse median | 362.2 ms | 354.5 ms | | Decode median | 17.9 ms | 17.9 ms | -| Finalize median | 179.1 ms | 179.4 ms | -| Pipeline total median | 616.0 ms | 596.5 ms | +| Finalize median | 180.4 ms | 181.2 ms | +| Pipeline total median | 620.7 ms | 601.0 ms | These independent runs do not prove a speedup inside the game, but they do rule out a material pipeline regression caused by the Crazy Robotaxi wrapper. -The app adds a measured 55.8 ms median engine stage in the synchronized run. -In the lower-overhead native capture, the steady engine median is 54.0 ms: -about 39.0 ms simulation, 13.1 ms conditioning, and 1.5 ms rules. +The app adds a measured 52.8 ms median engine stage in the synchronized run. +In the lower-overhead native capture, the steady engine median is 54.9 ms: +about 40.3 ms simulation, 12.8 ms conditioning, and 1.6 ms rules. + +The matched `original-perf` captures use 1168x640 and the original demo's +native FP8 DiT settings. They cut median rollout latency to 274.8 ms in the +native window and 272.4 ms over WebRTC, equivalent to 29.1 and 29.4 generated +fps. Their p90 latencies are 277.2 and 279.7 ms. This is a large improvement, +but every steady chunk remains above the 266.7 ms real-time budget. These files +exercise the V2 port with the matched preset; they are not captures from the +non-V2 application. An older capture contains 127-147 ms CPU spikes from the superseded NumPy waypoint rasterizer. Waypoints now use an ImGui background draw list, so that @@ -76,6 +84,30 @@ cleanly requires the ImGui renderer to accept a CUDA tensor or expose a CUDA-backed SlangPy texture registration hook. The app should not duplicate the renderer's Vulkan/CUDA resource ownership to work around that missing API. +## App-side PhysX bridge correction + +The matched native `original-perf` capture attributes 39.2 ms per chunk to +simulation. PhysX accounts for 36.0 ms: 30.8 ms in the Python/native bridge, +4.0 ms in the solver, and less than 0.1 ms in actor update plus readback. +PhysX already owns a two-worker CPU dispatcher, so adding application worker +threads cannot materially reduce this path. The eight simulated frames are +also state-dependent and cannot run concurrently. + +The dominant bridge path tested every active barrier through a Python loop on +every frame. The replacement caches contiguous barrier arrays whenever the +physics vicinity changes, evaluates the broad phase as one NumPy operation, +and applies collision responses sequentially only to the candidate contacts. +Candidate application retains authored barrier order, including corner +response behavior. A deterministic CPU parity sweep covers degenerate, +single-contact, and multi-contact geometry. + +An isolated CPU probe with 293 barriers and eight frames measured 21.1 ms for +the scalar scan and 0.51 ms for the vectorized scan, a 41.5x speedup for that +component. This is not a target-GPU headline result. Runtime metrics now split +the bridge into traffic preparation, barrier rebound, traffic update, state +materialization, and residual adapter time so a fresh run can validate where +the saved time lands. + ## Backpressure and visible latency The reported `block` versus `drop_oldest` behavior is consistent with a UI @@ -92,13 +124,12 @@ frame-based queue capacity) that can keep at most the next ordered chunk without dropping it. The earlier pre-generation capacity wait did not change the two-chunk capacity and therefore could not establish that bound. -## Required like-for-like rerun +## Required post-optimization rerun -The existing native/WebRTC captures do **not** test the new `original-perf` -preset. The original non-V2 demo uses `example_world_model_perf.yaml`, whose -important differences include 1168x640, native FP8 DiT with cuDNN attention, -`skip_finalize_kv_cache`, and denoising timesteps `[1000, 100]`. Use fresh -processes and keep ordered presentation for this comparison: +The original non-V2 demo uses `example_world_model_perf.yaml`. The +`original-perf` preset matches its important settings: 1168x640, native FP8 +DiT with cuDNN attention, `skip_finalize_kv_cache`, and denoising timesteps +`[1000, 100]`. Use fresh processes and keep ordered presentation: ```bash /usr/bin/time -v -o /tmp/crazy-original-native.time \ @@ -108,7 +139,7 @@ processes and keep ordered presentation for this comparison: --pixel-height 640 \ --backpressure-mode block \ --presentation-mode only_present_newest \ - --stats-path /tmp/crazy-original-native.json \ + --stats-path /tmp/crazy-vectorized-native.json \ -- \ --model-preset original-perf \ --seed 42 \ @@ -125,15 +156,17 @@ Record GPU, driver, CUDA, PyTorch, cuDNN, checkpoint revisions, and compiler cache state alongside the artifacts. Exclude at least steps 0-3, then report median and p90. The remaining questions are: -1. Does matched `original-perf` reach the original demo's measured producer +1. Does `physx_barrier_rebound_s` fall near the isolated probe, and does total + rollout latency move below the 266.7 ms chunk budget? +2. Does matched `original-perf` reach the original demo's measured producer throughput on the same machine? -2. After the BEV correction, how much UI-thread CPU remains in native and +3. After the BEV correction, how much UI-thread CPU remains in native and WebRTC modes? -3. If the pipeline still consumes nearly one full CPU core, which native or +4. If the pipeline still consumes nearly one full CPU core, which native or CUDA wait accounts for it? That requires a native stack sample or CPU profiler and belongs to the OmniDreams integration/API team rather than the game loop. -Current validation status: **Useful app-side correction, pending target-GPU -measurement**. Ordered frames and the model conditioning path are unchanged; -the unvalidated part is the magnitude of the runtime gain. +Current validation status: **Useful app-side optimization, pending target-GPU +measurement**. Physics response parity passes on CPU; the unvalidated part is +the magnitude of the full runtime gain. From 81d371af171cbfca0093e6f1ce7a0c733f209295 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 16:53:54 +0000 Subject: [PATCH 19/62] Isolate Crazy Robotaxi presentation stream Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/session.py | 10 +++ apps/crazy_robotaxi/crazy_robotaxi/ui.py | 69 ++++++++++++++++++ apps/crazy_robotaxi/tests/test_application.py | 1 + apps/crazy_robotaxi/tests/test_ui.py | 72 ++++++++++++++++++- docs/design/crazy_robotaxi_v2_architecture.md | 19 ++--- docs/design/crazy_robotaxi_v2_performance.md | 19 +++++ 6 files changed, 181 insertions(+), 9 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 6b4020924..af68bacbc 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -236,6 +236,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: input_transition_count=state.input_transition_count, input_ignored_event_count=state.input_ignored_event_count, input_dropped_transition_count=state.input_dropped_transition_count, + ready_event=_record_ready_event(video), ) invoke_async( state.ui_loop, @@ -365,6 +366,7 @@ def init(self) -> None: state=hud_state, width=self._session_desc.video_width, height=self._session_desc.video_height, + presentation_device=self._config.device, ) model_loop = self.register_model_loop( CrazyRobotaxiModelLoop, @@ -378,3 +380,11 @@ def init(self) -> None: ), ) hud_state.model_loop = model_loop + + +def _record_ready_event(video: torch.Tensor) -> torch.cuda.Event | None: + if not video.is_cuda: + return None + event = torch.cuda.Event() + event.record(torch.cuda.current_stream(video.device)) + return event diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 3dde7bb3b..38f2a234a 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -91,6 +91,9 @@ class TaxiHudFrame: input_dropped_transition_count: int = 0 """Cumulative transitions displaced by fixed-size model chunks.""" + ready_event: torch.cuda.Event | None = None + """CUDA event recorded after the model produced this frame's tensors.""" + @dataclass(slots=True) class TaxiHudState: @@ -123,6 +126,9 @@ class TaxiHudState: _waypoint_projections: tuple[TaxiCameraMarkerProjection, ...] = () """Cached world-marker projections for the presented generated frame.""" + _ready_source: TaxiHudFrame | None = None + """Presented frame whose model-to-UI CUDA dependency was submitted.""" + _name_input: str = "" """Immediate-mode name-entry buffer retained by the UI state.""" @@ -205,6 +211,16 @@ def consume_input_events(self, events: UserInputEvents) -> None: while len(self._input_received_at_s) > _MAX_BUFFERED_INPUT_EVENTS: self._input_received_at_s.popitem(last=False) + def wait_for_presented_frame(self, stream: torch.cuda.Stream) -> bool: + """Submit the current model frame's dependency to ``stream`` once.""" + selected = self._current + if selected is None or selected is self._ready_source: + return False + if selected.ready_event is not None: + stream.wait_event(selected.ready_event) + self._ready_source = selected + return selected.ready_event is not None + def _record_presented_input(self, selected: TaxiHudFrame) -> None: if not self.profile_input_latency: return @@ -311,6 +327,7 @@ def reset(self) -> None: self._current = None self._waypoint_source = None self._waypoint_projections = () + self._ready_source = None self._validation_message = "" self._submission_pending = False self._loading_status = "LOADING WORLD MODEL" @@ -514,27 +531,77 @@ def _submit_name(self, value: str) -> None: class CrazyRobotaxiImGuiUILoop(ImGuiUILoop[TaxiHudState]): """Present generated frames beneath a responsive Dear ImGui taxi HUD.""" + def __init__( + self, + *, + presentation_device: str | torch.device | None = None, + **kwargs: Any, + ) -> None: + """Configure the HUD renderer and its app-owned presentation stream. + + Args: + presentation_device: Device used for generated and composited + frames; ``None`` retains the calling thread's current stream. + **kwargs: Arguments forwarded to :class:`ImGuiUILoop`. + """ + super().__init__(**kwargs) + self._presentation_device = ( + None if presentation_device is None else torch.device(presentation_device) + ) + self._presentation_stream: torch.cuda.Stream | None = None + def step_ui( self, imgui: Any, step_index: int, events: UserInputEvents ) -> Tensor | None: """Draw the HUD and return the generated world frame beneath it.""" + presentation_stream = self._activate_presentation_stream() self.state.consume_input_events(events) frames = self.presented_model_frames() video = frames[0] if frames else None bev_frame = frames[1] if len(frames) > 1 else None if video is not None: self.state.select_presented_frame(video) + if presentation_stream is not None: + self.state.wait_for_presented_frame(presentation_stream) + video.record_stream(presentation_stream) + if bev_frame is not None: + bev_frame.record_stream(presentation_stream) self.state.draw_waypoints(imgui, video) self.state.draw(imgui, step_index, bev_frame=bev_frame) if video is None: return None return video.to(torch.float32) + def _activate_presentation_stream(self) -> torch.cuda.Stream | None: + """Make the app-owned CUDA stream current on the V2 UI thread.""" + device = self._presentation_device + if device is None or device.type != "cuda": + return None + with torch.cuda.device(device): + if self._presentation_stream is None: + self._presentation_stream = torch.cuda.Stream(device=device) + torch.cuda.set_stream(self._presentation_stream) + return self._presentation_stream + + def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" self.state.reset() super().reset() + def close(self) -> None: + """Release the renderer and restore the UI thread's default stream.""" + try: + super().close() + finally: + if self._presentation_stream is not None: + assert self._presentation_device is not None + with torch.cuda.device(self._presentation_device): + torch.cuda.set_stream( + torch.cuda.default_stream(self._presentation_device) + ) + self._presentation_stream = None + def build_hud_frames( video_tchw: Tensor, @@ -545,6 +612,7 @@ def build_hud_frames( input_transition_count: int = 0, input_ignored_event_count: int = 0, input_dropped_transition_count: int = 0, + ready_event: torch.cuda.Event | None = None, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) @@ -572,6 +640,7 @@ def build_hud_frames( input_transition_count=input_transition_count, input_ignored_event_count=input_ignored_event_count, input_dropped_transition_count=input_dropped_transition_count, + ready_event=ready_event, ) ) return tuple(frames) diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index df1755a83..7f90e58e9 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -98,6 +98,7 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert desc.frames_per_second_for_step == 30 assert isinstance(model_loop, CrazyRobotaxiModelLoop) assert isinstance(ui_loop, CrazyRobotaxiImGuiUILoop) + assert ui_loop._presentation_device == torch.device("cpu") assert model_loop.state.pipeline is pipeline assert model_loop.state.rollout is None assert model_loop.state.ui_loop is ui_loop diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 4ce406beb..5e5afb9de 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -7,9 +7,10 @@ import queue import threading +from contextlib import nullcontext from dataclasses import dataclass, field, replace from types import SimpleNamespace -from typing import Any +from typing import Any, cast import numpy as np import pytest @@ -249,6 +250,42 @@ def test_hud_frames_preserve_frame_aligned_input_diagnostics() -> None: assert frames[0].input_dropped_transition_count == 1 +def test_hud_submits_model_ready_dependency_once_per_presented_frame() -> None: + ready_event = cast(torch.cuda.Event, object()) + waited_events = [] + stream = cast( + torch.cuda.Stream, + SimpleNamespace(wait_event=waited_events.append), + ) + video = torch.zeros(1, 3, 96, 160) + state = TaxiHudState(160, 96, _calibration()) + state.publish( + build_hud_frames( + video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + ready_event=ready_event, + ) + ) + state.select_presented_frame(video[0]) + + assert state.wait_for_presented_frame(stream) + assert not state.wait_for_presented_frame(stream) + assert waited_events == [ready_event] + + state.reset() + state.publish( + build_hud_frames( + video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + ready_event=ready_event, + ) + ) + state.select_presented_frame(video[0]) + assert state.wait_for_presented_frame(stream) + + def test_hud_frames_reject_misaligned_input_diagnostics() -> None: with pytest.raises(ValueError, match="Input transitions"): build_hud_frames( @@ -402,6 +439,39 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert renderer.reset_count == 1 +def test_imgui_ui_loop_owns_and_restores_a_cuda_presentation_stream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + presentation_stream = cast(torch.cuda.Stream, object()) + default_stream = cast(torch.cuda.Stream, object()) + created_streams = [] + selected_streams = [] + + def create_stream(*, device: torch.device) -> torch.cuda.Stream: + created_streams.append(device) + return presentation_stream + + monkeypatch.setattr(torch.cuda, "device", lambda device: nullcontext(device)) + monkeypatch.setattr(torch.cuda, "Stream", create_stream) + monkeypatch.setattr(torch.cuda, "set_stream", selected_streams.append) + monkeypatch.setattr(torch.cuda, "default_stream", lambda device: default_stream) + renderer = _Renderer(160, 96) + loop = CrazyRobotaxiImGuiUILoop( + renderer=renderer, + presentation_device="cuda:0", + ) + + assert loop._activate_presentation_stream() is presentation_stream + assert loop._activate_presentation_stream() is presentation_stream + assert created_streams == [torch.device("cuda:0")] + assert selected_streams == [presentation_stream, presentation_stream] + + loop.close() + assert selected_streams[-1] is default_stream + assert loop._presentation_stream is None + assert renderer.closed + + def test_hud_draws_immediate_imgui_windows() -> None: state = TaxiHudState(160, 96, _calibration()) state.publish( diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 3d40ff39b..9a5c3a96b 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -45,12 +45,12 @@ flowchart TD INPUT["DriverInput
timestamped transition timeline"] ENGINE["GameEngine"] CACHE["session-local OmniDreams cache"] - HUDSTATE["immutable TaxiHudFrame batches"] + HUDSTATE["immutable TaxiHudFrame batches
+ CUDA ready event"] end subgraph UITHREAD["FlashDreams V2 UI thread"] BUFFER["PresentationManager
RGB + optional raw BEV frames"] - HUD["CrazyRobotaxiImGuiUILoop
waypoint projection + BEV pixel caches"] + HUD["CrazyRobotaxiImGuiUILoop
dedicated CUDA stream
waypoints + BEV presentation cache"] COMPOSE["one Dear ImGui overlay
background waypoints + HUD + BEV window"] WINDOW["V2 client window
native / WebRTC / MP4"] end @@ -64,7 +64,7 @@ flowchart TD INPUT --> ENGINE ENGINE --> HUDSTATE ENGINE -->|"RGB + optional raw BEV"| BUFFER - HUDSTATE -->|"invoke_async"| HUD + HUDSTATE -->|"invoke_async; wait on ready event"| HUD HUD -->|"invoke_async game actions"| ENGINE BUFFER --> HUD --> COMPOSE --> WINDOW ``` @@ -82,11 +82,14 @@ creation caps its raster to the actual ImGui image extent while preserving an authored aspect ratio; model HD-map conditioning retains full output resolution. For each generated frame it publishes an immutable `TaxiHudFrame` containing its game snapshot and camera pose to the UI loop with -`invoke_async`. The UI loop selects the metadata aligned with the currently -presented model frame and caches that frame's projected in-world waypoint -geometry. It submits rings, beacons, anchors, and labels to ImGui's background -draw list, beneath the HUD windows, without constructing a full-size waypoint -tensor. It draws the HUD and raw BEV in immediate Dear ImGui windows; +`invoke_async`. Each batch also carries a CUDA event recorded after the model +outputs so the app-owned presentation stream waits for exactly that chunk, +rather than fencing Vulkan against later model work on the shared default +stream. The UI loop selects the metadata aligned with the currently presented +model frame and caches that frame's projected in-world waypoint geometry. It +submits rings, beacons, anchors, and labels to ImGui's background draw list, +beneath the HUD windows, without constructing a full-size waypoint tensor. It +draws the HUD and BEV in an immediate Dear ImGui window; `ImGuiUILoop` composites that one UI layer over the world frame. UI callbacks send validated game actions back to the model loop with `invoke_async`; they never call game state directly. diff --git a/docs/design/crazy_robotaxi_v2_performance.md b/docs/design/crazy_robotaxi_v2_performance.md index 8f956b657..3b36460bb 100644 --- a/docs/design/crazy_robotaxi_v2_performance.md +++ b/docs/design/crazy_robotaxi_v2_performance.md @@ -84,6 +84,25 @@ cleanly requires the ImGui renderer to accept a CUDA tensor or expose a CUDA-backed SlangPy texture registration hook. The app should not duplicate the renderer's Vulkan/CUDA resource ownership to work around that missing API. +### CUDA-upload follow-up + +The `*-3.json` native-window capture on commit `ece9c52` confirms that the BEV +change removed its original synchronization hotspot. BEV submission fell from +45.68 seconds total and 195.7 ms p90 to 0.32 seconds total and 0.31 ms p90. +UI-step p90 fell from 37.2 ms to 4.2 ms, presented throughput increased from +17.25 to 22.62 fps, and elapsed time after the first publication fell from +104.58 to 79.76 seconds. + +The capture also exposes a second synchronization boundary. Native surface +acquisition consumed 22.15 seconds, with 222 of its 227 waits above 10 ms +occurring on a model-frame advance. Those waits cluster near 90--110 ms, once +per generated chunk. The app therefore records a CUDA event after each model +chunk and keeps UI conversion, ImGui interop, composition, and native upload on +a dedicated presentation stream. The stream waits only for the selected +chunk's event instead of making Vulkan depend on subsequently queued model +work. This follow-up remains pending a target-machine `*-4.json` validation and +is not yet a validated performance result. + ## App-side PhysX bridge correction The matched native `original-perf` capture attributes 39.2 ms per chunk to From 37b231fc235fbf1d356cb3382184f7ec0b5a1e46 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 17:23:39 +0000 Subject: [PATCH 20/62] Add combined fast performance preset Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 25 ++++++++++++++++ .../crazy_robotaxi/application.py | 18 +++++++++++- apps/crazy_robotaxi/tests/test_application.py | 29 +++++++++++++++++-- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index a522c4152..813922646 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -80,6 +80,31 @@ file. Use the existing `perf` and `native-perf` presets for their previous behavior. GPU throughput and quality still need to be validated on the target machine before treating this preset as a regression baseline. +## Candidate maximum-performance preset + +The opt-in `fast-perf` preset combines `original-perf`'s required native FP8 +DiT and cuDNN attention with `native-perf`'s native FP8 LightVAE image and +per-chunk encoders. It retains the compiled/CUDA-graphed LightTAE decoder and +the original demo's two-step schedule. This combination is a performance +candidate, not yet a validated default; compare its steady-state timings and +output quality with `original-perf` on the target GPU. + +It requires a calibrated LightVAE FP8 state. After generating that state, run: + +```bash +OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH=artifacts/native_vae/lightvae-fp8-state.pt \ + uv run flashdreams-run-v2 crazy-robotaxi \ + --mode native-window \ + --pixel-width 1168 \ + --pixel-height 640 \ + -- \ + --model-preset fast-perf +``` + +The native DiT sources must also have been prepared as described above. As +with `original-perf`, the resolution flags are optional and the app renderer +follows the V2 session dimensions. + ## Performance diagnostics Crazy Robotaxi emits lightweight model-thread, engine, overlay, and PhysX diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 431714052..9dd1f233c 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse +import copy import logging from collections.abc import Callable, Sequence from dataclasses import dataclass, replace @@ -67,6 +68,13 @@ }, ) +_FAST_PERF_PIPELINE = derive_config( + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline, + name="crazy-robotaxi-fast-perf", + diffusion_model=copy.deepcopy(_ORIGINAL_PERF_PIPELINE.diffusion_model), +) +"""Candidate maximum-performance path: native FP8 LightVAE and native FP8 DiT.""" + @dataclass(frozen=True, slots=True) class _ModelPreset: @@ -86,6 +94,10 @@ class _ModelPreset: _ORIGINAL_PERF_PIPELINE, renderer_follows_session=True, ), + "fast-perf": _ModelPreset( + _FAST_PERF_PIPELINE, + renderer_follows_session=True, + ), } @@ -245,11 +257,13 @@ def create_session(self, session_desc: SessionDesc) -> ISession: ) transformer = self._pipeline_config.diffusion_model.transformer scheduler = self._pipeline_config.diffusion_model.scheduler + encoder = self._pipeline_config.encoder bev = config.renderer.bev bev_resolution = f"{bev.width}x{bev.height}" if bev.enabled else "disabled" _LOGGER.info( "Crazy Robotaxi model preset=%s resolution=%sx%s native_dit=%s " - "native_backend=%s attention_backend=%s skip_finalize=%s " + "native_backend=%s attention_backend=%s native_vae=%s " + "native_vae_backend=%s skip_finalize=%s " "denoising_timesteps=%s bev=%s", config.model_preset_name, actual[0], @@ -257,6 +271,8 @@ def create_session(self, session_desc: SessionDesc) -> ISession: transformer.native_dit_acceleration, transformer.native_dit_backend, transformer.native_dit_attention_backend, + encoder.native_vae_acceleration, + encoder.native_vae_backend, transformer.skip_finalize_kv_cache, list(scheduler.denoising_timesteps), bev_resolution, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 7f90e58e9..32889aa23 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -244,9 +244,34 @@ def test_original_perf_matches_the_original_demo_manifest() -> None: assert pipeline.encoder.native_vae_acceleration == "disabled" +def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: + preset = _MODEL_PRESETS["fast-perf"] + pipeline = preset.pipeline + original = _MODEL_PRESETS["original-perf"].pipeline + native = _MODEL_PRESETS["native-perf"].pipeline + + assert preset.renderer_follows_session + assert pipeline.name == "crazy-robotaxi-fast-perf" + assert pipeline.diffusion_model == original.diffusion_model + assert pipeline.image_encoder == native.image_encoder + assert pipeline.encoder == native.encoder + assert pipeline.decoder == original.decoder + assert pipeline.image_encoder.native_vae_acceleration == "required" + assert pipeline.image_encoder.native_vae_backend == "fp8" + assert pipeline.encoder.native_vae_acceleration == "required" + assert pipeline.encoder.native_vae_backend == "fp8" + assert pipeline.diffusion_model.transformer.native_dit_acceleration == "required" + assert ( + pipeline.diffusion_model.transformer.native_dit_backend + == "fp8_kvcache_cudnn" + ) + + @pytest.mark.parametrize("resolution_wh", [(1280, 704), (1168, 640)]) -def test_original_perf_adapts_renderer_to_session_geometry( +@pytest.mark.parametrize("preset_name", ["original-perf", "fast-perf"]) +def test_app_owned_perf_presets_adapt_renderer_to_session_geometry( resolution_wh: tuple[int, int], + preset_name: str, ) -> None: configured: list[object] = [] raster_sizes: list[tuple[int, int]] = [] @@ -261,7 +286,7 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: pipeline_factory=lambda config, device: configured.append(config) or object(), scene_factory=load_test_scene, ) - app.init(["--model-preset", "original-perf"]) + app.init(["--model-preset", preset_name]) desc = replace( app.session_desc(), video_width=resolution_wh[0], From 2dc1fb99a1416c3a8c984e3a92d9d6fe5c5bd4dd Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 13:52:55 -0700 Subject: [PATCH 21/62] Add configurable Crazy Robotaxi FPS counter Signed-off-by: Aidan Foster --- .../crazy_robotaxi/application.py | 9 ++++ apps/crazy_robotaxi/crazy_robotaxi/session.py | 1 + apps/crazy_robotaxi/crazy_robotaxi/ui.py | 47 ++++++++++++++++++- apps/crazy_robotaxi/tests/test_application.py | 22 +++++++++ apps/crazy_robotaxi/tests/test_ui.py | 34 ++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 9dd1f233c..a23d389e3 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -120,6 +120,8 @@ class ApplicationConfig: profile_input_latency: bool """Whether the UI displays and logs input-to-model-frame diagnostics.""" + show_fps: bool + """Whether the HUD displays the measured generated-video frame rate.""" PipelineFactory = Callable[[Any, str], Any] SceneFactory = Callable[[SceneRequest, Any], SceneDefinition] @@ -213,6 +215,7 @@ def init(self, commandline_args: Sequence[str]) -> None: pipeline_profiling=bool(args.profile_pipeline), prewarm_blocks=args.prewarm_blocks, profile_input_latency=bool(args.profile_input_latency), + show_fps=bool(args.show_fps), ) def create_session(self, session_desc: SessionDesc) -> ISession: @@ -375,4 +378,10 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="show and log UI-input-to-model-frame latency diagnostics", ) + parser.add_argument( + "--show-fps", + action=argparse.BooleanOptionalAction, + default=None, + help="show the measured generated-video frame rate in the HUD", + ) return parser diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index af68bacbc..c8307b61c 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -360,6 +360,7 @@ def init(self) -> None: height=self._session_desc.video_height, calibration=self._scene.selected_camera, profile_input_latency=self._config.profile_input_latency, + show_fps=self._config.show_fps, ) ui_loop = self.register_ui_loop( CrazyRobotaxiImGuiUILoop, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 38f2a234a..c9e7c0bea 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -20,7 +20,7 @@ import logging import math import time -from collections import OrderedDict +from collections import OrderedDict, deque from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any @@ -54,6 +54,9 @@ _MAX_BUFFERED_INPUT_EVENTS = 64 """Maximum diagnostic event receipts retained before model-frame correlation.""" +_VIDEO_FPS_WINDOW_SECONDS = 2.0 +"""Rolling window used to smooth the generated-video frame-rate estimate.""" + _PROFILE_DRIVE_KEYS = frozenset( {"w", "a", "s", "d", "up", "down", "left", "right", "space"} ) @@ -111,6 +114,9 @@ class TaxiHudState: profile_input_latency: bool = False """Whether input arrival and model-frame latency diagnostics are visible.""" + show_fps: bool = False + """Whether to display the measured generated-video frame rate.""" + model_loop: ILoop[Any] | None = None """Model-loop endpoint used only through ``invoke_async``.""" @@ -162,6 +168,12 @@ class TaxiHudState: _latest_input_latency_ms: float | None = None """Latest UI-ingress-to-model-frame-selection latency measurement.""" + _presented_frame_times_s: deque[float] = field(default_factory=deque) + """Recent times when distinct generated video frames were selected.""" + + _video_fps: float = 0.0 + """Generated-video frame rate estimated from recent selections.""" + def publish(self, frames: Sequence[TaxiHudFrame]) -> None: """Publish immutable model-frame state to the UI-owned lookup.""" for frame in frames: @@ -174,6 +186,7 @@ def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: """Select the HUD snapshot aligned with ``frame`` when available.""" selected = self._frames.get(int(frame.data_ptr())) if selected is not None: + frame_changed = selected is not self._current if ( self._current is None or selected.snapshot.session_state @@ -182,9 +195,25 @@ def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: self._validation_message = "" self._submission_pending = False self._current = selected + if frame_changed: + self._record_presented_frame(time.monotonic()) self._record_presented_input(selected) return self._current + def _record_presented_frame(self, now_s: float) -> None: + """Update generated-video throughput after selecting a new frame.""" + times = self._presented_frame_times_s + times.append(now_s) + cutoff_s = now_s - _VIDEO_FPS_WINDOW_SECONDS + while len(times) >= 3 and times[1] <= cutoff_s: + times.popleft() + if len(times) < 2: + self._video_fps = 0.0 + return + elapsed_s = times[-1] - times[0] + if elapsed_s > 0.0: + self._video_fps = (len(times) - 1) / elapsed_s + def consume_input_events(self, events: UserInputEvents) -> None: """Track responsive drive state and receipt times on the UI thread.""" if not self.profile_input_latency: @@ -276,6 +305,7 @@ def draw( bev_frame: Tensor | None = None, ) -> None: """Draw one immediate Dear ImGui HUD frame.""" + self._draw_fps_counter(imgui) hud_frame = self._current if hud_frame is None: dots = "." * (1 + (ui_tick // 15) % 3) @@ -321,6 +351,19 @@ def draw( self._draw_terminal(imgui, snapshot) self._draw_input_diagnostic(imgui) + def _draw_fps_counter(self, imgui: Any) -> None: + """Draw the measured generated-video rate when the counter is enabled.""" + if not self.show_fps: + return + width = 170.0 + self._draw_text_window( + imgui, + "Performance", + position=(float(max(14.0, self.width - width - 14.0)), 14.0), + size=(width, 66.0), + lines=(f"VIDEO FPS {self._video_fps:5.1f}",), + ) + def reset(self) -> None: """Clear per-generation HUD snapshots and editable UI state.""" self._frames.clear() @@ -339,6 +382,8 @@ def reset(self) -> None: self._name_input = "" self._bev_source_key = None self._bev_pixels = None + self._presented_frame_times_s.clear() + self._video_fps = 0.0 def _draw_text_window( self, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 32889aa23..5818c72c8 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -85,6 +85,7 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: "--total-blocks", "2", "--profile-input-latency", + "--show-fps", ] ) @@ -104,6 +105,7 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert model_loop.state.ui_loop is ui_loop assert ui_loop.state.model_loop is model_loop assert ui_loop.state.profile_input_latency + assert ui_loop.state.show_fps assert session._config.renderer.bev.width == 234 assert session._config.renderer.bev.height == 234 @@ -372,6 +374,26 @@ def test_input_latency_profiling_is_an_app_local_opt_in( assert app._config.profile_input_latency is expected +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + ([], False), + (["--show-fps"], True), + (["--show-fps", "--no-show-fps"], False), + ], +) +def test_fps_counter_is_an_app_local_option( + arguments: list[str], + expected: bool, +) -> None: + app = CrazyRobotaxiApplication() + + app.init(arguments) + + assert app._config is not None + assert app._config.show_fps is expected + + @pytest.mark.parametrize("prewarm_blocks", [0, 4, 7]) def test_application_configures_prepresentation_warmup(prewarm_blocks: int) -> None: app = CrazyRobotaxiApplication( diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 5e5afb9de..9854870b0 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -7,6 +7,7 @@ import queue import threading +import time from contextlib import nullcontext from dataclasses import dataclass, field, replace from types import SimpleNamespace @@ -373,6 +374,39 @@ def project_world(self, points, rig_to_world): assert [projection.distance_m for projection in projections] == [10.0, 20.0, 30.0] +@pytest.mark.parametrize("show_fps", [False, True]) +def test_fps_counter_is_configurable(show_fps: bool) -> None: + state = TaxiHudState(640, 360, _calibration(), show_fps=show_fps) + imgui = _FakeImGui() + + state.draw(imgui) + + assert ("Performance" in imgui.windows) is show_fps + if show_fps: + assert imgui.windows["Performance"] == ["VIDEO FPS 0.0"] + + +def test_fps_counter_measures_distinct_generated_video_frames( + monkeypatch: pytest.MonkeyPatch, +) -> None: + frame_count = 61 + video = torch.zeros(frame_count, 3, 96, 160) + snapshots = tuple(_snapshot() for _ in range(frame_count)) + poses = np.repeat(np.eye(4, dtype=np.float32)[None], frame_count, axis=0) + state = TaxiHudState(640, 360, _calibration(), show_fps=True) + state.publish(build_hud_frames(video, snapshots, poses)) + frame_times = iter(index / 30.0 for index in range(frame_count)) + monkeypatch.setattr(time, "monotonic", lambda: next(frame_times)) + + for frame in video: + state.select_presented_frame(frame) + state.select_presented_frame(video[-1]) + imgui = _FakeImGui() + state._draw_fps_counter(imgui) + + assert imgui.windows["Performance"] == ["VIDEO FPS 30.0"] + + def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) From b0d7cc30e846cc5905b36e3313e0e55e88cd55d2 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 18:19:58 +0000 Subject: [PATCH 22/62] Keep Crazy Robotaxi BEV GPU-resident app-side Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 108 ++++++++++++------ apps/crazy_robotaxi/tests/test_ui.py | 43 +++---- docs/design/crazy_robotaxi_v2_architecture.md | 23 ++-- docs/design/crazy_robotaxi_v2_performance.md | 15 +-- 4 files changed, 116 insertions(+), 73 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index c9e7c0bea..4446fa899 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -28,6 +28,7 @@ import numpy as np import numpy.typing as npt import torch +import torch.nn.functional as functional from omnidreams_game_engine.types import CameraCalibration from torch import Tensor @@ -138,11 +139,14 @@ class TaxiHudState: _name_input: str = "" """Immediate-mode name-entry buffer retained by the UI state.""" - _bev_source_key: tuple[int, tuple[int, ...]] | None = None - """Identity and shape of the raw BEV frame cached for ImGui upload.""" + _bev_source_key: tuple[object, ...] | None = None + """Identity, geometry, and format of the cached GPU BEV panel.""" - _bev_pixels: npt.NDArray[np.uint8] | None = None - """Cached HWC RGB bytes for the currently presented BEV frame.""" + _bev_panel: Tensor | None = None + """Cached normalized CHW BEV panel retained on its source device.""" + + _bev_rect: tuple[int, int, int, int] | None = None + """Current ImGui content rectangle as ``(top, left, height, width)``.""" _validation_message: str = "" """Name-entry validation or submission status.""" @@ -305,6 +309,7 @@ def draw( bev_frame: Tensor | None = None, ) -> None: """Draw one immediate Dear ImGui HUD frame.""" + self._bev_rect = None self._draw_fps_counter(imgui) hud_frame = self._current if hud_frame is None: @@ -381,7 +386,8 @@ def reset(self) -> None: self._latest_input_latency_ms = None self._name_input = "" self._bev_source_key = None - self._bev_pixels = None + self._bev_panel = None + self._bev_rect = None self._presented_frame_times_s.clear() self._video_fps = 0.0 @@ -414,7 +420,6 @@ def _draw_bev_window(self, imgui: Any, bev_frame: Tensor | None) -> None: image_height = max(1, round(frame_height * scale)) if image_width <= 4 or image_height <= 4: return - pixels = self._bev_image_pixels(bev_frame) padding = 16 title_height = 34 window_size = ( @@ -426,44 +431,69 @@ def _draw_bev_window(self, imgui: Any, bev_frame: Tensor | None) -> None: float(self.width) - window_size[0] - margin, float(self.height) - window_size[1] - margin, ) - _prepare_window(imgui, position=position, size=window_size, alpha=0.82) + # The app composites the CUDA BEV beneath this transparent content area. + # ImGui still owns the map window's title, border, clipping and layout. + _prepare_window(imgui, position=position, size=window_size, alpha=0.0) visible = _begin_window(imgui, "Map") try: if visible: - imgui.image( - "crazy_robotaxi_bev", - pixels, - size=(float(image_width), float(image_height)), + cursor = imgui.get_cursor_screen_pos() + left, top = _point_xy(cursor) + self._bev_rect = ( + max(0, round(top)), + max(0, round(left)), + image_height, + image_width, ) + imgui.dummy(imgui.ImVec2(float(image_width), float(image_height))) finally: imgui.end() - def _bev_image_pixels(self, frame: Tensor) -> npt.NDArray[np.uint8]: + def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: + """Composite the current BEV into its ImGui-owned rectangle on-device.""" + rect = self._bev_rect + if frame is None or rect is None: + return video if frame.ndim != 3 or frame.shape[0] != 3: raise ValueError("BEV presentation frames must use [3,H,W]") - source_key = (int(frame.data_ptr()), tuple(int(value) for value in frame.shape)) - if source_key == self._bev_source_key and self._bev_pixels is not None: - return self._bev_pixels - source = frame.detach() - if source.dtype == torch.uint8: - pixels = source.permute(1, 2, 0).contiguous().cpu().numpy() - elif source.is_floating_point(): - pixels = ( - source.to(dtype=torch.float32) - .add(1.0) - .mul(127.5) - .clamp_(0.0, 255.0) - .to(torch.uint8) - .permute(1, 2, 0) - .contiguous() - .cpu() - .numpy() - ) - else: + if frame.dtype != torch.uint8 and not frame.is_floating_point(): raise ValueError("BEV presentation frames must be uint8 or floating point") - self._bev_source_key = source_key - self._bev_pixels = np.ascontiguousarray(pixels) - return self._bev_pixels + if frame.device != video.device: + raise ValueError("BEV and video presentation frames must share a device") + + top, left, image_height, image_width = rect + bottom = min(int(video.shape[-2]), top + image_height) + right = min(int(video.shape[-1]), left + image_width) + if bottom <= top or right <= left: + return video + source_key = ( + id(self._current), + int(frame.data_ptr()), + tuple(int(value) for value in frame.shape), + frame.dtype, + frame.device, + image_height, + image_width, + ) + panel = self._bev_panel + if source_key != self._bev_source_key or panel is None: + source = frame.detach().to(dtype=torch.float32) + panel = source.div(127.5).sub(1.0) if frame.dtype == torch.uint8 else source + if tuple(panel.shape[-2:]) != (image_height, image_width): + panel = functional.interpolate( + panel.unsqueeze(0), + size=(image_height, image_width), + mode="bilinear", + align_corners=False, + )[0] + self._bev_source_key = source_key + self._bev_panel = panel + + output = video.clone() + output[:, top:bottom, left:right].copy_( + panel[:, : bottom - top, : right - left] + ) + return output def _draw_input_diagnostic(self, imgui: Any) -> None: if not self.profile_input_latency: @@ -615,7 +645,8 @@ def step_ui( self.state.draw(imgui, step_index, bev_frame=bev_frame) if video is None: return None - return video.to(torch.float32) + converted = video.to(torch.float32) + return self.state.composite_bev(converted, bev_frame) def _activate_presentation_stream(self) -> torch.cuda.Stream | None: """Make the app-owned CUDA stream current on the V2 UI thread.""" @@ -729,6 +760,13 @@ def _normalize_profile_key(key: str) -> str: }.get(normalized, normalized) +def _point_xy(value: Any) -> tuple[float, float]: + """Return an ImGui vector's coordinates across supported Python bindings.""" + if hasattr(value, "x") and hasattr(value, "y"): + return float(value.x), float(value.y) + return float(value[0]), float(value[1]) + + def _score_label(snapshot: TaxiGameSnapshot) -> str: label = f"SCORE {snapshot.score:06d}" if snapshot.high_score is not None: diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 9854870b0..7055cca4c 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -107,8 +107,9 @@ class _FakeImGui: def __init__(self) -> None: self.windows: dict[str, list[str]] = {} - self.images: list[tuple[str, np.ndarray, tuple[float, float]]] = [] + self.dummies: list[tuple[float, float]] = [] self.current_window: str | None = None + self.next_window_position = (0.0, 0.0) self.input_value = "" self.submit_input = False self.click_submit = False @@ -134,7 +135,8 @@ def get_background_draw_list(self) -> _FakeDrawList: return self.background_draw_list def set_next_window_pos(self, position, condition) -> None: - del position, condition + self.next_window_position = position + del condition def set_next_window_size(self, size, condition) -> None: del size, condition @@ -155,14 +157,14 @@ def text(self, value: str) -> None: assert self.current_window is not None self.windows[self.current_window].append(value) - def image( - self, - key: str, - pixels: np.ndarray, - *, - size: tuple[float, float], - ) -> None: - self.images.append((key, pixels, size)) + def get_cursor_screen_pos(self) -> tuple[float, float]: + return ( + float(self.next_window_position[0]) + 8.0, + float(self.next_window_position[1]) + 26.0, + ) + + def dummy(self, size: tuple[float, float]) -> None: + self.dummies.append(size) def input_text(self, label: str, value: str, *, flags: int): del label, value, flags @@ -451,25 +453,26 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert hud_state._current is not None assert "SCORE 001200 HIGH 009000" in renderer.ui.windows["Crazy Robotaxi"] assert "TARGET AHEAD 0 deg" in renderer.ui.windows["Navigation"] - assert len(renderer.ui.images) == 1 - image_key, pixels, image_size = renderer.ui.images[0] - assert image_key == "crazy_robotaxi_bev" - assert pixels.shape == (32, 32, 3) - assert np.all(pixels == 191) - assert image_size == (32.0, 32.0) + assert renderer.ui.dummies == [(32.0, 32.0)] assert renderer.ui.background_draw_list.commands - assert torch.all(result.output == video) + top, left, panel_height, panel_width = hud_state._bev_rect or (0, 0, 0, 0) + panel = result.output[0, :, top : top + panel_height, left : left + panel_width] + assert torch.allclose(panel, torch.full_like(panel, 191.0 / 127.5 - 1.0)) + outside = result.output[0].clone() + outside[:, top : top + panel_height, left : left + panel_width] = -0.5 + assert torch.all(outside == video[0]) cached_waypoints = hud_state._waypoint_projections - cached_bev = hud_state._bev_pixels + cached_bev = hud_state._bev_panel loop.step(1, UserInputEvents([])) assert hud_state._waypoint_projections is cached_waypoints - assert hud_state._bev_pixels is cached_bev + assert hud_state._bev_panel is cached_bev loop.reset() assert hud_state._current is None assert hud_state._waypoint_projections == () - assert hud_state._bev_pixels is None + assert hud_state._bev_panel is None + assert hud_state._bev_rect is None assert renderer.reset_count == 1 diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index 9a5c3a96b..f1b9bd207 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -50,8 +50,8 @@ flowchart TD subgraph UITHREAD["FlashDreams V2 UI thread"] BUFFER["PresentationManager
RGB + optional raw BEV frames"] - HUD["CrazyRobotaxiImGuiUILoop
dedicated CUDA stream
waypoints + BEV presentation cache"] - COMPOSE["one Dear ImGui overlay
background waypoints + HUD + BEV window"] + HUD["CrazyRobotaxiImGuiUILoop
dedicated CUDA stream
waypoints + GPU BEV panel cache"] + COMPOSE["app GPU BEV panel
+ Dear ImGui overlay"] WINDOW["V2 client window
native / WebRTC / MP4"] end @@ -89,7 +89,7 @@ stream. The UI loop selects the metadata aligned with the currently presented model frame and caches that frame's projected in-world waypoint geometry. It submits rings, beacons, anchors, and labels to ImGui's background draw list, beneath the HUD windows, without constructing a full-size waypoint tensor. It -draws the HUD and BEV in an immediate Dear ImGui window; +uses an immediate Dear ImGui window to place and clip a GPU-composited BEV; `ImGuiUILoop` composites that one UI layer over the world frame. UI callbacks send validated game actions back to the model loop with `invoke_async`; they never call game state directly. @@ -191,14 +191,15 @@ return [video StepResult, optional raw_bev StepResult] The UI loop uses V2's `ImGuiUILoop` and `presented_model_frames()`. It projects waypoint geometry only when the presented frame changes and submits the cached -primitives to ImGui's background draw list each UI tick. It materializes the -small renderer-native uint8 BEV frame as cached RGB bytes only when the -presented BEV changes and displays it through the runtime-provided ImGui image -bridge. The base loop -composites the complete Dear ImGui overlay over the video back buffer. There is -no full-frame waypoint or BEV layer, retained SlangPy widget tree, intermediate -app renderer, local adapter, model session, chunk request, private command -queue, private frame queue, or legacy `PresentedFrame` aggregate. +primitives to ImGui's background draw list each UI tick. A transparent ImGui +map window owns the BEV's layout, clipping, title, and border. The app normalizes +and resizes the small renderer-native BEV on its presentation CUDA stream, caches +that panel for repeated UI ticks, and writes it into the returned video before +the base loop composites the ImGui overlay. The BEV never reaches a host image +or requires a FlashDreams image-upload extension. There is no full-frame +waypoint or BEV layer, retained SlangPy widget tree, intermediate app renderer, +local adapter, model session, chunk request, private command queue, private frame +queue, or legacy `PresentedFrame` aggregate. ## Game-engine internals diff --git a/docs/design/crazy_robotaxi_v2_performance.md b/docs/design/crazy_robotaxi_v2_performance.md index 3b36460bb..1f7d1232c 100644 --- a/docs/design/crazy_robotaxi_v2_performance.md +++ b/docs/design/crazy_robotaxi_v2_performance.md @@ -78,16 +78,17 @@ materialization versus 0.061 ms for the new 234x234 uint8 layout conversion. Those CPU numbers validate the direction only; GPU renderer, synchronization, and UI timings still require a target-machine run. -The remaining D2H/H2D round trip is an API boundary: V2's ImGui pixel helper -accepts NumPy-like pixels and calls `texture.copy_from_numpy`. Eliminating it -cleanly requires the ImGui renderer to accept a CUDA tensor or expose a -CUDA-backed SlangPy texture registration hook. The app should not duplicate -the renderer's Vulkan/CUDA resource ownership to work around that missing API. +The app removes the remaining D2H/H2D round trip without changing the V2 API. +ImGui owns a transparent map window and reserves its content rectangle; the UI +loop normalizes and resizes the renderer-native BEV on its presentation CUDA +stream, caches the panel across repeated ticks, and composites that small region +into the returned video. No Vulkan texture ownership escapes the ImGui renderer. ### CUDA-upload follow-up -The `*-3.json` native-window capture on commit `ece9c52` confirms that the BEV -change removed its original synchronization hotspot. BEV submission fell from +The `*-3.json` native-window capture from the superseded `ece9c52` experiment +confirmed that removing the BEV host round trip eliminates its original +synchronization hotspot. BEV submission fell from 45.68 seconds total and 195.7 ms p90 to 0.32 seconds total and 0.31 ms p90. UI-step p90 fell from 37.2 ms to 4.2 ms, presented throughput increased from 17.25 to 22.62 fps, and elapsed time after the first publication fell from From 5dda7b38a72feb619fc349802641a4912c2e6904 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 11:35:13 -0700 Subject: [PATCH 23/62] Port complete Crazy Robotaxi gameplay to V2 Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 70 + .../crazy_robotaxi/application.py | 252 ++- .../crazy_robotaxi/assets/README.md | 44 + .../crazy_robotaxi/assets/__init__.py | 4 + .../assets/obstacle_vehicle_tracks_v1.npz | Bin 0 -> 1180622 bytes apps/crazy_robotaxi/crazy_robotaxi/config.py | 266 +-- .../configs/default_renderer.yaml | 23 - .../configs/example_engine_config.yaml | 52 + ...ult_game.yaml => example_game_config.yaml} | 26 +- .../crazy_robotaxi/crazy_robotaxi/dynamics.py | 7 +- apps/crazy_robotaxi/crazy_robotaxi/factory.py | 90 +- .../crazy_robotaxi/high_scores.py | 233 ++- .../crazy_robotaxi/live_edit/__init__.py | 75 + .../crazy_robotaxi/live_edit/coin_ability.py | 359 ++++ .../crazy_robotaxi/live_edit/config.py | 1443 +++++++++++++++++ .../live_edit/gpu_compositor.py | 511 ++++++ .../crazy_robotaxi/live_edit/input_hooks.py | 81 + .../crazy_robotaxi/live_edit/item_ability.py | 311 ++++ .../crazy_robotaxi/live_edit/nitro_ability.py | 116 ++ .../live_edit/obstacle_ability.py | 315 ++++ .../live_edit/obstacle_events.py | 777 +++++++++ .../live_edit/obstacle_templates.py | 286 ++++ .../crazy_robotaxi/live_edit/runtime_v2.py | 358 ++++ .../crazy_robotaxi/live_edit/style_ability.py | 1190 ++++++++++++++ .../live_edit/weather_ability.py | 116 ++ .../maps/boulevard_district.robotaxi.yaml | 15 + .../maps/demo_race_track.robotaxi.yaml | 180 ++ .../maps/minimal_loop.robotaxi.yaml | 14 + .../maps/race_track_minimal.robotaxi.yaml | 104 ++ apps/crazy_robotaxi/crazy_robotaxi/physics.py | 17 + apps/crazy_robotaxi/crazy_robotaxi/race.py | 656 ++++++++ apps/crazy_robotaxi/crazy_robotaxi/session.py | 24 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 130 +- apps/crazy_robotaxi/pyproject.toml | 6 +- .../tests/maps/race_course.robotaxi.yaml | 55 + apps/crazy_robotaxi/tests/test_application.py | 26 +- .../tests/test_layered_settings.py | 169 ++ .../crazy_robotaxi/tests/test_live_edit_v2.py | 118 ++ apps/crazy_robotaxi/tests/test_race.py | 435 +++++ .../NODE_GRAPH_MAP_FORMAT.md | 443 +++++ apps/omnidreams_game_engine/README.md | 5 + .../omnidreams_game_engine/cli_args.py | 48 + .../omnidreams_game_engine/engine_settings.py | 209 +++ .../game_map/__init__.py | 2 + .../game_map/_schema.py | 23 +- .../game_map/compiler.py | 109 +- .../omnidreams_game_engine/game_map/loader.py | 85 + .../omnidreams_game_engine/game_map/types.py | 46 + .../omnidreams_game_engine/model.py | 43 + .../simulation/actor_controller.py | 81 + .../simulation/game_physics.py | 172 +- .../simulation/gameplay_physx.py | 187 +++ .../simulation/map_traffic.py | 83 +- .../omnidreams_game_engine/yaml_config.py | 161 +- .../flashdreams/core/attention/kvcache.py | 20 + flashdreams/tests/test_kvcache.py | 20 + .../omnidreams/impl/_drift_corrector.py | 614 +++++++ integrations_v2/omnidreams/impl/_edit_lora.py | 169 ++ .../omnidreams/impl/_module_utils.py | 40 + integrations_v2/omnidreams/impl/pipeline.py | 78 + .../omnidreams/impl/transformer/__init__.py | 139 +- .../omnidreams/impl/transformer/network.py | 20 + 62 files changed, 11474 insertions(+), 277 deletions(-) create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/assets/README.md create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/assets/__init__.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/assets/obstacle_vehicle_tracks_v1.npz delete mode 100644 apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml rename apps/crazy_robotaxi/crazy_robotaxi/configs/{default_game.yaml => example_game_config.yaml} (83%) create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/coin_ability.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/input_hooks.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/item_ability.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/nitro_ability.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/race.py create mode 100644 apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml create mode 100644 apps/crazy_robotaxi/tests/test_layered_settings.py create mode 100644 apps/crazy_robotaxi/tests/test_live_edit_v2.py create mode 100644 apps/crazy_robotaxi/tests/test_race.py create mode 100644 apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py create mode 100644 apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py create mode 100644 integrations_v2/omnidreams/impl/_drift_corrector.py create mode 100644 integrations_v2/omnidreams/impl/_edit_lora.py create mode 100644 integrations_v2/omnidreams/impl/_module_utils.py diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 813922646..01b68df83 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -45,6 +45,76 @@ and the autoregressive cache together while retaining the loaded model. Leaderboard name entry is owned by the Dear ImGui UI and submitted to the model loop through V2's asynchronous loop-message contract. +## Layered configuration + +Crazy Robotaxi accepts strict, partial `schema_version: 1` configuration files: + +- `--engine-config` covers the map request, model preset, renderer, and V2 + runtime diagnostics. +- `--game-config` covers taxi/race mode, rules, input reduction, vehicle + physics, persistence, and live-edit abilities. + +Settings resolve from packaged typed defaults, then YAML, then explicitly +supplied application flags. Omitted YAML fields retain their lower-precedence +values, unknown keys are errors, and relative paths inside YAML resolve beside +that file. The legacy `--renderer-config` option remains available as an +explicit compatibility layer. + +The checked-in `example_engine_config.yaml` and `example_game_config.yaml` +show the complete V2 surface: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ + --engine-config \ + apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml \ + --game-config \ + apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml \ + --model-preset perf +``` + +Here `--model-preset perf` wins over the value in the engine YAML. Maps remain +separate authoritative `.robotaxi.yaml` documents referenced by path; layered +configuration does not copy or reinterpret map content. + +## Race mode + +Authored maps may define ordered race courses using the same schema and map +files as the original Crazy Robotaxi branch. Start the bundled demonstration +course with: + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ + --map apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml \ + --game-mode race +``` + +Race progression uses swept, direction-aware gate crossings, supports looped +and point-to-point courses, and persists times per map/course. Select a +non-default course with `--race-course ID` and override its leaderboard with +`--race-times PATH`. + +## Live-edit gameplay + +The source game's flag-gated abilities are available through V2 application +arguments. `C` toggles collectible coins, `K` cycles style skins, `V` cycles +weather, and `O` spawns a crossing-obstacle event. Effect items can trigger +rain, snow, a timed mystery skin, or a physics-authoritative nitro boost. + +```bash +uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ + --live-edit-coins \ + --live-edit-items \ + --live-edit-weather \ + --live-edit-obstacle +``` + +Style mode additionally requires `--live-edit-style-lora PATH`. Optional +style/base/weather correctors retain the source branch's fused and unfused +deployment modes. Text-edit and obstacle guidance require a non-native DiT +preset because those paths intercept the Python transformer conditioning +seam; the application reports this before generation if an incompatible +native preset is selected. All abilities are disabled by default. + ## Original demo performance preset The opt-in `original-perf` preset reproduces the performance knobs from the diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index a23d389e3..ebac5bbe4 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -11,14 +11,26 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from pathlib import Path -from typing import Any +from typing import Any, Literal from omnidreams.config import ( RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, ) -from omnidreams_game_engine.config import DriverInputConfig +from omnidreams_game_engine.cli_args import ( + ExplicitArgTrackingArgumentParser, + arg_was_explicit, +) +from omnidreams_game_engine.config import BevConfig, DriverInputConfig, RasterConfig +from omnidreams_game_engine.engine_settings import ( + EngineSettings, + MapLaunchSettings, + RenderingSettings, + WorldModelLaunchSettings, + load_engine_settings, +) +from omnidreams_game_engine.game_map import load_game_map_header from omnidreams_game_engine.renderer_settings import ( RendererSettings, load_renderer_settings, @@ -26,8 +38,13 @@ from omnidreams_game_engine.scene import SceneRequest, load_scene from omnidreams_game_engine.types import SceneDefinition -from crazy_robotaxi.config import load_game_settings -from crazy_robotaxi.high_scores import default_high_scores_path +from crazy_robotaxi.config import CrazyRobotaxiSettings, load_game_settings +from crazy_robotaxi.high_scores import default_high_scores_path, default_race_times_path +from crazy_robotaxi.live_edit.config import ( + LiveEditConfig, + add_live_edit_args, + live_edit_config_from_args, +) from crazy_robotaxi.rules import TaxiGameConfig from crazy_robotaxi.session import CrazyRobotaxiSession from crazy_robotaxi.ui import bev_display_extent @@ -39,8 +56,6 @@ _ROOT = Path(__file__).resolve().parent _DEFAULT_MAP = _ROOT / "maps" / "boulevard_district.robotaxi.yaml" -_DEFAULT_GAME = _ROOT / "configs" / "default_game.yaml" -_DEFAULT_RENDERER = _ROOT / "configs" / "default_renderer.yaml" _VIDEO_FPS = 30 """Generation and UI cadence; each UI tick advances one generated frame.""" @@ -123,6 +138,22 @@ class ApplicationConfig: show_fps: bool """Whether the HUD displays the measured generated-video frame rate.""" + game_mode: Literal["taxi", "race"] = "taxi" + """Rules mode selected for every session created by the application.""" + + race_course_id: str | None = None + """Requested race course, or ``None`` for the map's first course.""" + + race_times_path: Path | None = None + """Persistent map- and course-scoped race leaderboard.""" + + live_edit: LiveEditConfig = LiveEditConfig() + """Flag-gated style, weather, pickup, nitro, and obstacle abilities.""" + + visual_flare_enabled: bool = False + """Whether collision feedback may darken the presented game frame.""" + + PipelineFactory = Callable[[Any, str], Any] SceneFactory = Callable[[SceneRequest, Any], SceneDefinition] @@ -136,7 +167,7 @@ def __init__( pipeline_factory: PipelineFactory | None = None, scene_factory: SceneFactory | None = None, ) -> None: - self._defaults = load_renderer_settings(_DEFAULT_RENDERER) + self._defaults = RendererSettings(raster=RasterConfig(), bev=BevConfig()) self._pipeline_factory = pipeline_factory or _build_pipeline self._scene_factory = scene_factory or load_scene self._pipeline_config: Any = _MODEL_PRESETS["standard"].pipeline @@ -145,7 +176,11 @@ def __init__( def session_desc(self) -> SessionDesc: """Declare the trained single-view output contract without loading.""" - raster = self._defaults.raster + raster = ( + self._defaults.raster + if self._config is None + else self._config.renderer.raster + ) return SessionDesc( output_layout=VideoTensorLayout.tchw, frames_per_second_for_ui=_VIDEO_FPS, @@ -157,67 +192,198 @@ def session_desc(self) -> SessionDesc: def init(self, commandline_args: Sequence[str]) -> None: """Parse application options without starting another runtime.""" args = _parser().parse_args(list(commandline_args)) - if args.total_blocks is not None and args.total_blocks <= 0: + engine_settings = self._resolve_engine_settings(args) + game_settings = self._resolve_game_settings(args) + if ( + engine_settings.runtime.total_blocks is not None + and engine_settings.runtime.total_blocks <= 0 + ): raise ValueError("--total-blocks must be positive") if args.game_time_s is not None and args.game_time_s <= 0.0: raise ValueError("--game-time-s must be positive") - if args.prewarm_blocks < 0: + if engine_settings.runtime.prewarm_blocks < 0: raise ValueError("--prewarm-blocks must be non-negative") - renderer = load_renderer_settings(args.renderer_config) - settings = load_game_settings(args.game_config) - game = settings.game + if game_settings.mode != "race" and ( + arg_was_explicit(args, "race_course") + or arg_was_explicit(args, "race_times") + ): + raise ValueError("--race-course and --race-times require --game-mode race") + map_path = engine_settings.map.path + if map_path is None: + raise ValueError("A map path is required (set engine.map.path or --map)") + if game_settings.mode == "race": + header = load_game_map_header(map_path.expanduser()) + if not header.race_course_ids: + raise ValueError(f"Map {header.map_id!r} defines no race courses") + if ( + game_settings.race.course is not None + and game_settings.race.course not in header.race_course_ids + ): + available = ", ".join(header.race_course_ids) + raise ValueError( + f"Unknown race course {game_settings.race.course!r}; available: {available}" + ) + renderer = RendererSettings( + raster=engine_settings.rendering.raster, + bev=engine_settings.rendering.bev, + ) + game = game_settings.game game = replace( game, - seed=args.seed, global_time_s=( game.global_time_s if args.game_time_s is None else args.game_time_s ), high_scores_path=( default_high_scores_path() - if args.high_scores is None - else args.high_scores.expanduser() + if game_settings.taxi.high_scores_path is None + else game_settings.taxi.high_scores_path.expanduser() ), ) - model_preset = _MODEL_PRESETS[args.model_preset] + model_preset_name = engine_settings.world_model.model_preset + if model_preset_name not in _MODEL_PRESETS: + available = ", ".join(_MODEL_PRESETS) + raise ValueError( + f"Unknown model preset {model_preset_name!r}; available: {available}" + ) + model_preset = _MODEL_PRESETS[model_preset_name] pipeline_config = model_preset.pipeline - if args.compile is not None: + if engine_settings.world_model.compile is not None: pipeline_config = derive_config( pipeline_config, diffusion_model={ - "transformer": {"compile_network": bool(args.compile)} + "transformer": { + "compile_network": bool(engine_settings.world_model.compile) + } }, ) - if args.seed is not None: + if game_settings.taxi.seed is not None: pipeline_config = derive_config( pipeline_config, - diffusion_model={"seed": int(args.seed)}, + diffusion_model={"seed": int(game_settings.taxi.seed)}, ) pipeline_config = derive_config( pipeline_config, - enable_sync_and_profile=bool(args.profile_pipeline), + enable_sync_and_profile=bool(engine_settings.world_model.profile_pipeline), ) self._pipeline_config = pipeline_config self._config = ApplicationConfig( scene_request=SceneRequest( - map_path=args.map.expanduser(), - camera_name=args.camera, - variant=args.variant, - prompt=args.prompt, - force_recompile=args.force_map_recompile, + map_path=map_path.expanduser(), + camera_name=engine_settings.map.camera, + variant=engine_settings.map.variant, + prompt=engine_settings.map.prompt, + force_recompile=engine_settings.map.force_recompile, ), renderer=renderer, game=game, - driver_input=settings.driver_input, - device=args.device, - total_blocks=args.total_blocks, - model_preset_name=args.model_preset, + driver_input=game_settings.driver_input, + device=engine_settings.world_model.device, + total_blocks=engine_settings.runtime.total_blocks, + model_preset_name=model_preset_name, renderer_follows_session=model_preset.renderer_follows_session, - pipeline_profiling=bool(args.profile_pipeline), - prewarm_blocks=args.prewarm_blocks, - profile_input_latency=bool(args.profile_input_latency), - show_fps=bool(args.show_fps), + pipeline_profiling=bool(engine_settings.world_model.profile_pipeline), + prewarm_blocks=engine_settings.runtime.prewarm_blocks, + profile_input_latency=engine_settings.runtime.profile_input_latency, + show_fps=engine_settings.presentation.show_fps, + game_mode=game_settings.mode, + race_course_id=game_settings.race.course, + race_times_path=( + default_race_times_path() + if game_settings.race.times_path is None + else game_settings.race.times_path.expanduser() + ), + live_edit=game_settings.live_edit, + visual_flare_enabled=game_settings.effects.visual_flare_enabled, ) + def _resolve_engine_settings(self, args: argparse.Namespace) -> EngineSettings: + settings = EngineSettings( + map=MapLaunchSettings(path=_DEFAULT_MAP), + world_model=WorldModelLaunchSettings(), + rendering=RenderingSettings( + raster=self._defaults.raster, + bev=self._defaults.bev, + ), + ) + if args.engine_config is not None: + settings = load_engine_settings(args.engine_config, base=settings) + if args.renderer_config is not None: + legacy = load_renderer_settings(args.renderer_config) + settings = replace( + settings, + rendering=RenderingSettings(raster=legacy.raster, bev=legacy.bev), + ) + map_settings = settings.map + for destination, field_name in ( + ("map", "path"), + ("camera", "camera"), + ("variant", "variant"), + ("prompt", "prompt"), + ("force_map_recompile", "force_recompile"), + ): + if arg_was_explicit(args, destination): + map_settings = replace( + map_settings, **{field_name: getattr(args, destination)} + ) + world_model = settings.world_model + for destination in ("model_preset", "device", "compile", "profile_pipeline"): + if arg_was_explicit(args, destination): + world_model = replace( + world_model, **{destination: getattr(args, destination)} + ) + runtime = settings.runtime + for destination in ("total_blocks", "prewarm_blocks", "profile_input_latency"): + if arg_was_explicit(args, destination): + runtime = replace(runtime, **{destination: getattr(args, destination)}) + if runtime.profile_world_model: + world_model = replace(world_model, profile_pipeline=True) + presentation = settings.presentation + if arg_was_explicit(args, "show_fps"): + presentation = replace(presentation, show_fps=bool(args.show_fps)) + return replace( + settings, + map=map_settings, + world_model=world_model, + presentation=presentation, + runtime=runtime, + ) + + def _resolve_game_settings(self, args: argparse.Namespace) -> CrazyRobotaxiSettings: + settings = CrazyRobotaxiSettings( + driver_input=DriverInputConfig( + steering_scale=1.0, + steering_rate_per_s=3.5, + steering_return_rate_per_s=5.0, + ) + ) + if args.game_config is not None: + settings = load_game_settings(args.game_config, base=settings) + game = settings.game + taxi = settings.taxi + race = settings.race + if arg_was_explicit(args, "game_mode"): + settings = replace(settings, mode=args.game_mode) + if arg_was_explicit(args, "visual_flare"): + settings = replace( + settings, + effects=replace( + settings.effects, + visual_flare_enabled=bool(args.visual_flare), + ), + ) + if arg_was_explicit(args, "seed"): + taxi = replace(taxi, seed=args.seed) + game = replace(game, seed=args.seed) + if arg_was_explicit(args, "high_scores"): + taxi = replace(taxi, high_scores_path=args.high_scores) + if arg_was_explicit(args, "race_course"): + race = replace(race, course=args.race_course) + if arg_was_explicit(args, "race_times"): + race = replace(race, times_path=args.race_times) + settings = replace(settings, game=game, taxi=taxi, race=race) + args._live_edit_settings = settings.live_edit + return replace(settings, live_edit=live_edit_config_from_args(args)) + def create_session(self, session_desc: SessionDesc) -> ISession: """Create one session after validating its fixed model geometry.""" config = self._config @@ -339,13 +505,14 @@ def _fit_bev_renderer_to_ui( def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( + parser = ExplicitArgTrackingArgumentParser( prog="flashdreams-run-v2 crazy-robotaxi --", description="Drive Crazy Robotaxi on an authored semantic map.", ) + parser.add_argument("--engine-config", type=Path) parser.add_argument("--map", type=Path, default=_DEFAULT_MAP) - parser.add_argument("--game-config", type=Path, default=_DEFAULT_GAME) - parser.add_argument("--renderer-config", type=Path, default=_DEFAULT_RENDERER) + parser.add_argument("--game-config", type=Path) + parser.add_argument("--renderer-config", type=Path) parser.add_argument("--camera", default="camera_front_wide_120fov") parser.add_argument("--variant", default="default") parser.add_argument("--prompt") @@ -358,6 +525,14 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--game-time-s", type=float) parser.add_argument("--seed", type=int) parser.add_argument("--high-scores", type=Path) + parser.add_argument("--game-mode", choices=("taxi", "race"), default="taxi") + parser.add_argument( + "--visual-flare", + action=argparse.BooleanOptionalAction, + default=None, + ) + parser.add_argument("--race-course") + parser.add_argument("--race-times", type=Path) parser.add_argument("--compile", action=argparse.BooleanOptionalAction) parser.add_argument( "--profile-pipeline", @@ -384,4 +559,5 @@ def _parser() -> argparse.ArgumentParser: default=None, help="show the measured generated-video frame rate in the HUD", ) + add_live_edit_args(parser) return parser diff --git a/apps/crazy_robotaxi/crazy_robotaxi/assets/README.md b/apps/crazy_robotaxi/crazy_robotaxi/assets/README.md new file mode 100644 index 000000000..209b7d29d --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/assets/README.md @@ -0,0 +1,44 @@ +# Assets + +This directory holds the bundled HUD control sprites under +`wheel_and_pedals/` and the map-independent obstacle template catalog. + +## `obstacle_vehicle_tracks_v1.npz` + +Numeric vehicle trajectories used by the live-edit obstacle ability. The +catalog was deterministically extracted from +`clipgt-0d404ff7-2b66-498c-b047-1ed8cded60d4.usdz` with: + +```bash +python -m crazy_robotaxi.live_edit.obstacle_template_authoring \ + /path/to/clipgt-0d404ff7-2b66-498c-b047-1ed8cded60d4.usdz \ + crazy_robotaxi/assets/obstacle_vehicle_tracks_v1.npz +``` + +The archive contains only concatenated numeric arrays: relative timestamps, +local center translations, orientations, first-sample dimensions, object-type +codes, sample offsets, and initial height above the source ground. It contains +all 668 Car and Truck tracks from the source obstacle table. Runtime loading +uses `allow_pickle=False`; the source USDZ is authoring input and is not +packaged. + +## `wheel_and_pedals/` + +AlpaSim-style steering-wheel and pedal PNGs that drive the desktop +HUD chrome (the `SlangPyHudPresenter` steering-wheel + pedal overlay): + +- `steering_wheel.png` +- `throttle_pressed.png`, `throttle_unpressed.png` +- `brake_pressed.png`, `brake_unpressed.png` + +These are loaded by default (resolved relative to the installed +package), so the realistic controls render out of the box. Pass +`--control-assets-dir` to point the demo at a different sprite set; +the brake PNGs are also accepted under AlpaSim's `break_*.png` +spelling. When a sprite is missing, the HUD falls back to a +CPU-rendered vector wheel / fill-bar pedals. + +## Maps + +Crazy Robotaxi's `.robotaxi.yaml` maps live under `crazy_robotaxi/maps/`. +Seed images referenced by a map may be map-relative files or packaged assets. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/assets/__init__.py b/apps/crazy_robotaxi/crazy_robotaxi/assets/__init__.py new file mode 100644 index 000000000..1d5926394 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/assets/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Scene bundle extraction and ClipGT loading.""" diff --git a/apps/crazy_robotaxi/crazy_robotaxi/assets/obstacle_vehicle_tracks_v1.npz b/apps/crazy_robotaxi/crazy_robotaxi/assets/obstacle_vehicle_tracks_v1.npz new file mode 100644 index 0000000000000000000000000000000000000000..f271399481e2147915d53a45bfbebc28eb2a62e8 GIT binary patch literal 1180622 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 z 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

bY=}qR45pQazR(8j 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

r*(?w1=7N~lhR)b4l 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

R^*;Oo^}(}h4R@`T;FUgbvCcu)x_kk3dNNw| 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

{9${-hYb>%DWeEte! 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

});)8icY^1%4 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

<(LXQrA!6Gvj%WQCqd| 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

U-@nn_?D-21 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

-|uAsLyPndTu&clm+wTsT&=$ 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 literal 0 HcmV?d00001 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/config.py b/apps/crazy_robotaxi/crazy_robotaxi/config.py index 062657544..1c7e3c45f 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/config.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/config.py @@ -1,130 +1,212 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Strict Crazy Robotaxi gameplay configuration loading.""" +"""Layered Crazy Robotaxi gameplay configuration.""" from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass, field, fields, replace from pathlib import Path -from typing import Any, cast +from typing import Literal from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.yaml_config import ( StrictConfigError, load_yaml_mapping, - require_bool, - require_exact_keys, - require_float, + overlay_dataclass, require_mapping, require_version, ) from crazy_robotaxi.dynamics import TaxiVehicleConfig +from crazy_robotaxi.live_edit.config import LiveEditConfig from crazy_robotaxi.rules import TaxiGameConfig -_RUNTIME_GAME_FIELDS = { - "seed", - "high_scores_path", -} +_RUNTIME_GAME_FIELDS = {"seed", "high_scores_path"} _RULE_FIELDS = ( - {field.name for field in fields(TaxiGameConfig)} - - _RUNTIME_GAME_FIELDS - - {"vehicle"} + {item.name for item in fields(TaxiGameConfig)} - _RUNTIME_GAME_FIELDS - {"vehicle"} ) -_VEHICLE_FIELDS = {field.name for field in fields(TaxiVehicleConfig)} -_VEHICLE_BOOL_FIELDS = { - "speed_limit_enabled", - "actor_collision_enabled", - "static_collision_enabled", +_VEHICLE_FIELDS = {item.name for item in fields(TaxiVehicleConfig)} +_INPUT_FIELDS = {item.name for item in fields(DriverInputConfig)} +_TOP_LEVEL_FIELDS = { + "schema_version", + "mode", + "effects", + "rules", + "input", + "vehicle", + "taxi", + "race", + "live_edit", + "diagnostics", } -_INPUT_FIELDS = {field.name for field in fields(DriverInputConfig)} -@dataclass(frozen=True, slots=True) -class TaxiSettings: - """Game rules, driver input, and vehicle dynamics loaded together.""" +@dataclass(frozen=True) +class GameEffectsSettings: + """Game-directed visual effects.""" + + visual_flare_enabled: bool = False + """Whether collisions may trigger the full-screen visual flare.""" + + +@dataclass(frozen=True) +class TaxiSessionSettings: + """Taxi-mode session and persistence settings.""" + + seed: int | None = None + """Optional deterministic fare-layout seed.""" + + high_scores_path: Path | None = None + """Taxi leaderboard path; ``None`` uses the cache-directory default.""" + + +@dataclass(frozen=True) +class RaceSessionSettings: + """Race-mode selection and persistence settings.""" + + course: str | None = None + """Race-course identifier; ``None`` selects the map's first course.""" + + times_path: Path | None = None + """Race leaderboard path; ``None`` uses the cache-directory default.""" + + +@dataclass(frozen=True) +class GameDiagnosticsSettings: + """Optional Crazy Robotaxi diagnostic outputs.""" + + alignment_directory: Path | None = None + """Optional frame-alignment diagnostic output directory.""" + + +@dataclass(frozen=True) +class CrazyRobotaxiSettings: + """Complete durable game configuration for the V2 application.""" + + mode: Literal["taxi", "race"] = "taxi" + """Gameplay mode selected for the session.""" + + effects: GameEffectsSettings = field(default_factory=GameEffectsSettings) + """Game-directed visual effects.""" + + game: TaxiGameConfig = field(default_factory=TaxiGameConfig) + """Taxi rules and player-vehicle configuration.""" + + driver_input: DriverInputConfig = field(default_factory=DriverInputConfig) + """V2 keyboard-to-command reduction settings.""" + + taxi: TaxiSessionSettings = field(default_factory=TaxiSessionSettings) + """Taxi session and persistence settings.""" + + race: RaceSessionSettings = field(default_factory=RaceSessionSettings) + """Race selection and persistence settings.""" + + live_edit: LiveEditConfig = field(default_factory=LiveEditConfig) + """Map-context prompting and live-edit ability settings.""" - game: TaxiGameConfig - driver_input: DriverInputConfig + diagnostics: GameDiagnosticsSettings = field( + default_factory=GameDiagnosticsSettings + ) + """Optional diagnostic outputs.""" + + +TaxiSettings = CrazyRobotaxiSettings -def load_game_settings(path: Path) -> TaxiSettings: - """Load a complete Crazy Robotaxi game YAML document. +def load_game_settings( + path: Path, + *, + base: CrazyRobotaxiSettings | None = None, +) -> CrazyRobotaxiSettings: + """Overlay a partial game YAML onto typed settings. Args: - path: Game YAML path. + path: Game configuration path. + base: Lower-precedence settings; ``None`` uses typed defaults. Returns: - Validated game rules, V2 input reduction, and taxi dynamics. + Resolved game, input, session, and live-edit settings. + + Raises: + StrictConfigError: The YAML or merged settings are invalid. """ - doc = load_yaml_mapping(path) - require_exact_keys( - doc, - {"schema_version", "rules", "input", "vehicle"}, - "game", - ) + config_path = path.expanduser().resolve() + doc = load_yaml_mapping(config_path) require_version(doc, "game") - raw_rules = require_mapping(doc["rules"], "game.rules") - require_exact_keys(raw_rules, _RULE_FIELDS, "game.rules") - rules = { - name: require_float(raw_rules[name], f"game.rules.{name}", minimum=0.0) - for name in _RULE_FIELDS - } - for integer_name in ("base_fare_points", "bonus_points_per_second"): - value = raw_rules[integer_name] - if type(value) is not int or value < 0: - raise StrictConfigError( - f"game.rules.{integer_name} must be a nonnegative integer" - ) - rules[integer_name] = value - if rules["fare_min_route_distance_m"] > rules["fare_max_route_distance_m"]: - raise StrictConfigError( - "game.rules.fare_min_route_distance_m must not exceed fare_max_route_distance_m" + _reject_unknown(doc, _TOP_LEVEL_FIELDS, "game") + settings = base or CrazyRobotaxiSettings() + base_dir = config_path.parent + + if "mode" in doc: + settings = overlay_dataclass( + settings, {"mode": doc["mode"]}, "game", base_dir=base_dir ) - if rules["min_time_s"] > rules["max_time_s"]: - raise StrictConfigError("game.rules.min_time_s must not exceed max_time_s") + for yaml_name in ("effects", "taxi", "race", "live_edit", "diagnostics"): + if yaml_name not in doc: + continue + nested = overlay_dataclass( + getattr(settings, yaml_name), + require_mapping(doc[yaml_name], f"game.{yaml_name}"), + f"game.{yaml_name}", + base_dir=base_dir, + ) + settings = replace(settings, **{yaml_name: nested}) - raw_input = require_mapping(doc["input"], "game.input") - require_exact_keys(raw_input, _INPUT_FIELDS, "game.input") - input_values = { - name: require_float(raw_input[name], f"game.input.{name}", minimum=0.0) - for name in _INPUT_FIELDS - } - if input_values["steering_scale"] <= 0.0: - raise StrictConfigError("game.input.steering_scale must be positive") + game = settings.game + if "rules" in doc: + rules = require_mapping(doc["rules"], "game.rules") + _reject_unknown(rules, _RULE_FIELDS, "game.rules") + game = overlay_dataclass(game, rules, "game.rules", base_dir=base_dir) + if "vehicle" in doc: + vehicle_values = require_mapping(doc["vehicle"], "game.vehicle") + _reject_unknown(vehicle_values, _VEHICLE_FIELDS, "game.vehicle") + vehicle = overlay_dataclass( + game.vehicle, vehicle_values, "game.vehicle", base_dir=base_dir + ) + game = replace(game, vehicle=vehicle) + driver_input = settings.driver_input + if "input" in doc: + input_values = require_mapping(doc["input"], "game.input") + _reject_unknown(input_values, _INPUT_FIELDS, "game.input") + driver_input = overlay_dataclass( + driver_input, input_values, "game.input", base_dir=base_dir + ) - raw_vehicle = require_mapping(doc["vehicle"], "game.vehicle") - require_exact_keys(raw_vehicle, _VEHICLE_FIELDS, "game.vehicle") - vehicle_values = { - name: require_bool(raw_vehicle[name], f"game.vehicle.{name}") - if name in _VEHICLE_BOOL_FIELDS - else require_float(raw_vehicle[name], f"game.vehicle.{name}", minimum=0.0) - for name in _VEHICLE_FIELDS - } - for name in ( - "wheel_base_m", - "max_speed_mps", - "aabb_length_m", - "aabb_width_m", - "aabb_height_m", - "speed_taper_knee_fraction", - ): - if vehicle_values[name] <= 0.0: - raise StrictConfigError(f"game.vehicle.{name} must be positive") - for name in ( - "speed_taper_knee_fraction", - "speed_taper_low_floor", - "speed_taper_high_floor", - "curb_collision_restitution", - "curb_forward_momentum_retention", - ): - if vehicle_values[name] > 1.0: - raise StrictConfigError(f"game.vehicle.{name} must be at most 1") - return TaxiSettings( - game=TaxiGameConfig( - vehicle=TaxiVehicleConfig(**cast(Any, vehicle_values)), - **cast(Any, rules), + game = replace( + game, + seed=settings.taxi.seed, + **( + {"high_scores_path": settings.taxi.high_scores_path} + if settings.taxi.high_scores_path is not None + else {} ), - driver_input=DriverInputConfig(**input_values), ) + settings = replace(settings, game=game, driver_input=driver_input) + _validate_game_settings(settings) + return settings + + +def _reject_unknown(values: dict[str, object], allowed: set[str], context: str) -> None: + unknown = sorted(values.keys() - allowed) + if unknown: + raise StrictConfigError(f"{context} has unknown keys: {', '.join(unknown)}") + + +def _validate_game_settings(settings: CrazyRobotaxiSettings) -> None: + game = settings.game + for name in _RULE_FIELDS: + if getattr(game, name) < 0: + raise StrictConfigError(f"game.rules.{name} must be non-negative") + for name in _VEHICLE_FIELDS: + value = getattr(game.vehicle, name) + if type(value) is not bool and value < 0: + raise StrictConfigError(f"game.vehicle.{name} must be non-negative") + if game.fare_min_route_distance_m > game.fare_max_route_distance_m: + raise StrictConfigError( + "game.rules.fare_min_route_distance_m must not exceed fare_max_route_distance_m" + ) + if game.min_time_s > game.max_time_s: + raise StrictConfigError("game.rules.min_time_s must not exceed max_time_s") + if settings.driver_input.steering_scale <= 0.0: + raise StrictConfigError("game.input.steering_scale must be positive") diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml deleted file mode 100644 index 0102d848a..000000000 --- a/apps/crazy_robotaxi/crazy_robotaxi/configs/default_renderer.yaml +++ /dev/null @@ -1,23 +0,0 @@ -schema_version: 1 -raster: - width: 1280 - height: 704 - near_plane_m: 0.1 - far_plane_m: 200.0 - fog_start_m: 40.0 - fog_end_m: 140.0 - fog_power: 1.5 - triangle_raytrace_distance_m: 25.0 - triangle_raytrace_edge_samples: 8 - lane_segment_interval_m: 0.05 - polyline_segment_interval_m: 0.8 - line_width_px: 12.0 - pole_width_px: 5.0 - dual_line_offset_m: 0.1 -bev: - enabled: true - width: 1024 - height: 1024 - height_m: 75.0 - fov_deg: 60.0 - tilt_deg: 0.0 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml new file mode 100644 index 000000000..9f9d0b593 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml @@ -0,0 +1,52 @@ +schema_version: 1 +map: + path: ../maps/boulevard_district.robotaxi.yaml + camera: camera_front_wide_120fov + variant: default + force_recompile: false + preload_maps: false +world_model: + backend: omnidreams + manifest: example_world_model.yaml + offload_text_encoder: false + model_preset: standard + device: cuda + compile: null + profile_pipeline: false +rendering: + raster: + width: 1280 + height: 704 + near_plane_m: 0.1 + far_plane_m: 200.0 + fog_start_m: 40.0 + fog_end_m: 140.0 + fog_power: 1.5 + triangle_raytrace_distance_m: 25.0 + triangle_raytrace_edge_samples: 8 + lane_segment_interval_m: 0.05 + polyline_segment_interval_m: 0.8 + line_width_px: 12.0 + pole_width_px: 5.0 + dual_line_offset_m: 0.1 + bev: + enabled: true + width: 1024 + height: 1024 + height_m: 75.0 + fov_deg: 60.0 + tilt_deg: 0.0 +presentation: + hud_enabled: true + show_fps: false + stream_jpeg_quality: 85 + stream_scale: 1.0 +wheel: + enabled: true + profile: auto +runtime: + cuda_visible_devices: auto + profile_world_model: false + total_blocks: null + prewarm_blocks: 4 + profile_input_latency: false diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/default_game.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml similarity index 83% rename from apps/crazy_robotaxi/crazy_robotaxi/configs/default_game.yaml rename to apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml index eb447772f..0cefa7221 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/configs/default_game.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml @@ -1,4 +1,7 @@ schema_version: 1 +mode: taxi +effects: + visual_flare_enabled: false rules: waypoint_spacing_m: 10.0 pickup_grid_spacing_m: 60.0 @@ -26,7 +29,7 @@ input: steering_return_rate_per_s: 5.0 vehicle: wheel_base_m: 2.8 - max_steer_rad: 0.5 + max_steer_rad: 0.69 steer_rate_rad_per_s: 0.55 steer_return_rate_rad_per_s: 0.9 speed_limit_enabled: true @@ -34,7 +37,7 @@ vehicle: max_reverse_speed_mps: 6.0 max_accel_mps2: 10.0 max_brake_mps2: 14.0 - max_lateral_accel_mps2: 8.5 + max_lateral_accel_mps2: 17.0 drag_mps2: 0.7 mass_kg: 1550.0 tire_grip: 1.35 @@ -72,3 +75,22 @@ vehicle: handbrake_yaw_response_rate: 4.0 handbrake_lateral_damping_rate: 2.0 handbrake_lateral_accel_scale: 0.35 +taxi: + seed: null + high_scores_path: null +race: + course: null + times_path: null +live_edit: + style: + enabled: false + weather: + enabled: false + obstacle: + enabled: false + coins: + enabled: false + items: + enabled: false +diagnostics: + alignment_directory: null diff --git a/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py b/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py index 611cc439d..f5161b2c0 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py @@ -20,13 +20,18 @@ class TaxiVehicleConfig(VehicleConfig): """Arcade vehicle values used only when the Taxi game is active.""" + max_steer_rad: float = 0.69 + """Full-lock steering angle for tight arcade turns.""" + max_accel_mps2: float = 10.0 reverse_accel_mps2: float = 10.0 max_brake_mps2: float = 14.0 handbrake_decel_mps2: float = 18.0 handbrake_yaw_gain: float = 3.25 max_handbrake_yaw_rate_radps: float = 1.5 - max_lateral_accel_mps2: float = 8.5 + max_lateral_accel_mps2: float = 17.0 + """Lateral-acceleration ceiling for responsive high-speed steering.""" + max_body_roll_rad: float = 0.16 curb_collision_restitution: float = 0.45 """Rebound coefficient for map curbs and other static barriers.""" diff --git a/apps/crazy_robotaxi/crazy_robotaxi/factory.py b/apps/crazy_robotaxi/crazy_robotaxi/factory.py index 977af7081..26c34b59a 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/factory.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/factory.py @@ -7,6 +7,8 @@ from dataclasses import replace from functools import partial +from pathlib import Path +from typing import Literal from omnidreams_game_engine.conditioning import LudusConditionRenderer from omnidreams_game_engine.config import BevConfig, RasterConfig, VehicleConfig @@ -17,10 +19,16 @@ state_from_initial_pose, ) from omnidreams_game_engine.simulation.ground_snap import GroundSnapper +from omnidreams_game_engine.simulation.actor_controller import PhysicsActorController from omnidreams_game_engine.types import DriverCommand, SceneDefinition, VehicleState from crazy_robotaxi.dynamics import TaxiVehicleConfig, integrate_taxi_vehicle from crazy_robotaxi.physics import TaxiPhysicsWorld, step_taxi_physics_world +from crazy_robotaxi.high_scores import RaceTimeStore +from crazy_robotaxi.live_edit.config import LiveEditConfig +from crazy_robotaxi.live_edit.nitro_ability import integrate_with_nitro +from crazy_robotaxi.live_edit.runtime_v2 import LiveEditGameRules, LiveEditGameplay +from crazy_robotaxi.race import RaceController, RaceGameRules from crazy_robotaxi.rules import TaxiGameConfig, TaxiGameController, TaxiGameRules from crazy_robotaxi.scene import load_scene_data @@ -33,12 +41,33 @@ def build_taxi_engine( bev: BevConfig, frame_interval_s: float, device: str, + game_mode: Literal["taxi", "race"] = "taxi", + race_course_id: str | None = None, + race_times_path: Path | None = None, + actor_controllers: tuple[PhysicsActorController, ...] = (), + live_edit: LiveEditConfig = LiveEditConfig(), ) -> GameEngine: """Construct every mutable Taxi subsystem on the calling model thread.""" if scene.game_map is None: raise ValueError("Crazy Robotaxi requires a compiled semantic game map") scene_data = load_scene_data(scene) ground_snapper = _build_ground_snapper(scene, game_config) + live_edit_gameplay = ( + LiveEditGameplay( + live_edit, + scene, + scene_data.navigation_lanes, + vehicle=game_config.vehicle, + ) + if live_edit.any_enabled + else None + ) + integrate_fn = _integrate_taxi_vehicle + if live_edit_gameplay is not None and live_edit_gameplay.nitro is not None: + integrate_fn = integrate_with_nitro(live_edit_gameplay.nitro, integrate_fn) + live_actor_controllers = ( + () if live_edit_gameplay is None else live_edit_gameplay.actor_controllers + ) simulation = EgoVehicleKinematics( initial_state=state_from_initial_pose( initial_rig_to_world=scene.initial_rig_to_world, @@ -49,33 +78,68 @@ def build_taxi_engine( ground_snapper=ground_snapper, initial_timestamp_us=scene.initial_timestamp_us, scene=scene, - integrate_fn=_integrate_taxi_vehicle, + integrate_fn=integrate_fn, physics_world_factory=lambda active_scene, vehicle: TaxiPhysicsWorld( active_scene, game_config.vehicle, curb_segments_world=scene_data.curb_segments_world, + actor_controllers=(*actor_controllers, *live_actor_controllers), ), physics_step_fn=step_taxi_physics_world, include_initial_state_in_first_chunk=True, ) - controller = TaxiGameController( - scene_id=scene.scene_id, - reference_route_world=scene_data.reference_route_world, - navigation_lanes=scene_data.navigation_lanes, - fare_regions=scene_data.fare_regions, - initial_state=simulation.current_state, - config=game_config, - initial_camera=scene.selected_camera, - vicinity_resolver=GameMapVicinityResolver(scene.game_map), - ) + if game_mode == "race": + courses = scene.game_map.race_courses + if not courses: + raise ValueError(f"Map {scene.game_map.map_id!r} defines no race courses") + course = next( + ( + candidate + for candidate in courses + if candidate.course_id == race_course_id + ), + None, + ) + if race_course_id is not None and course is None: + available = ", ".join(candidate.course_id for candidate in courses) + raise ValueError( + f"Unknown race course {race_course_id!r}; available: {available}" + ) + course = courses[0] if course is None else course + if race_times_path is None: + raise ValueError("Race mode requires a race-times path") + rules = RaceGameRules( + RaceController( + scene.game_map, + course, + simulation.current_state, + RaceTimeStore(race_times_path), + ) + ) + else: + controller = TaxiGameController( + scene_id=scene.scene_id, + reference_route_world=scene_data.reference_route_world, + navigation_lanes=scene_data.navigation_lanes, + fare_regions=scene_data.fare_regions, + initial_state=simulation.current_state, + config=game_config, + initial_camera=scene.selected_camera, + vicinity_resolver=GameMapVicinityResolver(scene.game_map), + ) + rules = TaxiGameRules(controller) + if live_edit_gameplay is not None: + rules = LiveEditGameRules(rules, live_edit_gameplay) renderer = LudusConditionRenderer(raster, bev, device=device) renderer.load_scene(scene) - return GameEngine( + engine = GameEngine( simulation=simulation, - rules=TaxiGameRules(controller), + rules=rules, condition_renderer=renderer, frame_interval_s=frame_interval_s, ) + engine.live_edit = live_edit_gameplay + return engine def _integrate_taxi_vehicle( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py index d2074a24e..81ff81ba5 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Persistent taxi-game high-score storage.""" +"""Persistent taxi and race leaderboard storage.""" from __future__ import annotations @@ -17,16 +17,46 @@ from loguru import logger _CSV_FIELDS = ("name", "score", "achieved_at_utc") +_RACE_CSV_FIELDS = ( + "map_id", + "course_id", + "name", + "elapsed_time_us", + "achieved_at_utc", +) + _PLAYER_NAME_RE = re.compile(r"[A-Za-z0-9 _-]{1,12}") +def format_race_time_us(elapsed_time_us: int) -> str: + """Format a race duration as minutes, seconds, and milliseconds. + + Args: + elapsed_time_us: Nonnegative duration in integer microseconds. + + Returns: + Duration formatted as ``M:SS.XXX`` with unbounded minutes. + """ + total_milliseconds = (max(0, elapsed_time_us) + 500) // 1_000 + minutes, milliseconds_in_minute = divmod(total_milliseconds, 60_000) + seconds, milliseconds = divmod(milliseconds_in_minute, 1_000) + return f"{minutes}:{seconds:02d}.{milliseconds:03d}" + + def default_high_scores_path() -> Path: """Return the default persistent taxi leaderboard path.""" - from omnidreams.scenes import FLASHDREAMS_CACHE_DIR + from omnidreams.impl.scenes import FLASHDREAMS_CACHE_DIR return FLASHDREAMS_CACHE_DIR / "crazy-robotaxi" / "highscores.csv" +def default_race_times_path() -> Path: + """Return the default persistent race leaderboard path.""" + from omnidreams.impl.scenes import FLASHDREAMS_CACHE_DIR + + return FLASHDREAMS_CACHE_DIR / "crazy-robotaxi" / "race_times.csv" + + def validate_player_name(name: str) -> str: """Normalize and validate a leaderboard player name. @@ -199,3 +229,202 @@ def _write_unlocked(self, entries: tuple[HighScoreEntry, ...]) -> None: finally: if temporary_path is not None and temporary_path.exists(): temporary_path.unlink() + + +@dataclass(frozen=True) +class RaceTimeEntry: + """One map- and course-specific race result.""" + + map_id: str + """Stable ID of the map on which the result was achieved.""" + + course_id: str + """Course ID scoped to ``map_id``.""" + + name: str + """Player name shown on the leaderboard.""" + + elapsed_time_us: int + """Total race time in integer microseconds.""" + + achieved_at_utc: str + """UTC ISO-8601 timestamp used to order tied times.""" + + def as_dict(self) -> dict[str, object]: + """Return a JSON-serializable representation of the entry.""" + return { + "map_id": self.map_id, + "course_id": self.course_id, + "name": self.name, + "elapsed_time_us": self.elapsed_time_us, + "elapsed_time_s": self.elapsed_time_us / 1_000_000.0, + "elapsed_time": format_race_time_us(self.elapsed_time_us), + "achieved_at_utc": self.achieved_at_utc, + } + + +class RaceTimeStore: + """Atomically maintain a top-ten race board for every map/course pair.""" + + def __init__(self, path: Path, *, limit: int = 10) -> None: + self._path = path + self._limit = limit + self._lock_path = path.with_suffix(f"{path.suffix}.lock") + + @property + def path(self) -> Path: + """Return the shared race-times CSV path.""" + return self._path + + def read(self, map_id: str, course_id: str) -> tuple[RaceTimeEntry, ...]: + """Return the board for one map/course pair.""" + entries = self._read_locked() + return self._board(entries, map_id, course_id) + + def qualifying_rank( + self, map_id: str, course_id: str, elapsed_time_us: int + ) -> int | None: + """Return the prospective rank for a total time, if it qualifies.""" + if elapsed_time_us <= 0: + return None + board = self.read(map_id, course_id) + if len(board) >= self._limit and elapsed_time_us >= board[-1].elapsed_time_us: + return None + return 1 + sum(entry.elapsed_time_us <= elapsed_time_us for entry in board) + + def record( + self, + map_id: str, + course_id: str, + name: str, + elapsed_time_us: int, + *, + achieved_at_utc: str | None = None, + ) -> tuple[RaceTimeEntry | None, tuple[RaceTimeEntry, ...]]: + """Insert a qualifying total time and return the updated scoped board.""" + map_id = self._validate_scope_id("map_id", map_id) + course_id = self._validate_scope_id("course_id", course_id) + normalized_name = validate_player_name(name) + if elapsed_time_us <= 0: + return None, self.read(map_id, course_id) + timestamp = achieved_at_utc or datetime.now(timezone.utc).isoformat( + timespec="seconds" + ) + entry = RaceTimeEntry( + map_id, course_id, normalized_name, int(elapsed_time_us), timestamp + ) + self._path.parent.mkdir(parents=True, exist_ok=True) + with FileLock(self._lock_path): + entries = list(self._read_unlocked()) + board = self._board(entries, map_id, course_id) + inserted: RaceTimeEntry | None = entry + if ( + len(board) >= self._limit + and elapsed_time_us >= board[-1].elapsed_time_us + ): + inserted = None + else: + entries.append(entry) + entries = self._trim_all(entries) + self._write_unlocked(entries) + return inserted, self._board(entries, map_id, course_id) + + def _read_locked(self) -> tuple[RaceTimeEntry, ...]: + if not self._path.exists(): + return () + try: + with FileLock(self._lock_path): + return self._read_unlocked() + except OSError as exc: + logger.warning(f"[race] could not lock times at {self._path}: {exc}") + return self._read_unlocked() + + def _read_unlocked(self) -> tuple[RaceTimeEntry, ...]: + if not self._path.exists(): + return () + entries: list[RaceTimeEntry] = [] + try: + with self._path.open(newline="", encoding="utf-8") as csv_file: + for row_number, row in enumerate(csv.DictReader(csv_file), start=2): + try: + map_id = self._validate_scope_id( + "map_id", row.get("map_id", "") + ) + course_id = self._validate_scope_id( + "course_id", row.get("course_id", "") + ) + name = validate_player_name(row.get("name", "")) + elapsed = int(row.get("elapsed_time_us", "")) + timestamp = row.get("achieved_at_utc", "") + datetime.fromisoformat(timestamp) + if elapsed <= 0: + raise ValueError + except (TypeError, ValueError): + logger.warning( + f"[race] ignoring malformed time row {row_number} " + f"in {self._path}" + ) + continue + entries.append( + RaceTimeEntry(map_id, course_id, name, elapsed, timestamp) + ) + except (OSError, csv.Error) as exc: + logger.warning(f"[race] could not read times from {self._path}: {exc}") + return () + return tuple(entries) + + def _board( + self, + entries: tuple[RaceTimeEntry, ...] | list[RaceTimeEntry], + map_id: str, + course_id: str, + ) -> tuple[RaceTimeEntry, ...]: + scoped = ( + entry + for entry in entries + if entry.map_id == map_id and entry.course_id == course_id + ) + return tuple( + sorted( + scoped, key=lambda entry: (entry.elapsed_time_us, entry.achieved_at_utc) + )[: self._limit] + ) + + def _trim_all(self, entries: list[RaceTimeEntry]) -> tuple[RaceTimeEntry, ...]: + scopes = sorted({(entry.map_id, entry.course_id) for entry in entries}) + return tuple( + entry + for map_id, course_id in scopes + for entry in self._board(entries, map_id, course_id) + ) + + @staticmethod + def _validate_scope_id(field: str, value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} must be a non-empty string") + return value.strip() + + def _write_unlocked(self, entries: tuple[RaceTimeEntry, ...]) -> None: + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + newline="", + encoding="utf-8", + dir=self._path.parent, + prefix=f".{self._path.name}.", + suffix=".tmp", + delete=False, + ) as csv_file: + temporary_path = Path(csv_file.name) + writer = csv.DictWriter(csv_file, fieldnames=_RACE_CSV_FIELDS) + writer.writeheader() + for entry in entries: + row = entry.as_dict() + writer.writerow({field: row[field] for field in _RACE_CSV_FIELDS}) + csv_file.flush() + os.fsync(csv_file.fileno()) + os.replace(temporary_path, self._path) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py new file mode 100644 index 000000000..822f5b0af --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""OmniDreams live-edit abilities for Crazy Robotaxi (flag-gated, off by default). + +Draft scaffold for the integration plan in +``integrations/omnidreams/docs/robotaxi_live_edit_integration.md``. Two +abilities, each behind its own ``--live-edit-*`` flag: + +- **Style skins** (:mod:`crazy_robotaxi.live_edit.style_ability`): mid-run + world restyle via a pre-merged text-edit LoRA + drift corrector attached + to the flashdreams session, toggled by a prompt swap between chunks. +- **Coin pickups** (:mod:`crazy_robotaxi.live_edit.coin_ability` + + :mod:`crazy_robotaxi.live_edit.presenter`): sprites laid out along the + navigation lanes, projected per frame through the scene's FTheta camera, + composited into the world-model frame before HUD/encode, collected by + ego proximity. +- **Weather events** (:mod:`crazy_robotaxi.live_edit.weather_ability`, key + ``v``): clear -> rain -> snow -> clear via plain two-prompt-guided swaps + (no LoRA; the style LoRA is bypassed for weather-only windows) issued + through the same :class:`StyleAbility` prompt state machine. +- **Effect items** (:mod:`crazy_robotaxi.live_edit.item_ability`): sparse + pickup items along the lanes (rain/snow icons, mystery boxes) that reuse + the coin course/projection/compositing machinery and trigger the + weather/skin state machines at the next chunk boundary — the same path + the key requests take (the keys stay live alongside). +- **Obstacle events** (:mod:`crazy_robotaxi.live_edit.obstacle_ability`, + key ``o``): generated crossing cars with a gameplay-owned lifecycle, + optional compiled-road placement, optional PhysX materialization, and + optional box-axis guidance on the model side. + +Wiring map (composition-root seams, all live in ``crazy_robotaxi``): + +1. CLI: ``runtime_cli.build_parser`` registers the ``--live-edit-*`` flags + via :func:`crazy_robotaxi.live_edit.config.add_live_edit_args`; the run + paths build :class:`LiveEditConfig` with + :func:`crazy_robotaxi.live_edit.config.live_edit_config_from_args` and + hand it to ``CrazyRobotaxiApp``. +2. Session attach: ``CrazyRobotaxiApp.__init__`` calls + :func:`crazy_robotaxi.live_edit.style_ability.install_style_ability_on_backend` + before model warmup starts (corrector-safe session swap + deferred + LoRA attach). +3. Presenter: ``CrazyRobotaxiApp.__init__`` wraps the chain as + ``CausalFrameAlignmentPresenter(LiveEditPresenter(inner, ...))`` so the + compositor sees frame-aligned poses; the per-rollout coin ability is + bound via ``LiveEditPresenter.set_coin_ability``. +4. Keys: ``k`` cycles the skin, ``c`` toggles coins — native HUD + (``hud_presenter._on_keyboard_event``) and MJPEG + (``streaming_presenter._apply_control``) both raise rising-edge + requests on ``CrazyRobotaxiKeyboardState.live_edit`` + (:class:`crazy_robotaxi.live_edit.input_hooks.LiveEditRequests`), + drained each tick by ``CrazyRobotaxiRuntime.process_events``. +""" + +from crazy_robotaxi.live_edit.config import ( + LiveEditCoinsConfig, + LiveEditConfig, + LiveEditItemsConfig, + LiveEditObstacleConfig, + LiveEditStyleConfig, + LiveEditWeatherConfig, + add_live_edit_args, + live_edit_config_from_args, +) + +__all__ = [ + "LiveEditCoinsConfig", + "LiveEditConfig", + "LiveEditItemsConfig", + "LiveEditObstacleConfig", + "LiveEditStyleConfig", + "LiveEditWeatherConfig", + "add_live_edit_args", + "live_edit_config_from_args", +] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/coin_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/coin_ability.py new file mode 100644 index 000000000..e53c8aa44 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/coin_ability.py @@ -0,0 +1,359 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Coin course: lane-aligned layout, FTheta projection, proximity pickup. + +Ports the course/projection logic of +``integrations/omnidreams/scripts/composite_track_items.py`` from its fitted +pinhole camera onto the scene's exact +:class:`omnidreams_game_engine.camera.FThetaCameraModel` and the authoritative +per-frame ego pose (``PresentedFrame.rig_to_world`` / ``vehicle_state``). +Pure CPU/numpy; no GPU dependencies. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.types import VehicleState + +from crazy_robotaxi.live_edit.config import LiveEditCoinsConfig +from crazy_robotaxi.navigation import NavigationLane + +_GRID_CELL_M = 32.0 +"""Spatial-hash cell edge for the coin course (see :class:`_CoinGrid`).""" + + +@dataclass(frozen=True) +class CoinSprite: + """One coin projected into the camera image for compositing.""" + + center_uv: tuple[float, float] + """Coin-center pixel position in the model-resolution image.""" + + height_px: float + """On-screen coin diameter in pixels.""" + + alpha: float + """Composite opacity in ``[0, 1]`` (distance fade).""" + + distance_m: float + """Horizontal camera-to-coin distance, used for far-to-near ordering.""" + + spin_phase: float + """Stable per-coin phase for the spin/squash animation.""" + + sprite_key: str = "coin" + """Compositor sprite-bank key (effect items carry their type here).""" + + spin: bool = True + """Whether the spin/squash animation applies (items render static).""" + + +def build_coin_course( + lanes: Sequence[NavigationLane], + config: LiveEditCoinsConfig, +) -> npt.NDArray[np.float32]: + """Lay out coin world positions along the driving-lane centerlines. + + Mirrors ``TaxiNavigationMap.sample_waypoints``' walk but emits lateral + groups (rows of coins across the lane) at ``config.spacing_m`` intervals. + + Every directed car lane contributes, including lanes without a mapped + roadside stopping edge (``allows_taxi_stops=False``): those are regular + driving lanes, and restricting coins to curb-adjacent lanes left the + course on road edges the ego never crosses within pickup radius. + + Args: + lanes: Directed driving-lane centerlines. + config: Coin layout parameters. + + Returns: + Coin centers with shape ``[coins, 3]`` in world coordinates. + + Raises: + ValueError: No lane yields a single coin. + """ + coins: list[npt.NDArray[np.float32]] = [] + occupied_cells: set[tuple[int, int]] = set() + for lane in lanes: + points = np.asarray(lane.centerline_world, dtype=np.float32) + if len(points) < 2: + continue + segment_lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + cumulative = np.concatenate(([0.0], np.cumsum(segment_lengths))) + total = float(cumulative[-1]) + distance = config.spacing_m / 2.0 + while distance < total: + index = int(np.searchsorted(cumulative, distance) - 1) + index = max(0, min(index, len(points) - 2)) + span = float(cumulative[index + 1] - cumulative[index]) + fraction = 0.0 if span <= 0.0 else (distance - cumulative[index]) / span + center = points[index] + fraction * (points[index + 1] - points[index]) + direction = points[index + 1, :2] - points[index, :2] + norm = float(np.linalg.norm(direction)) + if norm <= 1.0e-6: + distance += config.spacing_m + continue + right = np.array( + [direction[1] / norm, -direction[0] / norm], dtype=np.float32 + ) + cell = (round(center[0] * 0.5), round(center[1] * 0.5)) + if cell not in occupied_cells: + occupied_cells.add(cell) + for offset in config.group_offsets_m: + coin = center.copy() + coin[:2] += right * np.float32(offset) + coin[2] += np.float32(config.hover_height_m) + coins.append(coin) + distance += config.spacing_m + if not coins: + raise ValueError("Coin course requires at least one drivable lane sample.") + return np.stack(coins).astype(np.float32) + + +class _CoinGrid: + """Static 2D spatial hash over coin XY positions. + + A course spans thousands of coins but only the ones within + ``max_render_distance_m`` of the ego matter each frame; hashing the + (immutable) coin layout into ``_GRID_CELL_M`` cells makes the per-frame + candidate lookup O(nearby) instead of O(course). Query results are + cached per (cell, radius) and only recomputed when the query point + crosses a cell boundary, so the steady-state cost is one dict probe. + """ + + def __init__( + self, coins_xy: npt.NDArray[np.float32], cell_m: float = _GRID_CELL_M + ) -> None: + self._cell_m = float(cell_m) + cells = np.floor(coins_xy / self._cell_m).astype(np.int64) + order = np.lexsort((cells[:, 1], cells[:, 0])) + sorted_cells = cells[order] + boundaries = np.flatnonzero(np.any(np.diff(sorted_cells, axis=0), axis=1)) + 1 + self._cells: dict[tuple[int, int], npt.NDArray[np.intp]] = {} + for chunk in np.split(order, boundaries) if len(order) else (): + key = (int(cells[chunk[0], 0]), int(cells[chunk[0], 1])) + self._cells[key] = chunk + self._window_cache: dict[tuple[int, int, float], npt.NDArray[np.intp]] = {} + self._empty = np.empty(0, dtype=np.intp) + + def near(self, x: float, y: float, radius: float) -> npt.NDArray[np.intp]: + """Indices of all coins possibly within ``radius`` of ``(x, y)``. + + Superset by construction (cell granularity); callers still apply the + exact distance test. Cached per (query cell, radius): valid for any + query point inside the cell because the reach adds one full cell. + """ + cell_x = math.floor(x / self._cell_m) + cell_y = math.floor(y / self._cell_m) + key = (cell_x, cell_y, radius) + cached = self._window_cache.get(key) + if cached is not None: + return cached + reach = math.ceil(radius / self._cell_m) + 1 + chunks = [ + chunk + for cx in range(cell_x - reach, cell_x + reach + 1) + for cy in range(cell_y - reach, cell_y + reach + 1) + if (chunk := self._cells.get((cx, cy))) is not None + ] + # Ascending index order keeps painter's-order ties deterministic and + # identical to the pre-grid full scan. + window = np.sort(np.concatenate(chunks)) if chunks else self._empty + # Keep only the most recent windows; the ego revisits few cells. + if len(self._window_cache) > 8: + self._window_cache.clear() + self._window_cache[key] = window + return window + + +class CoinAbility: + """Track coin collection and produce per-frame screen sprites.""" + + def __init__( + self, + coins_world: npt.NDArray[np.float32], + config: LiveEditCoinsConfig, + *, + sprite_keys: Sequence[str] | None = None, + spin: bool = True, + ) -> None: + if coins_world.ndim != 2 or coins_world.shape[1] != 3: + raise ValueError("coins_world must have shape [coins, 3]") + if sprite_keys is not None and len(sprite_keys) != len(coins_world): + raise ValueError("sprite_keys must match coins_world length") + self._coins_world = coins_world.astype(np.float32) + self._config = config + self._sprite_keys = None if sprite_keys is None else tuple(sprite_keys) + self._spin = spin + self._collected = np.zeros(len(coins_world), dtype=bool) + self._grid = _CoinGrid(self._coins_world[:, :2]) + self.enabled = True + + @classmethod + def from_lanes( + cls, lanes: Sequence[NavigationLane], config: LiveEditCoinsConfig + ) -> CoinAbility: + """Build the ability with a course laid out along ``lanes``.""" + return cls(build_coin_course(lanes, config), config) + + @property + def collected_count(self) -> int: + """Return the number of coins collected so far.""" + return int(self._collected.sum()) + + @property + def score(self) -> int: + """Return the coin score contribution.""" + return self.collected_count * self._config.points_per_coin + + @property + def remaining_count(self) -> int: + """Return the number of uncollected coins.""" + return int((~self._collected).sum()) + + def toggle(self) -> bool: + """Flip rendering/collection on or off; return the new state.""" + self.enabled = not self.enabled + return self.enabled + + def advance_frames(self, vehicle_states: Iterable[VehicleState]) -> int: + """Collect coins within pickup radius of any pose; return new pickups.""" + return len(self.collect_near(vehicle_states)) + + def collect_near(self, vehicle_states: Iterable[VehicleState]) -> tuple[int, ...]: + """Collect coins within pickup radius; return their course indices. + + The indices let item-typed courses (:mod:`~.item_ability`) map each + pickup back to its effect; plain coin callers only need the count. + """ + if not self.enabled: + return () + collected: list[int] = [] + radius = self._config.pickup_radius_m + for state in vehicle_states: + near = self._grid.near(state.x_m, state.y_m, radius) + if len(near) == 0: + continue + candidates = near[~self._collected[near]] + if len(candidates) == 0: + continue + deltas = self._coins_world[candidates, :2] - np.array( + [state.x_m, state.y_m], dtype=np.float32 + ) + hits = np.linalg.norm(deltas, axis=1) <= radius + if hits.any(): + self._collected[candidates[hits]] = True + collected.extend(int(i) for i in candidates[hits]) + return tuple(collected) + + def visible_sprites( + self, + rig_to_world: npt.NDArray[np.float32], + camera_model: FThetaCameraModel, + *, + image_width: int, + image_height: int, + ) -> tuple[CoinSprite, ...]: + """Project uncollected coins near the camera into image pixels. + + The on-screen diameter comes from projecting each coin's vertical + extent (center ± diameter/2), so fisheye distortion and camera pitch + are handled exactly rather than via a pinhole ``fx/z`` approximation. + + Returns: + Sprites sorted far-to-near, ready for painter's-algorithm + compositing; at most ``config.max_visible_sprites`` (the + nearest ones win when the course is dense). + """ + if not self.enabled: + return () + camera_xy = rig_to_world[:2, 3] + window = self._grid.near( + float(camera_xy[0]), float(camera_xy[1]), self._config.max_render_distance_m + ) + if len(window) == 0: + return () + remaining = window[~self._collected[window]] + if len(remaining) == 0: + return () + centers = self._coins_world[remaining] + distances = np.linalg.norm(centers[:, :2] - camera_xy[None, :], axis=1) + near = distances <= self._config.max_render_distance_m + if not near.any(): + return () + centers = centers[near] + distances = distances[near] + indices = remaining[near] + + half = np.array( + [0.0, 0.0, self._config.coin_diameter_m / 2.0], dtype=np.float32 + ) + points = np.concatenate((centers - half, centers + half), axis=0) + uv, _depth, forward = camera_model.project_world(points, rig_to_world) + count = len(centers) + bottom_uv, top_uv = uv[:count], uv[count:] + center_uv = (bottom_uv + top_uv) / 2.0 + heights_px = np.linalg.norm(top_uv - bottom_uv, axis=1) + keep = ( + forward[:count] + & forward[count:] + & (center_uv[:, 0] >= 0.0) + & (center_uv[:, 0] < image_width) + & (center_uv[:, 1] >= 0.0) + & (center_uv[:, 1] < image_height) + & (heights_px >= 3.0) + ) + if not keep.any(): + return () + kept = np.flatnonzero(keep) + order = kept[np.argsort(-distances[kept], kind="stable")] + cap = self._config.max_visible_sprites + if cap > 0 and len(order) > cap: + # Far-to-near order: dropping the head keeps the nearest coins. + order = order[-cap:] + alphas = self._fade_array(distances) + keys = self._sprite_keys + return tuple( + CoinSprite( + center_uv=(float(center_uv[i, 0]), float(center_uv[i, 1])), + height_px=float(heights_px[i]), + alpha=float(alphas[i]), + distance_m=float(distances[i]), + spin_phase=float(indices[i]) * 0.61, + sprite_key="coin" if keys is None else keys[int(indices[i])], + spin=self._spin, + ) + for i in order + ) + + def _fade_array( + self, distances_m: npt.NDArray[np.floating] + ) -> npt.NDArray[np.floating]: + """Vectorized :meth:`_fade` over all candidate distances.""" + fade_start = self._config.fade_start_distance_m + fade_end = self._config.max_render_distance_m + if fade_end <= fade_start: + return np.ones_like(distances_m) + alphas = (fade_end - distances_m) / (fade_end - fade_start) + return np.clip(alphas, 0.0, 1.0) + + def _fade(self, distance_m: float) -> float: + fade_start = self._config.fade_start_distance_m + fade_end = self._config.max_render_distance_m + if distance_m <= fade_start: + return 1.0 + if fade_end <= fade_start: + return 1.0 + return max(0.0, (fade_end - distance_m) / (fade_end - fade_start)) + + +def coin_squash(spin_phase: float, frame_index: int) -> float: + """Return the horizontal squash of a spinning coin for one frame.""" + return max(0.3, abs(math.cos(frame_index * 2.0 * math.pi / 36.0 + spin_phase))) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py new file mode 100644 index 000000000..eeec75519 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py @@ -0,0 +1,1443 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Configuration for the flag-gated live-edit abilities. + +Follows the ``TaxiGameConfig`` pattern (frozen dataclass, docstring per +field, validation in ``__post_init__``) plus argparse helpers mirroring the +``--taxi-*`` flag flow in ``runtime_cli.py`` / ``app.taxi_config_from_args``. +""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Callable, Literal, cast + +from omnidreams_game_engine.cli_args import arg_was_explicit + +_CORRECTOR_MODES = ("fused", "unfused", "off") + + +@dataclass(frozen=True) +class StyleSkin: + """One selectable world skin driven by a prompt swap.""" + + name: str + """Short HUD label, e.g. ``arcade``.""" + + prompt: str + """Full edit prompt swapped into the text cross-attention cache.""" + + +# The v6 style LoRA is prompt-selected: these are the exact declarative +# prompts its four styles were trained on (edit_sft/style_prompts.py). +_DEFAULT_SKINS: tuple[StyleSkin, ...] = ( + StyleSkin( + name="arcade", + prompt=( + "A bright arcade racing game world with exaggerated saturated " + "colors, clean stylized surfaces, and a cheerful sunny palette." + ), + ), + StyleSkin( + name="comic", + prompt=( + "A comic book style world with bold black ink outlines, halftone " + "shading, and vivid flat colors." + ), + ), + StyleSkin( + name="cyberpunk", + prompt=( + "A neon-lit cyberpunk night city with glowing signs and " + "rain-slicked streets." + ), + ), + StyleSkin( + name="pixel", + prompt=( + "Retro 16-bit pixel art video game graphics with visible pixels " + "and a bright limited color palette." + ), + ), +) + + +@dataclass(frozen=True) +class LiveEditStyleConfig: + """Live game-skin switching (text-edit LoRA + drift corrector).""" + + enabled: bool = False + """Whether the style ability is attached to the world-model session.""" + + lora_checkpoint: Path | None = None + """Pre-merged text-edit LoRA checkpoint (``guidance_distill`` format).""" + + corrector_checkpoint: Path | None = None + """Style-drift corrector LoRA checkpoint (``train_v2`` format).""" + + corrector_gain: float = 0.15 + """Global corrector gain composed with the alpha*(t) gate profile.""" + + corrector_mode: str = "fused" + """Drift-corrector deploy mode. ``fused`` rides the CUDA-graph-safe + per-state ``DriftCorrectorDispatch`` (compile_network + use_cuda_graph + stay ON; validated 207 ms/chunk vs 203.9 no-corrector); ``unfused`` + falls back to the eager scale-gated path, which forces the graph-free + pipeline (~1.4 s/chunk in-game); ``off`` disables every corrector even + when checkpoints are configured — no corrector machinery is built and + no transformer weights are snapshotted or copied. + ``LIVE_EDIT_CORRECTOR_MODE`` sets the CLI default.""" + + base_corrector_checkpoint: Path | None = None + """Optional photoreal drift corrector for the BASE world state (fused + mode only; the shipped ``lora_v2_v3_valpeak.pt`` deploy). ``None`` + leaves the base world uncorrected.""" + + base_corrector_gain: float = 0.25 + """Gain for the base-state photoreal corrector (``corrgate025``).""" + + gate_alpha_json: Path | None = None + """Measured per-timestep gate profile (``edit_sft/gate_style.py`` output).""" + + guidance_scale: float = 2.5 + """Edit-window strength marker for skin swaps. With the pre-merged edit + LoRA deployed, any value > 1.0 (together with ``guidance_chunks`` > 0) + opens the single-branch LoRA window; exactly 1.0 falls back to a plain + swap, which *deactivates* the LoRA. 2.5/20 is the validated skin + deployment from the smoke harness.""" + + guidance_chunks: int = 6 + """Number of chunks the LoRA edit window stays open after a swap. + + With the pre-merged edit LoRA deployed this window is realized + single-branch (merged weights toggled at the boundaries), so its length + is NOT a per-chunk cost — only a stacked deployment without the LoRA + would fall back to the two-prompt guidance whose window doubles the + chunk cost. A/B on a cyberpunk swap (2026-08-21): 6 lands the style as + fast and as strong as the old 20 (the 8-chunk re-swap refresh re-opens + the window before long holds soften), so 6 is the default; it also caps + the exposure of any stacked no-LoRA deployment to the 2x window. + Exposed as ``--live-edit-skin-guidance-chunks``.""" + + reswap_interval_chunks: int = 8 + """Re-issue the active skin's ``replace_text`` every N generated chunks. + + Long holds soften after ~8-10 chunks as the edit window ages out of the + KV cache; a periodic duty-cycled re-swap keeps the style crisp. ``0`` + disables the refresh. Skipped entirely when a timed skin + (:attr:`skin_duration_chunks`) expires at or before the first refresh + would fire — the re-swap would land on an already-reverted world.""" + + skin_duration_chunks: int = 0 + """Timed "power-up" mode: auto-revert an activated skin to the base + world after this many generated chunks (at a chunk boundary, through + the same plain-swap revert path the K cycle uses). ``0`` (default) + keeps the current hold-until-cycled behavior. 11 chunks is ~3 s at the + shipped 8-frames-per-chunk / 30 fps recipe. Pressing K while a timed + skin is active cycles to the NEXT skin with a fresh timer (same K + semantics as untimed mode; mashing K to extend simply re-lands the + cycle). Exposed as ``--live-edit-skin-duration-chunks``. Also holds + ~10+ chunk scene-content drift in check: the skin never outlives the + crisp window.""" + + skins: tuple[StyleSkin, ...] = _DEFAULT_SKINS + """Selectable skins, cycled by the switch-skin key.""" + + def __post_init__(self) -> None: + """Validate style values at configuration time.""" + if self.enabled and self.lora_checkpoint is None: + raise ValueError("live_edit.style requires --live-edit-style-lora") + if not 0.0 <= self.corrector_gain <= 1.0: + raise ValueError("corrector_gain must be in [0, 1]") + if self.corrector_mode not in _CORRECTOR_MODES: + raise ValueError(f"corrector_mode must be one of {_CORRECTOR_MODES}") + if not 0.0 <= self.base_corrector_gain <= 1.0: + raise ValueError("base_corrector_gain must be in [0, 1]") + if self.guidance_scale < 1.0: + raise ValueError("guidance_scale must be at least 1.0") + if self.guidance_chunks < 0: + raise ValueError("guidance_chunks must be non-negative") + if self.reswap_interval_chunks < 0: + raise ValueError("reswap_interval_chunks must be non-negative") + if self.skin_duration_chunks < 0: + raise ValueError("skin_duration_chunks must be non-negative") + if self.enabled and not self.skins: + raise ValueError("live_edit.style requires at least one skin") + + +@dataclass(frozen=True) +class WeatherPreset: + """One selectable weather state driven by a prompt swap. + + Weather is a base-world-only ability (design decision 2026-08-20): it + never composes with a skin prompt, so each preset carries exactly one + standalone scene prompt. + """ + + name: str + """Short HUD label, e.g. ``rain``.""" + + prompt: str + """Full standalone scene prompt describing the weather over the base + world. Scene-native declarative phrasing lands much stronger than + instruction-style wording (calibration sweeps, 2026-08-08).""" + + +# Daytime-rain phrasing follows the validated RAIN_NIGHT_NATIVE structure +# (sweep_text_edit.py) adapted to the daylight suburban scenes, with the +# visible-precipitation cues front-loaded (streaks in the air, droplets on +# the windshield/lens, tire spray) — the 2026-08-20 recapture showed wording +# that leans on wet-road looks alone reads as "no rain" to viewers. Snow +# extends the scene bundle's snowstorm wording with the same front-loaded +# falling-precipitation cues (heavy snowfall, flakes in the air, accumulation +# on the hood) after the 2.5-guidance capture read as a light dusting. Storm +# is an experimental heavy-weather preset: appearance cues (dark sky, +# torrential rain, fog, headlights) are expected to land; dynamic wind +# effects (bending trees, flying debris) are unlikely to materialize in a +# history-anchored world model and are included only as steering pressure. +# Hurricane escalates storm along the axes that DO land (2026-08-21 A/B): +# visibility collapse, spray/mist walls, debris lying statically ON the +# flooded road, black-green sky — no flying-debris or bending-tree wording, +# which never materializes. +_DEFAULT_WEATHERS: tuple[WeatherPreset, ...] = ( + WeatherPreset( + name="rain", + prompt=( + "A dashcam perspective of a suburban street in a heavy daytime " + "downpour under a dark gray overcast sky. Dense visible rain " + "streaks slice through the air across the whole frame, and " + "raindrops and water droplets bead and run down the windshield " + "and camera lens. The asphalt road is saturated with sheeting " + "water, a glossy wet mirror breaking up reflections, and mist " + "and spray kick up from the tires of vehicles. The car's wet " + "hood is covered with rain droplets. Photorealistic dashcam " + "footage in pouring rain." + ), + ), + WeatherPreset( + name="snow", + prompt=( + "A dashcam perspective from inside a vehicle driving down a " + "wide suburban residential street in heavy snowfall during a " + "snowstorm. Thick white snowflakes fall densely and visibly " + "through the air across the whole frame, streaking past the " + "windshield. The road is heavily covered in white snow with " + "visible parallel tire tracks, and fresh snow keeps " + "accumulating on the asphalt. Vehicles parked along the curb " + "and the roadsides are coated in a thick layer of snow. The " + "surrounding houses, lawns, and large trees are completely " + "blanketed in winter snow. The sky is a bright white-out " + "overcast winter sky. In the foreground, the bottom of the " + "windshield and the car's snow-dusted hood are visible, with " + "thick snowflakes and snow accumulating on the hood and around " + "the windshield wipers." + ), + ), + WeatherPreset( + name="storm", + prompt=( + "A dashcam perspective of a suburban street in a violent " + "hurricane-force storm. Torrential rain hammers down in dense " + "sheets, thick rain streaks slice through the air, and water " + "sprays across the windshield and camera lens. The sky is a " + "dark green-black wall of storm clouds, so dark that oncoming " + "vehicles have their headlights on. Low fog and wind-driven " + "mist blow across the road, trees bend hard in the violent " + "wind, and loose leaves and debris fly through the air. The " + "flooded asphalt sheets with water and heavy spray kicks up " + "from the tires. Photorealistic dashcam footage inside a " + "severe storm." + ), + ), + WeatherPreset( + name="hurricane", + prompt=( + "A dashcam perspective of a suburban street in the eyewall of " + "a landfalling hurricane, visibility collapsed to almost " + "nothing. Blinding torrential rain bands and solid walls of " + "white spray and mist swallow the street, so only the nearest " + "stretch of road is visible before everything dissolves into " + "gray-white murk. Fallen tree branches, palm fronds, leaves, " + "and scattered debris litter the flooded road surface, lying " + "across the lanes in standing water. The sky is an oppressive " + "black-green hurricane sky, dark as night at midday, and the " + "whole scene is drowned in emergency gloom. Oncoming headlights " + "smear into halos through the deluge, windshield wipers thrash " + "at full speed, and sheets of water crash over the windshield " + "and camera lens. Photorealistic dashcam footage inside a " + "catastrophic hurricane." + ), + ), +) + + +def skins_starting_with(name: str | None) -> tuple[StyleSkin, ...]: + """Rotate the default skins so ``name`` leads the K-key cycle. + + Mirrors :func:`weathers_starting_with`: one confirmed K press selects + the named skin directly — important for timed power-up demos where + cycling through the skins ahead of it would burn transitional chunks. + + Raises: + ValueError: ``name`` is not a known skin name. + """ + if name is None: + return _DEFAULT_SKINS + names = [skin.name for skin in _DEFAULT_SKINS] + if name not in names: + raise ValueError(f"unknown skin {name!r}; choose from {names}") + index = names.index(name) + return _DEFAULT_SKINS[index:] + _DEFAULT_SKINS[:index] + + +def weathers_starting_with(name: str | None) -> tuple[WeatherPreset, ...]: + """Rotate the default presets so ``name`` leads the V-key cycle. + + The weather key steps clear -> presets in order -> clear, so putting a + preset first lets one confirmed key press select it directly (no brief + pass through the presets ahead of it in the default order). + + Raises: + ValueError: ``name`` is not a known preset name. + """ + if name is None: + return _DEFAULT_WEATHERS + names = [weather.name for weather in _DEFAULT_WEATHERS] + if name not in names: + raise ValueError(f"unknown weather preset {name!r}; choose from {names}") + index = names.index(name) + return _DEFAULT_WEATHERS[index:] + _DEFAULT_WEATHERS[:index] + + +@dataclass(frozen=True) +class LiveEditWeatherConfig: + """Live weather events (plain guided prompt swaps, no LoRA needed). + + Weather is only available over the base world: the V key is ignored + while a skin is active, and activating a skin clears any active + weather (design decision 2026-08-20 — skin+weather combo prompts + produced unattributable rain and were dropped). + """ + + enabled: bool = False + """Whether the weather ability responds to the weather-cycle key.""" + + guidance_scale: float = 2.5 + """Two-prompt edit-guidance strength for weather swaps (the PR #431 + mechanism: flow pushed along the new-minus-old text direction). 2.5/20 + is the validated skin deployment; earlier sweeps needed 3.0 for snow, + so this is exposed as ``--live-edit-weather-guidance``.""" + + guidance_chunks: int = 6 + """Number of chunks the two-prompt LANDING window stays open. + + TRANSIENT COST: weather has no LoRA, so every denoise step inside this + window runs a second network forward — a swap costs ~2x per chunk for + this many chunks. Weather then persists through the KV history and the + swapped cross-attention text with NO guidance ("land-then-release", + A/B'd 2026-08-21: a 6-chunk landing matches the old always-guided 20 + within noise over a 27-chunk hold), so the steady-state cost of an + active weather is ~1x. Exposed as + ``--live-edit-weather-guidance-chunks``.""" + + maintain_interval_chunks: int = 0 + """Re-open a short guidance window every N chunks while weather holds. + + ``0`` (default) holds with no guidance at all — the validated + land-then-release policy. A positive interval issues a maintenance + pulse of :attr:`maintain_chunks` guided chunks every N chunks, REBASED + first (plain swap to the base prompt, then the guided weather swap): + a same-prompt re-swap snapshots its old KV from buffers that already + hold the weather text, making the guidance direction exactly zero — + pure wasted 2x (this is what the old style re-swap refresh did to + weather). Exposed as ``--live-edit-weather-maintain-interval``.""" + + maintain_chunks: int = 2 + """Guided chunks per maintenance pulse (used when + :attr:`maintain_interval_chunks` > 0). Exposed as + ``--live-edit-weather-maintain-chunks``.""" + + duration_chunks: int = 90 + """Timed weather: auto-revert an active weather to clear after this many + generated chunks (~24 s at the shipped 8-frames-per-chunk / 30 fps + recipe). Applies to every activation path (V key and pickup items). + ``0`` holds until cycled. The revert lands GUIDED (see + :attr:`clear_guidance_chunks`): unlike a skin revert, clear is itself a + weather transition and a plain swap leaves the precipitation running on + KV-history momentum. Accepted physics: the revert stops NEW + precipitation but does not undo accumulated scene change — wet roads dry + gradually and snow lingers then fades, which reads as realistic weather + passing. Exposed as ``--live-edit-weather-duration-chunks``.""" + + clear_guidance_chunks: int = 8 + """Guided chunks for the weather -> clear landing (both the timed + auto-revert and a V-cycle wrap to clear). Slightly longer than the + 6-chunk activation landing because dense states (hurricane fog walls) + dissipate slower than they land. Exposed as + ``--live-edit-weather-clear-guidance-chunks``.""" + + corrector_gain: float = 0.0 + """Absolute style-drift-corrector gain while weather is active. ``0`` + (default) keeps the corrector off during weather — policy decision + 2026-08-23: the clean-forcing corrector runs ONLY for game-skin states + (0.15), base and weather states stay uncorrected. A/B note: 0.10 + measured slightly crisper late-run under long weather holds, but with + timed weather (~24 s default) the window is short, so the knob stays + for A/B while the default is off.""" + + corrector_checkpoint: Path | None = None + """Dedicated corrector checkpoint for the weather state (fused mode). + ``None`` reuses the style corrector at :attr:`corrector_gain`.""" + + weathers: tuple[WeatherPreset, ...] = _DEFAULT_WEATHERS + """Selectable weathers, cycled clear -> rain -> snow -> storm -> + hurricane -> clear by default; :func:`weathers_starting_with` rotates the order for direct + one-press selection.""" + + def __post_init__(self) -> None: + """Validate weather values at configuration time.""" + if self.guidance_scale < 1.0: + raise ValueError("weather guidance_scale must be at least 1.0") + if self.guidance_chunks < 0: + raise ValueError("weather guidance_chunks must be non-negative") + if self.maintain_interval_chunks < 0: + raise ValueError("weather maintain_interval_chunks must be non-negative") + if self.maintain_chunks < 0: + raise ValueError("weather maintain_chunks must be non-negative") + if self.duration_chunks < 0: + raise ValueError("weather duration_chunks must be non-negative") + if self.clear_guidance_chunks < 0: + raise ValueError("weather clear_guidance_chunks must be non-negative") + if not 0.0 <= self.corrector_gain <= 1.0: + raise ValueError("weather corrector_gain must be in [0, 1]") + if self.enabled and not self.weathers: + raise ValueError("live_edit.weather requires at least one preset") + + +@dataclass(frozen=True) +class LiveEditObstacleConfig: + """Track-backed general-obstacle events, initially shipping vehicles.""" + + enabled: bool = False + """Whether the obstacle ability responds to the spawn key.""" + + count: int = 1 + """Cars per spawn request. Additional cars alternate crossing direction + and are staggered by :attr:`spacing_m` and :attr:`stagger_chunks`.""" + + spawn_ahead_m: float = 16.0 + """Ahead distance for the first event in the selected placement mode.""" + + spacing_m: float = 8.0 + """Extra ahead-distance per additional car (count > 1). The default + puts a 4-car burst across a 16-40 m band — the model's validated + materialization range.""" + + stagger_chunks: int = 1 + """Chunks between consecutive car spawns in one burst. ``0`` spawns + the whole burst in one chunk; a small stagger both eases the model into + the event and spreads the passes out on screen.""" + + lateral_m: float = 0.0 + """Meters to the left (+) / right (-) of the ego heading at spawn.""" + + active_chunks: int = 10 + """Despawn each non-static event after this many generated chunks. + + Source-track exhaustion can end an event sooner.""" + + min_drift_m: float = 15.0 + """Minimum ground-plane displacement for a moving template.""" + + min_coverage_s: float = 4.0 + """Minimum source-track duration for a moving template.""" + + length_range_m: tuple[float, float] = (3.4, 5.6) + """Inclusive vehicle-length filter for obstacle templates.""" + + collision_radius_m: float = 3.0 + """Ego XY distance at which a visual-only event logs a hit.""" + + physics: bool = False + """Register obstacles with PhysX. False preserves PR494's visual-only + conditioning behavior; true makes collisions authoritative.""" + + placement: Literal["ego-relative", "road-ahead"] = "ego-relative" + """Placement resolver: ``ego-relative`` preserves PR494 behavior; + ``road-ahead`` walks the compiled directed-lane graph.""" + + static_count: int = 0 + """Static roadblock cars placed ahead of the spawn pose from the first + chunk and retained until reset. Slots start + ``static_ahead_m`` out, ``spacing_m`` apart, laterals alternating + right/left by ``static_lateral_m`` so the ego can weave between them. + In visual mode, pair with ``guide_scale`` ~2.0: unguided static + boxes can render at ghost strength when the initial camera frame shows + the road empty, while s=2.0 materializes solid + stopped cars in the 5-25 m band. ``0`` disables.""" + + static_ahead_m: float = 28.0 + """Meters ahead of the spawn pose where the first static car sits + (nearer slots fight the initial frame hardest and stay ghost).""" + + static_lateral_m: float = 2.8 + """Lateral offset magnitude of the alternating static-car slots.""" + + guide_scale: float = 0.0 + """Box-axis guidance strength (flow extrapolated along the + with-box/without-box conditioning direction). ``0`` disables the + guidance hook entirely (the event may render at ghost strength); ``2.0`` + is the validated in-game operating point (solid vehicle, in-box |diff| ~18 vs + ~7 unguided, out-box clean; ``3.0`` breaks up at near range). + CUDA-graph safe (2026-08-21): during an event each denoise step replays + the captured graph twice with the box/no-box conditioning staged in, so + event chunks cost ~2x model time and non-event chunks are unchanged; no + graph-free rebuild. Not wired for the native optimized-DiT executor.""" + + annotate: bool = False + """Draw each event's projected 3D box outline into presented frames + (evidence/demo aid).""" + + def __post_init__(self) -> None: + """Validate obstacle values at configuration time.""" + if self.count < 1: + raise ValueError("obstacle count must be at least 1") + if self.spacing_m <= 0.0: + raise ValueError("spacing_m must be positive") + if self.stagger_chunks < 0: + raise ValueError("stagger_chunks must be non-negative") + if self.spawn_ahead_m <= 0.0: + raise ValueError("spawn_ahead_m must be positive") + if self.active_chunks <= 0: + raise ValueError("active_chunks must be positive") + if self.min_drift_m < 0.0: + raise ValueError("min_drift_m must be non-negative") + if self.min_coverage_s < 0.0: + raise ValueError("min_coverage_s must be non-negative") + if not 0.0 < self.length_range_m[0] <= self.length_range_m[1]: + raise ValueError("length_range_m must be a positive (lo, hi) pair") + if self.static_count < 0: + raise ValueError("static_count must be non-negative") + if self.static_ahead_m <= 0.0: + raise ValueError("static_ahead_m must be positive") + if self.guide_scale < 0.0: + raise ValueError("guide_scale must be non-negative") + if self.placement not in {"ego-relative", "road-ahead"}: + raise ValueError( + "obstacle placement must be 'ego-relative' or 'road-ahead'" + ) + + +@dataclass(frozen=True) +class LiveEditCoinsConfig: + """Collectible coin course composited into the presented frames.""" + + enabled: bool = False + """Whether coins are laid out, rendered, and collectible.""" + + spacing_m: float = 25.0 + """Arc-length spacing between coin groups along each navigation lane.""" + + group_offsets_m: tuple[float, ...] = (-1.1, 0.0, 1.1) + """Lateral offsets of the coins in one group, metres across the lane.""" + + hover_height_m: float = 0.8 + """Coin center height above the waypoint ground point.""" + + coin_diameter_m: float = 0.62 + """World-space coin diameter used for sprite scaling.""" + + pickup_radius_m: float = 2.5 + """XY distance at which the ego collects a coin.""" + + points_per_coin: int = 50 + """Score awarded per collected coin (HUD counter only for now).""" + + max_render_distance_m: float = 120.0 + """Coins farther than this are not composited.""" + + fade_start_distance_m: float = 100.0 + """Alpha ramps to zero between this distance and the render limit.""" + + max_visible_sprites: int = 64 + """Composite at most this many coins per frame, keeping the nearest. + + Dense courses put hundreds of coins inside the render radius (the + shipped suburb course peaks at 211), and the compositor's per-frame + cost is launch-bound per sprite — unbounded sprite counts are what + actually blow the frame budget. The dropped coins are the farthest + (small, distance-faded) ones. ``0`` disables the cap.""" + + sprite_path: Path | None = None + """RGBA coin sprite; ``None`` renders a procedural coin.""" + + def __post_init__(self) -> None: + """Validate coin values at configuration time.""" + if self.spacing_m <= 0.0: + raise ValueError("coin spacing_m must be positive") + if self.pickup_radius_m <= 0.0: + raise ValueError("coin pickup_radius_m must be positive") + if self.coin_diameter_m <= 0.0: + raise ValueError("coin_diameter_m must be positive") + if not 0.0 < self.fade_start_distance_m <= self.max_render_distance_m: + raise ValueError( + "fade_start_distance_m must be in (0, max_render_distance_m]" + ) + if self.max_visible_sprites < 0: + raise ValueError("max_visible_sprites must be non-negative") + + +ITEM_TYPES = ("rain", "snow", "mystery", "nitro") +"""Effect-item kinds, in course-layout cycle order.""" + + +@dataclass(frozen=True) +class LiveEditItemsConfig: + """Sparse pickup items along the lanes that trigger live-edit effects. + + Reuses the coin machinery (lane-course layout, FTheta projection, + proximity pickup, GPU compositing) but places one item every + :attr:`spacing_m` instead of a coin row every 25 m. Picking an item up + routes the effect through the existing ability state machines at the + next chunk boundary — the exact path the K/V key requests take; the + keys stay fully functional alongside. + """ + + enabled: bool = False + """Whether effect items are laid out, rendered, and collectible.""" + + spacing_m: float = 200.0 + """Arc-length spacing between items along each navigation lane (items + are rare by design; 150-300 m is the intended range).""" + + hover_height_m: float = 1.0 + """Item center height above the waypoint ground point.""" + + item_diameter_m: float = 0.9 + """World-space item height used for sprite scaling (bigger than a coin + so the rare pickups read from a distance).""" + + pickup_radius_m: float = 2.5 + """XY distance at which the ego collects an item.""" + + max_render_distance_m: float = 120.0 + """Items farther than this are not composited.""" + + fade_start_distance_m: float = 100.0 + """Alpha ramps to zero between this distance and the render limit.""" + + rain_sprite_path: Path | None = None + """RGBA rain-item sprite; ``None`` renders a procedural placeholder. + Sprite files are local-only paths, never bundled (coin-sprite pattern).""" + + snow_sprite_path: Path | None = None + """RGBA snow-item sprite; ``None`` renders a procedural placeholder.""" + + mystery_sprite_path: Path | None = None + """RGBA mystery-box sprite; ``None`` renders a procedural '?' box.""" + + nitro_sprite_path: Path | None = None + """RGBA nitro-item sprite; ``None`` renders a procedural placeholder.""" + + item_types: tuple[str, ...] = ITEM_TYPES + """Item kinds included in the course mix, cycled in this order by the + layout walk (equal rarity per kind). A subset (e.g. ``("nitro",)``) + makes a single-effect course for scripted captures; the default mixes + every kind. Exposed as ``--live-edit-item-types``.""" + + nitro_boost: float = 1.6 + """Nitro speed-boost multiplier applied to BOTH the vehicle's max speed + and its max acceleration inside the app-authoritative physics tick + while a nitro pickup is active (>= 1). 1.6 reads punchy without + outrunning the world model at the default ceiling.""" + + nitro_duration_s: float = 4.0 + """Nitro boost duration in game time (simulated seconds, accumulated + from the physics-tick dt, which is wall time at the shipped realtime + recipe). Picking a second nitro while boosted RESETS the timer to this + value — no multiplicative stacking.""" + + nitro_max_speed_mps: float = 16.0 + """Hard ceiling on the boosted max speed. Safety knob: the world model + sees the faster ego through the conditioning, which stays plausible up + to highway speeds, but the scene must not outrun the model's manifold — + ~16 m/s is the validated comfort zone on the shipped suburb map. Raise + it cautiously on faster maps.""" + + mystery_burst_chunks: int = 11 + """Timed-skin duration granted by a mystery box (~3 s at the shipped + recipe). Overrides the global ``skin_duration_chunks`` per activation so + the box grants a burst even when the global mode is hold-forever (0); + ``0`` makes the granted skin untimed.""" + + mystery_seed: int | None = None + """Seed for the mystery-box skin roll (reproducible captures); ``None`` + draws from the OS entropy pool. Re-seeded per rollout.""" + + flash_seconds: float = 2.5 + """How long the pickup HUD flash chip stays up.""" + + def __post_init__(self) -> None: + """Validate item values at configuration time.""" + if self.spacing_m <= 0.0: + raise ValueError("item spacing_m must be positive") + if self.pickup_radius_m <= 0.0: + raise ValueError("item pickup_radius_m must be positive") + if self.item_diameter_m <= 0.0: + raise ValueError("item_diameter_m must be positive") + if not 0.0 < self.fade_start_distance_m <= self.max_render_distance_m: + raise ValueError( + "item fade_start_distance_m must be in (0, max_render_distance_m]" + ) + if self.mystery_burst_chunks < 0: + raise ValueError("mystery_burst_chunks must be non-negative") + if self.flash_seconds <= 0.0: + raise ValueError("flash_seconds must be positive") + if not self.item_types: + raise ValueError("item_types must name at least one item kind") + unknown = set(self.item_types) - set(ITEM_TYPES) + if unknown: + raise ValueError( + f"unknown item types {sorted(unknown)}; choose from {ITEM_TYPES}" + ) + if self.nitro_boost < 1.0: + raise ValueError("nitro_boost must be at least 1.0") + if self.nitro_duration_s <= 0.0: + raise ValueError("nitro_duration_s must be positive") + if self.nitro_max_speed_mps <= 0.0: + raise ValueError("nitro_max_speed_mps must be positive") + + def sprite_path(self, item_type: str) -> Path | None: + """Configured sprite path for one item type (``None`` = procedural).""" + paths = { + "rain": self.rain_sprite_path, + "snow": self.snow_sprite_path, + "mystery": self.mystery_sprite_path, + "nitro": self.nitro_sprite_path, + } + if item_type not in paths: + raise ValueError(f"unknown item type {item_type!r}") + return paths[item_type] + + +@dataclass(frozen=True) +class LiveEditConfig: + """Top-level live-edit ability switchboard.""" + + style: LiveEditStyleConfig = field(default_factory=LiveEditStyleConfig) + """Live skin-switching ability.""" + + coins: LiveEditCoinsConfig = field(default_factory=LiveEditCoinsConfig) + """Coin-pickup ability.""" + + items: LiveEditItemsConfig = field(default_factory=LiveEditItemsConfig) + """Effect-item pickup ability.""" + + weather: LiveEditWeatherConfig = field(default_factory=LiveEditWeatherConfig) + """Weather-event ability.""" + + obstacle: LiveEditObstacleConfig = field(default_factory=LiveEditObstacleConfig) + """Obstacle-event ability.""" + + sharpen_amount: float = 0.8 + """Unsharp-mask strength applied to styled frames (0 disables).""" + + sharpen_sigma: float = 2.0 + """Gaussian sigma of the unsharp mask.""" + + perf_log_every_frames: int = 0 + """Log p50/p95 of the live-edit per-frame costs (coin-update CPU ms, + compositor enqueue CPU ms, compositor GPU ms) every N composited frames + on the tensor path. ``0`` disables the report. Exposed as + ``--live-edit-perf-log``; ``LIVE_EDIT_PERF_LOG`` sets the CLI default.""" + + @property + def any_enabled(self) -> bool: + """Return whether any ability needs the presenter wrapper.""" + return ( + self.style.enabled + or self.coins.enabled + or self.items.enabled + or self.weather.enabled + or self.obstacle.enabled + ) + + def __post_init__(self) -> None: + """Validate presenter-filter values at configuration time.""" + if self.sharpen_amount < 0.0: + raise ValueError("sharpen_amount must be non-negative") + if self.sharpen_sigma <= 0.0: + raise ValueError("sharpen_sigma must be positive") + if self.perf_log_every_frames < 0: + raise ValueError("perf_log_every_frames must be non-negative") + + +def add_live_edit_args(parser: argparse.ArgumentParser) -> None: + """Register the ``--live-edit-*`` flags next to the ``--taxi-*`` flags.""" + group = parser.add_argument_group("live edit") + group.add_argument( + "--live-edit-style", + action=argparse.BooleanOptionalAction, + default=False, + help="Enable mid-run world-skin switching (requires the LoRA checkpoints).", + ) + group.add_argument( + "--live-edit-style-lora", + type=Path, + default=None, + help="Pre-merged text-edit LoRA checkpoint for the style ability.", + ) + group.add_argument( + "--live-edit-style-corrector", + type=Path, + default=None, + help="Style-drift corrector checkpoint (optional but recommended).", + ) + group.add_argument( + "--live-edit-style-gain", + type=float, + default=0.15, + help="Drift-corrector gain (composed with the gate profile).", + ) + group.add_argument( + "--live-edit-corrector-mode", + type=str, + choices=_CORRECTOR_MODES, + default=os.environ.get("LIVE_EDIT_CORRECTOR_MODE", "fused"), + help=( + "Drift-corrector deploy mode: 'fused' keeps CUDA graphs + " + "compile_network on (real-time); 'unfused' is the old eager " + "fallback; 'off' disables every corrector (no transformer " + "weights are touched even if corrector checkpoints are given). " + "Env default: LIVE_EDIT_CORRECTOR_MODE." + ), + ) + group.add_argument( + "--live-edit-skin-guidance-chunks", + type=int, + default=6, + help=( + "Chunks the skin edit window stays open after a swap (the " + "pre-merged LoRA realizes it single-branch, so this is not a " + "per-chunk cost; the 8-chunk re-swap refresh re-opens it)." + ), + ) + group.add_argument( + "--live-edit-base-corrector", + type=Path, + default=None, + help=( + "Photoreal drift-corrector checkpoint for the base world state " + "(fused mode only; omit to leave the base world uncorrected)." + ), + ) + group.add_argument( + "--live-edit-base-corrector-gain", + type=float, + default=0.25, + help="Gain for the base-state photoreal corrector (shipped: 0.25).", + ) + group.add_argument( + "--live-edit-gate-alpha-json", + type=Path, + default=None, + help="Measured per-timestep corrector gate profile JSON.", + ) + group.add_argument( + "--live-edit-style-reswap-chunks", + type=int, + default=8, + help=( + "Re-issue the active skin swap every N generated chunks so long " + "holds stay crisp (0 disables the refresh)." + ), + ) + group.add_argument( + "--live-edit-skin-first", + type=str, + default=None, + help=( + "Rotate the skin cycle so this skin comes first (direct " + "one-press select, e.g. 'cyberpunk'; default keeps arcade first)." + ), + ) + group.add_argument( + "--live-edit-skin-duration-chunks", + type=int, + default=0, + help=( + "Timed power-up mode: auto-revert an activated skin to the base " + "world after N generated chunks (0 = hold until cycled, the " + "default; 11 is ~3 s at 8 frames/chunk, 30 fps)." + ), + ) + group.add_argument( + "--live-edit-weather", + action=argparse.BooleanOptionalAction, + default=False, + help="Enable mid-run weather events (guided prompt swaps; V key).", + ) + group.add_argument( + "--live-edit-weather-guidance", + type=float, + default=2.5, + help=( + "Two-prompt guidance scale for weather swaps (2.5 = validated " + "default; snow needed 3.0 in earlier sweeps)." + ), + ) + group.add_argument( + "--live-edit-weather-guidance-chunks", + type=int, + default=6, + help=( + "Chunks the two-prompt weather LANDING window stays open (2x " + "model cost per guided chunk). After the landing the weather " + "holds unguided at ~1x (land-then-release)." + ), + ) + group.add_argument( + "--live-edit-weather-maintain-interval", + type=int, + default=0, + help=( + "Re-open a short rebased guidance window every N chunks while " + "weather holds (0 = plain hold, the validated default)." + ), + ) + group.add_argument( + "--live-edit-weather-maintain-chunks", + type=int, + default=2, + help="Guided chunks per weather maintenance pulse.", + ) + group.add_argument( + "--live-edit-weather-duration-chunks", + type=int, + default=90, + help=( + "Timed weather: auto-revert to clear after N generated chunks " + "via a guided clear landing (~24 s at 8 frames/chunk, 30 fps; " + "0 = hold until cycled). Applies to V-key and item pickups." + ), + ) + group.add_argument( + "--live-edit-weather-clear-guidance-chunks", + type=int, + default=8, + help=( + "Guided chunks for the weather->clear landing (auto-revert and " + "V-cycle wrap; a bit longer than the activation landing so " + "dense states like hurricane fog dissipate)." + ), + ) + group.add_argument( + "--live-edit-weather-first", + type=str, + default=None, + help=( + "Rotate the weather cycle so this preset comes first (direct " + "one-press select, e.g. 'snow'; default keeps rain first)." + ), + ) + group.add_argument( + "--live-edit-weather-corrector-gain", + type=float, + default=0.0, + help=( + "Absolute drift-corrector gain while weather is active. Default " + "0 = off (policy: the clean-forcing corrector runs only for " + "game-skin states; 0.10 was slightly crisper on long holds but " + "timed weather keeps windows short). Knob kept for A/B." + ), + ) + group.add_argument( + "--live-edit-weather-corrector", + type=Path, + default=None, + help=( + "Dedicated corrector checkpoint for the weather state (fused " + "mode; default reuses the style corrector)." + ), + ) + group.add_argument( + "--live-edit-obstacle", + action=argparse.BooleanOptionalAction, + default=False, + help="Enable track-backed obstacle events (O key).", + ) + group.add_argument( + "--live-edit-obstacle-physics", + action=argparse.BooleanOptionalAction, + default=False, + help=( + "Give obstacle events PhysX bodies (default: visual-only PR494 " + "conditioning behavior)." + ), + ) + group.add_argument( + "--live-edit-obstacle-placement", + choices=("ego-relative", "road-ahead"), + default="ego-relative", + help=( + "Place obstacles from the current ego pose (default) or along " + "the compiled directed-lane graph." + ), + ) + group.add_argument( + "--live-edit-obstacle-count", + type=int, + default=1, + help=( + "Cars per obstacle spawn (1 = single obstacle; 3-5 makes a " + "staggered crossing event)." + ), + ) + group.add_argument( + "--live-edit-obstacle-stagger-chunks", + type=int, + default=1, + help=("Chunks between consecutive car spawns in one burst (0 = all at once)."), + ) + group.add_argument( + "--live-edit-obstacle-ahead-m", + type=float, + default=16.0, + help="Ahead distance where the first obstacle event starts.", + ) + group.add_argument( + "--live-edit-obstacle-chunks", + type=int, + default=10, + help="Chunks the obstacle event stays active before despawn.", + ) + group.add_argument( + "--live-edit-obstacle-static-count", + type=int, + default=0, + help=( + "Static roadblock: N track-backed stopped cars placed midroad from the " + "session's first chunk (alternating laterals; pair with " + "--live-edit-obstacle-guide-scale 2.0; 0 disables)." + ), + ) + group.add_argument( + "--live-edit-obstacle-static-ahead-m", + type=float, + default=28.0, + help="Meters ahead of spawn where the first static car sits.", + ) + group.add_argument( + "--live-edit-obstacle-static-lateral-m", + type=float, + default=2.8, + help=( + "Lateral offset magnitude of the alternating static-car slots " + "(bigger leaves the ego a wider slalom line)." + ), + ) + group.add_argument( + "--live-edit-obstacle-guide-scale", + type=float, + default=0.0, + help=( + "Box-axis guidance strength over each event (0 disables; 3.0 was " + "the fully-opaque probe operating point)." + ), + ) + group.add_argument( + "--live-edit-obstacle-annotate", + action=argparse.BooleanOptionalAction, + default=False, + help="Draw the obstacle event's projected box outline (evidence aid).", + ) + group.add_argument( + "--live-edit-coins", + action=argparse.BooleanOptionalAction, + default=False, + help="Enable collectible coins composited along the route.", + ) + group.add_argument( + "--live-edit-coin-sprite", + type=Path, + default=None, + help="RGBA coin sprite path (default: procedural coin).", + ) + group.add_argument( + "--live-edit-coin-max-visible", + type=int, + default=64, + help=( + "Composite at most this many coins per frame (nearest win; the " + "farthest, distance-faded ones drop first; 0 disables the cap)." + ), + ) + group.add_argument( + "--live-edit-items", + action=argparse.BooleanOptionalAction, + default=False, + help=( + "Enable sparse effect-pickup items along the route (rain/snow " + "icons trigger weather, mystery boxes a random timed skin burst)." + ), + ) + group.add_argument( + "--live-edit-item-spacing", + type=float, + default=200.0, + help="Arc-length spacing between effect items per lane, metres.", + ) + group.add_argument( + "--live-edit-item-rain-sprite", + type=Path, + default=None, + help="RGBA rain-item sprite path (default: procedural placeholder).", + ) + group.add_argument( + "--live-edit-item-snow-sprite", + type=Path, + default=None, + help="RGBA snow-item sprite path (default: procedural placeholder).", + ) + group.add_argument( + "--live-edit-item-mystery-sprite", + type=Path, + default=None, + help="RGBA mystery-box sprite path (default: procedural '?' box).", + ) + group.add_argument( + "--live-edit-item-nitro-sprite", + type=Path, + default=None, + help="RGBA nitro-item sprite path (default: procedural placeholder).", + ) + group.add_argument( + "--live-edit-item-types", + type=str, + default=None, + help=( + "Comma-separated item kinds in the course mix (default: all of " + f"{','.join(ITEM_TYPES)}; e.g. 'nitro' lays a single-effect " + "course for scripted captures)." + ), + ) + group.add_argument( + "--live-edit-nitro-boost", + type=float, + default=1.6, + help=( + "Nitro multiplier on max speed AND max acceleration while a " + "nitro pickup is active (>= 1)." + ), + ) + group.add_argument( + "--live-edit-nitro-duration-s", + type=float, + default=4.0, + help=( + "Nitro boost duration in game seconds (a second pickup while " + "boosted resets the timer; no stacking)." + ), + ) + group.add_argument( + "--live-edit-nitro-max-speed", + type=float, + default=16.0, + help=( + "Ceiling on the boosted max speed, m/s (keeps the ego inside " + "the world model's manifold; ~16 validated on the suburb map)." + ), + ) + group.add_argument( + "--live-edit-item-mystery-burst-chunks", + type=int, + default=11, + help=( + "Timed-skin duration a mystery box grants (overrides the global " + "skin duration per activation; 0 = untimed)." + ), + ) + group.add_argument( + "--live-edit-item-mystery-seed", + type=int, + default=None, + help="Seed for the mystery-box skin roll (reproducible captures).", + ) + group.add_argument( + "--live-edit-perf-log", + type=int, + default=int(os.environ.get("LIVE_EDIT_PERF_LOG", "0")), + help=( + "Log p50/p95 of the live-edit per-frame costs (coin-update CPU " + "ms, compositor enqueue CPU ms, compositor GPU ms) every N " + "composited frames (0 disables; env default: LIVE_EDIT_PERF_LOG)." + ), + ) + + +def live_edit_config_from_args(args: argparse.Namespace) -> LiveEditConfig: + """Build live-edit settings, honoring a previously loaded game YAML.""" + base = getattr(args, "_live_edit_settings", None) + if base is not None: + return apply_live_edit_cli(base, args, explicit_only=True) + return LiveEditConfig( + style=LiveEditStyleConfig( + enabled=bool(args.live_edit_style), + lora_checkpoint=args.live_edit_style_lora, + corrector_checkpoint=args.live_edit_style_corrector, + corrector_gain=float(args.live_edit_style_gain), + corrector_mode=str(args.live_edit_corrector_mode), + base_corrector_checkpoint=args.live_edit_base_corrector, + base_corrector_gain=float(args.live_edit_base_corrector_gain), + gate_alpha_json=args.live_edit_gate_alpha_json, + guidance_chunks=int(args.live_edit_skin_guidance_chunks), + reswap_interval_chunks=int(args.live_edit_style_reswap_chunks), + skin_duration_chunks=int(args.live_edit_skin_duration_chunks), + skins=skins_starting_with(args.live_edit_skin_first), + ), + coins=LiveEditCoinsConfig( + enabled=bool(args.live_edit_coins), + sprite_path=args.live_edit_coin_sprite, + max_visible_sprites=int(args.live_edit_coin_max_visible), + ), + items=LiveEditItemsConfig( + enabled=bool(args.live_edit_items), + spacing_m=float(args.live_edit_item_spacing), + rain_sprite_path=args.live_edit_item_rain_sprite, + snow_sprite_path=args.live_edit_item_snow_sprite, + mystery_sprite_path=args.live_edit_item_mystery_sprite, + nitro_sprite_path=args.live_edit_item_nitro_sprite, + item_types=( + ITEM_TYPES + if args.live_edit_item_types is None + else tuple( + name.strip() + for name in str(args.live_edit_item_types).split(",") + if name.strip() + ) + ), + nitro_boost=float(args.live_edit_nitro_boost), + nitro_duration_s=float(args.live_edit_nitro_duration_s), + nitro_max_speed_mps=float(args.live_edit_nitro_max_speed), + mystery_burst_chunks=int(args.live_edit_item_mystery_burst_chunks), + mystery_seed=( + None + if args.live_edit_item_mystery_seed is None + else int(args.live_edit_item_mystery_seed) + ), + ), + weather=LiveEditWeatherConfig( + enabled=bool(args.live_edit_weather), + guidance_scale=float(args.live_edit_weather_guidance), + guidance_chunks=int(args.live_edit_weather_guidance_chunks), + maintain_interval_chunks=int(args.live_edit_weather_maintain_interval), + maintain_chunks=int(args.live_edit_weather_maintain_chunks), + duration_chunks=int(args.live_edit_weather_duration_chunks), + clear_guidance_chunks=int(args.live_edit_weather_clear_guidance_chunks), + corrector_gain=float(args.live_edit_weather_corrector_gain), + corrector_checkpoint=args.live_edit_weather_corrector, + weathers=weathers_starting_with(args.live_edit_weather_first), + ), + perf_log_every_frames=int(args.live_edit_perf_log), + obstacle=LiveEditObstacleConfig( + enabled=bool(args.live_edit_obstacle), + physics=bool(args.live_edit_obstacle_physics), + placement=cast( + Literal["ego-relative", "road-ahead"], + args.live_edit_obstacle_placement, + ), + count=int(args.live_edit_obstacle_count), + stagger_chunks=int(args.live_edit_obstacle_stagger_chunks), + spawn_ahead_m=float(args.live_edit_obstacle_ahead_m), + active_chunks=int(args.live_edit_obstacle_chunks), + static_count=int(args.live_edit_obstacle_static_count), + static_ahead_m=float(args.live_edit_obstacle_static_ahead_m), + static_lateral_m=float(args.live_edit_obstacle_static_lateral_m), + guide_scale=float(args.live_edit_obstacle_guide_scale), + annotate=bool(args.live_edit_obstacle_annotate), + ), + ) + + +def apply_live_edit_cli( + base: LiveEditConfig, + args: argparse.Namespace, + *, + explicit_only: bool, +) -> LiveEditConfig: + """Apply live-edit CLI values to ``base``. + + Args: + base: Lower-precedence live-edit settings. + args: Parsed Crazy Robotaxi arguments. + explicit_only: Whether to ignore parser defaults not supplied by the user. + + Returns: + Live-edit settings with selected CLI values applied. + """ + + def selected(name: str) -> bool: + return not explicit_only or arg_was_explicit(args, name) + + def updates( + mapping: dict[str, tuple[str, Callable[[Any], object]]], + ) -> dict[str, object]: + return { + field_name: transform(getattr(args, arg_name)) + for arg_name, (field_name, transform) in mapping.items() + if selected(arg_name) + } + + style = replace( + base.style, + **updates( + { + "live_edit_style": ("enabled", bool), + "live_edit_style_lora": ("lora_checkpoint", lambda value: value), + "live_edit_style_corrector": ( + "corrector_checkpoint", + lambda value: value, + ), + "live_edit_style_gain": ("corrector_gain", float), + "live_edit_corrector_mode": ("corrector_mode", str), + "live_edit_skin_guidance_chunks": ("guidance_chunks", int), + "live_edit_base_corrector": ( + "base_corrector_checkpoint", + lambda value: value, + ), + "live_edit_base_corrector_gain": ("base_corrector_gain", float), + "live_edit_gate_alpha_json": ("gate_alpha_json", lambda value: value), + "live_edit_style_reswap_chunks": ("reswap_interval_chunks", int), + "live_edit_skin_duration_chunks": ("skin_duration_chunks", int), + } + ), + ) + if selected("live_edit_skin_first") and args.live_edit_skin_first is not None: + style = replace( + style, + skins=_rotate_named(style.skins, args.live_edit_skin_first, "skin"), + ) + weather = replace( + base.weather, + **updates( + { + "live_edit_weather": ("enabled", bool), + "live_edit_weather_guidance": ("guidance_scale", float), + "live_edit_weather_guidance_chunks": ("guidance_chunks", int), + "live_edit_weather_maintain_interval": ( + "maintain_interval_chunks", + int, + ), + "live_edit_weather_maintain_chunks": ("maintain_chunks", int), + "live_edit_weather_duration_chunks": ("duration_chunks", int), + "live_edit_weather_clear_guidance_chunks": ( + "clear_guidance_chunks", + int, + ), + "live_edit_weather_corrector_gain": ("corrector_gain", float), + "live_edit_weather_corrector": ( + "corrector_checkpoint", + lambda value: value, + ), + } + ), + ) + if selected("live_edit_weather_first") and args.live_edit_weather_first is not None: + weather = replace( + weather, + weathers=_rotate_named( + weather.weathers, args.live_edit_weather_first, "weather" + ), + ) + obstacle = replace( + base.obstacle, + **updates( + { + "live_edit_obstacle": ("enabled", bool), + "live_edit_obstacle_physics": ("physics", bool), + "live_edit_obstacle_placement": ( + "placement", + lambda value: cast(Literal["ego-relative", "road-ahead"], value), + ), + "live_edit_obstacle_count": ("count", int), + "live_edit_obstacle_stagger_chunks": ("stagger_chunks", int), + "live_edit_obstacle_ahead_m": ("spawn_ahead_m", float), + "live_edit_obstacle_chunks": ("active_chunks", int), + "live_edit_obstacle_static_count": ("static_count", int), + "live_edit_obstacle_static_ahead_m": ("static_ahead_m", float), + "live_edit_obstacle_static_lateral_m": ("static_lateral_m", float), + "live_edit_obstacle_guide_scale": ("guide_scale", float), + "live_edit_obstacle_annotate": ("annotate", bool), + } + ), + ) + coins = replace( + base.coins, + **updates( + { + "live_edit_coins": ("enabled", bool), + "live_edit_coin_sprite": ("sprite_path", lambda value: value), + "live_edit_coin_max_visible": ("max_visible_sprites", int), + } + ), + ) + + def item_types(value: object) -> tuple[str, ...]: + if value is None: + return ITEM_TYPES + return tuple(name.strip() for name in str(value).split(",") if name.strip()) + + items = replace( + base.items, + **updates( + { + "live_edit_items": ("enabled", bool), + "live_edit_item_spacing": ("spacing_m", float), + "live_edit_item_rain_sprite": ("rain_sprite_path", lambda value: value), + "live_edit_item_snow_sprite": ("snow_sprite_path", lambda value: value), + "live_edit_item_mystery_sprite": ( + "mystery_sprite_path", + lambda value: value, + ), + "live_edit_item_nitro_sprite": ( + "nitro_sprite_path", + lambda value: value, + ), + "live_edit_item_types": ("item_types", item_types), + "live_edit_nitro_boost": ("nitro_boost", float), + "live_edit_nitro_duration_s": ("nitro_duration_s", float), + "live_edit_nitro_max_speed": ("nitro_max_speed_mps", float), + "live_edit_item_mystery_burst_chunks": ( + "mystery_burst_chunks", + int, + ), + "live_edit_item_mystery_seed": ( + "mystery_seed", + lambda value: None if value is None else int(value), + ), + } + ), + ) + config = replace( + base, + style=style, + weather=weather, + obstacle=obstacle, + coins=coins, + items=items, + ) + if selected("live_edit_perf_log"): + config = replace(config, perf_log_every_frames=int(args.live_edit_perf_log)) + return config + + +def _rotate_named(values: tuple[object, ...], name: str, kind: str) -> tuple: + names = [getattr(value, "name") for value in values] + if name not in names: + raise ValueError(f"unknown {kind} {name!r}; choose from {names}") + index = names.index(name) + return values[index:] + values[:index] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py new file mode 100644 index 000000000..85ca35a62 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py @@ -0,0 +1,511 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Torch compositor for live-edit pixels on device-resident model frames. + +The native window path hands ``PresentedFrame.model_rgb_host_uint8`` to the +Vulkan HUD as a CUDA uint8 HWC tensor (``LazyCudaFrame``); materializing it +to host numpy for PIL compositing forces a GPU->CPU->CPU-composite->GPU +round trip per frame (~10 fps observed). This module keeps the frame on +device: sprites, contact shadows, and HUD chips are pre-rendered once (PIL, +host) and uploaded as cached tensors; the per-frame work is a handful of +small alpha-blended ROI writes plus an optional separable-Gaussian unsharp +mask, all plain torch ops on the frame's device. + +Every operation is device-agnostic (CPU tensors run the identical code), +so the compositing math is unit-testable without a GPU. Visual parity with +the PIL path is approximate by design: sprites scale with bilinear +interpolation instead of Lanczos, and the contact shadow is one canonical +blurred ellipse rescaled per coin instead of a per-coin Gaussian blur. +""" + +from __future__ import annotations + +import math +import os +from collections import OrderedDict +from collections.abc import Sequence +from typing import TYPE_CHECKING, Protocol + +import numpy as np +import torch +import torch.nn.functional as F +from PIL import Image, ImageDraw, ImageFilter +from torch import Tensor + +if TYPE_CHECKING: + from crazy_robotaxi.live_edit.coin_ability import CoinSprite + +_CHIP_CACHE_MAX = 64 +"""Cached chip textures (labels change only on pickups/state switches).""" + +_SPRITE_REF_PX = 96 +"""Canonical sprite edge used for the pre-uploaded coin/shadow textures.""" + +_SCALED_CACHE_MAX = 1024 +"""Cached per-size merged coin textures (a few KB each).""" + +_FADE_STEPS = 16 +"""Distance-fade quantization for the texture cache. Baking the fade into +the cached texture makes every coin a single full-opacity blend (the hot +path is CPU-launch-bound, so per-coin torch-op count dominates); 1/16 alpha +steps are imperceptible on the 20 m fade ramp.""" + +_COUNTER_MARGIN_PX = 12 +_COUNTER_TEXT_RGBA = (255, 255, 255, 255) +_COUNTER_CHIP_RGBA = (30, 30, 30, 180) +_SHADOW_MAX_ALPHA = 60 +_SHADOW_WIDTH_FRACTION = 0.9 +_SHADOW_HEIGHT_FRACTION = 0.22 +_SHADOW_DROP_FRACTION = 0.62 + + +def _scaled_sprite_size( + original_wh: tuple[int, int], target_height: float, squash: float +) -> tuple[int, int]: + """Scale a sprite to its projected height and horizontal spin squash.""" + original_width, original_height = original_wh + height = max(1, round(target_height)) + width = max(1, round(height * original_width / original_height * squash)) + return width, height + + +def _rgba_to_tensors(image: Image.Image, device: torch.device) -> tuple[Tensor, Tensor]: + """Split an RGBA image into ``([3,H,W] rgb 0..255, [1,H,W] alpha 0..1)``.""" + array = np.asarray(image.convert("RGBA"), dtype=np.float32) + rgba = torch.from_numpy(array).to(device).permute(2, 0, 1) + return rgba[:3], rgba[3:] / 255.0 + + +def _gaussian_kernel1d(sigma: float, device: torch.device) -> Tensor: + """Normalized 1D Gaussian, radius 3 sigma (PIL GaussianBlur parity-ish).""" + radius = max(1, math.ceil(3.0 * sigma)) + x = torch.arange(-radius, radius + 1, dtype=torch.float32, device=device) + kernel = torch.exp(-(x * x) / (2.0 * sigma * sigma)) + return kernel / kernel.sum() + + +def alpha_blend_( + canvas_hwc_uint8: Tensor, + rgb: Tensor | None, + alpha: Tensor, + left: int, + top: int, +) -> None: + """Alpha-composite one texture into the canvas, clipped at the edges. + + Args: + canvas_hwc_uint8: ``[H,W,3]`` uint8 frame, written in place. + rgb: ``[3,h,w]`` float source colors in 0..255; ``None`` blends + black (shadow). + alpha: ``[1,h,w]`` float coverage in 0..1. + left, top: Destination of the texture's top-left corner; may lie + (partly) off the canvas. + """ + height, width = canvas_hwc_uint8.shape[:2] + src_h, src_w = alpha.shape[-2:] + x0, y0 = max(0, left), max(0, top) + x1, y1 = min(width, left + src_w), min(height, top + src_h) + if x0 >= x1 or y0 >= y1: + return + sx, sy = x0 - left, y0 - top + a = alpha[:, sy : sy + (y1 - y0), sx : sx + (x1 - x0)].permute(1, 2, 0) + roi = canvas_hwc_uint8[y0:y1, x0:x1].to(torch.float32) + if rgb is None: + out = roi * (1.0 - a) + else: + c = rgb[:, sy : sy + (y1 - y0), sx : sx + (x1 - x0)].permute(1, 2, 0) + out = roi * (1.0 - a) + c * a + canvas_hwc_uint8[y0:y1, x0:x1] = out.round_().clamp_(0.0, 255.0).to(torch.uint8) + + +def _blend_float_( + canvas_hwc_f32: Tensor, + premultiplied_rgb_hwc: Tensor | None, + one_minus_alpha_hw1: Tensor, + left: int, + top: int, + fade: float = 1.0, +) -> None: + """In-place premultiplied blend on a float32 HWC canvas (hot path). + + Same clipping semantics as :func:`alpha_blend_`, but the canvas stays + float across all blends of a frame (one uint8 round-trip per frame + instead of one per blend) and the textures are pre-baked so each blend + at full opacity is ``roi = roi * (1 - a) [+ rgb * a]`` — one or two + small kernels. ``premultiplied_rgb_hwc=None`` darkens toward black + (contact shadow). + """ + height, width = canvas_hwc_f32.shape[:2] + src_h, src_w = one_minus_alpha_hw1.shape[:2] + x0, y0 = max(0, left), max(0, top) + x1, y1 = min(width, left + src_w), min(height, top + src_h) + if x0 >= x1 or y0 >= y1: + return + sx, sy = x0 - left, y0 - top + om = one_minus_alpha_hw1[sy : sy + (y1 - y0), sx : sx + (x1 - x0)] + roi = canvas_hwc_f32[y0:y1, x0:x1] + # With fade f the factor on the canvas is 1 - f*(1-om) = (1-f) + f*om. + roi.mul_(om if fade >= 1.0 else (1.0 - fade) + fade * om) + if premultiplied_rgb_hwc is not None: + c = premultiplied_rgb_hwc[sy : sy + (y1 - y0), sx : sx + (x1 - x0)] + roi.add_(c if fade >= 1.0 else fade * c) + + +def _blend_uint8_( + canvas_hwc_uint8: Tensor, + premultiplied_rgb_hwc: Tensor | None, + one_minus_alpha_hw1: Tensor, + left: int, + top: int, + fade: float = 1.0, +) -> None: + """ROI-local premultiplied blend directly on a uint8 HWC canvas. + + Same math and clipping as :func:`_blend_float_`, but only the sprite's + ROI is converted to float and back — the full frame never leaves uint8, + so per-frame GPU memory traffic scales with on-screen sprite area + instead of the ~5-frame-sized traffic of the float32 canvas round trip + (selected via ``LIVE_EDIT_COMPOSITOR=roi``). Values can differ from the + float path by at most 1 LSB where blends overlap (each ROI blend rounds + independently). + """ + height, width = canvas_hwc_uint8.shape[:2] + src_h, src_w = one_minus_alpha_hw1.shape[:2] + x0, y0 = max(0, left), max(0, top) + x1, y1 = min(width, left + src_w), min(height, top + src_h) + if x0 >= x1 or y0 >= y1: + return + sx, sy = x0 - left, y0 - top + om = one_minus_alpha_hw1[sy : sy + (y1 - y0), sx : sx + (x1 - x0)] + if fade < 1.0: + om = (1.0 - fade) + fade * om + roi = canvas_hwc_uint8[y0:y1, x0:x1] + out = roi * om + if premultiplied_rgb_hwc is not None: + c = premultiplied_rgb_hwc[sy : sy + (y1 - y0), sx : sx + (x1 - x0)] + out += c if fade >= 1.0 else fade * c + roi.copy_(out.round_().clamp_(0.0, 255.0)) + + +class _BlendFn(Protocol): + """Signature shared by :func:`_blend_float_` and :func:`_blend_uint8_`.""" + + def __call__( + self, + canvas_hwc: Tensor, + premultiplied_rgb_hwc: Tensor | None, + one_minus_alpha_hw1: Tensor, + left: int, + top: int, + fade: float = 1.0, + ) -> None: ... + + +class LiveEditFrameCompositor: + """Pre-uploaded textures + per-frame ROI blends for one coin sprite. + + Mirrors the PIL path in :mod:`crazy_robotaxi.live_edit.presenter` + (:func:`~.presenter.unsharp_rgb`, coin/shadow compositing, HUD chips) + with torch ops on the frame's device. One instance per presenter; + texture caches are keyed by device so CPU tests and CUDA serving share + the code. + """ + + def __init__( + self, + coin_sprite: Image.Image, + sprite_bank: dict[str, Image.Image] | None = None, + ) -> None: + self._coin_sprite_image = coin_sprite.convert("RGBA") + # Sprite bank: "coin" plus any effect-item sprites, selected per + # sprite by CoinSprite.sprite_key. Items reuse the whole texture + # pipeline (shadow, fade quantization, per-size cache). + self._sprite_images: dict[str, Image.Image] = { + "coin": self._coin_sprite_image, + **{ + key: image.convert("RGBA") for key, image in (sprite_bank or {}).items() + }, + } + # A/B switch for remote perf triage (LIVE_EDIT_COMPOSITOR=roi): + # "roi" keeps the frame uint8 and blends each sprite slice in place + # (minimal GPU memory traffic, ~5 tiny kernels per coin); "float" + # (default) uses one full-frame float32 canvas with 2 kernels per + # coin. Both paths are launch-count-bound in every measurement on + # GB300 (GPU execution fully hidden), which makes "float" ~2x faster + # wall-clock there; "roi" exists for machines where the compositor's + # full-frame traffic on the inference stream is the actual cost. + # Pair with --live-edit-perf-log to compare. + self._roi_blends = os.environ.get("LIVE_EDIT_COMPOSITOR", "float") == "roi" + self._sprite_cache: dict[tuple[str, torch.device], tuple[Tensor, Tensor]] = {} + self._shadow_cache: dict[torch.device, Tensor] = {} + self._chip_cache: OrderedDict[tuple[str, torch.device], tuple[Tensor, Tensor]] + self._chip_cache = OrderedDict() + self._kernel_cache: dict[tuple[float, torch.device], Tensor] = {} + # Merged per-size coin textures (contact shadow + coin + quantized + # distance fade pre-composited): coin sizes quantize to a few dozen + # (height from distance, width from the 36-frame squash cycle), so + # the per-frame hot path is one dictionary lookup and ONE blend per + # coin — no per-frame F.interpolate, no separate shadow pass. + self._coin_texture_cache: OrderedDict[ + tuple[str, torch.device, int, int, int], tuple[Tensor, Tensor, int] + ] = OrderedDict() + + ## Texture caches + + def sprite_image(self, key: str) -> Image.Image: + """The bank sprite for one key (unknown keys fall back to the coin).""" + return self._sprite_images.get(key, self._coin_sprite_image) + + def _sprite(self, key: str, device: torch.device) -> tuple[Tensor, Tensor]: + cached = self._sprite_cache.get((key, device)) + if cached is None: + cached = _rgba_to_tensors(self.sprite_image(key), device) + self._sprite_cache[(key, device)] = cached + return cached + + def _shadow(self, device: torch.device) -> Tensor: + """Canonical blurred contact-shadow alpha at max strength. + + Rendered once with the exact PIL routine of the host path at the + reference sprite size; per-coin scaling stretches it, which also + scales the blur falloff proportionally. + """ + cached = self._shadow_cache.get(device) + if cached is None: + shadow_w = max(2, round(_SPRITE_REF_PX * _SHADOW_WIDTH_FRACTION)) + shadow_h = max(1, round(_SPRITE_REF_PX * _SHADOW_HEIGHT_FRACTION)) + blur = max(1, shadow_h // 3) + pad = 3 * blur + 2 + image = Image.new( + "RGBA", (shadow_w + 2 * pad, shadow_h + 2 * pad), (0, 0, 0, 0) + ) + draw = ImageDraw.Draw(image) + draw.ellipse( + [pad, pad, shadow_w + pad, shadow_h + pad], + fill=(0, 0, 0, _SHADOW_MAX_ALPHA), + ) + image = image.filter(ImageFilter.GaussianBlur(radius=blur)) + _, alpha = _rgba_to_tensors(image, device) + cached = alpha.unsqueeze(0) # [1,1,H,W] for interpolate + self._shadow_cache[device] = cached + return cached + + def _chip(self, label: str, device: torch.device) -> tuple[Tensor, Tensor]: + """Chip texture ``([h,w,3] rgb*a, [h,w,1] 1-a)``, rendered per label.""" + key = (label, device) + cached = self._chip_cache.get(key) + if cached is not None: + self._chip_cache.move_to_end(key) + return cached + probe = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + text_box = probe.textbbox((10, 6), label) + image = Image.new("RGBA", (text_box[2] + 10 + 1, text_box[3] + 6 + 1), (0,) * 4) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle( + [0, 0, text_box[2] + 10, text_box[3] + 6], + radius=6, + fill=_COUNTER_CHIP_RGBA, + ) + draw.text((10, 6), label, fill=_COUNTER_TEXT_RGBA) + rgb, alpha = _rgba_to_tensors(image, device) + rgb, alpha = rgb.permute(1, 2, 0), alpha.permute(1, 2, 0) + cached = ((rgb * alpha).contiguous(), (1.0 - alpha).contiguous()) + self._chip_cache[key] = cached + while len(self._chip_cache) > _CHIP_CACHE_MAX: + self._chip_cache.popitem(last=False) + return cached + + def _coin_texture( + self, + key: str, + device: torch.device, + width: int, + height: int, + alpha_q: int, + ) -> tuple[Tensor, Tensor, int]: + """Merged coin+shadow texture at one size and quantized fade. + + The contact shadow, the coin sprite (composited over the shadow with + premultiplied "over", exactly associativity-equivalent to the old + two-pass blend), and the ``alpha_q / _FADE_STEPS`` distance fade are + all baked in, so the per-frame cost per coin is one blend. + + Returns: + ``([h,w,3] premultiplied rgb, [h,w,1] 1-alpha, coin_left)`` + where ``coin_left`` is the coin rect's x offset inside the + texture (the texture is anchored at the coin's top edge). + """ + cache_key = (key, device, width, height, alpha_q) + cached = self._coin_texture_cache.get(cache_key) + if cached is not None: + self._coin_texture_cache.move_to_end(cache_key) + return cached + sprite_rgb, sprite_alpha = self._sprite(key, device) + scaled = F.interpolate( + torch.cat([sprite_rgb, sprite_alpha], dim=0).unsqueeze(0), + size=(height, width), + mode="bilinear", + align_corners=False, + )[0] + coin_rgb, coin_a = scaled[:3], scaled[3:] + shadow_ref = self._shadow(device) + shadow_w = max(2, round(width * shadow_ref.shape[-1] / _SPRITE_REF_PX)) + shadow_h = max(2, round(height * shadow_ref.shape[-2] / _SPRITE_REF_PX)) + shadow_a = F.interpolate( + shadow_ref, size=(shadow_h, shadow_w), mode="bilinear", align_corners=False + )[0] + tex_w = max(width, shadow_w) + coin_x = (tex_w - width) // 2 + shadow_x = (tex_w - shadow_w) // 2 + shadow_y = round(height * (0.5 + _SHADOW_DROP_FRACTION)) + tex_h = max(height, shadow_y + shadow_h) + alpha = torch.zeros((1, tex_h, tex_w), device=device) + rgb = torch.zeros((3, tex_h, tex_w), device=device) + alpha[:, shadow_y : shadow_y + shadow_h, shadow_x : shadow_x + shadow_w] = ( + shadow_a + ) + coin_region = alpha[:, :height, coin_x : coin_x + width] + coin_region.copy_(coin_a + coin_region * (1.0 - coin_a)) + rgb[:, :height, coin_x : coin_x + width] = coin_rgb * coin_a + fade = alpha_q / _FADE_STEPS + cached = ( + (rgb * fade).permute(1, 2, 0).contiguous(), + (1.0 - alpha * fade).permute(1, 2, 0).contiguous(), + coin_x, + ) + self._coin_texture_cache[cache_key] = cached + while len(self._coin_texture_cache) > _SCALED_CACHE_MAX: + self._coin_texture_cache.popitem(last=False) + return cached + + ## Frame operations + + def composite( + self, + frame_hwc_uint8: Tensor, + *, + sprites: Sequence[CoinSprite] = (), + frame_index: int = 0, + labels: Sequence[str] = (), + sharpen_sigma: float = 0.0, + sharpen_amount: float = 0.0, + ) -> Tensor: + """All live-edit pixels in one pass; returns a new uint8 frame. + + Every coin is exactly one blend of a merged (shadow+coin+fade) + cached texture. The default canvas is float32 full-frame (convert + once, fused in-place lerps, single round/clamp/cast at the end); + ``LIVE_EDIT_COMPOSITOR=roi`` keeps the frame uint8 end to end and + blends only the sprite ROIs (:func:`_blend_uint8_`) so per-frame + memory traffic scales with sprite area instead of frame size. The + unsharp mask always forces the float path (it filters the whole + frame anyway). + """ + if sharpen_amount <= 0.0 and self._roi_blends: + canvas = frame_hwc_uint8.clone() + self._blend_coins(canvas, sprites, frame_index, _blend_uint8_) + self._blend_chips(canvas, labels, _blend_uint8_) + return canvas + canvas = frame_hwc_uint8.to(torch.float32) + if sharpen_amount > 0.0: + canvas = self._unsharp_float( + canvas, sigma=sharpen_sigma, amount=sharpen_amount + ) + self._blend_coins(canvas, sprites, frame_index, _blend_float_) + self._blend_chips(canvas, labels, _blend_float_) + return canvas.round_().clamp_(0.0, 255.0).to(torch.uint8) + + def unsharp( + self, frame_hwc_uint8: Tensor, *, sigma: float, amount: float + ) -> Tensor: + """Separable-Gaussian unsharp mask (torch port of ``unsharp_rgb``).""" + if amount <= 0.0: + return frame_hwc_uint8 + sharpened = self._unsharp_float( + frame_hwc_uint8.to(torch.float32), sigma=sigma, amount=amount + ) + return sharpened.clamp_(0.0, 255.0).round_().to(torch.uint8) + + def _unsharp_float( + self, canvas_hwc_f32: Tensor, *, sigma: float, amount: float + ) -> Tensor: + device = canvas_hwc_f32.device + key = (float(sigma), device) + kernel = self._kernel_cache.get(key) + if kernel is None: + kernel = _gaussian_kernel1d(sigma, device) + self._kernel_cache[key] = kernel + radius = (kernel.numel() - 1) // 2 + image = canvas_hwc_f32.permute(2, 0, 1).unsqueeze(0) + padded = F.pad(image, (radius, radius, 0, 0), mode="replicate") + blurred = F.conv2d( + padded, kernel.view(1, 1, 1, -1).expand(3, 1, 1, -1), groups=3 + ) + padded = F.pad(blurred, (0, 0, radius, radius), mode="replicate") + blurred = F.conv2d( + padded, kernel.view(1, 1, -1, 1).expand(3, 1, -1, 1), groups=3 + ) + sharpened = (1.0 + amount) * image - amount * blurred + return sharpened.squeeze(0).permute(1, 2, 0).contiguous() + + def composite_coins( + self, + frame_hwc_uint8: Tensor, + sprites: Sequence[CoinSprite], + frame_index: int, + ) -> None: + """Blend the projected coin sprites in place (uint8 convenience).""" + frame_hwc_uint8.copy_( + self.composite(frame_hwc_uint8, sprites=sprites, frame_index=frame_index) + ) + + def draw_chips(self, frame_hwc_uint8: Tensor, labels: Sequence[str]) -> None: + """Blend the stacked HUD chips in place (uint8 convenience).""" + frame_hwc_uint8.copy_(self.composite(frame_hwc_uint8, labels=labels)) + + def _blend_coins( + self, + canvas_hwc: Tensor, + sprites: Sequence[CoinSprite], + frame_index: int, + blend: _BlendFn, + ) -> None: + """Blend sprites far-to-near (input order) onto the canvas.""" + if not sprites: + return + from crazy_robotaxi.live_edit.coin_ability import coin_squash + + device = canvas_hwc.device + for sprite in sprites: + alpha_q = min(_FADE_STEPS, round(sprite.alpha * _FADE_STEPS)) + if alpha_q <= 0: + continue + key = getattr(sprite, "sprite_key", "coin") + spin = getattr(sprite, "spin", True) + squash = coin_squash(sprite.spin_phase, frame_index) if spin else 1.0 + sprite_w, sprite_h = _scaled_sprite_size( + self.sprite_image(key).size, sprite.height_px, squash + ) + premultiplied, one_minus, coin_x = self._coin_texture( + key, device, sprite_w, sprite_h, alpha_q + ) + blend( + canvas_hwc, + premultiplied, + one_minus, + round(sprite.center_uv[0] - sprite_w / 2.0) - coin_x, + round(sprite.center_uv[1] - sprite_h / 2.0), + ) + + def _blend_chips( + self, canvas_hwc: Tensor, labels: Sequence[str], blend: _BlendFn + ) -> None: + if not labels: + return + device = canvas_hwc.device + y0 = _COUNTER_MARGIN_PX + for label in labels: + premultiplied, one_minus = self._chip(label, device) + blend(canvas_hwc, premultiplied, one_minus, _COUNTER_MARGIN_PX, y0) + y0 += one_minus.shape[0] - 1 + 8 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/input_hooks.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/input_hooks.py new file mode 100644 index 000000000..ccc41c0a5 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/input_hooks.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Rising-edge key requests for the live-edit abilities. + +Mirrors the one-slot request/consume channels on +:class:`crazy_robotaxi.input.CrazyRobotaxiKeyboardState` +(``submit_taxi_name`` / ``consume_taxi_name_submission``). Kept as a +separate object so the presenter key handlers and the runtime drain can +share it without subclassing the keyboard state; composition-root wiring: + +- native window: add ``k``/``c`` keysyms to ``_build_key_codes`` and call + ``requests.request_skin_cycle()`` / ``request_coins_toggle()`` from the + discrete tail of ``SlangPyHudPresenter._on_keyboard_event``; +- MJPEG: same calls from ``MJPEGStreamingPresenter._apply_control`` plus the + browser JS key allowlist; +- drain: ``CrazyRobotaxiRuntime.process_events`` consumes both each tick. +""" + +from __future__ import annotations + +import threading + + +class LiveEditRequests: + """Thread-safe one-shot requests raised by input, drained by the runtime.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._skin_cycle_requested = False + self._coins_toggle_requested = False + self._weather_cycle_requested = False + self._obstacle_spawn_requested = False + + def request_skin_cycle(self) -> None: + """Record a switch-skin keypress until the runtime consumes it.""" + with self._lock: + self._skin_cycle_requested = True + + def consume_skin_cycle(self) -> bool: + """Return and clear whether a skin switch was requested.""" + with self._lock: + requested = self._skin_cycle_requested + self._skin_cycle_requested = False + return requested + + def request_coins_toggle(self) -> None: + """Record a coins-toggle keypress until the runtime consumes it.""" + with self._lock: + self._coins_toggle_requested = True + + def consume_coins_toggle(self) -> bool: + """Return and clear whether a coins toggle was requested.""" + with self._lock: + requested = self._coins_toggle_requested + self._coins_toggle_requested = False + return requested + + def request_weather_cycle(self) -> None: + """Record a cycle-weather keypress until the runtime consumes it.""" + with self._lock: + self._weather_cycle_requested = True + + def consume_weather_cycle(self) -> bool: + """Return and clear whether a weather cycle was requested.""" + with self._lock: + requested = self._weather_cycle_requested + self._weather_cycle_requested = False + return requested + + def request_obstacle_spawn(self) -> None: + """Record a spawn-obstacle keypress until the runtime consumes it.""" + with self._lock: + self._obstacle_spawn_requested = True + + def consume_obstacle_spawn(self) -> bool: + """Return and clear whether an obstacle spawn was requested.""" + with self._lock: + requested = self._obstacle_spawn_requested + self._obstacle_spawn_requested = False + return requested diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/item_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/item_ability.py new file mode 100644 index 000000000..5c6bc82ef --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/item_ability.py @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Effect-pickup items: sparse lane-course sprites that trigger abilities. + +Extends the coin course concept: one item every ``spacing_m`` (much rarer +than coins), each with a sprite and an effect. Layout, FTheta projection, +spatial-hash culling, proximity pickup, and GPU compositing all reuse the +coin machinery (:class:`~.coin_ability.CoinAbility` with per-item sprite +keys); the effect dispatch (:class:`ItemEffects`) routes each pickup +through the existing ability state machines AT THE NEXT CHUNK BOUNDARY — +the same path the K/V key requests take, so the keys keep working alongside +pickups and both trigger paths share one set of state-machine rules: + +- ``rain`` / ``snow`` items request that weather preset. Weather is + base-world-only, so a pickup during an active skin is IGNORED with a HUD + hint (chosen over queueing: a queued weather landing seconds later, with + no visible cause, reads as a glitch; the ignore matches the V-key + semantics exactly and keeps the state machine free of deferred intents). + Re-picking the active weather refreshes its timed-weather timer. +- ``mystery`` boxes grant a random timed skin burst (seeded RNG knob picks + which skin; ``mystery_burst_chunks`` overrides the global skin duration + per activation, so the box grants a burst even in hold-forever mode). A + burst during a key-held skin behaves like a K cycle: switch, fresh timer. +- ``nitro`` items are the exception to the boundary rule: the effect is a + timed speed boost inside the app-authoritative physics tick + (:class:`~.nitro_ability.NitroAbility`), physics-only with no world-model + state to swap, so it activates immediately — the next sampled physics + tick drives faster. It composes with every skin/weather/obstacle state + and never touches the StyleAbility state machine. A second nitro while + boosted resets the timer (no stacking). + +Every pickup raises a short HUD flash ("RAIN!", "? PIXEL BURST!", ...) +drawn by the live-edit presenter next to the ability chips. +""" + +from __future__ import annotations + +import math +import random +import time +from collections.abc import Callable, Iterable, Sequence + +import numpy as np +import numpy.typing as npt +from loguru import logger +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.types import VehicleState + +from crazy_robotaxi.live_edit.coin_ability import CoinAbility, CoinSprite +from crazy_robotaxi.live_edit.config import ( + ITEM_TYPES, + LiveEditCoinsConfig, + LiveEditItemsConfig, +) +from crazy_robotaxi.navigation import NavigationLane + +_CANDIDATE_STEP_CAP_M = 25.0 +"""Upper bound on the along-lane candidate step (matches the coin walk).""" + + +def build_item_course( + lanes: Sequence[NavigationLane], + config: LiveEditItemsConfig, +) -> tuple[npt.NDArray[np.float32], tuple[str, ...]]: + """Lay out effect items with GLOBAL ``spacing_m`` sparsity over the map. + + Rarity is a property of the whole lane network, not of each polyline: + real maps chop lanes into short segments (the shipped suburb map's + segments are mostly shorter than the item spacing), so a per-lane walk + at ``spacing_m`` intervals places almost nothing. Instead the walk + samples candidates every ~``spacing_m / 4`` (capped at the coin step) + and accepts one only when no already-accepted item WITH A SIMILAR + HEADING lies within ``spacing_m`` (spacing-sized spatial hash, 3x3 + neighborhood check). The heading test keeps the two directions of a + road independent — without it, whichever directed lane is walked first + claims every spacing-disc along the whole road and drivers of the + opposite lane never pass within pickup radius of an item. Deterministic + for a given lane order; item types cycle through the configured + ``item_types`` mix in acceptance order (equal rarity per kind). + + Returns: + ``(centers [items, 3] world, types [items])``. + + Raises: + ValueError: No lane yields a single item. + """ + step = min(config.spacing_m / 4.0, _CANDIDATE_STEP_CAP_M) + centers: list[npt.NDArray[np.float32]] = [] + headings: list[tuple[float, float]] = [] + cells: dict[tuple[int, int], list[int]] = {} + cell_m = config.spacing_m + for lane in lanes: + points = np.asarray(lane.centerline_world, dtype=np.float32) + if len(points) < 2: + continue + segment_lengths = np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1) + cumulative = np.concatenate(([0.0], np.cumsum(segment_lengths))) + total = float(cumulative[-1]) + distance = step / 2.0 + while distance < total: + index = int(np.searchsorted(cumulative, distance) - 1) + index = max(0, min(index, len(points) - 2)) + span = float(cumulative[index + 1] - cumulative[index]) + fraction = 0.0 if span <= 0.0 else (distance - cumulative[index]) / span + center = points[index] + fraction * (points[index + 1] - points[index]) + direction = points[index + 1, :2] - points[index, :2] + norm = float(np.linalg.norm(direction)) + if norm <= 1.0e-6: + distance += step + continue + heading = (float(direction[0]) / norm, float(direction[1]) / norm) + x, y = float(center[0]), float(center[1]) + cell_x, cell_y = math.floor(x / cell_m), math.floor(y / cell_m) + too_close = any( + math.hypot(x - float(centers[i][0]), y - float(centers[i][1])) + < config.spacing_m + and heading[0] * headings[i][0] + heading[1] * headings[i][1] > 0.5 + for nx in (cell_x - 1, cell_x, cell_x + 1) + for ny in (cell_y - 1, cell_y, cell_y + 1) + for i in cells.get((nx, ny), ()) + ) + if not too_close: + item = center.copy() + item[2] += np.float32(config.hover_height_m) + cells.setdefault((cell_x, cell_y), []).append(len(centers)) + centers.append(item) + headings.append(heading) + distance += step + if not centers: + raise ValueError("Item course requires at least one drivable lane sample.") + mix = config.item_types + types = tuple(mix[i % len(mix)] for i in range(len(centers))) + return np.stack(centers).astype(np.float32), types + + +class ItemAbility: + """Track item pickups, produce screen sprites, hold the HUD flash. + + Wraps a :class:`~.coin_ability.CoinAbility` configured from the item + knobs (per-item sprite keys, no spin) so projection/culling/pickup stay + one implementation. + """ + + def __init__( + self, + items_world: npt.NDArray[np.float32], + item_types: Sequence[str], + config: LiveEditItemsConfig, + *, + clock: Callable[[], float] = time.monotonic, + ) -> None: + if len(item_types) != len(items_world): + raise ValueError("item_types must match items_world length") + unknown = set(item_types) - set(ITEM_TYPES) + if unknown: + raise ValueError(f"unknown item types {sorted(unknown)}") + self._config = config + self._types = tuple(item_types) + self._clock = clock + self._flash: tuple[str, float] | None = None + self._course = CoinAbility( + items_world, + _course_config(config), + sprite_keys=self._types, + spin=False, + ) + + @classmethod + def from_lanes( + cls, + lanes: Sequence[NavigationLane], + config: LiveEditItemsConfig, + *, + clock: Callable[[], float] = time.monotonic, + ) -> ItemAbility: + """Build the ability with a course laid out along ``lanes``.""" + centers, types = build_item_course(lanes, config) + return cls(centers, types, config, clock=clock) + + @property + def enabled(self) -> bool: + """Whether items render and collect (rides the inner course flag).""" + return self._course.enabled + + @property + def remaining_count(self) -> int: + """Return the number of uncollected items.""" + return self._course.remaining_count + + @property + def collected_count(self) -> int: + """Return the number of items picked up so far.""" + return self._course.collected_count + + def advance_frames(self, vehicle_states: Iterable[VehicleState]) -> tuple[str, ...]: + """Collect items within pickup radius; return their types in order.""" + indices = self._course.collect_near(vehicle_states) + return tuple(self._types[i] for i in indices) + + def visible_sprites( + self, + rig_to_world: npt.NDArray[np.float32], + camera_model: FThetaCameraModel, + *, + image_width: int, + image_height: int, + ) -> tuple[CoinSprite, ...]: + """Project uncollected items into image pixels (far-to-near).""" + return self._course.visible_sprites( + rig_to_world, + camera_model, + image_width=image_width, + image_height=image_height, + ) + + def flash(self, label: str) -> None: + """Raise the pickup HUD flash for ``config.flash_seconds``.""" + self._flash = (label, self._clock() + self._config.flash_seconds) + + @property + def flash_label(self) -> str | None: + """The active HUD flash label, or ``None`` once it has expired.""" + if self._flash is None: + return None + label, deadline = self._flash + if self._clock() >= deadline: + self._flash = None + return None + return label + + +def _course_config(config: LiveEditItemsConfig) -> LiveEditCoinsConfig: + """Adapt the item knobs onto the coin-course machinery.""" + return LiveEditCoinsConfig( + enabled=True, + spacing_m=config.spacing_m, + group_offsets_m=(0.0,), + hover_height_m=config.hover_height_m, + coin_diameter_m=config.item_diameter_m, + pickup_radius_m=config.pickup_radius_m, + points_per_coin=0, + max_render_distance_m=config.max_render_distance_m, + fade_start_distance_m=config.fade_start_distance_m, + max_visible_sprites=16, + ) + + +class ItemEffects: + """Route item pickups into the ability state machines. + + One instance per rollout (the mystery RNG re-seeds with the game), built + by the runtime next to the abilities. ``apply`` never raises: an effect + that cannot land (skin blocking weather, abilities not attached) + degrades to a HUD hint so a pickup never crashes the frame loop. + """ + + def __init__( + self, + style_ability: object | None, + config: LiveEditItemsConfig, + *, + nitro_ability: object | None = None, + ) -> None: + self._style = style_ability + self._nitro = nitro_ability + self._config = config + self._rng = random.Random(config.mystery_seed) + + def apply(self, item_type: str) -> str: + """Trigger one pickup's effect; return the HUD flash label.""" + if item_type in ("rain", "snow"): + return self._apply_weather(item_type) + if item_type == "mystery": + return self._apply_mystery() + if item_type == "nitro": + return self._apply_nitro() + logger.warning(f"[live-edit] unknown item pickup {item_type!r}") + return f"{item_type.upper()}?" + + def _apply_nitro(self) -> str: + # Physics-only: activates immediately (next sampled physics tick), + # no chunk-boundary handshake and no StyleAbility coupling. + activate = getattr(self._nitro, "activate", None) + if activate is None: + return "NITRO N/A" + activate() + return "NITRO!" + + def _apply_weather(self, name: str) -> str: + style = self._style + request = getattr(style, "request_weather", None) + if request is None or not getattr(style, "weather_names", ()): + return f"{name.upper()} N/A" + if request(name): + return f"{name.upper()}!" + # Base-world-only rule: ignored (not queued) with a HUD hint, the + # same rejection the V key gets while a skin is active. + return f"{name.upper()} BLOCKED (SKIN ON)" + + def _apply_mystery(self) -> str: + style = self._style + names = getattr(style, "skin_names", ()) + request = getattr(style, "request_skin_burst", None) + if request is None or not names: + return "? NO SKINS" + rolled = self._rng.choice(list(names)) + granted = request(rolled, self._config.mystery_burst_chunks) + if granted is None: + return "? NO SKINS" + return f"? {granted.upper()} BURST!" diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/nitro_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/nitro_ability.py new file mode 100644 index 000000000..604a9da2b --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/nitro_ability.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Nitro pickup: a timed speed boost inside the taxi physics tick. + +Unlike the weather/skin items, nitro never touches the world-model state +machines — it is pure app-side physics. The seam is the per-frame +``integrate_fn`` the rollout passes to ``sample_chunk_trajectory`` +(:func:`crazy_robotaxi.driving.integrate_taxi_vehicle`): while the boost is +active, :func:`integrate_with_nitro` hands the integrator a vehicle config +with ``max_accel_mps2`` and ``max_speed_mps`` multiplied by +``nitro_boost``, the boosted max speed hard-capped at +``nitro_max_speed_mps`` so the ego stays inside the world model's manifold +(the conditioning renders the faster ego plausibly up to highway speeds; +~16 m/s is the validated comfort zone on the shipped suburb map). + +Activation is INSTANT: a pickup detected in chunk N boosts the very next +sampled physics tick (chunk N+1 at the pipeline's one-chunk pickup +latency) — no chunk-boundary state-machine handshake, because there is no +model-side state to swap. The timer runs on game time (the integrator's +accumulated ``dt_s``), and a second pickup while boosted RESETS it to the +full duration — no multiplicative stacking. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace + +from loguru import logger +from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.types import DriverCommand, VehicleState + +from crazy_robotaxi.live_edit.config import LiveEditItemsConfig + +IntegrateFn = Callable[ + [VehicleState, DriverCommand, float, VehicleConfig], VehicleState +] + +_TIMER_EPSILON_S = 1.0e-9 +"""Treat sub-nanosecond residue as expired (dt accumulation float drift).""" + + +class NitroAbility: + """Hold the nitro timer and produce the boosted vehicle config. + + One instance per application, reset per rollout (the same lifecycle as + the item course, so a rollout reset always starts unboosted). + """ + + def __init__(self, config: LiveEditItemsConfig) -> None: + self._config = config + self._remaining_s = 0.0 + + @property + def boost(self) -> float: + """The configured speed/acceleration multiplier.""" + return self._config.nitro_boost + + @property + def active(self) -> bool: + """Whether the boost currently applies to physics ticks.""" + return self._remaining_s > _TIMER_EPSILON_S + + @property + def seconds_remaining(self) -> float: + """Game seconds of boost left (0 when inactive; HUD countdown).""" + return self._remaining_s if self.active else 0.0 + + def activate(self) -> None: + """Start the boost; a re-pickup while boosted resets the timer.""" + self._remaining_s = self._config.nitro_duration_s + logger.info( + f"[live-edit] nitro boost ON x{self._config.nitro_boost:.2f} " + f"for {self._config.nitro_duration_s:.1f}s " + f"(max {self._config.nitro_max_speed_mps:.1f} m/s)" + ) + + def reset(self) -> None: + """Drop any active boost (rollout reset).""" + self._remaining_s = 0.0 + + def boosted_vehicle(self, vehicle: VehicleConfig) -> VehicleConfig: + """The vehicle config with the nitro multiplier and ceiling applied.""" + return replace( + vehicle, + max_accel_mps2=vehicle.max_accel_mps2 * self._config.nitro_boost, + max_speed_mps=min( + vehicle.max_speed_mps * self._config.nitro_boost, + self._config.nitro_max_speed_mps, + ), + ) + + def vehicle_for_tick(self, vehicle: VehicleConfig, dt_s: float) -> VehicleConfig: + """Consume one physics tick; return the config the tick should use.""" + if not self.active: + return vehicle + boosted = self.boosted_vehicle(vehicle) + self._remaining_s -= dt_s + if not self.active: + logger.info("[live-edit] nitro boost expired") + return boosted + + +def integrate_with_nitro(nitro: NitroAbility, integrate_fn: IntegrateFn) -> IntegrateFn: + """Wrap a taxi integrator so active nitro boosts each tick's vehicle.""" + + def integrate( + state: VehicleState, + command: DriverCommand, + dt_s: float, + vehicle: VehicleConfig, + ) -> VehicleState: + return integrate_fn(state, command, dt_s, nitro.vehicle_for_tick(vehicle, dt_s)) + + return integrate diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py new file mode 100644 index 000000000..7aa12e840 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py @@ -0,0 +1,315 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Track-backed obstacle events and optional model-side box-axis guidance. + +Gameplay state, placement, rendering, and optional physics live in +:mod:`crazy_robotaxi.live_edit.obstacle_events`. This module retains the +established public import path and the optional OmniDreams guidance hook. +""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + +from crazy_robotaxi.live_edit.config import LiveEditObstacleConfig +from crazy_robotaxi.live_edit.obstacle_events import ( + OBSTACLE_ENTITY_PREFIX, + ObstacleAbility, + ObstacleEvent, + ObstaclePhase, + ObstacleTemplate, + ObstacleTemplateCatalog, + build_obstacle_event, + local_ground_z, + road_ahead_pose, +) + +## Box-axis guidance (model side, GPU only) + + +class ObstacleGuidance: + """Guide the flow along the with-box/without-box conditioning axis. + + Render the conditioning twice (with and without the obstacle event), encode the no-box branch + through a shadow encoder cache whose temporal state tracks the real one + from chunk 0, and combine ``flow_nobox + s * (flow_box - flow_nobox)`` + per denoising step while an event is on screen. Costs one extra lightVAE + encode per chunk always, plus one raster and one extra network forward + per step during events. + """ + + def __init__(self, scale: float) -> None: + if scale <= 0.0: + raise ValueError("ObstacleGuidance requires a positive scale") + self._scale = float(scale) + self._alt_frames: list[Any] | None = None + self._alt_input: Any | None = None + self._shadow_cache: Any | None = None + self._ar_index = 0 + + def install(self, backend: Any) -> None: + """Hook the warmed backend's raster and session seams.""" + session = backend._session + self._guard_transformer(session) + rasterizer = backend._rasterizer + + original_first = backend.render_first_chunk + original_next = backend.render_next_chunk + + def render_first_chunk(trajectory: Any) -> Any: + self._stash_alt_frames(rasterizer, trajectory) + return original_first(trajectory) + + def render_next_chunk(trajectory: Any) -> Any: + self._stash_alt_frames(rasterizer, trajectory) + return original_next(trajectory) + + backend.render_first_chunk = render_first_chunk + backend.render_next_chunk = render_next_chunk + + original_start = session.start + original_continue = session.continue_generation + + def start(initial_rgb: Any, condition_frames: Any, prompt: str) -> Any: + self._reset_shadow(session) + self._encode_shadow(session, condition_frames) + return original_start(initial_rgb, condition_frames, prompt) + + def continue_generation(condition_frames: Any) -> Any: + self._encode_shadow(session, condition_frames) + return original_continue(condition_frames) + + session.start = start + session.continue_generation = continue_generation + self._wrap_predict_flow(session) + logger.info(f"[live-edit] obstacle box-axis guidance armed s={self._scale}") + + def install_v2(self, pipeline: Any) -> None: + """Attach guidance directly to an API-v2 OmniDreams pipeline.""" + transformer = pipeline.diffusion_model.transformer + if getattr(transformer, "_optimized_dit_executor", None) is not None: + raise RuntimeError( + "Obstacle guidance requires native_dit_acceleration='disabled'" + ) + self._shadow_cache = pipeline.encoder.initialize_autoregressive_cache() + original_predict_flow = transformer.predict_flow + + def guided_predict_flow( + noisy_latent: Any, timestep: Any, cache: Any, input: Any = None + ) -> Any: + alt = self._alt_input + if alt is None or transformer._finalizing_kv_cache: + return original_predict_flow(noisy_latent, timestep, cache, input=input) + flow_box = original_predict_flow(noisy_latent, timestep, cache, input=input) + flow_nobox = original_predict_flow(noisy_latent, timestep, cache, input=alt) + return flow_nobox + self._scale * (flow_box - flow_nobox) + + transformer.predict_flow = guided_predict_flow + logger.info(f"[live-edit] V2 obstacle guidance armed s={self._scale}") + + def reset_v2(self, pipeline: Any) -> None: + """Reset the shadow encoder cache without reinstalling model hooks.""" + self._shadow_cache = pipeline.encoder.initialize_autoregressive_cache() + self._alt_frames = None + self._alt_input = None + self._ar_index = 0 + + def prepare_v2( + self, + pipeline: Any, + autoregressive_index: int, + hdmap: Any, + alternate_hdmap: Any | None, + ) -> None: + """Advance the shadow encoder and publish obstacle-free conditioning.""" + import torch + + from flashdreams.core.distributed.context_parallel import split_inputs_cp + + source = hdmap if alternate_hdmap is None else alternate_hdmap + source = split_inputs_cp(source, seq_dim=1, cp_group=pipeline.V_group) + with torch.no_grad(), _eager_vae_scope(pipeline.encoder): + encoded = pipeline.encoder( + input=source, + autoregressive_index=autoregressive_index, + cache=self._shadow_cache, + ) + transformer = pipeline.diffusion_model.transformer + self._alt_input = ( + transformer.patchify_and_maybe_split_cp(encoded) + if alternate_hdmap is not None + else None + ) + + def _stash_alt_frames(self, rasterizer: Any, trajectory: Any) -> None: + """Render the obstacle-free conditioning when an event is present.""" + actors = trajectory.dynamic_actors + others = tuple( + actor + for actor in actors + if not actor.entity_id.startswith(OBSTACLE_ENTITY_PREFIX) + ) + if len(others) == len(actors): + self._alt_frames = None + return + chunk = rasterizer.render_chunk( + rig_poses_world=trajectory.rig_poses_world, + timestamps_us=trajectory.timestamps_us, + dynamic_actors=others, + ) + self._alt_frames = [frame.rgb_host_uint8 for frame in chunk.frames] + + def _reset_shadow(self, session: Any) -> None: + self._shadow_cache = session.pipeline.encoder.initialize_autoregressive_cache() + self._ar_index = 0 + self._alt_frames = None + self._alt_input = None + + def _encode_shadow(self, session: Any, condition_frames: Any) -> None: + """Advance the shadow encoder; publish the patchified no-box input. + + Runs every chunk (with identical conditioning when no event is + active) so the shadow cache's temporal state matches the real + encoder's — an event can then start mid-run without a history + mismatch between the two branches. + + The encode runs EAGERLY (:func:`_eager_vae_scope`): the encoder's + CUDA-graph wrapper captures against one streaming cache's buffer + addresses, so a captured replay fed the shadow cache would silently + operate on the real cache's state. The eager shadow encode also + keeps the wrapper's warmup/capture stream fed by the real cache + only, so the real branch captures correctly. + """ + import torch + + from flashdreams.core.distributed.context_parallel import split_inputs_cp + + pipeline = session.pipeline + if self._shadow_cache is None: + self._reset_shadow(session) + frames = self._alt_frames if self._alt_frames is not None else condition_frames + with torch.no_grad(): + hdmap = session._condition_tensor(frames) + hdmap = split_inputs_cp(hdmap, seq_dim=1, cp_group=pipeline.V_group) + with _eager_vae_scope(pipeline.encoder): + encoded = pipeline.encoder( + input=hdmap, + autoregressive_index=self._ar_index, + cache=self._shadow_cache, + ) + transformer = pipeline.diffusion_model.transformer + self._alt_input = ( + transformer.patchify_and_maybe_split_cp(encoded) + if self._alt_frames is not None + else None + ) + self._ar_index += 1 + + def _wrap_predict_flow(self, session: Any) -> None: + transformer = session.pipeline.diffusion_model.transformer + original_predict_flow = transformer.predict_flow + + def guided_predict_flow( + noisy_latent: Any, timestep: Any, cache: Any, input: Any = None + ) -> Any: + alt = self._alt_input + if alt is None or transformer._finalizing_kv_cache: + return original_predict_flow(noisy_latent, timestep, cache, input=input) + flow_box = original_predict_flow(noisy_latent, timestep, cache, input=input) + flow_nobox = original_predict_flow(noisy_latent, timestep, cache, input=alt) + return flow_nobox + self._scale * (flow_box - flow_nobox) + + transformer.predict_flow = guided_predict_flow + + @staticmethod + def _guard_transformer(session: Any) -> None: + """Reject executors the predict_flow dispatch cannot intercept. + + CUDA graphs and ``compile_network`` are fine: the dispatch wraps the + transformer's eager ``predict_flow`` (outside any capture), and the + graph wrapper stages the ``hdmap_condition`` kwarg into its static + buffers per call — the two forwards of a guided step are two replays + of the same captured graph with different conditioning staged in. + The native optimized-DiT executor is the one seam that bypasses the + Python conditioning path. + """ + transformer = session.pipeline.diffusion_model.transformer + if getattr(transformer, "_optimized_dit_executor", None) is not None: + raise RuntimeError( + "obstacle guidance is not wired for the native optimized-DiT " + "executor; set native_dit_acceleration: disabled in the " + "world-model manifest." + ) + + +class _eager_vae_scope: + """Route a graph-wrapped Wan VAE's calls through its eager encoder. + + The VAE's ``CUDAGraphWrapper`` passes the streaming cache dict through + verbatim, binding captured kernels to ONE cache's buffer addresses; a + replay fed a different cache would silently read/write the capture-time + cache. Flipping ``_use_cuda_graph`` off for the duration makes the + encode dispatch to the (possibly compiled) eager module with the cache + that was actually passed. No-op for encoders without the knob (pixel + shuffle, fakes). + """ + + def __init__(self, encoder: Any) -> None: + self._vae = getattr(encoder, "vae", None) + if self._vae is not None and not hasattr(self._vae, "_use_cuda_graph"): + self._vae = None + self._saved: bool | None = None + + def __enter__(self) -> None: + if self._vae is not None: + self._saved = self._vae._use_cuda_graph + self._vae._use_cuda_graph = False + + def __exit__(self, *exc: object) -> None: + if self._vae is not None and self._saved is not None: + self._vae._use_cuda_graph = self._saved + + +def install_obstacle_guidance_on_backend( + backend: Any, config: LiveEditObstacleConfig +) -> None: + """Arm box-axis guidance before model warmup starts. + + Mirrors ``install_style_ability_on_backend``'s deferred attach: the hook + install waits until ``warmup_model`` has built the pipeline. The session + keeps its accelerated pipeline — the guidance is CUDA-graph safe (see + :class:`ObstacleGuidance`), so no graph-free rebuild is needed. + """ + if config.guide_scale <= 0.0: + return + session = getattr(backend, "_session", None) + if session is None: + raise ValueError( + "--live-edit-obstacle guidance requires the omnidreams world-model backend." + ) + guidance = ObstacleGuidance(config.guide_scale) + original_warmup = session.warmup_model + + def warmup_and_install() -> None: + original_warmup() + guidance.install(backend) + + session.warmup_model = warmup_and_install + + +__all__ = [ + "OBSTACLE_ENTITY_PREFIX", + "ObstacleAbility", + "ObstacleEvent", + "ObstacleGuidance", + "ObstaclePhase", + "ObstacleTemplate", + "ObstacleTemplateCatalog", + "build_obstacle_event", + "install_obstacle_guidance_on_backend", + "local_ground_z", + "road_ahead_pose", +] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py new file mode 100644 index 000000000..b207ce69a --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py @@ -0,0 +1,777 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Track-backed, map-capable live-edit obstacle events. + +Obstacle gameplay is deliberately separate from routed NPC traffic. An event +owns its archetype, placement, scripted motion, lifetime, and (optionally) +physical body. Rendering and PhysX consume that state downstream. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import Enum + +import numpy as np +import numpy.typing as npt +from loguru import logger +from ludus_renderer import BodyState, SceneObject +from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.game_map.types import GameMapLane, ResolvedGameMap +from omnidreams_game_engine.simulation.actor_controller import ( + ActorControlDecision, + ActorTrackTarget, +) +from omnidreams_game_engine.simulation.components import rigid_body_model_for_object +from omnidreams_game_engine.types import ( + DynamicActorTrajectory, + TrajectoryChunk, + VehicleState, +) + +from crazy_robotaxi.live_edit.config import LiveEditObstacleConfig +from crazy_robotaxi.live_edit.obstacle_templates import ( + ObstacleTemplate, + ObstacleTemplateCatalog, + load_obstacle_template_catalog, +) + +OBSTACLE_ENTITY_PREFIX = "live-edit-obstacle" +_STATIC_PERSIST_US = 10**13 +_MAX_OBSTACLE_DRIVE_SPEED_MPS = 15.0 * 0.44704 +"""Upper bound shared with the game engine's non-ego actor controller.""" + + +class ObstaclePhase(str, Enum): + """Authoritative lifecycle phase for one obstacle event.""" + + SCRIPTED = "scripted" + DETACHED = "detached" + EXPIRED = "expired" + + +@dataclass +class ObstacleEvent: + """One gameplay-owned obstacle and its current scripted/physical state.""" + + entity_id: str + object_type: str + timestamps_us: npt.NDArray[np.int64] + translations_world: npt.NDArray[np.float32] + orientations_xyzw: npt.NDArray[np.float32] + dimensions_lwh: npt.NDArray[np.float32] + template_index: int + """Stable catalog index of the cloned source track.""" + + drive_speed_mps: float + """Per-object PhysX drive cap derived from the sampled trajectory.""" + + static: bool = False + phase: ObstaclePhase = ObstaclePhase.SCRIPTED + chunks: int = 0 + hit_logged: bool = False + scene_object: SceneObject | None = None + logical_timestamp_us: float = 0.0 + physical_position_m: npt.NDArray[np.float32] | None = None + physical_orientation_xyzw: npt.NDArray[np.float32] | None = None + + def actor(self) -> DynamicActorTrajectory: + """Return the complete renderer trajectory for a visual-only event.""" + return DynamicActorTrajectory( + entity_id=self.entity_id, + object_type=self.object_type, + timestamps_us=self.timestamps_us, + translations_world=self.translations_world, + orientations_xyzw=self.orientations_xyzw, + dimensions_lwh=self.dimensions_lwh, + detached_from_track=self.phase is ObstaclePhase.DETACHED, + is_simulated=True, + ) + + def center_at(self, timestamp_us: int) -> npt.NDArray[np.float32] | None: + """Return the current physical center or interpolate scripted motion.""" + if self.phase is ObstaclePhase.EXPIRED: + return None + if self.physical_position_m is not None: + return self.physical_position_m.copy() + if timestamp_us < int(self.timestamps_us[0]) or timestamp_us > int( + self.timestamps_us[-1] + ): + return None + return np.asarray( + [ + np.interp( + float(timestamp_us), + self.timestamps_us, + self.translations_world[:, i], + ) + for i in range(3) + ], + dtype=np.float32, + ) + + def orientation_at(self, timestamp_us: int) -> npt.NDArray[np.float32] | None: + """Return the current physical or nearest scripted orientation.""" + if self.phase is ObstaclePhase.EXPIRED: + return None + if self.physical_orientation_xyzw is not None: + return self.physical_orientation_xyzw.copy() + if timestamp_us < int(self.timestamps_us[0]) or timestamp_us > int( + self.timestamps_us[-1] + ): + return None + sample = int(np.argmin(np.abs(self.timestamps_us - np.int64(timestamp_us)))) + return self.orientations_xyzw[sample].copy() + + +def local_ground_z( + vertices: npt.NDArray[np.floating] | None, + xy: npt.NDArray[np.floating], + radius_m: float = 3.0, +) -> float | None: + """Return the median nearby ground height, when ground samples exist.""" + if vertices is None: + return None + points = np.asarray(vertices) + near = np.linalg.norm(points[:, :2] - np.asarray(xy)[None, :], axis=1) < radius_m + if not near.any(): + return None + return float(np.median(points[near, 2])) + + +def _yaw_quaternion(yaw_rad: float) -> npt.NDArray[np.float32]: + return np.asarray( + [0.0, 0.0, math.sin(yaw_rad * 0.5), math.cos(yaw_rad * 0.5)], + dtype=np.float32, + ) + + +def _quaternion_yaw(quaternion_xyzw: npt.NDArray[np.floating]) -> float: + x, y, z, w = (float(value) for value in quaternion_xyzw) + return math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + + +def _quaternion_multiply( + first_xyzw: npt.NDArray[np.floating], + second_xyzw: npt.NDArray[np.floating], +) -> npt.NDArray[np.float32]: + ax, ay, az, aw = (float(value) for value in first_xyzw) + bx, by, bz, bw = (float(value) for value in second_xyzw) + return np.asarray( + [ + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ], + dtype=np.float32, + ) + + +def _angle_delta(first: float, second: float) -> float: + return math.atan2(math.sin(first - second), math.cos(first - second)) + + +def _polyline_lengths(points: npt.NDArray[np.floating]) -> npt.NDArray[np.float64]: + return np.linalg.norm(np.diff(points[:, :2], axis=0), axis=1).astype(np.float64) + + +def _sample_polyline( + points: npt.NDArray[np.floating], distance_m: float +) -> tuple[npt.NDArray[np.float32], float]: + lengths = _polyline_lengths(points) + total = float(np.sum(lengths)) + distance = min(max(float(distance_m), 0.0), total) + cumulative = 0.0 + for index, length in enumerate(lengths): + if cumulative + float(length) >= distance or index == len(lengths) - 1: + alpha = 0.0 if length <= 1.0e-8 else (distance - cumulative) / float(length) + position = points[index] + alpha * (points[index + 1] - points[index]) + tangent = points[index + 1, :2] - points[index, :2] + return np.asarray(position, dtype=np.float32), math.atan2( + float(tangent[1]), float(tangent[0]) + ) + cumulative += float(length) + raise AssertionError("non-empty lane polyline was not sampled") + + +def _nearest_lane_progress( + game_map: ResolvedGameMap, + ego_state: VehicleState, +) -> tuple[GameMapLane, float] | None: + ego_xy = np.asarray([ego_state.x_m, ego_state.y_m], dtype=np.float64) + best_compatible: tuple[float, str, GameMapLane, float] | None = None + best_fallback: tuple[float, str, GameMapLane, float] | None = None + for lane in game_map.lanes: + points = np.asarray(lane.centerline_world, dtype=np.float64) + lengths = _polyline_lengths(points) + cumulative = 0.0 + for index, length in enumerate(lengths): + segment = points[index + 1, :2] - points[index, :2] + length_sq = float(np.dot(segment, segment)) + if length_sq <= 1.0e-10: + cumulative += float(length) + continue + alpha = float( + np.clip( + np.dot(ego_xy - points[index, :2], segment) / length_sq, 0.0, 1.0 + ) + ) + projected = points[index, :2] + alpha * segment + distance = float(np.linalg.norm(ego_xy - projected)) + heading = math.atan2(float(segment[1]), float(segment[0])) + heading_error = abs(_angle_delta(heading, ego_state.yaw_rad)) + candidate = ( + distance, + lane.lane_id, + lane, + cumulative + alpha * float(length), + ) + if best_fallback is None or candidate[:2] < best_fallback[:2]: + best_fallback = candidate + if heading_error <= math.pi * 0.5 and ( + best_compatible is None or candidate[:2] < best_compatible[:2] + ): + best_compatible = candidate + cumulative += float(length) + best = best_compatible or best_fallback + return None if best is None else (best[2], best[3]) + + +def _straightest_successor( + lane: GameMapLane, + lanes_by_id: dict[str, GameMapLane], +) -> GameMapLane | None: + if not lane.successor_ids: + return None + _, outgoing_heading = _sample_polyline( + lane.centerline_world, float(np.sum(_polyline_lengths(lane.centerline_world))) + ) + candidates: list[tuple[float, str, GameMapLane]] = [] + for successor_id in lane.successor_ids: + successor = lanes_by_id.get(successor_id) + if successor is None: + continue + _, heading = _sample_polyline(successor.centerline_world, 0.0) + candidates.append( + (abs(_angle_delta(heading, outgoing_heading)), successor.lane_id, successor) + ) + return None if not candidates else min(candidates, key=lambda item: item[:2])[2] + + +def road_ahead_pose( + game_map: ResolvedGameMap, + ego_state: VehicleState, + ahead_m: float, +) -> tuple[npt.NDArray[np.float32], float] | None: + """Walk directed lanes ahead, choosing the straightest legal successor.""" + nearest = _nearest_lane_progress(game_map, ego_state) + if nearest is None: + return None + lane, distance = nearest + lanes_by_id = {candidate.lane_id: candidate for candidate in game_map.lanes} + remaining = float(ahead_m) + visited = 0 + while visited <= len(lanes_by_id): + total = float(np.sum(_polyline_lengths(lane.centerline_world))) + available = max(0.0, total - distance) + if remaining <= available: + return _sample_polyline(lane.centerline_world, distance + remaining) + remaining -= available + successor = _straightest_successor(lane, lanes_by_id) + if successor is None: + return _sample_polyline(lane.centerline_world, total) + lane = successor + distance = 0.0 + visited += 1 + return _sample_polyline(lane.centerline_world, distance) + + +def _placement_pose( + *, + ego_state: VehicleState, + ahead_m: float, + lateral_m: float, + placement: str, + game_map: ResolvedGameMap | None, +) -> tuple[npt.NDArray[np.float32], float]: + pose = ( + road_ahead_pose(game_map, ego_state, ahead_m) + if placement == "road-ahead" and game_map is not None + else None + ) + if pose is None: + heading = ego_state.yaw_rad + forward = np.asarray([math.cos(heading), math.sin(heading)], dtype=np.float32) + position = np.asarray( + [ego_state.x_m, ego_state.y_m, ego_state.z_m], dtype=np.float32 + ) + position[:2] += ahead_m * forward + else: + position, heading = pose + left = np.asarray([-math.sin(heading), math.cos(heading)], dtype=np.float32) + position = position.copy() + position[:2] += lateral_m * left + return position, heading + + +def build_obstacle_event( + template: ObstacleTemplate, + *, + ego_state: VehicleState, + spawn_timestamp_us: int, + config: LiveEditObstacleConfig, + entity_id: str, + slot: int = 0, + static: bool = False, + game_map: ResolvedGameMap | None = None, + ground_vertices: npt.NDArray[np.floating] | None = None, +) -> ObstacleEvent: + """Retime, place, and optionally rotate one source vehicle trajectory.""" + ahead_m = ( + config.static_ahead_m if static else config.spawn_ahead_m + ) + slot * config.spacing_m + lateral_m = ( + config.static_lateral_m * (1 if slot % 2 else -1) + if static + else config.lateral_m + ) + target, road_heading = _placement_pose( + ego_state=ego_state, + ahead_m=ahead_m, + lateral_m=lateral_m, + placement=config.placement, + game_map=game_map, + ) + + yaw_delta = 0.0 + if static: + yaw_delta = road_heading - _quaternion_yaw(template.orientations_xyzw[0]) + elif config.placement == "road-ahead" and game_map is not None: + crossing_heading = road_heading + ( + math.pi * 0.5 if slot % 2 == 0 else -math.pi * 0.5 + ) + yaw_delta = crossing_heading - template.motion_heading_rad + + translations = template.translations_local_m.copy() + orientations = template.orientations_xyzw.copy() + if abs(yaw_delta) > 1.0e-8: + cos_yaw = math.cos(yaw_delta) + sin_yaw = math.sin(yaw_delta) + rotation = np.asarray( + [[cos_yaw, -sin_yaw], [sin_yaw, cos_yaw]], dtype=np.float32 + ) + translations[:, :2] = translations[:, :2] @ rotation.T + yaw_quaternion = _yaw_quaternion(yaw_delta) + orientations = np.stack( + [ + _quaternion_multiply(yaw_quaternion, quaternion) + for quaternion in orientations + ] + ) + orientations /= np.linalg.norm(orientations, axis=1, keepdims=True) + + ground_z = local_ground_z(ground_vertices, target[:2]) + first_center_z = ( + float(target[2]) if ground_z is None else ground_z + ) + template.source_ground_offset_m + translations[:, 0] += target[0] + translations[:, 1] += target[1] + translations[:, 2] += np.float32(first_center_z) + timestamps = template.timestamps_us + np.int64(spawn_timestamp_us) + if static: + timestamps = np.concatenate( + [timestamps, np.asarray([_STATIC_PERSIST_US], dtype=np.int64)] + ) + translations = np.concatenate([translations, translations[-1:]], axis=0) + orientations = np.concatenate([orientations, orientations[-1:]], axis=0) + + return ObstacleEvent( + entity_id=entity_id, + object_type=template.object_type, + timestamps_us=timestamps, + translations_world=translations.astype(np.float32), + orientations_xyzw=orientations.astype(np.float32), + dimensions_lwh=template.dimensions_lwh.copy(), + template_index=template.template_index, + drive_speed_mps=( + 0.0 + if static + else min(template.sampled_speed_mps, _MAX_OBSTACLE_DRIVE_SPEED_MPS) + ), + static=static, + ) + + +class ObstacleAbility: + """Own track-backed obstacle lifecycle, rendering, and PhysX control.""" + + def __init__( + self, + config: LiveEditObstacleConfig, + *, + catalog: ObstacleTemplateCatalog | None = None, + templates: tuple[ObstacleTemplate, ...] | None = None, + parked_templates: tuple[ObstacleTemplate, ...] | None = None, + game_map: ResolvedGameMap | None = None, + ground_vertices: npt.NDArray[np.floating] | None = None, + vehicle: VehicleConfig | None = None, + ) -> None: + self._config = config + self._game_map = game_map + self._ground_vertices = ground_vertices + self._vehicle = vehicle or VehicleConfig() + resolved_catalog = catalog + if resolved_catalog is None and ( + templates is None or (parked_templates is None and config.static_count > 0) + ): + resolved_catalog = load_obstacle_template_catalog() + self._templates = ( + templates + if templates is not None + else resolved_catalog.moving( # type: ignore[union-attr] + min_drift_m=config.min_drift_m, + min_coverage_s=config.min_coverage_s, + length_range_m=config.length_range_m, + ) + ) + self._parked_templates = ( + parked_templates + if parked_templates is not None + else ( + () + if config.static_count == 0 + else resolved_catalog.parked( # type: ignore[union-attr] + length_range_m=config.length_range_m + ) + ) + ) + self._pending: list[tuple[int, int]] = [] + self._events: list[ObstacleEvent] = [] + self._chunk_index = 0 + self._event_count = 0 + self._burst_count = 0 + self._hit_count = 0 + self._static_initialized = False + self._owned_ids: set[str] = set() + self._drive_speeds_mps: dict[str, float] = {} + logger.info( + "[live-edit] obstacle templates: {} moving, {} parked (of {})", + len(self._templates), + len(self._parked_templates), + len(resolved_catalog.templates) if resolved_catalog is not None else 0, + ) + + @property + def active(self) -> bool: + return bool(self.events) + + @property + def event(self) -> ObstacleEvent | None: + return self.events[0] if self.events else None + + @property + def events(self) -> tuple[ObstacleEvent, ...]: + return tuple( + event for event in self._events if event.phase is not ObstaclePhase.EXPIRED + ) + + @property + def hit_count(self) -> int: + return self._hit_count + + @property + def objects(self) -> tuple[SceneObject, ...]: + return tuple( + event.scene_object + for event in self.events + if event.scene_object is not None + ) + + @property + def active_objects(self) -> tuple[SceneObject, ...]: + return self.objects if self._config.physics else () + + @property + def active_object_ids(self) -> frozenset[str]: + return frozenset(scene_object.object_id for scene_object in self.active_objects) + + @property + def active_timestamps_us(self) -> dict[str, int]: + return {scene_object.object_id: 0 for scene_object in self.active_objects} + + @property + def object_ids(self) -> frozenset[str]: + return frozenset(self._owned_ids) + + @property + def max_drive_speeds_mps(self) -> dict[str, float]: + return dict(self._drive_speeds_mps) + + def request_spawn(self) -> None: + """Queue one configured crossing burst; only one burst may be active.""" + if not self._templates: + logger.warning("[live-edit] obstacle spawn requested but no templates") + return + if self._pending or any(not event.static for event in self.events): + return + base = self._chunk_index + self._pending = [ + (slot, base + slot * self._config.stagger_chunks) + for slot in range(self._config.count) + ] + self._burst_count += 1 + + def reset(self) -> None: + self._pending.clear() + self._events.clear() + self._chunk_index = 0 + self._static_initialized = False + self._owned_ids.clear() + self._drive_speeds_mps.clear() + + def _make_event( + self, + ego_state: VehicleState, + spawn_timestamp_us: int, + slot: int, + *, + static: bool, + ) -> ObstacleEvent: + entity_id = ( + f"{OBSTACLE_ENTITY_PREFIX}-static-{slot}" + if static + else f"{OBSTACLE_ENTITY_PREFIX}-{self._event_count}" + ) + if not static: + self._event_count += 1 + if static: + if not self._parked_templates: + raise RuntimeError("No parked obstacle templates are available") + template = self._parked_templates[slot % len(self._parked_templates)] + else: + ahead_m = self._config.spawn_ahead_m + slot * self._config.spacing_m + _, reference_heading = _placement_pose( + ego_state=ego_state, + ahead_m=ahead_m, + lateral_m=self._config.lateral_m, + placement=self._config.placement, + game_map=self._game_map, + ) + template = self._templates[self._select_template_index(reference_heading)] + event = build_obstacle_event( + template, + ego_state=ego_state, + spawn_timestamp_us=spawn_timestamp_us, + config=self._config, + entity_id=entity_id, + slot=slot, + static=static, + game_map=self._game_map, + ground_vertices=self._ground_vertices, + ) + self._owned_ids.add(entity_id) + self._drive_speeds_mps[entity_id] = event.drive_speed_mps + if self._config.physics: + relative_timestamps = event.timestamps_us - np.int64(spawn_timestamp_us) + event.scene_object = SceneObject( + object_id=event.entity_id, + object_type=event.object_type, + model=rigid_body_model_for_object( + event.object_type, + event.dimensions_lwh, + restitution=self._vehicle.collision_restitution, + friction=self._vehicle.collision_friction, + ), + timestamps_us=relative_timestamps, + positions_m=event.translations_world.copy(), + orientations_xyzw=event.orientations_xyzw.copy(), + ) + self._events.append(event) + start = event.translations_world[0] + logger.info( + "[live-edit] obstacle spawned {} template={} mode={} placement={} " + "at ({:.1f}, {:.1f}, {:.1f})", + event.entity_id, + event.template_index, + "physical" if self._config.physics else "visual", + self._config.placement, + start[0], + start[1], + start[2], + ) + return event + + def _select_template_index(self, reference_heading_rad: float) -> int: + """Pick a distinct source track that crosses the reference heading.""" + forward = np.asarray( + [math.cos(reference_heading_rad), math.sin(reference_heading_rad)], + dtype=np.float64, + ) + + def crossness(index: int) -> float: + motion = ( + self._templates[index].translations_local_m[-1, :2].astype(np.float64) + ) + return abs(float(motion @ forward / (np.linalg.norm(motion) or 1.0))) + + ranked = sorted(range(len(self._templates)), key=crossness) + pool_size = max(1, min(max(4, self._config.count), len(ranked))) + top = ranked[:pool_size] + offset = (self._burst_count - 1) % len(top) + rotated = top[offset:] + top[:offset] + in_use_template_ids = { + event.template_index for event in self.events if not event.static + } + unused = [ + index + for index in rotated + if self._templates[index].template_index not in in_use_template_ids + ] + return (unused or rotated)[0] + + def _ego_state_from_body(self, ego: BodyState) -> VehicleState: + x, y, z, w = (float(value) for value in ego.orientation_xyzw) + yaw = math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + return VehicleState( + x_m=float(ego.position_m[0]), + y_m=float(ego.position_m[1]), + z_m=float(ego.position_m[2] - self._vehicle.aabb_height_m * 0.5), + yaw_rad=yaw, + speed_mps=float(np.linalg.norm(ego.linear_velocity_mps[:2])), + steer_rad=0.0, + ) + + def _spawn_due(self, ego_state: VehicleState, timestamp_us: int) -> None: + if not self._static_initialized: + self._static_initialized = True + if self._config.static_count and not self._parked_templates: + logger.warning( + "[live-edit] static roadblock requested but no parked templates" + ) + else: + for slot in range(self._config.static_count): + self._make_event(ego_state, timestamp_us, slot, static=True) + due = [entry for entry in self._pending if entry[1] <= self._chunk_index] + self._pending = [ + entry for entry in self._pending if entry[1] > self._chunk_index + ] + for slot, _ in due: + self._make_event(ego_state, timestamp_us, slot, static=False) + + def prepare_topology(self, ego: BodyState) -> None: + """Materialize due physical events before the native-world sync.""" + if not self._config.physics: + return + self._spawn_due(self._ego_state_from_body(ego), 0) + + def prepare_step(self, ego: BodyState, dt_s: float) -> tuple[ActorTrackTarget, ...]: + """Advance scripted motion and return targets for physical obstacles.""" + del ego + if not self._config.physics: + return () + targets: list[ActorTrackTarget] = [] + for event in self.events: + if event.phase is not ObstaclePhase.SCRIPTED or event.scene_object is None: + continue + event.logical_timestamp_us = min( + event.logical_timestamp_us + dt_s * 1_000_000.0, + float(event.scene_object.timestamps_us[-1]), + ) + targets.append( + ActorTrackTarget( + object_id=event.entity_id, + timestamp_us=int(event.logical_timestamp_us), + ) + ) + return tuple(targets) + + def observe_physics( + self, + object_id: str, + *, + struck: bool, + body: BodyState, + dt_s: float, + ) -> ActorControlDecision | None: + del dt_s + event = next( + (item for item in self.events if item.entity_id == object_id), None + ) + if event is None: + return None + event.physical_position_m = body.position_m.copy() + event.physical_orientation_xyzw = body.orientation_xyzw.copy() + if struck and event.phase is ObstaclePhase.SCRIPTED: + event.phase = ObstaclePhase.DETACHED + if not event.hit_logged: + event.hit_logged = True + self._hit_count += 1 + logger.info("[live-edit] obstacle HIT {}", event.entity_id) + return ActorControlDecision( + drive_enabled=event.phase is ObstaclePhase.SCRIPTED, + detached_from_track=event.phase is ObstaclePhase.DETACHED, + ) + + def _check_visual_collision( + self, event: ObstacleEvent, trajectory: TrajectoryChunk + ) -> None: + if event.hit_logged: + return + for timestamp_us, state in zip( + trajectory.timestamps_us, trajectory.vehicle_states, strict=True + ): + center = event.center_at(int(timestamp_us)) + if ( + center is not None + and math.hypot( + float(center[0]) - state.x_m, float(center[1]) - state.y_m + ) + <= self._config.collision_radius_m + ): + event.hit_logged = True + self._hit_count += 1 + logger.info("[live-edit] obstacle HIT {}", event.entity_id) + return + + def advance_frames( + self, trajectory: TrajectoryChunk + ) -> tuple[DynamicActorTrajectory, ...]: + """Advance chunk lifetimes and return visual-only renderer actors.""" + if not self._config.physics: + self._spawn_due( + trajectory.vehicle_states[0], int(trajectory.timestamps_us[0]) + ) + actors: list[DynamicActorTrajectory] = [] + last_timestamp_us = int(trajectory.timestamps_us[-1]) + for event in self.events: + event.chunks += 1 + if not self._config.physics: + self._check_visual_collision(event, trajectory) + actors.append(event.actor()) + track_exhausted = last_timestamp_us >= int(event.timestamps_us[-1]) + if not event.static and ( + event.chunks >= self._config.active_chunks or track_exhausted + ): + event.phase = ObstaclePhase.EXPIRED + reason = "track exhausted" if track_exhausted else "duration reached" + logger.info( + "[live-edit] obstacle despawned {} ({})", event.entity_id, reason + ) + self._chunk_index += 1 + return tuple(actors) + + +__all__ = [ + "OBSTACLE_ENTITY_PREFIX", + "ObstacleAbility", + "ObstacleEvent", + "ObstaclePhase", + "ObstacleTemplate", + "ObstacleTemplateCatalog", + "build_obstacle_event", + "local_ground_z", + "road_ahead_pose", +] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py new file mode 100644 index 000000000..768182573 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py @@ -0,0 +1,286 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Map-independent vehicle-track templates for live-edit obstacles.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from importlib.resources import files +from pathlib import Path +from typing import BinaryIO + +import numpy as np +import numpy.typing as npt + +_CATALOG_FILENAME = "obstacle_vehicle_tracks_v1.npz" +_CATALOG_FORMAT_VERSION = 1 +_OBJECT_TYPES = ("Car", "Truck") + + +@dataclass(frozen=True) +class ObstacleTemplate: + """One vehicle trajectory normalized to its first center and timestamp.""" + + template_index: int + """Stable zero-based index in the bundled catalog.""" + + object_type: str + """Renderer object type.""" + + timestamps_us: npt.NDArray[np.int64] + """Sample timestamps relative to the first sample.""" + + translations_local_m: npt.NDArray[np.float32] + """Sample centers relative to the first sample.""" + + orientations_xyzw: npt.NDArray[np.float32] + """Source orientation quaternion for every sample.""" + + dimensions_lwh: npt.NDArray[np.float32] + """Box dimensions from the first source sample.""" + + source_ground_offset_m: float + """First center height above the source scene's local ground.""" + + @property + def drift_m(self) -> float: + """Return ground-plane displacement between the endpoint samples.""" + return float(np.linalg.norm(self.translations_local_m[-1, :2])) + + @property + def duration_s(self) -> float: + """Return track coverage in seconds.""" + return float(self.timestamps_us[-1]) * 1.0e-6 + + @property + def motion_heading_rad(self) -> float: + """Return the endpoint ground-plane motion heading.""" + motion = self.translations_local_m[-1, :2] + return float(np.arctan2(motion[1], motion[0])) + + @property + def sampled_speed_mps(self) -> float: + """Return average speed along the sampled ground-plane path.""" + elapsed_s = np.diff(self.timestamps_us).astype(np.float64) * 1.0e-6 + distances_m = np.linalg.norm( + np.diff(self.translations_local_m[:, :2], axis=0), axis=1 + ) + valid = elapsed_s > 0.0 + if not valid.any(): + return 0.0 + return float(np.sum(distances_m[valid]) / np.sum(elapsed_s[valid])) + + +@dataclass(frozen=True) +class ObstacleTemplateCatalog: + """Validated collection of obstacle vehicle trajectories.""" + + templates: tuple[ObstacleTemplate, ...] + """Vehicle tracks in deterministic source-track order.""" + + def moving( + self, + *, + min_drift_m: float, + min_coverage_s: float, + length_range_m: tuple[float, float], + ) -> tuple[ObstacleTemplate, ...]: + """Return PR494-compatible moving templates in selection order.""" + lo, hi = length_range_m + selected = [ + template + for template in self.templates + if len(template.timestamps_us) >= 8 + and template.duration_s >= min_coverage_s + and lo <= float(template.dimensions_lwh.max()) <= hi + and template.drift_m >= min_drift_m + ] + + def order_key(template: ObstacleTemplate) -> tuple[int, float]: + speed_mps = template.drift_m / template.duration_s + return (0 if 2.0 <= speed_mps <= 8.0 else 1, -template.duration_s) + + selected.sort(key=order_key) + return tuple(selected) + + def parked( + self, *, length_range_m: tuple[float, float] + ) -> tuple[ObstacleTemplate, ...]: + """Return PR494-compatible parked templates in selection order.""" + lo, hi = length_range_m + selected = [ + template + for template in self.templates + if len(template.timestamps_us) >= 8 + and template.duration_s >= 3.0 + and lo <= float(template.dimensions_lwh.max()) <= hi + and template.drift_m < 2.0 + ] + selected.sort(key=lambda template: -template.duration_s) + return tuple(selected) + + +def _require_array( + archive: np.lib.npyio.NpzFile, + name: str, + *, + dtype: npt.DTypeLike, + ndim: int, +) -> np.ndarray: + if name not in archive.files: + raise ValueError(f"Obstacle template catalog is missing {name!r}") + value = np.asarray(archive[name]) + if value.dtype != np.dtype(dtype) or value.ndim != ndim: + raise ValueError( + f"Obstacle template catalog {name!r} must have dtype {np.dtype(dtype)} " + f"and {ndim} dimensions, got {value.dtype} and {value.ndim}" + ) + return value + + +def load_obstacle_template_catalog_from_file( + source: str | Path | BinaryIO, +) -> ObstacleTemplateCatalog: + """Load and validate a safe numeric obstacle-template archive.""" + with np.load(source, allow_pickle=False) as archive: + version = _require_array(archive, "format_version", dtype=np.int32, ndim=0) + if int(version) != _CATALOG_FORMAT_VERSION: + raise ValueError( + f"Unsupported obstacle template catalog version {int(version)}" + ) + offsets = _require_array(archive, "sample_offsets", dtype=np.int64, ndim=1) + timestamps = _require_array(archive, "timestamps_us", dtype=np.int64, ndim=1) + translations = _require_array( + archive, "translations_local_m", dtype=np.float32, ndim=2 + ) + orientations = _require_array( + archive, "orientations_xyzw", dtype=np.float32, ndim=2 + ) + dimensions = _require_array(archive, "dimensions_lwh", dtype=np.float32, ndim=2) + object_type_codes = _require_array( + archive, "object_type_codes", dtype=np.uint8, ndim=1 + ) + ground_offsets = _require_array( + archive, "source_ground_offsets_m", dtype=np.float32, ndim=1 + ) + + arrays = tuple( + np.array(value, copy=True) + for value in ( + offsets, + timestamps, + translations, + orientations, + dimensions, + object_type_codes, + ground_offsets, + ) + ) + ( + offsets, + timestamps, + translations, + orientations, + dimensions, + object_type_codes, + ground_offsets, + ) = arrays + _validate_catalog_arrays( + offsets=offsets, + timestamps=timestamps, + translations=translations, + orientations=orientations, + dimensions=dimensions, + object_type_codes=object_type_codes, + ground_offsets=ground_offsets, + ) + + templates = [] + for index, (start, end) in enumerate(zip(offsets[:-1], offsets[1:], strict=True)): + templates.append( + ObstacleTemplate( + template_index=index, + object_type=_OBJECT_TYPES[int(object_type_codes[index])], + timestamps_us=timestamps[int(start) : int(end)], + translations_local_m=translations[int(start) : int(end)], + orientations_xyzw=orientations[int(start) : int(end)], + dimensions_lwh=dimensions[index], + source_ground_offset_m=float(ground_offsets[index]), + ) + ) + return ObstacleTemplateCatalog(templates=tuple(templates)) + + +def _validate_catalog_arrays( + *, + offsets: np.ndarray, + timestamps: np.ndarray, + translations: np.ndarray, + orientations: np.ndarray, + dimensions: np.ndarray, + object_type_codes: np.ndarray, + ground_offsets: np.ndarray, +) -> None: + track_count = len(offsets) - 1 + sample_count = len(timestamps) + if len(offsets) < 2 or offsets[0] != 0 or offsets[-1] != sample_count: + raise ValueError("Obstacle template sample offsets are inconsistent") + if np.any(np.diff(offsets) < 2): + raise ValueError("Every obstacle template must have at least two samples") + if translations.shape != (sample_count, 3): + raise ValueError("Obstacle template translations must have shape [samples, 3]") + if orientations.shape != (sample_count, 4): + raise ValueError("Obstacle template orientations must have shape [samples, 4]") + if dimensions.shape != (track_count, 3): + raise ValueError("Obstacle template dimensions must have shape [tracks, 3]") + if object_type_codes.shape != (track_count,) or np.any( + object_type_codes >= len(_OBJECT_TYPES) + ): + raise ValueError("Obstacle template object type codes are invalid") + if ground_offsets.shape != (track_count,): + raise ValueError("Obstacle template ground offsets must have shape [tracks]") + numeric = (translations, orientations, dimensions, ground_offsets) + if any(not np.isfinite(value).all() for value in numeric): + raise ValueError("Obstacle template catalog contains non-finite values") + if np.any(dimensions <= 0.0): + raise ValueError("Obstacle template dimensions must be positive") + if np.any(np.linalg.norm(orientations, axis=1) <= 1.0e-8): + raise ValueError("Obstacle template orientations must be non-zero") + for start, end in zip(offsets[:-1], offsets[1:], strict=True): + track_timestamps = timestamps[int(start) : int(end)] + if track_timestamps[0] != 0 or np.any(np.diff(track_timestamps) <= 0): + raise ValueError( + "Obstacle template timestamps must start at zero and increase" + ) + if not np.allclose(translations[int(start)], 0.0, atol=1.0e-5): + raise ValueError("Obstacle template translations must start at zero") + + +@lru_cache(maxsize=1) +def load_obstacle_template_catalog() -> ObstacleTemplateCatalog: + """Load the obstacle catalog bundled with Crazy Robotaxi.""" + resource = files("crazy_robotaxi.assets").joinpath(_CATALOG_FILENAME) + with resource.open("rb") as handle: + return load_obstacle_template_catalog_from_file(handle) + + +__all__ = [ + "ObstacleTemplate", + "ObstacleTemplateCatalog", + "load_obstacle_template_catalog", + "load_obstacle_template_catalog_from_file", +] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py new file mode 100644 index 000000000..3a61d5e31 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py @@ -0,0 +1,358 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""API-v2 model-thread wiring for Crazy Robotaxi live-edit abilities.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +import numpy as np +import torch +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.contracts import GameRules, GameUpdate +from omnidreams_game_engine.types import SceneDefinition, TrajectoryChunk, VehicleState +from PIL import Image, ImageDraw +from torch import Tensor + +from crazy_robotaxi.live_edit.coin_ability import CoinAbility +from crazy_robotaxi.live_edit.config import LiveEditConfig +from crazy_robotaxi.live_edit.gpu_compositor import LiveEditFrameCompositor +from crazy_robotaxi.live_edit.item_ability import ItemAbility, ItemEffects +from crazy_robotaxi.live_edit.nitro_ability import NitroAbility +from crazy_robotaxi.live_edit.obstacle_events import ObstacleAbility +from crazy_robotaxi.live_edit.obstacle_ability import ( + OBSTACLE_ENTITY_PREFIX, + ObstacleGuidance, +) +from crazy_robotaxi.live_edit.style_ability import StyleAbility +from crazy_robotaxi.navigation import NavigationLane +from flashdreams.runtime_v2.user_input_event import ( + KeyboardInputState, + KeyboardUserInputEvent, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents + + +def _procedural_sprite(label: str, fill: tuple[int, int, int]) -> Image.Image: + image = Image.new("RGBA", (96, 96), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle( + (8, 8, 88, 88), radius=18, fill=(*fill, 255), outline=(20, 20, 20, 255), width=5 + ) + box = draw.textbbox((0, 0), label) + draw.text( + ((96 - box[2]) / 2, (96 - box[3]) / 2), + label, + fill=(20, 20, 20, 255), + ) + return image + + +class LiveEditGameplay: + """Own all flag-gated CPU gameplay and presentation state.""" + + def __init__( + self, + config: LiveEditConfig, + scene: SceneDefinition, + lanes: tuple[NavigationLane, ...], + *, + vehicle: Any, + ) -> None: + self.config = config + self.style = ( + StyleAbility(config.style, config.weather) + if config.style.enabled or config.weather.enabled + else None + ) + self.coins = ( + CoinAbility.from_lanes(lanes, config.coins) + if config.coins.enabled + else None + ) + self.items = ( + ItemAbility.from_lanes(lanes, config.items) + if config.items.enabled + else None + ) + self.nitro = NitroAbility(config.items) if config.items.enabled else None + self.effects = ( + ItemEffects(self.style, config.items, nitro_ability=self.nitro) + if self.items is not None + else None + ) + self.obstacles = ( + ObstacleAbility( + config.obstacle, + game_map=scene.game_map, + ground_vertices=scene.ground_mesh_vertices, + vehicle=vehicle, + ) + if config.obstacle.enabled + else None + ) + self.guidance = ( + ObstacleGuidance(config.obstacle.guide_scale) + if config.obstacle.enabled and config.obstacle.guide_scale > 0.0 + else None + ) + sprites = { + "rain": _procedural_sprite("R", (70, 130, 240)), + "snow": _procedural_sprite("S", (235, 245, 255)), + "mystery": _procedural_sprite("?", (250, 170, 40)), + "nitro": _procedural_sprite("N", (90, 225, 110)), + } + self._compositor = LiveEditFrameCompositor( + _procedural_sprite("$", (250, 200, 40)), sprites + ) + self._camera = FThetaCameraModel( + scene.selected_camera, + output_width=scene.selected_camera.width, + output_height=scene.selected_camera.height, + ) + self._frame_index = 0 + + def attach_model(self, pipeline: Any) -> None: + """Install optional model-side obstacle guidance.""" + if self.guidance is not None: + self.guidance.install_v2(pipeline) + + def adopt_model_state( + self, + previous: LiveEditGameplay, + pipeline: Any, + cache: Any, + base_prompt: str, + ) -> None: + """Reuse installed model hooks while resetting per-rollout gameplay.""" + if self.style is not None and previous.style is not None: + self.style = previous.style + self.style.reset_v2(cache) + if self.items is not None: + self.effects = ItemEffects( + self.style, + self.config.items, + nitro_ability=self.nitro, + ) + if self.guidance is not None and previous.guidance is not None: + self.guidance = previous.guidance + self.guidance.reset_v2(pipeline) + + def prepare_model_step( + self, pipeline: Any, engine: Any, step: Any, autoregressive_index: int + ) -> None: + """Prepare the obstacle-free shadow conditioning branch.""" + if self.guidance is None: + return + actors = step.trajectory.dynamic_actors + filtered = tuple( + actor + for actor in actors + if not actor.entity_id.startswith(OBSTACLE_ENTITY_PREFIX) + ) + alternate = None + if len(filtered) != len(actors): + clean_trajectory = replace(step.trajectory, dynamic_actors=filtered) + alternate = engine.condition_renderer.render(clean_trajectory).hdmap_bvtchw + self.guidance.prepare_v2( + pipeline, + autoregressive_index, + step.condition.hdmap_bvtchw, + alternate, + ) + + @property + def actor_controllers(self) -> tuple[ObstacleAbility, ...]: + """Return physical obstacle control when that mode is enabled.""" + if self.obstacles is None or not self.config.obstacle.physics: + return () + return (self.obstacles,) + + def process_events(self, events: UserInputEvents) -> None: + """Consume rising-edge ability keys on the V2 model thread.""" + for event in events.get_events(): + if not isinstance(event, KeyboardUserInputEvent): + continue + if event.state is not KeyboardInputState.PRESSED: + continue + key = str(event.key).strip().lower() + if key == "k" and self.style is not None: + self.style.request_cycle() + elif key == "v" and self.style is not None: + self.style.request_weather_cycle() + elif key == "c" and self.coins is not None: + self.coins.toggle() + elif key == "o" and self.obstacles is not None: + self.obstacles.request_spawn() + + def advance(self, trajectory: TrajectoryChunk) -> tuple[Any, ...]: + """Advance pickups and obstacles after one physics trajectory.""" + if self.coins is not None: + self.coins.advance_frames(trajectory.vehicle_states) + if self.items is not None and self.effects is not None: + for item_type in self.items.advance_frames(trajectory.vehicle_states): + self.items.flash(self.effects.apply(item_type)) + if self.obstacles is None: + return () + return self.obstacles.advance_frames(trajectory) + + def postprocess_video(self, video: Tensor, step: Any) -> Tensor: + """Composite frame-aligned collectibles and state chips on device.""" + if ( + self.coins is None + and self.items is None + and self.style is None + and self.obstacles is None + ): + return video + result = video.clone() + _, _, frame_count = result.shape[:3] + for index in range(frame_count): + pose = step.trajectory.rig_poses_world[index] + sprites = [] + if self.coins is not None: + sprites.extend( + self.coins.visible_sprites( + pose, + self._camera, + image_width=int(result.shape[-1]), + image_height=int(result.shape[-2]), + ) + ) + if self.items is not None: + sprites.extend( + self.items.visible_sprites( + pose, + self._camera, + image_width=int(result.shape[-1]), + image_height=int(result.shape[-2]), + ) + ) + labels = [] + if self.style is not None: + labels.append(f"SKIN {self.style.active_skin_name.upper()}") + labels.append(f"WEATHER {self.style.active_weather_name.upper()}") + if self.coins is not None: + labels.append( + f"COINS {self.coins.collected_count} +{self.coins.score}" + ) + if self.nitro is not None and self.nitro.active: + labels.append(f"NITRO {self.nitro.seconds_remaining:.1f}s") + if self.items is not None and self.items.flash_label is not None: + labels.append(self.items.flash_label) + if self.obstacles is not None: + labels.append( + f"OBSTACLES {len(self.obstacles.events)} HITS {self.obstacles.hit_count}" + ) + frame = ((result[0, 0, index] + 1.0) * 127.5).round().clamp(0, 255) + frame = frame.to(torch.uint8).permute(1, 2, 0).contiguous() + frame = self._compositor.composite( + frame, + sprites=sprites, + frame_index=self._frame_index, + labels=labels, + sharpen_sigma=self.config.sharpen_sigma, + sharpen_amount=( + self.config.sharpen_amount + if self.style is not None and self.style.active_skin_name != "base" + else 0.0 + ), + ) + if self.config.obstacle.annotate: + frame = self._annotate_obstacles( + frame, + pose, + int(step.trajectory.timestamps_us[index]), + ) + result[0, 0, index] = ( + frame.permute(2, 0, 1).to(dtype=result.dtype) / 127.5 - 1.0 + ) + self._frame_index += 1 + return result + + def _annotate_obstacles( + self, frame: Tensor, pose: Any, timestamp_us: int + ) -> Tensor: + """Draw the optional obstacle 3D-box evidence overlay.""" + if self.obstacles is None or not self.obstacles.events: + return frame + device = frame.device + canvas = Image.fromarray(frame.cpu().numpy(), mode="RGB") + draw = ImageDraw.Draw(canvas) + signs = np.asarray( + [[x, y, z] for x in (-1, 1) for y in (-1, 1) for z in (-1, 1)], + dtype=np.float32, + ) + edges = ( + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (4, 5), + (4, 6), + (5, 7), + (6, 7), + (0, 4), + (1, 5), + (2, 6), + (3, 7), + ) + for event in self.obstacles.events: + center = event.center_at(timestamp_us) + orientation = event.orientation_at(timestamp_us) + if center is None or orientation is None: + continue + x, y, z, w = (float(value) for value in orientation) + rotation = np.asarray( + [ + [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], + [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], + [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], + ], + dtype=np.float32, + ) + half = np.asarray(event.dimensions_lwh, dtype=np.float32) / 2.0 + corners = center[None, :] + (signs * half[None, :]) @ rotation.T + uv, _depth, forward = self._camera.project_world(corners, pose) + if not forward.all(): + continue + for first, second in edges: + draw.line( + [tuple(uv[first].tolist()), tuple(uv[second].tolist())], + fill=(255, 60, 60), + width=2, + ) + array = np.asarray(canvas, dtype=np.uint8).copy() + return torch.from_numpy(array).to(device=device) + + +class LiveEditGameRules: + """Add live-edit dynamic actors while preserving the selected game rules.""" + + def __init__(self, inner: GameRules, gameplay: LiveEditGameplay) -> None: + self.inner = inner + self.gameplay = gameplay + + @property + def is_running(self) -> bool: + return self.inner.is_running + + def snapshot(self, vehicle_state: VehicleState) -> object: + return self.inner.snapshot(vehicle_state) + + def advance_frames( + self, trajectory: TrajectoryChunk, frame_interval_s: float + ) -> GameUpdate: + update = self.inner.advance_frames(trajectory, frame_interval_s) + return GameUpdate( + frames=update.frames, + dynamic_actors=(*update.dynamic_actors, *self.gameplay.advance(trajectory)), + ) + + def submit_text(self, value: str, vehicle_state: VehicleState) -> object: + return self.inner.submit_text(value, vehicle_state) + + +__all__ = ["LiveEditGameRules", "LiveEditGameplay"] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py new file mode 100644 index 000000000..63f98428e --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py @@ -0,0 +1,1190 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Live game-skin switching on the flashdreams world-model session. + +Ports the attach recipe from +``integrations/omnidreams/scripts/smoke_text_edit.py`` onto +:class:`omnidreams_game_engine.world_model.flashdreams_adapter.FlashdreamsWorldModelSession`: + +- a pre-merged :class:`omnidreams._edit_lora.TextEditLoRA` on the + transformer (zero steady-state cost; ``replace_text`` opens its edit + window automatically), +- the rank-16 drift corrector. Default (``corrector_mode="fused"``): the + CUDA-graph-safe per-state + :class:`omnidreams._drift_corrector.DriftCorrectorDispatch` — one + pre-merged weight-set family per (base | skin | weather) state, + ``compile_network`` and ``use_cuda_graph`` stay ON. The edit LoRA hands + its self-attention deltas to the dispatch (``release_targets``), whose + skin sets carry LoRA + corrector in one ``copy_`` source, resolving the + old last-writer-wins clash; the LoRA keeps toggling only the + cross-attention projections. ``corrector_mode="unfused"`` (or + ``LIVE_EDIT_CORRECTOR_MODE=unfused``) restores the eager scale-gated + fallback, which still forces the graph-free pipeline; + ``corrector_mode="off"`` deploys no corrector at all (configured + corrector checkpoints are ignored and no weight sets are snapshotted), +- prompt swaps applied strictly between chunks by wrapping the session's + ``start`` / ``continue_generation``; corrector-state selection rides the + same boundary. + +Vanilla behavior is untouched until :func:`attach_style_ability` runs. +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Callable +from dataclasses import replace +from typing import Any + +from loguru import logger + +from crazy_robotaxi.live_edit.config import ( + LiveEditStyleConfig, + LiveEditWeatherConfig, +) +from crazy_robotaxi.live_edit.weather_ability import compose_swap_target + +_NO_PENDING = object() +"""Sentinel distinguishing "no request" from "revert to base" (None).""" + + +class _V2PromptSession: + """Small adapter exposing the legacy swap seam over a V2 rollout cache.""" + + def __init__(self, pipeline: Any, cache: Any) -> None: + self.pipeline = pipeline + self._cache = cache + self._pending_finalization_index = None + + def replace_prompt( + self, + prompt: str, + *, + guidance_scale: float, + guidance_chunks: int, + ) -> None: + """Replace text directly on the V2 rollout cache.""" + self.pipeline.replace_text( + self._cache, + [[prompt]], + guidance_scale=guidance_scale, + guidance_chunks=guidance_chunks, + ) + + +class StyleAbility: + """Cycle world skins and weather states on a running flashdreams session. + + One object owns the prompt state machine for both abilities so the + mutual-exclusion rule holds at one seam: weather is base-world-only, + the weather key is rejected while a skin is active, and activating a + skin clears any active weather. A single ``replace_text`` per boundary + carries the one active prompt; see + :mod:`crazy_robotaxi.live_edit.weather_ability` for the state matrix. + """ + + def __init__( + self, + config: LiveEditStyleConfig, + weather_config: LiveEditWeatherConfig | None = None, + ) -> None: + weather_enabled = weather_config is not None and weather_config.enabled + if not config.enabled and not weather_enabled: + raise ValueError( + "StyleAbility requires live_edit.style or live_edit.weather" + ) + self._config = config + self._weather_config = weather_config if weather_enabled else None + self._session: Any | None = None + self._transformer: Any | None = None + self._lora_attached = False + self._base_prompt: str | None = None + self._active_index: int | None = None + self._pending_index: int | None | object = _NO_PENDING + self._active_weather: int | None = None + self._pending_weather: int | None | object = _NO_PENDING + self._chunks_since_swap = 0 + self._skin_hold_chunks = 0 + self._weather_hold_chunks = 0 + # Per-activation timed-skin duration: item pickups (mystery box) + # override the global skin_duration_chunks for one activation. + self._active_skin_duration = config.skin_duration_chunks + self._pending_skin_duration: int | None = None + self._seconds_per_chunk = 8.0 / 30.0 # attach() reads the manifest + self._set_corrector_gain: Callable[[float], None] = lambda _: None + self._dispatch: Any | None = None + self._corrector_states: set[str] = set() + self._prompt_embeddings: dict[str, Any] = {} + + @property + def active_skin_name(self) -> str: + """Return the HUD label of the active skin (``base`` when off).""" + if self._active_index is None or not self._config.enabled: + return "base" + return self._config.skins[self._active_index].name + + @property + def skin_chunks_remaining(self) -> int | None: + """Chunks left on the active timed skin (``None`` when untimed/off).""" + duration = self._active_skin_duration + if duration <= 0 or self._active_index is None: + return None + return max(duration - self._skin_hold_chunks, 0) + + @property + def skin_seconds_remaining(self) -> float | None: + """Seconds left on the active timed skin (``None`` when untimed/off). + + Derived from the manifest's chunk length at attach time; ticks at + chunk granularity (~0.27 s for the shipped 8-frame recipe), which is + plenty for a HUD countdown chip. + """ + remaining = self.skin_chunks_remaining + if remaining is None: + return None + return remaining * self._seconds_per_chunk + + @property + def active_weather_name(self) -> str: + """Return the HUD label of the active weather (``clear`` when off).""" + if self._active_weather is None or self._weather_config is None: + return "clear" + return self._weather_config.weathers[self._active_weather].name + + @property + def weather_chunks_remaining(self) -> int | None: + """Chunks left on the active timed weather (``None`` when untimed/off).""" + if self._weather_config is None or self._active_weather is None: + return None + duration = self._weather_config.duration_chunks + if duration <= 0: + return None + return max(duration - self._weather_hold_chunks, 0) + + @property + def weather_seconds_remaining(self) -> float | None: + """Seconds left on the active timed weather (chunk granularity).""" + remaining = self.weather_chunks_remaining + if remaining is None: + return None + return remaining * self._seconds_per_chunk + + @property + def skin_names(self) -> tuple[str, ...]: + """Selectable skin names (empty when the style ability is off).""" + if not self._config.enabled: + return () + return tuple(skin.name for skin in self._config.skins) + + @property + def weather_names(self) -> tuple[str, ...]: + """Selectable weather names (empty when the weather ability is off).""" + if self._weather_config is None: + return () + return tuple(weather.name for weather in self._weather_config.weathers) + + def attach(self, session: Any) -> None: + """Attach the LoRA + corrector and hook the chunk boundaries. + + Args: + session: A warmed-up ``FlashdreamsWorldModelSession``. Accessing + its pipeline before ``warmup_model()`` raises. + + Raises: + RuntimeError: The manifest enables an acceleration mode the + prompt-swap machinery (or the configured corrector) cannot + ride; the message names the flags to drop. + """ + self._guard_manifest(session.manifest) + frames_per_chunk = getattr(session.manifest, "num_frames_per_block", 8) + fps = getattr(session.manifest, "fps", 30) or 30 + self._seconds_per_chunk = float(frames_per_chunk) / float(fps) + pipeline = session.pipeline + transformer = pipeline.diffusion_model.transformer + self._guard_transformer(transformer) + + mode = self._config.corrector_mode + if mode == "unfused" and self._config.gate_alpha_json is not None: + # The unfused path reads GATE_ALPHA_JSON at _drift_corrector + # import time; the fused dispatch takes per-state profiles + # directly, leaving the module default (photoreal) for the + # base-state corrector. + os.environ["GATE_ALPHA_JSON"] = str(self._config.gate_alpha_json) + + self._transformer = transformer + edit_lora = None + if self._config.enabled and self._config.lora_checkpoint is not None: + from omnidreams.impl._edit_lora import TextEditLoRA + + edit_lora = TextEditLoRA( + transformer.network, str(self._config.lora_checkpoint) + ) + transformer.set_text_edit_lora(edit_lora) + self._lora_attached = True + logger.info(f"[live-edit] deployed {edit_lora.describe()}") + + if self._corrector_enabled(): + if mode == "fused": + self._attach_corrector_fused(pipeline, transformer, edit_lora) + else: + self._attach_corrector(pipeline, transformer) + elif mode == "off" and self._any_corrector_configured(): + logger.info( + "[live-edit] corrector mode 'off': configured corrector " + "checkpoints are ignored; transformer weights stay untouched" + ) + + self._precompute_prompt_embeddings(pipeline) + self.hook_session(session) + skins = ( + [skin.name for skin in self._config.skins] if self._config.enabled else [] + ) + weathers = ( + [weather.name for weather in self._weather_config.weathers] + if self._weather_config is not None + else [] + ) + logger.info( + f"[live-edit] style ability attached skins={skins} weathers={weathers}" + ) + + def attach_v2( + self, + pipeline: Any, + cache: Any, + base_prompt: str, + *, + seconds_per_chunk: float, + ) -> None: + """Attach prompt editing to a direct API-v2 rollout cache. + + Args: + pipeline: Session-shared OmniDreams pipeline. + cache: Session-local autoregressive cache. + base_prompt: Prompt used to initialize the rollout. + seconds_per_chunk: Generated duration of one steady-state chunk. + """ + transformer = pipeline.diffusion_model.transformer + if getattr(transformer, "_optimized_dit_executor", None) is not None: + raise RuntimeError("Live text editing requires a non-native model preset") + self._guard_transformer(transformer) + self._transformer = transformer + self._base_prompt = base_prompt + self._seconds_per_chunk = seconds_per_chunk + edit_lora = None + if self._config.enabled and self._config.lora_checkpoint is not None: + from omnidreams.impl._edit_lora import TextEditLoRA + + edit_lora = TextEditLoRA(transformer.network, self._config.lora_checkpoint) + transformer.set_text_edit_lora(edit_lora) + self._lora_attached = True + if self._corrector_enabled(): + if self._config.corrector_mode == "fused": + self._attach_corrector_fused(pipeline, transformer, edit_lora) + else: + self._attach_corrector(pipeline, transformer) + self._precompute_prompt_embeddings(pipeline) + self._encode_prompt(pipeline, base_prompt) + self._session = _V2PromptSession(pipeline, cache) + self.reset_v2(cache) + + def reset_v2(self, cache: Any) -> None: + """Bind a new V2 cache and reset all live-edit state.""" + if isinstance(self._session, _V2PromptSession): + self._session._cache = cache + self._active_index = None + self._pending_index = _NO_PENDING + self._active_weather = None + self._pending_weather = _NO_PENDING + self._chunks_since_swap = 0 + self._skin_hold_chunks = 0 + self._weather_hold_chunks = 0 + self._active_skin_duration = self._config.skin_duration_chunks + self._pending_skin_duration = None + self._update_corrector(None, None, 0.0) + + def before_v2_chunk(self) -> None: + """Apply queued edits at the model-thread chunk boundary.""" + if self._timed_skin_expired(): + self._pending_index = None + if self._timed_weather_expired(): + self._pending_weather = None + refresh_due = self._reswap_due() + if ( + self._pending_index is not _NO_PENDING + or self._pending_weather is not _NO_PENDING + or refresh_due + ): + self._apply_pending(refresh=refresh_due) + + def after_v2_chunk(self) -> None: + """Advance timed ability counters after one generated chunk.""" + if self._active_index is not None or self._active_weather is not None: + self._chunks_since_swap += 1 + if self._active_index is not None: + self._skin_hold_chunks += 1 + if self._active_weather is not None: + self._weather_hold_chunks += 1 + + def _precompute_prompt_embeddings(self, pipeline: Any) -> None: + """Encode every configured swap prompt once at session start. + + A swap's dominant cost is the text-encoder forward inside + ``replace_text`` (450-930 ms at the chunk boundary); the prompts are + all known up front, so encoding them here lets ``_replace_text`` + inject cached embeddings through the pipeline's + ``replace_text_from_embeddings`` and skip the encoder entirely. + + No-op when the pipeline has no resident text encoder (the offload + path releases it); swaps then fall back to ``replace_text``, whose + own assertion reports the missing encoder. + """ + text_encoder = getattr(pipeline, "text_encoder", None) + if text_encoder is None: + logger.warning( + "[live-edit] no resident text encoder; prompt swaps will " + "re-encode per swap (pre-encoding skipped)" + ) + return + prompts: list[str] = [] + if self._config.enabled: + prompts.extend(skin.prompt for skin in self._config.skins) + if self._weather_config is not None: + prompts.extend(weather.prompt for weather in self._weather_config.weathers) + start = time.perf_counter() + for prompt in prompts: + self._encode_prompt(pipeline, prompt) + elapsed_ms = (time.perf_counter() - start) * 1000.0 + logger.info( + f"[live-edit] pre-encoded {len(prompts)} swap prompts in " + f"{elapsed_ms:.0f} ms (swaps now inject cached embeddings)" + ) + + def _encode_prompt(self, pipeline: Any, prompt: str) -> None: + """Cache the ``[B=1, V=1, L, D]`` embeddings of one prompt.""" + if prompt in self._prompt_embeddings: + return + import torch + + text_encoder = getattr(pipeline, "text_encoder", None) + if text_encoder is None: + return + with torch.no_grad(): + self._prompt_embeddings[prompt] = text_encoder([prompt]).unsqueeze(0) + + def request_cycle(self) -> None: + """Queue base -> skin[0] -> skin[1] -> ... -> base for the next chunk. + + Weather is base-world-only, so activating any skin also queues the + weather back to clear (documented state-machine rule: K wins over an + active weather; V is rejected while a skin is active). + + Timed power-up mode (``skin_duration_chunks > 0``) keeps these exact + K semantics: pressing K during an active timed skin cycles to the + NEXT skin with a fresh timer (the last skin cycles to base early). + Chosen over extend/reset-in-place because it keeps K meaning one + thing in both modes, every skin stays reachable mid-power-up, and a + cycle re-lands the swap anyway — so the new skin's timer is + naturally fresh; a dedicated "extend" would add a second behavior + for the same key with no gameplay the cycle doesn't already give. + """ + if not self._config.enabled: + return + current = ( + self._active_index + if self._pending_index is _NO_PENDING + else self._pending_index + ) + if current is None: + self._pending_index = 0 + elif current + 1 < len(self._config.skins): + self._pending_index = current + 1 + else: + self._pending_index = None + # A K press always uses the global duration, even when it races a + # queued mystery-box burst at the same boundary (last request wins). + self._pending_skin_duration = None + if self._pending_index is not None and self._weather_state() is not None: + self._pending_weather = None + logger.info( + "[live-edit] skin activation clears weather (base-only ability)" + ) + + def request_skin_burst(self, name: str, duration_chunks: int) -> str | None: + """Queue a specific skin with a per-activation duration override. + + The mystery-box pickup path: lands at the next chunk boundary + through the exact machinery :meth:`request_cycle` uses, so it + composes with the K key — a burst during a key-held skin behaves + like a K cycle (switch, fresh timer), and rolling the skin that is + already active re-lands the swap with a fresh burst timer. + + Args: + name: Skin name from :attr:`skin_names`. + duration_chunks: Auto-revert after this many chunks (0 = the + granted skin is untimed). + + Returns: + The queued skin name, or ``None`` when the style ability is off + or the name is unknown (logged, never raises: pickups must not + crash the frame loop). + """ + if not self._config.enabled: + return None + names = [skin.name for skin in self._config.skins] + if name not in names: + logger.warning(f"[live-edit] unknown skin burst {name!r}; ignoring") + return None + self._pending_index = names.index(name) + self._pending_skin_duration = max(0, int(duration_chunks)) + if self._weather_state() is not None: + self._pending_weather = None + logger.info("[live-edit] skin burst clears weather (base-only ability)") + return name + + def request_weather(self, name: str) -> bool: + """Queue a specific weather preset (item-pickup path). + + Same base-world-only rule as the V key: rejected while a skin is + active or queued (the caller shows the HUD hint). Re-requesting the + active weather refreshes its timed-weather timer without re-landing + the swap (a same-prompt guided re-swap has a zero guidance + direction — pure 2x cost). + + Returns: + ``True`` when the weather was queued (or its timer refreshed); + ``False`` when rejected (skin active, ability off, unknown name). + """ + if self._weather_config is None: + return False + names = [weather.name for weather in self._weather_config.weathers] + if name not in names: + logger.warning(f"[live-edit] unknown weather {name!r}; ignoring") + return False + if self._skin_state() is not None: + logger.info( + f"[live-edit] weather is base-skin only; ignoring {name} pickup" + ) + return False + self._pending_weather = names.index(name) + return True + + def request_weather_cycle(self) -> None: + """Queue clear -> rain -> snow -> clear for the next chunk. + + Ignored while a skin is active or queued: weather only runs over + the base world (skin+weather combo prompts were dropped 2026-08-20). + """ + if self._weather_config is None: + return + skin_state = self._skin_state() + if skin_state is not None: + logger.info( + "[live-edit] weather is base-skin only; ignoring V " + f"(skin={self._config.skins[skin_state].name})" + ) + return + current = self._weather_state() + if current is None: + self._pending_weather = 0 + elif current + 1 < len(self._weather_config.weathers): + self._pending_weather = current + 1 + else: + self._pending_weather = None + + def _skin_state(self) -> int | None: + """Effective skin index once any pending request lands.""" + if self._pending_index is _NO_PENDING: + return self._active_index + return self._pending_index # type: ignore[return-value] + + def _weather_state(self) -> int | None: + """Effective weather index once any pending request lands.""" + if self._pending_weather is _NO_PENDING: + return self._active_weather + return self._pending_weather # type: ignore[return-value] + + def _corrector_enabled(self) -> bool: + """Whether any drift corrector will actually attach to the session. + + ``corrector_mode == "off"`` disables every corrector even when + checkpoints are configured; ``fused`` needs at least one registered + state beyond ``base``; ``unfused`` rides the single style + checkpoint. + """ + mode = self._config.corrector_mode + if mode == "off": + return False + if mode == "fused": + return self._any_corrector_configured() + return self._config.corrector_checkpoint is not None + + def _any_corrector_configured(self) -> bool: + """Whether any state of the fused dispatch would carry a corrector.""" + weather = self._weather_config + return ( + self._config.corrector_checkpoint is not None + or self._config.base_corrector_checkpoint is not None + or ( + weather is not None + and weather.corrector_gain > 0.0 + and weather.corrector_checkpoint is not None + ) + ) + + def _attach_corrector_fused( + self, pipeline: Any, transformer: Any, edit_lora: Any | None + ) -> None: + """Deploy the CUDA-graph-safe per-state corrector dispatch. + + One pre-merged weight-set family per (base | skin | weather) + state; :meth:`_apply_pending` selects the state at chunk + boundaries. The edit LoRA releases its self-attention projections + to the dispatch, whose skin sets fold the LoRA delta into every + alpha set — one ``copy_`` source carries LoRA + corrector, so the + two mechanisms no longer race on the same weights; the LoRA keeps + toggling only the cross-attention projections. Consequence: while + a skin state is selected, the self-attention LoRA delta stays + applied even after the cross-attention edit window ages out + (the 8-chunk re-swap reopens the skin window before long holds + soften, so the split is invisible in practice). + """ + from types import SimpleNamespace + + from omnidreams.impl._drift_corrector import ( + DriftCorrectorDispatch, + _target_linears, + ) + + dispatch = DriftCorrectorDispatch(SimpleNamespace(pipeline=pipeline)) + lines: list[str] = [] + lora_delta = None + if edit_lora is not None: + network = transformer.network + if hasattr(network, "_orig_mod"): # unwrap torch.compile + network = network._orig_mod + lora_delta = edit_lora.release_targets(_target_linears(network)) + + config = self._config + if config.base_corrector_checkpoint is not None: + # Photoreal corrector over the base world, module-default gate. + lines.append( + dispatch.register_state( + "base", + checkpoint=config.base_corrector_checkpoint, + gain=config.base_corrector_gain, + ) + ) + if config.enabled and ( + config.corrector_checkpoint is not None or lora_delta is not None + ): + lines.append( + dispatch.register_state( + "skin", + checkpoint=config.corrector_checkpoint, + gain=( + config.corrector_gain + if config.corrector_checkpoint is not None + else 0.0 + ), + gate_alpha=config.gate_alpha_json, + lora_delta=lora_delta, + ) + ) + self._corrector_states.add("skin") + weather = self._weather_config + if weather is not None and weather.corrector_gain > 0.0: + ckpt = weather.corrector_checkpoint or config.corrector_checkpoint + if ckpt is not None: + lines.append( + dispatch.register_state( + "weather", + checkpoint=ckpt, + gain=weather.corrector_gain, + gate_alpha=config.gate_alpha_json, + ) + ) + self._corrector_states.add("weather") + self._corrector_states.add("base") + self._dispatch = dispatch + for line in lines: + logger.info(f"[live-edit] fused {line}") + + def _update_corrector( + self, skin: int | None, weather: int | None, gain: float + ) -> None: + """Route the new (skin | weather) state to the corrector backend. + + Fused: select the dispatch state (states without a registration + fall back to ``base``). Unfused: apply the absolute gain via the + scale-gated predict_flow dispatch. + """ + if self._dispatch is not None: + name = ( + "skin" + if skin is not None + else "weather" + if weather is not None + else "base" + ) + if name not in self._corrector_states: + name = "base" + self._dispatch.set_active_corrector(name) + else: + self._set_corrector_gain(gain) + + def _attach_corrector(self, pipeline: Any, transformer: Any) -> None: + """Deploy the unfused corrector behind a per-state gain dispatch. + + Legacy fallback (``corrector_mode="unfused"``): requires the + graph-free pipeline; see :meth:`_attach_corrector_fused` for the + real-time path. + + The dispatch supports three regimes per (skin | weather) state: + the configured style gain rides the validated ``gated_pf`` wrapper + unchanged; gain 0 short-circuits to the bit-clean base forward; any + other gain (e.g. a reduced weather gain) re-derives the per-step + LoRA scale ``alpha*(t) * gain`` here before calling the base + forward — identical math to ``gated_pf`` at a different gain, since + the unfused _LoRALinear wrappers stay installed permanently and + only the scale changes. + """ + from types import SimpleNamespace + + from omnidreams.impl._drift_corrector import ( + _nearest_alpha, + _set_scale, + apply_drift_corrector, + ) + + base_predict_flow = transformer.predict_flow + style_gain = self._config.corrector_gain + summary = apply_drift_corrector( + SimpleNamespace(pipeline=pipeline), + self._config.corrector_checkpoint, + style_gain, + unfused=True, + ) + corrected_predict_flow = transformer.predict_flow + active_gain = [0.0] + + # The unfused deployment installs _LoRALinear wrappers permanently; + # only the predict_flow wrapper re-scales them per step. Dispatching + # to the base predict_flow therefore leaves the LAST scale applied, + # so gain 0 must also zero the LoRA scale (scale == 0 is an exact + # short-circuit in _LoRALinear.forward -> bit-clean base output). + network = transformer.network + if hasattr(network, "_orig_mod"): # unwrap torch.compile + network = network._orig_mod + _set_scale(network, 0.0) + + def dispatched_predict_flow(*args: Any, **kwargs: Any) -> Any: + gain = active_gain[0] + if gain <= 0.0: + return base_predict_flow(*args, **kwargs) + if gain == style_gain: + return corrected_predict_flow(*args, **kwargs) + timestep = kwargs.get("timestep", args[1] if len(args) > 1 else None) + t = float(timestep.reshape(-1).max()) + _set_scale(network, _nearest_alpha(t) * gain) + return base_predict_flow(*args, **kwargs) + + def set_gain(value: float) -> None: + active_gain[0] = float(value) + if active_gain[0] <= 0.0: + _set_scale(network, 0.0) + + transformer.predict_flow = dispatched_predict_flow + self._set_corrector_gain = set_gain + logger.info(f"[live-edit] {summary} (dispatch-gated, gain 0)") + + def hook_session(self, session: Any) -> None: + """Wrap the session's chunk boundaries (model-free; CPU-testable). + + ``attach`` calls this after deploying the LoRA/corrector; tests can + call it directly with a fake session to exercise the swap protocol. + """ + self._session = session + original_start = session.start + original_continue = session.continue_generation + + def start(initial_rgb: Any, condition_frames: Any, prompt: str) -> Any: + self._base_prompt = prompt + # The base (revert) prompt is only known here; encode it once so + # reverting to base/clear is also an embedding injection. + self._encode_prompt(session.pipeline, prompt) + self._active_index = None + self._pending_index = _NO_PENDING + self._active_weather = None + self._pending_weather = _NO_PENDING + self._chunks_since_swap = 0 + self._skin_hold_chunks = 0 + self._weather_hold_chunks = 0 + self._active_skin_duration = self._config.skin_duration_chunks + self._pending_skin_duration = None + self._update_corrector(None, None, 0.0) + return original_start(initial_rgb, condition_frames, prompt) + + def continue_generation(condition_frames: Any) -> Any: + if self._timed_skin_expired(): + # Auto-revert rides the exact K-cycle revert path: a queued + # None lands as the plain base swap (guidance 1.0/0) and + # moves the corrector dispatch back to the base state. A + # user K press queued this boundary wins (it re-lands a + # fresh swap with a fresh timer). + self._pending_index = None + logger.info( + f"[live-edit] timed skin {self.active_skin_name} expired " + f"after {self._skin_hold_chunks} chunks; reverting to base" + ) + if self._timed_weather_expired(): + # Rides the V-cycle wrap-to-clear path; the landing is + # GUIDED (see _apply_pending) because clear is itself a + # weather transition. A user request queued this boundary + # wins. + self._pending_weather = None + logger.info( + f"[live-edit] timed weather {self.active_weather_name} " + f"expired after {self._weather_hold_chunks} chunks; " + "landing clear" + ) + refresh_due = self._reswap_due() + if ( + self._pending_index is not _NO_PENDING + or self._pending_weather is not _NO_PENDING + or refresh_due + ): + # The validated swap semantics are finalize -> replace_text + # -> generate (otherwise finalize re-commits the previous + # chunk under the NEW text, an implicit recache). The swap + # path (:meth:`_replace_text`) flushes the adapter's deferred + # finalize before every swap, so no flush is needed here. + self._apply_pending(refresh=refresh_due) + result = original_continue(condition_frames) + if self._active_index is not None or self._active_weather is not None: + self._chunks_since_swap += 1 + if self._active_index is not None: + self._skin_hold_chunks += 1 + if self._active_weather is not None: + self._weather_hold_chunks += 1 + return result + + session.start = start + session.continue_generation = continue_generation + + def _timed_skin_expired(self) -> bool: + """Whether the active timed skin is due its auto-revert to base. + + False when the mode is off (duration 0), no skin is active, or the + user already queued a request this boundary (their cycle wins). + """ + duration = self._active_skin_duration + return ( + duration > 0 + and self._active_index is not None + and self._pending_index is _NO_PENDING + and self._skin_hold_chunks >= duration + ) + + def _timed_weather_expired(self) -> bool: + """Whether the active timed weather is due its guided clear landing. + + False when the mode is off (duration 0), no weather is active, or a + user request already queued this boundary (their request wins; a + queued skin also clears weather through its own path). + """ + if self._weather_config is None: + return False + duration = self._weather_config.duration_chunks + return ( + duration > 0 + and self._active_weather is not None + and self._pending_weather is _NO_PENDING + and self._pending_index is _NO_PENDING + and self._weather_hold_chunks >= duration + ) + + def _reswap_due(self) -> bool: + """Whether the active edit window is due a duty-cycle refresh. + + Skins refresh on the style ``reswap_interval_chunks`` (the LoRA + realizes the window single-branch, so a refresh is free per chunk). + Weather holds land-then-release: the landing window expires and the + state persists through KV history + the swapped text, so weather + only refreshes when a maintenance interval is explicitly configured + (each pulse costs ``maintain_chunks`` chunks at 2x). + """ + if self._active_index is not None: + interval = self._config.reswap_interval_chunks + duration = self._active_skin_duration + if 0 < duration <= interval: + # Timed skin expires at or before the first refresh would + # fire — the re-swap would only ever land on the revert + # boundary, so skip the duty cycle entirely. + return False + elif self._active_weather is not None and self._weather_config is not None: + interval = self._weather_config.maintain_interval_chunks + else: + return False + return interval > 0 and self._chunks_since_swap >= interval + + def _apply_pending(self, *, refresh: bool = False) -> None: + """Swap the prompt between chunks when a request or refresh is due.""" + pending_skin = self._pending_index + pending_weather = self._pending_weather + pending_duration = self._pending_skin_duration + self._pending_index = _NO_PENDING + self._pending_weather = _NO_PENDING + self._pending_skin_duration = None + target_skin = ( + self._active_index if pending_skin is _NO_PENDING else pending_skin + ) + target_weather = ( + self._active_weather if pending_weather is _NO_PENDING else pending_weather + ) + changed = ( + target_skin != self._active_index or target_weather != self._active_weather + ) + # An explicit request for the already-active skin (mystery box + # rolling it again) re-lands the swap with a fresh timer; the same + # request for the active weather only refreshes the timer (a + # same-prompt guided re-swap has a zero guidance direction). + explicit_same_skin = ( + pending_skin is not _NO_PENDING + and pending_skin is not None + and pending_skin == self._active_index + ) + if ( + pending_weather is not _NO_PENDING + and pending_weather is not None + and pending_weather == self._active_weather + ): + self._weather_hold_chunks = 0 + if ( + not changed + and not explicit_same_skin + and (not refresh or (target_skin is None and target_weather is None)) + ): + return + session = self._session + if session is None or session._cache is None or self._base_prompt is None: + logger.warning("[live-edit] prompt swap requested before first chunk") + return + + target = compose_swap_target( + base_prompt=self._base_prompt, + skin=None if target_skin is None else self._config.skins[target_skin], + weather=( + None + if target_weather is None or self._weather_config is None + else self._weather_config.weathers[target_weather] + ), + style_config=self._config, + weather_config=self._weather_config, + lora_available=self._lora_attached, + ) + if ( + self._active_weather is not None + and target_skin is None + and target_weather is None + and self._weather_config is not None + ): + # Weather -> clear is itself a weather transition: a plain swap + # leaves the precipitation running on KV-history momentum, so + # the clear lands GUIDED (both the timed auto-revert and the + # V-cycle wrap). Accumulated scene change (wet roads, settled + # snow) is NOT undone — it decays naturally, by design. + target = replace( + target, + guidance_scale=self._weather_config.guidance_scale, + guidance_chunks=self._weather_config.clear_guidance_chunks, + ) + if not changed and target_weather is not None and target_skin is None: + # Weather maintenance pulse. A same-prompt re-swap would clone + # its "old" KV from buffers that already hold the weather text, + # collapsing the guidance direction to zero (paying 2x for + # nothing); rebase to the base prompt first so the pulse pushes + # weather-minus-base again, for maintain_chunks chunks only. + assert self._weather_config is not None + rebase = compose_swap_target( + base_prompt=self._base_prompt, + skin=None, + weather=None, + style_config=self._config, + weather_config=self._weather_config, + lora_available=self._lora_attached, + ) + self._replace_text(session, rebase) + target = replace( + target, guidance_chunks=self._weather_config.maintain_chunks + ) + self._replace_text(session, target) + verb = "re-swap" if not (changed or explicit_same_skin) else "state ->" + if target_skin != self._active_index or explicit_same_skin: + # Fresh activation (or skin->skin cycle, or an explicit re-roll + # of the active skin): a timed skin starts a fresh timer with + # its per-activation duration. A duty-cycle re-swap keeps the + # timer running. + self._skin_hold_chunks = 0 + self._active_skin_duration = ( + self._config.skin_duration_chunks + if pending_duration is None + else pending_duration + ) + if target_weather != self._active_weather: + self._weather_hold_chunks = 0 + self._active_index = target_skin + self._active_weather = target_weather + self._chunks_since_swap = 0 + self._update_corrector(target_skin, target_weather, target.corrector_gain) + logger.info( + f"[live-edit] {verb} skin={self.active_skin_name} " + f"weather={self.active_weather_name}" + ) + + def _replace_text(self, session: Any, target: Any) -> None: + """Issue the swap, bypassing the edit LoRA for two-prompt windows. + + A guided ``replace_text`` routes through the pre-merged text-edit + LoRA whenever one is attached; weather-only windows must instead run + the two-prompt KV-snapshot guidance (the LoRA was trained on the + style prompts), so the LoRA is detached around the call. Plain swaps + (scale 1.0) never open a LoRA window and need no bypass. + + Prompts pre-encoded at attach time (see + :meth:`_precompute_prompt_embeddings`) inject their cached + embeddings through ``replace_text_from_embeddings`` — no text + encoder forward at the boundary; anything else falls back to the + session's encode-per-swap ``replace_prompt``. + + Both paths flush the adapter's deferred chunk finalize first: + finalize must run under the OLD text. ``session.replace_prompt`` + does its own flush; the embeddings fast path (no upstream + equivalent yet) flushes here before touching the cache. + """ + transformer = self._transformer + bypass_lora = ( + not target.use_lora + and target.guidance_scale != 1.0 + and transformer is not None + and getattr(transformer, "_text_edit_lora", None) is not None + ) + edit_lora = None + if bypass_lora: + edit_lora = transformer._text_edit_lora + transformer.set_text_edit_lora(None) + embeddings = self._prompt_embeddings.get(target.prompt) + replace_from_embeddings = getattr( + session.pipeline, "replace_text_from_embeddings", None + ) + cached = embeddings is not None and callable(replace_from_embeddings) + start = time.perf_counter() + try: + if cached: + self._flush_pending_finalize(session) + import torch + + with torch.no_grad(): + replace_from_embeddings( + session._cache, + embeddings, + guidance_scale=target.guidance_scale, + guidance_chunks=target.guidance_chunks, + recache_last_chunk=False, + ) + else: + session.replace_prompt( + target.prompt, + guidance_scale=target.guidance_scale, + guidance_chunks=target.guidance_chunks, + ) + finally: + if bypass_lora: + transformer.set_text_edit_lora(edit_lora) + elapsed_ms = (time.perf_counter() - start) * 1000.0 + logger.info( + f"[live-edit] swap issued cached_embeddings={cached} " + f"swap_ms={elapsed_ms:.1f}" + ) + + @staticmethod + def _flush_pending_finalize(session: Any) -> None: + """Flush the adapter's deferred chunk finalize under the OLD text. + + Only the embeddings fast path needs this; ``session.replace_prompt`` + performs the same flush itself. + """ + pending_finalize = getattr(session, "_pending_finalization_index", None) + if pending_finalize is None or session._cache is None: + return + import torch + + with torch.no_grad(): + session.pipeline.finalize(pending_finalize, session._cache) + session._pending_finalization_index = None + + def _guard_manifest(self, manifest: Any) -> None: + """Reject native-DIT manifests with a message naming the fix. + + Prompt-swap abilities fundamentally need the Python transformer + forward today, for two independent reasons (verified 2026-08-21): + + - ``CosmosTransformer.replace_text_embeddings`` raises + ``NotImplementedError`` under the native optimized-DiT executor + (the cross-attention KV rebuild is not wired for it), and every + skin/weather swap goes through it; + - the pre-merged ``TextEditLoRA`` toggles by ``copy_``-ing into the + ``nn.Linear`` weights, but the native fp8 executor quantizes a + one-time weight snapshot (and then releases the PyTorch network), + so those toggles would silently never reach the native forward. + + Correctors add a third reason (same snapshot bypass), but they are + gated separately: with no corrector enabled the message does not + ask the user to change corrector flags. Abilities that never touch + the model (coins, obstacle without ``--live-edit-obstacle-guide-scale``) + do not construct this ability and stay perf-neutral under native + DIT. + """ + if getattr(manifest, "native_dit_acceleration", "disabled") in ( + "disabled", + None, + False, + ): + return + flags = [] + if self._config.enabled: + flags.append("--live-edit-style") + if self._weather_config is not None: + flags.append("--live-edit-weather") + corrector_note = ( + " The configured drift corrector also merges into the PyTorch " + "network's weights, which the native executor bypasses " + "(--live-edit-corrector-mode off would disable it, but the " + "prompt-swap limitation above still applies)." + if self._corrector_enabled() + else "" + ) + raise RuntimeError( + "Prompt-swap live-edit abilities need the Python transformer " + "forward: replace_text_embeddings is not wired for the native " + "optimized-DiT executor, and the pre-merged text-edit LoRA " + "toggles weights the native fp8 snapshot never re-reads. Either " + f"drop {' / '.join(flags)} (coins and other pixel-only " + "abilities stay available and perf-neutral), or set " + "native_dit_acceleration: disabled in the world-model manifest." + + corrector_note + ) + + def _guard_transformer(self, transformer: Any) -> None: + """Reject built pipeline configs the unfused corrector cannot ride. + + Fused mode needs no rejection (the per-state dispatch copies into + fixed parameter storages, which captured CUDA graphs and the + compiled network read by address — the whole point of the mode), + and ``off`` deploys no corrector at all. + + The manifest only carries ``compile_net`` / ``native_dit_*``; the + transformer's ``use_cuda_graph`` defaults to True in the recipe, so + it must be checked on the live config. CUDA-graph capture would bake + the corrector's scale-0 short-circuit (and the predict_flow dispatch + runs outside any captured graph), and ``compile_network`` re-traces + around the _LoRALinear wrap. + """ + if self._config.corrector_mode != "unfused": + return + config = getattr(transformer, "config", None) + if config is None: + return + needs_corrector = self._config.corrector_checkpoint is not None + if needs_corrector and getattr(config, "use_cuda_graph", False): + raise RuntimeError( + "live_edit.style with the drift corrector requires " + "use_cuda_graph=False on the transformer (unfused LoRA " + "scale-gating is not graph-safe)." + ) + if needs_corrector and getattr(config, "compile_network", False): + raise RuntimeError( + "live_edit.style with the drift corrector requires " + "compile_network=False (bring-up parity with the validated " + "smoke-harness configuration)." + ) + + +def attach_style_ability(session: Any, config: LiveEditStyleConfig) -> StyleAbility: + """Create and attach the style ability to a warmed-up session.""" + ability = StyleAbility(config) + ability.attach(session) + return ability + + +def install_style_ability_on_backend(backend: Any, ability: StyleAbility) -> None: + """Prepare a ``WorldModelRenderBackend`` for the style ability. + + Called at the composition root BEFORE model warmup starts: + + - in ``unfused`` corrector mode only, replaces the backend's session + with one whose pipeline factory disables CUDA graphs (the manifest + has no such knob, and the unfused corrector's scale gating is not + graph-safe). The default ``fused`` mode keeps the accelerated + pipeline (compile_network + use_cuda_graph) untouched; + - defers :meth:`StyleAbility.attach` until the session's own + ``warmup_model`` has built the pipeline (attach needs the live + transformer), by wrapping that method. + """ + session = getattr(backend, "_session", None) + if session is None: + raise ValueError( + "--live-edit-style requires the omnidreams world-model backend " + "(the raster backend has no flashdreams session)." + ) + needs_graph_free = ( + ability._config.corrector_checkpoint is not None + and ability._config.corrector_mode == "unfused" + ) + if needs_graph_free and not getattr(session, "_live_edit_cuda_graph_free", False): + session = _corrector_safe_session(session) + backend._session = session + original_warmup = session.warmup_model + + def warmup_and_attach() -> None: + original_warmup() + ability.attach(session) + + session.warmup_model = warmup_and_attach + + +def _corrector_safe_session(session: Any) -> Any: + """Rebuild a not-yet-warmed session with CUDA graphs disabled. + + Mirrors the validated bring-up factory: same manifest / profile / + offload / postprocess settings, but the transformer is built with + ``use_cuda_graph=False`` so the unfused drift corrector's per-step LoRA + scale gating stays outside any captured graph. + """ + from omnidreams_game_engine.world_model.flashdreams_adapter import ( + FlashdreamsWorldModelSession, + _build_pipeline_config, + _setup_pipeline_from_config, + ) + + def cuda_graph_free_factory(manifest: Any, profile: Any) -> Any: + from flashdreams.infra.config import derive_config + + config = _build_pipeline_config(manifest, profile) + config = derive_config( + config, diffusion_model={"transformer": {"use_cuda_graph": False}} + ) + return _setup_pipeline_from_config(config, manifest) + + rebuilt = FlashdreamsWorldModelSession( + session.manifest, + profile=session._profile_config, + offload_text_encoder=session._offload_text_encoder, + pipeline_factory=cuda_graph_free_factory, + postprocess=session._postprocess, + ) + # Marker so cooperating installers (obstacle guidance) skip a re-swap. + rebuilt._live_edit_cuda_graph_free = True + return rebuilt diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py new file mode 100644 index 000000000..49d805c37 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Weather events: composing the prompt swap for the (skin | weather) state. + +There is no weather LoRA. Weather uses the plain two-prompt edit-guidance +mechanism (the PR #431 ``replace_text`` path: the old prompt anchors the +scene, the flow is pushed along the new-minus-old text direction) to LAND +the state (guidance scale 2.5 over a short landing window), then holds +unguided: the weather persists through the KV history and the swapped +cross-attention text, so the steady-state cost of an active weather is a +single forward per step ("land-then-release", A/B'd 2026-08-21). Because +the transformer +routes any guided swap through the pre-merged text-edit LoRA when one is +attached, weather swaps must *bypass* the LoRA (it was trained on the four +style prompts, not weather); :class:`~.style_ability.StyleAbility` detaches +it around the ``replace_text`` call when ``use_lora`` is False. + +Weather is a **base-world-only** ability (design decision 2026-08-20): +skin+weather combo prompts produced rain that was not attributable as rain +under the neon skins, so :class:`~.style_ability.StyleAbility` rejects the +weather key while a skin is active and clears weather when a skin is +activated. Exactly one of (skin, weather) is active at any time: + +=========== ========== ============================== ======== ========= +skin weather prompt LoRA corrector +=========== ========== ============================== ======== ========= +none none base scene prompt (plain 1/0) off off +active none skin prompt on style gain +none active weather standalone prompt BYPASS weather gain* +=========== ========== ============================== ======== ========= + +``*`` the corrector gate profile was calibrated on style v6, not weather; +``LiveEditWeatherConfig.corrector_gain`` defaults to 0 (off) and can be +raised to a small absolute gain (e.g. 0.10) if base-world drift under a +long weather window proves worse than a mild corrector wash. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from crazy_robotaxi.live_edit.config import ( + LiveEditStyleConfig, + LiveEditWeatherConfig, + StyleSkin, + WeatherPreset, +) + + +@dataclass(frozen=True) +class SwapTarget: + """One fully-resolved ``replace_text`` call plus its side policies.""" + + prompt: str + """Full prompt to swap in.""" + + guidance_scale: float + """``replace_text`` guidance scale (1.0 = plain swap).""" + + guidance_chunks: int + """``replace_text`` guidance window length.""" + + use_lora: bool + """Whether the pre-merged text-edit LoRA may realize the window. False + forces the two-prompt KV-snapshot guidance (LoRA detached for the call).""" + + corrector_gain: float + """Absolute style-drift-corrector gain for this state (0 = corrector + off, an exact base-forward short-circuit).""" + + +def compose_swap_target( + *, + base_prompt: str, + skin: StyleSkin | None, + weather: WeatherPreset | None, + style_config: LiveEditStyleConfig, + weather_config: LiveEditWeatherConfig | None, + lora_available: bool, +) -> SwapTarget: + """Resolve the single active prompt for a (skin | weather) state. + + Raises: + ValueError: Both a skin and a weather are requested — weather is + base-world-only; the :class:`~.style_ability.StyleAbility` + state machine must never produce this combination. + """ + if skin is not None and weather is not None: + raise ValueError("weather is base-world-only and cannot compose with a skin") + if skin is None and weather is None: + # Plain swap back to the base world; guidance 1.0/0 also deactivates + # the pre-merged edit LoRA. + return SwapTarget( + prompt=base_prompt, + guidance_scale=1.0, + guidance_chunks=0, + use_lora=False, + corrector_gain=0.0, + ) + if skin is not None: + return SwapTarget( + prompt=skin.prompt, + guidance_scale=style_config.guidance_scale, + guidance_chunks=style_config.guidance_chunks, + use_lora=lora_available, + corrector_gain=style_config.corrector_gain, + ) + assert weather_config is not None, "weather state requires a weather config" + return SwapTarget( + prompt=weather.prompt, + guidance_scale=weather_config.guidance_scale, + guidance_chunks=weather_config.guidance_chunks, + use_lora=False, + corrector_gain=weather_config.corrector_gain, + ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml index a9d265ac5..e43b35d5a 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml @@ -349,6 +349,21 @@ roads: - {id: far_east_loop_west, from: far_east_bend_northwest, to: far_east_bend_southwest, profile: local} - {id: far_east_loop_exit, from: far_east_bend_southwest, to: far_east_north_crossing, profile: local} +race_courses: + - id: eastbound-boulevard-sprint + start: spawn_arterial + checkpoints: + - central_arterial_crossing + - diagonal_arterial_crossing + - arterial_merge_crossing + - arterial_crossing_550 + - arterial_crossing_710 + - arterial_crossing_895 + - arterial_crossing_1082 + - east_arterial_end + lap_count: 0 + checkpoint_markers: true + traffic_count: 100 traffic: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml new file mode 100644 index 000000000..53b4d053b --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml @@ -0,0 +1,180 @@ +schema_version: 1 +id: crazy-robotaxi-grand-prix-demo +name: Demo Grand Prix + +compiler: + sample_spacing_m: 1.5 + ground_margin_m: 30.0 + intersection_connector_samples: 8 + +profiles: + raceway: + lane_width_m: 5.5 + curb_offset_m: 1.5 + lanes: [backward, backward, forward, forward] + speed_limit_mps: 24.0 + lane_marking: {style: DASHED_SINGLE, color: WHITE} + divider_markings: + - {style: DASHED_SINGLE, color: WHITE} + - {style: DASHED_SINGLE, color: WHITE} + - {style: DASHED_SINGLE, color: WHITE} + +nodes: + - {id: start_finish, type: road_joint, pose: {x_m: 0, y_m: -160}} + - {id: turn_one_entry, type: road_joint, pose: {x_m: 210, y_m: -160}} + - {id: east_short_entry, type: road_joint, pose: {x_m: 290, y_m: -90}} + - {id: infield_turn_entry, type: road_joint, pose: {x_m: 290, y_m: -50}} + - {id: infield_straight_entry, type: road_joint, pose: {x_m: 230, y_m: 20}} + - {id: hairpin_entry, type: road_joint, pose: {x_m: 90, y_m: 20}} + - {id: hairpin_exit, type: road_joint, pose: {x_m: 100, y_m: 140}} + - {id: north_sweeper_entry, type: road_joint, pose: {x_m: 220, y_m: 140}} + - {id: top_straight_entry, type: road_joint, pose: {x_m: 150, y_m: 250}} + - {id: esses_entry, type: road_joint, pose: {x_m: -60, y_m: 250}} + - {id: esses_exit, type: road_joint, pose: {x_m: -160, y_m: 50}} + - {id: west_hairpin_entry, type: road_joint, pose: {x_m: -270, y_m: 50}} + - {id: west_hairpin_exit, type: road_joint, pose: {x_m: -270, y_m: -160}} + +roads: + - id: start_finish_straight + from: west_hairpin_exit + to: start_finish + profile: raceway + + - id: main_straight + from: start_finish + to: turn_one_entry + profile: raceway + + - id: turn_one + from: turn_one_entry + to: east_short_entry + profile: raceway + bezier: + - control_points: + - {x_m: 255, y_m: -160} + - {x_m: 290, y_m: -125} + end: {x_m: 290, y_m: -90} + + - id: east_short + from: east_short_entry + to: infield_turn_entry + profile: raceway + + - id: infield_turn + from: infield_turn_entry + to: infield_straight_entry + profile: raceway + bezier: + - control_points: + - {x_m: 290, y_m: -15} + - {x_m: 265, y_m: 20} + end: {x_m: 230, y_m: 20} + + - id: infield_straight + from: infield_straight_entry + to: hairpin_entry + profile: raceway + + - id: infield_hairpin + from: hairpin_entry + to: hairpin_exit + profile: raceway + bezier: + - control_points: + - {x_m: 40, y_m: 20} + - {x_m: 20, y_m: 45} + end: {x_m: 20, y_m: 75} + - control_points: + - {x_m: 20, y_m: 110} + - {x_m: 55, y_m: 140} + end: {x_m: 100, y_m: 140} + + - id: back_straight + from: hairpin_exit + to: north_sweeper_entry + profile: raceway + + - id: north_sweeper + from: north_sweeper_entry + to: top_straight_entry + profile: raceway + bezier: + - control_points: + - {x_m: 290, y_m: 140} + - {x_m: 300, y_m: 250} + end: {x_m: 150, y_m: 250} + + - id: top_straight + from: top_straight_entry + to: esses_entry + profile: raceway + + - id: technical_esses + from: esses_entry + to: esses_exit + profile: raceway + bezier: + - control_points: + - {x_m: -105, y_m: 250} + - {x_m: -155, y_m: 225} + end: {x_m: -155, y_m: 185} + - control_points: + - {x_m: -155, y_m: 145} + - {x_m: -90, y_m: 145} + end: {x_m: -80, y_m: 110} + - control_points: + - {x_m: -70, y_m: 75} + - {x_m: -120, y_m: 50} + end: {x_m: -160, y_m: 50} + + - id: west_straight + from: esses_exit + to: west_hairpin_entry + profile: raceway + + - id: west_hairpin + from: west_hairpin_entry + to: west_hairpin_exit + profile: raceway + bezier: + - control_points: + - {x_m: -330, y_m: 50} + - {x_m: -365, y_m: 5} + end: {x_m: -365, y_m: -55} + - control_points: + - {x_m: -365, y_m: -115} + - {x_m: -330, y_m: -160} + end: {x_m: -270, y_m: -160} + +race_courses: + - id: grand-prix + start: start_finish + checkpoints: + - turn_one + - east_short + - infield_turn + - infield_straight + - infield_hairpin + - back_straight + - north_sweeper + - top_straight + - technical_esses + - west_straight + - west_hairpin + - start_finish_straight + lap_count: 1 + checkpoint_markers: true + +spawns: + - id: race_start + road: start_finish_straight + lane: 2 + distance_m: 220 + variants: + default: + image: package://omnidreams_game_engine/screenshot.jpg + prompt: >- + A forward-facing view from a taxi on a wide closed Formula-style + road circuit in daylight, with varied-radius corners, technical + esses, hairpins, safety barriers, grandstands, sponsor banners, + and a clear start-finish straight. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml index 664411c9b..bc0d343fa 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml @@ -86,6 +86,20 @@ roads: to: dead_end profile: neighborhood +race_courses: + - id: neighborhood-loop + start: neighborhood_loop_southwest + checkpoints: + - hub + - neighborhood_loop_southeast + - neighborhood_loop_northeast_corner + - neighborhood_loop_north + - neighborhood_loop_northwest_corner + - neighborhood_loop_west + - neighborhood_loop_southwest_corner + lap_count: 3 + checkpoint_markers: true + spawns: - id: taxi_start road: neighborhood_loop_southwest diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml new file mode 100644 index 000000000..7e77d5a5d --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml @@ -0,0 +1,104 @@ +schema_version: 1 +id: crazy-robotaxi-demo-race-track +name: Minimal Raceway + +compiler: + sample_spacing_m: 2.0 + ground_margin_m: 25.0 + intersection_connector_samples: 8 + +profiles: + raceway: + lane_width_m: 5.5 + curb_offset_m: 1.5 + lanes: [backward, backward, forward, forward] + speed_limit_mps: 24.0 + lane_marking: {style: DASHED_SINGLE, color: WHITE} + divider_markings: + - {style: DASHED_SINGLE, color: WHITE} + - {style: DASHED_SINGLE, color: WHITE} + - {style: DASHED_SINGLE, color: WHITE} + +nodes: + - {id: start_finish, type: road_joint, pose: {x_m: 0, y_m: -100}} + - {id: east_curve_entry, type: road_joint, pose: {x_m: 150, y_m: -100}} + - {id: east_curve_exit, type: road_joint, pose: {x_m: 150, y_m: 100}} + - {id: north_midpoint, type: road_joint, pose: {x_m: 0, y_m: 100}} + - {id: west_curve_entry, type: road_joint, pose: {x_m: -150, y_m: 100}} + - {id: west_curve_exit, type: road_joint, pose: {x_m: -150, y_m: -100}} + +roads: + - id: start_finish_straight + from: west_curve_exit + to: start_finish + profile: raceway + + - id: south_straight + from: start_finish + to: east_curve_entry + profile: raceway + + - id: east_curve + from: east_curve_entry + to: east_curve_exit + profile: raceway + bezier: + - control_points: + - {x_m: 194.183, y_m: -100} + - {x_m: 230, y_m: -55.228} + end: {x_m: 230, y_m: 0} + - control_points: + - {x_m: 230, y_m: 55.228} + - {x_m: 194.183, y_m: 100} + end: {x_m: 150, y_m: 100} + + - id: north_straight + from: east_curve_exit + to: north_midpoint + profile: raceway + + - id: back_straight + from: north_midpoint + to: west_curve_entry + profile: raceway + + - id: west_curve + from: west_curve_entry + to: west_curve_exit + profile: raceway + bezier: + - control_points: + - {x_m: -194.183, y_m: 100} + - {x_m: -230, y_m: 55.228} + end: {x_m: -230, y_m: 0} + - control_points: + - {x_m: -230, y_m: -55.228} + - {x_m: -194.183, y_m: -100} + end: {x_m: -150, y_m: -100} + +race_courses: + - id: demo-oval + start: start_finish + checkpoints: + - east_curve_entry + - east_curve + - east_curve_exit + - north_midpoint + - west_curve_entry + - west_curve + - west_curve_exit + lap_count: 1 + checkpoint_markers: true + +spawns: + - id: race_start + road: start_finish_straight + lane: 2 + distance_m: 90 + variants: + default: + image: package://omnidreams_game_engine/screenshot.jpg + prompt: >- + A forward-facing view from a taxi on a wide closed urban race + circuit in daylight, with sweeping paved curves, safety barriers, + grandstands, sponsor banners, and a clear start-finish straight. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/physics.py b/apps/crazy_robotaxi/crazy_robotaxi/physics.py index bea49319a..44e51c727 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/physics.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/physics.py @@ -11,6 +11,9 @@ import numpy as np from loguru import logger from ludus_renderer import PhysicsObjectGraph, RigidBodyModel +from omnidreams_game_engine.simulation.actor_controller import ( + PhysicsActorController, +) from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld from omnidreams_game_engine.types import ( DriverCommand, @@ -51,6 +54,7 @@ def __init__( vehicle: TaxiVehicleConfig, *, curb_segments_world: np.ndarray | None = None, + actor_controllers: tuple[PhysicsActorController, ...] = (), ) -> None: curb_segments = np.asarray( curb_segments_world @@ -70,7 +74,9 @@ def __init__( else None ), static_barrier_restitution=vehicle.curb_collision_restitution, + actor_controllers=actor_controllers, ) + self._has_external_actor_controllers = bool(actor_controllers) self._taxi_vehicle = vehicle logger.info( "[crazy-robotaxi] Taxi physics active: app-authoritative heading, " @@ -83,6 +89,8 @@ def synchronize_window( self, center_xy_m: np.ndarray, timestamp_us: int | None = None, + *, + force_controller_refresh: bool = False, ) -> bool: """Refresh Taxi collision topology only when its spatial window changes. @@ -93,6 +101,15 @@ def synchronize_window( thousands of static curb segments. Passing no timestamp retains spatial recentering and traffic additions/removals without that redundant scan. """ + if ( + getattr(self, "_has_external_actor_controllers", False) + or force_controller_refresh + ): + return super().synchronize_window( + center_xy_m, + timestamp_us, + force_controller_refresh=force_controller_refresh, + ) del timestamp_us center = np.asarray(center_xy_m, dtype=np.float32) if center.shape != (2,): diff --git a/apps/crazy_robotaxi/crazy_robotaxi/race.py b/apps/crazy_robotaxi/crazy_robotaxi/race.py new file mode 100644 index 000000000..bff642c58 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/race.py @@ -0,0 +1,656 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Race-mode progression and leaderboard state.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +import numpy as np +import numpy.typing as npt +from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.contracts import GameUpdate +from omnidreams_game_engine.game_map import GameMapRaceCourse, ResolvedGameMap +from omnidreams_game_engine.types import TrajectoryChunk, VehicleState +from shapely.geometry import LineString, Point, Polygon + +from crazy_robotaxi.rules import relative_target_bearing_rad +from crazy_robotaxi.high_scores import ( + RaceTimeEntry, + RaceTimeStore, + format_race_time_us, +) + +RaceSessionState = Literal["awaiting_start", "racing", "awaiting_name", "leaderboard"] +RaceTargetKind = Literal["start", "checkpoint"] +RaceEvent = Literal["race_started", "checkpoint", "lap_complete", "race_complete"] + + +@dataclass(frozen=True) +class RaceGameSnapshot: + """Immutable race state published to native and browser HUDs.""" + + map_id: str + """Stable ID of the containing map.""" + + course_id: str + """Selected course ID within the map.""" + + session_state: RaceSessionState + """Current pre-race, racing, name-entry, or leaderboard state.""" + + target_kind: RaceTargetKind + """Kind of gate the player must enter next.""" + + target_element_id: str + """Map element whose surface is the active gate.""" + + target_xyz_m: tuple[float, float, float] + """Fixed midpoint of the active gate in world coordinates.""" + + gate_start_xyz_m: tuple[float, float, float] + """First fixed endpoint of the active gate line.""" + + gate_end_xyz_m: tuple[float, float, float] + """Second fixed endpoint of the active gate line.""" + + checkpoint_markers: bool + """Whether presenters display camera-view gate markers.""" + + distance_m: float + """Shortest XY distance from the ego to the active gate.""" + + relative_bearing_rad: float + """Bearing from ego heading toward the active gate.""" + + checkpoint_index: int + """Zero-based active checkpoint index.""" + + checkpoint_count: int + """Number of ordered checkpoints in the course.""" + + completed_laps: int + """Number of laps closed by returning to the start gate.""" + + lap_count: int + """Required laps, or zero for a point-to-point race.""" + + elapsed_time_us: int + """Current total race time in integer microseconds.""" + + best_time_us: int | None + """Fastest persisted total time for this map/course pair.""" + + final_time_us: int | None = None + """Frozen finished time, or ``None`` while the race is active.""" + + leaderboard: tuple[RaceTimeEntry, ...] = () + """Map- and course-specific top times.""" + + high_score_rank: int | None = None + """Prospective or recorded rank for the finished race.""" + + event: RaceEvent | None = None + """Progress event emitted by the latest processed pose.""" + + game_mode: Literal["race"] = "race" + """Mode discriminator consumed by presenter clients.""" + + @property + def phase(self) -> Literal["race"]: + """Return the compatibility phase used by shared target projection.""" + return "race" + + @property + def target_radius_m(self) -> float: + """Return the display radius for the race target marker.""" + return 4.0 + + @property + def target_label(self) -> Literal["START", "CHECKPOINT", "FINISH"]: + """Return the camera-view label for the active race gate.""" + if self.target_kind == "start": + return "START" if self.session_state == "awaiting_start" else "FINISH" + if self.lap_count == 0 and self.checkpoint_index + 1 == self.checkpoint_count: + return "FINISH" + return "CHECKPOINT" + + @property + def pickup_targets_xyz_m(self) -> tuple[tuple[float, float, float], ...]: + """Return no alternate targets for the ordered race course.""" + return () + + def as_dict(self) -> dict[str, object]: + """Return a JSON-serializable representation of the snapshot.""" + return { + "game_mode": self.game_mode, + "map_id": self.map_id, + "course_id": self.course_id, + "session_state": self.session_state, + "target_kind": self.target_kind, + "target_label": self.target_label, + "target_element_id": self.target_element_id, + "target_xyz_m": list(self.target_xyz_m), + "gate_start_xyz_m": list(self.gate_start_xyz_m), + "gate_end_xyz_m": list(self.gate_end_xyz_m), + "checkpoint_markers": self.checkpoint_markers, + "distance_m": self.distance_m, + "relative_bearing_rad": self.relative_bearing_rad, + "checkpoint_index": self.checkpoint_index, + "checkpoint_count": self.checkpoint_count, + "completed_laps": self.completed_laps, + "lap_count": self.lap_count, + "elapsed_time_us": self.elapsed_time_us, + "elapsed_time_s": self.elapsed_time_us / 1_000_000.0, + "elapsed_time": format_race_time_us(self.elapsed_time_us), + "best_time_us": self.best_time_us, + "best_time_s": ( + None if self.best_time_us is None else self.best_time_us / 1_000_000.0 + ), + "best_time": ( + None + if self.best_time_us is None + else format_race_time_us(self.best_time_us) + ), + "final_time_us": self.final_time_us, + "final_time_s": ( + None if self.final_time_us is None else self.final_time_us / 1_000_000.0 + ), + "final_time": ( + None + if self.final_time_us is None + else format_race_time_us(self.final_time_us) + ), + "leaderboard": [entry.as_dict() for entry in self.leaderboard], + "high_score_rank": self.high_score_rank, + "event": self.event, + } + + +class RaceController: + """Advance one ordered map course using swept gate-line activation.""" + + def __init__( + self, + game_map: ResolvedGameMap, + course: GameMapRaceCourse, + initial_state: VehicleState, + time_store: RaceTimeStore, + ) -> None: + self._map_id = game_map.map_id + self._course = course + self._time_store = time_store + surfaces = { + element.element_id: Polygon(element.surface_world[:, :2]) + for element in game_map.elements + } + element_ids = (course.start_element_id, *course.checkpoint_element_ids) + self._surfaces = { + element_id: surfaces[element_id] for element_id in element_ids + } + self._surface_z = { + element.element_id: float(element.surface_world[:, 2].mean()) + for element in game_map.elements + if element.element_id in self._surfaces + } + self._centerlines = { + element_id: tuple( + np.asarray(lane.centerline_world[:, :2], dtype=np.float64) + for lane in game_map.lanes + if lane.element_id == element_id + ) + for element_id in element_ids + } + self._gates, self._gate_directions = self._build_gates() + self._session_state: RaceSessionState = "awaiting_start" + self._target_kind: RaceTargetKind = "start" + self._checkpoint_index = 0 + self._completed_laps = 0 + self._start_timestamp_us: int | None = None + self._elapsed_time_us = 0 + self._final_time_us: int | None = None + self._event: RaceEvent | None = None + self._previous_xy = (initial_state.x_m, initial_state.y_m) + self._leaderboard = time_store.read(game_map.map_id, course.course_id) + self._best_time_us = ( + self._leaderboard[0].elapsed_time_us if self._leaderboard else None + ) + self._high_score_rank: int | None = None + + @property + def is_playing(self) -> bool: + """Return whether simulation should continue advancing.""" + return self._session_state in {"awaiting_start", "racing"} + + def advance_frames( + self, trajectory: TrajectoryChunk, frame_interval_s: float + ) -> tuple[RaceGameSnapshot, ...]: + """Advance checkpoints using each authoritative timestamped pose.""" + if frame_interval_s < 0.0: + raise ValueError("Race frame interval must be non-negative.") + snapshots: list[RaceGameSnapshot] = [] + for state, timestamp_us in zip( + trajectory.vehicle_states, trajectory.timestamps_us, strict=True + ): + if self.is_playing: + self._advance_pose(state, int(timestamp_us)) + self._previous_xy = (state.x_m, state.y_m) + snapshots.append(self.snapshot(state)) + return tuple(snapshots) + + def snapshot(self, state: VehicleState) -> RaceGameSnapshot: + """Return race state relative to the supplied ego pose.""" + target_id = self._target_element_id + gate = self._gates[target_id] + ego = Point(state.x_m, state.y_m) + start_xy, end_xy = gate.coords[0], gate.coords[-1] + target_xyz = ( + (float(start_xy[0]) + float(end_xy[0])) / 2.0, + (float(start_xy[1]) + float(end_xy[1])) / 2.0, + self._surface_z[target_id], + ) + gate_start_xyz = ( + float(start_xy[0]), + float(start_xy[1]), + self._surface_z[target_id], + ) + gate_end_xyz = ( + float(end_xy[0]), + float(end_xy[1]), + self._surface_z[target_id], + ) + distance = float(ego.distance(gate)) + return RaceGameSnapshot( + map_id=self._map_id, + course_id=self._course.course_id, + session_state=self._session_state, + target_kind=self._target_kind, + target_element_id=target_id, + target_xyz_m=target_xyz, + gate_start_xyz_m=gate_start_xyz, + gate_end_xyz_m=gate_end_xyz, + checkpoint_markers=self._course.checkpoint_markers, + distance_m=distance, + relative_bearing_rad=relative_target_bearing_rad( + state.x_m, + state.y_m, + state.yaw_rad, + target_xyz[0], + target_xyz[1], + ), + checkpoint_index=self._checkpoint_index, + checkpoint_count=len(self._course.checkpoint_element_ids), + completed_laps=self._completed_laps, + lap_count=self._course.lap_count, + elapsed_time_us=self._elapsed_time_us, + best_time_us=self._best_time_us, + final_time_us=self._final_time_us, + leaderboard=self._leaderboard, + high_score_rank=self._high_score_rank, + event=self._event, + ) + + def submit_high_score_name(self, name: str) -> None: + """Persist the finished total race time under a validated player name.""" + if self._session_state != "awaiting_name" or self._final_time_us is None: + raise RuntimeError("Race is not waiting for a leaderboard name.") + inserted, self._leaderboard = self._time_store.record( + self._map_id, + self._course.course_id, + name, + self._final_time_us, + ) + self._best_time_us = ( + self._leaderboard[0].elapsed_time_us if self._leaderboard else None + ) + self._high_score_rank = ( + None if inserted is None else 1 + self._leaderboard.index(inserted) + ) + self._session_state = "leaderboard" + + @property + def _target_element_id(self) -> str: + if self._target_kind == "start": + return self._course.start_element_id + return self._course.checkpoint_element_ids[self._checkpoint_index] + + def _advance_pose(self, state: VehicleState, timestamp_us: int) -> None: + self._event = None + if self._start_timestamp_us is not None: + self._elapsed_time_us = max(0, timestamp_us - self._start_timestamp_us) + movement = LineString((self._previous_xy, (state.x_m, state.y_m))) + minimum_hit_distance = 0.0 + while self.is_playing: + hit_distance = _first_gate_hit_distance( + movement, + self._gates[self._target_element_id], + self._gate_directions[self._target_element_id], + minimum_hit_distance, + ) + if hit_distance is None: + return + self._advance_target(timestamp_us) + minimum_hit_distance = hit_distance + 1.0e-6 + + def _advance_target(self, timestamp_us: int) -> None: + """Advance race state after crossing the active target gate.""" + if self._session_state == "awaiting_start": + self._start_timestamp_us = timestamp_us + self._elapsed_time_us = 0 + self._session_state = "racing" + self._target_kind = "checkpoint" + self._event = "race_started" + return + if self._target_kind == "start": + self._completed_laps += 1 + if self._completed_laps >= self._course.lap_count: + self._finish(timestamp_us) + else: + self._target_kind = "checkpoint" + self._checkpoint_index = 0 + self._event = "lap_complete" + return + if self._checkpoint_index + 1 < len(self._course.checkpoint_element_ids): + self._checkpoint_index += 1 + self._event = "checkpoint" + return + if self._course.lap_count == 0: + self._finish(timestamp_us) + else: + self._target_kind = "start" + self._event = "checkpoint" + + def _build_gates( + self, + ) -> tuple[dict[str, LineString], dict[str, npt.NDArray[np.float64]]]: + """Build fixed gate lines and their legal crossing directions.""" + start_id = self._course.start_element_id + start_gate, start_inward = _cross_course_gate( + self._surfaces[start_id], + self._surfaces[self._course.checkpoint_element_ids[0]], + self._centerlines[start_id], + ) + gates = {start_id: start_gate} + directions = {start_id: -start_inward} + previous_id = self._course.start_element_id + for checkpoint_id in self._course.checkpoint_element_ids: + gate, direction = _cross_course_gate( + self._surfaces[checkpoint_id], + self._surfaces[previous_id], + self._centerlines[checkpoint_id], + ) + gates[checkpoint_id] = gate + directions[checkpoint_id] = direction + previous_id = checkpoint_id + return gates, directions + + def _finish(self, timestamp_us: int) -> None: + assert self._start_timestamp_us is not None + self._elapsed_time_us = max(1, timestamp_us - self._start_timestamp_us) + self._final_time_us = self._elapsed_time_us + self._leaderboard = self._time_store.read(self._map_id, self._course.course_id) + self._high_score_rank = self._time_store.qualifying_rank( + self._map_id, self._course.course_id, self._final_time_us + ) + self._session_state = ( + "awaiting_name" if self._high_score_rank is not None else "leaderboard" + ) + self._event = "race_complete" + + +class RaceGameRules: + """Adapt ordered race progression to the reusable game engine.""" + + def __init__(self, controller: RaceController) -> None: + self.controller = controller + + @property + def is_running(self) -> bool: + return self.controller.is_playing + + def snapshot(self, vehicle_state: VehicleState) -> RaceGameSnapshot: + return self.controller.snapshot(vehicle_state) + + def advance_frames( + self, + trajectory: TrajectoryChunk, + frame_interval_s: float, + ) -> GameUpdate: + return GameUpdate( + frames=self.controller.advance_frames(trajectory, frame_interval_s) + ) + + def submit_text( + self, + value: str, + vehicle_state: VehicleState, + ) -> RaceGameSnapshot: + self.controller.submit_high_score_name(value) + return self.controller.snapshot(vehicle_state) + + +def _cross_course_gate( + surface: Polygon, + adjacent_surface: Polygon, + centerlines: tuple[npt.NDArray[np.float64], ...], +) -> tuple[LineString, npt.NDArray[np.float64]]: + """Build a fixed line across the side facing an adjacent course element. + + Args: + surface: Surface on which to place the gate. + adjacent_surface: Adjacent course surface used to select the relevant + entrance or exit and infer travel direction. + centerlines: Directed lane centerlines owned by the target element. + + Returns: + Line spanning the target surface and the inward travel direction from + the adjacent element. + """ + target = surface.representative_point() + approach = adjacent_surface.representative_point() + approach_xy = np.asarray([approach.x, approach.y], dtype=np.float64) + frame = _nearest_centerline_entry(centerlines, approach_xy) + if frame is None: + anchor = np.asarray([target.x, target.y], dtype=np.float64) + direction = anchor - approach_xy + norm = float(np.linalg.norm(direction)) + direction = ( + np.asarray([1.0, 0.0], dtype=np.float64) + if norm <= 1.0e-6 + else direction / norm + ) + else: + anchor, direction = frame + span = max( + surface.bounds[2] - surface.bounds[0], + surface.bounds[3] - surface.bounds[1], + ) + span = max(10.0, span * 4.0) + perpendicular = np.asarray([-direction[1], direction[0]], dtype=np.float64) + cross_line = LineString( + (tuple(anchor - perpendicular * span), tuple(anchor + perpendicular * span)) + ) + cross_parts = _line_parts(cross_line.intersection(surface)) + if not cross_parts: + return ( + LineString((tuple(anchor - perpendicular), tuple(anchor + perpendicular))), + direction, + ) + return max(cross_parts, key=lambda part: part.length), direction + + +def _nearest_centerline_entry( + centerlines: tuple[npt.NDArray[np.float64], ...], + approach_xy: npt.NDArray[np.float64], +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]] | None: + """Return the closest lane endpoint and its inward local tangent.""" + frames: list[tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]] = [] + for centerline in centerlines: + if centerline.shape[0] < 2: + continue + oriented = ( + centerline + if np.linalg.norm(centerline[0] - approach_xy) + <= np.linalg.norm(centerline[-1] - approach_xy) + else centerline[::-1] + ) + segments = np.diff(oriented, axis=0) + lengths = np.linalg.norm(segments, axis=1) + total_length = float(lengths.sum()) + if total_length <= 1.0e-6: + continue + remaining = min(0.25, total_length * 0.5) + for index, length in enumerate(lengths): + if length <= 1.0e-6: + continue + direction = segments[index] / length + if remaining <= length: + frames.append((oriented[index] + direction * remaining, direction)) + break + remaining -= float(length) + if not frames: + return None + anchor = np.mean([frame[0] for frame in frames], axis=0) + direction = np.mean([frame[1] for frame in frames], axis=0) + norm = float(np.linalg.norm(direction)) + if norm <= 1.0e-6: + return None + return anchor, direction / norm + + +def _line_parts(geometry: object) -> tuple[LineString, ...]: + if isinstance(geometry, LineString): + return (geometry,) if geometry.length > 1.0e-6 else () + return tuple( + part + for part in getattr(geometry, "geoms", ()) + if isinstance(part, LineString) and part.length > 1.0e-6 + ) + + +def _first_gate_hit_distance( + movement: LineString, + gate: LineString, + forward_direction: npt.NDArray[np.float64], + minimum_distance: float, +) -> float | None: + """Return the first gate crossing at or after a swept-path distance. + + Args: + movement: Ego-center path for one simulation step. + gate: Active race gate. + forward_direction: Unit vector defining legal forward travel. + minimum_distance: Earliest eligible distance along ``movement``. + + Returns: + Distance to the first eligible crossing, or ``None`` when the remaining + movement does not cross the gate in the forward direction. + """ + start = np.asarray(movement.coords[0], dtype=np.float64) + end = np.asarray(movement.coords[-1], dtype=np.float64) + if float(np.dot(end - start, forward_direction)) <= 1.0e-9: + return None + distances = list(_intersection_distances(movement, movement.intersection(gate))) + endpoint = Point(movement.coords[-1]) + if gate.distance(endpoint) <= 1.0e-6: + distances.append(movement.length) + eligible = [distance for distance in distances if distance >= minimum_distance] + return min(eligible) if eligible else None + + +def _intersection_distances(line: LineString, geometry: object) -> tuple[float, ...]: + """Return distances along a line for point and overlapping intersections.""" + if isinstance(geometry, Point): + return (float(line.project(geometry)),) + if isinstance(geometry, LineString): + if geometry.is_empty: + return () + return tuple( + float(line.project(Point(coordinate))) + for coordinate in (geometry.coords[0], geometry.coords[-1]) + ) + return tuple( + distance + for part in getattr(geometry, "geoms", ()) + for distance in _intersection_distances(line, part) + ) + + +def project_race_gate_to_camera( + snapshot: RaceGameSnapshot, + rig_to_world: npt.NDArray[np.float32], + camera_model: FThetaCameraModel, + *, + image_width: int, + image_height: int, +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Project and clip the active race gate into camera pixels. + + Args: + snapshot: Current race state and fixed gate endpoints. + rig_to_world: Camera-rig pose in world coordinates. + camera_model: Camera projection model. + image_width: Output image width in pixels. + image_height: Output image height in pixels. + + Returns: + Clipped pixel endpoints, or ``None`` when camera markers are disabled or + the gate is not visible. + """ + if not snapshot.checkpoint_markers: + return None + points = np.asarray( + (snapshot.gate_start_xyz_m, snapshot.gate_end_xyz_m), dtype=np.float32 + ) + points[:, 2] += np.float32(0.08) + uv, _depth, forward = camera_model.project_world(points, rig_to_world) + if not bool(forward.all()): + return None + clipped = _clip_unit_segment( + (float(uv[0, 0]) / image_width, float(uv[0, 1]) / image_height), + (float(uv[1, 0]) / image_width, float(uv[1, 1]) / image_height), + ) + if clipped is None: + return None + return ( + (clipped[0][0] * image_width, clipped[0][1] * image_height), + (clipped[1][0] * image_width, clipped[1][1] * image_height), + ) + + +def _clip_unit_segment( + start: tuple[float, float], end: tuple[float, float] +) -> tuple[tuple[float, float], tuple[float, float]] | None: + x0, y0 = start + dx, dy = end[0] - x0, end[1] - y0 + lower, upper = 0.0, 1.0 + for p, q in ((-dx, x0), (dx, 1.0 - x0), (-dy, y0), (dy, 1.0 - y0)): + if abs(p) <= 1.0e-12: + if q < 0.0: + return None + continue + ratio = q / p + if p < 0.0: + lower = max(lower, ratio) + else: + upper = min(upper, ratio) + if lower > upper: + return None + return ( + (x0 + lower * dx, y0 + lower * dy), + (x0 + upper * dx, y0 + upper * dy), + ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index c8307b61c..445f4a98e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -13,12 +13,12 @@ import numpy as np import torch from omnidreams_game_engine.input import DriverInput -from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state from omnidreams_game_engine.model import WorldModelRollout from omnidreams_game_engine.types import DriverCommand, SceneDefinition from crazy_robotaxi.factory import build_taxi_engine from crazy_robotaxi.rules import TaxiGameSnapshot +from crazy_robotaxi.race import RaceGameSnapshot from crazy_robotaxi.ui import ( CrazyRobotaxiImGuiUILoop, TaxiHudState, @@ -85,6 +85,10 @@ def ensure_rollout(self) -> WorldModelRollout: bev=self.config.renderer.bev, frame_interval_s=frame_interval_s, device=self.config.device, + game_mode=self.config.game_mode, + race_course_id=self.config.race_course_id, + race_times_path=self.config.race_times_path, + live_edit=self.config.live_edit, ), ) if not self.prewarm_complete: @@ -169,9 +173,15 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: step_wall_started = time.perf_counter() step_cpu_started = time.thread_time() snapshot = rollout.engine.current_game_frame - if not isinstance(snapshot, TaxiGameSnapshot): - raise TypeError("Taxi engine returned a non-taxi game frame") - if snapshot.session_state == "playing": + if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): + raise TypeError("Crazy Robotaxi engine returned an unknown game frame") + active_states = {"playing", "awaiting_start", "racing"} + if snapshot.session_state in active_states: + live_edit = getattr(rollout.engine, "live_edit", None) + if live_edit is not None: + live_edit.process_events(events) + if live_edit.style is not None: + live_edit.style.before_v2_chunk() frame_count = rollout.frame_count(state.blocks_generated) input_batch = state.driver_input.reduce( events, @@ -185,6 +195,8 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: autoregressive_index=state.blocks_generated, commands=input_batch.commands, ) + if live_edit is not None and live_edit.style is not None: + live_edit.style.after_v2_chunk() state.blocks_generated += 1 video = generated.video_bvtchw[0, 0] expected_shape = ( @@ -244,7 +256,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ) latest = game_frames[-1] if ( - isinstance(latest, TaxiGameSnapshot) + isinstance(latest, (TaxiGameSnapshot, RaceGameSnapshot)) and latest.session_state == "leaderboard" ): state.finished = True @@ -254,7 +266,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ): state.finished = True count = int(video.shape[0]) - if snapshot.session_state == "playing": + if snapshot.session_state in active_states: model_step_wall_ms = (time.perf_counter() - step_wall_started) * 1000.0 model_step_cpu_ms = (time.thread_time() - step_cpu_started) * 1000.0 chunk_duration_ms = ( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 4446fa899..23b2dcc01 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -29,10 +29,12 @@ import numpy.typing as npt import torch import torch.nn.functional as functional +from omnidreams_game_engine.camera import FThetaCameraModel from omnidreams_game_engine.types import CameraCalibration from torch import Tensor -from crazy_robotaxi.high_scores import validate_player_name +from crazy_robotaxi.high_scores import format_race_time_us, validate_player_name +from crazy_robotaxi.race import RaceGameSnapshot, project_race_gate_to_camera from crazy_robotaxi.rules import TaxiCameraMarkerProjection, TaxiGameSnapshot from crazy_robotaxi.world_overlay import ( draw_waypoints as draw_waypoint_markers, @@ -77,8 +79,8 @@ class TaxiHudFrame: frame_key: int """Live tensor data pointer identifying the corresponding video frame.""" - snapshot: TaxiGameSnapshot - """Taxi rules snapshot for the corresponding simulation frame.""" + snapshot: TaxiGameSnapshot | RaceGameSnapshot + """Game-rules snapshot for the corresponding simulation frame.""" rig_pose_world: npt.NDArray[np.float32] """Read-only rig pose that generated the corresponding video frame.""" @@ -285,21 +287,48 @@ def draw_waypoints(self, imgui: Any, frame: Tensor) -> None: if source is None: return if source is not self._waypoint_source: - self._waypoint_projections = project_waypoints( - source.snapshot, - source.rig_pose_world, - self.calibration, + if isinstance(source.snapshot, TaxiGameSnapshot): + self._waypoint_projections = project_waypoints( + source.snapshot, + source.rig_pose_world, + self.calibration, + width=self.width, + height=self.height, + ) + else: + self._waypoint_projections = () + self._waypoint_source = source + if isinstance(source.snapshot, TaxiGameSnapshot): + draw_waypoint_markers( + imgui, + self._waypoint_projections, + phase=source.snapshot.phase, width=self.width, height=self.height, ) - self._waypoint_source = source - draw_waypoint_markers( - imgui, - self._waypoint_projections, - phase=source.snapshot.phase, - width=self.width, - height=self.height, - ) + elif source.snapshot.checkpoint_markers: + camera = FThetaCameraModel( + self.calibration, + output_width=self.width, + output_height=self.height, + ) + gate = project_race_gate_to_camera( + source.snapshot, + source.rig_pose_world, + camera, + image_width=self.width, + image_height=self.height, + ) + if gate is not None: + draw_list = imgui.get_background_draw_list() + color = int( + imgui.color_convert_float4_to_u32( + imgui.ImVec4(1.0, 0.18, 0.08, 1.0) + ) + ) + draw_list.add_line( + imgui.ImVec2(*gate[0]), imgui.ImVec2(*gate[1]), color, 6.0 + ) def draw( self, @@ -325,7 +354,38 @@ def draw( return snapshot = hud_frame.snapshot - if snapshot.session_state == "playing": + if isinstance(snapshot, RaceGameSnapshot) and snapshot.session_state in { + "awaiting_start", + "racing", + }: + lap = ( + "POINT TO POINT" + if snapshot.lap_count == 0 + else f"LAP {snapshot.completed_laps + 1}/{snapshot.lap_count}" + ) + self._draw_text_window( + imgui, + "Crazy Robotaxi Race", + position=(14.0, 14.0), + size=(360.0, 190.0), + lines=( + format_race_time_us(snapshot.elapsed_time_us), + lap, + ( + f"CHECKPOINT {snapshot.checkpoint_index + 1}/" + f"{snapshot.checkpoint_count}" + ), + f"{snapshot.target_label} {snapshot.distance_m:04.0f} m", + snapshot.event.replace("_", " ").upper() + if snapshot.event is not None + else "", + ), + ) + self._draw_bev_window(imgui, bev_frame) + elif ( + isinstance(snapshot, TaxiGameSnapshot) + and snapshot.session_state == "playing" + ): objective = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" fare_time = ( "" @@ -532,7 +592,9 @@ def _draw_input_diagnostic(self, imgui: Any) -> None: lines=(input_state, latency_label, counts), ) - def _draw_terminal(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: + def _draw_terminal( + self, imgui: Any, snapshot: TaxiGameSnapshot | RaceGameSnapshot + ) -> None: awaiting_name = snapshot.session_state == "awaiting_name" leaderboard = snapshot.session_state == "leaderboard" if not (awaiting_name or leaderboard): @@ -549,8 +611,18 @@ def _draw_terminal(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: try: if not visible: return - imgui.text("NEW HIGH SCORE" if awaiting_name else "LEADERBOARD") - imgui.text(f"FINAL SCORE {snapshot.score:06d}") + race = isinstance(snapshot, RaceGameSnapshot) + imgui.text( + ("NEW BEST TIME" if race else "NEW HIGH SCORE") + if awaiting_name + else "LEADERBOARD" + ) + if race: + imgui.text( + "FINAL TIME " + format_race_time_us(snapshot.final_time_us or 0) + ) + else: + imgui.text(f"FINAL SCORE {snapshot.score:06d}") if snapshot.high_score_rank is not None: imgui.text(f"RANK #{snapshot.high_score_rank}") if awaiting_name: @@ -563,7 +635,7 @@ def _draw_terminal(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: self._name_input, flags=imgui.InputTextFlags_.enter_returns_true, ) - clicked = imgui.button("Submit score") + clicked = imgui.button("Submit time" if race else "Submit score") finally: if disabled: imgui.end_disabled() @@ -572,13 +644,18 @@ def _draw_terminal(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: if self._validation_message: imgui.text(self._validation_message) else: - entries = ( - "\n".join( + if race: + entries = "\n".join( + f"{rank:>2}. {entry.name:<12} " + f"{format_race_time_us(entry.elapsed_time_us)}" + for rank, entry in enumerate(snapshot.leaderboard, start=1) + ) + else: + entries = "\n".join( f"{rank:>2}. {entry.name:<12} {entry.score:>7}" for rank, entry in enumerate(snapshot.leaderboard, start=1) ) - or "NO SCORES YET" - ) + entries = entries or "NO SCORES YET" imgui.text(entries) finally: imgui.end() @@ -659,7 +736,6 @@ def _activate_presentation_stream(self) -> torch.cuda.Stream | None: torch.cuda.set_stream(self._presentation_stream) return self._presentation_stream - def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" self.state.reset() @@ -703,8 +779,8 @@ def build_hud_frames( raise ValueError("Input transitions and video frames must align") frames = [] for index, snapshot in enumerate(snapshots): - if not isinstance(snapshot, TaxiGameSnapshot): - raise TypeError("Taxi HUD received a non-taxi game snapshot") + if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): + raise TypeError("Taxi HUD received an unknown game snapshot") pose = poses[index].copy() pose.setflags(write=False) frames.append( diff --git a/apps/crazy_robotaxi/pyproject.toml b/apps/crazy_robotaxi/pyproject.toml index fb2fa69ee..6f517e423 100644 --- a/apps/crazy_robotaxi/pyproject.toml +++ b/apps/crazy_robotaxi/pyproject.toml @@ -33,7 +33,11 @@ dev = ["pytest>=8.0", "pytest-manual-marker>=2.0"] include = ["crazy_robotaxi*"] [tool.setuptools.package-data] -crazy_robotaxi = ["configs/*.yaml", "maps/*.robotaxi.yaml"] +crazy_robotaxi = [ + "assets/*.npz", + "configs/*.yaml", + "maps/*.robotaxi.yaml", +] [tool.uv] managed = true diff --git a/apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml b/apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml new file mode 100644 index 000000000..9ea1d5de1 --- /dev/null +++ b/apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml @@ -0,0 +1,55 @@ +schema_version: 1 +id: race-course-test +name: Race Course Test + +compiler: + sample_spacing_m: 1 + ground_margin_m: 10 + intersection_connector_samples: 8 + +profiles: + street: + lane_width_m: 3.6 + curb_offset_m: 0.6 + lanes: [backward, forward] + speed_limit_mps: 12 + lane_marking: {style: SOLID_GROUP, color: YELLOW} + divider_markings: + - {style: SOLID_GROUP, color: YELLOW} + +nodes: + - {id: southwest, type: road_joint, pose: {x_m: -150, y_m: -100}} + - {id: south, type: road_joint, pose: {x_m: 0, y_m: -100}} + - {id: southeast, type: road_joint, pose: {x_m: 150, y_m: -100}} + - {id: east, type: road_joint, pose: {x_m: 150, y_m: 0}} + - {id: northeast, type: road_joint, pose: {x_m: 150, y_m: 100}} + - {id: north, type: road_joint, pose: {x_m: 0, y_m: 100}} + - {id: northwest, type: road_joint, pose: {x_m: -150, y_m: 100}} + - {id: west, type: road_joint, pose: {x_m: -150, y_m: 0}} + +roads: + - {id: south_west, from: southwest, to: south, profile: street} + - {id: south_east, from: south, to: southeast, profile: street} + - {id: east_south, from: southeast, to: east, profile: street} + - {id: east_north, from: east, to: northeast, profile: street} + - {id: north_east, from: northeast, to: north, profile: street} + - {id: north_west, from: north, to: northwest, profile: street} + - {id: west_north, from: northwest, to: west, profile: street} + - {id: west_south, from: west, to: southwest, profile: street} + +race_courses: + - id: test-loop + start: south_west + checkpoints: [southeast, east_north, north, west_south] + lap_count: 3 + checkpoint_markers: true + +spawns: + - id: start + road: south_west + lane: 1 + distance_m: 30 + variants: + default: + image: package://omnidreams_game_engine/screenshot.jpg + prompt: A car racing on a rectangular city loop. diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 5818c72c8..28e42d6ee 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -264,8 +264,7 @@ def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: assert pipeline.encoder.native_vae_backend == "fp8" assert pipeline.diffusion_model.transformer.native_dit_acceleration == "required" assert ( - pipeline.diffusion_model.transformer.native_dit_backend - == "fp8_kvcache_cudnn" + pipeline.diffusion_model.transformer.native_dit_backend == "fp8_kvcache_cudnn" ) @@ -481,6 +480,29 @@ def test_taxi_physics_uses_spatial_and_traffic_topology_refreshes_only() -> None synchronize.assert_called_once_with(center, timestamp_us=None) +def test_taxi_physics_forwards_forced_controller_refresh() -> None: + world = object.__new__(TaxiPhysicsWorld) + world._has_external_actor_controllers = False + center = np.asarray([0.0, 0.0], dtype=np.float32) + with patch.object( + GamePhysicsWorld, + "synchronize_window", + return_value=True, + ) as synchronize: + changed = world.synchronize_window( + center, + timestamp_us=2_000_000, + force_controller_refresh=True, + ) + + assert changed + synchronize.assert_called_once_with( + center, + 2_000_000, + force_controller_refresh=True, + ) + + def test_application_rejects_geometry_the_model_does_not_produce() -> None: app = CrazyRobotaxiApplication( pipeline_factory=lambda config, device: object(), diff --git a/apps/crazy_robotaxi/tests/test_layered_settings.py b/apps/crazy_robotaxi/tests/test_layered_settings.py new file mode 100644 index 000000000..bb6144356 --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_layered_settings.py @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""CPU tests for layered Crazy Robotaxi settings.""" + +from pathlib import Path + +import pytest +from crazy_robotaxi.application import CrazyRobotaxiApplication +from crazy_robotaxi.config import load_game_settings +from omnidreams_game_engine.engine_settings import load_engine_settings +from omnidreams_game_engine.yaml_config import StrictConfigError + +pytestmark = pytest.mark.ci_cpu + +_CONFIGS = Path(__file__).parents[1] / "crazy_robotaxi" / "configs" + + +def test_shipped_example_configs_load() -> None: + engine = load_engine_settings(_CONFIGS / "example_engine_config.yaml") + game = load_game_settings(_CONFIGS / "example_game_config.yaml") + + assert ( + engine.map.path + == (_CONFIGS.parent / "maps" / "boulevard_district.robotaxi.yaml").resolve() + ) + assert engine.world_model.model_preset == "standard" + assert game.game.vehicle.max_steer_rad == pytest.approx(0.69) + assert game.driver_input.steering_rate_per_s == pytest.approx(3.5) + + +def test_partial_game_config_retains_defaults_and_resolves_paths( + tmp_path: Path, +) -> None: + config_path = tmp_path / "game.yaml" + config_path.write_text( + """\ +schema_version: 1 +rules: + global_time_s: 90 +taxi: + seed: 17 + high_scores_path: scores.csv +live_edit: + coins: + enabled: true +""", + encoding="utf-8", + ) + + settings = load_game_settings(config_path) + + assert settings.game.global_time_s == pytest.approx(90.0) + assert settings.game.pickup_radius_m == pytest.approx(5.0) + assert settings.game.seed == 17 + assert settings.game.high_scores_path == (tmp_path / "scores.csv").resolve() + assert settings.live_edit.coins.enabled + + +def test_partial_engine_config_retains_defaults_and_resolves_paths( + tmp_path: Path, +) -> None: + config_path = tmp_path / "engine.yaml" + config_path.write_text( + """\ +schema_version: 1 +map: + path: maps/test.robotaxi.yaml +rendering: + bev: + height_m: 42 +presentation: + show_fps: true +runtime: + prewarm_blocks: 2 +""", + encoding="utf-8", + ) + + settings = load_engine_settings(config_path) + + assert settings.map.path == (tmp_path / "maps/test.robotaxi.yaml").resolve() + assert settings.rendering.raster.width == 1280 + assert settings.rendering.bev.height_m == pytest.approx(42.0) + assert settings.presentation.show_fps + assert settings.runtime.prewarm_blocks == 2 + + +def test_explicit_cli_overrides_engine_yaml(tmp_path: Path) -> None: + config_path = tmp_path / "engine.yaml" + config_path.write_text( + """\ +schema_version: 1 +world_model: + model_preset: perf + device: cuda +presentation: + show_fps: true +runtime: + prewarm_blocks: 1 + profile_world_model: true + profile_input_latency: false +""", + encoding="utf-8", + ) + app = CrazyRobotaxiApplication() + + app.init( + [ + "--engine-config", + str(config_path), + "--model-preset", + "standard", + "--device", + "cpu", + "--prewarm-blocks", + "3", + "--profile-input-latency", + "--no-show-fps", + ] + ) + + assert app._config is not None + assert app._config.model_preset_name == "standard" + assert app._config.device == "cpu" + assert app._config.prewarm_blocks == 3 + assert app._config.profile_input_latency + assert not app._config.show_fps + assert app._config.pipeline_profiling + + +def test_unknown_layered_key_is_rejected(tmp_path: Path) -> None: + config_path = tmp_path / "game.yaml" + config_path.write_text( + "schema_version: 1\nrules:\n not_a_rule: 1\n", + encoding="utf-8", + ) + + with pytest.raises(StrictConfigError, match="not_a_rule"): + load_game_settings(config_path) + + +def test_game_yaml_flags_and_inactive_race_settings_are_layered( + tmp_path: Path, +) -> None: + config_path = tmp_path / "game.yaml" + config_path.write_text( + """\ +schema_version: 1 +effects: + visual_flare_enabled: true +race: + course: future-course + times_path: race-times.csv +""", + encoding="utf-8", + ) + yaml_app = CrazyRobotaxiApplication() + cli_app = CrazyRobotaxiApplication() + + yaml_app.init(["--game-config", str(config_path)]) + cli_app.init(["--game-config", str(config_path), "--no-visual-flare"]) + + assert yaml_app._config is not None + assert yaml_app._config.game_mode == "taxi" + assert yaml_app._config.race_course_id == "future-course" + assert yaml_app._config.visual_flare_enabled + assert cli_app._config is not None + assert not cli_app._config.visual_flare_enabled diff --git a/apps/crazy_robotaxi/tests/test_live_edit_v2.py b/apps/crazy_robotaxi/tests/test_live_edit_v2.py new file mode 100644 index 000000000..1ff5a7916 --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_live_edit_v2.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""CPU regression tests for API-v2 live-edit composition.""" + +from __future__ import annotations + +import numpy as np +import pytest +from crazy_robotaxi.live_edit.config import LiveEditItemsConfig +from crazy_robotaxi.live_edit.nitro_ability import NitroAbility +from crazy_robotaxi.live_edit.obstacle_templates import load_obstacle_template_catalog +from crazy_robotaxi.live_edit.runtime_v2 import LiveEditGameplay +from flashdreams.runtime_v2.user_input_event import ( + KeyboardInputState, + KeyboardUserInputEvent, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents +from omnidreams_game_engine.config import VehicleConfig + +pytestmark = pytest.mark.ci_cpu + + +class _StyleRequests: + def __init__(self) -> None: + self.skin_cycles = 0 + self.weather_cycles = 0 + + def request_cycle(self) -> None: + self.skin_cycles += 1 + + def request_weather_cycle(self) -> None: + self.weather_cycles += 1 + + +class _Coins: + def __init__(self) -> None: + self.toggles = 0 + + def toggle(self) -> bool: + self.toggles += 1 + return True + + +class _Obstacles: + def __init__(self) -> None: + self.spawns = 0 + + def request_spawn(self) -> None: + self.spawns += 1 + + +def test_v2_ability_keys_are_consumed_on_pressed_edges() -> None: + gameplay = LiveEditGameplay.__new__(LiveEditGameplay) + gameplay.style = _StyleRequests() + gameplay.coins = _Coins() + gameplay.obstacles = _Obstacles() + events = UserInputEvents( + [ + KeyboardUserInputEvent( + timestamp=np.uint64(index), + key=key, + state=state, + ) + for index, (key, state) in enumerate( + ( + ("k", KeyboardInputState.PRESSED), + ("k", KeyboardInputState.RELEASED), + ("v", KeyboardInputState.PRESSED), + ("c", KeyboardInputState.PRESSED), + ("o", KeyboardInputState.PRESSED), + ) + ) + ] + ) + + gameplay.process_events(events) + + assert gameplay.style.skin_cycles == 1 + assert gameplay.style.weather_cycles == 1 + assert gameplay.coins.toggles == 1 + assert gameplay.obstacles.spawns == 1 + + +def test_nitro_boosts_and_expires_on_game_time() -> None: + config = LiveEditItemsConfig( + enabled=True, + nitro_boost=2.0, + nitro_duration_s=0.2, + nitro_max_speed_mps=16.0, + ) + nitro = NitroAbility(config) + vehicle = VehicleConfig(max_speed_mps=10.0, max_accel_mps2=3.0) + nitro.activate() + + boosted = nitro.vehicle_for_tick(vehicle, 0.1) + nitro.vehicle_for_tick(vehicle, 0.1) + + assert boosted.max_speed_mps == 16.0 + assert boosted.max_accel_mps2 == 6.0 + assert not nitro.active + + +def test_bundled_obstacle_catalog_matches_source_branch() -> None: + catalog = load_obstacle_template_catalog() + + assert len(catalog.templates) == 668 + assert ( + len( + catalog.moving( + min_drift_m=15.0, + min_coverage_s=4.0, + length_range_m=(3.4, 5.6), + ) + ) + == 63 + ) + assert len(catalog.parked(length_range_m=(3.4, 5.6))) == 236 diff --git a/apps/crazy_robotaxi/tests/test_race.py b/apps/crazy_robotaxi/tests/test_race.py new file mode 100644 index 000000000..9a3716a1e --- /dev/null +++ b/apps/crazy_robotaxi/tests/test_race.py @@ -0,0 +1,435 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for race courses, progression, and scoped top times.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +import yaml +from crazy_robotaxi.high_scores import RaceTimeStore +from crazy_robotaxi.race import RaceController, RaceGameSnapshot +from omnidreams_game_engine.game_map import ( + GameMapError, + GameMapRaceCourse, + load_game_map, +) +from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state +from omnidreams_game_engine.types import TrajectoryChunk, VehicleState +from shapely.geometry import Polygon + +pytestmark = pytest.mark.ci_cpu + +_MAP = Path(__file__).parent / "maps" / "race_course.robotaxi.yaml" + + +def _state(x_m: float, y_m: float) -> VehicleState: + return VehicleState(x_m, y_m, 0.0, 0.0, 0.0, 0.0) + + +def _trajectory( + points: list[tuple[float, float]], timestamps_us: list[int] +) -> TrajectoryChunk: + states = tuple(_state(*point) for point in points) + return TrajectoryChunk( + timestamps_us=np.asarray(timestamps_us, dtype=np.int64), + rig_poses_world=np.stack( + [rig_pose_from_vehicle_state(state) for state in states] + ), + vehicle_states=states, + boundary_state_after_chunk=states[-1], + ) + + +def _point(game_map: object, element_id: str) -> tuple[float, float]: + element = next( + element + for element in game_map.elements # type: ignore[attr-defined] + if element.element_id == element_id + ) + point = Polygon(element.surface_world[:, :2]).representative_point() + return float(point.x), float(point.y) + + +def _active_gate_midpoint(controller: RaceController) -> tuple[float, float]: + snapshot = controller.snapshot(_state(0.0, 0.0)) + return snapshot.target_xyz_m[0], snapshot.target_xyz_m[1] + + +def _cross_active_gate( + controller: RaceController, + timestamp_us: int, + *, + forward: bool = True, +) -> RaceGameSnapshot: + target_id = controller._target_element_id + gate = controller._gates[target_id] + direction = controller._gate_directions[target_id] + midpoint = np.asarray(gate.interpolate(0.5, normalized=True).coords[0]) + before = midpoint - direction * 5.0 + after = midpoint + direction * 5.0 + if not forward: + before, after = after, before + controller._previous_xy = float(before[0]), float(before[1]) + return controller.advance_frames( + _trajectory([(float(after[0]), float(after[1]))], [timestamp_us]), 1.0 + )[-1] + + +def test_loop_finishes_only_after_final_return_to_start( + tmp_path: Path, +) -> None: + game_map = load_game_map(_MAP) + course = game_map.race_courses[0] + start = _point(game_map, course.start_element_id) + controller = RaceController( + game_map, + course, + _state(start[0] + 100.0, start[1] - 100.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + awaiting_start = controller.snapshot(_state(*start)) + assert awaiting_start.target_label == "START" + assert awaiting_start.as_dict()["target_label"] == "START" + + timestamp = 1_000_000 + started = _cross_active_gate(controller, timestamp) + assert started.target_label == "CHECKPOINT" + for lap in range(course.lap_count): + for _checkpoint in course.checkpoint_element_ids: + timestamp += 1_000_000 + _cross_active_gate(controller, timestamp) + assert controller.is_playing + assert controller.snapshot(_state(*start)).target_label == "FINISH" + timestamp += 1_000_000 + snapshot = _cross_active_gate(controller, timestamp) + assert snapshot.completed_laps == lap + 1 + + assert not controller.is_playing + assert snapshot.session_state == "awaiting_name" + assert snapshot.final_time_us == timestamp - 1_000_000 + controller.submit_high_score_name("Racer") + leaderboard = controller.snapshot(_state(*start)) + assert leaderboard.session_state == "leaderboard" + assert leaderboard.leaderboard[0].name == "Racer" + assert leaderboard.high_score_rank == 1 + + +def test_point_to_point_finishes_at_last_checkpoint_and_rejects_skips( + tmp_path: Path, +) -> None: + game_map = load_game_map(_MAP) + authored = game_map.race_courses[0] + course = GameMapRaceCourse( + course_id="sprint", + start_element_id=authored.start_element_id, + checkpoint_element_ids=( + authored.checkpoint_element_ids[0], + authored.checkpoint_element_ids[2], + ), + lap_count=0, + ) + start = _point(game_map, course.start_element_id) + last = _point(game_map, course.checkpoint_element_ids[1]) + controller = RaceController( + game_map, + course, + _state(start[0] + 100, start[1] - 100), + RaceTimeStore(tmp_path / "times.csv"), + ) + + _cross_active_gate(controller, 1_000_000) + skipped = controller.advance_frames(_trajectory([last], [2_000_000]), 1.0)[-1] + assert skipped.checkpoint_index == 0 + assert controller.is_playing + final_gate = _cross_active_gate(controller, 3_000_000) + assert final_gate.target_label == "FINISH" + finished = _cross_active_gate(controller, 4_000_000) + + assert finished.final_time_us == 3_000_000 + assert not controller.is_playing + + +def test_start_gate_detects_a_swept_crossing(tmp_path: Path) -> None: + game_map = load_game_map(_MAP) + course = game_map.race_courses[0] + probe = RaceController( + game_map, + course, + _state(0.0, 0.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + gate = probe.snapshot(_state(0.0, 0.0)) + start = np.asarray(gate.gate_start_xyz_m[:2]) + end = np.asarray(gate.gate_end_xyz_m[:2]) + midpoint = (start + end) / 2.0 + normal = np.asarray([-(end - start)[1], (end - start)[0]]) + normal /= np.linalg.norm(normal) + before = midpoint - normal * 5.0 + after = midpoint + normal * 5.0 + controller = RaceController( + game_map, + course, + _state(float(before[0]), float(before[1])), + RaceTimeStore(tmp_path / "times.csv"), + ) + + snapshot = controller.advance_frames( + _trajectory([(float(after[0]), float(after[1]))], [7_000_000]), 1.0 + )[-1] + + assert snapshot.session_state == "racing" + assert snapshot.elapsed_time_us == 0 + + +@pytest.mark.parametrize( + ("element_id", "expected_direction"), + [ + ("south_west", (1.0, 0.0)), + ("southeast", (1.0, 0.0)), + ("east_north", (0.0, 1.0)), + ("north", (-1.0, 0.0)), + ("west_south", (0.0, -1.0)), + ], +) +def test_gate_directions_follow_authored_course( + tmp_path: Path, + element_id: str, + expected_direction: tuple[float, float], +) -> None: + game_map = load_game_map(_MAP) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + + assert ( + np.dot(controller._gate_directions[element_id], np.asarray(expected_direction)) + > 0.99 + ) + + +def test_backward_start_crossing_does_not_start(tmp_path: Path) -> None: + game_map = load_game_map(_MAP) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + + snapshot = _cross_active_gate(controller, 1_000_000, forward=False) + + assert snapshot.session_state == "awaiting_start" + assert snapshot.target_kind == "start" + assert snapshot.event is None + assert snapshot.elapsed_time_us == 0 + + +def test_backward_checkpoint_crossing_does_not_advance(tmp_path: Path) -> None: + game_map = load_game_map(_MAP) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + _cross_active_gate(controller, 1_000_000) + checkpoint_id = controller._target_element_id + + snapshot = _cross_active_gate(controller, 2_000_000, forward=False) + + assert snapshot.session_state == "racing" + assert snapshot.checkpoint_index == 0 + assert snapshot.target_element_id == checkpoint_id + assert snapshot.event is None + + +def test_backward_finish_crossing_does_not_complete_race(tmp_path: Path) -> None: + game_map = load_game_map(_MAP) + authored = game_map.race_courses[0] + course = GameMapRaceCourse( + course_id="one-lap", + start_element_id=authored.start_element_id, + checkpoint_element_ids=authored.checkpoint_element_ids, + lap_count=1, + ) + store = RaceTimeStore(tmp_path / "times.csv") + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + store, + ) + timestamp_us = 1_000_000 + _cross_active_gate(controller, timestamp_us) + for _checkpoint in course.checkpoint_element_ids: + timestamp_us += 1_000_000 + _cross_active_gate(controller, timestamp_us) + assert controller._target_element_id == course.start_element_id + + snapshot = _cross_active_gate(controller, timestamp_us + 1_000_000, forward=False) + + assert snapshot.session_state == "racing" + assert snapshot.completed_laps == 0 + assert snapshot.target_label == "FINISH" + assert snapshot.final_time_us is None + assert snapshot.event is None + assert store.read(game_map.map_id, course.course_id) == () + + +@pytest.mark.parametrize("gate_fraction", [0.1, 0.9]) +def test_swept_crossing_advances_through_adjacent_gates( + tmp_path: Path, gate_fraction: float +) -> None: + game_map = load_game_map(_MAP) + authored = game_map.race_courses[0] + course = GameMapRaceCourse( + course_id="adjacent-gates", + start_element_id=authored.start_element_id, + checkpoint_element_ids=("southeast", "east_south", "north"), + lap_count=0, + ) + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + _cross_active_gate(controller, 1_000_000) + first_gate = controller._gates[course.checkpoint_element_ids[0]] + second_gate = controller._gates[course.checkpoint_element_ids[1]] + first_crossing = np.asarray( + first_gate.interpolate(gate_fraction, normalized=True).coords[0] + ) + second_crossing = np.asarray( + second_gate.interpolate(gate_fraction, normalized=True).coords[0] + ) + direction = second_crossing - first_crossing + direction /= np.linalg.norm(direction) + before = first_crossing - direction + after = second_crossing + direction + controller._previous_xy = (float(before[0]), float(before[1])) + + snapshot = controller.advance_frames( + _trajectory([(float(after[0]), float(after[1]))], [2_000_000]), 1.0 + )[-1] + + assert snapshot.checkpoint_index == 2 + assert snapshot.target_element_id == "north" + + +def test_start_gate_is_near_course_exit_instead_of_element_midpoint( + tmp_path: Path, +) -> None: + game_map = load_game_map(_MAP) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + start_center = np.asarray(_point(game_map, course.start_element_id)) + first_checkpoint = np.asarray(_point(game_map, course.checkpoint_element_ids[0])) + gate_center = np.asarray(_active_gate_midpoint(controller)) + + assert np.linalg.norm(gate_center - first_checkpoint) < np.linalg.norm( + start_center - first_checkpoint + ) + + +def test_course_gates_span_the_full_road_surface(tmp_path: Path) -> None: + game_map = load_game_map(_MAP) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + + for element_id in (course.start_element_id, *course.checkpoint_element_ids): + assert controller._gates[element_id].length == pytest.approx(8.4, abs=0.15) + + +def test_race_times_are_isolated_by_map_and_course(tmp_path: Path) -> None: + store = RaceTimeStore(tmp_path / "times.csv", limit=2) + store.record("map-a", "course-a", "Slow", 4_000_000) + store.record("map-a", "course-a", "Fast", 2_000_000) + store.record("map-a", "course-b", "Other", 1_000_000) + store.record("map-b", "course-a", "Elsewhere", 500_000) + + assert [entry.name for entry in store.read("map-a", "course-a")] == [ + "Fast", + "Slow", + ] + assert [entry.name for entry in store.read("map-a", "course-b")] == ["Other"] + assert [entry.name for entry in store.read("map-b", "course-a")] == ["Elsewhere"] + assert store.qualifying_rank("map-a", "course-a", 3_000_000) == 2 + assert store.qualifying_rank("map-a", "course-a", 5_000_000) is None + + +@pytest.mark.parametrize( + ("update", "message"), + [ + ({"start": "missing"}, "unknown node or road"), + ({"checkpoints": []}, "at least one checkpoint"), + ({"lap_count": -1}, "nonnegative integer"), + ({"checkpoints": ["south_west"]}, "may not reuse start"), + ({"checkpoint_markers": "yes"}, "must be a boolean"), + ], +) +def test_invalid_race_course_schema_is_rejected( + tmp_path: Path, update: dict[str, object], message: str +) -> None: + document = yaml.safe_load(_MAP.read_text(encoding="utf-8")) + document["race_courses"][0].update(update) + path = tmp_path / "invalid.robotaxi.yaml" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + + with pytest.raises(GameMapError, match=message): + load_game_map(path) + + +def test_checkpoint_markers_can_be_disabled_without_disabling_gates( + tmp_path: Path, +) -> None: + document = yaml.safe_load(_MAP.read_text(encoding="utf-8")) + document["race_courses"][0]["checkpoint_markers"] = False + path = tmp_path / "hidden-markers.robotaxi.yaml" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + game_map = load_game_map(path) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + + before = controller.snapshot(_state(-300.0, -300.0)) + after = _cross_active_gate(controller, 1_000_000) + + assert before.checkpoint_markers is False + assert after.session_state == "racing" diff --git a/apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md b/apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md new file mode 100644 index 000000000..d4f54745c --- /dev/null +++ b/apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md @@ -0,0 +1,443 @@ +# Node-Graph Map Format + +Schema version 1 is the authoring format for standalone OmniDreams games. It +models structural places as nodes, public roads as graph edges, and parking +access through driveway relationships. + +## Document shape + +```yaml +schema_version: 1 +id: example-map +name: Example Map +compiler: + sample_spacing_m: 2.0 + ground_margin_m: 20.0 + intersection_connector_samples: 8 +profiles: {} +nodes: [] +roads: [] +race_courses: [] +traffic_count: 12 +traffic: [] +spawns: [] +``` + +`profiles`, `race_courses`, `traffic_count`, and `traffic` are optional. All +other root fields are required, and unknown root fields are errors. + +The compiler settings control road sampling, ground extent, and routing-only +turn-connector resolution. They do not configure the renderer archive. + +## Attributes and profiles + +Profiles are optional, partial sets of defaults. An element may provide any +applicable attribute directly at its top level, reference a profile, or do +both. A directly supplied value wins over the profile value. Profile fields +that do not apply to an element are ignored. + +After combining direct values and profile defaults, every required attribute +must have a value or compilation fails. Identity, pose, topology, and road +geometry are not profile attributes. + +Linear elements use these attributes: + +```yaml +lane_width_m: 3.6 +curb_offset_m: 0.6 +lanes: [backward, forward] +speed_limit_mps: 13.4 +lane_marking: {style: SOLID_GROUP, color: YELLOW} +divider_markings: + - {style: SOLID_GROUP, color: YELLOW} +``` + +There must be one divider marking for every adjacent lane pair. The paved +surface width is `lane_width_m * len(lanes) + 2 * curb_offset_m`. + +Every element emits semantic road-boundary polylines around its surface, +excluding declared connections. Those boundaries are always included in HD-map +conditioning. `curb: true` also makes them physical collision barriers; +`curb: false` leaves them non-colliding. The `curb` attribute defaults to +`true` when neither the element nor its profile supplies it. + +For example, a road can inherit most values while overriding its width: + +```yaml +- id: oak_street + from: west_junction + to: east_junction + profile: neighborhood + lane_width_m: 4.0 +``` + +## Coordinates and topology + +Every node except a parking lot has an explicit map-space pose: + +```yaml +pose: {x_m: 12, y_m: -4} +``` + +`x_m` and `y_m` use metres. Connected road geometry determines every node's +approach directions and footprint orientation. + +The persisted `GameMapTopology` retains typed nodes, roads, derived parking +accesses, and adjacency. The compiler separately derives a +directed lane graph for routing. Routing-only turn connectors are not emitted +into ClipGT map conditioning. + +Each node and edge owns its surface and curb geometry. Connected elements meet +at equal-width openings without overlapping. Unrelated elements may not have +positive-area overlap or share a boundary edge; isolated point tangency is +allowed. Roads, parking lots, and other surfaces therefore cannot be layered +over one another to repair topology. + +## Nodes + +All non-parking nodes require `id`, `type`, and `pose`. Their remaining required +attributes may be supplied directly or by profile. + +### Intersections + +An intersection connects at least three incident road arms and has no required +attributes beyond its identity and pose: + +```yaml +- id: askew_junction + type: intersection + pose: {x_m: 0, y_m: 0} + lane_transition_length_m: 20 +``` + +Use a road joint for a degree-two connection and a cul-de-sac for a degree-one +road ending; one- and two-arm intersections are rejected. The compiler infers +the intersection footprint from its incident roads and access paths. Each +opening uses that element's paved width and endpoint tangent. +Adjacent road-edge lines determine how far each arm must reach, so orthogonal +roads form a compact rectangular junction while acute approaches extend far +enough to meet without gaps. Intersection dimensions and arm lengths are not +authored. Road centerlines determine their endpoint tangents independently of +node rotation. + +`lane_transition_length_m` is optional and defaults to zero. For each pair of +opposing through-road arms, the compiler independently selects the cross-section +with the greater lane count (or wider lanes when the counts match) at the +intersection. A narrower arm then widens over this distance: its incoming lane +splits before the intersection and its outgoing local lanes merge after it. +Perpendicular through roads are paired separately, so a north-south lane-count +change does not add lanes to a matching east-west street. The taper is part of +the intersection surface and its lanes and markings are conditioning-visible. +Each approach retains its own `curb_offset_m` outside the changing lane +envelope, and its authored curb mode controls the physical curb along the taper. + +Opposing arms are inferred from their endpoint tangents; authors do not label +through-road pairs. A positive transition length is required only when a pair +changes lane count or lane width. It is measured into the authored road from +the inferred intersection opening and must not consume the complete road arm. + +### Road joints + +A road joint connects exactly two compatible authored roads without creating an +intersection: + +```yaml +- id: diagonal_bend + type: road_joint + pose: {x_m: 40, y_m: 20} + lane_transition_length_m: 20 +``` + +The compiler independently infers the shortest trim on each incident road from +the roads' endpoint tangents and paved widths. It replaces those portions with +one tangent-continuous cubic Bézier and traces the joint surface from the +resulting roadside boundaries. The outside boundary remains curved rather than +forming a straight miter between the two approaches. Curved `path` and `bezier` +approaches are supported. + +To author a longer curve, place a `bezier` road between two road joints. The +joints provide the minimal tangent connections while the road owns the extended +curve geometry. + +`lane_transition_length_m` is optional and defaults to zero. It permits the two +roads to differ in lane count, lane width, or both. The joint uses the dominant +cross-section at the curve, then tapers into each narrower incident road over +the authored distance. As at an intersection, an incoming narrow lane splits +toward the joint and outgoing local lanes merge into the narrow road. The taper +is measured along the incident road after its inferred joint trim, including +across curved `path` or `bezier` approaches. + +When oriented through the joint, both roads must still have compatible +direction ordering and opposing dividers. Speed limits, outer markings, curb +offsets, and curb modes may differ. Each approach keeps its authored curb offset +outside the changing lane envelope throughout its taper; adding lanes never +widens or narrows that offset. A lane-count or lane-width change requires a +positive `lane_transition_length_m`; otherwise its default of zero preserves +the previous exact-width behavior. The joint and taper emit +conditioning-visible lanes and markings, and each directed joint lane inherits +its incoming road's speed. Inferred curve trims or lane transitions that consume +an entire road or produce invalid or overlapping geometry are errors. + +### Cul-de-sacs + +A cul-de-sac requires `culdesac_radius_m` and must terminate exactly one road: + +```yaml +- id: oak_court_end + type: cul_de_sac + pose: {x_m: 80, y_m: 20} + culdesac_radius_m: 10 +``` + +Its circular surface has a flat opening matching the incident road width. The +circle has no visible centerline or lane divisions and derives a routing-only +turnaround. + +### Parking lots + +A parking lot is an absolute map-space polygon. It has no pose, profile, or +linear attributes: + +```yaml +- id: market_lot + type: parking_lot + connected_to: market_west_driveway + opening_vertex: 3 + vertices: + - {x_m: 10, y_m: -30} + - {x_m: 10, y_m: -10} + - {x_m: 18, y_m: -10} + - {x_m: 26, y_m: -10} + - {x_m: 40, y_m: -10} + - {x_m: 40, y_m: -30} +``` + +Vertices must describe a simple clockwise polygon. Concave polygons are +supported; holes, self-intersections, duplicate vertices, and degenerate edges +are not. `connected_to` must name an intersection or driveway node. +`opening_vertex` is one-based and selects the complete polygon edge from that +vertex to the next, wrapping from the final vertex to the first. Authors may +insert vertices around a narrower opening. The lot has physical curbs and +semantic boundaries on every edge except its selected access opening. +It has no inferred aisle or turnaround lanes. Its surface becomes a green +ClipGT roadnet mask; parking-stall lines are not generated. + +### Driveways + +A driveway is a degree-two road node with one parking access: + +```yaml +- id: market_west_driveway + type: driveway + pose: {x_m: 17, y_m: -7} +``` + +Its two roads must have compatible cross-sections, markings, and curb modes. +The compiler infers a minimal through-road surface large enough to contain the +curb opening, preserves conditioning-visible through lanes, and adds hidden +turn connectors to the access. A driveway is not emitted as an intersection. +Its entrance width comes from the selected parking-lot polygon edge. + +## Road geometry + +An authored road is one topological edge between intersections, road joints, +driveways, and/or cul-de-sacs: + +```yaml +- id: oak_street + from: west_junction + to: east_junction + profile: neighborhood +``` + +It uses the linear attributes. Without `path` or `bezier`, its centerline is +the straight segment between node poses. A self-loop therefore requires one of +those fields. + +Each authored road has one uniform cross-section. To change lane count or lane +width along a contiguous street, end one road and begin another at a road joint, +then set the joint's `lane_transition_length_m`. Intersections provide the same +transition independently for each inferred through-road pair. + +For normal hand-authored maps, `path` is a list of map-space points the road +centerline passes through. The `from` node pose is the implicit first point and +the `to` node pose is the implicit final point. The compiler derives smooth +cubic spans through the authored points. + +```yaml +- id: river_road + from: west_junction + to: east_junction + profile: neighborhood + path: + - {x_m: 45, y_m: 15} + - {x_m: 70, y_m: 5} +``` + +The resulting centerline is: + +```text +west_junction pose -> (45, 15) -> (70, 5) -> east_junction pose +``` + +Intermediate path points are geometry only; they do not become graph nodes. + +For imported or precision-authored geometry, `bezier` supplies exact cubic +Bézier spans. Each span starts at the previous endpoint and has exactly two +control points plus an endpoint: + +```yaml +- id: imported_curve + from: west_junction + to: east_junction + profile: neighborhood + bezier: + - control_points: [{x_m: 20, y_m: 0}, {x_m: 35, y_m: 12}] + end: {x_m: 45, y_m: 15} + - control_points: [{x_m: 55, y_m: 18}, {x_m: 70, y_m: 5}] + end: {x_m: 80, y_m: 5} +``` + +An `end` closes its span and becomes the next span's implicit start. The final +`end` must match the `to` node pose within 0.05m. Control points pull the curve +toward themselves; the centerline does not generally pass through them. + +A road may include both fields. Both must be valid, and `bezier` determines the +compiled geometry when present. This lets a generated or precision-authored +curve override a simpler editable `path` without conflating the two formats. + +## Inferred parking access + +The parking lot's `connected_to` and `opening_vertex` fields generate a +boundary-to-boundary access span; authors do not declare a separate road or +top-level access object. The compiler derives its stable identifier from the +lot identifier. + +The compiler infers a tangent cubic to the opening midpoint and validates that +the connected node is outside the lot on the edge's exterior side. The exact +opening width becomes two equal opposing lanes with no shoulder, virtual white +markings, physical curbs, and a 5.5m/s speed limit. Intersection connections +include the access as an inferred footprint arm. Access lanes end at the lot +boundary; parking lots contain no internal routing lanes. + +## Race courses + +The optional `race_courses` list defines one or more ordered courses using +globally unique node or road IDs: + +```yaml +race_courses: + - id: neighborhood-loop + start: south_intersection + checkpoints: [east_road, north_intersection, west_road] + lap_count: 3 + checkpoint_markers: true +``` + +Each referenced node or road supplies geometry for a fixed cross-course gate. +The start line crosses its element near the exit toward the first checkpoint; +checkpoint lines cross their elements near the entrance approached from the +preceding course element. The player registers a gate by crossing that line, +not merely by entering the element. `start` and every checkpoint must be +distinct valid IDs, and checkpoints must be non-empty. A zero +`lap_count` defines a point-to-point race that ends at the final checkpoint. A +positive count defines a lap race: after reaching the final checkpoint, the +player must return to `start` to complete that lap and begin the next one. + +`checkpoint_markers` is optional and defaults to `true`. Set it to `false` to +hide the course's camera-world gate overlays without changing race progression +or timing. The BEV map always displays the active gate as a thick red line. + +## NPC traffic + +The optional `traffic` list defines vehicles that continuously follow the +compiled public-road lane graph: + +```yaml +traffic: + - id: neighborhood_car + nodes: [west_junction, central_intersection, east_junction] + end_behavior: reverse + vehicle_type: car + speed_mps: 11 + start_distance_m: 20 +``` + +`traffic_count` optionally fixes the final number of NPC vehicles. When it is +omitted, the compiler uses the authored `traffic` list unchanged. It must be a +nonnegative integer at least as large as the authored list; a smaller value is +a conflict and fails compilation. When it is larger, the compiler fills the +difference with deterministic default cars distributed across legal public-road +loops and cul-de-sac routes. Generated cars avoid playable spawns and unsafe +initial overlap. Compilation fails with the map's safe capacity when the +requested count cannot be placed. + +The compiled fleet advances logically across the full public-road graph, but +only graph-nearby vehicles enter PhysX and HD-map conditioning. While the ego +is on a road, nearby starts at both endpoint nodes; while the ego is on a node, +it starts at that node. The neighborhood includes public roads attached to +those nodes, the nodes at the other ends of those roads, and every public road +attached to that expanded node set. It stops before adding otherwise-unreached +nodes at the far ends of that final road ring. Leaving mapped road/node +surfaces retains the last valid neighborhood. Invisible vehicles continue +following their routes, speed limits, and same-direction headway. + +`nodes` requires at least two non-parking nodes. Consecutive nodes do not need +to be adjacent: the compiler selects the shortest routable sequence of public +roads and rejects routes that cannot be connected. Parking accesses and +parking-lot interiors are never considered. At each intersection, traffic uses +the rightmost lane for right turns, the leftmost lane for left turns, and +preserves its relative lane for straight travel. Lane-count changes are joined +with a smooth lateral transition. + +`end_behavior: wrap` routes from the final node back to the first without +teleporting. `reverse` traverses the waypoint list in the opposite order while +the vehicle continues to drive forward; the resulting route must have a legal +turnaround. A cul-de-sac endpoint supplies one automatically. + +`vehicle_type` is optional and defaults to `car`; accepted values are `car`, +`truck`, and `bus`. Their dimensions can be overridden with +`dimensions_lwh_m: [length, width, height]`. `speed_mps` is an optional cap on +road speed limits. `start_distance_m` offsets the initial position along the +compiled cyclic route and defaults to zero. Vehicles are physical, collidable, +and maintain simple same-lane headway; traffic signals and right-of-way are not +currently modeled. + +## Spawns and visual variants + +A spawn names an authored road lane and a distance along its directed +centerline. Lane indices follow the effective `lanes` order. + +```yaml +spawns: + - id: taxi_start + road: oak_street + lane: 1 + distance_m: 5 + variants: + default: + image: seed.png + prompt: A forward-facing taxi view in a quiet neighborhood at daylight. +``` + +Every spawn requires a `default` variant. `image` is optional; when omitted (or +set to `null`), the compiler generates a deterministic synthetic first-person +view by projecting the semantic map from that spawn through the runtime front +camera. This fallback shows aligned road surfaces, boundaries, curbs, and +markings, but does not synthesize scenery. Use it as a robust placeholder, not +as a photorealistic authoring result. + +Authored images may be map-relative paths or `package://package/resource` +references. Resolved geometry, compiler and fallback-renderer code, seed +images, and prompts participate in the compiled-map cache key. + +## Validation summary + +Compilation rejects unknown fields and references, missing effective +attributes, duplicate element identifiers, invalid endpoint types, malformed +or discontinuous road paths, invalid node degrees, invalid driveway +relationships, invalid parking polygons or openings, overlapping or +edge-sharing unrelated surfaces, overlapping connected surfaces, mismatched +connection openings, incompatible lane-transition cross-sections, transitions +that consume a road arm, and parking accesses placed on an opening's interior +side. diff --git a/apps/omnidreams_game_engine/README.md b/apps/omnidreams_game_engine/README.md index ced170de0..74a5638f1 100644 --- a/apps/omnidreams_game_engine/README.md +++ b/apps/omnidreams_game_engine/README.md @@ -4,5 +4,10 @@ Reusable model-thread simulation, authored-map, physics, and conditioning components for FlashDreams V2 applications. The engine does not own a runtime loop, presentation backend, or worker thread. +`engine_settings.py` provides strict partial YAML overlays for reusable map, +rendering, presentation, wheel, world-model, and runtime settings. Applications +compose those settings with their own typed defaults and explicit CLI options; +relative paths resolve beside the YAML document. + See the target architecture in [`../../docs/design/crazy_robotaxi_v2_architecture.md`](../../docs/design/crazy_robotaxi_v2_architecture.md). diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py b/apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py new file mode 100644 index 000000000..5cb8a238e --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Explicit command-line option tracking.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence + +_EXPLICIT_ARG_DESTS_ATTR = "_explicit_arg_dests" + + +def explicit_arg_dests( + parser: argparse.ArgumentParser, argv: Sequence[str] +) -> frozenset[str]: + """Return parser destinations whose option strings appear in ``argv``.""" + option_dests = { + option: action.dest + for action in parser._actions + for option in action.option_strings + } + return frozenset( + destination + for token in argv + if (destination := option_dests.get(token.split("=", 1)[0])) is not None + ) + + +def arg_was_explicit(args: argparse.Namespace, destination: str) -> bool: + """Return whether a namespace field came from an explicit CLI option.""" + return destination in getattr(args, _EXPLICIT_ARG_DESTS_ATTR, frozenset()) + + +class ExplicitArgTrackingArgumentParser(argparse.ArgumentParser): + """Record the optional arguments supplied by the user.""" + + def parse_args( + self, + args: Sequence[str] | None = None, + namespace: argparse.Namespace | None = None, + ) -> argparse.Namespace: + """Parse arguments and attach their explicitly supplied destinations.""" + raw_args = sys.argv[1:] if args is None else list(args) + parsed = super().parse_args(raw_args, namespace) + setattr(parsed, _EXPLICIT_ARG_DESTS_ATTR, explicit_arg_dests(self, raw_args)) + return parsed diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py b/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py new file mode 100644 index 000000000..59b0e435b --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Layered settings shared by interactive driving applications.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from omnidreams_game_engine.config import BevConfig, RasterConfig +from omnidreams_game_engine.yaml_config import ( + StrictConfigError, + load_yaml_mapping, + overlay_dataclass, + require_version, +) + + +@dataclass(frozen=True) +class MapLaunchSettings: + """Map selection and cache behavior.""" + + path: Path | None = None + """Selected game-map path; ``None`` requires a CLI selection.""" + + camera: str = "camera_front_wide_120fov" + """Camera identifier selected from the map's available views.""" + + variant: str = "default" + """Visual variant selected from the map.""" + + prompt: str | None = None + """Base prompt override; ``None`` uses the map variant's prompt.""" + + force_recompile: bool = False + """Whether to rebuild the selected map's compiled cache once.""" + + preload_maps: bool = False + """Whether to parse selectable maps during startup.""" + + +@dataclass(frozen=True) +class WorldModelLaunchSettings: + """World-model selection and V2 runtime options.""" + + backend: Literal["raster", "omnidreams"] = "omnidreams" + """Main-camera backend; the V2 game requires ``omnidreams``.""" + + manifest: Path | None = None + """Optional legacy manifest retained for configuration compatibility.""" + + offload_text_encoder: bool = False + """Whether one-shot encoders may be released after initialization.""" + + model_preset: str = "standard" + """V2 packaged pipeline preset.""" + + device: str = "cuda" + """Device used to instantiate the V2 pipeline.""" + + compile: bool | None = None + """Optional override for transformer compilation.""" + + profile_pipeline: bool = False + """Whether to collect synchronized pipeline stage timings.""" + + +@dataclass(frozen=True) +class RenderingSettings: + """Primary-camera and BEV rendering configuration.""" + + raster: RasterConfig = field(default_factory=RasterConfig) + """Primary-camera rasterization settings.""" + + bev: BevConfig = field(default_factory=BevConfig) + """Top-down map rasterization settings.""" + + +@dataclass(frozen=True) +class PresentationSettings: + """Presentation options retained across V1 and V2 hosts.""" + + hud_enabled: bool = True + """Whether the host presents the game HUD.""" + + show_fps: bool = False + """Whether the HUD displays the measured generated-video frame rate.""" + + stream_jpeg_quality: int = 85 + """JPEG quality used by streaming hosts.""" + + stream_scale: float = 1.0 + """Streaming output scale.""" + + +@dataclass(frozen=True) +class WheelSettings: + """Optional steering-wheel configuration.""" + + enabled: bool = True + """Whether steering-wheel input is enabled.""" + + profile: str = "auto" + """Wheel profile name or automatic-selection marker.""" + + +@dataclass(frozen=True) +class EngineRuntimeSettings: + """Operational and V2 diagnostic controls.""" + + cuda_visible_devices: str = "auto" + """CUDA visibility override retained for configuration compatibility.""" + + profile_world_model: bool = False + """Legacy spelling for pipeline profiling.""" + + total_blocks: int | None = None + """Optional bound on generated blocks.""" + + prewarm_blocks: int = 4 + """Hidden neutral blocks generated before presentation.""" + + profile_input_latency: bool = False + """Whether to display input-to-model-frame diagnostics.""" + + +@dataclass(frozen=True) +class EngineSettings: + """Complete durable engine configuration.""" + + map: MapLaunchSettings = field(default_factory=MapLaunchSettings) + """Map selection and cache behavior.""" + + world_model: WorldModelLaunchSettings = field( + default_factory=WorldModelLaunchSettings + ) + """World-model selection and runtime behavior.""" + + rendering: RenderingSettings = field(default_factory=RenderingSettings) + """Primary-camera and BEV rendering settings.""" + + presentation: PresentationSettings = field(default_factory=PresentationSettings) + """Presentation settings shared with non-V2 hosts.""" + + wheel: WheelSettings = field(default_factory=WheelSettings) + """Steering-wheel settings shared with non-V2 hosts.""" + + runtime: EngineRuntimeSettings = field(default_factory=EngineRuntimeSettings) + """Operational and diagnostic settings.""" + + +def load_engine_settings( + path: Path, + *, + base: EngineSettings | None = None, +) -> EngineSettings: + """Overlay a partial engine YAML onto typed settings. + + Args: + path: Engine configuration path. + base: Lower-precedence settings; ``None`` uses typed defaults. + + Returns: + Resolved engine settings. + + Raises: + StrictConfigError: The YAML or merged settings are invalid. + """ + config_path = path.expanduser().resolve() + doc = load_yaml_mapping(config_path) + require_version(doc, "engine") + values = dict(doc) + values.pop("schema_version") + settings = overlay_dataclass( + base or EngineSettings(), values, "engine", base_dir=config_path.parent + ) + _validate_engine_settings(settings) + return settings + + +def _validate_engine_settings(settings: EngineSettings) -> None: + raster = settings.rendering.raster + bev = settings.rendering.bev + if raster.width <= 0 or raster.height <= 0: + raise StrictConfigError("engine.rendering.raster dimensions must be positive") + if raster.near_plane_m >= raster.far_plane_m: + raise StrictConfigError( + "engine.rendering.raster.near_plane_m must be less than far_plane_m" + ) + if raster.fog_start_m >= raster.fog_end_m: + raise StrictConfigError( + "engine.rendering.raster.fog_start_m must be less than fog_end_m" + ) + if bev.width <= 0 or bev.height <= 0 or bev.height_m <= 0.0: + raise StrictConfigError("engine.rendering.bev dimensions must be positive") + if not 0.0 < bev.fov_deg < 180.0: + raise StrictConfigError( + "engine.rendering.bev.fov_deg must be between 0 and 180" + ) + if settings.world_model.backend != "omnidreams": + raise StrictConfigError( + "Crazy Robotaxi V2 requires world_model.backend=omnidreams" + ) + if settings.runtime.total_blocks is not None and settings.runtime.total_blocks <= 0: + raise StrictConfigError("engine.runtime.total_blocks must be positive") + if settings.runtime.prewarm_blocks < 0: + raise StrictConfigError("engine.runtime.prewarm_blocks must be non-negative") diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py index 0726e064f..6c439c452 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/__init__.py @@ -31,6 +31,7 @@ GameMapLineMarking, GameMapNode, GameMapParkingAccess, + GameMapRaceCourse, GameMapRoad, GameMapRoadBoundary, GameMapSpawn, @@ -57,6 +58,7 @@ "GameMapLineMarking", "GameMapNode", "GameMapParkingAccess", + "GameMapRaceCourse", "GameMapRoad", "GameMapRoadBoundary", "GameMapSpawn", diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py index 9b587db5a..8b5648eac 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/_schema.py @@ -34,7 +34,9 @@ "spawns", } ) -_OPTIONAL_ROOT_FIELDS = frozenset({"profiles", "traffic", "traffic_count"}) +_OPTIONAL_ROOT_FIELDS = frozenset( + {"profiles", "race_courses", "traffic", "traffic_count"} +) _PROFILE_ATTRIBUTE_FIELDS = frozenset( { @@ -62,6 +64,24 @@ class GameMapHeader: name: str variants: tuple[GameMapVisualVariant, ...] source_path: Path + race_course_ids: tuple[str, ...] = () + + +def _parse_race_course_ids(doc: dict[str, Any]) -> tuple[str, ...]: + """Read stable course identifiers without compiling map geometry.""" + if "race_courses" not in doc: + return () + raw_courses = _sequence(doc["race_courses"], "race_courses") + if not raw_courses: + raise GameMapError("race_courses must contain at least one course") + course_ids: list[str] = [] + for index, value in enumerate(raw_courses): + raw = _mapping(value, f"race_courses[{index}]") + course_id = str(raw.get("id", "")).strip() + if not course_id or course_id in course_ids: + raise GameMapError(f"Race course id {course_id!r} is empty or duplicated") + course_ids.append(course_id) + return tuple(course_ids) @dataclass(frozen=True) @@ -223,6 +243,7 @@ def load_game_map_header(path: Path) -> GameMapHeader: name=name, variants=_parse_variants(first_spawn, source_path), source_path=source_path, + race_course_ids=_parse_race_course_ids(doc), ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py index 3a5b9b3dd..a3dffb8f9 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/compiler.py @@ -8,6 +8,7 @@ import hashlib import io import json +import math import os import tempfile import zipfile @@ -20,6 +21,9 @@ import yaml from filelock import FileLock from PIL import Image +from shapely.geometry import LineString, Polygon +from shapely.geometry.base import BaseGeometry +from shapely.ops import substring, unary_union from omnidreams_game_engine import camera_defaults from omnidreams_game_engine.camera_defaults import DEFAULT_FRONT_CAMERA_LOGICAL_NAME @@ -38,6 +42,11 @@ # during development; a future release process owns version changes. _COMPILER_VERSION = "1" _START_TIMESTAMP_US = 1_700_000_000_000_000 +_BOUNDARY_CHUNK_CORE_LENGTH_M = 80.0 +"""Maximum non-overlapping span represented by one BEV boundary record.""" + +_BOUNDARY_CHUNK_OVERLAP_M = 5.0 +"""Per-side overlap that hides record endpoints without leaving the perimeter.""" @dataclass(frozen=True) @@ -187,19 +196,111 @@ def _lane_line_rows(game_map: ResolvedGameMap) -> list[dict[str, object]]: return rows +def _line_geometries(geometry: BaseGeometry) -> list[LineString]: + """Return every line component nested in a Shapely geometry.""" + if isinstance(geometry, LineString): + return [geometry] + return [ + line + for part in getattr(geometry, "geoms", ()) + for line in _line_geometries(part) + ] + + +def _cyclic_substring( + line: LineString, start_distance: float, end_distance: float +) -> np.ndarray: + """Extract a wrapping interval from a closed perimeter line. + + Args: + line: Closed surface-boundary ring. + start_distance: Possibly negative start distance along ``line``. + end_distance: Possibly over-length end distance along ``line``. + + Returns: + Ordered XY points for the requested cyclic interval. + """ + length = float(line.length) + ranges = ( + ((length + start_distance, length), (0.0, end_distance)) + if start_distance < 0.0 + else ( + ((start_distance, length), (0.0, end_distance - length)) + if end_distance > length + else ((start_distance, end_distance),) + ) + ) + parts: list[np.ndarray] = [] + for range_start, range_end in ranges: + points = np.asarray( + substring(line, range_start, range_end).coords, + dtype=np.float32, + ) + if parts and np.linalg.norm(parts[-1][-1] - points[0]) <= 1.0e-6: + points = points[1:] + if len(points): + parts.append(points) + return np.concatenate(parts, axis=0) + + +def _boundary_chunks(game_map: ResolvedGameMap) -> tuple[np.ndarray, ...]: + """Build overlapping local chunks along the true road-surface perimeter. + + Args: + game_map: Resolved semantic map whose surfaces define the BEV boundary. + + Returns: + Deterministically ordered XYZ boundary chunks. Every point remains on + the surface-union perimeter, including at intersections and openings. + """ + surfaces = [Polygon(element.surface_world[:, :2]) for element in game_map.elements] + lines = _line_geometries(unary_union(surfaces).boundary) + lines.sort(key=lambda line: tuple(round(value, 6) for value in line.bounds)) + z_m = float(game_map.elements[0].surface_world[0, 2]) + chunks: list[np.ndarray] = [] + for line in lines: + length = float(line.length) + chunk_count = max(1, math.ceil(length / _BOUNDARY_CHUNK_CORE_LENGTH_M)) + if chunk_count == 1: + chunks.append( + np.column_stack( + ( + np.asarray(line.coords, dtype=np.float32), + np.full(len(line.coords), z_m, dtype=np.float32), + ) + ) + ) + continue + core_length = length / chunk_count + for index in range(chunk_count): + points_xy = _cyclic_substring( + line, + index * core_length - _BOUNDARY_CHUNK_OVERLAP_M, + (index + 1) * core_length + _BOUNDARY_CHUNK_OVERLAP_M, + ) + chunks.append( + np.column_stack( + ( + points_xy, + np.full(len(points_xy), z_m, dtype=np.float32), + ) + ) + ) + return tuple(chunks) + + def _boundary_rows(game_map: ResolvedGameMap) -> list[dict[str, object]]: return [ { - "key": _key(game_map, boundary.boundary_id), + "key": _key(game_map, f"road_boundary:{index}"), "road_boundary": { - "location": [_point(point) for point in boundary.polyline_world], + "location": [_point(point) for point in chunk], "category": "road_boundary", "egomotion_label_class_id": "ego", }, "version": 1, } - for element in game_map.elements - for boundary in element.road_boundaries + for index, chunk in enumerate(_boundary_chunks(game_map)) ] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py index f756ec97c..ece7e5d3e 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/loader.py @@ -47,6 +47,7 @@ GameMapLinearAttributes, GameMapNode, GameMapParkingAccess, + GameMapRaceCourse, GameMapRoad, GameMapRoadBoundary, GameMapSpawn, @@ -998,6 +999,88 @@ def _validate_topology(topology: GameMapTopology) -> None: ) +def _parse_race_courses( + doc: dict[str, Any], topology: GameMapTopology +) -> tuple[GameMapRaceCourse, ...]: + """Validate ordered race courses against authored nodes and roads.""" + if "race_courses" not in doc: + return () + values = _sequence(doc["race_courses"], "race_courses") + if not values: + raise GameMapError("race_courses must contain at least one course") + valid_elements = { + *(node.node_id for node in topology.nodes), + *(road.road_id for road in topology.roads), + } + courses: list[GameMapRaceCourse] = [] + course_ids: set[str] = set() + for index, value in enumerate(values): + raw = _mapping(value, f"race_courses[{index}]") + required = {"id", "start", "checkpoints", "lap_count"} + allowed = required | {"checkpoint_markers"} + if not required <= set(raw) or not set(raw) <= allowed: + raise GameMapError( + f"race_courses[{index}] requires {sorted(required)} and optionally " + "'checkpoint_markers'" + ) + course_id = str(raw["id"]).strip() + if not course_id or course_id in course_ids: + raise GameMapError(f"Race course id {course_id!r} is empty or duplicated") + course_ids.add(course_id) + start = str(raw["start"]).strip() + if start not in valid_elements: + raise GameMapError( + f"Race course {course_id!r} start references unknown node or road " + f"{start!r}" + ) + checkpoints = tuple( + str(item).strip() + for item in _sequence( + raw["checkpoints"], f"race course {course_id!r}.checkpoints" + ) + ) + if not checkpoints: + raise GameMapError( + f"Race course {course_id!r} requires at least one checkpoint" + ) + if any(not checkpoint for checkpoint in checkpoints): + raise GameMapError( + f"Race course {course_id!r} checkpoints must not be empty" + ) + if len(set(checkpoints)) != len(checkpoints): + raise GameMapError(f"Race course {course_id!r} checkpoints must be unique") + if start in checkpoints: + raise GameMapError( + f"Race course {course_id!r} may not reuse start as a checkpoint" + ) + unknown = [item for item in checkpoints if item not in valid_elements] + if unknown: + raise GameMapError( + f"Race course {course_id!r} checkpoints reference unknown nodes or " + f"roads {unknown}" + ) + lap_count = raw["lap_count"] + if type(lap_count) is not int or lap_count < 0: + raise GameMapError( + f"Race course {course_id!r}.lap_count must be a nonnegative integer" + ) + checkpoint_markers = raw.get("checkpoint_markers", True) + if type(checkpoint_markers) is not bool: + raise GameMapError( + f"Race course {course_id!r}.checkpoint_markers must be a boolean" + ) + courses.append( + GameMapRaceCourse( + course_id=course_id, + start_element_id=start, + checkpoint_element_ids=checkpoints, + lap_count=lap_count, + checkpoint_markers=checkpoint_markers, + ) + ) + return tuple(courses) + + def _sample_road(spec: _RoadSpec, spacing_m: float) -> np.ndarray: if not spec.spans_xy: raise AssertionError("Straight road sampling requires node positions") @@ -2684,6 +2767,7 @@ def load_game_map(path: Path) -> ResolvedGameMap: ), ) _validate_topology(topology) + race_courses = _parse_race_courses(doc, topology) raw_roads: dict[str, np.ndarray] = {} for spec in road_specs: @@ -3025,5 +3109,6 @@ def load_game_map(path: Path) -> ResolvedGameMap: ground_vertices=ground_vertices, ground_faces=np.asarray([[0, 1, 2], [0, 2, 3]], dtype=np.int32), spawns=spawns, + race_courses=race_courses, traffic=traffic, ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py index 98ac547c3..f7b50de10 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/game_map/types.py @@ -244,6 +244,26 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True) +class GameMapRaceCourse: + """One ordered race course authored from map nodes and roads.""" + + course_id: str + """Stable course identifier scoped to the containing map.""" + + start_element_id: str + """Node or road surface that starts the timer and closes each lap.""" + + checkpoint_element_ids: tuple[str, ...] + """Ordered node or road surfaces that must be reached.""" + + lap_count: int + """Required laps, or zero for a point-to-point course.""" + + checkpoint_markers: bool = True + """Whether presenters display camera-view start and checkpoint markers.""" + + @dataclass(frozen=True) class GameMapLane: """Explicit directed lane and its legal successors.""" @@ -428,6 +448,9 @@ class ResolvedGameMap: spawns: tuple[GameMapSpawn, ...] """Playable vehicle spawns.""" + race_courses: tuple[GameMapRaceCourse, ...] = () + """Optional ordered race courses authored for this map.""" + traffic: tuple[GameMapTrafficVehicle, ...] = () """Map-authored vehicles with compiled cyclic public-road routes.""" @@ -581,6 +604,16 @@ def game_map_to_dict(game_map: ResolvedGameMap) -> dict[str, Any]: } for spawn in game_map.spawns ], + "race_courses": [ + { + "course_id": course.course_id, + "start_element_id": course.start_element_id, + "checkpoint_element_ids": list(course.checkpoint_element_ids), + "lap_count": course.lap_count, + "checkpoint_markers": course.checkpoint_markers, + } + for course in game_map.race_courses + ], "traffic": [ { "vehicle_id": vehicle.vehicle_id, @@ -818,6 +851,18 @@ def game_map_from_dict(value: dict[str, Any]) -> ResolvedGameMap: ) for raw in value.get("traffic", []) ) + race_courses = tuple( + GameMapRaceCourse( + course_id=str(raw["course_id"]), + start_element_id=str(raw["start_element_id"]), + checkpoint_element_ids=tuple( + str(item) for item in raw["checkpoint_element_ids"] + ), + lap_count=int(raw["lap_count"]), + checkpoint_markers=bool(raw.get("checkpoint_markers", True)), + ) + for raw in value.get("race_courses", []) + ) return ResolvedGameMap( schema_version=int(value["schema_version"]), map_id=str(value["map_id"]), @@ -846,5 +891,6 @@ def game_map_from_dict(value: dict[str, Any]) -> ResolvedGameMap: ground_vertices=np.asarray(value["ground_vertices"], dtype=np.float32), ground_faces=np.asarray(value["ground_faces"], dtype=np.int32), spawns=spawns, + race_courses=race_courses, traffic=traffic, ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py index c5415cba3..fa20ed97f 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py @@ -69,6 +69,7 @@ def __init__( self._postprocess = postprocess self.engine = engine_factory() self.cache = self._new_cache() + self._attach_live_edit(None) self._closed = False @property @@ -105,6 +106,15 @@ def step( pipeline_wall_started = time.perf_counter() pipeline_cpu_started = time.thread_time() + live_edit = getattr(self.engine, "live_edit", None) + prepare_model_step = getattr(live_edit, "prepare_model_step", None) + if callable(prepare_model_step): + prepare_model_step( + self.pipeline, + self.engine, + engine_step, + autoregressive_index, + ) with torch.no_grad(): video = self.pipeline.generate( autoregressive_index=autoregressive_index, @@ -122,6 +132,11 @@ def step( postprocess_cpu_started = time.thread_time() if self._postprocess is not None: video = self._postprocess(video) + else: + live_edit = getattr(self.engine, "live_edit", None) + live_edit_postprocess = getattr(live_edit, "postprocess_video", None) + if callable(live_edit_postprocess): + video = live_edit_postprocess(video, engine_step) postprocess_wall_ms = (time.perf_counter() - postprocess_wall_started) * 1000.0 postprocess_cpu_ms = (time.thread_time() - postprocess_cpu_started) * 1000.0 if video.ndim != 6 or tuple(video.shape[:2]) != (1, 1): @@ -156,9 +171,11 @@ def reset(self) -> None: """Recreate all mutable rollout state while retaining model weights.""" if self._closed: raise RuntimeError("WorldModelRollout is closed") + previous_live_edit = getattr(self.engine, "live_edit", None) self.engine.close() self.engine = self._engine_factory() self.cache = self._new_cache() + self._attach_live_edit(previous_live_edit) def close(self) -> None: """Release all session-local resources.""" @@ -178,6 +195,32 @@ def _new_cache(self) -> Any: view_names=[self.scene.selected_camera.logical_name], ) + def _attach_live_edit(self, previous: Any | None) -> None: + live_edit = getattr(self.engine, "live_edit", None) + if live_edit is None: + return + adopt_model_state = getattr(live_edit, "adopt_model_state", None) + if previous is not None and callable(adopt_model_state): + adopt_model_state( + previous, + self.pipeline, + self.cache, + self.scene.prompt, + ) + return + attach_model = getattr(live_edit, "attach_model", None) + if callable(attach_model): + attach_model(self.pipeline) + style = getattr(live_edit, "style", None) + if style is None: + return + style.attach_v2( + self.pipeline, + self.cache, + self.scene.prompt, + seconds_per_chunk=float(self.frame_count(1)) / 30.0, + ) + def _initial_image_tensor(image: object, *, device: torch.device | str) -> Tensor: array = np.asarray(image, dtype=np.uint8)[..., :3].copy(order="C") diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py new file mode 100644 index 000000000..a0639665b --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Neutral gameplay-controller contract for optional physical actors.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from ludus_renderer import BodyState, SceneObject + + +@dataclass(frozen=True) +class ActorControlDecision: + """Native actuator state returned by a gameplay actor controller.""" + + drive_enabled: bool + detached_from_track: bool + + +@dataclass(frozen=True) +class ActorTrackTarget: + """Logical route target for one gameplay-owned physical actor.""" + + object_id: str + """Stable object identifier shared with the physics graph.""" + + timestamp_us: int + """Logical timestamp to sample from the actor's source track.""" + + velocity_scale: float = 1.0 + """Track velocity multiplier within ``[0, 1]``.""" + + loop_duration_us: int | None = None + """Track loop duration; ``None`` clamps at the source track endpoints.""" + + +class PhysicsActorController(Protocol): + """Gameplay owner for a set of optional PhysX-driven scene objects. + + Controllers own actor intent and lifecycle. Rendering and physics remain + downstream consumers of that state; the physics world only combines the + active objects and routes observations back to their unique owner. + """ + + @property + def objects(self) -> tuple[SceneObject, ...]: ... + + @property + def active_objects(self) -> tuple[SceneObject, ...]: ... + + @property + def active_object_ids(self) -> frozenset[str]: ... + + @property + def active_timestamps_us(self) -> dict[str, int]: ... + + @property + def object_ids(self) -> frozenset[str]: ... + + @property + def max_drive_speeds_mps(self) -> dict[str, float]: ... + + def prepare_topology(self, ego: BodyState) -> None: ... + + def prepare_step( + self, ego: BodyState, dt_s: float + ) -> tuple[ActorTrackTarget, ...]: ... + + def observe_physics( + self, + object_id: str, + *, + struck: bool, + body: BodyState, + dt_s: float, + ) -> ActorControlDecision | None: ... + + +__all__ = ["ActorControlDecision", "ActorTrackTarget", "PhysicsActorController"] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py index 52167786c..5835e4376 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py @@ -28,7 +28,6 @@ BodyState, InvisibleBarrier, PhysicsObjectGraph, - PhysXWorld, RigidBodyModel, SceneObject, ) @@ -38,16 +37,19 @@ GameMapVicinity, GameMapVicinityResolver, ) +from omnidreams_game_engine.simulation.actor_controller import ( + PhysicsActorController, +) from omnidreams_game_engine.simulation.components import ( BoxColliderComponent, GameEntity, RigidBodyComponent, TransformComponent, - rigid_body_model_for_object, rigid_body_model_from_vehicle_config, suspension_for_object, vehicle_dynamics_for_object, ) +from omnidreams_game_engine.simulation.gameplay_physx import GameplayPhysXWorld from omnidreams_game_engine.simulation.map_traffic import MapTrafficController from omnidreams_game_engine.types import ( DynamicActorTrajectory, @@ -327,6 +329,7 @@ def __init__( model_adapter: Callable[[RigidBodyModel], RigidBodyModel] | None = None, static_barrier_segments_world: np.ndarray | None = None, static_barrier_restitution: float | None = None, + actor_controllers: tuple[PhysicsActorController, ...] = (), ) -> None: started_at = time.perf_counter() self._vehicle = vehicle @@ -343,6 +346,10 @@ def __init__( () if game_map is None else game_map.traffic, vehicle, ) + self._actor_controllers: tuple[PhysicsActorController, ...] = ( + self._map_traffic, + *actor_controllers, + ) if ( vehicle.static_collision_enabled and static_barrier_segments_world is not None @@ -388,7 +395,8 @@ def __init__( _PHYSX_SIMULATION_RADIUS_M, timestamp_us=initial_timestamp_us, ) - self._physics_graph = self._with_active_map_traffic(base_physics_graph) + self._physics_graph = self._with_active_controller_objects(base_physics_graph) + self._synchronized_controller_ids = self._controller_active_ids() self._active_objects_by_id = { scene_object.object_id: scene_object for scene_object in self._physics_graph.objects @@ -412,45 +420,96 @@ def __init__( self._visual_flare_collision_deadline_us: int | None = None self._pending_struck_vehicle_ids: set[str] = set() self._ego_model = adapt_model(_ego_model(vehicle)) - self._world = PhysXWorld( + self._world = GameplayPhysXWorld( base_physics_graph, self._ego_model, actor_collision_enabled=vehicle.actor_collision_enabled, max_actor_drive_speed_mps=_NON_EGO_MAX_DRIVE_SPEED_MPS, - max_actor_drive_speeds_mps=self._map_traffic.max_drive_speeds_mps, + max_actor_drive_speeds_mps=self._controller_drive_speed_caps(), ) self._world.synchronize( self._physics_graph, timestamp_us=initial_timestamp_us, - initial_object_timestamps_us=self._map_traffic.active_timestamps_us, + initial_object_timestamps_us=self._controller_initial_timestamps(), ) self._refresh_debug_barriers() logger.info( "[physics] PhysX graph ready in {:.1f} ms; objects={}/{} barriers={}/{} simulation_radius_m={:.0f}", (time.perf_counter() - started_at) * 1000.0, len(self._physics_graph.objects), - len(self.graph.objects) + len(self._map_traffic.objects), + len(self.graph.objects) + + sum(len(controller.objects) for controller in self._actor_controllers), len(self._physics_graph.barriers), len(self.graph.barriers), _PHYSX_SIMULATION_RADIUS_M, ) - def _with_active_map_traffic( + def _controller_owners(self) -> dict[str, PhysicsActorController]: + """Return the unique gameplay owner of every controller actor.""" + owners: dict[str, PhysicsActorController] = {} + scene_ids = {scene_object.object_id for scene_object in self.graph.objects} + for controller in self._actor_controllers: + for object_id in controller.object_ids: + if object_id in scene_ids: + raise ValueError( + f"controller actor ID {object_id!r} conflicts with a scene actor" + ) + if object_id in owners: + raise ValueError( + f"controller actor ID {object_id!r} has multiple owners" + ) + owners[object_id] = controller + return owners + + def _controller_active_ids(self) -> frozenset[str]: + return frozenset( + object_id + for controller in self._actor_controllers + for object_id in controller.active_object_ids + ) + + def _controller_initial_timestamps(self) -> dict[str, int]: + timestamps: dict[str, int] = {} + for controller in self._actor_controllers: + for object_id, timestamp_us in controller.active_timestamps_us.items(): + if object_id in timestamps: + raise ValueError( + f"controller actor ID {object_id!r} has multiple timestamps" + ) + timestamps[object_id] = timestamp_us + return timestamps + + def _controller_drive_speed_caps(self) -> dict[str, float]: + speed_caps: dict[str, float] = {} + for controller in self._actor_controllers: + for object_id, speed_mps in controller.max_drive_speeds_mps.items(): + if object_id in speed_caps: + raise ValueError( + f"controller actor ID {object_id!r} has multiple speed caps" + ) + speed_caps[object_id] = speed_mps + return speed_caps + + def _with_active_controller_objects( self, physics_graph: PhysicsObjectGraph ) -> PhysicsObjectGraph: - """Add only graph-nearby procedural traffic to a PhysX window.""" + """Add active gameplay-owned actors to a PhysX window.""" incoming_ids = { scene_object.object_id for scene_object in physics_graph.objects } - additions = tuple( - scene_object - for scene_object in self._map_traffic.active_objects - if scene_object.object_id not in incoming_ids - ) + additions: list[SceneObject] = [] + for controller in self._actor_controllers: + for scene_object in controller.active_objects: + if scene_object.object_id in incoming_ids: + raise ValueError( + f"active actor ID {scene_object.object_id!r} is duplicated" + ) + incoming_ids.add(scene_object.object_id) + additions.append(scene_object) if not additions: return physics_graph return PhysicsObjectGraph( - objects=physics_graph.objects + additions, + objects=physics_graph.objects + tuple(additions), barriers=physics_graph.barriers, ) @@ -529,6 +588,8 @@ def synchronize_window( self, center_xy_m: np.ndarray, timestamp_us: int | None = None, + *, + force_controller_refresh: bool = False, ) -> bool: """Incrementally recenter active PhysX topology when the ego moves.""" center = np.asarray(center_xy_m, dtype=np.float32) @@ -553,22 +614,29 @@ def synchronize_window( <= timestamp_us - self._physics_timestamp_us < _PHYSX_TOPOLOGY_REFRESH_INTERVAL_US ) - if center_is_current and timestamp_is_current and not traffic_topology_changed: + if ( + center_is_current + and timestamp_is_current + and not traffic_topology_changed + and not force_controller_refresh + ): return False physics_graph = self.graph.copy_for_physx( center, _PHYSX_SIMULATION_RADIUS_M, timestamp_us=timestamp_us, ) - physics_graph = self._with_active_map_traffic(physics_graph) + physics_graph = self._with_active_controller_objects(physics_graph) incoming_ids = {obj.object_id for obj in physics_graph.objects} + controller_ids = frozenset(self._controller_owners()) + active_controller_ids = self._controller_active_ids() retained_detached = tuple( obj for obj in self._physics_graph.objects if obj.object_id in self._detached_entity_ids and ( - obj.object_id not in self._map_traffic.object_ids - or obj.object_id in self._map_traffic.active_object_ids + obj.object_id not in controller_ids + or obj.object_id in active_controller_ids ) and obj.object_id not in incoming_ids ) @@ -577,10 +645,11 @@ def synchronize_window( objects=physics_graph.objects + retained_detached, barriers=physics_graph.barriers, ) + self._world.set_actor_drive_speed_caps(self._controller_drive_speed_caps()) self._world.synchronize( physics_graph, timestamp_us=timestamp_us, - initial_object_timestamps_us=self._map_traffic.active_timestamps_us, + initial_object_timestamps_us=self._controller_initial_timestamps(), ) existing_entities = {entity.entity_id: entity for entity in self._entities} self._entities = [ @@ -591,6 +660,7 @@ def synchronize_window( self._entities_by_id = {entity.entity_id: entity for entity in self._entities} self._detached_entity_ids.intersection_update(self._entities_by_id) self._physics_graph = physics_graph + self._synchronized_controller_ids = active_controller_ids self._active_objects_by_id = { scene_object.object_id: scene_object for scene_object in self._physics_graph.objects @@ -778,7 +848,22 @@ def step( state, self._ego_model.half_extents_m[2] ) traffic_prepare_started_at = time.perf_counter() - self._map_traffic.prepare_step(self._world, ego_before_step, dt_s) + for controller in self._actor_controllers: + controller.prepare_topology(ego_before_step) + if self._controller_active_ids() != self._synchronized_controller_ids: + self.synchronize_window( + np.asarray(ego_before_step.position_m[:2], dtype=np.float32), + timestamp_us, + force_controller_refresh=True, + ) + actor_targets = tuple( + target + for controller in self._actor_controllers + for target in controller.prepare_step(ego_before_step, dt_s) + ) + self._world.apply_actor_track_targets( + actor_targets, rollout_timestamp_us=timestamp_us + ) traffic_prepare_ms = (time.perf_counter() - traffic_prepare_started_at) * 1000.0 physics_step = self._world.step_compact( ego_before_step, @@ -836,13 +921,13 @@ def step( continue impact_normal_xy = separation_xy / separation_m traffic_state = self._map_traffic.state(object_id) - if traffic_state is None: - continue - _, _, track_velocity_mps = scene_object.sample( - int(traffic_state.timestamp_us) - ) + actor_velocity_mps = body.linear_velocity_mps + if traffic_state is not None: + _, _, actor_velocity_mps = scene_object.sample( + int(traffic_state.timestamp_us) + ) relative_velocity_xy = ( - track_velocity_mps[:2] - ego_before_step.linear_velocity_mps[:2] + actor_velocity_mps[:2] - ego_before_step.linear_velocity_mps[:2] ) closing_speed_mps = -float( np.dot(relative_velocity_xy, impact_normal_xy) @@ -884,17 +969,25 @@ def step( self._pending_struck_vehicle_ids.clear() actor_samples = [] pending_controls = [] + controller_owners = self._controller_owners() + actor_bodies: dict[str, BodyState] = {} for object_id, body, _native_detached in physics_step.actor_samples: - decision = self._map_traffic.observe_physics( + controller = controller_owners.get(object_id) + if controller is None: + raise RuntimeError(f"PhysX returned unmanaged actor {object_id!r}") + decision = controller.observe_physics( object_id, struck=object_id in physics_step.struck_object_ids, body=body, dt_s=dt_s, ) if decision is None: - raise RuntimeError(f"PhysX returned unmanaged NPC {object_id!r}") + raise RuntimeError( + f"actor controller rejected owned actor {object_id!r}" + ) detached = decision.detached_from_track pending_controls.append((object_id, decision.drive_enabled, detached)) + actor_bodies[object_id] = body actor_samples.append( (object_id, body.position_m, body.orientation_xyzw, detached) ) @@ -909,14 +1002,10 @@ def step( entity = self._entities_by_id[object_id] entity.transform.position_m = position.copy() entity.transform.orientation_xyzw = orientation.copy() - traffic_state = self._map_traffic.state(object_id) - if traffic_state is None: - raise RuntimeError(f"Missing state for managed NPC {object_id!r}") - entity.rigid_body.linear_velocity_mps = ( - traffic_state.linear_velocity_mps.copy() - ) + body = actor_bodies[object_id] + entity.rigid_body.linear_velocity_mps = body.linear_velocity_mps.copy() entity.rigid_body.angular_velocity_radps = ( - traffic_state.angular_velocity_radps.copy() + body.angular_velocity_radps.copy() ) entity.detached_from_track = detached self._detached_entity_ids = detached_ids @@ -1029,9 +1118,12 @@ def build_trajectories( samples_by_frame: list[tuple[tuple[str, np.ndarray, np.ndarray, bool], ...]], ) -> tuple[DynamicActorTrajectory, ...]: """Pack PhysX object samples for Ludus RGB and BEV HD-map rendering.""" - map_traffic = getattr(self, "_map_traffic", None) - active_map_objects = () if map_traffic is None else map_traffic.active_objects - if (not self.graph.objects and not active_map_objects) or not samples_by_frame: + controller_objects = tuple( + scene_object + for controller in getattr(self, "_actor_controllers", ()) + for scene_object in controller.active_objects + ) + if (not self.graph.objects and not controller_objects) or not samples_by_frame: return () result: list[DynamicActorTrajectory] = [] simulated_timestamps = np.asarray(timestamps_us, dtype=np.int64) @@ -1041,7 +1133,7 @@ def build_trajectories( simulated_ids = { object_id for frame in samples_by_id for object_id in frame.keys() } - render_objects = (*self.graph.objects, *active_map_objects) + render_objects = (*self.graph.objects, *controller_objects) for scene_object in render_objects: physically_simulated = scene_object.object_id in simulated_ids if physically_simulated: diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py new file mode 100644 index 000000000..4cd6df395 --- /dev/null +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Game-owned adaptation of Ludus PhysX tracks for procedural actors.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping + +import numpy as np +from ludus_renderer import PhysicsObjectGraph, PhysXWorld, RigidBodyModel, SceneObject + +from omnidreams_game_engine.simulation.actor_controller import ActorTrackTarget + +_TRACK_SAMPLE_WINDOW_US = 100_000 +"""Half-width of the local native track window around each physics step.""" + + +def _sample_target_track( + scene_object: SceneObject, + timestamp_us: int, + *, + loop_duration_us: int | None, +) -> tuple[np.ndarray, np.ndarray]: + if loop_duration_us is not None: + timestamp_us %= loop_duration_us + position, orientation, _ = scene_object.sample(timestamp_us) + return position, orientation + + +class GameplayPhysXWorld(PhysXWorld): + """Adapt gameplay-owned actor clocks to Ludus's global track clock. + + Ludus samples every native track from the rollout timestamp. This adapter + publishes a small, rollout-aligned window for each procedural actor while + leaving recorded scene objects on Ludus's normal global timeline. + """ + + def __init__( + self, + graph: PhysicsObjectGraph, + ego_model: RigidBodyModel, + *, + actor_collision_enabled: bool = True, + max_actor_drive_speed_mps: float | None = None, + max_actor_drive_speeds_mps: Mapping[str, float] | None = None, + capacity: int | None = None, + ) -> None: + self._gameplay_drive_speed_caps: dict[str, float] = {} + self.set_actor_drive_speed_caps(max_actor_drive_speeds_mps or {}) + super().__init__( + graph, + ego_model, + actor_collision_enabled=actor_collision_enabled, + max_actor_drive_speed_mps=max_actor_drive_speed_mps, + capacity=capacity, + ) + if not hasattr(self._scene, "set_body_track") or not hasattr( + self, "_object_native_ids" + ): + raise RuntimeError( + "the installed ludus_renderer lacks the native track replacement " + "bridge required by gameplay actors" + ) + + def set_actor_drive_speed_caps(self, speed_caps_mps: Mapping[str, float]) -> None: + """Replace drive-speed caps applied when gameplay actors are inserted.""" + caps = {object_id: float(speed) for object_id, speed in speed_caps_mps.items()} + if any(not math.isfinite(speed) or speed <= 0.0 for speed in caps.values()): + raise ValueError("per-actor drive speeds must be finite and positive") + self._gameplay_drive_speed_caps = caps + + def add_object( + self, scene_object: SceneObject, *, timestamp_us: int | None = None + ) -> None: + """Add an object with its configured gameplay drive-speed cap.""" + default_speed = self.max_actor_drive_speed_mps + self.max_actor_drive_speed_mps = self._gameplay_drive_speed_caps.get( + scene_object.object_id, default_speed + ) + try: + super().add_object(scene_object, timestamp_us=timestamp_us) + finally: + self.max_actor_drive_speed_mps = default_speed + + def synchronize( + self, + graph: PhysicsObjectGraph, + *, + timestamp_us: int | None = None, + initial_object_timestamps_us: Mapping[str, int] | None = None, + ) -> None: + """Synchronize topology with per-object initial logical timestamps.""" + incoming_objects = {value.object_id: value for value in graph.objects} + for object_id in tuple(self._objects): + if object_id not in incoming_objects: + self.remove_object(object_id) + for object_id, scene_object in incoming_objects.items(): + current = self._objects.get(object_id) + if current is scene_object: + continue + if current is not None: + self.remove_object(object_id) + initial_timestamp = ( + None + if initial_object_timestamps_us is None + else initial_object_timestamps_us.get(object_id) + ) + self.add_object( + scene_object, + timestamp_us=( + timestamp_us if initial_timestamp is None else initial_timestamp + ), + ) + super().synchronize(graph, timestamp_us=timestamp_us) + + def apply_actor_track_targets( + self, + targets: tuple[ActorTrackTarget, ...], + *, + rollout_timestamp_us: int, + ) -> None: + """Publish logical actor targets on Ludus's rollout clock.""" + if not targets: + return + native_timestamps = np.asarray( + [ + rollout_timestamp_us - _TRACK_SAMPLE_WINDOW_US, + rollout_timestamp_us, + rollout_timestamp_us + _TRACK_SAMPLE_WINDOW_US, + ], + dtype=np.int64, + ) + for target in targets: + if target.object_id not in self._objects: + raise KeyError(target.object_id) + if not math.isfinite(target.velocity_scale) or not ( + 0.0 <= target.velocity_scale <= 1.0 + ): + raise ValueError("track velocity scale must be within [0, 1]") + if target.loop_duration_us is not None and target.loop_duration_us <= 0: + raise ValueError("track loop duration must be positive") + + logical_window_us = int( + round(_TRACK_SAMPLE_WINDOW_US * target.velocity_scale) + ) + logical_timestamps = ( + target.timestamp_us - logical_window_us, + target.timestamp_us, + target.timestamp_us + logical_window_us, + ) + scene_object = self._objects[target.object_id] + samples = tuple( + _sample_target_track( + scene_object, + timestamp, + loop_duration_us=target.loop_duration_us, + ) + for timestamp in logical_timestamps + ) + self._scene.set_body_track( + self._object_native_ids[target.object_id], + native_timestamps, + np.ascontiguousarray( + np.stack([sample[0] for sample in samples]), dtype=np.float32 + ), + np.ascontiguousarray( + np.stack([sample[1] for sample in samples]), dtype=np.float32 + ), + -1.0, + ) + + +__all__ = ["GameplayPhysXWorld"] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py index 53892f793..82552f962 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py @@ -10,11 +10,15 @@ from enum import Enum import numpy as np -from ludus_renderer import BodyState, PhysXWorld, SceneObject +from ludus_renderer import BodyState, SceneObject from omnidreams_game_engine.config import VehicleConfig from omnidreams_game_engine.game_map.types import GameMapTrafficVehicle from omnidreams_game_engine.game_map.vicinity import GameMapVicinity +from omnidreams_game_engine.simulation.actor_controller import ( + ActorControlDecision, + ActorTrackTarget, +) from omnidreams_game_engine.simulation.components import rigid_body_model_for_object _OBJECT_ID_PREFIX = "map-traffic:" @@ -25,6 +29,7 @@ _MAX_HEADING_DELTA_RAD = math.radians(40.0) _HEADWAY_GRID_CELL_M = 64.0 _RESTART_AFTER_STOPPED_S = 1.0 +_MAX_COLLISION_SETTLING_S = 3.0 _STOPPED_LINEAR_SPEED_MPS = 0.10 _STOPPED_ANGULAR_SPEED_RADPS = 0.10 _RECOVERED_POSITION_ERROR_M = 0.60 @@ -46,12 +51,9 @@ class MapTrafficPhase(str, Enum): RECOVERING = "recovering" -@dataclass(frozen=True) -class MapTrafficDecision: - """Native actuator and renderer state derived from an NPC's phase.""" - - drive_enabled: bool - detached_from_track: bool +# Compatibility name for callers and tests written before external gameplay +# actor controllers were supported. +MapTrafficDecision = ActorControlDecision @dataclass @@ -72,13 +74,14 @@ class MapTrafficVehicleState: velocity_scale: float = 1.0 phase: MapTrafficPhase = MapTrafficPhase.TRAVERSING stopped_duration_s: float = 0.0 + collision_duration_s: float = 0.0 @property - def decision(self) -> MapTrafficDecision: + def decision(self) -> ActorControlDecision: """Return control outputs derived solely from the gameplay phase.""" - return MapTrafficDecision( + return ActorControlDecision( drive_enabled=self.phase is not MapTrafficPhase.COLLISION, - detached_from_track=self.phase is not MapTrafficPhase.TRAVERSING, + detached_from_track=self.phase is MapTrafficPhase.COLLISION, ) @property @@ -171,6 +174,7 @@ def __init__( traffic: tuple[GameMapTrafficVehicle, ...], vehicle: VehicleConfig, ) -> None: + self._ego_half_length_m = vehicle.aabb_length_m * 0.5 states: list[MapTrafficVehicleState] = [] for definition in traffic: scene_object, start_timestamp_us, duration_us = _route_track( @@ -264,6 +268,7 @@ def _set_route_snapshot(state: MapTrafficVehicleState) -> None: def _reset_offscreen(cls, state: MapTrafficVehicleState) -> None: state.phase = MapTrafficPhase.TRAVERSING state.stopped_duration_s = 0.0 + state.collision_duration_s = 0.0 state.velocity_scale = 1.0 cls._set_route_snapshot(state) @@ -460,9 +465,11 @@ def observe_physics( if struck: state.phase = MapTrafficPhase.COLLISION state.stopped_duration_s = 0.0 + state.collision_duration_s = 0.0 return state.decision if state.phase is MapTrafficPhase.COLLISION: + state.collision_duration_s += dt_s linear_speed = float(np.linalg.norm(body.linear_velocity_mps[:2])) angular_speed = float(np.linalg.norm(body.angular_velocity_radps)) stopped = ( @@ -472,13 +479,17 @@ def observe_physics( state.stopped_duration_s = ( state.stopped_duration_s + dt_s if stopped else 0.0 ) - if state.stopped_duration_s >= _RESTART_AFTER_STOPPED_S: + if ( + state.stopped_duration_s >= _RESTART_AFTER_STOPPED_S + or state.collision_duration_s >= _MAX_COLLISION_SETTLING_S + ): self._apply_route_projection( state, self._nearest_route_projection(state, body.position_m[:2]), ) state.phase = MapTrafficPhase.RECOVERING state.stopped_duration_s = 0.0 + state.collision_duration_s = 0.0 state.velocity_scale = 1.0 elif state.phase is MapTrafficPhase.RECOVERING and self._is_recovered( state, body @@ -536,8 +547,27 @@ def _headway_scale( ) ) - def prepare_step(self, world: PhysXWorld, ego: BodyState, dt_s: float) -> None: - """Advance all logical cars and publish active tracks in one native batch.""" + def prepare_topology(self, ego: BodyState) -> None: + """Keep the stable authored traffic set unchanged between steps.""" + del ego + + def prepare_step( + self, + ego_or_world: BodyState | object, + dt_or_ego: float | BodyState, + legacy_dt_s: float | None = None, + ) -> tuple[ActorTrackTarget, ...]: + """Advance logical cars and return targets for active physical bodies.""" + legacy_world = None + if legacy_dt_s is None: + ego = ego_or_world + dt_s = float(dt_or_ego) + else: + legacy_world = ego_or_world + ego = dt_or_ego + dt_s = float(legacy_dt_s) + if not isinstance(ego, BodyState): + raise TypeError("prepare_step requires an ego BodyState") for state in self._states: if state.phase is MapTrafficPhase.TRAVERSING: if state.object_id in self._active_ids: @@ -560,7 +590,7 @@ def prepare_step(self, world: PhysXWorld, ego: BodyState, dt_s: float) -> None: ego_observation = _TrafficObservation( position_xy=np.asarray(ego.position_m[:2], dtype=np.float32), velocity_xy=np.asarray(ego.linear_velocity_mps[:2], dtype=np.float32), - half_length_m=float(world.ego_model.half_extents_m[0]), + half_length_m=self._ego_half_length_m, ) buckets: dict[tuple[int, int], list[_TrafficObservation]] = {} for observation in (*observations.values(), ego_observation): @@ -581,17 +611,24 @@ def prepare_step(self, world: PhysXWorld, ego: BodyState, dt_s: float) -> None: if state.phase is MapTrafficPhase.COLLISION else self._headway_scale(observation, candidates) ) - world.apply_track_progress( - tuple( - ( - state.object_id, - self._drive_target_timestamp_us(state), - state.velocity_scale, - ) - for state in self._states - if state.object_id in self._active_ids + targets = tuple( + ActorTrackTarget( + object_id=state.object_id, + timestamp_us=self._drive_target_timestamp_us(state), + velocity_scale=state.velocity_scale, + loop_duration_us=state.duration_us, ) + for state in self._states + if state.object_id in self._active_ids ) + if legacy_world is not None: + legacy_world.apply_track_progress( + tuple( + (target.object_id, target.timestamp_us, target.velocity_scale) + for target in targets + ) + ) + return targets __all__ = [ diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py b/apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py index e6bd13481..f8ca3ef50 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/yaml_config.py @@ -6,8 +6,10 @@ from __future__ import annotations import math +import types +from dataclasses import MISSING, fields, is_dataclass, replace from pathlib import Path -from typing import Any +from typing import Any, Literal, Union, get_args, get_origin, get_type_hints import yaml @@ -99,3 +101,160 @@ def require_float( if maximum is not None and result > maximum: raise StrictConfigError(f"{context} must be <= {maximum}") return result + + +def overlay_dataclass( + base: Any, + values: dict[str, Any], + context: str, + *, + base_dir: Path, +) -> Any: + """Strictly overlay a YAML mapping onto a frozen configuration dataclass. + + Unknown fields are rejected, omitted fields retain their typed defaults, + and relative :class:`~pathlib.Path` values resolve beside the YAML file. + Nested dataclasses and tuples are handled recursively. + + Args: + base: Lower-precedence dataclass instance. + values: Partial YAML mapping to apply. + context: Field path used in validation errors. + base_dir: Directory used to resolve relative paths. + + Returns: + A replaced dataclass instance containing the validated overlay. + + Raises: + StrictConfigError: A field is unknown, incorrectly typed, or invalid. + """ + if not is_dataclass(base) or isinstance(base, type): + raise TypeError(f"{context} base must be a dataclass instance") + known = {item.name: item for item in fields(base)} + unknown = sorted(values.keys() - known.keys()) + if unknown: + raise StrictConfigError(f"{context} has unknown keys: {', '.join(unknown)}") + hints = get_type_hints(type(base)) + updates = { + name: _convert_typed_value( + raw, + hints[name], + f"{context}.{name}", + base_dir=base_dir, + current=getattr(base, name), + ) + for name, raw in values.items() + } + try: + return replace(base, **updates) + except (TypeError, ValueError) as exc: + raise StrictConfigError(f"{context} is invalid: {exc}") from exc + + +def _convert_typed_value( + value: Any, + expected: Any, + context: str, + *, + base_dir: Path, + current: Any = None, +) -> Any: + origin = get_origin(expected) + args = get_args(expected) + if origin in (Union, types.UnionType): + if value is None and type(None) in args: + return None + errors: list[str] = [] + for candidate in (arg for arg in args if arg is not type(None)): + try: + return _convert_typed_value( + value, + candidate, + context, + base_dir=base_dir, + current=current, + ) + except StrictConfigError as exc: + errors.append(str(exc)) + raise StrictConfigError(errors[-1] if errors else f"{context} is invalid") + if origin is Literal: + if value not in args or type(value) not in {type(item) for item in args}: + choices = ", ".join(repr(item) for item in args) + raise StrictConfigError(f"{context} must be one of {choices}") + return value + if origin is tuple: + if not isinstance(value, list): + raise StrictConfigError(f"{context} must be a sequence") + if len(args) == 2 and args[1] is Ellipsis: + item_type = args[0] + return tuple( + _convert_typed_value( + item, + item_type, + f"{context}[{index}]", + base_dir=base_dir, + ) + for index, item in enumerate(value) + ) + if len(value) != len(args): + raise StrictConfigError(f"{context} must contain {len(args)} values") + return tuple( + _convert_typed_value( + item, + item_type, + f"{context}[{index}]", + base_dir=base_dir, + ) + for index, (item, item_type) in enumerate(zip(value, args, strict=True)) + ) + if isinstance(expected, type) and is_dataclass(expected): + mapping = require_mapping(value, context) + if is_dataclass(current): + return overlay_dataclass(current, mapping, context, base_dir=base_dir) + known = {item.name: item for item in fields(expected)} + unknown = sorted(mapping.keys() - known.keys()) + if unknown: + raise StrictConfigError(f"{context} has unknown keys: {', '.join(unknown)}") + missing = sorted( + name + for name, item in known.items() + if name not in mapping + and item.default is MISSING + and item.default_factory is MISSING + ) + if missing: + raise StrictConfigError( + f"{context} is missing required keys: {', '.join(missing)}" + ) + hints = get_type_hints(expected) + converted = { + name: _convert_typed_value( + raw, + hints[name], + f"{context}.{name}", + base_dir=base_dir, + ) + for name, raw in mapping.items() + } + try: + return expected(**converted) + except (TypeError, ValueError) as exc: + raise StrictConfigError(f"{context} is invalid: {exc}") from exc + if expected is Path: + if not isinstance(value, str): + raise StrictConfigError(f"{context} must be a path string") + path = Path(value).expanduser() + return path if path.is_absolute() else (base_dir / path).resolve() + if expected is bool: + return require_bool(value, context) + if expected is int: + if type(value) is not int: + raise StrictConfigError(f"{context} must be an integer") + return value + if expected is float: + return require_float(value, context) + if expected is str: + if not isinstance(value, str): + raise StrictConfigError(f"{context} must be a string") + return value + raise StrictConfigError(f"{context} has unsupported type {expected!r}") diff --git a/flashdreams/flashdreams/core/attention/kvcache.py b/flashdreams/flashdreams/core/attention/kvcache.py index 5673a32a9..c2c420074 100644 --- a/flashdreams/flashdreams/core/attention/kvcache.py +++ b/flashdreams/flashdreams/core/attention/kvcache.py @@ -365,3 +365,23 @@ def reset(self) -> None: self._prev_chunk_idx = -1 self._curr_chunk_idx = None self._n_cached = 0 + + def clone_kv(self) -> tuple[Tensor, Tensor]: + """Return clones of the full physical K/V buffers.""" + return self._k.clone(), self._v.clone() + + def overwrite_kv_(self, k: Tensor, v: Tensor) -> None: + """Overwrite the full K/V buffers without changing their addresses. + + Args: + k: Replacement keys with the exact cache shape. + v: Replacement values with the exact cache shape. + """ + if k.shape != self._k.shape or v.shape != self._v.shape: + raise ValueError( + "overwrite_kv_ shape mismatch: " + f"got k {tuple(k.shape)} / v {tuple(v.shape)}, " + f"cache holds k {tuple(self._k.shape)} / v {tuple(self._v.shape)}" + ) + self._k.copy_(k) + self._v.copy_(v) diff --git a/flashdreams/tests/test_kvcache.py b/flashdreams/tests/test_kvcache.py index 90042e33b..273df5bb7 100644 --- a/flashdreams/tests/test_kvcache.py +++ b/flashdreams/tests/test_kvcache.py @@ -161,6 +161,26 @@ def test_reset_preserves_storage_and_restores_empty_bookkeeping() -> None: assert cache.size == 2 +@pytest.mark.ci_cpu +def test_clone_and_overwrite_kv_preserve_storage_addresses() -> None: + """Text swaps can replace static K/V without invalidating CUDA graphs.""" + cache = BlockKVCache.from_tensor( + torch.arange(8, dtype=torch.float32).reshape(1, 4, 1, 2), + torch.arange(8, 16, dtype=torch.float32).reshape(1, 4, 1, 2), + seq_dim=1, + ) + key_pointer = cache._k.data_ptr() + value_pointer = cache._v.data_ptr() + keys, values = cache.clone_kv() + + cache.overwrite_kv_(keys + 10.0, values + 20.0) + + assert cache._k.data_ptr() == key_pointer + assert cache._v.data_ptr() == value_pointer + torch.testing.assert_close(cache.cached_k(), keys + 10.0) + torch.testing.assert_close(cache.cached_v(), values + 20.0) + + @pytest.fixture def device() -> torch.device: return torch.device("cuda" if torch.cuda.is_available() else "cpu") diff --git a/integrations_v2/omnidreams/impl/_drift_corrector.py b/integrations_v2/omnidreams/impl/_drift_corrector.py new file mode 100644 index 000000000..71a068db7 --- /dev/null +++ b/integrations_v2/omnidreams/impl/_drift_corrector.py @@ -0,0 +1,614 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Clean Forcing drift corrector for the Omnidreams runner. + +Deploys the trained corrector LoRA (``drift_correction/train_v2.py`` +checkpoints) on a built :class:`~omnidreams.runner.OmnidreamsRunner`'s +pipeline at ``alpha*(t) * gain`` per denoise step. SHIPPED config (owner +decision 2026-07-24): ``lora_v2_v3_valpeak.pt`` at gain 0.25 +(``corrgate025`` — best trees/foliage detail and consistency, drift +Delta +0.99 vs base +2.44). Mirrors the HY-WorldPlay deploy module +(``hy_worldplay/_drift_corrector.py``); self-contained so the production +runner does not import the research directory. + +By default the LoRA is **pre-merged**: at load time each discrete +``alpha*(t) * gain`` value gets its own cached copy of the target +projection weights with the scaled delta folded in, and the per-step gate +just swaps the cached set in — zero extra work in the hot path. The gate +is driven CPU-side from the load-time solver schedule (one +``predict_flow`` call per solver step), so the corrected forward issues +the same kernels as base with no GPU timestep readback. Set +``DRIFT_CORRECTOR_UNFUSED=1`` to fall back to the runtime A/B-matmul path +(the pre-2026-07-25 behavior). + +CUDA-graph-safe ``fused`` mode (``DRIFT_CORRECTOR_MODE=fused``) +--------------------------------------------------------------- + +Neither default works under the accelerated serving stack +(``compile_network=True`` + ``use_cuda_graph=True``): the unfused path +adds live gated matmuls (new ops the captured graph never saw), and the +pre-merged path rebinds ``lin.weight.data`` to a cached tensor — captured +kernels reference the *original* storage address, so after capture the +gate silently stops changing what the graph computes. Serving therefore +had to disable acceleration to run the corrector (~6 fps vs 30 fps). + +The fused mode keeps the same pre-merged weight sets and the same +CPU-side call-index gate, but swaps by ``copy_``-ing the cached set into +the original parameter storages (batched ``torch._foreach_copy_``) — the +:class:`~omnidreams._edit_lora.TextEditLoRA` mechanism, extended from a +per-window toggle to a per-denoise-step one. This is graph-safe by +construction: the transformer's ``CUDAGraphWrapper`` captures ONE network +forward, and each solver step plus the ``finalize_kv_cache`` context +forward is a separate replay of that graph (``timestep`` is a staged +input), so the exact per-step ``alpha*(t) * gain`` profile survives — no +gate collapse to a constant is needed, and the graph sees only fixed +parameter addresses whose values change between replays. + +Tradeoff, stated honestly: each within-chunk alpha change is a +device-to-device copy of the four attention projections' weights (one +copy per distinct consecutive alpha; the default two-entry profile costs +two swaps per chunk, a profile with its own context-noise entry costs +three). That is HBM bandwidth the pointer-rebind mode does not spend — +sub-millisecond per swap on datacenter parts, and orders of magnitude +cheaper than the acceleration the corrector previously forfeited by +forcing the eager stack (real-time ~30 fps down to ~6 fps). An in-place ``addmm_`` of the rank-16 delta +difference would avoid the cached sets' VRAM, but repeated bf16 +accumulation drifts over long live-game rollouts; copying from pristine +fp32-merged sets restores exact values on every swap. Fused mode does +not compose with a concurrently attached ``TextEditLoRA`` (both ``copy_`` +into the same self-attention projections; the last writer wins) — same +restriction as the pre-merged mode; use ``unfused`` for stacked deploys, +or the per-state :class:`DriftCorrectorDispatch` below, whose composed +weight sets carry the style-LoRA delta and the corrector delta in one +``copy_`` source (resolving the last-writer-wins conflict for the +self-attention projections; the LoRA hook must then stop toggling them). + +Mode selection: ``DRIFT_CORRECTOR_MODE`` = ``premerged`` (default) | +``fused`` | ``unfused``; the legacy ``DRIFT_CORRECTOR_UNFUSED=1`` still +forces ``unfused``. +""" + +from __future__ import annotations + +import json +import os +import warnings +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import torch +import torch.nn as nn +from omnidreams.impl._module_utils import unwrap_compiled_module +from torch import Tensor + +## Deploy policy + + +def _gate_alpha() -> dict[float, float]: + """Resolve the gate profile: ``GATE_ALPHA_JSON`` override or the default. + + The override file holds either a flat ``{timestep: alpha}`` mapping or + an object with a ``"gate_alpha"`` entry (the ``edit_sft/gate_style.py`` + output format). Read once at import time, so set the variable before + importing this module. + """ + path = os.environ.get("GATE_ALPHA_JSON", "") + if not path: + return {1000.0: 0.96, 803.0: 0.667} + return _load_gate_json(path) + + +def _load_gate_json(path: Path | str) -> dict[float, float]: + """Load a ``{timestep: alpha}`` profile from a gate JSON file. + + Accepts either a flat mapping or an object with a ``"gate_alpha"`` + entry (the ``edit_sft/gate_style.py`` output format). + """ + table = json.loads(Path(path).read_text()) + table = table.get("gate_alpha", table) + profile = {float(t): float(a) for t, a in table.items()} + assert profile and all(0.0 < a <= 1.0 for a in profile.values()), ( + f"gate profile {str(path)!r} must map timesteps to alphas in (0, 1]" + ) + return profile + + +GATE_ALPHA = _gate_alpha() +"""Unbiased alpha*(t) from the step-0 systematicity gate. Default: the +photoreal drift-pair profile (drift_correction's +``outputs/gate/gate_faithful_v2.json``) — the systematic fraction of the +drift-induced error at each of the two distilled solver timesteps. The +corrector LoRA is rescaled to ``alpha*(t) * gain`` before every denoise +step (nearest-t lookup); the ``finalize_kv_cache`` context forward (t=128) +resolves to the nearest entry (t=803 in the default profile), matching the +evaluated deploy configs. ``GATE_ALPHA_JSON`` swaps in a measured profile +(e.g. ``edit_sft/outputs/gate_style.json`` for styled worlds), which may +add its own low-t entry for the context forward.""" + +_LORA_TARGETS = ( + "self_attn.q_proj", + "self_attn.k_proj", + "self_attn.v_proj", + "self_attn.output_proj", +) +"""Self-attention projections the corrector checkpoints were trained on.""" + +_LORA_RANK = 16 +"""Rank of the shipped corrector checkpoints.""" + + +class _LoRALinear(nn.Module): + """Frozen base linear plus a runtime-gated low-rank delta. + + Mirrors the training-side module in + ``integrations/omnidreams/drift_correction/_lora.py``: ``scale`` is the + runtime gain (``0`` = exact base output), and the A/B path runs in fp32 + regardless of the base dtype. + """ + + def __init__(self, base: nn.Linear, rank: int): + super().__init__() + self.base = base + for p in self.base.parameters(): + p.requires_grad_(False) + self.A = nn.Linear(base.in_features, rank, bias=False) + self.B = nn.Linear(rank, base.out_features, bias=False) + nn.init.zeros_(self.B.weight) + self.scale = 0.0 + + def forward(self, x: Tensor) -> Tensor: + out = self.base(x) + if self.scale != 0: + delta = self.B(self.A(x.to(self.A.weight.dtype))) + out = out + self.scale * delta.to(out.dtype) + return out + + +def _apply_lora(network: nn.Module) -> list[nn.Parameter]: + """Wrap the target linears and return the LoRA parameters in load order.""" + for mname, module in list(network.named_modules()): + for cname, child in list(module.named_children()): + full = f"{mname}.{cname}" if mname else cname + # Substring match, exactly as the training-side apply_lora, so + # the wrap set and load order match the checkpoint indices. + if isinstance(child, nn.Linear) and any(t in full for t in _LORA_TARGETS): + setattr( + module, + cname, + _LoRALinear(child, _LORA_RANK).to(child.weight.device), + ) + params: list[nn.Parameter] = [] + for m in network.modules(): + if isinstance(m, _LoRALinear): + params += list(m.A.parameters()) + list(m.B.parameters()) + return params + + +def _set_scale(network: nn.Module, scale: float) -> None: + """Set the runtime gain on every wrapped linear.""" + for m in network.modules(): + if isinstance(m, _LoRALinear): + m.scale = scale + + +def _nearest_alpha(t: float, profile: dict[float, float] | None = None) -> float: + """Return the profile entry (default :data:`GATE_ALPHA`) nearest in t.""" + profile = GATE_ALPHA if profile is None else profile + return min(profile.items(), key=lambda kv: abs(kv[0] - t))[1] + + +def _target_linears(network: nn.Module) -> list[nn.Linear]: + """Target linears in checkpoint load order (same walk as ``_apply_lora``).""" + linears: list[nn.Linear] = [] + for mname, module in network.named_modules(): + for cname, child in module.named_children(): + full = f"{mname}.{cname}" if mname else cname + if isinstance(child, nn.Linear) and any(t in full for t in _LORA_TARGETS): + linears.append(child) + return linears + + +def _premerge_weight_sets( + linears: list[nn.Linear], sd: dict, gain: float +) -> tuple[dict[float, list[Tensor]], int]: + """Cache ``W + gain*alpha*(B @ A)`` per distinct gate value. + + ``sd`` holds the checkpoint tensors in load order (``A_i`` at ``2i``, + ``B_i`` at ``2i + 1``). The merge runs in fp32 (matching the unfused + path's fp32 delta) and is cast back to the base weight dtype. + + Returns: + The per-alpha weight sets and the total cached bytes. + """ + sets: dict[float, list[Tensor]] = {} + added_bytes = 0 + for alpha in sorted(set(GATE_ALPHA.values())): + merged: list[Tensor] = [] + for i, lin in enumerate(linears): + a = sd[2 * i].to(lin.weight.device, torch.float32) + b = sd[2 * i + 1].to(lin.weight.device, torch.float32) + w32 = lin.weight.detach().to(torch.float32, copy=True) + w = w32.addmm_(b, a, alpha=gain * alpha).to(lin.weight.dtype) + merged.append(w) + added_bytes += w.numel() * w.element_size() + sets[alpha] = merged + return sets, added_bytes + + +_MODES = ("premerged", "fused", "unfused") + + +def _resolve_mode(mode: str | None, unfused: bool | None) -> str: + """Resolve the deploy mode from explicit args, then the environment. + + Precedence: explicit ``mode`` > explicit ``unfused`` bool > + ``DRIFT_CORRECTOR_MODE`` > legacy ``DRIFT_CORRECTOR_UNFUSED=1`` > + ``premerged``. + """ + if mode is None and unfused is not None: + mode = "unfused" if unfused else "premerged" + if mode is None: + mode = os.environ.get("DRIFT_CORRECTOR_MODE", "") + if not mode: + legacy = os.environ.get("DRIFT_CORRECTOR_UNFUSED", "0") == "1" + mode = "unfused" if legacy else "premerged" + assert mode in _MODES, f"drift-corrector mode {mode!r} not in {_MODES}" + return mode + + +def apply_drift_corrector( + runner: Any, + checkpoint: Path, + gain: float, + *, + unfused: bool | None = None, + mode: str | None = None, +) -> str: + """Deploy the corrector LoRA on ``runner`` with the alpha*(t) gate. + + Args: + runner: A built ``OmnidreamsRunner``. + checkpoint: Corrector LoRA checkpoint (``train_v1``/``train_v2`` + format: a dict whose ``"lora"`` entry maps load-order indices + to tensors). + gain: Global gain composed with the alpha*(t) profile; the + shipped configuration (``corrgate025``) is 0.25. + unfused: Force the runtime A/B-matmul path instead of the default + per-step pre-merged weights. ``None`` reads the environment + (see :func:`_resolve_mode`). + mode: Explicit deploy mode (``premerged`` | ``fused`` | + ``unfused``); overrides ``unfused`` and the environment. + ``fused`` is the CUDA-graph-safe in-place-``copy_`` variant — + required whenever the serving stack runs with + ``compile_network`` / ``use_cuda_graph`` enabled. + + Returns: + A log-line string describing the deployed configuration. + """ + mode = _resolve_mode(mode, unfused) + unfused = mode == "unfused" + network = unwrap_compiled_module( + runner.pipeline.diffusion_model.transformer.network + ) + transformer = runner.pipeline.diffusion_model.transformer + sd = torch.load(checkpoint, map_location="cpu", weights_only=False)["lora"] + + if unfused: + params = _apply_lora(network) + assert len(sd) == len(params), ( + f"corrector checkpoint has {len(sd)} LoRA tensors but the network " + f"exposes {len(params)}; rank or target mismatch." + ) + for i, p in enumerate(params): + p.data.copy_(sd[i].to(p.device, p.dtype)) + orig_pf = transformer.predict_flow + + # Per-step gate: rescale the LoRA to alpha*(t) x gain before every + # denoise step (nearest-t lookup; finalize_kv_cache calls positionally). + def gated_pf(*args, **kwargs): + ts = kwargs.get("timestep", args[1] if len(args) > 1 else None) + t = float(ts.reshape(-1).max()) + _set_scale(network, _nearest_alpha(t) * gain) + return orig_pf(*args, **kwargs) + + transformer.predict_flow = gated_pf + return f"corrected (alpha*(t) x {gain}, unfused)" + + # Pre-merged paths: one cached weight set per distinct alpha*(t) + # value; the per-step gate installs the cached set — no LoRA matmuls + # in the hot path. "premerged" re-points ``lin.weight.data`` (zero + # copy, NOT CUDA-graph-safe); "fused" ``copy_``s into the original + # parameter storages (graph-safe: captured kernels keep reading the + # same addresses and only the values change between replays). + linears = _target_linears(network) + assert len(sd) == 2 * len(linears), ( + f"corrector checkpoint has {len(sd)} LoRA tensors but the network " + f"exposes {2 * len(linears)}; rank or target mismatch." + ) + if mode == "fused": + assert getattr(transformer, "_optimized_dit_executor", None) is None, ( + "fused drift corrector merges into the PyTorch network's " + "weights, which the native optimized-DiT executor bypasses; " + "run with native_dit_acceleration='disabled'." + ) + weight_sets, added_bytes = _premerge_weight_sets(linears, sd, gain) + current: list[float | None] = [None] + + if mode == "fused": + live = [lin.weight.data for lin in linears] + + def _swap(alpha: float) -> None: + if alpha != current[0]: + torch._foreach_copy_(live, weight_sets[alpha]) + current[0] = alpha + else: + + def _swap(alpha: float) -> None: + if alpha != current[0]: + for lin, w in zip(linears, weight_sets[alpha]): + lin.weight.data = w + current[0] = alpha + + # Drive the gate CPU-side. The scheduler makes exactly one + # ``predict_flow`` call per solver step in a Python loop, so each + # step's alpha resolves from the load-time schedule by call index — + # reading the timestep tensor back per step (the unfused path's + # ``float(timestep.max())``) would stall the CPU launch queue every + # solver step. + scheduler = runner.pipeline.diffusion_model.scheduler + step_alphas = [_nearest_alpha(t) for t in scheduler.denoising_step_list.tolist()] + ctx_alpha = _nearest_alpha( + float(runner.pipeline.diffusion_model.config.context_noise) + ) + orig_sample = scheduler.sample + + def gated_sample(initial_noise, predict_flow, rng=None): + calls = [0] + + def pf(noisy, timestep): + assert calls[0] < len(step_alphas), "predict_flow calls > solver steps" + _swap(step_alphas[calls[0]]) + calls[0] += 1 + return predict_flow(noisy, timestep) + + return orig_sample(initial_noise=initial_noise, predict_flow=pf, rng=rng) + + scheduler.sample = gated_sample + orig_finalize = transformer.finalize_kv_cache + + def gated_finalize(*args, **kwargs): + _swap(ctx_alpha) + return orig_finalize(*args, **kwargs) + + transformer.finalize_kv_cache = gated_finalize + kind = "graph-safe fused" if mode == "fused" else "pre-merged" + return ( + f"corrected (alpha*(t) x {gain}, {kind} {len(weight_sets)} weight " + f"sets, +{added_bytes / 2**20:.0f} MiB)" + ) + + +## Per-state dispatch (fused mode, multiple correctors) + +_VRAM_WARN_GIB = 8.0 +"""Warn when the dispatch's cached weight sets exceed this budget.""" + + +@dataclass +class _CorrectorState: + """Pre-merged weight sets and the per-step gate schedule for one state.""" + + sets: dict[float, list[Tensor]] + step_alphas: list[float] + ctx_alpha: float + added_bytes: int + + +class DriftCorrectorDispatch: + """Multiple pre-merged corrector states behind one graph-safe selector. + + Extends the ``fused`` mode of :func:`apply_drift_corrector` from one + corrector to a registry of named states, each pre-merged from a + pristine base-weight snapshot taken at construction: + + ``sets[alpha] = pristine (+ lora_delta) + alpha * gain * (B @ A)`` + + The optional ``lora_delta`` (e.g. a style ``TextEditLoRA``'s + self-attention deltas) rides the same ``copy_`` source as the + corrector delta, which resolves the fused mode's last-writer-wins + conflict: this dispatch becomes the SOLE writer of the self-attention + projection weights, so a concurrently attached edit LoRA must be + restricted to projections outside :data:`_LORA_TARGETS` (e.g. + cross-attention only) while the dispatch is installed. + + A ``"base"`` state (pristine weights, corrector off) is registered at + construction and is the initial selection; re-register it to give the + base world its own corrector (e.g. the shipped photoreal checkpoint). + + :meth:`set_active_corrector` is safe between rollouts and at chunk + boundaries (between ``scheduler.sample`` calls) — the recommended call + sites. A mid-rollout call is still graph-safe (weights only change + between graph replays) but takes effect at the next denoise step, so + one chunk mixes two states; keep swaps at chunk boundaries for clean + visuals. + """ + + def __init__(self, runner: Any) -> None: + diffusion_model = runner.pipeline.diffusion_model + transformer = diffusion_model.transformer + assert getattr(transformer, "_optimized_dit_executor", None) is None, ( + "the fused drift-corrector dispatch merges into the PyTorch " + "network's weights, which the native optimized-DiT executor " + "bypasses; run with native_dit_acceleration='disabled'." + ) + network = unwrap_compiled_module(transformer.network) + self._linears = _target_linears(network) + self._live = [lin.weight.data for lin in self._linears] + self._pristine32 = [ + lin.weight.detach().to(torch.float32, copy=True) for lin in self._linears + ] + self._step_ts = [ + float(t) for t in diffusion_model.scheduler.denoising_step_list + ] + self._ctx_t = float(diffusion_model.config.context_noise) + self._states: dict[str, _CorrectorState] = {} + self._active = "base" + self._current: tuple[str, float] | None = None + self.register_state("base") + self._install_gate_driver(diffusion_model.scheduler, transformer) + + def register_state( + self, + name: str, + *, + checkpoint: Path | str | None = None, + gain: float = 0.0, + gate_alpha: dict[float, float] | Path | str | None = None, + lora_delta: list[Tensor] | None = None, + ) -> str: + """Pre-merge and cache the weight sets for one named state. + + Args: + name: State name for :meth:`set_active_corrector`. + checkpoint: Corrector LoRA checkpoint (``train_v2`` format); + ``None`` (or ``gain == 0``) makes this an off state. + gain: Global gain composed with the alpha*(t) profile. + gate_alpha: Per-state gate profile — a ``{timestep: alpha}`` + dict, a gate-JSON path, or ``None`` for :data:`GATE_ALPHA`. + lora_delta: Optional fp32 weight deltas (one per target linear, + checkpoint load order) folded into EVERY set of this state, + including its within-state off set — the state-aware base. + + Returns: + A log-line string describing the registered state. + """ + if isinstance(gate_alpha, (str, Path)): + gate_alpha = _load_gate_json(gate_alpha) + profile = GATE_ALPHA if gate_alpha is None else gate_alpha + base32 = [w.clone() for w in self._pristine32] + if lora_delta is not None: + assert len(lora_delta) == len(self._linears), ( + f"lora_delta has {len(lora_delta)} tensors for " + f"{len(self._linears)} target projections" + ) + for w, d in zip(base32, lora_delta): + w.add_(d.to(w.device, w.dtype)) + + added_bytes = 0 + if checkpoint is None or gain == 0.0: + merged = [w.to(lin.weight.dtype) for w, lin in zip(base32, self._linears)] + added_bytes = sum(w.numel() * w.element_size() for w in merged) + state = _CorrectorState( + sets={0.0: merged}, + step_alphas=[0.0] * len(self._step_ts), + ctx_alpha=0.0, + added_bytes=added_bytes, + ) + else: + sd = torch.load(checkpoint, map_location="cpu", weights_only=False)["lora"] + assert len(sd) == 2 * len(self._linears), ( + f"corrector checkpoint has {len(sd)} LoRA tensors but the " + f"network exposes {2 * len(self._linears)}; rank or target " + "mismatch." + ) + sets: dict[float, list[Tensor]] = {} + for alpha in sorted(set(profile.values())): + merged = [] + for i, (lin, w32) in enumerate(zip(self._linears, base32)): + a = sd[2 * i].to(w32.device, torch.float32) + b = sd[2 * i + 1].to(w32.device, torch.float32) + w = w32.clone().addmm_(b, a, alpha=gain * alpha) + merged.append(w.to(lin.weight.dtype)) + added_bytes += merged[-1].numel() * merged[-1].element_size() + sets[alpha] = merged + state = _CorrectorState( + sets=sets, + step_alphas=[_nearest_alpha(t, profile) for t in self._step_ts], + ctx_alpha=_nearest_alpha(self._ctx_t, profile), + added_bytes=added_bytes, + ) + + self._states[name] = state + if name == self._active: + self._current = None # re-registration invalidates live weights + total = sum(s.added_bytes for s in self._states.values()) + if total > _VRAM_WARN_GIB * 2**30: + warnings.warn( + f"drift-corrector dispatch caches {total / 2**30:.1f} GiB of " + f"weight sets across {len(self._states)} states, over the " + f"~{_VRAM_WARN_GIB:.0f} GiB budget; drop states or gate " + "entries.", + ResourceWarning, + stacklevel=2, + ) + return ( + f"corrector state {name!r}: {len(state.sets)} weight sets " + f"(gain {gain}, +{state.added_bytes / 2**20:.0f} MiB, " + f"total {total / 2**20:.0f} MiB)" + ) + + def set_active_corrector(self, name: str) -> None: + """Select the state whose weight sets the gate driver installs. + + Takes effect at the next gated forward (next denoise step / + context forward); call at chunk or rollout boundaries. Forces a + copy on that forward even if the alpha value matches, so a + re-selected state always restores exact pre-merged values. + """ + assert name in self._states, ( + f"unknown corrector state {name!r}; registered: {sorted(self._states)}" + ) + self._active = name + self._current = None + + @property + def active_state(self) -> str: + """Name of the currently selected state.""" + return self._active + + def _swap(self, alpha: float) -> None: + key = (self._active, alpha) + if key != self._current: + torch._foreach_copy_(self._live, self._states[self._active].sets[alpha]) + self._current = key + + def _install_gate_driver(self, scheduler: Any, transformer: Any) -> None: + """Drive the gate CPU-side by call index (see the fused-mode notes).""" + orig_sample = scheduler.sample + + def gated_sample(initial_noise, predict_flow, rng=None): + calls = [0] + + def pf(noisy, timestep): + # Resolve the state per call so a mid-rollout selector swap + # stays consistent between step_alphas and the weight sets. + step_alphas = self._states[self._active].step_alphas + assert calls[0] < len(step_alphas), "predict_flow calls > solver steps" + self._swap(step_alphas[calls[0]]) + calls[0] += 1 + return predict_flow(noisy, timestep) + + return orig_sample(initial_noise=initial_noise, predict_flow=pf, rng=rng) + + scheduler.sample = gated_sample + orig_finalize = transformer.finalize_kv_cache + + def gated_finalize(*args, **kwargs): + self._swap(self._states[self._active].ctx_alpha) + return orig_finalize(*args, **kwargs) + + transformer.finalize_kv_cache = gated_finalize diff --git a/integrations_v2/omnidreams/impl/_edit_lora.py b/integrations_v2/omnidreams/impl/_edit_lora.py new file mode 100644 index 000000000..fc32a8c69 --- /dev/null +++ b/integrations_v2/omnidreams/impl/_edit_lora.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pre-merged text-edit LoRA deploy hook for mid-stream prompt swaps. + +Deploys a ``guidance_distill/train_guidance.py`` checkpoint — a LoRA +distilled from the two-prompt edit guidance — so a plain prompt swap +responds at guided strength without the guidance's extra forward per +denoise step. Both weight sets (base and base-plus-delta) are cached at +load; toggling an edit window ``copy_``s the right set into the live +projection weights, so storage addresses survive and captured CUDA graphs +stay valid (the drift corrector's pointer-rebinding swap is not +graph-safe). Toggles happen only at edit-window boundaries — a few chunks +apart — so the copy cost (~1.6 GiB, sub-millisecond) is off the hot path. + +Window semantics live in :class:`~omnidreams.transformer.TextEditGuidance`: +``CosmosTransformer.replace_text_embeddings`` builds a ``use_lora`` window +when a hook is attached, ``predict_flow`` activates the merged weights for +the window's chunks (including the KV-commit context forwards — the +checkpoint was trained to match the guided context forward too), and the +first forward after the countdown expires restores the base weights. +""" + +from __future__ import annotations + +from pathlib import Path + +import torch +import torch.nn as nn +from omnidreams.impl._module_utils import unwrap_compiled_module +from torch import Tensor + +_LORA_TARGETS = ( + "self_attn.q_proj", + "self_attn.k_proj", + "self_attn.v_proj", + "self_attn.output_proj", + "cross_attn.q_proj", + "cross_attn.k_proj", + "cross_attn.v_proj", + "cross_attn.output_proj", +) +"""Projections the guidance-distillation checkpoints were trained on. + +Must match ``guidance_distill/train_guidance.py``'s ``LORA_TARGETS`` (same +substring rule, same ``named_modules`` walk) so the checkpoint's +load-order indices line up. ``cross_attn.`` does not match the multi-view +``cross_view_attn.`` modules. +""" + + +def _target_linears(network: nn.Module) -> list[nn.Linear]: + """Target linears in checkpoint load order (the training-side walk).""" + linears: list[nn.Linear] = [] + for mname, module in network.named_modules(): + for cname, child in module.named_children(): + full = f"{mname}.{cname}" if mname else cname + if isinstance(child, nn.Linear) and any(t in full for t in _LORA_TARGETS): + linears.append(child) + return linears + + +class TextEditLoRA: + """Two cached weight sets (base / edit) toggled per edit window. + + Args: + network: The unwrapped ``CosmosDiTNetwork`` whose projection + weights are toggled in place. + checkpoint: ``train_guidance.py`` checkpoint (a dict whose + ``"lora"`` entry maps load-order indices to A/B tensors; + ``A_i`` at ``2i``, ``B_i`` at ``2i + 1``). + scale: Gain on the LoRA delta. The checkpoint distills a fixed + teacher strength, so ``1.0`` reproduces the evaluated deploy. + """ + + def __init__( + self, + network: nn.Module, + checkpoint: Path | str, + *, + scale: float = 1.0, + ) -> None: + network = unwrap_compiled_module(network) + linears = _target_linears(network) + sd = torch.load(checkpoint, map_location="cpu", weights_only=False)["lora"] + assert len(sd) == 2 * len(linears), ( + f"edit-LoRA checkpoint has {len(sd)} tensors but the network " + f"exposes {2 * len(linears)} ({len(linears)} target projections); " + "target-list mismatch with the training recipe." + ) + + self._linears = linears + self._base: list[Tensor] = [] + self._edit: list[Tensor] = [] + added_bytes = 0 + for i, lin in enumerate(linears): + a = sd[2 * i].to(lin.weight.device, torch.float32) + b = sd[2 * i + 1].to(lin.weight.device, torch.float32) + base = lin.weight.detach().clone() + w32 = base.to(torch.float32, copy=True) + edit = w32.addmm_(b, a, alpha=scale).to(base.dtype) + self._base.append(base) + self._edit.append(edit) + added_bytes += 2 * base.numel() * base.element_size() + self.rank = int(sd[0].shape[0]) + self.added_bytes = added_bytes + self.active = False + + def set_active(self, active: bool) -> None: + """Copy the requested weight set into the live buffers (idempotent). + + In-place ``copy_`` so the weight storage addresses never change — + captured CUDA graphs keep reading the same buffers and only the + contents differ. + """ + if active == self.active: + return + source = self._edit if active else self._base + for lin, w in zip(self._linears, source): + lin.weight.data.copy_(w) + self.active = active + + def release_targets(self, linears: list[nn.Linear]) -> list[Tensor]: + """Stop toggling the given live linears; return their fp32 deltas. + + Composition seam for the fused drift-corrector dispatch + (:class:`omnidreams._drift_corrector.DriftCorrectorDispatch`): the + dispatch becomes the sole writer of the released projections and + folds the returned ``edit - base`` deltas into its per-state + pre-merged weight sets, while this hook keeps toggling only the + remaining (cross-attention) projections. Deltas are returned in + the order of ``linears``. + """ + assert not self.active, "release targets while the base weights are live" + index = {id(lin): i for i, lin in enumerate(self._linears)} + drop: set[int] = set() + deltas: list[Tensor] = [] + for lin in linears: + assert id(lin) in index, "linear is not one of this LoRA's targets" + i = index[id(lin)] + deltas.append( + self._edit[i].to(torch.float32) - self._base[i].to(torch.float32) + ) + drop.add(i) + keep = [i for i in range(len(self._linears)) if i not in drop] + self._linears = [self._linears[i] for i in keep] + self._base = [self._base[i] for i in keep] + self._edit = [self._edit[i] for i in keep] + return deltas + + def describe(self) -> str: + """One-line deploy description for startup logs.""" + return ( + f"text-edit LoRA r{self.rank} pre-merged on " + f"{len(self._linears)} projections " + f"(+{self.added_bytes / 2**20:.0f} MiB weight sets)" + ) diff --git a/integrations_v2/omnidreams/impl/_module_utils.py b/integrations_v2/omnidreams/impl/_module_utils.py new file mode 100644 index 000000000..a86793268 --- /dev/null +++ b/integrations_v2/omnidreams/impl/_module_utils.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""PyTorch module helpers shared by OmniDreams runtime features.""" + +from __future__ import annotations + +import torch.nn as nn + + +def unwrap_compiled_module(module: nn.Module) -> nn.Module: + """Return the original module stored by a ``torch.compile`` wrapper. + + Raises: + TypeError: ``_orig_mod`` exists but is not an ``nn.Module``. + """ + if not hasattr(module, "_orig_mod"): + return module + original = module._orig_mod + if not isinstance(original, nn.Module): + raise TypeError( + f"{type(module).__name__}._orig_mod must be an nn.Module, " + f"got {type(original).__name__}" + ) + return original + + +__all__ = ["unwrap_compiled_module"] diff --git a/integrations_v2/omnidreams/impl/pipeline.py b/integrations_v2/omnidreams/impl/pipeline.py index 41e62fdcc..bc82e5dde 100644 --- a/integrations_v2/omnidreams/impl/pipeline.py +++ b/integrations_v2/omnidreams/impl/pipeline.py @@ -396,6 +396,84 @@ def release_oneshot_encoders(self) -> None: torch_module=torch, ) + @torch.no_grad() + def replace_text( + self, + cache: OmnidreamsPipelineCache, + text: list[list[str]], + *, + guidance_scale: float = 1.0, + guidance_chunks: int = 0, + recache_last_chunk: bool = False, + ) -> None: + """Replace a live rollout's prompt between autoregressive steps. + + Args: + cache: Live rollout cache. + text: Nested ``[B, V]`` prompt strings. + guidance_scale: Strength of transient old/new prompt guidance. + guidance_chunks: Number of future chunks to guide. + recache_last_chunk: Whether to recommit the last context chunk. + """ + if self.text_encoder is None: + raise RuntimeError("replace_text requires the resident text encoder") + if not text or not isinstance(text[0], list): + raise ValueError("text must be a non-empty [B, V] nested list") + text_embeddings = torch.stack([self.text_encoder(row) for row in text], dim=0) + self.replace_text_from_embeddings( + cache, + text_embeddings, + guidance_scale=guidance_scale, + guidance_chunks=guidance_chunks, + recache_last_chunk=recache_last_chunk, + ) + + @torch.no_grad() + def replace_text_from_embeddings( + self, + cache: OmnidreamsPipelineCache, + text_embeddings: Tensor, + *, + guidance_scale: float = 1.0, + guidance_chunks: int = 0, + recache_last_chunk: bool = False, + ) -> None: + """Replace text conditioning from precomputed ``[B,V,L,D]`` embeddings.""" + transformer = self.diffusion_model.transformer + if not isinstance(transformer, CosmosTransformer): + raise TypeError("Omnidreams text replacement requires CosmosTransformer") + text_embeddings = split_inputs_cp( + text_embeddings.to(device=self.device), + seq_dim=1, + cp_group=self.V_group, + ) + transformer.replace_text_embeddings( + cache.transformer_cache, + text_embeddings, + guidance_scale=guidance_scale, + guidance_chunks=guidance_chunks, + ) + if recache_last_chunk: + self.recache_last_chunk(cache) + + @torch.no_grad() + def recache_last_chunk(self, cache: OmnidreamsPipelineCache) -> None: + """Recommit the last finalized chunk under the current text prompt.""" + final_state = cache.final_state + if final_state is None: + return + diffusion_model = self.diffusion_model + saved_rng = diffusion_model._rng + if diffusion_model.rng is not None: + diffusion_model._rng = torch.Generator(device=self.device).manual_seed( + 118_000 + final_state.autoregressive_index + ) + try: + final_state.cache.start(final_state.autoregressive_index) + diffusion_model.finalize(final_state=final_state) + finally: + diffusion_model._rng = saved_rng + @torch.no_grad() def generate( self, diff --git a/integrations_v2/omnidreams/impl/transformer/__init__.py b/integrations_v2/omnidreams/impl/transformer/__init__.py index 09fe59606..38281983f 100644 --- a/integrations_v2/omnidreams/impl/transformer/__init__.py +++ b/integrations_v2/omnidreams/impl/transformer/__init__.py @@ -77,6 +77,26 @@ ## Per-rollout cache +@dataclass(kw_only=True) +class TextEditGuidance: + """Transient old/new prompt guidance for a mid-rollout text edit.""" + + scale: float + """Strength applied to the new-minus-old flow direction.""" + + chunks_remaining: int + """Number of upcoming autoregressive chunks to guide.""" + + kv_old: list[tuple[Tensor, Tensor]] = field(default_factory=list) + """Per-block cross-attention K/V for the pre-edit prompt.""" + + kv_new: list[tuple[Tensor, Tensor]] = field(default_factory=list) + """Per-block cross-attention K/V for the post-edit prompt.""" + + use_lora: bool = False + """Whether a distilled LoRA realizes this window with one forward.""" + + @dataclass(kw_only=True) class CosmosTransformerCache(TransformerAutoregressiveCache): """Long-lived AR cache for the Cosmos transformer.""" @@ -114,7 +134,16 @@ class CosmosTransformerCache(TransformerAutoregressiveCache): autoregressive_index: int = -1 """AR step index for the chunk currently being processed; ``-1`` before the first ``start``.""" + text_edit_guidance: TextEditGuidance | None = None + """Transient text guidance, or ``None`` when no edit window is active.""" + def start(self, autoregressive_index: int) -> None: + guidance = self.text_edit_guidance + if guidance is not None and autoregressive_index > self.autoregressive_index: + if guidance.chunks_remaining <= 0: + self.text_edit_guidance = None + else: + guidance.chunks_remaining -= 1 # Hoist KV pre-update and RoPE shift out of the graph-captured forward # (predict_flow runs eager_mode=False; cond/uncond share rope_freqs). self.rope_freqs = self.rope_adapter.shift_t(autoregressive_index) @@ -345,6 +374,12 @@ def __init__(self, config: CosmosTransformerConfig) -> None: # Single view: flatten latent to 4D [B, V, L, D] so CP applies on L # directly. Multi-view: keep 5D [B, V, T, HW, D] for hierarchical CP. self.flatten_thw = config.num_views == 1 + self._finalizing_kv_cache = False + self._text_edit_lora: Any | None = None + + def set_text_edit_lora(self, edit_lora: Any | None) -> None: + """Attach a graph-safe distilled text-edit LoRA hook.""" + self._text_edit_lora = edit_lora def _configure_optimized_dit_from_config(self) -> None: from omnidreams.impl.native import omnidreams_singleview @@ -600,6 +635,10 @@ def initialize_autoregressive_cache( mask_first_patched = self.patchify_and_maybe_split_cp(mask_first_block) mask_other_patched = self.patchify_and_maybe_split_cp(mask_other_blocks) + text_edit_lora = getattr(self, "_text_edit_lora", None) + if text_edit_lora is not None: + text_edit_lora.set_active(False) + if self._use_cuda_graph: self._cuda_graph_dispatch.reset() @@ -616,6 +655,66 @@ def initialize_autoregressive_cache( self._optimized_dit_executor.after_initialize_autoregressive_cache(cache) return cache + @torch.no_grad() + def replace_text_embeddings( + self, + cache: CosmosTransformerCache, + text_embeddings: Tensor, + *, + guidance_scale: float = 1.0, + guidance_chunks: int = 0, + ) -> None: + """Replace cached text conditioning while retaining visual history. + + Args: + cache: Live autoregressive cache. + text_embeddings: Replacement embeddings ``[B, V, L, D]``. + guidance_scale: New-minus-old edit strength. + guidance_chunks: Number of upcoming chunks to guide. + """ + if self._optimized_dit_executor is not None: + raise NotImplementedError( + "Text replacement is not available with native DiT acceleration" + ) + cfg = self.config + text_embeddings = text_embeddings.to(device=self.device, dtype=cfg.dtype) + if self.cp_groups.V_group is not None: + text_embeddings = split_inputs_cp( + text_embeddings, seq_dim=1, cp_group=self.cp_groups.V_group + ) + use_guidance = guidance_scale != 1.0 and guidance_chunks > 0 + if use_guidance and cache.network_cache_uncond is not None: + raise ValueError( + "Text-edit guidance cannot be combined with negative-prompt CFG" + ) + block_caches = cache.network_cache.block_caches + if use_guidance and self._text_edit_lora is not None: + self.network.replace_text_embeddings(cache.network_cache, text_embeddings) + self._text_edit_lora.set_active(True) + cache.text_edit_guidance = TextEditGuidance( + scale=guidance_scale, + chunks_remaining=guidance_chunks, + use_lora=True, + ) + return + old = ( + [block.cross_attn.clone_kv() for block in block_caches] + if use_guidance + else None + ) + self.network.replace_text_embeddings(cache.network_cache, text_embeddings) + if old is None: + cache.text_edit_guidance = None + if self._text_edit_lora is not None: + self._text_edit_lora.set_active(False) + else: + cache.text_edit_guidance = TextEditGuidance( + scale=guidance_scale, + chunks_remaining=guidance_chunks, + kv_old=old, + kv_new=[block.cross_attn.clone_kv() for block in block_caches], + ) + ## Mask-injection helpers def _maybe_inject_image( @@ -689,6 +788,40 @@ def predict_flow( cache=cache, input=input, ) + guidance = cache.text_edit_guidance + if guidance is not None and guidance.use_lora: + assert self._text_edit_lora is not None + self._text_edit_lora.set_active(True) + elif self._text_edit_lora is not None and self._text_edit_lora.active: + self._text_edit_lora.set_active(False) + if ( + guidance is not None + and not guidance.use_lora + and not self._finalizing_kv_cache + and cache.network_cache_uncond is None + ): + blocks = cache.network_cache.block_caches + for block, (key, value) in zip(blocks, guidance.kv_old, strict=True): + block.cross_attn.overwrite_kv_(key, value) + flow_old = self._predict_branch( + noisy_latent=noisy_latent, + timestep=timestep, + cache=cache, + network_cache=cache.network_cache, + input=input, + uncond=False, + ) + for block, (key, value) in zip(blocks, guidance.kv_new, strict=True): + block.cross_attn.overwrite_kv_(key, value) + flow_new = self._predict_branch( + noisy_latent=noisy_latent, + timestep=timestep, + cache=cache, + network_cache=cache.network_cache, + input=input, + uncond=False, + ) + return flow_old + guidance.scale * (flow_new - flow_old) flow_cond = self._predict_branch( noisy_latent=noisy_latent, timestep=timestep, @@ -724,7 +857,11 @@ def finalize_kv_cache( ) -> None: try: if not self.config.skip_finalize_kv_cache: - super().finalize_kv_cache(*args, **kwargs) + self._finalizing_kv_cache = True + try: + super().finalize_kv_cache(*args, **kwargs) + finally: + self._finalizing_kv_cache = False finally: if self._optimized_dit_executor is not None: self._optimized_dit_executor.after_finalize_kv_cache() diff --git a/integrations_v2/omnidreams/impl/transformer/network.py b/integrations_v2/omnidreams/impl/transformer/network.py index 90a8abf02..bfb12f40a 100644 --- a/integrations_v2/omnidreams/impl/transformer/network.py +++ b/integrations_v2/omnidreams/impl/transformer/network.py @@ -442,6 +442,26 @@ def initialize_cache( ) return CosmosDiTNetworkCache(block_caches=block_caches) + @torch.no_grad() + def replace_text_embeddings( + self, + cache: CosmosDiTNetworkCache, + text_embeddings: Tensor, + ) -> None: + """Replace every block's cached cross-attention text K/V in place. + + Args: + cache: Live per-rollout network cache. + text_embeddings: Replacement text embeddings ``[B, V, L, D]``. + """ + context = text_embeddings + if self.config.use_crossattn_projection: + context = self.crossattn_proj(context) + for block, block_cache in zip(self.blocks, cache.block_caches, strict=True): + assert isinstance(block, Block) + fresh = block.cross_attn.compute_kv(context) + block_cache.cross_attn.overwrite_kv_(*fresh.clone_kv()) + def forward( self, x: Tensor, From 4d3c79db7de8fd9a748f3fd87752da2cc498622b Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 12:04:42 -0700 Subject: [PATCH 24/62] Restore Crazy Robotaxi navigation and restart Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 12 +- apps/crazy_robotaxi/crazy_robotaxi/rules.py | 18 ++ apps/crazy_robotaxi/crazy_robotaxi/session.py | 30 ++- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 233 +++++++++++++++++- apps/crazy_robotaxi/tests/test_application.py | 89 ++++++- apps/crazy_robotaxi/tests/test_ui.py | 59 ++++- docs/design/crazy_robotaxi_v2_architecture.md | 12 +- 7 files changed, 422 insertions(+), 31 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 01b68df83..fdcb6d7e0 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -39,11 +39,13 @@ uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ ``` Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application -options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. The V2 -client's reset event rebuilds simulation, game rules, traffic, conditioning, -and the autoregressive cache together while retaining the loaded model. -Leaderboard name entry is owned by the Dear ImGui UI and submitted to the model -loop through V2's asynchronous loop-message contract. +options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. Press R +to restart without closing the window. Restart rebuilds simulation, game rules, +traffic, conditioning, and the autoregressive cache together while retaining +the loaded model. The HUD keeps an always-visible bearing arrow and overlays +visible targets or an off-map direction arrow on the BEV. Leaderboard name +entry is owned by the Dear ImGui UI and submitted to the model loop through +V2's asynchronous loop-message contract. ## Layered configuration diff --git a/apps/crazy_robotaxi/crazy_robotaxi/rules.py b/apps/crazy_robotaxi/crazy_robotaxi/rules.py index 1a9b8f3c1..bc5cb531f 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/rules.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/rules.py @@ -288,6 +288,24 @@ def project_target_pose_to_bev( return u, v, 0.0 <= u <= 1.0 and 0.0 <= v <= 1.0 +def project_target_pose_to_bev_edge( + target_xyz_m: tuple[float, float, float], + rig_to_world: npt.NDArray[np.float32], + bev: BevConfig, +) -> tuple[float, float] | None: + """Project a target direction to the edge of a pose-aligned BEV.""" + target_u, target_v, _visible = project_target_pose_to_bev( + target_xyz_m, rig_to_world, bev + ) + delta_u = target_u - 0.5 + delta_v = target_v - 0.5 + extent = max(abs(delta_u), abs(delta_v)) + if not math.isfinite(extent) or extent <= 1.0e-9: + return None + scale = 0.5 / extent + return 0.5 + delta_u * scale, 0.5 + delta_v * scale + + def project_segment_pose_to_bev( segment_world: npt.NDArray[np.float32], rig_to_world: npt.NDArray[np.float32], diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 445f4a98e..74e31c434 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -17,8 +17,8 @@ from omnidreams_game_engine.types import DriverCommand, SceneDefinition from crazy_robotaxi.factory import build_taxi_engine -from crazy_robotaxi.rules import TaxiGameSnapshot from crazy_robotaxi.race import RaceGameSnapshot +from crazy_robotaxi.rules import TaxiGameSnapshot from crazy_robotaxi.ui import ( CrazyRobotaxiImGuiUILoop, TaxiHudState, @@ -28,6 +28,10 @@ from flashdreams.api_v2.session import ISession from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_event import ( + KeyboardInputState, + KeyboardUserInputEvent, +) from flashdreams.runtime_v2.user_input_events import UserInputEvents from flashdreams.runtime_v2.video_tensor import VideoTensorLayout @@ -175,6 +179,13 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: snapshot = rollout.engine.current_game_frame if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): raise TypeError("Crazy Robotaxi engine returned an unknown game frame") + if snapshot.session_state != "awaiting_name" and _restart_requested(events): + state.reset() + invoke_async(state.ui_loop, lambda ui_state: ui_state.reset()) + rollout = state.ensure_rollout() + snapshot = rollout.engine.current_game_frame + if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): + raise TypeError("Crazy Robotaxi reset returned an unknown game frame") active_states = {"playing", "awaiting_start", "racing"} if snapshot.session_state in active_states: live_edit = getattr(rollout.engine, "live_edit", None) @@ -254,12 +265,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: state.ui_loop, lambda ui_state, frames=hud_frames: ui_state.publish(frames), ) - latest = game_frames[-1] - if ( - isinstance(latest, (TaxiGameSnapshot, RaceGameSnapshot)) - and latest.session_state == "leaderboard" - ): - state.finished = True if ( state.config.total_blocks is not None and state.blocks_generated >= state.config.total_blocks @@ -371,6 +376,7 @@ def init(self) -> None: width=self._session_desc.video_width, height=self._session_desc.video_height, calibration=self._scene.selected_camera, + bev=self._config.renderer.bev, profile_input_latency=self._config.profile_input_latency, show_fps=self._config.show_fps, ) @@ -401,3 +407,13 @@ def _record_ready_event(video: torch.Tensor) -> torch.cuda.Event | None: event = torch.cuda.Event() event.record(torch.cuda.current_stream(video.device)) return event + + +def _restart_requested(events: UserInputEvents) -> bool: + """Return whether this model step received a pressed R key.""" + return any( + isinstance(event, KeyboardUserInputEvent) + and event.state is KeyboardInputState.PRESSED + and str(event.key).strip().lower() == "r" + for event in events.get_events() + ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 23b2dcc01..ef3fb46da 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -30,12 +30,19 @@ import torch import torch.nn.functional as functional from omnidreams_game_engine.camera import FThetaCameraModel +from omnidreams_game_engine.config import BevConfig from omnidreams_game_engine.types import CameraCalibration from torch import Tensor from crazy_robotaxi.high_scores import format_race_time_us, validate_player_name from crazy_robotaxi.race import RaceGameSnapshot, project_race_gate_to_camera -from crazy_robotaxi.rules import TaxiCameraMarkerProjection, TaxiGameSnapshot +from crazy_robotaxi.rules import ( + TaxiCameraMarkerProjection, + TaxiGameSnapshot, + project_segment_pose_to_bev, + project_target_pose_to_bev, + project_target_pose_to_bev_edge, +) from crazy_robotaxi.world_overlay import ( draw_waypoints as draw_waypoint_markers, ) @@ -114,6 +121,9 @@ class TaxiHudState: calibration: CameraCalibration """Camera calibration used to project world markers on the UI thread.""" + bev: BevConfig = BevConfig() + """BEV camera geometry used to place navigation markers on the map.""" + profile_input_latency: bool = False """Whether input arrival and model-frame latency diagnostics are visible.""" @@ -381,7 +391,12 @@ def draw( else "", ), ) - self._draw_bev_window(imgui, bev_frame) + self._draw_navigation_arrow( + imgui, + snapshot.relative_bearing_rad, + color_rgb=(1.0, 0.18, 0.08), + ) + self._draw_bev_window(imgui, bev_frame, hud_frame) elif ( isinstance(snapshot, TaxiGameSnapshot) and snapshot.session_state == "playing" @@ -405,14 +420,16 @@ def draw( _event_label(snapshot), ), ) - self._draw_text_window( + self._draw_navigation_arrow( imgui, - "Navigation", - position=(float(self.width - 294), 14.0), - size=(280.0, 92.0), - lines=(_navigation_label(snapshot.relative_bearing_rad),), + snapshot.relative_bearing_rad, + color_rgb=( + (118.0 / 255.0, 185.0 / 255.0, 0.0) + if snapshot.phase == "seeking_pickup" + else (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) + ), ) - self._draw_bev_window(imgui, bev_frame) + self._draw_bev_window(imgui, bev_frame, hud_frame) self._draw_terminal(imgui, snapshot) self._draw_input_diagnostic(imgui) @@ -470,7 +487,12 @@ def _draw_text_window( finally: imgui.end() - def _draw_bev_window(self, imgui: Any, bev_frame: Tensor | None) -> None: + def _draw_bev_window( + self, + imgui: Any, + bev_frame: Tensor | None, + hud_frame: TaxiHudFrame, + ) -> None: if bev_frame is None: return maximum_width, maximum_height = bev_display_extent(self.width, self.height) @@ -506,9 +528,194 @@ def _draw_bev_window(self, imgui: Any, bev_frame: Tensor | None) -> None: image_width, ) imgui.dummy(imgui.ImVec2(float(image_width), float(image_height))) + self._draw_bev_navigation(imgui, hud_frame) finally: imgui.end() + def _draw_navigation_arrow( + self, + imgui: Any, + bearing_rad: float, + *, + color_rgb: tuple[float, float, float], + ) -> None: + """Draw the always-visible target-bearing arrow from the original HUD.""" + draw_list = imgui.get_background_draw_list() + center_x = float(self.width) * 0.5 + center_y = 74.0 + radius = 30.0 + direction_x = -math.sin(bearing_rad) + direction_y = -math.cos(bearing_rad) + perpendicular_x = -direction_y + perpendicular_y = direction_x + tip_x = center_x + direction_x * radius + tip_y = center_y + direction_y * radius + base_x = center_x + direction_x * radius * 0.25 + base_y = center_y + direction_y * radius * 0.25 + tail = imgui.ImVec2( + center_x - direction_x * radius * 0.62, + center_y - direction_y * radius * 0.62, + ) + left_x = base_x - perpendicular_x * radius * 0.42 + left_y = base_y - perpendicular_y * radius * 0.42 + right_x = base_x + perpendicular_x * radius * 0.42 + right_y = base_y + perpendicular_y * radius * 0.42 + black = _imgui_color(imgui, (0.0, 0.0, 0.0, 1.0)) + color = _imgui_color(imgui, (*color_rgb, 1.0)) + panel = _imgui_color( + imgui, + (12.0 / 255.0, 12.0 / 255.0, 18.0 / 255.0, 0.82), + ) + center = imgui.ImVec2(center_x, center_y) + draw_list.add_circle_filled(center, 42.0, panel) + draw_list.add_circle(center, 42.0, color, 0, 3.0) + draw_list.add_line(tail, imgui.ImVec2(base_x, base_y), black, 11.0) + draw_list.add_line(tail, imgui.ImVec2(base_x, base_y), color, 7.0) + tip = imgui.ImVec2(tip_x, tip_y) + draw_list.add_triangle_filled( + tip, + imgui.ImVec2(left_x, left_y), + imgui.ImVec2(right_x, right_y), + black, + ) + inset = 0.78 + draw_list.add_triangle_filled( + tip, + imgui.ImVec2( + tip_x + (left_x - tip_x) * inset, + tip_y + (left_y - tip_y) * inset, + ), + imgui.ImVec2( + tip_x + (right_x - tip_x) * inset, + tip_y + (right_y - tip_y) * inset, + ), + color, + ) + + def _draw_bev_navigation(self, imgui: Any, hud_frame: TaxiHudFrame) -> None: + """Draw target markers and off-map arrows over the composited BEV.""" + rect = self._bev_rect + if rect is None or not self.bev.enabled: + return + top, left, height, width = rect + if width <= 0 or height <= 0: + return + snapshot = hud_frame.snapshot + pose = hud_frame.rig_pose_world + draw_list = imgui.get_background_draw_list() + + if isinstance(snapshot, RaceGameSnapshot): + segment = project_segment_pose_to_bev( + np.asarray( + [snapshot.gate_start_xyz_m, snapshot.gate_end_xyz_m], + dtype=np.float32, + ), + pose, + self.bev, + ) + red = _imgui_color(imgui, (1.0, 0.18, 0.08, 1.0)) + if segment is not None: + start, end = ( + imgui.ImVec2(left + uv[0] * width, top + uv[1] * height) + for uv in segment + ) + white = _imgui_color(imgui, (1.0, 1.0, 1.0, 1.0)) + draw_list.add_line(start, end, white, 9.0) + draw_list.add_line(start, end, red, 6.0) + return + self._draw_bev_edge_arrow( + imgui, + snapshot.target_xyz_m, + pose, + color=red, + ) + return + + rgba = ( + (118.0 / 255.0, 185.0 / 255.0, 0.0, 1.0) + if snapshot.phase == "seeking_pickup" + else (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0, 1.0) + ) + color = _imgui_color(imgui, rgba) + targets = ( + snapshot.pickup_targets_xyz_m + if snapshot.phase == "seeking_pickup" and snapshot.pickup_targets_xyz_m + else (snapshot.target_xyz_m,) + ) + visible = False + white = _imgui_color(imgui, (1.0, 1.0, 1.0, 1.0)) + outline = _imgui_color(imgui, (0.08, 0.08, 0.12, 1.0)) + for target in targets: + u, v, inside = project_target_pose_to_bev(target, pose, self.bev) + if not inside: + continue + visible = True + center = imgui.ImVec2(left + u * width, top + v * height) + radius = float(max(8, min(width, height) // 16)) + draw_list.add_circle_filled(center, radius + 3.0, white) + draw_list.add_circle_filled(center, radius, color) + draw_list.add_circle(center, radius, outline, 0, 2.0) + if snapshot.phase == "to_dropoff" and not visible: + self._draw_bev_edge_arrow( + imgui, + snapshot.target_xyz_m, + pose, + color=color, + ) + + def _draw_bev_edge_arrow( + self, + imgui: Any, + target_xyz_m: tuple[float, float, float], + pose: npt.NDArray[np.float32], + *, + color: int, + ) -> None: + rect = self._bev_rect + assert rect is not None + projected = project_target_pose_to_bev_edge(target_xyz_m, pose, self.bev) + if projected is None: + return + top, left, height, width = rect + edge_x = left + projected[0] * width + edge_y = top + projected[1] * height + center_x = left + width * 0.5 + center_y = top + height * 0.5 + delta_x, delta_y = edge_x - center_x, edge_y - center_y + length = math.hypot(delta_x, delta_y) + if length <= 1.0e-6: + return + direction_x, direction_y = delta_x / length, delta_y / length + perpendicular_x, perpendicular_y = -direction_y, direction_x + size = float(max(9, min(width, height) // 14)) + arrow_x = edge_x - direction_x * (size + 3.0) + arrow_y = edge_y - direction_y * (size + 3.0) + + def points(scale: float) -> tuple[Any, Any, Any]: + tip = imgui.ImVec2( + arrow_x + direction_x * size * scale, + arrow_y + direction_y * size * scale, + ) + base_x = arrow_x - direction_x * size * scale * 0.72 + base_y = arrow_y - direction_y * size * scale * 0.72 + half_width = size * scale * 0.68 + return ( + tip, + imgui.ImVec2( + base_x + perpendicular_x * half_width, + base_y + perpendicular_y * half_width, + ), + imgui.ImVec2( + base_x - perpendicular_x * half_width, + base_y - perpendicular_y * half_width, + ), + ) + + draw_list = imgui.get_background_draw_list() + white = _imgui_color(imgui, (1.0, 1.0, 1.0, 1.0)) + draw_list.add_triangle_filled(*points(1.0), white) + draw_list.add_triangle_filled(*points(0.68), color) + def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: """Composite the current BEV into its ImGui-owned rectangle on-device.""" rect = self._bev_rect @@ -657,6 +864,7 @@ def _draw_terminal( ) entries = entries or "NO SCORES YET" imgui.text(entries) + imgui.text("Press R to play again") finally: imgui.end() @@ -843,6 +1051,13 @@ def _point_xy(value: Any) -> tuple[float, float]: return float(value[0]), float(value[1]) +def _imgui_color( + imgui: Any, + rgba: tuple[float, float, float, float], +) -> int: + return int(imgui.color_convert_float4_to_u32(imgui.ImVec4(*rgba))) + + def _score_label(snapshot: TaxiGameSnapshot) -> str: label = f"SCORE {snapshot.score:06d}" if snapshot.high_score is not None: diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 28e42d6ee..a909a9666 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -5,6 +5,7 @@ from dataclasses import replace from pathlib import Path +from types import SimpleNamespace from typing import Any, cast from unittest.mock import patch @@ -17,7 +18,13 @@ _fit_bev_renderer_to_ui, ) from crazy_robotaxi.physics import TaxiPhysicsWorld -from crazy_robotaxi.session import CrazyRobotaxiModelLoop, CrazyRobotaxiSession +from crazy_robotaxi.rules import TaxiGameSnapshot +from crazy_robotaxi.session import ( + CrazyRobotaxiModelLoop, + CrazyRobotaxiSession, + ModelState, + _restart_requested, +) from crazy_robotaxi.ui import CrazyRobotaxiImGuiUILoop from omnidreams.config import ( RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, @@ -37,6 +44,11 @@ NativeWindowClientWindow, ) from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.user_input_event import ( + KeyboardInputState, + KeyboardUserInputEvent, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents from flashdreams.runtime_v2.video_tensor import VideoTensorLayout pytestmark = pytest.mark.ci_cpu @@ -175,6 +187,81 @@ def create_presenter(**arguments: object) -> Presenter: assert presenter.closed +def test_pressed_r_requests_a_v2_game_restart() -> None: + pressed = KeyboardUserInputEvent( + timestamp=np.uint64(1), + key="R", + state=KeyboardInputState.PRESSED, + ) + released = KeyboardUserInputEvent( + timestamp=np.uint64(2), + key="r", + state=KeyboardInputState.RELEASED, + ) + + assert _restart_requested(UserInputEvents([pressed])) + assert not _restart_requested(UserInputEvents([released])) + + +def test_leaderboard_does_not_finish_the_v2_model_loop() -> None: + snapshot = TaxiGameSnapshot( + phase="seeking_pickup", + target_xyz_m=(25.0, 0.0, 0.0), + distance_m=25.0, + relative_bearing_rad=0.0, + target_radius_m=5.0, + remaining_time_s=None, + score=1200, + global_remaining_time_s=0.0, + session_state="leaderboard", + ) + + class UILoop: + def __init__(self) -> None: + self.operations = [] + + def _invoke_async(self, operation) -> None: + self.operations.append(operation) + + rollout = SimpleNamespace( + engine=SimpleNamespace(current_game_frame=snapshot), + close=lambda: None, + reset=lambda: None, + ) + ui_loop = UILoop() + state = ModelState( + pipeline=object(), + scene=cast(Any, object()), + config=cast( + Any, + SimpleNamespace(total_blocks=None, pipeline_profiling=False), + ), + session_desc=cast( + Any, + SimpleNamespace( + frames_per_second_for_step=30, + video_height=4, + video_width=4, + ), + ), + driver_input=cast(Any, object()), + ui_loop=cast(Any, ui_loop), + rollout=cast(Any, rollout), + last_video=torch.zeros(1, 3, 4, 4), + last_pose=np.eye(4, dtype=np.float32), + prewarm_complete=True, + ) + loop = CrazyRobotaxiModelLoop() + loop.state = state + + results = loop.step(0, UserInputEvents([])) + + assert len(results) == 1 + assert not state.finished + assert not loop.is_finished() + assert len(ui_loop.operations) == 1 + + @pytest.mark.parametrize( ("arguments", "expected"), [ diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 7055cca4c..67073c989 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -85,6 +85,9 @@ def add_circle(self, *args: Any) -> None: def add_circle_filled(self, *args: Any) -> None: self.commands.append(("circle_filled", args)) + def add_triangle_filled(self, *args: Any) -> None: + self.commands.append(("triangle_filled", args)) + def add_rect(self, *args: Any) -> None: self.commands.append(("rect", args)) @@ -452,9 +455,11 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert result.output.dtype is torch.float32 assert hud_state._current is not None assert "SCORE 001200 HIGH 009000" in renderer.ui.windows["Crazy Robotaxi"] - assert "TARGET AHEAD 0 deg" in renderer.ui.windows["Navigation"] + assert "Navigation" not in renderer.ui.windows assert renderer.ui.dummies == [(32.0, 32.0)] - assert renderer.ui.background_draw_list.commands + command_names = [name for name, _ in renderer.ui.background_draw_list.commands] + assert "triangle_filled" in command_names + assert "circle_filled" in command_names top, left, panel_height, panel_width = hud_state._bev_rect or (0, 0, 0, 0) panel = result.output[0, :, top : top + panel_height, left : left + panel_width] assert torch.allclose(panel, torch.full_like(panel, 191.0 / 127.5 - 1.0)) @@ -476,6 +481,33 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert renderer.reset_count == 1 +def test_bev_draws_edge_arrow_for_an_offscreen_dropoff() -> None: + video = torch.zeros(1, 3, 96, 160) + snapshot = replace( + _snapshot(), + phase="to_dropoff", + target_xyz_m=(500.0, 0.0, 0.0), + remaining_time_s=20.0, + ) + state = TaxiHudState(160, 96, _calibration()) + frame = build_hud_frames( + video, + (snapshot,), + np.eye(4, dtype=np.float32)[None], + )[0] + state._bev_rect = (0, 0, 96, 96) + imgui = _FakeImGui() + + state._draw_bev_navigation(imgui, frame) + + triangles = [ + command + for command in imgui.background_draw_list.commands + if command[0] == "triangle_filled" + ] + assert len(triangles) == 2 + + def test_imgui_ui_loop_owns_and_restores_a_cuda_presentation_stream( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -523,8 +555,11 @@ def test_hud_draws_immediate_imgui_windows() -> None: state.draw(imgui) - assert set(imgui.windows) == {"Crazy Robotaxi", "Navigation"} + assert set(imgui.windows) == {"Crazy Robotaxi"} assert "SCORE 001200 HIGH 009000" in imgui.windows["Crazy Robotaxi"] + assert any( + name == "triangle_filled" for name, _ in imgui.background_draw_list.commands + ) def test_hud_animates_prepresentation_warmup_status() -> None: @@ -626,3 +661,21 @@ def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: assert model_loop.state.names == ["DRIVER 7"] assert state._submission_pending assert "Game Over" in imgui.windows + + +def test_leaderboard_prompts_for_restart() -> None: + state = TaxiHudState(160, 96, _calibration()) + video = torch.zeros(1, 3, 96, 160) + state.publish( + build_hud_frames( + video, + (_snapshot(session_state="leaderboard"),), + np.eye(4, dtype=np.float32)[None], + ) + ) + state.select_presented_frame(video[0]) + imgui = _FakeImGui() + + state.draw(imgui) + + assert "Press R to play again" in imgui.windows["Game Over"] diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md index f1b9bd207..740d95d1a 100644 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ b/docs/design/crazy_robotaxi_v2_architecture.md @@ -308,20 +308,20 @@ stateDiagram-v2 Playing --> AwaitingName: timer ends and score qualifies Playing --> Leaderboard: timer ends without qualification AwaitingName --> Leaderboard: name submitted - Leaderboard --> [*]: terminal output presented + Leaderboard --> Leaderboard: terminal frame remains visible Playing --> Playing: V2 reset generation rebuilds rollout AwaitingName --> Playing: V2 reset generation rebuilds rollout - Leaderboard --> Playing: V2 reset generation rebuilds rollout + Leaderboard --> Playing: R or V2 reset rebuilds rollout ``` While awaiting a name, no new world-model block is generated. The model loop re-emits the last generated RGB frame and publishes an updated immutable HUD frame; the UI loop replaces its cached waypoint projections from that metadata. ImGui owns the editable name buffer and submits a validated name to the model -loop with `invoke_async`. The model -loop reports `is_finished()` only after a leaderboard frame has been emitted -(or an explicit finite block limit is reached), allowing MP4 mode to terminate -without a client close event. +loop with `invoke_async`. A leaderboard does not finish the model loop: it keeps +the last generated frame visible so R can rebuild the rollout without closing +the client window. Only an explicit finite block limit or a client close ends +the run. On reset, the model loop closes and recreates the simulation, traffic, rules, condition renderer state, and autoregressive cache as one unit. On close, it From 8fdd97634b08d22095e78378e1c85fa98b7ec02a Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 12:11:19 -0700 Subject: [PATCH 25/62] Remove Crazy Robotaxi BEV window chrome Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 26 ++++++++++++++++++------ apps/crazy_robotaxi/tests/test_ui.py | 12 +++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index ef3fb46da..6357033ad 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -503,10 +503,9 @@ def _draw_bev_window( if image_width <= 4 or image_height <= 4: return padding = 16 - title_height = 34 window_size = ( float(image_width + padding), - float(image_height + title_height), + float(image_height + padding), ) margin = float(max(8, min(self.width, self.height) // 80)) position = ( @@ -514,9 +513,13 @@ def _draw_bev_window( float(self.height) - window_size[1] - margin, ) # The app composites the CUDA BEV beneath this transparent content area. - # ImGui still owns the map window's title, border, clipping and layout. + # ImGui owns layout and clipping without drawing window chrome. _prepare_window(imgui, position=position, size=window_size, alpha=0.0) - visible = _begin_window(imgui, "Map") + visible = _begin_window( + imgui, + "Map", + extra_flags=("no_title_bar", "no_background"), + ) try: if visible: cursor = imgui.get_cursor_screen_pos() @@ -1019,11 +1022,22 @@ def _prepare_window( imgui.set_next_window_bg_alpha(alpha) -def _begin_window(imgui: Any, title: str) -> bool: +def _begin_window( + imgui: Any, + title: str, + *, + extra_flags: Sequence[str] = (), +) -> bool: """Begin a fixed HUD window and normalize ImGui's binding return form.""" flags = 0 window_flags = imgui.WindowFlags_ - for name in ("no_move", "no_resize", "no_collapse", "no_saved_settings"): + for name in ( + "no_move", + "no_resize", + "no_collapse", + "no_saved_settings", + *extra_flags, + ): flags |= int(getattr(window_flags, name)) result = imgui.begin(title, flags=flags) if isinstance(result, tuple): diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 67073c989..63f95ca9c 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -105,6 +105,8 @@ class _FakeImGui: no_resize=2, no_collapse=4, no_saved_settings=8, + no_title_bar=16, + no_background=32, ) InputTextFlags_ = SimpleNamespace(enter_returns_true=1) @@ -117,6 +119,7 @@ def __init__(self) -> None: self.submit_input = False self.click_submit = False self.background_draw_list = _FakeDrawList() + self.window_flags: dict[str, int] = {} @staticmethod def ImVec2(x: float, y: float) -> tuple[float, float]: @@ -148,9 +151,9 @@ def set_next_window_bg_alpha(self, alpha) -> None: del alpha def begin(self, title: str, *, flags: int) -> bool: - del flags self.current_window = title self.windows.setdefault(title, []) + self.window_flags[title] = flags return True def end(self) -> None: @@ -161,9 +164,11 @@ def text(self, value: str) -> None: self.windows[self.current_window].append(value) def get_cursor_screen_pos(self) -> tuple[float, float]: + flags = self.window_flags.get(self.current_window or "", 0) + top_padding = 8.0 if flags & self.WindowFlags_.no_title_bar else 26.0 return ( float(self.next_window_position[0]) + 8.0, - float(self.next_window_position[1]) + 26.0, + float(self.next_window_position[1]) + top_padding, ) def dummy(self, size: tuple[float, float]) -> None: @@ -457,6 +462,9 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert "SCORE 001200 HIGH 009000" in renderer.ui.windows["Crazy Robotaxi"] assert "Navigation" not in renderer.ui.windows assert renderer.ui.dummies == [(32.0, 32.0)] + map_flags = renderer.ui.window_flags["Map"] + assert map_flags & renderer.ui.WindowFlags_.no_title_bar + assert map_flags & renderer.ui.WindowFlags_.no_background command_names = [name for name, _ in renderer.ui.background_draw_list.commands] assert "triangle_filled" in command_names assert "circle_filled" in command_names From f59e1ed643d0a06fb336a67a2d032e61eea4d2ac Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 12:29:46 -0700 Subject: [PATCH 26/62] Fade Crazy Robotaxi BEV waypoints Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 17 +++++++++------ apps/crazy_robotaxi/tests/test_ui.py | 27 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 6357033ad..76920d713 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -67,6 +67,9 @@ _VIDEO_FPS_WINDOW_SECONDS = 2.0 """Rolling window used to smooth the generated-video frame-rate estimate.""" +_BEV_WAYPOINT_ALPHA = 0.5 +"""Opacity of visible pickup and drop-off waypoints on the BEV map.""" + _PROFILE_DRIVE_KEYS = frozenset( {"w", "a", "s", "d", "up", "down", "left", "right", "space"} ) @@ -634,20 +637,20 @@ def _draw_bev_navigation(self, imgui: Any, hud_frame: TaxiHudFrame) -> None: ) return - rgba = ( - (118.0 / 255.0, 185.0 / 255.0, 0.0, 1.0) + rgb = ( + (118.0 / 255.0, 185.0 / 255.0, 0.0) if snapshot.phase == "seeking_pickup" - else (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0, 1.0) + else (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) ) - color = _imgui_color(imgui, rgba) + color = _imgui_color(imgui, (*rgb, _BEV_WAYPOINT_ALPHA)) targets = ( snapshot.pickup_targets_xyz_m if snapshot.phase == "seeking_pickup" and snapshot.pickup_targets_xyz_m else (snapshot.target_xyz_m,) ) visible = False - white = _imgui_color(imgui, (1.0, 1.0, 1.0, 1.0)) - outline = _imgui_color(imgui, (0.08, 0.08, 0.12, 1.0)) + white = _imgui_color(imgui, (1.0, 1.0, 1.0, _BEV_WAYPOINT_ALPHA)) + outline = _imgui_color(imgui, (0.08, 0.08, 0.12, _BEV_WAYPOINT_ALPHA)) for target in targets: u, v, inside = project_target_pose_to_bev(target, pose, self.bev) if not inside: @@ -663,7 +666,7 @@ def _draw_bev_navigation(self, imgui: Any, hud_frame: TaxiHudFrame) -> None: imgui, snapshot.target_xyz_m, pose, - color=color, + color=_imgui_color(imgui, (*rgb, 1.0)), ) def _draw_bev_edge_arrow( diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 63f95ca9c..49913138d 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -22,6 +22,7 @@ project_taxi_markers_to_camera, ) from crazy_robotaxi.ui import ( + _BEV_WAYPOINT_ALPHA, CrazyRobotaxiImGuiUILoop, TaxiHudState, build_hud_frames, @@ -514,6 +515,32 @@ def test_bev_draws_edge_arrow_for_an_offscreen_dropoff() -> None: if command[0] == "triangle_filled" ] assert len(triangles) == 2 + expected_white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + assert triangles[0][1][-1] == expected_white + + +def test_bev_draws_visible_waypoints_at_half_opacity() -> None: + video = torch.zeros(1, 3, 96, 160) + state = TaxiHudState(160, 96, _calibration()) + frame = build_hud_frames( + video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + )[0] + state._bev_rect = (0, 0, 96, 96) + imgui = _FakeImGui() + + state._draw_bev_navigation(imgui, frame) + + circles = [ + command + for command in imgui.background_draw_list.commands + if command[0] == "circle_filled" + ] + expected_white = imgui.color_convert_float4_to_u32( + (1.0, 1.0, 1.0, _BEV_WAYPOINT_ALPHA) + ) + assert circles[0][1][-1] == expected_white def test_imgui_ui_loop_owns_and_restores_a_cuda_presentation_stream( From 7f12e28c2dfde3c124cb6c7ad2704aafa46b8575 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 12:36:52 -0700 Subject: [PATCH 27/62] Mask Crazy Robotaxi BEV background Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 44 +++++++++++++++++++++--- apps/crazy_robotaxi/tests/test_ui.py | 39 +++++++++++++++++++-- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 76920d713..8dd4cc0f5 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -160,6 +160,9 @@ class TaxiHudState: _bev_panel: Tensor | None = None """Cached normalized CHW BEV panel retained on its source device.""" + _bev_alpha: Tensor | None = None + """Cached binary road/feature alpha mask retained on the source device.""" + _bev_rect: tuple[int, int, int, int] | None = None """Current ImGui content rectangle as ``(top, left, height, width)``.""" @@ -467,6 +470,7 @@ def reset(self) -> None: self._name_input = "" self._bev_source_key = None self._bev_panel = None + self._bev_alpha = None self._bev_rect = None self._presented_frame_times_s.clear() self._video_fps = 0.0 @@ -535,9 +539,26 @@ def _draw_bev_window( ) imgui.dummy(imgui.ImVec2(float(image_width), float(image_height))) self._draw_bev_navigation(imgui, hud_frame) + self._draw_bev_border(imgui) finally: imgui.end() + def _draw_bev_border(self, imgui: Any) -> None: + """Draw an opaque white border at the exact BEV image extent.""" + rect = self._bev_rect + if rect is None: + return + top, left, height, width = rect + draw_list = imgui.get_background_draw_list() + draw_list.add_rect( + imgui.ImVec2(float(left), float(top)), + imgui.ImVec2(float(left + width), float(top + height)), + _imgui_color(imgui, (1.0, 1.0, 1.0, 1.0)), + 0.0, + 2.0, + 0, + ) + def _draw_navigation_arrow( self, imgui: Any, @@ -570,7 +591,7 @@ def _draw_navigation_arrow( color = _imgui_color(imgui, (*color_rgb, 1.0)) panel = _imgui_color( imgui, - (12.0 / 255.0, 12.0 / 255.0, 18.0 / 255.0, 0.82), + (12.0 / 255.0, 12.0 / 255.0, 18.0 / 255.0, 0.75), ) center = imgui.ImVec2(center_x, center_y) draw_list.add_circle_filled(center, 42.0, panel) @@ -749,9 +770,15 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: image_width, ) panel = self._bev_panel - if source_key != self._bev_source_key or panel is None: + alpha = self._bev_alpha + if source_key != self._bev_source_key or panel is None or alpha is None: source = frame.detach().to(dtype=torch.float32) panel = source.div(127.5).sub(1.0) if frame.dtype == torch.uint8 else source + if frame.dtype == torch.uint8: + alpha = frame.detach().ne(0).any(dim=0, keepdim=True) + else: + alpha = frame.detach().gt(-1.0 + 1.0e-6).any(dim=0, keepdim=True) + alpha = alpha.to(dtype=torch.float32) if tuple(panel.shape[-2:]) != (image_height, image_width): panel = functional.interpolate( panel.unsqueeze(0), @@ -759,13 +786,20 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: mode="bilinear", align_corners=False, )[0] + alpha = functional.interpolate( + alpha.unsqueeze(0), + size=(image_height, image_width), + mode="nearest", + )[0] self._bev_source_key = source_key self._bev_panel = panel + self._bev_alpha = alpha output = video.clone() - output[:, top:bottom, left:right].copy_( - panel[:, : bottom - top, : right - left] - ) + target = output[:, top:bottom, left:right] + source_panel = panel[:, : bottom - top, : right - left] + source_alpha = alpha[:, : bottom - top, : right - left] + target.mul_(1.0 - source_alpha).add_(source_panel * source_alpha) return output def _draw_input_diagnostic(self, imgui: Any) -> None: diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 49913138d..7c54340c3 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -89,8 +89,18 @@ def add_circle_filled(self, *args: Any) -> None: def add_triangle_filled(self, *args: Any) -> None: self.commands.append(("triangle_filled", args)) - def add_rect(self, *args: Any) -> None: - self.commands.append(("rect", args)) + def add_rect( + self, + p_min: Any, + p_max: Any, + color: int, + rounding: float = 0.0, + thickness: float = 1.0, + flags: int = 0, + ) -> None: + self.commands.append( + ("rect", (p_min, p_max, color, rounding, thickness, flags)) + ) def add_rect_filled(self, *args: Any) -> None: self.commands.append(("rect_filled", args)) @@ -466,6 +476,12 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: map_flags = renderer.ui.window_flags["Map"] assert map_flags & renderer.ui.WindowFlags_.no_title_bar assert map_flags & renderer.ui.WindowFlags_.no_background + map_borders = [ + command + for command in renderer.ui.background_draw_list.commands + if command[0] == "rect" and command[1][4] == 2.0 + ] + assert len(map_borders) == 1 command_names = [name for name, _ in renderer.ui.background_draw_list.commands] assert "triangle_filled" in command_names assert "circle_filled" in command_names @@ -490,6 +506,25 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert renderer.reset_count == 1 +def test_bev_compositor_makes_black_offroad_pixels_transparent() -> None: + state = TaxiHudState(4, 4, _calibration()) + state._bev_rect = (0, 0, 4, 4) + video = torch.full((3, 4, 4), -0.5) + bev = torch.zeros((3, 4, 4), dtype=torch.uint8) + bev[:, :, 1:3] = 191 + + composited = state.composite_bev(video, bev) + + assert torch.all(composited[:, :, (0, 3)] == -0.5) + expected_road = 191.0 / 127.5 - 1.0 + assert torch.allclose( + composited[:, :, 1:3], + torch.full_like(composited[:, :, 1:3], expected_road), + ) + assert state._bev_alpha is not None + assert set(state._bev_alpha.unique().tolist()) == {0.0, 1.0} + + def test_bev_draws_edge_arrow_for_an_offscreen_dropoff() -> None: video = torch.zeros(1, 3, 96, 160) snapshot = replace( From 69e7ec8df01c7adc88711b1646d292c066f57dcd Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 12:40:07 -0700 Subject: [PATCH 28/62] Remove Crazy Robotaxi compass outlines Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 15 --------------- apps/crazy_robotaxi/tests/test_ui.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 8dd4cc0f5..913dd51ba 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -587,7 +587,6 @@ def _draw_navigation_arrow( left_y = base_y - perpendicular_y * radius * 0.42 right_x = base_x + perpendicular_x * radius * 0.42 right_y = base_y + perpendicular_y * radius * 0.42 - black = _imgui_color(imgui, (0.0, 0.0, 0.0, 1.0)) color = _imgui_color(imgui, (*color_rgb, 1.0)) panel = _imgui_color( imgui, @@ -596,26 +595,12 @@ def _draw_navigation_arrow( center = imgui.ImVec2(center_x, center_y) draw_list.add_circle_filled(center, 42.0, panel) draw_list.add_circle(center, 42.0, color, 0, 3.0) - draw_list.add_line(tail, imgui.ImVec2(base_x, base_y), black, 11.0) draw_list.add_line(tail, imgui.ImVec2(base_x, base_y), color, 7.0) tip = imgui.ImVec2(tip_x, tip_y) draw_list.add_triangle_filled( tip, imgui.ImVec2(left_x, left_y), imgui.ImVec2(right_x, right_y), - black, - ) - inset = 0.78 - draw_list.add_triangle_filled( - tip, - imgui.ImVec2( - tip_x + (left_x - tip_x) * inset, - tip_y + (left_y - tip_y) * inset, - ), - imgui.ImVec2( - tip_x + (right_x - tip_x) * inset, - tip_y + (right_y - tip_y) * inset, - ), color, ) diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 7c54340c3..de571a962 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -632,6 +632,21 @@ def test_hud_draws_immediate_imgui_windows() -> None: ) +def test_compass_arrow_has_no_black_underlay() -> None: + state = TaxiHudState(160, 96, _calibration()) + imgui = _FakeImGui() + + state._draw_navigation_arrow( + imgui, + 0.0, + color_rgb=(118.0 / 255.0, 185.0 / 255.0, 0.0), + ) + + commands = imgui.background_draw_list.commands + assert sum(name == "line" for name, _ in commands) == 1 + assert sum(name == "triangle_filled" for name, _ in commands) == 1 + + def test_hud_animates_prepresentation_warmup_status() -> None: state = TaxiHudState(160, 96, _calibration()) state.set_loading_status("WARMING WORLD MODEL 2/4") From 8f49f926e0d6c81e1d36b0057dd6f56ec51081ab Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 12:52:44 -0700 Subject: [PATCH 29/62] Add Crazy Robotaxi startup menus Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 18 ++- .../crazy_robotaxi/application.py | 51 ++++++- .../crazy_robotaxi/game_selection.py | 58 ++++++++ apps/crazy_robotaxi/crazy_robotaxi/session.py | 106 ++++++++++++-- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 135 +++++++++++++++++- apps/crazy_robotaxi/tests/test_application.py | 26 +++- apps/crazy_robotaxi/tests/test_ui.py | 93 +++++++++++- 7 files changed, 462 insertions(+), 25 deletions(-) create mode 100644 apps/crazy_robotaxi/crazy_robotaxi/game_selection.py diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index fdcb6d7e0..7dc65d6d6 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -38,6 +38,12 @@ uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ --game-time-s 90 ``` +At startup, choose Taxi or Race mode, then choose a discovered map. Race mode +shows each map's authored race courses on the map-selection screen and starts +the selected course. The configured `--map` is listed first; the menu also +discovers bundled maps and `.robotaxi.yaml` maps beside that configured file. +Map compilation and scene loading begin only after the menu choice. + Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. Press R to restart without closing the window. Restart rebuilds simulation, game rules, @@ -81,8 +87,8 @@ configuration does not copy or reinterpret map content. ## Race mode Authored maps may define ordered race courses using the same schema and map -files as the original Crazy Robotaxi branch. Start the bundled demonstration -course with: +files as the original Crazy Robotaxi branch. Put the bundled demonstration map +first in the selection screen with: ```bash uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ @@ -90,10 +96,10 @@ uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ --game-mode race ``` -Race progression uses swept, direction-aware gate crossings, supports looped -and point-to-point courses, and persists times per map/course. Select a -non-default course with `--race-course ID` and override its leaderboard with -`--race-times PATH`. +Choose Race on the first screen, then choose its course on the second. Race +progression uses swept, direction-aware gate crossings, supports looped and +point-to-point courses, and persists times per map/course. Override the race +leaderboard path with `--race-times PATH`. ## Live-edit gameplay diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index ebac5bbe4..c54de8c07 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -30,7 +30,7 @@ WorldModelLaunchSettings, load_engine_settings, ) -from omnidreams_game_engine.game_map import load_game_map_header +from omnidreams_game_engine.game_map import GAME_MAP_SUFFIX, load_game_map_header from omnidreams_game_engine.renderer_settings import ( RendererSettings, load_renderer_settings, @@ -39,6 +39,7 @@ from omnidreams_game_engine.types import SceneDefinition from crazy_robotaxi.config import CrazyRobotaxiSettings, load_game_settings +from crazy_robotaxi.game_selection import GameMapOption from crazy_robotaxi.high_scores import default_high_scores_path, default_race_times_path from crazy_robotaxi.live_edit.config import ( LiveEditConfig, @@ -173,6 +174,7 @@ def __init__( self._pipeline_config: Any = _MODEL_PRESETS["standard"].pipeline self._pipeline: Any | None = None self._config: ApplicationConfig | None = None + self._map_options: tuple[GameMapOption, ...] = () def session_desc(self) -> SessionDesc: """Declare the trained single-view output contract without loading.""" @@ -295,6 +297,10 @@ def init(self, commandline_args: Sequence[str]) -> None: live_edit=game_settings.live_edit, visual_flare_enabled=game_settings.effects.visual_flare_enabled, ) + self._map_options = _discover_game_maps( + map_path, + requested_variant=engine_settings.map.variant, + ) def _resolve_engine_settings(self, args: argparse.Namespace) -> EngineSettings: settings = EngineSettings( @@ -451,10 +457,10 @@ def create_session(self, session_desc: SessionDesc) -> ISession: self._pipeline_config, config.device, ) - scene = self._scene_factory(config.scene_request, config.renderer.raster) return CrazyRobotaxiSession( pipeline=self._pipeline, - scene=scene, + scene_factory=self._scene_factory, + map_options=self._map_options, config=config, session_desc=session_desc, ) @@ -464,6 +470,7 @@ def close(self) -> None: pipeline = self._pipeline self._pipeline = None self._config = None + self._map_options = () close = getattr(pipeline, "close", None) if callable(close): close() @@ -478,6 +485,44 @@ def _build_pipeline(config: Any, device: str) -> Any: return config.setup().to(device).eval() +def _discover_game_maps( + selected_path: Path, + *, + requested_variant: str, +) -> tuple[GameMapOption, ...]: + """Read menu metadata for bundled maps and maps beside the CLI selection.""" + selected = selected_path.expanduser().resolve() + paths = {selected} + for directory in (_DEFAULT_MAP.parent, selected.parent): + if directory.is_dir(): + paths.update( + path.resolve() for path in directory.glob(f"*{GAME_MAP_SUFFIX}") + ) + + options: list[GameMapOption] = [] + for path in paths: + header = load_game_map_header(path) + variants = tuple(item.name for item in header.variants) + preferred = requested_variant if path == selected else "default" + variant = ( + preferred + if preferred in variants + else ("default" if "default" in variants else variants[0]) + ) + options.append( + GameMapOption( + map_id=header.map_id, + name=header.name, + path=header.source_path, + variant=variant, + race_course_ids=header.race_course_ids, + ) + ) + return tuple( + sorted(options, key=lambda item: (item.path != selected, item.name.casefold())) + ) + + def _fit_bev_renderer_to_ui( renderer: RendererSettings, *, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/game_selection.py b/apps/crazy_robotaxi/crazy_robotaxi/game_selection.py new file mode 100644 index 000000000..a02aa7ad2 --- /dev/null +++ b/apps/crazy_robotaxi/crazy_robotaxi/game_selection.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Immutable game and map choices exchanged across the V2 loop boundary.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +GameMode = Literal["taxi", "race"] + + +@dataclass(frozen=True, slots=True) +class GameMapOption: + """Lightweight authored-map metadata displayed by the UI thread.""" + + map_id: str + """Stable identifier stored in scores and race times.""" + + name: str + """Human-readable map name shown in the selection screen.""" + + path: Path + """Resolved authored-map path loaded after selection.""" + + variant: str + """Visual variant used when loading this map.""" + + race_course_ids: tuple[str, ...] = () + """Ordered race courses available on this map.""" + + +@dataclass(frozen=True, slots=True) +class GameSelection: + """One complete menu choice queued for the model thread.""" + + mode: GameMode + """Rules mode chosen on the first selection screen.""" + + map_option: GameMapOption + """Map metadata chosen on the second selection screen.""" + + race_course_id: str | None = None + """Race course selected with the map; ``None`` in taxi mode.""" diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 74e31c434..8aa4ce061 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -7,16 +7,19 @@ import logging import time -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any import numpy as np import torch from omnidreams_game_engine.input import DriverInput from omnidreams_game_engine.model import WorldModelRollout +from omnidreams_game_engine.scene import SceneRequest from omnidreams_game_engine.types import DriverCommand, SceneDefinition from crazy_robotaxi.factory import build_taxi_engine +from crazy_robotaxi.game_selection import GameMapOption, GameSelection from crazy_robotaxi.race import RaceGameSnapshot from crazy_robotaxi.rules import TaxiGameSnapshot from crazy_robotaxi.ui import ( @@ -46,14 +49,24 @@ class ModelState: """All mutable state owned by the one V2 model thread.""" pipeline: Any - scene: SceneDefinition + scene_factory: Callable[[SceneRequest, Any], SceneDefinition] + """Scene loader invoked after a complete UI selection.""" + config: ApplicationConfig session_desc: SessionDesc driver_input: DriverInput ui_loop: IUILoop[TaxiHudState] """UI-loop endpoint used only through ``invoke_async``.""" + scene: SceneDefinition | None = None + """Selected immutable scene; ``None`` while the startup menu is active.""" + rollout: WorldModelRollout | None = None + game_selected: bool = False + """Whether the UI has supplied a complete mode and map selection.""" + + menu_video: torch.Tensor | None = None + """Cached black model channel published while the menu is active.""" last_video: torch.Tensor | None = None last_bev: torch.Tensor | None = None last_pose: np.ndarray | None = None @@ -78,12 +91,17 @@ class ModelState: def ensure_rollout(self) -> WorldModelRollout: """Build and prewarm renderer, PhysX, game, and cache on the model thread.""" if self.rollout is None: + scene = self.scene + if scene is None: + raise RuntimeError( + "Select a game mode and map before starting a rollout" + ) frame_interval_s = 1.0 / self.session_desc.frames_per_second_for_step self.rollout = WorldModelRollout( pipeline=self.pipeline, - scene=self.scene, + scene=scene, engine_factory=lambda: build_taxi_engine( - scene=self.scene, + scene=scene, game_config=self.config.game, raster=self.config.renderer.raster, bev=self.config.renderer.bev, @@ -99,6 +117,73 @@ def ensure_rollout(self) -> WorldModelRollout: self._prewarm_rollout() return self.rollout + def select_game(self, selection: GameSelection) -> None: + """Load the selected map and configure its rules on the model thread.""" + option = selection.map_option + if selection.mode == "race": + if selection.race_course_id not in option.race_course_ids: + raise ValueError( + f"Unknown race course {selection.race_course_id!r} " + f"for map {option.map_id!r}" + ) + elif selection.race_course_id is not None: + raise ValueError("Taxi mode cannot select a race course") + + self._set_loading_status(f"LOADING {option.name.upper()}") + request = replace( + self.config.scene_request, + map_path=option.path, + variant=option.variant, + prompt=( + self.config.scene_request.prompt + if option.path + == self.config.scene_request.map_path.expanduser().resolve() + else None + ), + ) + scene = self.scene_factory(request, self.config.renderer.raster) + self.close() + self.config = replace( + self.config, + scene_request=request, + game_mode=selection.mode, + race_course_id=selection.race_course_id, + ) + self.scene = scene + self.game_selected = True + self.prewarm_complete = False + self.prewarm_wall_ms = 0.0 + self.reset() + invoke_async( + self.ui_loop, + lambda ui_state, calibration=scene.selected_camera: ( + ui_state.activate_scene(calibration) + ), + ) + + def menu_result(self, step_index: int) -> list[StepResult]: + """Return a cached black frame while the UI waits for a menu choice.""" + if self.menu_video is None: + self.menu_video = torch.full( + ( + 1, + 3, + self.session_desc.video_height, + self.session_desc.video_width, + ), + -1.0, + dtype=torch.float32, + device=self.config.device, + ) + return [ + StepResult( + step_index=step_index, + output=self.menu_video, + frame_count=1, + output_layout=VideoTensorLayout.tchw, + ) + ] + def _prewarm_rollout(self) -> None: rollout = self.rollout assert rollout is not None @@ -173,6 +258,8 @@ class CrazyRobotaxiModelLoop(IModelLoop[ModelState]): def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: state = self.state + if not state.game_selected: + return state.menu_result(step_index) rollout = state.ensure_rollout() step_wall_started = time.perf_counter() step_cpu_started = time.thread_time() @@ -358,12 +445,14 @@ def __init__( self, *, pipeline: Any, - scene: SceneDefinition, + scene_factory: Callable[[SceneRequest, Any], SceneDefinition], + map_options: tuple[GameMapOption, ...], config: ApplicationConfig, session_desc: SessionDesc, ) -> None: self._pipeline = pipeline - self._scene = scene + self._scene_factory = scene_factory + self._map_options = map_options self._config = config self._session_desc = session_desc @@ -375,10 +464,11 @@ def init(self) -> None: hud_state = TaxiHudState( width=self._session_desc.video_width, height=self._session_desc.video_height, - calibration=self._scene.selected_camera, + calibration=None, bev=self._config.renderer.bev, profile_input_latency=self._config.profile_input_latency, show_fps=self._config.show_fps, + map_options=self._map_options, ) ui_loop = self.register_ui_loop( CrazyRobotaxiImGuiUILoop, @@ -391,7 +481,7 @@ def init(self) -> None: CrazyRobotaxiModelLoop, state=ModelState( pipeline=self._pipeline, - scene=self._scene, + scene_factory=self._scene_factory, config=self._config, session_desc=self._session_desc, driver_input=DriverInput(self._config.driver_input), diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 913dd51ba..ef16a708e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -23,7 +23,7 @@ from collections import OrderedDict, deque from collections.abc import Sequence from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal import numpy as np import numpy.typing as npt @@ -34,6 +34,7 @@ from omnidreams_game_engine.types import CameraCalibration from torch import Tensor +from crazy_robotaxi.game_selection import GameMapOption, GameMode, GameSelection from crazy_robotaxi.high_scores import format_race_time_us, validate_player_name from crazy_robotaxi.race import RaceGameSnapshot, project_race_gate_to_camera from crazy_robotaxi.rules import ( @@ -121,7 +122,7 @@ class TaxiHudState: height: int """Presentation height in pixels.""" - calibration: CameraCalibration + calibration: CameraCalibration | None """Camera calibration used to project world markers on the UI thread.""" bev: BevConfig = BevConfig() @@ -133,6 +134,9 @@ class TaxiHudState: show_fps: bool = False """Whether to display the measured generated-video frame rate.""" + map_options: tuple[GameMapOption, ...] = () + """Lightweight authored-map choices supplied by the application.""" + model_loop: ILoop[Any] | None = None """Model-loop endpoint used only through ``invoke_async``.""" @@ -178,6 +182,12 @@ class TaxiHudState: _loading_started_at_s: float = field(default_factory=time.monotonic) """Monotonic timestamp used to make startup progress visibly live.""" + _menu_stage: Literal["mode", "map", "loading", "game"] = "mode" + """Current startup screen owned by the UI thread.""" + + _selected_game_mode: GameMode | None = None + """Mode chosen on the first screen while the map screen is visible.""" + _profile_pressed: set[str] = field(default_factory=set) """Normalized drive keys currently held according to UI-thread events.""" @@ -217,6 +227,7 @@ def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: self._validation_message = "" self._submission_pending = False self._current = selected + self._menu_stage = "game" if frame_changed: self._record_presented_frame(time.monotonic()) self._record_presented_input(selected) @@ -297,8 +308,43 @@ def set_loading_status(self, status: str) -> None: """Update the startup phase from a model-loop message.""" self._loading_status = status + def activate_scene(self, calibration: CameraCalibration) -> None: + """Install projection data after the model thread loads the chosen map.""" + self.calibration = calibration + self._menu_stage = "loading" + + def _select_mode(self, mode: GameMode) -> None: + self._selected_game_mode = mode + self._menu_stage = "map" + + def _select_game( + self, + option: GameMapOption, + *, + race_course_id: str | None = None, + ) -> None: + mode = self._selected_game_mode + model_loop = self.model_loop + if mode is None or model_loop is None or self._menu_stage != "map": + return + selection = GameSelection( + mode=mode, + map_option=option, + race_course_id=race_course_id, + ) + self._menu_stage = "loading" + self._loading_status = f"LOADING {option.name.upper()}" + self._loading_started_at_s = time.monotonic() + invoke_async( + model_loop, + lambda model_state, value=selection: model_state.select_game(value), + ) + def draw_waypoints(self, imgui: Any, frame: Tensor) -> None: """Draw cached world-marker projections aligned with ``frame``.""" + calibration = self.calibration + if calibration is None: + return source = self._frames.get(int(frame.data_ptr())) if source is None: return @@ -307,7 +353,7 @@ def draw_waypoints(self, imgui: Any, frame: Tensor) -> None: self._waypoint_projections = project_waypoints( source.snapshot, source.rig_pose_world, - self.calibration, + calibration, width=self.width, height=self.height, ) @@ -324,7 +370,7 @@ def draw_waypoints(self, imgui: Any, frame: Tensor) -> None: ) elif source.snapshot.checkpoint_markers: camera = FThetaCameraModel( - self.calibration, + calibration, output_width=self.width, output_height=self.height, ) @@ -356,6 +402,12 @@ def draw( """Draw one immediate Dear ImGui HUD frame.""" self._bev_rect = None self._draw_fps_counter(imgui) + if self._menu_stage == "mode": + self._draw_mode_selection(imgui) + return + if self._menu_stage == "map": + self._draw_map_selection(imgui) + return hud_frame = self._current if hud_frame is None: dots = "." * (1 + (ui_tick // 15) % 3) @@ -475,6 +527,81 @@ def reset(self) -> None: self._presented_frame_times_s.clear() self._video_fps = 0.0 + def _draw_mode_selection(self, imgui: Any) -> None: + window_width = max(1.0, min(460.0, float(self.width) - 28.0)) + window_height = max(1.0, min(250.0, float(self.height) - 28.0)) + _prepare_window( + imgui, + position=( + max(14.0, (self.width - window_width) / 2.0), + max(14.0, (self.height - window_height) / 2.0), + ), + size=(window_width, window_height), + alpha=0.94, + ) + visible = _begin_window(imgui, "Crazy Robotaxi — Select Game Mode") + try: + if not visible: + return + imgui.text("SELECT GAME MODE") + imgui.text("Choose how you want to play.") + if imgui.button("TAXI"): + self._select_mode("taxi") + if imgui.button("RACE"): + self._select_mode("race") + finally: + imgui.end() + + def _draw_map_selection(self, imgui: Any) -> None: + mode = self._selected_game_mode + if mode is None: + self._menu_stage = "mode" + return + window_width = max(1.0, min(620.0, float(self.width) - 28.0)) + window_height = max(1.0, min(560.0, float(self.height) - 28.0)) + _prepare_window( + imgui, + position=( + max(14.0, (self.width - window_width) / 2.0), + max(14.0, (self.height - window_height) / 2.0), + ), + size=(window_width, window_height), + alpha=0.94, + ) + title = ( + "Crazy Robotaxi — Select Map" + if mode == "taxi" + else "Crazy Robotaxi — Select Map & Race Course" + ) + visible = _begin_window(imgui, title) + try: + if not visible: + return + if imgui.button("BACK"): + self._selected_game_mode = None + self._menu_stage = "mode" + return + imgui.text("SELECT MAP" if mode == "taxi" else "SELECT MAP & RACE COURSE") + available = False + for index, option in enumerate(self.map_options): + if mode == "taxi": + available = True + if imgui.button(f"{option.name}##map-{index}"): + self._select_game(option) + continue + if not option.race_course_ids: + continue + available = True + imgui.text(option.name) + for course_index, course_id in enumerate(option.race_course_ids): + label = course_id.replace("-", " ").replace("_", " ").upper() + if imgui.button(f"{label}##course-{index}-{course_index}"): + self._select_game(option, race_course_id=course_id) + if not available: + imgui.text("NO COMPATIBLE MAPS FOUND") + finally: + imgui.end() + def _draw_text_window( self, imgui: Any, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index a909a9666..21aa4c93e 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -17,6 +17,7 @@ CrazyRobotaxiApplication, _fit_bev_renderer_to_ui, ) +from crazy_robotaxi.game_selection import GameSelection from crazy_robotaxi.physics import TaxiPhysicsWorld from crazy_robotaxi.rules import TaxiGameSnapshot from crazy_robotaxi.session import ( @@ -113,14 +114,23 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert isinstance(ui_loop, CrazyRobotaxiImGuiUILoop) assert ui_loop._presentation_device == torch.device("cpu") assert model_loop.state.pipeline is pipeline + assert model_loop.state.scene is None assert model_loop.state.rollout is None + assert not model_loop.state.game_selected assert model_loop.state.ui_loop is ui_loop assert ui_loop.state.model_loop is model_loop + assert len(ui_loop.state.map_options) >= 4 + assert ui_loop.state.map_options[0].path.name == "boulevard_district.robotaxi.yaml" assert ui_loop.state.profile_input_latency assert ui_loop.state.show_fps assert session._config.renderer.bev.width == 234 assert session._config.renderer.bev.height == 234 + menu_results = model_loop.step(0, UserInputEvents([])) + assert len(menu_results) == 1 + assert menu_results[0].frame_count == 1 + assert torch.all(menu_results[0].output == -1.0) + def test_native_window_accepts_crazy_robotaxi_output_contract() -> None: """Keep the app's fixed output contract compatible with V2 native output.""" @@ -231,6 +241,7 @@ def _invoke_async(self, operation) -> None: ui_loop = UILoop() state = ModelState( pipeline=object(), + scene_factory=cast(Any, lambda request, raster: object()), scene=cast(Any, object()), config=cast( Any, @@ -250,6 +261,7 @@ def _invoke_async(self, operation) -> None: last_video=torch.zeros(1, 3, 4, 4), last_pose=np.eye(4, dtype=np.float32), prewarm_complete=True, + game_selected=True, ) loop = CrazyRobotaxiModelLoop() loop.state = state @@ -382,15 +394,21 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: ) session = app.create_session(desc) - assert isinstance(session, CrazyRobotaxiSession) + session.init() + _, model_loop = session._take_loops() + model_loop.state.select_game( + GameSelection(mode="taxi", map_option=session._map_options[0]) + ) + assert configured == [app._pipeline_config] assert raster_sizes == [resolution_wh] assert session._config.renderer.raster.resolution_wh == resolution_wh expected_bev_size = min(resolution_wh[0] // 4, resolution_wh[1] // 3) assert session._config.renderer.bev.width == expected_bev_size assert session._config.renderer.bev.height == expected_bev_size - assert session._scene.initial_rgb.shape == ( + assert model_loop.state.scene is not None + assert model_loop.state.scene.initial_rgb.shape == ( resolution_wh[1], resolution_wh[0], 3, @@ -526,8 +544,12 @@ def close(self) -> None: ) app.init(["--device", "cpu", "--prewarm-blocks", "4"]) session = app.create_session(app.session_desc()) + assert isinstance(session, CrazyRobotaxiSession) session.init() ui_loop, model_loop = session._take_loops() + model_loop.state.select_game( + GameSelection(mode="taxi", map_option=session._map_options[0]) + ) rollout = model_loop.state.ensure_rollout() ui_loop._run_message_batch() diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index de571a962..4a14426e4 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -10,12 +10,14 @@ import time from contextlib import nullcontext from dataclasses import dataclass, field, replace +from pathlib import Path from types import SimpleNamespace from typing import Any, cast import numpy as np import pytest import torch +from crazy_robotaxi.game_selection import GameMapOption, GameSelection from crazy_robotaxi.rules import ( TaxiGameSnapshot, TaxiSessionState, @@ -129,6 +131,7 @@ def __init__(self) -> None: self.input_value = "" self.submit_input = False self.click_submit = False + self.clicked_buttons: set[str] = set() self.background_draw_list = _FakeDrawList() self.window_flags: dict[str, int] = {} @@ -190,8 +193,7 @@ def input_text(self, label: str, value: str, *, flags: int): return self.submit_input, self.input_value def button(self, label: str) -> bool: - del label - return self.click_submit + return self.click_submit or label in self.clicked_buttons def begin_disabled(self) -> None: return @@ -236,6 +238,23 @@ def reset(self) -> None: return +@dataclass +class _SelectionState: + selections: list[GameSelection] = field(default_factory=list) + + def select_game(self, selection: GameSelection) -> None: + self.selections.append(selection) + + +class _SelectionLoop(IModelLoop[_SelectionState]): + def step(self, step_index, events): + del step_index, events + return [] + + def reset(self) -> None: + return + + def test_hud_frames_are_immutable_messages_keyed_to_video_storage() -> None: video = torch.zeros(2, 3, 96, 160) snapshots = (_snapshot(), _snapshot()) @@ -621,6 +640,7 @@ def test_hud_draws_immediate_imgui_windows() -> None: ) ) state._current = next(iter(state._frames.values())) + state._menu_stage = "game" imgui = _FakeImGui() state.draw(imgui) @@ -649,6 +669,7 @@ def test_compass_arrow_has_no_black_underlay() -> None: def test_hud_animates_prepresentation_warmup_status() -> None: state = TaxiHudState(160, 96, _calibration()) + state._menu_stage = "loading" state.set_loading_status("WARMING WORLD MODEL 2/4") imgui = _FakeImGui() @@ -659,6 +680,74 @@ def test_hud_animates_prepresentation_warmup_status() -> None: assert lines[1].startswith("ELAPSED ") +def test_startup_menu_selects_taxi_mode_then_map_through_v2_message() -> None: + option = GameMapOption( + map_id="test-city", + name="Test City", + path=Path("test-city.robotaxi.yaml"), + variant="default", + race_course_ids=("downtown-sprint",), + ) + state = TaxiHudState(640, 360, _calibration(), map_options=(option,)) + model_loop = _SelectionLoop() + model_loop.register_session_loop_objects( + state=_SelectionState(), + frequency=0, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + state.model_loop = model_loop + imgui = _FakeImGui() + imgui.clicked_buttons.add("TAXI") + + state.draw(imgui) + + assert state._menu_stage == "map" + assert "Crazy Robotaxi — Select Game Mode" in imgui.windows + imgui.clicked_buttons = {"Test City##map-0"} + state.draw(imgui) + + assert state._menu_stage == "loading" + model_loop._run_message_batch() + assert model_loop.state.selections == [ + GameSelection(mode="taxi", map_option=option) + ] + + +def test_race_menu_selects_map_and_course_together() -> None: + option = GameMapOption( + map_id="test-city", + name="Test City", + path=Path("test-city.robotaxi.yaml"), + variant="default", + race_course_ids=("downtown-sprint",), + ) + state = TaxiHudState(640, 360, _calibration(), map_options=(option,)) + model_loop = _SelectionLoop() + model_loop.register_session_loop_objects( + state=_SelectionState(), + frequency=0, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + state.model_loop = model_loop + imgui = _FakeImGui() + imgui.clicked_buttons.add("RACE") + state.draw(imgui) + imgui.clicked_buttons = {"DOWNTOWN SPRINT##course-0-0"} + + state.draw(imgui) + model_loop._run_message_batch() + + assert model_loop.state.selections == [ + GameSelection( + mode="race", + map_option=option, + race_course_id="downtown-sprint", + ) + ] + + def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: video = torch.zeros(1, 3, 96, 160) state = TaxiHudState( From ee464e2b2c844767bf763aa1db5e3df1bb807f9b Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 12:59:11 -0700 Subject: [PATCH 30/62] Add Crazy Robotaxi escape navigation Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 10 ++-- apps/crazy_robotaxi/crazy_robotaxi/session.py | 13 +++++ apps/crazy_robotaxi/crazy_robotaxi/ui.py | 52 ++++++++++++++++--- apps/crazy_robotaxi/tests/test_application.py | 14 +++++ apps/crazy_robotaxi/tests/test_ui.py | 51 ++++++++++++++++++ 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 7dc65d6d6..4deff150b 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -48,10 +48,12 @@ Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. Press R to restart without closing the window. Restart rebuilds simulation, game rules, traffic, conditioning, and the autoregressive cache together while retaining -the loaded model. The HUD keeps an always-visible bearing arrow and overlays -visible targets or an off-map direction arrow on the BEV. Leaderboard name -entry is owned by the Dear ImGui UI and submitted to the model loop through -V2's asynchronous loop-message contract. +the loaded model. Press Escape to return from the game to map selection, from +map selection to mode selection, and from mode selection to exit. The HUD keeps +an always-visible bearing arrow and overlays visible targets or an off-map +direction arrow on the BEV. Leaderboard name entry is owned by the Dear ImGui +UI and submitted to the model loop through V2's asynchronous loop-message +contract. ## Layered configuration diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 8aa4ce061..7da73262e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -184,6 +184,19 @@ def menu_result(self, step_index: int) -> list[StepResult]: ) ] + def return_to_map_menu(self) -> None: + """Tear down the active game and resume menu-frame publication.""" + self.close() + self.scene = None + self.game_selected = False + self.prewarm_complete = False + self.prewarm_wall_ms = 0.0 + self.reset() + + def request_exit(self) -> None: + """Finish the model loop after the root menu requests exit.""" + self.finished = True + def _prewarm_rollout(self) -> None: rollout = self.rollout assert rollout is not None diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index ef16a708e..bb36eb0c4 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -216,6 +216,8 @@ def publish(self, frames: Sequence[TaxiHudFrame]) -> None: def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: """Select the HUD snapshot aligned with ``frame`` when available.""" + if self.model_loop is not None and self._menu_stage in {"mode", "map"}: + return None selected = self._frames.get(int(frame.data_ptr())) if selected is not None: frame_changed = selected is not self._current @@ -249,9 +251,12 @@ def _record_presented_frame(self, now_s: float) -> None: def consume_input_events(self, events: UserInputEvents) -> None: """Track responsive drive state and receipt times on the UI thread.""" + received = events.get_events() + if any(_is_escape_press(event) for event in received): + self._handle_escape() if not self.profile_input_latency: return - for event in events.get_events(): + for event in received: recognized = False if isinstance(event, FocusUserInputEvent) and not event.focused: self._profile_pressed.clear() @@ -310,9 +315,29 @@ def set_loading_status(self, status: str) -> None: def activate_scene(self, calibration: CameraCalibration) -> None: """Install projection data after the model thread loads the chosen map.""" + self._clear_presented_game() self.calibration = calibration self._menu_stage = "loading" + def _handle_escape(self) -> None: + model_loop = self.model_loop + if self._menu_stage == "game": + self.reset() + self._menu_stage = "map" + if model_loop is not None: + invoke_async( + model_loop, + lambda model_state: model_state.return_to_map_menu(), + ) + elif self._menu_stage == "map": + self._selected_game_mode = None + self._menu_stage = "mode" + elif self._menu_stage == "mode" and model_loop is not None: + self._loading_status = "EXITING GAME" + self._loading_started_at_s = time.monotonic() + self._menu_stage = "loading" + invoke_async(model_loop, lambda model_state: model_state.request_exit()) + def _select_mode(self, mode: GameMode) -> None: self._selected_game_mode = mode self._menu_stage = "map" @@ -506,11 +531,7 @@ def _draw_fps_counter(self, imgui: Any) -> None: def reset(self) -> None: """Clear per-generation HUD snapshots and editable UI state.""" - self._frames.clear() - self._current = None - self._waypoint_source = None - self._waypoint_projections = () - self._ready_source = None + self._clear_presented_game() self._validation_message = "" self._submission_pending = False self._loading_status = "LOADING WORLD MODEL" @@ -520,6 +541,14 @@ def reset(self) -> None: self._reported_input_timestamps_us.clear() self._latest_input_latency_ms = None self._name_input = "" + + def _clear_presented_game(self) -> None: + """Discard frame-aligned HUD and BEV resources from the previous game.""" + self._frames.clear() + self._current = None + self._waypoint_source = None + self._waypoint_projections = () + self._ready_source = None self._bev_source_key = None self._bev_panel = None self._bev_alpha = None @@ -545,6 +574,7 @@ def _draw_mode_selection(self, imgui: Any) -> None: return imgui.text("SELECT GAME MODE") imgui.text("Choose how you want to play.") + imgui.text("ESC: EXIT") if imgui.button("TAXI"): self._select_mode("taxi") if imgui.button("RACE"): @@ -581,6 +611,7 @@ def _draw_map_selection(self, imgui: Any) -> None: self._selected_game_mode = None self._menu_stage = "mode" return + imgui.text("ESC: BACK") imgui.text("SELECT MAP" if mode == "taxi" else "SELECT MAP & RACE COURSE") available = False for index, option in enumerate(self.map_options): @@ -1158,6 +1189,15 @@ def build_hud_frames( return tuple(frames) +def _is_escape_press(event: object) -> bool: + """Return whether an input event is a pressed Escape key.""" + return ( + isinstance(event, KeyboardUserInputEvent) + and event.state is KeyboardInputState.PRESSED + and str(event.key).strip().lower() in {"esc", "escape"} + ) + + def _prepare_window( imgui: Any, *, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 21aa4c93e..4deffba28 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -131,6 +131,20 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert menu_results[0].frame_count == 1 assert torch.all(menu_results[0].output == -1.0) + rollout_closed: list[bool] = [] + model_loop.state.scene = _scene() + model_loop.state.rollout = cast( + Any, + SimpleNamespace(close=lambda: rollout_closed.append(True)), + ) + model_loop.state.game_selected = True + model_loop.state.return_to_map_menu() + assert rollout_closed == [True] + assert model_loop.state.scene is None + assert not model_loop.state.game_selected + model_loop.state.request_exit() + assert model_loop.is_finished() + def test_native_window_accepts_crazy_robotaxi_output_contract() -> None: """Keep the app's fixed output contract compatible with V2 native output.""" diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 4a14426e4..4810e49dd 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -241,10 +241,18 @@ def reset(self) -> None: @dataclass class _SelectionState: selections: list[GameSelection] = field(default_factory=list) + return_to_map_count: int = 0 + exit_requested: bool = False def select_game(self, selection: GameSelection) -> None: self.selections.append(selection) + def return_to_map_menu(self) -> None: + self.return_to_map_count += 1 + + def request_exit(self) -> None: + self.exit_requested = True + class _SelectionLoop(IModelLoop[_SelectionState]): def step(self, step_index, events): @@ -748,6 +756,48 @@ def test_race_menu_selects_map_and_course_together() -> None: ] +def test_escape_navigates_game_to_map_to_mode_then_exits() -> None: + state = TaxiHudState(640, 360, _calibration()) + state._selected_game_mode = "race" + state._menu_stage = "game" + model_loop = _SelectionLoop() + model_loop.register_session_loop_objects( + state=_SelectionState(), + frequency=0, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + state.model_loop = model_loop + released = KeyboardUserInputEvent( + timestamp=np.uint64(1), + key="Escape", + state=KeyboardInputState.RELEASED, + ) + pressed = KeyboardUserInputEvent( + timestamp=np.uint64(2), + key="Escape", + state=KeyboardInputState.PRESSED, + ) + + state.consume_input_events(UserInputEvents([released])) + assert state._menu_stage == "game" + + state.consume_input_events(UserInputEvents([pressed])) + assert state._menu_stage == "map" + model_loop._run_message_batch() + assert model_loop.state.return_to_map_count == 1 + + state.consume_input_events(UserInputEvents([pressed])) + assert state._menu_stage == "mode" + assert state._selected_game_mode is None + + state.consume_input_events(UserInputEvents([pressed])) + assert state._menu_stage == "loading" + assert state._loading_status == "EXITING GAME" + model_loop._run_message_batch() + assert model_loop.state.exit_requested + + def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: video = torch.zeros(1, 3, 96, 160) state = TaxiHudState( @@ -822,6 +872,7 @@ def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: np.eye(4, dtype=np.float32)[None], ) ) + state._menu_stage = "loading" state.select_presented_frame(video[0]) imgui = _FakeImGui() imgui.input_value = " DRIVER 7 " From 2e64fcd0dba7bf4a67d436972577b70eb1f8c133 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 13:40:08 -0700 Subject: [PATCH 31/62] Fix Crazy Robotaxi BEV transparency Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 21 ++-- apps/crazy_robotaxi/tests/test_ui.py | 17 ++-- .../omnidreams_game_engine/conditioning.py | 95 +++++++++++++++++-- .../tests/test_conditioning.py | 55 ++++++++++- .../ludus-renderer/ludus_renderer/__init__.py | 2 + .../ludus_renderer/_cpp/render/ludus_cuda.cu | 3 + .../ludus_renderer/_cpp/render/ludus_types.h | 3 +- .../ludus_renderer/_ops/__init__.py | 2 + .../ludus_renderer/_ops/primitives.py | 3 +- .../ludus_renderer/torch/__init__.py | 2 + 10 files changed, 165 insertions(+), 38 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index bb36eb0c4..8933d3c60 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -165,7 +165,7 @@ class TaxiHudState: """Cached normalized CHW BEV panel retained on its source device.""" _bev_alpha: Tensor | None = None - """Cached binary road/feature alpha mask retained on the source device.""" + """Cached binary renderer coverage retained on the source device.""" _bev_rect: tuple[int, int, int, int] | None = None """Current ImGui content rectangle as ``(top, left, height, width)``.""" @@ -891,8 +891,8 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: rect = self._bev_rect if frame is None or rect is None: return video - if frame.ndim != 3 or frame.shape[0] != 3: - raise ValueError("BEV presentation frames must use [3,H,W]") + if frame.ndim != 3 or frame.shape[0] != 4: + raise ValueError("BEV presentation frames must use [4,H,W] RGBA") if frame.dtype != torch.uint8 and not frame.is_floating_point(): raise ValueError("BEV presentation frames must be uint8 or floating point") if frame.device != video.device: @@ -915,13 +915,9 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: panel = self._bev_panel alpha = self._bev_alpha if source_key != self._bev_source_key or panel is None or alpha is None: - source = frame.detach().to(dtype=torch.float32) + source = frame[:3].detach().to(dtype=torch.float32) panel = source.div(127.5).sub(1.0) if frame.dtype == torch.uint8 else source - if frame.dtype == torch.uint8: - alpha = frame.detach().ne(0).any(dim=0, keepdim=True) - else: - alpha = frame.detach().gt(-1.0 + 1.0e-6).any(dim=0, keepdim=True) - alpha = alpha.to(dtype=torch.float32) + alpha_source = frame[3:4].detach() if tuple(panel.shape[-2:]) != (image_height, image_width): panel = functional.interpolate( panel.unsqueeze(0), @@ -929,11 +925,12 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: mode="bilinear", align_corners=False, )[0] - alpha = functional.interpolate( - alpha.unsqueeze(0), + alpha_source = functional.interpolate( + alpha_source.unsqueeze(0), size=(image_height, image_width), mode="nearest", )[0] + alpha = alpha_source.ne(0) self._bev_source_key = source_key self._bev_panel = panel self._bev_alpha = alpha @@ -942,7 +939,7 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: target = output[:, top:bottom, left:right] source_panel = panel[:, : bottom - top, : right - left] source_alpha = alpha[:, : bottom - top, : right - left] - target.mul_(1.0 - source_alpha).add_(source_panel * source_alpha) + torch.where(source_alpha, source_panel, target, out=target) return output def _draw_input_diagnostic(self, imgui: Any) -> None: diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 4810e49dd..77424ccea 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -458,7 +458,8 @@ def test_fps_counter_measures_distinct_generated_video_frames( def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: width, height = 160, 96 video = torch.full((1, 3, height, width), -0.5) - bev = torch.full((1, 3, 32, 32), 191, dtype=torch.uint8) + bev = torch.full((1, 4, 32, 32), 255, dtype=torch.uint8) + bev[:, :3].fill_(191) hud_state = TaxiHudState(width, height, _calibration()) hud_state.publish( build_hud_frames( @@ -533,23 +534,19 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert renderer.reset_count == 1 -def test_bev_compositor_makes_black_offroad_pixels_transparent() -> None: +def test_bev_compositor_uses_rgba_coverage_for_black_road_pixels() -> None: state = TaxiHudState(4, 4, _calibration()) state._bev_rect = (0, 0, 4, 4) video = torch.full((3, 4, 4), -0.5) - bev = torch.zeros((3, 4, 4), dtype=torch.uint8) - bev[:, :, 1:3] = 191 + bev = torch.zeros((4, 4, 4), dtype=torch.uint8) + bev[3, :, 1:3] = 255 composited = state.composite_bev(video, bev) assert torch.all(composited[:, :, (0, 3)] == -0.5) - expected_road = 191.0 / 127.5 - 1.0 - assert torch.allclose( - composited[:, :, 1:3], - torch.full_like(composited[:, :, 1:3], expected_road), - ) + assert torch.all(composited[:, :, 1:3] == -1.0) assert state._bev_alpha is not None - assert set(state._bev_alpha.unique().tolist()) == {0.0, 1.0} + assert set(state._bev_alpha.unique().tolist()) == {False, True} def test_bev_draws_edge_arrow_for_an_offscreen_dropoff() -> None: diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py index 0d035fc7e..e22656f67 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/conditioning.py @@ -9,15 +9,24 @@ import numpy as np import torch -from ludus_renderer import FThetaCamera, LudusCudaTimestampedContext +from ludus_renderer import ( + PRIM_BEV_ROAD_SURFACE, + FThetaCamera, + LudusCudaTimestampedContext, + TimestampedPolygonPool, + TimestampedScene, +) from ludus_renderer import load_scene as load_ludus_scene +from ludus_renderer._ops import _triangulate_polygon_ear_clipping from ludus_renderer.render_utils import SceneAdapter from ludus_renderer.torch.ops import CAMERA_TYPE_BEV, CAMERA_TYPE_REGULAR +from shapely.geometry import Polygon from torch import Tensor from omnidreams_game_engine.config import BevConfig, RasterConfig from omnidreams_game_engine.contracts import ConditionRenderer from omnidreams_game_engine.dynamic_scene import MutableObjectSceneBuffer +from omnidreams_game_engine.game_map.types import GameMapElement from omnidreams_game_engine.types import ( ConditionBatch, SceneDefinition, @@ -25,6 +34,8 @@ ) _BEV_CAMERA_NAME = "game_engine_bev" +_BEV_ROAD_SIMPLIFY_M = 0.05 +_BEV_ROAD_DEPTH_OFFSET_M = -0.01 class LudusConditionRenderer(ConditionRenderer): @@ -93,6 +104,17 @@ def load_scene(self, scene: SceneDefinition) -> None: self._sensor_to_rig[_BEV_CAMERA_NAME] = self._bev_sensor_pose self._context.upload_cameras(cameras) base_scene = loaded.timestamped_scene + if self._bev.enabled and scene.game_map is not None: + road_surface_pool = _build_bev_road_surface_pool( + scene.game_map.elements, + self._device, + ) + if road_surface_pool is not None: + base_scene = TimestampedScene( + polyline_pools=base_scene.polyline_pools, + polygon_pools=[road_surface_pool, *base_scene.polygon_pools], + cube_pools=base_scene.cube_pools, + ) self._scene_id = self._context.upload_scene(base_scene) self._dynamic_scene = MutableObjectSceneBuffer( self._context, @@ -134,6 +156,7 @@ def render(self, trajectory: TrajectoryChunk) -> ConditionBatch: sensor_to_rig=self._bev_sensor_pose, camera_type=CAMERA_TYPE_BEV, resolution=(self._bev.height, self._bev.width), + preserve_alpha=True, ) # BEV is a UI-only channel. Preserve the renderer's native bytes # instead of normalizing to BF16 only for presentation to reverse @@ -158,6 +181,7 @@ def _render_camera( sensor_to_rig: Tensor, camera_type: int, resolution: tuple[int, int], + preserve_alpha: bool = False, ) -> Tensor: assert self._scene_id is not None camera_to_world = torch.einsum( @@ -173,12 +197,12 @@ def _render_camera( camera_poses=torch.linalg.inv(camera_to_world), resolution=resolution, ) - rgb = images[..., :3] + output = images if preserve_alpha else images[..., :3] if self._context.needs_vflip: - rgb = rgb.flip(1) - if rgb.dtype != torch.uint8: - rgb = (rgb.clamp(0.0, 1.0) * 255.0 + 0.5).to(torch.uint8) - return rgb.detach().contiguous() + output = output.flip(1) + if output.dtype != torch.uint8: + output = (output.clamp(0.0, 1.0) * 255.0 + 0.5).to(torch.uint8) + return output.detach().contiguous() def _normalize_hwc(frames: Tensor) -> Tensor: @@ -186,12 +210,65 @@ def _normalize_hwc(frames: Tensor) -> Tensor: def _bev_presentation_frames(frames: Tensor) -> Tensor: - """Keep renderer-native BEV bytes while changing HWC to TCHW layout.""" - if frames.dtype != torch.uint8 or frames.ndim != 4 or frames.shape[-1] != 3: - raise ValueError("BEV renderer output must be uint8 THWC RGB") + """Keep renderer-native BEV RGBA bytes while changing to TCHW layout.""" + if frames.dtype != torch.uint8 or frames.ndim != 4 or frames.shape[-1] != 4: + raise ValueError("BEV renderer output must be uint8 THWC RGBA") return frames.permute(0, 3, 1, 2).contiguous() +def _build_bev_road_surface_pool( + elements: tuple[GameMapElement, ...], + device: torch.device, +) -> TimestampedPolygonPool | None: + """Build a lightweight black paved-surface layer for BEV alpha coverage.""" + vertices: list[Tensor] = [] + triangles: list[Tensor] = [] + vertex_counts: list[int] = [] + triangle_counts: list[int] = [] + for element in elements: + surface = np.asarray(element.surface_world, dtype=np.float32) + polygon = Polygon(surface[:, :2]).simplify( + _BEV_ROAD_SIMPLIFY_M, + preserve_topology=True, + ) + if polygon.is_empty or polygon.geom_type != "Polygon": + continue + xy = np.asarray(polygon.exterior.coords[:-1], dtype=np.float32) + if len(xy) < 3: + continue + z_m = float(np.median(surface[:, 2])) + _BEV_ROAD_DEPTH_OFFSET_M + polygon_vertices = torch.from_numpy( + np.column_stack((xy, np.full(len(xy), z_m, dtype=np.float32))).astype( + np.float32 + ) + ) + polygon_triangles = _triangulate_polygon_ear_clipping(polygon_vertices) + if not polygon_triangles: + continue + vertices.append(polygon_vertices) + triangles.append(torch.tensor(polygon_triangles, dtype=torch.int32)) + vertex_counts.append(len(polygon_vertices)) + triangle_counts.append(len(polygon_triangles)) + + if not vertices: + return None + return TimestampedPolygonPool( + timestamps_us=torch.tensor([0], dtype=torch.int64, device=device), + timestamped_varrays_prefix_sum=torch.tensor( + [len(vertices)], dtype=torch.int32, device=device + ), + varrays_prefix_sum=torch.tensor( + np.cumsum(vertex_counts), dtype=torch.int32, device=device + ), + triangle_prefix_sum=torch.tensor( + np.cumsum(triangle_counts), dtype=torch.int32, device=device + ), + vertices=torch.cat(vertices).to(device), + triangles=torch.cat(triangles).to(device), + prim_type_id=PRIM_BEV_ROAD_SURFACE, + ) + + def _build_bev_camera(bev: BevConfig, device: torch.device) -> FThetaCamera: cx = bev.width / 2.0 cy = bev.height / 2.0 diff --git a/apps/omnidreams_game_engine/tests/test_conditioning.py b/apps/omnidreams_game_engine/tests/test_conditioning.py index e31ac83cf..d68a504e9 100644 --- a/apps/omnidreams_game_engine/tests/test_conditioning.py +++ b/apps/omnidreams_game_engine/tests/test_conditioning.py @@ -3,19 +3,28 @@ """CPU checks for model versus UI conditioning tensor contracts.""" +from types import SimpleNamespace +from typing import cast + +import numpy as np import pytest import torch -from omnidreams_game_engine.conditioning import _bev_presentation_frames +from ludus_renderer import PRIM_BEV_ROAD_SURFACE +from omnidreams_game_engine.conditioning import ( + _bev_presentation_frames, + _build_bev_road_surface_pool, +) +from omnidreams_game_engine.game_map.types import GameMapElement pytestmark = pytest.mark.ci_cpu def test_bev_presentation_preserves_renderer_bytes_in_tchw_layout() -> None: - source = torch.arange(2 * 3 * 4 * 3, dtype=torch.uint8).reshape(2, 3, 4, 3) + source = torch.arange(2 * 3 * 4 * 4, dtype=torch.uint8).reshape(2, 3, 4, 4) result = _bev_presentation_frames(source) - assert result.shape == (2, 3, 3, 4) + assert result.shape == (2, 4, 3, 4) assert result.dtype is torch.uint8 assert result.is_contiguous() assert torch.equal(result.permute(0, 2, 3, 1), source) @@ -25,10 +34,46 @@ def test_bev_presentation_preserves_renderer_bytes_in_tchw_layout() -> None: "source", [ torch.zeros(1, 3, 4, 3, dtype=torch.float32), - torch.zeros(1, 3, 4, 4, dtype=torch.uint8), + torch.zeros(1, 3, 4, 3, dtype=torch.uint8), torch.zeros(3, 4, 3, dtype=torch.uint8), ], ) def test_bev_presentation_rejects_non_renderer_contract(source: torch.Tensor) -> None: - with pytest.raises(ValueError, match="uint8 THWC RGB"): + with pytest.raises(ValueError, match="uint8 THWC RGBA"): _bev_presentation_frames(source) + + +def test_bev_road_surface_pool_triangulates_concave_pavement() -> None: + surface = np.asarray( + [ + [0.0, 0.0, 0.0], + [4.0, 0.0, 0.0], + [4.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [1.0, 4.0, 0.0], + [0.0, 4.0, 0.0], + [0.0, 0.0, 0.0], + ], + dtype=np.float32, + ) + + pool = _build_bev_road_surface_pool( + (cast(GameMapElement, SimpleNamespace(surface_world=surface)),), + torch.device("cpu"), + ) + + assert pool is not None + assert pool.prim_type_id == PRIM_BEV_ROAD_SURFACE + assert pool.timestamped_varrays_prefix_sum.tolist() == [1] + assert pool.varrays_prefix_sum.tolist() == [6] + assert pool.triangle_prefix_sum.tolist() == [4] + assert torch.allclose(pool.vertices[:, 2], torch.full((6,), -0.01)) + points = pool.vertices[:, :2] + triangle_points = points[pool.triangles.to(torch.int64)] + doubled_areas = torch.abs( + (triangle_points[:, 1, 0] - triangle_points[:, 0, 0]) + * (triangle_points[:, 2, 1] - triangle_points[:, 0, 1]) + - (triangle_points[:, 1, 1] - triangle_points[:, 0, 1]) + * (triangle_points[:, 2, 0] - triangle_points[:, 0, 0]) + ) + assert torch.isclose(doubled_areas.sum() / 2.0, torch.tensor(7.0)) diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/__init__.py b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/__init__.py index 9b147275e..807e2a5cb 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/__init__.py +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/__init__.py @@ -45,6 +45,7 @@ CAMERA_TYPE_BEV, CAMERA_TYPE_REGULAR, CUBE_FLAG_WIREFRAME, + PRIM_BEV_ROAD_SURFACE, PRIM_CROSSWALK, PRIM_EGO_OBSTACLE, PRIM_EGO_TRAJECTORY, @@ -141,6 +142,7 @@ "ObstaclePool", "TimestampedScene", # Constants + "PRIM_BEV_ROAD_SURFACE", "PRIM_ROAD_BOUNDARY", "PRIM_LANE_LINE", "PRIM_CROSSWALK", diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_cuda.cu b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_cuda.cu index 74d780353..2f248092d 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_cuda.cu +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_cuda.cu @@ -321,6 +321,7 @@ static __device__ uint32_t get_default_prim_color(uint32_t prim_type_id) case 19: return pack_rgba8(255/255.f,255/255.f, 0/255.f); // lane_line_yellow_dashed case 20: return pack_rgba8(255/255.f,255/255.f, 0/255.f); // dot_yellow case 21: return pack_rgba8(255/255.f,255/255.f, 255/255.f); // dot_white + case 22: return pack_rgba8( 0/255.f, 0/255.f, 0/255.f); // BEV road surface default: return pack_rgba8(1.0f, 1.0f, 1.0f); // default white } } @@ -1538,6 +1539,8 @@ __global__ void polygonPoolKernel( if (poolId >= numPools) return; const TsPolygonPoolHeader& pool = poolHeaders[poolId]; + if (pool.prim_type_id == PRIM_BEV_ROAD_SURFACE && + params.cameraTypeId != CAMERA_TYPE_BEV) return; if (threadIdx.x == 0) { s_triStart = -1; // sentinel: "no work" diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_types.h b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_types.h index dc0633615..16ed4f0f7 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_types.h +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_cpp/render/ludus_types.h @@ -43,7 +43,8 @@ enum PrimTypeId : uint32_t { PRIM_EGO_TRAJECTORY = 4, PRIM_OBSTACLE = 5, // Dynamic obstacles (uses front/back colors from ObstaclePool) PRIM_EGO_OBSTACLE = 6, // Ego vehicle obstacle - PRIM_TYPE_COUNT = 7 + PRIM_BEV_ROAD_SURFACE = 22, // Black paved-surface fill, visible only to BEV cameras + PRIM_TYPE_COUNT = 23 }; // Camera type IDs for per-camera-type style adjustments diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/__init__.py b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/__init__.py index f944e7c26..7457f6ca7 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/__init__.py +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/__init__.py @@ -33,6 +33,7 @@ CAMERA_TYPE_BEV, CAMERA_TYPE_REGULAR, CUBE_FLAG_WIREFRAME, + PRIM_BEV_ROAD_SURFACE, PRIM_BUFFER_ZONE, PRIM_CROSSWALK, PRIM_DOT_WHITE, @@ -83,6 +84,7 @@ "get_log_level", "set_log_level", # Constants + "PRIM_BEV_ROAD_SURFACE", "PRIM_ROAD_BOUNDARY", "PRIM_LANE_LINE", "PRIM_CROSSWALK", diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/primitives.py b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/primitives.py index ae53d528f..90196a7f8 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/primitives.py +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/_ops/primitives.py @@ -48,7 +48,8 @@ PRIM_LANE_LINE_YELLOW_DASHED = 19 PRIM_DOT_YELLOW = 20 PRIM_DOT_WHITE = 21 -PRIM_TYPE_COUNT = 22 +PRIM_BEV_ROAD_SURFACE = 22 +PRIM_TYPE_COUNT = 23 # Camera type IDs CAMERA_TYPE_REGULAR = 0 diff --git a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/torch/__init__.py b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/torch/__init__.py index d054a4d74..86a2e8750 100644 --- a/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/torch/__init__.py +++ b/integrations_v2/omnidreams/impl/ludus-renderer/ludus_renderer/torch/__init__.py @@ -30,6 +30,7 @@ CAMERA_TYPE_BEV, CAMERA_TYPE_REGULAR, CUBE_FLAG_WIREFRAME, + PRIM_BEV_ROAD_SURFACE, PRIM_CROSSWALK, PRIM_EGO_OBSTACLE, PRIM_EGO_TRAJECTORY, @@ -74,6 +75,7 @@ "TimestampedScene", "CUBE_FLAG_WIREFRAME", # Primitive Type IDs + "PRIM_BEV_ROAD_SURFACE", "PRIM_ROAD_BOUNDARY", "PRIM_LANE_LINE", "PRIM_CROSSWALK", From 52671563bae9fc920f6f17115e49c5284ae4fbda Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 15:47:23 -0700 Subject: [PATCH 32/62] Batch Crazy Robotaxi actor track updates Signed-off-by: Aidan Foster --- .../simulation/actor_controller.py | 3 - .../simulation/game_physics.py | 4 +- .../simulation/gameplay_physx.py | 86 +++---------------- .../simulation/map_traffic.py | 1 - .../tests/test_gameplay_physx.py | 53 ++++++++++++ 5 files changed, 66 insertions(+), 81 deletions(-) create mode 100644 apps/omnidreams_game_engine/tests/test_gameplay_physx.py diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py index a0639665b..625a5aafb 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/actor_controller.py @@ -32,9 +32,6 @@ class ActorTrackTarget: velocity_scale: float = 1.0 """Track velocity multiplier within ``[0, 1]``.""" - loop_duration_us: int | None = None - """Track loop duration; ``None`` clamps at the source track endpoints.""" - class PhysicsActorController(Protocol): """Gameplay owner for a set of optional PhysX-driven scene objects. diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py index 5835e4376..fbc85de2c 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/game_physics.py @@ -861,9 +861,7 @@ def step( for controller in self._actor_controllers for target in controller.prepare_step(ego_before_step, dt_s) ) - self._world.apply_actor_track_targets( - actor_targets, rollout_timestamp_us=timestamp_us - ) + self._world.apply_actor_track_targets(actor_targets) traffic_prepare_ms = (time.perf_counter() - traffic_prepare_started_at) * 1000.0 physics_step = self._world.step_compact( ego_before_step, diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py index 4cd6df395..18b47d8bf 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/gameplay_physx.py @@ -20,33 +20,17 @@ import math from collections.abc import Mapping -import numpy as np from ludus_renderer import PhysicsObjectGraph, PhysXWorld, RigidBodyModel, SceneObject from omnidreams_game_engine.simulation.actor_controller import ActorTrackTarget -_TRACK_SAMPLE_WINDOW_US = 100_000 -"""Half-width of the local native track window around each physics step.""" - - -def _sample_target_track( - scene_object: SceneObject, - timestamp_us: int, - *, - loop_duration_us: int | None, -) -> tuple[np.ndarray, np.ndarray]: - if loop_duration_us is not None: - timestamp_us %= loop_duration_us - position, orientation, _ = scene_object.sample(timestamp_us) - return position, orientation - class GameplayPhysXWorld(PhysXWorld): - """Adapt gameplay-owned actor clocks to Ludus's global track clock. + """Adapt gameplay-owned actor clocks to Ludus's external track progress. - Ludus samples every native track from the rollout timestamp. This adapter - publishes a small, rollout-aligned window for each procedural actor while - leaving recorded scene objects on Ludus's normal global timeline. + Gameplay controllers publish logical timestamps independently of the rollout + clock. Ludus applies those timestamps and velocity scales to the actors' + original tracks in one native batch. """ def __init__( @@ -68,11 +52,9 @@ def __init__( max_actor_drive_speed_mps=max_actor_drive_speed_mps, capacity=capacity, ) - if not hasattr(self._scene, "set_body_track") or not hasattr( - self, "_object_native_ids" - ): + if not hasattr(self._scene, "set_body_track_progress"): raise RuntimeError( - "the installed ludus_renderer lacks the native track replacement " + "the installed ludus_renderer lacks the native track-progress " "bridge required by gameplay actors" ) @@ -130,58 +112,14 @@ def synchronize( def apply_actor_track_targets( self, targets: tuple[ActorTrackTarget, ...], - *, - rollout_timestamp_us: int, ) -> None: - """Publish logical actor targets on Ludus's rollout clock.""" - if not targets: - return - native_timestamps = np.asarray( - [ - rollout_timestamp_us - _TRACK_SAMPLE_WINDOW_US, - rollout_timestamp_us, - rollout_timestamp_us + _TRACK_SAMPLE_WINDOW_US, - ], - dtype=np.int64, - ) - for target in targets: - if target.object_id not in self._objects: - raise KeyError(target.object_id) - if not math.isfinite(target.velocity_scale) or not ( - 0.0 <= target.velocity_scale <= 1.0 - ): - raise ValueError("track velocity scale must be within [0, 1]") - if target.loop_duration_us is not None and target.loop_duration_us <= 0: - raise ValueError("track loop duration must be positive") - - logical_window_us = int( - round(_TRACK_SAMPLE_WINDOW_US * target.velocity_scale) - ) - logical_timestamps = ( - target.timestamp_us - logical_window_us, - target.timestamp_us, - target.timestamp_us + logical_window_us, - ) - scene_object = self._objects[target.object_id] - samples = tuple( - _sample_target_track( - scene_object, - timestamp, - loop_duration_us=target.loop_duration_us, - ) - for timestamp in logical_timestamps - ) - self._scene.set_body_track( - self._object_native_ids[target.object_id], - native_timestamps, - np.ascontiguousarray( - np.stack([sample[0] for sample in samples]), dtype=np.float32 - ), - np.ascontiguousarray( - np.stack([sample[1] for sample in samples]), dtype=np.float32 - ), - -1.0, + """Publish logical actor targets through Ludus's batched progress API.""" + self.apply_track_progress( + tuple( + (target.object_id, target.timestamp_us, target.velocity_scale) + for target in targets ) + ) __all__ = ["GameplayPhysXWorld"] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py index 82552f962..ea1f03a28 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py @@ -616,7 +616,6 @@ def prepare_step( object_id=state.object_id, timestamp_us=self._drive_target_timestamp_us(state), velocity_scale=state.velocity_scale, - loop_duration_us=state.duration_us, ) for state in self._states if state.object_id in self._active_ids diff --git a/apps/omnidreams_game_engine/tests/test_gameplay_physx.py b/apps/omnidreams_game_engine/tests/test_gameplay_physx.py new file mode 100644 index 000000000..4512587d2 --- /dev/null +++ b/apps/omnidreams_game_engine/tests/test_gameplay_physx.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for gameplay-owned PhysX track progress.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from ludus_renderer import PhysXWorld +from omnidreams_game_engine.simulation.actor_controller import ActorTrackTarget +from omnidreams_game_engine.simulation.gameplay_physx import GameplayPhysXWorld + +pytestmark = pytest.mark.ci_cpu + + +def test_actor_targets_use_one_batched_track_progress_update() -> None: + world = object.__new__(GameplayPhysXWorld) + targets = ( + ActorTrackTarget( + object_id="traffic-a", + timestamp_us=250_000, + velocity_scale=0.75, + ), + ActorTrackTarget( + object_id="obstacle-b", + timestamp_us=500_000, + velocity_scale=1.0, + ), + ) + + with patch.object(PhysXWorld, "apply_track_progress") as apply_track_progress: + world.apply_actor_track_targets(targets) + + apply_track_progress.assert_called_once_with( + ( + ("traffic-a", 250_000, 0.75), + ("obstacle-b", 500_000, 1.0), + ) + ) From af8e3ec863cbc88c205531ea2577faed99484e16 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 17:24:15 -0700 Subject: [PATCH 33/62] Honor Crazy Robotaxi CLI game selections Signed-off-by: Aidan Foster --- .../crazy_robotaxi/application.py | 24 ++- apps/crazy_robotaxi/crazy_robotaxi/session.py | 4 + apps/crazy_robotaxi/crazy_robotaxi/ui.py | 137 +++++++++++++++--- apps/crazy_robotaxi/tests/test_application.py | 40 +++++ apps/crazy_robotaxi/tests/test_ui.py | 70 ++++++++- 5 files changed, 249 insertions(+), 26 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index c54de8c07..12d7f5663 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -39,7 +39,7 @@ from omnidreams_game_engine.types import SceneDefinition from crazy_robotaxi.config import CrazyRobotaxiSettings, load_game_settings -from crazy_robotaxi.game_selection import GameMapOption +from crazy_robotaxi.game_selection import GameMapOption, GameMode from crazy_robotaxi.high_scores import default_high_scores_path, default_race_times_path from crazy_robotaxi.live_edit.config import ( LiveEditConfig, @@ -139,6 +139,15 @@ class ApplicationConfig: show_fps: bool """Whether the HUD displays the measured generated-video frame rate.""" + cli_game_mode: GameMode | None = None + """Game mode supplied explicitly on the command line, if any.""" + + cli_map_path: Path | None = None + """Map supplied explicitly on the command line, if any.""" + + cli_race_course_id: str | None = None + """Race course supplied explicitly on the command line, if any.""" + game_mode: Literal["taxi", "race"] = "taxi" """Rules mode selected for every session created by the application.""" @@ -287,6 +296,19 @@ def init(self, commandline_args: Sequence[str]) -> None: prewarm_blocks=engine_settings.runtime.prewarm_blocks, profile_input_latency=engine_settings.runtime.profile_input_latency, show_fps=engine_settings.presentation.show_fps, + cli_game_mode=( + game_settings.mode if arg_was_explicit(args, "game_mode") else None + ), + cli_map_path=( + map_path.expanduser().resolve() + if arg_was_explicit(args, "map") + else None + ), + cli_race_course_id=( + game_settings.race.course + if arg_was_explicit(args, "race_course") + else None + ), game_mode=game_settings.mode, race_course_id=game_settings.race.course, race_times_path=( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 7da73262e..4453ef258 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -482,6 +482,9 @@ def init(self) -> None: profile_input_latency=self._config.profile_input_latency, show_fps=self._config.show_fps, map_options=self._map_options, + initial_game_mode=self._config.cli_game_mode, + initial_map_path=self._config.cli_map_path, + initial_race_course_id=self._config.cli_race_course_id, ) ui_loop = self.register_ui_loop( CrazyRobotaxiImGuiUILoop, @@ -502,6 +505,7 @@ def init(self) -> None: ), ) hud_state.model_loop = model_loop + hud_state.initialize_selection() def _record_ready_event(video: torch.Tensor) -> torch.cuda.Event | None: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 8933d3c60..6539e911f 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -23,6 +23,7 @@ from collections import OrderedDict, deque from collections.abc import Sequence from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Literal import numpy as np @@ -137,6 +138,15 @@ class TaxiHudState: map_options: tuple[GameMapOption, ...] = () """Lightweight authored-map choices supplied by the application.""" + initial_game_mode: GameMode | None = None + """Mode selected explicitly by CLI, skipping the mode screen.""" + + initial_map_path: Path | None = None + """Map selected explicitly by CLI, skipping the map screen.""" + + initial_race_course_id: str | None = None + """Race course selected explicitly by CLI, skipping the course screen.""" + model_loop: ILoop[Any] | None = None """Model-loop endpoint used only through ``invoke_async``.""" @@ -182,12 +192,15 @@ class TaxiHudState: _loading_started_at_s: float = field(default_factory=time.monotonic) """Monotonic timestamp used to make startup progress visibly live.""" - _menu_stage: Literal["mode", "map", "loading", "game"] = "mode" + _menu_stage: Literal["mode", "map", "course", "loading", "game"] = "mode" """Current startup screen owned by the UI thread.""" _selected_game_mode: GameMode | None = None """Mode chosen on the first screen while the map screen is visible.""" + _selected_map_option: GameMapOption | None = None + """Map chosen before the separate race-course screen.""" + _profile_pressed: set[str] = field(default_factory=set) """Normalized drive keys currently held according to UI-thread events.""" @@ -216,7 +229,11 @@ def publish(self, frames: Sequence[TaxiHudFrame]) -> None: def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: """Select the HUD snapshot aligned with ``frame`` when available.""" - if self.model_loop is not None and self._menu_stage in {"mode", "map"}: + if self.model_loop is not None and self._menu_stage in { + "mode", + "map", + "course", + }: return None selected = self._frames.get(int(frame.data_ptr())) if selected is not None: @@ -319,17 +336,39 @@ def activate_scene(self, calibration: CameraCalibration) -> None: self.calibration = calibration self._menu_stage = "loading" + def initialize_selection(self) -> None: + """Skip selection screens whose values were supplied explicitly by CLI.""" + selected_path = self.initial_map_path + if selected_path is not None: + resolved = selected_path.expanduser().resolve() + self._selected_map_option = next( + (option for option in self.map_options if option.path == resolved), + None, + ) + if self._selected_map_option is None: + raise ValueError(f"CLI-selected map is unavailable: {resolved}") + self._selected_game_mode = self.initial_game_mode + if self._selected_game_mode is None: + self._menu_stage = "mode" + return + self._continue_after_mode_selection() + def _handle_escape(self) -> None: model_loop = self.model_loop if self._menu_stage == "game": self.reset() + self._selected_map_option = None self._menu_stage = "map" if model_loop is not None: invoke_async( model_loop, lambda model_state: model_state.return_to_map_menu(), ) + elif self._menu_stage == "course": + self._selected_map_option = None + self._menu_stage = "map" elif self._menu_stage == "map": + self._selected_map_option = None self._selected_game_mode = None self._menu_stage = "mode" elif self._menu_stage == "mode" and model_loop is not None: @@ -340,9 +379,34 @@ def _handle_escape(self) -> None: def _select_mode(self, mode: GameMode) -> None: self._selected_game_mode = mode - self._menu_stage = "map" + self._continue_after_mode_selection() + + def _continue_after_mode_selection(self) -> None: + option = self._selected_map_option + if option is None: + self._menu_stage = "map" + return + self._continue_after_map_selection(option) + + def _select_map(self, option: GameMapOption) -> None: + self._selected_map_option = option + self._continue_after_map_selection(option) + + def _continue_after_map_selection(self, option: GameMapOption) -> None: + mode = self._selected_game_mode + if mode is None: + self._menu_stage = "mode" + return + if mode == "taxi": + self._start_game(option) + return + course_id = self.initial_race_course_id + if course_id is not None and course_id in option.race_course_ids: + self._start_game(option, race_course_id=course_id) + return + self._menu_stage = "course" - def _select_game( + def _start_game( self, option: GameMapOption, *, @@ -350,7 +414,7 @@ def _select_game( ) -> None: mode = self._selected_game_mode model_loop = self.model_loop - if mode is None or model_loop is None or self._menu_stage != "map": + if mode is None or model_loop is None: return selection = GameSelection( mode=mode, @@ -433,6 +497,9 @@ def draw( if self._menu_stage == "map": self._draw_map_selection(imgui) return + if self._menu_stage == "course": + self._draw_course_selection(imgui) + return hud_frame = self._current if hud_frame is None: dots = "." * (1 + (ui_tick // 15) % 3) @@ -598,12 +665,7 @@ def _draw_map_selection(self, imgui: Any) -> None: size=(window_width, window_height), alpha=0.94, ) - title = ( - "Crazy Robotaxi — Select Map" - if mode == "taxi" - else "Crazy Robotaxi — Select Map & Race Course" - ) - visible = _begin_window(imgui, title) + visible = _begin_window(imgui, "Crazy Robotaxi — Select Map") try: if not visible: return @@ -612,27 +674,56 @@ def _draw_map_selection(self, imgui: Any) -> None: self._menu_stage = "mode" return imgui.text("ESC: BACK") - imgui.text("SELECT MAP" if mode == "taxi" else "SELECT MAP & RACE COURSE") + imgui.text("SELECT MAP") available = False for index, option in enumerate(self.map_options): - if mode == "taxi": - available = True - if imgui.button(f"{option.name}##map-{index}"): - self._select_game(option) - continue - if not option.race_course_ids: + if mode == "race" and not option.race_course_ids: continue available = True - imgui.text(option.name) - for course_index, course_id in enumerate(option.race_course_ids): - label = course_id.replace("-", " ").replace("_", " ").upper() - if imgui.button(f"{label}##course-{index}-{course_index}"): - self._select_game(option, race_course_id=course_id) + if imgui.button(f"{option.name}##map-{index}"): + self._select_map(option) if not available: imgui.text("NO COMPATIBLE MAPS FOUND") finally: imgui.end() + def _draw_course_selection(self, imgui: Any) -> None: + option = self._selected_map_option + if self._selected_game_mode != "race": + self._menu_stage = "map" + return + if option is None: + self._menu_stage = "map" + return + window_width = max(1.0, min(620.0, float(self.width) - 28.0)) + window_height = max(1.0, min(420.0, float(self.height) - 28.0)) + _prepare_window( + imgui, + position=( + max(14.0, (self.width - window_width) / 2.0), + max(14.0, (self.height - window_height) / 2.0), + ), + size=(window_width, window_height), + alpha=0.94, + ) + visible = _begin_window(imgui, "Crazy Robotaxi — Select Race Course") + try: + if not visible: + return + if imgui.button("BACK"): + self._selected_map_option = None + self._menu_stage = "map" + return + imgui.text("ESC: BACK") + imgui.text(option.name) + imgui.text("SELECT RACE COURSE") + for course_index, course_id in enumerate(option.race_course_ids): + label = course_id.replace("-", " ").replace("_", " ").upper() + if imgui.button(f"{label}##course-{course_index}"): + self._start_game(option, race_course_id=course_id) + finally: + imgui.end() + def _draw_text_window( self, imgui: Any, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 4deffba28..5093eb27e 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -54,6 +54,13 @@ pytestmark = pytest.mark.ci_cpu +_DEMO_RACE_MAP = ( + Path(__file__).parents[1] + / "crazy_robotaxi" + / "maps" + / "demo_race_track.robotaxi.yaml" +) + def _scene(*, width: int = 1280, height: int = 704) -> SceneDefinition: calibration = CameraCalibration( @@ -146,6 +153,39 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert model_loop.is_finished() +def test_complete_cli_game_selection_starts_without_menus() -> None: + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: object(), + scene_factory=lambda request, raster: _scene(), + ) + app.init( + [ + "--device", + "cpu", + "--game-mode", + "race", + "--map", + str(_DEMO_RACE_MAP), + "--race-course", + "grand-prix", + ] + ) + assert app._config is not None + assert app._config.cli_game_mode == "race" + assert app._config.cli_map_path == _DEMO_RACE_MAP.resolve() + assert app._config.cli_race_course_id == "grand-prix" + + session = app.create_session(app.session_desc()) + session.init() + ui_loop, model_loop = session._take_loops() + + assert ui_loop.state._menu_stage == "loading" + model_loop._run_message_batch() + assert model_loop.state.game_selected + assert model_loop.state.config.game_mode == "race" + assert model_loop.state.config.race_course_id == "grand-prix" + + def test_native_window_accepts_crazy_robotaxi_output_contract() -> None: """Keep the app's fixed output contract compatible with V2 native output.""" diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 77424ccea..1fcd73270 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -719,7 +719,7 @@ def test_startup_menu_selects_taxi_mode_then_map_through_v2_message() -> None: ] -def test_race_menu_selects_map_and_course_together() -> None: +def test_race_menu_selects_map_then_course() -> None: option = GameMapOption( map_id="test-city", name="Test City", @@ -739,7 +739,11 @@ def test_race_menu_selects_map_and_course_together() -> None: imgui = _FakeImGui() imgui.clicked_buttons.add("RACE") state.draw(imgui) - imgui.clicked_buttons = {"DOWNTOWN SPRINT##course-0-0"} + imgui.clicked_buttons = {"Test City##map-0"} + + state.draw(imgui) + assert state._menu_stage == "course" + imgui.clicked_buttons = {"DOWNTOWN SPRINT##course-0"} state.draw(imgui) model_loop._run_message_batch() @@ -753,6 +757,68 @@ def test_race_menu_selects_map_and_course_together() -> None: ] +def test_complete_cli_selection_skips_all_selection_screens() -> None: + option = GameMapOption( + map_id="test-city", + name="Test City", + path=Path("test-city.robotaxi.yaml").resolve(), + variant="default", + race_course_ids=("downtown-sprint",), + ) + state = TaxiHudState( + 640, + 360, + _calibration(), + map_options=(option,), + initial_game_mode="race", + initial_map_path=option.path, + initial_race_course_id="downtown-sprint", + ) + model_loop = _SelectionLoop() + model_loop.register_session_loop_objects( + state=_SelectionState(), + frequency=0, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + state.model_loop = model_loop + + state.initialize_selection() + + assert state._menu_stage == "loading" + model_loop._run_message_batch() + assert model_loop.state.selections == [ + GameSelection( + mode="race", + map_option=option, + race_course_id="downtown-sprint", + ) + ] + + +def test_explicit_race_mode_and_map_skip_to_course_screen() -> None: + option = GameMapOption( + map_id="test-city", + name="Test City", + path=Path("test-city.robotaxi.yaml").resolve(), + variant="default", + race_course_ids=("downtown-sprint",), + ) + state = TaxiHudState( + 640, + 360, + _calibration(), + map_options=(option,), + initial_game_mode="race", + initial_map_path=option.path, + ) + + state.initialize_selection() + + assert state._menu_stage == "course" + assert state._selected_map_option is option + + def test_escape_navigates_game_to_map_to_mode_then_exits() -> None: state = TaxiHudState(640, 360, _calibration()) state._selected_game_mode = "race" From ee5330830501a8514e84fe5faeefd1717af0f7bc Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 17:41:35 -0700 Subject: [PATCH 34/62] Reconcile Crazy Robotaxi with OmniDreams V2 layout Signed-off-by: Aidan Foster --- .../crazy_robotaxi/application.py | 44 ++++++++++++++----- .../crazy_robotaxi/live_edit/style_ability.py | 4 +- apps/crazy_robotaxi/tests/test_application.py | 29 ++++++------ .../omnidreams_game_engine/scene_loader.py | 2 +- pyproject.toml | 7 ++- uv.lock | 22 ++++++++-- 6 files changed, 75 insertions(+), 33 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 12d7f5663..6a5d76b1b 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -8,15 +8,16 @@ import argparse import copy import logging +import os from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Literal +import torch from omnidreams.config import ( - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, + OMNIDREAMS_PERF_PIPELINE_CONFIG, + OMNIDREAMS_PIPELINE_CONFIG, ) from omnidreams_game_engine.cli_args import ( ExplicitArgTrackingArgumentParser, @@ -65,8 +66,33 @@ _DEFAULT_PREWARM_BLOCKS = 4 """Blocks covering chunk2 cache filling and the first steady-state AR shape.""" +_NATIVE_PERF_PIPELINE = derive_config( + OMNIDREAMS_PIPELINE_CONFIG, + name="crazy-robotaxi-native-perf", + image_encoder={ + "dtype": torch.float16, + "use_compile": False, + "use_cuda_graph": False, + "native_vae_acceleration": "required", + "native_vae_backend": "fp8", + "native_vae_fp8_state_path": os.environ.get( + "OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH" + ), + }, + encoder={ + "dtype": torch.float16, + "use_compile": False, + "use_cuda_graph": False, + "native_vae_acceleration": "required", + "native_vae_backend": "fp8", + "native_vae_fp8_state_path": os.environ.get( + "OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH" + ), + }, +) + _ORIGINAL_PERF_PIPELINE = derive_config( - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline, + OMNIDREAMS_PERF_PIPELINE_CONFIG, name="crazy-robotaxi-original-perf", diffusion_model={ "seed": None, @@ -85,7 +111,7 @@ ) _FAST_PERF_PIPELINE = derive_config( - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline, + _NATIVE_PERF_PIPELINE, name="crazy-robotaxi-fast-perf", diffusion_model=copy.deepcopy(_ORIGINAL_PERF_PIPELINE.diffusion_model), ) @@ -101,11 +127,9 @@ class _ModelPreset: _MODEL_PRESETS = { - "standard": _ModelPreset(RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline), - "perf": _ModelPreset(RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline), - "native-perf": _ModelPreset( - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline - ), + "standard": _ModelPreset(OMNIDREAMS_PIPELINE_CONFIG), + "perf": _ModelPreset(OMNIDREAMS_PERF_PIPELINE_CONFIG), + "native-perf": _ModelPreset(_NATIVE_PERF_PIPELINE), "original-perf": _ModelPreset( _ORIGINAL_PERF_PIPELINE, renderer_follows_session=True, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py index 63f98428e..cef29bee5 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py @@ -7,12 +7,12 @@ ``integrations/omnidreams/scripts/smoke_text_edit.py`` onto :class:`omnidreams_game_engine.world_model.flashdreams_adapter.FlashdreamsWorldModelSession`: -- a pre-merged :class:`omnidreams._edit_lora.TextEditLoRA` on the +- a pre-merged :class:`omnidreams.impl._edit_lora.TextEditLoRA` on the transformer (zero steady-state cost; ``replace_text`` opens its edit window automatically), - the rank-16 drift corrector. Default (``corrector_mode="fused"``): the CUDA-graph-safe per-state - :class:`omnidreams._drift_corrector.DriftCorrectorDispatch` — one + :class:`omnidreams.impl._drift_corrector.DriftCorrectorDispatch` — one pre-merged weight-set family per (base | skin | weather) state, ``compile_network`` and ``use_cuda_graph`` stay ON. The edit LoRA hands its self-attention deltas to the dispatch (``release_targets``), whose diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 5093eb27e..cc6f9f398 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -14,6 +14,7 @@ import torch from crazy_robotaxi.application import ( _MODEL_PRESETS, + _NATIVE_PERF_PIPELINE, CrazyRobotaxiApplication, _fit_bev_renderer_to_ui, ) @@ -28,9 +29,8 @@ ) from crazy_robotaxi.ui import CrazyRobotaxiImGuiUILoop from omnidreams.config import ( - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, + OMNIDREAMS_PERF_PIPELINE_CONFIG, + OMNIDREAMS_PIPELINE_CONFIG, ) from omnidreams_game_engine.config import BevConfig, RasterConfig from omnidreams_game_engine.renderer_settings import RendererSettings @@ -197,7 +197,12 @@ def __init__(self) -> None: self.closed = False def set_input_callbacks(self, **callbacks: object) -> None: - assert set(callbacks) == {"on_keyboard_event", "on_mouse_event"} + assert set(callbacks) == { + "on_keyboard_event", + "on_mouse_event", + "on_gamepad_event", + "on_gamepad_state", + } def present_frame(self, frame: torch.Tensor) -> bool: self.frames.append(frame) @@ -355,18 +360,9 @@ def test_pipeline_profiling_is_an_app_local_opt_in( def test_existing_model_presets_keep_their_packaged_pipeline_configs() -> None: - assert ( - _MODEL_PRESETS["standard"].pipeline - is RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.pipeline - ) - assert ( - _MODEL_PRESETS["perf"].pipeline - is RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.pipeline - ) - assert ( - _MODEL_PRESETS["native-perf"].pipeline - is RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.pipeline - ) + assert _MODEL_PRESETS["standard"].pipeline is OMNIDREAMS_PIPELINE_CONFIG + assert _MODEL_PRESETS["perf"].pipeline is OMNIDREAMS_PERF_PIPELINE_CONFIG + assert _MODEL_PRESETS["native-perf"].pipeline is _NATIVE_PERF_PIPELINE assert all( not _MODEL_PRESETS[name].renderer_follows_session for name in ("standard", "perf", "native-perf") @@ -415,6 +411,7 @@ def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: assert pipeline.image_encoder.native_vae_backend == "fp8" assert pipeline.encoder.native_vae_acceleration == "required" assert pipeline.encoder.native_vae_backend == "fp8" + assert native.diffusion_model.transformer.native_dit_acceleration == "disabled" assert pipeline.diffusion_model.transformer.native_dit_acceleration == "required" assert ( pipeline.diffusion_model.transformer.native_dit_backend == "fp8_kvcache_cudnn" diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py index 226d0bcec..f2ebea593 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py @@ -15,7 +15,7 @@ import pyarrow.parquet as pq import yaml from loguru import logger -from omnidreams.scenes import ( +from omnidreams.impl.scenes import ( SCENE_FRAME_SUFFIXES, SCENE_FRAMES_DIRNAME, prompt_variant_for_scene_variant, diff --git a/pyproject.toml b/pyproject.toml index d905efe74..4ddf49559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,12 @@ members = [ # Nested sub-packages that the integration globs do not reach. "integrations_v2/omnidreams/impl/ludus-renderer", ] -exclude = ["apps/__init__.py", "apps/README.md", "integrations_v2/README.md"] +exclude = [ + "apps/__init__.py", + "apps/README.md", + "integrations/omnidreams", + "integrations_v2/README.md", +] # Override nvidia-cublas to >=13.4 because transformer-engine-cu13 2.14.0 was # compiled against cuBLAS 13.4.x, while torch 2.11 pins nvidia-cublas 13.1.0.3. diff --git a/uv.lock b/uv.lock index 19666f962..48f0dd2c8 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "flashdreams-cam2v", "flashdreams-cam2v-lingbot", "flashdreams-causal-forcing", + "flashdreams-clipgt2v-v2", "flashdreams-color-fade", "flashdreams-cosmos-predict2", "flashdreams-fastvideo-causal-wan22", @@ -701,7 +702,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "flashdreams", extras = ["local-window"], editable = "flashdreams" }, - { name = "flashdreams-omnidreams", editable = "integrations/omnidreams" }, + { name = "flashdreams-omnidreams", editable = "integrations_v2/omnidreams" }, { name = "omnidreams-game-engine", editable = "apps/omnidreams_game_engine" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-manual-marker", marker = "extra == 'dev'", specifier = ">=2.0" }, @@ -1218,6 +1219,21 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "flashdreams-clipgt2v-v2" +version = "0.1.0" +source = { editable = "apps/clipgt2v" } +dependencies = [ + { name = "flashdreams", extra = ["local-window", "serving"] }, + { name = "ludus-renderer" }, +] + +[package.metadata] +requires-dist = [ + { name = "flashdreams", extras = ["local-window", "serving"], editable = "flashdreams" }, + { name = "ludus-renderer", editable = "integrations_v2/omnidreams/impl/ludus-renderer" }, +] + [[package]] name = "flashdreams-color-fade" version = "0.1.0" @@ -3430,8 +3446,8 @@ dev = [ requires-dist = [ { name = "filelock", specifier = ">=3" }, { name = "flashdreams", editable = "flashdreams" }, - { name = "flashdreams-omnidreams", editable = "integrations/omnidreams" }, - { name = "ludus-renderer", editable = "integrations/omnidreams/ludus-renderer" }, + { name = "flashdreams-omnidreams", editable = "integrations_v2/omnidreams" }, + { name = "ludus-renderer", editable = "integrations_v2/omnidreams/impl/ludus-renderer" }, { name = "numpy" }, { name = "pillow" }, { name = "pyarrow" }, From eb57c2d61bf56029ae9a356743b48b0553803adc Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 18:08:31 -0700 Subject: [PATCH 35/62] Simplify Crazy Robotaxi model presets Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 52 ++++------------- .../crazy_robotaxi/application.py | 41 ++------------ apps/crazy_robotaxi/tests/test_application.py | 56 ++++--------------- .../omnidreams_game_engine/model.py | 4 +- .../tests/test_model.py | 10 ++-- 5 files changed, 36 insertions(+), 127 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 4deff150b..3d57a012b 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -125,14 +125,14 @@ preset because those paths intercept the Python transformer conditioning seam; the application reports this before generation if an incompatible native preset is selected. All abilities are disabled by default. -## Original demo performance preset +## Maximum-performance preset -The opt-in `original-perf` preset reproduces the performance knobs from the -current `example_world_model_perf.yaml` without importing or depending on the -`interactive_drive` application. It uses the compiled LightVAE/LightTAE path, -the required native FP8 DiT with cuDNN attention, skips the KV-cache finalize -pass, and uses denoising timesteps `[1000, 100]`. For direct comparison with -that manifest, select its 1168x640 resolution as shown below. +Crazy Robotaxi exposes the public OmniDreams `standard` and `perf` pipeline +configs unchanged. Its only app-specific model preset is `fast-perf`, which +derives from the public OmniDreams perf config and adds the native FP8 LightVAE +image and per-chunk encoders. It therefore retains perf's required native FP8 +DiT, cuDNN attention, compiled/CUDA-graphed LightTAE decoder, and two-step +schedule. Prepare the native DiT sources once from the repository root: @@ -140,35 +140,6 @@ Prepare the native DiT sources once from the repository root: uv run --package flashdreams-omnidreams omnidreams-prepare --perf ``` -Then run Crazy Robotaxi with the runtime resolution arguments before the -application-argument separator: - -```bash -uv run flashdreams-run-v2 crazy-robotaxi \ - --mode native-window \ - --pixel-width 1168 \ - --pixel-height 640 \ - -- \ - --model-preset original-perf -``` - -The resolution flags are optional: without them, the preset uses the V2 -session's default resolution and adapts the game renderer to match. The preset -still fails if the required native DiT cannot be built or loaded. It keeps the -PyTorch LightVAE encoder, so it does not require a `lightvae-fp8-state.pt` -file. Use the existing `perf` and `native-perf` presets for their previous -behavior. GPU throughput and quality still need to be validated on the target -machine before treating this preset as a regression baseline. - -## Candidate maximum-performance preset - -The opt-in `fast-perf` preset combines `original-perf`'s required native FP8 -DiT and cuDNN attention with `native-perf`'s native FP8 LightVAE image and -per-chunk encoders. It retains the compiled/CUDA-graphed LightTAE decoder and -the original demo's two-step schedule. This combination is a performance -candidate, not yet a validated default; compare its steady-state timings and -output quality with `original-perf` on the target GPU. - It requires a calibrated LightVAE FP8 state. After generating that state, run: ```bash @@ -181,9 +152,10 @@ OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH=artifacts/native_vae/lightvae-fp8-state.pt \ --model-preset fast-perf ``` -The native DiT sources must also have been prepared as described above. As -with `original-perf`, the resolution flags are optional and the app renderer -follows the V2 session dimensions. +The native DiT sources must also have been prepared as described above. The +resolution flags are optional; `fast-perf` adapts the app renderer to the V2 +session dimensions. GPU throughput and quality should be validated on the +target machine before treating it as a regression baseline. ## Performance diagnostics @@ -226,7 +198,7 @@ CUDA-tensor image hook in the V2 ImGui renderer. The saved [performance investigation](../../docs/design/crazy_robotaxi_v2_performance.md) records the current baseline, what the existing captures prove, and the exact -like-for-like `original-perf` rerun needed after presentation changes. +like-for-like `fast-perf` rerun needed after presentation changes. Presentation remains fixed at 30 fps. Disabling diagnostic synchronization removes an avoidable pause source, but it does not make a model preset whose diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 6a5d76b1b..6b1db1a82 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -6,7 +6,6 @@ from __future__ import annotations import argparse -import copy import logging import os from collections.abc import Callable, Sequence @@ -66,9 +65,10 @@ _DEFAULT_PREWARM_BLOCKS = 4 """Blocks covering chunk2 cache filling and the first steady-state AR shape.""" -_NATIVE_PERF_PIPELINE = derive_config( - OMNIDREAMS_PIPELINE_CONFIG, - name="crazy-robotaxi-native-perf", + +_FAST_PERF_PIPELINE = derive_config( + OMNIDREAMS_PERF_PIPELINE_CONFIG, + name="crazy-robotaxi-fast-perf", image_encoder={ "dtype": torch.float16, "use_compile": False, @@ -89,33 +89,9 @@ "OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH" ), }, + diffusion_model={"seed": None}, ) - -_ORIGINAL_PERF_PIPELINE = derive_config( - OMNIDREAMS_PERF_PIPELINE_CONFIG, - name="crazy-robotaxi-original-perf", - diffusion_model={ - "seed": None, - "transformer": { - "compile_network": True, - "skip_finalize_kv_cache": True, - "native_dit_acceleration": "required", - "native_dit_backend": "fp8_kvcache_cudnn", - "native_dit_attention_backend": "cudnn", - }, - "scheduler": { - "denoising_timesteps": [1000, 100], - "num_inference_steps": 2, - }, - }, -) - -_FAST_PERF_PIPELINE = derive_config( - _NATIVE_PERF_PIPELINE, - name="crazy-robotaxi-fast-perf", - diffusion_model=copy.deepcopy(_ORIGINAL_PERF_PIPELINE.diffusion_model), -) -"""Candidate maximum-performance path: native FP8 LightVAE and native FP8 DiT.""" +"""OmniDreams perf plus native FP8 LightVAE for maximum throughput.""" @dataclass(frozen=True, slots=True) @@ -129,11 +105,6 @@ class _ModelPreset: _MODEL_PRESETS = { "standard": _ModelPreset(OMNIDREAMS_PIPELINE_CONFIG), "perf": _ModelPreset(OMNIDREAMS_PERF_PIPELINE_CONFIG), - "native-perf": _ModelPreset(_NATIVE_PERF_PIPELINE), - "original-perf": _ModelPreset( - _ORIGINAL_PERF_PIPELINE, - renderer_follows_session=True, - ), "fast-perf": _ModelPreset( _FAST_PERF_PIPELINE, renderer_follows_session=True, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index cc6f9f398..542b58cd3 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -13,8 +13,8 @@ import pytest import torch from crazy_robotaxi.application import ( + _FAST_PERF_PIPELINE, _MODEL_PRESETS, - _NATIVE_PERF_PIPELINE, CrazyRobotaxiApplication, _fit_bev_renderer_to_ui, ) @@ -362,67 +362,33 @@ def test_pipeline_profiling_is_an_app_local_opt_in( def test_existing_model_presets_keep_their_packaged_pipeline_configs() -> None: assert _MODEL_PRESETS["standard"].pipeline is OMNIDREAMS_PIPELINE_CONFIG assert _MODEL_PRESETS["perf"].pipeline is OMNIDREAMS_PERF_PIPELINE_CONFIG - assert _MODEL_PRESETS["native-perf"].pipeline is _NATIVE_PERF_PIPELINE - assert all( - not _MODEL_PRESETS[name].renderer_follows_session - for name in ("standard", "perf", "native-perf") - ) - - -def test_original_perf_matches_the_original_demo_manifest() -> None: - preset = _MODEL_PRESETS["original-perf"] - pipeline = preset.pipeline - transformer = pipeline.diffusion_model.transformer - scheduler = pipeline.diffusion_model.scheduler - - assert preset.renderer_follows_session - assert pipeline.name == "crazy-robotaxi-original-perf" - assert pipeline.diffusion_model.seed is None - assert transformer.compile_network is True - assert transformer.use_cuda_graph is True - assert transformer.window_size_t == 6 - assert transformer.sink_size_t == 0 - assert transformer.skip_finalize_kv_cache is True - assert transformer.native_dit_acceleration == "required" - assert transformer.native_dit_backend == "fp8_kvcache_cudnn" - assert transformer.native_dit_attention_backend == "cudnn" - assert scheduler.num_inference_steps == 2 - assert scheduler.denoising_timesteps == [1000, 100] - assert pipeline.image_encoder.use_compile is True - assert pipeline.encoder.use_compile is True - assert pipeline.decoder.use_compile is True - assert pipeline.image_encoder.native_vae_acceleration == "disabled" - assert pipeline.encoder.native_vae_acceleration == "disabled" + assert _MODEL_PRESETS["fast-perf"].pipeline is _FAST_PERF_PIPELINE + assert not _MODEL_PRESETS["standard"].renderer_follows_session + assert not _MODEL_PRESETS["perf"].renderer_follows_session def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: preset = _MODEL_PRESETS["fast-perf"] pipeline = preset.pipeline - original = _MODEL_PRESETS["original-perf"].pipeline - native = _MODEL_PRESETS["native-perf"].pipeline - assert preset.renderer_follows_session assert pipeline.name == "crazy-robotaxi-fast-perf" - assert pipeline.diffusion_model == original.diffusion_model - assert pipeline.image_encoder == native.image_encoder - assert pipeline.encoder == native.encoder - assert pipeline.decoder == original.decoder + assert pipeline.diffusion_model.seed is None + assert pipeline.decoder.use_compile is True + assert pipeline.decoder.use_cuda_graph is True assert pipeline.image_encoder.native_vae_acceleration == "required" assert pipeline.image_encoder.native_vae_backend == "fp8" assert pipeline.encoder.native_vae_acceleration == "required" assert pipeline.encoder.native_vae_backend == "fp8" - assert native.diffusion_model.transformer.native_dit_acceleration == "disabled" assert pipeline.diffusion_model.transformer.native_dit_acceleration == "required" assert ( pipeline.diffusion_model.transformer.native_dit_backend == "fp8_kvcache_cudnn" ) + assert pipeline.diffusion_model.transformer.native_dit_attention_backend == "cudnn" @pytest.mark.parametrize("resolution_wh", [(1280, 704), (1168, 640)]) -@pytest.mark.parametrize("preset_name", ["original-perf", "fast-perf"]) def test_app_owned_perf_presets_adapt_renderer_to_session_geometry( resolution_wh: tuple[int, int], - preset_name: str, ) -> None: configured: list[object] = [] raster_sizes: list[tuple[int, int]] = [] @@ -437,7 +403,7 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: pipeline_factory=lambda config, device: configured.append(config) or object(), scene_factory=load_test_scene, ) - app.init(["--model-preset", preset_name]) + app.init(["--model-preset", "fast-perf"]) desc = replace( app.session_desc(), video_width=resolution_wh[0], @@ -466,13 +432,13 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: ) -def test_original_perf_honors_explicit_pipeline_overrides() -> None: +def test_fast_perf_honors_explicit_pipeline_overrides() -> None: app = CrazyRobotaxiApplication() app.init( [ "--model-preset", - "original-perf", + "fast-perf", "--seed", "7", "--no-compile", diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py index fa20ed97f..bc4392f66 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py @@ -78,7 +78,7 @@ def is_running(self) -> bool: def frame_count(self, autoregressive_index: int) -> int: """Return the pipeline's authoritative output count for one step.""" - count = int(self.pipeline.get_num_frames(autoregressive_index)) + count = int(self.pipeline.get_num_output_frames(autoregressive_index)) if count <= 0: raise ValueError("The pipeline returned a non-positive frame count") return count @@ -119,7 +119,7 @@ def step( video = self.pipeline.generate( autoregressive_index=autoregressive_index, cache=self.cache, - hdmap=engine_step.condition.hdmap_bvtchw, + input=engine_step.condition.hdmap_bvtchw, ) metrics = self.pipeline.finalize( autoregressive_index=autoregressive_index, diff --git a/apps/omnidreams_game_engine/tests/test_model.py b/apps/omnidreams_game_engine/tests/test_model.py index a244fcd8b..4dabb67e7 100644 --- a/apps/omnidreams_game_engine/tests/test_model.py +++ b/apps/omnidreams_game_engine/tests/test_model.py @@ -64,12 +64,12 @@ def initialize_cache(self, **kwargs): self.calls.append(("initialize_cache", kwargs)) return {"rollout": len(self.calls)} - def get_num_frames(self, autoregressive_index): - self.calls.append(("get_num_frames", autoregressive_index)) + def get_num_output_frames(self, autoregressive_index): + self.calls.append(("get_num_output_frames", autoregressive_index)) return 2 - def generate(self, *, autoregressive_index, cache, hdmap): - self.calls.append(("generate", (autoregressive_index, cache, hdmap.shape))) + def generate(self, *, autoregressive_index, cache, input): + self.calls.append(("generate", (autoregressive_index, cache, input.shape))) return torch.zeros(1, 1, 2, 3, 4, 8) def finalize(self, *, autoregressive_index, cache): @@ -138,7 +138,7 @@ def engine_factory() -> _Engine: assert result.metrics["rollout_cpu_ms"] >= 0.0 assert [call[0] for call in pipeline.calls] == [ "initialize_cache", - "get_num_frames", + "get_num_output_frames", "generate", "finalize", ] From 9d5e2aefe7ad74ebce49ab369dde378b2f0373e1 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 19:24:53 -0700 Subject: [PATCH 36/62] Wire Crazy Robotaxi to realtime V2 input Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 4 +- apps/crazy_robotaxi/crazy_robotaxi/session.py | 26 +-- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 69 ------ apps/crazy_robotaxi/tests/test_application.py | 4 +- apps/crazy_robotaxi/tests/test_ui.py | 72 +----- .../omnidreams_game_engine/input.py | 217 ++++++++---------- .../tests/test_input.py | 45 ++-- .../design/crazy_robotaxi_v2_input_latency.md | 56 ++--- docs/design/crazy_robotaxi_v2_performance.md | 25 +- flashdreams/flashdreams/runtime/keyboard.py | 2 + 10 files changed, 175 insertions(+), 345 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 3d57a012b..cb8cdf291 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -215,13 +215,13 @@ uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ This opt-in adds a UI-thread key indicator and logs the time from V2 UI event receipt to the first presented model frame carrying that transition. It also -shows resolved, redundant, and capacity-dropped transition counts. The normal +shows sampled, unsupported, and between-sample transition counts. The normal HUD does not construct these widgets when the flag is absent. The indicator's physical-key-to-browser delay still includes WebRTC transport latency, while the reported `UI TO MODEL FRAME` value isolates the synchronous app/model portion. See the [V2 latency handoff](../../docs/design/crazy_robotaxi_v2_input_latency.md) for -the known runtime boundary. +the remaining synchronous model-step boundary. All model presets generate four neutral, hidden blocks before publishing the first gameplay frame. The responsive ImGui HUD shows the current warmup block, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 4453ef258..5fb0046e2 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -9,6 +9,7 @@ import time from collections.abc import Callable from dataclasses import dataclass, replace +from functools import cached_property from typing import TYPE_CHECKING, Any import numpy as np @@ -29,6 +30,7 @@ ) from flashdreams.api_v2.loop import IModelLoop, IUILoop, invoke_async from flashdreams.api_v2.session import ISession +from flashdreams.runtime_v2.presentation_manager import PresentationManager from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( @@ -297,7 +299,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: input_batch = state.driver_input.reduce( events, frame_count=frame_count, - frame_interval_s=(1.0 / state.session_desc.frames_per_second_for_step), ) state.input_transition_count += input_batch.transition_count state.input_ignored_event_count += input_batch.ignored_event_count @@ -359,7 +360,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: input_transition_count=state.input_transition_count, input_ignored_event_count=state.input_ignored_event_count, input_dropped_transition_count=state.input_dropped_transition_count, - ready_event=_record_ready_event(video), ) invoke_async( state.ui_loop, @@ -473,6 +473,11 @@ def __init__( def session_desc(self) -> SessionDesc: return self._session_desc + @cached_property + def _presentation_manager(self) -> PresentationManager: + """Return a frame manager initialized on the game device.""" + return PresentationManager(device=torch.device(self._config.device)) + def init(self) -> None: hud_state = TaxiHudState( width=self._session_desc.video_width, @@ -491,7 +496,6 @@ def init(self) -> None: state=hud_state, width=self._session_desc.video_width, height=self._session_desc.video_height, - presentation_device=self._config.device, ) model_loop = self.register_model_loop( CrazyRobotaxiModelLoop, @@ -500,22 +504,18 @@ def init(self) -> None: scene_factory=self._scene_factory, config=self._config, session_desc=self._session_desc, - driver_input=DriverInput(self._config.driver_input), + driver_input=DriverInput( + self._config.driver_input, + samples_per_second=( + self._session_desc.frames_per_second_for_step + ), + ), ui_loop=ui_loop, ), ) hud_state.model_loop = model_loop hud_state.initialize_selection() - -def _record_ready_event(video: torch.Tensor) -> torch.cuda.Event | None: - if not video.is_cuda: - return None - event = torch.cuda.Event() - event.record(torch.cuda.current_stream(video.device)) - return event - - def _restart_requested(events: UserInputEvents) -> bool: """Return whether this model step received a pressed R key.""" return any( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 6539e911f..ac3bd2edb 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -109,10 +109,6 @@ class TaxiHudFrame: input_dropped_transition_count: int = 0 """Cumulative transitions displaced by fixed-size model chunks.""" - ready_event: torch.cuda.Event | None = None - """CUDA event recorded after the model produced this frame's tensors.""" - - @dataclass(slots=True) class TaxiHudState: """Mutable Dear ImGui state owned exclusively by the V2 UI thread.""" @@ -162,9 +158,6 @@ class TaxiHudState: _waypoint_projections: tuple[TaxiCameraMarkerProjection, ...] = () """Cached world-marker projections for the presented generated frame.""" - _ready_source: TaxiHudFrame | None = None - """Presented frame whose model-to-UI CUDA dependency was submitted.""" - _name_input: str = "" """Immediate-mode name-entry buffer retained by the UI state.""" @@ -295,16 +288,6 @@ def consume_input_events(self, events: UserInputEvents) -> None: while len(self._input_received_at_s) > _MAX_BUFFERED_INPUT_EVENTS: self._input_received_at_s.popitem(last=False) - def wait_for_presented_frame(self, stream: torch.cuda.Stream) -> bool: - """Submit the current model frame's dependency to ``stream`` once.""" - selected = self._current - if selected is None or selected is self._ready_source: - return False - if selected.ready_event is not None: - stream.wait_event(selected.ready_event) - self._ready_source = selected - return selected.ready_event is not None - def _record_presented_input(self, selected: TaxiHudFrame) -> None: if not self.profile_input_latency: return @@ -615,7 +598,6 @@ def _clear_presented_game(self) -> None: self._current = None self._waypoint_source = None self._waypoint_projections = () - self._ready_source = None self._bev_source_key = None self._bev_panel = None self._bev_alpha = None @@ -1162,41 +1144,16 @@ def _submit_name(self, value: str) -> None: class CrazyRobotaxiImGuiUILoop(ImGuiUILoop[TaxiHudState]): """Present generated frames beneath a responsive Dear ImGui taxi HUD.""" - def __init__( - self, - *, - presentation_device: str | torch.device | None = None, - **kwargs: Any, - ) -> None: - """Configure the HUD renderer and its app-owned presentation stream. - - Args: - presentation_device: Device used for generated and composited - frames; ``None`` retains the calling thread's current stream. - **kwargs: Arguments forwarded to :class:`ImGuiUILoop`. - """ - super().__init__(**kwargs) - self._presentation_device = ( - None if presentation_device is None else torch.device(presentation_device) - ) - self._presentation_stream: torch.cuda.Stream | None = None - def step_ui( self, imgui: Any, step_index: int, events: UserInputEvents ) -> Tensor | None: """Draw the HUD and return the generated world frame beneath it.""" - presentation_stream = self._activate_presentation_stream() self.state.consume_input_events(events) frames = self.presented_model_frames() video = frames[0] if frames else None bev_frame = frames[1] if len(frames) > 1 else None if video is not None: self.state.select_presented_frame(video) - if presentation_stream is not None: - self.state.wait_for_presented_frame(presentation_stream) - video.record_stream(presentation_stream) - if bev_frame is not None: - bev_frame.record_stream(presentation_stream) self.state.draw_waypoints(imgui, video) self.state.draw(imgui, step_index, bev_frame=bev_frame) if video is None: @@ -1204,35 +1161,11 @@ def step_ui( converted = video.to(torch.float32) return self.state.composite_bev(converted, bev_frame) - def _activate_presentation_stream(self) -> torch.cuda.Stream | None: - """Make the app-owned CUDA stream current on the V2 UI thread.""" - device = self._presentation_device - if device is None or device.type != "cuda": - return None - with torch.cuda.device(device): - if self._presentation_stream is None: - self._presentation_stream = torch.cuda.Stream(device=device) - torch.cuda.set_stream(self._presentation_stream) - return self._presentation_stream - def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" self.state.reset() super().reset() - def close(self) -> None: - """Release the renderer and restore the UI thread's default stream.""" - try: - super().close() - finally: - if self._presentation_stream is not None: - assert self._presentation_device is not None - with torch.cuda.device(self._presentation_device): - torch.cuda.set_stream( - torch.cuda.default_stream(self._presentation_device) - ) - self._presentation_stream = None - def build_hud_frames( video_tchw: Tensor, @@ -1243,7 +1176,6 @@ def build_hud_frames( input_transition_count: int = 0, input_ignored_event_count: int = 0, input_dropped_transition_count: int = 0, - ready_event: torch.cuda.Event | None = None, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) @@ -1271,7 +1203,6 @@ def build_hud_frames( input_transition_count=input_transition_count, input_ignored_event_count=input_ignored_event_count, input_dropped_transition_count=input_dropped_transition_count, - ready_event=ready_event, ) ) return tuple(frames) diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 542b58cd3..3dabffef4 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -119,7 +119,7 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert desc.frames_per_second_for_step == 30 assert isinstance(model_loop, CrazyRobotaxiModelLoop) assert isinstance(ui_loop, CrazyRobotaxiImGuiUILoop) - assert ui_loop._presentation_device == torch.device("cpu") + assert session._presentation_manager._presentation_stream is None assert model_loop.state.pipeline is pipeline assert model_loop.state.scene is None assert model_loop.state.rollout is None @@ -403,7 +403,7 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: pipeline_factory=lambda config, device: configured.append(config) or object(), scene_factory=load_test_scene, ) - app.init(["--model-preset", "fast-perf"]) + app.init(["--model-preset", "fast-perf", "--device", "cpu"]) desc = replace( app.session_desc(), video_width=resolution_wh[0], diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 1fcd73270..2a94a0fc9 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -8,11 +8,10 @@ import queue import threading import time -from contextlib import nullcontext from dataclasses import dataclass, field, replace from pathlib import Path from types import SimpleNamespace -from typing import Any, cast +from typing import Any import numpy as np import pytest @@ -299,42 +298,6 @@ def test_hud_frames_preserve_frame_aligned_input_diagnostics() -> None: assert frames[0].input_dropped_transition_count == 1 -def test_hud_submits_model_ready_dependency_once_per_presented_frame() -> None: - ready_event = cast(torch.cuda.Event, object()) - waited_events = [] - stream = cast( - torch.cuda.Stream, - SimpleNamespace(wait_event=waited_events.append), - ) - video = torch.zeros(1, 3, 96, 160) - state = TaxiHudState(160, 96, _calibration()) - state.publish( - build_hud_frames( - video, - (_snapshot(),), - np.eye(4, dtype=np.float32)[None], - ready_event=ready_event, - ) - ) - state.select_presented_frame(video[0]) - - assert state.wait_for_presented_frame(stream) - assert not state.wait_for_presented_frame(stream) - assert waited_events == [ready_event] - - state.reset() - state.publish( - build_hud_frames( - video, - (_snapshot(),), - np.eye(4, dtype=np.float32)[None], - ready_event=ready_event, - ) - ) - state.select_presented_frame(video[0]) - assert state.wait_for_presented_frame(stream) - - def test_hud_frames_reject_misaligned_input_diagnostics() -> None: with pytest.raises(ValueError, match="Input transitions"): build_hud_frames( @@ -602,39 +565,6 @@ def test_bev_draws_visible_waypoints_at_half_opacity() -> None: assert circles[0][1][-1] == expected_white -def test_imgui_ui_loop_owns_and_restores_a_cuda_presentation_stream( - monkeypatch: pytest.MonkeyPatch, -) -> None: - presentation_stream = cast(torch.cuda.Stream, object()) - default_stream = cast(torch.cuda.Stream, object()) - created_streams = [] - selected_streams = [] - - def create_stream(*, device: torch.device) -> torch.cuda.Stream: - created_streams.append(device) - return presentation_stream - - monkeypatch.setattr(torch.cuda, "device", lambda device: nullcontext(device)) - monkeypatch.setattr(torch.cuda, "Stream", create_stream) - monkeypatch.setattr(torch.cuda, "set_stream", selected_streams.append) - monkeypatch.setattr(torch.cuda, "default_stream", lambda device: default_stream) - renderer = _Renderer(160, 96) - loop = CrazyRobotaxiImGuiUILoop( - renderer=renderer, - presentation_device="cuda:0", - ) - - assert loop._activate_presentation_stream() is presentation_stream - assert loop._activate_presentation_stream() is presentation_stream - assert created_streams == [torch.device("cuda:0")] - assert selected_streams == [presentation_stream, presentation_stream] - - loop.close() - assert selected_streams[-1] is default_stream - assert loop._presentation_stream is None - assert renderer.closed - - def test_hud_draws_immediate_imgui_windows() -> None: state = TaxiHudState(160, 96, _calibration()) state.publish( diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index f357a6254..72cac5ba4 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -8,16 +8,19 @@ import logging from dataclasses import dataclass +from flashdreams.runtime.keyboard import DRIVING_SUPPORTED_KEYS +from flashdreams.runtime_v2.input_timeline import RealtimeInputTimeline +from flashdreams.runtime_v2.keyboard_input import ( + KeyboardStateSegment, + KeyboardStateTrack, +) from flashdreams.runtime_v2.user_input_event import ( - FocusUserInputEvent, - KeyboardInputState, KeyboardUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.types import DriverCommand -_DRIVE_KEYS = frozenset({"w", "a", "s", "d", "up", "down", "left", "right", "space"}) _LOGGER = logging.getLogger(__name__) @@ -32,14 +35,6 @@ class _DriveLevel: handbrake: bool = False -@dataclass(frozen=True, slots=True) -class _TimedDriveLevel: - """Resolved driving state paired with its V2 event timestamp.""" - - timestamp_us: int - level: _DriveLevel - - @dataclass(frozen=True, slots=True) class DriverInputBatch: """Per-frame driving commands for one model step.""" @@ -50,21 +45,32 @@ class DriverInputBatch: """Input transition represented by each command; ``None`` for retained state.""" transition_count: int - """Number of distinct driving-state transitions received in this batch.""" + """Number of driving-state transitions in the sampled input window.""" dropped_transition_count: int - """Number of oldest transitions that could not fit in the model chunk.""" + """Number of sampled transitions not represented by an output command.""" ignored_event_count: int - """Number of redundant drive events that did not change resolved controls.""" + """Number of keyboard events unsupported by the shared driving track.""" class DriverInput: - """Retain key levels while consuming V2 edge events exactly once.""" + """Sample shared V2 keyboard state into taxi driving commands.""" - def __init__(self, config: DriverInputConfig = DriverInputConfig()) -> None: + def __init__( + self, + config: DriverInputConfig = DriverInputConfig(), + *, + samples_per_second: float = 30.0, + ) -> None: self.config = config - self._pressed: set[str] = set() + self._timeline = RealtimeInputTimeline( + samples_per_second=samples_per_second, + ) + self._keyboard = KeyboardStateTrack( + supported_keys=DRIVING_SUPPORTED_KEYS, + ) + self._last_level = _DriveLevel() self._steering = 0.0 def reduce( @@ -72,49 +78,32 @@ def reduce( events: UserInputEvents, *, frame_count: int, - frame_interval_s: float, ) -> DriverInputBatch: - """Preserve input transitions and produce one command per simulated frame.""" + """Produce one command for each shared-timeline keyboard sample.""" if frame_count <= 0: raise ValueError("frame_count must be positive") - transitions: list[_TimedDriveLevel] = [] - ignored_event_count = 0 - for event in events.get_events(): - previous = self._drive_level() - if isinstance(event, FocusUserInputEvent) and not event.focused: - self._pressed.clear() - elif isinstance(event, KeyboardUserInputEvent): - key = _normalize_key(str(event.key)) - if key not in _DRIVE_KEYS: - continue - if event.state is KeyboardInputState.PRESSED: - self._pressed.add(key) - else: - self._pressed.discard(key) - else: - continue - - current = self._drive_level() - if current != previous: - transitions.append( - _TimedDriveLevel( - timestamp_us=int(event.get_timestamp()), - level=current, - ) - ) - else: - ignored_event_count += 1 - - scheduled, dropped_transition_count = _schedule_drive_levels( - transitions, - retained=self._drive_level(), - frame_count=frame_count, - frame_interval_s=frame_interval_s, + dispositions = self._keyboard.ingest(events) + window = self._timeline.next_window( + frame_count, + input_times_s=( + disposition.timestamp_s + for disposition in dispositions + if disposition.tracked + ), + ) + segments = self._keyboard.segments(window) + scheduled, transition_count, dropped_transition_count = ( + self._sample_drive_levels(segments, window.sample_times_s) + ) + ignored_event_count = sum( + isinstance(disposition.event, KeyboardUserInputEvent) + and not disposition.tracked + for disposition in dispositions ) if dropped_transition_count: _LOGGER.warning( - "Dropped %d oldest input transitions that could not fit in a " - "%d-frame model chunk", + "Dropped %d input transitions between model-frame samples in a " + "%d-frame chunk", dropped_transition_count, frame_count, ) @@ -133,7 +122,7 @@ def reduce( self._steering = _move_towards( self._steering, target, - rate * frame_interval_s, + rate * self._timeline.sample_interval_s, ) commands.append( DriverCommand( @@ -149,84 +138,74 @@ def reduce( return DriverInputBatch( commands=tuple(commands), transition_timestamps_us=tuple(transition_timestamps_us), - transition_count=len(transitions), + transition_count=transition_count, dropped_transition_count=dropped_transition_count, ignored_event_count=ignored_event_count, ) def reset(self) -> None: """Clear every retained input value.""" - self._pressed.clear() + self._timeline.reset() + self._keyboard.reset() + self._last_level = _DriveLevel() self._steering = 0.0 - def _drive_level(self) -> _DriveLevel: - return _DriveLevel( - forward=bool({"w", "up"} & self._pressed), - backward=bool({"s", "down"} & self._pressed), - left=bool({"a", "left"} & self._pressed), - right=bool({"d", "right"} & self._pressed), - handbrake="space" in self._pressed, - ) - - -def _schedule_drive_levels( - transitions: list[_TimedDriveLevel], - *, - retained: _DriveLevel, - frame_count: int, - frame_interval_s: float, -) -> tuple[list[tuple[_DriveLevel, int | None]], int]: - """Map timestamped transitions onto a fixed-size future model chunk.""" - if not transitions: - return [(retained, None) for _ in range(frame_count)], 0 - - scheduled: list[tuple[_DriveLevel, int, int]] = [] - safe_frame_interval_s = max(float(frame_interval_s), 1e-9) - for index, transition in enumerate(transitions): - if index + 1 < len(transitions): - next_transition = transitions[index + 1] - duration_s = max( - 0.0, - (next_transition.timestamp_us - transition.timestamp_us) / 1_000_000.0, - ) - repeat_count = max(0, round(duration_s / safe_frame_interval_s)) - else: - repeat_count = max(1, frame_count - len(scheduled)) - scheduled.extend( - (transition.level, transition.timestamp_us, index) - for _ in range(repeat_count) + def _sample_drive_levels( + self, + segments: list[KeyboardStateSegment], + sample_times_s: tuple[float, ...], + ) -> tuple[list[tuple[_DriveLevel, int | None]], int, int]: + """Project shared held-key segments onto discrete driving frames.""" + resolved_segments: list[tuple[float, float, _DriveLevel, int | None]] = [] + previous_level = self._last_level + transition_count = 0 + for start_s, end_s, keys in segments: + level = _drive_level(keys) + timestamp_us: int | None = None + if level != previous_level: + transition_count += 1 + timestamp_us = round(start_s * 1_000_000.0) + resolved_segments.append((start_s, end_s, level, timestamp_us)) + previous_level = level + + scheduled: list[tuple[_DriveLevel, int | None]] = [] + represented_timestamps: set[int] = set() + segment_index = 0 + current_timestamp_us: int | None = None + for sample_s in sample_times_s: + while ( + segment_index + 1 < len(resolved_segments) + and sample_s > resolved_segments[segment_index][1] + ): + introduced_timestamp_us = resolved_segments[segment_index][3] + if introduced_timestamp_us is not None: + current_timestamp_us = introduced_timestamp_us + represented_timestamps.add(introduced_timestamp_us) + segment_index += 1 + _, _, level, introduced_timestamp_us = resolved_segments[segment_index] + if introduced_timestamp_us is not None: + current_timestamp_us = introduced_timestamp_us + represented_timestamps.add(introduced_timestamp_us) + scheduled.append((level, current_timestamp_us)) + + self._last_level = previous_level + dropped_transition_count = max( + 0, + transition_count - len(represented_timestamps), ) + return scheduled, transition_count, dropped_transition_count - represented_before_capacity = {index for _, _, index in scheduled} - if len(scheduled) > frame_count: - scheduled = scheduled[-frame_count:] - elif len(scheduled) < frame_count: - latest = scheduled[-1] - scheduled.extend(latest for _ in range(frame_count - len(scheduled))) - retained_transition_indexes = {index for _, _, index in scheduled} - dropped_transition_count = len( - represented_before_capacity - retained_transition_indexes - ) - return ( - [(level, timestamp_us) for level, timestamp_us, _ in scheduled], - dropped_transition_count, +def _drive_level(keys: frozenset[str]) -> _DriveLevel: + return _DriveLevel( + forward=bool({"w", "up"} & keys), + backward=bool({"s", "down"} & keys), + left=bool({"a", "left"} & keys), + right=bool({"d", "right"} & keys), + handbrake="space" in keys, ) -def _normalize_key(key: str) -> str: - if key == " ": - return "space" - normalized = key.strip().lower() - return { - "arrowup": "up", - "arrowdown": "down", - "arrowleft": "left", - "arrowright": "right", - "spacebar": "space", - }.get(normalized, normalized) - - def _move_towards(current: float, target: float, maximum_delta: float) -> float: if current < target: return min(current + maximum_delta, target) diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index 4db1c98bd..7442283dc 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -51,7 +51,8 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: steering_scale=1.0, steering_rate_per_s=2.0, steering_return_rate_per_s=4.0, - ) + ), + samples_per_second=10.0, ) first = reducer.reduce( @@ -59,12 +60,10 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: _key("w", KeyboardInputState.PRESSED), _key("a", KeyboardInputState.PRESSED) ), frame_count=3, - frame_interval_s=0.1, ) retained = reducer.reduce( UserInputEvents([]), frame_count=1, - frame_interval_s=0.1, ) assert len(first.commands) == 3 @@ -80,11 +79,10 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: def test_focus_loss_releases_drive_state() -> None: - reducer = DriverInput() + reducer = DriverInput(samples_per_second=30.0) reverse = reducer.reduce( _events(_key("ArrowDown", KeyboardInputState.PRESSED)), frame_count=1, - frame_interval_s=1.0 / 30.0, ) assert reverse.commands[0].brake == 1.0 @@ -94,7 +92,6 @@ def test_focus_loss_releases_drive_state() -> None: result = reducer.reduce( _events(FocusUserInputEvent(timestamp=np.uint64(0), focused=False)), frame_count=1, - frame_interval_s=1.0 / 30.0, ) assert result.commands[0].throttle == 0.0 @@ -102,7 +99,7 @@ def test_focus_loss_releases_drive_state() -> None: def test_short_press_and_release_are_preserved_in_the_next_chunk() -> None: - reducer = DriverInput() + reducer = DriverInput(samples_per_second=20.0) result = reducer.reduce( _timed_events( @@ -110,7 +107,6 @@ def test_short_press_and_release_are_preserved_in_the_next_chunk() -> None: (1_100_000, _key("w", KeyboardInputState.RELEASED)), ), frame_count=5, - frame_interval_s=0.05, ) assert [command.throttle for command in result.commands] == [ @@ -132,7 +128,7 @@ def test_short_press_and_release_are_preserved_in_the_next_chunk() -> None: def test_duplicate_key_events_do_not_create_transitions() -> None: - reducer = DriverInput() + reducer = DriverInput(samples_per_second=30.0) result = reducer.reduce( _timed_events( @@ -140,28 +136,41 @@ def test_duplicate_key_events_do_not_create_transitions() -> None: (20, _key("w", KeyboardInputState.PRESSED)), ), frame_count=2, - frame_interval_s=1.0 / 30.0, ) assert result.transition_count == 1 assert result.transition_timestamps_us == (10, 10) - assert result.ignored_event_count == 1 + assert result.ignored_event_count == 0 -def test_transition_overflow_preserves_the_newest_controls() -> None: - reducer = DriverInput() +def test_browser_space_key_engages_the_handbrake() -> None: + reducer = DriverInput(samples_per_second=30.0) result = reducer.reduce( + _timed_events((10, _key(" ", KeyboardInputState.PRESSED))), + frame_count=1, + ) + + assert result.commands[0].handbrake + assert result.commands[0].brake == 1.0 + + +def test_transition_at_window_end_carries_into_the_next_chunk() -> None: + reducer = DriverInput(samples_per_second=30.0) + + first = reducer.reduce( _timed_events( (10_000, _key("w", KeyboardInputState.PRESSED)), (50_000, _key("w", KeyboardInputState.RELEASED)), (90_000, _key("w", KeyboardInputState.PRESSED)), ), frame_count=2, - frame_interval_s=1.0 / 30.0, ) + retained = reducer.reduce(UserInputEvents([]), frame_count=1) - assert [command.throttle for command in result.commands] == [0.0, 1.0] - assert result.transition_timestamps_us == (50_000, 90_000) - assert result.transition_count == 3 - assert result.dropped_transition_count == 1 + assert [command.throttle for command in first.commands] == [0.0, 0.0] + assert first.transition_timestamps_us == (50_000, 50_000) + assert first.transition_count == 2 + assert first.dropped_transition_count == 0 + assert retained.commands[0].throttle == 1.0 + assert retained.transition_timestamps_us == (90_000,) diff --git a/docs/design/crazy_robotaxi_v2_input_latency.md b/docs/design/crazy_robotaxi_v2_input_latency.md index 869dab8a5..2729044bd 100644 --- a/docs/design/crazy_robotaxi_v2_input_latency.md +++ b/docs/design/crazy_robotaxi_v2_input_latency.md @@ -2,11 +2,11 @@ ## Status -Crazy Robotaxi preserves timestamped input transitions on the application side -and offers opt-in UI-to-model-frame diagnostics. The remaining held-input -latency cannot be removed through the public application interfaces alone. -Shared V2 runtime changes are intentionally deferred to the API and -architecture owners. +Crazy Robotaxi uses the V2 runtime's shared real-time input timeline and +keyboard-state track, and offers opt-in UI-to-model-frame diagnostics. The V2 +WebRTC sender is bounded and drops stale unsent frames under congestion. The +remaining held-input latency is bounded by synchronous autoregressive model +steps: input cannot change a chunk whose inference is already in flight. ## Reproduction and evidence @@ -25,7 +25,7 @@ The investigation observed: video across model presets. - A 100–200 ms tap was often absent before timestamped transition reduction was added. -- `--presentation-mode only_present_new` did not materially improve the held +- `--presentation-mode on_demand` did not materially improve the held steering delay, so repeated UI frames were not its dominant cause. - A supplied synchronized diagnostic run produced eight-frame chunks covering 266.7 ms of video in 733.6–788.7 ms after warmup, averaging 760.1 ms. Pipeline @@ -39,41 +39,21 @@ an application/model response window of roughly 760–1520 ms before final WebRTC delivery. Smaller or preemptible model work is required to reduce that floor. -## V2 transport boundary +## V2 transport behavior -The current V2 WebRTC client window has a second, independent latency risk: +The V2 WebRTC client window now materializes UI frames at `window.write()`, +uses a bounded two-frame sender queue, and replaces the oldest unsent frame +during congestion. CUDA events order model output, UI composition, and WebRTC +transfer work without device-wide synchronization. Continuous presentation +can redraw input-responsive UI while model inference is still running, but it +cannot alter the already-conditioned model chunk. -- Its private video track uses an unbounded `asyncio.Queue` and appends every - submitted frame. -- `SessionDesc.backpressure_mode` configures `PresentationManager`, but does - not configure or bound the WebRTC queue. -- Every UI result is converted to CPU RGB and encoded by aiortc's software VP8 - path. The app cannot select the encoder, inspect queue depth or frame age, - flush stale frames, or observe browser presentation. -- The existing non-V2 WebRTC serving stack already contains bounded tracks, - stale-delivery flushing, pacing re-anchoring, delivery metrics, and optional - NVENC. V2 does not currently use those facilities. +## Remaining API decision -The application cannot safely work around this boundary without constructing -or reaching into a private client window, which would violate V2 ownership and -couple the game to one presentation mode. - -## Requested API decisions - -1. Define interactive transport backpressure end to end. Either propagate the - session policy through the client window or introduce a separate transport - policy with a bounded low-latency mode. -2. Reuse the shared WebRTC encoder/bridge abstractions, including optional - NVENC, rather than maintaining a second unbounded software-only path. -3. Expose encoder backend, conversion/encode time, queue depth, dropped or - flushed frames, and estimated frame age through runtime metrics. -4. Specify the input contract for long synchronous model steps. Sub-generation - response requires smaller chunks, cancellation/preemption, or an explicit - asynchronous conditioning mechanism; event polling alone cannot modify an - in-flight conditioned chunk. -5. Keep ordered/blocking delivery available for file and quality workflows, - while making the interactive low-latency policy explicit rather than - silently accumulating stale frames. +Specify the input contract for long synchronous model steps. Sub-generation +response requires smaller chunks, cancellation/preemption, or an explicit +asynchronous conditioning mechanism; event polling alone cannot modify an +in-flight conditioned chunk. ## Runtime acceptance tests diff --git a/docs/design/crazy_robotaxi_v2_performance.md b/docs/design/crazy_robotaxi_v2_performance.md index 1f7d1232c..6f08825a0 100644 --- a/docs/design/crazy_robotaxi_v2_performance.md +++ b/docs/design/crazy_robotaxi_v2_performance.md @@ -97,12 +97,12 @@ UI-step p90 fell from 37.2 ms to 4.2 ms, presented throughput increased from The capture also exposes a second synchronization boundary. Native surface acquisition consumed 22.15 seconds, with 222 of its 227 waits above 10 ms occurring on a model-frame advance. Those waits cluster near 90--110 ms, once -per generated chunk. The app therefore records a CUDA event after each model -chunk and keeps UI conversion, ImGui interop, composition, and native upload on -a dedicated presentation stream. The stream waits only for the selected -chunk's event instead of making Vulkan depend on subsequently queued model -work. This follow-up remains pending a target-machine `*-4.json` validation and -is not yet a validated performance result. +per generated chunk. V2 now records readiness on each `StepResult`; the +session's `PresentationManager` owns the dedicated presentation stream and +orders UI conversion, ImGui interop, composition, and native upload behind the +selected chunk without waiting for subsequently queued model work. This +follow-up remains pending a target-machine `*-4.json` validation and is not yet +a validated performance result. ## App-side PhysX bridge correction @@ -146,10 +146,9 @@ the two-chunk capacity and therefore could not establish that bound. ## Required post-optimization rerun -The original non-V2 demo uses `example_world_model_perf.yaml`. The -`original-perf` preset matches its important settings: 1168x640, native FP8 -DiT with cuDNN attention, `skip_finalize_kv_cache`, and denoising timesteps -`[1000, 100]`. Use fresh processes and keep ordered presentation: +The `fast-perf` preset derives from OmniDreams' supported performance config, +including its native DiT acceleration, and adds the native FP8 VAE path. Use +fresh processes and keep ordered presentation: ```bash /usr/bin/time -v -o /tmp/crazy-original-native.time \ @@ -158,10 +157,10 @@ DiT with cuDNN attention, `skip_finalize_kv_cache`, and denoising timesteps --pixel-width 1168 \ --pixel-height 640 \ --backpressure-mode block \ - --presentation-mode only_present_newest \ + --presentation-mode continuous \ --stats-path /tmp/crazy-vectorized-native.json \ -- \ - --model-preset original-perf \ + --model-preset fast-perf \ --seed 42 \ --total-blocks 32 \ --prewarm-blocks 4 @@ -178,7 +177,7 @@ median and p90. The remaining questions are: 1. Does `physx_barrier_rebound_s` fall near the isolated probe, and does total rollout latency move below the 266.7 ms chunk budget? -2. Does matched `original-perf` reach the original demo's measured producer +2. Does `fast-perf` reach the original demo's measured producer throughput on the same machine? 3. After the BEV correction, how much UI-thread CPU remains in native and WebRTC modes? diff --git a/flashdreams/flashdreams/runtime/keyboard.py b/flashdreams/flashdreams/runtime/keyboard.py index 1c04735ee..977aca36e 100644 --- a/flashdreams/flashdreams/runtime/keyboard.py +++ b/flashdreams/flashdreams/runtime/keyboard.py @@ -61,6 +61,8 @@ class SparseInputSnapshot: def normalize_key(key: str) -> str: normalized = key.lower() + if normalized.strip() == "spacebar": + return "space" return KEY_ALIASES.get(normalized, normalized.strip()) From 9a04b5e0525f3d64477f64bccc44f884979bd5cf Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 20:02:55 -0700 Subject: [PATCH 37/62] Align Crazy Robotaxi input with Interactive Drive Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 28 +- .../crazy_robotaxi/application.py | 12 +- apps/crazy_robotaxi/crazy_robotaxi/config.py | 20 +- .../configs/example_game_config.yaml | 4 - apps/crazy_robotaxi/crazy_robotaxi/session.py | 25 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 20 +- .../tests/test_layered_settings.py | 1 - apps/crazy_robotaxi/tests/test_ui.py | 41 ++- .../omnidreams_game_engine/config.py | 9 - .../omnidreams_game_engine/input.py | 304 ++++++++---------- .../tests/test_input.py | 162 ++++++---- .../design/crazy_robotaxi_v2_input_latency.md | 29 +- 12 files changed, 350 insertions(+), 305 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index cb8cdf291..753bb95eb 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -45,15 +45,19 @@ discovers bundled maps and `.robotaxi.yaml` maps beside that configured file. Map compilation and scene loading begin only after the menu choice. Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application -options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. Press R -to restart without closing the window. Restart rebuilds simulation, game rules, -traffic, conditioning, and the autoregressive cache together while retaining -the loaded model. Press Escape to return from the game to map selection, from -map selection to mode selection, and from mode selection to exit. The HUD keeps -an always-visible bearing arrow and overlays visible targets or an off-map -direction arrow on the BEV. Leaderboard name entry is owned by the Dear ImGui -UI and submitted to the model loop through V2's asynchronous loop-message -contract. +options. Drive with W/A/S/D or the arrow keys; Space stops the vehicle. A +standard gamepad uses the left stick for steering and the right/left triggers +for throttle/brake. V2 game-wheel events use their normalized steering, +throttle, and brake values directly. A connected controller takes precedence +over keyboard state, and disconnecting it falls back to any keys still held. +Press R to restart without closing the window. Restart rebuilds simulation, +game rules, traffic, conditioning, and the autoregressive cache together while +retaining the loaded model. Press Escape to return from the game to map +selection, from map selection to mode selection, and from mode selection to +exit. The HUD keeps an always-visible bearing arrow and overlays visible +targets or an off-map direction arrow on the BEV. Leaderboard name entry is +owned by the Dear ImGui UI and submitted to the model loop through V2's +asynchronous loop-message contract. ## Layered configuration @@ -61,8 +65,8 @@ Crazy Robotaxi accepts strict, partial `schema_version: 1` configuration files: - `--engine-config` covers the map request, model preset, renderer, and V2 runtime diagnostics. -- `--game-config` covers taxi/race mode, rules, input reduction, vehicle - physics, persistence, and live-edit abilities. +- `--game-config` covers taxi/race mode, rules, vehicle physics, persistence, + and live-edit abilities. Settings resolve from packaged typed defaults, then YAML, then explicitly supplied application flags. Omitted YAML fields retain their lower-precedence @@ -215,7 +219,7 @@ uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ This opt-in adds a UI-thread key indicator and logs the time from V2 UI event receipt to the first presented model frame carrying that transition. It also -shows sampled, unsupported, and between-sample transition counts. The normal +shows applied, ignored, and coalesced transition counts. The normal HUD does not construct these widgets when the flag is absent. The indicator's physical-key-to-browser delay still includes WebRTC transport latency, while the reported `UI TO MODEL FRAME` value isolates the synchronous app/model diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 6b1db1a82..50fb2552e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -22,7 +22,7 @@ ExplicitArgTrackingArgumentParser, arg_was_explicit, ) -from omnidreams_game_engine.config import BevConfig, DriverInputConfig, RasterConfig +from omnidreams_game_engine.config import BevConfig, RasterConfig from omnidreams_game_engine.engine_settings import ( EngineSettings, MapLaunchSettings, @@ -119,7 +119,6 @@ class ApplicationConfig: scene_request: SceneRequest renderer: RendererSettings game: TaxiGameConfig - driver_input: DriverInputConfig device: str total_blocks: int | None model_preset_name: str @@ -282,7 +281,6 @@ def init(self, commandline_args: Sequence[str]) -> None: ), renderer=renderer, game=game, - driver_input=game_settings.driver_input, device=engine_settings.world_model.device, total_blocks=engine_settings.runtime.total_blocks, model_preset_name=model_preset_name, @@ -372,13 +370,7 @@ def _resolve_engine_settings(self, args: argparse.Namespace) -> EngineSettings: ) def _resolve_game_settings(self, args: argparse.Namespace) -> CrazyRobotaxiSettings: - settings = CrazyRobotaxiSettings( - driver_input=DriverInputConfig( - steering_scale=1.0, - steering_rate_per_s=3.5, - steering_return_rate_per_s=5.0, - ) - ) + settings = CrazyRobotaxiSettings() if args.game_config is not None: settings = load_game_settings(args.game_config, base=settings) game = settings.game diff --git a/apps/crazy_robotaxi/crazy_robotaxi/config.py b/apps/crazy_robotaxi/crazy_robotaxi/config.py index 1c7e3c45f..267239cdf 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/config.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/config.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Literal -from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.yaml_config import ( StrictConfigError, load_yaml_mapping, @@ -27,13 +26,11 @@ {item.name for item in fields(TaxiGameConfig)} - _RUNTIME_GAME_FIELDS - {"vehicle"} ) _VEHICLE_FIELDS = {item.name for item in fields(TaxiVehicleConfig)} -_INPUT_FIELDS = {item.name for item in fields(DriverInputConfig)} _TOP_LEVEL_FIELDS = { "schema_version", "mode", "effects", "rules", - "input", "vehicle", "taxi", "race", @@ -93,9 +90,6 @@ class CrazyRobotaxiSettings: game: TaxiGameConfig = field(default_factory=TaxiGameConfig) """Taxi rules and player-vehicle configuration.""" - driver_input: DriverInputConfig = field(default_factory=DriverInputConfig) - """V2 keyboard-to-command reduction settings.""" - taxi: TaxiSessionSettings = field(default_factory=TaxiSessionSettings) """Taxi session and persistence settings.""" @@ -126,7 +120,7 @@ def load_game_settings( base: Lower-precedence settings; ``None`` uses typed defaults. Returns: - Resolved game, input, session, and live-edit settings. + Resolved game, session, and live-edit settings. Raises: StrictConfigError: The YAML or merged settings are invalid. @@ -165,14 +159,6 @@ def load_game_settings( game.vehicle, vehicle_values, "game.vehicle", base_dir=base_dir ) game = replace(game, vehicle=vehicle) - driver_input = settings.driver_input - if "input" in doc: - input_values = require_mapping(doc["input"], "game.input") - _reject_unknown(input_values, _INPUT_FIELDS, "game.input") - driver_input = overlay_dataclass( - driver_input, input_values, "game.input", base_dir=base_dir - ) - game = replace( game, seed=settings.taxi.seed, @@ -182,7 +168,7 @@ def load_game_settings( else {} ), ) - settings = replace(settings, game=game, driver_input=driver_input) + settings = replace(settings, game=game) _validate_game_settings(settings) return settings @@ -208,5 +194,3 @@ def _validate_game_settings(settings: CrazyRobotaxiSettings) -> None: ) if game.min_time_s > game.max_time_s: raise StrictConfigError("game.rules.min_time_s must not exceed max_time_s") - if settings.driver_input.steering_scale <= 0.0: - raise StrictConfigError("game.input.steering_scale must be positive") diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml index 0cefa7221..abc145231 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml @@ -23,10 +23,6 @@ rules: dropoff_time_bonus_s: 30.0 ground_snap_max_absolute_rotation_deg: 10.0 ground_snap_settle_fraction: 0.25 -input: - steering_scale: 1.0 - steering_rate_per_s: 3.5 - steering_return_rate_per_s: 5.0 vehicle: wheel_base_m: 2.8 max_steer_rad: 0.69 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 5fb0046e2..e167c4ccb 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -87,8 +87,8 @@ class ModelState: input_ignored_event_count: int = 0 """Cumulative redundant drive events consumed by model steps.""" - input_dropped_transition_count: int = 0 - """Cumulative transitions displaced by fixed-size model chunks.""" + input_coalesced_transition_count: int = 0 + """Cumulative earlier transitions collapsed into the latest input state.""" def ensure_rollout(self) -> WorldModelRollout: """Build and prewarm renderer, PhysX, game, and cache on the model thread.""" @@ -252,7 +252,7 @@ def reset(self) -> None: self.last_pose = None self.input_transition_count = 0 self.input_ignored_event_count = 0 - self.input_dropped_transition_count = 0 + self.input_coalesced_transition_count = 0 if self.rollout is not None: self.rollout.reset() @@ -302,7 +302,9 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ) state.input_transition_count += input_batch.transition_count state.input_ignored_event_count += input_batch.ignored_event_count - state.input_dropped_transition_count += input_batch.dropped_transition_count + state.input_coalesced_transition_count += ( + input_batch.coalesced_transition_count + ) generated = rollout.step( autoregressive_index=state.blocks_generated, commands=input_batch.commands, @@ -330,8 +332,8 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: { "input_transition_count": input_batch.transition_count, "input_ignored_event_count": input_batch.ignored_event_count, - "input_dropped_transition_count": ( - input_batch.dropped_transition_count + "input_coalesced_transition_count": ( + input_batch.coalesced_transition_count ), } ) @@ -359,7 +361,9 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: transition_timestamps_us=transition_timestamps_us, input_transition_count=state.input_transition_count, input_ignored_event_count=state.input_ignored_event_count, - input_dropped_transition_count=state.input_dropped_transition_count, + input_coalesced_transition_count=( + state.input_coalesced_transition_count + ), ) invoke_async( state.ui_loop, @@ -504,12 +508,7 @@ def init(self) -> None: scene_factory=self._scene_factory, config=self._config, session_desc=self._session_desc, - driver_input=DriverInput( - self._config.driver_input, - samples_per_second=( - self._session_desc.frames_per_second_for_step - ), - ), + driver_input=DriverInput(), ui_loop=ui_loop, ), ) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index ac3bd2edb..3e32668e6 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -55,6 +55,8 @@ from flashdreams.runtime_v2.imgui_ui_loop import ImGuiUILoop from flashdreams.runtime_v2.user_input_event import ( FocusUserInputEvent, + GamepadUserInputEvent, + GameWheelUserInputEvent, KeyboardInputState, KeyboardUserInputEvent, ) @@ -106,8 +108,8 @@ class TaxiHudFrame: input_ignored_event_count: int = 0 """Cumulative redundant drive events ignored by the model loop.""" - input_dropped_transition_count: int = 0 - """Cumulative transitions displaced by fixed-size model chunks.""" + input_coalesced_transition_count: int = 0 + """Cumulative earlier transitions collapsed into the latest input state.""" @dataclass(slots=True) class TaxiHudState: @@ -280,6 +282,8 @@ def consume_input_events(self, events: UserInputEvents) -> None: self._profile_pressed.add(key) else: self._profile_pressed.discard(key) + elif isinstance(event, (GamepadUserInputEvent, GameWheelUserInputEvent)): + recognized = True if not recognized: continue timestamp_us = int(event.get_timestamp()) @@ -301,12 +305,12 @@ def _record_presented_input(self, selected: TaxiHudFrame) -> None: self._latest_input_latency_ms = (time.perf_counter() - received_at_s) * 1000.0 _LOGGER.info( "[crazy-robotaxi] input-to-model-frame latency: event_us=%d " - "ui_to_frame_ms=%.1f transitions=%d ignored=%d dropped=%d", + "ui_to_frame_ms=%.1f transitions=%d ignored=%d coalesced=%d", timestamp_us, self._latest_input_latency_ms, selected.input_transition_count, selected.input_ignored_event_count, - selected.input_dropped_transition_count, + selected.input_coalesced_transition_count, ) def set_loading_status(self, status: str) -> None: @@ -1032,12 +1036,12 @@ def _draw_input_diagnostic(self, imgui: Any) -> None: current = self._current latency = self._latest_input_latency_ms if current is None: - counts = "TRANSITIONS 0 IGNORED 0 DROPPED 0" + counts = "TRANSITIONS 0 IGNORED 0 COALESCED 0" else: counts = ( f"TRANSITIONS {current.input_transition_count} " f"IGNORED {current.input_ignored_event_count} " - f"DROPPED {current.input_dropped_transition_count}" + f"COALESCED {current.input_coalesced_transition_count}" ) latency_label = ( "UI TO MODEL FRAME --" @@ -1175,7 +1179,7 @@ def build_hud_frames( transition_timestamps_us: Sequence[int | None] | None = None, input_transition_count: int = 0, input_ignored_event_count: int = 0, - input_dropped_transition_count: int = 0, + input_coalesced_transition_count: int = 0, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) @@ -1202,7 +1206,7 @@ def build_hud_frames( transition_timestamp_us=transition_timestamps_us[index], input_transition_count=input_transition_count, input_ignored_event_count=input_ignored_event_count, - input_dropped_transition_count=input_dropped_transition_count, + input_coalesced_transition_count=input_coalesced_transition_count, ) ) return tuple(frames) diff --git a/apps/crazy_robotaxi/tests/test_layered_settings.py b/apps/crazy_robotaxi/tests/test_layered_settings.py index bb6144356..f48bd34ba 100644 --- a/apps/crazy_robotaxi/tests/test_layered_settings.py +++ b/apps/crazy_robotaxi/tests/test_layered_settings.py @@ -26,7 +26,6 @@ def test_shipped_example_configs_load() -> None: ) assert engine.world_model.model_preset == "standard" assert game.game.vehicle.max_steer_rad == pytest.approx(0.69) - assert game.driver_input.steering_rate_per_s == pytest.approx(3.5) def test_partial_game_config_retains_defaults_and_resolves_paths( diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 2a94a0fc9..bc07be759 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -35,6 +35,7 @@ from flashdreams.runtime_v2.presentation_manager import PresentationManager from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( + GamepadUserInputEvent, KeyboardInputState, KeyboardUserInputEvent, ) @@ -289,13 +290,13 @@ def test_hud_frames_preserve_frame_aligned_input_diagnostics() -> None: transition_timestamps_us=(100, 200), input_transition_count=4, input_ignored_event_count=2, - input_dropped_transition_count=1, + input_coalesced_transition_count=1, ) assert [frame.transition_timestamp_us for frame in frames] == [100, 200] assert frames[0].input_transition_count == 4 assert frames[0].input_ignored_event_count == 2 - assert frames[0].input_dropped_transition_count == 1 + assert frames[0].input_coalesced_transition_count == 1 def test_hud_frames_reject_misaligned_input_diagnostics() -> None: @@ -818,7 +819,7 @@ def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: transition_timestamps_us=(100,), input_transition_count=1, input_ignored_event_count=2, - input_dropped_transition_count=0, + input_coalesced_transition_count=0, ) ) @@ -838,6 +839,40 @@ def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: assert state._latest_input_latency_ms is None +def test_input_latency_profile_correlates_gamepad_state() -> None: + video = torch.zeros(1, 3, 96, 160) + state = TaxiHudState( + 160, + 96, + _calibration(), + profile_input_latency=True, + ) + state.consume_input_events( + UserInputEvents( + [ + GamepadUserInputEvent( + timestamp=np.uint64(200), + action="state", + axes=(0.25,), + ) + ] + ) + ) + state.publish( + build_hud_frames( + video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + transition_timestamps_us=(200,), + input_transition_count=1, + ) + ) + + state.select_presented_frame(video[0]) + + assert state._latest_input_latency_ms is not None + + def test_input_latency_window_is_absent_by_default() -> None: state = TaxiHudState(160, 96, _calibration()) imgui = _FakeImGui() diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/config.py b/apps/omnidreams_game_engine/omnidreams_game_engine/config.py index 2645614a4..05e6a5c29 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/config.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/config.py @@ -102,12 +102,3 @@ class VehicleConfig: aabb_length_m: float = 4.8 aabb_width_m: float = 2.0 aabb_height_m: float = 1.6 - - -@dataclass(frozen=True, slots=True) -class DriverInputConfig: - """Deterministic keyboard-to-command tuning.""" - - steering_scale: float = 0.75 - steering_rate_per_s: float = 0.6 - steering_return_rate_per_s: float = 1.4 diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index 72cac5ba4..7ed47d785 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -1,77 +1,55 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Deterministic V2 event reduction for model-thread driving.""" +"""Latest-state V2 input handling for model-thread driving.""" from __future__ import annotations -import logging -from dataclasses import dataclass +from dataclasses import dataclass, field -from flashdreams.runtime.keyboard import DRIVING_SUPPORTED_KEYS -from flashdreams.runtime_v2.input_timeline import RealtimeInputTimeline -from flashdreams.runtime_v2.keyboard_input import ( - KeyboardStateSegment, - KeyboardStateTrack, -) from flashdreams.runtime_v2.user_input_event import ( + GamepadUserInputEvent, + GameWheelUserInputEvent, + KeyboardInputState, KeyboardUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents -from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.types import DriverCommand -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class _DriveLevel: - """Resolved digital driving state at one input transition.""" - - forward: bool = False - backward: bool = False - left: bool = False - right: bool = False - handbrake: bool = False - @dataclass(frozen=True, slots=True) class DriverInputBatch: - """Per-frame driving commands for one model step.""" + """Latest driving command repeated across one model step.""" commands: tuple[DriverCommand, ...] + """Frame-aligned copies of the current driving command.""" transition_timestamps_us: tuple[int | None, ...] - """Input transition represented by each command; ``None`` for retained state.""" + """Newest state transition on the first frame; ``None`` otherwise.""" transition_count: int - """Number of driving-state transitions in the sampled input window.""" + """Number of active driving-state transitions applied from this event batch.""" - dropped_transition_count: int - """Number of sampled transitions not represented by an output command.""" + coalesced_transition_count: int + """Earlier transitions collapsed into the latest state for this model step.""" ignored_event_count: int - """Number of keyboard events unsupported by the shared driving track.""" + """Relevant input events that did not change the active driving state.""" +@dataclass(slots=True) class DriverInput: - """Sample shared V2 keyboard state into taxi driving commands.""" + """Current keyboard, gamepad, or wheel driving state for model chunks. - def __init__( - self, - config: DriverInputConfig = DriverInputConfig(), - *, - samples_per_second: float = 30.0, - ) -> None: - self.config = config - self._timeline = RealtimeInputTimeline( - samples_per_second=samples_per_second, - ) - self._keyboard = KeyboardStateTrack( - supported_keys=DRIVING_SUPPORTED_KEYS, - ) - self._last_level = _DriveLevel() - self._steering = 0.0 + Unread edges are applied in order, and the resulting command conditions the + whole next model chunk. + """ + + pressed_keys: set[str] = field(default_factory=set) + """Normalized keyboard driving keys currently held down.""" + + controller_command: DriverCommand | None = None + """Latest wheel or gamepad command; ``None`` enables keyboard input.""" def reduce( self, @@ -79,134 +57,134 @@ def reduce( *, frame_count: int, ) -> DriverInputBatch: - """Produce one command for each shared-timeline keyboard sample.""" + """Apply ``events`` and repeat the latest command for every model frame.""" if frame_count <= 0: raise ValueError("frame_count must be positive") - dispositions = self._keyboard.ingest(events) - window = self._timeline.next_window( - frame_count, - input_times_s=( - disposition.timestamp_s - for disposition in dispositions - if disposition.tracked - ), - ) - segments = self._keyboard.segments(window) - scheduled, transition_count, dropped_transition_count = ( - self._sample_drive_levels(segments, window.sample_times_s) - ) - ignored_event_count = sum( - isinstance(disposition.event, KeyboardUserInputEvent) - and not disposition.tracked - for disposition in dispositions + transition_count = 0 + ignored_event_count = 0 + latest_transition_timestamp_us: int | None = None + for event in events.get_events(): + before = self.command() + if not self._apply_event(event): + if isinstance( + event, + ( + KeyboardUserInputEvent, + GamepadUserInputEvent, + GameWheelUserInputEvent, + ), + ): + ignored_event_count += 1 + continue + if self.command() == before: + ignored_event_count += 1 + continue + transition_count += 1 + latest_transition_timestamp_us = int(event.get_timestamp()) + + command = self.command() + transition_timestamps_us = (latest_transition_timestamp_us,) + (None,) * ( + frame_count - 1 ) - if dropped_transition_count: - _LOGGER.warning( - "Dropped %d input transitions between model-frame samples in a " - "%d-frame chunk", - dropped_transition_count, - frame_count, - ) - - commands: list[DriverCommand] = [] - transition_timestamps_us: list[int | None] = [] - for level, timestamp_us in scheduled: - target = self.config.steering_scale * ( - float(level.left) - float(level.right) - ) - rate = ( - self.config.steering_rate_per_s - if target - else self.config.steering_return_rate_per_s - ) - self._steering = _move_towards( - self._steering, - target, - rate * self._timeline.sample_interval_s, - ) - commands.append( - DriverCommand( - throttle=1.0 if level.forward and not level.backward else 0.0, - brake=1.0 if level.backward or level.handbrake else 0.0, - steer=self._steering, - handbrake=level.handbrake, - steer_is_direct=True, - manual_control=True, - ) - ) - transition_timestamps_us.append(timestamp_us) return DriverInputBatch( - commands=tuple(commands), - transition_timestamps_us=tuple(transition_timestamps_us), + commands=(command,) * frame_count, + transition_timestamps_us=transition_timestamps_us, transition_count=transition_count, - dropped_transition_count=dropped_transition_count, + coalesced_transition_count=max(0, transition_count - 1), ignored_event_count=ignored_event_count, ) - def reset(self) -> None: - """Clear every retained input value.""" - self._timeline.reset() - self._keyboard.reset() - self._last_level = _DriveLevel() - self._steering = 0.0 - - def _sample_drive_levels( - self, - segments: list[KeyboardStateSegment], - sample_times_s: tuple[float, ...], - ) -> tuple[list[tuple[_DriveLevel, int | None]], int, int]: - """Project shared held-key segments onto discrete driving frames.""" - resolved_segments: list[tuple[float, float, _DriveLevel, int | None]] = [] - previous_level = self._last_level - transition_count = 0 - for start_s, end_s, keys in segments: - level = _drive_level(keys) - timestamp_us: int | None = None - if level != previous_level: - transition_count += 1 - timestamp_us = round(start_s * 1_000_000.0) - resolved_segments.append((start_s, end_s, level, timestamp_us)) - previous_level = level - - scheduled: list[tuple[_DriveLevel, int | None]] = [] - represented_timestamps: set[int] = set() - segment_index = 0 - current_timestamp_us: int | None = None - for sample_s in sample_times_s: - while ( - segment_index + 1 < len(resolved_segments) - and sample_s > resolved_segments[segment_index][1] - ): - introduced_timestamp_us = resolved_segments[segment_index][3] - if introduced_timestamp_us is not None: - current_timestamp_us = introduced_timestamp_us - represented_timestamps.add(introduced_timestamp_us) - segment_index += 1 - _, _, level, introduced_timestamp_us = resolved_segments[segment_index] - if introduced_timestamp_us is not None: - current_timestamp_us = introduced_timestamp_us - represented_timestamps.add(introduced_timestamp_us) - scheduled.append((level, current_timestamp_us)) - - self._last_level = previous_level - dropped_transition_count = max( - 0, - transition_count - len(represented_timestamps), - ) - return scheduled, transition_count, dropped_transition_count + def command(self) -> DriverCommand: + """Return the command represented by the current retained input state.""" + if self.controller_command is not None: + return self.controller_command + return _keyboard_command(self.pressed_keys) + def source(self) -> str: + """Return the currently active input source.""" + if self.controller_command is not None: + return "wheel/gamepad" + return "keyboard" if self.pressed_keys else "idle" -def _drive_level(keys: frozenset[str]) -> _DriveLevel: - return _DriveLevel( - forward=bool({"w", "up"} & keys), - backward=bool({"s", "down"} & keys), - left=bool({"a", "left"} & keys), - right=bool({"d", "right"} & keys), - handbrake="space" in keys, + def reset(self) -> None: + """Clear every retained input value.""" + self.pressed_keys.clear() + self.controller_command = None + + def _apply_event(self, event: object) -> bool: + """Apply one event using controller-over-keyboard precedence.""" + if isinstance(event, KeyboardUserInputEvent): + key = _normalize_drive_key(event.key) + if key is None: + return False + if event.state is KeyboardInputState.PRESSED: + self.pressed_keys.add(key) + else: + self.pressed_keys.discard(key) + return True + if isinstance(event, GameWheelUserInputEvent): + self.controller_command = ( + None + if event.action == "disconnected" + else DriverCommand( + throttle=event.throttle, + brake=event.brake, + steer=-event.steering, + steer_is_direct=True, + manual_control=True, + ) + ) + return True + if isinstance(event, GamepadUserInputEvent): + self.controller_command = _gamepad_command(event) + return True + return False + + +def _keyboard_command(pressed_keys: set[str]) -> DriverCommand: + """Map retained keyboard state to a simulation command.""" + forward = bool({"w", "up"} & pressed_keys) + reverse = bool({"s", "down"} & pressed_keys) + opposing_directions = forward and reverse + throttle = 1.0 if forward != reverse else 0.0 + steer = 0.0 + if {"a", "left"} & pressed_keys: + steer += 1.0 + if {"d", "right"} & pressed_keys: + steer -= 1.0 + return DriverCommand( + throttle=throttle, + brake=1.0 if opposing_directions else 0.0, + steer=steer, + stop="space" in pressed_keys, + reverse=reverse and not forward, ) -def _move_towards(current: float, target: float, maximum_delta: float) -> float: - if current < target: - return min(current + maximum_delta, target) - return max(current - maximum_delta, target) +def _normalize_drive_key(key: str) -> str | None: + key = key.strip().lower() + aliases = { + "arrowup": "w", + "arrowdown": "s", + "arrowleft": "a", + "arrowright": "d", + } + key = aliases.get(key, key) + return key if key in {"w", "a", "s", "d", "space"} else None + + +def _gamepad_command(event: GamepadUserInputEvent) -> DriverCommand | None: + if event.action == "disconnected": + return None + if event.action != "state": + return None + steer = -(event.axes[0] if event.axes else 0.0) + throttle = event.buttons[7] if len(event.buttons) > 7 else 0.0 + brake = event.buttons[6] if len(event.buttons) > 6 else 0.0 + return DriverCommand( + throttle=throttle, + brake=brake, + steer=steer, + steer_is_direct=True, + manual_control=True, + ) diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index 7442283dc..46a3a64ac 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -7,12 +7,12 @@ import numpy as np import pytest -from omnidreams_game_engine.config import DriverInputConfig from omnidreams_game_engine.input import DriverInput from flashdreams.api_v2.user_input_event import UserInputEvent from flashdreams.runtime_v2.user_input_event import ( - FocusUserInputEvent, + GamepadUserInputEvent, + GameWheelUserInputEvent, KeyboardInputState, KeyboardUserInputEvent, ) @@ -46,14 +46,7 @@ def _key(key: str, state: KeyboardInputState) -> KeyboardUserInputEvent: def test_held_keys_produce_one_command_per_model_frame() -> None: - reducer = DriverInput( - DriverInputConfig( - steering_scale=1.0, - steering_rate_per_s=2.0, - steering_return_rate_per_s=4.0, - ), - samples_per_second=10.0, - ) + reducer = DriverInput() first = reducer.reduce( _events( @@ -67,39 +60,40 @@ def test_held_keys_produce_one_command_per_model_frame() -> None: ) assert len(first.commands) == 3 - assert [command.steer for command in first.commands] == pytest.approx( - [0.2, 0.4, 0.6] - ) + assert all(command == first.commands[0] for command in first.commands) + assert first.commands[0].steer == 1.0 + assert not first.commands[0].steer_is_direct + assert not first.commands[0].manual_control assert all(command.throttle == 1.0 for command in first.commands) - assert retained.commands[0].steer == pytest.approx(0.8) - assert first.transition_timestamps_us == (1, 1, 1) + assert retained.commands[0] == first.commands[0] + assert first.transition_timestamps_us == (1, None, None) assert first.transition_count == 2 - assert first.dropped_transition_count == 0 + assert first.coalesced_transition_count == 1 assert retained.transition_timestamps_us == (None,) -def test_focus_loss_releases_drive_state() -> None: - reducer = DriverInput(samples_per_second=30.0) +def test_reverse_key_matches_interactive_drive_command() -> None: + reducer = DriverInput() reverse = reducer.reduce( _events(_key("ArrowDown", KeyboardInputState.PRESSED)), frame_count=1, ) - assert reverse.commands[0].brake == 1.0 - assert reverse.commands[0].throttle == 0.0 - assert not reverse.commands[0].reverse + assert reverse.commands[0].brake == 0.0 + assert reverse.commands[0].throttle == 1.0 + assert reverse.commands[0].reverse - result = reducer.reduce( - _events(FocusUserInputEvent(timestamp=np.uint64(0), focused=False)), + released = reducer.reduce( + _events(_key("ArrowDown", KeyboardInputState.RELEASED)), frame_count=1, ) - assert result.commands[0].throttle == 0.0 - assert not result.commands[0].reverse + assert released.commands[0].throttle == 0.0 + assert not released.commands[0].reverse -def test_short_press_and_release_are_preserved_in_the_next_chunk() -> None: - reducer = DriverInput(samples_per_second=20.0) +def test_short_press_and_release_collapse_to_latest_state() -> None: + reducer = DriverInput() result = reducer.reduce( _timed_events( @@ -109,26 +103,20 @@ def test_short_press_and_release_are_preserved_in_the_next_chunk() -> None: frame_count=5, ) - assert [command.throttle for command in result.commands] == [ - 1.0, - 1.0, - 0.0, - 0.0, - 0.0, - ] + assert [command.throttle for command in result.commands] == [0.0] * 5 assert result.transition_timestamps_us == ( - 1_000_000, - 1_000_000, - 1_100_000, - 1_100_000, 1_100_000, + None, + None, + None, + None, ) assert result.transition_count == 2 - assert result.dropped_transition_count == 0 + assert result.coalesced_transition_count == 1 def test_duplicate_key_events_do_not_create_transitions() -> None: - reducer = DriverInput(samples_per_second=30.0) + reducer = DriverInput() result = reducer.reduce( _timed_events( @@ -139,24 +127,24 @@ def test_duplicate_key_events_do_not_create_transitions() -> None: ) assert result.transition_count == 1 - assert result.transition_timestamps_us == (10, 10) - assert result.ignored_event_count == 0 + assert result.transition_timestamps_us == (10, None) + assert result.ignored_event_count == 1 -def test_browser_space_key_engages_the_handbrake() -> None: - reducer = DriverInput(samples_per_second=30.0) +def test_space_key_requests_the_interactive_drive_stop_command() -> None: + reducer = DriverInput() result = reducer.reduce( - _timed_events((10, _key(" ", KeyboardInputState.PRESSED))), + _timed_events((10, _key("space", KeyboardInputState.PRESSED))), frame_count=1, ) - assert result.commands[0].handbrake - assert result.commands[0].brake == 1.0 + assert result.commands[0].stop + assert not result.commands[0].handbrake -def test_transition_at_window_end_carries_into_the_next_chunk() -> None: - reducer = DriverInput(samples_per_second=30.0) +def test_latest_state_conditions_the_entire_next_chunk() -> None: + reducer = DriverInput() first = reducer.reduce( _timed_events( @@ -168,9 +156,75 @@ def test_transition_at_window_end_carries_into_the_next_chunk() -> None: ) retained = reducer.reduce(UserInputEvents([]), frame_count=1) - assert [command.throttle for command in first.commands] == [0.0, 0.0] - assert first.transition_timestamps_us == (50_000, 50_000) - assert first.transition_count == 2 - assert first.dropped_transition_count == 0 + assert [command.throttle for command in first.commands] == [1.0, 1.0] + assert first.transition_timestamps_us == (90_000, None) + assert first.transition_count == 3 + assert first.coalesced_transition_count == 2 assert retained.commands[0].throttle == 1.0 - assert retained.transition_timestamps_us == (90_000,) + assert retained.transition_timestamps_us == (None,) + + +def test_gamepad_state_overrides_keyboard_until_disconnect() -> None: + reducer = DriverInput() + buttons = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25, 0.75) + gamepad = GamepadUserInputEvent( + timestamp=np.uint64(20), + action="state", + axes=(-0.5,), + buttons=buttons, + ) + + controlled = reducer.reduce( + UserInputEvents([_key("w", KeyboardInputState.PRESSED), gamepad]), + frame_count=2, + ) + + assert controlled.commands[0].throttle == pytest.approx(0.75) + assert controlled.commands[0].brake == pytest.approx(0.25) + assert controlled.commands[0].steer == pytest.approx(0.5) + assert controlled.commands[0].steer_is_direct + assert controlled.commands[0].manual_control + assert controlled.commands[1] == controlled.commands[0] + assert reducer.source() == "wheel/gamepad" + + disconnected = reducer.reduce( + UserInputEvents( + [ + GamepadUserInputEvent( + timestamp=np.uint64(30), + action="disconnected", + ) + ] + ), + frame_count=1, + ) + + assert disconnected.commands[0].throttle == 1.0 + assert not disconnected.commands[0].manual_control + assert reducer.source() == "keyboard" + + +def test_wheel_state_uses_direct_pedal_and_steering_values() -> None: + reducer = DriverInput() + + result = reducer.reduce( + UserInputEvents( + [ + GameWheelUserInputEvent( + timestamp=np.uint64(40), + action="state", + steering=-0.4, + throttle=0.8, + brake=0.1, + ) + ] + ), + frame_count=2, + ) + + assert result.commands[0].steer == pytest.approx(0.4) + assert result.commands[0].throttle == pytest.approx(0.8) + assert result.commands[0].brake == pytest.approx(0.1) + assert result.commands[0].steer_is_direct + assert result.commands[0].manual_control + assert result.commands[1] == result.commands[0] diff --git a/docs/design/crazy_robotaxi_v2_input_latency.md b/docs/design/crazy_robotaxi_v2_input_latency.md index 2729044bd..df7d75fa7 100644 --- a/docs/design/crazy_robotaxi_v2_input_latency.md +++ b/docs/design/crazy_robotaxi_v2_input_latency.md @@ -2,9 +2,11 @@ ## Status -Crazy Robotaxi uses the V2 runtime's shared real-time input timeline and -keyboard-state track, and offers opt-in UI-to-model-frame diagnostics. The V2 -WebRTC sender is bounded and drops stale unsent frames under congestion. The +Crazy Robotaxi follows the V2 Interactive Drive demo's latest-state input +contract and offers opt-in UI-to-model-frame diagnostics. Keyboard, gamepad, +and wheel events accumulated during an in-flight model step are applied in +order, but only the resulting current command conditions the next chunk. The +V2 WebRTC sender is bounded and drops stale unsent frames under congestion. The remaining held-input latency is bounded by synchronous autoregressive model steps: input cannot change a chunk whose inference is already in flight. @@ -23,8 +25,10 @@ The investigation observed: - A held steering key took more than 1.5 seconds to affect displayed model video across model presets. -- A 100–200 ms tap was often absent before timestamped transition reduction - was added. +- A superseded timestamped reducer preserved 100–200 ms taps by replaying their + historical states into a future model chunk. That made stale input visible + after the user had already released or changed it and diverged from the + ported Interactive Drive implementation. - `--presentation-mode on_demand` did not materially improve the held steering delay, so repeated UI frames were not its dominant cause. - A supplied synchronized diagnostic run produced eight-frame chunks covering @@ -33,11 +37,13 @@ The investigation observed: stack rather than a universal model benchmark. V2 reads model events immediately before calling the synchronous model-loop -`step`. An input arriving during the measured 760 ms step therefore waits for -that step to finish and for the next conditioned step to finish. This creates -an application/model response window of roughly 760–1520 ms before final -WebRTC delivery. Smaller or preemptible model work is required to reduce that -floor. +`step`. Crazy Robotaxi applies the unread batch in order, retains the final +keyboard or controller state, and repeats its resulting command across the +next model chunk. An input arriving during the measured 760 ms step therefore +waits for that step to finish and for the next conditioned step to finish. This +creates an application/model response window of roughly 760–1520 ms before +final WebRTC delivery. Smaller or preemptible model work is required to reduce +that floor. ## V2 transport behavior @@ -64,6 +70,9 @@ in-flight conditioned chunk. after a stall. - Record browser-key-to-runtime-event and UI-write-to-browser-display latency; target p95 transport overhead below 100 ms on a local loopback after warmup. +- Verify keyboard, standard-gamepad, and game-wheel state all condition the + complete next chunk, and controller disconnect falls back to retained + keyboard state. - Report best, average, p90, and worst latency together with encoder backend, resolution, host, GPU, driver, and warmup policy. - Verify reset and disconnect discard stale media without replaying frames from From 9312923894eea9c7c609f53e13353d0f8c6c4868 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 22:32:16 -0700 Subject: [PATCH 38/62] Fix V2 block presentation performance Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 56 +++++++++++++++-- apps/crazy_robotaxi/tests/test_ui.py | 17 ++++- flashdreams/flashdreams/api_v2/README.md | 8 ++- .../runtime_v2/presentation_manager.py | 2 +- .../runtime_v2/slangpy_ui_renderer.py | 11 ++-- flashdreams/test_v2/test_session_runner.py | 63 ++++++++++++++++++- .../test_v2/test_slangpy_ui_renderer.py | 27 +++++++- 7 files changed, 167 insertions(+), 17 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 3e32668e6..85fccebcb 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -111,6 +111,7 @@ class TaxiHudFrame: input_coalesced_transition_count: int = 0 """Cumulative earlier transitions collapsed into the latest input state.""" + @dataclass(slots=True) class TaxiHudState: """Mutable Dear ImGui state owned exclusively by the V2 UI thread.""" @@ -172,6 +173,12 @@ class TaxiHudState: _bev_alpha: Tensor | None = None """Cached binary renderer coverage retained on the source device.""" + _bev_composite_source_key: tuple[object, ...] | None = None + """Identity and layout of the cached video/BEV back buffer.""" + + _bev_composite: Tensor | None = None + """Cached float32 back buffer for the current presented frame.""" + _bev_rect: tuple[int, int, int, int] | None = None """Current ImGui content rectangle as ``(top, left, height, width)``.""" @@ -605,6 +612,8 @@ def _clear_presented_game(self) -> None: self._bev_source_key = None self._bev_panel = None self._bev_alpha = None + self._bev_composite_source_key = None + self._bev_composite = None self._bev_rect = None self._presented_frame_times_s.clear() self._video_fps = 0.0 @@ -964,10 +973,42 @@ def points(scale: float) -> tuple[Any, Any, Any]: draw_list.add_triangle_filled(*points(0.68), color) def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: - """Composite the current BEV into its ImGui-owned rectangle on-device.""" + """Return the cached float32 video and BEV back buffer.""" + if not video.is_floating_point(): + raise ValueError("Video presentation frames must be floating point") rect = self._bev_rect + frame_key = ( + None + if frame is None + else ( + int(frame.data_ptr()), + tuple(int(value) for value in frame.shape), + frame.dtype, + frame.device, + ) + ) + composite_source_key = ( + id(self._current), + int(video.data_ptr()), + tuple(int(value) for value in video.shape), + video.dtype, + video.device, + frame_key, + rect, + ) + if ( + composite_source_key == self._bev_composite_source_key + and self._bev_composite is not None + ): + return self._bev_composite + + # The shared ImGui overlay is float32. Converting once here avoids a + # full-frame overlay cast and extra BF16 blend kernels downstream. + output = video.to(dtype=torch.float32, copy=True) if frame is None or rect is None: - return video + self._bev_composite_source_key = composite_source_key + self._bev_composite = output + return output if frame.ndim != 3 or frame.shape[0] != 4: raise ValueError("BEV presentation frames must use [4,H,W] RGBA") if frame.dtype != torch.uint8 and not frame.is_floating_point(): @@ -979,7 +1020,10 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: bottom = min(int(video.shape[-2]), top + image_height) right = min(int(video.shape[-1]), left + image_width) if bottom <= top or right <= left: - return video + self._bev_composite_source_key = composite_source_key + self._bev_composite = output + return output + source_key = ( id(self._current), int(frame.data_ptr()), @@ -1012,11 +1056,12 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: self._bev_panel = panel self._bev_alpha = alpha - output = video.clone() target = output[:, top:bottom, left:right] source_panel = panel[:, : bottom - top, : right - left] source_alpha = alpha[:, : bottom - top, : right - left] torch.where(source_alpha, source_panel, target, out=target) + self._bev_composite_source_key = composite_source_key + self._bev_composite = output return output def _draw_input_diagnostic(self, imgui: Any) -> None: @@ -1162,8 +1207,7 @@ def step_ui( self.state.draw(imgui, step_index, bev_frame=bev_frame) if video is None: return None - converted = video.to(torch.float32) - return self.state.composite_bev(converted, bev_frame) + return self.state.composite_bev(video, bev_frame) def reset(self) -> None: """Reset UI-owned state and retained renderer resources.""" diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index bc07be759..9e324fa58 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -421,7 +421,7 @@ def test_fps_counter_measures_distinct_generated_video_frames( def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: width, height = 160, 96 - video = torch.full((1, 3, height, width), -0.5) + video = torch.full((1, 3, height, width), -0.5, dtype=torch.bfloat16) bev = torch.full((1, 4, 32, 32), 255, dtype=torch.uint8) bev[:, :3].fill_(191) hud_state = TaxiHudState(width, height, _calibration()) @@ -486,14 +486,17 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: cached_waypoints = hud_state._waypoint_projections cached_bev = hud_state._bev_panel + cached_composite = hud_state._bev_composite loop.step(1, UserInputEvents([])) assert hud_state._waypoint_projections is cached_waypoints assert hud_state._bev_panel is cached_bev + assert hud_state._bev_composite is cached_composite loop.reset() assert hud_state._current is None assert hud_state._waypoint_projections == () assert hud_state._bev_panel is None + assert hud_state._bev_composite is None assert hud_state._bev_rect is None assert renderer.reset_count == 1 @@ -513,6 +516,18 @@ def test_bev_compositor_uses_rgba_coverage_for_black_road_pixels() -> None: assert set(state._bev_alpha.unique().tolist()) == {False, True} +def test_presentation_back_buffer_is_cached_without_a_bev_frame() -> None: + state = TaxiHudState(4, 4, _calibration()) + video = torch.full((3, 4, 4), -0.5, dtype=torch.bfloat16) + + first = state.composite_bev(video, None) + repeated = state.composite_bev(video, None) + + assert first.dtype is torch.float32 + assert repeated is first + torch.testing.assert_close(first, video.float()) + + def test_bev_draws_edge_arrow_for_an_offscreen_dropoff() -> None: video = torch.zeros(1, 3, 96, 160) snapshot = replace( diff --git a/flashdreams/flashdreams/api_v2/README.md b/flashdreams/flashdreams/api_v2/README.md index dee05ac8b..dd0704b4c 100644 --- a/flashdreams/flashdreams/api_v2/README.md +++ b/flashdreams/flashdreams/api_v2/README.md @@ -64,9 +64,11 @@ comes from the session description: the model loop steps at `frames_per_second_for_step`, and the UI ticks at `frames_per_second_for_ui`. The UI thread initially selects frames from model chunks at -`frames_per_second_for_step`, then uses the model thread's rolling two-second -output rate. This paces chunked output evenly without tying input and UI redraws -to model throughput. +`frames_per_second_for_step`. With nonblocking `DROP_OLDEST` backpressure it +then uses the model thread's rolling two-second output rate. `BLOCK` retains the +declared rate because its own queue waits would otherwise feed back into and +progressively reduce the measured model rate. This paces chunked output evenly +without tying input and UI redraws to model throughput. `PresentationMode.CONTINUOUS` lets an `IUILoop` redraw every UI tick; `PresentationMode.ON_DEMAND` runs it only when the selected model frame changes. Interactive or clock-driven UIs should use continuous presentation. diff --git a/flashdreams/flashdreams/runtime_v2/presentation_manager.py b/flashdreams/flashdreams/runtime_v2/presentation_manager.py index 51f6689de..61eccedaf 100644 --- a/flashdreams/flashdreams/runtime_v2/presentation_manager.py +++ b/flashdreams/flashdreams/runtime_v2/presentation_manager.py @@ -470,7 +470,7 @@ def _composite_frame(bottom: Tensor | None, top: Tensor) -> Tensor: bottom = torch.full_like(color, fill_value) alpha = top[3:4].to(device=bottom.device, dtype=torch.float32) alpha = alpha.clamp(0.0, 1.0).to(bottom.dtype) - return color * alpha + bottom * (1.0 - alpha) + return torch.lerp(bottom, color, alpha) __all__ = ["PresentationManager"] diff --git a/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py b/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py index 10603ad86..bdfb57397 100644 --- a/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py +++ b/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py @@ -282,10 +282,13 @@ def _route_input_events( def _rgba8_to_compositing_frame(frame: Tensor) -> Tensor: """Convert shared ``[H, W, 4]`` bytes into normalized ``[4, H, W]``.""" - rgba = frame.permute(2, 0, 1).to(torch.float32) - color = rgba[:3].mul_(2.0 / 255.0).sub_(1.0) - alpha = rgba[3:4].mul_(1.0 / 255.0) - return torch.cat((color, alpha), dim=0) + rgba = frame.permute(2, 0, 1).to( + torch.float32, + memory_format=torch.contiguous_format, + ) + rgba[:3].mul_(2.0 / 255.0).sub_(1.0) + rgba[3:4].mul_(1.0 / 255.0) + return rgba def _resolve_slangpy_key(slangpy: Any, key: str) -> Any | None: diff --git a/flashdreams/test_v2/test_session_runner.py b/flashdreams/test_v2/test_session_runner.py index a8909858e..8486f194f 100644 --- a/flashdreams/test_v2/test_session_runner.py +++ b/flashdreams/test_v2/test_session_runner.py @@ -26,7 +26,11 @@ PresentationMode, SessionDesc, ) -from flashdreams.runtime_v2.session_runner import _PresentationClock, run_session +from flashdreams.runtime_v2.session_runner import ( + _PresentationClock, + _next_ui_tick_at, + run_session, +) from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( CloseUserInputEvent, @@ -119,6 +123,26 @@ def test_presentation_clock_clamps_model_fps_to_ui_fps() -> None: assert clock.frames_per_second == 60 +def test_presentation_clock_does_not_adapt_to_blocked_model_throughput() -> None: + clock = _PresentationClock( + frames_per_second=30, + adapt_to_model_throughput=False, + ) + + clock.observe_model_output(now=1.0, generation=0, frame_count=8) + clock.observe_model_output(now=2.0, generation=0, frame_count=8) + + assert clock.frames_per_second == 30 + + +def test_next_ui_tick_does_not_sleep_after_an_overrun() -> None: + assert _next_ui_tick_at(1.0, 0.1, 1.25) == 1.25 + + +def test_next_ui_tick_preserves_deadline_without_an_overrun() -> None: + assert _next_ui_tick_at(1.0, 0.1, 1.05) == 1.1 + + def test_presentation_clock_limits_estimate_to_recent_two_seconds() -> None: clock = _PresentationClock(frames_per_second=30) @@ -672,6 +696,17 @@ def test_composite_rejects_frames_with_different_dimensions() -> None: manager.composite(bottom, overlay) +def test_composite_clamps_alpha_before_interpolation() -> None: + manager = PresentationManager() + bottom = torch.full((3, 1, 2), -1.0) + overlay = torch.tensor([[[1.0, 1.0]], [[0.5, 0.5]], [[0.0, 0.0]], [[-0.5, 1.5]]]) + + composited = manager.composite(bottom, overlay) + + assert torch.equal(composited[:, :, 0], bottom[:, :, 0]) + assert torch.equal(composited[:, :, 1], overlay[:3, :, 1]) + + def test_default_ui_presents_each_frame_from_a_model_chunk() -> None: log = CallLog() @@ -798,6 +833,32 @@ def test_run_session_opens_window_with_the_resolved_session_desc() -> None: assert window.session_desc is resolved +def test_window_write_stays_in_the_presentation_context() -> None: + log = CallLog() + session = FakeSession(_session_desc(), log) + + class ContextRecordingManager(PresentationManager): + active = False + + @contextmanager + def presentation_context(self) -> Iterator[None]: + self.active = True + try: + yield + finally: + self.active = False + + manager = ContextRecordingManager() + session.__dict__["_presentation_manager"] = manager + + class ContextCheckingWindow(RecordingClientWindow): + def write(self, result: StepResult) -> None: + assert manager.active + super().write(result) + + run_session(session, ContextCheckingWindow(log), steps=1) + + def test_run_session_gives_the_first_step_input_already_collected() -> None: log = CallLog() session = FakeSession(_session_desc(), log) diff --git a/flashdreams/test_v2/test_slangpy_ui_renderer.py b/flashdreams/test_v2/test_slangpy_ui_renderer.py index 67e9bdc8c..c929614ad 100644 --- a/flashdreams/test_v2/test_slangpy_ui_renderer.py +++ b/flashdreams/test_v2/test_slangpy_ui_renderer.py @@ -7,9 +7,13 @@ from unittest.mock import Mock import pytest +import torch from numpy import uint64 -from flashdreams.runtime_v2.slangpy_ui_renderer import _route_input_events +from flashdreams.runtime_v2.slangpy_ui_renderer import ( + _rgba8_to_compositing_frame, + _route_input_events, +) from flashdreams.runtime_v2.user_input_event import ( KeyboardInputState, KeyboardUserInputEvent, @@ -20,6 +24,27 @@ pytestmark = pytest.mark.ci_cpu +def test_rgba_conversion_normalizes_directly_into_contiguous_chw() -> None: + frame = torch.tensor( + [[[0, 127, 255, 0], [255, 0, 127, 255]]], + dtype=torch.uint8, + ) + + converted = _rgba8_to_compositing_frame(frame) + + expected = torch.tensor( + [ + [[-1.0, 1.0]], + [[127.0 * 2.0 / 255.0 - 1.0, -1.0]], + [[1.0, 127.0 * 2.0 / 255.0 - 1.0]], + [[0.0, 1.0]], + ], + dtype=torch.float32, + ) + assert converted.is_contiguous() + torch.testing.assert_close(converted, expected) + + def test_space_key_routes_a_text_input_codepoint() -> None: ui_context = Mock() slangpy = SimpleNamespace( From 5f61f6584f176a728efe5820d36587fb0391007d Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 22:32:21 -0700 Subject: [PATCH 39/62] Improve Crazy Robotaxi input responsiveness Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 2 +- .../crazy_robotaxi/application.py | 23 ++--- apps/crazy_robotaxi/crazy_robotaxi/session.py | 28 +++--- apps/crazy_robotaxi/tests/test_application.py | 85 ++++++++++++++++--- 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 753bb95eb..9bf93ce12 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -45,7 +45,7 @@ discovers bundled maps and `.robotaxi.yaml` maps beside that configured file. Map compilation and scene loading begin only after the menu choice. Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application -options. Drive with W/A/S/D or the arrow keys; Space stops the vehicle. A +options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. A standard gamepad uses the left stick for steering and the right/left triggers for throttle/brake. V2 game-wheel events use their normalized steering, throttle, and brake values directly. A connected controller takes precedence diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 50fb2552e..153121235 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -52,13 +52,16 @@ from flashdreams.api_v2.application import IApplication from flashdreams.api_v2.session import ISession from flashdreams.infra.config import derive_config -from flashdreams.runtime_v2.session_desc import SessionDesc +from flashdreams.runtime_v2.session_desc import PresentationMode, SessionDesc from flashdreams.runtime_v2.video_tensor import VideoTensorLayout _ROOT = Path(__file__).resolve().parent _DEFAULT_MAP = _ROOT / "maps" / "boulevard_district.robotaxi.yaml" _VIDEO_FPS = 30 -"""Generation and UI cadence; each UI tick advances one generated frame.""" +"""Generated-video cadence required by the model.""" + +_UI_FPS = 60 +"""Input polling and HUD cadence used by Interactive Drive.""" _LOGGER = logging.getLogger(__name__) @@ -188,7 +191,7 @@ def session_desc(self) -> SessionDesc: ) return SessionDesc( output_layout=VideoTensorLayout.tchw, - frames_per_second_for_ui=_VIDEO_FPS, + frames_per_second_for_ui=_UI_FPS, frames_per_second_for_step=_VIDEO_FPS, video_width=raster.width, video_height=raster.height, @@ -406,13 +409,8 @@ def create_session(self, session_desc: SessionDesc) -> ISession: raise RuntimeError("init() must run before create_session()") if session_desc.output_layout is not VideoTensorLayout.tchw: raise ValueError("Crazy Robotaxi produces tchw output") - if ( - session_desc.frames_per_second_for_ui != _VIDEO_FPS - or session_desc.frames_per_second_for_step != _VIDEO_FPS - ): - raise ValueError( - "Crazy Robotaxi generates and presents video at 30 frames per second" - ) + if session_desc.frames_per_second_for_step != _VIDEO_FPS: + raise ValueError("Crazy Robotaxi generates video at 30 frames per second") actual = session_desc.video_width, session_desc.video_height if config.renderer_follows_session: config = replace( @@ -471,7 +469,10 @@ def create_session(self, session_desc: SessionDesc) -> ISession: scene_factory=self._scene_factory, map_options=self._map_options, config=config, - session_desc=session_desc, + session_desc=replace( + session_desc, + presentation_mode=PresentationMode.CONTINUOUS, + ), ) def close(self) -> None: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index e167c4ccb..893649435 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -243,7 +243,6 @@ def _set_loading_status(self, status: str) -> None: ) def reset(self) -> None: - self.driver_input.reset() self.blocks_generated = 0 self.finished = False self.realtime_miss_count = 0 @@ -273,6 +272,9 @@ class CrazyRobotaxiModelLoop(IModelLoop[ModelState]): def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: state = self.state + # Match Interactive Drive: apply every unread edge before rollout setup, + # reset handling, or simulation reads the retained command. + input_batch = state.driver_input.reduce(events, frame_count=1) if not state.game_selected: return state.menu_result(step_index) rollout = state.ensure_rollout() @@ -296,18 +298,15 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: if live_edit.style is not None: live_edit.style.before_v2_chunk() frame_count = rollout.frame_count(state.blocks_generated) - input_batch = state.driver_input.reduce( - events, - frame_count=frame_count, - ) state.input_transition_count += input_batch.transition_count state.input_ignored_event_count += input_batch.ignored_event_count state.input_coalesced_transition_count += ( input_batch.coalesced_transition_count ) + command = _taxi_driver_command(state.driver_input) generated = rollout.step( autoregressive_index=state.blocks_generated, - commands=input_batch.commands, + commands=(command,) * frame_count, ) if live_edit is not None and live_edit.style is not None: live_edit.style.after_v2_chunk() @@ -337,7 +336,9 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: ), } ) - transition_timestamps_us = input_batch.transition_timestamps_us + transition_timestamps_us = input_batch.transition_timestamps_us + ( + None, + ) * (int(video.shape[0]) - 1) if state.blocks_generated == 1 and state.prewarm_wall_ms > 0.0: metrics["startup_prewarm_wall_ms"] = state.prewarm_wall_ms metrics["startup_prewarm_blocks"] = state.config.prewarm_blocks @@ -361,9 +362,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: transition_timestamps_us=transition_timestamps_us, input_transition_count=state.input_transition_count, input_ignored_event_count=state.input_ignored_event_count, - input_coalesced_transition_count=( - state.input_coalesced_transition_count - ), + input_coalesced_transition_count=(state.input_coalesced_transition_count), ) invoke_async( state.ui_loop, @@ -515,6 +514,7 @@ def init(self) -> None: hud_state.model_loop = model_loop hud_state.initialize_selection() + def _restart_requested(events: UserInputEvents) -> bool: """Return whether this model step received a pressed R key.""" return any( @@ -523,3 +523,11 @@ def _restart_requested(events: UserInputEvents) -> bool: and str(event.key).strip().lower() == "r" for event in events.get_events() ) + + +def _taxi_driver_command(driver_input: DriverInput) -> DriverCommand: + """Restore Crazy Robotaxi's keyboard handbrake over shared drive input.""" + command = driver_input.command() + if "space" not in driver_input.pressed_keys: + return command + return replace(command, throttle=0.0, stop=False, handbrake=True) diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 3dabffef4..3cdc9ea1b 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -26,6 +26,7 @@ CrazyRobotaxiSession, ModelState, _restart_requested, + _taxi_driver_command, ) from crazy_robotaxi.ui import CrazyRobotaxiImGuiUILoop from omnidreams.config import ( @@ -33,6 +34,7 @@ OMNIDREAMS_PIPELINE_CONFIG, ) from omnidreams_game_engine.config import BevConfig, RasterConfig +from omnidreams_game_engine.input import DriverInput from omnidreams_game_engine.renderer_settings import RendererSettings from omnidreams_game_engine.simulation.game_physics import GamePhysicsWorld from omnidreams_game_engine.types import ( @@ -44,6 +46,7 @@ from flashdreams.runtime_v2.native_window_client_window import ( NativeWindowClientWindow, ) +from flashdreams.runtime_v2.session_desc import PresentationMode from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( KeyboardInputState, @@ -115,8 +118,9 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: ui_loop, model_loop = session._take_loops() assert desc.output_layout is VideoTensorLayout.tchw - assert desc.frames_per_second_for_ui == 30 + assert desc.frames_per_second_for_ui == 60 assert desc.frames_per_second_for_step == 30 + assert session.session_desc.presentation_mode is PresentationMode.CONTINUOUS assert isinstance(model_loop, CrazyRobotaxiModelLoop) assert isinstance(ui_loop, CrazyRobotaxiImGuiUILoop) assert session._presentation_manager._presentation_stream is None @@ -272,6 +276,53 @@ def test_pressed_r_requests_a_v2_game_restart() -> None: assert not _restart_requested(UserInputEvents([released])) +def test_model_input_is_applied_before_rollout_work() -> None: + class InputOrderVerified(Exception): + pass + + class ProbeState: + game_selected = True + + def __init__(self) -> None: + self.driver_input = DriverInput() + + def ensure_rollout(self) -> None: + assert self.driver_input.command().throttle == 1.0 + raise InputOrderVerified + + pressed = KeyboardUserInputEvent( + timestamp=np.uint64(1), + key="w", + state=KeyboardInputState.PRESSED, + ) + loop = CrazyRobotaxiModelLoop() + loop.state = cast(Any, ProbeState()) + + with pytest.raises(InputOrderVerified): + loop.step(0, UserInputEvents([pressed])) + + +def test_taxi_space_key_restores_handbrake_over_shared_input_mapping() -> None: + driver_input = DriverInput() + driver_input.reduce( + UserInputEvents( + [ + KeyboardUserInputEvent( + timestamp=np.uint64(1), + key="space", + state=KeyboardInputState.PRESSED, + ) + ] + ), + frame_count=1, + ) + + command = _taxi_driver_command(driver_input) + + assert command.handbrake + assert not command.stop + + def test_leaderboard_does_not_finish_the_v2_model_loop() -> None: snapshot = TaxiGameSnapshot( phase="seeking_pickup", @@ -314,7 +365,7 @@ def _invoke_async(self, operation) -> None: video_width=4, ), ), - driver_input=cast(Any, object()), + driver_input=DriverInput(), ui_loop=cast(Any, ui_loop), rollout=cast(Any, rollout), last_video=torch.zeros(1, 3, 4, 4), @@ -649,16 +700,7 @@ def test_application_rejects_geometry_the_model_does_not_produce() -> None: app.create_session(desc) -@pytest.mark.parametrize( - "override", - [ - {"frames_per_second_for_step": 60}, - {"frames_per_second_for_ui": 60}, - ], -) -def test_application_rejects_mismatched_generation_or_ui_rate( - override: dict[str, int], -) -> None: +def test_application_rejects_mismatched_generation_rate() -> None: app = CrazyRobotaxiApplication( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), @@ -666,4 +708,21 @@ def test_application_rejects_mismatched_generation_or_ui_rate( app.init([]) with pytest.raises(ValueError, match="30 frames per second"): - app.create_session(replace(app.session_desc(), **override)) + app.create_session(replace(app.session_desc(), frames_per_second_for_step=60)) + + +def test_application_forces_continuous_presentation_for_interactive_input() -> None: + app = CrazyRobotaxiApplication( + pipeline_factory=lambda config, device: object(), + scene_factory=lambda request, raster: _scene(), + ) + app.init([]) + + session = app.create_session( + replace( + app.session_desc(), + presentation_mode=PresentationMode.ON_DEMAND, + ) + ) + + assert session.session_desc.presentation_mode is PresentationMode.CONTINUOUS From 3bc0c12597e290d82e7155bb9003161626e1abcf Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 23:35:06 -0700 Subject: [PATCH 40/62] Fix Crazy Robotaxi live-edit presentation Signed-off-by: Aidan Foster --- .../live_edit/obstacle_events.py | 7 +- .../crazy_robotaxi/live_edit/runtime_v2.py | 63 ++++++--- .../crazy_robotaxi/tests/test_live_edit_v2.py | 122 +++++++++++++++++- 3 files changed, 175 insertions(+), 17 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py index b207ce69a..ef2716a53 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py @@ -751,7 +751,12 @@ def advance_frames( if not self._config.physics: self._check_visual_collision(event, trajectory) actors.append(event.actor()) - track_exhausted = last_timestamp_us >= int(event.timestamps_us[-1]) + track_exhausted = ( + event.logical_timestamp_us + >= float(event.scene_object.timestamps_us[-1]) + if event.scene_object is not None + else last_timestamp_us >= int(event.timestamps_us[-1]) + ) if not event.static and ( event.chunks >= self._config.active_chunks or track_exhausted ): diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py index 3a61d5e31..840d325ea 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py @@ -6,6 +6,7 @@ from __future__ import annotations from dataclasses import replace +from pathlib import Path from typing import Any import numpy as np @@ -17,7 +18,7 @@ from torch import Tensor from crazy_robotaxi.live_edit.coin_ability import CoinAbility -from crazy_robotaxi.live_edit.config import LiveEditConfig +from crazy_robotaxi.live_edit.config import ITEM_TYPES, LiveEditConfig from crazy_robotaxi.live_edit.gpu_compositor import LiveEditFrameCompositor from crazy_robotaxi.live_edit.item_ability import ItemAbility, ItemEffects from crazy_robotaxi.live_edit.nitro_ability import NitroAbility @@ -35,21 +36,48 @@ from flashdreams.runtime_v2.user_input_events import UserInputEvents -def _procedural_sprite(label: str, fill: tuple[int, int, int]) -> Image.Image: +def _procedural_coin_sprite() -> Image.Image: image = Image.new("RGBA", (96, 96), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) - draw.rounded_rectangle( - (8, 8, 88, 88), radius=18, fill=(*fill, 255), outline=(20, 20, 20, 255), width=5 + draw.ellipse( + (8, 8, 88, 88), + fill=(250, 200, 40, 255), + outline=(170, 120, 10, 255), + width=6, ) + draw.ellipse((24, 24, 72, 72), outline=(170, 120, 10, 255), width=4) + return image + + +_ITEM_PLACEHOLDERS = { + "rain": ((70, 130, 240, 255), (20, 60, 160, 255), "R"), + "snow": ((235, 245, 255, 255), (120, 160, 210, 255), "S"), + "mystery": ((250, 170, 40, 255), (160, 90, 10, 255), "?"), + "nitro": ((90, 225, 110, 255), (20, 120, 40, 255), "N"), +} + + +def _procedural_item_sprite(item_type: str) -> Image.Image: + fill, rim, label = _ITEM_PLACEHOLDERS[item_type] + image = Image.new("RGBA", (96, 96), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((9, 9, 87, 87), radius=16, fill=fill, outline=rim, width=6) box = draw.textbbox((0, 0), label) draw.text( - ((96 - box[2]) / 2, (96 - box[3]) / 2), + ((96 - (box[2] - box[0])) / 2, (96 - (box[3] - box[1])) / 2 - box[1]), label, - fill=(20, 20, 20, 255), + fill=rim, ) return image +def _load_sprite(path: Path | None, fallback: Image.Image) -> Image.Image: + if path is None: + return fallback + with Image.open(path) as image: + return image.convert("RGBA") + + class LiveEditGameplay: """Own all flag-gated CPU gameplay and presentation state.""" @@ -98,19 +126,24 @@ def __init__( if config.obstacle.enabled and config.obstacle.guide_scale > 0.0 else None ) - sprites = { - "rain": _procedural_sprite("R", (70, 130, 240)), - "snow": _procedural_sprite("S", (235, 245, 255)), - "mystery": _procedural_sprite("?", (250, 170, 40)), - "nitro": _procedural_sprite("N", (90, 225, 110)), - } + sprites = ( + { + item_type: _load_sprite( + config.items.sprite_path(item_type), + _procedural_item_sprite(item_type), + ) + for item_type in ITEM_TYPES + } + if config.items.enabled + else {} + ) self._compositor = LiveEditFrameCompositor( - _procedural_sprite("$", (250, 200, 40)), sprites + _load_sprite(config.coins.sprite_path, _procedural_coin_sprite()), sprites ) self._camera = FThetaCameraModel( scene.selected_camera, - output_width=scene.selected_camera.width, - output_height=scene.selected_camera.height, + output_width=scene.initial_rgb.shape[1], + output_height=scene.initial_rgb.shape[0], ) self._frame_index = 0 diff --git a/apps/crazy_robotaxi/tests/test_live_edit_v2.py b/apps/crazy_robotaxi/tests/test_live_edit_v2.py index 1ff5a7916..4fe3542fd 100644 --- a/apps/crazy_robotaxi/tests/test_live_edit_v2.py +++ b/apps/crazy_robotaxi/tests/test_live_edit_v2.py @@ -5,18 +5,37 @@ from __future__ import annotations +from types import SimpleNamespace + import numpy as np import pytest -from crazy_robotaxi.live_edit.config import LiveEditItemsConfig +from crazy_robotaxi.live_edit.config import ( + LiveEditCoinsConfig, + LiveEditConfig, + LiveEditItemsConfig, + LiveEditObstacleConfig, +) from crazy_robotaxi.live_edit.nitro_ability import NitroAbility +from crazy_robotaxi.live_edit.obstacle_events import ( + ObstacleAbility, + ObstacleEvent, + ObstaclePhase, +) from crazy_robotaxi.live_edit.obstacle_templates import load_obstacle_template_catalog from crazy_robotaxi.live_edit.runtime_v2 import LiveEditGameplay +from crazy_robotaxi.navigation import NavigationLane from flashdreams.runtime_v2.user_input_event import ( KeyboardInputState, KeyboardUserInputEvent, ) from flashdreams.runtime_v2.user_input_events import UserInputEvents from omnidreams_game_engine.config import VehicleConfig +from omnidreams_game_engine.types import ( + CameraCalibration, + TrajectoryChunk, + VehicleState, +) +from PIL import Image pytestmark = pytest.mark.ci_cpu @@ -50,6 +69,25 @@ def request_spawn(self) -> None: self.spawns += 1 +def _scene() -> SimpleNamespace: + calibration = CameraCalibration( + clipgt_name="camera_front_wide_120fov", + logical_name="camera_front_wide_120fov", + width=3848, + height=2168, + cx=1924.0, + cy=1084.0, + polynomial=np.asarray([0.0, 1.0], dtype=np.float32), + is_backward_polynomial=False, + linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), + sensor_to_rig_flu=np.eye(4, dtype=np.float32), + ) + return SimpleNamespace( + selected_camera=calibration, + initial_rgb=np.zeros((640, 1168, 3), dtype=np.uint8), + ) + + def test_v2_ability_keys_are_consumed_on_pressed_edges() -> None: gameplay = LiveEditGameplay.__new__(LiveEditGameplay) gameplay.style = _StyleRequests() @@ -101,6 +139,88 @@ def test_nitro_boosts_and_expires_on_game_time() -> None: assert not nitro.active +def test_v2_live_edit_camera_uses_generated_frame_size() -> None: + gameplay = LiveEditGameplay(LiveEditConfig(), _scene(), (), vehicle=VehicleConfig()) + + assert (gameplay._camera.output_width, gameplay._camera.output_height) == ( + 1168, + 640, + ) + assert gameplay._compositor.sprite_image("coin").getpixel((15, 15))[3] == 0 + + +def test_v2_live_edit_loads_configured_sprites(tmp_path) -> None: + coin_path = tmp_path / "coin.png" + nitro_path = tmp_path / "nitro.png" + Image.new("RGBA", (4, 4), (10, 20, 30, 255)).save(coin_path) + Image.new("RGBA", (4, 4), (40, 50, 60, 255)).save(nitro_path) + config = LiveEditConfig( + coins=LiveEditCoinsConfig(enabled=True, sprite_path=coin_path), + items=LiveEditItemsConfig( + enabled=True, + item_types=("nitro",), + nitro_sprite_path=nitro_path, + ), + ) + lane = NavigationLane( + np.asarray([[0.0, 0.0, 0.0], [100.0, 0.0, 0.0]], dtype=np.float32) + ) + + gameplay = LiveEditGameplay(config, _scene(), (lane,), vehicle=VehicleConfig()) + + assert gameplay._compositor.sprite_image("coin").getpixel((0, 0)) == ( + 10, + 20, + 30, + 255, + ) + assert gameplay._compositor.sprite_image("nitro").getpixel((0, 0)) == ( + 40, + 50, + 60, + 255, + ) + + +def test_physical_obstacle_lifetime_uses_relative_track_clock() -> None: + relative_timestamps = np.asarray([0, 4_000_000], dtype=np.int64) + event = ObstacleEvent( + entity_id="live-edit-obstacle-test", + object_type="Car", + timestamps_us=relative_timestamps, + translations_world=np.zeros((2, 3), dtype=np.float32), + orientations_xyzw=np.tile( + np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float32), (2, 1) + ), + dimensions_lwh=np.asarray([4.0, 2.0, 1.5], dtype=np.float32), + template_index=0, + drive_speed_mps=4.0, + scene_object=SimpleNamespace(timestamps_us=relative_timestamps), + ) + obstacle = ObstacleAbility.__new__(ObstacleAbility) + obstacle._config = LiveEditObstacleConfig( + enabled=True, physics=True, active_chunks=10 + ) + obstacle._events = [event] + obstacle._chunk_index = 0 + state = VehicleState(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + trajectory = TrajectoryChunk( + timestamps_us=np.asarray([1_000_000_000], dtype=np.int64), + rig_poses_world=np.eye(4, dtype=np.float32)[None], + vehicle_states=(state,), + boundary_state_after_chunk=state, + ) + + obstacle.advance_frames(trajectory) + + assert event.phase is ObstaclePhase.SCRIPTED + + event.logical_timestamp_us = 4_000_000.0 + obstacle.advance_frames(trajectory) + + assert event.phase is ObstaclePhase.EXPIRED + + def test_bundled_obstacle_catalog_matches_source_branch() -> None: catalog = load_obstacle_template_catalog() From 8f38f6fad03cba5553acf796c84dda86ba0fa20e Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 23:47:50 -0700 Subject: [PATCH 41/62] Fix native text input duplication Signed-off-by: Aidan Foster --- .../runtime_v2/native_window_client_window.py | 38 +++++---- .../test_native_window_client_window.py | 77 ++++++++++++++++++- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py index d086cbb10..3f1d787ea 100644 --- a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py +++ b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py @@ -119,7 +119,9 @@ def __init__( self._close_event_enqueued = False self._presenter: _SlangPyNativeWindowPresenter | None = None self._poll_input_events: list[UserInputEvent] | None = None - self._pending_printable_keys: deque[tuple[str, int]] = deque() + self._pending_printable_keys: deque[ + tuple[str, KeyboardUserInputEvent] + ] = deque() self._pressed_key_values: dict[str, str] = {} def open(self, session_desc: SessionDesc) -> None: @@ -168,15 +170,27 @@ def get_user_input_events(self) -> UserInputEvents: """Pump GLFW and return native input events not yet read.""" presenter = self._presenter if presenter is not None: + pending_before_poll = tuple(self._pending_printable_keys) self._poll_input_events = [] try: presenter.process_events() + # ponytail: Text delayed by more than one poll can still arrive + # as a second press. Split physical and text runtime events if a + # client needs a longer coalescing window. + pending_to_flush = ( + tuple(self._pending_printable_keys) + if presenter.should_close + else pending_before_poll + ) + for pending in pending_to_flush: + if pending in self._pending_printable_keys: + self._pending_printable_keys.remove(pending) + self._put_input(pending[1]) if presenter.should_close: self._on_window_closed() polled_input_events = self._poll_input_events finally: self._poll_input_events = None - self._pending_printable_keys.clear() for event in polled_input_events: self._put_input(event) @@ -245,15 +259,10 @@ def _on_keyboard_event(self, event: spy.KeyboardEvent) -> None: if _is_keyboard_input(event): text = _keyboard_input_text(event) if text is not None: - poll_input_events = self._poll_input_events - if self._pending_printable_keys and poll_input_events is not None: - physical_key, event_index = self._pending_printable_keys.pop() + if self._pending_printable_keys: + physical_key, keyboard_event = self._pending_printable_keys.pop() self._pressed_key_values[physical_key] = text - poll_input_events[event_index] = KeyboardUserInputEvent( - timestamp=uint64(0), - key=text, - state=KeyboardInputState.PRESSED, - ) + self._put_input(replace(keyboard_event, key=text)) else: self._put_input( KeyboardUserInputEvent( @@ -271,13 +280,7 @@ def _on_keyboard_event(self, event: spy.KeyboardEvent) -> None: keyboard_event.state is KeyboardInputState.PRESSED and len(keyboard_event.key) == 1 ): - poll_input_events = self._poll_input_events - if poll_input_events is not None: - event_index = len(poll_input_events) - self._put_input(keyboard_event) - self._pending_printable_keys.append((keyboard_event.key, event_index)) - else: - self._put_input(keyboard_event) + self._pending_printable_keys.append((keyboard_event.key, keyboard_event)) return if ( keyboard_event.state is KeyboardInputState.RELEASED @@ -293,6 +296,7 @@ def _on_keyboard_event(self, event: spy.KeyboardEvent) -> None: ) if pending_key is not None: self._pending_printable_keys.remove(pending_key) + self._put_input(pending_key[1]) key = self._pressed_key_values.pop(keyboard_event.key, keyboard_event.key) self._put_input( KeyboardUserInputEvent( diff --git a/flashdreams/test_v2/test_native_window_client_window.py b/flashdreams/test_v2/test_native_window_client_window.py index 48ab127cb..ab9b35de8 100644 --- a/flashdreams/test_v2/test_native_window_client_window.py +++ b/flashdreams/test_v2/test_native_window_client_window.py @@ -344,7 +344,7 @@ def test_native_window_reports_input_and_close_from_event_pump() -> None: clock_ns=lambda: next(clock_values), ) window.open(_session_desc()) - presenter.pending_events.put(("keyboard", _KeyboardEvent("w", pressed=True))) + presenter.pending_events.put(("keyboard", _KeyboardEvent("up", pressed=True))) presenter.pending_events.put( ( "mouse", @@ -360,7 +360,7 @@ def test_native_window_reports_input_and_close_from_event_pump() -> None: keyboard = events[0] mouse = events[1] assert isinstance(keyboard, KeyboardUserInputEvent) - assert keyboard.key == "w" + assert keyboard.key == "up" assert keyboard.state is KeyboardInputState.PRESSED assert isinstance(mouse, MouseUserInputEvent) assert mouse.action == "move" @@ -464,6 +464,79 @@ def test_native_text_input_uses_slangpy_resolved_shift_character() -> None: ] +@pytest.mark.parametrize("text", ("a", "A")) +def test_native_text_input_coalesces_across_event_polls(text: str) -> None: + presenter = _Presenter() + window = NativeWindowClientWindow(presenter_factory=_presenter_factory(presenter)) + window.open(_session_desc()) + presenter.pending_events.put(("keyboard", _KeyboardEvent("a", pressed=True))) + + assert window.get_user_input_events().get_events() == [] + + presenter.pending_events.put(("keyboard", _TextInputEvent(text))) + pressed = window.get_user_input_events().get_events() + presenter.pending_events.put(("keyboard", _KeyboardEvent("a", pressed=False))) + released = window.get_user_input_events().get_events() + window.close() + + assert [(event.key, event.state) for event in (*pressed, *released)] == [ + (text, KeyboardInputState.PRESSED), + (text, KeyboardInputState.RELEASED), + ] + + +def test_native_printable_key_without_text_is_flushed_after_one_poll() -> None: + presenter = _Presenter() + window = NativeWindowClientWindow(presenter_factory=_presenter_factory(presenter)) + window.open(_session_desc()) + presenter.pending_events.put(("keyboard", _KeyboardEvent("w", pressed=True))) + + assert window.get_user_input_events().get_events() == [] + pressed = window.get_user_input_events().get_events() + presenter.pending_events.put(("keyboard", _KeyboardEvent("w", pressed=False))) + released = window.get_user_input_events().get_events() + window.close() + + assert [(event.key, event.state) for event in (*pressed, *released)] == [ + ("w", KeyboardInputState.PRESSED), + ("w", KeyboardInputState.RELEASED), + ] + + +def test_native_printable_release_flushes_pending_press() -> None: + presenter = _Presenter() + window = NativeWindowClientWindow(presenter_factory=_presenter_factory(presenter)) + window.open(_session_desc()) + presenter.pending_events.put(("keyboard", _KeyboardEvent("z", pressed=True))) + presenter.pending_events.put(("keyboard", _KeyboardEvent("z", pressed=False))) + + events = window.get_user_input_events().get_events() + window.close() + + assert [(event.key, event.state) for event in events] == [ + ("z", KeyboardInputState.PRESSED), + ("z", KeyboardInputState.RELEASED), + ] + + +def test_native_close_flushes_pending_printable_press() -> None: + presenter = _Presenter() + window = NativeWindowClientWindow(presenter_factory=_presenter_factory(presenter)) + window.open(_session_desc()) + presenter.pending_events.put(("keyboard", _KeyboardEvent("w", pressed=True))) + presenter.pending_events.put(("close", None)) + + events = window.get_user_input_events().get_events() + window.close() + + assert len(events) == 2 + pressed, closed = events + assert isinstance(pressed, KeyboardUserInputEvent) + assert pressed.key == "w" + assert pressed.state is KeyboardInputState.PRESSED + assert isinstance(closed, CloseUserInputEvent) + + @pytest.mark.parametrize( ("slangpy_name", "runtime_key"), ( From 24bec204042157ada2aecfd1790553348c4aae54 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 23:56:39 -0700 Subject: [PATCH 42/62] Clean up maps --- .../maps/boulevard_district.robotaxi.yaml | 2 +- ...yaml => flashdreams_raceway.robotaxi.yaml} | 2 +- .../maps/minimal_loop.robotaxi.yaml | 114 ------------------ .../maps/race_track_minimal.robotaxi.yaml | 104 ---------------- 4 files changed, 2 insertions(+), 220 deletions(-) rename apps/crazy_robotaxi/crazy_robotaxi/maps/{demo_race_track.robotaxi.yaml => flashdreams_raceway.robotaxi.yaml} (99%) delete mode 100644 apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml delete mode 100644 apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml index e43b35d5a..f545e9599 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml @@ -1,6 +1,6 @@ schema_version: 1 id: crazy-robotaxi-boulevard-district -name: Original Boulevard District +name: Boulevard District compiler: sample_spacing_m: 2.0 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml similarity index 99% rename from apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml rename to apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml index 53b4d053b..fc946009a 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml @@ -1,6 +1,6 @@ schema_version: 1 id: crazy-robotaxi-grand-prix-demo -name: Demo Grand Prix +name: FlashDreams Raceway compiler: sample_spacing_m: 1.5 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml deleted file mode 100644 index bc0d343fa..000000000 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/minimal_loop.robotaxi.yaml +++ /dev/null @@ -1,114 +0,0 @@ -schema_version: 1 -id: crazy-robotaxi-minimal-loop -name: Minimal Loop and Parking Lot - -compiler: - sample_spacing_m: 2.0 - ground_margin_m: 20.0 - intersection_connector_samples: 8 - -profiles: - neighborhood: - lane_width_m: 3.6 - curb_offset_m: 0.6 - lanes: [backward, forward] - speed_limit_mps: 13.4 - lane_marking: {style: SOLID_GROUP, color: YELLOW} - divider_markings: - - {style: SOLID_GROUP, color: YELLOW} - -nodes: - - id: hub - type: intersection - pose: {x_m: 0, y_m: 0} - - - id: dead_end - type: cul_de_sac - pose: {x_m: 0, y_m: 30} - culdesac_radius_m: 10 - - - id: neighborhood_lot - type: parking_lot - connected_to: hub - opening_vertex: 2 - vertices: - - {x_m: -35, y_m: -7} - - {x_m: -3, y_m: -7} - - {x_m: 3, y_m: -7} - - {x_m: 35, y_m: -7} - - {x_m: 35, y_m: -27} - - {x_m: -35, y_m: -27} - - - id: neighborhood_loop_northeast_corner - type: road_joint - pose: {x_m: -50, y_m: 75} - - - id: neighborhood_loop_northwest_corner - type: road_joint - pose: {x_m: 50, y_m: 75} - - - id: neighborhood_loop_southeast_corner - type: road_joint - pose: {x_m: -50, y_m: 0} - - - id: neighborhood_loop_southwest_corner - type: road_joint - pose: {x_m: 50, y_m: 0} - -roads: - - id: neighborhood_loop_north - from: neighborhood_loop_northeast_corner - to: neighborhood_loop_northwest_corner - profile: neighborhood - - - id: neighborhood_loop_southeast - from: neighborhood_loop_southeast_corner - to: hub - profile: neighborhood - - - id: neighborhood_loop_southwest - from: neighborhood_loop_southwest_corner - to: hub - profile: neighborhood - - - id: neighborhood_loop_east - from: neighborhood_loop_northeast_corner - to: neighborhood_loop_southeast_corner - profile: neighborhood - - - id: neighborhood_loop_west - from: neighborhood_loop_northwest_corner - to: neighborhood_loop_southwest_corner - profile: neighborhood - - - id: dead_end_road - from: hub - to: dead_end - profile: neighborhood - -race_courses: - - id: neighborhood-loop - start: neighborhood_loop_southwest - checkpoints: - - hub - - neighborhood_loop_southeast - - neighborhood_loop_northeast_corner - - neighborhood_loop_north - - neighborhood_loop_northwest_corner - - neighborhood_loop_west - - neighborhood_loop_southwest_corner - lap_count: 3 - checkpoint_markers: true - -spawns: - - id: taxi_start - road: neighborhood_loop_southwest - lane: 1 - distance_m: 5 - variants: - default: - image: package://omnidreams_game_engine/screenshot.jpg - prompt: >- - A forward-facing view from a taxi moving through a quiet suburban - neighborhood in daylight, with houses, low commercial buildings, - landscaping, and parked cars. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml deleted file mode 100644 index 7e77d5a5d..000000000 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/race_track_minimal.robotaxi.yaml +++ /dev/null @@ -1,104 +0,0 @@ -schema_version: 1 -id: crazy-robotaxi-demo-race-track -name: Minimal Raceway - -compiler: - sample_spacing_m: 2.0 - ground_margin_m: 25.0 - intersection_connector_samples: 8 - -profiles: - raceway: - lane_width_m: 5.5 - curb_offset_m: 1.5 - lanes: [backward, backward, forward, forward] - speed_limit_mps: 24.0 - lane_marking: {style: DASHED_SINGLE, color: WHITE} - divider_markings: - - {style: DASHED_SINGLE, color: WHITE} - - {style: DASHED_SINGLE, color: WHITE} - - {style: DASHED_SINGLE, color: WHITE} - -nodes: - - {id: start_finish, type: road_joint, pose: {x_m: 0, y_m: -100}} - - {id: east_curve_entry, type: road_joint, pose: {x_m: 150, y_m: -100}} - - {id: east_curve_exit, type: road_joint, pose: {x_m: 150, y_m: 100}} - - {id: north_midpoint, type: road_joint, pose: {x_m: 0, y_m: 100}} - - {id: west_curve_entry, type: road_joint, pose: {x_m: -150, y_m: 100}} - - {id: west_curve_exit, type: road_joint, pose: {x_m: -150, y_m: -100}} - -roads: - - id: start_finish_straight - from: west_curve_exit - to: start_finish - profile: raceway - - - id: south_straight - from: start_finish - to: east_curve_entry - profile: raceway - - - id: east_curve - from: east_curve_entry - to: east_curve_exit - profile: raceway - bezier: - - control_points: - - {x_m: 194.183, y_m: -100} - - {x_m: 230, y_m: -55.228} - end: {x_m: 230, y_m: 0} - - control_points: - - {x_m: 230, y_m: 55.228} - - {x_m: 194.183, y_m: 100} - end: {x_m: 150, y_m: 100} - - - id: north_straight - from: east_curve_exit - to: north_midpoint - profile: raceway - - - id: back_straight - from: north_midpoint - to: west_curve_entry - profile: raceway - - - id: west_curve - from: west_curve_entry - to: west_curve_exit - profile: raceway - bezier: - - control_points: - - {x_m: -194.183, y_m: 100} - - {x_m: -230, y_m: 55.228} - end: {x_m: -230, y_m: 0} - - control_points: - - {x_m: -230, y_m: -55.228} - - {x_m: -194.183, y_m: -100} - end: {x_m: -150, y_m: -100} - -race_courses: - - id: demo-oval - start: start_finish - checkpoints: - - east_curve_entry - - east_curve - - east_curve_exit - - north_midpoint - - west_curve_entry - - west_curve - - west_curve_exit - lap_count: 1 - checkpoint_markers: true - -spawns: - - id: race_start - road: start_finish_straight - lane: 2 - distance_m: 90 - variants: - default: - image: package://omnidreams_game_engine/screenshot.jpg - prompt: >- - A forward-facing view from a taxi on a wide closed urban race - circuit in daylight, with sweeping paved curves, safety barriers, - grandstands, sponsor banners, and a clear start-finish straight. From c6a71e9d08a83687dbdd41c6b706400f264500c1 Mon Sep 17 00:00:00 2001 From: Ariel Glasroth Date: Fri, 28 Aug 2026 12:14:43 +0000 Subject: [PATCH 43/62] add fast config + initial rebase + stuff runs --- apps/crazy_robotaxi/README.md | 98 +++---- .../crazy_robotaxi/crazy_robotaxi/__init__.py | 7 +- .../crazy_robotaxi/application.py | 226 +++++---------- .../configs/example_engine_config.yaml | 52 ---- .../configs/example_game_config.yaml | 92 ------ .../crazy_robotaxi/high_scores.py | 10 +- apps/crazy_robotaxi/crazy_robotaxi/session.py | 111 +++---- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 33 +-- apps/crazy_robotaxi/pyproject.toml | 6 - apps/crazy_robotaxi/tests/test_application.py | 146 +++++++--- .../tests/test_layered_settings.py | 168 ----------- apps/crazy_robotaxi/tests/test_maps.py | 2 +- apps/crazy_robotaxi/tests/test_ui.py | 21 +- .../omnidreams_game_engine/engine_settings.py | 6 - .../omnidreams_game_engine/input.py | 158 +++++----- .../omnidreams_game_engine/scene_loader.py | 2 +- apps/omnidreams_game_engine/pyproject.toml | 4 +- .../tests/test_input.py | 271 ++++++++---------- .../apps/crazy_robotaxi/__init__.py | 4 + .../omnidreams/apps/crazy_robotaxi/adapter.py | 64 +++++ integrations_v2/omnidreams/pyproject.toml | 6 + .../omnidreams/tests/test_recipe_configs.py | 9 + uv.lock | 24 +- 23 files changed, 592 insertions(+), 928 deletions(-) delete mode 100644 apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml delete mode 100644 apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml delete mode 100644 apps/crazy_robotaxi/tests/test_layered_settings.py create mode 100644 integrations_v2/omnidreams/apps/crazy_robotaxi/__init__.py create mode 100644 integrations_v2/omnidreams/apps/crazy_robotaxi/adapter.py diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 9bf93ce12..fb86b0c6a 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -16,25 +16,31 @@ background waypoints, HUD, and BEV window. ```bash uv sync --package crazy-robotaxi -uv run flashdreams-run-v2 crazy-robotaxi \ +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams \ --mode native-window \ --window-title "Crazy Robotaxi" ``` +`crazy-robotaxi-omnidreams` is the default app name. +`crazy-robotaxi-omnidreams-perf` is the performance-optimized app name. +`crazy-robotaxi-omnidreams-fast-perf` is the fast-performance-optimized app name. + Native-window mode keeps the composited frame on the GPU and presents it in a local GLFW window. It requires a local display plus SlangPy's Vulkan/CUDA interop support. For a browser client instead, run: ```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode webrtc --port 8089 ``` +Open `http://127.0.0.1:8089/`. Add `--host 0.0.0.0` when the browser connects +through another host, matching Interactive Drive. + Application arguments follow `--`: ```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ - --map /path/to/city.robotaxi.yaml \ - --model-preset perf \ +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams-perf --mode webrtc -- \ + --map apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml \ --game-time-s 90 ``` @@ -44,7 +50,7 @@ the selected course. The configured `--map` is listed first; the menu also discovers bundled maps and `.robotaxi.yaml` maps beside that configured file. Map compilation and scene loading begin only after the menu choice. -Use `flashdreams-run-v2 crazy-robotaxi -- --help` for the complete application +Use `flashdreams-run-v2 crazy-robotaxi-omnidreams -- --help` for the complete application options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. A standard gamepad uses the left stick for steering and the right/left triggers for throttle/brake. V2 game-wheel events use their normalized steering, @@ -59,37 +65,6 @@ targets or an off-map direction arrow on the BEV. Leaderboard name entry is owned by the Dear ImGui UI and submitted to the model loop through V2's asynchronous loop-message contract. -## Layered configuration - -Crazy Robotaxi accepts strict, partial `schema_version: 1` configuration files: - -- `--engine-config` covers the map request, model preset, renderer, and V2 - runtime diagnostics. -- `--game-config` covers taxi/race mode, rules, vehicle physics, persistence, - and live-edit abilities. - -Settings resolve from packaged typed defaults, then YAML, then explicitly -supplied application flags. Omitted YAML fields retain their lower-precedence -values, unknown keys are errors, and relative paths inside YAML resolve beside -that file. The legacy `--renderer-config` option remains available as an -explicit compatibility layer. - -The checked-in `example_engine_config.yaml` and `example_game_config.yaml` -show the complete V2 surface: - -```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ - --engine-config \ - apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml \ - --game-config \ - apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml \ - --model-preset perf -``` - -Here `--model-preset perf` wins over the value in the engine YAML. Maps remain -separate authoritative `.robotaxi.yaml` documents referenced by path; layered -configuration does not copy or reinterpret map content. - ## Race mode Authored maps may define ordered race courses using the same schema and map @@ -97,7 +72,7 @@ files as the original Crazy Robotaxi branch. Put the bundled demonstration map first in the selection screen with: ```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode native-window -- \ --map apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml \ --game-mode race ``` @@ -115,7 +90,7 @@ weather, and `O` spawns a crossing-obstacle event. Effect items can trigger rain, snow, a timed mystery skin, or a physics-authoritative nitro boost. ```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode native-window -- \ +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode native-window -- \ --live-edit-coins \ --live-edit-items \ --live-edit-weather \ @@ -131,12 +106,10 @@ native preset is selected. All abilities are disabled by default. ## Maximum-performance preset -Crazy Robotaxi exposes the public OmniDreams `standard` and `perf` pipeline -configs unchanged. Its only app-specific model preset is `fast-perf`, which -derives from the public OmniDreams perf config and adds the native FP8 LightVAE -image and per-chunk encoders. It therefore retains perf's required native FP8 -DiT, cuDNN attention, compiled/CUDA-graphed LightTAE decoder, and two-step -schedule. +OmniDreams exposes `standard`, `perf`, and `fast-perf` pipeline configs. Crazy +Robotaxi selects those model-owned configs directly. `fast-perf` retains perf's +required native FP8 DiT, cuDNN attention, CUDA-graphed LightTAE decoder, and +two-step schedule, while preferring the native FP8 LightVAE encoders. Prepare the native DiT sources once from the repository root: @@ -144,21 +117,20 @@ Prepare the native DiT sources once from the repository root: uv run --package flashdreams-omnidreams omnidreams-prepare --perf ``` -It requires a calibrated LightVAE FP8 state. After generating that state, run: +Run the fast config directly: ```bash -OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH=artifacts/native_vae/lightvae-fp8-state.pt \ - uv run flashdreams-run-v2 crazy-robotaxi \ - --mode native-window \ - --pixel-width 1168 \ - --pixel-height 640 \ - -- \ - --model-preset fast-perf +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams-fast-perf \ + --mode native-window ``` -The native DiT sources must also have been prepared as described above. The -resolution flags are optional; `fast-perf` adapts the app renderer to the V2 -session dimensions. GPU throughput and quality should be validated on the +On its first launch, `fast-perf` downloads the public OmniDreams sample, +calibrates the native FP8 LightVAE, and atomically caches the result at +`artifacts/native_vae/lightvae_fp8_state.pt`. Later launches reuse that file. +Set `OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH` to override the cache location. + +The native DiT sources must also have been prepared as described above. GPU +throughput and quality should be validated on the target machine before treating it as a regression baseline. ## Performance diagnostics @@ -168,7 +140,7 @@ timings during normal play. Capture synchronized model-step and GPU-stage diagnostics while reproducing a chunk pause with: ```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc \ +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode webrtc \ --stats-path /tmp/crazy-robotaxi-stats.json -- \ --profile-pipeline ``` @@ -206,20 +178,18 @@ like-for-like `fast-perf` rerun needed after presentation changes. Presentation remains fixed at 30 fps. Disabling diagnostic synchronization removes an avoidable pause source, but it does not make a model preset whose -steady-state throughput is below 30 fps meet that rate; use `--model-preset -perf` when its quality/performance tradeoff is appropriate. +steady-state throughput is below 30 fps meet that rate; use the +`crazy-robotaxi-omnidreams-perf` app when that tradeoff is appropriate. Profile interactive input separately with: ```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ - --model-preset perf \ +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams-perf --mode webrtc -- \ --profile-input-latency ``` This opt-in adds a UI-thread key indicator and logs the time from V2 UI event -receipt to the first presented model frame carrying that transition. It also -shows applied, ignored, and coalesced transition counts. The normal +receipt to the first presented model frame carrying that transition. The normal HUD does not construct these widgets when the flag is absent. The indicator's physical-key-to-browser delay still includes WebRTC transport latency, while the reported `UI TO MODEL FRAME` value isolates the synchronous app/model @@ -239,7 +209,7 @@ it does not reduce total cold-start time. Disable it for comparisons or startup debugging with: ```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc -- \ +uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode webrtc -- \ --prewarm-blocks 0 ``` diff --git a/apps/crazy_robotaxi/crazy_robotaxi/__init__.py b/apps/crazy_robotaxi/crazy_robotaxi/__init__.py index db81976e5..d2ec37aca 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/__init__.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/__init__.py @@ -3,6 +3,9 @@ """Crazy Robotaxi FlashDreams V2 application.""" -from crazy_robotaxi.application import CrazyRobotaxiApplication, create_app +from crazy_robotaxi.application import ( + CrazyRobotaxiApplication, + CrazyRobotaxiApplicationDefaults, +) -__all__ = ["CrazyRobotaxiApplication", "create_app"] +__all__ = ["CrazyRobotaxiApplication", "CrazyRobotaxiApplicationDefaults"] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 153121235..8a16dac92 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -7,17 +7,12 @@ import argparse import logging -import os from collections.abc import Callable, Sequence from dataclasses import dataclass, replace +from functools import partial from pathlib import Path from typing import Any, Literal -import torch -from omnidreams.config import ( - OMNIDREAMS_PERF_PIPELINE_CONFIG, - OMNIDREAMS_PIPELINE_CONFIG, -) from omnidreams_game_engine.cli_args import ( ExplicitArgTrackingArgumentParser, arg_was_explicit, @@ -28,17 +23,13 @@ MapLaunchSettings, RenderingSettings, WorldModelLaunchSettings, - load_engine_settings, ) from omnidreams_game_engine.game_map import GAME_MAP_SUFFIX, load_game_map_header -from omnidreams_game_engine.renderer_settings import ( - RendererSettings, - load_renderer_settings, -) +from omnidreams_game_engine.renderer_settings import RendererSettings from omnidreams_game_engine.scene import SceneRequest, load_scene from omnidreams_game_engine.types import SceneDefinition -from crazy_robotaxi.config import CrazyRobotaxiSettings, load_game_settings +from crazy_robotaxi.config import CrazyRobotaxiSettings from crazy_robotaxi.game_selection import GameMapOption, GameMode from crazy_robotaxi.high_scores import default_high_scores_path, default_race_times_path from crazy_robotaxi.live_edit.config import ( @@ -60,7 +51,7 @@ _VIDEO_FPS = 30 """Generated-video cadence required by the model.""" -_UI_FPS = 60 +_UI_FPS = 30 """Input polling and HUD cadence used by Interactive Drive.""" _LOGGER = logging.getLogger(__name__) @@ -69,50 +60,15 @@ """Blocks covering chunk2 cache filling and the first steady-state AR shape.""" -_FAST_PERF_PIPELINE = derive_config( - OMNIDREAMS_PERF_PIPELINE_CONFIG, - name="crazy-robotaxi-fast-perf", - image_encoder={ - "dtype": torch.float16, - "use_compile": False, - "use_cuda_graph": False, - "native_vae_acceleration": "required", - "native_vae_backend": "fp8", - "native_vae_fp8_state_path": os.environ.get( - "OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH" - ), - }, - encoder={ - "dtype": torch.float16, - "use_compile": False, - "use_cuda_graph": False, - "native_vae_acceleration": "required", - "native_vae_backend": "fp8", - "native_vae_fp8_state_path": os.environ.get( - "OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH" - ), - }, - diffusion_model={"seed": None}, -) -"""OmniDreams perf plus native FP8 LightVAE for maximum throughput.""" - - @dataclass(frozen=True, slots=True) -class _ModelPreset: - """App-owned pipeline selection and renderer geometry policy.""" - - pipeline: Any - renderer_follows_session: bool = False - +class CrazyRobotaxiApplicationDefaults: + """Defaults supplied by a world-model integration.""" -_MODEL_PRESETS = { - "standard": _ModelPreset(OMNIDREAMS_PIPELINE_CONFIG), - "perf": _ModelPreset(OMNIDREAMS_PERF_PIPELINE_CONFIG), - "fast-perf": _ModelPreset( - _FAST_PERF_PIPELINE, - renderer_follows_session=True, - ), -} + title: str = "Crazy Robotaxi" + slug: str = "crazy-robotaxi" + width: int = 1280 + height: int = 704 + pipeline_config: Any | None = None @dataclass(frozen=True, slots=True) @@ -125,7 +81,6 @@ class ApplicationConfig: device: str total_blocks: int | None model_preset_name: str - renderer_follows_session: bool pipeline_profiling: bool prewarm_blocks: int """Hidden neutral blocks generated before the first presented game frame.""" @@ -166,19 +121,26 @@ class ApplicationConfig: class CrazyRobotaxiApplication(IApplication): - """Load one model and create isolated V2 game sessions.""" + """Configure isolated V2 game sessions with model-owned defaults.""" def __init__( self, *, pipeline_factory: PipelineFactory | None = None, + defaults: CrazyRobotaxiApplicationDefaults | None = None, scene_factory: SceneFactory | None = None, ) -> None: - self._defaults = RendererSettings(raster=RasterConfig(), bev=BevConfig()) + self._application_defaults = defaults or CrazyRobotaxiApplicationDefaults() + self._defaults = RendererSettings( + raster=RasterConfig( + width=self._application_defaults.width, + height=self._application_defaults.height, + ), + bev=BevConfig(), + ) self._pipeline_factory = pipeline_factory or _build_pipeline self._scene_factory = scene_factory or load_scene - self._pipeline_config: Any = _MODEL_PRESETS["standard"].pipeline - self._pipeline: Any | None = None + self._pipeline_config = self._application_defaults.pipeline_config self._config: ApplicationConfig | None = None self._map_options: tuple[GameMapOption, ...] = () @@ -199,7 +161,10 @@ def session_desc(self) -> SessionDesc: def init(self, commandline_args: Sequence[str]) -> None: """Parse application options without starting another runtime.""" - args = _parser().parse_args(list(commandline_args)) + pipeline_config = self._pipeline_config + if pipeline_config is None: + raise RuntimeError("A world-model integration must provide pipeline_config") + args = _parser(self._application_defaults).parse_args(list(commandline_args)) engine_settings = self._resolve_engine_settings(args) game_settings = self._resolve_game_settings(args) if ( @@ -247,14 +212,7 @@ def init(self, commandline_args: Sequence[str]) -> None: else game_settings.taxi.high_scores_path.expanduser() ), ) - model_preset_name = engine_settings.world_model.model_preset - if model_preset_name not in _MODEL_PRESETS: - available = ", ".join(_MODEL_PRESETS) - raise ValueError( - f"Unknown model preset {model_preset_name!r}; available: {available}" - ) - model_preset = _MODEL_PRESETS[model_preset_name] - pipeline_config = model_preset.pipeline + model_preset_name = pipeline_config.name if engine_settings.world_model.compile is not None: pipeline_config = derive_config( pipeline_config, @@ -287,7 +245,6 @@ def init(self, commandline_args: Sequence[str]) -> None: device=engine_settings.world_model.device, total_blocks=engine_settings.runtime.total_blocks, model_preset_name=model_preset_name, - renderer_follows_session=model_preset.renderer_follows_session, pipeline_profiling=bool(engine_settings.world_model.profile_pipeline), prewarm_blocks=engine_settings.runtime.prewarm_blocks, profile_input_latency=engine_settings.runtime.profile_input_latency, @@ -329,53 +286,42 @@ def _resolve_engine_settings(self, args: argparse.Namespace) -> EngineSettings: bev=self._defaults.bev, ), ) - if args.engine_config is not None: - settings = load_engine_settings(args.engine_config, base=settings) - if args.renderer_config is not None: - legacy = load_renderer_settings(args.renderer_config) - settings = replace( - settings, - rendering=RenderingSettings(raster=legacy.raster, bev=legacy.bev), - ) - map_settings = settings.map - for destination, field_name in ( - ("map", "path"), - ("camera", "camera"), - ("variant", "variant"), - ("prompt", "prompt"), - ("force_map_recompile", "force_recompile"), - ): - if arg_was_explicit(args, destination): - map_settings = replace( - map_settings, **{field_name: getattr(args, destination)} - ) - world_model = settings.world_model - for destination in ("model_preset", "device", "compile", "profile_pipeline"): - if arg_was_explicit(args, destination): - world_model = replace( - world_model, **{destination: getattr(args, destination)} - ) - runtime = settings.runtime - for destination in ("total_blocks", "prewarm_blocks", "profile_input_latency"): - if arg_was_explicit(args, destination): - runtime = replace(runtime, **{destination: getattr(args, destination)}) - if runtime.profile_world_model: - world_model = replace(world_model, profile_pipeline=True) - presentation = settings.presentation - if arg_was_explicit(args, "show_fps"): - presentation = replace(presentation, show_fps=bool(args.show_fps)) return replace( settings, - map=map_settings, - world_model=world_model, - presentation=presentation, - runtime=runtime, + map=replace( + settings.map, + path=args.map, + camera=args.camera, + variant=args.variant, + prompt=args.prompt, + force_recompile=args.force_map_recompile, + ), + rendering=replace( + settings.rendering, + raster=replace( + settings.rendering.raster, width=args.width, height=args.height + ), + ), + world_model=replace( + settings.world_model, + device=args.device, + compile=args.compile, + profile_pipeline=args.profile_pipeline, + ), + presentation=replace( + settings.presentation, + show_fps=bool(args.show_fps), + ), + runtime=replace( + settings.runtime, + total_blocks=args.total_blocks, + prewarm_blocks=args.prewarm_blocks, + profile_input_latency=args.profile_input_latency, + ), ) def _resolve_game_settings(self, args: argparse.Namespace) -> CrazyRobotaxiSettings: settings = CrazyRobotaxiSettings() - if args.game_config is not None: - settings = load_game_settings(args.game_config, base=settings) game = settings.game taxi = settings.taxi race = settings.race @@ -407,23 +353,14 @@ def create_session(self, session_desc: SessionDesc) -> ISession: config = self._config if config is None: raise RuntimeError("init() must run before create_session()") + pipeline_config = self._pipeline_config + if pipeline_config is None: + raise RuntimeError("init() must select a pipeline before create_session()") if session_desc.output_layout is not VideoTensorLayout.tchw: raise ValueError("Crazy Robotaxi produces tchw output") if session_desc.frames_per_second_for_step != _VIDEO_FPS: raise ValueError("Crazy Robotaxi generates video at 30 frames per second") actual = session_desc.video_width, session_desc.video_height - if config.renderer_follows_session: - config = replace( - config, - renderer=replace( - config.renderer, - raster=replace( - config.renderer.raster, - width=actual[0], - height=actual[1], - ), - ), - ) config = replace( config, renderer=_fit_bev_renderer_to_ui( @@ -437,9 +374,9 @@ def create_session(self, session_desc: SessionDesc) -> ISession: raise ValueError( f"Session dimensions {actual} do not match renderer {expected}" ) - transformer = self._pipeline_config.diffusion_model.transformer - scheduler = self._pipeline_config.diffusion_model.scheduler - encoder = self._pipeline_config.encoder + transformer = pipeline_config.diffusion_model.transformer + scheduler = pipeline_config.diffusion_model.scheduler + encoder = pipeline_config.encoder bev = config.renderer.bev bev_resolution = f"{bev.width}x{bev.height}" if bev.enabled else "disabled" _LOGGER.info( @@ -459,13 +396,12 @@ def create_session(self, session_desc: SessionDesc) -> ISession: list(scheduler.denoising_timesteps), bev_resolution, ) - if self._pipeline is None: - self._pipeline = self._pipeline_factory( - self._pipeline_config, - config.device, - ) return CrazyRobotaxiSession( - pipeline=self._pipeline, + pipeline_factory=partial( + self._pipeline_factory, + pipeline_config, + config.device, + ), scene_factory=self._scene_factory, map_options=self._map_options, config=config, @@ -476,19 +412,9 @@ def create_session(self, session_desc: SessionDesc) -> ISession: ) def close(self) -> None: - """Release process-lifetime model resources.""" - pipeline = self._pipeline - self._pipeline = None + """Release application configuration state.""" self._config = None self._map_options = () - close = getattr(pipeline, "close", None) - if callable(close): - close() - - -def create_app() -> IApplication: - """Return a fresh V2 application for entry-point discovery.""" - return CrazyRobotaxiApplication() def _build_pipeline(config: Any, device: str) -> Any: @@ -559,22 +485,20 @@ def _fit_bev_renderer_to_ui( return replace(renderer, bev=fitted) -def _parser() -> argparse.ArgumentParser: +def _parser( + defaults: CrazyRobotaxiApplicationDefaults, +) -> argparse.ArgumentParser: parser = ExplicitArgTrackingArgumentParser( - prog="flashdreams-run-v2 crazy-robotaxi --", + prog=f"flashdreams-run-v2 {defaults.slug} --", description="Drive Crazy Robotaxi on an authored semantic map.", ) - parser.add_argument("--engine-config", type=Path) parser.add_argument("--map", type=Path, default=_DEFAULT_MAP) - parser.add_argument("--game-config", type=Path) - parser.add_argument("--renderer-config", type=Path) + parser.add_argument("--width", type=int, default=defaults.width) + parser.add_argument("--height", type=int, default=defaults.height) parser.add_argument("--camera", default="camera_front_wide_120fov") parser.add_argument("--variant", default="default") parser.add_argument("--prompt") parser.add_argument("--force-map-recompile", action="store_true") - parser.add_argument( - "--model-preset", choices=tuple(_MODEL_PRESETS), default="standard" - ) parser.add_argument("--device", default="cuda") parser.add_argument("--total-blocks", type=int) parser.add_argument("--game-time-s", type=float) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml deleted file mode 100644 index 9f9d0b593..000000000 --- a/apps/crazy_robotaxi/crazy_robotaxi/configs/example_engine_config.yaml +++ /dev/null @@ -1,52 +0,0 @@ -schema_version: 1 -map: - path: ../maps/boulevard_district.robotaxi.yaml - camera: camera_front_wide_120fov - variant: default - force_recompile: false - preload_maps: false -world_model: - backend: omnidreams - manifest: example_world_model.yaml - offload_text_encoder: false - model_preset: standard - device: cuda - compile: null - profile_pipeline: false -rendering: - raster: - width: 1280 - height: 704 - near_plane_m: 0.1 - far_plane_m: 200.0 - fog_start_m: 40.0 - fog_end_m: 140.0 - fog_power: 1.5 - triangle_raytrace_distance_m: 25.0 - triangle_raytrace_edge_samples: 8 - lane_segment_interval_m: 0.05 - polyline_segment_interval_m: 0.8 - line_width_px: 12.0 - pole_width_px: 5.0 - dual_line_offset_m: 0.1 - bev: - enabled: true - width: 1024 - height: 1024 - height_m: 75.0 - fov_deg: 60.0 - tilt_deg: 0.0 -presentation: - hud_enabled: true - show_fps: false - stream_jpeg_quality: 85 - stream_scale: 1.0 -wheel: - enabled: true - profile: auto -runtime: - cuda_visible_devices: auto - profile_world_model: false - total_blocks: null - prewarm_blocks: 4 - profile_input_latency: false diff --git a/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml b/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml deleted file mode 100644 index abc145231..000000000 --- a/apps/crazy_robotaxi/crazy_robotaxi/configs/example_game_config.yaml +++ /dev/null @@ -1,92 +0,0 @@ -schema_version: 1 -mode: taxi -effects: - visual_flare_enabled: false -rules: - waypoint_spacing_m: 10.0 - pickup_grid_spacing_m: 60.0 - pickup_min_distance_m: 20.0 - initial_pickup_max_distance_m: 200.0 - pickup_radius_m: 5.0 - dropoff_radius_m: 6.0 - fare_min_route_distance_m: 200.0 - fare_max_route_distance_m: 250.0 - target_speed_mps: 10.0 - grace_s: 8.0 - min_time_s: 12.0 - max_time_s: 45.0 - trip_time_multiplier: 2.0 - base_fare_points: 500 - bonus_points_per_second: 100 - event_banner_s: 2.0 - global_time_s: 60.0 - dropoff_time_bonus_s: 30.0 - ground_snap_max_absolute_rotation_deg: 10.0 - ground_snap_settle_fraction: 0.25 -vehicle: - wheel_base_m: 2.8 - max_steer_rad: 0.69 - steer_rate_rad_per_s: 0.55 - steer_return_rate_rad_per_s: 0.9 - speed_limit_enabled: true - max_speed_mps: 31.2928 - max_reverse_speed_mps: 6.0 - max_accel_mps2: 10.0 - max_brake_mps2: 14.0 - max_lateral_accel_mps2: 17.0 - drag_mps2: 0.7 - mass_kg: 1550.0 - tire_grip: 1.35 - rolling_resistance: 0.015 - aero_drag_coefficient: 0.42 - collision_restitution: 0.22 - collision_friction: 0.65 - max_collision_yaw_rate_radps: 0.35 - suspension_stiffness: 42.0 - suspension_damping: 9.0 - suspension_travel_m: 0.22 - suspension_visual_gain: 0.15 - max_body_roll_rad: 0.16 - max_body_pitch_rad: 0.5 - actor_collision_enabled: true - static_collision_enabled: true - aabb_length_m: 4.8 - aabb_width_m: 2.0 - aabb_height_m: 1.6 - reverse_accel_mps2: 10.0 - handbrake_decel_mps2: 18.0 - handbrake_yaw_gain: 3.25 - max_handbrake_yaw_rate_radps: 1.5 - curb_collision_restitution: 0.45 - curb_forward_momentum_retention: 0.85 - input_activation_threshold: 0.01 - direction_change_accel_multiplier: 1.5 - speed_taper_knee_fraction: 0.62 - speed_taper_low_floor: 0.2 - speed_taper_high_floor: 0.05 - speed_taper_exponent: 3.0 - manual_coast_decel_mps2: 0.5 - ragdoll_grip_rate: 4.0 - ragdoll_yaw_response_rate: 8.0 - handbrake_yaw_response_rate: 4.0 - handbrake_lateral_damping_rate: 2.0 - handbrake_lateral_accel_scale: 0.35 -taxi: - seed: null - high_scores_path: null -race: - course: null - times_path: null -live_edit: - style: - enabled: false - weather: - enabled: false - obstacle: - enabled: false - coins: - enabled: false - items: - enabled: false -diagnostics: - alignment_directory: null diff --git a/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py index 81ff81ba5..2d7a19309 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py @@ -15,6 +15,8 @@ from filelock import FileLock from loguru import logger +from flashdreams.core.io.disk import default_flashdreams_cache_dir + _CSV_FIELDS = ("name", "score", "achieved_at_utc") _RACE_CSV_FIELDS = ( @@ -45,16 +47,12 @@ def format_race_time_us(elapsed_time_us: int) -> str: def default_high_scores_path() -> Path: """Return the default persistent taxi leaderboard path.""" - from omnidreams.impl.scenes import FLASHDREAMS_CACHE_DIR - - return FLASHDREAMS_CACHE_DIR / "crazy-robotaxi" / "highscores.csv" + return default_flashdreams_cache_dir() / "crazy-robotaxi" / "highscores.csv" def default_race_times_path() -> Path: """Return the default persistent race leaderboard path.""" - from omnidreams.impl.scenes import FLASHDREAMS_CACHE_DIR - - return FLASHDREAMS_CACHE_DIR / "crazy-robotaxi" / "race_times.csv" + return default_flashdreams_cache_dir() / "crazy-robotaxi" / "race_times.csv" def validate_player_name(name: str) -> str: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 893649435..185729eb0 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -8,7 +8,7 @@ import logging import time from collections.abc import Callable -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from functools import cached_property from typing import TYPE_CHECKING, Any @@ -30,10 +30,12 @@ ) from flashdreams.api_v2.loop import IModelLoop, IUILoop, invoke_async from flashdreams.api_v2.session import ISession +from flashdreams.runtime_v2.input_timeline import RealtimeInputTimeline from flashdreams.runtime_v2.presentation_manager import PresentationManager from flashdreams.runtime_v2.session_desc import SessionDesc from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( + GamepadUserInputEvent, KeyboardInputState, KeyboardUserInputEvent, ) @@ -44,22 +46,30 @@ from crazy_robotaxi.application import ApplicationConfig _LOGGER = logging.getLogger(__name__) +_GAMEPAD_START_BUTTON_INDEX = 9 +"""Browser-standard gamepad index shared by Start and Nintendo Plus.""" @dataclass(slots=True) class ModelState: """All mutable state owned by the one V2 model thread.""" - pipeline: Any + pipeline_factory: Callable[[], Any] scene_factory: Callable[[SceneRequest, Any], SceneDefinition] """Scene loader invoked after a complete UI selection.""" config: ApplicationConfig session_desc: SessionDesc driver_input: DriverInput + input_timeline: RealtimeInputTimeline = field(init=False) + """Frame-rate sampling clock for timestamped driving transitions.""" + ui_loop: IUILoop[TaxiHudState] """UI-loop endpoint used only through ``invoke_async``.""" + pipeline: Any | None = None + """Lazily constructed after the client opens and a game is selected.""" + scene: SceneDefinition | None = None """Selected immutable scene; ``None`` while the startup menu is active.""" @@ -81,14 +91,10 @@ class ModelState: prewarm_wall_ms: float = 0.0 """Wall time spent in hidden startup generation, excluding rollout creation.""" - input_transition_count: int = 0 - """Cumulative resolved drive transitions consumed by model steps.""" - - input_ignored_event_count: int = 0 - """Cumulative redundant drive events consumed by model steps.""" - - input_coalesced_transition_count: int = 0 - """Cumulative earlier transitions collapsed into the latest input state.""" + def __post_init__(self) -> None: + self.input_timeline = RealtimeInputTimeline( + samples_per_second=self.session_desc.frames_per_second_for_step, + ) def ensure_rollout(self) -> WorldModelRollout: """Build and prewarm renderer, PhysX, game, and cache on the model thread.""" @@ -98,6 +104,9 @@ def ensure_rollout(self) -> WorldModelRollout: raise RuntimeError( "Select a game mode and map before starting a rollout" ) + if self.pipeline is None: + self._set_loading_status("LOADING WORLD MODEL") + self.pipeline = self.pipeline_factory() frame_interval_s = 1.0 / self.session_desc.frames_per_second_for_step self.rollout = WorldModelRollout( pipeline=self.pipeline, @@ -249,9 +258,8 @@ def reset(self) -> None: self.last_video = None self.last_bev = None self.last_pose = None - self.input_transition_count = 0 - self.input_ignored_event_count = 0 - self.input_coalesced_transition_count = 0 + self.driver_input.reset() + self.input_timeline.reset() if self.rollout is not None: self.rollout.reset() @@ -261,6 +269,15 @@ def close(self) -> None: if rollout is not None: rollout.close() + def shutdown(self) -> None: + """Close the active rollout and process-lifetime model pipeline.""" + self.close() + pipeline = self.pipeline + self.pipeline = None + close = getattr(pipeline, "close", None) + if callable(close): + close() + def submit_player_name(self, name: str) -> None: """Submit a UI-validated leaderboard name on the model thread.""" rollout = self.ensure_rollout() @@ -274,7 +291,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: state = self.state # Match Interactive Drive: apply every unread edge before rollout setup, # reset handling, or simulation reads the retained command. - input_batch = state.driver_input.reduce(events, frame_count=1) + input_times_s = state.driver_input.apply(events) if not state.game_selected: return state.menu_result(step_index) rollout = state.ensure_rollout() @@ -298,15 +315,18 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: if live_edit.style is not None: live_edit.style.before_v2_chunk() frame_count = rollout.frame_count(state.blocks_generated) - state.input_transition_count += input_batch.transition_count - state.input_ignored_event_count += input_batch.ignored_event_count - state.input_coalesced_transition_count += ( - input_batch.coalesced_transition_count + input_window = state.input_timeline.next_window( + frame_count, + input_times_s=input_times_s, + ) + sampled_commands, transition_timestamps_us = state.driver_input.sample( + input_window ) - command = _taxi_driver_command(state.driver_input) generated = rollout.step( autoregressive_index=state.blocks_generated, - commands=(command,) * frame_count, + commands=tuple( + _taxi_driver_command(command) for command in sampled_commands + ), ) if live_edit is not None and live_edit.style is not None: live_edit.style.after_v2_chunk() @@ -327,18 +347,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: poses = engine_step.trajectory.rig_poses_world bev = engine_step.condition.bev_tchw metrics = dict(generated.metrics) - metrics.update( - { - "input_transition_count": input_batch.transition_count, - "input_ignored_event_count": input_batch.ignored_event_count, - "input_coalesced_transition_count": ( - input_batch.coalesced_transition_count - ), - } - ) - transition_timestamps_us = input_batch.transition_timestamps_us + ( - None, - ) * (int(video.shape[0]) - 1) if state.blocks_generated == 1 and state.prewarm_wall_ms > 0.0: metrics["startup_prewarm_wall_ms"] = state.prewarm_wall_ms metrics["startup_prewarm_blocks"] = state.config.prewarm_blocks @@ -360,9 +368,6 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: game_frames, poses, transition_timestamps_us=transition_timestamps_us, - input_transition_count=state.input_transition_count, - input_ignored_event_count=state.input_ignored_event_count, - input_coalesced_transition_count=(state.input_coalesced_transition_count), ) invoke_async( state.ui_loop, @@ -451,7 +456,7 @@ def reset(self) -> None: self.state.reset() def close(self) -> None: - self.state.close() + self.state.shutdown() class CrazyRobotaxiSession(ISession): @@ -460,13 +465,13 @@ class CrazyRobotaxiSession(ISession): def __init__( self, *, - pipeline: Any, + pipeline_factory: Callable[[], Any], scene_factory: Callable[[SceneRequest, Any], SceneDefinition], map_options: tuple[GameMapOption, ...], config: ApplicationConfig, session_desc: SessionDesc, ) -> None: - self._pipeline = pipeline + self._pipeline_factory = pipeline_factory self._scene_factory = scene_factory self._map_options = map_options self._config = config @@ -503,7 +508,7 @@ def init(self) -> None: model_loop = self.register_model_loop( CrazyRobotaxiModelLoop, state=ModelState( - pipeline=self._pipeline, + pipeline_factory=self._pipeline_factory, scene_factory=self._scene_factory, config=self._config, session_desc=self._session_desc, @@ -516,18 +521,26 @@ def init(self) -> None: def _restart_requested(events: UserInputEvents) -> bool: - """Return whether this model step received a pressed R key.""" - return any( - isinstance(event, KeyboardUserInputEvent) - and event.state is KeyboardInputState.PRESSED - and str(event.key).strip().lower() == "r" - for event in events.get_events() - ) + """Return whether this model step received a keyboard or gamepad restart.""" + for event in events.get_events(): + if ( + isinstance(event, KeyboardUserInputEvent) + and event.state is KeyboardInputState.PRESSED + and str(event.key).strip().lower() == "r" + ): + return True + if ( + isinstance(event, GamepadUserInputEvent) + and event.action == "state" + and len(event.pressed) > _GAMEPAD_START_BUTTON_INDEX + and event.pressed[_GAMEPAD_START_BUTTON_INDEX] + ): + return True + return False -def _taxi_driver_command(driver_input: DriverInput) -> DriverCommand: +def _taxi_driver_command(command: DriverCommand) -> DriverCommand: """Restore Crazy Robotaxi's keyboard handbrake over shared drive input.""" - command = driver_input.command() - if "space" not in driver_input.pressed_keys: + if not command.manual_control or command.steer_is_direct or command.brake <= 0.0: return command return replace(command, throttle=0.0, stop=False, handbrake=True) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 85fccebcb..76328981a 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -102,15 +102,6 @@ class TaxiHudFrame: transition_timestamp_us: int | None = None """V2 input transition represented by this frame, when one was received.""" - input_transition_count: int = 0 - """Cumulative resolved drive transitions consumed by the model loop.""" - - input_ignored_event_count: int = 0 - """Cumulative redundant drive events ignored by the model loop.""" - - input_coalesced_transition_count: int = 0 - """Cumulative earlier transitions collapsed into the latest input state.""" - @dataclass(slots=True) class TaxiHudState: @@ -311,13 +302,10 @@ def _record_presented_input(self, selected: TaxiHudFrame) -> None: self._reported_input_timestamps_us.add(timestamp_us) self._latest_input_latency_ms = (time.perf_counter() - received_at_s) * 1000.0 _LOGGER.info( - "[crazy-robotaxi] input-to-model-frame latency: event_us=%d " - "ui_to_frame_ms=%.1f transitions=%d ignored=%d coalesced=%d", + "[crazy-robotaxi] input-to-model-frame latency: " + "event_us=%d ui_to_frame_ms=%.1f", timestamp_us, self._latest_input_latency_ms, - selected.input_transition_count, - selected.input_ignored_event_count, - selected.input_coalesced_transition_count, ) def set_loading_status(self, status: str) -> None: @@ -1078,16 +1066,7 @@ def _draw_input_diagnostic(self, imgui: Any) -> None: ("SPACE", {"space"}), ) ) - current = self._current latency = self._latest_input_latency_ms - if current is None: - counts = "TRANSITIONS 0 IGNORED 0 COALESCED 0" - else: - counts = ( - f"TRANSITIONS {current.input_transition_count} " - f"IGNORED {current.input_ignored_event_count} " - f"COALESCED {current.input_coalesced_transition_count}" - ) latency_label = ( "UI TO MODEL FRAME --" if latency is None @@ -1098,7 +1077,7 @@ def _draw_input_diagnostic(self, imgui: Any) -> None: "Input Latency", position=(14.0, float(max(14, self.height - 124))), size=(440.0, 110.0), - lines=(input_state, latency_label, counts), + lines=(input_state, latency_label), ) def _draw_terminal( @@ -1221,9 +1200,6 @@ def build_hud_frames( rig_poses_world: npt.NDArray[np.float32], *, transition_timestamps_us: Sequence[int | None] | None = None, - input_transition_count: int = 0, - input_ignored_event_count: int = 0, - input_coalesced_transition_count: int = 0, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) @@ -1248,9 +1224,6 @@ def build_hud_frames( snapshot=snapshot, rig_pose_world=pose, transition_timestamp_us=transition_timestamps_us[index], - input_transition_count=input_transition_count, - input_ignored_event_count=input_ignored_event_count, - input_coalesced_transition_count=input_coalesced_transition_count, ) ) return tuple(frames) diff --git a/apps/crazy_robotaxi/pyproject.toml b/apps/crazy_robotaxi/pyproject.toml index 6f517e423..8ac2da5a3 100644 --- a/apps/crazy_robotaxi/pyproject.toml +++ b/apps/crazy_robotaxi/pyproject.toml @@ -10,7 +10,6 @@ readme = "README.md" requires-python = ">=3.10,<3.13" dependencies = [ "flashdreams[local-window]", - "flashdreams-omnidreams", "omnidreams-game-engine", "torch", ] @@ -18,12 +17,8 @@ dependencies = [ [project.scripts] crazy-robotaxi-map = "crazy_robotaxi.map_tool:main" -[project.entry-points."flashdreams.applications_v2"] -crazy-robotaxi = "crazy_robotaxi:create_app" - [tool.uv.sources] flashdreams = { workspace = true } -flashdreams-omnidreams = { workspace = true } omnidreams-game-engine = { workspace = true } [project.optional-dependencies] @@ -35,7 +30,6 @@ include = ["crazy_robotaxi*"] [tool.setuptools.package-data] crazy_robotaxi = [ "assets/*.npz", - "configs/*.yaml", "maps/*.robotaxi.yaml", ] diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 3cdc9ea1b..2aac78063 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -13,8 +13,7 @@ import pytest import torch from crazy_robotaxi.application import ( - _FAST_PERF_PIPELINE, - _MODEL_PRESETS, + CrazyRobotaxiApplicationDefaults, CrazyRobotaxiApplication, _fit_bev_renderer_to_ui, ) @@ -29,7 +28,13 @@ _taxi_driver_command, ) from crazy_robotaxi.ui import CrazyRobotaxiImGuiUILoop +from omnidreams.apps.crazy_robotaxi.adapter import ( + OMNIDREAMS_CRAZY_ROBOTAXI_DEFAULTS, + OMNIDREAMS_CRAZY_ROBOTAXI_FAST_PERF_DEFAULTS, + OMNIDREAMS_CRAZY_ROBOTAXI_PERF_DEFAULTS, +) from omnidreams.config import ( + OMNIDREAMS_FAST_PERF_PIPELINE_CONFIG, OMNIDREAMS_PERF_PIPELINE_CONFIG, OMNIDREAMS_PIPELINE_CONFIG, ) @@ -49,6 +54,7 @@ from flashdreams.runtime_v2.session_desc import PresentationMode from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( + GamepadUserInputEvent, KeyboardInputState, KeyboardUserInputEvent, ) @@ -61,10 +67,18 @@ Path(__file__).parents[1] / "crazy_robotaxi" / "maps" - / "demo_race_track.robotaxi.yaml" + / "flashdreams_raceway.robotaxi.yaml" ) +def _application( + *, + defaults: CrazyRobotaxiApplicationDefaults = OMNIDREAMS_CRAZY_ROBOTAXI_DEFAULTS, + **kwargs: Any, +) -> CrazyRobotaxiApplication: + return CrazyRobotaxiApplication(defaults=defaults, **kwargs) + + def _scene(*, width: int = 1280, height: int = 704) -> SceneDefinition: calibration = CameraCalibration( clipgt_name="front", @@ -96,8 +110,11 @@ def _scene(*, width: int = 1280, height: int = 704) -> SceneDefinition: def test_application_registers_model_and_imgui_ui_loops() -> None: pipeline = object() - app = CrazyRobotaxiApplication( - pipeline_factory=lambda config, device: pipeline, + pipeline_requests: list[tuple[object, str]] = [] + app = _application( + pipeline_factory=lambda config, device: ( + pipeline_requests.append((config, device)) or pipeline + ), scene_factory=lambda request, raster: _scene(), ) desc = app.session_desc() @@ -124,13 +141,14 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert isinstance(model_loop, CrazyRobotaxiModelLoop) assert isinstance(ui_loop, CrazyRobotaxiImGuiUILoop) assert session._presentation_manager._presentation_stream is None - assert model_loop.state.pipeline is pipeline + assert model_loop.state.pipeline is None + assert pipeline_requests == [] assert model_loop.state.scene is None assert model_loop.state.rollout is None assert not model_loop.state.game_selected assert model_loop.state.ui_loop is ui_loop assert ui_loop.state.model_loop is model_loop - assert len(ui_loop.state.map_options) >= 4 + assert len(ui_loop.state.map_options) == 2 assert ui_loop.state.map_options[0].path.name == "boulevard_district.robotaxi.yaml" assert ui_loop.state.profile_input_latency assert ui_loop.state.show_fps @@ -140,7 +158,7 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: menu_results = model_loop.step(0, UserInputEvents([])) assert len(menu_results) == 1 assert menu_results[0].frame_count == 1 - assert torch.all(menu_results[0].output == -1.0) + assert torch.all(menu_results[0].read_output() == -1.0) rollout_closed: list[bool] = [] model_loop.state.scene = _scene() @@ -158,7 +176,7 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: def test_complete_cli_game_selection_starts_without_menus() -> None: - app = CrazyRobotaxiApplication( + app = _application( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), ) @@ -215,7 +233,7 @@ def present_frame(self, frame: torch.Tensor) -> bool: def close(self) -> None: self.closed = True - desc = CrazyRobotaxiApplication().session_desc() + desc = _application().session_desc() presenter = Presenter() presenter_arguments: dict[str, object] = {} @@ -276,6 +294,30 @@ def test_pressed_r_requests_a_v2_game_restart() -> None: assert not _restart_requested(UserInputEvents([released])) +def test_pressed_gamepad_start_requests_a_v2_game_restart() -> None: + released = (False,) * 10 + pressed = (*released[:9], True) + + assert _restart_requested( + UserInputEvents( + [ + GamepadUserInputEvent( + timestamp=np.uint64(1), action="state", pressed=pressed + ) + ] + ) + ) + assert not _restart_requested( + UserInputEvents( + [ + GamepadUserInputEvent( + timestamp=np.uint64(2), action="state", pressed=released + ) + ] + ) + ) + + def test_model_input_is_applied_before_rollout_work() -> None: class InputOrderVerified(Exception): pass @@ -304,20 +346,19 @@ def ensure_rollout(self) -> None: def test_taxi_space_key_restores_handbrake_over_shared_input_mapping() -> None: driver_input = DriverInput() - driver_input.reduce( + driver_input.apply( UserInputEvents( [ KeyboardUserInputEvent( timestamp=np.uint64(1), - key="space", + key=" ", state=KeyboardInputState.PRESSED, ) ] ), - frame_count=1, ) - command = _taxi_driver_command(driver_input) + command = _taxi_driver_command(driver_input.command()) assert command.handbrake assert not command.stop @@ -350,6 +391,7 @@ def _invoke_async(self, operation) -> None: ) ui_loop = UILoop() state = ModelState( + pipeline_factory=lambda: object(), pipeline=object(), scene_factory=cast(Any, lambda request, raster: object()), scene=cast(Any, object()), @@ -396,40 +438,53 @@ def test_pipeline_profiling_is_an_app_local_opt_in( expected: bool, ) -> None: configured = [] - app = CrazyRobotaxiApplication( + app = _application( pipeline_factory=lambda config, device: configured.append(config) or object(), scene_factory=lambda request, raster: _scene(), ) app.init(arguments) - app.create_session(app.session_desc()) + session = app.create_session(app.session_desc()) + session.init() + _, model_loop = session._take_loops() + assert configured == [] + model_loop.state.pipeline_factory() assert configured[0].enable_sync_and_profile is expected assert app._config is not None assert app._config.pipeline_profiling is expected - assert _MODEL_PRESETS["standard"].pipeline.enable_sync_and_profile + assert OMNIDREAMS_PIPELINE_CONFIG.enable_sync_and_profile -def test_existing_model_presets_keep_their_packaged_pipeline_configs() -> None: - assert _MODEL_PRESETS["standard"].pipeline is OMNIDREAMS_PIPELINE_CONFIG - assert _MODEL_PRESETS["perf"].pipeline is OMNIDREAMS_PERF_PIPELINE_CONFIG - assert _MODEL_PRESETS["fast-perf"].pipeline is _FAST_PERF_PIPELINE - assert not _MODEL_PRESETS["standard"].renderer_follows_session - assert not _MODEL_PRESETS["perf"].renderer_follows_session +def test_model_adapters_keep_their_packaged_pipeline_configs() -> None: + assert ( + OMNIDREAMS_CRAZY_ROBOTAXI_DEFAULTS.pipeline_config is OMNIDREAMS_PIPELINE_CONFIG + ) + assert ( + OMNIDREAMS_CRAZY_ROBOTAXI_PERF_DEFAULTS.pipeline_config + is OMNIDREAMS_PERF_PIPELINE_CONFIG + ) + assert ( + OMNIDREAMS_CRAZY_ROBOTAXI_FAST_PERF_DEFAULTS.pipeline_config + is OMNIDREAMS_FAST_PERF_PIPELINE_CONFIG + ) def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: - preset = _MODEL_PRESETS["fast-perf"] - pipeline = preset.pipeline - assert preset.renderer_follows_session - assert pipeline.name == "crazy-robotaxi-fast-perf" + pipeline = OMNIDREAMS_FAST_PERF_PIPELINE_CONFIG + assert pipeline.name == "omnidreams-fast-perf" assert pipeline.diffusion_model.seed is None - assert pipeline.decoder.use_compile is True + assert ( + pipeline.decoder.use_compile + is OMNIDREAMS_PERF_PIPELINE_CONFIG.decoder.use_compile + ) assert pipeline.decoder.use_cuda_graph is True assert pipeline.image_encoder.native_vae_acceleration == "required" assert pipeline.image_encoder.native_vae_backend == "fp8" + assert pipeline.image_encoder.native_vae_fp8_auto_export is True assert pipeline.encoder.native_vae_acceleration == "required" assert pipeline.encoder.native_vae_backend == "fp8" + assert pipeline.encoder.native_vae_fp8_auto_export is True assert pipeline.diffusion_model.transformer.native_dit_acceleration == "required" assert ( pipeline.diffusion_model.transformer.native_dit_backend == "fp8_kvcache_cudnn" @@ -438,7 +493,7 @@ def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: @pytest.mark.parametrize("resolution_wh", [(1280, 704), (1168, 640)]) -def test_app_owned_perf_presets_adapt_renderer_to_session_geometry( +def test_adapter_dimensions_configure_renderer_geometry( resolution_wh: tuple[int, int], ) -> None: configured: list[object] = [] @@ -450,11 +505,16 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: raster_sizes.append(size) return _scene(width=size[0], height=size[1]) - app = CrazyRobotaxiApplication( + app = _application( + defaults=replace( + OMNIDREAMS_CRAZY_ROBOTAXI_FAST_PERF_DEFAULTS, + width=resolution_wh[0], + height=resolution_wh[1], + ), pipeline_factory=lambda config, device: configured.append(config) or object(), scene_factory=load_test_scene, ) - app.init(["--model-preset", "fast-perf", "--device", "cpu"]) + app.init(["--device", "cpu"]) desc = replace( app.session_desc(), video_width=resolution_wh[0], @@ -469,6 +529,8 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: GameSelection(mode="taxi", map_option=session._map_options[0]) ) + assert configured == [] + model_loop.state.pipeline_factory() assert configured == [app._pipeline_config] assert raster_sizes == [resolution_wh] assert session._config.renderer.raster.resolution_wh == resolution_wh @@ -484,12 +546,12 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: def test_fast_perf_honors_explicit_pipeline_overrides() -> None: - app = CrazyRobotaxiApplication() + app = _application( + defaults=OMNIDREAMS_CRAZY_ROBOTAXI_FAST_PERF_DEFAULTS, + ) app.init( [ - "--model-preset", - "fast-perf", "--seed", "7", "--no-compile", @@ -538,7 +600,7 @@ def test_input_latency_profiling_is_an_app_local_opt_in( arguments: list[str], expected: bool, ) -> None: - app = CrazyRobotaxiApplication() + app = _application() app.init(arguments) @@ -558,7 +620,7 @@ def test_fps_counter_is_an_app_local_option( arguments: list[str], expected: bool, ) -> None: - app = CrazyRobotaxiApplication() + app = _application() app.init(arguments) @@ -568,7 +630,7 @@ def test_fps_counter_is_an_app_local_option( @pytest.mark.parametrize("prewarm_blocks", [0, 4, 7]) def test_application_configures_prepresentation_warmup(prewarm_blocks: int) -> None: - app = CrazyRobotaxiApplication( + app = _application( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), ) @@ -579,7 +641,7 @@ def test_application_configures_prepresentation_warmup(prewarm_blocks: int) -> N def test_application_rejects_negative_prewarm_blocks() -> None: - app = CrazyRobotaxiApplication() + app = _application() with pytest.raises(ValueError, match="must be non-negative"): app.init(["--prewarm-blocks", "-1"]) @@ -606,7 +668,7 @@ def close(self) -> None: return monkeypatch.setattr("crazy_robotaxi.session.WorldModelRollout", FakeRollout) - app = CrazyRobotaxiApplication( + app = _application( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), ) @@ -681,7 +743,7 @@ def test_taxi_physics_forwards_forced_controller_refresh() -> None: def test_application_rejects_geometry_the_model_does_not_produce() -> None: - app = CrazyRobotaxiApplication( + app = _application( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), ) @@ -701,7 +763,7 @@ def test_application_rejects_geometry_the_model_does_not_produce() -> None: def test_application_rejects_mismatched_generation_rate() -> None: - app = CrazyRobotaxiApplication( + app = _application( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), ) @@ -712,7 +774,7 @@ def test_application_rejects_mismatched_generation_rate() -> None: def test_application_forces_continuous_presentation_for_interactive_input() -> None: - app = CrazyRobotaxiApplication( + app = _application( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), ) diff --git a/apps/crazy_robotaxi/tests/test_layered_settings.py b/apps/crazy_robotaxi/tests/test_layered_settings.py deleted file mode 100644 index f48bd34ba..000000000 --- a/apps/crazy_robotaxi/tests/test_layered_settings.py +++ /dev/null @@ -1,168 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - -"""CPU tests for layered Crazy Robotaxi settings.""" - -from pathlib import Path - -import pytest -from crazy_robotaxi.application import CrazyRobotaxiApplication -from crazy_robotaxi.config import load_game_settings -from omnidreams_game_engine.engine_settings import load_engine_settings -from omnidreams_game_engine.yaml_config import StrictConfigError - -pytestmark = pytest.mark.ci_cpu - -_CONFIGS = Path(__file__).parents[1] / "crazy_robotaxi" / "configs" - - -def test_shipped_example_configs_load() -> None: - engine = load_engine_settings(_CONFIGS / "example_engine_config.yaml") - game = load_game_settings(_CONFIGS / "example_game_config.yaml") - - assert ( - engine.map.path - == (_CONFIGS.parent / "maps" / "boulevard_district.robotaxi.yaml").resolve() - ) - assert engine.world_model.model_preset == "standard" - assert game.game.vehicle.max_steer_rad == pytest.approx(0.69) - - -def test_partial_game_config_retains_defaults_and_resolves_paths( - tmp_path: Path, -) -> None: - config_path = tmp_path / "game.yaml" - config_path.write_text( - """\ -schema_version: 1 -rules: - global_time_s: 90 -taxi: - seed: 17 - high_scores_path: scores.csv -live_edit: - coins: - enabled: true -""", - encoding="utf-8", - ) - - settings = load_game_settings(config_path) - - assert settings.game.global_time_s == pytest.approx(90.0) - assert settings.game.pickup_radius_m == pytest.approx(5.0) - assert settings.game.seed == 17 - assert settings.game.high_scores_path == (tmp_path / "scores.csv").resolve() - assert settings.live_edit.coins.enabled - - -def test_partial_engine_config_retains_defaults_and_resolves_paths( - tmp_path: Path, -) -> None: - config_path = tmp_path / "engine.yaml" - config_path.write_text( - """\ -schema_version: 1 -map: - path: maps/test.robotaxi.yaml -rendering: - bev: - height_m: 42 -presentation: - show_fps: true -runtime: - prewarm_blocks: 2 -""", - encoding="utf-8", - ) - - settings = load_engine_settings(config_path) - - assert settings.map.path == (tmp_path / "maps/test.robotaxi.yaml").resolve() - assert settings.rendering.raster.width == 1280 - assert settings.rendering.bev.height_m == pytest.approx(42.0) - assert settings.presentation.show_fps - assert settings.runtime.prewarm_blocks == 2 - - -def test_explicit_cli_overrides_engine_yaml(tmp_path: Path) -> None: - config_path = tmp_path / "engine.yaml" - config_path.write_text( - """\ -schema_version: 1 -world_model: - model_preset: perf - device: cuda -presentation: - show_fps: true -runtime: - prewarm_blocks: 1 - profile_world_model: true - profile_input_latency: false -""", - encoding="utf-8", - ) - app = CrazyRobotaxiApplication() - - app.init( - [ - "--engine-config", - str(config_path), - "--model-preset", - "standard", - "--device", - "cpu", - "--prewarm-blocks", - "3", - "--profile-input-latency", - "--no-show-fps", - ] - ) - - assert app._config is not None - assert app._config.model_preset_name == "standard" - assert app._config.device == "cpu" - assert app._config.prewarm_blocks == 3 - assert app._config.profile_input_latency - assert not app._config.show_fps - assert app._config.pipeline_profiling - - -def test_unknown_layered_key_is_rejected(tmp_path: Path) -> None: - config_path = tmp_path / "game.yaml" - config_path.write_text( - "schema_version: 1\nrules:\n not_a_rule: 1\n", - encoding="utf-8", - ) - - with pytest.raises(StrictConfigError, match="not_a_rule"): - load_game_settings(config_path) - - -def test_game_yaml_flags_and_inactive_race_settings_are_layered( - tmp_path: Path, -) -> None: - config_path = tmp_path / "game.yaml" - config_path.write_text( - """\ -schema_version: 1 -effects: - visual_flare_enabled: true -race: - course: future-course - times_path: race-times.csv -""", - encoding="utf-8", - ) - yaml_app = CrazyRobotaxiApplication() - cli_app = CrazyRobotaxiApplication() - - yaml_app.init(["--game-config", str(config_path)]) - cli_app.init(["--game-config", str(config_path), "--no-visual-flare"]) - - assert yaml_app._config is not None - assert yaml_app._config.game_mode == "taxi" - assert yaml_app._config.race_course_id == "future-course" - assert yaml_app._config.visual_flare_enabled - assert cli_app._config is not None - assert not cli_app._config.visual_flare_enabled diff --git a/apps/crazy_robotaxi/tests/test_maps.py b/apps/crazy_robotaxi/tests/test_maps.py index e313fed6a..58dda9676 100644 --- a/apps/crazy_robotaxi/tests/test_maps.py +++ b/apps/crazy_robotaxi/tests/test_maps.py @@ -15,7 +15,7 @@ @pytest.mark.parametrize( "filename", - ["minimal_loop.robotaxi.yaml", "boulevard_district.robotaxi.yaml"], + ["boulevard_district.robotaxi.yaml", "flashdreams_raceway.robotaxi.yaml"], ) def test_shipped_map_is_valid(filename: str) -> None: path = Path(__file__).parents[1] / "crazy_robotaxi" / "maps" / filename diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 9e324fa58..f25d84956 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -288,15 +288,9 @@ def test_hud_frames_preserve_frame_aligned_input_diagnostics() -> None: snapshots, poses, transition_timestamps_us=(100, 200), - input_transition_count=4, - input_ignored_event_count=2, - input_coalesced_transition_count=1, ) assert [frame.transition_timestamp_us for frame in frames] == [100, 200] - assert frames[0].input_transition_count == 4 - assert frames[0].input_ignored_event_count == 2 - assert frames[0].input_coalesced_transition_count == 1 def test_hud_frames_reject_misaligned_input_diagnostics() -> None: @@ -458,9 +452,10 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: result = loop.step(0, UserInputEvents([])) + output = result.read_output() assert changed - assert result.output.shape == (1, 3, height, width) - assert result.output.dtype is torch.float32 + assert output.shape == (1, 3, height, width) + assert output.dtype is torch.float32 assert hud_state._current is not None assert "SCORE 001200 HIGH 009000" in renderer.ui.windows["Crazy Robotaxi"] assert "Navigation" not in renderer.ui.windows @@ -478,9 +473,9 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert "triangle_filled" in command_names assert "circle_filled" in command_names top, left, panel_height, panel_width = hud_state._bev_rect or (0, 0, 0, 0) - panel = result.output[0, :, top : top + panel_height, left : left + panel_width] + panel = output[0, :, top : top + panel_height, left : left + panel_width] assert torch.allclose(panel, torch.full_like(panel, 191.0 / 127.5 - 1.0)) - outside = result.output[0].clone() + outside = output[0].clone() outside[:, top : top + panel_height, left : left + panel_width] = -0.5 assert torch.all(outside == video[0]) @@ -832,9 +827,6 @@ def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: (_snapshot(),), np.eye(4, dtype=np.float32)[None], transition_timestamps_us=(100,), - input_transition_count=1, - input_ignored_event_count=2, - input_coalesced_transition_count=0, ) ) @@ -846,8 +838,6 @@ def test_input_latency_profile_correlates_ui_event_with_model_frame() -> None: diagnostics = imgui.windows["Input Latency"] assert "A [X]" in diagnostics[0] assert "UI TO MODEL FRAME" in diagnostics[1] - assert "TRANSITIONS 1" in diagnostics[2] - assert "IGNORED 2" in diagnostics[2] state.reset() assert not state._profile_pressed @@ -879,7 +869,6 @@ def test_input_latency_profile_correlates_gamepad_state() -> None: (_snapshot(),), np.eye(4, dtype=np.float32)[None], transition_timestamps_us=(200,), - input_transition_count=1, ) ) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py b/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py index 59b0e435b..92420f0cf 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py @@ -48,15 +48,9 @@ class WorldModelLaunchSettings: backend: Literal["raster", "omnidreams"] = "omnidreams" """Main-camera backend; the V2 game requires ``omnidreams``.""" - manifest: Path | None = None - """Optional legacy manifest retained for configuration compatibility.""" - offload_text_encoder: bool = False """Whether one-shot encoders may be released after initialization.""" - model_preset: str = "standard" - """V2 packaged pipeline preset.""" - device: str = "cuda" """Device used to instantiate the V2 pipeline.""" diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index 7ed47d785..8e4759b3a 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -1,13 +1,16 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Latest-state V2 input handling for model-thread driving.""" +"""Retained V2 input state for model-thread driving.""" from __future__ import annotations from dataclasses import dataclass, field +from flashdreams.runtime.keyboard import normalize_key +from flashdreams.runtime_v2.input_timeline import InputWindow from flashdreams.runtime_v2.user_input_event import ( + FocusUserInputEvent, GamepadUserInputEvent, GameWheelUserInputEvent, KeyboardInputState, @@ -18,32 +21,17 @@ @dataclass(frozen=True, slots=True) -class DriverInputBatch: - """Latest driving command repeated across one model step.""" +class _DriverTransition: + """One timestamped change to the effective driving command.""" - commands: tuple[DriverCommand, ...] - """Frame-aligned copies of the current driving command.""" - - transition_timestamps_us: tuple[int | None, ...] - """Newest state transition on the first frame; ``None`` otherwise.""" - - transition_count: int - """Number of active driving-state transitions applied from this event batch.""" - - coalesced_transition_count: int - """Earlier transitions collapsed into the latest state for this model step.""" - - ignored_event_count: int - """Relevant input events that did not change the active driving state.""" + timestamp_s: float + timestamp_us: int + command: DriverCommand @dataclass(slots=True) class DriverInput: - """Current keyboard, gamepad, or wheel driving state for model chunks. - - Unread edges are applied in order, and the resulting command conditions the - whole next model chunk. - """ + """Driving input state owned and updated by one runtime loop.""" pressed_keys: set[str] = field(default_factory=set) """Normalized keyboard driving keys currently held down.""" @@ -51,48 +39,68 @@ class DriverInput: controller_command: DriverCommand | None = None """Latest wheel or gamepad command; ``None`` enables keyboard input.""" - def reduce( - self, - events: UserInputEvents, - *, - frame_count: int, - ) -> DriverInputBatch: - """Apply ``events`` and repeat the latest command for every model frame.""" - if frame_count <= 0: - raise ValueError("frame_count must be positive") - transition_count = 0 - ignored_event_count = 0 - latest_transition_timestamp_us: int | None = None + _sampled_command: DriverCommand = field( + default_factory=DriverCommand, + init=False, + repr=False, + ) + _pending_transitions: list[_DriverTransition] = field( + default_factory=list, + init=False, + repr=False, + ) + + def apply(self, events: UserInputEvents) -> tuple[float, ...]: + """Retain new input and return command-transition times in seconds.""" + input_times_s: list[float] = [] for event in events.get_events(): before = self.command() if not self._apply_event(event): - if isinstance( - event, - ( - KeyboardUserInputEvent, - GamepadUserInputEvent, - GameWheelUserInputEvent, - ), - ): - ignored_event_count += 1 continue - if self.command() == before: - ignored_event_count += 1 + command = self.command() + if command == before: continue - transition_count += 1 - latest_transition_timestamp_us = int(event.get_timestamp()) + timestamp_us = int(event.get_timestamp()) + timestamp_s = timestamp_us / 1_000_000.0 + self._pending_transitions.append( + _DriverTransition(timestamp_s, timestamp_us, command) + ) + input_times_s.append(timestamp_s) + return tuple(input_times_s) - command = self.command() - transition_timestamps_us = (latest_transition_timestamp_us,) + (None,) * ( - frame_count - 1 - ) - return DriverInputBatch( - commands=(command,) * frame_count, - transition_timestamps_us=transition_timestamps_us, - transition_count=transition_count, - coalesced_transition_count=max(0, transition_count - 1), - ignored_event_count=ignored_event_count, + def sample( + self, + window: InputWindow, + ) -> tuple[tuple[DriverCommand, ...], tuple[int | None, ...]]: + """Quantize retained transitions to frame starts in ``window``.""" + transitions = sorted( + self._pending_transitions, + key=lambda transition: transition.timestamp_s, ) + self._pending_transitions.clear() + command = self._sampled_command + transition_index = 0 + commands: list[DriverCommand] = [] + transition_timestamps_us: list[int | None] = [] + # ponytail: controls quantize to one physics frame; integrate timed + # segments here if sub-frame inputs become necessary. + frame_starts_s = (window.start_s, *window.sample_times_s[:-1]) + for frame_start_s in frame_starts_s: + latest_timestamp_us: int | None = None + while ( + transition_index < len(transitions) + and transitions[transition_index].timestamp_s <= frame_start_s + ): + transition = transitions[transition_index] + command = transition.command + latest_timestamp_us = transition.timestamp_us + transition_index += 1 + commands.append(command) + transition_timestamps_us.append(latest_timestamp_us) + + self._pending_transitions.extend(transitions[transition_index:]) + self._sampled_command = command + return tuple(commands), tuple(transition_timestamps_us) def command(self) -> DriverCommand: """Return the command represented by the current retained input state.""" @@ -107,12 +115,18 @@ def source(self) -> str: return "keyboard" if self.pressed_keys else "idle" def reset(self) -> None: - """Clear every retained input value.""" + """Clear retained, sampled, and pending driving input.""" self.pressed_keys.clear() self.controller_command = None + self._sampled_command = DriverCommand() + self._pending_transitions.clear() def _apply_event(self, event: object) -> bool: - """Apply one event using controller-over-keyboard precedence.""" + if isinstance(event, FocusUserInputEvent): + if event.focused: + return False + self.pressed_keys.clear() + return True if isinstance(event, KeyboardUserInputEvent): key = _normalize_drive_key(event.key) if key is None: @@ -143,33 +157,25 @@ def _apply_event(self, event: object) -> bool: def _keyboard_command(pressed_keys: set[str]) -> DriverCommand: """Map retained keyboard state to a simulation command.""" - forward = bool({"w", "up"} & pressed_keys) - reverse = bool({"s", "down"} & pressed_keys) - opposing_directions = forward and reverse - throttle = 1.0 if forward != reverse else 0.0 + forward = "w" in pressed_keys + reverse = "s" in pressed_keys + brake = "space" in pressed_keys steer = 0.0 - if {"a", "left"} & pressed_keys: + if "a" in pressed_keys: steer += 1.0 - if {"d", "right"} & pressed_keys: + if "d" in pressed_keys: steer -= 1.0 return DriverCommand( - throttle=throttle, - brake=1.0 if opposing_directions else 0.0, + throttle=1.0 if forward != reverse and not brake else 0.0, + brake=1.0 if brake else 0.0, steer=steer, - stop="space" in pressed_keys, reverse=reverse and not forward, + manual_control=brake, ) def _normalize_drive_key(key: str) -> str | None: - key = key.strip().lower() - aliases = { - "arrowup": "w", - "arrowdown": "s", - "arrowleft": "a", - "arrowright": "d", - } - key = aliases.get(key, key) + key = normalize_key(key) return key if key in {"w", "a", "s", "d", "space"} else None diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py index f2ebea593..db6fca708 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py @@ -15,7 +15,7 @@ import pyarrow.parquet as pq import yaml from loguru import logger -from omnidreams.impl.scenes import ( +from interactive_drive.scene_loader import ( SCENE_FRAME_SUFFIXES, SCENE_FRAMES_DIRNAME, prompt_variant_for_scene_variant, diff --git a/apps/omnidreams_game_engine/pyproject.toml b/apps/omnidreams_game_engine/pyproject.toml index 37d0cf1a9..25010a1c1 100644 --- a/apps/omnidreams_game_engine/pyproject.toml +++ b/apps/omnidreams_game_engine/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10,<3.13" dependencies = [ "filelock>=3", "flashdreams", - "flashdreams-omnidreams", + "flashdreams-interactive-drive-v2", "ludus-renderer", "numpy", "pillow", @@ -23,7 +23,7 @@ dependencies = [ [tool.uv.sources] flashdreams = { workspace = true } -flashdreams-omnidreams = { workspace = true } +flashdreams-interactive-drive-v2 = { workspace = true } ludus-renderer = { workspace = true } [project.optional-dependencies] diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index 46a3a64ac..f36e30d25 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -1,16 +1,19 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""CPU tests for V2 event reduction.""" +"""CPU tests for V2 retained driving input.""" from dataclasses import replace import numpy as np import pytest from omnidreams_game_engine.input import DriverInput +from omnidreams_game_engine.types import DriverCommand from flashdreams.api_v2.user_input_event import UserInputEvent +from flashdreams.runtime_v2.input_timeline import RealtimeInputTimeline from flashdreams.runtime_v2.user_input_event import ( + FocusUserInputEvent, GamepadUserInputEvent, GameWheelUserInputEvent, KeyboardInputState, @@ -30,142 +33,68 @@ def _events(*events: UserInputEvent) -> UserInputEvents: ) -def _timed_events( - *events: tuple[int, UserInputEvent], -) -> UserInputEvents: - return UserInputEvents( - [ - replace(event, timestamp=np.uint64(timestamp_us)) - for timestamp_us, event in events - ] - ) - - def _key(key: str, state: KeyboardInputState) -> KeyboardUserInputEvent: return KeyboardUserInputEvent(timestamp=np.uint64(0), key=key, state=state) -def test_held_keys_produce_one_command_per_model_frame() -> None: - reducer = DriverInput() - - first = reducer.reduce( +def test_held_keyboard_state_survives_empty_model_event_batches() -> None: + state = DriverInput() + state.apply( _events( - _key("w", KeyboardInputState.PRESSED), _key("a", KeyboardInputState.PRESSED) - ), - frame_count=3, - ) - retained = reducer.reduce( - UserInputEvents([]), - frame_count=1, - ) - - assert len(first.commands) == 3 - assert all(command == first.commands[0] for command in first.commands) - assert first.commands[0].steer == 1.0 - assert not first.commands[0].steer_is_direct - assert not first.commands[0].manual_control - assert all(command.throttle == 1.0 for command in first.commands) - assert retained.commands[0] == first.commands[0] - assert first.transition_timestamps_us == (1, None, None) - assert first.transition_count == 2 - assert first.coalesced_transition_count == 1 - assert retained.transition_timestamps_us == (None,) - - -def test_reverse_key_matches_interactive_drive_command() -> None: - reducer = DriverInput() - reverse = reducer.reduce( - _events(_key("ArrowDown", KeyboardInputState.PRESSED)), - frame_count=1, - ) - - assert reverse.commands[0].brake == 0.0 - assert reverse.commands[0].throttle == 1.0 - assert reverse.commands[0].reverse - - released = reducer.reduce( - _events(_key("ArrowDown", KeyboardInputState.RELEASED)), - frame_count=1, - ) - - assert released.commands[0].throttle == 0.0 - assert not released.commands[0].reverse - - -def test_short_press_and_release_collapse_to_latest_state() -> None: - reducer = DriverInput() - - result = reducer.reduce( - _timed_events( - (1_000_000, _key("w", KeyboardInputState.PRESSED)), - (1_100_000, _key("w", KeyboardInputState.RELEASED)), - ), - frame_count=5, + _key("w", KeyboardInputState.PRESSED), + _key("a", KeyboardInputState.PRESSED), + ) ) + first = state.command() - assert [command.throttle for command in result.commands] == [0.0] * 5 - assert result.transition_timestamps_us == ( - 1_100_000, - None, - None, - None, - None, - ) - assert result.transition_count == 2 - assert result.coalesced_transition_count == 1 - - -def test_duplicate_key_events_do_not_create_transitions() -> None: - reducer = DriverInput() + state.apply(UserInputEvents([])) - result = reducer.reduce( - _timed_events( - (10, _key("w", KeyboardInputState.PRESSED)), - (20, _key("w", KeyboardInputState.PRESSED)), - ), - frame_count=2, - ) + assert state.command() == first + assert first.throttle == 1.0 + assert first.steer == 1.0 + assert not first.steer_is_direct + assert not first.manual_control - assert result.transition_count == 1 - assert result.transition_timestamps_us == (10, None) - assert result.ignored_event_count == 1 +def test_arrow_keys_share_interactive_drive_mapping() -> None: + state = DriverInput() + state.apply(_events(_key("ArrowDown", KeyboardInputState.PRESSED))) -def test_space_key_requests_the_interactive_drive_stop_command() -> None: - reducer = DriverInput() + reverse = state.command() - result = reducer.reduce( - _timed_events((10, _key("space", KeyboardInputState.PRESSED))), - frame_count=1, - ) + assert reverse.brake == 0.0 + assert reverse.throttle == 1.0 + assert reverse.reverse - assert result.commands[0].stop - assert not result.commands[0].handbrake + state.apply(_events(_key("ArrowDown", KeyboardInputState.RELEASED))) + assert state.command().throttle == 0.0 + assert not state.command().reverse -def test_latest_state_conditions_the_entire_next_chunk() -> None: - reducer = DriverInput() +def test_browser_space_key_matches_controller_brake() -> None: + keyboard = DriverInput() + keyboard.apply(_events(_key(" ", KeyboardInputState.PRESSED))) - first = reducer.reduce( - _timed_events( - (10_000, _key("w", KeyboardInputState.PRESSED)), - (50_000, _key("w", KeyboardInputState.RELEASED)), - (90_000, _key("w", KeyboardInputState.PRESSED)), - ), - frame_count=2, + controller = DriverInput() + controller.apply( + UserInputEvents( + [ + GamepadUserInputEvent( + timestamp=np.uint64(20), + action="state", + axes=(0.0,), + buttons=(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0), + ) + ] + ) ) - retained = reducer.reduce(UserInputEvents([]), frame_count=1) - assert [command.throttle for command in first.commands] == [1.0, 1.0] - assert first.transition_timestamps_us == (90_000, None) - assert first.transition_count == 3 - assert first.coalesced_transition_count == 2 - assert retained.commands[0].throttle == 1.0 - assert retained.transition_timestamps_us == (None,) + assert keyboard.command().brake == controller.command().brake == 1.0 + assert keyboard.command().manual_control def test_gamepad_state_overrides_keyboard_until_disconnect() -> None: - reducer = DriverInput() + state = DriverInput() buttons = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25, 0.75) gamepad = GamepadUserInputEvent( timestamp=np.uint64(20), @@ -173,21 +102,18 @@ def test_gamepad_state_overrides_keyboard_until_disconnect() -> None: axes=(-0.5,), buttons=buttons, ) + state.apply(UserInputEvents([_key("w", KeyboardInputState.PRESSED), gamepad])) - controlled = reducer.reduce( - UserInputEvents([_key("w", KeyboardInputState.PRESSED), gamepad]), - frame_count=2, - ) + controlled = state.command() - assert controlled.commands[0].throttle == pytest.approx(0.75) - assert controlled.commands[0].brake == pytest.approx(0.25) - assert controlled.commands[0].steer == pytest.approx(0.5) - assert controlled.commands[0].steer_is_direct - assert controlled.commands[0].manual_control - assert controlled.commands[1] == controlled.commands[0] - assert reducer.source() == "wheel/gamepad" + assert controlled.throttle == pytest.approx(0.75) + assert controlled.brake == pytest.approx(0.25) + assert controlled.steer == pytest.approx(0.5) + assert controlled.steer_is_direct + assert controlled.manual_control + assert state.source() == "wheel/gamepad" - disconnected = reducer.reduce( + state.apply( UserInputEvents( [ GamepadUserInputEvent( @@ -195,19 +121,17 @@ def test_gamepad_state_overrides_keyboard_until_disconnect() -> None: action="disconnected", ) ] - ), - frame_count=1, + ) ) - assert disconnected.commands[0].throttle == 1.0 - assert not disconnected.commands[0].manual_control - assert reducer.source() == "keyboard" + assert state.command().throttle == 1.0 + assert not state.command().manual_control + assert state.source() == "keyboard" def test_wheel_state_uses_direct_pedal_and_steering_values() -> None: - reducer = DriverInput() - - result = reducer.reduce( + state = DriverInput() + state.apply( UserInputEvents( [ GameWheelUserInputEvent( @@ -218,13 +142,72 @@ def test_wheel_state_uses_direct_pedal_and_steering_values() -> None: brake=0.1, ) ] - ), - frame_count=2, + ) + ) + + command = state.command() + + assert command.steer == pytest.approx(0.4) + assert command.throttle == pytest.approx(0.8) + assert command.brake == pytest.approx(0.1) + assert command.steer_is_direct + assert command.manual_control + + +def test_timestamped_tap_is_preserved_across_physics_frames() -> None: + state = DriverInput() + timeline = RealtimeInputTimeline(samples_per_second=30.0) + input_times_s = state.apply( + UserInputEvents( + [ + replace( + _key("a", KeyboardInputState.PRESSED), + timestamp=np.uint64(1_000_000), + ), + replace( + _key("a", KeyboardInputState.RELEASED), + timestamp=np.uint64(1_100_000), + ), + ] + ) + ) + + commands, timestamps = state.sample( + timeline.next_window(5, input_times_s=input_times_s) + ) + + assert [command.steer for command in commands] == [1.0, 1.0, 1.0, 0.0, 0.0] + assert timestamps == (1_000_000, None, None, 1_100_000, None) + + +def test_focus_loss_releases_sampled_keyboard_input() -> None: + state = DriverInput() + timeline = RealtimeInputTimeline(samples_per_second=30.0) + pressed_at_s = state.apply( + UserInputEvents( + [ + replace( + _key("w", KeyboardInputState.PRESSED), + timestamp=np.uint64(1_000_000), + ) + ] + ) + ) + pressed, _ = state.sample(timeline.next_window(1, input_times_s=pressed_at_s)) + released_at_s = state.apply( + UserInputEvents( + [ + FocusUserInputEvent( + timestamp=np.uint64(9_000_000), + focused=False, + ) + ] + ) + ) + released, timestamps = state.sample( + timeline.next_window(1, input_times_s=released_at_s) ) - assert result.commands[0].steer == pytest.approx(0.4) - assert result.commands[0].throttle == pytest.approx(0.8) - assert result.commands[0].brake == pytest.approx(0.1) - assert result.commands[0].steer_is_direct - assert result.commands[0].manual_control - assert result.commands[1] == result.commands[0] + assert pressed[0].throttle == 1.0 + assert released[0] == DriverCommand() + assert timestamps == (9_000_000,) diff --git a/integrations_v2/omnidreams/apps/crazy_robotaxi/__init__.py b/integrations_v2/omnidreams/apps/crazy_robotaxi/__init__.py new file mode 100644 index 000000000..cf291d4fb --- /dev/null +++ b/integrations_v2/omnidreams/apps/crazy_robotaxi/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""OmniDreams Crazy Robotaxi application binding.""" diff --git a/integrations_v2/omnidreams/apps/crazy_robotaxi/adapter.py b/integrations_v2/omnidreams/apps/crazy_robotaxi/adapter.py new file mode 100644 index 000000000..59097c99b --- /dev/null +++ b/integrations_v2/omnidreams/apps/crazy_robotaxi/adapter.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""OmniDreams binding for the reusable Crazy Robotaxi application.""" + +from __future__ import annotations + +from crazy_robotaxi import CrazyRobotaxiApplication, CrazyRobotaxiApplicationDefaults +from omnidreams.config import ( + OMNIDREAMS_FAST_PERF_PIPELINE_CONFIG, + OMNIDREAMS_PERF_PIPELINE_CONFIG, + OMNIDREAMS_PIPELINE_CONFIG, +) + +from flashdreams.api_v2.application import IApplication + +OMNIDREAMS_CRAZY_ROBOTAXI_DEFAULTS = CrazyRobotaxiApplicationDefaults( + title="Crazy Robotaxi", + slug="crazy-robotaxi", + width=1280, + height=704, + pipeline_config=OMNIDREAMS_PIPELINE_CONFIG, +) +OMNIDREAMS_CRAZY_ROBOTAXI_PERF_DEFAULTS = CrazyRobotaxiApplicationDefaults( + title="Crazy Robotaxi (Perf)", + slug="crazy-robotaxi-perf", + width=1168, + height=640, + pipeline_config=OMNIDREAMS_PERF_PIPELINE_CONFIG, +) +OMNIDREAMS_CRAZY_ROBOTAXI_FAST_PERF_DEFAULTS = CrazyRobotaxiApplicationDefaults( + title="Crazy Robotaxi (Fast Perf)", + slug="crazy-robotaxi-fast-perf", + width=1168, + height=640, + pipeline_config=OMNIDREAMS_FAST_PERF_PIPELINE_CONFIG, +) + + +def create_app() -> IApplication: + """Create Crazy Robotaxi with the regular OmniDreams config.""" + return CrazyRobotaxiApplication(defaults=OMNIDREAMS_CRAZY_ROBOTAXI_DEFAULTS) + + +def create_perf_app() -> IApplication: + """Create Crazy Robotaxi with the performance OmniDreams config.""" + return CrazyRobotaxiApplication(defaults=OMNIDREAMS_CRAZY_ROBOTAXI_PERF_DEFAULTS) + + +def create_fast_perf_app() -> IApplication: + """Create Crazy Robotaxi with fast OmniDreams acceleration when available.""" + return CrazyRobotaxiApplication( + defaults=OMNIDREAMS_CRAZY_ROBOTAXI_FAST_PERF_DEFAULTS + ) + + +__all__ = [ + "OMNIDREAMS_CRAZY_ROBOTAXI_DEFAULTS", + "OMNIDREAMS_CRAZY_ROBOTAXI_FAST_PERF_DEFAULTS", + "OMNIDREAMS_CRAZY_ROBOTAXI_PERF_DEFAULTS", + "create_app", + "create_fast_perf_app", + "create_perf_app", +] diff --git a/integrations_v2/omnidreams/pyproject.toml b/integrations_v2/omnidreams/pyproject.toml index 99386a616..f125f7ca7 100644 --- a/integrations_v2/omnidreams/pyproject.toml +++ b/integrations_v2/omnidreams/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ # the model adapters for the reusable Interactive Drive application. "flashdreams[serving]", "flashdreams-interactive-drive-v2", + "crazy-robotaxi", "mediapy>=1.1", "ludus-renderer", "imageio>=2.20", @@ -69,6 +70,7 @@ dependencies = [ [tool.uv.sources] flashdreams = { workspace = true } flashdreams-interactive-drive-v2 = { workspace = true } +crazy-robotaxi = { workspace = true } ludus-renderer = { workspace = true } [project.optional-dependencies] @@ -101,12 +103,16 @@ omnidreams-prepare = "omnidreams.impl.tools.prepare:main" "interactive-drive-omnidreams" = "omnidreams.apps.interactive_drive.adapter:create_app" "interactive-drive-omnidreams-perf" = "omnidreams.apps.interactive_drive.adapter:create_perf_app" "interactive-drive-omnidreams-fast-perf" = "omnidreams.apps.interactive_drive.adapter:create_fast_perf_app" +"crazy-robotaxi-omnidreams" = "omnidreams.apps.crazy_robotaxi.adapter:create_app" +"crazy-robotaxi-omnidreams-perf" = "omnidreams.apps.crazy_robotaxi.adapter:create_perf_app" +"crazy-robotaxi-omnidreams-fast-perf" = "omnidreams.apps.crazy_robotaxi.adapter:create_fast_perf_app" [tool.setuptools] packages = [ "omnidreams", "omnidreams.apps", "omnidreams.apps.interactive_drive", + "omnidreams.apps.crazy_robotaxi", "omnidreams.impl", "omnidreams.impl.conditioning", "omnidreams.impl.conditioning.world_scenario", diff --git a/integrations_v2/omnidreams/tests/test_recipe_configs.py b/integrations_v2/omnidreams/tests/test_recipe_configs.py index 48cb3761e..d61e5ec80 100644 --- a/integrations_v2/omnidreams/tests/test_recipe_configs.py +++ b/integrations_v2/omnidreams/tests/test_recipe_configs.py @@ -132,4 +132,13 @@ def test_pyproject_registers_model_owned_app_adapters() -> None: "interactive-drive-omnidreams-fast-perf": ( "omnidreams.apps.interactive_drive.adapter:create_fast_perf_app" ), + "crazy-robotaxi-omnidreams": ( + "omnidreams.apps.crazy_robotaxi.adapter:create_app" + ), + "crazy-robotaxi-omnidreams-perf": ( + "omnidreams.apps.crazy_robotaxi.adapter:create_perf_app" + ), + "crazy-robotaxi-omnidreams-fast-perf": ( + "omnidreams.apps.crazy_robotaxi.adapter:create_fast_perf_app" + ), } diff --git a/uv.lock b/uv.lock index 48f0dd2c8..7f2df1162 100644 --- a/uv.lock +++ b/uv.lock @@ -24,7 +24,6 @@ members = [ "flashdreams-cam2v", "flashdreams-cam2v-lingbot", "flashdreams-causal-forcing", - "flashdreams-clipgt2v-v2", "flashdreams-color-fade", "flashdreams-cosmos-predict2", "flashdreams-fastvideo-causal-wan22", @@ -686,7 +685,6 @@ version = "0.1.0" source = { editable = "apps/crazy_robotaxi" } dependencies = [ { name = "flashdreams", extra = ["local-window"] }, - { name = "flashdreams-omnidreams" }, { name = "omnidreams-game-engine" }, { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform != 'win32' and extra == 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "torch", version = "2.12.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'win32' and extra == 'extra-11-flashdreams-dev') or (sys_platform != 'win32' and extra != 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, @@ -702,7 +700,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "flashdreams", extras = ["local-window"], editable = "flashdreams" }, - { name = "flashdreams-omnidreams", editable = "integrations_v2/omnidreams" }, { name = "omnidreams-game-engine", editable = "apps/omnidreams_game_engine" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-manual-marker", marker = "extra == 'dev'", specifier = ">=2.0" }, @@ -1219,21 +1216,6 @@ requires-dist = [ ] provides-extras = ["dev"] -[[package]] -name = "flashdreams-clipgt2v-v2" -version = "0.1.0" -source = { editable = "apps/clipgt2v" } -dependencies = [ - { name = "flashdreams", extra = ["local-window", "serving"] }, - { name = "ludus-renderer" }, -] - -[package.metadata] -requires-dist = [ - { name = "flashdreams", extras = ["local-window", "serving"], editable = "flashdreams" }, - { name = "ludus-renderer", editable = "integrations_v2/omnidreams/impl/ludus-renderer" }, -] - [[package]] name = "flashdreams-color-fade" version = "0.1.0" @@ -1445,6 +1427,7 @@ name = "flashdreams-omnidreams" version = "0.1.0" source = { editable = "integrations_v2/omnidreams" } dependencies = [ + { name = "crazy-robotaxi" }, { name = "einops" }, { name = "flashdreams", extra = ["serving"] }, { name = "flashdreams-interactive-drive-v2" }, @@ -1491,6 +1474,7 @@ rtx-postprocess = [ [package.metadata] requires-dist = [ + { name = "crazy-robotaxi", editable = "apps/crazy_robotaxi" }, { name = "einops", specifier = ">=0.8" }, { name = "flashdreams", extras = ["rtx-postprocess"], marker = "extra == 'rtx-postprocess'", editable = "flashdreams" }, { name = "flashdreams", extras = ["serving"], editable = "flashdreams" }, @@ -3423,7 +3407,7 @@ source = { editable = "apps/omnidreams_game_engine" } dependencies = [ { name = "filelock" }, { name = "flashdreams" }, - { name = "flashdreams-omnidreams" }, + { name = "flashdreams-interactive-drive-v2" }, { name = "ludus-renderer" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, @@ -3446,7 +3430,7 @@ dev = [ requires-dist = [ { name = "filelock", specifier = ">=3" }, { name = "flashdreams", editable = "flashdreams" }, - { name = "flashdreams-omnidreams", editable = "integrations_v2/omnidreams" }, + { name = "flashdreams-interactive-drive-v2", editable = "apps/interactive_drive" }, { name = "ludus-renderer", editable = "integrations_v2/omnidreams/impl/ludus-renderer" }, { name = "numpy" }, { name = "pillow" }, From ecad027e4d90315ef857e0a993248b425c733c23 Mon Sep 17 00:00:00 2001 From: Ariel Glasroth Date: Fri, 28 Aug 2026 12:45:32 +0000 Subject: [PATCH 44/62] get lora to work + document controls --- apps/crazy_robotaxi/README.md | 69 ++++++++++++---- .../crazy_robotaxi/application.py | 4 + .../crazy_robotaxi/live_edit/config.py | 82 ++++++++++++++++++- .../crazy_robotaxi/tests/test_live_edit_v2.py | 79 ++++++++++++++++++ 4 files changed, 212 insertions(+), 22 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index fb86b0c6a..242ccc887 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -14,6 +14,42 @@ inside a real ImGui `Map` window. `CrazyRobotaxiImGuiUILoop` returns the video back buffer and the base loop composites one ImGui overlay containing the background waypoints, HUD, and BEV window. +## Controls + +### Keyboard + +| Control | Action | +| --- | --- | +| `W` or Up Arrow | Drive forward. | +| `S` or Down Arrow | Reverse. | +| `A` or Left Arrow | Steer left. | +| `D` or Right Arrow | Steer right. | +| `Space` | Apply the handbrake and cancel throttle. | +| `R` | Restart the current game without reloading the model. | +| `Escape` | Go back: game → map, course → map, map → mode, then exit from the mode screen. | +| `C` | Toggle collectible coins when `--live-edit-coins` is enabled. | +| `K` | Cycle style skins when `--live-edit-style` is enabled. | +| `V` | Cycle weather when `--live-edit-weather` is enabled. Weather changes are ignored while a skin is active. | +| `O` | Spawn a crossing-obstacle event when `--live-edit-obstacle` is enabled. | +| `Enter` | Submit the leaderboard name while the Driver name field is focused. | + +Menu choices and the leaderboard Submit button can also be clicked with the mouse. + +### Controller + +| Control | Action | +| --- | --- | +| Left stick, push forward+tilt | Steer. | +| Right trigger (`RT` / `R2` / `ZR`) | Throttle. | +| Left trigger (`LT` / `L2` / `ZL`) | Brake. | +| Start / Menu / Plus | Restart the current game without reloading the model. | +| Steering wheel and pedals | Use normalized steering, throttle, and brake input directly. | + +A connected gamepad or wheel takes precedence over keyboard driving input. +Disconnecting it returns control to any keyboard keys still held. Gamepads do +not currently bind reverse, handbrake, menu navigation, or live-edit actions; +use the keyboard or mouse for those controls. + ```bash uv sync --package crazy-robotaxi uv run flashdreams-run-v2 crazy-robotaxi-omnidreams \ @@ -50,20 +86,11 @@ the selected course. The configured `--map` is listed first; the menu also discovers bundled maps and `.robotaxi.yaml` maps beside that configured file. Map compilation and scene loading begin only after the menu choice. -Use `flashdreams-run-v2 crazy-robotaxi-omnidreams -- --help` for the complete application -options. Drive with W/A/S/D or the arrow keys; Space is the handbrake. A -standard gamepad uses the left stick for steering and the right/left triggers -for throttle/brake. V2 game-wheel events use their normalized steering, -throttle, and brake values directly. A connected controller takes precedence -over keyboard state, and disconnecting it falls back to any keys still held. -Press R to restart without closing the window. Restart rebuilds simulation, -game rules, traffic, conditioning, and the autoregressive cache together while -retaining the loaded model. Press Escape to return from the game to map -selection, from map selection to mode selection, and from mode selection to -exit. The HUD keeps an always-visible bearing arrow and overlays visible -targets or an off-map direction arrow on the BEV. Leaderboard name entry is -owned by the Dear ImGui UI and submitted to the model loop through V2's -asynchronous loop-message contract. +Use `flashdreams-run-v2 crazy-robotaxi-omnidreams -- --help` for the complete +application options. Restart rebuilds simulation, game rules, traffic, +conditioning, and the autoregressive cache while retaining the loaded model. +The HUD keeps an always-visible bearing arrow and overlays visible targets or +an off-map direction arrow on the BEV. ## Race mode @@ -89,17 +116,23 @@ arguments. `C` toggles collectible coins, `K` cycles style skins, `V` cycles weather, and `O` spawns a crossing-obstacle event. Effect items can trigger rain, snow, a timed mystery skin, or a physics-authoritative nitro boost. +To launch with coins, effect items, weather effects, and the style LoRA: + ```bash uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode native-window -- \ --live-edit-coins \ --live-edit-items \ --live-edit-weather \ - --live-edit-obstacle + --live-edit-style ``` -Style mode additionally requires `--live-edit-style-lora PATH`. Optional -style/base/weather correctors retain the source branch's fused and unfused -deployment modes. Text-edit and obstacle guidance require a non-native DiT +On first launch, style mode downloads the v6 style LoRA, its recommended v5 +corrector and gate, and the base-world clean-forcing corrector into +`artifacts/crazy_robotaxi/live_edit`. Later launches reuse those files. The +`--live-edit-style-lora`, `--live-edit-style-corrector`, +`--live-edit-gate-alpha-json`, and `--live-edit-base-corrector` options override +individual assets. Weather remains LoRA-free unless a nonzero weather-corrector +gain is configured. Text-edit and obstacle guidance require a non-native DiT preset because those paths intercept the Python transformer conditioning seam; the application reports this before generation if an incompatible native preset is selected. All abilities are disabled by default. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 8a16dac92..6b9112cbe 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -36,6 +36,7 @@ LiveEditConfig, add_live_edit_args, live_edit_config_from_args, + resolve_live_edit_assets, ) from crazy_robotaxi.rules import TaxiGameConfig from crazy_robotaxi.session import CrazyRobotaxiSession @@ -231,6 +232,9 @@ def init(self, commandline_args: Sequence[str]) -> None: pipeline_config, enable_sync_and_profile=bool(engine_settings.world_model.profile_pipeline), ) + game_settings = replace( + game_settings, live_edit=resolve_live_edit_assets(game_settings.live_edit) + ) self._pipeline_config = pipeline_config self._config = ApplicationConfig( scene_request=SceneRequest( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py index eeec75519..949f5f188 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/config.py @@ -18,7 +18,26 @@ from omnidreams_game_engine.cli_args import arg_was_explicit +from flashdreams.core.io.download import download_to_cache + _CORRECTOR_MODES = ("fused", "unfused", "off") +_DEFAULT_ASSET_DIR = Path("artifacts/crazy_robotaxi/live_edit") +_STYLE_LORA_URL = ( + "https://github.com/wenqingw-nv/flashdreams-wq/releases/download/" + "style-skin-v6-multiskin/lora_style_v6_step1600.pt" +) +_STYLE_CORRECTOR_URL = ( + "https://github.com/wenqingw-nv/flashdreams-wq/releases/download/" + "style-skin-v5-stack/lora_style_corrector_v5_valpeak.pt" +) +_STYLE_GATE_URL = ( + "https://github.com/wenqingw-nv/flashdreams-wq/releases/download/" + "style-skin-v5-stack/gate_style_v5.json" +) +_BASE_CORRECTOR_URL = ( + "https://github.com/wenqingw-nv/flashdreams-wq/releases/download/" + "clean-forcing-omnidreams-v3vp/lora_v2_v3_valpeak.pt" +) @dataclass(frozen=True) @@ -149,8 +168,6 @@ class LiveEditStyleConfig: def __post_init__(self) -> None: """Validate style values at configuration time.""" - if self.enabled and self.lora_checkpoint is None: - raise ValueError("live_edit.style requires --live-edit-style-lora") if not 0.0 <= self.corrector_gain <= 1.0: raise ValueError("corrector_gain must be in [0, 1]") if self.corrector_mode not in _CORRECTOR_MODES: @@ -776,6 +793,63 @@ def __post_init__(self) -> None: raise ValueError("perf_log_every_frames must be non-negative") +def resolve_live_edit_assets( + config: LiveEditConfig, + *, + cache_dir: Path = _DEFAULT_ASSET_DIR, +) -> LiveEditConfig: + """Download missing checkpoints needed by enabled live-edit features. + + Explicit checkpoint paths remain authoritative. Style uses the latest + v6 multi-skin LoRA with the v5 corrector/gate stack recommended by its + release. Fused mode additionally corrects the base world with the shipped + OmniDreams clean-forcing checkpoint. Weather only needs a corrector when + its configured gain is nonzero; otherwise weather remains LoRA-free. + """ + style = config.style + weather = config.weather + if style.enabled and style.lora_checkpoint is None: + style = replace( + style, + lora_checkpoint=download_to_cache(_STYLE_LORA_URL, cache_dir=cache_dir), + ) + + correctors_enabled = style.corrector_mode != "off" + needs_style_corrector = style.enabled or ( + weather.enabled + and weather.corrector_gain > 0.0 + and weather.corrector_checkpoint is None + ) + if ( + correctors_enabled + and needs_style_corrector + and style.corrector_checkpoint is None + ): + style = replace( + style, + corrector_checkpoint=download_to_cache( + _STYLE_CORRECTOR_URL, cache_dir=cache_dir + ), + ) + if correctors_enabled and needs_style_corrector and style.gate_alpha_json is None: + style = replace( + style, + gate_alpha_json=download_to_cache(_STYLE_GATE_URL, cache_dir=cache_dir), + ) + if ( + style.enabled + and style.corrector_mode == "fused" + and style.base_corrector_checkpoint is None + ): + style = replace( + style, + base_corrector_checkpoint=download_to_cache( + _BASE_CORRECTOR_URL, cache_dir=cache_dir + ), + ) + return replace(config, style=style) + + def add_live_edit_args(parser: argparse.ArgumentParser) -> None: """Register the ``--live-edit-*`` flags next to the ``--taxi-*`` flags.""" group = parser.add_argument_group("live edit") @@ -783,13 +857,13 @@ def add_live_edit_args(parser: argparse.ArgumentParser) -> None: "--live-edit-style", action=argparse.BooleanOptionalAction, default=False, - help="Enable mid-run world-skin switching (requires the LoRA checkpoints).", + help="Enable mid-run world-skin switching (downloads default LoRAs).", ) group.add_argument( "--live-edit-style-lora", type=Path, default=None, - help="Pre-merged text-edit LoRA checkpoint for the style ability.", + help="Override the automatically downloaded style LoRA checkpoint.", ) group.add_argument( "--live-edit-style-corrector", diff --git a/apps/crazy_robotaxi/tests/test_live_edit_v2.py b/apps/crazy_robotaxi/tests/test_live_edit_v2.py index 4fe3542fd..687c59d13 100644 --- a/apps/crazy_robotaxi/tests/test_live_edit_v2.py +++ b/apps/crazy_robotaxi/tests/test_live_edit_v2.py @@ -5,15 +5,20 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace import numpy as np import pytest +import crazy_robotaxi.live_edit.config as live_edit_config from crazy_robotaxi.live_edit.config import ( LiveEditCoinsConfig, LiveEditConfig, LiveEditItemsConfig, LiveEditObstacleConfig, + LiveEditStyleConfig, + LiveEditWeatherConfig, + resolve_live_edit_assets, ) from crazy_robotaxi.live_edit.nitro_ability import NitroAbility from crazy_robotaxi.live_edit.obstacle_events import ( @@ -88,6 +93,80 @@ def _scene() -> SimpleNamespace: ) +def test_style_assets_download_only_when_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + downloads: list[str] = [] + + def fake_download(url: str, *, cache_dir: Path) -> Path: + downloads.append(url) + return cache_dir / Path(url).name + + monkeypatch.setattr(live_edit_config, "download_to_cache", fake_download) + + resolved = resolve_live_edit_assets( + LiveEditConfig(style=LiveEditStyleConfig(enabled=True)), + cache_dir=tmp_path, + ) + + assert [ + resolved.style.lora_checkpoint.name, + resolved.style.corrector_checkpoint.name, + resolved.style.gate_alpha_json.name, + resolved.style.base_corrector_checkpoint.name, + ] == [ + "lora_style_v6_step1600.pt", + "lora_style_corrector_v5_valpeak.pt", + "gate_style_v5.json", + "lora_v2_v3_valpeak.pt", + ] + assert len(downloads) == 4 + + +def test_explicit_style_assets_skip_download( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + live_edit_config, + "download_to_cache", + lambda *args, **kwargs: pytest.fail("explicit assets must not download"), + ) + style = LiveEditStyleConfig( + enabled=True, + lora_checkpoint=tmp_path / "style.pt", + corrector_checkpoint=tmp_path / "corrector.pt", + gate_alpha_json=tmp_path / "gate.json", + base_corrector_checkpoint=tmp_path / "base.pt", + ) + config = LiveEditConfig(style=style) + + assert resolve_live_edit_assets(config, cache_dir=tmp_path) == config + + +def test_weather_downloads_corrector_only_for_nonzero_gain( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + downloads: list[str] = [] + + def fake_download(url: str, *, cache_dir: Path) -> Path: + downloads.append(url) + return cache_dir / Path(url).name + + monkeypatch.setattr(live_edit_config, "download_to_cache", fake_download) + resolved = resolve_live_edit_assets( + LiveEditConfig(weather=LiveEditWeatherConfig(enabled=True, corrector_gain=0.1)), + cache_dir=tmp_path, + ) + + assert resolved.style.lora_checkpoint is None + assert resolved.style.base_corrector_checkpoint is None + assert resolved.style.corrector_checkpoint.name == ( + "lora_style_corrector_v5_valpeak.pt" + ) + assert resolved.style.gate_alpha_json.name == "gate_style_v5.json" + assert len(downloads) == 2 + + def test_v2_ability_keys_are_consumed_on_pressed_edges() -> None: gameplay = LiveEditGameplay.__new__(LiveEditGameplay) gameplay.style = _StyleRequests() From f6d7308a4efda90935536717b22345d3d67767d2 Mon Sep 17 00:00:00 2001 From: Ariel Glasroth Date: Fri, 28 Aug 2026 13:07:01 +0000 Subject: [PATCH 45/62] R button to reverse + small car in HUD --- apps/crazy_robotaxi/README.md | 3 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 46 +++++++++++++++++++ apps/crazy_robotaxi/tests/test_ui.py | 21 ++++++++- .../omnidreams_game_engine/input.py | 6 +++ .../tests/test_input.py | 38 +++++++++++++++ 5 files changed, 112 insertions(+), 2 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 242ccc887..12188150e 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -42,12 +42,13 @@ Menu choices and the leaderboard Submit button can also be clicked with the mous | Left stick, push forward+tilt | Steer. | | Right trigger (`RT` / `R2` / `ZR`) | Throttle. | | Left trigger (`LT` / `L2` / `ZL`) | Brake. | +| `R` / `RB` / `R1` (hold) | Select reverse gear while held; use the right trigger to drive in reverse. | | Start / Menu / Plus | Restart the current game without reloading the model. | | Steering wheel and pedals | Use normalized steering, throttle, and brake input directly. | A connected gamepad or wheel takes precedence over keyboard driving input. Disconnecting it returns control to any keyboard keys still held. Gamepads do -not currently bind reverse, handbrake, menu navigation, or live-edit actions; +not currently bind handbrake, menu navigation, or live-edit actions; use the keyboard or mouse for those controls. ```bash diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 76328981a..61c1a942e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -1048,6 +1048,7 @@ def composite_bev(self, video: Tensor, frame: Tensor | None) -> Tensor: source_panel = panel[:, : bottom - top, : right - left] source_alpha = alpha[:, : bottom - top, : right - left] torch.where(source_alpha, source_panel, target, out=target) + _composite_bev_ego_car(target) self._bev_composite_source_key = composite_source_key self._bev_composite = output return output @@ -1169,6 +1170,51 @@ def _submit_name(self, value: str) -> None: ) +def _composite_bev_ego_car(panel: Tensor) -> None: + """Draw a small heading-up taxi glyph directly on its tensor device.""" + height, width = (int(value) for value in panel.shape[-2:]) + extent = min(height, width) + if extent < 16: + return + + car_height = max(8, round(extent * 0.12)) + car_height = min(car_height + (car_height + 1) % 2, height - 2) + car_width = max(5, round(car_height * 0.55)) + car_width = min(car_width + (car_width + 1) % 2, width - 2) + top = (height - car_height) // 2 + left = (width - car_width) // 2 + bottom = top + car_height + right = left + car_width + + white, yellow, glass = panel.new_tensor( + ( + (1.0, 1.0, 1.0), + (1.0, 0.6, -1.0), + (-0.8, -0.2, 0.15), + ) + ).view(3, 3, 1, 1) + panel[:, top + 1 : bottom - 1, left:right] = white + panel[:, top:bottom, left + 1 : right - 1] = white + panel[:, top + 1 : bottom - 1, left + 1 : right - 1] = yellow + + window_left = left + max(2, car_width // 3) + window_right = right - max(2, car_width // 3) + if window_right <= window_left: + return + window_height = max(1, car_height // 5) + window_offset = max(2, car_height // 5) + panel[ + :, + top + window_offset : top + window_offset + window_height, + window_left:window_right, + ] = glass + panel[ + :, + bottom - window_offset - window_height : bottom - window_offset, + window_left:window_right, + ] = glass + + class CrazyRobotaxiImGuiUILoop(ImGuiUILoop[TaxiHudState]): """Present generated frames beneath a responsive Dear ImGui taxi HUD.""" diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index f25d84956..125024566 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -474,7 +474,12 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert "circle_filled" in command_names top, left, panel_height, panel_width = hud_state._bev_rect or (0, 0, 0, 0) panel = output[0, :, top : top + panel_height, left : left + panel_width] - assert torch.allclose(panel, torch.full_like(panel, 191.0 / 127.5 - 1.0)) + background = 191.0 / 127.5 - 1.0 + assert torch.allclose(panel[:, 0, 0], torch.full_like(panel[:, 0, 0], background)) + assert not torch.allclose(panel, torch.full_like(panel, background)) + torch.testing.assert_close( + panel[:, panel_height // 2, panel_width // 2], torch.tensor((1.0, 0.6, -1.0)) + ) outside = output[0].clone() outside[:, top : top + panel_height, left : left + panel_width] = -0.5 assert torch.all(outside == video[0]) @@ -511,6 +516,20 @@ def test_bev_compositor_uses_rgba_coverage_for_black_road_pixels() -> None: assert set(state._bev_alpha.unique().tolist()) == {False, True} +def test_bev_compositor_draws_ego_over_transparent_center() -> None: + state = TaxiHudState(32, 32, _calibration()) + state._bev_rect = (0, 0, 32, 32) + video = torch.full((3, 32, 32), -0.5) + transparent_bev = torch.zeros((4, 32, 32), dtype=torch.uint8) + + composited = state.composite_bev(video, transparent_bev) + + assert composited.device == video.device + torch.testing.assert_close(composited[:, 16, 16], torch.tensor((1.0, 0.6, -1.0))) + torch.testing.assert_close(composited[:, 12, 16], torch.tensor((-0.8, -0.2, 0.15))) + assert torch.all(composited[:, 0, 0] == -0.5) + + def test_presentation_back_buffer_is_cached_without_a_bev_frame() -> None: state = TaxiHudState(4, 4, _calibration()) video = torch.full((3, 4, 4), -0.5, dtype=torch.bfloat16) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index 8e4759b3a..420ee5572 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -187,10 +187,16 @@ def _gamepad_command(event: GamepadUserInputEvent) -> DriverCommand | None: steer = -(event.axes[0] if event.axes else 0.0) throttle = event.buttons[7] if len(event.buttons) > 7 else 0.0 brake = event.buttons[6] if len(event.buttons) > 6 else 0.0 + reverse = ( + event.pressed[5] + if len(event.pressed) > 5 + else len(event.buttons) > 5 and event.buttons[5] > 0.0 + ) return DriverCommand( throttle=throttle, brake=brake, steer=steer, + reverse=reverse, steer_is_direct=True, manual_control=True, ) diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index f36e30d25..f7c348daf 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -129,6 +129,44 @@ def test_gamepad_state_overrides_keyboard_until_disconnect() -> None: assert state.source() == "keyboard" +def test_gamepad_r_shoulder_selects_reverse_only_while_held() -> None: + state = DriverInput() + forward_buttons = (0.0,) * 7 + (0.75,) + reverse_buttons = (0.0,) * 5 + (1.0, 0.0, 0.75) + + state.apply( + UserInputEvents( + [ + GamepadUserInputEvent( + timestamp=np.uint64(30), + action="state", + buttons=reverse_buttons, + pressed=(False,) * 5 + (True, False, True), + ) + ] + ) + ) + + assert state.command().reverse + assert state.command().throttle == pytest.approx(0.75) + + state.apply( + UserInputEvents( + [ + GamepadUserInputEvent( + timestamp=np.uint64(40), + action="state", + buttons=forward_buttons, + pressed=(False,) * 8, + ) + ] + ) + ) + + assert not state.command().reverse + assert state.command().throttle == pytest.approx(0.75) + + def test_wheel_state_uses_direct_pedal_and_steering_values() -> None: state = DriverInput() state.apply( From d55a54316a9a428a37c7ff4f7dbc75fa61234506 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Thu, 27 Aug 2026 23:52:13 -0700 Subject: [PATCH 46/62] Restore Crazy Robotaxi HUD Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/session.py | 13 + apps/crazy_robotaxi/crazy_robotaxi/ui.py | 298 ++++++++++++++---- apps/crazy_robotaxi/tests/test_ui.py | 43 ++- 3 files changed, 286 insertions(+), 68 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 185729eb0..731a0c38b 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -82,6 +82,9 @@ class ModelState: last_video: torch.Tensor | None = None last_bev: torch.Tensor | None = None last_pose: np.ndarray | None = None + last_speed_mps: float = 0.0 + """Speed aligned with the retained terminal presentation frame.""" + blocks_generated: int = 0 finished: bool = False realtime_miss_count: int = 0 @@ -260,6 +263,10 @@ def reset(self) -> None: self.last_pose = None self.driver_input.reset() self.input_timeline.reset() + self.last_speed_mps = 0.0 + self.input_transition_count = 0 + self.input_ignored_event_count = 0 + self.input_coalesced_transition_count = 0 if self.rollout is not None: self.rollout.reset() @@ -345,6 +352,9 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: engine_step = generated.engine game_frames = engine_step.game_frames poses = engine_step.trajectory.rig_poses_world + speeds_mps = tuple( + vehicle.speed_mps for vehicle in engine_step.trajectory.vehicle_states + ) bev = engine_step.condition.bev_tchw metrics = dict(generated.metrics) if state.blocks_generated == 1 and state.prewarm_wall_ms > 0.0: @@ -353,12 +363,14 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: state.last_video = video[-1:].detach() state.last_bev = None if bev is None else bev[-1:].detach() state.last_pose = poses[-1].copy() + state.last_speed_mps = speeds_mps[-1] else: if state.last_video is None or state.last_pose is None: raise RuntimeError("Terminal game state has no generated frame") video = state.last_video game_frames = (snapshot,) poses = state.last_pose[None, ...] + speeds_mps = (state.last_speed_mps,) bev = state.last_bev metrics = {} transition_timestamps_us = (None,) * int(video.shape[0]) @@ -367,6 +379,7 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: video, game_frames, poses, + speeds_mps=speeds_mps, transition_timestamps_us=transition_timestamps_us, ) invoke_async( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 61c1a942e..f8ac0fb42 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -74,6 +74,9 @@ _BEV_WAYPOINT_ALPHA = 0.5 """Opacity of visible pickup and drop-off waypoints on the BEV map.""" +_MPS_TO_MPH = 2.2369362920544 +"""Metres-per-second to miles-per-hour conversion used by the source HUD.""" + _PROFILE_DRIVE_KEYS = frozenset( {"w", "a", "s", "d", "up", "down", "left", "right", "space"} ) @@ -99,6 +102,9 @@ class TaxiHudFrame: rig_pose_world: npt.NDArray[np.float32] """Read-only rig pose that generated the corresponding video frame.""" + speed_mps: float = 0.0 + """Authoritative signed vehicle speed for the corresponding simulation frame.""" + transition_timestamp_us: int | None = None """V2 input transition represented by this frame, when one was received.""" @@ -500,32 +506,11 @@ def draw( "awaiting_start", "racing", }: - lap = ( - "POINT TO POINT" - if snapshot.lap_count == 0 - else f"LAP {snapshot.completed_laps + 1}/{snapshot.lap_count}" - ) - self._draw_text_window( - imgui, - "Crazy Robotaxi Race", - position=(14.0, 14.0), - size=(360.0, 190.0), - lines=( - format_race_time_us(snapshot.elapsed_time_us), - lap, - ( - f"CHECKPOINT {snapshot.checkpoint_index + 1}/" - f"{snapshot.checkpoint_count}" - ), - f"{snapshot.target_label} {snapshot.distance_m:04.0f} m", - snapshot.event.replace("_", " ").upper() - if snapshot.event is not None - else "", - ), - ) + self._draw_race_status(imgui, snapshot) self._draw_navigation_arrow( imgui, snapshot.relative_bearing_rad, + center_y=110.0, color_rgb=(1.0, 0.18, 0.08), ) self._draw_bev_window(imgui, bev_frame, hud_frame) @@ -533,28 +518,11 @@ def draw( isinstance(snapshot, TaxiGameSnapshot) and snapshot.session_state == "playing" ): - objective = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" - fare_time = ( - "" - if snapshot.remaining_time_s is None - else f"FARE TIME {snapshot.remaining_time_s:04.1f}s" - ) - self._draw_text_window( - imgui, - "Crazy Robotaxi", - position=(14.0, 14.0), - size=(360.0, 190.0), - lines=( - _score_label(snapshot), - f"GAME TIME {snapshot.global_remaining_time_s:05.1f}s", - f"{objective} {snapshot.distance_m:04.0f} m", - fare_time, - _event_label(snapshot), - ), - ) + self._draw_taxi_status(imgui, snapshot) self._draw_navigation_arrow( imgui, snapshot.relative_bearing_rad, + center_y=198.0, color_rgb=( (118.0 / 255.0, 185.0 / 255.0, 0.0) if snapshot.phase == "seeking_pickup" @@ -562,9 +530,205 @@ def draw( ), ) self._draw_bev_window(imgui, bev_frame, hud_frame) + if snapshot.session_state in {"playing", "awaiting_start", "racing"}: + self._draw_speed(imgui, hud_frame.speed_mps) self._draw_terminal(imgui, snapshot) self._draw_input_diagnostic(imgui) + def _draw_taxi_status(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: + """Draw the source game's one-line taxi status directly over the frame.""" + phase = "PICKUP" if snapshot.phase == "seeking_pickup" else "DROPOFF" + fare_time = ( + "" + if snapshot.remaining_time_s is None + else f" {snapshot.remaining_time_s:04.1f}s" + ) + score = f"SCORE {snapshot.score}" + if snapshot.high_score is not None: + score += f" HIGH {snapshot.high_score}" + label = ( + f"GAME {snapshot.global_remaining_time_s:04.1f}s {phase} " + f"{snapshot.distance_m:.0f}m{fare_time} {score}" + ) + color = ( + (118.0 / 255.0, 185.0 / 255.0, 0.0) + if snapshot.phase == "seeking_pickup" + else (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) + ) + self._draw_status_strip(imgui, label, color_rgb=color, top=122.0) + event = _event_label(snapshot) + if event: + self._draw_centered_text( + imgui, + event, + top=250.0, + font_size=44.0, + color_rgb=color, + shadow=True, + ) + + def _draw_race_status(self, imgui: Any, snapshot: RaceGameSnapshot) -> None: + """Draw the source game's one-line race status directly over the frame.""" + if snapshot.session_state == "awaiting_start": + progress = "CROSS START LINE TO BEGIN" + elif snapshot.lap_count == 0: + progress = ( + f"CHECKPOINT {snapshot.checkpoint_index + 1}/" + f"{snapshot.checkpoint_count}" + ) + elif snapshot.target_kind == "start": + progress = ( + f"RETURN TO START LAP {snapshot.completed_laps + 1}/" + f"{snapshot.lap_count}" + ) + else: + progress = ( + f"LAP {snapshot.completed_laps + 1}/{snapshot.lap_count} " + f"CHECKPOINT {snapshot.checkpoint_index + 1}/" + f"{snapshot.checkpoint_count}" + ) + best = ( + "" + if snapshot.best_time_us is None + else f" BEST {format_race_time_us(snapshot.best_time_us)}" + ) + label = ( + f"RACE {format_race_time_us(snapshot.elapsed_time_us)} {progress} " + f"{snapshot.distance_m:.0f}m{best}" + ) + self._draw_status_strip( + imgui, + label, + color_rgb=(200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0), + top=35.0, + outline=True, + ) + + def _draw_status_strip( + self, + imgui: Any, + label: str, + *, + color_rgb: tuple[float, float, float], + top: float, + outline: bool = False, + ) -> None: + """Draw centered arcade status text without creating an ImGui window.""" + draw_list = imgui.get_background_draw_list() + font_size = 22.0 + text_width, text_height = _overlay_text_size(imgui, label, font_size) + available_width = max(1.0, float(self.width) - 28.0) + if text_width > available_width: + font_size = max(1.0, font_size * available_width / text_width) + text_width, text_height = _overlay_text_size(imgui, label, font_size) + left = (float(self.width) - text_width) * 0.5 + panel_color = _imgui_color( + imgui, + (12.0 / 255.0, 12.0 / 255.0, 18.0 / 255.0, 210.0 / 255.0), + ) + draw_list.add_rect_filled( + imgui.ImVec2(left - 14.0, top - 6.0), + imgui.ImVec2(left + text_width + 14.0, top + text_height + 6.0), + panel_color, + 9.0, + ) + color = _imgui_color(imgui, (*color_rgb, 1.0)) + if outline: + draw_list.add_rect( + imgui.ImVec2(left - 14.0, top - 6.0), + imgui.ImVec2(left + text_width + 14.0, top + text_height + 6.0), + color, + 9.0, + 2.0, + ) + _draw_overlay_text( + imgui, + draw_list, + label, + position=(left, top), + font_size=font_size, + color=color, + ) + + def _draw_centered_text( + self, + imgui: Any, + label: str, + *, + top: float, + font_size: float, + color_rgb: tuple[float, float, float], + shadow: bool = False, + ) -> None: + """Draw centered overlay text at an explicit display size.""" + draw_list = imgui.get_background_draw_list() + text_width, _ = _overlay_text_size(imgui, label, font_size) + available_width = max(1.0, float(self.width) - 28.0) + if text_width > available_width: + font_size = max(1.0, font_size * available_width / text_width) + text_width, _ = _overlay_text_size(imgui, label, font_size) + left = (float(self.width) - text_width) * 0.5 + if shadow: + _draw_overlay_text( + imgui, + draw_list, + label, + position=(left + 3.0, top + 3.0), + font_size=font_size, + color=_imgui_color(imgui, (0.0, 0.0, 0.0, 1.0)), + ) + _draw_overlay_text( + imgui, + draw_list, + label, + position=(left, top), + font_size=font_size, + color=_imgui_color(imgui, (*color_rgb, 1.0)), + ) + + def _draw_speed(self, imgui: Any, speed_mps: float) -> None: + """Draw the source HUD's green speed digit directly over the frame.""" + draw_list = imgui.get_background_draw_list() + font_size = max(28.0, min(76.0, float(self.height) * 0.12)) + speed = str(round(abs(float(speed_mps)) * _MPS_TO_MPH)) + speed_width, speed_height = _overlay_text_size(imgui, speed, font_size) + left = 24.0 + top = max(10.0, float(self.height) - speed_height - 42.0) + shadow = _imgui_color(imgui, (0.0, 0.0, 0.0, 0.9)) + green = _imgui_color( + imgui, + (118.0 / 255.0, 185.0 / 255.0, 0.0, 1.0), + ) + _draw_overlay_text( + imgui, + draw_list, + speed, + position=(left + 3.0, top + 3.0), + font_size=font_size, + color=shadow, + ) + _draw_overlay_text( + imgui, + draw_list, + speed, + position=(left, top), + font_size=font_size, + color=green, + ) + unit_size = max(14.0, font_size * 0.28) + unit_width, _ = _overlay_text_size(imgui, "mph", unit_size) + _draw_overlay_text( + imgui, + draw_list, + "mph", + position=( + left + (speed_width - unit_width) * 0.5, + top + speed_height + 2.0, + ), + font_size=unit_size, + color=_imgui_color(imgui, (0.86, 0.86, 0.9, 1.0)), + ) + def _draw_fps_counter(self, imgui: Any) -> None: """Draw the measured generated-video rate when the counter is enabled.""" if not self.show_fps: @@ -796,12 +960,12 @@ def _draw_navigation_arrow( imgui: Any, bearing_rad: float, *, + center_y: float, color_rgb: tuple[float, float, float], ) -> None: """Draw the always-visible target-bearing arrow from the original HUD.""" draw_list = imgui.get_background_draw_list() center_x = float(self.width) * 0.5 - center_y = 74.0 radius = 30.0 direction_x = -math.sin(bearing_rad) direction_y = -math.cos(bearing_rad) @@ -1245,6 +1409,7 @@ def build_hud_frames( snapshots: Sequence[object], rig_poses_world: npt.NDArray[np.float32], *, + speeds_mps: Sequence[float] | None = None, transition_timestamps_us: Sequence[int | None] | None = None, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" @@ -1254,6 +1419,10 @@ def build_hud_frames( poses = np.asarray(rig_poses_world, dtype=np.float32) if poses.shape != (frame_count, 4, 4): raise ValueError("Video and rig poses must align") + if speeds_mps is None: + speeds_mps = (0.0,) * frame_count + if len(speeds_mps) != frame_count: + raise ValueError("Vehicle speeds and video frames must align") if transition_timestamps_us is None: transition_timestamps_us = (None,) * frame_count if len(transition_timestamps_us) != frame_count: @@ -1269,6 +1438,7 @@ def build_hud_frames( frame_key=int(video_tchw[index].data_ptr()), snapshot=snapshot, rig_pose_world=pose, + speed_mps=float(speeds_mps[index]), transition_timestamp_us=transition_timestamps_us[index], ) ) @@ -1347,11 +1517,30 @@ def _imgui_color( return int(imgui.color_convert_float4_to_u32(imgui.ImVec4(*rgba))) -def _score_label(snapshot: TaxiGameSnapshot) -> str: - label = f"SCORE {snapshot.score:06d}" - if snapshot.high_score is not None: - label += f" HIGH {snapshot.high_score:06d}" - return label +def _overlay_text_size(imgui: Any, text: str, font_size: float) -> tuple[float, float]: + """Measure text after applying an explicit ImGui display size.""" + width, height = _point_xy(imgui.calc_text_size(text)) + scale = float(font_size) / max(1.0, float(imgui.get_font_size())) + return width * scale, height * scale + + +def _draw_overlay_text( + imgui: Any, + draw_list: Any, + text: str, + *, + position: tuple[float, float], + font_size: float, + color: int, +) -> None: + """Draw sized text directly into the shared background overlay.""" + draw_list.add_text( + imgui.get_font(), + float(font_size), + imgui.ImVec2(*position), + color, + text, + ) def _event_label(snapshot: TaxiGameSnapshot) -> str: @@ -1367,19 +1556,6 @@ def _event_label(snapshot: TaxiGameSnapshot) -> str: return "" -def _navigation_label(bearing_rad: float) -> str: - degrees = math.degrees(math.atan2(math.sin(bearing_rad), math.cos(bearing_rad))) - if abs(degrees) <= 15.0: - direction = "AHEAD" - elif abs(degrees) >= 165.0: - direction = "BEHIND" - elif degrees > 0.0: - direction = "LEFT" - else: - direction = "RIGHT" - return f"TARGET {direction} {abs(degrees):.0f} deg" - - __all__ = [ "CrazyRobotaxiImGuiUILoop", "TaxiHudFrame", diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 125024566..710b02c6b 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -151,6 +151,14 @@ def color_convert_float4_to_u32(color: tuple[float, ...]) -> int: def calc_text_size(text: str) -> SimpleNamespace: return SimpleNamespace(x=float(len(text) * 8), y=14.0) + @staticmethod + def get_font() -> object: + return object() + + @staticmethod + def get_font_size() -> float: + return 14.0 + def get_background_draw_list(self) -> _FakeDrawList: return self.background_draw_list @@ -268,12 +276,13 @@ def test_hud_frames_are_immutable_messages_keyed_to_video_storage() -> None: snapshots = (_snapshot(), _snapshot()) poses = np.repeat(np.eye(4, dtype=np.float32)[None], 2, axis=0) - frames = build_hud_frames(video, snapshots, poses) + frames = build_hud_frames(video, snapshots, poses, speeds_mps=(12.0, -3.0)) assert [frame.frame_key for frame in frames] == [ video[index].data_ptr() for index in range(2) ] assert frames[0].snapshot is snapshots[0] + assert [frame.speed_mps for frame in frames] == [12.0, -3.0] np.testing.assert_array_equal(frames[0].rig_pose_world, poses[0]) assert not frames[0].rig_pose_world.flags.writeable @@ -424,6 +433,7 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: video, (_snapshot(),), np.eye(4, dtype=np.float32)[None], + speeds_mps=(12.0,), ) ) presentation = PresentationManager() @@ -457,7 +467,7 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: assert output.shape == (1, 3, height, width) assert output.dtype is torch.float32 assert hud_state._current is not None - assert "SCORE 001200 HIGH 009000" in renderer.ui.windows["Crazy Robotaxi"] + assert "Crazy Robotaxi" not in renderer.ui.windows assert "Navigation" not in renderer.ui.windows assert renderer.ui.dummies == [(32.0, 32.0)] map_flags = renderer.ui.window_flags["Map"] @@ -472,6 +482,14 @@ def test_imgui_ui_loop_draws_waypoints_and_bev_in_the_ui_overlay() -> None: command_names = [name for name, _ in renderer.ui.background_draw_list.commands] assert "triangle_filled" in command_names assert "circle_filled" in command_names + overlay_text = [ + args[-1] + for name, args in renderer.ui.background_draw_list.commands + if name == "text" + ] + assert "GAME 42.5s PICKUP 25m SCORE 1200 HIGH 9000" in overlay_text + assert "27" in overlay_text + assert "mph" in overlay_text top, left, panel_height, panel_width = hud_state._bev_rect or (0, 0, 0, 0) panel = output[0, :, top : top + panel_height, left : left + panel_width] background = 191.0 / 127.5 - 1.0 @@ -595,11 +613,11 @@ def test_bev_draws_visible_waypoints_at_half_opacity() -> None: assert circles[0][1][-1] == expected_white -def test_hud_draws_immediate_imgui_windows() -> None: - state = TaxiHudState(160, 96, _calibration()) +def test_live_hud_draws_directly_over_the_game_frame() -> None: + state = TaxiHudState(640, 360, _calibration()) state.publish( build_hud_frames( - torch.zeros(1, 3, 96, 160), + torch.zeros(1, 3, 360, 640), (_snapshot(),), np.eye(4, dtype=np.float32)[None], ) @@ -610,11 +628,21 @@ def test_hud_draws_immediate_imgui_windows() -> None: state.draw(imgui) - assert set(imgui.windows) == {"Crazy Robotaxi"} - assert "SCORE 001200 HIGH 009000" in imgui.windows["Crazy Robotaxi"] + assert not imgui.windows + overlay_text = [ + args[-1] for name, args in imgui.background_draw_list.commands if name == "text" + ] + assert "GAME 42.5s PICKUP 25m SCORE 1200 HIGH 9000" in overlay_text + assert "mph" in overlay_text assert any( name == "triangle_filled" for name, _ in imgui.background_draw_list.commands ) + compass = next( + args + for name, args in imgui.background_draw_list.commands + if name == "circle_filled" + ) + assert compass[0][1] == 198.0 def test_compass_arrow_has_no_black_underlay() -> None: @@ -624,6 +652,7 @@ def test_compass_arrow_has_no_black_underlay() -> None: state._draw_navigation_arrow( imgui, 0.0, + center_y=198.0, color_rgb=(118.0 / 255.0, 185.0 / 255.0, 0.0), ) From d679dca44f7f47ecc8c86e1bca19f9754786aab4 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 01:45:46 -0700 Subject: [PATCH 47/62] Fix Taxi HUD placement Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 6 +++--- apps/crazy_robotaxi/tests/test_ui.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index f8ac0fb42..e61a675a2 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -522,7 +522,7 @@ def draw( self._draw_navigation_arrow( imgui, snapshot.relative_bearing_rad, - center_y=198.0, + center_y=110.0, color_rgb=( (118.0 / 255.0, 185.0 / 255.0, 0.0) if snapshot.phase == "seeking_pickup" @@ -555,13 +555,13 @@ def _draw_taxi_status(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: if snapshot.phase == "seeking_pickup" else (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) ) - self._draw_status_strip(imgui, label, color_rgb=color, top=122.0) + self._draw_status_strip(imgui, label, color_rgb=color, top=35.0) event = _event_label(snapshot) if event: self._draw_centered_text( imgui, event, - top=250.0, + top=160.0, font_size=44.0, color_rgb=color, shadow=True, diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 710b02c6b..514e997f9 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -642,7 +642,7 @@ def test_live_hud_draws_directly_over_the_game_frame() -> None: for name, args in imgui.background_draw_list.commands if name == "circle_filled" ) - assert compass[0][1] == 198.0 + assert compass[0][1] == 110.0 def test_compass_arrow_has_no_black_underlay() -> None: From b5251ea621861b051a1ec48365a69a5f47727e49 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 09:16:37 -0700 Subject: [PATCH 48/62] Fix cherry-pick of previous HUD commits --- apps/crazy_robotaxi/crazy_robotaxi/session.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 731a0c38b..28a336f30 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -264,9 +264,6 @@ def reset(self) -> None: self.driver_input.reset() self.input_timeline.reset() self.last_speed_mps = 0.0 - self.input_transition_count = 0 - self.input_ignored_event_count = 0 - self.input_coalesced_transition_count = 0 if self.rollout is not None: self.rollout.reset() From 0f12a271fa06d4c804d6f0bf466065cdedf64c75 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 09:44:23 -0700 Subject: [PATCH 49/62] Use Droid Sans for gameplay HUD callouts Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 51 +++++++++++++++-- apps/crazy_robotaxi/tests/test_ui.py | 70 ++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index e61a675a2..a16862c24 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -23,6 +23,7 @@ from collections import OrderedDict, deque from collections.abc import Sequence from dataclasses import dataclass, field +from importlib.resources import as_file, files from pathlib import Path from typing import Any, Literal @@ -218,6 +219,9 @@ class TaxiHudState: _video_fps: float = 0.0 """Generated-video frame rate estimated from recent selections.""" + _gameplay_font: Any | None = None + """Droid Sans face used for prominent gameplay feedback.""" + def publish(self, frames: Sequence[TaxiHudFrame]) -> None: """Publish immutable model-frame state to the UI-owned lookup.""" for frame in frames: @@ -565,6 +569,7 @@ def _draw_taxi_status(self, imgui: Any, snapshot: TaxiGameSnapshot) -> None: font_size=44.0, color_rgb=color, shadow=True, + font=self._gameplay_overlay_font(imgui), ) def _draw_race_status(self, imgui: Any, snapshot: RaceGameSnapshot) -> None: @@ -659,14 +664,15 @@ def _draw_centered_text( font_size: float, color_rgb: tuple[float, float, float], shadow: bool = False, + font: Any | None = None, ) -> None: """Draw centered overlay text at an explicit display size.""" draw_list = imgui.get_background_draw_list() - text_width, _ = _overlay_text_size(imgui, label, font_size) + text_width, _ = _overlay_text_size(imgui, label, font_size, font=font) available_width = max(1.0, float(self.width) - 28.0) if text_width > available_width: font_size = max(1.0, font_size * available_width / text_width) - text_width, _ = _overlay_text_size(imgui, label, font_size) + text_width, _ = _overlay_text_size(imgui, label, font_size, font=font) left = (float(self.width) - text_width) * 0.5 if shadow: _draw_overlay_text( @@ -676,6 +682,7 @@ def _draw_centered_text( position=(left + 3.0, top + 3.0), font_size=font_size, color=_imgui_color(imgui, (0.0, 0.0, 0.0, 1.0)), + font=font, ) _draw_overlay_text( imgui, @@ -684,14 +691,18 @@ def _draw_centered_text( position=(left, top), font_size=font_size, color=_imgui_color(imgui, (*color_rgb, 1.0)), + font=font, ) def _draw_speed(self, imgui: Any, speed_mps: float) -> None: """Draw the source HUD's green speed digit directly over the frame.""" draw_list = imgui.get_background_draw_list() + font = self._gameplay_overlay_font(imgui) font_size = max(28.0, min(76.0, float(self.height) * 0.12)) speed = str(round(abs(float(speed_mps)) * _MPS_TO_MPH)) - speed_width, speed_height = _overlay_text_size(imgui, speed, font_size) + speed_width, speed_height = _overlay_text_size( + imgui, speed, font_size, font=font + ) left = 24.0 top = max(10.0, float(self.height) - speed_height - 42.0) shadow = _imgui_color(imgui, (0.0, 0.0, 0.0, 0.9)) @@ -706,6 +717,7 @@ def _draw_speed(self, imgui: Any, speed_mps: float) -> None: position=(left + 3.0, top + 3.0), font_size=font_size, color=shadow, + font=font, ) _draw_overlay_text( imgui, @@ -714,9 +726,10 @@ def _draw_speed(self, imgui: Any, speed_mps: float) -> None: position=(left, top), font_size=font_size, color=green, + font=font, ) unit_size = max(14.0, font_size * 0.28) - unit_width, _ = _overlay_text_size(imgui, "mph", unit_size) + unit_width, _ = _overlay_text_size(imgui, "mph", unit_size, font=font) _draw_overlay_text( imgui, draw_list, @@ -727,8 +740,21 @@ def _draw_speed(self, imgui: Any, speed_mps: float) -> None: ), font_size=unit_size, color=_imgui_color(imgui, (0.86, 0.86, 0.9, 1.0)), + font=font, ) + def _gameplay_overlay_font(self, imgui: Any) -> Any: + """Load and cache imgui-bundle's Droid Sans face.""" + if self._gameplay_font is None: + resource = files("imgui_bundle").joinpath( + "assets", "fonts", "DroidSans.ttf" + ) + with as_file(resource) as path: + self._gameplay_font = imgui.get_io().fonts.add_font_from_file_ttf( + str(path), 13.0 + ) + return self._gameplay_font + def _draw_fps_counter(self, imgui: Any) -> None: """Draw the measured generated-video rate when the counter is enabled.""" if not self.show_fps: @@ -1517,8 +1543,20 @@ def _imgui_color( return int(imgui.color_convert_float4_to_u32(imgui.ImVec4(*rgba))) -def _overlay_text_size(imgui: Any, text: str, font_size: float) -> tuple[float, float]: +def _overlay_text_size( + imgui: Any, + text: str, + font_size: float, + *, + font: Any | None = None, +) -> tuple[float, float]: """Measure text after applying an explicit ImGui display size.""" + if font is not None: + imgui.push_font(font, float(font_size)) + try: + return _point_xy(imgui.calc_text_size(text)) + finally: + imgui.pop_font() width, height = _point_xy(imgui.calc_text_size(text)) scale = float(font_size) / max(1.0, float(imgui.get_font_size())) return width * scale, height * scale @@ -1532,10 +1570,11 @@ def _draw_overlay_text( position: tuple[float, float], font_size: float, color: int, + font: Any | None = None, ) -> None: """Draw sized text directly into the shared background overlay.""" draw_list.add_text( - imgui.get_font(), + imgui.get_font() if font is None else font, float(font_size), imgui.ImVec2(*position), color, diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 514e997f9..10ad1f257 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -111,6 +111,16 @@ def add_text(self, *args: Any) -> None: self.commands.append(("text", args)) +class _FakeFontAtlas: + def __init__(self) -> None: + self.loaded: list[tuple[str, float, object]] = [] + + def add_font_from_file_ttf(self, path: str, size: float) -> object: + font = object() + self.loaded.append((path, size, font)) + return font + + class _FakeImGui: Cond_ = SimpleNamespace(always=1) WindowFlags_ = SimpleNamespace( @@ -134,6 +144,12 @@ def __init__(self) -> None: self.clicked_buttons: set[str] = set() self.background_draw_list = _FakeDrawList() self.window_flags: dict[str, int] = {} + self.default_font = object() + self.current_font = self.default_font + self.current_font_size = 14.0 + self.font_stack: list[tuple[object, float]] = [] + self.fonts = _FakeFontAtlas() + self.io = SimpleNamespace(fonts=self.fonts) @staticmethod def ImVec2(x: float, y: float) -> tuple[float, float]: @@ -151,13 +167,22 @@ def color_convert_float4_to_u32(color: tuple[float, ...]) -> int: def calc_text_size(text: str) -> SimpleNamespace: return SimpleNamespace(x=float(len(text) * 8), y=14.0) - @staticmethod - def get_font() -> object: - return object() + def get_font(self) -> object: + return self.current_font - @staticmethod - def get_font_size() -> float: - return 14.0 + def get_font_size(self) -> float: + return self.current_font_size + + def get_io(self) -> SimpleNamespace: + return self.io + + def push_font(self, font: object, size: float) -> None: + self.font_stack.append((self.current_font, self.current_font_size)) + self.current_font = font + self.current_font_size = size + + def pop_font(self) -> None: + self.current_font, self.current_font_size = self.font_stack.pop() def get_background_draw_list(self) -> _FakeDrawList: return self.background_draw_list @@ -645,6 +670,39 @@ def test_live_hud_draws_directly_over_the_game_frame() -> None: assert compass[0][1] == 110.0 +def test_prominent_gameplay_text_uses_droid_sans() -> None: + state = TaxiHudState(640, 360, _calibration()) + state.publish( + build_hud_frames( + torch.zeros(1, 3, 360, 640), + (replace(_snapshot(), event="pickup_complete"),), + np.eye(4, dtype=np.float32)[None], + speeds_mps=(12.0,), + ) + ) + state._current = next(iter(state._frames.values())) + state._menu_stage = "game" + imgui = _FakeImGui() + + state.draw(imgui) + + [(path, size, droid_sans)] = imgui.fonts.loaded + assert path.endswith("DroidSans.ttf") + assert size == 13.0 + text_commands = { + args[-1]: args + for name, args in imgui.background_draw_list.commands + if name == "text" + } + assert text_commands["PASSENGER PICKED UP"][0] is droid_sans + assert text_commands["27"][0] is droid_sans + assert text_commands["mph"][0] is droid_sans + assert ( + text_commands["GAME 42.5s PICKUP 25m SCORE 1200 HIGH 9000"][0] + is imgui.default_font + ) + + def test_compass_arrow_has_no_black_underlay() -> None: state = TaxiHudState(160, 96, _calibration()) imgui = _FakeImGui() From 365371cf8b0a90e0fff863dc7b3699081f0be38d Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 12:00:45 -0700 Subject: [PATCH 50/62] Trace Crazy Robotaxi input Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 19 +- .../crazy_robotaxi/application.py | 39 +++- apps/crazy_robotaxi/crazy_robotaxi/session.py | 97 +++++++++- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 170 ++++++++++++++++-- apps/crazy_robotaxi/tests/test_application.py | 23 +++ apps/crazy_robotaxi/tests/test_ui.py | 76 ++++++++ .../omnidreams_game_engine/model.py | 51 ++++++ .../tests/test_model.py | 10 ++ .../design/crazy_robotaxi_v2_input_latency.md | 42 ++++- .../runtime_v2/presentation_manager.py | 133 +++++++++++++- .../flashdreams/runtime_v2/session_runner.py | 74 ++++++++ .../test_presentation_manager_trace.py | 97 ++++++++++ 12 files changed, 792 insertions(+), 39 deletions(-) create mode 100644 flashdreams/test_v2/test_presentation_manager_trace.py diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 12188150e..06481e76b 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -219,17 +219,22 @@ Profile interactive input separately with: ```bash uv run flashdreams-run-v2 crazy-robotaxi-omnidreams-perf --mode webrtc -- \ - --profile-input-latency + --profile-input-latency /tmp/crazy-robotaxi-input-trace.log ``` This opt-in adds a UI-thread key indicator and logs the time from V2 UI event -receipt to the first presented model frame carrying that transition. The normal -HUD does not construct these widgets when the flag is absent. The indicator's -physical-key-to-browser delay still includes WebRTC transport latency, while -the reported `UI TO MODEL FRAME` value isolates the synchronous app/model -portion. See the +receipt to the first presented model frame carrying that transition. It also +logs input sampling, model/cache boundaries, runtime publication, chunk drops, +resets, and the lead between committed and displayed simulation +state to the optional path following `--profile-input-latency`. Without an +explicit path, the trace is written to +`/tmp/crazy-robotaxi-input-trace.log`; it is separate from `--stats-path`. The +normal HUD does not construct these widgets or lifecycle records when the flag +is absent. The indicator's physical-key-to-browser delay still includes WebRTC +transport latency, while the reported `UI TO MODEL FRAME` value isolates the +synchronous app/model portion. See the [V2 latency handoff](../../docs/design/crazy_robotaxi_v2_input_latency.md) for -the remaining synchronous model-step boundary. +the trace fields and interpretation caveats. All model presets generate four neutral, hidden blocks before publishing the first gameplay frame. The responsive ImGui HUD shows the current warmup block, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 6b9112cbe..592ed531c 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -7,6 +7,7 @@ import argparse import logging +import tempfile from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from functools import partial @@ -55,6 +56,11 @@ _UI_FPS = 30 """Input polling and HUD cadence used by Interactive Drive.""" +_DEFAULT_INPUT_TRACE_PATH = ( + Path(tempfile.gettempdir()) / "crazy-robotaxi-input-trace.log" +) +"""Default line-oriented input trace written by the profiling flag.""" + _LOGGER = logging.getLogger(__name__) _DEFAULT_PREWARM_BLOCKS = 4 @@ -89,6 +95,9 @@ class ApplicationConfig: profile_input_latency: bool """Whether the UI displays and logs input-to-model-frame diagnostics.""" + input_trace_path: Path | None + """Lifecycle trace destination when input profiling is enabled.""" + show_fps: bool """Whether the HUD displays the measured generated-video frame rate.""" @@ -119,6 +128,8 @@ class ApplicationConfig: PipelineFactory = Callable[[Any, str], Any] SceneFactory = Callable[[SceneRequest, Any], SceneDefinition] +_TRACE_METADATA_KEY = "trace_chunk_lifecycle" +_TRACE_PATH_METADATA_KEY = "trace_chunk_lifecycle_path" class CrazyRobotaxiApplication(IApplication): @@ -166,6 +177,8 @@ def init(self, commandline_args: Sequence[str]) -> None: if pipeline_config is None: raise RuntimeError("A world-model integration must provide pipeline_config") args = _parser(self._application_defaults).parse_args(list(commandline_args)) + input_trace_path = args.profile_input_latency + args.profile_input_latency = input_trace_path is not None engine_settings = self._resolve_engine_settings(args) game_settings = self._resolve_game_settings(args) if ( @@ -252,6 +265,11 @@ def init(self, commandline_args: Sequence[str]) -> None: pipeline_profiling=bool(engine_settings.world_model.profile_pipeline), prewarm_blocks=engine_settings.runtime.prewarm_blocks, profile_input_latency=engine_settings.runtime.profile_input_latency, + input_trace_path=( + None + if input_trace_path is None + else input_trace_path.expanduser().resolve() + ), show_fps=engine_settings.presentation.show_fps, cli_game_mode=( game_settings.mode if arg_was_explicit(args, "game_mode") else None @@ -412,6 +430,17 @@ def create_session(self, session_desc: SessionDesc) -> ISession: session_desc=replace( session_desc, presentation_mode=PresentationMode.CONTINUOUS, + metadata={ + **session_desc.metadata, + **( + { + _TRACE_METADATA_KEY: True, + _TRACE_PATH_METADATA_KEY: str(config.input_trace_path), + } + if config.input_trace_path is not None + else {} + ), + }, ), ) @@ -533,8 +562,14 @@ def _parser( ) parser.add_argument( "--profile-input-latency", - action="store_true", - help="show and log UI-input-to-model-frame latency diagnostics", + nargs="?", + type=Path, + const=_DEFAULT_INPUT_TRACE_PATH, + metavar="TRACE_PATH", + help=( + "show input diagnostics and write the chunk lifecycle trace " + f"(default path: {_DEFAULT_INPUT_TRACE_PATH})" + ), ) parser.add_argument( "--show-fps", diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 28a336f30..59c971d9e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -46,6 +46,8 @@ from crazy_robotaxi.application import ApplicationConfig _LOGGER = logging.getLogger(__name__) +_TRACE_LOGGER = logging.getLogger("flashdreams.runtime_v2.chunk_trace") +_TRACE_PREFIX = "[crazy-robotaxi-chunk-trace]" _GAMEPAD_START_BUTTON_INDEX = 9 """Browser-standard gamepad index shared by Start and Nintendo Plus.""" @@ -86,6 +88,9 @@ class ModelState: """Speed aligned with the retained terminal presentation frame.""" blocks_generated: int = 0 + rollout_epoch: int = 0 + """Incremented whenever mutable game and model state is reset.""" + finished: bool = False realtime_miss_count: int = 0 prewarm_complete: bool = False @@ -126,6 +131,7 @@ def ensure_rollout(self) -> WorldModelRollout: race_times_path=self.config.race_times_path, live_edit=self.config.live_edit, ), + trace_chunk_lifecycle=self.config.profile_input_latency, ) if not self.prewarm_complete: self._prewarm_rollout() @@ -255,6 +261,13 @@ def _set_loading_status(self, status: str) -> None: ) def reset(self) -> None: + self.rollout_epoch += 1 + if getattr(self.config, "profile_input_latency", False): + _log_chunk_trace( + "rollout_reset", + time_ns=time.monotonic_ns(), + epoch=self.rollout_epoch, + ) self.blocks_generated = 0 self.finished = False self.realtime_miss_count = 0 @@ -293,6 +306,8 @@ class CrazyRobotaxiModelLoop(IModelLoop[ModelState]): def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: state = self.state + runtime_generation = getattr(self, "_generation", 0) + trace_enabled = getattr(state.config, "profile_input_latency", False) # Match Interactive Drive: apply every unread edge before rollout setup, # reset handling, or simulation reads the retained command. input_times_s = state.driver_input.apply(events) @@ -312,13 +327,17 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): raise TypeError("Crazy Robotaxi reset returned an unknown game frame") active_states = {"playing", "awaiting_start", "racing"} + autoregressive_index = -1 + simulation_timestamps_us: tuple[int, ...] | None = None + cache_finalize_returned_ns: int | None = None if snapshot.session_state in active_states: live_edit = getattr(rollout.engine, "live_edit", None) if live_edit is not None: live_edit.process_events(events) if live_edit.style is not None: live_edit.style.before_v2_chunk() - frame_count = rollout.frame_count(state.blocks_generated) + autoregressive_index = state.blocks_generated + frame_count = rollout.frame_count(autoregressive_index) input_window = state.input_timeline.next_window( frame_count, input_times_s=input_times_s, @@ -326,12 +345,58 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: sampled_commands, transition_timestamps_us = state.driver_input.sample( input_window ) + commands = tuple( + _taxi_driver_command(command) for command in sampled_commands + ) + if trace_enabled: + sampled_at_ns = time.monotonic_ns() + for frame_index, (command, transition_timestamp_us) in enumerate( + zip(commands, transition_timestamps_us, strict=True) + ): + _log_chunk_trace( + "input_sampled", + time_ns=sampled_at_ns, + generation=runtime_generation, + step=step_index, + epoch=state.rollout_epoch, + ar=autoregressive_index, + frame=frame_index, + event_us=( + "none" + if transition_timestamp_us is None + else transition_timestamp_us + ), + window_start_us=round(input_window.start_s * 1_000_000), + window_end_us=round(input_window.end_s * 1_000_000), + throttle=command.throttle, + brake=command.brake, + steer=command.steer, + reverse=command.reverse, + ) generated = rollout.step( - autoregressive_index=state.blocks_generated, - commands=tuple( - _taxi_driver_command(command) for command in sampled_commands - ), + autoregressive_index=autoregressive_index, + commands=commands, ) + if trace_enabled and generated._trace is not None: + trace = generated._trace + cache_finalize_returned_ns = trace.cache_finalize_returned_ns + for phase, timestamp_ns in ( + ("engine_step_started", trace.engine_step_started_ns), + ("engine_step_returned", trace.engine_step_returned_ns), + ("generate_started", trace.generate_started_ns), + ("generate_returned", trace.generate_returned_ns), + ("cache_finalize_returned", trace.cache_finalize_returned_ns), + ("rollout_step_returned", trace.rollout_step_returned_ns), + ): + _log_chunk_trace( + phase, + time_ns=timestamp_ns, + generation=runtime_generation, + step=step_index, + epoch=state.rollout_epoch, + ar=autoregressive_index, + frames=frame_count, + ) if live_edit is not None and live_edit.style is not None: live_edit.style.after_v2_chunk() state.blocks_generated += 1 @@ -349,6 +414,10 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: engine_step = generated.engine game_frames = engine_step.game_frames poses = engine_step.trajectory.rig_poses_world + if trace_enabled: + simulation_timestamps_us = tuple( + int(value) for value in engine_step.trajectory.timestamps_us + ) speeds_mps = tuple( vehicle.speed_mps for vehicle in engine_step.trajectory.vehicle_states ) @@ -378,6 +447,12 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: poses, speeds_mps=speeds_mps, transition_timestamps_us=transition_timestamps_us, + runtime_generation=runtime_generation, + model_step_index=step_index, + rollout_epoch=state.rollout_epoch, + autoregressive_index=autoregressive_index, + simulation_timestamps_us=simulation_timestamps_us, + cache_finalize_returned_ns=cache_finalize_returned_ns, ) invoke_async( state.ui_loop, @@ -530,6 +605,18 @@ def init(self) -> None: hud_state.initialize_selection() +def _log_chunk_trace(phase: str, *, time_ns: int, **fields: object) -> None: + """Emit one grep-friendly chunk lifecycle event.""" + details = " ".join(f"{name}={value}" for name, value in fields.items()) + _TRACE_LOGGER.info( + "%s phase=%s time_ns=%d %s", + _TRACE_PREFIX, + phase, + time_ns, + details, + ) + + def _restart_requested(events: UserInputEvents) -> bool: """Return whether this model step received a keyboard or gamepad restart.""" for event in events.get_events(): diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index a16862c24..f63595110 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -81,7 +81,8 @@ _PROFILE_DRIVE_KEYS = frozenset( {"w", "a", "s", "d", "up", "down", "left", "right", "space"} ) -_LOGGER = logging.getLogger(__name__) +_TRACE_LOGGER = logging.getLogger("flashdreams.runtime_v2.chunk_trace") +_TRACE_PREFIX = "[crazy-robotaxi-chunk-trace]" def bev_display_extent(video_width: int, video_height: int) -> tuple[int, int]: @@ -109,6 +110,15 @@ class TaxiHudFrame: transition_timestamp_us: int | None = None """V2 input transition represented by this frame, when one was received.""" + runtime_generation: int = 0 + model_step_index: int = -1 + rollout_epoch: int = 0 + autoregressive_index: int = -1 + frame_index: int = -1 + simulation_timestamp_us: int | None = None + cache_finalize_returned_ns: int | None = None + """Chunk-lifecycle correlation fields for input diagnosis.""" + @dataclass(slots=True) class TaxiHudState: @@ -204,7 +214,7 @@ class TaxiHudState: _profile_pressed: set[str] = field(default_factory=set) """Normalized drive keys currently held according to UI-thread events.""" - _input_received_at_s: OrderedDict[int, float] = field(default_factory=OrderedDict) + _input_received_at_ns: OrderedDict[int, int] = field(default_factory=OrderedDict) """UI receipt times keyed by V2 session-relative event timestamp.""" _reported_input_timestamps_us: set[int] = field(default_factory=set) @@ -213,6 +223,9 @@ class TaxiHudState: _latest_input_latency_ms: float | None = None """Latest UI-ingress-to-model-frame-selection latency measurement.""" + _latest_committed_frame: TaxiHudFrame | None = None + """Newest generated frame metadata received from the model thread.""" + _presented_frame_times_s: deque[float] = field(default_factory=deque) """Recent times when distinct generated video frames were selected.""" @@ -227,6 +240,8 @@ def publish(self, frames: Sequence[TaxiHudFrame]) -> None: for frame in frames: self._frames[frame.frame_key] = frame self._frames.move_to_end(frame.frame_key) + if frames and frames[-1].autoregressive_index >= 0: + self._latest_committed_frame = frames[-1] while len(self._frames) > _MAX_BUFFERED_HUD_FRAMES: self._frames.popitem(last=False) @@ -250,9 +265,19 @@ def select_presented_frame(self, frame: Tensor) -> TaxiHudFrame | None: self._submission_pending = False self._current = selected self._menu_stage = "game" + presented_at_ns = ( + time.monotonic_ns() if self.profile_input_latency else None + ) if frame_changed: - self._record_presented_frame(time.monotonic()) - self._record_presented_input(selected) + self._record_presented_frame( + time.monotonic() + if presented_at_ns is None + else presented_at_ns / 1_000_000_000.0 + ) + if presented_at_ns is not None: + self._record_presented_trace(selected, presented_at_ns) + if presented_at_ns is not None: + self._record_presented_input(selected, presented_at_ns) return self._current def _record_presented_frame(self, now_s: float) -> None: @@ -295,27 +320,91 @@ def consume_input_events(self, events: UserInputEvents) -> None: if not recognized: continue timestamp_us = int(event.get_timestamp()) - self._input_received_at_s.setdefault(timestamp_us, time.perf_counter()) - self._input_received_at_s.move_to_end(timestamp_us) - while len(self._input_received_at_s) > _MAX_BUFFERED_INPUT_EVENTS: - self._input_received_at_s.popitem(last=False) + received_at_ns = time.monotonic_ns() + self._input_received_at_ns.setdefault(timestamp_us, received_at_ns) + self._input_received_at_ns.move_to_end(timestamp_us) + _log_chunk_trace( + "input_received", + time_ns=received_at_ns, + event_us=timestamp_us, + **_input_event_trace_fields(event), + ) + while len(self._input_received_at_ns) > _MAX_BUFFERED_INPUT_EVENTS: + self._input_received_at_ns.popitem(last=False) - def _record_presented_input(self, selected: TaxiHudFrame) -> None: + def _record_presented_input( + self, + selected: TaxiHudFrame, + presented_at_ns: int, + ) -> None: if not self.profile_input_latency: return timestamp_us = selected.transition_timestamp_us if timestamp_us is None or timestamp_us in self._reported_input_timestamps_us: return - received_at_s = self._input_received_at_s.pop(timestamp_us, None) - if received_at_s is None: + received_at_ns = self._input_received_at_ns.pop(timestamp_us, None) + if received_at_ns is None: return self._reported_input_timestamps_us.add(timestamp_us) - self._latest_input_latency_ms = (time.perf_counter() - received_at_s) * 1000.0 - _LOGGER.info( + self._latest_input_latency_ms = ( + presented_at_ns - received_at_ns + ) / 1_000_000.0 + _TRACE_LOGGER.info( "[crazy-robotaxi] input-to-model-frame latency: " - "event_us=%d ui_to_frame_ms=%.1f", + "event_us=%d ui_to_frame_ms=%.1f generation=%d step=%d epoch=%d " + "ar=%d frame=%d", timestamp_us, self._latest_input_latency_ms, + selected.runtime_generation, + selected.model_step_index, + selected.rollout_epoch, + selected.autoregressive_index, + selected.frame_index, + ) + + def _record_presented_trace( + self, + selected: TaxiHudFrame, + presented_at_ns: int, + ) -> None: + if not self.profile_input_latency or selected.model_step_index < 0: + return + latest = self._latest_committed_frame + ar_lead: int | str = "unknown" + step_lead: int | str = "unknown" + simulation_lead_ms: float | str = "unknown" + if latest is not None and latest.rollout_epoch == selected.rollout_epoch: + ar_lead = latest.autoregressive_index - selected.autoregressive_index + step_lead = latest.model_step_index - selected.model_step_index + if ( + latest.simulation_timestamp_us is not None + and selected.simulation_timestamp_us is not None + ): + simulation_lead_ms = ( + latest.simulation_timestamp_us - selected.simulation_timestamp_us + ) / 1000.0 + finalize_to_present_ms: float | str = "unknown" + if selected.cache_finalize_returned_ns is not None: + finalize_to_present_ms = ( + presented_at_ns - selected.cache_finalize_returned_ns + ) / 1_000_000.0 + _log_chunk_trace( + "app_frame_presented", + time_ns=presented_at_ns, + generation=selected.runtime_generation, + step=selected.model_step_index, + epoch=selected.rollout_epoch, + ar=selected.autoregressive_index, + frame=selected.frame_index, + simulation_us=( + "unknown" + if selected.simulation_timestamp_us is None + else selected.simulation_timestamp_us + ), + step_lead=step_lead, + ar_lead=ar_lead, + simulation_lead_ms=simulation_lead_ms, + finalize_to_present_ms=finalize_to_present_ms, ) def set_loading_status(self, status: str) -> None: @@ -776,9 +865,10 @@ def reset(self) -> None: self._loading_status = "LOADING WORLD MODEL" self._loading_started_at_s = time.monotonic() self._profile_pressed.clear() - self._input_received_at_s.clear() + self._input_received_at_ns.clear() self._reported_input_timestamps_us.clear() self._latest_input_latency_ms = None + self._latest_committed_frame = None self._name_input = "" def _clear_presented_game(self) -> None: @@ -1430,6 +1520,35 @@ def reset(self) -> None: super().reset() +def _log_chunk_trace(phase: str, *, time_ns: int, **fields: object) -> None: + """Emit one grep-friendly chunk lifecycle event.""" + details = " ".join(f"{name}={value}" for name, value in fields.items()) + _TRACE_LOGGER.info( + "%s phase=%s time_ns=%d %s", + _TRACE_PREFIX, + phase, + time_ns, + details, + ) + + +def _input_event_trace_fields(event: object) -> dict[str, object]: + """Return non-text driving fields for one diagnostic input event.""" + if isinstance(event, KeyboardUserInputEvent): + return { + "source": "keyboard", + "key": _normalize_profile_key(str(event.key)), + "state": event.state.value, + } + if isinstance(event, FocusUserInputEvent): + return {"source": "focus", "focused": event.focused} + if isinstance(event, GamepadUserInputEvent): + return {"source": "gamepad", "action": event.action} + if isinstance(event, GameWheelUserInputEvent): + return {"source": "wheel", "action": event.action} + return {"source": type(event).__name__} + + def build_hud_frames( video_tchw: Tensor, snapshots: Sequence[object], @@ -1437,6 +1556,12 @@ def build_hud_frames( *, speeds_mps: Sequence[float] | None = None, transition_timestamps_us: Sequence[int | None] | None = None, + runtime_generation: int = 0, + model_step_index: int = -1, + rollout_epoch: int = 0, + autoregressive_index: int = -1, + simulation_timestamps_us: Sequence[int | None] | None = None, + cache_finalize_returned_ns: int | None = None, ) -> tuple[TaxiHudFrame, ...]: """Build immutable UI messages aligned with generated tensor frames.""" frame_count = int(video_tchw.shape[0]) @@ -1453,8 +1578,14 @@ def build_hud_frames( transition_timestamps_us = (None,) * frame_count if len(transition_timestamps_us) != frame_count: raise ValueError("Input transitions and video frames must align") + if simulation_timestamps_us is None: + simulation_timestamps_us = (None,) * frame_count + if len(simulation_timestamps_us) != frame_count: + raise ValueError("Simulation timestamps and video frames must align") frames = [] - for index, snapshot in enumerate(snapshots): + for index, (snapshot, simulation_timestamp_us) in enumerate( + zip(snapshots, simulation_timestamps_us, strict=True) + ): if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): raise TypeError("Taxi HUD received an unknown game snapshot") pose = poses[index].copy() @@ -1466,6 +1597,13 @@ def build_hud_frames( rig_pose_world=pose, speed_mps=float(speeds_mps[index]), transition_timestamp_us=transition_timestamps_us[index], + runtime_generation=runtime_generation, + model_step_index=model_step_index, + rollout_epoch=rollout_epoch, + autoregressive_index=autoregressive_index, + frame_index=index, + simulation_timestamp_us=simulation_timestamp_us, + cache_finalize_returned_ns=cache_finalize_returned_ns, ) ) return tuple(frames) diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 2aac78063..cab9d7d56 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -606,6 +606,29 @@ def test_input_latency_profiling_is_an_app_local_opt_in( assert app._config is not None assert app._config.profile_input_latency is expected + session = app.create_session(app.session_desc()) + assert ( + session.session_desc.metadata.get("trace_chunk_lifecycle") is True + ) is expected + trace_path = session.session_desc.metadata.get("trace_chunk_lifecycle_path") + assert (trace_path is not None) is expected + if trace_path is not None: + assert Path(trace_path).name == "crazy-robotaxi-input-trace.log" + + +def test_input_latency_trace_accepts_an_explicit_path(tmp_path) -> None: + trace_path = tmp_path / "robotaxi-input.log" + app = _application() + + app.init(["--profile-input-latency", str(trace_path)]) + + assert app._config is not None + assert app._config.profile_input_latency + assert app._config.input_trace_path == trace_path.resolve() + session = app.create_session(app.session_desc()) + assert session.session_desc.metadata["trace_chunk_lifecycle_path"] == str( + trace_path.resolve() + ) @pytest.mark.parametrize( diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 10ad1f257..cc6c3d80a 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -5,6 +5,7 @@ from __future__ import annotations +import logging import queue import threading import time @@ -983,6 +984,81 @@ def test_input_latency_profile_correlates_gamepad_state() -> None: assert state._latest_input_latency_ms is not None +def test_input_trace_reports_committed_state_ahead_of_presented_frame(caplog) -> None: + presented_video = torch.zeros(1, 3, 96, 160) + committed_video = torch.ones(1, 3, 96, 160) + state = TaxiHudState( + 160, + 96, + _calibration(), + profile_input_latency=True, + ) + pressed = KeyboardUserInputEvent( + timestamp=np.uint64(300), + key="d", + state=KeyboardInputState.PRESSED, + ) + + with caplog.at_level( + logging.INFO, logger="flashdreams.runtime_v2.chunk_trace" + ): + state.consume_input_events(UserInputEvents([pressed])) + state.publish( + build_hud_frames( + presented_video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + transition_timestamps_us=(300,), + runtime_generation=2, + model_step_index=10, + rollout_epoch=4, + autoregressive_index=1, + simulation_timestamps_us=(1_000,), + cache_finalize_returned_ns=time.monotonic_ns() - 1_000_000, + ) + ) + state.publish( + build_hud_frames( + committed_video, + (_snapshot(),), + np.eye(4, dtype=np.float32)[None], + runtime_generation=2, + model_step_index=11, + rollout_epoch=4, + autoregressive_index=2, + simulation_timestamps_us=(9_000,), + cache_finalize_returned_ns=time.monotonic_ns(), + ) + ) + state.select_presented_frame(presented_video[0]) + + trace = "\n".join(record.getMessage() for record in caplog.records) + assert "phase=input_received" in trace + assert "event_us=300 source=keyboard key=d state=Pressed" in trace + assert "phase=app_frame_presented" in trace + assert "generation=2 step=10 epoch=4 ar=1 frame=0" in trace + assert "step_lead=1 ar_lead=1 simulation_lead_ms=8.0" in trace + assert "event_us=300 ui_to_frame_ms=" in trace + + +def test_input_trace_is_silent_without_opt_in(caplog) -> None: + state = TaxiHudState(160, 96, _calibration()) + pressed = KeyboardUserInputEvent( + timestamp=np.uint64(400), + key="d", + state=KeyboardInputState.PRESSED, + ) + + with caplog.at_level( + logging.INFO, logger="flashdreams.runtime_v2.chunk_trace" + ): + state.consume_input_events(UserInputEvents([pressed])) + + assert "chunk-trace" not in "\n".join( + record.getMessage() for record in caplog.records + ) + + def test_input_latency_window_is_absent_by_default() -> None: state = TaxiHudState(160, 96, _calibration()) imgui = _FakeImGui() diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py index bc4392f66..e98f0b086 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/model.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/model.py @@ -43,6 +43,18 @@ def close(self) -> None: ... EngineFactory = Callable[[], RolloutEngine] +@dataclass(frozen=True, slots=True) +class _WorldModelStepTrace: + """Monotonic lifecycle boundaries for one generated chunk.""" + + engine_step_started_ns: int + engine_step_returned_ns: int + generate_started_ns: int + generate_returned_ns: int + cache_finalize_returned_ns: int + rollout_step_returned_ns: int + + @dataclass(frozen=True, slots=True) class WorldModelStep: """Generated video plus the engine data that produced it.""" @@ -50,6 +62,7 @@ class WorldModelStep: video_bvtchw: Tensor engine: EngineStep metrics: Mapping[str, float | int] + _trace: _WorldModelStepTrace | None = None class WorldModelRollout: @@ -62,11 +75,13 @@ def __init__( scene: SceneDefinition, engine_factory: EngineFactory, postprocess: VideoPostprocessor | None = None, + trace_chunk_lifecycle: bool = False, ) -> None: self.pipeline = pipeline self.scene = scene self._engine_factory = engine_factory self._postprocess = postprocess + self._trace_chunk_lifecycle = trace_chunk_lifecycle self.engine = engine_factory() self.cache = self._new_cache() self._attach_live_edit(None) @@ -98,9 +113,15 @@ def step( rollout_wall_started = time.perf_counter() rollout_cpu_started = time.thread_time() + engine_step_started_ns = ( + time.monotonic_ns() if self._trace_chunk_lifecycle else None + ) engine_wall_started = time.perf_counter() engine_cpu_started = time.thread_time() engine_step = self.engine.step(commands) + engine_step_returned_ns = ( + time.monotonic_ns() if self._trace_chunk_lifecycle else None + ) engine_wall_ms = (time.perf_counter() - engine_wall_started) * 1000.0 engine_cpu_ms = (time.thread_time() - engine_cpu_started) * 1000.0 @@ -115,16 +136,25 @@ def step( engine_step, autoregressive_index, ) + generate_started_ns = ( + time.monotonic_ns() if self._trace_chunk_lifecycle else None + ) with torch.no_grad(): video = self.pipeline.generate( autoregressive_index=autoregressive_index, cache=self.cache, input=engine_step.condition.hdmap_bvtchw, ) + generate_returned_ns = ( + time.monotonic_ns() if self._trace_chunk_lifecycle else None + ) metrics = self.pipeline.finalize( autoregressive_index=autoregressive_index, cache=self.cache, ) + cache_finalize_returned_ns = ( + time.monotonic_ns() if self._trace_chunk_lifecycle else None + ) pipeline_wall_ms = (time.perf_counter() - pipeline_wall_started) * 1000.0 pipeline_cpu_ms = (time.thread_time() - pipeline_cpu_started) * 1000.0 @@ -161,10 +191,31 @@ def step( "rollout_cpu_ms": (time.thread_time() - rollout_cpu_started) * 1000.0, } ) + rollout_step_returned_ns = ( + time.monotonic_ns() if self._trace_chunk_lifecycle else None + ) + trace = None + if engine_step_started_ns is not None: + assert ( + engine_step_returned_ns is not None + and generate_started_ns is not None + and generate_returned_ns is not None + and cache_finalize_returned_ns is not None + and rollout_step_returned_ns is not None + ) + trace = _WorldModelStepTrace( + engine_step_started_ns=engine_step_started_ns, + engine_step_returned_ns=engine_step_returned_ns, + generate_started_ns=generate_started_ns, + generate_returned_ns=generate_returned_ns, + cache_finalize_returned_ns=cache_finalize_returned_ns, + rollout_step_returned_ns=rollout_step_returned_ns, + ) return WorldModelStep( video_bvtchw=video.detach(), engine=engine_step, metrics=step_metrics, + _trace=trace, ) def reset(self) -> None: diff --git a/apps/omnidreams_game_engine/tests/test_model.py b/apps/omnidreams_game_engine/tests/test_model.py index 4dabb67e7..5d26e6b9f 100644 --- a/apps/omnidreams_game_engine/tests/test_model.py +++ b/apps/omnidreams_game_engine/tests/test_model.py @@ -122,6 +122,7 @@ def engine_factory() -> _Engine: pipeline=pipeline, scene=_scene(), engine_factory=engine_factory, + trace_chunk_lifecycle=True, ) result = rollout.step( autoregressive_index=0, @@ -136,6 +137,15 @@ def engine_factory() -> _Engine: assert result.metrics["pipeline_cpu_ms"] >= 0.0 assert result.metrics["rollout_wall_ms"] >= 0.0 assert result.metrics["rollout_cpu_ms"] >= 0.0 + assert result._trace is not None + assert ( + result._trace.engine_step_started_ns + <= result._trace.engine_step_returned_ns + <= result._trace.generate_started_ns + <= result._trace.generate_returned_ns + <= result._trace.cache_finalize_returned_ns + <= result._trace.rollout_step_returned_ns + ) assert [call[0] for call in pipeline.calls] == [ "initialize_cache", "get_num_output_frames", diff --git a/docs/design/crazy_robotaxi_v2_input_latency.md b/docs/design/crazy_robotaxi_v2_input_latency.md index df7d75fa7..c06ac453b 100644 --- a/docs/design/crazy_robotaxi_v2_input_latency.md +++ b/docs/design/crazy_robotaxi_v2_input_latency.md @@ -3,12 +3,13 @@ ## Status Crazy Robotaxi follows the V2 Interactive Drive demo's latest-state input -contract and offers opt-in UI-to-model-frame diagnostics. Keyboard, gamepad, -and wheel events accumulated during an in-flight model step are applied in -order, but only the resulting current command conditions the next chunk. The -V2 WebRTC sender is bounded and drops stale unsent frames under congestion. The -remaining held-input latency is bounded by synchronous autoregressive model -steps: input cannot change a chunk whose inference is already in flight. +contract and offers opt-in input/chunk lifecycle diagnostics. Keyboard, +gamepad, and wheel events accumulated during an in-flight model step are +applied in order, but only the resulting current command conditions the next +chunk. Input cannot change a chunk whose inference is already in flight, and +the lifecycle trace described below distinguishes that boundary from stale +queued presentation and cache state that has advanced past the displayed +frame. ## Reproduction and evidence @@ -18,10 +19,35 @@ Run the app at its trained 1280×704, 30 fps output contract: uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc \ --stats-path /tmp/crazy-robotaxi-latency.json -- \ --model-preset perf \ - --profile-input-latency + --profile-input-latency /tmp/crazy-robotaxi-input-trace.log ``` -The investigation observed: +`--profile-input-latency [TRACE_PATH]` writes grep-friendly lifecycle records +to `TRACE_PATH`, replacing that file at the start of a session and flushing +each record as it is written. Omitting the optional path uses +`/tmp/crazy-robotaxi-input-trace.log`. This trace is separate from the +model-metrics JSON selected by `--stats-path`. Records use the prefixes +`[crazy-robotaxi-chunk-trace]` and `[runtime-v2-chunk-trace]`; correlate them by +`generation`, `step`, `epoch`, `ar`, and `frame`: + +- `input_received` and `input_sampled` show the UI event and the exact command + assigned to each simulation frame. +- `engine_step_started`, `engine_step_returned`, `generate_started`, + `generate_returned`, `cache_finalize_returned`, and + `rollout_step_returned` show model-thread host control flow. +- `publish_started`, `publish_completed`, `chunk_dropped`, and + `frame_presented` show bounded runtime queue behavior. +- `app_frame_presented` reports how many completed model steps, AR chunks, and + milliseconds of simulation the cache-committed state is ahead of the frame + selected for display. +- `rollout_reset` increments `epoch`, preventing a post-reset frame from being + compared with pre-reset mutable state. + +All `time_ns` values are from the CPU monotonic clock. A `*_returned` event +means the host call returned; it deliberately does not synchronize CUDA or +claim that asynchronous GPU work completed at that instant. + +The earlier investigation observed: - A held steering key took more than 1.5 seconds to affect displayed model video across model presets. diff --git a/flashdreams/flashdreams/runtime_v2/presentation_manager.py b/flashdreams/flashdreams/runtime_v2/presentation_manager.py index 61eccedaf..c0aaeead3 100644 --- a/flashdreams/flashdreams/runtime_v2/presentation_manager.py +++ b/flashdreams/flashdreams/runtime_v2/presentation_manager.py @@ -3,8 +3,10 @@ """Buffer and present model frames.""" +import logging import queue import threading +import time from collections.abc import Iterator from contextlib import contextmanager @@ -19,6 +21,9 @@ _PRESENTATION_STREAM_PRIORITY = -1 """Prefer short presentation work over queued model kernels.""" +_TRACE_LOGGER = logging.getLogger("flashdreams.runtime_v2.chunk_trace") +_TRACE_PREFIX = "[runtime-v2-chunk-trace]" + class PresentationManager: """Buffer model output for a session's UI thread. @@ -61,6 +66,7 @@ def __init__(self, *, device: torch.device | None = None) -> None: self._discarded_at_reset = 0 self._stream_lock = threading.Lock() self._infer_stream_device = device is None + self._trace_chunk_lifecycle = False self._presentation_stream: torch.cuda.Stream | None = None if device is not None: device = torch.device(device) @@ -81,6 +87,7 @@ def configure( backpressure_mode: BackpressureMode, stop: threading.Event, put_timeout: float, + trace_chunk_lifecycle: bool = False, ) -> None: """Set the queue size and backpressure mode. @@ -93,6 +100,7 @@ def configure( stop: Session shutdown event, so a blocked publish gives up. put_timeout: How long a blocked publish waits before rechecking ``stop``, in seconds. + trace_chunk_lifecycle: Emit chunk lifecycle diagnostics. Raises: ValueError: ``max_pending`` is not positive. @@ -103,6 +111,7 @@ def configure( self._backpressure_mode = backpressure_mode self._stop = stop self._put_timeout = put_timeout + self._trace_chunk_lifecycle = trace_chunk_lifecycle def publish( self, @@ -138,16 +147,37 @@ def publish( None, ) ) + started_ns: int | None = None + if self._trace_chunk_lifecycle: + started_ns = time.monotonic_ns() + self._trace( + "publish_started", + generation=generation, + step=chunk[0].step_index, + frames=frame_count, + queue_depth=self._buffer.qsize(), + queue_capacity=self._buffer.maxsize, + ) pending = (generation, chunk) if self._backpressure_mode is BackpressureMode.DROP_OLDEST: self._publish_latest(pending) + self._trace_publish_completed(pending, started_ns) return while not self._stop.is_set(): try: self._buffer.put(pending, timeout=self._put_timeout) + self._trace_publish_completed(pending, started_ns) return except queue.Full: continue + if started_ns is not None: + self._trace( + "publish_stopped", + generation=generation, + step=chunk[0].step_index, + wait_ms=(time.monotonic_ns() - started_ns) / 1_000_000.0, + queue_depth=self._buffer.qsize(), + ) @contextmanager def presentation_context(self) -> Iterator[None]: @@ -190,6 +220,12 @@ def advance(self, generation: int) -> tuple[bool, list[StepResult] | None]: already being presented. """ if generation != self._generation: + if self._presented_chunk is not None: + self._trace_drop( + self._generation, + self._presented_chunk, + reason="generation_changed_active", + ) self._generation = generation self._presented_chunk = None self._frame_index = -1 @@ -201,6 +237,7 @@ def advance(self, generation: int) -> tuple[bool, list[StepResult] | None]: ): self._frame_index += 1 self._presented_frame_count += 1 + self._trace_presented_frame(generation) return True, None chunk = self._take_buffered_chunk( @@ -212,6 +249,7 @@ def advance(self, generation: int) -> tuple[bool, list[StepResult] | None]: self._presented_chunk = chunk self._frame_index = 0 self._presented_frame_count += 1 + self._trace_presented_frame(generation) return True, chunk @property @@ -389,9 +427,15 @@ def _publish_latest(self, pending: tuple[int, list[StepResult]]) -> None: return except queue.Full: try: - self._buffer.get_nowait() + dropped_generation, dropped_chunk = self._buffer.get_nowait() with self._counter_lock: self._dropped_for_space += 1 + self._trace_drop( + dropped_generation, + dropped_chunk, + reason="queue_full", + replacement=pending, + ) except queue.Empty: continue @@ -407,14 +451,101 @@ def _take_buffered_chunk( if chunk_generation != generation: with self._counter_lock: self._discarded_at_reset += 1 + self._trace_drop( + chunk_generation, + chunk, + reason="generation_mismatch", + ) continue if selected is not None: with self._counter_lock: self._dropped_for_space += 1 + self._trace_drop( + generation, + selected, + reason="take_latest", + replacement=(chunk_generation, chunk), + ) selected = chunk if not latest: return selected + def _trace_publish_completed( + self, + pending: tuple[int, list[StepResult]], + started_ns: int | None, + ) -> None: + if started_ns is None: + return + generation, chunk = pending + self._trace( + "publish_completed", + generation=generation, + step=chunk[0].step_index, + frames=chunk[0].frame_count, + wait_ms=(time.monotonic_ns() - started_ns) / 1_000_000.0, + queue_depth=self._buffer.qsize(), + queue_capacity=self._buffer.maxsize, + ) + + def _trace_presented_frame(self, generation: int) -> None: + if not self._trace_chunk_lifecycle: + return + chunk = self._presented_chunk + if chunk is None: + return + self._trace( + "frame_presented", + generation=generation, + step=chunk[0].step_index, + frame=self._frame_index, + frames=chunk[0].frame_count, + edge=( + "both" + if chunk[0].frame_count == 1 + else "first" + if self._frame_index == 0 + else "last" + if self._frame_index + 1 == chunk[0].frame_count + else "middle" + ), + queue_depth=self._buffer.qsize(), + ) + + def _trace_drop( + self, + generation: int, + chunk: list[StepResult], + *, + reason: str, + replacement: tuple[int, list[StepResult]] | None = None, + ) -> None: + if not self._trace_chunk_lifecycle: + return + fields: dict[str, object] = { + "generation": generation, + "step": chunk[0].step_index, + "reason": reason, + "queue_depth": self._buffer.qsize(), + } + if replacement is not None: + replacement_generation, replacement_chunk = replacement + fields["replacement_generation"] = replacement_generation + fields["replacement_step"] = replacement_chunk[0].step_index + self._trace("chunk_dropped", **fields) + + def _trace(self, phase: str, **fields: object) -> None: + if not self._trace_chunk_lifecycle: + return + details = " ".join(f"{name}={value}" for name, value in fields.items()) + _TRACE_LOGGER.info( + "%s phase=%s time_ns=%d %s", + _TRACE_PREFIX, + phase, + time.monotonic_ns(), + details, + ) + def _frame_at(result: StepResult, frame_index: int) -> Tensor: """Return one result frame as ``[C, H, W]``.""" diff --git a/flashdreams/flashdreams/runtime_v2/session_runner.py b/flashdreams/flashdreams/runtime_v2/session_runner.py index 7c6bca491..2bb86f8c6 100644 --- a/flashdreams/flashdreams/runtime_v2/session_runner.py +++ b/flashdreams/flashdreams/runtime_v2/session_runner.py @@ -6,6 +6,8 @@ import logging import threading import time +from dataclasses import dataclass +from pathlib import Path from flashdreams.api_v2.client_window import IClientWindow from flashdreams.api_v2.loop import IModelLoop, IUILoop @@ -26,6 +28,20 @@ _MODEL_FPS_WINDOW_SECONDS = 2.0 """Wall-time window used to estimate generated-frame throughput.""" +_TRACE_METADATA_KEY = "trace_chunk_lifecycle" +_TRACE_PATH_METADATA_KEY = "trace_chunk_lifecycle_path" +_TRACE_LOGGER = logging.getLogger("flashdreams.runtime_v2.chunk_trace") +_TRACE_PREFIX = "[runtime-v2-chunk-trace]" + + +@dataclass(frozen=True, slots=True) +class _ChunkTraceLog: + """Logger state restored after one traced session.""" + + handler: logging.FileHandler + previous_level: int + previous_propagate: bool + class _PresentationClock: """Schedule model-frame advances at recent model-step throughput.""" @@ -185,11 +201,13 @@ def run_session( event_buffer = EventBuffer() stop = session._shutdown_event presentation_manager = session._presentation_manager + trace_chunk_lifecycle = session_desc.metadata.get(_TRACE_METADATA_KEY) is True presentation_manager.configure( max_pending=max_pending, backpressure_mode=session_desc.backpressure_mode, stop=stop, put_timeout=tick_seconds, + trace_chunk_lifecycle=trace_chunk_lifecycle, ) model_thread_handle: threading.Thread | None = None ui_loop: IUILoop[object] | None = None @@ -254,7 +272,28 @@ def tick_ui() -> None: return run_ui_once() + trace_log = ( + _open_chunk_trace(session_desc.metadata.get(_TRACE_PATH_METADATA_KEY)) + if trace_chunk_lifecycle + else None + ) try: + if trace_chunk_lifecycle: + _TRACE_LOGGER.info( + "%s phase=session_config time_ns=%d backpressure=%s " + "presentation=%s max_pending=%d step_fps=%d ui_fps=%d " + "width=%d height=%d trace_path=%s", + _TRACE_PREFIX, + time.monotonic_ns(), + session_desc.backpressure_mode.value, + session_desc.presentation_mode.value, + max_pending, + session_desc.frames_per_second_for_step, + session_desc.frames_per_second_for_ui, + session_desc.video_width, + session_desc.video_height, + trace_log.handler.baseFilename if trace_log is not None else "none", + ) session.init() registered_ui, registered_model = session._take_loops() ui_loop = registered_ui @@ -336,6 +375,11 @@ def tick_ui() -> None: session.close() except BaseException as error: cleanup_failures.append(error) + if trace_log is not None: + try: + _close_chunk_trace(trace_log) + except BaseException as error: + cleanup_failures.append(error) loop_failures = ( None if session._failure_queue.empty() else session._failure_queue.get() @@ -362,4 +406,34 @@ def tick_ui() -> None: raise primary_failure +def _open_chunk_trace(path_value: object) -> _ChunkTraceLog: + """Open a line-buffered lifecycle trace for one session.""" + if not isinstance(path_value, str | Path): + raise TypeError( + f"{_TRACE_PATH_METADATA_KEY} must be a filesystem path when tracing" + ) + path = Path(path_value).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.FileHandler(path, mode="w", encoding="utf-8") + handler.setFormatter(logging.Formatter("%(message)s")) + # ponytail: this process-global logger assumes one active traced V2 session; + # pass a per-session sink through the loop contracts if concurrent sessions land. + previous_level = _TRACE_LOGGER.level + previous_propagate = _TRACE_LOGGER.propagate + _TRACE_LOGGER.addHandler(handler) + _TRACE_LOGGER.setLevel(logging.INFO) + _TRACE_LOGGER.propagate = False + return _ChunkTraceLog(handler, previous_level, previous_propagate) + + +def _close_chunk_trace(trace_log: _ChunkTraceLog) -> None: + """Flush and close a session trace, restoring the shared logger.""" + _TRACE_LOGGER.removeHandler(trace_log.handler) + try: + trace_log.handler.close() + finally: + _TRACE_LOGGER.setLevel(trace_log.previous_level) + _TRACE_LOGGER.propagate = trace_log.previous_propagate + + __all__ = ["run_session"] diff --git a/flashdreams/test_v2/test_presentation_manager_trace.py b/flashdreams/test_v2/test_presentation_manager_trace.py new file mode 100644 index 000000000..a05705873 --- /dev/null +++ b/flashdreams/test_v2/test_presentation_manager_trace.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CPU checks for V2 chunk-lifecycle diagnostics.""" + +import logging +import threading + +import pytest +import torch + +from flashdreams.runtime_v2.presentation_manager import PresentationManager +from flashdreams.runtime_v2.session_desc import BackpressureMode +from flashdreams.runtime_v2.session_runner import ( + _close_chunk_trace, + _open_chunk_trace, +) +from flashdreams.runtime_v2.step_result import StepResult +from flashdreams.runtime_v2.video_tensor import VideoTensorLayout + +pytestmark = pytest.mark.ci_cpu + +_LOGGER = "flashdreams.runtime_v2.chunk_trace" + + +def _result(step_index: int, frames: int = 2) -> StepResult: + return StepResult( + step_index=step_index, + output=torch.zeros(frames, 3, 1, 1), + frame_count=frames, + output_layout=VideoTensorLayout.tchw, + ) + + +def test_opt_in_trace_correlates_publish_drop_and_present(caplog) -> None: + manager = PresentationManager(device=torch.device("cpu")) + manager.configure( + max_pending=1, + backpressure_mode=BackpressureMode.DROP_OLDEST, + stop=threading.Event(), + put_timeout=0.01, + trace_chunk_lifecycle=True, + ) + + with caplog.at_level(logging.INFO, logger=_LOGGER): + manager.publish(3, [_result(10)]) + assert manager.advance(3)[0] + manager.publish(3, [_result(11)]) + manager.publish(3, [_result(12)]) + assert manager.advance(3)[0] + assert manager.advance(3)[0] + + trace = "\n".join( + record.getMessage() for record in caplog.records if record.name == _LOGGER + ) + assert "phase=publish_started" in trace + assert "phase=publish_completed" in trace + assert "phase=chunk_dropped" in trace + assert "step=11 reason=queue_full" in trace + assert "replacement_step=12" in trace + assert "phase=frame_presented" in trace + assert "generation=3 step=10 frame=0" in trace + assert "generation=3 step=12 frame=0" in trace + + +def test_trace_is_silent_by_default(caplog) -> None: + manager = PresentationManager(device=torch.device("cpu")) + + with caplog.at_level(logging.INFO, logger=_LOGGER): + manager.publish(0, [_result(0, frames=1)]) + manager.advance(0) + + assert not [record for record in caplog.records if record.name == _LOGGER] + + +def test_trace_file_receives_records_and_closes(tmp_path) -> None: + trace_path = tmp_path / "nested" / "input-trace.log" + manager = PresentationManager(device=torch.device("cpu")) + manager.configure( + max_pending=1, + backpressure_mode=BackpressureMode.BLOCK, + stop=threading.Event(), + put_timeout=0.01, + trace_chunk_lifecycle=True, + ) + + trace_log = _open_chunk_trace(trace_path) + try: + manager.publish(4, [_result(20, frames=1)]) + manager.advance(4) + finally: + _close_chunk_trace(trace_log) + + trace = trace_path.read_text(encoding="utf-8") + assert "phase=publish_started" in trace + assert "generation=4 step=20" in trace + assert "phase=frame_presented" in trace From e6224c9f654594344b0cd084b3d84acb06775015 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 12:01:14 -0700 Subject: [PATCH 51/62] Stabilize Crazy Robotaxi input Signed-off-by: Aidan Foster --- .../omnidreams_game_engine/input.py | 37 +++++++++-- .../tests/test_input.py | 63 +++++++++++++++++++ .../design/crazy_robotaxi_v2_input_latency.md | 23 +++---- .../runtime_v2/native_window_client_window.py | 8 --- .../test_native_window_client_window.py | 34 ++++++++++ 5 files changed, 142 insertions(+), 23 deletions(-) diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py index 420ee5572..ff2827d11 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/input.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/input.py @@ -80,13 +80,42 @@ def sample( self._pending_transitions.clear() command = self._sampled_command transition_index = 0 + while ( + transition_index < len(transitions) + and transitions[transition_index].timestamp_s <= window.start_s + ): + transition_index += 1 + stale_transitions = transitions[:transition_index] + replayed_transition: _DriverTransition | None = None + if stale_transitions: + current_command = stale_transitions[-1].command + if current_command == command: + replayed_transition = next( + ( + transition + for transition in reversed(stale_transitions[:-1]) + if transition.command != current_command + ), + None, + ) + command = current_command commands: list[DriverCommand] = [] transition_timestamps_us: list[int | None] = [] - # ponytail: controls quantize to one physics frame; integrate timed - # segments here if sub-frame inputs become necessary. + # ponytail: A completed tap that arrives behind the model clock gets one + # physics frame. Interruptible generation is the upgrade for true + # mid-step timing. frame_starts_s = (window.start_s, *window.sample_times_s[:-1]) - for frame_start_s in frame_starts_s: - latest_timestamp_us: int | None = None + for frame_index, frame_start_s in enumerate(frame_starts_s): + if frame_index == 0 and replayed_transition is not None: + commands.append(replayed_transition.command) + transition_timestamps_us.append(replayed_transition.timestamp_us) + continue + latest_timestamp_us = ( + stale_transitions[-1].timestamp_us + if stale_transitions + and frame_index == (1 if replayed_transition is not None else 0) + else None + ) while ( transition_index < len(transitions) and transitions[transition_index].timestamp_s <= frame_start_s diff --git a/apps/omnidreams_game_engine/tests/test_input.py b/apps/omnidreams_game_engine/tests/test_input.py index f7c348daf..9965dc9fa 100644 --- a/apps/omnidreams_game_engine/tests/test_input.py +++ b/apps/omnidreams_game_engine/tests/test_input.py @@ -218,6 +218,69 @@ def test_timestamped_tap_is_preserved_across_physics_frames() -> None: assert timestamps == (1_000_000, None, None, 1_100_000, None) +def test_completed_tap_behind_model_clock_gets_one_physics_frame() -> None: + state = DriverInput() + timeline = RealtimeInputTimeline(samples_per_second=30.0) + state.sample(timeline.next_window(8)) + input_times_s = state.apply( + UserInputEvents( + [ + replace( + _key("d", KeyboardInputState.PRESSED), + timestamp=np.uint64(50_000), + ), + replace( + _key("d", KeyboardInputState.RELEASED), + timestamp=np.uint64(150_000), + ), + ] + ) + ) + + commands, timestamps = state.sample( + timeline.next_window(8, input_times_s=input_times_s) + ) + following, _ = state.sample(timeline.next_window(1)) + + assert [command.steer for command in commands] == [-1.0] + [0.0] * 7 + assert timestamps == (50_000, 150_000, None, None, None, None, None, None) + assert following == (DriverCommand(),) + + +def test_stale_release_of_sampled_command_is_not_replayed() -> None: + state = DriverInput() + timeline = RealtimeInputTimeline(samples_per_second=30.0) + pressed_at_s = state.apply( + UserInputEvents( + [ + replace( + _key("d", KeyboardInputState.PRESSED), + timestamp=np.uint64(0), + ) + ] + ) + ) + held, _ = state.sample(timeline.next_window(8, input_times_s=pressed_at_s)) + released_at_s = state.apply( + UserInputEvents( + [ + replace( + _key("d", KeyboardInputState.RELEASED), + timestamp=np.uint64(150_000), + ) + ] + ) + ) + + released, timestamps = state.sample( + timeline.next_window(8, input_times_s=released_at_s) + ) + + assert all(command.steer == -1.0 for command in held) + assert released == (DriverCommand(),) * 8 + assert timestamps == (150_000, None, None, None, None, None, None, None) + + def test_focus_loss_releases_sampled_keyboard_input() -> None: state = DriverInput() timeline = RealtimeInputTimeline(samples_per_second=30.0) diff --git a/docs/design/crazy_robotaxi_v2_input_latency.md b/docs/design/crazy_robotaxi_v2_input_latency.md index c06ac453b..b94ca761c 100644 --- a/docs/design/crazy_robotaxi_v2_input_latency.md +++ b/docs/design/crazy_robotaxi_v2_input_latency.md @@ -5,11 +5,12 @@ Crazy Robotaxi follows the V2 Interactive Drive demo's latest-state input contract and offers opt-in input/chunk lifecycle diagnostics. Keyboard, gamepad, and wheel events accumulated during an in-flight model step are -applied in order, but only the resulting current command conditions the next -chunk. Input cannot change a chunk whose inference is already in flight, and -the lifecycle trace described below distinguishes that boundary from stale -queued presentation and cache state that has advanced past the displayed -frame. +applied in order. A completed tap that would otherwise collapse to its final +state behind the model clock is bounded to one physics frame; a release from a +command the model already sampled still takes effect immediately. Input cannot +change a chunk whose inference is already in flight, and the lifecycle trace +described below distinguishes that boundary from stale queued presentation and +cache state that has advanced past the displayed frame. ## Reproduction and evidence @@ -64,12 +65,12 @@ The earlier investigation observed: V2 reads model events immediately before calling the synchronous model-loop `step`. Crazy Robotaxi applies the unread batch in order, retains the final -keyboard or controller state, and repeats its resulting command across the -next model chunk. An input arriving during the measured 760 ms step therefore -waits for that step to finish and for the next conditioned step to finish. This -creates an application/model response window of roughly 760–1520 ms before -final WebRTC delivery. Smaller or preemptible model work is required to reduce -that floor. +keyboard or controller state, and preserves an otherwise invisible completed +tap for one physics frame. An input arriving during the measured 760 ms step +therefore waits for that step to finish and for the next conditioned step to +finish. This creates an application/model response window of roughly 760–1520 +ms before final WebRTC delivery. Smaller or preemptible model work is required +to reduce that floor. ## V2 transport behavior diff --git a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py index 3f1d787ea..cfbdb2809 100644 --- a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py +++ b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py @@ -263,14 +263,6 @@ def _on_keyboard_event(self, event: spy.KeyboardEvent) -> None: physical_key, keyboard_event = self._pending_printable_keys.pop() self._pressed_key_values[physical_key] = text self._put_input(replace(keyboard_event, key=text)) - else: - self._put_input( - KeyboardUserInputEvent( - timestamp=uint64(0), - key=text, - state=KeyboardInputState.PRESSED, - ) - ) return keyboard_event = _keyboard_event(event) diff --git a/flashdreams/test_v2/test_native_window_client_window.py b/flashdreams/test_v2/test_native_window_client_window.py index ab9b35de8..e70c941c0 100644 --- a/flashdreams/test_v2/test_native_window_client_window.py +++ b/flashdreams/test_v2/test_native_window_client_window.py @@ -70,6 +70,14 @@ def is_input(self) -> bool: return False +class _KeyboardRepeatEvent(_KeyboardEvent): + def __init__(self, key: str) -> None: + super().__init__(key, pressed=False) + + def is_key_release(self) -> bool: + return False + + class _TextInputEvent: def __init__(self, text: str) -> None: self.codepoint = ord(text) @@ -464,6 +472,32 @@ def test_native_text_input_uses_slangpy_resolved_shift_character() -> None: ] +def test_native_text_input_discards_repeat_callbacks_after_release() -> None: + presenter = _Presenter() + window = NativeWindowClientWindow(presenter_factory=_presenter_factory(presenter)) + window.open(_session_desc()) + presenter.pending_events.put(("keyboard", _KeyboardEvent("d", pressed=True))) + presenter.pending_events.put(("keyboard", _TextInputEvent("d"))) + + pressed = window.get_user_input_events().get_events() + presenter.pending_events.put(("keyboard", _KeyboardRepeatEvent("d"))) + assert window.get_user_input_events().get_events() == [] + presenter.pending_events.put(("keyboard", _TextInputEvent("d"))) + assert window.get_user_input_events().get_events() == [] + + presenter.pending_events.put(("keyboard", _KeyboardRepeatEvent("d"))) + presenter.pending_events.put(("keyboard", _KeyboardEvent("d", pressed=False))) + released = window.get_user_input_events().get_events() + presenter.pending_events.put(("keyboard", _TextInputEvent("d"))) + assert window.get_user_input_events().get_events() == [] + window.close() + + assert [(event.key, event.state) for event in (*pressed, *released)] == [ + ("d", KeyboardInputState.PRESSED), + ("d", KeyboardInputState.RELEASED), + ] + + @pytest.mark.parametrize("text", ("a", "A")) def test_native_text_input_coalesces_across_event_polls(text: str) -> None: presenter = _Presenter() From 0667cad750d5699aad43d6f3219056063365bb15 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 11:22:57 -0700 Subject: [PATCH 52/62] Polish Crazy Robotaxi game over screen --- apps/crazy_robotaxi/crazy_robotaxi/session.py | 10 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 284 +++++++++++++++--- apps/crazy_robotaxi/tests/test_application.py | 47 ++- apps/crazy_robotaxi/tests/test_ui.py | 263 +++++++++++++++- 4 files changed, 546 insertions(+), 58 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 59c971d9e..5754aade9 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -280,6 +280,11 @@ def reset(self) -> None: if self.rollout is not None: self.rollout.reset() + def restart_game(self) -> None: + """Reset the active rollout and its UI state.""" + self.reset() + invoke_async(self.ui_loop, lambda ui_state: ui_state.reset()) + def close(self) -> None: rollout = self.rollout self.rollout = None @@ -319,9 +324,8 @@ def step(self, step_index: int, events: UserInputEvents) -> list[StepResult]: snapshot = rollout.engine.current_game_frame if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): raise TypeError("Crazy Robotaxi engine returned an unknown game frame") - if snapshot.session_state != "awaiting_name" and _restart_requested(events): - state.reset() - invoke_async(state.ui_loop, lambda ui_state: ui_state.reset()) + if _restart_requested(events): + state.restart_game() rollout = state.ensure_rollout() snapshot = rollout.engine.current_game_frame if not isinstance(snapshot, (TaxiGameSnapshot, RaceGameSnapshot)): diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index f63595110..378282ee4 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -1368,67 +1368,235 @@ def _draw_terminal( leaderboard = snapshot.session_state == "leaderboard" if not (awaiting_name or leaderboard): return + race = isinstance(snapshot, RaceGameSnapshot) + accent_rgb = ( + (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) + if race + else (118.0 / 255.0, 185.0 / 255.0, 0.0) + ) + margin = 16.0 + card_width = max(1.0, min(620.0, float(self.width) - 2.0 * margin)) + card_height = max(1.0, min(540.0, float(self.height) - 2.0 * margin)) + card_left = (float(self.width) - card_width) * 0.5 + card_top = (float(self.height) - card_height) * 0.5 + scale = min(1.0, card_width / 620.0, card_height / 540.0) + + draw_list = imgui.get_background_draw_list() + draw_list.add_rect_filled( + imgui.ImVec2(0.0, 0.0), + imgui.ImVec2(float(self.width), float(self.height)), + _imgui_color(imgui, (0.0, 0.0, 0.0, 0.58)), + ) _prepare_window( imgui, - position=( - float(max(20, self.width // 2 - 270)), - float(max(20, self.height // 2 - 220)), - ), - size=(540.0, 440.0), + position=(card_left, card_top), + size=(card_width, card_height), + alpha=0.97, + ) + style_vars = ( + (imgui.StyleVar_.window_rounding, 16.0), + (imgui.StyleVar_.window_border_size, 2.0), + (imgui.StyleVar_.window_padding, imgui.ImVec2(28.0, 24.0)), + (imgui.StyleVar_.item_spacing, imgui.ImVec2(10.0, 10.0)), + (imgui.StyleVar_.frame_rounding, 7.0), + (imgui.StyleVar_.frame_padding, imgui.ImVec2(10.0, 8.0)), + ) + style_colors = ( + (imgui.Col_.window_bg, (0.047, 0.047, 0.071, 0.98)), + (imgui.Col_.border, (*accent_rgb, 0.95)), + (imgui.Col_.text, (0.94, 0.94, 0.97, 1.0)), + (imgui.Col_.text_disabled, (0.58, 0.58, 0.64, 1.0)), + (imgui.Col_.frame_bg, (0.09, 0.09, 0.13, 1.0)), + (imgui.Col_.frame_bg_hovered, (0.13, 0.13, 0.18, 1.0)), + (imgui.Col_.frame_bg_active, (0.16, 0.16, 0.22, 1.0)), + (imgui.Col_.button, (*accent_rgb, 0.78)), + (imgui.Col_.button_hovered, (*accent_rgb, 1.0)), + (imgui.Col_.button_active, (*accent_rgb, 0.62)), ) - visible = _begin_window(imgui, "Game Over") + for style_var, value in style_vars: + imgui.push_style_var(style_var, value) + for color, value in style_colors: + imgui.push_style_color(color, imgui.ImVec4(*value)) + visible = _begin_window(imgui, "Game Over", extra_flags=("no_title_bar",)) try: if not visible: return - race = isinstance(snapshot, RaceGameSnapshot) - imgui.text( + imgui.dummy(imgui.ImVec2(0.0, max(2.0, 8.0 * scale))) + headline = ( ("NEW BEST TIME" if race else "NEW HIGH SCORE") if awaiting_name - else "LEADERBOARD" + else ("RACE COMPLETE" if race else "GAME OVER") + ) + _centered_imgui_text( + imgui, + headline, + font=self._gameplay_overlay_font(imgui), + font_size=max(22.0, 38.0 * scale), + color=(*accent_rgb, 1.0), + ) + _centered_imgui_text( + imgui, + "FINAL TIME" if race else "FINAL SCORE", + font_size=max(13.0, 15.0 * scale), + color=(0.62, 0.62, 0.68, 1.0), ) if race: - imgui.text( - "FINAL TIME " + format_race_time_us(snapshot.final_time_us or 0) - ) + result = format_race_time_us(snapshot.final_time_us or 0) else: - imgui.text(f"FINAL SCORE {snapshot.score:06d}") + result = f"{snapshot.score:06d}" + _centered_imgui_text( + imgui, + result, + font=self._gameplay_overlay_font(imgui), + font_size=max(28.0, 50.0 * scale), + ) if snapshot.high_score_rank is not None: - imgui.text(f"RANK #{snapshot.high_score_rank}") + _centered_imgui_text( + imgui, + f"RANK #{snapshot.high_score_rank}", + font_size=max(13.0, 17.0 * scale), + color=(*accent_rgb, 1.0), + ) + imgui.separator() if awaiting_name: - disabled = self._submission_pending - if disabled: - imgui.begin_disabled() - try: - submitted, self._name_input = imgui.input_text( - "Driver name", - self._name_input, - flags=imgui.InputTextFlags_.enter_returns_true, - ) - clicked = imgui.button("Submit time" if race else "Submit score") - finally: - if disabled: - imgui.end_disabled() - if submitted or clicked: - self._submit_name(self._name_input) - if self._validation_message: - imgui.text(self._validation_message) + self._draw_terminal_name_entry(imgui, race, accent_rgb, scale) else: - if race: - entries = "\n".join( - f"{rank:>2}. {entry.name:<12} " - f"{format_race_time_us(entry.elapsed_time_us)}" - for rank, entry in enumerate(snapshot.leaderboard, start=1) - ) - else: - entries = "\n".join( - f"{rank:>2}. {entry.name:<12} {entry.score:>7}" - for rank, entry in enumerate(snapshot.leaderboard, start=1) - ) - entries = entries or "NO SCORES YET" - imgui.text(entries) - imgui.text("Press R to play again") + self._draw_terminal_leaderboard(imgui, snapshot, race, accent_rgb) + imgui.separator() + action_width = _point_xy(imgui.get_content_region_avail())[0] + if imgui.button( + "PLAY AGAIN", + imgui.ImVec2(action_width, max(34.0, 44.0 * scale)), + ): + self._request_restart() + _centered_imgui_text( + imgui, + "R RESTART · ESC MAP", + font_size=max(12.0, 13.0 * scale), + color=(0.58, 0.58, 0.64, 1.0), + ) finally: imgui.end() + imgui.pop_style_color(len(style_colors)) + imgui.pop_style_var(len(style_vars)) + + def _draw_terminal_name_entry( + self, + imgui: Any, + race: bool, + accent_rgb: tuple[float, float, float], + scale: float, + ) -> None: + """Draw terminal name entry and submission feedback.""" + _centered_imgui_text( + imgui, + "ENTER DRIVER NAME", + font_size=max(13.0, 16.0 * scale), + ) + imgui.set_next_item_width(-1.0) + disabled = self._submission_pending + if disabled: + imgui.begin_disabled() + try: + submitted, self._name_input = imgui.input_text( + "##driver-name", + self._name_input, + flags=imgui.InputTextFlags_.enter_returns_true, + ) + submit_width = _point_xy(imgui.get_content_region_avail())[0] + clicked = imgui.button( + "SAVE TIME" if race else "SAVE SCORE", + imgui.ImVec2(submit_width, max(32.0, 40.0 * scale)), + ) + finally: + if disabled: + imgui.end_disabled() + if submitted or clicked: + self._submit_name(self._name_input) + if self._validation_message: + color = ( + (*accent_rgb, 1.0) + if self._submission_pending + else (1.0, 0.38, 0.32, 1.0) + ) + _centered_imgui_text( + imgui, + self._validation_message, + font_size=max(12.0, 13.0 * scale), + color=color, + ) + + def _draw_terminal_leaderboard( + self, + imgui: Any, + snapshot: TaxiGameSnapshot | RaceGameSnapshot, + race: bool, + accent_rgb: tuple[float, float, float], + ) -> None: + """Draw the ranked terminal results table.""" + _centered_imgui_text(imgui, "LEADERBOARD", font_size=16.0) + entries = snapshot.leaderboard + if not entries: + _centered_imgui_text( + imgui, + "NO SCORES YET", + font_size=14.0, + color=(0.62, 0.62, 0.68, 1.0), + ) + return + available_height = _point_xy(imgui.get_content_region_avail())[1] + table_height = max(90.0, min(250.0, available_height - 92.0)) + table_flags = ( + imgui.TableFlags_.row_bg + | imgui.TableFlags_.borders_inner_h + | imgui.TableFlags_.no_saved_settings + | imgui.TableFlags_.sizing_stretch_prop + | imgui.TableFlags_.scroll_y + ) + if not imgui.begin_table( + "##leaderboard", + 3, + flags=table_flags, + outer_size=imgui.ImVec2(0.0, table_height), + ): + return + try: + imgui.table_setup_column("RANK", imgui.TableColumnFlags_.width_fixed, 64.0) + imgui.table_setup_column( + "DRIVER", imgui.TableColumnFlags_.width_stretch, 1.0 + ) + imgui.table_setup_column( + "TIME" if race else "SCORE", + imgui.TableColumnFlags_.width_fixed, + 128.0, + ) + imgui.table_headers_row() + for rank, entry in enumerate(entries, start=1): + imgui.table_next_row(min_row_height=26.0) + if rank == snapshot.high_score_rank: + imgui.table_set_bg_color( + imgui.TableBgTarget_.row_bg1, + _imgui_color(imgui, (*accent_rgb, 0.24)), + ) + values = ( + f"#{rank}", + entry.name, + ( + format_race_time_us(entry.elapsed_time_us) + if race + else f"{entry.score:>7}" + ), + ) + for column, value in enumerate(values): + imgui.table_set_column_index(column) + imgui.text(value) + finally: + imgui.end_table() + + def _request_restart(self) -> None: + """Queue a game restart on the model thread.""" + if self.model_loop is not None: + invoke_async(self.model_loop, lambda state: state.restart_game()) def _submit_name(self, value: str) -> None: if self._submission_pending: @@ -1700,6 +1868,32 @@ def _overlay_text_size( return width * scale, height * scale +def _centered_imgui_text( + imgui: Any, + text: str, + *, + font_size: float, + font: Any | None = None, + color: tuple[float, float, float, float] | None = None, +) -> None: + """Draw one centered ImGui text item.""" + cursor_x = float(imgui.get_cursor_pos_x()) + available_width = _point_xy(imgui.get_content_region_avail())[0] + imgui.push_font(font, float(font_size)) + if color is not None: + imgui.push_style_color(imgui.Col_.text, imgui.ImVec4(*color)) + try: + text_width = _point_xy(imgui.calc_text_size(text))[0] + imgui.set_cursor_pos_x( + cursor_x + max(0.0, (available_width - text_width) * 0.5) + ) + imgui.text(text) + finally: + if color is not None: + imgui.pop_style_color() + imgui.pop_font() + + def _draw_overlay_text( imgui: Any, draw_list: Any, diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index cab9d7d56..9e9625301 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -317,6 +317,48 @@ def test_pressed_gamepad_start_requests_a_v2_game_restart() -> None: ) ) +def test_pressed_r_can_discard_an_unsubmitted_score() -> None: + class RestartRequested(Exception): + pass + + snapshot = TaxiGameSnapshot( + phase="seeking_pickup", + target_xyz_m=(25.0, 0.0, 0.0), + distance_m=25.0, + relative_bearing_rad=0.0, + target_radius_m=5.0, + remaining_time_s=None, + score=1200, + global_remaining_time_s=0.0, + session_state="awaiting_name", + high_score_rank=1, + ) + + class ProbeState: + game_selected = True + + def __init__(self) -> None: + self.driver_input = DriverInput() + + @staticmethod + def ensure_rollout() -> object: + return SimpleNamespace(engine=SimpleNamespace(current_game_frame=snapshot)) + + @staticmethod + def restart_game() -> None: + raise RestartRequested + + pressed = KeyboardUserInputEvent( + timestamp=np.uint64(1), + key="R", + state=KeyboardInputState.PRESSED, + ) + loop = CrazyRobotaxiModelLoop() + loop.state = cast(Any, ProbeState()) + + with pytest.raises(RestartRequested): + loop.step(0, UserInputEvents([pressed])) + def test_model_input_is_applied_before_rollout_work() -> None: class InputOrderVerified(Exception): @@ -721,8 +763,11 @@ def close(self) -> None: assert model_loop.state.ensure_rollout() is rollout assert len(rollout.steps) == 4 - model_loop.state.reset() + ui_loop.state._name_input = "DRIVER 7" + model_loop.state.restart_game() + ui_loop._run_message_batch() assert rollout.reset_count == 2 + assert ui_loop.state._name_input == "" assert len(rollout.steps) == 4 diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index cc6c3d80a..d856b4cda 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -18,6 +18,8 @@ import pytest import torch from crazy_robotaxi.game_selection import GameMapOption, GameSelection +from crazy_robotaxi.high_scores import HighScoreEntry, RaceTimeEntry +from crazy_robotaxi.race import RaceGameSnapshot, RaceSessionState from crazy_robotaxi.rules import ( TaxiGameSnapshot, TaxiSessionState, @@ -76,6 +78,29 @@ def _snapshot(*, session_state: TaxiSessionState = "playing") -> TaxiGameSnapsho ) +def _race_snapshot(*, session_state: RaceSessionState = "racing") -> RaceGameSnapshot: + return RaceGameSnapshot( + map_id="test-city", + course_id="downtown-sprint", + session_state=session_state, + target_kind="start", + target_element_id="start", + target_xyz_m=(25.0, 0.0, 0.0), + gate_start_xyz_m=(25.0, -5.0, 0.0), + gate_end_xyz_m=(25.0, 5.0, 0.0), + checkpoint_markers=True, + distance_m=25.0, + relative_bearing_rad=0.0, + checkpoint_index=0, + checkpoint_count=3, + completed_laps=1, + lap_count=1, + elapsed_time_us=42_345_000, + best_time_us=41_000_000, + final_time_us=42_345_000, + ) + + class _FakeDrawList: def __init__(self) -> None: self.commands: list[tuple[str, tuple[Any, ...]]] = [] @@ -133,18 +158,56 @@ class _FakeImGui: no_background=32, ) InputTextFlags_ = SimpleNamespace(enter_returns_true=1) + StyleVar_ = SimpleNamespace( + window_rounding=1, + window_border_size=2, + window_padding=3, + item_spacing=4, + frame_rounding=5, + frame_padding=6, + ) + Col_ = SimpleNamespace( + text=1, + text_disabled=2, + window_bg=3, + border=4, + frame_bg=5, + frame_bg_hovered=6, + frame_bg_active=7, + button=8, + button_hovered=9, + button_active=10, + ) + TableFlags_ = SimpleNamespace( + row_bg=1, + borders_inner_h=2, + no_saved_settings=4, + sizing_stretch_prop=8, + scroll_y=16, + ) + TableColumnFlags_ = SimpleNamespace(width_fixed=1, width_stretch=2) + TableBgTarget_ = SimpleNamespace(row_bg1=1) def __init__(self) -> None: self.windows: dict[str, list[str]] = {} + self.text_fonts: list[tuple[str, object, float]] = [] self.dummies: list[tuple[float, float]] = [] self.current_window: str | None = None self.next_window_position = (0.0, 0.0) + self.next_window_size = (640.0, 360.0) + self.cursor_x = 8.0 self.input_value = "" self.submit_input = False self.click_submit = False self.clicked_buttons: set[str] = set() + self.buttons: list[str] = [] self.background_draw_list = _FakeDrawList() self.window_flags: dict[str, int] = {} + self.tables: dict[str, list[list[str]]] = {} + self.table_columns: dict[str, list[str]] = {} + self.highlighted_rows: list[int] = [] + self.current_table: str | None = None + self.current_table_column = 0 self.default_font = object() self.current_font = self.default_font self.current_font_size = 14.0 @@ -179,21 +242,38 @@ def get_io(self) -> SimpleNamespace: def push_font(self, font: object, size: float) -> None: self.font_stack.append((self.current_font, self.current_font_size)) - self.current_font = font + if font is not None: + self.current_font = font self.current_font_size = size def pop_font(self) -> None: self.current_font, self.current_font_size = self.font_stack.pop() + def push_style_var(self, style_var: int, value: object) -> None: + del style_var, value + + def pop_style_var(self, count: int = 1) -> None: + del count + + def push_style_color(self, color: int, value: object) -> None: + del color, value + + def pop_style_color(self, count: int = 1) -> None: + del count + def get_background_draw_list(self) -> _FakeDrawList: return self.background_draw_list + def get_window_draw_list(self) -> _FakeDrawList: + return self.background_draw_list + def set_next_window_pos(self, position, condition) -> None: self.next_window_position = position del condition def set_next_window_size(self, size, condition) -> None: - del size, condition + self.next_window_size = size + del condition def set_next_window_bg_alpha(self, alpha) -> None: del alpha @@ -210,6 +290,30 @@ def end(self) -> None: def text(self, value: str) -> None: assert self.current_window is not None self.windows[self.current_window].append(value) + self.text_fonts.append((value, self.current_font, self.current_font_size)) + if self.current_table is not None: + rows = self.tables[self.current_table] + while len(rows[-1]) <= self.current_table_column: + rows[-1].append("") + rows[-1][self.current_table_column] = value + + def get_window_pos(self) -> tuple[float, float]: + return self.next_window_position + + def get_window_size(self) -> tuple[float, float]: + return self.next_window_size + + def get_cursor_pos_x(self) -> float: + return self.cursor_x + + def set_cursor_pos_x(self, value: float) -> None: + self.cursor_x = value + + def get_content_region_avail(self) -> tuple[float, float]: + return ( + max(1.0, float(self.next_window_size[0]) - 56.0), + max(1.0, float(self.next_window_size[1]) - 48.0), + ) def get_cursor_screen_pos(self) -> tuple[float, float]: flags = self.window_flags.get(self.current_window or "", 0) @@ -222,12 +326,21 @@ def get_cursor_screen_pos(self) -> tuple[float, float]: def dummy(self, size: tuple[float, float]) -> None: self.dummies.append(size) + def separator(self) -> None: + return + + def set_next_item_width(self, width: float) -> None: + del width + def input_text(self, label: str, value: str, *, flags: int): del label, value, flags return self.submit_input, self.input_value - def button(self, label: str) -> bool: - return self.click_submit or label in self.clicked_buttons + def button(self, label: str, size: object | None = None) -> bool: + self.buttons.append(label) + del size + submit = self.click_submit and label in {"SAVE SCORE", "SAVE TIME"} + return submit or label in self.clicked_buttons def begin_disabled(self) -> None: return @@ -235,6 +348,45 @@ def begin_disabled(self) -> None: def end_disabled(self) -> None: return + def begin_table( + self, + table_id: str, + columns: int, + *, + flags: int, + outer_size: object, + ) -> bool: + del columns, flags, outer_size + self.current_table = table_id + self.tables[table_id] = [] + self.table_columns[table_id] = [] + return True + + def end_table(self) -> None: + self.current_table = None + + def table_setup_column(self, label: str, flags: int, width: float) -> None: + del flags, width + assert self.current_table is not None + self.table_columns[self.current_table].append(label) + + def table_headers_row(self) -> None: + return + + def table_next_row(self, *, min_row_height: float) -> None: + del min_row_height + assert self.current_table is not None + self.tables[self.current_table].append([]) + self.current_table_column = 0 + + def table_set_column_index(self, column: int) -> None: + self.current_table_column = column + + def table_set_bg_color(self, target: int, color: int) -> None: + del target, color + assert self.current_table is not None + self.highlighted_rows.append(len(self.tables[self.current_table])) + class _Renderer: def __init__(self, width: int, height: int) -> None: @@ -276,6 +428,7 @@ def reset(self) -> None: class _SelectionState: selections: list[GameSelection] = field(default_factory=list) return_to_map_count: int = 0 + restart_count: int = 0 exit_requested: bool = False def select_game(self, selection: GameSelection) -> None: @@ -284,6 +437,9 @@ def select_game(self, selection: GameSelection) -> None: def return_to_map_menu(self) -> None: self.return_to_map_count += 1 + def restart_game(self) -> None: + self.restart_count += 1 + def request_exit(self) -> None: self.exit_requested = True @@ -1102,19 +1258,108 @@ def test_imgui_name_submission_uses_v2_loop_message_queue() -> None: assert "Game Over" in imgui.windows -def test_leaderboard_prompts_for_restart() -> None: - state = TaxiHudState(160, 96, _calibration()) - video = torch.zeros(1, 3, 96, 160) +def test_taxi_results_card_draws_ranked_leaderboard() -> None: + state = TaxiHudState(640, 540, _calibration()) + video = torch.zeros(1, 3, 540, 640) + entries = ( + HighScoreEntry("ACE", 2400, "2026-01-01T00:00:00Z"), + HighScoreEntry("DRIVER 7", 1200, "2026-01-02T00:00:00Z"), + ) + state.publish( + build_hud_frames( + video, + ( + replace( + _snapshot(session_state="leaderboard"), + leaderboard=entries, + high_score_rank=2, + ), + ), + np.eye(4, dtype=np.float32)[None], + ) + ) + state.select_presented_frame(video[0]) + imgui = _FakeImGui() + + state.draw(imgui) + + [(path, _size, droid_sans)] = imgui.fonts.loaded + assert path.endswith("DroidSans.ttf") + text_fonts = {text: font for text, font, _size in imgui.text_fonts} + assert text_fonts["GAME OVER"] is droid_sans + assert text_fonts["001200"] is droid_sans + assert text_fonts["LEADERBOARD"] is imgui.default_font + assert imgui.table_columns["##leaderboard"] == ["RANK", "DRIVER", "SCORE"] + assert imgui.tables["##leaderboard"] == [ + ["#1", "ACE", " 2400"], + ["#2", "DRIVER 7", " 1200"], + ] + assert imgui.highlighted_rows == [2] + assert "PLAY AGAIN" in imgui.buttons + assert "R RESTART · ESC MAP" in imgui.windows["Game Over"] + + +def test_race_results_card_formats_times() -> None: + state = TaxiHudState(640, 540, _calibration()) + video = torch.zeros(1, 3, 540, 640) + entries = ( + RaceTimeEntry( + "test-city", + "downtown-sprint", + "RACER", + 42_345_000, + "2026-01-01T00:00:00Z", + ), + ) + state.publish( + build_hud_frames( + video, + ( + replace( + _race_snapshot(session_state="leaderboard"), + leaderboard=entries, + high_score_rank=1, + ), + ), + np.eye(4, dtype=np.float32)[None], + ) + ) + state.select_presented_frame(video[0]) + imgui = _FakeImGui() + + state.draw(imgui) + + assert "RACE COMPLETE" in imgui.windows["Game Over"] + assert "0:42.345" in imgui.windows["Game Over"] + assert imgui.table_columns["##leaderboard"] == ["RANK", "DRIVER", "TIME"] + assert imgui.tables["##leaderboard"] == [["#1", "RACER", "0:42.345"]] + + +@pytest.mark.parametrize("session_state", ["awaiting_name", "leaderboard"]) +def test_terminal_play_again_requests_restart(session_state: TaxiSessionState) -> None: + state = TaxiHudState(640, 360, _calibration()) + model_loop = _SelectionLoop() + model_loop.register_session_loop_objects( + state=_SelectionState(), + frequency=0, + shutdown_event=threading.Event(), + failure_queue=queue.Queue(), + ) + state.model_loop = model_loop + video = torch.zeros(1, 3, 360, 640) state.publish( build_hud_frames( video, - (_snapshot(session_state="leaderboard"),), + (_snapshot(session_state=session_state),), np.eye(4, dtype=np.float32)[None], ) ) + state._menu_stage = "loading" state.select_presented_frame(video[0]) imgui = _FakeImGui() + imgui.clicked_buttons.add("PLAY AGAIN") state.draw(imgui) + model_loop._run_message_batch() - assert "Press R to play again" in imgui.windows["Game Over"] + assert model_loop.state.restart_count == 1 From 622762bfbd4e2e87d252e2ed1137ccc6ce2733ca Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 13:19:18 -0700 Subject: [PATCH 53/62] Polish Crazy Robotaxi selection menus --- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 304 +++++++++++++++++------ apps/crazy_robotaxi/tests/test_ui.py | 51 +++- 2 files changed, 283 insertions(+), 72 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 378282ee4..662475976 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -78,6 +78,9 @@ _MPS_TO_MPH = 2.2369362920544 """Metres-per-second to miles-per-hour conversion used by the source HUD.""" +_TAXI_ACCENT_RGB = (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) +_RACE_ACCENT_RGB = (118.0 / 255.0, 185.0 / 255.0, 0.0) + _PROFILE_DRIVE_KEYS = frozenset( {"w", "a", "s", "d", "up", "down", "left", "right", "space"} ) @@ -887,8 +890,10 @@ def _clear_presented_game(self) -> None: self._video_fps = 0.0 def _draw_mode_selection(self, imgui: Any) -> None: - window_width = max(1.0, min(460.0, float(self.width) - 28.0)) - window_height = max(1.0, min(250.0, float(self.height) - 28.0)) + window_width = max(1.0, min(500.0, float(self.width) - 28.0)) + window_height = max(1.0, min(390.0, float(self.height) - 28.0)) + scale = min(1.0, window_width / 500.0, window_height / 390.0) + _draw_arcade_backdrop(imgui, self.width, self.height) _prepare_window( imgui, position=( @@ -896,21 +901,71 @@ def _draw_mode_selection(self, imgui: Any) -> None: max(14.0, (self.height - window_height) / 2.0), ), size=(window_width, window_height), - alpha=0.94, + alpha=0.97, + ) + style_var_count, style_color_count = _push_arcade_card_style( + imgui, _TAXI_ACCENT_RGB + ) + visible = _begin_window( + imgui, + "Crazy Robotaxi — Select Game Mode", + extra_flags=("no_title_bar",), ) - visible = _begin_window(imgui, "Crazy Robotaxi — Select Game Mode") try: if not visible: return - imgui.text("SELECT GAME MODE") - imgui.text("Choose how you want to play.") - imgui.text("ESC: EXIT") - if imgui.button("TAXI"): + _centered_imgui_text( + imgui, + "CRAZY ROBOTAXI", + font=self._gameplay_overlay_font(imgui), + font_size=max(24.0, 40.0 * scale), + color=(*_TAXI_ACCENT_RGB, 1.0), + ) + _centered_imgui_text( + imgui, + "CHOOSE YOUR RIDE", + font_size=max(13.0, 16.0 * scale), + color=(0.62, 0.62, 0.68, 1.0), + ) + imgui.separator() + button_width = _point_xy(imgui.get_content_region_avail())[0] + button_height = max(38.0, 54.0 * scale) + if imgui.button("TAXI", imgui.ImVec2(button_width, button_height)): self._select_mode("taxi") - if imgui.button("RACE"): - self._select_mode("race") + _centered_imgui_text( + imgui, + "PICK UP PASSENGERS. DROP THEM OFF TO SCORE POINTS.", + font_size=max(12.0, 13.0 * scale), + color=(0.72, 0.72, 0.76, 1.0), + ) + for color, alpha in ( + (imgui.Col_.button, 0.78), + (imgui.Col_.button_hovered, 1.0), + (imgui.Col_.button_active, 0.62), + ): + imgui.push_style_color(color, imgui.ImVec4(*_RACE_ACCENT_RGB, alpha)) + try: + if imgui.button("RACE", imgui.ImVec2(button_width, button_height)): + self._select_mode("race") + finally: + imgui.pop_style_color(3) + _centered_imgui_text( + imgui, + "CHASE THE FASTEST TRACK TIME.", + font_size=max(12.0, 13.0 * scale), + color=(0.72, 0.72, 0.76, 1.0), + ) + imgui.separator() + _centered_imgui_text( + imgui, + "ESC EXIT", + font_size=max(12.0, 13.0 * scale), + color=(0.58, 0.58, 0.64, 1.0), + ) finally: imgui.end() + imgui.pop_style_color(style_color_count) + imgui.pop_style_var(style_var_count) def _draw_map_selection(self, imgui: Any) -> None: mode = self._selected_game_mode @@ -919,6 +974,9 @@ def _draw_map_selection(self, imgui: Any) -> None: return window_width = max(1.0, min(620.0, float(self.width) - 28.0)) window_height = max(1.0, min(560.0, float(self.height) - 28.0)) + scale = min(1.0, window_width / 620.0, window_height / 560.0) + accent_rgb = _RACE_ACCENT_RGB if mode == "race" else _TAXI_ACCENT_RGB + _draw_arcade_backdrop(imgui, self.width, self.height) _prepare_window( imgui, position=( @@ -926,29 +984,78 @@ def _draw_map_selection(self, imgui: Any) -> None: max(14.0, (self.height - window_height) / 2.0), ), size=(window_width, window_height), - alpha=0.94, + alpha=0.97, + ) + style_var_count, style_color_count = _push_arcade_card_style(imgui, accent_rgb) + visible = _begin_window( + imgui, + "Crazy Robotaxi — Select Map", + extra_flags=("no_title_bar",), ) - visible = _begin_window(imgui, "Crazy Robotaxi — Select Map") try: if not visible: return - if imgui.button("BACK"): + _centered_imgui_text( + imgui, + "SELECT MAP", + font=self._gameplay_overlay_font(imgui), + font_size=max(24.0, 38.0 * scale), + color=(*accent_rgb, 1.0), + ) + _centered_imgui_text( + imgui, + "RACE MODE" if mode == "race" else "TAXI MODE", + font_size=max(13.0, 15.0 * scale), + color=(0.62, 0.62, 0.68, 1.0), + ) + imgui.separator() + button_height = max(36.0, 48.0 * scale) + list_height = max( + 60.0, _point_xy(imgui.get_content_region_avail())[1] - 92.0 + ) + list_visible = imgui.begin_child( + "##map-options", imgui.ImVec2(0.0, list_height) + ) + try: + if list_visible: + button_width = _point_xy(imgui.get_content_region_avail())[0] + available = False + for index, option in enumerate(self.map_options): + if mode == "race" and not option.race_course_ids: + continue + available = True + if imgui.button( + f"{option.name}##map-{index}", + imgui.ImVec2(button_width, button_height), + ): + self._select_map(option) + if not available: + _centered_imgui_text( + imgui, + "NO COMPATIBLE MAPS FOUND", + font_size=max(13.0, 15.0 * scale), + color=(0.62, 0.62, 0.68, 1.0), + ) + finally: + imgui.end_child() + imgui.separator() + button_width = _point_xy(imgui.get_content_region_avail())[0] + if imgui.button( + "BACK", imgui.ImVec2(button_width, max(34.0, 42.0 * scale)) + ): self._selected_game_mode = None self._menu_stage = "mode" return - imgui.text("ESC: BACK") - imgui.text("SELECT MAP") - available = False - for index, option in enumerate(self.map_options): - if mode == "race" and not option.race_course_ids: - continue - available = True - if imgui.button(f"{option.name}##map-{index}"): - self._select_map(option) - if not available: - imgui.text("NO COMPATIBLE MAPS FOUND") + _centered_imgui_text( + imgui, + "ESC BACK", + font_size=max(12.0, 13.0 * scale), + color=(0.58, 0.58, 0.64, 1.0), + ) finally: imgui.end() + imgui.pop_style_color(style_color_count) + imgui.pop_style_var(style_var_count) def _draw_course_selection(self, imgui: Any) -> None: option = self._selected_map_option @@ -960,6 +1067,8 @@ def _draw_course_selection(self, imgui: Any) -> None: return window_width = max(1.0, min(620.0, float(self.width) - 28.0)) window_height = max(1.0, min(420.0, float(self.height) - 28.0)) + scale = min(1.0, window_width / 620.0, window_height / 420.0) + _draw_arcade_backdrop(imgui, self.width, self.height) _prepare_window( imgui, position=( @@ -967,25 +1076,70 @@ def _draw_course_selection(self, imgui: Any) -> None: max(14.0, (self.height - window_height) / 2.0), ), size=(window_width, window_height), - alpha=0.94, + alpha=0.97, + ) + style_var_count, style_color_count = _push_arcade_card_style( + imgui, _RACE_ACCENT_RGB + ) + visible = _begin_window( + imgui, + "Crazy Robotaxi — Select Race Course", + extra_flags=("no_title_bar",), ) - visible = _begin_window(imgui, "Crazy Robotaxi — Select Race Course") try: if not visible: return - if imgui.button("BACK"): + _centered_imgui_text( + imgui, + "SELECT RACE COURSE", + font=self._gameplay_overlay_font(imgui), + font_size=max(22.0, 36.0 * scale), + color=(*_RACE_ACCENT_RGB, 1.0), + ) + _centered_imgui_text( + imgui, + option.name.upper(), + font_size=max(13.0, 15.0 * scale), + color=(0.62, 0.62, 0.68, 1.0), + ) + imgui.separator() + button_height = max(36.0, 48.0 * scale) + list_height = max( + 60.0, _point_xy(imgui.get_content_region_avail())[1] - 92.0 + ) + list_visible = imgui.begin_child( + "##course-options", imgui.ImVec2(0.0, list_height) + ) + try: + if list_visible: + button_width = _point_xy(imgui.get_content_region_avail())[0] + for course_index, course_id in enumerate(option.race_course_ids): + label = course_id.replace("-", " ").replace("_", " ").upper() + if imgui.button( + f"{label}##course-{course_index}", + imgui.ImVec2(button_width, button_height), + ): + self._start_game(option, race_course_id=course_id) + finally: + imgui.end_child() + imgui.separator() + button_width = _point_xy(imgui.get_content_region_avail())[0] + if imgui.button( + "BACK", imgui.ImVec2(button_width, max(34.0, 42.0 * scale)) + ): self._selected_map_option = None self._menu_stage = "map" return - imgui.text("ESC: BACK") - imgui.text(option.name) - imgui.text("SELECT RACE COURSE") - for course_index, course_id in enumerate(option.race_course_ids): - label = course_id.replace("-", " ").replace("_", " ").upper() - if imgui.button(f"{label}##course-{course_index}"): - self._start_game(option, race_course_id=course_id) + _centered_imgui_text( + imgui, + "ESC BACK", + font_size=max(12.0, 13.0 * scale), + color=(0.58, 0.58, 0.64, 1.0), + ) finally: imgui.end() + imgui.pop_style_color(style_color_count) + imgui.pop_style_var(style_var_count) def _draw_text_window( self, @@ -1369,11 +1523,7 @@ def _draw_terminal( if not (awaiting_name or leaderboard): return race = isinstance(snapshot, RaceGameSnapshot) - accent_rgb = ( - (200.0 / 255.0, 150.0 / 255.0, 50.0 / 255.0) - if race - else (118.0 / 255.0, 185.0 / 255.0, 0.0) - ) + accent_rgb = _RACE_ACCENT_RGB if race else _TAXI_ACCENT_RGB margin = 16.0 card_width = max(1.0, min(620.0, float(self.width) - 2.0 * margin)) card_height = max(1.0, min(540.0, float(self.height) - 2.0 * margin)) @@ -1381,42 +1531,14 @@ def _draw_terminal( card_top = (float(self.height) - card_height) * 0.5 scale = min(1.0, card_width / 620.0, card_height / 540.0) - draw_list = imgui.get_background_draw_list() - draw_list.add_rect_filled( - imgui.ImVec2(0.0, 0.0), - imgui.ImVec2(float(self.width), float(self.height)), - _imgui_color(imgui, (0.0, 0.0, 0.0, 0.58)), - ) + _draw_arcade_backdrop(imgui, self.width, self.height) _prepare_window( imgui, position=(card_left, card_top), size=(card_width, card_height), alpha=0.97, ) - style_vars = ( - (imgui.StyleVar_.window_rounding, 16.0), - (imgui.StyleVar_.window_border_size, 2.0), - (imgui.StyleVar_.window_padding, imgui.ImVec2(28.0, 24.0)), - (imgui.StyleVar_.item_spacing, imgui.ImVec2(10.0, 10.0)), - (imgui.StyleVar_.frame_rounding, 7.0), - (imgui.StyleVar_.frame_padding, imgui.ImVec2(10.0, 8.0)), - ) - style_colors = ( - (imgui.Col_.window_bg, (0.047, 0.047, 0.071, 0.98)), - (imgui.Col_.border, (*accent_rgb, 0.95)), - (imgui.Col_.text, (0.94, 0.94, 0.97, 1.0)), - (imgui.Col_.text_disabled, (0.58, 0.58, 0.64, 1.0)), - (imgui.Col_.frame_bg, (0.09, 0.09, 0.13, 1.0)), - (imgui.Col_.frame_bg_hovered, (0.13, 0.13, 0.18, 1.0)), - (imgui.Col_.frame_bg_active, (0.16, 0.16, 0.22, 1.0)), - (imgui.Col_.button, (*accent_rgb, 0.78)), - (imgui.Col_.button_hovered, (*accent_rgb, 1.0)), - (imgui.Col_.button_active, (*accent_rgb, 0.62)), - ) - for style_var, value in style_vars: - imgui.push_style_var(style_var, value) - for color, value in style_colors: - imgui.push_style_color(color, imgui.ImVec4(*value)) + style_var_count, style_color_count = _push_arcade_card_style(imgui, accent_rgb) visible = _begin_window(imgui, "Game Over", extra_flags=("no_title_bar",)) try: if not visible: @@ -1477,8 +1599,8 @@ def _draw_terminal( ) finally: imgui.end() - imgui.pop_style_color(len(style_colors)) - imgui.pop_style_var(len(style_vars)) + imgui.pop_style_color(style_color_count) + imgui.pop_style_var(style_var_count) def _draw_terminal_name_entry( self, @@ -1786,6 +1908,46 @@ def _is_escape_press(event: object) -> bool: ) +def _draw_arcade_backdrop(imgui: Any, width: int, height: int) -> None: + draw_list = imgui.get_background_draw_list() + draw_list.add_rect_filled( + imgui.ImVec2(0.0, 0.0), + imgui.ImVec2(float(width), float(height)), + _imgui_color(imgui, (0.0, 0.0, 0.0, 0.58)), + ) + + +def _push_arcade_card_style( + imgui: Any, + accent_rgb: tuple[float, float, float], +) -> tuple[int, int]: + style_vars = ( + (imgui.StyleVar_.window_rounding, 16.0), + (imgui.StyleVar_.window_border_size, 2.0), + (imgui.StyleVar_.window_padding, imgui.ImVec2(28.0, 24.0)), + (imgui.StyleVar_.item_spacing, imgui.ImVec2(10.0, 10.0)), + (imgui.StyleVar_.frame_rounding, 7.0), + (imgui.StyleVar_.frame_padding, imgui.ImVec2(10.0, 8.0)), + ) + style_colors = ( + (imgui.Col_.window_bg, (0.047, 0.047, 0.071, 0.98)), + (imgui.Col_.border, (*accent_rgb, 0.95)), + (imgui.Col_.text, (0.94, 0.94, 0.97, 1.0)), + (imgui.Col_.text_disabled, (0.58, 0.58, 0.64, 1.0)), + (imgui.Col_.frame_bg, (0.09, 0.09, 0.13, 1.0)), + (imgui.Col_.frame_bg_hovered, (0.13, 0.13, 0.18, 1.0)), + (imgui.Col_.frame_bg_active, (0.16, 0.16, 0.22, 1.0)), + (imgui.Col_.button, (*accent_rgb, 0.78)), + (imgui.Col_.button_hovered, (*accent_rgb, 1.0)), + (imgui.Col_.button_active, (*accent_rgb, 0.62)), + ) + for style_var, value in style_vars: + imgui.push_style_var(style_var, value) + for color, value in style_colors: + imgui.push_style_color(color, imgui.ImVec4(*value)) + return len(style_vars), len(style_colors) + + def _prepare_window( imgui: Any, *, diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index d856b4cda..ea69c4151 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -201,6 +201,7 @@ def __init__(self) -> None: self.click_submit = False self.clicked_buttons: set[str] = set() self.buttons: list[str] = [] + self.button_sizes: list[tuple[str, object | None]] = [] self.background_draw_list = _FakeDrawList() self.window_flags: dict[str, int] = {} self.tables: dict[str, list[list[str]]] = {} @@ -287,6 +288,13 @@ def begin(self, title: str, *, flags: int) -> bool: def end(self) -> None: self.current_window = None + def begin_child(self, child_id: str, size: object) -> bool: + del child_id, size + return True + + def end_child(self) -> None: + return + def text(self, value: str) -> None: assert self.current_window is not None self.windows[self.current_window].append(value) @@ -338,7 +346,7 @@ def input_text(self, label: str, value: str, *, flags: int): def button(self, label: str, size: object | None = None) -> bool: self.buttons.append(label) - del size + self.button_sizes.append((label, size)) submit = self.click_submit and label in {"SAVE SCORE", "SAVE TIME"} return submit or label in self.clicked_buttons @@ -889,6 +897,47 @@ def test_hud_animates_prepresentation_warmup_status() -> None: assert lines[1].startswith("ELAPSED ") +def test_selection_menus_use_arcade_card_layout() -> None: + option = GameMapOption( + map_id="test-city", + name="Test City", + path=Path("test-city.robotaxi.yaml"), + variant="default", + race_course_ids=("downtown-sprint",), + ) + state = TaxiHudState(640, 540, _calibration(), map_options=(option,)) + imgui = _FakeImGui() + + state.draw(imgui) + state._selected_game_mode = "race" + state._menu_stage = "map" + state.draw(imgui) + state._selected_map_option = option + state._menu_stage = "course" + state.draw(imgui) + + [(path, _size, droid_sans)] = imgui.fonts.loaded + assert path.endswith("DroidSans.ttf") + text_fonts = {text: font for text, font, _size in imgui.text_fonts} + assert text_fonts["CRAZY ROBOTAXI"] is droid_sans + assert text_fonts["SELECT MAP"] is droid_sans + assert text_fonts["SELECT RACE COURSE"] is droid_sans + for title in ( + "Crazy Robotaxi — Select Game Mode", + "Crazy Robotaxi — Select Map", + "Crazy Robotaxi — Select Race Course", + ): + assert imgui.window_flags[title] & imgui.WindowFlags_.no_title_bar + button_sizes = dict(imgui.button_sizes) + assert button_sizes["TAXI"] == button_sizes["RACE"] + assert button_sizes["TAXI"][0] > 0.0 + assert button_sizes["Test City##map-0"][0] > 0.0 + assert button_sizes["DOWNTOWN SPRINT##course-0"][0] > 0.0 + assert [command for command, _args in imgui.background_draw_list.commands].count( + "rect_filled" + ) == 3 + + def test_startup_menu_selects_taxi_mode_then_map_through_v2_message() -> None: option = GameMapOption( map_id="test-city", From fbecd08bf97553fae01b5f955cb809fc74009a7c Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 15:50:46 -0700 Subject: [PATCH 54/62] Increase UI rate to 60Hz for lower latency --- apps/crazy_robotaxi/crazy_robotaxi/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 592ed531c..5baa121f8 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -53,7 +53,7 @@ _VIDEO_FPS = 30 """Generated-video cadence required by the model.""" -_UI_FPS = 30 +_UI_FPS = 60 """Input polling and HUD cadence used by Interactive Drive.""" _DEFAULT_INPUT_TRACE_PATH = ( From 8acb146599507daee99502895566b2334d435ad2 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 16:36:25 -0700 Subject: [PATCH 55/62] Fix Crazy Robotaxi race checkpoint geometry Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/crazy_robotaxi/race.py | 26 +++++++++--- apps/crazy_robotaxi/tests/test_race.py | 46 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/race.py b/apps/crazy_robotaxi/crazy_robotaxi/race.py index bff642c58..95b2cb968 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/race.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/race.py @@ -490,7 +490,7 @@ def _cross_course_gate( LineString((tuple(anchor - perpendicular), tuple(anchor + perpendicular))), direction, ) - return max(cross_parts, key=lambda part: part.length), direction + return min(cross_parts, key=lambda part: part.distance(Point(anchor))), direction def _nearest_centerline_entry( @@ -498,7 +498,7 @@ def _nearest_centerline_entry( approach_xy: npt.NDArray[np.float64], ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]] | None: """Return the closest lane endpoint and its inward local tangent.""" - frames: list[tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]] = [] + frames: list[tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], float]] = [] for centerline in centerlines: if centerline.shape[0] < 2: continue @@ -519,17 +519,33 @@ def _nearest_centerline_entry( continue direction = segments[index] / length if remaining <= length: - frames.append((oriented[index] + direction * remaining, direction)) + frames.append((oriented[0], direction, remaining)) break remaining -= float(length) if not frames: return None - anchor = np.mean([frame[0] for frame in frames], axis=0) + + reference = min(frames, key=lambda frame: np.linalg.norm(frame[0] - approach_xy)) + frames = [ + frame + for frame in frames + if float(np.dot(frame[1], reference[1])) >= np.sqrt(0.5) + ] + endpoints = np.unique(np.stack([frame[0] for frame in frames]), axis=0) + anchor = endpoints.mean(axis=0) direction = np.mean([frame[1] for frame in frames], axis=0) norm = float(np.linalg.norm(direction)) if norm <= 1.0e-6: return None - return anchor, direction / norm + inward_direction = direction / norm + if len(endpoints) > 1: + _, _, axes = np.linalg.svd(endpoints - anchor, full_matrices=False) + direction = np.asarray([axes[0, 1], -axes[0, 0]], dtype=np.float64) + if float(np.dot(direction, inward_direction)) < 0.0: + direction *= -1.0 + else: + direction = inward_direction + return anchor + direction * min(frame[2] for frame in frames), direction def _line_parts(geometry: object) -> tuple[LineString, ...]: diff --git a/apps/crazy_robotaxi/tests/test_race.py b/apps/crazy_robotaxi/tests/test_race.py index 9a3716a1e..d33bda119 100644 --- a/apps/crazy_robotaxi/tests/test_race.py +++ b/apps/crazy_robotaxi/tests/test_race.py @@ -36,6 +36,18 @@ pytestmark = pytest.mark.ci_cpu _MAP = Path(__file__).parent / "maps" / "race_course.robotaxi.yaml" +_BOULEVARD_MAP = ( + Path(__file__).parents[1] + / "crazy_robotaxi" + / "maps" + / "boulevard_district.robotaxi.yaml" +) +_RACEWAY_MAP = ( + Path(__file__).parents[1] + / "crazy_robotaxi" + / "maps" + / "flashdreams_raceway.robotaxi.yaml" +) def _state(x_m: float, y_m: float) -> VehicleState: @@ -373,6 +385,40 @@ def test_course_gates_span_the_full_road_surface(tmp_path: Path) -> None: assert controller._gates[element_id].length == pytest.approx(8.4, abs=0.15) +def test_boulevard_intersection_gates_span_the_full_arterial(tmp_path: Path) -> None: + game_map = load_game_map(_BOULEVARD_MAP) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + + for element_id in (course.start_element_id, *course.checkpoint_element_ids): + assert controller._gates[element_id].length >= 15.5 + + +def test_grand_prix_hairpin_gate_is_at_the_course_entry(tmp_path: Path) -> None: + game_map = load_game_map(_RACEWAY_MAP) + course = game_map.race_courses[0] + controller = RaceController( + game_map, + course, + _state(-300.0, -300.0), + RaceTimeStore(tmp_path / "times.csv"), + ) + checkpoint_index = 4 + checkpoint_id = course.checkpoint_element_ids[checkpoint_index] + previous_id = course.checkpoint_element_ids[checkpoint_index - 1] + following_id = course.checkpoint_element_ids[checkpoint_index + 1] + + assert checkpoint_id == "infield_hairpin" + assert controller._gates[checkpoint_id].distance( + controller._surfaces[previous_id] + ) < controller._gates[checkpoint_id].distance(controller._surfaces[following_id]) + + def test_race_times_are_isolated_by_map_and_course(tmp_path: Path) -> None: store = RaceTimeStore(tmp_path / "times.csv", limit=2) store.record("map-a", "course-a", "Slow", 4_000_000) From bb51c49747852b12aaa5d6cb5ae63621f649ebc5 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 16:43:44 -0700 Subject: [PATCH 56/62] Double Crazy Robotaxi model warmup Signed-off-by: Aidan Foster --- apps/crazy_robotaxi/README.md | 2 +- apps/crazy_robotaxi/crazy_robotaxi/application.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 06481e76b..48bf45701 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -236,7 +236,7 @@ synchronous app/model portion. See the [V2 latency handoff](../../docs/design/crazy_robotaxi_v2_input_latency.md) for the trace fields and interpretation caveats. -All model presets generate four neutral, hidden blocks before publishing the +All model presets generate eight neutral, hidden blocks before publishing the first gameplay frame. The responsive ImGui HUD shows the current warmup block, an animated activity marker, and elapsed time while compilation and autotuning run. After warmup, the app recreates simulation, rules, conditioning, and the diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 5baa121f8..785273991 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -63,7 +63,7 @@ _LOGGER = logging.getLogger(__name__) -_DEFAULT_PREWARM_BLOCKS = 4 +_DEFAULT_PREWARM_BLOCKS = 8 """Blocks covering chunk2 cache filling and the first steady-state AR shape.""" @@ -557,7 +557,7 @@ def _parser( default=_DEFAULT_PREWARM_BLOCKS, help=( "generate hidden neutral blocks before presentation to compile and " - "autotune AR shapes (default: 4; 0 disables)" + "autotune AR shapes (default: 8; 0 disables)" ), ) parser.add_argument( From 7acd8a2f411e5503d49fd29b58178a94dd706f15 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 16:51:21 -0700 Subject: [PATCH 57/62] Restore Crazy Robotaxi arcade controls --- .../crazy_robotaxi/crazy_robotaxi/dynamics.py | 6 +++++ apps/crazy_robotaxi/crazy_robotaxi/session.py | 22 ++++++++++++++++--- apps/crazy_robotaxi/tests/test_application.py | 21 ++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py b/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py index f5161b2c0..dcebe5095 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/dynamics.py @@ -23,6 +23,12 @@ class TaxiVehicleConfig(VehicleConfig): max_steer_rad: float = 0.69 """Full-lock steering angle for tight arcade turns.""" + steer_rate_rad_per_s: float = 2.415 + """Reach full lock in the original keyboard control's 1 / 3.5 seconds.""" + + steer_return_rate_rad_per_s: float = 3.45 + """Return from full lock in the original keyboard control's 1 / 5 seconds.""" + max_accel_mps2: float = 10.0 reverse_accel_mps2: float = 10.0 max_brake_mps2: float = 14.0 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 5754aade9..c5335b604 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -641,7 +641,23 @@ def _restart_requested(events: UserInputEvents) -> bool: def _taxi_driver_command(command: DriverCommand) -> DriverCommand: - """Restore Crazy Robotaxi's keyboard handbrake over shared drive input.""" - if not command.manual_control or command.steer_is_direct or command.brake <= 0.0: + """Apply Taxi's arcade pedal policy to shared non-direct drive commands.""" + if command.steer_is_direct: return command - return replace(command, throttle=0.0, stop=False, handbrake=True) + if command.brake > 0.0: + return replace( + command, + throttle=0.0, + brake=0.0, + stop=False, + handbrake=True, + manual_control=True, + ) + if command.reverse: + command = replace( + command, + throttle=0.0, + brake=command.throttle, + reverse=False, + ) + return replace(command, manual_control=True) diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 9e9625301..68f22196c 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -17,6 +17,7 @@ CrazyRobotaxiApplication, _fit_bev_renderer_to_ui, ) +from crazy_robotaxi.dynamics import TaxiVehicleConfig from crazy_robotaxi.game_selection import GameSelection from crazy_robotaxi.physics import TaxiPhysicsWorld from crazy_robotaxi.rules import TaxiGameSnapshot @@ -406,6 +407,26 @@ def test_taxi_space_key_restores_handbrake_over_shared_input_mapping() -> None: assert not command.stop +def test_taxi_keyboard_restores_arcade_brake_reverse() -> None: + driver_input = DriverInput(pressed_keys={"a", "s"}) + vehicle = TaxiVehicleConfig() + + command = _taxi_driver_command(driver_input.command()) + + assert vehicle.steer_rate_rad_per_s == pytest.approx( + 3.5 * vehicle.max_steer_rad + ) + assert vehicle.steer_return_rate_rad_per_s == pytest.approx( + 5.0 * vehicle.max_steer_rad + ) + assert command.steer == 1.0 + assert not command.steer_is_direct + assert command.manual_control + assert command.throttle == 0.0 + assert command.brake == 1.0 + assert not command.reverse + + def test_leaderboard_does_not_finish_the_v2_model_loop() -> None: snapshot = TaxiGameSnapshot( phase="seeking_pickup", From 7754a57cdf7b4a4447df3d4194f41d29509fdf89 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 17:37:55 -0700 Subject: [PATCH 58/62] Polish Crazy Robotaxi for public release Signed-off-by: Aidan Foster --- THIRD-PARTY-NOTICES | 2 - apps/crazy_robotaxi/README.md | 313 +++++--------- .../crazy_robotaxi/crazy_robotaxi/__main__.py | 11 - .../crazy_robotaxi/assets/README.md | 44 +- apps/crazy_robotaxi/crazy_robotaxi/factory.py | 8 +- .../crazy_robotaxi/high_scores.py | 2 +- .../crazy_robotaxi/live_edit/__init__.py | 51 +-- .../live_edit/gpu_compositor.py | 18 +- .../live_edit/obstacle_ability.py | 36 +- .../live_edit/obstacle_events.py | 28 +- .../live_edit/obstacle_templates.py | 4 +- .../crazy_robotaxi/live_edit/runtime_v2.py | 2 +- .../crazy_robotaxi/live_edit/style_ability.py | 99 +---- .../live_edit/weather_ability.py | 1 + apps/crazy_robotaxi/crazy_robotaxi/race.py | 2 +- apps/crazy_robotaxi/crazy_robotaxi/ui.py | 30 +- apps/crazy_robotaxi/tests/test_application.py | 25 +- .../crazy_robotaxi/tests/test_live_edit_v2.py | 55 ++- apps/crazy_robotaxi/tests/test_race.py | 7 +- apps/crazy_robotaxi/tests/test_ui.py | 20 +- apps/omnidreams_game_engine/README.md | 3 - .../omnidreams_game_engine/cli_args.py | 1 + .../omnidreams_game_engine/scene_loader.py | 2 +- .../simulation/map_traffic.py | 22 +- .../tests/test_map_traffic.py | 17 +- .../crazy_robotaxi_legacy_architecture.md | 169 -------- docs/design/crazy_robotaxi_v2_architecture.md | 381 ------------------ .../design/crazy_robotaxi_v2_input_latency.md | 106 ----- docs/design/crazy_robotaxi_v2_performance.md | 191 --------- .../runtime_v2/native_window_client_window.py | 6 +- flashdreams/test_v2/test_imgui_ui_renderer.py | 7 +- .../test_native_window_client_window.py | 18 +- flashdreams/test_v2/test_session_runner.py | 36 +- 33 files changed, 268 insertions(+), 1449 deletions(-) delete mode 100644 apps/crazy_robotaxi/crazy_robotaxi/__main__.py delete mode 100644 docs/design/crazy_robotaxi_legacy_architecture.md delete mode 100644 docs/design/crazy_robotaxi_v2_architecture.md delete mode 100644 docs/design/crazy_robotaxi_v2_input_latency.md delete mode 100644 docs/design/crazy_robotaxi_v2_performance.md diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 2c3c897e2..b85576fdc 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -62,8 +62,6 @@ nvidia-cudnn-frontend MIT https://github.com/NVIDIA/cudnn-frontend opencv-python-headless Apache-2.0 https://github.com/opencv/opencv-python grpcio, grpcio-tools Apache-2.0 https://github.com/grpc/grpc shapely BSD-3-Clause https://github.com/shapely/shapely -slangpy Apache-2.0 WITH LLVM-exception - https://github.com/shader-slang/slangpy ludus-renderer Apache-2.0 (NVIDIA, internal distribution) cmake BSD-3-Clause https://github.com/Kitware/CMake NVIDIA PhysX 5.9.0 BSD-3-Clause https://github.com/NVIDIA-Omniverse/PhysX diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 48bf45701..787e984cd 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -1,269 +1,148 @@ # Crazy Robotaxi -Crazy Robotaxi is a FlashDreams V2 application built on the model/UI-loop API -and `omnidreams-game-engine`. FlashDreams owns input collection, reset -generations, presentation buffering, client windows, and the two-thread -runtime. The UI loop uses FlashDreams' Dear ImGui renderer for the game HUD and -composites it over model frames for native-window, WebRTC, and file clients. -Camera-projected waypoint rings, beacons, and labels are rendered as a -background ImGui draw list on the UI thread; they are not windows or controls. The -model loop publishes immutable snapshot-and-pose metadata for each generated -frame, and the UI loop caches the projected marker geometry while that frame -remains visible. The optional raw BEV view is a second model result displayed -inside a real ImGui `Map` window. `CrazyRobotaxiImGuiUILoop` returns the video -back buffer and the base loop composites one ImGui overlay containing the -background waypoints, HUD, and BEV window. +Crazy Robotaxi is an interactive FlashDreams V2 application built on the +OmniDreams world model and `omnidreams-game-engine`. Drive a taxi through +authored maps, collect fares, or race against the clock using a keyboard, +gamepad, or steering wheel. -## Controls - -### Keyboard +## Requirements -| Control | Action | -| --- | --- | -| `W` or Up Arrow | Drive forward. | -| `S` or Down Arrow | Reverse. | -| `A` or Left Arrow | Steer left. | -| `D` or Right Arrow | Steer right. | -| `Space` | Apply the handbrake and cancel throttle. | -| `R` | Restart the current game without reloading the model. | -| `Escape` | Go back: game → map, course → map, map → mode, then exit from the mode screen. | -| `C` | Toggle collectible coins when `--live-edit-coins` is enabled. | -| `K` | Cycle style skins when `--live-edit-style` is enabled. | -| `V` | Cycle weather when `--live-edit-weather` is enabled. Weather changes are ignored while a skin is active. | -| `O` | Spawn a crossing-obstacle event when `--live-edit-obstacle` is enabled. | -| `Enter` | Submit the leaderboard name while the Driver name field is focused. | - -Menu choices and the leaderboard Submit button can also be clicked with the mouse. - -### Controller +Crazy Robotaxi uses the same model assets and GPU runtime as the OmniDreams +integration. Set `HF_TOKEN` to a token with access to the NVIDIA OmniDreams +repositories. See the [OmniDreams integration guide](../../integrations_v2/omnidreams/README.md) +for the supported platform, model preparation, and controller setup. -| Control | Action | -| --- | --- | -| Left stick, push forward+tilt | Steer. | -| Right trigger (`RT` / `R2` / `ZR`) | Throttle. | -| Left trigger (`LT` / `L2` / `ZL`) | Brake. | -| `R` / `RB` / `R1` (hold) | Select reverse gear while held; use the right trigger to drive in reverse. | -| Start / Menu / Plus | Restart the current game without reloading the model. | -| Steering wheel and pedals | Use normalized steering, throttle, and brake input directly. | +## Quick start -A connected gamepad or wheel takes precedence over keyboard driving input. -Disconnecting it returns control to any keyboard keys still held. Gamepads do -not currently bind handbrake, menu navigation, or live-edit actions; -use the keyboard or mouse for those controls. +From the repository root: ```bash -uv sync --package crazy-robotaxi -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams \ - --mode native-window \ - --window-title "Crazy Robotaxi" -``` +export HF_TOKEN= -`crazy-robotaxi-omnidreams` is the default app name. -`crazy-robotaxi-omnidreams-perf` is the performance-optimized app name. -`crazy-robotaxi-omnidreams-fast-perf` is the fast-performance-optimized app name. +uv sync --package flashdreams-omnidreams --extra interactive-drive +uv run --package flashdreams-omnidreams python \ + integrations_v2/omnidreams/impl/omnidreams_singleview/tools/sync_thirdparty.py sync -Native-window mode keeps the composited frame on the GPU and presents it in a -local GLFW window. It requires a local display plus SlangPy's Vulkan/CUDA -interop support. For a browser client instead, run: - -```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode webrtc --port 8089 +uv run --package flashdreams-omnidreams flashdreams-run-v2 \ + crazy-robotaxi-omnidreams --mode native-window ``` -Open `http://127.0.0.1:8089/`. Add `--host 0.0.0.0` when the browser connects -through another host, matching Interactive Drive. - -Application arguments follow `--`: +Native-window mode requires a local display and SlangPy's Vulkan/CUDA interop. +To use a browser client instead: ```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams-perf --mode webrtc -- \ - --map apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml \ - --game-time-s 90 +uv run --package flashdreams-omnidreams flashdreams-run-v2 \ + crazy-robotaxi-omnidreams --mode webrtc --host 0.0.0.0 --port 8089 ``` -At startup, choose Taxi or Race mode, then choose a discovered map. Race mode -shows each map's authored race courses on the map-selection screen and starts -the selected course. The configured `--map` is listed first; the menu also -discovers bundled maps and `.robotaxi.yaml` maps beside that configured file. -Map compilation and scene loading begin only after the menu choice. +Open `http://127.0.0.1:8089/`, or use the host printed by the runner when +connecting remotely. The first run downloads model assets and may take time to +compile and autotune kernels. -Use `flashdreams-run-v2 crazy-robotaxi-omnidreams -- --help` for the complete -application options. Restart rebuilds simulation, game rules, traffic, -conditioning, and the autoregressive cache while retaining the loaded model. -The HUD keeps an always-visible bearing arrow and overlays visible targets or -an off-map direction arrow on the BEV. +Three OmniDreams runner configurations are registered: -## Race mode +| Runner | Configuration | +| --- | --- | +| `crazy-robotaxi-omnidreams` | Standard | +| `crazy-robotaxi-omnidreams-perf` | Performance optimized | +| `crazy-robotaxi-omnidreams-fast-perf` | Fast performance optimized | -Authored maps may define ordered race courses using the same schema and map -files as the original Crazy Robotaxi branch. Put the bundled demonstration map -first in the selection screen with: +The performance configurations require the native DiT sources to be prepared +once: ```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode native-window -- \ - --map apps/crazy_robotaxi/crazy_robotaxi/maps/demo_race_track.robotaxi.yaml \ - --game-mode race +uv run --package flashdreams-omnidreams omnidreams-prepare --perf ``` -Choose Race on the first screen, then choose its course on the second. Race -progression uses swept, direction-aware gate crossings, supports looped and -point-to-point courses, and persists times per map/course. Override the race -leaderboard path with `--race-times PATH`. - -## Live-edit gameplay - -The source game's flag-gated abilities are available through V2 application -arguments. `C` toggles collectible coins, `K` cycles style skins, `V` cycles -weather, and `O` spawns a crossing-obstacle event. Effect items can trigger -rain, snow, a timed mystery skin, or a physics-authoritative nitro boost. - -To launch with coins, effect items, weather effects, and the style LoRA: +Application arguments follow `--`. For example: ```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode native-window -- \ - --live-edit-coins \ - --live-edit-items \ - --live-edit-weather \ - --live-edit-style +uv run --package flashdreams-omnidreams flashdreams-run-v2 \ + crazy-robotaxi-omnidreams-perf --mode webrtc -- \ + --map apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml \ + --game-time-s 90 ``` -On first launch, style mode downloads the v6 style LoRA, its recommended v5 -corrector and gate, and the base-world clean-forcing corrector into -`artifacts/crazy_robotaxi/live_edit`. Later launches reuse those files. The -`--live-edit-style-lora`, `--live-edit-style-corrector`, -`--live-edit-gate-alpha-json`, and `--live-edit-base-corrector` options override -individual assets. Weather remains LoRA-free unless a nonzero weather-corrector -gain is configured. Text-edit and obstacle guidance require a non-native DiT -preset because those paths intercept the Python transformer conditioning -seam; the application reports this before generation if an incompatible -native preset is selected. All abilities are disabled by default. +Run the application with `-- --help` to list all game options. Restarting a +game rebuilds its simulation and autoregressive cache without reloading the +model. -## Maximum-performance preset - -OmniDreams exposes `standard`, `perf`, and `fast-perf` pipeline configs. Crazy -Robotaxi selects those model-owned configs directly. `fast-perf` retains perf's -required native FP8 DiT, cuDNN attention, CUDA-graphed LightTAE decoder, and -two-step schedule, while preferring the native FP8 LightVAE encoders. +## Controls -Prepare the native DiT sources once from the repository root: +### Keyboard -```bash -uv run --package flashdreams-omnidreams omnidreams-prepare --perf -``` +| Control | Action | +| --- | --- | +| `W` or Up Arrow | Drive forward | +| `S` or Down Arrow | Reverse | +| `A` or Left Arrow | Steer left | +| `D` or Right Arrow | Steer right | +| `Space` | Apply the handbrake and cancel throttle | +| `R` | Restart the current game | +| `Escape` | Return to the previous menu, then exit from the mode screen | +| `Enter` | Submit the focused leaderboard name | -Run the fast config directly: +Menu choices and leaderboard buttons can also be clicked with the mouse. -```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams-fast-perf \ - --mode native-window -``` +### Controller -On its first launch, `fast-perf` downloads the public OmniDreams sample, -calibrates the native FP8 LightVAE, and atomically caches the result at -`artifacts/native_vae/lightvae_fp8_state.pt`. Later launches reuse that file. -Set `OMNIDREAMS_LIGHTVAE_FP8_STATE_PATH` to override the cache location. +| Control | Action | +| --- | --- | +| Left stick | Steer | +| Right trigger (`RT` / `R2` / `ZR`) | Throttle | +| Left trigger (`LT` / `L2` / `ZL`) | Brake | +| `R` / `RB` / `R1` (hold) | Select reverse gear | +| Start / Menu / Plus | Restart the current game | +| Steering wheel and pedals | Use normalized steering, throttle, and brake input | -The native DiT sources must also have been prepared as described above. GPU -throughput and quality should be validated on the -target machine before treating it as a regression baseline. +A connected gamepad or wheel takes precedence over keyboard driving input. +Gamepads do not currently control menus, the handbrake, or live-edit actions. -## Performance diagnostics +## Race mode -Crazy Robotaxi emits lightweight model-thread, engine, overlay, and PhysX -timings during normal play. Capture synchronized model-step and GPU-stage -diagnostics while reproducing a chunk pause with: +Bundled maps can define ordered race courses. Start with the included raceway +and select the `grand-prix` course in the menu: ```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode webrtc \ - --stats-path /tmp/crazy-robotaxi-stats.json -- \ - --profile-pipeline +uv run --package flashdreams-omnidreams flashdreams-run-v2 \ + crazy-robotaxi-omnidreams --mode native-window -- \ + --map apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml \ + --game-mode race ``` -Pipeline profiling is diagnostic and disabled during normal play because its -CUDA synchronization creates a CPU spin hotspot and prevents pipeline overlap. -With `--profile-pipeline`, the runtime also warns when a model step exceeds the -duration of the frames it produces. The live `model_step_wall_ms`, -`model_step_cpu_ms`, `engine_cpu_ms`, -`simulation_cpu_ms`, `rules_cpu_ms`, `conditioning_cpu_ms`, and `physx_*_ms` -metrics separate a throughput miss from model-thread CPU work; the pipeline's -`encode_ms`, `diffuse_ms`, `decode_ms`, and `finalize_ms` metrics identify the -corresponding GPU stage. Waypoint projection and ImGui draw submission are -UI-thread work and are not included in model-step metrics. The JSON sink -normalizes `_ms` metric names to `_s`. Exclude the first chunks when judging -steady state because compilation and graph capture are startup costs. - -The PhysX split includes `physx_traffic_prepare_ms`, -`physx_barrier_rebound_ms`, `physx_traffic_update_ms`, -`physx_state_materialize_ms`, and `physx_bridge_other_ms`. Together they locate -adapter work within `physx_bridge_ms` without enabling synchronized GPU-stage -profiling. - -The BEV is HUD-only data, so Crazy Robotaxi caps its raster resolution to the -actual ImGui map-image extent while preserving the authored aspect ratio. At -the default 1280x704 output this changes the default square BEV from 1024x1024 -to 234x234. The renderer's uint8 pixels remain uint8 through the game-engine -contract; only the much smaller displayed image crosses to the CPU for ImGui's -current pixel-upload helper. A fully GPU-resident BEV texture still requires a -CUDA-tensor image hook in the V2 ImGui renderer. - -The saved [performance investigation](../../docs/design/crazy_robotaxi_v2_performance.md) -records the current baseline, what the existing captures prove, and the exact -like-for-like `fast-perf` rerun needed after presentation changes. - -Presentation remains fixed at 30 fps. Disabling diagnostic synchronization -removes an avoidable pause source, but it does not make a model preset whose -steady-state throughput is below 30 fps meet that rate; use the -`crazy-robotaxi-omnidreams-perf` app when that tradeoff is appropriate. - -Profile interactive input separately with: +Race times are stored per map and course. Use `--race-times PATH` to choose a +different leaderboard file. -```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams-perf --mode webrtc -- \ - --profile-input-latency /tmp/crazy-robotaxi-input-trace.log -``` +## Optional live-edit abilities -This opt-in adds a UI-thread key indicator and logs the time from V2 UI event -receipt to the first presented model frame carrying that transition. It also -logs input sampling, model/cache boundaries, runtime publication, chunk drops, -resets, and the lead between committed and displayed simulation -state to the optional path following `--profile-input-latency`. Without an -explicit path, the trace is written to -`/tmp/crazy-robotaxi-input-trace.log`; it is separate from `--stats-path`. The -normal HUD does not construct these widgets or lifecycle records when the flag -is absent. The indicator's physical-key-to-browser delay still includes WebRTC -transport latency, while the reported `UI TO MODEL FRAME` value isolates the -synchronous app/model portion. See the -[V2 latency handoff](../../docs/design/crazy_robotaxi_v2_input_latency.md) for -the trace fields and interpretation caveats. - -All model presets generate eight neutral, hidden blocks before publishing the -first gameplay frame. The responsive ImGui HUD shows the current warmup block, -an animated activity marker, and elapsed time while compilation and autotuning -run. After warmup, the app recreates simulation, rules, conditioning, and the -autoregressive cache, so warmup does not consume game time, move the taxi, -advance the visible AR index, or count toward `--total-blocks`. Cache-bound -CUDA graphs safely re-arm against the new gameplay cache, so shorter first-use -hitches can remain. This moves the multi-second pauses ahead of presentation; -it does not reduce total cold-start time. Disable it for comparisons or startup -debugging with: +Live-edit features are disabled by default. Enable them with application +arguments: ```bash -uv run flashdreams-run-v2 crazy-robotaxi-omnidreams --mode webrtc -- \ - --prewarm-blocks 0 +uv run --package flashdreams-omnidreams flashdreams-run-v2 \ + crazy-robotaxi-omnidreams --mode native-window -- \ + --live-edit-coins \ + --live-edit-items \ + --live-edit-weather \ + --live-edit-style ``` +When enabled, `C` toggles coins, `K` cycles style skins, `V` cycles weather, +and `O` spawns a crossing obstacle. Style mode downloads its additional model +assets on first use and caches them under `artifacts/crazy_robotaxi/live_edit`. +Text-edit and obstacle guidance require a non-native DiT configuration; the +application rejects incompatible configurations before generation begins. + ## Authored maps -Maps are strict semantic `.robotaxi.yaml` documents. Validate or compile them +Maps are strict semantic `.robotaxi.yaml` documents. Validate or preview them without loading a model: ```bash -uv run crazy-robotaxi-map validate path/to/city.robotaxi.yaml -uv run crazy-robotaxi-map compile path/to/city.robotaxi.yaml -uv run crazy-robotaxi-map preview path/to/city.robotaxi.yaml --output city.svg -uv run crazy-robotaxi-map preview-spawn path/to/city.robotaxi.yaml \ - --spawn taxi_start --output taxi_start.png +uv run --package crazy-robotaxi crazy-robotaxi-map validate path/to/city.robotaxi.yaml +uv run --package crazy-robotaxi crazy-robotaxi-map compile path/to/city.robotaxi.yaml +uv run --package crazy-robotaxi crazy-robotaxi-map preview \ + path/to/city.robotaxi.yaml --output city.svg +uv run --package crazy-robotaxi crazy-robotaxi-map preview-spawn \ + path/to/city.robotaxi.yaml --spawn taxi_start --output taxi_start.png ``` - -The architectural source of truth for this rewrite is -[`../../docs/design/crazy_robotaxi_v2_architecture.md`](../../docs/design/crazy_robotaxi_v2_architecture.md). diff --git a/apps/crazy_robotaxi/crazy_robotaxi/__main__.py b/apps/crazy_robotaxi/crazy_robotaxi/__main__.py deleted file mode 100644 index 81c950473..000000000 --- a/apps/crazy_robotaxi/crazy_robotaxi/__main__.py +++ /dev/null @@ -1,11 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Run Crazy Robotaxi through the canonical FlashDreams V2 host.""" - -import sys - -from flashdreams.runtime_v2.cli import entrypoint - -if __name__ == "__main__": - entrypoint(["crazy-robotaxi", *sys.argv[1:]]) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/assets/README.md b/apps/crazy_robotaxi/crazy_robotaxi/assets/README.md index 209b7d29d..62cb6b05b 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/assets/README.md +++ b/apps/crazy_robotaxi/crazy_robotaxi/assets/README.md @@ -1,44 +1,18 @@ # Assets -This directory holds the bundled HUD control sprites under -`wheel_and_pedals/` and the map-independent obstacle template catalog. +This directory contains map-independent runtime data for Crazy Robotaxi. ## `obstacle_vehicle_tracks_v1.npz` -Numeric vehicle trajectories used by the live-edit obstacle ability. The -catalog was deterministically extracted from -`clipgt-0d404ff7-2b66-498c-b047-1ed8cded60d4.usdz` with: +This catalog contains numeric vehicle trajectories used by the optional +live-edit obstacle ability. The archive stores relative timestamps, local +center translations, orientations, first-sample dimensions, object-type +codes, sample offsets, and initial heights for 668 car and truck tracks. -```bash -python -m crazy_robotaxi.live_edit.obstacle_template_authoring \ - /path/to/clipgt-0d404ff7-2b66-498c-b047-1ed8cded60d4.usdz \ - crazy_robotaxi/assets/obstacle_vehicle_tracks_v1.npz -``` - -The archive contains only concatenated numeric arrays: relative timestamps, -local center translations, orientations, first-sample dimensions, object-type -codes, sample offsets, and initial height above the source ground. It contains -all 668 Car and Truck tracks from the source obstacle table. Runtime loading -uses `allow_pickle=False`; the source USDZ is authoring input and is not -packaged. - -## `wheel_and_pedals/` - -AlpaSim-style steering-wheel and pedal PNGs that drive the desktop -HUD chrome (the `SlangPyHudPresenter` steering-wheel + pedal overlay): - -- `steering_wheel.png` -- `throttle_pressed.png`, `throttle_unpressed.png` -- `brake_pressed.png`, `brake_unpressed.png` - -These are loaded by default (resolved relative to the installed -package), so the realistic controls render out of the box. Pass -`--control-assets-dir` to point the demo at a different sprite set; -the brake PNGs are also accepted under AlpaSim's `break_*.png` -spelling. When a sprite is missing, the HUD falls back to a -CPU-rendered vector wheel / fill-bar pedals. +Runtime loading uses `allow_pickle=False`. The source scene used to derive the +catalog is not distributed with the package. ## Maps -Crazy Robotaxi's `.robotaxi.yaml` maps live under `crazy_robotaxi/maps/`. -Seed images referenced by a map may be map-relative files or packaged assets. +Crazy Robotaxi's `.robotaxi.yaml` maps live in `crazy_robotaxi/maps/`. Seed +images referenced by a map may be map-relative files or packaged assets. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/factory.py b/apps/crazy_robotaxi/crazy_robotaxi/factory.py index 26c34b59a..93b3f8b0d 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/factory.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/factory.py @@ -14,20 +14,20 @@ from omnidreams_game_engine.config import BevConfig, RasterConfig, VehicleConfig from omnidreams_game_engine.engine import GameEngine from omnidreams_game_engine.game_map.vicinity import GameMapVicinityResolver +from omnidreams_game_engine.simulation.actor_controller import PhysicsActorController from omnidreams_game_engine.simulation.ego_vehicle_kinematics import ( EgoVehicleKinematics, state_from_initial_pose, ) from omnidreams_game_engine.simulation.ground_snap import GroundSnapper -from omnidreams_game_engine.simulation.actor_controller import PhysicsActorController from omnidreams_game_engine.types import DriverCommand, SceneDefinition, VehicleState from crazy_robotaxi.dynamics import TaxiVehicleConfig, integrate_taxi_vehicle -from crazy_robotaxi.physics import TaxiPhysicsWorld, step_taxi_physics_world from crazy_robotaxi.high_scores import RaceTimeStore from crazy_robotaxi.live_edit.config import LiveEditConfig from crazy_robotaxi.live_edit.nitro_ability import integrate_with_nitro -from crazy_robotaxi.live_edit.runtime_v2 import LiveEditGameRules, LiveEditGameplay +from crazy_robotaxi.live_edit.runtime_v2 import LiveEditGameplay, LiveEditGameRules +from crazy_robotaxi.physics import TaxiPhysicsWorld, step_taxi_physics_world from crazy_robotaxi.race import RaceController, RaceGameRules from crazy_robotaxi.rules import TaxiGameConfig, TaxiGameController, TaxiGameRules from crazy_robotaxi.scene import load_scene_data @@ -138,7 +138,7 @@ def build_taxi_engine( condition_renderer=renderer, frame_interval_s=frame_interval_s, ) - engine.live_edit = live_edit_gameplay + setattr(engine, "live_edit", live_edit_gameplay) return engine diff --git a/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py index 2d7a19309..e0c70e0c1 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/high_scores.py @@ -15,8 +15,8 @@ from filelock import FileLock from loguru import logger -from flashdreams.core.io.disk import default_flashdreams_cache_dir +from flashdreams.core.io.disk import default_flashdreams_cache_dir _CSV_FIELDS = ("name", "score", "achieved_at_utc") _RACE_CSV_FIELDS = ( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py index 822f5b0af..e448a0305 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/__init__.py @@ -1,56 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""OmniDreams live-edit abilities for Crazy Robotaxi (flag-gated, off by default). - -Draft scaffold for the integration plan in -``integrations/omnidreams/docs/robotaxi_live_edit_integration.md``. Two -abilities, each behind its own ``--live-edit-*`` flag: - -- **Style skins** (:mod:`crazy_robotaxi.live_edit.style_ability`): mid-run - world restyle via a pre-merged text-edit LoRA + drift corrector attached - to the flashdreams session, toggled by a prompt swap between chunks. -- **Coin pickups** (:mod:`crazy_robotaxi.live_edit.coin_ability` + - :mod:`crazy_robotaxi.live_edit.presenter`): sprites laid out along the - navigation lanes, projected per frame through the scene's FTheta camera, - composited into the world-model frame before HUD/encode, collected by - ego proximity. -- **Weather events** (:mod:`crazy_robotaxi.live_edit.weather_ability`, key - ``v``): clear -> rain -> snow -> clear via plain two-prompt-guided swaps - (no LoRA; the style LoRA is bypassed for weather-only windows) issued - through the same :class:`StyleAbility` prompt state machine. -- **Effect items** (:mod:`crazy_robotaxi.live_edit.item_ability`): sparse - pickup items along the lanes (rain/snow icons, mystery boxes) that reuse - the coin course/projection/compositing machinery and trigger the - weather/skin state machines at the next chunk boundary — the same path - the key requests take (the keys stay live alongside). -- **Obstacle events** (:mod:`crazy_robotaxi.live_edit.obstacle_ability`, - key ``o``): generated crossing cars with a gameplay-owned lifecycle, - optional compiled-road placement, optional PhysX materialization, and - optional box-axis guidance on the model side. - -Wiring map (composition-root seams, all live in ``crazy_robotaxi``): - -1. CLI: ``runtime_cli.build_parser`` registers the ``--live-edit-*`` flags - via :func:`crazy_robotaxi.live_edit.config.add_live_edit_args`; the run - paths build :class:`LiveEditConfig` with - :func:`crazy_robotaxi.live_edit.config.live_edit_config_from_args` and - hand it to ``CrazyRobotaxiApp``. -2. Session attach: ``CrazyRobotaxiApp.__init__`` calls - :func:`crazy_robotaxi.live_edit.style_ability.install_style_ability_on_backend` - before model warmup starts (corrector-safe session swap + deferred - LoRA attach). -3. Presenter: ``CrazyRobotaxiApp.__init__`` wraps the chain as - ``CausalFrameAlignmentPresenter(LiveEditPresenter(inner, ...))`` so the - compositor sees frame-aligned poses; the per-rollout coin ability is - bound via ``LiveEditPresenter.set_coin_ability``. -4. Keys: ``k`` cycles the skin, ``c`` toggles coins — native HUD - (``hud_presenter._on_keyboard_event``) and MJPEG - (``streaming_presenter._apply_control``) both raise rising-edge - requests on ``CrazyRobotaxiKeyboardState.live_edit`` - (:class:`crazy_robotaxi.live_edit.input_hooks.LiveEditRequests`), - drained each tick by ``CrazyRobotaxiRuntime.process_events``. -""" +"""Flag-gated live-edit abilities for Crazy Robotaxi.""" from crazy_robotaxi.live_edit.config import ( LiveEditCoinsConfig, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py index 85ca35a62..64d9e1aa1 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/gpu_compositor.py @@ -120,7 +120,7 @@ def alpha_blend_( def _blend_float_( - canvas_hwc_f32: Tensor, + canvas_hwc: Tensor, premultiplied_rgb_hwc: Tensor | None, one_minus_alpha_hw1: Tensor, left: int, @@ -136,7 +136,7 @@ def _blend_float_( small kernels. ``premultiplied_rgb_hwc=None`` darkens toward black (contact shadow). """ - height, width = canvas_hwc_f32.shape[:2] + height, width = canvas_hwc.shape[:2] src_h, src_w = one_minus_alpha_hw1.shape[:2] x0, y0 = max(0, left), max(0, top) x1, y1 = min(width, left + src_w), min(height, top + src_h) @@ -144,7 +144,7 @@ def _blend_float_( return sx, sy = x0 - left, y0 - top om = one_minus_alpha_hw1[sy : sy + (y1 - y0), sx : sx + (x1 - x0)] - roi = canvas_hwc_f32[y0:y1, x0:x1] + roi = canvas_hwc[y0:y1, x0:x1] # With fade f the factor on the canvas is 1 - f*(1-om) = (1-f) + f*om. roi.mul_(om if fade >= 1.0 else (1.0 - fade) + fade * om) if premultiplied_rgb_hwc is not None: @@ -153,7 +153,7 @@ def _blend_float_( def _blend_uint8_( - canvas_hwc_uint8: Tensor, + canvas_hwc: Tensor, premultiplied_rgb_hwc: Tensor | None, one_minus_alpha_hw1: Tensor, left: int, @@ -170,7 +170,7 @@ def _blend_uint8_( float path by at most 1 LSB where blends overlap (each ROI blend rounds independently). """ - height, width = canvas_hwc_uint8.shape[:2] + height, width = canvas_hwc.shape[:2] src_h, src_w = one_minus_alpha_hw1.shape[:2] x0, y0 = max(0, left), max(0, top) x1, y1 = min(width, left + src_w), min(height, top + src_h) @@ -180,7 +180,7 @@ def _blend_uint8_( om = one_minus_alpha_hw1[sy : sy + (y1 - y0), sx : sx + (x1 - x0)] if fade < 1.0: om = (1.0 - fade) + fade * om - roi = canvas_hwc_uint8[y0:y1, x0:x1] + roi = canvas_hwc[y0:y1, x0:x1] out = roi * om if premultiplied_rgb_hwc is not None: c = premultiplied_rgb_hwc[sy : sy + (y1 - y0), sx : sx + (x1 - x0)] @@ -300,7 +300,11 @@ def _chip(self, label: str, device: torch.device) -> tuple[Tensor, Tensor]: return cached probe = ImageDraw.Draw(Image.new("RGBA", (1, 1))) text_box = probe.textbbox((10, 6), label) - image = Image.new("RGBA", (text_box[2] + 10 + 1, text_box[3] + 6 + 1), (0,) * 4) + image = Image.new( + "RGBA", + (round(text_box[2]) + 11, round(text_box[3]) + 7), + (0,) * 4, + ) draw = ImageDraw.Draw(image) draw.rounded_rectangle( [0, 0, text_box[2] + 10, text_box[3] + 6], diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py index 7aa12e840..afb959913 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_ability.py @@ -1,12 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Track-backed obstacle events and optional model-side box-axis guidance. - -Gameplay state, placement, rendering, and optional physics live in -:mod:`crazy_robotaxi.live_edit.obstacle_events`. This module retains the -established public import path and the optional OmniDreams guidance hook. -""" +"""Model-side guidance for track-backed obstacle events.""" from __future__ import annotations @@ -14,7 +9,6 @@ from loguru import logger -from crazy_robotaxi.live_edit.config import LiveEditObstacleConfig from crazy_robotaxi.live_edit.obstacle_events import ( OBSTACLE_ENTITY_PREFIX, ObstacleAbility, @@ -273,33 +267,6 @@ def __exit__(self, *exc: object) -> None: self._vae._use_cuda_graph = self._saved -def install_obstacle_guidance_on_backend( - backend: Any, config: LiveEditObstacleConfig -) -> None: - """Arm box-axis guidance before model warmup starts. - - Mirrors ``install_style_ability_on_backend``'s deferred attach: the hook - install waits until ``warmup_model`` has built the pipeline. The session - keeps its accelerated pipeline — the guidance is CUDA-graph safe (see - :class:`ObstacleGuidance`), so no graph-free rebuild is needed. - """ - if config.guide_scale <= 0.0: - return - session = getattr(backend, "_session", None) - if session is None: - raise ValueError( - "--live-edit-obstacle guidance requires the omnidreams world-model backend." - ) - guidance = ObstacleGuidance(config.guide_scale) - original_warmup = session.warmup_model - - def warmup_and_install() -> None: - original_warmup() - guidance.install(backend) - - session.warmup_model = warmup_and_install - - __all__ = [ "OBSTACLE_ENTITY_PREFIX", "ObstacleAbility", @@ -309,7 +276,6 @@ def warmup_and_install() -> None: "ObstacleTemplate", "ObstacleTemplateCatalog", "build_obstacle_event", - "install_obstacle_guidance_on_backend", "local_ground_z", "road_ahead_pose", ] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py index ef2716a53..2876fd35f 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_events.py @@ -428,26 +428,24 @@ def __init__( templates is None or (parked_templates is None and config.static_count > 0) ): resolved_catalog = load_obstacle_template_catalog() - self._templates = ( - templates - if templates is not None - else resolved_catalog.moving( # type: ignore[union-attr] + if templates is None: + assert resolved_catalog is not None + self._templates = resolved_catalog.moving( min_drift_m=config.min_drift_m, min_coverage_s=config.min_coverage_s, length_range_m=config.length_range_m, ) - ) - self._parked_templates = ( - parked_templates - if parked_templates is not None - else ( - () - if config.static_count == 0 - else resolved_catalog.parked( # type: ignore[union-attr] - length_range_m=config.length_range_m - ) + else: + self._templates = templates + if parked_templates is not None: + self._parked_templates = parked_templates + elif config.static_count == 0: + self._parked_templates = () + else: + assert resolved_catalog is not None + self._parked_templates = resolved_catalog.parked( + length_range_m=config.length_range_m ) - ) self._pending: list[tuple[int, int]] = [] self._events: list[ObstacleEvent] = [] self._chunk_index = 0 diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py index 768182573..f8d778eb9 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/obstacle_templates.py @@ -21,7 +21,7 @@ from functools import lru_cache from importlib.resources import files from pathlib import Path -from typing import BinaryIO +from typing import IO import numpy as np import numpy.typing as npt @@ -153,7 +153,7 @@ def _require_array( def load_obstacle_template_catalog_from_file( - source: str | Path | BinaryIO, + source: str | Path | IO[bytes], ) -> ObstacleTemplateCatalog: """Load and validate a safe numeric obstacle-template archive.""" with np.load(source, allow_pickle=False) as archive: diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py index 840d325ea..4169233c7 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/runtime_v2.py @@ -22,11 +22,11 @@ from crazy_robotaxi.live_edit.gpu_compositor import LiveEditFrameCompositor from crazy_robotaxi.live_edit.item_ability import ItemAbility, ItemEffects from crazy_robotaxi.live_edit.nitro_ability import NitroAbility -from crazy_robotaxi.live_edit.obstacle_events import ObstacleAbility from crazy_robotaxi.live_edit.obstacle_ability import ( OBSTACLE_ENTITY_PREFIX, ObstacleGuidance, ) +from crazy_robotaxi.live_edit.obstacle_events import ObstacleAbility from crazy_robotaxi.live_edit.style_ability import StyleAbility from crazy_robotaxi.navigation import NavigationLane from flashdreams.runtime_v2.user_input_event import ( diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py index cef29bee5..12b1ed135 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/style_ability.py @@ -36,7 +36,7 @@ import time from collections.abc import Callable from dataclasses import replace -from typing import Any +from typing import Any, cast from loguru import logger @@ -392,11 +392,7 @@ def request_cycle(self) -> None: """ if not self._config.enabled: return - current = ( - self._active_index - if self._pending_index is _NO_PENDING - else self._pending_index - ) + current = self._skin_state() if current is None: self._pending_index = 0 elif current + 1 < len(self._config.skins): @@ -498,13 +494,13 @@ def _skin_state(self) -> int | None: """Effective skin index once any pending request lands.""" if self._pending_index is _NO_PENDING: return self._active_index - return self._pending_index # type: ignore[return-value] + return cast(int | None, self._pending_index) def _weather_state(self) -> int | None: """Effective weather index once any pending request lands.""" if self._pending_weather is _NO_PENDING: return self._active_weather - return self._pending_weather # type: ignore[return-value] + return cast(int | None, self._pending_weather) def _corrector_enabled(self) -> bool: """Whether any drift corrector will actually attach to the session. @@ -661,9 +657,11 @@ def _attach_corrector(self, pipeline: Any, transformer: Any) -> None: base_predict_flow = transformer.predict_flow style_gain = self._config.corrector_gain + checkpoint = self._config.corrector_checkpoint + assert checkpoint is not None summary = apply_drift_corrector( SimpleNamespace(pipeline=pipeline), - self._config.corrector_checkpoint, + checkpoint, style_gain, unfused=True, ) @@ -838,11 +836,15 @@ def _apply_pending(self, *, refresh: bool = False) -> None: self._pending_index = _NO_PENDING self._pending_weather = _NO_PENDING self._pending_skin_duration = None - target_skin = ( - self._active_index if pending_skin is _NO_PENDING else pending_skin + target_skin: int | None = ( + self._active_index + if pending_skin is _NO_PENDING + else cast(int | None, pending_skin) ) - target_weather = ( - self._active_weather if pending_weather is _NO_PENDING else pending_weather + target_weather: int | None = ( + self._active_weather + if pending_weather is _NO_PENDING + else cast(int | None, pending_weather) ) changed = ( target_skin != self._active_index or target_weather != self._active_weather @@ -1117,74 +1119,3 @@ def attach_style_ability(session: Any, config: LiveEditStyleConfig) -> StyleAbil ability = StyleAbility(config) ability.attach(session) return ability - - -def install_style_ability_on_backend(backend: Any, ability: StyleAbility) -> None: - """Prepare a ``WorldModelRenderBackend`` for the style ability. - - Called at the composition root BEFORE model warmup starts: - - - in ``unfused`` corrector mode only, replaces the backend's session - with one whose pipeline factory disables CUDA graphs (the manifest - has no such knob, and the unfused corrector's scale gating is not - graph-safe). The default ``fused`` mode keeps the accelerated - pipeline (compile_network + use_cuda_graph) untouched; - - defers :meth:`StyleAbility.attach` until the session's own - ``warmup_model`` has built the pipeline (attach needs the live - transformer), by wrapping that method. - """ - session = getattr(backend, "_session", None) - if session is None: - raise ValueError( - "--live-edit-style requires the omnidreams world-model backend " - "(the raster backend has no flashdreams session)." - ) - needs_graph_free = ( - ability._config.corrector_checkpoint is not None - and ability._config.corrector_mode == "unfused" - ) - if needs_graph_free and not getattr(session, "_live_edit_cuda_graph_free", False): - session = _corrector_safe_session(session) - backend._session = session - original_warmup = session.warmup_model - - def warmup_and_attach() -> None: - original_warmup() - ability.attach(session) - - session.warmup_model = warmup_and_attach - - -def _corrector_safe_session(session: Any) -> Any: - """Rebuild a not-yet-warmed session with CUDA graphs disabled. - - Mirrors the validated bring-up factory: same manifest / profile / - offload / postprocess settings, but the transformer is built with - ``use_cuda_graph=False`` so the unfused drift corrector's per-step LoRA - scale gating stays outside any captured graph. - """ - from omnidreams_game_engine.world_model.flashdreams_adapter import ( - FlashdreamsWorldModelSession, - _build_pipeline_config, - _setup_pipeline_from_config, - ) - - def cuda_graph_free_factory(manifest: Any, profile: Any) -> Any: - from flashdreams.infra.config import derive_config - - config = _build_pipeline_config(manifest, profile) - config = derive_config( - config, diffusion_model={"transformer": {"use_cuda_graph": False}} - ) - return _setup_pipeline_from_config(config, manifest) - - rebuilt = FlashdreamsWorldModelSession( - session.manifest, - profile=session._profile_config, - offload_text_encoder=session._offload_text_encoder, - pipeline_factory=cuda_graph_free_factory, - postprocess=session._postprocess, - ) - # Marker so cooperating installers (obstacle guidance) skip a re-swap. - rebuilt._live_edit_cuda_graph_free = True - return rebuilt diff --git a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py index 49d805c37..e20bd4a8d 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/live_edit/weather_ability.py @@ -106,6 +106,7 @@ def compose_swap_target( use_lora=lora_available, corrector_gain=style_config.corrector_gain, ) + assert weather is not None assert weather_config is not None, "weather state requires a weather config" return SwapTarget( prompt=weather.prompt, diff --git a/apps/crazy_robotaxi/crazy_robotaxi/race.py b/apps/crazy_robotaxi/crazy_robotaxi/race.py index 95b2cb968..5c02bc4b4 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/race.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/race.py @@ -28,12 +28,12 @@ from omnidreams_game_engine.types import TrajectoryChunk, VehicleState from shapely.geometry import LineString, Point, Polygon -from crazy_robotaxi.rules import relative_target_bearing_rad from crazy_robotaxi.high_scores import ( RaceTimeEntry, RaceTimeStore, format_race_time_us, ) +from crazy_robotaxi.rules import relative_target_bearing_rad RaceSessionState = Literal["awaiting_start", "racing", "awaiting_name", "leaderboard"] RaceTargetKind = Literal["start", "checkpoint"] diff --git a/apps/crazy_robotaxi/crazy_robotaxi/ui.py b/apps/crazy_robotaxi/crazy_robotaxi/ui.py index 662475976..91b814ada 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/ui.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/ui.py @@ -37,7 +37,12 @@ from torch import Tensor from crazy_robotaxi.game_selection import GameMapOption, GameMode, GameSelection -from crazy_robotaxi.high_scores import format_race_time_us, validate_player_name +from crazy_robotaxi.high_scores import ( + HighScoreEntry, + RaceTimeEntry, + format_race_time_us, + validate_player_name, +) from crazy_robotaxi.race import RaceGameSnapshot, project_race_gate_to_camera from crazy_robotaxi.rules import ( TaxiCameraMarkerProjection, @@ -349,9 +354,7 @@ def _record_presented_input( if received_at_ns is None: return self._reported_input_timestamps_us.add(timestamp_us) - self._latest_input_latency_ms = ( - presented_at_ns - received_at_ns - ) / 1_000_000.0 + self._latest_input_latency_ms = (presented_at_ns - received_at_ns) / 1_000_000.0 _TRACE_LOGGER.info( "[crazy-robotaxi] input-to-model-frame latency: " "event_us=%d ui_to_frame_ms=%.1f generation=%d step=%d epoch=%d " @@ -838,8 +841,11 @@ def _draw_speed(self, imgui: Any, speed_mps: float) -> None: def _gameplay_overlay_font(self, imgui: Any) -> Any: """Load and cache imgui-bundle's Droid Sans face.""" if self._gameplay_font is None: - resource = files("imgui_bundle").joinpath( - "assets", "fonts", "DroidSans.ttf" + resource = ( + files("imgui_bundle") + .joinpath("assets") + .joinpath("fonts") + .joinpath("DroidSans.ttf") ) with as_file(resource) as path: self._gameplay_font = imgui.get_io().fonts.add_font_from_file_ttf( @@ -1700,14 +1706,16 @@ def _draw_terminal_leaderboard( imgui.TableBgTarget_.row_bg1, _imgui_color(imgui, (*accent_rgb, 0.24)), ) + if race: + assert isinstance(entry, RaceTimeEntry) + result = format_race_time_us(entry.elapsed_time_us) + else: + assert isinstance(entry, HighScoreEntry) + result = f"{entry.score:>7}" values = ( f"#{rank}", entry.name, - ( - format_race_time_us(entry.elapsed_time_us) - if race - else f"{entry.score:>7}" - ), + result, ) for column, value in enumerate(values): imgui.table_set_column_index(column) diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 68f22196c..7fd80970f 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -13,8 +13,8 @@ import pytest import torch from crazy_robotaxi.application import ( - CrazyRobotaxiApplicationDefaults, CrazyRobotaxiApplication, + CrazyRobotaxiApplicationDefaults, _fit_bev_renderer_to_ui, ) from crazy_robotaxi.dynamics import TaxiVehicleConfig @@ -318,6 +318,7 @@ def test_pressed_gamepad_start_requests_a_v2_game_restart() -> None: ) ) + def test_pressed_r_can_discard_an_unsubmitted_score() -> None: class RestartRequested(Exception): pass @@ -337,6 +338,7 @@ class RestartRequested(Exception): class ProbeState: game_selected = True + config = SimpleNamespace(profile_input_latency=False) def __init__(self) -> None: self.driver_input = DriverInput() @@ -367,6 +369,7 @@ class InputOrderVerified(Exception): class ProbeState: game_selected = True + config = SimpleNamespace(profile_input_latency=False) def __init__(self) -> None: self.driver_input = DriverInput() @@ -413,9 +416,7 @@ def test_taxi_keyboard_restores_arcade_brake_reverse() -> None: command = _taxi_driver_command(driver_input.command()) - assert vehicle.steer_rate_rad_per_s == pytest.approx( - 3.5 * vehicle.max_steer_rad - ) + assert vehicle.steer_rate_rad_per_s == pytest.approx(3.5 * vehicle.max_steer_rad) assert vehicle.steer_return_rate_rad_per_s == pytest.approx( 5.0 * vehicle.max_steer_rad ) @@ -507,12 +508,10 @@ def test_pipeline_profiling_is_an_app_local_opt_in( ) app.init(arguments) - session = app.create_session(app.session_desc()) - session.init() - _, model_loop = session._take_loops() + session = cast(CrazyRobotaxiSession, app.create_session(app.session_desc())) assert configured == [] - model_loop.state.pipeline_factory() + session._pipeline_factory() assert configured[0].enable_sync_and_profile is expected assert app._config is not None assert app._config.pipeline_profiling is expected @@ -534,13 +533,11 @@ def test_model_adapters_keep_their_packaged_pipeline_configs() -> None: def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: - pipeline = OMNIDREAMS_FAST_PERF_PIPELINE_CONFIG + pipeline: Any = OMNIDREAMS_FAST_PERF_PIPELINE_CONFIG + perf_pipeline: Any = OMNIDREAMS_PERF_PIPELINE_CONFIG assert pipeline.name == "omnidreams-fast-perf" assert pipeline.diffusion_model.seed is None - assert ( - pipeline.decoder.use_compile - is OMNIDREAMS_PERF_PIPELINE_CONFIG.decoder.use_compile - ) + assert pipeline.decoder.use_compile is perf_pipeline.decoder.use_compile assert pipeline.decoder.use_cuda_graph is True assert pipeline.image_encoder.native_vae_acceleration == "required" assert pipeline.image_encoder.native_vae_backend == "fp8" @@ -622,7 +619,7 @@ def test_fast_perf_honors_explicit_pipeline_overrides() -> None: ] ) - pipeline = app._pipeline_config + pipeline = cast(Any, app._pipeline_config) transformer = pipeline.diffusion_model.transformer assert pipeline.diffusion_model.seed == 7 assert transformer.compile_network is False diff --git a/apps/crazy_robotaxi/tests/test_live_edit_v2.py b/apps/crazy_robotaxi/tests/test_live_edit_v2.py index 687c59d13..06122ddad 100644 --- a/apps/crazy_robotaxi/tests/test_live_edit_v2.py +++ b/apps/crazy_robotaxi/tests/test_live_edit_v2.py @@ -7,10 +7,11 @@ from pathlib import Path from types import SimpleNamespace +from typing import cast +import crazy_robotaxi.live_edit.config as live_edit_config import numpy as np import pytest -import crazy_robotaxi.live_edit.config as live_edit_config from crazy_robotaxi.live_edit.config import ( LiveEditCoinsConfig, LiveEditConfig, @@ -29,19 +30,22 @@ from crazy_robotaxi.live_edit.obstacle_templates import load_obstacle_template_catalog from crazy_robotaxi.live_edit.runtime_v2 import LiveEditGameplay from crazy_robotaxi.navigation import NavigationLane -from flashdreams.runtime_v2.user_input_event import ( - KeyboardInputState, - KeyboardUserInputEvent, -) -from flashdreams.runtime_v2.user_input_events import UserInputEvents +from ludus_renderer import SceneObject from omnidreams_game_engine.config import VehicleConfig from omnidreams_game_engine.types import ( CameraCalibration, + SceneDefinition, TrajectoryChunk, VehicleState, ) from PIL import Image +from flashdreams.runtime_v2.user_input_event import ( + KeyboardInputState, + KeyboardUserInputEvent, +) +from flashdreams.runtime_v2.user_input_events import UserInputEvents + pytestmark = pytest.mark.ci_cpu @@ -74,7 +78,7 @@ def request_spawn(self) -> None: self.spawns += 1 -def _scene() -> SimpleNamespace: +def _scene() -> SceneDefinition: calibration = CameraCalibration( clipgt_name="camera_front_wide_120fov", logical_name="camera_front_wide_120fov", @@ -87,9 +91,14 @@ def _scene() -> SimpleNamespace: linear_cde=np.asarray([1.0, 0.0, 0.0], dtype=np.float32), sensor_to_rig_flu=np.eye(4, dtype=np.float32), ) - return SimpleNamespace( - selected_camera=calibration, - initial_rgb=np.zeros((640, 1168, 3), dtype=np.uint8), + return cast( + SceneDefinition, + SimpleNamespace( + selected_camera=calibration, + initial_rgb=np.zeros((640, 1168, 3), dtype=np.uint8), + game_map=None, + ground_mesh_vertices=None, + ), ) @@ -109,12 +118,13 @@ def fake_download(url: str, *, cache_dir: Path) -> Path: cache_dir=tmp_path, ) - assert [ - resolved.style.lora_checkpoint.name, - resolved.style.corrector_checkpoint.name, - resolved.style.gate_alpha_json.name, - resolved.style.base_corrector_checkpoint.name, - ] == [ + paths = ( + resolved.style.lora_checkpoint, + resolved.style.corrector_checkpoint, + resolved.style.gate_alpha_json, + resolved.style.base_corrector_checkpoint, + ) + assert [path.name for path in paths if path is not None] == [ "lora_style_v6_step1600.pt", "lora_style_corrector_v5_valpeak.pt", "gate_style_v5.json", @@ -160,6 +170,8 @@ def fake_download(url: str, *, cache_dir: Path) -> Path: assert resolved.style.lora_checkpoint is None assert resolved.style.base_corrector_checkpoint is None + assert resolved.style.corrector_checkpoint is not None + assert resolved.style.gate_alpha_json is not None assert resolved.style.corrector_checkpoint.name == ( "lora_style_corrector_v5_valpeak.pt" ) @@ -225,7 +237,12 @@ def test_v2_live_edit_camera_uses_generated_frame_size() -> None: 1168, 640, ) - assert gameplay._compositor.sprite_image("coin").getpixel((15, 15))[3] == 0 + assert gameplay._compositor.sprite_image("coin").getpixel((15, 15)) == ( + 0, + 0, + 0, + 0, + ) def test_v2_live_edit_loads_configured_sprites(tmp_path) -> None: @@ -274,7 +291,9 @@ def test_physical_obstacle_lifetime_uses_relative_track_clock() -> None: dimensions_lwh=np.asarray([4.0, 2.0, 1.5], dtype=np.float32), template_index=0, drive_speed_mps=4.0, - scene_object=SimpleNamespace(timestamps_us=relative_timestamps), + scene_object=cast( + SceneObject, SimpleNamespace(timestamps_us=relative_timestamps) + ), ) obstacle = ObstacleAbility.__new__(ObstacleAbility) obstacle._config = LiveEditObstacleConfig( diff --git a/apps/crazy_robotaxi/tests/test_race.py b/apps/crazy_robotaxi/tests/test_race.py index d33bda119..b10a52505 100644 --- a/apps/crazy_robotaxi/tests/test_race.py +++ b/apps/crazy_robotaxi/tests/test_race.py @@ -29,6 +29,7 @@ GameMapRaceCourse, load_game_map, ) +from omnidreams_game_engine.game_map.types import ResolvedGameMap from omnidreams_game_engine.math3d import rig_pose_from_vehicle_state from omnidreams_game_engine.types import TrajectoryChunk, VehicleState from shapely.geometry import Polygon @@ -68,11 +69,9 @@ def _trajectory( ) -def _point(game_map: object, element_id: str) -> tuple[float, float]: +def _point(game_map: ResolvedGameMap, element_id: str) -> tuple[float, float]: element = next( - element - for element in game_map.elements # type: ignore[attr-defined] - if element.element_id == element_id + element for element in game_map.elements if element.element_id == element_id ) point = Polygon(element.surface_world[:, :2]).representative_point() return float(point.x), float(point.y) diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index ea69c4151..be99d93fa 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -201,7 +201,7 @@ def __init__(self) -> None: self.click_submit = False self.clicked_buttons: set[str] = set() self.buttons: list[str] = [] - self.button_sizes: list[tuple[str, object | None]] = [] + self.button_sizes: list[tuple[str, tuple[float, float] | None]] = [] self.background_draw_list = _FakeDrawList() self.window_flags: dict[str, int] = {} self.tables: dict[str, list[list[str]]] = {} @@ -344,7 +344,7 @@ def input_text(self, label: str, value: str, *, flags: int): del label, value, flags return self.submit_input, self.input_value - def button(self, label: str, size: object | None = None) -> bool: + def button(self, label: str, size: tuple[float, float] | None = None) -> bool: self.buttons.append(label) self.button_sizes.append((label, size)) submit = self.click_submit and label in {"SAVE SCORE", "SAVE TIME"} @@ -734,7 +734,7 @@ def test_bev_compositor_draws_ego_over_transparent_center() -> None: assert composited.device == video.device torch.testing.assert_close(composited[:, 16, 16], torch.tensor((1.0, 0.6, -1.0))) - torch.testing.assert_close(composited[:, 12, 16], torch.tensor((-0.8, -0.2, 0.15))) + torch.testing.assert_close(composited[:, 13, 15], torch.tensor((-0.8, -0.2, 0.15))) assert torch.all(composited[:, 0, 0] == -0.5) @@ -930,9 +930,9 @@ def test_selection_menus_use_arcade_card_layout() -> None: assert imgui.window_flags[title] & imgui.WindowFlags_.no_title_bar button_sizes = dict(imgui.button_sizes) assert button_sizes["TAXI"] == button_sizes["RACE"] - assert button_sizes["TAXI"][0] > 0.0 - assert button_sizes["Test City##map-0"][0] > 0.0 - assert button_sizes["DOWNTOWN SPRINT##course-0"][0] > 0.0 + for label in ("TAXI", "Test City##map-0", "DOWNTOWN SPRINT##course-0"): + size = button_sizes[label] + assert size is not None and size[0] > 0.0 assert [command for command, _args in imgui.background_draw_list.commands].count( "rect_filled" ) == 3 @@ -1204,9 +1204,7 @@ def test_input_trace_reports_committed_state_ahead_of_presented_frame(caplog) -> state=KeyboardInputState.PRESSED, ) - with caplog.at_level( - logging.INFO, logger="flashdreams.runtime_v2.chunk_trace" - ): + with caplog.at_level(logging.INFO, logger="flashdreams.runtime_v2.chunk_trace"): state.consume_input_events(UserInputEvents([pressed])) state.publish( build_hud_frames( @@ -1254,9 +1252,7 @@ def test_input_trace_is_silent_without_opt_in(caplog) -> None: state=KeyboardInputState.PRESSED, ) - with caplog.at_level( - logging.INFO, logger="flashdreams.runtime_v2.chunk_trace" - ): + with caplog.at_level(logging.INFO, logger="flashdreams.runtime_v2.chunk_trace"): state.consume_input_events(UserInputEvents([pressed])) assert "chunk-trace" not in "\n".join( diff --git a/apps/omnidreams_game_engine/README.md b/apps/omnidreams_game_engine/README.md index 74a5638f1..bef179ff8 100644 --- a/apps/omnidreams_game_engine/README.md +++ b/apps/omnidreams_game_engine/README.md @@ -8,6 +8,3 @@ loop, presentation backend, or worker thread. rendering, presentation, wheel, world-model, and runtime settings. Applications compose those settings with their own typed defaults and explicit CLI options; relative paths resolve beside the YAML document. - -See the target architecture in -[`../../docs/design/crazy_robotaxi_v2_architecture.md`](../../docs/design/crazy_robotaxi_v2_architecture.md). diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py b/apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py index 5cb8a238e..9ce513be3 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/cli_args.py @@ -44,5 +44,6 @@ def parse_args( """Parse arguments and attach their explicitly supplied destinations.""" raw_args = sys.argv[1:] if args is None else list(args) parsed = super().parse_args(raw_args, namespace) + assert parsed is not None setattr(parsed, _EXPLICIT_ARG_DESTS_ATTR, explicit_arg_dests(self, raw_args)) return parsed diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py index db6fca708..97ce274e7 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py @@ -14,7 +14,6 @@ import numpy as np import pyarrow.parquet as pq import yaml -from loguru import logger from interactive_drive.scene_loader import ( SCENE_FRAME_SUFFIXES, SCENE_FRAMES_DIRNAME, @@ -22,6 +21,7 @@ resolve_variant_archive, variant_from_stem, ) +from loguru import logger from PIL import Image from omnidreams_game_engine.colors import ( diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py index ea1f03a28..afbdd13a7 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/simulation/map_traffic.py @@ -553,21 +553,10 @@ def prepare_topology(self, ego: BodyState) -> None: def prepare_step( self, - ego_or_world: BodyState | object, - dt_or_ego: float | BodyState, - legacy_dt_s: float | None = None, + ego: BodyState, + dt_s: float, ) -> tuple[ActorTrackTarget, ...]: """Advance logical cars and return targets for active physical bodies.""" - legacy_world = None - if legacy_dt_s is None: - ego = ego_or_world - dt_s = float(dt_or_ego) - else: - legacy_world = ego_or_world - ego = dt_or_ego - dt_s = float(legacy_dt_s) - if not isinstance(ego, BodyState): - raise TypeError("prepare_step requires an ego BodyState") for state in self._states: if state.phase is MapTrafficPhase.TRAVERSING: if state.object_id in self._active_ids: @@ -620,13 +609,6 @@ def prepare_step( for state in self._states if state.object_id in self._active_ids ) - if legacy_world is not None: - legacy_world.apply_track_progress( - tuple( - (target.object_id, target.timestamp_us, target.velocity_scale) - for target in targets - ) - ) return targets diff --git a/apps/omnidreams_game_engine/tests/test_map_traffic.py b/apps/omnidreams_game_engine/tests/test_map_traffic.py index 71f007c41..bff33054b 100644 --- a/apps/omnidreams_game_engine/tests/test_map_traffic.py +++ b/apps/omnidreams_game_engine/tests/test_map_traffic.py @@ -6,13 +6,11 @@ from __future__ import annotations import math -from types import SimpleNamespace -from typing import cast from unittest.mock import patch import numpy as np import pytest -from ludus_renderer import BodyState, PhysXWorld +from ludus_renderer import BodyState from omnidreams_game_engine.config import VehicleConfig from omnidreams_game_engine.game_map.types import GameMapTrafficVehicle from omnidreams_game_engine.game_map.vicinity import GameMapVicinity @@ -84,25 +82,16 @@ def test_active_traffic_walks_route_cursor_without_global_search() -> None: body=_body_at((12.4, 0.0), linear_velocity_xy=(10.0, 0.0)), dt_s=1.0 / 30.0, ) - published: list[tuple[tuple[str, int, float], ...]] = [] - world = cast( - PhysXWorld, - SimpleNamespace( - ego_model=SimpleNamespace(half_extents_m=(2.4, 1.0, 0.8)), - apply_track_progress=published.append, - ), - ) - with patch.object( controller, "_nearest_route_projection", side_effect=AssertionError("normal traversal used a global route search"), ): - controller.prepare_step(world, _body_at((-100.0, -100.0)), 1.0 / 30.0) + targets = controller.prepare_step(_body_at((-100.0, -100.0)), 1.0 / 30.0) assert state.route_segment_index == 12 assert state.timestamp_us == pytest.approx(1_240_000, abs=1.0) - assert published[-1][0][1] == pytest.approx(1_590_000, abs=1.0) + assert targets[0].timestamp_us == pytest.approx(1_590_000, abs=1.0) def test_collision_recovery_globally_reacquires_route_and_cursor() -> None: diff --git a/docs/design/crazy_robotaxi_legacy_architecture.md b/docs/design/crazy_robotaxi_legacy_architecture.md deleted file mode 100644 index 0ac9df591..000000000 --- a/docs/design/crazy_robotaxi_legacy_architecture.md +++ /dev/null @@ -1,169 +0,0 @@ -# Crazy Robotaxi map-branch architecture - -This document records the as-built architecture on -`origin/dev/aidanf/game/crazy-robotaxi-map` as fetched at commit `f92deda`. It -is a behavioral reference for the rewrite, not a target implementation and not -a list of files to transplant. The earlier V2 porting attempt was not -consulted. - -## Process and threading model - -```mermaid -flowchart LR - subgraph Main["main / presentation thread"] - CLI["runner + game CLI"] - APP["InteractiveDriveApp"] - LOOP["custom run_main_loop"] - INPUT["keyboard / wheel state"] - SIM["EgoVehicleKinematics"] - GAME["CrazyRobotaxiRuntime
TaxiGameController"] - PRESENT["SlangPy or MJPEG presenter
game HUD + pacing"] - end - - subgraph Worker["custom chunk-pipeline worker"] - QUEUE["FIFO worker commands"] - BACKEND["LocalVideoModelAdapter
WorldModelRenderBackend"] - RASTER["LudusConditionRasterizer"] - MODEL["FlashdreamsWorldModelSession"] - end - - subgraph Optional["additional temporary/background threads"] - LOADER["scene loader / preloader"] - WHEEL["evdev wheel readers"] - end - - CLI --> APP --> LOOP - INPUT --> LOOP - WHEEL --> INPUT - LOOP -->|"commands for each output frame"| SIM - SIM -->|"TrajectoryChunk"| GAME - GAME -->|"decorated trajectory + per-frame game snapshots"| QUEUE - QUEUE --> BACKEND - BACKEND --> RASTER - RASTER -->|"HD-map conditioning frames"| MODEL - MODEL -->|"generated RGB frames"| BACKEND - BACKEND -->|"QueuedFrame per generated frame"| LOOP - LOOP --> PRESENT - GAME -->|"boundary telemetry through shared keyboard state"| PRESENT - LOADER -->|"SceneBundle + ground snapper"| APP -``` - -The old engine owns scheduling that FlashDreams V2 now owns. The main loop -polls the presenter, samples controls, simulates a complete model chunk, sends -that chunk through a private worker queue, drains generated frames from a -second queue, and presents them at a fixed cadence. A generation token drops -stale queued frames after resets or scene switches. - -The presenter is more than a renderer: it owns or reads control state, HUD -state, view selection, scene selection, loading screens, game-over name entry, -and local/MJPEG output. Consequently, game state crosses from the simulation -path to presentation through mutable keyboard/presenter state. - -## Scene and map preparation - -```mermaid -flowchart TD - YAML["authored .robotaxi.yaml"] - RESOLVE["schema validation + topology/geometry resolution"] - ARCHIVE["cached ClipGT-compatible archive"] - LOAD["scene loader"] - SCENE["SceneBundle
camera, seed image, prompt,
semantic layers, objects, ground mesh"] - TAXIDATA["CrazyRobotaxiSceneData
navigation lanes, fare regions, curbs"] - VICINITY["GameMapVicinityResolver"] - SNAP["GroundSnapper"] - PHYSICS["TaxiPhysicsWorld"] - CONDITION["LudusConditionRasterizer"] - - YAML --> RESOLVE --> ARCHIVE --> LOAD --> SCENE - SCENE --> TAXIDATA - SCENE --> VICINITY - SCENE --> SNAP - TAXIDATA --> PHYSICS - VICINITY --> PHYSICS - SCENE --> CONDITION -``` - -The authored node graph is compiled into distinct runtime concepts: - -- semantic surfaces and markings for model conditioning; -- directed navigation lanes and parking fare regions for route planning; -- curb and barrier geometry for physics; -- authored traffic tracks and spawn parameters; -- camera, seed-image, prompt, and ground-mesh data. - -The private archive is a derived cache. The YAML remains the authoring format. - -## Per-chunk game and model flow - -```mermaid -flowchart LR - EVENTS["presenter events"] - COMMANDS["CommandTimeline
one command per frame"] - EGO["taxi integration"] - WORLD["TaxiPhysicsWorld
curbs + static actors"] - TRAFFIC["MapTrafficController
routing, headway, collision, recovery"] - TRAJ["TrajectoryChunk
ego poses + actor trajectories"] - FARES["TaxiGameController
pickup/dropoff selection,
timers, score, leaderboard"] - PEOPLE["passenger trajectories"] - RASTER["camera HD-map raster + optional BEV/debug"] - CACHE["autoregressive world-model cache"] - FRAMES["generated frame chunk"] - HUD["HUD / marker / minimap composition"] - - EVENTS --> COMMANDS --> EGO --> WORLD - TRAFFIC <--> WORLD - WORLD --> TRAJ - TRAJ --> FARES - FARES --> PEOPLE - PEOPLE --> TRAJ - TRAJ --> RASTER - RASTER --> CACHE --> FRAMES - FARES --> HUD - TRAJ --> HUD - FRAMES --> HUD -``` - -`EgoVehicleKinematics.pose_chunk` is authoritative for motion. It advances the -taxi and PhysX once per requested output frame and returns synchronized poses, -timestamps, dynamic actors, collision information, and optional debug -geometry. `TaxiGameController` advances once per trajectory frame so fare and -HUD snapshots align with generated frames. Visible waiting passengers are -then added to the same trajectory used for model conditioning. - -`MapTrafficController` owns the gameplay state of authored traffic. It selects -nearby traffic using the map vicinity, drives active PhysX actors along their -tracks, handles collision and recovery phases, and exposes their trajectories -to the rasterizer. - -## Ownership and reset behavior - -| Lifetime | As-built owner | State | -| --- | --- | --- | -| Process | `InteractiveDriveApp` / render backend | loaded world model, custom worker thread, scene cache, presenter | -| Selected scene | app + backend | `SceneBundle`, rasterizer geometry, prompt/image conditioning, ground snapper | -| Rollout | app main thread + backend worker | simulation, physics world, game controller, world-model cache | -| Presentation | presenter on main thread | input state, HUD state, view/scene selection, last frame | - -A manual reset recreates simulation and game state and queues a backend cache -reset while retaining the loaded model and selected scene. A scene switch also -rebinds raster geometry and resets scene-specific conditioning. - -## Architectural liabilities relevant to the rewrite - -These are structural observations, not judgments about individual features: - -- The engine duplicates runtime responsibilities now supplied by FlashDreams - V2: worker lifecycle, event fan-out, pacing, reset generations, result - buffering, frame dropping, and presentation. -- More than two long-lived runtime threads can exist. -- Mutable keyboard/presenter state is the implicit bridge between game logic, - input, and presentation. -- `PresentedFrame` combines model output, conditioning/debug images, gameplay - annotations, status text, and timing metadata. -- The world-model adapter reconstructs configuration and cache lifecycle around - an older session abstraction instead of using the current pipeline contract - directly. -- Native-window and MJPEG presenters duplicate substantial game-HUD behavior. - -Those liabilities define what the V2 target design must remove while retaining -the game behavior described above. diff --git a/docs/design/crazy_robotaxi_v2_architecture.md b/docs/design/crazy_robotaxi_v2_architecture.md deleted file mode 100644 index 740d95d1a..000000000 --- a/docs/design/crazy_robotaxi_v2_architecture.md +++ /dev/null @@ -1,381 +0,0 @@ -# Crazy Robotaxi target architecture for FlashDreams V2 - -This is the implementation guide for the ground-up rewrite. It was derived by -reconciling the current FlashDreams V2 contracts with the behavior recorded in -[`crazy_robotaxi_legacy_architecture.md`](crazy_robotaxi_legacy_architecture.md). -The map branch supplies behavioral details only; its runtime architecture is -not part of this design. - -## Design rules - -1. FlashDreams V2 owns the runtime loop, event fan-out, reset generation, - buffering, pacing, client window, and shutdown sequence. -2. There are only two runtime threads: the V2 model thread and the V2 UI - thread. No game loop, chunk worker, scene-loader worker, MJPEG server, or - input-device worker is created by the game engine. -3. The model thread exclusively owns mutable simulation, game, conditioning, - autoregressive-cache, and driving-input state. -4. The UI thread owns all HUD interaction state, name entry, and presentation. - Immutable HUD frame batches cross from model to UI through V2 - `invoke_async`; neither thread reads the other's mutable state. -5. The loaded OmniDreams pipeline is application-owned and reusable; every - session/rollout has its own cache and mutable resources. -6. Reset reconstructs all rollout state together. FlashDreams' generation - counter discards pre-reset results already in the presentation buffer. -7. Authored maps remain data. Map compilation and preview are CPU tooling, not - runtime scheduling infrastructure. - -## Process ownership - -```mermaid -flowchart TD - HOST["flashdreams-run-v2
ApplicationRunner + client window"] - - subgraph APP["CrazyRobotaxiApplication — process lifetime"] - ARGS["validated application configuration"] - PIPELINE["one loaded OmniDreams pipeline
weights + compiled networks"] - end - - subgraph SESSION["CrazyRobotaxiSession — one run"] - SCENE["immutable SceneDefinition
map, camera, seed, prompt"] - REG["register one model loop
register one Dear ImGui UI loop"] - end - - subgraph MODELTHREAD["FlashDreams V2 model thread"] - INPUT["DriverInput
timestamped transition timeline"] - ENGINE["GameEngine"] - CACHE["session-local OmniDreams cache"] - HUDSTATE["immutable TaxiHudFrame batches
+ CUDA ready event"] - end - - subgraph UITHREAD["FlashDreams V2 UI thread"] - BUFFER["PresentationManager
RGB + optional raw BEV frames"] - HUD["CrazyRobotaxiImGuiUILoop
dedicated CUDA stream
waypoints + GPU BEV panel cache"] - COMPOSE["app GPU BEV panel
+ Dear ImGui overlay"] - WINDOW["V2 client window
native / WebRTC / MP4"] - end - - HOST --> APP - ARGS --> SESSION - PIPELINE --> SESSION - SESSION --> MODELTHREAD - SCENE --> ENGINE - PIPELINE --> CACHE - INPUT --> ENGINE - ENGINE --> HUDSTATE - ENGINE -->|"RGB + optional raw BEV"| BUFFER - HUDSTATE -->|"invoke_async; wait on ready event"| HUD - HUD -->|"invoke_async game actions"| ENGINE - BUFFER --> HUD --> COMPOSE --> WINDOW -``` - -`CrazyRobotaxiApplication.session_desc()` declares the trained output layout, -resolution, and frame rate before model loading. `init()` parses only -application-owned options. `create_session()` validates the requested -description, lazily constructs the shared pipeline, prepares immutable scene -data, and returns an uninitialized session. - -The session registers a model loop and a `CrazyRobotaxiImGuiUILoop`. The model -loop emits generated RGB frames and, when configured, the synchronized raw -uint8 BEV frame. Because the BEV exists only inside a HUD window, session -creation caps its raster to the actual ImGui image extent while preserving an -authored aspect ratio; model HD-map conditioning retains full output -resolution. For each generated frame it publishes an immutable -`TaxiHudFrame` containing its game snapshot and camera pose to the UI loop with -`invoke_async`. Each batch also carries a CUDA event recorded after the model -outputs so the app-owned presentation stream waits for exactly that chunk, -rather than fencing Vulkan against later model work on the shared default -stream. The UI loop selects the metadata aligned with the currently presented -model frame and caches that frame's projected in-world waypoint geometry. It -submits rings, beacons, anchors, and labels to ImGui's background draw list, -beneath the HUD windows, without constructing a full-size waypoint tensor. It -uses an immediate Dear ImGui window to place and clip a GPU-composited BEV; -`ImGuiUILoop` composites that one UI layer over the world frame. UI callbacks -send validated game actions back to the model loop with `invoke_async`; they -never call game state directly. - -## Pre-presentation startup - -```mermaid -sequenceDiagram - participant UI as V2 UI thread - participant Model as V2 model thread - participant Rollout as WorldModelRollout - - UI->>UI: Render animated loading HUD - Model->>Rollout: Construct engine and cache - loop AR shapes 0..3 - Model-->>UI: invoke_async(warmup phase) - Model->>Rollout: Neutral hidden step - Rollout->>Rollout: Compile / autotune first-use shape - end - Model->>Rollout: reset engine + cache - Model-->>UI: invoke_async(starting game) - Model->>Rollout: First gameplay step at AR index 0 - Rollout-->>UI: First presentable RGB + metadata -``` - -The default four hidden blocks cover the initial, cache-filling, and first -steady-state shapes of the chunk2/window6 model. They run on the model thread -while the independently paced UI thread continues rendering loading progress. -Process-lifetime compiled kernels and autotune results remain available after -warmup. Resetting the rollout before gameplay discards warmup simulation, game -rules, conditioning, and autoregressive-cache state. CUDA graphs whose kernels -reference that cache are safely re-armed against the fresh gameplay cache; -they are never replayed against stale warmup storage. `--prewarm-blocks 0` -preserves a direct cold-start path for diagnosis and comparison. - -## Model-loop step - -```mermaid -flowchart LR - EVENTS["unread V2 input events"] - REDUCE["DriverInput.reduce"] - COUNT["pipeline.get_num_frames(step_index)"] - COMMANDS["per-frame DriverCommand sequence"] - SIM["SimulationWorld.pose_chunk"] - RULES["GameRules.advance_frames"] - SNAPSHOTS["GameFrame snapshots"] - ACTORS["traffic + passenger actor trajectories"] - CONDITION["ConditionRenderer.render"] - TENSOR["HD-map tensor
B,V,T,C,H,W"] - GENERATE["pipeline.generate"] - FINALIZE["pipeline.finalize"] - POST["optional session-local postprocess stream"] - BEV["raw BEV frames
small synchronized UI result"] - HUDFRAMES["immutable TaxiHudFrame batch
snapshot + camera pose"] - SEND["invoke_async(UI loop)"] - RESULTS["list[StepResult]
RGB + optional raw BEV"] - - EVENTS --> REDUCE - COUNT --> COMMANDS - REDUCE --> COMMANDS - COMMANDS --> SIM - SIM --> RULES - RULES --> SNAPSHOTS - SIM --> ACTORS - RULES --> ACTORS - ACTORS --> CONDITION - SIM --> CONDITION - CONDITION --> TENSOR --> GENERATE --> FINALIZE --> POST - CONDITION --> BEV - SNAPSHOTS --> HUDFRAMES - SIM --> HUDFRAMES - HUDFRAMES --> SEND - POST --> RESULTS - BEV --> RESULTS -``` - -The output-frame count is learned from the pipeline before simulation, so -simulation, conditioning, generated video, game snapshots, and immutable HUD -frames have one authoritative `T`. Input edges update retained held state and -their timestamps preserve transitions that arrive while a model step is in -flight. The next step maps those transitions onto its fixed-size command -sequence, coalescing sub-frame states and retaining the newest states if the -chunk cannot represent all of them. Steering and speed commands advance at the -simulation frame interval rather than once per model invocation. - -The direct model contract is: - -```text -cache = pipeline.initialize_cache(text, first_frame, view_names) -T = pipeline.get_num_frames(autoregressive_index) -engine_step = engine.step(commands[T]) -trajectory = engine_step.trajectory -hdmap = condition_renderer.render(trajectory) # [B,V,T,3,H,W] -video = pipeline.generate(autoregressive_index, cache, hdmap) -metrics = pipeline.finalize(autoregressive_index, cache) -invoke_async(ui_loop, publish_hud_frames(game_snapshots, poses, video)) -return [video StepResult, optional raw_bev StepResult] -``` - -The UI loop uses V2's `ImGuiUILoop` and `presented_model_frames()`. It projects -waypoint geometry only when the presented frame changes and submits the cached -primitives to ImGui's background draw list each UI tick. A transparent ImGui -map window owns the BEV's layout, clipping, title, and border. The app normalizes -and resizes the small renderer-native BEV on its presentation CUDA stream, caches -that panel for repeated UI ticks, and writes it into the returned video before -the base loop composites the ImGui overlay. The BEV never reaches a host image -or requires a FlashDreams image-upload extension. There is no full-frame -waypoint or BEV layer, retained SlangPy widget tree, intermediate app renderer, -local adapter, model session, chunk request, private command queue, private frame -queue, or legacy `PresentedFrame` aggregate. - -## Game-engine internals - -```mermaid -flowchart TD - MAP["ResolvedGameMap"] - SCENE["SceneDefinition"] - - subgraph ENGINE["GameEngine — model-thread owned"] - DRIVER["DriverInput"] - SIM["SimulationWorld"] - PHYSX["PhysicsWorld"] - TRAFFIC["TrafficSystem"] - GAME["GameRules protocol"] - COND["ConditionRenderer"] - OUTPUT["EngineStep
trajectory + game frames + condition frames"] - end - - MAP --> SCENE - SCENE --> SIM - SCENE --> PHYSX - SCENE --> TRAFFIC - SCENE --> COND - DRIVER --> SIM - SIM <--> PHYSX - TRAFFIC <--> PHYSX - SIM --> GAME - GAME -->|"game actors and immutable per-frame state"| COND - SIM --> COND - GAME --> OUTPUT - COND --> OUTPUT -``` - -The reusable engine defines narrow game-facing contracts: - -- `GameRules` owns mutable rules state, advances it from a trajectory, - contributes dynamic actors, and returns immutable per-frame snapshots. -- `VehicleDynamics` turns commands into proposed vehicle motion. -- `PhysicsWorld` resolves ego, static, and actor interactions and produces - synchronized actor trajectories. -- `ConditionRenderer` converts a scene and `EngineStep` into model-ready HD-map - frames plus optional renderer-native uint8 BEV data; the application - publishes that data as a second synchronized result for the UI loop. -- `GameEngine` sequences these components; it knows nothing about taxi fares, - leaderboards, application entry points, V2 windows, or model presets. - -The initial implementation may use concrete engine classes where no second -implementation exists, but dependencies still point from generic engine code -to game-injected protocols, never from the engine into `crazy_robotaxi`. - -## Crazy Robotaxi specialization - -```mermaid -flowchart LR - MAP["node-graph city map"] - NAV["TaxiNavigation
lanes + shortest routes"] - FARES["TaxiGameRules
pickup/dropoff, timers, score"] - TAXI["ArcadeTaxiDynamics"] - TAXIPHYS["TaxiPhysicsPolicy
curb response + chassis tuning"] - TRAFFIC["authored TrafficSystem"] - PASSENGERS["waiting-passenger actors"] - SCORES["HighScoreStore"] - FRAME["TaxiGameFrame"] - HUDSTATE["TaxiHudFrame
immutable presentation state"] - WAYPOINTS["ImGui background draw list
ring + beacon + anchor + label"] - IMGUI["CrazyRobotaxiImGuiUILoop
score, time, navigation, BEV,
name entry, leaderboard"] - - MAP --> NAV --> FARES - TAXI --> TAXIPHYS - TRAFFIC --> TAXIPHYS - FARES --> PASSENGERS - FARES <--> SCORES - FARES --> FRAME --> HUDSTATE --> IMGUI - HUDSTATE --> WAYPOINTS - PASSENGERS --> FRAME -``` - -Taxi-specific code supplies the generic engine with arcade vehicle dynamics, -curb/chassis physics policy, map-derived navigation/fare regions, game rules, -passenger actors, persistent scores, and UI-thread ImGui controls. It does not -subclass a game-engine application host or presenter. - -## State and lifetime table - -| Lifetime / owner | State | -| --- | --- | -| Application | resolved pipeline configuration; one loaded OmniDreams pipeline | -| Session, immutable | validated session description; compiled/loaded scene definition; game configuration | -| Model loop, per reset generation | driving-input reducer; simulation/PhysX/traffic; game rules; condition renderer; OmniDreams cache; last generated frame; AR index | -| UI loop, per reset generation | immediate ImGui state; immutable HUD-frame lookup; waypoint projection and BEV pixel caches; name-entry buffer; validation messages | -| Outside runtime | authored map YAML; derived map cache; high-score file | - -All simulation, condition-renderer, and world-model CUDA calls that mutate -per-rollout state occur on the model thread. The ImGui renderer creates and -uses its presentation resources only on the UI thread. Immutable scene arrays -may be prepared before thread startup, but the model-thread state factory -performs mutable condition-renderer, PhysX, game, and cache construction -lazily on its first `step`. - -## Reset, terminal state, and close - -```mermaid -stateDiagram-v2 - [*] --> Playing - Playing --> Playing: model step - Playing --> AwaitingName: timer ends and score qualifies - Playing --> Leaderboard: timer ends without qualification - AwaitingName --> Leaderboard: name submitted - Leaderboard --> Leaderboard: terminal frame remains visible - Playing --> Playing: V2 reset generation rebuilds rollout - AwaitingName --> Playing: V2 reset generation rebuilds rollout - Leaderboard --> Playing: R or V2 reset rebuilds rollout -``` - -While awaiting a name, no new world-model block is generated. The model loop -re-emits the last generated RGB frame and publishes an updated immutable HUD -frame; the UI loop replaces its cached waypoint projections from that metadata. -ImGui owns the editable name buffer and submits a validated name to the model -loop with `invoke_async`. A leaderboard does not finish the model loop: it keeps -the last generated frame visible so R can rebuild the rollout without closing -the client window. Only an explicit finite block limit or a client close ends -the run. - -On reset, the model loop closes and recreates the simulation, traffic, rules, -condition renderer state, and autoregressive cache as one unit. On close, it -releases all session-local resources; application close releases the shared -pipeline. - -## Legacy-to-V2 reconciliation - -| Map-branch responsibility | V2 owner / replacement | -| --- | --- | -| `InteractiveDriveApp` lifecycle | `IApplication` + `ISession` | -| custom `run_main_loop` | `run_session` supplied by FlashDreams V2 | -| `ChunkPipeline` worker | V2 model loop | -| private command/frame queues | direct step call + `PresentationManager` | -| generation token on reset | V2 `EventBuffer` generation | -| presenter event polling | V2 client window and shared event buffer | -| presenter pacing and frame replay/drop | V2 UI loop + presentation mode | -| `FlashdreamsWorldModelSession` | direct OmniDreams pipeline cache API | -| `PresentedFrame` metadata bundle | typed engine results plus immutable `TaxiHudFrame` messages | -| keyboard telemetry shared with presenter | UI-owned ImGui input plus `invoke_async` game actions | -| native/MJPEG presenters | V2 client-window modes | -| application-internal scene switching | map/variant application arguments; a different scene starts a new session | -| manual reset queueing | V2 reset event and loop `reset()` | - -## Implemented package boundary - -```text -apps/omnidreams_game_engine/ - omnidreams_game_engine/ - engine.py # GameEngine and EngineStep - contracts.py # injected game/dynamics/physics contracts - model.py # direct OmniDreams rollout bridge - scene.py # immutable runtime scene loading - input.py # V2 event reduction to per-frame commands - conditioning.py # Ludus conditioning boundary - simulation/ # generic kinematics, PhysX, traffic - game_map/ # authored-map schema/compiler/runtime types - -apps/crazy_robotaxi/ - crazy_robotaxi/ - application.py # IApplication composition root - session.py # ISession + model/UI loop wiring - ui.py # Dear ImGui HUD state and UI loop - world_overlay.py # ImGui world-marker projection and draw geometry - rules.py # taxi fare state machine - dynamics.py # arcade taxi controls - physics.py # taxi collision policy - navigation.py # map-derived routes/fare sampling - passengers.py # conditioning actors - high_scores.py # persistent leaderboard - config.py # strict game configuration - map_tool.py # offline validate/compile/preview command -``` - -Runtime responsibilities must remain in the blocks shown here. In particular, -the implementation must not introduce a second application host, custom -runtime loop, worker queue, or presenter abstraction. diff --git a/docs/design/crazy_robotaxi_v2_input_latency.md b/docs/design/crazy_robotaxi_v2_input_latency.md deleted file mode 100644 index b94ca761c..000000000 --- a/docs/design/crazy_robotaxi_v2_input_latency.md +++ /dev/null @@ -1,106 +0,0 @@ -# Crazy Robotaxi V2 input-latency handoff - -## Status - -Crazy Robotaxi follows the V2 Interactive Drive demo's latest-state input -contract and offers opt-in input/chunk lifecycle diagnostics. Keyboard, -gamepad, and wheel events accumulated during an in-flight model step are -applied in order. A completed tap that would otherwise collapse to its final -state behind the model clock is bounded to one physics frame; a release from a -command the model already sampled still takes effect immediately. Input cannot -change a chunk whose inference is already in flight, and the lifecycle trace -described below distinguishes that boundary from stale queued presentation and -cache state that has advanced past the displayed frame. - -## Reproduction and evidence - -Run the app at its trained 1280×704, 30 fps output contract: - -```bash -uv run flashdreams-run-v2 crazy-robotaxi --mode webrtc \ - --stats-path /tmp/crazy-robotaxi-latency.json -- \ - --model-preset perf \ - --profile-input-latency /tmp/crazy-robotaxi-input-trace.log -``` - -`--profile-input-latency [TRACE_PATH]` writes grep-friendly lifecycle records -to `TRACE_PATH`, replacing that file at the start of a session and flushing -each record as it is written. Omitting the optional path uses -`/tmp/crazy-robotaxi-input-trace.log`. This trace is separate from the -model-metrics JSON selected by `--stats-path`. Records use the prefixes -`[crazy-robotaxi-chunk-trace]` and `[runtime-v2-chunk-trace]`; correlate them by -`generation`, `step`, `epoch`, `ar`, and `frame`: - -- `input_received` and `input_sampled` show the UI event and the exact command - assigned to each simulation frame. -- `engine_step_started`, `engine_step_returned`, `generate_started`, - `generate_returned`, `cache_finalize_returned`, and - `rollout_step_returned` show model-thread host control flow. -- `publish_started`, `publish_completed`, `chunk_dropped`, and - `frame_presented` show bounded runtime queue behavior. -- `app_frame_presented` reports how many completed model steps, AR chunks, and - milliseconds of simulation the cache-committed state is ahead of the frame - selected for display. -- `rollout_reset` increments `epoch`, preventing a post-reset frame from being - compared with pre-reset mutable state. - -All `time_ns` values are from the CPU monotonic clock. A `*_returned` event -means the host call returned; it deliberately does not synchronize CUDA or -claim that asynchronous GPU work completed at that instant. - -The earlier investigation observed: - -- A held steering key took more than 1.5 seconds to affect displayed model - video across model presets. -- A superseded timestamped reducer preserved 100–200 ms taps by replaying their - historical states into a future model chunk. That made stale input visible - after the user had already released or changed it and diverged from the - ported Interactive Drive implementation. -- `--presentation-mode on_demand` did not materially improve the held - steering delay, so repeated UI frames were not its dominant cause. -- A supplied synchronized diagnostic run produced eight-frame chunks covering - 266.7 ms of video in 733.6–788.7 ms after warmup, averaging 760.1 ms. Pipeline - profiling perturbs CPU behavior, so these numbers describe that captured - stack rather than a universal model benchmark. - -V2 reads model events immediately before calling the synchronous model-loop -`step`. Crazy Robotaxi applies the unread batch in order, retains the final -keyboard or controller state, and preserves an otherwise invisible completed -tap for one physics frame. An input arriving during the measured 760 ms step -therefore waits for that step to finish and for the next conditioned step to -finish. This creates an application/model response window of roughly 760–1520 -ms before final WebRTC delivery. Smaller or preemptible model work is required -to reduce that floor. - -## V2 transport behavior - -The V2 WebRTC client window now materializes UI frames at `window.write()`, -uses a bounded two-frame sender queue, and replaces the oldest unsent frame -during congestion. CUDA events order model output, UI composition, and WebRTC -transfer work without device-wide synchronization. Continuous presentation -can redraw input-responsive UI while model inference is still running, but it -cannot alter the already-conditioned model chunk. - -## Remaining API decision - -Specify the input contract for long synchronous model steps. Sub-generation -response requires smaller chunks, cancellation/preemption, or an explicit -asynchronous conditioning mechanism; event polling alone cannot modify an -in-flight conditioned chunk. - -## Runtime acceptance tests - -- Exercise real 1280×704 content at 30 fps, not only the existing 16×16 smoke - test. -- Simulate a slow encoder or consumer and prove the interactive queue remains - bounded, stale work is flushed according to policy, and frame age recovers - after a stall. -- Record browser-key-to-runtime-event and UI-write-to-browser-display latency; - target p95 transport overhead below 100 ms on a local loopback after warmup. -- Verify keyboard, standard-gamepad, and game-wheel state all condition the - complete next chunk, and controller disconnect falls back to retained - keyboard state. -- Report best, average, p90, and worst latency together with encoder backend, - resolution, host, GPU, driver, and warmup policy. -- Verify reset and disconnect discard stale media without replaying frames from - the previous generation. diff --git a/docs/design/crazy_robotaxi_v2_performance.md b/docs/design/crazy_robotaxi_v2_performance.md deleted file mode 100644 index 6f08825a0..000000000 --- a/docs/design/crazy_robotaxi_v2_performance.md +++ /dev/null @@ -1,191 +0,0 @@ -# Crazy Robotaxi V2 performance investigation - -This note separates measured facts from hypotheses for the V2 port. Raw JSON -and `/usr/bin/time` captures are local benchmark artifacts and are intentionally -not source files. - -## Evidence from the recopied captures - -The `crazy-native` and `crazy-webrtc` commands used the `perf` preset at -1280x704, seed 42, 32 visible blocks, and four hidden prewarm blocks. The -statistics below exclude steps 0-3 and report median / p90 over steps 4-31. - -| Stage | Native window | WebRTC | -| --- | ---: | ---: | -| Pipeline wall | 436.0 / 439.9 ms | 430.8 / 436.8 ms | -| Engine wall | 54.9 / 59.3 ms | 53.0 / 59.3 ms | -| Rollout wall | 491.1 / 498.7 ms | 484.2 / 511.1 ms | -| Model-thread CPU | 485.5 / 491.5 ms | 477.2 / 486.3 ms | -| Effective 8-frame throughput from median rollout | 16.3 fps | 16.5 fps | - -The model produces 8-frame steady-state chunks representing 266.7 ms at the -declared 30 fps. These captures therefore prove a throughput miss, independent -of presentation: a 484-491 ms producer cannot continuously supply 30 fps. The -native and WebRTC generation numbers are close, so WebRTC is not causing the -model-thread chunk gap in this comparison. - -The process-wide `/usr/bin/time` captures averaged 136% CPU for native window -and 156% for WebRTC. The model metrics explain about one saturated core: -pipeline CPU time is approximately equal to pipeline wall time (about 430 ms -per chunk) even with synchronized stage profiling disabled. That timing -boundary surrounds only `pipeline.generate` and `pipeline.finalize`, so the -CPU use is not attributable to taxi rules, PhysX, or HUD code. The standalone -artifact does not record thread CPU and still needs a native stack sample to -confirm the same wait there. WebRTC adds roughly one third of a CPU core in -this pair of runs, consistent with presentation and video encoding work. - -The synchronized `crazy-pipeline-perf` and `omnidreams-pipeline-perf` captures -provide the pipeline-alone versus pipeline-in-game comparison: - -| Synchronized stage | Pipeline alone | Inside Crazy Robotaxi | -| --- | ---: | ---: | -| Encode median | 60.8 ms | 47.2 ms | -| Diffuse median | 362.2 ms | 354.5 ms | -| Decode median | 17.9 ms | 17.9 ms | -| Finalize median | 180.4 ms | 181.2 ms | -| Pipeline total median | 620.7 ms | 601.0 ms | - -These independent runs do not prove a speedup inside the game, but they do -rule out a material pipeline regression caused by the Crazy Robotaxi wrapper. -The app adds a measured 52.8 ms median engine stage in the synchronized run. -In the lower-overhead native capture, the steady engine median is 54.9 ms: -about 40.3 ms simulation, 12.8 ms conditioning, and 1.6 ms rules. - -The matched `original-perf` captures use 1168x640 and the original demo's -native FP8 DiT settings. They cut median rollout latency to 274.8 ms in the -native window and 272.4 ms over WebRTC, equivalent to 29.1 and 29.4 generated -fps. Their p90 latencies are 277.2 and 279.7 ms. This is a large improvement, -but every steady chunk remains above the 266.7 ms real-time budget. These files -exercise the V2 port with the matched preset; they are not captures from the -non-V2 application. - -An older capture contains 127-147 ms CPU spikes from the superseded NumPy -waypoint rasterizer. Waypoints now use an ImGui background draw list, so that -specific hotspot no longer exists and must not be projected onto current runs. - -## App-side BEV correction - -The Dear ImGui BEV was rendered at 1024x1024, normalized to BF16 on the GPU, -converted back to uint8 on the UI thread, copied to the CPU, expanded to RGBA, -and uploaded to a SlangPy texture. Its default on-screen image is only 234x234 -at 1280x704. - -The corrected path caps the BEV renderer to the actual image extent while -preserving authored aspect ratio and keeps the renderer's native uint8 pixels -through `ConditionBatch`. This reduces default BEV pixels and upload bytes by -19.15x. A CPU sanity probe measured 0.727 ms for the old 1024x1024 BF16 -materialization versus 0.061 ms for the new 234x234 uint8 layout conversion. -Those CPU numbers validate the direction only; GPU renderer, synchronization, -and UI timings still require a target-machine run. - -The app removes the remaining D2H/H2D round trip without changing the V2 API. -ImGui owns a transparent map window and reserves its content rectangle; the UI -loop normalizes and resizes the renderer-native BEV on its presentation CUDA -stream, caches the panel across repeated ticks, and composites that small region -into the returned video. No Vulkan texture ownership escapes the ImGui renderer. - -### CUDA-upload follow-up - -The `*-3.json` native-window capture from the superseded `ece9c52` experiment -confirmed that removing the BEV host round trip eliminates its original -synchronization hotspot. BEV submission fell from -45.68 seconds total and 195.7 ms p90 to 0.32 seconds total and 0.31 ms p90. -UI-step p90 fell from 37.2 ms to 4.2 ms, presented throughput increased from -17.25 to 22.62 fps, and elapsed time after the first publication fell from -104.58 to 79.76 seconds. - -The capture also exposes a second synchronization boundary. Native surface -acquisition consumed 22.15 seconds, with 222 of its 227 waits above 10 ms -occurring on a model-frame advance. Those waits cluster near 90--110 ms, once -per generated chunk. V2 now records readiness on each `StepResult`; the -session's `PresentationManager` owns the dedicated presentation stream and -orders UI conversion, ImGui interop, composition, and native upload behind the -selected chunk without waiting for subsequently queued model work. This -follow-up remains pending a target-machine `*-4.json` validation and is not yet -a validated performance result. - -## App-side PhysX bridge correction - -The matched native `original-perf` capture attributes 39.2 ms per chunk to -simulation. PhysX accounts for 36.0 ms: 30.8 ms in the Python/native bridge, -4.0 ms in the solver, and less than 0.1 ms in actor update plus readback. -PhysX already owns a two-worker CPU dispatcher, so adding application worker -threads cannot materially reduce this path. The eight simulated frames are -also state-dependent and cannot run concurrently. - -The dominant bridge path tested every active barrier through a Python loop on -every frame. The replacement caches contiguous barrier arrays whenever the -physics vicinity changes, evaluates the broad phase as one NumPy operation, -and applies collision responses sequentially only to the candidate contacts. -Candidate application retains authored barrier order, including corner -response behavior. A deterministic CPU parity sweep covers degenerate, -single-contact, and multi-contact geometry. - -An isolated CPU probe with 293 barriers and eight frames measured 21.1 ms for -the scalar scan and 0.51 ms for the vectorized scan, a 41.5x speedup for that -component. This is not a target-GPU headline result. Runtime metrics now split -the bridge into traffic preparation, barrier rebound, traffic update, state -materialization, and residual adapter time so a fresh run can validate where -the saved time lands. - -## Backpressure and visible latency - -The reported `block` versus `drop_oldest` behavior is consistent with a UI -consumer falling behind the producer. `block` preserves every generated frame, -but V2 may retain the currently presented chunk plus two queued chunks. For -8-frame chunks at 30 fps, that permits roughly 800 ms of model-frame history -before transport latency. `drop_oldest` bounds staleness by evicting complete -chunks, which necessarily creates visible motion jumps. - -The app-side BEV correction targets a concrete reason the UI consumer may miss -its cadence. If a matched rerun still fills the queue, the remaining fix is an -API policy rather than a game rule: V2 needs a configurable latency bound (or -frame-based queue capacity) that can keep at most the next ordered chunk -without dropping it. The earlier pre-generation capacity wait did not change -the two-chunk capacity and therefore could not establish that bound. - -## Required post-optimization rerun - -The `fast-perf` preset derives from OmniDreams' supported performance config, -including its native DiT acceleration, and adds the native FP8 VAE path. Use -fresh processes and keep ordered presentation: - -```bash -/usr/bin/time -v -o /tmp/crazy-original-native.time \ - uv run --no-sync flashdreams-run-v2 crazy-robotaxi \ - --mode native-window \ - --pixel-width 1168 \ - --pixel-height 640 \ - --backpressure-mode block \ - --presentation-mode continuous \ - --stats-path /tmp/crazy-vectorized-native.json \ - -- \ - --model-preset fast-perf \ - --seed 42 \ - --total-blocks 32 \ - --prewarm-blocks 4 -``` - -Repeat with `--mode webrtc` and different artifact names. Do not enable -`--profile-pipeline` for headline throughput because its CUDA synchronization -changes the result. Run a separate diagnostic capture with that flag only when -stage attribution is needed. - -Record GPU, driver, CUDA, PyTorch, cuDNN, checkpoint revisions, and compiler -cache state alongside the artifacts. Exclude at least steps 0-3, then report -median and p90. The remaining questions are: - -1. Does `physx_barrier_rebound_s` fall near the isolated probe, and does total - rollout latency move below the 266.7 ms chunk budget? -2. Does `fast-perf` reach the original demo's measured producer - throughput on the same machine? -3. After the BEV correction, how much UI-thread CPU remains in native and - WebRTC modes? -4. If the pipeline still consumes nearly one full CPU core, which native or - CUDA wait accounts for it? That requires a native stack sample or CPU - profiler and belongs to the OmniDreams integration/API team rather than the - game loop. - -Current validation status: **Useful app-side optimization, pending target-GPU -measurement**. Physics response parity passes on CPU; the unvalidated part is -the magnitude of the full runtime gain. diff --git a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py index cfbdb2809..cc41ed085 100644 --- a/flashdreams/flashdreams/runtime_v2/native_window_client_window.py +++ b/flashdreams/flashdreams/runtime_v2/native_window_client_window.py @@ -119,9 +119,9 @@ def __init__( self._close_event_enqueued = False self._presenter: _SlangPyNativeWindowPresenter | None = None self._poll_input_events: list[UserInputEvent] | None = None - self._pending_printable_keys: deque[ - tuple[str, KeyboardUserInputEvent] - ] = deque() + self._pending_printable_keys: deque[tuple[str, KeyboardUserInputEvent]] = ( + deque() + ) self._pressed_key_values: dict[str, str] = {} def open(self, session_desc: SessionDesc) -> None: diff --git a/flashdreams/test_v2/test_imgui_ui_renderer.py b/flashdreams/test_v2/test_imgui_ui_renderer.py index 95efd678a..c3a309def 100644 --- a/flashdreams/test_v2/test_imgui_ui_renderer.py +++ b/flashdreams/test_v2/test_imgui_ui_renderer.py @@ -13,12 +13,12 @@ import torch from numpy import uint64 +from flashdreams.runtime_v2.imgui_ui_loop import ImGuiUILoop from flashdreams.runtime_v2.imgui_ui_renderer import ( _ImGui, _rgba_pixels, _route_imgui_input_events, ) -from flashdreams.runtime_v2.imgui_ui_loop import ImGuiUILoop from flashdreams.runtime_v2.presentation_manager import PresentationManager from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( @@ -174,8 +174,9 @@ def test_imgui_loop_composites_over_the_presented_model_frame() -> None: result = loop.step(0, UserInputEvents([])) - assert result.output.shape == (1, 3, 3, 4) - assert torch.all(result.output == -0.5) + output = result.read_output() + assert output.shape == (1, 3, 3, 4) + assert torch.all(output == -0.5) loop.reset() loop.close() assert renderer.reset_count == 1 diff --git a/flashdreams/test_v2/test_native_window_client_window.py b/flashdreams/test_v2/test_native_window_client_window.py index e70c941c0..f5d390ebb 100644 --- a/flashdreams/test_v2/test_native_window_client_window.py +++ b/flashdreams/test_v2/test_native_window_client_window.py @@ -7,7 +7,7 @@ import queue import threading -from collections.abc import Callable +from collections.abc import Callable, Sequence from types import SimpleNamespace from typing import TYPE_CHECKING, Any, cast @@ -55,6 +55,14 @@ def _result(value: int = 0) -> StepResult: ) +def _keyboard_edges(events: Sequence[object]) -> list[tuple[str, KeyboardInputState]]: + return [ + (event.key, event.state) + for event in events + if isinstance(event, KeyboardUserInputEvent) + ] + + class _KeyboardEvent: def __init__(self, key: str, *, pressed: bool) -> None: self.key = SimpleNamespace(name=key) @@ -492,7 +500,7 @@ def test_native_text_input_discards_repeat_callbacks_after_release() -> None: assert window.get_user_input_events().get_events() == [] window.close() - assert [(event.key, event.state) for event in (*pressed, *released)] == [ + assert _keyboard_edges([*pressed, *released]) == [ ("d", KeyboardInputState.PRESSED), ("d", KeyboardInputState.RELEASED), ] @@ -513,7 +521,7 @@ def test_native_text_input_coalesces_across_event_polls(text: str) -> None: released = window.get_user_input_events().get_events() window.close() - assert [(event.key, event.state) for event in (*pressed, *released)] == [ + assert _keyboard_edges([*pressed, *released]) == [ (text, KeyboardInputState.PRESSED), (text, KeyboardInputState.RELEASED), ] @@ -531,7 +539,7 @@ def test_native_printable_key_without_text_is_flushed_after_one_poll() -> None: released = window.get_user_input_events().get_events() window.close() - assert [(event.key, event.state) for event in (*pressed, *released)] == [ + assert _keyboard_edges([*pressed, *released]) == [ ("w", KeyboardInputState.PRESSED), ("w", KeyboardInputState.RELEASED), ] @@ -547,7 +555,7 @@ def test_native_printable_release_flushes_pending_press() -> None: events = window.get_user_input_events().get_events() window.close() - assert [(event.key, event.state) for event in events] == [ + assert _keyboard_edges(events) == [ ("z", KeyboardInputState.PRESSED), ("z", KeyboardInputState.RELEASED), ] diff --git a/flashdreams/test_v2/test_session_runner.py b/flashdreams/test_v2/test_session_runner.py index 8486f194f..5c029421e 100644 --- a/flashdreams/test_v2/test_session_runner.py +++ b/flashdreams/test_v2/test_session_runner.py @@ -7,6 +7,8 @@ import queue import threading import time +from collections.abc import Iterator +from contextlib import contextmanager import pytest import torch @@ -26,11 +28,7 @@ PresentationMode, SessionDesc, ) -from flashdreams.runtime_v2.session_runner import ( - _PresentationClock, - _next_ui_tick_at, - run_session, -) +from flashdreams.runtime_v2.session_runner import _PresentationClock, run_session from flashdreams.runtime_v2.step_result import StepResult from flashdreams.runtime_v2.user_input_event import ( CloseUserInputEvent, @@ -123,26 +121,6 @@ def test_presentation_clock_clamps_model_fps_to_ui_fps() -> None: assert clock.frames_per_second == 60 -def test_presentation_clock_does_not_adapt_to_blocked_model_throughput() -> None: - clock = _PresentationClock( - frames_per_second=30, - adapt_to_model_throughput=False, - ) - - clock.observe_model_output(now=1.0, generation=0, frame_count=8) - clock.observe_model_output(now=2.0, generation=0, frame_count=8) - - assert clock.frames_per_second == 30 - - -def test_next_ui_tick_does_not_sleep_after_an_overrun() -> None: - assert _next_ui_tick_at(1.0, 0.1, 1.25) == 1.25 - - -def test_next_ui_tick_preserves_deadline_without_an_overrun() -> None: - assert _next_ui_tick_at(1.0, 0.1, 1.05) == 1.1 - - def test_presentation_clock_limits_estimate_to_recent_two_seconds() -> None: clock = _PresentationClock(frames_per_second=30) @@ -838,22 +816,22 @@ def test_window_write_stays_in_the_presentation_context() -> None: session = FakeSession(_session_desc(), log) class ContextRecordingManager(PresentationManager): - active = False + active_depth = 0 @contextmanager def presentation_context(self) -> Iterator[None]: - self.active = True + self.active_depth += 1 try: yield finally: - self.active = False + self.active_depth -= 1 manager = ContextRecordingManager() session.__dict__["_presentation_manager"] = manager class ContextCheckingWindow(RecordingClientWindow): def write(self, result: StepResult) -> None: - assert manager.active + assert manager.active_depth > 0 super().write(result) run_session(session, ContextCheckingWindow(log), steps=1) From 26c3c8d7564187b7c304b2111f864f3aee01d795 Mon Sep 17 00:00:00 2001 From: aidanfnv Date: Fri, 28 Aug 2026 17:38:47 -0700 Subject: [PATCH 59/62] Update flashdreams/flashdreams/runtime_v2/README.md Co-authored-by: ArielG-NV <159081215+ArielG-NV@users.noreply.github.com> Signed-off-by: aidanfnv --- flashdreams/flashdreams/runtime_v2/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/flashdreams/flashdreams/runtime_v2/README.md b/flashdreams/flashdreams/runtime_v2/README.md index f5aa0839e..599ceb737 100644 --- a/flashdreams/flashdreams/runtime_v2/README.md +++ b/flashdreams/flashdreams/runtime_v2/README.md @@ -207,11 +207,10 @@ enforces matching dimensions and device instead of silently repairing them. The default UI loop, `BlitModelOutputToScreenLoop`, composites every model channel in list order as if they were image layers and reshapes the result into -the session's layout. `SlangPyUILoop` is the alternative for SlangPy's retained -widget subset. `ImGuiUILoop` exposes the complete immediate Dear ImGui API and -adds `imgui.image(key, pixels, size=(width, height))` for app-owned RGB/RGBA -images. Both return a `[1, C, H, W]` frame, so a session using either declares -a `tchw` layout. +the session's layout. + +`SlangPyUILoop` is the alternative for SlangPy's retained +widget subset. `ImGuiUILoop` exposes the complete ImGui API. Both return a `[1, C, H, W]` frame, so an `ISession` using either should declare a `tchw` output layout. `IClientWindow` is both an `InputSource` and an `OutputSink`, so a window is written to with the same three calls as any sink: `open` with the session From 6cf6af7a921a8f87f098d427e14ff001257178da Mon Sep 17 00:00:00 2001 From: aidanfnv Date: Fri, 28 Aug 2026 17:38:56 -0700 Subject: [PATCH 60/62] Update flashdreams/flashdreams/api_v2/README.md Co-authored-by: ArielG-NV <159081215+ArielG-NV@users.noreply.github.com> Signed-off-by: aidanfnv --- flashdreams/flashdreams/api_v2/README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/flashdreams/flashdreams/api_v2/README.md b/flashdreams/flashdreams/api_v2/README.md index dd0704b4c..44255d12c 100644 --- a/flashdreams/flashdreams/api_v2/README.md +++ b/flashdreams/flashdreams/api_v2/README.md @@ -64,11 +64,7 @@ comes from the session description: the model loop steps at `frames_per_second_for_step`, and the UI ticks at `frames_per_second_for_ui`. The UI thread initially selects frames from model chunks at -`frames_per_second_for_step`. With nonblocking `DROP_OLDEST` backpressure it -then uses the model thread's rolling two-second output rate. `BLOCK` retains the -declared rate because its own queue waits would otherwise feed back into and -progressively reduce the measured model rate. This paces chunked output evenly -without tying input and UI redraws to model throughput. +`frames_per_second_for_step`. With nonblocking `BackpressureMode.DROP_OLDEST` backpressure, the oldest chunk not-finished being processed by the ui-thread will be discarded in favor of a new chunk returned by the model-thread if presentation-manager buffer is full. With `BackpressureMode.BLOCK` backpressure, instead of discarding a chunk the model-thread will wait for an open chunk-slot to store its result in the presentation-manager before progressing to its next `step`. The goal of this backpressure model is to allow independent computation and presentation of UI (for reactivity to user inputs) separate from the backend logic of the model-thread.``` `PresentationMode.CONTINUOUS` lets an `IUILoop` redraw every UI tick; `PresentationMode.ON_DEMAND` runs it only when the selected model frame changes. Interactive or clock-driven UIs should use continuous presentation. From bbd1aa2c608f28ae0f606121edccb904c4a6b302 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 17:42:24 -0700 Subject: [PATCH 61/62] Remove unrelated SlangPy conversion optimization Signed-off-by: Aidan Foster --- .../runtime_v2/slangpy_ui_renderer.py | 11 +++----- .../test_v2/test_slangpy_ui_renderer.py | 27 +------------------ 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py b/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py index bdfb57397..10603ad86 100644 --- a/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py +++ b/flashdreams/flashdreams/runtime_v2/slangpy_ui_renderer.py @@ -282,13 +282,10 @@ def _route_input_events( def _rgba8_to_compositing_frame(frame: Tensor) -> Tensor: """Convert shared ``[H, W, 4]`` bytes into normalized ``[4, H, W]``.""" - rgba = frame.permute(2, 0, 1).to( - torch.float32, - memory_format=torch.contiguous_format, - ) - rgba[:3].mul_(2.0 / 255.0).sub_(1.0) - rgba[3:4].mul_(1.0 / 255.0) - return rgba + rgba = frame.permute(2, 0, 1).to(torch.float32) + color = rgba[:3].mul_(2.0 / 255.0).sub_(1.0) + alpha = rgba[3:4].mul_(1.0 / 255.0) + return torch.cat((color, alpha), dim=0) def _resolve_slangpy_key(slangpy: Any, key: str) -> Any | None: diff --git a/flashdreams/test_v2/test_slangpy_ui_renderer.py b/flashdreams/test_v2/test_slangpy_ui_renderer.py index c929614ad..67e9bdc8c 100644 --- a/flashdreams/test_v2/test_slangpy_ui_renderer.py +++ b/flashdreams/test_v2/test_slangpy_ui_renderer.py @@ -7,13 +7,9 @@ from unittest.mock import Mock import pytest -import torch from numpy import uint64 -from flashdreams.runtime_v2.slangpy_ui_renderer import ( - _rgba8_to_compositing_frame, - _route_input_events, -) +from flashdreams.runtime_v2.slangpy_ui_renderer import _route_input_events from flashdreams.runtime_v2.user_input_event import ( KeyboardInputState, KeyboardUserInputEvent, @@ -24,27 +20,6 @@ pytestmark = pytest.mark.ci_cpu -def test_rgba_conversion_normalizes_directly_into_contiguous_chw() -> None: - frame = torch.tensor( - [[[0, 127, 255, 0], [255, 0, 127, 255]]], - dtype=torch.uint8, - ) - - converted = _rgba8_to_compositing_frame(frame) - - expected = torch.tensor( - [ - [[-1.0, 1.0]], - [[127.0 * 2.0 / 255.0 - 1.0, -1.0]], - [[1.0, 127.0 * 2.0 / 255.0 - 1.0]], - [[0.0, 1.0]], - ], - dtype=torch.float32, - ) - assert converted.is_contiguous() - torch.testing.assert_close(converted, expected) - - def test_space_key_routes_a_text_input_codepoint() -> None: ui_context = Mock() slangpy = SimpleNamespace( From 542329ca647461a29a1635aca4077a0f4616b262 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Fri, 28 Aug 2026 19:06:25 -0700 Subject: [PATCH 62/62] Prewarm Robotaxi before timed gameplay --- apps/crazy_robotaxi/crazy_robotaxi/session.py | 3 +++ apps/crazy_robotaxi/tests/test_application.py | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index c5335b604..a3849ee2e 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -180,6 +180,9 @@ def select_game(self, selection: GameSelection) -> None: ui_state.activate_scene(calibration) ), ) + # Selection messages run outside model-step timing. Finish one-time + # setup here so it cannot skew the first gameplay throughput sample. + self.ensure_rollout() def menu_result(self, step_index: int) -> list[StepResult]: """Return a cached black frame while the UI waits for a menu choice.""" diff --git a/apps/crazy_robotaxi/tests/test_application.py b/apps/crazy_robotaxi/tests/test_application.py index 7fd80970f..fc18e6185 100644 --- a/apps/crazy_robotaxi/tests/test_application.py +++ b/apps/crazy_robotaxi/tests/test_application.py @@ -176,7 +176,10 @@ def test_application_registers_model_and_imgui_ui_loops() -> None: assert model_loop.is_finished() -def test_complete_cli_game_selection_starts_without_menus() -> None: +def test_complete_cli_game_selection_starts_without_menus(monkeypatch) -> None: + monkeypatch.setattr( + "crazy_robotaxi.session.WorldModelRollout", lambda **_: SimpleNamespace() + ) app = _application( pipeline_factory=lambda config, device: object(), scene_factory=lambda request, raster: _scene(), @@ -185,6 +188,8 @@ def test_complete_cli_game_selection_starts_without_menus() -> None: [ "--device", "cpu", + "--prewarm-blocks", + "0", "--game-mode", "race", "--map", @@ -554,8 +559,11 @@ def test_fast_perf_combines_native_dit_and_native_vae_paths() -> None: @pytest.mark.parametrize("resolution_wh", [(1280, 704), (1168, 640)]) def test_adapter_dimensions_configure_renderer_geometry( - resolution_wh: tuple[int, int], + resolution_wh: tuple[int, int], monkeypatch ) -> None: + monkeypatch.setattr( + "crazy_robotaxi.session.WorldModelRollout", lambda **_: SimpleNamespace() + ) configured: list[object] = [] raster_sizes: list[tuple[int, int]] = [] @@ -574,7 +582,7 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: pipeline_factory=lambda config, device: configured.append(config) or object(), scene_factory=load_test_scene, ) - app.init(["--device", "cpu"]) + app.init(["--device", "cpu", "--prewarm-blocks", "0"]) desc = replace( app.session_desc(), video_width=resolution_wh[0], @@ -589,8 +597,6 @@ def load_test_scene(request: object, raster: RasterConfig) -> SceneDefinition: GameSelection(mode="taxi", map_option=session._map_options[0]) ) - assert configured == [] - model_loop.state.pipeline_factory() assert configured == [app._pipeline_config] assert raster_sizes == [resolution_wh] assert session._config.renderer.raster.resolution_wh == resolution_wh @@ -764,7 +770,8 @@ def close(self) -> None: GameSelection(mode="taxi", map_option=session._map_options[0]) ) - rollout = model_loop.state.ensure_rollout() + rollout = model_loop.state.rollout + assert rollout is not None ui_loop._run_message_batch() assert [index for index, _ in rollout.steps] == [0, 1, 2, 3]

yM1NfuP;Y2@=mhZeicASfJ`;3 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⪚8`;Br}H4{GzXyO}Jet7YzI_aYvcH|GmEn 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 RC3FQtXYFJ+Z;uf7Y}5x5O@^*KSfB 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