forked from robotika/eduro
/
vfh.py
361 lines (298 loc) · 13.6 KB
/
vfh.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
#!/usr/bin/env python
from itertools import islice, izip
import math
FAR_AWAY = 256.0 # A dummy far-away distance used instead of zeros in laser scans. [m]
class VFH:
'''
VFH+ implementation. No memory is used, only the obstacles in the last laser scan are taken in account.
The laser is expected to be mounted on the axis in the robot's facing direction.
Modeled after "VFH+: Reliable Obstacle Avoidance for Fast Mobile Robots", I. Ulrich and J. Borenstein.
'''
def __init__( self,
robot,
laserFOV = math.radians(270),
blockingDistance = 0.35,
safetyDistance = 0.6,
maxRange = 1.4,
turnRadius = None,
mu1 = 5,
mu2 = 2,
mu3 = 3,
binSize = math.radians(5),
laserDensity = 1,
toleratedLaserFailures = 3,
verbose = False,
sensorID = "laser"):
'''
Params:
robot ... The robot.
laserFOV ... Field of view of the laser. [rad]
safetyDistance ... This is a minimal planned distance from obstacles. [m]
maxRange ... Reading further than this threshold are ignored. This makes it possible to use a laser pointing to the ground. [m]
turnRadius ... The maximal turning trajectory of the robot is approximated with a circle with this radius. If None, a holonomic robot is assumed. [m]
mu1, mu2, mu3 ... Parameters of the cost function. mu1 is a weight of a difference between a candidate direction annd the goal direction; mu2 is a weight of a difference between a candidate direction and the current steering direction; mu3 is a weight of difference between a candidate direction and the previously selected direction. mu1 > mu2 + mu3 must hold. TODO: mu2 is currently not used.
binSize ... Angular width of the bins in the histograms. laserFOV should be divisible by binSize. [rad]
laserDensity ... Only every laserDensity-th beam is taken into account.
toleratedLaserFailures ... Number of consecutive "no-data" by laser, which is still considered to be ok.
verbose ... If verbose is set to True, some debugging information may be printed.
'''
self.robot = robot
self.laserMeasurements = None # Laser measurements. List of angle+distance pairs.
self.laserFOV = laserFOV
self.blockingDistance = blockingDistance
self.safetyDistance = safetyDistance
self.maxRange = maxRange
self.turnRadius = turnRadius
self.mu1 = mu1
self.mu2 = mu2
self.mu3 = mu3
self.binSize = binSize
self.laserDensity = laserDensity
self.toleratedLaserFailures = toleratedLaserFailures
self.verbose = verbose
self.sensorID = sensorID
self.__laserFailures = 0
def __bin(self, angle):
''' Find an index of a bin which the angle falls to. '''
return int( (angle + self.laserFOV / 2.0) / self.binSize)
def __angle(self, bin):
''' Find an angle corresponding to the given bin. '''
return bin * self.binSize + self.binSize / 2.0 - self.laserFOV / 2.0
def __obstDir(self, i, n):
''' Compute a direction to the obstacle from the scan index.
i ... index of the scan
n ... total amount of scan values
'''
return self.laserFOV * i / (n - 1.0) - self.laserFOV / 2.0
def updateExtension(self, robot, id, data):
if id == self.sensorID:
n = len(data)
self.laserMeasurements = [(self.__obstDir(i, n), d / 1000.)
for (i, d) in islice( enumerate(data),
0,
len(data),
self.laserDensity)
if d > 0]
if not data:
self.__laserFailures += 1
else:
self.__laserFailures = 0
def isBlocked(self, extraObs = []):
'''Return True if there is an obstacle within self.safetyDistance in front of the robot.
extraObs ... A list of extra obstacles. An obstacle is specified in local polar coordinates as a pair (angle, distance) [(rad, m)].
'''
if self.__laserFailures > self.toleratedLaserFailures:
return True
if self.laserMeasurements is None:
return True # Not initialized + better safe than sorry
def threshold(angle):
# A non-circular blocking shape
return self.blockingDistance * (0.7 + 0.3 * math.cos(angle))
return (any(x < threshold(a) for (a, x) in self.laserMeasurements)
or any(d < threshold(a) for (a, d) in extraObs))
def navigate(self, goalDir, prevDir = 0.0, extraObs = []):
''' Find a direction to the goal avoiding obstacles visible in the last scan.
Param:
goalDir ... A direction to the goal. [rad]
prevDir ... A previously selected steering direction. [rad]
extraObs ... A list of extra obstacles. An obstacle is specified in local polar coordinates as a pair (angle, distance) [(rad, m)].
Return:
Either a preferred direction [rad, counter-clockwise], or None.
None is returned whenever there is an obstacle very near or no direction seems reasonable.
'''
if self.laserMeasurements is None:
return None
if self.isBlocked(extraObs):
return None
polarHistogram = self.__polarHistogram(extraObs)
if polarHistogram is None:
return None
binaryPolarHistogram = self.__binaryPolarHistogram(polarHistogram)
maskedPolarHistogram = self.__maskedPolarHistogram(binaryPolarHistogram)
openWindows = self.__openWindows(maskedPolarHistogram)
candidates = self.__candidates(openWindows, goalDir)
dir = self.__bestCandidate(candidates, goalDir, prevDir)
if self.verbose:
s = [ 'x' if x else ' ' for x in maskedPolarHistogram]
if dir is not None:
i = self.__bin(dir)
s = s[:i] + ['.'] + s[i:]
s = ''.join(s)
print "'" + s[::-1] + "'"
return dir
def __polarHistogram(self, extraObs = []):
# In The Article, the threshold is carefully computed from unspecified parameters.
# In this implementation, the threshold denotes a maximal tolerated number of short reading within a bin.
self.__histogramThreshold = 0.
polarHistogram = [0.0] * (1 + self.__bin(self.laserFOV / 2.0))
obstacles = [ (beta, d) for (beta, d) in extraObs if d <= self.maxRange ]
for measurement in self.laserMeasurements:
d = measurement[1] # distance [m]
if d > self.maxRange:
continue
obstacles.append(measurement)
for (beta, d) in obstacles:
m = 1. #In the Article, this is a carefully computed cell magnitude. With the laser scanner, we can take any obstacle within the range as a road block.
if d < self.blockingDistance: # we are within the safety distance from an obstacle
return None
ratio = self.safetyDistance / d
# if we are within the safetyDistance, asin is undefined => let's HACK
if ratio > 1.0:
ratio = 1.0
elif ratio < -1.0:
ratio = -1.0
gamma = math.asin(ratio) # enlargement angle [rad]
low = max(0, self.__bin( beta - gamma ))
high = min(len(polarHistogram), self.__bin( beta + gamma ))
for j in range(low, high):
polarHistogram[j] += m
return polarHistogram
def __binaryPolarHistogram(self, polarHistogram):
return [ x > self.__histogramThreshold for x in polarHistogram ] #NOTE: No hysteresis. (Unlike in the article)
def __maskedPolarHistogram(self, binaryPolarHistogram):
if self.turnRadius is None: # A holonomic robot.
return binaryPolarHistogram
else:
MARGIN = math.pi
left = +self.laserFOV / 2.0 + MARGIN
right = -self.laserFOV / 2.0 - MARGIN
# Centers of the turning circles in the local coordinate frame [m, m]
(rcx, rcy) = (0., -self.turnRadius) # Center of the right-turning circle.
(lcx, lcy) = (0., self.turnRadius) # Center of the left-turning circle.
def isLeftFrom(phiWho, phiFrom):
''' Is the phiWho angle left from the phiFrom angle? '''
return phiWho > phiFrom
def isRightFrom(phiWho, phiFrom):
''' Is the phiWho angle right from the phiFrom angle? '''
return phiWho < phiFrom
for (beta, dist) in self.laserMeasurements:
if dist > self.maxRange:
continue
(obst_x, obst_y) = (dist * math.cos(beta), dist * math.sin(beta)) # Location of the obstacle in the local coordinate frame. [m, m]
if isLeftFrom(beta, right): # Right-turning to this direction and beyond is not blocked yet.
d = math.hypot(obst_x - rcx, obst_y - rcy)
if d < self.turnRadius + self.safetyDistance:
right = beta # An obstacle blocking turning to the right.
if isRightFrom(beta, left):
d = math.hypot(obst_x - lcx, obst_y - lcy)
if d < self.turnRadius + self.safetyDistance:
left = beta
li = self.__bin(left)
ri = self.__bin(right)
return [ False if binaryPolarHistogram[i] == False and i >= ri and i <= li else True for i in xrange(len(binaryPolarHistogram)) ]
def __openWindows(self, maskedPolarHistogram):
openWindows = []
prev = True
for i in xrange(1 + len(maskedPolarHistogram)):
mask = True if i == len(maskedPolarHistogram) else maskedPolarHistogram[i]
if prev == True and mask == False: # Right edge of a window.
right = self.__angle(i)
if prev == False and mask == True: # Left edge of a window.
left = self.__angle(i - 1)
openWindows.append( (right,left) )
prev = mask
return openWindows
def __candidates(self, openWindows, goalDir):
candidates = []
for (right, left) in openWindows:
if goalDir >= right and goalDir <= left:
candidates.append(goalDir)
# Note: Not distinguishing wide and narrow openings as in The Article.
candidates.append(right)
candidates.append(left)
return candidates
def __bestCandidate(self, candidates, goalDir, prevDir):
bestDir = None
bestCost = None
for dir in candidates:
cost = self.mu1 * abs(dir - goalDir) + self.mu3 * abs(dir - prevDir) #TODO: mu2
if bestDir is None or cost < bestCost:
bestDir = dir
bestCost = cost
return bestDir
def __remask(self, mask, n):
''' Rescales the mask to the given length.'''
m = len(mask)
if m == n:
return mask # no need to do anything
return [ mask[int(round(i * (m - 1) / float(n - 1)))] for i in xrange(n) ]
# Imports needed for the test
from driver import Driver, normalizeAnglePIPI
from eduro import EmergencyStopException
from localisation import SimpleOdometry
from ray_trace import combinedPose
class VFHTest:
def __init__(self, robot, dummy = None, verbose = False):
self.robot = robot
self.verbose = verbose
self.robot.attachLaser()
self.robot.attachEmergencyStopButton()
self.robot.laser.stopOnExit = False # for faster boot-up
def __call__(self):
try:
if getattr( self.robot.laser, 'startLaser', None ):
# trigger rotation of the laser, low level function, ignore for log files
print "Powering laser ON"
self.robot.laser.startLaser()
self.robot.waitForStart()
self.robot.laser.start() # laser also after start -- it should be already running
self.robot.localisation = SimpleOdometry()
self.driver = Driver( self.robot,
maxSpeed = 0.5,
maxAngularSpeed = math.radians(180))
self.testVFH(verbose = self.verbose)
except EmergencyStopException, e:
print "EmergencyStopException"
self.robot.gps.requestStop()
self.robot.laser.requestStop()
self.robot.camera.requestStop()
def testVFH(self, verbose = False):
turnRadius = None #1.2
vfh = VFH(self.robot,
blockingDistance = 0.35,
safetyDistance = 0.6,
maxRange = 1.4,
turnRadius = turnRadius,
verbose = verbose)
self.robot.addExtension(vfh.updateExtension)
TOLERATED_MISS = 0.2 # [m]
ANGULAR_THRESHOLD = math.radians(20) # [rad]
ANGULAR_SPEED = math.radians(60) # [rad/s]
D = 20.0 # [m]
waypoints = [ (D, 0.0), (D, D), (0.0, D), (0.0, 0.0) ]
prevDir = 0.0 # [rad]
while True:
for (x, y) in waypoints:
isThere = False
while not isThere:
pose = self.robot.localisation.pose()
if vfh.laserMeasurements is None:
strategy = self.driver.stopG()
prevDir = 0.0
else:
phi = math.atan2(y - pose[1], x- pose[0])
goalDir = normalizeAnglePIPI(phi - pose[2])
dir = vfh.navigate(goalDir, prevDir)
if dir is None:
strategy = self.driver.stopG()
prevDir= 0.0
else:
goal = combinedPose((pose[0], pose[1], pose[2]+dir), (1.0, 0, 0))
strategy = self.driver.goToG( goal,
TOLERATED_MISS,
angleThreshold = ANGULAR_THRESHOLD,
angularSpeed = ANGULAR_SPEED)
prevDir = dir
vfh.laserMeasurements = None
for (speed, angularSpeed) in strategy:
self.robot.setSpeedPxPa( speed, angularSpeed )
self.robot.update()
pose = self.robot.localisation.pose()
isThere = math.hypot(pose[0] - x, pose[1] - y) <= TOLERATED_MISS
if isThere or vfh.laserMeasurements is not None:
break
if __name__ == "__main__":
import sys
from eduromaxi import EduroMaxi
import launcher
launcher.launch(sys.argv, EduroMaxi, VFHTest)