[WWDC2018] -ARKit2.0视线追踪在界面操作中的尝试

概述

wwdc-2018-logo-100758546-large

随着WWDC 2018谢幕,ARKit也迎来了2.0版本,在本届开发者大会开始之前媒体根据邀请函就猜测本届WWDC的重点一定围绕着ARKit而展开,事实上ARKit确实成为了演示重点。从ARKit 1.0到年初发布的ARKit 1.5(iOS 11.3),进而到如今的ARKit 2.0可以看出苹果在AR领域一直在持续发力。从早期简单的平面检测到后来的垂直面检测、图像追踪、物体检测、多用户协同、地图保存等,分辨率也从720P提高到了1080P。作为iPhone X独享的Face Tracking本次也得到了全面的提升,除了之前开发者期待的舌头检测,在ARKit Session最后阶段还提到了视线追踪(或者叫做眼球追踪)。其实早在2017年6月份就有传闻苹果收购眼球追踪技术厂商,而9月份苹果发布的ARKit中并未包含眼球追踪功能,而今ARKit 2.0终于带来了视线追踪(Gaze tracking)功能。

视线追踪(Gaze tracking)

其实早在ARKit 1.0中iPhone X就可以利用ARFaceTrackingConfiguration进行人脸追踪,很多摄影App也充分利用这个功能进行3D贴纸开发,得益于TrueDepth摄像头强大的功能,这要比一般的人脸识别更加高效和准确。随着ARKit 2.0的发布Face Tracking也越来越强大,这其中就包括视线追踪的加入。

open class ARFaceAnchor : ARTrackable {  
    open var leftEyeTransform: simd_float4x4 { get }
    open var rightEyeTransform: simd_float4x4 { get }
    open var lookAtPoint: simd_float3 { get }
}

关于视线追踪的信息苹果是通过ARFaceAnchor提供,而ARFaceAnchor可以通过ARSCNViewDelegate的func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor)获取到。leftEyeTransform和rightEyeTransform分别记录了双眼空间状态变换矩阵(简单的理解就是瞳孔的空间位置),而lookAtPoint则是眼睛注视的焦点空间位置。

界面操作中的尝试

提起眼球追踪就会让人想起几年前三星S4中的眼球控制操作以及Win10中的滑鼠输入,既然现在ARKit 2.0已经可以实现追踪那么能不能尝试使用这个功能进行界面操作控制呢?尽管苹果并未着重强调这个功能的使用,不过眼控操作界面确实是一件有意思的尝试。

根据目前ARKit2.0提供的信息要实现视线追踪控制界面操作就必须获取到眼睛注视的屏幕坐标,根据这个坐标获取UI元素进而完成操作控制。尽管ARFaceAnchor中包含焦点的信息,不过这个信息并非屏幕坐标信息,也有可能出现这个焦点并不能命中屏幕的状况,所以直接使用这个信息进行屏幕坐标信息转化貌似不太可行。假设我们在空间中绘制一个手机节点virtualPhoneNode,始终和真实的手机保持一致,而leftEyeTransform和rightEyeTransform可以分别添加一个对应的节点eyeLNode和eyeRNode,然后分别增加两个子节点lookAtTargetEyeLNode和lookAtTargetEyeRNode并且调整z坐标,这样一来通过virtualPhoneNode的hitTestWithSegment()连接两个节点就可以在手机节点上形成一个新的节点,这个节点就是我们需要的坐标,当然两个眼球分别进行碰撞检测会形成两个节点,将两个节点的x、y坐标求平均基本就可以得到一个相对准确的位置。

下面是核心的代码:

class GazeTrackingManager:NSObject {

    var updateHandler:((_ x:Int,_ y:Int)->Void)?

    var lookAtPositionX = 0
    var lookAtPositionY = 0

    var faceNode: SCNNode = SCNNode()

    var leftEyeNode: SCNNode = SCNNode()

    var rightEyeNode: SCNNode = SCNNode()

    var lookAtTargetLeftEyeNode: SCNNode = SCNNode()
    var lookAtTargetRightEyeNode: SCNNode = SCNNode()

    // iPhone X 的屏幕实际尺寸(单位:米)
    let phoneScreenMeterSize = CGSize(width: 0.0623908297, height: 0.135096943231532)

    // iPhone X 尺寸(单位:点)
    let phoneScreenPointSize = CGSize(width: 375, height: 812)

    var virtualPhoneNode: SCNNode = SCNNode()

    var virtualScreenNode: SCNNode = SCNNode(geometry: SCNPlane(width: 1, height: 1))

    private var sceneView: ARSCNView!

    private var eyeLookAtPositionXs: [CGFloat] = []

    private var eyeLookAtPositionYs: [CGFloat] = []

    func arView() ->ARSCNView {
        sceneView = ARSCNView()
        sceneView.isHidden = true

        sceneView.delegate = self
        sceneView.session.delegate = self
        sceneView.automaticallyUpdatesLighting = true

        // 增加面部、眼睛、手机节点及参考节点
        sceneView.scene.rootNode.addChildNode(faceNode)
        sceneView.scene.rootNode.addChildNode(virtualPhoneNode)
        virtualPhoneNode.addChildNode(virtualScreenNode)
        faceNode.addChildNode(leftEyeNode)
        faceNode.addChildNode(rightEyeNode)
        leftEyeNode.addChildNode(lookAtTargetLeftEyeNode)
        rightEyeNode.addChildNode(lookAtTargetRightEyeNode)

        // 设置两个子节点作为参考点
        lookAtTargetLeftEyeNode.position.z = 2
        lookAtTargetRightEyeNode.position.z = 2

        return sceneView
    }

