Lab Exercise: The Radial Distribution Function for Flying Discs

In this Notebook we analyse movies taken from a 2D particle simulator used for educational purposes. In this device, small magnetic and repulsive discs move on a flat surface due to air flowing from the sides as well as from underneath to reduce friction (see figure below). The particles are confined by repulsive, magnetic bars, located on the four sides of the square surface.

Tips for Jupyter Notebooks

  • Double click on a cell to edit it.
  • Execute code in a cell by pressing shift+return.
  • For getting help on a function, place the cursor inside the () brackets and press shift+tab-tab.
  • More on text formatting, equations etc. here.

Part I: Structure

In the first part we explore the radial distribution function (RDF), $g(r)$.

Flow of program:

  1. split video into individual images (requires ffmpeg)
  2. find particle positions and save to trajectory file (.h5 format)
  3. calculate distance histogram
  4. calculate radial distribution function

More here info about the particle tracker here.


In [53]:
# load modules required for the analysis
from __future__ import division, unicode_literals, print_function  # for compatibility with Python 2 and 3
import matplotlib as mpl
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np, pandas as pd
from pandas import DataFrame, Series
import os, pims, scipy, trackpy as tp
import base64
from scipy.spatial import distance
from IPython.display import HTML
plt.rcParams.update({'font.size': 16, 'figure.figsize': [8.0, 6.0]})

# this function is used to visualize videos in the notebook
def video(file, mimetype='mp4'):
    """ Show given video file """
    video_encoded = base64.b64encode( open(file, "r+b").read() )
    return HTML(data='''<video alt="test" controls>
                <source src="data:video/mp4;base64,{0}" type="video/mp4" />
             </video>'''.format(video_encoded.decode('ascii')))

Input Data

The following videos of particle simulations were recorded using a smart phone and these will be the basis of the following analysis.
Here we create an array containing the filenames of the movies and two parameters that will be used for the image analysis:

  1. diameter: the diameter of the particles
  2. percentile: minimum relative brightness of the particles to distinguish them from the background

In [54]:
movies = [
    {'file': 'movies/N20-light.mp4', 'percentile': 60, 'diameter': 53}, # dictionary for movie 0
    {'file': 'movies/N40-light.mp4', 'percentile': 60, 'diameter': 53}, # dictionary for movie 1
    {'file': 'movies/N55-light.mp4', 'percentile': 60, 'diameter': 53}  # dictionary for movie 2
] # more movies can be added as needed...

Select one of the videos

Use the indexes from 0 to 2 to select one of the dictionaries containing filename, percentile and diameter for a system of N particles.


In [55]:
movie = movies[1]  # select movie file here by index, starting from 0
moviefile = movie['file'] # select key 'file' of the dictionary

Here we visualize the movie.


In [56]:
video(moviefile)


Out[56]:

Split movie into individual files

Here we use the command line tool ffmpeg to split the movie into individual images as these are easier for the trackpy module to handle.
First we check if the directory movies exist–if yes the images have already been generated and we can skip this step.


In [57]:
imgdir=os.path.splitext(moviefile)[0]
if not os.path.exists(imgdir):
    os.makedirs(imgdir)
    !ffmpeg -i $moviefile -f image2 -vcodec mjpeg $imgdir/img-%03d.jpg -v 0

Locate particle positions using image recognition

Before analysing all frames found in the 10-15 sec movie, let's check if the (slow) feature extraction works for a single frame (e.g. the first one, frames[0]).


In [58]:
frames = pims.ImageSequence(imgdir+'/img*.jpg', as_grey=True) # load all frames
print('read',len(frames),'frames.')
f = tp.locate(frames[0], diameter=movie['diameter'], invert=True, percentile=movie['percentile'])
print(f.tail())

# this is how we may separate light and heavy particles
heavy = f[f['mass']>50000] # heavy particles
light = f[f['mass']<50000] # light particles

tp.annotate(f, frames[0])
plt.show()


read 498 frames.
             y           x          mass       size       ecc     signal  \
58  539.479080  308.345977  58275.727616  13.463866  0.092761  67.254363   
60  549.263637  399.247833  78042.461004  15.828045  0.045238  68.677736   
61  560.277062  145.107482  74190.102624  15.772882  0.027046  69.033579   
63  560.576349  487.100510  71491.032024  16.126655  0.012841  67.610206   
64  553.675756  230.390975  56257.740883  13.695183  0.068629  65.830990   

    raw_mass   ep  frame  
58  317517.0  0.0      0  
60  367963.0  0.0      0  
61  375283.0  0.0      0  
63  387223.0  0.0      0  
64  325073.0  0.0      0  

Extract particle positions from all frames

Assuming that the recognition settings are OK, let's loop over all frames, extract features, and save to a .h5 trajectory file. This process is done only if the trajectory file, trjfile, does not exist,

Warning: this is a slow process!


