In this post I am going to explain how you can create a visual search display for a compound visual search task with an additional singleton that can either be singled out by color or orientation. The display has multiple items arranged in concentric circles. At the bottom there is the full code included in a working OpenSesame example.
There is nothing really special about the setup of the experimental structure in OpenSesame. The two scripts of interest are general_functions and search_display.
In search_display, I defined everything that is important to set up the display. This is called and executed on every trial. The script general_functions only includes the functions to actually draw each stimulus. The function is called from search_display for every item individually.
General idea
We want the search display to be arranged in concentric circles, so what we do is create three lists, one for each circle, which we then fill with objects. These objects contain the construction manual (i.e. size, color, orientation, etc) for the items in the search display
The inner circle has 6 items that are always nontargets (in this example), the outer circler has 18 items which are also always nontargets. These lists can be filled easily because we basically need to put in the same thing 6 respectively 18 times. The middle circle contains 12 items, one of which is the search target and one may be a distractor.
When we filled the lists with all the objects, we wil go on and loop through the lists to actually draw the items circle by circle and item by item until the whole search display is drawn.
Importantly, everything needs to be put into the prepare phase of OpenSesame because creating and drawing the stimuli is computation intensive and might slow down the experiment on slower machines. We only need one command in the run phase, which we will see later.
Below, I often use the function self.get(“VariableName“). You can replace this with var.VariableName. It’s an artifact from earlier version of OpenSesame.
Setting basic properties
First, we need to import necessary libraries which we should put in general_functions.
import math import random from random import randrange
Next, we start working within search_display. We set the basic colors for our stimuli and create the three empty lists.
# Determine the properties of the # target, distractor and non-targets target_color = "#FC0015" nontarget_color = "#00a0a6" distractor_color = "#FC0015" # Create three lists filled with the target, the # distractor (if present) and a number of # non-targets, which depends on the display size. # The lists represent the three circles. # Target and distractor can only appear on the middle circle. # Create an empty list of stimuli stimuli_circle1 = [] stimuli_circle2 = [] # circle with target and distractor stimuli_circle3 = []
Then we need to define the angles where stuff can appear on the circle. Usually you want to have all items equidistantly arranged across the whole extent of the circle.
# Set the angles at which targets can appear # assuming a circle with 12 possible positions angles = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
Remember that stimuli_circle1 and stimuli_circle3 will only contain nontargets so we will deal with them later.
Defining target and distractor
First, we specify the location of the search target. You can have a set locaiton or do it randomly, like me.
# Select random angle for the target and remove it from the lsit target_location = angles.pop(randrange(0,len(angles)))
With randrange, we select a random number within the specified range. The function pop returns an item of the list which is now saved in target_location and at the same time removes the item from the list. This is good, because we don’t want to have multiple items at the same locations.
In these types of compound searches, you want the target to have more than one “identity” so that not the same button is the correct answer in every trial (duh!). In my case, I change the orientation of the target so it either looks like an i or an !.
# Based on target identitiy (i or !), randomly pick a target orientation and add the target to the list of stimuli if self.get("target_identity") == "i": target_orient = random.choice( (12, 348) ) else: target_orient = random.choice( (192, 162) )
Then we add our target item for the list of items (it’s the first in the list, yay!).
stimuli_circle2.append( (target_color, target_orient, target_location) )
The distractor is slightly more complex because we first need to check whether it is actually present in the current display and if yes, what type the distractor is. Afterwards, we can generate its location and append it to the list stimuli_circle2.
# If a distractor is present in the display, # determine its orientation and color based on the distractor type if self.get("distractor") == "present": if self.get("distractor_type") == "color": distractor_color = "#FC0015" # red distractor distractor_orient = random.choice( (0,180) ) if self.get("distractor_type") == "orient": distractor_orient = random.choice( (90,270) ) # horizontal distractor # Set random distracotr location distractor_location = angles.pop(randrange(0,len(angles))) # Append the distractor to the list of display items stimuli_circle2.append( (distractor_color, distractor_orient, distractor_location) )
Adding the nontargets
The idea for the inner and outer circle is simple. We create a loop that generates a random orientation for each nontarget and add it to the list. It looks like below for the inner circle.
# Add the remaining nontarget stimuli to the list - CIRCLE 1 for i in range(6 - int(len(stimuli_circle1))): nontarget_orient = random.choice((0,180)) stimuli_circle1.append( (nontarget_color, nontarget_orient) )
For the outer circle, you might have guessed it, the 6 needs to be exchanged for an 18. What we do here is to say: as long as our list of object hasn’t reached the total number of objects we want (i.e. 6), append more objects! For the middle circle, we need to add information about the location of the objects, because one or two locations are already occupied with a target plus possibly distractor.
# Add the remaining nontarget stimuli to the list - CIRCLE 2 for i in range(12 - len(stimuli_circle2)): nontarget_orient = random.choice((0,180)) nontarget_location = angles.pop(randrange(0,len(angles))) stimuli_circle2.append( (nontarget_color, nontarget_orient, nontarget_location) )
Like we did with the target and distractor, we get our remaining object locations by popping them out of the angles list. We have three lists filled with all our items now and can start to create the canvas where we later draw the items on.
Creating the canvas and setting circle properties
Creating a canvas is simple.
# Create a new offline canvas self.c = self.offline_canvas() # We want the stimuli to have some body self.c.set_penwidth(1)
We now need to decide how big our circles are gonna be. The sizes can be specified relatively or absolutely. Since I wanted to make sure that they look the same on all screens, I specified absolute pixel values. This is something you just need to try out or calculate from the visual angle you want to achieve.
# Setting circle properties radius_circle1 = 341 * 0.3333 # Distance of the inner circle from center radius_circle2 = 341 * 0.6667 radius_circle3 = 341 line_length = 76*0.5 # Size of the lines in the shapes line_width = 14 # The angular separation of the stimuli depends on the number of items angular_separation_circle1 = 360.0 / 6 angular_separation_circle2 = 360.0 / 12 angular_separation_circle3 = 360.0 / 18 # The first stimulis is drawn at angle 270 (12 clock position) angle = 270
Above we defined the radius of each circle, the length and width of our stimuli as well as the angular separation of the items in the three circles. Importantly, the angle attribute specifies where we want the items to be drawn on the circle. The first item will be drawn at 270° which is the 12 o’clock position (I don’t know why).
Drawing all stimuli
The easiest thing is the center. There is one nontarget in the middle. We need to determine its location (i.e. the center of the screen) and then draw it with the correct attributes.
# Determine coordinates of center nontarget x = self.get("width") / 2 y = self.get("height") / 2 # Set the color of the stimulus self.c.set_fgcolor(nontarget_color) # draw the center nontarget draw_asp_stimulus(x, y, line_width, line_length, nontarget_orient, self.c)
The function draw_asp_stimulus draws the actual stimulus on the canvas and will be discussed later. After drawing the central nontarget we start drawing our items from the inner to the outer circle.
# Walk through all the stimuli - CIRCLE 1 - inner circle for color, orient in stimuli_circle1: # Determine the coordinates of the stimulus x = self.get("width") / 2 + radius_circle1 * math.cos(math.radians(angle)) y = self.get("height") / 2 + radius_circle1 * math.sin(math.radians(angle)) # Set the color of the stimulus self.c.set_fgcolor(color) # Draw special ASP stimulus which is an iterrupted bar looking like an i or bang draw_asp_stimulus(x, y, line_width, line_length, orient, self.c) # Make sure the next stimulus is drawn at a different location angle += angular_separation_circle1
For each item in the stimuli_circle1 list, we determine the coordinates by using some fancy math based on the angle, set the foreground color and call the draw function. Then we add the angular separation to the angle, so that the next item will be drawn at the next position on the circle. This works similarly for all circles.
Actually drawing one stimulus
One item contains of one large turquoise rectangle and one small black rectangle which is either at the top or bottom of the large rectangle. The function draw_asp_stimulus takes the arguments x,y position, width, length, orientation and the canvas. The x,y position represents the center of mass of the object, not some corner! This means what we first need to do is calculate the x,y positions four corners.
def draw_asp_stimulus(x, y, line_width, line_length, orientation, c): # OUTER RECTANGLE # set corners x1 = x - line_width/2 y1 = y - line_length x2 = x + line_width/2 y2 = y - line_length x3 = x + line_width/2 y3 = y + line_length x4 = x - line_width/2 y4 = y + line_length
Since not all our items have the same orientation (especially target and distractor), we now need to rotate all the corner points. What we do is a point rotation around a central pivot point (the center of mass) by the orientation angle.
# polygon rotation p1 = rotate_point(x, y, orientation, x1, y1) p2 = rotate_point(x, y, orientation, x2, y2) p3 = rotate_point(x, y, orientation, x3, y3) p4 = rotate_point(x, y, orientation, x4, y4)
Unfortunately, the rotate_point function, doesn’t really exist, so we need to create our own!
def rotate_point(cx, cy, angle, x, y): s = math.sin(math.radians(angle)) c = math.cos(math.radians(angle)) x = x - cx y = y - cy xnew = x * c - y * s ynew = x * s + y * c x = xnew + cx y = ynew + cy return [x,y]
I will spare you the mathematical details of the point rotation. We now created and rotated the corner points from the outer rectangle, we need to do the same with the inner rectangle.
# INNER RECTANGLE # set corners cx1 = x - line_width/2 cy1 = y - line_length + line_width cx2 = x + line_width/2 cy2 = y - line_length + line_width cx3 = x + line_width/2 cy3 = y - line_length + line_width + line_width cx4 = x - line_width/2 cy4 = y - line_length + line_width + line_width #point rotation cp1 = rotate_point(x, y, orientation, cx1, cy1) cp2 = rotate_point(x, y, orientation, cx2, cy2) cp3 = rotate_point(x, y, orientation, cx3, cy3) cp4 = rotate_point(x, y, orientation, cx4, cy4)
Lastly, we need to draw our rectangles with the polygon function on our canvas.
self.c.polygon([(p1[0], p1[1]),(p2[0], p2[1]), (p3[0], p3[1]), (p4[0], p4[1])], fill=True) self.c.set_fgcolor("black") self.c.polygon([(cp1[0], cp1[1]),(cp2[0], cp2[1]), (cp3[0], cp3[1]), (cp4[0], cp4[1])], fill=True)
We now created the canvas full of juicy objects, the only thing left to do is show it at runtime. The following command needs to be included in the run phase of the search_display.
# Show the canvas! self.c.show()
Tadaa! Wasn’t so hard was it? If you have any questions, feel free to ask me at any time. I am also happy about suggestions for improving the code. Please comment or write me an email.
Below you can find the whole OpenSesame code for this experiment but you can also download it here.
--- API: 2 OpenSesame: 3.1.3 Platform: posix --- set width 1024 set uniform_coordinates no set transparent_variables no set title "Marian Visual Search" set synth_backend legacy set subject_parity even set subject_nr 0 set start experiment set sound_sample_size -16 set sound_freq 48000 set sound_channels 2 set sound_buf_size 1024 set sampler_backend legacy set round_decimals 2 set mouse_backend psycho set keyboard_backend psycho set height 768 set fullscreen no set form_clicks no set foreground white set font_underline no set font_size 28 set font_italic no set font_family sans set font_bold no set experiment_path "/Users/mariansauter/Dropbox/PhD/Blog/Visual Search Display/MS-ASP-LT-1-Chronicles" set disable_garbage_collection yes set description "A probability cuing experiment" set coordinates relative set compensation 0 set color_backend psycho set clock_backend psycho set canvas_backend psycho set bidi no set background black define advanced_delay advanced_delay set jitter_mode Uniform set jitter 200 set duration 900 set description "Waits for a specified duration" define loop block_loop set source_file "" set source table set repeat 30 set order random set item trial_sequence set description "A block of trials" set cycles 4 set continuous no set column_order "distractor;circles;inner_circle_size;mid_circle_size;outer_circle_size;target_identity;distractor_type" set break_if_on_first yes set break_if never setcycle 0 distractor_type orient setcycle 0 target_identity i setcycle 0 distractor present setcycle 1 distractor_type orient setcycle 1 target_identity bang setcycle 1 distractor present setcycle 2 distractor_type orient setcycle 2 target_identity i setcycle 2 distractor absent setcycle 3 distractor_type orient setcycle 3 target_identity bang setcycle 3 distractor absent run trial_sequence define sequence block_sequence set flush_keyboard yes set description "An instruction screen, followed by a block of trials and feedback" run reset_feedback always run block_loop always run feedback always define inline_script each_block set description "Executes Python code" ___run__ current_block = current_block + 1; self.experiment.set("block_count", current_block) __end__ set _prepare "" define sketchpad error_display set reset_variables no set duration 495 set description "Displays stimuli" draw textline center=1 color=white font_bold=no font_family=mono font_italic=no font_size=18 html=no show_if=always text=Error x=0 y=0 z_index=0 define sequence experiment set flush_keyboard yes set description "The main experimental sequence" run general_functions always run instruction_form always run experimental_loop always run goodbye always define loop experimental_loop set source_file "" set source table set skip 0 set repeat 12 set order sequential set item block_sequence set description "Run a number of experimental blocks" set cycles 1 set continuous no set column_order "practice;condition" set break_if_on_first yes set break_if never setcycle 0 practice no setcycle 0 condition top run block_sequence define feedback feedback set reset_variables yes set duration keypress set description "Provides feedback to the participant" draw textline center=1 color=white font_bold=no font_family=mono font_italic=no font_size=18 html=yes show_if=always text="Your average response time was [avg_rt]ms" x=0 y=-128 z_index=0 draw textline center=1 color=white font_bold=no font_family=mono font_italic=no font_size=18 html=yes show_if=always text="Your accuracy was [acc]%" x=0 y=-64 z_index=0 draw textline center=1 color=white font_bold=no font_family=mono font_italic=no font_size=18 html=yes show_if=always text="Press any key to continue ..." x=0 y=64 z_index=0 draw textline center=1 color=white font_bold=no font_family=mono font_italic=no font_size=18 html=yes show_if=always text="This was block [block_count] of 12" x=0 y=-224 z_index=0 define fixation_dot fixation_dot set y 0 set x 0 set style cross set penwidth 3 set foreground white set duration 0 set description "Presents a central fixation dot with a choice of various styles" set background black define inline_script general_functions set description "Executes Python code" ___run__ current_block = 0 import math import random from random import randrange # function rotate_point rotates a point x,y at a given angle around a pivot point cx,cy def rotate_point(cx, cy, angle, x, y): s = math.sin(math.radians(angle)) c = math.cos(math.radians(angle)) x = x - cx y = y - cy xnew = x * c - y * s ynew = x * s + y * c x = xnew + cx y = ynew + cy return [x,y] # draws a rotated stimulus for the ASP paradigm # takes center x, y, stimulus width and height and its orientation def draw_asp_stimulus(x, y, line_width, line_length, orientation, c): # OUTER RECTANGLE # set corners x1 = x - line_width/2 y1 = y - line_length x2 = x + line_width/2 y2 = y - line_length x3 = x + line_width/2 y3 = y + line_length x4 = x - line_width/2 y4 = y + line_length # point rotation p1 = rotate_point(x, y, orientation, x1, y1) p2 = rotate_point(x, y, orientation, x2, y2) p3 = rotate_point(x, y, orientation, x3, y3) p4 = rotate_point(x, y, orientation, x4, y4) # INNER RECTANGLE # set corners cx1 = x - line_width/2 cy1 = y - line_length + line_width cx2 = x + line_width/2 cy2 = y - line_length + line_width cx3 = x + line_width/2 cy3 = y - line_length + line_width + line_width cx4 = x - line_width/2 cy4 = y - line_length + line_width + line_width #point rotation cp1 = rotate_point(x, y, orientation, cx1, cy1) cp2 = rotate_point(x, y, orientation, cx2, cy2) cp3 = rotate_point(x, y, orientation, cx3, cy3) cp4 = rotate_point(x, y, orientation, cx4, cy4) self.c.polygon([(p1[0], p1[1]),(p2[0], p2[1]), (p3[0], p3[1]), (p4[0], p4[1])], fill=True) self.c.set_fgcolor("black") self.c.polygon([(cp1[0], cp1[1]),(cp2[0], cp2[1]), (cp3[0], cp3[1]), (cp4[0], cp4[1])], fill=True) __end__ set _prepare "" define sketchpad goodbye set start_response_interval no set reset_variables no set duration keypress set description "Say goodbye!" draw textline center=1 color=white font_bold=no font_family=mono font_italic=no font_size=18 html=yes show_if=always text="The experiment is finished!" x=0 y=-64 z_index=0 draw textline center=1 color=white font_bold=no font_family=mono font_italic=no font_size=18 html=yes show_if=always text="Press any key to exit ..." x=0 y=64 z_index=0 define sketchpad green_fixdot set start_response_interval no set reset_variables no set duration 500 set description "Displays stimuli" draw fixdot color=green show_if=always style="medium-cross" x=0 y=0 z_index=0 define sketchpad instruction_form set start_response_interval no set reset_variables no set duration keypress set description "Present instruction for the form condition" draw textline center=1 color=white font_bold=no font_family=sans font_italic=no font_size=18 html=yes show_if=always text="When you have read and understood the instructions, <br />you can start the experiment by pressing any key." x=0 y=0 z_index=0 define keyboard_response keyboard_response set timeout infinite set flush yes set duration keypress set description "Collects keyboard responses" set allowed_responses "y;m" define logger logger set use_quotes yes set ignore_missing yes set description "Logs experimental data" set auto_log no log fixation_interval log response log response_time log correct log correct_keyboard_response log average_response_time log avg_rt log accuracy log acc log count_experimental_loop log practice log condition log repeat log count_block_loop log target_identity log distractor_type log mid_circle_size log distractor log inner_circle_size log outer_circle_size log foreground log background log target_location log target_pos log target_color log target_orient log distractor_pos log distractor_location log distractor_color log distractor_orient log correct_response log count_trial_sequence log block_count log height log subject_nr log width log count_block_sequence log response_time_instruction_form define sketchpad red_fixdot set start_response_interval no set reset_variables no set duration 500 set description "Displays stimuli" draw fixdot color=red show_if=always style="medium-cross" x=0 y=0 z_index=0 define reset_feedback reset_feedback set description "Resets the feedback variables, such as 'avg_rt' and 'acc'" define inline_script search_display set description "The search display" ___run__ # Show the canvas! self.c.show() __end__ ___prepare__ # Determine the properties of the # target, distractor and non-targets target_color = "#FC0015" nontarget_color = "#00a0a6" distractor_color = "#FC0015" # Create three lists filled with the target, the # distractor (if present) and a number of # non-targets, which depends on the display size. # The lists represent the three circles. # Target and distractor can only appear on the middle circle. # Create an empty list of stimuli stimuli_circle1 = [] stimuli_circle2 = [] # circle with target and distractor stimuli_circle3 = [] # Set the angles at which targets can appear # assuming a circle with 12 possible positions angles = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330] # Select random angle for the target and remove it from the lsit target_location = angles.pop(randrange(0,len(angles))) # Based on target identitiy (i or !), randomly pick a target orientation and add the target to the list of stimuli if self.get("target_identity") == "i": target_orient = random.choice( (12, 348) ) else: target_orient = random.choice( (192, 162) ) stimuli_circle2.append( (target_color, target_orient, target_location) ) # If a distractor is present in the display, # determine its orientation and color based on the distractor type if self.get("distractor") == "present": if self.get("distractor_type") == "color": distractor_color = "#FC0015" # red distractor distractor_orient = random.choice( (0,180) ) if self.get("distractor_type") == "orient": distractor_orient = random.choice( (90,270) ) # horizontal distractor # Set random distracotr location distractor_location = angles.pop(randrange(0,len(angles))) # Append the distractor to the list of display items stimuli_circle2.append( (distractor_color, distractor_orient, distractor_location) ) # determine the list of locations to be filled with nontargets and shuffle them (i dont know why I do that) random.shuffle(angles) # Add the remaining nontarget stimuli to the list - CIRCLE 1 for i in range(6 - int(len(stimuli_circle1))): nontarget_orient = random.choice((0,180)) stimuli_circle1.append( (nontarget_color, nontarget_orient) ) # Add the remaining nontarget stimuli to the list - CIRCLE 2 for i in range(12 - len(stimuli_circle2)): nontarget_orient = random.choice((0,180)) nontarget_location = angles.pop(randrange(0,len(angles))) stimuli_circle2.append( (nontarget_color, nontarget_orient, nontarget_location) ) # Add the remaining nontarget stimuli to the list - CIRCLE 3 for i in range(18 - len(stimuli_circle3)): nontarget_orient = random.choice((0,180)) stimuli_circle3.append( (nontarget_color, nontarget_orient) ) # # Set correct_repsonse dpending on condition # 'correct_response' is a special variable that, if it exists, # is interpreted as the expected response by the # keyboard_response item # if self.get("target_identity") == "i": self.experiment.set("correct_response", "m") else: self.experiment.set("correct_response", "y") ########################################################### # Create an offline canvas containing all off the stimuli # and a fixation dot ########################################################### # Create a new offline canvas self.c = self.offline_canvas() # Setting circle properties radius_circle1 = 341 * 0.3333 # Distance of the inner circle from center radius_circle2 = 341 * 0.6667 radius_circle3 = 341 line_length = 76*0.5 # Size of the lines in the shapes line_width = 14 # We want the stimuli to have some body self.c.set_penwidth(1) # The angular separation of the stimuli depends on the number of items # CAREFUL: middle circle locations are actually manually defined below angular_separation_circle1 = 360.0 / 6 angular_separation_circle2 = 360.0 / 12 angular_separation_circle3 = 360.0 / 18 # The first stimulis is drawn at angle 270 (12 clock position) angle = 270 # Determine coordinates of center nontarget x = self.get("width") / 2 y = self.get("height") / 2 # Set the color of the stimulus self.c.set_fgcolor(nontarget_color) # draw the center nontarget draw_asp_stimulus(x, y, line_width, line_length, nontarget_orient, self.c) # Walk through all the stimuli - CIRCLE 1 - inner circle for color, orient in stimuli_circle1: # Determine the coordinates of the stimulus x = self.get("width") / 2 + radius_circle1 * math.cos(math.radians(angle)) y = self.get("height") / 2 + radius_circle1 * math.sin(math.radians(angle)) # Set the color of the stimulus self.c.set_fgcolor(color) # Draw special ASP stimulus which is an iterrupted bar looking like an i or bang draw_asp_stimulus(x, y, line_width, line_length, orient, self.c) # Make sure the next stimulus is drawn at a different location angle += angular_separation_circle1 angle = 270 # Walk through all the stimuli - CIRCLE 2 - inner circle for color, orient, location in stimuli_circle2: # Determine the coordinates of the stimulus x = self.get("width") / 2 + radius_circle2 * math.cos(math.radians(location)) y = self.get("height") / 2 + radius_circle2 * math.sin(math.radians(location)) # Set the color of the stimulus self.c.set_fgcolor(color) # Draw special ASP stimulus which is an iterrupted bar looking like an i or bang draw_asp_stimulus(x, y, line_width, line_length, orient, self.c) # Make sure the next stimulus is drawn at a different location angle += angular_separation_circle2 angle = 270 # Walk through all the stimuli - CIRCLE 3 - outer circle for color, orient in stimuli_circle3: # Determine the coordinates of the stimulus x = self.get("width") / 2 + radius_circle3 * math.cos(math.radians(angle)) y = self.get("height") / 2 + radius_circle3 * math.sin(math.radians(angle)) # Set the color of the stimulus self.c.set_fgcolor(color) # Draw special ASP stimulus which is an iterrupted bar looking like an i or bang draw_asp_stimulus(x, y, line_width, line_length, orient, self.c) # Make sure the next stimulus is drawn at a different location angle += angular_separation_circle3 __end__ define inline_script trial_prep set description "Executes Python code" ___run__ #from random import randint #random_fix_interval = random.randint(700,1100) #self.experiment.set("fixation_interval", random_fix_interval) __end__ set _prepare "" define sequence trial_sequence set flush_keyboard yes set description "A single trial" run fixation_dot always run advanced_delay always run search_display always run keyboard_response always run error_display "[correct] = 0" run logger always
Leave a Reply