Skip to content

Commit de7ee2a

Browse files
cagataycalistrands-agentstrands-robotscagataycali
authored
fix(sim/mujoco): metric depth passthrough + capture fd-2 depth warning + silence export_xml attach noise (#891)
* fix(sim/mujoco): metric depth passthrough + capture fd-2 depth warning + silence export_xml attach noise render_depth treated MuJoCo's depth output as a normalized [0,1] OpenGL buffer and re-linearized it with znear*zfar/(zfar - d*(zfar-znear)). But MuJoCo >= 3.0's Renderer.enable_depth_rendering() returns METRIC depth in meters directly, so that re-projection collapsed near-field depth onto the near-clip plane: a scene whose nearest surface sits at 0.41 m reported a minimum of 0.01 m (znear) regardless of content. Consume the metric array as-is; only sanitize NaN/+inf -> far-clip, -inf/negative -> 0, clamp to [0, zfar]. The ARB_clip_control depth warning is a C-level write to fd 2, which contextlib.redirect_stderr cannot see (it only swaps sys.stderr), so the warning both leaked to the console and never reached the response text it was meant to populate. Add a reusable capture_stderr_fd() context manager (os.dup2-based fd-2 capture, mirroring filter_mujoco_attach_noise, with the same no-op fallback when fd 2 can't be duped) and use it in render_depth: surface the notice in the response text, drop the duplicate console line (log at DEBUG), and pass any other stderr lines through unchanged. export_xml called spec.to_xml() raw while every other call site wraps it in filter_mujoco_attach_noise(), so it spammed benign 'Attach conflict ... keeping parent value' chatter to the console for every attached-robot scene. Wrap it too. Tests: rewrite test_render_depth_linearizes_normalized_buffer_to_meters (which enshrined the bug) as test_render_depth_passes_through_metric_depth, add test_render_depth_sanitizes_nan_and_inf. Both fail on pre-fix code. * fix(sim/mujoco): document best-effort stderr-forward except per review feedback * review(sim/mujoco): R3 -- add explanatory comments to bare except clauses (addresses CodeQL threads backend.py:387,400) --------- Co-authored-by: strands-agent <217235299+strands-agent@users.noreply.github.com> Co-authored-by: strands-robots <agent@strands.local> Co-authored-by: cagataycali <cagatay@cali.so>
1 parent 79414e3 commit de7ee2a

4 files changed

Lines changed: 162 additions & 47 deletions

File tree

strands_robots/simulation/mujoco/backend.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,55 @@ def filter_mujoco_attach_noise():
347347
)
348348
# Tear down the UserWarning filter last.
349349
_wctx.__exit__(None, None, None)
350+
351+
352+
@contextlib.contextmanager
353+
def capture_stderr_fd():
354+
"""Capture C-level (fd 2) stderr writes into a list-wrapped string.
355+
356+
Unlike ``contextlib.redirect_stderr`` -- which only swaps Python's
357+
``sys.stderr`` object and is blind to writes coming from C extensions
358+
such as MuJoCo's OpenGL backend -- this redirects the underlying file
359+
descriptor 2, so warnings like the one-time ``ARB_clip_control`` notice
360+
emitted by ``renderer.render()`` are actually captured.
361+
362+
Yields a single-element list; after the block exits, ``box[0]`` holds the
363+
captured text (possibly empty).
364+
365+
No-op-ish fallback: if fd 2 can't be duped (captured stderr, pytest
366+
capsys, Jupyter), it yields an empty box and lets writes pass through
367+
rather than breaking the wrapped call.
368+
"""
369+
box = [""]
370+
try:
371+
orig_fd = sys.stderr.fileno()
372+
saved_fd = os.dup(orig_fd)
373+
except (AttributeError, OSError, ValueError):
374+
# No real fd to capture; yield empty and pass through.
375+
yield box
376+
return
377+
378+
tmp = tempfile.TemporaryFile(mode="w+b")
379+
try:
380+
with _noise_lock:
381+
os.dup2(tmp.fileno(), orig_fd)
382+
try:
383+
yield box
384+
finally:
385+
try:
386+
sys.stderr.flush()
387+
except (ValueError, OSError):
388+
pass # stderr may be closed/detached (pytest capsys, Jupyter)
389+
os.dup2(saved_fd, orig_fd)
390+
finally:
391+
try:
392+
tmp.seek(0)
393+
box[0] = tmp.read().decode(errors="replace")
394+
except OSError:
395+
box[0] = ""
396+
finally:
397+
tmp.close()
398+
try:
399+
os.close(saved_fd)
400+
except OSError:
401+
pass # fd may already be closed during interpreter shutdown

strands_robots/simulation/mujoco/physics.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020

2121
import numpy as np
2222

23-
from strands_robots.simulation.mujoco.backend import _NO_WORLD_MSG, _ensure_mujoco
23+
from strands_robots.simulation.mujoco.backend import (
24+
_NO_WORLD_MSG,
25+
_ensure_mujoco,
26+
filter_mujoco_attach_noise,
27+
)
2428

2529
logger = logging.getLogger(__name__)
2630

@@ -1205,7 +1209,12 @@ def export_xml(self, output_path: str | None = None) -> dict[str, Any]:
12051209
}
12061210

