HEAT MAP!
一週空きまして、久保田です。
僕は業務でPreLaunch.Meという北米版のスマホアプリ事前予約サービスの開発を担当しております。
仕事の内容は実に多岐に渡り、サーバーサイドの開発はもちろん、 フロントエンド、簡単な解析、さらにはPreLaunch.Meをどうすればもっと成長させられるか、というグロースハックもしています。
特に僕らエンジニアにとってとても難しいミッションだな、と感じているのがグロースハックの部分です。
何が難しいかというと、そもそもユーザーの動向がわからないわけです。 (もちろんGoogle Analyticsなどは導入しています。)
ユーザーが何を見てどういった行動を起こしているのか。。。それが目の前のPCとばかり闘ってきた僕らには非常に難しいと感じています。
ユーザーの行動が知りたいなぁと悩みながら日々を過ごしていると、 僕の悩みを解決してくれるかもしれないリポジトリをGitHubのTrending Repositoryの中に見つけました。
それがこちらです。 WebGazer.js
WebGazer.jsは、どうやらPCについてるWebカメラからユーザーの視線をトラッキングしてくれるようです。
これは!
ということで今回はこのWebGazer.jsを使い、ユーザーの視点からヒートマップを作ってみたいと思います!
ゴール
今回はペラ1の画面を作成し、その中でユーザーによく見られている位置を赤くするヒートマップを作ることがゴールです。

なので、ユーザーに見せる画面とヒートマップを表示する画面とデータを集めるサーバーが必要になりそうです。 機能としては、 - [ユーザーに見せる画面]視点のトラッキング、視点データの送信 - [サーバー]送られてきた視点データを加工して保存 - [ヒートマップ画面]サーバーからデータを取得し、色でよく見られている場所がわかるようにする といった機能が必要になります。
環境
環境は、以下の通りです。
- WebGazer.js - Chrome(50.0.2661.102 (64-bit)) - Rails(5.0.0rc1) - Ruby(2.3.0)
実装
比較的コード量が多いので、GitHubにコードをあげておきました。
なので、大切なところだけかいつまんで説明したいと思います。
1, WebGazerで視点の動きをトラッキングし、サーバーに送信する
まずはトラッキングした視点データの取得、サーバーへの送信です。
WebGazer.jsはユーザーの視点の動きを学習します。 具体的には、ユーザーのクリックアクションを取得し、その時の視点から学び、カメラからユーザーの視点を合わせていくらしいです。 詳しくは公式ページをご覧ください。 デモも用意してくれているので、とても親切です。
まずはカメラが起動したら、カーソルを見ながら何度かクリックします。 そうするとWebGazer.jsが学習し、視点をトラッキングできるようになります。
まだ顔認識の精度が甘いのか、よく僕の顔と僕の肩口から見える天井を誤認識されました。 顔が天井っぽいのか天井が壁っぽいのか。。。 事によってはショックなところです。

