Source code for so101_mvbench.utils.scene_config

# SPDX-License-Identifier: MIT
"""SceneConfig — unified scene declaration schema.

Single source of truth for scene structure: cameras, objects, robot,
visuals, and the scene's workspace origin (``workspace_origin_m``).
Consumed by ``teleop_recorder``, ``multicam_replay``, and ``async_eval``
via ``SceneConfig.from_json``; the shipped configs live in
``tasks/scene_configs/``.
- ... and 5 more (see plan-file "9 fragmentierte Configs" inventory)

Schema versioning: major version (``1.x``) must match ``SCHEMA_VERSION``;
minor bumps are forward-compatible for *new optional fields with defaults*,
but they may also tighten validation on existing fields (e.g. a previously
optional field becoming required for one ``type`` variant). The major bump
is reserved for changes that rename or remove fields or alter the semantics
of existing values.

Version history:

``1.0``
    Initial schema.
``1.1``
    ``CameraSpec.radius_m`` is required for external entries and forbidden
    on ego entries.
``1.2``
    ``CameraSpec.pose_id`` is removed. External cameras declare their
    hemisphere position via three explicit floats (``azimuth_deg``,
    ``elevation_deg``, ``radius_m``). Pre-1.2 configs must be migrated by
    splitting ``pose_id`` into the two angle fields.
"""

from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Literal

from so101_mvbench.utils.so101_transforms import BASE_SCENE_ORIGIN_W

__version__ = "1.2.0"

SCHEMA_VERSION = "1.2"


