目線でブラウザをスクロールするアプリを作ってみた

2021.06.16

クラスメソッドに転職してからというもの家族時間が以前より増え、それと同時に晩御飯を作る係に任命される機会も増え、家で料理する回数が増えました。

レシピなんて頭に入っていないので、iPhoneでレシピサイトを開いてそれを見ながら料理をするのですが、料理をしていると手が濡れたり、汚れたりするのでレシピを読み進める為にiPhoneをスクロールするにはその都度手を拭かないといけませんでした。

目でレシピを見ているんだから、ついでに目線でブラウザをスクロールできたら楽になりそうだ。

と思い、目線でブラウザをスクロールするアプリを作ってみることにしました。

作ったもの

環境

  • Xcode 12.5
  • Swift 5.4
  • iOS 14.6
  • iPhone12mini

ARFaceTrackingConfiguration

Apple標準フレームワークARKitの中には様々なAR体験をする為のConfigurationが用意されており、今回は目の動きを活用したいということで、ARFaceTrackingConfigurationで構成していくことにしました。

ARFaceTrackingConfigurationは、デバイスの正面カメラフィードに表示される顔を検出します。ARKitは顔を検出すると、人の顔の位置、向き、トポロジー、および表情に関する情報を提供するオブジェクトを作成します。

何の値を見てブラウザをスクロールさせるか

blendShapesという特定の表情をすると値が取得できるプロパティかEyeTransformから純粋に目の角度だけを取得するか等を検討しましたが、最終的に一番イメージに近い目の動きから目線を推測して目線がブラウザの下部に来たらスクロールする方向に決定しました。

補足

今回の目線というのは、眼球の中心から対象(iPhoneスクリーン側)に向かい水平方向に直線で引かれた仮想空間の線を指しています。

EyeTrackingManager

まずは目の動きを追跡するためのクラスを作っていきます。

import UIKit
import ARKit

protocol EyeTrackingManagerDelegate: AnyObject {
    func didUpdate(lookingPoint: CGPoint)
}

class EyeTrackingManager: NSObject {

    private weak var delegate: EyeTrackingManagerDelegate?

    private var sceneView: ARSCNView!

    private let configuration = ARFaceTrackingConfiguration()

    // 顔のNode
    private var faceNode = SCNNode()

    // 目のNode
    private var leftEyeNode = SCNNode()
    private var rightEyeNode = SCNNode()

    // 目の視線の先のNode
    private var leftEyeTargetNode = SCNNode()
    private var rightEyeTargetNode = SCNNode()

    // 目線の値を格納する配列
    private var lookingPositionXs: [CGFloat] = []
    private var lookingPositionYs: [CGFloat] = []

    // 実際のiPhone12miniの物理的なスクリーンサイズ(m)
    private let phoneScreenMeterSize = CGSize(width: 0.057478, height: 0.124536)

    // 実際のiPhone12miniのポイント値でのスクリーンサイズ
    private let phoneScreenPointSize = CGSize(width: 375, height: 812)

    // 仮想空間のiPhoneのNode
    private var virtualPhoneNode: SCNNode = SCNNode()

    // 仮想空間のiPhoneのScreenNode
    private var virtualScreenNode: SCNNode = {
        let screenGeometry = SCNPlane(width: 1, height: 1)
        return SCNNode(geometry: screenGeometry)
    }()

    init(with sceneView: ARSCNView, delegate: EyeTrackingManagerDelegate) {
        super.init()

        self.sceneView = sceneView
        self.delegate = delegate
        sceneView.delegate = self
        sceneView.session.delegate = self

        // SetupNode
        sceneView.scene.rootNode.addChildNode(faceNode) // 顔のNodeをsceneViewに追加
        sceneView.scene.rootNode.addChildNode(virtualPhoneNode) //仮想空間のiPhoneのNodeをsceneViewに追加
        virtualPhoneNode.addChildNode(virtualScreenNode) // 仮想空間のiPhoneにscreenNodeを追加
        faceNode.addChildNode(leftEyeNode) // 顔のnodeに左目のNodeを追加
        faceNode.addChildNode(rightEyeNode) // // 顔のnodeに右目のNodeを追加
        leftEyeNode.addChildNode(leftEyeTargetNode) //左目のNodeに視線TargetNodeを追加
        rightEyeNode.addChildNode(rightEyeTargetNode) //右目のNodeに視線TargetNodeを追加

        // TargetNodeを目の中心から2メートル離れたところに設定
        leftEyeTargetNode.position.z = 2
        rightEyeTargetNode.position.z = 2
    }