In [59]:
trjfile=os.path.splitext(moviefile)[0]+'.h5'
if os.path.isfile(trjfile):
    print('opening existing trajectory file', trjfile)
else:
    with tp.PandasHDFStore(trjfile) as s:
        cnt=0
        for image in frames:
            cnt=cnt+1
            print( 'frame %d/%d.' % (cnt, len(frames)), end=' ')
            features = tp.locate(
                image, diameter=movie['diameter'], percentile=movie['percentile'], invert=True)
            print('number of particles =',len(features))
            s.put(features[['x','y','mass','frame']])


opening existing trajectory file movies/N40-light.h5

Read trajectory file and calculate distances between all points

In this section we calculate all distances between all particles for each frame. These are then binned into a histogram to give the probability of observing a particular separation.
At the same time we sample the distribution for ideal particles by simply generating random positions and perform the same analysis as for the "real" particles.


In [60]:
dist  = np.ndarray(shape=(0,0))  # movie distribution
udist = np.ndarray(shape=(0,0))  # uniform distribution

with tp.PandasHDFStore(trjfile) as s:
    # measure all distances between discs
    for frame in s:
        dist = np.append( dist, distance.pdist( frame[['x','y']] ))

    data = s.dump() # keep full trajectory in `data`
    
    # find box corners
    xmin,xmax = min(data.x), max(data.x)
    ymin,ymax = min(data.y), max(data.y)
    
    # measure distances for uniform, random distribution (no correlations)
    # (number of points/particles is unimportant.)
    x     = np.random.randint(xmin, xmax+1, 4000)
    y     = np.random.randint(ymin, ymax+1, 4000)
    p     = np.array([x, y]).T
    udist = np.append( udist, distance.pdist(p) )
        
    hist  = plt.hist(dist,  bins=150, normed=True, range=[0,700], histtype='step', color='black', label='real')
    uhist = plt.hist(udist, bins=150, normed=True, range=[0,700], histtype='step', color='red', label='ideal')

    plt.legend(loc=0,frameon=False)
    plt.xlabel('distance (pixels)')
    plt.ylabel('probability')


Radial Distribution Function, $g(r)$

We have now calculated the distance distribution, hist, from the simulated particles from the movie, as well as for a uniform distribution of $N$ particles, uhist. The radial distribution function is simply the ratio between the two. This means that if the particles were behaving ideally (which they don't), $g(r)$ would be unity for all separations, $r$. After plotting, the final rdf is saved to disk.


In [61]:
r = hist[1][:len(hist[0])]
g = hist[0] / uhist[0]
plt.plot( r, g, 'k-')
plt.xlabel('$r$ (pixels)')
plt.ylabel('$g(r)$')
plt.title('Radial distribution function (RDF)')

rdffile=os.path.splitext(moviefile)[0]+'.rdf.dat'
np.savetxt(rdffile, np.array([r,g]).T, header='rdf from '+moviefile)


Plot all rdf's found on disk


In [62]:
for d in movies:
    name = os.path.splitext(d['file'])[0]
    rdffile = name+'.rdf.dat'
    if os.path.isfile(rdffile):
      r, g = np.loadtxt(rdffile, unpack=True )
      plt.plot(r, g, '-', label=os.path.basename(name), lw=2)
plt.legend(loc=0,frameon=False)
plt.xlabel('$r$')
plt.ylabel('$g(r)$')
plt.xlim([40,300])
plt.ylim([0,4])


Out[62]:
(0, 4)

Exercises, Part I

  1. Why does $g(r)$ deviate from unity at large separations?
  2. What is the particle size and the system's volume fraction (area occupied by the particles / total area)?
  3. It seems as if there's a small maximum in $g(r)$ at short separations. Is this real and, if so, how is this possible for repulsive particles?
  4. Convert $g(r)$ to the potential of mean force and plot this (Hint: use numpy's function np.log() as in pmf=-np.log(g)).
  5. Repeat the full analysis but for a more concentrated system. Discuss differences.

Part II: Kinetics (under construction: not a part of the lab)

Todo:

  • Compare velocity distribution w. Maxwell-Boltzmann
  • Calculate diffusion coefficient

In [63]:
t = tp.link_df(data, search_range=20, memory=3)
tp.plot_traj(t)


Frame 497: 40 trajectories present.
Out[63]:
<matplotlib.axes._subplots.AxesSubplot at 0x11c2eaef0>

In [64]:
em = tp.emsd(t, mpp=20./600., fps=24) # mpp=cm per pixel

In [65]:
plt.plot(em.index, em, 'o')
plt.xscale('log')
plt.yscale('log')
plt.ylabel(r'$\langle \Delta r^2 \rangle$ [cm$^2$]')
plt.xlabel('lag time $t$ [s]')


Out[65]:
Text(0.5,0,'lag time $t$ [s]')

Contact Information

Mikael Lund, Department of Theretical Chemistry
Lund University, POB 124, SE-22100 Lund, Sweden
http://www.teokem.lu.se