Voice Kit で顔認識をトリガーにして対話する

 前回は音声によるリクエストをトリガーにして、カメラ画像からの顔認識結果を返しましたが、今回は常に顔認識処理を続行し、顔が検出された場合はそれをトリガーにして簡単に対話する処理を実装してみたいと思います。

f:id:akanuma-hiroaki:20180219075032j:plain:w450

今回の実装内容

 今回の具体的な実装内容としては、ドリンクサービスコーナーでの簡単な受付処理を想定し、カメラで顔を検出した場合はユーザに飲みたいものを訪ね、ユーザの回答に対してメッセージを返すというものです。

 ちなみに今回は音声認識のインタフェースに Google Assistant ではなく Cloud Speech を使用しますので、下記サイトで紹介されているような事前のアカウントの準備が必要になります。内容については下記サイトを参照いただくこととしてここでの説明は割愛します。

aiyprojects.withgoogle.com

コード全体

 まずは今回実装したコードの全体を掲載しておきます。

#!/usr/bin/env python3

import sys
import cv2

import aiy.audio
import aiy.cloudspeech
import aiy.voicehat

class DrinkProvider:
    def __init__(self):
        self.face_cascade = cv2.CascadeClassifier('/home/pi/AIY-projects-python/src/examples/voice/haarcascade_frontalface_default.xml')

        self.menu_list = ['coffee', 'tea', 'milk', 'orange juice', 'beer']
        self.recognizer = aiy.cloudspeech.get_recognizer()
        for menu in self.menu_list:
            self.recognizer.expect_phrase(menu)
        aiy.audio.get_recorder().start()

        self.status_ui = aiy.voicehat.get_status_ui()

    def main(self):
        while(True):
            cap = cv2.VideoCapture(0)
            self.status_ui.status('ready')
            while(True):
                ret, frame = cap.read()
                gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
                faces = self.face_cascade.detectMultiScale(gray, 1.3, 5)
                for (x, y, w, h) in faces:
                    frame = cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
                cv2.imshow('frame', frame)
                if cv2.waitKey(1) & 0xff == ord('q'):
                    cap.release()
                    cv2.destroyAllWindows()
                    sys.exit(1)
                if len(faces) > 0:
                    break

            self.status_ui.status('listening')
            aiy.audio.say('Hi! What would you like to drink?')
            for i, menu in enumerate(self.menu_list):
                if i == len(self.menu_list) - 1:
                    aiy.audio.say('or')
                aiy.audio.say(menu)

            text = self.recognizer.recognize()
            if not text:
                aiy.audio.say('Sorry, I did not hear you.')
            elif text not in self.menu_list:
                print('You said: %s' % text)
                aiy.audio.say('Sorry, we can not provide it.')
            else:
                aiy.audio.say("Sure. We'll brought you a tasty %s. Please wait a sec!" % text)

            cv2.waitKey(3000)
            cap.release()

if __name__ == '__main__':
    provider = DrinkProvider()
    provider.main()

コンストラクタ

 コンストラクタではまず前回同様に OpenCV の検出器を初期化しています。

self.face_cascade = cv2.CascadeClassifier('/home/pi/AIY-projects-python/src/examples/voice/haarcascade_frontalface_default.xml')

 そしてユーザからの回答を受け付けるための準備です。今回はあらかじめ用意したメニューの中から希望のドリンクを選択してもらう形なので、選択肢をリストに保持しておきます。そして Cloud Speech の API を利用するための recoginzer を取得し、ユーザからの回答内容が認識されやすくなるよう、期待するフレーズを expect_phrase() メソッドで登録しておきます。

self.menu_list = ['coffee', 'tea', 'milk', 'orange juice', 'beer']
self.recognizer = aiy.cloudspeech.get_recognizer()
for menu in self.menu_list:
    self.recognizer.expect_phrase(menu)
aiy.audio.get_recorder().start()

 そして最後に状態に応じて筐体上部のボタンのLEDの点灯状態を変更するために、 status_ui を取得しておきます。

self.status_ui = aiy.voicehat.get_status_ui()

顔認識処理

 main() メソッドは全体を無限ループで繰り返していますが、その最初でもう一つ無限ループを用意し、カメラ画像から顔が検出されるまで顔認識処理を繰り返すようにします。顔認識処理の内容は前回までと同様で、キーボードから q が入力されたらカメラのリソースを解放してプログラムを終了するようにしています。筐体のLEDの状態は顔認識の無限ループに入る前に ready の状態にしておきます。

cap = cv2.VideoCapture(0)
self.status_ui.status('ready')
while(True):
    ret, frame = cap.read()
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    faces = self.face_cascade.detectMultiScale(gray, 1.3, 5)
    for (x, y, w, h) in faces:
        frame = cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
    cv2.imshow('frame', frame)
    if cv2.waitKey(1) & 0xff == ord('q'):
        cap.release()
        cv2.destroyAllWindows()
        sys.exit(1)
    if len(faces) > 0:
        break

入力プロンプト

 カメラ画像から顔が検出されたらループを抜け、ユーザに回答を促すための発話を行います。選択肢としてメニューのリストの内容も読み上げ、リストの最後の要素とその前の要素の間では英語っぽく or を挟むようにしています。

self.status_ui.status('listening')
aiy.audio.say('Hi! What would you like to drink?')
for i, menu in enumerate(self.menu_list):
    if i == len(self.menu_list) - 1:
        aiy.audio.say('or')
    aiy.audio.say(menu)

音声認識処理

 入力を促す発話の後はユーザの回答を待ち受けます。 recognizer の recognize() メソッドで入力を受け付けてテキスト化します。回答がなかった場合や回答内容がメニューリストにない場合はエラー時の固定回答を返し、メニューリストに含まれるものが回答だった場合はそれを埋め込んだ形の回答を返して3秒待って処理を終了し、再び main() メソッド全体のループに戻ります。

text = self.recognizer.recognize()
if not text:
    aiy.audio.say('Sorry, I did not hear you.')
elif text not in self.menu_list:
    print('You said: %s' % text)
    aiy.audio.say('Sorry, we can not provide it.')
else:
    aiy.audio.say("Sure. We'll brought you a tasty %s. Please wait a sec!" % text)

cv2.waitKey(3000)
cap.release()

動作確認

 それではスクリプトを実行してみます。実行してみた様子は下記の動画の通りで、ひとまず期待した通りに動作しているようです。

まとめ

 今回は Google Assistant ではなく Cloud Speech を使ってシンプルな対話処理を実装してみました。hotword 以外をトリガーとした処理を組み合わせることで色々用途は広がりそうです。ただこれだと単語での回答に対してのシンプルな返答のみなので、次回は Dialogflow を使ってもっと対話的な処理を実装してみたいと思います。

AIY Voice Kit + Web カメラで顔認識(誰かそこにいる?)

 前回は Voice Kit + Web カメラで画像を表示しつつ、音声で写真を撮るということをやってみました。今回は Web カメラで撮っている画像の中に人がいるかどうかを顔認識によって判定してみたいと思います。

実装内容

 今回の実装内容としては、スクリプトを実行すると Web カメラ画像の表示を開始し、 "OK Google, is anyone there?"(そこに誰かいる?)と発話すると、画像内に人の顔があるかを検出し、顔があった場合は "Yes, someone is there." と言って画像内で検出された顔を枠で囲み、緑色のLEDを点灯させます。顔がない場合は "No one is there." と言って黄色のLEDを点灯させます。

f:id:akanuma-hiroaki:20180211091346j:plain:w450

OpenCV で顔検出

 今回顔検出には OpenCV を使用します。下記チュートリアルを参考にしました。

Haar Cascadesを使った顔検出 — OpenCV-Python Tutorials 1 documentation

 OpenCV のインストールは前回までで実施済みで使えるようになっていますが、 検出器を使うために github からリポジトリを clone します。検出アルゴリズムについては上記チュートリアルで詳しく紹介されていますので、ここでは割愛します。

pi@raspberrypi:~ $ git clone https://github.com/opencv/opencv.git
Cloning into 'opencv'...
remote: Counting objects: 221908, done.
remote: Compressing objects: 100% (5/5), done.
remote: Total 221908 (delta 0), reused 0 (delta 0), pack-reused 221903
Receiving objects: 100% (221908/221908), 438.92 MiB | 1.57 MiB/s, done.
Resolving deltas: 100% (154066/154066), done.
Checking out files: 100% (5734/5734), done.

 検出器は opencv/data/haarcascades に格納されています。