    func runSession() {
        // sessionを開始
        sceneView.session.run(configuration,
                              options: [.resetTracking, .removeExistingAnchors])
    }

    func pauseSession() {
        // sessionを停止
        sceneView.session.pause()
    }
}

以下に詳細を説明します。

プロパティ

Delegate

private weak var delegate: EyeTrackingManagerDelegate?

SceneView

private var sceneView: ARSCNView!

Configuration

今回は顔の情報を追跡していきたいのでARFaceTrackingConfigurationを定義

private let configuration = ARFaceTrackingConfiguration()

Nodeと格納用の配列を定義

顔、目、目線の先のNodeと取得した目線の値を格納する為の配列を準備しておきます。

// 顔のNode
private var faceNode = SCNNode()

// 目のNode
private var leftEyeNode = SCNNode()
private var rightEyeNode = SCNNode()

// 目の視線の先のNode
private var leftEyeTargetNode = SCNNode()
private var rightEyeTargetNode = SCNNode()

// 目線の値を格納する配列
private var lookingPositionXs: [CGFloat] = []
private var lookingPositionYs: [CGFloat] = []

iPhoneスクリーンサイズを定義

目線から取得したポイント値をスクリーン上のポイント値に変換する為に必要になるので、使用する端末の実寸スクリーンサイズ(m)とポイント値でのスクリーンサイズを定義しています。

// 実際のiPhone12miniの物理的なスクリーンサイズ(m)
private let phoneScreenMeterSize = CGSize(width: 0.057478, height: 0.124536)

// 実際のiPhone12miniのポイント値でのスクリーンサイズ
private let phoneScreenPointSize = CGSize(width: 375, height: 812)

今回は1デバイスのみの使用しか考えていなかった為、端末の物理的なスクリーンサイズは、こちらのサイトで計算させていただきました。

仮想空間上のiPhoneのNodeを定義

現実空間と同じように仮想空間にiPhoneを作るためのNodeを準備しておきます

// 仮想空間のiPhoneのNode
private var virtualPhoneNode: SCNNode = SCNNode()

// 仮想空間のiPhoneのScreenNode
private var virtualScreenNode: SCNNode = {
    let screenGeometry = SCNPlane(width: 1, height: 1)
    return SCNNode(geometry: screenGeometry)
}()

initメソッド

パラメーターとして取得したsceneViewを代入

self.sceneView = sceneView

各Delegateの設定

self.delegate = delegate
sceneView.delegate = self
sceneView.session.delegate = self

プロパティで宣言したNodeをセットアップ

sceneView.scene.rootNode.addChildNode(faceNode) // 顔のNodeをsceneViewに追加
sceneView.scene.rootNode.addChildNode(virtualPhoneNode) //仮想空間のiPhoneのNodeをsceneViewに追加
virtualPhoneNode.addChildNode(virtualScreenNode) // 仮想空間のiPhoneにscreenNodeを追加
faceNode.addChildNode(leftEyeNode) // 顔のnodeに左目のNodeを追加
faceNode.addChildNode(rightEyeNode) // // 顔のnodeに右目のNodeを追加
leftEyeNode.addChildNode(leftEyeTargetNode) //左目のNodeに視線TargetNodeを追加
rightEyeNode.addChildNode(rightEyeTargetNode) //右目のNodeに視線TargetNodeを追加

TargetNodeを目の中心から2メートル離れたところに設定

leftEyeTargetNode.position.z = 2
rightEyeTargetNode.position.z = 2

イラストにするとこんな感じです。

セッションの呼び出しと停止

func runSession() {
    // sessionを開始
    sceneView.session.run(configuration,
                          options: [.resetTracking, .removeExistingAnchors])
}

func pauseSession() {
    // sessionを停止
    sceneView.session.pause()
}

sceneView.session.runでセッションの呼び出しを行い、sceneView.session.pauseでセッションの停止を行います。

ARARSCNViewDelegate

Delegateメソッドの処理を設定します。

extension EyeTrackingManager: ARSCNViewDelegate {

