Web画面のマウスドラッグでパペロの首を動かす

 センサー値をリアルタイムグラフ表示するではWebSocketを使ってPaPeRo iのセンサー情報をWeb画面にリアルタイム表示しましたが、今度は逆に、Web画面上のマウスドラッグの動きをWebSocketでパペロに送って首を動かしてみます。

Web画面側

 canvasタグ上でマウスのボタンを押してそのまま動かすとそれにあわせてパペロの首が動き、マウスのボタンをはなしたりcanvasからマウスポインタが出たら動かなくなるようにしたいと思います。確認のためマウス座標も表示する様にします。

<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>
      ドラッグで首を動かすデモ
    </title>
  </head>
  <body>
    <canvas id="head-area" width="320" height="240" style="background-color:royalblue;"></canvas>
    <span>マウス座標</span>
    <input type="text" id="xy-value" readonly>
    <script src="draghead.js"></script>
  </body>
</html>

このHTMLを開いた時点でscriptタグに記述したdraghead.jsでWebSocket接続するようにするのは前回と同じです。今回JavaScriptで扱う必要があるイベントは以下の通りです。

  • mousedown マウスボタンが押された
  • mouseup マウスボタンがはなされた
  • mouseleave マウスポインタが要素から外れた
  • mousemove マウスポインタが移動した

mousedownでフラグをON、mouseupやmouseleaveでフラグをOFFし、フラグがONの間だけマウスの位置情報をパペロに送りますが、mousemoveごとにデータを送るのはデータが多すぎ、またパペロは首移動中にイベントがあっても捨てざるを得ないことから、mousemoveでは単に位置を記録し、setIntervalで200msecごとにその位置を送る方法にします。
具体的なデータは、
mousedownでは移動の原点として

{'Name': '__mousedown', 'pos': [event.layerX, event.layerY]}

200msecごとに送る位置情報は

{'Name': '__mousemove', 'pos': [event.layerX, event.layerY]}

としました。

WebサーバとWebSocketサーバ

 Webサーバは固定のhtml/jsを公開するだけで変わりありません。WebSocketサーバは受信したマウスのデータをPaPeRo iアプリに送る必要があります。pypaperoには外部のイベントを受け取る口がありませんでしたので、stpypaperoにそのためのメソッドput_ex_event()を追加しました。WebSocketサーバでは受け取ったマウスのデータをJSONデコードしてそのままput_ex_event()に渡しています(そのまま渡せるようにWebSocketの通信データの方を決めました)。クラス変数stpaperoは初期化時にstpypaperoのオブジェクトで直接書き換えます。

class WsHandler(tornado.websocket.WebSocketHandler):
    stpapero = None

    def on_message(self, msg):
        if self.stpapero:
            dic = json.loads(msg)
            self.stpapero.put_ex_event(None, dic)

PaPeRo iアプリ

 今回の様にマウスの操作次第でどの方向にもいくらでも動かせる場合に首の可動範囲からはみ出ない様にするためには、首の現在位置を保持している必要があります。このため、アプリ起動時にはまずsend_get_head_status()による首の位置取得を行い(in_get_pos)、その後マウスのドラッグ・位置データ待ち状態(in_wait)に遷移します。位置データを受け取ったら首移動を開始し、完了待ち状態(in_move)とします。移動中に位置データを受け取った場合は破棄します。

