The best way to draw many text objects (RSVP)

Hi All,

In my experiment I show multiple RSVP streams around fixation. Each RSVP stream shows the alphabet - absent a couple of letters - in a random order in a particular position. A new letter is presented at each stream position every ~83ms.

I would like to show 21 streams simultaneously. My program can keep up with the screen refresh for 8 streams, but with 21 streams the time taken exceeds the screen refresh interval (both with the built-in graphics on new laptops, and old MacPro, and an external NVIDIA Quadra 4000).

I’m drawing each letter as a separate textStim before the trial begins, in the color of the background. On each trial frame, I then change the color of the critical letters to appear on each frame to their appropriate color.

Is there a faster way to draw this many letters? They are in fixed positions throughout the experiment, if that helps. I’ve considered elementArrayStim, but I’m unsure if it can do text stimuli or do them any quicker.

Hi,

I think ElementArrayStim is the best approach to use, but it isn’t clear how best to go about it.

I’ve taken a potentially overcomplicated method in the below example. First, I render a set of characters to a big array (like a film strip) that will form the element texture. That way, a particular character can be shown by setting the vertical ‘phase’ of each element. This seems to draw quickly enough.

import string

import numpy as np

import psychopy.visual
import psychopy.event
import psychopy.misc


letters = string.letters[:26]

win = psychopy.visual.Window(
    size=(800, 800),
    units="pix",
    fullscr=False
)

n_text = 26
text_cap_size = 34

text_strip_height = n_text * text_cap_size

text_strip = np.full((text_strip_height, text_cap_size), np.nan)

text = psychopy.visual.TextStim(win=win, height=32, font="Courier")

cap_rect_norm = [
    -(text_cap_size / 2.0) / (win.size[0] / 2.0),  # left
    +(text_cap_size / 2.0) / (win.size[1] / 2.0),  # top
    +(text_cap_size / 2.0) / (win.size[0] / 2.0),  # right
    -(text_cap_size / 2.0) / (win.size[1] / 2.0)   # bottom
]

# capture the rendering of each letter
for (i_letter, letter) in enumerate(letters):

    text.text = letter.upper()

    buff = psychopy.visual.BufferImageStim(
        win=win,
        stim=[text],
        rect=cap_rect_norm
    )

    i_rows = slice(
        i_letter * text_cap_size,
        i_letter * text_cap_size + text_cap_size
    )

    text_strip[i_rows, :] = (
        np.flipud(np.array(buff.image)[..., 0]) / 255.0 * 2.0 - 1.0
    )

# need to pad 'text_strip' to pow2 to use as a texture
new_size = max(
    [
        int(np.power(2, np.ceil(np.log(dim_size) / np.log(2))))
        for dim_size in text_strip.shape
    ]
)

pad_amounts = []

for i_dim in range(2):

    first_offset = int((new_size - text_strip.shape[i_dim]) / 2.0)
    second_offset = new_size - text_strip.shape[i_dim] - first_offset

    pad_amounts.append([first_offset, second_offset])

text_strip = np.pad(
    array=text_strip,
    pad_width=pad_amounts,
    mode="constant",
    constant_values=0.0
)

# position the elements on a circle
el_thetas = np.linspace(0, 360, n_text, endpoint=False)
el_rs = 300
el_xys = np.array(psychopy.misc.pol2cart(el_thetas, el_rs)).T

# make a central mask to show just one letter
el_mask = np.ones(text_strip.shape) * -1.0

# start by putting the visible section in the corner
el_mask[:text_cap_size, :text_cap_size] = 1.0

# then roll to the middle
el_mask = np.roll(
    el_mask,
    (new_size / 2 - text_cap_size / 2, ) * 2,
    axis=(0, 1)
)

# work out the phase offsets for the different letters
base_phase = (
    (text_cap_size * (n_text / 2.0)) - (text_cap_size / 2.0)
) / new_size

phase_inc = (text_cap_size) / float(new_size)

phases = np.array(
    [
        (0.0, base_phase - i_letter * phase_inc)
        for i_letter in range(n_text)
    ]
)

# random colours
colours = np.random.uniform(low=-1.0, high=1.0, size=(n_text, 3))

els = psychopy.visual.ElementArrayStim(
    win=win,
    units="pix",
    nElements=n_text,
    sizes=text_strip.shape,
    xys=el_xys,
    phases=phases,
    colors=colours,
    elementTex=text_strip,
    elementMask=el_mask
)

win.recordFrameIntervals = True

keep_going = True

i_frame = 0

while keep_going:

    if np.mod(i_frame, 5) == 0:
        els.phases = phases[np.random.choice(n_text, size=n_text), :]
        els.colors = colours[np.random.choice(n_text, size=n_text), :]

    els.draw()

    win.flip()

    keys = psychopy.event.getKeys()

    keep_going = not "q" in keys

    i_frame += 1

win.close()
1 Like

Hi Damien,

The way you’ve used bufferImageStim and elementArrayStim here does a great job of reducing the resource demands of multiple RSVP streams. I haven’t implemented it into our program yet, so I’m not sure if it will solve the problem entirely. Regardless, it’s a big step forward. Thanks very much! Come say hi next time you’re over Sydney Uni way.

Cheers,

Charlie