    // 新しい顔のNodeが追加されたら
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {

        // faceAnchorを取得
        faceNode.transform = node.transform
        guard let faceAnchor = anchor as? ARFaceAnchor else { return }

        update(withFaceAnchor: faceAnchor)
    }

renderer(_:didAdd:for:)

新しいARアンカーに対応するSceneKitノードがシーンに追加されたことをデリゲートに通知します。

通知を受けたら、今回は追加されたNodeをfaceNode.transfromに代入して、ARAnchorから目線のデータを取得していきます。

update(withFaceAnchor:)

func update(withFaceAnchor anchor: ARFaceAnchor) {

    // 各eyeNodeのsimdTransfromにARFaceAnchorのeyeTransfromを代入
    rightEyeNode.simdTransform = anchor.rightEyeTransform
    leftEyeNode.simdTransform = anchor.leftEyeTransform

    var leftEyeLookingPoint = CGPoint()
    var rightEyeLookingPoint = CGPoint()

    DispatchQueue.main.async {

        // 仮想空間に配置したiPhoneNodeのHitTest
        // 目の中心と2m先に追加したTargetNodeの間で、virtualPhoneNodeとの交点を調べる
        let phoneScreenEyeRightHitTestResults = self.virtualPhoneNode.hitTestWithSegment(from: self.rightEyeTargetNode.worldPosition, to: 
        self.rightEyeNode.worldPosition, options: nil)

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


        // HitTestの結果から各xとyを取得
        for result in phoneScreenEyeRightHitTestResults {

            rightEyeLookingPoint.x = CGFloat(result.localCoordinates.x) / self.phoneScreenMeterSize.width * self.phoneScreenPointSize.width
            rightEyeLookingPoint.y = CGFloat(result.localCoordinates.y) / self.phoneScreenMeterSize.height * self.phoneScreenPointSize.height
        }

        for result in phoneScreenEyeLeftHitTestResults {

            leftEyeLookingPoint.x = CGFloat(result.localCoordinates.x) / self.phoneScreenMeterSize.width * self.phoneScreenPointSize.width
            leftEyeLookingPoint.y = CGFloat(result.localCoordinates.y) / self.phoneScreenMeterSize.height * self.phoneScreenPointSize.height
        }

        // 最新の位置を追加し、直近の10通りの位置を配列で保持する
        let suffixNumber: Int = 10
        self.lookingPositionXs.append((rightEyeLookingPoint.x + leftEyeLookingPoint.x) / 2)
        self.lookingPositionYs.append(-(rightEyeLookingPoint.y + leftEyeLookingPoint.y) / 2)
        self.lookingPositionXs = Array(self.lookingPositionXs.suffix(suffixNumber))
        self.lookingPositionYs = Array(self.lookingPositionYs.suffix(suffixNumber))

        // 取得した配列の平均を出す
        let avarageLookAtPositionX = self.lookingPositionXs.average
        let avarageLookAtPositionY = self.lookingPositionYs.average

        let lookingPoint = CGPoint(x: avarageLookAtPositionX, y: avarageLookAtPositionY)
        self.delegate?.didUpdate(lookingPoint: lookingPoint)
    }
}

取得したEyeTransform情報を各EyeNodeプロパティに代入

// 各eyeNodeのsimdTransfromにARFaceAnchorのeyeTransfromを代入
rightEyeNode.simdTransform = anchor.rightEyeTransform
leftEyeNode.simdTransform = anchor.leftEyeTransform

HitTest

目の中心と2m先に追加したTargetNodeの間で、virtualPhoneNodeとの交点を調べる

let phoneScreenEyeRightHitTestResults = self.virtualPhoneNode.hitTestWithSegment(from: self.rightEyeTargetNode.worldPosition, to: 
self.rightEyeNode.worldPosition, options: nil)

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

イラストにするとこんな感じです。

HitTestの結果をアプリ上で反映できるポイントに変換

for result in phoneScreenEyeRightHitTestResults {

    rightEyeLookingPoint.x = CGFloat(result.localCoordinates.x) / self.phoneScreenMeterSize.width * self.phoneScreenPointSize.width
    rightEyeLookingPoint.y = CGFloat(result.localCoordinates.y) / self.phoneScreenMeterSize.height * self.phoneScreenPointSize.height
}

for result in phoneScreenEyeLeftHitTestResults {

    leftEyeLookingPoint.x = CGFloat(result.localCoordinates.x) / self.phoneScreenMeterSize.width * self.phoneScreenPointSize.width
    leftEyeLookingPoint.y = CGFloat(result.localCoordinates.y) / self.phoneScreenMeterSize.height * self.phoneScreenPointSize.height
}

アプリ上で反映できるポイント値に変換し、各EyeLookingPointx,yに代入しています

目線の動きをスムーズにする為に値を配列で保持し、平均を出す

// 最新の位置を追加し、直近の10通りの位置を配列で保持する
let suffixNumber: Int = 10
self.lookingPositionXs.append((rightEyeLookingPoint.x + leftEyeLookingPoint.x) / 2)
self.lookingPositionYs.append(-(rightEyeLookingPoint.y + leftEyeLookingPoint.y) / 2)
self.lookingPositionXs = Array(self.lookingPositionXs.suffix(suffixNumber))
self.lookingPositionYs = Array(self.lookingPositionYs.suffix(suffixNumber))

// 保持している配列の平均を出す
let avarageLookAtPositionX = self.lookingPositionXs.average
let avarageLookAtPositionY = self.lookingPositionYs.average

左右のLookingPointを合計して割った値を目線のポイントとしてをxyそれぞれの配列に追加していきます。

配列で保持している直近10要素の平均値を計算するavarageプロパティから各avarageLookAtPositionに代入しています。

extension Collection where Element == CGFloat {