pi@raspberrypi:~ $ ls -l opencv/data/haarcascades
合計 9568
-rw-r--r-- 1 pi pi  341406  26 07:59 haarcascade_eye.xml
-rw-r--r-- 1 pi pi  601661  26 07:59 haarcascade_eye_tree_eyeglasses.xml
-rwxr-xr-x 1 pi pi  411388  26 07:59 haarcascade_frontalcatface.xml
-rwxr-xr-x 1 pi pi  382918  26 07:59 haarcascade_frontalcatface_extended.xml
-rw-r--r-- 1 pi pi  676709  26 07:59 haarcascade_frontalface_alt.xml
-rw-r--r-- 1 pi pi  540616  26 07:59 haarcascade_frontalface_alt2.xml
-rw-r--r-- 1 pi pi 2689040  26 07:59 haarcascade_frontalface_alt_tree.xml
-rw-r--r-- 1 pi pi  930127  26 07:59 haarcascade_frontalface_default.xml
-rw-r--r-- 1 pi pi  476825  26 07:59 haarcascade_fullbody.xml
-rw-r--r-- 1 pi pi  195369  26 07:59 haarcascade_lefteye_2splits.xml
-rw-r--r-- 1 pi pi   47775  26 07:59 haarcascade_licence_plate_rus_16stages.xml
-rw-r--r-- 1 pi pi  395320  26 07:59 haarcascade_lowerbody.xml
-rw-r--r-- 1 pi pi  828514  26 07:59 haarcascade_profileface.xml
-rw-r--r-- 1 pi pi  196170  26 07:59 haarcascade_righteye_2splits.xml
-rw-r--r-- 1 pi pi   75482  26 07:59 haarcascade_russian_plate_number.xml
-rw-r--r-- 1 pi pi  188506  26 07:59 haarcascade_smile.xml
-rw-r--r-- 1 pi pi  785817  26 07:59 haarcascade_upperbody.xml

 今回はこの中から一番ベーシックな、 haarcascade_frontalface_default.xml を使用しますので、任意のディレクトリにコピーしておきます。

 それではコードを実装します。今回の主な変更としては、カメラ画像の表示を更新している無限ループの中に、下記の処理を追加しています。 "OK Google, is anyone there?" という発話を検知したら self.face_detection フラグを True にすることでこの処理が実行されます。

elif self.face_detection:
    faces = None
    for i in range(0, 30):
        ret, frame = cap.read()
        cv2.imshow('frame', frame)
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        faces = self.face_cascade.detectMultiScale(gray, 1.3, 5)
        if len(faces) != 0:
            break
        cv2.waitKey(1)

    if len(faces) == 0:
        GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
        self.print_and_say('No one is there.')
    else:
        for (x, y, w, h) in faces:
            frame = cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
        cv2.imshow('frame', frame)
        GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
        self.print_and_say('Yes, someone is there.')
        cv2.waitKey(5000)

    self.face_detection = False
    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
    GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

 顔検出処理はキャプチャした画像に対して detectMultiScale() メソッドを実行することで行いますが、一回の処理だけだと発話直後の1フレームに対してのみの検出になるので、少し幅を持たせるために30回処理を行い、その間に顔が検出されればそこに人がいるものとしています。また、グレースケール画像に対して行った方が早いようなので、カメラ画像をグレースケール画像に変換し手から detectMultiScale() メソッドを実行しています。

for i in range(0, 30):
    ret, frame = cap.read()
    cv2.imshow('frame', frame)
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    faces = self.face_cascade.detectMultiScale(gray, 1.3, 5)
    if len(faces) != 0:
        break
    cv2.waitKey(1)

 そして顔が検出された場合は検出された部分を青い枠で囲って表示し、緑色のLEDを点灯させます。 cv2.rectangle() メソッドでは矩形の領域の2点の座標を引数に指定しますが、 detectMultiScale() メソッドの戻り値は1点の座標と横幅、高さなので、1点の座標に横幅と高さを加えたものをもう1点の座標として指定しています。

for (x, y, w, h) in faces:
    frame = cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
cv2.imshow('frame', frame)
GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)

 処理が終わったらフラグを False に戻して LED も消灯しておきます。

self.face_detection = False
GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

 上記コードを含むスクリプト全体を下記に記載しておきます。

#!/usr/bin/env python3

import sys
import time
import cv2
import threading
from datetime import datetime

import aiy.assistant.auth_helpers
import aiy.audio
import aiy.voicehat
from google.assistant.library import Assistant
from google.assistant.library.event import EventType

import RPi.GPIO as GPIO

class MyAssistant:
    GPIO_LED_GREEN = 2
    GPIO_LED_YELLOW = 3

    def __init__(self):
        self.credentials = aiy.assistant.auth_helpers.get_assistant_credentials()
        self.status_ui = aiy.voicehat.get_status_ui()

        GPIO.setmode(GPIO.BCM)
        GPIO.setup(MyAssistant.GPIO_LED_GREEN, GPIO.OUT)
        GPIO.setup(MyAssistant.GPIO_LED_YELLOW, GPIO.OUT)

        self._run_event_task = threading.Thread(target = self._run_event)
        self._show_video_task = threading.Thread(target = self._show_video)

        self.face_cascade = cv2.CascadeClassifier('/home/pi/AIY-projects-python/src/examples/voice/haarcascade_frontalface_default.xml')
        self.take_photo = False
        self.face_detection = False
        self.stop = False

    def print_and_say(self, text):
        print(text)
        aiy.audio.say(text)

    def start(self):
        self._show_video_task.start()
        self._run_event_task.start()

    def _show_video(self):
        cap = cv2.VideoCapture(0)

        while(True):
            ret, frame = cap.read()

            cv2.imshow('frame', frame)
            if self.take_photo:
                now = datetime.now()
                filename = 'capture_%s.jpg' % now.strftime('%Y%m%d_%H%M%S')

                if cv2.imwrite(filename, frame):
                    self.print_and_say('I took nice one.')
                    print('Captured Image: %s' % filename)
                    time.sleep(5)
                else:
                    self.print_and_say("I couldn't take a picture.")

                self.take_photo = False

            elif self.face_detection:
                faces = None
                for i in range(0, 30):
                    ret, frame = cap.read()
                    cv2.imshow('frame', frame)
                    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
                    faces = self.face_cascade.detectMultiScale(gray, 1.3, 5)
                    if len(faces) != 0:
                        break
                    cv2.waitKey(1)

                if len(faces) == 0:
                    GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                    self.print_and_say('No one is there.')
                else:
                    for (x, y, w, h) in faces:
                        frame = cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
                    cv2.imshow('frame', frame)
                    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                    self.print_and_say('Yes, someone is there.')
                    cv2.waitKey(5000)

                self.face_detection = False
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

            cv2.waitKey(1)

            if self.stop:
                break

        cap.release()
        cv2.destroyAllWindows()
        print('Stop showing video.')

    def _run_event(self):
        with Assistant(self.credentials) as assistant:
            self._assistant = assistant
            for event in assistant.start():
                self._process_event(event)

    def _process_event(self, event):
        if event.type == EventType.ON_START_FINISHED:
            self.status_ui.status('ready')
            aiy.audio.say("OK, I'm ready.")
            if sys.stdout.isatty():
                print('Say "OK, Google" then speak.')

        elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED and event.args:
            print('You said: %s' % event.args['text'])
            text = event.args['text'].lower()

            if text == 'take a picture':
                self._assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

                aiy.audio.say('OK, 3')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                time.sleep(1)

                aiy.audio.say('2')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                time.sleep(1)

                aiy.audio.say('1')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                time.sleep(1)

                self.take_photo = True

                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

            elif text == 'is anyone there':
                self._assistant.stop_conversation()
                self.face_detection = True

            elif text == 'goodbye':
                self.status_ui.status('stopping')
                self._assistant.stop_conversation()
                self.stop = True
                aiy.audio.say('Goodbye. See you again.')
                print('Stop processing event.')
                sys.exit()

        elif event.type == EventType.ON_CONVERSATION_TURN_STARTED:
            self.status_ui.status('listening')
        elif event.type == EventType.ON_END_OF_UTTERANCE:
            self.status_ui.status('thinking')
        elif event.type == EventType.ON_CONVERSATION_TURN_FINISHED:
            self.status_ui.status('ready')
        elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']:
            sys.exit(1)

    def main(self):
        self.status_ui.status('starting')
        self.start()

