Skip to main content

Lightweight experiment framework for PsychoPy

Project description

PsychoPy-Scene

PyPI - Version PyPI - Downloads

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 Context and Scene are 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:

  1. create scene
  2. 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 show method 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_leftmouse_rightmouse_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.handler does not have an addResponse method at runtime, accessing ctx.responseHandler will throw an exception. So make sure the handler parameter passed in has an addResponse method when using ctx.responseHandler.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

psychopy_scene-0.1.0rc2.tar.gz (22.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

psychopy_scene-0.1.0rc2-py3-none-any.whl (23.5 kB view details)

Uploaded Python 3

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

Hashes for psychopy_scene-0.1.0rc2.tar.gz
Algorithm Hash digest
SHA256 c94740b3cea813ed95bdb21155f1176a31fc1dbea78a9634329036c1ade966d0
MD5 f899deeaf8eb24904bd645eba4c55fe3
BLAKE2b-256 608d6843381816f341b6c13ddd19001230bfb23949dc62180ec9ea7288699865

See more details on using hashes here.

Provenance

The following attestation bundles were made for psychopy_scene-0.1.0rc2.tar.gz:

Publisher: pypi.yaml on bluebones-team/psychopy-scene

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file psychopy_scene-0.1.0rc2-py3-none-any.whl.

File metadata

File hashes

Hashes for psychopy_scene-0.1.0rc2-py3-none-any.whl
Algorithm Hash digest
SHA256 d5a50f5d034207033e124371045a3f072a8cd53bb5858f169e00de57698ecbae
MD5 dc1babff76c47e118ae9893e536d365b
BLAKE2b-256 03f3e51c17f2e07c7c41d6f6de8cd68ff80693dd37b3cc2ca4f57bf3127d67fc

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page