Skip to content

Commit 7ddc048

Browse files
Track camera origins in Newton visualizers
Previously, Newton visualizers interpreted eye and lookat as absolute world coordinates. The Kit visualizer already supported world, environment, and asset origins, so the same visualizer config produced different recordings when used with Newton. Add the Kit origin selectors to the Newton visualizer config and apply the resolved origin before pushing the camera pose to GL or RTX. Asset origins are refreshed during normal steps and on RGB capture so recorded frames follow moving assets.
1 parent 119c172 commit 7ddc048

4 files changed

Lines changed: 184 additions & 2 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed Newton visualizer cameras ignoring environment and asset origins when interpreting ``eye`` and ``lookat``.

source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,8 @@ def __init__(self, cfg: NewtonVisualizerCfg):
971971
self._scene_cameras: dict = {}
972972
self._scene_camera_names: list[str] = []
973973
self._active_camera_idx: int = 0
974+
self._interactive_scene = None
975+
self._viewer_origin: torch.Tensor | None = None
974976

975977
# ------------------------------------------------------------------
976978
# Shared lifecycle
@@ -1040,6 +1042,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None:
10401042
self._viewer.set_visible_worlds(self._resolved_visible_env_ids)
10411043
self._viewer.set_world_offsets(self.cfg.world_spacing)
10421044
self._apply_camera_focal_length()
1045+
self._setup_initial_camera_origin(apply=False)
10431046
initial_pose = self._resolve_initial_camera_pose()
10441047
self._apply_camera_pose(initial_pose)
10451048
self._viewer._paused = False
@@ -1113,6 +1116,9 @@ def step(self, dt: float) -> None:
11131116
self._sim_time += dt
11141117
self._step_counter += 1
11151118

