XClose

COMP0233: Research Software Engineering With Python

Home
Menu

The Boids!

This section shows an example of using NumPy to encode a model of how a group of birds or other animals moves. It is based on a paper by Craig W. Reynolds. Reynolds calls the simulated creatures "bird-oids" or "boids", so that's what we'll be calling them here too.

Flocking

The aggregate motion of a flock of birds, a herd of land animals, or a school of fish is a beautiful and familiar part of the natural world... The aggregate motion of the simulated flock is created by a distributed behavioral model much like that at work in a natural flock; the birds choose their own course. Each simulated bird is implemented as an independent actor that navigates according to its local perception of the dynamic environment, the laws of simulated physics that rule its motion, and a set of behaviors programmed into it... The aggregate motion of the simulated flock is the result of the dense interaction of the relatively simple behaviors of the individual simulated birds.

-- Craig W. Reynolds, "Flocks, Herds, and Schools: A Distributed Behavioral Model", Computer Graphics 21 4 1987, pp 25-34

The model includes three main behaviours which, together, give rise to "flocking". In the words of the paper:

  • Collision Avoidance: avoid collisions with nearby flockmates
  • Velocity Matching: attempt to match velocity with nearby flockmates
  • Flock Centering: attempt to stay close to nearby flockmates

Setting up the Boids

Our boids will each have an x velocity and a y velocity, and an x position and a y position.

We'll build this up in NumPy notation, and eventually, have an animated simulation of our flying boids.

In [1]:
import numpy as np

Let's start with simple flying in a straight line.

Our positions, for each of our N boids, will be an array, shape $2 \times N$, with the x positions in the first row, and y positions in the second row.

In [2]:
boid_count = 10

We'll want to be able to seed our Boids in a random position.

We'd better define the edges of our simulation area:

In [3]:
limits = np.array([2000, 2000])
In [4]:
positions = np.random.rand(2, boid_count) * limits[:, np.newaxis]
positions
Out[4]:
array([[1596.06304204, 1670.54764671, 1603.19884414, 1511.62607123,
        1545.23044468, 1480.51551972, 1021.28935577,  324.15919888,
         294.25139389, 1026.46967901],
       [ 790.56095996, 1012.97761482,   93.69550511, 1495.94481947,
        1504.14910171,  927.55074629,  925.17914637, 1150.63395254,
        1569.79092028, 1510.27706572]])
In [5]:
positions.shape
Out[5]:
(2, 10)

We used broadcasting with np.newaxis to apply our upper limit to each boid. rand gives us a random number between 0 and 1. We multiply by our limits to get a number up to that limit.

In [6]:
limits[:, np.newaxis]
Out[6]:
array([[2000],
       [2000]])
In [7]:
limits[:, np.newaxis].shape
Out[7]:
(2, 1)
In [8]:
np.random.rand(2, boid_count).shape
Out[8]:
(2, 10)

So we multiply a $2\times1$ array by a $2 \times 10$ array -- and get a $2\times 10$ array.

Let's put that in a function:

In [9]:
def new_flock(count, lower_limits, upper_limits):
    width = upper_limits - lower_limits
    return (lower_limits[:, np.newaxis] + np.random.rand(2, count) * width[:, np.newaxis])

For example, let's assume that we want our initial positions to vary between 100 and 200 in the x axis, and 900 and 1100 in the y axis. We can generate random positions within these constraints with:

positions = new_flock(boid_count, np.array([100, 900]), np.array([200, 1100]))

But each bird will also need a starting velocity. Let's make these random too:

We can reuse the new_flock function defined above, since we're again essentially just generating random numbers from given limits. This saves us some code, but keep in mind that using a function for something other than what its name indicates can become confusing!

Here, we will let the initial x velocities range over $[0, 10]$ and the y velocities over $[-20, 20]$.

In [10]:
velocities = new_flock(boid_count, np.array([0, -20]), np.array([10, 20]))
velocities
Out[10]:
array([[  2.67040309,   1.67236501,   2.26279935,   5.91762343,
          1.99160594,   6.37877966,   7.70084382,   9.15236838,
          9.08226126,   0.52927403],
       [  8.58816575, -12.27412626,  17.95556206,   8.22531988,
         15.71081836,  19.52639305, -14.10955909,  11.67310645,
         -5.51328674,  13.38950322]])

Flying in a Straight Line

Now we see the real amazingness of NumPy: if we want to move our whole flock according to

$\delta_x = \delta_t \cdot \frac{dv}{dt}$

we just do:

In [11]:
positions += velocities

Matplotlib Animations

So now we can animate our Boids using the matplotlib animation tools. All we have to do is import the relevant libraries:

In [12]:
from matplotlib import animation
from matplotlib import pyplot as plt
%matplotlib inline

Then, we make a static plot, showing our first frame:

