- 科研
- 教育
【免费下载链接】psychopy
For running psychology and neuroscience experiments
本文是围绕 PsychoPy 仓库中 visualValidator 功能演示 展开的技术指南。视觉验证器(Visual Validator)是 PsychoPy Builder 中一种可附加到视觉刺激上的独立 Routine,通过外接光传感器(或屏幕缓冲区仿真器)记录刺激在显示器上实际呈现的时刻,从而量化呈现延迟、校验显示器刷新设置。读完本文,你将掌握 Visual Validator 的工作原理、Builder 中的配置步骤、演示实验的完整参数解读,以及基于源码的时间戳校验机制,能够独立搭建带呈现时间校准的心理物理实验。
什么是 Visual Validator
Visual Validator 是一种"验证器"(Validator)类型的独立 Routine,与普通的刺激组件不同,它不参与实验内容的呈现,而是作为一个测量工具挂在另一个刺激上,用来评估该刺激的呈现时间是否准确。
在 psychopy/experiment/routines/visualValidator/init.py 中,其类 docstring 写道:
Use a light sensor to confirm that visual stimuli are presented when they should be.
即:用光传感器确认视觉刺激是否在它应该出现的时间出现。该 Routine 的类别(categories)是['Validation'],目标平台是 PsychoPy 桌面端(targets = ['PsychoPy']),从版本2025.1.0开始引入。
在实验流程(Flow)层面,Visual Validator 作为一个"独立 Routine"被安插在流程中(例如正式 trial 之前),它会在实验开始时执行传感器自检(寻阈值、找位置),随后在正式刺激呈现期间逐帧记录传感器状态变化。
工作原理:屏幕上的"光传感器补丁"
Visual Validator 的核心思路非常直观:
- 它需要指向一个连接到电脑的光传感器设备,例如:
- Cedrus 的 RB 系列、Riponda、StimTracker;
- BBTK TPad;
- 或其他任何实现了 PsychoPy 光传感器接口(
BaseLightSensorGroup)的设备。
- 它会在窗口上找到传感器覆盖的屏幕区域(可以自动探测,也可以提前手动指定),并在这个位置绘制一个小方块(patch)。
- 当被附加验证的刺激在屏幕上可见时,这个小方块会显示为白色;当刺激不可见时,小方块显示为黑色。
这样,贴在屏幕角落的光传感器二极管就能"看"到这个小方块的明暗变化——方块的明暗完全由目标刺激的可见性决定,因此传感器记录到的明暗翻转时间,就等同于目标刺激真正出现在屏幕上的时刻。
从源码看,这套"方块绘制"逻辑实现在 psychopy/validation/visual.py 的VisualValidator类中:
- 构造函数创建了两个矩形:一个
offRect(黑色,常驻自动绘制autoDraw=True)和一个onRect(白色,仅在目标刺激可见时绘制); connectStimulus(stim)把刺激对象与验证器绑定,并注册到窗口的validators字典;updateRects()根据传感器的pos/size/units同步更新两个矩形的位置和大小,默认位置是窗口右下角(0.95, -0.95)(norm 单位),默认大小(0.05, 0.05)。
关键区别在于:刺激本身是"红色方块"还是"七角星"并不重要,验证器只关心自己那块黑白补丁。传感器测到的是"补丁"的明暗,而补丁的明暗又严格跟随目标刺激的可见性,因此补丁的翻转时刻就是刺激的真实呈现时刻。
为什么需要"实际呈现时间"而非"请求呈现时间"
PsychoPy 在向显示器提交绘制指令时,会记录"请求"的时刻(即stimulus.tStart/stimulus.tStop,对应代码生成时使用的tStartRefresh/tStopRefresh等时间戳),但这些只是软件层面的时间。显示器真正点亮像素的时刻,还受到垂直刷新(vsync)、显卡驱动、显示器响应时间等因素影响。
光传感器给出的开/关时间戳,则代表刺激真正被显示出来的时刻。因此:
- 数据文件中 Visual Validator 的记录为
stimulus.visualVal.tStart(按传感器测得的实际开始呈现时刻)与stimulus.visualVal.tStop(实际停止呈现时刻); - 与之对照的是刺激自身的
stimulus.tStart与stimulus.tStop(请求呈现时刻)。
两者之差就是呈现延迟。文档给出的判据是:一台校准良好的显示器,两者之间的差异应小于一帧。若差异超过 1/60 秒(以 60 Hz 刷新率为例),则可能说明显示器、连接线或显卡设置存在问题,例如:
- 未开启垂直同步(vsync)导致画面撕裂与时间抖动;
- 显示器处于响应时间较慢的省电模式;
- 显卡驱动或显卡切换(如双显卡笔记本)引入额外延迟;
- 多显示器环境下窗口被放到了非主显示器。
没有光传感器怎么办:Screen Buffer Emulator
真实实验中必须要有物理光传感器才能测到"实际光线",但在开发调试阶段,你很可能并不在装好传感器的电脑旁。为此,Visual Validator 提供了一个内置的仿真设备:Screen Buffer Emulator(屏幕缓冲区采样器)。
它在 psychopy/hardware/lightsensor.py 中实现为ScreenBufferSampler类,本质上是一个"假装自己是光传感器"的软件设备:
- 它不读取任何外部硬件信号,而是调用窗口的
_getPixels(buffer="front", ...)读取屏幕前缓冲(front buffer)上指定区域像素的平均亮度; - 将平均亮度与阈值比较:亮度高于阈值则判定为"亮"(
state=True),否则为"暗"; - 当判定状态发生变化时,生成一条
LightSensorResponse(包含时间戳t、状态value、通道channel、阈值threshold),从而模仿真实光传感器的消息流。
对应的 Builder 后端是ScreenBufferVisualValidatorBackend,其设备类指向psychopy.hardware.lightsensor.ScreenBufferSampler,在 psychopy/experiment/routines/visualValidator/init.py 中注册。
需要明确的是:Screen Buffer Emulator并不测量真实光线,因此在正式实验中用它得到的结果没有校准意义。它的价值在于:
- 让你在没有传感器的情况下完整跑通演示,观察整个验证流程长什么样;
- 允许你在离开实验室、没有传感器可用的场合,构建和调试"将要被验证"的实验脚本。
演示实验详解:visualValidator.psyexp
仓库自带的演示位于 psychopy/demos/builder/Feature Demos/visualValidator/visualValidator.psyexp。它展示了一个最小可运行的验证场景,下面拆解其结构与参数。
流程(Flow)布局
visualVal (VisualValidatorRoutine) → trials (TrialHandler 循环, 10 次) → trial (Routine)visualVal:Visual Validator Routine,实验开始时先执行一次传感器定位与阈值寻找;trials:TrialHandler 循环,loopType="random"(随机顺序),nReps=10(重复 10 次);trial:正式试次 Routine,内含一个被验证的星形刺激和一个提示文本框。
Visual Validator Routine 的参数(演示中的实际值)
| 参数 | 演示值 | 含义 |
|---|---|---|
name | visualVal | 验证器名称,也是数据文件中列名前缀 |
deviceLabel | demoLightSensor | 指向 DeviceManager 中注册的光传感器设备 |
channel | 0 | 光传感器通道号 |
findThreshold | True | 实验开始时自动寻找最佳阈值 |
threshold | 0.5 | 亮度阈值(0 最不敏感,1 最敏感) |
findSensor | True | 实验开始时自动探测传感器位置与大小 |
sensorPos | (1, 1) | 传感器在窗口上的位置(仅在findSensor=False时手动使用) |
sensorSize | (0.1, 0.1) | 传感器覆盖区域大小 |
sensorUnits | norm | 传感器位置/大小的空间单位 |
被验证的刺激(PolygonComponent)
演示中的目标刺激是一个七角星(shape="star7"),关键参数:
size=(0.5, 0.5),位置pos=(random()-0.5, random()-0.5),朝向ori=randint(0, 365),填充色$randchoice(["yellow", "green", "blue", "purple", "red", "orange"])——每次重复都会随机变化;- 呈现 1 秒(
stopVal=1.0,duration 类型); - 核心配置:
validator="visualVal"——这就是把该刺激与验证器关联起来的字段。
提示文本框(TextboxComponent)
文本框内容为:
Can you see the light sensor patch? It should be white when the star is on screen and black otherwise
("你能看到光传感器补丁吗?星星在屏幕上时它应该是白色的,否则是黑色的"),呈现 2 秒。它的作用只是引导观察者确认补丁工作正常,本身不接验证器(validator为空)。
实验设置要点
演示实验的其他设置(来自.psyexp的<Settings>部分)也值得注意:
Full-screen window=True(全屏窗口)、Screen=1、窗口(1024, 768)像素;monitor="testMonitor"、units="height";measureFrameRate=True(尝试测量帧率);Save psydat file=True、Save wide csv file=True、Save log file=True(默认保存 psydat 与宽格式 csv 数据)。
在 Builder 中配置自己的 Visual Validator
第一步:添加 Visual Validator Routine
在 Builder 的 Flow 视图中,从组件面板添加一个 Visual Validator Routine(类别为 Validation)。它会自动出现在流程中,并应在正式试次之前运行(用于实验开始时完成传感器自检)。
第二步:在刺激的 Testing 选项卡中关联验证器
所有视觉刺激组件(继承自BaseVisualComponent)都自带一个validator参数。在 psychopy/experiment/components/_base.py 中可以看到:
- 参数位于
categ="Testing"(即 Builder 中的 Testing 选项卡),标签为 "Validate with..."; - 可选项来自
validatorClasses = ["VisualValidatorRoutine"]; - 提示文本:"Name of validator Component/Routine to use to check the timing of this stimulus."(用于校验该刺激时机的验证器 Routine 名称)。
在下拉框中选择你添加的 Visual Validator Routine 名称(如visualVal)即可完成关联。源码中findConnectedStimuli()正是通过扫描所有 Routine 中每个组件的getValidator()返回值来寻找与自身关联的刺激,并为每个刺激生成visualVal.connectStimulus(stim)调用。
第三步:配置验证器参数
Visual Validator Routine 在 Builder 中的参数面板(Basic / Device 两个分类)由 psychopy/experiment/routines/visualValidator/init.py 定义,核心参数说明如下。
Basic(基础)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
Find best threshold?(findThreshold) | bool | True | 实验开始时是否运行一段简短的 Routine 寻找传感器最佳阈值 |
Threshold(threshold) | code | 0.5 | 光传感器判定为"亮"的阈值,0(最不敏感)到 1(最敏感);仅在findThreshold=False时显示并生效 |
Find sensor?(findSensor) | code | True | 实验开始时是否运行简短 Routine 探测传感器的位置与大小 |
Position [x,y](sensorPos) | list | (1, 1) | 传感器在窗口上的位置;仅在findSensor=False时显示 |
Size [x,y](sensorSize) | list | (0.1, 0.1) | 传感器在窗口上覆盖区域的大小;仅在findSensor=False时显示 |
Spatial units(sensorUnits) | str | norm | 传感器位置/大小的空间单位,可选from exp settings、deg、cm、pix、norm、height、degFlatPos、degFlat;仅在findSensor=False时显示 |
注意参数间的联动关系:源码通过depends列表实现了条件显隐——threshold仅在findThreshold == False时显示;sensorPos/sensorSize/sensorUnits仅在findSensor == False时显示。
Device(设备)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
deviceLabel | str | 空 | 设备标签,指向 DeviceManager 中注册的光传感器设备(如演示中的demoLightSensor) |
deviceBackend | str | screenbuffer | 设备后端;screenbuffer即屏幕缓冲区仿真器,也可选择其他已注册的光传感器后端(如 Cedrus / BBTK 等) |
Light sensor channel(channel) | code | 0 | 光传感器通道号;若同一端口接有多个传感器可用其区分,留空则使用第一个能检测到窗口的传感器 |
注:
deviceBackend在旧版本中曾是独立参数,新版已由 DeviceManager 统一管理,因此它被列在legacyParams中并在加载旧实验时自动清理。
源码视角:一次验证的完整调用链
将 Builder 实验编译为 Python 脚本时,VisualValidatorRoutine.writeMainCode()会生成类似下面的代码(按findThreshold=True、findSensor=True的默认路径):
# sensor object for visualVal visualValSensor = deviceManager.getDevice(demoLightSensor) # find threshold for light sensor visualValSensor.findThreshold(win, channel=0) # find position and size of the light sensor visualValSensor.findSensor(win, channel=0) # validator object for visualVal visualVal = validation.VisualValidator( win, visualValSensor, 0, ) # connect polygon to visualVal visualVal.connectStimulus(polygon)(以上代码结构由writeMainCode的模板拼接逻辑推断,实际生成脚本以 Builder 导出为准。)
如果关闭自动探测(findThreshold=False且findSensor=False),则改为直接设置阈值与补丁位置:
visualValSensor.setThreshold(0.5, channel=0) visualValSensor.units = norm visualValSensor.pos = (1, 1) visualValSensor.size = (0.1, 0.1)自动阈值寻找(findThreshold)
在 psychopy/hardware/lightsensor.py 的BaseLightSensorGroup.findThreshold()中,算法会将屏幕分别置为黑色与白色,用二分法(_bisectThreshold,最多递归 16 层)逐步逼近:对每种颜色找到"恰好触发/不触发"的边界阈值,最终取黑白两边界的中点作为安全阈值,即:
threshold = (thresholds['white'] + thresholds['black']) / 2如果黑色屏的响应阈值反而低于或等于白色屏的阈值,说明传感器可能未接好,此时会记录日志并返回None。
自动传感器定位(findSensor)
findSensor()采用递归象限收缩策略:从一个覆盖全屏的白色矩形开始,每次将矩形一分为二并在 9 个候选位置(四个角、四边中点、中心)逐个显示,同时轮询光传感器(最多等待 250 ms 让翻转发生);一旦某个位置被传感器"看到",就记录为新的lastGood并递归缩小,直到矩形小到无法再覆盖整个二极管为止。最终返回最后一个可被检测到的矩形的中心作为传感器位置、矩形尺寸作为"确定区域"大小。
过程中还内置了容错机制:
- 若所有位置都无响应,则显示提示信息,等待用户按Enter手动指定位置,或按Escape退出,否则 5 秒后自动重试(
retryLimit次); - 手动模式下,可用方向键移动补丁、加号/减号键缩放补丁,Enter 确认;
- 定位前会先通过
findChannels()用"整屏闪白"测试哪些通道对当前窗口有响应,channel=None时自动选用第一个响应通道。
逐帧验证(writeEachFrameValidationCode)
在正式刺激呈现期间,编译脚本会在每帧插入验证逻辑。以刺激polygon为例(依据writeEachFrameValidationCode的代码模板),核心片段形如:
# validate polygon start time if polygon.status == STARTED and visualVal.status == STARTED: visualVal.tStart, visualVal.tStartDelay = visualVal.validate(state=True, t=polygon.tStartRefresh) if visualVal.tStart is not None: visualVal.status = FINISHED thisExp.addData('polygon.visualVal.started', visualVal.tStart) thisExp.addData('visualVal.startDelay', visualVal.tStartDelay)这里validate()的实现位于 psychopy/validation/visual.py:它从传感器取出所有匹配状态(state=True表示"亮")的消息,取最近一条的时间戳lastTime,与请求时刻t做差得到延迟:
delay = lastTime - adjustment - t返回(lastTime, delay)作为"实际时刻"与"延迟量"。刺激停止时刻的验证同理(state=False,对应tStop/tStopDelay)。如果刺激开启了syncScreenRefresh,则比较时使用刷新同步后的tStartRefresh/tStopRefresh,并将验证器时钟与globalClock对齐(resetTimer(clock=globalClock));否则使用routineTimer。
数据文件中的列
当被验证刺激勾选了saveStartStop(演示中默认开启)时,每次验证结果会写入数据文件,新增列包括:
polygon.visualVal.started/polygon.visualVal.stopped:传感器实测的刺激开始/停止时刻;visualVal.startDelay/visualVal.stopDelay:实测时刻与请求时刻之间的延迟。
配合刺激自带的polygon.tStart/polygon.tStop(或tStartRefresh/tStopRefresh),即可在离线分析中计算每个试次的呈现误差分布。
常见问题与预警(Alerts)
源码中为验证流程设计了专门预警,运行 Builder 的完整性检查或实验脚本时可能触发:
Alert 4160:无法验证停止时间当被验证刺激恰好与 Routine 在同一帧结束时触发(见 psychopy/alerts/alertsCatalogue/4160.yaml)。原因:验证依赖"刺激开始/停止后的下一帧"来询问传感器,若停止帧就是 Routine 最后一帧,就没有后续帧可询问,tStop将为None。此时若只关心开始时刻可以忽略;若要验证停止时刻,需要让 Routine 比被验证刺激至少多一帧。
Alert 3610:同一 Routine 中多个刺激被同一验证器验证一个验证器同一时刻只能可靠地验证一个刺激,因为光传感器只返回明/暗二值,无法区分不同光源(见 psychopy/alerts/alertsCatalogue/3610.yaml)。若多个被验证刺激在同一 Routine 中,必须确保它们的呈现时间互不重叠,否则时间戳会互相污染。
此外,测试用例 psychopy/tests/test_hardware/test_photodiode.py 通过一个DummyPhotodiode模拟光传感器验证了findSensor的无响应处理路径,psychopy/tests/test_experiment/test_routines/test_PhotodiodeValidationRoutine.py 则对VisualValidatorRoutine的默认参数初始化进行了覆盖——这些测试是理解设备接口与 Routine 参数体系的最小示例,值得参考。
实践建议
- 调试阶段务必使用 Screen Buffer Emulator:在没有物理传感器的环境下,用它跑通实验逻辑、检查数据列是否按预期写入;
- 正式实验前做一次带传感器的干跑:确认
findThreshold与findSensor得到的阈值/位置合理(观察补丁是否与屏幕上的白色区域同步翻转),再开始正式采集; - 判断标准:
visualVal.startDelay/stopDelay的绝对值应小于一帧(60 Hz 显示器约为 16.7 ms)。若系统性超过一个帧周期,优先检查垂直同步、显卡设置与显示器型号; - 留意预警:若触发 Alert 4160 或 3610,按上文说明调整 Routine 时长或刺激的时间布局;
- 传感器摆放:补丁默认出现在窗口右下角(
(0.95, -0.95),norm 单位),请确保物理传感器恰好贴在补丁对应的屏幕区域,且该区域不被实验刺激遮挡。
- 科研
- 教育
【免费下载链接】psychopy
For running psychology and neuroscience experiments
相关推荐
Psychopy Builder 中的“刺激生成器”实验:用 make_shapes 自动批量生成 Drag N' Drop 演示的刺激图片
Psychopy Builder 中的“刺激生成器”实验:用 make_shapes 自动批量生成 Drag N' Drop 演示的刺激图片 本篇以 Psych
科研教育激光雷达与相机校准终极指南:快速实现3D传感器融合
激光雷达与相机校准终极指南:快速实现3D传感器融合 激光雷达与相机校准是自动驾驶、机器人感知和3D视觉系统中的关键步骤。lidar_camera_calibra
计算机视觉Redux Toolkit最佳实践:使用Redux-in-russian构建高效状态管理系统
Redux Toolkit最佳实践:使用Redux in russian构建高效状态管理系统 Redux Toolkit是Redux官方推荐的状态管理解决方案,
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考