はじめに
この記事は サイバーエージェント26卒内定者 Advent Calendarの10日目として投稿しています。
昨日に引き続き本日もアドカレを担当します!nashiと申します。
今回は視線計測(eye tracking)という、かなり(?)ニッチなテーマを扱います。
とはいえ、専門的な話を深掘りする気はありません。「詳しくない人でもなんとなく雰囲気が分かる」を目指してラフに書いています。
この記事では、視線計測データを動画に重ねて描画する際に私が苦戦したお話をします。
そもそも視線計測データって...?
視線計測(eye tracking)は、ユーザーが画面のどこを見ているかを
時系列の座標データとして記録する技術です。
つまり、ざっくり言えば、
- ある時刻 t に、ユーザーの視線が画面の (x, y) にあった
という情報がずらっと並んでいるだけのデータです。
例えば、以下のようなデータがあったとします。
| timestamp(ms) | x | y |
|---|---|---|
| 150 | 20 | 100 |
| 400 | 140 | 40 |
これは 150ms の時点では視線が (20,100) にあり、
400ms の時点では (140,40) にあったことを示しています。
これを画像にするとこんな感じです。
点がその時刻の視線位置を表していて、線は視線の移動を示します。
何に使うのか
視線計測というと研究用途のイメージが強いかもしれませんが、実はかなり身近なところでも使われています。
たとえばゲーム「ぷよぷよ eスポーツ」では、プレイヤーがどこを見ているかがそのまま研究対象になっています。
→ 記事
また、視線計測を使って配信しているプレイヤーもいます。
→ @falpassym さん(YouTube)
(視線がリアルタイムでどう動くのか、かなりイメージしやすいです!)
他にも広告分野での注視点分析、UI/UX改善、心理学・神経科学など、
「目は口ほどに物を言う」の言葉どおり、人の思考や意図を探るヒントとして幅広く利用されています。
このように用途は意外と広く、データの可視化の手法も画像・動画問わず様々あるのですが、今回は動画特有のFPSに悩まされたお話をします。
FPS(Frames Per Second:フレーム毎秒)とは、1秒間に表示・処理される画像(フレーム)の枚数を表す指標です。値が高いほど動きが滑らかに見えます。
最近の話題で言うと、switchは最大60fpsであったのが、switch2は最大120fpsとユーザー体験が向上しましたね。(First-Person Shooterの話題だと思ってた人はすみません...)
サンプリングレートと戦う
視線計測におけるサンプリングレート(Hz)は1秒間に何回視線データを取得するかを表します。
例えばサンプリングレートが120Hzのものであれば約8.33ms(=1000ms/120Hz)に一回データを取得していることになります。
ただし、サンプリングにも誤差はつきものです。必ずしもデータの一行目が8.33msでの視線の位置とは限りません。そのため、timestampのようなカラムを用意し、正確な測定時間を記録しておくのが一般的です。
ここからは例として、20fpsの動画と100Hzの視線データを重ねることを考えます。
- 動画:1秒間に 20 枚のフレーム
→ 1フレームあたり 50ms - 視線データ:1秒間に 100 サンプル
→ 1サンプルあたり 10ms
つまり動画 1 フレームの間に、視線データが 5 回 記録されている計算になります。
ここで問題になるのは、「動画のフレーム時刻」と「視線データの timestamp」が必ずしも一致しないという点です。
例えば動画の100msフレーム(2フレーム目)を描画したいとき、視線データはこんなふうに誤差を含んでちょうど100msのデータがないことがほとんどです。
| timestamp(ms) | x | y |
|---|---|---|
| 90.2 | 20 | 100 |
| 99.9 | 30 | 90 |
| 109.9 | 40 | 85 |
残念ながらdf[df['timestamp']==100]で取ってこれるほど甘くはないです。(FPSめ...)
こう言ったケースにどう対処すれば良いのでしょうか。
代表値を使う
手っ取り早いのが、対象のフレームに最も近いデータを代表値として扱うことです。
データを前から順に読み込み、代表値を見つけては描画を繰り返します。
ただ、代表値を使うにしても、色々気をつけないといけません...。
視線データのデータフレームをgazeとし、任意の時刻に最も近いデータを取得することを考えます。
def find_nearest_xy(gaze, target_timestamp):
prev_row = None
for _, row in gaze.iterrows():
if row["timestamp"] >= target_timestamp:
# 最初の行から target を追い越したとき(0フレーム目での対応)
if prev_row is None:
return row["x"], row["y"]
# 前後どちらが近いか比較して決める
before_diff = abs(prev_row["timestamp"] - target_timestamp)
after_diff = abs(row["timestamp"] - target_timestamp)
return (prev_row["x"], prev_row["y"]) if before_diff <= after_diff else (row["x"], row["y"])
prev_row = row
return None, None
一見よさそうに見えますが、計算量を考えてみましょう。この処理の計算量は最悪で $O(N)$ です。これを毎フレーム実行すると、全体の計算量は $O(F × N)$ になります。(N: 視線データ数, F: 動画の総フレーム数)
例えば 30 秒の動画だとすると、視線のデータ数 N は
$$100\mathrm{Hz} \times 30\mathrm{s} = 3000$$動画の総フレーム数 F は
$$20\mathrm{fps} \times 30\mathrm{s} = 600$$したがって計算量としては
$$N \times F = 3000 \times 600 = 1.8 \times 10^6$$となります。
かなり大きくなってしまいます。ここにユーザー数増やしたり、その他の処理を加えたりするとあっという間に動かなくなってしまいます。(経験談)
ということで、どこまで読み込んだかのindexを保存するようにしました。iterrows()は使えないので、単にindexをrangeで回しました。
def find_nearest_xy(gaze, target_timestamp, start_index):
prev_row = None
min_diff = float("inf")
for i in range(start_index, len(gaze)):
row = gaze.loc[i]
if row["timestamp"] >= target_timestamp:
# 最初の行から target を追い越したとき(0フレーム目での対応)
if prev_row is None:
return row["x"], row["y"], i
# 前後どちらが近いか比較して決める
before_diff = abs(prev_row["timestamp"] - target_timestamp)
after_diff = abs(row["timestamp"] - target_timestamp)
return (prev_row["x"], prev_row["y"], i) if before_diff <= after_diff else (row["x"], row["y"], i)
prev_row = row
return None, None, start_index
そうすると、毎回先頭から探索する必要がなくなり、計算量は $ O(F × N) $ から $ O(F + N) $ まで大幅に改善され、無事完成...!のはずでした...。
人ってまばたきするよねって話
視線データをプロットしたデータを見ていると、直感に反すというか、「なんかおかしいな」と思うことがありました。
そのモヤモヤの原因は欠損値の存在でした。当たり前です。人間はまばたきをするので、その間の視線データは当然のように欠損します。
例えば 100ms の視線データを探しているのに、気づいたら 500ms を返してしまうような挙動がありました。よく見るとその間の 100〜500ms がすべて欠損で、まばたきで観測できていなかったんですね。
探索するのにも制約をつけないといけないという話です。ということでmax_timestampを引数に入れ、その時間を過ぎればNoneを返すように変更しました。
これにて一件落着です!ぱちぱちぱち!これで1フレームごとに動画を見比べる生活もおさらばです!
おわりに
いかがでしたでしょうか?
今回は、学部4年のときに苦労した視線計測周りの話を、供養のつもりでまとめてみました。
ここに書いたこと以外にも、初歩的な勘違いで躓いたり、原因不明のバグに悩まされたりと、思い返すと大変だった記憶が多いです。
やっていること自体は振り返れば単純なのですが、実際にコードへ組み込んでいく中で気づけない部分や落とし穴が意外と多いんですよね。
この記事が、これから視線データを扱う人のハードルを少しでも下げるきっかけになれば嬉しいです。
補足 線形補完をする
今回は私の経験ベースで話しているので、代表値を使ったお話をしました。ただ、あとあと気づいたことにはなるのですが、代表値をとる以外にも線形補完をするという方法があります。
こっちの方がより正確だなと思いつつ、視線データを使った分析は幕を閉じてしまった(研究が消えた)ので、実際どうかはわかりません。
試したことのある人がいればぜひ教えてください。