diff --git a/apps/crazy_robotaxi/README.md b/apps/crazy_robotaxi/README.md index 0617255dd..bbf5383eb 100644 --- a/apps/crazy_robotaxi/README.md +++ b/apps/crazy_robotaxi/README.md @@ -156,6 +156,6 @@ uv run --package crazy-robotaxi crazy-robotaxi-map preview-spawn \ path/to/city.robotaxi.yaml --spawn taxi_start --output taxi_start.png ``` -Spawn variants can define both a full `prompt` for normal play and a shorter +Each spawn can define both a full `prompt` for normal play and a shorter `prompt_context` base for `--live-edit-map-context`; dynamic road and motion clauses are appended only to the latter. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/application.py b/apps/crazy_robotaxi/crazy_robotaxi/application.py index 5b4d7279c..ae972b517 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/application.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/application.py @@ -264,8 +264,6 @@ def init(self, commandline_args: Sequence[str]) -> None: scene_request=SceneRequest( map_path=map_path.expanduser(), camera_name=engine_settings.map.camera, - variant=engine_settings.map.variant, - prompt=engine_settings.map.prompt, use_prompt_context=game_settings.live_edit.map_context.enabled, force_recompile=engine_settings.map.force_recompile, ), @@ -306,10 +304,7 @@ 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, - ) + self._map_options = _discover_game_maps(map_path) def _resolve_engine_settings(self, args: argparse.Namespace) -> EngineSettings: settings = EngineSettings( @@ -326,8 +321,6 @@ def _resolve_engine_settings(self, args: argparse.Namespace) -> EngineSettings: settings.map, path=args.map, camera=args.camera, - variant=args.variant, - prompt=args.prompt, force_recompile=args.force_map_recompile, ), rendering=replace( @@ -466,11 +459,7 @@ 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, ...]: +def _discover_game_maps(selected_path: Path) -> tuple[GameMapOption, ...]: """Read menu metadata for bundled maps and maps beside the CLI selection.""" selected = selected_path.expanduser().resolve() paths = {selected} @@ -483,19 +472,11 @@ def _discover_game_maps( 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, ) ) @@ -541,8 +522,6 @@ def _parser( 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("--device", default="cuda") parser.add_argument("--total-blocks", type=int) diff --git a/apps/crazy_robotaxi/crazy_robotaxi/game_selection.py b/apps/crazy_robotaxi/crazy_robotaxi/game_selection.py index a02aa7ad2..6ed823b57 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/game_selection.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/game_selection.py @@ -37,9 +37,6 @@ class GameMapOption: 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.""" 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 088a44890..173476ed4 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/boulevard_district.robotaxi.yaml @@ -418,12 +418,10 @@ spawns: 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. - prompt_context: >- - A forward-facing view from a taxi on a road through a city in daylight. + 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. + prompt_context: >- + A forward-facing view from a taxi on a road through a city in daylight. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml b/apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml index 38b381425..241d7b699 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml +++ b/apps/crazy_robotaxi/crazy_robotaxi/maps/flashdreams_raceway.robotaxi.yaml @@ -209,14 +209,12 @@ spawns: 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. - prompt_context: >- - A forward-facing view from a taxi on a wide road racing circuit - through a city in daylight. + 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. + prompt_context: >- + A forward-facing view from a taxi on a wide road racing circuit + through a city in daylight. diff --git a/apps/crazy_robotaxi/crazy_robotaxi/session.py b/apps/crazy_robotaxi/crazy_robotaxi/session.py index 6a80a2caa..c6100abd6 100644 --- a/apps/crazy_robotaxi/crazy_robotaxi/session.py +++ b/apps/crazy_robotaxi/crazy_robotaxi/session.py @@ -153,13 +153,6 @@ def select_game(self, selection: GameSelection) -> None: 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() diff --git a/apps/crazy_robotaxi/tests/maps/intersection_geometry.robotaxi.yaml b/apps/crazy_robotaxi/tests/maps/intersection_geometry.robotaxi.yaml index c241664ca..0e4280561 100644 --- a/apps/crazy_robotaxi/tests/maps/intersection_geometry.robotaxi.yaml +++ b/apps/crazy_robotaxi/tests/maps/intersection_geometry.robotaxi.yaml @@ -52,7 +52,5 @@ spawns: road: west_road lane: 2 distance_m: 30 - variants: - default: - image: package://omnidreams_game_engine/screenshot.jpg - prompt: A skewed three-way city intersection. + image: package://omnidreams_game_engine/screenshot.jpg + prompt: A skewed three-way city intersection. diff --git a/apps/crazy_robotaxi/tests/maps/parking_driveway.robotaxi.yaml b/apps/crazy_robotaxi/tests/maps/parking_driveway.robotaxi.yaml index 45d3c64d6..a7066b3a5 100644 --- a/apps/crazy_robotaxi/tests/maps/parking_driveway.robotaxi.yaml +++ b/apps/crazy_robotaxi/tests/maps/parking_driveway.robotaxi.yaml @@ -50,7 +50,5 @@ spawns: road: west_road lane: 1 distance_m: 30 - variants: - default: - image: package://omnidreams_game_engine/screenshot.jpg - prompt: A street beside a small parking lot. + image: package://omnidreams_game_engine/screenshot.jpg + prompt: A street beside a small parking lot. diff --git a/apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml b/apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml index 9ea1d5de1..56f25d5ef 100644 --- a/apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml +++ b/apps/crazy_robotaxi/tests/maps/race_course.robotaxi.yaml @@ -49,7 +49,6 @@ spawns: 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. + image: package://omnidreams_game_engine/screenshot.jpg + prompt: A car racing on a rectangular city loop. + prompt_context: A forward-facing view from a car on a city road. diff --git a/apps/crazy_robotaxi/tests/maps/traffic_loop.robotaxi.yaml b/apps/crazy_robotaxi/tests/maps/traffic_loop.robotaxi.yaml index 458b802e2..73190bbbc 100644 --- a/apps/crazy_robotaxi/tests/maps/traffic_loop.robotaxi.yaml +++ b/apps/crazy_robotaxi/tests/maps/traffic_loop.robotaxi.yaml @@ -42,7 +42,5 @@ spawns: road: south_west lane: 1 distance_m: 30 - variants: - default: - image: package://omnidreams_game_engine/screenshot.jpg - prompt: A car on a rectangular city loop. + image: package://omnidreams_game_engine/screenshot.jpg + prompt: A car on a rectangular city loop. diff --git a/apps/crazy_robotaxi/tests/test_maps.py b/apps/crazy_robotaxi/tests/test_maps.py index 23938eae5..73670ebde 100644 --- a/apps/crazy_robotaxi/tests/test_maps.py +++ b/apps/crazy_robotaxi/tests/test_maps.py @@ -4,20 +4,24 @@ """CPU validation for shipped semantic maps.""" import math +import zipfile from pathlib import Path import numpy as np import pytest import yaml +from omnidreams_game_engine.config import RasterConfig from omnidreams_game_engine.game_map import ( GameMapError, compile_game_map, load_game_map, + load_game_map_header, ) from omnidreams_game_engine.game_map.types import ( game_map_from_dict, game_map_to_dict, ) +from omnidreams_game_engine.scene import SceneRequest, load_scene pytestmark = pytest.mark.ci_cpu @@ -35,9 +39,8 @@ def test_shipped_map_is_valid(filename: str) -> None: assert game_map.map_id.startswith("crazy-robotaxi-") assert game_map.spawns assert game_map.lanes - variant = game_map.default_spawn.variants[0] - assert variant.prompt_context - assert variant.prompt_context != variant.prompt + assert game_map.default_spawn.prompt_context + assert game_map.default_spawn.prompt_context != game_map.default_spawn.prompt def test_prompt_context_is_trimmed_and_round_trips(tmp_path: Path) -> None: @@ -46,9 +49,7 @@ def test_prompt_context_is_trimmed_and_round_trips(tmp_path: Path) -> None: ) source["nodes"][0]["prompt_context"] = " A neighborhood landmark. " source["roads"][0]["prompt_context"] = " Detached homes line the road. " - source["spawns"][0]["variants"]["default"]["prompt_context"] = ( - " A forward-facing road view. " - ) + source["spawns"][0]["prompt_context"] = " A forward-facing road view. " path = tmp_path / "prompt-context.robotaxi.yaml" path.write_text(yaml.safe_dump(source, sort_keys=False)) @@ -59,11 +60,8 @@ def test_prompt_context_is_trimmed_and_round_trips(tmp_path: Path) -> None: assert original.topology.roads[0].prompt_context == ( "Detached homes line the road." ) - assert original.default_spawn.variants[0].prompt_context == ( - "A forward-facing road view." - ) - assert restored.default_spawn.variants == original.default_spawn.variants - assert restored.topology == original.topology + assert original.default_spawn.prompt_context == "A forward-facing road view." + assert game_map_to_dict(restored) == game_map_to_dict(original) @pytest.mark.parametrize("value", ["", " ", 42, ["not", "text"]]) @@ -86,7 +84,7 @@ def test_spawn_prompt_context_requires_nonempty_text( source = yaml.safe_load( (_MAP_FIXTURES / "intersection_geometry.robotaxi.yaml").read_text() ) - source["spawns"][0]["variants"]["default"]["prompt_context"] = value + source["spawns"][0]["prompt_context"] = value path = tmp_path / "invalid-spawn-prompt-context.robotaxi.yaml" path.write_text(yaml.safe_dump(source, sort_keys=False)) @@ -113,6 +111,32 @@ def test_prompt_context_change_invalidates_compiler_cache(tmp_path: Path) -> Non assert changed.archive_path != first.archive_path +def test_compiled_map_uses_canonical_spawn_conditioning( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = Path(__file__).parent / "maps" / "race_course.robotaxi.yaml" + monkeypatch.setenv("FLASHDREAMS_CACHE_DIR", str(tmp_path)) + game_map = load_game_map(path) + + scene = load_scene( + SceneRequest(map_path=path), + RasterConfig(width=64, height=32, compute_device="automatic"), + ) + context_scene = load_scene( + SceneRequest(map_path=path, use_prompt_context=True), + RasterConfig(width=64, height=32, compute_device="automatic"), + ) + + assert scene.prompt == game_map.default_spawn.prompt + assert context_scene.prompt == game_map.default_spawn.prompt_context + assert context_scene.scene_path != scene.scene_path + assert scene.initial_rgb.shape == (32, 64, 3) + with zipfile.ZipFile(scene.scene_path) as archive: + names = set(archive.namelist()) + assert {"prompt.txt", "first_image.png"} <= names + assert not any(name.startswith(("prompt_", "first_image_")) for name in names) + + def test_boulevard_traffic_turns_are_continuous_and_physically_limited() -> None: path = ( Path(__file__).parents[1] diff --git a/apps/crazy_robotaxi/tests/test_ui.py b/apps/crazy_robotaxi/tests/test_ui.py index 3525a18bf..a0ad455ce 100644 --- a/apps/crazy_robotaxi/tests/test_ui.py +++ b/apps/crazy_robotaxi/tests/test_ui.py @@ -903,7 +903,6 @@ def test_selection_menus_use_arcade_card_layout() -> None: 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,)) @@ -944,7 +943,6 @@ def test_startup_menu_selects_taxi_mode_then_map_through_v2_message() -> None: 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,)) @@ -978,7 +976,6 @@ def test_race_menu_selects_map_then_course() -> None: 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,)) @@ -1016,7 +1013,6 @@ def test_complete_cli_selection_skips_all_selection_screens() -> None: map_id="test-city", name="Test City", path=Path("test-city.robotaxi.yaml").resolve(), - variant="default", race_course_ids=("downtown-sprint",), ) state = TaxiHudState( @@ -1055,7 +1051,6 @@ def test_explicit_race_mode_and_map_skip_to_course_screen() -> None: map_id="test-city", name="Test City", path=Path("test-city.robotaxi.yaml").resolve(), - variant="default", race_course_ids=("downtown-sprint",), ) state = TaxiHudState( diff --git a/apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md b/apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md index 95b535e1b..2ac9e80c3 100644 --- a/apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md +++ b/apps/omnidreams_game_engine/NODE_GRAPH_MAP_FORMAT.md @@ -425,7 +425,7 @@ 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 +## Spawns and visual conditioning A spawn names an authored road lane and a distance along its directed centerline. Lane indices follow the effective `lanes` order. @@ -436,25 +436,22 @@ spawns: 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. - prompt_context: A forward-facing taxi view on a road at daylight. + image: seed.png + prompt: A forward-facing taxi view in a quiet neighborhood at daylight. + prompt_context: A forward-facing taxi view on a road 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. +Every spawn requires a non-empty `prompt`. `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. `prompt` is the complete standalone scene description. `prompt_context` is an optional shorter base prompt for applications that append live road, topology, and motion context. When that mode is selected, `prompt_context` is used if -present and otherwise falls back to `prompt`. An explicit runtime prompt -override remains authoritative in either mode. +present and otherwise falls back to `prompt`. Authored images may be map-relative paths or `package://package/resource` references. Resolved geometry, compiler and fallback-renderer code, seed 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 039af8bd5..5ee055ecf 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/engine_settings.py @@ -28,12 +28,6 @@ class MapLaunchSettings: 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.""" 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 a301fe7a1..66c1b1c7c 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 @@ -14,10 +14,7 @@ import numpy as np import yaml -from omnidreams_game_engine.game_map.types import ( - GameMapLinearAttributes, - GameMapVisualVariant, -) +from omnidreams_game_engine.game_map.types import GameMapLinearAttributes GAME_MAP_SUFFIX = ".robotaxi.yaml" """Filename suffix for authored node-graph game maps.""" @@ -62,7 +59,6 @@ class GameMapHeader: map_id: str name: str - variants: tuple[GameMapVisualVariant, ...] source_path: Path race_course_ids: tuple[str, ...] = () @@ -203,52 +199,31 @@ def _parse_map_identity(doc: dict[str, Any]) -> tuple[str, str]: return map_id, name -def _parse_variants( +def _parse_spawn_conditioning( 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", "prompt_context"} - 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") - prompt_context_value = variant.get("prompt_context") - if prompt_context_value is not None and ( - not isinstance(prompt_context_value, str) - or not prompt_context_value.strip() - ): - raise GameMapError( - f"Variant {name!r} prompt_context must be a nonempty string" - ) - prompt_context = ( - None if prompt_context_value is None else prompt_context_value.strip() - ) - 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, - prompt_context=prompt_context, - ) - ) - variants.sort(key=lambda item: (item.name != "default", item.name)) - return tuple(variants) +) -> tuple[str | None, str, str | None]: + image_value = raw_spawn.get("image") + image = None if image_value is None else str(image_value).strip() + prompt = str(raw_spawn.get("prompt", "")).strip() + if not prompt: + raise GameMapError("Every spawn requires a non-empty prompt") + if image_value is not None and not image: + raise GameMapError("spawn.image must not be empty") + prompt_context_value = raw_spawn.get("prompt_context") + if prompt_context_value is not None and ( + not isinstance(prompt_context_value, str) or not prompt_context_value.strip() + ): + raise GameMapError("spawn.prompt_context must be a nonempty string") + prompt_context = ( + None if prompt_context_value is None else prompt_context_value.strip() + ) + if image is not None: + resolve_seed_asset(source_path, image) + return image, prompt, prompt_context def load_game_map_header(path: Path) -> GameMapHeader: - """Load map name and default-spawn variants without resolving geometry.""" + """Load menu metadata and validate default-spawn conditioning.""" source_path = Path(path).expanduser().resolve() doc = _read_document(source_path) map_id, name = _parse_map_identity(doc) @@ -256,10 +231,10 @@ def load_game_map_header(path: Path) -> GameMapHeader: if not spawns: raise GameMapError("Map must define at least one spawn") first_spawn = _mapping(spawns[0], "spawns[0]") + _parse_spawn_conditioning(first_spawn, source_path) return GameMapHeader( map_id=map_id, 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 a3dffb8f9..8bdea2452 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 @@ -87,17 +87,14 @@ def _digest(game_map: ResolvedGameMap) -> str: 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()) + if spawn.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, spawn.image) + hasher.update(asset.read_bytes()) return hasher.hexdigest() @@ -412,9 +409,13 @@ def _trajectory(game_map: ResolvedGameMap) -> dict[str, object]: } -def _write_archive(path: Path, game_map: ResolvedGameMap) -> None: +def _write_archive( + path: Path, + game_map: ResolvedGameMap, + *, + use_prompt_context: bool, +) -> 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) @@ -428,26 +429,24 @@ def _write_archive(path: Path, game_map: ResolvedGameMap) -> None: "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), - ) + archive.writestr( + "prompt.txt", + spawn.prompt_context or spawn.prompt + if use_prompt_context + else spawn.prompt, + ) + if spawn.image is None: + _write_image_array( + archive, + "first_image.png", + spawn_render.render_spawn_first_frame(game_map, spawn), + ) + else: + _write_image( + archive, + "first_image.png", + resolve_seed_asset(game_map.source_path, spawn.image), + ) _write_parquet( archive, "clipgt/calibration_estimate.parquet", _calibration_row() ) @@ -467,6 +466,7 @@ def _write_archive(path: Path, game_map: ResolvedGameMap) -> None: def compile_game_map( path: Path, *, + use_prompt_context: bool = False, cache_root: Path | None = None, force: bool = False, ) -> CompiledGameMap: @@ -475,7 +475,8 @@ def compile_game_map( 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" + context_suffix = "-context" if use_prompt_context else "" + archive_path = output_dir / f"{game_map.map_id}{context_suffix}.usdz" lock = FileLock(str(root / f"{digest}.lock")) root.mkdir(parents=True, exist_ok=True) with lock: @@ -495,7 +496,11 @@ def compile_game_map( os.close(file_descriptor) temporary = Path(temporary_name) try: - _write_archive(temporary, game_map) + _write_archive( + temporary, + game_map, + use_prompt_context=use_prompt_context, + ) temporary.replace(archive_path) finally: temporary.unlink(missing_ok=True) 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 96ff23486..e35fb7404 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 @@ -30,7 +30,7 @@ _parse_compiler_settings, _parse_map_identity, _parse_profiles, - _parse_variants, + _parse_spawn_conditioning, _positive_float, _Profile, _read_document, @@ -2722,9 +2722,12 @@ def _spawn( source_path: Path, lane_by_id: dict[str, _LaneBuild], ) -> GameMapSpawn: - if set(raw) != {"id", "road", "lane", "distance_m", "variants"}: + required = {"id", "road", "lane", "distance_m", "prompt"} + unknown = set(raw) - (required | {"image", "prompt_context"}) + missing = required - set(raw) + if missing or unknown: raise GameMapError( - "Spawns require exactly id, road, lane, distance_m, and variants" + f"Spawns are missing {sorted(missing)} or have unknown fields {sorted(unknown)}" ) spawn_id = str(raw["id"]).strip() if not spawn_id: @@ -2751,13 +2754,16 @@ def _spawn( 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] + image, prompt, prompt_context = _parse_spawn_conditioning(raw, source_path) 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), + image=image, + prompt=prompt, + prompt_context=prompt_context, ) 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 633daa3fd..a88393911 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 @@ -173,23 +173,6 @@ class GameMapTopology: """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.""" - - prompt_context: str | None = None - """Optional shorter base prompt used with dynamic map context.""" - - @dataclass(frozen=True) class GameMapSpawn: """Vehicle spawn resolved onto a directed lane.""" @@ -209,8 +192,14 @@ class GameMapSpawn: yaw_rad: float """World heading following the directed lane.""" - variants: tuple[GameMapVisualVariant, ...] - """Available visual seed variants; ``default`` is always present.""" + image: str | None + """Optional map-relative or ``package://`` seed-image reference.""" + + prompt: str + """World-model text prompt paired with the spawn's seed image.""" + + prompt_context: str | None = None + """Optional shorter base prompt used with dynamic map context.""" @dataclass(frozen=True) @@ -469,12 +458,6 @@ 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.""" @@ -605,15 +588,9 @@ def game_map_to_dict(game_map: ResolvedGameMap) -> dict[str, Any]: "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, - "prompt_context": variant.prompt_context, - } - for variant in spawn.variants - ], + "image": spawn.image, + "prompt": spawn.prompt, + "prompt_context": spawn.prompt_context, } for spawn in game_map.spawns ], @@ -844,20 +821,12 @@ def game_map_from_dict(value: dict[str, Any]) -> ResolvedGameMap: 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"]), - prompt_context=( - None - if variant.get("prompt_context") is None - else str(variant["prompt_context"]) - ), - ) - for variant in raw["variants"] + image=None if raw.get("image") is None else str(raw["image"]), + prompt=str(raw["prompt"]), + prompt_context=( + None + if raw.get("prompt_context") is None + else str(raw["prompt_context"]) ), ) for raw in value["spawns"] diff --git a/apps/omnidreams_game_engine/omnidreams_game_engine/scene.py b/apps/omnidreams_game_engine/omnidreams_game_engine/scene.py index e6182b57c..6a86ca233 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/scene.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene.py @@ -20,26 +20,19 @@ class SceneRequest: map_path: Path camera_name: str = "camera_front_wide_120fov" - variant: str = "default" - prompt: str | None = None use_prompt_context: bool = False 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) - prompt_override = request.prompt - if prompt_override is None and request.use_prompt_context: - variants = compiled.game_map.default_spawn.variants - selected = next( - (item for item in variants if item.name == request.variant), variants[0] - ) - prompt_override = selected.prompt_context or selected.prompt + compiled = compile_game_map( + request.map_path, + use_prompt_context=request.use_prompt_context, + force=request.force_recompile, + ) return load_scene_bundle( scene_path=compiled.archive_path, camera_name=request.camera_name, - variant=request.variant, - prompt_override=prompt_override, raster=raster, ) 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 97ce274e7..fb1b7cfcc 100644 --- a/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py +++ b/apps/omnidreams_game_engine/omnidreams_game_engine/scene_loader.py @@ -7,20 +7,12 @@ 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 interactive_drive.scene_loader import ( - SCENE_FRAME_SUFFIXES, - SCENE_FRAMES_DIRNAME, - prompt_variant_for_scene_variant, - resolve_variant_archive, - variant_from_stem, -) from loguru import logger from PIL import Image @@ -58,12 +50,6 @@ _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)) @@ -83,198 +69,17 @@ def _points_from_records(points: list[dict[str, float]]) -> np.ndarray: ) -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: +def _load_initial_image(zf: zipfile.ZipFile, raster: RasterConfig) -> np.ndarray: + """Load the canonical seed frame emitted by the game-map compiler.""" + with Image.open(io.BytesIO(zf.read("first_image.png"))) 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 _load_prompt(zf: zipfile.ZipFile) -> str: + """Load the canonical prompt emitted by the game-map compiler.""" + return zf.read("prompt.txt").decode("utf-8").strip() def _select_camera( @@ -790,21 +595,17 @@ def _load_ground_mesh( 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) + scene_path = Path(scene_path) 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) + initial_rgb = _load_initial_image(zf, raster) + prompt = _load_prompt(zf) line_layers, triangle_layers, polygon_layers = _load_map_layers(zf, raster) ground_mesh_vertices, ground_mesh_faces = _load_ground_mesh(zf) game_map = ( @@ -831,26 +632,3 @@ def load_scene_bundle( 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/pyproject.toml b/apps/omnidreams_game_engine/pyproject.toml index 25010a1c1..555a04d4b 100644 --- a/apps/omnidreams_game_engine/pyproject.toml +++ b/apps/omnidreams_game_engine/pyproject.toml @@ -11,7 +11,6 @@ requires-python = ">=3.10,<3.13" dependencies = [ "filelock>=3", "flashdreams", - "flashdreams-interactive-drive-v2", "ludus-renderer", "numpy", "pillow", @@ -23,7 +22,6 @@ dependencies = [ [tool.uv.sources] flashdreams = { workspace = true } -flashdreams-interactive-drive-v2 = { workspace = true } ludus-renderer = { workspace = true } [project.optional-dependencies] diff --git a/apps/omnidreams_game_engine/tests/test_scene.py b/apps/omnidreams_game_engine/tests/test_scene.py index 0f18f2603..73b65016b 100644 --- a/apps/omnidreams_game_engine/tests/test_scene.py +++ b/apps/omnidreams_game_engine/tests/test_scene.py @@ -9,59 +9,48 @@ import pytest from omnidreams_game_engine import scene as scene_module from omnidreams_game_engine.config import RasterConfig -from omnidreams_game_engine.game_map.types import GameMapVisualVariant from omnidreams_game_engine.scene import SceneRequest, load_scene pytestmark = pytest.mark.ci_cpu -@pytest.mark.parametrize( - ("use_prompt_context", "explicit_prompt", "context_prompt", "expected"), - [ - (False, None, "context prompt", None), - (True, None, "context prompt", "context prompt"), - (True, None, None, "full prompt"), - (True, "explicit prompt", "context prompt", "explicit prompt"), - ], -) -def test_scene_selects_context_prompt_only_when_requested( +@pytest.mark.parametrize("use_prompt_context", [False, True]) +def test_scene_forwards_context_selection_to_map_compiler( monkeypatch: pytest.MonkeyPatch, use_prompt_context: bool, - explicit_prompt: str | None, - context_prompt: str | None, - expected: str | None, ) -> None: - variants = ( - GameMapVisualVariant( - name="default", - image=None, - prompt="full prompt", - prompt_context=context_prompt, - ), - ) - compiled = SimpleNamespace( - archive_path=Path("compiled.usdz"), - game_map=SimpleNamespace( - default_spawn=SimpleNamespace(variants=variants), - ), - ) - captured: list[str | None] = [] + compiled = SimpleNamespace(archive_path=Path("compiled.usdz")) + compiler_options: list[dict[str, object]] = [] + loader_options: list[dict[str, object]] = [] + + def compile_map(*args: object, **kwargs: object) -> SimpleNamespace: + del args + compiler_options.append(kwargs) + return compiled + monkeypatch.setattr( - scene_module, "compile_game_map", lambda *args, **kwargs: compiled + scene_module, + "compile_game_map", + compile_map, ) monkeypatch.setattr( scene_module, "load_scene_bundle", - lambda **kwargs: captured.append(kwargs["prompt_override"]), + lambda **kwargs: loader_options.append(kwargs), ) load_scene( SceneRequest( map_path=Path("map.robotaxi.yaml"), - prompt=explicit_prompt, use_prompt_context=use_prompt_context, ), RasterConfig(), ) - assert captured == [expected] + assert compiler_options == [ + { + "use_prompt_context": use_prompt_context, + "force": False, + } + ] + assert "prompt_override" not in loader_options[0] diff --git a/uv.lock b/uv.lock index b57f5808b..969534eec 100644 --- a/uv.lock +++ b/uv.lock @@ -3306,7 +3306,6 @@ source = { editable = "apps/omnidreams_game_engine" } dependencies = [ { name = "filelock" }, { name = "flashdreams" }, - { 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')" }, @@ -3329,7 +3328,6 @@ dev = [ requires-dist = [ { name = "filelock", specifier = ">=3" }, { name = "flashdreams", editable = "flashdreams" }, - { name = "flashdreams-interactive-drive-v2", editable = "apps/interactive_drive" }, { name = "ludus-renderer", editable = "integrations_v2/omnidreams/impl/ludus-renderer" }, { name = "numpy" }, { name = "pillow" },