-
Notifications
You must be signed in to change notification settings - Fork 788
feat(spot): spot-replay blueprint - play a recording back into Rerun #3433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jeff-hykin
wants to merge
34
commits into
main
Choose a base branch
from
jeff/feat/spot-replay2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+474
−4
Open
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
52f85de
feat(spot): minimal Spot velocity control + keyboard teleop blueprint
jeff-hykin 9cdad08
cmd_vel works
jeff-hykin d6067bc
feat(spot): flatten cmd_vel into SpotHighLevel; add cameras/record bl…
jeff-hykin bf92efe
add lossless recording option
jeff-hykin 624997a
organize, untested
jeff-hykin 21c6c88
fixup, hardware tested, odom timestamps fix
jeff-hykin 1b49dfa
redo how rotations work, tested on hardware
jeff-hykin 77d29a4
fix too large angular vel
jeff-hykin 4e96faf
Merge remote-tracking branch 'origin/main' into jeff/feat/spot
jeff-hykin 09efc07
docs(spot): move Spot README into Mintlify docs tree
jeff-hykin e7dd7a2
comment cleanup
jeff-hykin 9f03c1c
refactor(spot): reorder utils helpers, drop stale docstring line
jeff-hykin a6f6c60
misc
jeff-hykin e54e6e4
chore(spot): drop stray spot-replay registration from no-replay PR
jeff-hykin 7311e74
Merge branch 'main' into jeff/feat/spot
jeff-hykin 6209f40
fix(spot): roll front camera optical tf to match upright images
jeff-hykin fcb3815
fix(spot): tear down on setup failure and fail loud on missing odom TF
jeff-hykin 59827cb
-
jeff-hykin 7760602
Merge branch 'main' into jeff/feat/spot
jeff-hykin 0c3acdb
Merge remote-tracking branch 'origin/main' into _pr_merge
jeff-hykin cc2cfa4
Merge branch 'main' into jeff/feat/spot
jeff-hykin 3fb81ea
Merge main into jeff/feat/spot
jeff-hykin c20a49a
Merge remote-tracking branch 'origin/main' into jeff/feat/spot
jeff-hykin 5094d53
refactor(spot): move Spot support under dimos/experimental
jeff-hykin c57c36b
fix(spot): adapt to the retired TF service
jeff-hykin c6a7b1c
fix(spot): defer the rerun imports in spot/rerun.py
jeff-hykin 901e71b
docs(spot): use the current CLI flag syntax, not the removed -o form
jeff-hykin 04749b5
feat(spot): add spot-replay blueprint + per-camera frustum anchoring
jeff-hykin 1e61021
feat(spot): draw the whole travelled path in spot-replay
jeff-hykin a0c8840
refactor(mapping): take the newer async-handler OdometryPath
jeff-hykin a3513c6
fixup jitter
jeff-hykin 4fb8cf6
fix(blueprints): register OdometryPath in all_blueprints
jeff-hykin e644e5e
feat(spot): let spot-replay pull its recording from LFS by name
jeff-hykin 82142b1
Merge remote-tracking branch 'origin/main' into jeff/feat/spot-replay2
jeff-hykin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
dimos/experimental/robot/bosdyn/spot/blueprints/spot_replay.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # Copyright 2025-2026 Dimensional Inc. | ||
| # | ||
| # 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. | ||
|
|
||
| """Replay the latest Spot recording to Rerun — no robot, runs anywhere. | ||
|
|
||
| `SpotReplay` plays a memory2 recording (from `spot-record`) back onto the same | ||
| stream names `SpotHighLevel` uses, so the Spot camera layout and per-camera | ||
| frustums light up in the Rerun 3D view. The `visual_override` routes the two | ||
| shared CameraInfo streams onto each camera's image entity so every frustum | ||
| anchors to its optical frame. | ||
|
|
||
| Usage: | ||
| # the shared recording, pulled from LFS on first run: | ||
| dimos run spot-replay --db-path=spot_small_loop.db | ||
| # newest *.db under ~/datasets/spot: | ||
| dimos run spot-replay | ||
| # a specific recording: | ||
| dimos run spot-replay --db-path=/path/to/spot.db | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dimos.core.coordination.blueprints import autoconnect | ||
| from dimos.core.global_config import global_config | ||
| from dimos.experimental.robot.bosdyn.spot.replay import SpotReplay | ||
| from dimos.experimental.robot.bosdyn.spot.rerun import ( | ||
| spot_body_static_overrides, | ||
| spot_camera_layout, | ||
| spot_camera_visual_overrides, | ||
| ) | ||
| from dimos.mapping.odometry_path import OdometryPath | ||
| from dimos.protocol.pubsub.impl.lcmpubsub import LCM | ||
| from dimos.visualization.rerun.bridge import RerunBridgeModule | ||
| from dimos.visualization.rerun.websocket_server import RerunWebSocketServer | ||
|
|
||
| # Compose only the Rerun bridge (+ its websocket server) directly instead of the | ||
| # shared `vis_module`: replay just needs the 3D viewer, and `vis_module` also | ||
| # bundles the WebsocketVisModule, which auto-opens the 7779 Command Center tab. | ||
| spot_replay = autoconnect( | ||
| SpotReplay.blueprint(), | ||
| OdometryPath.blueprint(), | ||
| RerunBridgeModule.blueprint( | ||
| pubsubs=[LCM()], | ||
| rerun_open=global_config.rerun_open, | ||
| rerun_web=global_config.rerun_web, | ||
| blueprint=spot_camera_layout, | ||
| visual_override=spot_camera_visual_overrides(), | ||
| static=spot_body_static_overrides(), | ||
| ), | ||
| RerunWebSocketServer.blueprint(), | ||
| ).remappings([(OdometryPath, "path", "odom_path")]) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| # Copyright 2025-2026 Dimensional Inc. | ||
| # | ||
| # 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. | ||
|
|
||
| """Replay a recorded Spot session to streams, mirroring `SpotHighLevel`'s outputs. | ||
|
|
||
| Opens a memory2 SQLite recording (written by `SpotRecorder`) and replays every | ||
| camera, depth, and odometry stream onto Out ports named exactly like | ||
| `SpotHighLevel`'s, so the same Rerun visualization wires up by name — no robot | ||
| required. The recorded ``tf`` tree (odom->base_link plus the base_link->camera | ||
| mounts) is republished so every frame stays spatially anchored in 3D. | ||
|
|
||
| The travelled trail is not built here: ``odometry`` feeds ``OdometryPath``, | ||
| which accumulates it and is equally happy on a live robot. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import AsyncIterator | ||
| import math | ||
| from pathlib import Path | ||
|
|
||
| from dimos.core.module import Module, ModuleConfig | ||
| from dimos.core.stream import Out | ||
| from dimos.experimental.robot.bosdyn.spot.config import ( | ||
| CAMERA_STREAM_SUFFIXES, | ||
| FRONT_CAMERA_ROTATE_UPRIGHT, | ||
| ) | ||
| from dimos.memory2.replay import resolve_db_path | ||
| from dimos.memory2.store.sqlite import SqliteStore | ||
| from dimos.msgs.geometry_msgs.Quaternion import Quaternion | ||
| from dimos.msgs.geometry_msgs.Transform import Transform | ||
| from dimos.msgs.geometry_msgs.Vector3 import Vector3 | ||
| from dimos.msgs.nav_msgs.Odometry import Odometry | ||
| from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo | ||
| from dimos.msgs.sensor_msgs.Image import Image | ||
| from dimos.msgs.tf2_msgs.TFMessage import TFMessage | ||
| from dimos.utils.logging_config import setup_logger | ||
|
|
||
| logger = setup_logger() | ||
|
|
||
| # Image Out ports to replay, matching SpotHighLevel / SpotRecorder stream names. | ||
| _IMAGE_STREAMS = [ | ||
| f"{kind}_image_{suffix}" for kind in ("grayscale", "depth") for suffix in CAMERA_STREAM_SUFFIXES | ||
| ] | ||
| _PLAYBACK_STREAMS = [*_IMAGE_STREAMS, "grayscale_info", "depth_info", "odometry"] | ||
|
|
||
| # SpotHighLevel rights the sideways front camera images but leaves their optical | ||
| # tf frames at the raw mount orientation, so the 3D frustum + depth | ||
| # back-projection land rotated. Roll each front optical frame about its viewing | ||
| # (z) axis to bring it back upright. The two front cameras mount mirror-imaged, | ||
| # so frontright sits a half turn (2 quarter turns) past frontleft. | ||
| _HALF_TURN_QUARTERS = 2 | ||
| _OPTICAL_FRAME_ROLL_TURNS = { | ||
| "frontleft_camera_optical": FRONT_CAMERA_ROTATE_UPRIGHT, | ||
| "frontright_camera_optical": FRONT_CAMERA_ROTATE_UPRIGHT + _HALF_TURN_QUARTERS, | ||
| } | ||
|
|
||
|
|
||
| class SpotReplayConfig(ModuleConfig): | ||
| """Where to read the recording from and how to play it back.""" | ||
|
|
||
| # An on-disk path, or the name of a dataset to pull from LFS (e.g. | ||
| # ``spot_small_loop.db``). Empty -> newest ``*.db`` in ``dataset_dir``. | ||
| db_path: str = "" | ||
| dataset_dir: str = "~/datasets/spot" | ||
|
|
||
| speed: float = 1.0 | ||
| loop: bool = True | ||
| seek: float | None = None | ||
| duration: float | None = None | ||
|
|
||
| # Roll the front optical tf frames upright at replay time. Off by default: | ||
| # recordings are expected to already store upright front frames. Turn on to | ||
| # view an old recording whose tf still holds the raw sideways mount. | ||
| roll_front_frames: bool = False | ||
|
|
||
|
|
||
| class SpotReplay(Module): | ||
| """Replays Spot's fisheye + depth cameras, odometry, and tf from a recording.""" | ||
|
|
||
| config: SpotReplayConfig | ||
| dedicated_worker = True | ||
|
|
||
| grayscale_image_front_left: Out[Image] | ||
| grayscale_image_front_right: Out[Image] | ||
| grayscale_image_left: Out[Image] | ||
| grayscale_image_right: Out[Image] | ||
| grayscale_image_back: Out[Image] | ||
|
|
||
| depth_image_front_left: Out[Image] | ||
| depth_image_front_right: Out[Image] | ||
| depth_image_left: Out[Image] | ||
| depth_image_right: Out[Image] | ||
| depth_image_back: Out[Image] | ||
|
|
||
| grayscale_info: Out[CameraInfo] | ||
| depth_info: Out[CameraInfo] | ||
|
|
||
| odometry: Out[Odometry] | ||
| tf: Out[TFMessage] | ||
|
|
||
| def _resolve_db_path(self) -> Path: | ||
| if self.config.db_path: | ||
| return resolve_db_path(Path(self.config.db_path).expanduser()) | ||
| directory = Path(self.config.dataset_dir).expanduser() | ||
| recordings = sorted(directory.glob("*.db"), key=lambda path: path.stat().st_mtime) | ||
| if not recordings: | ||
| raise FileNotFoundError(f"No .db recordings found in {directory}") | ||
| return recordings[-1] | ||
|
|
||
| def _republish_tf(self, message: TFMessage) -> None: | ||
| self.tf.publish( | ||
| TFMessage(*(self._roll_optical_frame(transform) for transform in message.transforms)) | ||
| ) | ||
|
|
||
| def _roll_optical_frame(self, transform: Transform) -> Transform: | ||
| if not self.config.roll_front_frames: | ||
| return transform | ||
| turns = _OPTICAL_FRAME_ROLL_TURNS.get(transform.child_frame_id) | ||
| if not turns: | ||
| return transform | ||
| roll = Quaternion.from_euler(Vector3(0.0, 0.0, turns * math.pi / 2)) | ||
| return Transform( | ||
| translation=transform.translation, | ||
| rotation=transform.rotation * roll, | ||
| frame_id=transform.frame_id, | ||
| child_frame_id=transform.child_frame_id, | ||
| ts=transform.ts, | ||
| ) | ||
|
|
||
| async def main(self) -> AsyncIterator[None]: | ||
| db_path = self._resolve_db_path() | ||
| logger.info(f"Replaying Spot recording from {db_path}") | ||
|
|
||
| store = SqliteStore(path=str(db_path), must_exist=True) | ||
| store.start() | ||
| self.register_disposable(store) | ||
|
|
||
| replay = store.replay( | ||
| speed=self.config.speed, | ||
| loop=self.config.loop, | ||
| seek=self.config.seek, | ||
| duration=self.config.duration, | ||
| ) | ||
| available = set(replay.list_streams()) | ||
|
|
||
| for name in _PLAYBACK_STREAMS: | ||
| if name not in available: | ||
| logger.warning(f"Spot replay: stream {name!r} missing from recording; skipping") | ||
| continue | ||
| self.register_disposable( | ||
| replay.stream(name).observable().subscribe(getattr(self, name).publish) | ||
| ) | ||
|
|
||
| if "tf" in available: | ||
| self.register_disposable(replay.stream("tf").observable().subscribe(self._republish_tf)) | ||
| else: | ||
| logger.warning("Spot replay: no tf stream in recording; 3D frames will be missing") | ||
|
|
||
| yield | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, not sure
~/datasetsis the best place. I don't like it when programs mess with my home dir without asking. Maybe this should useSTATE_DIR?