基于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就能正常使用了

HTC Vive Pro eye 眼动数据简单获取

【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 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;

}

}

}

}

}

视频录制反而花了不少时间:似乎是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()

其实我觉得这个东西最好是在采集数据的同时判断+记录,不过似乎采集数据结束后再处理也是可以的,就先用着 顺带,基于tag获取的数据缺乏严谨,之后我会想办法搞定这个。

AOI还在施工中,初步设想了两种方案:一种是给场景内所有物体增加tag,通过视线碰撞时识别tag实现,一种是划定3D空物体,识别视线穿过的第一个空物体,然后输出该物体的名称。

工程示范:简单物理实验环境下的眼动追踪

注:搭建的实验环境可能并不严谨,不过设计严谨的实验并不在该题目的研究范畴中。

依旧在施工中,会在未来更新

推荐文章

评论可见,请评论后查看内容,谢谢!!!评论后请刷新页面。