English | 简体中文
This project is a lightweight experiment framework for PsychoPy, source code only 200 lines.
Note
this project aim to provide a new way to build PsychoPy experiments, only provide the basic API and encourage developers to develop on top of this project.
- Lightweight: Only 1 file, no extra dependencies
- Type-safe: All parameters are type annotated
- Newcomer-friendly: Only the concepts of
Context
andScene
are required to get started.
pip install psychopy-scene
or copy the psychopy_scene
folder directly to the root directory of your project.
Experiment context Context
means this experiment's global settings,
including environment parameters and task parameters.
The first step to writing an experiment is to create an experiment context.
from psychopy_scene import Context
from psychopy.visual import Window
from psychopy.monitors import Monitor
from psychopy.data import ExperimentHandler
# create monitor
monitor = Monitor(
name="testMonitor",
width=52.65,
distance=57,
)
monitor.setSizePix((1920, 1080))
# create window
win = Window(
monitor=monitor,
units="deg",
fullscr=False,
size=(800, 600),
)
# create experiment context
ctx = Context(
win,
exp=ExperimentHandler(extraInfo={"subject": "test"}),
)
The experiment can be seen as a composition of a series of scenes, only 2 steps are required to write an experiment program:
- create scene
- write scene presentation logic
scene provides some configuration parameters:
duration
:seconds unitclose_on
:the event to close scene, such askey_f
means pressing thef
key to close the sceneon_key_[name]
:when the keyboard key is pressed, execute the functionon_mouse_[name]
:when the mouse button is clicked, execute the functionon_scene_[name]
:when the scene reaches a specific stage, execute the function
Creating a scene only requires a function that accepts stimulus parameters and returns the stimulus:
from psychopy.visual import TextStim
# create stimulus
stim_1 = TextStim(ctx.win, text="Hello")
stim_2 = TextStim(ctx.win, text="World")
# create scene
@ctx.scene(
duration=1,
close_on=["key_f", "mouse_right"],
on_key_escape=lambda: print("escape key was pressed"),
on_mouse_left=lambda: print("left mouse button was pressed"),
on_scene_drawn=lambda: print("it will be called after first drawing"),
on_scene_frame=lambda: print("it will be called each frame"),
)
def demo(color: str, ori: float): # it will be used as on_scene_setup
print('it will be called before first drawing')
stim_1.color = color
stim_2.ori = ori
return stim_1, stim_2
# show scene
demo.show(color="red", ori=45)
scene can also be configured dynamically, it is useful in some cases, such as presentations with variable duration:
@ctx.scene()
def demo():
return stim
demo.config(duration=0.5).show()
this ctx.scene
method is a shortcut for demo.config
, so they are equivalent.
An event represents a specific moment in the program's running, such as pressing a key or clicking the mouse. To execute some operation when an event occurs, we need to add a callback function for it:
demo = ctx.scene(close_on="key_f") # or
demo = ctx.scene(on_key_f=lambda: demo.close()) # or
demo = ctx.scene().on("key_f", lambda: demo.close())
Note
each event can only have one callback function, adding repeatedly will raise an error.
each callback parameter name should follow the format on_[type]_[name]
.
Now we support the following events:
type | name |
---|---|
scene | setup、drawn、frame |
key | any、other keys is same as returned by Keyboard.getKeys |
mouse | left、right、middle |
these events will be triggered in the following order after the show
method is executed:
graph TD
initialize --> on_scene_setup --> first-draw --> on_scene_drawn --> c{whether to draw}
c -->|no| stop-draw
c -->|yes| on_scene_frame --> re-draw --> _["on_key_[name]<br>on_mouse_[name]"] --> c
scene will collect data automatically in showing:
name | description |
---|---|
show_time | first drawing timestamp |
events | interaction events list: keyboard events and mouse events |
we can access these data by scene.get
:
@ctx.scene(close_on=["key_f", "key_j"])
def demo():
return stim
demo.show()
close_event = demo.get("events")[-1]
close_key = close_event.key.value
close_time = close_event.timestamp - demo.get('show_time')
we can also collect data manually:
@ctx.scene(
on_key_f=lambda: demo.set('rt', core.getTime() - demo.get('show_time')),
)
def demo():
return stim
demo.show()
rt = demo.get('rt')
Context
also provides some shortcut methods:
ctx.text('Welcome to the experiment!', pos=(0, 0)).show() # show static text
ctx.fixation(1).show()
ctx.blank(1).show()
ctx.addRow(a='', b=1, c=True) # collect data to ExperimentHandler
It is recommended to write the task as a function, pass the experimental context as the first parameter, the task-specific parameters as the rest of the parameters, and return the experimental data.
from psychopy_scene import Context
def task(ctx: Context, duration: float):
from psychopy.visual import TextStim
stim = TextStim(ctx.win, text="")
scene = ctx.scene(duration, on_scene_setup=lambda: stim)
scene.show()
ctx.addRow(time=scene.get('show_time'))
Task functions should not contain any logic that is not related to the task itself, for example:
- Introductory and closing statements
- Number of blocks
- Data processing, analysis, presentation of results
If there are no data dependencies between blocks, it is recommended to write the task function as a single block. For experiments that require the presentation of multiple blocks, consider the following example.
from psychopy_scene import Context
from psychopy.visual import Window
def task(ctx: Context):
from psychopy.visual import TextStim
stim = TextStim(ctx.win, text="")
scene = ctx.scene(1, on_scene_setup=lambda: stim)
scene.show()
ctx.addRow(time=scene.get('show_time'))
win = Window()
data = []
for block_index in range(10):
ctx = Context(win)
ctx.exp.extraInfo['block_index'] = block_index
task(ctx)
block_data = ctx.exp.getAllEntries()
data.extends(block_data)