How I used Python to induce Optokinetic Nystagmus

Shivan Sivakumaran
11 min readNov 15, 2020

Time to go back. Back in time. It’s prime time. Fifth and final year of university. A year filled with stress but also a fun project. And that’s what I want to talk about today.

Click here to view the original post.

In our final year of university, we had to complete a project related to an emerging field in Optometry.

Some did novel treatments on dry eye, others delved into the unknown of myopia control. I chose to use pursue a novel method of testing vision with optokinetic nystagmus.

With syllables longer than my degree, you would think I would be over it now? Nope. Since I’ve been learning Python, let’s recreate my good ol’ university days with this lovely language.

Here is the github to the code. And here is the result:

The final result

Continue and see how we can come to this result. Let’s begin.

Special Thanks

Let’s begin with a thanks. I would like to thank my old supervisor for inspiring me on this project. On the off change, they are reading this — I really wished I was better and more on to it at the time.

What is Python?

Python is a programming language. Both popular and easy to learn, I talk more about this in a post on my intended learning programming pathway.

Python is blessed with a vast array of libraries/modules. Once of these libraries is called pygame. You can do a log with pygame like build two dimensional games. However for the purpose of this project, we are going to use pygame to display stimulus on a screen.

What is Optokinetic Nystagmus?

Optokinetic nystagmus or OKN is a slow pursuit (slow eye movement) followed by a fast saccade (quick eye movement) in the opposite direction. This is generally involuntary in response to a particular type of stimulus that is repeating and in one direction.

A good example is when you are in a moving car (a passenger of course). When you look at the passing telephone/electrical/fence poles, your eyes will exhibit this response.

What’s the point of this?

What we want to measure is visual acuity or VA.

VA is one of the ‘life signs’ of the eye.

It is a how well detail can be defined. For example a person who is ‘6/6’ vision is considered to be able to see well (i.e. street signs from a distance) compared to someone with ‘6/60’ type vision.

I have written a post about this more extensively and how it is measured here.

Visual acuity for most of the population can be easy to measure. Just use a letter chart.

Where is becomes interesting, is where the person is illiterate. They could be too young to communicate, or they cannot understand how the test works. I know this is rare, but it happens. OKN can be used to measure vision — with a bit more steps involved!

Pygame

Pygame is a way of putting moving objects on a screen. This is what we are going to use to stimulate OKN.

This library has some excellent documentation, which explains how movement is handled.

Before we Begin

For this project, we are going to use Python 3.8. Before we truly begin, let’s set up a virtual environment and get those libraries that are not included in the standard installation. I’m using VScode for this project, as well.

We create a new folder and link the correction Python as the interpreter. Let’s create that virtual environment:

python3 -m venv VAtest_withOKN

We can activate this environment using:

source ./VAtest_withOKN/bin/activate

As per convention, let’s create a requirements.txt.

pygame >= 2.0.0
numpy >= 1.19.4

Let’s them install our libraries from the requirements.txt.

pip install -r requirememts.txt

This should install the appropriate version of pygame, version 2.0.0, which is the latest at the time of posting this.

Importing the libraries

Let’s start by importing our libraries that we are going to use. You will see the libraries in action as we go on.

import sys
import random
import numpy as np
import pygame
import time
from pygame.locals import *

Building a screen

Creating our screen is quite simple. We will build a screen that is 2000 by 1500 pixels.

pygame.init()
(W, H) = (2000, 1500) # this creates a window 2000 by 1500 pixels in size
# you may have to vary this based on resolution of your monitor
tup = (W, H)screen = pygame.display.set_mode(tup)

This is pretty uninteresting window. Let’s give it a title:

pygame.display.set_caption('OKN_VA')

Now let’s alter the background. We do this in multiple steps: we set the background colour and then ‘blit’ it onto screen.

# creating the background
background = pygame.Surface(screen.get_size())
background = background.convert()
grey_50 = (128, 128, 128)
background.fill(grey_50) # 50% grey
# blit on to screen
screen.blit(background, (0, 0))
pygame.display.flip() # updates background

And this is what we are left with:

If we were to run this of a program and not the command line, we need to create a while loop that will run indefinitely, so the screen does not exit at the end of the program’s runtime.

while True:
for event in pygame.event.get():
if event.type in (QUIT, KEYDOWN):
sys.exit()
screen.blit(background, (0, 0))
pygame.display.flip()

This will mean the program is running until a key is pressed or the program is forced to end.

