Bakulog

獏の夢日記的な何か。

【Python】Pepperのメタプログラミング【qi Framework】

アプリ向けのtipsではなくライブラリの内部に興味がある人向けのお話です。

 

もくじ

  1. とりあえずサンプルやってみよう!
  2. このJSONなんて書いてあるの?
  3. メタデータの使い道の例: コード生成
  4. まとめと注意

   

1. とりあえずサンプルやってみよう!


qi Frameworkのメタデータって何、というような話題の前にまずこのサンプルコードをデスクトップかどっかにコピペして実行してください。実行にはPython 2.7系とPyNAOqi SDKが必要です。

# -*- coding: utf-8 -*-
import json, os, sys, time

import qi


def saveJson(session, serviceName, baseDir=""):
    fname = os.path.join(baseDir, "{0}.json".format(serviceName))
    with open(fname, "w+") as f:
        metaObject = session.service(serviceName).metaObject()
        f.write(json.dumps(metaObject, sort_keys=True, indent=2))


def main():
    ip = "127.0.0.1"
    port = "9559"
    if len(sys.argv) >= 2:
        ip = sys.argv[1]
    if len(sys.argv) >= 3:
        port = sys.argv[2]

    s = qi.Session()
    try:
        s.connect(ip + ":" + port)
    except Exception:
        print("wrong target address/port, usage is")
        print("python <scriptname> [ip] [port]")
        print("example:")
        print("python hoge.py pepper.local 9559")
        return

    #スクリプトの横に"QiFrameworkMetadata"フォルダを用意
    baseDir = os.path.join(
        os.path.dirname(os.path.abspath(__file__)),
        "QiFrameworkMetadata"
        )
    if not os.path.exists(baseDir):
        os.makedirs(baseDir)

    #例1: 一つだけjson作って保存
    #saveJson(s, "ALTextToSpeech", baseDir)

    #例2: ありったけのサービス情報をすべてjsonで保存
    for serviceName in (x["name"] for x in s.services()):
        saveJson(s, serviceName, baseDir)

if __name__ == "__main__":
    main()

 

実行に際しては下記のように、接続先になるPepper/NaoのIPアドレスを指定します。

python get_metaobject.py 192.168.xx.xx

これを実行するとスクリプトと同じ階層に"QiFrameworkMetadata"フォルダが作成され、その下にjsonファイルがバババッと保存されます。

   

2. このJSONなんて書いてあるの?


保存されたJSONを見てみましょう。ここでは典型的な例として合成音声APIである"ALTextToSpeech.json"の中身を一部抜粋します。

{
  "description": "This module embeds a speech synthetizer whose role is to convert text commands into sound waves that are then either sent to Nao's loudspeakers or written into a file. This service supports several languages and some parameters of the synthetizer can be tuned to change each language's synthetic voice.", 
  "methods": {
    "0": {
      "description": "", 
      "name": "registerEvent", 
      "parameters": [], 
      "parametersSignature": "(IIL)", 
      "returnDescription": "", 
      "returnSignature": "L", 
      "uid": 0
    }, 
    "1": {
      "description": "", 
      "name": "unregisterEvent", 
      "parameters": [], 
      "parametersSignature": "(IIL)", 
      "returnDescription": "", 
      "returnSignature": "v", 
      "uid": 1
    }, 
    "2": {
      "description": "", 
      "name": "metaObject", 
      "parameters": [], 
      "parametersSignature": "(I)", 
      "returnDescription": "", 
      "returnSignature": "({I(Issss[(ss)<MetaMethodParameter,name,description>]s)<MetaMethod,uid,returnSignature,name,parametersSignature,description,parameters,returnDescription>}{I(Iss)<MetaSignal,uid,name,signature>}{I(Iss)<MetaProperty,uid,name,signature>}s)<MetaObject,methods,signals,properties,description>", 
      "uid": 2
    }, 
    // (途中長いので省略)
    "114": {
      "description": "Performs the text-to-speech operations : it takes a std::string as input and outputs a sound in both speakers. String encoding must be UTF8.", 
      "name": "say", 
      "parameters": [
        {
          "description": "Text to say, encoded in UTF-8.", 
          "name": "stringToSay"
        }
      ], 
      "parametersSignature": "(s)", 
      "returnDescription": "", 
      "returnSignature": "v", 
      "uid": 114
    }, 
    "115": {
      "description": "Performs the text-to-speech operations in a specific language: it takes a std::string as input and outputs a sound in both speakers. String encoding must be UTF8. Once the text is said, the language is set back to its initial value.", 
      "name": "say", 
      "parameters": [
        {
          "description": "Text to say, encoded in UTF-8.", 
          "name": "stringToSay"
        }, 
        {
          "description": "Language used to say the text.", 
          "name": "language"
        }
      ], 
      "parametersSignature": "(ss)", 
      "returnDescription": "", 
      "returnSignature": "v", 
      "uid": 115
    }, 
    // (また長いので省略)
    "151": {
      "description": "TODO", 
      "name": "_loadDictionary", 
      "parameters": [], 
      "parametersSignature": "()", 
      "returnDescription": "", 
      "returnSignature": "v", 
      "uid": 151
    }
  }, 
  "properties": {}, 
  "signals": {
    "86": {
      "name": "traceObject", 
      "signature": "((IiIm(ll)<timeval,tv_sec,tv_usec>llII)<EventTrace,id,kind,slotId,arguments,timestamp,userUsTime,systemUsTime,callerContext,calleeContext>)", 
      "uid": 86
    }, 
    "152": {
      "name": "synchroTTS", 
      "signature": "(L)", 
      "uid": 152
    }, 
    "153": {
      "name": "languageTTS", 
      "signature": "(s)", 
      "uid": 153
    }
  }
}

 

