Skip to content

Commit 70bc7be

Browse files
yuvaltassacopybara-github
authored andcommitted
Add additional data fields that can be reported by rangefinder sensors.
PiperOrigin-RevId: 848316991 Change-Id: Idbf7ba81b4da711a22c23302c8782ab2b0b98d82
1 parent f2e9097 commit 70bc7be

File tree

27 files changed

+714
-135
lines changed

27 files changed

+714
-135
lines changed

doc/XMLreference.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6382,12 +6382,46 @@ This element creates a rangefinder.
63826382
measurements in this case is equal to product of the camera's width and height
63836383
:ref:`resolutions<body-camera-resolution>`.
63846384

6385+
.. image:: images/XMLreference/rfcamera.png
6386+
:width: 45%
6387+
:align: right
6388+
:target: https://github.com/google-deepmind/mujoco/blob/main/test/engine/testdata/sensor/rfcamera.xml
6389+
63856390
If a ray does not intersect any geom surface, the sensor output is -1. If the origin of the ray is inside a geom, the
63866391
surface is still detected. Geoms attached to the same body as the sensor site/camera are excluded. Invisible geoms,
63876392
defined as geoms whose rgba (or whose material rgba) has alpha=0, are also excluded. Note however that geoms made
63886393
invisible in the visualizer by disabling their geom group are not excluded; this is because sensor calculations are
63896394
independent of the visualizer.
63906395

6396+
The image on the right (click to see the model being visualized) shows two rangefinder sensors attached to a perspective and
6397+
an orthographic camera, with frustums visualized. Both cameras have 4x4 resolution, for 16 rays each. The rangefinder
6398+
sensors report :at:`data` = :at-val:`"dist point normal"` (see below), so we can see the rays (lines), the intersection
6399+
points (spheres) and the surface normals (arrows).
6400+
6401+
.. _sensor-rangefinder-data:
6402+
6403+
:at:`data`: :at-val:`[dist, dir, origin, point, normal, depth], "dist"`
6404+
By default, the rangefinder outputs a distance measurement, as described above. However, it is also possible to
6405+
specify a set of output data fields. The :at:`data` attribute can contain **multiple sequential data types**, as long
6406+
as the relative order---as listed above---is maintained. For example, :at:`data` = :at-val:`"dist point normal"` will
6407+
return 7 numbers per ray, while :at:`data` = :at-val:`"point origin"` is an error because :at-val:`origin` must come
6408+
before :at-val:`point`.
6409+
6410+
- :at-val:`dist` **real(1)**: The distance from the ray origin to the nearest geom surface, -1 if no surface was hit.
6411+
If this data type is included, rays will be visualized as lines.
6412+
- :at-val:`dir` **real(3)**: Normalized direction of the ray, or (0, 0, 0) if no surface was hit.
6413+
- :at-val:`origin` **real(3)**: The point from which the ray emanates (global frame). For sites and perspective
6414+
cameras, this is the site/camera xpos. However for orthographic cameras, ray origins are spatially distributed
6415+
along the image plane.
6416+
- :at-val:`point` **real(3)**: The point where the ray intersects the nearest geom surface in the global frame, or
6417+
(0, 0, 0) if no surface was hit. If this data type is included, intersection points will be visualized as spheres.
6418+
- :at-val:`normal`: **real(3)**: The geom surface normal at the point where the ray intersects it, in the global
6419+
frame, or (0, 0, 0) if no surface was hit. Note that normals always point towards the outside of the geom surface,
6420+
regardless of the ray origin. If this data type is included along with either :at-val:`dist` or :at-val:`point`,
6421+
normals will be visualized as arrows at the intersection points.
6422+
- :at-val:`depth`: **real(1)**: The distance of the hit point from the camera plane, -1 if no surface was hit. Note
6423+
that this depth sematic corresponds to depth images in the computer graphics sense.
6424+
63916425
.. _sensor-rangefinder-name:
63926426

63936427
.. _sensor-rangefinder-noise:

