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のパワー不足?)
まぁ感覚としては概ねいい感じ、もう少し処理を軽くできたら面白いかも、と感じました!
皆さんも遊んでみてくださいー!
今回は以上です。 ではまた。