class PaperoApp(StPyPapero):
    def initialize(self):
        self.handler = self.in_init
        self.head_pos = None
        self.drag_robot = None
        self.drag_disp = None

    def trans_state(self, newhandler):
        self.handler = newhandler

    d2r = -0.2

    # 状態遷移メソッド
    def go_move(self, msg):
        robot = self.drag_robot
        disp = self.drag_disp
        if 2 != len(robot) or 2 != len(disp):
            logger.error('bad value: robot={} disp={}'.format(robot, disp))
            return
        disptgt = msg.get('pos')
        dispdif = (int(self.d2r * (disptgt[0] - disp[0])),
                   int(self.d2r * (disptgt[1] - disp[1])), )
        if dispdif == (0, 0):
            return False
        robottgt = (robot[0] + dispdif[0], robot[1] + dispdif[1], )
        if robottgt == self.head_pos:
            return False
        limpos = self.move_to(robottgt, sfx='S100L')
        self.head_pos = limpos
        self.trans_state(self.in_move)
        return True

    # 状態ごとのイベント処理メソッド
    def in_init(self, name, msg):
        if name == 'Ready':
            self.papero.send_get_head_status()
            self.trans_state(self.in_get_pos)

    def in_get_pos(self, name, msg):
        if name == 'getHeadStatusRes':
            self.head_pos = (int(msg.get('Horizontal')), int(msg.get('Vertical')))
            self.trans_state(self.in_wait)

    def in_wait(self, name, msg):
        if name == '__mousedown':
            self.drag_robot = self.head_pos
            self.drag_disp = msg.get('pos')
        elif name == '__mousemove':
            self.go_move(msg)

    def in_move(self, name, msg):
        if name == 'moveFinish':
            self.trans_state(self.in_wait)

in_wait()ではmousedownイベントでは首の現位置とWebSocketで送られてきたマウス座標を記録しています。mousemoveイベントではmousedown時の座標との相対位置に対して係数d2rを掛けて首移動のための指令値(角度)に変換し、首を動かしています(go_move())。

動作確認

 このデモプログラムはPaPeRo i上でもラズパイでもPCでも、pythonが動作する環境であれば動かすことが出来ます。pypapero.pyとこの後に記載しているソースファイルを全て同じディレクトリに置いて下さい。PaPeRo iのアドレスはdraghead.pyに直書きしているので必要なら修正し、以下で起動して下さい。

> python3 draghead.py

ローカルPCで起動した場合、ブラウザでlocalhost:8888/draghead.htmlを開くと青い画面が表示されるので、その内側でマウスをドラッグして動かすと、それにあわせてパペロの首が動きます。別のホストで動かす場合にはlocalhostの部分をIPアドレスと入れ替えて下さい。

ソース

(1) draghead.py

# -*- coding: utf-8 -*-
import os
import json
from logging import (getLogger, debug, info, warn, error, critical,
                     DEBUG, INFO, WARN, ERROR, CRITICAL, basicConfig)

import tornado.ioloop
import tornado.web
import tornado.websocket

from stpypapero import StPyPaperoThread as StPyPapero


logger = getLogger(__name__)


class HtmlHandler(tornado.web.RequestHandler):
    def get(self):
        self.render('draghead.html')


class JsHandler(tornado.web.RequestHandler):
    def get(self):
        self.render('draghead.js')


class WsHandler(tornado.websocket.WebSocketHandler):
    clients = []
    stpapero = None

    def initialize(self):
        """ initialize (called by tornado) """
        self.clients.append(self)

    def open(self, *args, **kwargs):
        logger.debug("WsHandler args:{} kwargs:{}".format(args, kwargs))

    def on_message(self, msg):
        logger.debug("WsHandler msg:{}".format(msg))
        if self.stpapero:
            dic = json.loads(msg)
            self.stpapero.put_ex_event(None, dic)

    def on_close(self):
        logger.debug("WsHandler")
        self.clients.remove(self)

    def send(self, msg, binary=False):
        logger.debug("WsHandler msg:{}".format(msg))
        super().write_message(msg, binary)

    @classmethod
    def broadcast(cls, msg):
        for con in cls.clients:
            con.send(msg)

    @classmethod
    def invoke_broadcast(cls, msg):
        tornado.ioloop.IOLoop.current().add_callback(cls.broadcast, msg)


def start_web(papero):
    WsHandler.stpapero = papero
    app = tornado.web.Application([
        (r'/draghead.html', HtmlHandler),
        (r'/draghead.js', JsHandler),
        (r'/__ws', WsHandler)
        ],
        template_path=os.path.dirname(__file__),
    )
    app.listen(8888)
    tornado.ioloop.IOLoop.current().start()


