はじめに
aptpod Advent Calendar 2018 21日目を担当させて頂きます上野と申します。
普段はiOSやAndroidのスマートフォン、macOSやWindowsのようなPC、ネイティブアプリ系の開発をメインで行なっていて、テクニカルな所の調査もしたりしてます。
今回弊社aptpodのアドベントカレンダーに参加させていただくので
せっかくなので普段アプリではどんなことををやっているのー?
そう言えば最近こんな技術出たねーといったことが一度に見る事が記事にしたいと思います。
今回フォーカスする点
aptpodではintdashと呼ばれるプラットフォームを開発していて、
その中でM2M(Machine-to-Machine)双方間でセンサーやGPS、カメラで得た映像の様なデータを計測・収集、伝送するような事を行なっています。
そのデータを計測・収集するLoggerと言う役割がiPhoneではお手軽に出来てしまうんですが
今回お見せしたいものがARKit2.0で出来る「Eye-Tracking」です。
この記事ではiPhoneで収集できる 目の向き と 顔の向き を元に実際に どこを見ているのか のデータを収集し、可視化、収集したデータをリプレイ再生する所までのデモアプリを開発していきたいと思います。
サンプルをみてみる
Appleの方で、顔系のARKitのサンプルアプリのプロジェクトを公開していたので確認してみます。
、、、すごい。笑
このサンプルだけでもかなりの機能が確認出来ますね。
ビルド及び動作にはXcodeプロジェクトに ARKitFrameworkの追加 が必要だったの
と iPhoneX以降のFaceIDが利用できる端末 でかつ iOS12以降 である必要があります。
しれっとモザイクをかけていてメインではないので深くは語りませんが
下記コードの様にCIFilterで出力する画像に対してフィルターを簡単にかけれます。(※参考)
/// - Tag: ARFaceGeometryUpdate
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
// ARKit 設定時にカメラからの画像が空で渡されるのでその場合は処理しない
guard let cuptureImage = sceneView.session.currentFrame?.capturedImage else {
return
}
// PixelBuffer を CIImage に変換しフィルターをかける
let ciImage = CIImage.init(cvPixelBuffer: cuptureImage)
let filter:CIFilter = CIFilter(name: "CIPixellate")!
filter.setValue(ciImage, forKey: kCIInputImageKey)
// モザイクのブロックの大きさ
filter.setValue(15, forKey: "inputScale")
// CIImage を CGImage に変換して背景に適応
// カメラ画像はホーム右のランドスケープの状態で画像が渡されるため、CGImagePropertyOrientation(rawValue: 6) でポートレートで正しい向きに表示されるよう変換
let context = CIContext()
let result = filter.outputImage!.oriented(CGImagePropertyOrientation(rawValue: 6)!)
if let cgImage = context.createCGImage(result, from: result.extent) {
sceneView.scene.background.contents = cgImage
}
guard anchor == currentFaceAnchor,
let contentNode = selectedContentController.contentNode,
contentNode.parent == node
else { return }
selectedContentController.renderer(renderer, didUpdate: contentNode, for: anchor)
}
サンプルから読み解くと ARSCNView の中に session(ARSession)を持っていて、
ARSessionにDelegateだったりRunScriptが存在する様ですね。
/// - Tag: ARFaceTrackingSetup
func resetTracking() {
guard ARFaceTrackingConfiguration.isSupported else { return }
let configuration = ARFaceTrackingConfiguration()
configuration.isLightEstimationEnabled = true
sceneView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
}
実際にデモアプリを作りながらデータを可視化してみる
ARKit2の視線・見ている方向を可視化するデモ #ARKit #ARKit2 #iOS #AR #デモ pic.twitter.com/AbJoDgcM63
— aptueno (@aptueno) 2018年12月20日
ARKitで一般に公開されているものはサンプル同様SceneKitを利用して3D空間上に表示しているものが多いので
今回は平面上で見れる様にしたいと思います。
※割とiOSアプリ開発で一般的な事は省略しながら進めますのでご了承ください。
取り敢えず目のデータを取得してみる
今回はサンプルの様に顔や3Dオブジェクトを描画することはしないので Single View App でプロジェクトを開発します。
プロジェクトを作り終えたら描画はしませんがカメラは利用するので Info.plist にカメラのプライバシー宣言をしましょう。
サンプルアプリ同様、 ARKit.framework の追加も忘れずに。
これで準備は完了でさっそくコードを書いていきます。
デフォルトで作られる ViewController.swift に書いていきます。
import UIKit
import ARKit
class ViewController: UIViewController {
// ARSession
let session = ARSession()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.session.delegate = self
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// "Reset" to run the AR session for the first time.
resetTracking()
}
}
//MARK:- ARSession
extension ViewController: ARSessionDelegate {
func resetTracking() {
guard ARFaceTrackingConfiguration.isSupported else { return }
let configuration = ARFaceTrackingConfiguration()
configuration.isLightEstimationEnabled = true
self.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
}
//MARK:- ARSessionDelegate
func session(_ session: ARSession, didUpdate frame: ARFrame) {
frame.anchors.forEach { anchor in
guard #available(iOS 12.0, *), let faceAnchor = anchor as? ARFaceAnchor else { return }
// FaceAnchorから左、右目の位置や向きが取得可能。
let left = faceAnchor.leftEyeTransform
print("left:\(left)")
let right = faceAnchor.rightEyeTransform
print("right:\(right)")
}
}
func session(_ session: ARSession, didFailWithError error: Error) {}
}
いざ、実行。
なんと言う事でしょう、、たった数十行のコードで取得が可能な様です。
通常カメラアプリを作る際はデバイスがなんだの、レイヤー、サンプルがなんだのと
設定が必要ですがたったこれだけで動くとは驚きです。
取得したデータについて
取得した左目や右目の情報(leftEyeTransform)はsimd_float4x4で取得ができます。
3D系の開発経験がない人からしたら「なんなんだこれは、、、」と思うかもしれませんが
3Dに置ける 座標、向き 等の情報でサンプルアプリでは
下記の様に左目、右目のNodeにそれらを入れることで向きや位置を設定しています。
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
guard #available(iOS 12.0, *), let faceAnchor = anchor as? ARFaceAnchor else { return }
rightEyeNode.simdTransform = faceAnchor.rightEyeTransform
leftEyeNode.simdTransform = faceAnchor.leftEyeTransform
}
取得したデータを加工する
取得したデータの各情報や3Dに関する事も書いてみたいですが今回はデモを作る事に専念するので
深くは書きませんがある程度利用できるデータの加工を行います。
まず取得出来たtransformからに置ける角度を取得します。
let leftX = faceAnchor.leftEyeTransform.columns.2.x
ここで取得できた値はラジアン(弧度法)値です。一般的にラジアンの値で角度を表現する人はいませんから度(度数法)数値に変換します。
extension FloatingPoint {
var degreesToRadians: Self { return self * .pi / 180 }
var radiansToDegrees: Self { return self * 180 / .pi }
}
let leftX = faceAnchor.leftEyeTransform.columns.2.x
self.leftEyeXLabel.text = String.init(format: "x:%0.2f degrees", leftX.radiansToDegrees)
ですが、実はこれだけではダメなんです。
この leftEyeTransform で取得出来る角度の値は ローカル座標系
(この例で言うと顔を正面からみた時の目の見ている角度) なので
実際に 見ている方向を得る には 顔の角度情報を足してあげる と良いでしょう。
3D空間における各座標系については詳しくは説明しませんが
良い記事があったので気になる方は下記の記事等を見てみてください。
・ARKitのための3D数学
顔の角度情報は leftEyeTransform を取得した faceAnchor の transform から取得出来ますが
let leftX = faceAnchor.transform.columns.2.x + faceAnchor.leftEyeTransform.columns.2.x
self.leftEyeXLabel.text = String.init(format: "x:%0.2f degrees", leftX.radiansToDegrees)
今回の可視化は3Dではなく平面的に表示しようと考えているので少し変えようと思います。
var faceNode = SCNNode()
var faceTargetNode = SCNNode()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
// Face Target
self.faceTargetNode.position = SCNVector3(x: 0, y: 0, z: 1)
self.faceNode.addChildNode(self.faceTargetNode)
}
上記コードが何をやっているかと言うと親のノードに対してZ軸をずらした子ノードを追加しています。
3D上ではこんな感じ。
この親ノードの角度を動かすと子ノードの位置が親ノードの動いた角度分動きます。
学生の頃、数学で習った人も多いかと思いますが2点間の位置が分かれば角度がわかります。
← 画像をお借りした場所:2点間の距離と角度と座標の求め方
func radians(v1: Float, v12: Float, v2: Float, v22: Float) -> Float {
return atan2f(v22 - v12, v2 - v1)
}
上記の処理を利用して平面上の角度を求めるとこんな感じ。
func session(_ session: ARSession, didUpdate frame: ARFrame) {
frame.anchors.forEach { anchor in
guard #available(iOS 12.0, *), let faceAnchor = anchor as? ARFaceAnchor else { return }
// Face Node
self.faceNode.simdTransform = faceAnchor.transform
// Left Radians
let leftX = radians(v1: self.faceNode.worldPosition.z,
v12: self.faceNode.worldPosition.x,
v2: self.faceTargetNode.worldPosition.z,
v22: self.faceTargetNode.worldPosition.x)
+ faceAnchor.leftEyeTransform.columns.2.x
self.leftEyeXLabel.text = String.init(format: "x:%0.2f degrees", leftX.radiansToDegrees)
let leftY = faceAnchor.transform.columns.2.y + faceAnchor.leftEyeTransform.columns.2.y
self.leftEyeYLabel.text = String.init(format: "y:%0.2f degrees", leftY.radiansToDegrees)
// Right Radians
let rightX = radians(v1: self.faceNode.worldPosition.z,
v12: self.faceNode.worldPosition.x,
v2: self.faceTargetNode.worldPosition.z,
v22: self.faceTargetNode.worldPosition.x)
+ faceAnchor.rightEyeTransform.columns.2.x
self.rightEyeXLabel.text = String.init(format: "x:%0.2f degrees", rightX.radiansToDegrees)
let rightY = faceAnchor.transform.columns.2.y + faceAnchor.rightEyeTransform.columns.2.y
self.rightEyeYLabel.text = String.init(format: "y:%0.2f degrees", rightY.radiansToDegrees)
}
データを可視化する
データの可視化は度数で値を入力しアニメーションする様にしました。
CAShapeLayerとUIBezierPathを利用しマスクレイヤーを作成し扇型をつくっています。
コードが汚くなってしまったのとデザインに関してはここには記述しませんが割と簡単に作成する事ができます。
var degrees: CGFloat = 0 {
didSet {
UIView.animate(withDuration: 0) {
self.directionView.transform = CGAffineTransform.init(rotationAngle: (self.offsetDegrees+self.degrees).degreesToRadians)
}
}
}
回転するアニメーションは加工した度数値で rotationAngle するだけ良くて簡単ですね。
録画・リプレイ機能をつけてみる
ARKit2視線可視化デモ - リプレイまで #ARKit2 #Demo pic.twitter.com/m2ND1u81mC
— aptueno (@aptueno) 2018年12月20日
可視化はできましたが視線情報は自分で確認しづらいので録画・リプレイ機能をつけてみたいと思います。
struct LineOfSightUnit {
var timeStamp: TimeInterval
var faceEyeTransform: simd_float4x4
var leftEyeTransform: simd_float4x4
var rightEyeTransform: simd_float4x4
}
var lastLineOfSightObj: LineOfSightUnit?
var baseTime: TimeInterval? = nil
var recordedList: [LineOfSightUnit] = [LineOfSightUnit]()
func startRecording() {
self.recordedList.removeAll()
self.baseTime = nil
self.isRecording = true
}
func stopRecording() {
self.isRecording = false
}
func session(_ session: ARSession, didUpdate frame: ARFrame) {
frame.anchors.forEach { anchor in
guard #available(iOS 12.0, *), let faceAnchor = anchor as? ARFaceAnchor else { return }
// Keep Time.
let date = Date()
let obj = LineOfSightUnit.init(
timeStamp: date.timeIntervalSince1970,
faceEyeTransform: faceAnchor.transform,
leftEyeTransform: faceAnchor.leftEyeTransform,
rightEyeTransform: faceAnchor.rightEyeTransform)
self.lastLineOfSightObj = obj
if self.isRecording {
if self.baseTime == nil {
self.baseTime = date.timeIntervalSince1970
}
self.recordedList.append(obj)
self.unitStatusLabel.text = "\(self.recordedList.count.commaString) units saved"
}
...
}
まずはこんな感じで出力されたデータを保持しておきます。
func startReplay() {
self.isReplay = true
var delay: TimeInterval = 0
DispatchQueue.global().async {
while(self.isReplay) {
guard self.isReplay, let baseTime = self.baseTime, self.recordedList.count > 0, self.isRunning else {
Thread.sleep(forTimeInterval: self.REPLAY_THREAD_INTERVAL)
continue
}
let obj = self.recordedList[self.replayCnt]
DispatchQueue.main.async {
// Current Time
self.currentTimeLabel.text = (obj.timeStamp - baseTime).timeString
// Units Status
self.unitStatusLabel.text = "\(self.replayCnt.commaString) / \(self.recordedList.count.commaString) units"
// Face Transform
self.faceNode.simdTransform = obj.faceEyeTransform
// Left Transform
let leftX = self.radians(v1: self.faceNode.worldPosition.z, v12: self.faceNode.worldPosition.x, v2: self.faceTargetNode.worldPosition.z, v22: self.faceTargetNode.worldPosition.x) + obj.leftEyeTransform.columns.2.x
self.leftEyeXLabel.text = String.init(format: "x:%0.2f degrees", leftX.radiansToDegrees)
self.leftEyeXView.degrees = CGFloat(leftX.radiansToDegrees)
// 各ラベルやデザインにデータを入れる処理
...
self.replayCnt += 1
if self.replayCnt >= self.recordedList.count {
self.replayCnt = 0
delay = self.REPLAY_THREAD_INTERVAL
} else {
delay = self.recordedList[self.replayCnt].timeStamp - obj.timeStamp
}
Thread.sleep(forTimeInterval: delay)
}
}
}
}
func stopReplay() {
self.isReplay = false
self.replayCnt = 0
}
後はリプレイ様の関数を呼び出して、フラグがたっている間別スレッドで貯めたデータをループして再生します。
スレッドでは次のユニットのタイムスタンプとの差分の時間でDelayを行い、プレイヤーっぽいものを作ってみました。
まとめ
ここまでで全てしっかりとは解説できてないですが今回作成したデモについてざっくりとした解説は以上です。
座標の計算方法や、その他の処理は他にもやり方はあるとは思いますがARKit自体がとても簡単なので試す価値ありです。
今回作成したデモはgithubで公開しますので興味ある方は動かしてみてください。
・iOS-VisualizeLineOfSight-Demo
またARKitのアドベントカレンダーもある様なので興味ある方はみてもいいかもしれません。
・ARKit Advent Calendar 2018