👀

WebGazer.js で Webカメラで安価にアイトラッキングをする

2022/12/16に公開

はじめに

こんにちは、ストックマークのプロダクトデザイナーの @motokazuです。
ストックマークではAnewsAstrategyというビジネス向けSaaSを展開しているのですが、そういったWeb系アプリのユーザビリティをテストするために、アイトラッキングデータを活用してみたい方向けの、Webカメラで安価にアイトラッキングする仕組みについて書きたいと思います。

試しに、アイトラッキングの方法を検索すると、大抵の場合はデバイスが出てきます。Tobiiが有名です。ハードウェアでのアイトラッキングは処理速度も早く、精度高く検出できるので素敵なのですが、「ちょっと試してみたい...」ぐらいの気持ちで始められるような安価なものではありません。

そこで、今回は、js使えるよ。というスキル感のプロダクトデザイナーが Webカメラを使ったアイトラッキングを実践的に使うまでの方法を紹介します。

Webカメラを使ったアイトラッキング

調べてみると、いろんな方法があります。
Pythonベースの、pygazeやjsベースのWebGazerGazeCloudAPIが見つかりました。

テストする対象が今回はブラウザで動くアプリであるので、ブラウザで動く js を選択することにしました。(自分のスキルの観点で使いやすいというのもあります...)

WebGazer v.s. GazeCloudAPI の2強のようです。
https://medium.com/@williamwang15/integrating-gazecloudapi-a-high-accuracy-webcam-based-eye-tracking-solution-into-your-own-web-app-2d8513bb9865

As you can tell from the result, GazeCloudAPI is more reliable than WebGazer, and its performance is stable enough and accurate enough for a project that does not demand a pixel-level accuracy.

GazeCloudAPIの方が少し優れていそうなのですが、有料でした。
利用には、タイムリミットがあり、1分しか使えません。安価に何とかしたいという気持ちから、もう一方のWebGazer.js に落ち着きました。

WebGazer.js も提供元のサイトにあるデモを使ってみるとわかるのですが、そんなに悪いことはありません。キャリブレーションして試してみると、検出できる領域が目線の下にくる傾向にはあるものの、大体の方向感は合っているといえば合っています。

WebGazer.js を使ったアイトラッキングのコードは以下。
https://github.com/motokazu-nishimura/webgazer-eyetracking

Devtoolから入れて、とりあえず試してみる

js で動くのだから、ライブで組み込んで試すこともできます。おもむろに、devtoolを開き、コンソールに以下を入力すると... WebGazer.js試せます。

const gazeel = document.createElement("div");
gazeel.id = "gaze";
gazeel.style.width = "30px";
gazeel.style.height = "30px";
gazeel.style.backgroundColor = "rgba(255,0,0,0.3)";
gazeel.style.position = "absolute";
gazeel.style.left = "0px";
gazeel.style.top = "0px";
gazeel.style["z-index"] = 1000;
gazeel.style["border-radius"] = "50%";
document.querySelector("body").append(gazeel);

function PlotGaze(GazeData, elapsedTime) {
      if (GazeData == null) return;
       let x = GazeData.x;
    let y = GazeData.y;
    x -= 15;
    y -= 15;
    gazeel.style.left = x + "px";
    gazeel.style.top = y + "px";
}

const s = document.createElement("script");
s.src = "https://webgazer.cs.brown.edu/webgazer.js";
document.querySelector('body').append(s);

webgazer.setRegression('ridge');
webgazer.showPredictionPoints(true);
webgazer.showVideo(false);
webgazer.setGazeListener(PlotGaze).begin();

動きますが、とはいえこれでは インジェクションしてて気持ち悪いし、無理矢理すぎます。
そして決定的にできないことがあります。

ブラウザをリロードしたり、別のタブに遷移したらアイトラッキングが失われます。

これは、実際 致命的です。
弊社のプロダクトでは、記事を配信しているので記事の元サイトに移動することが想定されます。元サイトに移動したら、その時点でアイトラッキングが切れます。

プロダクト内のアイトラッキングという意味では間違ってないのですが、、連続性がなくなります。
また、読み込んだ時点で注視点が描画されるため、操作する側からすると邪魔です。かといって、注視点を消してしまうと、アイトラッキングデータを管理しなくてはならなくなり工数がかかります。

リロードしても問題ないように測定ページを作成する

何とかできないものかと考えて、たどり着いたのが、リロードしても大丈夫なように、測定用のページを作り、ブラウザのタブをレイヤーにして重ね合わせる方法です。

かなり力技ですが、、操作画面(UI)とタブ(eyetracking用)を動画で撮影(MediaRecorder)し、重ね合わせ(ffpmeg)ます。

一方、やってみて分かったこととしてメリットもありました。
記事をクリックして元サイトを開くなどの操作をしても、常にアイトラッキングし続けることができる点です。自社サイト以外もデータの参考にできます。

WebGazer.js を使ってアイトラッキングデータを得る