class PaperoApp(StPyPapero):
    def initialize(self):
        self.handler = self.in_init
        self.head_pos = None
        self.drag_robot = None
        self.drag_disp = None

    def trans_state(self, newhandler):
        self.handler = newhandler

    d2r = -0.2

    # 状態遷移メソッド
    def go_move(self, msg):
        robot = self.drag_robot
        disp = self.drag_disp
        if 2 != len(robot) or 2 != len(disp):
            logger.error('bad value: robot={} disp={}'.format(robot, disp))
            return
        disptgt = msg.get('pos')
        dispdif = (int(self.d2r * (disptgt[0] - disp[0])),
                   int(self.d2r * (disptgt[1] - disp[1])), )
        if dispdif == (0, 0):
            return False
        robottgt = (robot[0] + dispdif[0], robot[1] + dispdif[1], )
        if robottgt == self.head_pos:
            # logger.info('no move')
            return False
        limpos = self.move_to(robottgt, sfx='S100L')
        self.head_pos = limpos
        logger.info('move to {} => {}'.format(robottgt, limpos))
        self.trans_state(self.in_move)
        return True

    # 状態ごとのイベント処理メソッド

    def in_init(self, name, msg):
        if name == 'Ready':
            self.papero.send_get_head_status()
            self.trans_state(self.in_get_pos)

    def in_get_pos(self, name, msg):
        # logger.info('event: {} {}'.format(name, msg))
        if name == 'getHeadStatusRes':
            self.head_pos = (int(msg.get('Horizontal')), int(msg.get('Vertical')))
            self.trans_state(self.in_wait)

    def in_wait(self, name, msg):
        # logger.info('event: {} {}'.format(name, msg))
        if name == '__mousedown':
            self.drag_robot = self.head_pos
            self.drag_disp = msg.get('pos')
        elif name == '__mousemove':
            self.go_move(msg)

    def in_move(self, name, msg):
        # logger.info('event: {} {}'.format(name, msg))
        if name == 'moveFinish':
            self.trans_state(self.in_wait)


def main():
    simid = ''
    url = 'ws://192.168.1.1:8088/papero'
    papero = PaperoApp(url=url, simid=simid)
    papero.start()
    start_web(papero)


if __name__ == '__main__':
    basicConfig(format='%(asctime)-15s %(levelname)s %(module)s.%(funcName)s %(message)s')
    logger = getLogger()  # root logger
    # logger.setLevel(DEBUG)
    logger.setLevel(INFO)
    logger.info('start')
    main()

(2) draghead.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" 
      content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>
      ドラッグで首を動かすデモ
    </title>
  </head>
  <body>
    <div id="photo">
      <canvas id="head-area" width="320" height="240" style="background-color:royalblue;">
      </canvas>
    </div>
    <span>
      マウス座標
    </span>
    <input type="text" id="xy-value" readonly>

    <script src="draghead.js"></script>
  </body>
</html>

(3) draghead.js
 上ではマウスイベントのみ説明しましたが、タッチイベントでも動かせる様にしてあります。但しスクロールなどタッチイベントのデフォルト動作を無効化するための記述は入っていません。

