首页 / 资讯中心 / 文章详情

PsychoPy 实验控制:毫秒级刺激呈现、打标与复现避坑

PsychoPy 实验控制:毫秒级刺激呈现、打标与复现避坑 ★ FEATURED ARTICLE
简介PsychoPy 是面向心理学与神经科学实验的开源工具包将 OpenGL 图形渲染能力与简洁的 Python 语法结合可作为 Matlab 的免费替代方案适合心理学研究者、神经科学实验人员及需要自建行为实验的学生使用。压缩包约 17.5MB文件总数与类型明细上游暂未提供具体内容以实际下载包为准从资源定位看主要服务于刺激呈现、实验流程组织与反应采集等常见实验编程需求。它可用于搭建视听觉刺激、按键反应、计时控制等实验环节也便于理解 Python 实验脚本的基本结构。已有 506 人学习说明在实验编程入门与实验室工具选型中有一定参考价值。对于希望降低商业软件依赖、快速接触开源实验平台的读者可据此了解 PsychoPy 的用途、适用场景与后续学习方向。1. 解压之后先别点 exePsychoPy 替你做掉的是哪一层活如果你手上这个包写着「用于运行心理学和神经系统实验」第一反应多半是解压、找启动程序、双击然后期待它像播放器一样载入任务、按空格开始。这条路大概十分钟就会把人劝退因为 PsychoPyPython 包名psychopy不是一个现成的实验而是一套开源的刺激呈现与实验控制运行时它把在某一帧把某个图形画到屏幕的某个位置、同时记下被试在这一刻之后多久按了哪个键这件事做到了可重复。zip 里真正值钱的是两样东西Builder 那套拖拽式流程编辑器和 Coder 里那个能直接调visual、core、event的脚本环境。它解决的核心问题只有一个时序。问卷星、普通网页表单做不了毫秒级的呈现—反应配对而 PsychoPy 把屏幕刷新、刺激绘制、按键时间戳、外部设备打标串成了一条时间线。适合的人很明确——要做行为范式、脑电或眼动的刺激呈现、需要自己改流程又不想从 OpenGL 写起的研究者和工程同学不适合的人也很明确——只想发个量表、对几十毫秒误差无所谓的场景用它纯属给自己加活。2. 从解压到第一个注视点环境、显示器参数与两种写法的边界先把能跑起来这件事做掉但别急着写实验逻辑。这一步最容易埋雷的地方不是代码是环境和显示器配置——它们决定了你后面所有以度deg为单位的尺寸、所有以帧为单位的时长到底是不是你以为的那个值。2.1 独立发行包、pip 与 conda 三条路的取舍摆在面前的选择通常有三个用官方独立发行包Standalone、pip install psychopy、或者 conda 环境。Standalone 解压即用自带 Python 解释器和一堆编译好的依赖代价是它和系统 Python 完全隔离你要装个第三方库比如pylsl、pandas得进它自带的解释器里装很多人第一次在这里卡住——在系统终端pip install成功了脚本里import还是报错因为跑的压根不是同一个 Python。pip路线最灵活适合你已经有成熟的项目环境、需要跟数据处理脚本共用一套依赖。麻烦在于依赖链长wxPython、pygame、音视频相关轮子在某些平台上没有现成 wheel编译过程能消耗掉一整个下午。conda 介于两者之间科学计算生态的轮子覆盖好一些但 PsychoPy 更新节奏和 conda 频道不一定同步。我一般的做法是实验机器用 Standalone分析和开发机器用 conda。理由是实验机器要的是三年后换个学生来还能跑环境越封闭越好分析机器要的是跟 pandas、mne 这些库共存越开放越好。这两件事不该在同一台机器上妥协。不管走哪条路装完先跑一个最小验证脚本别一上来就打开 Builder 拖组件# smoke_test.py —— 只验证窗口、绘制、翻转、退出四件事 from psychopy import visual, core win visual.Window( size(800, 600), colorblack, # 背景色实验里通常用中性灰 (0,0,0) 或 0.5 灰 unitspix, # 先用像素避免依赖显示器配置 fullscrFalse, # 冒烟测试不要全屏方便看报错 allowGUIFalse ) msg visual.TextStim(win, textready, colorwhite, height40) msg.draw() # 只是把绘制指令写进缓冲屏幕上还看不到 win.flip() # 交换前后缓冲这一刻才真正上屏 core.wait(2.0) # 停 2 秒 win.close() core.quit() # 必须显式退出否则进程可能挂着这段代码的重点不在能显示一行字而在验证三件事窗口能不能创建显卡驱动和 OpenGL 正常、flip()有没有阻塞垂直同步生效、core.quit()能不能干净退出不会留下僵尸进程占着全屏。参数上unitspix是刻意选的——单位设成deg或cm时PsychoPy 会去读显示器配置配置不对你会得到一个尺寸离谱的刺激还以为是代码写错了。allowGUIFalse会去掉窗口边框上的关闭按钮正式实验建议开着冒烟测试阶段建议关掉不然报错时你连窗口都关不掉只能去任务管理器杀进程。2.2 显示器参数必须先配而且它不跟着文件走这是新手最容易忽略、老手最容易翻车的一点。PsychoPy 的Monitor Center里存的是屏幕分辨率、物理宽高、观察距离、刷新率。你写unitsdeg、size2.0的刺激最终落到屏幕上是多少像素全靠这套参数换算。默认配置是个通用假设跟你的实验室显示器大概率对不上。更麻烦的是这套配置存在用户目录下Windows 在 AppDataLinux/macOS 在~/.psychopy3不随.psyexp文件走。于是出现经典的场景你在自己电脑上调好的视夹角拷到实验室机器上同一个size2.0的刺激看起来大了一圈。你以为是显示器尺寸不同其实是你根本没在实验机上配过 Monitor。配置的时候有四个值必须填实分辨率用系统实际值别用推荐值物理宽高拿卷尺量屏幕可视区域不含边框观察距离按实际下巴托或眼动仪的位置量刷新率填完之后用后面的getActualFrameRate()实测校正。最后一项尤其重要很多标称 60Hz 的屏幕实际是 59.94Hz按 60 算时长一个 10 秒的 block 就会偏出去十几毫秒累积到整场实验能到上百毫秒。2.3 Builder 与 Coder什么时候该切换Builder 生成的是.psyexp本质是 XML能一键导出 Python 脚本Coder 是直接写脚本。两者的关系不是入门用 Builder、进阶用 Coder而是流程结构用 Builder 表达、时序细节用 Coder 兜底。Builder 里的组件底层对应对象什么时候必须下沉到 CoderImage / Text 刺激visual.ImageStim/visual.TextStim需要逐帧改参数相位、位置、对比度时Keyboard 响应hardware.keyboard.Keyboard需要区分按下/释放、需要自定义 rt 起点时Code 组件任意 Python需要并口打标、LSL 推流、自适应阶梯时Loop随机/阶梯data.TrialHandler/data.StairHandler需要按被试表现动态改条件时实际经验是只要实验里出现必须在某一帧同时做两件事比如刺激上屏的同一刻给放大器发一个 TTL就别在 Builder 里硬凑直接导出脚本改。Builder 的 Code 组件能塞代码但它挂在哪一帧、在flip()之前还是之后执行靠的是组件顺序这种隐式约定在排错时非常痛苦。导出成.py之后执行顺序是白纸黑字写在文件里的出了问题至少能看。3. 时间精度从 vsync 到 flip那几毫秒到底是从哪来的实验心理学里说刺激呈现了 500 毫秒和工程上说我 sleep 了 0.5 秒是两件精度要求完全不同的事。PsychoPy 的时间体系建立在屏幕刷新上理解这条链子你才知道自己测出来的反应时里到底掺了多少系统误差。3.1 一帧的一生draw 只是记账flip 才上屏调用stim.draw()的时候什么都没发生——它只是往当前的后台缓冲里写了一条绘制指令。真正让画面变化的是win.flip()它请求把前后缓冲交换而这个交换会等待显示器的垂直同步信号vsync所以画面切换总是发生在帧边界上不会出现画到一半被撕裂。这里有个关键区别win.flip()的返回值是你请求翻转的时刻不是画面真正出现在屏幕上的时刻。两者之间差着一个刷新周期60Hz 下约 16.7ms加上显卡的排队延迟。如果实验里用flip()的返回值当刺激起始时间你的所有反应时都会系统性偏大十几毫秒而且这个偏差在不同机器上还不一样。正确的做法是让 PsychoPy 在翻转真正发生的瞬间把时间写进对象属性里win.timeOnFlip(stim, tStart) # 翻转瞬间把时刻写入 stim.tStart这个调用要写在draw()之后、flip()之前。同理如果你想让一个计时器跟刺激严格对齐可以把计时器的reset方法挂上去win.timeOnFlip(trial_clock, reset) # 刺激上屏的同一帧计时器清零这样被试的反应时就是以刺激真实呈现时刻为零点算的而不是以你调用 flip 的那一刻为零点。3.2 把每一帧的时间戳记下来光配对起点还不够你得能事后证明这一场实验没有掉帧。做法是在每个 trial 记录计划翻转时刻和实际翻转时刻跑完统计间隔分布from psychopy import visual, core import numpy as np win visual.Window(fullscrTrue, unitspix, colorblack) stim visual.GratingStim(win, texsin, maskgauss, size200, sf0.02) trial_clock core.Clock() planned, actual [], [] for i in range(20): stim.phase 0.05 * i stim.draw() win.timeOnFlip(stim, tStart) # 真实呈现时刻 - stim.tStart win.timeOnFlip(trial_clock, reset) # 计时器与刺激同帧清零 planned.append(win.getFutureFlipTime(clockptb)) # 计划翻转时刻必须在 flip 前取 win.flip() actual.append(stim.tStart) # 实际呈现时刻 core.wait(0.5) # 试次间隔正式实验用帧数控制更好 d np.diff(actual) * 1000 print(n%d mean%.2fms sd%.2fms min%.2f max%.2f % (len(d), d.mean(), d.std(), d.min(), d.max()))getFutureFlipTime(clockptb)返回的是 PsychoPy 预测的下一次翻转时刻用的是底层高精度时钟ptb指 PsychToolbox 那套时钟源必须在flip()之前调用因为它预测的是下一次。stim.tStart由timeOnFlip写入是翻转真实发生后的回填值。两者相减得到的是预测误差正常应该在 1ms 以内np.diff(actual)得到的帧间隔标准差才是判断掉帧的直接证据——60Hz 下理想值 16.67ms标准差超过 1ms 就说明这一场有帧被吞了。参数上core.wait(0.5)适合做冒烟测试正式实验建议改成按帧数控制。原因是core.wait依赖系统调度Windows 上默认计时器粒度较粗实际等待时间可能比设定值多出十几毫秒而且这个误差不稳定。改成数帧就没有这个问题帧数 × 实测帧间隔 时长误差被锁死在一个刷新周期内。3.3 实测刷新率与掉帧排查在正式实验开始前一定要跑一次实测rate win.getActualFrameRate( nIdentical10, # 连续多少帧间隔一致才认为稳定 nMaxFrames120, # 最多测多少帧 nWarmUpFrames10, # 预热帧丢弃开头的不稳定数据 threshold1.0 # 允许的抖动阈值毫秒级容差 ) print(measured refresh rate: %.3f Hz % rate)返回None通常意味着垂直同步没生效或者窗口被其他程序抢了焦点。这时候不要去改代码先查环境显卡驱动里有没有强制关闭垂直同步、是不是接了双屏做扩展模式、后台有没有在跑录屏或会议软件。双屏扩展是重灾区副屏上任何一次刷新都可能打断主屏的 vsync 等待。如果实测值是 59.94Hz 而不是 60Hz别忽略这个 0.06。按 60 算每 1000 帧累积偏差约 1 帧一个 20 分钟的实验跑下来能偏出几百毫秒。稳妥的写法是所有时长参数在脚本里都用帧数表达需要报告秒数时再用实测刷新率换算而不是反过来。4. 外部设备与触发键盘、并口串口、以及时钟对齐这件苦差事PsychoPy 单独跑测出来的只是屏幕到键盘的时间接了脑电、眼动、经颅刺激之后你要回答的是屏幕到放大器的时间。这中间隔着操作系统消息队列、USB 轮询、驱动缓冲每一层都能吃掉几毫秒到几十毫秒。4.1 键盘输入别再用轮询式的 getKeys 了老代码里常见event.getKeys()塞在while循环里每帧调一次。问题在于它返回的时间戳是你调用这个函数的时刻而不是按键真正发生的时刻。这个差值取决于你的循环体有多重、系统消息队列里积压了多少事件抖动可以到十几毫秒而且不服从任何规律。新版的hardware.keyboard.Keyboard走的是事件回调记录的是按键事件进入系统的时间from psychopy.hardware import keyboard from psychopy import core kb keyboard.Keyboard() kb.clearEvents() # 清掉上一试次残留否则会串味 kb.clock.reset() # 反应时零点 # ...此处 draw 刺激 flipflip 前挂 timeOnFlip 对齐... keys kb.waitKeys( maxWait2.0, # 超时上限超时返回空列表 keyList[left, right], waitReleaseTrue, # 等按键释放避免长按被识别成多次 clockkb.clock # 用键盘对象的时钟与刺激起点对齐 ) if keys: print(keys[0].name, keys[0].rt, keys[0].tDown, keys[0].tUp)返回的KeyPress对象里rt是相对clock的反应时tDown/tUp是按下和释放的绝对时刻。waitReleaseTrue这个参数值得强调物理按键的抖动会产生多次 down 事件不开这个选项一个长按可能被记录成三四次反应被试数据里就多出几个莫名其妙的短 RT。即便如此也要清楚边界键盘本身的 USB 轮询率常见 125Hz 到 1000Hz、操作系统输入栈、有无线缆干扰都会影响时间戳下限。对反应时精度要求到 1ms 级别的实验正经做法是改用专用响应盒走串口或并口进机器而不是指望键盘。4.2 并口与串口打标给放大器一个物理信号脑电实验里最常见的要求是刺激上屏的同一刻给放大器一个 TTL 脉冲让事件在脑电数据里有个绝对锚点。PsychoPy 提供了parallel和serial两个模块from psychopy import parallel, core parallel.setPortAddress(0x378) # 并口基地址不同主板可能不同需在设备管理器确认 parallel.setData(0) # 先清零避免上次残留 # 关键把打标动作挂到 flip 的同一帧 win.callOnFlip(parallel.setData, 1) # 翻转瞬间拉高 win.flip() win.callOnFlip(parallel.setData, 0) # 下一帧拉低脉宽约一个刷新周期callOnFlip是这里的关键函数它保证打标动作和缓冲交换在同一帧发生而不是先 flip 再 setData——后者会引入一次函数调用和可能的系统调度延迟几十微秒到几毫秒不等。参数上setPortAddress里的0x378是传统并口 LPT1 的基地址实际值要看主板现代机器大多没有物理并口用 PCIe 转接卡或者 USB 转并口时地址往往不是这个值需要查驱动信息。平台差异也得说清楚Windows 上parallel模块依赖inpout32.dll或 64 位版需要手动放到系统目录或 PsychoPy 的 libs 目录下否则一调就抛异常Linux 上依赖pyparallel和内核并口模块权限配置不对会直接报 permission denied。串口路线serial.Serial(portCOM3, baudrate115200)跨平台友好一些但多了一层 USB 转串口芯片的缓冲抖动比并口大脉宽也要按波特率算够。4.3 时钟对齐最土的办法往往最可靠所有软件层面的打标最终都要回答一个问题你记录的时间戳和放大器的时间轴是不是同一个时间轴两条常见路线。一条是软件对齐用 LSLLab Streaming Layer把刺激事件和脑电数据汇到同一个流里靠各自的时钟校正算法对齐。PsychoPy 侧推流大致是这样from pylsl import StreamInfo, StreamOutlet info StreamInfo(PsychoPyMarkers, Markers, 1, 0, int32, exp01) outlet StreamOutlet(info) # 每个事件点推一个整数编码 outlet.push_sample([code])StreamInfo的五个参数分别是流名称、内容类型、通道数、标称采样率0 表示不规则、通道格式、唯一 ID。这套方案的好处是省事坏处是对齐精度依赖两端的时钟漂移补偿具体能到多少要看采集端实现不做实测就写进方法学部分是不负责任的。另一条是硬件对齐也是我更推荐的做法拿一个光电传感器贴在屏幕角落屏幕上每试次闪一个小方块光电管把光信号转成电信号送进放大器的空余通道。这样屏幕、打标、放大器三者在物理上是同一个事件根本不依赖任何时钟校正。它土但它是唯一的后悔药——事后数据对不上时你能靠这一路信号反推每一帧的真实位置。5. 避坑五条让一整批数据报废的现场记录下面每一条都是跑完才发现的类型共同点是现象看着像代码 bug根因全在环境或约定上。5.1 换台机器刺激尺寸就变了显示器配置没跟着文件走现象同一份.psyexp在自己电脑上视夹角 2 度的光栅刚刚好拷到实验室机器上明显变大被试报告太亮了晃眼。原因Monitor Center里的显示器参数存在用户目录不在实验文件里。对方机器用的是默认配置分辨率、物理尺寸、观察距离三项全错unitsdeg的换算就整体偏移了。解决把显示器配置导出成文件随实验包一起交付接收方导入或者更省事的做法在脚本启动时显式设置并在日志里打印实际生效的参数每次跑实验第一行就核对。换机器必做的三件事读一遍配置、跑一次getActualFrameRate()、投一个已知尺寸的参照刺激用尺子量。5.2 500 毫秒的刺激实际是 550 毫秒时长按秒算而不是按帧算现象日志里记录的刺激持续时间比设定值系统性地偏长偏差在几十毫秒量级且不同试次还不一样。原因用了core.wait(0.5)之类的墙钟等待来控制时长。这个函数走的是系统睡眠Windows 上默认计时器粒度约 15.6ms实际等待会向上取整到最近的粒度边界再叠加进程调度延迟。另外把刷新率当成精确 60Hz 也是常见错误实际 59.94Hz。解决时长改用帧数控制循环里数nFrames每帧一次flip()必要处用实测刷新率把秒换算成帧数并四舍五入。所有呈现 N 毫秒的需求在脚本里都翻译成呈现 N 帧报告时再换回秒。5.3 反应时整体偏大且抖动大时间戳取的是检查时刻现象同一批被试反应时均值比文献高 30ms 左右标准差也明显更大个体差异异常。原因用了老的event.getKeys()在渲染循环里轮询返回的时间是函数被调用时系统的时刻而不是按键发生时刻。循环体里如果有绘图、写日志、读写文件抖动会进一步放大。解决换成hardware.keyboard.Keyboard的waitKeys用返回的.rt确保反应窗口内每帧只做必要的绘制日志写盘放到试次之间而不是帧循环里。改完务必在一台机器上做前后对比同一个按键脚本跑 100 次看均值和标准差的收敛情况。5.4 中文刺激显示成方块字体和编码两道坎现象TextStim里的中文变成一排方框或者导出的 CSV 用表格软件打开是乱码。原因两个独立问题撞在一起。字体侧是TextStim默认字体不含中文字形数据侧是文件以 UTF-8 写出而表格软件默认按本地编码解析。解决字体用font指定中文字体名找不到就用字体文件路径显式加载数据导出时把编码设成utf-8-sig带 BOM表格软件就能正确识别。注意字体授权问题商用字体不要随手打包进实验包分发。5.5 发给别人的实验包一跑就报找不到文件现象自己机器上一切正常压缩包发出去对方解压后运行第一个试次就抛FileNotFoundError。原因Builder 里选素材时用了绝对路径.psyexp里存的就是那串本机路径。换个人、换个盘符就断链。解决所有素材放进与实验文件同级的子目录一律用相对路径引用导出脚本后全局搜一遍盘符和用户目录名交付前把整个文件夹换个路径跑一次第一个 block。这条看着最琐碎但翻车率最高因为它不会在你自己的机器上暴露。6. 把实验交出去数据导出、版本记录与一次复现演练实验跑完只是开始真正的交付物是别人拿到这个包能跑出跟你一致的结果。这件事靠两样东西一份能直接进统计软件的数据表和一份写在文件里的环境快照。6.1 宽表导出与编码Builder 的默认输出是长表每行一个试次大多数统计流程更喜欢宽表每行一个被试或一个条件组合。ExperimentHandler提供了宽表导出from psychopy import data exp data.ExperimentHandler( namedemo, version1.0, extraInfoexpInfo, dataFileNamesub-%s_demo % expInfo[participant] ) # ...每个试次 addData 之后... exp.saveAsWideText( sub-%s_demo.csv % expInfo[participant], delim,, encodingutf-8-sig # 带 BOM表格软件不乱码 )三个参数值得记住delim用逗号便于直接读入encodingutf-8-sig是给非技术同事看数据时的救命设置extraInfo会把被试信息和环境变量一起写进每一行省得后面还要做表连接。缺失值默认写成空字符串或 NaN导入分析脚本时记得显式声明。6.2 把能复现写进文件本身我吃过最大的亏是三个月后回头核对一批数据发现已经说不清当时那台机器的刷新率到底是多少、跑的是哪一版脚本。后来我强制在每个实验脚本开头写一段环境快照跟数据一起落盘import random, platform, datetime from psychopy import __version__ as pp_version random.seed(int(expInfo[participant])) # 固定随机种子条件顺序可复现 expInfo[psychopy_version] pp_version expInfo[python_version] platform.python_version() expInfo[os] platform.platform() expInfo[screen_size] tuple(win.size) expInfo[refresh_rate] round(win.getActualFrameRate() or -1, 3) expInfo[start_time] datetime.datetime.now().isoformat(timespecseconds)这段代码不解决任何技术难题但它是复现的地基。随机种子固定之后同一份.psyexp生成的试次顺序稳定刷新率入库之后任何时长对不上的争议都能当场查清版本号入库之后至少不会出现换了机器行为变了却不知道从哪查起的情况。归档时我会把这几样放在同一个目录.psyexp原文件、导出的.py脚本、素材目录、原始数据 CSV、一份写清楚显示器参数和运行命令的说明。做完这些再做一次演练——找一台没装过 PsychoPy 的机器按说明装环境、跑第一个 block、比对日志里的刷新率和刺激时长。这一步做完交付才算真的结束。从那以后我每次往外发实验包前都会在那台干净机器上把第一个试次跑通再点发送哪怕只是改了一个刺激颜色——因为真正让人返工的从来不是复杂逻辑而是那些我以为它跟着文件走了的隐形配置。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站