    func start() {
        guard ARFaceTrackingConfiguration.isSupported else { return }
        let configuration = ARFaceTrackingConfiguration()
        configuration.isLightEstimationEnabled = true
        sceneView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
    }

    func pause() {
        sceneView.session.pause()
    }

    private func update(withFaceAnchor anchor: ARFaceAnchor) {
        rightEyeNode.simdTransform = anchor.rightEyeTransform
        leftEyeNode.simdTransform = anchor.leftEyeTransform

        var eyeLLookAt = CGPoint()
        var eyeRLookAt = CGPoint()

        DispatchQueue.main.async {

            // 进行两个节点和虚拟手机之间的碰撞检测以确定焦点在手机上的位置
            let phoneScreenEyeRHitTestResults = self.virtualPhoneNode.hitTestWithSegment(from: self.lookAtTargetRightEyeNode.worldPosition, to: self.rightEyeNode.worldPosition, options: nil)

            let phoneScreenEyeLHitTestResults = self.virtualPhoneNode.hitTestWithSegment(from: self.lookAtTargetLeftEyeNode.worldPosition, to: self.leftEyeNode.worldPosition, options: nil)

            for result in phoneScreenEyeRHitTestResults {
                eyeRLookAt.x = CGFloat(result.localCoordinates.x) / (self.phoneScreenMeterSize.width / 2) * self.phoneScreenPointSize.width

                eyeRLookAt.y = CGFloat(result.localCoordinates.y) / (self.phoneScreenMeterSize.height / 2) * self.phoneScreenPointSize.height
            }

            for result in phoneScreenEyeLHitTestResults {
                eyeLLookAt.x = CGFloat(result.localCoordinates.x) / (self.phoneScreenMeterSize.width / 2) * self.phoneScreenPointSize.width

                eyeLLookAt.y = CGFloat(result.localCoordinates.y) / (self.phoneScreenMeterSize.height / 2) * self.phoneScreenPointSize.height
            }

            // 取最近的几次j位置以确保不会漂移
            let smoothThresholdNumber: Int = 10
            self.eyeLookAtPositionXs.append((eyeRLookAt.x + eyeLLookAt.x) / 2)
            self.eyeLookAtPositionYs.append(-(eyeRLookAt.y + eyeLLookAt.y) / 2)
            self.eyeLookAtPositionXs = Array(self.eyeLookAtPositionXs.suffix(smoothThresholdNumber))
            self.eyeLookAtPositionYs = Array(self.eyeLookAtPositionYs.suffix(smoothThresholdNumber))

            // 求平均
            let smoothEyeLookAtPositionX = self.eyeLookAtPositionXs.average!
            let smoothEyeLookAtPositionY = self.eyeLookAtPositionYs.average!

            self.lookAtPositionX = Int(round(smoothEyeLookAtPositionX + self.phoneScreenPointSize.width / 2))

            self.lookAtPositionY = Int(round(smoothEyeLookAtPositionY + self.phoneScreenPointSize.height / 2))

            self.updateHandler?(self.lookAtPositionX,self.lookAtPositionY)
        }
    }

}

extension GazeTrackingManager:ARSessionDelegate, ARSCNViewDelegate {  
    // MARK: - ARSCNViewDelegate
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        faceNode.transform = node.transform
        guard let faceAnchor = anchor as? ARFaceAnchor else { return }
        update(withFaceAnchor: faceAnchor)
    }

    // MARK - ARSessionDelegate
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        virtualPhoneNode.transform = (sceneView.pointOfView?.transform)!
    }

    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        faceNode.transform = node.transform
        guard let faceAnchor = anchor as? ARFaceAnchor else { return }
        update(withFaceAnchor: faceAnchor)
    }
}

由于目前ARKit运行机制必须依赖于ARSCNView,因此在GazeTrackingManager中将这个view进行隐藏。使用时在自己的view上增加arView()即可,然后需要启动的时候调用start()方法,其余的所有工作监听updateHandler获取平面点坐标即可。

下面是利用眼控实现的一个web翻页功能,当视线注视的位置接近于屏幕底部位置时自动将内容滚动到中间以方便查看(黄色标识为当前视线的焦点位置)。演示效果:

ARKit First

那么视线追踪能不能结合图虫App完成一些操作呢,考虑到图虫壁纸界面的列表是由UICollectionView布局完成的,每张壁纸由一个较大的独立单元格进行展示,方便实现操作,因此考虑实现这样一个功能:当视线焦点查看到壁纸列表底部时自动滚动壁纸列表,当视线注视着某壁纸一定时间后显示进度(类似于Kinect上的手势选择效果),进度到一定时间后打开壁纸预览。演示效果:

ARKit First

文章来源:

Author:liuxiwei.777
link:https://techblog.toutiao.com/2018/07/03/untitled-50/