Creating the Optotypes

We have a blank screen now but with no stimulus. We are going to use an optotype. An optotype is what we use to measure vision. Letters on a vision chart are optotypes.

In choosing a optotype, I think it will be a good idea to introduce my old supervisor’s research. He has developed something much better than what I could have done. This can be found here.

There are a few option in what shape for an optotype can be used. I’m thinking the circles would be easy to replicate.

I’m going to change these circles and make them into high contrast targets. This is inspired again(1).

The idea is that if blur were introduced, the blacks and whites would be averaged to the gray background and would no longer be visible. What would alter is the ‘thickness’ of the black and the white to reflect the visual acuity.

Thankfully, pygame comes with an easy way to draw circles:

cir_col = (0, 0, 0) # Circle colour
cir_pos = (W/2, H/2) # middle of screen
cir_r = W/10 # radius of circle (size)
cir_border = 0 # border of circle which we will exclude
circle = pygame.draw.circle(background, cir_col, cir_pos, cir_r, cir_border)

Here is the result, a circle.

Here’s a circle, but let’s apply our principle of high contrast. To do this, we need to draw circles on top of each other. The subsequent circles would be smaller in diameter by our desired thickness.

d = 10 # the smaller circles, this number is arbitarycircle_1 = pygame.draw.circle(background, cir_col, cir_pos, cir_r, cir_border) 
circle_2 = pygame.draw.circle(background, cir_col, cir_pos, cir_r-d, cir_border)
circle_3 = pygame.draw.circle(background, cir_col, cir_pos, cir_r-3*d, cir_border)
circle_4 = pygame.draw.circle(background, cir_col, cir_pos, cir_r-4*d, cir_border)

Notice how there are four circles. The first circle is white and largest in diameter. The second circle is black but smaller in diameter; we take away d to achieve this. The next circle is white but smaller by not twice d but three times. We do this because we need the black edge to be twice thick the white edges. This means it will average out to zero.

class DrawCircle:    def __init__(self, screen_, x_pos, y_pos, radius, diff):
self.screen_ = screen_
self.x_pos = x_pos
self.y_pos = y_pos
self.radius = radius
self.diff = diff
return def drawCircle(self): white = (255, 255, 255)
black = (0, 0, 0)
grey = (128, 128, 128)
cir_pos = (self.x_pos+self.radius, self.y_pos+self.radius)
r = self.radius
d = self.diff
pygame.draw.circle(self.screen_, white, cir_pos, r, 0)
pygame.draw.circle(self.screen_, black, cir_pos, r-d, 0)
pygame.draw.circle(self.screen_, white, cir_pos, r-3*d, 0)
pygame.draw.circle(self.screen_, grey, cir_pos, r-4*d, 0)
return

def move(self, move_speed):
self.x_pos = self.x_pos + move_speed if self.x_pos < 0:
self.x_pos = self.screen_.get_size()[0]
elif self.x_pos > self.screen_.get_size()[0]:
self.x_pos = 0
return def changeVA(self, acuity):
# changes thickness of circle lines
self.diff = acuity return

Don’t believe me? One white edge is half-d thick, the other white edge is half-d thick, so we need black to be d thick to negate the white.

I do not think one circle will cut it. We will need to make this into a class where we can make many objects from this.

Now we have the ability to create multiple circles from this class. We can start with having these circles in a line.

There are a few methods here:

  • drawCiricle() – creates multiple circles that make up the optotype
  • move() – this moves to circles and resets their position once they reach the end of the screen
  • changeVA()– this changes how visible the stimulus. In the future, the monitor size and dimensions can be taken into consideration as well as how far the user is to determine the true visual acuity.

Here is our result:

nx = 10 # number of circles in a rowcircles = [DrawCircle(background, x, 100, 50, 5, 15)
for x in range(0, W, int(W/n_circles))]
for circle in circles:
circle.drawCircle()

One line won’t suffice. Let’s fill our screen up. We are going to rewrite the top section of code. We are also going to use np.arange which creates an evenly spaced array of numbers. This array is a bit faster to access than using a python list or range.

nx, ny = 15, 10 # columns, rows of circlesa = np.arange(0, W, W/nx)
b = np.arange(0, H, H/ny)
circles = [DrawCircle(background, x, y, 50, 5, 15) for x in a for y in b]for circle in circles:
circle.drawCircle()

And here we have all our circles. We’ve also made the circles a bit smaller so they can fit on screen.

