Skip to content

Commit a038ce1

Browse files
committed
Added-methods-to-numpify-and-msgify-LaserScan-msg-and-corresponding-unittest
1 parent 3855ae8 commit a038ce1

File tree

5 files changed

+230
-0
lines changed

5 files changed

+230
-0
lines changed

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ if(BUILD_TESTING)
1818
ament_add_pytest_test(occupancygrids test/test_occupancygrids.py)
1919
ament_add_pytest_test(geometry test/test_geometry.py)
2020
ament_add_pytest_test(quaternions test/test_quat.py)
21+
ament_add_pytest_test(laserscan test/test_laserscan.py)
2122
endif()
2223

2324
##############

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Currently supports:
5454
data = ros2_numpy.numpify(msg)
5555
```
5656

57+
* `sensor_msgs.msg.LaserScan` ↔ structured `np.array`
5758
* `sensor_msgs.msg.Image` ↔ 2/3-D `np.array`, similar to the function of `cv_bridge`, but without the dependency on `cv2`
5859
* `nav_msgs.msg.OccupancyGrid` ↔ `np.ma.array`
5960
* `geometry.msg.Vector3` ↔ 1-D `np.array`. `hom=True` gives `[x, y, z, 0]`

ros2_numpy/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
from . import image
88
from . import occupancy_grid
99
from . import geometry
10+
from . import laser_scan

ros2_numpy/laser_scan.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Methods to numpify LaserScan message.
4+
"""
5+
6+
from .registry import converts_from_numpy, converts_to_numpy
7+
8+
import numpy as np
9+
from sensor_msgs.msg import LaserScan
10+
11+
12+
@converts_to_numpy(LaserScan)
13+
def laserscan_to_array(
14+
scan, remove_invalid_ranges=False, include_ranges_and_intensities=False
15+
):
16+
"""
17+
Takes a sensor_msgs/msg/LaserScan msg and returns a structered array with
18+
fields x, y and z that correspond to cartesian position data. Optionally,
19+
ranges and intensities fields that correspond to the range and intensity
20+
of a point are also included if include_ranges_and_intensities is True.
21+
22+
Parameters
23+
----------
24+
scan : ROS2 LaserScan message
25+
Input laser scan message to get numpyed
26+
remove_invalid_ranges : bool, optional
27+
whether to remove invalid ranges from the input scan, by default False
28+
include_ranges_and_intensities : bool, optional
29+
whether to also return the ranges & intensities along with the cartesian
30+
position.
31+
32+
Returns
33+
-------
34+
pts : numpy k-array where each element will be a structured record that
35+
contains either 3 or 5 fields. If include_ranges_and_intensities is
36+
False, each element of output array is a structured record that has
37+
3 fields ['x', 'y', 'z'] of type float 32. Else, it has 5 fields ['x',
38+
'y', 'z', 'ranges', 'intensities']. Since output is a structured array,
39+
all the x-coordinates of the points can be accessed as out_array['x'].
40+
Similarly, the y and z coodinates can be accessed as out_array['y']
41+
and out_array['z'] respectively.
42+
"""
43+
n_points = len(scan.ranges)
44+
angles = np.linspace(
45+
scan.angle_min,
46+
scan.angle_max,
47+
n_points,
48+
)
49+
ranges = np.array(scan.ranges, dtype="f4")
50+
intensities = np.array(scan.intensities, dtype="f4")
51+
if remove_invalid_ranges:
52+
indices_invalid_range = (
53+
np.isinf(ranges)
54+
| (ranges < scan.range_min)
55+
| (ranges > scan.range_max)
56+
)
57+
ranges = ranges[~indices_invalid_range]
58+
angles = angles[~indices_invalid_range]
59+
intensities = intensities[~indices_invalid_range]
60+
61+
x = np.array(ranges * np.cos(angles), dtype="f4")
62+
y = np.array(ranges * np.sin(angles), dtype="f4")
63+
z = np.zeros(ranges.shape[0], dtype="f4")
64+
if include_ranges_and_intensities:
65+
dtype = np.dtype(
66+
[
67+
("x", "f4"),
68+
("y", "f4"),
69+
("z", "f4"),
70+
("ranges", "f4"),
71+
("intensities", "f4"),
72+
]
73+
)
74+
out_array = np.empty(len(x), dtype=dtype)
75+
out_array["x"] = x
76+
out_array["y"] = y
77+
out_array["z"] = z
78+
out_array["ranges"] = ranges
79+
out_array["intensities"] = intensities
80+
return out_array
81+
else:
82+
dtype = np.dtype([("x", "f4"), ("y", "f4"), ("z", "f4")])
83+
out_array = np.empty(len(x), dtype=dtype)
84+
out_array["x"] = x
85+
out_array["y"] = y
86+
out_array["z"] = z
87+
return out_array
88+
89+
90+
@converts_from_numpy(LaserScan)
91+
def array_to_laserscan(arr, header, scan_time=0.0, time_increment=0.0):
92+
"""
93+
Takes a structured array(created from LaserScan msg) and returns a
94+
LaserScan message. Fields that cannot be determined from the numpy
95+
array are provided as inputs. Since the LaserScan message relies
96+
on a consistent angular increment, structured array in which the
97+
points have been omitted will result in a LaserScan message that is
98+
not correct.
99+
100+
Parameters
101+
----------
102+
arr : Structured numpy array with fields x, y and z.
103+
Input numpy array that was created from LaserScan message.
104+
header : std_msgs::msg::Header,
105+
The header to be written to the output LaserScan message.
106+
scan_time : float, optional
107+
time between scans [seconds] of LaserScan
108+
time_increment : float, optional
109+
time between measurements [seconds] - if lidar is moving,
110+
this will be used in interpolating position
111+
112+
Returns
113+
-------
114+
scan_msg : sensor_msgs::msg::LaserScan message
115+
If the input array does not contain an `intensities` field,
116+
this message has the intensities list filled to zeros.
117+
118+
"""
119+
n_points = arr.shape[0]
120+
121+
if "intensities" in arr.dtype.names:
122+
intensities = arr["intensities"]
123+
else:
124+
intensities = np.zeros(n_points).astype(float)
125+
126+
if "ranges" in arr.dtype.names:
127+
ranges = arr["ranges"]
128+
else:
129+
ranges = np.sqrt(arr["x"] ** 2 + arr["y"] ** 2).astype(float)
130+
131+
angles = np.arctan2(arr["y"], arr["x"]).astype(float)
132+
133+
# Create a LaserScan message
134+
scan_msg = LaserScan()
135+
136+
scan_msg.header = header
137+
138+
scan_msg.intensities = intensities.tolist()
139+
scan_msg.ranges = ranges.tolist()
140+
141+
# Compute min and max of the ranges
142+
scan_msg.range_min = np.min(ranges)
143+
scan_msg.range_max = np.max(ranges)
144+
145+
# Compute min and max of the angles
146+
scan_msg.angle_min = np.min(angles)
147+
scan_msg.angle_max = np.max(angles)
148+
149+
# Use the time_increment and scan_time from input arguments
150+
scan_msg.time_increment = time_increment
151+
scan_msg.scan_time = scan_time
152+
153+
# Compute angle increment. Since the angle_max is not exclusive, omit
154+
# the last point.
155+
scan_msg.angle_increment = (scan_msg.angle_max - scan_msg.angle_min) / (
156+
n_points - 1
157+
)
158+
159+
return scan_msg

test/test_laserscan.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import unittest
2+
import numpy as np
3+
import ros2_numpy as rnp
4+
5+
from sensor_msgs.msg import LaserScan
6+
from rclpy.clock import Clock
7+
8+
9+
class TestLaserScan(unittest.TestCase):
10+
def test_to_and_from_laserscan(self):
11+
# Create a dummy LaserScan message
12+
scan = LaserScan()
13+
scan.header.stamp = Clock().now().to_msg()
14+
scan.header.frame_id = "lidar_2d"
15+
scan.range_min = 0.01
16+
scan.range_max = 200.0
17+
scan.angle_increment = np.radians(0.1)
18+
scan.angle_min = -np.pi
19+
scan.angle_max = np.pi - scan.angle_increment
20+
scan.scan_time = 0.0
21+
scan.time_increment = 0.0
22+
scan.ranges = np.full(3600, 10.0, dtype="f4").tolist()
23+
scan.intensities = np.full(3600, 5, dtype="f4").tolist()
24+
laserscan_array = rnp.numpify(
25+
scan,
26+
remove_invalid_ranges=False,
27+
include_ranges_and_intensities=True,
28+
)
29+
30+
self.assertEqual(laserscan_array.shape[0], 3600)
31+
np.testing.assert_array_equal(
32+
laserscan_array["ranges"], np.array(scan.ranges)
33+
)
34+
np.testing.assert_array_equal(
35+
laserscan_array["intensities"], np.array(scan.intensities)
36+
)
37+
38+
laserscan_array_without_ranges_and_intensities = rnp.numpify(scan)
39+
40+
laserscan_msg = rnp.msgify(
41+
LaserScan,
42+
laserscan_array_without_ranges_and_intensities,
43+
scan.header,
44+
scan.scan_time,
45+
scan.time_increment,
46+
)
47+
self.assertAlmostEqual(
48+
laserscan_msg.angle_min, scan.angle_min, places=5
49+
)
50+
self.assertAlmostEqual(
51+
laserscan_msg.angle_increment, scan.angle_increment
52+
)
53+
self.assertAlmostEqual(laserscan_msg.angle_max, scan.angle_max)
54+
self.assertAlmostEqual(laserscan_msg.scan_time, scan.scan_time)
55+
self.assertAlmostEqual(
56+
laserscan_msg.time_increment, scan.time_increment
57+
)
58+
self.assertAlmostEqual(laserscan_msg.header.stamp, scan.header.stamp)
59+
self.assertEqual(laserscan_msg.header.frame_id, scan.header.frame_id)
60+
self.assertEqual(len(laserscan_msg.ranges), 3600)
61+
self.assertEqual(len(laserscan_msg.intensities), 3600)
62+
np.testing.assert_array_equal(
63+
laserscan_array["ranges"], np.array(scan.ranges)
64+
)
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

0 commit comments

Comments
 (0)