RAPIRO(ラピロ)のスマホリモコン(ライブ映像付き)

今回は、前回の続きでラピロに色々な感情表現をさせようかと思いましたが、ちょっとカメラ機能の方を優先的にやりたくなったので、そちらをやることにします。

ラピロを動かしながらのカメラ撮影は、過去に一度やろうとしたのですが、そのときはなぜかうまくいきませんでした。今なら環境も色々変わってきているかも、ということで、再チャレンジです。

これを使うので、スイッチサイエンスさんのブログを参考にするのが良さそうです。元々Cloud Piの紹介用ページですが、別にCloud Piを使わなくても、カメラの設定方法やmjpg-streamerの設定方法はそのまま流用できるハズです。

ちなみにCloud Piは購入済みなのですが、iOS版のクライアントアプリがまだ手に入らんのです(2015/1/29時点)。普段持ちがiPhoneなので、それが出てから使い始めたほうが良いかと思って、絶賛保留中です。

では、早速。

まず、カメラの有効化です。

$ sudo apt-get update
$ sudo raspi-config

“Enable Camera”の設定で”Enable”にして、”Finish”、再起動します。

それから、ライブ配信サーバソフト”mjpg-streamer”を利用するための環境構築をします。

$ sudo apt-get install libv4l-dev libjpeg8-dev imagemagick
$ sudo apt-get install cmake

“mjpg-streamer”をgitからクローンしてきます。

$ git clone https://github.com/jacksonliam/mjpg-streamer

ちなみに、スイッチサイエンスさんのブログでは、gitのURLが間違っています(2015/1/29時点)。”jacksoliam”ではなく”jacksonliam”が正解です。

落としてきたら、makeします。

$ cd mjpg-streamer/mjpg-streamer-experimental
$ make

makeできたら、wwwフォルダに移動して、テスト用のcamera.htmlファイルを作ります。

<html>
  <body>
    <img src="/?action=stream">
  </body>
</html>

できたら、一つ上のフォルダに移動して、サーバをスタートさせます。

$ ./mjpg_streamer -o "./output_http.so -w ./www -p 11080" -i "./input_raspicam.so -fps 10 -vf -hf"

この場合、ポート番号は11080を指定しているので、iPhoneで”http://192.168.xxx.xxx:11080/camera.html”にアクセスすると、こんな感じになります。

rapiro_camera_test

うん、問題なさそうです。

次は、スマホからコントロールするためのUI作成です。Bootstrapを使って、できる限り簡単に作りたいと思います。

さっきのmjpg-streamerのwwwフォルダ中に作り込んでいってもいいのですが、mjpg-streamerのデフォルトファイルが色々入ってごちゃごちゃしているので、適当に”www”と同階層に”rapiro”とかフォルダを作って、mjpg-streamerの実行時に、オプションでこのフォルダを参照するようにします。こんな感じです。

$ ./mjpg_streamer -o "./output_http.so -w ./rapiro -p 11080" -i "./input_raspicam.so -fps 10 -vf -hf "

Bootstrap(とjQuery)はCDN( Content Delivery Network)で読み込んで利用するようにすると、Bootstrap用のCSSフォルダとかを用意しなくてよいのでラクです。この場合、”rapiro”フォルダの下には以下のファイルを置くだけになります。

  • control.html
  • control.css
  • contorl.js

それぞれのファイルはこんな感じです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link href="//netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css" rel="stylesheet">
  <link href="control.css" rel="stylesheet">
  <title>Control RAPIRO</title>
