Cordova
monaca
JINSMEME

JINS MEME で首振り&目線移動をトリガーにして「何か」をコントロールするロジック(JS)

動作風景

2018/06/20 編集

モジュールの形に実装し直しました。ロジックの中身には特に興味ない場合はこちらをご利用ください。

JINS MEME Controller Library(github)
サンプルの index.html とライブラリjmctrllib.js のみ含みます。
Monaca 公開プロジェクト
Monacaでインポートし、client_id/secretをセットすればすぐ動きます

序文

最近Monacaによるアプリ開発を始めました。クラウドIDEは最初敬遠していたのですが、慣れるとすごい便利ですね~。私のような非本業プログラマで端末をコロコロ変えて仕事をするようなスタイルの人間には最強の選択肢かと。Rも最近はRstudio serverメインになっていてローカルツールはほぼ使わなくなったかも。ということでこれを機に今までR言語しか書けなくて公開できずもどかしい思いをしていたコントロール系のロジックを解説したいと思います。

JINS MEMEでできること

アシアルさんご協力のもと、monacaでも使用できるJINS MEME用 Cordovaプラグインが公開されています。JINS MEMEで何ができるかをざっくり説明すると以下の2つになります。
(1)生体指標系の算出(覚醒、集中など。今後ライブラリ化予定)
(2)何かの操作のトリガ(ある動きをしたらトリガが発生する)
ただし現在はストリームデータを取ってくるAPIのみ公開しているため、自分で1から開発すると(1)は1つの指標につき5-50人月ぐらい、(2)も実績ベースで1-2人月ぐらいかかってしまいます、、。こんなことではIoTアプリ開発は広まらない!、ということでまずは(2)をロジックごと公開することにしました。

このコードで実現できること3つ

・短い間隔で首を左右、または上下に振った時の最初の向きとその連続回数のイベント検出
・短い間隔で視線を左右、または上下に動かした時の最初の向きとその連続回数のイベント検出
スクリーンショット 2017-09-08 08.58.46.png

ポイントは短い間隔で 連続して 動かす、というところです。この動作は日常生活で通常あまり行わないため誤判定を抑えて抽出できます。これを利用して例えば文書のページを送ったり、何か他アプリにインテントを送ったり、WebHook発行したりすることができます。当然ながら連続回数==1は日常生活で頻繁に発生するので2以上をトリガとしてご使用ください。
・首の角度の絶対値の計測
首がまっすぐ向いている状態を0度として、下を向くと+、上を向くとマイナスとして絶対値の角度を計算します。JINS MEMEのSDKで返される角度はジャイロセンサー値のため、結構な頻度でキャリブレーションをしないと絶対値がずれます。そのためある程度長い周期、かつ絶対値で角度が取りたい場合はここで解説するように加速度から算出するのがおススメです。

コードに関する注意

このコード、JavaScriptを勉強し始めて2週間目ぐらいで書いていたコードなので、文法的には突っ込みどころ満載だと思います。気になってしょうがないところがございましたらコメントください、、、

下準備

以下のスライドで説明されているコードをベースに改造を加えていきます。
https://www.slideshare.net/AsialCorp/jins-meme-developer-handson-6-monaca-apache-cordova

仕上がり

こんな感じになります
ファイル_000 (1).png
ファイル_001 (1).png

コード

バッファ、パラメター

バッファ、パラメターはグローバルオブジェクトで定義してください。オブジェクトにした理由は、、特にありません、、。