if __name__ == '__main__':
    sample = MyAssistant()
    sample.main()

動作確認

 それでは動作確認をしてみます。下記の動画のようにだいたい想定した通りに動作してくれました。

まとめ

 今回は OpenCV を使うことで簡単に顔認識を行うことができました。調べてみると OpenCV 以外にも最近は dlib を使う方が精度が高くて良いようなのですが、 Raspberry Pi 3 Model B の環境で試した限りでは OpenCV と比べて処理が遅く、リアルタイムの顔認識には使えませんでした。ただパフォーマンスの改善についてはまだあまり調べられていないので、ビルドの仕方などによって改善する可能性はあるかと思います。顔認識では精度とパフォーマンスの両方が重要になってくると思いますので、もう少し dlib についても調べてみたいと思います。また、 OpenCV についても各検出器の処理内容や、元画像とグレースケール変換画像への処理によるパフォーマンスの違いなども気になるところではあるので、気が向いたら調べてみようと思います。

AIY Voice Kit + Web カメラで画像を表示しながら写真を撮る

 前回は Voice Kit に Web カメラを接続して、音声で写真を撮れるようにしてみましたが、カメラにどんな映像が写っているかは写真を撮ってみないとわからなかったので、今回はカメラからの映像をデスクトップに映しつつ、写真を撮れるようにしてみました。デバイスの接続は前回と同様で、コードのみの変更です。

f:id:akanuma-hiroaki:20180205234315j:plain:w450

OpenCV での動画撮影のサンプル

 OpenCV での動画の撮影の仕方については下記リンク先のチュートリアルを参考にさせていただきました。

動画を扱う — OpenCV-Python Tutorials 1 documentation

 基本的な処理は VideoCapture クラスの read() メソッドで読み込んだ画像を cv2.imshow() メソッドで表示するという処理を無限ループで繰り返しています。

実装する内容

 上記チュートリアルの内容を前回のコードをベースに組み込んで行きます。実装する処理としては、スクリプトを起動したら Web カメラの映像をデスクトップに表示開始し、 "OK Google. Take a picture." と発話したらカウントダウンして写真を撮影し、その画像を数秒表示したらまた Web カメラの映像を開始するというものです。

 ここで問題になるのは、上記サンプルの通り、映像の表示は無限ループで行われているため、このまま前回のコードの処理の一部に入れてしまうとそこで処理がブロックされ、発話に対する処理がブロックされてしまいます。なので今回は Thread を使い、映像の表示と発話に対する処理をそれぞれ独立した Thread で行うようにしました。 Thread を使用する例は Voice Kit のガイドでも解説されていて、 hotword によるリクエストと、キット上部のボタンによるリクエストの両方を同時に待ち受けるためのサンプルが提供されています。

aiyprojects.withgoogle.com

github.com

映像表示用 Thread

 まず映像表示用 Thread で実行する処理は下記のようなメソッドとして実装します。基本的な処理はチュートリアルの通りで無限ループで画像の読み込みと表示を繰り返していますが、写真の撮影と処理の終了を判断するためのトリガーとして self.take_photo と self.stop というフラグを使用しています。このフラグは発話に対する処理を行う Thread から変更することを想定していて、 self.take_photo が True になったらその時に読み込んでいる画像をファイルに保存し、5秒間表示をキープした後にフラグを False に戻し、無限ループを続行します。

 また、プログラムの終了時には発話処理 Thread が終了してもそのままだと映像表示用 Thread は動き続けたままになるため、 発話処理 Thread の終了処理の中で self.stop を True に変更し、映像表示用 Thread で self.stop が True になっていたら無限ループを抜けて処理を終了するようにしています。

def _show_video(self):
    cap = cv2.VideoCapture(0)

    while(True):
        ret, frame = cap.read()

        cv2.imshow('frame', frame)
        if self.take_photo:
            now = datetime.now()
            filename = 'capture_%s.jpg' % now.strftime('%Y%m%d_%H%M%S')

            if cv2.imwrite(filename, frame):
                self.print_and_say('I took nice one.')
                print('Captured Image: %s' % filename)
                time.sleep(5)
            else:
                self.print_and_say("I couldn't take a picture.")

            self.take_photo = False

        cv2.waitKey(1)

        if self.stop:
            break

    cap.release()
    cv2.destroyAllWindows()
    print('Stop showing video.')

 そしてコンストラクタで上記メソッドを使用する Thread を生成します。

self._show_video_task = threading.Thread(target = self._show_video)

発話処理用 Thread

 発話に対する処理も今回は Thread で行うように変更しています。下記のようなメソッドにして Thread で実行します。

def _run_event(self):
    with Assistant(self.credentials) as assistant:
        self._assistant = assistant
        for event in assistant.start():
            self._process_event(event)

 発話内容が "Take a picture." だった場合の処理からは画像のキャプチャ用処理は削除し、フラグの変更だけを行なっています。

self.take_photo = True

 また、発話内容が "Goodbye." だった場合の終了処理にフラグの変更を追加しています。

self.stop = True

 そして映像表示用 Thread と同様に、コンストラクタで Thread を生成します。

self._run_event_task = threading.Thread(target = self._run_event)

Thread の処理開始

 生成した Thread の start() メソッドを実行することで、処理が開始されます。今回は下記のように各 Thread の処理を開始しています。

def start(self):
    self._show_video_task.start()
    self._run_event_task.start()

コード全体

 今回実装したコードの全体は下記の通りになります。前回のコードから単純に LED を操作するだけの部分は削除しました。

#!/usr/bin/env python3

import sys
import time
import cv2
import threading
from datetime import datetime

import aiy.assistant.auth_helpers
import aiy.audio
import aiy.voicehat
from google.assistant.library import Assistant
from google.assistant.library.event import EventType

import RPi.GPIO as GPIO

class MyAssistant:
    GPIO_LED_GREEN = 2
    GPIO_LED_YELLOW = 3

    def __init__(self):
        self.credentials = aiy.assistant.auth_helpers.get_assistant_credentials()
        self.status_ui = aiy.voicehat.get_status_ui()

        GPIO.setmode(GPIO.BCM)
        GPIO.setup(MyAssistant.GPIO_LED_GREEN, GPIO.OUT)
        GPIO.setup(MyAssistant.GPIO_LED_YELLOW, GPIO.OUT)

        self._run_event_task = threading.Thread(target = self._run_event)
        self._show_video_task = threading.Thread(target = self._show_video)

        self.take_photo = False
        self.stop = False

    def print_and_say(self, text):
        print(text)
        aiy.audio.say(text)

    def start(self):
        self._show_video_task.start()
        self._run_event_task.start()

    def _show_video(self):
        cap = cv2.VideoCapture(0)

        while(True):
            ret, frame = cap.read()

            cv2.imshow('frame', frame)
            if self.take_photo:
                now = datetime.now()
                filename = 'capture_%s.jpg' % now.strftime('%Y%m%d_%H%M%S')

                if cv2.imwrite(filename, frame):
                    self.print_and_say('I took nice one.')
                    print('Captured Image: %s' % filename)
                    time.sleep(5)
                else:
                    self.print_and_say("I couldn't take a picture.")

                self.take_photo = False

            cv2.waitKey(1)

            if self.stop:
                break

        cap.release()
        cv2.destroyAllWindows()
        print('Stop showing video.')

    def _run_event(self):
        with Assistant(self.credentials) as assistant:
            self._assistant = assistant
            for event in assistant.start():
                self._process_event(event)

    def _process_event(self, event):
        if event.type == EventType.ON_START_FINISHED:
            self.status_ui.status('ready')
            aiy.audio.say("OK, I'm ready.")
            if sys.stdout.isatty():
                print('Say "OK, Google" then speak.')

        elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED and event.args:
            print('You said: %s' % event.args['text'])
            text = event.args['text'].lower()

            if text == 'take a picture':
                self._assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

                aiy.audio.say('OK, 3')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                time.sleep(1)

                aiy.audio.say('2')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                time.sleep(1)

                aiy.audio.say('1')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                time.sleep(1)

                self.take_photo = True

                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

            elif text == 'goodbye':
                self.status_ui.status('stopping')
                self._assistant.stop_conversation()
                self.stop = True
                aiy.audio.say('Goodbye. See you again.')
                print('Stop processing event.')
                sys.exit()

        elif event.type == EventType.ON_CONVERSATION_TURN_STARTED:
            self.status_ui.status('listening')
        elif event.type == EventType.ON_END_OF_UTTERANCE:
            self.status_ui.status('thinking')
        elif event.type == EventType.ON_CONVERSATION_TURN_FINISHED:
            self.status_ui.status('ready')
        elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']:
            sys.exit(1)

    def main(self):
        self.status_ui.status('starting')
        self.start()