長々と書かれていますが、そこまで変な内容ではありません。内容は下記の通りです1

  • description: サービスの説明文(「このサービスは合成音声のAPIです」等)
  • methods: メソッド一覧
    • description: メソッドの説明文(「この関数でロボットが喋ります」等)
    • name: メソッド名
    • parameters: メソッド引数の説明文的なデータ
    • parametersSignature: メソッド引数の型情報
    • returnDescription: 戻り値の説明文的なデータ
    • returnSignature: 戻り値の型情報
    • uid: 一意識別用に割り振られてるID
  • properties: プロパティ一覧
    • name: プロパティ名
    • signature: プロパティの型情報
    • uid: 一意識別用に割り振られてるID
  • signals: シグナル一覧
    • name: シグナル(イベント)名
    • signature: シグナルとともに送られる型の情報
    • uid: 一意識別用に割り振られてるID

 

一言で言うと上記JSONは「サービスの中身をクラス定義っぽく表したもの」程度に解釈すれば問題ありません。データを取得してJSONデータに変換し、保存するまでの処理はsaveJson関数で定義されています。

def saveJson(session, serviceName, baseDir=""):
    fname = os.path.join(baseDir, "{0}.json".format(serviceName))
    with open(fname, "w+") as f:
        metaObject = session.service(serviceName).metaObject()
        f.write(json.dumps(metaObject, sort_keys=True, indent=2))

サービスに対してmetaObject関数を使うことでメタ情報を拾い、それをjson.dumpsですぐに文字列化しています。案外シンプルに捌けていることが分かります。

   

3. メタデータの使い道の例: コード生成


前節まででサービスの中身を表すJSONが拾える事自体は実証できましたが、コレだけだと「なんか複雑なデータだね」程度の感想しか出てこないので、具体的な使い道を一つ紹介しておきます。それはqi Frameworkのサービスの静的コード化です。

 

「静的コード化」と言われてもピンと来ないと思うのでもうちょっと掘り下げてみます。はじめに現状のqi Frameworkにおける問題点(というほどでもないですが)を一つ指摘します。PythonでのみPepperとやりとりする場合あまり意識しないと思いますが、qi Frameworkはほぼ動的呼び出しのみに頼ったフレームワークです。たとえばHello Worldコードを見てみます。

# -*- coding: utf-8 -*-

import qi

app = qi.Application()
session = app.session
#接続先のIPアドレス
session.connect("192.168.xx.xx")
#アドレスを完全に書く場合はこんな感じ
#session.connect("tcp://192.168.xx.xx:9559")

tts = session.service("ALTextToSpeech")
tts.say("Hello, World!")

このコードで動的呼び出し感の高い処理は以下の3点です。

  1. 居るかどうかわからないロボットを指定して接続(session.connect("192.168.xx.xx"))
  2. 接続先のロボットが持っているかどうか不確定なサービスを文字列指定して取得(tts = session.service("ALTextToSpeech"))
  3. サービスに登録されているのか不確定なメソッドを、合っているか不確定な引数リストを与えて呼び出す(後述)

 

3つ目の「不確定なメソッドを不確定な引数でリスト呼び出す」はコードだけ見るとそう見えないかもしれませんが、実は