グローバル宣言
    //追加機能用のグローバルバッファ
    var gb = {
        rt_cnt :0,  //累積カウンタ
        wa_accy : 0.0, wa_accz : -16.0, tilt : 0, tilt_fw_cnt : -100, //姿勢用、イニシャルで1g分入れとく、tilt、前回ワーニングカウント
        blink_dur : 0, // このカウントが0より大きかったとき瞬目データを残す
        yaw_m0 : [0, 0], yaw_m1 : [0, 0], //yawとyawの差分,m1は前回値
        pitch_m0 : [0, 0], pitch_m1 : [0, 0], //pitchとpitchの差分,m1は前回値
        yaw_swing_cnt :0, yaw_swing_fep_cnt : -100, yaw_swing_fep_sign : 0,
        yaw_swing_seq_cnt : 0, yaw_swing_in_seq : 1, yaw_swing_start_sign : 0, 
        pitch_swing_cnt :0, pitch_swing_fep_cnt : -100, pitch_swing_fep_sign : 0,
        pitch_swing_seq_cnt : 0, pitch_swing_in_seq : 1, pitch_swing_start_sign : 0, 
        em_long_cnt :0, em_long_fep_cnt : -100, em_long_fep_sign : 0,
        em_long_seq_cnt : 0,  em_long_in_seq : 1, em_long_start_sign : 0,
        em_lat_cnt :0, em_lat_fep_cnt : -100, em_lat_fep_sign : 0,
        em_lat_seq_cnt : 0,  em_lat_in_seq : 1, em_lat_start_sign : 0
    }
    //グローバルパラメター
    var param = {
        blink_dur:4,
        tilt_th:15,
        yaw_d1_th:3.1,pitch_d1_th:1.5,
        yaw_seq_th:8, pitch_seq_th:8, em_long_seq_th:10, em_lat_seq_th:10,
        tilt_warn_interval_th:60
    }

パラメターは以下が調整可能です。
yaw_seq_th 横の首振り操作で連続と判定するカウント数。8だと0.4秒
pitch_seq_th 縦の同上
em_long_seq_th 横の視線移動で連続と判定するカウント数。10だと0.5秒
em_lat_seq_th 縦の同上
yaw_d1_th ,pitch_d1_th 首を振ったと判定するときの角速度。小さくすると反応が敏感になります。

startDataReport(修正)

MEMEのデータを受け取った時に実行するコールバック関数です。この中にコントロール用の関数を配置します。

startDataReport部分
    function startDataReport() {
        cordova.plugins.JinsMemePlugin.startDataReport(function(data) {
            document.getElementById('modal').hide();
            gb.rt_cnt += 1; //カウンタをインクリメント

            getSequentialSwing(data,swingLatAction, swingLongAction);
            getSequentialEyeMove(data,emLatAction, emLongAction);
            calcTilt(data);

            draw(data);
        }, function() {
            console.log('Error: startDataReport');
            document.getElementById('modal').hide();
        });
    }

真ん中3つが首振り判定、視線判定、角度計算です。

getSequentialSwing

首を振った時にswingLatAction、swingLongActionを実行します。今回はUI上の文字列を更新するコードが入っています。ロジックとしては角度から角速度への変換→ピークの抽出→連続判定の順番になっています。