WebGazer.jsからダウンロードして、読み込みます。それ以上やることはないのですが、注視点がデフォルトでは小さいので、画面上にわかりやすく表示するために注視点用のelement を用意しておきます(下記コードでは参照先として <div id="gazer"></div> をHTMLに定義している)

const gazeel = document.querySelector("#gazer");

webgazer.setGazeListener(function(data, elapsedTime) {
    if (data == null) {
        return;
    }
    const xprediction = data.x; //these x coordinates are relative to the viewport
    const yprediction = data.y; //these y coordinates are relative to the viewport
    gazeel.style.left = (xprediction - 20) + "px";
    gazeel.style.top = (yprediction - 20) + "px";
}).saveDataAcrossSessions(true).begin();

webgazer.showPredictionPoints(false);
webgazer.applyKalmanFilter(true);

window.saveDataAcrossSessions = true;

タブを録画する

MediaRecorderを使うことで、タブを録画することができます。
キャプチャーを開始するコードの例です。
navigator.mediaDevices.getDisplayMedia でキャプチャの許可を確認し、キャプチャ対象の選択を開始します。
https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getDisplayMedia

MediaRecorder(captureStream, options)で、 optionsに { mimeType: "video/webm; codecs=vp9" }を指定して、webmとして録画します。

すぐに録画が始まると、重ね合わせる先の録画が間に合わないため、5秒の猶予を入れています。

const displayMediaOptions = {
    video: {
      cursor: "always"
    },
    audio: false
};

let captureStream = null;
let mediaRecorder = null;
let recordedChunks = [];
async function startCapture(displayMediaOptions) {
    try {
        captureStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
        const options = { mimeType: "video/webm; codecs=vp9" };

        document.querySelector("#showrecordingcount").style.display = "block";
        // 5 sec
        let countdown = 5;
        let handle = setInterval(()=>{
            document.querySelector("#showrecordingcount").innerHTML = countdown;
            countdown --;
            if (countdown == 0) {
                document.querySelector("#showrecordingcount").style.display = "none";
                mediaRecorder = new MediaRecorder(captureStream, options);
                mediaRecorder.ondataavailable = handleDataAvailable;
                
                mediaRecorder.start();
                clearInterval(handle);
            }
        }, 1000);

        document.querySelectorAll(".hideonprogress").forEach((el)=>el.style.display = "none")
        document.querySelector("#stoprecording").style.display = "block";
    } catch (err) {
        console.error(`Error: ${err}`);
    }
    return captureStream;
}

// handle data of recorder
function handleDataAvailable(event) {
    if (event.data.size > 0) {
      recordedChunks.push(event.data);
      download();
      stop();
    } else {
      // ...
    }
}
// download record data
function download() {
    var blob = new Blob(recordedChunks, {
      type: "video/webm"
    });
    var url = URL.createObjectURL(blob);
    var a = document.createElement("a");
    document.body.appendChild(a);
    a.style = "display: none";
    a.href = url;
    a.download = "eyetracking.webm";
    a.click();
    window.URL.revokeObjectURL(url);
}


// end record
function stop() {   
        document.querySelectorAll(".hideonprogress").forEach((el)=>el.style.display = "block");
    document.querySelector("#stoprecording").style.display = "none";
    webgazer.pause();
    if (mediaRecorder !== null) {
        mediaRecorder.stop();
    }
    if (captureStream !== null) {
        const tracks = captureStream.getTracks();
        tracks[0].stop();
    }
}

操作画面を録画する

こちらは、コードを変更していない対象になるので、Macの画面録画でキャプチャーします。こちらは更に力技です。
https://support.apple.com/ja-jp/HT208721

ブラウザの操作を画面録画機能でキャプチャーしていきます。
画面録画でキャプチャーすることによるメリットは、タブを入れ替えたり閉じたり開いたりしてもアイトラッキングをし続けられることです。

一方、ブラウザを移動されてしまうと大問題なので、それは禁止事項として被験者の方に注意深くお伝えします。

録画データを重ね合わせる

タブの録画データと、操作画面の録画データの2つのデータができあがりますので、これらをffmpegで重ね合わせます。

重ね合わせる前に、動画のサイズを合わせてください。(下記の 1504 x 968 は任意です)

ffmpeg -i webui.mov -vf scale=1504:968 bg.mp4
ffmpeg -i eyetracking.webm  -vf scale=1504:968 front.mp4

サイズを合わせたら、重ね合わせます。

ffmpeg -i bg.mp4 -i front.mp4 -filter_complex "[1:0]colorkey=white:0.01:1[colorkey];[0:0][colorkey]overlay=x=0:y=0" -preset ultrafast out.mp4

out.mp4に、アイトラッキングデータが重なった最終的な動画データが出来上がります。

さいごに

今回はとにかく力技ですが、WebGazer.jsを使って Webカメラを使った安価なアイトラッキングを行うことをしてみました。
キャリブレーションの方法を工夫すると、もう少し精度を高めることができるかもしれませんが、Webカメラでのアイトラッキングでわかることは、目線が左右上下のどこかに留まっているか、バラついているか(迷っているか)ぐらいが限度かもしれません。

Discussion