</head>
<body>
  <div class="container">

    <div class="panel panel-default">
      <div class="panel-heading">
        <h3>Control RAPIRO</h3>
      </div>
      <div class="panel-body text-center">
        <img class="img-responsive img-thumbnail" src="/?action=stream">
        <br>
        <p id="head_angle">Front</p>
        <div class="row">
          <div class="col-xs-offset-2 col-xs-2 head">
            <a href="#" class="button" id="head_left">Left</a>
          </div>
          <div class="col-xs-offset-1 col-xs-2 head">
            <a href="#" class="button" id="head_front">Front</a>
          </div>
          <div class="col-xs-offset-1 col-xs-2 head">
            <a href="#" class="button" id="head_right">Right</a>
          </div>
          <div class="col-xs-2">
          </div>
        </div>
        <br>
        <br>
        <div class="row">
          <div class="col-xs-offset-2 col-xs-2 extension" id="voice_box">
            <a href="#" class="button"id="voice">Voice</a>
          </div>
          <div class="col-xs-offset-1 col-xs-2 control">
            <a href="#" class="button"id="forward">Forward</a>
          </div>
          <div class="col-xs-5">
          </div>
        </div>
        <br>
        <div class="row">
          <div class="col-xs-offset-2 col-xs-2 control">
            <a href="#" class="button" id="left">Left</a>
          </div>
          <div class="col-xs-offset-1 col-xs-2 control">
            <a href="#" class="button" id="stop">Stop</a>
          </div>
          <div class="col-xs-offset-1 col-xs-2 control">
            <a href="#" class="button" id="right">Right</a>
          </div>
          <div class="col-xs-2">
          </div>
        </div>
        <br>
        <div class="row">
          <div class="col-xs-offset-5 col-xs-2 control">
            <a href="#" class="button" id="back">Back</a>
          </div>
          <div class="col-xs-offset-1 col-xs-2 extension">
            <a href="#" class="button" id="init">Wait</a>
          </div>
          <div class="col-xs-2">
          </div>
        </div>
        <br>
        <br>
        <div class="row">
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_none">None</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_normal">Normal</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_happy">Happy</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_angry">Angry</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_sad">Sad</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_surprise">!?</a>
          </div>
        </div>
        <div class="row">
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_silent">Silent</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_genial">Genial</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_love">Love</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_giveup">GiveUp</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_oops">Oops!</a>
          </div>
          <div class="col-xs-2 emotion">
            <a href="#" class="button" id="emotion_come">(゚∀゚)</a>
          </div>
        </div>
      <div class="panel-footer text-center">
        <p>&copy;make-muda.weblike.jp</p>
      </div>
    </div> 
  </div>
<script type="text/javascript" src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<script type="text/javascript" src="//netdna.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
<script type="text/javascript" src="control.js"></script>
</body>
</html>

Bootstrapを使うと、ちょっと凝ったことをやろうとすると逆に扱いづらい気がしますが、シンプルなUIでよければとてもわかりやすく書けます。21〜34行目がラピロの頭をコントロールするためのボタン群、37〜71行目がラピロ全体の動作をコントロールするためのボタン群、74〜113行目がラピロの表情を変化させるためのボタン群です。最後の表情変化については、次回紹介する予定です。

.button{
  color: #ffffff;
}

.button:hover{
  color: #b22222;
  text-decoration: none;
}
/*******************************************/

.head{
  padding: 10px 0px;
  background-color: #6495ed; 
  border-radius: 10px;
  -webkit-border-radius: 10px; 
  -moz-border-radius: 10px;
}

/*******************************************/

.control{
  padding: 10px 0px;
  background-color: #ff8c00; 
  border-radius: 10px;
  -webkit-border-radius: 10px; 
  -moz-border-radius: 10px;
}

/*******************************************/
.extension{
  padding: 10px 0px;
  background-color: #008b8b; 
  border-radius: 10px;
  -webkit-border-radius: 10px; 
  -moz-border-radius: 10px;
}

/*******************************************/
.emotion{
  padding: 10px 0px;
  background-color: #cd5c5c; 
}

CSSも特に難しいことはしていません。ほんとは三角形をCSSだけで作ってみたりと色々やってみたのですが、最終的に余計なことしない方がいいな、という結論に至りました。

const HEAD_LEFT_MAX = 150;
const HEAD_RIGHT_MIN = 30;
const HEAD_FRONT = 90;
var angle  = HEAD_FRONT;

function sendToPythonServer(target, paramObj){
  var url   = "http://192.168.24.50:10080/v1/robots/rapiro/control/" + target;
  var param = paramObj || {};
  $.get(url, param, function(data){
    console.log(data);
  });
}

function update_angle(angle){
    if(angle < 90){
      $('#head_angle').text("R:" + String(90-angle) + "°");
    }else if(angle == 90){
      $('#head_angle').text("FRONT");
    }else{
      $('#head_angle').text("L:" + String(angle-90) + "°");
    }
}