In [13]:
# create a simple plot
# initial x position in [100, 200], initial y position in [900, 1100]
# initial x velocity in [0, 10], initial y velocity in [-20, 20]
positions = new_flock(100, np.array([100, 900]), np.array([200, 1100]))
velocities = new_flock(100, np.array([0, -20]), np.array([10, 20]))

figure = plt.figure()
axes = plt.axes(xlim=(0, limits[0]), ylim=(0, limits[1]))
scatter = axes.scatter(positions[0, :], positions[1, :],
                       marker='o', edgecolor='k', lw=0.5)
scatter
Out[13]:
<matplotlib.collections.PathCollection at 0x7f62ac16a8b0>
No description has been provided for this image

Then, we define a function which updates the figure for each timestep

In [14]:
def update_boids(positions, velocities):
    positions += velocities


def animate(frame):
    update_boids(positions, velocities)
    scatter.set_offsets(positions.transpose())

Call FuncAnimation, and specify how many frames we want:

In [15]:
anim = animation.FuncAnimation(figure, animate,
                               frames=50, interval=50)

Save out the figure:

In [16]:
positions = new_flock(100, np.array([100, 900]), np.array([200, 1100]))
velocities = new_flock(100, np.array([0, -20]), np.array([10, 20]))
anim.save('boids_1.mp4')

And download the saved animation.

You can even view the results directly in the notebook.

In [17]:
from IPython.display import HTML
HTML(anim.to_jshtml())
Out[17]:
No description has been provided for this image

Fly towards the middle

Boids try to fly towards the middle:

In [18]:
positions = new_flock(4, np.array([100, 900]), np.array([200, 1100]))
velocities = new_flock(4, np.array([0, -20]), np.array([10, 20]))
In [19]:
positions
Out[19]:
array([[ 106.44704965,  185.66421052,  107.20566657,  140.64747818],
       [ 949.22679185,  916.15988092, 1075.87326898, 1077.69531162]])
In [20]:
velocities
Out[20]:
array([[  3.52250203,   2.53770645,   0.7643894 ,   2.0114137 ],
       [-10.31912455,  16.86122225, -18.97239212,   3.85197589]])
In [21]:
middle = np.mean(positions, 1)
middle
Out[21]:
array([ 134.99110123, 1004.73881334])
In [22]:
direction_to_middle = positions - middle[:, np.newaxis]
direction_to_middle
Out[22]:
array([[-28.54405158,  50.67310929, -27.78543466,   5.65637696],
       [-55.51202149, -88.57893243,  71.13445564,  72.95649828]])

This is easier and faster than:

for bird in birds:
    for dimension in [0, 1]:
        direction_to_middle[dimension][bird] = positions[dimension][bird] - middle[dimension]
In [23]:
move_to_middle_strength = 0.01
velocities = velocities - direction_to_middle * move_to_middle_strength

Let's update our function, and animate that:

In [24]:
def update_boids(positions, velocities):
    move_to_middle_strength = 0.01
    middle = np.mean(positions, 1)
    direction_to_middle = positions - middle[:, np.newaxis]
    velocities -= direction_to_middle * move_to_middle_strength
    positions += velocities
In [25]:
def animate(frame):
    update_boids(positions, velocities)
    scatter.set_offsets(positions.transpose())
In [26]:
anim = animation.FuncAnimation(figure, animate,
                               frames=50, interval=50)
In [27]:
positions = new_flock(100, np.array([100, 900]), np.array([200, 1100]))
velocities = new_flock(100, np.array([0, -20]), np.array([10, 20]))
HTML(anim.to_jshtml())
Out[27]:
No description has been provided for this image

Avoiding collisions

We'll want to add our other flocking rules to the behaviour of the Boids.

We'll need a matrix giving the distances between each bird. This should be $N \times N$.

In [28]:
positions = new_flock(4, np.array([100, 900]), np.array([200, 1100]))
velocities = new_flock(4, np.array([0, -20]), np.array([10, 20]))

We might think that we need to do the X-distances and Y-distances separately:

In [29]:
xpos = positions[0, :]
In [30]:
xsep_matrix = xpos[:, np.newaxis] - xpos[np.newaxis, :]
In [31]:
xsep_matrix.shape
Out[31]:
(4, 4)
In [32]:
xsep_matrix
Out[32]:
array([[  0.        ,  34.37519932,   2.08576931,  24.23605445],
       [-34.37519932,   0.        , -32.28943001, -10.13914487],
       [ -2.08576931,  32.28943001,   0.        ,  22.15028514],
       [-24.23605445,  10.13914487, -22.15028514,   0.        ]])

But in NumPy we can be cleverer than that, and make a $2 \times N \times N$ matrix of separations:

In [33]:
separations = positions[:, np.newaxis, :] - positions[:, :, np.newaxis]
In [34]:
separations.shape
Out[34]:
(2, 4, 4)

And then we can get the sum-of-squares $\delta_x^2 + \delta_y^2$ like this:

