Skip to content

Commit 7d3ad7c

Browse files
Add draw_text and draw_overlay methods to Renderer
Add text rendering capabilities to the Python Renderer class: - draw_text(): renders 2D text at (x, y) with RGB color support - draw_overlay(): renders text overlay at grid positions This exposes mjr_text and mjr_overlay functionality in the Renderer utility, addressing the feature request in issue #1864. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bf05bb1 commit 7d3ad7c

File tree

2 files changed

+184
-3
lines changed

2 files changed

+184
-3
lines changed

python/mujoco/renderer.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
1414
# ==============================================================================
1515
"""Defines a renderer class for the MuJoCo Python native bindings."""
1616

17-
from typing import Optional, Union
17+
from typing import Optional, Tuple, Union
18+
19+
import numpy as np
1820

1921
from mujoco import _enums
2022
from mujoco import _functions
2123
from mujoco import _render
2224
from mujoco import _structs
2325
from mujoco import gl_context
24-
import numpy as np
2526

2627

2728
class Renderer:
@@ -96,6 +97,9 @@ def __init__(
9697
self._depth_rendering = False
9798
self._segmentation_rendering = False
9899

100+
# Pending text operations to be drawn after mjr_render.
101+
self._text_overlays = []
102+
99103
@property
100104
def model(self):
101105
return self._model
@@ -126,6 +130,67 @@ def enable_segmentation_rendering(self):
126130
def disable_segmentation_rendering(self):
127131
self._segmentation_rendering = False
128132

133+
def draw_text(
134+
self,
135+
text: str,
136+
x: float,
137+
y: float,
138+
rgb: Tuple[float, float, float] = (1.0, 1.0, 1.0),
139+
font: _enums.mjtFont = _enums.mjtFont.mjFONT_NORMAL,
140+
) -> None:
141+
"""Queues 2D text to be rendered on the next frame.
142+
143+
Text is drawn at the specified (x, y) coordinates in relative screen space
144+
(0 to 1), with the specified RGB color.
145+
146+
Args:
147+
text: The text string to render.
148+
x: Horizontal position in relative coordinates (0 = left, 1 = right).
149+
y: Vertical position in relative coordinates (0 = bottom, 1 = top).
150+
rgb: Tuple of (red, green, blue) color values, each in range [0, 1].
151+
font: Font style from mjtFont enum (NORMAL, SHADOW, or BIG).
152+
"""
153+
self._text_overlays.append(('text', text, x, y, rgb, font))
154+
155+
def draw_overlay(
156+
self,
157+
title: str,
158+
body: str = '',
159+
position: _enums.mjtGridPos = _enums.mjtGridPos.mjGRID_TOPLEFT,
160+
font: _enums.mjtFont = _enums.mjtFont.mjFONT_NORMAL,
161+
) -> None:
162+
"""Queues an overlay text to be rendered on the next frame.
163+
164+
Overlay text is drawn at a grid position on the viewport.
165+
166+
Args:
167+
title: The title text (rendered in bold).
168+
body: The body text (rendered below the title).
169+
position: Grid position from mjtGridPos enum.
170+
font: Font style from mjtFont enum (NORMAL, SHADOW, or BIG).
171+
"""
172+
self._text_overlays.append(('overlay', title, body, position, font))
173+
174+
def _draw_pending_text(self) -> None:
175+
"""Draws all pending text operations."""
176+
for op in self._text_overlays:
177+
if op[0] == 'text':
178+
_, text, x, y, rgb, font = op
179+
_render.mjr_text(
180+
font.value, text, self._mjr_context, x, y, rgb[0], rgb[1], rgb[2]
181+
)
182+
elif op[0] == 'overlay':
183+
_, title, body, position, font = op
184+
_render.mjr_overlay(
185+
font.value,
186+
position.value,
187+
self._rect,
188+
title,
189+
body,
190+
self._mjr_context,
191+
)
192+
self._text_overlays.clear()
193+
129194
def render(self, *, out: Optional[np.ndarray] = None) -> np.ndarray:
130195
"""Renders the scene as a numpy array of pixel values.
131196
@@ -179,6 +244,14 @@ def render(self, *, out: Optional[np.ndarray] = None) -> np.ndarray:
179244

180245
# Render scene and read contents of RGB and depth buffers.
181246
_render.mjr_render(self._rect, self._scene, self._mjr_context)
247+
248+
# Draw any pending text overlays (only for RGB rendering).
249+
if not self._depth_rendering and not self._segmentation_rendering:
250+
self._draw_pending_text()
251+
else:
252+
# Clear pending text without rendering for depth/segmentation modes.
253+
self._text_overlays.clear()
254+
182255
if self._depth_rendering:
183256
_render.mjr_readPixels(None, out, self._rect, self._mjr_context)
184257

python/mujoco/renderer_test.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616

1717
from absl.testing import absltest
1818
from absl.testing import parameterized
19-
import mujoco
2019
import numpy as np
2120

21+
import mujoco
22+
2223

2324
@absltest.skipUnless(
2425
hasattr(mujoco, 'GLContext'), 'MuJoCo rendering is disabled'
@@ -157,6 +158,113 @@ def test_renderer_output_with_out(self):
157158
with self.assertRaises(ValueError):
158159
renderer.render(out=np.zeros((*failing_render_size, 3), np.uint8))
159160

161+
def test_renderer_draw_text(self):
162+
xml = """
163+
<mujoco>
164+
<worldbody>
165+
<camera name="closeup" pos="0 -6 0" xyaxes="1 0 0 0 1 100"/>
166+
<geom name="white_box" type="box" size="1 1 1" rgba="1 1 1 1"/>
167+
</worldbody>
168+
</mujoco>
169+
"""
170+
model = mujoco.MjModel.from_xml_string(xml)
171+
data = mujoco.MjData(model)
172+
with mujoco.Renderer(model, 50, 50) as renderer:
173+
mujoco.mj_forward(model, data)
174+
renderer.update_scene(data, 'closeup')
175+
176+
# Render without text.
177+
pixels_no_text = renderer.render().copy()
178+
179+
# Render with text.
180+
renderer.update_scene(data, 'closeup')
181+
renderer.draw_text('Hello', 0.5, 0.5, rgb=(1.0, 0.0, 0.0))
182+
pixels_with_text = renderer.render()
183+
184+
# Pixels should be different when text is drawn.
185+
self.assertFalse((pixels_no_text == pixels_with_text).all())
186+
187+
def test_renderer_draw_overlay(self):
188+
xml = """
189+
<mujoco>
190+
<worldbody>
191+
<camera name="closeup" pos="0 -6 0" xyaxes="1 0 0 0 1 100"/>
192+
<geom name="white_box" type="box" size="1 1 1" rgba="1 1 1 1"/>
193+
</worldbody>
194+
</mujoco>
195+
"""
196+
model = mujoco.MjModel.from_xml_string(xml)
197+
data = mujoco.MjData(model)
198+
with mujoco.Renderer(model, 50, 50) as renderer:
199+
mujoco.mj_forward(model, data)
200+
renderer.update_scene(data, 'closeup')
201+
202+
# Render without overlay.
203+
pixels_no_overlay = renderer.render().copy()
204+
205+
# Render with overlay.
206+
renderer.update_scene(data, 'closeup')
207+
renderer.draw_overlay('Title', 'Body text')
208+
pixels_with_overlay = renderer.render()
209+
210+
# Pixels should be different when overlay is drawn.
211+
self.assertFalse((pixels_no_overlay == pixels_with_overlay).all())
212+
213+
def test_renderer_text_cleared_after_render(self):
214+
xml = """
215+
<mujoco>
216+
<worldbody>
217+
<camera name="closeup" pos="0 -6 0" xyaxes="1 0 0 0 1 100"/>
218+
<geom name="white_box" type="box" size="1 1 1" rgba="1 1 1 1"/>
219+
</worldbody>
220+
</mujoco>
221+
"""
222+
model = mujoco.MjModel.from_xml_string(xml)
223+
data = mujoco.MjData(model)
224+
with mujoco.Renderer(model, 50, 50) as renderer:
225+
mujoco.mj_forward(model, data)
226+
renderer.update_scene(data, 'closeup')
227+
228+
# Render with text.
229+
renderer.draw_text('Hello', 0.5, 0.5)
230+
pixels_with_text = renderer.render().copy()
231+
232+
# Second render should not have text (queue was cleared).
233+
renderer.update_scene(data, 'closeup')
234+
pixels_second_render = renderer.render()
235+
236+
# First render had text, second should match baseline (no text).
237+
renderer.update_scene(data, 'closeup')
238+
pixels_baseline = renderer.render()
239+
self.assertTrue((pixels_second_render == pixels_baseline).all())
240+
241+
def test_renderer_text_not_drawn_in_depth_mode(self):
242+
xml = """
243+
<mujoco>
244+
<worldbody>
245+
<camera name="closeup" pos="0 -6 0" xyaxes="1 0 0 0 1 100"/>
246+
<geom name="white_box" type="box" size="1 1 1" rgba="1 1 1 1"/>
247+
</worldbody>
248+
</mujoco>
249+
"""
250+
model = mujoco.MjModel.from_xml_string(xml)
251+
data = mujoco.MjData(model)
252+
with mujoco.Renderer(model, 50, 50) as renderer:
253+
mujoco.mj_forward(model, data)
254+
renderer.update_scene(data, 'closeup')
255+
renderer.enable_depth_rendering()
256+
257+
# Render depth without text.
258+
depth_no_text = renderer.render().copy()
259+
260+
# Render depth with text (should be ignored).
261+
renderer.update_scene(data, 'closeup')
262+
renderer.draw_text('Hello', 0.5, 0.5)
263+
depth_with_text = renderer.render()
264+
265+
# Depth images should be identical (text not drawn in depth mode).
266+
self.assertTrue((depth_no_text == depth_with_text).all())
267+
160268

161269
if __name__ == '__main__':
162270
absltest.main()

0 commit comments

Comments
 (0)