(function() {
  var moveHeadInterval = 200;
  var ws;
  var proto = 'https:' == location.protocol ? 'wss:' : 'ws:';
  var ws_url = proto + "//" + location.host + "/__ws";
  if ("WebSocket" in window) {
    ws = new WebSocket(ws_url);
  } else if ("MozWebSocket" in window) {
    ws = new MozWebSocket(ws_url);
  }
  var wssend = function(dic) {
    if (!ws) {
      return;
    }
    if (ws.readyState == 1) {
      var dat = JSON.stringify(dic)
      ws.send(dat);
    } else {
      alert('接続が切れました。リロードして下さい。');
      ws = undefined;
    }
  };
  var elms = {
    'head-area': null,
    'xy-value': null,
  };
  var setval = function(name, val) {
    var elm = elms[name];
    if (elm) {
      elm.value = val;
    }
    else {
      console.error('bad name: ' + name);
    }
  }
  var isDragged = false;
  var draggedPos;
  var lastPos;
  var dragTimer;
  var sendPos = function() {
    if (isDragged && lastPos) {
      wssend({'Name': '__mousemove', 'pos': lastPos});
      //console.info('lastPos: ' + lastPos);
    }
  };
  var dragChange = function(en, pos) {
    isDragged = en;
    if (en) {
      wssend({'Name': '__mousedown', 'pos': pos});
      setval('xy-value', pos);
      draggedPos = pos;
      lastPos = pos;
      dragTimer = setInterval(sendPos, moveHeadInterval);
    }
    else {
      setval('xy-value', '');
      if (dragTimer !== undefined) {
        clearInterval(dragTimer);
        dragTimer = undefined;
      }
    }
  };
  var dragMove = function(pos) {
    lastPos = pos;
    setval('xy-value', pos);
  }
  var onMouseDownInHeadArea = function(evnt) {
    var gs = window.getSelection();
    if (gs.empty) {
      gs.empty();
    }
    else if (gs.removeAllRanges) {
      gs.removeAllRanges();
    }
    dragChange(true, [evnt.layerX, evnt.layerY]);
  };
  var onMouseUpInHeadArea = function(evnt) {
    dragChange(false);
  };
  var onMouseLeaveInHeadArea = function(evnt) {
    if (isDragged) {
      dragChange(false);
    }
  };
  var onMouseMoveInHeadArea = function(evnt) {
    var now = new Date().getTime();
    if (isDragged) {
      dragMove([evnt.layerX, evnt.layerY])
    }
  };
  // for smart phone
  var onTouchStartInHeadArea = function(evnt) {
    var touch = evnt.targetTouches[0];
    dragChange(true, [touch.clientX, touch.clientY]);
  };
  var onTouchEndInHeadArea = function(evnt) {
    dragChange(false);
  };
  var onTouchLeaveInHeadArea = function(evnt) {
    if (isDragged) {
      dragChange(false);
    }
  };
  var onTouchMoveInHeadArea = function(evnt) {
    if (isDragged) {
      var touch = evnt.targetTouches[0];
      if (isDragged) {
        dragMove([touch.clientX, touch.clientY]);
      }
    }
  };
  // handler table
  var handlerTbl = [
    ['head-area', 'mousedown', onMouseDownInHeadArea, ],
    ['head-area', 'mouseup', onMouseUpInHeadArea, ],
    ['head-area', 'mouseleave', onMouseLeaveInHeadArea, ],
    ['head-area', 'mousemove', onMouseMoveInHeadArea, ],
    ['head-area', 'touchstart', onTouchStartInHeadArea, ],
    ['head-area', 'touchend', onTouchEndInHeadArea, ],
    ['head-area', 'touchleave', onTouchLeaveInHeadArea, ],
    ['head-area', 'touchmove', onTouchMoveInHeadArea, ],
  ];
  var init = function() {
    // element table
    for (var name in elms) {
      elms[name] = document.getElementById(name);
    }
    // handlers
    for (var ent of handlerTbl) {
      var elm = elms[ent[0]];
      if (elm) {
        elm.addEventListener(ent[1], ent[2], false);
      }
    }
  };
  document.body.onload = init;
  ws.onclose = function(event) {
    // ...
  };
  ws.onmessage = function(event){
    var js = JSON.parse(event.data);
    // ...
  };
})();

(4) stpypapero.py

# -*- coding: utf-8 -*-
# pypaperoラッパークラスstpypapero
# (c) 2017-2018 Sophia Planning Inc.
# LICENSE: MIT
#
# Ver   Date       Description
# 0.3.0 2018/05/29 put_ex_event(), move_to()追加
# 0.2.0 2018/05/22 状態変数を廃止、状態処理メソッドself.handlerを書き換える方法に変更
# 0.1.1 2018/02/20 speech()にmouth_en, head_en追加
# 0.1.0 2018/02/09 名称変更paperoappbase->stpypapero、on_startup削除、
#                  メッセージのリスト(現状要素数常に1)ではなく要素を渡すよう変更、
#                  paperoの引数指定追加、処理関数の返値をTRUEでプログラム終了(return書かなければ継続)に反転
#                  タイマー機能追加
# 0.0.2 2018/02/02 基本クラスAppBaseと派生クラスThreadAppBaseに分離
# 0.0.1 2017/12/19 Initial version
"""
使い方:
(1) StPyPaperoを継承したクラスを作る
(2) in_xxxで各状態ごとの処理を記述
(3) initialize()でself.handlerに初期状態用のin_xxxを設定
(4) 状態遷移時にはself.handlerを別のin_xxxに書き換える

機能:
(1) 終話検知
  stpypapero.speech(text)
  ...
  def in_xxxx(self, name, msg):
      if name == '_endOfSpeech':
          ...
 イベント: ''
(2) タイマー
  stpypapero.set_timer(delay, timer_id)
  stpypapero.cancel_timer(timer_id)
  ...
  def in_xxxx(self, name, msg):
      if name == '_timer':
          tid = msg.get('TimerID')
          if tid == timer_id:
              ...
(3) 外部イベント(パペロスレッドにパペロ以外のイベントを送る)
  stpypapero.put_ex_event('__exEvent1', {'data': [1,2,3,4]})
  ...
  def in_xxxx(self, name, msg):
      if name == '__exEvent1':
          ...
"""
import sys
import threading
import random
import json
from logging import (getLogger, basicConfig, DEBUG, INFO, WARN, ERROR,)