In [35]:
squared_displacements = separations * separations
In [36]:
square_distances = np.sum(squared_displacements, 0)
In [37]:
square_distances
Out[37]:
array([[    0.        , 30649.9556702 ,   377.96334221,  4197.83820513],
       [30649.9556702 ,     0.        , 24248.3365597 , 12552.07329771],
       [  377.96334221, 24248.3365597 ,     0.        ,  2151.84698803],
       [ 4197.83820513, 12552.07329771,  2151.84698803,     0.        ]])

Now we need to find birds that are too close:

In [38]:
alert_distance = 2000
close_birds = square_distances < alert_distance
close_birds
Out[38]:
array([[ True, False,  True, False],
       [False,  True, False, False],
       [ True, False,  True, False],
       [False, False, False,  True]])

Find the direction distances only to those birds which are too close:

In [39]:
separations_if_close = np.copy(separations)
far_away = np.logical_not(close_birds)

Set x and y values in separations_if_close to zero if they are far away:

In [40]:
separations_if_close[0, :, :][far_away] = 0
separations_if_close[1, :, :][far_away] = 0
separations_if_close
Out[40]:
array([[[  0.        ,   0.        ,  -2.08576931,   0.        ],
        [  0.        ,   0.        ,   0.        ,   0.        ],
        [  2.08576931,   0.        ,   0.        ,   0.        ],
        [  0.        ,   0.        ,   0.        ,   0.        ]],

       [[  0.        ,   0.        ,  19.329069  ,   0.        ],
        [  0.        ,   0.        ,   0.        ,   0.        ],
        [-19.329069  ,   0.        ,   0.        ,   0.        ],
        [  0.        ,   0.        ,   0.        ,   0.        ]]])

And fly away from them:

In [41]:
np.sum(separations_if_close, 2)
Out[41]:
array([[ -2.08576931,   0.        ,   2.08576931,   0.        ],
       [ 19.329069  ,   0.        , -19.329069  ,   0.        ]])
In [42]:
velocities = velocities + np.sum(separations_if_close, 2)

Now we can update our animation:

In [43]:
def update_boids(positions, velocities):
    move_to_middle_strength = 0.01
    middle = np.mean(positions, 1)
    direction_to_middle = positions - middle[:, np.newaxis]
    velocities -= direction_to_middle * move_to_middle_strength

    separations = positions[:, np.newaxis, :] - positions[:, :, np.newaxis]
    squared_displacements = separations * separations
    square_distances = np.sum(squared_displacements, 0)
    alert_distance = 100
    far_away = square_distances > alert_distance
    separations_if_close = np.copy(separations)
    separations_if_close[0, :, :][far_away] = 0
    separations_if_close[1, :, :][far_away] = 0
    velocities += np.sum(separations_if_close, 1)

    positions += velocities
In [44]:
def animate(frame):
    update_boids(positions, velocities)
    scatter.set_offsets(positions.transpose())


anim = animation.FuncAnimation(figure, animate,
                               frames=50, interval=50)

positions = new_flock(100, np.array([100, 900]), np.array([200, 1100]))
velocities = new_flock(100, np.array([0, -20]), np.array([10, 20]))
HTML(anim.to_jshtml())
Out[44]:
No description has been provided for this image

Match speed with nearby birds

This is pretty similar:

In [45]:
def update_boids(positions, velocities):
    move_to_middle_strength = 0.01
    middle = np.mean(positions, 1)
    direction_to_middle = positions - middle[:, np.newaxis]
    velocities -= direction_to_middle * move_to_middle_strength

    separations = positions[:, np.newaxis, :] - positions[:, :, np.newaxis]
    squared_displacements = separations * separations
    square_distances = np.sum(squared_displacements, 0)
    alert_distance = 100
    far_away = square_distances > alert_distance
    separations_if_close = np.copy(separations)
    separations_if_close[0, :, :][far_away] = 0
    separations_if_close[1, :, :][far_away] = 0
    velocities += np.sum(separations_if_close, 1)

    velocity_differences = velocities[:, np.newaxis, :] - velocities[:, :, np.newaxis]
    formation_flying_distance = 10000
    formation_flying_strength = 0.125
    very_far = square_distances > formation_flying_distance
    velocity_differences_if_close = np.copy(velocity_differences)
    velocity_differences_if_close[0, :, :][very_far] = 0
    velocity_differences_if_close[1, :, :][very_far] = 0
    velocities -= np.mean(velocity_differences_if_close, 1) * formation_flying_strength

    positions += velocities
In [46]:
def animate(frame):
    update_boids(positions, velocities)
    scatter.set_offsets(positions.transpose())


anim = animation.FuncAnimation(figure, animate,
                               frames=200, interval=50)


positions = new_flock(100, np.array([100, 900]), np.array([200, 1100]))
velocities = new_flock(100, np.array([0, -20]), np.array([10, 20]))
HTML(anim.to_jshtml())
Out[46]:
No description has been provided for this image

Hopefully the power of NumPy should be pretty clear now. This would be enormously slower and harder to understand using traditional lists.