Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3ad5cd0
feat(perception): StereoPointCloud module — gradient filter, backproj…
shahtvisha Jul 7, 2026
5e5ddd9
feat(control): add coordinator_flowbase_stereo_nav blueprint
shahtvisha Jul 7, 2026
298cde7
fix(perception): registry entry, correct TF frame, remove double rota…
shahtvisha Jul 7, 2026
730d23f
refactor(perception): port realsense_stereo_nav algorithms into Modul…
shahtvisha Jul 7, 2026
11699ae
refactor(perception): port Madgwick+ICP VIO from realsense_stereo_nav…
shahtvisha Jul 7, 2026
028c01a
refactor(perception): split stereo_point_cloud into package with vio.…
shahtvisha Jul 7, 2026
79175f9
fix(perception): name all magic constants across vio.py, utils.py, mo…
shahtvisha Jul 8, 2026
656a698
fix(perception): add spatial+hole-fill depth filters, match original …
shahtvisha Jul 8, 2026
b144948
fix(perception): cv2 depth filters, restore global map floor filter
shahtvisha Jul 8, 2026
74812bd
fix(perception): bilateral depth filter, split floor margins to match…
shahtvisha Jul 8, 2026
c3b1ae1
fix(perception): floor calibrator uses lowest prominent Z cluster, no…
shahtvisha Jul 8, 2026
61da0d4
feat(perception): subclass RealSenseCamera to apply rs.spatial_filter…
shahtvisha Jul 8, 2026
c8f8014
fix(perception): floor calibrator uses absolute count threshold + rel…
shahtvisha Jul 8, 2026
2080703
fix(perception): revert floor calibration to original np.argmax method
shahtvisha Jul 8, 2026
5d3a60d
fix(perception): lowest-Z significant bin for floor, nearest-fill for…
shahtvisha Jul 8, 2026
3f8ec7e
revert: floor calibration to np.argmax + hole_fill to original default
shahtvisha Jul 8, 2026
a70be95
fix(perception): remove per-frame floor filter, only filter global map
shahtvisha Jul 8, 2026
c39e7d1
fix(perception): disable per-frame floor cut, keep global map floor f…
shahtvisha Jul 8, 2026
b10281b
fix: floor calibration bounded Z range [-3m,-0.3m] + lowest significa…
shahtvisha Jul 8, 2026
a7a8802
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions dimos/control/blueprints/mobile.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
dimos run coordinator-flowbase # FlowBase holonomic base (Portal RPC)
dimos run coordinator-flowbase-keyboard-teleop # FlowBase + WASD pygame teleop
dimos run coordinator-flowbase-nav # FlowBase + FastLio2 + nav stack (click-to-drive)
dimos run coordinator-flowbase-stereo-nav # FlowBase + RealSense D435i stereo depth + nav stack
"""

from __future__ import annotations
Expand All @@ -34,9 +35,12 @@
)
from dimos.control.coordinator import ControlCoordinator, TaskConfig
from dimos.core.coordination.blueprints import autoconnect
from dimos.hardware.sensors.camera.realsense.camera import RealSenseCamera
from dimos.hardware.sensors.lidar.fastlio2.module import FastLio2
from dimos.mapping.costmapper import CostMapper
from dimos.navigation.cmu_nav.main import cmu_nav_rerun_config, create_cmu_nav
from dimos.navigation.movement_manager.movement_manager import MovementManager
from dimos.perception.stereo_point_cloud import StereoPointCloud
from dimos.robot.unitree.g1.config import G1_LOCAL_PLANNER_PRECOMPUTED_PATHS
from dimos.robot.unitree.keyboard_teleop import KeyboardTeleop
from dimos.visualization.rerun.bridge import RerunBridgeModule
Expand Down Expand Up @@ -212,3 +216,34 @@ def _flowbase_twist_base(
),
],
).remappings([(ControlCoordinator, "twist_command", "cmd_vel")])


# FlowBase + RealSense D435i stereo depth + CostMapper + nav stack
coordinator_flowbase_stereo_nav = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Run Target Missing From Registry

The documented dimos run coordinator-flowbase-stereo-nav path resolves through the generated blueprint registry, but this change only adds the blueprint variable. Since the registry was not updated with coordinator-flowbase-stereo-nav, the advertised command can fail before this blueprint is loaded.

autoconnect(
RealSenseCamera.blueprint(enable_depth=True, enable_pointcloud=False),
StereoPointCloud.blueprint(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Planner Path Missing This blueprint starts the camera, point-cloud module, CostMapper, MovementManager, and coordinator, but it never adds the planner/path-follower modules that turn a clicked goal into nav_cmd_vel. In the advertised click-to-drive flow, clicks can reach MovementManager and the costmap can be produced, but no component consumes the goal and costmap to publish navigation velocity commands, so autonomous cmd_vel is never generated.

CostMapper.blueprint(),
MovementManager.blueprint(),
ControlCoordinator.blueprint(
hardware=[_flowbase_twist_base()],
tasks=[
TaskConfig(
name="vel_base",
type="velocity",
Comment on lines +226 to +233

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Planner Path Is Missing The stereo navigation blueprint starts the camera, point-cloud module, CostMapper, MovementManager, and coordinator, but it does not include a planner that consumes the costmap or click target and produces navigation velocity commands. The run target can load, but click-to-drive has no path from the generated costmap to cmd_vel, so the advertised stereo navigation workflow starts without actually navigating.

joint_names=_base_joints,
priority=10,
),
],
),
RerunBridgeModule.blueprint(rerun_open="web"),
RerunWebSocketServer.blueprint(),
)
.remappings(
[
(MovementManager, "way_point", "_mgr_way_point_unused"),
(ControlCoordinator, "twist_command", "cmd_vel"),
]
)
.global_config(n_workers=8)
)
241 changes: 241 additions & 0 deletions dimos/perception/stereo_point_cloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# 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.

"""Stereo depth → per-frame cloud + persistent global map for CostMapper."""

from __future__ import annotations

import threading
from typing import Any

import numpy as np
from reactivex.disposable import Disposable
from scipy.ndimage import sobel

from dimos.core.core import rpc
from dimos.core.module import Module, ModuleConfig
from dimos.core.stream import In, Out
from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo
from dimos.msgs.sensor_msgs.Image import Image
from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2
from dimos.utils.logging_config import setup_logger

logger = setup_logger()

# ── Constants ─────────────────────────────────────────────────────────────────

_VOFF = np.int64(100_000)
_VMASK = np.int64(0x3FFFF)


# ── Helpers ───────────────────────────────────────────────────────────────────

def _pack(vkeys: np.ndarray) -> np.ndarray:
v = (vkeys.astype(np.int64) + _VOFF) & _VMASK
return (v[:, 0] << 36) | (v[:, 1] << 18) | v[:, 2]


def _gradient_mask(depth: np.ndarray, threshold: float) -> np.ndarray:
"""True where depth is valid and Sobel gradient magnitude is below threshold (m/px)."""
valid = np.isfinite(depth)
depth_f = np.where(valid, depth, 0.0).astype(np.float64)
grad = np.hypot(sobel(depth_f, axis=1), sobel(depth_f, axis=0))
return valid & (grad < threshold)


def _raycast_free_keys(
origin: np.ndarray,
surface_pts: np.ndarray,
vox: float,
n_rays: int = 200,
max_steps: int = 40,
) -> np.ndarray:
"""World-frame voxel keys on rays from origin to surface_pts. Keys match floor(pts/vox) world voxelisation."""
if len(surface_pts) == 0:
return np.array([], dtype=np.int64)

idx = np.random.choice(len(surface_pts), min(n_rays, len(surface_pts)), replace=False)
vecs = (surface_pts[idx] - origin).astype(np.float32)
dist = np.linalg.norm(vecs, axis=1, keepdims=True)
dist = np.where(dist < 1e-6, 1e-6, dist)
dirs = vecs / dist

steps = np.linspace(0.1, 0.9, max_steps, dtype=np.float32)
pts = origin + dirs[:, None, :] * (dist[:, None, :] * steps[None, :, None])
pts = pts.reshape(-1, 3)
return np.unique(_pack(np.floor(pts / vox).astype(np.int32)))


# ── Module ────────────────────────────────────────────────────────────────────

class Config(ModuleConfig):
min_depth: float = 0.3
max_depth: float = 8.0
gradient_threshold: float = 0.30 # m/px; above → depth edge artifact → reject
vox_size: float = 0.020 # per-frame dedup voxel (2 cm)
global_vox_size: float = 0.040 # accumulation voxel (4 cm, absorbs pose jitter)
floor_margin: float = 0.04 # metres above detected floor to keep points
max_global_pts: int = 200_000
publish_every: int = 3 # emit global_map every N frames
world_frame: str = "world"
camera_frame: str = "camera_color_optical_frame"
base_frame: str = "base_link"
tf_timeout: float = 0.2


class StereoPointCloud(Module):
"""Gradient filter → backproject → floor removal → voxel dedup → ray-cast ghost clearing → global_map."""

config: Config

depth_image: In[Image]
depth_camera_info: In[CameraInfo]

frame_cloud: Out[PointCloud2]
global_map: Out[PointCloud2]

def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._lock = threading.Lock()
self._latest_info: CameraInfo | None = None
self._last_tf = None
self._floor_buf: list[float] = []
self._acc_pts: np.ndarray = np.empty((0, 3), dtype=np.float32)
self._frame = 0

@rpc
def start(self) -> None:
super().start()
self.register_disposable(Disposable(self.depth_camera_info.subscribe(self._on_info)))
self.register_disposable(Disposable(self.depth_image.subscribe(self._on_depth)))

@rpc
def stop(self) -> None:
super().stop()

def _on_info(self, info: CameraInfo) -> None:
with self._lock:
self._latest_info = info

def _on_depth(self, img: Image) -> None:
with self._lock:
info = self._latest_info

depth = img.data
if hasattr(depth, "get"):
depth = depth.get()
if depth.ndim == 3:
depth = depth[:, :, 0]
depth = depth.astype(np.float32)
if np.nanmedian(depth[depth > 0]) > 100:
depth /= 1000.0

H, W = depth.shape

if info is not None:
K = info.get_K_matrix()
fx, fy, cx, cy = float(K[0, 0]), float(K[1, 1]), float(K[0, 2]), float(K[1, 2])
else:
fx = fy = float(max(H, W)) / 2.0
cx, cy = W / 2.0, H / 2.0

mask = (
_gradient_mask(depth, self.config.gradient_threshold)
& (depth > self.config.min_depth)
& (depth < self.config.max_depth)
)
if not mask.any():
return

uu, vv = np.meshgrid(np.arange(W, dtype=np.float32), np.arange(H, dtype=np.float32))
dd = depth[mask]
xyz_opt = np.column_stack([
(uu[mask] - cx) * dd / fx,
(vv[mask] - cy) * dd / fy,
dd,
]).astype(np.float32)

tf = self.tf.get(
self.config.world_frame, self.config.camera_frame, img.ts, self.config.tf_timeout
)
if tf is not None:
self._last_tf = tf
if self._last_tf is not None:
R = self._last_tf.rotation.to_rotation_matrix().astype(np.float32)
t = np.array(
[self._last_tf.translation.x,
self._last_tf.translation.y,
self._last_tf.translation.z],
dtype=np.float32,
)
else:
R, t = np.eye(3, dtype=np.float32), np.zeros(3, dtype=np.float32)

xyz_world = (xyz_opt @ R.T + t).astype(np.float32)

# TF-primary floor detection; falls back to rolling low-Z percentile
base_tf = self.tf.get(
self.config.world_frame, self.config.base_frame, img.ts, self.config.tf_timeout
)
if base_tf is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Floor Height Misread
This uses the base_link origin height as the floor height whenever TF is available. On mobile bases where base_link is at the chassis or IMU height above the ground, the filter later keeps only points above base_link.z + floor_margin, so low obstacles and floor-adjacent geometry can be removed before frame_cloud or global_map reaches CostMapper. Use an actual floor frame/height estimate here instead of the robot body origin.

floor_z = float(base_tf.translation.z)
else:
bot = xyz_world[xyz_world[:, 2] < np.percentile(xyz_world[:, 2], 10)]
if len(bot):
self._floor_buf.append(float(np.median(bot[:, 2])))
if len(self._floor_buf) > 30:
self._floor_buf.pop(0)
floor_z = float(np.percentile(self._floor_buf, 20)) if len(self._floor_buf) >= 5 else None

vk = np.floor(xyz_world / self.config.vox_size).astype(np.int32)
_, ui = np.unique(_pack(vk), return_index=True)
xyz_vox = xyz_world[ui]
if floor_z is not None:
xyz_vox = xyz_vox[xyz_vox[:, 2] > floor_z + self.config.floor_margin]
if len(xyz_vox) == 0:
return

self.frame_cloud.publish(
PointCloud2.from_numpy(xyz_vox, frame_id=self.config.world_frame, timestamp=img.ts)
)

pts_snap = None

with self._lock:
if len(self._acc_pts) > 0:
free = _raycast_free_keys(t, xyz_vox, self.config.global_vox_size)
if len(free):
keys_acc = _pack(np.floor(self._acc_pts / self.config.global_vox_size).astype(np.int32))
self._acc_pts = self._acc_pts[~np.isin(keys_acc, free)]

self._acc_pts = (
np.vstack([self._acc_pts, xyz_vox])
if len(self._acc_pts) else xyz_vox.copy()
)
vk_g = np.floor(self._acc_pts / self.config.global_vox_size).astype(np.int32)
_, ui_g = np.unique(_pack(vk_g), return_index=True)
self._acc_pts = self._acc_pts[ui_g]

if len(self._acc_pts) > self.config.max_global_pts:
keep = np.random.choice(len(self._acc_pts), self.config.max_global_pts, replace=False)
self._acc_pts = self._acc_pts[keep]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Random Trim Drops Current Obstacles

When the accumulated map exceeds max_global_pts, the code samples uniformly from the whole map immediately after appending the current frame. In dense scenes this can discard freshly observed nearby obstacles with the same probability as old distant points, causing CostMapper to intermittently lose occupied cells on the navigation path.


self._frame += 1
if self._frame % self.config.publish_every == 0 and len(self._acc_pts):
pts_snap = self._acc_pts.copy()

if pts_snap is not None:
self.global_map.publish(
PointCloud2.from_numpy(pts_snap, frame_id=self.config.world_frame, timestamp=img.ts)
)
1 change: 1 addition & 0 deletions dimos/robot/all_blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"coordinator-flowbase": "dimos.control.blueprints.mobile:coordinator_flowbase",
"coordinator-flowbase-keyboard-teleop": "dimos.control.blueprints.mobile:coordinator_flowbase_keyboard_teleop",
"coordinator-flowbase-nav": "dimos.control.blueprints.mobile:coordinator_flowbase_nav",
"coordinator-flowbase-stereo-nav": "dimos.control.blueprints.mobile:coordinator_flowbase_stereo_nav",
"coordinator-mobile-manip-mock": "dimos.control.blueprints.mobile:coordinator_mobile_manip_mock",
"coordinator-mock": "dimos.robot.manipulators.common.mock:coordinator_mock",
"coordinator-mock-twist-base": "dimos.control.blueprints.mobile:coordinator_mock_twist_base",
Expand Down
Loading