if __name__ == '__main__':
    sample = MyAssistant()
    sample.main()

動作確認

 上記スクリプトを実行した様子は下記の動画のようになります。ひとまず想定したように動作しているようです。

まとめ

 各スレッド間の連携はフラグを用いただけの簡単な実装ですし色々と適当な実装ですが、ひとまず映像を確認しながら写真を撮ることができるようになりました。発話内容に対する処理の方は別スレッドにせずにメインスレッドのままにして、映像表示の方だけ別スレッドにする形でも良かったかもしれませんがまぁ今回は良しということで。今後はこれにさらに Motion Detection やその他のトリガーの追加も試してみたいと思います。

AIY Voice Kit + Web カメラで音声で写真を撮る

 前回は Voice Kit にブレッドボードとLEDをつないで音声で操作してみました。今回は手持ちの Web カメラを繋げて音声で操作して写真を撮ってみたいと思います。

f:id:akanuma-hiroaki:20180201091154j:plain:w450

Web カメラの接続

 今回はUSB接続の Web カメラを使用します。まず接続前の USB デバイスの認識状況です。

pi@raspberrypi:~ $ lsusb
Bus 001 Device 003: ID 0424:ec00 Standard Microsystems Corp. SMSC9512/9514 Fast Ethernet Adapter
Bus 001 Device 002: ID 0424:9514 Standard Microsystems Corp. SMC9514 Hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ ls /dev/video*
ls: '/dev/video*' にアクセスできません: そのようなファイルやディレクトリはありません

 そして Raspberry Pi の USB ポートに Web カメラを接続した後は下記のようになります。

pi@raspberrypi:~ $ lsusb
Bus 001 Device 004: ID 056e:700f Elecom Co., Ltd 
Bus 001 Device 003: ID 0424:ec00 Standard Microsystems Corp. SMSC9512/9514 Fast Ethernet Adapter
Bus 001 Device 002: ID 0424:9514 Standard Microsystems Corp. SMC9514 Hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ ls /dev/video*
/dev/video0

 Device 004 として Elecom の Web カメラが認識され、デバイスファイルも作成されました。とりあえずのカメラの動作テスト用に、 guvcview をインストールします。

pi@raspberrypi:~ $ sudo apt-get install guvcview

 無事にインストールできたら下記のように起動します。 ssh ではなく VNC か直接デスクトップにログインして実行する必要があります。

pi@raspberrypi:~ $ guvcview &

 起動すると Web カメラの映像がデスクトップ上に表示されます。

f:id:akanuma-hiroaki:20180202075850p:plain:w450

OpenCV のインストールと動作確認

 今回は Web カメラからの画像のキャプチャ用に OpenCV を使います。

opencv.org

 Voice Kit ではスクリプトを実行する場合にデスクトップの Start dev terminal ショートカットからシェルを起動します。

f:id:akanuma-hiroaki:20180201082622p:plain:w450

 このショートカットの中身は下記のようになっていて、 /home/pi/bin/AIY-projects-shell.sh が実行されていますので、 VNC ではなく ssh 等でログインしている場合にはこの AIY-projects-shell.sh を実行してシェルを起動してからスクリプトを実行します。

[Desktop Entry]
Encoding=UTF-8
Type=Application
Name=Start dev terminal
Commment=Start a terminal with the environment for the AIY projects kit.
Exec=/home/pi/bin/AIY-projects-shell.sh
Terminal=true
Icon=utilities-terminal

 AIY-projects-shell.sh の中身は下記のようになっていて、 env/bin/activate で Voice Kit のコード実行用の環境設定が行われています。

#!/bin/bash --rcfile

source /etc/bash.bashrc
source ~/.bashrc

cat /etc/aiyprojects.info

cd ~/AIY-projects-python
source env/bin/activate

echo "Dev terminal is ready! See the demos in 'src/examples' to get started."

 AIY-projects-shell.sh を起動して環境設定が行われると $PATH の先頭は /home/pi/AIY-projects-python/env/bin になり、 python や pip 等のコマンドもそちらのものが参照されるようになります。

(env) pi@raspberrypi:~ $ which python
/home/pi/AIY-projects-python/env/bin/python
(env) pi@raspberrypi:~ $ which pip3
/home/pi/AIY-projects-python/env/bin/pip3

 なので OpenCV のインストールも AIY-projects-shell.sh を起動した後に pip でインストールします。

(env) pi@raspberrypi:~ $ pip3 install opencv-python
Collecting opencv-python
  Using cached https://www.piwheels.hostedpi.com/simple/opencv-python/opencv_python-3.3.0.10-cp35-cp35m-linux_armv7l.whl
Requirement already satisfied: numpy>=1.12.1 in /usr/lib/python3/dist-packages (from opencv-python)
Installing collected packages: opencv-python
Successfully installed opencv-python-3.3.0.10

 インストールされたら簡単に動作確認をしてみます。

(env) pi@raspberrypi:~ $ python -c 'import cv2'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/home/pi/AIY-projects-python/env/lib/python3.5/site-packages/cv2/__init__.py", line 9, in <module>
    from .cv2 import *
ImportError: libcblas.so.3: cannot open shared object file: No such file or directory

 libcblas.so.3 がないと言われてしまったので、 libatlas-base-dev を apt-get でインストールします。

(env) pi@raspberrypi:~ $ sudo apt-get install libatlas-base-dev 

 再度動作確認してみます。

(env) pi@raspberrypi:~ $ python -c 'import cv2'                                                                                                                                                                   
(env) pi@raspberrypi:~ $ 

 ライブラリが参照できるようになったようなので、インタラクティブシェルで動作を確認してみます。

(env) pi@raspberrypi:~ $ python
Python 3.5.3 (default, Jan 19 2017, 14:11:04) 
[GCC 6.3.0 20170124] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 
>>> import cv2
>>> 
>>> c = cv2.VideoCapture(0)
>>> 
>>> r, img = c.read()
>>> 
>>> cv2.imwrite('capture.jpg', img)
True
>>> 

 キャプチャ画像が capture.jpg というファイルに保存されていれば正常に動作しています。今回は下記のような画像がキャプチャされました。

f:id:akanuma-hiroaki:20180202082309j:plain:w450

サンプルコード実行時のエラー

 途中で Voice Kit で提供されているサンプルコードを動かしてみたところ、下記のようにエラーになってしまいました。

(env) pi@raspberrypi:~/AIY-projects-python $ src/examples/voice/assistant_library_demo.py 
ALSA lib pcm.c:8403:(snd_pcm_set_params) Rate doesn't match (requested 16000Hz, get 48000Hz)
[1013:1028:ERROR:audio_input_processor.cc(755)] Input error
/home/pi/AIY-projects-python/src/aiy/_drivers/_led.py:51: RuntimeWarning: This channel is already in use, continuing anyway.  Use GPIO.setwarnings(False) to disable warnings.
  GPIO.setup(channel, GPIO.OUT)
Say "OK, Google" then speak, or press Ctrl+C to quit...
[1013:1031:ERROR:audio_input_processor.cc(755)] Input error
Segmentation fault

 オーディオ入力のサンプリングレートがマッチしないという感じのエラーのようです。ググってみたところ下記の情報を見つけました。

github.com

 .asoundrc の設定内容が合っていないということのようなので、 /etc/asound.conf をコピーして使用します。

pi@raspberrypi:~ $ cat .asoundrc 
pcm.!default {
        type hw
        card 0
}

ctl.!default {
        type hw
        card 0
}
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ cat /etc/asound.conf 
options snd_rpi_googlevoicehat_soundcard index=0

pcm.softvol {
    type softvol
    slave.pcm dmix
    control {
        name Master
        card 0
    }
}

pcm.micboost {
    type route
    slave.pcm dsnoop
    ttable {
        0.0 30.0
        1.1 30.0
    }
}

pcm.!default {
    type asym
    playback.pcm "plug:softvol"
    capture.pcm "plug:micboost"
}

ctl.!default {
    type hw
    card 0
}
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ mv .asoundrc .asoundrc.20180202
pi@raspberrypi:~ $ cp /etc/asound.conf .asoundrc

 これで無事動作するようになりました。