[docs] @dataclass class CameraSpec: """One camera in the scene. ``ego`` — gripper-mounted (use ``offset_pos_m`` + ``offset_rot_deg`` relative to the gripper joint). ``external`` — hemisphere-mounted around the workspace origin. Position is declared by three explicit floats: ``azimuth_deg``, ``elevation_deg``, ``radius_m``. All three are REQUIRED. The world-frame ``(x, y, z)`` is computed from them by ``utils/camera_grid.spherical_to_cartesian``. Schema 1.2 removed the string-form ``pose_id`` (e.g. ``"az000_el45"``): carrying both numbers and a string-encoding of two of them invited parsing, key mangling, and registry mutation downstream. """ type: Literal["ego", "external"] # external: hemisphere position around the workspace center azimuth_deg: float | None = None elevation_deg: float | None = None radius_m: float | None = None # ego: gripper-relative pose offset_pos_m: tuple[float, float, float] | None = None offset_rot_deg: tuple[float, float, float] | None = None # common resolution_hw: tuple[int, int] = (480, 640) focal_length: float = 5.0 horizontal_aperture: float = 10.0 # optional fixed world up-vector for the camera roll (external only). When # set, the camera roll is derived from this constant up instead of the stage # up-axis, so a camera swept along a meridian tilts continuously without a # roll jump at the pole (camera-drift ablation). Default None = stage up-axis. up_world: tuple[float, float, float] | None = None def __post_init__(self) -> None: if self.type == "external": missing = [ name for name, val in ( ("azimuth_deg", self.azimuth_deg), ("elevation_deg", self.elevation_deg), ("radius_m", self.radius_m), ) if val is None ] if missing: raise ValueError( f"external camera needs {missing!r} (all three required: " f"azimuth_deg, elevation_deg, radius_m)" ) elif self.type == "ego": for name, val in ( ("azimuth_deg", self.azimuth_deg), ("elevation_deg", self.elevation_deg), ("radius_m", self.radius_m), ): if val is not None: raise ValueError( f"ego camera should not have {name} " f"(gripper-relative offset, no spherical concept)" ) if self.offset_pos_m is None or self.offset_rot_deg is None: raise ValueError( "ego camera needs offset_pos_m + offset_rot_deg " "(gripper-relative pose)" )
[docs] @dataclass class ObjectSpec: """One scene object: rigid cube or static USD asset. ``init_pos_m`` is expressed in the **workspace frame {W}**, not in world coordinates: add the scene's ``workspace_origin_m`` to get the world pose. Declaring it {W}-relative is what makes an object placement portable between scenes (see :doc:`the frame contract </concepts/coordinate_frames>`). """ type: Literal["cube", "usd_asset"] init_pos_m: tuple[float, float, float] | None = None init_rpy_rad: tuple[float, float, float] = (0.0, 0.0, 0.0) size_m: tuple[float, float, float] | None = None color_rgb: tuple[float, float, float] = (0.8, 0.8, 0.8) mass_kg: float = 0.05 usd_path: str | None = None
[docs] @dataclass class RobotSpec: """Robot config: type, initial pose, default + home joint positions. Joint dicts use *Isaac-USD* names (``Rotation``, ``Pitch``, ``Elbow``, ``Wrist_Pitch``, ``Wrist_Roll``, ``Jaw``) for consistency with ``LiftCubeEnvCfg.actions.joint_positions.joint_names``. """ type: str = "so101_white" init_pos_m: tuple[float, float, float] = (0.0, 0.0, 0.0) init_rot_xyzw: tuple[float, float, float, float] = (0.0, 0.0, 0.7071, 0.7071) default_joint_pos_rad: dict[str, float] = field(default_factory=dict) home_joint_pos_rad: dict[str, float] = field(default_factory=dict) color_rgb: tuple[float, float, float] = (1.0, 1.0, 1.0)
[docs] @dataclass class VisualSpec: """Visual defaults: lighting, sky dome orientation, action pad.""" room_light_exposure: float = 1.0 env_light_exposure: float = 1.0 sky_dome_yaw_rad: float = 0.0 action_pad_yaw_rad: float = 0.0 dome_light_y_mirror: bool = True
[docs] @dataclass class SceneConfig: """Single source of truth for scene declaration.""" schema_version: str = SCHEMA_VERSION workspace_origin_m: tuple[float, float, float] = BASE_SCENE_ORIGIN_W """{W} origin in world coordinates. Defaults to the scene the package ships (the cylindrical room). A config that omits it therefore declares the current base scene, not a retired one. The legacy fallback for *recorded data* without a frame declaration is a separate rule and lives in the consumers (see ``so101_transforms.rigid_shift_from_recording``).""" cameras: dict[str, CameraSpec] = field(default_factory=dict) objects: dict[str, ObjectSpec] = field(default_factory=dict) robot: RobotSpec = field(default_factory=RobotSpec) visual: VisualSpec = field(default_factory=VisualSpec)
[docs] @classmethod def from_json(cls, path: Path | str) -> SceneConfig: """Load SceneConfig from a JSON file. Hard-fails when the JSON's ``schema_version`` major component does not match :data:`SCHEMA_VERSION`. JSON list-fields are converted back to tuples for type-consistency with downstream Isaac Lab APIs. """ path = Path(path) with path.open("r", encoding="utf-8") as f: data = json.load(f) version = str(data.get("schema_version", "0")) if _major(version) != _major(SCHEMA_VERSION): raise ValueError( f"schema_version mismatch: file {path} has {version!r}, " f"expected major {_major(SCHEMA_VERSION)!r}" ) return cls._from_dict(data)
[docs] def to_json(self, path: Path | str) -> None: """Serialize SceneConfig to JSON (pretty-printed, 2-space indent).""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as f: json.dump(asdict(self), f, indent=2) f.write("\n")
@classmethod def _from_dict(cls, data: dict[str, Any]) -> SceneConfig: cameras = { name: CameraSpec(**_coerce_tuples(spec)) for name, spec in data.get("cameras", {}).items() } objects = { name: ObjectSpec(**_coerce_tuples(spec)) for name, spec in data.get("objects", {}).items() } robot = RobotSpec(**_coerce_tuples(data.get("robot", {}))) visual = VisualSpec(**data.get("visual", {})) ws_origin = data.get("workspace_origin_m") # No second default here: an omitted origin means the base scene, and # the dataclass field is the one place that decides which scene that is. ws_origin = tuple(ws_origin) if ws_origin is not None else BASE_SCENE_ORIGIN_W return cls( schema_version=data.get("schema_version", SCHEMA_VERSION), workspace_origin_m=ws_origin, cameras=cameras, objects=objects, robot=robot, visual=visual, )
_TUPLE_FIELDS = frozenset({ "offset_pos_m", "offset_rot_deg", "resolution_hw", "init_pos_m", "init_rpy_rad", "size_m", "color_rgb", "init_rot_xyzw", "up_world", }) def _coerce_tuples(spec: dict[str, Any]) -> dict[str, Any]: """Convert known list-fields to tuples (JSON lacks tuple literals).""" out = dict(spec) for key in _TUPLE_FIELDS: if key in out and isinstance(out[key], list): out[key] = tuple(out[key]) return out def _major(version: str) -> str: """Return major version (e.g. ``"1.2"`` → ``"1"``).""" return version.split(".", 1)[0]