基于Unity和Vive眼动SDK的VR眼动追踪研究场景开发
前言:因为毕业论文的需要,我得在一年内尽快熟悉实验室的Vive pro eye并基于这套设备完成眼动追踪教育学注意力行为研究。感谢@Farewell弈和b站“邓布利多军”的先前工作,目前我的东西就是基于这两位大佬的东西摸着石头过河的。
跟随本篇文章,你将学到如何在Unity开发环境下,基于Vive pro eye硬件和SteamVR、OpenXR、SRanipaRuntime SDK三个第三方包,开发出一个能实时获取眼动追踪数据(包括3D视线碰撞坐标,2D屏幕下转换坐标、注视物体名称、时间戳等)的UnityVR场景,为之后的VR环境下眼动追踪研究提供参考。
需要注意的是,目前VR设备的眼动追踪能力只能和中低端眼动仪设备相媲美,最高采样率普遍为100hz左右(某些高端眼动仪能达到上千hz),对于VR教学研究、一般的心理学研究已经足够,但是不太适合用于精度要求较高的研究题目(如研究“微眼动”的心理学课题)。
环境配置
太长不看版:
Unity 2021.3.19版本 + 三个第三方包:SteamVR、OpenXR、SRanipaRuntime SDK 1.3.3.0版(一定要1.3版本)
Unity的安装就不说了,都是些最为基础的东西
笔者使用的Unity版本为2021.3.19
第三方包方面,需要准备SteamVR、OpenXR,还有一个SRanipaRuntime SDK,前两者可以用Window——Package Manager导入,后者需要去Vive官网下载,因为Vive已经全面转向基于OpenXR的开发,SRanipaRuntime SDK并未上线Unity商店。
具体流程就不造轮子了,请参考以下文章:
需要注意:SRanipaRuntime SDK需选用1.3.3.0版本,1.6版本我在导入时发生了报错,在第一篇文章下也有人反应新版本反而有兼容性问题,回滚至1.3.3.0就能正常使用了
【VR】HTC VIVE pro eye + Unity 眼动注视轨迹可视化方案二
其实现在用SRanipaRuntime SDK是有点过时的选择,如果可以的话建议换用OpenXR的XR_EXT_eye_gaze_interaction拓展,但是查了一圈国内暂时没有基于这个插件的实现工程,我就先求稳用的SRanipaRuntime SDK了。
但是考虑到OpenXR统一化了大多数主流VR设备的开发环境这点来看,转向OpenXR+SteamVR在未来几年是有必要的,届时只要是支持OpenXR的硬件设备,就可以使用基于该开发环境做出来的工程,不用再担心兼容性问题(考虑到这是VR硬件大厂们牵头推广的东西,这个概率很大),我也在考虑等这个demo开发差不多后将SRanipaRuntime SDK转成XR_EXT_eye_gaze_interaction
熟悉环境
太长不看版:
SteamVR的Intractable Simple场景可用于快速熟悉SteamVR下的预制件;SRanipaRuntime SDK则有一个EyeSample_V2,两者是后续开发的基础
Vive pro eye自带眼动追踪校准程序,体验前建议运行,确保数据准确
SteamVR熟悉
Unity资源管理器里通过SteamVR——InteractionSystem——Samples——Interaction_Examples.unity,可以找到SteamVR的交互预制件与演示合集,基本上之后想实现什么样的功能都可以从这里找原型,不需要真的从头造轮子。
这个场景自身也是可玩的,有弓、遥控车、手榴弹等等。
如果对场景需求的质量要求不高的话,可以直接复制该场景进行开发。
SRanipaRuntime SDK熟悉
ViveSR——Scenes——Eye——EyeSample_v2.unity
也是个进去后只要没报错就直接能运行的场景,其中比较重要的组件的GazeRaySample,我也是根据@邓布利多军大佬的想法,爆改了相关组件,以实现获取数据的效果。
如果需要在其它场景中使用,搬运SRanipal Eye Framework和Gaze Ray Sample两个组件即可。
需求确定与实现
太长不看版:用了一个取巧(偷懒)的办法,爆改眼动追踪SDK的Gaze_Ray_Sample.cs,使其能输出数据,然后基于C#和python脚本,实现了辨析注视点、AOI可视化、动态热点图可视化等研究需求
我的毕业论文需要在VR教学环境下实现采集眼动追踪数据并且进行简单的分析,分析可以完全人工进行,但是难点在于如何实现VR环境的眼动追踪数据采集。目前大多数眼动追踪实验都是基于2d平面(屏幕)进行的,3d环境中的研究很少,GitHub倒是有个专门研究这个的pupil labs,但是他们的软件需要购买额外的硬件设备,也就是“软件免费,硬件收费,两者捆绑”的模式。
最后实在没办法,我自己想办法实现了一下。思路放在这里,供大家参考。
根据找到的不多的文献来看,至少得实现以上6个需求
凝视点是收集数据时最基本的单位;
注视点可视为用户视线聚焦在某处超过某个值时(一般为200ms左右),即可视为在“注视”该物体;
感兴趣区域(AOI)由研究者自行设置,主要研究多个用户在实验时视线落在不同AOI处有无视觉规律或者其它现象;
热点图则是眼动追踪最直观的可视化方式之一,同时也是实现时的难点,unity不自带实现方法,需要考虑动用python。
最后决定分为两大块开发,原始数据获取和数据处理部分,以实现上方的6个需求
不过实际开发时,“数据处理部分”又细分成“数据集生成”与“数据可视化”两块。
获取原始数据
获取原始数据分两部分:一个是凝视的数据集,一个是所需的视频
凝视的数据集的获取方面,我则模仿了其它几位大佬的做法,通过爆改SRanipal_GazeRaySample_v2.cs实现,爆改后的代码如下:
//========= Copyright 2018, HTC Corporation. All rights reserved. ===========
using System;
using System.IO;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Assertions;
namespace ViveSR
{
namespace anipal
{
namespace Eye
{
public class SRanipal_GazeRaySample_v2 : MonoBehaviour
{
public int LengthOfRay = 25;
[SerializeField] private LineRenderer GazeRayRenderer;
private static EyeData_v2 eyeData = new EyeData_v2();
private bool eye_callback_registered = false;
//增加变量
private float pupilDiameterLeft, pupilDiameterRight;
private Vector2 pupilPositionLeft, pupilPositionRight;
private float eyeOpenLeft, eyeOpenRight;
private string datasetFilePath;
private StreamWriter datasetFileWriter;
private float startTime;
//增加变量结束
public event Action<Vector3> CollisionPointEvent;
//定义事件,以便将原始数据传参给其他脚本
private void Start()
{
if (!SRanipal_Eye_Framework.Instance.EnableEye)
{
enabled = false;
return;
}
Assert.IsNotNull(GazeRayRenderer);
//
startTime = Time.time;
string format = "yyyy-MM-dd_HH-mm-ss";
string recordTime = System.DateTime.Now.ToString(format);
datasetFilePath = "dataset_" + recordTime + ".txt";
datasetFileWriter = File.AppendText(Path.Combine(UnityEngine.Application.dataPath, datasetFilePath));
UnityEngine.Debug.Log("Dataset file created at: " + Path.Combine(UnityEngine.Application.dataPath, datasetFilePath));
UnityEngine.Debug.Log("Recording started at: " + recordTime);
//
}
private void Update()
{
if (SRanipal_Eye_Framework.Status != SRanipal_Eye_Framework.FrameworkStatus.WORKING &&
SRanipal_Eye_Framework.Status != SRanipal_Eye_Framework.FrameworkStatus.NOT_SUPPORT) return;
if (SRanipal_Eye_Framework.Instance.EnableEyeDataCallback == true && eye_callback_registered == false)
{
SRanipal_Eye_v2.WrapperRegisterEyeDataCallback(Marshal.GetFunctionPointerForDelegate((SRanipal_Eye_v2.CallbackBasic)EyeCallback));
eye_callback_registered = true;
}
else if (SRanipal_Eye_Framework.Instance.EnableEyeDataCallback == false && eye_callback_registered == true)
{
SRanipal_Eye_v2.WrapperUnRegisterEyeDataCallback(Marshal.GetFunctionPointerForDelegate((SRanipal_Eye_v2.CallbackBasic)EyeCallback));
eye_callback_registered = false;
}
Vector3 GazeOriginCombinedLocal, GazeDirectionCombinedLocal;
if (eye_callback_registered)
{
if (SRanipal_Eye_v2.GetGazeRay(GazeIndex.COMBINE, out GazeOriginCombinedLocal, out GazeDirectionCombinedLocal, eyeData)) { }
else if (SRanipal_Eye_v2.GetGazeRay(GazeIndex.LEFT, out GazeOriginCombinedLocal, out GazeDirectionCombinedLocal, eyeData)) { }
else if (SRanipal_Eye_v2.GetGazeRay(GazeIndex.RIGHT, out GazeOriginCombinedLocal, out GazeDirectionCombinedLocal, eyeData)) { }
else return;
}
else
{
if (SRanipal_Eye_v2.GetGazeRay(GazeIndex.COMBINE, out GazeOriginCombinedLocal, out GazeDirectionCombinedLocal)) { }
else if (SRanipal_Eye_v2.GetGazeRay(GazeIndex.LEFT, out GazeOriginCombinedLocal, out GazeDirectionCombinedLocal)) { }
else if (SRanipal_Eye_v2.GetGazeRay(GazeIndex.RIGHT, out GazeOriginCombinedLocal, out GazeDirectionCombinedLocal)) { }
else return;
}
Vector3 GazeDirectionCombined = Camera.main.transform.TransformDirection(GazeDirectionCombinedLocal);
GazeRayRenderer.SetPosition(0, Camera.main.transform.position);
GazeRayRenderer.SetPosition(1, Camera.main.transform.position + GazeDirectionCombined * LengthOfRay);
//以下为新增部分
//pupil diameter 瞳孔的直径
pupilDiameterLeft = eyeData.verbose_data.left.pupil_diameter_mm;
pupilDiameterRight = eyeData.verbose_data.right.pupil_diameter_mm;
//pupil positions 瞳孔位置
//pupil_position_in_sensor_area手册里写的是The normalized position of a pupil in [0,1],给坐标归一化了
pupilPositionLeft = eyeData.verbose_data.left.pupil_position_in_sensor_area;
pupilPositionRight = eyeData.verbose_data.right.pupil_position_in_sensor_area;
//eye open 睁眼
//eye_openness手册里写的是A value representing how open the eye is,也就是睁眼程度,从输出来看是在0-1之间,也归一化了
eyeOpenLeft = eyeData.verbose_data.left.eye_openness;
eyeOpenRight = eyeData.verbose_data.right.eye_openness;
//UnityEngine.Debug.Log("左眼瞳孔直径:" + pupilDiameterLeft + " 左眼位置坐标:" + pupilPositionLeft + "左眼睁眼程度" + eyeOpenLeft);
//UnityEngine.Debug.Log("右眼瞳孔直径:" + pupilDiameterRight + " 右眼位置坐标:" + pupilPositionRight + " 左眼睁眼程度" + eyeOpenRight);
// 调用Physics.SphereCast进行检测,并返回是否有碰撞产生
RaycastHit hit;
bool isHit = Physics.SphereCast(Camera.main.transform.position, 0.1f, GazeDirectionCombined.normalized, out hit, LengthOfRay);
string timestamp = (Time.time - startTime).ToString();
if (isHit)
{
// 碰撞到物体,返回碰撞点的坐标
Vector3 collisionPoint = hit.point;
UnityEngine.Debug.Log("相交物体:" + hit.collider.gameObject.name);
//UnityEngine.Debug.Log("碰撞点坐标:" + collisionPoint);
// 触发事件并传递碰撞点坐标
CollisionPointEvent?.Invoke(collisionPoint);
// Write the data to the dataset file
datasetFileWriter.WriteLine(hit.collider.gameObject.name + "," +
collisionPoint + "," +
pupilDiameterLeft + "," +
pupilDiameterRight + "," +
timestamp);
}
else
{
// 未碰撞到物体
UnityEngine.Debug.Log("未发生碰撞");
}
}
private void Release()
{
if (eye_callback_registered == true)
{
SRanipal_Eye_v2.WrapperUnRegisterEyeDataCallback(Marshal.GetFunctionPointerForDelegate((SRanipal_Eye_v2.CallbackBasic)EyeCallback));
eye_callback_registered = false;
}
}
private static void EyeCallback(ref EyeData_v2 eye_data)
{
eyeData = eye_data;
}
}
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 125
- 126
- 127
- 128
- 129
- 130
- 131
- 132
- 133
- 134
- 135
- 136
- 137
- 138
- 139
- 140
- 141
- 142
- 143
- 144
- 145
- 146
- 147
- 148
视频录制反而花了不少时间:似乎是VR场景的渲染模式和一般的3D场景是不同的,而且根据研究需要,得在实验场景中设置两个摄像机,一个是玩家正常游玩视角,一个是固定的录制视角(用于后期输出动态热点图视频),双摄像机有个坑点:需要设置渲染顺序,即Camera的depth值,不可设置成一样,否则两个摄像机都无法工作。
(后来查了一下这个也是3D游戏里制作抬头显示的方法——设置多个摄像机跟随玩家视角,其中一个是专用的UI摄像机,通过调整渲染顺序的方式实现。)
Unity自带的UnityRecorder无法正常工作。最后换用了AVPro Recorder,该软件需要付费,我就不砸钱了,换用的破解版()
数据集生成
注视点的识别是我托几位学弟完成的,
其原理为处理数据集,然后数据集中只要在某个值内超过一定时间便视为注视点。
import math
#打开文件并读取
fin=open('dataset1.txt','r')
fout0=open('first_time.txt','w')
fout1=open('gazepoints.txt','w')
lines=fin.readlines() #读取整个文件所有行,保存在 list 列表中
#遍历lines列表进行数据处理
set0 =set()
gazingtime=0.0
distance=1.0
num=0
list1=str.split(lines[0],',')
print(list1)
x0 = float(list1[1][1:])
y0 = float(list1[2])
z0 = float(list1[3][0:-1])
time0 = float(list1[-1])
object_name0=list1[0]
print("{} {} {} {}".format(x0,y0,z0,time0))
for line in lines:
list0=str.split(line,',')
# print(list0)
# print(list0[1][1:],end=' ')
# print(list0[2],end=' ')
# print(list0[3][0:-1])
# print(type(float(list0[2])))
#1.找到首次看到的物体及时间,并写入first_time.txt文件中
if list0[0] not in set0:
set0.add(list0[0])
fout0.write("{} {}".format(list0[0],list0[-1]))
#2.找出凝视点,并写入gazepoints.txt文件中
x1=float(list0[1][1:])
y1=float(list0[2])
z1=float(list0[3][0:-1])
time1=float(list0[-1])
# print("{} {} {} {}".format(x1, y1, z1, time1))
object_name1=list0[0]
distance=math.sqrt(pow(x1-x0,2)+pow(y1-y0,2)+pow(z1-z0,2))
# print(distance)
# print(time1-time0)
if distance <=0.1 and object_name1==object_name0:
detletime=time1-time0
# print(detletime)
gazingtime=gazingtime+detletime
#print(gazingtime)
elif distance>0.1:
if gazingtime>=0.2:
num=num+1
print('{} {}'.format(object_name0, gazingtime))
fout1.write("{}.{} {}\n".format(num,object_name0,gazingtime))
gazingtime=0.0
#print(1)
elif object_name1!=object_name0 and gazingtime>=0.2:
num=num+1
print('{} {}'.format(object_name0, gazingtime))
fout1.write("{}.{} {}\n".format(num, object_name0, gazingtime))
gazingtime = 0.0
# if object_name1 != object_name0 and gazingtime>=0.2 :
# print('{} {}'.format(object_name0,gazingtime))
# gazingtime=0.0
x0=x1
y0=y1
z0=z1
time0=time1
object_name0=object_name1
# print("{} {} {} {}".format(x0, y0, z0, time0))
fin.close()
fout0.close()
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
其实我觉得这个东西最好是在采集数据的同时判断+记录,不过似乎采集数据结束后再处理也是可以的,就先用着
顺带,基于tag获取的数据缺乏严谨,之后我会想办法搞定这个。
AOI还在施工中,初步设想了两种方案:一种是给场景内所有物体增加tag,通过视线碰撞时识别tag实现,一种是划定3D空物体,识别视线穿过的第一个空物体,然后输出该物体的名称。
工程示范:简单物理实验环境下的眼动追踪
注:搭建的实验环境可能并不严谨,不过设计严谨的实验并不在该题目的研究范畴中。
依旧在施工中,会在未来更新
评论记录:
回复评论: