美文网首页使用PsychoPy编写心理学实验PsychoPy
【Python】从零开始运用Pygame/PsychoPy编写一

【Python】从零开始运用Pygame/PsychoPy编写一

作者: 韦子谦 | 来源:发表于2020-10-17 22:25 被阅读0次

    1 引言

    分享一下如何用Python编写一个简单的心理学程序,本文介绍了两个版本的程序,分别是用Python中的第三方库Pygame和PsychoPy编写的。

    Pygame是一个很经典的用于制作游戏的Python库,上手也很简单,其发布于2000年。除了做游戏之外,大家也会用它来做一些其他东西,例如心理学实验程序(之前在某本书里看到一句话,大致的意思是,心理学实验其实就相当于“boring game”,我觉得这个说法很有道理哈哈哈)。

    PsychoPy则是一个用于运行行为科学实验(例如神经科学、心理学、心理物理学、语言学……)的库,最早在2007年左右面世(详细请参阅:PsychoPy—Psychophysics software in Python),并于2010年在GitHub上开源。相较于Pygame,其更专注于实验程序的编写。得益于后来加入的独立客户端以及支持可视化编程的Builder界面,PsychoPy的使用者越来越多。

    编写的例子如下,我之前在编写过MATLAB的版本,感兴趣的话,可以点击链接阅读:【MATLAB】Psychtoolbox:编写一个简单的心理学实验程序

    今天要编写的例子是一个很简单的按键判断的实验,只有一个block,block里有五个trial。每个trial的流程图如下。首先呈现一个500~1200ms的注视点,然后随机出现左箭头或右箭头,被试需要根据出现的刺激按方向键反应,如果500ms之内按键则记录反应时、正确率等信息,并且箭头消失,否则箭头会呈现500ms的时间后自动消失,最后是300ms的空屏。


    无论是采用Pygame还是PsychoPy,都请新建一个名为“exp_example”的文件夹,在该文件夹中新建一个Python脚本(下文的代码都放在该脚本中),并在文件夹中新建两个名为“pic”和“data”的文件夹,将以下两个图片,分别命名为“exp_end.tif”和“exp_instruction.tif”,放在“pic”文件夹中。这两个图片是我用ppt画的,大家可以自行制作自己需要的指导语图片。

    2 实验程序的编写

    2.1 导入库

    无论采用Pygame还是Psychopy,都需要在脚本文件开头添加以下代码,声明该脚本是用Python3写的,编码格式是utf-8。

    #!/usr/bin/python3
    # -*- coding: utf-8 -*-
    

    接下来导入一些我们需要用到的库。

    Pygame:

    import csv
    import random
    import sys
    import time
    import pygame
    

    PsychoPy:

    import csv
    import random
    import time
    from psychopy import gui, visual, event, clock, core
    

    Python的强大之处就是存在着各种各样的标准库(library)和第三方模组(module),我们可以从各种库中调用各种函数,以满足我们的编程需求,这样就避免了“造车先造轮子”的问题,因为别人已经帮我们把轮子造好了。

    例如,我们想使用Pygame的函数,则需要在脚本的开头通过import pygame来导入这个库。

    一个模组可能由多个包(package)组成,有时我们只需要使用一个模组中的某个/某些包,此时可以用from 模组 import 包的形式来导入。例如我们只想使用PsychoPy模组中的visual包,则输入from psychopy import visual,然后便可以直接调用该包的函数,例如visual.Window(),而不需要psychopy.visual.Window()。一方面简化了代码,另一方面节省了导入库所需的时间。

    2.2 收集被试的基本信息

    Pygame:

    # 收集被试的基本信息
    sub_info = [input('请输入被试编号:'),
                input('请输入性别(1=male,2=female):'),
                input('请输入年龄:'),
                input('请输入利手(1=left, 2=right, 3=both):')]
    

    input()函数的作用是,让用户在控制台输入一些信息,括号内的参数则是对于需要输入的内容的提示。在这里,我们收集了四个基本信息,并以列表的形式储存至sub_info这个变量中,之后我们便可以通过调用该变量的值来获取这些信息。

    例如,输入的被试编号为“1001”,之后我们想知道本次实验的被试的编号,就可以通过sub_info[0]来获取(在Python中,列表的第一个元素的索引是0,第二个是1,以此类推)。

    PsychoPy:

    # 收集被试的基本信息
    sub_info = {'subNum': '', 'gender': ['male', 'female'],
                'age': '', 'handedness': ['right', 'left', 'both']}
    inputDlg = gui.DlgFromDict(dictionary=sub_info, title='exp_example',
                               order=['subNum', 'gender', 'age', 'handedness'])
    

    在PsychoPy中,我们可以运用更酷炫的方法来收集被试的基本信息,也就是gui.DlgFromDict()函数(gui.Dlg()函数也可以做到相同的功能,但语句相对而言比较繁琐,所以更推荐用gui.DlgFromDict())。

    首先,我们需要定义一个名为sub_info的字典(dictionary),在字典变量中,每个元素都是一个键值对,冒号之前的是键(key),冒号之后的是值(value)。我们将需要收集的信息放置在这个字典变量中。

    接着调用gui.DlgFromDict()函数,根据sub_info的内容,打开一个对话框,用于输入被试的基本信息。若字典中的键对应的值是空值,那么将在对话框中添加一个输入栏,若键对应的值是一个字符串列表,那么将在对话框中创建一个下拉列表(见下图)。

    另外两个参数titleorder,分别是设置对话框的标题,以及设置字典变量在对话框中的呈现顺序(默认的顺序是根据键的首字母来排列)。

    填写完毕并点击“OK”之后,sub_info中的值就会根据填写的内容而变化。例如,我们在对话框中输入被试编号为“1001”。那么之后便可以通过sub_info{'subNum'}来调用这个“1001”数值。

    此外我们还可以根据用户对打开的对话框的操作,在终端输出相应的信息,例如:

    if inputDlg.OK:  # 点击OK
        print(sub_info)
    else:  # 点击Cancel
        print('user cancelled')
    

    若点击OK,则显示输入的信息,若点击Cancel,则显示“user cancelled”。

    2.3 打开窗口

    无论是MATLAB中的PsychToolBox,还是Python中的Pygame、PsychoPy,编写实验程序的套路都是一样的。即,先打开一个窗口,然后将刺激呈现在这个窗口上(Draw & Flip!)。

    Pygame:

    # 打开窗口,设置一些参数
    pygame.init()  # pygame初始化
    x_pixels, y_pixels = pygame.display.list_modes()[0]  # 获取屏幕分辨率
    win = pygame.display.set_mode((x_pixels, y_pixels), pygame.FULLSCREEN)  # 打开窗口
    x_center = int(x_pixels / 2)  # 获取屏幕中心坐标
    y_center = int(y_pixels / 2)
    win.fill((0, 0, 0))  # 将窗口设置为黑色
    font = pygame.font.SysFont('SimHei', 50)  # 字体 & 字号
    pygame.mouse.set_visible(False)  # 隐藏鼠标指针
    

    为了使用Pygame,首先需要输入pygame.init(),使Pygame初始化,如果没有这段代码,直接调用Pygame的函数,程序就会出错。

    接着,我们通过pygame.display.list_modes()这个函数,来获取电脑屏幕的分辨率,该函数以列表(list)的形式返回了该电脑可用的分辨率,例如:

    [(1920, 1080), (1680, 1050), (1600, 900), ……(320, 240), (320, 200)]
    

    在这里,我们调用这个列表的第一个元素,即[0],本例中就是(1920, 1080)啦,我们将这两个数值赋给变量x_pixelsy_pixels

    接着,我们调用根据x_pixelsy_pixels的值,通过pygame.display.set_mode()函数,打开一个窗口,我们将这个窗口命名为win。顾名思义,pygame.FULLSCREEN这个参数的目的是将窗口设置为全屏。

    接着,我们将x_pixelsy_pixels除以2,分别赋值给x_centery_center,作为屏幕的中心坐标。Pygame打开的窗口,左上角的坐标是 (0, 0),右下角的坐标是屏幕的分辨率,因此中心点的坐标就是分辨率除以2啦。如果我们想在屏幕上的一些位置呈现刺激,那么这些刺激的坐标就可以设置为 (x_center ± 数值,y_center ± 数值),这样理解起来更方便。

    win.fill()的作用是更改win这个窗口的背景色,0,0,0是黑色的RGB颜色值,我们通过这个函数,将窗口的背景改为黑色。

    font = pygame.font.SysFont('SimHei', 50)则是设置窗口中呈现的文本对象的字体(SimHei,也就是“黑体”)和字号(字号为50),之后我们便可以通过调用font这个变量,来呈现文本刺激(包括中文的文本,前提是我们选择了类似SimHei这种支持中文的字体)。

    pygame.mouse.set_visible(False)True就是显示鼠标指针,False则是隐藏鼠标指针。

    PsychoPy:

    # 打开一个1920*1080分辨率的窗口,黑色背景,全屏,单位为像素,中心坐标是(0,0)
    win = visual.Window(size=[1920, 1080], color='#010101', fullscr=True, units='pix')
    event.Mouse(visible=False)  # 隐藏鼠标指针
    

    PsychoPy的代码就简单多了,直接用visual.Window()打开一个窗口即可。visual.Window()有很多可更改的参数,建议大家前往官网的文档页面学习一下,目前我这里只写了该程序中必须要设定的参数。

    值得一提的是,PsychoPy的优势之一在于提供了多种单位类型,我这里使用了像素作为单位。无论采用何种单位,窗口中心的坐标始终为(0, 0)(即,窗口中心始终是坐标轴原点),相应地,设置视觉刺激的坐标时,正数意味着上/右,负数意味着下/左。

    以下是对各种可选单位的小结,参考自官方文档:Units for the window and stimuli。表格中提到了窗口(window)和屏幕(screen),前者指的是visual.Window()打开的窗口,后者指的是电脑显示器的屏幕。

    单位名称 说明 需要提供的信息
    高度单位(Height unit) 基于窗口的高度指定刺激内容。这里的高度对应的是y轴,其范围始终为-0.5至0.5,x轴的范围则可以基于此数值进行换算。
    例如,对于一个宽高比为16:10的窗口,左下角的坐标是(-0.8, -0.5),右上角的坐标是(0.8, 0.5)。
    标准化单位(Normalised units) 窗口坐标系的范围始终为-1至1,其中左下角的坐标为(-1, -1),右上角的坐标为(1, 1)。使用此单位时,刺激的形状会因为窗口比例的不同而扭曲。
    例如,对于4:3的窗口,size=(0.75,1)才是正方形。
    基于屏幕的厘米单位(Centimeters on screen) 以厘米为单位设置刺激的大小和位置,坐标轴的范围便是屏幕的高度、宽度。
    例如,右上角的坐标是(屏幕宽度/2, 屏幕高度/2)。
    屏幕的宽度(单位:cm、像素)
    视角度单位(Degrees of visual angle) 使用视角度设置刺激的大小和位置。该单位有三种计算方式:degdegFlatdegFlatPos,具体可以查阅官方文档的说明。 屏幕的宽度(单位:cm、像素),以及被试距离屏幕的距离(单位:cm。可以在Monitor Center里设置)
    基于屏幕的像素单位(Pixels in screen) 以像素为单位设置刺激的大小和位置。 屏幕的宽度(单位:像素)

    2.4 准备实验材料,呈现指导语

    这一部分,Pygame和Psychopy的代码是一样的。

    Pygame & Psychopy:

    # 准备数据变量
    rt = []
    resp = []
    acc = []
    dots_time = []
    arrow_orientation = []
    
    # 随机生成注视点时间和刺激材料
    for i in range(0, 5):
        dots_time.append(random.uniform(0.5, 1.2))  # 注视点的呈现时间(包含500 & 1200)
        if random.randint(0, 1) == 0:  # 随机决定箭头的朝向
            arrow_orientation.append('←')
        else:
            arrow_orientation.append('→')
    

    先定义几个名为rtrespacc的空列表,之后我们便可以将被试的反应时、反应按键和正确率,添加到这些列表中。dots_timearrow_orientation这两个列表则用于存储注视点时间和箭头刺激。

    如果你不记得这个例子的实验设计,可以回头去看看文章开头的实验流程图。在本例中,注视点的呈现时间是在一个范围内随机确定的(500~1200ms),箭头刺激则是左箭头和右箭头随机呈现。

    这个例子中共有5个trial,所以我们需要五个注视点时间和五个箭头刺激。for i in range(0, 5):就是将下述的语句循环五次的意思。

    random.uniform()是random库中的一个函数,作用是生成一个指定范围之内的随机浮点数,我们通过这个函数随机生成一个数值,并使用dots_time.append()将该数值添加至dots_time这个列表中。

    例如,生成的第一个随机数是0.879,我们将这个数值添加至dots_time这个列表中,此时该列表的内容从[]变成了[0.879],之后我们便可以通过dots_time[0]来调用这个数值。

    对于箭头刺激也是类似的逻辑,我们先通过random.randint(0, 1)生成一个随机整数(在这里,范围为0~1),并通过if语句判断,如果结果是0,则向arrow_orientation这个列表中添加一个内容为左箭头的字符串,否则添加一个右箭头。

    2.5 呈现指导语

    Pygame:

    # 呈现指导语
    instruction = pygame.image.load('pic/exp_instruction.tif')
    instruction_size = instruction.get_rect()
    win.blit(instruction, (instruction_size[2] - x_center, instruction_size[3] - y_center))
    pygame.display.update()
    wait = True
    while wait:  # 等待按键
        for event in pygame.event.get():
            if event.type == pygame.KEYDOWN:
                wait = False
    

    在Pygame中,呈现图片的一般方法如下:先使用pygame.image.load()函数读取指定文件夹中的图片,将读取到的内容放置到一个变量中,然后使用窗口名.blit(图片变量),将图片变量绘制在指定的窗口内,最后使用pygame.display.flip()或者pygame.display.update()来刷新窗口,从而使绘制好的图片能够呈现出来。

    接下来简要说说Pygame窗口的坐标系。

    在Pygame的窗口中,窗口左上角的坐标是(0, 0)。窗口上方的边界是X轴,左边的边界是Y轴。假设窗口的大小是1920*1080像素,那么窗口右上角的坐标是(1920, 0),左下角的坐标是(0, 1080)。

    对于blit(),第一个参数是图片变量,第二个参数是图片在窗口中的位置,这个位置的参考点是图片的左上角。

    所以,如果我们将图片的呈现位置设置为(0, 0),那么图片将呈现在窗口的左上方,且图片的左上角正好位于窗口的左上角。

    所以,为了使指导语图片能够呈现在屏幕中心,我们首先需要通过instruction.get_rect()函数来获取指导语图片的大小。假设图片的大小为1280*720像素,那么该函数返回的结果将是<rect(0, 0, 1280, 720)>,四个数值分别指图片左上角距离Y轴的距离、图片左上角距离X轴的距离、图片的宽、图片的高。

    于是,将图片的坐标设置为(instruction_size[2] - x_center, instruction_size[3] - y_center),这样图片就可以呈现在屏幕中心了。

    PsychoPy:

    # 生成实验材料
    fixation = visual.Circle(win, fillColor='#FFFFFF', radius=5)
    arrow = visual.TextStim(win, font='SimHei', color='#FFFFFF', height=50)
    instruction = visual.ImageStim(win, image='pic/exp_instruction.tif')
    exp_end = visual.ImageStim(win, image='pic/exp_end.tif')
    
    # 呈现指导语
    instruction.draw()
    win.flip()
    event.waitKeys()
    

    至于PsychoPy,刺激呈现的逻辑大致是这样的:创建(create)刺激、绘制(draw)刺激,然后刷新窗口(flip)。在正式实验开始前,我们先创建所需的刺激,之后当需要呈现这些刺激时,只需要draw & flip就可以了。这样我们就可以在正式实验中省去创建刺激所用的时间,减少计时误差。

    这里我们创建了四个刺激,依次为注视点(fixation)、箭头刺激(arrow)指导语(instruction)、结束语(exp_end)。

    PsychoPy提供了众多用于创建视觉刺激的函数,这里使用了三种函数。

    • visual.Circle()的作用是根据给定的半径,创建一个圆形。我们使用这个函数来创建注视点。
    • visual.TextStim()的作用是创建文本刺激。我们通过这个函数来创建箭头刺激。在这个函数内,通过text=来指定文本的内容,目前我们还没有指定text,因为每个trial的arrow都是不一样的,我们需要在每个trial中指定具体的文本内容。
    • 最后,visual.ImageStim()就是呈现图片啦,参数也很简单,分别选择“pic”文件夹下的两张图片即可。

    接着是呈现指导语,正如上文所述,先将刺激变量绘制出来(draw()),然后刷新屏幕(flip())即可。接着我们调用event.waitKeys()函数,等待被试按任意键继续。

    2.6 每个trial的内容

    对于Pygame和PsychoPy,无论是呈现什么视觉刺激,思路都是一样的:

    1. 绘制刺激(对于Pygame,在绘制刺激之前,还需要将背景填充为黑色,以覆盖掉之前在屏幕上呈现的内容),
    2. 刷新屏幕,使绘制的内容能够呈现出来。
    3. 等待一段时间(例如,实验设计为刺激呈现500ms,那么就等待500ms),或者等待某个事件(例如等待被试按键反应)。

    首先添加以下代码:

    # 总共5个trial,每个trial的内容如下
    for trial in range(0, 5):
    

    这是一个for循环,循环语句内的代码将反复5次,即,总共有5个trial。

    接下来,2.6.1至2.6.4的内容都放置在这个for循环语句内。

    2.6.1 注视点

    Pygame:

        # 注视点:500~1200ms
        win.fill((0, 0, 0))
        pygame.draw.circle(win, (255, 255, 255), (x_center, y_center), 5)
        pygame.display.update()
        time.sleep(dots_time[trial])
    

    首先是注视点的绘制,pygame.draw.circle()是Pygame绘制圆形的函数,我们就通过该函数来绘制注视点。函数中的几个参数分别是:绘制的窗口(就是win啦),圆形的颜色(255, 255, 255指的是白色),圆形的坐标位置((x_center, y_center)就是屏幕中心啦),5则是圆形的半径。绘制完毕后刷新屏幕,接着我们通过time.sleep()来使程序等待一会,time.sleep()中的参数就相当于注视点的呈现时间(单位:秒),这里我们直接调用了dots_time中的数值。

    PsychoPy:

        # 注视点:500~1200ms
        fixation.draw()
        win.flip()
        clock.wait(dots_time[trial])
    

    PsychoPy的部分就简单多了,因为我们刚刚已经把所以刺激都创建好了,现在只需要draw & flip就好。刷新屏幕后,通过clock.wait()使程序等待一段时间(单位:秒),这里直接调用dots_time这个列表中的值即可。

    2.6.2 箭头刺激

        # 箭头刺激:500ms
        win.fill((0, 0, 0))
        arrow = font.render(arrow_orientation[trial], True, (255, 255, 255))  # 创建文本
        arrow_x, arrow_y = arrow.get_size()  # 获取文字的高度和宽度
        win.blit(arrow, (int(x_center - arrow_x / 2), int(y_center - arrow_y / 2)))
        pygame.display.update()
    

    刚刚绘制的注视点是一个图形,而现在要绘制的则是一个文本字符串。我们通过font.render()函数来绘制箭头,其中的参数分别是:字符串(我们已经将准备好的箭头刺激放在arrow_orientation这个列表里啦,所以这里直接调用就好),是否抗锯齿(当然是选“True”啦),字体颜色。

    win.blit()中设置坐标时,注意横坐标、纵坐标要分别减去字符串高度、宽度的一半,这样才可以呈现在屏幕正中心。

    PsychoPy:

        # 箭头刺激:500ms
        arrow.text = arrow_orientation[trial]
        arrow.draw()
        win.flip()
    

    和呈现注视点类似。区别在于,我们先调用arrow_orientation这个列表中的值,作为arrow的文本内容。

    2.6.3 记录反应信息

    该部分,Pygame和PsychoPy版本的程序的代码,基本是相同的,只是个别函数不同,因此先展示两个版本的代码,然后一齐讲解。

    Pygame:

        # 记录反应信息
        # 如果在500ms之内反应,则记录按键和反应时
        key_check = False
        t0 = time.time()  # 获取刺激开始呈现的时间
        while time.time() - t0 < 0.5:  # 在500ms内可以反应
            for event in pygame.event.get():
                if event.type == pygame.KEYDOWN:
                    if not key_check:
                        key_check = True
                        if event.key == pygame.K_ESCAPE:
                            pygame.quit()
                            sys.exit()
                        else:
                            rt.append(time.time() - t0)
                            resp.append(pygame.key.name(event.key))
                            if event.key == pygame.K_LEFT and arrow_orientation[trial] == '←':
                                acc.append(1)
                            elif event.key == pygame.K_RIGHT and arrow_orientation[trial] == '→':
                                acc.append(1)
                            else:
                                acc.append(0)
                        break
        if not key_check:  # 未按键的情况下,反应信息记为"None"
            rt.append('None')
            resp.append('None')
            acc.append('None')
    

    PsychoPy:

        # 记录反应信息
        # 如果在500ms之内反应,则记录按键和反应时
        key_check = False
        t0 = core.getTime()
        while core.getTime() - t0 < 0.5:  # 在500ms内可以反应
            key = event.getKeys()
            if len(key) != 0:
                rt.append(core.getTime() - t0)
                resp.append(key[0])
                key_check = True
                if key[0] == 'escape':
                    win.close()
                else:
                    if key[0] == 'left' and arrow_orientation[trial] == '←':
                        acc.append(1)
                    elif key[0] == 'right' and arrow_orientation[trial] == '→':
                        acc.append(1)
                    else:
                        acc.append(0)
                break
        if not key_check:  # 未按键的情况下,反应信息记为"None"
            rt.append('None')
            resp.append('None')
            acc.append('None')
    

    或许大家会注意到,在上述的代码中,我们并没有使用time.sleep()(Pygame部分)或者clock.wait()(PsychoPy部分)来指定箭头刺激呈现的时间。因为time.sleep()clock.wait()实际上是将程序挂起,而挂起的这段时间内我们是无法进行任何操作的。

    我们的设想是,被试需要在刺激呈现的500ms内反应,若反应,则刺激消失,否则500ms后刺激自动消失。

    因此,我们可以通过while循环语句来反复获取时间,当时间在500ms之内时,获取被试的按键并记录反应信息。

    此外,我们还定义了一个变量key_check,其初始值为“False”,当被试按键时,我们将key_check的值改为“True”。500ms的循环结束后,假若key_check的值仍然是“False”,则我们将反应时、反应按键、正确率记为“None”。

    现在来看看while循环中的内容,在500ms内,我们反复地通过pygame.event.get()(Pygame部分)或key = event.getKeys()来(PsychoPy部分)来获取被试的操作:

    • Pygame部分:当pygame.event.get()获取的操作是按下按键(KEYDOWN)的时候,我们先判断key_check是的值是否为“False”(if not key_check:相当于if key_check == False:),若为“False”,则赋值为“True”,这么做的目的是,当被试多次按键时,只记录第一次按键的信息。
    • PsychoPy部分:event.getKeys()返回的是一个列表,其中的元素是按键名。当未识别到按键时,key是一个空列表。我们可以通过len()函数来获取列表key的长度,当len(key)不等于0时,说明被试按下了至少一个按键。如果被试在500ms内多次按键,那么key这个列表内将有多个元素,而我们只需要记录被试的第一个按键,第一个按键便是key[0](即列表的第一个元素)。

    K_ESCAPE(Pygame版本)、'escape'(PsychoPy版本)指的是“Esc”键,我们将这个按键设置为退出键,当按下这个按键时,程序将被强制关闭。

    如果按下的按键不是“Esc”键,那么:

    Pygame:

    • time.time() - t0作为这个trial的反应时,添加至rt列表中。
    • 通过pygame.key.name(event.key) 获取按键名,添加至resp列表中。
    • 通过if语句进行判断,若按键为K_LEFT/K_RIGHT(即,左方向键/右方向键)且这个trial的箭头刺激为左箭头/右箭头,则将数字1添加至acc列表中。否则将数字0添加至acc列表中。

    PsychoPy:

    • core.getTime() - t0作为这个trial的反应时,添加至rt列表中。
    • key[0]作为反应按键,添加至resp列表中。
    • 通过if语句进行判断,若按键为'left'/'right'(即,左方向键/右方向键)且这个trial的箭头刺激为左箭头/右箭头,则将数字1添加至acc列表中。否则将数字0添加至acc列表中。

    Pygame中的按键名可以在Pygame的官方文档中查阅(如下图所示)。

    PsychoPy的按键名列表我没有找到,我是先运行一遍,然后看看按下某个按键会返回什么。

    2.6.4 空屏

    Pygame:

        # 空屏:300ms
        win.fill((0, 0, 0))
        pygame.display.update()
        time.sleep(0.3)
    

    PsychoPy:

        # 空屏:300ms
        win.flip()
        clock.wait(0.3)
    

    这部分比较容易理解,就不展开说了。

    2.7 呈现结束语,关闭窗口

    Pygame:

    # 呈现结束语
    exp_end = pygame.image.load('pic/exp_end.tif')
    exp_end_size = exp_end.get_rect()
    win.blit(exp_end, (exp_end_size[2] - x_center, exp_end_size[3] - y_center))
    pygame.display.update()
    time.sleep(1)
    
    # 关闭窗口
    pygame.quit()
    

    PsychoPy:

    # 呈现结束语
    exp_end.draw()
    win.flip()
    clock.wait(1)
    
    # 关闭窗口
    win.close()
    

    呈现结束语的套路和呈现指导语是一样的,区别在于,呈现结束语的1秒之后,我们通过pygame.quit()(Pygame版本)或win.close()(PsychoPy版本)将窗口关闭。

    2.8 储存数据

    Pygame:

    # 储存数据
    date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
    c = open('data/exp_example_pygame_{}_{}.csv'.format(sub_info[0], date),
             'w', encoding='utf-8', newline='')  # 创建csv表格
    csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
    csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                         'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
    for trial in range(0, 5):  # 写入csv
        csv_writer.writerow([sub_info[0], sub_info[1], sub_info[2], sub_info[3],
                             dots_time[trial], ord(arrow_orientation[trial]),
                             rt[trial], resp[trial], acc[trial]])
    c.close()  # 关闭csv表格
    
    print('Succeed!')
    

    PsychoPy:

    # 储存数据
    date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
    c = open('data/exp_example_psychopy_{}_{}.csv'.format(sub_info['subNum'], date),
             'w', encoding='utf-8', newline='')  # 创建csv表格
    csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
    csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                         'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
    for trial in range(0, 5):  # 写入csv
        csv_writer.writerow([sub_info['subNum'], sub_info['gender'], sub_info['age'], sub_info['handedness'],
                             dots_time[trial], ord(arrow_orientation[trial]),
                             rt[trial], resp[trial], acc[trial]])
    c.close()  # 关闭csv表格
    
    print('Succeed!')
    

    两个版本的代码是基本一样的,所以一齐讲解。

    首先,我们使用time.strftime()函数,获取日期和时间,例如:

    '2020_Oct_16_223718'
    

    表示“2020年10月16日,22点37分18秒”。

    接着,通过open()函数,在“data”文件夹中创建一个新文件,将文件名命名为:exp_example_pygame_{}_{}.csv(Pygame版本)或exp_example_psychopy_{}_{}.csv(PsychoPy版本),我们通过format()函数,将sub_info[0](Pygame版本)或sub_info['subNum'](PsychoPy版本)和date的值放入到文件名中。

    最后生成的文件名将会类似这种样子:

    exp_example_psychopy_1001_2020_Oct_16_223718.csv
    

    这将保证,每次运行所得的数据文件,文件名都是独一无二的,因此即便输入了相同的被试编号,也无需担心数据文件会被同名文件覆盖掉。

    接下来,我们构建一个csv写入对象,名为csv_writer,然后就可以通过writerow()一行行地写入啦。

    首先写个表头,依次是被试编号、性别、年龄、利手、注视点时间、箭头朝向、反应按键、反应时、正确率。

    然后循环5次,依次写入每个trial的数据。其中,arrow_orientation的内容是特殊符号,写入时会乱码,所以我通过ord函数将其转换为ASCII字符串了,'←'是8592,'→'是8594。

    最后关闭数据文件。

    然后在终端输出“Succeed!”(这个不是必要操作,但是我喜欢哈哈哈)。

    3 实验程序的运行效果

    PsychoPy的程序在进入全屏之后,录屏软件就不起作用了,目前我还搞不懂是咋回事,先放个Pygame版本的运行效果好了(其实两个版本运行起来都差不多)。

    运行结束后,显示“Succeed!”,看来是顺利运行了!

    前往“data”文件夹,找到运行完毕后生成的数据文件,如下。

    首先,被试编号、性别、年龄、利手,都和我们输入的数值一致。注视点时间确实处于500~1200ms的范围内。刚刚的运行过程中,5个trial的箭头朝向分别是“右左左右右”,数据文件中的记录也是一致的(“左箭头”记录为8592,“右箭头”记录为8594)。反应时也处于合理的范围。刚刚运行时,我的按键反应分别是“右方向键、左方向键、上方向键、左方向键、无反应”,其中前两个是正确反应,第三、第四个是错误反应,在数据文件中,这些信息的记录也是无误的。

    Pygame版本完整代码:

    #!/usr/bin/python3
    # -*- coding: utf-8 -*-
    
    import csv
    import random
    import sys
    import time
    import pygame
    
    # 收集被试的基本信息
    sub_info = [input('请输入被试编号:'),
                input('请输入性别(1=male,2=female):'),
                input('请输入年龄:'),
                input('请输入利手(1=left, 2=right, 3=both):')]
    
    # 打开窗口,设置一些参数
    pygame.init()  # pygame初始化
    x_pixels, y_pixels = pygame.display.list_modes()[0]  # 获取屏幕分辨率
    win = pygame.display.set_mode((x_pixels, y_pixels), pygame.FULLSCREEN)  # 打开窗口
    x_center = int(x_pixels / 2)  # 获取屏幕中心坐标
    y_center = int(y_pixels / 2)
    win.fill((0, 0, 0))  # 将窗口设置为黑色
    font = pygame.font.SysFont('SimHei', 50)  # 字体 & 字号
    pygame.mouse.set_visible(False)  # 隐藏鼠标指针
    
    # 准备数据变量
    rt = []
    resp = []
    acc = []
    dots_time = []
    arrow_orientation = []
    
    # 随机生成注视点时间和刺激材料
    for i in range(0, 5):
        dots_time.append(random.uniform(0.5, 1.2))  # 注视点的呈现时间(包含500 & 1200)
        if random.randint(0, 1) == 0:  # 随机决定箭头的朝向
            arrow_orientation.append('←')
        else:
            arrow_orientation.append('→')
    
    # 呈现指导语
    instruction = pygame.image.load('pic/exp_instruction.tif')
    instruction_size = instruction.get_rect()
    win.blit(instruction, (instruction_size[2] - x_center, instruction_size[3] - y_center))
    pygame.display.update()
    wait = True
    while wait:  # 等待按键
        for event in pygame.event.get():
            if event.type == pygame.KEYDOWN:
                wait = False
    
    # 总共5个trial,每个trial的内容如下
    for trial in range(0, 5):
    
        # 注视点:500~1200ms
        win.fill((0, 0, 0))
        pygame.draw.circle(win, (255, 255, 255), (x_center, y_center), 5)
        pygame.display.update()
        time.sleep(dots_time[trial])
    
        # 箭头刺激:500ms
        win.fill((0, 0, 0))
        arrow = font.render(arrow_orientation[trial], True, (255, 255, 255))  # 创建文本
        arrow_x, arrow_y = arrow.get_size()  # 获取文字的高度和宽度
        win.blit(arrow, (int(x_center - arrow_x / 2), int(y_center - arrow_y / 2)))
        pygame.display.update()
    
        # 记录反应信息
        # 如果在500ms之内反应,则记录按键和反应时
        key_check = False
        t0 = time.time()  # 获取刺激开始呈现的时间
        while time.time() - t0 < 0.5:  # 在500ms内可以反应
            for event in pygame.event.get():
                if event.type == pygame.KEYDOWN:
                    if not key_check:
                        key_check = True
                        if event.key == pygame.K_ESCAPE:
                            pygame.quit()
                            sys.exit()
                        else:
                            rt.append(time.time() - t0)
                            resp.append(pygame.key.name(event.key))
                            if event.key == pygame.K_LEFT and arrow_orientation[trial] == '←':
                                acc.append(1)
                            elif event.key == pygame.K_RIGHT and arrow_orientation[trial] == '→':
                                acc.append(1)
                            else:
                                acc.append(0)
                        break
        if not key_check:  # 未按键的情况下,反应信息记为"None"
            rt.append('None')
            resp.append('None')
            acc.append('None')
    
        # 空屏:300ms
        win.fill((0, 0, 0))
        pygame.display.update()
        time.sleep(0.3)
    
    # 呈现结束语
    exp_end = pygame.image.load('pic/exp_end.tif')
    exp_end_size = exp_end.get_rect()
    win.blit(exp_end, (exp_end_size[2] - x_center, exp_end_size[3] - y_center))
    pygame.display.update()
    time.sleep(1)
    
    # 关闭窗口
    pygame.quit()
    
    # 储存数据
    date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
    c = open('data/exp_example_pygame_{}_{}.csv'.format(sub_info[0], date),
             'w', encoding='utf-8', newline='')  # 创建csv表格
    csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
    csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                         'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
    for trial in range(0, 5):  # 写入csv
        csv_writer.writerow([sub_info[0], sub_info[1], sub_info[2], sub_info[3],
                             dots_time[trial], ord(arrow_orientation[trial]),
                             rt[trial], resp[trial], acc[trial]])
    c.close()  # 关闭csv表格
    
    print('Succeed!')
    

    PsychoPy版本完整代码:

    #!/usr/bin/python3
    # -*- coding: utf-8 -*-
    
    import csv
    import random
    import time
    from psychopy import gui, visual, event, clock, core
    
    # 收集被试的基本信息
    sub_info = {'subNum': '', 'gender': ['male', 'female'],
                'age': '', 'handedness': ['right', 'left', 'both']}
    inputDlg = gui.DlgFromDict(dictionary=sub_info, title='exp_example',
                               order=['subNum', 'gender', 'age', 'handedness'])
    
    # 打开一个1920*1080分辨率的窗口,黑色背景,全屏,单位为像素,中心坐标是(0,0)
    win = visual.Window(size=[1920, 1080], color='#010101', fullscr=True, units='pix')
    event.Mouse(visible=False)  # 隐藏鼠标指针
    
    # 准备数据变量
    rt = []
    resp = []
    acc = []
    dots_time = []
    arrow_orientation = []
    
    # 随机生成注视点时间和刺激材料
    for i in range(0, 5):
        dots_time.append(random.uniform(0.5, 1.2))  # 注视点的呈现时间(包含500 & 1200)
        if random.randint(0, 1) == 0:  # 随机决定箭头的朝向
            arrow_orientation.append('←')
        else:
            arrow_orientation.append('→')
    
    # 生成实验材料
    fixation = visual.Circle(win, fillColor='#FFFFFF', radius=5)
    arrow = visual.TextStim(win, font='SimHei', color='#FFFFFF', height=50)
    instruction = visual.ImageStim(win, image='pic/exp_instruction.tif')
    exp_end = visual.ImageStim(win, image='pic/exp_end.tif')
    
    # 呈现指导语
    instruction.draw()
    win.flip()
    event.waitKeys()
    
    # 总共5个trial,每个trial的内容如下
    for trial in range(0, 5):
    
        # 注视点:500~1200ms
        fixation.draw()
        win.flip()
        clock.wait(dots_time[trial])
    
        # 箭头刺激:500ms
        arrow.text = arrow_orientation[trial]
        arrow.draw()
        win.flip()
    
        # 记录反应信息
        # 如果在500ms之内反应,则记录按键和反应时
        key_check = False
        t0 = core.getTime()
        while core.getTime() - t0 < 0.5:  # 在500ms内可以反应
            key = event.getKeys()
            if len(key) != 0:
                rt.append(core.getTime() - t0)
                resp.append(key[0])
                key_check = True
                if key[0] == 'escape':
                    win.close()
                else:
                    if key[0] == 'left' and arrow_orientation[trial] == '←':
                        acc.append(1)
                    elif key[0] == 'right' and arrow_orientation[trial] == '→':
                        acc.append(1)
                    else:
                        acc.append(0)
                break
        if not key_check:  # 未按键的情况下,反应信息记为"None"
            rt.append('None')
            resp.append('None')
            acc.append('None')
    
        # 空屏:300ms
        win.flip()
        clock.wait(0.3)
    
    # 呈现结束语
    exp_end.draw()
    win.flip()
    clock.wait(1)
    
    # 关闭窗口
    win.close()
    
    # 储存数据
    date = (time.strftime("%Y_%b_%d_%H%M%S"))  # 获取时间
    c = open('data/exp_example_psychopy_{}_{}.csv'.format(sub_info['subNum'], date),
             'w', encoding='utf-8', newline='')  # 创建csv表格
    csv_writer = csv.writer(c)  # 基于文件对象构建csv写入对象
    csv_writer.writerow(['SubjectNumber', 'Gender', 'Age', 'Handedness',
                         'DotsTime', 'Arrow', 'RT', 'Resp', 'ACC'])  # 表头
    for trial in range(0, 5):  # 写入csv
        csv_writer.writerow([sub_info['subNum'], sub_info['gender'], sub_info['age'], sub_info['handedness'],
                             dots_time[trial], ord(arrow_orientation[trial]),
                             rt[trial], resp[trial], acc[trial]])
    c.close()  # 关闭csv表格
    
    print('Succeed!')
    

    4 扩展

    4.1 扩展1:文本形式的指导语

    有时候,我们并不需要很复杂的指导语,或者只是希望呈现一两句话(例如告知被试休息一会再继续实验),那么可以通过以下代码来文本,这样就不需要另外准备文字图片了。其中,\n是Python中的换行符。

    Pygame:

    #呈现指导语
    win.fill((0,0,0))
    instruction=font.render('这是指导语!\n这是指导语的第二行!',True,(255,255,255))#创建文本
    arrow_x,arrow_y=instruction.get_size()#获取文字的高度和宽度
    win.blit(instruction,(int(x_center-arrow_x/2),int(y_center-arrow_y/2)))
    pygame.display.update()
    wait=True
    while wait:#等待按键
        for eventinpygame.event.get():
            if event.type == pygame.KEYDOWN:
    wait=False
    

    至于PsychoPy,其实就是用visual.TextStim()函数,上文已经提到过了。

    4.2 扩展2 Python中的代码复用

    如果程序比较复杂,可以通过代码复用来节省代码量,增加脚本的可读性。

    以spatial cueing task为例,这是注意研究中经常用到的一个实验范式。

    cueing task (Asplund, Todd, Snyder, & Marois, 2010)

    在该任务中,每个trial有四个“页面”,每个页面都需要画一个注视点和两个方框。如果没有代码复用的话,编写出来的脚本会显得非常冗余。

    解决的办法是,我们可以自己定义一个函数,这个函数的作用就是画两个方框和一个注视点。绘制每个页面时,只需要调用刚刚自定义的函数即可。

    例如,自定义一个叫draw_circle_and_rect()的函数(以PsychoPy为例):

    def draw_circle_and_rect(fixation_color):
        """
        usage: 画一个注视点以及两个矩形方框
        :param fixation_color: 设置注视点的颜色
        :return: none
        """
        fixation = visual.TextStim(win, font='SimHei', color=fixation_color, height=50)
        rect_l = 绘制左边矩形的代码  # 可以使用visual.Rect()函数,具体代码就不写了
        rect_r = 绘制右边矩形的代码
    
        fiaxtion.draw()
        rect_l.draw()
        rect_r.draw()
    

    这部分代码放在脚本开头,import部分的后面。

    然后,当我们想要绘制每个trial的内容时,就可以直接调用这个函数(以第一、第二个页面为例):

      # cue:200ms
      draw_circle_and_rect(fixation_color=其他颜色)  # 根据需要填写颜色代码
      win.flip()
      clock.wait(0.2)
      
      # variable delay:2000-8000ms
      draw_circle_and_rect(fixation_color='#FFFFFF')
      win.flip()
      clock.wait(random.random.uniform(2, 8))
    
      # 后面的以此类推
    

    4.3 扩展3 block & trial

    本文的例子很简单,只有一个变量(箭头朝向),5个trial。

    但如果我们的实验有多个变量,应该怎么安排呢?

    例如,一个两因素的研究,有四个水平,实验流程共计4个block,每个block有30个trial。

    这时候,我们可以把每个block的刺激安排好,放在一个list里,然后:

    # 总共4个block,每个block的内容如下
    for block in range(0, 4):
        # 每个block有30个trial,每个trial的内容如下
        for trial in range(0, 30):
    

    这样脚本的可读性会更好一些。

    4.4 扩展4 古典概率和统计概率

    抛一次硬币,正面朝上和反面朝上的概率是相等的,这就是古典概率

    但实际上,抛若干次硬币,正面朝上和反面朝上的频次并不一定是50:50,有可能抛十次硬币,都是正面朝上,这就是统计概率

    一般而言,大部分实验设计在提到“不同刺激出现的概率”这样的描述时,说的都是古典概率。

    例如,在4.2部分提到的Asplund等人 (2010) 的研究中,注视点的颜色有效预测了80%trial的目标刺激的位置。这里提到的80%就是古典概率。

    在本文的例子中,左箭头和右箭头出现的比例为50:50,这里的50%其实是统计概率。

    如果想实现古典概率的话,首先保证trial的次数是刺激类型的倍数。例如我们有两种刺激(左箭头和右箭头),所以至少有6个trial。

    实现的方法有很多,这里提供其中一种思路。

    import random
    
    arrow_orientation = []
    
    # 将两种类型的箭头刺激添加至list中
    for i in range(0, 3):
        arrow_orientation.append('←')
        arrow_orientation.append('→')
    
    # 打乱list中元素的顺序
    random.shuffle(arrow_orientation)
    

    打乱之前:

    ['←', '→', '←', '→', '←', '→']
    

    打乱之后:

    ['←', '→', '→', '←', '→', '←']
    

    4.5 扩展5 完全随机和伪随机

    以本文的实验为例,完全随机意味着,每次运行,注释点呈现时间和箭头的朝向都是不同的,例如,这次运行,5个trial的箭头朝向分别是“左右左左右”,再运行一次,又变成“左左右右左”了。而伪随机意味着,虽然刺激依然是随机生成的,但每次运行,都会是一个固定的随机序列,例如每次运行的箭头朝向都会是“左右左左右”。

    本文的例子就是完全随机,如果想实现伪随机的话,也很简单,我们可以另外新建一个脚本,在这个脚本里随机生成所需的刺激序列,保存至一个csv表格里,然后每次运行都调用这个表格中的刺激序列。

    4.6 扩展6 计算反应信息的均值

    与E-prime等开发工具相比,通过编程语言来编写实验程序时,我们可以进行更多灵活的操作。

    例如,我们不再需要在Excel中整理出每一个被试的反应时、正确率均值了,这些事情完全可以让Python帮我们完成。

    例如,我们可以在实验程序脚本的结尾添加几行代码,计算出本次运行的反应时、正确率的均值(这里使用了“Numpy”模组,如果你需要在Python中进行数据处理,那么往后会经常用到它):

    import numpy as np
    
    rt = np.array(rt)
    acc = np.array(acc)
    
    rt_mean = np.mean(rt)
    acc_mean = np.mean(acc)
    

    在这里,我们先将rtacc这两个列表,转换为“Numpy”中的数组(array),然后调用mean()函数,其作用是计算出一个数组中所有元素的算术平均值,然后我们将计算得到的结果,赋值给rt_meanacc_mean两个变量。

    最后,我们还可以将把算好的数值,连同被试的基本信息,以及实验分组的信息等等,通过writerow()写入一个单独的csv表格,例如命名为“exp_data.csv”。

    于是,每做完一个被试,“exp_data.csv”就会新添一行,这样可以节省不少整理数据的时间。

    4.7 扩展7 关于计时误差

    以PsychopPy为例,在呈现空屏的代码前,添加如下代码,以记录箭头刺激的呈现时间。

    print(timer.getTime() - t0)
    

    运行一次程序,过程中不进行按键反应,结果如下:

    0.500005500000043
    0.5000073000001066
    0.5000078000002759
    0.5000015000000531
    0.5000036000001273
    

    在MATLAB版本的该程序中添加类似的代码,结果如下:

    0.5004
    0.5000
    0.5002
    0.5003
    0.5002
    

    可以发现,实际上刺激呈现的时间并不是500.00000000ms,而是一个近似值,但即便如此,PsychoPy确实提供了毫秒级的时间精度。

    此外,MATLAB提供了以帧为单位的刺激呈现方式,这种方式同样具备很好的时间精度。

    5 结语

    本文的目的是用尽量简单的方法在Python中编写一个心理学实验程序。不过鉴于作者水平有限,某些部分可能会有更便捷的方法。以及,文中难免会有一些错漏,请大家多多指正。

    经过这次尝试,我的建议是,如果想在Python中编写心理学实验程序,请优先使用PsychoPy。因为就编写心理学实验而言,与Pygame相比,PsychoPy的语法更加简洁,可读性更好,时间精度也更高。

     

    Reference

    [1] Pygame Documentation
    [2] PsychoPy Reference Manual (API)
    [3] Asplund, C. L., Todd, J. J., Snyder, A. P., & Marois, R. (2010). A central role for the lateral prefrontal cortex in goal-directed and stimulus-driven attention. Nature Neuroscience, 13(4), 507–512. doi:10.1038/nn.2509

     
    ----------2020.10.25更新----------
    添加了一个介绍PsychoPy坐标单位的表格

    ----------2021.01.13更新----------
    修正了代码中的一处错误(按键反应后未使用break退出循环)

    相关文章

      网友评论

        本文标题:【Python】从零开始运用Pygame/PsychoPy编写一

        本文链接:https://www.haomeiwen.com/subject/apvipktx.html