import pypapero

logger = getLogger(__name__)


# mouth data for speech
MOUTH_DAT = [
    [
        "G3" + "G3" + "G3" + "G3" + "G3" + "G3" + "G3" + "G3" + "G3", "3",
        "NNN" + "G3" + "G3" + "G3" + "NNN", "3",
        "NNNN" + "G3" + "NNNN", "3",
        "N" + "G3" + "G3" + "G3" + "G3" + "G3" + "G3" + "G3" + "N", "3",
        "NN" + "G3" + "G3" + "G3" + "G3" + "G3" + "NN", "3",
    ]
]

# head data for speech (random select)
HEAD_DAT = [
    [ # 0
        ["A4T300L",  "R0T300L",  "R0T300L",  "R0T300L", "A0T300L", ],  # x
        ["A3T300L", "A-3T300L", "A3T300L", "A-6T300L", "A0T300L", ],   # y
    ], [ # 1
        ["A-4T300L",  "R0T300L",  "R0T300L",  "R0T300L", "A0T300L", ],  # x
        ["A-3T300L", "A3T300L", "A-3T300L", "A6T300L", "A0T300L", ],    # y
    ], [ # 2
        ["A-3T300L",  "R0T300L",  "R5T300L",  "R0T300L", "A0T300L", ],  # x
        ["A-3T300L", "A3T300L", "A-2T300L", "A4T300L", "A0T300L", ],    # y
    ]
]