$('#head_left').on('click', function (e) {
  console.log("Head Left.");
  if(angle < HEAD_LEFT_MAX){
    angle += 20;
    update_angle(angle);
    sendToPythonServer("head", {value:angle}); 
  }else{
    console.log("Sorry, I can NOT turn left anymore.");
  } 
  return false;
});

$('#head_front').on('click', function (e) {
  console.log("Head Front.");
  angle = HEAD_FRONT;
  update_angle(angle);
  sendToPythonServer("head", {value:HEAD_FRONT});
  return false;
});

$('#head_right').on('click', function (e) {
  console.log("Head Right.");
  if(angle > HEAD_RIGHT_MIN){
    angle -= 20;
    update_angle(angle);
    sendToPythonServer("head", {value:angle}); 
  }else{
    console.log("Sorry, I can NOT turn right anymore.");
  } 
  return false;
});

$('#forward').on('click', function (e) {
  console.log("Forward.");
  angle = HEAD_FRONT;
  update_angle(angle);
  sendToPythonServer("forward"); 
  return false;
});

$('#back').on('click', function (e) {
  console.log("Back.");
  angle = HEAD_FRONT;
  update_angle(angle);
  sendToPythonServer("back"); 
  return false;
});

$('#left').on('click', function (e) {
  console.log("Left.");
  angle = HEAD_FRONT;
  update_angle(angle);
  sendToPythonServer("left"); 
  return false;
});

$('#right').on('click', function (e) {
  console.log("Right.");
  angle = HEAD_FRONT;
  update_angle(angle);
  sendToPythonServer("right"); 
  return false;
});

$('#stop').on('click', function (e) {
  console.log("Stop.");
  angle = HEAD_FRONT;
  update_angle(angle);
  sendToPythonServer("stop"); 
  return false;
});

$('#init').on('click', function (e) {
  console.log("Init.");
  angle = HEAD_FRONT;
  update_angle(angle);
  sendToPythonServer("init"); 
  return false;
});

$('#emotion_none').on('click', function (e) {
  console.log("Emotion None");
  sendToPythonServer("emotion",{value: "none"}); 
  return false;
});

$('#emotion_normal').on('click', function (e) {
  console.log("Emotion Normal");
  sendToPythonServer("emotion",{value: "normal"}); 
  return false;
});

$('#emotion_happy').on('click', function (e) {
  console.log("Emotion Happy");
  sendToPythonServer("emotion",{value: "happy"}); 
  return false;
});

$('#emotion_angry').on('click', function (e) {
  console.log("Emotion Angry");
  sendToPythonServer("emotion",{value: "angry"}); 
  return false;
});

$('#emotion_sad').on('click', function (e) {
  console.log("Emotion Sad");
  sendToPythonServer("emotion",{value: "sad"}); 
  return false;
});

$('#emotion_surprise').on('click', function (e) {
  console.log("Emotion Surprise");
  sendToPythonServer("emotion",{value: "surprise"}); 
  return false;
});

$('#emotion_silent').on('click', function (e) {
  console.log("Emotion Silent");
  sendToPythonServer("emotion",{value: "silent"}); 
  return false;
});

$('#emotion_genial').on('click', function (e) {
  console.log("Emotion Genial");
  sendToPythonServer("emotion",{value: "genial"}); 
  return false;
});

$('#emotion_love').on('click', function (e) {
  console.log("Emotion Love");
  sendToPythonServer("emotion",{value: "love"}); 
  return false;
});

$('#emotion_giveup').on('click', function (e) {
  console.log("Emotion GiveUp");
  sendToPythonServer("emotion",{value: "giveup"}); 
  return false;
});

$('#emotion_oops').on('click', function (e) {
  console.log("Emotion oops");
  sendToPythonServer("emotion",{value: "oops"}); 
  return false;
});

$('#emotion_come').on('click', function (e) {
  console.log("Emotion Come");
  sendToPythonServer("emotion",{value: "come"}); 
  return false;
});

// Test brower support

window.SpeechRecognition = window.SpeechRecognition ||
                           window.webkitSpeechRecognition ||
                           null;