getSequentialSwing部分
    //首振りを検出しトリガを出すコールバック関数
    function getSequentialSwing (data, callbackLat,callbackLong) {
        gb.yaw_m0[0] = data.yaw;
        if(Math.abs(gb.yaw_m1[0] - gb.yaw_m0[0]) > 300){ //一周対応
            gb.yaw_m0[1] = gb.yaw_m1[0] - (gb.yaw_m0[0] + 360 * Math.sign(gb.yaw_m1[0] - gb.yaw_m0[0]));
        } else {
            gb.yaw_m0[1] = gb.yaw_m1[0] - gb.yaw_m0[0];
        }
        //もしピーク終了を検出したら
        if((Math.abs(gb.yaw_m1[1]) >= param.yaw_d1_th && Math.abs(gb.yaw_m0[1]) >= param.yaw_d1_th && Math.sign(gb.yaw_m1[1] * gb.yaw_m0[1]) == -1 ) || 
           (Math.abs(gb.yaw_m1[1]) >= param.yaw_d1_th && Math.abs(gb.yaw_m0[1]) < param.yaw_d1_th) ){
            gb.yaw_swing_cnt += 1;
            gb.yaw_swing_in_seq = 1; //連続中フラグセット
            gb.yaw_swing_fep_sign = Math.sign(gb.yaw_m0[1]); //符号のセット
            //前のピークからのカウント差がしきい値以下ならまだ連続
            if(gb.rt_cnt - gb.yaw_swing_fep_cnt <= param.yaw_seq_th){
                gb.yaw_swing_seq_cnt += 1; //連続カウントアップ
            }
            gb.yaw_swing_fep_cnt = gb.rt_cnt; //前回ピーク発生カウントに今回値をセットしておく
        }
        //前のピークから一定時間たったら呼び出す
        if(gb.yaw_swing_in_seq == 1 && (gb.rt_cnt - gb.yaw_swing_fep_cnt > param.yaw_seq_th)){
            direction = -1 * gb.yaw_swing_fep_sign * ((gb.yaw_swing_seq_cnt % 2) * 2 - 1);
            callbackLat(direction, gb.yaw_swing_seq_cnt);
            gb.yaw_swing_in_seq = 0; //連続中フラグリセット
            gb.yaw_swing_seq_cnt = 1; //連続カウントリセット
        }
        gb.yaw_m1 = [].concat(gb.yaw_m0); //次の前回値としてセット,単純に配列名コピーすると参照コピーになる

        gb.pitch_m0[0] = data.pitch;
        if(Math.abs(gb.pitch_m1[0] - gb.pitch_m0[0]) > 300){ //一周対応
            gb.pitch_m0[1] = gb.pitch_m1[0] - (gb.pitch_m0[0] + 360 * Math.sign(gb.pitch_m1[0] - gb.pitch_m0[0]));
        } else {
            gb.pitch_m0[1] = gb.pitch_m1[0] - gb.pitch_m0[0];
        }
        //もしピーク終了を検出したら
        if((Math.abs(gb.pitch_m1[1]) >= param.pitch_d1_th && Math.abs(gb.pitch_m0[1]) >= param.pitch_d1_th && Math.sign(gb.pitch_m1[1] * gb.pitch_m0[1]) == -1 ) || 
           (Math.abs(gb.pitch_m1[1]) >= param.pitch_d1_th && Math.abs(gb.pitch_m0[1]) < param.pitch_d1_th) ){
            gb.pitch_swing_cnt += 1;
            gb.pitch_swing_in_seq = 1; //連続中フラグセット
            gb.pitch_swing_fep_sign = Math.sign(gb.pitch_m0[1]); //符号のセット
            //前のピークからのカウント差がしきい値以下ならまだ連続
            if(gb.rt_cnt - gb.pitch_swing_fep_cnt <= param.pitch_seq_th){
                gb.pitch_swing_seq_cnt += 1; //連続カウントアップ
            }
            gb.pitch_swing_fep_cnt = gb.rt_cnt; //前回ピーク発生カウントに今回値をセットしておく
        }
        //前のピークから一定時間たったら呼び出す
        if(gb.pitch_swing_in_seq == 1 && (gb.rt_cnt - gb.pitch_swing_fep_cnt > param.pitch_seq_th)){
            direction = gb.pitch_swing_fep_sign * ((gb.pitch_swing_seq_cnt % 2) * 2 - 1);
            callbackLong(direction, gb.pitch_swing_seq_cnt);
            gb.pitch_swing_in_seq = 0; //連続中フラグリセット
            gb.pitch_swing_seq_cnt = 1; //連続カウントリセット
        }
        gb.pitch_m1 = [].concat(gb.pitch_m0); //次の前回値としてセット,単純に配列名コピーすると参照コピーになる
    }

    //首振りを検出後の実行内容(横),direction(向き)とseq_times(連続回数)を利用して何かを行う
    function swingLatAction(direction,seq_times){
        document.getElementById("swing_lat_dtl_msg").innerHTML = "向き:" + direction + " 連続回数:" + seq_times;
    };

    //首振りを検出後の実行内容(縦),direction(向き)とseq_times(連続回数)を利用して何かを行う
    function swingLongAction(direction,seq_times){
        document.getElementById("swing_long_dtl_msg").innerHTML = "向き:" + direction + " 連続回数:" + seq_times;
    };

getSequentialEyeMove

視線を動かした時にemLatAction、emLongActionを実行します。今回はUI上の文字列を更新するコードが入っています。ロジックとしては首振りと似ていますが、視線移動の抽出自体はすでに行われているためフィルタ→連続判定のみになります。