class StPyPapero(object):
    # papero application class
    def __init__(self, simid='', simname='', url=None, polling_sec=0.2, papero=None):
        if url is None:
            simid, simname, url = pypapero.get_params_from_commandline(sys.argv)
        self._polling_sec = polling_sec
        self._eos_wait_res = False  # send_get_speech_staus and waiting response
        self._eos_polling = False
        self._eos_callback = None  # end of speech callback
        self._timer_dic = {}
        if papero is None:
            papero = pypapero.Papero(simid, simname, url)
        self.papero = papero
        self.event_hook = None
        self.handler = None
        self.initialize()
        if 0 < self.papero.errOccurred:
            logger.error(self.papero.errDetail)
        else:
            self._inner_event_handler('Ready')

    """virtual
    def initialize(self):
        self.handler = self.in_init
    """

    def trans_state(self, newhandler):
        # transit state
        logger.info('state: {} -> {}'.format(self.handler, newhandler))
        self.handler = newhandler

    def speech(self, text,
               language=None, speaker_id=None,
               pitch=None, speed=None, volume=None,
               pause=None, comma_pause=None, urgent=None, priority="normal",
               eos_callback=None, mouth_en=True, head_en=True):
        # speech with head and mouth
        logger.debug('start speech')
        self._eos_polling = True
        self._eos_callback = eos_callback
        if mouth_en:
            self.papero.send_turn_led_on('mouth', MOUTH_DAT[0], repeat=True)
        if head_en:
            hidx = random.randint(0, len(HEAD_DAT) - 1)
            self.papero.send_move_head(HEAD_DAT[hidx][1], HEAD_DAT[hidx][0])
        self.papero.send_start_speech(text, language=language, speaker_id=speaker_id,
                                      pitch=pitch, speed=speed, volume=volume,
                                      pause=pause, comma_pause=comma_pause,
                                      urgent=urgent, priority=priority)

    def recv_msg(self):
        msg = self.papero.papero_robot_message_recv(self._polling_sec)
        return self._inner_event_handler(msg)

    def _inner_event_handler(self, msg):
        if self.papero.errOccurred != 0:
            return True
        # msg = self.recv_msg()
        if msg is None:
            if self._eos_polling and not self._eos_wait_res:
                # poll end of speech
                self.papero.send_get_speech_status()
                self._eos_wait_res = True
            msg = self._timeout_hook()
        if msg is None:
            return False
        else:
            res = self._handle_msg(msg)
            return res

    def run(self):
        while True:
            if self.recv_msg():
                break
        self.papero.papero_cleanup()

    def _timeout_hook(self):
        return None

    """
    def in_init(self, name, msg):
        if name == 'Ready':
            self.trans_state(self.State.IDLE)

    def in_idle(self, name, msg):
        pass
    """

    def _handle_msg(self, msg):
        if msg is None:
            return False
        if isinstance(msg, str):
            name = msg
        elif isinstance(msg, list):
            if 1 == len(msg):
                msg = msg[0]
                name = msg.get('Name')
            else:
                name = 'MsgList'
        else:
            name = msg.get('Name')
        if name == "getSpeechStatusRes":
            self._eos_wait_res = False
            ret = msg.get("Return")
            if self._eos_polling and (ret == 0):
                logger.debug('end of speech')
                self._eos_polling = False
                self.papero.send_turn_led_off('mouth')
                if callable(self._eos_callback):
                    self._eos_callback()
                # change name
                name = "_endOfSpeech"
        if callable(self.event_hook):
            self.event_hook(name, msg)
        # call function for this state
        if callable(self.handler):
            self.handler(name, msg)

    # callbackが別スレッドで動く事に注意
    def _set_timeout(self, delay, callback, *args, **kwargs):
        hdl = threading.Timer(delay, callback, args, kwargs)
        hdl.start()
        return hdl

    def _remove_timeout(self, hdl):
        hdl.cancel()

    def _send_timeout_msg(self, timer_id):
        msg = ('{{"Name": "RobotMessage", "Messages": [' +
               '{{"Name": "_timer", "TimerID": "{}"}}] }}').format(timer_id)
        self.papero.queFromCom.put(msg)

    def set_timer(self, delay, timer_id):
        hdl = self._set_timeout(delay, self._send_timeout_msg, timer_id)
        self._timer_dic[timer_id] = hdl

    def cancel_timer(self, timer_id):
        if timer_id in self._timer_dic:
            hdl = self._timer_dic[timer_id]
            self._remove_timeout(hdl)

    def put_ex_event(self, name, msg=None):
        if msg is None:
            msg = {}
        if name is not None:
            msg['Name'] = name
        dic = {
            'Name': 'RobotMessage',
            "Messages": [msg, ],
        }
        s = json.dumps(dic)
        self.papero.queFromCom.put(s)

    @staticmethod
    def limit(x, lb, ub):
        if x < lb:
            return lb
        if x > ub:
            return ub
        return x

    def move_to(self, xy, y=None, pfx='A', sfx='S40L'):
        if y is None:
            x, y = xy
        else:
            x = xy
        x = self.limit(x, -128, 128)
        y = self.limit(y, -20, 55)
        hs = ['{}{}{}'.format(pfx, int(x), sfx), ]
        vs = ['{}{}{}'.format(pfx, int(y), sfx), ]
        self.papero.send_move_head(vs, hs)
        return x, y


class StPyPaperoThread(threading.Thread, StPyPapero):
    def __init__(self, simid='', simname='', url=None, polling_sec=0.2, papero=None):
        if url is None:
            simid, simname, url = pypapero.get_params_from_commandline(sys.argv)
        # threading.Thread.__init__()
        super(StPyPaperoThread, self).__init__()
        # PyPaperoAppBase.__init__()
        super(threading.Thread, self).__init__(simid=simid, simname=simname,
                                               url=url, polling_sec=polling_sec,
                                               papero=papero)

    def run(self):
        return super(threading.Thread, self).run()

0