    var average: CGFloat {
        let totalNumber = sum()
        return totalNumber / CGFloat(count)
    }

    private func sum() -> CGFloat {
            return reduce(CGFloat(0), +)
        }

計算した目線のポイントをDelegateメソッドの引数にセット

let lookingPoint = CGPoint(x: avarageLookAtPositionX, y: avarageLookAtPositionY)
self.delegate?.didUpdate(lookingPoint: lookingPoint)

EyeTrackingManagerDelegateのメソッドdidUpdate(lookingPoint:)の引数に計算した目線のポイントlookingPointを渡しています。

ARSessionDelegate

extension EyeTrackingManager: ARSessionDelegate {

    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {

        // 現在のカメラのTransfromを取得し、virtualPhoneNodeに代入
        guard let pointOfViewTransfrom = sceneView.pointOfView?.transform
        else { return }
        virtualPhoneNode.transform = pointOfViewTransfrom
    }

    // アンカーが更新されたら
    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)
    }
  • renderer(renderer: , time: TimeInterval)はフレーム毎に呼び出されるメソッドで、この中で現在のカメラの位置・向き等を取得して仮想空間に設置しているvirtualPhoneNodeに値を代入しています。
  • renderer(_:didUpdate:for:)はアンカー情報が更新された時に呼ばれるので、この時にも上で記述したupdate(withFaceAnchor:)を呼んでいます。

WebViewで自動スクロールする機能を実装

UIScrollView Extension

extension UIScrollView {

    func scrollUp() {
        DispatchQueue.main.async {
            for _ in 1...10 {
                if  self.topInsetY >= self.contentOffset.y {
                    return
                }
                self.contentOffset.y = self.contentOffset.y - 1
            }
        }
    }

    func scrollDown() {
        DispatchQueue.main.async {
            for _ in 1...10 {
                if  self.bottomInsetY <= self.contentOffset.y {
                    return
                }
                self.contentOffset.y = self.contentOffset.y + 1
            }
        }
    }

// MARK: - Edge Inset Properties

    private var bottomInsetY: CGFloat {
        let inset = self.contentSize.height - self.bounds.height + self.contentInset.bottom
        return inset
    }

    private var topInsetY: CGFloat {
        let inset = -self.contentInset.top
        return inset
    }
}

ページを永続的にスクロールできてしまうと困るので、スクロールの上限と下限をプロパティとして持っておき、スクロールさせる度に限度値に到達していないかチェックを行い、限度値を超えている場合はスクロールを行わないようにしています。

WKWebView Extension

import WebKit

extension WKWebView {

    func loadPage(with urlString: String) {
        guard let url = URL(string: urlString) else { return }
        let request = URLRequest(url: url)
        self.load(request)
    }

    func scrollByLookingAt(at position: CGFloat) {
        if position >= self.bounds.maxY {
            self.scrollView.scrollDown()
        } else if position < self.bounds.minY {
            self.scrollView.scrollUp()
        }
    }
}

Webページを呼び出すためのloadPage(with:)メソッドと、目線がWebViewTop又はBottomにある場合にスクロール処理を行うscrollByLookingAt(at:)を作成しました。

EyeScrollableWebViewController

あとは、ViewControllerEyeTrackingManagerクラスやWKWebViewに処理を託します。

import UIKit
import ARKit
import WebKit

class EyeScrollableViewController: UIViewController {

