Lightweight experiment framework for PsychoPy
Project description
PsychoPy-Scene
English | 简体中文
This project is a lightweight experiment framework for PsychoPy, source code <300 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.
Features
- Lightweight: Only 1 file, no extra dependencies
- Type-safe: All parameters are type annotated
- Newcomer-friendly: Only the concepts of
ContextandSceneare required to get started.
Install
pip install psychopy-scene
or copy the psychopy_scene folder directly to the root directory of your project.
Usage
Context
Experiment context Context means this experiment's global settings,
including environment parameters, task parameters, and so on.
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
# 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)
Scene
The experiment can be seen as a composition of a series of scenes Scene,
only 2 steps are required to write an experiment program:
- create scene
- write scene presentation logic
we can pass several stimuli to be drawn directly into
the Scene method, and these stimuli will be drawn automatically:
from psychopy.visual import TextStim
# create stimulus
stim_1 = TextStim(win, text="Hello")
stim_2 = TextStim(win, text="World")
# create scene
scene = ctx.Scene(stim_1) # draw stim_1
scene = ctx.Scene([stim_1, stim_2]) # draw stim_1 and stim_2
# show scene
scene.show()
The scene has 2 configuration methods, each method should be called once.
duration method sets the duration of the scene,
close_on method sets the keys to close the scene,
scene = ctx.Scene(stim).duration(1).close_on("f", 'j')
The duration of some scenes isn't fixed, so we can set its duration dynamically by state management:
scene = ctx.Scene(stim).duration()
scene.show(duration=1)
[!CAUTION] error example of configuring scene:
scene = ctx.Scene(stim).duration(1).duration(2)scene_1 = ctx.Scene(stim).duration(1) scene_2 = scene_1.close_on("f", 'j')
Different scenes may draw the same type of stimulus,
such as guide and end scenes need to draw text stimulus.
In this case, we only need to create 1 text stimulus,
then use the hook method to add a custom function to a specific stage of the scene,
this function is named lifecycle hook.
In the following example,
the guide and end scenes will show different text,
but they use the same stimulus. Because the hook is added to the setup stage will be called before the first draw.
# this is equivalent to:
# guide = ctx.Scene(stim).hook('setup')(lambda: stim.text = "Welcome to the experiment")
@(ctx.Scene(stim).hook('setup'))
def guide():
# change stimulus parameters before first drawing
stim.text = "Welcome to the experiment"
@(ctx.Scene(stim).hook('setup'))
def end():
# change stimulus parameters again, becasue this scene will show another text
stim.text = "Thanks for your participation"
guide.show()
end.show()
In this way, we can draw stimlus flexibly.
if we want to draw dynamic stimuli, just add a lifecycle hook to the frame stage:
from psychopy import core
@(ctx.Scene(stim).hook('frame'))
def scene():
stim.text = f"Current time is {core.getTime()}"
scene.show()
State Management
Always, we need to show a series of scenes with the same type of stimulus but different content. In this case, we can use these kinds of parameters that will be changed in each show as the state of the scene:
@(ctx.Scene(stim).duration(0.1).hook('setup'))
def scene():
stim.text = scene.get("text") # get `text` state
for instensity in ['A', 'B', 'C']:
scene.show(text=instensity) # set `text` state and show
[!NOTE] the
showmethod will reset state during its initialization phase at each call. See lifecycle for details.
Built-in State
Some states will be set automatically by the configured method of the scene.
| State | Description | Which method |
|---|---|---|
| show_time | timestamp of the start of the display | show |
| close_time | timestamp of the end of the display | show |
| duration | duration | duration |
| keys | pressed keys | close_on |
| response_time | timestamp of key press | close_on |
Handle Interaction
In most cases, using close_on method to configure the keys to close the scene is enough.
However, if we want to do other things when the keys are pressed,
we can use the on method to add custom functions for different keys.
These functions are named event listeners, ref wiki:
# add listener for keys, listener will be executed when the corresponding key is pressed
ctx.Scene().on(
space=lambda e: print(f"space key was pressed, this event is: {e}"),
mouse_left=lambda e: print(f"left mouse button was pressed, this event is: {e}"),
)
Note that one key with one listener, the last listener will cover the previous listeners:
# only the last listener will be emitted when multiple listeners are added for the same key
ctx.Scene().on(
space=lambda e: print("this listener won't be executed")
).on(
space=lambda e: print("this listener will be executed")
)
# when `f` is pressed, the scene won't be closed
ctx.Scene().close_on("f", "j").on(
f=lambda e: print("this listener will cover `close_on` listener")
)
Every listener function should accept an event object as parameter e,
which includes the scene that emits the event and the pressed keys
ctx.Scene().on(
space=lambda e: print(f"this scene is: {e.target}; pressed keys are: {e.keys}")
)
Keys
- keyboard: same as the return value of
keyboard.getKeys() - mouse:
mouse_left、mouse_right、mouse_middle
Data Collection
PsychoPy's recommended way of collecting experimental data is to use ExperimentHandler.
Now we can use ctx.addLine for data collection,
and access the ExperimentHandler object via ctx.expHandler.
# it will call `ctx.expHandler.addData` and `ctx.expHandler.nextEntry` automatically
ctx.addLine(correct=..., rt=...)
As stated in the State Management section,
some interaction data are automatically collected by close_on.
If we use the close_on method, we can access these states after the show method is executed:
scene = ctx.Scene().close_on("f", "j")
scene.show()
keys = scene.get("keys") # KeyPress or str
response_time = scene.get("response_time") # float
Of course, we can also add listeners manually as in the Handle Interaction section:
scene = ctx.Scene().on(space=lambda e: scene.set(rt=core.getTime() - scene.get("show_time")))
scene.show()
rt = scene.get("rt") # float
Lifecycle
There are a series of steps involved
in drawing a picture to the screen using the show method:
Resetting and initializing the state, clearing the event buffer,
drawing the stimulus, recording the start of the display time, and so on,
this entire process is named the lifecycle of the scene.
During this process, lifecycle hooks are executed at the same time,
allowing us to perform some custom actions at specific stages of the screen showing process.
| Stage | Execution Timing | Common Usage |
|---|---|---|
| setup | before first draw | set stimulus parameters |
| drawn | after first draw | execute time-consuming tasks |
| frame | every frame | update stimulus parameters |
Illustration of the lifecycle:
graph TD
Initialize --> setup-stage --> First-draw --> drawn-stage --> c{should it show ?}
c -->|no| Close
c -->|yes| frame-stage --> Redraw --> Listen-interaction --> c
Best Practices
Separation of context and task
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(stim).duration(duration)
scene.show()
return ctx.expHandler.getAllEntries()
Focus only on task-related logic
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(stim).duration(0.2)
scene.show()
return ctx.expHandler.getAllEntries()
win = Window()
data = []
for block_index in range(10):
ctx = Context(win)
ctx.expHandler.extraInfo['block_index'] = block_index
block_data = task(ctx)
data.extends(block_data)
Separate of TrialHandler and task
Thanks to PsychoPy's encapsulation, we can easily control the next trial.
from psychopy.data import TrialHandler
handler = TrialHandler(trialsList=['A', 'B', 'C'], nReps=1, nTrials=10)
for trial in handler:
trial # except: 'A" or 'B' or 'C'
For the separation of the trial iterator from the task function,
the library provides the ctx.handler property.
It can be used to control the next trial and collect trial-related data into ctx.expHandler.
All we need to do is set the handler parameter when creating the context.
from psychopy_scene import Context
from psychopy.visual import Window
from psychopy.data import TrialHandler
def task(ctx: Context):
from psychopy.visual import TextStim
stim = TextStim(ctx.win, text="")
@(ctx.Scene(stim).duration(0.2).hook('setup'))
def scene():
stim.text = scene.get("text")
for instensity in ctx.handler:
scene.show(text=instensity)
return ctx.expHandler.getAllEntries()
ctx = Context(
Window(),
handler=TrialHandler(trialsList=['A', 'B', 'C'], nReps=1, nTrials=10),
)
data = task(ctx)
However, when we intend to use StairHandler and access ctx.handler.addResponse,
the Pylance type checker will report an error,
even though ctx.handler is a StairHandler object.
This is because ctx.handler does not have an addResponse method of type ctx.handler.
To work around this, we can use ctx.responseHandler instead of ctx.handler.
[!WARNING] If the
ctx.handlerdoes not have anaddResponsemethod at runtime, accessingctx.responseHandlerwill throw an exception. So make sure thehandlerparameter passed in has anaddResponsemethod when usingctx.responseHandler.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file psychopy_scene-0.1.0rc2.tar.gz.
File metadata
- Download URL: psychopy_scene-0.1.0rc2.tar.gz
- Upload date:
- Size: 22.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.12.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c94740b3cea813ed95bdb21155f1176a31fc1dbea78a9634329036c1ade966d0
|
|
| MD5 |
f899deeaf8eb24904bd645eba4c55fe3
|
|
| BLAKE2b-256 |
608d6843381816f341b6c13ddd19001230bfb23949dc62180ec9ea7288699865
|
Provenance
The following attestation bundles were made for psychopy_scene-0.1.0rc2.tar.gz:
Publisher:
pypi.yaml on bluebones-team/psychopy-scene
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
psychopy_scene-0.1.0rc2.tar.gz -
Subject digest:
c94740b3cea813ed95bdb21155f1176a31fc1dbea78a9634329036c1ade966d0 - Sigstore transparency entry: 193678275
- Sigstore integration time:
-
Permalink:
bluebones-team/psychopy-scene@b118a4cad056ef51ce9d6cd05fd52cad5ce63a9e -
Branch / Tag:
refs/tags/0.1.0rc2 - Owner: https://github.com/bluebones-team
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yaml@b118a4cad056ef51ce9d6cd05fd52cad5ce63a9e -
Trigger Event:
push
-
Statement type:
File details
Details for the file psychopy_scene-0.1.0rc2-py3-none-any.whl.
File metadata
- Download URL: psychopy_scene-0.1.0rc2-py3-none-any.whl
- Upload date:
- Size: 23.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.12.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d5a50f5d034207033e124371045a3f072a8cd53bb5858f169e00de57698ecbae
|
|
| MD5 |
dc1babff76c47e118ae9893e536d365b
|
|
| BLAKE2b-256 |
03f3e51c17f2e07c7c41d6f6de8cd68ff80693dd37b3cc2ca4f57bf3127d67fc
|
Provenance
The following attestation bundles were made for psychopy_scene-0.1.0rc2-py3-none-any.whl:
Publisher:
pypi.yaml on bluebones-team/psychopy-scene
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
psychopy_scene-0.1.0rc2-py3-none-any.whl -
Subject digest:
d5a50f5d034207033e124371045a3f072a8cd53bb5858f169e00de57698ecbae - Sigstore transparency entry: 193678276
- Sigstore integration time:
-
Permalink:
bluebones-team/psychopy-scene@b118a4cad056ef51ce9d6cd05fd52cad5ce63a9e -
Branch / Tag:
refs/tags/0.1.0rc2 - Owner: https://github.com/bluebones-team
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yaml@b118a4cad056ef51ce9d6cd05fd52cad5ce63a9e -
Trigger Event:
push
-
Statement type: