from typing import Dict, List, Literal, Tuple, Union
try:
from psychopy.visual import ImageStim, TextStim, Window
from psychopy.visual.rect import Rect
except ModuleNotFoundError:
from .psychopy_stubs import Window, TextStim, ImageStim, Rect
import warnings
from .default_images import lose_cross, win_cross, zero_cross
from .logger import ExperimentLogger, SimulationLogger
[docs]
class BaseStimulus:
"""
Base class for stimulus presentation. If called on its own it will flip the
window.
"""
[docs]
def __init__(self, duration: float = None, name: str = None, wait_no_keys=False):
"""
Stimulus presentation base class. The parameters do not really do anything.
Parameters
----------
duration : float, optional
duration of the stimulus presentation, by default None
name : str, optional
name of the object, will be used for logging, by default None
"""
self.duration = duration
self.name = name
self.entity = "base"
self.wait_no_keys = wait_no_keys
self.is_setup = False
[docs]
def setup(self, win: Window, **kwargs):
"""
Call this to setup the stimulus. This means associating the stimulus
with a window (so there is something to flip).
Parameters
----------
win : Window
The psychopy window object that is used for displaying stimuli.
"""
self._setup(win, **kwargs)
self.is_setup = True
[docs]
def display(self, win: Window, logger: ExperimentLogger, **kwargs) -> None:
"""
Calls the stimulus object. In this case initiate a window flip.
Should only return something, if there has been an action required.
Returns
-------
None
Should return None
"""
stim_onset = logger.get_time()
win.flip()
logger.wait(win, self.duration, stim_onset, self.wait_no_keys)
logger.log_event(
{"event_type": self.name, "expected_duration": self.duration},
onset=stim_onset,
)
return None
[docs]
def simulate(self, logger: ExperimentLogger, **kwargs) -> None:
"""
Function to pretend that a stimulus has been shown. Logging and creating
timing.
Returns
-------
None
Does not return anything, but logs the stimulus.
"""
stim_onset = logger.get_time()
logger.wait(win=None, time=self.duration, start=stim_onset)
logger.log_event(
{"event_type": self.name, "expected_duration": self.duration},
onset=stim_onset,
)
def _setup(self, win: Window, **kwargs):
pass
[docs]
class TextStimulus(BaseStimulus):
"""
A stimulus class for text display in psychopy.
"""
[docs]
def __init__(
self,
duration: float,
text: str,
position: Tuple[int, int] = None,
name: str = None,
text_color: str = "white",
):
"""
Stimulus class for text displays.
Parameters
----------
duration : float
Duration of the stimulus presentation.
text : str
The text that should be displayed on the screen.
position : Tuple[int, int], optional
Where to display the text (by default in px), by default None
name : str, optional
name of the object, will be used for logging, by default None
text_color : str, optional
Color of the text string, by default "white"
"""
super().__init__(name=name, duration=duration)
self.text = text
self.position = position
self.text_color = text_color
def _setup(self, win: Window, **kwargs):
"""
Performs the setup for the stimulus object. Initiating a PsychoPy.TextStim,
object, using the parameters given parameters.
Parameters
----------
win : Window
The psychopy window object that is used for displaying stimuli.
"""
self.textStim = TextStim(
win=win, name=self.name, text=self.text, color=self.text_color
)
[docs]
def display(self, win: Window, logger: ExperimentLogger, **kwargs) -> None:
"""
Calls the stimulus object. In this case drawing the text stim, flipping the window,
waiting and logging.
Parameters
----------
win : Window
The psychopy window object that is used for displaying stimuli.
logger : ExperimentLogger
The logger associated with the experiment.
Returns
-------
None
Should return None
"""
stim_onset = logger.get_time()
self.textStim.draw()
win.flip()
logger.wait(win, self.duration, stim_onset)
logger.log_event(
{"event_type": self.name, "expected_duration": self.duration},
onset=stim_onset,
)
return None
[docs]
class ImageStimulus(BaseStimulus):
"""
A stimulus class to display (multiple) images in psychopy.
"""
[docs]
def __init__(
self,
duration: float,
image_paths: List,
positions: List = None,
name: str = None,
width: float = None,
height: float = None,
autodraw: bool = False,
wait_no_keys: bool = False,
):
"""
Stimulus class for image displays.
Parameters
----------
duration : float
Duration of the stimulus presentation.
image_paths : List
A list of paths to images that should be displayed on screen.
positions : List, optional
A list of positions (where the images should be displayed), by default None
name : str, optional
name of the object, will be used for logging, by default None
wait_no_keys : str, optional
If the logger's wait function should get key presses - only set to True if presses that are too early should be used, by default False
"""
super().__init__(name=name, duration=duration, wait_no_keys=wait_no_keys)
self.image_paths = image_paths
if positions is None:
positions = [(0, 0)] * len(image_paths)
self.positions = positions
self.width = width
self.height = height
self.autodraw = autodraw
def _setup(self, win: Window, image_paths=None, **kwargs):
"""
Performs the setup for the stimulus object. Initiating PsychoPy.ImageStim objects,
given the provides paths and positions.
Parameters
----------
win : Window
The psychopy window object that is used for displaying stimuli.
image_paths : _type_, optional
Image paths can be overwritten during setup, for example to allow for randomization, by default None
"""
if image_paths is not None:
self.image_paths = image_paths
self.imageStims = []
for ip, pos in zip(self.image_paths, self.positions):
if isinstance(ip, str):
self.imageStims.append(ImageStim(win, image=ip, pos=pos))
else:
width = ip.shape[1] if self.width is None else self.width
height = ip.shape[0] if self.height is None else self.height
self.imageStims.append(
ImageStim(win, image=ip, size=(width, height), pos=pos)
)
for ip in self.imageStims:
ip.autoDraw = self.autodraw
[docs]
def display(self, win: Window, logger: ExperimentLogger, **kwargs):
"""
Calls the stimulus object. In this case drawing the images stims, flipping the window,
waiting and logging.
Parameters
----------
win : Window
The psychopy window object that is used for displaying stimuli.
logger : ExperimentLogger
The logger associated with the experiment.
Returns
-------
None
Should return None
"""
stim_onset = logger.get_time()
# So that images are drawn on top
for ii in self.imageStims:
ii.autoDraw = True
win.flip()
for ii in self.imageStims:
ii.autoDraw = self.autodraw
logger.wait(win, self.duration, stim_onset, self.wait_no_keys)
logger.log_event(
{"event_type": self.name, "expected_duration": self.duration},
onset=stim_onset,
)
return None
[docs]
class ActionStimulus(BaseStimulus):
"""
Stimulus class, for when actions are required by the participants.
"""
[docs]
def __init__(
self,
duration: float,
key_dict: Dict = {"left": 0, "right": 1},
name: str = "response",
timeout_action: int = None,
name_timeout="response-time-out",
):
"""
Setting up the action object.
Parameters
----------
duration : float
Duration of the response window.
key_dict : dict, optional
Dictionary to map keyboard responses to actions recognized by the environment, by default {"left": 0, "right": 1}
name : str, optional
name of the object, will be used for logging, by default None
timeout_action : int, optional
Behavior of the object if the response window times out, making it possible that no response is also a distinc action, by default None
"""
super().__init__(name=name, duration=duration)
self.key_list = list(key_dict.keys())
self.key_dict = key_dict
self.timeout_action = timeout_action
self.name_timeout = name_timeout
self.entity = "action"
def _setup(self, win: Window = None, **kwargs):
"""
Does not need a special setup, including the function, to make easy looping possible.
Parameters
----------
win : Window, optional
The psychopy window object that is used for displaying stimuli, by default None
"""
pass
[docs]
def display(
self, win: Window, logger: ExperimentLogger, info: Dict, **kwargs
) -> Union[int, str]:
"""
Calls the stimulus object. In this case waiting for a specific response,
returning it and logging the process. Flipping the window in the end.
Parameters
----------
win : Window
The psychopy window object that is used for displaying stimuli.
logger : ExperimentLogger
The logger associated with the experiment.
Returns
-------
Union[int, str]
The response issued by the participant, interpretable by the environment.
"""
logger.key_strokes(win)
response_window_onset = logger.get_time()
response = self._response_handling(
win, logger, response_window_onset, info=info
)
win.flip()
return response
def _response_handling(
self, win, logger, response_window_onset, key_dict=None, info=None
):
response_window = response_window_onset + self.duration
response_present = False
remaining = None
if key_dict is None:
key_dict = self.key_dict
# Main loop, keeping time and waiting for response.
while response_window > logger.get_time() and response_present is False:
response = logger.key_strokes(win, keyList=self.key_list)
if response:
RT = response[1] - response_window_onset
response_present = True
if info is not None and "behav_remap" in info.keys():
action = info["behav_remap"][key_dict[response[0]]]
response_key = key_dict[response[0]]
logger.log_event(
{
"event_type": self.name,
"response_button": response[0],
"response_time": RT,
"action": action,
},
onset=response_window_onset,
)
remaining = self.duration - RT
# What todo if response window timed out and now response has been given.
if response_present is False:
RT = None
response_key = self.timeout_action
if response_key is None:
response_key = logger.na
logger.log_event(
{
"event_type": self.name_timeout,
"response_late": True,
"response_time": RT,
"response_button": logger.na,
"action": self.timeout_action,
},
onset=response_window_onset,
)
if response_key == logger.na:
return None
return response_key, remaining
[docs]
def simulate(
self,
win: Window,
logger=SimulationLogger,
key: str = None,
rt: float = None,
info: Dict = None,
**kwargs,
):
response_window_onset = logger.get_time()
response = self._simulate_response(
logger,
key,
rt,
response_window_onset=response_window_onset,
info=info,
)
return response
def _simulate_response(self, logger, key, rt, response_window_onset, info):
response_key, rt = logger.key_strokes(key, rt)
if info is not None and "behav_remap" in info.keys():
response_button = [
n for n, b in enumerate(info["behav_remap"]) if b == response_key
]
if len(response_button) == 1:
response_button = response_button[0]
else:
response_button = response_key
else:
response_button = response_key
if rt is None:
warnings.warn(
"Simulating environment: Consider setting expose_last_stim=True during env setup."
)
logger.global_clock.time += min([rt, self.duration])
if rt > self.duration:
if response_key is None:
response_key = logger.na
response_button = logger.na
logger.log_event(
{
"event_type": self.name_timeout,
"response_late": True,
"response_time": rt,
"response_button": response_button,
"action": response_key,
},
onset=response_window_onset,
)
response_key = self.timeout_action
remaining = None
if response_key is None:
return None
else:
logger.log_event(
{
"event_type": self.name,
"response_button": response_button,
"response_time": rt,
"action": response_key,
},
onset=response_window_onset,
)
remaining = self.duration - rt
return response_key, remaining
[docs]
class FeedBackStimulus(BaseStimulus):
"""
Class that possibly will be superseded or become obsolete at some point.
Purpose of the class is to provide feedback to the participant, about the
number of points they have received in a given trial or across the experiment.
"""
[docs]
def __init__(
self,
duration: float,
text: str,
position: Tuple[int, int] = (0, 125),
name: str = None,
target: Literal["reward", "total_reward"] = "reward",
text_color: str = "white",
font_height: float = 50,
feedback_stim: Dict = True,
simple: bool = False,
bar_total: float = None,
bar_labels: Dict = {"left": "0 kr", "right": "75 kr"},
):
"""
FeedBack class, provides feedback to the participant, by creating
updatable TextStims.
Parameters
----------
duration : float
Duration of the feedback.
text : str
The text that should be displayed on the screen. Should be a format string!
position : Tuple[int, int], optional
Where to display the text (by default in px), by default None
name : str, optional
name of the object, will be used for logging, by default None
target : Literal["reward", "total_reward"], optional
If the trial's reward or the total reward should be shown, by default "reward"
text_color : str, optional
Color of the text, by default "white"
feedback_stim : Dict, optional
If the feedback_stim should be displayed, if True changes fixation cross,
if None does not show an image as feedback. For other images populate the
keys: win, lose, zero with a string to an image or a numpy array.
"""
super().__init__(name=name, duration=duration)
self.text = text
self.position = position
self.text_color = text_color
self.target = target
if feedback_stim is True:
self.feedback_stim = {
"win": win_cross(),
"lose": lose_cross(),
"zero": zero_cross(),
}
elif feedback_stim is None or feedback_stim is False:
self.feedback_stim = {}
else:
self.feedback_stim = feedback_stim
self.font_height = font_height
self.simple = simple
self.bar_total = bar_total
self.bar_labels = bar_labels
self.bar_length = 400
self.bar_height = 50
def _setup(self, win, **kwargs):
self.is_setup = True
self.reward_text = TextStim(
win=win,
name=self.name,
text=self.text,
color=self.text_color,
height=self.font_height,
pos=self.position,
font="arial",
alignText="center",
anchorHoriz="center",
units="pix",
)
if self.bar_total is None:
self.total_reward_ind = TextStim(
win=win,
name=self.name + "2",
text="Total: 0.0",
font="arial",
color=self.text_color,
height=20,
pos=(0, 350),
alignText="center",
anchorHoriz="center",
units="pix",
)
self.total_reward_ind.autoDraw = True
else:
self.point_bar = Rect(
win=win,
name=self.name + "total_bar",
width=self.bar_length,
height=self.bar_height,
lineColor="white",
fillColor=[0.25, 0.25, 0.75],
lineWidth=5,
pos=(0, 350),
units="pix",
)
self.total_reward_ind = Rect(
win=win,
name=self.name + "total_indicator",
width=10,
height=self.bar_height + 10,
lineColor=None,
fillColor="white",
lineWidth=0,
pos=(-self.bar_length // 2, 350),
)
self.text_left = TextStim(
win=win,
name="label_left",
text=self.bar_labels["left"],
color="white",
height=25,
pos=(-self.bar_length // 2 - 40, 350),
)
self.text_right = TextStim(
win=win,
name="label_right",
text=self.bar_labels["right"],
color="white",
height=25,
pos=(self.bar_length // 2 + 40, 350),
)
self.text_right.setAutoDraw(True)
self.text_left.setAutoDraw(True)
self.point_bar.autoDraw = True
self.total_reward_ind.autoDraw = True
self.feedback_image = {}
for kk in self.feedback_stim.keys():
if isinstance(self.feedback_stim[kk], str):
self.feedback_image[kk] = ImageStim(
win=win, image=self.feedback_stim[kk]
)
else:
self.feedback_image[kk] = ImageStim(
win,
image=self.feedback_stim[kk],
size=self.feedback_stim[kk].shape[:2],
)
[docs]
def display(
self,
win: Window,
logger: ExperimentLogger,
reward: float,
total_reward: float,
**kwargs,
) -> None:
"""
Calls the stimulus object, to display the reward. Uses reward or total_reward
depending on the target that has been defined.
Parameters
----------
win : Window
The psychopy window object that is used for displaying stimuli.
logger : ExperimentLogger
The logger associated with the experiment.
reward : float
Reward of the given trial, provided by the environment.
total_reward : float
Total reward, provided by the environment.
Returns
-------
None
Should return None
"""
# Fills in the format string. Adds the + sign for positive rewards.
# if self.target == "reward":
if reward > 0:
feedback_img = "win"
elif reward < 0:
feedback_img = "lose"
else:
feedback_img = "zero"
reward_outcome = f"+{reward:5.1f}" if reward > 0 else f"{reward:5.1f}"
self.reward_text.setText(self.text.format(reward_outcome))
stim_onset = logger.get_time()
if feedback_img in self.feedback_image.keys():
self.feedback_image[feedback_img].setAutoDraw(True)
self.reward_text.setAutoDraw(True)
if not self.simple:
if self.bar_total is None:
self._draw_total_reward(total_reward=total_reward, win=win)
else:
self._update_reward_bar(total_reward=total_reward)
win.flip()
logger.wait(win, self.duration, stim_onset)
if feedback_img in self.feedback_image.keys():
self.feedback_image[feedback_img].setAutoDraw(False)
self.reward_text.setAutoDraw(False)
if not self.simple:
self.total_reward_ind.setAutoDraw(True)
win.flip()
logger.log_event(
{
"event_type": self.name,
"expected_duration": self.duration,
"total_reward": total_reward,
},
onset=stim_onset,
reward=reward,
)
return None
def _draw_total_reward(self, total_reward, win):
total_reward_outcome = (
f"+{total_reward:5.1f}" if total_reward > 0 else f"{total_reward:5.1f}"
)
reward_stage2 = "Total: " + total_reward_outcome
self.total_reward_ind.setText(reward_stage2)
self.total_reward_ind.setAutoDraw(False)
self.total_reward_ind.draw()
def _update_reward_bar(self, total_reward):
move_bar = int((total_reward / self.bar_total) * self.bar_length)
move_bar = min([max([move_bar, 0]), self.bar_length])
self.total_reward_ind.setPos((-self.bar_length // 2 + move_bar, 350))
self.total_reward_ind.setAutoDraw(True)
self.total_reward_ind.draw()
[docs]
def simulate(
self,
logger=ExperimentLogger,
reward: float = None,
total_reward: float = None,
**kwargs,
) -> None:
"""
Function to pretend that a stimulus has been shown. Logging and creating
timing.
Returns
-------
None
Does not return anything, but logs the stimulus.
"""
stim_onset = logger.get_time()
logger.wait(win=None, time=self.duration, start=stim_onset)
logger.log_event(
{
"event_type": self.name,
"expected_duration": self.duration,
"total_reward": total_reward,
},
onset=stim_onset,
reward=reward,
)