目線でブラウザをスクロールするアプリを作ってみた
クラスメソッドに転職してからというもの家族時間が以前より増え、それと同時に晩御飯を作る係に任命される機会も増え、家で料理する回数が増えました。
レシピなんて頭に入っていないので、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 }
アプリ上で反映できるポイント値に変換し、各EyeLookingPoint
のx
,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
を合計して割った値を目線のポイントとしてをx
、y
それぞれの配列に追加していきます。
配列で保持している直近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:)
メソッドと、目線がWebView
のTop
又はBottom
にある場合にスクロール処理を行うscrollByLookingAt(at:)
を作成しました。
EyeScrollableWebViewController
あとは、ViewController
でEyeTrackingManager
クラスや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.isIdleTimerDisabled
をtrue
を代入しています。viewWillDisappear
では、sceneViewのセッションを停止し、UIApplication.shared.isIdleTimerDisabled
をfalse
を代入しています。
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がバズったりして人生何があるか分かりませんね、、。笑