Just as a side: the list comprehension really helps when you are in a situation where you have a for-loop-in-a-for-loop. What do I mean by this? When we drew out our circles, we would have to replicate them over rows and columns. This requires two for loops:

a = np.arange(0, W, W/nx)
b = np.arange(0, H, H/ny)
circles = []
for x in a:
for y in b:
circles.append(DrawCircle(background, x, y, 50, 5, 15))

Thanks to Python’s list comprehension, we can write this in one single line.

circles = [DrawCircle(background, x, y, 50, 5, 15) for x in a for y in b]

Beautiful!

Getting things moving

Obviously, a bunch of static circles is not going to simulate OKN. We need to get them moving. Thankfully, we can do this with the move() method we created earlier.

Let’s go back to our while loop that we made before.

while True:        for event in pygame.event.get():
if event.type in (QUIT, KEYDOWN):
sys.exit()
for circle in circles:
circle.move(11) # speed of OKN stimilus
circle.drawCircle()
screen.blit(background, (0, 0))
grey_50 = (128, 128, 128)
background.fill(grey_50)
pygame.display.update()
return

This while loop, which we will call the ‘event’ loop runs indefinitely. It moves the circles, draws the circles. The display updates to blank and the process is repeated. This gives us the illusion that the circles are moving across the screen.

Here we have it. Click the play button below.

We need to take this one step further. Let’s change the ‘acuity’ of the circles for an elapsed period of time. Let’s expand on our event loop from above.

rand_dir = 1
timer = time.time()

while True:
for event in pygame.event.get():
if event.type in (QUIT, KEYDOWN):
sys.exit()
for circle in circles:
circle.move(rand_dir*11) # speed of OKN stimilus
circle.drawCircle()
screen.blit(background, (0, 0))
grey_50 = (128, 128, 128)
background.fill(grey_50)
pygame.display.update()
if time.time() - timer >= 5: # length of 'VA' shown for

pygame.time.delay(500)
timer = time.time() rand_VA = random.randint(0, 10)
rand_dir = random.choice([-1, 1])
for circle in circles:
circle.changeVA(rand_VA)
return

You can see we’ve added rand_dir, which is a modifier for direction the stimulus travels. There is also a timer, which we will use to define how long the stimulus has been displayed for. When we reach that time a condition is activated, this results in the stimulus size changing randomly in both ‘acuity’ and direction.

Let’s check it out! Click on the play button. The stimulus changes every 5 seconds. Funnily enough, the first change shows no circles because black and white circles have no thickness.

Conclusion

We have done quite a lot in this project and I’m hoping you are feeling quite satisfied. We learned about optokinetic nystagmus: an involuntary eye movement which is characterised as a smooth motion followed by a rapid eye movement in the opposing direction to a moving and repeating stimulus.

Next, we used the Python library, pygame, to create stimulus and get it moving across the screen.

We used the class method – a foray into object orientated programming – to create multiple stimuli in the form of stacked circles.

There is more that could be improved on, but if we set out the fix everything, then time would pass us by. We can always improve on these things later.

Thank you for reading this. I really hoped you enjoyed this. If you have any feedback, please get in touch with me or comment below. Share this with anyone who you would think find useful.

What can be done better

Whenever you have a project, you can fall down the rabbit hole of continually wanting to add more features and make it better and better.

We can do this, but you probably have a life. Project perfection is time consuming and you often notice time going on without you. Let’s just be satisfied with how far we have come and list down things we want to work on for the future, which may or may not happen.

They are:

  • More accuracy in the visual acuity
  • Contrast
  • Detecting OKN using a camera

Let me elaborate on these points a bit more.

Let’s start with the simplest: more accurate visual acuity. Instead of randomly changing the thicknesses of the circles arbitrarily, the monitor size, pixel density and distance away from the monitor should be taken into account to workout the visual acuity.

On top of this, high contrast targets were used. Maybe this could vary with low contrast targets or being able to adjust this at least.

Finally, what we can consider a huge commitment, would be to use some extra python libraries like OpenCV to detect the pupil and observe OKN as it were happening. By measuring the amount of OKN, the visual acuity could be adjusted to determine how good their vision is.

References

  1. Shah N, Dakin SC, Redmond T & Anderson RS. Vanishing Optotype acuity: repeatability and effect of the number of alter-natives.Ophthalmic Physiol Opt2011,31, 17–22. doi: 10.1111/j.1475–1313.2010.00806.x

--

--