if(window.SpeechRecognition == null){
  $('#voice_box').css('visibility', 'hidden');
}else{
  var recognizing;
  var recognition = new webkitSpeechRecognition();
  recognition.continuous = true;
  recognition.interimResults = false;
  recognition.lang = "ja-JP";
  recognizing = false;

  recognition.onstart = function (event) {
    console.log("Recognize on start");
    $('#voice').css('color', '#00ff00');
    recognizing = true;
  }

  recognition.onspeechstart = function (event) {
    console.log("Recognize on speech start");
  }

  recognition.onspeechend = function (event) {
    console.log("Recognize on speech end");
  }

  recognition.onend = function (event) {
    console.log("Recognize on end");
    $('#voice').css('color','#ffffff');
    recognizing = false;
  }

  recognition.onerror = function (event) {
    console.log("Recognize on error:" + event.error);
  }

  recognition.onresult = function (event) {
    console.log("Recognized!");
    var interim_transcript = '';
    var result_script = '';
    for (var i = event.resultIndex; i < event.results.length; ++i) {
      result_script = event.results[i][0].transcript
      result_script = result_script.replace(/^\s+/g, "");
      if (event.results[i].isFinal) {
        console.log("FINAL: " + result_script);
        var command = null;
        var param = {};
        var url = "http://192.168.24.50:10080/v1/robots/rapiro/control/"
        if(result_script == "前に進んで"){
          command = "forward";
        }else if(result_script == "戻って"){
          command = "back";
        }else if(result_script == "右に進んで"){
          command = "right";
        }else if(result_script == "左に進んで"){
          command = "left";
        }else if(result_script == "止まって"){
          command = "stop";
        }else if(result_script == "待機して"){
          command = "init";
        }else if(result_script == "前を向いて"){
          command = "head";
          angle = HEAD_FRONT;
          update_angle(angle);
          param = {value: HEAD_FRONT};
        }else if(result_script == "左を向いて"){
          command = "head";
          if(angle < HEAD_LEFT_MAX){
            angle += 20;
            update_angle(angle);
            param = {value: angle};
          }else{
            console.log("Sorry, I can NOT turn left anymore.");
            command = null;
          } 
        }else if(result_script == "右を向いて"){
          command = "head";
          if(angle > HEAD_RIGHT_MIN){
            angle -= 20;
            update_angle(angle);
            param = {value: angle};
          }else{
            console.log("Sorry, I can NOT turn right anymore.");
            command = null;
          } 
        }
        // ------ HTTP Connection ------
        if(command != null){
          $.get(url+command, param, function(data){
            console.log(data);
          });
        }
      }
    }
  }

  $('#voice').on('click', function (e) {
    console.log("Toggle Speech Recognition ON/OFF.");
    if (recognizing) {
      recognition.stop();
    } else {
      recognition.start();
    }
  });
}

自分の実装では、2つのWebサーバを連携させる形にしています。シリアル通信を使ってラピロの動きを制御するPythonサーバと、カメラの映像を配信するためのmjpg-streamerのサーバです。このcontrol.jsは、同じRaspberry pi上で動いているPythonサーバに対してhttpでリクエストを出すことで、ラピロの制御を行っています。Python側のサーバは、Bottleフレームワークを使って実装しています。

177行目以下はWeb Speech APIを使った音声制御用の記述です。残念ながら、iPhoneでは使えません。AndroidならOK。

で、実際に作成したUIを使ってラピロを制御してみた様子が、冒頭の動画になります。Mac OS 10.10 Yosemiteから、iPhoneの画面上の動作を動画として簡単に録画することができるようになりました。素敵。

鏡で映しながら撮影していますが、どうやらウチのラピロはまっすぐ歩かせようとすると、ちょっと曲がってしまう模様。まあ、それもまたかわいいというものです。

Cloud Piを使えば、そのまま外出先からの自宅警備用に利用できそうです。

もう購入はしているので、iOS対応を心待ちにしております。

動画の中でちょこちょこと、ラピロの表情変化もリモコン操作しています。これだと小さくて見えにくいので、これについてはまた別記事としてあげようと思っていますが、これで喋らせる機能も追加したら、遠隔コミュニケーションロボットとしても使えそうですね。うーん、ワクワクします。次回はこのへんをもう少し掘り下げようと思います。