HEAT MAP!

HEAT MAP!

一週空きまして、久保田です。

僕は業務でPreLaunch.Meという北米版のスマホアプリ事前予約サービスの開発を担当しております。

仕事の内容は実に多岐に渡り、サーバーサイドの開発はもちろん、 フロントエンド、簡単な解析、さらにはPreLaunch.Meをどうすればもっと成長させられるか、というグロースハックもしています。

特に僕らエンジニアにとってとても難しいミッションだな、と感じているのがグロースハックの部分です。

何が難しいかというと、そもそもユーザーの動向がわからないわけです。 (もちろんGoogle Analyticsなどは導入しています。)

ユーザーが何を見てどういった行動を起こしているのか。。。それが目の前のPCとばかり闘ってきた僕らには非常に難しいと感じています。

ユーザーの行動が知りたいなぁと悩みながら日々を過ごしていると、 僕の悩みを解決してくれるかもしれないリポジトリをGitHubのTrending Repositoryの中に見つけました。

それがこちらです。 WebGazer.js

WebGazer.jsは、どうやらPCについてるWebカメラからユーザーの視線をトラッキングしてくれるようです。

これは!

ということで今回はこのWebGazer.jsを使い、ユーザーの視点からヒートマップを作ってみたいと思います!

ゴール

今回はペラ1の画面を作成し、その中でユーザーによく見られている位置を赤くするヒートマップを作ることがゴールです。

heatmap

なので、ユーザーに見せる画面とヒートマップを表示する画面とデータを集めるサーバーが必要になりそうです。 機能としては、 - [ユーザーに見せる画面]視点のトラッキング、視点データの送信 - [サーバー]送られてきた視点データを加工して保存 - [ヒートマップ画面]サーバーからデータを取得し、色でよく見られている場所がわかるようにする といった機能が必要になります。

環境

環境は、以下の通りです。

- WebGazer.js
- Chrome(50.0.2661.102 (64-bit))
- Rails(5.0.0rc1)
- Ruby(2.3.0)

実装

比較的コード量が多いので、GitHubにコードをあげておきました。

webcam_heatmap

なので、大切なところだけかいつまんで説明したいと思います。

1, WebGazerで視点の動きをトラッキングし、サーバーに送信する

まずはトラッキングした視点データの取得、サーバーへの送信です。

WebGazer.jsはユーザーの視点の動きを学習します。 具体的には、ユーザーのクリックアクションを取得し、その時の視点から学び、カメラからユーザーの視点を合わせていくらしいです。 詳しくは公式ページをご覧ください。 デモも用意してくれているので、とても親切です。

まずはカメラが起動したら、カーソルを見ながら何度かクリックします。 そうするとWebGazer.jsが学習し、視点をトラッキングできるようになります。

まだ顔認識の精度が甘いのか、よく僕の顔と僕の肩口から見える天井を誤認識されました。 顔が天井っぽいのか天井が壁っぽいのか。。。 事によってはショックなところです。

tenzyo

さて、実装に戻ります。 ほぼサンプルコードをそのままいただきましたので、 変わったところはありませんが、視点データを取得し送信しているのは以下の処理です。

// 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のテーブルに座標を当てはめていく感じです。(ここの比率は自由です) そして当てはまったら数字をインクリメントし、セルごとにスコアをつけていきます。 表にするとこんな感じです。

table

実装はシンプルで、こんな感じです。

# 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のパワー不足?)

まぁ感覚としては概ねいい感じ、もう少し処理を軽くできたら面白いかも、と感じました!

皆さんも遊んでみてくださいー!

今回は以上です。 ではまた。