スクリプト実装

 動作確認が取れたのでスクリプトを実装していきます。前回の LED のスクリプトの発話内容による分岐に下記の処理を追加します。「Take a picture.」と発話するとカウントダウンして写真を撮影します。また、カウントダウンに合わせて LED も使用してみました。

elif text == 'take a picture':
    assistant.stop_conversation()
    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
    GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

    now = datetime.now()
    filename = 'capture_%s.jpg' % now.strftime('%Y%m%d_%H%M%S')

    aiy.audio.say('OK, 3')
    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
    time.sleep(1)

    aiy.audio.say('2')
    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
    GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
    time.sleep(1)

    aiy.audio.say('1')
    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
    time.sleep(1)

    c = cv2.VideoCapture(0)
    r, img = c.read()
    if cv2.imwrite(filename, img):
        self.print_and_say('I took nice one.')
    else:
        self.print_and_say("I couldn't take a picture.")

    c.release()
    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
    GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)
    print('Captured Image: %s' % filename)

 スクリプト全体は下記のようになります。

#!/usr/bin/env python3

import sys
import time
import cv2
from datetime import datetime

import aiy.assistant.auth_helpers
import aiy.audio
import aiy.voicehat
from google.assistant.library import Assistant
from google.assistant.library.event import EventType

import RPi.GPIO as GPIO

class MyAssistant:
    GPIO_LED_GREEN = 2
    GPIO_LED_YELLOW = 3

    def __init__(self):
        self.print_and_say('Initializing MyAssistant.')
        self.credentials = aiy.assistant.auth_helpers.get_assistant_credentials()
        self.status_ui = aiy.voicehat.get_status_ui()

        GPIO.setmode(GPIO.BCM)
        GPIO.setup(MyAssistant.GPIO_LED_GREEN, GPIO.OUT)
        GPIO.setup(MyAssistant.GPIO_LED_YELLOW, GPIO.OUT)

    def print_and_say(self, text):
        print(text)
        aiy.audio.say(text)

    def process_event(self, assistant, event):
        print('Processing event. The event is %s.' % event.type)

        if event.type == EventType.ON_START_FINISHED:
            self.status_ui.status('ready')
            if sys.stdout.isatty():
                print('Say "OK, Google" then speak.')
        elif event.type == EventType.ON_CONVERSATION_TURN_STARTED:
            self.status_ui.status('listening')
        elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED and event.args:
            print('You said: %s' % event.args['text'])
            text = event.args['text'].lower()

            if text == 'turn on green led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                self.print_and_say('Turned on green LED.')
            elif text == 'turn off green led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                self.print_and_say('Turned off green LED.')
            elif text == 'turn on yellow led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                self.print_and_say('Turned on yellow LED.')
            elif text == 'turn off yellow led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)
                self.print_and_say('Turned off yellow LED.')

            elif text == 'turn on all led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                self.print_and_say('Turned on all LED.')
            elif text == 'turn off all led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)
                self.print_and_say('Turned off all LED.')
            elif text == 'take a picture':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)

                now = datetime.now()
                filename = 'capture_%s.jpg' % now.strftime('%Y%m%d_%H%M%S')

                aiy.audio.say('OK, 3')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                time.sleep(1)

                aiy.audio.say('2')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                time.sleep(1)

                aiy.audio.say('1')
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                time.sleep(1)

                c = cv2.VideoCapture(0)
                r, img = c.read()
                if cv2.imwrite(filename, img):
                    self.print_and_say('I took nice one.')
                else:
                    self.print_and_say("I couldn't take a picture.")

                c.release()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)
                print('Captured Image: %s' % filename)
            elif text == 'goodbye':
                self.status_ui.status('stopping')
                assistant.stop_conversation()
                aiy.audio.say('Goodbye. See you again.')
                print('Stopping...')
                sys.exit()

        elif event.type == EventType.ON_END_OF_UTTERANCE:
            self.status_ui.status('thinking')
        elif event.type == EventType.ON_CONVERSATION_TURN_FINISHED:
            self.status_ui.status('ready')
        elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']:
            sys.exit(1)

    def main(self):
        self.status_ui.status('starting')
        self.print_and_say('Starting main method.')
        with Assistant(self.credentials) as assistant:
            for event in assistant.start():
                self.process_event(assistant, event)

if __name__ == '__main__':
    sample = MyAssistant()
    sample.main()

動作確認

 スクリプトを実行して動作を確認してみます。下記の動画のような動作になりました。

 撮影された写真は下記のようになります。

f:id:akanuma-hiroaki:20180202090308j:plain:w450

まとめ

 USBカメラの接続も特に苦労することなく、 OpenCV でのキャプチャーもすんなり行えました。この辺りの自由度が Google Home と違う面白さかと思います。今回の内容だと撮影してみないとどんな画像かがわからないので、映像を確認しながら撮影できるようにしたり、撮影後にデスクトップに画像を表示するなどの対応を試してみたいと思います。

AIY Voice Kit にブレッドボードをつないで音声で LED を操作する

 前回 Google AIY Voice Kit を購入してマニュアル通りに組み立ててサンプルプログラムを動かすところまでやってみましたが、やはり Voice Kit が Google Echo と違って面白いのは Raspberry Pi ベースであるからこその自由度ということで、今回はとりあえずブレッドボードで LED を接続して、音声で LED を操作してみました。

GPIO ピンヘッダの取り付け

 AIY Voice Kit では Raspberry Pi に Voice HAT Accessory Board をマウントしているので、 Raspberry Pi の GPIO は全て覆われてしまっています。なので GPIO で接続する場合には Voice HAT の方の GPIO を使用することになります。 Voice HAT の GPIO Pinout はドキュメントでも図で説明されています。

aiyprojects.withgoogle.com

 図の下の方には表形式で各ピンについての説明が書かれています。今回は LED を二つ接続してみますので、GPIO02, 03 を使ってみます。 I2C としても使えるピンなので、ボード上のプリントは I2C になっています。写真赤枠のピンの右から GPIO02, GPIO03, GND になります。

f:id:akanuma-hiroaki:20180123223632j:plain:w450

 Voice HAT にはピンがついていないので、別途ピンヘッダを用意して、一度 Voice HAT を Raspberry Pi から外して半田付けします。

f:id:akanuma-hiroaki:20180123224112j:plain:w450

 半田付けしたらジャンパーコードを接続しておきます。

f:id:akanuma-hiroaki:20180123224336j:plain:w450

 そして再度 Voice Kit を組み上げ、ジャンパーコードの先を外へ引っ張り出しておきます。

f:id:akanuma-hiroaki:20180123224503j:plain:w300

ブレッドボードと LED の取り付け

 先ほど引っ張り出しておいたジャンパーコードから、下記の写真のようにブレッドボードとLEDを接続します。緑色のコードが GPIO02、黄色のコードが GPIO03 でそれぞれ LED のアノード側へ接続し、黒いコードが GND の接続になります。

f:id:akanuma-hiroaki:20180123225024j:plain:w300

スクリプト実装

 それでは Python のスクリプトを実装します。公式から提供されている下記サンプルをベースに、GPIOの操作などを追加しています。

github.com

 まずスクリプト全体は下記の通りです。

#!/usr/bin/env python3

import sys

import aiy.assistant.auth_helpers
import aiy.audio
import aiy.voicehat
from google.assistant.library import Assistant
from google.assistant.library.event import EventType

import RPi.GPIO as GPIO

class MyAssistant:
    GPIO_LED_GREEN = 2
    GPIO_LED_YELLOW = 3

    def __init__(self):
        self.print_and_say('Initializing MyAssistant.')
        self.credentials = aiy.assistant.auth_helpers.get_assistant_credentials()
        self.status_ui = aiy.voicehat.get_status_ui()

        GPIO.setmode(GPIO.BCM)
        GPIO.setup(MyAssistant.GPIO_LED_GREEN, GPIO.OUT)
        GPIO.setup(MyAssistant.GPIO_LED_YELLOW, GPIO.OUT)

    def print_and_say(self, text):
        print(text)
        aiy.audio.say(text)

    def process_event(self, assistant, event):
        print('Processing event. The event is %s.' % event.type)

        if event.type == EventType.ON_START_FINISHED:
            self.status_ui.status('ready')
            if sys.stdout.isatty():
                print('Say "OK, Google" then speak.')
        elif event.type == EventType.ON_CONVERSATION_TURN_STARTED:
            self.status_ui.status('listening')
        elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED and event.args:
            print('You said: %s' % event.args['text'])
            text = event.args['text'].lower()

            if text == 'turn on green led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                self.print_and_say('Turned on green LED.')
            elif text == 'turn off green led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                self.print_and_say('Turned off green LED.')
            elif text == 'turn on yellow led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                self.print_and_say('Turned on yellow LED.')
            elif text == 'turn off yellow led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)
                self.print_and_say('Turned off yellow LED.')
            elif text == 'turn on all led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.HIGH)
                self.print_and_say('Turned on all LED.')
            elif text == 'turn off all led':
                assistant.stop_conversation()
                GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.LOW)
                GPIO.output(MyAssistant.GPIO_LED_YELLOW, GPIO.LOW)
                self.print_and_say('Turned off all LED.')
            elif text == 'goodbye':
                self.status_ui.status('stopping')
                assistant.stop_conversation()
                aiy.audio.say('Goodbye. See you again.')
                print('Stopping...')
                sys.exit()

        elif event.type == EventType.ON_END_OF_UTTERANCE:
            self.status_ui.status('thinking')
        elif event.type == EventType.ON_CONVERSATION_TURN_FINISHED:
            self.status_ui.status('ready')
        elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']:
            sys.exit(1)

    def main(self):
        self.status_ui.status('starting')
        self.print_and_say('Starting main method.')
        with Assistant(self.credentials) as assistant:
            for event in assistant.start():
                self.process_event(assistant, event)

if __name__ == '__main__':
    sample = MyAssistant()
    sample.main()

 Assistant の start() メソッドを実行することで Google Assistant が hotword を待ち受け、検知するとイベントが発生しますので、イベントを process_event() メソッドに渡して処理します。

with Assistant(self.credentials) as assistant:
    for event in assistant.start():
        self.process_event(assistant, event)

 イベントの種別についてはこちらに記載があります。

Google Assistant Library  |  Google Assistant SDK  |  Google Developers

 ユーザが hotword の後に発話した内容は ON_RECOGNIZING_SPEECH_FINISHED イベントのパラメータとして渡されますので、その内容によって処理を切り分け、 GPIO の操作をして LED をコントロールしています。ポイントは assistant.stop_conversation() を実行しているところで、これを行うことによって Google Assistant による処理を中断し、ローカルで行わせたい処理だけ行なっています。 stop_conversation() を実行しないと発話内容に対して Google Assistant でも処理を行おうとするので、そのコマンドには対応できない的な返答が返されてしまいます。

if text == 'turn on green led':
    assistant.stop_conversation()
    GPIO.output(MyAssistant.GPIO_LED_GREEN, GPIO.HIGH)
    self.print_and_say('Turned on green LED.')

動作確認

 上記のスクリプトを実行して hotword に続いて発話すると、下記の動画のように LED を操作することができます。

まとめ

 Voice HAT へのピンヘッダの半田付けは少々手間ですが、それ以外の点ではすんなり GPIO を使うことができました。今回は LED の出力のコントロールだけでしたが、情報を入力するセンサーデバイスも使ってもう少し遊んでみたいと思います。

Google AIY Voice Kit を試してみる

 Google AIY Projects の Voice Kit を買ってみたのでとりあえず試してみました。

aiyprojects.withgoogle.com

 こんな感じのパッケージで届きます。

f:id:akanuma-hiroaki:20180113200108j:plain:w450

 別途 Raspberry Pi や micro SD カード等も必要なので、単純にスマートスピーカーとして使うだけなら Google Home mini を買った方が安いしお手軽なのですが、 Raspberry Pi 上でスクリプトを作成したり、 GPIO 等でセンサーデバイス等と連携させることもできるのが面白そうだったので購入してみました。今回はひとまず組み立てと動作確認を行ってみます。

 基本的な手順は下記 Google の製品ページで紹介されています。

https://aiyprojects.withgoogle.com/voice/#assembly-guide

 KSY で購入ですると日本語版の組み立てガイドが付属しているのでそれに従って進めます。

https://raspberry-pi.ksyic.com/main/index/pdp.id/331/pdp.open/331

f:id:akanuma-hiroaki:20180113201618j:plain:w450

OS イメージの準備

 キットの組み立ての前に SD カードに OS のイメージを書き込んでおきます。今回は下記イメージをダウンロードして使用しました。

https://dl.google.com/dl/aiyprojects/vision/aiyprojects-2018-01-03.img.xz

 書き込みには Etcher を使用します。

etcher.io

 Etcher を起動して Select Image をクリックしてダウンロードしておいたOSイメージを選択します。

f:id:akanuma-hiroaki:20180113141124p:plain:w450

 SDカードをリーダーに挿入してあればドライブが自動的に選択されていますので、 Flash! をクリックして書き込みを実行します。ドライブが適切に選択されていない場合は Change をクリックして対象のドライブを選択します。

f:id:akanuma-hiroaki:20180113141142p:plain:w450

 下記のような表示になれば無事完了です。

f:id:akanuma-hiroaki:20180113141133p:plain:w450

組み立て

 それではキットを組み立てていきます。箱を開けるとダンボールの筐体や各種パーツ、組み立てガイド(英語)が入っています。

f:id:akanuma-hiroaki:20180113202302j:plain:w450

 まずは GPIO 等の端子を備えたメインのボードである Voice HAT Accessory Board です。

f:id:akanuma-hiroaki:20180113201847j:plain:w450

 これをスペーサーを使って Raspberry Pi にマウントします。

f:id:akanuma-hiroaki:20180113201920j:plain:w450

 そしてスピーカーを Voice HAT の端子に精密ドライバーを使って接続します。

f:id:akanuma-hiroaki:20180113202041j:plain:w450

 次はマイク基盤です。このマイクでユーザの音声入力を受け付けます。

f:id:akanuma-hiroaki:20180113202119j:plain:w450

 これも Voice HAT に接続します。

f:id:akanuma-hiroaki:20180113202155j:plain:w450

 そして外側の筐体と内側のフレームを組み立てます。

f:id:akanuma-hiroaki:20180113202228j:plain:w450

 内側のフレームと先ほどまでに組み立てた基盤やスピーカーを組み合わせて筐体に入れます。

f:id:akanuma-hiroaki:20180113202416j:plain:w450

 上部のボタンを取り付けて箱を閉じれば完成です。

f:id:akanuma-hiroaki:20180113202521j:plain:w450

デバイスの動作確認

 OSイメージを書き込んでおいた micro SD カードを Raspberry Pi に挿入し、USBでキーボードとマウスを接続し、HDMI でモニタを接続したら電源ケーブルを接続して起動します。 Wi-Fi の接続や SSH, VNC の接続が必要であれば設定しておきます。

f:id:akanuma-hiroaki:20180113141153p:plain:w450

 公式のOSイメージを使用していれば動作確認用のスクリプトが用意されています。まずはオーディオデバイスの動作確認用スクリプトです。デスクトップの下記スクリプトをダブルクリックで実行します。

f:id:akanuma-hiroaki:20180115220156p:plain:w450

 実行すると下記のようにスクリプトが実行され、テスト用の音声が再生されますので、スピーカーから聞こえていれば y を入力します。続いてマイクのテストも行われますので、 Enter を押して「Testing, 1 2 3」と喋ります。正しくマイクが機能していれば今喋った内容が再生されますので、 y を入力してテスト終了です。

Enabling audio driver for VoiceKit.
Playing a test sound...
Did you hear the test sound? (y/n) y
When you're ready, press enter and say 'Testing, 1 2 3'...
Recording...
Playing back recorded audio...
Did you hear your own voice? (y/n) y
The audio seems to be working.
Press Enter to close...

 次に Wi-Fi の接続テストです。デスクトップの下記スクリプトをダブルクリックで実行します。

f:id:akanuma-hiroaki:20180115230018p:plain:w450

 実行すると下記のように Wi-Fi の接続確認が行われます。

Checking the WiFi connection...
Trying to contact Google's servers...
The WiFi connection seems to be working.
Press Enter to close...

サンプルプログラムの実行

 公式の OS イメージでは Google Assistant SDK のサンプルプログラムが提供されているのでそれを実行してみます。まずは Google Assistant API を有効にする必要がありますので、Google Cloud Platform(GCP) のコンソールにアクセスして、新しいプロジェクトを作成します。

f:id:akanuma-hiroaki:20180115230834p:plain:w450

 プロジェクト名を入力して 作成 ボタンをクリックします。

f:id:akanuma-hiroaki:20180115230911p:plain:w450

 プロジェクトが作成されたら「API とサービス」メニューから API とサービスの有効化 をクリックします。

f:id:akanuma-hiroaki:20180115230930p:plain:w450

 Google Assistant API を検索してクリックします。

f:id:akanuma-hiroaki:20180115230949p:plain:w450

 API の詳細画面で 有効にする をクリックします。

f:id:akanuma-hiroaki:20180115231008p:plain:w450

 次に認証情報を作成する必要があるので、「認証情報」メニューの画面で 認証情報を作成 プルダウンから OAuth クライアント ID を選択します。

f:id:akanuma-hiroaki:20180115231054p:plain:w450

 最初は同意画面を作成する必要があるので、 同意画面を設定 をクリックします。

f:id:akanuma-hiroaki:20180115231114p:plain:w450

 入力フォームでユーザーに表示するサービス名を設定します。それ以外はそのままで構いませんので、 保存 をクリックします。

f:id:akanuma-hiroaki:20180115231156p:plain:w450

 クライアントIDの作成画面に戻ったら名前を入力して 作成 ボタンをクリックします。

f:id:akanuma-hiroaki:20180115231214p:plain:w450

 これで認証情報の作成は完了ですので、一覧画面からダウンロードボタンをクリックして json ファイルをダウンロードし、 /home/pi/assistant.json として保存しておきます。

f:id:akanuma-hiroaki:20180115231232p:plain:w450

 サンプルプログラム実行の準備ができましたので、デスクトップから Start dev terminal を実行します。

f:id:akanuma-hiroaki:20180116074957p:plain:w450

 ターミナルが起動しますので、下記のようにサンプルプログラムを実行します。初回起動時は OAuth での認証が必要ですのでブラウザ上で認証します。認証が完了するとプログラムが実行されますので、 Wake Word「OK, Google」を使って話しかけます。ちなみに現状では英語のみの対応となっています。

$ src/examples/voice/assistant_library_demo.py
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=965148223698-v9m9nb4bgdp58qk159cghm1ketnvin5t.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2F&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fassistant-sdk-prototype&state=tA7PMNhUtTMQdAdj2vRu7IuMAYcUe3&access_type=offline
既存のブラウザ セッションに新しいウィンドウが作成されました。
[2018-01-13 14:57:30,435] INFO:google_auth_oauthlib.flow:"GET /?state=tA7PMNhUtTMQdAdj2vRu7IuMAYcUe3&code=4/ESIoWeN3UiJqensJetZpHruY2DCoPh6KWWigZzEuqSE HTTP/1.1" 200 65
[2018-01-13 14:57:30,812] INFO:root:OAuth credentials initialized: /home/pi/.cache/voice-recognizer/assistant_credentials.json
Say "OK, Google" then speak, or press Ctrl+C to quit...

 続いて、 Google Assistant の gRPC のサンプルプログラムも提供されているので実行してみます。こちらは Wake Word には対応していないので、プログラムを実行した上でスピーカー上部のボタンを押してから話しかけます。こちらもデフォルトでは英語のみの対応となっています。例えば "What's time is it now?" と話しかけると下記のように認識されていることがわかります。

$ src/examples/voice/assistant_grpc_demo.py
/home/pi/AIY-projects-python/src/aiy/_drivers/_led.py:51: RuntimeWarning: This channel is already in use, continuing anyway.  Use GPIO.setwarnings(False) to disable warnings.
  GPIO.setup(channel, GPIO.OUT)
Press the button and speak
[2018-01-13 15:07:12,945] INFO:recorder:started recording
Listening...
[2018-01-13 15:07:46,627] INFO:speech:event_type: 1
[2018-01-13 15:07:46,666] INFO:speech:transcript: what's time is it now
You said " what's time is it now "
Press the button and speak

まとめ

 完成品を買ってくるのとはまた違って、自分で組み立てたデバイスがスマートスピーカーとして動くというのは面白いですね。今回はひとまず組み立てとサンプルプログラムの実行まででしたが、色々なデバイスと組み合わせることもできるので、サンプルプログラムをベースに遊んでみたいと思います。

Alexa Voice Service(AVS) のサンプルを Raspberry Pi で動かす

 Alexa Skills Kit(ASK)の実装は以前試してみましたが、 Alexa Voice Service(AVS) はまだ試してみていなかったので、今回は AVS のサンプルアプリを Raspberry Pi で動かしてみました。基本的には下記のチュートリアルの内容をトレースしたものです。

github.com

 Alexa は主に ASK と AVS で構成されていて、 ASK がクラウド上で動作して処理を返すのに対して、 AVS はデバイス上で動作して、入力された音声データの処理と ASK へのリクエストとレスポンスの仲介を行います。

developer.amazon.com

使用デバイス

 今回使用したデバイスは Raspberry Pi 3 Model B と、下記のUSBマイクです。

https://www.amazon.co.jp/gp/product/B01KZPF1U8/

 スピーカーは 3.5mm ステレオジャックの手持ちのものがなかったので、とりあえず手持ちのヘッドホンを使用して試してみました。

 また、 microSDカードも新しい 16GB のものを用意してセットアップしました。

f:id:akanuma-hiroaki:20180110083836j:plain:w300

Raspberry Pi のセットアップ

 まずは Raspberry Pi のセットアップを下記で紹介されている手順に従ってNOOBSを使用して行います。

github.com

 特に難しいことはありませんが、注意点としては、 micro SD カードは購入時のままでも大抵は Mac で読み書きできますが、改めてフォーマットしてから使用しないと Raspberry Pi でインストーラが起動したところからずっと待ち状態のまま進みませんでした。

 あと追加で raspi-config から Wi-Fi アクセスのセットアップと、 GUI 上から SSH と VNC の有効化を行いました。

Amazon Developer アカウントの作成

 まだ Amazon Developer アカウントを作成していない場合は developer.amazon.com から登録します。私は以前 ASK の実装を試した時に作成していたのでそれを使用しました。詳細な登録手順は割愛します。

f:id:akanuma-hiroaki:20180109080720p:plain

製品とセキュリティプロファイルの作成

 Amazon Developer コンソールから AVS を動かす製品(デバイス)の登録とセキュリティプロファイルの作成を行います。手順については下記ページで紹介されています。

Create Security Profile · alexa/alexa-avs-sample-app Wiki · GitHub

 まず製品の登録です。コンソール上部のメニューから ALEXA をクリックし、 Alexa Voice Service の 始める をクリックします。

f:id:akanuma-hiroaki:20180109083329p:plain

 登録済みの製品の一覧ページに遷移しますので、右上の 製品を作成する をクリックします。

f:id:akanuma-hiroaki:20180109083356p:plain

 製品情報の入力フォームが表示されますので、チュートリアルで紹介されている通り、下記の画像のように入力して、 次へ をクリックします。ここで入力している製品ID はあとでサンプルアプリを動かす際に必要になります。

f:id:akanuma-hiroaki:20180109083414p:plain

 Amazon へログインするためのセキュリティプロファイルの選択画面が表示されますので、 プロフィールを新規作成する リンクをクリックします。

f:id:akanuma-hiroaki:20180109083440p:plain

 セキュリティプロファイル名とセキュリティプロファイル記述の入力フォームが開きますので、ここもチュートリアルの内容に従って下記のように入力して、 次へ をクリックします。

f:id:akanuma-hiroaki:20180109083606p:plain

 さらに画面下部にプラットフォーム情報の入力フォームが開きます。ここで表示されているクライアントIDとクライアントシークレットはあとでサンプルアプリを動かす際に必要になりますのでメモしておきます。「許可された出荷地」のフォームにURLを入力して 追加 をクリックするとURLが追加されますので、 http://localhost:3000https://localhost:3000 を追加します。また、「許可された返品 URL」の方にも同様の手順で http://localhost:3000/authresponsehttps://localhost:3000/authresponse を追加し、規約への同意のチェックボックスにチェックを入れて 完了する をクリックすると製品が追加されます。

f:id:akanuma-hiroaki:20180110224830p:plain

 続いて、作成したセキュリティプロファイルを有効化します。 Developer コンソールの Amazon でログイン のページに移動し、プルダウンから先ほど作成したセキュリティプロファイルを選択して Confirm をクリックします。