    @IBOutlet private weak var sceneView: ARSCNView!
    @IBOutlet private weak var webView: WKWebView!
    @IBOutlet private weak var pointerView: UIImageView!

    private var eyeTrackingManager: EyeTrackingManager!

    private let recipeUrlString = "https://cookpad.com/recipe/3514965"

    override func viewDidLoad() {
        super.viewDidLoad()

        eyeTrackingManager = EyeTrackingManager(with: sceneView, delegate: self)
        webView.loadPage(with: recipeUrlString)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        eyeTrackingManager.runSession()
        UIApplication.shared.isIdleTimerDisabled = true
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        eyeTrackingManager.pauseSession()
        UIApplication.shared.isIdleTimerDisabled = false
    }
}

extension EyeScrollableViewController: EyeTrackingManagerDelegate {

    func didUpdate(lookingPoint: CGPoint) {
        pointerView.transform = CGAffineTransform(translationX: lookingPoint.x, y: lookingPoint.y)
        webView.scrollByLookingAt(at: pointerView.frame.minY)
    }
}
  • viewDidLoad()では、EyeTrackingManagerを初期化し、webViewでURL(今回はお好み焼きレシピ)先を呼び出し表示しています。
  • viewWillAppearでは、sceneViewのセッションを開始し、UIApplication.shared.isIdleTimerDisabledtrueを代入しています。
  • viewWillDisappearでは、sceneViewのセッションを停止し、UIApplication.shared.isIdleTimerDisabledfalseを代入しています。

UIApplication.shared.isIdleTimerDisabled

iPhoneでレシピを見ながら料理をしていると、一定時間画面を触らないので画面ロックがかかって、パスワードロック解除しないといけないという面倒くさいポイントがあったのですが、

UIApplication.shared.isIdleTimerDisabled = true

このプロパティのデフォルト値はfalseです。ほとんどのアプリがユーザー入力として短時間タッチしない場合、システムはデバイスを「スリープ」状態にして画面が暗くなります。これは電力を節約する目的で行われます。ただし、ユーザー入力がないアプリは加速度計(ゲームなど)を除いて、このプロパティをtrueに設定することで、「アイドルタイマー」を無効にしてシステムのスリープを回避できます。

こちらをtrueにすることで画面ロック問題が解決できることが分かりました。

EyeTrackingManagerDelegate

func didUpdate(lookingPoint: CGPoint) {
    pointerView.transform = CGAffineTransform(translationX: lookingPoint.x, y: lookingPoint.y)
    webView.scrollByLookingAt(at: pointerView.frame.minY)
}

目線のポイントが更新される度に呼び出されるdidUpdate(lookingPoint:)処理の中で、その渡されたlookingPointの位置にpointerViewの位置を移動させています。 またその時にpointerViewがいる位置を見て、WebViewのscrollByLookingAt(at:)メソッドを実行しています。

おわりに

FaceTrackingで目の動きを追跡するというのは数年前からある技術で、今回はこちらのGithubリポジトリを参考にさせていただき開発を進めていきました。

この他にもすでにアイトラッキングを活用したアプリをリリースされている方はいらっしゃり、色々な事例を参考にさせていただきました。ありがとうございました。

また顔認証に対応しているiPhoneではないと利用できない機能なのでまだ全ての人が利用するのは難しいですが、逆にいうと顔認証対応のiPhoneさえあれば使える機能なので以前に比べると身近になってきているのではないでしょうか。

元々は最初にお伝えしたように料理の時に手が濡れるのでその度拭くのが面倒くさいという自分自身の課題を解決する為に作成しましたが、使い方によっては世の中の様々な課題を解決できるのではないかと色々な方からの意見を聞いたりして、このFaceTracking機能だけではなくARプログラミングの可能性をとても感じました。

AR初心者なので、間違いやこうした方が良いなどのご意見ありましたら優しく教えていただけたら幸いです🙇‍♂️

これからも何か技術を通じて楽しいをお届けできたらと思います☺︎

おまけ

この一件をキッカケに人生初めてのねとらぼデビューができました。笑

またこのアプリがきっかけでTwitterがバズったりして人生何があるか分かりませんね、、。笑

参考