1119+
if self.cfg.origin_type == "asset":
1120+
self._update_asset_tracking_camera()
1121+
11161122
# Headless mode renders on demand via render_rgb_array(). Keep the latest
11171123
# physics state available without paying the per-step render cost.
11181124
if self._runtime_headless:
@@ -1268,6 +1274,15 @@ def set_camera_view(
12681274
self.cfg.lookat = target_t
12691275
self._apply_camera_pose((eye_t, target_t))
12701276

1277+
def reapply_origin(self) -> None:
1278+
"""Recompute the camera position from the current origin config."""
1279+
self._setup_initial_camera_origin()
1280+
1281+
@property
1282+
def viewer_origin(self) -> torch.Tensor | None:
1283+
"""World-space origin offset applied to the configured eye and look-at target."""
1284+
return self._viewer_origin
1285+
12711286
# ------------------------------------------------------------------
12721287
# Hook methods — override in subclasses
12731288
# ------------------------------------------------------------------
@@ -1323,7 +1338,86 @@ def _uses_streaming_view(self) -> bool:
13231338

13241339
def _resolve_initial_camera_pose(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
13251340
"""Resolve initial camera pose from config or USD camera path."""
1326-
return self._resolve_cfg_camera_pose(type(self).__name__)
1341+
return self._camera_pose_with_origin(self._resolve_cfg_camera_pose(type(self).__name__))
1342+
1343+
def _setup_initial_camera_origin(self, *, apply: bool = True) -> None:
1344+
"""Position the viewer camera according to :attr:`NewtonVisualizerCfg.origin_type`."""
1345+
from isaaclab.sim import SimulationContext # noqa: PLC0415
1346+
1347+
self._interactive_scene = getattr(SimulationContext.instance(), "_interactive_scene", None)
1348+
self._viewer_origin = self._resolve_configured_origin()
1349+
1350+
if apply:
1351+
self._apply_viewer_origin_to_camera()
1352+
1353+
def _resolve_configured_origin(self) -> torch.Tensor:
1354+
"""Resolve the configured camera origin."""
1355+
if self.cfg.origin_type == "world":
1356+
return torch.zeros(3)
1357+
if self.cfg.origin_type == "env":
1358+
return self._resolve_env_origin()
1359+
if self.cfg.origin_type == "asset":
1360+
return self._resolve_asset_origin()
1361+
logger.warning("[NewtonVisualizer] Unknown origin_type '%s'; defaulting to world.", self.cfg.origin_type)
1362+
return torch.zeros(3)
1363+
1364+
def _resolve_env_origin(self) -> torch.Tensor:
1365+
"""Resolve the configured environment origin."""
1366+
scene = self._interactive_scene
1367+
if scene is None:
1368+
logger.warning("[NewtonVisualizer] origin_type='env' requested but no scene is registered yet.")
1369+
return torch.zeros(3)
1370+
1371+
num_envs = scene.num_envs
1372+
if not (0 <= self.cfg.origin_env_index < num_envs):
1373+
raise ValueError(
1374+
f"[NewtonVisualizer] origin_env_index {self.cfg.origin_env_index} is out of range "
1375+
f"[0, {num_envs - 1}] for origin_type='env'."
1376+
)
1377+
return scene.env_origins[self.cfg.origin_env_index]
1378+
1379+
def _resolve_asset_origin(self) -> torch.Tensor:
1380+
"""Resolve the initial asset origin."""
1381+
if self.cfg.origin_track_path is None:
1382+
raise ValueError("[NewtonVisualizer] origin_type='asset' requires origin_track_path to be set.")
1383+
return torch.zeros(3)
1384+
1385+
def _update_asset_tracking_camera(self) -> None:
1386+
"""Update the viewer camera to track an asset root or body."""
1387+
scene = self._interactive_scene
1388+
if scene is None or self.cfg.origin_track_path is None:
1389+
return
1390+
asset_name, _, body_name = self.cfg.origin_track_path.partition("/")
1391+
try:
1392+
asset = scene[asset_name]
1393+
except KeyError:
1394+
return
1395+
if body_name:
1396+
body_ids, _ = asset.find_bodies(body_name)
1397+
origin = asset.data.body_pos_w.torch[self.cfg.origin_env_index, body_ids[0]]
1398+
else:
1399+
origin = asset.data.root_pos_w.torch[self.cfg.origin_env_index]
1400+
self._viewer_origin = origin
1401+
self._apply_viewer_origin_to_camera()
1402+
1403+
def _apply_viewer_origin_to_camera(self) -> None:
1404+
"""Compute absolute eye/target from :attr:`_viewer_origin` and push to the viewer."""
1405+
self._apply_camera_pose(self._resolve_initial_camera_pose())
1406+
1407+
def _camera_pose_with_origin(
1408+
self,
1409+
pose: tuple[tuple[float, float, float], tuple[float, float, float]],
1410+
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
1411+
if self._viewer_origin is None:
1412+
return pose
1413+
origin = self._viewer_origin.detach().cpu().numpy()
1414+
eye = np.array(pose[0], dtype=float) + origin
1415+
target = np.array(pose[1], dtype=float) + origin
1416+
return (float(eye[0]), float(eye[1]), float(eye[2])), (
1417+
float(target[0]),
1418+
float(target[1]),
1419+
float(target[2]),
1420+
)
13271421

13281422
def _resolve_streaming_renderer_cfg(self):
13291423
"""Return the renderer cfg for the auto-created streaming camera.
@@ -1919,6 +2013,8 @@ def render_rgb_array(self) -> np.ndarray:
19192013
"""
19202014
if self._viewer is None:
19212015
raise RuntimeError("NewtonGLVisualizer must be initialized before capturing an RGB frame.")
2016+
if self.cfg.origin_type == "asset":
2017+
self._update_asset_tracking_camera()
19222018
if self._runtime_headless and self._state is not None and not self._viewer.is_paused():
19232019
self._pre_step()
19242020
self._viewer.begin_frame(self._sim_time)
@@ -2178,6 +2274,8 @@ def render_rgb_array(self) -> np.ndarray | None:
21782274
"""
21792275
if self._viewer is None:
21802276
return None
2277+
if self.cfg.origin_type == "asset":
2278+
self._update_asset_tracking_camera()
21812279
if self._runtime_headless and self._state is not None and not self._viewer.is_paused():
21822280
self._pre_step()
21832281
self._viewer.begin_frame(self._sim_time)

source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ def __post_init__(self):
4040
window_height: int = 1080
4141
"""Window height in pixels."""
4242

43+
origin_type: str = "world"
44+
"""Frame in which :attr:`~isaaclab.visualizers.VisualizerCfg.eye` and
45+
:attr:`~isaaclab.visualizers.VisualizerCfg.lookat` are interpreted.
46+
47+
Options:
48+
49+
* ``"world"``: global origin.
50+
* ``"env"``: origin of the environment at :attr:`origin_env_index`.
51+
* ``"asset"``: a scene asset (or body) specified by :attr:`origin_track_path`.
52+
"""
53+
54+
origin_env_index: int = 0
55+
"""Index of the environment used as the viewer camera origin.
56+
57+
Only meaningful when :attr:`origin_type` is ``"env"`` or ``"asset"``.
58+
"""
59+
60+
origin_track_path: str | None = None
61+
"""Asset tracking path for the viewer camera origin.
62+
63+
Format: ``"<asset_name>"`` to track the asset root, or ``"<asset_name>/<body_name>"``
64+
to track a specific body on the asset. Required when :attr:`origin_type` is ``"asset"``.
65+
66+
Examples::
67+
68+
origin_track_path = "robot" # track robot root
69+
origin_track_path = "robot/panda_hand" # track panda_hand body on robot
70+
"""
71+
4372
headless: bool = False
4473
"""Run the Newton viewer without requiring a display server."""
4574

source/isaaclab_visualizers/test/test_newton_adapter.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from isaaclab_visualizers.newton import newton_visualization_markers as newton_markers
2424
from isaaclab_visualizers.newton import newton_visualizer as newton_visualizer_module
25-
from isaaclab_visualizers.newton.newton_visualizer import NewtonViewerGL, _eye_lookat_to_pitch_yaw
25+
from isaaclab_visualizers.newton.newton_visualizer import NewtonViewerGL, NewtonVisualizer, _eye_lookat_to_pitch_yaw
2626
from isaaclab_visualizers.newton_adapter import (
2727
VISUALIZER_INFINITE_PLANE_SIZE,
2828
apply_viewer_visible_worlds,
@@ -125,6 +125,57 @@ def test_newton_visualizer_cfg_exposes_viewer_options():
125125
assert cfg.particle_color == (0.1, 0.2, 0.3)
126126

127127

128+
def test_newton_visualizer_offsets_cfg_camera_by_viewer_origin():
129+
visualizer = NewtonVisualizer.__new__(NewtonVisualizer)
130+
visualizer._viewer_origin = torch.tensor([10.0, 20.0, 30.0])
131+
132+
pose = visualizer._camera_pose_with_origin(((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)))
133+
134+
assert pose == ((11.0, 22.0, 33.0), (14.0, 25.0, 36.0))
135+
136+
137+
def test_newton_visualizer_set_camera_view_uses_absolute_coordinates():
138+
visualizer = NewtonVisualizer.__new__(NewtonVisualizer)
139+
visualizer.cfg = SimpleNamespace(eye=(0.0, 0.0, 0.0), lookat=(1.0, 0.0, 0.0))
140+
visualizer._viewer_origin = torch.tensor([10.0, 20.0, 30.0])
141+
visualizer._apply_camera_pose = Mock()
142+
143+
NewtonVisualizer.set_camera_view(visualizer, (1.0, 2.0, 3.0), (4.0, 5.0, 6.0))
144+
145+
assert visualizer.cfg.eye == (1.0, 2.0, 3.0)
146+
assert visualizer.cfg.lookat == (4.0, 5.0, 6.0)
147+
visualizer._apply_camera_pose.assert_called_once_with(((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)))
148+
149+
150+
def test_newton_visualizer_reapply_origin_updates_env_camera(monkeypatch: pytest.MonkeyPatch):
151+
import isaaclab.sim as sim_utils
152+
153+
scene = SimpleNamespace(num_envs=2, env_origins=torch.tensor([[0.0, 0.0, 0.0], [10.0, 20.0, 30.0]]))
154+
context = SimpleNamespace(_interactive_scene=scene)
155+
156+
class _SimulationContext:
157+
@staticmethod
158+
def instance():
159+
return context
160+
161+
monkeypatch.setattr(sim_utils, "SimulationContext", _SimulationContext)
162+
163+
visualizer = NewtonVisualizer.__new__(NewtonVisualizer)
164+
visualizer.cfg = SimpleNamespace(
165+
eye=(1.0, 2.0, 3.0),
166+
lookat=(4.0, 5.0, 6.0),
167+
origin_type="env",
168+
origin_env_index=1,
169+
origin_track_path=None,
170+
)
171+
visualizer._resolve_cfg_camera_pose = Mock(return_value=(visualizer.cfg.eye, visualizer.cfg.lookat))
172+
visualizer._apply_camera_pose = Mock()
173+
174+
NewtonVisualizer.reapply_origin(visualizer)
175+
176+
visualizer._apply_camera_pose.assert_called_once_with(((11.0, 22.0, 33.0), (14.0, 25.0, 36.0)))
177+
178+
128179
def test_newton_marker_registry_lifecycle(monkeypatch: pytest.MonkeyPatch):
129180
"""Construction caches the registry; close survives context teardown and is idempotent."""
130181

0 commit comments

Comments
 (0)