f:id:akanuma-hiroaki:20180109083937p:plain

 プライバシーポリシーの入力フォームが表示されますので、ダミーのURLを入力しておき、 Save をクリックします。

f:id:akanuma-hiroaki:20180109084000p:plain

サンプルアプリの clone

 GitHub からサンプルアプリを clone します。

$ cd ~/デスクトップ
$ git clone https://github.com/alexa/alexa-avs-sample-app.git
Cloning into 'alexa-avs-sample-app'...
remote: Counting objects: 1488, done.
remote: Total 1488 (delta 0), reused 0 (delta 0), pack-reused 1488
Receiving objects: 100% (1488/1488), 19.94 MiB | 1.57 MiB/s, done.
Resolving deltas: 100% (638/638), done.
Checking connectivity... done.

インストールスクリプトの編集

 インストールスクリプトを編集し、先ほど作成した製品とセキュリティプロファイルの製品ID(ProductID)、クライアントID(ClientID)、クライアントシークレット(ClientSecret)を記載します。

$ cd ~/デスクトップ/alexa-avs-sample-app/
$ vi automated_install.sh
$ head -20 automated_install.sh 
#!/bin/bash

#-------------------------------------------------------
# Paste from developer.amazon.com below
#-------------------------------------------------------

# This is the name given to your device or mobile app in the Amazon developer portal. To look this up, navigate to https://developer.amazon.com/edw/home.html. It may be labeled Device Type ID.
ProductID=my_device

# Retrieve your client ID from the web settings tab within the developer console: https://developer.amazon.com/edw/home.html
ClientID=amzn1.application-oa2-client.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# Retrieve your client secret from the web settings tab within the developer console: https://developer.amazon.com/edw/home.html
ClientSecret=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

#-------------------------------------------------------
# No need to change anything below this...
#-------------------------------------------------------

#-------------------------------------------------------

インストールスクリプトの実行

 それでは下記のようにインストールスクリプトを実行します。

$ . automated_install.sh

 いくつか質問のプロンプトが表示されますが、yes で回答するとインストールが始まります。

サンプルアプリの実行

 ここまでで一通り準備はできたのでサンプルアプリを実行してみます。サンプルアプリは下記3つの要素で構成されています。

  • Companion Service:サンプルアプリの認証を行うためのWebサービス

  • AVS Sample App:AVSサンプルアプリ本体

  • Wake Word Engine:"Alexa" というフレーズでサンプルアプリとのインタラクションを開始するためのエンジン

 上記をそれぞれ別ターミナルで動作させます。インストールスクリプトの実行までは ssh 等での CLI からの実行でも問題ないかと思いますが、サンプルアプリでは GUI やブラウザを使用しますので、直接 Raspberry Pi にログインするか、 VNC Viewer などで Raspberry Pi の GUI デスクトップにアクセスして実行します。まずは Companion Service を起動します。 Companion Service は node.js のサーバアプリになっています。

$ cd ~/デスクトップ/alexa-avs-sample-app/samples
$ cd companionService && npm start

> alexa-voice-service-sample-companion-service@1.0.0 start /home/pi/デスクトップ/alexa-avs-sample-app/samples/companionService
> node ./bin/www

This node service needs to be running to store token information memory and vend them for the AVS app.

Listening on port 3000

 これでポート 3000 で接続を待ち受けます。続いてサンプルアプリ本体を起動します。Companion Service とは別のターミナルで下記コマンドを実行します。サンプルアプリ本体は Java アプリになっています。

$ cd ~/デスクトップ/alexa-avs-sample-app/samples
$ cd javaclient && mvn exec:exec
[INFO] Scanning for projects...
[INFO]                                                                         
[INFO] ------------------------------------------------------------------------
[INFO] Building Alexa Voice Service Sample Java Client 20160207.7
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] --- exec-maven-plugin:1.2.1:exec (default-cli) @ sample-java-client ---

 サンプルアプリを起動すると下記のような Window が表示されます。

f:id:akanuma-hiroaki:20180110075434p:plain

 これはデバイスを登録するためにデフォルトブラウザを起動して表示されているURLにアクセスするかどうかということなので、 はい をクリックするとブラウザが起動します。起動しない場合は手動でブラウザを起動して表示されているURLにアクセスします。プライバシー保護の警告が表示されますが、問題ないので 詳細設定 をクリックします。

f:id:akanuma-hiroaki:20180110080033p:plain

 localhost にアクセスする をクリックして続行します。

f:id:akanuma-hiroaki:20180110080137p:plain

 Amazon のログインページに遷移しますのでメールアドレスとパスワードでログインします。

f:id:akanuma-hiroaki:20180110080420p:plain

 認証に成功すると https://localhost:3000/authresponse で始まるURLに遷移し、ブラウザには下記のように表示されます。

device tokens ready これで認証は完了です。 Java アプリの方に戻ると下記のような Window が表示されていますので、 OK をクリックして Window を閉じます。

f:id:akanuma-hiroaki:20180110081115p:plain

 するとサンプルアプリのGUIが表示されます。

f:id:akanuma-hiroaki:20180110081443p:plain

 ここまでで GUI のマイクマークをクリックすることで Alexa とのインタラクションをスタートすることはできるようになっていますが、 Wake Word を使用するために Wake Word Engine を起動します。このサンプルアプリのプロジェクトではサードパーティの Wake Word Engine として Sensory の TrulyHandsFree と KITT.AI の Snowboy がサポートされています。今回は下記のように Sensory の TrulyHandsFree を使用してみます。

$ cd ~/デスクトップ/alexa-avs-sample-app/samples
$ cd wakeWordAgent/src && ./wakeWordAgent -e sensory
INFO:main: Starting Wake Word Agent
INFO:WakeWordAgent: State set to IDLE(2)
INFO:Initializing Sensory library | library name: TrulyHandsfree | library version: 5.0.0-beta.10.2 | model file: ../ext/resources/spot-alexa-rpi.snsr
WARNING:Library expires on: License expires on 28 Mar 2018 00:00:00 GMT
INFO:SensoryWakeWordEngine: mainLoop thread started
INFO:WakeWordIPCSocket::mainLoop thread started
INFO:WakeWordIPCSocket: init socket on port:5123
INFO:WakeWordAgent: thread started
INFO:===> Connected to AVS client <===

動作確認

 それではサンプルアプリの動作を確認してみます。 GUI のマイクマークをクリックするか、 "Alexa" と話しかけるとインタラクションが開始します。「今何時?』「今日の天気は?」などと質問すると Amazon Echo と同じように Alexa が応答してくれます。 Wake Word Engine を動かしているターミナルには下記のような出力があります。

INFO:===> WakeWordAgent: wake word detected <===
INFO:WakeWordAgent: State set to WAKE_WORD_DETECTED(3)
INFO:WakeWordAgent: State set to SENT_WAKE_WORD_DETECTED(4)
INFO:WakeWordAgent: IPC Command received:3
INFO:WakeWordAgent: State set to WAKE_WORD_PAUSE_REQUESTED(5)
INFO:SensoryWakeWordEngine: handling pause
INFO: *** THREAD JOINING: Sensory ***
INFO:SensoryWakeWordEngine: mainLoop thread ended
INFO:WakeWordAgent: State set to WAKE_WORD_PAUSED(6)
INFO:WakeWordAgent: IPC Command received:4
INFO:WakeWordAgent: State set to WAKE_WORD_RESUME_REQUESTED(7)
INFO:SensoryWakeWordEngine: handling resume
INFO:SensoryWakeWordEngine: mainLoop thread started
INFO:WakeWordAgent: State set to IDLE(2)

 ちなみに Wake Word Engine については、 Amazon Echo Dot ではカタカナ発音の「アレクサ」でも十分検知してくれましたが、今回のケースでは英語っぽく "Alexa" と発音しないと検知してくれませんでした。

まとめ

 今回はチュートリアルの内容をトレースすることで Raspberry Pi と Alexa を統合することができましたが、ここまでの内容だけでは Amazon Echo をそのまま使うのと変わらないので、 Raspberry Pi の自由度を活かして色々なデバイスと連携させたりしてみたいと思っています。ただ、サンプルアプリで Java アプリが公開されているものの、実装方法の詳細についての説明は見つからず、 C++ の SDK である AVS Device SDK も見てみましたが詳細な部分はよくわからなかったので、今後わかりそうであれば Raspberry Pi 上で動かす独自アプリを実装してみたいと思います。

github.com