Welcome to PyParadigm¶
PyParadigm is a small set of classes and functions designed to make it easy to write psychological paradigms in Python.
“Why another presentation software?” You may ask. There is already a lot of software, like E-Prime, Presentation, or Matlab. But since you are reading the documentation of a Python library, I assume you already decided to use freely available, non-commercial options. Of course there is still PsychoPy, but it was never ported to Python3.
PyParadigm takes another approach. It does not force you to use a mouse to drag and drop a paradigm together and struggle with some details that might not have been forseen by the developers. Paradigms usually are just a sequence of screens combined with some user-interaction, which will generate some data that needs to be stored afterwards. Python allows you to manipulate the screen in any thinkable way and process keyboard and mouse input arbitrarily through the great PyGame library. But while PyGame is great, it requires a lot of code to write a paradigm, mostly because it is designed to write programs that are much more complex than paradigms (i.e. video games). And this is where PyParadigm comes in. It reduces the amount of required code to a minimum. To wet your appetite, I will present a short script that implements a simple inter temporal choice task, where the subject chooses between 2 offers with the left or the right arrow-key, and gets a short feedback. The decisions, including delay and amount, will be stored in a json-file.
The screens looks like this:
Now the subject has to choose an option through a button press, in this case she/he chooses the right option.
And this is the script:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | import pygame
from pyparadigm.surface_composition import *
from pyparadigm.misc import empty_surface, display, init
from pyparadigm.eventlistener import EventListener
import json
import time
# Scroll to the bottom, and start reading in the main() ;)
def offer_box(title, amount):
# Creates a border around a vertical layout containing 2 cells, where the
# lower one has twice the size of the upper one (layout children are
# automatically wrapped in LLItems with relative_size=1). Both Boxes are
# filled with text, wich is centered in its parent area.
return Border()(
LinLayout("v")(
Text(title, Font(size=50)),
LLItem(2)(Text(f"{amount}€", Font(size=50, bold=True)))
)
)
def make_offer(now, later, delay):
# Create pygame.Surface with a white background.
# The LinLayout splits the available space into (in this case)
# equally sized horizontally aligned parts. 80% of the available
# space of each part is used to display a offer box.
return compose(empty_surface(0xFFFFFF), LinLayout("h"))(
Padding.from_scale(0.8)(offer_box("Now", now)),
Padding.from_scale(0.8)(offer_box(f"In {delay} days", later)),
)
def make_feedback(amount, delay):
# creates a pygame.Surface which only contains the text message
msg = f"{amount}€ " + ("now" if delay == 0 else f"in {delay} days")
return compose(empty_surface(0xFFFFFF))(Text(msg, Font(size=50)))
def main():
# initiate a window with a resolution of 800 x 600 pixels
init((800, 600))
# alternatively, to create a full screen, hardware accelrated window, you
# could use:
# init((1920, 1080), pygame.FULLSCREEN | pygame.HWSURFACE | pygame.DOUBLEBUF)
# Create an Eventlistener object
event_listener = EventListener()
# Initiate the data for the paradigm, and create 2 lists to store
# the results
immediate_offers = ([10] * 3) + ([20] * 3) + ([30] * 3)
delays = [10, 20, 30] * 3
delayed_offers = [delay + im_offer
for delay, im_offer in zip(delays, immediate_offers)]
chosen_amounts = []
chosen_delays = []
reaction_times = []
# Execute the paradigm
for im_offer, del_offer, delay in zip(immediate_offers, delayed_offers, delays):
# display the offer
display(make_offer(im_offer, del_offer, delay))
offer_onset = time.time()
# wait for a decision in form of the left or right arrow-key
key = event_listener.wait_for_keys([pygame.K_LEFT, pygame.K_RIGHT])
# calculate reaction time and save it
reaction_times.append(time.time() - offer_onset)
# store results according to decision
if key == pygame.K_LEFT:
chosen_amounts.append(im_offer)
chosen_delays.append(0)
else:
chosen_amounts.append(del_offer)
chosen_delays.append(delay)
# display a feedback for 2 seconds
display(make_feedback(chosen_amounts[-1], chosen_delays[-1]))
event_listener.wait_for_seconds(2)
# save results to a json File
with open("results.json", "w") as file:
json.dump({"amount": chosen_amounts, "delay": chosen_delays,
"reaction_times": reaction_times}, file)
if __name__ == '__main__':
main()
|
The next step now would be to read the tutorial
A tutorial¶
Installation¶
As most python libraries, PyParadigm can be installed via pip:
pip install pyparadigm
And thats it.
Overview¶
- PyParadigm is split into 4 modules:
- The Surface Composition Module which allows to create
- pygame.Surfaces , which is the class representing images, in a declarative way.
- The Event Listener Module which allows to react to user input
- The Misc-Module which just contains a few utility functions
- Extras which contains functions to render numpy arrays
Althout PyParadigm is organized into multiple modules, everything can be imported from pyparadigm directly. The contents of Extras is only imported if matplotlib and numpy are installed
Creating a Window¶
In the simplest option to create a window is nothing but a call to the
init()
function of the Misc-Module, which only takes
one parameter: a 2-tuple with the prefered resolution. E.g. init((800,
600))
. However, most of the time, this is not exactly what you want.
Usually you want to create a full-screen or borderless window (which looks like
fullscreen, if it has the size of the screen, but behaves a little different).
For these scenarios, you can use the pygame_flags argument.
One of the things that init()
does is calling
pygame.display.set_mode(), which
is the pygame function that creates the window, and the flags argument is passed
through. Here, a short list of flags you will care about the most:
pygame.FULLSCREEN
which will create a full-screen window.pygame.NOFRAME
which creates a window without window-frame. This looks- like fullscreen if the created window has the same resolution as the desktop and is placed at (0, 0).
By default, the CPU will be used to render the images. This should suffice for
most paradigms. If, however, your paradigm is computationally very intensive and
requires a GPU you could use pygame.HWSURFACE, usually in combination with
pygame.DOUBLEBUF and pygame.FULLSCREEN, you can combine multiple flags with the
|
operator. E.g:
init((1920, 1080), pygame.FULLSCREEN | pygame.HWSURFACE | pygame.DOUBLEBUF)
A warning: Creating a hardware accelerated window in other systems than Windows can be problematic. Now we can worry about how to fill this screen.
Creating Surfaces¶
An important concept here is to avoid worrying about absolute positions. Using
compose()
, an image-structure can be described as a tree of elements.
E.g.:
image = compose(target_surface)(
LinLayout("h")(
Circle(0xFF0000, width=1),
Circle(0x00FF00, width=1)
)
)
Here, the available space (which is the size of target_surface
) is divided
horizontally ("h"
) into 2 parts of equal size. Generally, the space is
equally divided between the children if not explicitly modified. Then, a red
circle will be drawn into the left area and a blue one in the right area. The
trees can get arbitrarily complex, and I recommend to take a look at the
examples
Here is a list of the different elements that can be used within
compose()
- Containers with multiple children:
LinLayout
arranges items in a horizontal or vertical lineGridLayout()
arranges items in a gridOverlay
draws its children on top of each other
- Wrappers, which take a single child:
Padding
creates a padding around its childLLItem
is only usable within aLinLayout
and defines- proportions of items within a
LinLayout
Surface
wraps pygame.Surfaces.- E.g. loaded stimuli from files or texts, which are also generated as
Surfaces. All pygame.Surfaces in a tree are wrapped in
Surface
objects automatically. It can also be done manually to change placement or scaling options.
RectangleShaper
is closely related toPadding
. It- will create horizontal or vertical padding to create a child-shape with a desired aspect ratio.
Fill
fills the assigned area with a given color before- rendering its child. Can also be used without child.
Border
creates a border around its area. Can also be used- without child.
- Primitives that don’t take any children:
Circle
draws a circle in the assigned areaCross()
draws a cross within the assigned areaLine
draws a line within the assigned areaText()
creates a pygame.Surface containing the passed text. The- text can be multi-line, left-/ or right-aligned or centered. It takes a pygame.Font as additional argument.
Children are generally passed via the __call__()
operator of the
object. E.g. LinLayout("h")(child1, child2, child3)
Whenever something only
takes a single child, the child can be a container. This way, it is possible to
add multiple children whenever only one child is allowed. compose()
itself allows only one child, which gets the whole image as target area. But
since a lot of compose()
calls would have a container as its child,
compose()
allows a second argument, which can be any component that
takes at least one child (except for Surface). The above example could also be
written like this:
image = compose(target_surface, LinLayout("h"))(
Circle(0xFF0000),
Circle(0x00FF00)
)
The first argument to compose()
can either be a pygame.Surface to
render on (like above) or a 2-tuple with width and height. In the second case, a
new pygame.Surface with the specified dimensions would be created. To get a desired
background color for the newly created surface the root component should be a
Fill
object.
The most common case though would be
image = compose(empty_surface(color), LinLayout("h"))(
Circle(0xFF0000),
Circle(0x00FF00)
)
empty_surface()
is part of the Misc-Module and will
create a new pygame.Surface which is automatically filled with the given color.
A size for the new surface can be specified as second argument. If the size
argument is omitted, the created pygame.Surface will automatically have the size of the
display.
To display saved images, use pygame.image.load()
and just use the
loaded pygame.Surface in compose.
Creating Text¶
Text()
is not an object with a _draw()
-method but a function that returns
a pygame.Surface, which contains the text on a transparent background.
Since a pygame.Surface is automatically wrapped into a
surface_composition.Surface
object, it can be used like any other object.
This means that it will be centered in the available space and scaled down if the
available space is smaller than the text, but not scaled up otherwise.
You can wrap it explicitly in a surface_composition.Surface
to
change scaling and positioning behavior.
Text()
takes a pygame.Font as second argument, which can also be used to set the
size, and modifiers i.e. bold and italic.
Also Text supports multi-line texts which will be aligned according to the
align-parameter.
To load a font, the Font()
function can be used. If called without
parameters, it will use the default system font with size=20 and without any modifiers, e.g.:
Text("Hello\nWordl!", Font())
Usually most text within a paradigm uses the same font settings. Therefore, it’s recommended to define a function with according parameters. e.g.:
instruction_text = lambda s: Text(s, Font("arial", bold=True, size=30))
A tip for performance¶
Commonly, a paradigm is composed of a hand full of screens, which are the
same except for the specific content. E.g in the
IteCh example, there is a function make_offer()
that will
create the offer screen and takes the details of the offer as arguments.
If such a function is called multiple times with the same
arguments, it is recommended to use
functools.lru_cache
as annotator. In this way, the screen will only be computed once for every unique
parameter combination, and, after the first call, the result will be returned from
cache, which lowers computation time.
The reason this was not done in the IteCh example was
that make_offer()
was never called twice for a unique parameter combination.
Using numpy arrays as images¶
It is possible to use numpy arrays as input for images. The extras module
contains the mat_to_surface()
function, which will return a
pygame.Surface which can then be used within compose. It expects a 2D array of
rgb values, and applies a transformer function to create a gray-value image.
Alternatively apply_color_map()
can be used to get a colored surface
according to a matplotlib color map.
To generate a pygame.Surface from a 3D array where the third axis contains rgb
values you can use pygame.pixelcopy.make_surface()
. Be aware that it
will silently transpose your array.
Reacting to user input¶
For input The Event Listener Module is
used, which handles the corresponding pygame events. When the user presses a
key, a pygame.Event is generated and added to the event queue. The
EventListener
’s listen()
method will query all pending
events from the event-queue and process them according to handler-functions. It
has already three methods that should suffice for most needs:
wait_for_n_keypresses()
will return if a specified key was- pressed n times.
wait_for_keys()
will return if one of the given keys- was pressed and return the pressed key. It also supports a timeout; when the
timeout is reached without a user pressing one of the keys,
None
is returned.
wait_for_seconds()
will return after n seconds. Use this method- instead of
time.sleep()
, so events will be processed in the meantime.
I recommend taking a look at the implementation of these 3 methods to see how
to use the listen()
-method to implement your own handlers. The source
can be viewed from the module documentation page. There,
you can also find in-depth explanations on how to use the EventListener class.
Getting text input¶
For text input wait_for_unicode_char()
will return a string with the
last pressed key expressed as a single character, so pressing the a key, will
return an “a”, pressing shift + a will return “A” and pressing return will
return “r”. Therefore it is necessary to have a buffer. You can use
process_char()
(from the misc module)
to update the buffer using the returned character.
Example:
from pyparadigm import init, EventListener, compose, display, Text,\
Font, process_char, empty_surface, Margin, Surface
init((400, 100))
buffer = ""
el = EventListener()
while True:
display(compose(empty_surface(0xFFFFFF))(
# using a left top margin of 0 will put the resulting pygame.Surface
# to the left top corner
Surface(Margin(left=0, top=0))(
Text(buffer, Font("monospace"), align="left")
)))
new_char = el.wait_for_unicode_char()
if new_char == "\x1b": # Str representation of ESC
break
else:
buffer = process_char(buffer, new_char)
Getting mouse input¶
In this scenario it is easier to use an example. The following code will display 4 squares of random color:
import random
from pyparadigm import init, EventListener, compose, display,\
empty_surface, GridLayout, Fill, EventConsumerInfo
import pygame
init((400, 400))
all_colors = [0xFFFFFF, 0x000000, 0xFF0000, 0x00FF00, 0x0000FF]
active_colors = [random.choice(all_colors) for i in range(4)]
el = EventListener()
def field(i):
return Fill(active_colors[i])
while True:
display(compose(empty_surface(0xFFFFFF), GridLayout())(
[field(0), field(1)],
[field(2), field(3)]
))
result = el.wait_for_keys(pygame.K_ESCAPE)
if result == pygame.K_ESCAPE:
break
We will now introduce mouse support, to change the color of a square, if we
click on it. For that we install a MouseProxy()
into the render tree.
A MouseProxy has a _draw()
method that will be called by compose, but
it does not render anything, it only saves the assigned area, and then renders
its children.
A MouseProxy takes a handler function that takes 3 arguments, the event itself,
as well as an x and a y value, which are relative to the mouse area.
- The event object iteself contains a few information:
- type:
One of: pygame.MOUSEBUTTONUP, pygame.MOUSEBUTTONDOWN, or pygame.MOUSEMOTION.
- pos:
a 2-tuple with the window coordinates, x and y, of the click.
- pos_rel:
only for MOUSEMOTION, contains the differences for x and y since the last MOUSEMOTION event.
- buttons:
only for MOUSEMOTION, contains a 3-tuple each value is 0 or 1, representing whether the correspoding button is pressed (1) or not (0). The order is (LEFT, MIDDLE, RIGHT)
- button:
only for MOUSEBUTTONUP and MOUSEBUTTONDOWN: contains the keycode of the pressed button. Since pygame did not define constants for them, they are defined in the eventlistener module. The possible values are:
- MOUSE_LEFT
- MOUSE_MIDDLE
- MOUSE_RIGHT
- MOUSE_SCROL_FW (forwards)
- MOUSE_SCROL_BW (backwards)
The MouseProxy
class has a method listener, which could be used in
conjunction with EventListener.listen().
There is a shortcut though: EventListener.mouse_area()
it creates
MouseProxy, stores it internally, and returns it. Every stored proxy is assigned
a group (0 by default), and only the mouse proxies from within the active group
are used as permanent handler. To prevent recreation of existing proxies during
repeated calls every proxy is assigned an id, by default the memory address of
their handlers are used.
There is a function EventListener.group()
which sets the current group,
so you could use something like el.group(2).wait_for_keys(…) to specify
which group of mouse proxies should be used explicitly. To disable proxies
simply use the id of a non existing group.
A version of the upper example which changes the color of a square randomly, if you click on it is:
import random
from pyparadigm import init, EventListener, compose, display,\
empty_surface, GridLayout, Fill, EventConsumerInfo, MOUSE_LEFT
import pygame
init((400, 400))
all_colors = [0xFFFFFF, 0x000000, 0xFF0000, 0x00FF00, 0x0000FF]
active_colors = [random.choice(all_colors) for i in range(4)]
el = EventListener()
def make_id_returner(i):
return lambda e, x, y: i if (e.type == pygame.MOUSEBUTTONDOWN
and e.button == MOUSE_LEFT)\
else EventConsumerInfo.DONT_CARE
def field(i):
return el.mouse_area(make_id_returner(i))(Fill(active_colors[i]))
while True:
display(compose(empty_surface(0xFFFFFF), GridLayout())(
[field(0), field(1)],
[field(2), field(3)]
))
result = el.wait_for_keys(pygame.K_ESCAPE)
if result == pygame.K_ESCAPE:
break
else:
active_colors[result] = random.choice(all_colors)
The Misc-Module¶
The Misc-Module contains everything that was handy enough to be part of PyParadigm, but was not big enough for its own module. It contains the following functions:
init()
needs to be called before any other call to a member of- PyParadgim and creates the pygame window in which the contents will be displayed.
display()
can be used to conveniently display a pygame surface,- which has the size of the pygame window.
slide_show()
takes a list of pygame.Surfaces, which are supposed- to have the same size as the display window, and a handler function. When the handler function returns, the next slide is shown. Handy to display multi-page text.
empty_surface()
creates a new pygame.Surface of the given size- (or of the size of the pygame window, if no size was specified) and automatically fills it with a given background color.
process_char()
returns a new version of a given buffer, modified- based on a string containing a unicode character.
Next Step¶
The next step now would be to take a look the the examples to see how to apply what you just learned.
Examples¶
You can find all examples on this page in the doc/examples folder.
Inter-temporal Choice Task¶
This is the exact same example as on the front page.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | import pygame
from pyparadigm.surface_composition import *
from pyparadigm.misc import empty_surface, display, init
from pyparadigm.eventlistener import EventListener
import json
import time
# Scroll to the bottom, and start reading in the main() ;)
def offer_box(title, amount):
# Creates a border around a vertical layout containing 2 cells, where the
# lower one has twice the size of the upper one (layout children are
# automatically wrapped in LLItems with relative_size=1). Both Boxes are
# filled with text, wich is centered in its parent area.
return Border()(
LinLayout("v")(
Text(title, Font(size=50)),
LLItem(2)(Text(f"{amount}€", Font(size=50, bold=True)))
)
)
def make_offer(now, later, delay):
# Create pygame.Surface with a white background.
# The LinLayout splits the available space into (in this case)
# equally sized horizontally aligned parts. 80% of the available
# space of each part is used to display a offer box.
return compose(empty_surface(0xFFFFFF), LinLayout("h"))(
Padding.from_scale(0.8)(offer_box("Now", now)),
Padding.from_scale(0.8)(offer_box(f"In {delay} days", later)),
)
def make_feedback(amount, delay):
# creates a pygame.Surface which only contains the text message
msg = f"{amount}€ " + ("now" if delay == 0 else f"in {delay} days")
return compose(empty_surface(0xFFFFFF))(Text(msg, Font(size=50)))
def main():
# initiate a window with a resolution of 800 x 600 pixels
init((800, 600))
# alternatively, to create a full screen, hardware accelrated window, you
# could use:
# init((1920, 1080), pygame.FULLSCREEN | pygame.HWSURFACE | pygame.DOUBLEBUF)
# Create an Eventlistener object
event_listener = EventListener()
# Initiate the data for the paradigm, and create 2 lists to store
# the results
immediate_offers = ([10] * 3) + ([20] * 3) + ([30] * 3)
delays = [10, 20, 30] * 3
delayed_offers = [delay + im_offer
for delay, im_offer in zip(delays, immediate_offers)]
chosen_amounts = []
chosen_delays = []
reaction_times = []
# Execute the paradigm
for im_offer, del_offer, delay in zip(immediate_offers, delayed_offers, delays):
# display the offer
display(make_offer(im_offer, del_offer, delay))
offer_onset = time.time()
# wait for a decision in form of the left or right arrow-key
key = event_listener.wait_for_keys([pygame.K_LEFT, pygame.K_RIGHT])
# calculate reaction time and save it
reaction_times.append(time.time() - offer_onset)
# store results according to decision
if key == pygame.K_LEFT:
chosen_amounts.append(im_offer)
chosen_delays.append(0)
else:
chosen_amounts.append(del_offer)
chosen_delays.append(delay)
# display a feedback for 2 seconds
display(make_feedback(chosen_amounts[-1], chosen_delays[-1]))
event_listener.wait_for_seconds(2)
# save results to a json File
with open("results.json", "w") as file:
json.dump({"amount": chosen_amounts, "delay": chosen_delays,
"reaction_times": reaction_times}, file)
if __name__ == '__main__':
main()
|
Flashing Checkerboard¶
This example just alternates the two stimuli with frequency of 2Hz. To make sure, that the interpreter finds the stimuli cd into the examples folder, and execute it from there.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | import pygame
from pyparadigm.misc import init, display, empty_surface
from pyparadigm.surface_composition import compose, Surface, Text, Font
from pyparadigm.eventlistener import EventListener
from functools import lru_cache
from itertools import cycle
def render_frame(screen, frame):
# This time we dont use :py:func:`misc.display` and instead draw directly
# onto the screen, and call flip() then to display it. Usually we would want
# to generate a screen with a function (with lru_cache), and then use
# :py:func:`misc.display` to blit the different screens. This way every
# screen is only computed once. This time though, no screens are computed,
# it is simply displaying an existing image, and no screens are reused.
compose(screen)(Surface(scale=1)(frame))
pygame.display.flip()
def main():
# we want to display the two states with 2Hz, therefore the timeout is 1/2s
timeout = 0.5
# initialize a window, and get a reference to the pygame.Surface
# representing the screen.
screen = init((1024, 800))
# Load the frames. When loading a pygame.Surface from a file, you should
# always call convert() on it, this will change the image format to optimize
# performance. If you have an image that uses transparent pixels, use
# convert_alpha() instead.
# We use itertools.cycle to get an iterator that will alternate between the
# images, see the python-doc (https://docs.python.org/3/library/itertools.html)
frames = cycle([pygame.image.load(f"checkerboard_{i}.png").convert()
for i in range(2)])
# Create an EventListener object. No additional handlers needed here.
event_listener = EventListener()
# Display an initial text
display(compose(empty_surface(0xFFFFFF))(Text(
"""Press Return to start.
Press again to end.""", Font(size=60))))
# Wait for the return key
event_listener.wait_for_n_keypresses(pygame.K_RETURN)
key = None
# Repeat until return is pressed again
while key == None:
# display one of the two checkerboard images
render_frame(screen, next(frames))
# wait for timeout seconds for RETURN to be pressed. If RETURN is
# pressed :py:meth:`EventListener.wait_for_keys` will return
# pygame.K_RETURN otherwise it will return NONE
key = event_listener.wait_for_keys([pygame.K_RETURN], timeout)
if __name__ == '__main__':
main()
|
Stroop Task¶
This example is more serious, and implements a stroop task with a two-stage training procedure.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | """ This script contains an example implementation of the stroop task.
The easiest way to understand this code is to start reading in the main
function, and then read every function when it's called first
"""
import random
import time
import csv
from collections import namedtuple
from enum import Enum
from functools import lru_cache
from itertools import islice, repeat
import pygame
from pyparadigm import (EventListener, Fill, Font, LinLayout, LLItem, Padding,
RectangleShaper, Surface, Text, compose, display,
empty_surface, init, slide_show)
# ================================================================================
# Just Configuration
# ================================================================================
n_train_1_trials = 30
n_train_2_trials = 10
trials_per_block = 10
intro_text = ["""
Welcome to the Stroop-demo.
In this task you will be presented with words naming colors
which are written in a colored font.
You will either have to indicate the name of the color, or the
color of the font, using the arrow keys.
Press Return to continue.
""",
"""
To indicate the color you will have to use the number keys,
1 for red
2 for green
3 for blue
press Return to continue
""",
"""
First you will learn the mappings by heart.
To do so you will be displayed with the mappings, after %d trials
the mappings won't be shown any more. Then after %d
correct trials, the main task will start.
""" % (n_train_1_trials, n_train_2_trials)]
pre_hidden_training_text = """
Now we will hide the mapping, and the task will continue until you answered
correctly %d times.
Press Return to continue""" % n_train_2_trials
post_hidden_training_text = """
The training was succesful.
Press Return to continue"""
pre_text_block_text = """
Now, we begin with the test. Please indicate the color
that is named by the letters
Press Return to continue
"""
post_test_block_text = """
Now, please indicate the color in which
the word is written
Press Return to continue
"""
end_text = """
The task is complete, thank you for your participation
Press Return to escape
"""
# ================================================================================
# some utility functions
# ================================================================================
@lru_cache()
def text(s: str, color=0):
# This is our configuration of how to display text, with the arial font, and
# a pointsize of 30.
# Due to the way text is plotted it needs the information of an alphachannel
# therefore it is not possible to simply pass the hex-code of the color, but it
# is necessary to create a pygame.Color object. For which, again, it is necessary
# to multiply the hex code with 0x100 to respect the alphachannel
return Text(s, Font("Arial", size=30), color=pygame.Color(color * 0x100))
def _bg():
# short for background
return empty_surface(0xFFFFFF)
def display_text(s:str):
display(compose(_bg())(text(s)))
def display_text_and_wait(s: str, el: EventListener, key: int = pygame.K_RETURN):
display_text(s)
el.wait_for_keys(key)
# ================================================================================
# Main Program
# ================================================================================
class Color(Enum):
red = 0xFF0000
green = 0x00FF00
blue = 0x0000FF
colors = list(Color)
key_color_mapping = {
eval("pygame.K_%d" % (i + 1)): color
for i, color in enumerate(Color)
}
def display_intro(el: EventListener):
# the first argument for slide_show must be an iterable of pygame.Surface
# to create it we render the text onto an empty surface with the map()
# function. slide_show displays one slide, and then calls the function that
# is passed to it as second argument. When this function returns, the next
# slide is displayed and the function is called again. The function that is
# passed simply waits for the return key
slides = map(lambda s: compose(_bg())(text(s)), intro_text)
slide_show(slides, lambda: el.wait_for_keys(pygame.K_RETURN))
@lru_cache()
def make_train_stim(type: str, color: Color):
# encapsulates the creation of a training stim. Either a square of the given
# or the name of the color as text, they are wrapped by a RectangleShaper,
# by default, will create a square
assert type in ["color", "text"]
return RectangleShaper()(
text(color.name) if type == "text" else Fill(color.value))
def make_color_mapping():
# The mapping is a horizontal layout consisting of groups of
# a text element describing the key, and a square containing the color
# we use make_train_stim() to create the square, and add a LLItem(1) in
# the back and the front to get visible gaps between the groups.
# The * in from of the list is used to expand the list to the arguments
# for the LinLayouts inner function.
return LinLayout("h")(*[LinLayout("h")(LLItem(1), text(str(key + 1)),
make_train_stim("color", color), LLItem(1))
for key, color in enumerate(Color)])
@lru_cache()
def render_train_screen(show_mapping, stim_type, target_color):
# the contents are aranged in a vertical layout, topmost is the
# title "target color", followed by the stim for training (either a
# square containing the color, or the word naming the color)
# in the Bottom there is the information which key is mapped to which
# color. But its only displayed optionally
return compose(_bg(), LinLayout("v"))(
# Create the Text
text("target color:"),
# Create the stimulus, and scale it down a little.
Padding.from_scale(0.3)(make_train_stim(stim_type, target_color)),
# Up till here the content is static, but displaying the mapping is optionally
# and depends on the parameter, therefore we either add the mapping, or
# an LLItem(1) as placeholder
make_color_mapping() if show_mapping else LLItem(1)
)
def do_train_trial(event_listener: EventListener, show_mapping: bool, stim_type:
str, target_color: Color):
# displays a training_screen
display(render_train_screen(show_mapping, stim_type, target_color))
# waits for a response
response_key = event_listener.wait_for_keys(key_color_mapping)
# returns whether the response was correct
return key_color_mapping[response_key] == target_color
def rand_elem(seq, n=None):
"""returns a random element from seq n times. If n is None, it continues indefinitly"""
return map(random.choice, repeat(seq, n) if n is not None else repeat(seq))
def until_n_correct(n, func):
n_correct = 0
while n_correct < n:
if func():
n_correct += 1
else:
n_correct = 0
def do_training(el: EventListener):
arguments = zip(rand_elem(["text", "color"]), rand_elem(colors))
for stim_type, color in islice(arguments, n_train_1_trials):
do_train_trial(el, True, stim_type, color)
display_text_and_wait(pre_hidden_training_text, el)
until_n_correct(n_train_2_trials, lambda: do_train_trial(el, False, *next(arguments)))
display_text_and_wait(post_hidden_training_text, el)
@lru_cache()
def render_trial_screen(word, font_color, target):
return compose(_bg(), LinLayout("v"))(
# Create the Text
text("Which color is named by the letters?" if target == "text"
else "What's the color of the word?"),
# Create the stimulus, and scale it down a little.
Padding.from_scale(0.3)(text(word, font_color)),
LLItem(1)
)
BlockResult = namedtuple("BlockResult", "RT word font_color response was_correct")
def run_block(event_listener: EventListener, by: str, n_trials: int)-> BlockResult:
assert by in ["text", "color"]
RTs = []; words = []; fonts = []; responses = []; was_correct = []
for word, font in zip(rand_elem(colors, n_trials), rand_elem(colors)):
words.append(word)
fonts.append(font)
display(render_trial_screen(word.name, font.value, by))
# We use this to record reaction times
start = time.time()
response_key = event_listener.wait_for_keys(key_color_mapping)
# Now the reaction time is just now - then
RTs.append(time.time() - start)
response = key_color_mapping[response_key]
responses.append(response)
was_correct.append(response == (word if by == "text" else font))
return BlockResult(RTs, words, fonts, responses, was_correct)
def save_results(text_res: BlockResult, font_res: BlockResult):
with open("results.tsv", "w") as f:
writer = csv.writer(f, delimiter="\t")
writer.writerow(("by",) + BlockResult._fields)
for line in zip(*text_res):
writer.writerow(("text",) + line)
for line in zip(*font_res):
writer.writerow(("font",) + line)
def main():
# create the pygame window. It has a resolution of 1024 x 800 pixels
init((1024, 800))
# create an event listener that will be used through the whole program
event_listener = EventListener()
display_intro(event_listener)
do_training(event_listener)
display_text_and_wait(pre_text_block_text, event_listener)
text_block_results = run_block(event_listener, by="text",
n_trials=trials_per_block)
display_text_and_wait(post_test_block_text, event_listener)
color_block_results = run_block(event_listener, by="color",
n_trials=trials_per_block)
display_text_and_wait(end_text, event_listener)
save_results(text_block_results, color_block_results)
if __name__ == "__main__":
main()
|
The Surface Composition Module¶
Easy Image Composition
The purpose of this module is to make it easy to compose the frames that are displayed in a paradigm. For an introduction, please refer to the tutorial
-
class
pyparadigm.surface_composition.
Border
(width=3, color=0)[source]¶ Draws a border around the contained area. Can have a single child.
Parameters: - width (int) – width of the border in pixels
- color (pygame.Color) – color of the border
-
class
pyparadigm.surface_composition.
Circle
(color, width=0)[source]¶ Draws a Circle in the assigned space.
The circle will always be centered, and the radius will be half of the shorter side of the assigned space.
Parameters: - color (pygame.Color or int) – The color of the circle
- width (int) – width of the circle (in pixels). If 0 the circle will be filled
-
pyparadigm.surface_composition.
Cross
(width=3, color=0)[source]¶ Draws a cross centered in the target area
Parameters: - width (int) – width of the lines of the cross in pixels
- color (pygame.Color) – color of the lines of the cross
-
class
pyparadigm.surface_composition.
FRect
(x, y, w, h)[source]¶ A wrapper Item for children of the FreeFloatLayout, see description of FreeFloatLayout
-
class
pyparadigm.surface_composition.
Fill
(color)[source]¶ Fills the assigned area. Afterwards, the children are rendered
Parameters: color (pygame.Color or int) – the color with which the area is filled
-
pyparadigm.surface_composition.
Font
[source]¶ Unifies loading of fonts.
Parameters: - name (str) – name of system-font or filepath, if None is passed the default system-font is loaded
- source (str) – “sys” for system font, or “file” to load a file
-
class
pyparadigm.surface_composition.
FreeFloatLayout
[source]¶ A “Layout” that allows for free positioning of its elements. All children must be Wrapped in an FRect, which takes a rects arguments (x, y, w, h), and determines the childs rect. All values can either be floats, and must then be between 0 and 1 and are relative to the rect-size of the layout, positive integers, in which case the values are interpreded as pixel offsets from the layout rect origin, or negative integers, in which case the absolute value is the available width or height minus the value
-
pyparadigm.surface_composition.
GridLayout
(row_proportions=None, col_proportions=None, line_width=0, color=0)[source]¶ Layout that arranges its children on a grid.
Proportions are given as lists of integers, where the nth element represents the proportion of the nth row or column.
Children are added in lists, every list represents one row, if row or column proportions are provided, the number of rows or columns in the children must match the provided proportions. To define an empty cell use None as child.
If no column proportions are provided, rows can have different lengths. In this case the width of the layout will be the length of the longest row, and the other rows will be filled with Nones
-
class
pyparadigm.surface_composition.
LLItem
(relative_size)[source]¶ Defines the relative size of an element in a LinLayout
All Elements that are passed to a linear layout are automatically wrapped into an LLItem with relative_size=1. Therefore by default all elements within a layout will be of the same size. To change the proportions a LLItem can be used explicitely with another relative size.
It is also possible to use an LLItem as placeholde in a layout, to generate an empty space like this:
Example: - LinLayout(“h”)(
- LLItem(1), LLItem(1)(Circle(0xFFFF00)))
-
class
pyparadigm.surface_composition.
LinLayout
(orientation)[source]¶ A linear layout to order items horizontally or vertically.
Every element in the layout is automatically wrapped within a LLItem with relative_size=1, i.e. all elements get assigned an equal amount of space, to change that elements can be wrappend in LLItems manually to get desired proportions
Parameters: orientation (str) – orientation of the layout, either ‘v’ for vertica, or ‘h’ for horizontal.
-
class
pyparadigm.surface_composition.
Line
(orientation, width=3, color=0)[source]¶ Draws a line.
Parameters: - width – width of the line in pixels
- orientation (str) – “v” or “h”. Indicates whether the line should be horizontal or vertical.
-
class
pyparadigm.surface_composition.
Margin
(left=1, right=1, top=1, bottom=1)[source]¶ Defines the relative position of an item within a Surface. For details see Surface.
-
class
pyparadigm.surface_composition.
Overlay
(*children)[source]¶ Draws all its children on top of each other in the same rect
-
class
pyparadigm.surface_composition.
Padding
(left, right, top, bottom)[source]¶ Pads a child element
Each argument refers to a percentage of the axis it belongs to. A padding of (0.25, 0.25, 0.25, 0.25) would generate blocked area a quater of the available height in size above and below the child, and a quarter of the available width left and right of the child.
If left and right or top and bottom sum up to one that would mean no space for the child is remaining
-
static
from_scale
(scale_w, scale_h=None)[source]¶ Creates a padding by the remaining space after scaling the content.
E.g. Padding.from_scale(0.5) would produce Padding(0.25, 0.25, 0.25, 0.25) and Padding.from_scale(0.5, 1) would produce Padding(0.25, 0.25, 0, 0) because the content would not be scaled (since scale_h=1) and therefore there would be no vertical padding.
If scale_h is not specified scale_h=scale_w is used as default
Parameters: - scale_w (float) – horizontal scaling factors
- scale_h (float) – vertical scaling factor
-
static
-
class
pyparadigm.surface_composition.
RectangleShaper
(width=1, height=1)[source]¶ Creates a padding, defined by a target Shape.
Width and height are the relative proportions of the target rectangle. E.g RectangleShaper(1, 1) would create a square. and RectangleShaper(2, 1) would create a rectangle which is twice as wide as it is high. The rectangle always has the maximal possible size within the parent area.
-
class
pyparadigm.surface_composition.
Surface
(margin=<pyparadigm.surface_composition.Margin object>, scale=0, smooth=True, keep_aspect_ratio=True)[source]¶ Wraps a pygame surface.
The Surface is the connection between the absolute world of pygame.Surfaces and the relative world of the composition functions. A pygame.Surfaces can be bigger than the space that is available to the Surface, or smaller. The Surface does the actual blitting, and determines the concrete position, and if necessary (or desired) scales the input surface.
Warning: When images are scaled with smoothing, colors will change decently, which makes it inappropriate to use in combination with colorkeys.
Parameters: - margin (Margin object) – used to determine the exact location of the pygame.Surfaces within the available space. The margin value represents the proportion of the free space, along an axis, i.e. Margin(1, 1, 1, 1) is centered, Margin(0, 1, 1, 2) is as far left as possible and one/third on the way down.
- scale (float) – If 0 < scale <= 1 the longer side of the surface is scaled to to the given fraction of the available space, the aspect ratio is will be preserved. If scale is 0 the will be no scaling if the image is smaller than the available space. It will still be scaled down if it is too big.
- smooth (float) – if True the result of the scaling will be smoothed
-
pyparadigm.surface_composition.
Text
(text, font, color=(0, 0, 0, 255), antialias=False, align='center')[source]¶ Renders a text. Supports multiline text, the background will be transparent.
Parameters: align (str) – text-alignment must be “center”, “left”, or “righ” Returns: the input text Return type: pygame.Surface
The Event Listener Module¶
The Eventlistener wraps pygames event-loop.
The Core method is the listen() method. It gathers the events that have piled up in pygame so far and processes them acording to handler functions. This allows for a main-loop free script design, which is more suited for experimental paradigms. In a typical experiment the script just waits for a userinput and does nothing, or only a very few things in between. Approaching this need with a main event-loop requires the implementation of some sort of statemachine, which again requires quite some code.
The EventListener enables one to write scripts in a time-linear manner, and only dab into local event-loops whenever neccessary throught the listen-function.
There are a few pre-implemented methods, which cover most of those use-cases in the developement of experimental paradigms.
- wait_for_keypress() will return once a key was pressed n times.
- wait_for_keys_timed_out() will wait for one of multiple possible keys,
- but return after the given timeout in an y case
- and wait_for_seconds will simply wait the given time, but in the mean-time run what ever handlers were passed to the EventListener.
By default, there is one permanent handler, which will call exit(1) when Ctrl + c is pressed.
-
class
pyparadigm.eventlistener.
EventConsumerInfo
[source]¶ Can be returned by event-handler functions to communicate with the listener. For Details see EventListener
-
class
pyparadigm.eventlistener.
EventListener
(permanent_handlers=None, use_ctrl_c_handler=True)[source]¶ Parameters: - permanent_handlers (iterable) – iterable of permanent handlers
- use_ctrl_c_handler (Bool) – specifies whether a handler that quits the script when ctrl + c is pressed should be used
-
group
(group)[source]¶ sets current mouse proxy group and returns self. Enables lines like el.group(1).wait_for_keys(…)
-
listen
(*temporary_handlers)[source]¶ When listen() is called all queued pygame.Events will be passed to all registered listeners. There are two ways to register a listener:
- as a permanent listener, that is always executed for every event. These
- are registered by passing the handler-functions during construction
- as a temporary listener, that will only be executed during the current
- call to listen(). These are registered by passing the handler functions as arguments to listen()
When a handler is called it can provoke three different reactions through its return value.
- It can return EventConsumerInfo.DONT_CARE in which case the EventListener
- will pass the event to the next handler in line, or go to the next event, if the last handler was called.
- It can return EventConsumerInfo.CONSUMED in which case the event will not
- be passed to following handlers, and the next event in line will be processed.
- It can return anything else (including None, which will be returned if no
- return value is specified) in this case the listen()-method will return the result of the handler.
Therefore all permanent handlers should usually return EventConsumerInfo.DONT_CARE
-
listen_until_return
(*temporary_handlers, timeout=0, sleeptime=0)[source]¶ Calls listen repeatedly until listen returns something else than None. Then returns listen’s result. If timeout is not zero listen_until_return stops after timeout seconds and returns None.
-
mouse_area
(handler, group=0, ident=None)[source]¶ Adds a new MouseProxy for the given group to the EventListener.mouse_proxies dict if it is not in there yet, and returns the (new) MouseProxy. In listen() all entries in the current group of mouse_proxies are used.
-
wait_for_keys
(*keys, timeout=0, sleeptime=0)[source]¶ Waits until one of the specified keys was pressed, and returns which key was pressed.
Parameters: - keys (iterable) – iterable of integers of pygame-keycodes, or simply multiple keys passed via multiple arguments
- timeout (float) – number of seconds to wait till the function returns
Returns: The keycode of the pressed key, or None in case of timeout
Return type: int
-
wait_for_keys_modified
(*keys, modifiers_to_check={1, 2, 3, 64, 128, 192, 256, 512, 768, 1024, 2048, 3072, 4096, 8192, 16384}, timeout=0, sleeptime=0.001)[source]¶ The same as wait_for_keys, but returns a frozen_set which contains the pressed key, and the modifier keys.
Parameters: modifiers_to_check – iterable of modifiers for which the function will check whether they are pressed
-
wait_for_n_keypresses
(key, n=1)[source]¶ Waits till one key was pressed n times.
Parameters: - key (int) – the key to be pressed as defined by pygame. E.g. pygame.K_LEFT for the left arrow key
- n (int) – number of repetitions till the function returns
-
wait_for_seconds
(seconds, sleeptime=0.001)[source]¶ basically time.sleep() but in the mean-time the permanent handlers are executed
-
wait_for_unicode_char
(ignored_chars=None, timeout=0, sleeptime=0.001)[source]¶ Returns a str that contains the single character that was pressed. This already respects modifier keys and keyboard layouts. If timeout is not none and no key is pressed within the specified timeout, None is returned. If a key is ingnored_chars it will be ignored. As argument for irgnored_chars any object that has a __contains__ method can be used, e.g. a string, a set, a list, etc
-
class
pyparadigm.eventlistener.
MouseProxy
(handler: Callable[[int, int], int], ident=None)[source]¶ has a _draw method so that it can be used with surface_composition.compose(). When “rendered” it simply saves the own coordinates and then renders its child. The listener method can then be used with EventListener.listen() to execute the provided handler when the mouse interacts with the area. The handler gets the event type, pygame.MOUSEBUTTONUP, pygame.MOUSEBUTTONDOWN and pygame.MOUSEMOTION and the relative coordinates within the area. For unique identification along all MouseProxies the ident paramenter is used. If ident is None (the default) it is set to id(handler)
The Misc-Module¶
Contains code that did not make it into an own module.
-
pyparadigm.misc.
display
(surface)[source]¶ Displays a pygame.Surface in the window.
in pygame the window is represented through a surface, on which you can draw as on any other pygame.Surface. A refernce to to the screen can be optained via the
pygame.display.get_surface()
function. To display the contents of the screen surface in the windowpygame.display.flip()
needs to be called.display()
draws the surface onto the screen surface at the postion (0, 0), and then callsflip()
.Parameters: surface (pygame.Surface) – the pygame.Surface to display
-
pyparadigm.misc.
empty_surface
(fill_color, size=None, flags=0)[source]¶ Returns an empty surface filled with fill_color.
Parameters: - fill_color (pygame.Color) – color to fill the surface with
- size (int-2-tuple) – the size of the new surface, if None its created to be the same size as the screen
-
pyparadigm.misc.
init
(resolution, pygame_flags=0, display_pos=(0, 0), interactive_mode=False, title='Pygame Window')[source]¶ Creates a window of given resolution.
Parameters: - resolution (tuple) – the resolution of the windows as (width, height) in pixels
- pygame_flags (int) – modify the creation of the window. For further information see Creating a Window
- display_pos (tuple) – determines the position on the desktop where the window is created. In a multi monitor system this can be used to position the window on a different monitor. E.g. the monitor to the right of the main-monitor would be at position (1920, 0) if the main monitor has the width 1920.
- interactive_mode (bool) – Will install a thread, that emptys the event-queue every 100ms. This is neccessary to be able to use the display() function in an interactive console on windows systems. If interactive_mode is set, init() will return a reference to the background thread. This thread has a stop() method which can be used to cancel it. If you use ctrl+d or exit() within ipython, while the thread is still running, ipython will become unusable, but not close.
- title (str) – the Title of the Window
Returns: a reference to the display screen, or a reference to the background thread if interactive_mode was set to true. In the second scenario you can obtain a reference to the display surface via pygame.display.get_surface()
Return type: pygame.Surface
-
pyparadigm.misc.
make_transparent_by_colorkey
(surf, colorkey, copy=True)[source]¶ Makes image transparent, and sets all pixel of a certain color transparent
This is useful if images should be scaled and smoothed, as this will change the colors and make colorkeys useless, if surf has no alpha channel a new image is returned, if it does have one the behavior depends on the copy parameter
-
pyparadigm.misc.
make_transparent_by_mask
(surf, mask, copy=True)[source]¶ Sets all voxels that are 1 in the mask to transparent. if surf has no alpha channel a new image is returned, if it does have one the behavior depends on the copy parameter
-
pyparadigm.misc.
process_char
(buffer: str, char: str, mappings={'\t': ' ', '\r': '\n'})[source]¶ This is a convinience method for use with EventListener.wait_for_unicode_char(). In most cases it simply appends char to buffer. Some replacements are done because presing return will produce ‘r’ but for most cases ‘n’ would be desireable. Also backspace cant just be added to a string either, therefore, if char is “u0008” the last character from buffer will be cut off. The replacement from ‘r’ to ‘n’ is done using the mappings argument, the default value for it also contains a mapping from ‘ ‘ to 4 spaces.
Parameters: - buffer (str) – the string to be updated
- char (str) – the unicode character to be processed
- mappings (dict) – a dict containing mappings
Returns: a new string
-
pyparadigm.misc.
rgba
(colorcode, alpha=255)[source]¶ Returns a pygame rgba color object, with the provided alpha value.
-
pyparadigm.misc.
slide_show
(slides, continue_handler)[source]¶ Displays one “slide” after another.
After displaying a slide, continue_handler is called without arguments. When continue_handler returns, the next slide is displayed.
Usage example
slide_show(text_screens, partial(event_listener.wait_for_n_keypresses, pygame.K_RETURN))
(partial is imported from the functools module.)
Parameters: - slides (iterable) – pygame.Surfaces to be displayed.
- continue_handler (callable with arity 0.) – function, that returns when the next slide should be displayed.
Extras¶
This module requires additional installation of numpy and matplotlib.
The Dialogs-Module¶
This module contains dialogues, which will query user input
-
pyparadigm.dialogs.
string_dialog
(caption: str, renderer: callable = <function _center_renderer>, el: pyparadigm.eventlistener.EventListener = None, text_renderer=<functools._lru_cache_wrapper object>)[source]¶ Will display a dialog which gets a string as input from the user. By default the dialog will be rendered to the screen directly, to control the target pass a callable as renderer which takes a single argument, which is an element tree. This will be called by string_dialog to display itself. You can pass an eventlistener instance, which will then be used in case you got some important permanent handlers that must be run. You can pass a function that converts a string to something that can be used within compose() with the text_renderer to control the optic of the text