getSequentialEyeMove部分
    //目線コントロールを検出しトリガを出すコールバック関数、モード1
    function getSequentialEyeMove (data, callbackLat,callbackLong) {
        var em_min_th = 1;
        //横(lalteral)
        if(data.eyeMoveRight > em_min_th || data.eyeMoveLeft > em_min_th){
            gb.em_lat_cnt += 1;
            gb.em_lat_in_seq = 1; //連続中フラグセット
            gb.em_lat_fep_sign = data.eyeMoveRight > em_min_th ? 1 : -1; //符号のセット
            //前のピークからのカウント差がしきい値以下ならまだ連続
            if(gb.rt_cnt - gb.em_lat_fep_cnt <= param.em_lat_seq_th){
                gb.em_lat_seq_cnt += 1; //連続カウントアップ
            }
            gb.em_lat_fep_cnt = gb.rt_cnt; //前回ピーク発生カウントに今回値をセットしておく
        }
        //前のピークから一定時間たったら呼び出す
        if(gb.em_lat_in_seq == 1 && (gb.rt_cnt - gb.em_lat_fep_cnt > param.em_lat_seq_th)){
            direction = gb.em_lat_fep_sign * ((gb.em_lat_seq_cnt % 2) * 2 - 1); //符号の調整
            callbackLat(direction, gb.em_lat_seq_cnt);
            gb.em_lat_in_seq = 0; //連続中フラグリセット
            gb.em_lat_seq_cnt = 1; //連続カウントリセット
        }
        //縦(longitudinal)
        if(data.eyeMoveUp > em_min_th || data.eyeMoveDown > em_min_th){
            gb.em_long_cnt += 1;
            gb.em_long_in_seq = 1; //連続中フラグセット
            gb.em_long_fep_sign = data.eyeMoveRight > em_min_th ? 1 : -1; //符号のセット
            //前のピークからのカウント差がしきい値以下ならまだ連続
            if(gb.rt_cnt - gb.em_long_fep_cnt <= param.em_long_seq_th){
                gb.em_long_seq_cnt += 1; //連続カウントアップ
            }
            gb.em_long_fep_cnt = gb.rt_cnt; //前回ピーク発生カウントに今回値をセットしておく
        }
        //前のピークから一定時間たったら呼び出す
        if(gb.em_long_in_seq == 1 && (gb.rt_cnt - gb.em_long_fep_cnt > param.em_long_seq_th)){
            direction = gb.em_long_fep_sign * ((gb.em_long_seq_cnt % 2) * 2 - 1); //符号の調整
            callbackLong(direction, gb.em_long_seq_cnt);
            gb.em_long_in_seq = 0; //連続中フラグリセット
            gb.em_long_seq_cnt = 1; //連続カウントリセット
        }
    }

    //目線コントロールを検出後の実行内容(横)direction(向き)とseq_times(連続回数)を利用して何かを行う
    function emLatAction(direction,seq_times){
        document.getElementById("em_lat_dtl_msg").innerHTML = "向き:" + direction + " 連続回数:" + seq_times;
    };

    //目線コントロールを検出後の実行内容(縦)direction(向き)とseq_times(連続回数)を利用して何かを行う
    function emLongAction(direction,seq_times){
        document.getElementById("em_long_dtl_msg").innerHTML = "向き:" + direction + " 連続回数:" + seq_times;
    };

calcTilt

首の角度絶対値を計算します。加速度のYとZの加重平均を出した後に重力加速度を利用してATANでラジアン角を出し最後にdegreeに変換しています。if文以下のところはある一定値より下を向いたら間欠的にバイブレーションするコードを入れています。肩こり防止に!

calcTilt
    //tiltをgb.tilt(連続値)に格納する関数
    function calcTilt(data) {
        //Tiltの計算用
        gb.wa_accy = gb.wa_accy * 0.9 + data.accY * 0.1; //wa Y
        gb.wa_accz = gb.wa_accz * 0.9 + data.accZ * 0.1; //wa Z
        gb.tilt = Math.atan2(gb.wa_accy,-1 * gb.wa_accz) * 57.3;
        if(gb.tilt > param.tilt_th && (gb.rt_cnt - gb.tilt_fw_cnt) > param.tilt_warn_interval_th){
            navigator.vibrate(700);
            gb.tilt_fw_cnt = gb.rt_cnt;
        }
    }

draw(修正)

まばたきにduration入れたりなどちょっと改造しました