さて、実装に戻ります。 ほぼサンプルコードをそのままいただきましたので、 変わったところはありませんが、視点データを取得し送信しているのは以下の処理です。
// app/assets/javascripts/application.js
funcs = {};
funcs['cam'] = function() {
var storage = [];
var innerStorage = [];
// #1
// setup & データの格納
webgazer.setRegression('ridge') /* currently must set regression and tracker */
.setTracker('clmtrackr')
.setGazeListener(function(data, _) {
// ここでループが起こり、dataに視点の座標を格納してくれる。
if (data == null) {
return;
}
innerStorage.push([data.x, data.y]);
})
.begin()
.showPredictionPoints(true); /* shows a square every 100 milliseconds where current prediction is */
// #2
// 定期的にinnerStorageに溜まったデータを格納&サーバーに送信する
setInterval(function() {
storage.push(innerStorage);
innerStorage = [];
sendServer(window.innerWidth, window.innerHeight);
}, 5000);
// #3
// 送信処理
var i = 0;
sendServer = function(w, h) {
$.ajax({
type: "POST",
url: "/application",
data: {
width: w,
height: h,
datas: storage[i]
},
success: function() {
// 成功したらiを足していく。
// サーバーに送信済みの不要なデータがたまるので、storageも綺麗にする。
storage[i] = void 0;
i++;
}
});
}
... snip ...
まず#1のところで、webgazerを呼び出し、視点データを集めています。
setGazeListenerに渡されたコールバック関数がループで回り続け、第一引数のdataに座標を含んだオブジェクトを渡し続けてくれます。
なので、このコールバックでdataのx,yの座標を配列に入れ、2つ用意した配列のうち、innerStorageの方に格納しまくります。
そして#2のところで、5秒ごとにinnerStorageの配列をStrageに格納し、ajaxでの送信処理を開始します。 なぜstorageとinnerStorageを入れ子にしているかというと、非同期でサーバーに送信している間も視点データはもちろん集めていたいですが、送信済みのデータは無駄になります。しかし通信が成功した時だけデータを捨てたいので、入れ子にしておきます。
最後に#3でサーバーに視点データを送信します。
ここでは、集めた視点データの他に、現在ユーザーが見ている画面のサイズも送っています。これは後でサーバー側で視点データを加工するのに必要となります。
そして送信が完了したら、送ったデータをとっておくのはリソースの無駄づかいなので、void 0でundefined状態にしています。
2, データを加工し、データベースに保存する
次はサーバー側の実装です。 ここでは、送られてきたデータを二次元配列にマッピングします。 どのようにマッピングするかというと、受け取ったデータは、[645, 331]といった非常に細かい座標データなので、そのまま扱うのはしんどそうなので、いくつかのパターンに分けていきたいと思います。 具体的には、横15*縦10のテーブルに座標を当てはめていく感じです。(ここの比率は自由です) そして当てはまったら数字をインクリメントし、セルごとにスコアをつけていきます。 表にするとこんな感じです。

実装はシンプルで、こんな感じです。
# app/controllers/application_controller.rb
... snip ...
X_MIN = 0
Y_MIN = 0
X_MAX = 15
Y_MAX = 10
def create
x_operator = params[:width].to_i / X_MAX
y_operator = params[:height].to_i / Y_MAX
# #1
# 二次元配列の作成
positions = Array.new(Y_MAX){Array.new(X_MAX){0}}
# #2
# データの加工。座標をセルのどこに当てはめるかを決定する。
Array(params[:datas]).each do |_, data|
x_pos = data[0].to_i / x_operator # 0-14
y_pos = data[1].to_i / y_operator # 0-9
# ガード処理。たまに-2とか画面サイズ以上の座標が送られてくる。
next if x_pos < X_MIN
next if y_pos < Y_MIN
next if x_pos >= X_MAX
next if y_pos >= Y_MAX
positions[y_pos][x_pos] += 1
end
r = ::Redis.new
# #3
# スコアを更新する。
positions.each.with_index(0) do |x, x_i|
x.each.with_index(0) do |weight, y_i|
if weight > 0
# find_or_create
x_arr = JSON.parse(r.get(x_i) || Array.new(X_MAX){0}.to_s)
x_arr[y_i] += weight
r.set(x_i, x_arr)
end
end
end
end
... snip ...
まず#1のところでテーブルを再現する二次元配列を作成します。
15個の要素を持つ10個の配列を持つ配列にすることで、後でヒートマップに出力するときにとても楽です。
ここでArray.new(10, Array.new(15, 0))とせずにArray.new(Y_MAX){Array.new(X_MAX){0}}としているのは、
Rubyを使っている人はわかると思いますが、Rubyでこのように配列を作ってしまうと、配列の要素がすべて同じものになり、どれか更新すると他の要素しまいます。参照になってしまうんですね。
それを避けるために、ブロックを渡す必要があります。
次に#2のところでデータを変換していきます。
ユーザーの画面サイズ / 分割した数でさらに座標を割ると縦、横ともにどこのセルに当てはまるかが求められますので、それをそのまま#1で作った二次元配列に当てはめていきます。
最後に#3でRedisにデータを入れていきます。 少しでも速くしたかったので(ならRuby使うな)、RDBではなく、KVSにしました。
3, 保存したデータを取得し、表示しヒートマップを作る。
さて後は集めたデータを表示するだけです! とりあえずデータを送信するだけのエンドポイントを作成します。
# app/controllers/application_controller.rb
... snip ...
def datas
r = Redis.new
render json: r.keys.map(&:to_i).sort.map{|k| r.get(k)}
end
そしてヒートマップを作るjsがこちらです。
// app/assets/javascripts/application.js
funcs['heat_map'] = function() {
// #1
// データ取得、ヒートマップ作成
$.ajax({
type: 'Get',
url: '/datas',
success: function(datas) {
var table = document.getElementById('heat-map');
table.textContent = null;
for(var i = 0; i < datas.length; i++) {
var tr = document.createElement('tr');
tr.setAttribute('class', 'y-' + i);
var data = JSON.parse(datas[i]);
for(var n = 0; n < data.length; n++) {
var td = document.createElement('td')
td.setAttribute('class', 'x-' + i);
// スコアによって色を決定
td = convertColorCode(td, data[n]);
td.innerHTML = data[n];
tr.appendChild(td);
}
table.appendChild(tr);
}
}
});
function convertColorCode(td, n) {
// 本当は計算してグラデーションしたい。。。
if(n > 1000) {
td.setAttribute('style', 'background-color:#ff0000')
} else if(900 < n && n < 1000) {
td.setAttribute('style', 'background-color:#ff2222')
} else if(800 < n && n < 900) {
td.setAttribute('style', 'background-color:#ff4444')
} else if(700 < n && n < 800) {
td.setAttribute('style', 'background-color:#ff6666')
} else if(600 < n && n < 700) {
td.setAttribute('style', 'background-color:#ff8888')
} else if(500 < n && n < 600) {
td.setAttribute('style', 'background-color:#ff9999')
} else if(400 < n && n < 500) {
td.setAttribute('style', 'background-color:#ffaaaa')
} else if(300 < n && n < 400) {
td.setAttribute('style', 'background-color:#ffbbbb')
} else if(200 < n && n < 300) {
td.setAttribute('style', 'background-color:#ffdddd')
} else if(100 < n && n < 200) {
td.setAttribute('style', 'background-color:#ffeeee')
} else if(n < 100) {
td.setAttribute('style', 'background-color:#ffffff')
}
return td
}
document.querySelector('#heat-map').style.visibility = 'visible';
}
// #2
// heat_mapの時はポーリングする
if(location.hash.slice(1) === "heat_map"){
setInterval(funcs[location.hash.slice(1)], 6000)
}
// #3
// URLのhashによってjsを出しわけ
window.onload = funcs[location.hash.slice(1)];
1のところで、データを取得してヒートマップを作っています。
そしてスコアによってそのセルの色を決定しています。 少しでも無駄なことはしたくなかったので、jqueryも使っていません。(なら$.ajaxもjsで頑張れ)
そして#2でポーリングをしています。
そして#3では、ヒートマップ用の関数を動かすのか、WebGazer.jsを動かすのかをURLのハッシュで決定しています。 なぜなら、これはできたものを見ればわかるのですが、 ヒートマップを表示している時もページのコンテンツは後ろに表示させておきたかったのですが、ビューを分けるのが面倒だったからです。
完成
結局長くなりましたが、これで完成です! では動かしてみます!
赤点が視点の位置とされています。 赤点の動きのあったところでヒートマップ上の数字が増えていっていますね! 今回は10秒ごとにデータを送信したので、動画だと10, 20, 30秒あたりでヒートマップが更新されています。 最後の方は若干重いのか、赤点がついてブレまくりました。(PCのパワー不足?)
まぁ感覚としては概ねいい感じ、もう少し処理を軽くできたら面白いかも、と感じました!
皆さんも遊んでみてくださいー!
今回は以上です。 ではまた。