doc/XMLschema.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -940,9 +940,9 @@
940940
| :ref:`rangefinder | \* | :class: mjcf-attributes |
941941
| <sensor-rangefinder>` | | |
942942
| | | +-----------------------------------------------------------------+-----------------------------------------------------------------+-----------------------------------------------------------------+-----------------------------------------------------------------+ |
943-
| | | | :ref:`name<sensor-rangefinder-name>` | :ref:`site<sensor-rangefinder-site>` | :ref:`camera<sensor-rangefinder-camera>` | :ref:`cutoff<sensor-rangefinder-cutoff>` | |
943+
| | | | :ref:`name<sensor-rangefinder-name>` | :ref:`site<sensor-rangefinder-site>` | :ref:`camera<sensor-rangefinder-camera>` | :ref:`data<sensor-rangefinder-data>` | |
944944
| | | +-----------------------------------------------------------------+-----------------------------------------------------------------+-----------------------------------------------------------------+-----------------------------------------------------------------+ |
945-
| | | | :ref:`noise<sensor-rangefinder-noise>` | :ref:`user<sensor-rangefinder-user>` | | | |
945+
| | | | :ref:`cutoff<sensor-rangefinder-cutoff>` | :ref:`noise<sensor-rangefinder-noise>` | :ref:`user<sensor-rangefinder-user>` | | |
946946
| | | +-----------------------------------------------------------------+-----------------------------------------------------------------+-----------------------------------------------------------------+-----------------------------------------------------------------+ |
947947
+------------------------------------+----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
948948
| |_| sensor |br| |_| |L| | | .. table:: |

doc/changelog.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,24 @@ Upcoming version (not yet released)
1818

1919
General
2020
^^^^^^^
21+
22+
.. image:: images/XMLreference/rfcamera.png
23+
:width: 45%
24+
:align: right
25+
:target: https://github.com/google-deepmind/mujoco/blob/main/test/engine/testdata/sensor/rfcamera.xml
26+
2127
- Camera frustum visualization is now triggered by setting :ref:`resolution<body-camera-resolution>` to values larger
2228
than 1. Relatedly, frustum visualization also works for :ref:`orthographic<body-camera-projection>` cameras.
2329
- Rangefinder sensors can now be attached to a camera using the :ref:`ragefinder/camera<sensor-rangefinder-camera>`
24-
attribute. In this case, the sensor will cast multiple ray, one for each camera pixel.
30+
attribute. In this case, the sensor will cast multiple ray, one for each pixel.
31+
- Rangefinder sensors can now now report various kinds of information besides ray distances, including surface normals.
32+
See :ref:`rangefinder<sensor-rangefinder>` for details.
2533
- Non-breaking ABI changes:
2634

2735
- The type of the ``sig`` (signature) argument of :ref:`mj_stateSize` and related functions has been changed from
2836
``unsigned int`` to ``int``. Before this change, invalid negative arguments passed to this function would result in
2937
a silent implicit cast, now negativity will trigger an error.
30-
- Added a :ref:`depth<mjtRndFlag>` rendering flag
38+
- Added a :ref:`depth<mjtRndFlag>` rendering flag.
3139

3240
MJX
3341
^^^
562 KB
Loading

