简介:PsychoPy 是一套面向心理学与神经科学实验的开源工具包,适合心理学、认知科学、神经科学方向的学生、研究者及实验编程初学者使用。它把 OpenGL 的图形渲染能力与简洁的 Python 语法结合起来,可用于创建视觉与听觉刺激、控制实验流程、呈现问卷与收集反应数据,并被视为 Matlab 的免费替代方案之一,能帮助读者搭建从实验设计到运行、记录结果的完整流程。压缩包约 17.5MB,文件总数与主要文件类型明细上游暂无数据,下载后需结合包内实际内容进一步确认目录与示例结构。页面显示已有 506 人学习或下载,说明该资源在实验编程入门与工具选型场景中有一定参考价值。对于需要免费开展刺激呈现、行为实验和基础神经科学实验的用户,这份资源可作为了解 PsychoPy 功能组成与使用方式的入口。
1. PsychoPy 开源压缩包到底装了什么:从实验流程到离线复现
实验跑完才发现反应时字段少了一列,这种返工比搭实验更折磨人。PsychoPy 这个面向心理学和神经科学实验的开源压缩包,解决的就是从刺激呈现、按键采集到数据落盘的整条链路:它既能用 Builder 拖出实验流程,也能用 Coder 写 Python 脚本,还能把触发码、时间戳和条件表一起记进日志。
拿到这份资源的人,通常不是想读一份软件说明书,而是想把一个实验在实验室机器上跑起来、跑准,并且换一台机器还能复现。适合刚接触实验编程的研究生,也适合已经用过其他刺激呈现工具、但想换成开源方案的老手。下面按“环境怎么起、流程怎么搭、硬件怎么对、哪里容易翻车”的顺序拆。
2. 从压缩包到可运行环境:解释器、依赖和两条入口
2.1 先判断压缩包是源码、安装产物还是实验工程
拿到压缩包别急着双击里面的可执行文件或脚本。第一步是列目录,确认它到底是 PsychoPy 源码目录、打包好的安装产物,还是一组.psyexp实验工程。三种东西的处理方式不一样:源码目录要看pyproject.toml、setup.py、requirements.txt;安装产物要找入口;实验工程则要先把运行环境装好再打开。先做只读检查,别在原目录里直接改文件。
# 只读列出压缩包内容,先不覆盖原包 python - <<'PY' import zipfile p = "psychopy, 用于运行心理学和神经系统实验.zip" with zipfile.ZipFile(p) as z: for name in z.namelist()[:80]: print(name) PY这段代码用 Python 标准库读取压缩包清单,namelist()不会解压,[:80]只是防止目录太长刷屏。如果清单里出现大量.psyexp、.csv和图片,那更接近实验工程;如果出现setup.py、pyproject.toml和包目录,那更接近源码。这个判断决定后面是走pip install -e .还是直接打开 Builder 工程。
注意:解压路径不要带中文空格混排,尤其不要在同步盘目录里直接跑实验。路径里的特殊字符会把日志文件名、资源加载和后续批处理一起带偏。
2.2 用虚拟环境把 PsychoPy 依赖隔离出来
PsychoPy 依赖科学计算、图像、音频和硬件接口,直接装进系统解释器,后面很容易和别的实验脚本打架。我一般会先建一个独立虚拟环境,再让所有安装都走python -m pip,避免出现“命令行里的 python 和编辑器里的 python 不是同一个”这种翻车。下面命令不绑定具体版本号,按你机器上的解释器来。
# 在解压目录之外建环境,避免把 .venv 打进资源包 python -m venv .venv # 类 Unix 终端激活 . .venv/bin/activate # 桌面命令行激活(按你的终端类型选一条) .venv\Scripts\activate # 先升级安装器,再按包内声明安装 python -m pip install --upgrade pip python -m pip install -r requirements.txt这段命令的关键是“先激活、再安装、始终用python -m pip”。venv把依赖锁在目录里;requirements.txt只在包内存在时使用;如果包内是源码目录,把最后一行换成python -m pip install -e .,-e表示可编辑安装,方便你改源码后立即生效。安装完不要只看“Successfully installed”,还要验证导入路径,确认导入的是当前环境里的 PsychoPy,而不是系统里残留的旧包。
python - <<'PY' import sys import psychopy print("解释器:", sys.executable) print("PsychoPy 路径:", psychopy.__file__) PYsys.executable告诉你当前解释器位置,psychopy.__file__告诉你实际加载的包路径。两者不在同一个虚拟环境目录下,就说明环境没激活或 PATH 被别的东西抢了。这个检查比报错后再猜省时间。
2.3 第一次启动:Builder 和 Coder 各验证一次
PsychoPy 有两条入口:Builder 适合拖拽式搭流程,Coder 适合写脚本和做精细控制。第一次启动不要直接跑正式实验,先各做一个最小验证。Builder 里新建一个空白工程,放一个文本组件和一个键盘组件,跑三五个 trial,确认窗口能开、按键能记、数据文件能生成。Coder 里跑一个只开窗口、画十字、按键退出的脚本,确认图形后端和键盘事件都正常。
from psychopy import visual, core, event # 最小窗口验证:不采集数据,只确认图形和键盘入口 win = visual.Window( size=(800, 600), fullscr=False, units="pix", color=(0, 0, 0), allowGUI=True ) fix = visual.TextStim(win, text="+", color="white", height=60) fix.draw() win.flip() keys = event.waitKeys(maxWait=3.0, keyList=["space", "escape"]) print("按键:", keys) win.close() core.quit()size是窗口像素尺寸,fullscr=False方便第一次排查,units="pix"让坐标按像素走,allowGUI=True留出关闭入口。event.waitKeys的maxWait是超时秒数,keyList限定只接收空格和退出键。这个脚本能跑通,说明图形窗口、垂直刷新和键盘事件这条最小链路没有大问题;跑不通就先别碰硬件触发和复杂流程。
提示:正式实验前把
fullscr改成True、allowGUI改成False,再单独跑一次。窗口模式和全屏模式的行为差异,是后面很多“时间戳看起来正常但画面不对”的来源。
3. 实验流程怎么搭:从刺激呈现到反应采集的最小闭环
3.1 Builder 里把 trial 拆成时间轴:onset、duration、response
Builder 的核心不是“拖组件”,而是把每个 trial 拆成时间轴。常见做法是一个 Routine 里放注视点、刺激文本和键盘组件,用组件的Start、Stop控制出现和消失,用 Loop 把条件表逐行喂进来。Start设为 0.0 表示 routine 开始就出现,Stop设为 0.5 表示呈现 0.5 秒;键盘组件的Allowed keys限定反应键,Force end of Routine决定按键后是否提前结束。下面这张表是我一般会先写清楚的参数,不然组件一多就乱。
| 组件/参数 | 常用设置 | 作用 | 容易错的地方 |
|---|---|---|---|
TextStart | 0.0 | 刺激从 routine 起点出现 | 和注视点抢同一帧 |
TextStop | 0.5 | 控制呈现时长 | 把Stop当成反应窗口 |
KeyboardAllowed keys | f、j、escape | 限定有效按键 | 没限定时任何键都记 |
KeyboardForce end | 按实验设计 | 按键后是否提前结束 | 反应窗口和刺激时长混淆 |
LoopConditions | 条件表 csv | 逐行给刺激参数 | 列名和组件字段不一致 |
DataOutput | 每 trial 一行 | 记录条件和反应 | 字段名重复或缺失 |
这张表不是让你照抄,而是让每个参数都有明确职责。刺激时长和反应窗口是两件事:刺激 0.5 秒消失,不代表被试不能在之后 1 秒内按键。Builder 里可以用一个固定时长的刺激加一个独立的键盘组件,也可以用 Code 组件在中途打时间戳。新手最容易把Stop当成“反应截止”,结果数据里只有刺激消失前的按键。
条件表建议单独存成 CSV,第一行写列名,例如condition, target, correct_key。Loop 组件读取后,每一行就是一个 trial。字段名不要用空格和中文标点,后面导出宽表时省事。Builder 生成的.psyexp是工程文件,不是数据文件;正式跑之前先在 Builder 里点一次预览,确认条件表行数、刺激顺序和退出键都符合预期。
3.2 Coder 版最小实验脚本:窗口、文本、键盘、计时
需要精确控制时间戳时,我会直接写 Coder 脚本。下面是一个最小闭环:黑底白字、中心注视点、目标刺激、按键反应、反应时和正确率落盘。代码不依赖具体硬件,先保证逻辑跑通,再接触发。
from psychopy import visual, core, event, data # 1. 条件表:实际实验可从 csv 读入 conditions = [ {"condition": "A", "target": "左", "correct_key": "f"}, {"condition": "B", "target": "右", "correct_key": "j"}, ] # 2. 窗口和刺激:正式实验改 fullscr=True win = visual.Window( size=(1024, 768), fullscr=False, units="pix", color=(0, 0, 0), allowGUI=True ) fix = visual.TextStim(win, text="+", color="white", height=40) target = visual.TextStim(win, text="", color="white", height=64) # 3. 数据处理器:每个 trial 一行 exp = data.ExperimentHandler( name="demo", dataFileName="demo_data" ) clock = core.Clock() for i, cond in enumerate(conditions): # 注视点 0.5 秒 fix.draw() win.flip() core.wait(0.5) # 目标呈现,时钟在 flip 时重置 target.text = cond["target"] target.draw() win.callOnFlip(clock.reset) win.flip() # 反应窗口 1.5 秒,escape 随时退出 keys = event.waitKeys( maxWait=1.5, keyList=["f", "j", "escape"], timeStamped=clock ) if keys: key, rt = keys[0] else: key, rt = None, None if key == "escape": win.close() core.quit() # 正确率按条件表里的 correct_key 判断 acc = int(key == cond["correct_key"]) if key else 0 exp.addData("trial_index", i) exp.addData("condition", cond["condition"]) exp.addData("target", cond["target"]) exp.addData("response_key", key) exp.addData("rt", rt) exp.addData("accuracy", acc) exp.nextEntry() exp.saveAsWideText("demo_data.csv", delim=",") win.close() core.quit()这段代码的逻辑是“注视点 → 目标 → 反应窗口 → 写一行数据”。win.callOnFlip(clock.reset)让时钟在目标真正显示的那一帧复位,比win.flip()之后再clock.reset()更接近刺激起点;timeStamped=clock让按键时间戳直接相对这个时钟;maxWait=1.5是反应窗口,不是刺激时长;accuracy用条件表里的correct_key计算,避免把键位写死在循环里。data.ExperimentHandler负责把多行数据攒起来,最后导成宽表。
注意:
event.waitKeys是阻塞式等待,适合简单实验;要做连续采样、边呈现边监测,改用Keyboard对象或事件循环,不要在一个 trial 里混用两套键盘接口。
3.3 数据落盘:条件表、反应时与正确率的字段设计
数据字段最好在实验开始前定死,不要跑完再对着列名猜。下面这些字段是我做最小闭环时至少会保留的:trial_index用来对齐条件表行号,condition和target是实验条件,response_key和rt是行为反应,accuracy是离线算好的正确率,stim_onset和frame_dropped用来事后判断时间质量。字段多不怕,怕的是缺了关键列。
| 字段 | 类型 | 说明 | 用途 |
|---|---|---|---|
| trial_index | 整数 | 当前 trial 序号 | 和条件表对齐 |
| condition | 字符串 | 实验条件 | 分组统计 |
| target | 字符串 | 刺激内容 | 复核呈现 |
| response_key | 字符串/空 | 被试按键 | 反应分析 |
| rt | 浮点/空 | 相对刺激起点 | 反应时分析 |
| accuracy | 0/1 | 正确率 | 行为指标 |
| stim_onset | 浮点 | 刺激呈现时间 | 硬件对齐 |
| frame_dropped | 整数 | 掉帧计数 | 质量筛查 |
CSV 编码建议统一用utf-8-sig,这样表格软件打开中文条件名不容易乱码。写文件时用newline="",避免某些环境多出空行。条件表、日志和导出的宽表分开放,不要覆盖原始文件;原始文件一旦被覆盖,后面排查时间戳就没有后悔药。
4. 和 EEG/眼动/fMRI 对接:触发码、时间戳与时钟对齐
4.1 并口、串口和 USB 采集卡:先选接口再谈精度
硬件触发不是“接上线就行”,先要确认设备接受哪种接口。并口 TTL 常见于老式放大器和触发盒,优点是接线简单,缺点是地址和脉宽要试;串口触发通过字节码发送,接线少,但接收端要能解析;USB 采集卡适合没有并口的新机器,但驱动和延迟要单独测。下面这张对比表是我选型时先填的,不是绝对结论,具体以设备说明书为准。
| 方式 | 常见用途 | 需要确认 | 风险点 |
|---|---|---|---|
| 并口 TTL | 放大器、触发盒 | 地址、数据位、脉宽 | 地址冲突、脉宽太窄 |
| 串口字节 | 采集模块、自定义盒 | 端口、波特率、协议 | 字节被拆分或粘包 |
| USB 采集卡 | 新机器、无并口 | 驱动、通道映射 | 驱动延迟不透明 |
| 音频/光敏 | 外部记录对齐 | 声道、光敏位置 | 声卡缓冲引入偏移 |
我的习惯是先用一个独立脚本发固定码,不跑实验,拿示波器或采集端日志确认“发一次只来一次”。确认后再嵌进实验流程,否则触发码丢一次,后面整段 EEG 分段都对不上。
import serial from psychopy import core # 串口触发示例:端口和波特率按设备说明书替换 PORT = "REPLACE_WITH_DEVICE" BAUD = 115200 ser = serial.Serial( port=PORT, baudrate=BAUD, timeout=0.05, write_timeout=0.05 ) def send_code(code: int): # 只发一字节,具体协议按接收端要求改 ser.write(bytes([code & 0xFF])) ser.flush() core.wait(0.001) # 留出脉宽,按设备要求调整 send_code(1) send_code(2) ser.close()serial.Serial的port必须换成实际设备名,baudrate要和接收端一致,timeout和write_timeout防止写阻塞把实验卡死。bytes([code & 0xFF])把触发码裁成一字节,ser.flush()确保数据真正推出去,core.wait(0.001)是脉宽占位,不是所有设备都需要。正式实验里还要在发送前后各记一个时间戳,才能知道触发码和刺激帧之间差了多少。
并口方案可以用 PsychoPy 自带的并口接口,但地址不要照抄示例,必须在设备管理器或厂商工具里确认。发送后把数据清回 0,避免下一个 trial 还挂着上一个码。脉宽太窄接收端可能认不到,太宽又可能跨到下一个刺激,通常要在一毫秒到几毫秒之间试。
4.2 眼动与 EEG 的时钟对齐:把 trigger 和 log 写进同一行
硬件同步最怕两套时钟各记各的。我的做法是让实验日志里同时出现stim_onset、trigger_sent、response_time三个时间点,事后用触发码把行为日志和设备日志对起来。stim_onset来自win.callOnFlip的回调,trigger_sent是串口写出的时刻,response_time是按键时间戳。三者落在同一行,分段时不用靠猜。
from psychopy import core clock = core.Clock() log_rows = [] def mark_event(name, code=None): # 记录事件名、当前时钟和触发码 row = {"event": name, "t": clock.getTime(), "code": code} log_rows.append(row) return row # 在刺激 flip 时记录 onset win.callOnFlip(mark_event, "stim_on", 10) win.flip() # 在发送触发码前后各记一次 mark_event("trigger_before", 10) send_code(10) mark_event("trigger_after", 10)mark_event把事件名、时钟读数和触发码塞进同一行;win.callOnFlip让 onset 记录发生在真正刷新的那一帧;trigger_before和trigger_after用来估算发送开销。后面导出 CSV 时,把log_rows写成独立文件,不要只留在内存里。眼动和 EEG 的采样率再高,没有同一行里的 trigger 码,也只能做粗对齐。
提示:触发码不要用 0 作为有效刺激码。很多设备把 0 当作空闲电平,0 和“没发”在日志里很难区分。
4.3 fMRI 场景的等待扫描与按键盒
fMRI 场景多了一层等待扫描触发。常见做法是实验先停在“等待扫描”界面,收到扫描触发键后开始第一个 trial,同时记录扫描触发时刻。dummy scan 的数量、TR 和按键盒映射要以扫描设备为准,不要在脚本里写死。下面这段只表达等待逻辑,触发键和超时时间按现场改。
from psychopy import visual, event, core win = visual.Window(fullscr=True, units="pix", color=(-0.5, -0.5, -0.5)) wait_text = visual.TextStim(win, text="等待扫描触发", color="white", height=32) wait_text.draw() win.flip() # 扫描触发键按现场按键盒映射替换 scan_key = event.waitKeys(keyList=["5", "s"], maxWait=120) if scan_key: scan_onset = core.getTime() print("扫描触发:", scan_key[0], "时间:", scan_onset) else: print("等待超时,检查按键盒或触发线")keyList里的键名必须和按键盒实际输出一致,maxWait是等待上限,超时后要给出明确提示,不能静默进入实验。core.getTime()记录的是当前系统时钟,真正对齐还要和扫描设备的日志合并。fMRI 里按键盒的键位映射、扫描触发极性和 dummy scan 数量,是三个最容易在现场翻车的点,提前和扫描操作员对一遍,比跑完再补记录强。
5. 常见问题排查:刷新率、中文刺激、计时漂移和依赖混装
5.1 现象:刺激在屏幕上“跳”了一下,日志里的反应时却正常
现象是目标刺激出现时画面有一个可见的跳动,或者注视点消失和目标出现之间多了一帧黑屏,但日志里的rt看起来完全正常。原因通常不在按键,而在窗口刷新链路:全屏模式没开、垂直同步被系统合成器干扰、窗口尺寸和显示器分辨率不匹配,或者刺激在flip()之后才改属性。日志记的是时钟,不是像素,所以时间戳正常不代表画面正确。
解决分三步。先把窗口参数收紧,正式实验用全屏、关掉界面控件、打开刷新检查;再把刺激属性在flip()之前全部设置好;最后单独跑一段空转,记录实际刷新率和掉帧数。下面这段诊断代码不采集被试数据,只用来观察窗口。
from psychopy import visual, core win = visual.Window( fullscr=True, allowGUI=False, units="pix", color=(0, 0, 0), waitBlanking=True, checkTiming=True ) # 连续 flip,观察刷新和掉帧 for i in range(300): win.flip() if i % 60 == 0: print("frame", i, "fps", win.fps(), "dropped", win.nDroppedFrames) win.close() core.quit()waitBlanking=True让flip()等待垂直刷新,checkTiming=True打开刷新检查,win.fps()和win.nDroppedFrames用来观察实际表现。不同图形后端对这些属性的支持不完全一样,但先跑这段能快速判断是不是窗口层的问题。如果空转都掉帧,先换机器或关掉后台占用图形资源的程序,不要急着改实验逻辑。
5.2 现象:中文刺激显示成方块,或者按键被输入法吃掉
现象是中文目标刺激显示为方块、问号,或者被试按f、j时日志里没有记录。原因通常是字体没有中文字形、文本编码不统一,或者实验机上的输入法截获了按键。PsychoPy 的文本刺激依赖字体文件,字体缺字形时不会报错,只会显示替代符号;输入法在前台运行时,键盘事件可能先被输入法处理。
解决方法是给文本刺激指定一个包含中文字形的字体,实验前把文本内容统一成 UTF-8,正式跑的时候让实验机处于纯英文键盘布局,并在脚本里显式限定keyList。导出 CSV 时用utf-8-sig,避免表格软件打开乱码。
from psychopy import visual, core, event win = visual.Window(size=(800, 600), units="pix", color=(0, 0, 0)) text = visual.TextStim( win, text="中文刺激", color="white", height=48, font="REPLACE_WITH_CJK_FONT" # 换成实验机已安装的中文字体 ) text.draw() win.flip() keys = event.waitKeys(maxWait=2.0, keyList=["f", "j", "escape"]) print("按键:", keys) win.close() core.quit()font不要留空,也不要写一个实验机没装的字体名;height按屏幕和视距调,中文比英文占宽,同样的高度可能显得更挤。keyList限定有效键,能减少输入法和其他按键的干扰。正式实验前用目标机器跑一次中文显示测试,不要只在开发机上验证。
注意:条件表 CSV 如果被表格软件另存过,编码可能变。每次改完条件表都重新用脚本读一遍,确认中文列名和刺激内容没有变成乱码。
5.3 现象:实验跑二十分钟后越来越慢,时间戳开始漂移
现象是前几个 trial 正常,跑久了画面变卡,反应时分布整体往后拖。原因常见于三类:每帧都在写日志或刷磁盘、图片和音频没有预加载、循环里反复创建窗口或文本对象。Python 的垃圾回收和图形资源释放也会叠加影响。core.wait()不是硬实时,长时间循环里用它做精确节拍会积累误差。
解决思路是“预加载、少 I/O、用单调时钟”。刺激对象在循环外创建,图片用visual.ImageStim预加载,日志先在内存里攒着,trial 结束或实验结束再落盘;计时统一用core.Clock或core.getTime(),不要用系统日期时间做差值。下面这段只演示计时和批量写盘的结构。
from psychopy import core import csv clock = core.Clock() rows = [] for i in range(100): clock.reset() core.wait(0.05) rows.append({"trial": i, "rt": clock.getTime()}) # 实验结束后一次性写盘,减少循环内 I/O with open("timing_log.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=["trial", "rt"]) writer.writeheader() writer.writerows(rows)clock.reset()把每个 trial 的计时起点归零,clock.getTime()读相对时间;rows在内存里攒数据,循环结束再写 CSV,避免每帧刷盘。真实实验里还要记录win.nDroppedFrames和每个刺激的 onset,不然只能看到“变慢”,不知道慢在哪一帧。
5.4 现象:安装完导入报错,或者两个 Python 混着用
现象是命令行里import psychopy成功,编辑器里却报ModuleNotFoundError;或者反过来,编辑器能导入,运行实验时又找不到依赖。原因通常是虚拟环境没激活、IDE 解释器指向系统 Python、pip和python不是同一套,或者源码包和二进制包混装。依赖冲突也会表现为导入某个子模块时报错。
先用解释器路径和包路径定位,再查依赖一致性。不要一上来就重装整个环境,先把“谁在导入谁”弄清楚。
python - <<'PY' import sys print("当前解释器:", sys.executable) try: import psychopy print("PsychoPy 路径:", psychopy.__file__) except Exception as e: print("导入失败:", repr(e)) PY python -m pip checksys.executable和psychopy.__file__必须在同一个虚拟环境目录下;python -m pip check会列出依赖冲突。IDE 里要把解释器显式指向.venv里的 Python,不要靠系统 PATH 猜。源码安装和二进制安装不要叠在同一个环境里,实在乱了就新建环境重来,比在旧环境里打补丁省时间。
6. 进阶验证:把“能跑”变成“可复现”的校时与日志审计
正式实验前,我会加一段 60 秒空转校时:只开窗口、画一个方块、每帧翻转,同时记录frame_time、nDroppedFrames和一个模拟触发码。这段数据不进入正式分析,只用来回答三个问题:刷新率是不是接近显示器标称值、掉帧是否集中在某几秒、触发码和画面翻转的时间差是否可重复。下面这段把 onset 和触发码写进同一行,方便事后审计。
from psychopy import visual, core win = visual.Window(fullscr=True, allowGUI=False, units="pix", color=(0, 0, 0), waitBlanking=True, checkTiming=True) box = visual.Rect(win, width=120, height=120, fillColor="white") clock = core.Clock() audit = [] for i in range(600): box.draw() win.callOnFlip(lambda i=i: audit.append({ "frame": i, "onset": clock.getTime(), "dropped": win.nDroppedFrames })) win.flip() win.close() core.quit() print(audit[:5])win.callOnFlip把记录动作挂到真正刷新那一帧,clock.getTime()给出相对时间,nDroppedFrames用来标记质量。跑完看前五条和最后五条,如果onset间隔明显不是刷新周期,或者dropped在某段突然增长,就先处理机器和图形后端,不要带着问题收正式数据。日志审计还要检查 CSV 的编码、列名、空值和重复行;我吃过一次条件表列名带空格、导出后字段对不上的亏,从那以后每次正式实验前都强制走一遍“空转校时 + 日志字段核对 + 触发回环”,希望帮到你。
本文还有配套的精品资源,点击获取