12071211
try:
1208-
xml = spec.to_xml()
1212+
# spec.to_xml() emits benign "Attach conflict ... keeping parent
1213+
# value" chatter (Python UserWarning + raw fd-2 writes) for every
1214+
# attached robot scene. Every other call site wraps it; export_xml
1215+
# must too, or the noise leaks straight to the user's console.
1216+
with filter_mujoco_attach_noise():
1217+
xml = spec.to_xml()
12091218
except Exception as e:
12101219
return {"status": "error", "content": [{"text": f"spec.to_xml() failed: {e}"}]}
12111220

strands_robots/simulation/mujoco/rendering.py

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
import logging
55
from typing import TYPE_CHECKING, Any
66

7-
from strands_robots.simulation.mujoco.backend import _NO_WORLD_MSG, _can_render, _ensure_mujoco
7+
from strands_robots.simulation.mujoco.backend import (
8+
_NO_WORLD_MSG,
9+
_can_render,
10+
_ensure_mujoco,
11+
capture_stderr_fd,
12+
)
813

914
logger = logging.getLogger(__name__)
1015

@@ -734,22 +739,38 @@ def render_depth(
734739
# text (the LLM otherwise never hears about it).
735740
clip_warn = getattr(self, "_depth_warn_text", None)
736741
if clip_warn is None:
737-
import contextlib as _ctx
738-
import io as _io
739742
import sys as _sys
740743

741-
buf = _io.StringIO()
742-
with _ctx.redirect_stderr(buf):
744+
# MuJoCo's ARB_clip_control notice is a C-level write to fd 2,
745+
# so Python's contextlib.redirect_stderr (which only swaps the
746+
# sys.stderr object) cannot see it. Capture the real fd.
747+
with capture_stderr_fd() as _cap:
743748
renderer.enable_depth_rendering()
744749
depth = renderer.render()
745750
renderer.disable_depth_rendering()
746-
captured = buf.getvalue()
747-
# Also forward to the real stderr so logs don't vanish.
748-
if captured and _sys.__stderr__ is not None:
749-
try:
750-
_sys.__stderr__.write(captured)
751-
except Exception:
752-
pass
751+
captured = _cap[0]
752+
# Forward captured stderr, but drop the ARB_clip_control line
753+
# -- it's now surfaced in the response text below, so echoing
754+
# it to the console too would be duplicate noise. Anything
755+
# *other* than that benign notice is passed through unchanged
756+
# so genuine errors never vanish.
757+
if captured:
758+
kept_lines = [ln for ln in captured.splitlines(keepends=True) if "ARB_clip_control" not in ln]
759+
leftover = "".join(kept_lines)
760+
if leftover.strip() and _sys.__stderr__ is not None:
761+
try:
762+
_sys.__stderr__.write(leftover)
763+
except (ValueError, OSError):
764+
# Best-effort forward of non-benign stderr; the
765+
# original __stderr__ may be closed or detached
766+
# (pytest capsys, teardown). Nothing to recover.
767+
pass
768+
if "ARB_clip_control" in captured:
769+
logger.debug(
770+
"Suppressed benign MuJoCo depth warning "
771+
"(surfaced in response text): ARB_clip_control "
772+
"unavailable, depth precision degraded."
773+
)
753774
if "ARB_clip_control" in captured:
754775
# ARB_clip_control missing -> OpenGL depth buffer uses
755776
# default [0,1] range with compressed far-plane precision.
@@ -759,7 +780,7 @@ def render_depth(
759780
# consumers should treat these values as approximate.
760781
self._depth_warn_text = (
761782
"Warning: Depth accuracy limited on this GPU (missing ARB_clip_control). "
762-
"Linearized Min/Max are in meters but precision is degraded "
783+
"Metric Min/Max are in meters but precision is degraded "
763784
"(especially for far-plane pixels) - treat as approximate."
764785
)
765786
else:
@@ -770,25 +791,27 @@ def render_depth(
770791
depth = renderer.render()
771792
renderer.disable_depth_rendering()
772793

773-
# Linearize OpenGL depth buffer to metric depth (meters).
774-
# MuJoCo renderer returns normalized values in [0, 1] where 0 = near,
775-
# 1 = far plane. Convert: z = znear*zfar / (zfar - d*(zfar - znear))
794+
# MuJoCo >= 3.0's ``Renderer.enable_depth_rendering()`` returns
795+
# METRIC depth in meters directly (distance from the camera to the
796+
# first surface along each ray), NOT a normalized [0, 1] OpenGL
797+
# depth buffer. Re-linearizing it with the znear/zfar formula (as
798+
# older OpenGL pipelines required) is wrong and collapses the whole
799+
# frame to znear -- so we consume the array as-is.
776800
#
777-
# On MuJoCo >= 3.0, `model.vis.map.{znear,zfar}` are fractions of
778-
# `model.stat.extent` (the model's bounding scale), NOT absolute
779-
# meters - multiply by extent to get real clip-plane distances.
780-
# pyproject.toml pins mujoco>=3.2, so this convention is safe here.
801+
# pyproject.toml pins mujoco>=3.2, so the metric-depth convention is
802+
# guaranteed. We only sanitize: pixels with no geometry come back as
803+
# the far-clip distance (large finite value); NaN/inf can appear on
804+
# some GL backends and would poison min/max and the PNG, so replace
805+
# them with the far-clip distance before computing bounds.
781806
import numpy as _np
782807

783808
extent = float(self._world._model.stat.extent)
784-
znear = float(self._world._model.vis.map.znear) * extent
785809
zfar = float(self._world._model.vis.map.zfar) * extent
786-
# Avoid division by zero for pixels at exactly the far plane
787-
denom = zfar - depth * (zfar - znear)
788-
denom = _np.where(denom == 0, 1e-10, denom)
789-
depth_m = znear * zfar / denom
790-
# Clamp: pixels at far plane (depth==1) -> zfar
791-
depth_m = _np.clip(depth_m, znear, zfar)
810+
depth_m = _np.asarray(depth, dtype=_np.float32)
811+
depth_m = _np.nan_to_num(depth_m, nan=zfar, posinf=zfar, neginf=0.0)
812+
# Negative depth is non-physical (a surface behind the camera);
813+
# clamp the lower bound at 0 and cap runaway values at the far clip.
814+
depth_m = _np.clip(depth_m, 0.0, zfar)
792815

793816
dmin = float(depth_m.min())
794817
dmax = float(depth_m.max())

tests/simulation/mujoco/test_rendering.py

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -881,16 +881,16 @@ def test_render_all_errors_on_unresolved_requested_cameras(monkeypatch) -> None:
881881
assert "ghost" in r["content"][0]["text"]
882882

883883

884-
# render_depth() metric-depth linearization - GL-free coverage.
884+
# render_depth() metric-depth handling - GL-free coverage.
885885
#
886886
# render_depth() resolves a camera, asks _get_renderer() for an offscreen
887-
# renderer, grabs a normalized [0, 1] OpenGL depth buffer, and linearizes it
888-
# to metric depth (meters) using the model's znear/zfar clip planes scaled by
889-
# stat.extent. The linearization math, the one-time ARB_clip_control warning
890-
# capture, the cached-warning fast path, and the failure handler are all pure
891-
# Python that only needs a compiled model (no live GL), so we drive them with
892-
# a real world plus a scripted fake renderer. This pins the metric conversion
893-
# and the warning contract on every platform, including GL-less CI.
887+
# renderer, and reads MuJoCo's metric depth buffer (meters) directly, only
888+
# sanitizing NaN/inf and clamping to [0, zfar]. The passthrough + sanitation,
889+
# the one-time ARB_clip_control warning capture, the cached-warning fast path,
890+
# and the failure handler are all pure Python that only needs a compiled model
891+
# (no live GL), so we drive them with a real world plus a scripted fake
892+
# renderer. This pins the metric-depth contract and the warning contract on
893+
# every platform, including GL-less CI.
894894

895895

896896
def _depth_world():
@@ -906,10 +906,14 @@ def _depth_world():
906906

907907

908908
class _FakeDepthRenderer:
909-
"""Scripted offscreen renderer: returns a fixed normalized depth buffer.
909+
"""Scripted offscreen renderer: returns a fixed METRIC depth buffer.
910+
911+
MuJoCo >= 3.0 returns metric depth (meters) directly from
912+
``enable_depth_rendering()`` + ``render()`` -- not a normalized [0, 1]
913+
OpenGL buffer -- so the fake mirrors that contract.
910914
911915
Args:
912-
depth: the [0, 1] normalized depth array render() should return.
916+
depth: the metric depth array (meters) render() should return.
913917
stderr_text: text to emit on enable_depth_rendering(), used to drive
914918
the ARB_clip_control warning-capture branch.
915919
raise_on_render: when set, render() raises it (failure-path coverage).
@@ -937,25 +941,52 @@ def render(self):
937941
return self._depth
938942

939943

940-
def test_render_depth_linearizes_normalized_buffer_to_meters(monkeypatch) -> None:
941-
"""A normalized [0,1] depth buffer is converted to metric depth bounded by
942-
the model's znear/zfar clip planes; near pixel -> znear, far pixel -> zfar."""
944+
def test_render_depth_passes_through_metric_depth(monkeypatch) -> None:
945+
"""MuJoCo >= 3.0 returns METRIC depth (meters) directly, so render_depth
946+
must surface those meters as-is -- NOT re-linearize them with a znear/zfar
947+
formula (which wrongly collapses the whole frame to znear).
948+
949+
Regression for the depth bug: the engine treated the renderer output as a
950+
normalized [0, 1] OpenGL buffer and re-projected it through
951+
``znear*zfar / (zfar - d*(zfar-znear))``, so a scene with surfaces at
952+
1.1 m / 2.5 m reported Min==Max==znear (0.01 m). The renderer already hands
953+
back meters; the engine must report them unchanged (modulo NaN/inf
954+
sanitation and a [0, zfar] clamp)."""
955+
np = pytest.importorskip("numpy")
956+
sim = _depth_world()
957+
try:
958+
# Metric depths in meters: nearest surface 0.75 m, farthest 2.40 m.
959+
buf = np.array([[0.75, 2.40], [1.50, 1.50]], dtype=np.float32)
960+
monkeypatch.setattr(sim, "_get_renderer", lambda w, h: _FakeDepthRenderer(buf))
961+
962+
r = sim.render_depth(camera_name="cam_a", width=2, height=2)
963+
assert r["status"] == "success", r
964+
payload = next(b["json"] for b in r["content"] if isinstance(b, dict) and "json" in b)
965+
assert payload["depth_min"] == pytest.approx(0.75, rel=1e-4)
966+
assert payload["depth_max"] == pytest.approx(2.40, rel=1e-4)
967+
assert "Depth 2x2 from 'cam_a'" in r["content"][0]["text"]
968+
finally:
969+
sim.destroy()
970+
971+
972+
def test_render_depth_sanitizes_nan_and_inf(monkeypatch) -> None:
973+
"""NaN/inf pixels (some GL backends emit them for empty rays) must not
974+
poison the metric min/max or the PNG -- they're replaced with the far-clip
975+
distance, and negative depth is clamped to 0."""
943976
np = pytest.importorskip("numpy")
944977
sim = _depth_world()
945978
try:
946979
extent = float(sim._world._model.stat.extent)
947-
znear = float(sim._world._model.vis.map.znear) * extent
948980
zfar = float(sim._world._model.vis.map.zfar) * extent
949-
# 0.0 -> near plane, 1.0 -> far plane, plus a mid value.
950-
buf = np.array([[0.0, 1.0], [0.5, 0.5]], dtype=np.float32)
981+
buf = np.array([[0.5, np.nan], [np.inf, -1.0]], dtype=np.float32)
951982
monkeypatch.setattr(sim, "_get_renderer", lambda w, h: _FakeDepthRenderer(buf))
952983

953984
r = sim.render_depth(camera_name="cam_a", width=2, height=2)
954985
assert r["status"] == "success", r
955986
payload = next(b["json"] for b in r["content"] if isinstance(b, dict) and "json" in b)
956-
assert payload["depth_min"] == pytest.approx(znear, rel=1e-4)
987+
# min comes from the clamped -1.0 -> 0.0; max from nan/inf -> zfar.
988+
assert payload["depth_min"] == pytest.approx(0.0, abs=1e-6)
957989
assert payload["depth_max"] == pytest.approx(zfar, rel=1e-4)
958-
assert "Depth 2x2 from 'cam_a'" in r["content"][0]["text"]
959990
finally:
960991
sim.destroy()
961992

0 commit comments

Comments
 (0)