简介:PsychoPy 是一套面向心理学与神经科学实验的开源工具包,适合需要自行搭建刺激呈现、反应采集与实验流程的研究者、教师和学生使用。它把 OpenGL 的图形渲染能力与简洁的 Python 语法结合起来,可用于视知觉、注意、记忆、反应时等实验场景,并为习惯 Matlab 的用户提供免费替代方案。压缩包约 17.5MB,上游未提供有效文件总数与主要文件类型明细,下载后可按实际目录核对安装包、示例、文档等组成部分。目前已有 506 人学习,说明该工具在实验编程入门与教学实践中具有一定关注度。对读者而言,可借助它降低实验程序开发门槛,用脚本组织刺激呈现、按键响应和时间控制,并便于实验参数的调整、运行与复现;开源特性也方便按需修改和共享,适合作为心理学实验编程的起步资料,也可用于课程演示与重复实验的脚本整理。
1. 一个毫秒都不能糊弄:PsychoPy 在心理学实验里的真实位置
屏幕上一个方块亮 200 毫秒,被试按一下键,你要的是那张记录着「刺激第几帧出现、按键第几毫秒发生」的表。PsychoPy 就是干这件事的开源工具包:一套 Python 库加一个图形化 Builder,把刺激呈现、时钟同步、键盘/眼动/脑电采集和数据落盘串成一条链路。它解决的不是「怎么画图」,而是「怎么在一片操作系统抖动、显示器刷新、USB 轮询延迟里,把时间误差压到可控范围」。适合两类人:要跑行为实验、需要按帧控制刺激的心理学方向研究者,以及要做视觉/听觉范式、需要和 EEG、眼动仪对时间戳的神经科学方向工程师。不用写代码也能用 Builder 起步,但要真把数据做干净,最后绕不开 Coder 那一层。
2. 从装环境到第一个窗口:先把刷新率和坐标系钉死
多数人卡在第一步不是不会写代码,而是装完跑起来发现刺激是糊的、尺寸不对、时长飘。所以这一章的顺序是反着来的:先确认显示器在什么节奏上刷新,再确认窗口用什么单位,最后才写刺激本身。
2.1 三种安装方式,选错了后面全是依赖问题
PsychoPy 的安装路径实际有三条,各自适用的场景差别很大:
| 方式 | 命令/载体 | 适合 | 主要代价 |
|---|---|---|---|
| 独立发行版 | 官方打包的可执行程序 | 第一次用、要跑 Builder、要接并口 | 内置 Python 环境,装第三方包得用它自带的 pip |
| pip 装库 | pip install psychopy | 已经有 Python 工程、要和别的库混用 | 可能卡在 wxPython、OpenGL 依赖上 |
| conda | conda install -c conda-forge psychopy | 多版本环境隔离、Linux 服务器 | 包更新滞后于 pip |
我一般的做法是:实验机用独立发行版,稳定、自带并口驱动组件、出问题少一个变量;分析机用 conda 装一份,方便和 pandas、mne 混着用。纯 pip 在 Windows 上偶尔会因为没有预编译 wheel 而现场编译,很浪费时间。
# 确认 Python 版本与 pip 是否就位(PsychoPy 新版对 Python 版本有下限要求) python --version python -m pip install --upgrade pip # 纯库方式安装 python -m pip install psychopy # 装完立刻验证核心模块能导入,别等到写完整脚本才发现 python -c "from psychopy import visual, core, event, data; print('ok', core.getTime())"这段的作用是把「装没装上」和「装上了能不能用」分开验证。core.getTime()返回的是高精度单调时钟读数,能不能打印出来,意味着底层的运行时和依赖链至少打通了。装完之后如果导入报 OpenGL 相关错误,基本是显卡驱动或远程桌面会话的问题,先换成本机登录再试。
2.2 把显示器参数喂给 Window:刷新率不是标签上那个数
PsychoPy 的所有时间控制,最终都落在win.flip()上。你写「呈现 200 ms」,它实际做的是「连续翻转 N 帧,直到累计时长够 200 ms」。所以刷新率估计错了,一切都错。
Window 打开后第一件事是实测刷新率:
from psychopy import visual, core # fullscr=True 才是实验该用的状态;窗口模式会引入桌面合成器的额外延迟 win = visual.Window( size=(1920, 1080), fullscr=True, screen=0, units='pix', color='black', allowGUI=False, waitBlanking=True, # 关键:等待垂直同步,避免撕裂和丢帧 ) # 实测刷新率,nIdentical 表示连续多少帧间隔一致才算数 fps = win.getActualFrameRate(nIdentical=60, nMaxFrames=1000) print('实测刷新率:', fps) win.close() core.quit()nMaxFrames是采样上限,太小会估不稳,一般给 600 到 1000。waitBlanking=True是让每次 flip 都等垂直同步信号,这是时间精度的地基,关掉它省不了多少时间,只会让刺激呈现时刻变得不可预测。如果getActualFrameRate返回None,常见原因是没开独占全屏、被桌面合成器接管,或者显示器接了分配器/HMD 转接盒,这时候先排查硬件链路,不要急着改代码。
物理尺寸也要一起给。做视角单位的实验,PsychoPy 需要知道屏幕宽多少厘米、被试眼睛离屏幕多远。这些参数填在显示器配置里,而不是写死在脚本中:
from psychopy import monitors, visual # 屏幕物理宽度、观看距离都用厘米,分辨率用于换算像素 mon = monitors.Monitor('lab-screen', width=53.0, distance=60.0) mon.setSizePix((1920, 1080)) mon.saveMon() # 存进本地配置,后续 Window 按名字引用就行 win = visual.Window(size=(1920, 1080), fullscr=True, monitor='lab-screen', units='deg')width是显示区实际宽度,量的时候用卷尺量发光区域,不要用整机外框。distance是被试眼睛到屏幕的直线距离,用下巴托固定后再量。这两项差 2 厘米,视角尺寸就偏 3% 以上,对需要精确视角的范式(比如视觉搜索、 crowding)来说是系统性偏差,不是随机误差。
2.3 最小可运行实验:一个文字刺激加一次按键
在搭完整流程之前,先用二十行代码跑通「呈现—等待—记录—退出」这条链路,后面所有复杂结构都是它的放大版:
from psychopy import visual, core, event win = visual.Window(size=(1280, 800), fullscr=False, units='pix', color='black') # 刺激对象只创建一次,放进循环里反复 draw,不要每帧新建 fixation = visual.TextStim(win, text='+', color='white', height=40) cue = visual.TextStim(win, text='准备', color='white', height=32) fixation.draw() win.flip() core.wait(0.5) # 引导阶段可以用固定等待,不影响正式计时 cue.draw() win.flip() event.clearEvents() # 关键:清掉之前残留的按键,防止串到本试次 resp_clock = core.Clock() # 试次级时钟,每个试次重置 win.callOnFlip(resp_clock.reset) # 在 flip 生效那一刻把时钟归零 win.flip() keys = event.waitKeys(maxWait=2.0, keyList=['f', 'j'], timeStamped=resp_clock, clock=resp_clock) print('反应:', keys) win.close() core.quit()三处细节决定了这份代码能不能用于正式实验。event.clearEvents()放在试次开始前,是因为 PsychoPy 的事件缓冲是全局的,上一个试次多余的按键会污染下一个试次的反应时。win.callOnFlip()是它提供的一个很实用的机制:把一个回调函数挂到下一次 flip 真正生效的时刻执行,这样时钟零点对齐的是屏幕刷新的物理时刻,而不是你调用函数的那一行 CPU 时间。timeStamped=resp_clock让waitKeys返回的每个按键都带上这个时钟的读数,而不是返回裸的键名。
maxWait=2.0是超时上限,超时返回空列表,代表被试没反应,这个分支必须在后面处理,否则统计时会莫名其妙少试次。
3. 刺激、时钟与输入:把一次试次拆成三个可复用的部件
跑通最小例子之后,实验的复杂度基本来自三件事:刺激怎么摆、时间怎么算、反应怎么收。这三件事在 PsychoPy 里对应三套独立机制,混在一起写就会变成一坨难以维护的脚本。
3.1 units 的取舍:pix、norm、height、deg 各管一段
visual.Window(units=...)决定所有刺激的位置和尺寸怎么解释,选错会导致换台机器就跑歪。
| units | 含义 | 适用 | 注意 |
|---|---|---|---|
pix | 像素 | 和屏幕分辨率强绑定的任务 | 换分辨率就失效,跨机器不可移植 |
norm | 归一化,窗口宽或高为 2 | 快速布局、演示 | 宽高比不同会变形 |
height | 窗口高度为 1 | 相对布局 | 同样是相对量,不适合跨实验室复现 |
deg | 视角 | 视觉、注意、 crowding 类范式 | 依赖显示器宽度和观看距离参数 |
我的习惯是:正式实验一律用deg,因为它是唯一有物理意义的单位,写进论文的方法部分也能直接对应。代价是每台实验机都得提前配好显示器参数,否则视角会算错。pix只留给纯工程调试,比如你想精确知道某个刺激落在第几个像素上。
另外注意坐标原点默认在屏幕中心,pos=(0, 0)就是正中。要放到左下象限就写负数,别去算「分辨率除以二」那种写法,换分辨率就崩了。
3.2 用帧来计时,别用 core.wait 控刺激时长
core.wait(0.2)很诱人,但它只是让 CPU 睡 200 毫秒,醒来之后你还要画、还要 flip,实际呈现时长是你控制不了的一个数。正确做法是用帧循环或者倒计时器把呈现窗口锁住:
from psychopy import visual, core win = visual.Window(size=(1920, 1080), fullscr=True, units='deg', color='black') target = visual.GratingStim(win, tex='sin', mask='gauss', size=4.0, sf=2.0, ori=0, color='white') # 方案一:按帧数控制。60Hz 下 12 帧 = 200ms,帧数必须是整数 n_frames = 12 for frame in range(n_frames): target.draw() win.flip() # 方案二:按时间控制,用倒计时器,允许时长不是帧间隔的整数倍 timer = core.CountdownTimer(0.2) while timer.getTime() > 0: target.draw() win.flip() win.close() core.quit()方案一的好处是时长确定,坏处是只能取帧间隔的整数倍,60 Hz 下最小粒度 16.7 ms。方案二能凑出任意时长,但实际落地仍然会被向上取整到最近的帧边界。所以写方法部分的时候,报的是「200 ms,向上取整到 12 帧(201 ms)」,而不是拍脑袋写 200 ms,这个差别审稿人真的会问。
还有一个反直觉的点:win.flip()本身是阻塞的,它会等到垂直同步才返回,所以循环里不需要额外加 sleep。你在循环里塞一个core.wait(0.001)反而会打乱节奏,让某些帧被跳过。
3.3 反应时从哪一刻算起:callOnFlip 和三种时间戳的差别
反应时的定义看起来简单——按键时刻减去刺激出现时刻——但在代码里,这两个时刻各有两个候选值,选错了就是几毫秒到几十毫秒的系统偏差。
刺激出现时刻有两个候选:你调用win.flip()的那一行执行完的时间,和这一帧真正被显示器画出来的时间。前者通常早后者几毫秒到十几毫秒。按键时刻也有两个候选:操作系统收到 HID 事件的时间,和 PsychoPy 从事件队列里读出来的时间。这两个差距更大,取决于 USB 轮询率和系统负载。
PsychoPy 给出的解法是用同一个时钟基准,并且尽量把起点对齐到物理时刻:
from psychopy import visual, core, event win = visual.Window(size=(1280, 800), fullscr=True, units='deg', color='black') stim = visual.TextStim(win, text='F 或 J', height=1.0, color='white') trial_clock = core.Clock() # 先用 getFutureFlipTime 预估下一次 flip 的物理时刻,用于精细对齐 win.flip() event.clearEvents() stim.draw() win.callOnFlip(trial_clock.reset) # 时钟零点 = 这一帧真正上屏 actual_flip = win.flip() # 返回该帧的预估/实际时刻 resp = event.waitKeys(maxWait=2.0, keyList=['f', 'j'], timeStamped=trial_clock, clock=trial_clock) if resp: key, rt = resp[0] print(f'按键 {key},反应时 {rt * 1000:.1f} ms') else: key, rt = None, None print('超时未反应') win.close() core.quit()core.Clock()用的是系统高精度计时器,精度通常在微秒级,但它的绝对值没有意义,只有差值有意义。所以每个试次重置一次、只取差值,是更稳的做法。跨试次需要比较的话,用core.monotonicClock这种不会被系统时间调整影响的时钟。
event.waitKeys是阻塞式的,适合「等一个反应就结束」的结构。如果试次里还有别的刺激要继续呈现,就得用event.getKeys()在帧循环里轮询,配合clearEvents()管理缓冲。两种写法不要混用,混用必然出现「按键被吃掉了」或者「一次按键触发两个试次」这类问题。
4. Builder 与 Coder 之间:条件文件、随机化和数据落盘
Builder 的价值在于把「试次结构」可视化,但它生成的脚本是完全可读的 Python,理解这层生成关系,你就能在图形界面和代码之间自由切换,而不是被它绑死。
4.1 条件文件的列怎么设计,决定了后面分析顺不顺
Builder 的 loop 支持从 Excel 或 CSV 读条件表。表的第一行是列名,每行是一个试次的参数组合。列名会直接变成脚本里的变量名,所以命名要克制:全小写、下划线分隔、别用中文和空格。
| target_ori | target_pos | cue_valid | corrAns | soa |
|---|---|---|---|---|
| 0 | -6,0 | 1 | f | 0.1 |
| 45 | 6,0 | 0 | j | 0.2 |
| 90 | 0,-6 | 1 | f | 0.1 |
几个实操经验:corrAns单独一列存正确答案,不要靠别的列推导,推导逻辑写进代码会让条件表失去自解释性;位置这种复合值,要么拆成pos_x、pos_y两列,要么固定写成x,y字符串再在代码里 split,前者更省事;数值列不要混着写「0.1」和「100ms」,PsychoPy 读进来会当字符串处理,运行时报类型错误。
读条件文件本身很简单:
from psychopy import data # csv 最稳,不依赖 openpyxl;Excel 也能读,但要额外装 openpyxl 或 pandas conditions = data.importConditions('conditions.csv') print(len(conditions), '个条件', conditions[0])importConditions会把每行读成字典,数字列自动转 float 或 int,空单元格变成空字符串。空字符串是很多 bug 的来源——比如某个试次没填 soa,代码里算出float('')就炸了。所以条件表填完之后,我习惯先跑一遍这行打印,把第一个和最后一个条件都看一眼。
4.2 TrialHandler 把随机化、平衡和逐试次记录一次做完
手写for循环遍历条件表当然可以,但随机化、重复次数、试次编号、以及「每个试次的数据往哪存」这几件事会迅速把你的脚本撑大。TrialHandler就是把这四件事打包的容器:
from psychopy import data, visual, core, event conditions = data.importConditions('conditions.csv') # nReps 是每个条件重复几次;method='random' 表示每个重复块内随机 trials = data.TrialHandler( trialList=conditions, nReps=4, method='random', extraInfo={'participant': 's01', 'session': '1'}, ) win = visual.Window(size=(1280, 800), fullscr=True, units='deg', color='black') stim = visual.TextStim(win, text='', height=1.0, color='white') fix = visual.TextStim(win, text='+', height=1.0, color='white') for trial in trials: ori = trial['target_ori'] # 当前试次的参数,直接按列名取 corr = trial['corrAns'] fix.draw() win.flip() core.wait(0.5) stim.text = f'{ori}' stim.draw() trial_clock = core.Clock() win.callOnFlip(trial_clock.reset) win.flip() event.clearEvents() resp = event.waitKeys(maxWait=2.0, keyList=['f', 'j'], timeStamped=trial_clock, clock=trial_clock) if resp: key, rt = resp[0] acc = int(key == corr) else: key, rt, acc = None, None, 0 # addData 把值挂到当前试次行上,最后一次性写出 trials.addData('key', key) trials.addData('rt', rt) trials.addData('acc', acc) trials.saveAsWideText('s01_session1.csv', delim=',', appendFile=False) win.close() core.quit()method='random'和method='fullRandom'的区别值得记一下:前者在每个重复块内打乱,保证每种条件在每块里都出现一次;后者对整体打乱,有可能出现连续多个相同条件。做事件相关电位这类需要均匀分布的范式,用前者;做纯行为、条件多的范式,用后者更省事。
addData是关键设计。你不必自己维护一个列表再拼 CSV,直接把值挂到 handler 上,它会按试次顺序追加列。忘记调用saveAsWideText的话,所有数据都在内存里,窗口一关就没了——这是新手最常见的翻车方式之一。
4.3 输出 CSV 的每一列到底是什么意思
Builder 跑出来的数据文件列很多,混着几类信息,分析前必须分清:
| 列名 | 含义 | 用途 |
|---|---|---|
trials.thisRepN | 当前是第几个重复块 | 检查平衡是否成功 |
trials.thisTrialN | 块内第几个试次 | 排序、回溯 |
trials.thisIndex | 对应条件表里的原始行号 | 反查条件参数 |
session、date、expName | 会话与实验元信息 | 数据管理 |
frameRate | 运行时实测刷新率 | 事后核对时间精度 |
key_resp.keys、key_resp.rt、key_resp.corr | 某个反应组件的记录 | 主分析变量 |
frameRate这一列很多人不看,但它其实是时间精度的黑匣子。如果某个被试的这列值明显偏低或者波动很大,说明那次运行的机器状态有问题,反应时数据要打问号。我一般会在数据清洗阶段先按这列筛一遍,比事后争论「被试是不是走神了」有用得多。
还有一类字段要自己加上去:条件表里没有、但分析时需要的量,比如实际呈现的帧数、试次开始时的绝对时间戳。这些用trials.addData()补,不要事后靠索引去猜。
5. 避坑清单:这些细节能让一批数据白跑
这一章是我自己踩过、也见过别人踩的几条,按「现象—原因—解决」写,能对上号的直接改。
5.1 显示与计时类:三个最容易被忽略的偏差
现象:反应时整体比同类文献慢 20 到 40 毫秒,且被试间差异很小。原因通常不是被试,是刺激呈现和时钟零点没对齐——用了core.wait()控时长,或者时钟在 flip 之前就重置了。解决方式是把时钟重置挂到win.callOnFlip()上,呈现时长用帧循环或CountdownTimer控制,同时把实测frameRate写进数据,事后能核对。
现象:刺激边缘发虚,或者快速运动的刺激出现拖影。原因一般是没开垂直同步,或者窗口不是独占全屏,被桌面合成器插了一脚。解决方式是waitBlanking=True加fullscr=True,并且在实验机上关掉系统的动态壁纸、通知弹窗和自动亮度调节。这类干扰不会报错,只会悄悄提高帧间隔的方差,属于最阴的一类。
现象:换了一台实验机,同样的视角参数,刺激大小明显不一样。原因是units='deg'但显示器配置沿用了旧机器的分辨率或物理宽度。解决方式是在每台机器上重新量屏幕宽度和观看距离,mon.setSizePix()和monitors.Monitor()都更新,然后跑一次已知视角的刺激做目视核对。这一步花五分钟,比事后解释「为什么两个实验室结果不一致」省事得多。
5.2 输入与数据类:两个不报错但会毁数据的坑
现象:某些试次明明被试按了键,数据里却是空的。原因通常是事件缓冲没清干净,或者waitKeys的keyList写的是大写而实际键名是小写。PsychoPy 的键名默认小写,'F'匹配不上。解决方式是在每个试次开始前调用event.clearEvents(),keyList统一用小写,并且把超时分支显式记录下来而不是直接跳过。
现象:数据文件里少了几列,或者列顺序和分析脚本对不上。原因是addData只在部分试次里调用,导致某些行的列缺失;或者中途改了条件表列名,旧脚本仍按旧名字取。解决方式是把所有addData调用集中在试次末尾的固定位置,条件表列名一旦定稿就不再改,改的话同步改分析脚本并做一次小样本回归。
现象:脚本在开发机上跑得好好的,到实验机上第一屏就报错退出。原因多半是路径写成了相对路径,而实验机的工作目录不同;或者是编码问题,条件表里有中文列名或全角字符。解决方式是所有文件路径用os.path.join基于脚本所在目录拼,条件表强制 UTF-8 且列名只用 ASCII。这一条没有技术含量,但它是实验当天最容易翻车的地方。
6. 端到端延迟自检:别信代码,信一次光电二极管
代码层面的时间对齐做完了,还有个问题绕不过去:从「你调用 flip」到「屏幕上真的亮起来」,中间隔着显卡、显示器固件、面板响应时间,这一段是纯黑匣子。想知道真实延迟,只能用外部测量。我常用的做法是光电二极管贴在屏幕角落,接示波器或带时间戳的采集卡,同时让 PsychoPy 在刺激出现那一帧往并口发一个方波:
from psychopy import visual, core, parallel win = visual.Window(size=(1920, 1080), fullscr=True, units='deg', color='black') port = parallel.ParallelPort(address=0x0378) # Windows 下按实际端口地址填 port.setData(0) stim = visual.Circle(win, radius=1.0, fillColor='white', colorSpace='rgb') # 黑屏 500ms,给采集端一个明确的基线 win.flip() core.wait(0.5) # 刺激上屏的同一帧拉起并口电平,采集端记录这个上升沿 win.callOnFlip(port.setData, 1) stim.draw() win.flip() core.wait(0.05) # 保持 50ms,保证光电二极管能稳定采到 win.callOnFlip(port.setData, 0) win.flip() win.close() core.quit()采到的两条曲线一对比,就能读出三段延迟:并口电平上升沿到光电二极管信号上升沿的差,是「软件到屏幕」的总延迟;两次上升沿之间的宽度,是你实际得到的呈现时长;多次重复测出来的标准差,才是这次实验真正的时间抖动。我见过最夸张的一次,标称 60 Hz 的显示器实际帧间隔在 16.7 到 22 毫秒之间跳,原因是接了分配器,这种情况下无论代码写得多漂亮,数据都没法用。
这套自检建议在正式收数据前跑一次,换机器、换显示器、升级系统之后各跑一次,结果记在本子上。常见的几个量级参考:并口写电平到物理输出,几十微秒到几百微秒;flip 到屏幕像素真正变化,一帧到两帧;LCD 面板响应时间,几毫秒到十几毫秒,这部分只能接受、不能消除。
从那以后我每次上机前都会先用二十行自检脚本跑一遍光电二极管,把延迟和抖动记下来贴在机箱上,再开始招被试。希望帮到你。
本文还有配套的精品资源,点击获取