Tobii製アイトラッカーで動画再生中の視線追跡（2/3，動画再生中の視線追跡）

前回の記事：
　　[1/3，SDKを用いたアイトラッカー動作](https://qiita.com/takahashiMamo/items/a3328a43001be26b8daf)
次回の記事：
　　[3/3，調整そして完成！](https://qiita.com/takahashiMamo/items/cb1623c2ce115a0fb113)

# はじめに

Tobii社製のアイトラッカー，Tobii Pro スパークを入手したものの，公式ソフトウェアTobii Pro ラボが導入できていないので，Tobii Pro SDKでアプリを自作するしかない！という背景でした．
　前回記事ではチュートリアルプログラムを発展させ，sボタンを押すと開始し，5秒間視線座標を取得し，結果をcsvファイルに書き出すまでを実現しました．今回は視線追跡と動画の再生を，頑張ってシンクロさせていきます．

# まずは動画を再生してみる

モジュールOpenCVを使うのが王道のようです．`cv2`をインポートすると使えます．これは十分な量の情報がウェブにあり，簡単なサンプルプログラムが簡単に見つかります．
```py
import cv2

# 動画ファイルのパス
video_path = "sample.mp4"

# 動画をフルスクリーンで再生する
video_player = cv2.VideoCapture(video_path)
fps = video_player.get(cv2.CAP_PROP_FPS)
cv2.namedWindow("Video", cv2.WND_PROP_FULLSCREEN)
cv2.setWindowProperty("Video", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
while video_player.isOpened():
    ret, frame = video_player.read()
    if ret:
        cv2.imshow("Video", frame)
        cv2.setWindowProperty("Video", cv2.WND_PROP_TOPMOST, 1)  # ウィンドウを最前面に表示
        wait_time = int(1000 / fps)  # フレームレートの逆数をミリ秒に変換
        if cv2.waitKey(wait_time) & 0xFF == ord("q"):
            break
    else:
        break
# ウィンドウを閉じる
cv2.destroyAllWindows()
```
ソースファイルと動画が同じディレクトリにない時は動画ファイルパスは絶対パスで指定しましょう．後での視線追跡との合体のため，動画はフルスクリーン表示を指定しています．
　コマンドの考え方としては，`while`ループで動画の各フレームを`video_player.read()`により取得し順次表示していくものになっています．`while`ループ終了フラグは2つあり，1つは`ret`，これはフレーム読み込みができたかどうかに相当しますが，これがfalseだった場合（動画の最後まで表示すると次で表示フレームがなくなる），もう1つは`wait_time`ミリ秒の間にqキーのストライクがあった場合です．動画再生を途中中断する手段の定番だそうです．`wait_time = int(1000/fps)`とすることで次のフレームが表示されるまで待機し，その間にqキーストライクがあったらループブレイクします．

 **ただしこの方法には重大な欠点がありました** ．おそらくコマンド実行に所要される小さい時間誤差の積み重なりの影響だと思うのですが， **きちんと1秒間に既定のfps枚のフレームが表示されてくれません** ．本連載ではデモとして再生時間17秒のフリー素材動画を用いますが，動画終了までに20数秒かかってしまいます（環境依存性はあると思います）．
　幸いにして， **筆者のアイトラッカーを導入した本来の用途上（記事中では触れない予定）はこの問題は決定的ではありません** ．したがってこういうものだと，仕様だとしてこのスクリプトを採用することにします．しかし，以下の開発ではこの事実をきちんと押さえないと適切な視線取得結果にならないので注意が必要です．

# 動画再生と視線追跡を合体

合体は意外と簡単そうです．前回つくった関数`toggle_streaming()`の中に上記動画再生プログラムを放り込んだらOKでしょう．視線追跡開始`my_eyetracker.subscribe_to`は`while`ループ開始直前，追跡終了`my_eyetracker.unsubscribe_from`は`break`直線でいいのではないでしょうか．そのうえでcsv書き出しは，`break`の後にもってきて動画再生終了後に実行してもらえばいいと思います．

```py
import tobii_research as tr
import time
import keyboard
import cv2

# 動画ファイルのパス
video_path = "sample.mp4"

# アイトラッカー検出
found_eyetrackers = tr.find_all_eyetrackers()
my_eyetracker = found_eyetrackers[0]
gaze_data_list = [] # 視線データを格納するリスト

def gaze_data_callback(gaze_data): 
    gaze_data_list.append(gaze_data)

# キーボードのsキーでストリーミングを開始/停止する
def toggle_streaming():
    # 動画をフルスクリーンで再生する
    video_player = cv2.VideoCapture(video_path)
    fps = video_player.get(cv2.CAP_PROP_FPS)
    cv2.namedWindow("Video", cv2.WND_PROP_FULLSCREEN)
    cv2.setWindowProperty("Video", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
    # 視線追跡・動画再生開始
    my_eyetracker.subscribe_to(tr.EYETRACKER_GAZE_DATA, gaze_data_callback, as_dictionary=True)
    while video_player.isOpened():
        ret, frame = video_player.read()
        if ret:
            cv2.imshow("Video", frame)
            cv2.setWindowProperty("Video", cv2.WND_PROP_TOPMOST, 1)  # ウィンドウを最前面に表示
            wait_time = int(1000 / fps)  # フレームレートの逆数をミリ秒に変換
            # 視線追跡・動画再生終了
            if cv2.waitKey(wait_time) & 0xFF == ord("q"):
                my_eyetracker.unsubscribe_from(tr.EYETRACKER_GAZE_DATA, gaze_data_callback)
                break
        else:
            my_eyetracker.unsubscribe_from(tr.EYETRACKER_GAZE_DATA, gaze_data_callback)
            break
    # ウィンドウを閉じる
    cv2.destroyAllWindows()
    # CSVファイルに視線データを書き込む
    with open('gaze_data.csv', 'w', newline='') as csvfile:
        fieldnames = ['left_eye_x', 'left_eye_y', 'right_eye_x', 'right_eye_y']
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        for data in gaze_data_list:
            left_eye_x = data['left_gaze_point_on_display_area'][0]
            left_eye_y = data['left_gaze_point_on_display_area'][1]
            right_eye_x = data['right_gaze_point_on_display_area'][0]
            right_eye_y = data['right_gaze_point_on_display_area'][1]
            # csv書き込み
            writer.writerow({
                'left_eye_x': left_eye_x,
                'left_eye_y': left_eye_y,
                'right_eye_x': right_eye_x,
                'right_eye_y': right_eye_y
            })

keyboard.add_hotkey('s', toggle_streaming)

# キーボード入力を監視
keyboard.wait()
```
あっさり完成しました．これは良好に動作することが確認できています．

# 結果を可視化してチェック！

さて，「動画再生中の視線追跡」という目標に対する骨組みはもう完成したといっていいでしょう．あとは適宜肉付けです．しかし，ここからが大変です．現時点で75点くらいありそうですが，ここから急にスコアを伸ばすのが大変になります．

以上は視線データの記録でありましたが以降は後処理ですので，新たに別のスクリプトファイルをつくったほうがいいでしょう．
　まず，csvに格納された数値を座標情報として，動画に合成します．動画は[こちらのフリー動画素材サイト](https://video-ac.com/)から17秒の花火動画を頂戴しました．名前をsaple.mp4とします．最初は簡単のため，視線座標データ点数は動画のフレーム数と同じ，30fps*17秒=510（と思いきや実際は512でした）とします．画面上の円周上をぐるぐるまわる点の系列データでも人工生成すればいいでしょう．エクセルで下の通り(x,y)を512点生成します．周回数は17秒かけて10周です：
```math
t_{n} = 1, 2, \cdots , N(=512),\\
x(t_{n}) = 0.4\cos\left(\frac{2\pi t_{n}}{N/10}\right)+0.5,\\
y(t_{n}) = 0.4\sin\left(\frac{2\pi t_{n}}{N/10}\right)+0.5.
```
ただし，実際の視線データとあわせるため，最上行はヘッダーとしています．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3345878/0c1d857d-c9ac-c319-582b-57c5ffca7db9.png)


さて各フレームに対し，座標が指示する箇所に図形を描いて指し示すことにしましょう．GPTがつくってくれました．これもcv2でできるんですね．一方でcsvからのデータリードはnumpyの出番です．
```py
import cv2
import numpy as np

# CSVの読み込み
gaze_data = np.genfromtxt('gaze_data.csv', delimiter=',')
eye_x = gaze_data[1:, 0]
eye_y = gaze_data[1:, 1]

# 動画ファイルの読み込み
cap = cv2.VideoCapture('sample.mp4')

# 動画書き出しの準備
fourcc = cv2.VideoWriter_fourcc(*'mp4v')  # mp4用のコーデックを指定
fps = 30.0  # フレームレート
frame_size = (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))  # フレームサイズを取得
out = cv2.VideoWriter('output.mp4', fourcc, fps, frame_size)

# 各フレームに図形を追加
for i in range(len(eye_x)):
    ret, frame = cap.read()  # フレームの読み込み
    if not ret:  # フレームが読み込めなかった場合
        break

    # 座標をフレームのサイズにスケーリング
    x = int(eye_x[i] * frame_size[0])
    y = int(eye_y[i] * frame_size[1])

    cv2.circle(frame, (x, y), 10, (0, 255, 0), -1)  # 円を追加
    out.write(frame)  # フレームを書き出し

# 後処理
cap.release()
out.release()
cv2.destroyAllWindows()
```
結果は下のGIFのとおりです．ファイルサイズ圧縮のためタテ360pxに，10fpsにしています．このやり方だとファイルサイズがわりと肥大化してしまうようですが，やりたいことはできました．
![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3345878/c4be6780-b13c-5e96-bbb9-21a6a6e948cd.gif)

# 動画再生と視線追跡のシンクロ

では，あとは読み込む視線データファイルをアイトラッカーが生成したgaze_data.csvにすればいいじゃない・・・と思いきやそうはいきません．その意味でここからが大変です．
その理由は，以下の要因のためです．

## 動画がちゃんと1秒間に30フレーム表示されてくれない
上述のとおり，17秒の動画でもcv2で再生すると17秒で終わりません．これは内部処理に起因する？遅延のため1秒間に30よりも少ないフレームしか表示されないためです．実際動画プレーヤで再生するよりも若干のっそりしています．Tobii Pro スパークのサンプルレートは60Hzですので，フレームレートに従った再生速度が実現されていたなら60fpsまたは30fpsの動画となら相性よかったと考えられます．

## Tobii Pro スパークのサンプリングタイミングの実際
前回の5秒動作→csv出力プログラムを実行すると，サンプリングレートが60Hzなので理想的には280データ出力されてくれるはずです．しかし実際には280データほどしか書き出されていません．さらに，これを1秒動作とするとデータ数は40にまでなってしまいます．
　この原因は，`left_gaze_point_on_display_area`など視線座標データを格納していた`gaze_data`に同様に含まれる，タイムスタンプをcsvに出力してみるとわかります．[SDKドキュメントによると](https://developer.tobiipro.com/commonconcepts/timestamp-and-timing.html)，アイトラッカーはDevice TimestampsとSystem Timestampを備えており，前者はPC時計基準，後者はデバイス内部時計基準です．それぞれ`gaze_data.DeviceTimeStamp`ならびに`gaze_data.SystemTimeStamp`で取得することができます．
　動画-視線追跡-csv出力プログラムにて，動画表示while文直前にて再生開始時刻を`start_time = time.time()`で取得し，また視線追跡タイムｓタンプのゼロ点を`record_start_time = gaze_data_list[0]["system_time_stamp"]`と定義して取得したうえで，以下のように改変します：
```py
            sample_time_from_start = (start_time + (data["system_time_stamp"]-record_start_time)/(10**6))
            left_eye_x = data['left_gaze_point_on_display_area'][0]
            left_eye_y = data['left_gaze_point_on_display_area'][1]
            right_eye_x = data['right_gaze_point_on_display_area'][0]
            right_eye_y = data['right_gaze_point_on_display_area'][1]
            # csv書き込み
            writer.writerow({
                'timestamp': sample_time_from_start,
                'left_eye_x': left_eye_x,
                'left_eye_y': left_eye_y,
                'right_eye_x': right_eye_x,
                'right_eye_y': right_eye_y
            })
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3345878/e7e92ecf-a037-d58e-1f19-d29384fea446.png)
`/(10**6)`の演算はタイムスタンプがマイクロ秒単位であることに由来します．上の画像のとおり，タイムスタンプの差分をつかってサンプリング時間間隔を取得してみると，1番目と2番目のデータの間は他の約2倍になってしまっていますし，またサンプリング間隔も1/60~0.016秒ではありますが逆に言えばミリ秒程度の精度しかないことがわかります．

## 対策：タイムスタンプを使ってシンクロさせる→次回
というわけで今回完成したプログラムは，このままだと明らかに精度がわるく使用に耐えなさそうです．今回の記事はここまでですが，次回はなんとかこれを解決します．アイトラッカーが動作中にPC内部時計基準の経過時間をSystem Timestampという名前で取得しているのは大変幸いで，これがあることで原理的には動画再生と視線追跡のシンクロが可能です．すなわち，PC内部基準で「動画フレームが表示された時刻」と「注視点が記録された時刻」をレコードすればいいのです．

ということに次回の記事で挑戦していきます．次回でクローズできそうですね．

# 動画素材提供サイト
[videoAC](https://video-ac.com/)