基于Unity和Vive眼动SDK的VR眼动追踪研究场景开发

基于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

第三方包方面,需要准备SteamVROpenXR,还有一个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<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;
                }
            }
        }
    }
}

视频录制反而花了不少时间:似乎是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空物体,识别视线穿过的第一个空物体,然后输出该物体的名称。

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

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

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

  • 4
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值