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.
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:
Continue and see how we can come to this result. Let’s begin.
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.
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 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:
As per convention, let’s create a
pygame >= 2.0.0 numpy >= 1.19.4
Let’s them install our libraries from the
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:
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.
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 arbitary circle_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() elif self.x_pos > self.screen_.get_size(): 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 row circles = [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 circles a = 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]
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
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.
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.
- More accuracy in the visual acuity
- 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.
- 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