doc/includes/references.h

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,8 +774,18 @@ typedef enum mjtConDataField_ { // data fields returned by contact sensors
774774
mjCONDATA_NORMAL, // contact frame normal
775775
mjCONDATA_TANGENT, // contact frame first tangent
776776

777-
mjNCONDATA = 7 // number of contact sensor data fields
777+
mjNCONDATA // number of contact sensor data fields
778778
} mjtConDataField;
779+
typedef enum mjtRayDataField_ { // data fields returned by rangefinder sensors
780+
mjRAYDATA_DIST = 0, // distance from ray origin to nearest surface
781+
mjRAYDATA_DIR, // normalized ray direction
782+
mjRAYDATA_ORIGIN, // ray origin
783+
mjRAYDATA_POINT, // point at which ray intersects nearest surface
784+
mjRAYDATA_NORMAL, // surface normal at intersection point
785+
mjRAYDATA_DEPTH, // depth along z-axis
786+
787+
mjNRAYDATA // number of rangefinder sensor data fields
788+
} mjtRayDataField;
779789
typedef enum mjtSameFrame_ { // frame alignment of bodies with their children
780790
mjSAMEFRAME_NONE = 0, // no alignment
781791
mjSAMEFRAME_BODY, // frame is same as body frame

include/mujoco/mjmodel.h

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,10 +400,22 @@ typedef enum mjtConDataField_ { // data fields returned by contact sensors
400400
mjCONDATA_NORMAL, // contact frame normal
401401
mjCONDATA_TANGENT, // contact frame first tangent
402402

403-
mjNCONDATA = 7 // number of contact sensor data fields
403+
mjNCONDATA // number of contact sensor data fields
404404
} mjtConDataField;
405405

406406

407+
typedef enum mjtRayDataField_ { // data fields returned by rangefinder sensors
408+
mjRAYDATA_DIST = 0, // distance from ray origin to nearest surface
409+
mjRAYDATA_DIR, // normalized ray direction
410+
mjRAYDATA_ORIGIN, // ray origin
411+
mjRAYDATA_POINT, // point at which ray intersects nearest surface
412+
mjRAYDATA_NORMAL, // surface normal at intersection point
413+
mjRAYDATA_DEPTH, // depth along z-axis
414+
415+
mjNRAYDATA // number of rangefinder sensor data fields
416+
} mjtRayDataField;
417+
418+
407419
typedef enum mjtSameFrame_ { // frame alignment of bodies with their children
408420
mjSAMEFRAME_NONE = 0, // no alignment
409421
mjSAMEFRAME_BODY, // frame is same as body frame

python/mujoco/introspect/enums.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,20 @@
416416
('mjNCONDATA', 7),
417417
]),
418418
)),
419+
('mjtRayDataField',
420+
EnumDecl(
421+
name='mjtRayDataField',
422+
declname='enum mjtRayDataField_',
423+
values=dict([
424+
('mjRAYDATA_DIST', 0),
425+
('mjRAYDATA_DIR', 1),
426+
('mjRAYDATA_ORIGIN', 2),
427+
('mjRAYDATA_POINT', 3),
428+
('mjRAYDATA_NORMAL', 4),
429+
('mjRAYDATA_DEPTH', 5),
430+
('mjNRAYDATA', 6),
431+
]),
432+
)),
419433
('mjtSameFrame',
420434
EnumDecl(
421435
name='mjtSameFrame',

python/mujoco/specs_test.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,5 +1662,126 @@ def test_from_zip(self):
16621662
string_spec.compile()
16631663
self.assertEqual(spec.to_xml(), string_spec.to_xml())
16641664

1665+
def test_rangefinder_sensor(self):
1666+
"""Test rangefinder sensor with mjSpec, iterative model building."""
1667+
# Raydata field enum values for dataspec bitfield
1668+
rd = mujoco.mjtRayDataField
1669+
dist_val = int(rd.mjRAYDATA_DIST)
1670+
dir_val = int(rd.mjRAYDATA_DIR)
1671+
origin_val = int(rd.mjRAYDATA_ORIGIN)
1672+
point_val = int(rd.mjRAYDATA_POINT)
1673+
normal_val = int(rd.mjRAYDATA_NORMAL)
1674+
depth_val = int(rd.mjRAYDATA_DEPTH)
1675+
1676+
# Step 1: Create a rangefinder sensor attached to a site, no dataspec set.
1677+
# Note: site goes on a child body because rangefinder excludes the site's
1678+
# parent body from ray casting.
1679+
spec = mujoco.MjSpec()
1680+
sensor_body = spec.worldbody.add_body(name='sensor_body', pos=[0, 0, 1])
1681+
sensor_body.add_site(name='rf_site', zaxis=[0, 0, -1])
1682+
rf_sensor = spec.add_sensor(
1683+
name='rf',
1684+
type=mujoco.mjtSensor.mjSENS_RANGEFINDER,
1685+
objtype=mujoco.mjtObj.mjOBJ_SITE,
1686+
objname='rf_site',
1687+
)
1688+
1689+
# This should fail: data spec (intprm[0]) must be positive
1690+
with self.assertRaisesWithPredicateMatch(
1691+
ValueError,
1692+
lambda e: 'data spec (intprm[0]) must be positive' in str(e)
1693+
):
1694+
spec.compile()
1695+
1696+
# Step 2: Set dataspec to just mjRAYDATA_DIST
1697+
rf_sensor.intprm[0] = 1 << dist_val
1698+
model = spec.compile()
1699+
data = mujoco.MjData(model)
1700+
mujoco.mj_forward(model, data)
1701+
1702+
# With no geometry, the ray should miss: dist = -1
1703+
self.assertEqual(model.nsensordata, 1)
1704+
self.assertEqual(data.bind(rf_sensor).data[0], -1)
1705+
1706+
# Step 3: Add all raydata fields and check no-hit values
1707+
all_fields = (
1708+
(1 << dist_val) | (1 << dir_val) | (1 << origin_val) |
1709+
(1 << point_val) | (1 << normal_val) | (1 << depth_val)
1710+
)
1711+
rf_sensor.intprm[0] = all_fields
1712+
model = spec.compile()
1713+
data = mujoco.MjData(model)
1714+
mujoco.mj_forward(model, data)
1715+
1716+
# Expected size: dist(1) + dir(3) + origin(3) + point(3) + normal(3) +
1717+
# depth(1) = 14
1718+
self.assertEqual(model.nsensordata, 14)
1719+
1720+
# No-hit values
1721+
sd = data.bind(rf_sensor).data
1722+
self.assertEqual(sd[0], -1) # dist
1723+
np.testing.assert_allclose(sd[1:4], [0, 0, 0]) # dir
1724+
np.testing.assert_allclose(sd[4:7], [0, 0, 1]) # origin
1725+
np.testing.assert_allclose(sd[7:10], [0, 0, 0]) # point
1726+
np.testing.assert_allclose(sd[10:13], [0, 0, 0]) # normal
1727+
self.assertEqual(sd[13], -1) # depth
1728+
1729+
# Step 4: Add a floor plane, now the ray should hit
1730+
spec.worldbody.add_geom(
1731+
name='floor',
1732+
type=mujoco.mjtGeom.mjGEOM_PLANE,
1733+
size=[10, 10, 0.1],
1734+
)
1735+
model = spec.compile()
1736+
data = mujoco.MjData(model)
1737+
mujoco.mj_forward(model, data)
1738+
1739+
# Ray starts at z=1 pointing down, hits floor at z=0
1740+
# For site sensor, depth = dist
1741+
sd = data.bind(rf_sensor).data
1742+
self.assertAlmostEqual(sd[0], 1.0, places=6) # dist
1743+
np.testing.assert_allclose(sd[1:4], [0, 0, -1], atol=1e-10) # dir
1744+
np.testing.assert_allclose(sd[4:7], [0, 0, 1], atol=1e-10) # origin
1745+
np.testing.assert_allclose(sd[7:10], [0, 0, 0], atol=1e-10) # point
1746+
np.testing.assert_allclose(sd[10:13], [0, 0, 1], atol=1e-10) # normal
1747+
self.assertAlmostEqual(sd[13], 1.0, places=6) # depth
1748+
1749+
# Step 5: Add a camera-based rangefinder sensor
1750+
# Camera also on child body so it doesn't exclude the floor
1751+
cam_body = spec.worldbody.add_body(name='cam_body', pos=[0, 0, 2])
1752+
cam_body.add_camera(
1753+
name='rf_cam',
1754+
xyaxes=[1, 0, 0, 0, 1, 0], # z=[0,0,1], looks along -z (down)
1755+
resolution=[3, 3],
1756+
fovy=90,
1757+
)
1758+
cam_sensor = spec.add_sensor(
1759+
name='rf_cam_sensor',
1760+
type=mujoco.mjtSensor.mjSENS_RANGEFINDER,
1761+
objtype=mujoco.mjtObj.mjOBJ_CAMERA,
1762+
objname='rf_cam',
1763+
intprm=[(1 << dist_val) | (1 << depth_val), 0, 0],
1764+
)
1765+
1766+
model = spec.compile()
1767+
data = mujoco.MjData(model)
1768+
mujoco.mj_forward(model, data)
1769+
1770+
# Site sensor: 14 values, Camera sensor: (1+1)*9 = 18 values
1771+
self.assertEqual(model.nsensordata, 14 + 18)
1772+
1773+
# Check camera sensor data using bind
1774+
cam_sd = data.bind(cam_sensor).data
1775+
stride = 2 # dist + depth per pixel
1776+
center_pixel = 4 # center of 3x3 = row 1, col 1
1777+
1778+
# Center pixel: ray straight down from z=2 to z=0
1779+
self.assertAlmostEqual(cam_sd[center_pixel * stride], 2.0, places=6)
1780+
self.assertAlmostEqual(cam_sd[center_pixel * stride + 1], 2.0, places=6)
1781+
1782+
# Corner pixel: off-axis ray, dist > depth
1783+
self.assertGreater(cam_sd[0], cam_sd[1]) # dist > depth
1784+
self.assertAlmostEqual(cam_sd[1], 2.0, places=6) # depth is still 2.0
1785+
16651786
if __name__ == '__main__':
16661787
absltest.main()

src/engine/engine_ray.c

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -662,11 +662,11 @@ static mjtNum mj_rayHfieldNormal(const mjModel* m, const mjData* d, int geomid,
662662
// triangle normal
663663
mjtNum normal_tri[3];
664664

665-
// first triangle
665+
// first triangle: swap v1 and v2 for consistent CCW winding (normals point up)
666666
mjtNum va[3][3] = {
667667
{dx*c-size[0], dy*r-size[1], data[r*ncol+c]*size[2]},
668-
{dx*(c+1)-size[0], dy*(r+1)-size[1], data[(r+1)*ncol+(c+1)]*size[2]},
669-
{dx*(c+1)-size[0], dy*(r+0)-size[1], data[(r+0)*ncol+(c+1)]*size[2]}
668+
{dx*(c+1)-size[0], dy*(r+0)-size[1], data[(r+0)*ncol+(c+1)]*size[2]},
669+
{dx*(c+1)-size[0], dy*(r+1)-size[1], data[(r+1)*ncol+(c+1)]*size[2]}
670670
};
671671
mjtNum sol = ray_triangle(va, lpnt, lvec, b0, b1, normal ? normal_tri : NULL);
672672
if (sol >= 0 && (x < 0 || sol < x)) {
@@ -1458,6 +1458,18 @@ void mju_multiRayPrepare(const mjModel* m, const mjData* d, const mjtNum pnt[3],
14581458
AABB[3] = mju_max(AABB[3], elevation);
14591459
}
14601460

1461+
// add distance-dependent angular margin to account for edge/face curvature
1462+
// margin = atan(max_half_size / dist) bounds the angular deviation of face centers
1463+
mjtNum max_half = mju_max(aabb[3], mju_max(aabb[4], aabb[5]));
1464+
mjtNum dist = mju_dist3(pnt, xpos);
1465+
if (dist > mjMINVAL) {
1466+
mjtNum margin = mju_atan2(max_half, dist);
1467+
AABB[0] -= margin;
1468+
AABB[1] -= margin;
1469+
AABB[2] += margin;
1470+
AABB[3] += margin;
1471+
}
1472+
14611473
// azimuth crosses discontinuity, fall back to no angular culling
14621474
if (AABB[2]-AABB[0] > mjPI) {
14631475
AABB[0] = -mjPI;
@@ -1520,8 +1532,23 @@ static mjtNum mju_singleRay(const mjModel* m, mjData* d, const mjtNum pnt[3], co
15201532

15211533
// exclude geom using bounding angles
15221534
if (m->body_bvhadr[b] != -1) {
1523-
if (azimuth < (geom_ba+4*i)[0] || elevation < (geom_ba+4*i)[1] ||
1524-
azimuth > (geom_ba+4*i)[2] || elevation > (geom_ba+4*i)[3]) {
1535+
mjtNum az_min = (geom_ba+4*i)[0];
1536+
mjtNum az_max = (geom_ba+4*i)[2];
1537+
mjtNum el_min = (geom_ba+4*i)[1];
1538+
mjtNum el_max = (geom_ba+4*i)[3];
1539+
1540+
// check elevation
1541+
if (elevation < el_min || elevation > el_max) {
1542+
continue;
1543+
}
1544+
1545+
// check azimuth with wraparound
1546+
mjtNum az_center = (az_min + az_max) * 0.5;
1547+
mjtNum az_half_width = (az_max - az_min) * 0.5;
1548+
mjtNum az_diff = azimuth - az_center;
1549+
if (az_diff > mjPI) az_diff -= 2*mjPI;
1550+
else if (az_diff < -mjPI) az_diff += 2*mjPI;
1551+
if (mju_abs(az_diff) > az_half_width) {
15251552
continue;
15261553
}
15271554
}

0 commit comments

Comments
 (0)