Here I discuss and visualise the navigation problem of a robot traversing a square grid with random obstacles. The documentation is also included here.
- Robot Traversing a Grid Using Space-Filling Curves
This algorithm is based on the paper Online Obstacle evasion with Space-Filling Curves(Ashay Wakode, Arpita Sinha - Aug. 2023) for solving the grid traversal with obstacles problem:
Given an
The original source code by the authors is somewhat cryptic, this project aims to clarify all aspects of their approach, and to make the visualisations as accessible as possible.
In our implementation, the Hilbert curve is used (although any space filling curve is viable) based on the original source code.
The gist is to follow the space filling curve as far as we can, and where we cannot(because of an obstacle), use Dijkstra's algorithm to get us to the next viable position with the smallest Hilbert number.
Since the Hilbert curve only works for grids with side length
We use the igraph
library to set up a graph containing the visited gridpoints, augmented with the next possible set of positions. We then use Dijkstra's algorithm (get_all_shortest_paths()
method) to find the shortest path to our desired position.
It is recommended to first run the code as-is, and quickly read through main.py
, as well as the documentation on the primary and secondary hyperparameters. It will be much simpler to understand once you'ce played with the algorithm a few times.
Then feel free to read the Functions and Driver Code sections.
If there are rendering issues, refer to the pltrcparams section, and make sure your IDE is using the appropriate backend. I used TKikner
in the Spyder IDE, and made sure the animation was rendering as an independent window, outside of the terminal.
The source code is divided into distinct sections, implying they should exist in separate files. Since main.py
is only 300 lines of code, it's all grouped together.
The script generate_environment.py
is deprecated and unused. It was the original script in this repo. The functions it contains were adapted and incorporated into main.py
These parameters control the primary behaviors of the algorithm, the size of the board, the obstacles, and some visuals.
This controls the resolution of the board when it is visualised. Currently set to 1200
. By default, matplotlib
sets it to 100
. If your visualisation looks strange, try changing this number. If the issues persist, change the backend visualiser used by your IDE. In my case, I used TKinker
backend rendered in the Spyder IDE.
This controls the iteration number of our Hilbert curve. It determines the size of the board. It grows in powers of 2, so if iter = 3
then the side length is 16
, if iter = 4
then the side length is 32
and so on.
iter = 5
as it becomes computationally demanding to run the algorithm. If you are confident in your hardware, feel free to increase it.
This fraction is the minimum proportion of the grid that the obstacles should cover. So if MIN_OBSTACLE_COVERAGE_RATIO = 0.2
, then roughly 20%
of the grid should have some obstacle on it. It's a minimum since these proportions cannot be exact for an arbitrary grid, so if at least some minimum percentage has obstacles, stop generating them.
For example, if we have 19.5%
coverage, and the next obstacle will push it to 22%
, then stop generating new obstacles after 22%
, since we've crossed the minimum threshold.
In essence,
In general it should be bound between these values:
This determines the minimum side length of an obstacle. By default its fine to leave it at 1
, that way there is a mix of large and small obstacles.
In general it should be bound between these values:
MIN_OBSTACLE_SIZE = MAX_OBSTACLE_SIZE
, then all generated obstacles will be the same fixed size.
These parameters do not need to be changed, other than for experimentation and curiosity. They still effect the underlying behaviour of the algorithm, but for presentation purposes, their default value is sufficient to understand what is happening.
This controls the maximum side length of a given obstacle. This should always be smaller than the side length of the grid. By default this is set to 1
.
In general, it should be bound between these values:
These are used in the driver code, or by eachother as helper functions. They mainly serve to set up the infrastructure necessary to run the main logic.
For a given Hilbert curve (in global scope), returns the index at which the point H
of the i
, such that Hilbert[i] = H
.
Since x,y
are part of the grid, they are type int
.
This function decorated with the njit
function of the numba
library, to make it run faster.
Example of use:
>> iter = 1 # A 2x2 grid
>> ...
>> get_point_index(0,0)
>> 0
>> get_point_index(1,0)
>> 3
>> get_point_index(1,1)
>> 2
Returns the Hilbert indices of the points directly adjacent (up/down/left/right) to the point of consideration, i
. It uses get_point_index
as a helper function.
Suppose H
of Hilbert
. Then its' Hilbert index i
is such that Hilbert[i] = H
. This function returns the Hilbert indices of the points
In case we are on the edge of the grid, it only returns the indices within the grid.
Since i
is an index, it is of type int
.
It is used by itself, and also as a helper function in get_subgraph
.
Example of use:
>> iter = 2 # A 4x4 grid
>> ...
>> get_adjacent_nodes(8) # grid coordinate (2,2)
>> [7,9,11,13]
Returns Hilbert indices of the non-visited gridpoints that neighbour the visited gridpoints, and aren't detected as obstacles.
v_list
is a list containing the Hilbert indices of the already visited gridpoints, each index of typeint
obs_list
is a list containing the Hilbert indices of the detected obstacles, each index of typeint
Example of use:
>> iter = 2 # A 4x4 grid
>> ... # Obstacles at (0,1), (2,2)
>> ... # Currently we are on index Hilbert 2
>> ... # v_list = [0,1,2], obs_list = [3]
>> get_adjacency_list(v_list, obs_list)
>> [7,13,14]
This function returns the first common path between subgraph_list
and l
. If l
has only one path, return it instead.
subgraph_list
is a list containing Hilbert indices, each index of typeint
.l
is anigraph
object of theigraph
library.
The return is a list of Hilbert indices, each index representing a path segment on the grid.
Typically we use this function to find the shortest viable path between two Hilbert indices on the grid. subgraph_list
would contain the visited gridpoints and the next desired position. l
contains the set of shortest past between the robot's current position, and the next desired position.
Since l
is an igraph
object, it's typically created by calling the get_all_shortest_paths()
method. From some larger graph, o
, which is a subgraph of the current grid, we create l
as follows: l = o.get_all_shortest_paths(visited_nodes[-1], to = desired_node)
. visited_nodes[-1]
refers to the robots' current position. Internally, the get_all_shortest_paths
method uses Dijkstra's algorithm.
Example of use:
>> iter = 2 # A 4x4 grid
>> ... # Obstacles at (0,1), (0,2)
>> ... # We've visited Hilbert indices [0,1,2]
>> ...
>> get_req_path(subgraph_list,l)
>> [7,6] # The order matters(first step, second step, ...)
This function generates a graph connecting a given set of Hilbert indices to their adjacent neighbours within those indices.
This function is typically used to construct all viable next positions for the robot when an obstacle is present.
The input v
is a list of indices, each index of type int
.
The output is an igraph
object from the igraph
library, containing a set of connected nodes.
Example of use:
>> iter = 2 # A 4x4 grid
>> ...
>> get_subgraph([2,7,11])
>> igraph Object at address 0x42683...
>> # igraph object where indices 2,7 are connected with an edge
This function generates a list of lists, each containing the
-
field_size
is the side length of our board, it bounds the possible coordinates of the obstacles. It is anint
.
-
ratio
is the proportion of the board that should be covered by obstacles. It is afloat
, bound within$[0,1[$ -
min_obstacle
is the minimum side length an obstacle could have. It is anint
. -
max_obstacle
is the maximum side length an obstacle could have. It is anint
.
The two latter arguments must be bound according to:
min_obstacle
max_obstacle
field_size
Example of use:
>> generateObstacles(4, 0.25, 1, 2)
>> [[1,1],[1,2],[2,1],[2,2]] # single 2x2 obstacle
>>
>> generateObstacles(4, 0.25, 1, 2)
>> [[1,1],[2,2],[0,3],[1,2]] # four 1x1 obstacles
This function produces a list of lists, each sub-list containing field_size
specifies the side length of our grid, and should be and int
.
Example of use:
>> generateFieldXY(2) # A 2x2 grid
>> [[0,0],[0,1],[1,0],[1,1]]
Here we explain the main logic of the algorithm, along with the relevant variables.
- The
obstacle
variable is a list containing the Hilbert indices of all the obstacles. - The
visited_nodes
variable is an array containing the sequence of Hilbert indices we have already visited. Since the robot may go over the same path again, it may contain multiples of the same index. But theunique
elements are the already visited gridpoints. It is initialised to[0]
as this is the starting position. - The
obstacles_detected
variable is an array containing the Hilbert indices of the obstacles the robot has encountered up until its' current position. It is initialised as an empty array since the robot hasn't encountered anything the moment it starts.
obstacle= hilbert_curve.distances_from_points(obstacle_coordinates)
visited_nodes = np.array([0])
obstacle_detected = np.array([])
i = get_subgraph(visited_nodes)
while get_adjacency_list(visited_nodes, obstacle_detected) != []:
min_adj = min(get_adjacency_list(visited_nodes, obstacle_detected))
o = get_subgraph(np.append(visited_nodes, min_adj))
if min_adj == visited_nodes[-1] + 1:
if min_adj in obstacle:
obstacle_detected = np.append(obstacle_detected, min_adj)
else:
visited_nodes = np.append(visited_nodes, min_adj)
else:
l = get_req_path(
visited_nodes, o.get_all_shortest_paths(
visited_nodes[-1], to=min_adj)
)
if min_adj in obstacle:
try:
obstacle_detected = np.append(obstacle_detected, l[0][-1])
visited_nodes = np.append(visited_nodes, l[0][1:-1])
except:
obstacle_detected = np.append(obstacle_detected, l[-1])
visited_nodes = np.append(visited_nodes, l[1:-1])
else:
try:
visited_nodes = np.append(visited_nodes, l[0][1:])
except:
visited_nodes = np.append(visited_nodes, l[1:])
The condition get_adjacency_list(visited_nodes, obstacle_detected) != []
continuously checks if there are any unvisited, and accessible, gridpoints. It's set up so that it won't include closed-off regions.
This statement:
min_adj = min(get_adjacency_list(visited_nodes, obstacle_detected))
o = get_subgraph(np.append(visited_nodes, min_adj))
Looks for the next smallest Hilbert index, min_adj
, and creates a sub graph of the grid,o
, among the visited_nodes
that includes min_adj
. By nature of the Hilbert curve, the next smallest index will either directly neighbour an already visited gridpoint, or an obstacle(ie: The next smallest index will always neighbour the obstacle that neighbours the visited gridpoints).
This statement:
if min_adj == visited_nodes[-1] + 1:
if min_adj in obstacle:
obstacle_detected = np.append(obstacle_detected, min_adj)
else:
visited_nodes = np.append(visited_nodes, min_adj)
Checks if the next smallest Hilbert gridpoint is immediately accessible. If there is no obstacle, we move to it(the else
statement). If we cannot go there(so it's an obstacle), add the index to the list of detected obstacles, obstacles_detected
.
This statement:
else:
l = get_req_path(
visited_nodes, o.get_all_shortest_paths(
visited_nodes[-1], to=min_adj)
)
Finds the shortest path (l
), using Dijkstra, from the robots' current position (visited_nodes[-1]
) to the desired position min_adj
. This is run when min_adj
is not the increment of the robots' current position (because its blocked by an obstacle, or is an obstacle neighbouring another obstacle).
This statement:
if min_adj in obstacle:
try:
obstacle_detected = np.append(obstacle_detected, l[0][-1])
visited_nodes = np.append(visited_nodes, l[0][1:-1])
except:
obstacle_detected = np.append(obstacle_detected, l[-1])
visited_nodes = np.append(visited_nodes, l[1:-1])
else:
try:
visited_nodes = np.append(visited_nodes, l[0][1:])
except:
visited_nodes = np.append(visited_nodes, l[1:])
Checks if min_adj
is an obstacle(or not) in the case that min_adj
isn't and increment of the robots' current position.
If it's an obstacle, we follow Dijkstra until its' second-to-last gridpoint (so we don't crash into the obstacle), and append min_adj
's index into obstacle_detected
.
If it isn't an obstacle, we follow Dijkstra's path directly, and we append it to the visited_nodes
.
The try/except
clauses deals with the edge of the board, but their logic is equivalent.
The relevant code is in the src folder. First run requirements.sh to install the necessary libraries. Then run main.py to view the default path.
If you want to play around with it, I strongly recommend to read the Primary Hyperparameters section so you know what variables to change.
It is not recommended to change the iter
parameter above 5. It is computationally demanding and will take a long time to execute.
When you close the animation window, something similar to the following should appear in your terminal:
minimal possible moves
refers to the theoretical minimal amount of moves the robot could do. This number isn't necessarily achievable, since it depends on how the obstacles are placed. It just counts the total number of gridpoints that aren't covered by an obstacle- As
actual moves
implies, its the total number of moves done by the robot to complete the grid overshot moves by
is the percentage difference between theminimal possible moves
and theactual moves