draw
    // 計測結果を描画する
    function draw(data) {
        var tabbar = document.getElementById('tabbar');
        var tabIndex = tabbar.getActiveTabIndex();
        if (tabIndex === 0) {
            // まばたきされたらアイコンを変更する
            var eye = document.getElementById("icon-eye");

            if (data.blinkSpeed >50) {
                gb.blink_dur = param.blink_dur; //設定カウント分表示を維持するロジック
                document.getElementById("blink_msg").innerHTML = 
                  "瞬目強度:" + data.blinkStrength + "cnt 閉眼時間:" + data.blinkSpeed + "ms";
            }
            if (gb.blink_dur > 0) {
                eye.setAttribute("icon", "eye-slash");
            } else {
                eye.setAttribute("icon", "eye");
            }            
            gb.blink_dur += -1; //カウンタのデクリメント
        } else if(tabIndex === 1) {
            // 姿勢角Rollに合わせてアイコンを傾ける
            var body = document.getElementById("icon-body");
            var deg = data.roll * -1;
            body.style["transform"] = "rotate(" + deg + "deg";
            document.getElementById("gyro_msg").innerHTML = 
             "data_id:" + gb.rt_cnt + " roll:" + Math.round(data.roll) + 
             " pitch:" + Math.round(data.pitch) + " yaw:" + Math.round(data.yaw);
            // 前傾角とスイング状況を知らせる
            document.getElementById("tilt_msg").innerHTML = "前傾角:" + Math.round(gb.tilt) + "deg";
            if (gb.tilt > param.tilt_th){
                document.getElementById("tilt_msg").style["color"] = "red";
            } else {
                document.getElementById("tilt_msg").style["color"] = "black";
            }
        } else if(tabIndex === 2) {
        }
    }

HTML部分

body内のみ切り出しておきます

<body>
  <ons-page>
    <!-- ツールバー -->
    <ons-toolbar>
      <div class="center">JINS MEME 機能サンプル</div>
      <div class="right">
        <ons-toolbar-button>
            <ons-icon icon="plug" size="24px" onclick="reconnect()"></ons-icon>
        </ons-toolbar-button>
      </div>
    </ons-toolbar>

    <!-- タブバー -->
    <ons-tabbar position="auto" id="tabbar">
      <ons-tab label="Eye" page="tab1.html" icon="eye">
      </ons-tab>
      <ons-tab label="Body" page="tab2.html" icon="male">
      </ons-tab>
      <ons-tab label="Tilt&Swing" page="tab3.html" icon="fa-paw" active>
      </ons-tab>
    </ons-tabbar>
  </ons-page>

  <!-- Eyeタブ -->
  <ons-template id="tab1.html">
    <ons-page id="first-page">
      <div style="text-align: center;">
        <p>まばたきを検出します。</p>
        <ons-icon id="icon-eye" icon="fa-meh-o" size="200px"></ons-icon>
        <p id="blink_msg"></p>
      </div>
    </ons-page>
  </ons-template>

  <!-- Bodyタブ -->
  <ons-template id="tab2.html">
    <ons-page id="second-page">
      <div style="text-align: center;">
        <p>体の傾きを検出します。</p>
        <ons-icon id="icon-body" icon="male" size="200px"></ons-icon>
        <p id="gyro_msg"></p>
        <br />
        <h2 id="tilt_msg"></h2>
      </div>
    </ons-page>
  </ons-template>

  <!-- Tilt&swingタブ -->
  <ons-template id="tab3.html">
    <ons-page id="third-page">
      <div style="text-align: center;">
        <p>連続スイング(横/縦)</p>
        <h3 id="swing_lat_dtl_msg"></h3>
        <h3 id="swing_long_dtl_msg"></h3>
        <br />
        <p>視線コントロール(横/縦)</p>
        <h3 id="em_lat_dtl_msg"></h3>
        <h3 id="em_long_dtl_msg"></h3>
      </div>
    </ons-page>
  </ons-template>

  <!-- デバイス選択ダイアログ -->
  <ons-dialog id="selectDeviceDialog">
    <ons-list id="deviceList">
    </ons-list>
  </ons-dialog>

  <!-- モーダルウィンドウ -->
  <ons-modal id="modal">
    <p>接続中...</p>
    <ons-icon icon="spinner" size="28px" spin></ons-icon>
  </ons-modal>

</body>

終わりに

頭や眼だけで操作できると便利なものは色々ありそうですよね。みなさんこちらを使ってIoTアプリ開発を楽しんでください!!!