Skip to content

Commit 322fead

Browse files
authored
feat: add DistanceMap (#1142)
* feat: add DistanceMap * feat: add DistanceMap test * feat: add DistanceMap doc * feat: DistanceMap doc update * feat: DistanceMap update
1 parent 7b7bd78 commit 322fead

File tree

5 files changed

+297
-0
lines changed

5 files changed

+297
-0
lines changed

Mapping/DistanceMap/distance_map.py

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""
2+
Distance Map
3+
4+
author: Wang Zheng (@Aglargil)
5+
6+
Ref:
7+
8+
- [Distance Map]
9+
(https://cs.brown.edu/people/pfelzens/papers/dt-final.pdf)
10+
"""
11+
12+
import numpy as np
13+
import matplotlib.pyplot as plt
14+
15+
INF = 1e20
16+
ENABLE_PLOT = True
17+
18+
19+
def compute_sdf(obstacles):
20+
"""
21+
Compute the signed distance field (SDF) from a boolean field.
22+
23+
Parameters
24+
----------
25+
obstacles : array_like
26+
A 2D boolean array where '1' represents obstacles and '0' represents free space.
27+
28+
Returns
29+
-------
30+
array_like
31+
A 2D array representing the signed distance field, where positive values indicate distance
32+
to the nearest obstacle, and negative values indicate distance to the nearest free space.
33+
"""
34+
a = compute_udf(obstacles)
35+
b = compute_udf(obstacles == 0)
36+
return a - b
37+
38+
39+
def compute_udf(obstacles):
40+
"""
41+
Compute the unsigned distance field (UDF) from a boolean field.
42+
43+
Parameters
44+
----------
45+
obstacles : array_like
46+
A 2D boolean array where '1' represents obstacles and '0' represents free space.
47+
48+
Returns
49+
-------
50+
array_like
51+
A 2D array of distances from the nearest obstacle, with the same dimensions as `bool_field`.
52+
"""
53+
edt = obstacles.copy()
54+
if not np.all(np.isin(edt, [0, 1])):
55+
raise ValueError("Input array should only contain 0 and 1")
56+
edt = np.where(edt == 0, INF, edt)
57+
edt = np.where(edt == 1, 0, edt)
58+
for row in range(len(edt)):
59+
dt(edt[row])
60+
edt = edt.T
61+
for row in range(len(edt)):
62+
dt(edt[row])
63+
edt = edt.T
64+
return np.sqrt(edt)
65+
66+
67+
def dt(d):
68+
"""
69+
Compute 1D distance transform under the squared Euclidean distance
70+
71+
Parameters
72+
----------
73+
d : array_like
74+
Input array containing the distances.
75+
76+
Returns:
77+
--------
78+
d : array_like
79+
The transformed array with computed distances.
80+
"""
81+
v = np.zeros(len(d) + 1)
82+
z = np.zeros(len(d) + 1)
83+
k = 0
84+
v[0] = 0
85+
z[0] = -INF
86+
z[1] = INF
87+
for q in range(1, len(d)):
88+
s = ((d[q] + q * q) - (d[int(v[k])] + v[k] * v[k])) / (2 * q - 2 * v[k])
89+
while s <= z[k]:
90+
k = k - 1
91+
s = ((d[q] + q * q) - (d[int(v[k])] + v[k] * v[k])) / (2 * q - 2 * v[k])
92+
k = k + 1
93+
v[k] = q
94+
z[k] = s
95+
z[k + 1] = INF
96+
k = 0
97+
for q in range(len(d)):
98+
while z[k + 1] < q:
99+
k = k + 1
100+
dx = q - v[k]
101+
d[q] = dx * dx + d[int(v[k])]
102+
103+
104+
def main():
105+
obstacles = np.array(
106+
[
107+
[1, 0, 0, 0, 0],
108+
[0, 1, 1, 1, 0],
109+
[0, 1, 1, 1, 0],
110+
[0, 0, 1, 1, 0],
111+
[0, 0, 1, 0, 0],
112+
[0, 0, 0, 0, 0],
113+
[0, 0, 0, 0, 0],
114+
[0, 0, 0, 0, 0],
115+
[0, 0, 1, 0, 0],
116+
[0, 0, 0, 0, 0],
117+
[0, 0, 0, 0, 0],
118+
]
119+
)
120+
121+
# Compute the signed distance field
122+
sdf = compute_sdf(obstacles)
123+
udf = compute_udf(obstacles)
124+
125+
if ENABLE_PLOT:
126+
_, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(15, 5))
127+
128+
obstacles_plot = ax1.imshow(obstacles, cmap="binary")
129+
ax1.set_title("Obstacles")
130+
ax1.set_xlabel("x")
131+
ax1.set_ylabel("y")
132+
plt.colorbar(obstacles_plot, ax=ax1)
133+
134+
udf_plot = ax2.imshow(udf, cmap="viridis")
135+
ax2.set_title("Unsigned Distance Field")
136+
ax2.set_xlabel("x")
137+
ax2.set_ylabel("y")
138+
plt.colorbar(udf_plot, ax=ax2)
139+
140+
sdf_plot = ax3.imshow(sdf, cmap="RdBu")
141+
ax3.set_title("Signed Distance Field")
142+
ax3.set_xlabel("x")
143+
ax3.set_ylabel("y")
144+
plt.colorbar(sdf_plot, ax=ax3)
145+
146+
plt.tight_layout()
147+
plt.show()
148+
149+
150+
if __name__ == "__main__":
151+
main()
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Distance Map
2+
------------
3+
4+
This is an implementation of the Distance Map algorithm for path planning.
5+
6+
The Distance Map algorithm computes the unsigned distance field (UDF) and signed distance field (SDF) from a boolean field representing obstacles.
7+
8+
The UDF gives the distance from each point to the nearest obstacle. The SDF gives positive distances for points outside obstacles and negative distances for points inside obstacles.
9+
10+
Example
11+
~~~~~~~
12+
13+
The algorithm is demonstrated on a simple 2D grid with obstacles:
14+
15+
.. image:: distance_map.png
16+
17+
API
18+
~~~
19+
20+
.. autofunction:: PathPlanning.DistanceMap.distance_map.compute_sdf
21+
22+
.. autofunction:: PathPlanning.DistanceMap.distance_map.compute_udf
23+
24+
References
25+
~~~~~~~~~~
26+
27+
- `Distance Transforms of Sampled Functions <https://cs.brown.edu/people/pfelzens/papers/dt-final.pdf>`_ paper by Pedro F. Felzenszwalb and Daniel P. Huttenlocher.

docs/modules/3_mapping/mapping_main.rst

+1
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ Mapping is the ability of a robot to understand its surroundings with external s
1717
circle_fitting/circle_fitting
1818
rectangle_fitting/rectangle_fitting
1919
normal_vector_estimation/normal_vector_estimation
20+
distance_map/distance_map

tests/test_distance_map.py

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import conftest # noqa
2+
import numpy as np
3+
from Mapping.DistanceMap import distance_map as m
4+
5+
6+
def test_compute_sdf():
7+
"""Test the computation of Signed Distance Field (SDF)"""
8+
# Create a simple obstacle map for testing
9+
obstacles = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]])
10+
11+
sdf = m.compute_sdf(obstacles)
12+
13+
# Verify basic properties of SDF
14+
assert sdf.shape == obstacles.shape, "SDF should have the same shape as input map"
15+
assert np.all(np.isfinite(sdf)), "SDF should not contain infinite values"
16+
17+
# Verify SDF value is negative at obstacle position
18+
assert sdf[1, 1] < 0, "SDF value should be negative at obstacle position"
19+
20+
# Verify SDF value is positive in free space
21+
assert sdf[0, 0] > 0, "SDF value should be positive in free space"
22+
23+
24+
def test_compute_udf():
25+
"""Test the computation of Unsigned Distance Field (UDF)"""
26+
# Create obstacle map for testing
27+
obstacles = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]])
28+
29+
udf = m.compute_udf(obstacles)
30+
31+
# Verify basic properties of UDF
32+
assert udf.shape == obstacles.shape, "UDF should have the same shape as input map"
33+
assert np.all(np.isfinite(udf)), "UDF should not contain infinite values"
34+
assert np.all(udf >= 0), "All UDF values should be non-negative"
35+
36+
# Verify UDF value is 0 at obstacle position
37+
assert np.abs(udf[1, 1]) < 1e-10, "UDF value should be 0 at obstacle position"
38+
39+
# Verify UDF value is 1 for adjacent cells
40+
assert np.abs(udf[0, 1] - 1.0) < 1e-10, (
41+
"UDF value should be 1 for cells adjacent to obstacle"
42+
)
43+
assert np.abs(udf[1, 0] - 1.0) < 1e-10, (
44+
"UDF value should be 1 for cells adjacent to obstacle"
45+
)
46+
assert np.abs(udf[1, 2] - 1.0) < 1e-10, (
47+
"UDF value should be 1 for cells adjacent to obstacle"
48+
)
49+
assert np.abs(udf[2, 1] - 1.0) < 1e-10, (
50+
"UDF value should be 1 for cells adjacent to obstacle"
51+
)
52+
53+
54+
def test_dt():
55+
"""Test the computation of 1D distance transform"""
56+
# Create test data
57+
d = np.array([m.INF, 0, m.INF])
58+
m.dt(d)
59+
60+
# Verify distance transform results
61+
assert np.all(np.isfinite(d)), (
62+
"Distance transform result should not contain infinite values"
63+
)
64+
assert d[1] == 0, "Distance at obstacle position should be 0"
65+
assert d[0] == 1, "Distance at adjacent position should be 1"
66+
assert d[2] == 1, "Distance at adjacent position should be 1"
67+
68+
69+
def test_compute_sdf_empty():
70+
"""Test SDF computation with empty map"""
71+
# Test with empty map (no obstacles)
72+
empty_map = np.zeros((5, 5))
73+
sdf = m.compute_sdf(empty_map)
74+
75+
assert np.all(sdf > 0), "All SDF values should be positive for empty map"
76+
assert sdf.shape == empty_map.shape, "Output shape should match input shape"
77+
78+
79+
def test_compute_sdf_full():
80+
"""Test SDF computation with fully occupied map"""
81+
# Test with fully occupied map
82+
full_map = np.ones((5, 5))
83+
sdf = m.compute_sdf(full_map)
84+
85+
assert np.all(sdf < 0), "All SDF values should be negative for fully occupied map"
86+
assert sdf.shape == full_map.shape, "Output shape should match input shape"
87+
88+
89+
def test_compute_udf_invalid_input():
90+
"""Test UDF computation with invalid input values"""
91+
# Test with invalid values (not 0 or 1)
92+
invalid_map = np.array([[0, 2, 0], [0, -1, 0], [0, 0.5, 0]])
93+
94+
try:
95+
m.compute_udf(invalid_map)
96+
assert False, "Should raise ValueError for invalid input values"
97+
except ValueError:
98+
pass
99+
100+
101+
def test_compute_udf_empty():
102+
"""Test UDF computation with empty map"""
103+
# Test with empty map
104+
empty_map = np.zeros((5, 5))
105+
udf = m.compute_udf(empty_map)
106+
107+
assert np.all(udf > 0), "All UDF values should be positive for empty map"
108+
assert np.all(np.isfinite(udf)), "UDF should not contain infinite values"
109+
110+
111+
def test_main():
112+
"""Test the execution of main function"""
113+
m.ENABLE_PLOT = False
114+
m.main()
115+
116+
117+
if __name__ == "__main__":
118+
conftest.run_this_test(__file__)

0 commit comments

Comments
 (0)