tts.say("Hello, World!")

この呼び出しは糖衣構文で、内部処理的には下記に近い事が行われます。

tts._find_and_call("say", ["Hello, World"!])

つまりメソッド呼び出しも文字列をベースにした動的呼び出しです。

 

ということで、繰り返しですがqi Frameworkは動的呼び出しベースフレームワークです。一部の人は「はいはい分かった。で、それの何が悪いの?」と思われるでしょうが、この仕様はインテリセンスの大敵です。ALTextToSpeechサービスを実際にロードするのはプログラムの実行時であるため、コーディングの時点ではエディタやIDEから見ると"ALTextToSpeech"サービスや"say"メソッドに関する情報が何もありません。そのためインテリセンスやドキュメント文字列無しでのコーディングが必要になってきます。おもにコンパイラ型言語(C++/Java/C#など)において、この問題はより重要になります。

この問題を解決するために一つ提案をしてみます。インテリセンスが欲しいならラッパークラスとしてALTextToSpeechクラスを定義してしまってはどうでしょうか。

class ALTextToSpeech(object):
    def __init__(self, session):
        self._service = session.service("ALTextToSpeech")

    def say(self, text):
        self._service.say(text)

    def say(self, text, location):
        self._service.say(text, location)

    #..

 

こう書いておけば、例えばIPythonのインテリセンスが保障された環境でコーディングしている場合に下記のコード

tts = ALTex

コレがALTextToSpeechへと補完でき、同様に次のコード

tts.sa

これもインテリセンスでsay関数に補完されます。sayが(オーバーロードが二つあるので)引数を1つか2つしか取れない関数であることもコーディング時に確認できます。なかなか悪くないですよね?

 

ただ、上のALTextToSpeechクラスのようなラッパークラスは手作業でいちいち書いてたらキリがありません。そこで役に立ってくれるのが、最初の節で紹介したメタデータjsonを取得するプログラムです。具体的な実装の話は冗長になるので割愛しますが、少なくとも下記のメタ情報

"114": {
  "description": "Performs the text-to-speech operations : it takes a std::string as input and outputs a sound in both speakers. String encoding must be UTF8.", 
  "name": "say", 
  "parameters": [
    {
      "description": "Text to say, encoded in UTF-8.", 
      "name": "stringToSay"
    }
  ], 
  "parametersSignature": "(s)", 
  "returnDescription": "", 
  "returnSignature": "v", 
  "uid": 114
}

これをうまく変換すると下記のようなPythonのメソッド定義(とdocstring)が作れそう、という見通しについては納得してもらえると思います。

#クラス名はサービス名拾った時点で分かっているのでコレも自動で命名可
class ALTextToSpeech(object):
    #..

    #以下を自動生成したい
    def say(self, stringToSay):
        """ Performs the text-to-speech operations : it takes a std::string as input and outputs a sound in both speakers. String encoding must be UTF8. 

        Arguments:
        stringToSay -- Text to say, encoded in UTF-8.
        """ 
        self._service.say(stringToSay)

実際にテキスト変換のプログラムを作る場合parametersSignatureやreturnSignatureに記載された"v"や"(s)"といったシグネチャ表現文字列の意味を把握する必要があるのですが、これについてはqi::signatureとかを読んで勉強してください。

   

4. まとめと注意


qi Framework流のメタプログラミングサポートとその恩恵について紹介しました。ただし重要な注意が一点。

 

最初の節で紹介したサンプルコードですが、コレは特定のロボットに接続してサービス情報を拾ってくるプログラムなので接続するロボットが違うと微妙に異なる結果を得ます。どのロボットに対しても使えるサービス情報を割り出すには公式ドキュメントに名前が登場するサービスだけを用いるか、あるいは下記の手順を踏むべきでしょう。

  • 複数台のPepperを用意する
  • それぞれのPepperからJsonをまとめて拾う
  • ぜんぶのPepperから同じJsonが返ってきたサービスは「どのロボットでも共通で使えるライブラリ」と認定し、標準的に採用(そしてPythonコードなどに変換して利用)
  • 同じJsonが返ってこないサービスは非標準サービスとみなし、使わない

 

ともかく互換性に注意してください、という蛇足でした。今回は以上です。

 


  1. 些末ではありますが、実際はmethods, properties, signalsの各要素は配列じゃなくて「一意識別ID:データ本体」の形式で辞書式データになっていることに注意してください。